carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package host
2
3import (
4 "context"
5 "errors"
6 "os"
7 "path/filepath"
8 "reflect"
9 "strconv"
10 "strings"
11 "testing"
12
13 "github.com/carlosframework/platform/internal/hostclaim"
14 "github.com/carlosframework/platform/internal/hostguard"
15 "github.com/carlosframework/platform/internal/provision"
16)
17
18// Two model witnesses ported onto Task 4's target-side rollback harness and
19// Task 5's source-side recovery harness (moves spec §5.1 step 9 and §6.4;
20// Plan 4 Task 8). The claim half of the first is in hostclaim's
21// move_witness_test.go.
22
23// sourceGenerationObjects are the objects of the source's generation "cur"
24// in the source region's bucket, by key.
25func sourceGenerationObjects(h *recoverHarness) map[string]fakeMoveObject {
26 out := map[string]fakeMoveObject{}
27 for k, o := range h.f.buckets["data-ie"] {
28 if strings.Contains(k, "/g-cur/") {
29 out[k] = o
30 }
31 }
32 return out
33}
34
35// TestRollbackBaselineSurvivesTheCleanReturn is wRollbackBaselinePreserved
36// (claim rev 6.14 item 5, R12-3; spec §5.1 step 9 and §6.4): a landing's
37// probe publishes the target's generation in uk, and the source's
38// generation, the target's predecessor in ie, is not queued for a park by
39// PublishGeneration. The target's admission record stays uncovered, no
40// obligation reaches ie, and the real sequence RollBack, TargetReleased and
41// ReturnToSource(clean) then finds the source's generation exactly as the
42// cut left it in the ie bucket fake, so the source restores from it. Only
43// Commit covers the record (hostclaim's half of the witness).
44func TestRollbackBaselineSurvivesTheCleanReturn(t *testing.T) {
45 t.Parallel()
46 ctx := context.Background()
47 h := newRecoverHarness(t, false)
48 baseline := sourceGenerationObjects(h)
49 if len(baseline) == 0 {
50 t.Fatal("fixture: the source's generation holds no objects in ie")
51 }
52
53 // The landing's probe admission, with the claim library's own
54 // transition: cold, in uk, its predecessor the source's generation in ie.
55 srcGen := h.claim(t).Op.Journal[hostclaim.JournalSrcGen]
56 if srcGen != "cur" {
57 t.Fatalf("fixture: src_gen %q", srcGen)
58 }
59 obs, err := hostclaim.Read(ctx, h.s, p1Host)
60 if err != nil {
61 t.Fatal(err)
62 }
63 op, holder := obs.Claim.Op, obs.Claim.Holder
64 if _, err := hostclaim.Update(ctx, h.s, obs, hostclaim.AdmitWriter(hostclaim.Admission{
65 ObservedOpID: op.ID, ObservedPhase: op.Phase, ObservedSession: holder.Session, ObservedGeneration: holder.Generation,
66 Box: "uk-box", Mode: "cold", NewGeneration: p1ID, Coverage: "sealed", Region: "uk", PredecessorRegion: "ie", KeepStamp: true,
67 })); err != nil {
68 t.Fatal(err)
69 }
70
71 // kept is the witness at one point of the flow.
72 kept := func(where string) {
73 t.Helper()
74 c := h.claim(t)
75 var target *hostclaim.AdmissionRecord
76 for i := range c.Holder.Admissions {
77 if a := &c.Holder.Admissions[i]; a.Box == "uk-box" && a.Mode == "cold" {
78 target = a
79 }
80 }
81 if target == nil || target.Region != "uk" || target.PredecessorRegion != "ie" || target.Predecessor != srcGen {
82 t.Fatalf("%s: the target's record does not name the source's generation in ie: %+v", where, c.Holder.Admissions)
83 }
84 if target.Covered {
85 t.Fatalf("%s: the target's record is covered: %+v", where, target)
86 }
87 for _, o := range c.Obligations {
88 if o.Region == "ie" || o.Box == "ie-box" {
89 t.Fatalf("%s: an obligation reaches the source's region: %+v", where, o)
90 }
91 }
92 if got := sourceGenerationObjects(h); !reflect.DeepEqual(got, baseline) {
93 t.Fatalf("%s: the source's generation changed in ie: %d objects, was %d", where, len(got), len(baseline))
94 }
95 }
96
97 // The probe's publication, as the claim library has it on main.
98 obs, err = hostclaim.Read(ctx, h.s, p1Host)
99 if err != nil {
100 t.Fatal(err)
101 }
102 op, holder = obs.Claim.Op, obs.Claim.Holder
103 if holder.Generation != p1ID {
104 t.Fatalf("fixture: holder.generation %q", holder.Generation)
105 }
106 publish := hostclaim.PublishGeneration(op.ID, op.Phase, holder.Session, holder.Generation, "uk", func() string {
107 t.Error("the target's publication allocated an obligation id")
108 return "id"
109 })
110 if _, err := hostclaim.Update(ctx, h.s, obs, publish); err != nil {
111 t.Fatal(err)
112 }
113 kept(t.Name() + " after the target's publication")
114
115 // The real sequence: the rollback, the target's release, the source's
116 // clean return, through to opened.
117 rb := h.rollBack(t)
118 kept("after the rollback decision")
119 h.rollbackPass(t)
120 if got := h.rb(t); got.TargetPhase != provision.RbTargetReleased || got.Dirty {
121 t.Fatalf("the target did not release clean: %+v", got)
122 }
123 kept("after the target's release")
124 h.srcPass(t)
125 h.opened(t)
126 kept("after the clean return")
127
128 c := h.claim(t)
129 if c.Holder.Generation != srcGen || c.Op.Journal[hostclaim.JournalSrcGen] != srcGen {
130 t.Fatalf("the return names generation %q, want the source's %q", c.Holder.Generation, srcGen)
131 }
132 if len(h.probes) != 1 || !h.probes[0].Clean || h.probes[0].SourceGeneration != srcGen || h.probes[0].RollbackID != rb.ID {
133 t.Fatalf("the recovery probe was not asked to restore from the source's generation: %+v", h.probes)
134 }
135}
136
137// loseGuardRecords is the lost-record fault: the source's installation
138// record is gone, either the host's file or the whole hostguard directory
139// (re-provisioned empty, as the box's boot does).
140func loseGuardRecords(t *testing.T, dataDir string, wholeDir bool) {
141 t.Helper()
142 dir := filepath.Join(dataDir, "hostguard")
143 if !wholeDir {
144 ents, err := os.ReadDir(dir)
145 if err != nil || len(ents) == 0 {
146 t.Fatalf("the guard directory holds no record to lose: %v %v", ents, err)
147 }
148 for _, e := range ents {
149 if err := os.RemoveAll(filepath.Join(dir, e.Name())); err != nil {
150 t.Fatal(err)
151 }
152 }
153 return
154 }
155 if err := os.RemoveAll(dir); err != nil {
156 t.Fatal(err)
157 }
158 if err := os.MkdirAll(dir, 0o770); err != nil {
159 t.Fatal(err)
160 }
161}
162
163// TestDirtyReturnAfterALostGuardRecord is wReturnLostRecord (claim rev 6.5,
164// R6-4; spec §5.1 step 9 and §6.4): a dirty rollback whose source loses its
165// installation record after the return's claim. The claimed phase had not
166// yet been left, so the source's recovery replays it: ReturnToSource wrote
167// S, the pre-move stamp, back into holder.stamp, so Guard.Activate
168// re-initialises the record from the claim at S, never at F (the target's
169// stamp), and Task 3's installer, entered through recoverSource, tags every
170// artifact it registers with S. The source ends opened at S with no path
171// tagged F.
172func TestDirtyReturnAfterALostGuardRecord(t *testing.T) {
173 t.Parallel()
174 for _, wholeDir := range []bool{false, true} {
175 for _, registered := range []bool{false, true} {
176 name := map[bool]string{false: "the record file", true: "the whole directory"}[wholeDir] + ", " + map[bool]string{false: "unregistered", true: "registered"}[registered]
177 t.Run(name, func(t *testing.T) {
178 h := newRecoverHarness(t, true)
179 if registered {
180 rewriteP1Claim(t, h.s, func(c *hostclaim.Claim) {
181 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}}}
182 })
183 }
184 rb := h.released(t)
185 c := h.claim(t)
186 s, err := strconv.ParseUint(c.Op.Journal[hostclaim.JournalSourceStamp], 10, 64)
187 if err != nil || s == 0 {
188 t.Fatalf("source_stamp %q: %v", c.Op.Journal[hostclaim.JournalSourceStamp], err)
189 }
190 f, err := strconv.ParseUint(c.Op.Journal[hostclaim.JournalTargetStamp], 10, 64)
191 if err != nil || f == 0 || f == s {
192 t.Fatalf("target_stamp %q, source_stamp %d: %v", c.Op.Journal[hostclaim.JournalTargetStamp], s, err)
193 }
194
195 // The record is lost right after the return's claim and the
196 // activation, before the record leaves claimed.
197 h.rcAt = "after-activate"
198 h.rcHook = func(point string) {
199 if point == "after-activate" {
200 loseGuardRecords(t, h.sdir, wholeDir)
201 }
202 }
203 h.srcPass(t)
204 h.sourceAt(t, provision.RbSourceClaimed)
205 if _, err := hostguard.Capture(h.sdir, p1Host); !errors.Is(err, hostguard.ErrNoRecord) && !errors.Is(err, hostguard.ErrNotProvisioned) {
206 t.Fatalf("the installation record survived the loss: %v", err)
207 }
208 if c := h.claim(t); c.Op.Phase != hostclaim.PhaseClaimed || c.Op.Exec != "ie-box" || c.Holder.Stamp != s {
209 t.Fatalf("claim after the loss: %+v holder stamp %d, want claimed by ie-box at %d", c.Op, c.Holder.Stamp, s)
210 }
211
212 // The replay re-initialises from the claim and recovers.
213 h.rcHook = nil
214 h.srcPass(t)
215 got := h.opened(t)
216 if got.ID != rb.ID {
217 t.Fatalf("opened %s, want %s", got.ID, rb.ID)
218 }
219 captured, err := hostguard.Capture(h.sdir, p1Host)
220 if err != nil || captured.Stamp != s || captured.Incarnation != h.claim(t).Holder.Incarnation {
221 t.Fatalf("the re-initialised record: %+v %v, want stamp %d (never %d)", captured, err, s, f)
222 }
223 c = h.claim(t)
224 if c.Holder.Stamp != s {
225 t.Fatalf("holder.stamp %d, want S = %d", c.Holder.Stamp, s)
226 }
227 // Everything the install registered on the source is tagged S,
228 // and none of it F (the target's own entries are its to park).
229 entry, ok := c.Inventory["ie-box"]
230 if !ok || len(entry.Paths) == 0 {
231 t.Fatalf("the install registered nothing: %+v", c.Inventory)
232 }
233 for p, stamp := range entry.Paths {
234 if stamp != s {
235 t.Fatalf("%s is tagged %d, want S = %d (F = %d)", p, stamp, s, f)
236 }
237 }
238 // The database is the target's rows, installed under S.
239 if n := countRowsForTest(t, provision.DefaultDBPath(h.sdir, p1Host)); n != h.rows+1 {
240 t.Fatalf("the returned database holds %d rows, the target wrote %d", n, h.rows+1)
241 }
242 })
243 }
244 }
245}
246