| 1 | //go:build linux |
| 2 | |
| 3 | package host |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "fmt" |
| 8 | "testing" |
| 9 | |
| 10 | "github.com/carlosframework/platform/internal/hostbox" |
| 11 | "github.com/carlosframework/platform/internal/hostclaim" |
| 12 | "github.com/carlosframework/platform/internal/hostguard" |
| 13 | "github.com/carlosframework/platform/internal/hostwrite" |
| 14 | "github.com/carlosframework/platform/internal/registry" |
| 15 | "github.com/carlosframework/platform/internal/rootstate" |
| 16 | "github.com/carlosframework/platform/internal/schedule" |
| 17 | ) |
| 18 | |
| 19 | // witnessPolicy is the production claim policy as claimconstruction.go |
| 20 | // composes it (hostbox.Policy with the registry hold policy, then |
| 21 | // WithRefusals) carrying claimproduction.go's unit-shape refusal from |
| 22 | // productionClaimUnitShapePolicy. The witnesses used an allow-all policy, |
| 23 | // which hid that the refusal refused every fresh install (harness G10, |
| 24 | // 2026-10-21). |
| 25 | // |
| 26 | // The refusal runs in instance-control mode: agent mode asks root's |
| 27 | // boundInputs over the role-launch socket, which these in-process worlds do |
| 28 | // not serve. Both modes share the row lookup this pins, and |
| 29 | // TestClaimUnitShapePolicyFreshInstallAgentReader covers root's reader. The |
| 30 | // unit-binding store and claim-units configuration are empty seams: no |
| 31 | // configured shape and no saved binding, as on a box with no bound units. |
| 32 | // missing counts the lookups that found no row. |
| 33 | func witnessPolicy(t *testing.T, reg *registry.Registry) (policy func(context.Context, *hostclaim.Claim, hostbox.Selection, hostwrite.Identity) error, missing *int) { |
| 34 | t.Helper() |
| 35 | oldOpen, oldShapes := openClaimUnitBindingsRead, productionClaimUnitShapes |
| 36 | t.Cleanup(func() { openClaimUnitBindingsRead, productionClaimUnitShapes = oldOpen, oldShapes }) |
| 37 | openClaimUnitBindingsRead = func() (rootstate.Store, error) { return &rollbackMemory{files: map[string][]byte{}}, nil } |
| 38 | productionClaimUnitShapes = func() (map[string]claimUnitShape, error) { return map[string]claimUnitShape{}, nil } |
| 39 | missing = new(int) |
| 40 | route := func(host string) (*registry.Route, error) { |
| 41 | rt, err := reg.Get(host) |
| 42 | if err == nil && rt == nil { |
| 43 | *missing++ |
| 44 | } |
| 45 | return rt, err |
| 46 | } |
| 47 | return hostbox.Policy{Held: hostbox.RegistryHoldPolicy(reg)}.WithRefusals(productionClaimUnitShapePolicy("instance-control", route)).Check, missing |
| 48 | } |
| 49 | |
| 50 | // G10 (harness, 2026-10-21): the first claim install on an empty box, through |
| 51 | // the remote path into root's real commitRootInventory, under the production |
| 52 | // policy. The install's first work.With (inventory_registration.go's route |
| 53 | // entry) runs before the registry row exists; the unit-shape refusal used to |
| 54 | // answer ErrChanged there on every attempt, leaving root's inventory at |
| 55 | // revision 1 with no route entry and no row. |
| 56 | func TestClaimFreshInstallUnderProductionPolicy(t *testing.T) { |
| 57 | w := newBindingInstallWorld(t) |
| 58 | policy, missing := witnessPolicy(t, w.reg) |
| 59 | w.source.Policy = policy |
| 60 | w.adapter.Reconciler.Writes = hostwrite.Config{Enabled: true, Selector: w.source} |
| 61 | if row, err := w.reg.Get(w.rec.Host); err != nil || row != nil { |
| 62 | t.Fatalf("not a fresh install: row %v, %v", row, err) |
| 63 | } |
| 64 | |
| 65 | w.install(t) |
| 66 | |
| 67 | if *missing == 0 { |
| 68 | t.Fatal("the unit-shape refusal never saw the fresh install's missing row") |
| 69 | } |
| 70 | in, revision := w.inventory(t) |
| 71 | if revision != 3 || len(in.Entries) != 1 || in.Entries[0].Kind != "route" || in.Entries[0].Materialization != "materialized" || in.DirectoryRevision != w.pinned.Binding { |
| 72 | t.Fatalf("inventory revision %d, entries %+v, stamp %q", revision, in.Entries, in.DirectoryRevision) |
| 73 | } |
| 74 | row, err := w.reg.Get(w.rec.Host) |
| 75 | if err != nil || row == nil || row.ClaimOwnership != w.route.ClaimOwnership || row.ClaimRowID != in.Entries[0].RowIdentity { |
| 76 | t.Fatalf("registry row %+v, %v", row, err) |
| 77 | } |
| 78 | holder := w.f.read().Claim.Holder |
| 79 | if row.ClaimIncarnation != holder.Incarnation || row.ClaimStamp != holder.Stamp { |
| 80 | t.Fatalf("row owned by %s/%d, holder %s/%d", row.ClaimIncarnation, row.ClaimStamp, holder.Incarnation, holder.Stamp) |
| 81 | } |
| 82 | view, err := w.rootAuth.View(t.Context(), w.rec.Host, hostguard.Captured{Incarnation: holder.Incarnation, Stamp: holder.Stamp}) |
| 83 | if err != nil || view.Installation.InventoryRevision != revision { |
| 84 | t.Fatalf("guard inventory revision %d (journal %d), %v", view.Installation.InventoryRevision, revision, err) |
| 85 | } |
| 86 | } |
| 87 | |
| 88 | // Fix round 2 (Astra on 4dd4c436): only the installation's own work may go |
| 89 | // on without a registry row. A timer the runner selected for a serving host |
| 90 | // keeps its cached route; when the route is removed after that read, while |
| 91 | // the claim, installation, timer row and running producer all survive, the |
| 92 | // production policy must refuse both the delivery's producer registration |
| 93 | // (ClaimTimerDelivery) and the runner's bookkeeping (Runner.record's |
| 94 | // MarkTimerFired, which runs inside the timer work's With exactly as here). |
| 95 | func TestClaimTimerRefusedAfterRouteRemoval(t *testing.T) { |
| 96 | w := newBindingInstallWorld(t) |
| 97 | w.install(t) |
| 98 | ctx := t.Context() |
| 99 | host := w.rec.Host |
| 100 | if err := w.reg.UpsertTimer(registry.Timer{Host: host, Name: "nightly", Kind: schedule.KindOnce, Spec: "once", Path: "/tick", NextAt: 1}); err != nil { |
| 101 | t.Fatal(err) |
| 102 | } |
| 103 | timer, err := w.reg.GetTimer(host, "nightly") |
| 104 | if err != nil || timer == nil { |
| 105 | t.Fatal(timer, err) |
| 106 | } |
| 107 | route, err := w.reg.Get(host) // the runner's cached route |
| 108 | if err != nil || route == nil || !route.ClaimOwnership.Valid() { |
| 109 | t.Fatal(route, err) |
| 110 | } |
| 111 | timer.ClaimOwnership = route.ClaimOwnership // as a claim box's timer rows carry it |
| 112 | holder := w.f.read().Claim.Holder |
| 113 | work, err := w.source.Select(ctx, host, "timer") |
| 114 | if err != nil { |
| 115 | t.Fatal(err) |
| 116 | } |
| 117 | if s, err := hostbox.Selected(work); err != nil || s.Installation() { |
| 118 | t.Fatal("timer work is marked as installation work", err) |
| 119 | } |
| 120 | target := hostguard.FenceTarget{Host: host, Incarnation: holder.Incarnation, Stamp: holder.Stamp, Session: holder.Session, Box: w.pinned.Box, Enrollment: w.pinned.Enrollment, ControllerID: "controller"} |
| 121 | var registered []string |
| 122 | p, err := NewClaimProducers(target, "start", func(_ context.Context, _ hostguard.FenceTarget, id string) error { |
| 123 | registered = append(registered, id) |
| 124 | return nil |
| 125 | }) |
| 126 | if err != nil { |
| 127 | t.Fatal(err) |
| 128 | } |
| 129 | set := &ClaimProducerSet{} |
| 130 | if err := set.Add(p); err != nil { |
| 131 | t.Fatal(err) |
| 132 | } |
| 133 | seq := 0 |
| 134 | deliver := ClaimTimerDelivery(set, func() string { seq++; return fmt.Sprintf("d%d", seq) }) |
| 135 | mark := func(at int64) (bool, error) { |
| 136 | var applied bool |
| 137 | err := work.With(ctx, hostwrite.Identity{}, func() error { |
| 138 | var e error |
| 139 | applied, e = w.reg.MarkTimerFired(host, timer.Name, timer.Registration, at, "200", 5, 0, 0, 0) |
| 140 | return e |
| 141 | }) |
| 142 | return applied, err |
| 143 | } |
| 144 | |
| 145 | // With the route present the same work delivers and records. |
| 146 | _, finish, err := deliver(ctx, work, *route, *timer) |
| 147 | if err != nil || len(registered) != 1 { |
| 148 | t.Fatalf("delivery with the route present: %v, registrations %v", err, registered) |
| 149 | } |
| 150 | finish() |
| 151 | if applied, err := mark(100); err != nil || !applied { |
| 152 | t.Fatalf("bookkeeping with the route present: applied %v, %v", applied, err) |
| 153 | } |
| 154 | before, err := w.reg.GetTimer(host, "nightly") |
| 155 | if err != nil || before == nil || before.LastAt != 100 { |
| 156 | t.Fatal(before, err) |
| 157 | } |
| 158 | |
| 159 | // The route goes after the runner read it. |
| 160 | if err := w.reg.Delete(host); err != nil { |
| 161 | t.Fatal(err) |
| 162 | } |
| 163 | if rt, err := w.reg.Get(host); err != nil || rt != nil { |
| 164 | t.Fatal("route still present", rt, err) |
| 165 | } |
| 166 | if still, err := w.reg.GetTimer(host, "nightly"); err != nil || still == nil { |
| 167 | t.Fatal("the timer row went with the route; this witness needs it to survive", err) |
| 168 | } |
| 169 | if _, _, err := deliver(ctx, work, *route, *timer); err == nil || len(registered) != 1 { |
| 170 | t.Errorf("delivered after the route was removed: %v, registrations %v", err, registered) |
| 171 | } |
| 172 | if applied, err := mark(200); err == nil || applied { |
| 173 | t.Errorf("bookkeeping after the route was removed: applied %v, %v", applied, err) |
| 174 | } |
| 175 | after, err := w.reg.GetTimer(host, "nightly") |
| 176 | if err != nil || after == nil || *after != *before { |
| 177 | t.Errorf("timer row changed after the route was removed: %+v (was %+v), %v", after, before, err) |
| 178 | } |
| 179 | } |
| 180 | |