multi-account: e2e + docs — same name, different account, different app
TestMultiAccountEndToEnd is the feature end to end: two accounts, an app of the SAME NAME in each, different builds, and the edge serving each account's own bytes on its own host — plus a third app whose objects are still at the legacy account-less keys, serving unchanged through the read-fallback. One reconcile pass then materializes both accounts with the bucket's ids and mints each app's platform domain under the account its key paths say it belongs to. Writing it found the last name-keyed consumer: the domains reconcile pass keyed its apex guard, its candidate list and its upstream lookup on the app NAME, so the second account's "blog" got no platform domain at all. Those are (account, app) now, as is the canary-channel set. The app→account linkage map became a LIST per name for the same reason — collapsing it re-homed every tenant's app onto whichever account the bucket listing reached last — and a route whose account matches none of several owners is now left alone rather than re-homed onto another tenant's bytes. README gains the accounts section: what an account is, the two roles, the switcher, invites, -account, accounts migrate, and the explicit statement that the legacy read-fallback exists until the migration completes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
3 files changed,
+324
−68
README.md+78 −6e2e_test.go+148 −0internal/domains/reconcile.go+98 −62
diff --git a/README.md b/README.md| index b2b065ff3..f5477d69a 100644 |
| --- a/README.md |
| +++ b/README.md |
| @@ -22,6 +22,77 @@ One static binary plays every role, selected by subcommand: `carlos edge`, |
| process per host), `carlos ship`/`promote`/`rollback`, `carlos vet`. Run |
| `carlos` with no arguments for the full command list. |
| +## Accounts |
| + |
| +An **account** is the tenancy primitive: a project or team that owns apps and |
| +has members. Kass, Keymail and Woodstar each get their own rather than sharing |
| +one flat list, and a self-hosted single-app deployment can give team A and |
| +team B separate accounts that deploy independently. |
| + |
| +An account is `{id, sqid, name}`. The **id** is a random 40-bit integer minted |
| +by the console at creation and written to `accounts/<sqid>/account.json` in |
| +the deployment bucket; the registry only ever *materializes* what the bucket |
| +says, never assigns. That is what makes account identity survive a registry |
| +rebuild and be identical on every box — and, because the ids are random, |
| +sqids are not enumerable: you cannot walk `bab`, `bac`, `bad` to discover who |
| +else is on a deployment. The **sqid** is the frozen encoder over that id and |
| +is the account's public identity, in every platform domain and every bucket |
| +key. The **name** is free text and deliberately not unique: two accounts may |
| +both be called "Blog", because identity is the sqid and there is nothing to |
| +squat. |
| + |
| +`ops` is the built-in operator account — id 1, sqid `bab` — created by |
| +migration on every deployment. CLI-added apps live there by default, so a |
| +console-less self-hoster is unaffected by any of this. |
| + |
| +**Roles.** Exactly two: |
| + |
| +| Role | Can | |
| +|---|---| |
| +| **owner** | everything a member can, plus invite, remove members, rename | |
| +| **member** | deploy, promote, rollback, create/retire canaries, view everything | |
| + |
| +Every account has at least one owner; the last one can be neither removed nor |
| +demoted (transfer first). |
| + |
| +**In the console.** The account switcher sits top left on every account page. |
| +URLs are explicit and shareable — `/a/<sqid>/apps/<app>` — so a link a |
| +teammate opens lands where you meant; `/` redirects to the account you were |
| +last in (or your first membership, or one minted on the spot). A signed-in |
| +non-member asking for an account URL gets the **same 404 a nonexistent page |
| +gets**: account existence is not probeable. |
| + |
| +**Invites** go into the addressee's Keymail Apps inbox — the platform never |
| +emails anyone — as a single-use link that expires in 7 days and is revocable. |
| +Clicking it while signed in as that address creates the membership. |
| + |
| +**From the CLI.** Every app verb takes `-account`, a sqid or a name, |
| +defaulting to `ops`: |
| + |
| + carlos accounts create -name Kass # mints the id, writes the manifest |
| + carlos accounts list # sqid, name, app count |
| + carlos ship -account kass -app blog ... |
| + carlos add -account kass -app blog blog.example.com |
| + |
| +An exact sqid wins; otherwise a unique name match; an ambiguous name lists the |
| +candidates and refuses rather than guessing which tenant you meant. |
| + |
| +**Bucket layout, and the migration.** App objects live under |
| +`apps/<account-sqid>/<app>/…`, and that key path *is* the app→account linkage |
| +— there is no separate manifest. Existing deployments still have their objects |
| +at the legacy `apps/<app>/…` keys, so **every read path tries the qualified |
| +key first and falls back to the legacy one**; writes are always qualified. |
| +Moving an app is a verb: |
| + |
| + carlos accounts migrate -app kass -to <sqid> -dry-run # prints the plan |
| + carlos accounts migrate -app kass -to <sqid> |
| + |
| +It copies the objects and re-stamps the app's routes. **It never deletes a |
| +legacy object.** Deleting them — and removing the fallback code — is a |
| +separate operator step, gated on verification: every channel pointer must |
| +resolve from the qualified keys with the fallback disabled, and one adopt pass |
| +must be a no-op, first. |
| + |
| ## Domains |
| Every app has domains as a first-class concept: a **platform domain** it is |
| @@ -32,12 +103,13 @@ when a custom domain isn't connected yet. The scheme: |
| <app>.<account-sqid>.<apps-domain> kass.bab.oncarlos.com |
| <canary>.<app>.<account-sqid>.<apps-domain> pricing.kass.bab.oncarlos.com |
| -The account carries the sqid (its registry row id, base-23 over a frozen |
| -no-lookalike alphabet — deliberately not configurable, so nobody can squat a |
| -vanity label; custom domains are the vanity mechanism), the app carries its |
| -name — app names are unique per account, not globally. Account #1 is `ops`, |
| -the deployment operator's built-in account, created by migration; CLI-added |
| -apps live there, and its sqid (`bab`) is identical on every deployment. The |
| +The account carries the sqid (base-23 over a frozen no-lookalike alphabet, |
| +encoding the account's minted id — deliberately not configurable, so nobody |
| +can squat a vanity label; custom domains are the vanity mechanism), the app |
| +carries its name — app names are unique per account, not globally. Account #1 |
| +is `ops`, the deployment operator's built-in account, created by migration; |
| +CLI-added apps live there, and its sqid (`bab`) is identical on every |
| +deployment. The |
| console writes `apps/<app>/account.json` at claim time; the agent's |
| reconcile pass (riding the 5-minute tick) reads those manifests and mints |
| platform routes, and removes only routes it minted itself — **custom |
diff --git a/e2e_test.go b/e2e_test.go| index 32f6e7d4b..3be8c847d 100644 |
| --- a/e2e_test.go |
| +++ b/e2e_test.go |
| @@ -18,6 +18,7 @@ package main_test |
| import ( |
| "context" |
| "crypto/ed25519" |
| + "crypto/sha256" |
| "encoding/json" |
| "fmt" |
| "io" |
| @@ -492,6 +493,153 @@ func TestStatusCLI(t *testing.T) { |
| } |
| } |
| +// e2eSiteTar packs a one-file site into the tarball a static release ships. |
| +func e2eSiteTar(t *testing.T, body string) []byte { |
| + t.Helper() |
| + dir := t.TempDir() |
| + if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte(body), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + tarball, err := edge.PackDir(dir) |
| + if err != nil { |
| + t.Fatalf("PackDir: %v", err) |
| + } |
| + return tarball |
| +} |
| + |
| +// TestMultiAccountEndToEnd is the whole point of the feature, end to end: two |
| +// accounts, an app of the SAME NAME in each, different builds, and the edge |
| +// serving each account's own bytes on its own host. Plus the migration seam: |
| +// a third app whose objects are still at the legacy, account-less keys serves |
| +// through the read-fallback, unchanged. |
| +func TestMultiAccountEndToEnd(t *testing.T) { |
| + ctx := context.Background() |
| + tmp := t.TempDir() |
| + store := release.NewDirStore(filepath.Join(tmp, "store")) |
| + seed, pubB64, err := release.GenerateKey() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + priv, err := release.ParseSigningKey(seed, "e2e") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + pub, err := release.ParsePublicKey(pubB64, "e2e") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + // Two accounts, minted the way the console does it: a random id, its |
| + // frozen sqid, and a bucket manifest the agent materializes from. |
| + reg, err := registry.Open(filepath.Join(tmp, "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer reg.Close() |
| + accounts := map[string]string{} // name -> sqid |
| + for name, id := range map[string]int64{"Kass": 793481920157, "Woodstar": 481920157793} { |
| + sqid := domains.Sqid(id) |
| + accounts[name] = sqid |
| + body := fmt.Sprintf(`{"id":%d,"sqid":%q,"name":%q,"created_at":"2026-08-02T09:00:00Z"}`, id, sqid, name) |
| + if _, err := store.Put(ctx, "accounts/"+sqid+"/account.json", []byte(body), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if accounts["Kass"] == accounts["Woodstar"] { |
| + t.Fatal("fixture accounts collided") |
| + } |
| + |
| + // The same app name in both, shipped with different content. |
| + for name, content := range map[string]string{"Kass": "kass blog", "Woodstar": "woodstar blog"} { |
| + acct := accounts[name] |
| + m := release.Manifest{Schema: 1, App: "blog", Version: "v1", Kind: "static", |
| + Artifacts: map[string]release.Artifact{"site.tar.gz": {}}} |
| + if err := release.Ship(ctx, store, acct, m, map[string][]byte{"site.tar.gz": e2eSiteTar(t, content)}); err != nil { |
| + t.Fatalf("Ship blog for %s: %v", name, err) |
| + } |
| + if err := release.Promote(ctx, store, priv, acct, "blog", "stable", "v1"); err != nil { |
| + t.Fatalf("Promote blog for %s: %v", name, err) |
| + } |
| + } |
| + // And a legacy app: objects at the account-less keys, exactly as an |
| + // unmigrated deployment's bucket has them. |
| + legacyTar := e2eSiteTar(t, "legacy site") |
| + legacyManifest := release.Manifest{Schema: 1, App: "oldsite", Version: "v1", Kind: "static", |
| + Artifacts: map[string]release.Artifact{"site.tar.gz": { |
| + File: "site.tar.gz", SHA256: fmt.Sprintf("%x", sha256.Sum256(legacyTar)), Size: int64(len(legacyTar))}}} |
| + raw, err := json.MarshalIndent(legacyManifest, "", " ") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := store.Put(ctx, release.LegacyArtifactKey("oldsite", "v1", "site.tar.gz"), legacyTar, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := store.Put(ctx, release.LegacyManifestKey("oldsite", "v1"), raw, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := release.Promote(ctx, store, priv, release.OpsAccount, "oldsite", "stable", "v1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + for _, rt := range []registry.Route{ |
| + {Host: "blog.kass.test", Kind: "static", App: "blog", Account: accounts["Kass"], Channel: "stable", Status: "ready"}, |
| + {Host: "blog.woodstar.test", Kind: "static", App: "blog", Account: accounts["Woodstar"], Channel: "stable", Status: "ready"}, |
| + {Host: "old.test", Kind: "static", App: "oldsite", Channel: "stable", Status: "ready"}, |
| + } { |
| + if err := reg.Upsert(rt); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + |
| + e, err := edge.New(reg, nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + e.EnableStatic(store, pub, filepath.Join(tmp, "static-cache")) |
| + e.SetStaticRecheck(0) |
| + if err := e.Reload(); err != nil { |
| + t.Fatal(err) |
| + } |
| + srv := httptest.NewServer(e) |
| + defer srv.Close() |
| + |
| + for host, want := range map[string]string{ |
| + "blog.kass.test": "kass blog", |
| + "blog.woodstar.test": "woodstar blog", |
| + "old.test": "legacy site", // served through the read-fallback |
| + } { |
| + if code, body := e2eGet(t, srv.URL, host, "/"); code != 200 || body != want { |
| + t.Errorf("GET %s / = %d %q, want 200 %q", host, code, body, want) |
| + } |
| + } |
| + |
| + // One reconcile pass: accounts materialize with the bucket's ids, and each |
| + // app mints its platform domain under the account its KEY PATHS say it |
| + // belongs to — the legacy one under ops. |
| + if err := domains.Reconcile(ctx, store, reg, domains.Config{AppsDomain: "oncarlos.test"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + for name, sqid := range accounts { |
| + id, got, _ := reg.AccountBySqid(sqid) |
| + if id == 0 || got != name { |
| + t.Errorf("account %s materialized as id=%d name=%q", sqid, id, got) |
| + } |
| + } |
| + if err := e.Reload(); err != nil { |
| + t.Fatal(err) |
| + } |
| + for host, want := range map[string]string{ |
| + "blog." + accounts["Kass"] + ".oncarlos.test": "kass blog", |
| + "blog." + accounts["Woodstar"] + ".oncarlos.test": "woodstar blog", |
| + "oldsite.bab.oncarlos.test": "legacy site", |
| + } { |
| + if code, body := e2eGet(t, srv.URL, host, "/"); code != 200 || body != want { |
| + t.Errorf("GET %s / = %d %q, want 200 %q — the platform domain must serve its own account's build", |
| + host, code, body, want) |
| + } |
| + } |
| +} |
| + |
| // TestAccountsCLI drives the `carlos accounts` verbs as an operator would: |
| // create mints identity, list shows it, and migrate moves an app's objects to |
| // the account-qualified prefix WITHOUT ever deleting the legacy ones — |
diff --git a/internal/domains/reconcile.go b/internal/domains/reconcile.go| index 12253c5cd..a38392895 100644 |
| --- a/internal/domains/reconcile.go |
| +++ b/internal/domains/reconcile.go |
| @@ -9,6 +9,7 @@ import ( |
| "log" |
| "os" |
| "path/filepath" |
| + "slices" |
| "strings" |
| "time" |
| @@ -124,23 +125,26 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg |
| // 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 |
| + // owners is app name -> the accounts that hold objects for it. A LIST, not |
| + // one account: the same app name legitimately exists in several accounts, |
| + // and collapsing them to one would re-home every tenant's app onto |
| + // whichever account the bucket listing happened to reach last. |
| + owners := map[string][]string{} |
| for _, key := range keys { |
| acct, app, _, ok := appKeyOwner(key, known) |
| if !ok { |
| continue |
| } |
| - accountOf[app] = acct |
| + if !slices.Contains(owners[app], acct) { |
| + owners[app] = append(owners[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 { |
| + acct := resolveRouteAccount(routes[i], owners[routes[i].App]) |
| + if acct == "" || routes[i].Account == acct { |
| continue |
| } |
| if err := reg.SetAccount(routes[i].Host, acct); err != nil { |
| @@ -152,35 +156,38 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg |
| // --- 2. platform-domain minting ------------------------------------------ |
| byHost := map[string]bool{} |
| - // hasApex tracks apps that already own a platform apex, under ANY |
| - // account. The guard is deliberately "already has one" rather than |
| - // "already has the account-correct one": minting is additive (nothing |
| - // here ever removes an apex), so keying on the expected host would mint |
| - // a SECOND apex the moment an app's account changed, leaving both live |
| - // forever with no rule for which is canonical. One app, one apex; moving |
| - // an app between accounts is a deliberate operator migration, not a |
| - // silent side effect of a manifest edit. |
| - hasApex := map[string]bool{} |
| + // hasApex tracks the (ACCOUNT, app) pairs that already own a platform |
| + // apex. The account is part of the key because app names are unique per |
| + // account, never globally: two tenants can both run a "blog", and each |
| + // deserves its own apex. |
| + // |
| + // Within one account the guard is deliberately "already has one" rather |
| + // than "already has the account-correct one": minting is additive (nothing |
| + // here ever removes an apex), so keying on the expected host would mint a |
| + // SECOND apex the moment an app's account changed, leaving both live |
| + // forever with no rule for which is canonical. One app per account, one |
| + // apex; moving an app between accounts is a deliberate operator migration, |
| + // not a silent side effect. |
| + hasApex := map[appKey]bool{} |
| for _, rt := range routes { |
| byHost[rt.Host] = true |
| if rt.Platform && !rt.Canary { |
| - hasApex[rt.App] = true |
| + hasApex[appKey{release.RouteAccount(rt), rt.App}] = true |
| } |
| } |
| - for _, app := range candidateApps(routes) { |
| - if hasApex[app] { |
| + for _, k := range candidateApps(routes) { |
| + if hasApex[k] { |
| continue |
| } |
| - acct := appAccount(routes, accountOf, app) |
| - host := fmt.Sprintf("%s.%s.%s", app, acct, cfg.AppsDomain) |
| + host := fmt.Sprintf("%s.%s.%s", k.app, k.acct, cfg.AppsDomain) |
| if byHost[host] { |
| continue // already minted — idempotent |
| } |
| - src := firstReadyUpstream(routes, app) |
| + src := firstReadyUpstream(routes, k) |
| if src == nil { |
| continue // nothing serving to copy an upstream from; next pass |
| } |
| - nr := mintedRoute(host, app, acct, *src) |
| + nr := mintedRoute(host, k.app, k.acct, *src) |
| if err := reg.Upsert(nr); err != nil { |
| log.Printf("domains: mint %s: %v", host, err) |
| continue |
| @@ -188,24 +195,29 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg |
| log.Printf("domains: minted platform domain %s (upstream %s)", host, src.Host) |
| routes = append(routes, nr) |
| byHost[host] = true |
| - hasApex[app] = true |
| + hasApex[k] = true |
| } |
| // --- 3. canary hosts: mint for hostless pointers, remove for retired ---- |
| - canaryChannels := map[string]map[string]bool{} // app -> set of live canary/<name> channels |
| + canaryChannels := map[appKey]map[string]bool{} // (account, app) -> live canary/<name> channels |
| for _, key := range keys { |
| + acctOfKey, app, _, ok := appKeyOwner(key, known) |
| + if !ok { |
| + continue |
| + } |
| app, name, ok := canaryPointer(key, known) |
| if !ok { |
| continue |
| } |
| - if canaryChannels[app] == nil { |
| - canaryChannels[app] = map[string]bool{} |
| + k := appKey{acctOfKey, app} |
| + if canaryChannels[k] == nil { |
| + canaryChannels[k] = map[string]bool{} |
| } |
| channel := "canary/" + name |
| - canaryChannels[app][channel] = true |
| + canaryChannels[k][channel] = true |
| followed := false |
| for _, rt := range routes { |
| - if rt.App == app && rt.Channel == channel { |
| + if rt.App == app && release.RouteAccount(rt) == k.acct && rt.Channel == channel { |
| followed = true |
| break |
| } |
| @@ -213,8 +225,8 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg |
| if followed { |
| continue |
| } |
| - acct := appAccount(routes, accountOf, app) |
| - src := firstReadyUpstream(routes, app) |
| + acct := k.acct |
| + src := firstReadyUpstream(routes, k) |
| if src == nil { |
| continue // app has nothing serving yet; the pointer waits |
| } |
| @@ -254,7 +266,7 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg |
| kept := routes[:0] |
| for _, rt := range routes { |
| if rt.Platform && rt.Canary && strings.HasPrefix(rt.Channel, "canary/") && |
| - !canaryChannels[rt.App][rt.Channel] { |
| + !canaryChannels[appKey{release.RouteAccount(rt), rt.App}][rt.Channel] { |
| if err := reg.Delete(rt.Host); err != nil { |
| log.Printf("domains: remove retired canary route %s: %v", rt.Host, err) |
| kept = append(kept, rt) // still there; stamp it honestly below |
| @@ -375,53 +387,77 @@ func canaryPointer(key string, known map[string]bool) (app, name string, ok bool |
| return app, name, true |
| } |
| -// candidateApps returns the distinct app names eligible for a platform |
| +// appKey is an app's identity: the account it belongs to plus its name. App |
| +// names are unique WITHIN an account, never globally, so nothing about an app |
| +// may be keyed on the name alone. |
| +type appKey struct{ acct, app string } |
| + |
| +// resolveRouteAccount decides which account a route's app belongs to, given |
| +// the accounts the BUCKET says hold objects under that app name. "" means |
| +// leave the row alone. |
| +// |
| +// The rules, in order: |
| +// - the row's own account already holds objects: it is right, keep it; |
| +// - exactly one account holds them: adopt it (this is what makes a |
| +// migration land, and what puts a legacy app under ops); |
| +// - several do, and the row names none of them: leave it alone. Picking one |
| +// would re-home somebody's app onto another tenant's bytes, which is far |
| +// worse than a row that reconciliation declines to touch. |
| +func resolveRouteAccount(rt registry.Route, owners []string) string { |
| + if len(owners) == 0 { |
| + if rt.Account == "" { |
| + return OpsSqid // nothing shipped yet: ops, like a console-less box |
| + } |
| + return "" // the row says something; the bucket says nothing. Believe the row. |
| + } |
| + if slices.Contains(owners, release.RouteAccount(rt)) { |
| + return release.RouteAccount(rt) |
| + } |
| + if len(owners) == 1 { |
| + return owners[0] |
| + } |
| + log.Printf("domains: %s (app %q) names account %q, but objects exist under %v — leaving it alone", |
| + rt.Host, rt.App, rt.Account, owners) |
| + return "" |
| +} |
| + |
| +// candidateApps returns the (account, app) pairs eligible for a platform |
| // domain, in first-seen (host) order: every app — static sites included, a |
| // shipped static tree deserves an addressable URL as much as an instance |
| // does — outside the platform-reserved svc/* namespace, with at least one |
| // route that is NOT itself platform-minted. An app whose only routes are |
| // minted ones has been removed by its operator, and re-minting from a mint |
| // would keep a ghost alive forever. |
| -func candidateApps(routes []registry.Route) []string { |
| - seen := map[string]bool{} |
| - real := map[string]bool{} // has a non-platform route |
| - var order []string |
| +// |
| +// The account is the one already stamped on the route by the linkage step |
| +// above, so an app whose objects have moved mints under the account that now |
| +// owns them. |
| +func candidateApps(routes []registry.Route) []appKey { |
| + seen := map[appKey]bool{} |
| + real := map[appKey]bool{} // has a non-platform route |
| + var order []appKey |
| for _, rt := range routes { |
| if rt.App == "" || strings.HasPrefix(rt.App, "svc/") { |
| continue |
| } |
| - if !seen[rt.App] { |
| - seen[rt.App] = true |
| - order = append(order, rt.App) |
| + k := appKey{release.RouteAccount(rt), rt.App} |
| + if !seen[k] { |
| + seen[k] = true |
| + order = append(order, k) |
| } |
| if !rt.Platform { |
| - real[rt.App] = true |
| + real[k] = true |
| } |
| } |
| - var out []string |
| - for _, app := range order { |
| - if real[app] { |
| - out = append(out, app) |
| + var out []appKey |
| + for _, k := range order { |
| + if real[k] { |
| + out = append(out, k) |
| } |
| } |
| return out |
| } |
| -// 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 |
| - } |
| - for _, rt := range routes { |
| - if rt.App == app && rt.Account != "" { |
| - return rt.Account |
| - } |
| - } |
| - return OpsSqid |
| -} |
| - |
| // firstReadyUpstream picks the deterministic upstream a minted domain |
| // serves from: the app's first ready route in host order (reg.List's order) |
| // that has something to copy and is not itself a minted route — minting |
| @@ -429,10 +465,10 @@ func appAccount(routes []registry.Route, accountOf map[string]string, app string |
| // upstream (Socket or Addr) for instances and services, or — for static |
| // routes — the release channel, since a static route's backend IS its |
| // channel pointer, not a socket. |
| -func firstReadyUpstream(routes []registry.Route, app string) *registry.Route { |
| +func firstReadyUpstream(routes []registry.Route, k appKey) *registry.Route { |
| for i := range routes { |
| rt := &routes[i] |
| - if rt.App != app || rt.Platform || rt.Status != "ready" { |
| + if rt.App != k.app || release.RouteAccount(*rt) != k.acct || rt.Platform || rt.Status != "ready" { |
| continue |
| } |
| if rt.Socket != "" || rt.Addr != "" || (rt.Kind == "static" && rt.Channel != "") { |