package config import ( "fmt" "os" "gopkg.in/yaml.v3" ) type Config struct { Kubeconfig string `yaml:"kubeconfig"` SSHUser string `yaml:"ssh_user"` IACRepoPath string `yaml:"iac_repo_path"` ExpectedFluxBranch string `yaml:"expected_flux_branch"` ControlPlanes []string `yaml:"control_planes"` Workers []string `yaml:"workers"` LocalBootstrapPaths []string `yaml:"local_bootstrap_paths"` ExcludedNamespaces []string `yaml:"excluded_namespaces"` Shutdown Shutdown `yaml:"shutdown"` UPS UPS `yaml:"ups"` Coordination Coordination `yaml:"coordination"` Metrics Metrics `yaml:"metrics"` State State `yaml:"state"` } type Shutdown struct { DefaultBudgetSeconds int `yaml:"default_budget_seconds"` SkipEtcdSnapshot bool `yaml:"skip_etcd_snapshot"` SkipDrain bool `yaml:"skip_drain"` PoweroffEnabled bool `yaml:"poweroff_enabled"` PoweroffDelaySeconds int `yaml:"poweroff_delay_seconds"` PoweroffLocalHost bool `yaml:"poweroff_local_host"` ExtraPoweroffHosts []string `yaml:"extra_poweroff_hosts"` } type UPS struct { Enabled bool `yaml:"enabled"` Provider string `yaml:"provider"` Target string `yaml:"target"` Targets []UPSTarget `yaml:"targets"` PollSeconds int `yaml:"poll_seconds"` RuntimeSafetyFactor float64 `yaml:"runtime_safety_factor"` DebounceCount int `yaml:"debounce_count"` TelemetryTimeoutSeconds int `yaml:"telemetry_timeout_seconds"` } type UPSTarget struct { Name string `yaml:"name"` Target string `yaml:"target"` } type Coordination struct { ForwardShutdownHost string `yaml:"forward_shutdown_host"` ForwardShutdownUser string `yaml:"forward_shutdown_user"` ForwardShutdownConfig string `yaml:"forward_shutdown_config"` FallbackLocalShutdown bool `yaml:"fallback_local_shutdown"` CommandTimeoutSeconds int `yaml:"command_timeout_seconds"` } type Metrics struct { Enabled bool `yaml:"enabled"` BindAddr string `yaml:"bind_addr"` Path string `yaml:"path"` } type State struct { Dir string `yaml:"dir"` RunHistoryPath string `yaml:"run_history_path"` LockPath string `yaml:"lock_path"` } func Load(path string) (Config, error) { cfg := defaults() b, err := os.ReadFile(path) if err != nil { return Config{}, fmt.Errorf("read config %s: %w", path, err) } if err := yaml.Unmarshal(b, &cfg); err != nil { return Config{}, fmt.Errorf("decode config %s: %w", path, err) } cfg.applyDefaults() if err := cfg.Validate(); err != nil { return Config{}, err } return cfg, nil } func (c Config) Validate() error { if len(c.ControlPlanes) == 0 { return fmt.Errorf("config.control_planes must not be empty") } if c.ExpectedFluxBranch == "" { return fmt.Errorf("config.expected_flux_branch must not be empty") } if c.IACRepoPath == "" { return fmt.Errorf("config.iac_repo_path must not be empty") } if c.Shutdown.DefaultBudgetSeconds <= 0 { return fmt.Errorf("config.shutdown.default_budget_seconds must be > 0") } if c.UPS.Enabled { if c.UPS.Provider == "" { return fmt.Errorf("config.ups.provider must not be empty when ups is enabled") } if c.UPS.Target == "" && len(c.UPS.Targets) == 0 { return fmt.Errorf("config.ups.target or config.ups.targets must be set when ups is enabled") } for _, t := range c.UPS.Targets { if t.Target == "" { return fmt.Errorf("config.ups.targets[].target must not be empty") } } } if c.Coordination.ForwardShutdownHost != "" { if c.Coordination.ForwardShutdownConfig == "" { return fmt.Errorf("config.coordination.forward_shutdown_config must not be empty when forward_shutdown_host is set") } } if c.State.RunHistoryPath == "" || c.State.LockPath == "" { return fmt.Errorf("config.state.run_history_path and config.state.lock_path must not be empty") } return nil } func defaults() Config { c := Config{ IACRepoPath: "/opt/titan-iac", ExpectedFluxBranch: "main", ControlPlanes: []string{"titan-0a", "titan-0b", "titan-0c"}, LocalBootstrapPaths: []string{ "infrastructure/core", "infrastructure/flux-system", "infrastructure/sources/helm", "infrastructure/metallb", "infrastructure/traefik", "infrastructure/vault-csi", "infrastructure/vault-injector", "services/vault", "infrastructure/postgres", "services/gitea", }, ExcludedNamespaces: []string{ "kube-system", "kube-public", "kube-node-lease", "flux-system", "traefik", "metallb-system", "cert-manager", "longhorn-system", "vault", "postgres", "maintenance", }, Shutdown: Shutdown{ DefaultBudgetSeconds: 300, PoweroffEnabled: true, PoweroffDelaySeconds: 25, PoweroffLocalHost: true, }, UPS: UPS{ Enabled: true, Provider: "nut", PollSeconds: 5, RuntimeSafetyFactor: 1.10, DebounceCount: 3, TelemetryTimeoutSeconds: 90, }, Coordination: Coordination{ ForwardShutdownConfig: "/etc/hecate/hecate.yaml", FallbackLocalShutdown: true, CommandTimeoutSeconds: 25, }, Metrics: Metrics{ Enabled: true, BindAddr: "0.0.0.0:9560", Path: "/metrics", }, State: State{ Dir: "/var/lib/hecate", RunHistoryPath: "/var/lib/hecate/runs.json", LockPath: "/var/lib/hecate/hecate.lock", }, } c.applyDefaults() return c } func (c *Config) applyDefaults() { if c.ExpectedFluxBranch == "" { c.ExpectedFluxBranch = "main" } if c.IACRepoPath == "" { c.IACRepoPath = "/opt/titan-iac" } if c.Shutdown.DefaultBudgetSeconds <= 0 { c.Shutdown.DefaultBudgetSeconds = 300 } if c.Shutdown.PoweroffDelaySeconds <= 0 { c.Shutdown.PoweroffDelaySeconds = 25 } if c.UPS.PollSeconds <= 0 { c.UPS.PollSeconds = 5 } if c.UPS.RuntimeSafetyFactor <= 0 { c.UPS.RuntimeSafetyFactor = 1.10 } if c.UPS.DebounceCount <= 0 { c.UPS.DebounceCount = 3 } if c.UPS.TelemetryTimeoutSeconds <= 0 { c.UPS.TelemetryTimeoutSeconds = 90 } if c.Coordination.ForwardShutdownConfig == "" { c.Coordination.ForwardShutdownConfig = "/etc/hecate/hecate.yaml" } if c.Coordination.CommandTimeoutSeconds <= 0 { c.Coordination.CommandTimeoutSeconds = 25 } if c.Metrics.BindAddr == "" { c.Metrics.BindAddr = "0.0.0.0:9560" } if c.Metrics.Path == "" { c.Metrics.Path = "/metrics" } if c.State.Dir == "" { c.State.Dir = "/var/lib/hecate" } if c.State.RunHistoryPath == "" { c.State.RunHistoryPath = "/var/lib/hecate/runs.json" } if c.State.LockPath == "" { c.State.LockPath = "/var/lib/hecate/hecate.lock" } }