| 1 | //go:build unix |
| 2 | |
| 3 | package hostbox |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "os" |
| 8 | "path/filepath" |
| 9 | "strconv" |
| 10 | "testing" |
| 11 | "time" |
| 12 | |
| 13 | "github.com/carlosframework/platform/internal/hostclaim" |
| 14 | "github.com/carlosframework/platform/internal/hostguard" |
| 15 | "github.com/carlosframework/platform/internal/hostwrite" |
| 16 | "github.com/carlosframework/platform/internal/park" |
| 17 | "github.com/carlosframework/platform/internal/provision" |
| 18 | "github.com/carlosframework/platform/internal/registry" |
| 19 | "github.com/carlosframework/platform/internal/release" |
| 20 | ) |
| 21 | |
| 22 | // relocationReconcile is the box "box" as a relocation target: the |
| 23 | // production adapter with the real census and park.Local, region HA's |
| 24 | // relocation-aware reconciler (FenceClosed and HoldRow set), and the |
| 25 | // target's row minted held by MintHeld at the install stamp. |
| 26 | type relocationReconcile struct { |
| 27 | f admissionFixture |
| 28 | reg *registry.Registry |
| 29 | adapter ReconcileAdapter |
| 30 | cfg provision.ReconcileConfig |
| 31 | captured hostguard.Captured |
| 32 | holds []string |
| 33 | } |
| 34 | |
| 35 | func newRelocationReconcile(t *testing.T) *relocationReconcile { |
| 36 | t.Helper() |
| 37 | ctx := t.Context() |
| 38 | f := newAdmissionFixture(t, false) |
| 39 | r := &relocationReconcile{f: f} |
| 40 | var err error |
| 41 | if r.reg, err = registry.Open(filepath.Join(t.TempDir(), "registry.db")); err != nil { |
| 42 | t.Fatal(err) |
| 43 | } |
| 44 | t.Cleanup(func() { r.reg.Close() }) |
| 45 | inv, err := OpenStopInventoryDB(filepath.Join(t.TempDir(), "inventory.db")) |
| 46 | if err != nil { |
| 47 | t.Fatal(err) |
| 48 | } |
| 49 | t.Cleanup(func() { inv.Close() }) |
| 50 | launches, err := OpenLaunchJournal(filepath.Join(t.TempDir(), "launches.db")) |
| 51 | if err != nil { |
| 52 | t.Fatal(err) |
| 53 | } |
| 54 | t.Cleanup(func() { launches.Close() }) |
| 55 | census := &EmptyInstallationCensus{Inventory: inv, Registry: r.reg, Launches: launches, Box: "box", Enrollment: "en", DirectoryRevision: "1", |
| 56 | Roots: map[string]string{"data": f.data, "opt": t.TempDir(), "units": t.TempDir()}, SharedPaths: map[string][]string{"data": {"hostguard"}}} |
| 57 | guard := hostguard.Guard{DataDir: f.data, LockDir: t.TempDir(), Claims: hostguard.StoreReader{Store: f.store}} |
| 58 | source := WorkSource{Guard: guard, Box: "box", Region: "ie", Now: time.Now, |
| 59 | Policy: func(context.Context, *hostclaim.Claim, Selection, hostwrite.Identity) error { return nil }, |
| 60 | Local: func(context.Context, *hostclaim.Claim, Selection) error { return nil }} |
| 61 | r.adapter = ReconcileAdapter{Registry: r.reg, Inventory: inv, Census: census, Box: "box", Enrollment: "en", DirectoryRevision: "1", |
| 62 | Reconciler: Reconciler{Claims: f.store, Box: "box", Region: "ie", Guard: guard, Writes: hostwrite.Config{Enabled: true, Selector: source}, |
| 63 | Local: park.Local{Guard: guard, Claims: f.store, DataDir: f.data, Box: "box"}, |
| 64 | Now: func() time.Time { return time.Unix(100, 0) }, CheckRecord: func(context.Context, RecordSelection) error { return nil }, CheckLocal: census.CheckLocal, |
| 65 | FenceClosed: func(context.Context, hostclaim.Observed) (bool, error) { return false, nil }, |
| 66 | HoldRow: func(_ context.Context, host, op string) error { |
| 67 | r.holds = append(r.holds, op) |
| 68 | row, err := r.reg.Get(host) |
| 69 | if err != nil || row == nil { |
| 70 | return ErrChanged |
| 71 | } |
| 72 | if row.HoldOp == op { |
| 73 | return nil |
| 74 | } |
| 75 | return r.reg.SetHold(host, "relocating", op) |
| 76 | }}} |
| 77 | r.cfg = provision.ReconcileConfig{DataDir: f.data, Region: "ie"} |
| 78 | r.cfg.Layout = func(context.Context, release.Store) (string, error) { return release.LayoutModeNested, nil } |
| 79 | r.adapter.Configure(&r.cfg) |
| 80 | // The installing CAS: this box executes rel-1 at the install stamp. |
| 81 | o, err := hostclaim.Read(ctx, f.store, f.host) |
| 82 | if err != nil { |
| 83 | t.Fatal(err) |
| 84 | } |
| 85 | if o, err = hostclaim.Update(ctx, f.store, o, func(c *hostclaim.Claim) error { |
| 86 | c.Holder.Stamp = c.Gen + 1 |
| 87 | c.Op = hostclaim.Op{Kind: hostclaim.KindRelocating, ID: "rel-1", Phase: hostclaim.PhaseInstalling, Exec: "box", Participants: []string{"src", "box"}, |
| 88 | Intent: map[string]string{"region": "ie", "source_box": "src", "target_box": "box"}, Journal: map[string]string{"install_stamp": strconv.FormatUint(c.Holder.Stamp, 10)}} |
| 89 | return nil |
| 90 | }); err != nil { |
| 91 | t.Fatal(err) |
| 92 | } |
| 93 | if err := guard.Activate(ctx, f.host, "box", nil); err != nil { |
| 94 | t.Fatal(err) |
| 95 | } |
| 96 | r.captured = hostguard.Captured{Incarnation: o.Claim.Holder.Incarnation, Stamp: o.Claim.Holder.Stamp} |
| 97 | record := provision.Record{Schema: 1, Host: f.host, Slug: "shop", App: "app", Account: f.account, Region: "ie", Channel: "stable", CreatedAt: time.Date(2026, 10, 7, 0, 0, 0, 0, time.UTC)} |
| 98 | rec := provision.ClaimRecord{Host: f.host, Key: f.key, Family: "instance", Account: f.account, App: "app", CreatedAt: record.CreatedAt, Value: record, Route: provision.RouteFor(record, f.data, "", true, nil)} |
| 99 | if err := r.adapter.MintHeld(ctx, rec, r.captured, "relocating", "rel-1"); err != nil { |
| 100 | t.Fatal(err) |
| 101 | } |
| 102 | return r |
| 103 | } |
| 104 | |
| 105 | func (r *relocationReconcile) phase(t *testing.T, tr func(*hostclaim.Claim)) hostclaim.Observed { |
| 106 | t.Helper() |
| 107 | o, err := hostclaim.Read(t.Context(), r.f.store, r.f.host) |
| 108 | if err != nil { |
| 109 | t.Fatal(err) |
| 110 | } |
| 111 | if o, err = hostclaim.Update(t.Context(), r.f.store, o, func(c *hostclaim.Claim) error { tr(c); return nil }); err != nil { |
| 112 | t.Fatal(err) |
| 113 | } |
| 114 | return o |
| 115 | } |
| 116 | |
| 117 | func targetGuardFiles(t *testing.T, data string) map[string]string { |
| 118 | t.Helper() |
| 119 | out := map[string]string{} |
| 120 | entries, err := os.ReadDir(filepath.Join(data, "hostguard")) |
| 121 | if err != nil { |
| 122 | t.Fatal(err) |
| 123 | } |
| 124 | for _, e := range entries { |
| 125 | raw, err := os.ReadFile(filepath.Join(data, "hostguard", e.Name())) |
| 126 | if err != nil { |
| 127 | t.Fatal(err) |
| 128 | } |
| 129 | out[e.Name()] = string(raw) |
| 130 | } |
| 131 | return out |
| 132 | } |
| 133 | |
| 134 | // Ruling 31: under a relocating claim reconcile never activates the |
| 135 | // target. The target's held row (minted by its install) is left to the |
| 136 | // install at installing, installed and probing: stage A's check refuses |
| 137 | // the executor's reconcile before any write (claim Plan 11 admits no |
| 138 | // reconcile action under relocating), so the row, the guard record and |
| 139 | // the claim are unchanged and no hold is written. |
| 140 | func TestReconcileLeavesARelocatingTargetsRowToItsInstall(t *testing.T) { |
| 141 | ctx := t.Context() |
| 142 | r := newRelocationReconcile(t) |
| 143 | before, err := r.reg.Get(r.f.host) |
| 144 | if err != nil || before == nil { |
| 145 | t.Fatal(err) |
| 146 | } |
| 147 | for _, phase := range []string{hostclaim.PhaseInstalling, hostclaim.PhaseInstalled, hostclaim.PhaseRelocatingProbing} { |
| 148 | o := r.phase(t, func(c *hostclaim.Claim) { c.Op.Phase = phase }) |
| 149 | guard := targetGuardFiles(t, r.f.data) |
| 150 | r.holds = nil |
| 151 | if err := r.f.pass(ctx, r.f.store, r.reg, r.cfg); err != nil { |
| 152 | t.Fatalf("%s: %v", phase, err) |
| 153 | } |
| 154 | row, err := r.reg.Get(r.f.host) |
| 155 | if err != nil || row == nil || *row != *before { |
| 156 | t.Fatalf("%s: the row changed: %+v %v", phase, row, err) |
| 157 | } |
| 158 | if len(r.holds) != 0 { |
| 159 | t.Fatalf("%s: a hold was written: %v", phase, r.holds) |
| 160 | } |
| 161 | if got := targetGuardFiles(t, r.f.data); len(got) != len(guard) || got[r.f.host+".json"] != guard[r.f.host+".json"] { |
| 162 | t.Fatalf("%s: the guard record changed", phase) |
| 163 | } |
| 164 | after, err := hostclaim.Read(ctx, r.f.store, r.f.host) |
| 165 | if err != nil || after.ETag != o.ETag { |
| 166 | t.Fatalf("%s: the claim was written: %v", phase, err) |
| 167 | } |
| 168 | } |
| 169 | } |
| 170 | |
| 171 | // Rev 1 finding 5: after completion the target serves at the install |
| 172 | // stamp; relocationReleaseHold releases the row's hold (registry |
| 173 | // ReleaseHold, under the op); reconcile's real adapter then finds the |
| 174 | // installation's inventory coherent and keeps the row, ready and owned by |
| 175 | // the same installation. |
| 176 | func TestReconcileAdapterClearsTheHoldAfterCompletion(t *testing.T) { |
| 177 | ctx := t.Context() |
| 178 | r := newRelocationReconcile(t) |
| 179 | minted, err := r.reg.Get(r.f.host) |
| 180 | if err != nil || minted == nil { |
| 181 | t.Fatal(err) |
| 182 | } |
| 183 | r.phase(t, func(c *hostclaim.Claim) { |
| 184 | c.Op = hostclaim.Op{Kind: hostclaim.KindServing, Exec: "box", Participants: []string{"box"}} |
| 185 | c.Holder.WriterBox = "box" |
| 186 | }) |
| 187 | if changed, err := r.reg.ReleaseHold(r.f.host, "rel-1"); err != nil || !changed { |
| 188 | t.Fatalf("release hold: %v %v", changed, err) |
| 189 | } |
| 190 | if err := r.f.pass(ctx, r.f.store, r.reg, r.cfg); err != nil { |
| 191 | t.Fatalf("the serving reconcile: %v", err) |
| 192 | } |
| 193 | row, err := r.reg.Get(r.f.host) |
| 194 | if err != nil || row == nil || row.Status == "relocating" || row.HoldOp != "" || row.ClaimOwnership != minted.ClaimOwnership || !provision.NestedPaths(r.f.data, *row) { |
| 195 | t.Fatalf("after completion: %+v %v (minted %+v)", row, err, minted.ClaimOwnership) |
| 196 | } |
| 197 | if len(r.holds) != 0 { |
| 198 | t.Fatalf("a serving claim took the hold-only path: %v", r.holds) |
| 199 | } |
| 200 | } |
| 201 | |