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 "encoding/json"
6 "errors"
7 "os"
8 "path/filepath"
9 "slices"
10 "strings"
11 "testing"
12 "time"
13
14 "github.com/carlosframework/platform/internal/hostbox"
15 "github.com/carlosframework/platform/internal/hostclaim"
16 execctl "github.com/carlosframework/platform/internal/hostexec"
17 "github.com/carlosframework/platform/internal/hostguard"
18 "github.com/carlosframework/platform/internal/release"
19)
20
21type recordingDrain struct{ calls *[]string }
22
23func (d recordingDrain) DrainClaim(context.Context, StopRequest, string) (DrainAcknowledgement, error) {
24 *d.calls = append(*d.calls, "producers")
25 return DrainAcknowledgement{FenceID: "fence"}, nil
26}
27
28type memPositions struct {
29 calls *[]string
30 p ReleasePosition
31 ok bool
32}
33
34func (m *memPositions) Record(_ context.Context, p ReleasePosition) error {
35 *m.calls = append(*m.calls, "record")
36 m.p, m.ok = p, true
37 return nil
38}
39func (m *memPositions) Recorded(_ context.Context, r StopRequest) (ReleasePosition, bool, error) {
40 if !m.ok || !sameStop(m.p.Request, r) {
41 return ReleasePosition{}, false, nil
42 }
43 return m.p, true, nil
44}
45
46// writersStoppedFor is a complete StopWriters witness and root observation
47// for o's writer session, by "src" (main's ClaimAssembly.WritersStopped).
48func writersStoppedFor(o hostclaim.Observed) (ClaimWritersDrained, WritersStoppedEvidence, error) {
49 h := o.Claim.Holder
50 at := time.Date(2026, 10, 7, 9, 0, 0, 0, time.UTC)
51 target := hostguard.FenceTarget{Host: o.Host, Incarnation: h.Incarnation, Stamp: h.Stamp, Session: h.Session, Box: "src", Enrollment: "enr", BootID: "boot", ControllerID: "ctl"}
52 drained := ClaimWritersDrained{Target: target, Generation: h.Generation, WriterBox: h.WriterBox, FenceClosed: true, LaunchJoined: true, WorkDrained: true, SpawnsSettled: true,
53 CgroupEmpty: true, Cgroup: "carlos/shop", DBPaths: []string{"/data/shop.db"}, CheckedAt: at}
54 seen := WritersStoppedEvidence{Host: o.Host, Incarnation: h.Incarnation, InstallationStamp: h.Stamp, WriterSession: h.Session, Box: "src", Enrollment: "enr",
55 BootID: "boot", ControllerID: "ctl", ClaimETag: o.ETag, CheckedAt: at}
56 return drained, seen, nil
57}
58
59// fakeReleaseObligation records main's relocation-release CASes: Queue
60// before root's stop, Complete with its receipt.
61type fakeReleaseObligation struct {
62 calls *[]string
63 queueErr error
64 completeErr error
65 completed []string
66}
67
68func (f *fakeReleaseObligation) Queue(_ context.Context, o hostclaim.Observed) (hostclaim.Observed, error) {
69 *f.calls = append(*f.calls, "queue")
70 return o, f.queueErr
71}
72
73func (f *fakeReleaseObligation) Verify(context.Context, hostclaim.Observed, string, string) error {
74 *f.calls = append(*f.calls, "verify")
75 return nil
76}
77
78func (f *fakeReleaseObligation) Complete(_ context.Context, o hostclaim.Observed, key, digest string) (hostclaim.Observed, error) {
79 *f.calls = append(*f.calls, "complete")
80 f.completed = append(f.completed, key+"@"+digest)
81 return o, f.completeErr
82}
83
84func effectsFixture(t *testing.T) (*RelocationEffects, hostclaim.Observed, *[]string, *StopRequest) {
85 t.Helper()
86 calls := &[]string{}
87 seen := &StopRequest{}
88 exp := time.Date(2026, 10, 7, 10, 0, 0, 0, time.UTC)
89 e := &RelocationEffects{
90 Claims: release.NewDirStore(t.TempDir()),
91 Participation: func(context.Context, hostclaim.Observed) (execctl.State, error) {
92 return execctl.State{Binding: execctl.Binding{Host: "shop.example", Incarnation: "inc", Op: "rel-1", Enrollment: "enr", BootID: "boot", ControllerID: "c9"}, LeaseETag: "lease-1", Expires: exp}, nil
93 },
94 Identity: func(context.Context) (RelocationIdentity, error) {
95 return RelocationIdentity{Actor: hostbox.Actor{Box: "src", Enrollment: "enr", Revision: 4, Binding: "4"}, BootID: "boot", ControllerID: "ctl", PID: 100, ProcessStart: "5000"}, nil
96 },
97 Producers: func(context.Context, hostclaim.Observed) (StopDrainer, error) { return recordingDrain{calls}, nil },
98 WritersStopped: func(_ context.Context, o hostclaim.Observed) (ClaimWritersDrained, WritersStoppedEvidence, error) {
99 *calls = append(*calls, "writers-stopped")
100 return writersStoppedFor(o)
101 },
102 Releases: &fakeReleaseObligation{calls: calls},
103 FlushDB: func(context.Context, hostclaim.Observed) (uint64, error) {
104 *calls = append(*calls, "db")
105 return 41, nil
106 },
107 FlushFiles: func(context.Context, hostclaim.Observed) (int64, error) {
108 *calls = append(*calls, "files")
109 return 8, nil
110 },
111 Positions: &memPositions{calls: calls},
112 Collect: func(ctx context.Context, r StopRequest, d StopDrainer) (StopResult, error) {
113 *seen = r
114 if _, err := d.DrainClaim(ctx, r, "fence"); err != nil {
115 return StopResult{}, err
116 }
117 *calls = append(*calls, "receipt")
118 return StopResult{Key: "control/hosts/shop.example/evidence/rel-1/src/r.json", Digest: strings.Repeat("a", 64)}, nil
119 },
120 }
121 obs := hostclaim.Observed{Host: "shop.example", ETag: "etag-7", Claim: &hostclaim.Claim{Host: "shop.example",
122 Holder: &hostclaim.Holder{Incarnation: "inc", Stamp: 5, Session: 6, Generation: "g6", WriterBox: "src"},
123 Op: hostclaim.Op{Kind: hostclaim.KindRelocating, ID: "rel-1", Phase: hostclaim.PhaseReleasing, Exec: "src"}}}
124 return e, obs, calls, seen
125}
126
127func TestReleaseStopsWritersAndFlushesBeforeItQueues(t *testing.T) {
128 e, obs, calls, seen := effectsFixture(t)
129 r, err := e.Release(context.Background(), obs)
130 if err != nil {
131 t.Fatal(err)
132 }
133 if want := []string{"writers-stopped", "db", "files", "record", "queue", "producers", "receipt", "complete"}; !slices.Equal(*calls, want) {
134 t.Fatalf("order %v, want %v", *calls, want)
135 }
136 if r.FinalTXID != 41 || r.ManifestSeq != 8 || r.Session != 6 || r.Generation != "g6" || r.ReleasedBy != "src" || r.Receipt == "" {
137 t.Fatalf("release: %+v", r)
138 }
139 if seen.ObligationID != RelocationReleaseObligation || seen.Phase != hostclaim.PhaseReleasing || seen.ExpectedClaimETag != "etag-7" || seen.ExpectedDirectoryRevision != "4" {
140 t.Fatalf("stop request: %+v", *seen)
141 }
142}
143
144func TestRelocationReleaseStopsAtTheFirstFailedStep(t *testing.T) {
145 failure := errors.New("sync failed")
146 for _, tc := range []struct {
147 name string
148 change func(*RelocationEffects)
149 want []string
150 }{
151 {"the obligation cannot be queued", func(e *RelocationEffects) {
152 e.Releases.(*fakeReleaseObligation).queueErr = failure
153 }, []string{"writers-stopped", "db", "files", "record", "queue"}},
154 {"the writers cannot be stopped", func(e *RelocationEffects) {
155 e.WritersStopped = func(context.Context, hostclaim.Observed) (ClaimWritersDrained, WritersStoppedEvidence, error) {
156 return ClaimWritersDrained{}, WritersStoppedEvidence{}, failure
157 }
158 }, nil},
159 {"writers still running", func(e *RelocationEffects) {
160 e.WritersStopped = func(_ context.Context, o hostclaim.Observed) (ClaimWritersDrained, WritersStoppedEvidence, error) {
161 *e.Positions.(*memPositions).calls = append(*e.Positions.(*memPositions).calls, "writers-stopped")
162 d, ev, err := writersStoppedFor(o)
163 d.WorkDrained = false
164 return d, ev, err
165 }
166 }, []string{"writers-stopped"}},
167 {"root observed another session", func(e *RelocationEffects) {
168 e.WritersStopped = func(_ context.Context, o hostclaim.Observed) (ClaimWritersDrained, WritersStoppedEvidence, error) {
169 *e.Positions.(*memPositions).calls = append(*e.Positions.(*memPositions).calls, "writers-stopped")
170 d, ev, err := writersStoppedFor(o)
171 ev.WriterSession++
172 return d, ev, err
173 }
174 }, []string{"writers-stopped"}},
175 {"the database flush", func(e *RelocationEffects) {
176 e.FlushDB = func(context.Context, hostclaim.Observed) (uint64, error) { return 0, failure }
177 }, []string{"writers-stopped"}},
178 {"the files flush", func(e *RelocationEffects) {
179 e.FlushFiles = func(context.Context, hostclaim.Observed) (int64, error) { return 0, failure }
180 }, []string{"writers-stopped", "db"}},
181 {"the receipt does not discharge the obligation", func(e *RelocationEffects) {
182 e.Releases.(*fakeReleaseObligation).completeErr = failure
183 }, []string{"writers-stopped", "db", "files", "record", "queue", "producers", "receipt", "complete"}},
184 } {
185 t.Run(tc.name, func(t *testing.T) {
186 e, obs, calls, _ := effectsFixture(t)
187 tc.change(e)
188 if _, err := e.Release(context.Background(), obs); err == nil {
189 t.Fatal("released")
190 }
191 if !slices.Equal(*calls, tc.want) {
192 t.Fatalf("ran %v, want %v: nothing after a failed step", *calls, tc.want)
193 }
194 })
195 }
196}
197
198func TestRelocationReleaseReplaysOnlyRecordedPositions(t *testing.T) {
199 replayed := func(context.Context, StopRequest, StopDrainer) (StopResult, error) {
200 return StopResult{Key: "k", Digest: strings.Repeat("a", 64)}, nil // root replayed; no drain ran here
201 }
202 t.Run("no record", func(t *testing.T) {
203 // Ruling 28: without a recorded flush the release flushes first.
204 // A process with no admitted attempt cannot stop or flush the
205 // writers, so it never reaches the obligation or root.
206 e, obs, calls, _ := effectsFixture(t)
207 e.Collect = replayed
208 e.WritersStopped = func(context.Context, hostclaim.Observed) (ClaimWritersDrained, WritersStoppedEvidence, error) {
209 return ClaimWritersDrained{}, WritersStoppedEvidence{}, ErrClaimStopUnavailable
210 }
211 if _, err := e.Release(context.Background(), obs); !errors.Is(err, ErrClaimStopUnavailable) || len(*calls) != 0 {
212 t.Fatalf("got %v, ran %v", err, *calls)
213 }
214 })
215 t.Run("the lost response", func(t *testing.T) {
216 // The first call flushed and recorded; root completed its receipt;
217 // the response was lost. The retry pairs the replayed receipt with
218 // the recorded positions.
219 e, obs, _, _ := effectsFixture(t)
220 collect := e.Collect
221 e.Collect = func(ctx context.Context, r StopRequest, d StopDrainer) (StopResult, error) {
222 _, err := collect(ctx, r, d)
223 return StopResult{}, errors.Join(err, errors.New("connection reset"))
224 }
225 if _, err := e.Release(context.Background(), obs); err == nil {
226 t.Fatal("the lost response released")
227 }
228 e.Collect = replayed
229 r, err := e.Release(context.Background(), obs)
230 if err != nil || r.FinalTXID != 41 || r.ManifestSeq != 8 {
231 t.Fatalf("replay: %+v %v", r, err)
232 }
233 })
234 t.Run("an identity without its process", func(t *testing.T) {
235 e, obs, calls, _ := effectsFixture(t)
236 e.Identity = func(context.Context) (RelocationIdentity, error) {
237 return RelocationIdentity{Actor: hostbox.Actor{Box: "src", Enrollment: "enr", Revision: 4, Binding: "4"}, BootID: "boot", ControllerID: "ctl"}, nil
238 }
239 if _, err := e.Release(context.Background(), obs); !errors.Is(err, ErrClaimStopUnavailable) || len(*calls) != 0 {
240 t.Fatalf("released without a process identity: %v ran %v", err, *calls)
241 }
242 })
243 t.Run("a record of another session", func(t *testing.T) {
244 e, obs, _, _ := effectsFixture(t)
245 if _, err := e.Release(context.Background(), obs); err != nil {
246 t.Fatal(err)
247 }
248 e.Collect = replayed
249 obs.Claim.Holder.Session = 7
250 *e.Positions.(*memPositions).calls = nil
251 if _, err := e.Release(context.Background(), obs); err != nil || !slices.Contains(*e.Positions.(*memPositions).calls, "writers-stopped") {
252 t.Fatalf("another session's positions were used: %v ran %v", err, *e.Positions.(*memPositions).calls)
253 }
254 })
255}
256
257// Review Focus 3: a release never returns before root's receipt. Root
258// fails after the producers closed (kernel verification or CompleteFence),
259// so no receipt is durable: the release returns an error with its
260// positions recorded, and the retry, same controller, no restart, resumes
261// at the producers' drain with the recorded positions. No step of the
262// release runs twice.
263func TestReleaseNeverReturnsBeforeRootsReceipt(t *testing.T) {
264 // Round 7: the drain flushed, recorded and closed the producers;
265 // root then failed before its receipt was durable (kernel
266 // verification or CompleteFence). The retry, same controller, no
267 // restart, resumes at the producers' drain with the recorded
268 // positions: no step of the release runs again.
269 e, obs, calls, seen := effectsFixture(t)
270 collect := e.Collect
271 e.Collect = func(ctx context.Context, r StopRequest, d StopDrainer) (StopResult, error) {
272 if _, err := d.DrainClaim(ctx, r, "fence"); err != nil {
273 return StopResult{}, err
274 }
275 return StopResult{}, errors.New("verify stopped: cgroup not yet empty") // no receipt persisted
276 }
277 if _, err := e.Release(context.Background(), obs); err == nil {
278 t.Fatal("a release without a receipt succeeded")
279 }
280 if want := []string{"writers-stopped", "db", "files", "record", "queue", "producers"}; !slices.Equal(*calls, want) {
281 t.Fatalf("the first call ran %v, want %v", *calls, want)
282 }
283 *calls = nil
284 e.Collect = collect
285 // Round 8 finding 2: the roster moved on between the attempts
286 // (same boot, same controller); the retry still names the
287 // revision the stop was flushed under.
288 e.Identity = func(context.Context) (RelocationIdentity, error) {
289 return RelocationIdentity{Actor: hostbox.Actor{Box: "src", Enrollment: "enr", Revision: 5, Binding: "5"}, BootID: "boot", ControllerID: "ctl", PID: 100, ProcessStart: "5000"}, nil
290 }
291 r, err := e.Release(context.Background(), obs)
292 if err != nil || r.FinalTXID != 41 || r.ManifestSeq != 8 {
293 t.Fatalf("retry: %+v %v", r, err)
294 }
295 if want := []string{"queue", "producers", "receipt", "complete"}; !slices.Equal(*calls, want) {
296 t.Fatalf("the retry ran %v, want %v (no flush, no record, no writers stop again)", *calls, want)
297 }
298 if seen.ExpectedDirectoryRevision != "4" || seen.BootID != "boot" || seen.ControllerID != "ctl" {
299 t.Fatalf("the retry asked root under another stop: %+v", *seen)
300 }
301 if p := e.Positions.(*memPositions); !p.ok || p.p.TXID != 41 || p.p.Seq != 8 {
302 t.Fatalf("the positions were not kept for the retry: %+v", p)
303 }
304}
305
306// A restarted agent asks root as the recorded owner's recovery: the
307// request names the boot and controller the flush was made under, offers
308// no drain, and pairs root's replayed receipt with the recorded positions.
309// Both restarts count: new ids, and the same ids in a new process (main's
310// claimLaunchOwner.local).
311func TestReleaseAfterARestartRecoversTheRecordedOwner(t *testing.T) {
312 t.Run("after a restart the recorded identity asks root", func(t *testing.T) {
313 // The agent flushed and recorded under controller "ctl", root
314 // completed its receipt, the agent restarted. The new process asks
315 // root under the recorded boot and controller (so root finds that
316 // stop's durable receipt), offers no drain of its own, and pairs
317 // the replayed receipt with the recorded positions.
318 e, obs, _, _ := effectsFixture(t)
319 if _, err := e.Release(context.Background(), obs); err != nil {
320 t.Fatal(err)
321 }
322 e.Identity = func(context.Context) (RelocationIdentity, error) {
323 return RelocationIdentity{Actor: hostbox.Actor{Box: "src", Enrollment: "enr", Revision: 5, Binding: "5"}, BootID: "boot-2", ControllerID: "ctl-2", PID: 200, ProcessStart: "9000"}, nil
324 }
325 var asked StopRequest
326 recovery := false
327 e.Collect = func(_ context.Context, r StopRequest, d StopDrainer) (StopResult, error) {
328 asked = r
329 _, recovery = d.(RecoveryReleaseDrain) // Task 8a sets req.Recover from this
330 if _, err := d.DrainClaim(context.Background(), r, "fence"); !errors.Is(err, ErrClaimStopIdentity) {
331 return StopResult{}, errors.New("a restarted agent offered a drain for another process's stop")
332 }
333 return StopResult{Key: "k", Digest: strings.Repeat("a", 64)}, nil
334 }
335 r, err := e.Release(context.Background(), obs)
336 if err != nil || r.FinalTXID != 41 || r.ManifestSeq != 8 {
337 t.Fatalf("after restart: %+v %v", r, err)
338 }
339 if asked.BootID != "boot" || asked.ControllerID != "ctl" || asked.ExpectedDirectoryRevision != "4" || !recovery {
340 t.Fatalf("root was not asked as the recorded owner's recovery: %+v recovery=%v", asked, recovery)
341 }
342 })
343 t.Run("a restarted process with the same boot and controller ids recovers", func(t *testing.T) {
344 // Fix round 1 (I-2), main's claimLaunchOwner.local: the same boot
345 // and controller ids in a restarted process are not this process.
346 // Its producers ended with the old one, so the request is a
347 // recovery under the recorded owner and no drain is offered.
348 e, obs, calls, _ := effectsFixture(t)
349 if _, err := e.Release(context.Background(), obs); err != nil {
350 t.Fatal(err)
351 }
352 *calls = nil
353 e.Identity = func(context.Context) (RelocationIdentity, error) {
354 return RelocationIdentity{Actor: hostbox.Actor{Box: "src", Enrollment: "enr", Revision: 4, Binding: "4"}, BootID: "boot", ControllerID: "ctl", PID: 101, ProcessStart: "6000"}, nil
355 }
356 var asked StopRequest
357 recovery := false
358 e.Collect = func(_ context.Context, r StopRequest, d StopDrainer) (StopResult, error) {
359 asked = r
360 _, recovery = d.(RecoveryReleaseDrain)
361 if _, err := d.DrainClaim(context.Background(), r, "fence"); !errors.Is(err, ErrClaimStopIdentity) {
362 return StopResult{}, errors.New("a restarted process offered its producers for the old process's stop")
363 }
364 return StopResult{Key: "k", Digest: strings.Repeat("a", 64)}, nil
365 }
366 r, err := e.Release(context.Background(), obs)
367 if err != nil || r.FinalTXID != 41 || r.ManifestSeq != 8 {
368 t.Fatalf("same ids, new process: %+v %v", r, err)
369 }
370 if !recovery || asked.BootID != "boot" || asked.ControllerID != "ctl" || !slices.Equal(*calls, []string{"queue", "complete"}) {
371 t.Fatalf("not a recovery: recovery=%v asked=%+v ran %v", recovery, asked, *calls)
372 }
373 })
374}
375
376func TestReleasePositionsFileBindsTheExactStop(t *testing.T) {
377 ctx := context.Background()
378 f := ReleasePositionsFile{Dir: filepath.Join(t.TempDir(), "relocate")}
379 r := StopRequest{Host: "shop.example", Incarnation: "inc", InstallationStamp: 5, WriterSession: 6, OperationID: "rel-1", Phase: "releasing", ObligationID: RelocationReleaseObligation,
380 Box: "src", Enrollment: "enr", BootID: "boot", ControllerID: "ctl", ExpectedClaimETag: "e1", ExpectedDirectoryRevision: "4"}
381 if _, ok, err := f.Recorded(ctx, r); ok || err != nil {
382 t.Fatalf("before any record: %v %v", ok, err)
383 }
384 if err := f.Record(ctx, ReleasePosition{Request: r, TXID: 41, Seq: 8}); !errors.Is(err, ErrClaimStopUnavailable) {
385 t.Fatalf("a record without its process: %v", err)
386 }
387 if err := f.Record(ctx, ReleasePosition{Request: r, PID: 100, ProcessStart: "5000", TXID: 41, Seq: 8}); err != nil {
388 t.Fatal(err)
389 }
390 moved := r
391 moved.ExpectedClaimETag, moved.ExpectedDirectoryRevision = "e2", "5" // a target replacement moved the claim
392 if rec, ok, err := f.Recorded(ctx, moved); !ok || err != nil || rec.TXID != 41 || rec.Seq != 8 || rec.Request.ControllerID != "ctl" || rec.PID != 100 || rec.ProcessStart != "5000" {
393 t.Fatalf("recorded: %+v %v %v", rec, ok, err)
394 }
395 // A restarted controller of the same installation finds the record,
396 // and gets the identity the flush was made under.
397 restarted := r
398 restarted.BootID, restarted.ControllerID = "boot-2", "ctl-2"
399 if rec, ok, err := f.Recorded(ctx, restarted); !ok || err != nil || rec.Request.BootID != "boot" || rec.Request.ControllerID != "ctl" {
400 t.Fatalf("after a restart: %+v %v %v", rec, ok, err)
401 }
402 for name, change := range map[string]func(*StopRequest){
403 "session": func(r *StopRequest) { r.WriterSession = 7 },
404 "stamp": func(r *StopRequest) { r.InstallationStamp = 6 },
405 "operation": func(r *StopRequest) { r.OperationID = "rel-2" },
406 "phase": func(r *StopRequest) { r.Phase = "probe-failed" },
407 "box": func(r *StopRequest) { r.Box = "dst" },
408 } {
409 other := r
410 change(&other)
411 if _, ok, _ := f.Recorded(ctx, other); ok {
412 t.Fatalf("%s: another stop's positions were returned", name)
413 }
414 }
415 if err := os.WriteFile(filepath.Join(f.Dir, "rel-1.json"), []byte("{"), 0o600); err != nil {
416 t.Fatal(err)
417 }
418 if _, _, err := f.Recorded(ctx, r); !errors.Is(err, ErrClaimStopReceipt) {
419 t.Fatalf("a damaged record: %v", err)
420 }
421 // A record that names no process would read as this process's own
422 // under claimLaunchOwner.local: it is damaged, never trusted.
423 if err := os.WriteFile(filepath.Join(f.Dir, "rel-1.json"), []byte(`{"v":1,"request":{"Host":"shop.example"},"txid":41,"seq":8}`), 0o600); err != nil {
424 t.Fatal(err)
425 }
426 if _, _, err := f.Recorded(ctx, r); !errors.Is(err, ErrClaimStopReceipt) {
427 t.Fatalf("a record without its process: %v", err)
428 }
429}
430
431// Round 14 finding 2: EnsureLocal verifies the receipt's install, restores
432// a retired one under the same stamp and verifies it again, refuses a
433// restore that is not the receipt's, and never touches an opened P.
434func TestEnsureLocalRepairsOnlyUnderTheActiveStampBeforePOpens(t *testing.T) {
435 ctx := context.Background()
436 e, obs, _, _ := effectsFixture(t)
437 receipt := hostclaim.LocalInstall{Box: "dst", Enrollment: "enr-d", InstallStamp: 9, FilesManifestVersion: 1, JournalSHA256: strings.Repeat("e", 64)}
438 raw, _ := json.Marshal(receipt)
439 obs.Claim.Op.Phase = hostclaim.PhaseRelocatingProbing
440 obs.Claim.Op.Journal = map[string]string{"local_install": string(raw), "prepared": "P1", "install_stamp": "9"}
441 present, installs := true, 0
442 e.VerifyLocal = func(context.Context, hostclaim.Observed, hostclaim.LocalInstall) (bool, error) { return present, nil }
443 restored := receipt
444 e.Install = func(context.Context, hostclaim.Observed) (hostclaim.LocalInstall, error) {
445 installs++
446 present = true
447 return restored, nil
448 }
449 if err := e.EnsureLocal(ctx, obs); err != nil || installs != 0 {
450 t.Fatalf("a complete install: %v installs=%d", err, installs)
451 }
452 present = false
453 if err := e.EnsureLocal(ctx, obs); err != nil || installs != 1 || !present {
454 t.Fatalf("a damaged install under its active stamp: %v installs=%d", err, installs)
455 }
456 present, restored.InstallStamp = false, 10
457 if err := e.EnsureLocal(ctx, obs); !errors.Is(err, ErrClaimStopReceipt) {
458 t.Fatalf("a restore under another stamp: %v", err)
459 }
460 present, installs = false, 0
461 obs.Claim.Op.Journal["prepared_opened"] = "P1"
462 if err := e.EnsureLocal(ctx, obs); err != nil || installs != 0 {
463 t.Fatalf("an opened P was reinstalled: %v installs=%d", err, installs)
464 }
465}
466
467// Round 15 finding 1: after P opens, VerifyReceipt compares the box's
468// durable receipt with the journaled one and nothing else: a missing or
469// different receipt is false (the participant marks the operation stuck);
470// changed files never trigger a restore.
471func TestVerifyReceiptChecksTheReceiptOnly(t *testing.T) {
472 ctx := context.Background()
473 e, obs, _, _ := effectsFixture(t)
474 receipt := hostclaim.LocalInstall{Box: "dst", Enrollment: "enr-d", InstallStamp: 9, FilesManifestVersion: 1, JournalSHA256: strings.Repeat("e", 64)}
475 raw, _ := json.Marshal(receipt)
476 obs.Claim.Op.Journal = map[string]string{"local_install": string(raw), "prepared": "P1", "prepared_opened": "P1", "install_stamp": "9"}
477 stored, present := receipt, true
478 e.Receipt = func(context.Context, hostclaim.Observed) (hostclaim.LocalInstall, bool, error) {
479 return stored, present, nil
480 }
481 touched := 0
482 e.VerifyLocal = func(context.Context, hostclaim.Observed, hostclaim.LocalInstall) (bool, error) {
483 touched++
484 return false, nil
485 }
486 e.Install = func(context.Context, hostclaim.Observed) (hostclaim.LocalInstall, error) {
487 touched++
488 return receipt, nil
489 }
490 if ok, err := e.VerifyReceipt(ctx, obs); !ok || err != nil || touched != 0 {
491 t.Fatalf("an intact receipt over changed files: %v %v touched=%d", ok, err, touched)
492 }
493 present = false
494 if ok, err := e.VerifyReceipt(ctx, obs); ok || err != nil {
495 t.Fatalf("a missing receipt: %v %v", ok, err)
496 }
497 present, stored.InstallStamp = true, 8
498 if ok, err := e.VerifyReceipt(ctx, obs); ok || err != nil {
499 t.Fatalf("another installation's receipt: %v %v", ok, err)
500 }
501}
502