| new file mode 100644 |
| index 000000000..7d3c08d66 |
| --- /dev/null |
| +++ b/internal/console/apiinstances_seam_test.go |
| @@ -0,0 +1,125 @@ |
| +package console |
| + |
| +// The seam between the two halves of instance provisioning: the console |
| +// verbs that write records to the bucket (apiinstances.go) and the agent |
| +// pass that turns them into routes (internal/provision). They share no code |
| +// path and no process — only key shapes and an account sqid — which is |
| +// exactly the kind of agreement that breaks silently. |
| + |
| +import ( |
| + "context" |
| + "net/http" |
| + "path/filepath" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// TestAPIInstanceReconcileSeam is the seam spec §13 names: the console's |
| +// verbs and the agent's reconcile pass, joined by nothing but the bucket. |
| +// Every literal that could drift between them is computed, never typed — |
| +// the account segment comes from console's own accountSqid (domains.Sqid), |
| +// the record key from provision.RecordKey, the route's paths from the |
| +// provisioner's shared helpers — because the failure this pins is silent: |
| +// a console writing apps/<sqidA>/... and an agent parsing apps/<sqidB>/... |
| +// both "work", and instances simply never appear. |
| +// |
| +// It runs on a NON-ops account deliberately: ops's sqid is hardcoded known |
| +// on the agent side (domains.OpsSqid), so an ops-only test would pass with |
| +// the sqid agreement completely broken. |
| +func TestAPIInstanceReconcileSeam(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + token := cliToken(t, fx, "alice@keymail.dev", "code-inst-seam") |
| + now := time.Now() |
| + const acctID int64 = 4242 |
| + sqid := accountSqid(acctID) |
| + if err := fx.st.CreateAccount(acctID, sqid, "Tito", "alice@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := fx.st.CreateAppIn(acctID, "titogo", "alice@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + // The account manifest is how the agent learns this sqid exists at all |
| + // (ParseRecordKey refuses keys under an unknown account). |
| + if err := WriteAccountManifest(context.Background(), fx.rel, acctID, sqid, "Tito", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := provision.PutManifest(context.Background(), fx.rel, sqid, "titogo", titoManifest()); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + u := instancesURL(fx, sqid, "titogo") |
| + const host = "acme.go.tito.io" |
| + if status, _, out := rawReq(t, http.MethodPost, u, token, |
| + `{"host":"`+host+`","region":"ie"}`); status != http.StatusCreated { |
| + t.Fatalf("create = %d %s", status, out) |
| + } |
| + |
| + // A hand-added neighbour in the SAME scope: not provisioned, so the |
| + // removal phase has no authority over it however the rest of the pass |
| + // goes. |
| + dataDir := t.TempDir() |
| + neighbour := registry.Route{ |
| + Host: "hand.go.tito.io", Slug: "hand", Kind: "instance", |
| + Socket: provision.DefaultSocket("hand.go.tito.io"), |
| + DBPath: provision.DefaultDBPath(dataDir, "hand.go.tito.io"), |
| + App: "titogo", Account: sqid, Pool: "tito", Region: "ie", |
| + Channel: "stable", Status: "ready", |
| + } |
| + reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { reg.Close() }) |
| + if err := reg.Upsert(neighbour); err != nil { |
| + t.Fatal(err) |
| + } |
| + before, err := reg.Get(neighbour.Host) |
| + if err != nil || before == nil { |
| + t.Fatalf("neighbour route: %v", err) |
| + } |
| + |
| + cfg := provision.ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: dataDir} |
| + if err := provision.Reconcile(context.Background(), fx.rel, reg, cfg); err != nil { |
| + t.Fatalf("reconcile: %v", err) |
| + } |
| + got, err := reg.Get(host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got == nil { |
| + t.Fatalf("the console's record never materialized as a route — the sqid or key shapes disagree") |
| + } |
| + want := registry.Route{ |
| + Host: host, Slug: "acme", Kind: "instance", |
| + Socket: provision.DefaultSocket(host), DBPath: provision.DefaultDBPath(dataDir, host), |
| + App: "titogo", Account: sqid, Pool: "tito", Region: "ie", Channel: "stable", |
| + Hibernate: true, Provisioned: true, Seed: `["seed"]`, Status: "ready", |
| + CreatedAt: got.CreatedAt, |
| + } |
| + if *got != want { |
| + t.Fatalf("minted route =\n %+v\nwant\n %+v", *got, want) |
| + } |
| + |
| + // And back the other way: the DELETE verb is what retires the route. |
| + if status, _, out := rawReq(t, http.MethodDelete, u+"/"+host, token, ""); status != http.StatusOK { |
| + t.Fatalf("delete = %d %s", status, out) |
| + } |
| + if err := provision.Reconcile(context.Background(), fx.rel, reg, cfg); err != nil { |
| + t.Fatalf("reconcile after delete: %v", err) |
| + } |
| + if gone, err := reg.Get(host); err != nil { |
| + t.Fatal(err) |
| + } else if gone != nil { |
| + t.Fatalf("the route survived its record's deletion: %+v", gone) |
| + } |
| + after, err := reg.Get(neighbour.Host) |
| + if err != nil || after == nil { |
| + t.Fatalf("the hand-added neighbour was removed by a pass that never minted it: %v", err) |
| + } |
| + if *after != *before { |
| + t.Fatalf("the hand-added neighbour changed under reconciliation:\n got %+v\nwant %+v", *after, *before) |
| + } |
| +} |