Files
pages/internal/config/load.go
T
2026-08-15 07:13:00 +00:00

201 lines
6.2 KiB
Go

package config
import (
"flag"
"fmt"
"io"
"log/slog"
"os"
"strconv"
"strings"
"github.com/BurntSushi/toml"
)
// Options is the result of parsing the server command line.
type Options struct {
Config Config
ConfigPath string
CheckOnly bool
ShowVersion bool
}
// Load resolves the server configuration with precedence
//
// flag > PAGES_* env > config file > built-in default
//
// args excludes the program name. It returns flag.ErrHelp when -h was passed.
func Load(args []string, out io.Writer) (*Options, error) {
fs := flag.NewFlagSet("pages-server", flag.ContinueOnError)
fs.SetOutput(out)
// flagCfg receives whatever the user typed; we later copy across only the
// fields whose flags were actually visited, so unset flags never shadow the
// file or the environment.
flagCfg := Default()
opts := &Options{}
fs.StringVar(&opts.ConfigPath, "config", os.Getenv("PAGES_CONFIG"), "path to the TOML config file")
fs.BoolVar(&opts.CheckOnly, "check-config", false, "validate configuration and exit without binding ports")
fs.BoolVar(&opts.ShowVersion, "version", false, "print version and exit")
fs.StringVar(&flagCfg.DataDir, "data-dir", flagCfg.DataDir, "directory for the database, CAS blobs and deployment trees")
fs.StringVar(&flagCfg.Webroot, "webroot", flagCfg.Webroot, "directory in which ~PROJECT symlinks are maintained")
fs.StringVar(&flagCfg.Listen, "listen", flagCfg.Listen, "address for the public static-content listener")
fs.StringVar(&flagCfg.APIListen, "api-listen", flagCfg.APIListen, "address for the management API listener")
fs.StringVar(&flagCfg.SiteURL, "site-url", flagCfg.SiteURL, "public base URL of the static listener, e.g. https://pages.example.com (reported in API responses)")
fs.StringVar((*string)(&flagCfg.AssembleMode), "assemble-mode", string(flagCfg.AssembleMode), "how deployment trees are built: auto|hardlink|copy|none")
fs.StringVar(&flagCfg.LogLevel, "log-level", flagCfg.LogLevel, "debug|info|warn|error")
fs.StringVar(&flagCfg.LogFormat, "log-format", flagCfg.LogFormat, "json|text")
fs.Var(&flagCfg.GCInterval, "gc-interval", "how often the retention/blob sweep runs")
fs.Var(&flagCfg.ShutdownGrace, "shutdown-grace", "how long in-flight requests may finish during shutdown")
fs.Usage = func() {
fmt.Fprintf(out, "Usage: pages-server [flags]\n\n"+
"Serves static sites deployed through the pages CLI, switching each\n"+
"project's content atomically.\n\nFlags:\n")
fs.PrintDefaults()
}
if err := fs.Parse(args); err != nil {
return nil, err
}
if opts.ShowVersion {
return opts, nil
}
if fs.NArg() > 0 {
return nil, fmt.Errorf("unexpected argument %q", fs.Arg(0))
}
set := make(map[string]bool, 16)
fs.Visit(func(f *flag.Flag) { set[f.Name] = true })
cfg := Default()
if opts.ConfigPath != "" {
if err := applyFile(&cfg, opts.ConfigPath); err != nil {
return nil, err
}
}
if err := applyEnv(&cfg); err != nil {
return nil, err
}
// Highest precedence: flags the user actually typed.
overrides := map[string]func(){
"data-dir": func() { cfg.DataDir = flagCfg.DataDir },
"webroot": func() { cfg.Webroot = flagCfg.Webroot },
"listen": func() { cfg.Listen = flagCfg.Listen },
"api-listen": func() { cfg.APIListen = flagCfg.APIListen },
"site-url": func() { cfg.SiteURL = flagCfg.SiteURL },
"assemble-mode": func() { cfg.AssembleMode = flagCfg.AssembleMode },
"log-level": func() { cfg.LogLevel = flagCfg.LogLevel },
"log-format": func() { cfg.LogFormat = flagCfg.LogFormat },
"gc-interval": func() { cfg.GCInterval = flagCfg.GCInterval },
"shutdown-grace": func() { cfg.ShutdownGrace = flagCfg.ShutdownGrace },
}
for name, apply := range overrides {
if set[name] {
apply()
}
}
if err := cfg.Validate(); err != nil {
return nil, err
}
opts.Config = cfg
return opts, nil
}
// applyFile decodes the TOML file over cfg. Unknown keys are an error: a typo in
// a config file should fail loudly rather than silently leave a default in place.
func applyFile(cfg *Config, path string) error {
md, err := toml.DecodeFile(path, cfg)
if err != nil {
return fmt.Errorf("config %s: %w", path, err)
}
if undec := md.Undecoded(); len(undec) > 0 {
keys := make([]string, len(undec))
for i, k := range undec {
keys[i] = k.String()
}
return fmt.Errorf("config %s: unknown key(s): %s", path, strings.Join(keys, ", "))
}
return nil
}
func applyEnv(cfg *Config) error {
str := func(key string, dst *string) {
if v, ok := os.LookupEnv(key); ok {
*dst = v
}
}
str("PAGES_DATA_DIR", &cfg.DataDir)
str("PAGES_WEBROOT", &cfg.Webroot)
str("PAGES_LISTEN", &cfg.Listen)
str("PAGES_API_LISTEN", &cfg.APIListen)
str("PAGES_SITE_URL", &cfg.SiteURL)
str("PAGES_LOG_LEVEL", &cfg.LogLevel)
str("PAGES_LOG_FORMAT", &cfg.LogFormat)
str("PAGES_ASSEMBLE_MODE", (*string)(&cfg.AssembleMode))
dur := func(key string, dst *Duration) error {
v, ok := os.LookupEnv(key)
if !ok {
return nil
}
if err := dst.UnmarshalText([]byte(v)); err != nil {
return fmt.Errorf("%s=%q: %w", key, v, err)
}
return nil
}
if err := dur("PAGES_GC_INTERVAL", &cfg.GCInterval); err != nil {
return err
}
if err := dur("PAGES_RECONCILE_INTERVAL", &cfg.ReconcileInterval); err != nil {
return err
}
if err := dur("PAGES_SHUTDOWN_GRACE", &cfg.ShutdownGrace); err != nil {
return err
}
if v, ok := os.LookupEnv("PAGES_TRUSTED_PROXY_CIDRS"); ok {
parts := strings.Split(v, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
cfg.TrustedProxyCIDRs = out
}
if v, ok := os.LookupEnv("PAGES_MAX_FILE_BYTES"); ok {
n, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return fmt.Errorf("PAGES_MAX_FILE_BYTES=%q: %w", v, err)
}
cfg.Limits.MaxFileBytes = n
}
return nil
}
// Logger builds the structured logger described by the configuration.
func (c *Config) Logger(w io.Writer) *slog.Logger {
var level slog.Level
switch c.LogLevel {
case "debug":
level = slog.LevelDebug
case "warn":
level = slog.LevelWarn
case "error":
level = slog.LevelError
default:
level = slog.LevelInfo
}
opts := &slog.HandlerOptions{Level: level}
if c.LogFormat == "text" {
return slog.New(slog.NewTextHandler(w, opts))
}
return slog.New(slog.NewJSONHandler(w, opts))
}