package config import ( "os" "path/filepath" "testing" ) // TestLoadFailsOnMissingFile runs one orchestration or CLI step. // Signature: TestLoadFailsOnMissingFile(t *testing.T). // Why: covers file-read failure handling for config load path. func TestLoadFailsOnMissingFile(t *testing.T) { if _, err := Load(filepath.Join(t.TempDir(), "missing.yaml")); err == nil { t.Fatalf("expected missing file error") } } // TestLoadFailsOnBadYAML runs one orchestration or CLI step. // Signature: TestLoadFailsOnBadYAML(t *testing.T). // Why: covers YAML decode failure handling for config load path. func TestLoadFailsOnBadYAML(t *testing.T) { cfgPath := filepath.Join(t.TempDir(), "ananke.yaml") if err := os.WriteFile(cfgPath, []byte(":\n- invalid"), 0o644); err != nil { t.Fatalf("write bad config: %v", err) } if _, err := Load(cfgPath); err == nil { t.Fatalf("expected decode error") } } // TestLoadFailsValidationOnBrokenValues runs one orchestration or CLI step. // Signature: TestLoadFailsValidationOnBrokenValues(t *testing.T). // Why: covers Load->Validate error propagation. func TestLoadFailsValidationOnBrokenValues(t *testing.T) { cfgPath := filepath.Join(t.TempDir(), "ananke.yaml") raw := ` control_planes: [titan-0a] expected_flux_branch: main expected_flux_source_url: ssh://git@scm.bstein.dev:2242/bstein/titan-iac.git iac_repo_path: /opt/titan-iac startup: service_checklist: - name: x url: https://ok.example timeout_seconds: 0 ` if err := os.WriteFile(cfgPath, []byte(raw), 0o644); err != nil { t.Fatalf("write config: %v", err) } if _, err := Load(cfgPath); err == nil { t.Fatalf("expected validation failure") } } // TestLoadKeepsExplicitServiceChecklist runs one orchestration or CLI step. // Signature: TestLoadKeepsExplicitServiceChecklist(t *testing.T). // Why: host recovery configs must be able to keep a narrow, explicit checklist // without silently inheriting the full default service catalog. func TestLoadKeepsExplicitServiceChecklist(t *testing.T) { cfgPath := filepath.Join(t.TempDir(), "ananke.yaml") raw := ` control_planes: [titan-0a] expected_flux_branch: main expected_flux_source_url: ssh://git@scm.bstein.dev:2242/bstein/titan-iac.git iac_repo_path: /opt/titan-iac startup: service_checklist_explicit_only: true service_checklist: - name: gitea-api url: https://scm.bstein.dev/api/healthz accepted_statuses: [200] body_contains: pass timeout_seconds: 12 ups: enabled: false ` if err := os.WriteFile(cfgPath, []byte(raw), 0o644); err != nil { t.Fatalf("write config: %v", err) } cfg, err := Load(cfgPath) if err != nil { t.Fatalf("load config: %v", err) } if len(cfg.Startup.ServiceChecklist) != 1 { t.Fatalf("expected 1 explicit service check, got %d", len(cfg.Startup.ServiceChecklist)) } if cfg.Startup.ServiceChecklist[0].Name != "gitea-api" { t.Fatalf("expected explicit gitea-api check, got %q", cfg.Startup.ServiceChecklist[0].Name) } } // TestLoadWithFragmentsReplacesGeneratedInventory runs one orchestration or CLI step. // Signature: TestLoadWithFragmentsReplacesGeneratedInventory(t *testing.T). // Why: Terraform-generated inventory fragments must be authoritative for the // declarative bootstrap fields they own, without merging stale host config. func TestLoadWithFragmentsReplacesGeneratedInventory(t *testing.T) { tmp := t.TempDir() basePath := filepath.Join(tmp, "ananke.yaml") fragmentPath := filepath.Join(tmp, "ananke.inventory.yaml") base := ` control_planes: [old-cp] workers: [old-worker] ssh_user: atlas ssh_port: 2277 ssh_node_hosts: old-cp: 192.0.2.10 ssh_managed_nodes: [old-cp, old-worker] expected_flux_branch: main expected_flux_source_url: ssh://git@scm.bstein.dev:2242/bstein/titan-iac.git iac_repo_path: /opt/titan-iac startup: required_node_labels: old-worker: old-label: "true" ignore_unavailable_nodes: [old-worker] ups: enabled: false ` fragment := ` ssh_user: atlas ssh_port: 2277 ssh_config_file: /home/atlas/.ssh/config ssh_identity_file: /home/atlas/.ssh/id_ed25519 ssh_node_hosts: titan-0a: 192.168.22.11 titan-04: 192.168.22.30 ssh_node_users: titan-04: workeradmin ssh_managed_nodes: [titan-0a, titan-04] control_planes: [titan-0a] workers: [titan-04] startup: required_node_labels_mode: validate required_node_labels: titan-04: node-role.kubernetes.io/worker: "true" ignore_unavailable_nodes: [] ` if err := os.WriteFile(basePath, []byte(base), 0o644); err != nil { t.Fatalf("write base config: %v", err) } if err := os.WriteFile(fragmentPath, []byte(fragment), 0o644); err != nil { t.Fatalf("write fragment config: %v", err) } cfg, err := LoadWithFragments(basePath, fragmentPath) if err != nil { t.Fatalf("load config with fragment: %v", err) } if len(cfg.ControlPlanes) != 1 || cfg.ControlPlanes[0] != "titan-0a" { t.Fatalf("expected fragment control planes, got %v", cfg.ControlPlanes) } if _, ok := cfg.SSHNodeHosts["old-cp"]; ok { t.Fatalf("expected fragment ssh_node_hosts to replace base map, got %#v", cfg.SSHNodeHosts) } if cfg.SSHNodeUsers["titan-04"] != "workeradmin" { t.Fatalf("expected fragment ssh_node_users override, got %#v", cfg.SSHNodeUsers) } if _, ok := cfg.Startup.RequiredNodeLabels["old-worker"]; ok { t.Fatalf("expected fragment required_node_labels to replace base map, got %#v", cfg.Startup.RequiredNodeLabels) } if cfg.Startup.RequiredNodeLabelsMode != "validate" { t.Fatalf("expected fragment label mode validate, got %q", cfg.Startup.RequiredNodeLabelsMode) } if len(cfg.Startup.IgnoreUnavailableNodes) != 0 { t.Fatalf("expected fragment ignored unavailable nodes to replace base slice, got %v", cfg.Startup.IgnoreUnavailableNodes) } } // TestLoadWithFragmentsAllowsTopLevelInventoryOnly runs one orchestration or CLI step. // Signature: TestLoadWithFragmentsAllowsTopLevelInventoryOnly(t *testing.T). // Why: generated inventory fragments may omit startup labels during staged // rollout while still replacing host/node inventory. func TestLoadWithFragmentsAllowsTopLevelInventoryOnly(t *testing.T) { tmp := t.TempDir() basePath := filepath.Join(tmp, "ananke.yaml") fragmentPath := filepath.Join(tmp, "ananke.inventory.yaml") base := ` control_planes: [old-cp] workers: [old-worker] ssh_user: atlas ssh_port: 2277 ssh_node_hosts: old-cp: 192.0.2.10 ssh_managed_nodes: [old-cp, old-worker] expected_flux_branch: main expected_flux_source_url: ssh://git@scm.bstein.dev:2242/bstein/titan-iac.git iac_repo_path: /opt/titan-iac startup: required_node_labels_mode: enforce required_node_labels: old-worker: old-label: "true" ups: enabled: false ` fragment := ` ssh_node_hosts: titan-0a: 192.168.22.11 ssh_managed_nodes: [titan-0a] control_planes: [titan-0a] workers: [] ` if err := os.WriteFile(basePath, []byte(base), 0o644); err != nil { t.Fatalf("write base config: %v", err) } if err := os.WriteFile(fragmentPath, []byte(fragment), 0o644); err != nil { t.Fatalf("write fragment config: %v", err) } cfg, err := LoadWithFragments(basePath, fragmentPath) if err != nil { t.Fatalf("load config with top-level fragment: %v", err) } if len(cfg.ControlPlanes) != 1 || cfg.ControlPlanes[0] != "titan-0a" { t.Fatalf("expected top-level fragment control plane, got %v", cfg.ControlPlanes) } if _, ok := cfg.Startup.RequiredNodeLabels["old-worker"]; !ok { t.Fatalf("expected startup labels to remain when fragment omits startup, got %#v", cfg.Startup.RequiredNodeLabels) } if cfg.Startup.RequiredNodeLabelsMode != "enforce" { t.Fatalf("expected base label mode to remain enforce, got %q", cfg.Startup.RequiredNodeLabelsMode) } } // TestLoadWithFragmentsFailsOnMissingFragment runs one orchestration or CLI step. // Signature: TestLoadWithFragmentsFailsOnMissingFragment(t *testing.T). // Why: installer and systemd mistakes should fail closed instead of silently // running with stale embedded inventory. func TestLoadWithFragmentsFailsOnMissingFragment(t *testing.T) { cfgPath := filepath.Join(t.TempDir(), "ananke.yaml") raw := ` control_planes: [titan-0a] expected_flux_branch: main expected_flux_source_url: ssh://git@scm.bstein.dev:2242/bstein/titan-iac.git iac_repo_path: /opt/titan-iac ups: enabled: false ` if err := os.WriteFile(cfgPath, []byte(raw), 0o644); err != nil { t.Fatalf("write config: %v", err) } if _, err := LoadWithFragments(cfgPath, filepath.Join(t.TempDir(), "missing.yaml")); err == nil { t.Fatalf("expected missing fragment error") } } // TestLoadWithFragmentsFailsOnBadFragmentYAML runs one orchestration or CLI step. // Signature: TestLoadWithFragmentsFailsOnBadFragmentYAML(t *testing.T). // Why: malformed generated inventory must fail closed instead of running with // partially decoded declarative state. func TestLoadWithFragmentsFailsOnBadFragmentYAML(t *testing.T) { tmp := t.TempDir() cfgPath := filepath.Join(tmp, "ananke.yaml") fragmentPath := filepath.Join(tmp, "ananke.inventory.yaml") raw := ` control_planes: [titan-0a] expected_flux_branch: main expected_flux_source_url: ssh://git@scm.bstein.dev:2242/bstein/titan-iac.git iac_repo_path: /opt/titan-iac ups: enabled: false ` if err := os.WriteFile(cfgPath, []byte(raw), 0o644); err != nil { t.Fatalf("write config: %v", err) } if err := os.WriteFile(fragmentPath, []byte(":\n- invalid"), 0o644); err != nil { t.Fatalf("write bad fragment: %v", err) } if _, err := LoadWithFragments(cfgPath, fragmentPath); err == nil { t.Fatalf("expected bad fragment decode error") } }