package host import ( "os" "path/filepath" "testing" "github.com/carlosframework/platform/internal/hostbox" "github.com/carlosframework/platform/internal/hostguard" "github.com/carlosframework/platform/internal/registry" ) func TestPlan14GenerationTimerRetirement(t *testing.T) { t.Setenv("CARLOS_HOST_CLAIM", "true") for _, name := range []string{"exact", "replacement", "unrecorded", "legacy-unrecorded", "abandoned", "intended-row", "withdrawn"} { mismatch := name == "replacement" || name == "unrecorded" t.Run(name, func(t *testing.T) { coordinator, req, _ := claimStopFixture(t) result, err := coordinator.FenceAndStop(t.Context(), req) if err != nil { t.Fatal(err) } base := t.TempDir() inventory, err := openLegacyInventory(t, filepath.Join(base, "inventory.db")) if err != nil { t.Fatal(err) } defer inventory.Close() reg, err := registry.Open(filepath.Join(base, "registry.db")) if err != nil { t.Fatal(err) } defer reg.Close() owner := registry.ClaimOwnership{ClaimIncarnation: req.Incarnation, ClaimStamp: req.InstallationStamp, ClaimOwner: "installation", ClaimRowID: "route-1"} row := registry.Route{Host: req.Host, ClaimOwnership: owner} if err := reg.Upsert(row); err != nil { t.Fatal(err) } timer := registry.Timer{Host: req.Host, Name: "backup", Kind: "cron", Registration: "timer-1", ClaimOwnership: owner} timer.ClaimRowID = "timer-1" if name == "replacement" { timer.Registration = "replacement" timer.ClaimRowID = "replacement" } if err := reg.ReplaceTimers(req.Host, []registry.Timer{timer}); err != nil { t.Fatal(err) } in := hostbox.StopInventory{Complete: true, Host: req.Host, Incarnation: req.Incarnation, InstallationStamp: req.InstallationStamp, Box: req.Box, Enrollment: req.Enrollment, DirectoryRevision: req.ExpectedDirectoryRevision, Entries: []hostbox.StopInventoryEntry{ {Kind: "route", ArtifactID: "route", ManagedRoot: "registry", RelativePath: req.Host, RowIdentity: owner.ClaimRowID, Ownership: owner.ClaimOwner, Materialization: "materialized"}, {Kind: "timer", ArtifactID: "timer", ManagedRoot: "registry", RelativePath: req.Host + "/backup", RowIdentity: "timer-1", Ownership: owner.ClaimOwner, Materialization: "materialized"}, }} if name == "unrecorded" || name == "legacy-unrecorded" { in.Entries = in.Entries[:1] } if name == "abandoned" || name == "intended-row" { in.Entries[1].Materialization = "intended" } if name == "withdrawn" { in.Entries[1].TimerRetired = true } if name == "abandoned" || name == "withdrawn" { if err := reg.DeleteTimerOwnedRegistration(timer); err != nil { t.Fatal(err) } } if err := inventory.WriteStopInventory(t.Context(), in); err != nil { t.Fatal(err) } journal := filepath.Join(base, "retire") root := ClaimRootHTTP{Stop: ClaimStopHTTP{Coordinator: coordinator}, Roots: map[string]string{"data": base}, Reconcile: hostbox.ReconcileAdapter{RequireTimerInventory: name != "legacy-unrecorded", Inventory: inventory, Registry: reg, Retirement: hostbox.TypedRetirement{JournalDir: journal}}} // Remote acknowledgement is deliberately unavailable. Local completion // must already be durable, and cannot be confused with the remote result. if err := root.retire(t.Context(), result.Target); err == nil { t.Fatal("missing claim accepted") } else { t.Logf("retirement: %v", err) } got, err := reg.Get(req.Host) if err != nil { t.Fatal(err) } timers, err := reg.TimersFor(req.Host) if err != nil { t.Fatal(err) } if mismatch { if got == nil || len(timers) != 1 || timers[0].Registration != timer.Registration { t.Fatal("replacement changed") } return } if got != nil || len(timers) != 0 { t.Fatalf("retained timer prevented exact retirement: route=%v timers=%v", got, timers) } records, err := generationRetirementRecords(t.Context(), &rollbackDisk{dir: journal}) if err != nil || len(records) != 1 || !records[0].Complete { t.Fatal("missing local completion", records, err) } if name != "legacy-unrecorded" { receipt, err := coordinator.Guard.FenceReceiptFor(t.Context(), result.Target) if err != nil { t.Fatal(err) } snapshot := generationInventory{Retirements: records, Payloads: []hostbox.GenerationPayload{{Inventory: in}}, Installations: []hostguard.FenceView{{Host: req.Host, Receipts: []hostguard.FenceReceipt{receipt}}}} if _, err := generationPhysicalPayloads(t.Context(), snapshot); err != nil { t.Fatal("completed timer retirement still blocks completeness", err) } } if len(records[0].Inventory.Artifacts) != 2 { t.Fatal("duplicated timer", records[0]) } before, err := os.ReadFile(filepath.Join(journal, result.Target.FenceID+".json")) if err != nil { t.Fatal(err) } row.ClaimStamp++ row.ClaimOwner = "successor" row.ClaimRowID = "route-2" if err := reg.Upsert(row); err != nil { t.Fatal(err) } timer.ClaimOwnership = row.ClaimOwnership timer.ClaimRowID = "timer-2" timer.Registration = "timer-2" if err := reg.ReplaceTimers(req.Host, []registry.Timer{timer}); err != nil { t.Fatal(err) } if err := root.retire(t.Context(), result.Target); err == nil { t.Fatal("missing claim accepted on replay") } got, err = reg.Get(req.Host) if err != nil || got == nil || got.ClaimRowID != "route-2" { t.Fatal("successor route changed", got, err) } timers, err = reg.TimersFor(req.Host) if err != nil || len(timers) != 1 || timers[0].Registration != "timer-2" { t.Fatal("successor timer changed", timers, err) } after, err := os.ReadFile(filepath.Join(journal, result.Target.FenceID+".json")) if err != nil || string(before) != string(after) { t.Fatal("replay changed completed journal", err) } }) } }