// Package config loads and validates felis.toml (spec §24). root_domain lives // here and nowhere else in code: every FQDN is composed at runtime as // subdomain + "." + root_domain, so changing the deployment domain is a // one-line config edit and the source tree stays domain-agnostic. package config import ( "fmt" "strings" "github.com/BurntSushi/toml" ) // Config is the parsed felis.toml. type Config struct { Server ServerConfig `toml:"server"` Database DatabaseConfig `toml:"database"` Velocity VelocityConfig `toml:"velocity"` Auth AuthConfig `toml:"auth"` K8s K8sConfig `toml:"k8s"` Registry RegistryConfig `toml:"registry"` Archive ArchiveConfig `toml:"archive"` } // ServerConfig is the [server] table. type ServerConfig struct { Listen string `toml:"listen"` RootDomain string `toml:"root_domain"` } // DatabaseConfig is the [database] table. type DatabaseConfig struct { URL string `toml:"url"` } // VelocityConfig is the [velocity] table. type VelocityConfig struct { PublicIP string `toml:"public_ip"` ServiceTokenRef string `toml:"service_token_ref"` } // AuthConfig is the [auth] table: the two privileged faces and the access-JWT // audience the API enforces. type AuthConfig struct { AdminHostname string `toml:"admin_hostname"` PanelHostname string `toml:"panel_hostname"` AccessJWTAud string `toml:"access_jwt_aud"` } // K8sConfig is the [k8s] table. type K8sConfig struct { Namespace string `toml:"namespace"` EgressMode string `toml:"egress_mode"` MetalLBPool string `toml:"metallb_pool"` } // RegistryConfig is the [registry] table. type RegistryConfig struct { URL string `toml:"url"` BuildNamespace string `toml:"build_namespace"` // UserUploadsContext is the object-store base under which a user-submitted // modpack's Kaniko build context is pinned. It belongs to the §16 build // subsystem's input domain (the build-context store), introduced by the // user-directed modpack approval lane (see internal/submit package doc). The // lane derives {UserUploadsContext}/{submissionID}/context.tar.gz; the upload // transport that places the blob there is a separate, deferred integration // (INTEGRATION-ONLY). It is kept distinct from [archive] on purpose — a world // archive (§19 WorldArchiver) and a build context (§16) are different artifacts // with different lifecycles, so the two must not share a store binding. UserUploadsContext string `toml:"user_uploads_context"` } // ArchiveConfig is the [archive] table plus its [archive.s3] subtable (spec §19). type ArchiveConfig struct { Store string `toml:"store"` LocalPath string `toml:"local_path"` Retention string `toml:"retention"` WarnBefore []string `toml:"warn_before"` MaxLocalBytes string `toml:"max_local_bytes"` S3 ArchiveS3Config `toml:"s3"` } // ArchiveS3Config is the [archive.s3] subtable. type ArchiveS3Config struct { Endpoint string `toml:"endpoint"` Bucket string `toml:"bucket"` AccessKeyRef string `toml:"access_key_ref"` SecretKeyRef string `toml:"secret_key_ref"` } // archive store backends recognized by §19. var archiveStores = map[string]struct{}{ "tarLocal": {}, "tarS3": {}, "volumeSnapshot": {}, "longhorn": {}, } // archive store backends this build can actually honor. §19 names four, but only // tarLocal is implemented: the reaper's buildArchiver, the `felis restore` // command, and the felis-api restore executor all construct tarLocal and nothing // else. A config naming a recognized-but-unimplemented store is a footgun — it // clears the "is this a real store name" check yet silently breaks retention (the // reaper CronJob fails every run) and restore (503), while felis-api otherwise // looks healthy. Validate rejects it so every binary that loads config (migrate, // api, reaper) fails fast at startup with a clear remediation instead. (`felis // restore` enforces the same invariant on its own --store flag: it runs inside // the sandboxed weak-SA restore Job and by design never loads felis.toml or holds // DB credentials, so it cannot lean on this load-time check.) var implementedArchiveStores = map[string]struct{}{ "tarLocal": {}, } // Defaults that callers get when the field is omitted. const ( defaultListen = "0.0.0.0:8080" defaultNamespace = "minecraft" defaultEgressMode = "loadbalancer" defaultStore = "tarLocal" // defaultUserUploadsContext is a non-empty, platform-namespaced placeholder so // the modpack approval lane's derived context ref is well-formed even before a // deployment points it at a real object store. The blob transport is deferred, // so this base only has to be a sensible, parseable prefix (see the §16 build // subsystem and the internal/submit package doc for the lane's provenance). defaultUserUploadsContext = "s3://felis-user-uploads" ) // Load reads and validates a felis.toml from path. func Load(path string) (*Config, error) { var cfg Config md, err := toml.DecodeFile(path, &cfg) if err != nil { return nil, fmt.Errorf("config: decode %s: %w", path, err) } if undecoded := md.Undecoded(); len(undecoded) > 0 { // Surface typos rather than silently ignoring unknown keys. keys := make([]string, len(undecoded)) for i, k := range undecoded { keys[i] = k.String() } return nil, fmt.Errorf("config: unknown keys in %s: %s", path, strings.Join(keys, ", ")) } cfg.applyDefaults() if err := cfg.Validate(); err != nil { return nil, err } return &cfg, nil } func (c *Config) applyDefaults() { if c.Server.Listen == "" { c.Server.Listen = defaultListen } if c.K8s.Namespace == "" { c.K8s.Namespace = defaultNamespace } if c.K8s.EgressMode == "" { c.K8s.EgressMode = defaultEgressMode } if c.Archive.Store == "" { c.Archive.Store = defaultStore } if c.Registry.UserUploadsContext == "" { c.Registry.UserUploadsContext = defaultUserUploadsContext } } // Validate enforces the mandatory fields (spec §24: database.url is 强制) and // the closed value sets. func (c *Config) Validate() error { if c.Database.URL == "" { return fmt.Errorf("config: [database] url is required") } if c.Server.RootDomain == "" { return fmt.Errorf("config: [server] root_domain is required") } if !strings.Contains(c.Server.RootDomain, ".") { return fmt.Errorf("config: [server] root_domain %q is not a domain", c.Server.RootDomain) } if _, ok := archiveStores[c.Archive.Store]; !ok { return fmt.Errorf("config: [archive] store %q is not one of tarLocal|tarS3|volumeSnapshot|longhorn", c.Archive.Store) } if _, ok := implementedArchiveStores[c.Archive.Store]; !ok { return fmt.Errorf("config: [archive] store %q is recognized by §19 but not implemented in this build — only tarLocal is supported; set store = \"tarLocal\"", c.Archive.Store) } switch c.K8s.EgressMode { case "loadbalancer", "nodeport": default: return fmt.Errorf("config: [k8s] egress_mode %q must be loadbalancer or nodeport", c.K8s.EgressMode) } // The registry URL is a bare host[:port] (spec §24: url="registry.felis.svc:5000"), // never a scheme-qualified URL. This is not cosmetic: two consumers read it with // different robustness. The admin build path normalizes via registryHost() (which // strips a scheme), but the user-modpack approval lane derives its push target by // string concatenation (submit.Manager.deriveImageRef → "{url}/user-uploads/{id}:latest") // with no stripping. A "http://" prefix would make the lane's pre-CAS build.Validate // reject every derived ref (imageNameRE forbids the leading "http:/…") and collapse // EVERY approve to 500 while admin builds keep working — a silent split-brain. Fail // fast at load instead, with the contract spelled out. if c.Registry.URL != "" && strings.Contains(c.Registry.URL, "://") { return fmt.Errorf("config: [registry] url %q must be a bare host[:port] with no scheme (e.g. registry.felis.svc:5000); a scheme breaks the user-modpack build lane's derived push target", c.Registry.URL) } return nil }