build: reconcile split modules and restore clean checkout integrity
This commit is contained in:
parent
95fefba244
commit
baead1426e
259
cmd/ananke/bootstrap_handoff.go
Normal file
259
cmd/ananke/bootstrap_handoff.go
Normal 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
|
||||
}
|
||||
360
cmd/ananke/bootstrap_handoff_additional_test.go
Normal file
360
cmd/ananke/bootstrap_handoff_additional_test.go
Normal 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
|
||||
}
|
||||
105
cmd/ananke/bootstrap_handoff_test.go
Normal file
105
cmd/ananke/bootstrap_handoff_test.go
Normal 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
34
cmd/ananke/builder.go
Normal 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
118
cmd/ananke/builder_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
267
cmd/ananke/command_additional_test.go
Normal file
267
cmd/ananke/command_additional_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
420
cmd/ananke/command_handlers.go
Normal file
420
cmd/ananke/command_handlers.go
Normal 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
|
||||
}
|
||||
487
cmd/ananke/command_handlers_injection_test.go
Normal file
487
cmd/ananke/command_handlers_injection_test.go
Normal 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
|
||||
}
|
||||
244
cmd/ananke/command_handlers_status_error_test.go
Normal file
244
cmd/ananke/command_handlers_status_error_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
79
cmd/ananke/command_handlers_test.go
Normal file
79
cmd/ananke/command_handlers_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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
105
cmd/ananke/main_dispatch.go
Normal 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.
|
||||
`)
|
||||
}
|
||||
157
cmd/ananke/main_dispatch_additional_test.go
Normal file
157
cmd/ananke/main_dispatch_additional_test.go
Normal 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
|
||||
}
|
||||
}
|
||||
52
cmd/ananke/main_dispatch_test.go
Normal file
52
cmd/ananke/main_dispatch_test.go
Normal 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
127
cmd/ananke/power_safety.go
Normal 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):
|
||||
}
|
||||
}
|
||||
}
|
||||
200
cmd/ananke/power_safety_test.go
Normal file
200
cmd/ananke/power_safety_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
47
cmd/ananke/test_helpers_test.go
Normal file
47
cmd/ananke/test_helpers_test.go
Normal 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
463
internal/cluster/orchestrator_access_fluxsource.go
Normal file
463
internal/cluster/orchestrator_access_fluxsource.go
Normal 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):
|
||||
}
|
||||
}
|
||||
}
|
||||
275
internal/cluster/orchestrator_coordination.go
Normal file
275
internal/cluster/orchestrator_coordination.go
Normal 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")
|
||||
}
|
||||
172
internal/cluster/orchestrator_critical_endpoints.go
Normal file
172
internal/cluster/orchestrator_critical_endpoints.go
Normal 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
|
||||
}
|
||||
471
internal/cluster/orchestrator_critical_vault.go
Normal file
471
internal/cluster/orchestrator_critical_vault.go
Normal 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)")
|
||||
}
|
||||
241
internal/cluster/orchestrator_drain.go
Normal file
241
internal/cluster/orchestrator_drain.go
Normal 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
|
||||
}
|
||||
266
internal/cluster/orchestrator_fluxhealth.go
Normal file
266
internal/cluster/orchestrator_fluxhealth.go
Normal 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
|
||||
}
|
||||
344
internal/cluster/orchestrator_ingress.go
Normal file
344
internal/cluster/orchestrator_ingress.go
Normal 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())
|
||||
}
|
||||
189
internal/cluster/orchestrator_inventory_test.go
Normal file
189
internal/cluster/orchestrator_inventory_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
493
internal/cluster/orchestrator_lifecycle.go
Normal file
493
internal/cluster/orchestrator_lifecycle.go
Normal 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)
|
||||
}
|
||||
}
|
||||
109
internal/cluster/orchestrator_node_reachability.go
Normal file
109
internal/cluster/orchestrator_node_reachability.go
Normal 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):
|
||||
}
|
||||
}
|
||||
}
|
||||
381
internal/cluster/orchestrator_poststart_exec.go
Normal file
381
internal/cluster/orchestrator_poststart_exec.go
Normal 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 ""
|
||||
}
|
||||
355
internal/cluster/orchestrator_report.go
Normal file
355
internal/cluster/orchestrator_report.go
Normal 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)
|
||||
}
|
||||
230
internal/cluster/orchestrator_report_test.go
Normal file
230
internal/cluster/orchestrator_report_test.go
Normal 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
|
||||
}
|
||||
379
internal/cluster/orchestrator_scaling.go
Normal file
379
internal/cluster/orchestrator_scaling.go
Normal 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
|
||||
}
|
||||
26
internal/cluster/orchestrator_snapshot_parse.go
Normal file
26
internal/cluster/orchestrator_snapshot_parse.go
Normal 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 ""
|
||||
}
|
||||
275
internal/cluster/orchestrator_storage_types.go
Normal file
275
internal/cluster/orchestrator_storage_types.go
Normal 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
|
||||
}
|
||||
346
internal/cluster/orchestrator_timesync_inventory.go
Normal file
346
internal/cluster/orchestrator_timesync_inventory.go
Normal 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
|
||||
}
|
||||
483
internal/cluster/orchestrator_unit_additional_test.go
Normal file
483
internal/cluster/orchestrator_unit_additional_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
272
internal/cluster/orchestrator_workload_convergence.go
Normal file
272
internal/cluster/orchestrator_workload_convergence.go
Normal 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 ""
|
||||
}
|
||||
321
internal/cluster/orchestrator_workload_ignore.go
Normal file
321
internal/cluster/orchestrator_workload_ignore.go
Normal 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
|
||||
}
|
||||
336
internal/cluster/testing_hooks.go
Normal file
336
internal/cluster/testing_hooks.go
Normal 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)
|
||||
}
|
||||
204
internal/cluster/testing_hooks_lifecycle.go
Normal file
204
internal/cluster/testing_hooks_lifecycle.go
Normal 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()
|
||||
}
|
||||
143
internal/cluster/testing_hooks_ops.go
Normal file
143
internal/cluster/testing_hooks_ops.go
Normal 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)
|
||||
}
|
||||
158
internal/cluster/testing_hooks_report.go
Normal file
158
internal/cluster/testing_hooks_report.go
Normal 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
|
||||
}
|
||||
462
internal/cluster/testing_hooks_workloads.go
Normal file
462
internal/cluster/testing_hooks_workloads.go
Normal 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
|
||||
}
|
||||
@ -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
29
internal/config/load.go
Normal 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
|
||||
}
|
||||
53
internal/config/load_additional_test.go
Normal file
53
internal/config/load_additional_test.go
Normal 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
294
internal/config/validate.go
Normal 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
|
||||
}
|
||||
147
internal/config/validate_matrix_test.go
Normal file
147
internal/config/validate_matrix_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
177
testing/coverage/coverage_test.go
Normal file
177
testing/coverage/coverage_test.go
Normal 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
9
testing/go.mod
Normal 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
4
testing/go.sum
Normal 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=
|
||||
139
testing/hygiene/hygiene_test.go
Normal file
139
testing/hygiene/hygiene_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
249
testing/orchestrator/drill_lifecycle_branch_saturation_test.go
Normal file
249
testing/orchestrator/drill_lifecycle_branch_saturation_test.go
Normal 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)
|
||||
}
|
||||
374
testing/orchestrator/drill_lifecycle_test.go
Normal file
374
testing/orchestrator/drill_lifecycle_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
236
testing/orchestrator/drill_recovery_test.go
Normal file
236
testing/orchestrator/drill_recovery_test.go
Normal 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)
|
||||
}
|
||||
699
testing/orchestrator/drill_startup_paths_test.go
Normal file
699
testing/orchestrator/drill_startup_paths_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
243
testing/orchestrator/hooks_access_coordination_test.go
Normal file
243
testing/orchestrator/hooks_access_coordination_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
194
testing/orchestrator/hooks_access_deep_matrix_test.go
Normal file
194
testing/orchestrator/hooks_access_deep_matrix_test.go
Normal 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
366
testing/orchestrator/hooks_access_failure_matrix_test.go
Normal file
366
testing/orchestrator/hooks_access_failure_matrix_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
293
testing/orchestrator/hooks_branch_closeout_test.go
Normal file
293
testing/orchestrator/hooks_branch_closeout_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
509
testing/orchestrator/hooks_branch_saturation_test.go
Normal file
509
testing/orchestrator/hooks_branch_saturation_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
170
testing/orchestrator/hooks_flux_workload_matrix_test.go
Normal file
170
testing/orchestrator/hooks_flux_workload_matrix_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
224
testing/orchestrator/hooks_gap_matrix_part1_test.go
Normal file
224
testing/orchestrator/hooks_gap_matrix_part1_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
358
testing/orchestrator/hooks_gap_matrix_part2_test.go
Normal file
358
testing/orchestrator/hooks_gap_matrix_part2_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
174
testing/orchestrator/hooks_gap_matrix_part3_test.go
Normal file
174
testing/orchestrator/hooks_gap_matrix_part3_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
449
testing/orchestrator/hooks_gap_matrix_part4_test.go
Normal file
449
testing/orchestrator/hooks_gap_matrix_part4_test.go
Normal 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
498
testing/orchestrator/hooks_gap_matrix_part5_test.go
Normal file
498
testing/orchestrator/hooks_gap_matrix_part5_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
313
testing/orchestrator/hooks_gap_matrix_part6_test.go
Normal file
313
testing/orchestrator/hooks_gap_matrix_part6_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
240
testing/orchestrator/hooks_gap_matrix_part7_test.go
Normal file
240
testing/orchestrator/hooks_gap_matrix_part7_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
410
testing/orchestrator/hooks_gap_matrix_part8_test.go
Normal file
410
testing/orchestrator/hooks_gap_matrix_part8_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
294
testing/orchestrator/hooks_gap_matrix_part9_test.go
Normal file
294
testing/orchestrator/hooks_gap_matrix_part9_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
338
testing/orchestrator/hooks_health_failure_matrix_test.go
Normal file
338
testing/orchestrator/hooks_health_failure_matrix_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
235
testing/orchestrator/hooks_health_storage_test.go
Normal file
235
testing/orchestrator/hooks_health_storage_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
229
testing/orchestrator/hooks_ingress_service_matrix_test.go
Normal file
229
testing/orchestrator/hooks_ingress_service_matrix_test.go
Normal 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
372
testing/orchestrator/hooks_lifecycle_deep_matrix_test.go
Normal file
372
testing/orchestrator/hooks_lifecycle_deep_matrix_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
538
testing/orchestrator/hooks_lowfile_coverage_test.go
Normal file
538
testing/orchestrator/hooks_lowfile_coverage_test.go
Normal 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
178
testing/orchestrator/hooks_lowfunc_matrix_test.go
Normal file
178
testing/orchestrator/hooks_lowfunc_matrix_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
251
testing/orchestrator/hooks_ops_timesync_matrix_test.go
Normal file
251
testing/orchestrator/hooks_ops_timesync_matrix_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
211
testing/orchestrator/hooks_pure_helpers_test.go
Normal file
211
testing/orchestrator/hooks_pure_helpers_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
134
testing/orchestrator/hooks_report_wrappers_test.go
Normal file
134
testing/orchestrator/hooks_report_wrappers_test.go
Normal 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")
|
||||
})
|
||||
}
|
||||
297
testing/orchestrator/hooks_vault_failure_matrix_test.go
Normal file
297
testing/orchestrator/hooks_vault_failure_matrix_test.go
Normal 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())
|
||||
}
|
||||
150
testing/orchestrator/hooks_vault_matrix_extra_test.go
Normal file
150
testing/orchestrator/hooks_vault_matrix_extra_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
297
testing/orchestrator/hooks_wrappers_coverage_test.go
Normal file
297
testing/orchestrator/hooks_wrappers_coverage_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
70
testing/sshutil/sshutil_quality_additional_test.go
Normal file
70
testing/sshutil/sshutil_quality_additional_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
117
testing/sshutil/sshutil_quality_test.go
Normal file
117
testing/sshutil/sshutil_quality_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
223
testing/state/state_quality_additional_test.go
Normal file
223
testing/state/state_quality_additional_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
141
testing/state/state_quality_branch_closeout_test.go
Normal file
141
testing/state/state_quality_branch_closeout_test.go
Normal 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()
|
||||
}
|
||||
200
testing/state/state_quality_test.go
Normal file
200
testing/state/state_quality_test.go
Normal 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)
|
||||
}
|
||||
82
testing/ups/nut_quality_additional_test.go
Normal file
82
testing/ups/nut_quality_additional_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
112
testing/ups/nut_quality_test.go
Normal file
112
testing/ups/nut_quality_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user