package config import ( "fmt" "os" "path/filepath" "reflect" "strings" "time" "github.com/pelletier/go-toml/v2" ) // Config is the top-level MCR configuration. type Config struct { Server ServerConfig `toml:"server"` Database DatabaseConfig `toml:"database"` Storage StorageConfig `toml:"storage"` MCIAS MCIASConfig `toml:"mcias"` Web WebConfig `toml:"web"` Log LogConfig `toml:"log"` } type ServerConfig struct { ListenAddr string `toml:"listen_addr"` GRPCAddr string `toml:"grpc_addr"` TLSCert string `toml:"tls_cert"` TLSKey string `toml:"tls_key"` ReadTimeout time.Duration `toml:"read_timeout"` WriteTimeout time.Duration `toml:"write_timeout"` IdleTimeout time.Duration `toml:"idle_timeout"` ShutdownTimeout time.Duration `toml:"shutdown_timeout"` } type DatabaseConfig struct { Path string `toml:"path"` } type StorageConfig struct { LayersPath string `toml:"layers_path"` UploadsPath string `toml:"uploads_path"` } type MCIASConfig struct { ServerURL string `toml:"server_url"` CACert string `toml:"ca_cert"` ServiceName string `toml:"service_name"` Tags []string `toml:"tags"` } type WebConfig struct { ListenAddr string `toml:"listen_addr"` GRPCAddr string `toml:"grpc_addr"` CACert string `toml:"ca_cert"` } type LogConfig struct { Level string `toml:"level"` } // Load reads a TOML config file, applies environment variable overrides, // sets defaults, and validates required fields. func Load(path string) (*Config, error) { data, err := os.ReadFile(path) //nolint:gosec // config path is operator-supplied, not user input if err != nil { return nil, fmt.Errorf("config: read %s: %w", path, err) } var cfg Config if err := toml.Unmarshal(data, &cfg); err != nil { return nil, fmt.Errorf("config: parse %s: %w", path, err) } applyEnvOverrides(&cfg) applyDefaults(&cfg) if err := validate(&cfg); err != nil { return nil, err } return &cfg, nil } func applyDefaults(cfg *Config) { if cfg.Server.ReadTimeout == 0 { cfg.Server.ReadTimeout = 30 * time.Second } // WriteTimeout defaults to 0 (disabled) — no action needed. if cfg.Server.IdleTimeout == 0 { cfg.Server.IdleTimeout = 120 * time.Second } if cfg.Server.ShutdownTimeout == 0 { cfg.Server.ShutdownTimeout = 60 * time.Second } if cfg.Storage.UploadsPath == "" && cfg.Storage.LayersPath != "" { cfg.Storage.UploadsPath = filepath.Join(filepath.Dir(cfg.Storage.LayersPath), "uploads") } if cfg.Log.Level == "" { cfg.Log.Level = "info" } } func validate(cfg *Config) error { required := []struct { name string value string }{ {"server.listen_addr", cfg.Server.ListenAddr}, {"server.tls_cert", cfg.Server.TLSCert}, {"server.tls_key", cfg.Server.TLSKey}, {"database.path", cfg.Database.Path}, {"storage.layers_path", cfg.Storage.LayersPath}, {"mcias.server_url", cfg.MCIAS.ServerURL}, } for _, r := range required { if r.value == "" { return fmt.Errorf("config: required field %q is missing", r.name) } } return validateSameFilesystem(cfg.Storage.LayersPath, cfg.Storage.UploadsPath) } // validateSameFilesystem checks that two paths reside on the same filesystem // by comparing device IDs. If either path does not exist yet, it checks the // nearest existing parent directory. func validateSameFilesystem(layersPath, uploadsPath string) error { layersDev, err := deviceID(layersPath) if err != nil { return fmt.Errorf("config: stat layers_path: %w", err) } uploadsDev, err := deviceID(uploadsPath) if err != nil { return fmt.Errorf("config: stat uploads_path: %w", err) } if layersDev != uploadsDev { return fmt.Errorf("config: storage.layers_path and storage.uploads_path must be on the same filesystem") } return nil } // deviceID returns the device ID for the given path. If the path does not // exist, it walks up to the nearest existing parent. func deviceID(path string) (uint64, error) { p := filepath.Clean(path) for { info, err := os.Stat(p) if err == nil { return extractDeviceID(info) } if !os.IsNotExist(err) { return 0, err } parent := filepath.Dir(p) if parent == p { return 0, fmt.Errorf("no existing parent for %s", path) } p = parent } } // applyEnvOverrides walks the Config struct and overrides fields from // environment variables with the MCR_ prefix. For example, // MCR_SERVER_LISTEN_ADDR overrides Config.Server.ListenAddr. func applyEnvOverrides(cfg *Config) { applyEnvToStruct(reflect.ValueOf(cfg).Elem(), "MCR") } func applyEnvToStruct(v reflect.Value, prefix string) { t := v.Type() for i := range t.NumField() { field := t.Field(i) fv := v.Field(i) tag := field.Tag.Get("toml") if tag == "" || tag == "-" { continue } envKey := prefix + "_" + strings.ToUpper(tag) 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.Int64: if field.Type == reflect.TypeOf(time.Duration(0)) { d, err := time.ParseDuration(envVal) if err == nil { fv.Set(reflect.ValueOf(d)) } } case reflect.Slice: if field.Type.Elem().Kind() == reflect.String { parts := strings.Split(envVal, ",") fv.Set(reflect.ValueOf(parts)) } } } }