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)) }