2026-04-08 23:52:29 -03:00
|
|
|
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")
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-05 05:17:59 -03:00
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-16 02:27:44 -03:00
|
|
|
|
|
|
|
|
// 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")
|
|
|
|
|
}
|
|
|
|
|
}
|