Files
anchorage/internal/pkg/config/config.go
T
57_Wolve 12bf35caf8 anchorage v1.0 initial tree
Greenfield Go multi-tenant IPFS Pinning Service wire-compatible with the
IPFS Pinning Services API spec. Paired 1:1 with Kubo over localhost RPC,
clustered via embedded NATS JetStream, Postgres source-of-truth with
RLS-enforced tenancy, Fiber + huma v2 for the HTTP surface, Authentik
OIDC for session login with kid-rotated HS256 JWT API tokens.

Feature-complete against the 22-milestone build plan, including the
ship-it v1.0 gap items:

  * admin CLIs: drain/uncordon, maintenance, mint-token, rotate-key,
    prune-denylist, rebalance --dry-run, cache-stats, cluster-presences
  * TTL leader election via NATS KV, fence tokens, JetStream dedup
  * rebalancer (plan/apply split), reconciler, requeue sweeper
  * ristretto caches with NATS-backed cross-node invalidation
    (placements live-nodes + token denylist)
  * maintenance watchdog for stuck cluster-pause flag
  * Prometheus /metrics with CIDR ACL, HTTP/pin/scheduler/cache gauges
  * rate limiting: session (10/min) + anonymous global (120/min)
  * integration tests: rebalance, refcount multi-org, RLS belt
  * goreleaser (tar + deb/rpm/apk + Alpine Docker) targeting Gitea

Stack: Cobra/Viper, Fiber v2 + huma v2, embedded NATS JetStream,
pgx/sqlc/golang-migrate, ristretto, TypeID, prometheus/client_golang,
testcontainers-go.
2026-04-16 18:13:36 -05:00

337 lines
12 KiB
Go

