| 1 | package provision |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "github.com/carlosframework/platform/internal/registry" |
| 7 | "github.com/carlosframework/platform/internal/release" |
| 8 | "os" |
| 9 | "path/filepath" |
| 10 | "testing" |
| 11 | ) |
| 12 | |
| 13 | func TestClaimRemovalUsesTypedHookForBothPasses(t *testing.T) { |
| 14 | for _, sidecar := range []bool{false, true} { |
| 15 | t.Run(map[bool]string{false: "instance", true: "sidecar"}[sidecar], func(t *testing.T) { |
| 16 | r, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| 17 | if err != nil { |
| 18 | t.Fatal(err) |
| 19 | } |
| 20 | defer r.Close() |
| 21 | rt := registry.Route{Host: "shop.example", Kind: "instance", Provisioned: true, ClaimOwnership: registry.ClaimOwnership{ClaimIncarnation: "inc", ClaimStamp: 1, ClaimOwner: "owner", ClaimRowID: "route"}} |
| 22 | if sidecar { |
| 23 | rt.Kind = SidecarKind |
| 24 | rt.Host = "worker.sidecar.internal" |
| 25 | } |
| 26 | if err = r.Upsert(rt); err != nil { |
| 27 | t.Fatal(err) |
| 28 | } |
| 29 | cfg := ReconcileConfig{DataDir: t.TempDir(), ClaimEnabled: true} |
| 30 | calls := 0 |
| 31 | pass := Reconcile |
| 32 | if sidecar { |
| 33 | pass = ReconcileSidecars |
| 34 | } |
| 35 | store := release.NewDirStore(t.TempDir()) |
| 36 | if err = pass(context.Background(), store, r, cfg); err != nil { |
| 37 | t.Fatal(err) |
| 38 | } |
| 39 | if got, e := r.Get(rt.Host); e != nil || got == nil { |
| 40 | t.Fatal("nil provider deleted route") |
| 41 | } |
| 42 | cfg.ClaimRetire = func(_ context.Context, got registry.Route) error { |
| 43 | calls++ |
| 44 | if got.ClaimOwnership != rt.ClaimOwnership { |
| 45 | t.Fatal("ownership dropped") |
| 46 | } |
| 47 | return ErrClaimAdmission |
| 48 | } |
| 49 | if err = pass(context.Background(), store, r, cfg); err != nil { |
| 50 | t.Fatal(err) |
| 51 | } |
| 52 | if calls != 1 { |
| 53 | t.Fatalf("typed calls = %d", calls) |
| 54 | } |
| 55 | if got, e := r.Get(rt.Host); e != nil || got == nil { |
| 56 | t.Fatal("refused retirement deleted route") |
| 57 | } |
| 58 | }) |
| 59 | } |
| 60 | } |
| 61 | |
| 62 | func TestClaimInstallGuardsMintAndKeepInBothPasses(t *testing.T) { |
| 63 | for _, sidecar := range []bool{false, true} { |
| 64 | t.Run(map[bool]string{false: "instance", true: "sidecar"}[sidecar], func(t *testing.T) { |
| 65 | var s release.Store |
| 66 | var reg *registry.Registry |
| 67 | var host string |
| 68 | cfg := ReconcileConfig{ClaimEnabled: true, DataDir: t.TempDir()} |
| 69 | pass := Reconcile |
| 70 | if sidecar { |
| 71 | f := sidecarSetup(t) |
| 72 | f.putManifest(t, "eleven", SidecarManifest{Schema: 1, App: "eleven", Args: []string{"run"}, Channel: "stable"}) |
| 73 | f.putRecord(t, SidecarRecord{Schema: 1, Name: "bot", App: "eleven", Channel: "stable"}) |
| 74 | s, reg, host = f.store, f.reg, f.host("bot", "eleven") |
| 75 | pass = ReconcileSidecars |
| 76 | } else { |
| 77 | store, sqid := setup(t) |
| 78 | record := acmeRecord(sqid) |
| 79 | if err := CreateRecord(ctx, store, record); err != nil { |
| 80 | t.Fatal(err) |
| 81 | } |
| 82 | s, reg, host = store, regWith(t), record.Host |
| 83 | cfg.Region = "ie" |
| 84 | cfg.Pools = []string{"tito"} |
| 85 | } |
| 86 | defer reg.Close() |
| 87 | if err := pass(ctx, s, reg, cfg); err != nil { |
| 88 | t.Fatal(err) |
| 89 | } |
| 90 | if got, err := reg.Get(host); err != nil || got != nil { |
| 91 | t.Fatal("missing provider minted route") |
| 92 | } |
| 93 | calls := 0 |
| 94 | owner := registry.ClaimOwnership{ClaimIncarnation: "inc", ClaimStamp: 1, ClaimOwner: "owner", ClaimRowID: "row"} |
| 95 | cfg.ClaimInstall = func(_ context.Context, rec ClaimRecord, write func(registry.ClaimOwnership) error) error { |
| 96 | calls++ |
| 97 | if rec.Host != host || rec.Key == "" || rec.Family == "" || rec.Value == nil { |
| 98 | t.Fatal("selection identity missing") |
| 99 | } |
| 100 | return write(owner) |
| 101 | } |
| 102 | for i := 0; i < 2; i++ { |
| 103 | if err := pass(ctx, s, reg, cfg); err != nil { |
| 104 | t.Fatal(err) |
| 105 | } |
| 106 | } |
| 107 | if calls != 2 { |
| 108 | t.Fatalf("mint and keep calls = %d", calls) |
| 109 | } |
| 110 | got, err := reg.Get(host) |
| 111 | if err != nil || got == nil || got.ClaimOwnership != owner { |
| 112 | t.Fatalf("mint lost ownership: %v", err) |
| 113 | } |
| 114 | owner.ClaimStamp++ |
| 115 | if err := pass(ctx, s, reg, cfg); err != nil { |
| 116 | t.Fatal(err) |
| 117 | } |
| 118 | current, err := reg.Get(host) |
| 119 | if err != nil || *current != *got { |
| 120 | t.Fatalf("stale keep changed successor: %v", err) |
| 121 | } |
| 122 | }) |
| 123 | } |
| 124 | } |
| 125 | |
| 126 | // claimEffects stands in for the production ClaimInstall hook's durable |
| 127 | // effects: activating the host guard and writing the inventory. Each call |
| 128 | // leaves a sentinel file for both, so a pass that reached the hook cannot |
| 129 | // look untouched, whatever the hook went on to decide. |
| 130 | type claimEffects struct { |
| 131 | dir string |
| 132 | owner registry.ClaimOwnership |
| 133 | calls int |
| 134 | } |
| 135 | |
| 136 | func newClaimEffects(t *testing.T) *claimEffects { |
| 137 | t.Helper() |
| 138 | return &claimEffects{dir: t.TempDir(), owner: registry.ClaimOwnership{ClaimIncarnation: "inc", ClaimStamp: 1, ClaimOwner: "owner", ClaimRowID: "row"}} |
| 139 | } |
| 140 | |
| 141 | func (e *claimEffects) hook(t *testing.T) func(context.Context, ClaimRecord, func(registry.ClaimOwnership) error) error { |
| 142 | return func(_ context.Context, rec ClaimRecord, write func(registry.ClaimOwnership) error) error { |
| 143 | e.calls++ |
| 144 | for _, name := range []string{"guard.active", "inventory." + rec.Host} { |
| 145 | if err := os.WriteFile(filepath.Join(e.dir, name), []byte(rec.Key), 0o600); err != nil { |
| 146 | t.Fatal(err) |
| 147 | } |
| 148 | } |
| 149 | return write(e.owner) |
| 150 | } |
| 151 | } |
| 152 | |
| 153 | // untouched fails unless the hook never ran: no call, no guard, no inventory. |
| 154 | func (e *claimEffects) untouched(t *testing.T, why string) { |
| 155 | t.Helper() |
| 156 | left, err := os.ReadDir(e.dir) |
| 157 | if err != nil { |
| 158 | t.Fatal(err) |
| 159 | } |
| 160 | if e.calls != 0 || len(left) != 0 { |
| 161 | t.Fatalf("%s: claim hook ran before refusal: calls=%d sentinels=%d", why, e.calls, len(left)) |
| 162 | } |
| 163 | } |
| 164 | |
| 165 | // reached fails unless the hook ran exactly n times and left its sentinels, |
| 166 | // proving the harness can observe the effect untouched() denies. |
| 167 | func (e *claimEffects) reached(t *testing.T, n int, why string) { |
| 168 | t.Helper() |
| 169 | left, err := os.ReadDir(e.dir) |
| 170 | if err != nil { |
| 171 | t.Fatal(err) |
| 172 | } |
| 173 | if e.calls != n || len(left) == 0 { |
| 174 | t.Fatalf("%s: claim hook calls=%d (want %d) sentinels=%d", why, e.calls, n, len(left)) |
| 175 | } |
| 176 | } |
| 177 | |
| 178 | func instanceClaimFixture(t *testing.T) (release.Store, *registry.Registry, string, ReconcileConfig) { |
| 179 | t.Helper() |
| 180 | store, sqid := setup(t) |
| 181 | record := acmeRecord(sqid) |
| 182 | if err := CreateRecord(ctx, store, record); err != nil { |
| 183 | t.Fatal(err) |
| 184 | } |
| 185 | reg := regWith(t) |
| 186 | t.Cleanup(func() { reg.Close() }) |
| 187 | return store, reg, record.Host, ReconcileConfig{ClaimEnabled: true, DataDir: t.TempDir(), Region: "ie", Pools: []string{"tito"}} |
| 188 | } |
| 189 | |
| 190 | func sidecarClaimFixture(t *testing.T) (*sidecarFixture, string, ReconcileConfig) { |
| 191 | t.Helper() |
| 192 | f := sidecarSetup(t) |
| 193 | t.Cleanup(func() { f.reg.Close() }) |
| 194 | f.putManifest(t, "eleven", SidecarManifest{Schema: 1, App: "eleven", Args: []string{"run"}, Channel: "stable"}) |
| 195 | f.putRecord(t, SidecarRecord{Schema: 1, Name: "bot", App: "eleven", Channel: "stable"}) |
| 196 | return f, f.host("bot", "eleven"), ReconcileConfig{ClaimEnabled: true, DataDir: f.dir} |
| 197 | } |
| 198 | |
| 199 | func TestClaimRefusalsPrecedeInstallHookInstanceMint(t *testing.T) { |
| 200 | refuse := errors.New("no sealed config") |
| 201 | cases := []struct { |
| 202 | name string |
| 203 | apply func(*ReconcileConfig, *int) |
| 204 | gated bool |
| 205 | }{ |
| 206 | {"inbound none", func(cfg *ReconcileConfig, _ *int) { cfg.Inbound = InboundNone }, false}, |
| 207 | {"config gate", func(cfg *ReconcileConfig, gates *int) { |
| 208 | cfg.ConfigGate = func(context.Context, registry.Route) error { *gates++; return refuse } |
| 209 | }, true}, |
| 210 | {"layout unreadable", func(cfg *ReconcileConfig, _ *int) { |
| 211 | cfg.Layout = func(context.Context, release.Store) (string, error) { return "", refuse } |
| 212 | }, false}, |
| 213 | } |
| 214 | for _, tc := range cases { |
| 215 | t.Run(tc.name, func(t *testing.T) { |
| 216 | s, reg, host, cfg := instanceClaimFixture(t) |
| 217 | fx := newClaimEffects(t) |
| 218 | cfg.ClaimInstall = fx.hook(t) |
| 219 | gates := 0 |
| 220 | tc.apply(&cfg, &gates) |
| 221 | if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| 222 | t.Fatal(err) |
| 223 | } |
| 224 | fx.untouched(t, tc.name) |
| 225 | if got, err := reg.Get(host); err != nil || got != nil { |
| 226 | t.Fatalf("refused mint wrote a route: %v", err) |
| 227 | } |
| 228 | if tc.gated && gates != 1 { |
| 229 | t.Fatalf("config gate asked %d times, want once", gates) |
| 230 | } |
| 231 | }) |
| 232 | } |
| 233 | t.Run("accepted mint reaches the hook once", func(t *testing.T) { |
| 234 | s, reg, host, cfg := instanceClaimFixture(t) |
| 235 | fx := newClaimEffects(t) |
| 236 | cfg.ClaimInstall = fx.hook(t) |
| 237 | cfg.ConfigGate = func(context.Context, registry.Route) error { return nil } |
| 238 | if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| 239 | t.Fatal(err) |
| 240 | } |
| 241 | fx.reached(t, 1, "accepted") |
| 242 | if got, err := reg.Get(host); err != nil || got == nil || got.ClaimOwnership != fx.owner { |
| 243 | t.Fatalf("accepted mint lost ownership: %v", err) |
| 244 | } |
| 245 | }) |
| 246 | } |
| 247 | |
| 248 | // Both refusals are mint-time rules. A kept row converges as before, and its |
| 249 | // claim hook still runs, whatever the box's inbound posture or config gate. |
| 250 | func TestClaimKeepStillReachesInstallHookInstance(t *testing.T) { |
| 251 | s, reg, host, cfg := instanceClaimFixture(t) |
| 252 | fx := newClaimEffects(t) |
| 253 | cfg.ClaimInstall = fx.hook(t) |
| 254 | if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| 255 | t.Fatal(err) |
| 256 | } |
| 257 | fx.reached(t, 1, "mint") |
| 258 | minted, err := reg.Get(host) |
| 259 | if err != nil || minted == nil { |
| 260 | t.Fatalf("mint: %v", err) |
| 261 | } |
| 262 | gates := 0 |
| 263 | cfg.Inbound = InboundNone |
| 264 | cfg.ConfigGate = func(context.Context, registry.Route) error { gates++; return errors.New("refuse") } |
| 265 | if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| 266 | t.Fatal(err) |
| 267 | } |
| 268 | fx.reached(t, 2, "keep") |
| 269 | if gates != 0 { |
| 270 | t.Fatalf("config gate asked %d times for a kept row", gates) |
| 271 | } |
| 272 | if got, err := reg.Get(host); err != nil || got == nil || *got != *minted { |
| 273 | t.Fatalf("keep changed the row: %v", err) |
| 274 | } |
| 275 | } |
| 276 | |
| 277 | func TestClaimRefusalsPrecedeInstallHookSidecarMint(t *testing.T) { |
| 278 | refuse := errors.New("no sealed config") |
| 279 | cases := []struct { |
| 280 | name string |
| 281 | apply func(*testing.T, string, *ReconcileConfig, *int) |
| 282 | }{ |
| 283 | {"co-spawn staged", func(t *testing.T, host string, cfg *ReconcileConfig, _ *int) { |
| 284 | if err := os.WriteFile(filepath.Join(cfg.DataDir, host+sidecarCoSpawnSuffix), []byte("A=1\n"), 0o600); err != nil { |
| 285 | t.Fatal(err) |
| 286 | } |
| 287 | }}, |
| 288 | {"config gate", func(_ *testing.T, _ string, cfg *ReconcileConfig, gates *int) { |
| 289 | cfg.ConfigGate = func(context.Context, registry.Route) error { *gates++; return refuse } |
| 290 | }}, |
| 291 | } |
| 292 | for _, tc := range cases { |
| 293 | t.Run(tc.name, func(t *testing.T) { |
| 294 | f, host, cfg := sidecarClaimFixture(t) |
| 295 | fx := newClaimEffects(t) |
| 296 | cfg.ClaimInstall = fx.hook(t) |
| 297 | gates := 0 |
| 298 | tc.apply(t, host, &cfg, &gates) |
| 299 | f.reconcile(t, cfg) |
| 300 | fx.untouched(t, tc.name) |
| 301 | if got, err := f.reg.Get(host); err != nil || got != nil { |
| 302 | t.Fatalf("refused mint wrote a route: %v", err) |
| 303 | } |
| 304 | if tc.name == "config gate" && gates != 1 { |
| 305 | t.Fatalf("config gate asked %d times, want once", gates) |
| 306 | } |
| 307 | }) |
| 308 | } |
| 309 | t.Run("accepted mint reaches the hook once", func(t *testing.T) { |
| 310 | f, host, cfg := sidecarClaimFixture(t) |
| 311 | fx := newClaimEffects(t) |
| 312 | cfg.ClaimInstall = fx.hook(t) |
| 313 | cfg.ConfigGate = func(context.Context, registry.Route) error { return nil } |
| 314 | f.reconcile(t, cfg) |
| 315 | fx.reached(t, 1, "accepted") |
| 316 | if got, err := f.reg.Get(host); err != nil || got == nil || got.ClaimOwnership != fx.owner { |
| 317 | t.Fatalf("accepted mint lost ownership: %v", err) |
| 318 | } |
| 319 | }) |
| 320 | } |
| 321 | |
| 322 | // The co-spawn rule is mint-time only: a file staged after the row exists |
| 323 | // must not freeze the row's convergence, nor stop its claim hook. |
| 324 | func TestClaimKeepStillReachesInstallHookSidecar(t *testing.T) { |
| 325 | f, host, cfg := sidecarClaimFixture(t) |
| 326 | fx := newClaimEffects(t) |
| 327 | cfg.ClaimInstall = fx.hook(t) |
| 328 | f.reconcile(t, cfg) |
| 329 | fx.reached(t, 1, "mint") |
| 330 | minted, err := f.reg.Get(host) |
| 331 | if err != nil || minted == nil { |
| 332 | t.Fatalf("mint: %v", err) |
| 333 | } |
| 334 | gates := 0 |
| 335 | cfg.ConfigGate = func(context.Context, registry.Route) error { gates++; return errors.New("refuse") } |
| 336 | if err := os.WriteFile(filepath.Join(cfg.DataDir, host+sidecarCoSpawnSuffix), []byte("A=1\n"), 0o600); err != nil { |
| 337 | t.Fatal(err) |
| 338 | } |
| 339 | f.reconcile(t, cfg) |
| 340 | fx.reached(t, 2, "keep") |
| 341 | if gates != 0 { |
| 342 | t.Fatalf("config gate asked %d times for a kept row", gates) |
| 343 | } |
| 344 | if got, err := f.reg.Get(host); err != nil || got == nil || *got != *minted { |
| 345 | t.Fatalf("keep changed the row: %v", err) |
| 346 | } |
| 347 | } |
| 348 | |
| 349 | // With the flag off the claim hook is never consulted, and the same refusals |
| 350 | // still decide the mint exactly as before. |
| 351 | func TestClaimFlagOffRefusalsUnchanged(t *testing.T) { |
| 352 | refuse := func(context.Context, registry.Route) error { return errors.New("refuse") } |
| 353 | hookRan := false |
| 354 | hook := func(context.Context, ClaimRecord, func(registry.ClaimOwnership) error) error { |
| 355 | hookRan = true |
| 356 | return ErrClaimAdmission |
| 357 | } |
| 358 | t.Run("instance", func(t *testing.T) { |
| 359 | for _, tc := range []struct { |
| 360 | name string |
| 361 | apply func(*ReconcileConfig) |
| 362 | mint bool |
| 363 | }{ |
| 364 | {"inbound none", func(c *ReconcileConfig) { c.Inbound = InboundNone }, false}, |
| 365 | {"config gate", func(c *ReconcileConfig) { c.ConfigGate = refuse }, false}, |
| 366 | {"accepted", func(c *ReconcileConfig) {}, true}, |
| 367 | } { |
| 368 | s, reg, host, cfg := instanceClaimFixture(t) |
| 369 | cfg.ClaimEnabled, cfg.ClaimInstall = false, hook |
| 370 | tc.apply(&cfg) |
| 371 | if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| 372 | t.Fatal(err) |
| 373 | } |
| 374 | got, err := reg.Get(host) |
| 375 | if err != nil || (got != nil) != tc.mint { |
| 376 | t.Fatalf("%s: minted=%v err=%v, want minted=%v", tc.name, got != nil, err, tc.mint) |
| 377 | } |
| 378 | if got != nil && got.ClaimOwnership != (registry.ClaimOwnership{}) { |
| 379 | t.Fatalf("%s: flag-off mint carries ownership", tc.name) |
| 380 | } |
| 381 | } |
| 382 | }) |
| 383 | t.Run("sidecar", func(t *testing.T) { |
| 384 | for _, tc := range []struct { |
| 385 | name string |
| 386 | apply func(*testing.T, string, *ReconcileConfig) |
| 387 | mint bool |
| 388 | }{ |
| 389 | {"co-spawn", func(t *testing.T, host string, c *ReconcileConfig) { |
| 390 | if err := os.WriteFile(filepath.Join(c.DataDir, host+sidecarCoSpawnSuffix), nil, 0o600); err != nil { |
| 391 | t.Fatal(err) |
| 392 | } |
| 393 | }, false}, |
| 394 | {"config gate", func(_ *testing.T, _ string, c *ReconcileConfig) { c.ConfigGate = refuse }, false}, |
| 395 | {"accepted", func(*testing.T, string, *ReconcileConfig) {}, true}, |
| 396 | } { |
| 397 | f, host, cfg := sidecarClaimFixture(t) |
| 398 | cfg.ClaimEnabled, cfg.ClaimInstall = false, hook |
| 399 | tc.apply(t, host, &cfg) |
| 400 | f.reconcile(t, cfg) |
| 401 | got, err := f.reg.Get(host) |
| 402 | if err != nil || (got != nil) != tc.mint { |
| 403 | t.Fatalf("%s: minted=%v err=%v, want minted=%v", tc.name, got != nil, err, tc.mint) |
| 404 | } |
| 405 | } |
| 406 | }) |
| 407 | if hookRan { |
| 408 | t.Fatal("flag-off pass consulted the claim hook") |
| 409 | } |
| 410 | } |
| 411 | |
| 412 | func TestClaimInstallWrapperRefusesUnownedExistingBeforeHook(t *testing.T) { |
| 413 | owner := registry.ClaimOwnership{ClaimIncarnation: "inc", ClaimStamp: 1, ClaimOwner: "owner", ClaimRowID: "row"} |
| 414 | calls, writes := 0, 0 |
| 415 | hook := func(_ context.Context, _ ClaimRecord, write func(registry.ClaimOwnership) error) error { |
| 416 | calls++ |
| 417 | return write(owner) |
| 418 | } |
| 419 | write := func(registry.ClaimOwnership) error { writes++; return nil } |
| 420 | cfg := ReconcileConfig{ClaimEnabled: true, ClaimInstall: hook} |
| 421 | |
| 422 | if err := cfg.claimInstall(ctx, ClaimRecord{}, ®istry.Route{}, write); !errors.Is(err, registry.ErrClaimOwnership) { |
| 423 | t.Fatalf("unowned existing row: err = %v, want ErrClaimOwnership", err) |
| 424 | } |
| 425 | if calls != 0 || writes != 0 { |
| 426 | t.Fatalf("unowned existing row reached the hook: calls=%d writes=%d", calls, writes) |
| 427 | } |
| 428 | // Valid owned rows still keep: the hook runs and its write goes through. |
| 429 | if err := cfg.claimInstall(ctx, ClaimRecord{}, ®istry.Route{ClaimOwnership: owner}, write); err != nil { |
| 430 | t.Fatal(err) |
| 431 | } |
| 432 | if calls != 1 || writes != 1 { |
| 433 | t.Fatalf("owned existing row: calls=%d writes=%d, want 1 and 1", calls, writes) |
| 434 | } |
| 435 | // A different valid owner is still refused, by the write, after the hook. |
| 436 | other := owner |
| 437 | other.ClaimStamp++ |
| 438 | if err := cfg.claimInstall(ctx, ClaimRecord{}, ®istry.Route{ClaimOwnership: other}, write); !errors.Is(err, registry.ErrClaimOwnership) { |
| 439 | t.Fatalf("foreign owner: err = %v, want ErrClaimOwnership", err) |
| 440 | } |
| 441 | if calls != 2 || writes != 1 { |
| 442 | t.Fatalf("foreign owner: calls=%d writes=%d, want 2 and 1", calls, writes) |
| 443 | } |
| 444 | // A mint has no existing row and is unaffected. |
| 445 | if err := cfg.claimInstall(ctx, ClaimRecord{}, nil, write); err != nil { |
| 446 | t.Fatal(err) |
| 447 | } |
| 448 | if calls != 3 || writes != 2 { |
| 449 | t.Fatalf("mint: calls=%d writes=%d, want 3 and 2", calls, writes) |
| 450 | } |
| 451 | // Flag off: the hook is not consulted and an unowned row is written as before. |
| 452 | off := ReconcileConfig{ClaimInstall: hook} |
| 453 | if err := off.claimInstall(ctx, ClaimRecord{}, ®istry.Route{}, write); err != nil { |
| 454 | t.Fatal(err) |
| 455 | } |
| 456 | if calls != 3 || writes != 3 { |
| 457 | t.Fatalf("flag off: calls=%d writes=%d, want 3 and 3", calls, writes) |
| 458 | } |
| 459 | } |
| 460 | |
| 461 | // Existing rows this pass will not keep are refused before the claim hook, so |
| 462 | // the guard activation and inventory write it stands for never happen. Each |
| 463 | // row's refusal is isolated: the hand-added rows carry a VALID owner, so the |
| 464 | // wrapper's ownership check cannot be what stops them. |
| 465 | func TestClaimKnownExistingRowRefusalsPrecedeInstallHookInstance(t *testing.T) { |
| 466 | cases := []struct { |
| 467 | name string |
| 468 | setup func(*testing.T, release.Store, *registry.Registry, string, *claimEffects, ReconcileConfig) |
| 469 | }{ |
| 470 | {"shape mismatch", func(t *testing.T, _ release.Store, reg *registry.Registry, host string, fx *claimEffects, _ ReconcileConfig) { |
| 471 | if err := reg.Upsert(registry.Route{Host: host, Kind: "instance", App: "other", ClaimOwnership: fx.owner}); err != nil { |
| 472 | t.Fatal(err) |
| 473 | } |
| 474 | }}, |
| 475 | {"legacy owned row without claim owner", func(t *testing.T, s release.Store, reg *registry.Registry, host string, _ *claimEffects, cfg ReconcileConfig) { |
| 476 | cfg.ClaimEnabled = false |
| 477 | if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| 478 | t.Fatal(err) |
| 479 | } |
| 480 | if got, err := reg.Get(host); err != nil || got == nil || !got.Provisioned { |
| 481 | t.Fatalf("legacy mint: %v", err) |
| 482 | } |
| 483 | }}, |
| 484 | {"legacy adoptable row without claim owner", func(t *testing.T, s release.Store, reg *registry.Registry, host string, _ *claimEffects, cfg ReconcileConfig) { |
| 485 | cfg.ClaimEnabled = false |
| 486 | if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| 487 | t.Fatal(err) |
| 488 | } |
| 489 | if err := reg.SetProvisioned(host, false); err != nil { |
| 490 | t.Fatal(err) |
| 491 | } |
| 492 | }}, |
| 493 | } |
| 494 | for _, tc := range cases { |
| 495 | t.Run(tc.name, func(t *testing.T) { |
| 496 | s, reg, host, cfg := instanceClaimFixture(t) |
| 497 | fx := newClaimEffects(t) |
| 498 | tc.setup(t, s, reg, host, fx, cfg) |
| 499 | before, err := reg.Get(host) |
| 500 | if err != nil || before == nil { |
| 501 | t.Fatalf("setup left no row: %v", err) |
| 502 | } |
| 503 | gates := 0 |
| 504 | cfg.ClaimInstall = fx.hook(t) |
| 505 | cfg.ConfigGate = func(context.Context, registry.Route) error { gates++; return nil } |
| 506 | if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| 507 | t.Fatal(err) |
| 508 | } |
| 509 | fx.untouched(t, tc.name) |
| 510 | if gates != 0 { |
| 511 | t.Fatalf("config gate asked %d times for an existing row", gates) |
| 512 | } |
| 513 | if got, err := reg.Get(host); err != nil || got == nil || *got != *before { |
| 514 | t.Fatalf("refused existing row changed: %v", err) |
| 515 | } |
| 516 | }) |
| 517 | } |
| 518 | } |
| 519 | |
| 520 | func TestClaimKnownExistingRowRefusalsPrecedeInstallHookSidecar(t *testing.T) { |
| 521 | cases := []struct { |
| 522 | name string |
| 523 | setup func(*testing.T, *sidecarFixture, string, *claimEffects, ReconcileConfig) |
| 524 | }{ |
| 525 | {"hand-added row", func(t *testing.T, f *sidecarFixture, host string, fx *claimEffects, _ ReconcileConfig) { |
| 526 | f.mustUpsert(t, registry.Route{Host: host, Kind: SidecarKind, Slug: "bot", App: "eleven", Account: f.sqid, ClaimOwnership: fx.owner}) |
| 527 | }}, |
| 528 | {"legacy owned row without claim owner", func(t *testing.T, f *sidecarFixture, host string, _ *claimEffects, cfg ReconcileConfig) { |
| 529 | cfg.ClaimEnabled = false |
| 530 | f.reconcile(t, cfg) |
| 531 | if got, err := f.reg.Get(host); err != nil || got == nil || !got.Provisioned { |
| 532 | t.Fatalf("legacy mint: %v", err) |
| 533 | } |
| 534 | }}, |
| 535 | } |
| 536 | for _, tc := range cases { |
| 537 | t.Run(tc.name, func(t *testing.T) { |
| 538 | f, host, cfg := sidecarClaimFixture(t) |
| 539 | fx := newClaimEffects(t) |
| 540 | tc.setup(t, f, host, fx, cfg) |
| 541 | before, err := f.reg.Get(host) |
| 542 | if err != nil || before == nil { |
| 543 | t.Fatalf("setup left no row: %v", err) |
| 544 | } |
| 545 | gates := 0 |
| 546 | cfg.ClaimInstall = fx.hook(t) |
| 547 | cfg.ConfigGate = func(context.Context, registry.Route) error { gates++; return nil } |
| 548 | f.reconcile(t, cfg) |
| 549 | fx.untouched(t, tc.name) |
| 550 | if gates != 0 { |
| 551 | t.Fatalf("config gate asked %d times for an existing row", gates) |
| 552 | } |
| 553 | if got, err := f.reg.Get(host); err != nil || got == nil || *got != *before { |
| 554 | t.Fatalf("refused existing row changed: %v", err) |
| 555 | } |
| 556 | }) |
| 557 | } |
| 558 | } |
| 559 | |
| 560 | // Flag off, the wrapper never consults the hook: a shape mismatch is still |
| 561 | // left alone by the write, and an adoptable unowned row is still adopted. |
| 562 | func TestClaimFlagOffExistingRowsUnchanged(t *testing.T) { |
| 563 | hookRan := false |
| 564 | hook := func(context.Context, ClaimRecord, func(registry.ClaimOwnership) error) error { |
| 565 | hookRan = true |
| 566 | return ErrClaimAdmission |
| 567 | } |
| 568 | t.Run("shape mismatch left alone", func(t *testing.T) { |
| 569 | s, reg, host, cfg := instanceClaimFixture(t) |
| 570 | cfg.ClaimEnabled, cfg.ClaimInstall = false, hook |
| 571 | if err := reg.Upsert(registry.Route{Host: host, Kind: "instance", App: "other"}); err != nil { |
| 572 | t.Fatal(err) |
| 573 | } |
| 574 | before := mustGet(t, reg, host) |
| 575 | if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| 576 | t.Fatal(err) |
| 577 | } |
| 578 | if got := mustGet(t, reg, host); *got != *before { |
| 579 | t.Fatal("flag-off pass changed a mismatched row") |
| 580 | } |
| 581 | }) |
| 582 | t.Run("adoptable unowned row adopted", func(t *testing.T) { |
| 583 | s, reg, host, cfg := instanceClaimFixture(t) |
| 584 | cfg.ClaimEnabled, cfg.ClaimInstall = false, hook |
| 585 | if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| 586 | t.Fatal(err) |
| 587 | } |
| 588 | if err := reg.SetProvisioned(host, false); err != nil { |
| 589 | t.Fatal(err) |
| 590 | } |
| 591 | if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| 592 | t.Fatal(err) |
| 593 | } |
| 594 | if got := mustGet(t, reg, host); !got.Provisioned || got.ClaimOwnership != (registry.ClaimOwnership{}) { |
| 595 | t.Fatalf("flag-off adoption: provisioned=%v owner=%+v", got.Provisioned, got.ClaimOwnership) |
| 596 | } |
| 597 | }) |
| 598 | t.Run("sidecar hand-added row left alone", func(t *testing.T) { |
| 599 | f, host, cfg := sidecarClaimFixture(t) |
| 600 | cfg.ClaimEnabled, cfg.ClaimInstall = false, hook |
| 601 | f.mustUpsert(t, registry.Route{Host: host, Kind: SidecarKind, Slug: "bot", App: "eleven", Account: f.sqid}) |
| 602 | before := mustGet(t, f.reg, host) |
| 603 | f.reconcile(t, cfg) |
| 604 | if got := mustGet(t, f.reg, host); *got != *before { |
| 605 | t.Fatal("flag-off pass changed a hand-added sidecar row") |
| 606 | } |
| 607 | }) |
| 608 | if hookRan { |
| 609 | t.Fatal("flag-off pass consulted the claim hook") |
| 610 | } |
| 611 | } |
| 612 | |