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 }