package host import ( "context" "errors" "os" "path/filepath" "reflect" "strconv" "strings" "testing" "github.com/carlosframework/platform/internal/hostclaim" "github.com/carlosframework/platform/internal/hostguard" "github.com/carlosframework/platform/internal/provision" ) // Two model witnesses ported onto Task 4's target-side rollback harness and // Task 5's source-side recovery harness (moves spec §5.1 step 9 and §6.4; // Plan 4 Task 8). The claim half of the first is in hostclaim's // move_witness_test.go. // sourceGenerationObjects are the objects of the source's generation "cur" // in the source region's bucket, by key. func sourceGenerationObjects(h *recoverHarness) map[string]fakeMoveObject { out := map[string]fakeMoveObject{} for k, o := range h.f.buckets["data-ie"] { if strings.Contains(k, "/g-cur/") { out[k] = o } } return out } // TestRollbackBaselineSurvivesTheCleanReturn is wRollbackBaselinePreserved // (claim rev 6.14 item 5, R12-3; spec §5.1 step 9 and §6.4): a landing's // probe publishes the target's generation in uk, and the source's // generation, the target's predecessor in ie, is not queued for a park by // PublishGeneration. The target's admission record stays uncovered, no // obligation reaches ie, and the real sequence RollBack, TargetReleased and // ReturnToSource(clean) then finds the source's generation exactly as the // cut left it in the ie bucket fake, so the source restores from it. Only // Commit covers the record (hostclaim's half of the witness). func TestRollbackBaselineSurvivesTheCleanReturn(t *testing.T) { t.Parallel() ctx := context.Background() h := newRecoverHarness(t, false) baseline := sourceGenerationObjects(h) if len(baseline) == 0 { t.Fatal("fixture: the source's generation holds no objects in ie") } // The landing's probe admission, with the claim library's own // transition: cold, in uk, its predecessor the source's generation in ie. srcGen := h.claim(t).Op.Journal[hostclaim.JournalSrcGen] if srcGen != "cur" { t.Fatalf("fixture: src_gen %q", srcGen) } obs, err := hostclaim.Read(ctx, h.s, p1Host) if err != nil { t.Fatal(err) } op, holder := obs.Claim.Op, obs.Claim.Holder if _, err := hostclaim.Update(ctx, h.s, obs, hostclaim.AdmitWriter(hostclaim.Admission{ ObservedOpID: op.ID, ObservedPhase: op.Phase, ObservedSession: holder.Session, ObservedGeneration: holder.Generation, Box: "uk-box", Mode: "cold", NewGeneration: p1ID, Coverage: "sealed", Region: "uk", PredecessorRegion: "ie", KeepStamp: true, })); err != nil { t.Fatal(err) } // kept is the witness at one point of the flow. kept := func(where string) { t.Helper() c := h.claim(t) var target *hostclaim.AdmissionRecord for i := range c.Holder.Admissions { if a := &c.Holder.Admissions[i]; a.Box == "uk-box" && a.Mode == "cold" { target = a } } if target == nil || target.Region != "uk" || target.PredecessorRegion != "ie" || target.Predecessor != srcGen { t.Fatalf("%s: the target's record does not name the source's generation in ie: %+v", where, c.Holder.Admissions) } if target.Covered { t.Fatalf("%s: the target's record is covered: %+v", where, target) } for _, o := range c.Obligations { if o.Region == "ie" || o.Box == "ie-box" { t.Fatalf("%s: an obligation reaches the source's region: %+v", where, o) } } if got := sourceGenerationObjects(h); !reflect.DeepEqual(got, baseline) { t.Fatalf("%s: the source's generation changed in ie: %d objects, was %d", where, len(got), len(baseline)) } } // The probe's publication, as the claim library has it on main. obs, err = hostclaim.Read(ctx, h.s, p1Host) if err != nil { t.Fatal(err) } op, holder = obs.Claim.Op, obs.Claim.Holder if holder.Generation != p1ID { t.Fatalf("fixture: holder.generation %q", holder.Generation) } publish := hostclaim.PublishGeneration(op.ID, op.Phase, holder.Session, holder.Generation, "uk", func() string { t.Error("the target's publication allocated an obligation id") return "id" }) if _, err := hostclaim.Update(ctx, h.s, obs, publish); err != nil { t.Fatal(err) } kept(t.Name() + " after the target's publication") // The real sequence: the rollback, the target's release, the source's // clean return, through to opened. rb := h.rollBack(t) kept("after the rollback decision") h.rollbackPass(t) if got := h.rb(t); got.TargetPhase != provision.RbTargetReleased || got.Dirty { t.Fatalf("the target did not release clean: %+v", got) } kept("after the target's release") h.srcPass(t) h.opened(t) kept("after the clean return") c := h.claim(t) if c.Holder.Generation != srcGen || c.Op.Journal[hostclaim.JournalSrcGen] != srcGen { t.Fatalf("the return names generation %q, want the source's %q", c.Holder.Generation, srcGen) } if len(h.probes) != 1 || !h.probes[0].Clean || h.probes[0].SourceGeneration != srcGen || h.probes[0].RollbackID != rb.ID { t.Fatalf("the recovery probe was not asked to restore from the source's generation: %+v", h.probes) } } // loseGuardRecords is the lost-record fault: the source's installation // record is gone, either the host's file or the whole hostguard directory // (re-provisioned empty, as the box's boot does). func loseGuardRecords(t *testing.T, dataDir string, wholeDir bool) { t.Helper() dir := filepath.Join(dataDir, "hostguard") if !wholeDir { ents, err := os.ReadDir(dir) if err != nil || len(ents) == 0 { t.Fatalf("the guard directory holds no record to lose: %v %v", ents, err) } for _, e := range ents { if err := os.RemoveAll(filepath.Join(dir, e.Name())); err != nil { t.Fatal(err) } } return } if err := os.RemoveAll(dir); err != nil { t.Fatal(err) } if err := os.MkdirAll(dir, 0o770); err != nil { t.Fatal(err) } } // TestDirtyReturnAfterALostGuardRecord is wReturnLostRecord (claim rev 6.5, // R6-4; spec §5.1 step 9 and §6.4): a dirty rollback whose source loses its // installation record after the return's claim. The claimed phase had not // yet been left, so the source's recovery replays it: ReturnToSource wrote // S, the pre-move stamp, back into holder.stamp, so Guard.Activate // re-initialises the record from the claim at S, never at F (the target's // stamp), and Task 3's installer, entered through recoverSource, tags every // artifact it registers with S. The source ends opened at S with no path // tagged F. func TestDirtyReturnAfterALostGuardRecord(t *testing.T) { t.Parallel() for _, wholeDir := range []bool{false, true} { for _, registered := range []bool{false, true} { name := map[bool]string{false: "the record file", true: "the whole directory"}[wholeDir] + ", " + map[bool]string{false: "unregistered", true: "registered"}[registered] t.Run(name, func(t *testing.T) { h := newRecoverHarness(t, true) if registered { rewriteP1Claim(t, h.s, func(c *hostclaim.Claim) { c.Inventory = map[string]hostclaim.InventoryEntry{"ie-box": {Layout: "flat", Paths: map[string]uint64{p1Host + ".db": 1, p1Host + ".db.gen": 1, p1Host: 1, p1Host + ".env": 1}}} }) } rb := h.released(t) c := h.claim(t) s, err := strconv.ParseUint(c.Op.Journal[hostclaim.JournalSourceStamp], 10, 64) if err != nil || s == 0 { t.Fatalf("source_stamp %q: %v", c.Op.Journal[hostclaim.JournalSourceStamp], err) } f, err := strconv.ParseUint(c.Op.Journal[hostclaim.JournalTargetStamp], 10, 64) if err != nil || f == 0 || f == s { t.Fatalf("target_stamp %q, source_stamp %d: %v", c.Op.Journal[hostclaim.JournalTargetStamp], s, err) } // The record is lost right after the return's claim and the // activation, before the record leaves claimed. h.rcAt = "after-activate" h.rcHook = func(point string) { if point == "after-activate" { loseGuardRecords(t, h.sdir, wholeDir) } } h.srcPass(t) h.sourceAt(t, provision.RbSourceClaimed) if _, err := hostguard.Capture(h.sdir, p1Host); !errors.Is(err, hostguard.ErrNoRecord) && !errors.Is(err, hostguard.ErrNotProvisioned) { t.Fatalf("the installation record survived the loss: %v", err) } if c := h.claim(t); c.Op.Phase != hostclaim.PhaseClaimed || c.Op.Exec != "ie-box" || c.Holder.Stamp != s { t.Fatalf("claim after the loss: %+v holder stamp %d, want claimed by ie-box at %d", c.Op, c.Holder.Stamp, s) } // The replay re-initialises from the claim and recovers. h.rcHook = nil h.srcPass(t) got := h.opened(t) if got.ID != rb.ID { t.Fatalf("opened %s, want %s", got.ID, rb.ID) } captured, err := hostguard.Capture(h.sdir, p1Host) if err != nil || captured.Stamp != s || captured.Incarnation != h.claim(t).Holder.Incarnation { t.Fatalf("the re-initialised record: %+v %v, want stamp %d (never %d)", captured, err, s, f) } c = h.claim(t) if c.Holder.Stamp != s { t.Fatalf("holder.stamp %d, want S = %d", c.Holder.Stamp, s) } // Everything the install registered on the source is tagged S, // and none of it F (the target's own entries are its to park). entry, ok := c.Inventory["ie-box"] if !ok || len(entry.Paths) == 0 { t.Fatalf("the install registered nothing: %+v", c.Inventory) } for p, stamp := range entry.Paths { if stamp != s { t.Fatalf("%s is tagged %d, want S = %d (F = %d)", p, stamp, s, f) } } // The database is the target's rows, installed under S. if n := countRowsForTest(t, provision.DefaultDBPath(h.sdir, p1Host)); n != h.rows+1 { t.Fatalf("the returned database holds %d rows, the target wrote %d", n, h.rows+1) } }) } } }