carlos / platform Public

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>
Paul Campbell 2408cb12527ec177096230130ebfc89e6e8071b0 parent be02113
5 files changed, +368 −126
  • e2e_test.go +8 −16
  • internal/console/account.go +55 −0
  • internal/console/account_test.go +55 −0
  • internal/domains/reconcile.go +130 −77
  • internal/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")
}