domains: accounts come from the bucket's own manifests — key paths carry the linkage
Reconcile now materializes accounts from accounts/<sqid>/account.json, taking the id as given, and derives app→account from the BUCKET KEY PATH rather than a separate apps/<app>/account.json linkage manifest (retired; a leftover one is now inert, with a test saying so). Three things must agree before an account materializes: the key path, the body's sqid, and Sqid(id) under the frozen encoder. A manifest that disagrees with itself is a lie about identity — it would put an account at a domain that stops resolving the moment anything recomputes the sqid — so it is skipped loudly, never guessed at. Accounts are listed before apps because the account set is what tells apps/<sqid>/<app>/… from legacy apps/<app>/…. The one real collision (an app NAMED like a materialized sqid) is resolved by the segment that follows: "channels"/"releases" there means it was a legacy key all along. Routes now carry the account SQID, not a name — which is what adopt and the edge build bucket keys from.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
5 files changed,
+368
−126
e2e_test.go+8 −16internal/console/account.go+55 −0internal/console/account_test.go+55 −0internal/domains/reconcile.go+130 −77internal/domains/reconcile_test.go+120 −33
diff --git a/e2e_test.go b/e2e_test.go| index 4debc0ba5..6f25a4252 100644 |
| --- a/e2e_test.go |
| +++ b/e2e_test.go |
| @@ -383,20 +383,12 @@ func TestEndToEndSmoke(t *testing.T) { |
| t.Errorf("fleet.json instances.total = %v, want %d (one box over %d routes)", got, wantTotal, wantTotal) |
| } |
| - // (5) Domains: an account manifest in the bucket + one reconcile pass |
| - // mints the app's platform domain, and the edge serves it by Host header |
| - // over the same echo backend — every app is born addressable. The account |
| - // id is MATERIALIZED, never assigned (multi-account spec §1): the id comes |
| - // from outside the registry, and its sqid is the frozen encoder over it. |
| - const acctID = int64(793481920157) |
| - acctSqid := domains.Sqid(acctID) |
| - if err := reg.PutAccount(acctID, acctSqid, "paul@keymail.dev"); err != nil { |
| - t.Fatalf("materialize account: %v", err) |
| - } |
| - if _, err := store.Put(ctx, "apps/echoapp/account.json", |
| - []byte(`{"account":"paul@keymail.dev","owner":"paul@keymail.dev"}`), ""); err != nil { |
| - t.Fatalf("write account manifest: %v", err) |
| - } |
| + // (5) Domains: one reconcile pass mints the app's platform domain under |
| + // the account its BUCKET KEYS say it belongs to, and the edge serves it by |
| + // Host header over the same echo backend — every app is born addressable. |
| + // echoapp was shipped under ops above, so ops is where it mints; the |
| + // two-account case is TestMultiAccountEndToEnd. |
| + acctSqid := release.OpsAccount |
| if err := domains.Reconcile(ctx, store, reg, domains.Config{ |
| AppsDomain: "oncarlos.test", |
| EdgeIPs: []string{"203.0.113.7"}, |
| @@ -413,8 +405,8 @@ func TestEndToEndSmoke(t *testing.T) { |
| if prt == nil { |
| t.Fatalf("reconcile minted no platform route %s", platformHost) |
| } |
| - if !prt.Platform || prt.Addr != backendAddr || prt.Account != "paul@keymail.dev" { |
| - t.Fatalf("platform route = %+v, want platform-flagged, echo upstream, manifest account", prt) |
| + if !prt.Platform || prt.Addr != backendAddr || prt.Account != acctSqid { |
| + t.Fatalf("platform route = %+v, want platform-flagged, echo upstream, account %s", prt, acctSqid) |
| } |
| if prt.DNSStatus != "pointing" { |
| t.Fatalf("platform route dns_status = %q, want pointing (fake resolver says edge)", prt.DNSStatus) |
diff --git a/internal/console/account.go b/internal/console/account.go| index a0c438e16..027903127 100644 |
| --- a/internal/console/account.go |
| +++ b/internal/console/account.go |
| @@ -6,8 +6,15 @@ |
| package console |
| import ( |
| + "context" |
| "crypto/rand" |
| "encoding/binary" |
| + "encoding/json" |
| + "errors" |
| + "os" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| ) |
| // accountIDBits is the width of a minted account id. 40 bits is ~1.1e12 |
| @@ -37,3 +44,51 @@ func NewAccountID() int64 { |
| } |
| } |
| } |
| + |
| +// AccountManifestKey is where an account's identity lives in the bucket. The |
| +// sqid is in the PATH as well as the body so the agent can list accounts |
| +// without opening every object. |
| +func AccountManifestKey(sqid string) string { return "accounts/" + sqid + "/account.json" } |
| + |
| +// accountManifest is the wire shape at that key. The agent's reconcile pass |
| +// materializes exactly this and assigns nothing. |
| +type accountManifest struct { |
| + ID int64 `json:"id"` |
| + Sqid string `json:"sqid"` |
| + Name string `json:"name"` |
| + CreatedAt time.Time `json:"created_at"` |
| +} |
| + |
| +// WriteAccountManifest publishes an account into the deployment bucket — the |
| +// one place account identity crosses from the console to every box. The |
| +// registry only ever reads this; it never assigns an id of its own, which is |
| +// what makes the identity survive a registry rebuild. |
| +// |
| +// Overwrite-safe (read etag, put, one retry), the same shape the fleet |
| +// snapshot publisher uses: the store's Put is CAS-only, so a rename racing |
| +// another of the owner's own tabs must retry rather than fail. |
| +func WriteAccountManifest(ctx context.Context, s release.Store, id int64, sqid, name string, created time.Time) error { |
| + body, err := json.Marshal(accountManifest{ |
| + ID: id, Sqid: sqid, Name: name, CreatedAt: created.UTC(), |
| + }) |
| + if err != nil { |
| + return err |
| + } |
| + key := AccountManifestKey(sqid) |
| + put := func() error { |
| + etag := "" |
| + if _, cur, err := s.Get(ctx, key); err == nil { |
| + etag = cur |
| + } else if !errors.Is(err, os.ErrNotExist) { |
| + return err |
| + } |
| + _, err := s.Put(ctx, key, body, etag) |
| + return err |
| + } |
| + if err := put(); errors.Is(err, release.ErrCASConflict) { |
| + return put() // one retry on a lost race; a second loss surfaces |
| + } else if err != nil { |
| + return err |
| + } |
| + return nil |
| +} |
diff --git a/internal/console/account_test.go b/internal/console/account_test.go| index 46aabcdf9..7bd32d979 100644 |
| --- a/internal/console/account_test.go |
| +++ b/internal/console/account_test.go |
| @@ -1,10 +1,65 @@ |
| package console |
| import ( |
| + "context" |
| + "encoding/json" |
| "testing" |
| "time" |
| + |
| + "github.com/carlosframework/platform/internal/domains" |
| + "github.com/carlosframework/platform/internal/release" |
| ) |
| +// The manifest is where account identity LEAVES the console: the registry on |
| +// every box materializes exactly what this writes, and never assigns anything |
| +// of its own. |
| +func TestWriteAccountManifestRoundTrips(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + if err := WriteAccountManifest(ctx, s, 42, "cdef", "Kass", time.Unix(1700000000, 0)); err != nil { |
| + t.Fatal(err) |
| + } |
| + body, _, err := s.Get(ctx, "accounts/cdef/account.json") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var m struct { |
| + ID int64 `json:"id"` |
| + Sqid string `json:"sqid"` |
| + Name string `json:"name"` |
| + CreatedAt string `json:"created_at"` |
| + } |
| + if err := json.Unmarshal(body, &m); err != nil { |
| + t.Fatal(err) |
| + } |
| + if m.ID != 42 || m.Sqid != "cdef" || m.Name != "Kass" || m.CreatedAt == "" { |
| + t.Errorf("manifest = %+v", m) |
| + } |
| + // Overwrite-safe: a rename must land, not lose to the CAS gate. |
| + if err := WriteAccountManifest(ctx, s, 42, "cdef", "Kassidy", time.Unix(1700000000, 0)); err != nil { |
| + t.Fatalf("rewrite: %v", err) |
| + } |
| + body, _, _ = s.Get(ctx, "accounts/cdef/account.json") |
| + json.Unmarshal(body, &m) |
| + if m.Name != "Kassidy" { |
| + t.Errorf("rewrite lost the rename: %+v", m) |
| + } |
| +} |
| + |
| +// The sqid a minted account carries must be the frozen encoder over its id — |
| +// nothing else, ever, or a platform domain stops resolving to its account. |
| +func TestMintedSqidIsTheFrozenEncoding(t *testing.T) { |
| + for i := 0; i < 50; i++ { |
| + id := NewAccountID() |
| + if got := domains.Sqid(id); got != domains.Sqid(id) || got == "" { |
| + t.Fatalf("Sqid(%d) unstable/empty: %q", id, got) |
| + } |
| + } |
| + if got := domains.Sqid(1); got != release.OpsAccount { |
| + t.Fatalf("ops sqid = %q, want %q — the frozen encoding moved", got, release.OpsAccount) |
| + } |
| +} |
| + |
| // Account ids are minted by the CONSOLE (not the registry, not the bucket |
| // listing order) so that identity survives a registry rebuild. Randomness is |
| // load-bearing twice over: it stops two boxes materializing different ids for |
diff --git a/internal/domains/reconcile.go b/internal/domains/reconcile.go| index acfb3cf01..12253c5cd 100644 |
| --- a/internal/domains/reconcile.go |
| +++ b/internal/domains/reconcile.go |
| @@ -56,8 +56,14 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg |
| cfg.Now = time.Now |
| } |
| - // One bucket listing feeds both the manifest scan and the canary-pointer |
| - // scan below. |
| + // One bucket listing feeds the app→account key-path scan and the |
| + // canary-pointer scan below; accounts/ is listed separately because it |
| + // must be read FIRST — the account set is what makes a key path |
| + // unambiguous. |
| + acctKeys, err := s.List(ctx, "accounts/") |
| + if err != nil { |
| + return fmt.Errorf("domains: list accounts: %w", err) |
| + } |
| keys, err := s.List(ctx, "apps/") |
| if err != nil { |
| return fmt.Errorf("domains: list bucket: %w", err) |
| @@ -68,12 +74,16 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg |
| } |
| // --- 1. account materialization ------------------------------------------ |
| - // The console originates account truth as apps/<app>/account.json in the |
| - // bucket; this is the only place it becomes registry state. A deployment |
| - // with no console has no manifests: everything stays under ops. |
| - accountOf := map[string]string{} // app -> manifest account name |
| - for _, key := range keys { |
| - app, ok := manifestApp(key) |
| + // The console originates account truth as accounts/<sqid>/account.json in |
| + // the bucket; this is the only place it becomes registry state, and the id |
| + // in the manifest is TAKEN AS GIVEN — never assigned here. That is the |
| + // whole durability property: a rebuilt registry, or a second box, ends up |
| + // with byte-identical account identity, so platform domains never re-home. |
| + // A deployment with no console has no manifests: everything stays under |
| + // the built-in ops account. |
| + known := map[string]bool{OpsSqid: true} // sqid -> materialized |
| + for _, key := range acctKeys { |
| + sqid, ok := accountManifestSqid(key) |
| if !ok { |
| continue |
| } |
| @@ -82,28 +92,62 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg |
| log.Printf("domains: skipping %s: read failed: %v", key, err) |
| continue |
| } |
| - var m struct{ Account, Owner string } |
| - if err := json.Unmarshal(body, &m); err != nil || m.Account == "" { |
| - log.Printf("domains: skipping %s: malformed manifest: %v", key, err) |
| + var m struct { |
| + ID int64 `json:"id"` |
| + Sqid string `json:"sqid"` |
| + Name string `json:"name"` |
| + } |
| + if err := json.Unmarshal(body, &m); err != nil || m.ID <= 0 || m.Sqid == "" { |
| + log.Printf("domains: skipping %s: malformed account manifest: %v", key, err) |
| continue |
| } |
| - // NOTE (multi-account, task 1 of 8): the registry no longer assigns |
| - // account ids, so a manifest naming an account by NAME can no longer |
| - // materialize one — the id has to come from the bucket. Until task 4 |
| - // replaces this whole block with the accounts/<sqid>/account.json |
| - // reader, a named account is only usable if it is already materialized; |
| - // anything else falls through to ops. |
| - accountOf[app] = m.Account |
| - for i := range routes { |
| - if routes[i].App != app || routes[i].Account == m.Account { |
| - continue |
| - } |
| - if err := reg.SetAccount(routes[i].Host, m.Account); err != nil { |
| - log.Printf("domains: stamp account on %s: %v", routes[i].Host, err) |
| - continue |
| - } |
| - routes[i].Account = m.Account |
| + // Three things must agree: the key path, the body's sqid, and the |
| + // frozen encoding of the body's id. A manifest that disagrees with |
| + // itself is a lie about identity — materializing it would put an |
| + // account at a domain that stops resolving the moment anything |
| + // recomputes the sqid — so it is skipped, loudly, never guessed at. |
| + if m.Sqid != sqid || Sqid(m.ID) != sqid { |
| + log.Printf("domains: skipping %s: sqid %q/%q is not Sqid(%d)=%q", key, sqid, m.Sqid, m.ID, Sqid(m.ID)) |
| + continue |
| + } |
| + if err := reg.PutAccount(m.ID, m.Sqid, m.Name); err != nil { |
| + log.Printf("domains: materialize account %s: %v", sqid, err) |
| + continue |
| + } |
| + known[sqid] = true |
| + } |
| + |
| + // --- 1b. app→account linkage, from the KEY PATH -------------------------- |
| + // apps/<sqid>/<app>/… says the app belongs to that account; a legacy |
| + // unqualified apps/<app>/… belongs to ops until it is migrated. The two |
| + // shapes are told apart by whether the first segment is a KNOWN account |
| + // sqid, which is why the account set had to be read first — app names and |
| + // account sqids live in different namespaces, so there is no ambiguity |
| + // once the set is known. |
| + accountOf := map[string]string{} // app -> account sqid |
| + for _, key := range keys { |
| + acct, app, _, ok := appKeyOwner(key, known) |
| + if !ok { |
| + continue |
| + } |
| + accountOf[app] = acct |
| + } |
| + for i := range routes { |
| + if routes[i].App == "" { |
| + continue // an app-less route (status page, bare proxy) has no account |
| + } |
| + acct := accountOf[routes[i].App] |
| + if acct == "" { |
| + acct = OpsSqid // no objects in the bucket yet: ops, like a console-less box |
| + } |
| + if routes[i].Account == acct { |
| + continue |
| + } |
| + if err := reg.SetAccount(routes[i].Host, acct); err != nil { |
| + log.Printf("domains: stamp account on %s: %v", routes[i].Host, err) |
| + continue |
| } |
| + routes[i].Account = acct |
| } |
| // --- 2. platform-domain minting ------------------------------------------ |
| @@ -128,12 +172,7 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg |
| continue |
| } |
| acct := appAccount(routes, accountOf, app) |
| - sq, err := accountSqid(reg, acct) |
| - if err != nil { |
| - log.Printf("domains: account id for %q: %v", acct, err) |
| - continue |
| - } |
| - host := fmt.Sprintf("%s.%s.%s", app, sq, cfg.AppsDomain) |
| + host := fmt.Sprintf("%s.%s.%s", app, acct, cfg.AppsDomain) |
| if byHost[host] { |
| continue // already minted — idempotent |
| } |
| @@ -155,7 +194,7 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg |
| // --- 3. canary hosts: mint for hostless pointers, remove for retired ---- |
| canaryChannels := map[string]map[string]bool{} // app -> set of live canary/<name> channels |
| for _, key := range keys { |
| - app, name, ok := canaryPointer(key) |
| + app, name, ok := canaryPointer(key, known) |
| if !ok { |
| continue |
| } |
| @@ -175,11 +214,6 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg |
| continue |
| } |
| acct := appAccount(routes, accountOf, app) |
| - sq, err := accountSqid(reg, acct) |
| - if err != nil { |
| - log.Printf("domains: account id for %q: %v", acct, err) |
| - continue |
| - } |
| src := firstReadyUpstream(routes, app) |
| if src == nil { |
| continue // app has nothing serving yet; the pointer waits |
| @@ -198,7 +232,7 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg |
| log.Printf("domains: canary channel %s for app %s has no host: minting one needs a per-host canary instance (carlos canary), not an alias to stable — skipping", channel, app) |
| continue |
| } |
| - host := fmt.Sprintf("%s.%s.%s.%s", name, app, sq, cfg.AppsDomain) |
| + host := fmt.Sprintf("%s.%s.%s.%s", name, app, acct, cfg.AppsDomain) |
| nr := mintedRoute(host, app, acct, *src) |
| nr.Channel = channel |
| nr.Canary = true |
| @@ -269,29 +303,69 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg |
| return nil |
| } |
| -// manifestApp reports whether key is an account manifest (apps/<app>/ |
| -// account.json, exactly one path level deep) and which app it names. |
| -func manifestApp(key string) (string, bool) { |
| - rest, ok := strings.CutPrefix(key, "apps/") |
| +// OpsSqid is the built-in ops account's frozen sqid (registry id 1). Every |
| +// deployment has it, console or not. |
| +var OpsSqid = Sqid(1) |
| + |
| +// accountManifestSqid reports whether key is an account manifest |
| +// (accounts/<sqid>/account.json, exactly one path level deep) and which sqid |
| +// its PATH claims. |
| +func accountManifestSqid(key string) (string, bool) { |
| + rest, ok := strings.CutPrefix(key, "accounts/") |
| if !ok { |
| return "", false |
| } |
| - app, ok := strings.CutSuffix(rest, "/account.json") |
| - if !ok || app == "" || strings.Contains(app, "/") { |
| + sqid, ok := strings.CutSuffix(rest, "/account.json") |
| + if !ok || sqid == "" || strings.Contains(sqid, "/") { |
| return "", false |
| } |
| - return app, true |
| + return sqid, true |
| } |
| -// canaryPointer reports whether key is a canary channel pointer |
| -// (apps/<app>/channels/canary/<name>.json) and its app + canary name. |
| -func canaryPointer(key string) (app, name string, ok bool) { |
| +// appKeyOwner reads the app→account linkage straight out of a bucket key: |
| +// apps/<sqid>/<app>/… belongs to that account, apps/<app>/… is a legacy |
| +// unqualified key and belongs to ops until it is migrated. known is the set |
| +// of materialized account sqids — the only thing that tells the two shapes |
| +// apart, which is why accounts are read first. |
| +// |
| +// Keys with nothing after the app segment (apps/<app>) are not linkage: an |
| +// object sitting directly at the app prefix says nothing about ownership. |
| +// It also returns the app's key prefix, so callers can parse what comes after |
| +// it without re-deciding which of the two shapes they are looking at. |
| +func appKeyOwner(key string, known map[string]bool) (acct, app, prefix string, ok bool) { |
| rest, ok := strings.CutPrefix(key, "apps/") |
| + if !ok { |
| + return "", "", "", false |
| + } |
| + first, rest, ok := strings.Cut(rest, "/") |
| + if !ok || first == "" { |
| + return "", "", "", false |
| + } |
| + app, _, hasThird := strings.Cut(rest, "/") |
| + // The one place the two namespaces can genuinely collide: an app whose |
| + // NAME happens to equal a materialized account sqid (an app called "bab" |
| + // on a legacy key). What follows disambiguates it — a qualified key's |
| + // third segment is the app name, a legacy key's second segment is always |
| + // one of the release plane's own words. |
| + if !known[first] || app == "channels" || app == "releases" { |
| + return OpsSqid, first, "apps/" + first + "/", true // apps/<app>/… |
| + } |
| + if !hasThird || app == "" { // apps/<sqid>/<app>/… |
| + return "", "", "", false |
| + } |
| + return first, app, "apps/" + first + "/" + app + "/", true |
| +} |
| + |
| +// canaryPointer reports whether key is a canary channel pointer — qualified |
| +// (apps/<sqid>/<app>/channels/canary/<name>.json) or legacy |
| +// (apps/<app>/channels/canary/<name>.json) — and its app + canary name. |
| +func canaryPointer(key string, known map[string]bool) (app, name string, ok bool) { |
| + _, app, prefix, ok := appKeyOwner(key, known) |
| if !ok { |
| return "", "", false |
| } |
| - app, rest, ok = strings.Cut(rest, "/channels/canary/") |
| - if !ok || app == "" || strings.Contains(app, "/") { |
| + rest, ok := strings.CutPrefix(key, prefix+"channels/canary/") |
| + if !ok { |
| return "", "", false |
| } |
| name, ok = strings.CutSuffix(rest, ".json") |
| @@ -333,9 +407,9 @@ func candidateApps(routes []registry.Route) []string { |
| return out |
| } |
| -// appAccount is the account an app's domains mint under: the bucket |
| -// manifest's word first, else whatever the app's routes already carry, else |
| -// the built-in ops — a deployment with no console lives entirely there. |
| +// appAccount is the account sqid an app's domains mint under: what the bucket |
| +// key paths say first, else whatever the app's routes already carry, else the |
| +// built-in ops — a deployment with no console lives entirely there. |
| func appAccount(routes []registry.Route, accountOf map[string]string, app string) string { |
| if a := accountOf[app]; a != "" { |
| return a |
| @@ -345,28 +419,7 @@ func appAccount(routes []registry.Route, accountOf map[string]string, app string |
| return rt.Account |
| } |
| } |
| - return "ops" |
| -} |
| - |
| -// accountSqid resolves an account NAME to its frozen sqid by looking it up |
| -// among the accounts the bucket has already materialized. It never creates a |
| -// row: ids belong to the bucket (spec §1). An unknown name is an error, so |
| -// nothing ever mints under a guessed identity. |
| -// |
| -// NOTE (multi-account, task 1 of 8): task 4 replaces name-keyed lookup |
| -// entirely — the app→account linkage becomes the bucket key path, which |
| -// carries the sqid directly. |
| -func accountSqid(reg *registry.Registry, name string) (string, error) { |
| - accts, err := reg.Accounts() |
| - if err != nil { |
| - return "", err |
| - } |
| - for _, a := range accts { |
| - if a.Name == name { |
| - return a.Sqid, nil |
| - } |
| - } |
| - return "", fmt.Errorf("no account named %q is materialized", name) |
| + return OpsSqid |
| } |
| // firstReadyUpstream picks the deterministic upstream a minted domain |
diff --git a/internal/domains/reconcile_test.go b/internal/domains/reconcile_test.go| index 56dc37699..515737ad0 100644 |
| --- a/internal/domains/reconcile_test.go |
| +++ b/internal/domains/reconcile_test.go |
| @@ -10,6 +10,7 @@ import ( |
| "crypto/x509/pkix" |
| "encoding/pem" |
| "errors" |
| + "fmt" |
| "math/big" |
| "os" |
| "path/filepath" |
| @@ -43,11 +44,25 @@ func regWith(t *testing.T, routes ...registry.Route) *registry.Registry { |
| return reg |
| } |
| -// manifest writes the console-shaped account manifest for app. |
| -func manifest(t *testing.T, s release.Store, app, account, owner string) { |
| +// writeAccountManifest writes the console-shaped account manifest at |
| +// accounts/<sqid>/account.json — the bucket IS where account identity lives, |
| +// and the id in it is taken as given. The sqid is the frozen encoding of the |
| +// id (never anything else), so the helper derives it and hands it back. |
| +func writeAccountManifest(t *testing.T, s release.Store, id int64, name string) string { |
| t.Helper() |
| - body := []byte(`{"account":"` + account + `","owner":"` + owner + `"}`) |
| - if _, err := s.Put(ctx, "apps/"+app+"/account.json", body, ""); err != nil { |
| + sqid := domains.Sqid(id) |
| + body := []byte(`{"id":` + fmt.Sprint(id) + `,"sqid":"` + sqid + `","name":"` + name + |
| + `","created_at":"2026-08-02T09:00:00Z"}`) |
| + if _, err := s.Put(ctx, "accounts/"+sqid+"/account.json", body, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + return sqid |
| +} |
| + |
| +// putKey drops an object at key so the key-path linkage has something to read. |
| +func putKey(t *testing.T, s release.Store, key string) { |
| + t.Helper() |
| + if _, err := s.Put(ctx, key, []byte("{}"), ""); err != nil { |
| t.Fatal(err) |
| } |
| } |
| @@ -94,7 +109,7 @@ func TestReconcileMintsPlatformDomainUnderOps(t *testing.T) { |
| // No manifest anywhere: the app lives under the built-in ops account |
| // (id 1, Sqid "bab" — frozen). |
| got := mustGet(t, reg, "kass.bab.oncarlos.test") |
| - if !got.Platform || got.Addr != "127.0.0.1:9000" || got.Account != "ops" || got.App != "kass" { |
| + if !got.Platform || got.Addr != "127.0.0.1:9000" || got.Account != "bab" || got.App != "kass" { |
| t.Errorf("minted route = %+v", got) |
| } |
| n := routeCount(t, reg) |
| @@ -106,28 +121,95 @@ func TestReconcileMintsPlatformDomainUnderOps(t *testing.T) { |
| } |
| } |
| -// The registry no longer invents account ids (multi-account spec §1: the |
| -// bucket assigns, the registry materializes), so a manifest naming an account |
| -// the bucket has never described mints NOTHING — a guessed sqid would be a |
| -// platform domain nobody can ever reproduce after a rebuild. The source route |
| -// is still stamped with what the manifest says. |
| -// |
| -// NOTE (task 1 of 8): task 4 replaces name-keyed manifests entirely with |
| -// accounts/<sqid>/account.json + key-path linkage; this test goes with them. |
| -func TestReconcileWillNotMintUnderAnUnmaterializedAccount(t *testing.T) { |
| +// The durability property, at the reconcile layer: whatever id the bucket |
| +// says, the registry takes as given. It never assigns one of its own, so two |
| +// boxes — or one box rebuilt — materialize identical sqids. |
| +func TestReconcileMaterializesAccountsFromManifests(t *testing.T) { |
| + s, reg := dirStore(t), regWith(t) |
| + sqid := writeAccountManifest(t, s, 793481920157, "Kass") |
| + if err := domains.Reconcile(ctx, s, reg, cfg(t)); err != nil { |
| + t.Fatal(err) |
| + } |
| + id, name, _ := reg.AccountBySqid(sqid) |
| + if id != 793481920157 || name != "Kass" { |
| + t.Errorf("materialized id=%d name=%q — the bucket's id must be taken as given", id, name) |
| + } |
| + // A second pass over the same bucket is a no-op, and a REBUILT registry |
| + // re-materializes the identical id — the durability property. |
| + reg2 := regWith(t) |
| + if err := domains.Reconcile(ctx, s, reg2, cfg(t)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if id2, _, _ := reg2.AccountBySqid(sqid); id2 != id { |
| + t.Errorf("rebuilt registry materialized id %d, want %d", id2, id) |
| + } |
| +} |
| + |
| +// The app→account linkage IS the bucket key path: an app under |
| +// apps/<sqid>/<app>/… belongs to that account; a legacy unqualified |
| +// apps/<app>/… belongs to ops until it is migrated. |
| +func TestReconcileDerivesAccountFromKeyPath(t *testing.T) { |
| + s, reg := dirStore(t), regWith(t, |
| + registry.Route{Host: "kass.training", Kind: "instance", App: "kass", Status: "ready", Addr: "127.0.0.1:9000"}, |
| + registry.Route{Host: "old.example.com", Kind: "instance", App: "oldapp", Status: "ready", Addr: "127.0.0.1:9001"}) |
| + sqid := writeAccountManifest(t, s, 793481920157, "Kass") |
| + putKey(t, s, "apps/"+sqid+"/kass/channels/stable.json") |
| + putKey(t, s, "apps/oldapp/channels/stable.json") |
| + if err := domains.Reconcile(ctx, s, reg, cfg(t)); err != nil { |
| + t.Fatal(err) |
| + } |
| + // kass mints under its account; oldapp mints under bab (ops). |
| + kass := mustGet(t, reg, "kass."+sqid+".oncarlos.test") |
| + if !kass.Platform || kass.Account != sqid { |
| + t.Errorf("kass minted route = %+v, want platform under %s", kass, sqid) |
| + } |
| + old := mustGet(t, reg, "oldapp.bab.oncarlos.test") |
| + if !old.Platform || old.Account != "bab" { |
| + t.Errorf("oldapp minted route = %+v, want platform under bab", old) |
| + } |
| + // The source route carries the sqid too, so adopt builds the right keys. |
| + if src := mustGet(t, reg, "kass.training"); src.Account != sqid { |
| + t.Errorf("source route account = %q, want %s", src.Account, sqid) |
| + } |
| +} |
| + |
| +// A malformed account manifest is one skipped line, never a failed pass, and |
| +// never a guessed id. |
| +func TestReconcileSkipsMalformedAccountManifest(t *testing.T) { |
| s, reg := dirStore(t), regWith(t, registry.Route{Host: "kass.training", Kind: "instance", |
| App: "kass", Status: "ready", Addr: "127.0.0.1:9000"}) |
| - manifest(t, s, "kass", "paul", "paul@keymail.dev") |
| - if err := domains.Reconcile(ctx, s, reg, cfg(t)); err != nil { |
| + putKey(t, s, "accounts/bad/account.json") // "{}" — no id, no sqid |
| + if _, err := s.Put(ctx, "accounts/worse/account.json", []byte("{not json"), ""); err != nil { |
| t.Fatal(err) |
| } |
| - for _, host := range []string{"kass.bab.oncarlos.test", "kass.bac.oncarlos.test"} { |
| - if rt, err := reg.Get(host); err != nil || rt != nil { |
| - t.Errorf("minted %s under a guessed identity: %+v %v", host, rt, err) |
| + sqid := writeAccountManifest(t, s, 793481920157, "Kass") |
| + if err := domains.Reconcile(ctx, s, reg, cfg(t)); err != nil { |
| + t.Fatalf("one bad manifest must not fail the pass: %v", err) |
| + } |
| + if id, _, _ := reg.AccountBySqid(sqid); id != 793481920157 { |
| + t.Error("good manifest not processed after the bad ones") |
| + } |
| + for _, sqid := range []string{"bad", "worse"} { |
| + if id, _, _ := reg.AccountBySqid(sqid); id != 0 { |
| + t.Errorf("materialized a malformed manifest as %s (id %d)", sqid, id) |
| } |
| } |
| - if src := mustGet(t, reg, "kass.training"); src.Account != "paul" { |
| - t.Errorf("source route account not stamped from manifest: %+v", src) |
| +} |
| + |
| +// A manifest whose sqid is not the frozen encoding of its own id is a lie |
| +// about identity — materializing it would put an account at a domain that |
| +// stops resolving the moment anything recomputes the sqid. |
| +func TestReconcileRejectsMismatchedSqid(t *testing.T) { |
| + s, reg := dirStore(t), regWith(t) |
| + if _, err := s.Put(ctx, "accounts/kqmv/account.json", |
| + []byte(`{"id":42,"sqid":"kqmv","name":"Liar"}`), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := domains.Reconcile(ctx, s, reg, cfg(t)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if id, _, _ := reg.AccountBySqid("kqmv"); id != 0 { |
| + t.Errorf("materialized a manifest whose sqid isn't Sqid(id): id=%d", id) |
| } |
| } |
| @@ -143,7 +225,7 @@ func TestReconcileMintsStaticAppWithChannel(t *testing.T) { |
| t.Fatal(err) |
| } |
| got := mustGet(t, reg, "platform-site.bab.oncarlos.test") |
| - if !got.Platform || got.Kind != "static" || got.Channel != "stable" || got.Account != "ops" { |
| + if !got.Platform || got.Kind != "static" || got.Channel != "stable" || got.Account != "bab" { |
| t.Errorf("minted static route = %+v, want platform static on channel stable", got) |
| } |
| // Idempotence with the channel copy in place: the minted route now |
| @@ -240,14 +322,15 @@ func TestReconcileNeverMintsASecondApex(t *testing.T) { |
| registry.Route{Host: "kass.training", Kind: "instance", App: "kass", |
| Status: "ready", Addr: "127.0.0.1:9000"}, |
| registry.Route{Host: "kass.bab.oncarlos.test", Kind: "instance", App: "kass", |
| - Status: "ready", Addr: "127.0.0.1:9000", Platform: true, Account: "ops"}, |
| + Status: "ready", Addr: "127.0.0.1:9000", Platform: true, Account: "bab"}, |
| ) |
| - // The app moves to paul's account (id 2, sqid "bac"). |
| - manifest(t, s, "kass", "paul", "paul@keymail.dev") |
| + // The app moves to another account: its objects are now under that sqid. |
| + sqid := writeAccountManifest(t, s, 793481920157, "Kass") |
| + putKey(t, s, "apps/"+sqid+"/kass/channels/stable.json") |
| if err := domains.Reconcile(ctx, s, reg, cfg(t)); err != nil { |
| t.Fatal(err) |
| } |
| - if rt, err := reg.Get("kass.bac.oncarlos.test"); err != nil || rt != nil { |
| + if rt, err := reg.Get("kass." + sqid + ".oncarlos.test"); err != nil || rt != nil { |
| t.Errorf("minted a second apex %+v %v — one app, one platform apex", rt, err) |
| } |
| mustGet(t, reg, "kass.bab.oncarlos.test") // the existing apex stays live |
| @@ -395,7 +478,7 @@ func TestReconcileStampsCertExpiry(t *testing.T) { |
| func TestReconcileInertWithoutAppsDomain(t *testing.T) { |
| s, reg := dirStore(t), regWith(t, registry.Route{Host: "kass.training", Kind: "instance", |
| App: "kass", Status: "ready", Addr: "127.0.0.1:9000"}) |
| - manifest(t, s, "kass", "paul", "paul@keymail.dev") // present but must not be read |
| + writeAccountManifest(t, s, 793481920157, "Kass") // present but must not be read |
| before, err := reg.List() |
| if err != nil { |
| t.Fatal(err) |
| @@ -417,17 +500,21 @@ func TestReconcileInertWithoutAppsDomain(t *testing.T) { |
| } |
| } |
| -func TestReconcileSkipsMalformedManifest(t *testing.T) { |
| +// A retired apps/<app>/account.json (the domains spec's linkage manifest, |
| +// superseded by the key path) is just another object in the bucket now — it |
| +// must not be read, and above all must not re-home an app. |
| +func TestReconcileIgnoresRetiredAppAccountManifests(t *testing.T) { |
| s, reg := dirStore(t), regWith(t, registry.Route{Host: "kass.training", Kind: "instance", |
| App: "kass", Status: "ready", Addr: "127.0.0.1:9000"}) |
| - if _, err := s.Put(ctx, "apps/bad/account.json", []byte("{not json"), ""); err != nil { |
| + if _, err := s.Put(ctx, "apps/kass/account.json", |
| + []byte(`{"account":"paul@keymail.dev","owner":"paul@keymail.dev"}`), ""); err != nil { |
| t.Fatal(err) |
| } |
| - manifest(t, s, "kass", "paul", "paul@keymail.dev") |
| if err := domains.Reconcile(ctx, s, reg, cfg(t)); err != nil { |
| - t.Fatalf("one bad manifest must not fail the pass: %v", err) |
| + t.Fatal(err) |
| } |
| - if src := mustGet(t, reg, "kass.training"); src.Account != "paul" { |
| - t.Errorf("good manifest not processed after the bad one: %+v", src) |
| + if src := mustGet(t, reg, "kass.training"); src.Account != "bab" { |
| + t.Errorf("retired linkage manifest re-homed the app: %+v", src) |
| } |
| + mustGet(t, reg, "kass.bab.oncarlos.test") |
| } |