feat(core): add naming, RCON, store, config, and image-build libraries
Foundational libraries: deterministic resource naming, the RCON client, the Postgres store with embedded SQL migrations, configuration loading, and container image-build helpers.
This commit is contained in:
18 files changed
+3475
No files matched your search
@@ -0,0 +1,204 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"felis.lolicon.best/internal/config"
|
||||
)
|
||||
|
||||
// writeTOML writes content to a temp felis.toml and returns its path.
|
||||
func writeTOML(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "felis.toml")
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("write toml: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// Note: tests use the neutral example domain mc.example.net, never a real
|
||||
// deployment domain, to keep the source tree clean of domain literals.
|
||||
const validTOML = `
|
||||
[server]
|
||||
listen = "0.0.0.0:9090"
|
||||
root_domain = "mc.example.net"
|
||||
|
||||
[database]
|
||||
url = "postgres://felis:secret@db:5432/felis"
|
||||
|
||||
[velocity]
|
||||
public_ip = "203.0.113.4"
|
||||
service_token_ref = "felis-velocity-token"
|
||||
|
||||
[auth]
|
||||
admin_hostname = "admin.example.net"
|
||||
panel_hostname = "panel.example.net"
|
||||
access_jwt_aud = "felis-panel"
|
||||
|
||||
[k8s]
|
||||
namespace = "minecraft"
|
||||
egress_mode = "loadbalancer"
|
||||
metallb_pool = "192.0.2.200-250"
|
||||
|
||||
[registry]
|
||||
url = "registry.felis.svc:5000"
|
||||
build_namespace = "felis-build"
|
||||
|
||||
[archive]
|
||||
store = "tarLocal"
|
||||
local_path = "backup-pvc"
|
||||
retention = "3mo"
|
||||
warn_before = ["3d", "1d"]
|
||||
max_local_bytes = "200Gi"
|
||||
[archive.s3]
|
||||
endpoint = ""
|
||||
bucket = "felis-backups"
|
||||
access_key_ref = ""
|
||||
secret_key_ref = ""
|
||||
`
|
||||
|
||||
func TestLoadValid(t *testing.T) {
|
||||
cfg, err := config.Load(writeTOML(t, validTOML))
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.Server.Listen != "0.0.0.0:9090" {
|
||||
t.Errorf("listen = %q", cfg.Server.Listen)
|
||||
}
|
||||
if cfg.Server.RootDomain != "mc.example.net" {
|
||||
t.Errorf("root_domain = %q", cfg.Server.RootDomain)
|
||||
}
|
||||
if cfg.Database.URL == "" {
|
||||
t.Error("database url empty")
|
||||
}
|
||||
if cfg.Archive.Store != "tarLocal" {
|
||||
t.Errorf("archive store = %q", cfg.Archive.Store)
|
||||
}
|
||||
if len(cfg.Archive.WarnBefore) != 2 || cfg.Archive.WarnBefore[0] != "3d" {
|
||||
t.Errorf("warn_before = %v", cfg.Archive.WarnBefore)
|
||||
}
|
||||
if cfg.Archive.S3.Bucket != "felis-backups" {
|
||||
t.Errorf("s3 bucket = %q", cfg.Archive.S3.Bucket)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAppliesDefaults(t *testing.T) {
|
||||
cfg, err := config.Load(writeTOML(t, `
|
||||
[server]
|
||||
root_domain = "mc.example.net"
|
||||
[database]
|
||||
url = "postgres://felis@db/felis"
|
||||
`))
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.Server.Listen != "0.0.0.0:8080" {
|
||||
t.Errorf("default listen = %q, want 0.0.0.0:8080", cfg.Server.Listen)
|
||||
}
|
||||
if cfg.K8s.Namespace != "minecraft" {
|
||||
t.Errorf("default namespace = %q, want minecraft", cfg.K8s.Namespace)
|
||||
}
|
||||
if cfg.K8s.EgressMode != "loadbalancer" {
|
||||
t.Errorf("default egress_mode = %q", cfg.K8s.EgressMode)
|
||||
}
|
||||
if cfg.Archive.Store != "tarLocal" {
|
||||
t.Errorf("default archive store = %q", cfg.Archive.Store)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsMissingDatabaseURL(t *testing.T) {
|
||||
_, err := config.Load(writeTOML(t, `
|
||||
[server]
|
||||
root_domain = "mc.example.net"
|
||||
`))
|
||||
if err == nil {
|
||||
t.Fatal("expected error when database.url is missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsMissingRootDomain(t *testing.T) {
|
||||
_, err := config.Load(writeTOML(t, `
|
||||
[database]
|
||||
url = "postgres://felis@db/felis"
|
||||
`))
|
||||
if err == nil {
|
||||
t.Fatal("expected error when root_domain is missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsUnknownArchiveStore(t *testing.T) {
|
||||
_, err := config.Load(writeTOML(t, `
|
||||
[server]
|
||||
root_domain = "mc.example.net"
|
||||
[database]
|
||||
url = "postgres://felis@db/felis"
|
||||
[archive]
|
||||
store = "magicbox"
|
||||
`))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown archive store")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadRejectsUnimplementedArchiveStore guards the §19/build-reality gap:
|
||||
// tarS3, volumeSnapshot and longhorn are recognized store names but only
|
||||
// tarLocal is implemented in this build. A config naming one of them must be
|
||||
// rejected at load — otherwise felis-api boots green while the reaper CronJob
|
||||
// fails every run and restore silently 503s. The error must point the operator
|
||||
// at the fix (tarLocal), distinct from the "unknown store" message.
|
||||
func TestLoadRejectsUnimplementedArchiveStore(t *testing.T) {
|
||||
for _, store := range []string{"tarS3", "volumeSnapshot", "longhorn"} {
|
||||
t.Run(store, func(t *testing.T) {
|
||||
_, err := config.Load(writeTOML(t, `
|
||||
[server]
|
||||
root_domain = "mc.example.net"
|
||||
[database]
|
||||
url = "postgres://felis@db/felis"
|
||||
[archive]
|
||||
store = "`+store+`"
|
||||
`))
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for recognized-but-unimplemented store %q", store)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "tarLocal") {
|
||||
t.Errorf("error for %q should point at the tarLocal remediation, got: %v", store, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadRejectsSchemeQualifiedRegistryURL guards the §24 split-brain: the
|
||||
// registry url is a bare host[:port], read scheme-tolerantly by the admin build
|
||||
// path (registryHost strips the scheme) but scheme-INtolerantly by the
|
||||
// user-modpack lane (deriveImageRef concatenates raw). A scheme-qualified url
|
||||
// would boot felis-api green and 500 every approve while admin builds keep
|
||||
// working, so it must be rejected at load with the bare-host contract spelled
|
||||
// out. Both http:// and https:// are caught (the check is on "://").
|
||||
func TestLoadRejectsSchemeQualifiedRegistryURL(t *testing.T) {
|
||||
for _, url := range []string{"http://registry.felis.svc:5000", "https://registry.felis.svc:5000"} {
|
||||
t.Run(url, func(t *testing.T) {
|
||||
_, err := config.Load(writeTOML(t, `
|
||||
[server]
|
||||
root_domain = "mc.example.net"
|
||||
[database]
|
||||
url = "postgres://felis@db/felis"
|
||||
[registry]
|
||||
url = "`+url+`"
|
||||
`))
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for scheme-qualified registry url %q", url)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "scheme") {
|
||||
t.Errorf("error for %q should explain the bare-host contract, got: %v", url, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsUnknownKeys(t *testing.T) {
|
||||
_, err := config.Load(writeTOML(t, `
|
||||
[server]
|
||||
root_domain = "mc.example.net"
|
||||
typo_field = "oops"
|
||||
[database]
|
||||
url = "postgres://felis@db/felis"
|
||||
`))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown key")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user