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>
374 lines
9.7 KiB
Go
374 lines
9.7 KiB
Go
// 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.
|
|
//
|
|
// # Duration fields
|
|
//
|
|
// Timeout fields in [ServerConfig] use the [Duration] type rather than
|
|
// [time.Duration] because go-toml v2 does not natively decode strings
|
|
// (e.g., "30s") into time.Duration. Access the underlying value via
|
|
// the embedded field:
|
|
//
|
|
// cfg.Server.ReadTimeout.Duration // time.Duration
|
|
//
|
|
// In TOML files, durations are written as Go duration strings:
|
|
//
|
|
// read_timeout = "30s"
|
|
// idle_timeout = "2m"
|
|
//
|
|
// Environment variable overrides also use this format:
|
|
//
|
|
// MCR_SERVER_READ_TIMEOUT=30s
|
|
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)
|
|
}
|
|
|
|
applyPortEnv(&cfg)
|
|
|
|
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
|
|
}
|
|
|
|
// applyPortEnv overrides ServerConfig.ListenAddr and ServerConfig.GRPCAddr
|
|
// from $PORT and $PORT_GRPC respectively. These environment variables are
|
|
// set by the MCP agent to assign authoritative port bindings, so they take
|
|
// precedence over both TOML values and generic env overrides.
|
|
func applyPortEnv(cfg any) {
|
|
sc := findServerConfig(cfg)
|
|
if sc == nil {
|
|
return
|
|
}
|
|
|
|
if port, ok := os.LookupEnv("PORT"); ok {
|
|
sc.ListenAddr = ":" + port
|
|
}
|
|
if port, ok := os.LookupEnv("PORT_GRPC"); ok {
|
|
sc.GRPCAddr = ":" + port
|
|
}
|
|
}
|
|
|
|
// findServerConfig returns a pointer to the ServerConfig in the config
|
|
// struct. It first checks for an embedded Base (which contains Server),
|
|
// then walks the struct tree via reflection to find any ServerConfig field
|
|
// directly (e.g., the Metacrypt pattern where ServerConfig is embedded
|
|
// without Base).
|
|
func findServerConfig(cfg any) *ServerConfig {
|
|
if base := findBase(cfg); base != nil {
|
|
return &base.Server
|
|
}
|
|
|
|
return findServerConfigReflect(reflect.ValueOf(cfg))
|
|
}
|
|
|
|
// findServerConfigReflect walks the struct tree to find a ServerConfig field.
|
|
func findServerConfigReflect(v reflect.Value) *ServerConfig {
|
|
if v.Kind() == reflect.Ptr {
|
|
v = v.Elem()
|
|
}
|
|
if v.Kind() != reflect.Struct {
|
|
return nil
|
|
}
|
|
|
|
scType := reflect.TypeOf(ServerConfig{})
|
|
t := v.Type()
|
|
for i := range t.NumField() {
|
|
field := t.Field(i)
|
|
fv := v.Field(i)
|
|
|
|
if field.Type == scType {
|
|
sc, ok := fv.Addr().Interface().(*ServerConfig)
|
|
if ok {
|
|
return sc
|
|
}
|
|
}
|
|
|
|
// Recurse into embedded or nested structs.
|
|
if fv.Kind() == reflect.Struct && field.Type != scType {
|
|
if sc := findServerConfigReflect(fv); sc != nil {
|
|
return sc
|
|
}
|
|
}
|
|
}
|
|
|
|
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))
|
|
}
|
|
}
|
|
}
|
|
}
|