init
This commit is contained in:
@@ -0,0 +1,311 @@
|
||||
// Package config defines the pages-server configuration: its shape, defaults,
|
||||
// and validation. Loading (and the flag > env > file > default precedence) lives
|
||||
// in load.go.
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ProjectNamePattern constrains project names. It is deliberately strict: the
|
||||
// name becomes a "~name" entry inside $WEBROOT, so anything that could contain
|
||||
// a path separator, a "..", or a leading dot must be impossible by construction.
|
||||
var ProjectNamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,62}$`)
|
||||
|
||||
// AssembleMode selects how deployment directories are built from the CAS.
|
||||
type AssembleMode string
|
||||
|
||||
const (
|
||||
// AssembleAuto probes for hardlink support at startup and falls back to copy.
|
||||
AssembleAuto AssembleMode = "auto"
|
||||
// AssembleHardlink requires hardlinks and fails loudly if unavailable.
|
||||
AssembleHardlink AssembleMode = "hardlink"
|
||||
// AssembleCopy always copies. Correct but uses disk proportional to content.
|
||||
AssembleCopy AssembleMode = "copy"
|
||||
// AssembleNone skips on-disk assembly entirely; content is served straight
|
||||
// from the CAS and $WEBROOT symlinks are not maintained.
|
||||
AssembleNone AssembleMode = "none"
|
||||
)
|
||||
|
||||
func (m AssembleMode) valid() bool {
|
||||
switch m {
|
||||
case AssembleAuto, AssembleHardlink, AssembleCopy, AssembleNone:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Duration wraps time.Duration so it can be written as "15m" in TOML.
|
||||
type Duration time.Duration
|
||||
|
||||
var _ encoding.TextUnmarshaler = (*Duration)(nil)
|
||||
|
||||
func (d *Duration) UnmarshalText(text []byte) error {
|
||||
v, err := time.ParseDuration(string(text))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*d = Duration(v)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Set implements flag.Value, so the same type backs both the TOML field and the
|
||||
// command-line flag.
|
||||
func (d *Duration) Set(s string) error { return d.UnmarshalText([]byte(s)) }
|
||||
|
||||
func (d Duration) MarshalText() ([]byte, error) { return []byte(d.String()), nil }
|
||||
func (d Duration) String() string { return time.Duration(d).String() }
|
||||
func (d Duration) D() time.Duration { return time.Duration(d) }
|
||||
|
||||
// Limits bounds what a single deployment may push. Per-project overrides live
|
||||
// in the projects table; these are the server-wide ceilings.
|
||||
type Limits struct {
|
||||
MaxFileBytes int64 `toml:"max_file_bytes"`
|
||||
MaxManifestFiles int `toml:"max_manifest_files"`
|
||||
MaxConcurrentUploads int `toml:"max_concurrent_uploads"`
|
||||
MaxManifestBytes int64 `toml:"max_manifest_bytes"`
|
||||
MaxJSONBytes int64 `toml:"max_json_bytes"`
|
||||
}
|
||||
|
||||
// Config is the fully resolved server configuration.
|
||||
type Config struct {
|
||||
DataDir string `toml:"data_dir"`
|
||||
Webroot string `toml:"webroot"`
|
||||
|
||||
Listen string `toml:"listen"` // public static-content listener
|
||||
APIListen string `toml:"api_listen"` // management API listener
|
||||
|
||||
// SiteURL is the public origin the static listener is reachable at, as seen
|
||||
// from outside — behind a reverse proxy that is a name the server itself
|
||||
// never learns. It exists only so API responses can tell a CI job where its
|
||||
// deployment landed; nothing about serving depends on it, so leaving it
|
||||
// empty simply omits the url field.
|
||||
SiteURL string `toml:"site_url"`
|
||||
|
||||
TrustedProxyCIDRs []string `toml:"trusted_proxy_cidrs"`
|
||||
AssembleMode AssembleMode `toml:"assemble_mode"`
|
||||
|
||||
LogLevel string `toml:"log_level"`
|
||||
LogFormat string `toml:"log_format"`
|
||||
|
||||
GCInterval Duration `toml:"gc_interval"`
|
||||
ReconcileInterval Duration `toml:"reconcile_interval"`
|
||||
ShutdownGrace Duration `toml:"shutdown_grace"`
|
||||
|
||||
// ReadHeaderTimeout and ReadTimeout guard both listeners. There is
|
||||
// deliberately no WriteTimeout: a global write deadline on the static
|
||||
// listener would kill legitimate large downloads over slow links.
|
||||
ReadHeaderTimeout Duration `toml:"read_header_timeout"`
|
||||
ReadTimeout Duration `toml:"read_timeout"`
|
||||
IdleTimeout Duration `toml:"idle_timeout"`
|
||||
|
||||
Limits Limits `toml:"limits"`
|
||||
|
||||
// trustedNets is the parsed form of TrustedProxyCIDRs, filled by Validate.
|
||||
trustedNets []netip.Prefix
|
||||
}
|
||||
|
||||
// Default returns the baseline configuration. Every other layer (file, env,
|
||||
// flags) is applied on top of this.
|
||||
func Default() Config {
|
||||
return Config{
|
||||
DataDir: "/var/lib/pages-server",
|
||||
Webroot: "/srv/www",
|
||||
Listen: ":8080",
|
||||
APIListen: "127.0.0.1:8081",
|
||||
TrustedProxyCIDRs: []string{"127.0.0.1/32", "::1/128"},
|
||||
AssembleMode: AssembleAuto,
|
||||
LogLevel: "info",
|
||||
LogFormat: "json",
|
||||
GCInterval: Duration(15 * time.Minute),
|
||||
ReconcileInterval: Duration(5 * time.Minute),
|
||||
ShutdownGrace: Duration(30 * time.Second),
|
||||
ReadHeaderTimeout: Duration(10 * time.Second),
|
||||
ReadTimeout: Duration(5 * time.Minute),
|
||||
IdleTimeout: Duration(120 * time.Second),
|
||||
Limits: Limits{
|
||||
MaxFileBytes: 256 << 20, // 256 MiB
|
||||
MaxManifestFiles: 50000,
|
||||
MaxConcurrentUploads: 32,
|
||||
MaxManifestBytes: 64 << 20, // 64 MiB of manifest JSON
|
||||
MaxJSONBytes: 1 << 20, // 1 MiB for ordinary API bodies
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// DBPath is the SQLite database file.
|
||||
func (c *Config) DBPath() string { return filepath.Join(c.DataDir, "pages.db") }
|
||||
|
||||
// CASDir holds content-addressed blobs.
|
||||
func (c *Config) CASDir() string { return filepath.Join(c.DataDir, "cas") }
|
||||
|
||||
// DeploymentsDir holds assembled deployment trees.
|
||||
func (c *Config) DeploymentsDir() string { return filepath.Join(c.DataDir, "deployments") }
|
||||
|
||||
// BootstrapTokenPath is where the first-run admin token is written.
|
||||
func (c *Config) BootstrapTokenPath() string { return filepath.Join(c.DataDir, "bootstrap-token") }
|
||||
|
||||
// TrustedProxies returns the parsed trusted-proxy prefixes. Only requests whose
|
||||
// direct peer falls inside one of these may have their X-Forwarded-For honoured.
|
||||
func (c *Config) TrustedProxies() []netip.Prefix { return c.trustedNets }
|
||||
|
||||
// Validate checks the configuration and normalises it in place. It performs no
|
||||
// I/O beyond stat-ing the configured directories' parents, so it is safe to run
|
||||
// from --check-config without binding ports.
|
||||
func (c *Config) Validate() error {
|
||||
if c.DataDir == "" {
|
||||
return fmt.Errorf("data_dir is required")
|
||||
}
|
||||
abs, err := filepath.Abs(c.DataDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("data_dir: %w", err)
|
||||
}
|
||||
c.DataDir = filepath.Clean(abs)
|
||||
|
||||
if c.AssembleMode != AssembleNone {
|
||||
if c.Webroot == "" {
|
||||
return fmt.Errorf("webroot is required unless assemble_mode is %q", AssembleNone)
|
||||
}
|
||||
abs, err = filepath.Abs(c.Webroot)
|
||||
if err != nil {
|
||||
return fmt.Errorf("webroot: %w", err)
|
||||
}
|
||||
c.Webroot = filepath.Clean(abs)
|
||||
|
||||
// $WEBROOT must not sit inside $DATA_DIR (or vice versa): the reconciler
|
||||
// removes stale "~name" symlinks from the webroot, and the GC removes
|
||||
// trees from the data dir. Overlapping them makes each capable of
|
||||
// deleting the other's state.
|
||||
if c.Webroot == c.DataDir {
|
||||
return fmt.Errorf("webroot and data_dir must differ (both %q)", c.DataDir)
|
||||
}
|
||||
if isUnder(c.Webroot, c.DataDir) || isUnder(c.DataDir, c.Webroot) {
|
||||
return fmt.Errorf("webroot %q and data_dir %q must not be nested", c.Webroot, c.DataDir)
|
||||
}
|
||||
}
|
||||
|
||||
if !c.AssembleMode.valid() {
|
||||
return fmt.Errorf("assemble_mode: want auto|hardlink|copy|none, got %q", c.AssembleMode)
|
||||
}
|
||||
|
||||
for _, spec := range []struct{ name, addr string }{
|
||||
{"listen", c.Listen},
|
||||
{"api_listen", c.APIListen},
|
||||
} {
|
||||
if spec.addr == "" {
|
||||
return fmt.Errorf("%s is required", spec.name)
|
||||
}
|
||||
if _, _, err := net.SplitHostPort(spec.addr); err != nil {
|
||||
return fmt.Errorf("%s %q: %w", spec.name, spec.addr, err)
|
||||
}
|
||||
}
|
||||
if c.Listen == c.APIListen {
|
||||
return fmt.Errorf("listen and api_listen must differ (both %q); "+
|
||||
"the management API must not share an origin with served content", c.Listen)
|
||||
}
|
||||
|
||||
if c.SiteURL != "" {
|
||||
// Stored without the trailing slash so api.SiteURL can append one and
|
||||
// get exactly one back.
|
||||
c.SiteURL = strings.TrimRight(c.SiteURL, "/")
|
||||
u, err := url.Parse(c.SiteURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("site_url %q: %w", c.SiteURL, err)
|
||||
}
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return fmt.Errorf("site_url %q must be an absolute http:// or https:// URL", c.SiteURL)
|
||||
}
|
||||
if u.Host == "" {
|
||||
return fmt.Errorf("site_url %q is missing a host", c.SiteURL)
|
||||
}
|
||||
if u.RawQuery != "" || u.Fragment != "" {
|
||||
return fmt.Errorf("site_url %q must not carry a query or fragment", c.SiteURL)
|
||||
}
|
||||
}
|
||||
|
||||
c.trustedNets = c.trustedNets[:0]
|
||||
for _, s := range c.TrustedProxyCIDRs {
|
||||
p, err := netip.ParsePrefix(strings.TrimSpace(s))
|
||||
if err != nil {
|
||||
return fmt.Errorf("trusted_proxy_cidrs %q: %w", s, err)
|
||||
}
|
||||
c.trustedNets = append(c.trustedNets, p)
|
||||
}
|
||||
|
||||
switch c.LogLevel {
|
||||
case "debug", "info", "warn", "error":
|
||||
default:
|
||||
return fmt.Errorf("log_level: want debug|info|warn|error, got %q", c.LogLevel)
|
||||
}
|
||||
switch c.LogFormat {
|
||||
case "json", "text":
|
||||
default:
|
||||
return fmt.Errorf("log_format: want json|text, got %q", c.LogFormat)
|
||||
}
|
||||
|
||||
for _, spec := range []struct {
|
||||
name string
|
||||
d Duration
|
||||
}{
|
||||
{"gc_interval", c.GCInterval},
|
||||
{"reconcile_interval", c.ReconcileInterval},
|
||||
{"shutdown_grace", c.ShutdownGrace},
|
||||
{"read_header_timeout", c.ReadHeaderTimeout},
|
||||
{"read_timeout", c.ReadTimeout},
|
||||
{"idle_timeout", c.IdleTimeout},
|
||||
} {
|
||||
if spec.d <= 0 {
|
||||
return fmt.Errorf("%s must be positive, got %s", spec.name, spec.d)
|
||||
}
|
||||
}
|
||||
|
||||
if c.Limits.MaxFileBytes <= 0 {
|
||||
return fmt.Errorf("limits.max_file_bytes must be positive")
|
||||
}
|
||||
if c.Limits.MaxManifestFiles <= 0 {
|
||||
return fmt.Errorf("limits.max_manifest_files must be positive")
|
||||
}
|
||||
if c.Limits.MaxConcurrentUploads <= 0 {
|
||||
return fmt.Errorf("limits.max_concurrent_uploads must be positive")
|
||||
}
|
||||
if c.Limits.MaxManifestBytes <= 0 {
|
||||
return fmt.Errorf("limits.max_manifest_bytes must be positive")
|
||||
}
|
||||
if c.Limits.MaxJSONBytes <= 0 {
|
||||
return fmt.Errorf("limits.max_json_bytes must be positive")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureDirs creates the data directory layout. Separated from Validate so
|
||||
// --check-config stays read-only.
|
||||
func (c *Config) EnsureDirs() error {
|
||||
dirs := []string{c.DataDir, c.CASDir(), filepath.Join(c.CASDir(), "tmp"), c.DeploymentsDir()}
|
||||
if c.AssembleMode != AssembleNone {
|
||||
dirs = append(dirs, c.Webroot)
|
||||
}
|
||||
for _, d := range dirs {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
return fmt.Errorf("create %s: %w", d, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isUnder reports whether path is lexically inside base.
|
||||
func isUnder(path, base string) bool {
|
||||
rel, err := filepath.Rel(base, path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDefaultIsValid(t *testing.T) {
|
||||
cfg := Default()
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("the built-in default must validate: %v", err)
|
||||
}
|
||||
if len(cfg.TrustedProxies()) != 2 {
|
||||
t.Errorf("trusted proxies = %v, want loopback v4 + v6", cfg.TrustedProxies())
|
||||
}
|
||||
}
|
||||
|
||||
func writeConfig(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "config.toml")
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// Precedence is flag > env > file > default, and — the part that is easy to get
|
||||
// wrong — a flag left unset must not shadow the file or the environment with its
|
||||
// default value.
|
||||
func TestLoadPrecedence(t *testing.T) {
|
||||
path := writeConfig(t, `
|
||||
data_dir = "/srv/from-file"
|
||||
webroot = "/var/www/from-file"
|
||||
listen = ":9001"
|
||||
log_level = "warn"
|
||||
gc_interval = "9m"
|
||||
`)
|
||||
|
||||
t.Setenv("PAGES_WEBROOT", "/var/www/from-env")
|
||||
t.Setenv("PAGES_LOG_LEVEL", "debug")
|
||||
|
||||
opts, err := Load([]string{
|
||||
"-config", path,
|
||||
"-log-level", "error",
|
||||
"-gc-interval", "1m",
|
||||
}, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
cfg := opts.Config
|
||||
|
||||
if cfg.LogLevel != "error" {
|
||||
t.Errorf("log_level = %q, want the flag to win", cfg.LogLevel)
|
||||
}
|
||||
if cfg.Webroot != "/var/www/from-env" {
|
||||
t.Errorf("webroot = %q, want the env to beat the file", cfg.Webroot)
|
||||
}
|
||||
if cfg.DataDir != "/srv/from-file" {
|
||||
t.Errorf("data_dir = %q, want the file to beat the default", cfg.DataDir)
|
||||
}
|
||||
if cfg.Listen != ":9001" {
|
||||
t.Errorf("listen = %q, want the file value (no flag, no env)", cfg.Listen)
|
||||
}
|
||||
if cfg.GCInterval.D() != time.Minute {
|
||||
t.Errorf("gc_interval = %s, want the flag to win", cfg.GCInterval)
|
||||
}
|
||||
if cfg.APIListen != Default().APIListen {
|
||||
t.Errorf("api_listen = %q, want the untouched default", cfg.APIListen)
|
||||
}
|
||||
}
|
||||
|
||||
// An unset flag defaults to the same value as Default(), so a naive
|
||||
// implementation silently overwrites whatever the file said with that default.
|
||||
func TestUnsetFlagDoesNotShadowFile(t *testing.T) {
|
||||
path := writeConfig(t, "log_format = \"text\"\napi_listen = \"127.0.0.1:9999\"\n")
|
||||
opts, err := Load([]string{"-config", path}, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if opts.Config.LogFormat != "text" {
|
||||
t.Errorf("log_format = %q, want text", opts.Config.LogFormat)
|
||||
}
|
||||
if opts.Config.APIListen != "127.0.0.1:9999" {
|
||||
t.Errorf("api_listen = %q, want the file value", opts.Config.APIListen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsUnknownKey(t *testing.T) {
|
||||
path := writeConfig(t, "data_dir = \"/srv/x\"\nlog_levle = \"debug\"\n")
|
||||
_, err := Load([]string{"-config", path}, io.Discard)
|
||||
if err == nil {
|
||||
t.Fatal("a typo in the config file must fail loudly")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "log_levle") {
|
||||
t.Errorf("error should name the offending key, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsUnknownArgs(t *testing.T) {
|
||||
if _, err := Load([]string{"serve"}, io.Discard); err == nil {
|
||||
t.Fatal("positional arguments must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadEnvDurationError(t *testing.T) {
|
||||
t.Setenv("PAGES_GC_INTERVAL", "fifteen minutes")
|
||||
_, err := Load(nil, io.Discard)
|
||||
if err == nil || !strings.Contains(err.Error(), "PAGES_GC_INTERVAL") {
|
||||
t.Fatalf("want an error naming the variable, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTrustedProxiesFromEnv(t *testing.T) {
|
||||
t.Setenv("PAGES_TRUSTED_PROXY_CIDRS", "10.0.0.0/8, 192.168.0.0/16 ,")
|
||||
opts, err := Load(nil, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
got := opts.Config.TrustedProxies()
|
||||
if len(got) != 2 || got[0].String() != "10.0.0.0/8" || got[1].String() != "192.168.0.0/16" {
|
||||
t.Errorf("trusted proxies = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
mutate func(*Config)
|
||||
wantErr string
|
||||
}{
|
||||
{"webroot inside data_dir", func(c *Config) {
|
||||
c.DataDir = "/var/lib/pages"
|
||||
c.Webroot = "/var/lib/pages/www"
|
||||
}, "nested"},
|
||||
{"data_dir inside webroot", func(c *Config) {
|
||||
c.DataDir = "/srv/www/state"
|
||||
c.Webroot = "/srv/www"
|
||||
}, "nested"},
|
||||
{"identical dirs", func(c *Config) {
|
||||
c.DataDir = "/srv/www"
|
||||
c.Webroot = "/srv/www"
|
||||
}, "must differ"},
|
||||
{"listeners collide", func(c *Config) {
|
||||
c.Listen = "127.0.0.1:8080"
|
||||
c.APIListen = "127.0.0.1:8080"
|
||||
}, "must differ"},
|
||||
{"listen without port", func(c *Config) { c.Listen = "8080" }, "listen"},
|
||||
{"bad cidr", func(c *Config) { c.TrustedProxyCIDRs = []string{"10.0.0.1"} }, "trusted_proxy_cidrs"},
|
||||
{"bad assemble mode", func(c *Config) { c.AssembleMode = "symlink" }, "assemble_mode"},
|
||||
{"bad log level", func(c *Config) { c.LogLevel = "verbose" }, "log_level"},
|
||||
{"zero interval", func(c *Config) { c.GCInterval = 0 }, "gc_interval"},
|
||||
{"negative limit", func(c *Config) { c.Limits.MaxFileBytes = -1 }, "max_file_bytes"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cfg := Default()
|
||||
tc.mutate(&cfg)
|
||||
err := cfg.Validate()
|
||||
if err == nil {
|
||||
t.Fatalf("want an error mentioning %q", tc.wantErr)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Errorf("error = %v, want it to mention %q", err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// assemble_mode = "none" means no webroot is maintained, so the webroot checks
|
||||
// must not fire.
|
||||
func TestValidateAllowsEmptyWebrootWhenAssemblyDisabled(t *testing.T) {
|
||||
cfg := Default()
|
||||
cfg.AssembleMode = AssembleNone
|
||||
cfg.Webroot = ""
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("Validate: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMakesPathsAbsolute(t *testing.T) {
|
||||
cfg := Default()
|
||||
cfg.DataDir = "state/./"
|
||||
cfg.Webroot = "www"
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("Validate: %v", err)
|
||||
}
|
||||
if !filepath.IsAbs(cfg.DataDir) || strings.HasSuffix(cfg.DataDir, "/") {
|
||||
t.Errorf("data_dir = %q, want a cleaned absolute path", cfg.DataDir)
|
||||
}
|
||||
if !filepath.IsAbs(cfg.Webroot) {
|
||||
t.Errorf("webroot = %q, want an absolute path", cfg.Webroot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureDirs(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
cfg := Default()
|
||||
cfg.DataDir = filepath.Join(base, "state")
|
||||
cfg.Webroot = filepath.Join(base, "www")
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cfg.EnsureDirs(); err != nil {
|
||||
t.Fatalf("EnsureDirs: %v", err)
|
||||
}
|
||||
for _, d := range []string{cfg.DataDir, cfg.CASDir(), filepath.Join(cfg.CASDir(), "tmp"), cfg.DeploymentsDir(), cfg.Webroot} {
|
||||
fi, err := os.Stat(d)
|
||||
if err != nil {
|
||||
t.Errorf("missing %s: %v", d, err)
|
||||
continue
|
||||
}
|
||||
if !fi.IsDir() {
|
||||
t.Errorf("%s is not a directory", d)
|
||||
}
|
||||
}
|
||||
// Idempotent: a restart must not fail on directories that already exist.
|
||||
if err := cfg.EnsureDirs(); err != nil {
|
||||
t.Fatalf("second EnsureDirs: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectNamePattern(t *testing.T) {
|
||||
valid := []string{"a", "demo", "my-site", "my.site", "my_site", "a1", strings.Repeat("x", 63)}
|
||||
invalid := []string{
|
||||
"", "-lead", ".lead", "_lead", "UPPER", "has space", "has/slash", "..",
|
||||
"a/../b", "a\\b", "tilde~", strings.Repeat("x", 64), "naïve", "a\x00b",
|
||||
}
|
||||
for _, s := range valid {
|
||||
if !ProjectNamePattern.MatchString(s) {
|
||||
t.Errorf("%q should be a valid project name", s)
|
||||
}
|
||||
}
|
||||
for _, s := range invalid {
|
||||
if ProjectNamePattern.MatchString(s) {
|
||||
t.Errorf("%q must be rejected as a project name", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurationRoundTrip(t *testing.T) {
|
||||
var d Duration
|
||||
if err := d.Set("15m30s"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d.D() != 15*time.Minute+30*time.Second {
|
||||
t.Errorf("d = %s", d)
|
||||
}
|
||||
b, err := d.MarshalText()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var back Duration
|
||||
if err := back.UnmarshalText(b); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if back != d {
|
||||
t.Errorf("round trip: %s != %s", back, d)
|
||||
}
|
||||
if err := d.Set("soon"); err == nil {
|
||||
t.Error("Set must reject a non-duration")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
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))
|
||||
}
|
||||
Reference in New Issue
Block a user