224 lines
9.1 KiB
Go
224 lines
9.1 KiB
Go
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)
|
|
}
|
|
}
|