// Package config loads and validates anchorage's runtime configuration.
//
// The Config struct mirrors configs/anchorage.example.yaml. Values flow in
// from three sources, in order of increasing priority:
//
// 1. Baked-in defaults (setDefaults).
// 2. A YAML/TOML file located via --config or the default search path.
// 3. Environment variables prefixed ANCHORAGE_ (e.g., ANCHORAGE_SERVER_PORT).
//
// All exported Config types use mapstructure tags matching camelCase YAML
// keys. time.Duration fields are parsed from strings like "30s" or "1h".
package config
import (
"errors"
"fmt"
"net/url"
"os"
"strings"
"time"
"github.com/spf13/viper"
)
// Config is the fully-resolved anchorage configuration.
type Config struct {
Server ServerConfig `mapstructure:"server"`
Node NodeConfig `mapstructure:"node"`
IPFS IPFSConfig `mapstructure:"ipfs"`
Cluster ClusterConfig `mapstructure:"cluster"`
Postgres PostgresConfig `mapstructure:"postgres"`
NATS NATSConfig `mapstructure:"nats"`
Auth AuthConfig `mapstructure:"auth"`
Bootstrap BootstrapConfig `mapstructure:"bootstrap"`
Logging LoggingConfig `mapstructure:"logging"`
}
// ServerConfig controls the HTTP listener.
type ServerConfig struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
ReadTimeout time.Duration `mapstructure:"readTimeout"`
WriteTimeout time.Duration `mapstructure:"writeTimeout"`
Metrics MetricsConfig `mapstructure:"metrics"`
RateLimit RateLimitConfig `mapstructure:"rateLimit"`
}
// MetricsConfig scopes the Prometheus /metrics endpoint.
type MetricsConfig struct {
// AllowCIDRs is the scraper allowlist checked against the direct
// TCP peer IP. Unset → loopback + RFC1918 defaults.
// Explicit empty list (`[]`) → no restriction (rely on firewall).
AllowCIDRs []string `mapstructure:"allowCIDRs"`
}
// RateLimitConfig scopes the session + anonymous rate limiters.
type RateLimitConfig struct {
// SessionPerMinute caps POST /v1/auth/session attempts per IP.
// Zero → default (10).
SessionPerMinute int `mapstructure:"sessionPerMinute"`
// AnonymousPerMinute caps unauthenticated requests per IP.
// Authenticated requests are exempt. Zero → default (120).
AnonymousPerMinute int `mapstructure:"anonymousPerMinute"`
}
// NodeConfig identifies this anchorage instance within the cluster and
// declares the libp2p multiaddrs advertised for its paired Kubo daemon.
//
// ID is the operator-friendly display name (hostname by default). The
// actual cluster-unique TypeID is persisted under StateDir/node.id so it
// stays stable across restarts even when ID is a non-TypeID string.
type NodeConfig struct {
ID string `mapstructure:"id"`
Multiaddrs []string `mapstructure:"multiaddrs"`
StateDir string `mapstructure:"stateDir"`
}
// IPFSConfig points at the local paired Kubo RPC endpoint.
type IPFSConfig struct {
RPC string `mapstructure:"rpc"`
Timeout time.Duration `mapstructure:"timeout"`
Reconciler ReconcilerConfig `mapstructure:"reconciler"`
}
// ReconcilerConfig controls the per-node Kubo-vs-Postgres drift checker.
type ReconcilerConfig struct {
Interval time.Duration `mapstructure:"interval"`
AutoRepair bool `mapstructure:"autoRepair"`
}
// ClusterConfig governs replication, heartbeats, rebalancing, and maintenance.
type ClusterConfig struct {
MinReplicas int `mapstructure:"minReplicas"`
HeartbeatInterval time.Duration `mapstructure:"heartbeatInterval"`
DownAfter time.Duration `mapstructure:"downAfter"`
RebalanceInterval time.Duration `mapstructure:"rebalanceInterval"`
AutoRepair bool `mapstructure:"autoRepair"`
DrainGracePeriod time.Duration `mapstructure:"drainGracePeriod"`
Maintenance MaintenanceConfig `mapstructure:"maintenance"`
}
// MaintenanceConfig tunes cluster-wide maintenance safety rails.
type MaintenanceConfig struct {
MaxDuration time.Duration `mapstructure:"maxDuration"`
}
// PostgresConfig wires the source-of-truth database.
type PostgresConfig struct {
// DSN selects the backend. Leave empty to force the in-memory
// store (dev / tests / CI). A non-empty DSN switches anchorage
// to the pgx-backed store.
DSN string `mapstructure:"dsn"`
MaxConns int `mapstructure:"maxConns"`
AutoMigrate bool `mapstructure:"autoMigrate"`
RequeueSweeper time.Duration `mapstructure:"requeueSweeper"`
}
// NATSConfig wires the embedded NATS server and JetStream data plane.
type NATSConfig struct {
DataDir string `mapstructure:"dataDir"`
Client NATSClientConfig `mapstructure:"client"`
Cluster NATSClusterConfig `mapstructure:"cluster"`
JetStream NATSJetStreamConfig `mapstructure:"jetstream"`
}
// NATSClientConfig is the client-facing listener (port 4222 by default).
type NATSClientConfig struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
}
// NATSClusterConfig is the peer-to-peer listener used by JetStream.
type NATSClusterConfig struct {
Name string `mapstructure:"name"`
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
Routes []string `mapstructure:"routes"`
}
// NATSJetStreamConfig tunes stream replication.
type NATSJetStreamConfig struct {
Replicas int `mapstructure:"replicas"`
}
// AuthConfig holds OIDC verifier and API-token signing parameters.
type AuthConfig struct {
Authentik AuthentikConfig `mapstructure:"authentik"`
APIToken APITokenConfig `mapstructure:"apiToken"`
}
// AuthentikConfig points at the external OIDC provider.
type AuthentikConfig struct {
Issuer string `mapstructure:"issuer"`
ClientID string `mapstructure:"clientID"`
Audience string `mapstructure:"audience"`
}
// APITokenConfig controls anchorage's own JWT minting.
//
// SigningKeys is a list of key entries, exactly one of which has
// `primary: true`. See deploy/README.md "Rotating the JWT signing key"
// for the rotation procedure.
//
// When SigningKeys is empty (dev / local testing), anchorage falls back
// to a built-in dev key with kid="dev" and logs a loud warning.
type APITokenConfig struct {
SigningKeys []SigningKeyConfig `mapstructure:"signingKeys"`
DefaultTTL time.Duration `mapstructure:"defaultTTL"`
MaxTTL time.Duration `mapstructure:"maxTTL"`
}
// SigningKeyConfig is one entry in APITokenConfig.SigningKeys.
type SigningKeyConfig struct {
// Path is the absolute path to the key file (>=32 bytes).
Path string `mapstructure:"path"`
// ID is the stable label emitted as the JWT `kid` header and used
// by Parse to look up which key verified a given token.
ID string `mapstructure:"id"`
// Primary marks the key anchorage mints new tokens with. Exactly
// one entry must have this set.
Primary bool `mapstructure:"primary"`
}
// BootstrapConfig bootstraps sysadmins from static config on first login.
type BootstrapConfig struct {
Sysadmins []string `mapstructure:"sysadmins"`
}
// LoggingConfig is consumed by internal/pkg/logging.Init.
type LoggingConfig struct {
Level string `mapstructure:"level"`
Format string `mapstructure:"format"`
Source bool `mapstructure:"source"`
File string `mapstructure:"file"`
MaxSizeMB int `mapstructure:"maxSizeMB"`
MaxBackups int `mapstructure:"maxBackups"`
MaxAgeDays int `mapstructure:"maxAgeDays"`
Compress bool `mapstructure:"compress"`
}
// LoadOptions controls Load's behavior.
type LoadOptions struct {
// Path is the explicit config file to read. Empty triggers the default
// search path (./configs, $HOME/.anchorage, /etc/anchorage).
Path string
// AllowMissing lets Load succeed when no config file is found; defaults
// and env overrides still apply. Useful for the `version` command and
// for tests.
AllowMissing bool
}
// Load reads the configuration from disk (if present), overlays environment
// variables, and applies defaults. It does not call logging.Init — the caller
// owns that lifecycle so tests can stub it.
func Load(opts LoadOptions) (*Config, error) {
v := viper.New()
setDefaults(v)
v.SetEnvPrefix("ANCHORAGE")
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
v.AutomaticEnv()
if opts.Path != "" {
v.SetConfigFile(opts.Path)
} else {
v.SetConfigName("anchorage")
v.SetConfigType("yaml")
v.AddConfigPath("./configs")
v.AddConfigPath(".")
if home, err := os.UserHomeDir(); err == nil {
v.AddConfigPath(home + "/.anchorage")
}
v.AddConfigPath("/etc/anchorage")
}
if err := v.ReadInConfig(); err != nil {
var notFound viper.ConfigFileNotFoundError
if errors.As(err, &notFound) || os.IsNotExist(err) {
if !opts.AllowMissing {
return nil, fmt.Errorf("read config: %w", err)
}
} else {
return nil, fmt.Errorf("read config %s: %w", v.ConfigFileUsed(), err)
}
}
var cfg Config
if err := v.Unmarshal(&cfg); err != nil {
return nil, fmt.Errorf("decode config: %w", err)
}
applyHostnameFallback(&cfg)
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("invalid config: %w", err)
}
return &cfg, nil
}
// applyHostnameFallback sets Node.ID to the OS hostname when the operator
// did not override it. Done after Unmarshal so it cannot be masked by a
// stray empty-string value in the YAML.
func applyHostnameFallback(cfg *Config) {
if cfg.Node.ID != "" {
return
}
if h, err := os.Hostname(); err == nil && h != "" {
cfg.Node.ID = h
}
}
// Validate enforces invariants that cannot be expressed as mapstructure
// tags. It runs after Load so callers can also re-validate after mutating
// the Config in-process (e.g., tests).
func (c *Config) Validate() error {
if c.Server.Port < 1 || c.Server.Port > 65535 {
return fmt.Errorf("server.port %d out of range", c.Server.Port)
}
if c.Cluster.MinReplicas < 1 {
return fmt.Errorf("cluster.minReplicas must be >= 1, got %d", c.Cluster.MinReplicas)
}
if c.Cluster.HeartbeatInterval <= 0 {
return errors.New("cluster.heartbeatInterval must be > 0")
}
if c.Cluster.DownAfter <= c.Cluster.HeartbeatInterval {
return fmt.Errorf("cluster.downAfter (%s) must exceed heartbeatInterval (%s)",
c.Cluster.DownAfter, c.Cluster.HeartbeatInterval)
}
if c.Cluster.DrainGracePeriod <= 0 {
return errors.New("cluster.drainGracePeriod must be > 0")
}
if c.IPFS.RPC == "" {
return errors.New("ipfs.rpc is required")
}
if _, err := url.Parse(c.IPFS.RPC); err != nil {
return fmt.Errorf("ipfs.rpc is not a valid URL: %w", err)
}
if c.NATS.Client.Port < 1 || c.NATS.Client.Port > 65535 {
return fmt.Errorf("nats.client.port %d out of range", c.NATS.Client.Port)
}
if c.NATS.Cluster.Port < 1 || c.NATS.Cluster.Port > 65535 {
return fmt.Errorf("nats.cluster.port %d out of range", c.NATS.Cluster.Port)
}
if c.Auth.APIToken.DefaultTTL <= 0 {
return errors.New("auth.apiToken.defaultTTL must be > 0")
}
if c.Auth.APIToken.MaxTTL < c.Auth.APIToken.DefaultTTL {
return fmt.Errorf("auth.apiToken.maxTTL (%s) must be >= defaultTTL (%s)",
c.Auth.APIToken.MaxTTL, c.Auth.APIToken.DefaultTTL)
}
if c.Node.ID == "" {
return errors.New("node.id is required (and hostname fallback failed)")
}
return nil
}
// RequireServe returns an error if fields that are only required at
// `anchorage serve` time are missing. Lets other subcommands (migrate,
// admin) load a partial config without tripping over a missing DSN.
//
// Postgres DSN is intentionally NOT required here — omitting it is how
// an operator opts into the in-memory store for dev / demo. The logs
// will loudly warn that the memstore is not durable.
func (c *Config) RequireServe() error {
if c.Auth.Authentik.Issuer == "" {
return errors.New("auth.authentik.issuer is required for `anchorage serve`")
}
// Empty signingKeys is allowed — anchorage falls back to the
// built-in dev key with a loud warning. Production operators
// populate signingKeys with at least one primary entry.
if c.NATS.DataDir == "" {
return errors.New("nats.dataDir is required for `anchorage serve`")
}
return nil
}