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.
337 lines
12 KiB
Go
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, ¬Found) || 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
|
|
}
|