After TOML loading and generic env overrides, config.Load now checks $PORT and $PORT_GRPC and overrides ServerConfig.ListenAddr and ServerConfig.GRPCAddr respectively. These take precedence over all other config sources because they represent agent-assigned authoritative port bindings. Handles both Base embedding (MCR, MCNS, MCAT) and direct ServerConfig embedding (Metacrypt) via struct tree walking. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
510 lines
12 KiB
Go
510 lines
12 KiB
Go
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// testConfig embeds Base and adds a service-specific section.
|
|
type testConfig struct {
|
|
Base
|
|
MyService myServiceConfig `toml:"my_service"`
|
|
}
|
|
|
|
type myServiceConfig struct {
|
|
Name string `toml:"name"`
|
|
Enabled bool `toml:"enabled"`
|
|
Items []string `toml:"items"`
|
|
}
|
|
|
|
// validatingConfig implements the Validator interface.
|
|
type validatingConfig struct {
|
|
Base
|
|
Custom customSection `toml:"custom"`
|
|
}
|
|
|
|
type customSection struct {
|
|
Required string `toml:"required"`
|
|
}
|
|
|
|
func (c *validatingConfig) Validate() error {
|
|
if c.Custom.Required == "" {
|
|
return fmt.Errorf("custom.required is missing")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
const minimalTOML = `
|
|
[server]
|
|
listen_addr = ":8443"
|
|
tls_cert = "/tmp/cert.pem"
|
|
tls_key = "/tmp/key.pem"
|
|
|
|
[database]
|
|
path = "/tmp/test.db"
|
|
|
|
[mcias]
|
|
server_url = "https://mcias.example.com"
|
|
service_name = "test"
|
|
|
|
[log]
|
|
level = "debug"
|
|
`
|
|
|
|
const fullTOML = `
|
|
[server]
|
|
listen_addr = ":8443"
|
|
tls_cert = "/tmp/cert.pem"
|
|
tls_key = "/tmp/key.pem"
|
|
grpc_addr = ":9443"
|
|
read_timeout = "10s"
|
|
write_timeout = "15s"
|
|
idle_timeout = "60s"
|
|
shutdown_timeout = "30s"
|
|
|
|
[database]
|
|
path = "/tmp/test.db"
|
|
|
|
[mcias]
|
|
server_url = "https://mcias.example.com"
|
|
ca_cert = "/tmp/ca.pem"
|
|
service_name = "myservice"
|
|
tags = ["env:test", "tier:dev"]
|
|
|
|
[log]
|
|
level = "warn"
|
|
|
|
[my_service]
|
|
name = "hello"
|
|
enabled = true
|
|
items = ["a", "b", "c"]
|
|
`
|
|
|
|
func writeTOML(t *testing.T, content string) string {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "test.toml")
|
|
if err := os.WriteFile(path, []byte(content), 0600); err != nil {
|
|
t.Fatalf("write config: %v", err)
|
|
}
|
|
return path
|
|
}
|
|
|
|
func TestLoadMinimal(t *testing.T) {
|
|
path := writeTOML(t, minimalTOML)
|
|
cfg, err := Load[testConfig](path, "TEST")
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
|
|
if cfg.Server.ListenAddr != ":8443" {
|
|
t.Fatalf("ListenAddr = %q, want %q", cfg.Server.ListenAddr, ":8443")
|
|
}
|
|
if cfg.Log.Level != "debug" {
|
|
t.Fatalf("Log.Level = %q, want %q", cfg.Log.Level, "debug")
|
|
}
|
|
if cfg.MCIAS.ServerURL != "https://mcias.example.com" {
|
|
t.Fatalf("MCIAS.ServerURL = %q", cfg.MCIAS.ServerURL)
|
|
}
|
|
}
|
|
|
|
func TestLoadFull(t *testing.T) {
|
|
path := writeTOML(t, fullTOML)
|
|
cfg, err := Load[testConfig](path, "TEST")
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
|
|
if cfg.Server.GRPCAddr != ":9443" {
|
|
t.Fatalf("GRPCAddr = %q, want %q", cfg.Server.GRPCAddr, ":9443")
|
|
}
|
|
if cfg.Server.ReadTimeout.Duration != 10*time.Second {
|
|
t.Fatalf("ReadTimeout = %v, want 10s", cfg.Server.ReadTimeout)
|
|
}
|
|
if cfg.Server.WriteTimeout.Duration != 15*time.Second {
|
|
t.Fatalf("WriteTimeout = %v, want 15s", cfg.Server.WriteTimeout)
|
|
}
|
|
if cfg.MCIAS.CACert != "/tmp/ca.pem" {
|
|
t.Fatalf("CACert = %q", cfg.MCIAS.CACert)
|
|
}
|
|
if len(cfg.MCIAS.Tags) != 2 {
|
|
t.Fatalf("Tags = %v, want 2 items", cfg.MCIAS.Tags)
|
|
}
|
|
if cfg.MyService.Name != "hello" {
|
|
t.Fatalf("MyService.Name = %q, want %q", cfg.MyService.Name, "hello")
|
|
}
|
|
if !cfg.MyService.Enabled {
|
|
t.Fatal("MyService.Enabled = false, want true")
|
|
}
|
|
if len(cfg.MyService.Items) != 3 {
|
|
t.Fatalf("MyService.Items = %v, want 3 items", cfg.MyService.Items)
|
|
}
|
|
}
|
|
|
|
func TestDefaults(t *testing.T) {
|
|
path := writeTOML(t, minimalTOML)
|
|
cfg, err := Load[testConfig](path, "TEST")
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
|
|
if cfg.Server.ReadTimeout.Duration != 30*time.Second {
|
|
t.Fatalf("ReadTimeout = %v, want 30s (default)", cfg.Server.ReadTimeout)
|
|
}
|
|
if cfg.Server.WriteTimeout.Duration != 30*time.Second {
|
|
t.Fatalf("WriteTimeout = %v, want 30s (default)", cfg.Server.WriteTimeout)
|
|
}
|
|
if cfg.Server.IdleTimeout.Duration != 120*time.Second {
|
|
t.Fatalf("IdleTimeout = %v, want 120s (default)", cfg.Server.IdleTimeout)
|
|
}
|
|
if cfg.Server.ShutdownTimeout.Duration != 60*time.Second {
|
|
t.Fatalf("ShutdownTimeout = %v, want 60s (default)", cfg.Server.ShutdownTimeout)
|
|
}
|
|
}
|
|
|
|
func TestDefaultsNotOverrideExplicit(t *testing.T) {
|
|
path := writeTOML(t, fullTOML)
|
|
cfg, err := Load[testConfig](path, "TEST")
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
|
|
// fullTOML sets read_timeout = "10s"; default is 30s.
|
|
if cfg.Server.ReadTimeout.Duration != 10*time.Second {
|
|
t.Fatalf("ReadTimeout = %v, want 10s (explicit, not default)", cfg.Server.ReadTimeout)
|
|
}
|
|
}
|
|
|
|
func TestDefaultLogLevel(t *testing.T) {
|
|
toml := `
|
|
[server]
|
|
listen_addr = ":8443"
|
|
tls_cert = "/tmp/cert.pem"
|
|
tls_key = "/tmp/key.pem"
|
|
|
|
[database]
|
|
path = "/tmp/test.db"
|
|
|
|
[mcias]
|
|
server_url = "https://mcias.example.com"
|
|
`
|
|
path := writeTOML(t, toml)
|
|
cfg, err := Load[testConfig](path, "TEST")
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
|
|
if cfg.Log.Level != "info" {
|
|
t.Fatalf("Log.Level = %q, want %q (default)", cfg.Log.Level, "info")
|
|
}
|
|
}
|
|
|
|
func TestMissingRequiredField(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
toml string
|
|
}{
|
|
{
|
|
"missing listen_addr",
|
|
`[server]
|
|
tls_cert = "/tmp/cert.pem"
|
|
tls_key = "/tmp/key.pem"
|
|
[database]
|
|
path = "/tmp/test.db"`,
|
|
},
|
|
{
|
|
"missing tls_cert",
|
|
`[server]
|
|
listen_addr = ":8443"
|
|
tls_key = "/tmp/key.pem"
|
|
[database]
|
|
path = "/tmp/test.db"`,
|
|
},
|
|
{
|
|
"missing tls_key",
|
|
`[server]
|
|
listen_addr = ":8443"
|
|
tls_cert = "/tmp/cert.pem"
|
|
[database]
|
|
path = "/tmp/test.db"`,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
path := writeTOML(t, tt.toml)
|
|
_, err := Load[testConfig](path, "TEST")
|
|
if err == nil {
|
|
t.Fatal("expected error for missing required field")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEnvOverrideString(t *testing.T) {
|
|
path := writeTOML(t, minimalTOML)
|
|
|
|
t.Setenv("TEST_SERVER_LISTEN_ADDR", ":9999")
|
|
|
|
cfg, err := Load[testConfig](path, "TEST")
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
|
|
if cfg.Server.ListenAddr != ":9999" {
|
|
t.Fatalf("ListenAddr = %q, want %q (from env)", cfg.Server.ListenAddr, ":9999")
|
|
}
|
|
}
|
|
|
|
func TestEnvOverrideDuration(t *testing.T) {
|
|
path := writeTOML(t, minimalTOML)
|
|
|
|
t.Setenv("TEST_SERVER_READ_TIMEOUT", "5s")
|
|
|
|
cfg, err := Load[testConfig](path, "TEST")
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
|
|
if cfg.Server.ReadTimeout.Duration != 5*time.Second {
|
|
t.Fatalf("ReadTimeout = %v, want 5s (from env)", cfg.Server.ReadTimeout)
|
|
}
|
|
}
|
|
|
|
func TestEnvOverrideSlice(t *testing.T) {
|
|
path := writeTOML(t, minimalTOML)
|
|
|
|
t.Setenv("TEST_MCIAS_TAGS", "env:prod, tier:api")
|
|
|
|
cfg, err := Load[testConfig](path, "TEST")
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
|
|
if len(cfg.MCIAS.Tags) != 2 {
|
|
t.Fatalf("Tags = %v, want 2 items", cfg.MCIAS.Tags)
|
|
}
|
|
if cfg.MCIAS.Tags[0] != "env:prod" {
|
|
t.Fatalf("Tags[0] = %q, want %q", cfg.MCIAS.Tags[0], "env:prod")
|
|
}
|
|
if cfg.MCIAS.Tags[1] != "tier:api" {
|
|
t.Fatalf("Tags[1] = %q, want %q", cfg.MCIAS.Tags[1], "tier:api")
|
|
}
|
|
}
|
|
|
|
func TestEnvOverrideServiceSpecific(t *testing.T) {
|
|
path := writeTOML(t, fullTOML)
|
|
|
|
t.Setenv("TEST_MY_SERVICE_NAME", "overridden")
|
|
|
|
cfg, err := Load[testConfig](path, "TEST")
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
|
|
if cfg.MyService.Name != "overridden" {
|
|
t.Fatalf("MyService.Name = %q, want %q (from env)", cfg.MyService.Name, "overridden")
|
|
}
|
|
}
|
|
|
|
func TestEnvOverrideBool(t *testing.T) {
|
|
path := writeTOML(t, minimalTOML)
|
|
|
|
t.Setenv("TEST_MY_SERVICE_ENABLED", "true")
|
|
|
|
cfg, err := Load[testConfig](path, "TEST")
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
|
|
if !cfg.MyService.Enabled {
|
|
t.Fatal("MyService.Enabled = false, want true (from env)")
|
|
}
|
|
}
|
|
|
|
func TestValidatorCalled(t *testing.T) {
|
|
toml := `
|
|
[server]
|
|
listen_addr = ":8443"
|
|
tls_cert = "/tmp/cert.pem"
|
|
tls_key = "/tmp/key.pem"
|
|
|
|
[database]
|
|
path = "/tmp/test.db"
|
|
|
|
[mcias]
|
|
server_url = "https://mcias.example.com"
|
|
`
|
|
path := writeTOML(t, toml)
|
|
|
|
// custom.required is missing → Validate should fail.
|
|
_, err := Load[validatingConfig](path, "TEST")
|
|
if err == nil {
|
|
t.Fatal("expected validation error for missing custom.required")
|
|
}
|
|
}
|
|
|
|
func TestValidatorPasses(t *testing.T) {
|
|
toml := `
|
|
[server]
|
|
listen_addr = ":8443"
|
|
tls_cert = "/tmp/cert.pem"
|
|
tls_key = "/tmp/key.pem"
|
|
|
|
[database]
|
|
path = "/tmp/test.db"
|
|
|
|
[mcias]
|
|
server_url = "https://mcias.example.com"
|
|
|
|
[custom]
|
|
required = "present"
|
|
`
|
|
path := writeTOML(t, toml)
|
|
|
|
cfg, err := Load[validatingConfig](path, "TEST")
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if cfg.Custom.Required != "present" {
|
|
t.Fatalf("Custom.Required = %q, want %q", cfg.Custom.Required, "present")
|
|
}
|
|
}
|
|
|
|
func TestLoadNonexistentFile(t *testing.T) {
|
|
_, err := Load[testConfig]("/nonexistent/path.toml", "TEST")
|
|
if err == nil {
|
|
t.Fatal("expected error for nonexistent file")
|
|
}
|
|
}
|
|
|
|
func TestLoadInvalidTOML(t *testing.T) {
|
|
path := writeTOML(t, "this is not valid toml [[[")
|
|
_, err := Load[testConfig](path, "TEST")
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid TOML")
|
|
}
|
|
}
|
|
|
|
func TestEmptyEnvPrefix(t *testing.T) {
|
|
path := writeTOML(t, minimalTOML)
|
|
|
|
// Should work fine with no env prefix (no overrides applied).
|
|
cfg, err := Load[testConfig](path, "")
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if cfg.Server.ListenAddr != ":8443" {
|
|
t.Fatalf("ListenAddr = %q, want %q", cfg.Server.ListenAddr, ":8443")
|
|
}
|
|
}
|
|
|
|
// directServerConfig embeds ServerConfig without Base (Metacrypt pattern).
|
|
type directServerConfig struct {
|
|
Server ServerConfig `toml:"server"`
|
|
Extra string `toml:"extra"`
|
|
}
|
|
|
|
func TestPortEnvOverridesListenAddr(t *testing.T) {
|
|
path := writeTOML(t, minimalTOML)
|
|
t.Setenv("PORT", "9999")
|
|
|
|
cfg, err := Load[testConfig](path, "TEST")
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
|
|
if cfg.Server.ListenAddr != ":9999" {
|
|
t.Fatalf("ListenAddr = %q, want %q", cfg.Server.ListenAddr, ":9999")
|
|
}
|
|
}
|
|
|
|
func TestPortGRPCEnvOverridesGRPCAddr(t *testing.T) {
|
|
path := writeTOML(t, minimalTOML)
|
|
t.Setenv("PORT_GRPC", "9998")
|
|
|
|
cfg, err := Load[testConfig](path, "TEST")
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
|
|
if cfg.Server.GRPCAddr != ":9998" {
|
|
t.Fatalf("GRPCAddr = %q, want %q", cfg.Server.GRPCAddr, ":9998")
|
|
}
|
|
}
|
|
|
|
func TestPortEnvOverridesTOMLValue(t *testing.T) {
|
|
// fullTOML sets listen_addr = ":8443" and grpc_addr = ":9443".
|
|
path := writeTOML(t, fullTOML)
|
|
t.Setenv("PORT", "9999")
|
|
|
|
cfg, err := Load[testConfig](path, "TEST")
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
|
|
if cfg.Server.ListenAddr != ":9999" {
|
|
t.Fatalf("ListenAddr = %q, want %q ($PORT should override TOML)", cfg.Server.ListenAddr, ":9999")
|
|
}
|
|
}
|
|
|
|
func TestPortEnvOverridesGenericEnv(t *testing.T) {
|
|
path := writeTOML(t, minimalTOML)
|
|
t.Setenv("TEST_SERVER_LISTEN_ADDR", ":7777")
|
|
t.Setenv("PORT", "9999")
|
|
|
|
cfg, err := Load[testConfig](path, "TEST")
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
|
|
if cfg.Server.ListenAddr != ":9999" {
|
|
t.Fatalf("ListenAddr = %q, want %q ($PORT should override generic env)", cfg.Server.ListenAddr, ":9999")
|
|
}
|
|
}
|
|
|
|
func TestNoPortEnvNoChange(t *testing.T) {
|
|
path := writeTOML(t, minimalTOML)
|
|
|
|
cfg, err := Load[testConfig](path, "TEST")
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
|
|
// minimalTOML sets listen_addr = ":8443", no $PORT set.
|
|
if cfg.Server.ListenAddr != ":8443" {
|
|
t.Fatalf("ListenAddr = %q, want %q (TOML value preserved without $PORT)", cfg.Server.ListenAddr, ":8443")
|
|
}
|
|
}
|
|
|
|
func TestPortEnvDirectServerConfig(t *testing.T) {
|
|
// Test the Metacrypt pattern: ServerConfig embedded without Base.
|
|
toml := `
|
|
[server]
|
|
listen_addr = ":8443"
|
|
tls_cert = "/tmp/cert.pem"
|
|
tls_key = "/tmp/key.pem"
|
|
grpc_addr = ":9443"
|
|
|
|
extra = "value"
|
|
`
|
|
path := writeTOML(t, toml)
|
|
t.Setenv("PORT", "5555")
|
|
t.Setenv("PORT_GRPC", "5556")
|
|
|
|
cfg, err := Load[directServerConfig](path, "TEST")
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
|
|
if cfg.Server.ListenAddr != ":5555" {
|
|
t.Fatalf("ListenAddr = %q, want %q ($PORT on direct ServerConfig)", cfg.Server.ListenAddr, ":5555")
|
|
}
|
|
if cfg.Server.GRPCAddr != ":5556" {
|
|
t.Fatalf("GRPCAddr = %q, want %q ($PORT_GRPC on direct ServerConfig)", cfg.Server.GRPCAddr, ":5556")
|
|
}
|
|
}
|