| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "os" |
| 5 | "path/filepath" |
| 6 | "testing" |
| 7 | |
| 8 | "github.com/carlosframework/platform/internal/hostbox" |
| 9 | "github.com/carlosframework/platform/internal/hostguard" |
| 10 | "github.com/carlosframework/platform/internal/registry" |
| 11 | ) |
| 12 | |
| 13 | func TestPlan14GenerationTimerRetirement(t *testing.T) { |
| 14 | t.Setenv("CARLOS_HOST_CLAIM", "true") |
| 15 | for _, name := range []string{"exact", "replacement", "unrecorded", "legacy-unrecorded", "abandoned", "intended-row", "withdrawn"} { |
| 16 | mismatch := name == "replacement" || name == "unrecorded" |
| 17 | t.Run(name, func(t *testing.T) { |
| 18 | coordinator, req, _ := claimStopFixture(t) |
| 19 | result, err := coordinator.FenceAndStop(t.Context(), req) |
| 20 | if err != nil { |
| 21 | t.Fatal(err) |
| 22 | } |
| 23 | base := t.TempDir() |
| 24 | inventory, err := openLegacyInventory(t, filepath.Join(base, "inventory.db")) |
| 25 | if err != nil { |
| 26 | t.Fatal(err) |
| 27 | } |
| 28 | defer inventory.Close() |
| 29 | reg, err := registry.Open(filepath.Join(base, "registry.db")) |
| 30 | if err != nil { |
| 31 | t.Fatal(err) |
| 32 | } |
| 33 | defer reg.Close() |
| 34 | owner := registry.ClaimOwnership{ClaimIncarnation: req.Incarnation, ClaimStamp: req.InstallationStamp, ClaimOwner: "installation", ClaimRowID: "route-1"} |
| 35 | row := registry.Route{Host: req.Host, ClaimOwnership: owner} |
| 36 | if err := reg.Upsert(row); err != nil { |
| 37 | t.Fatal(err) |
| 38 | } |
| 39 | timer := registry.Timer{Host: req.Host, Name: "backup", Kind: "cron", Registration: "timer-1", ClaimOwnership: owner} |
| 40 | timer.ClaimRowID = "timer-1" |
| 41 | if name == "replacement" { |
| 42 | timer.Registration = "replacement" |
| 43 | timer.ClaimRowID = "replacement" |
| 44 | } |
| 45 | if err := reg.ReplaceTimers(req.Host, []registry.Timer{timer}); err != nil { |
| 46 | t.Fatal(err) |
| 47 | } |
| 48 | 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{ |
| 49 | {Kind: "route", ArtifactID: "route", ManagedRoot: "registry", RelativePath: req.Host, RowIdentity: owner.ClaimRowID, Ownership: owner.ClaimOwner, Materialization: "materialized"}, |
| 50 | {Kind: "timer", ArtifactID: "timer", ManagedRoot: "registry", RelativePath: req.Host + "/backup", RowIdentity: "timer-1", Ownership: owner.ClaimOwner, Materialization: "materialized"}, |
| 51 | }} |
| 52 | if name == "unrecorded" || name == "legacy-unrecorded" { |
| 53 | in.Entries = in.Entries[:1] |
| 54 | } |
| 55 | if name == "abandoned" || name == "intended-row" { |
| 56 | in.Entries[1].Materialization = "intended" |
| 57 | } |
| 58 | if name == "withdrawn" { |
| 59 | in.Entries[1].TimerRetired = true |
| 60 | } |
| 61 | if name == "abandoned" || name == "withdrawn" { |
| 62 | if err := reg.DeleteTimerOwnedRegistration(timer); err != nil { |
| 63 | t.Fatal(err) |
| 64 | } |
| 65 | } |
| 66 | if err := inventory.WriteStopInventory(t.Context(), in); err != nil { |
| 67 | t.Fatal(err) |
| 68 | } |
| 69 | journal := filepath.Join(base, "retire") |
| 70 | 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}}} |
| 71 | // Remote acknowledgement is deliberately unavailable. Local completion |
| 72 | // must already be durable, and cannot be confused with the remote result. |
| 73 | if err := root.retire(t.Context(), result.Target); err == nil { |
| 74 | t.Fatal("missing claim accepted") |
| 75 | } else { |
| 76 | t.Logf("retirement: %v", err) |
| 77 | } |
| 78 | got, err := reg.Get(req.Host) |
| 79 | if err != nil { |
| 80 | t.Fatal(err) |
| 81 | } |
| 82 | timers, err := reg.TimersFor(req.Host) |
| 83 | if err != nil { |
| 84 | t.Fatal(err) |
| 85 | } |
| 86 | if mismatch { |
| 87 | if got == nil || len(timers) != 1 || timers[0].Registration != timer.Registration { |
| 88 | t.Fatal("replacement changed") |
| 89 | } |
| 90 | return |
| 91 | } |
| 92 | if got != nil || len(timers) != 0 { |
| 93 | t.Fatalf("retained timer prevented exact retirement: route=%v timers=%v", got, timers) |
| 94 | } |
| 95 | records, err := generationRetirementRecords(t.Context(), &rollbackDisk{dir: journal}) |
| 96 | if err != nil || len(records) != 1 || !records[0].Complete { |
| 97 | t.Fatal("missing local completion", records, err) |
| 98 | } |
| 99 | if name != "legacy-unrecorded" { |
| 100 | receipt, err := coordinator.Guard.FenceReceiptFor(t.Context(), result.Target) |
| 101 | if err != nil { |
| 102 | t.Fatal(err) |
| 103 | } |
| 104 | snapshot := generationInventory{Retirements: records, Payloads: []hostbox.GenerationPayload{{Inventory: in}}, Installations: []hostguard.FenceView{{Host: req.Host, Receipts: []hostguard.FenceReceipt{receipt}}}} |
| 105 | if _, err := generationPhysicalPayloads(t.Context(), snapshot); err != nil { |
| 106 | t.Fatal("completed timer retirement still blocks completeness", err) |
| 107 | } |
| 108 | } |
| 109 | if len(records[0].Inventory.Artifacts) != 2 { |
| 110 | t.Fatal("duplicated timer", records[0]) |
| 111 | } |
| 112 | before, err := os.ReadFile(filepath.Join(journal, result.Target.FenceID+".json")) |
| 113 | if err != nil { |
| 114 | t.Fatal(err) |
| 115 | } |
| 116 | row.ClaimStamp++ |
| 117 | row.ClaimOwner = "successor" |
| 118 | row.ClaimRowID = "route-2" |
| 119 | if err := reg.Upsert(row); err != nil { |
| 120 | t.Fatal(err) |
| 121 | } |
| 122 | timer.ClaimOwnership = row.ClaimOwnership |
| 123 | timer.ClaimRowID = "timer-2" |
| 124 | timer.Registration = "timer-2" |
| 125 | if err := reg.ReplaceTimers(req.Host, []registry.Timer{timer}); err != nil { |
| 126 | t.Fatal(err) |
| 127 | } |
| 128 | if err := root.retire(t.Context(), result.Target); err == nil { |
| 129 | t.Fatal("missing claim accepted on replay") |
| 130 | } |
| 131 | got, err = reg.Get(req.Host) |
| 132 | if err != nil || got == nil || got.ClaimRowID != "route-2" { |
| 133 | t.Fatal("successor route changed", got, err) |
| 134 | } |
| 135 | timers, err = reg.TimersFor(req.Host) |
| 136 | if err != nil || len(timers) != 1 || timers[0].Registration != "timer-2" { |
| 137 | t.Fatal("successor timer changed", timers, err) |
| 138 | } |
| 139 | after, err := os.ReadFile(filepath.Join(journal, result.Target.FenceID+".json")) |
| 140 | if err != nil || string(before) != string(after) { |
| 141 | t.Fatal("replay changed completed journal", err) |
| 142 | } |
| 143 | }) |
| 144 | } |
| 145 | } |
| 146 | |