268 lines
9.8 KiB
Go
268 lines
9.8 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|