package config import ( "fmt" "os" "gopkg.in/yaml.v3" ) type inventoryFragment struct { SSHUser *string `yaml:"ssh_user"` SSHPort *int `yaml:"ssh_port"` SSHConfigFile *string `yaml:"ssh_config_file"` SSHIdentityFile *string `yaml:"ssh_identity_file"` SSHNodeHosts *map[string]string `yaml:"ssh_node_hosts"` SSHNodeUsers *map[string]string `yaml:"ssh_node_users"` SSHManagedNodes *[]string `yaml:"ssh_managed_nodes"` ControlPlanes *[]string `yaml:"control_planes"` Workers *[]string `yaml:"workers"` Startup *struct { RequiredNodeLabels *map[string]map[string]string `yaml:"required_node_labels"` RequiredNodeLabelsMode *string `yaml:"required_node_labels_mode"` IgnoreUnavailableNodes *[]string `yaml:"ignore_unavailable_nodes"` } `yaml:"startup"` } // Load runs one orchestration or CLI step. // Signature: Load(path string) (Config, error). // Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve. func Load(path string) (Config, error) { return LoadWithFragments(path) } // LoadWithFragments runs one orchestration or CLI step. // Signature: LoadWithFragments(path string, fragments ...string) (Config, error). // Why: Terraform can generate a narrow inventory fragment while Ananke keeps // loading the ordinary host runtime config for imperative recovery behavior. func LoadWithFragments(path string, fragments ...string) (Config, error) { cfg := defaults() b, err := os.ReadFile(path) if err != nil { return Config{}, fmt.Errorf("read config %s: %w", path, err) } startupProbeRepairConfigured, err := yamlPathExists(b, "startup", "critical_service_startup_probe_repair") if err != nil { return Config{}, fmt.Errorf("decode config %s: %w", path, err) } if err := yaml.Unmarshal(b, &cfg); err != nil { return Config{}, fmt.Errorf("decode config %s: %w", path, err) } for _, fragment := range fragments { fb, err := os.ReadFile(fragment) if err != nil { return Config{}, fmt.Errorf("read config fragment %s: %w", fragment, err) } if err := applyInventoryFragment(fb, &cfg); err != nil { return Config{}, fmt.Errorf("decode config fragment %s: %w", fragment, err) } } cfg.applyDefaults() if !startupProbeRepairConfigured { cfg.Startup.CriticalServiceStartupProbeRepair = defaults().Startup.CriticalServiceStartupProbeRepair } if err := cfg.Validate(); err != nil { return Config{}, err } return cfg, nil } // applyInventoryFragment runs one orchestration or CLI step. // Signature: applyInventoryFragment(raw []byte, cfg *Config) error. // Why: restricts generated Terraform fragments to declarative inventory fields // so they cannot accidentally take over Ananke's recovery behavior. func applyInventoryFragment(raw []byte, cfg *Config) error { var fragment inventoryFragment if err := yaml.Unmarshal(raw, &fragment); err != nil { return err } if fragment.SSHUser != nil { cfg.SSHUser = *fragment.SSHUser } if fragment.SSHPort != nil { cfg.SSHPort = *fragment.SSHPort } if fragment.SSHConfigFile != nil { cfg.SSHConfigFile = *fragment.SSHConfigFile } if fragment.SSHIdentityFile != nil { cfg.SSHIdentityFile = *fragment.SSHIdentityFile } if fragment.SSHNodeHosts != nil { cfg.SSHNodeHosts = *fragment.SSHNodeHosts } if fragment.SSHNodeUsers != nil { cfg.SSHNodeUsers = *fragment.SSHNodeUsers } if fragment.SSHManagedNodes != nil { cfg.SSHManagedNodes = *fragment.SSHManagedNodes } if fragment.ControlPlanes != nil { cfg.ControlPlanes = *fragment.ControlPlanes } if fragment.Workers != nil { cfg.Workers = *fragment.Workers } if fragment.Startup == nil { return nil } if fragment.Startup.RequiredNodeLabels != nil { cfg.Startup.RequiredNodeLabels = *fragment.Startup.RequiredNodeLabels } if fragment.Startup.RequiredNodeLabelsMode != nil { cfg.Startup.RequiredNodeLabelsMode = *fragment.Startup.RequiredNodeLabelsMode } if fragment.Startup.IgnoreUnavailableNodes != nil { cfg.Startup.IgnoreUnavailableNodes = *fragment.Startup.IgnoreUnavailableNodes } return nil } // yamlPathExists runs one orchestration or CLI step. // Signature: yamlPathExists(raw []byte, path ...string) (bool, error). // Why: selected boolean defaults need to distinguish an omitted YAML key from an // explicit false value after nested startup mappings are decoded. func yamlPathExists(raw []byte, path ...string) (bool, error) { var root yaml.Node if err := yaml.Unmarshal(raw, &root); err != nil { return false, err } node := &root if node.Kind == yaml.DocumentNode && len(node.Content) > 0 { node = node.Content[0] } for _, key := range path { if node.Kind != yaml.MappingNode { return false, nil } found := false for i := 0; i+1 < len(node.Content); i += 2 { if node.Content[i].Value == key { node = node.Content[i+1] found = true break } } if !found { return false, nil } } return true, nil }