config: initial loading & env overrides

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Iaa7401a20506a084a2a16882e61ea0bc6a6a6964
This commit is contained in:
raf 2026-03-05 23:36:44 +03:00
commit 4f8d1c64d2
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
3 changed files with 222 additions and 0 deletions

30
config.example.yaml Normal file
View file

@ -0,0 +1,30 @@
server:
listen: ":8080"
read_timeout: 30s
write_timeout: 30s
upstreams:
- url: "https://cache.nixos.org"
priority: 10
public_key: "cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY="
# Try without a public key
- url: "https://nix-community.cachix.org"
priority: 20
cache:
db_path: "/var/lib/ncro/routes.db"
max_entries: 100000
ttl: 1h
latency_alpha: 0.3
mesh:
enabled: false
bind_addr: "0.0.0.0:7946"
peers: []
private_key: "/etc/ncro/node.key"
gossip_interval: 30s
logging:
level: "info"
format: "json"

View file

@ -1 +1,130 @@
package config
import (
"fmt"
"os"
"time"
"gopkg.in/yaml.v3"
)
// Duration is a wrapper around time.Duration that supports YAML unmarshaling
// from Go duration strings (e.g., "30s", "1h"). yaml.v3 cannot unmarshal
// duration strings directly into time.Duration (int64), so we handle it here.
type Duration struct {
time.Duration
}
func (d *Duration) UnmarshalYAML(value *yaml.Node) error {
var s string
if err := value.Decode(&s); err != nil {
// Try decoding as a raw int64 (nanoseconds) as fallback.
var ns int64
if err2 := value.Decode(&ns); err2 != nil {
return fmt.Errorf("cannot unmarshal duration: %w", err)
}
d.Duration = time.Duration(ns)
return nil
}
parsed, err := time.ParseDuration(s)
if err != nil {
return fmt.Errorf("invalid duration %q: %w", s, err)
}
d.Duration = parsed
return nil
}
type UpstreamConfig struct {
URL string `yaml:"url"`
Priority int `yaml:"priority"`
PublicKey string `yaml:"public_key"`
}
type ServerConfig struct {
Listen string `yaml:"listen"`
ReadTimeout Duration `yaml:"read_timeout"`
WriteTimeout Duration `yaml:"write_timeout"`
}
type CacheConfig struct {
DBPath string `yaml:"db_path"`
MaxEntries int `yaml:"max_entries"`
TTL Duration `yaml:"ttl"`
LatencyAlpha float64 `yaml:"latency_alpha"`
}
type MeshConfig struct {
Enabled bool `yaml:"enabled"`
BindAddr string `yaml:"bind_addr"`
Peers []string `yaml:"peers"`
PrivateKeyPath string `yaml:"private_key"`
GossipInterval Duration `yaml:"gossip_interval"`
}
type LoggingConfig struct {
Level string `yaml:"level"`
Format string `yaml:"format"`
}
type Config struct {
Server ServerConfig `yaml:"server"`
Upstreams []UpstreamConfig `yaml:"upstreams"`
Cache CacheConfig `yaml:"cache"`
Mesh MeshConfig `yaml:"mesh"`
Logging LoggingConfig `yaml:"logging"`
}
func defaults() Config {
return Config{
Server: ServerConfig{
Listen: ":8080",
ReadTimeout: Duration{30 * time.Second},
WriteTimeout: Duration{30 * time.Second},
},
Upstreams: []UpstreamConfig{
{URL: "https://cache.nixos.org", Priority: 10},
},
Cache: CacheConfig{
DBPath: "/var/lib/ncro/routes.db",
MaxEntries: 100000,
TTL: Duration{time.Hour},
LatencyAlpha: 0.3,
},
Mesh: MeshConfig{
BindAddr: "0.0.0.0:7946",
GossipInterval: Duration{30 * time.Second},
},
Logging: LoggingConfig{
Level: "info",
Format: "json",
},
}
}
// Load loads config from file (if non-empty) and applies env overrides.
func Load(path string) (*Config, error) {
cfg := defaults()
if path != "" {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, err
}
}
// Env overrides
if v := os.Getenv("NCRO_LISTEN"); v != "" {
cfg.Server.Listen = v
}
if v := os.Getenv("NCRO_DB_PATH"); v != "" {
cfg.Cache.DBPath = v
}
if v := os.Getenv("NCRO_LOG_LEVEL"); v != "" {
cfg.Logging.Level = v
}
return &cfg, nil
}

View file

@ -0,0 +1,63 @@
package config_test
import (
"os"
"testing"
"notashelf.dev/ncro/internal/config"
)
func TestLoadDefaults(t *testing.T) {
cfg, err := config.Load("")
if err != nil {
t.Fatalf("Load(\"\") error: %v", err)
}
if cfg.Server.Listen != ":8080" {
t.Errorf("default listen = %q, want :8080", cfg.Server.Listen)
}
if len(cfg.Upstreams) == 0 {
t.Error("expected at least one default upstream")
}
if cfg.Cache.MaxEntries != 100000 {
t.Errorf("default max_entries = %d, want 100000", cfg.Cache.MaxEntries)
}
}
func TestLoadFromYAML(t *testing.T) {
yamlContent := `
server:
listen: ":9090"
upstreams:
- url: "https://cache.nixos.org"
priority: 10
cache:
db_path: "/tmp/test.db"
max_entries: 500
`
f, _ := os.CreateTemp("", "ncro-*.yaml")
defer os.Remove(f.Name())
f.WriteString(yamlContent)
f.Close()
cfg, err := config.Load(f.Name())
if err != nil {
t.Fatalf("Load error: %v", err)
}
if cfg.Server.Listen != ":9090" {
t.Errorf("listen = %q, want :9090", cfg.Server.Listen)
}
if cfg.Cache.MaxEntries != 500 {
t.Errorf("max_entries = %d, want 500", cfg.Cache.MaxEntries)
}
}
func TestEnvOverride(t *testing.T) {
t.Setenv("NCRO_LISTEN", ":1234")
cfg, err := config.Load("")
if err != nil {
t.Fatalf("Load error: %v", err)
}
if cfg.Server.Listen != ":1234" {
t.Errorf("env override listen = %q, want :1234", cfg.Server.Listen)
}
}