| 1 | package console |
| 2 | |
| 3 | // The seam between the two halves of instance provisioning: the console |
| 4 | // verbs that write records to the bucket (apiinstances.go) and the agent |
| 5 | // pass that turns them into routes (internal/provision). They share no code |
| 6 | // path and no process — only key shapes and an account sqid — which is |
| 7 | // exactly the kind of agreement that breaks silently. |
| 8 | |
| 9 | import ( |
| 10 | "context" |
| 11 | "net/http" |
| 12 | "path/filepath" |
| 13 | "testing" |
| 14 | "time" |
| 15 | |
| 16 | "github.com/carlosframework/platform/internal/provision" |
| 17 | "github.com/carlosframework/platform/internal/registry" |
| 18 | ) |
| 19 | |
| 20 | // TestAPIInstanceReconcileSeam is the seam spec §13 names: the console's |
| 21 | // verbs and the agent's reconcile pass, joined by nothing but the bucket. |
| 22 | // Every literal that could drift between them is computed, never typed — |
| 23 | // the account segment comes from console's own accountSqid (domains.Sqid), |
| 24 | // the record key from provision.RecordKey, the route's paths from the |
| 25 | // provisioner's shared helpers — because the failure this pins is silent: |
| 26 | // a console writing apps/<sqidA>/... and an agent parsing apps/<sqidB>/... |
| 27 | // both "work", and instances simply never appear. |
| 28 | // |
| 29 | // It runs on a NON-ops account deliberately: ops's sqid is hardcoded known |
| 30 | // on the agent side (domains.OpsSqid), so an ops-only test would pass with |
| 31 | // the sqid agreement completely broken. |
| 32 | func TestAPIInstanceReconcileSeam(t *testing.T) { |
| 33 | fx := newConsoleFixture(t) |
| 34 | token := cliToken(t, fx, "alice@keymail.dev", "code-inst-seam") |
| 35 | now := time.Now() |
| 36 | const acctID int64 = 4242 |
| 37 | sqid := accountSqid(acctID) |
| 38 | if err := fx.st.CreateAccount(acctID, sqid, "Tito", "alice@keymail.dev", now); err != nil { |
| 39 | t.Fatal(err) |
| 40 | } |
| 41 | if _, err := fx.st.CreateAppIn(acctID, "titogo", "alice@keymail.dev", now); err != nil { |
| 42 | t.Fatal(err) |
| 43 | } |
| 44 | // The account manifest is how the agent learns this sqid exists at all |
| 45 | // (ParseRecordKey refuses keys under an unknown account). |
| 46 | if err := WriteAccountManifest(context.Background(), fx.rel, acctID, sqid, "Tito", now); err != nil { |
| 47 | t.Fatal(err) |
| 48 | } |
| 49 | if err := provision.PutManifest(context.Background(), fx.rel, sqid, "titogo", titoManifest()); err != nil { |
| 50 | t.Fatal(err) |
| 51 | } |
| 52 | |
| 53 | u := instancesURL(fx, sqid, "titogo") |
| 54 | const host = "acme.go.tito.io" |
| 55 | if status, _, out := rawReq(t, http.MethodPost, u, token, |
| 56 | `{"host":"`+host+`","region":"ie"}`); status != http.StatusCreated { |
| 57 | t.Fatalf("create = %d %s", status, out) |
| 58 | } |
| 59 | |
| 60 | // A hand-added neighbour in the SAME scope: not provisioned, so the |
| 61 | // removal phase has no authority over it however the rest of the pass |
| 62 | // goes. |
| 63 | dataDir := t.TempDir() |
| 64 | neighbour := registry.Route{ |
| 65 | Host: "hand.go.tito.io", Slug: "hand", Kind: "instance", |
| 66 | Socket: provision.DefaultSocket("hand.go.tito.io"), |
| 67 | DBPath: provision.DefaultDBPath(dataDir, "hand.go.tito.io"), |
| 68 | App: "titogo", Account: sqid, Pool: "tito", Region: "ie", |
| 69 | Channel: "stable", Status: "ready", |
| 70 | } |
| 71 | reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| 72 | if err != nil { |
| 73 | t.Fatal(err) |
| 74 | } |
| 75 | t.Cleanup(func() { reg.Close() }) |
| 76 | if err := reg.Upsert(neighbour); err != nil { |
| 77 | t.Fatal(err) |
| 78 | } |
| 79 | before, err := reg.Get(neighbour.Host) |
| 80 | if err != nil || before == nil { |
| 81 | t.Fatalf("neighbour route: %v", err) |
| 82 | } |
| 83 | |
| 84 | cfg := provision.ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: dataDir} |
| 85 | if err := provision.Reconcile(context.Background(), fx.rel, reg, cfg); err != nil { |
| 86 | t.Fatalf("reconcile: %v", err) |
| 87 | } |
| 88 | got, err := reg.Get(host) |
| 89 | if err != nil { |
| 90 | t.Fatal(err) |
| 91 | } |
| 92 | if got == nil { |
| 93 | t.Fatalf("the console's record never materialized as a route — the sqid or key shapes disagree") |
| 94 | } |
| 95 | want := registry.Route{ |
| 96 | Host: host, Slug: "acme", Kind: "instance", |
| 97 | Socket: provision.DefaultSocket(host), DBPath: provision.DefaultDBPath(dataDir, host), |
| 98 | App: "titogo", Account: sqid, Pool: "tito", Region: "ie", Channel: "stable", |
| 99 | Hibernate: true, Provisioned: true, Seed: `["seed"]`, Status: "ready", |
| 100 | CreatedAt: got.CreatedAt, |
| 101 | } |
| 102 | if *got != want { |
| 103 | t.Fatalf("minted route =\n %+v\nwant\n %+v", *got, want) |
| 104 | } |
| 105 | |
| 106 | // And back the other way: the DELETE verb is what retires the route. |
| 107 | if status, _, out := rawReq(t, http.MethodDelete, u+"/"+host, token, ""); status != http.StatusOK { |
| 108 | t.Fatalf("delete = %d %s", status, out) |
| 109 | } |
| 110 | if err := provision.Reconcile(context.Background(), fx.rel, reg, cfg); err != nil { |
| 111 | t.Fatalf("reconcile after delete: %v", err) |
| 112 | } |
| 113 | if gone, err := reg.Get(host); err != nil { |
| 114 | t.Fatal(err) |
| 115 | } else if gone != nil { |
| 116 | t.Fatalf("the route survived its record's deletion: %+v", gone) |
| 117 | } |
| 118 | after, err := reg.Get(neighbour.Host) |
| 119 | if err != nil || after == nil { |
| 120 | t.Fatalf("the hand-added neighbour was removed by a pass that never minted it: %v", err) |
| 121 | } |
| 122 | if *after != *before { |
| 123 | t.Fatalf("the hand-added neighbour changed under reconciliation:\n got %+v\nwant %+v", *after, *before) |
| 124 | } |
| 125 | } |
| 126 | |