201 lines
6.9 KiB
Go
201 lines
6.9 KiB
Go
|
|
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)
|
||
|
|
}
|