carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1//go:build unix
2
3package hostbox
4
5import (
6 "context"
7 "errors"
8 "io/fs"
9 "maps"
10 "os"
11 "path/filepath"
12 "testing"
13 "time"
14
15 "github.com/carlosframework/platform/internal/hostclaim"
16 "github.com/carlosframework/platform/internal/hostguard"
17 "github.com/carlosframework/platform/internal/hostwrite"
18 "github.com/carlosframework/platform/internal/park"
19)
20
21// relocationReconciler is the reconciler over workFixture's guard and
22// store, with the real fence observer (Guard.AdmissionFenced) and a
23// recording hold writer. The claim relocates the host off "box".
24func relocationReconciler(t *testing.T) (Reconciler, RecordSelection, *[]string, hostguard.Guard) {
25 t.Helper()
26 source, store, obs := workFixture(t)
27 source.Policy = Policy{Held: func(context.Context, string) (string, error) { return "", nil }, Relocation: func(context.Context, *hostclaim.Claim, Selection, hostwrite.Identity) error { return nil }}.Check
28 // One cold writer session, so the source has a session a stop can fence.
29 obs, err := hostclaim.Update(context.Background(), store, obs, hostclaim.AdmitWriter(hostclaim.Admission{Box: "box", Mode: "cold", NewGeneration: "g1", Region: "ie", KeepStamp: true}))
30 if err != nil {
31 t.Fatal(err)
32 }
33 h := obs.Claim.Holder
34 selected := RecordSelection{Host: obs.Host, Key: h.Key, ETag: "record-etag", Incarnation: h.Incarnation, Family: h.Family, Layout: "flat", Paths: []string{"shop.example.db"}}
35 holds := &[]string{}
36 r := Reconciler{Claims: store, Guard: source.Guard, Local: park.Local{Guard: source.Guard, Claims: store, DataDir: source.Guard.DataDir, Box: "box"}, Writes: hostwrite.Config{Enabled: true, Selector: source}, Box: "box", Region: "ie", Now: func() time.Time { return time.Unix(100, 0) },
37 CheckRecord: func(context.Context, RecordSelection) error { return nil },
38 CheckLocal: func(context.Context, hostclaim.Observed, RecordSelection) error { return nil },
39 CheckHeld: func(context.Context, hostclaim.Observed, RecordSelection) error { return nil },
40 HoldRow: func(_ context.Context, _ string, op string) error { *holds = append(*holds, op); return nil }}
41 r.FenceClosed = func(_ context.Context, o hostclaim.Observed) (bool, error) {
42 captured, err := hostguard.Capture(source.Guard.DataDir, o.Host)
43 if err != nil {
44 return false, err
45 }
46 return source.Guard.AdmissionFenced(o.Host, o.Claim, captured)
47 }
48 return r, selected, holds, source.Guard
49}
50
51func relocate(t *testing.T, r Reconciler, host, phase string) hostclaim.Observed {
52 t.Helper()
53 ctx := context.Background()
54 obs, err := hostclaim.Read(ctx, r.Claims, host)
55 if err != nil {
56 t.Fatal(err)
57 }
58 obs, err = hostclaim.Update(ctx, r.Claims, obs, func(c *hostclaim.Claim) error {
59 c.Op = hostclaim.Op{Kind: hostclaim.KindRelocating, ID: "rel-1", Phase: phase, Exec: "box", Participants: []string{"box", "dst"},
60 Intent: map[string]string{"region": "ie", "source_box": "box", "target_box": "dst"}, Journal: map[string]string{"exec_ack.box": "{}", "exec_ack.dst": "{}"}}
61 return nil
62 })
63 if err != nil {
64 t.Fatal(err)
65 }
66 return obs
67}
68
69// The source in releasing takes the activation path (ready) until root
70// begins its stop; then the hold-only path, under the flock, with the
71// observer read inside the existing locked operation: a bounded context
72// finishes both, so neither reacquires the host lock.
73//
74// Claim Plan 11's policy gate admits no "reconcile" action under a
75// relocating claim (R1: only op.exec's probe), so the activation path is
76// refused by the first check, before the guard is activated or the
77// inventory written, dry run or not; the hold-only path, which takes no
78// policy, still keeps the fenced source's row held.
79func TestReconcileRelocationSourceReadyThenHeldWithTheRealFenceObserver(t *testing.T) {
80 r, selected, holds, guard := relocationReconciler(t)
81 obs := relocate(t, r, selected.Host, hostclaim.PhaseReleasing)
82 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
83 defer cancel()
84 writes := 0
85 before := snapshotDir(t, guard.DataDir)
86 for _, dry := range []bool{true, false} {
87 sel := selected
88 sel.DryRun = dry
89 if err := r.Install(ctx, sel, func() error { writes++; return nil }); !errors.Is(err, ErrChanged) {
90 t.Fatalf("the source before its fence closed (dry run %v): %v (claim Plan 11 refuses a relocating reconcile)", dry, err)
91 }
92 }
93 if writes != 0 || len(*holds) != 0 {
94 t.Fatalf("writes %d holds %v", writes, *holds)
95 }
96 if after := snapshotDir(t, guard.DataDir); !maps.Equal(before, after) {
97 t.Fatal("a refused relocating reconcile changed the guard directory")
98 }
99 if now, err := hostclaim.Read(ctx, r.Claims, selected.Host); err != nil || now.ETag != obs.ETag {
100 t.Fatalf("a refused relocating reconcile wrote the claim: %v", err)
101 }
102 // Root begins the source's stop: the durable admission fence closes.
103 captured, err := hostguard.Capture(guard.DataDir, selected.Host)
104 if err != nil {
105 t.Fatal(err)
106 }
107 obs, err = hostclaim.Read(ctx, r.Claims, selected.Host)
108 if err != nil {
109 t.Fatal(err)
110 }
111 target := hostguard.FenceTarget{FenceID: "fence-1", Host: selected.Host, Incarnation: captured.Incarnation, Stamp: captured.Stamp, Session: obs.Claim.Holder.Session,
112 Op: "rel-1", Phase: hostclaim.PhaseReleasing, Box: "box", Enrollment: "enrollment-1", BootID: "boot", ControllerID: "ctl", ClaimETag: obs.ETag, DirectoryRevision: "4"}
113 if _, err := guard.BeginFence(ctx, target); err != nil {
114 t.Fatal(err)
115 }
116 if err := r.Install(ctx, selected, func() error { writes++; return nil }); err != nil {
117 t.Fatalf("the fenced source's hold: %v", err)
118 }
119 if writes != 0 || len(*holds) != 1 || (*holds)[0] != "rel-1" {
120 t.Fatalf("after the fence: writes %d holds %v (the guarded path must not run)", writes, *holds)
121 }
122 // A box with no role mints nothing; an observer error keeps rows and
123 // mints none.
124 r.Box = "zzz"
125 if err := r.Install(ctx, selected, func() error { writes++; return nil }); !errors.Is(err, ErrChanged) || writes != 0 {
126 t.Fatalf("no role: %v writes %d", err, writes)
127 }
128 r.Box = "box"
129 broken := errors.New("record unreadable")
130 r.FenceClosed = func(context.Context, hostclaim.Observed) (bool, error) { return false, broken }
131 if err := r.Install(ctx, selected, func() error { writes++; return nil }); !errors.Is(err, broken) || writes != 0 {
132 t.Fatalf("an observer error: %v writes %d", err, writes)
133 }
134}
135
136// With no observer (region HA off) the reconciler is today's: the serving
137// executor is ready; an executor of any other operation is refused by the
138// first check, before the guard is activated or inventory recorded, dry
139// run or not (round 5 finding 3).
140func TestReconcileWithoutAFenceObserverIsTodays(t *testing.T) {
141 r, selected, _, guard := relocationReconciler(t)
142 r.FenceClosed, r.HoldRow = nil, nil
143 ctx := context.Background()
144 writes := 0
145 if err := r.Install(ctx, selected, func() error { writes++; return nil }); err != nil || writes != 1 {
146 t.Fatalf("serving: %v writes %d", err, writes)
147 }
148 moving := func() hostclaim.Observed {
149 obs, err := hostclaim.Read(ctx, r.Claims, selected.Host)
150 if err != nil {
151 t.Fatal(err)
152 }
153 obs, err = hostclaim.Update(ctx, r.Claims, obs, func(c *hostclaim.Claim) error {
154 c.Op = hostclaim.Op{Kind: hostclaim.KindMoving, ID: "mv-1", Phase: "copying", Exec: "box", Participants: []string{"box", "dst"}, Intent: map[string]string{"admission_id": "adm"}}
155 return nil
156 })
157 if err != nil {
158 t.Fatal(err)
159 }
160 return obs
161 }
162 for _, phase := range []string{hostclaim.PhaseStarting, hostclaim.PhaseReleasing, "moving"} {
163 var obs hostclaim.Observed
164 if phase == "moving" {
165 obs = moving() // a moves executor: main's path with HA on too
166 } else {
167 obs = relocate(t, r, selected.Host, phase) // ready with HA on (RowReady)
168 }
169 before := snapshotDir(t, guard.DataDir)
170 for _, dry := range []bool{true, false} {
171 sel := selected
172 sel.DryRun = dry
173 if err := r.Install(ctx, sel, func() error { writes++; return nil }); !errors.Is(err, ErrChanged) || writes != 1 {
174 t.Fatalf("%s (dry run %v) with HA off: %v writes %d", phase, dry, err, writes)
175 }
176 }
177 after, err := hostclaim.Read(ctx, r.Claims, selected.Host)
178 if err != nil {
179 t.Fatal(err)
180 }
181 if after.ETag != obs.ETag {
182 t.Fatalf("%s: the claim (inventory) was written with HA off", phase)
183 }
184 if got := snapshotDir(t, guard.DataDir); !maps.Equal(got, before) {
185 t.Fatalf("%s: the guard's local state changed with HA off", phase)
186 }
187 }
188}
189
190// With the observer set (region HA on) a moves claim still takes main's
191// path: Serving refuses it first. The target mints and holds nothing
192// here (moves owns its holds, in provision's pass), and the executor is
193// refused before the guard is activated or inventory recorded, dry run
194// or not. Only relocating claims take the row decision.
195func TestReconcileWithAFenceObserverLeavesMovesToMain(t *testing.T) {
196 for _, box := range []string{"dst", "box"} {
197 t.Run(box, func(t *testing.T) {
198 r, selected, holds, guard := relocationReconciler(t)
199 ctx := context.Background()
200 observed := 0
201 fence := r.FenceClosed
202 r.FenceClosed = func(ctx context.Context, o hostclaim.Observed) (bool, error) {
203 observed++
204 return fence(ctx, o)
205 }
206 obs, err := hostclaim.Read(ctx, r.Claims, selected.Host)
207 if err != nil {
208 t.Fatal(err)
209 }
210 obs, err = hostclaim.Update(ctx, r.Claims, obs, func(c *hostclaim.Claim) error {
211 c.Op = hostclaim.Op{Kind: hostclaim.KindMoving, ID: "mv-1", Phase: "copying", Exec: "box", Participants: []string{"box", "dst"}, Intent: map[string]string{"admission_id": "adm"}}
212 return nil
213 })
214 if err != nil {
215 t.Fatal(err)
216 }
217 r.Box = box
218 before := snapshotDir(t, guard.DataDir)
219 writes := 0
220 for _, dry := range []bool{true, false} {
221 sel := selected
222 sel.DryRun = dry
223 if err := r.Install(ctx, sel, func() error { writes++; return nil }); !errors.Is(err, ErrChanged) {
224 t.Fatalf("%s (dry run %v) under a move with HA on: %v", box, dry, err)
225 }
226 }
227 if writes != 0 || len(*holds) != 0 || observed != 0 {
228 t.Fatalf("%s under a move with HA on: writes %d holds %v observer calls %d", box, writes, *holds, observed)
229 }
230 after, err := hostclaim.Read(ctx, r.Claims, selected.Host)
231 if err != nil {
232 t.Fatal(err)
233 }
234 if after.ETag != obs.ETag {
235 t.Fatalf("%s: the claim (inventory) was written under a move", box)
236 }
237 if got := snapshotDir(t, guard.DataDir); !maps.Equal(got, before) {
238 t.Fatalf("%s: the guard's local state changed under a move", box)
239 }
240 })
241 }
242}
243
244// Install asks the fence observer once for its own decision, and its
245// check and its path both act on that answer: the hold-only path acts on
246// the answer the decision was made from. The source in releasing is the
247// one row case that depends on the observer. Before the final review's
248// fixes it was asked three times here (check, Install, holdOnly); now
249// Install's one answer serves its check too, so it is asked twice
250// (Install, holdOnly under the flock). A box whose case does not depend on
251// it is never asked (TestReconcileRelocationTargetWithNoLocalRecordMintsItsRow).
252func TestReconcileInstallAsksTheFenceObserverOnce(t *testing.T) {
253 r, selected, holds, _ := relocationReconciler(t)
254 relocate(t, r, selected.Host, hostclaim.PhaseReleasing)
255 ctx := context.Background()
256 // Install's answer is closed, and every later one open: acting on one
257 // answer, Install takes the hold path, whose decision under the flock
258 // then differs, so nothing is written and nothing held.
259 answers := []bool{true}
260 calls := 0
261 r.FenceClosed = func(context.Context, hostclaim.Observed) (bool, error) {
262 calls++
263 if calls <= len(answers) {
264 return answers[calls-1], nil
265 }
266 return false, nil
267 }
268 writes := 0
269 if err := r.Install(ctx, selected, func() error { writes++; return nil }); !errors.Is(err, ErrChanged) {
270 t.Fatalf("a changed answer under the flock: %v", err)
271 }
272 if calls != 2 || writes != 0 || len(*holds) != 0 {
273 t.Fatalf("observer calls %d (Install, holdOnly) writes %d holds %v", calls, writes, *holds)
274 }
275}
276
277// Final review: the hold-only write is not execution, so the region's
278// quiet window does not gate it. The activation path still waits for it.
279func TestReconcileQuietWindowGatesExecutionNotTheHold(t *testing.T) {
280 r, selected, holds, guard := relocationReconciler(t)
281 obs := relocate(t, r, selected.Host, hostclaim.PhaseReleasing)
282 ctx := context.Background()
283 obs, err := hostclaim.Update(ctx, r.Claims, obs, func(c *hostclaim.Claim) error {
284 c.QuietAfter = map[string]time.Time{"ie": r.Now().Add(time.Minute)}
285 return nil
286 })
287 if err != nil {
288 t.Fatal(err)
289 }
290 writes := 0
291 // The source before its fence closes would take the activation path,
292 // which a relocating claim now refuses before the quiet window is
293 // reached (main's policy admits only the probe; checkOn refuses first).
294 if err := r.Install(ctx, selected, func() error { writes++; return nil }); !errors.Is(err, ErrChanged) || writes != 0 || len(*holds) != 0 {
295 t.Fatalf("activation in the quiet window: %v writes %d holds %v", err, writes, *holds)
296 }
297 captured, err := hostguard.Capture(guard.DataDir, selected.Host)
298 if err != nil {
299 t.Fatal(err)
300 }
301 target := hostguard.FenceTarget{FenceID: "fence-1", Host: selected.Host, Incarnation: captured.Incarnation, Stamp: captured.Stamp, Session: obs.Claim.Holder.Session,
302 Op: "rel-1", Phase: hostclaim.PhaseReleasing, Box: "box", Enrollment: "enrollment-1", BootID: "boot", ControllerID: "ctl", ClaimETag: obs.ETag, DirectoryRevision: "4"}
303 if _, err := guard.BeginFence(ctx, target); err != nil {
304 t.Fatal(err)
305 }
306 // The fenced source's row is held at once, inside the quiet window.
307 if err := r.Install(ctx, selected, func() error { writes++; return nil }); err != nil {
308 t.Fatalf("the fenced source's hold in the quiet window: %v", err)
309 }
310 if writes != 0 || len(*holds) != 1 || (*holds)[0] != "rel-1" {
311 t.Fatalf("writes %d holds %v", writes, *holds)
312 }
313}
314
315// targetReconciler is relocationReconciler moved onto the target "dst":
316// its own guard directory, with no local record for the host, and the
317// real fence observer over it, which errors on a box with no record.
318func targetReconciler(t *testing.T, r Reconciler) (Reconciler, hostguard.Guard, *int) {
319 t.Helper()
320 dir := t.TempDir()
321 locks := filepath.Join(dir, "locks")
322 for _, p := range []string{locks, filepath.Join(dir, "hostguard")} {
323 if err := os.Mkdir(p, 0770); err != nil {
324 t.Fatal(err)
325 }
326 }
327 guard := hostguard.Guard{DataDir: dir, LockDir: locks, Claims: hostguard.StoreReader{Store: r.Claims}}
328 target := WorkSource{Guard: guard, Box: "dst", Region: "ie", Now: r.Now,
329 Policy: Policy{Held: func(context.Context, string) (string, error) { return "", nil }, Relocation: func(context.Context, *hostclaim.Claim, Selection, hostwrite.Identity) error { return nil }}.Check,
330 Local: func(context.Context, *hostclaim.Claim, Selection) error { return nil }}
331 r.Box, r.Guard, r.Writes.Selector = "dst", guard, target
332 r.Local = park.Local{Guard: guard, Claims: r.Claims, DataDir: dir, Box: "dst"}
333 calls := new(int)
334 r.FenceClosed = func(_ context.Context, o hostclaim.Observed) (bool, error) {
335 *calls++
336 captured, err := hostguard.Capture(dir, o.Host)
337 if err != nil {
338 return false, err
339 }
340 return guard.AdmissionFenced(o.Host, o.Claim, captured)
341 }
342 return r, guard, calls
343}
344
345// Final review Important 2: with region HA on, a relocation target that
346// has no local guard record yet decides its row (held, as op.exec and a
347// participant) without the fence observer, which errors without a record
348// and is never asked. Claim Plan 11's policy gate admits no "reconcile"
349// action under a relocating claim (R1), so the activation path is refused
350// by the first check, dry run or not: nothing is activated or written,
351// and no row is minted (stage B settles how one is).
352func TestReconcileRelocationTargetWithNoLocalRecordIsRefusedBeforeActivation(t *testing.T) {
353 r, selected, holds, _ := relocationReconciler(t)
354 r, guard, calls := targetReconciler(t, r)
355 ctx := context.Background()
356 obs, err := hostclaim.Read(ctx, r.Claims, selected.Host)
357 if err != nil {
358 t.Fatal(err)
359 }
360 obs, err = hostclaim.Update(ctx, r.Claims, obs, func(c *hostclaim.Claim) error {
361 c.Op = hostclaim.Op{Kind: hostclaim.KindRelocating, ID: "rel-1", Phase: hostclaim.PhaseInstalling, Exec: "dst", Participants: []string{"box", "dst"},
362 Intent: map[string]string{"region": "ie", "source_box": "box", "target_box": "dst"}, Journal: map[string]string{"exec_ack.box": "{}", "exec_ack.dst": "{}"}}
363 return nil
364 })
365 if err != nil {
366 t.Fatal(err)
367 }
368 // The production observer cannot answer here: no record.
369 if _, err := r.FenceClosed(ctx, obs); err == nil {
370 t.Fatal("the observer answered for a box with no local record")
371 }
372 *calls = 0
373 writes := 0
374 before := snapshotDir(t, guard.DataDir)
375 for _, dry := range []bool{true, false} {
376 sel := selected
377 sel.DryRun = dry
378 if err := r.Install(ctx, sel, func() error { writes++; return nil }); !errors.Is(err, ErrChanged) {
379 t.Fatalf("the target's first install (dry run %v): %v (claim Plan 11 refuses a relocating reconcile)", dry, err)
380 }
381 }
382 if writes != 0 || len(*holds) != 0 || *calls != 0 {
383 t.Fatalf("writes %d holds %v observer calls %d", writes, *holds, *calls)
384 }
385 if after := snapshotDir(t, guard.DataDir); !maps.Equal(before, after) {
386 t.Fatal("a refused relocating reconcile changed the target's guard directory")
387 }
388 if _, err := hostguard.Capture(guard.DataDir, selected.Host); err == nil {
389 t.Fatal("the target's installation was activated")
390 }
391 if now, err := hostclaim.Read(ctx, r.Claims, selected.Host); err != nil || now.ETag != obs.ETag {
392 t.Fatalf("a refused relocating reconcile wrote the claim: %v", err)
393 }
394}
395
396// snapshotDir is every regular file under dir, by relative path.
397func snapshotDir(t *testing.T, dir string) map[string]string {
398 t.Helper()
399 out := map[string]string{}
400 err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
401 if err != nil || !d.Type().IsRegular() {
402 return err
403 }
404 raw, err := os.ReadFile(path)
405 if err != nil {
406 return err
407 }
408 out[path] = string(raw)
409 return nil
410 })
411 if err != nil {
412 t.Fatal(err)
413 }
414 return out
415}
416