build: reconcile split modules and restore clean checkout integrity

This commit is contained in:
Brad Stein 2026-04-08 23:52:29 -03:00
parent 95fefba244
commit baead1426e
91 changed files with 22138 additions and 6041 deletions

View File

@ -0,0 +1,259 @@
package main
import (
"context"
"fmt"
"log"
"os"
"os/exec"
"strconv"
"strings"
"time"
"scm.bstein.dev/bstein/ananke/internal/config"
"scm.bstein.dev/bstein/ananke/internal/sshutil"
"scm.bstein.dev/bstein/ananke/internal/state"
)
var (
sshConfigCandidates = []string{
"/home/atlas/.ssh/config",
"/home/tethys/.ssh/config",
}
sshIdentityCandidates = []string{
"/home/atlas/.ssh/id_ed25519",
"/home/tethys/.ssh/id_ed25519",
}
)
// tryPeerBootstrapHandoff runs one orchestration or CLI step.
// Signature: tryPeerBootstrapHandoff(ctx context.Context, cfg config.Config, logger *log.Logger) (bool, error).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func tryPeerBootstrapHandoff(ctx context.Context, cfg config.Config, logger *log.Logger) (bool, error) {
coordinator := strings.TrimSpace(cfg.Coordination.ForwardShutdownHost)
if coordinator == "" {
return false, fmt.Errorf("coordination.forward_shutdown_host is empty for peer role")
}
user := strings.TrimSpace(cfg.Coordination.ForwardShutdownUser)
if user == "" {
if override, ok := cfg.SSHNodeUsers[coordinator]; ok && strings.TrimSpace(override) != "" {
user = strings.TrimSpace(override)
} else {
user = strings.TrimSpace(cfg.SSHUser)
}
}
host := coordinator
if mapped, ok := cfg.SSHNodeHosts[coordinator]; ok && strings.TrimSpace(mapped) != "" {
host = strings.TrimSpace(mapped)
}
target := host
if user != "" {
target = user + "@" + host
}
args := buildSSHBaseArgs(cfg)
remote := "sudo -n systemctl start ananke-bootstrap.service"
attempt := 1
for {
cmdArgs := append(append([]string{}, args...), target, remote)
_, err := runSSHWithRecovery(ctx, logger, cfg, cmdArgs, []string{coordinator, host, cfg.SSHJumpHost})
if err == nil {
logger.Printf("peer bootstrap handoff succeeded on %s (attempt=%d)", coordinator, attempt)
return true, nil
}
logger.Printf("peer bootstrap handoff attempt %d failed for %s: %v", attempt, coordinator, err)
select {
case <-ctx.Done():
return false, fmt.Errorf("coordinator handoff timeout for %s: %w", coordinator, ctx.Err())
case <-time.After(5 * time.Second):
attempt++
}
}
}
// coordinatorAllowsPeerFallbackStartup runs one orchestration or CLI step.
// Signature: coordinatorAllowsPeerFallbackStartup(ctx context.Context, cfg config.Config, logger *log.Logger) (bool, string, error).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func coordinatorAllowsPeerFallbackStartup(ctx context.Context, cfg config.Config, logger *log.Logger) (bool, string, error) {
coordinator := strings.TrimSpace(cfg.Coordination.ForwardShutdownHost)
if coordinator == "" {
return true, "no coordinator configured", nil
}
user := strings.TrimSpace(cfg.Coordination.ForwardShutdownUser)
if user == "" {
if override, ok := cfg.SSHNodeUsers[coordinator]; ok && strings.TrimSpace(override) != "" {
user = strings.TrimSpace(override)
} else {
user = strings.TrimSpace(cfg.SSHUser)
}
}
host := coordinator
if mapped, ok := cfg.SSHNodeHosts[coordinator]; ok && strings.TrimSpace(mapped) != "" {
host = strings.TrimSpace(mapped)
}
target := host
if user != "" {
target = user + "@" + host
}
remoteCmd := "if sudo -n /usr/bin/systemctl is-active --quiet ananke-bootstrap.service; then echo __ANANKE_BOOTSTRAP_ACTIVE__; else echo __ANANKE_BOOTSTRAP_IDLE__; fi; sudo -n /usr/local/bin/ananke intent --config /etc/ananke/ananke.yaml"
args := append(buildSSHBaseArgs(cfg), target, remoteCmd)
out, err := runSSHWithRecovery(ctx, logger, cfg, args, []string{coordinator, host, cfg.SSHJumpHost})
if err != nil {
logger.Printf("warning: coordinator guard check unavailable on %s: %v; allowing peer fallback startup", coordinator, err)
return true, "coordinator unreachable", nil
}
trimmed := strings.TrimSpace(out)
if strings.Contains(trimmed, "__ANANKE_BOOTSTRAP_ACTIVE__") {
return false, "coordinator bootstrap service is active", nil
}
remoteIntent, parseErr := state.ParseIntentOutput(trimmed)
if parseErr != nil {
return false, "", fmt.Errorf("decode coordinator intent: %w", parseErr)
}
if remoteIntent.State == "" || remoteIntent.State == state.IntentNormal {
return true, "coordinator intent is normal", nil
}
guardAge := time.Duration(maxInt(cfg.Coordination.StartupGuardMaxAgeSec, 60)) * time.Second
intentAge := time.Duration(0)
if !remoteIntent.UpdatedAt.IsZero() {
intentAge = time.Since(remoteIntent.UpdatedAt)
}
switch remoteIntent.State {
case state.IntentShuttingDown:
if remoteIntent.UpdatedAt.IsZero() || intentAge <= guardAge {
return false, fmt.Sprintf("coordinator intent=%s age=%s reason=%q", remoteIntent.State, intentAge.Round(time.Second), remoteIntent.Reason), nil
}
logger.Printf("warning: coordinator shutdown intent appears stale (age=%s > guard=%s); allowing peer fallback startup", intentAge.Round(time.Second), guardAge)
return true, "coordinator shutdown intent stale", nil
case state.IntentStartupInProgress:
if remoteIntent.UpdatedAt.IsZero() || intentAge <= guardAge {
return false, fmt.Sprintf("coordinator intent=%s age=%s reason=%q", remoteIntent.State, intentAge.Round(time.Second), remoteIntent.Reason), nil
}
logger.Printf("warning: coordinator startup intent appears stale (age=%s > guard=%s); allowing peer fallback startup", intentAge.Round(time.Second), guardAge)
return true, "coordinator startup intent stale", nil
case state.IntentShutdownComplete:
if remoteIntent.UpdatedAt.IsZero() {
return false, "coordinator reported shutdown_complete with unknown age", nil
}
if intentAge <= startupShutdownCooldown(cfg) {
return false, fmt.Sprintf("coordinator recently completed shutdown (%s ago)", intentAge.Round(time.Second)), nil
}
return true, "coordinator shutdown_complete is old enough", nil
default:
return false, fmt.Sprintf("coordinator intent state %q is unknown", remoteIntent.State), nil
}
}
// runSSHWithRecovery runs one orchestration or CLI step.
// Signature: runSSHWithRecovery(ctx context.Context, logger *log.Logger, cfg config.Config, args []string, repairHosts []string) (string, error).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func runSSHWithRecovery(ctx context.Context, logger *log.Logger, cfg config.Config, args []string, repairHosts []string) (string, error) {
try := func() (string, error) {
cmd := exec.CommandContext(ctx, "ssh", args...)
out, err := cmd.CombinedOutput()
trimmed := strings.TrimSpace(string(out))
if err != nil {
if trimmed == "" {
return "", fmt.Errorf("ssh failed: %w", err)
}
return trimmed, fmt.Errorf("ssh failed: %w: %s", err, trimmed)
}
return trimmed, nil
}
out, err := try()
if err == nil {
return out, nil
}
if !sshutil.ShouldAttemptKnownHostsRepair(out, err) {
return out, err
}
sshutil.RepairKnownHosts(ctx, logger, sshutil.KnownHostsFiles(resolveSSHConfigFile(cfg), resolveSSHIdentityFile(cfg)), repairHosts, cfg.SSHPort)
return try()
}
// buildSSHBaseArgs runs one orchestration or CLI step.
// Signature: buildSSHBaseArgs(cfg config.Config) []string.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func buildSSHBaseArgs(cfg config.Config) []string {
args := []string{
"-o", "BatchMode=yes",
"-o", "ConnectTimeout=8",
"-o", "StrictHostKeyChecking=accept-new",
}
if cfgPath := resolveSSHConfigFile(cfg); cfgPath != "" {
args = append(args, "-F", cfgPath)
}
if idPath := resolveSSHIdentityFile(cfg); idPath != "" {
args = append(args, "-i", idPath)
}
if cfg.SSHPort > 0 {
args = append(args, "-p", strconv.Itoa(cfg.SSHPort))
}
if cfg.SSHJumpHost != "" {
jump := cfg.SSHJumpHost
if cfg.SSHJumpUser != "" {
jump = cfg.SSHJumpUser + "@" + jump
}
if cfg.SSHPort > 0 && !strings.Contains(jump, ":") {
jump = fmt.Sprintf("%s:%d", jump, cfg.SSHPort)
}
args = append(args, "-J", jump)
}
return args
}
// resolveSSHConfigFile runs one orchestration or CLI step.
// Signature: resolveSSHConfigFile(cfg config.Config) string.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func resolveSSHConfigFile(cfg config.Config) string {
if strings.TrimSpace(cfg.SSHConfigFile) != "" {
return strings.TrimSpace(cfg.SSHConfigFile)
}
for _, p := range sshConfigCandidates {
if stat, err := os.Stat(p); err == nil && !stat.IsDir() {
return p
}
}
return ""
}
// resolveSSHIdentityFile runs one orchestration or CLI step.
// Signature: resolveSSHIdentityFile(cfg config.Config) string.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func resolveSSHIdentityFile(cfg config.Config) string {
if strings.TrimSpace(cfg.SSHIdentityFile) != "" {
return strings.TrimSpace(cfg.SSHIdentityFile)
}
for _, p := range sshIdentityCandidates {
if stat, err := os.Stat(p); err == nil && !stat.IsDir() {
return p
}
}
return ""
}
// maxInt runs one orchestration or CLI step.
// Signature: maxInt(a, b int) int.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
// startupShutdownCooldown runs one orchestration or CLI step.
// Signature: startupShutdownCooldown(cfg config.Config) time.Duration.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func startupShutdownCooldown(cfg config.Config) time.Duration {
seconds := cfg.Startup.ShutdownCooldownSeconds
if seconds <= 0 {
seconds = 45
}
return time.Duration(seconds) * time.Second
}

View File

@ -0,0 +1,360 @@
package main
import (
"context"
"io"
"log"
"os"
"path/filepath"
"strings"
"testing"
"time"
"scm.bstein.dev/bstein/ananke/internal/config"
)
// writeFakeSSH runs one orchestration or CLI step.
// Signature: writeFakeSSH(t *testing.T, script string) string.
// Why: peer handoff tests need deterministic SSH behavior without external hosts.
func writeFakeSSH(t *testing.T, script string) string {
t.Helper()
tmp := t.TempDir()
sshPath := filepath.Join(tmp, "ssh")
if err := os.WriteFile(sshPath, []byte(script), 0o755); err != nil {
t.Fatalf("write fake ssh: %v", err)
}
t.Setenv("PATH", tmp+":"+os.Getenv("PATH"))
return sshPath
}
// TestTryPeerBootstrapHandoffSuccess runs one orchestration or CLI step.
// Signature: TestTryPeerBootstrapHandoffSuccess(t *testing.T).
// Why: covers successful peer bootstrap handoff flow.
func TestTryPeerBootstrapHandoffSuccess(t *testing.T) {
writeFakeSSH(t, "#!/usr/bin/env bash\nset -euo pipefail\necho ok\n")
cfg := config.Config{
SSHUser: "atlas",
Coordination: config.Coordination{
ForwardShutdownHost: "titan-db",
},
}
ok, err := tryPeerBootstrapHandoff(context.Background(), cfg, log.New(io.Discard, "", 0))
if err != nil || !ok {
t.Fatalf("expected successful handoff, got ok=%v err=%v", ok, err)
}
}
// TestTryPeerBootstrapHandoffRejectsMissingCoordinator runs one orchestration or CLI step.
// Signature: TestTryPeerBootstrapHandoffRejectsMissingCoordinator(t *testing.T).
// Why: covers peer handoff validation when no coordinator host is configured.
func TestTryPeerBootstrapHandoffRejectsMissingCoordinator(t *testing.T) {
cfg := config.Config{}
if _, err := tryPeerBootstrapHandoff(context.Background(), cfg, log.New(io.Discard, "", 0)); err == nil {
t.Fatalf("expected missing coordinator error")
}
}
// TestTryPeerBootstrapHandoffTimeout runs one orchestration or CLI step.
// Signature: TestTryPeerBootstrapHandoffTimeout(t *testing.T).
// Why: covers timeout branch when coordinator handoff cannot be completed.
func TestTryPeerBootstrapHandoffTimeout(t *testing.T) {
writeFakeSSH(t, "#!/usr/bin/env bash\nset -euo pipefail\necho fail >&2\nexit 1\n")
cfg := config.Config{
SSHUser: "atlas",
Coordination: config.Coordination{
ForwardShutdownHost: "titan-db",
},
}
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
ok, err := tryPeerBootstrapHandoff(ctx, cfg, log.New(io.Discard, "", 0))
if err == nil || ok {
t.Fatalf("expected timeout failure, got ok=%v err=%v", ok, err)
}
}
// TestTryPeerBootstrapHandoffUsesMappedHostAndUser runs one orchestration or CLI step.
// Signature: TestTryPeerBootstrapHandoffUsesMappedHostAndUser(t *testing.T).
// Why: covers target/user/jump argument composition for coordinator handoff SSH calls.
func TestTryPeerBootstrapHandoffUsesMappedHostAndUser(t *testing.T) {
argsFile := filepath.Join(t.TempDir(), "ssh-args.txt")
t.Setenv("ANANKE_SSH_ARGS_FILE", argsFile)
writeFakeSSH(t, "#!/usr/bin/env bash\nset -euo pipefail\nprintf '%s\\n' \"$@\" > \"$ANANKE_SSH_ARGS_FILE\"\necho ok\n")
cfg := config.Config{
SSHUser: "atlas",
SSHPort: 2222,
SSHNodeUsers: map[string]string{
"titan-db": "override-user",
},
SSHNodeHosts: map[string]string{
"titan-db": "10.0.0.55",
},
SSHJumpHost: "titan-jh",
Coordination: config.Coordination{
ForwardShutdownHost: "titan-db",
},
}
ok, err := tryPeerBootstrapHandoff(context.Background(), cfg, log.New(io.Discard, "", 0))
if err != nil || !ok {
t.Fatalf("expected successful handoff, got ok=%v err=%v", ok, err)
}
argsRaw, err := os.ReadFile(argsFile)
if err != nil {
t.Fatalf("read captured ssh args: %v", err)
}
args := string(argsRaw)
if !strings.Contains(args, "override-user@10.0.0.55") {
t.Fatalf("expected mapped target in args, got:\n%s", args)
}
if !strings.Contains(args, "-p") || !strings.Contains(args, "2222") {
t.Fatalf("expected port args in ssh invocation, got:\n%s", args)
}
if !strings.Contains(args, "-J") || !strings.Contains(args, "titan-jh:2222") {
t.Fatalf("expected jump host args in ssh invocation, got:\n%s", args)
}
}
// TestCoordinatorAllowsFallbackStates runs one orchestration or CLI step.
// Signature: TestCoordinatorAllowsFallbackStates(t *testing.T).
// Why: covers coordinator guard logic for bootstrap-active and normal intent states.
func TestCoordinatorAllowsFallbackStates(t *testing.T) {
// bootstrap active -> disallow fallback
writeFakeSSH(t, "#!/usr/bin/env bash\nset -euo pipefail\necho __ANANKE_BOOTSTRAP_ACTIVE__\necho intent=normal\n")
cfg := config.Config{
SSHUser: "atlas",
Coordination: config.Coordination{
ForwardShutdownHost: "titan-db",
StartupGuardMaxAgeSec: 60,
},
}
allowed, reason, err := coordinatorAllowsPeerFallbackStartup(context.Background(), cfg, log.New(io.Discard, "", 0))
if err != nil {
t.Fatalf("guard check failed: %v", err)
}
if allowed || !strings.Contains(reason, "bootstrap service is active") {
t.Fatalf("expected disallow for bootstrap active, got allowed=%v reason=%q", allowed, reason)
}
// coordinator normal -> allow fallback
writeFakeSSH(t, "#!/usr/bin/env bash\nset -euo pipefail\necho __ANANKE_BOOTSTRAP_IDLE__\necho intent=normal reason=\"ok\" source=test updated_at=2026-04-07T00:00:00Z\n")
allowed, reason, err = coordinatorAllowsPeerFallbackStartup(context.Background(), cfg, log.New(io.Discard, "", 0))
if err != nil {
t.Fatalf("guard check failed: %v", err)
}
if !allowed || !strings.Contains(reason, "intent is normal") {
t.Fatalf("expected allow for normal intent, got allowed=%v reason=%q", allowed, reason)
}
}
// TestCoordinatorAllowsFallbackOnUnreachableCoordinator runs one orchestration or CLI step.
// Signature: TestCoordinatorAllowsFallbackOnUnreachableCoordinator(t *testing.T).
// Why: covers fallback-allowed path when coordinator cannot be reached.
func TestCoordinatorAllowsFallbackOnUnreachableCoordinator(t *testing.T) {
writeFakeSSH(t, "#!/usr/bin/env bash\nset -euo pipefail\nexit 255\n")
cfg := config.Config{
SSHUser: "atlas",
Coordination: config.Coordination{
ForwardShutdownHost: "titan-db",
},
}
allowed, reason, err := coordinatorAllowsPeerFallbackStartup(context.Background(), cfg, log.New(io.Discard, "", 0))
if err != nil {
t.Fatalf("expected no hard error, got %v", err)
}
if !allowed || !strings.Contains(reason, "coordinator unreachable") {
t.Fatalf("expected coordinator-unreachable allow, got allowed=%v reason=%q", allowed, reason)
}
}
// TestCoordinatorAllowsFallbackWhenNoCoordinatorConfigured runs one orchestration or CLI step.
// Signature: TestCoordinatorAllowsFallbackWhenNoCoordinatorConfigured(t *testing.T).
// Why: covers no-coordinator short-circuit path for peer fallback checks.
func TestCoordinatorAllowsFallbackWhenNoCoordinatorConfigured(t *testing.T) {
cfg := config.Config{}
allowed, reason, err := coordinatorAllowsPeerFallbackStartup(context.Background(), cfg, log.New(io.Discard, "", 0))
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if !allowed || !strings.Contains(reason, "no coordinator configured") {
t.Fatalf("unexpected guard result allowed=%v reason=%q", allowed, reason)
}
}
// TestCoordinatorAllowsFallbackIntentStateMatrix runs one orchestration or CLI step.
// Signature: TestCoordinatorAllowsFallbackIntentStateMatrix(t *testing.T).
// Why: covers peer fallback guard behavior for each coordinator intent branch.
func TestCoordinatorAllowsFallbackIntentStateMatrix(t *testing.T) {
now := time.Now().UTC()
stale := now.Add(-20 * time.Minute).Format(time.RFC3339)
fresh := now.Format(time.RFC3339)
oldComplete := now.Add(-5 * time.Minute).Format(time.RFC3339)
cases := []struct {
name string
intentLine string
wantAllowed bool
wantReasonSub string
}{
{
name: "shutting_down_fresh_blocks",
intentLine: intentOutputLine("shutting_down", "fresh", "test", fresh),
wantAllowed: false,
wantReasonSub: "intent=shutting_down",
},
{
name: "shutting_down_stale_allows",
intentLine: intentOutputLine("shutting_down", "stale", "test", stale),
wantAllowed: true,
wantReasonSub: "shutdown intent stale",
},
{
name: "startup_in_progress_fresh_blocks",
intentLine: intentOutputLine("startup_in_progress", "fresh", "test", fresh),
wantAllowed: false,
wantReasonSub: "intent=startup_in_progress",
},
{
name: "startup_in_progress_stale_allows",
intentLine: intentOutputLine("startup_in_progress", "stale", "test", stale),
wantAllowed: true,
wantReasonSub: "startup intent stale",
},
{
name: "shutdown_complete_without_age_blocks",
intentLine: `intent=shutdown_complete reason="unknown" source=test`,
wantAllowed: false,
wantReasonSub: "unknown age",
},
{
name: "shutdown_complete_recent_blocks",
intentLine: intentOutputLine("shutdown_complete", "recent", "test", fresh),
wantAllowed: false,
wantReasonSub: "recently completed shutdown",
},
{
name: "shutdown_complete_old_allows",
intentLine: intentOutputLine("shutdown_complete", "old", "test", oldComplete),
wantAllowed: true,
wantReasonSub: "old enough",
},
{
name: "unknown_state_blocks",
intentLine: intentOutputLine("mystery_state", "unknown", "test", fresh),
wantAllowed: false,
wantReasonSub: "unknown",
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
writeFakeSSH(t, "#!/usr/bin/env bash\nset -euo pipefail\necho __ANANKE_BOOTSTRAP_IDLE__\necho '"+tc.intentLine+"'\n")
cfg := config.Config{
SSHUser: "atlas",
Coordination: config.Coordination{
ForwardShutdownHost: "titan-db",
StartupGuardMaxAgeSec: 60,
},
Startup: config.Startup{
ShutdownCooldownSeconds: 45,
},
}
allowed, reason, err := coordinatorAllowsPeerFallbackStartup(context.Background(), cfg, log.New(io.Discard, "", 0))
if err != nil {
t.Fatalf("guard check failed: %v", err)
}
if allowed != tc.wantAllowed {
t.Fatalf("unexpected allowed=%v want=%v reason=%q", allowed, tc.wantAllowed, reason)
}
if !strings.Contains(strings.ToLower(reason), strings.ToLower(tc.wantReasonSub)) {
t.Fatalf("unexpected reason=%q want substring %q", reason, tc.wantReasonSub)
}
})
}
}
// TestCoordinatorGuardRejectsUnparseableIntent runs one orchestration or CLI step.
// Signature: TestCoordinatorGuardRejectsUnparseableIntent(t *testing.T).
// Why: covers parse-error path when coordinator output is malformed.
func TestCoordinatorGuardRejectsUnparseableIntent(t *testing.T) {
writeFakeSSH(t, "#!/usr/bin/env bash\nset -euo pipefail\necho __ANANKE_BOOTSTRAP_IDLE__\necho gibberish-output\n")
cfg := config.Config{
SSHUser: "atlas",
Coordination: config.Coordination{
ForwardShutdownHost: "titan-db",
},
}
if _, _, err := coordinatorAllowsPeerFallbackStartup(context.Background(), cfg, log.New(io.Discard, "", 0)); err == nil {
t.Fatalf("expected parse error for malformed coordinator output")
}
}
// TestResolveSSHPathsPreferExplicitValues runs one orchestration or CLI step.
// Signature: TestResolveSSHPathsPreferExplicitValues(t *testing.T).
// Why: covers explicit ssh config/identity path branches in startup handoff helpers.
func TestResolveSSHPathsPreferExplicitValues(t *testing.T) {
cfg := config.Config{
SSHConfigFile: "/tmp/ssh-config",
SSHIdentityFile: "/tmp/ssh-key",
}
if got := resolveSSHConfigFile(cfg); got != "/tmp/ssh-config" {
t.Fatalf("unexpected config path: %q", got)
}
if got := resolveSSHIdentityFile(cfg); got != "/tmp/ssh-key" {
t.Fatalf("unexpected identity path: %q", got)
}
}
// TestResolveSSHPathCandidatesAndCooldownDefaults runs one orchestration or CLI step.
// Signature: TestResolveSSHPathCandidatesAndCooldownDefaults(t *testing.T).
// Why: covers candidate-list fallback branches and startup cooldown default logic.
func TestResolveSSHPathCandidatesAndCooldownDefaults(t *testing.T) {
tmp := t.TempDir()
cfgPath := filepath.Join(tmp, "config")
idPath := filepath.Join(tmp, "id_ed25519")
if err := os.WriteFile(cfgPath, []byte("host *"), 0o600); err != nil {
t.Fatalf("write cfg candidate: %v", err)
}
if err := os.WriteFile(idPath, []byte("key"), 0o600); err != nil {
t.Fatalf("write id candidate: %v", err)
}
prevCfg := append([]string{}, sshConfigCandidates...)
prevID := append([]string{}, sshIdentityCandidates...)
defer func() {
sshConfigCandidates = prevCfg
sshIdentityCandidates = prevID
}()
sshConfigCandidates = []string{cfgPath}
sshIdentityCandidates = []string{idPath}
if got := resolveSSHConfigFile(config.Config{}); got != cfgPath {
t.Fatalf("expected cfg candidate %q, got %q", cfgPath, got)
}
if got := resolveSSHIdentityFile(config.Config{}); got != idPath {
t.Fatalf("expected identity candidate %q, got %q", idPath, got)
}
sshConfigCandidates = []string{filepath.Join(tmp, "missing-config")}
sshIdentityCandidates = []string{filepath.Join(tmp, "missing-key")}
if got := resolveSSHConfigFile(config.Config{}); got != "" {
t.Fatalf("expected empty config fallback, got %q", got)
}
if got := resolveSSHIdentityFile(config.Config{}); got != "" {
t.Fatalf("expected empty identity fallback, got %q", got)
}
if got := startupShutdownCooldown(config.Config{}); got != 45*time.Second {
t.Fatalf("expected default cooldown 45s, got %s", got)
}
if got := startupShutdownCooldown(config.Config{Startup: config.Startup{ShutdownCooldownSeconds: 90}}); got != 90*time.Second {
t.Fatalf("expected configured cooldown 90s, got %s", got)
}
}
// intentOutputLine runs one orchestration or CLI step.
// Signature: intentOutputLine(stateValue, reason, source, updatedAt string) string.
// Why: keeps guard-branch fixtures concise and readable.
func intentOutputLine(stateValue, reason, source, updatedAt string) string {
return `intent=` + stateValue + ` reason="` + reason + `" source=` + source + ` updated_at=` + updatedAt
}

View File

@ -0,0 +1,105 @@
package main
import (
"os"
"path/filepath"
"reflect"
"strconv"
"strings"
"testing"
"time"
"scm.bstein.dev/bstein/ananke/internal/config"
)
// TestBuildSSHBaseArgsIncludesConfiguredPaths runs one orchestration or CLI step.
// Signature: TestBuildSSHBaseArgsIncludesConfiguredPaths(t *testing.T).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func TestBuildSSHBaseArgsIncludesConfiguredPaths(t *testing.T) {
cfg := config.Config{
SSHConfigFile: "/tmp/ssh-config",
SSHIdentityFile: "/tmp/id_ed25519",
SSHPort: 2277,
SSHJumpHost: "titan-jh",
SSHJumpUser: "atlas",
}
args := buildSSHBaseArgs(cfg)
joined := strings.Join(args, " ")
expectedPieces := []string{
"-F /tmp/ssh-config",
"-i /tmp/id_ed25519",
"-p 2277",
"-J atlas@titan-jh:2277",
}
for _, piece := range expectedPieces {
if !strings.Contains(joined, piece) {
t.Fatalf("expected ssh args to contain %q, got %q", piece, joined)
}
}
}
// TestResolveSSHConfigAndIdentityPreferExplicit runs one orchestration or CLI step.
// Signature: TestResolveSSHConfigAndIdentityPreferExplicit(t *testing.T).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func TestResolveSSHConfigAndIdentityPreferExplicit(t *testing.T) {
cfg := config.Config{
SSHConfigFile: "/etc/ssh/custom-config",
SSHIdentityFile: "/etc/ssh/custom-key",
}
if got := resolveSSHConfigFile(cfg); got != cfg.SSHConfigFile {
t.Fatalf("expected explicit SSH config file, got %q", got)
}
if got := resolveSSHIdentityFile(cfg); got != cfg.SSHIdentityFile {
t.Fatalf("expected explicit SSH identity file, got %q", got)
}
}
// TestMaxIntAndStartupShutdownCooldown runs one orchestration or CLI step.
// Signature: TestMaxIntAndStartupShutdownCooldown(t *testing.T).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func TestMaxIntAndStartupShutdownCooldown(t *testing.T) {
if got := maxInt(2, 5); got != 5 {
t.Fatalf("expected maxInt=5, got %d", got)
}
if got := maxInt(9, 3); got != 9 {
t.Fatalf("expected maxInt=9, got %d", got)
}
cfg := config.Config{Startup: config.Startup{ShutdownCooldownSeconds: 120}}
if got := startupShutdownCooldown(cfg); got != 120*time.Second {
t.Fatalf("expected cooldown 120s, got %s", got)
}
}
// TestBuildSSHBaseArgsNoJumpHasStableOrdering runs one orchestration or CLI step.
// Signature: TestBuildSSHBaseArgsNoJumpHasStableOrdering(t *testing.T).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func TestBuildSSHBaseArgsNoJumpHasStableOrdering(t *testing.T) {
cfg := config.Config{SSHPort: 10022}
args := buildSSHBaseArgs(cfg)
want := []string{
"-o", "BatchMode=yes",
"-o", "ConnectTimeout=8",
"-o", "StrictHostKeyChecking=accept-new",
"-p", strconv.Itoa(10022),
}
if !reflect.DeepEqual(args, want) {
t.Fatalf("unexpected args order.\n got=%v\nwant=%v", args, want)
}
}
// TestResolveSSHConfigFileFallsBackToDiscoveredPath runs one orchestration or CLI step.
// Signature: TestResolveSSHConfigFileFallsBackToDiscoveredPath(t *testing.T).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func TestResolveSSHConfigFileFallsBackToDiscoveredPath(t *testing.T) {
tmp := t.TempDir()
cfgPath := filepath.Join(tmp, "ssh_config")
if err := os.WriteFile(cfgPath, []byte("Host *\n"), 0o644); err != nil {
t.Fatalf("write temp ssh config: %v", err)
}
cfg := config.Config{SSHConfigFile: cfgPath}
if got := resolveSSHConfigFile(cfg); got != cfgPath {
t.Fatalf("expected fallback config path %q, got %q", cfgPath, got)
}
}

34
cmd/ananke/builder.go Normal file
View File

@ -0,0 +1,34 @@
package main
import (
"log"
"scm.bstein.dev/bstein/ananke/internal/cluster"
"scm.bstein.dev/bstein/ananke/internal/config"
"scm.bstein.dev/bstein/ananke/internal/execx"
"scm.bstein.dev/bstein/ananke/internal/state"
)
// buildOrchestrator runs one orchestration or CLI step.
// Signature: buildOrchestrator(logger *log.Logger, cfgPath string, dryRun bool) (config.Config, *cluster.Orchestrator, error).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func buildOrchestrator(logger *log.Logger, cfgPath string, dryRun bool) (config.Config, *cluster.Orchestrator, error) {
cfg, err := config.Load(cfgPath)
if err != nil {
return config.Config{}, nil, err
}
if err := state.EnsureDir(cfg.State.Dir); err != nil {
return config.Config{}, nil, err
}
if err := state.EnsureDir(cfg.State.ReportsDir); err != nil {
return config.Config{}, nil, err
}
runner := &execx.Runner{
DryRun: dryRun,
Kubeconfig: cfg.Kubeconfig,
Logger: logger,
}
store := state.New(cfg.State.RunHistoryPath)
orch := cluster.New(cfg, runner, store, logger)
return cfg, orch, nil
}

118
cmd/ananke/builder_test.go Normal file
View File

@ -0,0 +1,118 @@
package main
import (
"io"
"log"
"os"
"path/filepath"
"testing"
)
// TestBuildOrchestratorCreatesStateDirs runs one orchestration or CLI step.
// Signature: TestBuildOrchestratorCreatesStateDirs(t *testing.T).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func TestBuildOrchestratorCreatesStateDirs(t *testing.T) {
cfgPath := writeTestConfig(t)
logger := log.New(io.Discard, "", 0)
cfg, orch, err := buildOrchestrator(logger, cfgPath, true)
if err != nil {
t.Fatalf("buildOrchestrator failed: %v", err)
}
if orch == nil {
t.Fatalf("expected orchestrator instance")
}
if _, err := os.Stat(cfg.State.Dir); err != nil {
t.Fatalf("expected state dir %s: %v", cfg.State.Dir, err)
}
if _, err := os.Stat(cfg.State.ReportsDir); err != nil {
t.Fatalf("expected reports dir %s: %v", cfg.State.ReportsDir, err)
}
if filepath.Base(cfg.State.RunHistoryPath) != "runs.json" {
t.Fatalf("unexpected run history path: %s", cfg.State.RunHistoryPath)
}
}
// TestBuildOrchestratorFailsWhenStateDirIsFile runs one orchestration or CLI step.
// Signature: TestBuildOrchestratorFailsWhenStateDirIsFile(t *testing.T).
// Why: covers EnsureDir error path in orchestrator builder.
func TestBuildOrchestratorFailsWhenStateDirIsFile(t *testing.T) {
cfgPath := writeTestConfig(t)
stateFile := filepath.Join(t.TempDir(), "state-file")
if err := os.WriteFile(stateFile, []byte("x"), 0o600); err != nil {
t.Fatalf("write state file: %v", err)
}
// Build a minimal explicit config to avoid brittle string surgery.
raw := `control_planes: [titan-0a]
workers: [titan-04]
ssh_user: atlas
ssh_port: 2277
ssh_node_hosts:
titan-0a: 192.168.22.11
titan-04: 192.168.22.30
ssh_managed_nodes: [titan-0a, titan-04]
expected_flux_branch: main
expected_flux_source_url: ssh://git@scm.bstein.dev:2242/bstein/titan-iac.git
iac_repo_path: /tmp
ups:
enabled: false
state:
dir: ` + stateFile + `
reports_dir: ` + filepath.Join(stateFile, "reports") + `
run_history_path: ` + filepath.Join(stateFile, "runs.json") + `
lock_path: ` + filepath.Join(stateFile, "ananke.lock") + `
intent_path: ` + filepath.Join(stateFile, "intent.json") + `
`
if err := os.WriteFile(cfgPath, []byte(raw), 0o644); err != nil {
t.Fatalf("rewrite config: %v", err)
}
logger := log.New(io.Discard, "", 0)
if _, _, err := buildOrchestrator(logger, cfgPath, true); err == nil {
t.Fatalf("expected buildOrchestrator failure when state dir is a file")
}
}
// TestBuildOrchestratorFailsWhenReportsDirIsFile runs one orchestration or CLI step.
// Signature: TestBuildOrchestratorFailsWhenReportsDirIsFile(t *testing.T).
// Why: covers reports-dir EnsureDir error path in orchestrator builder.
func TestBuildOrchestratorFailsWhenReportsDirIsFile(t *testing.T) {
cfgPath := writeTestConfig(t)
stateDir := filepath.Join(t.TempDir(), "state")
if err := os.MkdirAll(stateDir, 0o755); err != nil {
t.Fatalf("mkdir state dir: %v", err)
}
reportsFile := filepath.Join(stateDir, "reports-file")
if err := os.WriteFile(reportsFile, []byte("x"), 0o600); err != nil {
t.Fatalf("write reports file: %v", err)
}
raw := `control_planes: [titan-0a]
workers: [titan-04]
ssh_user: atlas
ssh_port: 2277
ssh_node_hosts:
titan-0a: 192.168.22.11
titan-04: 192.168.22.30
ssh_managed_nodes: [titan-0a, titan-04]
expected_flux_branch: main
expected_flux_source_url: ssh://git@scm.bstein.dev:2242/bstein/titan-iac.git
iac_repo_path: /tmp
ups:
enabled: false
state:
dir: ` + stateDir + `
reports_dir: ` + reportsFile + `
run_history_path: ` + filepath.Join(stateDir, "runs.json") + `
lock_path: ` + filepath.Join(stateDir, "ananke.lock") + `
intent_path: ` + filepath.Join(stateDir, "intent.json") + `
`
if err := os.WriteFile(cfgPath, []byte(raw), 0o644); err != nil {
t.Fatalf("rewrite config: %v", err)
}
logger := log.New(io.Discard, "", 0)
if _, _, err := buildOrchestrator(logger, cfgPath, true); err == nil {
t.Fatalf("expected buildOrchestrator failure when reports dir is a file")
}
}

View File

@ -0,0 +1,267 @@
package main
import (
"context"
"encoding/json"
"io"
"log"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"time"
"scm.bstein.dev/bstein/ananke/internal/service"
)
// rewriteConfig runs one orchestration or CLI step.
// Signature: rewriteConfig(t *testing.T, path string, transform func(string) string).
// Why: keeps command tests readable while mutating config fixtures for branch coverage.
func rewriteConfig(t *testing.T, path string, transform func(string) string) {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read config fixture: %v", err)
}
out := transform(string(b))
if err := os.WriteFile(path, []byte(out), 0o644); err != nil {
t.Fatalf("write config fixture: %v", err)
}
}
// TestRunStartupDryRunHitsFluxDriftGuard runs one orchestration or CLI step.
// Signature: TestRunStartupDryRunHitsFluxDriftGuard(t *testing.T).
// Why: covers startup handler path and current dry-run flux-source drift guard behavior.
func TestRunStartupDryRunHitsFluxDriftGuard(t *testing.T) {
cfgPath := writeTestConfig(t)
logger := log.New(io.Discard, "", 0)
err := runStartup(logger, []string{"--config", cfgPath})
if err == nil {
t.Fatalf("expected drift guard error in dry-run mode")
}
if !strings.Contains(err.Error(), "flux source url drift") {
t.Fatalf("unexpected startup error: %v", err)
}
}
// TestRunStartupPeerRoleRequiresOverride runs one orchestration or CLI step.
// Signature: TestRunStartupPeerRoleRequiresOverride(t *testing.T).
// Why: covers peer role guard when execute mode is requested.
func TestRunStartupPeerRoleRequiresOverride(t *testing.T) {
cfgPath := writeTestConfig(t)
rewriteConfig(t, cfgPath, func(in string) string {
return in + "\ncoordination:\n role: peer\n"
})
logger := log.New(io.Discard, "", 0)
err := runStartup(logger, []string{"--config", cfgPath, "--execute"})
if err == nil {
t.Fatalf("expected peer-role startup block")
}
if !strings.Contains(err.Error(), "role=peer") {
t.Fatalf("unexpected error: %v", err)
}
}
// TestRunEtcdRestoreDryRun runs one orchestration or CLI step.
// Signature: TestRunEtcdRestoreDryRun(t *testing.T).
// Why: covers etcd-restore command path in non-execute mode.
func TestRunEtcdRestoreDryRun(t *testing.T) {
cfgPath := writeTestConfig(t)
logger := log.New(io.Discard, "", 0)
if err := runEtcdRestore(logger, []string{"--config", cfgPath}); err != nil {
t.Fatalf("runEtcdRestore dry-run failed: %v", err)
}
}
// TestRunDaemonRejectsMissingUPSProvider runs one orchestration or CLI step.
// Signature: TestRunDaemonRejectsMissingUPSProvider(t *testing.T).
// Why: covers daemon target-build error path.
func TestRunDaemonRejectsMissingUPSProvider(t *testing.T) {
cfgPath := writeTestConfig(t)
rewriteConfig(t, cfgPath, func(in string) string {
return strings.Replace(in, "ups:\n enabled: false\n", "ups:\n enabled: true\n", 1)
})
logger := log.New(io.Discard, "", 0)
if err := runDaemon(logger, []string{"--config", cfgPath}); err == nil {
t.Fatalf("expected daemon config error")
}
}
// TestBuildOrchestratorFailsForMissingConfig runs one orchestration or CLI step.
// Signature: TestBuildOrchestratorFailsForMissingConfig(t *testing.T).
// Why: covers config-load failure branch in orchestrator builder.
func TestBuildOrchestratorFailsForMissingConfig(t *testing.T) {
logger := log.New(io.Discard, "", 0)
if _, _, err := buildOrchestrator(logger, filepath.Join(t.TempDir(), "missing.yaml"), true); err == nil {
t.Fatalf("expected missing-config error")
}
}
// TestEnsureStartupPowerSafeTimeoutWithNoTelemetry runs one orchestration or CLI step.
// Signature: TestEnsureStartupPowerSafeTimeoutWithNoTelemetry(t *testing.T).
// Why: covers timeout branch where telemetry never succeeds.
func TestEnsureStartupPowerSafeTimeoutWithNoTelemetry(t *testing.T) {
targets := []service.Target{
{
Name: "Pyrphoros",
Target: "pyrphoros@localhost",
Provider: &fakeUPSProvider{errs: []error{context.DeadlineExceeded, context.DeadlineExceeded}},
},
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
if err := ensureStartupPowerSafe(ctx, targets, 20); err == nil {
t.Fatalf("expected startup safety timeout")
}
}
// TestLoadStartupStatusSnapshotPrefersProgress runs one orchestration or CLI step.
// Signature: TestLoadStartupStatusSnapshotPrefersProgress(t *testing.T).
// Why: status polling should read the live startup progress file when both
// progress and terminal reports exist.
func TestLoadStartupStatusSnapshotPrefersProgress(t *testing.T) {
tmp := t.TempDir()
progress := startupStatusSnapshot{
Status: "running",
Phase: "convergence-checks",
Checks: map[string]startupCheckRecord{
"service-checklist": {Status: "running", Detail: "waiting"},
},
}
last := startupStatusSnapshot{
Status: "success",
Phase: "complete",
Checks: map[string]startupCheckRecord{},
}
writeStartupStatusFixture(t, filepath.Join(tmp, "startup-progress.json"), progress)
writeStartupStatusFixture(t, filepath.Join(tmp, "last-startup-report.json"), last)
got, source, err := loadStartupStatusSnapshot(tmp)
if err != nil {
t.Fatalf("load snapshot failed: %v", err)
}
if got == nil {
t.Fatalf("expected snapshot")
}
if !strings.HasSuffix(source, "startup-progress.json") {
t.Fatalf("expected progress source, got %s", source)
}
if got.Status != "running" || got.Phase != "convergence-checks" {
t.Fatalf("unexpected snapshot: %+v", got)
}
}
// TestLoadStartupStatusSnapshotFallsBackToLast runs one orchestration or CLI step.
// Signature: TestLoadStartupStatusSnapshotFallsBackToLast(t *testing.T).
// Why: when no live progress file exists, operators still need the latest
// terminal startup report for troubleshooting.
func TestLoadStartupStatusSnapshotFallsBackToLast(t *testing.T) {
tmp := t.TempDir()
last := startupStatusSnapshot{
Status: "failed",
Phase: "failed",
Error: "boom",
Checks: map[string]startupCheckRecord{"flux-health": {Status: "failed", Detail: "not ready"}},
}
writeStartupStatusFixture(t, filepath.Join(tmp, "last-startup-report.json"), last)
got, source, err := loadStartupStatusSnapshot(tmp)
if err != nil {
t.Fatalf("load snapshot failed: %v", err)
}
if got == nil {
t.Fatalf("expected snapshot")
}
if !strings.HasSuffix(source, "last-startup-report.json") {
t.Fatalf("expected last report source, got %s", source)
}
if got.Error != "boom" {
t.Fatalf("expected preserved error, got %+v", got)
}
}
// TestLoadStartupStatusSnapshotMissing runs one orchestration or CLI step.
// Signature: TestLoadStartupStatusSnapshotMissing(t *testing.T).
// Why: status should remain readable even before startup has written any report files.
func TestLoadStartupStatusSnapshotMissing(t *testing.T) {
got, source, err := loadStartupStatusSnapshot(t.TempDir())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != nil || source != "" {
t.Fatalf("expected empty result, got snapshot=%v source=%q", got, source)
}
}
// TestSummarizeChecklistCountsAndDetails runs one orchestration or CLI step.
// Signature: TestSummarizeChecklistCountsAndDetails(t *testing.T).
// Why: checklist counters power human-readable startup progress reporting, so
// counts and failed detail formatting must remain deterministic.
func TestSummarizeChecklistCountsAndDetails(t *testing.T) {
checks := map[string]startupCheckRecord{
"alpha": {Status: "passed"},
"beta": {Status: "failed", Detail: "timed out"},
"gamma": {Status: "running"},
"delta": {Status: "unknown"},
}
passed, failed, running, other, failedChecks := summarizeChecklist(checks)
if passed != 1 || failed != 1 || running != 1 || other != 1 {
t.Fatalf("unexpected counts: passed=%d failed=%d running=%d other=%d", passed, failed, running, other)
}
if len(failedChecks) != 1 || failedChecks[0] != "beta: timed out" {
t.Fatalf("unexpected failed details: %v", failedChecks)
}
}
// TestFormatMaybeTime runs one orchestration or CLI step.
// Signature: TestFormatMaybeTime(t *testing.T).
// Why: status output should keep zero values explicit without breaking timestamp formatting.
func TestFormatMaybeTime(t *testing.T) {
if got := formatMaybeTime(time.Time{}); got != "none" {
t.Fatalf("expected none for zero time, got %q", got)
}
stamp := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC)
if got := formatMaybeTime(stamp); got != "2026-01-02T03:04:05Z" {
t.Fatalf("unexpected formatted timestamp: %q", got)
}
}
// writeStartupStatusFixture runs one orchestration or CLI step.
// Signature: writeStartupStatusFixture(t *testing.T, path string, snapshot startupStatusSnapshot).
// Why: startup status fixture writes are shared by multiple tests and should be
// deterministic to keep failure output easy to inspect.
func writeStartupStatusFixture(t *testing.T, path string, snapshot startupStatusSnapshot) {
t.Helper()
if snapshot.Checks == nil {
snapshot.Checks = map[string]startupCheckRecord{}
}
b, err := json.MarshalIndent(snapshot, "", " ")
if err != nil {
t.Fatalf("encode fixture: %v", err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir fixture dir: %v", err)
}
if err := os.WriteFile(path, b, 0o644); err != nil {
t.Fatalf("write fixture: %v", err)
}
}
// TestSummarizeChecklistSortsFailedChecks runs one orchestration or CLI step.
// Signature: TestSummarizeChecklistSortsFailedChecks(t *testing.T).
// Why: stable ordering keeps status output diff-friendly for operator drill notes.
func TestSummarizeChecklistSortsFailedChecks(t *testing.T) {
checks := map[string]startupCheckRecord{
"zeta": {Status: "failed", Detail: "z"},
"eta": {Status: "failed", Detail: "e"},
}
_, _, _, _, failedChecks := summarizeChecklist(checks)
expect := []string{"eta: e", "zeta: z"}
sort.Strings(failedChecks)
for i := range expect {
if failedChecks[i] != expect[i] {
t.Fatalf("unexpected failed check ordering: got=%v want=%v", failedChecks, expect)
}
}
}

View File

@ -0,0 +1,420 @@
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"log"
"os"
"os/signal"
"path/filepath"
"sort"
"strings"
"syscall"
"time"
"scm.bstein.dev/bstein/ananke/internal/cluster"
"scm.bstein.dev/bstein/ananke/internal/config"
"scm.bstein.dev/bstein/ananke/internal/service"
"scm.bstein.dev/bstein/ananke/internal/state"
)
type startupStatusSnapshot struct {
StartedAt time.Time `json:"started_at"`
Completed time.Time `json:"completed_at"`
Reason string `json:"reason"`
Status string `json:"status"`
Phase string `json:"phase"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
Checks map[string]startupCheckRecord `json:"checks"`
AutoHeals []string `json:"auto_heals"`
SourceHost string `json:"source_host"`
LastUpdated time.Time `json:"last_updated"`
}
type startupCheckRecord struct {
Status string `json:"status"`
Detail string `json:"detail"`
UpdatedAt time.Time `json:"updated_at"`
}
var (
buildOrchestratorCommand = buildOrchestrator
tryPeerBootstrapHandoffCommand = tryPeerBootstrapHandoff
coordinatorAllowsPeerFallbackCommand = coordinatorAllowsPeerFallbackStartup
buildUPSTargetsCommand = buildUPSTargets
ensureStartupPowerSafeCommand = ensureStartupPowerSafe
startupOrchestratorCommand = func(ctx context.Context, orch *cluster.Orchestrator, opts cluster.StartupOptions) error {
return orch.Startup(ctx, opts)
}
shutdownOrchestratorCommand = func(ctx context.Context, orch *cluster.Orchestrator, opts cluster.ShutdownOptions) error {
return orch.Shutdown(ctx, opts)
}
etcdRestoreOrchestratorCommand = func(ctx context.Context, orch *cluster.Orchestrator, opts cluster.EtcdRestoreOptions) error {
return orch.EtcdRestore(ctx, opts)
}
daemonRunCommand = func(ctx context.Context, daemon *service.Daemon) error { return daemon.Run(ctx) }
readIntentCommand = state.ReadIntent
writeIntentCommand = state.MustWriteIntent
)
// runStartup runs one orchestration or CLI step.
// Signature: runStartup(logger *log.Logger, args []string) error.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func runStartup(logger *log.Logger, args []string) error {
fs := flag.NewFlagSet("startup", flag.ExitOnError)
configPath := fs.String("config", "/etc/ananke/ananke.yaml", "Path to config file")
execute := fs.Bool("execute", false, "Actually execute changes (default dry-run)")
forceBranch := fs.String("force-flux-branch", "", "Patch Flux source branch before resume")
skipLocalBootstrap := fs.Bool("skip-local-bootstrap", false, "Skip local fallback bootstrap applies")
allowPeerStartup := fs.Bool("allow-peer-startup", false, "Allow startup to run on a peer instance")
autoPeerFailover := fs.Bool("auto-peer-failover", false, "On peer role, try coordinator bootstrap handoff first and only run local startup as fallback")
peerWaitSeconds := fs.Int("peer-wait-seconds", 180, "How long auto peer failover waits for coordinator handoff before local fallback startup")
allowOnBattery := fs.Bool("allow-on-battery", false, "Allow startup when UPS reports on-battery")
reason := fs.String("reason", "manual-startup", "Startup reason for run history")
_ = fs.Parse(args)
cfg, orch, err := buildOrchestratorCommand(logger, *configPath, !*execute)
if err != nil {
return err
}
allowPeer := *allowPeerStartup
if *execute {
if cfg.Coordination.Role == "peer" && !allowPeer {
if *autoPeerFailover {
handoffCtx, cancel := context.WithTimeout(context.Background(), time.Duration(maxInt(*peerWaitSeconds, 1))*time.Second)
defer cancel()
handoff, handoffErr := tryPeerBootstrapHandoffCommand(handoffCtx, cfg, logger)
if handoffErr != nil {
logger.Printf("warning: peer bootstrap handoff failed: %v", handoffErr)
}
if handoff {
logger.Printf("peer startup handoff complete; skipping local startup")
return nil
}
guardCtx, guardCancel := context.WithTimeout(context.Background(), time.Duration(maxInt(cfg.Coordination.CommandTimeoutSeconds, 15))*time.Second)
defer guardCancel()
allowed, guardReason, guardErr := coordinatorAllowsPeerFallbackCommand(guardCtx, cfg, logger)
if guardErr != nil {
return fmt.Errorf("startup blocked: unable to evaluate coordinator startup guard: %w", guardErr)
}
if !allowed {
return fmt.Errorf("startup blocked: coordinator guard disallowed peer fallback (%s)", guardReason)
}
logger.Printf("peer startup handoff unavailable; proceeding with local peer startup fallback")
allowPeer = true
} else {
return fmt.Errorf("startup blocked: this instance is configured as role=peer (use --allow-peer-startup to override)")
}
}
if cfg.UPS.Enabled && !cfg.Coordination.AllowStartupOnBattery && !*allowOnBattery {
targets, targetErr := buildUPSTargetsCommand(cfg)
if targetErr != nil {
return targetErr
}
checkCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := ensureStartupPowerSafeCommand(checkCtx, targets, cfg.Startup.MinimumBatteryPercent); err != nil {
return err
}
}
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
return startupOrchestratorCommand(ctx, orch, cluster.StartupOptions{
ForceFluxBranch: *forceBranch,
SkipLocalBootstrap: *skipLocalBootstrap,
Reason: *reason,
})
}
// runShutdown runs one orchestration or CLI step.
// Signature: runShutdown(logger *log.Logger, args []string) error.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func runShutdown(logger *log.Logger, args []string) error {
fs := flag.NewFlagSet("shutdown", flag.ExitOnError)
configPath := fs.String("config", "/etc/ananke/ananke.yaml", "Path to config file")
execute := fs.Bool("execute", false, "Actually execute changes (default dry-run)")
skipEtcd := fs.Bool("skip-etcd-snapshot", false, "Skip etcd snapshot")
skipDrain := fs.Bool("skip-drain", false, "Skip worker drain")
mode := fs.String("mode", "cluster-only", "Shutdown mode: config|cluster-only")
reason := fs.String("reason", "manual-shutdown", "Shutdown reason for run history")
_ = fs.Parse(args)
_, orch, err := buildOrchestratorCommand(logger, *configPath, !*execute)
if err != nil {
return err
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
return shutdownOrchestratorCommand(ctx, orch, cluster.ShutdownOptions{
SkipEtcdSnapshot: *skipEtcd,
SkipDrain: *skipDrain,
Mode: *mode,
Reason: *reason,
})
}
// runDaemon runs one orchestration or CLI step.
// Signature: runDaemon(logger *log.Logger, args []string) error.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func runDaemon(logger *log.Logger, args []string) error {
fs := flag.NewFlagSet("daemon", flag.ExitOnError)
configPath := fs.String("config", "/etc/ananke/ananke.yaml", "Path to config file")
dryRunActions := fs.Bool("dry-run-actions", false, "Log planned actions without executing")
_ = fs.Parse(args)
cfg, orch, err := buildOrchestratorCommand(logger, *configPath, *dryRunActions)
if err != nil {
return err
}
if !cfg.UPS.Enabled {
return fmt.Errorf("UPS monitoring is disabled in config")
}
targets, err := buildUPSTargetsCommand(cfg)
if err != nil {
return err
}
d := service.NewDaemon(cfg, orch, targets, logger)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := daemonRunCommand(ctx, d); err != nil && !errors.Is(err, context.Canceled) {
return err
}
return nil
}
// runEtcdRestore runs one orchestration or CLI step.
// Signature: runEtcdRestore(logger *log.Logger, args []string) error.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func runEtcdRestore(logger *log.Logger, args []string) error {
fs := flag.NewFlagSet("etcd-restore", flag.ExitOnError)
configPath := fs.String("config", "/etc/ananke/ananke.yaml", "Path to config file")
execute := fs.Bool("execute", false, "Actually execute restore (default dry-run)")
controlPlane := fs.String("control-plane", "", "Control plane to run restore on (defaults to startup.etcd_restore_control_plane)")
snapshotPath := fs.String("snapshot", "", "Explicit snapshot path (defaults to latest on selected control plane)")
_ = fs.Parse(args)
_, orch, err := buildOrchestratorCommand(logger, *configPath, !*execute)
if err != nil {
return err
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
return etcdRestoreOrchestratorCommand(ctx, orch, cluster.EtcdRestoreOptions{
ControlPlane: *controlPlane,
SnapshotPath: *snapshotPath,
})
}
// runStatus runs one orchestration or CLI step.
// Signature: runStatus(logger *log.Logger, args []string) error.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func runStatus(logger *log.Logger, args []string) error {
fs := flag.NewFlagSet("status", flag.ExitOnError)
configPath := fs.String("config", "/etc/ananke/ananke.yaml", "Path to config file")
jsonOut := fs.Bool("json", false, "Emit startup status snapshot as JSON")
_ = fs.Parse(args)
cfg, orch, err := buildOrchestratorCommand(logger, *configPath, true)
if err != nil {
return err
}
recs, err := state.New(cfg.State.RunHistoryPath).Load()
if err != nil {
return err
}
last := "none"
if len(recs) > 0 {
r := recs[len(recs)-1]
last = fmt.Sprintf("%s success=%t duration=%ds at=%s", r.Action, r.Success, r.DurationSeconds, r.EndedAt.Format(time.RFC3339))
}
snapshot, snapshotPath, snapshotErr := loadStartupStatusSnapshot(cfg.State.Dir)
if snapshotErr != nil {
logger.Printf("startup_status_read_error=%v", snapshotErr)
}
logger.Printf("expected_flux_branch=%s", cfg.ExpectedFluxBranch)
logger.Printf("control_planes=%v", cfg.ControlPlanes)
logger.Printf("estimated_shutdown_budget_seconds=%d", orch.EstimatedShutdownSeconds())
logger.Printf("estimated_emergency_shutdown_budget_seconds=%d", orch.EstimatedEmergencyShutdownSeconds())
intent, intentErr := readIntentCommand(cfg.State.IntentPath)
if intentErr != nil {
logger.Printf("intent_read_error=%v", intentErr)
} else if intent.State == "" {
logger.Printf("intent=none")
} else {
logger.Printf("intent=%s reason=%q source=%s updated_at=%s",
intent.State, intent.Reason, intent.Source, intent.UpdatedAt.Format(time.RFC3339))
}
if snapshot != nil {
status := strings.TrimSpace(snapshot.Status)
if status == "" {
if snapshot.Completed.IsZero() {
status = "running"
} else if snapshot.Success {
status = "success"
} else {
status = "failed"
}
}
phase := strings.TrimSpace(snapshot.Phase)
if phase == "" {
phase = "unknown"
}
passed, failed, running, other, failedChecks := summarizeChecklist(snapshot.Checks)
totalChecks := len(snapshot.Checks)
logger.Printf("startup_status_source=%s", snapshotPath)
logger.Printf("startup_status=%s", status)
logger.Printf("startup_phase=%s", phase)
logger.Printf("startup_reason=%q", strings.TrimSpace(snapshot.Reason))
logger.Printf("startup_started_at=%s", formatMaybeTime(snapshot.StartedAt))
logger.Printf("startup_last_updated=%s", formatMaybeTime(snapshot.LastUpdated))
logger.Printf("startup_completed_at=%s", formatMaybeTime(snapshot.Completed))
logger.Printf("startup_checklist_total=%d passed=%d failed=%d running=%d other=%d", totalChecks, passed, failed, running, other)
if len(snapshot.AutoHeals) > 0 {
logger.Printf("startup_auto_heals=%d", len(snapshot.AutoHeals))
for _, heal := range snapshot.AutoHeals {
logger.Printf("startup_auto_heal_detail=%s", strings.TrimSpace(heal))
}
}
if len(failedChecks) > 0 {
for _, entry := range failedChecks {
logger.Printf("startup_failed_check=%s", entry)
}
}
if *jsonOut {
payload, err := json.MarshalIndent(snapshot, "", " ")
if err != nil {
return fmt.Errorf("encode startup status json: %w", err)
}
fmt.Println(string(payload))
}
}
logger.Printf("last_run=%s", last)
return nil
}
// loadStartupStatusSnapshot runs one orchestration or CLI step.
// Signature: loadStartupStatusSnapshot(stateDir string) (*startupStatusSnapshot, string, error).
// Why: `ananke status` should expose active checklist progress while startup is
// still running, not only terminal run history.
func loadStartupStatusSnapshot(stateDir string) (*startupStatusSnapshot, string, error) {
stateDir = strings.TrimSpace(stateDir)
if stateDir == "" {
return nil, "", nil
}
candidates := []string{
filepath.Join(stateDir, "startup-progress.json"),
filepath.Join(stateDir, "last-startup-report.json"),
}
for _, path := range candidates {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
continue
}
return nil, path, err
}
var snapshot startupStatusSnapshot
if err := json.Unmarshal(data, &snapshot); err != nil {
return nil, path, fmt.Errorf("parse %s: %w", path, err)
}
if snapshot.Checks == nil {
snapshot.Checks = map[string]startupCheckRecord{}
}
return &snapshot, path, nil
}
return nil, "", nil
}
// summarizeChecklist runs one orchestration or CLI step.
// Signature: summarizeChecklist(checks map[string]startupCheckRecord) (int, int, int, int, []string).
// Why: status polling should show checklist completion in a compact operator-
// friendly form, with explicit failed check details.
func summarizeChecklist(checks map[string]startupCheckRecord) (int, int, int, int, []string) {
passed := 0
failed := 0
running := 0
other := 0
failedChecks := []string{}
names := make([]string, 0, len(checks))
for name := range checks {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
check := checks[name]
switch strings.TrimSpace(check.Status) {
case "passed":
passed++
case "failed":
failed++
detail := strings.TrimSpace(check.Detail)
if detail == "" {
detail = "no detail provided"
}
failedChecks = append(failedChecks, fmt.Sprintf("%s: %s", name, detail))
case "running":
running++
default:
other++
}
}
return passed, failed, running, other, failedChecks
}
// formatMaybeTime runs one orchestration or CLI step.
// Signature: formatMaybeTime(value time.Time) string.
// Why: status output should avoid zero-time noise while still being parseable.
func formatMaybeTime(value time.Time) string {
if value.IsZero() {
return "none"
}
return value.UTC().Format(time.RFC3339)
}
// runIntent runs one orchestration or CLI step.
// Signature: runIntent(logger *log.Logger, args []string) error.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func runIntent(logger *log.Logger, args []string) error {
fs := flag.NewFlagSet("intent", flag.ExitOnError)
configPath := fs.String("config", "/etc/ananke/ananke.yaml", "Path to config file")
setState := fs.String("set", "", "Set intent state (normal|startup_in_progress|shutting_down|shutdown_complete)")
reason := fs.String("reason", "manual-intent", "Intent reason (used with --set)")
source := fs.String("source", "manual", "Intent source (used with --set)")
execute := fs.Bool("execute", false, "Actually write intent state (default read-only)")
_ = fs.Parse(args)
cfg, err := config.Load(*configPath)
if err != nil {
return err
}
if strings.TrimSpace(*setState) == "" {
in, readErr := readIntentCommand(cfg.State.IntentPath)
if readErr != nil {
return readErr
}
if in.State == "" {
logger.Printf("intent=none")
return nil
}
logger.Printf("intent=%s reason=%q source=%s updated_at=%s",
in.State, in.Reason, in.Source, in.UpdatedAt.Format(time.RFC3339))
return nil
}
if !*execute {
return fmt.Errorf("refusing to write intent without --execute")
}
stateValue := strings.TrimSpace(*setState)
if err := writeIntentCommand(cfg.State.IntentPath, stateValue, *reason, *source); err != nil {
return err
}
logger.Printf("intent updated: state=%s reason=%q source=%s", stateValue, *reason, *source)
return nil
}

View File

@ -0,0 +1,487 @@
package main
import (
"context"
"errors"
"io"
"log"
"os"
"path/filepath"
"strings"
"testing"
"time"
"scm.bstein.dev/bstein/ananke/internal/cluster"
"scm.bstein.dev/bstein/ananke/internal/config"
"scm.bstein.dev/bstein/ananke/internal/execx"
"scm.bstein.dev/bstein/ananke/internal/service"
"scm.bstein.dev/bstein/ananke/internal/state"
)
// TestRunStartupDispatchWithInjectedOrchestrator runs one orchestration or CLI step.
// Signature: TestRunStartupDispatchWithInjectedOrchestrator(t *testing.T).
// Why: covers startup command wiring and option propagation without invoking a real cluster flow.
func TestRunStartupDispatchWithInjectedOrchestrator(t *testing.T) {
restore := stubCommandHandlerHooks()
defer restore()
var captured cluster.StartupOptions
buildOrchestratorCommand = func(_ *log.Logger, _ string, dryRun bool) (config.Config, *cluster.Orchestrator, error) {
cfg := minimalHandlerConfig(t)
cfg.Coordination.Role = "coordinator"
cfg.UPS.Enabled = false
orch := newTestOrchestrator(cfg, dryRun)
return cfg, orch, nil
}
startupOrchestratorCommand = func(_ context.Context, _ *cluster.Orchestrator, opts cluster.StartupOptions) error {
captured = opts
return nil
}
logger := log.New(io.Discard, "", 0)
err := runStartup(logger, []string{
"--config", "/ignored.yaml",
"--execute",
"--force-flux-branch", "main",
"--skip-local-bootstrap",
"--reason", "drill-startup",
})
if err != nil {
t.Fatalf("runStartup failed: %v", err)
}
if captured.ForceFluxBranch != "main" || !captured.SkipLocalBootstrap || captured.Reason != "drill-startup" {
t.Fatalf("unexpected startup options: %+v", captured)
}
}
// TestRunStartupPeerRoleGuards runs one orchestration or CLI step.
// Signature: TestRunStartupPeerRoleGuards(t *testing.T).
// Why: covers peer-role guard, handoff short-circuit, and coordinator guard disallow paths.
func TestRunStartupPeerRoleGuards(t *testing.T) {
restore := stubCommandHandlerHooks()
defer restore()
logger := log.New(io.Discard, "", 0)
buildOrchestratorCommand = func(_ *log.Logger, _ string, dryRun bool) (config.Config, *cluster.Orchestrator, error) {
cfg := minimalHandlerConfig(t)
cfg.Coordination.Role = "peer"
cfg.UPS.Enabled = false
return cfg, newTestOrchestrator(cfg, dryRun), nil
}
if err := runStartup(logger, []string{"--config", "/ignored.yaml", "--execute"}); err == nil {
t.Fatalf("expected peer-role block without override")
}
tryPeerBootstrapHandoffCommand = func(_ context.Context, _ config.Config, _ *log.Logger) (bool, error) {
return true, nil
}
calledStartup := false
startupOrchestratorCommand = func(_ context.Context, _ *cluster.Orchestrator, _ cluster.StartupOptions) error {
calledStartup = true
return nil
}
if err := runStartup(logger, []string{"--config", "/ignored.yaml", "--execute", "--auto-peer-failover"}); err != nil {
t.Fatalf("expected handoff short-circuit success, got %v", err)
}
if calledStartup {
t.Fatalf("startup should not run after successful handoff")
}
tryPeerBootstrapHandoffCommand = func(_ context.Context, _ config.Config, _ *log.Logger) (bool, error) {
return false, nil
}
coordinatorAllowsPeerFallbackCommand = func(_ context.Context, _ config.Config, _ *log.Logger) (bool, string, error) {
return false, "still busy", nil
}
if err := runStartup(logger, []string{"--config", "/ignored.yaml", "--execute", "--auto-peer-failover"}); err == nil {
t.Fatalf("expected coordinator guard block")
}
}
// TestRunStartupPowerSafetyHooks runs one orchestration or CLI step.
// Signature: TestRunStartupPowerSafetyHooks(t *testing.T).
// Why: covers startup UPS target build and startup power safety check error propagation.
func TestRunStartupPowerSafetyHooks(t *testing.T) {
restore := stubCommandHandlerHooks()
defer restore()
buildOrchestratorCommand = func(_ *log.Logger, _ string, dryRun bool) (config.Config, *cluster.Orchestrator, error) {
cfg := minimalHandlerConfig(t)
cfg.Coordination.Role = "coordinator"
cfg.UPS.Enabled = true
cfg.Coordination.AllowStartupOnBattery = false
return cfg, newTestOrchestrator(cfg, dryRun), nil
}
buildUPSTargetsCommand = func(_ config.Config) ([]service.Target, error) {
return []service.Target{{Name: "Pyrphoros", Target: "pyrphoros@localhost"}}, nil
}
ensureStartupPowerSafeCommand = func(_ context.Context, _ []service.Target, _ float64) error {
return errors.New("ups blocked")
}
logger := log.New(io.Discard, "", 0)
err := runStartup(logger, []string{"--config", "/ignored.yaml", "--execute"})
if err == nil || !strings.Contains(err.Error(), "ups blocked") {
t.Fatalf("expected startup UPS block, got %v", err)
}
}
// TestRunShutdownAndRestoreDispatchHooks runs one orchestration or CLI step.
// Signature: TestRunShutdownAndRestoreDispatchHooks(t *testing.T).
// Why: covers option wiring for shutdown and etcd restore handlers.
func TestRunShutdownAndRestoreDispatchHooks(t *testing.T) {
restore := stubCommandHandlerHooks()
defer restore()
var gotShutdown cluster.ShutdownOptions
var gotRestore cluster.EtcdRestoreOptions
buildOrchestratorCommand = func(_ *log.Logger, _ string, dryRun bool) (config.Config, *cluster.Orchestrator, error) {
cfg := minimalHandlerConfig(t)
return cfg, newTestOrchestrator(cfg, dryRun), nil
}
shutdownOrchestratorCommand = func(_ context.Context, _ *cluster.Orchestrator, opts cluster.ShutdownOptions) error {
gotShutdown = opts
return nil
}
etcdRestoreOrchestratorCommand = func(_ context.Context, _ *cluster.Orchestrator, opts cluster.EtcdRestoreOptions) error {
gotRestore = opts
return nil
}
logger := log.New(io.Discard, "", 0)
if err := runShutdown(logger, []string{"--config", "/ignored.yaml", "--skip-etcd-snapshot", "--skip-drain", "--mode", "cluster-only", "--reason", "drill"}); err != nil {
t.Fatalf("runShutdown failed: %v", err)
}
if !gotShutdown.SkipEtcdSnapshot || !gotShutdown.SkipDrain || gotShutdown.Mode != "cluster-only" || gotShutdown.Reason != "drill" {
t.Fatalf("unexpected shutdown options: %+v", gotShutdown)
}
if err := runEtcdRestore(logger, []string{"--config", "/ignored.yaml", "--control-plane", "titan-0a", "--snapshot", "/tmp/snap"}); err != nil {
t.Fatalf("runEtcdRestore failed: %v", err)
}
if gotRestore.ControlPlane != "titan-0a" || gotRestore.SnapshotPath != "/tmp/snap" {
t.Fatalf("unexpected etcd restore options: %+v", gotRestore)
}
}
// TestRunDaemonAndIntentHooks runs one orchestration or CLI step.
// Signature: TestRunDaemonAndIntentHooks(t *testing.T).
// Why: covers daemon context-cancel behavior and injected intent read/write hooks.
func TestRunDaemonAndIntentHooks(t *testing.T) {
restore := stubCommandHandlerHooks()
defer restore()
cfgPath := writeTestConfig(t)
logger := log.New(io.Discard, "", 0)
buildOrchestratorCommand = func(_ *log.Logger, _ string, dryRun bool) (config.Config, *cluster.Orchestrator, error) {
cfg, err := config.Load(cfgPath)
if err != nil {
return config.Config{}, nil, err
}
cfg.UPS.Enabled = true
orch := newTestOrchestrator(cfg, dryRun)
return cfg, orch, nil
}
buildUPSTargetsCommand = func(_ config.Config) ([]service.Target, error) {
return []service.Target{{Name: "Pyrphoros", Target: "pyrphoros@localhost"}}, nil
}
daemonRunCommand = func(_ context.Context, _ *service.Daemon) error {
return context.Canceled
}
if err := runDaemon(logger, []string{"--config", cfgPath}); err != nil {
t.Fatalf("runDaemon should ignore context cancellation, got %v", err)
}
readIntentCommand = func(_ string) (state.Intent, error) {
return state.Intent{State: state.IntentNormal, Reason: "ok", Source: "unit", UpdatedAt: time.Now().UTC()}, nil
}
if err := runIntent(logger, []string{"--config", cfgPath}); err != nil {
t.Fatalf("runIntent read failed: %v", err)
}
writeCalled := false
writeIntentCommand = func(_ string, stateValue, reason, source string) error {
writeCalled = true
if stateValue != state.IntentNormal || reason != "unit" || source != "tester" {
t.Fatalf("unexpected intent write payload state=%s reason=%s source=%s", stateValue, reason, source)
}
return nil
}
if err := runIntent(logger, []string{"--config", cfgPath, "--set", state.IntentNormal, "--reason", "unit", "--source", "tester", "--execute"}); err != nil {
t.Fatalf("runIntent write failed: %v", err)
}
if !writeCalled {
t.Fatalf("expected write intent hook call")
}
}
// TestRunStatusReportsSnapshotAndJSON runs one orchestration or CLI step.
// Signature: TestRunStatusReportsSnapshotAndJSON(t *testing.T).
// Why: covers runStatus checklist/phase reporting and JSON payload emission from live startup progress.
func TestRunStatusReportsSnapshotAndJSON(t *testing.T) {
restore := stubCommandHandlerHooks()
defer restore()
cfg := minimalHandlerConfig(t)
cfgPath := writeTestConfig(t)
buildOrchestratorCommand = func(_ *log.Logger, _ string, dryRun bool) (config.Config, *cluster.Orchestrator, error) {
return cfg, newTestOrchestrator(cfg, dryRun), nil
}
progress := startupStatusSnapshot{
StartedAt: time.Now().UTC().Add(-30 * time.Second),
Status: "running",
Phase: "convergence-checks",
Reason: "drill",
Checks: map[string]startupCheckRecord{
"service-checklist": {Status: "running", Detail: "waiting"},
"critical-service-endpoints": {Status: "failed", Detail: "monitoring/victoria-metrics-single-server endpoints=0"},
"workload-convergence": {Status: "passed", Detail: "ok"},
"phase": {Status: "running", Detail: "waiting for readiness"},
},
AutoHeals: []string{"restored critical endpoint backends: monitoring/statefulset/victoria-metrics-single-server"},
}
writeStartupStatusFixture(t, filepath.Join(cfg.State.Dir, "startup-progress.json"), progress)
var loggerOut strings.Builder
logger := log.New(&loggerOut, "", 0)
stdout := captureStdout(t, func() {
if err := runStatus(logger, []string{"--config", cfgPath, "--json"}); err != nil {
t.Fatalf("runStatus failed: %v", err)
}
})
if !strings.Contains(loggerOut.String(), "startup_phase=convergence-checks") {
t.Fatalf("expected startup phase in logger output, got:\n%s", loggerOut.String())
}
if !strings.Contains(loggerOut.String(), "startup_failed_check=critical-service-endpoints") {
t.Fatalf("expected failed checklist output, got:\n%s", loggerOut.String())
}
if !strings.Contains(stdout, "\"phase\": \"convergence-checks\"") {
t.Fatalf("expected json status payload on stdout, got:\n%s", stdout)
}
}
// TestRunStatusLogsSnapshotReadError runs one orchestration or CLI step.
// Signature: TestRunStatusLogsSnapshotReadError(t *testing.T).
// Why: covers malformed snapshot parsing path without failing status command.
func TestRunStatusLogsSnapshotReadError(t *testing.T) {
restore := stubCommandHandlerHooks()
defer restore()
cfg := minimalHandlerConfig(t)
cfgPath := writeTestConfig(t)
buildOrchestratorCommand = func(_ *log.Logger, _ string, dryRun bool) (config.Config, *cluster.Orchestrator, error) {
return cfg, newTestOrchestrator(cfg, dryRun), nil
}
progressPath := filepath.Join(cfg.State.Dir, "startup-progress.json")
if err := os.MkdirAll(filepath.Dir(progressPath), 0o755); err != nil {
t.Fatalf("mkdir state dir: %v", err)
}
if err := os.WriteFile(progressPath, []byte("{bad-json"), 0o644); err != nil {
t.Fatalf("write malformed progress file: %v", err)
}
var loggerOut strings.Builder
logger := log.New(&loggerOut, "", 0)
if err := runStatus(logger, []string{"--config", cfgPath}); err != nil {
t.Fatalf("runStatus should continue on snapshot parse error: %v", err)
}
if !strings.Contains(loggerOut.String(), "startup_status_read_error=") {
t.Fatalf("expected snapshot read error log, got:\n%s", loggerOut.String())
}
}
// TestRunStartupAdditionalBranches runs one orchestration or CLI step.
// Signature: TestRunStartupAdditionalBranches(t *testing.T).
// Why: expands startup handler coverage for guard and UPS target build error branches.
func TestRunStartupAdditionalBranches(t *testing.T) {
restore := stubCommandHandlerHooks()
defer restore()
logger := log.New(io.Discard, "", 0)
buildOrchestratorCommand = func(_ *log.Logger, _ string, dryRun bool) (config.Config, *cluster.Orchestrator, error) {
cfg := minimalHandlerConfig(t)
cfg.Coordination.Role = "peer"
return cfg, newTestOrchestrator(cfg, dryRun), nil
}
tryPeerBootstrapHandoffCommand = func(_ context.Context, _ config.Config, _ *log.Logger) (bool, error) {
return false, errors.New("handoff failed")
}
coordinatorAllowsPeerFallbackCommand = func(_ context.Context, _ config.Config, _ *log.Logger) (bool, string, error) {
return false, "", errors.New("guard unavailable")
}
if err := runStartup(logger, []string{"--config", "/ignored.yaml", "--execute", "--auto-peer-failover"}); err == nil {
t.Fatalf("expected startup guard evaluation failure")
}
restore = stubCommandHandlerHooks()
defer restore()
buildOrchestratorCommand = func(_ *log.Logger, _ string, dryRun bool) (config.Config, *cluster.Orchestrator, error) {
cfg := minimalHandlerConfig(t)
cfg.Coordination.Role = "coordinator"
cfg.UPS.Enabled = true
return cfg, newTestOrchestrator(cfg, dryRun), nil
}
buildUPSTargetsCommand = func(_ config.Config) ([]service.Target, error) {
return nil, errors.New("bad ups config")
}
if err := runStartup(logger, []string{"--config", "/ignored.yaml", "--execute"}); err == nil {
t.Fatalf("expected UPS target build error")
}
}
// TestRunStartupPeerFallbackAllowedRunsStartup runs one orchestration or CLI step.
// Signature: TestRunStartupPeerFallbackAllowedRunsStartup(t *testing.T).
// Why: covers peer auto-failover branch where coordinator allows local startup fallback.
func TestRunStartupPeerFallbackAllowedRunsStartup(t *testing.T) {
restore := stubCommandHandlerHooks()
defer restore()
buildOrchestratorCommand = func(_ *log.Logger, _ string, dryRun bool) (config.Config, *cluster.Orchestrator, error) {
cfg := minimalHandlerConfig(t)
cfg.Coordination.Role = "peer"
cfg.UPS.Enabled = false
return cfg, newTestOrchestrator(cfg, dryRun), nil
}
tryPeerBootstrapHandoffCommand = func(_ context.Context, _ config.Config, _ *log.Logger) (bool, error) {
return false, nil
}
coordinatorAllowsPeerFallbackCommand = func(_ context.Context, _ config.Config, _ *log.Logger) (bool, string, error) {
return true, "allowed", nil
}
startupCalled := false
startupOrchestratorCommand = func(_ context.Context, _ *cluster.Orchestrator, _ cluster.StartupOptions) error {
startupCalled = true
return nil
}
logger := log.New(io.Discard, "", 0)
if err := runStartup(logger, []string{"--config", "/ignored.yaml", "--execute", "--auto-peer-failover"}); err != nil {
t.Fatalf("expected startup fallback success, got %v", err)
}
if !startupCalled {
t.Fatalf("expected startup fallback execution")
}
}
// TestRunStartupAllowOnBatterySkipsSafetyCheck runs one orchestration or CLI step.
// Signature: TestRunStartupAllowOnBatterySkipsSafetyCheck(t *testing.T).
// Why: covers startup battery override branch that intentionally bypasses power safety gating.
func TestRunStartupAllowOnBatterySkipsSafetyCheck(t *testing.T) {
restore := stubCommandHandlerHooks()
defer restore()
buildOrchestratorCommand = func(_ *log.Logger, _ string, dryRun bool) (config.Config, *cluster.Orchestrator, error) {
cfg := minimalHandlerConfig(t)
cfg.Coordination.Role = "coordinator"
cfg.UPS.Enabled = true
cfg.Coordination.AllowStartupOnBattery = false
return cfg, newTestOrchestrator(cfg, dryRun), nil
}
buildUPSTargetsCommand = func(_ config.Config) ([]service.Target, error) {
return nil, errors.New("should not be called")
}
startupOrchestratorCommand = func(_ context.Context, _ *cluster.Orchestrator, _ cluster.StartupOptions) error {
return nil
}
logger := log.New(io.Discard, "", 0)
if err := runStartup(logger, []string{"--config", "/ignored.yaml", "--execute", "--allow-on-battery"}); err != nil {
t.Fatalf("expected allow-on-battery startup success, got %v", err)
}
}
// stubCommandHandlerHooks runs one orchestration or CLI step.
// Signature: stubCommandHandlerHooks() func().
// Why: keeps handler hook overrides isolated to each test to avoid cross-test bleed.
func stubCommandHandlerHooks() func() {
prevBuild := buildOrchestratorCommand
prevHandoff := tryPeerBootstrapHandoffCommand
prevGuard := coordinatorAllowsPeerFallbackCommand
prevBuildUPS := buildUPSTargetsCommand
prevPowerSafe := ensureStartupPowerSafeCommand
prevStartup := startupOrchestratorCommand
prevShutdown := shutdownOrchestratorCommand
prevRestore := etcdRestoreOrchestratorCommand
prevDaemon := daemonRunCommand
prevReadIntent := readIntentCommand
prevWriteIntent := writeIntentCommand
return func() {
buildOrchestratorCommand = prevBuild
tryPeerBootstrapHandoffCommand = prevHandoff
coordinatorAllowsPeerFallbackCommand = prevGuard
buildUPSTargetsCommand = prevBuildUPS
ensureStartupPowerSafeCommand = prevPowerSafe
startupOrchestratorCommand = prevStartup
shutdownOrchestratorCommand = prevShutdown
etcdRestoreOrchestratorCommand = prevRestore
daemonRunCommand = prevDaemon
readIntentCommand = prevReadIntent
writeIntentCommand = prevWriteIntent
}
}
// minimalHandlerConfig runs one orchestration or CLI step.
// Signature: minimalHandlerConfig(t *testing.T) config.Config.
// Why: command handler tests need a stable config baseline without reaching external systems.
func minimalHandlerConfig(t *testing.T) config.Config {
t.Helper()
stateDir := t.TempDir()
return config.Config{
ControlPlanes: []string{"titan-0a"},
Workers: []string{"titan-22"},
SSHUser: "atlas",
SSHPort: 2277,
Startup: config.Startup{
MinimumBatteryPercent: 20,
},
Shutdown: config.Shutdown{
DefaultBudgetSeconds: 1380,
HistoryMinSamples: 3,
EmergencyBudgetSec: 420,
EmergencyMinSamples: 3,
},
State: config.State{
Dir: stateDir,
ReportsDir: filepath.Join(stateDir, "reports"),
RunHistoryPath: filepath.Join(stateDir, "runs.json"),
LockPath: filepath.Join(stateDir, "ananke.lock"),
IntentPath: filepath.Join(stateDir, "intent.json"),
},
}
}
// newTestOrchestrator runs one orchestration or CLI step.
// Signature: newTestOrchestrator(cfg config.Config, dryRun bool) *cluster.Orchestrator.
// Why: command handler tests need a concrete orchestrator pointer for method signatures while hooks intercept execution.
func newTestOrchestrator(cfg config.Config, dryRun bool) *cluster.Orchestrator {
return cluster.New(
cfg,
&execx.Runner{DryRun: dryRun, Logger: log.New(io.Discard, "", 0)},
state.New(cfg.State.RunHistoryPath),
log.New(io.Discard, "", 0),
)
}
// captureStdout runs one orchestration or CLI step.
// Signature: captureStdout(t *testing.T, fn func()) string.
// Why: status command optionally emits JSON to stdout, so tests need deterministic capture.
func captureStdout(t *testing.T, fn func()) string {
t.Helper()
orig := os.Stdout
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("pipe: %v", err)
}
os.Stdout = w
done := make(chan string, 1)
go func() {
b, _ := io.ReadAll(r)
done <- string(b)
}()
fn()
_ = w.Close()
os.Stdout = orig
return <-done
}

View File

@ -0,0 +1,244 @@
package main
import (
"context"
"errors"
"io"
"log"
"os"
"path/filepath"
"strings"
"testing"
"time"
"scm.bstein.dev/bstein/ananke/internal/cluster"
"scm.bstein.dev/bstein/ananke/internal/config"
"scm.bstein.dev/bstein/ananke/internal/service"
"scm.bstein.dev/bstein/ananke/internal/state"
)
// TestRunDaemonErrorBranches runs one orchestration or CLI step.
// Signature: TestRunDaemonErrorBranches(t *testing.T).
// Why: covers daemon configuration and runtime error branches.
func TestRunDaemonErrorBranches(t *testing.T) {
restore := stubCommandHandlerHooks()
defer restore()
logger := log.New(io.Discard, "", 0)
cfgPath := writeTestConfig(t)
buildOrchestratorCommand = func(_ *log.Logger, _ string, dryRun bool) (config.Config, *cluster.Orchestrator, error) {
cfg, err := config.Load(cfgPath)
if err != nil {
return config.Config{}, nil, err
}
cfg.UPS.Enabled = false
return cfg, newTestOrchestrator(cfg, dryRun), nil
}
if err := runDaemon(logger, []string{"--config", cfgPath}); err == nil {
t.Fatalf("expected daemon UPS-disabled error")
}
restore = stubCommandHandlerHooks()
defer restore()
buildOrchestratorCommand = func(_ *log.Logger, _ string, dryRun bool) (config.Config, *cluster.Orchestrator, error) {
cfg, err := config.Load(cfgPath)
if err != nil {
return config.Config{}, nil, err
}
cfg.UPS.Enabled = true
return cfg, newTestOrchestrator(cfg, dryRun), nil
}
buildUPSTargetsCommand = func(_ config.Config) ([]service.Target, error) {
return nil, errors.New("targets failed")
}
if err := runDaemon(logger, []string{"--config", cfgPath}); err == nil {
t.Fatalf("expected buildUPSTargets error")
}
restore = stubCommandHandlerHooks()
defer restore()
buildOrchestratorCommand = func(_ *log.Logger, _ string, dryRun bool) (config.Config, *cluster.Orchestrator, error) {
cfg, err := config.Load(cfgPath)
if err != nil {
return config.Config{}, nil, err
}
cfg.UPS.Enabled = true
return cfg, newTestOrchestrator(cfg, dryRun), nil
}
buildUPSTargetsCommand = func(_ config.Config) ([]service.Target, error) {
return []service.Target{{Name: "Pyrphoros", Target: "pyrphoros@localhost"}}, nil
}
daemonRunCommand = func(_ context.Context, _ *service.Daemon) error {
return errors.New("daemon crashed")
}
if err := runDaemon(logger, []string{"--config", cfgPath}); err == nil {
t.Fatalf("expected daemon runtime error")
}
}
// TestRunIntentErrorBranches runs one orchestration or CLI step.
// Signature: TestRunIntentErrorBranches(t *testing.T).
// Why: covers intent read error and intent write error branches.
func TestRunIntentErrorBranches(t *testing.T) {
restore := stubCommandHandlerHooks()
defer restore()
cfgPath := writeTestConfig(t)
logger := log.New(io.Discard, "", 0)
readIntentCommand = func(_ string) (state.Intent, error) {
return state.Intent{}, errors.New("read intent failed")
}
if err := runIntent(logger, []string{"--config", cfgPath}); err == nil {
t.Fatalf("expected read intent error")
}
writeIntentCommand = func(_ string, _, _, _ string) error {
return errors.New("write failed")
}
if err := runIntent(logger, []string{"--config", cfgPath, "--set", state.IntentNormal, "--execute"}); err == nil {
t.Fatalf("expected write intent error")
}
}
// TestRunShutdownAndEtcdRestoreBuildError runs one orchestration or CLI step.
// Signature: TestRunShutdownAndEtcdRestoreBuildError(t *testing.T).
// Why: covers orchestrator build failure propagation in shutdown and restore handlers.
func TestRunShutdownAndEtcdRestoreBuildError(t *testing.T) {
restore := stubCommandHandlerHooks()
defer restore()
buildOrchestratorCommand = func(_ *log.Logger, _ string, _ bool) (config.Config, *cluster.Orchestrator, error) {
return config.Config{}, nil, errors.New("build failed")
}
logger := log.New(io.Discard, "", 0)
if err := runShutdown(logger, []string{"--config", "/ignored"}); err == nil {
t.Fatalf("expected shutdown build error")
}
if err := runEtcdRestore(logger, []string{"--config", "/ignored"}); err == nil {
t.Fatalf("expected etcd restore build error")
}
if err := runStatus(logger, []string{"--config", "/ignored"}); err == nil {
t.Fatalf("expected status build error")
}
}
// TestLoadStartupStatusSnapshotReadError runs one orchestration or CLI step.
// Signature: TestLoadStartupStatusSnapshotReadError(t *testing.T).
// Why: covers file read errors for startup status snapshots.
func TestLoadStartupStatusSnapshotReadError(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "startup-progress.json")
if err := os.MkdirAll(path, 0o755); err != nil {
t.Fatalf("mkdir unexpected progress path: %v", err)
}
_, _, err := loadStartupStatusSnapshot(tmp)
if err == nil {
t.Fatalf("expected snapshot read error")
}
}
// TestRunStatusNoSnapshotIntentReadError runs one orchestration or CLI step.
// Signature: TestRunStatusNoSnapshotIntentReadError(t *testing.T).
// Why: covers status output when no startup report exists and intent read fails.
func TestRunStatusNoSnapshotIntentReadError(t *testing.T) {
restore := stubCommandHandlerHooks()
defer restore()
cfg := minimalHandlerConfig(t)
cfgPath := writeTestConfig(t)
buildOrchestratorCommand = func(_ *log.Logger, _ string, dryRun bool) (config.Config, *cluster.Orchestrator, error) {
return cfg, newTestOrchestrator(cfg, dryRun), nil
}
readIntentCommand = func(_ string) (state.Intent, error) {
return state.Intent{}, errors.New("intent unavailable")
}
var loggerOut strings.Builder
logger := log.New(&loggerOut, "", 0)
if err := runStatus(logger, []string{"--config", cfgPath}); err != nil {
t.Fatalf("runStatus failed: %v", err)
}
if !strings.Contains(loggerOut.String(), "intent_read_error=") {
t.Fatalf("expected intent read error in output, got:\n%s", loggerOut.String())
}
}
// TestRunStatusDerivesFallbackStatusFields runs one orchestration or CLI step.
// Signature: TestRunStatusDerivesFallbackStatusFields(t *testing.T).
// Why: covers status/phase fallback derivation for snapshots missing explicit status fields.
func TestRunStatusDerivesFallbackStatusFields(t *testing.T) {
restore := stubCommandHandlerHooks()
defer restore()
cfg := minimalHandlerConfig(t)
cfgPath := writeTestConfig(t)
buildOrchestratorCommand = func(_ *log.Logger, _ string, dryRun bool) (config.Config, *cluster.Orchestrator, error) {
return cfg, newTestOrchestrator(cfg, dryRun), nil
}
progress := startupStatusSnapshot{
StartedAt: time.Now().UTC().Add(-10 * time.Second),
Checks: map[string]startupCheckRecord{
"phase": {Status: "running", Detail: "waiting"},
},
}
writeStartupStatusFixture(t, filepath.Join(cfg.State.Dir, "startup-progress.json"), progress)
var loggerOut strings.Builder
logger := log.New(&loggerOut, "", 0)
if err := runStatus(logger, []string{"--config", cfgPath}); err != nil {
t.Fatalf("runStatus failed: %v", err)
}
out := loggerOut.String()
if !strings.Contains(out, "startup_status=running") {
t.Fatalf("expected derived running status, got:\n%s", out)
}
if !strings.Contains(out, "startup_phase=unknown") {
t.Fatalf("expected derived unknown phase fallback, got:\n%s", out)
}
}
// TestRunStatusDerivesTerminalFailedStatus runs one orchestration or CLI step.
// Signature: TestRunStatusDerivesTerminalFailedStatus(t *testing.T).
// Why: covers derived failed status when a completed report omits explicit status.
func TestRunStatusDerivesTerminalFailedStatus(t *testing.T) {
restore := stubCommandHandlerHooks()
defer restore()
cfg := minimalHandlerConfig(t)
cfgPath := writeTestConfig(t)
buildOrchestratorCommand = func(_ *log.Logger, _ string, dryRun bool) (config.Config, *cluster.Orchestrator, error) {
return cfg, newTestOrchestrator(cfg, dryRun), nil
}
report := startupStatusSnapshot{
StartedAt: time.Now().UTC().Add(-2 * time.Minute),
Completed: time.Now().UTC(),
Success: false,
Checks: map[string]startupCheckRecord{},
}
writeStartupStatusFixture(t, filepath.Join(cfg.State.Dir, "last-startup-report.json"), report)
var loggerOut strings.Builder
logger := log.New(&loggerOut, "", 0)
if err := runStatus(logger, []string{"--config", cfgPath}); err != nil {
t.Fatalf("runStatus failed: %v", err)
}
if !strings.Contains(loggerOut.String(), "startup_status=failed") {
t.Fatalf("expected derived failed status, got:\n%s", loggerOut.String())
}
}
// TestLoadStartupStatusSnapshotInitializesChecks runs one orchestration or CLI step.
// Signature: TestLoadStartupStatusSnapshotInitializesChecks(t *testing.T).
// Why: covers nil-check-map initialization path in snapshot loading.
func TestLoadStartupStatusSnapshotInitializesChecks(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "startup-progress.json")
if err := os.WriteFile(path, []byte(`{"status":"running"}`), 0o644); err != nil {
t.Fatalf("write snapshot: %v", err)
}
snapshot, _, err := loadStartupStatusSnapshot(tmp)
if err != nil {
t.Fatalf("load snapshot failed: %v", err)
}
if snapshot == nil || snapshot.Checks == nil {
t.Fatalf("expected snapshot with initialized checks map")
}
}

View File

@ -0,0 +1,79 @@
package main
import (
"io"
"log"
"strings"
"testing"
)
// TestRunStatusAndIntentReadOnly runs one orchestration or CLI step.
// Signature: TestRunStatusAndIntentReadOnly(t *testing.T).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func TestRunStatusAndIntentReadOnly(t *testing.T) {
cfgPath := writeTestConfig(t)
logger := log.New(io.Discard, "", 0)
if err := runStatus(logger, []string{"--config", cfgPath}); err != nil {
t.Fatalf("runStatus failed: %v", err)
}
if err := runIntent(logger, []string{"--config", cfgPath}); err != nil {
t.Fatalf("runIntent read-only failed: %v", err)
}
}
// TestRunShutdownDryRunClusterOnly runs one orchestration or CLI step.
// Signature: TestRunShutdownDryRunClusterOnly(t *testing.T).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func TestRunShutdownDryRunClusterOnly(t *testing.T) {
cfgPath := writeTestConfig(t)
logger := log.New(io.Discard, "", 0)
if err := runShutdown(logger, []string{"--config", cfgPath, "--mode", "cluster-only"}); err != nil {
t.Fatalf("runShutdown dry-run failed: %v", err)
}
}
// TestRunShutdownRejectsPoweroffMode runs one orchestration or CLI step.
// Signature: TestRunShutdownRejectsPoweroffMode(t *testing.T).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func TestRunShutdownRejectsPoweroffMode(t *testing.T) {
cfgPath := writeTestConfig(t)
logger := log.New(io.Discard, "", 0)
err := runShutdown(logger, []string{"--config", cfgPath, "--mode", "poweroff"})
if err == nil {
t.Fatalf("expected poweroff mode rejection")
}
if !strings.Contains(err.Error(), "no longer powers off hosts") {
t.Fatalf("unexpected error: %v", err)
}
}
// TestRunIntentWriteRequiresExecute runs one orchestration or CLI step.
// Signature: TestRunIntentWriteRequiresExecute(t *testing.T).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func TestRunIntentWriteRequiresExecute(t *testing.T) {
cfgPath := writeTestConfig(t)
logger := log.New(io.Discard, "", 0)
err := runIntent(logger, []string{"--config", cfgPath, "--set", "normal"})
if err == nil {
t.Fatalf("expected write without --execute to fail")
}
if !strings.Contains(err.Error(), "without --execute") {
t.Fatalf("unexpected error: %v", err)
}
}
// TestRunIntentWriteExecute runs one orchestration or CLI step.
// Signature: TestRunIntentWriteExecute(t *testing.T).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func TestRunIntentWriteExecute(t *testing.T) {
cfgPath := writeTestConfig(t)
logger := log.New(io.Discard, "", 0)
if err := runIntent(logger, []string{"--config", cfgPath, "--set", "normal", "--reason", "test", "--source", "unit", "--execute"}); err != nil {
t.Fatalf("runIntent write failed: %v", err)
}
}

View File

@ -1,651 +0,0 @@
package main
import (
"context"
"errors"
"flag"
"fmt"
"log"
"math"
"os"
"os/exec"
"os/signal"
"strconv"
"strings"
"syscall"
"time"
"scm.bstein.dev/bstein/ananke/internal/cluster"
"scm.bstein.dev/bstein/ananke/internal/config"
"scm.bstein.dev/bstein/ananke/internal/execx"
"scm.bstein.dev/bstein/ananke/internal/service"
"scm.bstein.dev/bstein/ananke/internal/sshutil"
"scm.bstein.dev/bstein/ananke/internal/state"
"scm.bstein.dev/bstein/ananke/internal/ups"
)
func main() {
logger := log.New(os.Stdout, "[ananke] ", log.LstdFlags)
if len(os.Args) < 2 {
usage()
os.Exit(2)
}
sub := os.Args[1]
switch sub {
case "startup":
if err := runStartup(logger, os.Args[2:]); err != nil {
logger.Printf("startup failed: %v", err)
os.Exit(1)
}
case "shutdown":
if err := runShutdown(logger, os.Args[2:]); err != nil {
logger.Printf("shutdown failed: %v", err)
os.Exit(1)
}
case "etcd-restore":
if err := runEtcdRestore(logger, os.Args[2:]); err != nil {
logger.Printf("etcd-restore failed: %v", err)
os.Exit(1)
}
case "daemon":
if err := runDaemon(logger, os.Args[2:]); err != nil {
logger.Printf("daemon failed: %v", err)
os.Exit(1)
}
case "status":
if err := runStatus(logger, os.Args[2:]); err != nil {
logger.Printf("status failed: %v", err)
os.Exit(1)
}
case "intent":
if err := runIntent(logger, os.Args[2:]); err != nil {
logger.Printf("intent failed: %v", err)
os.Exit(1)
}
case "help", "-h", "--help":
usage()
default:
logger.Printf("unknown command: %s", sub)
usage()
os.Exit(2)
}
}
func runStartup(logger *log.Logger, args []string) error {
fs := flag.NewFlagSet("startup", flag.ExitOnError)
configPath := fs.String("config", "/etc/ananke/ananke.yaml", "Path to config file")
execute := fs.Bool("execute", false, "Actually execute changes (default dry-run)")
forceBranch := fs.String("force-flux-branch", "", "Patch Flux source branch before resume")
skipLocalBootstrap := fs.Bool("skip-local-bootstrap", false, "Skip local fallback bootstrap applies")
allowPeerStartup := fs.Bool("allow-peer-startup", false, "Allow startup to run on a peer instance")
autoPeerFailover := fs.Bool("auto-peer-failover", false, "On peer role, try coordinator bootstrap handoff first and only run local startup as fallback")
peerWaitSeconds := fs.Int("peer-wait-seconds", 180, "How long auto peer failover waits for coordinator handoff before local fallback startup")
allowOnBattery := fs.Bool("allow-on-battery", false, "Allow startup when UPS reports on-battery")
reason := fs.String("reason", "manual-startup", "Startup reason for run history")
_ = fs.Parse(args)
cfg, orch, err := buildOrchestrator(logger, *configPath, !*execute)
if err != nil {
return err
}
allowPeer := *allowPeerStartup
if *execute {
if cfg.Coordination.Role == "peer" && !allowPeer {
if *autoPeerFailover {
handoffCtx, cancel := context.WithTimeout(context.Background(), time.Duration(maxInt(*peerWaitSeconds, 1))*time.Second)
defer cancel()
handoff, handoffErr := tryPeerBootstrapHandoff(handoffCtx, cfg, logger)
if handoffErr != nil {
logger.Printf("warning: peer bootstrap handoff failed: %v", handoffErr)
}
if handoff {
logger.Printf("peer startup handoff complete; skipping local startup")
return nil
}
guardCtx, guardCancel := context.WithTimeout(context.Background(), time.Duration(maxInt(cfg.Coordination.CommandTimeoutSeconds, 15))*time.Second)
defer guardCancel()
allowed, guardReason, guardErr := coordinatorAllowsPeerFallbackStartup(guardCtx, cfg, logger)
if guardErr != nil {
return fmt.Errorf("startup blocked: unable to evaluate coordinator startup guard: %w", guardErr)
}
if !allowed {
return fmt.Errorf("startup blocked: coordinator guard disallowed peer fallback (%s)", guardReason)
}
logger.Printf("peer startup handoff unavailable; proceeding with local peer startup fallback")
allowPeer = true
} else {
return fmt.Errorf("startup blocked: this instance is configured as role=peer (use --allow-peer-startup to override)")
}
}
if cfg.UPS.Enabled && !cfg.Coordination.AllowStartupOnBattery && !*allowOnBattery {
targets, targetErr := buildUPSTargets(cfg)
if targetErr != nil {
return targetErr
}
checkCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := ensureStartupPowerSafe(checkCtx, targets, cfg.Startup.MinimumBatteryPercent); err != nil {
return err
}
}
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
return orch.Startup(ctx, cluster.StartupOptions{
ForceFluxBranch: *forceBranch,
SkipLocalBootstrap: *skipLocalBootstrap,
Reason: *reason,
})
}
func runShutdown(logger *log.Logger, args []string) error {
fs := flag.NewFlagSet("shutdown", flag.ExitOnError)
configPath := fs.String("config", "/etc/ananke/ananke.yaml", "Path to config file")
execute := fs.Bool("execute", false, "Actually execute changes (default dry-run)")
skipEtcd := fs.Bool("skip-etcd-snapshot", false, "Skip etcd snapshot")
skipDrain := fs.Bool("skip-drain", false, "Skip worker drain")
mode := fs.String("mode", "cluster-only", "Shutdown mode: config|cluster-only|poweroff")
reason := fs.String("reason", "manual-shutdown", "Shutdown reason for run history")
_ = fs.Parse(args)
_, orch, err := buildOrchestrator(logger, *configPath, !*execute)
if err != nil {
return err
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
return orch.Shutdown(ctx, cluster.ShutdownOptions{
SkipEtcdSnapshot: *skipEtcd,
SkipDrain: *skipDrain,
Mode: *mode,
Reason: *reason,
})
}
func runDaemon(logger *log.Logger, args []string) error {
fs := flag.NewFlagSet("daemon", flag.ExitOnError)
configPath := fs.String("config", "/etc/ananke/ananke.yaml", "Path to config file")
dryRunActions := fs.Bool("dry-run-actions", false, "Log planned actions without executing")
_ = fs.Parse(args)
cfg, orch, err := buildOrchestrator(logger, *configPath, *dryRunActions)
if err != nil {
return err
}
if !cfg.UPS.Enabled {
return fmt.Errorf("UPS monitoring is disabled in config")
}
targets, err := buildUPSTargets(cfg)
if err != nil {
return err
}
d := service.NewDaemon(cfg, orch, targets, logger)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := d.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
return err
}
return nil
}
func runEtcdRestore(logger *log.Logger, args []string) error {
fs := flag.NewFlagSet("etcd-restore", flag.ExitOnError)
configPath := fs.String("config", "/etc/ananke/ananke.yaml", "Path to config file")
execute := fs.Bool("execute", false, "Actually execute restore (default dry-run)")
controlPlane := fs.String("control-plane", "", "Control plane to run restore on (defaults to startup.etcd_restore_control_plane)")
snapshotPath := fs.String("snapshot", "", "Explicit snapshot path (defaults to latest on selected control plane)")
_ = fs.Parse(args)
_, orch, err := buildOrchestrator(logger, *configPath, !*execute)
if err != nil {
return err
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
return orch.EtcdRestore(ctx, cluster.EtcdRestoreOptions{
ControlPlane: *controlPlane,
SnapshotPath: *snapshotPath,
})
}
func runStatus(logger *log.Logger, args []string) error {
fs := flag.NewFlagSet("status", flag.ExitOnError)
configPath := fs.String("config", "/etc/ananke/ananke.yaml", "Path to config file")
_ = fs.Parse(args)
cfg, orch, err := buildOrchestrator(logger, *configPath, true)
if err != nil {
return err
}
recs, err := state.New(cfg.State.RunHistoryPath).Load()
if err != nil {
return err
}
last := "none"
if len(recs) > 0 {
r := recs[len(recs)-1]
last = fmt.Sprintf("%s success=%t duration=%ds at=%s", r.Action, r.Success, r.DurationSeconds, r.EndedAt.Format(time.RFC3339))
}
logger.Printf("expected_flux_branch=%s", cfg.ExpectedFluxBranch)
logger.Printf("control_planes=%v", cfg.ControlPlanes)
logger.Printf("estimated_shutdown_budget_seconds=%d", orch.EstimatedShutdownSeconds())
logger.Printf("estimated_emergency_shutdown_budget_seconds=%d", orch.EstimatedEmergencyShutdownSeconds())
intent, intentErr := state.ReadIntent(cfg.State.IntentPath)
if intentErr != nil {
logger.Printf("intent_read_error=%v", intentErr)
} else if intent.State == "" {
logger.Printf("intent=none")
} else {
logger.Printf("intent=%s reason=%q source=%s updated_at=%s",
intent.State, intent.Reason, intent.Source, intent.UpdatedAt.Format(time.RFC3339))
}
logger.Printf("last_run=%s", last)
return nil
}
func runIntent(logger *log.Logger, args []string) error {
fs := flag.NewFlagSet("intent", flag.ExitOnError)
configPath := fs.String("config", "/etc/ananke/ananke.yaml", "Path to config file")
setState := fs.String("set", "", "Set intent state (normal|startup_in_progress|shutting_down|shutdown_complete)")
reason := fs.String("reason", "manual-intent", "Intent reason (used with --set)")
source := fs.String("source", "manual", "Intent source (used with --set)")
execute := fs.Bool("execute", false, "Actually write intent state (default read-only)")
_ = fs.Parse(args)
cfg, err := config.Load(*configPath)
if err != nil {
return err
}
if strings.TrimSpace(*setState) == "" {
in, readErr := state.ReadIntent(cfg.State.IntentPath)
if readErr != nil {
return readErr
}
if in.State == "" {
logger.Printf("intent=none")
return nil
}
logger.Printf("intent=%s reason=%q source=%s updated_at=%s",
in.State, in.Reason, in.Source, in.UpdatedAt.Format(time.RFC3339))
return nil
}
if !*execute {
return fmt.Errorf("refusing to write intent without --execute")
}
stateValue := strings.TrimSpace(*setState)
if err := state.MustWriteIntent(cfg.State.IntentPath, stateValue, *reason, *source); err != nil {
return err
}
logger.Printf("intent updated: state=%s reason=%q source=%s", stateValue, *reason, *source)
return nil
}
func buildUPSTargets(cfg config.Config) ([]service.Target, error) {
targets := make([]service.Target, 0, len(cfg.UPS.Targets)+1)
switch cfg.UPS.Provider {
case "nut":
if len(cfg.UPS.Targets) == 0 {
target := cfg.UPS.Target
if target == "" {
return nil, fmt.Errorf("ups target must be set")
}
targets = append(targets, service.Target{
Name: "primary",
Target: target,
Provider: ups.NewNUTProvider(target),
})
} else {
for idx, t := range cfg.UPS.Targets {
name := t.Name
if name == "" {
name = fmt.Sprintf("target-%d", idx+1)
}
targets = append(targets, service.Target{
Name: name,
Target: t.Target,
Provider: ups.NewNUTProvider(t.Target),
})
}
}
default:
return nil, fmt.Errorf("unsupported UPS provider: %s", cfg.UPS.Provider)
}
return targets, nil
}
func ensureStartupPowerSafe(ctx context.Context, targets []service.Target, minimumBatteryPercent float64) error {
type targetState struct {
seenGood bool
lastErr error
}
states := make(map[string]*targetState, len(targets))
for _, t := range targets {
key := t.Name + "|" + t.Target
states[key] = &targetState{}
}
const pollInterval = 3 * time.Second
for {
onBatteryTargets := []string{}
lowChargeTargets := []string{}
allSeen := true
for _, t := range targets {
key := t.Name + "|" + t.Target
st := states[key]
sample, err := t.Provider.Read(ctx)
if err != nil {
st.lastErr = err
if !st.seenGood {
allSeen = false
}
continue
}
st.seenGood = true
st.lastErr = nil
if sample.OnBattery {
onBatteryTargets = append(onBatteryTargets, fmt.Sprintf("%s(status=%s runtime_s=%d)", t.Name, sample.RawStatus, sample.RuntimeSeconds))
}
if minimumBatteryPercent > 0 && sample.BatteryCharge > 0 && sample.BatteryCharge < minimumBatteryPercent {
lowChargeTargets = append(
lowChargeTargets,
fmt.Sprintf(
"%s(charge=%.1f%%<%.1f%% status=%s)",
t.Name,
sample.BatteryCharge,
minimumBatteryPercent,
sample.RawStatus,
),
)
}
}
if len(onBatteryTargets) > 0 {
return fmt.Errorf("startup blocked: UPS is on battery for %s", strings.Join(onBatteryTargets, ", "))
}
if len(lowChargeTargets) > 0 {
return fmt.Errorf("startup blocked: UPS battery charge below minimum for %s", strings.Join(lowChargeTargets, ", "))
}
if allSeen {
return nil
}
select {
case <-ctx.Done():
unverified := make([]string, 0, len(targets))
for _, t := range targets {
key := t.Name + "|" + t.Target
st := states[key]
if st.seenGood {
continue
}
if st.lastErr != nil {
unverified = append(unverified, fmt.Sprintf("%s(%s): %v", t.Name, t.Target, st.lastErr))
} else {
unverified = append(unverified, fmt.Sprintf("%s(%s): no telemetry sample yet", t.Name, t.Target))
}
}
roundedMin := math.Round(minimumBatteryPercent*10) / 10
return fmt.Errorf("startup blocked: unable to verify UPS telemetry before timeout (minimum_battery_percent=%.1f): %s", roundedMin, strings.Join(unverified, " | "))
case <-time.After(pollInterval):
}
}
}
func buildOrchestrator(logger *log.Logger, cfgPath string, dryRun bool) (config.Config, *cluster.Orchestrator, error) {
cfg, err := config.Load(cfgPath)
if err != nil {
return config.Config{}, nil, err
}
if err := state.EnsureDir(cfg.State.Dir); err != nil {
return config.Config{}, nil, err
}
runner := &execx.Runner{
DryRun: dryRun,
Kubeconfig: cfg.Kubeconfig,
Logger: logger,
}
store := state.New(cfg.State.RunHistoryPath)
orch := cluster.New(cfg, runner, store, logger)
return cfg, orch, nil
}
func usage() {
fmt.Print(`ananke: staged startup/shutdown + UPS-triggered protection
Usage:
ananke <command> [flags]
Commands:
startup Perform staged cluster startup
shutdown Perform graceful cluster shutdown
etcd-restore Restore etcd from snapshot on a control plane
daemon Monitor UPS and auto-trigger shutdown
status Print current ananke status and estimates
intent Read or manually set intent state
Examples:
ananke startup --config /etc/ananke/ananke.yaml --execute --force-flux-branch main
ananke shutdown --config /etc/ananke/ananke.yaml --execute --mode cluster-only --reason "manual-maintenance"
ananke etcd-restore --config /etc/ananke/ananke.yaml --execute
ananke daemon --config /etc/ananke/ananke.yaml
ananke status --config /etc/ananke/ananke.yaml
ananke intent --config /etc/ananke/ananke.yaml --set normal --reason "manual-clear" --execute
`)
}
func tryPeerBootstrapHandoff(ctx context.Context, cfg config.Config, logger *log.Logger) (bool, error) {
coordinator := strings.TrimSpace(cfg.Coordination.ForwardShutdownHost)
if coordinator == "" {
return false, fmt.Errorf("coordination.forward_shutdown_host is empty for peer role")
}
user := strings.TrimSpace(cfg.Coordination.ForwardShutdownUser)
if user == "" {
if override, ok := cfg.SSHNodeUsers[coordinator]; ok && strings.TrimSpace(override) != "" {
user = strings.TrimSpace(override)
} else {
user = strings.TrimSpace(cfg.SSHUser)
}
}
host := coordinator
if mapped, ok := cfg.SSHNodeHosts[coordinator]; ok && strings.TrimSpace(mapped) != "" {
host = strings.TrimSpace(mapped)
}
target := host
if user != "" {
target = user + "@" + host
}
args := buildSSHBaseArgs(cfg)
remote := "sudo -n systemctl start ananke-bootstrap.service"
attempt := 1
for {
cmdArgs := append(append([]string{}, args...), target, remote)
_, err := runSSHWithRecovery(ctx, logger, cfg, cmdArgs, []string{coordinator, host, cfg.SSHJumpHost})
if err == nil {
logger.Printf("peer bootstrap handoff succeeded on %s (attempt=%d)", coordinator, attempt)
return true, nil
}
logger.Printf("peer bootstrap handoff attempt %d failed for %s: %v", attempt, coordinator, err)
select {
case <-ctx.Done():
return false, fmt.Errorf("coordinator handoff timeout for %s: %w", coordinator, ctx.Err())
case <-time.After(5 * time.Second):
attempt++
}
}
}
func coordinatorAllowsPeerFallbackStartup(ctx context.Context, cfg config.Config, logger *log.Logger) (bool, string, error) {
coordinator := strings.TrimSpace(cfg.Coordination.ForwardShutdownHost)
if coordinator == "" {
return true, "no coordinator configured", nil
}
user := strings.TrimSpace(cfg.Coordination.ForwardShutdownUser)
if user == "" {
if override, ok := cfg.SSHNodeUsers[coordinator]; ok && strings.TrimSpace(override) != "" {
user = strings.TrimSpace(override)
} else {
user = strings.TrimSpace(cfg.SSHUser)
}
}
host := coordinator
if mapped, ok := cfg.SSHNodeHosts[coordinator]; ok && strings.TrimSpace(mapped) != "" {
host = strings.TrimSpace(mapped)
}
target := host
if user != "" {
target = user + "@" + host
}
remoteCmd := "if sudo -n /usr/bin/systemctl is-active --quiet ananke-bootstrap.service; then echo __ANANKE_BOOTSTRAP_ACTIVE__; else echo __ANANKE_BOOTSTRAP_IDLE__; fi; sudo -n /usr/local/bin/ananke intent --config /etc/ananke/ananke.yaml"
args := append(buildSSHBaseArgs(cfg), target, remoteCmd)
out, err := runSSHWithRecovery(ctx, logger, cfg, args, []string{coordinator, host, cfg.SSHJumpHost})
if err != nil {
logger.Printf("warning: coordinator guard check unavailable on %s: %v; allowing peer fallback startup", coordinator, err)
return true, "coordinator unreachable", nil
}
trimmed := strings.TrimSpace(out)
if strings.Contains(trimmed, "__ANANKE_BOOTSTRAP_ACTIVE__") {
return false, "coordinator bootstrap service is active", nil
}
remoteIntent, parseErr := state.ParseIntentOutput(trimmed)
if parseErr != nil {
return false, "", fmt.Errorf("decode coordinator intent: %w", parseErr)
}
if remoteIntent.State == "" || remoteIntent.State == state.IntentNormal {
return true, "coordinator intent is normal", nil
}
guardAge := time.Duration(maxInt(cfg.Coordination.StartupGuardMaxAgeSec, 60)) * time.Second
intentAge := time.Duration(0)
if !remoteIntent.UpdatedAt.IsZero() {
intentAge = time.Since(remoteIntent.UpdatedAt)
}
switch remoteIntent.State {
case state.IntentShuttingDown:
if remoteIntent.UpdatedAt.IsZero() || intentAge <= guardAge {
return false, fmt.Sprintf("coordinator intent=%s age=%s reason=%q", remoteIntent.State, intentAge.Round(time.Second), remoteIntent.Reason), nil
}
logger.Printf("warning: coordinator shutdown intent appears stale (age=%s > guard=%s); allowing peer fallback startup", intentAge.Round(time.Second), guardAge)
return true, "coordinator shutdown intent stale", nil
case state.IntentStartupInProgress:
if remoteIntent.UpdatedAt.IsZero() || intentAge <= guardAge {
return false, fmt.Sprintf("coordinator intent=%s age=%s reason=%q", remoteIntent.State, intentAge.Round(time.Second), remoteIntent.Reason), nil
}
logger.Printf("warning: coordinator startup intent appears stale (age=%s > guard=%s); allowing peer fallback startup", intentAge.Round(time.Second), guardAge)
return true, "coordinator startup intent stale", nil
case state.IntentShutdownComplete:
if remoteIntent.UpdatedAt.IsZero() {
return false, "coordinator reported shutdown_complete with unknown age", nil
}
if intentAge <= startupShutdownCooldown(cfg) {
return false, fmt.Sprintf("coordinator recently completed shutdown (%s ago)", intentAge.Round(time.Second)), nil
}
return true, "coordinator shutdown_complete is old enough", nil
default:
return false, fmt.Sprintf("coordinator intent state %q is unknown", remoteIntent.State), nil
}
}
func runSSHWithRecovery(ctx context.Context, logger *log.Logger, cfg config.Config, args []string, repairHosts []string) (string, error) {
try := func() (string, error) {
cmd := exec.CommandContext(ctx, "ssh", args...)
out, err := cmd.CombinedOutput()
trimmed := strings.TrimSpace(string(out))
if err != nil {
if trimmed == "" {
return "", fmt.Errorf("ssh failed: %w", err)
}
return trimmed, fmt.Errorf("ssh failed: %w: %s", err, trimmed)
}
return trimmed, nil
}
out, err := try()
if err == nil {
return out, nil
}
if !sshutil.ShouldAttemptKnownHostsRepair(out, err) {
return out, err
}
sshutil.RepairKnownHosts(ctx, logger, sshutil.KnownHostsFiles(resolveSSHConfigFile(cfg), resolveSSHIdentityFile(cfg)), repairHosts, cfg.SSHPort)
return try()
}
func buildSSHBaseArgs(cfg config.Config) []string {
args := []string{
"-o", "BatchMode=yes",
"-o", "ConnectTimeout=8",
"-o", "StrictHostKeyChecking=accept-new",
}
if cfgPath := resolveSSHConfigFile(cfg); cfgPath != "" {
args = append(args, "-F", cfgPath)
}
if idPath := resolveSSHIdentityFile(cfg); idPath != "" {
args = append(args, "-i", idPath)
}
if cfg.SSHPort > 0 {
args = append(args, "-p", strconv.Itoa(cfg.SSHPort))
}
if cfg.SSHJumpHost != "" {
jump := cfg.SSHJumpHost
if cfg.SSHJumpUser != "" {
jump = cfg.SSHJumpUser + "@" + jump
}
if cfg.SSHPort > 0 && !strings.Contains(jump, ":") {
jump = fmt.Sprintf("%s:%d", jump, cfg.SSHPort)
}
args = append(args, "-J", jump)
}
return args
}
func resolveSSHConfigFile(cfg config.Config) string {
if strings.TrimSpace(cfg.SSHConfigFile) != "" {
return strings.TrimSpace(cfg.SSHConfigFile)
}
candidates := []string{
"/home/atlas/.ssh/config",
"/home/tethys/.ssh/config",
}
for _, p := range candidates {
if stat, err := os.Stat(p); err == nil && !stat.IsDir() {
return p
}
}
return ""
}
func resolveSSHIdentityFile(cfg config.Config) string {
if strings.TrimSpace(cfg.SSHIdentityFile) != "" {
return strings.TrimSpace(cfg.SSHIdentityFile)
}
candidates := []string{
"/home/atlas/.ssh/id_ed25519",
"/home/tethys/.ssh/id_ed25519",
}
for _, p := range candidates {
if stat, err := os.Stat(p); err == nil && !stat.IsDir() {
return p
}
}
return ""
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
func startupShutdownCooldown(cfg config.Config) time.Duration {
seconds := cfg.Startup.ShutdownCooldownSeconds
if seconds <= 0 {
seconds = 45
}
return time.Duration(seconds) * time.Second
}

105
cmd/ananke/main_dispatch.go Normal file
View File

@ -0,0 +1,105 @@
package main
import (
"fmt"
"log"
"os"
)
var (
startupCommand = runStartup
shutdownCommand = runShutdown
etcdRestoreCommand = runEtcdRestore
daemonCommand = runDaemon
statusCommand = runStatus
intentCommand = runIntent
)
// main runs one orchestration or CLI step.
// Signature: main().
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func main() {
logger := log.New(os.Stdout, "[ananke] ", log.LstdFlags)
os.Exit(runMain(logger, os.Args))
}
// runMain runs one orchestration or CLI step.
// Signature: runMain(logger *log.Logger, args []string) int.
// Why: extracting dispatch logic makes command routing testable without spawning subprocesses.
func runMain(logger *log.Logger, args []string) int {
if len(args) < 2 {
usage()
return 2
}
sub := args[1]
switch sub {
case "startup":
if err := startupCommand(logger, args[2:]); err != nil {
logger.Printf("startup failed: %v", err)
return 1
}
case "shutdown":
if err := shutdownCommand(logger, args[2:]); err != nil {
logger.Printf("shutdown failed: %v", err)
return 1
}
case "etcd-restore":
if err := etcdRestoreCommand(logger, args[2:]); err != nil {
logger.Printf("etcd-restore failed: %v", err)
return 1
}
case "daemon":
if err := daemonCommand(logger, args[2:]); err != nil {
logger.Printf("daemon failed: %v", err)
return 1
}
case "status":
if err := statusCommand(logger, args[2:]); err != nil {
logger.Printf("status failed: %v", err)
return 1
}
case "intent":
if err := intentCommand(logger, args[2:]); err != nil {
logger.Printf("intent failed: %v", err)
return 1
}
case "help", "-h", "--help":
usage()
default:
logger.Printf("unknown command: %s", sub)
usage()
return 2
}
return 0
}
// usage runs one orchestration or CLI step.
// Signature: usage().
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func usage() {
fmt.Print(`ananke: staged startup/shutdown + UPS-triggered protection
Usage:
ananke <command> [flags]
Commands:
startup Perform staged cluster startup
shutdown Perform graceful cluster shutdown
etcd-restore Restore etcd from snapshot on a control plane
daemon Monitor UPS and auto-trigger shutdown
status Print current ananke status and estimates
intent Read or manually set intent state
Examples:
ananke startup --config /etc/ananke/ananke.yaml --execute --force-flux-branch main
ananke shutdown --config /etc/ananke/ananke.yaml --execute --mode cluster-only --reason "manual-maintenance"
ananke etcd-restore --config /etc/ananke/ananke.yaml --execute
ananke daemon --config /etc/ananke/ananke.yaml
ananke status --config /etc/ananke/ananke.yaml
ananke intent --config /etc/ananke/ananke.yaml --set normal --reason "manual-clear" --execute
Notes:
shutdown mode is cluster-only by design; host poweroff is intentionally not implemented.
`)
}

View File

@ -0,0 +1,157 @@
package main
import (
"errors"
"io"
"log"
"os"
"os/exec"
"strings"
"testing"
)
// TestMainNoArgsSubprocess runs one orchestration or CLI step.
// Signature: TestMainNoArgsSubprocess(t *testing.T).
// Why: covers usage exit path when no command is provided.
func TestMainNoArgsSubprocess(t *testing.T) {
if os.Getenv("ANANKE_TEST_MAIN_NO_ARGS") == "1" {
os.Args = []string{"ananke"}
main()
return
}
cmd := exec.Command(os.Args[0], "-test.run=TestMainNoArgsSubprocess")
cmd.Env = append(os.Environ(), "ANANKE_TEST_MAIN_NO_ARGS=1")
out, err := cmd.CombinedOutput()
if err == nil {
t.Fatalf("expected no-args subprocess to fail")
}
exitErr, ok := err.(*exec.ExitError)
if !ok {
t.Fatalf("expected exit error type, got %T (%v)", err, err)
}
if exitErr.ExitCode() != 2 {
t.Fatalf("expected exit code 2, got %d output=%s", exitErr.ExitCode(), string(out))
}
if !strings.Contains(string(out), "Usage:") {
t.Fatalf("expected usage output, got: %s", string(out))
}
}
// TestMainStatusSubprocess runs one orchestration or CLI step.
// Signature: TestMainStatusSubprocess(t *testing.T).
// Why: covers successful dispatch branch for status command.
func TestMainStatusSubprocess(t *testing.T) {
if os.Getenv("ANANKE_TEST_MAIN_STATUS") == "1" {
cfgPath := os.Getenv("ANANKE_TEST_CFG")
os.Args = []string{"ananke", "status", "--config", cfgPath}
main()
return
}
cfgPath := writeTestConfig(t)
cmd := exec.Command(os.Args[0], "-test.run=TestMainStatusSubprocess")
cmd.Env = append(os.Environ(),
"ANANKE_TEST_MAIN_STATUS=1",
"ANANKE_TEST_CFG="+cfgPath,
)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("expected status subprocess success: %v\n%s", err, string(out))
}
}
// TestRunMainDispatchSuccessPaths runs one orchestration or CLI step.
// Signature: TestRunMainDispatchSuccessPaths(t *testing.T).
// Why: unit-level dispatch coverage ensures every CLI command route remains wired.
func TestRunMainDispatchSuccessPaths(t *testing.T) {
logger := log.New(io.Discard, "", 0)
restore := stubDispatchCommands(
func(_ *log.Logger, _ []string) error { return nil },
func(_ *log.Logger, _ []string) error { return nil },
func(_ *log.Logger, _ []string) error { return nil },
func(_ *log.Logger, _ []string) error { return nil },
func(_ *log.Logger, _ []string) error { return nil },
func(_ *log.Logger, _ []string) error { return nil },
)
defer restore()
commands := []string{"startup", "shutdown", "etcd-restore", "daemon", "status", "intent", "help", "-h", "--help"}
for _, command := range commands {
code := runMain(logger, []string{"ananke", command})
if code != 0 {
t.Fatalf("expected exit code 0 for command %q, got %d", command, code)
}
}
}
// TestRunMainDispatchErrorPaths runs one orchestration or CLI step.
// Signature: TestRunMainDispatchErrorPaths(t *testing.T).
// Why: command failures must consistently map to exit code 1 for automation safety.
func TestRunMainDispatchErrorPaths(t *testing.T) {
logger := log.New(io.Discard, "", 0)
fail := errors.New("boom")
restore := stubDispatchCommands(
func(_ *log.Logger, _ []string) error { return fail },
func(_ *log.Logger, _ []string) error { return fail },
func(_ *log.Logger, _ []string) error { return fail },
func(_ *log.Logger, _ []string) error { return fail },
func(_ *log.Logger, _ []string) error { return fail },
func(_ *log.Logger, _ []string) error { return fail },
)
defer restore()
commands := []string{"startup", "shutdown", "etcd-restore", "daemon", "status", "intent"}
for _, command := range commands {
code := runMain(logger, []string{"ananke", command})
if code != 1 {
t.Fatalf("expected exit code 1 for failing command %q, got %d", command, code)
}
}
}
// TestRunMainUsageAndUnknownPaths runs one orchestration or CLI step.
// Signature: TestRunMainUsageAndUnknownPaths(t *testing.T).
// Why: no-arg and unknown-command branches should stay deterministic for shell callers.
func TestRunMainUsageAndUnknownPaths(t *testing.T) {
logger := log.New(io.Discard, "", 0)
if code := runMain(logger, []string{"ananke"}); code != 2 {
t.Fatalf("expected exit code 2 for no-arg usage path, got %d", code)
}
if code := runMain(logger, []string{"ananke", "definitely-unknown"}); code != 2 {
t.Fatalf("expected exit code 2 for unknown command path, got %d", code)
}
}
// stubDispatchCommands runs one orchestration or CLI step.
// Signature: stubDispatchCommands(startup, shutdown, etcdRestore, daemon, status, intent func(*log.Logger, []string) error) func().
// Why: keeps command-var overrides scoped and safely restorable across tests.
func stubDispatchCommands(
startup,
shutdown,
etcdRestore,
daemon,
status,
intent func(*log.Logger, []string) error,
) func() {
prevStartup := startupCommand
prevShutdown := shutdownCommand
prevEtcdRestore := etcdRestoreCommand
prevDaemon := daemonCommand
prevStatus := statusCommand
prevIntent := intentCommand
startupCommand = startup
shutdownCommand = shutdown
etcdRestoreCommand = etcdRestore
daemonCommand = daemon
statusCommand = status
intentCommand = intent
return func() {
startupCommand = prevStartup
shutdownCommand = prevShutdown
etcdRestoreCommand = prevEtcdRestore
daemonCommand = prevDaemon
statusCommand = prevStatus
intentCommand = prevIntent
}
}

View File

@ -0,0 +1,52 @@
package main
import (
"os"
"os/exec"
"strings"
"testing"
)
// TestMainHelpSubprocess runs one orchestration or CLI step.
// Signature: TestMainHelpSubprocess(t *testing.T).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func TestMainHelpSubprocess(t *testing.T) {
if os.Getenv("ANANKE_TEST_MAIN_HELP") == "1" {
os.Args = []string{"ananke", "help"}
main()
return
}
cmd := exec.Command(os.Args[0], "-test.run=TestMainHelpSubprocess")
cmd.Env = append(os.Environ(), "ANANKE_TEST_MAIN_HELP=1")
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("expected help subprocess success: %v\n%s", err, string(out))
}
if !strings.Contains(string(out), "Usage:") {
t.Fatalf("expected usage output, got:\n%s", string(out))
}
}
// TestMainUnknownCommandSubprocess runs one orchestration or CLI step.
// Signature: TestMainUnknownCommandSubprocess(t *testing.T).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func TestMainUnknownCommandSubprocess(t *testing.T) {
if os.Getenv("ANANKE_TEST_MAIN_UNKNOWN") == "1" {
os.Args = []string{"ananke", "definitely-unknown"}
main()
return
}
cmd := exec.Command(os.Args[0], "-test.run=TestMainUnknownCommandSubprocess")
cmd.Env = append(os.Environ(), "ANANKE_TEST_MAIN_UNKNOWN=1")
out, err := cmd.CombinedOutput()
if err == nil {
t.Fatalf("expected unknown-command subprocess to fail")
}
exitErr, ok := err.(*exec.ExitError)
if !ok {
t.Fatalf("expected exit error type, got %T (%v)", err, err)
}
if exitErr.ExitCode() != 2 {
t.Fatalf("expected exit code 2 for usage failure, got %d\n%s", exitErr.ExitCode(), string(out))
}
}

127
cmd/ananke/power_safety.go Normal file
View File

@ -0,0 +1,127 @@
package main
import (
"context"
"fmt"
"math"
"strings"
"time"
"scm.bstein.dev/bstein/ananke/internal/config"
"scm.bstein.dev/bstein/ananke/internal/service"
"scm.bstein.dev/bstein/ananke/internal/ups"
)
// buildUPSTargets runs one orchestration or CLI step.
// Signature: buildUPSTargets(cfg config.Config) ([]service.Target, error).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func buildUPSTargets(cfg config.Config) ([]service.Target, error) {
targets := make([]service.Target, 0, len(cfg.UPS.Targets)+1)
switch cfg.UPS.Provider {
case "nut":
if len(cfg.UPS.Targets) == 0 {
target := cfg.UPS.Target
if target == "" {
return nil, fmt.Errorf("ups target must be set")
}
targets = append(targets, service.Target{
Name: "primary",
Target: target,
Provider: ups.NewNUTProvider(target),
})
} else {
for idx, t := range cfg.UPS.Targets {
name := t.Name
if name == "" {
name = fmt.Sprintf("target-%d", idx+1)
}
targets = append(targets, service.Target{
Name: name,
Target: t.Target,
Provider: ups.NewNUTProvider(t.Target),
})
}
}
default:
return nil, fmt.Errorf("unsupported UPS provider: %s", cfg.UPS.Provider)
}
return targets, nil
}
// ensureStartupPowerSafe runs one orchestration or CLI step.
// Signature: ensureStartupPowerSafe(ctx context.Context, targets []service.Target, minimumBatteryPercent float64) error.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func ensureStartupPowerSafe(ctx context.Context, targets []service.Target, minimumBatteryPercent float64) error {
type targetState struct {
seenGood bool
lastErr error
}
states := make(map[string]*targetState, len(targets))
for _, t := range targets {
key := t.Name + "|" + t.Target
states[key] = &targetState{}
}
const pollInterval = 3 * time.Second
for {
onBatteryTargets := []string{}
lowChargeTargets := []string{}
allSeen := true
for _, t := range targets {
key := t.Name + "|" + t.Target
st := states[key]
sample, err := t.Provider.Read(ctx)
if err != nil {
st.lastErr = err
if !st.seenGood {
allSeen = false
}
continue
}
st.seenGood = true
st.lastErr = nil
if sample.OnBattery {
onBatteryTargets = append(onBatteryTargets, fmt.Sprintf("%s(status=%s runtime_s=%d)", t.Name, sample.RawStatus, sample.RuntimeSeconds))
}
if minimumBatteryPercent > 0 && sample.BatteryCharge > 0 && sample.BatteryCharge < minimumBatteryPercent {
lowChargeTargets = append(
lowChargeTargets,
fmt.Sprintf(
"%s(charge=%.1f%%<%.1f%% status=%s)",
t.Name,
sample.BatteryCharge,
minimumBatteryPercent,
sample.RawStatus,
),
)
}
}
if len(onBatteryTargets) > 0 {
return fmt.Errorf("startup blocked: UPS is on battery for %s", strings.Join(onBatteryTargets, ", "))
}
if len(lowChargeTargets) > 0 {
return fmt.Errorf("startup blocked: UPS battery charge below minimum for %s", strings.Join(lowChargeTargets, ", "))
}
if allSeen {
return nil
}
select {
case <-ctx.Done():
unverified := make([]string, 0, len(targets))
for _, t := range targets {
key := t.Name + "|" + t.Target
st := states[key]
if st.seenGood {
continue
}
if st.lastErr != nil {
unverified = append(unverified, fmt.Sprintf("%s(%s): %v", t.Name, t.Target, st.lastErr))
} else {
unverified = append(unverified, fmt.Sprintf("%s(%s): no telemetry sample yet", t.Name, t.Target))
}
}
roundedMin := math.Round(minimumBatteryPercent*10) / 10
return fmt.Errorf("startup blocked: unable to verify UPS telemetry before timeout (minimum_battery_percent=%.1f): %s", roundedMin, strings.Join(unverified, " | "))
case <-time.After(pollInterval):
}
}
}

View File

@ -0,0 +1,200 @@
package main
import (
"context"
"errors"
"testing"
"time"
"scm.bstein.dev/bstein/ananke/internal/config"
"scm.bstein.dev/bstein/ananke/internal/service"
"scm.bstein.dev/bstein/ananke/internal/ups"
)
type fakeUPSProvider struct {
samples []ups.Sample
errs []error
idx int
}
// Read runs one orchestration or CLI step.
// Signature: (p *fakeUPSProvider) Read(ctx context.Context) (ups.Sample, error).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (p *fakeUPSProvider) Read(_ context.Context) (ups.Sample, error) {
if p.idx < len(p.errs) && p.errs[p.idx] != nil {
err := p.errs[p.idx]
p.idx++
return ups.Sample{}, err
}
if p.idx < len(p.samples) {
s := p.samples[p.idx]
p.idx++
return s, nil
}
if len(p.samples) > 0 {
return p.samples[len(p.samples)-1], nil
}
return ups.Sample{}, errors.New("no sample")
}
// TestBuildUPSTargetsSupportsSingleTarget runs one orchestration or CLI step.
// Signature: TestBuildUPSTargetsSupportsSingleTarget(t *testing.T).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func TestBuildUPSTargetsSupportsSingleTarget(t *testing.T) {
cfg := config.Config{
UPS: config.UPS{
Provider: "nut",
Target: "pyrphoros@localhost",
},
}
targets, err := buildUPSTargets(cfg)
if err != nil {
t.Fatalf("buildUPSTargets failed: %v", err)
}
if len(targets) != 1 {
t.Fatalf("expected one target, got %d", len(targets))
}
if targets[0].Name != "primary" || targets[0].Target != "pyrphoros@localhost" {
t.Fatalf("unexpected target: %+v", targets[0])
}
}
// TestBuildUPSTargetsRejectsUnsupportedProvider runs one orchestration or CLI step.
// Signature: TestBuildUPSTargetsRejectsUnsupportedProvider(t *testing.T).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func TestBuildUPSTargetsRejectsUnsupportedProvider(t *testing.T) {
cfg := config.Config{UPS: config.UPS{Provider: "unknown"}}
if _, err := buildUPSTargets(cfg); err == nil {
t.Fatalf("expected unsupported provider error")
}
}
// TestBuildUPSTargetsRejectsMissingTarget runs one orchestration or CLI step.
// Signature: TestBuildUPSTargetsRejectsMissingTarget(t *testing.T).
// Why: covers missing-target validation when using the single-target NUT mode.
func TestBuildUPSTargetsRejectsMissingTarget(t *testing.T) {
cfg := config.Config{
UPS: config.UPS{
Provider: "nut",
Target: "",
Targets: nil,
},
}
if _, err := buildUPSTargets(cfg); err == nil {
t.Fatalf("expected missing target error")
}
}
// TestBuildUPSTargetsSupportsNamedTargets runs one orchestration or CLI step.
// Signature: TestBuildUPSTargetsSupportsNamedTargets(t *testing.T).
// Why: covers multi-target expansion including automatic fallback naming.
func TestBuildUPSTargetsSupportsNamedTargets(t *testing.T) {
cfg := config.Config{
UPS: config.UPS{
Provider: "nut",
Targets: []config.UPSTarget{
{Name: "Pyrphoros", Target: "pyrphoros@localhost"},
{Name: "", Target: "statera@localhost"},
},
},
}
targets, err := buildUPSTargets(cfg)
if err != nil {
t.Fatalf("buildUPSTargets failed: %v", err)
}
if len(targets) != 2 {
t.Fatalf("expected two targets, got %d", len(targets))
}
if targets[0].Name != "Pyrphoros" || targets[1].Name != "target-2" {
t.Fatalf("unexpected target names: %+v", targets)
}
}
// TestEnsureStartupPowerSafePassesWhenOnlineAndCharged runs one orchestration or CLI step.
// Signature: TestEnsureStartupPowerSafePassesWhenOnlineAndCharged(t *testing.T).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func TestEnsureStartupPowerSafePassesWhenOnlineAndCharged(t *testing.T) {
targets := []service.Target{
{
Name: "Pyrphoros",
Target: "pyrphoros@localhost",
Provider: &fakeUPSProvider{
samples: []ups.Sample{
{OnBattery: false, BatteryCharge: 90, RawStatus: "OL"},
},
},
},
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := ensureStartupPowerSafe(ctx, targets, 20); err != nil {
t.Fatalf("expected startup power safety to pass: %v", err)
}
}
// TestEnsureStartupPowerSafeRejectsOnBattery runs one orchestration or CLI step.
// Signature: TestEnsureStartupPowerSafeRejectsOnBattery(t *testing.T).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func TestEnsureStartupPowerSafeRejectsOnBattery(t *testing.T) {
targets := []service.Target{
{
Name: "Statera",
Target: "statera@localhost",
Provider: &fakeUPSProvider{
samples: []ups.Sample{
{OnBattery: true, RuntimeSeconds: 1200, RawStatus: "OB"},
},
},
},
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := ensureStartupPowerSafe(ctx, targets, 20); err == nil {
t.Fatalf("expected startup blocked error while on battery")
}
}
// TestEnsureStartupPowerSafeRejectsLowBatteryCharge runs one orchestration or CLI step.
// Signature: TestEnsureStartupPowerSafeRejectsLowBatteryCharge(t *testing.T).
// Why: covers battery percentage threshold enforcement when node is online.
func TestEnsureStartupPowerSafeRejectsLowBatteryCharge(t *testing.T) {
targets := []service.Target{
{
Name: "Pyrphoros",
Target: "pyrphoros@localhost",
Provider: &fakeUPSProvider{
samples: []ups.Sample{
{OnBattery: false, BatteryCharge: 10, RawStatus: "OL"},
},
},
},
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := ensureStartupPowerSafe(ctx, targets, 20); err == nil {
t.Fatalf("expected low-battery startup rejection")
}
}
// TestEnsureStartupPowerSafeRecoversAfterTransientTelemetryError runs one orchestration or CLI step.
// Signature: TestEnsureStartupPowerSafeRecoversAfterTransientTelemetryError(t *testing.T).
// Why: covers the allSeen gate where a target must produce at least one good sample before pass.
func TestEnsureStartupPowerSafeRecoversAfterTransientTelemetryError(t *testing.T) {
targets := []service.Target{
{
Name: "Statera",
Target: "statera@localhost",
Provider: &fakeUPSProvider{
errs: []error{context.DeadlineExceeded},
samples: []ups.Sample{
{OnBattery: false, BatteryCharge: 95, RawStatus: "OL"},
},
},
},
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := ensureStartupPowerSafe(ctx, targets, 20); err != nil {
t.Fatalf("expected startup power safety to recover after transient telemetry error: %v", err)
}
}

View File

@ -0,0 +1,47 @@
package main
import (
"fmt"
"os"
"path/filepath"
"testing"
)
// writeTestConfig runs one orchestration or CLI step.
// Signature: writeTestConfig(t *testing.T) string.
// Why: command-level tests need a stable on-disk config path that satisfies
// validation without depending on host-global files.
func writeTestConfig(t *testing.T) string {
t.Helper()
tmp := t.TempDir()
stateDir := filepath.Join(tmp, "state")
if err := os.MkdirAll(stateDir, 0o755); err != nil {
t.Fatalf("mkdir state dir: %v", err)
}
cfgPath := filepath.Join(tmp, "ananke.yaml")
raw := fmt.Sprintf(`
control_planes: [titan-0a]
workers: [titan-04]
ssh_user: atlas
ssh_port: 2277
ssh_node_hosts:
titan-0a: 192.168.22.11
titan-04: 192.168.22.30
ssh_managed_nodes: [titan-0a, titan-04]
expected_flux_branch: main
expected_flux_source_url: ssh://git@scm.bstein.dev:2242/bstein/titan-iac.git
iac_repo_path: %q
ups:
enabled: false
state:
dir: %q
reports_dir: %q
run_history_path: %q
lock_path: %q
intent_path: %q
`, tmp, stateDir, filepath.Join(stateDir, "reports"), filepath.Join(stateDir, "runs.json"), filepath.Join(stateDir, "ananke.lock"), filepath.Join(stateDir, "intent.json"))
if err := os.WriteFile(cfgPath, []byte(raw), 0o644); err != nil {
t.Fatalf("write config: %v", err)
}
return cfgPath
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,463 @@
package cluster
import (
"context"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
// reconcileNodeAccess runs one orchestration or CLI step.
// Signature: (o *Orchestrator) reconcileNodeAccess(ctx context.Context, nodes []string) error.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) reconcileNodeAccess(ctx context.Context, nodes []string) error {
if len(nodes) == 0 {
return nil
}
parallelism := o.cfg.Shutdown.SSHParallelism
if parallelism <= 0 {
parallelism = 8
}
if parallelism > len(nodes) {
parallelism = len(nodes)
}
sem := make(chan struct{}, parallelism)
var wg sync.WaitGroup
errCh := make(chan error, len(nodes))
for _, node := range nodes {
node := strings.TrimSpace(node)
if node == "" || !o.sshManaged(node) {
continue
}
wg.Add(1)
go func() {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
if _, err := o.ssh(ctx, node, "sudo -n /usr/bin/systemctl --version"); err != nil {
errCh <- fmt.Errorf("%s: missing sudo access to /usr/bin/systemctl (--version): %w", node, err)
}
}()
}
wg.Wait()
close(errCh)
if len(errCh) == 0 {
return nil
}
samples := []string{}
for err := range errCh {
samples = append(samples, err.Error())
if len(samples) >= 4 {
break
}
}
return fmt.Errorf("access validation had %d errors (first: %s)", len(errCh), strings.Join(samples, " | "))
}
// waitForNodeSSHAuth runs one orchestration or CLI step.
// Signature: (o *Orchestrator) waitForNodeSSHAuth(ctx context.Context, nodes []string) error.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) waitForNodeSSHAuth(ctx context.Context, nodes []string) error {
if o.runner.DryRun || !o.cfg.Startup.RequireNodeSSHAuth {
return nil
}
wait := time.Duration(o.cfg.Startup.NodeSSHAuthWaitSeconds) * time.Second
if wait <= 0 {
wait = 4 * time.Minute
}
poll := time.Duration(o.cfg.Startup.NodeSSHAuthPollSeconds) * time.Second
if poll <= 0 {
poll = 5 * time.Second
}
ignored := makeStringSet(o.cfg.Startup.IgnoreUnavailableNodes)
seen := map[string]struct{}{}
targets := make([]string, 0, len(nodes))
for _, node := range nodes {
node = strings.TrimSpace(node)
if node == "" {
continue
}
if _, skip := ignored[node]; skip {
continue
}
if _, ok := seen[node]; ok {
continue
}
seen[node] = struct{}{}
targets = append(targets, node)
}
sort.Strings(targets)
if len(targets) == 0 {
return nil
}
deadline := time.Now().Add(wait)
lastFailure := "unknown"
lastLogged := time.Time{}
for {
authDenied := []string{}
pending := []string{}
for _, node := range targets {
if !o.sshManaged(node) {
authDenied = append(authDenied, fmt.Sprintf("%s(not in ssh_managed_nodes)", node))
continue
}
out, err := o.sshWithTimeout(ctx, node, "echo __ANANKE_SSH_AUTH_OK__", 12*time.Second)
if err != nil {
detail := strings.TrimSpace(err.Error())
full := strings.ToLower(strings.TrimSpace(detail + " " + out))
switch {
case strings.Contains(full, "permission denied"), strings.Contains(full, "publickey"), strings.Contains(full, "authentication"):
authDenied = append(authDenied, fmt.Sprintf("%s(auth denied)", node))
default:
pending = append(pending, fmt.Sprintf("%s(unreachable)", node))
}
continue
}
if !strings.Contains(out, "__ANANKE_SSH_AUTH_OK__") {
pending = append(pending, fmt.Sprintf("%s(unexpected output)", node))
}
}
if len(authDenied) > 0 {
sort.Strings(authDenied)
return fmt.Errorf("ssh auth gate failed: %s", joinLimited(authDenied, 8))
}
if len(pending) == 0 {
return nil
}
sort.Strings(pending)
lastFailure = joinLimited(pending, 8)
if time.Now().After(deadline) {
return fmt.Errorf("node ssh auth gate did not pass within %s (%s)", wait, lastFailure)
}
if time.Since(lastLogged) >= 30*time.Second {
remaining := time.Until(deadline).Round(time.Second)
if remaining < 0 {
remaining = 0
}
o.log.Printf("waiting for node ssh auth (%s remaining): %s", remaining, lastFailure)
lastLogged = time.Now()
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(poll):
}
}
}
// fluxSourceReady runs one orchestration or CLI step.
// Signature: (o *Orchestrator) fluxSourceReady(ctx context.Context) (bool, error).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) fluxSourceReady(ctx context.Context) (bool, error) {
out, err := o.kubectl(ctx, 10*time.Second, "-n", "flux-system", "get", "gitrepository", "flux-system", "-o", "jsonpath={.status.conditions[?(@.type==\"Ready\")].status}")
if err != nil {
return false, err
}
return strings.Contains(out, "True"), nil
}
// reportFluxSource runs one orchestration or CLI step.
// Signature: (o *Orchestrator) reportFluxSource(ctx context.Context, forceBranch string).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) reportFluxSource(ctx context.Context, forceBranch string) {
urlOut, urlErr := o.kubectl(ctx, 10*time.Second, "-n", "flux-system", "get", "gitrepository", "flux-system", "-o", "jsonpath={.spec.url}")
if urlErr == nil {
currentURL := strings.TrimSpace(urlOut)
o.log.Printf("flux-source-url=%s", currentURL)
expectedURL := strings.TrimSpace(o.cfg.ExpectedFluxSource)
if expectedURL != "" && normalizeGitURL(currentURL) != normalizeGitURL(expectedURL) {
o.log.Printf("warning: flux source URL is %q, expected %q", currentURL, expectedURL)
}
}
branchOut, branchErr := o.kubectl(ctx, 10*time.Second, "-n", "flux-system", "get", "gitrepository", "flux-system", "-o", "jsonpath={.spec.ref.branch}")
if branchErr == nil {
branch := strings.TrimSpace(branchOut)
o.log.Printf("flux-source-branch=%s", branch)
if forceBranch == "" && branch != o.cfg.ExpectedFluxBranch {
o.log.Printf("warning: flux source branch is '%s', expected '%s'", branch, o.cfg.ExpectedFluxBranch)
}
}
}
// guardFluxSourceDrift runs one orchestration or CLI step.
// Signature: (o *Orchestrator) guardFluxSourceDrift(ctx context.Context, expectedBranch string, allowBranchPatch bool) error.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) guardFluxSourceDrift(ctx context.Context, expectedBranch string, allowBranchPatch bool) error {
urlOut, err := o.kubectl(
ctx,
10*time.Second,
"-n", "flux-system",
"get", "gitrepository", "flux-system",
"-o", "jsonpath={.spec.url}",
)
if err != nil {
if isNotFoundErr(err) {
o.log.Printf("warning: flux gitrepository/flux-system not found while checking source drift")
return nil
}
return fmt.Errorf("read flux source url: %w", err)
}
currentURL := strings.TrimSpace(urlOut)
expectedURL := strings.TrimSpace(o.cfg.ExpectedFluxSource)
if expectedURL != "" && normalizeGitURL(currentURL) != normalizeGitURL(expectedURL) {
return fmt.Errorf("startup blocked: flux source url drift detected (current=%q expected=%q)", currentURL, expectedURL)
}
branchOut, err := o.kubectl(
ctx,
10*time.Second,
"-n", "flux-system",
"get", "gitrepository", "flux-system",
"-o", "jsonpath={.spec.ref.branch}",
)
if err != nil {
return fmt.Errorf("read flux source branch: %w", err)
}
currentBranch := strings.TrimSpace(branchOut)
if expectedBranch == "" || currentBranch == expectedBranch || allowBranchPatch {
return nil
}
return fmt.Errorf("startup blocked: flux source branch drift detected (current=%q expected=%q)", currentBranch, expectedBranch)
}
// ensureFluxBranch runs one orchestration or CLI step.
// Signature: (o *Orchestrator) ensureFluxBranch(ctx context.Context, branch string, allowPatch bool) error.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) ensureFluxBranch(ctx context.Context, branch string, allowPatch bool) error {
branch = strings.TrimSpace(branch)
if branch == "" {
return nil
}
out, err := o.kubectl(
ctx,
10*time.Second,
"-n", "flux-system",
"get", "gitrepository", "flux-system",
"-o", "jsonpath={.spec.ref.branch}",
)
if err != nil {
if isNotFoundErr(err) {
o.log.Printf("warning: flux gitrepository/flux-system not found while ensuring branch=%s", branch)
return nil
}
return fmt.Errorf("read flux source branch: %w", err)
}
current := strings.TrimSpace(out)
if current == branch {
return nil
}
if !allowPatch {
return fmt.Errorf("startup blocked: flux source branch is %q but expected %q (use --force-flux-branch to patch intentionally)", current, branch)
}
patch := fmt.Sprintf(`{"spec":{"ref":{"branch":"%s"}}}`, branch)
if _, err := o.kubectl(
ctx,
20*time.Second,
"-n", "flux-system",
"patch", "gitrepository", "flux-system",
"--type=merge",
"-p", patch,
); err != nil {
return fmt.Errorf("set flux source branch %q (current %q): %w", branch, current, err)
}
o.log.Printf("updated flux source branch from %q to %q", current, branch)
return nil
}
// normalizeGitURL runs one orchestration or CLI step.
// Signature: normalizeGitURL(raw string) string.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func normalizeGitURL(raw string) string {
raw = strings.TrimSpace(strings.ToLower(raw))
raw = strings.TrimSuffix(raw, "/")
raw = strings.TrimSuffix(raw, ".git")
return raw
}
// bootstrapLocal runs one orchestration or CLI step.
// Signature: (o *Orchestrator) bootstrapLocal(ctx context.Context) error.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) bootstrapLocal(ctx context.Context) error {
failures := 0
successes := 0
for _, rel := range o.cfg.LocalBootstrapPaths {
full := filepath.Join(o.cfg.IACRepoPath, rel)
o.log.Printf("local bootstrap apply rel=%s path=%s", rel, full)
if o.runner.DryRun {
successes++
continue
}
if _, err := o.kubectl(ctx, 2*time.Minute, "apply", "-k", full); err != nil {
o.log.Printf("warning: local bootstrap apply failed at %s: %v", full, err)
o.log.Printf("local bootstrap fallback render/apply with LoadRestrictionsNone for %s", full)
if fallbackErr := o.applyKustomizeFallback(ctx, full); fallbackErr != nil {
o.log.Printf("warning: local bootstrap fallback failed at %s: %v", full, fallbackErr)
o.log.Printf("local bootstrap cache apply for rel=%s", rel)
if cacheErr := o.applyBootstrapCache(ctx, rel); cacheErr != nil {
failures++
o.log.Printf("warning: local bootstrap cache apply failed for rel=%s: %v", rel, cacheErr)
continue
}
}
}
successes++
}
if failures > 0 && successes == 0 {
return fmt.Errorf("local bootstrap apply failed for every configured path (%d total)", failures)
}
return nil
}
// applyKustomizeFallback runs one orchestration or CLI step.
// Signature: (o *Orchestrator) applyKustomizeFallback(ctx context.Context, full string) error.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) applyKustomizeFallback(ctx context.Context, full string) error {
cmd := fmt.Sprintf("kubectl kustomize --load-restrictor=LoadRestrictionsNone %q | kubectl apply -f -", full)
if _, err := o.runSensitive(ctx, 3*time.Minute, "sh", "-lc", cmd); err != nil {
return err
}
return nil
}
// syncLocalIACRepo runs one orchestration or CLI step.
// Signature: (o *Orchestrator) syncLocalIACRepo(ctx context.Context) error.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) syncLocalIACRepo(ctx context.Context) error {
repo := strings.TrimSpace(o.cfg.IACRepoPath)
if repo == "" {
return fmt.Errorf("iac repo path is empty")
}
gitDir := filepath.Join(repo, ".git")
if stat, err := os.Stat(gitDir); err != nil || !stat.IsDir() {
return fmt.Errorf("iac repo %s is not a git checkout", repo)
}
statusOut, statusErr := o.runSensitive(ctx, 10*time.Second, "git", "-C", repo, "status", "--porcelain")
if statusErr != nil {
return fmt.Errorf("inspect iac repo working tree: %w", statusErr)
}
if strings.TrimSpace(statusOut) != "" {
o.log.Printf("warning: skipping local titan-iac sync because working tree is dirty")
return nil
}
branch := strings.TrimSpace(o.cfg.ExpectedFluxBranch)
if branch == "" {
branch = "main"
}
if _, err := o.runSensitive(ctx, 45*time.Second, "git", "-C", repo, "fetch", "origin", "--prune"); err != nil {
return fmt.Errorf("git fetch origin: %w", err)
}
if _, err := o.runSensitive(ctx, 20*time.Second, "git", "-C", repo, "checkout", branch); err != nil {
return fmt.Errorf("git checkout %s: %w", branch, err)
}
if _, err := o.runSensitive(ctx, 20*time.Second, "git", "-C", repo, "reset", "--hard", "origin/"+branch); err != nil {
return fmt.Errorf("git reset --hard origin/%s: %w", branch, err)
}
return nil
}
// refreshBootstrapCache runs one orchestration or CLI step.
// Signature: (o *Orchestrator) refreshBootstrapCache(ctx context.Context) error.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) refreshBootstrapCache(ctx context.Context) error {
if len(o.cfg.LocalBootstrapPaths) == 0 {
return nil
}
if err := os.MkdirAll(o.bootstrapCacheDir(), 0o755); err != nil {
return fmt.Errorf("ensure bootstrap cache dir: %w", err)
}
rendered := 0
for _, rel := range o.cfg.LocalBootstrapPaths {
rel = strings.TrimSpace(rel)
if rel == "" {
continue
}
full := filepath.Join(o.cfg.IACRepoPath, rel)
if stat, err := os.Stat(full); err != nil || !stat.IsDir() {
o.log.Printf("warning: skip bootstrap cache render for rel=%s (path missing)", rel)
continue
}
cmd := fmt.Sprintf("kubectl kustomize --load-restrictor=LoadRestrictionsNone %q", full)
manifest, err := o.runSensitive(ctx, 2*time.Minute, "sh", "-lc", cmd)
if err != nil {
o.log.Printf("warning: bootstrap cache render failed for rel=%s: %v", rel, err)
continue
}
cachePath := o.bootstrapCachePath(rel)
if err := os.WriteFile(cachePath, []byte(manifest+"\n"), 0o644); err != nil {
o.log.Printf("warning: bootstrap cache write failed for rel=%s path=%s: %v", rel, cachePath, err)
continue
}
rendered++
}
if rendered == 0 {
return fmt.Errorf("no bootstrap cache manifests rendered")
}
o.log.Printf("bootstrap cache refreshed (%d paths)", rendered)
return nil
}
// applyBootstrapCache runs one orchestration or CLI step.
// Signature: (o *Orchestrator) applyBootstrapCache(ctx context.Context, rel string) error.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) applyBootstrapCache(ctx context.Context, rel string) error {
cachePath := o.bootstrapCachePath(rel)
if _, err := os.Stat(cachePath); err != nil {
return fmt.Errorf("bootstrap cache missing at %s: %w", cachePath, err)
}
if _, err := o.kubectl(ctx, 2*time.Minute, "apply", "-f", cachePath); err != nil {
return err
}
return nil
}
// bootstrapCacheDir runs one orchestration or CLI step.
// Signature: (o *Orchestrator) bootstrapCacheDir() string.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) bootstrapCacheDir() string {
return filepath.Join(o.cfg.State.Dir, "bootstrap-cache")
}
// bootstrapCachePath runs one orchestration or CLI step.
// Signature: (o *Orchestrator) bootstrapCachePath(rel string) string.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) bootstrapCachePath(rel string) string {
safe := strings.TrimSpace(rel)
safe = strings.ReplaceAll(safe, "/", "__")
safe = strings.ReplaceAll(safe, string(os.PathSeparator), "__")
return filepath.Join(o.bootstrapCacheDir(), safe+".yaml")
}
// waitForFluxSourceReady runs one orchestration or CLI step.
// Signature: (o *Orchestrator) waitForFluxSourceReady(ctx context.Context, window time.Duration) (bool, error).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) waitForFluxSourceReady(ctx context.Context, window time.Duration) (bool, error) {
if o.runner.DryRun {
return true, nil
}
deadline := time.Now().Add(window)
for {
ready, err := o.fluxSourceReady(ctx)
if err != nil {
return false, err
}
if ready {
return true, nil
}
if time.Now().After(deadline) {
return false, nil
}
select {
case <-ctx.Done():
return false, ctx.Err()
case <-time.After(5 * time.Second):
}
}
}

View File

@ -0,0 +1,275 @@
package cluster
import (
"context"
"fmt"
"path/filepath"
"strconv"
"strings"
"time"
"scm.bstein.dev/bstein/ananke/internal/state"
)
// intentAge runs one orchestration or CLI step.
// Signature: intentAge(in state.Intent) time.Duration.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func intentAge(in state.Intent) time.Duration {
if in.UpdatedAt.IsZero() {
return 0
}
return time.Since(in.UpdatedAt)
}
// intentFresh runs one orchestration or CLI step.
// Signature: intentFresh(in state.Intent, maxAge time.Duration) bool.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func intentFresh(in state.Intent, maxAge time.Duration) bool {
if in.UpdatedAt.IsZero() {
return true
}
return intentAge(in) <= maxAge
}
// startupGuardAge runs one orchestration or CLI step.
// Signature: (o *Orchestrator) startupGuardAge() time.Duration.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) startupGuardAge() time.Duration {
seconds := o.cfg.Coordination.StartupGuardMaxAgeSec
if seconds <= 0 {
seconds = 900
}
return time.Duration(seconds) * time.Second
}
// startupShutdownCooldown runs one orchestration or CLI step.
// Signature: (o *Orchestrator) startupShutdownCooldown() time.Duration.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) startupShutdownCooldown() time.Duration {
seconds := o.cfg.Startup.ShutdownCooldownSeconds
if seconds <= 0 {
seconds = 45
}
return time.Duration(seconds) * time.Second
}
// coordinationPeers runs one orchestration or CLI step.
// Signature: (o *Orchestrator) coordinationPeers() []string.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) coordinationPeers() []string {
seen := map[string]struct{}{}
out := make([]string, 0, len(o.cfg.Coordination.PeerHosts)+1)
add := func(node string) {
node = strings.TrimSpace(node)
if node == "" {
return
}
if _, ok := seen[node]; ok {
return
}
seen[node] = struct{}{}
out = append(out, node)
}
for _, node := range o.cfg.Coordination.PeerHosts {
add(node)
}
if strings.TrimSpace(o.cfg.Coordination.ForwardShutdownHost) != "" {
add(o.cfg.Coordination.ForwardShutdownHost)
}
return out
}
// guardPeerStartupIntents runs one orchestration or CLI step.
// Signature: (o *Orchestrator) guardPeerStartupIntents(ctx context.Context) error.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) guardPeerStartupIntents(ctx context.Context) error {
peers := o.coordinationPeers()
if len(peers) == 0 {
return nil
}
guardAge := o.startupGuardAge()
localRole := strings.ToLower(strings.TrimSpace(o.cfg.Coordination.Role))
for _, peer := range peers {
peerStatus, err := o.readRemotePeerStatus(ctx, peer)
if err != nil {
o.log.Printf("warning: peer startup guard skipped intent check for %s: %v", peer, err)
continue
}
intent := peerStatus.Intent
switch intent.State {
case "", state.IntentNormal:
continue
case state.IntentShuttingDown:
if intentFresh(intent, guardAge) {
return fmt.Errorf("startup blocked: peer %s has active shutdown intent (reason=%q age=%s)", peer, intent.Reason, intentAge(intent).Round(time.Second))
}
o.log.Printf("warning: peer %s shutdown intent appears stale; allowing startup", peer)
case state.IntentStartupInProgress:
if !peerStatus.BootstrapActive {
o.log.Printf("warning: peer %s reports startup_in_progress but bootstrap service is inactive (reason=%q age=%s); auto-clearing stale peer intent",
peer, intent.Reason, intentAge(intent).Round(time.Second))
o.bestEffort(fmt.Sprintf("clear stale peer startup intent on %s", peer), func() error {
return o.clearRemotePeerIntent(ctx, peer, "auto-clear stale peer startup intent")
})
continue
}
if localRole == "coordinator" && strings.EqualFold(strings.TrimSpace(intent.Reason), "manual-startup") {
o.log.Printf("warning: peer %s has manual startup in progress (age=%s); allowing coordinator startup to continue",
peer, intentAge(intent).Round(time.Second))
continue
}
if intentFresh(intent, guardAge) {
return fmt.Errorf("startup blocked: peer %s reports startup_in_progress (reason=%q age=%s)", peer, intent.Reason, intentAge(intent).Round(time.Second))
}
o.log.Printf("warning: peer %s startup intent appears stale; auto-clearing and allowing startup", peer)
o.bestEffort(fmt.Sprintf("clear stale peer startup intent on %s", peer), func() error {
return o.clearRemotePeerIntent(ctx, peer, "auto-clear stale peer startup intent")
})
case state.IntentShutdownComplete:
if intentFresh(intent, o.startupShutdownCooldown()) {
return fmt.Errorf("startup blocked: peer %s completed shutdown too recently (age=%s)", peer, intentAge(intent).Round(time.Second))
}
default:
o.log.Printf("warning: peer %s intent state %q is unknown; ignoring", peer, intent.State)
}
}
return nil
}
// readRemotePeerStatus runs one orchestration or CLI step.
// Signature: (o *Orchestrator) readRemotePeerStatus(ctx context.Context, node string) (remotePeerStatus, error).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) readRemotePeerStatus(ctx context.Context, node string) (remotePeerStatus, error) {
if !o.sshManaged(node) {
return remotePeerStatus{}, fmt.Errorf("%s is not in ssh_managed_nodes", node)
}
out, err := o.ssh(ctx, node, "if sudo -n /usr/bin/systemctl is-active --quiet ananke-bootstrap.service; then echo __ANANKE_BOOTSTRAP_ACTIVE__; else echo __ANANKE_BOOTSTRAP_IDLE__; fi; sudo -n /usr/local/bin/ananke intent --config /etc/ananke/ananke.yaml")
if err != nil {
return remotePeerStatus{}, err
}
status := remotePeerStatus{
BootstrapActive: strings.Contains(out, "__ANANKE_BOOTSTRAP_ACTIVE__"),
}
in, err := state.ParseIntentOutput(out)
if err != nil {
return remotePeerStatus{}, fmt.Errorf("parse remote intent output: %w", err)
}
status.Intent = in
return status, nil
}
// clearRemotePeerIntent runs one orchestration or CLI step.
// Signature: (o *Orchestrator) clearRemotePeerIntent(ctx context.Context, node string, reason string) error.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) clearRemotePeerIntent(ctx context.Context, node string, reason string) error {
cmd := fmt.Sprintf(
"sudo -n /usr/local/bin/ananke intent --config /etc/ananke/ananke.yaml --set normal --reason %s --source startup --execute",
shellQuote(reason),
)
_, err := o.ssh(ctx, node, cmd)
return err
}
// shellQuote runs one orchestration or CLI step.
// Signature: shellQuote(v string) string.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func shellQuote(v string) string {
return "'" + strings.ReplaceAll(v, "'", `'"'"'`) + "'"
}
// verifyEtcdSnapshot runs one orchestration or CLI step.
// Signature: (o *Orchestrator) verifyEtcdSnapshot(ctx context.Context, node string, snapshotPath string) error.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) verifyEtcdSnapshot(ctx context.Context, node string, snapshotPath string) error {
if o.runner.DryRun {
return nil
}
path := strings.TrimSpace(snapshotPath)
if path == "" {
return fmt.Errorf("etcd snapshot verification failed: snapshot path is empty")
}
quoted := shellQuote(path)
sizeOut, err := o.ssh(ctx, node, fmt.Sprintf("sudo -n sh -lc 'test -s %s && stat -c %%s %s'", quoted, quoted))
if err != nil {
return fmt.Errorf("etcd snapshot verification failed for %s on %s: %w", path, node, err)
}
size, convErr := strconv.ParseInt(strings.TrimSpace(sizeOut), 10, 64)
if convErr != nil {
return fmt.Errorf("etcd snapshot verification failed for %s on %s: parse size %q: %w", path, node, strings.TrimSpace(sizeOut), convErr)
}
const minSnapshotBytes = int64(1 << 20) // 1 MiB sanity floor.
if size < minSnapshotBytes {
return fmt.Errorf("etcd snapshot verification failed for %s on %s: snapshot too small (%d bytes)", path, node, size)
}
lsOut, err := o.runSudoK3S(ctx, node, "etcd-snapshot", "ls")
if err != nil {
return fmt.Errorf("etcd snapshot verification failed for %s on %s: list snapshots: %w", path, node, err)
}
if !strings.Contains(lsOut, path) && !strings.Contains(lsOut, filepath.Base(path)) {
return fmt.Errorf("etcd snapshot verification failed for %s on %s: snapshot is not present in k3s etcd-snapshot ls output", path, node)
}
sumOut, err := o.ssh(ctx, node, fmt.Sprintf("sudo -n sh -lc 'sha256sum %s | awk \"{print \\$1}\"'", quoted))
if err != nil {
return fmt.Errorf("etcd snapshot verification failed for %s on %s: sha256: %w", path, node, err)
}
hash := strings.TrimSpace(sumOut)
if len(hash) != 64 {
return fmt.Errorf("etcd snapshot verification failed for %s on %s: invalid sha256 %q", path, node, hash)
}
o.log.Printf("etcd snapshot verified path=%s size_bytes=%d sha256=%s", path, size, hash[:12])
return nil
}
// runSudoK3S runs one orchestration or CLI step.
// Signature: (o *Orchestrator) runSudoK3S(ctx context.Context, node string, args ...string) (string, error).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) runSudoK3S(ctx context.Context, node string, args ...string) (string, error) {
k3sPaths := []string{
"/usr/local/bin/k3s",
"/usr/bin/k3s",
"k3s",
}
var lastErr error
for _, path := range k3sPaths {
parts := []string{"sudo", "-n", path}
parts = append(parts, args...)
command := strings.Join(parts, " ")
out, err := o.ssh(ctx, node, command)
if err == nil {
return out, nil
}
lastErr = err
}
if lastErr == nil {
lastErr = fmt.Errorf("no k3s executable candidates configured")
}
return "", lastErr
}
// controlPlaneUsesExternalDatastore runs one orchestration or CLI step.
// Signature: (o *Orchestrator) controlPlaneUsesExternalDatastore(ctx context.Context, node string) (bool, error).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) controlPlaneUsesExternalDatastore(ctx context.Context, node string) (bool, error) {
out, err := o.ssh(ctx, node, "sudo systemctl cat k3s")
if err != nil {
return false, fmt.Errorf("inspect k3s service on %s for datastore mode: %w", node, err)
}
return strings.Contains(out, "--datastore-endpoint="), nil
}
// waitForAPI runs one orchestration or CLI step.
// Signature: (o *Orchestrator) waitForAPI(ctx context.Context, attempts int, sleep time.Duration) error.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) waitForAPI(ctx context.Context, attempts int, sleep time.Duration) error {
if o.runner.DryRun {
return nil
}
for i := 0; i < attempts; i++ {
_, err := o.kubectl(ctx, 5*time.Second, "version", "--request-timeout=5s")
if err == nil {
return nil
}
time.Sleep(sleep)
}
return fmt.Errorf("kubernetes API did not become reachable within timeout")
}

View File

@ -0,0 +1,172 @@
package cluster
import (
"context"
"fmt"
"sort"
"strings"
"time"
)
// waitForCriticalServiceEndpoints runs one orchestration or CLI step.
// Signature: (o *Orchestrator) waitForCriticalServiceEndpoints(ctx context.Context) error.
// Why: some externally-healthy services (like Grafana) still require backend
// in-cluster dependencies; endpoint checks catch this drift before startup passes.
func (o *Orchestrator) waitForCriticalServiceEndpoints(ctx context.Context) error {
wait := time.Duration(o.cfg.Startup.CriticalServiceEndpointWaitSec) * time.Second
if wait <= 0 {
wait = 7 * time.Minute
}
poll := time.Duration(o.cfg.Startup.CriticalServiceEndpointPollSec) * time.Second
if poll <= 0 {
poll = 5 * time.Second
}
deadline := time.Now().Add(wait)
lastFailure := "unknown"
lastLogged := time.Time{}
lastHealAttempt := time.Time{}
for {
ready, detail, failedNamespace, failedService, err := o.criticalServiceEndpointsReady(ctx)
if err != nil {
lastFailure = err.Error()
} else {
lastFailure = detail
}
if ready {
o.log.Printf("critical service endpoint checklist passed (%s)", detail)
return nil
}
now := time.Now()
if !o.runner.DryRun && failedNamespace != "" && failedService != "" &&
(lastHealAttempt.IsZero() || now.Sub(lastHealAttempt) >= 30*time.Second) {
lastHealAttempt = now
healed, healErr := o.maybeHealCriticalEndpointBackends(ctx, failedNamespace, failedService)
if healErr != nil {
o.log.Printf("warning: critical endpoint backend auto-heal failed for %s/%s: %v", failedNamespace, failedService, healErr)
}
if len(healed) > 0 {
sort.Strings(healed)
healDetail := fmt.Sprintf("restored critical endpoint backends: %s", joinLimited(healed, 8))
o.log.Printf("%s", healDetail)
o.noteStartupAutoHeal(healDetail)
}
}
if now.Sub(lastLogged) >= 30*time.Second {
remaining := time.Until(deadline).Round(time.Second)
if remaining < 0 {
remaining = 0
}
o.log.Printf("waiting for critical service endpoints (%s remaining): %s", remaining, lastFailure)
lastLogged = now
}
if time.Now().After(deadline) {
return fmt.Errorf("startup blocked: critical service endpoint checklist not satisfied within %s (%s)", wait, lastFailure)
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(poll):
}
}
}
// criticalServiceEndpointsReady runs one orchestration or CLI step.
// Signature: (o *Orchestrator) criticalServiceEndpointsReady(ctx context.Context) (bool, string, string, string, error).
// Why: startup should only declare success when critical services have active
// backend addresses, not just rendered objects.
func (o *Orchestrator) criticalServiceEndpointsReady(ctx context.Context) (bool, string, string, string, error) {
entries := o.cfg.Startup.CriticalServiceEndpoints
if len(entries) == 0 {
return true, "no critical service endpoints configured", "", "", nil
}
for _, entry := range entries {
namespace, service, err := parseCriticalServiceEndpoint(entry)
if err != nil {
return false, "", "", "", err
}
count, err := o.endpointAddressCount(ctx, namespace, service)
if err != nil {
if isNotFoundErr(err) {
return false, fmt.Sprintf("%s/%s not found", namespace, service), namespace, service, nil
}
return false, "", namespace, service, fmt.Errorf("query endpoints %s/%s: %w", namespace, service, err)
}
if count <= 0 {
return false, fmt.Sprintf("%s/%s endpoints=0", namespace, service), namespace, service, nil
}
}
return true, fmt.Sprintf("services=%d", len(entries)), "", "", nil
}
// maybeHealCriticalEndpointBackends runs one orchestration or CLI step.
// Signature: (o *Orchestrator) maybeHealCriticalEndpointBackends(ctx context.Context, namespace string, service string) ([]string, error).
// Why: endpoint-ready gating should include controlled self-healing for workloads
// that are configured to be available after startup.
func (o *Orchestrator) maybeHealCriticalEndpointBackends(ctx context.Context, namespace string, service string) ([]string, error) {
namespace = strings.TrimSpace(namespace)
service = strings.TrimSpace(service)
if namespace == "" || service == "" {
return nil, nil
}
healed := []string{}
for _, kind := range []string{"deployment", "statefulset"} {
workload := startupWorkload{Namespace: namespace, Kind: kind, Name: service}
if err := o.ensureWorkloadReplicas(ctx, workload, 1); err != nil {
if isNotFoundErr(err) {
continue
}
return healed, fmt.Errorf("scale %s/%s/%s to 1: %w", namespace, kind, service, err)
}
if err := o.waitWorkloadReady(ctx, workload); err != nil {
if !isNotFoundErr(err) {
return healed, err
}
continue
}
healed = append(healed, namespace+"/"+kind+"/"+service)
}
return healed, nil
}
// endpointAddressCount runs one orchestration or CLI step.
// Signature: (o *Orchestrator) endpointAddressCount(ctx context.Context, namespace string, service string) (int, error).
// Why: endpoint address counts provide an objective service-backend readiness
// signal independent of ingress/controller Ready conditions.
func (o *Orchestrator) endpointAddressCount(ctx context.Context, namespace string, service string) (int, error) {
out, err := o.kubectl(
ctx,
20*time.Second,
"-n",
namespace,
"get",
"endpoints",
service,
"-o",
`jsonpath={range .subsets[*].addresses[*]}{.ip}{"\n"}{end}`,
)
if err != nil {
return 0, err
}
return len(lines(out)), nil
}
// parseCriticalServiceEndpoint runs one orchestration or CLI step.
// Signature: parseCriticalServiceEndpoint(entry string) (string, string, error).
// Why: endpoint config should be parsed consistently so validation and runtime
// checks agree on namespace/service identity.
func parseCriticalServiceEndpoint(entry string) (string, string, error) {
entry = strings.TrimSpace(entry)
parts := strings.SplitN(entry, "/", 2)
if len(parts) != 2 {
return "", "", fmt.Errorf("invalid startup.critical_service_endpoints entry %q: expected namespace/service", entry)
}
namespace := strings.TrimSpace(parts[0])
service := strings.TrimSpace(parts[1])
if namespace == "" || service == "" {
return "", "", fmt.Errorf("invalid startup.critical_service_endpoints entry %q: namespace and service must be non-empty", entry)
}
return namespace, service, nil
}

View File

@ -0,0 +1,471 @@
package cluster
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
// bestEffort runs one orchestration or CLI step.
// Signature: (o *Orchestrator) bestEffort(name string, fn func() error).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) bestEffort(name string, fn func() error) {
if err := fn(); err != nil {
o.log.Printf("warning: %s: %v", name, err)
}
}
// missingCriticalStartupWorkloads runs one orchestration or CLI step.
// Signature: (o *Orchestrator) missingCriticalStartupWorkloads(ctx context.Context) ([]string, error).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) missingCriticalStartupWorkloads(ctx context.Context) ([]string, error) {
missing := []string{}
for _, w := range criticalStartupWorkloads {
ready, err := o.workloadReady(ctx, w)
if err != nil {
if isNotFoundErr(err) {
missing = append(missing, fmt.Sprintf("%s/%s/%s(not found)", w.Namespace, w.Kind, w.Name))
continue
}
return nil, fmt.Errorf("check %s/%s/%s: %w", w.Namespace, w.Kind, w.Name, err)
}
if !ready {
missing = append(missing, fmt.Sprintf("%s/%s/%s", w.Namespace, w.Kind, w.Name))
}
}
return missing, nil
}
// ensureCriticalStartupWorkloads runs one orchestration or CLI step.
// Signature: (o *Orchestrator) ensureCriticalStartupWorkloads(ctx context.Context) error.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) ensureCriticalStartupWorkloads(ctx context.Context) error {
for _, w := range criticalStartupWorkloads {
if err := o.ensureWorkloadReplicas(ctx, w, 1); err != nil {
if isNotFoundErr(err) {
o.log.Printf("warning: startup workload missing, skipping scale: %s/%s/%s", w.Namespace, w.Kind, w.Name)
continue
}
return fmt.Errorf("scale %s/%s/%s: %w", w.Namespace, w.Kind, w.Name, err)
}
if err := o.cleanupStaleCriticalWorkloadPods(ctx, w); err != nil {
if isNotFoundErr(err) {
o.log.Printf("warning: startup workload missing during stale-pod cleanup: %s/%s/%s", w.Namespace, w.Kind, w.Name)
continue
}
return fmt.Errorf("cleanup stale pods %s/%s/%s: %w", w.Namespace, w.Kind, w.Name, err)
}
if err := o.waitWorkloadReady(ctx, w); err != nil {
if isNotFoundErr(err) {
o.log.Printf("warning: startup workload missing during readiness wait: %s/%s/%s", w.Namespace, w.Kind, w.Name)
continue
}
return err
}
}
return nil
}
// cleanupStaleCriticalWorkloadPods runs one orchestration or CLI step.
// Signature: (o *Orchestrator) cleanupStaleCriticalWorkloadPods(ctx context.Context, w startupWorkload) error.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) cleanupStaleCriticalWorkloadPods(ctx context.Context, w startupWorkload) error {
if o.runner.DryRun {
return nil
}
if w.Kind != "statefulset" {
return nil
}
out, err := o.kubectl(
ctx,
20*time.Second,
"-n",
w.Namespace,
"get",
"pods",
"-o",
"custom-columns=NAME:.metadata.name,PHASE:.status.phase,OWNER_KIND:.metadata.ownerReferences[0].kind,OWNER_NAME:.metadata.ownerReferences[0].name",
"--no-headers",
)
if err != nil {
return err
}
prefix := w.Name + "-"
for _, line := range lines(out) {
fields := strings.Fields(line)
if len(fields) < 4 {
continue
}
podName := fields[0]
phase := strings.ToLower(strings.TrimSpace(fields[1]))
ownerKind := strings.TrimSpace(fields[2])
ownerName := strings.TrimSpace(fields[3])
if !strings.EqualFold(ownerKind, "StatefulSet") || ownerName != w.Name {
continue
}
if !strings.HasPrefix(podName, prefix) {
continue
}
if phase != "unknown" && phase != "failed" {
continue
}
o.log.Printf("warning: deleting stale critical pod %s/%s phase=%s before readiness wait", w.Namespace, podName, phase)
if _, delErr := o.kubectl(
ctx,
40*time.Second,
"-n",
w.Namespace,
"delete",
"pod",
podName,
"--grace-period=0",
"--force",
"--wait=false",
); delErr != nil {
return fmt.Errorf("delete stale pod %s/%s: %w", w.Namespace, podName, delErr)
}
}
return nil
}
// ensureWorkloadReplicas runs one orchestration or CLI step.
// Signature: (o *Orchestrator) ensureWorkloadReplicas(ctx context.Context, w startupWorkload, replicas int) error.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) ensureWorkloadReplicas(ctx context.Context, w startupWorkload, replicas int) error {
_, err := o.kubectl(
ctx,
45*time.Second,
"-n",
w.Namespace,
"scale",
w.Kind,
w.Name,
fmt.Sprintf("--replicas=%d", replicas),
)
return err
}
// waitWorkloadReady runs one orchestration or CLI step.
// Signature: (o *Orchestrator) waitWorkloadReady(ctx context.Context, w startupWorkload) error.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) waitWorkloadReady(ctx context.Context, w startupWorkload) error {
if w.Namespace == "vault" && w.Kind == "statefulset" && w.Name == "vault" {
return o.waitVaultReady(ctx, w)
}
timeout := "240s"
if w.Kind == "statefulset" {
timeout = "360s"
}
_, err := o.kubectl(
ctx,
7*time.Minute,
"-n",
w.Namespace,
"rollout",
"status",
fmt.Sprintf("%s/%s", w.Kind, w.Name),
"--timeout="+timeout,
)
if err != nil {
return fmt.Errorf("wait ready %s/%s/%s: %w", w.Namespace, w.Kind, w.Name, err)
}
return nil
}
// waitVaultReady runs one orchestration or CLI step.
// Signature: (o *Orchestrator) waitVaultReady(ctx context.Context, w startupWorkload) error.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) waitVaultReady(ctx context.Context, w startupWorkload) error {
if o.runner.DryRun {
return nil
}
deadline := time.Now().Add(7 * time.Minute)
var lastErr error
for time.Now().Before(deadline) {
ready, err := o.workloadReady(ctx, w)
if err == nil && ready {
return nil
}
if err != nil {
lastErr = err
}
if err := o.ensureVaultUnsealed(ctx); err != nil {
lastErr = err
o.log.Printf("warning: vault readiness still blocked: %v", err)
}
ready, err = o.workloadReady(ctx, w)
if err == nil && ready {
return nil
}
if err != nil {
lastErr = err
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(5 * time.Second):
}
}
if lastErr != nil {
return fmt.Errorf("wait ready %s/%s/%s: %w", w.Namespace, w.Kind, w.Name, lastErr)
}
return fmt.Errorf("wait ready %s/%s/%s: timeout", w.Namespace, w.Kind, w.Name)
}
// ensureVaultUnsealed runs one orchestration or CLI step.
// Signature: (o *Orchestrator) ensureVaultUnsealed(ctx context.Context) error.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) ensureVaultUnsealed(ctx context.Context) error {
if o.runner.DryRun {
return nil
}
phase, err := o.kubectl(ctx, 15*time.Second, "-n", "vault", "get", "pod", "vault-0", "-o", "jsonpath={.status.phase}")
if err != nil {
return fmt.Errorf("vault pod phase check failed: %w", err)
}
if strings.TrimSpace(phase) != "Running" {
return fmt.Errorf("vault-0 pod phase is %q", strings.TrimSpace(phase))
}
sealed, err := o.vaultSealed(ctx)
if err != nil {
return err
}
if !sealed {
return nil
}
unsealKey, err := o.vaultUnsealKey(ctx)
if err != nil {
return err
}
for attempt := 1; attempt <= 5; attempt++ {
o.log.Printf("vault is sealed; attempting auto-unseal (%d/5)", attempt)
if _, err := o.runSensitive(
ctx,
30*time.Second,
"kubectl",
"-n", "vault",
"exec", "vault-0", "--",
"vault", "operator", "unseal", unsealKey,
); err != nil {
return fmt.Errorf("vault unseal attempt %d failed: %w", attempt, err)
}
sealed, err = o.vaultSealed(ctx)
if err != nil {
return err
}
if !sealed {
o.log.Printf("vault auto-unseal succeeded")
return nil
}
time.Sleep(2 * time.Second)
}
return fmt.Errorf("vault remained sealed after 5 auto-unseal attempts")
}
// vaultSealed runs one orchestration or CLI step.
// Signature: (o *Orchestrator) vaultSealed(ctx context.Context) (bool, error).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) vaultSealed(ctx context.Context) (bool, error) {
out, err := o.kubectl(
ctx,
25*time.Second,
"-n", "vault",
"exec", "vault-0", "--",
"sh", "-lc",
"VAULT_ADDR=http://127.0.0.1:8200 vault status -format=json 2>/dev/null || true",
)
if err != nil {
return false, fmt.Errorf("vault status check failed: %w", err)
}
sealed, err := parseVaultSealed(out)
if err != nil {
return false, fmt.Errorf("parse vault status: %w", err)
}
return sealed, nil
}
// parseVaultSealed runs one orchestration or CLI step.
// Signature: parseVaultSealed(raw string) (bool, error).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func parseVaultSealed(raw string) (bool, error) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return false, fmt.Errorf("empty vault status output")
}
start := strings.Index(trimmed, "{")
end := strings.LastIndex(trimmed, "}")
if start < 0 || end < 0 || end < start {
return false, fmt.Errorf("vault status payload missing JSON object")
}
payload := trimmed[start : end+1]
type vaultStatus struct {
Sealed bool `json:"sealed"`
}
var st vaultStatus
if err := json.Unmarshal([]byte(payload), &st); err != nil {
return false, err
}
return st.Sealed, nil
}
// vaultUnsealKey runs one orchestration or CLI step.
// Signature: (o *Orchestrator) vaultUnsealKey(ctx context.Context) (string, error).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) vaultUnsealKey(ctx context.Context) (string, error) {
out, err := o.kubectl(
ctx,
15*time.Second,
"-n", "vault",
"get", "secret", "vault-init",
"-o", "jsonpath={.data.unseal_key_b64}",
)
if err == nil {
decoded, decodeErr := base64.StdEncoding.DecodeString(strings.TrimSpace(out))
if decodeErr == nil {
key := strings.TrimSpace(string(decoded))
if key != "" {
o.bestEffort("cache vault unseal key locally", func() error { return o.writeVaultUnsealKeyFile(key) })
return key, nil
}
err = fmt.Errorf("vault-init unseal key is empty")
} else {
err = fmt.Errorf("decode vault-init unseal_key_b64: %w", decodeErr)
}
} else {
err = fmt.Errorf("read vault-init secret: %w", err)
}
fallbackKey, fileErr := o.readVaultUnsealKeyFile()
if fileErr == nil {
o.log.Printf("warning: using cached vault unseal key from %s", o.cfg.Startup.VaultUnsealKeyFile)
return fallbackKey, nil
}
breakglassKey, breakglassErr := o.readVaultUnsealKeyBreakglass(ctx)
if breakglassErr == nil {
o.log.Printf("warning: using break-glass vault unseal key command fallback")
o.bestEffort("cache vault unseal key locally", func() error { return o.writeVaultUnsealKeyFile(breakglassKey) })
return breakglassKey, nil
}
return "", fmt.Errorf("%v; fallback %v; break-glass %v", err, fileErr, breakglassErr)
}
// readVaultUnsealKeyBreakglass runs one orchestration or CLI step.
// Signature: (o *Orchestrator) readVaultUnsealKeyBreakglass(ctx context.Context) (string, error).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) readVaultUnsealKeyBreakglass(ctx context.Context) (string, error) {
cmd := strings.TrimSpace(o.cfg.Startup.VaultUnsealBreakglassCommand)
if cmd == "" {
return "", fmt.Errorf("break-glass command not configured")
}
timeout := time.Duration(o.cfg.Startup.VaultUnsealBreakglassTimeout) * time.Second
if timeout <= 0 {
timeout = 15 * time.Second
}
out, err := o.runSensitive(ctx, timeout, "sh", "-lc", cmd)
if err != nil {
return "", fmt.Errorf("run break-glass command: %w", err)
}
key := strings.TrimSpace(out)
if key == "" {
return "", fmt.Errorf("break-glass command returned empty output")
}
return key, nil
}
// writeVaultUnsealKeyFile runs one orchestration or CLI step.
// Signature: (o *Orchestrator) writeVaultUnsealKeyFile(key string) error.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) writeVaultUnsealKeyFile(key string) error {
path := strings.TrimSpace(o.cfg.Startup.VaultUnsealKeyFile)
if path == "" {
return fmt.Errorf("vault unseal key file path is empty")
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return fmt.Errorf("ensure vault unseal key dir: %w", err)
}
if err := os.WriteFile(path, []byte(strings.TrimSpace(key)+"\n"), 0o600); err != nil {
return fmt.Errorf("write vault unseal key file: %w", err)
}
return nil
}
// readVaultUnsealKeyFile runs one orchestration or CLI step.
// Signature: (o *Orchestrator) readVaultUnsealKeyFile() (string, error).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) readVaultUnsealKeyFile() (string, error) {
path := strings.TrimSpace(o.cfg.Startup.VaultUnsealKeyFile)
if path == "" {
return "", fmt.Errorf("vault unseal key file path is empty")
}
b, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("read vault unseal key file %s: %w", path, err)
}
key := strings.TrimSpace(string(b))
if key == "" {
return "", fmt.Errorf("vault unseal key file %s is empty", path)
}
return key, nil
}
// workloadReady runs one orchestration or CLI step.
// Signature: (o *Orchestrator) workloadReady(ctx context.Context, w startupWorkload) (bool, error).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) workloadReady(ctx context.Context, w startupWorkload) (bool, error) {
out, err := o.kubectl(
ctx,
20*time.Second,
"-n",
w.Namespace,
"get",
w.Kind,
w.Name,
"-o",
"jsonpath={.status.readyReplicas}",
)
if err != nil {
return false, err
}
raw := strings.TrimSpace(out)
if raw == "" || raw == "<no value>" {
return false, nil
}
n, err := strconv.Atoi(raw)
if err != nil {
return false, fmt.Errorf("parse readyReplicas %q: %w", raw, err)
}
return n >= 1, nil
}
// isNotFoundErr runs one orchestration or CLI step.
// Signature: isNotFoundErr(err error) bool.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func isNotFoundErr(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "not found") || strings.Contains(msg, "(notfound)")
}

View File

@ -0,0 +1,241 @@
package cluster
import (
"context"
"fmt"
"strings"
"sync"
"time"
)
type drainFailure struct {
node string
err error
details string
}
// drainWorkers runs one orchestration or CLI step.
// Signature: (o *Orchestrator) drainWorkers(ctx context.Context, workers []string) error.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) drainWorkers(ctx context.Context, workers []string) error {
total := len(workers)
if total == 0 {
return nil
}
parallelism := o.cfg.Shutdown.DrainParallelism
if parallelism <= 0 {
parallelism = 6
}
if parallelism > total {
parallelism = total
}
o.log.Printf("drain workers total=%d parallelism=%d", total, parallelism)
sem := make(chan struct{}, parallelism)
var wg sync.WaitGroup
errCh := make(chan drainFailure, total)
for idx, node := range workers {
idx := idx
node := node
wg.Add(1)
go func() {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
o.log.Printf("drain worker %d/%d: %s", idx+1, total, node)
if _, err := o.kubectl(ctx, 20*time.Second, "cordon", node); err != nil {
o.log.Printf("warning: cordon %s failed: %v", node, err)
}
if _, err := o.kubectl(ctx, 3*time.Minute, "drain", node, "--ignore-daemonsets", "--delete-emptydir-data", "--grace-period=30", "--timeout=180s"); err != nil {
details := o.drainNodeDiagnostics(ctx, node)
errCh <- drainFailure{
node: node,
err: fmt.Errorf("drain %s failed: %w", node, err),
details: details,
}
return
}
}()
}
wg.Wait()
close(errCh)
if len(errCh) == 0 {
return nil
}
failures := make([]drainFailure, 0, len(errCh))
for failure := range errCh {
failures = append(failures, failure)
}
count := len(failures)
samples := []string{}
for _, failure := range failures {
msg := failure.err.Error()
if strings.TrimSpace(failure.details) != "" {
msg = fmt.Sprintf("%s (details: %s)", msg, failure.details)
}
samples = append(samples, msg)
if len(samples) >= 4 {
break
}
}
return fmt.Errorf("drain workers had %d errors (first: %s)", count, strings.Join(samples, " | "))
}
// drainNodeDiagnostics runs one orchestration or CLI step.
// Signature: (o *Orchestrator) drainNodeDiagnostics(ctx context.Context, node string) string.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) drainNodeDiagnostics(ctx context.Context, node string) string {
out, err := o.kubectl(
ctx,
20*time.Second,
"get",
"pods",
"-A",
"--field-selector", "spec.nodeName="+node,
"-o",
"custom-columns=NS:.metadata.namespace,NAME:.metadata.name,PHASE:.status.phase,OWNER:.metadata.ownerReferences[0].kind",
"--no-headers",
)
if err != nil {
if strings.TrimSpace(out) == "" {
return fmt.Sprintf("diagnostics unavailable: %v", err)
}
return fmt.Sprintf("diagnostics unavailable: %v (%s)", err, strings.Join(lines(out), "; "))
}
blockers := make([]string, 0, 6)
for _, line := range lines(out) {
fields := strings.Fields(line)
if len(fields) < 4 {
continue
}
namespace := fields[0]
name := fields[1]
phase := fields[2]
owner := fields[3]
if strings.EqualFold(owner, "DaemonSet") {
continue
}
if strings.EqualFold(phase, "Succeeded") || strings.EqualFold(phase, "Failed") {
continue
}
blockers = append(blockers, fmt.Sprintf("%s/%s(phase=%s owner=%s)", namespace, name, phase, owner))
if len(blockers) >= 6 {
break
}
}
if len(blockers) == 0 {
return "no non-daemonset blocking pods found on node"
}
return strings.Join(blockers, ", ")
}
// uncordonWorkers runs one orchestration or CLI step.
// Signature: (o *Orchestrator) uncordonWorkers(ctx context.Context, workers []string) error.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) uncordonWorkers(ctx context.Context, workers []string) error {
for _, node := range workers {
if _, err := o.kubectl(ctx, 20*time.Second, "uncordon", node); err != nil {
o.log.Printf("warning: uncordon %s failed: %v", node, err)
}
}
return nil
}
// stopWorkers runs one orchestration or CLI step.
// Signature: (o *Orchestrator) stopWorkers(ctx context.Context, workers []string).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) stopWorkers(ctx context.Context, workers []string) {
o.runSSHAcrossNodes(ctx, workers, "stop k3s-agent", "sudo systemctl stop k3s-agent || true")
}
// startWorkers runs one orchestration or CLI step.
// Signature: (o *Orchestrator) startWorkers(ctx context.Context, workers []string).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) startWorkers(ctx context.Context, workers []string) {
o.runSSHAcrossNodes(ctx, workers, "start k3s-agent", "sudo systemctl start k3s-agent || true")
}
// stopControlPlanes runs one orchestration or CLI step.
// Signature: (o *Orchestrator) stopControlPlanes(ctx context.Context, cps []string).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) stopControlPlanes(ctx context.Context, cps []string) {
o.runSSHAcrossNodes(ctx, cps, "stop k3s", "sudo systemctl stop k3s || true")
}
// startControlPlanes runs one orchestration or CLI step.
// Signature: (o *Orchestrator) startControlPlanes(ctx context.Context, cps []string).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) startControlPlanes(ctx context.Context, cps []string) {
o.runSSHAcrossNodes(ctx, cps, "start k3s", "sudo systemctl start k3s || true")
}
// runSSHAcrossNodes runs one orchestration or CLI step.
// Signature: (o *Orchestrator) runSSHAcrossNodes(ctx context.Context, nodes []string, action, command string).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) runSSHAcrossNodes(ctx context.Context, nodes []string, action, command string) {
if len(nodes) == 0 {
return
}
parallelism := o.cfg.Shutdown.SSHParallelism
if parallelism <= 0 {
parallelism = 8
}
if parallelism > len(nodes) {
parallelism = len(nodes)
}
sem := make(chan struct{}, parallelism)
var wg sync.WaitGroup
for _, node := range nodes {
node := node
if !o.sshManaged(node) {
o.log.Printf("skip %s on %s: node not in ssh_managed_nodes", action, node)
continue
}
wg.Add(1)
go func() {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
o.bestEffort(action+" on "+node, func() error {
_, err := o.ssh(ctx, node, command)
return err
})
}()
}
wg.Wait()
}
// takeEtcdSnapshot runs one orchestration or CLI step.
// Signature: (o *Orchestrator) takeEtcdSnapshot(ctx context.Context, node string) error.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) takeEtcdSnapshot(ctx context.Context, node string) error {
if !o.sshManaged(node) {
return fmt.Errorf("cannot run etcd snapshot on %s: node not in ssh_managed_nodes", node)
}
name := "pre-shutdown-" + time.Now().UTC().Format("20060102-150405")
_, err := o.runSudoK3S(ctx, node, "etcd-snapshot", "save", "--name", name)
return err
}
// latestEtcdSnapshotPath runs one orchestration or CLI step.
// Signature: (o *Orchestrator) latestEtcdSnapshotPath(ctx context.Context, node string) (string, error).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) latestEtcdSnapshotPath(ctx context.Context, node string) (string, error) {
if !o.sshManaged(node) {
return "", fmt.Errorf("cannot resolve etcd snapshot on %s: node not in ssh_managed_nodes", node)
}
out, err := o.runSudoK3S(ctx, node, "etcd-snapshot", "ls")
if err != nil {
return "", fmt.Errorf("resolve latest etcd snapshot on %s: %w", node, err)
}
snapshot := parseSnapshotPathFromEtcdSnapshotList(out)
if snapshot == "" {
return "", fmt.Errorf("no etcd snapshots found on %s under /var/lib/rancher/k3s/server/db/snapshots", node)
}
return snapshot, nil
}

View File

@ -0,0 +1,266 @@
package cluster
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"time"
)
// waitForFluxHealth runs one orchestration or CLI step.
// Signature: (o *Orchestrator) waitForFluxHealth(ctx context.Context) error.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) waitForFluxHealth(ctx context.Context) error {
wait := time.Duration(o.cfg.Startup.FluxHealthWaitSeconds) * time.Second
if wait <= 0 {
wait = 15 * time.Minute
}
if effective, reason, err := o.adaptiveFluxHealthWait(ctx, wait); err != nil {
o.log.Printf("warning: unable to evaluate adaptive flux wait window: %v", err)
} else if effective > wait {
o.log.Printf("adjusted flux convergence wait window from %s to %s (%s)", wait, effective, reason)
wait = effective
}
poll := time.Duration(o.cfg.Startup.FluxHealthPollSeconds) * time.Second
if poll <= 0 {
poll = 5 * time.Second
}
deadline := time.Now().Add(wait)
lastFailure := "unknown"
lastLogged := time.Time{}
lastImmutableHealAttempt := time.Time{}
lastRecycleAttempt := time.Time{}
lastReplicaHeal := time.Time{}
for {
o.maybeAutoRecycleStuckPods(ctx, &lastRecycleAttempt)
o.maybeAutoHealCriticalWorkloadReplicas(ctx, &lastReplicaHeal)
prevFailure := lastFailure
ready, detail, err := o.fluxHealthReady(ctx)
if err != nil {
lastFailure = err.Error()
} else {
lastFailure = detail
}
if ready {
o.log.Printf("flux convergence check passed (%s)", detail)
return nil
}
if !o.runner.DryRun && looksLikeImmutableJobError(lastFailure) && time.Since(lastImmutableHealAttempt) >= 30*time.Second {
lastImmutableHealAttempt = time.Now()
healed, healErr := o.healImmutableFluxJobs(ctx)
if healErr != nil {
o.log.Printf("warning: immutable-job self-heal attempt failed: %v", healErr)
} else if healed {
o.log.Printf("detected immutable-job failure and removed stale failed job(s); re-requesting reconcile")
o.noteStartupAutoHeal("deleted stale failed flux-managed job(s) after immutable template error")
o.bestEffort("reconcile flux after immutable-job cleanup", func() error { return o.resumeFluxAndReconcile(ctx) })
}
}
if lastFailure != prevFailure || time.Since(lastLogged) >= 30*time.Second {
remaining := time.Until(deadline).Round(time.Second)
if remaining < 0 {
remaining = 0
}
o.log.Printf("waiting for Flux convergence (%s remaining): %s", remaining, lastFailure)
lastLogged = time.Now()
}
if time.Now().After(deadline) {
return fmt.Errorf("startup blocked: flux convergence not satisfied within %s (%s)", wait, lastFailure)
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(poll):
}
}
}
// adaptiveFluxHealthWait runs one orchestration or CLI step.
// Signature: (o *Orchestrator) adaptiveFluxHealthWait(ctx context.Context, base time.Duration) (time.Duration, string, error).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) adaptiveFluxHealthWait(ctx context.Context, base time.Duration) (time.Duration, string, error) {
if base <= 0 {
base = 15 * time.Minute
}
out, err := o.kubectl(ctx, 20*time.Second, "get", "kustomizations.kustomize.toolkit.fluxcd.io", "-A", "-o", "json")
if err != nil {
return base, "", fmt.Errorf("query flux kustomizations: %w", err)
}
var list fluxKustomizationList
if err := json.Unmarshal([]byte(out), &list); err != nil {
return base, "", fmt.Errorf("decode flux kustomizations: %w", err)
}
maxTimeout := time.Duration(0)
maxName := ""
for _, ks := range list.Items {
if ks.Spec.Suspend {
continue
}
timeout := parseFluxKustomizationTimeout(ks.Spec.Timeout)
if timeout <= maxTimeout {
continue
}
maxTimeout = timeout
maxName = strings.TrimSpace(ks.Metadata.Namespace) + "/" + strings.TrimSpace(ks.Metadata.Name)
}
if maxTimeout <= 0 {
return base, "no explicit kustomization timeouts found", nil
}
required := maxTimeout + 2*time.Minute
if required <= base {
return base, fmt.Sprintf("max flux timeout %s on %s", maxTimeout, maxName), nil
}
return required, fmt.Sprintf("max flux timeout %s on %s", maxTimeout, maxName), nil
}
// parseFluxKustomizationTimeout runs one orchestration or CLI step.
// Signature: parseFluxKustomizationTimeout(raw string) time.Duration.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func parseFluxKustomizationTimeout(raw string) time.Duration {
raw = strings.TrimSpace(raw)
if raw == "" {
return 0
}
d, err := time.ParseDuration(raw)
if err != nil {
return 0
}
return d
}
// fluxHealthReady runs one orchestration or CLI step.
// Signature: (o *Orchestrator) fluxHealthReady(ctx context.Context) (bool, string, error).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) fluxHealthReady(ctx context.Context) (bool, string, error) {
out, err := o.kubectl(ctx, 20*time.Second, "get", "kustomizations.kustomize.toolkit.fluxcd.io", "-A", "-o", "json")
if err != nil {
return false, "", fmt.Errorf("query flux kustomizations: %w", err)
}
var list fluxKustomizationList
if err := json.Unmarshal([]byte(out), &list); err != nil {
return false, "", fmt.Errorf("decode flux kustomizations: %w", err)
}
ignored := makeStringSet(o.cfg.Startup.IgnoreFluxKustomizations)
notReady := []string{}
for _, ks := range list.Items {
ns := strings.TrimSpace(ks.Metadata.Namespace)
name := strings.TrimSpace(ks.Metadata.Name)
if ns == "" || name == "" {
continue
}
full := ns + "/" + name
if ks.Spec.Suspend {
continue
}
if _, ok := ignored[full]; ok {
continue
}
cond := readyCondition(ks.Status.Conditions)
if cond != nil && strings.EqualFold(strings.TrimSpace(cond.Status), "True") {
continue
}
reason := "ready condition missing"
if cond != nil {
reason = strings.TrimSpace(cond.Message)
if reason == "" {
reason = strings.TrimSpace(cond.Reason)
}
if reason == "" {
reason = "ready=false"
}
}
notReady = append(notReady, fmt.Sprintf("%s(%s)", full, reason))
}
if len(notReady) > 0 {
sort.Strings(notReady)
return false, "not ready: " + joinLimited(notReady, 6), nil
}
return true, fmt.Sprintf("all kustomizations ready=%d", len(list.Items)), nil
}
// looksLikeImmutableJobError runs one orchestration or CLI step.
// Signature: looksLikeImmutableJobError(detail string) bool.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func looksLikeImmutableJobError(detail string) bool {
d := strings.ToLower(strings.TrimSpace(detail))
if d == "" {
return false
}
return strings.Contains(d, "field is immutable") && strings.Contains(d, "job")
}
// healImmutableFluxJobs runs one orchestration or CLI step.
// Signature: (o *Orchestrator) healImmutableFluxJobs(ctx context.Context) (bool, error).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) healImmutableFluxJobs(ctx context.Context) (bool, error) {
out, err := o.kubectl(ctx, 25*time.Second, "get", "jobs", "-A", "-o", "json")
if err != nil {
return false, fmt.Errorf("query jobs: %w", err)
}
var list jobList
if err := json.Unmarshal([]byte(out), &list); err != nil {
return false, fmt.Errorf("decode jobs: %w", err)
}
deleted := []string{}
for _, job := range list.Items {
ns := strings.TrimSpace(job.Metadata.Namespace)
name := strings.TrimSpace(job.Metadata.Name)
if ns == "" || name == "" {
continue
}
if !jobLooksFluxManaged(job) {
continue
}
if !jobFailed(job) {
continue
}
o.log.Printf("warning: deleting stale failed flux-managed job %s/%s to recover immutable template drift", ns, name)
if _, err := o.kubectl(ctx, 20*time.Second, "-n", ns, "delete", "job", name, "--wait=false"); err != nil && !isNotFoundErr(err) {
o.log.Printf("warning: delete failed for stale job %s/%s: %v", ns, name, err)
continue
}
deleted = append(deleted, ns+"/"+name)
}
if len(deleted) == 0 {
return false, nil
}
sort.Strings(deleted)
o.log.Printf("immutable-job cleanup removed %d job(s): %s", len(deleted), joinLimited(deleted, 8))
return true, nil
}
// jobLooksFluxManaged runs one orchestration or CLI step.
// Signature: jobLooksFluxManaged(job jobResource) bool.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func jobLooksFluxManaged(job jobResource) bool {
if strings.TrimSpace(job.Metadata.Labels["kustomize.toolkit.fluxcd.io/name"]) != "" {
return true
}
for _, owner := range job.Metadata.OwnerReferences {
if strings.EqualFold(strings.TrimSpace(owner.Kind), "CronJob") {
return false
}
}
return false
}
// jobFailed runs one orchestration or CLI step.
// Signature: jobFailed(job jobResource) bool.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func jobFailed(job jobResource) bool {
if job.Status.Succeeded > 0 {
return false
}
if job.Status.Failed <= 0 {
return false
}
for _, cond := range job.Status.Conditions {
if strings.EqualFold(strings.TrimSpace(cond.Type), "Failed") &&
strings.EqualFold(strings.TrimSpace(cond.Status), "True") {
return true
}
}
return false
}

View File

@ -0,0 +1,344 @@
package cluster
import (
"context"
"encoding/json"
"fmt"
neturl "net/url"
"sort"
"strings"
"time"
"scm.bstein.dev/bstein/ananke/internal/config"
)
// ensureRequiredNodeLabels runs one orchestration or CLI step.
// Signature: (o *Orchestrator) ensureRequiredNodeLabels(ctx context.Context) error.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) ensureRequiredNodeLabels(ctx context.Context) error {
if o.runner.DryRun || len(o.cfg.Startup.RequiredNodeLabels) == 0 {
return nil
}
nodes := make([]string, 0, len(o.cfg.Startup.RequiredNodeLabels))
for node := range o.cfg.Startup.RequiredNodeLabels {
node = strings.TrimSpace(node)
if node != "" {
nodes = append(nodes, node)
}
}
sort.Strings(nodes)
for _, node := range nodes {
labels := o.cfg.Startup.RequiredNodeLabels[node]
if len(labels) == 0 {
continue
}
keys := make([]string, 0, len(labels))
for key := range labels {
key = strings.TrimSpace(key)
if key != "" {
keys = append(keys, key)
}
}
sort.Strings(keys)
args := []string{"label", "node", node, "--overwrite"}
pairs := make([]string, 0, len(keys))
for _, key := range keys {
value := strings.TrimSpace(labels[key])
if value == "" {
continue
}
pair := fmt.Sprintf("%s=%s", key, value)
args = append(args, pair)
pairs = append(pairs, pair)
}
if len(pairs) == 0 {
continue
}
if _, err := o.kubectl(ctx, 25*time.Second, args...); err != nil {
return fmt.Errorf("ensure required node labels on %s (%s): %w", node, strings.Join(pairs, ", "), err)
}
o.log.Printf("ensured required labels on node %s: %s", node, strings.Join(pairs, ", "))
}
return nil
}
// waitForStartupConvergence runs one orchestration or CLI step.
// Signature: (o *Orchestrator) waitForStartupConvergence(ctx context.Context) error.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) waitForStartupConvergence(ctx context.Context) error {
if o.runner.DryRun {
return nil
}
if o.cfg.Startup.RequireIngressChecklist {
o.noteStartupCheckState("ingress-checklist", "running", "waiting for ingress host reachability")
if err := o.waitForIngressChecklist(ctx); err != nil {
o.noteStartupCheck("ingress-checklist", false, err.Error())
return err
}
o.noteStartupCheck("ingress-checklist", true, "all ingress hosts reachable")
}
if o.cfg.Startup.RequireServiceChecklist {
o.noteStartupCheckState("service-checklist", "running", "waiting for external service checklist")
if err := o.waitForServiceChecklist(ctx); err != nil {
o.noteStartupCheck("service-checklist", false, err.Error())
return err
}
o.noteStartupCheck("service-checklist", true, "all configured service checks passed")
}
if o.cfg.Startup.RequireCriticalServiceEndpoints {
o.noteStartupCheckState("critical-service-endpoints", "running", "waiting for critical service endpoint backends")
if err := o.waitForCriticalServiceEndpoints(ctx); err != nil {
o.noteStartupCheck("critical-service-endpoints", false, err.Error())
return err
}
o.noteStartupCheck("critical-service-endpoints", true, "critical service endpoints have active backends")
}
if o.cfg.Startup.RequireFluxHealth {
o.noteStartupCheckState("flux-health", "running", "waiting for flux kustomization readiness")
if err := o.waitForFluxHealth(ctx); err != nil {
o.noteStartupCheck("flux-health", false, err.Error())
return err
}
o.noteStartupCheck("flux-health", true, "all flux kustomizations ready")
}
if o.cfg.Startup.RequireWorkloadConvergence {
o.noteStartupCheckState("workload-convergence", "running", "waiting for controller convergence")
if err := o.waitForWorkloadConvergence(ctx); err != nil {
o.noteStartupCheck("workload-convergence", false, err.Error())
return err
}
o.noteStartupCheck("workload-convergence", true, "controllers converged")
}
o.noteStartupCheckState("stability-window", "running", "running startup stability soak window")
if err := o.waitForStabilityWindow(ctx); err != nil {
o.noteStartupCheck("stability-window", false, err.Error())
return err
}
o.noteStartupCheck("stability-window", true, "startup soak passed")
return nil
}
// waitForIngressChecklist runs one orchestration or CLI step.
// Signature: (o *Orchestrator) waitForIngressChecklist(ctx context.Context) error.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) waitForIngressChecklist(ctx context.Context) error {
wait := time.Duration(o.cfg.Startup.IngressChecklistWaitSeconds) * time.Second
if wait <= 0 {
wait = 7 * time.Minute
}
poll := time.Duration(o.cfg.Startup.IngressChecklistPollSeconds) * time.Second
if poll <= 0 {
poll = 5 * time.Second
}
deadline := time.Now().Add(wait)
lastFailure := "unknown"
lastLogged := time.Time{}
lastRecycleAttempt := time.Time{}
lastReplicaHeal := time.Time{}
lastIngressHeal := time.Time{}
for {
o.maybeAutoRecycleStuckPods(ctx, &lastRecycleAttempt)
o.maybeAutoHealCriticalWorkloadReplicas(ctx, &lastReplicaHeal)
prevFailure := lastFailure
ready, detail := o.ingressChecklistReady(ctx)
lastFailure = detail
if ready {
o.log.Printf("ingress checklist passed (%s)", detail)
return nil
}
o.maybeAutoHealIngressHostBackends(ctx, &lastIngressHeal, lastFailure)
if lastFailure != prevFailure || time.Since(lastLogged) >= 30*time.Second {
remaining := time.Until(deadline).Round(time.Second)
if remaining < 0 {
remaining = 0
}
o.log.Printf("waiting for ingress checklist (%s remaining): %s", remaining, lastFailure)
lastLogged = time.Now()
}
if time.Now().After(deadline) {
return fmt.Errorf("startup blocked: ingress checklist not satisfied within %s (%s)", wait, lastFailure)
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(poll):
}
}
}
// ingressChecklistReady runs one orchestration or CLI step.
// Signature: (o *Orchestrator) ingressChecklistReady(ctx context.Context) (bool, string).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) ingressChecklistReady(ctx context.Context) (bool, string) {
hosts, err := o.discoverIngressHosts(ctx)
if err != nil {
return false, err.Error()
}
if len(hosts) == 0 {
return true, "no ingress hosts discovered"
}
accepted := o.cfg.Startup.IngressChecklistAccepted
if len(accepted) == 0 {
accepted = []int{200, 301, 302, 307, 308, 401, 403, 404}
}
for _, host := range hosts {
check := config.ServiceChecklistCheck{
Name: "ingress-" + host,
URL: "https://" + host + "/",
AcceptedStatuses: accepted,
TimeoutSeconds: 12,
InsecureSkipTLS: o.cfg.Startup.IngressChecklistInsecureSkip,
}
ok, detail := o.serviceCheckReady(ctx, check)
if !ok {
return false, fmt.Sprintf("%s: %s", host, detail)
}
}
return true, fmt.Sprintf("hosts=%d", len(hosts))
}
// discoverIngressHosts runs one orchestration or CLI step.
// Signature: (o *Orchestrator) discoverIngressHosts(ctx context.Context) ([]string, error).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) discoverIngressHosts(ctx context.Context) ([]string, error) {
out, err := o.kubectl(ctx, 25*time.Second, "get", "ingress", "-A", "-o", "json")
if err != nil {
return nil, fmt.Errorf("query ingresses: %w", err)
}
var list ingressList
if err := json.Unmarshal([]byte(out), &list); err != nil {
return nil, fmt.Errorf("decode ingresses: %w", err)
}
ignored := makeStringSet(o.cfg.Startup.IngressChecklistIgnoreHosts)
hosts := map[string]struct{}{}
for _, item := range list.Items {
for _, rule := range item.Spec.Rules {
host := strings.TrimSpace(rule.Host)
if host == "" || strings.Contains(host, "*") {
continue
}
if _, skip := ignored[host]; skip {
continue
}
hosts[host] = struct{}{}
}
}
outHosts := make([]string, 0, len(hosts))
for host := range hosts {
outHosts = append(outHosts, host)
}
sort.Strings(outHosts)
return outHosts, nil
}
// discoverIngressNamespacesForHost runs one orchestration or CLI step.
// Signature: (o *Orchestrator) discoverIngressNamespacesForHost(ctx context.Context, host string) ([]string, error).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) discoverIngressNamespacesForHost(ctx context.Context, host string) ([]string, error) {
host = strings.ToLower(strings.TrimSpace(host))
if host == "" {
return nil, nil
}
out, err := o.kubectl(ctx, 25*time.Second, "get", "ingress", "-A", "-o", "json")
if err != nil {
return nil, fmt.Errorf("query ingresses: %w", err)
}
var list ingressList
if err := json.Unmarshal([]byte(out), &list); err != nil {
return nil, fmt.Errorf("decode ingresses: %w", err)
}
namespaces := map[string]struct{}{}
for _, item := range list.Items {
ns := strings.TrimSpace(item.Metadata.Namespace)
if ns == "" {
continue
}
for _, rule := range item.Spec.Rules {
ruleHost := strings.ToLower(strings.TrimSpace(rule.Host))
if ruleHost == "" {
continue
}
if ruleHost == host {
namespaces[ns] = struct{}{}
break
}
}
}
outNamespaces := make([]string, 0, len(namespaces))
for ns := range namespaces {
outNamespaces = append(outNamespaces, ns)
}
sort.Strings(outNamespaces)
return outNamespaces, nil
}
// maybeAutoHealIngressHostBackends runs one orchestration or CLI step.
// Signature: (o *Orchestrator) maybeAutoHealIngressHostBackends(ctx context.Context, lastAttempt *time.Time, failureDetail string).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) maybeAutoHealIngressHostBackends(ctx context.Context, lastAttempt *time.Time, failureDetail string) {
if o.runner.DryRun {
return
}
host := o.checklistFailureHost(failureDetail)
if host == "" {
return
}
now := time.Now()
if lastAttempt != nil && !lastAttempt.IsZero() && now.Sub(*lastAttempt) < 45*time.Second {
return
}
if lastAttempt != nil {
*lastAttempt = now
}
healed, err := o.healIngressHostBackendReplicas(ctx, host)
if err != nil {
o.log.Printf("warning: ingress host auto-heal failed for %s: %v", host, err)
return
}
if len(healed) == 0 {
return
}
sort.Strings(healed)
detail := fmt.Sprintf("restored ingress backend replicas for %s: %s", host, joinLimited(healed, 8))
o.log.Printf("%s", detail)
o.noteStartupAutoHeal(detail)
}
// checklistFailureHost runs one orchestration or CLI step.
// Signature: (o *Orchestrator) checklistFailureHost(failureDetail string) string.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) checklistFailureHost(failureDetail string) string {
prefix := strings.TrimSpace(failureDetail)
if idx := strings.Index(prefix, ":"); idx > 0 {
prefix = strings.TrimSpace(prefix[:idx])
}
if isLikelyHostname(prefix) {
return strings.ToLower(prefix)
}
for _, check := range o.cfg.Startup.ServiceChecklist {
name := strings.TrimSpace(check.Name)
if !strings.EqualFold(name, prefix) {
continue
}
host := hostFromURL(check.URL)
if host != "" {
return strings.ToLower(host)
}
}
if host := hostFromURL(prefix); host != "" {
return strings.ToLower(host)
}
return ""
}
// hostFromURL runs one orchestration or CLI step.
// Signature: hostFromURL(raw string) string.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func hostFromURL(raw string) string {
parsed, err := neturl.Parse(strings.TrimSpace(raw))
if err != nil || parsed == nil {
return ""
}
return strings.TrimSpace(parsed.Hostname())
}

View File

@ -0,0 +1,189 @@
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)
}
}

View File

@ -0,0 +1,493 @@
package cluster
import (
"context"
"errors"
"fmt"
"sort"
"strings"
"time"
"scm.bstein.dev/bstein/ananke/internal/state"
)
// Startup runs one orchestration or CLI step.
// Signature: (o *Orchestrator) Startup(ctx context.Context, opts StartupOptions) (err error).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) Startup(ctx context.Context, opts StartupOptions) (err error) {
unlock, err := state.AcquireLock(o.cfg.State.LockPath)
if err != nil {
return err
}
defer unlock()
o.beginStartupReport(opts.Reason)
defer o.finalizeStartupReport(err)
o.setStartupPhase("preflight-node-inventory", "validating configured node inventory")
record := state.RunRecord{
ID: fmt.Sprintf("startup-%d", time.Now().UnixNano()),
Action: "startup",
Reason: opts.Reason,
DryRun: o.runner.DryRun,
StartedAt: time.Now().UTC(),
}
defer o.finalizeRecord(&record, &err)
if invErr := o.validateNodeInventory(); invErr != nil {
o.noteStartupCheck("node-inventory", false, invErr.Error())
return invErr
}
o.noteStartupCheck("node-inventory", true, "inventory/user/port validation passed")
o.setStartupPhase("preflight-node-reachability", "waiting for ssh reachability across configured inventory")
if reachErr := o.waitForNodeInventoryReachability(ctx); reachErr != nil {
o.noteStartupCheck("node-inventory-reachability", false, reachErr.Error())
return reachErr
}
o.noteStartupCheck("node-inventory-reachability", true, "all expected nodes responded over SSH")
resumedFlux := false
defer func() {
if o.runner.DryRun || err == nil || resumedFlux {
return
}
o.log.Printf("warning: startup failed before normal flux resume; attempting best-effort recovery resume")
o.bestEffort("restore scaled workloads after failed startup", func() error { return o.restoreScaledApps(ctx) })
o.bestEffort("resume flux after failed startup", func() error { return o.resumeFluxAndReconcile(ctx) })
}()
if !o.runner.DryRun {
currentIntent, readErr := state.ReadIntent(o.cfg.State.IntentPath)
if readErr != nil {
return fmt.Errorf("read startup intent: %w", readErr)
}
if currentIntent.State == state.IntentStartupInProgress {
o.log.Printf("warning: detected stale startup intent from a previous interrupted run; clearing it before continuing")
if clearErr := state.MustWriteIntent(o.cfg.State.IntentPath, state.IntentNormal, "auto-clear stale startup intent", "startup"); clearErr != nil {
return fmt.Errorf("clear stale startup intent: %w", clearErr)
}
currentIntent = state.Intent{State: state.IntentNormal}
}
if currentIntent.State == state.IntentShuttingDown {
if intentFresh(currentIntent, o.startupGuardAge()) {
return fmt.Errorf("startup blocked: shutdown intent is active (%s)", currentIntent.Reason)
}
o.log.Printf("warning: local shutdown intent appears stale (updated_at=%s reason=%q); auto-clearing to continue startup",
currentIntent.UpdatedAt.Format(time.RFC3339), currentIntent.Reason)
if clearErr := state.MustWriteIntent(o.cfg.State.IntentPath, state.IntentNormal, "auto-clear stale shutdown intent", "startup"); clearErr != nil {
return fmt.Errorf("clear stale shutdown intent: %w", clearErr)
}
currentIntent = state.Intent{State: state.IntentNormal}
}
cooldown := o.startupShutdownCooldown()
if currentIntent.State == state.IntentShutdownComplete && intentFresh(currentIntent, cooldown) {
elapsed := intentAge(currentIntent)
remaining := cooldown - elapsed
if remaining < time.Second {
remaining = time.Second
}
o.log.Printf("startup cooldown active: last shutdown completed %s ago; waiting %s", elapsed.Round(time.Second), remaining.Round(time.Second))
timer := time.NewTimer(remaining)
select {
case <-ctx.Done():
timer.Stop()
return fmt.Errorf("startup canceled while waiting for shutdown cooldown: %w", ctx.Err())
case <-timer.C:
}
refreshed, readErr := state.ReadIntent(o.cfg.State.IntentPath)
if readErr != nil {
return fmt.Errorf("re-read startup intent after cooldown wait: %w", readErr)
}
currentIntent = refreshed
if currentIntent.State == state.IntentShuttingDown && intentFresh(currentIntent, o.startupGuardAge()) {
return fmt.Errorf("startup blocked: shutdown intent became active during cooldown wait (%s)", currentIntent.Reason)
}
if currentIntent.State == state.IntentShutdownComplete && intentFresh(currentIntent, cooldown) {
return fmt.Errorf("startup blocked: shutdown completed too recently (%s ago)", intentAge(currentIntent).Round(time.Second))
}
}
if err := o.guardPeerStartupIntents(ctx); err != nil {
return err
}
if writeErr := state.MustWriteIntent(o.cfg.State.IntentPath, state.IntentStartupInProgress, opts.Reason, "startup"); writeErr != nil {
return fmt.Errorf("set startup intent: %w", writeErr)
}
defer func() {
finalReason := opts.Reason
if err != nil {
finalReason = fmt.Sprintf("%s (failed)", strings.TrimSpace(opts.Reason))
}
if writeErr := state.MustWriteIntent(o.cfg.State.IntentPath, state.IntentNormal, finalReason, "startup"); writeErr != nil {
o.log.Printf("warning: write startup completion intent failed: %v", writeErr)
}
}()
}
o.log.Printf("startup control-planes=%s", strings.Join(o.cfg.ControlPlanes, ","))
if o.cfg.Startup.RequireTimeSync {
o.noteStartupCheckState("time-sync", "running", "waiting for node clock synchronization")
o.setStartupPhase("preflight-time-sync", "waiting for control-plane time sync quorum")
if err := o.waitForTimeSync(ctx, o.cfg.ControlPlanes); err != nil {
o.noteStartupCheck("time-sync", false, err.Error())
return err
}
o.noteStartupCheck("time-sync", true, "time synchronization healthy")
}
o.noteStartupCheckState("datastore-preflight", "running", "checking datastore endpoint health")
o.setStartupPhase("preflight-datastore", "checking k3s datastore endpoint")
if err := o.preflightExternalDatastore(ctx); err != nil {
o.noteStartupCheck("datastore-preflight", false, err.Error())
return err
}
o.noteStartupCheck("datastore-preflight", true, "datastore endpoint accepted connections")
o.bestEffort("sync local titan-iac checkout", func() error { return o.syncLocalIACRepo(ctx) })
o.bestEffort("refresh bootstrap cache from local repo", func() error { return o.refreshBootstrapCache(ctx) })
if o.cfg.Startup.ReconcileAccessOnBoot {
o.bestEffort("reconcile control-plane access", func() error { return o.reconcileNodeAccess(ctx, o.cfg.ControlPlanes) })
}
o.reportFluxSource(ctx, opts.ForceFluxBranch)
o.setStartupPhase("control-plane-start", "starting control-plane nodes")
o.startControlPlanes(ctx, o.cfg.ControlPlanes)
apiPoll := time.Duration(o.cfg.Startup.APIPollSeconds) * time.Second
apiAttempts := o.cfg.Startup.APIWaitSeconds / o.cfg.Startup.APIPollSeconds
if apiAttempts < 1 {
apiAttempts = 1
}
o.noteStartupCheckState("kubernetes-api", "running", "waiting for kubernetes api availability")
o.setStartupPhase("api-wait", "waiting for kubernetes api")
if err := o.waitForAPI(ctx, apiAttempts, apiPoll); err != nil {
if !o.cfg.Startup.AutoEtcdRestoreOnAPIFailure {
o.noteStartupCheck("kubernetes-api", false, err.Error())
return err
}
cp := strings.TrimSpace(o.cfg.Startup.EtcdRestoreControlPlane)
if cp == "" && len(o.cfg.ControlPlanes) > 0 {
cp = o.cfg.ControlPlanes[0]
}
o.log.Printf("warning: initial API wait failed (%v); attempting automatic etcd restore on %s", err, cp)
if restoreErr := o.EtcdRestore(ctx, EtcdRestoreOptions{ControlPlane: cp}); restoreErr != nil {
if errors.Is(restoreErr, ErrEtcdRestoreNotApplicable) {
o.log.Printf("warning: automatic etcd restore skipped: %v", restoreErr)
o.log.Printf("warning: retrying control-plane start because datastore recovery path is external")
o.startControlPlanes(ctx, o.cfg.ControlPlanes)
} else {
return fmt.Errorf("kubernetes API did not become reachable and automatic etcd restore failed: %w", restoreErr)
}
}
if err := o.waitForAPI(ctx, apiAttempts, apiPoll); err != nil {
o.noteStartupCheck("kubernetes-api", false, err.Error())
return fmt.Errorf("kubernetes API did not become reachable after automatic etcd restore: %w", err)
}
}
o.noteStartupCheck("kubernetes-api", true, "kubernetes api reachable")
if err := o.ensureRequiredNodeLabels(ctx); err != nil {
return err
}
desiredFluxBranch := strings.TrimSpace(opts.ForceFluxBranch)
if desiredFluxBranch == "" {
desiredFluxBranch = strings.TrimSpace(o.cfg.ExpectedFluxBranch)
}
allowFluxBranchPatch := strings.TrimSpace(opts.ForceFluxBranch) != ""
o.noteStartupCheckState("flux-source-guard", "running", "validating flux source url and branch drift")
o.setStartupPhase("flux-guard", "validating flux source url and branch")
if err := o.guardFluxSourceDrift(ctx, desiredFluxBranch, allowFluxBranchPatch); err != nil {
o.noteStartupCheck("flux-source-guard", false, err.Error())
return err
}
if err := o.ensureFluxBranch(ctx, desiredFluxBranch, allowFluxBranchPatch); err != nil {
o.noteStartupCheck("flux-source-guard", false, err.Error())
return err
}
o.noteStartupCheck("flux-source-guard", true, fmt.Sprintf("flux source branch/url validated (branch=%s)", desiredFluxBranch))
workers, err := o.effectiveWorkers(ctx)
if err != nil {
return err
}
o.log.Printf("startup workers=%s", strings.Join(workers, ","))
o.setStartupPhase("worker-start", "starting and uncordoning worker nodes")
if o.cfg.Startup.ReconcileAccessOnBoot {
o.bestEffort("reconcile worker access", func() error { return o.reconcileNodeAccess(ctx, workers) })
}
o.startWorkers(ctx, workers)
o.bestEffort("uncordon workers", func() error { return o.uncordonWorkers(ctx, workers) })
sshCheckNodes := append([]string{}, o.cfg.ControlPlanes...)
sshCheckNodes = append(sshCheckNodes, workers...)
if err := o.waitForNodeSSHAuth(ctx, sshCheckNodes); err != nil {
o.noteStartupCheck("node-ssh-auth", false, err.Error())
return err
}
o.noteStartupCheck("node-ssh-auth", true, fmt.Sprintf("nodes=%d", len(sshCheckNodes)))
needsLocalBootstrap := false
bootstrapReasons := []string{}
if !opts.SkipLocalBootstrap {
ready, readyErr := o.fluxSourceReady(ctx)
if readyErr != nil {
o.log.Printf("warning: unable to read flux source readiness: %v", readyErr)
needsLocalBootstrap = true
bootstrapReasons = append(bootstrapReasons, "flux source readiness check failed")
}
if !ready {
needsLocalBootstrap = true
bootstrapReasons = append(bootstrapReasons, "flux source not ready")
}
}
missing, missingErr := o.missingCriticalStartupWorkloads(ctx)
if missingErr != nil {
o.log.Printf("warning: unable to inspect critical startup workloads: %v", missingErr)
}
if len(missing) > 0 {
o.log.Printf("startup critical workloads not ready; applying targeted recovery first: %s", strings.Join(missing, ", "))
}
if o.cfg.Startup.RequireStorageReady {
o.noteStartupCheckState("storage-readiness", "running", "waiting for longhorn and critical pvc readiness")
o.setStartupPhase("storage-readiness", "waiting for longhorn and critical pvcs")
if err := o.waitForStorageReady(ctx); err != nil {
o.noteStartupCheck("storage-readiness", false, err.Error())
return err
}
o.noteStartupCheck("storage-readiness", true, "longhorn and critical PVCs ready")
}
o.noteStartupCheckState("critical-workloads", "running", "ensuring critical startup workloads have replicas")
o.setStartupPhase("critical-workloads", "ensuring critical startup workloads are ready")
if err := o.ensureCriticalStartupWorkloads(ctx); err != nil {
o.noteStartupCheck("critical-workloads", false, err.Error())
return err
}
o.noteStartupCheck("critical-workloads", true, "critical startup workloads are ready")
if !opts.SkipLocalBootstrap && needsLocalBootstrap {
if ready, err := o.waitForFluxSourceReady(ctx, 5*time.Minute); err != nil {
o.log.Printf("warning: flux source readiness wait failed before local bootstrap: %v", err)
} else if ready {
o.log.Printf("flux source became ready after targeted recovery; skipping local bootstrap")
needsLocalBootstrap = false
}
}
if !opts.SkipLocalBootstrap && needsLocalBootstrap {
o.log.Printf("startup bootstrap required after wait: %s", strings.Join(bootstrapReasons, "; "))
if err := o.bootstrapLocal(ctx); err != nil {
return err
}
if err := o.ensureCriticalStartupWorkloads(ctx); err != nil {
return err
}
ready, err := o.fluxSourceReady(ctx)
if err != nil {
return fmt.Errorf("flux source readiness after bootstrap: %w", err)
}
if !ready {
return fmt.Errorf("flux source still not ready after local bootstrap")
}
}
o.bestEffort("restore scaled workloads", func() error { return o.restoreScaledApps(ctx) })
o.noteStartupCheckState("flux-resume-reconcile", "running", "resuming flux and waiting for reconcile")
o.setStartupPhase("flux-resume", "resuming flux controllers and reconciling kustomizations")
if err := o.resumeFluxAndReconcile(ctx); err != nil {
o.noteStartupCheck("flux-resume-reconcile", false, err.Error())
return err
}
o.noteStartupCheck("flux-resume-reconcile", true, "flux resumed and reconciled")
resumedFlux = true
o.bestEffort("heal critical zero-replica workloads", func() error {
healed, healErr := o.healCriticalWorkloadReplicas(ctx)
if healErr != nil {
return healErr
}
if len(healed) > 0 {
sort.Strings(healed)
o.noteStartupAutoHeal(fmt.Sprintf("restored critical workload replicas: %s", joinLimited(healed, 8)))
}
return nil
})
o.setStartupPhase("convergence-checks", "waiting for ingress, service, flux, workload, and stability checks")
if err := o.waitForStartupConvergence(ctx); err != nil {
return err
}
if o.cfg.Startup.RequirePostStartProbes {
o.noteStartupCheckState("post-start-probes", "running", "waiting for post-start probe endpoints")
o.setStartupPhase("post-start-probes", "running post-start probe checks")
if err := o.waitForPostStartProbes(ctx); err != nil {
o.noteStartupCheck("post-start-probes", false, err.Error())
return err
}
o.noteStartupCheck("post-start-probes", true, "post-start probes passed")
}
o.setStartupPhase("startup-complete", "startup workflow reached completion")
o.log.Printf("startup flow complete")
return nil
}
// EtcdRestore runs one orchestration or CLI step.
// Signature: (o *Orchestrator) EtcdRestore(ctx context.Context, opts EtcdRestoreOptions) error.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) EtcdRestore(ctx context.Context, opts EtcdRestoreOptions) error {
controlPlane := strings.TrimSpace(opts.ControlPlane)
if controlPlane == "" {
if len(o.cfg.ControlPlanes) == 0 {
return fmt.Errorf("cannot restore etcd: no control planes configured")
}
controlPlane = o.cfg.ControlPlanes[0]
}
found := false
for _, cp := range o.cfg.ControlPlanes {
if cp == controlPlane {
found = true
break
}
}
if !found {
return fmt.Errorf("cannot restore etcd: control plane %s is not in configured control_planes", controlPlane)
}
if !o.sshManaged(controlPlane) {
return fmt.Errorf("cannot restore etcd on %s: node not in ssh_managed_nodes", controlPlane)
}
snapshotPath := strings.TrimSpace(opts.SnapshotPath)
if o.runner.DryRun {
if snapshotPath == "" {
snapshotPath = "<latest-snapshot-on-" + controlPlane + ">"
}
o.log.Printf("etcd restore target=%s snapshot=%s (dry-run; datastore-mode and snapshot checks skipped)", controlPlane, snapshotPath)
return nil
}
externalDatastore, err := o.controlPlaneUsesExternalDatastore(ctx, controlPlane)
if err != nil {
return err
}
if externalDatastore {
return fmt.Errorf("%w: %s uses --datastore-endpoint", ErrEtcdRestoreNotApplicable, controlPlane)
}
if snapshotPath == "" {
resolved, err := o.latestEtcdSnapshotPath(ctx, controlPlane)
if err != nil {
return err
}
snapshotPath = resolved
}
if err := o.verifyEtcdSnapshot(ctx, controlPlane, snapshotPath); err != nil {
return err
}
o.log.Printf("etcd restore target=%s snapshot=%s", controlPlane, snapshotPath)
for _, cp := range o.cfg.ControlPlanes {
cp := cp
o.bestEffort("stop k3s before etcd restore on "+cp, func() error {
_, err := o.ssh(ctx, cp, "sudo systemctl stop k3s || true")
return err
})
}
if _, err := o.runSudoK3S(ctx, controlPlane, "server", "--cluster-reset", "--cluster-reset-restore-path", snapshotPath); err != nil {
return fmt.Errorf("etcd restore command failed on %s: %w", controlPlane, err)
}
o.log.Printf("etcd restore command completed on %s", controlPlane)
if _, err := o.ssh(ctx, controlPlane, "sudo systemctl start k3s || true"); err != nil {
return fmt.Errorf("failed to start k3s on restore control plane %s: %w", controlPlane, err)
}
time.Sleep(10 * time.Second)
for _, cp := range o.cfg.ControlPlanes {
cp := cp
if cp == controlPlane {
continue
}
o.bestEffort("start k3s after etcd restore on "+cp, func() error {
_, err := o.ssh(ctx, cp, "sudo systemctl start k3s || true")
return err
})
}
return nil
}
// Shutdown runs one orchestration or CLI step.
// Signature: (o *Orchestrator) Shutdown(ctx context.Context, opts ShutdownOptions) (err error).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) Shutdown(ctx context.Context, opts ShutdownOptions) (err error) {
unlock, err := state.AcquireLock(o.cfg.State.LockPath)
if err != nil {
return err
}
defer unlock()
if invErr := o.validateNodeInventory(); invErr != nil {
return invErr
}
record := state.RunRecord{
ID: fmt.Sprintf("shutdown-%d", time.Now().UnixNano()),
Action: "shutdown",
Reason: opts.Reason,
DryRun: o.runner.DryRun,
StartedAt: time.Now().UTC(),
}
defer o.finalizeRecord(&record, &err)
if !o.runner.DryRun {
if writeErr := state.MustWriteIntent(o.cfg.State.IntentPath, state.IntentShuttingDown, opts.Reason, "shutdown"); writeErr != nil {
return fmt.Errorf("set shutdown intent: %w", writeErr)
}
defer func() {
final := state.IntentShuttingDown
if err == nil {
final = state.IntentShutdownComplete
}
if writeErr := state.MustWriteIntent(o.cfg.State.IntentPath, final, opts.Reason, "shutdown"); writeErr != nil {
o.log.Printf("warning: write shutdown completion intent failed: %v", writeErr)
}
}()
}
workers, err := o.effectiveWorkers(ctx)
if err != nil {
return err
}
o.log.Printf("shutdown control-planes=%s workers=%s", strings.Join(o.cfg.ControlPlanes, ","), strings.Join(workers, ","))
o.reportFluxSource(ctx, "")
skipEtcd := opts.SkipEtcdSnapshot || o.cfg.Shutdown.SkipEtcdSnapshot
if !skipEtcd {
o.bestEffort("etcd snapshot", func() error {
return o.takeEtcdSnapshot(ctx, o.cfg.ControlPlanes[0])
})
}
o.bestEffort("suspend flux", func() error { return o.patchFluxSuspendAll(ctx, true) })
o.bestEffort("scale down apps", func() error { return o.scaleDownApps(ctx) })
skipDrain := opts.SkipDrain || o.cfg.Shutdown.SkipDrain
if !skipDrain {
o.bestEffort("drain workers", func() error { return o.drainWorkers(ctx, workers) })
}
shutdownMode := strings.TrimSpace(opts.Mode)
effectiveMode, modeErr := normalizeShutdownMode(shutdownMode)
if modeErr != nil {
return modeErr
}
o.log.Printf("shutdown execution mode=%s (requested=%q)", effectiveMode, shutdownMode)
o.stopWorkers(ctx, workers)
o.stopControlPlanes(ctx, o.cfg.ControlPlanes)
o.log.Printf("shutdown flow complete")
return nil
}
// normalizeShutdownMode runs one orchestration or CLI step.
// Signature: normalizeShutdownMode(raw string) (string, error).
// Why: keeps shutdown behavior explicit and safe by allowing only cluster-only
// semantics while preserving compatibility with legacy "config" callers.
func normalizeShutdownMode(raw string) (string, error) {
switch strings.TrimSpace(raw) {
case "", "config", "cluster-only":
return "cluster-only", nil
case "poweroff":
return "", fmt.Errorf("shutdown mode %q has been removed; ananke no longer powers off hosts", raw)
default:
return "", fmt.Errorf("unsupported shutdown mode %q (expected config|cluster-only)", raw)
}
}

View File

@ -0,0 +1,109 @@
package cluster
import (
"context"
"fmt"
"sort"
"strings"
"time"
)
// waitForNodeInventoryReachability runs one orchestration or CLI step.
// Signature: (o *Orchestrator) waitForNodeInventoryReachability(ctx context.Context) error.
// Why: Startup deadlocks often begin when the coordinator races ahead while one or
// more expected hosts are still unavailable; this gate blocks bootstrap until
// inventory hosts are actually reachable and authenticated over SSH.
func (o *Orchestrator) waitForNodeInventoryReachability(ctx context.Context) error {
if o.runner.DryRun || !o.cfg.Startup.RequireNodeInventoryReach {
return nil
}
wait := time.Duration(o.cfg.Startup.NodeInventoryReachWaitSeconds) * time.Second
if wait <= 0 {
wait = 5 * time.Minute
}
poll := time.Duration(o.cfg.Startup.NodeInventoryReachPollSeconds) * time.Second
if poll <= 0 {
poll = 5 * time.Second
}
ignored := makeStringSet(o.cfg.Startup.IgnoreUnavailableNodes)
targets := make([]string, 0, len(o.inventoryNodesForValidation()))
seen := map[string]struct{}{}
for _, node := range o.inventoryNodesForValidation() {
node = strings.TrimSpace(node)
if node == "" {
continue
}
if _, skip := ignored[node]; skip {
continue
}
if _, ok := seen[node]; ok {
continue
}
seen[node] = struct{}{}
targets = append(targets, node)
}
sort.Strings(targets)
if len(targets) == 0 {
return nil
}
deadline := time.Now().Add(wait)
lastFailure := "unknown"
lastLogged := time.Time{}
for {
authDenied := []string{}
pending := []string{}
for _, node := range targets {
if !o.sshManaged(node) {
authDenied = append(authDenied, fmt.Sprintf("%s(not in ssh_managed_nodes)", node))
continue
}
out, err := o.sshWithTimeout(ctx, node, "echo __ANANKE_NODE_REACHABLE__", 12*time.Second)
if err != nil {
detail := strings.ToLower(strings.TrimSpace(err.Error() + " " + out))
switch {
case strings.Contains(detail, "permission denied"),
strings.Contains(detail, "publickey"),
strings.Contains(detail, "authentication"):
authDenied = append(authDenied, fmt.Sprintf("%s(auth denied)", node))
default:
pending = append(pending, fmt.Sprintf("%s(unreachable)", node))
}
continue
}
if !strings.Contains(out, "__ANANKE_NODE_REACHABLE__") {
pending = append(pending, fmt.Sprintf("%s(unexpected output)", node))
}
}
if len(authDenied) > 0 {
sort.Strings(authDenied)
return fmt.Errorf("node inventory reachability gate failed: %s", joinLimited(authDenied, 10))
}
if len(pending) == 0 {
return nil
}
sort.Strings(pending)
lastFailure = joinLimited(pending, 10)
if time.Now().After(deadline) {
return fmt.Errorf("node inventory reachability gate did not pass within %s (%s)", wait, lastFailure)
}
if time.Since(lastLogged) >= 30*time.Second {
remaining := time.Until(deadline).Round(time.Second)
if remaining < 0 {
remaining = 0
}
o.log.Printf("waiting for node inventory reachability (%s remaining): %s", remaining, lastFailure)
lastLogged = time.Now()
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(poll):
}
}
}

View File

@ -0,0 +1,381 @@
package cluster
import (
"context"
"fmt"
"os"
"os/exec"
"strconv"
"strings"
"time"
"scm.bstein.dev/bstein/ananke/internal/sshutil"
)
// waitForPostStartProbes runs one orchestration or CLI step.
// Signature: (o *Orchestrator) waitForPostStartProbes(ctx context.Context) error.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) waitForPostStartProbes(ctx context.Context) error {
if o.runner.DryRun {
return nil
}
wait := time.Duration(o.cfg.Startup.PostStartProbeWaitSeconds) * time.Second
if wait <= 0 {
wait = 240 * time.Second
}
poll := time.Duration(o.cfg.Startup.PostStartProbePollSeconds) * time.Second
if poll <= 0 {
poll = 5 * time.Second
}
deadline := time.Now().Add(wait)
lastFailure := "unknown"
lastLogged := time.Time{}
for {
ok, failure := o.postStartProbesReady(ctx)
if ok {
o.log.Printf("post-start probes passed")
return nil
}
if failure != lastFailure || time.Since(lastLogged) >= 30*time.Second {
remaining := time.Until(deadline).Round(time.Second)
if remaining < 0 {
remaining = 0
}
o.log.Printf("waiting for post-start probes (%s remaining): %s", remaining, failure)
lastLogged = time.Now()
}
lastFailure = failure
if time.Now().After(deadline) {
return fmt.Errorf("startup blocked: post-start probes did not pass within %s (%s)", wait, lastFailure)
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(poll):
}
}
}
// postStartProbesReady runs one orchestration or CLI step.
// Signature: (o *Orchestrator) postStartProbesReady(ctx context.Context) (bool, string).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) postStartProbesReady(ctx context.Context) (bool, string) {
probes := make([]string, 0, len(o.cfg.Startup.PostStartProbes))
for _, p := range o.cfg.Startup.PostStartProbes {
p = strings.TrimSpace(p)
if p != "" {
probes = append(probes, p)
}
}
if len(probes) == 0 {
return true, "no probes configured"
}
for _, probe := range probes {
code, err := o.httpProbe(ctx, probe)
if err != nil {
return false, fmt.Sprintf("%s: %v", probe, err)
}
if !probeStatusAccepted(probe, code) {
return false, fmt.Sprintf("%s: unexpected status code=%d", probe, code)
}
}
return true, "all probes successful"
}
// probeStatusAccepted runs one orchestration or CLI step.
// Signature: probeStatusAccepted(_ string, code int) bool.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func probeStatusAccepted(_ string, code int) bool {
if code >= 200 && code < 400 {
return true
}
// Auth fronts often return unauthorized/forbidden while still proving the service is up.
if code == 401 || code == 403 {
return true
}
return false
}
// httpProbe runs one orchestration or CLI step.
// Signature: (o *Orchestrator) httpProbe(ctx context.Context, probeURL string) (int, error).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) httpProbe(ctx context.Context, probeURL string) (int, error) {
out, err := o.run(
ctx,
20*time.Second,
"curl",
"--silent",
"--show-error",
"--location",
"--max-time",
"12",
"--output",
"/dev/null",
"--write-out",
"%{http_code}",
probeURL,
)
if err != nil {
return 0, err
}
code, convErr := strconv.Atoi(strings.TrimSpace(out))
if convErr != nil {
return 0, fmt.Errorf("parse http status %q: %w", strings.TrimSpace(out), convErr)
}
return code, nil
}
// resumeFluxAndReconcile runs one orchestration or CLI step.
// Signature: (o *Orchestrator) resumeFluxAndReconcile(ctx context.Context) error.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) resumeFluxAndReconcile(ctx context.Context) error {
if err := o.patchFluxSuspendAll(ctx, false); err != nil {
return err
}
now := time.Now().UTC().Format(time.RFC3339)
if _, err := o.kubectl(
ctx,
25*time.Second,
"-n", "flux-system",
"annotate",
"kustomizations.kustomize.toolkit.fluxcd.io",
"--all",
"reconcile.fluxcd.io/requestedAt="+now,
"--overwrite",
); err != nil {
o.log.Printf("warning: annotate kustomizations for reconcile failed: %v", err)
}
if _, err := o.kubectl(
ctx,
25*time.Second,
"annotate",
"--all-namespaces",
"helmreleases.helm.toolkit.fluxcd.io",
"--all",
"reconcile.fluxcd.io/requestedAt="+now,
"--overwrite",
); err != nil {
o.log.Printf("warning: annotate helmreleases for reconcile failed: %v", err)
}
if o.runner.CommandExists("flux") {
sourceCmd := []string{"reconcile", "source", "git", "flux-system", "-n", "flux-system", "--timeout=60s"}
if _, err := o.run(ctx, 75*time.Second, "flux", sourceCmd...); err != nil {
o.log.Printf("warning: flux command failed (%s): %v", strings.Join(sourceCmd, " "), err)
}
}
return nil
}
// kubectl runs one orchestration or CLI step.
// Signature: (o *Orchestrator) kubectl(ctx context.Context, timeout time.Duration, args ...string) (string, error).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) kubectl(ctx context.Context, timeout time.Duration, args ...string) (string, error) {
return o.run(ctx, timeout, "kubectl", args...)
}
// ssh runs one orchestration or CLI step.
// Signature: (o *Orchestrator) ssh(ctx context.Context, node string, command string) (string, error).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) ssh(ctx context.Context, node string, command string) (string, error) {
return o.sshWithTimeout(ctx, node, command, 45*time.Second)
}
// sshWithTimeout runs one orchestration or CLI step.
// Signature: (o *Orchestrator) sshWithTimeout(ctx context.Context, node string, command string, timeout time.Duration) (string, error).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) sshWithTimeout(ctx context.Context, node string, command string, timeout time.Duration) (string, error) {
host := node
if mapped, ok := o.cfg.SSHNodeHosts[node]; ok && strings.TrimSpace(mapped) != "" {
host = strings.TrimSpace(mapped)
}
sshUser := o.cfg.SSHUser
if override, ok := o.cfg.SSHNodeUsers[node]; ok && strings.TrimSpace(override) != "" {
sshUser = strings.TrimSpace(override)
}
target := host
if sshUser != "" {
target = sshUser + "@" + host
}
sshConfigFile := o.resolveSSHConfigFile()
sshIdentity := o.resolveSSHIdentityFile()
baseArgs := []string{
"-o", "BatchMode=yes",
"-o", "ConnectTimeout=8",
"-o", "StrictHostKeyChecking=accept-new",
}
if sshConfigFile != "" {
baseArgs = append(baseArgs, "-F", sshConfigFile)
}
if sshIdentity != "" {
baseArgs = append(baseArgs, "-i", sshIdentity)
}
if o.cfg.SSHPort > 0 {
baseArgs = append(baseArgs, "-p", strconv.Itoa(o.cfg.SSHPort))
}
attempts := make([][]string, 0, 2)
attemptNames := make([]string, 0, 2)
knownHostsFiles := sshutil.KnownHostsFiles(sshConfigFile, sshIdentity)
repairHosts := []string{node, host}
if o.cfg.SSHJumpHost != "" {
jump := o.cfg.SSHJumpHost
repairHosts = append(repairHosts, jump)
if mapped, ok := o.cfg.SSHNodeHosts[jump]; ok && strings.TrimSpace(mapped) != "" {
repairHosts = append(repairHosts, strings.TrimSpace(mapped))
}
if o.cfg.SSHJumpUser != "" {
jump = o.cfg.SSHJumpUser + "@" + jump
}
if o.cfg.SSHPort > 0 && !strings.Contains(jump, ":") {
jump = fmt.Sprintf("%s:%d", jump, o.cfg.SSHPort)
}
withJump := append([]string{}, baseArgs...)
withJump = append(withJump, "-J", jump, target, command)
attempts = append(attempts, withJump)
attemptNames = append(attemptNames, "jump")
}
direct := append([]string{}, baseArgs...)
direct = append(direct, target, command)
attempts = append(attempts, direct)
attemptNames = append(attemptNames, "direct")
var lastOut string
var lastErr error
for i, args := range attempts {
out, err := o.run(ctx, timeout, "ssh", args...)
if err == nil {
if i > 0 {
o.log.Printf("warning: ssh %s path failed for %s, using %s path", attemptNames[i-1], node, attemptNames[i])
}
return out, nil
}
if sshutil.ShouldAttemptKnownHostsRepair(out, err) {
o.log.Printf("warning: ssh failure on %s via %s path may be host-key related; repairing known_hosts and retrying once", node, attemptNames[i])
sshutil.RepairKnownHosts(ctx, o.log, knownHostsFiles, repairHosts, o.cfg.SSHPort)
retryOut, retryErr := o.run(ctx, timeout, "ssh", args...)
if retryErr == nil {
return retryOut, nil
}
out = retryOut
err = retryErr
}
lastOut = out
lastErr = err
if i < len(attempts)-1 {
o.log.Printf("warning: ssh %s path failed for %s: %v; trying %s path", attemptNames[i], node, err, attemptNames[i+1])
}
}
return lastOut, lastErr
}
// run runs one orchestration or CLI step.
// Signature: (o *Orchestrator) run(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) run(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
if o.runOverride != nil {
return o.runOverride(ctx, timeout, name, args...)
}
runCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
return o.runner.Run(runCtx, name, args...)
}
// runSensitive runs one orchestration or CLI step.
// Signature: (o *Orchestrator) runSensitive(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) runSensitive(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
if o.runSensitiveOverride != nil {
return o.runSensitiveOverride(ctx, timeout, name, args...)
}
runCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
cmd := exec.CommandContext(runCtx, name, args...)
cmd.Env = os.Environ()
if o.runner.Kubeconfig != "" {
cmd.Env = append(cmd.Env, "KUBECONFIG="+o.runner.Kubeconfig)
}
out, err := cmd.CombinedOutput()
trimmed := strings.TrimSpace(string(out))
if err != nil {
if trimmed == "" {
return "", fmt.Errorf("%s failed: %w", name, err)
}
return trimmed, fmt.Errorf("%s failed: %w", name, err)
}
return trimmed, nil
}
// lines runs one orchestration or CLI step.
// Signature: lines(in string) []string.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func lines(in string) []string {
in = strings.TrimSpace(in)
if in == "" {
return nil
}
parts := strings.Split(in, "\n")
out := make([]string, 0, len(parts))
for _, p := range parts {
v := strings.TrimSpace(p)
if v != "" {
out = append(out, v)
}
}
return out
}
// sshManaged runs one orchestration or CLI step.
// Signature: (o *Orchestrator) sshManaged(node string) bool.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) sshManaged(node string) bool {
if len(o.cfg.SSHManagedNodes) == 0 {
return true
}
for _, allowed := range o.cfg.SSHManagedNodes {
if strings.TrimSpace(allowed) == node {
return true
}
}
return false
}
// resolveSSHConfigFile runs one orchestration or CLI step.
// Signature: (o *Orchestrator) resolveSSHConfigFile() string.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) resolveSSHConfigFile() string {
if strings.TrimSpace(o.cfg.SSHConfigFile) != "" {
return strings.TrimSpace(o.cfg.SSHConfigFile)
}
candidates := []string{
"/home/atlas/.ssh/config",
"/home/tethys/.ssh/config",
}
for _, p := range candidates {
if stat, err := os.Stat(p); err == nil && !stat.IsDir() {
return p
}
}
return ""
}
// resolveSSHIdentityFile runs one orchestration or CLI step.
// Signature: (o *Orchestrator) resolveSSHIdentityFile() string.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) resolveSSHIdentityFile() string {
if strings.TrimSpace(o.cfg.SSHIdentityFile) != "" {
return strings.TrimSpace(o.cfg.SSHIdentityFile)
}
candidates := []string{
"/home/atlas/.ssh/id_ed25519",
"/home/tethys/.ssh/id_ed25519",
}
for _, p := range candidates {
if stat, err := os.Stat(p); err == nil && !stat.IsDir() {
return p
}
}
return ""
}

View File

@ -0,0 +1,355 @@
package cluster
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"scm.bstein.dev/bstein/ananke/internal/state"
)
// EstimatedShutdownSeconds runs one orchestration or CLI step.
// Signature: (o *Orchestrator) EstimatedShutdownSeconds() int.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) EstimatedShutdownSeconds() int {
return o.store.ShutdownP95WithMinSamples(o.cfg.Shutdown.DefaultBudgetSeconds, o.cfg.Shutdown.HistoryMinSamples)
}
// EstimatedEmergencyShutdownSeconds runs one orchestration or CLI step.
// Signature: (o *Orchestrator) EstimatedEmergencyShutdownSeconds() int.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) EstimatedEmergencyShutdownSeconds() int {
return o.store.ShutdownP95ByReasonPrefix(
o.cfg.Shutdown.EmergencyBudgetSec,
o.cfg.Shutdown.EmergencyMinSamples,
[]string{"ups-", "emergency-", "drill-emergency"},
)
}
// finalizeRecord runs one orchestration or CLI step.
// Signature: (o *Orchestrator) finalizeRecord(record *state.RunRecord, err *error).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) finalizeRecord(record *state.RunRecord, err *error) {
record.EndedAt = time.Now().UTC()
record.DurationSeconds = int(record.EndedAt.Sub(record.StartedAt).Seconds())
record.Success = *err == nil
if *err != nil {
record.Error = (*err).Error()
}
if appendErr := o.store.Append(*record); appendErr != nil {
o.log.Printf("warning: append run record failed: %v", appendErr)
}
if artifactErr := o.writeRunRecordArtifact(*record); artifactErr != nil {
o.log.Printf("warning: write run artifact failed: %v", artifactErr)
}
}
// startupReportPath runs one orchestration or CLI step.
// Signature: (o *Orchestrator) startupReportPath() string.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) startupReportPath() string {
return filepath.Join(o.cfg.State.Dir, "last-startup-report.json")
}
// startupProgressPath runs one orchestration or CLI step.
// Signature: (o *Orchestrator) startupProgressPath() string.
// Why: status polling during long startup drills needs a live, on-disk snapshot
// instead of waiting for final completion artifacts.
func (o *Orchestrator) startupProgressPath() string {
return filepath.Join(o.cfg.State.Dir, "startup-progress.json")
}
// lastShutdownReportPath runs one orchestration or CLI step.
// Signature: (o *Orchestrator) lastShutdownReportPath() string.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) lastShutdownReportPath() string {
return filepath.Join(o.cfg.State.Dir, "last-shutdown-report.json")
}
// reportArchiveDir runs one orchestration or CLI step.
// Signature: (o *Orchestrator) reportArchiveDir() string.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) reportArchiveDir() string {
dir := strings.TrimSpace(o.cfg.State.ReportsDir)
if dir != "" {
return dir
}
return filepath.Join(o.cfg.State.Dir, "reports")
}
// writeRunRecordArtifact runs one orchestration or CLI step.
// Signature: (o *Orchestrator) writeRunRecordArtifact(record state.RunRecord) error.
// Why: Every drill should leave durable timing and outcome evidence so we can
// compare runs and tighten automation without relying on terminal history.
func (o *Orchestrator) writeRunRecordArtifact(record state.RunRecord) error {
dir := o.reportArchiveDir()
if err := os.MkdirAll(dir, 0o750); err != nil {
return fmt.Errorf("ensure report archive dir %s: %w", dir, err)
}
blob, err := json.MarshalIndent(record, "", " ")
if err != nil {
return fmt.Errorf("encode run record %s: %w", record.ID, err)
}
reportPath := filepath.Join(dir, sanitizeReportFileName(record.ID)+".json")
if err := os.WriteFile(reportPath, blob, 0o640); err != nil {
return fmt.Errorf("write run artifact %s: %w", reportPath, err)
}
if record.Action == "shutdown" {
if err := os.WriteFile(o.lastShutdownReportPath(), blob, 0o640); err != nil {
return fmt.Errorf("write last shutdown report: %w", err)
}
}
return nil
}
// sanitizeReportFileName runs one orchestration or CLI step.
// Signature: sanitizeReportFileName(value string) string.
// Why: IDs can include punctuation from external inputs; sanitize to keep report
// filenames portable and predictable across hosts.
func sanitizeReportFileName(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return "run"
}
var b strings.Builder
b.Grow(len(value))
for _, r := range value {
switch {
case r >= 'a' && r <= 'z':
b.WriteRune(r)
case r >= 'A' && r <= 'Z':
b.WriteRune(r)
case r >= '0' && r <= '9':
b.WriteRune(r)
case r == '-', r == '_':
b.WriteRune(r)
default:
b.WriteRune('_')
}
}
cleaned := strings.Trim(b.String(), "_")
if cleaned == "" {
return "run"
}
return cleaned
}
// cloneStartupReport runs one orchestration or CLI step.
// Signature: cloneStartupReport(report *startupReport) *startupReport.
// Why: a status poll can occur while startup is active; clone protects readers
// from observing partially-mutated map/slice state.
func cloneStartupReport(report *startupReport) *startupReport {
if report == nil {
return nil
}
cloned := *report
cloned.Checks = make(map[string]startupCheckRecord, len(report.Checks))
for name, check := range report.Checks {
cloned.Checks[name] = check
}
cloned.AutoHeals = append([]string{}, report.AutoHeals...)
return &cloned
}
// writeStartupReportFile runs one orchestration or CLI step.
// Signature: (o *Orchestrator) writeStartupReportFile(path string, report *startupReport) error.
// Why: startup status needs one shared serializer so progress and final reports
// stay consistent for both humans and automation.
func (o *Orchestrator) writeStartupReportFile(path string, report *startupReport) error {
if report == nil {
return nil
}
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return fmt.Errorf("ensure startup report dir %s: %w", filepath.Dir(path), err)
}
b, err := json.MarshalIndent(report, "", " ")
if err != nil {
return fmt.Errorf("encode startup report: %w", err)
}
if err := os.WriteFile(path, b, 0o640); err != nil {
return fmt.Errorf("write startup report %s: %w", path, err)
}
return nil
}
// persistStartupProgress runs one orchestration or CLI step.
// Signature: (o *Orchestrator) persistStartupProgress(report *startupReport).
// Why: status polling must reflect in-flight checklist changes instead of only
// a terminal success/failure snapshot.
func (o *Orchestrator) persistStartupProgress(report *startupReport) {
if report == nil {
return
}
if err := o.writeStartupReportFile(o.startupProgressPath(), report); err != nil {
o.log.Printf("warning: persist startup progress failed: %v", err)
}
}
// setStartupPhase runs one orchestration or CLI step.
// Signature: (o *Orchestrator) setStartupPhase(phase string, detail string).
// Why: operators need to see where startup is paused without reading controller
// logs line-by-line during a drill.
func (o *Orchestrator) setStartupPhase(phase string, detail string) {
phase = strings.TrimSpace(phase)
detail = strings.TrimSpace(detail)
o.startupReportMu.Lock()
if o.activeStartupReport == nil {
o.startupReportMu.Unlock()
return
}
if phase != "" {
o.activeStartupReport.Phase = phase
}
now := time.Now().UTC()
o.activeStartupReport.LastUpdated = now
if detail != "" {
o.activeStartupReport.Checks["phase"] = startupCheckRecord{
Status: "running",
Detail: detail,
UpdatedAt: now,
}
}
snapshot := cloneStartupReport(o.activeStartupReport)
o.startupReportMu.Unlock()
o.persistStartupProgress(snapshot)
}
// beginStartupReport runs one orchestration or CLI step.
// Signature: (o *Orchestrator) beginStartupReport(reason string).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) beginStartupReport(reason string) {
host, _ := os.Hostname()
o.startupReportMu.Lock()
now := time.Now().UTC()
o.activeStartupReport = &startupReport{
StartedAt: now,
Reason: strings.TrimSpace(reason),
Status: "running",
Phase: "startup-initializing",
Checks: map[string]startupCheckRecord{},
AutoHeals: []string{},
SourceHost: strings.TrimSpace(host),
LastUpdated: now,
}
snapshot := cloneStartupReport(o.activeStartupReport)
o.startupReportMu.Unlock()
o.persistStartupProgress(snapshot)
}
// noteStartupCheckState runs one orchestration or CLI step.
// Signature: (o *Orchestrator) noteStartupCheckState(name, status, detail string).
// Why: startup checks can be running, passed, or failed; explicit state improves
// checklist introspection from the CLI while startup is still executing.
func (o *Orchestrator) noteStartupCheckState(name, status, detail string) {
name = strings.TrimSpace(name)
status = strings.TrimSpace(status)
detail = strings.TrimSpace(detail)
if name == "" {
return
}
switch status {
case "passed", "failed", "running":
default:
status = "running"
}
o.startupReportMu.Lock()
if o.activeStartupReport == nil {
o.startupReportMu.Unlock()
return
}
now := time.Now().UTC()
o.activeStartupReport.LastUpdated = now
o.activeStartupReport.Checks[name] = startupCheckRecord{
Status: status,
Detail: detail,
UpdatedAt: now,
}
snapshot := cloneStartupReport(o.activeStartupReport)
o.startupReportMu.Unlock()
o.persistStartupProgress(snapshot)
}
// noteStartupCheck runs one orchestration or CLI step.
// Signature: (o *Orchestrator) noteStartupCheck(name string, success bool, detail string).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) noteStartupCheck(name string, success bool, detail string) {
status := "failed"
if success {
status = "passed"
}
o.noteStartupCheckState(name, status, detail)
}
// noteStartupAutoHeal runs one orchestration or CLI step.
// Signature: (o *Orchestrator) noteStartupAutoHeal(detail string).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) noteStartupAutoHeal(detail string) {
o.startupReportMu.Lock()
if o.activeStartupReport == nil {
o.startupReportMu.Unlock()
return
}
detail = strings.TrimSpace(detail)
if detail == "" {
o.startupReportMu.Unlock()
return
}
o.activeStartupReport.AutoHeals = append(o.activeStartupReport.AutoHeals, detail)
o.activeStartupReport.LastUpdated = time.Now().UTC()
snapshot := cloneStartupReport(o.activeStartupReport)
o.startupReportMu.Unlock()
o.persistStartupProgress(snapshot)
}
// finalizeStartupReportSnapshot runs one orchestration or CLI step.
// Signature: (o *Orchestrator) finalizeStartupReportSnapshot(report *startupReport) *startupReport.
// Why: completion metadata should be consistent across both final and progress
// report files so operators can trust whichever file they read.
func (o *Orchestrator) finalizeStartupReportSnapshot(report *startupReport, runErr error) *startupReport {
report = cloneStartupReport(report)
if report == nil {
return nil
}
report.Completed = time.Now().UTC()
report.Success = runErr == nil
report.Status = "success"
report.Phase = "complete"
report.LastUpdated = report.Completed
if runErr != nil {
report.Status = "failed"
report.Phase = "failed"
report.Error = strings.TrimSpace(runErr.Error())
}
return report
}
// finalizeStartupReport runs one orchestration or CLI step.
// Signature: (o *Orchestrator) finalizeStartupReport(runErr error).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) finalizeStartupReport(runErr error) {
o.startupReportMu.Lock()
report := o.activeStartupReport
o.activeStartupReport = nil
o.startupReportMu.Unlock()
if report == nil {
return
}
report = o.finalizeStartupReportSnapshot(report, runErr)
path := o.startupReportPath()
if writeErr := o.writeStartupReportFile(path, report); writeErr != nil {
o.log.Printf("warning: %v", writeErr)
return
}
o.persistStartupProgress(report)
archivePath := filepath.Join(
o.reportArchiveDir(),
fmt.Sprintf("startup-report-%d.json", report.StartedAt.UnixNano()),
)
if writeErr := o.writeStartupReportFile(archivePath, report); writeErr != nil {
o.log.Printf("warning: %v", writeErr)
}
o.log.Printf("startup report written: %s", path)
}

View File

@ -0,0 +1,230 @@
package cluster
import (
"encoding/json"
"io"
"log"
"os"
"path/filepath"
"strings"
"testing"
"time"
"scm.bstein.dev/bstein/ananke/internal/config"
"scm.bstein.dev/bstein/ananke/internal/state"
)
// TestSanitizeReportFileName runs one orchestration or CLI step.
// Signature: TestSanitizeReportFileName(t *testing.T).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func TestSanitizeReportFileName(t *testing.T) {
if got := sanitizeReportFileName("startup-123"); got != "startup-123" {
t.Fatalf("expected startup-123, got %q", got)
}
if got := sanitizeReportFileName(" run id / weird "); got != "run_id___weird" {
t.Fatalf("unexpected sanitized value: %q", got)
}
if got := sanitizeReportFileName(" "); got != "run" {
t.Fatalf("expected fallback run, got %q", got)
}
}
// TestWriteRunRecordArtifactWritesShutdownLatest runs one orchestration or CLI step.
// Signature: TestWriteRunRecordArtifactWritesShutdownLatest(t *testing.T).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func TestWriteRunRecordArtifactWritesShutdownLatest(t *testing.T) {
tmp := t.TempDir()
orch := &Orchestrator{
cfg: config.Config{
State: config.State{
Dir: tmp,
ReportsDir: filepath.Join(tmp, "reports"),
},
},
log: log.New(io.Discard, "", 0),
}
rec := state.RunRecord{
ID: "shutdown-123",
Action: "shutdown",
Reason: "test",
DurationSeconds: 12,
StartedAt: time.Now().UTC().Add(-12 * time.Second),
EndedAt: time.Now().UTC(),
Success: true,
}
if err := orch.writeRunRecordArtifact(rec); err != nil {
t.Fatalf("write run artifact failed: %v", err)
}
archived := filepath.Join(tmp, "reports", "shutdown-123.json")
if _, err := os.Stat(archived); err != nil {
t.Fatalf("expected archived run file %s: %v", archived, err)
}
latest := filepath.Join(tmp, "last-shutdown-report.json")
if _, err := os.Stat(latest); err != nil {
t.Fatalf("expected last shutdown report %s: %v", latest, err)
}
}
// TestFinalizeStartupReportWritesLastAndArchive runs one orchestration or CLI step.
// Signature: TestFinalizeStartupReportWritesLastAndArchive(t *testing.T).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func TestFinalizeStartupReportWritesLastAndArchive(t *testing.T) {
tmp := t.TempDir()
orch := &Orchestrator{
cfg: config.Config{
State: config.State{
Dir: tmp,
ReportsDir: filepath.Join(tmp, "reports"),
},
},
log: log.New(io.Discard, "", 0),
}
orch.beginStartupReport("drill")
orch.noteStartupCheck("node-inventory", true, "ok")
orch.finalizeStartupReport(nil)
lastPath := filepath.Join(tmp, "last-startup-report.json")
if _, err := os.Stat(lastPath); err != nil {
t.Fatalf("expected last startup report %s: %v", lastPath, err)
}
entries, err := os.ReadDir(filepath.Join(tmp, "reports"))
if err != nil {
t.Fatalf("read report archive: %v", err)
}
found := false
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), "startup-report-") && strings.HasSuffix(entry.Name(), ".json") {
found = true
break
}
}
if !found {
t.Fatalf("expected archived startup report under %s", filepath.Join(tmp, "reports"))
}
}
// TestBeginStartupReportWritesProgressSnapshot runs one orchestration or CLI step.
// Signature: TestBeginStartupReportWritesProgressSnapshot(t *testing.T).
// Why: status polling during startup depends on a live progress file that exists
// before the final report is written.
func TestBeginStartupReportWritesProgressSnapshot(t *testing.T) {
tmp := t.TempDir()
orch := &Orchestrator{
cfg: config.Config{
State: config.State{
Dir: tmp,
ReportsDir: filepath.Join(tmp, "reports"),
},
},
log: log.New(io.Discard, "", 0),
}
orch.beginStartupReport("drill")
path := filepath.Join(tmp, "startup-progress.json")
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read startup progress: %v", err)
}
var report startupReport
if err := json.Unmarshal(b, &report); err != nil {
t.Fatalf("decode startup progress: %v", err)
}
if report.Status != "running" {
t.Fatalf("expected running status, got %q", report.Status)
}
if report.Phase == "" {
t.Fatalf("expected non-empty phase")
}
}
// TestSetStartupPhaseWritesProgressSnapshot runs one orchestration or CLI step.
// Signature: TestSetStartupPhaseWritesProgressSnapshot(t *testing.T).
// Why: startup phase changes should be externally visible without waiting for completion.
func TestSetStartupPhaseWritesProgressSnapshot(t *testing.T) {
tmp := t.TempDir()
orch := &Orchestrator{
cfg: config.Config{
State: config.State{
Dir: tmp,
ReportsDir: filepath.Join(tmp, "reports"),
},
},
log: log.New(io.Discard, "", 0),
}
orch.beginStartupReport("drill")
orch.setStartupPhase("flux-resume", "resuming flux")
path := filepath.Join(tmp, "startup-progress.json")
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read startup progress: %v", err)
}
var report startupReport
if err := json.Unmarshal(b, &report); err != nil {
t.Fatalf("decode startup progress: %v", err)
}
if report.Phase != "flux-resume" {
t.Fatalf("expected updated phase, got %q", report.Phase)
}
check, ok := report.Checks["phase"]
if !ok {
t.Fatalf("expected phase check entry in progress report")
}
if check.Status != "running" {
t.Fatalf("expected running phase check status, got %q", check.Status)
}
}
// TestFinalizeStartupReportWritesFailedProgressSnapshot runs one orchestration or CLI step.
// Signature: TestFinalizeStartupReportWritesFailedProgressSnapshot(t *testing.T).
// Why: failures must remain visible through the progress file so operators can
// read the terminal state from a single status path.
func TestFinalizeStartupReportWritesFailedProgressSnapshot(t *testing.T) {
tmp := t.TempDir()
orch := &Orchestrator{
cfg: config.Config{
State: config.State{
Dir: tmp,
ReportsDir: filepath.Join(tmp, "reports"),
},
},
log: log.New(io.Discard, "", 0),
}
orch.beginStartupReport("drill")
orch.finalizeStartupReport(assertErr("boom"))
path := filepath.Join(tmp, "startup-progress.json")
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read startup progress: %v", err)
}
var report startupReport
if err := json.Unmarshal(b, &report); err != nil {
t.Fatalf("decode startup progress: %v", err)
}
if report.Status != "failed" {
t.Fatalf("expected failed status, got %q", report.Status)
}
if report.Error != "boom" {
t.Fatalf("expected error message boom, got %q", report.Error)
}
}
// assertErr runs one orchestration or CLI step.
// Signature: assertErr(message string) error.
// Why: keeps error fixtures explicit without repeating inline helper closures.
func assertErr(message string) error {
return &startupTestErr{msg: message}
}
type startupTestErr struct {
msg string
}
// Error runs one orchestration or CLI step.
// Signature: (e *startupTestErr) Error() string.
// Why: keeps deterministic error strings in startup report tests concise.
func (e *startupTestErr) Error() string {
return e.msg
}

View File

@ -0,0 +1,379 @@
package cluster
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
)
// effectiveWorkers runs one orchestration or CLI step.
// Signature: (o *Orchestrator) effectiveWorkers(ctx context.Context) ([]string, error).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) effectiveWorkers(ctx context.Context) ([]string, error) {
if len(o.cfg.Workers) > 0 {
return append([]string{}, o.cfg.Workers...), nil
}
workers, err := o.discoverWorkers(ctx)
if err == nil {
return workers, nil
}
fallback := o.fallbackWorkersFromInventory()
if len(fallback) == 0 {
return nil, err
}
o.log.Printf("warning: worker discovery failed via kubernetes API (%v); falling back to inventory workers=%s", err, strings.Join(fallback, ","))
return fallback, nil
}
// discoverWorkers runs one orchestration or CLI step.
// Signature: (o *Orchestrator) discoverWorkers(ctx context.Context) ([]string, error).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) discoverWorkers(ctx context.Context) ([]string, error) {
out, err := o.kubectl(ctx, 15*time.Second,
"get", "nodes",
"-o", "custom-columns=NAME:.metadata.name,CP:.metadata.labels.node-role\\.kubernetes\\.io/control-plane,MASTER:.metadata.labels.node-role\\.kubernetes\\.io/master",
"--no-headers",
)
if err != nil {
return nil, fmt.Errorf("discover workers: %w", err)
}
var workers []string
for _, line := range lines(out) {
fields := strings.Fields(line)
if len(fields) < 3 {
continue
}
if fields[1] == "<none>" && fields[2] == "<none>" {
workers = append(workers, fields[0])
}
}
if len(workers) == 0 {
return nil, fmt.Errorf("no workers discovered")
}
return workers, nil
}
// fallbackWorkersFromInventory runs one orchestration or CLI step.
// Signature: (o *Orchestrator) fallbackWorkersFromInventory() []string.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) fallbackWorkersFromInventory() []string {
cp := make(map[string]struct{}, len(o.cfg.ControlPlanes))
for _, node := range o.cfg.ControlPlanes {
cp[strings.TrimSpace(node)] = struct{}{}
}
candidates := make(map[string]struct{})
add := func(node string) {
name := strings.TrimSpace(node)
if name == "" {
return
}
if _, isCP := cp[name]; isCP {
return
}
candidates[name] = struct{}{}
}
for _, node := range o.cfg.SSHManagedNodes {
add(node)
}
if len(candidates) == 0 {
for node := range o.cfg.SSHNodeHosts {
add(node)
}
}
workers := make([]string, 0, len(candidates))
for node := range candidates {
workers = append(workers, node)
}
sort.Strings(workers)
return workers
}
// patchFluxSuspendAll runs one orchestration or CLI step.
// Signature: (o *Orchestrator) patchFluxSuspendAll(ctx context.Context, suspend bool) error.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) patchFluxSuspendAll(ctx context.Context, suspend bool) error {
patch := fmt.Sprintf(`{"spec":{"suspend":%t}}`, suspend)
ksOut, err := o.kubectl(ctx, 20*time.Second,
"-n", "flux-system", "get", "kustomizations.kustomize.toolkit.fluxcd.io",
"-o", "jsonpath={range .items[*]}{.metadata.name}{'\\n'}{end}",
)
if err != nil {
return err
}
for _, ks := range lines(ksOut) {
_, patchErr := o.kubectl(ctx, 20*time.Second, "-n", "flux-system", "patch", "kustomization", ks, "--type=merge", "-p", patch)
if patchErr != nil {
o.log.Printf("warning: patch kustomization %s failed: %v", ks, patchErr)
}
}
hrOut, err := o.kubectl(ctx, 25*time.Second,
"get", "helmreleases.helm.toolkit.fluxcd.io", "-A",
"-o", "jsonpath={range .items[*]}{.metadata.namespace}{'/'}{.metadata.name}{'\\n'}{end}",
)
if err != nil {
return err
}
for _, hr := range lines(hrOut) {
parts := strings.SplitN(hr, "/", 2)
if len(parts) != 2 {
continue
}
_, patchErr := o.kubectl(ctx, 20*time.Second, "-n", parts[0], "patch", "helmrelease", parts[1], "--type=merge", "-p", patch)
if patchErr != nil {
o.log.Printf("warning: patch helmrelease %s failed: %v", hr, patchErr)
}
}
return nil
}
// scaleDownApps runs one orchestration or CLI step.
// Signature: (o *Orchestrator) scaleDownApps(ctx context.Context) error.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) scaleDownApps(ctx context.Context) error {
targets, err := o.listScalableWorkloads(ctx)
if err != nil {
return err
}
if err := o.writeScaledWorkloadSnapshot(targets); err != nil {
return err
}
if len(targets) == 0 {
o.log.Printf("scale down apps: no workloads above 0 replicas")
return nil
}
parallelism := o.cfg.Shutdown.ScaleParallelism
if parallelism <= 0 {
parallelism = 8
}
o.log.Printf("scale down apps targets=%d parallelism=%d", len(targets), parallelism)
return o.scaleWorkloads(ctx, targets, 0, parallelism)
}
// restoreScaledApps runs one orchestration or CLI step.
// Signature: (o *Orchestrator) restoreScaledApps(ctx context.Context) error.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) restoreScaledApps(ctx context.Context) error {
snapshot, err := o.readScaledWorkloadSnapshot()
if err != nil {
return err
}
if snapshot == nil || len(snapshot.Entries) == 0 {
o.log.Printf("restore scaled workloads: no snapshot entries to restore")
return nil
}
parallelism := o.cfg.Shutdown.ScaleParallelism
if parallelism <= 0 {
parallelism = 8
}
o.log.Printf("restore scaled workloads entries=%d parallelism=%d snapshot_at=%s", len(snapshot.Entries), parallelism, snapshot.GeneratedAt.Format(time.RFC3339))
if err := o.scaleWorkloads(ctx, snapshot.Entries, -1, parallelism); err != nil {
return err
}
if o.runner.DryRun {
return nil
}
if err := os.Remove(o.scaledWorkloadSnapshotPath()); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove scaled workload snapshot: %w", err)
}
return nil
}
// listScalableWorkloads runs one orchestration or CLI step.
// Signature: (o *Orchestrator) listScalableWorkloads(ctx context.Context) ([]workloadScaleEntry, error).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) listScalableWorkloads(ctx context.Context) ([]workloadScaleEntry, error) {
exclude := map[string]struct{}{}
for _, ns := range o.cfg.ExcludedNamespaces {
exclude[strings.TrimSpace(ns)] = struct{}{}
}
collect := func(kind string) ([]workloadScaleEntry, error) {
out, err := o.kubectl(
ctx,
25*time.Second,
"get",
kind,
"-A",
"-o",
"jsonpath={range .items[*]}{.metadata.namespace}{'\\t'}{.metadata.name}{'\\t'}{.spec.replicas}{'\\n'}{end}",
)
if err != nil {
return nil, err
}
var entries []workloadScaleEntry
for _, line := range lines(out) {
parts := strings.Split(line, "\t")
if len(parts) < 3 {
continue
}
ns := strings.TrimSpace(parts[0])
if _, skip := exclude[ns]; skip {
continue
}
replicas, convErr := strconv.Atoi(strings.TrimSpace(parts[2]))
if convErr != nil || replicas <= 0 {
continue
}
entries = append(entries, workloadScaleEntry{
Namespace: ns,
Kind: kind,
Name: strings.TrimSpace(parts[1]),
Replicas: replicas,
})
}
return entries, nil
}
deployments, err := collect("deployment")
if err != nil {
return nil, fmt.Errorf("collect deployments: %w", err)
}
statefulsets, err := collect("statefulset")
if err != nil {
return nil, fmt.Errorf("collect statefulsets: %w", err)
}
targets := append(deployments, statefulsets...)
sort.Slice(targets, func(i, j int) bool {
a, b := targets[i], targets[j]
if a.Namespace != b.Namespace {
return a.Namespace < b.Namespace
}
if a.Kind != b.Kind {
return a.Kind < b.Kind
}
return a.Name < b.Name
})
return targets, nil
}
// scaleWorkloads runs one orchestration or CLI step.
// Signature: (o *Orchestrator) scaleWorkloads(ctx context.Context, entries []workloadScaleEntry, forceReplicas int, parallelism int) error.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) scaleWorkloads(ctx context.Context, entries []workloadScaleEntry, forceReplicas int, parallelism int) error {
if len(entries) == 0 {
return nil
}
if parallelism <= 0 {
parallelism = 1
}
if parallelism > len(entries) {
parallelism = len(entries)
}
sem := make(chan struct{}, parallelism)
var wg sync.WaitGroup
errCh := make(chan error, len(entries))
for _, entry := range entries {
entry := entry
wg.Add(1)
go func() {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
replicas := entry.Replicas
if forceReplicas >= 0 {
replicas = forceReplicas
}
if _, err := o.kubectl(
ctx,
20*time.Second,
"-n",
entry.Namespace,
"scale",
entry.Kind,
entry.Name,
fmt.Sprintf("--replicas=%d", replicas),
); err != nil {
if isNotFoundErr(err) {
o.log.Printf("warning: skip missing workload while scaling %s/%s/%s", entry.Namespace, entry.Kind, entry.Name)
return
}
errCh <- fmt.Errorf("scale %s/%s/%s -> %d: %w", entry.Namespace, entry.Kind, entry.Name, replicas, err)
}
}()
}
wg.Wait()
close(errCh)
errorCount := len(errCh)
if errorCount == 0 {
return nil
}
errs := make([]string, 0, len(errCh))
for err := range errCh {
errs = append(errs, err.Error())
if len(errs) >= 5 {
break
}
}
return fmt.Errorf("scaling had %d errors (first: %s)", errorCount, strings.Join(errs, " | "))
}
// scaledWorkloadSnapshotPath runs one orchestration or CLI step.
// Signature: (o *Orchestrator) scaledWorkloadSnapshotPath() string.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) scaledWorkloadSnapshotPath() string {
return filepath.Join(o.cfg.State.Dir, "scaled-workloads.json")
}
// writeScaledWorkloadSnapshot runs one orchestration or CLI step.
// Signature: (o *Orchestrator) writeScaledWorkloadSnapshot(entries []workloadScaleEntry) error.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) writeScaledWorkloadSnapshot(entries []workloadScaleEntry) error {
if o.runner.DryRun {
return nil
}
if err := os.MkdirAll(o.cfg.State.Dir, 0o755); err != nil {
return fmt.Errorf("ensure state dir: %w", err)
}
payload := workloadScaleSnapshot{
GeneratedAt: time.Now().UTC(),
Entries: entries,
}
b, err := json.MarshalIndent(payload, "", " ")
if err != nil {
return fmt.Errorf("marshal scaled workload snapshot: %w", err)
}
if err := os.WriteFile(o.scaledWorkloadSnapshotPath(), b, 0o644); err != nil {
return fmt.Errorf("write scaled workload snapshot: %w", err)
}
return nil
}
// readScaledWorkloadSnapshot runs one orchestration or CLI step.
// Signature: (o *Orchestrator) readScaledWorkloadSnapshot() (*workloadScaleSnapshot, error).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) readScaledWorkloadSnapshot() (*workloadScaleSnapshot, error) {
if o.runner.DryRun {
return nil, nil
}
b, err := os.ReadFile(o.scaledWorkloadSnapshotPath())
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("read scaled workload snapshot: %w", err)
}
var snapshot workloadScaleSnapshot
if err := json.Unmarshal(b, &snapshot); err != nil {
return nil, fmt.Errorf("decode scaled workload snapshot: %w", err)
}
return &snapshot, nil
}

View File

@ -0,0 +1,26 @@
package cluster
import (
"strings"
)
// parseSnapshotPathFromEtcdSnapshotList runs one orchestration or CLI step.
// Signature: parseSnapshotPathFromEtcdSnapshotList(out string) string.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func parseSnapshotPathFromEtcdSnapshotList(out string) string {
for _, line := range lines(out) {
trimmed := strings.TrimSpace(line)
lower := strings.ToLower(trimmed)
if strings.HasPrefix(lower, "name") && strings.Contains(lower, "location") {
continue
}
for _, field := range strings.Fields(trimmed) {
candidate := strings.Trim(strings.TrimSpace(field), "\",")
candidate = strings.TrimPrefix(candidate, "file://")
if strings.HasPrefix(candidate, "/var/lib/rancher/k3s/server/db/snapshots/") {
return candidate
}
}
}
return ""
}

View File

@ -0,0 +1,275 @@
package cluster
import (
"context"
"fmt"
"strings"
"time"
)
// waitForStorageReady runs one orchestration or CLI step.
// Signature: (o *Orchestrator) waitForStorageReady(ctx context.Context) error.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) waitForStorageReady(ctx context.Context) error {
if o.runner.DryRun {
return nil
}
wait := time.Duration(o.cfg.Startup.StorageReadyWaitSeconds) * time.Second
if wait <= 0 {
wait = 420 * time.Second
}
poll := time.Duration(o.cfg.Startup.StorageReadyPollSeconds) * time.Second
if poll <= 0 {
poll = 5 * time.Second
}
deadline := time.Now().Add(wait)
lastReason := "unknown"
for {
ok, reason, err := o.storageReady(ctx)
if err != nil {
lastReason = err.Error()
} else {
lastReason = reason
}
if ok {
o.log.Printf("storage readiness check passed (%s)", reason)
return nil
}
if time.Now().After(deadline) {
return fmt.Errorf("startup blocked: storage readiness not satisfied within %s (%s)", wait, lastReason)
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(poll):
}
}
}
// storageReady runs one orchestration or CLI step.
// Signature: (o *Orchestrator) storageReady(ctx context.Context) (bool, string, error).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) storageReady(ctx context.Context) (bool, string, error) {
minReady := o.cfg.Startup.StorageMinReadyNodes
if minReady <= 0 {
minReady = 2
}
longhornOut, err := o.kubectl(
ctx,
15*time.Second,
"-n",
"longhorn-system",
"get",
"nodes.longhorn.io",
"-o",
`jsonpath={range .items[*]}{.metadata.name}{":"}{.status.conditions[?(@.type=="Ready")].status}{":"}{.status.conditions[?(@.type=="Schedulable")].status}{"\n"}{end}`,
)
if err != nil {
return false, "", fmt.Errorf("query longhorn nodes: %w", err)
}
readyNodes := 0
for _, line := range lines(longhornOut) {
parts := strings.Split(line, ":")
if len(parts) < 3 {
continue
}
ready := strings.EqualFold(strings.TrimSpace(parts[1]), "true")
sched := strings.EqualFold(strings.TrimSpace(parts[2]), "true")
if ready && sched {
readyNodes++
}
}
if readyNodes < minReady {
return false, fmt.Sprintf("longhorn ready+sched nodes %d/%d", readyNodes, minReady), nil
}
for _, item := range o.cfg.Startup.StorageCriticalPVCs {
item = strings.TrimSpace(item)
if item == "" {
continue
}
parts := strings.SplitN(item, "/", 2)
if len(parts) != 2 {
return false, "", fmt.Errorf("invalid storage_critical_pvcs entry %q", item)
}
ns := strings.TrimSpace(parts[0])
name := strings.TrimSpace(parts[1])
out, pvcErr := o.kubectl(ctx, 15*time.Second, "-n", ns, "get", "pvc", name, "-o", "jsonpath={.status.phase}")
if pvcErr != nil {
if isNotFoundErr(pvcErr) {
return false, fmt.Sprintf("pvc %s/%s not found", ns, name), nil
}
return false, "", fmt.Errorf("query pvc %s/%s: %w", ns, name, pvcErr)
}
if !strings.EqualFold(strings.TrimSpace(out), "Bound") {
return false, fmt.Sprintf("pvc %s/%s phase=%s", ns, name, strings.TrimSpace(out)), nil
}
}
return true, fmt.Sprintf("longhorn ready+sched nodes=%d critical pvcs bound=%d", readyNodes, len(o.cfg.Startup.StorageCriticalPVCs)), nil
}
type fluxCondition struct {
Type string `json:"type"`
Status string `json:"status"`
Reason string `json:"reason"`
Message string `json:"message"`
}
type fluxKustomizationList struct {
Items []fluxKustomization `json:"items"`
}
type fluxKustomization struct {
Metadata struct {
Namespace string `json:"namespace"`
Name string `json:"name"`
} `json:"metadata"`
Spec struct {
Suspend bool `json:"suspend"`
Timeout string `json:"timeout"`
} `json:"spec"`
Status struct {
Conditions []fluxCondition `json:"conditions"`
} `json:"status"`
}
type workloadList struct {
Items []workloadResource `json:"items"`
}
type ingressList struct {
Items []ingressResource `json:"items"`
}
type ingressResource struct {
Metadata struct {
Namespace string `json:"namespace"`
Name string `json:"name"`
} `json:"metadata"`
Spec struct {
Rules []struct {
Host string `json:"host"`
} `json:"rules"`
} `json:"spec"`
}
type jobList struct {
Items []jobResource `json:"items"`
}
type jobResource struct {
Metadata struct {
Namespace string `json:"namespace"`
Name string `json:"name"`
Labels map[string]string `json:"labels"`
OwnerReferences []ownerReference `json:"ownerReferences"`
} `json:"metadata"`
Status struct {
Failed int32 `json:"failed"`
Succeeded int32 `json:"succeeded"`
Conditions []jobConditionRef `json:"conditions"`
} `json:"status"`
}
type jobConditionRef struct {
Type string `json:"type"`
Status string `json:"status"`
}
type workloadResource struct {
Kind string `json:"kind"`
Metadata struct {
Namespace string `json:"namespace"`
Name string `json:"name"`
} `json:"metadata"`
Spec struct {
Replicas *int32 `json:"replicas"`
Template struct {
Spec podSpec `json:"spec"`
} `json:"template"`
} `json:"spec"`
Status struct {
ReadyReplicas int32 `json:"readyReplicas"`
DesiredNumberScheduled int32 `json:"desiredNumberScheduled"`
NumberReady int32 `json:"numberReady"`
} `json:"status"`
}
type podList struct {
Items []podResource `json:"items"`
}
type podResource struct {
Metadata struct {
Namespace string `json:"namespace"`
Name string `json:"name"`
Annotations map[string]string `json:"annotations"`
CreationTimestamp time.Time `json:"creationTimestamp"`
OwnerReferences []ownerReference `json:"ownerReferences"`
} `json:"metadata"`
Spec struct {
NodeName string `json:"nodeName"`
podSpec
} `json:"spec"`
Status struct {
Phase string `json:"phase"`
InitContainerStatuses []podContainerStatus `json:"initContainerStatuses"`
ContainerStatuses []podContainerStatus `json:"containerStatuses"`
} `json:"status"`
}
type ownerReference struct {
Kind string `json:"kind"`
}
type podContainerStatus struct {
Name string `json:"name"`
State podContainerState `json:"state"`
}
type podContainerState struct {
Waiting *podContainerWaitingState `json:"waiting"`
Running *podContainerRunningState `json:"running"`
}
type podContainerWaitingState struct {
Reason string `json:"reason"`
}
type podContainerRunningState struct {
StartedAt time.Time `json:"startedAt"`
}
type podSpec struct {
NodeSelector map[string]string `json:"nodeSelector"`
Affinity *podAffinity `json:"affinity"`
}
type podAffinity struct {
NodeAffinity *nodeAffinity `json:"nodeAffinity"`
}
type nodeAffinity struct {
RequiredDuringSchedulingIgnoredDuringExecution *nodeSelector `json:"requiredDuringSchedulingIgnoredDuringExecution"`
}
type nodeSelector struct {
NodeSelectorTerms []nodeSelectorTerm `json:"nodeSelectorTerms"`
}
type nodeSelectorTerm struct {
MatchExpressions []nodeSelectorRequirement `json:"matchExpressions"`
}
type nodeSelectorRequirement struct {
Key string `json:"key"`
Operator string `json:"operator"`
Values []string `json:"values"`
}
type workloadIgnoreRule struct {
Namespace string
Kind string
Name string
}

View File

@ -0,0 +1,346 @@
package cluster
import (
"context"
"fmt"
"net"
neturl "net/url"
"sort"
"strings"
"time"
)
// waitForTimeSync runs one orchestration or CLI step.
// Signature: (o *Orchestrator) waitForTimeSync(ctx context.Context, nodes []string) error.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) waitForTimeSync(ctx context.Context, nodes []string) error {
if o.runner.DryRun {
return nil
}
wait := time.Duration(o.cfg.Startup.TimeSyncWaitSeconds) * time.Second
if wait <= 0 {
wait = 240 * time.Second
}
poll := time.Duration(o.cfg.Startup.TimeSyncPollSeconds) * time.Second
if poll <= 0 {
poll = 5 * time.Second
}
mode := strings.ToLower(strings.TrimSpace(o.cfg.Startup.TimeSyncMode))
if mode == "" {
mode = "strict"
}
managedControlPlanes := 0
for _, node := range nodes {
node = strings.TrimSpace(node)
if node == "" {
continue
}
if o.sshManaged(node) {
managedControlPlanes++
}
}
requiredQuorum := o.cfg.Startup.TimeSyncQuorum
if requiredQuorum <= 0 {
requiredQuorum = managedControlPlanes
if requiredQuorum <= 0 {
requiredQuorum = 1
}
}
if requiredQuorum > managedControlPlanes && managedControlPlanes > 0 {
requiredQuorum = managedControlPlanes
}
deadline := time.Now().Add(wait)
for {
unsynced := []string{}
syncedControlPlanes := 0
checkedControlPlanes := 0
localOut, localErr := o.run(ctx, 10*time.Second, "sh", "-lc", "timedatectl show -p NTPSynchronized --value 2>/dev/null || echo unknown")
localSynced := localErr == nil && isTimeSynced(localOut)
if !localSynced {
if localErr != nil {
unsynced = append(unsynced, fmt.Sprintf("local(%v)", localErr))
} else {
unsynced = append(unsynced, fmt.Sprintf("local(%s)", strings.TrimSpace(localOut)))
}
}
for _, node := range nodes {
node = strings.TrimSpace(node)
if node == "" {
continue
}
if !o.sshManaged(node) {
continue
}
checkedControlPlanes++
out, err := o.ssh(ctx, node, "timedatectl show -p NTPSynchronized --value 2>/dev/null || echo unknown")
if err != nil || !isTimeSynced(out) {
if err != nil {
unsynced = append(unsynced, fmt.Sprintf("%s(%v)", node, err))
} else {
unsynced = append(unsynced, fmt.Sprintf("%s(%s)", node, strings.TrimSpace(out)))
}
} else {
syncedControlPlanes++
}
}
ready := false
switch mode {
case "quorum":
if localSynced && syncedControlPlanes >= requiredQuorum {
ready = true
}
default:
if localSynced && len(unsynced) == 0 {
ready = true
}
}
if ready {
return nil
}
if time.Now().After(deadline) {
if mode == "quorum" {
return fmt.Errorf(
"startup blocked: time sync quorum not ready within %s (mode=quorum local_synced=%t synced_control_planes=%d required=%d checked=%d details=%s)",
wait,
localSynced,
syncedControlPlanes,
requiredQuorum,
checkedControlPlanes,
strings.Join(unsynced, ", "),
)
}
return fmt.Errorf("startup blocked: time sync not ready within %s (%s)", wait, strings.Join(unsynced, ", "))
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(poll):
}
}
}
// isTimeSynced runs one orchestration or CLI step.
// Signature: isTimeSynced(raw string) bool.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func isTimeSynced(raw string) bool {
v := strings.ToLower(strings.TrimSpace(raw))
return v == "yes" || v == "true" || v == "1"
}
// preflightExternalDatastore runs one orchestration or CLI step.
// Signature: (o *Orchestrator) preflightExternalDatastore(ctx context.Context) error.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) preflightExternalDatastore(ctx context.Context) error {
if len(o.cfg.ControlPlanes) == 0 {
return nil
}
controlPlane := strings.TrimSpace(o.cfg.ControlPlanes[0])
if controlPlane == "" || !o.sshManaged(controlPlane) {
return nil
}
unitOut, err := o.ssh(ctx, controlPlane, "sudo systemctl cat k3s")
if err != nil {
o.log.Printf("warning: external datastore preflight skipped: unable to inspect %s k3s unit: %v", controlPlane, err)
return nil
}
datastoreEndpoint := parseDatastoreEndpoint(unitOut)
if datastoreEndpoint == "" {
return nil
}
u, err := neturl.Parse(datastoreEndpoint)
if err != nil || u.Host == "" {
o.log.Printf("warning: external datastore preflight skipped: unable to parse datastore endpoint %q", datastoreEndpoint)
return nil
}
host := strings.TrimSpace(u.Hostname())
port := strings.TrimSpace(u.Port())
if port == "" {
port = "5432"
}
address := net.JoinHostPort(host, port)
if o.tcpReachable(address, 3*time.Second) {
return nil
}
o.log.Printf("warning: datastore endpoint %s is unreachable; attempting software recovery", address)
if node := o.nodeNameForHost(host); node != "" && o.sshManaged(node) {
o.bestEffort("restart datastore service on "+node, func() error {
_, err := o.ssh(ctx, node, "sudo systemctl restart postgresql || sudo systemctl restart postgresql@16-main || sudo systemctl restart postgres")
return err
})
}
deadline := time.Now().Add(90 * time.Second)
for time.Now().Before(deadline) {
if o.tcpReachable(address, 3*time.Second) {
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(3 * time.Second):
}
}
return fmt.Errorf("startup blocked: external datastore endpoint %s remained unreachable after recovery attempt", address)
}
// parseDatastoreEndpoint runs one orchestration or CLI step.
// Signature: parseDatastoreEndpoint(unitText string) string.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func parseDatastoreEndpoint(unitText string) string {
if match := datastoreEndpointPattern.FindStringSubmatch(unitText); len(match) == 4 {
for _, candidate := range match[1:] {
value := strings.TrimSpace(candidate)
if value != "" {
return value
}
}
}
for _, raw := range strings.Split(unitText, "\n") {
line := strings.TrimSpace(raw)
idx := strings.Index(line, "--datastore-endpoint")
if idx < 0 {
continue
}
value := strings.TrimSpace(line[idx+len("--datastore-endpoint"):])
value = strings.TrimSpace(strings.TrimPrefix(value, "="))
value = strings.TrimSuffix(strings.TrimSpace(value), "\\")
value = strings.Trim(value, `"'`)
if value != "" {
return value
}
}
return ""
}
// nodeNameForHost runs one orchestration or CLI step.
// Signature: (o *Orchestrator) nodeNameForHost(host string) string.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) nodeNameForHost(host string) string {
host = strings.TrimSpace(host)
if host == "" {
return ""
}
if _, ok := o.cfg.SSHNodeHosts[host]; ok {
return host
}
for node, mapped := range o.cfg.SSHNodeHosts {
if strings.TrimSpace(mapped) == host {
return strings.TrimSpace(node)
}
}
return ""
}
// inventoryNodesForValidation runs one orchestration or CLI step.
// Signature: (o *Orchestrator) inventoryNodesForValidation() []string.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) inventoryNodesForValidation() []string {
set := map[string]struct{}{}
add := func(node string) {
node = strings.TrimSpace(node)
if node == "" {
return
}
set[node] = struct{}{}
}
for _, n := range o.cfg.ControlPlanes {
add(n)
}
for _, n := range o.cfg.Workers {
add(n)
}
for _, n := range o.cfg.SSHManagedNodes {
add(n)
}
for _, n := range o.cfg.Coordination.PeerHosts {
add(n)
}
add(o.cfg.Coordination.ForwardShutdownHost)
nodes := make([]string, 0, len(set))
for n := range set {
nodes = append(nodes, n)
}
sort.Strings(nodes)
return nodes
}
// validateNodeInventory runs one orchestration or CLI step.
// Signature: (o *Orchestrator) validateNodeInventory() error.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) validateNodeInventory() error {
issues := []string{}
if o.cfg.SSHPort <= 0 || o.cfg.SSHPort > 65535 {
issues = append(issues, fmt.Sprintf("ssh_port=%d is invalid", o.cfg.SSHPort))
}
managed := makeStringSet(o.cfg.SSHManagedNodes)
for _, cp := range o.cfg.ControlPlanes {
cp = strings.TrimSpace(cp)
if cp == "" {
continue
}
if _, ok := managed[cp]; !ok {
issues = append(issues, fmt.Sprintf("control plane %s is missing from ssh_managed_nodes", cp))
}
}
for _, node := range o.cfg.Workers {
node = strings.TrimSpace(node)
if node == "" {
continue
}
if _, ok := managed[node]; !ok {
issues = append(issues, fmt.Sprintf("worker %s is missing from ssh_managed_nodes", node))
}
}
baseUser := strings.TrimSpace(o.cfg.SSHUser)
for _, node := range o.inventoryNodesForValidation() {
if _, ok := o.cfg.SSHNodeHosts[node]; !ok {
issues = append(issues, fmt.Sprintf("%s is missing ssh_node_hosts entry", node))
}
host := strings.TrimSpace(o.cfg.SSHNodeHosts[node])
if host == "" {
host = node
}
if strings.ContainsAny(host, " \t\r\n") {
issues = append(issues, fmt.Sprintf("%s has invalid ssh host %q (contains whitespace)", node, host))
}
if strings.Contains(host, "/") {
issues = append(issues, fmt.Sprintf("%s has invalid ssh host %q (contains slash)", node, host))
}
user := baseUser
if override, ok := o.cfg.SSHNodeUsers[node]; ok {
user = strings.TrimSpace(override)
}
if user == "" {
issues = append(issues, fmt.Sprintf("%s has empty ssh user (ssh_user/ssh_node_users)", node))
}
if strings.ContainsAny(user, " \t\r\n@") {
issues = append(issues, fmt.Sprintf("%s has invalid ssh user %q", node, user))
}
}
if len(issues) > 0 {
sort.Strings(issues)
return fmt.Errorf("node inventory preflight failed: %s", joinLimited(issues, 10))
}
return nil
}
// tcpReachable runs one orchestration or CLI step.
// Signature: (o *Orchestrator) tcpReachable(address string, timeout time.Duration) bool.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) tcpReachable(address string, timeout time.Duration) bool {
conn, err := net.DialTimeout("tcp", address, timeout)
if err != nil {
return false
}
_ = conn.Close()
return true
}

View File

@ -0,0 +1,483 @@
package cluster
import (
"context"
"errors"
"io"
"log"
"net"
"path/filepath"
"strings"
"testing"
"time"
"scm.bstein.dev/bstein/ananke/internal/config"
"scm.bstein.dev/bstein/ananke/internal/execx"
"scm.bstein.dev/bstein/ananke/internal/state"
)
type commandStub struct {
match func(name string, args []string) bool
out string
err error
}
// buildOrchestratorWithStubs runs one orchestration or CLI step.
// Signature: buildOrchestratorWithStubs(t *testing.T, cfg config.Config, stubs []commandStub) *Orchestrator.
// Why: helper centralizes deterministic command dispatch for fast, isolated unit tests.
func buildOrchestratorWithStubs(t *testing.T, cfg config.Config, stubs []commandStub) *Orchestrator {
t.Helper()
if cfg.State.Dir == "" {
cfg.State.Dir = t.TempDir()
}
if cfg.State.ReportsDir == "" {
cfg.State.ReportsDir = filepath.Join(cfg.State.Dir, "reports")
}
if cfg.State.RunHistoryPath == "" {
cfg.State.RunHistoryPath = filepath.Join(cfg.State.Dir, "runs.json")
}
orch := &Orchestrator{
cfg: cfg,
runner: &execx.Runner{},
store: state.New(cfg.State.RunHistoryPath),
log: log.New(io.Discard, "", 0),
}
dispatch := func(_ context.Context, _ time.Duration, name string, args ...string) (string, error) {
for _, stub := range stubs {
if stub.match(name, args) {
return stub.out, stub.err
}
}
return "", nil
}
orch.runOverride = dispatch
orch.runSensitiveOverride = dispatch
return orch
}
// matchContains runs one orchestration or CLI step.
// Signature: matchContains(cmd string, parts ...string) func(string, []string) bool.
// Why: concise substring matching keeps command stubs readable across many tests.
func matchContains(cmd string, parts ...string) func(string, []string) bool {
return func(name string, args []string) bool {
if name != cmd {
return false
}
joined := strings.Join(args, " ")
for _, part := range parts {
if !strings.Contains(joined, part) {
return false
}
}
return true
}
}
// TestNewConstructsOrchestrator runs one orchestration or CLI step.
// Signature: TestNewConstructsOrchestrator(t *testing.T).
// Why: covers constructor path in orchestrator core module.
func TestNewConstructsOrchestrator(t *testing.T) {
cfg := config.Config{State: config.State{RunHistoryPath: filepath.Join(t.TempDir(), "runs.json")}}
r := &execx.Runner{}
s := state.New(cfg.State.RunHistoryPath)
orch := New(cfg, r, s, log.New(io.Discard, "", 0))
if orch == nil || orch.runner != r || orch.store != s {
t.Fatalf("constructor returned unexpected orchestrator: %#v", orch)
}
}
// TestParseSnapshotPathFromEtcdSnapshotList runs one orchestration or CLI step.
// Signature: TestParseSnapshotPathFromEtcdSnapshotList(t *testing.T).
// Why: covers snapshot-path parser branches including header skip and no-match.
func TestParseSnapshotPathFromEtcdSnapshotList(t *testing.T) {
out := strings.Join([]string{
"Name Size Created Location",
`pre-shutdown 4.2M now "file:///var/lib/rancher/k3s/server/db/snapshots/pre-shutdown"`,
}, "\n")
got := parseSnapshotPathFromEtcdSnapshotList(out)
if got != "/var/lib/rancher/k3s/server/db/snapshots/pre-shutdown" {
t.Fatalf("unexpected snapshot path: %q", got)
}
if parseSnapshotPathFromEtcdSnapshotList("no snapshots") != "" {
t.Fatalf("expected no snapshot path")
}
}
// TestFluxSourceHelpers runs one orchestration or CLI step.
// Signature: TestFluxSourceHelpers(t *testing.T).
// Why: covers flux source readiness/guard/branch patch helper flows.
func TestFluxSourceHelpers(t *testing.T) {
cfg := config.Config{
ExpectedFluxSource: "ssh://git@scm.bstein.dev:2242/bstein/titan-iac.git",
}
orch := buildOrchestratorWithStubs(t, cfg, []commandStub{
{match: matchContains("kubectl", "jsonpath={.status.conditions"), out: "True"},
{match: matchContains("kubectl", "jsonpath={.spec.url}"), out: "ssh://git@scm.bstein.dev:2242/bstein/titan-iac.git"},
{match: matchContains("kubectl", "jsonpath={.spec.ref.branch}"), out: "main"},
{match: matchContains("kubectl", "patch", "gitrepository"), out: ""},
})
ready, err := orch.fluxSourceReady(context.Background())
if err != nil || !ready {
t.Fatalf("expected flux source ready, got ready=%v err=%v", ready, err)
}
if err := orch.guardFluxSourceDrift(context.Background(), "main", false); err != nil {
t.Fatalf("guardFluxSourceDrift failed: %v", err)
}
if err := orch.ensureFluxBranch(context.Background(), "main", false); err != nil {
t.Fatalf("ensureFluxBranch no-op failed: %v", err)
}
if got := normalizeGitURL(" SSH://Git@Host/Repo.git/ "); got != "ssh://git@host/repo" {
t.Fatalf("unexpected normalized url: %q", got)
}
}
// TestCoordinationHelpers runs one orchestration or CLI step.
// Signature: TestCoordinationHelpers(t *testing.T).
// Why: covers intent-age helpers, shell quoting, and peer selection logic.
func TestCoordinationHelpers(t *testing.T) {
in := state.Intent{UpdatedAt: time.Now().Add(-10 * time.Second)}
if intentAge(in) <= 0 {
t.Fatalf("expected positive age")
}
if !intentFresh(state.Intent{}, time.Second) {
t.Fatalf("zero timestamp should be fresh")
}
if shellQuote("a'b") != `'a'"'"'b'` {
t.Fatalf("unexpected shell quote output")
}
orch := buildOrchestratorWithStubs(t, config.Config{
Coordination: config.Coordination{
PeerHosts: []string{"titan-24", "titan-24", "titan-db"},
ForwardShutdownHost: "titan-db",
},
}, nil)
peers := orch.coordinationPeers()
if len(peers) != 2 {
t.Fatalf("expected deduped peers, got %v", peers)
}
}
// TestVerifyEtcdSnapshotAndRunSudoK3S runs one orchestration or CLI step.
// Signature: TestVerifyEtcdSnapshotAndRunSudoK3S(t *testing.T).
// Why: covers k3s command fallback and snapshot verification happy path.
func TestVerifyEtcdSnapshotAndRunSudoK3S(t *testing.T) {
orch := buildOrchestratorWithStubs(t, config.Config{}, []commandStub{
{match: matchContains("ssh", "stat -c %s"), out: "2097152"},
{match: matchContains("ssh", "k3s etcd-snapshot ls"), out: "/var/lib/rancher/k3s/server/db/snapshots/pre-shutdown"},
{match: matchContains("ssh", "sha256sum"), out: strings.Repeat("a", 64)},
})
if err := orch.verifyEtcdSnapshot(context.Background(), "titan-0a", "/var/lib/rancher/k3s/server/db/snapshots/pre-shutdown"); err != nil {
t.Fatalf("verifyEtcdSnapshot failed: %v", err)
}
}
// TestScalingHelpers runs one orchestration or CLI step.
// Signature: TestScalingHelpers(t *testing.T).
// Why: covers workload discovery, snapshot IO, and scale command orchestration.
func TestScalingHelpers(t *testing.T) {
cfg := config.Config{
ExcludedNamespaces: []string{"kube-system"},
State: config.State{Dir: t.TempDir()},
}
orch := buildOrchestratorWithStubs(t, cfg, []commandStub{
{
match: matchContains("kubectl", "get deployment", "jsonpath"),
out: strings.Join([]string{
"default\tgrafana\t1",
"kube-system\tcoredns\t2",
"",
}, "\n"),
},
{
match: matchContains("kubectl", "get statefulset", "jsonpath"),
out: "vault\tvault\t1\n",
},
{match: matchContains("kubectl", "scale", "deployment", "grafana"), out: ""},
{match: matchContains("kubectl", "scale", "statefulset", "vault"), out: ""},
})
entries, err := orch.listScalableWorkloads(context.Background())
if err != nil {
t.Fatalf("listScalableWorkloads failed: %v", err)
}
if len(entries) != 2 {
t.Fatalf("expected 2 scalable entries, got %d (%v)", len(entries), entries)
}
if err := orch.writeScaledWorkloadSnapshot(entries); err != nil {
t.Fatalf("writeScaledWorkloadSnapshot failed: %v", err)
}
snapshot, err := orch.readScaledWorkloadSnapshot()
if err != nil || snapshot == nil || len(snapshot.Entries) != 2 {
t.Fatalf("readScaledWorkloadSnapshot failed snapshot=%v err=%v", snapshot, err)
}
if err := orch.scaleWorkloads(context.Background(), entries, -1, 2); err != nil {
t.Fatalf("scaleWorkloads failed: %v", err)
}
}
// TestStorageReadyAndWorkloadHelpers runs one orchestration or CLI step.
// Signature: TestStorageReadyAndWorkloadHelpers(t *testing.T).
// Why: covers storage readiness checks and workload helper utilities.
func TestStorageReadyAndWorkloadHelpers(t *testing.T) {
cfg := config.Config{
Startup: config.Startup{
StorageMinReadyNodes: 1,
StorageCriticalPVCs: []string{"vault/data-vault-0"},
},
}
orch := buildOrchestratorWithStubs(t, cfg, []commandStub{
{match: matchContains("kubectl", "nodes.longhorn.io"), out: "titan-23:True:True\n"},
{match: matchContains("kubectl", "get pvc data-vault-0"), out: "Bound"},
})
ok, reason, err := orch.storageReady(context.Background())
if err != nil || !ok {
t.Fatalf("expected storageReady true, got ok=%v reason=%q err=%v", ok, reason, err)
}
}
// TestIngressAndServiceHelpers runs one orchestration or CLI step.
// Signature: TestIngressAndServiceHelpers(t *testing.T).
// Why: covers ingress host discovery helpers and URL parsing helpers.
func TestIngressAndServiceHelpers(t *testing.T) {
cfg := config.Config{
Startup: config.Startup{
IngressChecklistIgnoreHosts: []string{"ignore.bstein.dev"},
},
}
orch := buildOrchestratorWithStubs(t, cfg, []commandStub{
{match: matchContains("kubectl", "get ingress", "-A", "-o", "json"), out: `{"items":[{"metadata":{"namespace":"gitea"},"spec":{"rules":[{"host":"scm.bstein.dev"}]}},{"metadata":{"namespace":"x"},"spec":{"rules":[{"host":"ignore.bstein.dev"}]}}]}`},
})
hosts, err := orch.discoverIngressHosts(context.Background())
if err != nil || len(hosts) != 1 || hosts[0] != "scm.bstein.dev" {
t.Fatalf("discoverIngressHosts unexpected hosts=%v err=%v", hosts, err)
}
if got := hostFromURL("https://metrics.bstein.dev/api/health"); got != "metrics.bstein.dev" {
t.Fatalf("unexpected hostFromURL value: %q", got)
}
if !isLikelyHostname("metrics.bstein.dev") || isLikelyHostname("bad path/value") {
t.Fatalf("isLikelyHostname classification mismatch")
}
}
// TestWorkloadConvergenceHelpers runs one orchestration or CLI step.
// Signature: TestWorkloadConvergenceHelpers(t *testing.T).
// Why: covers controller readiness helpers and stuck-pod heuristics.
func TestWorkloadConvergenceHelpers(t *testing.T) {
replicas := int32(2)
item := workloadResource{Kind: "deployment"}
item.Spec.Replicas = &replicas
item.Status.ReadyReplicas = 1
desired, ready, ok := desiredReady(item)
if !ok || desired != 2 || ready != 1 {
t.Fatalf("desiredReady mismatch desired=%d ready=%d ok=%v", desired, ready, ok)
}
var pod podResource
pod.Metadata.OwnerReferences = []ownerReference{{Kind: "ReplicaSet"}}
if !podControllerOwned(pod) {
t.Fatalf("expected podControllerOwned=true")
}
pod.Status.ContainerStatuses = []podContainerStatus{{State: podContainerState{Waiting: &podContainerWaitingState{Reason: "CrashLoopBackOff"}}}}
reason := stuckContainerReason(pod, map[string]struct{}{"CrashLoopBackOff": struct{}{}})
if reason != "CrashLoopBackOff" {
t.Fatalf("unexpected stuck reason: %q", reason)
}
}
// TestDrainAndK3SHelpers runs one orchestration or CLI step.
// Signature: TestDrainAndK3SHelpers(t *testing.T).
// Why: covers node drain diagnostics and k3s snapshot selection flow.
func TestDrainAndK3SHelpers(t *testing.T) {
cfg := config.Config{
SSHManagedNodes: []string{"titan-0a"},
}
orch := buildOrchestratorWithStubs(t, cfg, []commandStub{
{match: matchContains("kubectl", "get pods", "--field-selector", "spec.nodeName=titan-22"), out: "vault vault-0 Running StatefulSet\n"},
{match: matchContains("ssh", "k3s etcd-snapshot ls"), out: "pre-shutdown /var/lib/rancher/k3s/server/db/snapshots/pre-shutdown"},
})
diag := orch.drainNodeDiagnostics(context.Background(), "titan-22")
if !strings.Contains(diag, "vault/vault-0") {
t.Fatalf("unexpected diagnostics output: %q", diag)
}
snapshot, err := orch.latestEtcdSnapshotPath(context.Background(), "titan-0a")
if err != nil || snapshot == "" {
t.Fatalf("latestEtcdSnapshotPath failed snapshot=%q err=%v", snapshot, err)
}
}
// TestTimesyncAndInventoryHelpers runs one orchestration or CLI step.
// Signature: TestTimesyncAndInventoryHelpers(t *testing.T).
// Why: covers time sync helpers, datastore endpoint parsing, and inventory assembly.
func TestTimesyncAndInventoryHelpers(t *testing.T) {
cfg := config.Config{
ControlPlanes: []string{"titan-0a"},
Workers: []string{"titan-22"},
SSHManagedNodes: []string{"titan-0a", "titan-22"},
SSHNodeHosts: map[string]string{
"titan-db": "10.0.0.10",
},
Coordination: config.Coordination{
PeerHosts: []string{"titan-24"},
ForwardShutdownHost: "titan-db",
},
}
orch := buildOrchestratorWithStubs(t, cfg, nil)
nodes := orch.inventoryNodesForValidation()
if len(nodes) < 3 {
t.Fatalf("expected combined inventory nodes, got %v", nodes)
}
if parseDatastoreEndpoint("ExecStart=/usr/local/bin/k3s server --datastore-endpoint=postgres://x") == "" {
t.Fatalf("expected datastore endpoint parse")
}
if !isTimeSynced("YES") || isTimeSynced("no") {
t.Fatalf("unexpected isTimeSynced behavior")
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen failed: %v", err)
}
defer ln.Close()
if !orch.tcpReachable(ln.Addr().String(), 500*time.Millisecond) {
t.Fatalf("expected tcpReachable=true for open listener")
}
}
// TestShutdownModeValidation runs one orchestration or CLI step.
// Signature: TestShutdownModeValidation(t *testing.T).
// Why: covers removed poweroff mode and invalid-mode errors.
func TestShutdownModeValidation(t *testing.T) {
if mode, err := normalizeShutdownMode("cluster-only"); err != nil || mode != "cluster-only" {
t.Fatalf("expected cluster-only mode, got mode=%q err=%v", mode, err)
}
if _, err := normalizeShutdownMode("bogus"); err == nil {
t.Fatalf("expected invalid mode error")
}
}
// TestWaitForAPIDryRunShortCircuit runs one orchestration or CLI step.
// Signature: TestWaitForAPIDryRunShortCircuit(t *testing.T).
// Why: covers dry-run short-circuit branch for api readiness wait.
func TestWaitForAPIDryRunShortCircuit(t *testing.T) {
orch := &Orchestrator{runner: &execx.Runner{DryRun: true}}
if err := orch.waitForAPI(context.Background(), 1, time.Millisecond); err != nil {
t.Fatalf("expected dry-run waitForAPI to pass: %v", err)
}
}
// TestGuardFluxSourceDriftMismatch runs one orchestration or CLI step.
// Signature: TestGuardFluxSourceDriftMismatch(t *testing.T).
// Why: covers url-drift and branch-drift error branches.
func TestGuardFluxSourceDriftMismatch(t *testing.T) {
cfg := config.Config{ExpectedFluxSource: "ssh://git@scm.bstein.dev:2242/bstein/titan-iac.git"}
orch := buildOrchestratorWithStubs(t, cfg, []commandStub{
{match: matchContains("kubectl", "jsonpath={.spec.url}"), out: "ssh://git@scm.bstein.dev:2242/bstein/wrong.git"},
})
if err := orch.guardFluxSourceDrift(context.Background(), "main", false); err == nil {
t.Fatalf("expected guardFluxSourceDrift mismatch error")
}
orch = buildOrchestratorWithStubs(t, cfg, []commandStub{
{match: matchContains("kubectl", "jsonpath={.spec.url}"), out: "ssh://git@scm.bstein.dev:2242/bstein/titan-iac.git"},
{match: matchContains("kubectl", "jsonpath={.spec.ref.branch}"), out: "atlasbot"},
})
if err := orch.guardFluxSourceDrift(context.Background(), "main", false); err == nil {
t.Fatalf("expected branch drift error")
}
}
// TestRunSudoK3SFailsWhenAllCandidatesFail runs one orchestration or CLI step.
// Signature: TestRunSudoK3SFailsWhenAllCandidatesFail(t *testing.T).
// Why: covers fallback failure return in runSudoK3S.
func TestRunSudoK3SFailsWhenAllCandidatesFail(t *testing.T) {
orch := buildOrchestratorWithStubs(t, config.Config{}, []commandStub{
{match: matchContains("ssh", "k3s"), err: errors.New("no binary")},
})
if _, err := orch.runSudoK3S(context.Background(), "titan-0a", "server"); err == nil {
t.Fatalf("expected runSudoK3S failure when all candidates fail")
}
}
// TestCriticalEndpointHelpers runs one orchestration or CLI step.
// Signature: TestCriticalEndpointHelpers(t *testing.T).
// Why: covers critical endpoint parsing and readiness checks that gate startup completion.
func TestCriticalEndpointHelpers(t *testing.T) {
cfg := config.Config{
Startup: config.Startup{
CriticalServiceEndpoints: []string{"monitoring/victoria-metrics-single-server"},
},
}
orch := buildOrchestratorWithStubs(t, cfg, []commandStub{
{match: matchContains("kubectl", "get endpoints victoria-metrics-single-server"), out: "10.42.0.10\n10.42.0.11\n"},
})
ok, detail, ns, svc, err := orch.criticalServiceEndpointsReady(context.Background())
if err != nil || !ok {
t.Fatalf("expected criticalServiceEndpointsReady success, got ok=%v detail=%q ns=%q svc=%q err=%v", ok, detail, ns, svc, err)
}
if detail != "services=1" {
t.Fatalf("unexpected readiness detail: %q", detail)
}
gotNS, gotSvc, err := parseCriticalServiceEndpoint("monitoring/victoria-metrics-single-server")
if err != nil || gotNS != "monitoring" || gotSvc != "victoria-metrics-single-server" {
t.Fatalf("unexpected parse result ns=%q svc=%q err=%v", gotNS, gotSvc, err)
}
if _, _, err := parseCriticalServiceEndpoint("invalid"); err == nil {
t.Fatalf("expected parseCriticalServiceEndpoint error")
}
}
// TestCriticalEndpointAutoHealWorkflow runs one orchestration or CLI step.
// Signature: TestCriticalEndpointAutoHealWorkflow(t *testing.T).
// Why: covers endpoint-zero recovery where startup heals workload replicas before succeeding.
func TestCriticalEndpointAutoHealWorkflow(t *testing.T) {
cfg := config.Config{
Startup: config.Startup{
CriticalServiceEndpointWaitSec: 2,
CriticalServiceEndpointPollSec: 1,
CriticalServiceEndpoints: []string{"monitoring/victoria-metrics-single-server"},
},
State: config.State{
Dir: t.TempDir(),
ReportsDir: filepath.Join(t.TempDir(), "reports"),
RunHistoryPath: filepath.Join(t.TempDir(), "runs.json"),
},
}
orch := &Orchestrator{
cfg: cfg,
runner: &execx.Runner{},
store: state.New(cfg.State.RunHistoryPath),
log: log.New(io.Discard, "", 0),
}
endpointChecks := 0
dispatch := func(_ context.Context, _ time.Duration, name string, args ...string) (string, error) {
joined := name + " " + strings.Join(args, " ")
if strings.Contains(joined, "get endpoints victoria-metrics-single-server") {
endpointChecks++
if endpointChecks == 1 {
return "", nil
}
return "10.42.0.10\n", nil
}
if strings.Contains(joined, "scale deployment victoria-metrics-single-server") {
return "", errors.New(`Error from server (NotFound): deployments.apps "victoria-metrics-single-server" not found`)
}
if strings.Contains(joined, "scale statefulset victoria-metrics-single-server") {
return "", nil
}
if strings.Contains(joined, "rollout status statefulset/victoria-metrics-single-server") {
return "statefulset rolled out", nil
}
return "", nil
}
orch.runOverride = dispatch
orch.runSensitiveOverride = dispatch
if err := orch.waitForCriticalServiceEndpoints(context.Background()); err != nil {
t.Fatalf("waitForCriticalServiceEndpoints failed: %v", err)
}
if endpointChecks < 2 {
t.Fatalf("expected repeated endpoint checks, got %d", endpointChecks)
}
}

View File

@ -0,0 +1,272 @@
package cluster
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"time"
)
// waitForWorkloadConvergence runs one orchestration or CLI step.
// Signature: (o *Orchestrator) waitForWorkloadConvergence(ctx context.Context) error.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) waitForWorkloadConvergence(ctx context.Context) error {
wait := time.Duration(o.cfg.Startup.WorkloadConvergenceWaitSeconds) * time.Second
if wait <= 0 {
wait = 15 * time.Minute
}
poll := time.Duration(o.cfg.Startup.WorkloadConvergencePollSeconds) * time.Second
if poll <= 0 {
poll = 5 * time.Second
}
deadline := time.Now().Add(wait)
lastFailure := "unknown"
lastLogged := time.Time{}
lastRecycleAttempt := time.Time{}
lastReplicaHeal := time.Time{}
for {
prevFailure := lastFailure
o.maybeAutoRecycleStuckPods(ctx, &lastRecycleAttempt)
o.maybeAutoHealCriticalWorkloadReplicas(ctx, &lastReplicaHeal)
ready, detail, err := o.workloadConvergenceReady(ctx)
if err != nil {
lastFailure = err.Error()
} else {
lastFailure = detail
}
if ready {
o.log.Printf("workload convergence check passed (%s)", detail)
return nil
}
if lastFailure != prevFailure || time.Since(lastLogged) >= 30*time.Second {
remaining := time.Until(deadline).Round(time.Second)
if remaining < 0 {
remaining = 0
}
o.log.Printf("waiting for workload convergence (%s remaining): %s", remaining, lastFailure)
lastLogged = time.Now()
}
if time.Now().After(deadline) {
return fmt.Errorf("startup blocked: workload convergence not satisfied within %s (%s)", wait, lastFailure)
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(poll):
}
}
}
// workloadConvergenceReady runs one orchestration or CLI step.
// Signature: (o *Orchestrator) workloadConvergenceReady(ctx context.Context) (bool, string, error).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) workloadConvergenceReady(ctx context.Context) (bool, string, error) {
out, err := o.kubectl(ctx, 30*time.Second, "get", "deploy,statefulset,daemonset", "-A", "-o", "json")
if err != nil {
return false, "", fmt.Errorf("query controllers: %w", err)
}
var list workloadList
if err := json.Unmarshal([]byte(out), &list); err != nil {
return false, "", fmt.Errorf("decode controllers: %w", err)
}
ignoredNamespaces := makeStringSet(o.cfg.Startup.IgnoreWorkloadNamespaces)
ignoredNodes := makeStringSet(o.cfg.Startup.IgnoreUnavailableNodes)
ignoreRules := parseWorkloadIgnoreRules(o.cfg.Startup.IgnoreWorkloads)
ignoredByFlux := namespaceCandidatesFromIgnoreKustomizations(o.cfg.Startup.IgnoreFluxKustomizations)
pending := []string{}
checked := 0
for _, item := range list.Items {
kind := strings.ToLower(strings.TrimSpace(item.Kind))
ns := strings.TrimSpace(item.Metadata.Namespace)
name := strings.TrimSpace(item.Metadata.Name)
if kind == "" || ns == "" || name == "" {
continue
}
if _, ok := ignoredNamespaces[ns]; ok {
continue
}
if _, ok := ignoredByFlux[ns]; ok {
continue
}
if workloadIgnored(ignoreRules, ns, kind, name) {
continue
}
if workloadTargetsIgnoredNodes(item.Spec.Template.Spec, ignoredNodes) {
continue
}
desired, ready, ok := desiredReady(item)
if !ok || desired <= 0 {
continue
}
if kind == "daemonset" && desired > ready && len(ignoredNodes) > 0 {
missing := desired - ready
if missing <= int32(len(ignoredNodes)) {
ready = desired
}
}
checked++
if ready < desired {
pending = append(pending, fmt.Sprintf("%s/%s/%s ready=%d desired=%d", ns, kind, name, ready, desired))
}
}
if len(pending) > 0 {
sort.Strings(pending)
return false, "not ready: " + joinLimited(pending, 8), nil
}
return true, fmt.Sprintf("controllers ready=%d", checked), nil
}
// desiredReady runs one orchestration or CLI step.
// Signature: desiredReady(item workloadResource) (int32, int32, bool).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func desiredReady(item workloadResource) (int32, int32, bool) {
switch strings.ToLower(strings.TrimSpace(item.Kind)) {
case "deployment", "statefulset":
desired := int32(1)
if item.Spec.Replicas != nil {
desired = *item.Spec.Replicas
}
return desired, item.Status.ReadyReplicas, true
case "daemonset":
return item.Status.DesiredNumberScheduled, item.Status.NumberReady, true
default:
return 0, 0, false
}
}
// recycleStuckControllerPods runs one orchestration or CLI step.
// Signature: (o *Orchestrator) recycleStuckControllerPods(ctx context.Context) error.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) recycleStuckControllerPods(ctx context.Context) error {
out, err := o.kubectl(ctx, 30*time.Second, "get", "pods", "-A", "-o", "json")
if err != nil {
return fmt.Errorf("query pods: %w", err)
}
var list podList
if err := json.Unmarshal([]byte(out), &list); err != nil {
return fmt.Errorf("decode pods: %w", err)
}
ignoredNamespaces := makeStringSet(o.cfg.Startup.IgnoreWorkloadNamespaces)
ignoredNodes := makeStringSet(o.cfg.Startup.IgnoreUnavailableNodes)
ignoreRules := parseWorkloadIgnoreRules(o.cfg.Startup.IgnoreWorkloads)
grace := time.Duration(o.cfg.Startup.StuckPodGraceSeconds) * time.Second
if grace <= 0 {
grace = 180 * time.Second
}
stuckReasons := map[string]struct{}{
"ImagePullBackOff": {},
"ErrImagePull": {},
"CrashLoopBackOff": {},
"CreateContainerConfigError": {},
"CreateContainerError": {},
}
recycled := []string{}
for _, pod := range list.Items {
ns := strings.TrimSpace(pod.Metadata.Namespace)
name := strings.TrimSpace(pod.Metadata.Name)
if ns == "" || name == "" {
continue
}
if _, ok := ignoredNamespaces[ns]; ok {
continue
}
if workloadIgnored(ignoreRules, ns, "", name) {
continue
}
if podTargetsIgnoredNode(pod, ignoredNodes) {
continue
}
if !podControllerOwned(pod) {
continue
}
age := time.Since(pod.Metadata.CreationTimestamp)
if !pod.Metadata.CreationTimestamp.IsZero() && age < grace {
continue
}
reason := stuckContainerReason(pod, stuckReasons)
if reason == "" {
reason = stuckVaultInitReason(pod, grace)
}
if reason == "" {
continue
}
o.log.Printf("warning: recycling stuck pod %s/%s reason=%s age=%s", ns, name, reason, age.Round(time.Second))
if _, err := o.kubectl(ctx, 30*time.Second, "-n", ns, "delete", "pod", name, "--wait=false"); err != nil && !isNotFoundErr(err) {
o.log.Printf("warning: recycle pod failed for %s/%s: %v", ns, name, err)
continue
}
recycled = append(recycled, ns+"/"+name)
}
if len(recycled) > 0 {
sort.Strings(recycled)
o.log.Printf("recycled stuck controller pods (%d): %s", len(recycled), joinLimited(recycled, 10))
o.noteStartupAutoHeal(fmt.Sprintf("recycled stuck controller pods: %s", joinLimited(recycled, 10)))
}
return nil
}
// podControllerOwned runs one orchestration or CLI step.
// Signature: podControllerOwned(p podResource) bool.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func podControllerOwned(p podResource) bool {
for _, owner := range p.Metadata.OwnerReferences {
switch strings.TrimSpace(owner.Kind) {
case "ReplicaSet", "StatefulSet", "DaemonSet":
return true
}
}
return false
}
// stuckContainerReason runs one orchestration or CLI step.
// Signature: stuckContainerReason(p podResource, reasons map[string]struct{}) string.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func stuckContainerReason(p podResource, reasons map[string]struct{}) string {
check := func(statuses []podContainerStatus) string {
for _, st := range statuses {
if st.State.Waiting == nil {
continue
}
reason := strings.TrimSpace(st.State.Waiting.Reason)
if reason == "" {
continue
}
if _, ok := reasons[reason]; ok {
return reason
}
}
return ""
}
if reason := check(p.Status.InitContainerStatuses); reason != "" {
return reason
}
return check(p.Status.ContainerStatuses)
}
// stuckVaultInitReason runs one orchestration or CLI step.
// Signature: stuckVaultInitReason(p podResource, grace time.Duration) string.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func stuckVaultInitReason(p podResource, grace time.Duration) string {
if !strings.EqualFold(strings.TrimSpace(p.Status.Phase), "Pending") {
return ""
}
if !strings.EqualFold(strings.TrimSpace(p.Metadata.Annotations["vault.hashicorp.com/agent-inject"]), "true") {
return ""
}
for _, st := range p.Status.InitContainerStatuses {
if strings.TrimSpace(st.Name) != "vault-agent-init" || st.State.Running == nil {
continue
}
startedAt := st.State.Running.StartedAt
if startedAt.IsZero() {
continue
}
if time.Since(startedAt) < grace {
return ""
}
return "VaultInitStuck"
}
return ""
}

View File

@ -0,0 +1,321 @@
package cluster
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"time"
)
// maybeAutoRecycleStuckPods runs one orchestration or CLI step.
// Signature: (o *Orchestrator) maybeAutoRecycleStuckPods(ctx context.Context, lastAttempt *time.Time).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) maybeAutoRecycleStuckPods(ctx context.Context, lastAttempt *time.Time) {
if o.runner.DryRun || !o.cfg.Startup.AutoRecycleStuckPods {
return
}
now := time.Now()
if lastAttempt != nil && !lastAttempt.IsZero() && now.Sub(*lastAttempt) < 30*time.Second {
return
}
if lastAttempt != nil {
*lastAttempt = now
}
o.bestEffort("recycle stuck controller pods", func() error { return o.recycleStuckControllerPods(ctx) })
}
// maybeAutoHealCriticalWorkloadReplicas runs one orchestration or CLI step.
// Signature: (o *Orchestrator) maybeAutoHealCriticalWorkloadReplicas(ctx context.Context, lastAttempt *time.Time).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) maybeAutoHealCriticalWorkloadReplicas(ctx context.Context, lastAttempt *time.Time) {
if o.runner.DryRun {
return
}
now := time.Now()
if lastAttempt != nil && !lastAttempt.IsZero() && now.Sub(*lastAttempt) < 30*time.Second {
return
}
if lastAttempt != nil {
*lastAttempt = now
}
healed, err := o.healCriticalWorkloadReplicas(ctx)
if err != nil {
o.log.Printf("warning: critical workload replica auto-heal failed: %v", err)
return
}
if len(healed) == 0 {
return
}
sort.Strings(healed)
detail := fmt.Sprintf("restored critical workload replicas: %s", joinLimited(healed, 8))
o.log.Printf("%s", detail)
o.noteStartupAutoHeal(detail)
}
// healCriticalWorkloadReplicas runs one orchestration or CLI step.
// Signature: (o *Orchestrator) healCriticalWorkloadReplicas(ctx context.Context) ([]string, error).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) healCriticalWorkloadReplicas(ctx context.Context) ([]string, error) {
out, err := o.kubectl(ctx, 30*time.Second, "get", "deploy,statefulset", "-A", "-o", "json")
if err != nil {
return nil, fmt.Errorf("query workloads: %w", err)
}
var list workloadList
if err := json.Unmarshal([]byte(out), &list); err != nil {
return nil, fmt.Errorf("decode workloads: %w", err)
}
current := map[string]int32{}
for _, item := range list.Items {
kind := strings.ToLower(strings.TrimSpace(item.Kind))
ns := strings.TrimSpace(item.Metadata.Namespace)
name := strings.TrimSpace(item.Metadata.Name)
if kind == "" || ns == "" || name == "" {
continue
}
if kind != "deployment" && kind != "statefulset" {
continue
}
desired := int32(1)
if item.Spec.Replicas != nil {
desired = *item.Spec.Replicas
}
key := ns + "/" + kind + "/" + name
current[key] = desired
}
healed := []string{}
for _, w := range criticalStartupWorkloads {
key := w.Namespace + "/" + strings.ToLower(w.Kind) + "/" + w.Name
desired, ok := current[key]
if !ok || desired >= 1 {
continue
}
if err := o.ensureWorkloadReplicas(ctx, w, 1); err != nil {
if isNotFoundErr(err) {
continue
}
return healed, fmt.Errorf("scale %s to 1: %w", key, err)
}
healed = append(healed, key)
}
return healed, nil
}
// startupFailurePods runs one orchestration or CLI step.
// Signature: (o *Orchestrator) startupFailurePods(ctx context.Context) ([]string, error).
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func (o *Orchestrator) startupFailurePods(ctx context.Context) ([]string, error) {
out, err := o.kubectl(ctx, 30*time.Second, "get", "pods", "-A", "-o", "json")
if err != nil {
return nil, fmt.Errorf("query pods: %w", err)
}
var list podList
if err := json.Unmarshal([]byte(out), &list); err != nil {
return nil, fmt.Errorf("decode pods: %w", err)
}
ignoredNamespaces := makeStringSet(o.cfg.Startup.IgnoreWorkloadNamespaces)
ignoredNodes := makeStringSet(o.cfg.Startup.IgnoreUnavailableNodes)
stuckReasons := map[string]struct{}{
"ImagePullBackOff": {},
"ErrImagePull": {},
"CrashLoopBackOff": {},
"CreateContainerConfigError": {},
"CreateContainerError": {},
"RunContainerError": {},
}
grace := time.Duration(o.cfg.Startup.StuckPodGraceSeconds) * time.Second
if grace <= 0 {
grace = 180 * time.Second
}
failures := []string{}
for _, pod := range list.Items {
ns := strings.TrimSpace(pod.Metadata.Namespace)
name := strings.TrimSpace(pod.Metadata.Name)
if ns == "" || name == "" {
continue
}
if _, ok := ignoredNamespaces[ns]; ok {
continue
}
if podTargetsIgnoredNode(pod, ignoredNodes) {
continue
}
reason := stuckContainerReason(pod, stuckReasons)
if reason == "" {
reason = stuckVaultInitReason(pod, grace)
}
if reason == "" {
continue
}
failures = append(failures, fmt.Sprintf("%s/%s(%s)", ns, name, reason))
}
sort.Strings(failures)
return failures, nil
}
// podTargetsIgnoredNode runs one orchestration or CLI step.
// Signature: podTargetsIgnoredNode(p podResource, ignored map[string]struct{}) bool.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func podTargetsIgnoredNode(p podResource, ignored map[string]struct{}) bool {
if len(ignored) == 0 {
return false
}
node := strings.TrimSpace(p.Spec.NodeName)
if node != "" {
_, ok := ignored[node]
return ok
}
return workloadTargetsIgnoredNodes(p.Spec.podSpec, ignored)
}
// workloadTargetsIgnoredNodes runs one orchestration or CLI step.
// Signature: workloadTargetsIgnoredNodes(spec podSpec, ignored map[string]struct{}) bool.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func workloadTargetsIgnoredNodes(spec podSpec, ignored map[string]struct{}) bool {
if len(ignored) == 0 {
return false
}
if hostname, ok := spec.NodeSelector["kubernetes.io/hostname"]; ok {
_, ignoredHost := ignored[strings.TrimSpace(hostname)]
if ignoredHost {
return true
}
}
if spec.Affinity == nil || spec.Affinity.NodeAffinity == nil || spec.Affinity.NodeAffinity.RequiredDuringSchedulingIgnoredDuringExecution == nil {
return false
}
terms := spec.Affinity.NodeAffinity.RequiredDuringSchedulingIgnoredDuringExecution.NodeSelectorTerms
if len(terms) != 1 {
return false
}
for _, expr := range terms[0].MatchExpressions {
if strings.TrimSpace(expr.Key) != "kubernetes.io/hostname" {
continue
}
if !strings.EqualFold(strings.TrimSpace(expr.Operator), "In") {
return false
}
if len(expr.Values) == 0 {
return false
}
for _, value := range expr.Values {
if _, ok := ignored[strings.TrimSpace(value)]; !ok {
return false
}
}
return true
}
return false
}
// parseWorkloadIgnoreRules runs one orchestration or CLI step.
// Signature: parseWorkloadIgnoreRules(entries []string) []workloadIgnoreRule.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func parseWorkloadIgnoreRules(entries []string) []workloadIgnoreRule {
out := []workloadIgnoreRule{}
for _, entry := range entries {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
parts := strings.Split(entry, "/")
switch len(parts) {
case 2:
out = append(out, workloadIgnoreRule{
Namespace: strings.TrimSpace(parts[0]),
Name: strings.TrimSpace(parts[1]),
})
case 3:
out = append(out, workloadIgnoreRule{
Namespace: strings.TrimSpace(parts[0]),
Kind: strings.ToLower(strings.TrimSpace(parts[1])),
Name: strings.TrimSpace(parts[2]),
})
}
}
return out
}
// workloadIgnored runs one orchestration or CLI step.
// Signature: workloadIgnored(rules []workloadIgnoreRule, namespace, kind, name string) bool.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func workloadIgnored(rules []workloadIgnoreRule, namespace, kind, name string) bool {
ns := strings.TrimSpace(namespace)
k := strings.ToLower(strings.TrimSpace(kind))
n := strings.TrimSpace(name)
for _, rule := range rules {
if rule.Namespace != ns {
continue
}
if rule.Kind != "" && rule.Kind != k {
continue
}
if rule.Name == n {
return true
}
}
return false
}
// makeStringSet runs one orchestration or CLI step.
// Signature: makeStringSet(entries []string) map[string]struct{}.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func makeStringSet(entries []string) map[string]struct{} {
out := make(map[string]struct{}, len(entries))
for _, entry := range entries {
entry = strings.TrimSpace(entry)
if entry != "" {
out[entry] = struct{}{}
}
}
return out
}
// readyCondition runs one orchestration or CLI step.
// Signature: readyCondition(conditions []fluxCondition) *fluxCondition.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func readyCondition(conditions []fluxCondition) *fluxCondition {
for i := range conditions {
cond := &conditions[i]
if strings.EqualFold(strings.TrimSpace(cond.Type), "Ready") {
return cond
}
}
return nil
}
// joinLimited runs one orchestration or CLI step.
// Signature: joinLimited(items []string, limit int) string.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func joinLimited(items []string, limit int) string {
if len(items) <= limit || limit <= 0 {
return strings.Join(items, "; ")
}
return strings.Join(items[:limit], "; ") + fmt.Sprintf("; ... (+%d more)", len(items)-limit)
}
// namespaceCandidatesFromIgnoreKustomizations runs one orchestration or CLI step.
// Signature: namespaceCandidatesFromIgnoreKustomizations(entries []string) map[string]struct{}.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
func namespaceCandidatesFromIgnoreKustomizations(entries []string) map[string]struct{} {
out := map[string]struct{}{}
for _, entry := range entries {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
parts := strings.SplitN(entry, "/", 2)
if len(parts) != 2 {
continue
}
name := strings.TrimSpace(parts[1])
if name != "" {
out[name] = struct{}{}
}
}
return out
}

View File

@ -0,0 +1,336 @@
package cluster
import (
"context"
"time"
"scm.bstein.dev/bstein/ananke/internal/state"
)
// TestHookReconcileNodeAccess runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookReconcileNodeAccess(ctx context.Context, nodes []string) error.
// Why: exposes access reconciliation internals to the top-level testing module.
func (o *Orchestrator) TestHookReconcileNodeAccess(ctx context.Context, nodes []string) error {
return o.reconcileNodeAccess(ctx, nodes)
}
// TestHookWaitForNodeSSHAuth runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookWaitForNodeSSHAuth(ctx context.Context, nodes []string) error.
// Why: exposes node SSH auth gate internals to the top-level testing module.
func (o *Orchestrator) TestHookWaitForNodeSSHAuth(ctx context.Context, nodes []string) error {
return o.waitForNodeSSHAuth(ctx, nodes)
}
// TestHookReportFluxSource runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookReportFluxSource(ctx context.Context, forceBranch string).
// Why: exposes flux source logging/diagnostic internals to top-level tests.
func (o *Orchestrator) TestHookReportFluxSource(ctx context.Context, forceBranch string) {
o.reportFluxSource(ctx, forceBranch)
}
// TestHookGuardFluxSourceDrift runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookGuardFluxSourceDrift(ctx context.Context, expectedBranch string, allowBranchPatch bool) error.
// Why: exposes flux source drift guard internals to the top-level testing module.
func (o *Orchestrator) TestHookGuardFluxSourceDrift(ctx context.Context, expectedBranch string, allowBranchPatch bool) error {
return o.guardFluxSourceDrift(ctx, expectedBranch, allowBranchPatch)
}
// TestHookEnsureFluxBranch runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookEnsureFluxBranch(ctx context.Context, branch string, allowPatch bool) error.
// Why: exposes flux branch ensure logic to top-level tests.
func (o *Orchestrator) TestHookEnsureFluxBranch(ctx context.Context, branch string, allowPatch bool) error {
return o.ensureFluxBranch(ctx, branch, allowPatch)
}
// TestHookBootstrapLocal runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookBootstrapLocal(ctx context.Context) error.
// Why: exposes local bootstrap internals to top-level tests.
func (o *Orchestrator) TestHookBootstrapLocal(ctx context.Context) error {
return o.bootstrapLocal(ctx)
}
// TestHookApplyKustomizeFallback runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookApplyKustomizeFallback(ctx context.Context, full string) error.
// Why: exposes fallback render/apply path to top-level tests.
func (o *Orchestrator) TestHookApplyKustomizeFallback(ctx context.Context, full string) error {
return o.applyKustomizeFallback(ctx, full)
}
// TestHookSyncLocalIACRepo runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookSyncLocalIACRepo(ctx context.Context) error.
// Why: exposes local IAC sync internals to top-level tests.
func (o *Orchestrator) TestHookSyncLocalIACRepo(ctx context.Context) error {
return o.syncLocalIACRepo(ctx)
}
// TestHookRefreshBootstrapCache runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookRefreshBootstrapCache(ctx context.Context) error.
// Why: exposes bootstrap cache render internals to top-level tests.
func (o *Orchestrator) TestHookRefreshBootstrapCache(ctx context.Context) error {
return o.refreshBootstrapCache(ctx)
}
// TestHookApplyBootstrapCache runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookApplyBootstrapCache(ctx context.Context, rel string) error.
// Why: exposes cache apply internals to top-level tests.
func (o *Orchestrator) TestHookApplyBootstrapCache(ctx context.Context, rel string) error {
return o.applyBootstrapCache(ctx, rel)
}
// TestHookBootstrapCachePath runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookBootstrapCachePath(rel string) string.
// Why: exposes bootstrap cache naming to top-level tests.
func (o *Orchestrator) TestHookBootstrapCachePath(rel string) string {
return o.bootstrapCachePath(rel)
}
// TestHookWaitForFluxSourceReady runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookWaitForFluxSourceReady(ctx context.Context, window time.Duration) (bool, error).
// Why: exposes flux source readiness wait internals to top-level tests.
func (o *Orchestrator) TestHookWaitForFluxSourceReady(ctx context.Context, window time.Duration) (bool, error) {
return o.waitForFluxSourceReady(ctx, window)
}
// TestHookNormalizeGitURL runs one orchestration or CLI step.
// Signature: TestHookNormalizeGitURL(raw string) string.
// Why: exposes URL normalization helper to top-level tests.
func TestHookNormalizeGitURL(raw string) string {
return normalizeGitURL(raw)
}
// TestHookGuardPeerStartupIntents runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookGuardPeerStartupIntents(ctx context.Context) error.
// Why: exposes peer intent guard internals to top-level tests.
func (o *Orchestrator) TestHookGuardPeerStartupIntents(ctx context.Context) error {
return o.guardPeerStartupIntents(ctx)
}
// TestHookReadRemotePeerStatus runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookReadRemotePeerStatus(ctx context.Context, node string) (remotePeerStatus, error).
// Why: exposes peer status parser internals to top-level tests.
func (o *Orchestrator) TestHookReadRemotePeerStatus(ctx context.Context, node string) (remotePeerStatus, error) {
return o.readRemotePeerStatus(ctx, node)
}
// TestHookClearRemotePeerIntent runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookClearRemotePeerIntent(ctx context.Context, node string, reason string) error.
// Why: exposes remote intent clear internals to top-level tests.
func (o *Orchestrator) TestHookClearRemotePeerIntent(ctx context.Context, node string, reason string) error {
return o.clearRemotePeerIntent(ctx, node, reason)
}
// TestHookVerifyEtcdSnapshot runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookVerifyEtcdSnapshot(ctx context.Context, node string, snapshotPath string) error.
// Why: exposes etcd snapshot verification internals to top-level tests.
func (o *Orchestrator) TestHookVerifyEtcdSnapshot(ctx context.Context, node string, snapshotPath string) error {
return o.verifyEtcdSnapshot(ctx, node, snapshotPath)
}
// TestHookControlPlaneUsesExternalDatastore runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookControlPlaneUsesExternalDatastore(ctx context.Context, node string) (bool, error).
// Why: exposes datastore-mode detector internals to top-level tests.
func (o *Orchestrator) TestHookControlPlaneUsesExternalDatastore(ctx context.Context, node string) (bool, error) {
return o.controlPlaneUsesExternalDatastore(ctx, node)
}
// TestHookWaitForAPI runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookWaitForAPI(ctx context.Context, attempts int, sleep time.Duration) error.
// Why: exposes API wait internals to top-level tests.
func (o *Orchestrator) TestHookWaitForAPI(ctx context.Context, attempts int, sleep time.Duration) error {
return o.waitForAPI(ctx, attempts, sleep)
}
// TestHookWaitForFluxHealth runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookWaitForFluxHealth(ctx context.Context) error.
// Why: exposes flux-health gate internals to top-level tests.
func (o *Orchestrator) TestHookWaitForFluxHealth(ctx context.Context) error {
return o.waitForFluxHealth(ctx)
}
// TestHookFluxHealthReady runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookFluxHealthReady(ctx context.Context) (bool, string, error).
// Why: exposes flux-health poll internals to top-level tests.
func (o *Orchestrator) TestHookFluxHealthReady(ctx context.Context) (bool, string, error) {
return o.fluxHealthReady(ctx)
}
// TestHookAdaptiveFluxHealthWait runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookAdaptiveFluxHealthWait(ctx context.Context, base time.Duration) (time.Duration, string, error).
// Why: exposes adaptive flux wait-window logic to top-level tests.
func (o *Orchestrator) TestHookAdaptiveFluxHealthWait(ctx context.Context, base time.Duration) (time.Duration, string, error) {
return o.adaptiveFluxHealthWait(ctx, base)
}
// TestHookHealImmutableFluxJobs runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookHealImmutableFluxJobs(ctx context.Context) (bool, error).
// Why: exposes immutable-job self-heal internals to top-level tests.
func (o *Orchestrator) TestHookHealImmutableFluxJobs(ctx context.Context) (bool, error) {
return o.healImmutableFluxJobs(ctx)
}
// TestHookWaitForStorageReady runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookWaitForStorageReady(ctx context.Context) error.
// Why: exposes storage readiness gate internals to top-level tests.
func (o *Orchestrator) TestHookWaitForStorageReady(ctx context.Context) error {
return o.waitForStorageReady(ctx)
}
// TestHookStorageReady runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookStorageReady(ctx context.Context) (bool, string, error).
// Why: exposes storage readiness poll internals to top-level tests.
func (o *Orchestrator) TestHookStorageReady(ctx context.Context) (bool, string, error) {
return o.storageReady(ctx)
}
// TestHookWaitForTimeSync runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookWaitForTimeSync(ctx context.Context, nodes []string) error.
// Why: exposes time-sync gate internals to top-level tests.
func (o *Orchestrator) TestHookWaitForTimeSync(ctx context.Context, nodes []string) error {
return o.waitForTimeSync(ctx, nodes)
}
// TestHookPreflightExternalDatastore runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookPreflightExternalDatastore(ctx context.Context) error.
// Why: exposes datastore preflight internals to top-level tests.
func (o *Orchestrator) TestHookPreflightExternalDatastore(ctx context.Context) error {
return o.preflightExternalDatastore(ctx)
}
// TestHookParseDatastoreEndpoint runs one orchestration or CLI step.
// Signature: TestHookParseDatastoreEndpoint(unitText string) string.
// Why: exposes datastore endpoint parsing helper to top-level tests.
func TestHookParseDatastoreEndpoint(unitText string) string {
return parseDatastoreEndpoint(unitText)
}
// TestHookNodeNameForHost runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookNodeNameForHost(host string) string.
// Why: exposes host-to-node mapping helper to top-level tests.
func (o *Orchestrator) TestHookNodeNameForHost(host string) string {
return o.nodeNameForHost(host)
}
// TestHookValidateNodeInventory runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookValidateNodeInventory() error.
// Why: exposes inventory validator internals to top-level tests.
func (o *Orchestrator) TestHookValidateNodeInventory() error {
return o.validateNodeInventory()
}
// TestHookTCPReachable runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookTCPReachable(address string, timeout time.Duration) bool.
// Why: exposes TCP reachability helper to top-level tests.
func (o *Orchestrator) TestHookTCPReachable(address string, timeout time.Duration) bool {
return o.tcpReachable(address, timeout)
}
// TestHookWaitForNodeInventoryReachability runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookWaitForNodeInventoryReachability(ctx context.Context) error.
// Why: exposes inventory reachability gate internals to top-level tests.
func (o *Orchestrator) TestHookWaitForNodeInventoryReachability(ctx context.Context) error {
return o.waitForNodeInventoryReachability(ctx)
}
// TestHookIntentAge runs one orchestration or CLI step.
// Signature: TestHookIntentAge(in state.Intent) time.Duration.
// Why: exposes startup coordination age helper to top-level tests.
func TestHookIntentAge(in state.Intent) time.Duration {
return intentAge(in)
}
// TestHookIntentFresh runs one orchestration or CLI step.
// Signature: TestHookIntentFresh(in state.Intent, maxAge time.Duration) bool.
// Why: exposes startup coordination freshness helper to top-level tests.
func TestHookIntentFresh(in state.Intent, maxAge time.Duration) bool {
return intentFresh(in, maxAge)
}
// TestHookCoordinationPeers runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookCoordinationPeers() []string.
// Why: exposes peer list normalizer helper to top-level tests.
func (o *Orchestrator) TestHookCoordinationPeers() []string {
return o.coordinationPeers()
}
// TestHookNormalizeShutdownMode runs one orchestration or CLI step.
// Signature: TestHookNormalizeShutdownMode(raw string) (string, error).
// Why: exposes shutdown mode parser helper to top-level tests.
func TestHookNormalizeShutdownMode(raw string) (string, error) {
return normalizeShutdownMode(raw)
}
// TestHookParseFluxKustomizationTimeout runs one orchestration or CLI step.
// Signature: TestHookParseFluxKustomizationTimeout(raw string) time.Duration.
// Why: exposes flux timeout parser helper to top-level tests.
func TestHookParseFluxKustomizationTimeout(raw string) time.Duration {
return parseFluxKustomizationTimeout(raw)
}
// TestHookLooksLikeImmutableJobError runs one orchestration or CLI step.
// Signature: TestHookLooksLikeImmutableJobError(detail string) bool.
// Why: exposes immutable-job detector helper to top-level tests.
func TestHookLooksLikeImmutableJobError(detail string) bool {
return looksLikeImmutableJobError(detail)
}
// TestHookJobLooksFluxManaged runs one orchestration or CLI step.
// Signature: TestHookJobLooksFluxManaged(namespace string, name string, labels map[string]string, ownerKinds []string) bool.
// Why: exposes flux-job ownership helper to top-level tests.
func TestHookJobLooksFluxManaged(namespace string, name string, labels map[string]string, ownerKinds []string) bool {
var job jobResource
job.Metadata.Namespace = namespace
job.Metadata.Name = name
job.Metadata.Labels = labels
for _, kind := range ownerKinds {
job.Metadata.OwnerReferences = append(job.Metadata.OwnerReferences, ownerReference{Kind: kind})
}
return jobLooksFluxManaged(job)
}
// TestHookJobFailed runs one orchestration or CLI step.
// Signature: TestHookJobFailed(failed int32, succeeded int32, conditionTypes []string, conditionStatuses []string) bool.
// Why: exposes failed-job detector helper to top-level tests.
func TestHookJobFailed(failed int32, succeeded int32, conditionTypes []string, conditionStatuses []string) bool {
var job jobResource
job.Status.Failed = failed
job.Status.Succeeded = succeeded
for i := range conditionTypes {
status := ""
if i < len(conditionStatuses) {
status = conditionStatuses[i]
}
job.Status.Conditions = append(job.Status.Conditions, jobConditionRef{
Type: conditionTypes[i],
Status: status,
})
}
return jobFailed(job)
}
// TestHookEstimatedShutdownSeconds runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookEstimatedShutdownSeconds() int.
// Why: exposes shutdown estimate helper to top-level tests.
func (o *Orchestrator) TestHookEstimatedShutdownSeconds() int {
return o.EstimatedShutdownSeconds()
}
// TestHookEstimatedEmergencyShutdownSeconds runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookEstimatedEmergencyShutdownSeconds() int.
// Why: exposes emergency shutdown estimate helper to top-level tests.
func (o *Orchestrator) TestHookEstimatedEmergencyShutdownSeconds() int {
return o.EstimatedEmergencyShutdownSeconds()
}
// TestHookSanitizeReportFileName runs one orchestration or CLI step.
// Signature: TestHookSanitizeReportFileName(value string) string.
// Why: exposes report filename sanitizer helper to top-level tests.
func TestHookSanitizeReportFileName(value string) string {
return sanitizeReportFileName(value)
}
// TestHookIsTimeSynced runs one orchestration or CLI step.
// Signature: TestHookIsTimeSynced(raw string) bool.
// Why: exposes NTP synchronization parser helper to top-level tests.
func TestHookIsTimeSynced(raw string) bool {
return isTimeSynced(raw)
}

View File

@ -0,0 +1,204 @@
package cluster
import (
"context"
"time"
"scm.bstein.dev/bstein/ananke/internal/config"
)
// TestHookWaitForPostStartProbes runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookWaitForPostStartProbes(ctx context.Context) error.
// Why: exposes post-start probe gate internals to top-level tests.
func (o *Orchestrator) TestHookWaitForPostStartProbes(ctx context.Context) error {
return o.waitForPostStartProbes(ctx)
}
// TestHookPostStartProbesReady runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookPostStartProbesReady(ctx context.Context) (bool, string).
// Why: exposes post-start probe poll internals to top-level tests.
func (o *Orchestrator) TestHookPostStartProbesReady(ctx context.Context) (bool, string) {
return o.postStartProbesReady(ctx)
}
// TestHookHTTPProbe runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookHTTPProbe(ctx context.Context, probeURL string) (int, error).
// Why: exposes HTTP probe internals to top-level tests.
func (o *Orchestrator) TestHookHTTPProbe(ctx context.Context, probeURL string) (int, error) {
return o.httpProbe(ctx, probeURL)
}
// TestHookResumeFluxAndReconcile runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookResumeFluxAndReconcile(ctx context.Context) error.
// Why: exposes flux resume/reconcile internals to top-level tests.
func (o *Orchestrator) TestHookResumeFluxAndReconcile(ctx context.Context) error {
return o.resumeFluxAndReconcile(ctx)
}
// TestHookEnsureRequiredNodeLabels runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookEnsureRequiredNodeLabels(ctx context.Context) error.
// Why: exposes required-node-label enforcement internals to top-level tests.
func (o *Orchestrator) TestHookEnsureRequiredNodeLabels(ctx context.Context) error {
return o.ensureRequiredNodeLabels(ctx)
}
// TestHookSSHWithTimeout runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookSSHWithTimeout(ctx context.Context, node string, command string, timeout time.Duration) (string, error).
// Why: exposes SSH transport internals to top-level tests.
func (o *Orchestrator) TestHookSSHWithTimeout(ctx context.Context, node string, command string, timeout time.Duration) (string, error) {
return o.sshWithTimeout(ctx, node, command, timeout)
}
// TestHookRunSensitive runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookRunSensitive(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error).
// Why: exposes sensitive-run command path to top-level tests.
func (o *Orchestrator) TestHookRunSensitive(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
return o.runSensitive(ctx, timeout, name, args...)
}
// TestHookWaitForStartupConvergence runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookWaitForStartupConvergence(ctx context.Context) error.
// Why: exposes convergence gate internals to top-level tests.
func (o *Orchestrator) TestHookWaitForStartupConvergence(ctx context.Context) error {
return o.waitForStartupConvergence(ctx)
}
// TestHookWaitForServiceChecklist runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookWaitForServiceChecklist(ctx context.Context) error.
// Why: exposes service checklist gate internals to top-level tests.
func (o *Orchestrator) TestHookWaitForServiceChecklist(ctx context.Context) error {
return o.waitForServiceChecklist(ctx)
}
// TestHookServiceChecklistReady runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookServiceChecklistReady(ctx context.Context) (bool, string).
// Why: exposes service checklist poll internals to top-level tests.
func (o *Orchestrator) TestHookServiceChecklistReady(ctx context.Context) (bool, string) {
return o.serviceChecklistReady(ctx)
}
// TestHookServiceCheckReady runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookServiceCheckReady(ctx context.Context, check config.ServiceChecklistCheck) (bool, string).
// Why: exposes service check evaluator internals to top-level tests.
func (o *Orchestrator) TestHookServiceCheckReady(ctx context.Context, check config.ServiceChecklistCheck) (bool, string) {
return o.serviceCheckReady(ctx, check)
}
// TestHookHTTPChecklistProbe runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookHTTPChecklistProbe(ctx context.Context, check config.ServiceChecklistCheck) (int, string, error).
// Why: exposes checklist HTTP probe internals to top-level tests.
func (o *Orchestrator) TestHookHTTPChecklistProbe(ctx context.Context, check config.ServiceChecklistCheck) (int, string, error) {
return o.httpChecklistProbe(ctx, check)
}
// TestHookWaitForStabilityWindow runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookWaitForStabilityWindow(ctx context.Context) error.
// Why: exposes startup stability-window internals to top-level tests.
func (o *Orchestrator) TestHookWaitForStabilityWindow(ctx context.Context) error {
return o.waitForStabilityWindow(ctx)
}
// TestHookStartupStabilityHealthy runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookStartupStabilityHealthy(ctx context.Context) error.
// Why: exposes startup stability-health poll internals to top-level tests.
func (o *Orchestrator) TestHookStartupStabilityHealthy(ctx context.Context) error {
return o.startupStabilityHealthy(ctx)
}
// TestHookWaitForIngressChecklist runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookWaitForIngressChecklist(ctx context.Context) error.
// Why: exposes ingress checklist gate internals to top-level tests.
func (o *Orchestrator) TestHookWaitForIngressChecklist(ctx context.Context) error {
return o.waitForIngressChecklist(ctx)
}
// TestHookIngressChecklistReady runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookIngressChecklistReady(ctx context.Context) (bool, string).
// Why: exposes ingress checklist poll internals to top-level tests.
func (o *Orchestrator) TestHookIngressChecklistReady(ctx context.Context) (bool, string) {
return o.ingressChecklistReady(ctx)
}
// TestHookDiscoverIngressHosts runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookDiscoverIngressHosts(ctx context.Context) ([]string, error).
// Why: exposes ingress host discovery internals to top-level tests.
func (o *Orchestrator) TestHookDiscoverIngressHosts(ctx context.Context) ([]string, error) {
return o.discoverIngressHosts(ctx)
}
// TestHookDiscoverIngressNamespacesForHost runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookDiscoverIngressNamespacesForHost(ctx context.Context, host string) ([]string, error).
// Why: exposes ingress host namespace discovery internals to top-level tests.
func (o *Orchestrator) TestHookDiscoverIngressNamespacesForHost(ctx context.Context, host string) ([]string, error) {
return o.discoverIngressNamespacesForHost(ctx, host)
}
// TestHookHealIngressHostBackendReplicas runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookHealIngressHostBackendReplicas(ctx context.Context, host string) ([]string, error).
// Why: exposes ingress host backend replica-heal internals to top-level tests.
func (o *Orchestrator) TestHookHealIngressHostBackendReplicas(ctx context.Context, host string) ([]string, error) {
return o.healIngressHostBackendReplicas(ctx, host)
}
// TestHookMaybeAutoHealIngressHostBackends runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookMaybeAutoHealIngressHostBackends(ctx context.Context, lastAttempt *time.Time, failureDetail string).
// Why: exposes ingress backend auto-heal internals to top-level tests.
func (o *Orchestrator) TestHookMaybeAutoHealIngressHostBackends(ctx context.Context, lastAttempt *time.Time, failureDetail string) {
o.maybeAutoHealIngressHostBackends(ctx, lastAttempt, failureDetail)
}
// TestHookChecklistFailureHost runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookChecklistFailureHost(failureDetail string) string.
// Why: exposes checklist failure host parser internals to top-level tests.
func (o *Orchestrator) TestHookChecklistFailureHost(failureDetail string) string {
return o.checklistFailureHost(failureDetail)
}
// TestHookHostFromURL runs one orchestration or CLI step.
// Signature: TestHookHostFromURL(raw string) string.
// Why: exposes URL host parser helper to top-level tests.
func TestHookHostFromURL(raw string) string {
return hostFromURL(raw)
}
// TestHookProbeStatusAccepted runs one orchestration or CLI step.
// Signature: TestHookProbeStatusAccepted(code int) bool.
// Why: exposes post-start probe status-code acceptance helper to top-level tests.
func TestHookProbeStatusAccepted(code int) bool {
return probeStatusAccepted("", code)
}
// TestHookChecklistContains runs one orchestration or CLI step.
// Signature: TestHookChecklistContains(body string, marker string) bool.
// Why: exposes checklist body matcher helper to top-level tests.
func TestHookChecklistContains(body string, marker string) bool {
return checklistContains(body, marker)
}
// TestHookIsLikelyHostname runs one orchestration or CLI step.
// Signature: TestHookIsLikelyHostname(value string) bool.
// Why: exposes hostname heuristic helper to top-level tests.
func TestHookIsLikelyHostname(value string) bool {
return isLikelyHostname(value)
}
// TestHookSSHManaged runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookSSHManaged(node string) bool.
// Why: exposes managed-node selector internals to top-level tests.
func (o *Orchestrator) TestHookSSHManaged(node string) bool {
return o.sshManaged(node)
}
// TestHookResolveSSHConfigFile runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookResolveSSHConfigFile() string.
// Why: exposes SSH config path resolution internals to top-level tests.
func (o *Orchestrator) TestHookResolveSSHConfigFile() string {
return o.resolveSSHConfigFile()
}
// TestHookResolveSSHIdentityFile runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookResolveSSHIdentityFile() string.
// Why: exposes SSH identity path resolution internals to top-level tests.
func (o *Orchestrator) TestHookResolveSSHIdentityFile() string {
return o.resolveSSHIdentityFile()
}

View File

@ -0,0 +1,143 @@
package cluster
import "context"
// TestHookDrainWorkers runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookDrainWorkers(ctx context.Context, workers []string) error.
// Why: exposes worker drain internals to top-level tests.
func (o *Orchestrator) TestHookDrainWorkers(ctx context.Context, workers []string) error {
return o.drainWorkers(ctx, workers)
}
// TestHookUncordonWorkers runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookUncordonWorkers(ctx context.Context, workers []string) error.
// Why: exposes worker uncordon internals to top-level tests.
func (o *Orchestrator) TestHookUncordonWorkers(ctx context.Context, workers []string) error {
return o.uncordonWorkers(ctx, workers)
}
// TestHookDrainNodeDiagnostics runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookDrainNodeDiagnostics(ctx context.Context, node string) string.
// Why: exposes drain diagnostic internals to top-level tests.
func (o *Orchestrator) TestHookDrainNodeDiagnostics(ctx context.Context, node string) string {
return o.drainNodeDiagnostics(ctx, node)
}
// TestHookTakeEtcdSnapshot runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookTakeEtcdSnapshot(ctx context.Context, controlPlane string) error.
// Why: exposes etcd snapshot capture internals to top-level tests.
func (o *Orchestrator) TestHookTakeEtcdSnapshot(ctx context.Context, controlPlane string) error {
return o.takeEtcdSnapshot(ctx, controlPlane)
}
// TestHookLatestEtcdSnapshotPath runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookLatestEtcdSnapshotPath(ctx context.Context, controlPlane string) (string, error).
// Why: exposes snapshot path parser internals to top-level tests.
func (o *Orchestrator) TestHookLatestEtcdSnapshotPath(ctx context.Context, controlPlane string) (string, error) {
return o.latestEtcdSnapshotPath(ctx, controlPlane)
}
// TestHookEffectiveWorkers runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookEffectiveWorkers(ctx context.Context) ([]string, error).
// Why: exposes worker-selection internals to top-level tests.
func (o *Orchestrator) TestHookEffectiveWorkers(ctx context.Context) ([]string, error) {
return o.effectiveWorkers(ctx)
}
// TestHookDiscoverWorkers runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookDiscoverWorkers(ctx context.Context) ([]string, error).
// Why: exposes worker discovery internals to top-level tests.
func (o *Orchestrator) TestHookDiscoverWorkers(ctx context.Context) ([]string, error) {
return o.discoverWorkers(ctx)
}
// TestHookPatchFluxSuspendAll runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookPatchFluxSuspendAll(ctx context.Context, suspend bool) error.
// Why: exposes flux suspend patch internals to top-level tests.
func (o *Orchestrator) TestHookPatchFluxSuspendAll(ctx context.Context, suspend bool) error {
return o.patchFluxSuspendAll(ctx, suspend)
}
// TestHookScaleDownApps runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookScaleDownApps(ctx context.Context) error.
// Why: exposes app scale-down internals to top-level tests.
func (o *Orchestrator) TestHookScaleDownApps(ctx context.Context) error {
return o.scaleDownApps(ctx)
}
// TestHookRestoreScaledApps runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookRestoreScaledApps(ctx context.Context) error.
// Why: exposes app scale-restore internals to top-level tests.
func (o *Orchestrator) TestHookRestoreScaledApps(ctx context.Context) error {
return o.restoreScaledApps(ctx)
}
// TestHookListScalableWorkloads runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookListScalableWorkloads(ctx context.Context) ([]workloadScaleEntry, error).
// Why: exposes scalable workload discovery internals to top-level tests.
func (o *Orchestrator) TestHookListScalableWorkloads(ctx context.Context) ([]workloadScaleEntry, error) {
return o.listScalableWorkloads(ctx)
}
// TestHookScaleWorkloads runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookScaleWorkloads(ctx context.Context, entries []workloadScaleEntry, forceReplicas int, parallelism int) error.
// Why: exposes workload scaling internals to top-level tests.
func (o *Orchestrator) TestHookScaleWorkloads(ctx context.Context, entries []workloadScaleEntry, forceReplicas int, parallelism int) error {
return o.scaleWorkloads(ctx, entries, forceReplicas, parallelism)
}
// TestHookWriteScaledWorkloadSnapshot runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookWriteScaledWorkloadSnapshot(entries []workloadScaleEntry) error.
// Why: exposes scaled workload snapshot writer to top-level tests.
func (o *Orchestrator) TestHookWriteScaledWorkloadSnapshot(entries []workloadScaleEntry) error {
return o.writeScaledWorkloadSnapshot(entries)
}
// TestHookReadScaledWorkloadSnapshot runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookReadScaledWorkloadSnapshot() (*workloadScaleSnapshot, error).
// Why: exposes scaled workload snapshot reader to top-level tests.
func (o *Orchestrator) TestHookReadScaledWorkloadSnapshot() (*workloadScaleSnapshot, error) {
return o.readScaledWorkloadSnapshot()
}
// TestHookRunSSHAcrossNodes runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookRunSSHAcrossNodes(ctx context.Context, nodes []string, action string, command string).
// Why: exposes fan-out SSH executor internals to top-level tests.
func (o *Orchestrator) TestHookRunSSHAcrossNodes(ctx context.Context, nodes []string, action string, command string) {
o.runSSHAcrossNodes(ctx, nodes, action, command)
}
// TestHookStopWorkers runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookStopWorkers(ctx context.Context, workers []string).
// Why: exposes worker stop fan-out internals to top-level tests.
func (o *Orchestrator) TestHookStopWorkers(ctx context.Context, workers []string) {
o.stopWorkers(ctx, workers)
}
// TestHookStartWorkers runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookStartWorkers(ctx context.Context, workers []string).
// Why: exposes worker start fan-out internals to top-level tests.
func (o *Orchestrator) TestHookStartWorkers(ctx context.Context, workers []string) {
o.startWorkers(ctx, workers)
}
// TestHookStopControlPlanes runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookStopControlPlanes(ctx context.Context, cps []string).
// Why: exposes control-plane stop fan-out internals to top-level tests.
func (o *Orchestrator) TestHookStopControlPlanes(ctx context.Context, cps []string) {
o.stopControlPlanes(ctx, cps)
}
// TestHookStartControlPlanes runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookStartControlPlanes(ctx context.Context, cps []string).
// Why: exposes control-plane start fan-out internals to top-level tests.
func (o *Orchestrator) TestHookStartControlPlanes(ctx context.Context, cps []string) {
o.startControlPlanes(ctx, cps)
}
// TestHookParseSnapshotPathFromEtcdSnapshotList runs one orchestration or CLI step.
// Signature: TestHookParseSnapshotPathFromEtcdSnapshotList(out string) string.
// Why: exposes etcd snapshot list parser helper to top-level tests.
func TestHookParseSnapshotPathFromEtcdSnapshotList(out string) string {
return parseSnapshotPathFromEtcdSnapshotList(out)
}

View File

@ -0,0 +1,158 @@
package cluster
import (
"errors"
"time"
"scm.bstein.dev/bstein/ananke/internal/state"
)
// TestHookReportArchiveDir runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookReportArchiveDir() string.
// Why: exposes report archive directory resolver internals to top-level tests.
func (o *Orchestrator) TestHookReportArchiveDir() string {
return o.reportArchiveDir()
}
// TestHookWriteRunRecordArtifact runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookWriteRunRecordArtifact(record state.RunRecord) error.
// Why: exposes run-record artifact writer internals to top-level tests.
func (o *Orchestrator) TestHookWriteRunRecordArtifact(record state.RunRecord) error {
return o.writeRunRecordArtifact(record)
}
// TestHookStartupReportPath runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookStartupReportPath() string.
// Why: exposes startup report path helper internals to top-level tests.
func (o *Orchestrator) TestHookStartupReportPath() string {
return o.startupReportPath()
}
// TestHookStartupProgressPath runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookStartupProgressPath() string.
// Why: exposes startup progress path helper internals to top-level tests.
func (o *Orchestrator) TestHookStartupProgressPath() string {
return o.startupProgressPath()
}
// TestHookLastShutdownReportPath runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookLastShutdownReportPath() string.
// Why: exposes last-shutdown report path helper internals to top-level tests.
func (o *Orchestrator) TestHookLastShutdownReportPath() string {
return o.lastShutdownReportPath()
}
// TestHookBeginStartupReport runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookBeginStartupReport(reason string).
// Why: exposes startup-report bootstrap internals to top-level tests.
func (o *Orchestrator) TestHookBeginStartupReport(reason string) {
o.beginStartupReport(reason)
}
// TestHookSetStartupPhase runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookSetStartupPhase(phase string, detail string).
// Why: exposes startup phase progress tracking internals to top-level tests.
func (o *Orchestrator) TestHookSetStartupPhase(phase string, detail string) {
o.setStartupPhase(phase, detail)
}
// TestHookNoteStartupCheckState runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookNoteStartupCheckState(name string, status string, detail string).
// Why: exposes startup check-state tracking internals to top-level tests.
func (o *Orchestrator) TestHookNoteStartupCheckState(name string, status string, detail string) {
o.noteStartupCheckState(name, status, detail)
}
// TestHookNoteStartupCheck runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookNoteStartupCheck(name string, success bool, detail string).
// Why: exposes startup check convenience helper internals to top-level tests.
func (o *Orchestrator) TestHookNoteStartupCheck(name string, success bool, detail string) {
o.noteStartupCheck(name, success, detail)
}
// TestHookNoteStartupAutoHeal runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookNoteStartupAutoHeal(detail string).
// Why: exposes startup auto-heal report tracking internals to top-level tests.
func (o *Orchestrator) TestHookNoteStartupAutoHeal(detail string) {
o.noteStartupAutoHeal(detail)
}
// TestHookFinalizeStartupReport runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookFinalizeStartupReport(runErr error).
// Why: exposes startup report finalization internals to top-level tests.
func (o *Orchestrator) TestHookFinalizeStartupReport(runErr error) {
o.finalizeStartupReport(runErr)
}
// TestHookWriteStartupReportFileNil runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookWriteStartupReportFileNil(path string) error.
// Why: exposes nil-report writer branch to top-level tests.
func (o *Orchestrator) TestHookWriteStartupReportFileNil(path string) error {
return o.writeStartupReportFile(path, nil)
}
// TestHookWriteStartupReportFile runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookWriteStartupReportFile(path string, status string) error.
// Why: exposes startup-report writer error/success branches to top-level tests.
func (o *Orchestrator) TestHookWriteStartupReportFile(path string, status string) error {
report := &startupReport{
StartedAt: time.Now().UTC(),
Status: status,
Checks: map[string]startupCheckRecord{},
AutoHeals: []string{},
LastUpdated: time.Now().UTC(),
}
return o.writeStartupReportFile(path, report)
}
// TestHookPersistStartupProgressNil runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookPersistStartupProgressNil().
// Why: exposes startup-progress nil branch to top-level tests.
func (o *Orchestrator) TestHookPersistStartupProgressNil() {
o.persistStartupProgress(nil)
}
// TestHookPersistStartupProgress runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookPersistStartupProgress(status string).
// Why: exposes startup-progress persistence internals to top-level tests.
func (o *Orchestrator) TestHookPersistStartupProgress(status string) {
report := &startupReport{
StartedAt: time.Now().UTC(),
Status: status,
Checks: map[string]startupCheckRecord{},
AutoHeals: []string{},
LastUpdated: time.Now().UTC(),
}
o.persistStartupProgress(report)
}
// TestHookFinalizeRecord runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookFinalizeRecord(action string, reason string, runErr string).
// Why: exposes run-record finalization branches to top-level tests.
func (o *Orchestrator) TestHookFinalizeRecord(action string, reason string, runErr string) {
record := state.RunRecord{
ID: "hook-record-" + sanitizeReportFileName(action),
Action: action,
Reason: reason,
StartedAt: time.Now().UTC().Add(-3 * time.Second),
}
var err error
if runErr != "" {
err = errors.New(runErr)
}
o.finalizeRecord(&record, &err)
}
// TestHookFinalizeStartupReportSnapshot runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookFinalizeStartupReportSnapshot(runErr error) bool.
// Why: exposes snapshot finalizer internals to top-level tests without leaking private types.
func (o *Orchestrator) TestHookFinalizeStartupReportSnapshot(runErr error) bool {
report := &startupReport{
StartedAt: time.Now().UTC(),
Checks: map[string]startupCheckRecord{},
AutoHeals: []string{},
LastUpdated: time.Now().UTC(),
}
final := o.finalizeStartupReportSnapshot(report, runErr)
return final != nil
}

View File

@ -0,0 +1,462 @@
package cluster
import (
"context"
"fmt"
"sort"
"strings"
"time"
)
// TestHookWaitForCriticalServiceEndpoints runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookWaitForCriticalServiceEndpoints(ctx context.Context) error.
// Why: exposes critical endpoint gate internals to top-level tests.
func (o *Orchestrator) TestHookWaitForCriticalServiceEndpoints(ctx context.Context) error {
return o.waitForCriticalServiceEndpoints(ctx)
}
// TestHookMaybeHealCriticalEndpointBackends runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookMaybeHealCriticalEndpointBackends(ctx context.Context, namespace string, service string) ([]string, error).
// Why: exposes critical endpoint backend-heal internals to top-level tests.
func (o *Orchestrator) TestHookMaybeHealCriticalEndpointBackends(ctx context.Context, namespace string, service string) ([]string, error) {
return o.maybeHealCriticalEndpointBackends(ctx, strings.TrimSpace(namespace), strings.TrimSpace(service))
}
// TestHookCriticalServiceEndpointsReady runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookCriticalServiceEndpointsReady(ctx context.Context) (bool, string, string, string, error).
// Why: exposes critical endpoint poll internals to top-level tests.
func (o *Orchestrator) TestHookCriticalServiceEndpointsReady(ctx context.Context) (bool, string, string, string, error) {
return o.criticalServiceEndpointsReady(ctx)
}
// TestHookWaitForWorkloadConvergence runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookWaitForWorkloadConvergence(ctx context.Context) error.
// Why: exposes workload convergence gate internals to top-level tests.
func (o *Orchestrator) TestHookWaitForWorkloadConvergence(ctx context.Context) error {
return o.waitForWorkloadConvergence(ctx)
}
// TestHookWorkloadConvergenceReady runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookWorkloadConvergenceReady(ctx context.Context) (bool, string, error).
// Why: exposes workload convergence poll internals to top-level tests.
func (o *Orchestrator) TestHookWorkloadConvergenceReady(ctx context.Context) (bool, string, error) {
return o.workloadConvergenceReady(ctx)
}
// TestHookRecycleStuckControllerPods runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookRecycleStuckControllerPods(ctx context.Context) error.
// Why: exposes stuck-pod recycle internals to top-level tests.
func (o *Orchestrator) TestHookRecycleStuckControllerPods(ctx context.Context) error {
return o.recycleStuckControllerPods(ctx)
}
// TestHookMaybeAutoRecycleStuckPods runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookMaybeAutoRecycleStuckPods(ctx context.Context, lastAttempt *time.Time).
// Why: exposes auto-recycle trigger internals to top-level tests.
func (o *Orchestrator) TestHookMaybeAutoRecycleStuckPods(ctx context.Context, lastAttempt *time.Time) {
o.maybeAutoRecycleStuckPods(ctx, lastAttempt)
}
// TestHookMaybeAutoHealCriticalWorkloadReplicas runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookMaybeAutoHealCriticalWorkloadReplicas(ctx context.Context, lastAttempt *time.Time).
// Why: exposes critical workload auto-heal trigger internals to top-level tests.
func (o *Orchestrator) TestHookMaybeAutoHealCriticalWorkloadReplicas(ctx context.Context, lastAttempt *time.Time) {
o.maybeAutoHealCriticalWorkloadReplicas(ctx, lastAttempt)
}
// TestHookHealCriticalWorkloadReplicas runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookHealCriticalWorkloadReplicas(ctx context.Context) ([]string, error).
// Why: exposes critical workload replica-heal internals to top-level tests.
func (o *Orchestrator) TestHookHealCriticalWorkloadReplicas(ctx context.Context) ([]string, error) {
return o.healCriticalWorkloadReplicas(ctx)
}
// TestHookStartupFailurePods runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookStartupFailurePods(ctx context.Context) ([]string, error).
// Why: exposes startup failure pod summarizer internals to top-level tests.
func (o *Orchestrator) TestHookStartupFailurePods(ctx context.Context) ([]string, error) {
return o.startupFailurePods(ctx)
}
// TestHookEnsureCriticalStartupWorkloads runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookEnsureCriticalStartupWorkloads(ctx context.Context) error.
// Why: exposes critical workload ensure path to top-level tests.
func (o *Orchestrator) TestHookEnsureCriticalStartupWorkloads(ctx context.Context) error {
return o.ensureCriticalStartupWorkloads(ctx)
}
// TestHookMissingCriticalStartupWorkloads runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookMissingCriticalStartupWorkloads(ctx context.Context) ([]string, error).
// Why: exposes missing critical workload scanner internals to top-level tests.
func (o *Orchestrator) TestHookMissingCriticalStartupWorkloads(ctx context.Context) ([]string, error) {
return o.missingCriticalStartupWorkloads(ctx)
}
// TestHookCleanupStaleCriticalWorkloadPods runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookCleanupStaleCriticalWorkloadPods(ctx context.Context, namespace string, kind string, name string) error.
// Why: exposes stale critical-pod cleanup internals to top-level tests.
func (o *Orchestrator) TestHookCleanupStaleCriticalWorkloadPods(ctx context.Context, namespace string, kind string, name string) error {
return o.cleanupStaleCriticalWorkloadPods(ctx, startupWorkload{
Namespace: strings.TrimSpace(namespace),
Kind: strings.TrimSpace(kind),
Name: strings.TrimSpace(name),
})
}
// TestHookEnsureWorkloadReplicas runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookEnsureWorkloadReplicas(ctx context.Context, namespace string, kind string, name string, replicas int) error.
// Why: exposes workload scaling helper internals to top-level tests.
func (o *Orchestrator) TestHookEnsureWorkloadReplicas(ctx context.Context, namespace string, kind string, name string, replicas int) error {
return o.ensureWorkloadReplicas(ctx, startupWorkload{
Namespace: strings.TrimSpace(namespace),
Kind: strings.TrimSpace(kind),
Name: strings.TrimSpace(name),
}, replicas)
}
// TestHookWaitWorkloadReady runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookWaitWorkloadReady(ctx context.Context, namespace string, kind string, name string) error.
// Why: exposes workload readiness waiter internals to top-level tests.
func (o *Orchestrator) TestHookWaitWorkloadReady(ctx context.Context, namespace string, kind string, name string) error {
return o.waitWorkloadReady(ctx, startupWorkload{
Namespace: strings.TrimSpace(namespace),
Kind: strings.TrimSpace(kind),
Name: strings.TrimSpace(name),
})
}
// TestHookWaitVaultReady runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookWaitVaultReady(ctx context.Context, namespace string, kind string, name string) error.
// Why: exposes vault-specific readiness loop internals to top-level tests.
func (o *Orchestrator) TestHookWaitVaultReady(ctx context.Context, namespace string, kind string, name string) error {
return o.waitVaultReady(ctx, startupWorkload{
Namespace: strings.TrimSpace(namespace),
Kind: strings.TrimSpace(kind),
Name: strings.TrimSpace(name),
})
}
// TestHookEnsureVaultUnsealed runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookEnsureVaultUnsealed(ctx context.Context) error.
// Why: exposes vault auto-unseal internals to top-level tests.
func (o *Orchestrator) TestHookEnsureVaultUnsealed(ctx context.Context) error {
return o.ensureVaultUnsealed(ctx)
}
// TestHookVaultSealed runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookVaultSealed(ctx context.Context) (bool, error).
// Why: exposes vault sealed-state parser internals to top-level tests.
func (o *Orchestrator) TestHookVaultSealed(ctx context.Context) (bool, error) {
return o.vaultSealed(ctx)
}
// TestHookVaultUnsealKey runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookVaultUnsealKey(ctx context.Context) (string, error).
// Why: exposes unseal key resolution internals to top-level tests.
func (o *Orchestrator) TestHookVaultUnsealKey(ctx context.Context) (string, error) {
return o.vaultUnsealKey(ctx)
}
// TestHookReadVaultUnsealKeyBreakglass runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookReadVaultUnsealKeyBreakglass(ctx context.Context) (string, error).
// Why: exposes break-glass fallback key command internals to top-level tests.
func (o *Orchestrator) TestHookReadVaultUnsealKeyBreakglass(ctx context.Context) (string, error) {
return o.readVaultUnsealKeyBreakglass(ctx)
}
// TestHookWriteVaultUnsealKeyFile runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookWriteVaultUnsealKeyFile(key string) error.
// Why: exposes local unseal key cache writer internals to top-level tests.
func (o *Orchestrator) TestHookWriteVaultUnsealKeyFile(key string) error {
return o.writeVaultUnsealKeyFile(key)
}
// TestHookReadVaultUnsealKeyFile runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookReadVaultUnsealKeyFile() (string, error).
// Why: exposes local unseal key cache reader internals to top-level tests.
func (o *Orchestrator) TestHookReadVaultUnsealKeyFile() (string, error) {
return o.readVaultUnsealKeyFile()
}
// TestHookWorkloadReady runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookWorkloadReady(ctx context.Context, namespace string, kind string, name string) (bool, error).
// Why: exposes readyReplicas readiness parser internals to top-level tests.
func (o *Orchestrator) TestHookWorkloadReady(ctx context.Context, namespace string, kind string, name string) (bool, error) {
return o.workloadReady(ctx, startupWorkload{
Namespace: strings.TrimSpace(namespace),
Kind: strings.TrimSpace(kind),
Name: strings.TrimSpace(name),
})
}
// TestHookParseVaultSealed runs one orchestration or CLI step.
// Signature: TestHookParseVaultSealed(raw string) (bool, error).
// Why: exposes vault status JSON parser helper to top-level tests.
func TestHookParseVaultSealed(raw string) (bool, error) {
return parseVaultSealed(raw)
}
// TestHookIsNotFoundErr runs one orchestration or CLI step.
// Signature: TestHookIsNotFoundErr(errText string) bool.
// Why: exposes not-found error classifier helper to top-level tests.
func TestHookIsNotFoundErr(errText string) bool {
if strings.TrimSpace(errText) == "" {
return isNotFoundErr(nil)
}
return isNotFoundErr(fmt.Errorf("%s", errText))
}
// TestHookWaitForServiceChecklistAlias runs one orchestration or CLI step.
// Signature: (o *Orchestrator) TestHookWaitForServiceChecklistAlias(ctx context.Context) error.
// Why: alias exported for top-level tests that focus on workload-driven checklist behavior.
func (o *Orchestrator) TestHookWaitForServiceChecklistAlias(ctx context.Context) error {
return o.waitForServiceChecklist(ctx)
}
// TestHookDesiredReady runs one orchestration or CLI step.
// Signature: TestHookDesiredReady(kind string, hasReplicas bool, replicas int32, ready int32, desiredScheduled int32, numberReady int32) (int32, int32, bool).
// Why: exposes controller desired/ready resolver helper to top-level tests.
func TestHookDesiredReady(kind string, hasReplicas bool, replicas int32, ready int32, desiredScheduled int32, numberReady int32) (int32, int32, bool) {
var item workloadResource
item.Kind = kind
if hasReplicas {
value := replicas
item.Spec.Replicas = &value
}
item.Status.ReadyReplicas = ready
item.Status.DesiredNumberScheduled = desiredScheduled
item.Status.NumberReady = numberReady
return desiredReady(item)
}
// TestHookPodControllerOwned runs one orchestration or CLI step.
// Signature: TestHookPodControllerOwned(ownerKinds []string) bool.
// Why: exposes controller ownership helper to top-level tests.
func TestHookPodControllerOwned(ownerKinds []string) bool {
var pod podResource
for _, kind := range ownerKinds {
pod.Metadata.OwnerReferences = append(pod.Metadata.OwnerReferences, ownerReference{Kind: kind})
}
return podControllerOwned(pod)
}
// TestHookStuckContainerReason runs one orchestration or CLI step.
// Signature: TestHookStuckContainerReason(initReasons []string, containerReasons []string, allowed []string) string.
// Why: exposes stuck-container reason helper to top-level tests.
func TestHookStuckContainerReason(initReasons []string, containerReasons []string, allowed []string) string {
reasons := map[string]struct{}{}
for _, reason := range allowed {
reasons[reason] = struct{}{}
}
var pod podResource
for _, reason := range initReasons {
pod.Status.InitContainerStatuses = append(pod.Status.InitContainerStatuses, podContainerStatus{
State: podContainerState{Waiting: &podContainerWaitingState{Reason: reason}},
})
}
for _, reason := range containerReasons {
pod.Status.ContainerStatuses = append(pod.Status.ContainerStatuses, podContainerStatus{
State: podContainerState{Waiting: &podContainerWaitingState{Reason: reason}},
})
}
return stuckContainerReason(pod, reasons)
}
// TestHookStuckVaultInitReason runs one orchestration or CLI step.
// Signature: TestHookStuckVaultInitReason(phase string, inject bool, startedAgo time.Duration, grace time.Duration) string.
// Why: exposes vault-init stuck detector helper to top-level tests.
func TestHookStuckVaultInitReason(phase string, inject bool, startedAgo time.Duration, grace time.Duration) string {
var pod podResource
pod.Status.Phase = phase
pod.Metadata.Annotations = map[string]string{}
if inject {
pod.Metadata.Annotations["vault.hashicorp.com/agent-inject"] = "true"
}
if startedAgo > 0 {
pod.Status.InitContainerStatuses = append(pod.Status.InitContainerStatuses, podContainerStatus{
Name: "vault-agent-init",
State: podContainerState{
Running: &podContainerRunningState{StartedAt: time.Now().Add(-startedAgo)},
},
})
}
return stuckVaultInitReason(pod, grace)
}
// TestHookPodTargetsIgnoredNode runs one orchestration or CLI step.
// Signature: TestHookPodTargetsIgnoredNode(nodeName string, ignored []string) bool.
// Why: exposes ignored-node targeting helper to top-level tests.
func TestHookPodTargetsIgnoredNode(nodeName string, ignored []string) bool {
var pod podResource
pod.Spec.NodeName = nodeName
return podTargetsIgnoredNode(pod, makeStringSet(ignored))
}
// TestHookWorkloadTargetsIgnoredNodes runs one orchestration or CLI step.
// Signature: TestHookWorkloadTargetsIgnoredNodes(nodeSelectorHost string, affinityHosts []string, ignored []string) bool.
// Why: exposes ignored-node selector/affinity helper to top-level tests.
func TestHookWorkloadTargetsIgnoredNodes(nodeSelectorHost string, affinityHosts []string, ignored []string) bool {
spec := podSpec{NodeSelector: map[string]string{}}
if nodeSelectorHost != "" {
spec.NodeSelector["kubernetes.io/hostname"] = nodeSelectorHost
}
if len(affinityHosts) > 0 {
spec.Affinity = &podAffinity{
NodeAffinity: &nodeAffinity{
RequiredDuringSchedulingIgnoredDuringExecution: &nodeSelector{
NodeSelectorTerms: []nodeSelectorTerm{
{
MatchExpressions: []nodeSelectorRequirement{
{
Key: "kubernetes.io/hostname",
Operator: "In",
Values: affinityHosts,
},
},
},
},
},
},
}
}
return workloadTargetsIgnoredNodes(spec, makeStringSet(ignored))
}
// TestHookNodeSelectorExpr defines one synthetic affinity expression for tests.
// Signature: type TestHookNodeSelectorExpr struct { Key string; Operator string; Values []string }.
// Why: allows top-level tests to drive workload ignore affinity branches that
// require malformed or non-default expression shapes.
type TestHookNodeSelectorExpr struct {
Key string
Operator string
Values []string
}
// TestHookWorkloadTargetsIgnoredNodesRaw runs one orchestration or CLI step.
// Signature: TestHookWorkloadTargetsIgnoredNodesRaw(termExpressions [][]TestHookNodeSelectorExpr, ignored []string) bool.
// Why: exposes raw affinity-shape branch coverage for ignored-node matching.
func TestHookWorkloadTargetsIgnoredNodesRaw(termExpressions [][]TestHookNodeSelectorExpr, ignored []string) bool {
spec := podSpec{}
if len(termExpressions) > 0 {
terms := make([]nodeSelectorTerm, 0, len(termExpressions))
for _, exprs := range termExpressions {
term := nodeSelectorTerm{
MatchExpressions: make([]nodeSelectorRequirement, 0, len(exprs)),
}
for _, expr := range exprs {
term.MatchExpressions = append(term.MatchExpressions, nodeSelectorRequirement{
Key: strings.TrimSpace(expr.Key),
Operator: strings.TrimSpace(expr.Operator),
Values: append([]string{}, expr.Values...),
})
}
terms = append(terms, term)
}
spec.Affinity = &podAffinity{
NodeAffinity: &nodeAffinity{
RequiredDuringSchedulingIgnoredDuringExecution: &nodeSelector{
NodeSelectorTerms: terms,
},
},
}
}
return workloadTargetsIgnoredNodes(spec, makeStringSet(ignored))
}
// TestHookStuckVaultInitInput defines one synthetic vault-init pod state.
// Signature: type TestHookStuckVaultInitInput struct { Phase string; Inject bool; InitContainerName string; Running bool; StartedAtOffsetSec int64; GraceSeconds int64 }.
// Why: allows top-level tests to drive all stuckVaultInitReason branches,
// including edge inputs that are awkward to represent with high-level helpers.
type TestHookStuckVaultInitInput struct {
Phase string
Inject bool
InitContainerName string
Running bool
StartedAtOffsetSec int64
GraceSeconds int64
}
// TestHookStuckVaultInitReasonRaw runs one orchestration or CLI step.
// Signature: TestHookStuckVaultInitReasonRaw(in TestHookStuckVaultInitInput) string.
// Why: exposes raw vault-init stuck detector inputs for full branch coverage.
func TestHookStuckVaultInitReasonRaw(in TestHookStuckVaultInitInput) string {
var pod podResource
pod.Status.Phase = strings.TrimSpace(in.Phase)
pod.Metadata.Annotations = map[string]string{}
if in.Inject {
pod.Metadata.Annotations["vault.hashicorp.com/agent-inject"] = "true"
}
state := podContainerState{}
if in.Running {
startedAt := time.Time{}
if in.StartedAtOffsetSec != 0 {
startedAt = time.Now().Add(-time.Duration(in.StartedAtOffsetSec) * time.Second)
}
state.Running = &podContainerRunningState{StartedAt: startedAt}
}
name := strings.TrimSpace(in.InitContainerName)
if name == "" {
name = "vault-agent-init"
}
pod.Status.InitContainerStatuses = append(pod.Status.InitContainerStatuses, podContainerStatus{
Name: name,
State: state,
})
grace := time.Duration(in.GraceSeconds) * time.Second
return stuckVaultInitReason(pod, grace)
}
// TestHookParseWorkloadIgnoreRulesCount runs one orchestration or CLI step.
// Signature: TestHookParseWorkloadIgnoreRulesCount(entries []string) int.
// Why: exposes workload-ignore rule parser helper to top-level tests.
func TestHookParseWorkloadIgnoreRulesCount(entries []string) int {
return len(parseWorkloadIgnoreRules(entries))
}
// TestHookWorkloadIgnoredEntries runs one orchestration or CLI step.
// Signature: TestHookWorkloadIgnoredEntries(entries []string, namespace string, kind string, name string) bool.
// Why: exposes workload-ignore matcher helper to top-level tests.
func TestHookWorkloadIgnoredEntries(entries []string, namespace string, kind string, name string) bool {
rules := parseWorkloadIgnoreRules(entries)
return workloadIgnored(rules, namespace, kind, name)
}
// TestHookReadyConditionIndex runs one orchestration or CLI step.
// Signature: TestHookReadyConditionIndex(types []string) int.
// Why: exposes ready-condition selector helper to top-level tests.
func TestHookReadyConditionIndex(types []string) int {
conditions := make([]fluxCondition, 0, len(types))
for _, t := range types {
conditions = append(conditions, fluxCondition{Type: t})
}
cond := readyCondition(conditions)
if cond == nil {
return -1
}
for i := range conditions {
if &conditions[i] == cond {
return i
}
}
return -1
}
// TestHookJoinLimited runs one orchestration or CLI step.
// Signature: TestHookJoinLimited(items []string, limit int) string.
// Why: exposes compact list formatter helper to top-level tests.
func TestHookJoinLimited(items []string, limit int) string {
return joinLimited(items, limit)
}
// TestHookNamespaceCandidatesFromIgnoreKustomizations runs one orchestration or CLI step.
// Signature: TestHookNamespaceCandidatesFromIgnoreKustomizations(entries []string) []string.
// Why: exposes namespace-candidate helper to top-level tests.
func TestHookNamespaceCandidatesFromIgnoreKustomizations(entries []string) []string {
set := namespaceCandidatesFromIgnoreKustomizations(entries)
out := make([]string, 0, len(set))
for ns := range set {
out = append(out, ns)
}
sort.Strings(out)
return out
}

View File

@ -1,849 +0,0 @@
package config
import (
"fmt"
neturl "net/url"
"os"
"strings"
"gopkg.in/yaml.v3"
)
type Config struct {
Kubeconfig string `yaml:"kubeconfig"`
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"`
SSHJumpHost string `yaml:"ssh_jump_host"`
SSHJumpUser string `yaml:"ssh_jump_user"`
IACRepoPath string `yaml:"iac_repo_path"`
ExpectedFluxBranch string `yaml:"expected_flux_branch"`
ExpectedFluxSource string `yaml:"expected_flux_source_url"`
ControlPlanes []string `yaml:"control_planes"`
Workers []string `yaml:"workers"`
LocalBootstrapPaths []string `yaml:"local_bootstrap_paths"`
ExcludedNamespaces []string `yaml:"excluded_namespaces"`
Startup Startup `yaml:"startup"`
Shutdown Shutdown `yaml:"shutdown"`
UPS UPS `yaml:"ups"`
Coordination Coordination `yaml:"coordination"`
Metrics Metrics `yaml:"metrics"`
State State `yaml:"state"`
}
type Startup struct {
APIWaitSeconds int `yaml:"api_wait_seconds"`
APIPollSeconds int `yaml:"api_poll_seconds"`
ShutdownCooldownSeconds int `yaml:"shutdown_cooldown_seconds"`
MinimumBatteryPercent float64 `yaml:"minimum_battery_percent"`
RequiredNodeLabels map[string]map[string]string `yaml:"required_node_labels"`
RequireTimeSync bool `yaml:"require_time_sync"`
TimeSyncWaitSeconds int `yaml:"time_sync_wait_seconds"`
TimeSyncPollSeconds int `yaml:"time_sync_poll_seconds"`
TimeSyncMode string `yaml:"time_sync_mode"`
TimeSyncQuorum int `yaml:"time_sync_quorum"`
ReconcileAccessOnBoot bool `yaml:"reconcile_access_on_boot"`
AutoEtcdRestoreOnAPIFailure bool `yaml:"auto_etcd_restore_on_api_failure"`
EtcdRestoreControlPlane string `yaml:"etcd_restore_control_plane"`
RequireStorageReady bool `yaml:"require_storage_ready"`
StorageReadyWaitSeconds int `yaml:"storage_ready_wait_seconds"`
StorageReadyPollSeconds int `yaml:"storage_ready_poll_seconds"`
StorageMinReadyNodes int `yaml:"storage_min_ready_nodes"`
StorageCriticalPVCs []string `yaml:"storage_critical_pvcs"`
RequirePostStartProbes bool `yaml:"require_post_start_probes"`
PostStartProbeWaitSeconds int `yaml:"post_start_probe_wait_seconds"`
PostStartProbePollSeconds int `yaml:"post_start_probe_poll_seconds"`
PostStartProbes []string `yaml:"post_start_probes"`
RequireServiceChecklist bool `yaml:"require_service_checklist"`
ServiceChecklistWaitSeconds int `yaml:"service_checklist_wait_seconds"`
ServiceChecklistPollSeconds int `yaml:"service_checklist_poll_seconds"`
ServiceChecklistStabilitySec int `yaml:"service_checklist_stability_seconds"`
ServiceChecklist []ServiceChecklistCheck `yaml:"service_checklist"`
RequireIngressChecklist bool `yaml:"require_ingress_checklist"`
IngressChecklistWaitSeconds int `yaml:"ingress_checklist_wait_seconds"`
IngressChecklistPollSeconds int `yaml:"ingress_checklist_poll_seconds"`
IngressChecklistAccepted []int `yaml:"ingress_checklist_accepted_statuses"`
IngressChecklistIgnoreHosts []string `yaml:"ingress_checklist_ignore_hosts"`
IngressChecklistInsecureSkip bool `yaml:"ingress_checklist_insecure_skip_tls"`
RequireNodeSSHAuth bool `yaml:"require_node_ssh_auth"`
NodeSSHAuthWaitSeconds int `yaml:"node_ssh_auth_wait_seconds"`
NodeSSHAuthPollSeconds int `yaml:"node_ssh_auth_poll_seconds"`
RequireFluxHealth bool `yaml:"require_flux_health"`
FluxHealthWaitSeconds int `yaml:"flux_health_wait_seconds"`
FluxHealthPollSeconds int `yaml:"flux_health_poll_seconds"`
IgnoreFluxKustomizations []string `yaml:"ignore_flux_kustomizations"`
RequireWorkloadConvergence bool `yaml:"require_workload_convergence"`
WorkloadConvergenceWaitSeconds int `yaml:"workload_convergence_wait_seconds"`
WorkloadConvergencePollSeconds int `yaml:"workload_convergence_poll_seconds"`
IgnoreWorkloadNamespaces []string `yaml:"ignore_workload_namespaces"`
IgnoreWorkloads []string `yaml:"ignore_workloads"`
IgnoreUnavailableNodes []string `yaml:"ignore_unavailable_nodes"`
AutoRecycleStuckPods bool `yaml:"auto_recycle_stuck_pods"`
StuckPodGraceSeconds int `yaml:"stuck_pod_grace_seconds"`
VaultUnsealKeyFile string `yaml:"vault_unseal_key_file"`
VaultUnsealBreakglassCommand string `yaml:"vault_unseal_breakglass_command"`
VaultUnsealBreakglassTimeout int `yaml:"vault_unseal_breakglass_timeout_seconds"`
}
type ServiceChecklistCheck struct {
Name string `yaml:"name"`
URL string `yaml:"url"`
AcceptedStatuses []int `yaml:"accepted_statuses"`
BodyContains string `yaml:"body_contains"`
BodyNotContains string `yaml:"body_not_contains"`
TimeoutSeconds int `yaml:"timeout_seconds"`
InsecureSkipTLS bool `yaml:"insecure_skip_tls"`
}
type Shutdown struct {
DefaultBudgetSeconds int `yaml:"default_budget_seconds"`
HistoryMinSamples int `yaml:"history_min_samples"`
EmergencyBudgetSec int `yaml:"emergency_budget_seconds"`
EmergencyMinSamples int `yaml:"emergency_history_min_samples"`
EmergencySkipEtcd bool `yaml:"emergency_skip_etcd_snapshot"`
EmergencySkipDrain bool `yaml:"emergency_skip_drain"`
SkipEtcdSnapshot bool `yaml:"skip_etcd_snapshot"`
SkipDrain bool `yaml:"skip_drain"`
DrainParallelism int `yaml:"drain_parallelism"`
ScaleParallelism int `yaml:"scale_parallelism"`
SSHParallelism int `yaml:"ssh_parallelism"`
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"`
PeerHosts []string `yaml:"peer_hosts"`
FallbackLocalShutdown bool `yaml:"fallback_local_shutdown"`
CommandTimeoutSeconds int `yaml:"command_timeout_seconds"`
StartupGuardMaxAgeSec int `yaml:"startup_guard_max_age_seconds"`
Role string `yaml:"role"`
AllowStartupOnBattery bool `yaml:"allow_startup_on_battery"`
}
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"`
IntentPath string `yaml:"intent_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.ExpectedFluxSource == "" {
return fmt.Errorf("config.expected_flux_source_url 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.Shutdown.HistoryMinSamples <= 0 {
return fmt.Errorf("config.shutdown.history_min_samples must be > 0")
}
if c.Shutdown.EmergencyBudgetSec <= 0 {
return fmt.Errorf("config.shutdown.emergency_budget_seconds must be > 0")
}
if c.Shutdown.EmergencyMinSamples <= 0 {
return fmt.Errorf("config.shutdown.emergency_history_min_samples must be > 0")
}
if c.Shutdown.DrainParallelism <= 0 {
return fmt.Errorf("config.shutdown.drain_parallelism must be > 0")
}
if c.Shutdown.ScaleParallelism <= 0 {
return fmt.Errorf("config.shutdown.scale_parallelism must be > 0")
}
if c.Shutdown.SSHParallelism <= 0 {
return fmt.Errorf("config.shutdown.ssh_parallelism must be > 0")
}
if c.Startup.APIWaitSeconds <= 0 {
return fmt.Errorf("config.startup.api_wait_seconds must be > 0")
}
if c.Startup.APIPollSeconds <= 0 {
return fmt.Errorf("config.startup.api_poll_seconds must be > 0")
}
if c.Startup.ShutdownCooldownSeconds <= 0 {
return fmt.Errorf("config.startup.shutdown_cooldown_seconds must be > 0")
}
if c.Startup.MinimumBatteryPercent < 0 || c.Startup.MinimumBatteryPercent > 100 {
return fmt.Errorf("config.startup.minimum_battery_percent must be between 0 and 100")
}
for node, labels := range c.Startup.RequiredNodeLabels {
if strings.TrimSpace(node) == "" {
return fmt.Errorf("config.startup.required_node_labels keys must not be empty")
}
if len(labels) == 0 {
return fmt.Errorf("config.startup.required_node_labels[%q] must include at least one label", node)
}
for key, value := range labels {
if strings.TrimSpace(key) == "" {
return fmt.Errorf("config.startup.required_node_labels[%q] contains empty label key", node)
}
if strings.TrimSpace(value) == "" {
return fmt.Errorf("config.startup.required_node_labels[%q][%q] must not be empty", node, key)
}
}
}
if c.Startup.TimeSyncWaitSeconds <= 0 {
return fmt.Errorf("config.startup.time_sync_wait_seconds must be > 0")
}
if c.Startup.TimeSyncPollSeconds <= 0 {
return fmt.Errorf("config.startup.time_sync_poll_seconds must be > 0")
}
if c.Startup.TimeSyncMode != "strict" && c.Startup.TimeSyncMode != "quorum" {
return fmt.Errorf("config.startup.time_sync_mode must be strict or quorum")
}
if c.Startup.TimeSyncMode == "quorum" && c.Startup.TimeSyncQuorum <= 0 {
return fmt.Errorf("config.startup.time_sync_quorum must be > 0 when time_sync_mode=quorum")
}
if c.Startup.EtcdRestoreControlPlane != "" {
found := false
for _, cp := range c.ControlPlanes {
if cp == c.Startup.EtcdRestoreControlPlane {
found = true
break
}
}
if !found {
return fmt.Errorf("config.startup.etcd_restore_control_plane must be one of config.control_planes when set")
}
}
if c.Startup.StorageReadyWaitSeconds <= 0 {
return fmt.Errorf("config.startup.storage_ready_wait_seconds must be > 0")
}
if c.Startup.StorageReadyPollSeconds <= 0 {
return fmt.Errorf("config.startup.storage_ready_poll_seconds must be > 0")
}
if c.Startup.StorageMinReadyNodes <= 0 {
return fmt.Errorf("config.startup.storage_min_ready_nodes must be > 0")
}
for _, pvc := range c.Startup.StorageCriticalPVCs {
if strings.Count(strings.TrimSpace(pvc), "/") != 1 {
return fmt.Errorf("config.startup.storage_critical_pvcs entries must be namespace/name, got %q", pvc)
}
}
if c.Startup.PostStartProbeWaitSeconds <= 0 {
return fmt.Errorf("config.startup.post_start_probe_wait_seconds must be > 0")
}
if c.Startup.PostStartProbePollSeconds <= 0 {
return fmt.Errorf("config.startup.post_start_probe_poll_seconds must be > 0")
}
if c.Startup.RequirePostStartProbes && len(c.Startup.PostStartProbes) == 0 {
return fmt.Errorf("config.startup.post_start_probes must not be empty when require_post_start_probes is true")
}
if c.Startup.ServiceChecklistWaitSeconds <= 0 {
return fmt.Errorf("config.startup.service_checklist_wait_seconds must be > 0")
}
if c.Startup.ServiceChecklistPollSeconds <= 0 {
return fmt.Errorf("config.startup.service_checklist_poll_seconds must be > 0")
}
if c.Startup.ServiceChecklistStabilitySec < 0 {
return fmt.Errorf("config.startup.service_checklist_stability_seconds must be >= 0")
}
if c.Startup.RequireServiceChecklist && len(c.Startup.ServiceChecklist) == 0 {
return fmt.Errorf("config.startup.service_checklist must not be empty when require_service_checklist is true")
}
for i, check := range c.Startup.ServiceChecklist {
if strings.TrimSpace(check.Name) == "" {
return fmt.Errorf("config.startup.service_checklist[%d].name must not be empty", i)
}
rawURL := strings.TrimSpace(check.URL)
if rawURL == "" {
return fmt.Errorf("config.startup.service_checklist[%d].url must not be empty", i)
}
parsed, err := neturl.Parse(rawURL)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return fmt.Errorf("config.startup.service_checklist[%d].url is invalid: %q", i, rawURL)
}
if check.TimeoutSeconds <= 0 {
return fmt.Errorf("config.startup.service_checklist[%d].timeout_seconds must be > 0", i)
}
for _, code := range check.AcceptedStatuses {
if code < 100 || code > 599 {
return fmt.Errorf("config.startup.service_checklist[%d].accepted_statuses contains invalid HTTP code %d", i, code)
}
}
}
if c.Startup.IngressChecklistWaitSeconds <= 0 {
return fmt.Errorf("config.startup.ingress_checklist_wait_seconds must be > 0")
}
if c.Startup.IngressChecklistPollSeconds <= 0 {
return fmt.Errorf("config.startup.ingress_checklist_poll_seconds must be > 0")
}
for _, code := range c.Startup.IngressChecklistAccepted {
if code < 100 || code > 599 {
return fmt.Errorf("config.startup.ingress_checklist_accepted_statuses contains invalid HTTP code %d", code)
}
}
for _, host := range c.Startup.IngressChecklistIgnoreHosts {
if strings.TrimSpace(host) == "" {
return fmt.Errorf("config.startup.ingress_checklist_ignore_hosts entries must not be empty")
}
}
if c.Startup.NodeSSHAuthWaitSeconds <= 0 {
return fmt.Errorf("config.startup.node_ssh_auth_wait_seconds must be > 0")
}
if c.Startup.NodeSSHAuthPollSeconds <= 0 {
return fmt.Errorf("config.startup.node_ssh_auth_poll_seconds must be > 0")
}
if c.Startup.FluxHealthWaitSeconds <= 0 {
return fmt.Errorf("config.startup.flux_health_wait_seconds must be > 0")
}
if c.Startup.FluxHealthPollSeconds <= 0 {
return fmt.Errorf("config.startup.flux_health_poll_seconds must be > 0")
}
if c.Startup.WorkloadConvergenceWaitSeconds <= 0 {
return fmt.Errorf("config.startup.workload_convergence_wait_seconds must be > 0")
}
if c.Startup.WorkloadConvergencePollSeconds <= 0 {
return fmt.Errorf("config.startup.workload_convergence_poll_seconds must be > 0")
}
if c.Startup.StuckPodGraceSeconds <= 0 {
return fmt.Errorf("config.startup.stuck_pod_grace_seconds must be > 0")
}
for _, probe := range c.Startup.PostStartProbes {
if strings.TrimSpace(probe) == "" {
return fmt.Errorf("config.startup.post_start_probes entries must not be empty")
}
}
for _, item := range c.Startup.IgnoreFluxKustomizations {
item = strings.TrimSpace(item)
if item == "" {
return fmt.Errorf("config.startup.ignore_flux_kustomizations entries must not be empty")
}
if strings.Count(item, "/") != 1 {
return fmt.Errorf("config.startup.ignore_flux_kustomizations entries must be namespace/name, got %q", item)
}
}
for _, item := range c.Startup.IgnoreWorkloads {
item = strings.TrimSpace(item)
if item == "" {
return fmt.Errorf("config.startup.ignore_workloads entries must not be empty")
}
parts := strings.Split(item, "/")
if len(parts) != 2 && len(parts) != 3 {
return fmt.Errorf("config.startup.ignore_workloads entries must be namespace/name or namespace/kind/name, got %q", item)
}
}
for _, ns := range c.Startup.IgnoreWorkloadNamespaces {
if strings.TrimSpace(ns) == "" {
return fmt.Errorf("config.startup.ignore_workload_namespaces entries must not be empty")
}
}
for _, node := range c.Startup.IgnoreUnavailableNodes {
if strings.TrimSpace(node) == "" {
return fmt.Errorf("config.startup.ignore_unavailable_nodes entries must not be empty")
}
}
if strings.TrimSpace(c.Startup.VaultUnsealKeyFile) == "" {
return fmt.Errorf("config.startup.vault_unseal_key_file must not be empty")
}
if c.SSHPort <= 0 || c.SSHPort > 65535 {
return fmt.Errorf("config.ssh_port must be in range 1-65535")
}
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")
}
}
for _, peer := range c.Coordination.PeerHosts {
if strings.TrimSpace(peer) == "" {
return fmt.Errorf("config.coordination.peer_hosts entries must not be empty")
}
}
if c.Coordination.StartupGuardMaxAgeSec <= 0 {
return fmt.Errorf("config.coordination.startup_guard_max_age_seconds must be > 0")
}
if c.Coordination.Role != "coordinator" && c.Coordination.Role != "peer" {
return fmt.Errorf("config.coordination.role must be coordinator or peer")
}
if c.State.RunHistoryPath == "" || c.State.LockPath == "" {
return fmt.Errorf("config.state.run_history_path and config.state.lock_path must not be empty")
}
if c.State.IntentPath == "" {
return fmt.Errorf("config.state.intent_path must not be empty")
}
return nil
}
func defaults() Config {
c := Config{
IACRepoPath: "/opt/titan-iac",
ExpectedFluxBranch: "main",
ExpectedFluxSource: "ssh://git@scm.bstein.dev:2242/bstein/titan-iac.git",
SSHPort: 2277,
ControlPlanes: []string{"titan-0a", "titan-0b", "titan-0c"},
LocalBootstrapPaths: []string{
"infrastructure/core",
"clusters/atlas/flux-system",
"infrastructure/sources/helm",
"infrastructure/metallb",
"infrastructure/traefik",
"infrastructure/cert-manager",
"infrastructure/vault-csi",
"infrastructure/vault-injector",
"services/vault",
"infrastructure/postgres",
"services/gitea",
"services/keycloak",
"services/oauth2-proxy",
},
ExcludedNamespaces: []string{
"kube-system",
"kube-public",
"kube-node-lease",
"flux-system",
"traefik",
"metallb-system",
"cert-manager",
"longhorn-system",
"vault",
"postgres",
"maintenance",
},
Startup: Startup{
APIWaitSeconds: 1200,
APIPollSeconds: 2,
ShutdownCooldownSeconds: 45,
RequireTimeSync: true,
TimeSyncWaitSeconds: 240,
TimeSyncPollSeconds: 5,
TimeSyncMode: "quorum",
TimeSyncQuorum: 2,
ReconcileAccessOnBoot: true,
AutoEtcdRestoreOnAPIFailure: true,
EtcdRestoreControlPlane: "titan-0a",
RequireStorageReady: true,
StorageReadyWaitSeconds: 420,
StorageReadyPollSeconds: 5,
StorageMinReadyNodes: 2,
StorageCriticalPVCs: []string{
"vault/data-vault-0",
"postgres/postgres-data-postgres-0",
"gitea/gitea-data",
"sso/keycloak-data",
},
MinimumBatteryPercent: 20,
RequiredNodeLabels: map[string]map[string]string{
"titan-09": {
"ananke.bstein.dev/harbor-bootstrap": "true",
},
},
RequirePostStartProbes: true,
PostStartProbeWaitSeconds: 240,
PostStartProbePollSeconds: 5,
PostStartProbes: []string{
"https://sso.bstein.dev/realms/atlas/.well-known/openid-configuration",
"https://scm.bstein.dev/api/healthz",
"https://metrics.bstein.dev/api/health",
},
RequireServiceChecklist: true,
ServiceChecklistWaitSeconds: 420,
ServiceChecklistPollSeconds: 5,
ServiceChecklistStabilitySec: 120,
ServiceChecklist: []ServiceChecklistCheck{
{
Name: "gitea-api",
URL: "https://scm.bstein.dev/api/healthz",
AcceptedStatuses: []int{200},
BodyContains: "pass",
TimeoutSeconds: 12,
},
{
Name: "grafana-api",
URL: "https://metrics.bstein.dev/api/health",
AcceptedStatuses: []int{200},
BodyContains: "\"database\":\"ok\"",
TimeoutSeconds: 12,
},
{
Name: "keycloak-oidc",
URL: "https://sso.bstein.dev/realms/atlas/.well-known/openid-configuration",
AcceptedStatuses: []int{200},
BodyContains: "\"issuer\":\"https://sso.bstein.dev/realms/atlas\"",
TimeoutSeconds: 12,
},
{
Name: "harbor-registry",
URL: "https://registry.bstein.dev/v2/",
AcceptedStatuses: []int{401},
BodyContains: "unauthorized",
TimeoutSeconds: 12,
},
{
Name: "longhorn-auth",
URL: "https://longhorn.bstein.dev/",
AcceptedStatuses: []int{200, 302},
TimeoutSeconds: 12,
},
},
RequireIngressChecklist: true,
IngressChecklistWaitSeconds: 420,
IngressChecklistPollSeconds: 5,
IngressChecklistAccepted: []int{200, 301, 302, 307, 308, 401, 403, 404},
IngressChecklistIgnoreHosts: []string{},
RequireNodeSSHAuth: true,
NodeSSHAuthWaitSeconds: 240,
NodeSSHAuthPollSeconds: 5,
RequireFluxHealth: true,
FluxHealthWaitSeconds: 900,
FluxHealthPollSeconds: 5,
IgnoreFluxKustomizations: []string{},
RequireWorkloadConvergence: true,
WorkloadConvergenceWaitSeconds: 900,
WorkloadConvergencePollSeconds: 5,
IgnoreWorkloadNamespaces: []string{},
IgnoreWorkloads: []string{},
IgnoreUnavailableNodes: []string{},
AutoRecycleStuckPods: true,
StuckPodGraceSeconds: 180,
VaultUnsealKeyFile: "/var/lib/ananke/vault-unseal.key",
VaultUnsealBreakglassTimeout: 15,
},
Shutdown: Shutdown{
DefaultBudgetSeconds: 1380,
HistoryMinSamples: 3,
EmergencyBudgetSec: 420,
EmergencyMinSamples: 3,
EmergencySkipEtcd: true,
EmergencySkipDrain: true,
DrainParallelism: 6,
ScaleParallelism: 8,
SSHParallelism: 8,
PoweroffEnabled: false,
PoweroffDelaySeconds: 25,
PoweroffLocalHost: false,
},
UPS: UPS{
Enabled: true,
Provider: "nut",
PollSeconds: 5,
RuntimeSafetyFactor: 1.25,
DebounceCount: 3,
TelemetryTimeoutSeconds: 90,
},
Coordination: Coordination{
ForwardShutdownConfig: "/etc/ananke/ananke.yaml",
PeerHosts: []string{},
FallbackLocalShutdown: true,
CommandTimeoutSeconds: 25,
StartupGuardMaxAgeSec: 900,
Role: "coordinator",
AllowStartupOnBattery: false,
},
Metrics: Metrics{
Enabled: true,
BindAddr: "0.0.0.0:9560",
Path: "/metrics",
},
State: State{
Dir: "/var/lib/ananke",
RunHistoryPath: "/var/lib/ananke/runs.json",
LockPath: "/var/lib/ananke/ananke.lock",
IntentPath: "/var/lib/ananke/intent.json",
},
}
c.applyDefaults()
return c
}
func (c *Config) applyDefaults() {
if c.ExpectedFluxBranch == "" {
c.ExpectedFluxBranch = "main"
}
if c.IACRepoPath == "" {
c.IACRepoPath = "/opt/titan-iac"
}
if c.ExpectedFluxSource == "" {
c.ExpectedFluxSource = "ssh://git@scm.bstein.dev:2242/bstein/titan-iac.git"
}
if c.Startup.APIWaitSeconds <= 0 {
c.Startup.APIWaitSeconds = 1200
}
if c.Startup.APIPollSeconds <= 0 {
c.Startup.APIPollSeconds = 2
}
if c.Startup.ShutdownCooldownSeconds <= 0 {
c.Startup.ShutdownCooldownSeconds = 45
}
if c.Startup.MinimumBatteryPercent <= 0 {
c.Startup.MinimumBatteryPercent = 20
}
if c.Startup.RequiredNodeLabels == nil {
c.Startup.RequiredNodeLabels = map[string]map[string]string{
"titan-09": {
"ananke.bstein.dev/harbor-bootstrap": "true",
},
}
}
if c.Startup.TimeSyncWaitSeconds <= 0 {
c.Startup.TimeSyncWaitSeconds = 240
}
if c.Startup.TimeSyncPollSeconds <= 0 {
c.Startup.TimeSyncPollSeconds = 5
}
if c.Startup.TimeSyncMode == "" {
c.Startup.TimeSyncMode = "quorum"
}
if c.Startup.TimeSyncQuorum <= 0 {
c.Startup.TimeSyncQuorum = 2
}
if c.Startup.TimeSyncQuorum > len(c.ControlPlanes) && len(c.ControlPlanes) > 0 {
c.Startup.TimeSyncQuorum = len(c.ControlPlanes)
}
if c.Startup.EtcdRestoreControlPlane == "" && len(c.ControlPlanes) > 0 {
c.Startup.EtcdRestoreControlPlane = c.ControlPlanes[0]
}
if c.Startup.StorageReadyWaitSeconds <= 0 {
c.Startup.StorageReadyWaitSeconds = 420
}
if c.Startup.StorageReadyPollSeconds <= 0 {
c.Startup.StorageReadyPollSeconds = 5
}
if c.Startup.StorageMinReadyNodes <= 0 {
c.Startup.StorageMinReadyNodes = 2
}
if len(c.Startup.StorageCriticalPVCs) == 0 {
c.Startup.StorageCriticalPVCs = []string{
"vault/data-vault-0",
"postgres/postgres-data-postgres-0",
"gitea/gitea-data",
"sso/keycloak-data",
}
}
if c.Startup.PostStartProbeWaitSeconds <= 0 {
c.Startup.PostStartProbeWaitSeconds = 240
}
if c.Startup.PostStartProbePollSeconds <= 0 {
c.Startup.PostStartProbePollSeconds = 5
}
if len(c.Startup.PostStartProbes) == 0 {
c.Startup.PostStartProbes = []string{
"https://sso.bstein.dev/realms/atlas/.well-known/openid-configuration",
"https://scm.bstein.dev/api/healthz",
"https://metrics.bstein.dev/api/health",
}
}
if c.Startup.ServiceChecklistWaitSeconds <= 0 {
c.Startup.ServiceChecklistWaitSeconds = 420
}
if c.Startup.ServiceChecklistPollSeconds <= 0 {
c.Startup.ServiceChecklistPollSeconds = 5
}
if c.Startup.ServiceChecklistStabilitySec < 0 {
c.Startup.ServiceChecklistStabilitySec = 0
}
if len(c.Startup.ServiceChecklist) == 0 {
c.Startup.ServiceChecklist = []ServiceChecklistCheck{
{
Name: "gitea-api",
URL: "https://scm.bstein.dev/api/healthz",
AcceptedStatuses: []int{200},
BodyContains: "pass",
TimeoutSeconds: 12,
},
{
Name: "grafana-api",
URL: "https://metrics.bstein.dev/api/health",
AcceptedStatuses: []int{200},
BodyContains: "\"database\":\"ok\"",
TimeoutSeconds: 12,
},
}
}
for i := range c.Startup.ServiceChecklist {
if c.Startup.ServiceChecklist[i].TimeoutSeconds <= 0 {
c.Startup.ServiceChecklist[i].TimeoutSeconds = 12
}
}
if c.Startup.IngressChecklistWaitSeconds <= 0 {
c.Startup.IngressChecklistWaitSeconds = 420
}
if c.Startup.IngressChecklistPollSeconds <= 0 {
c.Startup.IngressChecklistPollSeconds = 5
}
if len(c.Startup.IngressChecklistAccepted) == 0 {
c.Startup.IngressChecklistAccepted = []int{200, 301, 302, 307, 308, 401, 403, 404}
}
if c.Startup.IngressChecklistIgnoreHosts == nil {
c.Startup.IngressChecklistIgnoreHosts = []string{}
}
if c.Startup.NodeSSHAuthWaitSeconds <= 0 {
c.Startup.NodeSSHAuthWaitSeconds = 240
}
if c.Startup.NodeSSHAuthPollSeconds <= 0 {
c.Startup.NodeSSHAuthPollSeconds = 5
}
if c.Startup.FluxHealthWaitSeconds <= 0 {
c.Startup.FluxHealthWaitSeconds = 900
}
if c.Startup.FluxHealthPollSeconds <= 0 {
c.Startup.FluxHealthPollSeconds = 5
}
if c.Startup.IgnoreFluxKustomizations == nil {
c.Startup.IgnoreFluxKustomizations = []string{}
}
if c.Startup.WorkloadConvergenceWaitSeconds <= 0 {
c.Startup.WorkloadConvergenceWaitSeconds = 900
}
if c.Startup.WorkloadConvergencePollSeconds <= 0 {
c.Startup.WorkloadConvergencePollSeconds = 5
}
if c.Startup.IgnoreWorkloadNamespaces == nil {
c.Startup.IgnoreWorkloadNamespaces = []string{}
}
if c.Startup.IgnoreWorkloads == nil {
c.Startup.IgnoreWorkloads = []string{}
}
if c.Startup.IgnoreUnavailableNodes == nil {
c.Startup.IgnoreUnavailableNodes = []string{}
}
if c.Startup.StuckPodGraceSeconds <= 0 {
c.Startup.StuckPodGraceSeconds = 180
}
if strings.TrimSpace(c.Startup.VaultUnsealKeyFile) == "" {
c.Startup.VaultUnsealKeyFile = "/var/lib/ananke/vault-unseal.key"
}
if c.Startup.VaultUnsealBreakglassTimeout <= 0 {
c.Startup.VaultUnsealBreakglassTimeout = 15
}
if c.SSHPort <= 0 {
c.SSHPort = 2277
}
if c.Shutdown.DefaultBudgetSeconds <= 0 {
c.Shutdown.DefaultBudgetSeconds = 1380
}
if c.Shutdown.HistoryMinSamples <= 0 {
c.Shutdown.HistoryMinSamples = 3
}
if c.Shutdown.EmergencyBudgetSec <= 0 {
c.Shutdown.EmergencyBudgetSec = 420
}
if c.Shutdown.EmergencyMinSamples <= 0 {
c.Shutdown.EmergencyMinSamples = 3
}
if c.Shutdown.DrainParallelism <= 0 {
c.Shutdown.DrainParallelism = 6
}
if c.Shutdown.ScaleParallelism <= 0 {
c.Shutdown.ScaleParallelism = 8
}
if c.Shutdown.SSHParallelism <= 0 {
c.Shutdown.SSHParallelism = 8
}
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.25
}
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/ananke/ananke.yaml"
}
if c.Coordination.PeerHosts == nil {
c.Coordination.PeerHosts = []string{}
}
if c.Coordination.CommandTimeoutSeconds <= 0 {
c.Coordination.CommandTimeoutSeconds = 25
}
if c.Coordination.StartupGuardMaxAgeSec <= 0 {
c.Coordination.StartupGuardMaxAgeSec = 900
}
if c.Coordination.Role == "" {
c.Coordination.Role = "coordinator"
}
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/ananke"
}
if c.State.RunHistoryPath == "" {
c.State.RunHistoryPath = "/var/lib/ananke/runs.json"
}
if c.State.LockPath == "" {
c.State.LockPath = "/var/lib/ananke/ananke.lock"
}
if c.State.IntentPath == "" {
c.State.IntentPath = "/var/lib/ananke/intent.json"
}
}

29
internal/config/load.go Normal file
View File

@ -0,0 +1,29 @@
package config
import (
"fmt"
"os"
"gopkg.in/yaml.v3"
)
// 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) {
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
}

View File

@ -0,0 +1,53 @@
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")
}
}

294
internal/config/validate.go Normal file
View File

@ -0,0 +1,294 @@
package config
import (
"fmt"
neturl "net/url"
"strings"
)
// Validate runs one orchestration or CLI step.
// Signature: (c Config) Validate() error.
// Why: keeps behavior explicit so startup/shutdown workflows remain maintainable as services evolve.
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.ExpectedFluxSource == "" {
return fmt.Errorf("config.expected_flux_source_url 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.Shutdown.HistoryMinSamples <= 0 {
return fmt.Errorf("config.shutdown.history_min_samples must be > 0")
}
if c.Shutdown.EmergencyBudgetSec <= 0 {
return fmt.Errorf("config.shutdown.emergency_budget_seconds must be > 0")
}
if c.Shutdown.EmergencyMinSamples <= 0 {
return fmt.Errorf("config.shutdown.emergency_history_min_samples must be > 0")
}
if c.Shutdown.DrainParallelism <= 0 {
return fmt.Errorf("config.shutdown.drain_parallelism must be > 0")
}
if c.Shutdown.ScaleParallelism <= 0 {
return fmt.Errorf("config.shutdown.scale_parallelism must be > 0")
}
if c.Shutdown.SSHParallelism <= 0 {
return fmt.Errorf("config.shutdown.ssh_parallelism must be > 0")
}
if c.Startup.APIWaitSeconds <= 0 {
return fmt.Errorf("config.startup.api_wait_seconds must be > 0")
}
if c.Startup.APIPollSeconds <= 0 {
return fmt.Errorf("config.startup.api_poll_seconds must be > 0")
}
if c.Startup.ShutdownCooldownSeconds <= 0 {
return fmt.Errorf("config.startup.shutdown_cooldown_seconds must be > 0")
}
if c.Startup.MinimumBatteryPercent < 0 || c.Startup.MinimumBatteryPercent > 100 {
return fmt.Errorf("config.startup.minimum_battery_percent must be between 0 and 100")
}
if c.Startup.NodeInventoryReachWaitSeconds <= 0 {
return fmt.Errorf("config.startup.node_inventory_reachability_wait_seconds must be > 0")
}
if c.Startup.NodeInventoryReachPollSeconds <= 0 {
return fmt.Errorf("config.startup.node_inventory_reachability_poll_seconds must be > 0")
}
for node, labels := range c.Startup.RequiredNodeLabels {
if strings.TrimSpace(node) == "" {
return fmt.Errorf("config.startup.required_node_labels keys must not be empty")
}
if len(labels) == 0 {
return fmt.Errorf("config.startup.required_node_labels[%q] must include at least one label", node)
}
for key, value := range labels {
if strings.TrimSpace(key) == "" {
return fmt.Errorf("config.startup.required_node_labels[%q] contains empty label key", node)
}
if strings.TrimSpace(value) == "" {
return fmt.Errorf("config.startup.required_node_labels[%q][%q] must not be empty", node, key)
}
}
}
if c.Startup.TimeSyncWaitSeconds <= 0 {
return fmt.Errorf("config.startup.time_sync_wait_seconds must be > 0")
}
if c.Startup.TimeSyncPollSeconds <= 0 {
return fmt.Errorf("config.startup.time_sync_poll_seconds must be > 0")
}
if c.Startup.TimeSyncMode != "strict" && c.Startup.TimeSyncMode != "quorum" {
return fmt.Errorf("config.startup.time_sync_mode must be strict or quorum")
}
if c.Startup.TimeSyncMode == "quorum" && c.Startup.TimeSyncQuorum <= 0 {
return fmt.Errorf("config.startup.time_sync_quorum must be > 0 when time_sync_mode=quorum")
}
if c.Startup.EtcdRestoreControlPlane != "" {
found := false
for _, cp := range c.ControlPlanes {
if cp == c.Startup.EtcdRestoreControlPlane {
found = true
break
}
}
if !found {
return fmt.Errorf("config.startup.etcd_restore_control_plane must be one of config.control_planes when set")
}
}
if c.Startup.StorageReadyWaitSeconds <= 0 {
return fmt.Errorf("config.startup.storage_ready_wait_seconds must be > 0")
}
if c.Startup.StorageReadyPollSeconds <= 0 {
return fmt.Errorf("config.startup.storage_ready_poll_seconds must be > 0")
}
if c.Startup.StorageMinReadyNodes <= 0 {
return fmt.Errorf("config.startup.storage_min_ready_nodes must be > 0")
}
for _, pvc := range c.Startup.StorageCriticalPVCs {
if strings.Count(strings.TrimSpace(pvc), "/") != 1 {
return fmt.Errorf("config.startup.storage_critical_pvcs entries must be namespace/name, got %q", pvc)
}
}
if c.Startup.PostStartProbeWaitSeconds <= 0 {
return fmt.Errorf("config.startup.post_start_probe_wait_seconds must be > 0")
}
if c.Startup.PostStartProbePollSeconds <= 0 {
return fmt.Errorf("config.startup.post_start_probe_poll_seconds must be > 0")
}
if c.Startup.RequirePostStartProbes && len(c.Startup.PostStartProbes) == 0 {
return fmt.Errorf("config.startup.post_start_probes must not be empty when require_post_start_probes is true")
}
if c.Startup.ServiceChecklistWaitSeconds <= 0 {
return fmt.Errorf("config.startup.service_checklist_wait_seconds must be > 0")
}
if c.Startup.ServiceChecklistPollSeconds <= 0 {
return fmt.Errorf("config.startup.service_checklist_poll_seconds must be > 0")
}
if c.Startup.ServiceChecklistStabilitySec < 0 {
return fmt.Errorf("config.startup.service_checklist_stability_seconds must be >= 0")
}
if c.Startup.RequireServiceChecklist && len(c.Startup.ServiceChecklist) == 0 {
return fmt.Errorf("config.startup.service_checklist must not be empty when require_service_checklist is true")
}
for i, check := range c.Startup.ServiceChecklist {
if strings.TrimSpace(check.Name) == "" {
return fmt.Errorf("config.startup.service_checklist[%d].name must not be empty", i)
}
rawURL := strings.TrimSpace(check.URL)
if rawURL == "" {
return fmt.Errorf("config.startup.service_checklist[%d].url must not be empty", i)
}
parsed, err := neturl.Parse(rawURL)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return fmt.Errorf("config.startup.service_checklist[%d].url is invalid: %q", i, rawURL)
}
if check.TimeoutSeconds <= 0 {
return fmt.Errorf("config.startup.service_checklist[%d].timeout_seconds must be > 0", i)
}
for _, code := range check.AcceptedStatuses {
if code < 100 || code > 599 {
return fmt.Errorf("config.startup.service_checklist[%d].accepted_statuses contains invalid HTTP code %d", i, code)
}
}
}
if c.Startup.CriticalServiceEndpointWaitSec <= 0 {
return fmt.Errorf("config.startup.critical_service_endpoint_wait_seconds must be > 0")
}
if c.Startup.CriticalServiceEndpointPollSec <= 0 {
return fmt.Errorf("config.startup.critical_service_endpoint_poll_seconds must be > 0")
}
if c.Startup.RequireCriticalServiceEndpoints && len(c.Startup.CriticalServiceEndpoints) == 0 {
return fmt.Errorf("config.startup.critical_service_endpoints must not be empty when require_critical_service_endpoints is true")
}
for _, entry := range c.Startup.CriticalServiceEndpoints {
entry = strings.TrimSpace(entry)
if entry == "" {
return fmt.Errorf("config.startup.critical_service_endpoints entries must not be empty")
}
if strings.Count(entry, "/") != 1 {
return fmt.Errorf("config.startup.critical_service_endpoints entries must be namespace/service, got %q", entry)
}
}
if c.Startup.IngressChecklistWaitSeconds <= 0 {
return fmt.Errorf("config.startup.ingress_checklist_wait_seconds must be > 0")
}
if c.Startup.IngressChecklistPollSeconds <= 0 {
return fmt.Errorf("config.startup.ingress_checklist_poll_seconds must be > 0")
}
for _, code := range c.Startup.IngressChecklistAccepted {
if code < 100 || code > 599 {
return fmt.Errorf("config.startup.ingress_checklist_accepted_statuses contains invalid HTTP code %d", code)
}
}
for _, host := range c.Startup.IngressChecklistIgnoreHosts {
if strings.TrimSpace(host) == "" {
return fmt.Errorf("config.startup.ingress_checklist_ignore_hosts entries must not be empty")
}
}
if c.Startup.NodeSSHAuthWaitSeconds <= 0 {
return fmt.Errorf("config.startup.node_ssh_auth_wait_seconds must be > 0")
}
if c.Startup.NodeSSHAuthPollSeconds <= 0 {
return fmt.Errorf("config.startup.node_ssh_auth_poll_seconds must be > 0")
}
if c.Startup.FluxHealthWaitSeconds <= 0 {
return fmt.Errorf("config.startup.flux_health_wait_seconds must be > 0")
}
if c.Startup.FluxHealthPollSeconds <= 0 {
return fmt.Errorf("config.startup.flux_health_poll_seconds must be > 0")
}
if c.Startup.WorkloadConvergenceWaitSeconds <= 0 {
return fmt.Errorf("config.startup.workload_convergence_wait_seconds must be > 0")
}
if c.Startup.WorkloadConvergencePollSeconds <= 0 {
return fmt.Errorf("config.startup.workload_convergence_poll_seconds must be > 0")
}
if c.Startup.StuckPodGraceSeconds <= 0 {
return fmt.Errorf("config.startup.stuck_pod_grace_seconds must be > 0")
}
for _, probe := range c.Startup.PostStartProbes {
if strings.TrimSpace(probe) == "" {
return fmt.Errorf("config.startup.post_start_probes entries must not be empty")
}
}
for _, item := range c.Startup.IgnoreFluxKustomizations {
item = strings.TrimSpace(item)
if item == "" {
return fmt.Errorf("config.startup.ignore_flux_kustomizations entries must not be empty")
}
if strings.Count(item, "/") != 1 {
return fmt.Errorf("config.startup.ignore_flux_kustomizations entries must be namespace/name, got %q", item)
}
}
for _, item := range c.Startup.IgnoreWorkloads {
item = strings.TrimSpace(item)
if item == "" {
return fmt.Errorf("config.startup.ignore_workloads entries must not be empty")
}
parts := strings.Split(item, "/")
if len(parts) != 2 && len(parts) != 3 {
return fmt.Errorf("config.startup.ignore_workloads entries must be namespace/name or namespace/kind/name, got %q", item)
}
}
for _, ns := range c.Startup.IgnoreWorkloadNamespaces {
if strings.TrimSpace(ns) == "" {
return fmt.Errorf("config.startup.ignore_workload_namespaces entries must not be empty")
}
}
for _, node := range c.Startup.IgnoreUnavailableNodes {
if strings.TrimSpace(node) == "" {
return fmt.Errorf("config.startup.ignore_unavailable_nodes entries must not be empty")
}
}
if strings.TrimSpace(c.Startup.VaultUnsealKeyFile) == "" {
return fmt.Errorf("config.startup.vault_unseal_key_file must not be empty")
}
if c.SSHPort <= 0 || c.SSHPort > 65535 {
return fmt.Errorf("config.ssh_port must be in range 1-65535")
}
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")
}
}
for _, peer := range c.Coordination.PeerHosts {
if strings.TrimSpace(peer) == "" {
return fmt.Errorf("config.coordination.peer_hosts entries must not be empty")
}
}
if c.Coordination.StartupGuardMaxAgeSec <= 0 {
return fmt.Errorf("config.coordination.startup_guard_max_age_seconds must be > 0")
}
if c.Coordination.Role != "coordinator" && c.Coordination.Role != "peer" {
return fmt.Errorf("config.coordination.role must be coordinator or peer")
}
if c.State.ReportsDir == "" {
return fmt.Errorf("config.state.reports_dir must not be empty")
}
if c.State.RunHistoryPath == "" || c.State.LockPath == "" {
return fmt.Errorf("config.state.run_history_path and config.state.lock_path must not be empty")
}
if c.State.IntentPath == "" {
return fmt.Errorf("config.state.intent_path must not be empty")
}
return nil
}

View File

@ -0,0 +1,147 @@
package config
import "testing"
// TestValidateRejectsInvalidFieldsMatrix runs one orchestration or CLI step.
// Signature: TestValidateRejectsInvalidFieldsMatrix(t *testing.T).
// Why: exercises validation branches so every config guardrail is test-covered.
func TestValidateRejectsInvalidFieldsMatrix(t *testing.T) {
base := defaults()
base.UPS.Target = "pyrphoros@localhost"
if err := base.Validate(); err != nil {
t.Fatalf("test baseline config must validate: %v", err)
}
cases := []struct {
name string
mutate func(*Config)
}{
{"empty_control_planes", func(c *Config) { c.ControlPlanes = nil }},
{"empty_expected_flux_branch", func(c *Config) { c.ExpectedFluxBranch = "" }},
{"empty_expected_flux_source", func(c *Config) { c.ExpectedFluxSource = "" }},
{"empty_iac_repo_path", func(c *Config) { c.IACRepoPath = "" }},
{"bad_shutdown_budget", func(c *Config) { c.Shutdown.DefaultBudgetSeconds = 0 }},
{"bad_shutdown_history_samples", func(c *Config) { c.Shutdown.HistoryMinSamples = 0 }},
{"bad_shutdown_emergency_budget", func(c *Config) { c.Shutdown.EmergencyBudgetSec = 0 }},
{"bad_shutdown_emergency_samples", func(c *Config) { c.Shutdown.EmergencyMinSamples = 0 }},
{"bad_drain_parallelism", func(c *Config) { c.Shutdown.DrainParallelism = 0 }},
{"bad_scale_parallelism", func(c *Config) { c.Shutdown.ScaleParallelism = 0 }},
{"bad_ssh_parallelism", func(c *Config) { c.Shutdown.SSHParallelism = 0 }},
{"bad_api_wait", func(c *Config) { c.Startup.APIWaitSeconds = 0 }},
{"bad_api_poll", func(c *Config) { c.Startup.APIPollSeconds = 0 }},
{"bad_min_battery_percent", func(c *Config) { c.Startup.MinimumBatteryPercent = 101 }},
{"bad_node_inventory_poll", func(c *Config) { c.Startup.NodeInventoryReachPollSeconds = 0 }},
{"bad_time_sync_wait", func(c *Config) { c.Startup.TimeSyncWaitSeconds = 0 }},
{"bad_time_sync_poll", func(c *Config) { c.Startup.TimeSyncPollSeconds = 0 }},
{"bad_time_sync_quorum", func(c *Config) { c.Startup.TimeSyncMode = "quorum"; c.Startup.TimeSyncQuorum = 0 }},
{"bad_storage_wait", func(c *Config) { c.Startup.StorageReadyWaitSeconds = 0 }},
{"bad_storage_poll", func(c *Config) { c.Startup.StorageReadyPollSeconds = 0 }},
{"bad_storage_min_ready_nodes", func(c *Config) { c.Startup.StorageMinReadyNodes = 0 }},
{"bad_post_start_wait", func(c *Config) { c.Startup.PostStartProbeWaitSeconds = 0 }},
{"bad_post_start_poll", func(c *Config) { c.Startup.PostStartProbePollSeconds = 0 }},
{"bad_service_checklist_wait", func(c *Config) { c.Startup.ServiceChecklistWaitSeconds = 0 }},
{"bad_service_checklist_poll", func(c *Config) { c.Startup.ServiceChecklistPollSeconds = 0 }},
{"bad_service_checklist_stability", func(c *Config) { c.Startup.ServiceChecklistStabilitySec = -1 }},
{"bad_service_checklist_item_name", func(c *Config) {
c.Startup.ServiceChecklist = []ServiceChecklistCheck{{Name: "", URL: "https://ok", TimeoutSeconds: 5}}
}},
{"bad_service_checklist_item_timeout", func(c *Config) {
c.Startup.ServiceChecklist = []ServiceChecklistCheck{{Name: "x", URL: "https://ok", TimeoutSeconds: 0}}
}},
{"bad_service_checklist_http_code", func(c *Config) {
c.Startup.ServiceChecklist = []ServiceChecklistCheck{{Name: "x", URL: "https://ok", TimeoutSeconds: 5, AcceptedStatuses: []int{999}}}
}},
{"bad_critical_endpoint_wait", func(c *Config) { c.Startup.CriticalServiceEndpointWaitSec = 0 }},
{"bad_critical_endpoint_poll", func(c *Config) { c.Startup.CriticalServiceEndpointPollSec = 0 }},
{"bad_empty_critical_endpoints_when_required", func(c *Config) {
c.Startup.RequireCriticalServiceEndpoints = true
c.Startup.CriticalServiceEndpoints = nil
}},
{"bad_empty_critical_endpoint_entry", func(c *Config) {
c.Startup.CriticalServiceEndpoints = []string{"", "monitoring/victoria-metrics-single-server"}
}},
{"bad_malformed_critical_endpoint_entry", func(c *Config) {
c.Startup.CriticalServiceEndpoints = []string{"monitoring-victoria-metrics-single-server"}
}},
{"bad_ingress_wait", func(c *Config) { c.Startup.IngressChecklistWaitSeconds = 0 }},
{"bad_ingress_poll", func(c *Config) { c.Startup.IngressChecklistPollSeconds = 0 }},
{"bad_ingress_http_code", func(c *Config) { c.Startup.IngressChecklistAccepted = []int{700} }},
{"bad_ingress_ignore_entry", func(c *Config) { c.Startup.IngressChecklistIgnoreHosts = []string{"", "grafana.bstein.dev"} }},
{"bad_node_ssh_wait", func(c *Config) { c.Startup.NodeSSHAuthWaitSeconds = 0 }},
{"bad_node_ssh_poll", func(c *Config) { c.Startup.NodeSSHAuthPollSeconds = 0 }},
{"bad_flux_wait", func(c *Config) { c.Startup.FluxHealthWaitSeconds = 0 }},
{"bad_flux_poll", func(c *Config) { c.Startup.FluxHealthPollSeconds = 0 }},
{"bad_workload_wait", func(c *Config) { c.Startup.WorkloadConvergenceWaitSeconds = 0 }},
{"bad_workload_poll", func(c *Config) { c.Startup.WorkloadConvergencePollSeconds = 0 }},
{"bad_stuck_pod_grace", func(c *Config) { c.Startup.StuckPodGraceSeconds = 0 }},
{"bad_empty_post_start_probe_entry", func(c *Config) { c.Startup.PostStartProbes = []string{"https://ok", ""} }},
{"bad_empty_ignore_flux_entry", func(c *Config) { c.Startup.IgnoreFluxKustomizations = []string{"", "ns/name"} }},
{"bad_empty_ignore_workloads_entry", func(c *Config) { c.Startup.IgnoreWorkloads = []string{"", "ns/name"} }},
{"bad_empty_ignore_workload_namespaces_entry", func(c *Config) { c.Startup.IgnoreWorkloadNamespaces = []string{"", "vault"} }},
{"bad_empty_ignore_unavailable_nodes_entry", func(c *Config) { c.Startup.IgnoreUnavailableNodes = []string{"", "titan-22"} }},
{"bad_empty_vault_key_file", func(c *Config) { c.Startup.VaultUnsealKeyFile = "" }},
{"bad_ssh_port_low", func(c *Config) { c.SSHPort = 0 }},
{"bad_ups_provider", func(c *Config) { c.UPS.Enabled = true; c.UPS.Provider = "" }},
{"bad_ups_target_empty", func(c *Config) { c.UPS.Enabled = true; c.UPS.Provider = "nut"; c.UPS.Target = ""; c.UPS.Targets = nil }},
{"bad_ups_targets_item_empty", func(c *Config) {
c.UPS.Enabled = true
c.UPS.Provider = "nut"
c.UPS.Target = ""
c.UPS.Targets = []UPSTarget{{Name: "x", Target: ""}}
}},
{"bad_startup_guard_age", func(c *Config) { c.Coordination.StartupGuardMaxAgeSec = 0 }},
{"bad_state_run_history_path", func(c *Config) { c.State.RunHistoryPath = "" }},
{"bad_state_intent_path", func(c *Config) { c.State.IntentPath = "" }},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
cfg := base
tc.mutate(&cfg)
if err := cfg.Validate(); err == nil {
t.Fatalf("expected validation error for %s", tc.name)
}
})
}
}
// TestApplyDefaultsPopulatesZeroConfig runs one orchestration or CLI step.
// Signature: TestApplyDefaultsPopulatesZeroConfig(t *testing.T).
// Why: validates that defaulting logic fills all critical runtime values.
func TestApplyDefaultsPopulatesZeroConfig(t *testing.T) {
var cfg Config
cfg.ControlPlanes = []string{"titan-0a", "titan-0b", "titan-0c"}
cfg.applyDefaults()
if cfg.ExpectedFluxBranch == "" || cfg.ExpectedFluxSource == "" || cfg.IACRepoPath == "" {
t.Fatalf("expected core defaults to be set")
}
if cfg.State.Dir == "" || cfg.State.ReportsDir == "" || cfg.State.RunHistoryPath == "" || cfg.State.LockPath == "" || cfg.State.IntentPath == "" {
t.Fatalf("expected state defaults to be set")
}
if cfg.Startup.TimeSyncMode == "" || cfg.Startup.EtcdRestoreControlPlane == "" || cfg.Startup.VaultUnsealKeyFile == "" {
t.Fatalf("expected startup defaults to be set")
}
if cfg.Startup.CriticalServiceEndpointWaitSec <= 0 || cfg.Startup.CriticalServiceEndpointPollSec <= 0 {
t.Fatalf("expected critical service endpoint timing defaults to be set")
}
if len(cfg.Startup.CriticalServiceEndpoints) == 0 {
t.Fatalf("expected critical service endpoint defaults to be set")
}
if cfg.Shutdown.SSHParallelism <= 0 || cfg.Shutdown.ScaleParallelism <= 0 || cfg.Shutdown.DrainParallelism <= 0 {
t.Fatalf("expected shutdown parallelism defaults to be set")
}
}
// TestValidateAcceptsFullySpecifiedConfig runs one orchestration or CLI step.
// Signature: TestValidateAcceptsFullySpecifiedConfig(t *testing.T).
// Why: covers success branches for forward-shutdown and UPS target validation.
func TestValidateAcceptsFullySpecifiedConfig(t *testing.T) {
cfg := defaults()
cfg.UPS.Target = "pyrphoros@localhost"
cfg.Coordination.ForwardShutdownHost = "titan-db"
cfg.Coordination.ForwardShutdownConfig = "/etc/ananke/ananke.yaml"
cfg.Coordination.PeerHosts = []string{"titan-24"}
if err := cfg.Validate(); err != nil {
t.Fatalf("expected fully specified config to validate: %v", err)
}
}

View File

@ -0,0 +1,177 @@
package coverage
import (
"bufio"
"bytes"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"testing"
)
type coverageBlock struct {
file string
stmts float64
hitCount float64
}
type fileCoverage struct {
covered float64
total float64
}
func repoRoot(tb testing.TB) string {
tb.Helper()
_, thisFile, _, ok := runtime.Caller(0)
if !ok {
tb.Fatalf("unable to resolve caller path")
}
return filepath.Clean(filepath.Join(filepath.Dir(thisFile), "..", ".."))
}
// runCoverageCommand runs one orchestration or CLI step.
// Signature: runCoverageCommand(t *testing.T, dir string, coverFile string, args ...string).
// Why: coverage is now merged from both root and top-level testing modules, so
// command execution needs one shared helper to avoid drift between invocations.
func runCoverageCommand(t *testing.T, dir string, coverFile string, args ...string) {
t.Helper()
cmdArgs := append([]string{"test"}, args...)
cmdArgs = append(cmdArgs, "-coverprofile="+coverFile)
cmd := exec.Command("go", cmdArgs...)
cmd.Dir = dir
cmd.Env = os.Environ()
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("go test coverage run failed in %s: %v\n%s", dir, err, out)
}
}
// parseCoverageProfile runs one orchestration or CLI step.
// Signature: parseCoverageProfile(t *testing.T, path string, blocks map[string]coverageBlock).
// Why: merged coverage requires block-level union logic so hits from either
// module count toward per-file coverage without double-counting statements.
func parseCoverageProfile(t *testing.T, path string, blocks map[string]coverageBlock) {
t.Helper()
f, err := os.Open(path)
if err != nil {
t.Fatalf("open coverprofile %s: %v", path, err)
}
defer f.Close()
s := bufio.NewScanner(f)
for s.Scan() {
line := strings.TrimSpace(s.Text())
if line == "" || strings.HasPrefix(line, "mode:") {
continue
}
parts := strings.Fields(line)
if len(parts) != 3 {
t.Fatalf("unexpected coverprofile line: %q", line)
}
loc := parts[0]
stmts, err := strconv.ParseFloat(parts[1], 64)
if err != nil {
t.Fatalf("parse statement count %q: %v", parts[1], err)
}
hitCount, err := strconv.ParseFloat(parts[2], 64)
if err != nil {
t.Fatalf("parse hit count %q: %v", parts[2], err)
}
file := strings.SplitN(loc, ":", 2)[0]
key := loc + "|" + parts[1]
block := blocks[key]
block.file = file
block.stmts = stmts
if hitCount > block.hitCount {
block.hitCount = hitCount
}
blocks[key] = block
}
if err := s.Err(); err != nil {
t.Fatalf("scan coverprofile %s: %v", path, err)
}
}
func TestPerFileCoverageReport(t *testing.T) {
root := repoRoot(t)
tmp := t.TempDir()
rootCover := filepath.Join(tmp, "ananke.root.cover.out")
testingCover := filepath.Join(tmp, "ananke.testing.cover.out")
runCoverageCommand(t, root, rootCover, "./...")
runCoverageCommand(
t,
filepath.Join(root, "testing"),
testingCover,
"./hygiene",
"./orchestrator",
"./state",
"./sshutil",
"./ups",
"-coverpkg=scm.bstein.dev/bstein/ananke/...",
)
blocks := map[string]coverageBlock{}
parseCoverageProfile(t, rootCover, blocks)
parseCoverageProfile(t, testingCover, blocks)
byFile := map[string]*fileCoverage{}
for _, block := range blocks {
fc := byFile[block.file]
if fc == nil {
fc = &fileCoverage{}
byFile[block.file] = fc
}
fc.total += block.stmts
if block.hitCount > 0 {
fc.covered += block.stmts
}
}
target := 95.0
if raw := strings.TrimSpace(os.Getenv("ANANKE_PER_FILE_COVERAGE_TARGET")); raw != "" {
parsed, err := strconv.ParseFloat(raw, 64)
if err != nil {
t.Fatalf("invalid ANANKE_PER_FILE_COVERAGE_TARGET=%q: %v", raw, err)
}
target = parsed
}
enforce := strings.TrimSpace(os.Getenv("ANANKE_ENFORCE_COVERAGE")) == "1"
keys := make([]string, 0, len(byFile))
for file := range byFile {
if strings.Contains(file, "/testing/") {
continue
}
keys = append(keys, file)
}
sort.Strings(keys)
var report bytes.Buffer
report.WriteString("per-file coverage\n")
under := make([]string, 0)
for _, file := range keys {
fc := byFile[file]
pct := 100.0
if fc.total > 0 {
pct = (fc.covered / fc.total) * 100.0
}
report.WriteString(fmt.Sprintf("- %s: %.1f%%\n", file, pct))
if pct < target {
under = append(under, fmt.Sprintf("%s (%.1f%% < %.1f%%)", file, pct, target))
}
}
t.Log(report.String())
if len(under) > 0 && enforce {
t.Fatalf("files below coverage target:\n%s", strings.Join(under, "\n"))
}
if len(under) > 0 && !enforce {
t.Logf("coverage target %.1f%% is not enforced yet; %d files are below target", target, len(under))
}
}

9
testing/go.mod Normal file
View File

@ -0,0 +1,9 @@
module scm.bstein.dev/bstein/ananke/testing
go 1.25
require scm.bstein.dev/bstein/ananke v0.0.0
require gopkg.in/yaml.v3 v3.0.1 // indirect
replace scm.bstein.dev/bstein/ananke => ..

4
testing/go.sum Normal file
View File

@ -0,0 +1,4 @@
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -0,0 +1,139 @@
package hygiene
import (
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"regexp"
"runtime"
"sort"
"strings"
"testing"
)
const maxGoFileLOC = 500
var goFileNamePattern = regexp.MustCompile(`^[a-z0-9]+(_[a-z0-9]+)*(_test)?\.go$`)
func repoRoot(tb testing.TB) string {
tb.Helper()
_, thisFile, _, ok := runtime.Caller(0)
if !ok {
tb.Fatalf("unable to resolve caller path")
}
return filepath.Clean(filepath.Join(filepath.Dir(thisFile), "..", ".."))
}
func collectGoFiles(tb testing.TB, roots ...string) []string {
tb.Helper()
files := make([]string, 0, 128)
for _, root := range roots {
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
switch d.Name() {
case ".git", "vendor", "dist", "artifacts", "testdata":
return filepath.SkipDir
}
return nil
}
if strings.HasSuffix(path, ".go") {
files = append(files, path)
}
return nil
})
if err != nil {
tb.Fatalf("walk %s: %v", root, err)
}
}
return files
}
// TestHygieneContracts runs one orchestration or CLI step.
// Signature: TestHygieneContracts(t *testing.T).
// Why: Enforces an explicit quality order: doc contracts first, then naming,
// then file-size constraints. That keeps maintainability rules deterministic.
func TestHygieneContracts(t *testing.T) {
root := repoRoot(t)
files := collectGoFiles(t, filepath.Join(root, "cmd"), filepath.Join(root, "internal"))
sort.Strings(files)
t.Run("doc_contract", func(t *testing.T) {
checkDocContracts(t, files)
})
t.Run("naming_contract", func(t *testing.T) {
checkNamingContracts(t, files)
})
t.Run("loc_limit", func(t *testing.T) {
checkFileLOCLimits(t, files)
})
}
// checkDocContracts runs one orchestration or CLI step.
// Signature: checkDocContracts(t *testing.T, files []string).
// Why: The project requires function-level intent to be explicit so future edits
// preserve behavior and avoid reintroducing drill-time regressions.
func checkDocContracts(t *testing.T, files []string) {
t.Helper()
fset := token.NewFileSet()
for _, file := range files {
af, err := parser.ParseFile(fset, file, nil, parser.ParseComments)
if err != nil {
t.Errorf("parse %s: %v", file, err)
continue
}
for _, decl := range af.Decls {
fn, ok := decl.(*ast.FuncDecl)
if !ok {
continue
}
if fn.Doc == nil {
t.Errorf("%s: function %s is missing doc comment block", file, fn.Name.Name)
continue
}
doc := fn.Doc.Text()
if !strings.Contains(doc, "Signature:") {
t.Errorf("%s: function %s doc is missing Signature: contract", file, fn.Name.Name)
}
if !strings.Contains(doc, "Why:") {
t.Errorf("%s: function %s doc is missing Why: contract", file, fn.Name.Name)
}
}
}
}
// checkNamingContracts runs one orchestration or CLI step.
// Signature: checkNamingContracts(t *testing.T, files []string).
// Why: File/module naming consistency is part of maintainability and helps new
// contributors find behavior quickly as the lab and codebase expand.
func checkNamingContracts(t *testing.T, files []string) {
t.Helper()
for _, file := range files {
base := filepath.Base(file)
if !goFileNamePattern.MatchString(base) {
t.Errorf("%s: filename %q violates naming contract %s", file, base, goFileNamePattern.String())
}
}
}
// checkFileLOCLimits runs one orchestration or CLI step.
// Signature: checkFileLOCLimits(t *testing.T, files []string).
// Why: A strict LOC cap forces focused files and keeps refactors manageable.
func checkFileLOCLimits(t *testing.T, files []string) {
t.Helper()
for _, file := range files {
data, err := os.ReadFile(file)
if err != nil {
t.Errorf("read %s: %v", file, err)
continue
}
lineCount := strings.Count(string(data), "\n") + 1
if lineCount > maxGoFileLOC {
t.Errorf("%s has %d lines, exceeds %d", file, lineCount, maxGoFileLOC)
}
}
}

View File

@ -0,0 +1,249 @@
package orchestrator
import (
"context"
"errors"
"io"
"log"
"net"
"os"
"strconv"
"strings"
"testing"
"time"
"scm.bstein.dev/bstein/ananke/internal/cluster"
"scm.bstein.dev/bstein/ananke/internal/config"
"scm.bstein.dev/bstein/ananke/internal/execx"
"scm.bstein.dev/bstein/ananke/internal/state"
)
// newLifecycleSaturationOrchestrator runs one orchestration or CLI step.
// Signature: newLifecycleSaturationOrchestrator(t *testing.T, cfg config.Config, run commandOverride) *cluster.Orchestrator.
// Why: lifecycle branch saturation needs deterministic command behavior while preserving real intent/lock/file semantics.
func newLifecycleSaturationOrchestrator(
t *testing.T,
cfg config.Config,
run func(context.Context, time.Duration, string, ...string) (string, error),
) *cluster.Orchestrator {
t.Helper()
if run == nil {
run = lifecycleDispatcher(&commandRecorder{})
}
orch := cluster.New(cfg, &execx.Runner{DryRun: false}, state.New(cfg.State.RunHistoryPath), log.New(io.Discard, "", 0))
orch.SetCommandOverrides(run, run)
return orch
}
// TestLifecycleStartupBranchSaturation runs one orchestration or CLI step.
// Signature: TestLifecycleStartupBranchSaturation(t *testing.T).
// Why: drives startup through its main error/safety branches so lifecycle coverage
// reflects realistic drill failure modes.
func TestLifecycleStartupBranchSaturation(t *testing.T) {
t.Run("read-intent-error-branch", func(t *testing.T) {
cfg := lifecycleConfig(t)
if err := state.WriteIntent(cfg.State.IntentPath, state.Intent{
State: state.IntentNormal,
Reason: "seed",
Source: "test",
UpdatedAt: time.Now().UTC(),
}); err != nil {
t.Fatalf("seed intent: %v", err)
}
// Replace intent file with directory so ReadIntent fails.
if err := osRemove(cfg.State.IntentPath); err != nil {
t.Fatalf("remove intent file: %v", err)
}
if err := osMkdir(cfg.State.IntentPath); err != nil {
t.Fatalf("make intent dir: %v", err)
}
orch := newLifecycleSaturationOrchestrator(t, cfg, nil)
if err := orch.Startup(context.Background(), cluster.StartupOptions{Reason: "intent-read-error"}); err == nil {
t.Fatalf("expected startup to fail when intent path is unreadable")
}
})
t.Run("fresh-shutdown-intent-blocks", func(t *testing.T) {
cfg := lifecycleConfig(t)
if err := state.WriteIntent(cfg.State.IntentPath, state.Intent{
State: state.IntentShuttingDown,
Reason: "active-shutdown",
Source: "test",
UpdatedAt: time.Now().UTC(),
}); err != nil {
t.Fatalf("write shutdown intent: %v", err)
}
orch := newLifecycleSaturationOrchestrator(t, cfg, nil)
err := orch.Startup(context.Background(), cluster.StartupOptions{Reason: "blocked-by-shutdown"})
if err == nil || !strings.Contains(err.Error(), "startup blocked: shutdown intent is active") {
t.Fatalf("expected active shutdown intent block, got %v", err)
}
})
t.Run("cooldown-cancel-branch", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.ShutdownCooldownSeconds = 20
if err := state.WriteIntent(cfg.State.IntentPath, state.Intent{
State: state.IntentShutdownComplete,
Reason: "just-finished",
Source: "test",
UpdatedAt: time.Now().UTC(),
}); err != nil {
t.Fatalf("write cooldown intent: %v", err)
}
orch := newLifecycleSaturationOrchestrator(t, cfg, nil)
ctx, cancel := context.WithCancel(context.Background())
go func() {
time.Sleep(20 * time.Millisecond)
cancel()
}()
err := orch.Startup(ctx, cluster.StartupOptions{Reason: "cooldown-cancel"})
if err == nil || !strings.Contains(err.Error(), "startup canceled while waiting for shutdown cooldown") {
t.Fatalf("expected cooldown cancel branch, got %v", err)
}
})
t.Run("api-failure-without-restore", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.AutoEtcdRestoreOnAPIFailure = false
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, "version --request-timeout=5s") {
return "", errors.New("apiserver down")
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orch := newLifecycleSaturationOrchestrator(t, cfg, run)
err := orch.Startup(context.Background(), cluster.StartupOptions{Reason: "api-fail-no-restore"})
if err == nil || !strings.Contains(err.Error(), "kubernetes API did not become reachable") {
t.Fatalf("expected api wait failure, got %v", err)
}
})
t.Run("api-failure-restore-not-applicable-retries", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.AutoEtcdRestoreOnAPIFailure = true
cfg.Startup.EtcdRestoreControlPlane = "titan-db"
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("open local datastore listener: %v", err)
}
defer l.Close()
port := l.Addr().(*net.TCPAddr).Port
attempt := 0
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "version --request-timeout=5s"):
attempt++
if attempt <= 1 {
return "", errors.New("apiserver down")
}
return "v1.31.0", nil
case name == "ssh" && strings.Contains(command, "systemctl cat k3s"):
return "ExecStart=/usr/local/bin/k3s server --datastore-endpoint=postgres://127.0.0.1:" + strconv.Itoa(port) + "/k3s", nil
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orch := newLifecycleSaturationOrchestrator(t, cfg, run)
if err := orch.Startup(context.Background(), cluster.StartupOptions{Reason: "api-restore-not-applicable"}); err != nil {
t.Fatalf("expected startup success after retry, got %v", err)
}
})
t.Run("bootstrap-required-and-cache-missing-fails", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.IACRepoPath = t.TempDir()
cfg.LocalBootstrapPaths = []string{"services/bootstrap"}
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "jsonpath={.status.conditions[?(@.type==\"Ready\")].status}"):
return "", errors.New("flux source unavailable")
case name == "kubectl" && strings.Contains(command, " apply -k "):
return "", errors.New("apply failed")
case name == "sh" && strings.Contains(command, "kubectl kustomize"):
return "", errors.New("fallback failed")
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orch := newLifecycleSaturationOrchestrator(t, cfg, run)
err := orch.Startup(context.Background(), cluster.StartupOptions{Reason: "bootstrap-required"})
if err == nil || !strings.Contains(err.Error(), "local bootstrap apply failed") {
t.Fatalf("expected bootstrap failure, got %v", err)
}
})
}
// TestLifecycleEtcdRestoreAndShutdownBranchSaturation runs one orchestration or CLI step.
// Signature: TestLifecycleEtcdRestoreAndShutdownBranchSaturation(t *testing.T).
// Why: covers restore/shutdown branch paths that are difficult to hit from a single happy-path drill.
func TestLifecycleEtcdRestoreAndShutdownBranchSaturation(t *testing.T) {
t.Run("etcd-restore-input-validation", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.ControlPlanes = nil
orch := newLifecycleSaturationOrchestrator(t, cfg, nil)
if err := orch.EtcdRestore(context.Background(), cluster.EtcdRestoreOptions{}); err == nil {
t.Fatalf("expected restore error with no control planes")
}
})
t.Run("etcd-restore-unmanaged-node", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.SSHManagedNodes = []string{"titan-23"}
orch := newLifecycleSaturationOrchestrator(t, cfg, nil)
if err := orch.EtcdRestore(context.Background(), cluster.EtcdRestoreOptions{ControlPlane: "titan-db"}); err == nil {
t.Fatalf("expected unmanaged control plane restore error")
}
})
t.Run("etcd-restore-command-failure", func(t *testing.T) {
cfg := lifecycleConfig(t)
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "ssh" && strings.Contains(command, "systemctl cat k3s"):
return "ExecStart=/usr/local/bin/k3s server", nil
case name == "ssh" && strings.Contains(command, "etcd-snapshot ls"):
return "/var/lib/rancher/k3s/server/db/snapshots/pre-shutdown", nil
case name == "ssh" && strings.Contains(command, "stat -c %s"):
return "2097152", nil
case name == "ssh" && strings.Contains(command, "sha256sum"):
return "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil
case name == "ssh" && strings.Contains(command, "server --cluster-reset"):
return "", errors.New("cluster reset failed")
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orch := newLifecycleSaturationOrchestrator(t, cfg, run)
err := orch.EtcdRestore(context.Background(), cluster.EtcdRestoreOptions{ControlPlane: "titan-db"})
if err == nil || !strings.Contains(err.Error(), "etcd restore command failed") {
t.Fatalf("expected restore command failure branch, got %v", err)
}
})
t.Run("shutdown-invalid-mode", func(t *testing.T) {
cfg := lifecycleConfig(t)
orch := newLifecycleSaturationOrchestrator(t, cfg, nil)
if err := orch.Shutdown(context.Background(), cluster.ShutdownOptions{Reason: "bad-mode", Mode: "invalid"}); err == nil {
t.Fatalf("expected shutdown mode validation error")
}
})
}
// osRemove runs one orchestration or CLI step.
// Signature: osRemove(path string) error.
// Why: keeps error handling explicit in lifecycle branch tests without repeated ignore logic.
func osRemove(path string) error {
return os.Remove(path)
}
// osMkdir runs one orchestration or CLI step.
// Signature: osMkdir(path string) error.
// Why: keeps branch setup concise in lifecycle branch tests.
func osMkdir(path string) error {
return os.Mkdir(path, 0o755)
}

View File

@ -0,0 +1,374 @@
package orchestrator
import (
"context"
"fmt"
"io"
"log"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"scm.bstein.dev/bstein/ananke/internal/cluster"
"scm.bstein.dev/bstein/ananke/internal/config"
"scm.bstein.dev/bstein/ananke/internal/execx"
"scm.bstein.dev/bstein/ananke/internal/state"
)
type commandRecorder struct {
mu sync.Mutex
calls []string
}
// record runs one orchestration or CLI step.
// Signature: (r *commandRecorder) record(name string, args []string).
// Why: keeps startup/shutdown integration tests deterministic and auditable.
func (r *commandRecorder) record(name string, args []string) {
r.mu.Lock()
defer r.mu.Unlock()
r.calls = append(r.calls, name+" "+strings.Join(args, " "))
}
// contains runs one orchestration or CLI step.
// Signature: (r *commandRecorder) contains(part string) bool.
// Why: validates that the orchestration flow reached the intended checklist gates.
func (r *commandRecorder) contains(part string) bool {
r.mu.Lock()
defer r.mu.Unlock()
for _, call := range r.calls {
if strings.Contains(call, part) {
return true
}
}
return false
}
// lifecycleConfig runs one orchestration or CLI step.
// Signature: lifecycleConfig(t *testing.T) config.Config.
// Why: centralizes a realistic startup/shutdown config used by top-level drill tests.
func lifecycleConfig(t *testing.T) config.Config {
t.Helper()
root := t.TempDir()
return config.Config{
SSHUser: "atlas",
SSHPort: 22,
ExpectedFluxBranch: "main",
ExpectedFluxSource: "ssh://git@scm.bstein.dev:2242/bstein/titan-iac.git",
ControlPlanes: []string{"titan-db"},
Workers: []string{"titan-23"},
SSHManagedNodes: []string{"titan-db", "titan-23"},
SSHNodeHosts: map[string]string{
"titan-db": "titan-db",
"titan-23": "titan-23",
},
State: config.State{
Dir: root,
ReportsDir: filepath.Join(root, "reports"),
RunHistoryPath: filepath.Join(root, "runs.json"),
LockPath: filepath.Join(root, "ananke.lock"),
IntentPath: filepath.Join(root, "intent.json"),
},
Startup: config.Startup{
APIWaitSeconds: 2,
APIPollSeconds: 1,
RequireNodeInventoryReach: true,
NodeInventoryReachWaitSeconds: 2,
NodeInventoryReachPollSeconds: 1,
RequireNodeSSHAuth: false,
RequireStorageReady: false,
RequireIngressChecklist: false,
RequireServiceChecklist: false,
RequireCriticalServiceEndpoints: true,
CriticalServiceEndpointWaitSec: 2,
CriticalServiceEndpointPollSec: 1,
CriticalServiceEndpoints: []string{
"monitoring/victoria-metrics-single-server",
},
RequireFluxHealth: true,
FluxHealthWaitSeconds: 2,
FluxHealthPollSeconds: 1,
RequireWorkloadConvergence: true,
WorkloadConvergenceWaitSeconds: 2,
WorkloadConvergencePollSeconds: 1,
ServiceChecklistStabilitySec: 0,
AutoRecycleStuckPods: true,
StuckPodGraceSeconds: 1,
VaultUnsealKeyFile: filepath.Join(root, "vault-unseal.key"),
},
Shutdown: config.Shutdown{
DrainParallelism: 1,
ScaleParallelism: 1,
SSHParallelism: 1,
},
}
}
// lifecycleDispatcher runs one orchestration or CLI step.
// Signature: lifecycleDispatcher(recorder *commandRecorder) func(context.Context, time.Duration, string, ...string) (string, error).
// Why: emulates kubectl/ssh/git behavior so top-level tests can exercise full orchestration logic deterministically.
func lifecycleDispatcher(recorder *commandRecorder) func(context.Context, time.Duration, string, ...string) (string, error) {
return func(_ context.Context, _ time.Duration, name string, args ...string) (string, error) {
recorder.record(name, args)
command := name + " " + strings.Join(args, " ")
switch name {
case "ssh":
switch {
case strings.Contains(command, "__ANANKE_NODE_REACHABLE__"):
return "__ANANKE_NODE_REACHABLE__", nil
case strings.Contains(command, "__ANANKE_SSH_AUTH_OK__"):
return "__ANANKE_SSH_AUTH_OK__", nil
case strings.Contains(command, "systemctl cat k3s"):
return "ExecStart=/usr/local/bin/k3s server", nil
case strings.Contains(command, "k3s etcd-snapshot"):
return "/var/lib/rancher/k3s/server/db/snapshots/pre-shutdown", nil
default:
return "ok", nil
}
case "git":
if strings.Contains(command, "status --porcelain") {
return "", nil
}
return "", nil
case "flux":
return "", nil
case "sh":
return "", nil
case "kubectl":
switch {
case strings.Contains(command, "version --request-timeout=5s"):
return "v1.31.0", nil
case strings.Contains(command, "jsonpath={.status.conditions[?(@.type==\"Ready\")].status}"):
return "True", nil
case strings.Contains(command, "jsonpath={.spec.url}"):
return "ssh://git@scm.bstein.dev:2242/bstein/titan-iac.git", nil
case strings.Contains(command, "jsonpath={.spec.ref.branch}"):
return "main", nil
case strings.Contains(command, "get kustomizations.kustomize.toolkit.fluxcd.io -A -o json"):
return `{"items":[{"metadata":{"namespace":"flux-system","name":"services"},"spec":{"suspend":false,"timeout":"30s"},"status":{"conditions":[{"type":"Ready","status":"True","reason":"ReconciliationSucceeded","message":"ok"}]}}]}`, nil
case strings.Contains(command, "-n flux-system get kustomizations.kustomize.toolkit.fluxcd.io -o jsonpath="):
return "services\n", nil
case strings.Contains(command, "get helmreleases.helm.toolkit.fluxcd.io -A -o jsonpath="):
return "monitoring/grafana\n", nil
case strings.Contains(command, "annotate kustomizations.kustomize.toolkit.fluxcd.io"):
return "", nil
case strings.Contains(command, "annotate --all-namespaces helmreleases.helm.toolkit.fluxcd.io"):
return "", nil
case strings.Contains(command, "get deploy,statefulset,daemonset -A -o json"):
return `{"items":[{"kind":"Deployment","metadata":{"namespace":"monitoring","name":"grafana"},"spec":{"replicas":1,"template":{"spec":{}}},"status":{"readyReplicas":1}}]}`, nil
case strings.Contains(command, "get deploy,statefulset -A -o json"):
return `{"items":[{"kind":"Deployment","metadata":{"namespace":"monitoring","name":"grafana"},"spec":{"replicas":1}},{"kind":"StatefulSet","metadata":{"namespace":"monitoring","name":"victoria-metrics-single-server"},"spec":{"replicas":1}}]}`, nil
case strings.Contains(command, "get pods -A -o json"):
return `{"items":[]}`, nil
case strings.Contains(command, "get deployment -A -o jsonpath="):
return "monitoring\tgrafana\t1\n", nil
case strings.Contains(command, "get statefulset -A -o jsonpath="):
return "monitoring\tvictoria-metrics-single-server\t1\n", nil
case strings.Contains(command, "jsonpath={.status.readyReplicas}"):
return "1", nil
case strings.Contains(command, "get endpoints victoria-metrics-single-server"):
return "10.42.0.10\n", nil
case strings.Contains(command, "get jobs -A -o json"):
return `{"items":[]}`, nil
case strings.Contains(command, "rollout status"):
return "rolled out", nil
case strings.Contains(command, "cordon "):
return "", nil
case strings.Contains(command, "drain "):
return "", nil
case strings.Contains(command, "uncordon "):
return "", nil
case strings.Contains(command, "patch "):
return "", nil
case strings.Contains(command, "scale "):
return "", nil
default:
return "", nil
}
default:
return "", nil
}
}
}
// newLifecycleOrchestrator runs one orchestration or CLI step.
// Signature: newLifecycleOrchestrator(t *testing.T) (*cluster.Orchestrator, *commandRecorder, config.Config).
// Why: constructs an orchestrator with deterministic command behavior for top-level module tests.
func newLifecycleOrchestrator(t *testing.T) (*cluster.Orchestrator, *commandRecorder, config.Config) {
t.Helper()
cfg := lifecycleConfig(t)
if err := os.MkdirAll(cfg.State.Dir, 0o755); err != nil {
t.Fatalf("ensure state dir: %v", err)
}
runner := &execx.Runner{DryRun: false}
recorder := &commandRecorder{}
orch := cluster.New(cfg, runner, state.New(cfg.State.RunHistoryPath), log.New(io.Discard, "", 0))
dispatch := lifecycleDispatcher(recorder)
orch.SetCommandOverrides(dispatch, dispatch)
return orch, recorder, cfg
}
// TestLifecycleStartupShutdownFromTopLevelModule runs one orchestration or CLI step.
// Signature: TestLifecycleStartupShutdownFromTopLevelModule(t *testing.T).
// Why: validates that a separate top-level testing module can drive full startup
// and shutdown flows, and confirms checklist-oriented steps were actually executed.
func TestLifecycleStartupShutdownFromTopLevelModule(t *testing.T) {
orch, recorder, cfg := newLifecycleOrchestrator(t)
ctx := context.Background()
if err := orch.Startup(ctx, cluster.StartupOptions{Reason: "drill"}); err != nil {
t.Fatalf("startup failed: %v", err)
}
if err := orch.Shutdown(ctx, cluster.ShutdownOptions{
Reason: "drill",
Mode: "cluster-only",
SkipEtcdSnapshot: true,
}); err != nil {
t.Fatalf("shutdown failed: %v", err)
}
if !recorder.contains("get gitrepository flux-system") {
t.Fatalf("expected flux source guard/read commands to run")
}
if !recorder.contains("get endpoints victoria-metrics-single-server") {
t.Fatalf("expected critical endpoint checklist to run")
}
if !recorder.contains("drain titan-23") {
t.Fatalf("expected worker drain command to run")
}
lastStartup := filepath.Join(cfg.State.Dir, "last-startup-report.json")
if _, err := os.Stat(lastStartup); err != nil {
t.Fatalf("expected startup report at %s: %v", lastStartup, err)
}
lastShutdown := filepath.Join(cfg.State.Dir, "last-shutdown-report.json")
if _, err := os.Stat(lastShutdown); err != nil {
t.Fatalf("expected shutdown report at %s: %v", lastShutdown, err)
}
}
// TestLifecycleStartupFailsWhenFluxSourceDrifts runs one orchestration or CLI step.
// Signature: TestLifecycleStartupFailsWhenFluxSourceDrifts(t *testing.T).
// Why: verifies startup blocks on flux source URL drift, preventing deadlock-prone bootstrap against the wrong repo.
func TestLifecycleStartupFailsWhenFluxSourceDrifts(t *testing.T) {
orch, _, cfg := newLifecycleOrchestrator(t)
orch.SetCommandOverrides(func(_ context.Context, _ time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, "jsonpath={.spec.url}") {
return "ssh://git@scm.bstein.dev:2242/bstein/wrong-repo.git", nil
}
if name == "kubectl" && strings.Contains(command, "version --request-timeout=5s") {
return "v1.31.0", nil
}
if name == "ssh" && strings.Contains(command, "__ANANKE_NODE_REACHABLE__") {
return "__ANANKE_NODE_REACHABLE__", nil
}
if name == "ssh" && strings.Contains(command, "systemctl cat k3s") {
return "ExecStart=/usr/local/bin/k3s server", nil
}
if name == "kubectl" && strings.Contains(command, "jsonpath={.status.conditions[?(@.type==\"Ready\")].status}") {
return "True", nil
}
if name == "kubectl" && strings.Contains(command, "jsonpath={.spec.ref.branch}") {
return "main", nil
}
return "", nil
}, nil)
err := orch.Startup(context.Background(), cluster.StartupOptions{Reason: "drift-test"})
if err == nil {
t.Fatalf("expected startup to fail on flux source drift")
}
if !strings.Contains(err.Error(), "flux source url drift") {
t.Fatalf("unexpected startup error: %v", err)
}
intent, readErr := state.ReadIntent(cfg.State.IntentPath)
if readErr != nil {
t.Fatalf("read intent after failed startup: %v", readErr)
}
if intent.State != state.IntentNormal {
t.Fatalf("expected intent to be normalized after startup failure, got %q", intent.State)
}
}
// TestLifecycleShutdownRejectsLegacyPoweroffMode runs one orchestration or CLI step.
// Signature: TestLifecycleShutdownRejectsLegacyPoweroffMode(t *testing.T).
// Why: enforces the safety rule that Ananke never powers off hosts, only performs cluster-only shutdown.
func TestLifecycleShutdownRejectsLegacyPoweroffMode(t *testing.T) {
orch, _, _ := newLifecycleOrchestrator(t)
err := orch.Shutdown(context.Background(), cluster.ShutdownOptions{
Reason: "mode-test",
Mode: "poweroff",
})
if err == nil {
t.Fatalf("expected poweroff mode to be rejected")
}
if !strings.Contains(err.Error(), "no longer powers off hosts") {
t.Fatalf("unexpected shutdown mode error: %v", err)
}
}
// TestLifecycleStatusArtifactsIncludeProgress runs one orchestration or CLI step.
// Signature: TestLifecycleStatusArtifactsIncludeProgress(t *testing.T).
// Why: confirms startup writes progress snapshots so CLI status polling can report checklist progress live.
func TestLifecycleStatusArtifactsIncludeProgress(t *testing.T) {
orch, _, cfg := newLifecycleOrchestrator(t)
ctx := context.Background()
if err := orch.Startup(ctx, cluster.StartupOptions{Reason: "status-test"}); err != nil {
t.Fatalf("startup failed: %v", err)
}
progressPath := filepath.Join(cfg.State.Dir, "startup-progress.json")
b, err := os.ReadFile(progressPath)
if err != nil {
t.Fatalf("read startup progress artifact: %v", err)
}
payload := string(b)
if !strings.Contains(payload, `"status": "success"`) {
t.Fatalf("expected startup progress to show success, payload=%s", payload)
}
if !strings.Contains(payload, `"checks"`) {
t.Fatalf("expected startup progress payload to include checks map")
}
lastReportPath := filepath.Join(cfg.State.Dir, "last-startup-report.json")
lastReport, err := os.ReadFile(lastReportPath)
if err != nil {
t.Fatalf("read last startup report: %v", err)
}
if !strings.Contains(string(lastReport), `"phase": "complete"`) {
t.Fatalf("expected startup report to include final completion phase, got: %s", string(lastReport))
}
}
// TestLifecycleReasonAppearsInRunHistory runs one orchestration or CLI step.
// Signature: TestLifecycleReasonAppearsInRunHistory(t *testing.T).
// Why: ties operator-supplied reasons to durable history for post-drill analysis.
func TestLifecycleReasonAppearsInRunHistory(t *testing.T) {
orch, _, cfg := newLifecycleOrchestrator(t)
reason := fmt.Sprintf("drill-%d", time.Now().UnixNano())
if err := orch.Startup(context.Background(), cluster.StartupOptions{Reason: reason}); err != nil {
t.Fatalf("startup failed: %v", err)
}
records, err := state.New(cfg.State.RunHistoryPath).Load()
if err != nil {
t.Fatalf("load run history: %v", err)
}
if len(records) == 0 {
t.Fatalf("expected at least one run history record")
}
found := false
for _, rec := range records {
if rec.Action == "startup" && rec.Reason == reason && rec.Success {
found = true
break
}
}
if !found {
t.Fatalf("expected startup record with reason %q in run history", reason)
}
}

View File

@ -0,0 +1,236 @@
package orchestrator
import (
"context"
"errors"
"io"
"log"
"strings"
"testing"
"time"
"scm.bstein.dev/bstein/ananke/internal/cluster"
"scm.bstein.dev/bstein/ananke/internal/execx"
"scm.bstein.dev/bstein/ananke/internal/state"
)
// TestLifecycleEtcdRestoreHappyPath runs one orchestration or CLI step.
// Signature: TestLifecycleEtcdRestoreHappyPath(t *testing.T).
// Why: covers non-dry-run etcd restore flow including snapshot discovery, verification, and k3s reset.
func TestLifecycleEtcdRestoreHappyPath(t *testing.T) {
orch, recorder, _ := newLifecycleOrchestrator(t)
orch.SetCommandOverrides(func(_ context.Context, _ time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
recorder.record(name, args)
switch name {
case "ssh":
switch {
case strings.Contains(command, "sudo systemctl cat k3s"):
return "ExecStart=/usr/local/bin/k3s server", nil
case strings.Contains(command, "etcd-snapshot ls"):
return "Name Size Created Location\npre-shutdown 4.2M now \"file:///var/lib/rancher/k3s/server/db/snapshots/pre-shutdown\"\n", nil
case strings.Contains(command, "stat -c %s"):
return "2097152", nil
case strings.Contains(command, "sha256sum"):
return "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil
default:
return "ok", nil
}
default:
return "", nil
}
}, nil)
if err := orch.EtcdRestore(context.Background(), cluster.EtcdRestoreOptions{}); err != nil {
t.Fatalf("etcd restore failed: %v", err)
}
if !recorder.contains("--cluster-reset-restore-path") {
t.Fatalf("expected k3s cluster-reset command in call log")
}
}
// TestLifecycleEtcdRestoreRejectsExternalDatastore runs one orchestration or CLI step.
// Signature: TestLifecycleEtcdRestoreRejectsExternalDatastore(t *testing.T).
// Why: ensures restore exits early when k3s is configured for external datastore mode.
func TestLifecycleEtcdRestoreRejectsExternalDatastore(t *testing.T) {
cfg := lifecycleConfig(t)
orch := cluster.New(cfg, &execx.Runner{DryRun: false}, state.New(cfg.State.RunHistoryPath), testLogger())
orch.SetCommandOverrides(func(_ context.Context, _ time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "ssh" && strings.Contains(command, "sudo systemctl cat k3s") {
return "ExecStart=/usr/local/bin/k3s server --datastore-endpoint=postgres://db:5432/k3s", nil
}
return "", nil
}, nil)
err := orch.EtcdRestore(context.Background(), cluster.EtcdRestoreOptions{ControlPlane: "titan-db"})
if err == nil {
t.Fatalf("expected external datastore restore rejection")
}
if !errors.Is(err, cluster.ErrEtcdRestoreNotApplicable) {
t.Fatalf("expected ErrEtcdRestoreNotApplicable, got %v", err)
}
}
// TestLifecycleStartupCriticalEndpointAutoHeal runs one orchestration or CLI step.
// Signature: TestLifecycleStartupCriticalEndpointAutoHeal(t *testing.T).
// Why: covers endpoint checklist self-heal path that restores backend replicas before declaring startup complete.
func TestLifecycleStartupCriticalEndpointAutoHeal(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.RequireFluxHealth = false
cfg.Startup.RequireWorkloadConvergence = false
cfg.Startup.CriticalServiceEndpointWaitSec = 3
cfg.Startup.CriticalServiceEndpointPollSec = 1
recorder := &commandRecorder{}
orch := cluster.New(cfg, &execx.Runner{DryRun: false}, state.New(cfg.State.RunHistoryPath), testLogger())
var healed bool
dispatch := func(_ context.Context, _ time.Duration, name string, args ...string) (string, error) {
recorder.record(name, args)
command := name + " " + strings.Join(args, " ")
switch name {
case "ssh":
if strings.Contains(command, "__ANANKE_NODE_REACHABLE__") {
return "__ANANKE_NODE_REACHABLE__", nil
}
if strings.Contains(command, "systemctl cat k3s") {
return "ExecStart=/usr/local/bin/k3s server", nil
}
return "ok", nil
case "kubectl":
switch {
case strings.Contains(command, "version --request-timeout=5s"):
return "v1.31.0", nil
case strings.Contains(command, "jsonpath={.status.conditions[?(@.type==\"Ready\")].status}"):
return "True", nil
case strings.Contains(command, "jsonpath={.spec.url}"):
return cfg.ExpectedFluxSource, nil
case strings.Contains(command, "jsonpath={.spec.ref.branch}"):
return cfg.ExpectedFluxBranch, nil
case strings.Contains(command, "get endpoints victoria-metrics-single-server"):
if healed {
return "10.42.0.10\n", nil
}
return "", nil
case strings.Contains(command, "scale statefulset victoria-metrics-single-server"):
healed = true
return "", nil
case strings.Contains(command, "scale "), strings.Contains(command, "rollout status"):
return "ok", nil
case strings.Contains(command, "get pods -A -o json"):
return `{"items":[]}`, nil
case strings.Contains(command, "get deploy,statefulset -A -o json"):
return `{"items":[{"kind":"StatefulSet","metadata":{"namespace":"monitoring","name":"victoria-metrics-single-server"},"spec":{"replicas":1}}]}`, nil
case strings.Contains(command, "jsonpath={.status.readyReplicas}"):
return "1", nil
case strings.Contains(command, "get deployment -A -o jsonpath="):
return "monitoring\tgrafana\t1\n", nil
case strings.Contains(command, "get statefulset -A -o jsonpath="):
return "monitoring\tvictoria-metrics-single-server\t1\n", nil
case strings.Contains(command, "-n flux-system get kustomizations.kustomize.toolkit.fluxcd.io -o jsonpath="):
return "", nil
case strings.Contains(command, "get helmreleases.helm.toolkit.fluxcd.io -A -o jsonpath="):
return "", nil
default:
return "", nil
}
default:
return "", nil
}
}
orch.SetCommandOverrides(dispatch, dispatch)
if err := orch.Startup(context.Background(), cluster.StartupOptions{Reason: "critical-endpoint-heal"}); err != nil {
t.Fatalf("startup with endpoint auto-heal failed: %v", err)
}
if !recorder.contains("scale statefulset victoria-metrics-single-server") {
t.Fatalf("expected endpoint auto-heal scale command in call log")
}
}
// TestLifecycleStartupFluxImmutableJobSelfHeal runs one orchestration or CLI step.
// Signature: TestLifecycleStartupFluxImmutableJobSelfHeal(t *testing.T).
// Why: covers immutable-job detection and stale failed job cleanup during flux convergence.
func TestLifecycleStartupFluxImmutableJobSelfHeal(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.RequireCriticalServiceEndpoints = false
cfg.Startup.RequireWorkloadConvergence = false
cfg.Startup.FluxHealthWaitSeconds = 4
cfg.Startup.FluxHealthPollSeconds = 1
recorder := &commandRecorder{}
orch := cluster.New(cfg, &execx.Runner{DryRun: false}, state.New(cfg.State.RunHistoryPath), testLogger())
fluxCalls := 0
dispatch := func(_ context.Context, _ time.Duration, name string, args ...string) (string, error) {
recorder.record(name, args)
command := name + " " + strings.Join(args, " ")
switch name {
case "ssh":
if strings.Contains(command, "__ANANKE_NODE_REACHABLE__") {
return "__ANANKE_NODE_REACHABLE__", nil
}
if strings.Contains(command, "systemctl cat k3s") {
return "ExecStart=/usr/local/bin/k3s server", nil
}
return "ok", nil
case "kubectl":
switch {
case strings.Contains(command, "version --request-timeout=5s"):
return "v1.31.0", nil
case strings.Contains(command, "jsonpath={.status.conditions[?(@.type==\"Ready\")].status}"):
return "True", nil
case strings.Contains(command, "jsonpath={.spec.url}"):
return cfg.ExpectedFluxSource, nil
case strings.Contains(command, "jsonpath={.spec.ref.branch}"):
return cfg.ExpectedFluxBranch, nil
case strings.Contains(command, "get kustomizations.kustomize.toolkit.fluxcd.io -A -o json"):
fluxCalls++
if fluxCalls <= 2 {
return `{"items":[{"metadata":{"namespace":"flux-system","name":"services"},"spec":{"suspend":false},"status":{"conditions":[{"type":"Ready","status":"False","message":"Job update failed: field is immutable"}]}}]}`, nil
}
return `{"items":[{"metadata":{"namespace":"flux-system","name":"services"},"spec":{"suspend":false},"status":{"conditions":[{"type":"Ready","status":"True","message":"ok"}]}}]}`, nil
case strings.Contains(command, "get jobs -A -o json"):
return `{"items":[{"metadata":{"namespace":"flux-system","name":"reconcile-services","labels":{"kustomize.toolkit.fluxcd.io/name":"services"}},"status":{"failed":1,"conditions":[{"type":"Failed","status":"True"}]}}]}`, nil
case strings.Contains(command, "delete job reconcile-services"):
return "", nil
case strings.Contains(command, "-n flux-system get kustomizations.kustomize.toolkit.fluxcd.io -o jsonpath="):
return "services\n", nil
case strings.Contains(command, "get helmreleases.helm.toolkit.fluxcd.io -A -o jsonpath="):
return "", nil
case strings.Contains(command, "patch "), strings.Contains(command, "annotate "):
return "", nil
case strings.Contains(command, "get deployment -A -o jsonpath="):
return "monitoring\tgrafana\t1\n", nil
case strings.Contains(command, "get statefulset -A -o jsonpath="):
return "monitoring\tvictoria-metrics-single-server\t1\n", nil
case strings.Contains(command, "scale "), strings.Contains(command, "rollout status"):
return "", nil
case strings.Contains(command, "jsonpath={.status.readyReplicas}"):
return "1", nil
case strings.Contains(command, "get pods -A -o json"):
return `{"items":[]}`, nil
default:
return "", nil
}
case "flux":
return "", nil
default:
return "", nil
}
}
orch.SetCommandOverrides(dispatch, dispatch)
if err := orch.Startup(context.Background(), cluster.StartupOptions{Reason: "immutable-job-heal"}); err != nil {
t.Fatalf("startup with immutable-job heal failed: %v", err)
}
if !recorder.contains("delete job reconcile-services") {
t.Fatalf("expected stale failed flux job deletion in call log")
}
}
// testLogger runs one orchestration or CLI step.
// Signature: testLogger() *log.Logger.
// Why: centralizes discarded logging for focused orchestration recovery tests.
func testLogger() *log.Logger {
return log.New(io.Discard, "", 0)
}

View File

@ -0,0 +1,699 @@
package orchestrator
import (
"context"
"encoding/base64"
"fmt"
"io"
"log"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"scm.bstein.dev/bstein/ananke/internal/cluster"
"scm.bstein.dev/bstein/ananke/internal/config"
"scm.bstein.dev/bstein/ananke/internal/execx"
"scm.bstein.dev/bstein/ananke/internal/state"
)
// newLifecycleOrchestratorWithConfig runs one orchestration or CLI step.
// Signature: newLifecycleOrchestratorWithConfig(t *testing.T, cfg config.Config, run commandOverride, runSensitive commandOverride) (*cluster.Orchestrator, *commandRecorder).
// Why: centralizes top-level orchestrator construction so branch-heavy startup tests stay focused on intent.
func newLifecycleOrchestratorWithConfig(
t *testing.T,
cfg config.Config,
run func(context.Context, time.Duration, string, ...string) (string, error),
runSensitive func(context.Context, time.Duration, string, ...string) (string, error),
) (*cluster.Orchestrator, *commandRecorder) {
t.Helper()
if err := os.MkdirAll(cfg.State.Dir, 0o755); err != nil {
t.Fatalf("ensure state dir: %v", err)
}
recorder := &commandRecorder{}
if run == nil || runSensitive == nil {
base := lifecycleDispatcher(recorder)
if run == nil {
run = base
}
if runSensitive == nil {
runSensitive = base
}
}
orch := cluster.New(cfg, &execx.Runner{DryRun: false}, state.New(cfg.State.RunHistoryPath), log.New(io.Discard, "", 0))
orch.SetCommandOverrides(run, runSensitive)
return orch, recorder
}
// TestLifecycleStartupBootstrapFallbackWithPeerGuard runs one orchestration or CLI step.
// Signature: TestLifecycleStartupBootstrapFallbackWithPeerGuard(t *testing.T).
// Why: covers peer intent guard, access reconciliation, local bootstrap fallback, and cache apply branches.
func TestLifecycleStartupBootstrapFallbackWithPeerGuard(t *testing.T) {
cfg := lifecycleConfig(t)
repo := t.TempDir()
cfg.IACRepoPath = repo
cfg.LocalBootstrapPaths = []string{"services/bootstrap"}
cfg.Startup.ReconcileAccessOnBoot = true
cfg.Startup.RequireNodeSSHAuth = true
cfg.Startup.NodeSSHAuthWaitSeconds = 2
cfg.Startup.NodeSSHAuthPollSeconds = 1
cfg.Startup.RequireCriticalServiceEndpoints = false
cfg.Startup.RequireFluxHealth = false
cfg.Startup.RequireWorkloadConvergence = false
cfg.Coordination.Role = "coordinator"
cfg.Coordination.PeerHosts = []string{"titan-24"}
cfg.SSHManagedNodes = append(cfg.SSHManagedNodes, "titan-24")
cfg.SSHNodeHosts["titan-24"] = "titan-24"
if err := os.MkdirAll(filepath.Join(repo, ".git"), 0o755); err != nil {
t.Fatalf("mkdir .git: %v", err)
}
if err := os.MkdirAll(filepath.Join(repo, "services", "bootstrap"), 0o755); err != nil {
t.Fatalf("mkdir bootstrap path: %v", err)
}
recorder := &commandRecorder{}
base := lifecycleDispatcher(recorder)
fluxReadyChecks := 0
cacheApplied := false
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "ssh" && strings.Contains(command, "ananke intent --config /etc/ananke/ananke.yaml"):
recorder.record(name, args)
now := time.Now().UTC().Format(time.RFC3339)
return "__ANANKE_BOOTSTRAP_ACTIVE__\nintent=startup_in_progress reason=\"manual-startup\" source=peer updated_at=" + now + "\n", nil
case name == "ssh" && strings.Contains(command, "/usr/bin/systemctl --version"):
recorder.record(name, args)
return "systemd 252", nil
case name == "kubectl" &&
strings.Contains(command, "get gitrepository flux-system") &&
strings.Contains(command, "jsonpath={.status.conditions[?(@.type==\"Ready\")].status}"):
recorder.record(name, args)
fluxReadyChecks++
if fluxReadyChecks <= 2 {
return "", fmt.Errorf("flux source not ready")
}
return "True", nil
case name == "kubectl" && strings.Contains(command, " apply -k "):
recorder.record(name, args)
return "", fmt.Errorf("apply -k failed")
case name == "kubectl" && strings.Contains(command, " apply -f ") && strings.Contains(command, "bootstrap-cache"):
recorder.record(name, args)
cacheApplied = true
return "", nil
default:
return base(ctx, timeout, name, args...)
}
}
runSensitive := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "git" && strings.Contains(command, "status --porcelain"):
recorder.record(name, args)
return "", nil
case name == "git" && strings.Contains(command, "fetch origin --prune"):
recorder.record(name, args)
return "", nil
case name == "git" && strings.Contains(command, "checkout main"):
recorder.record(name, args)
return "", nil
case name == "git" && strings.Contains(command, "reset --hard origin/main"):
recorder.record(name, args)
return "", nil
case name == "sh" && strings.Contains(command, "kubectl kustomize"):
recorder.record(name, args)
if strings.Contains(command, "| kubectl apply -f -") {
return "", fmt.Errorf("fallback kustomize pipe failed")
}
return "apiVersion: v1\nkind: Namespace\nmetadata:\n name: bootstrap\n", nil
default:
return base(ctx, timeout, name, args...)
}
}
orch := cluster.New(cfg, &execx.Runner{DryRun: false}, state.New(cfg.State.RunHistoryPath), log.New(io.Discard, "", 0))
orch.SetCommandOverrides(run, runSensitive)
if err := orch.Startup(context.Background(), cluster.StartupOptions{Reason: "bootstrap-fallback"}); err != nil {
t.Fatalf("startup failed: %v", err)
}
if !cacheApplied {
t.Fatalf("expected bootstrap cache apply fallback to run")
}
if !recorder.contains("ananke intent --config /etc/ananke/ananke.yaml") {
t.Fatalf("expected peer intent guard command in call log")
}
}
// TestLifecycleStartupCoversTimeSyncStorageAndServiceChecklist runs one orchestration or CLI step.
// Signature: TestLifecycleStartupCoversTimeSyncStorageAndServiceChecklist(t *testing.T).
// Why: covers time-sync quorum path, storage readiness checks, and service checklist success path.
func TestLifecycleStartupCoversTimeSyncStorageAndServiceChecklist(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.ControlPlanes = []string{"titan-db", "titan-23"}
cfg.Workers = []string{"titan-24"}
cfg.SSHManagedNodes = []string{"titan-db", "titan-23", "titan-24"}
cfg.SSHNodeHosts["titan-23"] = "titan-23"
cfg.SSHNodeHosts["titan-24"] = "titan-24"
cfg.Startup.RequireTimeSync = true
cfg.Startup.TimeSyncMode = "quorum"
cfg.Startup.TimeSyncQuorum = 1
cfg.Startup.TimeSyncWaitSeconds = 2
cfg.Startup.TimeSyncPollSeconds = 1
cfg.Startup.RequireStorageReady = true
cfg.Startup.StorageReadyWaitSeconds = 2
cfg.Startup.StorageReadyPollSeconds = 1
cfg.Startup.StorageMinReadyNodes = 2
cfg.Startup.StorageCriticalPVCs = []string{"monitoring/grafana-data"}
cfg.Startup.RequireServiceChecklist = true
cfg.Startup.ServiceChecklistWaitSeconds = 2
cfg.Startup.ServiceChecklistPollSeconds = 1
cfg.Startup.ServiceChecklistStabilitySec = 0
cfg.Startup.RequireCriticalServiceEndpoints = false
cfg.Startup.RequireFluxHealth = false
cfg.Startup.RequireWorkloadConvergence = false
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"status":"ok"}`))
}))
defer server.Close()
cfg.Startup.ServiceChecklist = []config.ServiceChecklistCheck{
{
Name: "status",
URL: server.URL,
AcceptedStatuses: []int{200},
BodyContains: `"status":"ok"`,
TimeoutSeconds: 5,
},
}
recorder := &commandRecorder{}
base := lifecycleDispatcher(recorder)
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "sh" && strings.Contains(command, "timedatectl show -p NTPSynchronized --value"):
recorder.record(name, args)
return "yes", nil
case name == "ssh" && strings.Contains(command, "timedatectl show -p NTPSynchronized --value"):
recorder.record(name, args)
if strings.Contains(command, "titan-db") {
return "yes", nil
}
return "no", nil
case name == "kubectl" && strings.Contains(command, "nodes.longhorn.io"):
recorder.record(name, args)
return "lh-a:True:True\nlh-b:True:True\n", nil
case name == "kubectl" && strings.Contains(command, "-n monitoring get pvc grafana-data"):
recorder.record(name, args)
return "Bound", nil
default:
return base(ctx, timeout, name, args...)
}
}
orch := cluster.New(cfg, &execx.Runner{DryRun: false}, state.New(cfg.State.RunHistoryPath), log.New(io.Discard, "", 0))
orch.SetCommandOverrides(run, run)
if err := orch.Startup(context.Background(), cluster.StartupOptions{Reason: "timesync-storage-service"}); err != nil {
t.Fatalf("startup failed: %v", err)
}
if !recorder.contains("nodes.longhorn.io") {
t.Fatalf("expected longhorn readiness query in call log")
}
if !recorder.contains("timedatectl show -p NTPSynchronized") {
t.Fatalf("expected timedatectl checks in call log")
}
}
// TestLifecycleStartupFailsOnNodeSSHAuthDenied runs one orchestration or CLI step.
// Signature: TestLifecycleStartupFailsOnNodeSSHAuthDenied(t *testing.T).
// Why: verifies startup fails fast when SSH auth is denied for managed nodes.
func TestLifecycleStartupFailsOnNodeSSHAuthDenied(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.RequireNodeSSHAuth = true
cfg.Startup.NodeSSHAuthWaitSeconds = 1
cfg.Startup.NodeSSHAuthPollSeconds = 1
cfg.Startup.RequireCriticalServiceEndpoints = false
cfg.Startup.RequireFluxHealth = false
cfg.Startup.RequireWorkloadConvergence = false
recorder := &commandRecorder{}
base := lifecycleDispatcher(recorder)
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "ssh" && strings.Contains(command, "__ANANKE_SSH_AUTH_OK__") && strings.Contains(command, "titan-23") {
recorder.record(name, args)
return "Permission denied (publickey)", fmt.Errorf("permission denied")
}
return base(ctx, timeout, name, args...)
}
orch, _ := newLifecycleOrchestratorWithConfig(t, cfg, run, run)
err := orch.Startup(context.Background(), cluster.StartupOptions{Reason: "ssh-auth-denied"})
if err == nil {
t.Fatalf("expected startup to fail when ssh auth is denied")
}
if !strings.Contains(err.Error(), "ssh auth gate failed") {
t.Fatalf("unexpected startup error: %v", err)
}
}
// TestLifecycleStartupVaultAutoUnsealPath runs one orchestration or CLI step.
// Signature: TestLifecycleStartupVaultAutoUnsealPath(t *testing.T).
// Why: covers vault sealed detection, unseal key retrieval, and auto-unseal retry success.
func TestLifecycleStartupVaultAutoUnsealPath(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.RequireCriticalServiceEndpoints = false
cfg.Startup.RequireFluxHealth = false
cfg.Startup.RequireWorkloadConvergence = false
recorder := &commandRecorder{}
base := lifecycleDispatcher(recorder)
vaultReadyChecks := 0
vaultSealedChecks := 0
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "-n vault get pod vault-0 -o jsonpath={.status.phase}"):
recorder.record(name, args)
return "Running", nil
case name == "kubectl" && strings.Contains(command, "-n vault get statefulset vault -o jsonpath={.status.readyReplicas}"):
recorder.record(name, args)
vaultReadyChecks++
if vaultReadyChecks <= 2 {
return "0", nil
}
return "1", nil
case name == "kubectl" && strings.Contains(command, "vault status -format=json"):
recorder.record(name, args)
vaultSealedChecks++
if vaultSealedChecks <= 1 {
return `{"sealed":true,"initialized":true}`, nil
}
return `{"sealed":false,"initialized":true}`, nil
case name == "kubectl" && strings.Contains(command, "-n vault get secret vault-init -o jsonpath={.data.unseal_key_b64}"):
recorder.record(name, args)
return base64.StdEncoding.EncodeToString([]byte("unseal-key")), nil
default:
return base(ctx, timeout, name, args...)
}
}
runSensitive := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, "vault operator unseal unseal-key") {
recorder.record(name, args)
return "ok", nil
}
return run(ctx, timeout, name, args...)
}
orch := cluster.New(cfg, &execx.Runner{DryRun: false}, state.New(cfg.State.RunHistoryPath), log.New(io.Discard, "", 0))
orch.SetCommandOverrides(run, runSensitive)
if err := orch.Startup(context.Background(), cluster.StartupOptions{Reason: "vault-unseal"}); err != nil {
t.Fatalf("startup failed: %v", err)
}
if !recorder.contains("vault operator unseal unseal-key") {
t.Fatalf("expected vault unseal command in call log")
}
}
// TestLifecycleStartupServiceChecklistAutoHealsIngressBackends runs one orchestration or CLI step.
// Signature: TestLifecycleStartupServiceChecklistAutoHealsIngressBackends(t *testing.T).
// Why: covers service-checklist failure recovery by auto-healing ingress namespace backend replicas.
func TestLifecycleStartupServiceChecklistAutoHealsIngressBackends(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.RequireCriticalServiceEndpoints = false
cfg.Startup.RequireFluxHealth = false
cfg.Startup.RequireWorkloadConvergence = false
cfg.Startup.RequireServiceChecklist = true
cfg.Startup.ServiceChecklistWaitSeconds = 3
cfg.Startup.ServiceChecklistPollSeconds = 1
cfg.Startup.ServiceChecklistStabilitySec = 0
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
requests++
w.WriteHeader(http.StatusOK)
if requests <= 1 {
_, _ = w.Write([]byte(`{"database":"booting"}`))
return
}
_, _ = w.Write([]byte(`{"database":"ok"}`))
}))
defer server.Close()
cfg.Startup.ServiceChecklist = []config.ServiceChecklistCheck{
{
Name: "grafana-check",
URL: server.URL,
AcceptedStatuses: []int{200},
BodyContains: `"database":"ok"`,
TimeoutSeconds: 5,
},
}
recorder := &commandRecorder{}
base := lifecycleDispatcher(recorder)
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "get ingress -A -o json"):
recorder.record(name, args)
return `{"items":[{"metadata":{"namespace":"monitoring","name":"grafana"},"spec":{"rules":[{"host":"127.0.0.1"}]}}]}`, nil
case name == "kubectl" && strings.Contains(command, "get deploy,statefulset -A -o json"):
recorder.record(name, args)
return `{"items":[{"kind":"Deployment","metadata":{"namespace":"monitoring","name":"grafana"},"spec":{"replicas":0,"template":{"spec":{}}},"status":{"readyReplicas":0}}]}`, nil
case name == "kubectl" && strings.Contains(command, "-n monitoring scale deployment grafana --replicas=1"):
recorder.record(name, args)
return "", nil
case name == "kubectl" && strings.Contains(command, "rollout status deployment/grafana"):
recorder.record(name, args)
return "rolled out", nil
default:
return base(ctx, timeout, name, args...)
}
}
orch := cluster.New(cfg, &execx.Runner{DryRun: false}, state.New(cfg.State.RunHistoryPath), log.New(io.Discard, "", 0))
orch.SetCommandOverrides(run, run)
if err := orch.Startup(context.Background(), cluster.StartupOptions{Reason: "service-heal"}); err != nil {
t.Fatalf("startup failed: %v", err)
}
if !recorder.contains("scale deployment grafana --replicas=1") {
t.Fatalf("expected auto-heal scale command in call log")
}
}
// TestLifecycleStartupWithPostStartProbes runs one orchestration or CLI step.
// Signature: TestLifecycleStartupWithPostStartProbes(t *testing.T).
// Why: covers post-start probe readiness checks in the final startup phase.
func TestLifecycleStartupWithPostStartProbes(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.RequireCriticalServiceEndpoints = false
cfg.Startup.RequireFluxHealth = false
cfg.Startup.RequireWorkloadConvergence = false
cfg.Startup.RequirePostStartProbes = true
cfg.Startup.PostStartProbeWaitSeconds = 2
cfg.Startup.PostStartProbePollSeconds = 1
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
}))
defer server.Close()
cfg.Startup.PostStartProbes = []string{server.URL}
recorder := &commandRecorder{}
base := lifecycleDispatcher(recorder)
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
if name == "curl" {
recorder.record(name, args)
return "200", nil
}
return base(ctx, timeout, name, args...)
}
orch, _ := newLifecycleOrchestratorWithConfig(t, cfg, run, run)
if err := orch.Startup(context.Background(), cluster.StartupOptions{Reason: "post-start-probes"}); err != nil {
t.Fatalf("startup failed: %v", err)
}
}
// TestLifecycleStartupForceFluxBranchPatchesSource runs one orchestration or CLI step.
// Signature: TestLifecycleStartupForceFluxBranchPatchesSource(t *testing.T).
// Why: covers the intentional branch-patch path used during controlled startup override scenarios.
func TestLifecycleStartupForceFluxBranchPatchesSource(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.RequireCriticalServiceEndpoints = false
cfg.Startup.RequireFluxHealth = false
cfg.Startup.RequireWorkloadConvergence = false
recorder := &commandRecorder{}
base := lifecycleDispatcher(recorder)
patched := false
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, "patch gitrepository flux-system --type=merge") {
recorder.record(name, args)
patched = true
return "", nil
}
return base(ctx, timeout, name, args...)
}
orch := cluster.New(cfg, &execx.Runner{DryRun: false}, state.New(cfg.State.RunHistoryPath), log.New(io.Discard, "", 0))
orch.SetCommandOverrides(run, run)
if err := orch.Startup(context.Background(), cluster.StartupOptions{
Reason: "force-branch",
ForceFluxBranch: "feature/sso",
}); err != nil {
t.Fatalf("startup failed: %v", err)
}
if !patched {
t.Fatalf("expected forced branch startup to patch GitRepository branch")
}
}
// TestLifecycleStartupPeerRecentShutdownBlocks runs one orchestration or CLI step.
// Signature: TestLifecycleStartupPeerRecentShutdownBlocks(t *testing.T).
// Why: validates peer coordination cooldown guard before startup proceeds.
func TestLifecycleStartupPeerRecentShutdownBlocks(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Coordination.PeerHosts = []string{"titan-24"}
cfg.SSHManagedNodes = append(cfg.SSHManagedNodes, "titan-24")
cfg.SSHNodeHosts["titan-24"] = "titan-24"
recorder := &commandRecorder{}
base := lifecycleDispatcher(recorder)
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "ssh" && strings.Contains(command, "ananke intent --config /etc/ananke/ananke.yaml") {
recorder.record(name, args)
now := time.Now().UTC().Format(time.RFC3339)
return "__ANANKE_BOOTSTRAP_IDLE__\nintent=shutdown_complete reason=\"drill\" source=peer updated_at=" + now + "\n", nil
}
return base(ctx, timeout, name, args...)
}
orch := cluster.New(cfg, &execx.Runner{DryRun: false}, state.New(cfg.State.RunHistoryPath), log.New(io.Discard, "", 0))
orch.SetCommandOverrides(run, run)
err := orch.Startup(context.Background(), cluster.StartupOptions{Reason: "peer-cooldown"})
if err == nil {
t.Fatalf("expected startup to be blocked by recent peer shutdown completion")
}
if !strings.Contains(err.Error(), "completed shutdown too recently") {
t.Fatalf("unexpected startup error: %v", err)
}
}
// TestLifecycleStartupFailsWhenAllBootstrapPathsFail runs one orchestration or CLI step.
// Signature: TestLifecycleStartupFailsWhenAllBootstrapPathsFail(t *testing.T).
// Why: covers bootstrap fallback failure reporting when apply, render/apply, and cache paths all fail.
func TestLifecycleStartupFailsWhenAllBootstrapPathsFail(t *testing.T) {
cfg := lifecycleConfig(t)
repo := t.TempDir()
cfg.IACRepoPath = repo
cfg.LocalBootstrapPaths = []string{"services/bootstrap"}
cfg.Startup.RequireCriticalServiceEndpoints = false
cfg.Startup.RequireFluxHealth = false
cfg.Startup.RequireWorkloadConvergence = false
if err := os.MkdirAll(filepath.Join(repo, ".git"), 0o755); err != nil {
t.Fatalf("mkdir .git: %v", err)
}
if err := os.MkdirAll(filepath.Join(repo, "services", "bootstrap"), 0o755); err != nil {
t.Fatalf("mkdir bootstrap path: %v", err)
}
recorder := &commandRecorder{}
base := lifecycleDispatcher(recorder)
fluxReadyChecks := 0
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" &&
strings.Contains(command, "get gitrepository flux-system") &&
strings.Contains(command, "jsonpath={.status.conditions[?(@.type==\"Ready\")].status}"):
recorder.record(name, args)
fluxReadyChecks++
if fluxReadyChecks <= 3 {
return "", fmt.Errorf("flux source unavailable")
}
return "True", nil
case name == "kubectl" && strings.Contains(command, " apply -k "):
recorder.record(name, args)
return "", fmt.Errorf("apply -k failed")
default:
return base(ctx, timeout, name, args...)
}
}
runSensitive := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "git" && strings.Contains(command, "status --porcelain") {
recorder.record(name, args)
return "", nil
}
if name == "sh" && strings.Contains(command, "kubectl kustomize") {
recorder.record(name, args)
return "", fmt.Errorf("render failed")
}
return run(ctx, timeout, name, args...)
}
orch := cluster.New(cfg, &execx.Runner{DryRun: false}, state.New(cfg.State.RunHistoryPath), log.New(io.Discard, "", 0))
orch.SetCommandOverrides(run, runSensitive)
err := orch.Startup(context.Background(), cluster.StartupOptions{Reason: "bootstrap-fail"})
if err == nil {
t.Fatalf("expected startup to fail when all bootstrap paths fail")
}
if !strings.Contains(err.Error(), "local bootstrap apply failed for every configured path") {
t.Fatalf("unexpected startup error: %v", err)
}
}
// TestLifecycleStartupVaultUsesCachedUnsealKeyFallback runs one orchestration or CLI step.
// Signature: TestLifecycleStartupVaultUsesCachedUnsealKeyFallback(t *testing.T).
// Why: covers vault unseal fallback when secret retrieval fails but cached key exists.
func TestLifecycleStartupVaultUsesCachedUnsealKeyFallback(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.RequireCriticalServiceEndpoints = false
cfg.Startup.RequireFluxHealth = false
cfg.Startup.RequireWorkloadConvergence = false
if err := os.WriteFile(cfg.Startup.VaultUnsealKeyFile, []byte("cached-unseal-key\n"), 0o600); err != nil {
t.Fatalf("write cached key: %v", err)
}
recorder := &commandRecorder{}
base := lifecycleDispatcher(recorder)
vaultReadyChecks := 0
vaultSealedChecks := 0
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "-n vault get pod vault-0 -o jsonpath={.status.phase}"):
recorder.record(name, args)
return "Running", nil
case name == "kubectl" && strings.Contains(command, "-n vault get statefulset vault -o jsonpath={.status.readyReplicas}"):
recorder.record(name, args)
vaultReadyChecks++
if vaultReadyChecks <= 2 {
return "0", nil
}
return "1", nil
case name == "kubectl" && strings.Contains(command, "vault status -format=json"):
recorder.record(name, args)
vaultSealedChecks++
if vaultSealedChecks <= 1 {
return `{"sealed":true,"initialized":true}`, nil
}
return `{"sealed":false,"initialized":true}`, nil
case name == "kubectl" && strings.Contains(command, "-n vault get secret vault-init -o jsonpath={.data.unseal_key_b64}"):
recorder.record(name, args)
return "", fmt.Errorf("secret not found")
default:
return base(ctx, timeout, name, args...)
}
}
runSensitive := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, "vault operator unseal cached-unseal-key") {
recorder.record(name, args)
return "ok", nil
}
return run(ctx, timeout, name, args...)
}
orch := cluster.New(cfg, &execx.Runner{DryRun: false}, state.New(cfg.State.RunHistoryPath), log.New(io.Discard, "", 0))
orch.SetCommandOverrides(run, runSensitive)
if err := orch.Startup(context.Background(), cluster.StartupOptions{Reason: "vault-cached-key"}); err != nil {
t.Fatalf("startup failed: %v", err)
}
if !recorder.contains("vault operator unseal cached-unseal-key") {
t.Fatalf("expected cached key unseal command in call log")
}
}
// TestLifecycleStartupIngressChecklistTimeout runs one orchestration or CLI step.
// Signature: TestLifecycleStartupIngressChecklistTimeout(t *testing.T).
// Why: covers ingress checklist timeout and host-derived auto-heal attempt branches.
func TestLifecycleStartupIngressChecklistTimeout(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.RequireIngressChecklist = true
cfg.Startup.IngressChecklistWaitSeconds = 1
cfg.Startup.IngressChecklistPollSeconds = 1
cfg.Startup.IngressChecklistInsecureSkip = true
cfg.Startup.RequireCriticalServiceEndpoints = false
cfg.Startup.RequireFluxHealth = false
cfg.Startup.RequireWorkloadConvergence = false
recorder := &commandRecorder{}
base := lifecycleDispatcher(recorder)
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "get ingress -A -o json"):
recorder.record(name, args)
return `{"items":[{"metadata":{"namespace":"monitoring","name":"grafana"},"spec":{"rules":[{"host":"127.0.0.1"}]}}]}`, nil
case name == "kubectl" && strings.Contains(command, "get deploy,statefulset -A -o json"):
recorder.record(name, args)
return `{"items":[]}`, nil
default:
return base(ctx, timeout, name, args...)
}
}
orch := cluster.New(cfg, &execx.Runner{DryRun: false}, state.New(cfg.State.RunHistoryPath), log.New(io.Discard, "", 0))
orch.SetCommandOverrides(run, run)
err := orch.Startup(context.Background(), cluster.StartupOptions{Reason: "ingress-timeout"})
if err == nil {
t.Fatalf("expected startup to fail when ingress checklist cannot pass")
}
if !strings.Contains(err.Error(), "ingress checklist not satisfied") {
t.Fatalf("unexpected startup error: %v", err)
}
}
// TestLifecycleStartupRepairsHostKeyAndRetriesSSHAuth runs one orchestration or CLI step.
// Signature: TestLifecycleStartupRepairsHostKeyAndRetriesSSHAuth(t *testing.T).
// Why: covers host-key mismatch retry logic inside SSH auth gate during startup.
func TestLifecycleStartupRepairsHostKeyAndRetriesSSHAuth(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.RequireNodeSSHAuth = true
cfg.Startup.NodeSSHAuthWaitSeconds = 2
cfg.Startup.NodeSSHAuthPollSeconds = 1
cfg.Startup.RequireCriticalServiceEndpoints = false
cfg.Startup.RequireFluxHealth = false
cfg.Startup.RequireWorkloadConvergence = false
recorder := &commandRecorder{}
base := lifecycleDispatcher(recorder)
repairAttempts := 0
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "ssh" && strings.Contains(command, "__ANANKE_SSH_AUTH_OK__") && strings.Contains(command, "titan-23") {
recorder.record(name, args)
repairAttempts++
if repairAttempts == 1 {
return "Host key verification failed", fmt.Errorf("exit status 255")
}
return "__ANANKE_SSH_AUTH_OK__", nil
}
return base(ctx, timeout, name, args...)
}
orch := cluster.New(cfg, &execx.Runner{DryRun: false}, state.New(cfg.State.RunHistoryPath), log.New(io.Discard, "", 0))
orch.SetCommandOverrides(run, run)
if err := orch.Startup(context.Background(), cluster.StartupOptions{Reason: "ssh-hostkey-repair"}); err != nil {
t.Fatalf("startup failed: %v", err)
}
if repairAttempts < 2 {
t.Fatalf("expected ssh auth retry after host key mismatch, attempts=%d", repairAttempts)
}
}

View File

@ -0,0 +1,243 @@
package orchestrator
import (
"context"
"fmt"
"io"
"log"
"os"
"path/filepath"
"strings"
"testing"
"time"
"scm.bstein.dev/bstein/ananke/internal/cluster"
"scm.bstein.dev/bstein/ananke/internal/config"
"scm.bstein.dev/bstein/ananke/internal/execx"
"scm.bstein.dev/bstein/ananke/internal/state"
)
// newHookOrchestrator runs one orchestration or CLI step.
// Signature: newHookOrchestrator(t *testing.T, cfg config.Config, run commandOverride, runSensitive commandOverride) (*cluster.Orchestrator, *commandRecorder).
// Why: keeps hook-level tests concise while preserving deterministic command behavior.
func newHookOrchestrator(
t *testing.T,
cfg config.Config,
run func(context.Context, time.Duration, string, ...string) (string, error),
runSensitive func(context.Context, time.Duration, string, ...string) (string, error),
) (*cluster.Orchestrator, *commandRecorder) {
t.Helper()
if err := os.MkdirAll(cfg.State.Dir, 0o755); err != nil {
t.Fatalf("ensure state dir: %v", err)
}
recorder := &commandRecorder{}
if run == nil || runSensitive == nil {
base := lifecycleDispatcher(recorder)
if run == nil {
run = base
}
if runSensitive == nil {
runSensitive = base
}
}
orch := cluster.New(cfg, &execx.Runner{DryRun: false}, state.New(cfg.State.RunHistoryPath), log.New(io.Discard, "", 0))
orch.SetCommandOverrides(run, runSensitive)
return orch, recorder
}
// TestHookAccessFluxsourceBranches runs one orchestration or CLI step.
// Signature: TestHookAccessFluxsourceBranches(t *testing.T).
// Why: exercises direct access/fluxsource internals that are expensive to cover via full lifecycle tests.
func TestHookAccessFluxsourceBranches(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.RequireNodeSSHAuth = true
cfg.Startup.NodeSSHAuthWaitSeconds = 1
cfg.Startup.NodeSSHAuthPollSeconds = 1
repo := t.TempDir()
cfg.IACRepoPath = repo
cfg.LocalBootstrapPaths = []string{"services/bootstrap"}
if err := os.MkdirAll(filepath.Join(repo, ".git"), 0o755); err != nil {
t.Fatalf("mkdir .git: %v", err)
}
if err := os.MkdirAll(filepath.Join(repo, "services", "bootstrap"), 0o755); err != nil {
t.Fatalf("mkdir bootstrap path: %v", err)
}
recorder := &commandRecorder{}
base := lifecycleDispatcher(recorder)
fluxReadyCalls := 0
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "ssh" && strings.Contains(command, "__ANANKE_SSH_AUTH_OK__") && strings.Contains(command, "titan-23"):
recorder.record(name, args)
return "__ANANKE_SSH_AUTH_OK__", nil
case name == "ssh" && strings.Contains(command, "/usr/bin/systemctl --version"):
recorder.record(name, args)
return "systemd 252", nil
case name == "kubectl" && strings.Contains(command, "jsonpath={.status.conditions[?(@.type==\"Ready\")].status}"):
recorder.record(name, args)
fluxReadyCalls++
if fluxReadyCalls <= 1 {
return "", nil
}
return "True", nil
case name == "kubectl" && strings.Contains(command, "jsonpath={.spec.url}"):
recorder.record(name, args)
return cfg.ExpectedFluxSource, nil
case name == "kubectl" && strings.Contains(command, "jsonpath={.spec.ref.branch}"):
recorder.record(name, args)
return "main", nil
case name == "kubectl" && strings.Contains(command, "patch gitrepository flux-system"):
recorder.record(name, args)
return "", nil
case name == "kubectl" && strings.Contains(command, " apply -k "):
recorder.record(name, args)
return "", fmt.Errorf("apply -k failed")
default:
return base(ctx, timeout, name, args...)
}
}
runSensitive := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "git" && strings.Contains(command, "status --porcelain"):
recorder.record(name, args)
return "", nil
case name == "git" && strings.Contains(command, "fetch origin --prune"):
recorder.record(name, args)
return "", nil
case name == "git" && strings.Contains(command, "checkout main"):
recorder.record(name, args)
return "", nil
case name == "git" && strings.Contains(command, "reset --hard origin/main"):
recorder.record(name, args)
return "", nil
case name == "sh" && strings.Contains(command, "kubectl kustomize"):
recorder.record(name, args)
if strings.Contains(command, "| kubectl apply -f -") {
return "", nil
}
return "apiVersion: v1\nkind: Namespace\nmetadata:\n name: bootstrap\n", nil
default:
return run(ctx, timeout, name, args...)
}
}
orch := cluster.New(cfg, &execx.Runner{DryRun: false}, state.New(cfg.State.RunHistoryPath), log.New(io.Discard, "", 0))
orch.SetCommandOverrides(run, runSensitive)
if got := cluster.TestHookNormalizeGitURL("SSH://git@SCM.BSTEIN.DEV:2242/bstein/titan-iac.git/"); got != "ssh://git@scm.bstein.dev:2242/bstein/titan-iac" {
t.Fatalf("unexpected normalized URL: %s", got)
}
if err := orch.TestHookReconcileNodeAccess(context.Background(), []string{"titan-db", "titan-23"}); err != nil {
t.Fatalf("reconcile node access: %v", err)
}
if err := orch.TestHookWaitForNodeSSHAuth(context.Background(), []string{"titan-db", "titan-23"}); err != nil {
t.Fatalf("wait for node ssh auth: %v", err)
}
if err := orch.TestHookGuardFluxSourceDrift(context.Background(), "main", false); err != nil {
t.Fatalf("guard flux source drift: %v", err)
}
if err := orch.TestHookEnsureFluxBranch(context.Background(), "feature/sso", true); err != nil {
t.Fatalf("ensure flux branch: %v", err)
}
if err := orch.TestHookBootstrapLocal(context.Background()); err != nil {
t.Fatalf("bootstrap local: %v", err)
}
if err := orch.TestHookApplyKustomizeFallback(context.Background(), filepath.Join(repo, "services", "bootstrap")); err != nil {
t.Fatalf("apply kustomize fallback: %v", err)
}
if err := orch.TestHookSyncLocalIACRepo(context.Background()); err != nil {
t.Fatalf("sync local IAC repo: %v", err)
}
if err := orch.TestHookRefreshBootstrapCache(context.Background()); err != nil {
t.Fatalf("refresh bootstrap cache: %v", err)
}
cacheRel := "services/bootstrap"
cachePath := orch.TestHookBootstrapCachePath(cacheRel)
if _, err := os.Stat(cachePath); err != nil {
t.Fatalf("expected bootstrap cache path %s: %v", cachePath, err)
}
if err := orch.TestHookApplyBootstrapCache(context.Background(), cacheRel); err != nil {
t.Fatalf("apply bootstrap cache: %v", err)
}
if err := orch.TestHookPreflightExternalDatastore(context.Background()); err != nil {
t.Fatalf("preflight external datastore: %v", err)
}
if err := orch.TestHookWaitForNodeInventoryReachability(context.Background()); err != nil {
t.Fatalf("wait for node inventory reachability: %v", err)
}
ready, err := orch.TestHookWaitForFluxSourceReady(context.Background(), 2*time.Second)
if err != nil {
t.Fatalf("wait for flux source ready: %v", err)
}
if !ready {
t.Fatalf("expected flux source readiness wait to succeed")
}
}
// TestHookCoordinationBranches runs one orchestration or CLI step.
// Signature: TestHookCoordinationBranches(t *testing.T).
// Why: exercises coordination helper internals and snapshot verification without a full lifecycle run.
func TestHookCoordinationBranches(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Coordination.PeerHosts = []string{"titan-24"}
cfg.SSHManagedNodes = append(cfg.SSHManagedNodes, "titan-24")
cfg.SSHNodeHosts["titan-24"] = "titan-24"
recorder := &commandRecorder{}
base := lifecycleDispatcher(recorder)
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "ssh" && strings.Contains(command, "ananke intent --config /etc/ananke/ananke.yaml"):
recorder.record(name, args)
now := time.Now().UTC().Add(-2 * time.Minute).Format(time.RFC3339)
return "__ANANKE_BOOTSTRAP_IDLE__\nintent=normal reason=\"ok\" source=peer updated_at=" + now + "\n", nil
case name == "ssh" && strings.Contains(command, "ananke intent --config /etc/ananke/ananke.yaml --set normal"):
recorder.record(name, args)
return "ok", nil
case name == "ssh" && strings.Contains(command, "stat -c %s"):
recorder.record(name, args)
return "2097152", nil
case name == "ssh" && strings.Contains(command, "sha256sum"):
recorder.record(name, args)
return "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil
case name == "ssh" && strings.Contains(command, "systemctl cat k3s"):
recorder.record(name, args)
return "ExecStart=/usr/local/bin/k3s server --datastore-endpoint=postgres://db:5432/k3s", nil
default:
return base(ctx, timeout, name, args...)
}
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
if err := orch.TestHookGuardPeerStartupIntents(context.Background()); err != nil {
t.Fatalf("guard peer startup intents: %v", err)
}
peer, err := orch.TestHookReadRemotePeerStatus(context.Background(), "titan-24")
if err != nil {
t.Fatalf("read remote peer status: %v", err)
}
if peer.Intent.State != state.IntentNormal {
t.Fatalf("unexpected peer intent: %+v", peer.Intent)
}
if err := orch.TestHookClearRemotePeerIntent(context.Background(), "titan-24", "test-clear"); err != nil {
t.Fatalf("clear remote peer intent: %v", err)
}
if err := orch.TestHookVerifyEtcdSnapshot(context.Background(), "titan-db", "/var/lib/rancher/k3s/server/db/snapshots/pre-shutdown"); err != nil {
t.Fatalf("verify etcd snapshot: %v", err)
}
external, err := orch.TestHookControlPlaneUsesExternalDatastore(context.Background(), "titan-db")
if err != nil {
t.Fatalf("controlPlaneUsesExternalDatastore: %v", err)
}
if !external {
t.Fatalf("expected external datastore detection")
}
if err := orch.TestHookWaitForAPI(context.Background(), 1, 0); err != nil {
t.Fatalf("wait for API: %v", err)
}
}

View File

@ -0,0 +1,194 @@
package orchestrator
import (
"context"
"errors"
"io"
"log"
"os"
"path/filepath"
"strings"
"testing"
"time"
"scm.bstein.dev/bstein/ananke/internal/cluster"
"scm.bstein.dev/bstein/ananke/internal/execx"
"scm.bstein.dev/bstein/ananke/internal/state"
)
// TestHookAccessDeepMatrixReconcileAndSSHAuth runs one orchestration or CLI step.
// Signature: TestHookAccessDeepMatrixReconcileAndSSHAuth(t *testing.T).
// Why: closes remaining access-gate branch gaps for access validation and SSH
// auth wait behavior in the top-level testing module.
func TestHookAccessDeepMatrixReconcileAndSSHAuth(t *testing.T) {
t.Run("reconcile-access-aggregates-errors", func(t *testing.T) {
cfg := lifecycleConfig(t)
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "ssh" && strings.Contains(command, "/usr/bin/systemctl --version") {
return "", errors.New("sudo denied")
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
err := orch.TestHookReconcileNodeAccess(context.Background(), []string{"titan-db", "titan-23"})
if err == nil || !strings.Contains(err.Error(), "access validation had") || !strings.Contains(err.Error(), "missing sudo access") {
t.Fatalf("expected aggregated reconcile error, got %v", err)
}
})
t.Run("ssh-auth-denied-fails-fast", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.RequireNodeSSHAuth = true
cfg.Startup.NodeSSHAuthWaitSeconds = 1
cfg.Startup.NodeSSHAuthPollSeconds = 1
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "ssh" && strings.Contains(command, "__ANANKE_SSH_AUTH_OK__") {
return "Permission denied (publickey)", errors.New("permission denied")
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
err := orch.TestHookWaitForNodeSSHAuth(context.Background(), []string{"titan-db", "titan-23"})
if err == nil || !strings.Contains(err.Error(), "ssh auth gate failed") {
t.Fatalf("expected ssh auth denied error, got %v", err)
}
})
t.Run("ssh-auth-timeout-and-context-cancel", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.RequireNodeSSHAuth = true
cfg.Startup.NodeSSHAuthWaitSeconds = 1
cfg.Startup.NodeSSHAuthPollSeconds = 1
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "ssh" && strings.Contains(command, "__ANANKE_SSH_AUTH_OK__") {
return "no route to host", errors.New("network unreachable")
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
err := orch.TestHookWaitForNodeSSHAuth(context.Background(), []string{"titan-db"})
if err == nil || !strings.Contains(err.Error(), "node ssh auth gate did not pass within") {
t.Fatalf("expected timeout branch, got %v", err)
}
cancelCtx, cancel := context.WithCancel(context.Background())
cancel()
err = orch.TestHookWaitForNodeSSHAuth(cancelCtx, []string{"titan-db"})
if !errors.Is(err, context.Canceled) {
t.Fatalf("expected canceled context, got %v", err)
}
})
}
// TestHookAccessDeepMatrixLocalRepoAndCache runs one orchestration or CLI step.
// Signature: TestHookAccessDeepMatrixLocalRepoAndCache(t *testing.T).
// Why: covers local-repo sync and bootstrap-cache error branches that are not
// always exercised by lifecycle startup tests.
func TestHookAccessDeepMatrixLocalRepoAndCache(t *testing.T) {
t.Run("sync-local-repo-input-and-command-errors", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.IACRepoPath = ""
orch, _ := newHookOrchestrator(t, cfg, nil, nil)
if err := orch.TestHookSyncLocalIACRepo(context.Background()); err == nil {
t.Fatalf("expected empty repo path error")
}
repo := t.TempDir()
cfg = lifecycleConfig(t)
cfg.IACRepoPath = repo
orch, _ = newHookOrchestrator(t, cfg, nil, nil)
if err := orch.TestHookSyncLocalIACRepo(context.Background()); err == nil || !strings.Contains(err.Error(), "is not a git checkout") {
t.Fatalf("expected missing .git error, got %v", err)
}
if err := os.MkdirAll(filepath.Join(repo, ".git"), 0o755); err != nil {
t.Fatalf("mkdir .git: %v", err)
}
runStatusErr := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "git" && strings.Contains(command, "status --porcelain") {
return "", errors.New("status failed")
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orch, _ = newHookOrchestrator(t, cfg, runStatusErr, runStatusErr)
if err := orch.TestHookSyncLocalIACRepo(context.Background()); err == nil || !strings.Contains(err.Error(), "inspect iac repo working tree") {
t.Fatalf("expected status error branch, got %v", err)
}
runDirty := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "git" && strings.Contains(command, "status --porcelain") {
return " M README.md", nil
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orch, _ = newHookOrchestrator(t, cfg, runDirty, runDirty)
if err := orch.TestHookSyncLocalIACRepo(context.Background()); err != nil {
t.Fatalf("expected dirty-tree skip branch success, got %v", err)
}
runFetchErr := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "git" && strings.Contains(command, "status --porcelain"):
return "", nil
case name == "git" && strings.Contains(command, "fetch origin --prune"):
return "", errors.New("fetch failed")
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orch, _ = newHookOrchestrator(t, cfg, runFetchErr, runFetchErr)
if err := orch.TestHookSyncLocalIACRepo(context.Background()); err == nil || !strings.Contains(err.Error(), "git fetch origin") {
t.Fatalf("expected fetch error branch, got %v", err)
}
})
t.Run("bootstrap-cache-apply-and-refresh-errors", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.LocalBootstrapPaths = []string{"services/bootstrap"}
cfg.IACRepoPath = t.TempDir()
orch, _ := newHookOrchestrator(t, cfg, nil, nil)
if err := orch.TestHookApplyBootstrapCache(context.Background(), "services/bootstrap"); err == nil || !strings.Contains(err.Error(), "bootstrap cache missing") {
t.Fatalf("expected missing cache error, got %v", err)
}
stateFile := filepath.Join(t.TempDir(), "state-file")
if err := os.WriteFile(stateFile, []byte("x"), 0o644); err != nil {
t.Fatalf("write state-file: %v", err)
}
cfgFailMkdir := lifecycleConfig(t)
cfgFailMkdir.State.Dir = stateFile
cfgFailMkdir.LocalBootstrapPaths = []string{"services/bootstrap"}
cfgFailMkdir.IACRepoPath = t.TempDir()
orchFailMkdir := cluster.New(cfgFailMkdir, &execx.Runner{DryRun: false}, state.New(cfgFailMkdir.State.RunHistoryPath), log.New(io.Discard, "", 0))
orchFailMkdir.SetCommandOverrides(lifecycleDispatcher(&commandRecorder{}), lifecycleDispatcher(&commandRecorder{}))
if err := orchFailMkdir.TestHookRefreshBootstrapCache(context.Background()); err == nil || !strings.Contains(err.Error(), "ensure bootstrap cache dir") {
t.Fatalf("expected cache-dir creation failure, got %v", err)
}
runApplyErr := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, " apply -f ") {
return "", errors.New("apply failed")
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orchApply, _ := newHookOrchestrator(t, cfg, runApplyErr, runApplyErr)
cachePath := orchApply.TestHookBootstrapCachePath("services/bootstrap")
if err := os.MkdirAll(filepath.Dir(cachePath), 0o755); err != nil {
t.Fatalf("mkdir cache dir: %v", err)
}
if err := os.WriteFile(cachePath, []byte("apiVersion: v1\nkind: Namespace\nmetadata:\n name: cached\n"), 0o644); err != nil {
t.Fatalf("write cache file: %v", err)
}
if err := orchApply.TestHookApplyBootstrapCache(context.Background(), "services/bootstrap"); err == nil {
t.Fatalf("expected apply cache command failure")
}
})
}

View File

@ -0,0 +1,366 @@
package orchestrator
import (
"context"
"errors"
"fmt"
"io"
"log"
"os"
"path/filepath"
"strings"
"testing"
"time"
"scm.bstein.dev/bstein/ananke/internal/cluster"
"scm.bstein.dev/bstein/ananke/internal/execx"
"scm.bstein.dev/bstein/ananke/internal/state"
)
// TestHookNodeReachabilityAndSSHAuthFailureBranches runs one orchestration or CLI step.
// Signature: TestHookNodeReachabilityAndSSHAuthFailureBranches(t *testing.T).
// Why: reaches non-happy-path auth/reachability branches that only appear during real
// drill disruptions so startup gates do not regress silently.
func TestHookNodeReachabilityAndSSHAuthFailureBranches(t *testing.T) {
t.Run("node-inventory-unmanaged-node-fails-fast", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.RequireNodeInventoryReach = true
cfg.Startup.NodeInventoryReachWaitSeconds = 1
cfg.Startup.NodeInventoryReachPollSeconds = 1
cfg.SSHManagedNodes = []string{"titan-db"}
orch, _ := newHookOrchestrator(t, cfg, nil, nil)
err := orch.TestHookWaitForNodeInventoryReachability(context.Background())
if err == nil || !strings.Contains(err.Error(), "not in ssh_managed_nodes") {
t.Fatalf("expected unmanaged-node reachability failure, got %v", err)
}
})
t.Run("node-inventory-timeout-and-context-cancel", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.RequireNodeInventoryReach = true
cfg.Startup.NodeInventoryReachWaitSeconds = 1
cfg.Startup.NodeInventoryReachPollSeconds = 1
cfg.SSHManagedNodes = []string{"titan-db", "titan-23"}
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "ssh" && strings.Contains(command, "__ANANKE_NODE_REACHABLE__") && strings.Contains(command, "titan-23") {
return "unexpected", nil
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
err := orch.TestHookWaitForNodeInventoryReachability(context.Background())
if err == nil || !strings.Contains(err.Error(), "did not pass within") {
t.Fatalf("expected reachability timeout, got %v", err)
}
cfg.Startup.NodeInventoryReachWaitSeconds = 30
orchCanceled, _ := newHookOrchestrator(t, cfg, run, run)
cancelCtx, cancel := context.WithCancel(context.Background())
cancel()
err = orchCanceled.TestHookWaitForNodeInventoryReachability(cancelCtx)
if !errors.Is(err, context.Canceled) {
t.Fatalf("expected context canceled from reachability gate, got %v", err)
}
})
t.Run("node-ssh-auth-denied-timeout-and-context-cancel", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.RequireNodeSSHAuth = true
cfg.Startup.NodeSSHAuthWaitSeconds = 1
cfg.Startup.NodeSSHAuthPollSeconds = 1
cfg.SSHManagedNodes = []string{"titan-db", "titan-23"}
deniedRun := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "ssh" && strings.Contains(command, "__ANANKE_SSH_AUTH_OK__") && strings.Contains(command, "titan-23") {
return "", fmt.Errorf("Permission denied (publickey)")
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orchDenied, _ := newHookOrchestrator(t, cfg, deniedRun, deniedRun)
err := orchDenied.TestHookWaitForNodeSSHAuth(context.Background(), []string{"titan-db", "titan-23"})
if err == nil || !strings.Contains(err.Error(), "ssh auth gate failed") {
t.Fatalf("expected ssh auth denied failure, got %v", err)
}
timeoutRun := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "ssh" && strings.Contains(command, "__ANANKE_SSH_AUTH_OK__") && strings.Contains(command, "titan-23") {
return "unexpected", nil
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orchTimeout, _ := newHookOrchestrator(t, cfg, timeoutRun, timeoutRun)
err = orchTimeout.TestHookWaitForNodeSSHAuth(context.Background(), []string{"titan-db", "titan-23"})
if err == nil || !strings.Contains(err.Error(), "did not pass within") {
t.Fatalf("expected ssh auth timeout, got %v", err)
}
cfg.Startup.NodeSSHAuthWaitSeconds = 30
orchCanceled, _ := newHookOrchestrator(t, cfg, timeoutRun, timeoutRun)
cancelCtx, cancel := context.WithCancel(context.Background())
cancel()
err = orchCanceled.TestHookWaitForNodeSSHAuth(cancelCtx, []string{"titan-db", "titan-23"})
if !errors.Is(err, context.Canceled) {
t.Fatalf("expected context canceled from ssh-auth gate, got %v", err)
}
})
}
// TestHookAccessAndFluxSourceFailureBranches runs one orchestration or CLI step.
// Signature: TestHookAccessAndFluxSourceFailureBranches(t *testing.T).
// Why: validates drift/branch/repo failure branches so startup catches source deadlocks
// before the cluster is declared recovered.
func TestHookAccessAndFluxSourceFailureBranches(t *testing.T) {
t.Run("reconcile-node-access-aggregates-errors", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.SSHManagedNodes = []string{"titan-db", "titan-23"}
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "ssh" && strings.Contains(command, "/usr/bin/systemctl --version") {
return "", fmt.Errorf("sudo blocked")
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
err := orch.TestHookReconcileNodeAccess(context.Background(), []string{"titan-db", "titan-23"})
if err == nil || !strings.Contains(err.Error(), "access validation had") {
t.Fatalf("expected access validation aggregation error, got %v", err)
}
})
t.Run("guard-and-ensure-flux-source-branches", func(t *testing.T) {
cfg := lifecycleConfig(t)
notFoundRun := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, "jsonpath={.spec.url}") {
return "", fmt.Errorf("Error from server (NotFound): gitrepositories.source.toolkit.fluxcd.io \"flux-system\" not found")
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orchNotFound, _ := newHookOrchestrator(t, cfg, notFoundRun, notFoundRun)
if err := orchNotFound.TestHookGuardFluxSourceDrift(context.Background(), "main", false); err != nil {
t.Fatalf("expected not-found branch to be tolerated, got %v", err)
}
readErrRun := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, "jsonpath={.spec.url}") {
return "", fmt.Errorf("boom")
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orchReadErr, _ := newHookOrchestrator(t, cfg, readErrRun, readErrRun)
if err := orchReadErr.TestHookGuardFluxSourceDrift(context.Background(), "main", false); err == nil {
t.Fatalf("expected flux source read error")
}
noPatchRun := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, "jsonpath={.spec.ref.branch}") {
return "feature/sso", nil
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orchNoPatch, _ := newHookOrchestrator(t, cfg, noPatchRun, noPatchRun)
if err := orchNoPatch.TestHookEnsureFluxBranch(context.Background(), "main", false); err == nil {
t.Fatalf("expected branch mismatch without patch permission")
}
patchErrRun := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "jsonpath={.spec.ref.branch}"):
return "feature/sso", nil
case name == "kubectl" && strings.Contains(command, "patch gitrepository flux-system"):
return "", fmt.Errorf("patch failed")
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orchPatchErr, _ := newHookOrchestrator(t, cfg, patchErrRun, patchErrRun)
if err := orchPatchErr.TestHookEnsureFluxBranch(context.Background(), "main", true); err == nil {
t.Fatalf("expected patch failure branch")
}
patchOKRun := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, "jsonpath={.spec.ref.branch}") {
return "feature/sso", nil
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orchPatchOK, _ := newHookOrchestrator(t, cfg, patchOKRun, patchOKRun)
if err := orchPatchOK.TestHookEnsureFluxBranch(context.Background(), "main", true); err != nil {
t.Fatalf("expected branch patch success, got %v", err)
}
})
t.Run("wait-for-flux-source-ready-error-timeout-and-cancel", func(t *testing.T) {
cfg := lifecycleConfig(t)
errorRun := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, "jsonpath={.status.conditions[?(@.type==\"Ready\")].status}") {
return "", fmt.Errorf("query failed")
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orchErr, _ := newHookOrchestrator(t, cfg, errorRun, errorRun)
if _, err := orchErr.TestHookWaitForFluxSourceReady(context.Background(), time.Second); err == nil {
t.Fatalf("expected readiness query error")
}
timeoutRun := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, "jsonpath={.status.conditions[?(@.type==\"Ready\")].status}") {
return "", nil
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orchTimeout, _ := newHookOrchestrator(t, cfg, timeoutRun, timeoutRun)
ready, err := orchTimeout.TestHookWaitForFluxSourceReady(context.Background(), time.Millisecond)
if err != nil || ready {
t.Fatalf("expected timeout branch (ready=false, err=nil), got ready=%v err=%v", ready, err)
}
cancelCtx, cancel := context.WithCancel(context.Background())
cancel()
_, err = orchTimeout.TestHookWaitForFluxSourceReady(cancelCtx, 30*time.Second)
if !errors.Is(err, context.Canceled) {
t.Fatalf("expected context canceled, got %v", err)
}
})
}
// TestHookBootstrapCacheAndRepoSyncFailureBranches runs one orchestration or CLI step.
// Signature: TestHookBootstrapCacheAndRepoSyncFailureBranches(t *testing.T).
// Why: covers local bootstrap/repo edge paths so bootstrap fallback behavior stays deterministic.
func TestHookBootstrapCacheAndRepoSyncFailureBranches(t *testing.T) {
t.Run("bootstrap-local-all-paths-fail", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.IACRepoPath = t.TempDir()
cfg.LocalBootstrapPaths = []string{"services/bootstrap"}
if err := os.MkdirAll(filepath.Join(cfg.IACRepoPath, "services", "bootstrap"), 0o755); err != nil {
t.Fatalf("mkdir bootstrap path: %v", err)
}
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, " apply -k "):
return "", fmt.Errorf("apply -k failed")
case name == "kubectl" && strings.Contains(command, " apply -f "):
return "", fmt.Errorf("cache apply failed")
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
runSensitive := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "sh" && strings.Contains(command, "kubectl kustomize") {
return "", fmt.Errorf("kustomize render failed")
}
return run(ctx, timeout, name, args...)
}
orch, _ := newHookOrchestrator(t, cfg, run, runSensitive)
err := orch.TestHookBootstrapLocal(context.Background())
if err == nil || !strings.Contains(err.Error(), "failed for every configured path") {
t.Fatalf("expected bootstrap all-failed error, got %v", err)
}
})
t.Run("sync-local-iac-repo-branches", func(t *testing.T) {
baseCfg := lifecycleConfig(t)
emptyCfg := baseCfg
emptyCfg.IACRepoPath = ""
orchEmpty, _ := newHookOrchestrator(t, emptyCfg, nil, nil)
if err := orchEmpty.TestHookSyncLocalIACRepo(context.Background()); err == nil {
t.Fatalf("expected empty repo path error")
}
notGitCfg := baseCfg
notGitCfg.IACRepoPath = t.TempDir()
orchNotGit, _ := newHookOrchestrator(t, notGitCfg, nil, nil)
if err := orchNotGit.TestHookSyncLocalIACRepo(context.Background()); err == nil {
t.Fatalf("expected non-git checkout error")
}
repo := t.TempDir()
if err := os.MkdirAll(filepath.Join(repo, ".git"), 0o755); err != nil {
t.Fatalf("mkdir .git: %v", err)
}
dirtyCfg := baseCfg
dirtyCfg.IACRepoPath = repo
runSensitive := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "git" && strings.Contains(command, "status --porcelain"):
return " M README.md\n", nil
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orchDirty, _ := newHookOrchestrator(t, dirtyCfg, nil, runSensitive)
if err := orchDirty.TestHookSyncLocalIACRepo(context.Background()); err != nil {
t.Fatalf("dirty working-tree branch should skip sync, got %v", err)
}
fetchErrRun := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "git" && strings.Contains(command, "status --porcelain"):
return "", nil
case name == "git" && strings.Contains(command, "fetch origin --prune"):
return "", fmt.Errorf("fetch failed")
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orchFetchErr, _ := newHookOrchestrator(t, dirtyCfg, nil, fetchErrRun)
if err := orchFetchErr.TestHookSyncLocalIACRepo(context.Background()); err == nil {
t.Fatalf("expected git fetch failure")
}
})
t.Run("refresh-and-apply-bootstrap-cache-branches", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.IACRepoPath = t.TempDir()
cfg.LocalBootstrapPaths = []string{"services/bootstrap"}
if err := os.MkdirAll(filepath.Join(cfg.IACRepoPath, "services", "bootstrap"), 0o755); err != nil {
t.Fatalf("mkdir bootstrap path: %v", err)
}
runSensitive := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "sh" && strings.Contains(command, "kubectl kustomize") {
return "", fmt.Errorf("render failed")
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orch, _ := newHookOrchestrator(t, cfg, nil, runSensitive)
if err := orch.TestHookRefreshBootstrapCache(context.Background()); err == nil {
t.Fatalf("expected refresh bootstrap cache failure when no renders succeed")
}
if err := orch.TestHookApplyBootstrapCache(context.Background(), "services/bootstrap"); err == nil {
t.Fatalf("expected apply bootstrap cache missing-file failure")
}
})
t.Run("wait-for-flux-source-ready-dry-run", func(t *testing.T) {
cfg := lifecycleConfig(t)
if err := os.MkdirAll(cfg.State.Dir, 0o755); err != nil {
t.Fatalf("ensure state dir: %v", err)
}
orch := cluster.New(cfg, &execx.Runner{DryRun: true}, state.New(cfg.State.RunHistoryPath), log.New(io.Discard, "", 0))
if ready, err := orch.TestHookWaitForFluxSourceReady(context.Background(), time.Second); err != nil || !ready {
t.Fatalf("expected dry-run readiness fast-path, got ready=%v err=%v", ready, err)
}
})
}

View File

@ -0,0 +1,293 @@
package orchestrator
import (
"context"
"errors"
"io"
"log"
"os"
"path/filepath"
"strings"
"testing"
"time"
"scm.bstein.dev/bstein/ananke/internal/cluster"
"scm.bstein.dev/bstein/ananke/internal/config"
"scm.bstein.dev/bstein/ananke/internal/execx"
"scm.bstein.dev/bstein/ananke/internal/state"
)
// newDryRunHookOrchestrator runs one orchestration or CLI step.
// Signature: newDryRunHookOrchestrator(t *testing.T, cfg config.Config, run commandOverride) *cluster.Orchestrator.
// Why: some branch-only paths are dry-run guarded; this helper lets top-level tests
// exercise those paths without mutating real systems.
func newDryRunHookOrchestrator(
t *testing.T,
cfg config.Config,
run func(context.Context, time.Duration, string, ...string) (string, error),
) *cluster.Orchestrator {
t.Helper()
if err := os.MkdirAll(cfg.State.Dir, 0o755); err != nil {
t.Fatalf("ensure state dir: %v", err)
}
if run == nil {
run = lifecycleDispatcher(&commandRecorder{})
}
orch := cluster.New(cfg, &execx.Runner{DryRun: true}, state.New(cfg.State.RunHistoryPath), log.New(io.Discard, "", 0))
orch.SetCommandOverrides(run, run)
return orch
}
// TestHookAccessFluxsourceBranchCloseout runs one orchestration or CLI step.
// Signature: TestHookAccessFluxsourceBranchCloseout(t *testing.T).
// Why: closes the remaining access/fluxsource branch gaps that only appear under
// unusual repo/auth states.
func TestHookAccessFluxsourceBranchCloseout(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.SSHManagedNodes = []string{"titan-db"}
cfg.Startup.RequireNodeSSHAuth = false
orch, _ := newHookOrchestrator(t, cfg, nil, nil)
if err := orch.TestHookReconcileNodeAccess(context.Background(), nil); err != nil {
t.Fatalf("expected empty-node reconcile fast-path, got %v", err)
}
if err := orch.TestHookWaitForNodeSSHAuth(context.Background(), []string{"titan-db"}); err != nil {
t.Fatalf("expected ssh-auth disabled fast-path, got %v", err)
}
dryRunCfg := lifecycleConfig(t)
dryRunCfg.LocalBootstrapPaths = []string{"services/bootstrap"}
dryRunCfg.IACRepoPath = t.TempDir()
if err := os.MkdirAll(filepath.Join(dryRunCfg.IACRepoPath, "services", "bootstrap"), 0o755); err != nil {
t.Fatalf("mkdir bootstrap path: %v", err)
}
orchDry := newDryRunHookOrchestrator(t, dryRunCfg, nil)
if err := orchDry.TestHookBootstrapLocal(context.Background()); err != nil {
t.Fatalf("expected dry-run bootstrap success, got %v", err)
}
cfgWaitDefaults := lifecycleConfig(t)
cfgWaitDefaults.Startup.RequireNodeSSHAuth = true
cfgWaitDefaults.Startup.NodeSSHAuthWaitSeconds = 0
cfgWaitDefaults.Startup.NodeSSHAuthPollSeconds = 0
waitRun := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "ssh" && strings.Contains(command, "__ANANKE_SSH_AUTH_OK__") {
return "", errors.New("network unreachable")
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orchWaitDefaults, _ := newHookOrchestrator(t, cfgWaitDefaults, waitRun, waitRun)
cancelCtx, cancel := context.WithCancel(context.Background())
cancel()
if err := orchWaitDefaults.TestHookWaitForNodeSSHAuth(cancelCtx, []string{"titan-db"}); !errors.Is(err, context.Canceled) {
t.Fatalf("expected canceled wait with default wait/poll fallback, got %v", err)
}
cfgSync := lifecycleConfig(t)
repo := t.TempDir()
cfgSync.IACRepoPath = repo
if err := os.MkdirAll(filepath.Join(repo, ".git"), 0o755); err != nil {
t.Fatalf("mkdir .git: %v", err)
}
checkoutErrRun := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "git" && strings.Contains(command, "status --porcelain"):
return "", nil
case name == "git" && strings.Contains(command, "fetch origin --prune"):
return "", nil
case name == "git" && strings.Contains(command, "checkout main"):
return "", errors.New("checkout failed")
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orchCheckoutErr, _ := newHookOrchestrator(t, cfgSync, nil, checkoutErrRun)
if err := orchCheckoutErr.TestHookSyncLocalIACRepo(context.Background()); err == nil {
t.Fatalf("expected checkout error branch")
}
resetErrRun := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "git" && strings.Contains(command, "status --porcelain"):
return "", nil
case name == "git" && strings.Contains(command, "fetch origin --prune"):
return "", nil
case name == "git" && strings.Contains(command, "checkout main"):
return "", nil
case name == "git" && strings.Contains(command, "reset --hard origin/main"):
return "", errors.New("reset failed")
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orchResetErr, _ := newHookOrchestrator(t, cfgSync, nil, resetErrRun)
if err := orchResetErr.TestHookSyncLocalIACRepo(context.Background()); err == nil {
t.Fatalf("expected reset error branch")
}
cfgCache := lifecycleConfig(t)
cfgCache.IACRepoPath = t.TempDir()
cfgCache.LocalBootstrapPaths = nil
orchNoCachePaths, _ := newHookOrchestrator(t, cfgCache, nil, nil)
if err := orchNoCachePaths.TestHookRefreshBootstrapCache(context.Background()); err != nil {
t.Fatalf("expected empty bootstrap path list success, got %v", err)
}
}
// TestHookNodeReachabilityBranchCloseout runs one orchestration or CLI step.
// Signature: TestHookNodeReachabilityBranchCloseout(t *testing.T).
// Why: finishes uncovered inventory-reachability branches for ignored/duplicate/auth-denied paths.
func TestHookNodeReachabilityBranchCloseout(t *testing.T) {
dryCfg := lifecycleConfig(t)
dryCfg.Startup.RequireNodeInventoryReach = true
orchDry := newDryRunHookOrchestrator(t, dryCfg, nil)
if err := orchDry.TestHookWaitForNodeInventoryReachability(context.Background()); err != nil {
t.Fatalf("expected dry-run reachability success, got %v", err)
}
ignoreCfg := lifecycleConfig(t)
ignoreCfg.Startup.RequireNodeInventoryReach = true
ignoreCfg.Startup.IgnoreUnavailableNodes = []string{"titan-db", "titan-23"}
ignoreCfg.ControlPlanes = []string{"titan-db", "titan-db"}
ignoreCfg.Workers = []string{"titan-23", ""}
orchIgnore, _ := newHookOrchestrator(t, ignoreCfg, nil, nil)
if err := orchIgnore.TestHookWaitForNodeInventoryReachability(context.Background()); err != nil {
t.Fatalf("expected all-targets-ignored success, got %v", err)
}
authCfg := lifecycleConfig(t)
authCfg.Startup.RequireNodeInventoryReach = true
authCfg.Startup.NodeInventoryReachWaitSeconds = 1
authCfg.Startup.NodeInventoryReachPollSeconds = 1
authCfg.SSHManagedNodes = []string{"titan-db", "titan-23"}
authRun := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "ssh" && strings.Contains(command, "__ANANKE_NODE_REACHABLE__") && strings.Contains(command, "titan-23") {
return "", errors.New("authentication failed")
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orchAuth, _ := newHookOrchestrator(t, authCfg, authRun, authRun)
if err := orchAuth.TestHookWaitForNodeInventoryReachability(context.Background()); err == nil || !strings.Contains(err.Error(), "auth denied") {
t.Fatalf("expected auth denied branch, got %v", err)
}
}
// TestHookStorageCriticalEndpointFluxCloseout runs one orchestration or CLI step.
// Signature: TestHookStorageCriticalEndpointFluxCloseout(t *testing.T).
// Why: closes remaining branch gaps for storage/endpoint/flux control loops with
// default-window and cancel/error fallbacks.
func TestHookStorageCriticalEndpointFluxCloseout(t *testing.T) {
t.Run("wait-for-storage-default-window-cancel", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.StorageReadyWaitSeconds = 0
cfg.Startup.StorageReadyPollSeconds = 0
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, "nodes.longhorn.io") {
return "titan-23:True:True\n", nil
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
cancelCtx, cancel := context.WithCancel(context.Background())
cancel()
if err := orch.TestHookWaitForStorageReady(cancelCtx); !errors.Is(err, context.Canceled) {
t.Fatalf("expected canceled storage wait, got %v", err)
}
})
t.Run("critical-endpoint-default-window-cancel", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.CriticalServiceEndpointWaitSec = 0
cfg.Startup.CriticalServiceEndpointPollSec = 0
cfg.Startup.CriticalServiceEndpoints = []string{"monitoring/grafana"}
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, "get endpoints grafana") {
return "", nil
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
cancelCtx, cancel := context.WithCancel(context.Background())
cancel()
if err := orch.TestHookWaitForCriticalServiceEndpoints(cancelCtx); !errors.Is(err, context.Canceled) {
t.Fatalf("expected canceled critical-endpoint wait, got %v", err)
}
})
t.Run("flux-health-default-window-cancel-and-no-heal", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.FluxHealthWaitSeconds = 0
cfg.Startup.FluxHealthPollSeconds = 0
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "get kustomizations.kustomize.toolkit.fluxcd.io -A -o json"):
return `{"items":[{"metadata":{"namespace":"flux-system","name":"services"},"spec":{"suspend":false},"status":{"conditions":[{"type":"Ready","status":"False","reason":"Progressing","message":"still reconciling"}]}}]}`, nil
case name == "kubectl" && strings.Contains(command, "get jobs -A -o json"):
return `{"items":[]}`, nil
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
cancelCtx, cancel := context.WithCancel(context.Background())
cancel()
if err := orch.TestHookWaitForFluxHealth(cancelCtx); !errors.Is(err, context.Canceled) {
t.Fatalf("expected canceled flux-health wait, got %v", err)
}
})
}
// TestHookWorkloadIgnoreBranchCloseout runs one orchestration or CLI step.
// Signature: TestHookWorkloadIgnoreBranchCloseout(t *testing.T).
// Why: exercises auto-recycle/auto-heal cooldown and pod parsing edge branches so
// convergence reporting remains stable when startup failures are noisy.
func TestHookWorkloadIgnoreBranchCloseout(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.AutoRecycleStuckPods = true
cfg.Startup.StuckPodGraceSeconds = 0
cfg.Startup.IgnoreWorkloadNamespaces = []string{"kube-system"}
cfg.Startup.IgnoreUnavailableNodes = []string{"titan-22"}
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "get deploy,statefulset -A -o json"):
return `{"items":[
{"kind":"Deployment","metadata":{"namespace":"vault","name":"vault"},"spec":{"replicas":0}},
{"kind":"CronJob","metadata":{"namespace":"monitoring","name":"ignored"},"spec":{}}
]}`, nil
case name == "kubectl" && strings.Contains(command, "get pods -A -o json"):
return `{"items":[
{"metadata":{"namespace":"kube-system","name":"skip-ns"},"spec":{"nodeName":"titan-23"},"status":{"phase":"Pending","initContainerStatuses":[{"state":{"waiting":{"reason":"ImagePullBackOff"}}}]}}
]}`, nil
case name == "kubectl" && strings.Contains(command, "scale statefulset vault --replicas=1"):
return "", errors.New("Error from server (NotFound): statefulsets.apps \"vault\" not found")
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
last := time.Now()
orch.TestHookMaybeAutoRecycleStuckPods(context.Background(), &last)
orch.TestHookMaybeAutoHealCriticalWorkloadReplicas(context.Background(), &last)
last = time.Time{}
orch.TestHookMaybeAutoRecycleStuckPods(context.Background(), &last)
orch.TestHookMaybeAutoHealCriticalWorkloadReplicas(context.Background(), &last)
if _, err := orch.TestHookHealCriticalWorkloadReplicas(context.Background()); err != nil {
t.Fatalf("expected healCriticalWorkloadReplicas not-found branch to continue, got %v", err)
}
if failures, err := orch.TestHookStartupFailurePods(context.Background()); err != nil || len(failures) != 0 {
t.Fatalf("expected ignored pod list branch, got failures=%v err=%v", failures, err)
}
}

View File

@ -0,0 +1,509 @@
package orchestrator
import (
"context"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"scm.bstein.dev/bstein/ananke/internal/cluster"
"scm.bstein.dev/bstein/ananke/internal/config"
"scm.bstein.dev/bstein/ananke/internal/execx"
"scm.bstein.dev/bstein/ananke/internal/state"
)
// newLiveHookOrchestrator runs one orchestration or CLI step.
// Signature: newLiveHookOrchestrator(t *testing.T, cfg config.Config) *cluster.Orchestrator.
// Why: branch saturation tests need real run/runSensitive execution for wrapper fallback paths.
func newLiveHookOrchestrator(t *testing.T, cfg config.Config) *cluster.Orchestrator {
t.Helper()
if err := os.MkdirAll(cfg.State.Dir, 0o755); err != nil {
t.Fatalf("ensure state dir: %v", err)
}
return cluster.New(cfg, &execx.Runner{DryRun: false}, state.New(cfg.State.RunHistoryPath), log.New(io.Discard, "", 0))
}
// TestHookPostStartExecRunBranchSaturation runs one orchestration or CLI step.
// Signature: TestHookPostStartExecRunBranchSaturation(t *testing.T).
// Why: closes uncovered run/runSensitive/post-start probe branches that only
// execute when command overrides are absent.
func TestHookPostStartExecRunBranchSaturation(t *testing.T) {
cfg := lifecycleConfig(t)
orch := newLiveHookOrchestrator(t, cfg)
probeSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
_, _ = w.Write([]byte("ok"))
}))
defer probeSrv.Close()
code, err := orch.TestHookHTTPProbe(context.Background(), probeSrv.URL)
if err != nil || code != http.StatusNoContent {
t.Fatalf("expected successful probe, code=%d err=%v", code, err)
}
if _, err := orch.TestHookHTTPProbe(context.Background(), "http://127.0.0.1:1"); err == nil {
t.Fatalf("expected failing probe for closed port")
}
out, err := orch.TestHookRunSensitive(context.Background(), 3*time.Second, "sh", "-lc", "printf ok")
if err != nil || strings.TrimSpace(out) != "ok" {
t.Fatalf("expected runSensitive success, out=%q err=%v", out, err)
}
if _, err := orch.TestHookRunSensitive(context.Background(), 3*time.Second, "sh", "-lc", "echo boom >&2; exit 12"); err == nil {
t.Fatalf("expected runSensitive failure branch")
}
}
// TestHookCoordinationBranchSaturation runs one orchestration or CLI step.
// Signature: TestHookCoordinationBranchSaturation(t *testing.T).
// Why: covers peer-intent and snapshot verification edge branches that are hard
// to hit through end-to-end startup runs.
func TestHookCoordinationBranchSaturation(t *testing.T) {
t.Run("peer-intent-matrix", func(t *testing.T) {
now := time.Now().UTC()
old := now.Add(-2 * time.Hour)
for _, tc := range []struct {
name string
role string
bootstrap string
intent string
updatedAt time.Time
wantErr string
}{
{
name: "unknown-intent-ignored",
intent: "intent=weird reason=\"odd\" source=peer updated_at=" + now.Format(time.RFC3339),
bootstrap: "__ANANKE_BOOTSTRAP_IDLE__",
},
{
name: "fresh-shutdown-complete-blocks",
intent: "intent=shutdown_complete reason=\"done\" source=peer updated_at=" + now.Format(time.RFC3339),
bootstrap: "__ANANKE_BOOTSTRAP_IDLE__",
wantErr: "completed shutdown too recently",
},
{
name: "stale-startup-intent-auto-clears",
intent: "intent=startup_in_progress reason=\"stale\" source=peer updated_at=" + old.Format(time.RFC3339),
bootstrap: "__ANANKE_BOOTSTRAP_IDLE__",
},
{
name: "manual-startup-coordinator-allowed",
role: "coordinator",
intent: "intent=startup_in_progress reason=\"manual-startup\" source=peer updated_at=" + now.Format(time.RFC3339),
bootstrap: "__ANANKE_BOOTSTRAP_ACTIVE__",
},
} {
t.Run(tc.name, func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Coordination.Role = tc.role
cfg.Coordination.PeerHosts = []string{"titan-24"}
cfg.SSHManagedNodes = append(cfg.SSHManagedNodes, "titan-24")
cfg.SSHNodeHosts["titan-24"] = "titan-24"
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "ssh" && strings.Contains(command, "ananke intent --config /etc/ananke/ananke.yaml --set normal"):
return "ok", nil
case name == "ssh" && strings.Contains(command, "ananke intent --config /etc/ananke/ananke.yaml"):
return tc.bootstrap + "\n" + tc.intent + "\n", nil
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
err := orch.TestHookGuardPeerStartupIntents(context.Background())
if tc.wantErr == "" && err != nil {
t.Fatalf("expected success, got %v", err)
}
if tc.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("expected error containing %q, got %v", tc.wantErr, err)
}
}
})
}
})
t.Run("verify-etcd-snapshot-errors", func(t *testing.T) {
type mode string
for _, tc := range []struct {
name string
mode mode
path string
wantErr string
}{
{name: "empty-path", mode: "ok", path: "", wantErr: "snapshot path is empty"},
{name: "size-parse", mode: "size-parse", path: "/snap", wantErr: "parse size"},
{name: "size-too-small", mode: "size-small", path: "/snap", wantErr: "snapshot too small"},
{
name: "snapshot-missing-from-list",
mode: "list-miss",
path: "/var/lib/rancher/k3s/server/db/snapshots/pre-shutdown-1",
wantErr: "not present in k3s",
},
{name: "invalid-sha", mode: "bad-sha", path: "/snap", wantErr: "invalid sha256"},
{name: "success", mode: "ok", path: "/snap", wantErr: ""},
} {
t.Run(tc.name, func(t *testing.T) {
cfg := lifecycleConfig(t)
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "ssh" && strings.Contains(command, "stat -c %s"):
switch tc.mode {
case "size-parse":
return "abc", nil
case "size-small":
return "128", nil
default:
return "2097152", nil
}
case name == "ssh" && strings.Contains(command, "sha256sum"):
if tc.mode == "bad-sha" {
return "deadbeef", nil
}
return "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil
case name == "ssh" && strings.Contains(command, "etcd-snapshot ls"):
if tc.mode == "list-miss" {
return "/other/snapshot", nil
}
return "/snap", nil
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
err := orch.TestHookVerifyEtcdSnapshot(context.Background(), "titan-db", tc.path)
if tc.wantErr == "" && err != nil {
t.Fatalf("expected success, got %v", err)
}
if tc.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("expected error containing %q, got %v", tc.wantErr, err)
}
}
})
}
})
t.Run("wait-for-api-failure-and-dryrun", func(t *testing.T) {
cfg := lifecycleConfig(t)
failRun := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, "version --request-timeout=5s") {
return "", errors.New("api unavailable")
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orch, _ := newHookOrchestrator(t, cfg, failRun, failRun)
if err := orch.TestHookWaitForAPI(context.Background(), 2, 0); err == nil {
t.Fatalf("expected api wait failure")
}
dry := newDryRunHookOrchestrator(t, cfg, failRun)
if err := dry.TestHookWaitForAPI(context.Background(), 1, 0); err != nil {
t.Fatalf("expected dry-run api wait success, got %v", err)
}
})
}
// TestHookAccessFluxsourceBranchSaturation runs one orchestration or CLI step.
// Signature: TestHookAccessFluxsourceBranchSaturation(t *testing.T).
// Why: covers remaining branch paths in flux-source guard/ensure/cache helpers.
func TestHookAccessFluxsourceBranchSaturation(t *testing.T) {
t.Run("ensure-flux-branch-notfound-and-patch-error", func(t *testing.T) {
cfg := lifecycleConfig(t)
var mode string
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "jsonpath={.spec.ref.branch}"):
if mode == "notfound" {
return "", errors.New("Error from server (NotFound): gitrepositories.source.toolkit.fluxcd.io \"flux-system\" not found")
}
return "dev", nil
case name == "kubectl" && strings.Contains(command, "patch gitrepository flux-system"):
return "", errors.New("patch denied")
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
mode = "notfound"
if err := orch.TestHookEnsureFluxBranch(context.Background(), "main", false); err != nil {
t.Fatalf("expected notfound branch to be tolerated, got %v", err)
}
mode = "normal"
if err := orch.TestHookEnsureFluxBranch(context.Background(), "main", false); err == nil {
t.Fatalf("expected strict mismatch error")
}
if err := orch.TestHookEnsureFluxBranch(context.Background(), "main", true); err == nil {
t.Fatalf("expected patch failure branch")
}
})
t.Run("guard-drift-notfound-and-branch-read-error", func(t *testing.T) {
cfg := lifecycleConfig(t)
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "jsonpath={.spec.url}"):
return "", errors.New("Error from server (NotFound): gitrepositories.source.toolkit.fluxcd.io \"flux-system\" not found")
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
if err := orch.TestHookGuardFluxSourceDrift(context.Background(), "main", false); err != nil {
t.Fatalf("expected notfound branch to pass, got %v", err)
}
runBranchErr := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "jsonpath={.spec.url}"):
return cfg.ExpectedFluxSource, nil
case name == "kubectl" && strings.Contains(command, "jsonpath={.spec.ref.branch}"):
return "", errors.New("branch read failure")
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orchBranchErr, _ := newHookOrchestrator(t, cfg, runBranchErr, runBranchErr)
if err := orchBranchErr.TestHookGuardFluxSourceDrift(context.Background(), "main", false); err == nil {
t.Fatalf("expected branch read failure")
}
})
t.Run("bootstrap-cache-empty-and-missing", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.IACRepoPath = t.TempDir()
cfg.LocalBootstrapPaths = []string{"services/does-not-exist"}
orch, _ := newHookOrchestrator(t, cfg, nil, nil)
if err := orch.TestHookRefreshBootstrapCache(context.Background()); err == nil {
t.Fatalf("expected no rendered cache manifests error")
}
if err := orch.TestHookApplyBootstrapCache(context.Background(), "services/does-not-exist"); err == nil {
t.Fatalf("expected missing cache apply error")
}
})
}
// TestHookStartupConvergenceAndStabilitySaturation runs one orchestration or CLI step.
// Signature: TestHookStartupConvergenceAndStabilitySaturation(t *testing.T).
// Why: saturates convergence/stability checklist branches that are otherwise only
// reached in full drill flows.
func TestHookStartupConvergenceAndStabilitySaturation(t *testing.T) {
t.Run("startup-convergence-success-all-gates-enabled", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.RequireIngressChecklist = true
cfg.Startup.RequireServiceChecklist = true
cfg.Startup.RequireCriticalServiceEndpoints = true
cfg.Startup.CriticalServiceEndpoints = nil
cfg.Startup.RequireFluxHealth = true
cfg.Startup.RequireWorkloadConvergence = true
cfg.Startup.ServiceChecklist = nil
cfg.Startup.ServiceChecklistStabilitySec = 0
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "get ingress -A -o json"):
return `{"items":[]}`, nil
case name == "kubectl" && strings.Contains(command, "get kustomizations.kustomize.toolkit.fluxcd.io -A -o json"):
return `{"items":[{"metadata":{"namespace":"flux-system","name":"services"},"spec":{"suspend":false},"status":{"conditions":[{"type":"Ready","status":"True","reason":"Ready","message":"ok"}]}}]}`, nil
case name == "kubectl" && strings.Contains(command, "get deploy,statefulset,daemonset -A -o json"):
return `{"items":[]}`, nil
case name == "kubectl" && strings.Contains(command, "get pods -A -o json"):
return `{"items":[]}`, nil
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
if err := orch.TestHookWaitForStartupConvergence(context.Background()); err != nil {
t.Fatalf("expected startup convergence success, got %v", err)
}
})
t.Run("startup-convergence-fails-on-flux", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.RequireIngressChecklist = false
cfg.Startup.RequireServiceChecklist = false
cfg.Startup.RequireCriticalServiceEndpoints = false
cfg.Startup.RequireFluxHealth = true
cfg.Startup.RequireWorkloadConvergence = false
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, "get kustomizations.kustomize.toolkit.fluxcd.io -A -o json") {
return `{"items":[{"metadata":{"namespace":"flux-system","name":"services"},"spec":{"suspend":false},"status":{"conditions":[{"type":"Ready","status":"False","reason":"Progressing","message":"syncing"}]}}]}`, nil
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
if err := orch.TestHookWaitForStartupConvergence(context.Background()); err == nil {
t.Fatalf("expected startup convergence failure")
}
})
t.Run("checklist-host-parsing-branches", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.ServiceChecklist = []config.ServiceChecklistCheck{
{Name: "grafana-check", URL: "https://metrics.bstein.dev/"},
}
orch, _ := newHookOrchestrator(t, cfg, nil, nil)
if host := orch.TestHookChecklistFailureHost("grafana-check: status=502"); host != "metrics.bstein.dev" {
t.Fatalf("expected mapped host metrics.bstein.dev, got %q", host)
}
if host := cluster.TestHookHostFromURL("https://gitea.bstein.dev/"); host != "gitea.bstein.dev" {
t.Fatalf("expected URL host gitea.bstein.dev, got %q", host)
}
if host := cluster.TestHookHostFromURL("://bad"); host != "" {
t.Fatalf("expected empty host for invalid URL, got %q", host)
}
})
}
// TestHookScalingDrainAndTimesyncSaturation runs one orchestration or CLI step.
// Signature: TestHookScalingDrainAndTimesyncSaturation(t *testing.T).
// Why: covers remaining branch edges for snapshot/scaling/drain/inventory parsers.
func TestHookScalingDrainAndTimesyncSaturation(t *testing.T) {
t.Run("scaled-snapshot-read-write-errors", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.State.Dir = filepath.Join(t.TempDir(), "state-root")
if err := os.MkdirAll(cfg.State.Dir, 0o755); err != nil {
t.Fatalf("mkdir state dir: %v", err)
}
orch, _ := newHookOrchestrator(t, cfg, nil, nil)
snapshotPath := filepath.Join(cfg.State.Dir, "scaled-workloads.json")
if err := os.WriteFile(snapshotPath, []byte("{bad json"), 0o644); err != nil {
t.Fatalf("write invalid snapshot: %v", err)
}
if _, err := orch.TestHookReadScaledWorkloadSnapshot(); err == nil {
t.Fatalf("expected decode snapshot error")
}
if err := os.Remove(snapshotPath); err != nil {
t.Fatalf("remove snapshot file: %v", err)
}
if err := os.Mkdir(snapshotPath, 0o755); err != nil {
t.Fatalf("create snapshot directory: %v", err)
}
if _, err := orch.TestHookReadScaledWorkloadSnapshot(); err == nil {
t.Fatalf("expected read snapshot error when path is directory")
}
})
t.Run("latest-etcd-snapshot-branches", func(t *testing.T) {
cfg := lifecycleConfig(t)
orch, _ := newHookOrchestrator(t, cfg, nil, nil)
if _, err := orch.TestHookLatestEtcdSnapshotPath(context.Background(), "not-managed"); err == nil {
t.Fatalf("expected unmanaged node snapshot error")
}
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "ssh" && strings.Contains(command, "etcd-snapshot ls") {
return "NAME LOCATION SIZE CREATED\n", nil
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orchEmpty, _ := newHookOrchestrator(t, cfg, run, run)
if _, err := orchEmpty.TestHookLatestEtcdSnapshotPath(context.Background(), "titan-db"); err == nil {
t.Fatalf("expected empty snapshot list error")
}
})
t.Run("parse-datastore-and-validate-inventory-branches", func(t *testing.T) {
if got := cluster.TestHookParseDatastoreEndpoint(`ExecStart=/usr/local/bin/k3s server --datastore-endpoint "postgres://db:5432/k3s"`); got != "postgres://db:5432/k3s" {
t.Fatalf("unexpected parsed endpoint %q", got)
}
if got := cluster.TestHookParseDatastoreEndpoint("ExecStart=/usr/local/bin/k3s server"); got != "" {
t.Fatalf("expected empty endpoint, got %q", got)
}
cfg := lifecycleConfig(t)
cfg.SSHPort = 0
cfg.SSHUser = "bad user"
cfg.ControlPlanes = []string{"titan-db"}
cfg.Workers = []string{"titan-23"}
cfg.SSHManagedNodes = []string{}
cfg.SSHNodeHosts = map[string]string{
"titan-db": "bad/host",
}
orch, _ := newHookOrchestrator(t, cfg, nil, nil)
if err := orch.TestHookValidateNodeInventory(); err == nil {
t.Fatalf("expected inventory validation errors")
}
})
}
// TestHookReportBranchSaturation runs one orchestration or CLI step.
// Signature: TestHookReportBranchSaturation(t *testing.T).
// Why: executes report helper branches from the top-level testing module so
// startup status/artifact coverage remains strict without package-local tests.
func TestHookReportBranchSaturation(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.State.ReportsDir = ""
orch := newLiveHookOrchestrator(t, cfg)
if got := cluster.TestHookSanitizeReportFileName(" ### "); got != "run" {
t.Fatalf("expected sanitized fallback run, got %q", got)
}
if got := orch.TestHookReportArchiveDir(); got == "" {
t.Fatalf("expected non-empty report archive dir")
}
if !strings.HasSuffix(orch.TestHookStartupReportPath(), "last-startup-report.json") {
t.Fatalf("unexpected startup report path: %s", orch.TestHookStartupReportPath())
}
if !strings.HasSuffix(orch.TestHookStartupProgressPath(), "startup-progress.json") {
t.Fatalf("unexpected startup progress path: %s", orch.TestHookStartupProgressPath())
}
if !strings.HasSuffix(orch.TestHookLastShutdownReportPath(), "last-shutdown-report.json") {
t.Fatalf("unexpected shutdown report path: %s", orch.TestHookLastShutdownReportPath())
}
record := state.RunRecord{
ID: "startup:abc",
Action: "startup",
Reason: "test",
StartedAt: time.Now().UTC().Add(-10 * time.Second),
EndedAt: time.Now().UTC(),
Success: true,
}
if err := orch.TestHookWriteRunRecordArtifact(record); err != nil {
t.Fatalf("write startup artifact failed: %v", err)
}
// Exercise no-active-report branches.
orch.TestHookSetStartupPhase("ignored", "no report active")
orch.TestHookNoteStartupCheckState("", "passed", "ignored")
orch.TestHookNoteStartupAutoHeal(" ")
orch.TestHookFinalizeStartupReport(nil)
// Exercise active startup report mutation and completion branches.
orch.TestHookBeginStartupReport("quality-drill")
orch.TestHookSetStartupPhase("phase-a", "detail-a")
orch.TestHookNoteStartupCheckState("node-inventory", "weird", "detail-b")
orch.TestHookNoteStartupCheck("flux", true, "ok")
orch.TestHookNoteStartupAutoHeal("healed something")
orch.TestHookFinalizeStartupReport(fmt.Errorf("startup failed"))
if ok := orch.TestHookFinalizeStartupReportSnapshot(nil); !ok {
t.Fatalf("expected startup report snapshot finalize wrapper to return true")
}
}

View File

@ -0,0 +1,170 @@
package orchestrator
import (
"context"
"errors"
"strings"
"testing"
"time"
"scm.bstein.dev/bstein/ananke/internal/cluster"
)
// TestHookFluxAndWorkloadMatrix runs one orchestration or CLI step.
// Signature: TestHookFluxAndWorkloadMatrix(t *testing.T).
// Why: drives additional flux/workload convergence branches so startup gating
// remains resilient under immutable-job and controller-stuck failure modes.
func TestHookFluxAndWorkloadMatrix(t *testing.T) {
t.Run("flux-health-ready-adaptive-and-immutable-heal", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.FluxHealthWaitSeconds = 1
cfg.Startup.FluxHealthPollSeconds = 1
cfg.Startup.IgnoreFluxKustomizations = []string{"flux-system/ignored"}
kustomizations := `{"items":[
{"metadata":{"namespace":"flux-system","name":"services"},"spec":{"suspend":false,"timeout":"10m"},"status":{"conditions":[{"type":"Ready","status":"False","message":"field is immutable on Job"}]}},
{"metadata":{"namespace":"flux-system","name":"ignored"},"spec":{"suspend":false},"status":{"conditions":[{"type":"Ready","status":"False","message":"ignored"}]}}
]}`
jobs := `{"items":[
{"metadata":{"namespace":"flux-system","name":"old-job","labels":{"kustomize.toolkit.fluxcd.io/name":"services"}},"status":{"failed":1,"conditions":[{"type":"Failed","status":"True"}]}}
]}`
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "get kustomizations.kustomize.toolkit.fluxcd.io -A -o json"):
return kustomizations, nil
case name == "kubectl" && strings.Contains(command, "get jobs -A -o json"):
return jobs, nil
case name == "kubectl" && strings.Contains(command, " delete job old-job "):
return "", nil
case name == "kubectl" && strings.Contains(command, "-n flux-system get kustomizations.kustomize.toolkit.fluxcd.io -o jsonpath="):
return "services\n", nil
case name == "kubectl" && strings.Contains(command, "get helmreleases.helm.toolkit.fluxcd.io -A -o jsonpath="):
return "monitoring/grafana\n", nil
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
wait, reason, err := orch.TestHookAdaptiveFluxHealthWait(context.Background(), 30*time.Second)
if err != nil || wait <= 30*time.Second || !strings.Contains(reason, "max flux timeout") {
t.Fatalf("adaptive flux wait expected extension, wait=%s reason=%q err=%v", wait, reason, err)
}
ready, detail, err := orch.TestHookFluxHealthReady(context.Background())
if err != nil || ready || !strings.Contains(detail, "not ready") {
t.Fatalf("expected fluxHealthReady not-ready result, ready=%v detail=%q err=%v", ready, detail, err)
}
healed, err := orch.TestHookHealImmutableFluxJobs(context.Background())
if err != nil || !healed {
t.Fatalf("expected immutable job heal success, healed=%v err=%v", healed, err)
}
cancelCtx, cancel := context.WithCancel(context.Background())
cancel()
if err := orch.TestHookWaitForFluxHealth(cancelCtx); !errors.Is(err, context.Canceled) {
t.Fatalf("expected canceled flux-health wait, got %v", err)
}
if !cluster.TestHookLooksLikeImmutableJobError("field is immutable on Job template") {
t.Fatalf("expected immutable-job detector true")
}
if cluster.TestHookJobLooksFluxManaged("flux-system", "job", nil, []string{"CronJob"}) {
t.Fatalf("expected CronJob-owned job to be treated as non flux-managed")
}
if !cluster.TestHookJobFailed(1, 0, []string{"Failed"}, []string{"True"}) {
t.Fatalf("expected failed job detector true")
}
})
t.Run("workload-convergence-and-recycle-branches", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.WorkloadConvergenceWaitSeconds = 1
cfg.Startup.WorkloadConvergencePollSeconds = 1
cfg.Startup.StuckPodGraceSeconds = 1
cfg.Startup.IgnoreWorkloads = []string{"vault//vault-ignore"}
cfg.Startup.IgnoreUnavailableNodes = []string{"titan-22"}
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "get deploy,statefulset,daemonset -A -o json"):
return `{"items":[
{"kind":"Deployment","metadata":{"namespace":"monitoring","name":"grafana"},"spec":{"replicas":1,"template":{"spec":{"nodeSelector":{"kubernetes.io/hostname":"titan-23"}}}},"status":{"readyReplicas":0}},
{"kind":"DaemonSet","metadata":{"namespace":"kube-system","name":"node-exporter"},"spec":{"template":{"spec":{}}},"status":{"desiredNumberScheduled":2,"numberReady":1}}
]}`, nil
case name == "kubectl" && strings.Contains(command, "get pods -A -o json"):
return `{"items":[
{"metadata":{"namespace":"vault","name":"vault-0","creationTimestamp":"2020-01-01T00:00:00Z","ownerReferences":[{"kind":"StatefulSet","name":"vault"}],"annotations":{"vault.hashicorp.com/agent-inject":"true"}},"spec":{"nodeName":"titan-23","containers":[{"name":"vault"}]},"status":{"phase":"Pending","initContainerStatuses":[{"name":"vault-agent-init","state":{"running":{"startedAt":"2020-01-01T00:00:00Z"}}}]}},
{"metadata":{"namespace":"monitoring","name":"grafana-0","creationTimestamp":"2020-01-01T00:00:00Z","ownerReferences":[{"kind":"ReplicaSet","name":"grafana"}]},"spec":{"nodeName":"titan-23","containers":[{"name":"grafana"}]},"status":{"containerStatuses":[{"state":{"waiting":{"reason":"CrashLoopBackOff"}}}]}}
]}`, nil
case name == "kubectl" && strings.Contains(command, "delete pod"):
return "", errors.New("Error from server (NotFound): pods \"x\" not found")
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
ready, detail, err := orch.TestHookWorkloadConvergenceReady(context.Background())
if err != nil || ready || !strings.Contains(detail, "not ready") {
t.Fatalf("expected workload convergence not-ready result, ready=%v detail=%q err=%v", ready, detail, err)
}
if err := orch.TestHookRecycleStuckControllerPods(context.Background()); err != nil {
t.Fatalf("expected recycleStuckControllerPods best-effort success, got %v", err)
}
cancelCtx, cancel := context.WithCancel(context.Background())
cancel()
if err := orch.TestHookWaitForWorkloadConvergence(cancelCtx); !errors.Is(err, context.Canceled) {
t.Fatalf("expected canceled workload convergence wait, got %v", err)
}
failures, err := orch.TestHookStartupFailurePods(context.Background())
if err != nil || len(failures) == 0 {
t.Fatalf("expected startup failure pod list, failures=%v err=%v", failures, err)
}
last := time.Time{}
orch.TestHookMaybeAutoRecycleStuckPods(context.Background(), &last)
prev := last
orch.TestHookMaybeAutoRecycleStuckPods(context.Background(), &last)
if last != prev {
t.Fatalf("expected auto-recycle cooldown to suppress second attempt")
}
orch.TestHookMaybeAutoHealCriticalWorkloadReplicas(context.Background(), &last)
if count := cluster.TestHookParseWorkloadIgnoreRulesCount([]string{"monitoring/deployment/grafana", "vault/vault"}); count != 2 {
t.Fatalf("expected two workload ignore rules, got %d", count)
}
if !cluster.TestHookWorkloadTargetsIgnoredNodes("", []string{"titan-22"}, []string{"titan-22"}) {
t.Fatalf("expected affinity ignore-node helper true")
}
})
t.Run("critical-workload-replica-heal-branches", func(t *testing.T) {
cfg := lifecycleConfig(t)
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "get deploy,statefulset -A -o json"):
return `{"items":[
{"kind":"StatefulSet","metadata":{"namespace":"vault","name":"vault"},"spec":{"replicas":0},"status":{"readyReplicas":0}},
{"kind":"Deployment","metadata":{"namespace":"monitoring","name":"grafana"},"spec":{"replicas":1},"status":{"readyReplicas":1}}
]}`, nil
case name == "kubectl" && strings.Contains(command, " scale statefulset vault --replicas=1"):
return "", errors.New("Error from server (NotFound): statefulsets.apps \"vault\" not found")
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
healed, err := orch.TestHookHealCriticalWorkloadReplicas(context.Background())
if err != nil || len(healed) != 0 {
t.Fatalf("expected notfound skip branch in healCriticalWorkloadReplicas, healed=%v err=%v", healed, err)
}
})
}

View File

@ -0,0 +1,224 @@
package orchestrator
import (
"context"
"fmt"
"io"
"log"
"os"
"path/filepath"
"strings"
"testing"
"time"
"scm.bstein.dev/bstein/ananke/internal/cluster"
"scm.bstein.dev/bstein/ananke/internal/config"
"scm.bstein.dev/bstein/ananke/internal/execx"
"scm.bstein.dev/bstein/ananke/internal/state"
)
// TestHookScalingStateSnapshotErrorBranches runs one orchestration or CLI step.
// Signature: TestHookScalingStateSnapshotErrorBranches(t *testing.T).
// Why: drives write/read/restore snapshot failures so scale lifecycle coverage
// captures filesystem edge cases seen during recovery drills.
func TestHookScalingStateSnapshotErrorBranches(t *testing.T) {
t.Run("scale-down fails when state dir is a file", func(t *testing.T) {
cfg := lifecycleConfig(t)
statePath := filepath.Join(t.TempDir(), "state-as-file")
if err := os.WriteFile(statePath, []byte("blocked"), 0o600); err != nil {
t.Fatalf("write state blocker file: %v", err)
}
cfg.State.Dir = statePath
cfg.State.RunHistoryPath = filepath.Join(filepath.Dir(statePath), "runs.json")
cfg.State.IntentPath = filepath.Join(filepath.Dir(statePath), "intent.txt")
cfg.State.LockPath = filepath.Join(filepath.Dir(statePath), "lock")
recorder := &commandRecorder{}
dispatch := lifecycleDispatcher(recorder)
orch := cluster.New(cfg, &execx.Runner{DryRun: false}, state.New(cfg.State.RunHistoryPath), log.New(io.Discard, "", 0))
orch.SetCommandOverrides(dispatch, dispatch)
err := orch.TestHookScaleDownApps(context.Background())
if err == nil || !strings.Contains(err.Error(), "ensure state dir") {
t.Fatalf("expected state-dir write failure, got %v", err)
}
})
t.Run("restore fails on corrupt snapshot and then succeeds", func(t *testing.T) {
cfg := lifecycleConfig(t)
orch, _ := newHookOrchestrator(t, cfg, nil, nil)
snapshotPath := filepath.Join(cfg.State.Dir, "scaled-workloads.json")
if err := os.WriteFile(snapshotPath, []byte("{bad-json"), 0o600); err != nil {
t.Fatalf("write corrupt snapshot: %v", err)
}
if err := orch.TestHookRestoreScaledApps(context.Background()); err == nil || !strings.Contains(err.Error(), "decode scaled workload snapshot") {
t.Fatalf("expected decode failure, got %v", err)
}
valid := `{"generated_at":"2026-01-01T00:00:00Z","entries":[{"namespace":"monitoring","kind":"deployment","name":"grafana","replicas":1}]}`
if err := os.WriteFile(snapshotPath, []byte(valid), 0o600); err != nil {
t.Fatalf("write valid snapshot: %v", err)
}
if err := orch.TestHookRestoreScaledApps(context.Background()); err != nil {
t.Fatalf("restore scaled apps from snapshot: %v", err)
}
if _, err := os.Stat(snapshotPath); !os.IsNotExist(err) {
t.Fatalf("expected restore to remove snapshot, stat err=%v", err)
}
})
}
// TestHookAccessGateFailureMatrix runs one orchestration or CLI step.
// Signature: TestHookAccessGateFailureMatrix(t *testing.T).
// Why: ensures node access/auth gating distinguishes auth-denied versus
// transient reachability failures and returns deterministic startup errors.
func TestHookAccessGateFailureMatrix(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.RequireNodeSSHAuth = true
cfg.Startup.NodeSSHAuthWaitSeconds = 1
cfg.Startup.NodeSSHAuthPollSeconds = 1
cfg.Shutdown.SSHParallelism = 2
recorder := &commandRecorder{}
base := lifecycleDispatcher(recorder)
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "ssh" && strings.Contains(command, "__ANANKE_SSH_AUTH_OK__") && strings.Contains(command, "titan-db"):
recorder.record(name, args)
return "Permission denied (publickey)", fmt.Errorf("permission denied (publickey)")
case name == "ssh" && strings.Contains(command, "__ANANKE_SSH_AUTH_OK__") && strings.Contains(command, "titan-23"):
recorder.record(name, args)
return "", fmt.Errorf("no route to host")
case name == "ssh" && strings.Contains(command, "/usr/bin/systemctl --version") && strings.Contains(command, "titan-db"):
recorder.record(name, args)
return "", fmt.Errorf("sudo denied")
default:
return base(ctx, timeout, name, args...)
}
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
if err := orch.TestHookReconcileNodeAccess(context.Background(), []string{"titan-db", "titan-23"}); err == nil || !strings.Contains(err.Error(), "access validation had") {
t.Fatalf("expected access validation failure, got %v", err)
}
if err := orch.TestHookWaitForNodeSSHAuth(context.Background(), []string{"titan-db", "titan-23"}); err == nil || !strings.Contains(err.Error(), "ssh auth gate failed") {
t.Fatalf("expected ssh auth gate failure, got %v", err)
}
pendingCfg := lifecycleConfig(t)
pendingCfg.Startup.RequireNodeSSHAuth = true
pendingCfg.Startup.NodeSSHAuthWaitSeconds = 1
pendingCfg.Startup.NodeSSHAuthPollSeconds = 1
runPending := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "ssh" && strings.Contains(command, "__ANANKE_SSH_AUTH_OK__") {
return "", fmt.Errorf("connection timed out")
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orchPending, _ := newHookOrchestrator(t, pendingCfg, runPending, runPending)
if err := orchPending.TestHookWaitForNodeSSHAuth(context.Background(), []string{"titan-db"}); err == nil || !strings.Contains(err.Error(), "did not pass within") {
t.Fatalf("expected ssh auth timeout, got %v", err)
}
}
// TestHookCoordinationPeerAndSnapshotFailureMatrix runs one orchestration or CLI step.
// Signature: TestHookCoordinationPeerAndSnapshotFailureMatrix(t *testing.T).
// Why: covers stale-intent guard branches and strict etcd snapshot verification
// failures that are hard to hit through full startup integration alone.
func TestHookCoordinationPeerAndSnapshotFailureMatrix(t *testing.T) {
newOrch := func(t *testing.T, cfg config.Config, run func(context.Context, time.Duration, string, ...string) (string, error)) *cluster.Orchestrator {
t.Helper()
orch := cluster.New(cfg, &execx.Runner{DryRun: false}, state.New(cfg.State.RunHistoryPath), log.New(io.Discard, "", 0))
orch.SetCommandOverrides(run, run)
return orch
}
cfg := lifecycleConfig(t)
cfg.Coordination.PeerHosts = []string{"titan-24"}
cfg.Coordination.Role = "worker"
cfg.SSHManagedNodes = append(cfg.SSHManagedNodes, "titan-24")
cfg.SSHNodeHosts["titan-24"] = "titan-24"
t.Run("peer shutdown intent blocks startup", func(t *testing.T) {
run := func(_ context.Context, _ time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "ssh" && strings.Contains(command, "ananke intent --config /etc/ananke/ananke.yaml") {
now := time.Now().UTC().Format(time.RFC3339)
return "__ANANKE_BOOTSTRAP_ACTIVE__\nintent=shutting_down reason=\"ups\" source=peer updated_at=" + now + "\n", nil
}
return "ok", nil
}
orch := newOrch(t, cfg, run)
if err := orch.TestHookGuardPeerStartupIntents(context.Background()); err == nil || !strings.Contains(err.Error(), "active shutdown intent") {
t.Fatalf("expected active shutdown block, got %v", err)
}
})
t.Run("stale peer startup intent auto-clears", func(t *testing.T) {
cleared := false
run := func(_ context.Context, _ time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "ssh" && strings.Contains(command, "ananke intent --config /etc/ananke/ananke.yaml --set normal"):
cleared = true
return "ok", nil
case name == "ssh" && strings.Contains(command, "ananke intent --config /etc/ananke/ananke.yaml"):
now := time.Now().UTC().Add(-30 * time.Minute).Format(time.RFC3339)
return "__ANANKE_BOOTSTRAP_IDLE__\nintent=startup_in_progress reason=\"stale\" source=peer updated_at=" + now + "\n", nil
default:
return "ok", nil
}
}
orch := newOrch(t, cfg, run)
if err := orch.TestHookGuardPeerStartupIntents(context.Background()); err != nil {
t.Fatalf("expected stale startup intent to be cleared, got %v", err)
}
if !cleared {
t.Fatalf("expected stale peer intent clear command to run")
}
})
t.Run("verifyEtcdSnapshot strict validation branches", func(t *testing.T) {
cases := []struct {
name string
statOut string
statErr error
lsOut string
lsErr error
shaOut string
shaErr error
path string
wantPart string
}{
{name: "empty-path", path: " ", wantPart: "snapshot path is empty"},
{name: "stat-error", path: "/snap", statErr: fmt.Errorf("stat failed"), wantPart: "verification failed"},
{name: "size-parse-error", path: "/snap", statOut: "abc", wantPart: "parse size"},
{name: "too-small", path: "/snap", statOut: "64", wantPart: "snapshot too small"},
{name: "missing-in-list", path: "/snap", statOut: "2097152", lsOut: "/other", shaOut: strings.Repeat("a", 64), wantPart: "not present"},
{name: "bad-sha", path: "/snap", statOut: "2097152", lsOut: "/snap", shaOut: "short", wantPart: "invalid sha256"},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
run := func(_ context.Context, _ time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "ssh" && strings.Contains(command, "stat -c %s"):
return tc.statOut, tc.statErr
case name == "ssh" && strings.Contains(command, "sha256sum"):
return tc.shaOut, tc.shaErr
case name == "ssh" && strings.Contains(command, "k3s etcd-snapshot ls"):
return tc.lsOut, tc.lsErr
default:
return "ok", nil
}
}
orch := newOrch(t, cfg, run)
err := orch.TestHookVerifyEtcdSnapshot(context.Background(), "titan-db", tc.path)
if err == nil || !strings.Contains(err.Error(), tc.wantPart) {
t.Fatalf("expected %q error, got %v", tc.wantPart, err)
}
})
}
})
}

View File

@ -0,0 +1,358 @@
package orchestrator
import (
"context"
"errors"
"io"
"log"
"os"
"path/filepath"
"strings"
"testing"
"time"
"scm.bstein.dev/bstein/ananke/internal/cluster"
"scm.bstein.dev/bstein/ananke/internal/config"
"scm.bstein.dev/bstein/ananke/internal/execx"
"scm.bstein.dev/bstein/ananke/internal/state"
)
// TestHookGapMatrixPart2TimesyncAndStability runs one orchestration or CLI step.
// Signature: TestHookGapMatrixPart2TimesyncAndStability(t *testing.T).
// Why: drives low-coverage time-sync, datastore parsing, and startup stability
// branches from the top-level testing module.
func TestHookGapMatrixPart2TimesyncAndStability(t *testing.T) {
t.Run("parse-datastore-endpoint-matrix", func(t *testing.T) {
cases := []struct {
line string
want string
}{
{"ExecStart=/usr/local/bin/k3s server", ""},
{"ExecStart=/usr/local/bin/k3s server --datastore-endpoint=postgres://db:5432/k3s", "postgres://db:5432/k3s"},
{"ExecStart=/usr/local/bin/k3s server --datastore-endpoint='postgres://db:5432/k3s' \\", "postgres://db:5432/k3s"},
{"ExecStart=/usr/local/bin/k3s server --datastore-endpoint = \"postgres://db:5432/k3s\" \\", "="},
{"X --datastore-endpoint= \"postgres://db:5432/k3s\" ", "postgres://db:5432/k3s"},
}
for _, tc := range cases {
got := cluster.TestHookParseDatastoreEndpoint(tc.line)
if got != tc.want {
t.Fatalf("parseDatastoreEndpoint(%q)=%q want %q", tc.line, got, tc.want)
}
}
})
t.Run("wait-for-time-sync-strict-timeout", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.TimeSyncMode = "strict"
cfg.Startup.TimeSyncWaitSeconds = 1
cfg.Startup.TimeSyncPollSeconds = 1
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "sh" && strings.Contains(command, "timedatectl show -p NTPSynchronized --value") {
return "no", nil
}
if name == "ssh" && strings.Contains(command, "timedatectl show -p NTPSynchronized --value") {
return "no", nil
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
err := orch.TestHookWaitForTimeSync(context.Background(), []string{"titan-db"})
if err == nil || !strings.Contains(err.Error(), "time sync not ready") {
t.Fatalf("expected strict time-sync timeout branch, got %v", err)
}
})
t.Run("wait-for-time-sync-quorum-success", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.ControlPlanes = []string{"titan-db", "titan-23"}
cfg.Startup.TimeSyncMode = "quorum"
cfg.Startup.TimeSyncQuorum = 1
cfg.Startup.TimeSyncWaitSeconds = 2
cfg.Startup.TimeSyncPollSeconds = 1
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "sh" && strings.Contains(command, "timedatectl show -p NTPSynchronized --value"):
return "yes", nil
case name == "ssh" && strings.Contains(command, "titan-db") && strings.Contains(command, "timedatectl show -p NTPSynchronized --value"):
return "yes", nil
case name == "ssh" && strings.Contains(command, "titan-23") && strings.Contains(command, "timedatectl show -p NTPSynchronized --value"):
return "no", nil
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
if err := orch.TestHookWaitForTimeSync(context.Background(), []string{"titan-db", "titan-23"}); err != nil {
t.Fatalf("expected quorum time-sync success, got %v", err)
}
})
t.Run("startup-stability-failure-matrix", func(t *testing.T) {
baseCfg := lifecycleConfig(t)
baseCfg.Startup.RequireIngressChecklist = false
baseCfg.Startup.RequireServiceChecklist = false
baseCfg.Startup.RequireWorkloadConvergence = false
baseCfg.Startup.RequireFluxHealth = false
runPodsCrash := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, "get pods -A -o json") {
return `{"items":[{"metadata":{"namespace":"default","name":"bad-pod"},"status":{"containerStatuses":[{"state":{"waiting":{"reason":"CrashLoopBackOff"}}}]}}]}`, nil
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orchCrash, _ := newHookOrchestrator(t, baseCfg, runPodsCrash, runPodsCrash)
if err := orchCrash.TestHookStartupStabilityHealthy(context.Background()); err == nil || !strings.Contains(err.Error(), "pods in crash/image-pull failures") {
t.Fatalf("expected crashloop stability failure, got %v", err)
}
cfgFlux := baseCfg
cfgFlux.Startup.RequireFluxHealth = true
runFlux := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, "get kustomizations.kustomize.toolkit.fluxcd.io -A -o json") {
return `{"items":[{"metadata":{"namespace":"flux-system","name":"services"},"spec":{"suspend":false},"status":{"conditions":[{"type":"Ready","status":"False","message":"syncing"}]}}]}`, nil
}
if name == "kubectl" && strings.Contains(command, "get pods -A -o json") {
return `{"items":[]}`, nil
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orchFlux, _ := newHookOrchestrator(t, cfgFlux, runFlux, runFlux)
if err := orchFlux.TestHookStartupStabilityHealthy(context.Background()); err == nil || !strings.Contains(err.Error(), "flux not ready") {
t.Fatalf("expected flux-not-ready stability failure, got %v", err)
}
cfgWork := baseCfg
cfgWork.Startup.RequireWorkloadConvergence = true
runWork := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "get deploy,statefulset,daemonset -A -o json"):
return `{"items":[{"kind":"Deployment","metadata":{"namespace":"default","name":"app"},"spec":{"replicas":1},"status":{"readyReplicas":0}}]}`, nil
case name == "kubectl" && strings.Contains(command, "get pods -A -o json"):
return `{"items":[]}`, nil
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orchWork, _ := newHookOrchestrator(t, cfgWork, runWork, runWork)
if err := orchWork.TestHookStartupStabilityHealthy(context.Background()); err == nil || !strings.Contains(err.Error(), "workloads not converged") {
t.Fatalf("expected workload convergence stability failure, got %v", err)
}
cfgService := baseCfg
cfgService.Startup.RequireServiceChecklist = true
cfgService.Startup.ServiceChecklist = []config.ServiceChecklistCheck{
{Name: "api", URL: "http://127.0.0.1:1/health", AcceptedStatuses: []int{200}, TimeoutSeconds: 1},
}
runService := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, "get pods -A -o json") {
return `{"items":[]}`, nil
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orchService, _ := newHookOrchestrator(t, cfgService, runService, runService)
if err := orchService.TestHookStartupStabilityHealthy(context.Background()); err == nil || !strings.Contains(err.Error(), "external services not healthy") {
t.Fatalf("expected service checklist stability failure, got %v", err)
}
})
}
// TestHookGapMatrixPart2FluxScalingReport runs one orchestration or CLI step.
// Signature: TestHookGapMatrixPart2FluxScalingReport(t *testing.T).
// Why: targets low branch density in flux-health, scaling snapshot handling,
// and report sanitization helpers.
func TestHookGapMatrixPart2FluxScalingReport(t *testing.T) {
t.Run("flux-helper-matrix", func(t *testing.T) {
if !cluster.TestHookLooksLikeImmutableJobError("Job update failed: FIELD IS IMMUTABLE") {
t.Fatalf("expected immutable matcher true for uppercase+job variant")
}
if cluster.TestHookLooksLikeImmutableJobError("totally unrelated error") {
t.Fatalf("expected immutable matcher false")
}
if !cluster.TestHookJobLooksFluxManaged("flux-system", "job-a", map[string]string{"kustomize.toolkit.fluxcd.io/name": "services"}, nil) {
t.Fatalf("expected flux-managed job by kustomize label")
}
if cluster.TestHookJobFailed(1, 0, []string{"Failed"}, []string{"True"}) != true {
t.Fatalf("expected Failed=True to mark job failed")
}
cfg := lifecycleConfig(t)
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "get jobs -A -o json"):
return `{"items":[{"metadata":{"namespace":"flux-system","name":"job-a","labels":{"kustomize.toolkit.fluxcd.io/name":"services"}},"status":{"failed":1,"conditions":[{"type":"Failed","status":"True"}]}}]}`, nil
case name == "kubectl" && strings.Contains(command, "delete job -n flux-system job-a"):
return "", nil
case name == "kubectl" && strings.Contains(command, "get kustomizations.kustomize.toolkit.fluxcd.io -A -o json"):
return `{"items":[{"metadata":{"namespace":"flux-system","name":"services"},"spec":{"suspend":false},"status":{"conditions":[{"type":"Ready","status":"True","message":"ok"}]}}]}`, nil
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
healed, err := orch.TestHookHealImmutableFluxJobs(context.Background())
if err != nil || !healed {
t.Fatalf("expected immutable flux job heal success, healed=%t err=%v", healed, err)
}
if _, _, err := orch.TestHookAdaptiveFluxHealthWait(context.Background(), 2*time.Second); err != nil {
t.Fatalf("expected adaptive flux wait success, got %v", err)
}
})
t.Run("scaling-snapshot-branch-matrix", func(t *testing.T) {
cfg := lifecycleConfig(t)
orch, _ := newHookOrchestrator(t, cfg, nil, nil)
if err := orch.TestHookRestoreScaledApps(context.Background()); err != nil {
t.Fatalf("expected empty snapshot restore success, got %v", err)
}
stateFile := filepath.Join(t.TempDir(), "state-file")
if err := os.WriteFile(stateFile, []byte("x"), 0o644); err != nil {
t.Fatalf("write state-file: %v", err)
}
cfgWriteErr := lifecycleConfig(t)
cfgWriteErr.State.Dir = stateFile
orchWriteErr := cluster.New(cfgWriteErr, &execx.Runner{DryRun: false}, state.New(cfgWriteErr.State.RunHistoryPath), log.New(io.Discard, "", 0))
dispatch := lifecycleDispatcher(&commandRecorder{})
orchWriteErr.SetCommandOverrides(dispatch, dispatch)
if err := orchWriteErr.TestHookWriteScaledWorkloadSnapshot(nil); err == nil || !strings.Contains(err.Error(), "ensure state dir") {
t.Fatalf("expected scaled snapshot mkdir failure, got %v", err)
}
})
t.Run("report-sanitize-and-checklist-host-parsers", func(t *testing.T) {
got := cluster.TestHookSanitizeReportFileName(" Startup / Drill : Night#2 ")
if got == "" || strings.Contains(got, " ") || strings.Contains(got, "/") {
t.Fatalf("unexpected sanitized report filename: %q", got)
}
cfg := lifecycleConfig(t)
orch, _ := newHookOrchestrator(t, cfg, nil, nil)
host := orch.TestHookChecklistFailureHost("metrics.bstein.dev: GET https://metrics.bstein.dev/: EOF")
if host != "metrics.bstein.dev" {
t.Fatalf("expected checklist failure host extraction, got %q", host)
}
})
}
// TestHookGapMatrixPart2VaultAndCoordination runs one orchestration or CLI step.
// Signature: TestHookGapMatrixPart2VaultAndCoordination(t *testing.T).
// Why: raises branch coverage on vault/key and coordination helpers without
// requiring package-local tests.
func TestHookGapMatrixPart2VaultAndCoordination(t *testing.T) {
t.Run("vault-unseal-and-file-branches", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.VaultUnsealKeyFile = ""
orch, _ := newHookOrchestrator(t, cfg, nil, nil)
if err := orch.TestHookWriteVaultUnsealKeyFile("abc"); err == nil || !strings.Contains(err.Error(), "path is empty") {
t.Fatalf("expected empty vault key path error")
}
if cluster.TestHookIsNotFoundErr("") {
t.Fatalf("expected nil/notfound helper false on empty input")
}
if !cluster.TestHookIsNotFoundErr("resource not found") {
t.Fatalf("expected notfound helper true for notfound text")
}
runPhase := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, "-n vault get pod vault-0 -o jsonpath={.status.phase}") {
return "Pending", nil
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orchPhase, _ := newHookOrchestrator(t, lifecycleConfig(t), runPhase, runPhase)
if err := orchPhase.TestHookEnsureVaultUnsealed(context.Background()); err == nil || !strings.Contains(err.Error(), "pod phase") {
t.Fatalf("expected vault phase gate error, got %v", err)
}
})
t.Run("coordination-peers-and-snapshot-stat-error", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Coordination.PeerHosts = []string{" titan-24 ", "titan-24", " ", "titan-jh"}
orch, _ := newHookOrchestrator(t, cfg, nil, nil)
peers := orch.TestHookCoordinationPeers()
if len(peers) != 2 || peers[0] != "titan-24" || peers[1] != "titan-jh" {
t.Fatalf("unexpected normalized peers: %v", peers)
}
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "ssh" && strings.Contains(command, "stat -c %s") {
return "", errors.New("stat failed")
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orchErr, _ := newHookOrchestrator(t, lifecycleConfig(t), run, run)
if err := orchErr.TestHookVerifyEtcdSnapshot(context.Background(), "titan-db", "/snap/path"); err == nil || !strings.Contains(err.Error(), "stat failed") {
t.Fatalf("expected snapshot stat error branch, got %v", err)
}
})
}
// TestHookGapMatrixPart2WorkloadIgnore runs one orchestration or CLI step.
// Signature: TestHookGapMatrixPart2WorkloadIgnore(t *testing.T).
// Why: expands low branch coverage in workload ignore helpers and startup-failure
// pod classification.
func TestHookGapMatrixPart2WorkloadIgnore(t *testing.T) {
t.Run("ignored-node-helper-matrix", func(t *testing.T) {
if !cluster.TestHookWorkloadTargetsIgnoredNodes("titan-22", nil, []string{"titan-22"}) {
t.Fatalf("expected selector-host ignored match")
}
if cluster.TestHookWorkloadTargetsIgnoredNodes("titan-23", []string{"titan-24"}, []string{"titan-22"}) {
t.Fatalf("expected workload targets ignored false when no ignored host targeted")
}
if !cluster.TestHookWorkloadTargetsIgnoredNodes("", []string{"titan-22"}, []string{"titan-22"}) {
t.Fatalf("expected affinity host ignored match")
}
if !cluster.TestHookPodTargetsIgnoredNode("titan-22", []string{"titan-22"}) {
t.Fatalf("expected pod ignored-node match")
}
if cluster.TestHookPodTargetsIgnoredNode("titan-23", []string{"titan-22"}) {
t.Fatalf("expected pod ignored-node mismatch")
}
})
t.Run("startup-failure-pods-decode-error-and-success", func(t *testing.T) {
cfg := lifecycleConfig(t)
runBad := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, "get pods -A -o json") {
return "{bad json", nil
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orchBad, _ := newHookOrchestrator(t, cfg, runBad, runBad)
if _, err := orchBad.TestHookStartupFailurePods(context.Background()); err == nil {
t.Fatalf("expected startupFailurePods decode error")
}
runOK := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, "get pods -A -o json") {
return `{"items":[{"metadata":{"namespace":"default","name":"ok-pod"},"status":{"containerStatuses":[{"state":{"running":{}}}]}}]}`, nil
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orchOK, _ := newHookOrchestrator(t, cfg, runOK, runOK)
failures, err := orchOK.TestHookStartupFailurePods(context.Background())
if err != nil || len(failures) != 0 {
t.Fatalf("expected no startup failures, failures=%v err=%v", failures, err)
}
})
t.Run("stuck-vault-init-reason-matrix", func(t *testing.T) {
if got := cluster.TestHookStuckVaultInitReason("Running", true, 0, 10*time.Second); got != "" {
t.Fatalf("expected no stuck init reason without running init, got %q", got)
}
if got := cluster.TestHookStuckVaultInitReason("Pending", true, 30*time.Second, 10*time.Second); !strings.Contains(got, "VaultInitStuck") {
t.Fatalf("expected stuck vault init reason, got %q", got)
}
})
}

View File

@ -0,0 +1,174 @@
package orchestrator
import (
"context"
"errors"
"strings"
"testing"
"time"
"scm.bstein.dev/bstein/ananke/internal/cluster"
"scm.bstein.dev/bstein/ananke/internal/config"
)
// TestHookGapMatrixPart3ConvergenceAndStability runs one orchestration or CLI step.
// Signature: TestHookGapMatrixPart3ConvergenceAndStability(t *testing.T).
// Why: raises coverage for startup convergence orchestration and stability gates
// that determine whether startup is considered truly complete.
func TestHookGapMatrixPart3ConvergenceAndStability(t *testing.T) {
t.Run("wait-for-startup-convergence-gate-matrix", func(t *testing.T) {
cfgIngress := lifecycleConfig(t)
cfgIngress.Startup.RequireIngressChecklist = true
cfgIngress.Startup.IngressChecklistWaitSeconds = 1
cfgIngress.Startup.IngressChecklistPollSeconds = 1
cfgIngress.Startup.RequireServiceChecklist = false
cfgIngress.Startup.RequireCriticalServiceEndpoints = false
cfgIngress.Startup.RequireFluxHealth = false
cfgIngress.Startup.RequireWorkloadConvergence = false
cfgIngress.Startup.ServiceChecklistStabilitySec = 0
runIngress := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, "get ingress -A -o json") {
return `{"items":[{"metadata":{"namespace":"monitoring","name":"grafana"},"spec":{"rules":[{"host":"127.0.0.1:1"}]}}]}`, nil
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orchIngress, _ := newHookOrchestrator(t, cfgIngress, runIngress, runIngress)
if err := orchIngress.TestHookWaitForStartupConvergence(context.Background()); err == nil || !strings.Contains(err.Error(), "ingress checklist") {
t.Fatalf("expected ingress convergence failure, got %v", err)
}
cfgService := lifecycleConfig(t)
cfgService.Startup.RequireServiceChecklist = true
cfgService.Startup.ServiceChecklistWaitSeconds = 1
cfgService.Startup.ServiceChecklistPollSeconds = 1
cfgService.Startup.RequireIngressChecklist = false
cfgService.Startup.RequireCriticalServiceEndpoints = false
cfgService.Startup.RequireFluxHealth = false
cfgService.Startup.RequireWorkloadConvergence = false
cfgService.Startup.ServiceChecklist = []config.ServiceChecklistCheck{
{Name: "api", URL: "http://127.0.0.1:1/health", AcceptedStatuses: []int{200}, TimeoutSeconds: 1},
}
orchService, _ := newHookOrchestrator(t, cfgService, nil, nil)
if err := orchService.TestHookWaitForStartupConvergence(context.Background()); err == nil || !strings.Contains(err.Error(), "service checklist") {
t.Fatalf("expected service convergence failure, got %v", err)
}
cfgFlux := lifecycleConfig(t)
cfgFlux.Startup.RequireIngressChecklist = false
cfgFlux.Startup.RequireServiceChecklist = false
cfgFlux.Startup.RequireCriticalServiceEndpoints = false
cfgFlux.Startup.RequireFluxHealth = true
cfgFlux.Startup.FluxHealthWaitSeconds = 1
cfgFlux.Startup.FluxHealthPollSeconds = 1
cfgFlux.Startup.RequireWorkloadConvergence = false
runFlux := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, "get kustomizations.kustomize.toolkit.fluxcd.io -A -o json") {
return `{"items":[{"metadata":{"namespace":"flux-system","name":"services"},"spec":{"suspend":false},"status":{"conditions":[{"type":"Ready","status":"False","message":"syncing"}]}}]}`, nil
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orchFlux, _ := newHookOrchestrator(t, cfgFlux, runFlux, runFlux)
if err := orchFlux.TestHookWaitForStartupConvergence(context.Background()); err == nil || !strings.Contains(err.Error(), "flux convergence") {
t.Fatalf("expected flux convergence failure, got %v", err)
}
})
t.Run("startup-stability-success-and-pod-check-error", func(t *testing.T) {
cfgOK := lifecycleConfig(t)
cfgOK.Startup.RequireFluxHealth = false
cfgOK.Startup.RequireWorkloadConvergence = false
cfgOK.Startup.RequireServiceChecklist = false
cfgOK.Startup.RequireIngressChecklist = false
runOK := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, "get pods -A -o json") {
return `{"items":[]}`, nil
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orchOK, _ := newHookOrchestrator(t, cfgOK, runOK, runOK)
if err := orchOK.TestHookStartupStabilityHealthy(context.Background()); err != nil {
t.Fatalf("expected startup stability success, got %v", err)
}
cfgErr := cfgOK
runErr := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, "get pods -A -o json") {
return "", errors.New("pod list failed")
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orchErr, _ := newHookOrchestrator(t, cfgErr, runErr, runErr)
if err := orchErr.TestHookStartupStabilityHealthy(context.Background()); err == nil || !strings.Contains(err.Error(), "pod failure check error") {
t.Fatalf("expected pod-check error branch, got %v", err)
}
})
}
// TestHookGapMatrixPart3LifecycleRestoreShutdown runs one orchestration or CLI step.
// Signature: TestHookGapMatrixPart3LifecycleRestoreShutdown(t *testing.T).
// Why: fills lifecycle restore/shutdown success paths that are easy to miss in
// failure-focused drill tests.
func TestHookGapMatrixPart3LifecycleRestoreShutdown(t *testing.T) {
t.Run("etcd-restore-dry-run-and-success", func(t *testing.T) {
cfgDry := lifecycleConfig(t)
dry := newDryRunHookOrchestrator(t, cfgDry, nil)
if err := dry.EtcdRestore(context.Background(), cluster.EtcdRestoreOptions{ControlPlane: "titan-db"}); err != nil {
t.Fatalf("expected dry-run etcd restore success, got %v", err)
}
cfg := lifecycleConfig(t)
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "ssh" && strings.Contains(command, "systemctl cat k3s"):
return "ExecStart=/usr/local/bin/k3s server", nil
case name == "ssh" && strings.Contains(command, "etcd-snapshot ls"):
return "/var/lib/rancher/k3s/server/db/snapshots/pre-shutdown", nil
case name == "ssh" && strings.Contains(command, "stat -c %s"):
return "2097152", nil
case name == "ssh" && strings.Contains(command, "sha256sum"):
return "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil
case name == "ssh" && strings.Contains(command, "server --cluster-reset"):
return "reset done", nil
case name == "ssh" && strings.Contains(command, "systemctl stop k3s"):
return "stopped", nil
case name == "ssh" && strings.Contains(command, "systemctl start k3s"):
return "started", nil
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
if err := orch.EtcdRestore(context.Background(), cluster.EtcdRestoreOptions{ControlPlane: "titan-db"}); err != nil {
t.Fatalf("expected etcd restore success path, got %v", err)
}
})
t.Run("shutdown-full-path-cluster-only", func(t *testing.T) {
cfg := lifecycleConfig(t)
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "ssh" && strings.Contains(command, "k3s etcd-snapshot save"):
return "saved", nil
case name == "ssh" && strings.Contains(command, "k3s etcd-snapshot ls"):
return "/var/lib/rancher/k3s/server/db/snapshots/pre-shutdown", nil
case name == "kubectl" && strings.Contains(command, "get deployment -A -o jsonpath="):
return "monitoring\tgrafana\t1\n", nil
case name == "kubectl" && strings.Contains(command, "get statefulset -A -o jsonpath="):
return "", nil
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
err := orch.Shutdown(context.Background(), cluster.ShutdownOptions{Reason: "full", Mode: "cluster-only"})
if err != nil {
t.Fatalf("expected full shutdown success, got %v", err)
}
})
}

View File

@ -0,0 +1,449 @@
package orchestrator
import (
"context"
"errors"
"io"
"log"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"scm.bstein.dev/bstein/ananke/internal/cluster"
"scm.bstein.dev/bstein/ananke/internal/config"
"scm.bstein.dev/bstein/ananke/internal/execx"
"scm.bstein.dev/bstein/ananke/internal/state"
)
// TestHookGapMatrixPart4CoordinationAndReachability runs one orchestration or CLI step.
// Signature: TestHookGapMatrixPart4CoordinationAndReachability(t *testing.T).
// Why: closes remaining coordination/reachability low branches with deterministic
// command responses and short timeouts.
func TestHookGapMatrixPart4CoordinationAndReachability(t *testing.T) {
t.Run("peer-shutdown-complete-cooldown-blocks-startup", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Coordination.PeerHosts = []string{"titan-24"}
cfg.SSHManagedNodes = append(cfg.SSHManagedNodes, "titan-24")
cfg.SSHNodeHosts["titan-24"] = "titan-24"
base := lifecycleDispatcher(&commandRecorder{})
now := time.Now().UTC().Format(time.RFC3339)
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "ssh" && strings.Contains(command, "ananke intent --config /etc/ananke/ananke.yaml") {
return "__ANANKE_BOOTSTRAP_IDLE__\nintent=shutdown_complete reason=\"recent\" source=peer updated_at=" + now + "\n", nil
}
return base(ctx, timeout, name, args...)
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
err := orch.TestHookGuardPeerStartupIntents(context.Background())
if err == nil || !strings.Contains(err.Error(), "completed shutdown too recently") {
t.Fatalf("expected shutdown-complete cooldown block, got %v", err)
}
})
t.Run("peer-startup-stale-auto-clears-when-bootstrap-idle", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Coordination.PeerHosts = []string{"titan-24"}
cfg.SSHManagedNodes = append(cfg.SSHManagedNodes, "titan-24")
cfg.SSHNodeHosts["titan-24"] = "titan-24"
cfg.Coordination.StartupGuardMaxAgeSec = 30
stale := time.Now().UTC().Add(-3 * time.Hour).Format(time.RFC3339)
base := lifecycleDispatcher(&commandRecorder{})
clearCalls := 0
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "ssh" && strings.Contains(command, "ananke intent --config /etc/ananke/ananke.yaml --set normal"):
clearCalls++
return "ok", nil
case name == "ssh" && strings.Contains(command, "ananke intent --config /etc/ananke/ananke.yaml"):
return "__ANANKE_BOOTSTRAP_IDLE__\nintent=startup_in_progress reason=\"stale\" source=peer updated_at=" + stale + "\n", nil
default:
return base(ctx, timeout, name, args...)
}
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
if err := orch.TestHookGuardPeerStartupIntents(context.Background()); err != nil {
t.Fatalf("expected stale peer startup intent to auto-clear, got %v", err)
}
if clearCalls == 0 {
t.Fatalf("expected remote stale-intent clear call")
}
})
t.Run("read-peer-parse-error-api-timeout-and-snapshot-size-parse", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Coordination.PeerHosts = []string{"titan-24"}
cfg.SSHManagedNodes = append(cfg.SSHManagedNodes, "titan-24")
cfg.SSHNodeHosts["titan-24"] = "titan-24"
base := lifecycleDispatcher(&commandRecorder{})
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "ssh" && strings.Contains(command, "ananke intent --config /etc/ananke/ananke.yaml"):
return "__ANANKE_BOOTSTRAP_IDLE__\nnot-an-intent-payload\n", nil
case name == "kubectl" && strings.Contains(command, "version --request-timeout=5s"):
return "", errors.New("api unreachable")
case name == "ssh" && strings.Contains(command, "stat -c %s"):
return "not-a-size", nil
default:
return base(ctx, timeout, name, args...)
}
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
if _, err := orch.TestHookReadRemotePeerStatus(context.Background(), "titan-24"); err == nil {
t.Fatalf("expected parse failure for remote peer intent output")
}
if err := orch.TestHookWaitForAPI(context.Background(), 1, 0); err == nil {
t.Fatalf("expected waitForAPI timeout error")
}
err := orch.TestHookVerifyEtcdSnapshot(context.Background(), "titan-db", "/var/lib/rancher/k3s/server/db/snapshots/pre-shutdown")
if err == nil || !strings.Contains(err.Error(), "parse size") {
t.Fatalf("expected snapshot size parse error, got %v", err)
}
})
t.Run("inventory-reachability-times-out-on-unexpected-output", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.RequireNodeInventoryReach = true
cfg.Startup.NodeInventoryReachWaitSeconds = 1
cfg.Startup.NodeInventoryReachPollSeconds = 1
base := lifecycleDispatcher(&commandRecorder{})
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "ssh" && strings.Contains(command, "__ANANKE_NODE_REACHABLE__") {
return "unexpected", nil
}
return base(ctx, timeout, name, args...)
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
err := orch.TestHookWaitForNodeInventoryReachability(context.Background())
if err == nil || !strings.Contains(err.Error(), "unexpected output") {
t.Fatalf("expected unexpected-output timeout branch, got %v", err)
}
})
}
// TestHookGapMatrixPart4IngressServiceAndPostStart runs one orchestration or CLI step.
// Signature: TestHookGapMatrixPart4IngressServiceAndPostStart(t *testing.T).
// Why: drives ingress/service checklist and post-start branches that were still
// under-covered after drill-focused matrix tests.
func TestHookGapMatrixPart4IngressServiceAndPostStart(t *testing.T) {
t.Run("ingress-backend-autoscale-cooldown-and-host-parser", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.ServiceChecklist = []config.ServiceChecklistCheck{
{Name: "metrics", URL: "https://metrics.bstein.dev/api/health"},
}
cfg.Startup.IngressChecklistIgnoreHosts = []string{"ignore.bstein.dev"}
base := lifecycleDispatcher(&commandRecorder{})
scaleCalls := 0
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "get ingress -A -o json"):
return `{"items":[{"metadata":{"namespace":"monitoring","name":"grafana"},"spec":{"rules":[{"host":"metrics.bstein.dev"},{"host":"ignore.bstein.dev"}]}}]}`, nil
case name == "kubectl" && strings.Contains(command, "get deploy,statefulset -A -o json"):
return `{"items":[{"kind":"Deployment","metadata":{"namespace":"monitoring","name":"grafana"},"spec":{"replicas":0},"status":{"readyReplicas":0}}]}`, nil
case name == "kubectl" && strings.Contains(command, " scale deployment grafana --replicas=1"):
scaleCalls++
return "", nil
default:
return base(ctx, timeout, name, args...)
}
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
last := time.Time{}
orch.TestHookMaybeAutoHealIngressHostBackends(context.Background(), &last, "metrics: unexpected status code=502")
orch.TestHookMaybeAutoHealIngressHostBackends(context.Background(), &last, "metrics: still bad")
if scaleCalls != 1 {
t.Fatalf("expected one scale call due cooldown gate, got %d", scaleCalls)
}
if got := orch.TestHookChecklistFailureHost("metrics: request failed"); got != "metrics.bstein.dev" {
t.Fatalf("expected mapped host metrics.bstein.dev, got %q", got)
}
if got := orch.TestHookChecklistFailureHost("not-a-host detail"); got != "" {
t.Fatalf("expected empty host for unknown failure prefix, got %q", got)
}
if !cluster.TestHookChecklistContains("hello \n world", "HELLO WORLD") {
t.Fatalf("expected compact checklist matcher branch")
}
})
t.Run("service-check-body-notcontains-and-poststart-timeout", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("metrics ready marker"))
}))
defer srv.Close()
cfg := lifecycleConfig(t)
orch, _ := newHookOrchestrator(t, cfg, nil, nil)
ok, detail := orch.TestHookServiceCheckReady(context.Background(), config.ServiceChecklistCheck{
Name: "forbidden-marker",
URL: srv.URL,
AcceptedStatuses: []int{200},
BodyNotContains: "marker",
TimeoutSeconds: 2,
})
if ok || !strings.Contains(detail, "forbidden marker") {
t.Fatalf("expected forbidden-marker branch, ok=%v detail=%q", ok, detail)
}
cfg = lifecycleConfig(t)
cfg.Startup.PostStartProbeWaitSeconds = 1
cfg.Startup.PostStartProbePollSeconds = 1
cfg.Startup.PostStartProbes = []string{"https://metrics.bstein.dev/health"}
base := lifecycleDispatcher(&commandRecorder{})
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "curl" || (name == "kubectl" && strings.Contains(command, "curl")) {
return "500", nil
}
return base(ctx, timeout, name, args...)
}
orch, _ = newHookOrchestrator(t, cfg, run, run)
err := orch.TestHookWaitForPostStartProbes(context.Background())
if err == nil || !strings.Contains(err.Error(), "post-start probes did not pass") {
t.Fatalf("expected post-start timeout branch, got %v", err)
}
})
t.Run("hostname-heuristic-negative-cases", func(t *testing.T) {
cases := []string{"", "not-a-host", "metrics bstein dev", "metrics.bstein.dev/path"}
for _, in := range cases {
if cluster.TestHookIsLikelyHostname(in) {
t.Fatalf("expected %q to be treated as non-hostname", in)
}
}
})
}
// TestHookGapMatrixPart4ReportScalingStorageDrain runs one orchestration or CLI step.
// Signature: TestHookGapMatrixPart4ReportScalingStorageDrain(t *testing.T).
// Why: covers artifact, scaling snapshot, storage, and drain error branches that
// are difficult to hit from happy-path lifecycle drills.
func TestHookGapMatrixPart4ReportScalingStorageDrain(t *testing.T) {
t.Run("report-artifact-and-progress-error-paths", func(t *testing.T) {
cfg := lifecycleConfig(t)
reportsFile := filepath.Join(t.TempDir(), "reports-as-file")
if err := os.WriteFile(reportsFile, []byte("x"), 0o644); err != nil {
t.Fatalf("create reports file: %v", err)
}
cfg.State.ReportsDir = reportsFile
orch, _ := newHookOrchestrator(t, cfg, nil, nil)
err := orch.TestHookWriteRunRecordArtifact(state.RunRecord{
ID: "shutdown-record",
Action: "shutdown",
Reason: "drill",
StartedAt: time.Now().UTC(),
EndedAt: time.Now().UTC(),
})
if err == nil {
t.Fatalf("expected report archive dir error")
}
if err := orch.TestHookWriteStartupReportFile(filepath.Join(reportsFile, "startup.json"), "running"); err == nil {
t.Fatalf("expected startup report path mkdir error")
}
cfg2 := lifecycleConfig(t)
cfg2.State.Dir = filepath.Join(t.TempDir(), "state")
cfg2.State.ReportsDir = reportsFile
orch2, _ := newHookOrchestrator(t, cfg2, nil, nil)
orch2.TestHookPersistStartupProgress("running")
orch2.TestHookBeginStartupReport("drill")
orch2.TestHookFinalizeStartupReport(errors.New("boom"))
})
t.Run("scaled-workload-snapshot-write-and-read-error-paths", func(t *testing.T) {
cfg := lifecycleConfig(t)
stateFile := filepath.Join(t.TempDir(), "state-file")
if err := os.WriteFile(stateFile, []byte("x"), 0o644); err != nil {
t.Fatalf("create state file: %v", err)
}
cfg.State.Dir = filepath.Join(stateFile, "nested")
orch := cluster.New(cfg, &execx.Runner{DryRun: false}, state.New(cfg.State.RunHistoryPath), log.New(io.Discard, "", 0))
dispatch := lifecycleDispatcher(&commandRecorder{})
orch.SetCommandOverrides(dispatch, dispatch)
entries, err := orch.TestHookListScalableWorkloads(context.Background())
if err != nil {
t.Fatalf("list scalable workloads: %v", err)
}
err = orch.TestHookWriteScaledWorkloadSnapshot(entries[:1])
if err == nil || !strings.Contains(err.Error(), "ensure state dir") {
t.Fatalf("expected scaled snapshot state-dir failure, got %v", err)
}
cfg2 := lifecycleConfig(t)
orch2, _ := newHookOrchestrator(t, cfg2, nil, nil)
snapshotPath := filepath.Join(cfg2.State.Dir, "scaled-workloads.json")
if err := os.WriteFile(snapshotPath, []byte("{bad"), 0o644); err != nil {
t.Fatalf("write corrupt snapshot: %v", err)
}
if _, err := orch2.TestHookReadScaledWorkloadSnapshot(); err == nil {
t.Fatalf("expected corrupt snapshot decode error")
}
})
t.Run("storage-and-drain-failure-branches", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.StorageReadyWaitSeconds = 1
cfg.Startup.StorageReadyPollSeconds = 1
cfg.Startup.StorageMinReadyNodes = 3
cfg.Startup.StorageCriticalPVCs = []string{"monitoring/grafana-data"}
cfg.Shutdown.DrainParallelism = 1
base := lifecycleDispatcher(&commandRecorder{})
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "nodes.longhorn.io"):
return "titan-23:True:True\ntitan-24:False:False\n", nil
case name == "kubectl" && strings.Contains(command, " drain titan-23 "):
return "", errors.New("drain blocked")
case name == "kubectl" && strings.Contains(command, "--field-selector spec.nodeName=titan-23"):
return "monitoring grafana-0 Running ReplicaSet\n", nil
default:
return base(ctx, timeout, name, args...)
}
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
if _, _, err := orch.TestHookStorageReady(context.Background()); err != nil {
t.Fatalf("expected storageReady non-error not-ready branch, got %v", err)
}
if err := orch.TestHookWaitForStorageReady(context.Background()); err == nil {
t.Fatalf("expected storage readiness timeout")
}
err := orch.TestHookDrainWorkers(context.Background(), []string{"titan-23"})
if err == nil || !strings.Contains(err.Error(), "details:") {
t.Fatalf("expected drain diagnostics branch, got %v", err)
}
})
}
// TestHookGapMatrixPart4TimesyncLifecycleAndAccess runs one orchestration or CLI step.
// Signature: TestHookGapMatrixPart4TimesyncLifecycleAndAccess(t *testing.T).
// Why: closes remaining timing/access/lifecycle branches that still sat below
// target after the earlier matrices.
func TestHookGapMatrixPart4TimesyncLifecycleAndAccess(t *testing.T) {
t.Run("timesync-quorum-and-strict-failure-branches", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.TimeSyncMode = "quorum"
cfg.Startup.TimeSyncQuorum = 1
cfg.Startup.TimeSyncWaitSeconds = 1
cfg.Startup.TimeSyncPollSeconds = 1
base := lifecycleDispatcher(&commandRecorder{})
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "sh" && strings.Contains(command, "timedatectl show -p NTPSynchronized"):
return "yes", nil
case name == "ssh" && strings.Contains(command, "timedatectl show -p NTPSynchronized") && strings.Contains(command, "titan-db"):
return "yes", nil
case name == "ssh" && strings.Contains(command, "timedatectl show -p NTPSynchronized") && strings.Contains(command, "titan-23"):
return "no", nil
default:
return base(ctx, timeout, name, args...)
}
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
if err := orch.TestHookWaitForTimeSync(context.Background(), []string{"titan-db", "titan-23"}); err != nil {
t.Fatalf("expected quorum-mode success, got %v", err)
}
cfgStrict := lifecycleConfig(t)
cfgStrict.Startup.TimeSyncMode = "strict"
cfgStrict.Startup.TimeSyncWaitSeconds = 1
cfgStrict.Startup.TimeSyncPollSeconds = 1
runStrict := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if (name == "sh" || name == "ssh") && strings.Contains(command, "timedatectl show -p NTPSynchronized") {
return "no", nil
}
return base(ctx, timeout, name, args...)
}
orchStrict, _ := newHookOrchestrator(t, cfgStrict, runStrict, runStrict)
err := orchStrict.TestHookWaitForTimeSync(context.Background(), []string{"titan-db"})
if err == nil || !strings.Contains(err.Error(), "time sync not ready") {
t.Fatalf("expected strict-mode timesync failure, got %v", err)
}
})
t.Run("validate-inventory-and-access-guard-branches", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.SSHPort = 70000
cfg.SSHManagedNodes = []string{"titan-db"}
cfg.Workers = []string{"titan-23"}
cfg.SSHNodeUsers = map[string]string{}
cfg.SSHNodeHosts["titan-db"] = "bad/host"
cfg.SSHNodeUsers["titan-db"] = "bad user"
orch, _ := newHookOrchestrator(t, cfg, nil, nil)
if err := orch.TestHookValidateNodeInventory(); err == nil {
t.Fatalf("expected inventory validation failure")
}
base := lifecycleDispatcher(&commandRecorder{})
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "ssh" && strings.Contains(command, "/usr/bin/systemctl --version") && strings.Contains(command, "titan-23"):
return "", errors.New("permission denied")
case name == "kubectl" && strings.Contains(command, "jsonpath={.spec.ref.branch}"):
return "feature/sso", nil
default:
return base(ctx, timeout, name, args...)
}
}
cfg2 := lifecycleConfig(t)
orch2, _ := newHookOrchestrator(t, cfg2, run, run)
if err := orch2.TestHookReconcileNodeAccess(context.Background(), []string{"titan-db", "titan-23"}); err == nil {
t.Fatalf("expected reconcileNodeAccess aggregated error")
}
if err := orch2.TestHookEnsureFluxBranch(context.Background(), "main", false); err == nil {
t.Fatalf("expected ensureFluxBranch mismatch guard")
}
})
t.Run("lifecycle-restore-and-mode-guard-branches", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.ControlPlanes = []string{"titan-db"}
cfg.SSHManagedNodes = []string{"titan-db"}
orch, _ := newHookOrchestrator(t, cfg, nil, nil)
if err := orch.EtcdRestore(context.Background(), cluster.EtcdRestoreOptions{ControlPlane: "not-a-cp"}); err == nil {
t.Fatalf("expected etcd restore control-plane membership guard")
}
cfgDry := lifecycleConfig(t)
cfgDry.ControlPlanes = []string{"titan-db"}
cfgDry.SSHManagedNodes = []string{"titan-db"}
orchDry := newDryRunHookOrchestrator(t, cfgDry, nil)
if err := orchDry.EtcdRestore(context.Background(), cluster.EtcdRestoreOptions{ControlPlane: "titan-db"}); err != nil {
t.Fatalf("expected dry-run etcd restore success, got %v", err)
}
cfgMode := lifecycleConfig(t)
orchMode, _ := newHookOrchestrator(t, cfgMode, nil, nil)
if err := orchMode.Shutdown(context.Background(), cluster.ShutdownOptions{Mode: "poweroff"}); err == nil {
t.Fatalf("expected removed shutdown mode guard")
}
})
}

View File

@ -0,0 +1,498 @@
package orchestrator
import (
"context"
"errors"
"io"
"log"
"net/http"
"net/http/httptest"
neturl "net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"scm.bstein.dev/bstein/ananke/internal/cluster"
"scm.bstein.dev/bstein/ananke/internal/config"
"scm.bstein.dev/bstein/ananke/internal/execx"
"scm.bstein.dev/bstein/ananke/internal/state"
)
// TestHookGapMatrixPart5CoverageClosure runs one orchestration or CLI step.
// Signature: TestHookGapMatrixPart5CoverageClosure(t *testing.T).
// Why: closes branch gaps that still remained after drill-style tests by driving
// low-coverage orchestrator internals through the exported top-level hook surface.
func TestHookGapMatrixPart5CoverageClosure(t *testing.T) {
t.Run("critical-endpoint-backend-heal-matrix", func(t *testing.T) {
t.Run("empty-namespace-service-noop", func(t *testing.T) {
orch, _ := newHookOrchestrator(t, lifecycleConfig(t), nil, nil)
healed, err := orch.TestHookMaybeHealCriticalEndpointBackends(context.Background(), "", "")
if err != nil || len(healed) != 0 {
t.Fatalf("expected empty noop heal, healed=%v err=%v", healed, err)
}
})
t.Run("scale-error-bubbles", func(t *testing.T) {
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, " -n monitoring scale deployment grafana ") {
return "", errors.New("boom")
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orch, _ := newHookOrchestrator(t, lifecycleConfig(t), run, run)
_, err := orch.TestHookMaybeHealCriticalEndpointBackends(context.Background(), "monitoring", "grafana")
if err == nil || !strings.Contains(err.Error(), "scale monitoring/deployment/grafana to 1") {
t.Fatalf("expected deployment scale error, got %v", err)
}
})
t.Run("deployment-notfound-statefulset-healed", func(t *testing.T) {
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, " -n monitoring scale deployment grafana "):
return "", errors.New("Error from server (NotFound): deployments.apps \"grafana\" not found")
case name == "kubectl" && strings.Contains(command, " -n monitoring scale statefulset grafana "):
return "", nil
case name == "kubectl" && strings.Contains(command, "rollout status statefulset/grafana"):
return "rolled out", nil
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orch, _ := newHookOrchestrator(t, lifecycleConfig(t), run, run)
healed, err := orch.TestHookMaybeHealCriticalEndpointBackends(context.Background(), "monitoring", "grafana")
if err != nil {
t.Fatalf("expected statefulset fallback heal success, got %v", err)
}
if len(healed) != 1 || healed[0] != "monitoring/statefulset/grafana" {
t.Fatalf("expected statefulset heal entry, got %v", healed)
}
})
})
t.Run("ingress-backend-heal-and-stability-matrix", func(t *testing.T) {
t.Run("ingress-host-heal-noop-and-error", func(t *testing.T) {
cfg := lifecycleConfig(t)
runNoop := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "get ingress -A -o json"):
return `{"items":[{"metadata":{"namespace":"monitoring","name":"grafana"},"spec":{"rules":[{"host":"other.bstein.dev"}]}}]}`, nil
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orchNoop, _ := newHookOrchestrator(t, cfg, runNoop, runNoop)
healed, err := orchNoop.TestHookHealIngressHostBackendReplicas(context.Background(), "metrics.bstein.dev")
if err != nil || len(healed) != 0 {
t.Fatalf("expected no-op ingress heal, healed=%v err=%v", healed, err)
}
runErr := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "get ingress -A -o json"):
return `{"items":[{"metadata":{"namespace":"monitoring","name":"grafana"},"spec":{"rules":[{"host":"metrics.bstein.dev"}]}}]}`, nil
case name == "kubectl" && strings.Contains(command, "get deploy,statefulset -A -o json"):
return `{"items":[{"kind":"Deployment","metadata":{"namespace":"monitoring","name":"grafana"},"spec":{"replicas":0}}]}`, nil
case name == "kubectl" && strings.Contains(command, " scale deployment grafana --replicas=1"):
return "", errors.New("permission denied")
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orchErr, _ := newHookOrchestrator(t, cfg, runErr, runErr)
_, err = orchErr.TestHookHealIngressHostBackendReplicas(context.Background(), "metrics.bstein.dev")
if err == nil || !strings.Contains(err.Error(), "scale monitoring/deployment/grafana to 1") {
t.Fatalf("expected ingress backend scale error, got %v", err)
}
})
t.Run("startup-stability-health-failure-branches", func(t *testing.T) {
t.Run("flux-check-error", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.RequireFluxHealth = true
cfg.Startup.RequireWorkloadConvergence = false
cfg.Startup.RequireServiceChecklist = false
cfg.Startup.RequireIngressChecklist = false
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, "get kustomizations.kustomize.toolkit.fluxcd.io -A -o json") {
return "", errors.New("api down")
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
if err := orch.TestHookStartupStabilityHealthy(context.Background()); err == nil || !strings.Contains(err.Error(), "flux check error") {
t.Fatalf("expected flux-check error branch, got %v", err)
}
})
t.Run("service-checklist-failure", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(httpStatusHandler(500, "not ready")))
defer srv.Close()
cfg := lifecycleConfig(t)
cfg.Startup.RequireFluxHealth = false
cfg.Startup.RequireWorkloadConvergence = false
cfg.Startup.RequireIngressChecklist = false
cfg.Startup.RequireServiceChecklist = true
cfg.Startup.ServiceChecklist = []config.ServiceChecklistCheck{
{Name: "svc", URL: srv.URL, AcceptedStatuses: []int{200}, TimeoutSeconds: 1},
}
orch, _ := newHookOrchestrator(t, cfg, nil, nil)
if err := orch.TestHookStartupStabilityHealthy(context.Background()); err == nil || !strings.Contains(err.Error(), "external services not healthy") {
t.Fatalf("expected service-check failure, got %v", err)
}
})
t.Run("ingress-checklist-failure", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.RequireFluxHealth = false
cfg.Startup.RequireWorkloadConvergence = false
cfg.Startup.RequireServiceChecklist = false
cfg.Startup.RequireIngressChecklist = true
cfg.Startup.IngressChecklistInsecureSkip = true
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, "get ingress -A -o json") {
return `{"items":[{"metadata":{"namespace":"monitoring","name":"grafana"},"spec":{"rules":[{"host":"nonexistent.invalid"}]}}]}`, nil
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
if err := orch.TestHookStartupStabilityHealthy(context.Background()); err == nil || !strings.Contains(err.Error(), "ingress reachability not healthy") {
t.Fatalf("expected ingress-check failure, got %v", err)
}
})
t.Run("pod-failure-check-error-and-nonempty", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.RequireFluxHealth = false
cfg.Startup.RequireWorkloadConvergence = false
cfg.Startup.RequireServiceChecklist = false
cfg.Startup.RequireIngressChecklist = false
runErr := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, "get pods -A -o json") {
return "", errors.New("pods unavailable")
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orchErr, _ := newHookOrchestrator(t, cfg, runErr, runErr)
if err := orchErr.TestHookStartupStabilityHealthy(context.Background()); err == nil || !strings.Contains(err.Error(), "pod failure check error") {
t.Fatalf("expected pod-check error branch, got %v", err)
}
runFail := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, "get pods -A -o json") {
return `{"items":[{"metadata":{"namespace":"monitoring","name":"grafana-0","creationTimestamp":"2020-01-01T00:00:00Z","ownerReferences":[{"kind":"ReplicaSet"}]},"spec":{"nodeName":"titan-23","containers":[{"name":"grafana"}]},"status":{"containerStatuses":[{"state":{"waiting":{"reason":"CrashLoopBackOff"}}}]}}]}`, nil
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orchFail, _ := newHookOrchestrator(t, cfg, runFail, runFail)
if err := orchFail.TestHookStartupStabilityHealthy(context.Background()); err == nil || !strings.Contains(err.Error(), "pods in crash/image-pull failures") {
t.Fatalf("expected failing-pod branch, got %v", err)
}
})
})
})
t.Run("report-scaling-timesync-and-ssh-matrix", func(t *testing.T) {
t.Run("report-artifact-and-progress-branches", func(t *testing.T) {
cfg := lifecycleConfig(t)
orch, _ := newHookOrchestrator(t, cfg, nil, nil)
orch.TestHookNoteStartupAutoHeal("ignored without active report")
orch.TestHookBeginStartupReport("drill")
orch.TestHookNoteStartupAutoHeal("")
orch.TestHookNoteStartupAutoHeal("restored critical workload replicas")
orch.TestHookFinalizeStartupReport(nil)
if err := orch.TestHookWriteRunRecordArtifact(state.RunRecord{
ID: "shutdown-record-1",
Action: "shutdown",
Reason: "drill",
StartedAt: time.Now().UTC().Add(-3 * time.Second),
EndedAt: time.Now().UTC(),
}); err != nil {
t.Fatalf("expected shutdown run artifact success, got %v", err)
}
if _, err := os.Stat(orch.TestHookLastShutdownReportPath()); err != nil {
t.Fatalf("expected last-shutdown report artifact, err=%v", err)
}
stateFile := filepath.Join(t.TempDir(), "state-as-file")
if err := os.WriteFile(stateFile, []byte("x"), 0o644); err != nil {
t.Fatalf("seed state file: %v", err)
}
badCfg := lifecycleConfig(t)
badCfg.State.Dir = stateFile
badCfg.State.ReportsDir = filepath.Join(stateFile, "reports")
badCfg.State.RunHistoryPath = filepath.Join(stateFile, "runs.json")
bad := cluster.New(badCfg, &execx.Runner{DryRun: false}, state.New(badCfg.State.RunHistoryPath), log.New(io.Discard, "", 0))
if err := bad.TestHookWriteStartupReportFile(filepath.Join(stateFile, "startup.json"), "running"); err == nil {
t.Fatalf("expected startup report mkdir/write error")
}
bad.TestHookPersistStartupProgress("running")
bad.TestHookFinalizeRecord("shutdown", "drill", "simulated-failure")
})
t.Run("scaled-snapshot-write-and-restore-errors", func(t *testing.T) {
listRun := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "get deployment -A -o jsonpath="):
return "monitoring\tgrafana\t1\n", nil
case name == "kubectl" && strings.Contains(command, "get statefulset -A -o jsonpath="):
return "", nil
case name == "kubectl" && strings.Contains(command, " scale deployment grafana --replicas=1"):
return "", nil
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
stateAsFile := filepath.Join(t.TempDir(), "state-as-file")
if err := os.WriteFile(stateAsFile, []byte("x"), 0o644); err != nil {
t.Fatalf("seed state file: %v", err)
}
cfgWriteErr := lifecycleConfig(t)
cfgWriteErr.State.Dir = stateAsFile
orchWriteErr := cluster.New(cfgWriteErr, &execx.Runner{DryRun: false}, state.New(cfgWriteErr.State.RunHistoryPath), log.New(io.Discard, "", 0))
orchWriteErr.SetCommandOverrides(listRun, listRun)
entries, err := orchWriteErr.TestHookListScalableWorkloads(context.Background())
if err != nil {
t.Fatalf("list entries for write-error case: %v", err)
}
if err := orchWriteErr.TestHookWriteScaledWorkloadSnapshot(entries); err == nil {
t.Fatalf("expected write scaled snapshot error")
}
cfgRemoveErr := lifecycleConfig(t)
orchRemoveErr, _ := newHookOrchestrator(t, cfgRemoveErr, listRun, listRun)
entries, err = orchRemoveErr.TestHookListScalableWorkloads(context.Background())
if err != nil {
t.Fatalf("list entries for remove-error case: %v", err)
}
if err := orchRemoveErr.TestHookWriteScaledWorkloadSnapshot(entries); err != nil {
t.Fatalf("seed snapshot for remove-error case: %v", err)
}
if err := os.Chmod(cfgRemoveErr.State.Dir, 0o500); err != nil {
t.Fatalf("chmod state dir readonly: %v", err)
}
t.Cleanup(func() { _ = os.Chmod(cfgRemoveErr.State.Dir, 0o700) })
if err := orchRemoveErr.TestHookRestoreScaledApps(context.Background()); err == nil || !strings.Contains(err.Error(), "remove scaled workload snapshot") {
t.Fatalf("expected remove snapshot error, got %v", err)
}
})
t.Run("timesync-quorum-and-strict", func(t *testing.T) {
build := func(mode string, quorum int, local string, remote map[string]string) *cluster.Orchestrator {
cfg := lifecycleConfig(t)
cfg.ControlPlanes = []string{"titan-db", "titan-24"}
cfg.SSHManagedNodes = []string{"titan-db", "titan-24"}
cfg.SSHNodeHosts["titan-24"] = "titan-24"
cfg.Startup.TimeSyncWaitSeconds = 1
cfg.Startup.TimeSyncPollSeconds = 1
cfg.Startup.TimeSyncMode = mode
cfg.Startup.TimeSyncQuorum = quorum
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "sh" && strings.Contains(command, "timedatectl show -p NTPSynchronized"):
return local, nil
case name == "ssh" && strings.Contains(command, "timedatectl show -p NTPSynchronized"):
for node, out := range remote {
if strings.Contains(command, node) {
return out, nil
}
}
return "no", nil
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
return orch
}
strict := build("strict", 0, "no", map[string]string{"titan-db": "no", "titan-24": "no"})
if err := strict.TestHookWaitForTimeSync(context.Background(), []string{"titan-db", "titan-24"}); err == nil || !strings.Contains(err.Error(), "time sync not ready") {
t.Fatalf("expected strict-mode timeout, got %v", err)
}
quorumOK := build("quorum", 1, "yes", map[string]string{"titan-db": "yes", "titan-24": "no"})
if err := quorumOK.TestHookWaitForTimeSync(context.Background(), []string{"titan-db", "titan-24"}); err != nil {
t.Fatalf("expected quorum-mode success, got %v", err)
}
quorumFail := build("quorum", 2, "yes", map[string]string{"titan-db": "yes", "titan-24": "no"})
if err := quorumFail.TestHookWaitForTimeSync(context.Background(), []string{"titan-db", "titan-24"}); err == nil || !strings.Contains(err.Error(), "time sync quorum not ready") {
t.Fatalf("expected quorum-mode timeout, got %v", err)
}
})
t.Run("poststart-and-ssh-helper-branches", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.PostStartProbes = []string{"https://metrics.bstein.dev/health"}
cfg.Startup.PostStartProbeWaitSeconds = 2
cfg.Startup.PostStartProbePollSeconds = 1
cfg.SSHManagedNodes = []string{"titan-db"}
cfg.SSHConfigFile = " /tmp/ananke-ssh-config "
cfg.SSHIdentityFile = " /tmp/ananke-ssh-id "
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
if name == "curl" {
return "500", nil
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
if !orch.TestHookSSHManaged("titan-db") || orch.TestHookSSHManaged("titan-23") {
t.Fatalf("unexpected sshManaged evaluation")
}
if got := orch.TestHookResolveSSHConfigFile(); got != "/tmp/ananke-ssh-config" {
t.Fatalf("unexpected ssh config path: %q", got)
}
if got := orch.TestHookResolveSSHIdentityFile(); got != "/tmp/ananke-ssh-id" {
t.Fatalf("unexpected ssh identity path: %q", got)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if err := orch.TestHookWaitForPostStartProbes(ctx); !errors.Is(err, context.Canceled) {
t.Fatalf("expected canceled post-start probe wait, got %v", err)
}
dryCfg := lifecycleConfig(t)
dry := cluster.New(dryCfg, &execx.Runner{DryRun: true}, state.New(dryCfg.State.RunHistoryPath), log.New(io.Discard, "", 0))
if err := dry.TestHookWaitForPostStartProbes(context.Background()); err != nil {
t.Fatalf("expected dry-run post-start skip, got %v", err)
}
})
})
t.Run("coordination-and-workload-ignore-helpers", func(t *testing.T) {
t.Run("peer-guard-skip-unknown-and-block", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Coordination.PeerHosts = []string{"ghost", "titan-24"}
cfg.Coordination.StartupGuardMaxAgeSec = 120
cfg.SSHManagedNodes = append(cfg.SSHManagedNodes, "titan-24")
cfg.SSHNodeHosts["titan-24"] = "titan-24"
now := time.Now().UTC().Format(time.RFC3339)
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "ssh" && strings.Contains(command, "ghost"):
return "", errors.New("no route to host")
case name == "ssh" && strings.Contains(command, "ananke intent --config /etc/ananke/ananke.yaml"):
return "__ANANKE_BOOTSTRAP_ACTIVE__\nintent=startup_in_progress reason=\"normal\" source=peer updated_at=" + now + "\n", nil
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
if err := orch.TestHookGuardPeerStartupIntents(context.Background()); err == nil || !strings.Contains(err.Error(), "startup_in_progress") {
t.Fatalf("expected startup_in_progress block, got %v", err)
}
runUnknown := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "ssh" && strings.Contains(command, "ananke intent --config /etc/ananke/ananke.yaml") {
return "__ANANKE_BOOTSTRAP_IDLE__\nintent=unknown_state reason=\"odd\" source=peer updated_at=" + now + "\n", nil
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orchUnknown, _ := newHookOrchestrator(t, cfg, runUnknown, runUnknown)
if err := orchUnknown.TestHookGuardPeerStartupIntents(context.Background()); err != nil {
t.Fatalf("expected unknown-state ignore, got %v", err)
}
})
t.Run("workload-ignore-affinity-branch-matrix", func(t *testing.T) {
ignored := []string{"titan-22"}
if cluster.TestHookWorkloadTargetsIgnoredNodesRaw(nil, ignored) {
t.Fatalf("expected false when no affinity terms are present")
}
if cluster.TestHookWorkloadTargetsIgnoredNodesRaw([][]cluster.TestHookNodeSelectorExpr{
{{Key: "kubernetes.io/hostname", Operator: "In", Values: []string{"titan-22"}}},
{{Key: "kubernetes.io/hostname", Operator: "In", Values: []string{"titan-22"}}},
}, ignored) {
t.Fatalf("expected false when more than one term is present")
}
if cluster.TestHookWorkloadTargetsIgnoredNodesRaw([][]cluster.TestHookNodeSelectorExpr{
{{Key: "other", Operator: "In", Values: []string{"titan-22"}}},
}, ignored) {
t.Fatalf("expected false for non-hostname key")
}
if cluster.TestHookWorkloadTargetsIgnoredNodesRaw([][]cluster.TestHookNodeSelectorExpr{
{{Key: "kubernetes.io/hostname", Operator: "NotIn", Values: []string{"titan-22"}}},
}, ignored) {
t.Fatalf("expected false for non-In operator")
}
if cluster.TestHookWorkloadTargetsIgnoredNodesRaw([][]cluster.TestHookNodeSelectorExpr{
{{Key: "kubernetes.io/hostname", Operator: "In", Values: nil}},
}, ignored) {
t.Fatalf("expected false for empty hostname values")
}
if cluster.TestHookWorkloadTargetsIgnoredNodesRaw([][]cluster.TestHookNodeSelectorExpr{
{{Key: "kubernetes.io/hostname", Operator: "In", Values: []string{"titan-22", "titan-23"}}},
}, ignored) {
t.Fatalf("expected false when any hostname is not ignored")
}
if !cluster.TestHookWorkloadTargetsIgnoredNodesRaw([][]cluster.TestHookNodeSelectorExpr{
{{Key: "kubernetes.io/hostname", Operator: "In", Values: []string{"titan-22"}}},
}, ignored) {
t.Fatalf("expected true when all affinity hostnames are ignored")
}
if cluster.TestHookPodTargetsIgnoredNode("", ignored) {
t.Fatalf("expected false when pod has no explicit node name")
}
})
})
}
// httpStatusHandler runs one orchestration or CLI step.
// Signature: httpStatusHandler(code int, body string) func(http.ResponseWriter, *http.Request).
// Why: keeps checklist/stability tests compact while still driving real HTTP probe branches.
func httpStatusHandler(code int, body string) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(code)
_, _ = w.Write([]byte(body))
}
}
// TestHookGapMatrixPart5IngressHostMappingRegression runs one orchestration or CLI step.
// Signature: TestHookGapMatrixPart5IngressHostMappingRegression(t *testing.T).
// Why: ensures host parsing fallback paths stay stable for ingress/service checklist failures.
func TestHookGapMatrixPart5IngressHostMappingRegression(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.ServiceChecklist = []config.ServiceChecklistCheck{
{Name: "metrics", URL: "https://metrics.bstein.dev/api/health"},
}
orch, _ := newHookOrchestrator(t, cfg, nil, nil)
if got := orch.TestHookChecklistFailureHost("metrics: down"); got != "metrics.bstein.dev" {
t.Fatalf("expected metrics host map, got %q", got)
}
rawURL := "https://grafana.bstein.dev/path"
if got := orch.TestHookChecklistFailureHost(rawURL); got != "" {
t.Fatalf("expected checklist host parser to treat raw URL as unknown prefix, got %q", got)
}
if got := cluster.TestHookHostFromURL(rawURL); got != "grafana.bstein.dev" {
t.Fatalf("expected hostFromURL helper parse, got %q", got)
}
u, _ := neturl.Parse(rawURL)
if u == nil || u.Hostname() == "" {
t.Fatalf("expected URL parse sanity")
}
}

View File

@ -0,0 +1,313 @@
package orchestrator
import (
"context"
"errors"
"io"
"log"
"os"
"path/filepath"
"strings"
"testing"
"time"
"scm.bstein.dev/bstein/ananke/internal/cluster"
"scm.bstein.dev/bstein/ananke/internal/execx"
"scm.bstein.dev/bstein/ananke/internal/state"
)
// TestHookGapMatrixPart6CoverageClosure runs one orchestration or CLI step.
// Signature: TestHookGapMatrixPart6CoverageClosure(t *testing.T).
// Why: targets the remaining low branch paths after part5 so per-file coverage
// can move toward the strict 95% quality gate.
func TestHookGapMatrixPart6CoverageClosure(t *testing.T) {
t.Run("critical-vault-and-poststart-branches", func(t *testing.T) {
t.Run("wait-vault-ready-dryrun-and-cancel", func(t *testing.T) {
cfg := lifecycleConfig(t)
dry := cluster.New(cfg, &execx.Runner{DryRun: true}, state.New(cfg.State.RunHistoryPath), log.New(io.Discard, "", 0))
if err := dry.TestHookWaitVaultReady(context.Background(), "vault", "statefulset", "vault"); err != nil {
t.Fatalf("expected dry-run waitVaultReady skip, got %v", err)
}
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "-n vault get statefulset vault -o jsonpath={.status.readyReplicas}"):
return "0", nil
case name == "kubectl" && strings.Contains(command, "-n vault get pod vault-0 -o jsonpath={.status.phase}"):
return "Running", nil
case name == "kubectl" && strings.Contains(command, "vault status -format=json"):
return `{"sealed":true}`, nil
case name == "kubectl" && strings.Contains(command, "get secret vault-init"):
return "", errors.New("missing secret")
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
ctx, cancel := context.WithCancel(context.Background())
cancel()
if err := orch.TestHookWaitVaultReady(ctx, "vault", "statefulset", "vault"); !errors.Is(err, context.Canceled) {
t.Fatalf("expected canceled waitVaultReady, got %v", err)
}
})
t.Run("ensure-vault-unsealed-and-vault-sealed-errors", func(t *testing.T) {
cfg := lifecycleConfig(t)
runUnsealErr := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "-n vault get pod vault-0 -o jsonpath={.status.phase}"):
return "Running", nil
case name == "kubectl" && strings.Contains(command, "vault status -format=json"):
return `{"sealed":true}`, nil
case name == "kubectl" && strings.Contains(command, "get secret vault-init"):
return "c2VjcmV0Cg==", nil
case name == "kubectl" && strings.Contains(command, "vault operator unseal"):
return "", errors.New("unseal failed")
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orchUnsealErr, _ := newHookOrchestrator(t, cfg, runUnsealErr, runUnsealErr)
if err := orchUnsealErr.TestHookEnsureVaultUnsealed(context.Background()); err == nil || !strings.Contains(err.Error(), "unseal attempt") {
t.Fatalf("expected unseal failure branch, got %v", err)
}
runStatusErr := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, "vault status -format=json") {
return "", errors.New("status failed")
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orchStatusErr, _ := newHookOrchestrator(t, cfg, runStatusErr, runStatusErr)
if _, err := orchStatusErr.TestHookVaultSealed(context.Background()); err == nil {
t.Fatalf("expected vaultSealed command error")
}
runStatusParseErr := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, "vault status -format=json") {
return "not-json", nil
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orchStatusParseErr, _ := newHookOrchestrator(t, cfg, runStatusParseErr, runStatusParseErr)
if _, err := orchStatusParseErr.TestHookVaultSealed(context.Background()); err == nil {
t.Fatalf("expected vaultSealed parse error")
}
})
})
t.Run("scaling-and-storage-branches", func(t *testing.T) {
t.Run("scale-down-and-restore-branch-matrix", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.ExcludedNamespaces = []string{"flux-system"}
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "get deployment -A -o jsonpath="):
return "monitoring\tgrafana\t1\n", nil
case name == "kubectl" && strings.Contains(command, "get statefulset -A -o jsonpath="):
return "", nil
case name == "kubectl" && strings.Contains(command, " scale deployment grafana --replicas=0"):
return "", errors.New("scale failed")
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
if err := orch.TestHookScaleDownApps(context.Background()); err == nil || !strings.Contains(err.Error(), "scaling had") {
t.Fatalf("expected scaleDownApps failure aggregation, got %v", err)
}
snapshotPath := filepath.Join(cfg.State.Dir, "scaled-workloads.json")
if err := os.WriteFile(snapshotPath, []byte("{bad-json"), 0o644); err != nil {
t.Fatalf("seed decode error snapshot: %v", err)
}
if err := orch.TestHookRestoreScaledApps(context.Background()); err == nil || !strings.Contains(err.Error(), "decode scaled workload snapshot") {
t.Fatalf("expected restore decode error, got %v", err)
}
})
t.Run("storage-ready-branch-matrix", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.StorageMinReadyNodes = 2
cfg.Startup.StorageCriticalPVCs = []string{"monitoring/grafana"}
runInsufficient := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, "get nodes.longhorn.io") {
return "a:True:False\n", nil
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orchInsufficient, _ := newHookOrchestrator(t, cfg, runInsufficient, runInsufficient)
if ok, reason, err := orchInsufficient.TestHookStorageReady(context.Background()); err != nil || ok || !strings.Contains(reason, "longhorn ready+sched nodes") {
t.Fatalf("expected insufficient longhorn readiness, ok=%v reason=%q err=%v", ok, reason, err)
}
runPVCNotFound := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "get nodes.longhorn.io"):
return "a:True:True\nb:True:True\n", nil
case name == "kubectl" && strings.Contains(command, "-n monitoring get pvc grafana -o jsonpath={.status.phase}"):
return "", errors.New("Error from server (NotFound): pvc not found")
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orchPVCNotFound, _ := newHookOrchestrator(t, cfg, runPVCNotFound, runPVCNotFound)
if ok, reason, err := orchPVCNotFound.TestHookStorageReady(context.Background()); err != nil || ok || !strings.Contains(reason, "not found") {
t.Fatalf("expected pvc-not-found path, ok=%v reason=%q err=%v", ok, reason, err)
}
})
})
t.Run("reachability-drain-and-flux-branches", func(t *testing.T) {
t.Run("node-inventory-reachability-auth-denied", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.RequireNodeInventoryReach = true
cfg.Startup.NodeInventoryReachWaitSeconds = 1
cfg.Startup.NodeInventoryReachPollSeconds = 1
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "ssh" && strings.Contains(command, "__ANANKE_NODE_REACHABLE__") {
return "", errors.New("Permission denied (publickey)")
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
err := orch.TestHookWaitForNodeInventoryReachability(context.Background())
if err == nil || !strings.Contains(err.Error(), "auth denied") {
t.Fatalf("expected auth denied branch, got %v", err)
}
})
t.Run("drain-diagnostics-nonempty-error-and-no-snapshot", func(t *testing.T) {
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "spec.nodeName=titan-23"):
return "monitoring grafana-0 Running ReplicaSet\n", errors.New("forbidden")
case name == "ssh" && strings.Contains(command, "sudo -n /usr/local/bin/k3s etcd-snapshot ls"):
return "Name Size Created Location\n", nil
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orch, _ := newHookOrchestrator(t, lifecycleConfig(t), run, run)
got := orch.TestHookDrainNodeDiagnostics(context.Background(), "titan-23")
if !strings.Contains(got, "diagnostics unavailable") || !strings.Contains(got, "grafana-0") {
t.Fatalf("expected diagnostics error with output details, got %q", got)
}
if _, err := orch.TestHookLatestEtcdSnapshotPath(context.Background(), "titan-db"); err == nil || !strings.Contains(err.Error(), "no etcd snapshots found") {
t.Fatalf("expected no-snapshot branch, got %v", err)
}
})
t.Run("flux-health-json-and-ready-condition-branches", func(t *testing.T) {
cfg := lifecycleConfig(t)
runDecodeErr := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, "get kustomizations.kustomize.toolkit.fluxcd.io -A -o json") {
return "{bad-json", nil
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orchDecodeErr, _ := newHookOrchestrator(t, cfg, runDecodeErr, runDecodeErr)
if _, _, err := orchDecodeErr.TestHookFluxHealthReady(context.Background()); err == nil {
t.Fatalf("expected flux health decode error")
}
runMissingReady := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, "get kustomizations.kustomize.toolkit.fluxcd.io -A -o json") {
return `{"items":[{"metadata":{"namespace":"flux-system","name":"services"},"spec":{"suspend":false},"status":{"conditions":[{"type":"Reconciling","status":"True","reason":"Progressing"}]}}]}`, nil
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orchMissingReady, _ := newHookOrchestrator(t, cfg, runMissingReady, runMissingReady)
ready, detail, err := orchMissingReady.TestHookFluxHealthReady(context.Background())
if err != nil || ready || !strings.Contains(detail, "ready condition missing") {
t.Fatalf("expected missing-ready-condition branch, ready=%v detail=%q err=%v", ready, detail, err)
}
})
})
t.Run("access-coordination-lifecycle-branches", func(t *testing.T) {
t.Run("reconcile-and-ssh-auth-gates", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.RequireNodeSSHAuth = true
cfg.Startup.NodeSSHAuthWaitSeconds = 1
cfg.Startup.NodeSSHAuthPollSeconds = 1
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "ssh" && strings.Contains(command, "/usr/bin/systemctl --version") && strings.Contains(command, "titan-db"):
return "", errors.New("permission denied")
case name == "ssh" && strings.Contains(command, "echo __ANANKE_SSH_AUTH_OK__"):
return "", errors.New("Permission denied (publickey)")
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
if err := orch.TestHookReconcileNodeAccess(context.Background(), []string{"titan-db", "titan-23"}); err == nil || !strings.Contains(err.Error(), "access validation had") {
t.Fatalf("expected reconcileNodeAccess error aggregation, got %v", err)
}
if err := orch.TestHookWaitForNodeSSHAuth(context.Background(), []string{"titan-db"}); err == nil || !strings.Contains(err.Error(), "ssh auth gate failed") {
t.Fatalf("expected waitForNodeSSHAuth auth-denied branch, got %v", err)
}
})
t.Run("verify-snapshot-and-shutdown-mode-errors", func(t *testing.T) {
cfg := lifecycleConfig(t)
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "ssh" && strings.Contains(command, "stat -c %s"):
return "512", nil
case name == "ssh" && strings.Contains(command, "sudo -n /usr/local/bin/k3s etcd-snapshot ls"):
return "/var/lib/rancher/k3s/server/db/snapshots/pre-shutdown", nil
case name == "ssh" && strings.Contains(command, "sha256sum"):
return "short-hash", nil
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
if err := orch.TestHookVerifyEtcdSnapshot(context.Background(), "titan-db", "/var/lib/rancher/k3s/server/db/snapshots/pre-shutdown"); err == nil || !strings.Contains(err.Error(), "snapshot too small") {
t.Fatalf("expected small snapshot rejection, got %v", err)
}
if _, err := cluster.TestHookNormalizeShutdownMode("poweroff"); err == nil {
t.Fatalf("expected removed poweroff shutdown mode error")
}
})
t.Run("etcd-restore-not-applicable-and-shutdown-mode", func(t *testing.T) {
cfg := lifecycleConfig(t)
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "ssh" && strings.Contains(command, "systemctl cat k3s") {
return "ExecStart=/usr/local/bin/k3s server --datastore-endpoint=postgres://db:5432/k3s", nil
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
err := orch.EtcdRestore(context.Background(), cluster.EtcdRestoreOptions{ControlPlane: "titan-db"})
if err == nil || !errors.Is(err, cluster.ErrEtcdRestoreNotApplicable) {
t.Fatalf("expected etcd restore not-applicable error, got %v", err)
}
err = orch.Shutdown(context.Background(), cluster.ShutdownOptions{Reason: "drill", Mode: "unsupported"})
if err == nil || !strings.Contains(err.Error(), "unsupported shutdown mode") {
t.Fatalf("expected shutdown mode validation error, got %v", err)
}
})
})
}

View File

@ -0,0 +1,240 @@
package orchestrator
import (
"context"
"errors"
"io"
"log"
"strings"
"testing"
"time"
"scm.bstein.dev/bstein/ananke/internal/cluster"
"scm.bstein.dev/bstein/ananke/internal/execx"
"scm.bstein.dev/bstein/ananke/internal/state"
)
// TestHookGapMatrixPart7CoverageClosure runs one orchestration or CLI step.
// Signature: TestHookGapMatrixPart7CoverageClosure(t *testing.T).
// Why: closes additional low-coverage branches in convergence, storage, access,
// flux, lifecycle, and sensitive command wrappers.
func TestHookGapMatrixPart7CoverageClosure(t *testing.T) {
t.Run("workload-convergence-branch-matrix", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.WorkloadConvergenceWaitSeconds = 1
cfg.Startup.WorkloadConvergencePollSeconds = 1
cfg.Startup.IgnoreWorkloadNamespaces = []string{"ignored-ns"}
cfg.Startup.IgnoreUnavailableNodes = []string{"titan-22"}
cfg.Startup.IgnoreWorkloads = []string{"monitoring/deployment/skip-me"}
cfg.Startup.IgnoreFluxKustomizations = []string{"flux-system/ignored"}
runReady := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "get deploy,statefulset,daemonset -A -o json"):
return `{"items":[
{"kind":"Deployment","metadata":{"namespace":"monitoring","name":"grafana"},"spec":{"replicas":1},"status":{"readyReplicas":1}},
{"kind":"DaemonSet","metadata":{"namespace":"monitoring","name":"node-exporter"},"spec":{"template":{"spec":{"nodeName":"titan-22"}}},"status":{"desiredNumberScheduled":2,"numberReady":1}},
{"kind":"Deployment","metadata":{"namespace":"ignored-ns","name":"ignore-ns"},"spec":{"replicas":1},"status":{"readyReplicas":0}},
{"kind":"Deployment","metadata":{"namespace":"flux-system","name":"ignored"},"spec":{"replicas":1},"status":{"readyReplicas":1}},
{"kind":"Deployment","metadata":{"namespace":"monitoring","name":"skip-me"},"spec":{"replicas":1},"status":{"readyReplicas":0}}
]}`, nil
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orchReady, _ := newHookOrchestrator(t, cfg, runReady, runReady)
ready, detail, err := orchReady.TestHookWorkloadConvergenceReady(context.Background())
if err != nil || !ready || !strings.Contains(detail, "controllers ready=") {
t.Fatalf("expected workload convergence ready branch, ready=%v detail=%q err=%v", ready, detail, err)
}
runDecodeErr := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, "get deploy,statefulset,daemonset -A -o json") {
return "{bad-json", nil
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orchDecodeErr, _ := newHookOrchestrator(t, cfg, runDecodeErr, runDecodeErr)
if _, _, err := orchDecodeErr.TestHookWorkloadConvergenceReady(context.Background()); err == nil {
t.Fatalf("expected workload convergence decode error")
}
runPending := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "get deploy,statefulset,daemonset -A -o json"):
return `{"items":[{"kind":"Deployment","metadata":{"namespace":"monitoring","name":"grafana"},"spec":{"replicas":1},"status":{"readyReplicas":0}}]}`, nil
case name == "kubectl" && strings.Contains(command, "get pods -A -o json"):
return `{"items":[]}`, nil
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orchPending, _ := newHookOrchestrator(t, cfg, runPending, runPending)
if err := orchPending.TestHookWaitForWorkloadConvergence(context.Background()); err == nil || !strings.Contains(err.Error(), "workload convergence not satisfied") {
t.Fatalf("expected workload convergence timeout branch, got %v", err)
}
cancelCtx, cancel := context.WithCancel(context.Background())
cancel()
if err := orchPending.TestHookWaitForWorkloadConvergence(cancelCtx); !errors.Is(err, context.Canceled) {
t.Fatalf("expected canceled convergence wait, got %v", err)
}
cases := []cluster.TestHookStuckVaultInitInput{
{Phase: "Running", Inject: true, InitContainerName: "vault-agent-init", Running: true, StartedAtOffsetSec: 600, GraceSeconds: 60},
{Phase: "Pending", Inject: false, InitContainerName: "vault-agent-init", Running: true, StartedAtOffsetSec: 600, GraceSeconds: 60},
{Phase: "Pending", Inject: true, InitContainerName: "other-init", Running: true, StartedAtOffsetSec: 600, GraceSeconds: 60},
{Phase: "Pending", Inject: true, InitContainerName: "vault-agent-init", Running: true, StartedAtOffsetSec: 0, GraceSeconds: 60},
{Phase: "Pending", Inject: true, InitContainerName: "vault-agent-init", Running: false, StartedAtOffsetSec: 600, GraceSeconds: 60},
}
for _, in := range cases {
if got := cluster.TestHookStuckVaultInitReasonRaw(in); got != "" {
t.Fatalf("expected no stuck reason for %+v, got %q", in, got)
}
}
if got := cluster.TestHookStuckVaultInitReasonRaw(cluster.TestHookStuckVaultInitInput{
Phase: "Pending",
Inject: true,
InitContainerName: "vault-agent-init",
Running: true,
StartedAtOffsetSec: 600,
GraceSeconds: 60,
}); got != "VaultInitStuck" {
t.Fatalf("expected VaultInitStuck branch, got %q", got)
}
})
t.Run("storage-access-and-reachability-branches", func(t *testing.T) {
t.Run("storage-ready-invalid-entry-and-query-error", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.StorageCriticalPVCs = []string{"bad-entry"}
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, "get nodes.longhorn.io") {
return "a:True:True\nb:True:True\n", nil
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
if _, _, err := orch.TestHookStorageReady(context.Background()); err == nil {
t.Fatalf("expected invalid storage_critical_pvcs entry error")
}
cfg.Startup.StorageCriticalPVCs = []string{"monitoring/grafana"}
runPVCError := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "get nodes.longhorn.io"):
return "a:True:True\nb:True:True\n", nil
case name == "kubectl" && strings.Contains(command, "-n monitoring get pvc grafana -o jsonpath={.status.phase}"):
return "", errors.New("query pvc failed")
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orchPVCError, _ := newHookOrchestrator(t, cfg, runPVCError, runPVCError)
if _, _, err := orchPVCError.TestHookStorageReady(context.Background()); err == nil {
t.Fatalf("expected pvc query error branch")
}
})
t.Run("wait-for-node-ssh-auth-and-inventory-timeouts", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.RequireNodeSSHAuth = true
cfg.Startup.NodeSSHAuthWaitSeconds = 1
cfg.Startup.NodeSSHAuthPollSeconds = 1
cfg.Startup.RequireNodeInventoryReach = true
cfg.Startup.NodeInventoryReachWaitSeconds = 1
cfg.Startup.NodeInventoryReachPollSeconds = 1
runUnexpected := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "ssh" && strings.Contains(command, "__ANANKE_") {
return "unexpected", nil
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orch, _ := newHookOrchestrator(t, cfg, runUnexpected, runUnexpected)
if err := orch.TestHookWaitForNodeSSHAuth(context.Background(), []string{"titan-db"}); err == nil || !strings.Contains(err.Error(), "did not pass") {
t.Fatalf("expected ssh-auth timeout on unexpected output, got %v", err)
}
if err := orch.TestHookWaitForNodeInventoryReachability(context.Background()); err == nil || !strings.Contains(err.Error(), "did not pass") {
t.Fatalf("expected inventory reachability timeout on unexpected output, got %v", err)
}
})
})
t.Run("flux-lifecycle-and-sensitive-run-branches", func(t *testing.T) {
t.Run("sensitive-run-error-shapes", func(t *testing.T) {
cfg := lifecycleConfig(t)
orch := cluster.New(cfg, &execx.Runner{DryRun: false}, state.New(cfg.State.RunHistoryPath), log.New(io.Discard, "", 0))
if _, err := orch.TestHookRunSensitive(context.Background(), 3*time.Second, "sh", "-lc", "exit 1"); err == nil {
t.Fatalf("expected runSensitive failure without output")
}
out, err := orch.TestHookRunSensitive(context.Background(), 3*time.Second, "sh", "-lc", "echo boom; exit 1")
if err == nil || strings.TrimSpace(out) != "boom" {
t.Fatalf("expected runSensitive failure with captured output, out=%q err=%v", out, err)
}
})
t.Run("flux-health-helper-branches", func(t *testing.T) {
cfg := lifecycleConfig(t)
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "get jobs -A -o json"):
return `{"items":[
{"metadata":{"namespace":"flux-system","name":"job-a","labels":{"kustomize.toolkit.fluxcd.io/name":"services"}},"status":{"failed":1,"conditions":[{"type":"Complete","status":"False"}]}},
{"metadata":{"namespace":"flux-system","name":"job-b","labels":{"kustomize.toolkit.fluxcd.io/name":"services"}},"status":{"failed":1,"conditions":[{"type":"Failed","status":"True"}]}}
]}`, nil
case name == "kubectl" && strings.Contains(command, "-n flux-system delete job job-b"):
return "", nil
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
healed, err := orch.TestHookHealImmutableFluxJobs(context.Background())
if err != nil || !healed {
t.Fatalf("expected immutable-job heal branch, healed=%v err=%v", healed, err)
}
})
t.Run("lifecycle-etcd-restore-dryrun-and-shutdown-mode", func(t *testing.T) {
cfg := lifecycleConfig(t)
dry := cluster.New(cfg, &execx.Runner{DryRun: true}, state.New(cfg.State.RunHistoryPath), log.New(io.Discard, "", 0))
if err := dry.EtcdRestore(context.Background(), cluster.EtcdRestoreOptions{}); err != nil {
t.Fatalf("expected dry-run etcd restore path, got %v", err)
}
orch, _ := newHookOrchestrator(t, cfg, nil, nil)
err := orch.Shutdown(context.Background(), cluster.ShutdownOptions{Reason: "drill", Mode: "poweroff"})
if err == nil || !strings.Contains(err.Error(), "has been removed") {
t.Fatalf("expected removed poweroff mode error, got %v", err)
}
})
t.Run("scale-down-list-error-and-restore-no-snapshot", func(t *testing.T) {
cfg := lifecycleConfig(t)
runListErr := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, "get deployment -A -o jsonpath=") {
return "", errors.New("list deployments failed")
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orchListErr, _ := newHookOrchestrator(t, cfg, runListErr, runListErr)
if err := orchListErr.TestHookScaleDownApps(context.Background()); err == nil || !strings.Contains(err.Error(), "collect deployments") {
t.Fatalf("expected scaleDownApps list error, got %v", err)
}
orchNoSnapshot, _ := newHookOrchestrator(t, lifecycleConfig(t), nil, nil)
if err := orchNoSnapshot.TestHookRestoreScaledApps(context.Background()); err != nil {
t.Fatalf("expected restore with missing snapshot to succeed, got %v", err)
}
})
})
}

View File

@ -0,0 +1,410 @@
package orchestrator
import (
"context"
"encoding/base64"
"errors"
"fmt"
"io"
"log"
"net"
"os"
"path/filepath"
"strings"
"testing"
"time"
"scm.bstein.dev/bstein/ananke/internal/cluster"
"scm.bstein.dev/bstein/ananke/internal/execx"
"scm.bstein.dev/bstein/ananke/internal/state"
)
// TestHookGapMatrixPart8CoverageClosure runs one orchestration or CLI step.
// Signature: TestHookGapMatrixPart8CoverageClosure(t *testing.T).
// Why: closes additional low-coverage branches in access, vault, lifecycle,
// ingress/service stability, and timesync/inventory orchestration paths.
func TestHookGapMatrixPart8CoverageClosure(t *testing.T) {
t.Run("access-ssh-and-branch-guard-branches", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.RequireNodeSSHAuth = true
cfg.Startup.NodeSSHAuthWaitSeconds = 1
cfg.Startup.NodeSSHAuthPollSeconds = 1
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "ssh" && strings.Contains(command, "__ANANKE_SSH_AUTH_OK__"):
return "", errors.New("Permission denied (publickey)")
case name == "ssh" && strings.Contains(command, "/usr/bin/systemctl --version"):
return "", errors.New("sudo: a password is required")
case name == "kubectl" && strings.Contains(command, "jsonpath={.spec.url}"):
return cfg.ExpectedFluxSource, nil
case name == "kubectl" && strings.Contains(command, "jsonpath={.spec.ref.branch}"):
return "legacy", nil
case name == "kubectl" && strings.Contains(command, "patch gitrepository flux-system"):
return "", errors.New("patch denied")
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
if err := orch.TestHookWaitForNodeSSHAuth(context.Background(), []string{"titan-db"}); err == nil || !strings.Contains(err.Error(), "auth denied") {
t.Fatalf("expected ssh auth denied branch, got %v", err)
}
if err := orch.TestHookReconcileNodeAccess(context.Background(), []string{"titan-db"}); err == nil || !strings.Contains(err.Error(), "access validation had") {
t.Fatalf("expected reconcile access error aggregation, got %v", err)
}
if err := orch.TestHookEnsureFluxBranch(context.Background(), "main", false); err == nil || !strings.Contains(err.Error(), "startup blocked") {
t.Fatalf("expected ensureFluxBranch block branch, got %v", err)
}
if err := orch.TestHookEnsureFluxBranch(context.Background(), "main", true); err == nil || !strings.Contains(err.Error(), "set flux source branch") {
t.Fatalf("expected ensureFluxBranch patch failure branch, got %v", err)
}
cfgCache := lifecycleConfig(t)
cfgCache.IACRepoPath = t.TempDir()
cfgCache.LocalBootstrapPaths = []string{"missing-path"}
orchCache, _ := newHookOrchestrator(t, cfgCache, nil, nil)
if err := orchCache.TestHookRefreshBootstrapCache(context.Background()); err == nil || !strings.Contains(err.Error(), "no bootstrap cache manifests rendered") {
t.Fatalf("expected refresh cache zero-rendered branch, got %v", err)
}
})
t.Run("coordination-and-snapshot-verification-branches", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Coordination.PeerHosts = []string{"titan-24"}
cfg.SSHManagedNodes = append(cfg.SSHManagedNodes, "titan-24")
cfg.SSHNodeHosts["titan-24"] = "titan-24"
now := time.Now().UTC().Format(time.RFC3339)
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "ssh" && strings.Contains(command, "ananke intent --config /etc/ananke/ananke.yaml"):
return "__ANANKE_BOOTSTRAP_ACTIVE__\nintent=startup_in_progress reason=\"rolling\" source=peer updated_at=" + now + "\n", nil
case name == "ssh" && strings.Contains(command, "stat -c %s"):
return "abc", nil
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
if err := orch.TestHookGuardPeerStartupIntents(context.Background()); err == nil || !strings.Contains(err.Error(), "startup_in_progress") {
t.Fatalf("expected peer startup-in-progress block branch, got %v", err)
}
if err := orch.TestHookVerifyEtcdSnapshot(context.Background(), "titan-db", "/var/lib/rancher/k3s/server/db/snapshots/pre-shutdown"); err == nil || !strings.Contains(err.Error(), "parse size") {
t.Fatalf("expected verify snapshot parse-size branch, got %v", err)
}
runSmall := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "ssh" && strings.Contains(command, "stat -c %s"):
return "128", nil
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orchSmall, _ := newHookOrchestrator(t, cfg, runSmall, runSmall)
if err := orchSmall.TestHookVerifyEtcdSnapshot(context.Background(), "titan-db", "/var/lib/rancher/k3s/server/db/snapshots/pre-shutdown"); err == nil || !strings.Contains(err.Error(), "too small") {
t.Fatalf("expected verify snapshot too-small branch, got %v", err)
}
})
t.Run("critical-endpoints-and-vault-branches", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.CriticalServiceEndpoints = []string{"bad-entry"}
orchInvalid, _ := newHookOrchestrator(t, cfg, nil, nil)
if _, _, _, _, err := orchInvalid.TestHookCriticalServiceEndpointsReady(context.Background()); err == nil {
t.Fatalf("expected invalid critical endpoint entry branch")
}
cfg = lifecycleConfig(t)
keyB64 := base64.StdEncoding.EncodeToString([]byte("vault-unseal-key"))
sealedChecks := 0
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "-n vault get pod vault-0 -o jsonpath={.status.phase}"):
return "Running", nil
case name == "kubectl" && strings.Contains(command, "vault status -format=json"):
sealedChecks++
if sealedChecks <= 2 {
return `{"sealed":true}`, nil
}
return `{"sealed":false}`, nil
case name == "kubectl" && strings.Contains(command, "get secret vault-init"):
return keyB64, nil
case name == "kubectl" && strings.Contains(command, "vault operator unseal"):
return "unsealed", nil
case name == "kubectl" && strings.Contains(command, "jsonpath={.status.readyReplicas}"):
if sealedChecks == 0 {
return "0", nil
}
return "1", nil
case name == "kubectl" && strings.Contains(command, "get pods -o custom-columns"):
return "vault-0 Unknown StatefulSet vault\n", nil
case name == "kubectl" && strings.Contains(command, "delete pod vault-0"):
return "", errors.New("delete failed")
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
if err := orch.TestHookEnsureVaultUnsealed(context.Background()); err != nil {
t.Fatalf("expected ensureVaultUnsealed success branch, got %v", err)
}
if err := orch.TestHookWaitVaultReady(context.Background(), "vault", "statefulset", "vault"); err != nil {
t.Fatalf("expected waitVaultReady retry/success branch, got %v", err)
}
if err := orch.TestHookCleanupStaleCriticalWorkloadPods(context.Background(), "vault", "statefulset", "vault"); err == nil || !strings.Contains(err.Error(), "delete stale pod") {
t.Fatalf("expected stale-pod delete failure branch, got %v", err)
}
runBadReady := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, "jsonpath={.status.readyReplicas}") {
return "not-a-number", nil
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orchBadReady, _ := newHookOrchestrator(t, lifecycleConfig(t), runBadReady, runBadReady)
if _, err := orchBadReady.TestHookWorkloadReady(context.Background(), "monitoring", "deployment", "grafana"); err == nil {
t.Fatalf("expected workloadReady parse branch")
}
cfgEmptyKey := lifecycleConfig(t)
cfgEmptyKey.Startup.VaultUnsealKeyFile = ""
orchEmptyKey, _ := newHookOrchestrator(t, cfgEmptyKey, nil, nil)
if err := orchEmptyKey.TestHookWriteVaultUnsealKeyFile("abc"); err == nil || !strings.Contains(err.Error(), "path is empty") {
t.Fatalf("expected writeVaultUnsealKeyFile empty-path branch, got %v", err)
}
})
t.Run("lifecycle-etcd-restore-and-shutdown-branches", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.ControlPlanes = []string{}
orchNoCP := cluster.New(cfg, &execx.Runner{DryRun: false}, state.New(cfg.State.RunHistoryPath), log.New(io.Discard, "", 0))
if err := orchNoCP.EtcdRestore(context.Background(), cluster.EtcdRestoreOptions{}); err == nil || !strings.Contains(err.Error(), "no control planes") {
t.Fatalf("expected etcd restore no-control-planes branch, got %v", err)
}
cfgManaged := lifecycleConfig(t)
cfgManaged.SSHManagedNodes = []string{"titan-db"}
runExternal := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "ssh" && strings.Contains(command, "systemctl cat k3s"):
return "ExecStart=/usr/local/bin/k3s server --datastore-endpoint=postgres://db:5432/k3s", nil
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orchExternal, _ := newHookOrchestrator(t, cfgManaged, runExternal, runExternal)
err := orchExternal.EtcdRestore(context.Background(), cluster.EtcdRestoreOptions{ControlPlane: "titan-db"})
if err == nil || !errors.Is(err, cluster.ErrEtcdRestoreNotApplicable) {
t.Fatalf("expected etcd restore not-applicable branch, got %v", err)
}
cfgShutdown := lifecycleConfig(t)
cfgShutdown.SSHPort = 0
orchShutdown := cluster.New(cfgShutdown, &execx.Runner{DryRun: false}, state.New(cfgShutdown.State.RunHistoryPath), log.New(io.Discard, "", 0))
if err := orchShutdown.Shutdown(context.Background(), cluster.ShutdownOptions{Reason: "drill", Mode: "cluster-only"}); err == nil || !strings.Contains(err.Error(), "node inventory preflight failed") {
t.Fatalf("expected shutdown inventory-preflight branch, got %v", err)
}
})
t.Run("ingress-service-stability-and-timesync-branches", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.RequireIngressChecklist = true
cfg.Startup.RequireServiceChecklist = true
cfg.Startup.RequireCriticalServiceEndpoints = true
cfg.Startup.RequireFluxHealth = true
cfg.Startup.RequireWorkloadConvergence = true
cfg.Startup.ServiceChecklistStabilitySec = 1
cfg.Startup.ServiceChecklistPollSeconds = 1
cfg.Startup.TimeSyncWaitSeconds = 1
cfg.Startup.TimeSyncPollSeconds = 1
cfg.Startup.TimeSyncMode = "quorum"
cfg.Startup.TimeSyncQuorum = 1
cfg.Startup.ServiceChecklist = nil
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "sh" && strings.Contains(command, "timedatectl show -p NTPSynchronized"):
return "yes", nil
case name == "ssh" && strings.Contains(command, "timedatectl show -p NTPSynchronized"):
if strings.Contains(command, "titan-db") {
return "yes", nil
}
return "no", nil
case name == "kubectl" && strings.Contains(command, "get ingress -A -o json"):
return `{"items":[]}`, nil
case name == "kubectl" && strings.Contains(command, "get kustomizations.kustomize.toolkit.fluxcd.io -A -o json"):
return `{"items":[{"metadata":{"namespace":"flux-system","name":"services"},"spec":{"suspend":false},"status":{"conditions":[{"type":"Ready","status":"True","message":"ok"}]}}]}`, nil
case name == "kubectl" && strings.Contains(command, "get deploy,statefulset,daemonset -A -o json"):
return `{"items":[{"kind":"Deployment","metadata":{"namespace":"monitoring","name":"grafana"},"spec":{"replicas":1,"template":{"spec":{}}},"status":{"readyReplicas":1}}]}`, nil
case name == "kubectl" && strings.Contains(command, "get endpoints victoria-metrics-single-server"):
return "10.42.0.10\n", nil
case name == "kubectl" && strings.Contains(command, "get pods -A -o json"):
return `{"items":[]}`, nil
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
if err := orch.TestHookWaitForStartupConvergence(context.Background()); err != nil {
t.Fatalf("expected startup convergence pass branch, got %v", err)
}
if err := orch.TestHookWaitForTimeSync(context.Background(), []string{"titan-db", "titan-23"}); err != nil {
t.Fatalf("expected quorum timesync pass branch, got %v", err)
}
runDecodeIngress := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, "get ingress -A -o json") {
return "{bad-json", nil
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orchDecodeIngress, _ := newHookOrchestrator(t, lifecycleConfig(t), runDecodeIngress, runDecodeIngress)
if _, err := orchDecodeIngress.TestHookDiscoverIngressNamespacesForHost(context.Background(), "metrics.bstein.dev"); err == nil {
t.Fatalf("expected ingress namespace decode branch")
}
cfgInventory := lifecycleConfig(t)
cfgInventory.SSHPort = 70000
orchInventory, _ := newHookOrchestrator(t, cfgInventory, nil, nil)
if err := orchInventory.TestHookValidateNodeInventory(); err == nil || !strings.Contains(err.Error(), "ssh_port") {
t.Fatalf("expected inventory invalid-port branch, got %v", err)
}
})
t.Run("report-and-scaling-edge-branches", func(t *testing.T) {
cfg := lifecycleConfig(t)
orch, _ := newHookOrchestrator(t, cfg, nil, nil)
rec := state.RunRecord{ID: "shutdown-1", Action: "shutdown", StartedAt: time.Now().UTC()}
if err := orch.TestHookWriteRunRecordArtifact(rec); err != nil {
t.Fatalf("expected writeRunRecordArtifact shutdown branch, got %v", err)
}
if _, err := os.Stat(orch.TestHookLastShutdownReportPath()); err != nil {
t.Fatalf("expected last shutdown report file: %v", err)
}
cfgScaleErr := lifecycleConfig(t)
stateFile := filepath.Join(t.TempDir(), "state-file")
if err := os.WriteFile(stateFile, []byte("x"), 0o600); err != nil {
t.Fatalf("write state file: %v", err)
}
cfgScaleErr.State.Dir = stateFile
orchScaleErr := cluster.New(
cfgScaleErr,
&execx.Runner{DryRun: false},
state.New(cfgScaleErr.State.RunHistoryPath),
log.New(io.Discard, "", 0),
)
if err := orchScaleErr.TestHookWriteScaledWorkloadSnapshot(nil); err == nil || !strings.Contains(err.Error(), "ensure state dir") {
t.Fatalf("expected writeScaledWorkloadSnapshot mkdir branch, got %v", err)
}
cfgRestore := lifecycleConfig(t)
stateDir := cfgRestore.State.Dir
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, " scale ") {
return "", nil
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orchRestore, _ := newHookOrchestrator(t, cfgRestore, run, run)
snapshotPath := filepath.Join(stateDir, "scaled-workloads.json")
snapshot := `{"generated_at":"2026-01-01T00:00:00Z","entries":[{"namespace":"monitoring","kind":"deployment","name":"grafana","replicas":1}]}`
if err := os.WriteFile(snapshotPath, []byte(snapshot), 0o644); err != nil {
t.Fatalf("write snapshot: %v", err)
}
if err := orchRestore.TestHookRestoreScaledApps(context.Background()); err != nil {
t.Fatalf("expected restoreScaledApps success branch, got %v", err)
}
if _, err := os.Stat(snapshotPath); !os.IsNotExist(err) {
t.Fatalf("expected snapshot removal after restore, stat err=%v", err)
}
})
}
// TestHookGapMatrixPart8LifecycleStartupAutoRestoreBranch runs one orchestration or CLI step.
// Signature: TestHookGapMatrixPart8LifecycleStartupAutoRestoreBranch(t *testing.T).
// Why: covers Startup's API-failure->auto-restore retry path that is otherwise
// hard to exercise in deterministic top-level tests.
func TestHookGapMatrixPart8LifecycleStartupAutoRestoreBranch(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.AutoEtcdRestoreOnAPIFailure = true
cfg.Startup.EtcdRestoreControlPlane = "titan-db"
cfg.Startup.RequireNodeInventoryReach = false
cfg.Startup.RequireNodeSSHAuth = false
cfg.Startup.RequireStorageReady = false
cfg.Startup.RequireServiceChecklist = false
cfg.Startup.RequireIngressChecklist = false
cfg.Startup.RequireCriticalServiceEndpoints = false
cfg.Startup.RequireFluxHealth = false
cfg.Startup.RequireWorkloadConvergence = false
datastoreListener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen datastore preflight stub: %v", err)
}
defer datastoreListener.Close()
apiCalls := 0
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "version --request-timeout=5s"):
apiCalls++
if apiCalls == 1 {
return "", errors.New("api down")
}
return "v1.31.0", nil
case name == "ssh" && strings.Contains(command, "systemctl cat k3s"):
return fmt.Sprintf(
"ExecStart=/usr/local/bin/k3s server --datastore-endpoint=postgres://127.0.0.1:%d/k3s",
datastoreListener.Addr().(*net.TCPAddr).Port,
), nil
case name == "kubectl" && strings.Contains(command, "jsonpath={.status.conditions[?(@.type==\"Ready\")].status}"):
return "True", nil
case name == "kubectl" && strings.Contains(command, "jsonpath={.spec.url}"):
return cfg.ExpectedFluxSource, nil
case name == "kubectl" && strings.Contains(command, "jsonpath={.spec.ref.branch}"):
return "main", nil
case name == "kubectl" && strings.Contains(command, "annotate kustomizations.kustomize.toolkit.fluxcd.io"):
return "", nil
case name == "kubectl" && strings.Contains(command, "annotate --all-namespaces helmreleases.helm.toolkit.fluxcd.io"):
return "", nil
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
err = orch.Startup(context.Background(), cluster.StartupOptions{Reason: "part8-auto-restore"})
if err != nil {
t.Fatalf("expected startup auto-restore path success, got %v", err)
}
if apiCalls < 2 {
t.Fatalf("expected startup to retry API after auto-restore path, calls=%d", apiCalls)
}
cfgBadMode := lifecycleConfig(t)
orchBadMode, _ := newHookOrchestrator(t, cfgBadMode, nil, nil)
err = orchBadMode.Shutdown(context.Background(), cluster.ShutdownOptions{Reason: "part8", Mode: "unknown-mode"})
if err == nil || !strings.Contains(err.Error(), "unsupported shutdown mode") {
t.Fatalf("expected shutdown unsupported-mode branch, got %v", err)
}
cfgReport := lifecycleConfig(t)
orchReport, _ := newHookOrchestrator(t, cfgReport, nil, nil)
if err := orchReport.TestHookWriteStartupReportFile(filepath.Join("", string(os.PathSeparator)), "running"); err == nil {
t.Fatalf("expected startup report write failure branch")
}
if ok := orchReport.TestHookFinalizeStartupReportSnapshot(fmt.Errorf("boom")); !ok {
t.Fatalf("expected finalize startup report snapshot non-nil")
}
}

View File

@ -0,0 +1,294 @@
package orchestrator
import (
"context"
"errors"
"io"
"log"
"os"
"path/filepath"
"strings"
"testing"
"time"
"scm.bstein.dev/bstein/ananke/internal/cluster"
"scm.bstein.dev/bstein/ananke/internal/execx"
"scm.bstein.dev/bstein/ananke/internal/state"
)
// TestHookGapMatrixPart9AccessCoordinationEndpoints runs one orchestration or CLI step.
// Signature: TestHookGapMatrixPart9AccessCoordinationEndpoints(t *testing.T).
// Why: closes uncovered statement ranges in access/fluxsource, coordination,
// and critical-endpoint orchestration helpers.
func TestHookGapMatrixPart9AccessCoordinationEndpoints(t *testing.T) {
t.Run("access-fluxsource-uncovered-ranges", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Shutdown.SSHParallelism = 0
cfg.SSHManagedNodes = []string{"n1", "n2", "n3", "n4", "n5"}
cfg.SSHNodeHosts = map[string]string{
"n1": "n1",
"n2": "n2",
"n3": "n3",
"n4": "n4",
"n5": "n5",
}
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "ssh" && strings.Contains(command, "/usr/bin/systemctl --version"):
return "", errors.New("sudo denied")
case name == "kubectl" && strings.Contains(command, "jsonpath={.spec.url}"):
return cfg.ExpectedFluxSource, nil
case name == "kubectl" && strings.Contains(command, "jsonpath={.spec.ref.branch}"):
return "dev", nil
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
err := orch.TestHookReconcileNodeAccess(context.Background(), []string{"", "unmanaged", "n1", "n2", "n3", "n4", "n5"})
if err == nil || !strings.Contains(err.Error(), "access validation had") {
t.Fatalf("expected reconcile error aggregation branch, got %v", err)
}
cfgAuthNone := lifecycleConfig(t)
cfgAuthNone.Startup.RequireNodeSSHAuth = true
cfgAuthNone.Startup.NodeSSHAuthWaitSeconds = 1
cfgAuthNone.Startup.NodeSSHAuthPollSeconds = 1
cfgAuthNone.SSHManagedNodes = []string{"n1"}
cfgAuthNone.Startup.IgnoreUnavailableNodes = []string{"n1"}
orchAuthNone, _ := newHookOrchestrator(t, cfgAuthNone, nil, nil)
if err := orchAuthNone.TestHookWaitForNodeSSHAuth(context.Background(), []string{"", "n1", "n1"}); err != nil {
t.Fatalf("expected ssh auth pruned-target branch, got %v", err)
}
cfgAuthDenied := lifecycleConfig(t)
cfgAuthDenied.Startup.RequireNodeSSHAuth = true
cfgAuthDenied.Startup.NodeSSHAuthWaitSeconds = 1
cfgAuthDenied.Startup.NodeSSHAuthPollSeconds = 1
cfgAuthDenied.SSHManagedNodes = []string{"n1"}
orchAuthDenied, _ := newHookOrchestrator(t, cfgAuthDenied, nil, nil)
if err := orchAuthDenied.TestHookWaitForNodeSSHAuth(context.Background(), []string{"n2"}); err == nil || !strings.Contains(err.Error(), "not in ssh_managed_nodes") {
t.Fatalf("expected ssh auth unmanaged-node branch, got %v", err)
}
if err := orch.TestHookGuardFluxSourceDrift(context.Background(), "main", false); err == nil || !strings.Contains(err.Error(), "branch drift") {
t.Fatalf("expected guardFluxSourceDrift mismatch branch, got %v", err)
}
if err := orch.TestHookEnsureFluxBranch(context.Background(), " ", false); err != nil {
t.Fatalf("expected ensureFluxBranch empty-branch fast path, got %v", err)
}
runReadErr := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, "jsonpath={.spec.ref.branch}") {
return "", errors.New("read failed")
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orchReadErr, _ := newHookOrchestrator(t, lifecycleConfig(t), runReadErr, runReadErr)
if err := orchReadErr.TestHookEnsureFluxBranch(context.Background(), "main", true); err == nil || !strings.Contains(err.Error(), "read flux source branch") {
t.Fatalf("expected ensureFluxBranch read error branch, got %v", err)
}
cfgReport := lifecycleConfig(t)
runReport := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "jsonpath={.spec.url}"):
return cfgReport.ExpectedFluxSource, nil
case name == "kubectl" && strings.Contains(command, "jsonpath={.spec.ref.branch}"):
return "legacy", nil
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orchReport, _ := newHookOrchestrator(t, cfgReport, runReport, runReport)
orchReport.TestHookReportFluxSource(context.Background(), "")
cfgCache := lifecycleConfig(t)
repo := t.TempDir()
cfgCache.IACRepoPath = repo
cfgCache.LocalBootstrapPaths = []string{" ", "services/bootstrap"}
if err := os.MkdirAll(filepath.Join(repo, "services", "bootstrap"), 0o755); err != nil {
t.Fatalf("mkdir bootstrap path: %v", err)
}
runCache := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "sh" && strings.Contains(command, "kubectl kustomize") {
return "apiVersion: v1\nkind: Namespace\nmetadata:\n name: test\n", nil
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orchCache, _ := newHookOrchestrator(t, cfgCache, runCache, runCache)
cachePath := orchCache.TestHookBootstrapCachePath("services/bootstrap")
if err := os.MkdirAll(cachePath, 0o755); err != nil {
t.Fatalf("mkdir cache path-as-dir: %v", err)
}
if err := orchCache.TestHookRefreshBootstrapCache(context.Background()); err == nil || !strings.Contains(err.Error(), "no bootstrap cache manifests rendered") {
t.Fatalf("expected refresh cache write-failure branch, got %v", err)
}
})
t.Run("coordination-uncovered-ranges", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Coordination.PeerHosts = []string{"titan-24"}
cfg.Coordination.ForwardShutdownHost = "titan-25"
cfg.SSHManagedNodes = append(cfg.SSHManagedNodes, "titan-24", "titan-25")
cfg.SSHNodeHosts["titan-24"] = "titan-24"
cfg.SSHNodeHosts["titan-25"] = "titan-25"
orchPeers, _ := newHookOrchestrator(t, cfg, nil, nil)
peers := orchPeers.TestHookCoordinationPeers()
if len(peers) < 2 {
t.Fatalf("expected peers to include forward host, got %v", peers)
}
stale := time.Now().UTC().Add(-2 * time.Hour).Format(time.RFC3339)
runStale := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "ssh" && strings.Contains(command, "ananke intent --config /etc/ananke/ananke.yaml --set normal"):
return "ok", nil
case name == "ssh" && strings.Contains(command, "ananke intent --config /etc/ananke/ananke.yaml"):
return "__ANANKE_BOOTSTRAP_ACTIVE__\nintent=startup_in_progress reason=\"rolling\" source=peer updated_at=" + stale + "\n", nil
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orchStale, _ := newHookOrchestrator(t, cfg, runStale, runStale)
if err := orchStale.TestHookGuardPeerStartupIntents(context.Background()); err != nil {
t.Fatalf("expected stale startup intent auto-clear branch, got %v", err)
}
runShutdownStale := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "ssh" && strings.Contains(command, "ananke intent --config /etc/ananke/ananke.yaml") {
return "__ANANKE_BOOTSTRAP_IDLE__\nintent=shutting_down reason=\"old\" source=peer updated_at=" + stale + "\n", nil
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orchShutdownStale, _ := newHookOrchestrator(t, cfg, runShutdownStale, runShutdownStale)
if err := orchShutdownStale.TestHookGuardPeerStartupIntents(context.Background()); err != nil {
t.Fatalf("expected stale shutdown intent allow branch, got %v", err)
}
runSSHErr := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "ssh" && strings.Contains(command, "ananke intent --config /etc/ananke/ananke.yaml") {
return "", errors.New("ssh failed")
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orchSSHErr, _ := newHookOrchestrator(t, cfg, runSSHErr, runSSHErr)
if _, err := orchSSHErr.TestHookReadRemotePeerStatus(context.Background(), "titan-24"); err == nil || !strings.Contains(err.Error(), "ssh failed") {
t.Fatalf("expected readRemotePeerStatus ssh-error branch, got %v", err)
}
dryCfg := lifecycleConfig(t)
dry := cluster.New(dryCfg, &execx.Runner{DryRun: true}, state.New(dryCfg.State.RunHistoryPath), log.New(io.Discard, "", 0))
if err := dry.TestHookVerifyEtcdSnapshot(context.Background(), "titan-db", "/snap"); err != nil {
t.Fatalf("expected verifyEtcdSnapshot dry-run fast path, got %v", err)
}
runMissing := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "ssh" && strings.Contains(command, "stat -c %s"):
return "2097152", nil
case name == "ssh" && strings.Contains(command, "etcd-snapshot ls"):
return "other-snapshot", nil
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orchMissing, _ := newHookOrchestrator(t, cfg, runMissing, runMissing)
if err := orchMissing.TestHookVerifyEtcdSnapshot(context.Background(), "titan-db", "/var/lib/rancher/k3s/server/db/snapshots/pre-shutdown"); err == nil || !strings.Contains(err.Error(), "not present") {
t.Fatalf("expected verifyEtcdSnapshot missing-in-ls branch, got %v", err)
}
runBadHash := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "ssh" && strings.Contains(command, "stat -c %s"):
return "2097152", nil
case name == "ssh" && strings.Contains(command, "etcd-snapshot ls"):
return "/var/lib/rancher/k3s/server/db/snapshots/pre-shutdown", nil
case name == "ssh" && strings.Contains(command, "sha256sum"):
return "abc", nil
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orchBadHash, _ := newHookOrchestrator(t, cfg, runBadHash, runBadHash)
if err := orchBadHash.TestHookVerifyEtcdSnapshot(context.Background(), "titan-db", "/var/lib/rancher/k3s/server/db/snapshots/pre-shutdown"); err == nil || !strings.Contains(err.Error(), "invalid sha256") {
t.Fatalf("expected verifyEtcdSnapshot invalid-hash branch, got %v", err)
}
})
t.Run("critical-endpoint-uncovered-ranges", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.CriticalServiceEndpointWaitSec = 1
cfg.Startup.CriticalServiceEndpointPollSec = 1
cfg.Startup.CriticalServiceEndpoints = []string{"invalid-entry"}
orchErr, _ := newHookOrchestrator(t, cfg, nil, nil)
ctx, cancel := context.WithCancel(context.Background())
cancel()
if err := orchErr.TestHookWaitForCriticalServiceEndpoints(ctx); !errors.Is(err, context.Canceled) {
t.Fatalf("expected critical endpoint canceled branch after parse error pass, got %v", err)
}
runNotFound := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, "get endpoints") {
return "", errors.New("endpoints \"svc\" not found")
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
cfgNF := lifecycleConfig(t)
cfgNF.Startup.CriticalServiceEndpoints = []string{"ns/svc"}
orchNF, _ := newHookOrchestrator(t, cfgNF, runNotFound, runNotFound)
ready, detail, ns, svc, err := orchNF.TestHookCriticalServiceEndpointsReady(context.Background())
if err != nil || ready || ns != "ns" || svc != "svc" || !strings.Contains(detail, "not found") {
t.Fatalf("expected critical endpoint notfound branch, ready=%v detail=%q ns=%q svc=%q err=%v", ready, detail, ns, svc, err)
}
runQueryErr := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, "get endpoints") {
return "", errors.New("boom")
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orchQErr, _ := newHookOrchestrator(t, cfgNF, runQueryErr, runQueryErr)
if _, _, _, _, err := orchQErr.TestHookCriticalServiceEndpointsReady(context.Background()); err == nil || !strings.Contains(err.Error(), "query endpoints") {
t.Fatalf("expected critical endpoint query error branch, got %v", err)
}
runHealErr := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, " scale deployment svc "):
return "", nil
case name == "kubectl" && strings.Contains(command, "rollout status deployment/svc"):
return "", errors.New("rollout failed")
case name == "kubectl" && strings.Contains(command, " scale statefulset svc "):
return "", errors.New("statefulset not found")
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orchHealErr, _ := newHookOrchestrator(t, lifecycleConfig(t), runHealErr, runHealErr)
if _, err := orchHealErr.TestHookMaybeHealCriticalEndpointBackends(context.Background(), "ns", "svc"); err == nil || !strings.Contains(err.Error(), "rollout failed") {
t.Fatalf("expected critical endpoint heal wait error branch, got %v", err)
}
cfgBadParse := lifecycleConfig(t)
cfgBadParse.Startup.CriticalServiceEndpoints = []string{"/svc"}
orchBadParse, _ := newHookOrchestrator(t, cfgBadParse, nil, nil)
if _, _, _, _, err := orchBadParse.TestHookCriticalServiceEndpointsReady(context.Background()); err == nil || !strings.Contains(err.Error(), "namespace and service") {
t.Fatalf("expected critical endpoint parse-empty branch, got %v", err)
}
})
}

View File

@ -0,0 +1,338 @@
package orchestrator
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
"time"
)
// TestHookStorageFailureBranches runs one orchestration or CLI step.
// Signature: TestHookStorageFailureBranches(t *testing.T).
// Why: validates storage readiness edge paths so startup does not mark success while
// Longhorn/PVC dependencies are still degraded.
func TestHookStorageFailureBranches(t *testing.T) {
t.Run("storage-ready-branch-matrix", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.StorageMinReadyNodes = 2
cfg.Startup.StorageCriticalPVCs = []string{}
queryErrRun := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, "nodes.longhorn.io") {
return "", fmt.Errorf("query failed")
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orchQueryErr, _ := newHookOrchestrator(t, cfg, queryErrRun, queryErrRun)
if _, _, err := orchQueryErr.TestHookStorageReady(context.Background()); err == nil {
t.Fatalf("expected longhorn query error branch")
}
insufficientRun := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, "nodes.longhorn.io") {
return "titan-23:True:True\n", nil
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orchInsufficient, _ := newHookOrchestrator(t, cfg, insufficientRun, insufficientRun)
ok, reason, err := orchInsufficient.TestHookStorageReady(context.Background())
if err != nil || ok || !strings.Contains(reason, "longhorn ready+sched nodes") {
t.Fatalf("expected insufficient longhorn readiness, got ok=%v reason=%q err=%v", ok, reason, err)
}
invalidPVC := cfg
invalidPVC.Startup.StorageCriticalPVCs = []string{"invalid"}
readyNodesRun := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, "nodes.longhorn.io") {
return "titan-23:True:True\ntitan-24:True:True\n", nil
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orchInvalidPVC, _ := newHookOrchestrator(t, invalidPVC, readyNodesRun, readyNodesRun)
if _, _, err := orchInvalidPVC.TestHookStorageReady(context.Background()); err == nil {
t.Fatalf("expected invalid pvc entry error")
}
notFoundPVC := cfg
notFoundPVC.Startup.StorageCriticalPVCs = []string{"monitoring/grafana-data"}
notFoundRun := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "nodes.longhorn.io"):
return "titan-23:True:True\ntitan-24:True:True\n", nil
case name == "kubectl" && strings.Contains(command, "-n monitoring get pvc grafana-data"):
return "", fmt.Errorf("Error from server (NotFound): persistentvolumeclaims \"grafana-data\" not found")
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orchNotFoundPVC, _ := newHookOrchestrator(t, notFoundPVC, notFoundRun, notFoundRun)
ok, reason, err = orchNotFoundPVC.TestHookStorageReady(context.Background())
if err != nil || ok || !strings.Contains(reason, "not found") {
t.Fatalf("expected pvc-not-found readiness detail, got ok=%v reason=%q err=%v", ok, reason, err)
}
notBoundRun := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "nodes.longhorn.io"):
return "titan-23:True:True\ntitan-24:True:True\n", nil
case name == "kubectl" && strings.Contains(command, "-n monitoring get pvc grafana-data"):
return "Pending", nil
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orchNotBound, _ := newHookOrchestrator(t, notFoundPVC, notBoundRun, notBoundRun)
ok, reason, err = orchNotBound.TestHookStorageReady(context.Background())
if err != nil || ok || !strings.Contains(reason, "phase=Pending") {
t.Fatalf("expected pvc non-bound detail, got ok=%v reason=%q err=%v", ok, reason, err)
}
})
t.Run("wait-for-storage-ready-timeout-and-cancel", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.StorageReadyWaitSeconds = 1
cfg.Startup.StorageReadyPollSeconds = 1
cfg.Startup.StorageMinReadyNodes = 3
stuckRun := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, "nodes.longhorn.io") {
return "titan-23:True:True\n", nil
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orchTimeout, _ := newHookOrchestrator(t, cfg, stuckRun, stuckRun)
err := orchTimeout.TestHookWaitForStorageReady(context.Background())
if err == nil || !strings.Contains(err.Error(), "storage readiness not satisfied") {
t.Fatalf("expected storage wait timeout, got %v", err)
}
cfg.Startup.StorageReadyWaitSeconds = 30
orchCanceled, _ := newHookOrchestrator(t, cfg, stuckRun, stuckRun)
cancelCtx, cancel := context.WithCancel(context.Background())
cancel()
err = orchCanceled.TestHookWaitForStorageReady(cancelCtx)
if !errors.Is(err, context.Canceled) {
t.Fatalf("expected context canceled while waiting for storage, got %v", err)
}
})
}
// TestHookCriticalEndpointFailureBranches runs one orchestration or CLI step.
// Signature: TestHookCriticalEndpointFailureBranches(t *testing.T).
// Why: ensures endpoint checklist behavior remains strict when services exist but
// backends are empty, missing, or recovering.
func TestHookCriticalEndpointFailureBranches(t *testing.T) {
t.Run("critical-endpoint-ready-matrix", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.CriticalServiceEndpoints = nil
orchNone, _ := newHookOrchestrator(t, cfg, nil, nil)
ok, detail, ns, svc, err := orchNone.TestHookCriticalServiceEndpointsReady(context.Background())
if err != nil || !ok || detail == "" || ns != "" || svc != "" {
t.Fatalf("expected no-config success branch, got ok=%v detail=%q ns=%q svc=%q err=%v", ok, detail, ns, svc, err)
}
cfg.Startup.CriticalServiceEndpoints = []string{"invalid"}
orchInvalid, _ := newHookOrchestrator(t, cfg, nil, nil)
if _, _, _, _, err := orchInvalid.TestHookCriticalServiceEndpointsReady(context.Background()); err == nil {
t.Fatalf("expected invalid endpoint entry error")
}
cfg.Startup.CriticalServiceEndpoints = []string{"monitoring/grafana"}
notFoundRun := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, "get endpoints grafana") {
return "", fmt.Errorf("Error from server (NotFound): endpoints \"grafana\" not found")
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orchNotFound, _ := newHookOrchestrator(t, cfg, notFoundRun, notFoundRun)
ok, detail, ns, svc, err = orchNotFound.TestHookCriticalServiceEndpointsReady(context.Background())
if err != nil || ok || ns != "monitoring" || svc != "grafana" || !strings.Contains(detail, "not found") {
t.Fatalf("expected endpoint not-found detail, got ok=%v detail=%q ns=%q svc=%q err=%v", ok, detail, ns, svc, err)
}
zeroRun := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, "get endpoints grafana") {
return "", nil
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orchZero, _ := newHookOrchestrator(t, cfg, zeroRun, zeroRun)
ok, detail, ns, svc, err = orchZero.TestHookCriticalServiceEndpointsReady(context.Background())
if err != nil || ok || ns != "monitoring" || svc != "grafana" || !strings.Contains(detail, "endpoints=0") {
t.Fatalf("expected endpoint-zero detail, got ok=%v detail=%q ns=%q svc=%q err=%v", ok, detail, ns, svc, err)
}
})
t.Run("critical-endpoint-wait-timeout-with-autoheal", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.CriticalServiceEndpointWaitSec = 1
cfg.Startup.CriticalServiceEndpointPollSec = 1
cfg.Startup.CriticalServiceEndpoints = []string{"monitoring/grafana"}
autoHealRun := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "get endpoints grafana"):
return "", nil
case name == "kubectl" && strings.Contains(command, "-n monitoring scale deployment grafana --replicas=1"):
return "", nil
case name == "kubectl" && strings.Contains(command, "-n monitoring rollout status deployment/grafana"):
return "", fmt.Errorf("Error from server (NotFound): deployment \"grafana\" not found")
case name == "kubectl" && strings.Contains(command, "-n monitoring scale statefulset grafana --replicas=1"):
return "", nil
case name == "kubectl" && strings.Contains(command, "-n monitoring rollout status statefulset/grafana"):
return "ready", nil
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orch, _ := newHookOrchestrator(t, cfg, autoHealRun, autoHealRun)
err := orch.TestHookWaitForCriticalServiceEndpoints(context.Background())
if err == nil || !strings.Contains(err.Error(), "critical service endpoint checklist not satisfied") {
t.Fatalf("expected critical-endpoint wait timeout, got %v", err)
}
})
}
// TestHookFluxHealthFailureBranches runs one orchestration or CLI step.
// Signature: TestHookFluxHealthFailureBranches(t *testing.T).
// Why: covers adaptive wait, convergence parsing, and immutable job self-heal error paths.
func TestHookFluxHealthFailureBranches(t *testing.T) {
t.Run("adaptive-flux-wait-branches", func(t *testing.T) {
cfg := lifecycleConfig(t)
queryErrRun := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, "get kustomizations.kustomize.toolkit.fluxcd.io -A -o json") {
return "", fmt.Errorf("query failed")
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orchQueryErr, _ := newHookOrchestrator(t, cfg, queryErrRun, queryErrRun)
if _, _, err := orchQueryErr.TestHookAdaptiveFluxHealthWait(context.Background(), 10*time.Minute); err == nil {
t.Fatalf("expected adaptive wait query error")
}
decodeErrRun := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, "get kustomizations.kustomize.toolkit.fluxcd.io -A -o json") {
return "{bad-json", nil
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orchDecodeErr, _ := newHookOrchestrator(t, cfg, decodeErrRun, decodeErrRun)
if _, _, err := orchDecodeErr.TestHookAdaptiveFluxHealthWait(context.Background(), 10*time.Minute); err == nil {
t.Fatalf("expected adaptive wait decode error")
}
noTimeoutRun := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, "get kustomizations.kustomize.toolkit.fluxcd.io -A -o json") {
return `{"items":[{"metadata":{"namespace":"flux-system","name":"services"},"spec":{"suspend":false,"timeout":""}}]}`, nil
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orchNoTimeout, _ := newHookOrchestrator(t, cfg, noTimeoutRun, noTimeoutRun)
wait, reason, err := orchNoTimeout.TestHookAdaptiveFluxHealthWait(context.Background(), 10*time.Minute)
if err != nil || wait != 10*time.Minute || !strings.Contains(reason, "no explicit kustomization timeouts") {
t.Fatalf("expected no-timeout branch, got wait=%s reason=%q err=%v", wait, reason, err)
}
})
t.Run("flux-health-ready-and-immutable-job-heal-branches", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.IgnoreFluxKustomizations = []string{"infra/ignored"}
fluxItems := map[string]any{
"items": []map[string]any{
{
"metadata": map[string]any{"namespace": "infra", "name": "ignored"},
"spec": map[string]any{"suspend": false, "timeout": "30s"},
"status": map[string]any{"conditions": []map[string]any{
{"type": "Ready", "status": "False", "reason": "Progressing", "message": "ignore-me"},
}},
},
{
"metadata": map[string]any{"namespace": "flux-system", "name": "services"},
"spec": map[string]any{"suspend": false, "timeout": "30m"},
"status": map[string]any{"conditions": []map[string]any{
{"type": "Ready", "status": "False", "reason": "InstallFailed", "message": "job field is immutable"},
}},
},
},
}
fluxJSON, err := json.Marshal(fluxItems)
if err != nil {
t.Fatalf("marshal flux fixture: %v", err)
}
jobsJSON := `{"items":[{"metadata":{"namespace":"flux-system","name":"reconcile-services","labels":{"kustomize.toolkit.fluxcd.io/name":"services"},"ownerReferences":[]},"status":{"failed":1,"succeeded":0,"conditions":[{"type":"Failed","status":"True"}]}}]}`
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "get kustomizations.kustomize.toolkit.fluxcd.io -A -o json"):
return string(fluxJSON), nil
case name == "kubectl" && strings.Contains(command, "get jobs -A -o json"):
return jobsJSON, nil
case name == "kubectl" && strings.Contains(command, "-n flux-system delete job reconcile-services --wait=false"):
return "", nil
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
ok, detail, err := orch.TestHookFluxHealthReady(context.Background())
if err != nil || ok || !strings.Contains(strings.ToLower(detail), "immutable") {
t.Fatalf("expected flux not-ready detail with immutable signal, got ok=%v detail=%q err=%v", ok, detail, err)
}
healed, err := orch.TestHookHealImmutableFluxJobs(context.Background())
if err != nil || !healed {
t.Fatalf("expected immutable-job cleanup success, got healed=%v err=%v", healed, err)
}
})
t.Run("wait-for-flux-health-timeout-and-cancel", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.FluxHealthWaitSeconds = 1
cfg.Startup.FluxHealthPollSeconds = 1
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "get kustomizations.kustomize.toolkit.fluxcd.io -A -o json"):
return `{"items":[{"metadata":{"namespace":"flux-system","name":"services"},"spec":{"suspend":false},"status":{"conditions":[{"type":"Ready","status":"False","reason":"InstallFailed","message":"job field is immutable"}]}}]}`, nil
case name == "kubectl" && strings.Contains(command, "get jobs -A -o json"):
return `{"items":[]}`, nil
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orchTimeout, _ := newHookOrchestrator(t, cfg, run, run)
err := orchTimeout.TestHookWaitForFluxHealth(context.Background())
if err == nil || !strings.Contains(err.Error(), "flux convergence not satisfied") {
t.Fatalf("expected flux health timeout, got %v", err)
}
cfg.Startup.FluxHealthWaitSeconds = 30
orchCanceled, _ := newHookOrchestrator(t, cfg, run, run)
cancelCtx, cancel := context.WithCancel(context.Background())
cancel()
err = orchCanceled.TestHookWaitForFluxHealth(cancelCtx)
if !errors.Is(err, context.Canceled) {
t.Fatalf("expected context canceled from flux wait, got %v", err)
}
})
}

View File

@ -0,0 +1,235 @@
package orchestrator
import (
"context"
"io"
"log"
"net"
"strings"
"testing"
"time"
"scm.bstein.dev/bstein/ananke/internal/cluster"
"scm.bstein.dev/bstein/ananke/internal/config"
"scm.bstein.dev/bstein/ananke/internal/execx"
"scm.bstein.dev/bstein/ananke/internal/state"
)
// TestHookFluxHealthAndStorageBranches runs one orchestration or CLI step.
// Signature: TestHookFluxHealthAndStorageBranches(t *testing.T).
// Why: exercises flux-health and storage readiness helpers directly for coverage and behavioral safety.
func TestHookFluxHealthAndStorageBranches(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.FluxHealthWaitSeconds = 2
cfg.Startup.FluxHealthPollSeconds = 1
cfg.Startup.IgnoreFluxKustomizations = []string{"flux-system/skip-me"}
cfg.Startup.StorageReadyWaitSeconds = 2
cfg.Startup.StorageReadyPollSeconds = 1
cfg.Startup.StorageMinReadyNodes = 1
cfg.Startup.StorageCriticalPVCs = []string{"monitoring/grafana-data"}
recorder := &commandRecorder{}
base := lifecycleDispatcher(recorder)
fluxCalls := 0
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "get kustomizations.kustomize.toolkit.fluxcd.io -A -o json"):
recorder.record(name, args)
fluxCalls++
if fluxCalls <= 1 {
return `{"items":[{"metadata":{"namespace":"flux-system","name":"services"},"spec":{"suspend":false},"status":{"conditions":[{"type":"Ready","status":"False","reason":"Unknown","message":"waiting"}]}}]}`, nil
}
return `{"items":[{"metadata":{"namespace":"flux-system","name":"services"},"spec":{"suspend":false},"status":{"conditions":[{"type":"Ready","status":"True","reason":"ReconciliationSucceeded","message":"ok"}]}}]}`, nil
case name == "kubectl" && strings.Contains(command, "get jobs -A -o json"):
recorder.record(name, args)
return `{"items":[]}`, nil
case name == "kubectl" && strings.Contains(command, "nodes.longhorn.io"):
recorder.record(name, args)
return "lh-a:True:True\n", nil
case name == "kubectl" && strings.Contains(command, "-n monitoring get pvc grafana-data"):
recorder.record(name, args)
return "Bound", nil
default:
return base(ctx, timeout, name, args...)
}
}
orch := cluster.New(cfg, &execx.Runner{DryRun: false}, state.New(cfg.State.RunHistoryPath), log.New(io.Discard, "", 0))
orch.SetCommandOverrides(run, run)
ok, detail, err := orch.TestHookFluxHealthReady(context.Background())
if err != nil {
t.Fatalf("fluxHealthReady error: %v", err)
}
if ok {
t.Fatalf("expected first fluxHealthReady call to be not-ready: %s", detail)
}
healed, err := orch.TestHookHealImmutableFluxJobs(context.Background())
if err != nil {
t.Fatalf("healImmutableFluxJobs error: %v", err)
}
if healed {
t.Fatalf("expected no immutable job heal action in this fixture")
}
if err := orch.TestHookWaitForFluxHealth(context.Background()); err != nil {
t.Fatalf("waitForFluxHealth: %v", err)
}
ready, reason, readyErr := orch.TestHookStorageReady(context.Background())
if readyErr != nil {
t.Fatalf("storageReady error: %v", readyErr)
}
if !ready {
t.Fatalf("expected storage ready, reason=%s", reason)
}
if err := orch.TestHookWaitForStorageReady(context.Background()); err != nil {
t.Fatalf("waitForStorageReady: %v", err)
}
}
// TestHookTimeSyncAndDatastoreBranches runs one orchestration or CLI step.
// Signature: TestHookTimeSyncAndDatastoreBranches(t *testing.T).
// Why: covers time-sync gate and datastore preflight helpers, including parser and TCP helper paths.
func TestHookTimeSyncAndDatastoreBranches(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.ControlPlanes = []string{"titan-db", "titan-23"}
cfg.Workers = []string{"titan-24"}
cfg.SSHManagedNodes = []string{"titan-db", "titan-23", "titan-24"}
cfg.SSHNodeHosts["titan-23"] = "titan-23"
cfg.SSHNodeHosts["titan-24"] = "titan-24"
cfg.Startup.TimeSyncMode = "quorum"
cfg.Startup.TimeSyncQuorum = 1
cfg.Startup.TimeSyncWaitSeconds = 2
cfg.Startup.TimeSyncPollSeconds = 1
recorder := &commandRecorder{}
base := lifecycleDispatcher(recorder)
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "sh" && strings.Contains(command, "timedatectl show -p NTPSynchronized --value"):
recorder.record(name, args)
return "yes", nil
case name == "ssh" && strings.Contains(command, "timedatectl show -p NTPSynchronized --value"):
recorder.record(name, args)
if strings.Contains(command, "titan-db") {
return "yes", nil
}
return "no", nil
case name == "ssh" && strings.Contains(command, "systemctl cat k3s"):
recorder.record(name, args)
return "ExecStart=/usr/local/bin/k3s server --datastore-endpoint=postgres://127.0.0.1:6543/k3s", nil
default:
return base(ctx, timeout, name, args...)
}
}
orch := cluster.New(cfg, &execx.Runner{DryRun: false}, state.New(cfg.State.RunHistoryPath), log.New(io.Discard, "", 0))
orch.SetCommandOverrides(run, run)
if err := orch.TestHookWaitForTimeSync(context.Background(), []string{"titan-db", "titan-23"}); err != nil {
t.Fatalf("waitForTimeSync: %v", err)
}
if got := cluster.TestHookParseDatastoreEndpoint(`ExecStart=/usr/local/bin/k3s server --datastore-endpoint="postgres://db:5432/k3s"`); !strings.Contains(got, "postgres://db:5432/k3s") {
t.Fatalf("unexpected datastore endpoint parse: %q", got)
}
if got := orch.TestHookNodeNameForHost("titan-23"); got != "titan-23" {
t.Fatalf("unexpected nodeNameForHost direct match: %q", got)
}
if err := orch.TestHookValidateNodeInventory(); err != nil {
t.Fatalf("validateNodeInventory: %v", err)
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen for tcpReachable test: %v", err)
}
addr := ln.Addr().String()
if !orch.TestHookTCPReachable(addr, time.Second) {
t.Fatalf("expected tcpReachable=true for listener %s", addr)
}
_ = ln.Close()
if orch.TestHookTCPReachable(addr, 100*time.Millisecond) {
t.Fatalf("expected tcpReachable=false after listener close")
}
}
// TestHookChecklistAndStabilityBranches runs one orchestration or CLI step.
// Signature: TestHookChecklistAndStabilityBranches(t *testing.T).
// Why: covers checklist helper methods and startup stability window internals.
func TestHookChecklistAndStabilityBranches(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.ServiceChecklist = []config.ServiceChecklistCheck{
{
Name: "grafana",
URL: "https://metrics.bstein.dev/api/health",
AcceptedStatuses: []int{200},
BodyContains: `"database":"ok"`,
TimeoutSeconds: 5,
},
}
cfg.Startup.ServiceChecklistWaitSeconds = 1
cfg.Startup.ServiceChecklistPollSeconds = 1
cfg.Startup.ServiceChecklistStabilitySec = 1
cfg.Startup.RequireWorkloadConvergence = false
recorder := &commandRecorder{}
base := lifecycleDispatcher(recorder)
serviceCalls := 0
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "get pods -A -o json"):
recorder.record(name, args)
return `{"items":[]}`, nil
case name == "kubectl" && strings.Contains(command, "get deploy,statefulset -A -o json"):
recorder.record(name, args)
return `{"items":[{"kind":"Deployment","metadata":{"namespace":"monitoring","name":"grafana"},"spec":{"replicas":1,"template":{"spec":{}}},"status":{"readyReplicas":1}}]}`, nil
case name == "kubectl" && strings.Contains(command, "get deployment -A -o jsonpath="):
recorder.record(name, args)
return "monitoring\tgrafana\t1\n", nil
case name == "kubectl" && strings.Contains(command, "get statefulset -A -o jsonpath="):
recorder.record(name, args)
return "", nil
case name == "kubectl" && strings.Contains(command, "get ingress -A -o json"):
recorder.record(name, args)
return `{"items":[]}`, nil
case name == "kubectl" && strings.Contains(command, "nodes.longhorn.io"):
recorder.record(name, args)
return "lh-a:True:True\n", nil
case name == "curl":
recorder.record(name, args)
serviceCalls++
if serviceCalls == 1 {
return "503", nil
}
return "200", nil
default:
return base(ctx, timeout, name, args...)
}
}
orch := cluster.New(cfg, &execx.Runner{DryRun: false}, state.New(cfg.State.RunHistoryPath), log.New(io.Discard, "", 0))
orch.SetCommandOverrides(run, run)
ok, detail := orch.TestHookPostStartProbesReady(context.Background())
if !ok || !strings.Contains(detail, "no probes configured") {
t.Fatalf("expected no-probes ready branch, got ok=%v detail=%q", ok, detail)
}
code, err := orch.TestHookHTTPProbe(context.Background(), "https://example.invalid")
if err != nil {
t.Fatalf("unexpected HTTP probe error with recorder override: %v", err)
}
if code != 503 {
t.Fatalf("expected first synthetic HTTP probe code=503, got %d", code)
}
// Direct checklist readiness path should always return a non-empty status detail.
_, checkDetail := orch.TestHookServiceChecklistReady(context.Background())
if checkDetail == "" {
t.Fatalf("expected service checklist detail to be populated")
}
// Force stability helper path through synthetic kubectl outputs.
if err := orch.TestHookWaitForStabilityWindow(context.Background()); err != nil && !strings.Contains(err.Error(), "stability") {
t.Fatalf("unexpected stability window error: %v", err)
}
}

View File

@ -0,0 +1,229 @@
package orchestrator
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"scm.bstein.dev/bstein/ananke/internal/cluster"
"scm.bstein.dev/bstein/ananke/internal/config"
)
// TestHookIngressServiceMatrix runs one orchestration or CLI step.
// Signature: TestHookIngressServiceMatrix(t *testing.T).
// Why: expands checklist/ingress branch coverage so startup readiness gating is
// validated against more realistic and failure-prone edge cases.
func TestHookIngressServiceMatrix(t *testing.T) {
t.Run("required-node-labels-success-and-error", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.RequiredNodeLabels = map[string]map[string]string{
"titan-23": {
"topology.kubernetes.io/zone": "lab-a",
"skip-empty": "",
},
"": {"ignored": "value"},
}
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, "label node titan-23 --overwrite") {
return "", nil
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
if err := orch.TestHookEnsureRequiredNodeLabels(context.Background()); err != nil {
t.Fatalf("expected required-node-labels success, got %v", err)
}
runErr := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, "label node titan-23 --overwrite") {
return "", errors.New("label denied")
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orchErr, _ := newHookOrchestrator(t, cfg, runErr, runErr)
if err := orchErr.TestHookEnsureRequiredNodeLabels(context.Background()); err == nil {
t.Fatalf("expected ensureRequiredNodeLabels failure branch")
}
})
t.Run("ingress-discovery-checklist-and-heal", func(t *testing.T) {
tlsServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`ok`))
}))
defer tlsServer.Close()
ingressHost := strings.TrimPrefix(tlsServer.URL, "https://")
cfg := lifecycleConfig(t)
cfg.Startup.IngressChecklistInsecureSkip = true
cfg.Startup.IngressChecklistIgnoreHosts = []string{"ignored.bstein.dev", "stream.bstein.dev"}
cfg.Startup.ServiceChecklist = []config.ServiceChecklistCheck{
{Name: "metrics", URL: "https://" + ingressHost + "/"},
}
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "get ingress -A -o json"):
return fmt.Sprintf(`{"items":[
{"metadata":{"namespace":"monitoring"},"spec":{"rules":[{"host":"%s"},{"host":"ignored.bstein.dev"},{"host":"*.wildcard.bstein.dev"}]}},
{"metadata":{"namespace":"media"},"spec":{"rules":[{"host":"stream.bstein.dev"}]}}
]}`, ingressHost), nil
case name == "kubectl" && strings.Contains(command, "get deploy,statefulset -A -o json"):
return `{"items":[
{"kind":"Deployment","metadata":{"namespace":"monitoring","name":"grafana"},"spec":{"replicas":0},"status":{"readyReplicas":0}},
{"kind":"StatefulSet","metadata":{"namespace":"media","name":"jellyfin"},"spec":{"replicas":1},"status":{"readyReplicas":1}}
]}`, nil
case name == "kubectl" && strings.Contains(command, " scale deployment grafana --replicas=1"):
return "", nil
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
hosts, err := orch.TestHookDiscoverIngressHosts(context.Background())
if err != nil {
t.Fatalf("discover ingress hosts: %v", err)
}
if len(hosts) == 0 || hosts[0] == "ignored.bstein.dev" {
t.Fatalf("expected filtered ingress hosts, got %v", hosts)
}
ns, err := orch.TestHookDiscoverIngressNamespacesForHost(context.Background(), ingressHost)
if err != nil || len(ns) == 0 || ns[0] != "monitoring" {
t.Fatalf("discover ingress namespaces: ns=%v err=%v", ns, err)
}
if ok, detail := orch.TestHookIngressChecklistReady(context.Background()); !ok || !strings.Contains(detail, "hosts=") {
t.Fatalf("expected ingress checklist ready, ok=%v detail=%q", ok, detail)
}
last := time.Time{}
orch.TestHookMaybeAutoHealIngressHostBackends(context.Background(), &last, ingressHost+": status=503")
prev := last
orch.TestHookMaybeAutoHealIngressHostBackends(context.Background(), &last, ingressHost+": status=503")
if last != prev {
t.Fatalf("expected ingress heal cooldown to suppress second attempt")
}
expectedHost := cluster.TestHookHostFromURL("https://" + ingressHost + "/")
if got := orch.TestHookChecklistFailureHost("metrics: failed"); got != expectedHost {
t.Fatalf("expected checklistFailureHost to map service check name to host %q, got %q", expectedHost, got)
}
if got := cluster.TestHookHostFromURL("not a url"); got != "" {
t.Fatalf("expected invalid URL host parse to be empty, got %q", got)
}
})
t.Run("ingress-discovery-error-branches", func(t *testing.T) {
cfg := lifecycleConfig(t)
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "get ingress -A -o json"):
return "{broken", nil
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
if _, err := orch.TestHookDiscoverIngressHosts(context.Background()); err == nil {
t.Fatalf("expected ingress decode error branch")
}
if _, err := orch.TestHookDiscoverIngressNamespacesForHost(context.Background(), "metrics.bstein.dev"); err == nil {
t.Fatalf("expected ingress namespace decode error branch")
}
})
t.Run("service-checklist-and-stability-branches", func(t *testing.T) {
okServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"database":"ok"}`))
}))
defer okServer.Close()
failServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = w.Write([]byte(`{"status":"down"}`))
}))
defer failServer.Close()
cfg := lifecycleConfig(t)
check := config.ServiceChecklistCheck{
Name: "grafana",
URL: okServer.URL,
AcceptedStatuses: []int{200},
BodyContains: `"database":"ok"`,
BodyNotContains: `"status":"down"`,
TimeoutSeconds: 2,
}
cfg.Startup.ServiceChecklist = []config.ServiceChecklistCheck{check}
orch, _ := newHookOrchestrator(t, cfg, nil, nil)
if ok, detail := orch.TestHookServiceCheckReady(context.Background(), check); !ok || !strings.Contains(detail, "status=200") {
t.Fatalf("expected service check success, ok=%v detail=%q", ok, detail)
}
check.BodyContains = `"missing-marker"`
if ok, _ := orch.TestHookServiceCheckReady(context.Background(), check); ok {
t.Fatalf("expected body-contains mismatch failure")
}
check.BodyContains = `"database":"ok"`
check.BodyNotContains = `"database":"ok"`
if ok, _ := orch.TestHookServiceCheckReady(context.Background(), check); ok {
t.Fatalf("expected body-not-contains failure")
}
if _, _, err := orch.TestHookHTTPChecklistProbe(context.Background(), config.ServiceChecklistCheck{URL: "://bad-url"}); err == nil {
t.Fatalf("expected http checklist request-build error")
}
if _, _, err := orch.TestHookHTTPChecklistProbe(context.Background(), config.ServiceChecklistCheck{URL: "http://127.0.0.1:1"}); err == nil {
t.Fatalf("expected http checklist request-failure branch")
}
cfgWait := lifecycleConfig(t)
cfgWait.Startup.ServiceChecklist = []config.ServiceChecklistCheck{
{Name: "down", URL: failServer.URL, AcceptedStatuses: []int{200}, TimeoutSeconds: 1},
}
cfgWait.Startup.ServiceChecklistWaitSeconds = 1
cfgWait.Startup.ServiceChecklistPollSeconds = 1
orchWait, _ := newHookOrchestrator(t, cfgWait, nil, nil)
cancelCtx, cancel := context.WithCancel(context.Background())
cancel()
if err := orchWait.TestHookWaitForServiceChecklist(cancelCtx); !errors.Is(err, context.Canceled) {
t.Fatalf("expected canceled service-checklist wait, got %v", err)
}
cfgStable := lifecycleConfig(t)
cfgStable.Startup.ServiceChecklistStabilitySec = 0
orchStable, _ := newHookOrchestrator(t, cfgStable, nil, nil)
if err := orchStable.TestHookWaitForStabilityWindow(context.Background()); err != nil {
t.Fatalf("expected zero-window stability fast-path, got %v", err)
}
runFailPods := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, "get pods -A -o json") {
return `{"items":[{"metadata":{"namespace":"vault","name":"vault-0"},"spec":{"nodeName":"titan-23"},"status":{"containerStatuses":[{"state":{"waiting":{"reason":"CrashLoopBackOff"}}}]}}]}`, nil
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
cfgFailStable := lifecycleConfig(t)
cfgFailStable.Startup.ServiceChecklistStabilitySec = 1
orchFailStable, _ := newHookOrchestrator(t, cfgFailStable, runFailPods, runFailPods)
err := orchFailStable.TestHookWaitForStabilityWindow(context.Background())
if err == nil || !strings.Contains(err.Error(), "startup stability window failed") {
t.Fatalf("expected stability failure from failing pods, got %v", err)
}
if !cluster.TestHookChecklistContains(`{"A":"B"}`, `"a":"b"`) {
t.Fatalf("expected case-insensitive checklistContains success")
}
if cluster.TestHookIsLikelyHostname("not/a/host") {
t.Fatalf("expected invalid hostname heuristic to be false")
}
})
}

View File

@ -0,0 +1,372 @@
package orchestrator
import (
"context"
"errors"
"net"
"os"
"strings"
"testing"
"time"
"scm.bstein.dev/bstein/ananke/internal/cluster"
"scm.bstein.dev/bstein/ananke/internal/config"
"scm.bstein.dev/bstein/ananke/internal/state"
)
// lifecycleFastConfig runs one orchestration or CLI step.
// Signature: lifecycleFastConfig(t *testing.T) config.Config.
// Why: lifecycle gap tests intentionally fail early in many branches, so short
// wait windows keep branch coverage runs fast and deterministic.
func lifecycleFastConfig(t *testing.T) config.Config {
t.Helper()
cfg := lifecycleConfig(t)
cfg.Startup.APIWaitSeconds = 1
cfg.Startup.APIPollSeconds = 1
cfg.Startup.NodeInventoryReachWaitSeconds = 1
cfg.Startup.NodeInventoryReachPollSeconds = 1
cfg.Startup.RequireCriticalServiceEndpoints = false
cfg.Startup.RequireFluxHealth = false
cfg.Startup.RequireWorkloadConvergence = false
cfg.Startup.RequireIngressChecklist = false
cfg.Startup.RequireServiceChecklist = false
cfg.Startup.ServiceChecklistStabilitySec = 0
cfg.Startup.RequirePostStartProbes = false
return cfg
}
// TestLifecycleDeepFailureMatrix runs one orchestration or CLI step.
// Signature: TestLifecycleDeepFailureMatrix(t *testing.T).
// Why: saturates remaining lifecycle startup/shutdown edge branches that are
// difficult to hit from happy-path drill tests.
func TestLifecycleDeepFailureMatrix(t *testing.T) {
t.Run("startup-lock-path-is-directory", func(t *testing.T) {
cfg := lifecycleFastConfig(t)
cfg.State.LockPath = t.TempDir()
orch, _ := newHookOrchestrator(t, cfg, nil, nil)
if err := orch.Startup(context.Background(), cluster.StartupOptions{Reason: "lock-dir"}); err == nil {
t.Fatalf("expected lock-path directory failure")
}
})
t.Run("startup-node-inventory-validation-fails", func(t *testing.T) {
cfg := lifecycleFastConfig(t)
cfg.SSHPort = 70000
orch, _ := newHookOrchestrator(t, cfg, nil, nil)
err := orch.Startup(context.Background(), cluster.StartupOptions{Reason: "bad-inventory"})
if err == nil || !strings.Contains(err.Error(), "node inventory preflight failed") {
t.Fatalf("expected node inventory preflight failure, got %v", err)
}
})
t.Run("startup-node-reachability-fails", func(t *testing.T) {
cfg := lifecycleFastConfig(t)
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "ssh" && strings.Contains(command, "__ANANKE_NODE_REACHABLE__") {
return "", errors.New("no route to host")
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
err := orch.Startup(context.Background(), cluster.StartupOptions{Reason: "reachability-fail"})
if err == nil || !strings.Contains(err.Error(), "node inventory reachability gate") {
t.Fatalf("expected reachability gate failure, got %v", err)
}
})
t.Run("startup-clear-stale-startup-intent-write-fails", func(t *testing.T) {
cfg := lifecycleFastConfig(t)
if err := state.WriteIntent(cfg.State.IntentPath, state.Intent{
State: state.IntentStartupInProgress,
Reason: "stale",
Source: "test",
UpdatedAt: time.Now().UTC().Add(-2 * time.Hour),
}); err != nil {
t.Fatalf("seed stale startup intent: %v", err)
}
if err := os.Chmod(cfg.State.IntentPath, 0o400); err != nil {
t.Fatalf("chmod intent file readonly: %v", err)
}
defer os.Chmod(cfg.State.IntentPath, 0o600)
orch, _ := newHookOrchestrator(t, cfg, nil, nil)
err := orch.Startup(context.Background(), cluster.StartupOptions{Reason: "clear-stale-startup"})
if err == nil || !strings.Contains(err.Error(), "clear stale startup intent") {
t.Fatalf("expected stale startup clear failure, got %v", err)
}
})
t.Run("startup-clear-stale-shutdown-intent-write-fails", func(t *testing.T) {
cfg := lifecycleFastConfig(t)
if err := state.WriteIntent(cfg.State.IntentPath, state.Intent{
State: state.IntentShuttingDown,
Reason: "stale-shutdown",
Source: "test",
UpdatedAt: time.Now().UTC().Add(-2 * time.Hour),
}); err != nil {
t.Fatalf("seed stale shutdown intent: %v", err)
}
if err := os.Chmod(cfg.State.IntentPath, 0o400); err != nil {
t.Fatalf("chmod intent file readonly: %v", err)
}
defer os.Chmod(cfg.State.IntentPath, 0o600)
orch, _ := newHookOrchestrator(t, cfg, nil, nil)
err := orch.Startup(context.Background(), cluster.StartupOptions{Reason: "clear-stale-shutdown"})
if err == nil || !strings.Contains(err.Error(), "clear stale shutdown intent") {
t.Fatalf("expected stale shutdown clear failure, got %v", err)
}
})
t.Run("startup-cooldown-reread-intent-error", func(t *testing.T) {
cfg := lifecycleFastConfig(t)
cfg.Startup.ShutdownCooldownSeconds = 1
if err := state.WriteIntent(cfg.State.IntentPath, state.Intent{
State: state.IntentShutdownComplete,
Reason: "recent",
Source: "test",
UpdatedAt: time.Now().UTC(),
}); err != nil {
t.Fatalf("seed cooldown intent: %v", err)
}
go func(intentPath string) {
time.Sleep(150 * time.Millisecond)
_ = os.Remove(intentPath)
_ = os.Mkdir(intentPath, 0o755)
}(cfg.State.IntentPath)
orch, _ := newHookOrchestrator(t, cfg, nil, nil)
err := orch.Startup(context.Background(), cluster.StartupOptions{Reason: "cooldown-reread"})
if err == nil || !strings.Contains(err.Error(), "re-read startup intent after cooldown wait") {
t.Fatalf("expected cooldown reread failure, got %v", err)
}
})
t.Run("startup-cooldown-shutdown-became-active", func(t *testing.T) {
cfg := lifecycleFastConfig(t)
cfg.Startup.ShutdownCooldownSeconds = 1
if err := state.WriteIntent(cfg.State.IntentPath, state.Intent{
State: state.IntentShutdownComplete,
Reason: "recent",
Source: "test",
UpdatedAt: time.Now().UTC(),
}); err != nil {
t.Fatalf("seed cooldown intent: %v", err)
}
go func(intentPath string) {
time.Sleep(150 * time.Millisecond)
_ = state.WriteIntent(intentPath, state.Intent{
State: state.IntentShuttingDown,
Reason: "peer-shutdown",
Source: "test",
UpdatedAt: time.Now().UTC(),
})
}(cfg.State.IntentPath)
orch, _ := newHookOrchestrator(t, cfg, nil, nil)
err := orch.Startup(context.Background(), cluster.StartupOptions{Reason: "cooldown-peer-active"})
if err == nil || !strings.Contains(err.Error(), "shutdown intent became active during cooldown wait") {
t.Fatalf("expected cooldown active-shutdown failure, got %v", err)
}
})
t.Run("startup-set-intent-write-fails", func(t *testing.T) {
cfg := lifecycleFastConfig(t)
if err := state.WriteIntent(cfg.State.IntentPath, state.Intent{
State: state.IntentNormal,
Reason: "seed",
Source: "test",
UpdatedAt: time.Now().UTC(),
}); err != nil {
t.Fatalf("seed normal intent: %v", err)
}
if err := os.Chmod(cfg.State.IntentPath, 0o400); err != nil {
t.Fatalf("chmod intent file readonly: %v", err)
}
defer os.Chmod(cfg.State.IntentPath, 0o600)
orch, _ := newHookOrchestrator(t, cfg, nil, nil)
err := orch.Startup(context.Background(), cluster.StartupOptions{Reason: "intent-write-fail"})
if err == nil || !strings.Contains(err.Error(), "set startup intent") {
t.Fatalf("expected startup intent write failure, got %v", err)
}
})
t.Run("startup-timesync-error-propagates", func(t *testing.T) {
cfg := lifecycleFastConfig(t)
cfg.Startup.RequireTimeSync = true
cfg.Startup.TimeSyncWaitSeconds = 1
cfg.Startup.TimeSyncPollSeconds = 1
orch, _ := newHookOrchestrator(t, cfg, nil, nil)
err := orch.Startup(context.Background(), cluster.StartupOptions{Reason: "timesync-fail"})
if err == nil || !strings.Contains(err.Error(), "time sync") {
t.Fatalf("expected time sync failure, got %v", err)
}
})
t.Run("startup-datastore-preflight-cancel", func(t *testing.T) {
cfg := lifecycleFastConfig(t)
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "ssh" && strings.Contains(command, "systemctl cat k3s") {
return "ExecStart=/usr/local/bin/k3s server --datastore-endpoint=postgres://127.0.0.1:1/k3s", nil
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
ctx, cancel := context.WithCancel(context.Background())
cancel()
err := orch.Startup(ctx, cluster.StartupOptions{Reason: "datastore-cancel"})
if !errors.Is(err, context.Canceled) {
t.Fatalf("expected canceled datastore preflight, got %v", err)
}
})
t.Run("startup-auto-etcd-restore-hard-failure", func(t *testing.T) {
cfg := lifecycleFastConfig(t)
cfg.Startup.AutoEtcdRestoreOnAPIFailure = true
cfg.Startup.EtcdRestoreControlPlane = "titan-db"
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "version --request-timeout=5s"):
return "", errors.New("api down")
case name == "ssh" && strings.Contains(command, "systemctl cat k3s"):
return "ExecStart=/usr/local/bin/k3s server", nil
case name == "ssh" && strings.Contains(command, "etcd-snapshot ls"):
return "/var/lib/rancher/k3s/server/db/snapshots/pre-shutdown", nil
case name == "ssh" && strings.Contains(command, "stat -c %s"):
return "2097152", nil
case name == "ssh" && strings.Contains(command, "sha256sum"):
return "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil
case name == "ssh" && strings.Contains(command, "server --cluster-reset"):
return "", errors.New("cluster reset failed")
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
err := orch.Startup(context.Background(), cluster.StartupOptions{Reason: "restore-hard-fail"})
if err == nil || !strings.Contains(err.Error(), "automatic etcd restore failed") {
t.Fatalf("expected automatic etcd restore failure, got %v", err)
}
})
t.Run("startup-auto-etcd-restore-not-applicable-then-api-still-down", func(t *testing.T) {
cfg := lifecycleFastConfig(t)
cfg.Startup.AutoEtcdRestoreOnAPIFailure = true
cfg.Startup.EtcdRestoreControlPlane = "titan-db"
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("open local datastore listener: %v", err)
}
defer listener.Close()
address := listener.Addr().String()
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "version --request-timeout=5s"):
return "", errors.New("api still down")
case name == "ssh" && strings.Contains(command, "systemctl cat k3s"):
return "ExecStart=/usr/local/bin/k3s server --datastore-endpoint=postgres://" + address + "/k3s", nil
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
err = orch.Startup(context.Background(), cluster.StartupOptions{Reason: "restore-not-applicable"})
if err == nil || !strings.Contains(err.Error(), "after automatic etcd restore") {
t.Fatalf("expected api failure after not-applicable restore path, got %v", err)
}
})
t.Run("startup-required-node-labels-error", func(t *testing.T) {
cfg := lifecycleFastConfig(t)
cfg.Startup.RequiredNodeLabels = map[string]map[string]string{
"titan-db": {"topology.kubernetes.io/zone": "lab-a"},
}
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, "label node titan-db --overwrite") {
return "", errors.New("label denied")
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
err := orch.Startup(context.Background(), cluster.StartupOptions{Reason: "label-fail"})
if err == nil || !strings.Contains(err.Error(), "ensure required node labels") {
t.Fatalf("expected required-node-label failure, got %v", err)
}
})
t.Run("startup-worker-discovery-error", func(t *testing.T) {
cfg := lifecycleFastConfig(t)
cfg.Workers = nil
cfg.SSHManagedNodes = []string{"titan-db"}
cfg.SSHNodeHosts = map[string]string{
"titan-db": "titan-db",
}
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
if name == "kubectl" && len(args) >= 2 && args[0] == "get" && args[1] == "nodes" {
return "", errors.New("nodes denied")
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
err := orch.Startup(context.Background(), cluster.StartupOptions{Reason: "worker-discovery-fail"})
if err == nil || !strings.Contains(err.Error(), "discover workers") {
t.Fatalf("expected worker-discovery failure, got %v", err)
}
})
t.Run("startup-storage-readiness-error", func(t *testing.T) {
cfg := lifecycleFastConfig(t)
cfg.Startup.RequireStorageReady = true
cfg.Startup.StorageReadyWaitSeconds = 1
cfg.Startup.StorageReadyPollSeconds = 1
cfg.Startup.StorageMinReadyNodes = 1
cfg.Startup.StorageCriticalPVCs = []string{"bad-entry"}
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, "nodes.longhorn.io") {
return "a:True:True\n", nil
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
err := orch.Startup(context.Background(), cluster.StartupOptions{Reason: "storage-fail"})
if err == nil || !strings.Contains(err.Error(), "invalid storage_critical_pvcs entry") {
t.Fatalf("expected storage readiness failure, got %v", err)
}
})
t.Run("startup-critical-workload-scale-error", func(t *testing.T) {
cfg := lifecycleFastConfig(t)
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, "scale deployment source-controller") {
return "", errors.New("scale denied")
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
err := orch.Startup(context.Background(), cluster.StartupOptions{Reason: "critical-scale-fail"})
if err == nil || !strings.Contains(err.Error(), "scale flux-system/deployment/source-controller") {
t.Fatalf("expected critical-workload scale failure, got %v", err)
}
})
t.Run("startup-flux-resume-failure", func(t *testing.T) {
cfg := lifecycleFastConfig(t)
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" &&
strings.Contains(command, "-n flux-system get kustomizations.kustomize.toolkit.fluxcd.io") &&
strings.Contains(command, "jsonpath={range .items[*]}{.metadata.name}") {
return "", errors.New("kustomization list denied")
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
err := orch.Startup(context.Background(), cluster.StartupOptions{Reason: "resume-fail"})
if err == nil || !strings.Contains(err.Error(), "kustomization list denied") {
t.Fatalf("expected flux-resume failure, got %v", err)
}
})
}

View File

@ -0,0 +1,538 @@
package orchestrator
import (
"context"
"errors"
"io"
"log"
"os"
"path/filepath"
"strings"
"testing"
"time"
"scm.bstein.dev/bstein/ananke/internal/cluster"
"scm.bstein.dev/bstein/ananke/internal/execx"
"scm.bstein.dev/bstein/ananke/internal/state"
)
// TestHookLowFileCoverageBoost runs one orchestration or CLI step.
// Signature: TestHookLowFileCoverageBoost(t *testing.T).
// Why: raises the low-coverage orchestrator files through deterministic top-level
// tests that only use the exported hook surface.
func TestHookLowFileCoverageBoost(t *testing.T) {
t.Run("workload-convergence-helpers-and-gates", func(t *testing.T) {
desiredCases := []struct {
kind string
has bool
rep int32
ready int32
sched int32
num int32
wantD int32
wantR int32
wantB bool
}{
{kind: "Deployment", has: false, ready: 1, wantD: 1, wantR: 1, wantB: true},
{kind: "deployment", has: true, rep: 3, ready: 2, wantD: 3, wantR: 2, wantB: true},
{kind: "daemonset", sched: 4, num: 3, wantD: 4, wantR: 3, wantB: true},
{kind: "job", wantD: 0, wantR: 0, wantB: false},
}
for _, tc := range desiredCases {
gotD, gotR, gotB := cluster.TestHookDesiredReady(tc.kind, tc.has, tc.rep, tc.ready, tc.sched, tc.num)
if gotD != tc.wantD || gotR != tc.wantR || gotB != tc.wantB {
t.Fatalf("desiredReady(%q)=%d,%d,%v want %d,%d,%v", tc.kind, gotD, gotR, gotB, tc.wantD, tc.wantR, tc.wantB)
}
}
if !cluster.TestHookPodControllerOwned([]string{"ReplicaSet"}) {
t.Fatalf("expected ReplicaSet owner to be controller-owned")
}
if !cluster.TestHookPodControllerOwned([]string{"StatefulSet"}) {
t.Fatalf("expected StatefulSet owner to be controller-owned")
}
if !cluster.TestHookPodControllerOwned([]string{"DaemonSet"}) {
t.Fatalf("expected DaemonSet owner to be controller-owned")
}
if cluster.TestHookPodControllerOwned([]string{"Job"}) {
t.Fatalf("expected Job owner to be non controller-owned")
}
if got := cluster.TestHookStuckContainerReason([]string{"ImagePullBackOff"}, nil, []string{"ImagePullBackOff"}); got != "ImagePullBackOff" {
t.Fatalf("expected init-container stuck reason, got %q", got)
}
if got := cluster.TestHookStuckContainerReason(nil, []string{"CrashLoopBackOff"}, []string{"CrashLoopBackOff"}); got != "CrashLoopBackOff" {
t.Fatalf("expected container stuck reason, got %q", got)
}
if got := cluster.TestHookStuckContainerReason([]string{"ImagePullBackOff"}, []string{"CrashLoopBackOff"}, []string{"Missing"}); got != "" {
t.Fatalf("expected filtered stuck reason to be empty, got %q", got)
}
vaultCases := []struct {
name string
phase string
inject bool
startedAgo time.Duration
grace time.Duration
want string
}{
{name: "phase-running", phase: "Running", inject: true, startedAgo: 10 * time.Minute, grace: time.Minute, want: ""},
{name: "inject-false", phase: "Pending", inject: false, startedAgo: 10 * time.Minute, grace: time.Minute, want: ""},
{name: "within-grace", phase: "Pending", inject: true, startedAgo: 30 * time.Second, grace: time.Minute, want: ""},
{name: "stuck", phase: "Pending", inject: true, startedAgo: 10 * time.Minute, grace: time.Minute, want: "VaultInitStuck"},
}
for _, tc := range vaultCases {
got := cluster.TestHookStuckVaultInitReason(tc.phase, tc.inject, tc.startedAgo, tc.grace)
if got != tc.want {
t.Fatalf("%s: stuckVaultInitReason=%q want %q", tc.name, got, tc.want)
}
}
cfg := lifecycleConfig(t)
cfg.Startup.WorkloadConvergenceWaitSeconds = 1
cfg.Startup.WorkloadConvergencePollSeconds = 1
cfg.Startup.StuckPodGraceSeconds = 1
cfg.Startup.IgnoreWorkloadNamespaces = []string{"ignored-ns"}
cfg.Startup.IgnoreUnavailableNodes = []string{"titan-22"}
cfg.Startup.IgnoreWorkloads = []string{"monitoring/deployment/ignore-me"}
cfg.Startup.IgnoreFluxKustomizations = []string{"ignored/flux-system"}
readyRun := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "get deploy,statefulset,daemonset -A -o json"):
return `{"items":[
{"kind":"Deployment","metadata":{"namespace":"monitoring","name":"grafana"},"spec":{"replicas":1,"template":{"spec":{"nodeSelector":{"kubernetes.io/hostname":"titan-23"}}}},"status":{"readyReplicas":1}},
{"kind":"DaemonSet","metadata":{"namespace":"monitoring","name":"node-exporter"},"spec":{"template":{"spec":{"nodeName":"titan-23"}}},"status":{"desiredNumberScheduled":2,"numberReady":1}},
{"kind":"Deployment","metadata":{"namespace":"ignored-ns","name":"skip"},"spec":{"replicas":1},"status":{"readyReplicas":0}},
{"kind":"Deployment","metadata":{"namespace":"flux-system","name":"ignored"},"spec":{"replicas":1},"status":{"readyReplicas":0}},
{"kind":"Deployment","metadata":{"namespace":"monitoring","name":"ignore-me"},"spec":{"replicas":1},"status":{"readyReplicas":0}}
]}`, nil
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orchReady, _ := newHookOrchestrator(t, cfg, readyRun, readyRun)
ready, detail, err := orchReady.TestHookWorkloadConvergenceReady(context.Background())
if err != nil || !ready || !strings.Contains(detail, "controllers ready=") {
t.Fatalf("expected workload convergence ready path, ready=%v detail=%q err=%v", ready, detail, err)
}
pendingRun := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "get deploy,statefulset,daemonset -A -o json"):
return `{"items":[{"kind":"Deployment","metadata":{"namespace":"monitoring","name":"grafana"},"spec":{"replicas":1},"status":{"readyReplicas":0}}]}`, nil
case name == "kubectl" && strings.Contains(command, "get pods -A -o json"):
return `{"items":[]}`, nil
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orchPending, _ := newHookOrchestrator(t, cfg, pendingRun, pendingRun)
if err := orchPending.TestHookWaitForWorkloadConvergence(context.Background()); err == nil || !strings.Contains(err.Error(), "workload convergence not satisfied") {
t.Fatalf("expected workload convergence timeout, got %v", err)
}
podRun := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "get pods -A -o json"):
return `{"items":[
{"metadata":{"namespace":"","name":"missing-ns"}},
{"metadata":{"namespace":"kube-system","name":"ignored","creationTimestamp":"2020-01-01T00:00:00Z","ownerReferences":[{"kind":"ReplicaSet","name":"ignored"}]},"spec":{"nodeName":"titan-23","containers":[{"name":"c"}]},"status":{"containerStatuses":[{"state":{"waiting":{"reason":"CrashLoopBackOff"}}}]}},
{"metadata":{"namespace":"monitoring","name":"ignore-me","creationTimestamp":"2020-01-01T00:00:00Z","ownerReferences":[{"kind":"ReplicaSet","name":"ignore-me"}]},"spec":{"nodeName":"titan-23","containers":[{"name":"c"}]},"status":{"containerStatuses":[{"state":{"waiting":{"reason":"CrashLoopBackOff"}}}]}},
{"metadata":{"namespace":"monitoring","name":"node-ignored","creationTimestamp":"2020-01-01T00:00:00Z","ownerReferences":[{"kind":"ReplicaSet","name":"node-ignored"}]},"spec":{"nodeName":"titan-22","containers":[{"name":"c"}]},"status":{"containerStatuses":[{"state":{"waiting":{"reason":"CrashLoopBackOff"}}}]}},
{"metadata":{"namespace":"monitoring","name":"unowned","creationTimestamp":"2020-01-01T00:00:00Z"},"spec":{"nodeName":"titan-23","containers":[{"name":"c"}]},"status":{"containerStatuses":[{"state":{"waiting":{"reason":"CrashLoopBackOff"}}}]}},
{"metadata":{"namespace":"monitoring","name":"recent","creationTimestamp":"` + time.Now().UTC().Format(time.RFC3339) + `","ownerReferences":[{"kind":"ReplicaSet","name":"recent"}]},"spec":{"nodeName":"titan-23","containers":[{"name":"c"}]},"status":{"containerStatuses":[{"state":{"waiting":{"reason":"CrashLoopBackOff"}}}]}},
{"metadata":{"namespace":"monitoring","name":"grafana-0","creationTimestamp":"2020-01-01T00:00:00Z","ownerReferences":[{"kind":"ReplicaSet","name":"grafana"}]},"spec":{"nodeName":"titan-23","containers":[{"name":"c"}]},"status":{"containerStatuses":[{"state":{"waiting":{"reason":"CrashLoopBackOff"}}}]}},
{"metadata":{"namespace":"vault","name":"vault-0","creationTimestamp":"2020-01-01T00:00:00Z","ownerReferences":[{"kind":"StatefulSet","name":"vault"}],"annotations":{"vault.hashicorp.com/agent-inject":"true"}},"spec":{"nodeName":"titan-23","containers":[{"name":"vault"}]},"status":{"phase":"Pending","initContainerStatuses":[{"name":"vault-agent-init","state":{"running":{"startedAt":"2020-01-01T00:00:00Z"}}}]}}
]}`, nil
case name == "kubectl" && strings.Contains(command, "delete pod grafana-0"):
return "", errors.New("boom")
case name == "kubectl" && strings.Contains(command, "delete pod vault-0"):
return "", nil
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orchPods, _ := newHookOrchestrator(t, cfg, podRun, podRun)
if err := orchPods.TestHookRecycleStuckControllerPods(context.Background()); err != nil {
t.Fatalf("expected recycleStuckControllerPods best-effort success, got %v", err)
}
})
t.Run("scaling-helpers-and-recovery", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.ExcludedNamespaces = []string{"flux-system", "vault"}
explicit := []string{"worker-a", "worker-b"}
cfg.Workers = append([]string{}, explicit...)
orchWorkers, _ := newHookOrchestrator(t, cfg, nil, nil)
gotWorkers, err := orchWorkers.TestHookEffectiveWorkers(context.Background())
if err != nil || len(gotWorkers) != len(explicit) || gotWorkers[0] != explicit[0] || gotWorkers[1] != explicit[1] {
t.Fatalf("expected explicit workers copy, got %v err=%v", gotWorkers, err)
}
gotWorkers[0] = "mutated"
if cfg.Workers[0] != explicit[0] {
t.Fatalf("expected effectiveWorkers to return a copy")
}
cfg.Workers = nil
cfg.SSHManagedNodes = nil
cfg.SSHNodeHosts = map[string]string{
"worker-c": "worker-c",
"worker-b": "worker-b",
"worker-a": "worker-a",
"titan-db": "titan-db",
}
discoverErrRun := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, "get nodes -o custom-columns=") {
return "", errors.New("nodes unavailable")
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orchFallback, _ := newHookOrchestrator(t, cfg, discoverErrRun, discoverErrRun)
fallbackWorkers, err := orchFallback.TestHookEffectiveWorkers(context.Background())
if err != nil || strings.Join(fallbackWorkers, ",") != "worker-a,worker-b,worker-c" {
t.Fatalf("expected fallback workers, got %v err=%v", fallbackWorkers, err)
}
discoverRun := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "get nodes -o custom-columns="):
return "titan-db <none> <none>\nworker-b <none> <none>\nworker-c control-plane <none>\nbadline\n", nil
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orchDiscover, _ := newHookOrchestrator(t, lifecycleConfig(t), discoverRun, discoverRun)
discovered, err := orchDiscover.TestHookDiscoverWorkers(context.Background())
if err != nil || strings.Join(discovered, ",") != "titan-db,worker-b" {
t.Fatalf("expected discovered workers, got %v err=%v", discovered, err)
}
cfgPatch := lifecycleConfig(t)
patchRun := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "-n flux-system get kustomizations.kustomize.toolkit.fluxcd.io -o jsonpath="):
return "services\nignored\n", nil
case name == "kubectl" && strings.Contains(command, "get helmreleases.helm.toolkit.fluxcd.io -A -o jsonpath="):
return "monitoring/grafana\nmonitoring/failing\n", nil
case name == "kubectl" && strings.Contains(command, "-n flux-system patch kustomization services"):
return "", errors.New("patch failed")
case name == "kubectl" && strings.Contains(command, "-n flux-system patch kustomization ignored"):
return "", nil
case name == "kubectl" && strings.Contains(command, "-n monitoring patch helmrelease grafana"):
return "", nil
case name == "kubectl" && strings.Contains(command, "-n monitoring patch helmrelease failing"):
return "", errors.New("patch failed")
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orchPatch, _ := newHookOrchestrator(t, cfgPatch, patchRun, patchRun)
if err := orchPatch.TestHookPatchFluxSuspendAll(context.Background(), true); err != nil {
t.Fatalf("expected patchFluxSuspendAll best-effort success, got %v", err)
}
listRun := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "get deployment -A -o jsonpath="):
return "monitoring\tgrafana\t2\nflux-system\tsource-controller\t1\nmonitoring\tbad\tbogus\nmonitoring\tempty\t0\n", nil
case name == "kubectl" && strings.Contains(command, "get statefulset -A -o jsonpath="):
return "monitoring\tvictoria-metrics-single-server\t3\nvault\tvault\t1\n", nil
case name == "kubectl" && strings.Contains(command, "scale deployment grafana --replicas=0"):
return "", errors.New("scale failed")
case name == "kubectl" && strings.Contains(command, "scale deployment grafana --replicas=1"):
return "", nil
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orchList, _ := newHookOrchestrator(t, cfgPatch, listRun, listRun)
entries, err := orchList.TestHookListScalableWorkloads(context.Background())
if err != nil || len(entries) != 4 {
t.Fatalf("expected four scalable workloads, got %v err=%v", entries, err)
}
if err := orchList.TestHookScaleWorkloads(context.Background(), entries[:1], 0, 0); err != nil {
t.Fatalf("expected single-entry scaleWorkloads success, got %v", err)
}
if err := orchList.TestHookScaleWorkloads(context.Background(), entries[1:], 0, 0); err == nil || !strings.Contains(err.Error(), "scaling had") {
t.Fatalf("expected scaleWorkloads error aggregation, got %v", err)
}
if err := orchList.TestHookScaleWorkloads(context.Background(), nil, 0, 1); err != nil {
t.Fatalf("expected empty scaleWorkloads success, got %v", err)
}
orchSnapshotWrite, _ := newHookOrchestrator(t, lifecycleConfig(t), nil, nil)
if err := orchSnapshotWrite.TestHookWriteScaledWorkloadSnapshot(nil); err != nil {
t.Fatalf("expected snapshot write with empty entries, got %v", err)
}
orchSnapshotRead, _ := newHookOrchestrator(t, lifecycleConfig(t), nil, nil)
if snapshot, err := orchSnapshotRead.TestHookReadScaledWorkloadSnapshot(); err != nil || snapshot != nil {
t.Fatalf("expected missing snapshot to read as nil,nil, got snapshot=%v err=%v", snapshot, err)
}
manualCfg := lifecycleConfig(t)
manualCfg.State.Dir = filepath.Join(t.TempDir(), "state")
manualOrch := cluster.New(manualCfg, &execx.Runner{DryRun: true}, state.New(manualCfg.State.RunHistoryPath), log.New(io.Discard, "", 0))
if err := manualOrch.TestHookWriteScaledWorkloadSnapshot(nil); err != nil {
t.Fatalf("expected dry-run snapshot write success, got %v", err)
}
if snapshot, err := manualOrch.TestHookReadScaledWorkloadSnapshot(); err != nil || snapshot != nil {
t.Fatalf("expected dry-run snapshot read to return nil,nil, got snapshot=%v err=%v", snapshot, err)
}
restorePath := filepath.Join(t.TempDir(), "state", "scaled-workloads.json")
if err := os.MkdirAll(filepath.Dir(restorePath), 0o755); err != nil {
t.Fatalf("mkdir restore path: %v", err)
}
valid := `{"generated_at":"2026-01-01T00:00:00Z","entries":[{"namespace":"monitoring","kind":"deployment","name":"grafana","replicas":1}]}`
if err := os.WriteFile(restorePath, []byte(valid), 0o600); err != nil {
t.Fatalf("write restore snapshot: %v", err)
}
restoreCfg := lifecycleConfig(t)
restoreCfg.State.Dir = filepath.Dir(restorePath)
restoreOrch, _ := newHookOrchestrator(t, restoreCfg, listRun, listRun)
if err := restoreOrch.TestHookRestoreScaledApps(context.Background()); err != nil {
t.Fatalf("expected restoreScaledApps success, got %v", err)
}
if _, err := os.Stat(restorePath); !os.IsNotExist(err) {
t.Fatalf("expected restore snapshot to be removed, stat err=%v", err)
}
pendingCfg := lifecycleConfig(t)
pendingCfg.Startup.WorkloadConvergenceWaitSeconds = 1
pendingCfg.Startup.WorkloadConvergencePollSeconds = 1
pendingRun := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "get deploy,statefulset,daemonset -A -o json"):
return `{"items":[{"kind":"Deployment","metadata":{"namespace":"monitoring","name":"grafana"},"spec":{"replicas":1},"status":{"readyReplicas":0}}]}`, nil
case name == "kubectl" && strings.Contains(command, "get pods -A -o json"):
return `{"items":[]}`, nil
case name == "kubectl" && strings.Contains(command, "scale deployment grafana --replicas=0"):
return "", errors.New("scale failed")
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orchPendingScale, _ := newHookOrchestrator(t, pendingCfg, pendingRun, pendingRun)
if err := orchPendingScale.TestHookScaleDownApps(context.Background()); err == nil {
t.Fatalf("expected scaleDownApps to fail when workloads stay pending")
}
})
t.Run("critical-vault-and-flux-health-helpers", func(t *testing.T) {
fluxCfg := lifecycleConfig(t)
fluxCfg.Startup.FluxHealthWaitSeconds = 1
fluxCfg.Startup.FluxHealthPollSeconds = 1
fluxCfg.Startup.IgnoreFluxKustomizations = []string{"flux-system/ignored"}
fluxRun := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "get kustomizations.kustomize.toolkit.fluxcd.io -A -o json"):
return `{"items":[
{"metadata":{"namespace":"flux-system","name":"services"},"spec":{"suspend":false,"timeout":"40s"},"status":{"conditions":[{"type":"Ready","status":"False","message":"syncing"}]}},
{"metadata":{"namespace":"flux-system","name":"no-condition"},"spec":{"suspend":false,"timeout":"10s"},"status":{"conditions":[{"type":"Reconciling","status":"True","message":"still"}]}},
{"metadata":{"namespace":"flux-system","name":"ignored"},"spec":{"suspend":false,"timeout":"5s"},"status":{"conditions":[{"type":"Ready","status":"False","message":"ignored"}]}},
{"metadata":{"namespace":"flux-system","name":"suspended"},"spec":{"suspend":true,"timeout":"2m"},"status":{"conditions":[{"type":"Ready","status":"False","message":"skip"}]}},
{"metadata":{"namespace":"monitoring","name":"grafana"},"spec":{"suspend":false,"timeout":"5m"},"status":{"conditions":[{"type":"Ready","status":"True","message":"ok"}]}}
]}`, nil
case name == "kubectl" && strings.Contains(command, "get jobs -A -o json"):
return `{"items":[
{"metadata":{"namespace":"flux-system","name":"job-a","labels":{"kustomize.toolkit.fluxcd.io/name":"services"}},"status":{"failed":1,"conditions":[{"type":"Failed","status":"True"}]}},
{"metadata":{"namespace":"flux-system","name":"job-b","labels":{"kustomize.toolkit.fluxcd.io/name":"services"}},"status":{"failed":1,"conditions":[{"type":"Failed","status":"True"}]}},
{"metadata":{"namespace":"flux-system","name":"cronjob-owned","ownerReferences":[{"kind":"CronJob","name":"cron"}]},"status":{"failed":1,"conditions":[{"type":"Failed","status":"True"}]}},
{"metadata":{"namespace":"flux-system","name":"succeeded"},"status":{"succeeded":1,"failed":1,"conditions":[{"type":"Failed","status":"True"}]}}
]}`, nil
case name == "kubectl" && strings.Contains(command, "delete job job-a"):
return "", nil
case name == "kubectl" && strings.Contains(command, "delete job job-b"):
return "", errors.New("boom")
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orchFlux, _ := newHookOrchestrator(t, fluxCfg, fluxRun, fluxRun)
wait, reason, err := orchFlux.TestHookAdaptiveFluxHealthWait(context.Background(), 30*time.Second)
if err != nil || wait <= 30*time.Second || !strings.Contains(reason, "max flux timeout") {
t.Fatalf("expected adaptive flux wait extension, wait=%s reason=%q err=%v", wait, reason, err)
}
noTimeoutCfg := lifecycleConfig(t)
noTimeoutRun := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "get kustomizations.kustomize.toolkit.fluxcd.io -A -o json"):
return `{"items":[{"metadata":{"namespace":"flux-system","name":"ready"},"spec":{"suspend":false},"status":{"conditions":[{"type":"Ready","status":"True","message":"ok"}]}}]}`, nil
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orchNoTimeout, _ := newHookOrchestrator(t, noTimeoutCfg, noTimeoutRun, noTimeoutRun)
if wait, reason, err := orchNoTimeout.TestHookAdaptiveFluxHealthWait(context.Background(), 0); err != nil || wait < 15*time.Minute || !strings.Contains(reason, "no explicit kustomization timeouts found") {
t.Fatalf("expected adaptive wait fallback, wait=%s reason=%q err=%v", wait, reason, err)
}
if ready, detail, err := orchFlux.TestHookFluxHealthReady(context.Background()); err != nil || ready || !strings.Contains(detail, "not ready") {
t.Fatalf("expected flux health not-ready result, ready=%v detail=%q err=%v", ready, detail, err)
}
if ready, detail, err := orchNoTimeout.TestHookFluxHealthReady(context.Background()); err != nil || !ready || !strings.Contains(detail, "all kustomizations ready=") {
t.Fatalf("expected flux health ready result, ready=%v detail=%q err=%v", ready, detail, err)
}
if !cluster.TestHookLooksLikeImmutableJobError("Job update failed: field is immutable") {
t.Fatalf("expected immutable-job detector true")
}
if cluster.TestHookLooksLikeImmutableJobError("") {
t.Fatalf("expected empty immutable-job detail to be false")
}
if !cluster.TestHookJobLooksFluxManaged("flux-system", "job-a", map[string]string{"kustomize.toolkit.fluxcd.io/name": "services"}, nil) {
t.Fatalf("expected label-based flux-managed job")
}
if cluster.TestHookJobLooksFluxManaged("flux-system", "cronjob-owned", nil, []string{"CronJob"}) {
t.Fatalf("expected CronJob-owned job to be non flux-managed")
}
if !cluster.TestHookJobFailed(1, 0, []string{"Failed"}, []string{"True"}) {
t.Fatalf("expected failed job detector to be true")
}
if cluster.TestHookJobFailed(0, 1, []string{"Complete"}, []string{"True"}) {
t.Fatalf("expected succeeded job to be false")
}
if healed, err := orchFlux.TestHookHealImmutableFluxJobs(context.Background()); err != nil || !healed {
t.Fatalf("expected immutable job heal success, healed=%v err=%v", healed, err)
}
critCfg := lifecycleConfig(t)
critCfg.Startup.VaultUnsealKeyFile = filepath.Join(t.TempDir(), "vault", "unseal.key")
critCfg.Startup.VaultUnsealBreakglassCommand = "echo breakglass-key"
critCfg.Startup.VaultUnsealBreakglassTimeout = 1
critCfg.Startup.WorkloadConvergenceWaitSeconds = 1
critCfg.Startup.WorkloadConvergencePollSeconds = 1
critCfg.Startup.StuckPodGraceSeconds = 1
critCfg.Startup.IgnoreWorkloadNamespaces = []string{"kube-system"}
critCfg.Startup.IgnoreUnavailableNodes = []string{"titan-22"}
critCfg.Startup.IgnoreWorkloads = []string{"monitoring/deployment/ignore-me"}
critRun := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "-n flux-system get deployment source-controller -o jsonpath={.status.readyReplicas}"):
return "", errors.New("boom")
case name == "kubectl" && strings.Contains(command, "-n flux-system scale deployment source-controller --replicas=1"):
return "", errors.New("boom")
case name == "kubectl" && strings.Contains(command, "-n vault get pods -o custom-columns="):
return "vault-0 Pending StatefulSet vault\nvault-1 Unknown StatefulSet vault\nvault-2 Running StatefulSet vault\nvault-other Failed Deployment vault\nbadline\n", nil
case name == "kubectl" && strings.Contains(command, "delete pod vault-0"):
return "", nil
case name == "kubectl" && strings.Contains(command, "delete pod vault-1"):
return "", errors.New("boom")
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orchCrit, _ := newHookOrchestrator(t, critCfg, critRun, critRun)
if missing, err := orchCrit.TestHookMissingCriticalStartupWorkloads(context.Background()); err == nil || len(missing) != 0 {
t.Fatalf("expected missingCriticalStartupWorkloads generic error, missing=%v err=%v", missing, err)
}
if err := orchCrit.TestHookEnsureCriticalStartupWorkloads(context.Background()); err == nil || !strings.Contains(err.Error(), "scale") {
t.Fatalf("expected ensureCriticalStartupWorkloads scale error, got %v", err)
}
if err := orchCrit.TestHookCleanupStaleCriticalWorkloadPods(context.Background(), "vault", "statefulset", "vault"); err == nil {
t.Fatalf("expected stale critical workload cleanup error branch")
}
vaultCfg := lifecycleConfig(t)
vaultCfg.Startup.VaultUnsealKeyFile = filepath.Join(t.TempDir(), "vault", "unseal.key")
vaultCfg.Startup.VaultUnsealBreakglassCommand = "echo breakglass-key"
vaultCfg.Startup.VaultUnsealBreakglassTimeout = 1
ensureUnsealed := false
ensureRun := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "-n vault get pod vault-0 -o jsonpath={.status.phase}"):
return "Running", nil
case name == "kubectl" && strings.Contains(command, "vault status -format=json"):
if ensureUnsealed {
return `{"sealed":false}`, nil
}
return `{"sealed":true}`, nil
case name == "kubectl" && strings.Contains(command, "get secret vault-init"):
return "dmF1bHQta2V5", nil
case name == "kubectl" && strings.Contains(command, "vault operator unseal"):
ensureUnsealed = true
return "", nil
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orchVaultEnsure, _ := newHookOrchestrator(t, vaultCfg, ensureRun, ensureRun)
if err := orchVaultEnsure.TestHookEnsureVaultUnsealed(context.Background()); err != nil {
t.Fatalf("expected vault auto-unseal success path, got %v", err)
}
if sealed, err := orchVaultEnsure.TestHookVaultSealed(context.Background()); err != nil || sealed {
t.Fatalf("expected vault sealed helper false after unseal, sealed=%v err=%v", sealed, err)
}
waitReady := false
waitUnsealed := false
waitRun := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "-n vault get pod vault-0 -o jsonpath={.status.phase}"):
return "Running", nil
case name == "kubectl" && strings.Contains(command, "vault status -format=json"):
if waitUnsealed {
return `{"sealed":false}`, nil
}
return `{"sealed":true}`, nil
case name == "kubectl" && strings.Contains(command, "get secret vault-init"):
return "dmF1bHQta2V5", nil
case name == "kubectl" && strings.Contains(command, "vault operator unseal"):
waitUnsealed = true
waitReady = true
return "", nil
case name == "kubectl" && strings.Contains(command, "get statefulset vault -o jsonpath={.status.readyReplicas}"):
if waitReady {
return "1", nil
}
return "0", nil
case name == "kubectl" && strings.Contains(command, "get deployment -A -o jsonpath="):
return "monitoring\tgrafana\t1\n", nil
case name == "kubectl" && strings.Contains(command, "get statefulset -A -o jsonpath="):
return "monitoring\tvictoria-metrics-single-server\t1\n", nil
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orchVaultWait, _ := newHookOrchestrator(t, vaultCfg, waitRun, waitRun)
if err := orchVaultWait.TestHookWaitVaultReady(context.Background(), "vault", "statefulset", "vault"); err != nil {
t.Fatalf("expected vault wait success path, got %v", err)
}
if err := orchVaultWait.TestHookWaitWorkloadReady(context.Background(), "monitoring", "deployment", "grafana"); err != nil {
t.Fatalf("expected generic workload wait success, got %v", err)
}
if err := orchVaultWait.TestHookWriteVaultUnsealKeyFile("cached-key"); err != nil {
t.Fatalf("expected vault key file write success, got %v", err)
}
if got, err := orchVaultWait.TestHookReadVaultUnsealKeyFile(); err != nil || got != "cached-key" {
t.Fatalf("expected vault key file read success, got %q err=%v", got, err)
}
blockedDir := t.TempDir()
blockedFile := filepath.Join(blockedDir, "blocked")
if err := os.WriteFile(blockedFile, []byte("x"), 0o600); err != nil {
t.Fatalf("write blocked file: %v", err)
}
blockedCfg := lifecycleConfig(t)
blockedCfg.Startup.VaultUnsealKeyFile = filepath.Join(blockedFile, "vault.key")
blockedOrch, _ := newHookOrchestrator(t, blockedCfg, nil, nil)
if err := blockedOrch.TestHookWriteVaultUnsealKeyFile("x"); err == nil {
t.Fatalf("expected vault key dir error")
}
})
}

View File

@ -0,0 +1,178 @@
package orchestrator
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"scm.bstein.dev/bstein/ananke/internal/cluster"
"scm.bstein.dev/bstein/ananke/internal/config"
"scm.bstein.dev/bstein/ananke/internal/state"
)
// TestHookCoordinationLowFunctionMatrix runs one orchestration or CLI step.
// Signature: TestHookCoordinationLowFunctionMatrix(t *testing.T).
// Why: closes remaining coordination helper branches that are not naturally hit by startup drill tests.
func TestHookCoordinationLowFunctionMatrix(t *testing.T) {
old := state.Intent{UpdatedAt: time.Now().Add(-2 * time.Hour)}
if got := cluster.TestHookIntentAge(state.Intent{}); got != 0 {
t.Fatalf("expected zero age for zero timestamp, got %s", got)
}
if !cluster.TestHookIntentFresh(state.Intent{}, time.Second) {
t.Fatalf("expected zero timestamp intent to be treated as fresh")
}
if cluster.TestHookIntentFresh(old, 5*time.Minute) {
t.Fatalf("expected stale intent to be non-fresh")
}
cfg := lifecycleConfig(t)
orch, _ := newHookOrchestrator(t, cfg, nil, nil)
if _, err := orch.TestHookReadRemotePeerStatus(context.Background(), "not-managed"); err == nil {
t.Fatalf("expected unmanaged peer status error")
}
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "ssh" && strings.Contains(command, "systemctl cat k3s") {
return "", errors.New("permission denied")
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orchErr, _ := newHookOrchestrator(t, cfg, run, run)
if _, err := orchErr.TestHookControlPlaneUsesExternalDatastore(context.Background(), "titan-db"); err == nil {
t.Fatalf("expected datastore mode inspection error")
}
}
// TestHookFluxHealthLowFunctionMatrix runs one orchestration or CLI step.
// Signature: TestHookFluxHealthLowFunctionMatrix(t *testing.T).
// Why: covers immutable-job and flux parser/helper branches to lift fluxhealth file coverage.
func TestHookFluxHealthLowFunctionMatrix(t *testing.T) {
if !cluster.TestHookLooksLikeImmutableJobError("Job update failed: field is immutable") {
t.Fatalf("expected immutable job matcher true")
}
if cluster.TestHookLooksLikeImmutableJobError("random failure") {
t.Fatalf("expected immutable job matcher false")
}
if !cluster.TestHookJobLooksFluxManaged("flux-system", "job-a", map[string]string{"kustomize.toolkit.fluxcd.io/name": "services"}, nil) {
t.Fatalf("expected flux-managed job by label")
}
if cluster.TestHookJobLooksFluxManaged("flux-system", "job-b", nil, []string{"CronJob"}) {
t.Fatalf("expected cronjob-owned job to be ignored")
}
if cluster.TestHookJobFailed(1, 1, []string{"Failed"}, []string{"True"}) {
t.Fatalf("expected succeeded job to not count as failed")
}
if cluster.TestHookJobFailed(1, 0, []string{"Complete"}, []string{"True"}) {
t.Fatalf("expected failed job without Failed=True condition to be false")
}
cfg := lifecycleConfig(t)
cfg.Startup.FluxHealthWaitSeconds = 1
cfg.Startup.FluxHealthPollSeconds = 1
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "get kustomizations.kustomize.toolkit.fluxcd.io -A -o json"):
return "{bad json", nil
case name == "kubectl" && strings.Contains(command, "get jobs -A -o json"):
return "{bad json", nil
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
if _, _, err := orch.TestHookFluxHealthReady(context.Background()); err == nil {
t.Fatalf("expected fluxHealthReady decode error")
}
if _, err := orch.TestHookHealImmutableFluxJobs(context.Background()); err == nil {
t.Fatalf("expected healImmutableFluxJobs decode error")
}
cancelCtx, cancel := context.WithCancel(context.Background())
cancel()
if err := orch.TestHookWaitForFluxHealth(cancelCtx); !errors.Is(err, context.Canceled) {
t.Fatalf("expected waitForFluxHealth cancel branch, got %v", err)
}
}
// TestHookServiceStabilityLowFunctionMatrix runs one orchestration or CLI step.
// Signature: TestHookServiceStabilityLowFunctionMatrix(t *testing.T).
// Why: drives startup-stability failure/success branches across all gated checks.
func TestHookServiceStabilityLowFunctionMatrix(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.RequireFluxHealth = true
cfg.Startup.RequireWorkloadConvergence = true
cfg.Startup.RequireServiceChecklist = true
cfg.Startup.RequireIngressChecklist = false
svc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
}))
defer svc.Close()
cfg.Startup.ServiceChecklist = []config.ServiceChecklistCheck{
{Name: "svc", URL: svc.URL, AcceptedStatuses: []int{200}, TimeoutSeconds: 2},
}
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "get kustomizations.kustomize.toolkit.fluxcd.io -A -o json"):
return `{"items":[{"metadata":{"namespace":"flux-system","name":"services"},"spec":{"suspend":false},"status":{"conditions":[{"type":"Ready","status":"True","message":"ok"}]}}]}`, nil
case name == "kubectl" && strings.Contains(command, "get deploy,statefulset,daemonset -A -o json"):
return `{"items":[]}`, nil
case name == "kubectl" && strings.Contains(command, "get pods -A -o json"):
return `{"items":[]}`, nil
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
if err := orch.TestHookStartupStabilityHealthy(context.Background()); err != nil {
t.Fatalf("expected stability healthy success, got %v", err)
}
runFluxDown := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, "get kustomizations.kustomize.toolkit.fluxcd.io -A -o json") {
return `{"items":[{"metadata":{"namespace":"flux-system","name":"services"},"spec":{"suspend":false},"status":{"conditions":[{"type":"Ready","status":"False","message":"syncing"}]}}]}`, nil
}
return run(ctx, timeout, name, args...)
}
orchFluxDown, _ := newHookOrchestrator(t, cfg, runFluxDown, runFluxDown)
if err := orchFluxDown.TestHookStartupStabilityHealthy(context.Background()); err == nil {
t.Fatalf("expected flux-not-ready stability failure")
}
}
// TestHookStorageTimesyncLowFunctionMatrix runs one orchestration or CLI step.
// Signature: TestHookStorageTimesyncLowFunctionMatrix(t *testing.T).
// Why: targets low parser and storage edge branches to increase readiness helper coverage.
func TestHookStorageTimesyncLowFunctionMatrix(t *testing.T) {
if got := cluster.TestHookParseDatastoreEndpoint("ExecStart=/usr/local/bin/k3s server --datastore-endpoint='postgres://db:5432/k3s' \\"); got != "postgres://db:5432/k3s" {
t.Fatalf("unexpected parsed endpoint from single-quoted arg: %q", got)
}
if got := cluster.TestHookParseDatastoreEndpoint("x --datastore-endpoint = \"postgres://db:5432/k3s\" \\"); got == "" {
t.Fatalf("expected non-empty parse result from spaced datastore flag")
}
cfg := lifecycleConfig(t)
cfg.Startup.StorageCriticalPVCs = []string{"invalid-entry"}
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, "nodes.longhorn.io") {
return "titan-23:True:True\ntitan-24:True:True\n", nil
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
if _, _, err := orch.TestHookStorageReady(context.Background()); err == nil {
t.Fatalf("expected invalid critical pvc entry error")
}
}

View File

@ -0,0 +1,363 @@
package orchestrator
import (
"context"
"errors"
"io"
"log"
"os"
"path/filepath"
"strings"
"testing"
"time"
"scm.bstein.dev/bstein/ananke/internal/cluster"
"scm.bstein.dev/bstein/ananke/internal/execx"
"scm.bstein.dev/bstein/ananke/internal/state"
)
// TestHookScalingDrainPostStartMatrix runs one orchestration or CLI step.
// Signature: TestHookScalingDrainPostStartMatrix(t *testing.T).
// Why: closes branch-heavy operational coverage gaps for scaling, drain, and
// post-start execution helpers used by startup/shutdown drills.
func TestHookScalingDrainPostStartMatrix(t *testing.T) {
t.Run("effective-workers-discovery-and-fallback", func(t *testing.T) {
cfgPinned := lifecycleConfig(t)
cfgPinned.Workers = []string{"titan-23"}
orchPinned, _ := newHookOrchestrator(t, cfgPinned, nil, nil)
workers, err := orchPinned.TestHookEffectiveWorkers(context.Background())
if err != nil || len(workers) != 1 || workers[0] != "titan-23" {
t.Fatalf("expected pinned worker list, workers=%v err=%v", workers, err)
}
cfgDiscover := lifecycleConfig(t)
cfgDiscover.Workers = nil
runDiscover := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, "get nodes -o custom-columns=") {
return "titan-db cp cp\ntitan-23 <none> <none>\n", nil
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orchDiscover, _ := newHookOrchestrator(t, cfgDiscover, runDiscover, runDiscover)
discovered, err := orchDiscover.TestHookDiscoverWorkers(context.Background())
if err != nil || len(discovered) != 1 || discovered[0] != "titan-23" {
t.Fatalf("expected discovered worker titan-23, discovered=%v err=%v", discovered, err)
}
cfgFallback := lifecycleConfig(t)
cfgFallback.Workers = nil
runFallback := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, "get nodes -o custom-columns=") {
return "", errors.New("api unavailable")
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orchFallback, _ := newHookOrchestrator(t, cfgFallback, runFallback, runFallback)
fallbackWorkers, err := orchFallback.TestHookEffectiveWorkers(context.Background())
if err != nil || len(fallbackWorkers) == 0 {
t.Fatalf("expected inventory fallback workers, workers=%v err=%v", fallbackWorkers, err)
}
cfgNoFallback := lifecycleConfig(t)
cfgNoFallback.Workers = nil
cfgNoFallback.ControlPlanes = []string{"titan-db"}
cfgNoFallback.SSHManagedNodes = []string{"titan-db"}
cfgNoFallback.SSHNodeHosts = map[string]string{"titan-db": "titan-db"}
orchNoFallback, _ := newHookOrchestrator(t, cfgNoFallback, runFallback, runFallback)
if _, err := orchNoFallback.TestHookEffectiveWorkers(context.Background()); err == nil {
t.Fatalf("expected discover error when no fallback workers exist")
}
})
t.Run("scaling-workloads-and-snapshots", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.ExcludedNamespaces = []string{"kube-system"}
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "-n flux-system get kustomizations.kustomize.toolkit.fluxcd.io -o jsonpath="):
return "services\n", nil
case name == "kubectl" && strings.Contains(command, "get helmreleases.helm.toolkit.fluxcd.io -A -o jsonpath="):
return "monitoring/grafana\nbroken-entry\n", nil
case name == "kubectl" && strings.Contains(command, "get deployment -A -o jsonpath="):
return "monitoring\tgrafana\t1\ndefault\tbad-deploy\t2\ndefault\tbad-replicas\tx\nkube-system\tskipped\t3\n", nil
case name == "kubectl" && strings.Contains(command, "get statefulset -A -o jsonpath="):
return "default\tmissing-set\t2\ndefault\tok-set\t1\n", nil
case name == "kubectl" && strings.Contains(command, " scale deployment bad-deploy "):
return "", errors.New("boom")
case name == "kubectl" && strings.Contains(command, " scale statefulset missing-set "):
return "", errors.New("Error from server (NotFound): statefulsets.apps \"missing-set\" not found")
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
if err := orch.TestHookPatchFluxSuspendAll(context.Background(), true); err != nil {
t.Fatalf("patch flux suspend expected success with partial warnings, got %v", err)
}
entries, err := orch.TestHookListScalableWorkloads(context.Background())
if err != nil {
t.Fatalf("list scalable workloads failed: %v", err)
}
if len(entries) < 3 {
t.Fatalf("expected at least 3 scalable entries, got %d", len(entries))
}
if err := orch.TestHookScaleWorkloads(context.Background(), entries, 0, 2); err == nil {
t.Fatalf("expected scaling aggregate error path")
}
validIdx := -1
for i := range entries {
line := strings.ToLower(strings.TrimSpace(entries[i].Namespace + "/" + entries[i].Kind + "/" + entries[i].Name))
if strings.Contains(line, "bad-deploy") || strings.Contains(line, "missing-set") {
continue
}
validIdx = i
break
}
if validIdx < 0 {
t.Fatalf("expected at least one non-failing entry for restore path")
}
okOnly := entries[validIdx : validIdx+1]
if err := orch.TestHookWriteScaledWorkloadSnapshot(okOnly); err != nil {
t.Fatalf("write scaled snapshot: %v", err)
}
if err := orch.TestHookRestoreScaledApps(context.Background()); err != nil {
t.Fatalf("restore scaled apps with valid snapshot: %v", err)
}
snapshotPath := filepath.Join(cfg.State.Dir, "scaled-workloads.json")
if _, err := os.Stat(snapshotPath); !os.IsNotExist(err) {
t.Fatalf("expected restore to remove scaled snapshot, stat err=%v", err)
}
if err := os.WriteFile(snapshotPath, []byte("{broken-json"), 0o644); err != nil {
t.Fatalf("seed broken snapshot: %v", err)
}
if _, err := orch.TestHookReadScaledWorkloadSnapshot(); err == nil {
t.Fatalf("expected decode error for broken snapshot")
}
cfgEmpty := lifecycleConfig(t)
cfgEmpty.ExcludedNamespaces = []string{}
runEmpty := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "get deployment -A -o jsonpath="):
return "", nil
case name == "kubectl" && strings.Contains(command, "get statefulset -A -o jsonpath="):
return "", nil
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orchEmpty, _ := newHookOrchestrator(t, cfgEmpty, runEmpty, runEmpty)
if err := orchEmpty.TestHookScaleDownApps(context.Background()); err != nil {
t.Fatalf("expected scale-down no-target path success, got %v", err)
}
})
t.Run("drain-and-ssh-fanout", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Shutdown.DrainParallelism = 1
cfg.Shutdown.SSHParallelism = 1
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, " drain titan-23 "):
return "", errors.New("evictions timed out")
case name == "kubectl" && strings.Contains(command, "spec.nodeName=titan-23"):
return "monitoring grafana-0 Running StatefulSet\nkube-system cni-abc Running DaemonSet\n", nil
case name == "kubectl" && strings.Contains(command, "spec.nodeName=badnode"):
return "", errors.New("forbidden")
case name == "kubectl" && strings.Contains(command, "spec.nodeName=clean-node"):
return "kube-system coredns-0 Succeeded Deployment\n", nil
case name == "kubectl" && strings.Contains(command, " uncordon titan-23"):
return "", errors.New("uncordon denied")
case name == "ssh" && strings.Contains(command, "sudo k3s etcd-snapshot ls"):
return "Name Size Created Location\npre 1M now file:///var/lib/rancher/k3s/server/db/snapshots/pre\n", nil
case name == "ssh" && strings.Contains(command, "sudo k3s etcd-snapshot save"):
return "saved", nil
case name == "ssh":
return "", errors.New("ssh failure")
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
if err := orch.TestHookDrainWorkers(context.Background(), nil); err != nil {
t.Fatalf("expected empty drain worker list success, got %v", err)
}
if err := orch.TestHookDrainWorkers(context.Background(), []string{"titan-23"}); err == nil || !strings.Contains(err.Error(), "details") {
t.Fatalf("expected drain diagnostics error detail, got %v", err)
}
if got := orch.TestHookDrainNodeDiagnostics(context.Background(), "badnode"); !strings.Contains(got, "diagnostics unavailable") {
t.Fatalf("expected diagnostics unavailable branch, got %q", got)
}
if got := orch.TestHookDrainNodeDiagnostics(context.Background(), "clean-node"); !strings.Contains(got, "no non-daemonset") {
t.Fatalf("expected no-blockers diagnostics branch, got %q", got)
}
if err := orch.TestHookUncordonWorkers(context.Background(), []string{"titan-23"}); err != nil {
t.Fatalf("uncordon should remain best-effort, got %v", err)
}
orch.TestHookRunSSHAcrossNodes(context.Background(), nil, "noop", "echo noop")
orch.TestHookRunSSHAcrossNodes(context.Background(), []string{"titan-23", "unmanaged-node"}, "noop", "echo noop")
orch.TestHookStopWorkers(context.Background(), []string{"titan-23"})
orch.TestHookStartWorkers(context.Background(), []string{"titan-23"})
orch.TestHookStopControlPlanes(context.Background(), []string{"titan-db"})
orch.TestHookStartControlPlanes(context.Background(), []string{"titan-db"})
if err := orch.TestHookTakeEtcdSnapshot(context.Background(), "unmanaged-node"); err == nil {
t.Fatalf("expected unmanaged etcd snapshot error")
}
if err := orch.TestHookTakeEtcdSnapshot(context.Background(), "titan-db"); err == nil {
t.Fatalf("expected runSudo snapshot failure via ssh failure dispatcher")
}
cfgLatest := lifecycleConfig(t)
runLatest := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "ssh" && strings.Contains(command, "sudo -n /usr/local/bin/k3s etcd-snapshot ls"):
return "", errors.New("no such file or directory")
case name == "ssh" && strings.Contains(command, "sudo -n /usr/bin/k3s etcd-snapshot ls"):
return "Name Size Created Location\npre 1M now file:///var/lib/rancher/k3s/server/db/snapshots/pre\n", nil
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orchLatest, _ := newHookOrchestrator(t, cfgLatest, runLatest, runLatest)
if _, err := orchLatest.TestHookLatestEtcdSnapshotPath(context.Background(), "unmanaged-node"); err == nil {
t.Fatalf("expected unmanaged latest snapshot error")
}
if snapshot, err := orchLatest.TestHookLatestEtcdSnapshotPath(context.Background(), "titan-db"); err != nil || snapshot == "" {
t.Fatalf("expected latest snapshot parse success, snapshot=%q err=%v", snapshot, err)
}
})
t.Run("poststart-probes-ssh-and-sensitive-run", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.PostStartProbes = []string{"https://metrics.bstein.dev/health"}
cfg.Startup.PostStartProbeWaitSeconds = 1
cfg.Startup.PostStartProbePollSeconds = 1
curlCode := "200"
sshCalls := 0
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "curl":
return curlCode, nil
case name == "kubectl" && strings.Contains(command, "-n flux-system get kustomizations.kustomize.toolkit.fluxcd.io -o jsonpath="):
return "services\n", nil
case name == "kubectl" && strings.Contains(command, "get helmreleases.helm.toolkit.fluxcd.io -A -o jsonpath="):
return "monitoring/grafana\n", nil
case name == "kubectl" && strings.Contains(command, "annotate "):
return "", errors.New("annotation failed")
case name == "ssh" && strings.Contains(command, "-J "):
sshCalls++
return "", errors.New("jump path failed")
case name == "ssh":
sshCalls++
if sshCalls == 1 {
return "WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!", errors.New("host key mismatch")
}
return "ok", nil
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
if err := orch.TestHookWaitForPostStartProbes(context.Background()); err != nil {
t.Fatalf("expected post-start probe success, got %v", err)
}
curlCode = "500"
err := orch.TestHookWaitForPostStartProbes(context.Background())
if err == nil || !strings.Contains(err.Error(), "post-start probes did not pass") {
t.Fatalf("expected post-start timeout branch, got %v", err)
}
cancelCtx, cancel := context.WithCancel(context.Background())
cancel()
if err := orch.TestHookWaitForPostStartProbes(cancelCtx); !errors.Is(err, context.Canceled) {
t.Fatalf("expected canceled post-start probe wait, got %v", err)
}
cfgNoProbe := lifecycleConfig(t)
orchNoProbe, _ := newHookOrchestrator(t, cfgNoProbe, run, run)
if ok, _ := orchNoProbe.TestHookPostStartProbesReady(context.Background()); !ok {
t.Fatalf("expected no-probe ready branch")
}
curlCode = "not-a-code"
if _, err := orch.TestHookHTTPProbe(context.Background(), "https://example.com"); err == nil {
t.Fatalf("expected parse error for non-numeric curl output")
}
cfgResumeErr := lifecycleConfig(t)
runResumeErr := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, "-n flux-system get kustomizations.kustomize.toolkit.fluxcd.io -o jsonpath=") {
return "", errors.New("list kustomizations failed")
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orchResumeErr, _ := newHookOrchestrator(t, cfgResumeErr, runResumeErr, runResumeErr)
if err := orchResumeErr.TestHookResumeFluxAndReconcile(context.Background()); err == nil {
t.Fatalf("expected resumeFluxAndReconcile failure when patch listing fails")
}
if err := orch.TestHookResumeFluxAndReconcile(context.Background()); err != nil {
t.Fatalf("expected resumeFluxAndReconcile success with annotation warnings, got %v", err)
}
cfgSSH := lifecycleConfig(t)
cfgSSH.SSHJumpHost = "titan-jh"
cfgSSH.SSHJumpUser = "atlas"
cfgSSH.SSHConfigFile = "/tmp/ananke-ssh-config"
cfgSSH.SSHIdentityFile = "/tmp/ananke-ssh-key"
orchSSH, _ := newHookOrchestrator(t, cfgSSH, run, run)
if out, err := orchSSH.TestHookSSHWithTimeout(context.Background(), "titan-db", "echo ok", 2*time.Second); err != nil || strings.TrimSpace(out) != "ok" {
t.Fatalf("expected ssh fallback/repair success, out=%q err=%v", out, err)
}
if !orchSSH.TestHookSSHManaged("titan-db") {
t.Fatalf("expected managed node check to be true")
}
if orchSSH.TestHookResolveSSHConfigFile() != "/tmp/ananke-ssh-config" {
t.Fatalf("expected explicit ssh config path")
}
if orchSSH.TestHookResolveSSHIdentityFile() != "/tmp/ananke-ssh-key" {
t.Fatalf("expected explicit ssh identity path")
}
cfgStrict := lifecycleConfig(t)
cfgStrict.SSHManagedNodes = []string{"titan-db"}
orchStrict, _ := newHookOrchestrator(t, cfgStrict, run, run)
if orchStrict.TestHookSSHManaged("titan-23") {
t.Fatalf("expected unmanaged-node check to be false")
}
cfgDefaultRun := lifecycleConfig(t)
if err := os.MkdirAll(cfgDefaultRun.State.Dir, 0o755); err != nil {
t.Fatalf("ensure state dir for default runSensitive: %v", err)
}
orchDefault := cluster.New(
cfgDefaultRun,
&execx.Runner{DryRun: false},
state.New(cfgDefaultRun.State.RunHistoryPath),
log.New(io.Discard, "", 0),
)
if out, err := orchDefault.TestHookRunSensitive(context.Background(), 2*time.Second, "sh", "-lc", "echo hello-sensitive"); err != nil || strings.TrimSpace(out) != "hello-sensitive" {
t.Fatalf("expected runSensitive success path, out=%q err=%v", out, err)
}
if out, err := orchDefault.TestHookRunSensitive(context.Background(), 2*time.Second, "sh", "-lc", "echo boom && exit 3"); err == nil || !strings.Contains(out, "boom") {
t.Fatalf("expected runSensitive failure with output, out=%q err=%v", out, err)
}
})
}

View File

@ -0,0 +1,251 @@
package orchestrator
import (
"context"
"errors"
"fmt"
"net"
"strings"
"testing"
"time"
"scm.bstein.dev/bstein/ananke/internal/cluster"
)
// TestHookTimeSyncInventoryMatrix runs one orchestration or CLI step.
// Signature: TestHookTimeSyncInventoryMatrix(t *testing.T).
// Why: closes branch-heavy time-sync and datastore preflight gaps that appear
// during cold bootstraps and network jitter.
func TestHookTimeSyncInventoryMatrix(t *testing.T) {
t.Run("wait-for-time-sync-strict-and-quorum", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.TimeSyncWaitSeconds = 1
cfg.Startup.TimeSyncPollSeconds = 1
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "sh" && strings.Contains(command, "timedatectl show -p NTPSynchronized"):
return "yes", nil
case name == "ssh" && strings.Contains(command, "timedatectl show -p NTPSynchronized"):
if strings.Contains(command, "titan-23") {
return "no", nil
}
return "yes", nil
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
if err := orch.TestHookWaitForTimeSync(context.Background(), []string{"titan-db"}); err != nil {
t.Fatalf("strict timesync success expected, got %v", err)
}
cfg.Startup.TimeSyncMode = "quorum"
cfg.Startup.TimeSyncQuorum = 1
orchQuorum, _ := newHookOrchestrator(t, cfg, run, run)
if err := orchQuorum.TestHookWaitForTimeSync(context.Background(), []string{"titan-db", "titan-23"}); err != nil {
t.Fatalf("quorum timesync success expected, got %v", err)
}
})
t.Run("wait-for-time-sync-timeout-and-cancel", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.TimeSyncWaitSeconds = 1
cfg.Startup.TimeSyncPollSeconds = 1
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "sh" && strings.Contains(command, "timedatectl show -p NTPSynchronized"):
return "no", nil
case name == "ssh" && strings.Contains(command, "timedatectl show -p NTPSynchronized"):
return "no", nil
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
err := orch.TestHookWaitForTimeSync(context.Background(), []string{"titan-db"})
if err == nil || !strings.Contains(err.Error(), "time sync not ready") {
t.Fatalf("expected strict timesync timeout error, got %v", err)
}
cancelCtx, cancel := context.WithCancel(context.Background())
cancel()
if err := orch.TestHookWaitForTimeSync(cancelCtx, []string{"titan-db"}); !errors.Is(err, context.Canceled) {
t.Fatalf("expected canceled timesync wait, got %v", err)
}
})
t.Run("preflight-external-datastore-noop-branches", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.ControlPlanes = nil
orchNoCP, _ := newHookOrchestrator(t, cfg, nil, nil)
if err := orchNoCP.TestHookPreflightExternalDatastore(context.Background()); err != nil {
t.Fatalf("expected nil when no control planes configured, got %v", err)
}
cfgUnmanaged := lifecycleConfig(t)
cfgUnmanaged.ControlPlanes = []string{"titan-24"}
cfgUnmanaged.SSHManagedNodes = []string{"titan-db"}
orchUnmanaged, _ := newHookOrchestrator(t, cfgUnmanaged, nil, nil)
if err := orchUnmanaged.TestHookPreflightExternalDatastore(context.Background()); err != nil {
t.Fatalf("expected nil when control plane is unmanaged, got %v", err)
}
cfgInspectErr := lifecycleConfig(t)
runInspectErr := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "ssh" && strings.Contains(command, "systemctl cat k3s") {
return "", errors.New("permission denied")
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orchInspectErr, _ := newHookOrchestrator(t, cfgInspectErr, runInspectErr, runInspectErr)
if err := orchInspectErr.TestHookPreflightExternalDatastore(context.Background()); err != nil {
t.Fatalf("expected nil when k3s unit inspect fails, got %v", err)
}
cfgBadEndpoint := lifecycleConfig(t)
runBadEndpoint := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "ssh" && strings.Contains(command, "systemctl cat k3s") {
return "ExecStart=/usr/local/bin/k3s server --datastore-endpoint=::bad-endpoint::", nil
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orchBadEndpoint, _ := newHookOrchestrator(t, cfgBadEndpoint, runBadEndpoint, runBadEndpoint)
if err := orchBadEndpoint.TestHookPreflightExternalDatastore(context.Background()); err != nil {
t.Fatalf("expected nil when datastore endpoint cannot be parsed, got %v", err)
}
})
t.Run("preflight-external-datastore-reachable-and-recovery", func(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen for reachable datastore branch: %v", err)
}
reachablePort := listener.Addr().(*net.TCPAddr).Port
defer listener.Close()
cfgReachable := lifecycleConfig(t)
runReachable := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "ssh" && strings.Contains(command, "systemctl cat k3s") {
return fmt.Sprintf(
"ExecStart=/usr/local/bin/k3s server --datastore-endpoint=postgres://127.0.0.1:%d/k3s",
reachablePort,
), nil
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orchReachable, _ := newHookOrchestrator(t, cfgReachable, runReachable, runReachable)
if err := orchReachable.TestHookPreflightExternalDatastore(context.Background()); err != nil {
t.Fatalf("expected reachable datastore preflight success, got %v", err)
}
recoverySeed, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("seed recovery port: %v", err)
}
recoveryPort := recoverySeed.Addr().(*net.TCPAddr).Port
_ = recoverySeed.Close()
cfgRecovery := lifecycleConfig(t)
cfgRecovery.SSHManagedNodes = append(cfgRecovery.SSHManagedNodes, "db-postgres")
cfgRecovery.SSHNodeHosts["db-postgres"] = "127.0.0.1"
restarted := false
runRecovery := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "ssh" && strings.Contains(command, "systemctl cat k3s"):
return fmt.Sprintf(
"ExecStart=/usr/local/bin/k3s server --datastore-endpoint=postgres://127.0.0.1:%d/k3s",
recoveryPort,
), nil
case name == "ssh" && strings.Contains(command, "systemctl restart postgresql"):
restarted = true
go func() {
time.Sleep(500 * time.Millisecond)
l, listenErr := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", recoveryPort))
if listenErr != nil {
return
}
time.Sleep(4 * time.Second)
_ = l.Close()
}()
return "", nil
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orchRecovery, _ := newHookOrchestrator(t, cfgRecovery, runRecovery, runRecovery)
if err := orchRecovery.TestHookPreflightExternalDatastore(context.Background()); err != nil {
t.Fatalf("expected datastore recovery branch success, got %v", err)
}
if !restarted {
t.Fatalf("expected datastore restart branch to run")
}
})
t.Run("preflight-external-datastore-canceled", func(t *testing.T) {
cfg := lifecycleConfig(t)
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "ssh" && strings.Contains(command, "systemctl cat k3s") {
return "ExecStart=/usr/local/bin/k3s server --datastore-endpoint=postgres://127.0.0.1:1/k3s", nil
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
cancelCtx, cancel := context.WithCancel(context.Background())
cancel()
if err := orch.TestHookPreflightExternalDatastore(cancelCtx); !errors.Is(err, context.Canceled) {
t.Fatalf("expected canceled datastore preflight, got %v", err)
}
})
t.Run("helpers-parse-endpoint-node-lookup-and-inventory-validate", func(t *testing.T) {
if got := cluster.TestHookParseDatastoreEndpoint(`ExecStart=/usr/local/bin/k3s server --datastore-endpoint="postgres://db.internal:5432/k3s"`); got == "" {
t.Fatalf("expected regex endpoint parse to return value")
}
if got := cluster.TestHookParseDatastoreEndpoint("ExecStart=/usr/local/bin/k3s server \\\n --datastore-endpoint=postgres://db.internal:5432/k3s \\\n"); got == "" {
t.Fatalf("expected line-scan endpoint parse to return value")
}
if got := cluster.TestHookParseDatastoreEndpoint("ExecStart=/usr/local/bin/k3s server"); got != "" {
t.Fatalf("expected empty parse result for missing endpoint, got %q", got)
}
cfg := lifecycleConfig(t)
cfg.SSHNodeHosts["db-postgres"] = "10.42.0.10"
orch, _ := newHookOrchestrator(t, cfg, nil, nil)
if got := orch.TestHookNodeNameForHost("db-postgres"); got != "db-postgres" {
t.Fatalf("expected direct node lookup, got %q", got)
}
if got := orch.TestHookNodeNameForHost("10.42.0.10"); got != "db-postgres" {
t.Fatalf("expected reverse host lookup to resolve db-postgres, got %q", got)
}
if got := orch.TestHookNodeNameForHost(""); got != "" {
t.Fatalf("expected empty host lookup to be empty, got %q", got)
}
cfgBad := lifecycleConfig(t)
cfgBad.SSHPort = 70000
cfgBad.Workers = []string{"titan-23", "titan-24"}
cfgBad.SSHManagedNodes = []string{"titan-db", "titan-23"}
cfgBad.SSHNodeHosts["titan-24"] = "bad/host"
cfgBad.SSHNodeUsers = map[string]string{}
cfgBad.SSHNodeUsers["titan-24"] = "bad@user"
orchBad, _ := newHookOrchestrator(t, cfgBad, nil, nil)
err := orchBad.TestHookValidateNodeInventory()
if err == nil {
t.Fatalf("expected invalid inventory error")
}
if !strings.Contains(err.Error(), "ssh_port") || !strings.Contains(err.Error(), "invalid ssh host") || !strings.Contains(err.Error(), "invalid ssh user") {
t.Fatalf("unexpected inventory validation error: %v", err)
}
})
}

View File

@ -0,0 +1,211 @@
package orchestrator
import (
"io"
"log"
"strings"
"testing"
"time"
"scm.bstein.dev/bstein/ananke/internal/cluster"
"scm.bstein.dev/bstein/ananke/internal/execx"
"scm.bstein.dev/bstein/ananke/internal/state"
)
// TestHookPureHelperCoverage runs one orchestration or CLI step.
// Signature: TestHookPureHelperCoverage(t *testing.T).
// Why: covers exported helper wrappers so parser/normalizer utility paths stay stable and test-visible.
func TestHookPureHelperCoverage(t *testing.T) {
if !cluster.TestHookProbeStatusAccepted(200) {
t.Fatalf("expected 200 accepted")
}
if !cluster.TestHookProbeStatusAccepted(401) {
t.Fatalf("expected 401 accepted for auth-fronted probes")
}
if cluster.TestHookProbeStatusAccepted(500) {
t.Fatalf("expected 500 rejected")
}
if !cluster.TestHookChecklistContains(`{"database":"ok"}`, `"database":"ok"`) {
t.Fatalf("expected exact marker match")
}
if !cluster.TestHookChecklistContains("{\n \"database\": \"ok\"\n}", `"database":"ok"`) {
t.Fatalf("expected compact whitespace-insensitive marker match")
}
if cluster.TestHookChecklistContains(`{"status":"bad"}`, `"database":"ok"`) {
t.Fatalf("unexpected marker match")
}
if !cluster.TestHookIsLikelyHostname("metrics.bstein.dev") {
t.Fatalf("expected hostname heuristic true")
}
if cluster.TestHookIsLikelyHostname("not a host/path") {
t.Fatalf("expected hostname heuristic false")
}
if got := cluster.TestHookHostFromURL("https://metrics.bstein.dev/api/health"); got != "metrics.bstein.dev" {
t.Fatalf("unexpected parsed host: %q", got)
}
now := time.Now().UTC()
if age := cluster.TestHookIntentAge(state.Intent{UpdatedAt: now.Add(-2 * time.Minute)}); age <= 0 {
t.Fatalf("expected positive intent age, got %s", age)
}
if !cluster.TestHookIntentFresh(state.Intent{UpdatedAt: now.Add(-30 * time.Second)}, 2*time.Minute) {
t.Fatalf("expected fresh intent")
}
if cluster.TestHookIntentFresh(state.Intent{UpdatedAt: now.Add(-5 * time.Minute)}, time.Minute) {
t.Fatalf("expected stale intent")
}
mode, err := cluster.TestHookNormalizeShutdownMode("cluster-only")
if err != nil || mode != "cluster-only" {
t.Fatalf("normalize shutdown mode failed: mode=%q err=%v", mode, err)
}
if _, err := cluster.TestHookNormalizeShutdownMode("poweroff"); err == nil {
t.Fatalf("expected poweroff shutdown mode rejection")
}
if _, err := cluster.TestHookNormalizeShutdownMode("invalid-mode"); err == nil {
t.Fatalf("expected invalid shutdown mode rejection")
}
if got := cluster.TestHookParseFluxKustomizationTimeout("45s"); got != 45*time.Second {
t.Fatalf("unexpected flux timeout parse: %s", got)
}
if got := cluster.TestHookParseFluxKustomizationTimeout("not-a-duration"); got != 0 {
t.Fatalf("expected zero timeout for invalid duration, got %s", got)
}
if !cluster.TestHookLooksLikeImmutableJobError("Job update failed: field is immutable") {
t.Fatalf("expected immutable job detector match")
}
if cluster.TestHookLooksLikeImmutableJobError("all good") {
t.Fatalf("unexpected immutable job detector match")
}
if !cluster.TestHookJobLooksFluxManaged("flux-system", "reconcile-services", map[string]string{"kustomize.toolkit.fluxcd.io/name": "services"}, nil) {
t.Fatalf("expected flux-managed job by label")
}
if cluster.TestHookJobLooksFluxManaged("default", "user-job", nil, []string{"CronJob"}) {
t.Fatalf("expected cronjob-owned job not treated as flux-managed")
}
if cluster.TestHookJobFailed(0, 1, []string{"Failed"}, []string{"True"}) {
t.Fatalf("expected succeeded job not failed")
}
if !cluster.TestHookJobFailed(1, 0, []string{"Failed"}, []string{"True"}) {
t.Fatalf("expected failed job detection")
}
if !cluster.TestHookIsTimeSynced("yes") || !cluster.TestHookIsTimeSynced("1") || cluster.TestHookIsTimeSynced("no") {
t.Fatalf("unexpected time sync parser behavior")
}
if got := cluster.TestHookSanitizeReportFileName("startup drill #1"); got == "" {
t.Fatalf("expected sanitized report filename")
}
snapshot := cluster.TestHookParseSnapshotPathFromEtcdSnapshotList("Name Size Created Location\npre-shutdown 4.2M now \"file:///var/lib/rancher/k3s/server/db/snapshots/pre-shutdown\"\n")
if snapshot == "" {
t.Fatalf("expected snapshot parser to extract path")
}
snapshotDirect := cluster.TestHookParseSnapshotPathFromEtcdSnapshotList("\n \npre-shutdown 4.2M now /var/lib/rancher/k3s/server/db/snapshots/direct-path,\n")
if snapshotDirect == "" {
t.Fatalf("expected direct-path snapshot parser extraction")
}
if empty := cluster.TestHookParseSnapshotPathFromEtcdSnapshotList("no rows"); empty != "" {
t.Fatalf("expected empty snapshot parse for non-matching input, got %q", empty)
}
cfg := lifecycleConfig(t)
orch := cluster.New(cfg, &execx.Runner{DryRun: true}, state.New(cfg.State.RunHistoryPath), log.New(io.Discard, "", 0))
peers := orch.TestHookCoordinationPeers()
if len(peers) != 0 {
t.Fatalf("expected no coordination peers in baseline fixture, got %v", peers)
}
_ = orch.TestHookEstimatedShutdownSeconds()
_ = orch.TestHookEstimatedEmergencyShutdownSeconds()
}
// TestHookWorkloadHelperCoverage runs one orchestration or CLI step.
// Signature: TestHookWorkloadHelperCoverage(t *testing.T).
// Why: covers workload helper wrappers so ignored-node, readiness, and failure summarization paths remain deterministic.
func TestHookWorkloadHelperCoverage(t *testing.T) {
if desired, ready, ok := cluster.TestHookDesiredReady("deployment", true, 3, 2, 0, 0); !ok || desired != 3 || ready != 2 {
t.Fatalf("unexpected desiredReady deployment result: desired=%d ready=%d ok=%v", desired, ready, ok)
}
if desired, ready, ok := cluster.TestHookDesiredReady("daemonset", false, 0, 0, 2, 1); !ok || desired != 2 || ready != 1 {
t.Fatalf("unexpected desiredReady daemonset result: desired=%d ready=%d ok=%v", desired, ready, ok)
}
if _, _, ok := cluster.TestHookDesiredReady("job", false, 0, 0, 0, 0); ok {
t.Fatalf("expected unsupported kind to return ok=false")
}
if !cluster.TestHookPodControllerOwned([]string{"ReplicaSet"}) {
t.Fatalf("expected controller-owned pod")
}
if cluster.TestHookPodControllerOwned([]string{"CronJob"}) {
t.Fatalf("expected non-controller pod for this helper")
}
reason := cluster.TestHookStuckContainerReason(
[]string{"ImagePullBackOff"},
[]string{},
[]string{"ImagePullBackOff", "ErrImagePull"},
)
if reason != "ImagePullBackOff" {
t.Fatalf("unexpected stuck container reason: %q", reason)
}
if reason := cluster.TestHookStuckContainerReason(nil, []string{"Running"}, []string{"CrashLoopBackOff"}); reason != "" {
t.Fatalf("expected no stuck reason, got %q", reason)
}
if reason := cluster.TestHookStuckVaultInitReason("Pending", true, 5*time.Minute, 10*time.Second); reason != "VaultInitStuck" {
t.Fatalf("expected VaultInitStuck, got %q", reason)
}
if reason := cluster.TestHookStuckVaultInitReason("Running", true, 5*time.Minute, 10*time.Second); reason != "" {
t.Fatalf("expected no vault-init stuck reason when phase is not Pending, got %q", reason)
}
if !cluster.TestHookPodTargetsIgnoredNode("titan-22", []string{"titan-22"}) {
t.Fatalf("expected ignored node match")
}
if cluster.TestHookPodTargetsIgnoredNode("titan-23", []string{"titan-22"}) {
t.Fatalf("expected ignored node mismatch")
}
if !cluster.TestHookWorkloadTargetsIgnoredNodes("titan-22", nil, []string{"titan-22"}) {
t.Fatalf("expected node selector ignore match")
}
if !cluster.TestHookWorkloadTargetsIgnoredNodes("", []string{"titan-22"}, []string{"titan-22"}) {
t.Fatalf("expected affinity-only ignored-node match")
}
if cluster.TestHookWorkloadTargetsIgnoredNodes("", []string{"titan-23"}, []string{"titan-22"}) {
t.Fatalf("expected affinity mismatch to be false")
}
if count := cluster.TestHookParseWorkloadIgnoreRulesCount([]string{"monitoring/grafana", "monitoring/deployment/kube-state-metrics"}); count != 2 {
t.Fatalf("unexpected workload ignore rule count: %d", count)
}
if !cluster.TestHookWorkloadIgnoredEntries([]string{"monitoring/grafana"}, "monitoring", "deployment", "grafana") {
t.Fatalf("expected workload ignored by rules")
}
if cluster.TestHookWorkloadIgnoredEntries([]string{"monitoring/grafana"}, "monitoring", "deployment", "loki") {
t.Fatalf("unexpected workload ignored by rules")
}
if idx := cluster.TestHookReadyConditionIndex([]string{"Progressing", "Ready"}); idx != 1 {
t.Fatalf("unexpected ready condition index: %d", idx)
}
if idx := cluster.TestHookReadyConditionIndex([]string{"Progressing"}); idx != -1 {
t.Fatalf("expected missing ready condition index=-1, got %d", idx)
}
if got := cluster.TestHookJoinLimited([]string{"a", "b", "c"}, 2); !strings.Contains(got, "+1 more") {
t.Fatalf("expected joinLimited truncation marker, got %q", got)
}
if got := cluster.TestHookJoinLimited([]string{"a", "b"}, 5); got != "a; b" {
t.Fatalf("unexpected joinLimited output: %q", got)
}
candidates := cluster.TestHookNamespaceCandidatesFromIgnoreKustomizations([]string{"flux-system/monitoring", "flux-system/gitea", "invalid"})
if len(candidates) != 2 || candidates[0] != "gitea" || candidates[1] != "monitoring" {
t.Fatalf("unexpected namespace candidates: %v", candidates)
}
}

View File

@ -0,0 +1,134 @@
package orchestrator
import (
"fmt"
"os"
"path/filepath"
"testing"
"time"
"scm.bstein.dev/bstein/ananke/internal/state"
)
// TestHookReportWrappersCoverage runs one orchestration or CLI step.
// Signature: TestHookReportWrappersCoverage(t *testing.T).
// Why: ensures every report-specific testing hook is exercised from the top-level
// testing module so hook coverage never drifts below quality gates.
func TestHookReportWrappersCoverage(t *testing.T) {
cfg := lifecycleConfig(t)
orch := newLiveHookOrchestrator(t, cfg)
if got := orch.TestHookReportArchiveDir(); got == "" {
t.Fatalf("expected non-empty report archive dir")
}
if got := orch.TestHookStartupReportPath(); got == "" {
t.Fatalf("expected startup report path")
}
if got := orch.TestHookStartupProgressPath(); got == "" {
t.Fatalf("expected startup progress path")
}
if got := orch.TestHookLastShutdownReportPath(); got == "" {
t.Fatalf("expected last shutdown report path")
}
orch.TestHookBeginStartupReport("wrapper-coverage")
orch.TestHookSetStartupPhase("wrapper-phase", "coverage detail")
orch.TestHookNoteStartupCheckState("wrapper-check", "running", "detail")
orch.TestHookNoteStartupCheck("wrapper-final", true, "ok")
orch.TestHookNoteStartupAutoHeal("auto-heal detail")
orch.TestHookPersistStartupProgress("running")
orch.TestHookPersistStartupProgressNil()
orch.TestHookFinalizeStartupReport(fmt.Errorf("intentional failure"))
if !orch.TestHookFinalizeStartupReportSnapshot(nil) {
t.Fatalf("expected finalize snapshot wrapper success")
}
orch.TestHookFinalizeStartupReport(nil) // nil-active-report branch
progressPath := orch.TestHookStartupProgressPath()
if _, err := os.Stat(progressPath); err != nil {
t.Fatalf("expected startup progress at %s: %v", progressPath, err)
}
if err := orch.TestHookWriteStartupReportFileNil(filepath.Join(cfg.State.Dir, "nil-report.json")); err != nil {
t.Fatalf("expected nil report write no-op, got %v", err)
}
if err := orch.TestHookWriteStartupReportFile(filepath.Join(cfg.State.Dir, "wrapper-report.json"), "running"); err != nil {
t.Fatalf("expected startup report write success, got %v", err)
}
recStartup := state.RunRecord{
ID: "wrapper-startup-1",
Action: "startup",
Reason: "wrapper",
StartedAt: time.Now().UTC().Add(-2 * time.Second),
EndedAt: time.Now().UTC(),
Success: true,
}
if err := orch.TestHookWriteRunRecordArtifact(recStartup); err != nil {
t.Fatalf("expected startup artifact write success, got %v", err)
}
recShutdown := state.RunRecord{
ID: "wrapper-shutdown-1",
Action: "shutdown",
Reason: "wrapper",
StartedAt: time.Now().UTC().Add(-2 * time.Second),
EndedAt: time.Now().UTC(),
Success: true,
}
if err := orch.TestHookWriteRunRecordArtifact(recShutdown); err != nil {
t.Fatalf("expected shutdown artifact write success, got %v", err)
}
orch.TestHookFinalizeRecord("startup", "wrapper-finalize-ok", "")
orch.TestHookFinalizeRecord("startup", "wrapper-finalize-fail", "boom")
t.Run("report-writer-error-branches", func(t *testing.T) {
cfgErr := lifecycleConfig(t)
root := t.TempDir()
reportDirFile := filepath.Join(root, "reports-file")
if err := os.WriteFile(reportDirFile, []byte("x"), 0o644); err != nil {
t.Fatalf("write report dir file: %v", err)
}
cfgErr.State.ReportsDir = reportDirFile
orchErr := newLiveHookOrchestrator(t, cfgErr)
if err := orchErr.TestHookWriteRunRecordArtifact(state.RunRecord{
ID: "startup-error-path",
Action: "startup",
Reason: "test",
StartedAt: time.Now().UTC(),
}); err == nil {
t.Fatalf("expected report archive dir creation failure")
}
cfgWriteErr := lifecycleConfig(t)
cfgWriteErr.State.ReportsDir = filepath.Join(root, "reports-ok")
orchWriteErr := newLiveHookOrchestrator(t, cfgWriteErr)
if err := os.MkdirAll(cfgWriteErr.State.ReportsDir, 0o755); err != nil {
t.Fatalf("mkdir reports dir: %v", err)
}
blockPath := filepath.Join(cfgWriteErr.State.ReportsDir, "collision.json")
if err := os.Mkdir(blockPath, 0o755); err != nil {
t.Fatalf("mkdir collision path: %v", err)
}
if err := orchWriteErr.TestHookWriteRunRecordArtifact(state.RunRecord{
ID: "collision",
Action: "startup",
Reason: "test",
StartedAt: time.Now().UTC(),
}); err == nil {
t.Fatalf("expected artifact write collision error")
}
cfgStartupWriteErr := lifecycleConfig(t)
parentFile := filepath.Join(root, "startup-parent-file")
if err := os.WriteFile(parentFile, []byte("x"), 0o644); err != nil {
t.Fatalf("write parent file: %v", err)
}
cfgStartupWriteErr.State.Dir = filepath.Join(root, "startup-state-ok")
orchStartupWriteErr := newLiveHookOrchestrator(t, cfgStartupWriteErr)
if err := orchStartupWriteErr.TestHookWriteStartupReportFile(filepath.Join(parentFile, "report.json"), "running"); err == nil {
t.Fatalf("expected startup report mkdir error")
}
orchStartupWriteErr.TestHookPersistStartupProgress("running")
})
}

View File

@ -0,0 +1,297 @@
package orchestrator
import (
"context"
"encoding/base64"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"scm.bstein.dev/bstein/ananke/internal/cluster"
)
// TestHookVaultParserAndClassifierBranches runs one orchestration or CLI step.
// Signature: TestHookVaultParserAndClassifierBranches(t *testing.T).
// Why: closes parser/classifier helper branches that gate vault recovery decisions.
func TestHookVaultParserAndClassifierBranches(t *testing.T) {
if _, err := cluster.TestHookParseVaultSealed(""); err == nil {
t.Fatalf("expected empty vault payload parse error")
}
if _, err := cluster.TestHookParseVaultSealed("not-json"); err == nil {
t.Fatalf("expected missing JSON object parse error")
}
sealed, err := cluster.TestHookParseVaultSealed("prefix {\"sealed\":true} suffix")
if err != nil || !sealed {
t.Fatalf("expected sealed=true parse success, got sealed=%v err=%v", sealed, err)
}
if !cluster.TestHookIsNotFoundErr("Error from server (NotFound): pods \"vault-0\" not found") {
t.Fatalf("expected NotFound classifier to match")
}
if cluster.TestHookIsNotFoundErr("permission denied") {
t.Fatalf("expected non-not-found classifier miss")
}
}
// TestHookVaultWorkloadAndCleanupBranches runs one orchestration or CLI step.
// Signature: TestHookVaultWorkloadAndCleanupBranches(t *testing.T).
// Why: validates workload readiness and stale-pod cleanup branches that matter
// during repeated post-outage startup attempts.
func TestHookVaultWorkloadAndCleanupBranches(t *testing.T) {
cfg := lifecycleConfig(t)
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "-n vault get statefulset vault -o jsonpath={.status.readyReplicas}"):
return "1", nil
case name == "kubectl" && strings.Contains(command, "-n vault get deployment missing -o jsonpath={.status.readyReplicas}"):
return "", errors.New("Error from server (NotFound): deployments.apps \"missing\" not found")
case name == "kubectl" && strings.Contains(command, "-n vault get statefulset parse-bad -o jsonpath={.status.readyReplicas}"):
return "not-a-number", nil
case name == "kubectl" && strings.Contains(command, "-n vault get pods -o custom-columns="):
return "vault-0 Failed StatefulSet vault\nvault-other Running StatefulSet vault\n", nil
case name == "kubectl" && strings.Contains(command, "-n vault delete pod vault-0"):
return "", nil
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
ready, err := orch.TestHookWorkloadReady(context.Background(), "vault", "statefulset", "vault")
if err != nil || !ready {
t.Fatalf("expected workloadReady true path, got ready=%v err=%v", ready, err)
}
if _, err := orch.TestHookWorkloadReady(context.Background(), "vault", "deployment", "missing"); err == nil {
t.Fatalf("expected workloadReady error propagation branch")
}
if _, err := orch.TestHookWorkloadReady(context.Background(), "vault", "statefulset", "parse-bad"); err == nil {
t.Fatalf("expected workloadReady parse error branch")
}
if err := orch.TestHookCleanupStaleCriticalWorkloadPods(context.Background(), "vault", "statefulset", "vault"); err != nil {
t.Fatalf("expected stale critical pod cleanup success, got %v", err)
}
if err := orch.TestHookCleanupStaleCriticalWorkloadPods(context.Background(), "vault", "deployment", "vault"); err != nil {
t.Fatalf("expected non-statefulset stale cleanup no-op, got %v", err)
}
}
// TestHookVaultKeyResolutionBranches runs one orchestration or CLI step.
// Signature: TestHookVaultKeyResolutionBranches(t *testing.T).
// Why: ensures all vault unseal key fallback paths remain deterministic.
func TestHookVaultKeyResolutionBranches(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.VaultUnsealKeyFile = filepath.Join(t.TempDir(), "vault", "unseal.key")
cfg.Startup.VaultUnsealBreakglassCommand = "printf breakglass-key"
cfg.Startup.VaultUnsealBreakglassTimeout = 1
validKey := base64.StdEncoding.EncodeToString([]byte("secret-key"))
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "-n vault get secret vault-init -o jsonpath={.data.unseal_key_b64}"):
return validKey, nil
case name == "sh" && strings.Contains(command, "printf breakglass-key"):
return "breakglass-key", nil
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
key, err := orch.TestHookVaultUnsealKey(context.Background())
if err != nil || key != "secret-key" {
t.Fatalf("expected primary vault-init key path, got key=%q err=%v", key, err)
}
if cached, err := orch.TestHookReadVaultUnsealKeyFile(); err != nil || cached == "" {
t.Fatalf("expected cached unseal key file after primary path, got key=%q err=%v", cached, err)
}
decodeFailRun := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "-n vault get secret vault-init -o jsonpath={.data.unseal_key_b64}"):
return "%%%not-base64%%%", nil
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orchDecodeFallback, _ := newHookOrchestrator(t, cfg, decodeFailRun, decodeFailRun)
if key, err := orchDecodeFallback.TestHookVaultUnsealKey(context.Background()); err != nil || key == "" {
t.Fatalf("expected file fallback after decode error, got key=%q err=%v", key, err)
}
breakglassOnlyCfg := lifecycleConfig(t)
breakglassOnlyCfg.Startup.VaultUnsealKeyFile = filepath.Join(t.TempDir(), "missing", "unseal.key")
breakglassOnlyCfg.Startup.VaultUnsealBreakglassCommand = "printf breakglass-key"
breakglassOnlyCfg.Startup.VaultUnsealBreakglassTimeout = 1
breakglassRun := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "-n vault get secret vault-init -o jsonpath={.data.unseal_key_b64}"):
return "", errors.New("Error from server (NotFound): secrets \"vault-init\" not found")
case name == "sh" && strings.Contains(command, "printf breakglass-key"):
return "breakglass-key", nil
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orchBreakglass, _ := newHookOrchestrator(t, breakglassOnlyCfg, breakglassRun, breakglassRun)
if key, err := orchBreakglass.TestHookVaultUnsealKey(context.Background()); err != nil || key != "breakglass-key" {
t.Fatalf("expected breakglass fallback key, got key=%q err=%v", key, err)
}
}
// TestHookVaultBreakglassAndFileErrorBranches runs one orchestration or CLI step.
// Signature: TestHookVaultBreakglassAndFileErrorBranches(t *testing.T).
// Why: covers local file/breakglass error paths used when secrets are unavailable.
func TestHookVaultBreakglassAndFileErrorBranches(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.VaultUnsealKeyFile = ""
orchEmptyPath, _ := newHookOrchestrator(t, cfg, nil, nil)
if err := orchEmptyPath.TestHookWriteVaultUnsealKeyFile("x"); err == nil {
t.Fatalf("expected empty key-file path write error")
}
if _, err := orchEmptyPath.TestHookReadVaultUnsealKeyFile(); err == nil {
t.Fatalf("expected empty key-file path read error")
}
cfg = lifecycleConfig(t)
cfg.Startup.VaultUnsealBreakglassCommand = ""
orchNoBreakglass, _ := newHookOrchestrator(t, cfg, nil, nil)
if _, err := orchNoBreakglass.TestHookReadVaultUnsealKeyBreakglass(context.Background()); err == nil {
t.Fatalf("expected breakglass-not-configured error")
}
cfg.Startup.VaultUnsealBreakglassCommand = "printf ''"
breakglassEmptyRun := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "sh" && strings.Contains(command, "printf ''") {
return "", nil
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orchBreakglassEmpty, _ := newHookOrchestrator(t, cfg, breakglassEmptyRun, breakglassEmptyRun)
if _, err := orchBreakglassEmpty.TestHookReadVaultUnsealKeyBreakglass(context.Background()); err == nil {
t.Fatalf("expected breakglass empty-output error")
}
cfg.Startup.VaultUnsealBreakglassCommand = "exit 1"
breakglassErrRun := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "sh" && strings.Contains(command, "exit 1") {
return "", errors.New("command failed")
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orchBreakglassErr, _ := newHookOrchestrator(t, cfg, breakglassErrRun, breakglassErrRun)
if _, err := orchBreakglassErr.TestHookReadVaultUnsealKeyBreakglass(context.Background()); err == nil {
t.Fatalf("expected breakglass command error")
}
cfg.Startup.VaultUnsealKeyFile = filepath.Join(t.TempDir(), "vault", "unseal.key")
orchFile, _ := newHookOrchestrator(t, cfg, nil, nil)
if err := orchFile.TestHookWriteVaultUnsealKeyFile("from-file"); err != nil {
t.Fatalf("expected file write success, got %v", err)
}
if got, err := orchFile.TestHookReadVaultUnsealKeyFile(); err != nil || got != "from-file" {
t.Fatalf("expected file read success, got key=%q err=%v", got, err)
}
if err := os.WriteFile(cfg.Startup.VaultUnsealKeyFile, []byte(" \n"), 0o600); err != nil {
t.Fatalf("write empty key file fixture: %v", err)
}
if _, err := orchFile.TestHookReadVaultUnsealKeyFile(); err == nil {
t.Fatalf("expected empty key-file content error")
}
}
// TestHookVaultSealedAndUnsealBranches runs one orchestration or CLI step.
// Signature: TestHookVaultSealedAndUnsealBranches(t *testing.T).
// Why: covers sealed-state checks and unseal flow branches that can block startup.
func TestHookVaultSealedAndUnsealBranches(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.VaultUnsealKeyFile = filepath.Join(t.TempDir(), "vault", "unseal.key")
phaseErrRun := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, "-n vault get pod vault-0 -o jsonpath={.status.phase}") {
return "", errors.New("phase check failed")
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orchPhaseErr, _ := newHookOrchestrator(t, cfg, phaseErrRun, phaseErrRun)
if err := orchPhaseErr.TestHookEnsureVaultUnsealed(context.Background()); err == nil {
t.Fatalf("expected vault phase error branch")
}
notRunningRun := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
if name == "kubectl" && strings.Contains(command, "-n vault get pod vault-0 -o jsonpath={.status.phase}") {
return "Pending", nil
}
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
orchNotRunning, _ := newHookOrchestrator(t, cfg, notRunningRun, notRunningRun)
if err := orchNotRunning.TestHookEnsureVaultUnsealed(context.Background()); err == nil {
t.Fatalf("expected non-running vault pod branch")
}
sealedFalseRun := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "-n vault get pod vault-0 -o jsonpath={.status.phase}"):
return "Running", nil
case name == "kubectl" && strings.Contains(command, "vault status -format=json"):
return `{"sealed":false}`, nil
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orchSealedFalse, _ := newHookOrchestrator(t, cfg, sealedFalseRun, sealedFalseRun)
if err := orchSealedFalse.TestHookEnsureVaultUnsealed(context.Background()); err != nil {
t.Fatalf("expected already-unsealed branch, got %v", err)
}
unsealed := false
sealedTrueRun := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "-n vault get pod vault-0 -o jsonpath={.status.phase}"):
return "Running", nil
case name == "kubectl" && strings.Contains(command, "vault status -format=json"):
if unsealed {
return `{"sealed":false}`, nil
}
return `{"sealed":true}`, nil
case name == "kubectl" && strings.Contains(command, "-n vault get secret vault-init -o jsonpath={.data.unseal_key_b64}"):
return base64.StdEncoding.EncodeToString([]byte("secret-key")), nil
case name == "kubectl" && strings.Contains(command, "vault operator unseal secret-key"):
unsealed = true
return "", nil
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orchSealedTrue, _ := newHookOrchestrator(t, cfg, sealedTrueRun, sealedTrueRun)
if err := orchSealedTrue.TestHookEnsureVaultUnsealed(context.Background()); err != nil {
t.Fatalf("expected auto-unseal success path, got %v", err)
}
}
// TestHookVaultWrapperSurfaceCoverage runs one orchestration or CLI step.
// Signature: TestHookVaultWrapperSurfaceCoverage(t *testing.T).
// Why: ensures newly-exposed vault test hooks stay directly exercised so wrapper
// coverage does not regress below quality-gate thresholds.
func TestHookVaultWrapperSurfaceCoverage(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.VaultUnsealKeyFile = filepath.Join(t.TempDir(), "vault", "unseal.key")
run := lifecycleDispatcher(&commandRecorder{})
orch, _ := newHookOrchestrator(t, cfg, run, run)
_ = orch.TestHookEnsureWorkloadReplicas(context.Background(), "vault", "statefulset", "vault", 1)
_ = orch.TestHookWaitWorkloadReady(context.Background(), "vault", "deployment", "grafana")
_ = orch.TestHookWaitVaultReady(context.Background(), "vault", "statefulset", "vault")
_, _ = orch.TestHookVaultSealed(context.Background())
}

View File

@ -0,0 +1,150 @@
package orchestrator
import (
"context"
"errors"
"strings"
"testing"
"time"
"scm.bstein.dev/bstein/ananke/internal/cluster"
)
// TestHookCriticalVaultMatrixExtra runs one orchestration or CLI step.
// Signature: TestHookCriticalVaultMatrixExtra(t *testing.T).
// Why: drives deeper vault/critical-workload recovery branches so sealed/unready
// states are exercised before the next power-cycle drill.
func TestHookCriticalVaultMatrixExtra(t *testing.T) {
t.Run("missing-and-ensure-critical-workloads", func(t *testing.T) {
cfg := lifecycleConfig(t)
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "get deployment source-controller"):
return "", errors.New("Error from server (NotFound): deployments.apps \"source-controller\" not found")
case name == "kubectl" && strings.Contains(command, "get deployment kustomize-controller"):
return "0", nil
case name == "kubectl" && strings.Contains(command, "get") && strings.Contains(command, "jsonpath={.status.readyReplicas}"):
return "1", nil
case name == "kubectl" && strings.Contains(command, " scale deployment source-controller --replicas=1"):
return "", errors.New("Error from server (NotFound): deployments.apps \"source-controller\" not found")
case name == "kubectl" && strings.Contains(command, " scale statefulset vault --replicas=1"):
return "", nil
case name == "kubectl" && strings.Contains(command, " -n vault get pods -o custom-columns="):
return "", errors.New("Error from server (NotFound): pods not found")
case name == "kubectl" && strings.Contains(command, " scale deployment gitea --replicas=1"):
return "", nil
case name == "kubectl" && strings.Contains(command, "rollout status deployment/gitea"):
return "", errors.New("Error from server (NotFound): deployments.apps \"gitea\" not found")
case name == "kubectl" && strings.Contains(command, "rollout status"):
return "rolled out", nil
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
missing, err := orch.TestHookMissingCriticalStartupWorkloads(context.Background())
if err != nil || len(missing) == 0 {
t.Fatalf("expected missing critical workload list, missing=%v err=%v", missing, err)
}
if err := orch.TestHookEnsureCriticalStartupWorkloads(context.Background()); err != nil {
t.Fatalf("expected ensureCriticalStartupWorkloads notfound branches to continue, got %v", err)
}
})
t.Run("vault-ready-unsealed-and-key-branches", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.VaultUnsealBreakglassCommand = "echo breakglass-key"
readyCalls := 0
run := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "-n vault get statefulset vault -o jsonpath={.status.readyReplicas}"):
readyCalls++
if readyCalls < 2 {
return "0", nil
}
return "1", nil
case name == "kubectl" && strings.Contains(command, "-n vault get pod vault-0 -o jsonpath={.status.phase}"):
return "Running", nil
case name == "kubectl" && strings.Contains(command, "vault status -format=json"):
if readyCalls < 2 {
return `{"sealed":true}`, nil
}
return `{"sealed":false}`, nil
case name == "kubectl" && strings.Contains(command, "get secret vault-init"):
return "", errors.New("secret missing")
case name == "sh" && strings.Contains(command, "echo breakglass-key"):
return "breakglass-key", nil
case name == "kubectl" && strings.Contains(command, "vault operator unseal"):
return "unsealed", nil
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orch, _ := newHookOrchestrator(t, cfg, run, run)
if err := orch.TestHookWaitVaultReady(context.Background(), "vault", "statefulset", "vault"); err != nil {
t.Fatalf("expected waitVaultReady unseal branch success, got %v", err)
}
if sealed, err := orch.TestHookVaultSealed(context.Background()); err != nil || sealed {
t.Fatalf("expected vaultSealed false after unseal, sealed=%v err=%v", sealed, err)
}
key, err := orch.TestHookVaultUnsealKey(context.Background())
if err != nil || strings.TrimSpace(key) == "" {
t.Fatalf("expected breakglass vault key fallback, key=%q err=%v", key, err)
}
if _, err := cluster.TestHookParseVaultSealed("not-json"); err == nil {
t.Fatalf("expected parseVaultSealed error for malformed payload")
}
if !cluster.TestHookIsNotFoundErr("Error from server (NotFound)") {
t.Fatalf("expected notfound helper to detect notfound text")
}
})
t.Run("vault-write-workload-and-phase-errors", func(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.VaultUnsealKeyFile = ""
runPhase := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "-n vault get pod vault-0 -o jsonpath={.status.phase}"):
return "Pending", nil
case name == "kubectl" && strings.Contains(command, "-n vault get statefulset vault -o jsonpath={.status.readyReplicas}"):
return "not-a-number", nil
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orchPhase, _ := newHookOrchestrator(t, cfg, runPhase, runPhase)
if err := orchPhase.TestHookEnsureVaultUnsealed(context.Background()); err == nil {
t.Fatalf("expected ensureVaultUnsealed phase guard error")
}
if err := orchPhase.TestHookWriteVaultUnsealKeyFile("x"); err == nil {
t.Fatalf("expected writeVaultUnsealKeyFile empty-path error")
}
if _, err := orchPhase.TestHookWorkloadReady(context.Background(), "vault", "statefulset", "vault"); err == nil {
t.Fatalf("expected workloadReady parse/lookup failure branch")
}
cfgCancel := lifecycleConfig(t)
runCancel := func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "kubectl" && strings.Contains(command, "-n vault get statefulset vault -o jsonpath={.status.readyReplicas}"):
return "0", nil
case name == "kubectl" && strings.Contains(command, "-n vault get pod vault-0 -o jsonpath={.status.phase}"):
return "", errors.New("transient phase query error")
default:
return lifecycleDispatcher(&commandRecorder{})(ctx, timeout, name, args...)
}
}
orchCancel, _ := newHookOrchestrator(t, cfgCancel, runCancel, runCancel)
cancelCtx, cancel := context.WithCancel(context.Background())
cancel()
if err := orchCancel.TestHookWaitVaultReady(cancelCtx, "vault", "statefulset", "vault"); !errors.Is(err, context.Canceled) {
t.Fatalf("expected canceled waitVaultReady branch, got %v", err)
}
})
}

View File

@ -0,0 +1,297 @@
package orchestrator
import (
"context"
"io"
"log"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"scm.bstein.dev/bstein/ananke/internal/cluster"
"scm.bstein.dev/bstein/ananke/internal/config"
"scm.bstein.dev/bstein/ananke/internal/execx"
"scm.bstein.dev/bstein/ananke/internal/state"
)
// wrapperCoverageDispatcher runs one orchestration or CLI step.
// Signature: wrapperCoverageDispatcher(recorder *commandRecorder) func(context.Context, time.Duration, string, ...string) (string, error).
// Why: centralizes deterministic command output so hook-wrapper tests can execute all exported test hooks without live cluster access.
func wrapperCoverageDispatcher(recorder *commandRecorder) func(context.Context, time.Duration, string, ...string) (string, error) {
base := lifecycleDispatcher(recorder)
return func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
command := name + " " + strings.Join(args, " ")
switch {
case name == "curl":
recorder.record(name, args)
return "200", nil
case name == "ssh" && strings.Contains(command, "k3s etcd-snapshot ls"):
recorder.record(name, args)
return "Name Size Created Location\npre-shutdown 4.2M now \"file:///var/lib/rancher/k3s/server/db/snapshots/pre-shutdown\"\n", nil
case name == "ssh" && strings.Contains(command, "k3s etcd-snapshot save"):
recorder.record(name, args)
return "snapshot saved", nil
case name == "kubectl" && strings.Contains(command, "get ingress -A -o json"):
recorder.record(name, args)
return `{"items":[{"metadata":{"namespace":"monitoring"},"spec":{"rules":[{"host":"metrics.bstein.dev"}]}}]}`, nil
case name == "kubectl" && strings.Contains(command, "get endpoints victoria-metrics-single-server"):
recorder.record(name, args)
return "10.42.0.10\n", nil
case name == "kubectl" && strings.Contains(command, "get deploy,statefulset,daemonset -A -o json"):
recorder.record(name, args)
return `{"items":[{"kind":"Deployment","metadata":{"namespace":"monitoring","name":"grafana"},"spec":{"replicas":1,"template":{"spec":{"nodeSelector":{"kubernetes.io/hostname":"titan-23"}}}},"status":{"readyReplicas":1}},{"kind":"StatefulSet","metadata":{"namespace":"monitoring","name":"victoria-metrics-single-server"},"spec":{"replicas":1,"template":{"spec":{}}},"status":{"readyReplicas":1}},{"kind":"DaemonSet","metadata":{"namespace":"monitoring","name":"node-exporter"},"spec":{"template":{"spec":{}}},"status":{"desiredNumberScheduled":1,"numberReady":1}}]}`, nil
case name == "kubectl" && strings.Contains(command, "get deploy,statefulset -A -o json"):
recorder.record(name, args)
return `{"items":[{"kind":"Deployment","metadata":{"namespace":"monitoring","name":"grafana"},"spec":{"replicas":0,"template":{"spec":{}}},"status":{"readyReplicas":0}},{"kind":"StatefulSet","metadata":{"namespace":"monitoring","name":"victoria-metrics-single-server"},"spec":{"replicas":1,"template":{"spec":{}}},"status":{"readyReplicas":1}}]}`, nil
case name == "kubectl" && strings.Contains(command, "get pods -A -o json"):
recorder.record(name, args)
return `{"items":[{"metadata":{"namespace":"vault","name":"vault-0","creationTimestamp":"2020-01-01T00:00:00Z","ownerReferences":[{"kind":"StatefulSet","name":"vault"}]},"spec":{"nodeName":"titan-23","containers":[{"name":"vault"}]},"status":{"phase":"Pending","initContainerStatuses":[{"name":"vault-agent-init","state":{"running":{"startedAt":"2020-01-01T00:00:00Z"}}}]}}]}`, nil
case name == "kubectl" && strings.Contains(command, "get deployment -A -o jsonpath="):
recorder.record(name, args)
return "monitoring\tgrafana\t1\nflux-system\tsource-controller\t1\ngitea\tgitea\t1\n", nil
case name == "kubectl" && strings.Contains(command, "get statefulset -A -o jsonpath="):
recorder.record(name, args)
return "monitoring\tvictoria-metrics-single-server\t1\nvault\tvault\t1\npostgres\tpostgres\t1\n", nil
case name == "kubectl" && strings.Contains(command, "get nodes -o custom-columns="):
recorder.record(name, args)
return "titan-23 <none> <none>\n", nil
case name == "kubectl" && strings.Contains(command, "-n flux-system get kustomizations.kustomize.toolkit.fluxcd.io -o jsonpath="):
recorder.record(name, args)
return "services\n", nil
case name == "kubectl" && strings.Contains(command, "get helmreleases.helm.toolkit.fluxcd.io -A -o jsonpath="):
recorder.record(name, args)
return "monitoring/grafana\n", nil
case name == "kubectl" && strings.Contains(command, "get statefulset vault -o jsonpath={.status.readyReplicas}"):
recorder.record(name, args)
return "1", nil
case name == "kubectl" && strings.Contains(command, "jsonpath={.status.readyReplicas}"):
recorder.record(name, args)
return "1", nil
case name == "kubectl" && strings.Contains(command, "rollout status"):
recorder.record(name, args)
return "rolled out", nil
case name == "kubectl" && strings.Contains(command, "scale "):
recorder.record(name, args)
return "", nil
case name == "kubectl" && strings.Contains(command, "delete pod"):
recorder.record(name, args)
return "", nil
case name == "kubectl" && strings.Contains(command, "patch "):
recorder.record(name, args)
return "", nil
default:
return base(ctx, timeout, name, args...)
}
}
}
// TestHookLifecycleWrappersCoverage runs one orchestration or CLI step.
// Signature: TestHookLifecycleWrappersCoverage(t *testing.T).
// Why: executes lifecycle hook wrappers so test-hook files and their orchestration backends stay covered from the top-level testing module.
func TestHookLifecycleWrappersCoverage(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.PostStartProbes = []string{"https://metrics.bstein.dev/healthz"}
cfg.Startup.PostStartProbeWaitSeconds = 1
cfg.Startup.PostStartProbePollSeconds = 1
cfg.Startup.RequireIngressChecklist = false
cfg.Startup.RequireServiceChecklist = false
cfg.Startup.RequireCriticalServiceEndpoints = false
cfg.Startup.RequireFluxHealth = false
cfg.Startup.RequireWorkloadConvergence = false
cfg.Startup.ServiceChecklistStabilitySec = 1
cfg.Startup.IngressChecklistWaitSeconds = 1
cfg.Startup.IngressChecklistPollSeconds = 1
cfg.Startup.ServiceChecklistWaitSeconds = 1
cfg.Startup.ServiceChecklistPollSeconds = 1
cfg.Startup.CriticalServiceEndpoints = []string{"monitoring/victoria-metrics-single-server"}
cfg.Startup.CriticalServiceEndpointWaitSec = 1
cfg.Startup.CriticalServiceEndpointPollSec = 1
svc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"database":"ok"}`))
}))
defer svc.Close()
cfg.Startup.ServiceChecklist = []config.ServiceChecklistCheck{
{
Name: "grafana",
URL: svc.URL,
AcceptedStatuses: []int{200},
BodyContains: `"database":"ok"`,
TimeoutSeconds: 2,
},
}
recorder := &commandRecorder{}
dispatch := wrapperCoverageDispatcher(recorder)
orch := cluster.New(cfg, &execx.Runner{DryRun: false}, state.New(cfg.State.RunHistoryPath), log.New(io.Discard, "", 0))
orch.SetCommandOverrides(dispatch, dispatch)
ctx := context.Background()
if err := orch.TestHookWaitForPostStartProbes(ctx); err != nil {
t.Fatalf("wait for post-start probes: %v", err)
}
if _, _ = orch.TestHookPostStartProbesReady(ctx); true {
}
if _, err := orch.TestHookHTTPProbe(ctx, "https://metrics.bstein.dev/healthz"); err != nil {
t.Fatalf("http probe: %v", err)
}
if err := orch.TestHookResumeFluxAndReconcile(ctx); err != nil {
t.Fatalf("resume flux and reconcile: %v", err)
}
if _, err := orch.TestHookSSHWithTimeout(ctx, "titan-db", "echo ok", time.Second); err != nil {
t.Fatalf("ssh with timeout: %v", err)
}
if _, err := orch.TestHookRunSensitive(ctx, time.Second, "kubectl", "version", "--request-timeout=5s"); err != nil {
t.Fatalf("runSensitive wrapper: %v", err)
}
if err := orch.TestHookWaitForStartupConvergence(ctx); err != nil {
t.Fatalf("wait for startup convergence: %v", err)
}
if err := orch.TestHookWaitForServiceChecklist(ctx); err != nil {
t.Fatalf("wait for service checklist: %v", err)
}
if ok, detail := orch.TestHookServiceChecklistReady(ctx); !ok || detail == "" {
t.Fatalf("service checklist ready result unexpected: ok=%v detail=%q", ok, detail)
}
if ok, detail := orch.TestHookServiceCheckReady(ctx, cfg.Startup.ServiceChecklist[0]); !ok || detail == "" {
t.Fatalf("service check ready result unexpected: ok=%v detail=%q", ok, detail)
}
if status, body, err := orch.TestHookHTTPChecklistProbe(ctx, cfg.Startup.ServiceChecklist[0]); err != nil || status != 200 || body == "" {
t.Fatalf("http checklist probe unexpected result status=%d body=%q err=%v", status, body, err)
}
if err := orch.TestHookWaitForStabilityWindow(ctx); err != nil {
t.Fatalf("wait for stability window: %v", err)
}
if err := orch.TestHookStartupStabilityHealthy(ctx); err != nil {
t.Fatalf("startup stability healthy: %v", err)
}
cancelCtx, cancel := context.WithCancel(ctx)
cancel()
_ = orch.TestHookWaitForIngressChecklist(cancelCtx)
if _, _ = orch.TestHookIngressChecklistReady(ctx); true {
}
if hosts, err := orch.TestHookDiscoverIngressHosts(ctx); err != nil || len(hosts) == 0 {
t.Fatalf("discover ingress hosts failed: hosts=%v err=%v", hosts, err)
}
if namespaces, err := orch.TestHookDiscoverIngressNamespacesForHost(ctx, "metrics.bstein.dev"); err != nil || len(namespaces) == 0 {
t.Fatalf("discover ingress namespaces failed: ns=%v err=%v", namespaces, err)
}
last := time.Time{}
orch.TestHookMaybeAutoHealIngressHostBackends(ctx, &last, "metrics.bstein.dev: status=503")
if got := orch.TestHookChecklistFailureHost("metrics.bstein.dev: status=503"); got != "metrics.bstein.dev" {
t.Fatalf("unexpected checklistFailureHost parse: %q", got)
}
if got := cluster.TestHookHostFromURL("https://metrics.bstein.dev/api/health"); got != "metrics.bstein.dev" {
t.Fatalf("unexpected host parse: %q", got)
}
}
// TestHookOpsAndWorkloadWrappersCoverage runs one orchestration or CLI step.
// Signature: TestHookOpsAndWorkloadWrappersCoverage(t *testing.T).
// Why: executes ops/workload wrappers so all exposed hook files remain covered and regression-safe.
func TestHookOpsAndWorkloadWrappersCoverage(t *testing.T) {
cfg := lifecycleConfig(t)
cfg.Startup.WorkloadConvergenceWaitSeconds = 1
cfg.Startup.WorkloadConvergencePollSeconds = 1
cfg.Startup.CriticalServiceEndpointWaitSec = 1
cfg.Startup.CriticalServiceEndpointPollSec = 1
cfg.Startup.AutoRecycleStuckPods = true
cfg.Startup.StuckPodGraceSeconds = 1
svc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"status":"ok"}`))
}))
defer svc.Close()
cfg.Startup.ServiceChecklist = []config.ServiceChecklistCheck{
{Name: "grafana", URL: svc.URL, AcceptedStatuses: []int{200}, BodyContains: `"status":"ok"`, TimeoutSeconds: 2},
}
cfg.Startup.ServiceChecklistWaitSeconds = 1
cfg.Startup.ServiceChecklistPollSeconds = 1
recorder := &commandRecorder{}
dispatch := wrapperCoverageDispatcher(recorder)
orch := cluster.New(cfg, &execx.Runner{DryRun: false}, state.New(cfg.State.RunHistoryPath), log.New(io.Discard, "", 0))
orch.SetCommandOverrides(dispatch, dispatch)
ctx := context.Background()
if err := orch.TestHookDrainWorkers(ctx, []string{"titan-23"}); err != nil {
t.Fatalf("drain workers: %v", err)
}
if err := orch.TestHookUncordonWorkers(ctx, []string{"titan-23"}); err != nil {
t.Fatalf("uncordon workers: %v", err)
}
if err := orch.TestHookTakeEtcdSnapshot(ctx, "titan-db"); err != nil {
t.Fatalf("take etcd snapshot: %v", err)
}
if snapshot, err := orch.TestHookLatestEtcdSnapshotPath(ctx, "titan-db"); err != nil || snapshot == "" {
t.Fatalf("latest snapshot path failed: snapshot=%q err=%v", snapshot, err)
}
if workers, err := orch.TestHookEffectiveWorkers(ctx); err != nil || len(workers) == 0 {
t.Fatalf("effective workers failed: workers=%v err=%v", workers, err)
}
if workers, err := orch.TestHookDiscoverWorkers(ctx); err != nil || len(workers) == 0 {
t.Fatalf("discover workers failed: workers=%v err=%v", workers, err)
}
if err := orch.TestHookPatchFluxSuspendAll(ctx, true); err != nil {
t.Fatalf("patch flux suspend: %v", err)
}
if entries, err := orch.TestHookListScalableWorkloads(ctx); err != nil {
t.Fatalf("list scalable workloads: %v", err)
} else {
if err := orch.TestHookScaleWorkloads(ctx, entries, 0, 1); err != nil {
t.Fatalf("scale workloads forced: %v", err)
}
if err := orch.TestHookWriteScaledWorkloadSnapshot(entries); err != nil {
t.Fatalf("write scaled workload snapshot: %v", err)
}
if _, err := orch.TestHookReadScaledWorkloadSnapshot(); err != nil {
t.Fatalf("read scaled workload snapshot: %v", err)
}
}
if err := orch.TestHookScaleDownApps(ctx); err != nil {
t.Fatalf("scale down apps: %v", err)
}
if err := orch.TestHookRestoreScaledApps(ctx); err != nil {
t.Fatalf("restore scaled apps: %v", err)
}
if err := orch.TestHookWaitForCriticalServiceEndpoints(ctx); err != nil {
t.Fatalf("wait for critical service endpoints: %v", err)
}
if ok, _, _, _, err := orch.TestHookCriticalServiceEndpointsReady(ctx); err != nil || !ok {
t.Fatalf("critical endpoints ready failed: ok=%v err=%v", ok, err)
}
if err := orch.TestHookWaitForWorkloadConvergence(ctx); err != nil {
t.Fatalf("wait for workload convergence: %v", err)
}
if ok, detail, err := orch.TestHookWorkloadConvergenceReady(ctx); err != nil || !ok || detail == "" {
t.Fatalf("workload convergence ready failed: ok=%v detail=%q err=%v", ok, detail, err)
}
if err := orch.TestHookRecycleStuckControllerPods(ctx); err != nil {
t.Fatalf("recycle stuck controller pods: %v", err)
}
lastRecycle := time.Time{}
orch.TestHookMaybeAutoRecycleStuckPods(ctx, &lastRecycle)
lastHeal := time.Time{}
orch.TestHookMaybeAutoHealCriticalWorkloadReplicas(ctx, &lastHeal)
if _, err := orch.TestHookHealCriticalWorkloadReplicas(ctx); err != nil {
t.Fatalf("heal critical workload replicas: %v", err)
}
if _, err := orch.TestHookStartupFailurePods(ctx); err != nil {
t.Fatalf("startup failure pods: %v", err)
}
if err := orch.TestHookEnsureCriticalStartupWorkloads(ctx); err != nil {
t.Fatalf("ensure critical startup workloads: %v", err)
}
if _, err := orch.TestHookMissingCriticalStartupWorkloads(ctx); err != nil {
t.Fatalf("missing critical startup workloads: %v", err)
}
if err := orch.TestHookWaitForServiceChecklistAlias(ctx); err != nil {
t.Fatalf("wait for service checklist alias: %v", err)
}
}

View File

@ -0,0 +1,70 @@
package sshutilquality
import (
"bytes"
"context"
"errors"
"log"
"os"
"path/filepath"
"strings"
"testing"
"scm.bstein.dev/bstein/ananke/internal/sshutil"
)
// TestSSHHeuristicAdditionalBranches runs one orchestration or CLI step.
// Signature: TestSSHHeuristicAdditionalBranches(t *testing.T).
// Why: expands host-key heuristic coverage for marker-in-error and nil-error paths.
func TestSSHHeuristicAdditionalBranches(t *testing.T) {
if !sshutil.IsHostKeyError("", errors.New("REMOTE HOST IDENTIFICATION HAS CHANGED")) {
t.Fatalf("expected marker in error text to be detected")
}
if sshutil.ShouldAttemptKnownHostsRepair("", nil) {
t.Fatalf("expected nil error to skip repair")
}
}
// TestRepairKnownHostsNoopAndNilLogger runs one orchestration or CLI step.
// Signature: TestRepairKnownHostsNoopAndNilLogger(t *testing.T).
// Why: validates no-op and dedupe handling with nil logger and mixed file inputs.
func TestRepairKnownHostsNoopAndNilLogger(t *testing.T) {
dir := t.TempDir()
scriptPath := filepath.Join(dir, "ssh-keygen")
tracePath := filepath.Join(dir, "trace.log")
script := "#!/usr/bin/env sh\necho $* >>" + tracePath + "\nexit 0\n"
if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
t.Fatalf("write fake ssh-keygen: %v", err)
}
t.Setenv("PATH", dir+":"+os.Getenv("PATH"))
// No hosts means immediate no-op.
sshutil.RepairKnownHosts(context.Background(), nil, []string{"", " "}, []string{"", " "}, 22)
knownHosts := filepath.Join(dir, "known_hosts")
if err := os.WriteFile(knownHosts, []byte("seed"), 0o644); err != nil {
t.Fatalf("write known_hosts: %v", err)
}
dirEntry := filepath.Join(dir, "is-dir")
if err := os.MkdirAll(dirEntry, 0o755); err != nil {
t.Fatalf("mkdir dir entry: %v", err)
}
var logs bytes.Buffer
sshutil.RepairKnownHosts(
context.Background(),
log.New(&logs, "", 0),
[]string{knownHosts, knownHosts, dirEntry},
[]string{"titan-db", "titan-db", " "},
22,
)
trace, err := os.ReadFile(tracePath)
if err != nil {
t.Fatalf("read trace: %v", err)
}
traceText := string(trace)
if strings.Count(traceText, "titan-db") == 0 {
t.Fatalf("expected ssh-keygen to be called for deduped host, trace=%q", traceText)
}
}

View File

@ -0,0 +1,117 @@
package sshutilquality
import (
"bytes"
"context"
"errors"
"log"
"os"
"path/filepath"
"strings"
"testing"
"scm.bstein.dev/bstein/ananke/internal/sshutil"
)
// TestHostKeyHeuristics runs one orchestration or CLI step.
// Signature: TestHostKeyHeuristics(t *testing.T).
// Why: covers host-key error detection and repair-decision heuristics used by bootstrap SSH retries.
func TestHostKeyHeuristics(t *testing.T) {
if sshutil.IsHostKeyError("", nil) {
t.Fatalf("expected nil error to not be host-key related")
}
if !sshutil.IsHostKeyError("Host key verification failed", errors.New("ssh failure")) {
t.Fatalf("expected host-key verification text to be detected")
}
if !sshutil.ShouldAttemptKnownHostsRepair("", errors.New("exit status 255")) {
t.Fatalf("expected empty-output exit-255 branch to trigger known_hosts repair")
}
if sshutil.ShouldAttemptKnownHostsRepair("permission denied", errors.New("permission denied")) {
t.Fatalf("unexpected known_hosts repair suggestion for non host-key error")
}
}
// TestKnownHostsDiscoveryAndMissingBinaryBranch runs one orchestration or CLI step.
// Signature: TestKnownHostsDiscoveryAndMissingBinaryBranch(t *testing.T).
// Why: verifies known_hosts path discovery and the graceful no-op path when ssh-keygen is unavailable.
func TestKnownHostsDiscoveryAndMissingBinaryBranch(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "config")
keyPath := filepath.Join(dir, "id_ed25519")
if err := os.WriteFile(cfgPath, []byte("Host *\n"), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
if err := os.WriteFile(keyPath, []byte("key"), 0o600); err != nil {
t.Fatalf("write identity: %v", err)
}
files := sshutil.KnownHostsFiles(cfgPath, keyPath)
foundLocal := false
for _, path := range files {
if strings.Contains(path, dir) {
foundLocal = true
break
}
}
if !foundLocal {
t.Fatalf("expected derived known_hosts entries under %s, got %v", dir, files)
}
originalPath := os.Getenv("PATH")
t.Setenv("PATH", filepath.Join(dir, "missing-bin"))
var logs bytes.Buffer
sshutil.RepairKnownHosts(context.Background(), log.New(&logs, "", 0), files, []string{"titan-db"}, 22)
t.Setenv("PATH", originalPath)
if !strings.Contains(logs.String(), "ssh-keygen missing") {
t.Fatalf("expected missing ssh-keygen warning, got: %s", logs.String())
}
}
// TestRepairKnownHostsCoversSuccessNotFoundAndFailure runs one orchestration or CLI step.
// Signature: TestRepairKnownHostsCoversSuccessNotFoundAndFailure(t *testing.T).
// Why: exercises cleanup outcomes for successful removal, absent entries, and hard failures.
func TestRepairKnownHostsCoversSuccessNotFoundAndFailure(t *testing.T) {
dir := t.TempDir()
scriptPath := filepath.Join(dir, "ssh-keygen")
script := `#!/usr/bin/env sh
args="$*"
if echo "$args" | grep -q "notfound"; then
echo "Host notfound not found in $args" >&2
exit 1
fi
if echo "$args" | grep -q "boom"; then
echo "boom failure" >&2
exit 2
fi
echo "updated" >&2
exit 0
`
if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
t.Fatalf("write fake ssh-keygen: %v", err)
}
t.Setenv("PATH", dir+":"+os.Getenv("PATH"))
knownHosts := filepath.Join(dir, "known_hosts")
if err := os.WriteFile(knownHosts, []byte("example"), 0o644); err != nil {
t.Fatalf("write known_hosts: %v", err)
}
if err := os.WriteFile(knownHosts+".old", []byte("backup"), 0o644); err != nil {
t.Fatalf("write known_hosts backup: %v", err)
}
var logs bytes.Buffer
logger := log.New(&logs, "", 0)
sshutil.RepairKnownHosts(
context.Background(),
logger,
[]string{knownHosts, knownHosts},
[]string{"good", "notfound", "boom", "good"},
22,
)
out := logs.String()
if !strings.Contains(out, "known_hosts repaired: removed good") {
t.Fatalf("expected successful cleanup log, got: %s", out)
}
if !strings.Contains(out, "warning: known_hosts cleanup failed for boom") {
t.Fatalf("expected failure warning for boom host, got: %s", out)
}
}

View File

@ -0,0 +1,223 @@
package statequality
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"scm.bstein.dev/bstein/ananke/internal/state"
)
// TestStateIntentErrorAndParseBranches runs one orchestration or CLI step.
// Signature: TestStateIntentErrorAndParseBranches(t *testing.T).
// Why: covers non-happy-path intent branches that show up during interrupted drill recovery.
func TestStateIntentErrorAndParseBranches(t *testing.T) {
root := t.TempDir()
// ReadIntent should surface non-not-exist read errors.
if _, err := state.ReadIntent(root); err == nil {
t.Fatalf("expected read error when intent path is a directory")
}
// WriteIntent should fail when parent path cannot be created as a directory.
parentFile := filepath.Join(root, "parent-file")
if err := os.WriteFile(parentFile, []byte("x"), 0o600); err != nil {
t.Fatalf("write parent marker: %v", err)
}
if err := state.WriteIntent(filepath.Join(parentFile, "intent.json"), state.Intent{State: state.IntentNormal}); err == nil {
t.Fatalf("expected write failure when parent is not a directory")
}
ts := time.Now().UTC().Truncate(time.Second)
raw := "prefix text intent=startup_in_progress reason=\"manual\" source=peer updated_at=" + ts.Format(time.RFC3339)
in, err := state.ParseIntentOutput(raw)
if err != nil {
t.Fatalf("parse intent output: %v", err)
}
if in.State != state.IntentStartupInProgress || in.Reason != "manual" || in.Source != "peer" || !in.UpdatedAt.Equal(ts) {
t.Fatalf("unexpected parsed intent: %+v", in)
}
}
// TestStateStoreLockAndEstimatorBranches runs one orchestration or CLI step.
// Signature: TestStateStoreLockAndEstimatorBranches(t *testing.T).
// Why: exercises stale-lock error handling and estimator filtering behavior.
func TestStateStoreLockAndEstimatorBranches(t *testing.T) {
root := t.TempDir()
// AcquireLock should fail when lock parent cannot be created.
parentFile := filepath.Join(root, "lock-parent")
if err := os.WriteFile(parentFile, []byte("x"), 0o600); err != nil {
t.Fatalf("write lock parent marker: %v", err)
}
if _, err := state.AcquireLock(filepath.Join(parentFile, "ananke.lock")); err == nil {
t.Fatalf("expected acquire lock to fail when parent path is a file")
}
// AcquireLock should report stale-lock check failure when lock file cannot be read.
lockDir := filepath.Join(root, "locks")
if err := os.MkdirAll(lockDir, 0o755); err != nil {
t.Fatalf("mkdir lock dir: %v", err)
}
lockPath := filepath.Join(lockDir, "ananke.lock")
if err := os.WriteFile(lockPath, []byte("pid=999999 started=1970-01-01T00:00:00Z\n"), 0o000); err != nil {
t.Fatalf("write unreadable lock file: %v", err)
}
t.Cleanup(func() { _ = os.Chmod(lockPath, 0o600) })
if _, err := state.AcquireLock(lockPath); err == nil || !strings.Contains(err.Error(), "existing lock check failed") {
t.Fatalf("expected stale lock check failure, got %v", err)
}
storePath := filepath.Join(root, "runs.json")
st := state.New(storePath)
records := []state.RunRecord{
{ID: "a", Action: "shutdown", Reason: "ups-threshold alpha", StartedAt: time.Now().Add(-10 * time.Second), EndedAt: time.Now(), DurationSeconds: 40, Success: true},
{ID: "b", Action: "shutdown", Reason: "UPS-THRESHOLD beta", StartedAt: time.Now().Add(-10 * time.Second), EndedAt: time.Now(), DurationSeconds: 50, Success: true},
}
for _, rec := range records {
if err := st.Append(rec); err != nil {
t.Fatalf("append record: %v", err)
}
}
if got := st.ShutdownP95WithMinSamples(99, 0); got <= 0 {
t.Fatalf("expected computed p95 when minSamples<=0 branch is normalized, got %d", got)
}
if got := st.ShutdownP95ByReasonPrefix(77, 1, []string{" ups-threshold "}); got < 40 {
t.Fatalf("expected prefix-filtered p95 to include matching records, got %d", got)
}
}
// TestStateAutoHealFailurePath runs one orchestration or CLI step.
// Signature: TestStateAutoHealFailurePath(t *testing.T).
// Why: verifies corrupt-state auto-heal reports a useful error when filesystem permissions block quarantine.
func TestStateAutoHealFailurePath(t *testing.T) {
root := t.TempDir()
dir := filepath.Join(root, "readonly")
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatalf("mkdir readonly dir: %v", err)
}
path := filepath.Join(dir, "runs.json")
if err := os.WriteFile(path, []byte("{invalid-json"), 0o600); err != nil {
t.Fatalf("write corrupt runs file: %v", err)
}
if err := os.Chmod(dir, 0o500); err != nil {
t.Fatalf("chmod readonly dir: %v", err)
}
t.Cleanup(func() { _ = os.Chmod(dir, 0o700) })
_, err := state.New(path).Load()
if err == nil {
t.Fatalf("expected auto-heal failure when directory is not writable")
}
if !strings.Contains(err.Error(), "auto-heal failed") {
t.Fatalf("expected auto-heal failure detail, got %v", err)
}
}
// TestStateQuarantineHookBranches runs one orchestration or CLI step.
// Signature: TestStateQuarantineHookBranches(t *testing.T).
// Why: closes remaining corruption-heal branches so drill recovery file repair stays reliable.
func TestStateQuarantineHookBranches(t *testing.T) {
root := t.TempDir()
// Success path with backup+replacement writes.
path := filepath.Join(root, "ok", "intent.json")
if err := state.TestHookQuarantineCorruptFile(path, []byte("{bad"), []byte("{}\n"), 0o640); err != nil {
t.Fatalf("expected successful quarantine write, got %v", err)
}
matches, err := filepath.Glob(path + ".corrupt-*")
if err != nil {
t.Fatalf("glob corrupt backups: %v", err)
}
if len(matches) == 0 {
t.Fatalf("expected a .corrupt backup artifact")
}
// Parent is a file -> mkdir branch fails.
parentFile := filepath.Join(root, "parent-as-file")
if err := os.WriteFile(parentFile, []byte("x"), 0o600); err != nil {
t.Fatalf("write parent file: %v", err)
}
err = state.TestHookQuarantineCorruptFile(filepath.Join(parentFile, "intent.json"), []byte("bad"), []byte("{}\n"), 0o640)
if err == nil {
t.Fatalf("expected mkdir failure when parent is a file")
}
// Read-only directory forces backup write failure branch.
roDir := filepath.Join(root, "readonly")
if err := os.MkdirAll(roDir, 0o700); err != nil {
t.Fatalf("mkdir readonly dir: %v", err)
}
roPath := filepath.Join(roDir, "state.json")
if err := os.Chmod(roDir, 0o500); err != nil {
t.Fatalf("chmod readonly dir: %v", err)
}
t.Cleanup(func() { _ = os.Chmod(roDir, 0o700) })
err = state.TestHookQuarantineCorruptFile(roPath, []byte("bad"), []byte("{}\n"), 0o640)
if err == nil || !strings.Contains(err.Error(), "write backup") {
t.Fatalf("expected backup write failure in readonly directory, got %v", err)
}
}
// TestStateIntentParseAndWriteEdgeBranches runs one orchestration or CLI step.
// Signature: TestStateIntentParseAndWriteEdgeBranches(t *testing.T).
// Why: exercises parser and write edge paths that can otherwise hide intent-state drift.
func TestStateIntentParseAndWriteEdgeBranches(t *testing.T) {
root := t.TempDir()
// Parse branch where reason quote is malformed but token still parses.
parsed, err := state.ParseIntentOutput(`intent=startup_in_progress reason="unterminated source=peer`)
if err != nil {
t.Fatalf("parse with malformed reason quote: %v", err)
}
if parsed.State != state.IntentStartupInProgress {
t.Fatalf("unexpected parsed state: %+v", parsed)
}
// Parse branch with blank state token should normalize to zero intent.
blank, err := state.ParseIntentOutput(`intent= source=peer`)
if err != nil {
t.Fatalf("parse blank intent token: %v", err)
}
if blank != (state.Intent{}) {
t.Fatalf("expected zero intent for blank state token, got %+v", blank)
}
// WriteIntent branch with parent path not directory.
parentFile := filepath.Join(root, "bad-parent")
if err := os.WriteFile(parentFile, []byte("x"), 0o600); err != nil {
t.Fatalf("write bad parent marker: %v", err)
}
if err := state.WriteIntent(filepath.Join(parentFile, "intent.json"), state.Intent{State: state.IntentNormal}); err == nil {
t.Fatalf("expected WriteIntent error for invalid parent path")
}
}
// TestStateStoreAppendAndAcquireBranches runs one orchestration or CLI step.
// Signature: TestStateStoreAppendAndAcquireBranches(t *testing.T).
// Why: covers append error and active-lock branches used by CLI locking semantics.
func TestStateStoreAppendAndAcquireBranches(t *testing.T) {
root := t.TempDir()
// Append should fail when parent path cannot be created as a directory.
parentFile := filepath.Join(root, "runs-parent")
if err := os.WriteFile(parentFile, []byte("x"), 0o600); err != nil {
t.Fatalf("write append parent marker: %v", err)
}
st := state.New(filepath.Join(parentFile, "runs.json"))
if err := st.Append(state.RunRecord{ID: "x", Action: "shutdown", StartedAt: time.Now(), EndedAt: time.Now(), DurationSeconds: 1, Success: true}); err == nil {
t.Fatalf("expected append error when parent path is not a directory")
}
// AcquireLock active process branch: pid=1 should be treated as active on Linux.
lockPath := filepath.Join(root, "active.lock")
if err := os.WriteFile(lockPath, []byte("pid=1 started=1970-01-01T00:00:00Z\n"), 0o600); err != nil {
t.Fatalf("write active lock: %v", err)
}
if _, err := state.AcquireLock(lockPath); err == nil || !strings.Contains(err.Error(), "active process") {
t.Fatalf("expected active lock rejection, got %v", err)
}
}

View File

@ -0,0 +1,141 @@
package statequality
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"scm.bstein.dev/bstein/ananke/internal/state"
)
// TestStateEnsureDirAndWriteIntentBranchCoverage runs one orchestration or CLI step.
// Signature: TestStateEnsureDirAndWriteIntentBranchCoverage(t *testing.T).
// Why: closes remaining success-branch gaps in EnsureDir and WriteIntent timestamp preservation.
func TestStateEnsureDirAndWriteIntentBranchCoverage(t *testing.T) {
root := t.TempDir()
dir := filepath.Join(root, "state")
if err := state.EnsureDir(dir); err != nil {
t.Fatalf("EnsureDir success path failed: %v", err)
}
if stat, err := os.Stat(dir); err != nil || !stat.IsDir() {
t.Fatalf("expected ensured state dir to exist, stat err=%v", err)
}
intentPath := filepath.Join(dir, "intent.json")
ts := time.Now().UTC().Add(-time.Minute).Truncate(time.Second)
if err := state.WriteIntent(intentPath, state.Intent{
State: state.IntentNormal,
Reason: "preserve-updated-at",
Source: "test",
UpdatedAt: ts,
}); err != nil {
t.Fatalf("WriteIntent with explicit UpdatedAt failed: %v", err)
}
got, err := state.ReadIntent(intentPath)
if err != nil {
t.Fatalf("ReadIntent after explicit UpdatedAt write failed: %v", err)
}
if !got.UpdatedAt.Equal(ts) {
t.Fatalf("expected UpdatedAt to remain unchanged; got=%s want=%s", got.UpdatedAt, ts)
}
}
// TestStateStoreLockAndLoadBranchCoverage runs one orchestration or CLI step.
// Signature: TestStateStoreLockAndLoadBranchCoverage(t *testing.T).
// Why: closes remaining lock/load branch gaps used by daemon and CLI quality gates.
func TestStateStoreLockAndLoadBranchCoverage(t *testing.T) {
root := t.TempDir()
lockPath := filepath.Join(root, "ananke.lock")
unlock, err := state.AcquireLock(lockPath)
if err != nil {
t.Fatalf("AcquireLock fresh lock failed: %v", err)
}
unlock()
storePath := filepath.Join(root, "runs.json")
if err := os.WriteFile(storePath, []byte{}, 0o640); err != nil {
t.Fatalf("write empty store file: %v", err)
}
recs, err := state.New(storePath).Load()
if err != nil {
t.Fatalf("Load empty store file failed: %v", err)
}
if len(recs) != 0 {
t.Fatalf("expected zero records from empty store file, got %d", len(recs))
}
}
// TestStateIntentParserAdditionalEdges runs one orchestration or CLI step.
// Signature: TestStateIntentParserAdditionalEdges(t *testing.T).
// Why: covers parser edge branches where source and updated_at tokens are absent/blank.
func TestStateIntentParserAdditionalEdges(t *testing.T) {
afterBlank, err := state.ParseIntentOutput("\nintent=normal source=peer")
if err != nil {
t.Fatalf("ParseIntentOutput with leading blank line failed: %v", err)
}
if afterBlank.State != state.IntentNormal {
t.Fatalf("unexpected parsed state after blank line: %+v", afterBlank)
}
in, err := state.ParseIntentOutput("intent=normal source=peer updated_at=")
if err != nil {
t.Fatalf("ParseIntentOutput with blank updated_at failed: %v", err)
}
if in.State != state.IntentNormal || in.Source != "peer" {
t.Fatalf("unexpected parsed intent payload: %+v", in)
}
withPrefix, err := state.ParseIntentOutput("status=ok intent=shutdown_complete reason=\"done\" source=daemon")
if err != nil {
t.Fatalf("ParseIntentOutput with prefixed tokens failed: %v", err)
}
if withPrefix.State != state.IntentShutdownComplete || withPrefix.Reason != "done" || withPrefix.Source != "daemon" {
t.Fatalf("unexpected prefixed parsed intent payload: %+v", withPrefix)
}
}
// TestStateIntentAutoHealFailureBranch runs one orchestration or CLI step.
// Signature: TestStateIntentAutoHealFailureBranch(t *testing.T).
// Why: covers ReadIntent branch where corrupt file quarantine fails and error details are returned.
func TestStateIntentAutoHealFailureBranch(t *testing.T) {
root := t.TempDir()
dir := filepath.Join(root, "ro")
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatalf("mkdir readonly dir: %v", err)
}
path := filepath.Join(dir, "intent.json")
if err := os.WriteFile(path, []byte("{bad-json"), 0o600); err != nil {
t.Fatalf("write malformed intent file: %v", err)
}
if err := os.Chmod(dir, 0o500); err != nil {
t.Fatalf("chmod readonly dir: %v", err)
}
t.Cleanup(func() { _ = os.Chmod(dir, 0o700) })
_, err := state.ReadIntent(path)
if err == nil {
t.Fatalf("expected ReadIntent auto-heal failure for readonly dir")
}
if !strings.Contains(err.Error(), "auto-heal failed") {
t.Fatalf("expected auto-heal failure context, got %v", err)
}
}
// TestStateAcquireLockReclaimsNoPidLock runs one orchestration or CLI step.
// Signature: TestStateAcquireLockReclaimsNoPidLock(t *testing.T).
// Why: covers stale-lock parsing branch where lock file contains no pid line.
func TestStateAcquireLockReclaimsNoPidLock(t *testing.T) {
root := t.TempDir()
lockPath := filepath.Join(root, "ananke.lock")
if err := os.WriteFile(lockPath, []byte("started=2020-01-01T00:00:00Z\n"), 0o600); err != nil {
t.Fatalf("write no-pid lock: %v", err)
}
unlock, err := state.AcquireLock(lockPath)
if err != nil {
t.Fatalf("expected lock reclaim for no-pid lock, got %v", err)
}
unlock()
}

View File

@ -0,0 +1,200 @@
package statequality
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"scm.bstein.dev/bstein/ananke/internal/state"
)
// TestEnsureDirAndIntentIOContracts runs one orchestration or CLI step.
// Signature: TestEnsureDirAndIntentIOContracts(t *testing.T).
// Why: exercises directory and intent IO edges that appear during interrupted drills.
func TestEnsureDirAndIntentIOContracts(t *testing.T) {
if err := state.EnsureDir(""); err == nil {
t.Fatalf("expected empty dir rejection")
}
root := t.TempDir()
intentPath := filepath.Join(root, "intent", "intent.json")
in := state.Intent{
State: state.IntentStartupInProgress,
Reason: "drill",
Source: "test",
}
if err := state.WriteIntent(intentPath, in); err != nil {
t.Fatalf("write intent: %v", err)
}
got, err := state.ReadIntent(intentPath)
if err != nil {
t.Fatalf("read intent: %v", err)
}
if got.State != in.State || got.Reason != in.Reason || got.Source != in.Source {
t.Fatalf("unexpected intent readback: %+v", got)
}
if got.UpdatedAt.IsZero() {
t.Fatalf("expected UpdatedAt to be auto-filled")
}
missingIntent, err := state.ReadIntent(filepath.Join(root, "missing.json"))
if err != nil {
t.Fatalf("read missing intent: %v", err)
}
if missingIntent != (state.Intent{}) {
t.Fatalf("expected zero intent for missing file, got %+v", missingIntent)
}
emptyPath := filepath.Join(root, "empty-intent.json")
if err := os.WriteFile(emptyPath, nil, 0o644); err != nil {
t.Fatalf("write empty intent: %v", err)
}
emptyIntent, err := state.ReadIntent(emptyPath)
if err != nil {
t.Fatalf("read empty intent: %v", err)
}
if emptyIntent != (state.Intent{}) {
t.Fatalf("expected zero intent for empty file, got %+v", emptyIntent)
}
}
// TestIntentReadAutoHealAndParseIntentOutput runs one orchestration or CLI step.
// Signature: TestIntentReadAutoHealAndParseIntentOutput(t *testing.T).
// Why: validates corruption auto-heal behavior and CLI intent output parser edge handling.
func TestIntentReadAutoHealAndParseIntentOutput(t *testing.T) {
root := t.TempDir()
intentPath := filepath.Join(root, "intent.json")
if err := os.WriteFile(intentPath, []byte(`{"state":"broken"`), 0o644); err != nil {
t.Fatalf("write malformed intent: %v", err)
}
parsed, err := state.ReadIntent(intentPath)
if err != nil {
t.Fatalf("read malformed intent should auto-heal: %v", err)
}
if parsed != (state.Intent{}) {
t.Fatalf("expected healed malformed intent to return zero value, got %+v", parsed)
}
corrupt, err := filepath.Glob(filepath.Join(root, "intent.json.corrupt-*"))
if err != nil {
t.Fatalf("glob corrupt files: %v", err)
}
if len(corrupt) == 0 {
t.Fatalf("expected a quarantined corrupt intent artifact")
}
if _, err := state.ParseIntentOutput("status: ok"); err == nil {
t.Fatalf("expected parse error when intent token is missing")
}
if in, err := state.ParseIntentOutput(`intent=none reason="ignored"`); err != nil || in != (state.Intent{}) {
t.Fatalf("expected none intent to parse as zero value, got %+v err=%v", in, err)
}
_, err = state.ParseIntentOutput(`intent=normal updated_at=not-a-timestamp`)
if err == nil {
t.Fatalf("expected invalid updated_at parse failure")
}
}
// TestLockingAndStoreHistoryContracts runs one orchestration or CLI step.
// Signature: TestLockingAndStoreHistoryContracts(t *testing.T).
// Why: covers lock lifecycle, stale lock recovery, history trimming, and P95 calculations.
func TestLockingAndStoreHistoryContracts(t *testing.T) {
root := t.TempDir()
lockPath := filepath.Join(root, "ananke.lock")
// Active lock should reject acquisition.
active := "pid=" + strconvI(os.Getpid()) + " started=" + time.Now().UTC().Format(time.RFC3339) + "\n"
if err := os.WriteFile(lockPath, []byte(active), 0o600); err != nil {
t.Fatalf("write active lock: %v", err)
}
if _, err := state.AcquireLock(lockPath); err == nil {
t.Fatalf("expected active lock rejection")
}
// Malformed/stale lock should be reclaimed.
if err := os.WriteFile(lockPath, []byte("pid=not-a-number\n"), 0o600); err != nil {
t.Fatalf("write malformed lock: %v", err)
}
unlock, err := state.AcquireLock(lockPath)
if err != nil {
t.Fatalf("acquire reclaimed lock: %v", err)
}
unlock()
if _, err := os.Stat(lockPath); !os.IsNotExist(err) {
t.Fatalf("expected lock file cleanup after unlock, stat err=%v", err)
}
storePath := filepath.Join(root, "runs.json")
st := state.New(storePath)
for i := 0; i < 205; i++ {
rec := state.RunRecord{
ID: "run-" + strconvI(i),
Action: "shutdown",
StartedAt: time.Now().UTC().Add(-time.Duration(i+1) * time.Second),
EndedAt: time.Now().UTC(),
DurationSeconds: i + 1,
Success: true,
}
if err := st.Append(rec); err != nil {
t.Fatalf("append record %d: %v", i, err)
}
}
records, err := st.Load()
if err != nil {
t.Fatalf("load records: %v", err)
}
if len(records) != 200 {
t.Fatalf("expected history trim to 200 records, got %d", len(records))
}
if got := st.ShutdownP95(30); got <= 30 {
t.Fatalf("expected computed p95 above default, got %d", got)
}
if got := st.ShutdownP95ByReasonPrefix(77, 3, []string{"missing-prefix"}); got != 77 {
t.Fatalf("expected default fallback when no reason matches, got %d", got)
}
// Corrupt run history should be auto-healed to an empty list.
if err := os.WriteFile(storePath, []byte("{bad-json"), 0o640); err != nil {
t.Fatalf("write corrupt history: %v", err)
}
recovered, err := st.Load()
if err != nil {
t.Fatalf("load corrupt history should auto-heal: %v", err)
}
if len(recovered) != 0 {
t.Fatalf("expected healed corrupt history to return empty list, got %d", len(recovered))
}
// Directory path triggers read error; shutdown estimator should fall back to default.
dirAsFile := filepath.Join(root, "history-dir")
if err := os.MkdirAll(dirAsFile, 0o755); err != nil {
t.Fatalf("mkdir history-dir: %v", err)
}
brokenStore := state.New(dirAsFile)
if got := brokenStore.ShutdownP95(123); got != 123 {
t.Fatalf("expected default on read error, got %d", got)
}
}
// TestMustWriteIntentValidation runs one orchestration or CLI step.
// Signature: TestMustWriteIntentValidation(t *testing.T).
// Why: covers strict intent-state validation guarantees for operator CLI writes.
func TestMustWriteIntentValidation(t *testing.T) {
path := filepath.Join(t.TempDir(), "intent.json")
if err := state.MustWriteIntent(path, state.IntentShutdownComplete, "done", "test"); err != nil {
t.Fatalf("expected valid intent write, got %v", err)
}
err := state.MustWriteIntent(path, "unknown-state", "nope", "test")
if err == nil || !strings.Contains(err.Error(), "invalid intent state") {
t.Fatalf("expected invalid intent state error, got %v", err)
}
}
// strconvI runs one orchestration or CLI step.
// Signature: strconvI(v int) string.
// Why: avoids importing strconv repeatedly in compact coverage-focused tests.
func strconvI(v int) string {
return fmt.Sprintf("%d", v)
}

View File

@ -0,0 +1,82 @@
package upsquality
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"scm.bstein.dev/bstein/ananke/internal/ups"
)
// TestNUTProviderReadCommandFailure runs one orchestration or CLI step.
// Signature: TestNUTProviderReadCommandFailure(t *testing.T).
// Why: ensures upsc execution failures are surfaced with target context.
func TestNUTProviderReadCommandFailure(t *testing.T) {
dir := t.TempDir()
upsc := filepath.Join(dir, "upsc")
script := "#!/usr/bin/env sh\necho boom >&2\nexit 7\n"
if err := os.WriteFile(upsc, []byte(script), 0o755); err != nil {
t.Fatalf("write fake upsc: %v", err)
}
t.Setenv("PATH", dir+":"+os.Getenv("PATH"))
p := ups.NewNUTProvider("statera@localhost")
_, err := p.Read(context.Background())
if err == nil || !strings.Contains(err.Error(), "upsc statera@localhost") {
t.Fatalf("expected contextual upsc failure, got %v", err)
}
}
// TestNUTProviderReadCoversNumericParseFailureBranch runs one orchestration or CLI step.
// Signature: TestNUTProviderReadCoversNumericParseFailureBranch(t *testing.T).
// Why: exercises parseFloat failure branch after symbol stripping.
func TestNUTProviderReadCoversNumericParseFailureBranch(t *testing.T) {
dir := t.TempDir()
upsc := filepath.Join(dir, "upsc")
script := `#!/usr/bin/env sh
cat <<'EOF'
ups.status: OB
battery.runtime: 10
battery.charge: ++
ups.load: --
ups.realpower.nominal: ..
EOF
`
if err := os.WriteFile(upsc, []byte(script), 0o755); err != nil {
t.Fatalf("write fake upsc: %v", err)
}
t.Setenv("PATH", dir+":"+os.Getenv("PATH"))
p := ups.NewNUTProvider("pyrphoros@localhost")
sample, err := p.Read(context.Background())
if err != nil {
t.Fatalf("read sample: %v", err)
}
if !sample.OnBattery {
t.Fatalf("expected OB status to set OnBattery, got %+v", sample)
}
if sample.BatteryCharge != 0 || sample.LoadPercent != 0 || sample.NominalPowerW != 0 {
t.Fatalf("expected parse failures to leave numeric fields at zero, got %+v", sample)
}
}
// TestNUTProviderReadScannerTooLongError runs one orchestration or CLI step.
// Signature: TestNUTProviderReadScannerTooLongError(t *testing.T).
// Why: covers scanner error handling branch for malformed oversized NUT output lines.
func TestNUTProviderReadScannerTooLongError(t *testing.T) {
dir := t.TempDir()
upsc := filepath.Join(dir, "upsc")
long := strings.Repeat("x", 80*1024)
script := "#!/usr/bin/env sh\nprintf '%s\\n' '" + long + "'\n"
if err := os.WriteFile(upsc, []byte(script), 0o755); err != nil {
t.Fatalf("write fake upsc: %v", err)
}
t.Setenv("PATH", dir+":"+os.Getenv("PATH"))
p := ups.NewNUTProvider("oversize@localhost")
if _, err := p.Read(context.Background()); err == nil {
t.Fatalf("expected scanner token-size parse error")
}
}

View File

@ -0,0 +1,112 @@
package upsquality
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"scm.bstein.dev/bstein/ananke/internal/ups"
)
// TestNUTProviderReadRejectsEmptyTarget runs one orchestration or CLI step.
// Signature: TestNUTProviderReadRejectsEmptyTarget(t *testing.T).
// Why: validates that provider configuration errors fail fast before shelling out.
func TestNUTProviderReadRejectsEmptyTarget(t *testing.T) {
p := ups.NewNUTProvider("")
_, err := p.Read(context.Background())
if err == nil || !strings.Contains(err.Error(), "must not be empty") {
t.Fatalf("expected empty target validation error, got %v", err)
}
}
// TestNUTProviderReadParsesNominalSample runs one orchestration or CLI step.
// Signature: TestNUTProviderReadParsesNominalSample(t *testing.T).
// Why: covers normal NUT parsing for runtime, charge, load, and nominal power fields.
func TestNUTProviderReadParsesNominalSample(t *testing.T) {
dir := t.TempDir()
upsc := filepath.Join(dir, "upsc")
script := `#!/usr/bin/env sh
cat <<'EOF'
ups.status: OB LB
battery.runtime: 812.0
battery.charge: 72.5
ups.load: 41
ups.realpower.nominal: 900
EOF
`
if err := os.WriteFile(upsc, []byte(script), 0o755); err != nil {
t.Fatalf("write fake upsc: %v", err)
}
t.Setenv("PATH", dir+":"+os.Getenv("PATH"))
p := ups.NewNUTProvider("statera@localhost")
sample, err := p.Read(context.Background())
if err != nil {
t.Fatalf("read sample: %v", err)
}
if !sample.OnBattery || !sample.LowBattery {
t.Fatalf("expected OB+LB status, got %+v", sample)
}
if sample.RuntimeSeconds != 812 || sample.BatteryCharge != 72.5 || sample.LoadPercent != 41 || sample.NominalPowerW != 900 {
t.Fatalf("unexpected parsed values: %+v", sample)
}
}
// TestNUTProviderReadHandlesMissingStatusAndNoisyNumbers runs one orchestration or CLI step.
// Signature: TestNUTProviderReadHandlesMissingStatusAndNoisyNumbers(t *testing.T).
// Why: covers parse failure paths for missing status and non-numeric field content.
func TestNUTProviderReadHandlesMissingStatusAndNoisyNumbers(t *testing.T) {
dir := t.TempDir()
upsc := filepath.Join(dir, "upsc")
// First script invocation: missing ups.status should fail.
missingStatus := `#!/usr/bin/env sh
cat <<'EOF'
battery.runtime: 120
battery.charge: n/a
ups.load: unknown
EOF
`
if err := os.WriteFile(upsc, []byte(missingStatus), 0o755); err != nil {
t.Fatalf("write fake upsc: %v", err)
}
t.Setenv("PATH", dir+":"+os.Getenv("PATH"))
p := ups.NewNUTProvider("pyrphoros@localhost")
if _, err := p.Read(context.Background()); err == nil || !strings.Contains(err.Error(), "ups.status missing") {
t.Fatalf("expected missing status error, got %v", err)
}
// Second script invocation: noisy numbers should parse to zeros where invalid.
noisyNumbers := `#!/usr/bin/env sh
cat <<'EOF'
ups.status: OL
battery.runtime: 61.4
battery.charge: --not-a-number--
ups.load: 34%
ups.realpower.nominal: ###watts###
EOF
`
if err := os.WriteFile(upsc, []byte(noisyNumbers), 0o755); err != nil {
t.Fatalf("rewrite fake upsc: %v", err)
}
sample, err := p.Read(context.Background())
if err != nil {
t.Fatalf("read noisy sample: %v", err)
}
if sample.OnBattery {
t.Fatalf("expected OL to clear OnBattery, got %+v", sample)
}
if sample.RuntimeSeconds != 61 {
t.Fatalf("expected runtime integer parse from decimal, got %d", sample.RuntimeSeconds)
}
if sample.BatteryCharge != 0 {
t.Fatalf("expected invalid charge to parse as 0, got %f", sample.BatteryCharge)
}
if sample.LoadPercent != 34 {
t.Fatalf("expected percentage load parse, got %f", sample.LoadPercent)
}
if sample.NominalPowerW != 0 {
t.Fatalf("expected invalid nominal power to parse as 0, got %f", sample.NominalPowerW)
}
}