Add config package: TOML loading with env overrides
- Base type with standard sections (Server, Database, MCIAS, Log) - Duration wrapper type for TOML string→time.Duration decoding - Generic Load[T] with TOML parse, reflection-based env overrides, defaults, required field validation, optional Validator interface - Env overrides: PREFIX_SECTION_FIELD for string, duration, bool, []string (comma-separated) - WebConfig exported for services with web UIs (not embedded in Base) - 16 tests covering full/minimal configs, defaults, env overrides, validation, error cases Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
47
PROGRESS.md
47
PROGRESS.md
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
## Current State
|
## Current State
|
||||||
|
|
||||||
Phase 2 complete. The `db` and `auth` packages are implemented and tested.
|
Phase 3 complete. The `db`, `auth`, and `config` packages are implemented
|
||||||
|
and tested.
|
||||||
|
|
||||||
## Completed
|
## Completed
|
||||||
|
|
||||||
@@ -27,25 +28,33 @@ Phase 2 complete. The `db` and `auth` packages are implemented and tested.
|
|||||||
- 11 tests covering open, migrate, and snapshot
|
- 11 tests covering open, migrate, and snapshot
|
||||||
|
|
||||||
### Phase 2: `auth` — MCIAS Token Validation (2026-03-25)
|
### Phase 2: `auth` — MCIAS Token Validation (2026-03-25)
|
||||||
- `Config` type matching `[mcias]` TOML section (ServerURL, CACert,
|
- `Config` type matching `[mcias]` TOML section
|
||||||
ServiceName, Tags)
|
|
||||||
- `TokenInfo` type (Username, Roles, IsAdmin)
|
- `TokenInfo` type (Username, Roles, IsAdmin)
|
||||||
- `New(cfg Config, logger *slog.Logger) (*Authenticator, error)` — creates
|
- `New(cfg, logger)` — MCIAS client with TLS 1.3, custom CA, 10s timeout
|
||||||
MCIAS client with TLS 1.3, custom CA support, 10s timeout
|
- `Login`, `ValidateToken` (30s SHA-256 cache), `Logout`
|
||||||
- `Login(username, password, totpCode string) (token, expiresAt, err)` —
|
- Error types, context helpers
|
||||||
forwards to MCIAS with service context, returns ErrForbidden for policy
|
- 14 tests with mock MCIAS server and injectable clock
|
||||||
denials, ErrInvalidCredentials for bad creds
|
|
||||||
- `ValidateToken(token string) (*TokenInfo, error)` — 30s SHA-256-keyed
|
### Phase 3: `config` — TOML Configuration (2026-03-25)
|
||||||
cache, lazy eviction, concurrent-safe (RWMutex)
|
- `Base` type embedding standard sections (Server, Database, MCIAS, Log)
|
||||||
- `Logout(token string) error` — revokes token on MCIAS
|
- `ServerConfig` with `Duration` wrapper type for TOML string decoding
|
||||||
- Error types: ErrInvalidToken, ErrInvalidCredentials, ErrForbidden,
|
(go-toml v2 does not natively decode strings to time.Duration)
|
||||||
ErrUnavailable
|
- `DatabaseConfig`, `LogConfig`, `WebConfig` (non-embedded, for web UIs)
|
||||||
- Context helpers: ContextWithTokenInfo, TokenInfoFromContext
|
- `Duration` type with TextUnmarshaler/TextMarshaler for TOML compatibility
|
||||||
- 14 tests: login (success, invalid creds, forbidden), validate (admin,
|
- `Load[T any](path, envPrefix)` — generic loader with TOML parse, env
|
||||||
non-admin, expired, unknown), cache (hit, expiry via injectable clock),
|
overrides via reflection, defaults, required field validation
|
||||||
logout, constructor validation, context roundtrip, admin detection
|
- `Validator` interface for service-specific validation
|
||||||
- `make all` passes clean (vet, lint 0 issues, 25 total tests, build)
|
- Environment overrides: PREFIX_SECTION_FIELD for strings, durations,
|
||||||
|
bools, and comma-separated string slices
|
||||||
|
- Defaults: ReadTimeout=30s, WriteTimeout=30s, IdleTimeout=120s,
|
||||||
|
ShutdownTimeout=60s, Log.Level="info"
|
||||||
|
- Required: listen_addr, tls_cert, tls_key
|
||||||
|
- 16 tests: minimal/full config, defaults (applied and not overriding
|
||||||
|
explicit), missing required fields (3 cases), env overrides (string,
|
||||||
|
duration, slice, bool, service-specific), Validator interface (pass/fail),
|
||||||
|
nonexistent file, invalid TOML, empty prefix
|
||||||
|
- `make all` passes clean (vet, lint 0 issues, 41 total tests, build)
|
||||||
|
|
||||||
## Next Steps
|
## Next Steps
|
||||||
|
|
||||||
- Phase 3: `config` package (TOML loading, env overrides, standard sections)
|
- Phase 4: `httpserver` package (TLS HTTP server, middleware, JSON helpers)
|
||||||
|
|||||||
289
config/config.go
Normal file
289
config/config.go
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
// Package config provides TOML configuration loading with environment
|
||||||
|
// variable overrides for Metacircular services.
|
||||||
|
//
|
||||||
|
// Services define their own config struct embedding [Base], which provides
|
||||||
|
// the standard sections (Server, Database, MCIAS, Log). Use [Load] to
|
||||||
|
// parse a TOML file, apply environment overrides, set defaults, and
|
||||||
|
// validate required fields.
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pelletier/go-toml/v2"
|
||||||
|
|
||||||
|
"git.wntrmute.dev/kyle/mcdsl/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Base contains the configuration sections common to all Metacircular
|
||||||
|
// services. Services embed this in their own config struct and add
|
||||||
|
// service-specific sections.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// type MyConfig struct {
|
||||||
|
// config.Base
|
||||||
|
// MyService MyServiceSection `toml:"my_service"`
|
||||||
|
// }
|
||||||
|
type Base struct {
|
||||||
|
Server ServerConfig `toml:"server"`
|
||||||
|
Database DatabaseConfig `toml:"database"`
|
||||||
|
MCIAS auth.Config `toml:"mcias"`
|
||||||
|
Log LogConfig `toml:"log"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServerConfig holds TLS server settings.
|
||||||
|
type ServerConfig struct {
|
||||||
|
// ListenAddr is the HTTPS listen address (e.g., ":8443"). Required.
|
||||||
|
ListenAddr string `toml:"listen_addr"`
|
||||||
|
|
||||||
|
// GRPCAddr is the gRPC listen address (e.g., ":9443"). Optional;
|
||||||
|
// gRPC is disabled if empty.
|
||||||
|
GRPCAddr string `toml:"grpc_addr"`
|
||||||
|
|
||||||
|
// TLSCert is the path to the TLS certificate file (PEM). Required.
|
||||||
|
TLSCert string `toml:"tls_cert"`
|
||||||
|
|
||||||
|
// TLSKey is the path to the TLS private key file (PEM). Required.
|
||||||
|
TLSKey string `toml:"tls_key"`
|
||||||
|
|
||||||
|
// ReadTimeout is the maximum duration for reading the entire request.
|
||||||
|
// Defaults to 30s.
|
||||||
|
ReadTimeout Duration `toml:"read_timeout"`
|
||||||
|
|
||||||
|
// WriteTimeout is the maximum duration before timing out writes.
|
||||||
|
// Defaults to 30s.
|
||||||
|
WriteTimeout Duration `toml:"write_timeout"`
|
||||||
|
|
||||||
|
// IdleTimeout is the maximum time to wait for the next request on
|
||||||
|
// a keep-alive connection. Defaults to 120s.
|
||||||
|
IdleTimeout Duration `toml:"idle_timeout"`
|
||||||
|
|
||||||
|
// ShutdownTimeout is the maximum time to wait for in-flight requests
|
||||||
|
// to drain during graceful shutdown. Defaults to 60s.
|
||||||
|
ShutdownTimeout Duration `toml:"shutdown_timeout"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DatabaseConfig holds SQLite database settings.
|
||||||
|
type DatabaseConfig struct {
|
||||||
|
// Path is the path to the SQLite database file. Required.
|
||||||
|
Path string `toml:"path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogConfig holds logging settings.
|
||||||
|
type LogConfig struct {
|
||||||
|
// Level is the log level (debug, info, warn, error). Defaults to "info".
|
||||||
|
Level string `toml:"level"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebConfig holds web UI server settings. This is not part of Base because
|
||||||
|
// not all services have a web UI — services that do can add it to their
|
||||||
|
// own config struct.
|
||||||
|
type WebConfig struct {
|
||||||
|
// ListenAddr is the web UI listen address (e.g., "127.0.0.1:8080").
|
||||||
|
ListenAddr string `toml:"listen_addr"`
|
||||||
|
|
||||||
|
// GRPCAddr is the gRPC address of the API server that the web UI
|
||||||
|
// connects to.
|
||||||
|
GRPCAddr string `toml:"grpc_addr"`
|
||||||
|
|
||||||
|
// CACert is an optional CA certificate for verifying the API server's
|
||||||
|
// TLS certificate.
|
||||||
|
CACert string `toml:"ca_cert"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validator is an optional interface that config structs can implement
|
||||||
|
// to add service-specific validation. If the config type implements
|
||||||
|
// Validator, its Validate method is called after defaults and env
|
||||||
|
// overrides are applied.
|
||||||
|
type Validator interface {
|
||||||
|
Validate() error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load reads a TOML config file at path, applies environment variable
|
||||||
|
// overrides using envPrefix (e.g., "MCR" maps MCR_SERVER_LISTEN_ADDR to
|
||||||
|
// Server.ListenAddr), sets defaults for unset optional fields, and
|
||||||
|
// validates required fields.
|
||||||
|
//
|
||||||
|
// If T implements [Validator], its Validate method is called after all
|
||||||
|
// other processing.
|
||||||
|
func Load[T any](path string, envPrefix string) (*T, error) {
|
||||||
|
data, err := os.ReadFile(path) //nolint:gosec // config path is operator-supplied
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("config: read %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg T
|
||||||
|
if err := toml.Unmarshal(data, &cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("config: parse %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if envPrefix != "" {
|
||||||
|
applyEnvToStruct(reflect.ValueOf(&cfg).Elem(), envPrefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
applyBaseDefaults(&cfg)
|
||||||
|
|
||||||
|
if err := validateBase(&cfg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if v, ok := any(&cfg).(Validator); ok {
|
||||||
|
if err := v.Validate(); err != nil {
|
||||||
|
return nil, fmt.Errorf("config: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyBaseDefaults sets defaults on the embedded Base struct if present.
|
||||||
|
func applyBaseDefaults(cfg any) {
|
||||||
|
base := findBase(cfg)
|
||||||
|
if base == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if base.Server.ReadTimeout.Duration == 0 {
|
||||||
|
base.Server.ReadTimeout.Duration = 30 * time.Second
|
||||||
|
}
|
||||||
|
if base.Server.WriteTimeout.Duration == 0 {
|
||||||
|
base.Server.WriteTimeout.Duration = 30 * time.Second
|
||||||
|
}
|
||||||
|
if base.Server.IdleTimeout.Duration == 0 {
|
||||||
|
base.Server.IdleTimeout.Duration = 120 * time.Second
|
||||||
|
}
|
||||||
|
if base.Server.ShutdownTimeout.Duration == 0 {
|
||||||
|
base.Server.ShutdownTimeout.Duration = 60 * time.Second
|
||||||
|
}
|
||||||
|
if base.Log.Level == "" {
|
||||||
|
base.Log.Level = "info"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateBase checks required fields on the embedded Base struct if present.
|
||||||
|
func validateBase(cfg any) error {
|
||||||
|
base := findBase(cfg)
|
||||||
|
if base == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
required := []struct {
|
||||||
|
name string
|
||||||
|
value string
|
||||||
|
}{
|
||||||
|
{"server.listen_addr", base.Server.ListenAddr},
|
||||||
|
{"server.tls_cert", base.Server.TLSCert},
|
||||||
|
{"server.tls_key", base.Server.TLSKey},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, r := range required {
|
||||||
|
if r.value == "" {
|
||||||
|
return fmt.Errorf("config: required field %q is missing", r.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// findBase returns a pointer to the embedded Base struct, or nil if the
|
||||||
|
// config type does not embed Base.
|
||||||
|
func findBase(cfg any) *Base {
|
||||||
|
v := reflect.ValueOf(cfg)
|
||||||
|
if v.Kind() == reflect.Ptr {
|
||||||
|
v = v.Elem()
|
||||||
|
}
|
||||||
|
if v.Kind() != reflect.Struct {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if cfg *is* a Base.
|
||||||
|
if b, ok := v.Addr().Interface().(*Base); ok {
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check embedded fields.
|
||||||
|
t := v.Type()
|
||||||
|
for i := range t.NumField() {
|
||||||
|
field := t.Field(i)
|
||||||
|
if field.Anonymous && field.Type == reflect.TypeOf(Base{}) {
|
||||||
|
b, ok := v.Field(i).Addr().Interface().(*Base)
|
||||||
|
if ok {
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyEnvToStruct recursively walks a struct and overrides field values
|
||||||
|
// from environment variables. The env variable name is built from the
|
||||||
|
// prefix and the toml tag: PREFIX_SECTION_FIELD (uppercased).
|
||||||
|
//
|
||||||
|
// Supported field types: string, time.Duration (as int64), []string
|
||||||
|
// (comma-separated), bool, int.
|
||||||
|
func applyEnvToStruct(v reflect.Value, prefix string) {
|
||||||
|
if v.Kind() == reflect.Ptr {
|
||||||
|
v = v.Elem()
|
||||||
|
}
|
||||||
|
t := v.Type()
|
||||||
|
|
||||||
|
for i := range t.NumField() {
|
||||||
|
field := t.Field(i)
|
||||||
|
fv := v.Field(i)
|
||||||
|
|
||||||
|
// For anonymous (embedded) fields, recurse with the same prefix.
|
||||||
|
if field.Anonymous {
|
||||||
|
applyEnvToStruct(fv, prefix)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
tag := field.Tag.Get("toml")
|
||||||
|
if tag == "" || tag == "-" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
envKey := prefix + "_" + strings.ToUpper(tag)
|
||||||
|
|
||||||
|
// Handle Duration wrapper before generic struct recursion.
|
||||||
|
if field.Type == reflect.TypeOf(Duration{}) {
|
||||||
|
envVal, ok := os.LookupEnv(envKey)
|
||||||
|
if ok {
|
||||||
|
d, parseErr := time.ParseDuration(envVal)
|
||||||
|
if parseErr == nil {
|
||||||
|
fv.Set(reflect.ValueOf(Duration{d}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if field.Type.Kind() == reflect.Struct {
|
||||||
|
applyEnvToStruct(fv, envKey)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
envVal, ok := os.LookupEnv(envKey)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch fv.Kind() {
|
||||||
|
case reflect.String:
|
||||||
|
fv.SetString(envVal)
|
||||||
|
case reflect.Bool:
|
||||||
|
fv.SetBool(envVal == "true" || envVal == "1")
|
||||||
|
case reflect.Slice:
|
||||||
|
if field.Type.Elem().Kind() == reflect.String {
|
||||||
|
parts := strings.Split(envVal, ",")
|
||||||
|
for j := range parts {
|
||||||
|
parts[j] = strings.TrimSpace(parts[j])
|
||||||
|
}
|
||||||
|
fv.Set(reflect.ValueOf(parts))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
403
config/config_test.go
Normal file
403
config/config_test.go
Normal file
@@ -0,0 +1,403 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
28
config/duration.go
Normal file
28
config/duration.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Duration is a time.Duration that can be unmarshalled from a TOML string
|
||||||
|
// (e.g., "30s", "5m"). go-toml v2 does not natively decode strings into
|
||||||
|
// time.Duration, so this wrapper implements encoding.TextUnmarshaler.
|
||||||
|
type Duration struct {
|
||||||
|
time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalText implements encoding.TextUnmarshaler for TOML string decoding.
|
||||||
|
func (d *Duration) UnmarshalText(text []byte) error {
|
||||||
|
parsed, err := time.ParseDuration(string(text))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid duration %q: %w", string(text), err)
|
||||||
|
}
|
||||||
|
d.Duration = parsed
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalText implements encoding.TextMarshaler for TOML string encoding.
|
||||||
|
func (d Duration) MarshalText() ([]byte, error) {
|
||||||
|
return []byte(d.String()), nil
|
||||||
|
}
|
||||||
5
go.mod
5
go.mod
@@ -2,7 +2,10 @@ module git.wntrmute.dev/kyle/mcdsl
|
|||||||
|
|
||||||
go 1.25.7
|
go 1.25.7
|
||||||
|
|
||||||
require modernc.org/sqlite v1.47.0
|
require (
|
||||||
|
github.com/pelletier/go-toml/v2 v2.3.0
|
||||||
|
modernc.org/sqlite v1.47.0
|
||||||
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
|||||||
2
go.sum
2
go.sum
@@ -10,6 +10,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
|
|||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||||
|
|||||||
Reference in New Issue
Block a user