package cluster import ( "context" "log" "os" "path/filepath" "strings" "testing" "scm.bstein.dev/bstein/ananke/internal/config" "scm.bstein.dev/bstein/ananke/internal/execx" ) // TestValidateNodeInventoryPassesForStrictMappings runs one orchestration or CLI step. // Signature: TestValidateNodeInventoryPassesForStrictMappings(t *testing.T). // Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve. func TestValidateNodeInventoryPassesForStrictMappings(t *testing.T) { orch := &Orchestrator{ cfg: config.Config{ SSHUser: "atlas", SSHPort: 2277, SSHNodeHosts: map[string]string{ "titan-0a": "192.168.22.11", "titan-0b": "192.168.22.12", "titan-0c": "192.168.22.13", "titan-22": "192.168.22.22", }, SSHManagedNodes: []string{"titan-0a", "titan-0b", "titan-0c", "titan-22"}, ControlPlanes: []string{"titan-0a", "titan-0b", "titan-0c"}, Workers: []string{"titan-22"}, }, log: log.New(os.Stdout, "", 0), } if err := orch.validateNodeInventory(); err != nil { t.Fatalf("expected inventory to pass, got error: %v", err) } } // TestValidateNodeInventoryFailsWhenNodeMappingMissing runs one orchestration or CLI step. // Signature: TestValidateNodeInventoryFailsWhenNodeMappingMissing(t *testing.T). // Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve. func TestValidateNodeInventoryFailsWhenNodeMappingMissing(t *testing.T) { orch := &Orchestrator{ cfg: config.Config{ SSHUser: "atlas", SSHPort: 2277, SSHNodeHosts: map[string]string{"titan-0a": "192.168.22.11"}, SSHManagedNodes: []string{"titan-0a", "titan-0b"}, ControlPlanes: []string{"titan-0a"}, Workers: []string{"titan-0b"}, }, log: log.New(os.Stdout, "", 0), } err := orch.validateNodeInventory() if err == nil { t.Fatalf("expected inventory error for missing mapping") } if !strings.Contains(err.Error(), "missing ssh_node_hosts entry") { t.Fatalf("expected missing-mapping detail, got: %v", err) } } // TestValidateNodeInventoryFailsWhenWorkerNotManaged runs one orchestration or CLI step. // Signature: TestValidateNodeInventoryFailsWhenWorkerNotManaged(t *testing.T). // Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve. func TestValidateNodeInventoryFailsWhenWorkerNotManaged(t *testing.T) { orch := &Orchestrator{ cfg: config.Config{ SSHUser: "atlas", SSHPort: 2277, SSHNodeHosts: map[string]string{ "titan-0a": "192.168.22.11", "titan-22": "192.168.22.22", }, SSHManagedNodes: []string{"titan-0a"}, ControlPlanes: []string{"titan-0a"}, Workers: []string{"titan-22"}, }, log: log.New(os.Stdout, "", 0), } err := orch.validateNodeInventory() if err == nil { t.Fatalf("expected inventory error for unmanaged worker") } if !strings.Contains(err.Error(), "missing from ssh_managed_nodes") { t.Fatalf("expected unmanaged-worker detail, got: %v", err) } } // TestNormalizeShutdownModeRejectsPoweroff runs one orchestration or CLI step. // Signature: TestNormalizeShutdownModeRejectsPoweroff(t *testing.T). // Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve. func TestNormalizeShutdownModeRejectsPoweroff(t *testing.T) { if _, err := normalizeShutdownMode("poweroff"); err == nil { t.Fatalf("expected poweroff mode to be rejected") } if mode, err := normalizeShutdownMode("config"); err != nil || mode != "cluster-only" { t.Fatalf("expected config mode to normalize to cluster-only, mode=%q err=%v", mode, err) } } // TestWaitForNodeInventoryReachabilityPasses runs one orchestration or CLI step. // Signature: TestWaitForNodeInventoryReachabilityPasses(t *testing.T). // Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve. func TestWaitForNodeInventoryReachabilityPasses(t *testing.T) { tmp := t.TempDir() sshPath := filepath.Join(tmp, "ssh") sshScript := `#!/usr/bin/env bash set -euo pipefail target="" for arg in "$@"; do case "$arg" in *@*) target="$arg" ;; esac done if [[ "$target" == *"titan-0a"* ]] || [[ "$target" == *"titan-0b"* ]]; then echo "__ANANKE_NODE_REACHABLE__" exit 0 fi echo "no route to host" >&2 exit 255 ` if err := os.WriteFile(sshPath, []byte(sshScript), 0o755); err != nil { t.Fatalf("write fake ssh: %v", err) } t.Setenv("PATH", tmp+":"+os.Getenv("PATH")) orch := &Orchestrator{ cfg: config.Config{ SSHUser: "atlas", SSHPort: 2277, ControlPlanes: []string{"titan-0a"}, Workers: []string{"titan-0b"}, SSHManagedNodes: []string{"titan-0a", "titan-0b"}, Startup: config.Startup{ RequireNodeInventoryReach: true, NodeInventoryReachWaitSeconds: 2, NodeInventoryReachPollSeconds: 1, IgnoreUnavailableNodes: []string{}, }, }, runner: &execx.Runner{DryRun: false}, log: log.New(os.Stdout, "", 0), } if err := orch.waitForNodeInventoryReachability(context.Background()); err != nil { t.Fatalf("expected node inventory reachability to pass, got: %v", err) } } // TestWaitForNodeInventoryReachabilityFails runs one orchestration or CLI step. // Signature: TestWaitForNodeInventoryReachabilityFails(t *testing.T). // Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve. func TestWaitForNodeInventoryReachabilityFails(t *testing.T) { tmp := t.TempDir() sshPath := filepath.Join(tmp, "ssh") sshScript := `#!/usr/bin/env bash set -euo pipefail echo "no route to host" >&2 exit 255 ` if err := os.WriteFile(sshPath, []byte(sshScript), 0o755); err != nil { t.Fatalf("write fake ssh: %v", err) } t.Setenv("PATH", tmp+":"+os.Getenv("PATH")) orch := &Orchestrator{ cfg: config.Config{ SSHUser: "atlas", SSHPort: 2277, ControlPlanes: []string{"titan-0a"}, SSHManagedNodes: []string{"titan-0a"}, Startup: config.Startup{ RequireNodeInventoryReach: true, NodeInventoryReachWaitSeconds: 1, NodeInventoryReachPollSeconds: 1, }, }, runner: &execx.Runner{DryRun: false}, log: log.New(os.Stdout, "", 0), } err := orch.waitForNodeInventoryReachability(context.Background()) if err == nil { t.Fatalf("expected node inventory reachability to fail") } if !strings.Contains(err.Error(), "did not pass within") { t.Fatalf("expected timeout detail in error, got: %v", err) } }