package reconcile import ( "os" "path/filepath" "testing" "time" ) func appendRaw(t *testing.T, path, line string) { t.Helper() f, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0o600) if err != nil { t.Fatalf("open for raw append: %v", err) } defer f.Close() if _, err := f.WriteString(line + "\n"); err != nil { t.Fatalf("raw append: %v", err) } } func reopen(t *testing.T, j *Journal, path string) *Journal { t.Helper() if err := j.Close(); err != nil { t.Fatalf("close: %v", err) } nj, err := OpenJournal(path) if err != nil { t.Fatalf("reopen: %v", err) } t.Cleanup(func() { nj.Close() }) return nj } func TestJournal_LifecycleLatestWins(t *testing.T) { path := filepath.Join(t.TempDir(), "journal.log") j, err := OpenJournal(path) if err != nil { t.Fatalf("open: %v", err) } t.Cleanup(func() { j.Close() }) now := time.Now().UTC() for _, e := range []JournalEntry{ {OpID: "op1", VMID: 100, Kind: "start", State: OpStarted, At: now}, {OpID: "op1", VMID: 100, Kind: "start", UPID: "UPID:x:", State: OpTaskRunning, At: now}, {OpID: "op1", VMID: 100, Kind: "start", UPID: "UPID:x:", State: OpSucceeded, At: now}, } { if err := j.Append(e); err != nil { t.Fatalf("append: %v", err) } } got, ok := j.Latest("op1") if !ok || got.State != OpSucceeded { t.Fatalf("Latest(op1) = %+v ok=%v, want succeeded", got, ok) } if len(j.InFlight()) != 0 { t.Errorf("a succeeded op must not be in-flight: %+v", j.InFlight()) } } func TestJournal_InFlightSurvivesRestart(t *testing.T) { path := filepath.Join(t.TempDir(), "journal.log") j, err := OpenJournal(path) if err != nil { t.Fatalf("open: %v", err) } now := time.Now().UTC() // op started + got a task id, but NO terminal record — simulates a crash mid-op. mustAppend(t, j, JournalEntry{OpID: "op9", VMID: 100, Kind: "set_config", UPID: "UPID:crash:", State: OpTaskRunning, At: now}) j2 := reopen(t, j, path) inflight := j2.InFlight() if len(inflight) != 1 || inflight[0].OpID != "op9" || inflight[0].UPID != "UPID:crash:" { t.Fatalf("crash-mid-op should replay as in-flight with its task id, got %+v", inflight) } } func TestJournal_IdempotencyDedupeAcrossRestart(t *testing.T) { path := filepath.Join(t.TempDir(), "journal.log") j, err := OpenJournal(path) if err != nil { t.Fatalf("open: %v", err) } now := time.Now().UTC() const key = "job-abc-123" if j.AlreadyApplied(key) { t.Fatal("key should not be applied before any record") } // A one-shot op succeeds carrying an idempotency key. mustAppend(t, j, JournalEntry{OpID: "op1", VMID: 100, Kind: "restore", IdempKey: key, State: OpStarted, At: now}) mustAppend(t, j, JournalEntry{OpID: "op1", VMID: 100, Kind: "restore", IdempKey: key, State: OpSucceeded, At: now}) if !j.AlreadyApplied(key) { t.Fatal("key should be applied after success") } // Survives a restart (replayed from the log) — a redelivered job must not re-run. j2 := reopen(t, j, path) if !j2.AlreadyApplied(key) { t.Error("idempotency key must survive an agent restart") } // Empty key is never 'applied'. if j2.AlreadyApplied("") { t.Error("empty idempotency key must never be considered applied") } } func TestJournal_FailedKeyNotApplied(t *testing.T) { path := filepath.Join(t.TempDir(), "journal.log") j, err := OpenJournal(path) if err != nil { t.Fatalf("open: %v", err) } t.Cleanup(func() { j.Close() }) now := time.Now().UTC() const key = "job-fail" mustAppend(t, j, JournalEntry{OpID: "opF", VMID: 1, Kind: "restore", IdempKey: key, State: OpStarted, At: now}) mustAppend(t, j, JournalEntry{OpID: "opF", VMID: 1, Kind: "restore", IdempKey: key, State: OpFailed, At: now}) if j.AlreadyApplied(key) { t.Error("a FAILED one-shot op must not mark its key applied (it may be retried)") } } func TestJournal_SkipsTornTrailingLine(t *testing.T) { path := filepath.Join(t.TempDir(), "journal.log") j, err := OpenJournal(path) if err != nil { t.Fatalf("open: %v", err) } mustAppend(t, j, JournalEntry{OpID: "ok", VMID: 1, Kind: "start", State: OpSucceeded, At: time.Now().UTC()}) j.Close() // Append a torn (partial) JSON line as a crash would leave. appendRaw(t, path, `{"op_id":"torn","state":`) j2, err := OpenJournal(path) if err != nil { t.Fatalf("reopen with torn line: %v", err) } t.Cleanup(func() { j2.Close() }) if _, ok := j2.Latest("ok"); !ok { t.Error("the good record before the torn line must still load") } if _, ok := j2.Latest("torn"); ok { t.Error("the torn line must be skipped") } } func mustAppend(t *testing.T, j *Journal, e JournalEntry) { t.Helper() if err := j.Append(e); err != nil { t.Fatalf("append: %v", err) } }