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 linux
2
3package host
4
5import (
6 "context"
7 "encoding/json"
8 "errors"
9 "os"
10 "os/exec"
11 "path/filepath"
12 "testing"
13 "time"
14
15 "github.com/carlosframework/platform/internal/hostbox"
16 "github.com/carlosframework/platform/internal/hostguard"
17 "github.com/carlosframework/platform/internal/registry"
18 "github.com/carlosframework/platform/internal/release"
19 "github.com/carlosframework/platform/internal/rootparticipation"
20 "github.com/carlosframework/platform/internal/rootstate"
21 "golang.org/x/sys/unix"
22)
23
24func generationParkedFixture(t *testing.T, currentBoot ...bool) (generationInventory, hostbox.RetirementJournal, string) {
25 t.Helper()
26 root := t.TempDir()
27 t.Setenv("CARLOS_DATA_DIR", root)
28 host := "parked.example"
29 in := hostbox.StopInventory{Complete: true, Host: host, Incarnation: "inc", InstallationStamp: 1, Box: "box", Enrollment: "en", DirectoryRevision: "1"}
30 in.Entries = []hostbox.StopInventoryEntry{
31 {Kind: "route", ArtifactID: "route", ManagedRoot: "registry", RelativePath: host, Ownership: "owner", RowIdentity: "row", Materialization: "materialized"},
32 {Kind: "timer", ArtifactID: "timer", ManagedRoot: "registry", RelativePath: host + "/backup", Ownership: "owner", RowIdentity: "timer", Materialization: "materialized"},
33 }
34 path := filepath.Join(root, "instances", host)
35 if err := os.MkdirAll(path, 0700); err != nil {
36 t.Fatal(err)
37 }
38 if err := os.WriteFile(filepath.Join(path, "payload"), []byte("retained"), 0600); err != nil {
39 t.Fatal(err)
40 }
41 var st unix.Stat_t
42 if err := unix.Stat(path, &st); err != nil {
43 t.Fatal(err)
44 }
45 in.Entries = append(in.Entries, hostbox.StopInventoryEntry{Identity: inventoryIdentityFixture(t, root, "instances/"+host), Kind: "path", ArtifactID: "state", ManagedRoot: "data", RelativePath: "instances/" + host, Ownership: "owner", Materialization: "materialized", Device: uint64(st.Dev), Inode: st.Ino, Type: unix.S_IFDIR})
46 target := hostguard.FenceTarget{Host: host, Incarnation: in.Incarnation, Stamp: 1, Session: 1, Box: in.Box, Enrollment: in.Enrollment, DirectoryRevision: in.DirectoryRevision, RosterRevision: 1, FenceID: "retire", BootID: in.Entries[len(in.Entries)-1].Identity.Boot, ControllerID: "controller", Op: "op", Phase: "retiring", ObligationID: "park"}
47 receipt := StopReceipt{V: 1, ID: "receipt", Kind: "stopped", Host: host, Incarnation: target.Incarnation, InstallationStamp: target.Stamp, WriterSession: target.Session, Box: target.Box, Enrollment: target.Enrollment, DirectoryRevision: testReceiptNumeral(target), Binding: target.DirectoryRevision, FenceID: target.FenceID, BootID: target.BootID, ControllerID: target.ControllerID, OperationID: target.Op, Phase: target.Phase, ObligationID: target.ObligationID, EvidenceAt: time.Unix(100, 0).UTC()}
48 raw, err := json.Marshal(receipt)
49 if err != nil {
50 t.Fatal(err)
51 }
52 inventory := hostbox.RetirementInventory{Host: host, Captured: hostguard.Captured{Incarnation: in.Incarnation, Stamp: 1}, Ownership: "owner"}
53 for _, e := range in.Entries {
54 inventory.Artifacts = append(inventory.Artifacts, hostbox.Artifact{Kind: hostbox.ArtifactKind(e.Kind), Root: e.ManagedRoot, Name: e.RelativePath, Owner: e.Ownership, RowID: e.RowIdentity, Identity: e.Identity, Dev: e.Device, Ino: e.Inode, Type: e.Type})
55 }
56 j, err := hostbox.NewRetirementJournal(target.FenceID, inventory)
57 if err != nil {
58 t.Fatal(err)
59 }
60 j.Target = &target
61 j.StopReceipt = raw
62 j.Complete = true
63 for i := range j.Steps {
64 j.Steps[i].Done = true
65 }
66 view := hostguard.FenceView{Schema: 1, Host: host, Installation: hostguard.RootInstallation{Host: host, Incarnation: in.Incarnation, Stamp: 1, Box: in.Box, Enrollment: in.Enrollment, Migration: 1, InventoryRevision: 1, Account: "bab", App: "shop", Record: "apps/bab/shop/instances/" + host + ".json", Artifacts: hostguard.InstallationArtifacts{Schema: 1, StateDirectory: true, Timers: "backup"}}, Receipts: []hostguard.FenceReceipt{{Target: target, Bytes: raw, EvidenceAt: receipt.EvidenceAt}}}
67 snapshot := generationInventory{Census: rootparticipation.Census{Schema: 1, Box: in.Box, Enrollment: in.Enrollment, Migration: 1, Revision: 1, LaunchRevision: 1, Complete: true}, Installations: []hostguard.FenceView{view}, Payloads: []hostbox.GenerationPayload{{Inventory: in, Revision: 1}}, Launches: []hostbox.LaunchInventory{{Target: target, WritersClosed: true}}}
68 return snapshot, j, root
69}
70
71// Decode the durable snapshot shape, including the newly retained journal list.
72func generationParkedSnapshot(t *testing.T, snapshot generationInventory, journals []hostbox.RetirementJournal) generationInventory {
73 t.Helper()
74 raw, err := json.Marshal(snapshot)
75 if err != nil {
76 t.Fatal(err)
77 }
78 var fields map[string]json.RawMessage
79 if err = json.Unmarshal(raw, &fields); err != nil {
80 t.Fatal(err)
81 }
82 fields["Retirements"], err = json.Marshal(journals)
83 if err != nil {
84 t.Fatal(err)
85 }
86 raw, err = json.Marshal(fields)
87 if err != nil {
88 t.Fatal(err)
89 }
90 if err = json.Unmarshal(raw, &snapshot); err != nil {
91 t.Fatal(err)
92 }
93 return snapshot
94}
95
96func TestPlan14GenerationParkedPayload(t *testing.T) {
97 for _, attack := range []string{"complete", "predecessor", "pending", "unfinished-step", "wrong-inode", "wrong-owner", "wrong-target", "missing-receipt", "wrong-receipt", "missing-artifact", "extra-artifact", "duplicate-journal", "missing-parked", "replaced-parked", "symlink-parked", "live-row", "live-timer", "successor", "changed-during-kernel"} {
98 t.Run(attack, func(t *testing.T) {
99 snapshot, j, root := generationParkedFixture(t)
100 original := filepath.Join(root, "instances", j.Inventory.Host)
101 parked := filepath.Join(root, ".parked", j.Inventory.Captured.Incarnation, j.Inventory.Host, j.ID, "data", "instances", j.Inventory.Host)
102 if err := os.MkdirAll(filepath.Dir(parked), 0700); err != nil {
103 t.Fatal(err)
104 }
105 if err := os.Rename(original, parked); err != nil {
106 t.Fatal(err)
107 }
108 reg, err := registry.Open(filepath.Join(root, "registry.db"))
109 if err != nil {
110 t.Fatal(err)
111 }
112 defer reg.Close()
113 t.Setenv("CARLOS_HOST_CLAIM", "true")
114 switch attack {
115 case "predecessor", "successor":
116 snapshot.Installations[0].Retired = []hostguard.Captured{j.Inventory.Captured}
117 snapshot.Installations[0].Installation.Stamp = 2
118 snapshot.Installations[0].Installation.Empty = true
119 current := snapshot.Payloads[0]
120 current.Inventory.InstallationStamp = 2
121 current.Inventory.Entries = nil
122 if attack == "successor" {
123 snapshot.Installations[0].Installation.Empty = false
124 current.Inventory.Entries = []hostbox.StopInventoryEntry{{Kind: "route", ArtifactID: "route", ManagedRoot: "registry", RelativePath: j.Inventory.Host, Ownership: "successor", RowIdentity: "next", Materialization: "materialized"}}
125 snapshot.Launches = append(snapshot.Launches, hostbox.LaunchInventory{Target: hostguard.FenceTarget{Host: j.Inventory.Host, Incarnation: "inc", Stamp: 2, Box: "box", Enrollment: "en"}, WritersClosed: true})
126 if err := reg.Upsert(registry.Route{Host: j.Inventory.Host, ClaimOwnership: registry.ClaimOwnership{ClaimIncarnation: "inc", ClaimStamp: 2, ClaimOwner: "successor", ClaimRowID: "next"}}); err != nil {
127 t.Fatal(err)
128 }
129 }
130 snapshot.Payloads = append(snapshot.Payloads, current)
131 case "pending":
132 j.Complete = false
133 case "unfinished-step":
134 j.Steps[0].Done = false
135 case "wrong-inode":
136 j.Inventory.Artifacts[0].Ino++
137 case "wrong-owner":
138 j.Inventory.Ownership = "other"
139 case "wrong-target":
140 j.Target.Session++
141 case "missing-receipt":
142 snapshot.Installations[0].Receipts = nil
143 case "wrong-receipt":
144 j.StopReceipt = []byte("forged")
145 case "missing-artifact":
146 j.Inventory.Artifacts = j.Inventory.Artifacts[:len(j.Inventory.Artifacts)-1]
147 case "extra-artifact":
148 j.Inventory.Artifacts = append(j.Inventory.Artifacts, hostbox.Artifact{Kind: hostbox.ArtifactTimer, Root: "registry", Name: j.Inventory.Host + "/unknown", Owner: "owner", RowID: "extra"})
149 case "missing-parked":
150 if err := os.Rename(parked, parked+".old"); err != nil {
151 t.Fatal(err)
152 }
153 case "replaced-parked", "symlink-parked":
154 if err := os.Rename(parked, parked+".old"); err != nil {
155 t.Fatal(err)
156 }
157 if attack == "replaced-parked" {
158 err = os.Mkdir(parked, 0700)
159 } else {
160 err = os.Symlink(parked+".old", parked)
161 }
162 if err != nil {
163 t.Fatal(err)
164 }
165 case "live-row":
166 if err := reg.Upsert(registry.Route{Host: j.Inventory.Host, ClaimOwnership: registry.ClaimOwnership{ClaimIncarnation: "inc", ClaimStamp: 1, ClaimOwner: "owner", ClaimRowID: "row"}}); err != nil {
167 t.Fatal(err)
168 }
169 case "live-timer":
170 if err := reg.ReplaceTimers(j.Inventory.Host, []registry.Timer{{Host: j.Inventory.Host, Name: "backup", Kind: "cron", Registration: "timer", ClaimOwnership: registry.ClaimOwnership{ClaimIncarnation: "inc", ClaimStamp: 1, ClaimOwner: "owner", ClaimRowID: "timer"}}}); err != nil {
171 t.Fatal(err)
172 }
173 }
174 journals := []hostbox.RetirementJournal{j}
175 if attack == "duplicate-journal" {
176 journals = append(journals, j)
177 }
178 snapshot = generationParkedSnapshot(t, snapshot, journals)
179 v := generationReconciler{read: func(ctx context.Context) (generationInventory, error) {
180 return generationRegistryInventory(ctx, reg, snapshot)
181 }, kernel: func(context.Context) (string, string, error) {
182 if attack == "changed-during-kernel" {
183 j.Complete = false
184 snapshot = generationParkedSnapshot(t, snapshot, []hostbox.RetirementJournal{j})
185 }
186 return "boot", generationInventoryDigest("kernel"), nil
187 }}
188 got, err := v.reconcile(t.Context(), nil)
189 good := attack == "complete" || attack == "predecessor" || attack == "successor"
190 if good && (err != nil || got.Installations == "") {
191 t.Fatalf("completed root retirement did not reconcile: %+v %v", got, err)
192 }
193 if !good && err == nil {
194 t.Fatal("invalid retirement certified a snapshot")
195 }
196 })
197 }
198}
199
200type generationParkAuthority struct{}
201
202func (generationParkAuthority) WithCleanup(_ context.Context, _ hostguard.FenceTarget, effect func() error) error {
203 return effect()
204}
205
206type generationCrashParker struct {
207 hostbox.TypedParker
208 crash bool
209}
210
211func (p generationCrashParker) Park(ctx context.Context, id string, a hostbox.Artifact) error {
212 if err := p.TypedParker.Park(ctx, id, a); err != nil {
213 return err
214 }
215 if p.crash {
216 os.Exit(38)
217 }
218 return nil
219}
220
221type generationCrashRows struct {
222 hostbox.RegistryRows
223 crash bool
224}
225
226func (r generationCrashRows) RetireRowsJournal(ctx context.Context, id, owner string, rows []hostbox.Artifact, archive func(json.RawMessage) error) error {
227 if err := r.RegistryRows.RetireRowsJournal(ctx, id, owner, rows, archive); err != nil {
228 return err
229 }
230 if r.crash {
231 os.Exit(38)
232 }
233 return nil
234}
235
236func generationRunPark(t *testing.T, root string, j hostbox.RetirementJournal, reg *registry.Registry, crash string) {
237 t.Helper()
238 j.Complete = false
239 for i := range j.Steps {
240 j.Steps[i].Done = false
241 }
242 p := hostbox.TypedParker{Roots: map[string]string{"data": root}, ParkedRoots: map[string]string{"data": root}, Incarnation: j.Inventory.Captured.Incarnation, Host: j.Inventory.Host, Retirement: j.ID}
243 r := hostbox.TypedRetirement{JournalDir: filepath.Join(root, "retire"), Authority: generationParkAuthority{}, Filesystem: generationCrashParker{p, crash == "park"}, Rows: generationCrashRows{hostbox.RegistryRows{Registry: reg, Inventory: j.Inventory}, crash == "rows"}, Ack: func(context.Context, hostbox.RetirementJournal) error {
244 if crash == "complete" {
245 os.Exit(38)
246 }
247 return nil
248 }}
249 if err := r.Run(t.Context(), *j.Target, j); err != nil {
250 t.Fatal(err)
251 }
252}
253
254func TestPlan14GenerationParkedCrash(t *testing.T) {
255 if root := os.Getenv("PLAN14_PARK_CRASH_ROOT"); root != "" {
256 var j hostbox.RetirementJournal
257 raw, err := os.ReadFile(filepath.Join(root, "intent.json"))
258 if err != nil {
259 t.Fatal(err)
260 }
261 if err = json.Unmarshal(raw, &j); err != nil {
262 t.Fatal(err)
263 }
264 reg, err := registry.Open(filepath.Join(root, "registry.db"))
265 if err != nil {
266 t.Fatal(err)
267 }
268 defer reg.Close()
269 generationRunPark(t, root, j, reg, os.Getenv("PLAN14_PARK_CRASH_POINT"))
270 t.Fatal("crash boundary did not exit")
271 }
272 for _, point := range []string{"park", "rows", "complete"} {
273 t.Run(point, func(t *testing.T) {
274 snapshot, j, root := generationParkedFixture(t)
275 t.Setenv("CARLOS_HOST_CLAIM", "true")
276 reg, err := registry.Open(filepath.Join(root, "registry.db"))
277 if err != nil {
278 t.Fatal(err)
279 }
280 own := registry.ClaimOwnership{ClaimIncarnation: "inc", ClaimStamp: 1, ClaimOwner: "owner", ClaimRowID: "row"}
281 if err := reg.Upsert(registry.Route{Host: j.Inventory.Host, ClaimOwnership: own}); err != nil {
282 t.Fatal(err)
283 }
284 own.ClaimRowID = "timer"
285 if err := reg.ReplaceTimers(j.Inventory.Host, []registry.Timer{{Host: j.Inventory.Host, Name: "backup", Kind: "cron", Registration: "timer", ClaimOwnership: own}}); err != nil {
286 t.Fatal(err)
287 }
288 reg.Close()
289 raw, err := json.Marshal(j)
290 if err != nil {
291 t.Fatal(err)
292 }
293 if err := os.WriteFile(filepath.Join(root, "intent.json"), raw, 0600); err != nil {
294 t.Fatal(err)
295 }
296 cmd := exec.CommandContext(t.Context(), os.Args[0], "-test.run=^TestPlan14GenerationParkedCrash$", "-test.timeout=20s")
297 cmd.Dir = root
298 cmd.Env = append(os.Environ(), "PLAN14_PARK_CRASH_ROOT="+root, "PLAN14_PARK_CRASH_POINT="+point)
299 out, err := cmd.CombinedOutput()
300 var exited *exec.ExitError
301 if !errors.As(err, &exited) || exited.ExitCode() != 38 {
302 t.Fatalf("child did not crash at %s: %v %s", point, err, out)
303 }
304 reg, err = registry.Open(filepath.Join(root, "registry.db"))
305 if err != nil {
306 t.Fatal(err)
307 }
308 defer reg.Close()
309 read := func(ctx context.Context) (generationInventory, error) {
310 records, err := generationRetirementRecords(ctx, &rollbackDisk{dir: filepath.Join(root, "retire")})
311 if err != nil {
312 return generationInventory{}, err
313 }
314 return generationRegistryInventory(ctx, reg, generationParkedSnapshot(t, snapshot, records))
315 }
316 v := generationReconciler{read: read, kernel: func(context.Context) (string, string, error) { return "boot", generationInventoryDigest("kernel"), nil }}
317 _, err = v.reconcile(t.Context(), nil)
318 if (err == nil) != (point == "complete") {
319 t.Fatalf("interrupted retirement at %s: %v", point, err)
320 }
321 generationRunPark(t, root, j, reg, "")
322 if _, err := v.reconcile(t.Context(), nil); err != nil {
323 t.Fatal("resumed retirement", err)
324 }
325 generationRunPark(t, root, j, reg, "")
326 if _, err := v.reconcile(t.Context(), nil); err != nil {
327 t.Fatal("replayed retirement", err)
328 }
329 })
330 }
331}
332
333func TestPlan14GenerationParkedReadback(t *testing.T) {
334 _, j, _ := generationParkedFixture(t)
335 for _, attack := range []string{"complete", "sync-failure", "unknown-field", "trailing", "wrong-name", "malformed", "unknown-file", "reader-failure"} {
336 t.Run(attack, func(t *testing.T) {
337 dir := t.TempDir()
338 raw, err := json.Marshal(j)
339 if err != nil {
340 t.Fatal(err)
341 }
342 name := j.ID + ".json"
343 switch attack {
344 case "unknown-field":
345 raw = append([]byte(`{"unexpected":true,`), raw[1:]...)
346 case "trailing":
347 raw = append(raw, []byte(` {}`)...)
348 case "wrong-name":
349 name = "other.json"
350 case "unknown-file":
351 name = "other"
352 case "malformed":
353 raw = []byte(`{"complete":`)
354 }
355 if err := os.WriteFile(filepath.Join(dir, name), raw, 0600); err != nil {
356 t.Fatal(err)
357 }
358 open := func(path string) (rootstate.Store, error) {
359 if attack == "reader-failure" {
360 return nil, os.ErrPermission
361 }
362 return &rollbackDisk{dir: path}, nil
363 }
364 calls := 0
365 sync := func(string) error {
366 calls++
367 if attack == "sync-failure" {
368 return os.ErrPermission
369 }
370 return nil
371 }
372 got, err := generationReadRetirements(t.Context(), dir, open, sync)
373 if attack == "complete" {
374 if err != nil || len(got) != 1 || calls != 1 {
375 t.Fatalf("complete read: %v %v %d", got, err, calls)
376 }
377 return
378 }
379 if err == nil || got != nil {
380 t.Fatal("invalid retirement read granted evidence", got, err)
381 }
382 if attack == "sync-failure" {
383 got, err = generationReadRetirements(t.Context(), dir, open, sync)
384 if err == nil || got != nil || calls != 2 {
385 t.Fatal("failed sync was not retried", got, err, calls)
386 }
387 got, err = generationReadRetirements(t.Context(), dir, open, func(string) error { return nil })
388 if err != nil || len(got) != 1 {
389 t.Fatal("durable reread failed", got, err)
390 }
391 }
392 })
393 }
394}
395
396func TestPlan14GenerationParkedKeepsReservation(t *testing.T) {
397 snapshot, j, root := generationParkedFixture(t, true)
398 reg, err := registry.Open(filepath.Join(root, "registry.db"))
399 if err != nil {
400 t.Fatal(err)
401 }
402 defer reg.Close()
403 t.Setenv("CARLOS_HOST_CLAIM", "true")
404 own := registry.ClaimOwnership{ClaimIncarnation: "inc", ClaimStamp: 1, ClaimOwner: "owner", ClaimRowID: "row"}
405 if err := reg.Upsert(registry.Route{Host: j.Inventory.Host, ClaimOwnership: own}); err != nil {
406 t.Fatal(err)
407 }
408 own.ClaimRowID = "timer"
409 if err := reg.ReplaceTimers(j.Inventory.Host, []registry.Timer{{Host: j.Inventory.Host, Name: "backup", Kind: "cron", Registration: "timer", ClaimOwnership: own}}); err != nil {
410 t.Fatal(err)
411 }
412 generationRunPark(t, root, j, reg, "")
413 opt := t.TempDir()
414 path := release.VersionPath(opt, "bab", "v1", "shop")
415 if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
416 t.Fatal(err)
417 }
418 if err := os.WriteFile(path, []byte("reserved build"), 0755); err != nil {
419 t.Fatal(err)
420 }
421 identity := func(path string) rootstate.FileIdentity {
422 t.Helper()
423 var s unix.Stat_t
424 if err := unix.Stat(path, &s); err != nil {
425 t.Fatal(err)
426 }
427 return rootstate.FileIdentity{Device: uint64(s.Dev), Inode: s.Ino}
428 }
429 in := snapshot.Installations[0].Installation
430 binding := release.GenerationBinding{Binding: release.HistoryBinding{Host: in.Host, Account: in.Account, App: in.App}, Target: generationOwner(*j.Target), Installation: rootHistoryInstallation(in), Record: in.Record, RecordETag: "record", Attempt: "pending", Inputs: "inputs", Invocation: "invocation", ControllerPID: 42, ControllerStart: "start", Version: "v1", Digest: generationInventoryDigest("build"), Path: path, Tree: filepath.Dir(path), Executable: identity(path), TreeIdentity: identity(filepath.Dir(path))}
431 snapshot.Launches[0].ControllerPID = 42
432 snapshot.Launches[0].ProcessStart = "start"
433 disk := &rollbackDisk{dir: t.TempDir()}
434 ledger := &release.RootGenerations{Store: disk}
435 reserved, err := ledger.Reserve(binding)
436 if err != nil {
437 t.Fatal(err)
438 }
439 read := func(ctx context.Context) (generationInventory, error) {
440 records, err := generationRetirementRecords(ctx, &rollbackDisk{dir: filepath.Join(root, "retire")})
441 if err != nil {
442 return generationInventory{}, err
443 }
444 return generationRegistryInventory(ctx, reg, generationParkedSnapshot(t, snapshot, records))
445 }
446 reconciler := generationReconciler{read: read, kernel: func(context.Context) (string, string, error) { return "boot", generationInventoryDigest("kernel"), nil }}
447 pins, err := (release.RootGenerationPins{Ledger: ledger, Reconcile: reconciler.reconcile}).StagedPins(t.Context(), opt)
448 if err != nil {
449 t.Fatal(err)
450 }
451 if pinned, err := pins.Refers(path); err != nil || !pinned {
452 t.Fatal("completed retirement dropped pending reservation", pinned, err)
453 }
454 if got, err := ledger.Read(binding); err != nil || got != reserved {
455 t.Fatal("payload retirement changed generation authority", got, err)
456 }
457}
458
459func TestPlan14GenerationParkedNestedMountLocation(t *testing.T) {
460 snapshot, _, root := generationParkedFixture(t)
461 entry := &snapshot.Payloads[0].Inventory.Entries[2]
462 entry.Identity.MountPoint = filepath.Join(root, "instances")
463 in := snapshot.Payloads[0].Inventory
464 inventory := hostbox.RetirementInventory{Host: in.Host, Captured: hostguard.Captured{Incarnation: in.Incarnation, Stamp: in.InstallationStamp}, Ownership: "owner"}
465 for _, e := range in.Entries {
466 inventory.Artifacts = append(inventory.Artifacts, hostbox.Artifact{Kind: hostbox.ArtifactKind(e.Kind), Root: e.ManagedRoot, Name: e.RelativePath, Owner: e.Ownership, RowID: e.RowIdentity, Identity: e.Identity, Dev: e.Device, Ino: e.Inode, Type: e.Type})
467 }
468 j, err := hostbox.NewRetirementJournal("retire", inventory)
469 if err != nil {
470 t.Fatal(err)
471 }
472 r := snapshot.Installations[0].Receipts[0]
473 j.Target, j.StopReceipt, j.Complete = &r.Target, r.Bytes, true
474 for i := range j.Steps {
475 j.Steps[i].Done = true
476 }
477 snapshot.Retirements = []hostbox.RetirementJournal{j}
478 physical, err := generationPhysicalPayloads(t.Context(), snapshot)
479 if err != nil {
480 t.Fatal(err)
481 }
482 got := physical[0].Inventory.Entries[2]
483 want := filepath.Join("instances", ".parked", in.Incarnation, in.Host, j.ID, "data", entry.RelativePath)
484 if got.RelativePath != want || got.Identity != entry.Identity {
485 t.Fatalf("parked payload=%+v, want %s with unchanged identity", got, want)
486 }
487}
488