| 1 | //go:build linux |
| 2 | |
| 3 | package host |
| 4 | |
| 5 | import ( |
| 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 | |
| 24 | func 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. |
| 72 | func 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 | |
| 96 | func 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 | |
| 200 | type generationParkAuthority struct{} |
| 201 | |
| 202 | func (generationParkAuthority) WithCleanup(_ context.Context, _ hostguard.FenceTarget, effect func() error) error { |
| 203 | return effect() |
| 204 | } |
| 205 | |
| 206 | type generationCrashParker struct { |
| 207 | hostbox.TypedParker |
| 208 | crash bool |
| 209 | } |
| 210 | |
| 211 | func (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 | |
| 221 | type generationCrashRows struct { |
| 222 | hostbox.RegistryRows |
| 223 | crash bool |
| 224 | } |
| 225 | |
| 226 | func (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 | |
| 236 | func 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 | |
| 254 | func 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 | |
| 333 | func 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 | |
| 396 | func 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 | |
| 459 | func 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 | |