| 1 | //go:build unix |
| 2 | |
| 3 | package hostbox |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "database/sql" |
| 8 | "encoding/json" |
| 9 | "errors" |
| 10 | "os" |
| 11 | "path/filepath" |
| 12 | "testing" |
| 13 | "time" |
| 14 | |
| 15 | "github.com/carlosframework/platform/internal/domains" |
| 16 | "github.com/carlosframework/platform/internal/hostclaim" |
| 17 | "github.com/carlosframework/platform/internal/hostguard" |
| 18 | "github.com/carlosframework/platform/internal/provision" |
| 19 | "github.com/carlosframework/platform/internal/registry" |
| 20 | "github.com/carlosframework/platform/internal/release" |
| 21 | ) |
| 22 | |
| 23 | // admissionFixture is a bucket holding one record (instance or sidecar) and |
| 24 | // the adopted hostclaim for its host, plus the identity a claim hook would |
| 25 | // stamp on the route. |
| 26 | type admissionFixture struct { |
| 27 | store release.Store |
| 28 | data string |
| 29 | account, host, key string |
| 30 | family, name string |
| 31 | holder hostclaim.Holder |
| 32 | owner registry.ClaimOwnership |
| 33 | pass func(context.Context, release.Store, *registry.Registry, provision.ReconcileConfig) error |
| 34 | } |
| 35 | |
| 36 | func newAdmissionFixture(t *testing.T, sidecar bool) admissionFixture { |
| 37 | t.Helper() |
| 38 | ctx := t.Context() |
| 39 | store := release.NewDirStore(t.TempDir()) |
| 40 | account := domains.Sqid(2) |
| 41 | raw, err := json.Marshal(map[string]any{"id": 2, "sqid": account, "name": "fixture", "created_at": "2026-08-02T09:00:00Z"}) |
| 42 | if err != nil { |
| 43 | t.Fatal(err) |
| 44 | } |
| 45 | if _, err := store.Put(ctx, "accounts/"+account+"/account.json", raw, ""); err != nil { |
| 46 | t.Fatal(err) |
| 47 | } |
| 48 | data := t.TempDir() |
| 49 | if err := os.Mkdir(filepath.Join(data, "hostguard"), 0770); err != nil { |
| 50 | t.Fatal(err) |
| 51 | } |
| 52 | created := time.Date(2026, 10, 7, 0, 0, 0, 0, time.UTC) |
| 53 | f := admissionFixture{store: store, data: data, account: account, host: "shop.example", family: "instance"} |
| 54 | f.key = provision.RecordKey(account, "app", f.host) |
| 55 | f.pass = provision.Reconcile |
| 56 | if sidecar { |
| 57 | f.host = provision.SidecarHost("worker", "app", account) |
| 58 | f.family, f.name = "sidecar", "worker" |
| 59 | f.key = provision.SidecarRecordKey(account, "app", "worker") |
| 60 | f.pass = provision.ReconcileSidecars |
| 61 | if err := provision.PutSidecarManifest(ctx, store, account, "app", provision.SidecarManifest{Schema: 1, App: "app", Args: []string{"run"}, Channel: "stable"}); err != nil { |
| 62 | t.Fatal(err) |
| 63 | } |
| 64 | if err := provision.CreateSidecarRecord(ctx, store, provision.SidecarRecord{Schema: 1, Name: "worker", App: "app", Account: account, Host: f.host, Region: "ie", Channel: "stable", CreatedAt: created}); err != nil { |
| 65 | t.Fatal(err) |
| 66 | } |
| 67 | } else { |
| 68 | if err := provision.PutManifest(ctx, store, account, "app", provision.Manifest{Schema: 1, App: "app", Domain: "example"}); err != nil { |
| 69 | t.Fatal(err) |
| 70 | } |
| 71 | if err := provision.CreateRecord(ctx, store, provision.Record{Schema: 1, Host: f.host, Slug: "shop", App: "app", Account: account, Region: "ie", Channel: "stable", CreatedAt: created}); err != nil { |
| 72 | t.Fatal(err) |
| 73 | } |
| 74 | } |
| 75 | o, err := hostclaim.Read(ctx, store, f.host) |
| 76 | if err != nil { |
| 77 | t.Fatal(err) |
| 78 | } |
| 79 | o, err = hostclaim.Update(ctx, store, o, hostclaim.Adopt(hostclaim.NewHolder{Family: f.family, Name: f.name, Key: f.key, Account: account, App: "app", Host: f.host, Region: "ie", CreatedAt: created}, "box", nil, nil, "adopt", func() string { return "id" })) |
| 80 | if err != nil { |
| 81 | t.Fatal(err) |
| 82 | } |
| 83 | f.holder = *o.Claim.Holder |
| 84 | f.owner = registry.ClaimOwnership{ClaimIncarnation: f.holder.Incarnation, ClaimStamp: f.holder.Stamp, ClaimOwner: "owner", ClaimRowID: "row"} |
| 85 | return f |
| 86 | } |
| 87 | |
| 88 | func TestClaimRefusalPreservesDurableGuardAndInventory(t *testing.T) { |
| 89 | type env struct { |
| 90 | cfg *provision.ReconcileConfig |
| 91 | reg *registry.Registry |
| 92 | f admissionFixture |
| 93 | } |
| 94 | allow := func(gates *int) func(context.Context, registry.Route) error { |
| 95 | return func(context.Context, registry.Route) error { *gates++; return nil } |
| 96 | } |
| 97 | refuse := func(gates *int) func(context.Context, registry.Route) error { |
| 98 | return func(context.Context, registry.Route) error { *gates++; return errors.New("config refused") } |
| 99 | } |
| 100 | upsert := func(t *testing.T, e env, rt registry.Route) { |
| 101 | t.Helper() |
| 102 | if err := e.reg.Upsert(rt); err != nil { |
| 103 | t.Fatal(err) |
| 104 | } |
| 105 | } |
| 106 | cases := []struct { |
| 107 | name string |
| 108 | sidecar bool |
| 109 | reached bool // the claim hook ran: guard and inventory written |
| 110 | gates int // ConfigGate calls this pass makes |
| 111 | apply func(*testing.T, env, *int) |
| 112 | }{ |
| 113 | {name: "inbound", apply: func(_ *testing.T, e env, _ *int) { e.cfg.Inbound = provision.InboundNone }}, |
| 114 | {name: "instance config", gates: 1, apply: func(_ *testing.T, e env, gates *int) { e.cfg.ConfigGate = refuse(gates) }}, |
| 115 | {name: "sidecar config", sidecar: true, gates: 1, apply: func(_ *testing.T, e env, gates *int) { e.cfg.ConfigGate = refuse(gates) }}, |
| 116 | {name: "co-spawn", sidecar: true, apply: func(t *testing.T, e env, _ *int) { |
| 117 | if err := os.WriteFile(filepath.Join(e.f.data, e.f.host+"-sidecar.env"), []byte("A=1\n"), 0600); err != nil { |
| 118 | t.Fatal(err) |
| 119 | } |
| 120 | }}, |
| 121 | {name: "instance shape mismatch", apply: func(t *testing.T, e env, gates *int) { |
| 122 | e.cfg.ConfigGate = allow(gates) |
| 123 | upsert(t, e, registry.Route{Host: e.f.host, Kind: "instance", App: "other", ClaimOwnership: e.f.owner}) |
| 124 | }}, |
| 125 | {name: "instance row without claim owner", apply: func(t *testing.T, e env, gates *int) { |
| 126 | e.cfg.ConfigGate = allow(gates) |
| 127 | upsert(t, e, registry.Route{Host: e.f.host, Kind: "instance", App: "app", Account: e.f.account, Provisioned: true}) |
| 128 | }}, |
| 129 | {name: "sidecar row not owned by this pass", sidecar: true, apply: func(t *testing.T, e env, gates *int) { |
| 130 | e.cfg.ConfigGate = allow(gates) |
| 131 | upsert(t, e, registry.Route{Host: e.f.host, Kind: provision.SidecarKind, Slug: "worker", App: "app", Account: e.f.account, ClaimOwnership: e.f.owner}) |
| 132 | }}, |
| 133 | {name: "sidecar row without claim owner", sidecar: true, apply: func(t *testing.T, e env, gates *int) { |
| 134 | e.cfg.ConfigGate = allow(gates) |
| 135 | upsert(t, e, registry.Route{Host: e.f.host, Kind: provision.SidecarKind, Slug: "worker", App: "app", Account: e.f.account, Provisioned: true}) |
| 136 | }}, |
| 137 | // Positive controls: the same fixtures reach the hook when nothing refuses. |
| 138 | {name: "instance accepted", reached: true, gates: 1, apply: func(_ *testing.T, e env, gates *int) { e.cfg.ConfigGate = allow(gates) }}, |
| 139 | {name: "sidecar accepted", sidecar: true, reached: true, gates: 1, apply: func(_ *testing.T, e env, gates *int) { e.cfg.ConfigGate = allow(gates) }}, |
| 140 | {name: "instance owned row kept", reached: true, apply: func(t *testing.T, e env, gates *int) { |
| 141 | e.cfg.ConfigGate = allow(gates) |
| 142 | upsert(t, e, registry.Route{Host: e.f.host, Kind: "instance", App: "app", Account: e.f.account, Provisioned: true, ClaimOwnership: e.f.owner}) |
| 143 | }}, |
| 144 | {name: "sidecar owned row kept", sidecar: true, reached: true, apply: func(t *testing.T, e env, gates *int) { |
| 145 | e.cfg.ConfigGate = allow(gates) |
| 146 | upsert(t, e, registry.Route{Host: e.f.host, Kind: provision.SidecarKind, Slug: "worker", App: "app", Account: e.f.account, Provisioned: true, ClaimOwnership: e.f.owner}) |
| 147 | }}, |
| 148 | } |
| 149 | for _, tc := range cases { |
| 150 | t.Run(tc.name, func(t *testing.T) { |
| 151 | ctx := t.Context() |
| 152 | f := newAdmissionFixture(t, tc.sidecar) |
| 153 | reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| 154 | if err != nil { |
| 155 | t.Fatal(err) |
| 156 | } |
| 157 | defer reg.Close() |
| 158 | inventory, err := OpenStopInventoryDB(filepath.Join(t.TempDir(), "inventory.db")) |
| 159 | if err != nil { |
| 160 | t.Fatal(err) |
| 161 | } |
| 162 | defer inventory.Close() |
| 163 | guard := hostguard.Guard{DataDir: f.data, LockDir: t.TempDir(), Claims: hostguard.StoreReader{Store: f.store}} |
| 164 | h := f.holder |
| 165 | in := StopInventory{Host: f.host, Incarnation: h.Incarnation, InstallationStamp: h.Stamp, Box: "box", Enrollment: "en", DirectoryRevision: "1", Complete: true} |
| 166 | calls, gates := 0, 0 |
| 167 | cfg := provision.ReconcileConfig{DataDir: f.data, ClaimEnabled: true, Region: "ie"} |
| 168 | cfg.ClaimInstall = func(ctx context.Context, selected provision.ClaimRecord, write func(registry.ClaimOwnership) error) error { |
| 169 | calls++ |
| 170 | if err := guard.Activate(ctx, selected.Host, "box", nil); err != nil { |
| 171 | return err |
| 172 | } |
| 173 | if err := inventory.WriteStopInventory(ctx, in); err != nil { |
| 174 | return err |
| 175 | } |
| 176 | return write(f.owner) |
| 177 | } |
| 178 | tc.apply(t, env{cfg: &cfg, reg: reg, f: f}, &gates) |
| 179 | before, err := reg.Get(f.host) |
| 180 | if err != nil { |
| 181 | t.Fatal(err) |
| 182 | } |
| 183 | if err := f.pass(ctx, f.store, reg, cfg); err != nil { |
| 184 | t.Fatal(err) |
| 185 | } |
| 186 | if gates != tc.gates { |
| 187 | t.Fatalf("config gate asked %d times, want %d", gates, tc.gates) |
| 188 | } |
| 189 | _, guardErr := os.ReadFile(filepath.Join(f.data, "hostguard", f.host+".json")) |
| 190 | _, inventoryErr := inventory.ReadInstallation(ctx, f.host, h.Incarnation, h.Stamp) |
| 191 | after, err := reg.Get(f.host) |
| 192 | if err != nil { |
| 193 | t.Fatal(err) |
| 194 | } |
| 195 | if tc.reached { |
| 196 | if calls != 1 || guardErr != nil || inventoryErr != nil { |
| 197 | t.Fatalf("positive control: calls=%d guard=%v inventory=%v", calls, guardErr, inventoryErr) |
| 198 | } |
| 199 | if after == nil || after.ClaimOwnership != f.owner { |
| 200 | t.Fatalf("positive control lost ownership: %+v", after) |
| 201 | } |
| 202 | return |
| 203 | } |
| 204 | if calls != 0 || !errors.Is(guardErr, os.ErrNotExist) || !errors.Is(inventoryErr, sql.ErrNoRows) { |
| 205 | t.Fatalf("refusal changed durable state: calls=%d guard=%v inventory=%v", calls, guardErr, inventoryErr) |
| 206 | } |
| 207 | if (before == nil) != (after == nil) || (before != nil && *before != *after) { |
| 208 | t.Fatalf("refusal changed the registry: before=%+v after=%+v", before, after) |
| 209 | } |
| 210 | }) |
| 211 | } |
| 212 | } |
| 213 | |