// The `carlos accounts` verb: create, list, and migrate accounts from the // command line — for CLI-only deployments (the console does its own minting) // and for the one deliberate data move the multi-account rollout needs. // // The migration is a VERB, not a runbook: `carlos accounts migrate --app kass // --to ` copies an app's bucket objects to the account-qualified prefix // and re-stamps its routes. It NEVER deletes a legacy object — deletion is a // separate operator step, behind the verification gate (every channel pointer // resolves qualified with the fallback disabled, and one adopt pass is a // no-op). See docs/superpowers/specs/2026-08-02-multi-account-design.md §5. package main import ( "context" "crypto/ed25519" "errors" "flag" "fmt" "io" "log" "net/http" "os" "slices" "strings" "time" "github.com/carlosframework/platform/internal/domains" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" ) const accountsUsage = `usage: carlos accounts create --name mint an account: the next free id, its frozen sqid, and the bucket manifest every box materializes identity from [--owner
] who owns it. On a box or in CI — no browser, no login — this is the path: it needs no identity of its own and takes precedence over any logged-in one. [--console ] which logged-in console to mint through (only needed with several) list your accounts. Signed-in (the usual case): name, sqid and your role, from the console. On a box with a registry: sqid, name and app count from the registry itself. [--console ] list through a console even where a registry exists (or: which logged-in console, with several) migrate --app --to copy an app's objects to the account- qualified prefix and re-stamp its routes [--from ] source account (default: the legacy, account-less keys) [--dry-run] print the plan and touch nothing plan --account show or change an account's plan (deployment operators) [--set ] put the account on this plan. needs --reason [--pack ] add a pack. repeat it for more than one [--sponsored ] limits for a sponsored account, used where they are more generous than the plan's [--reason ] why the plan is changing [--console ] which logged-in console to use plan only works where the console has a plan catalog (CARLOS_PLAN_CATALOG). create always names an owner, one way or the other: log this terminal in with "carlos auth login" and you become the owner, or pass --owner. It refuses rather than mint an account nobody can open in the console. migrate never deletes a legacy object. Deleting them is a separate operator step, behind the verification gate: every channel pointer must resolve from the qualified keys with the fallback disabled, and one adopt pass must be a no-op, first. ` // RunAccounts dispatches the accounts sub-verbs. func RunAccounts(args []string) { if len(args) == 0 { fmt.Fprint(os.Stderr, accountsUsage) os.Exit(1) } switch args[0] { case "create": runAccountsCreate(args[1:]) case "list": runAccountsList(args[1:]) case "migrate": runAccountsMigrate(args[1:]) case "plan": runAccountsPlan(args[1:]) default: fmt.Fprint(os.Stderr, accountsUsage) os.Exit(1) } } func runAccountsCreate(args []string) { fs := flag.NewFlagSet("accounts create", flag.ExitOnError) regPath := fs.String("registry", defaultRegistry(), "registry database") name := fs.String("name", "", "account name (a-z, 0-9, hyphen; need not be unique)") owner := fs.String("owner", "", "keymail address that owns the account — the box-side/headless path, "+ "and what to use when this terminal is not logged in") console := consoleOption(fs, "which logged-in console to create it through (only needed with several)") fs.Usage = flagUsage(fs, "") fs.Parse(args) *name = strings.TrimSpace(*name) if *name == "" { log.Fatal("usage: carlos accounts create --name [--owner
]") } if !domains.ValidAccountName(*name) { log.Fatalf("accounts create: %q is not a usable account name — 1-63 characters of a-z, 0-9\n"+ "and hyphen, no leading or trailing hyphen. The name appears in console URLs and\n"+ "resolves --account.", *name) } // An ownerless account is the failure this whole path exists to prevent: // it materializes into the console with no members, which means nobody can // open it, invite anyone to it, or see the apps migrated into it. So there // are exactly two ways to name an owner and no way to skip it. // // --owner WINS over the logged-in identity, deliberately: it is the explicit // instruction, it is the documented headless path, and an operator creating // an account FOR somebody else must not have their own address quietly // substituted. if addr := strings.TrimSpace(*owner); addr != "" { createAccountInBucket(*regPath, *name, addr) return } cred, credErr := loggedInCredential(*console) if credErr != nil { log.Fatalf("accounts create: this account would have no owner, and an ownerless account is one\n"+ "nobody can open in the console. Name one, either way:\n\n"+ " carlos auth login --console log this terminal in; you become the owner\n"+ " carlos accounts create --owner
--name %q\n"+ " name the owner outright — the box-side and\n"+ " headless path, no browser or login needed\n\n"+ "(%v)", *name, credErr) } createAccountViaConsole(cred, *regPath, *name) } // createAccountInBucket is the original path, plus an owner: mint the id with // a create-only manifest write, then materialize the registry row. // // The bucket first, and not just for ordering: the manifest write IS the // allocator. domains.MintAccount picks the next free integer and claims it // with a create-only Put, so this CLI and a console minting at the same moment // cannot land on the same id — and the registry, which only ever materializes // what the bucket says, never has to assign anything. The owner rides along in // that same manifest, which is how the console learns who to admit. func createAccountInBucket(regPath, name, owner string) { // Normalize before anything is written OR printed. MintAccount normalizes // what it stores, so printing the raw input would tell the operator their // membership is "Paul@Keymail.dev" when the row — and the session that must // match it — is lowercase. That mismatch is exactly the confusion the // `console member` verb was fixed for. owner = domains.NormalizeAddress(owner) store := openStore() id, sqid, err := domains.MintAccount(context.Background(), store, name, owner, time.Now()) if err != nil { log.Fatalf("accounts create: %v", err) } r, err := registry.Open(regPath) if err != nil { log.Fatal(err) } defer r.Close() if err := r.PutAccount(id, sqid, name); err != nil { log.Fatalf("accounts create: materialize: %v", err) } fmt.Printf("%s %q\n id: %d\n sqid: %s\n owner: %s\n", colorFor(os.Stdout).ok("created account"), name, id, sqid, owner) mutedf(os.Stdout, "use it with: carlos ship --account %s --app ...\n", sqid) fmt.Printf("%s becomes its owner in the console the next time the console materializes\n"+ "accounts from the bucket (on start, or on any `console member` command).\n", owner) } // createAccountViaConsole mints through the console this terminal is logged in // to, which makes the logged-in human the owner in one step. // // Through the console rather than straight into the bucket because the console // is the identity authority: it writes the manifest AND the owner membership in // its own database in one path, so the account is openable by its creator the // instant it exists — rather than "once something materializes it", which is the // gap that produced accounts nobody could reach. func createAccountViaConsole(cred *credential, regPath, name string) { if warn := credentialPermWarning(); warn != "" { warnf(os.Stderr, "%s", warn) } c := newConsoleClient(cred.Console, cred.Token) var out struct { ID int64 `json:"id"` Sqid string `json:"sqid"` Name string `json:"name"` Owner string `json:"owner"` URL string `json:"url"` } if _, err := c.do(context.Background(), http.MethodPost, "/auth/cli/accounts", map[string]string{"name": name}, &out); err != nil { log.Fatalf("accounts create: %s: %v", cred.Console, err) } pal := colorFor(os.Stdout) fmt.Printf("%s %q\n id: %d\n sqid: %s\n owner: %s (you)\n", pal.ok("created account"), out.Name, out.ID, out.Sqid, out.Owner) fmt.Printf(" open: %s\n", pal.link(out.URL)) mutedf(os.Stdout, "use it with: carlos ship --account %s --app ...\n", out.Sqid) // The registry row is this box's local materialization of what the bucket // already says. Best-effort, unlike the bucket-mint path: the account // exists and is owned no matter what happens here, and a build machine // with no registry at all is a perfectly normal place to run this. r, err := registry.Open(regPath) if err != nil { mutedf(os.Stdout, "\n(no registry at %s, so nothing was materialized locally: %v —\n"+ "harmless: the bucket is the control plane and every box reconciles from it.)", regPath, err) return } defer r.Close() if err := r.PutAccount(out.ID, out.Sqid, out.Name); err != nil { mutedf(os.Stdout, "\n(could not materialize the registry row here: %v — harmless: the account\n"+ "exists in the bucket and every box reconciles from it.)", err) } } func runAccountsList(args []string) { fs := flag.NewFlagSet("accounts list", flag.ExitOnError) regPath := fs.String("registry", defaultRegistry(), "registry database (box mode)") consoleFlag := consoleOption(fs, "list through a console instead (default: the box registry when one exists, else your logged-in console)") fs.Usage = flagUsage(fs, "") fs.Parse(args) // Mode split by what exists, not by resolveMode: this verb predates the // console API and must keep working on a box that has a registry and no // bucket env, no credentials — while a laptop with neither registry nor // box access (the #157 case) asks its console. An explicit --console // skips the registry even where one exists; an explicit --registry is a // commitment, not a hint — a typo'd path or an EACCES must surface as // registry.Open's own error, never as a silent hop to console mode // answering "not logged in" to someone who asked about a database. explicitRegistry := false fs.Visit(func(f *flag.Flag) { if f.Name == "registry" { explicitRegistry = true } }) if *consoleFlag == "" { if explicitRegistry { runAccountsListRegistry(*regPath) return } if _, err := os.Stat(*regPath); err == nil { runAccountsListRegistry(*regPath) return } } if err := accountsListAPI(os.Stdout, *consoleFlag); err != nil { log.Fatal(err) } } // accountsListAPI is the signed-in form: the caller's own memberships from // GET /api/cli/apps, as a NAME/SQID/ROLE table. The one 404 this path can // see is a console too old to have the endpoint (no account/app segment // here for the membership gate to refuse), so it gets a named refusal // instead of notFoundErr's check-your-flags riddle. func accountsListAPI(out io.Writer, consoleFlag string) error { cred, err := loggedInCredential(consoleFlag) if err != nil { return err } var acc struct { Accounts []apiAccountEntry `json:"accounts"` } status, err := newConsoleClient(cred.Console, cred.Token). do(context.Background(), http.MethodGet, "/api/cli/apps", nil, &acc) if status == http.StatusNotFound { return fmt.Errorf("%s predates account listing — upgrade the console, or run this on a box with --registry", cred.Console) } if err != nil { return err } w := newTable(out) fmt.Fprintln(w, "NAME\tSQID\tROLE") for _, a := range acc.Accounts { fmt.Fprintf(w, "%s\t%s\t%s\n", a.Name, a.Sqid, a.Role) } w.Flush() return nil } func runAccountsListRegistry(regPath string) { r, err := registry.Open(regPath) if err != nil { log.Fatal(err) } defer r.Close() accts, err := r.Accounts() if err != nil { log.Fatal(err) } routes, err := r.List() if err != nil { log.Fatal(err) } // App count from the ROUTES, not the bucket: this verb must work on a box // with no deployment store configured at all. apps := map[string]map[string]bool{} for _, rt := range routes { if rt.App == "" { continue } acct := release.RouteAccount(rt) if apps[acct] == nil { apps[acct] = map[string]bool{} } apps[acct][rt.App] = true } w := newTable(os.Stdout) fmt.Fprintln(w, "SQID\tNAME\tAPPS\tID") for _, a := range accts { fmt.Fprintf(w, "%s\t%s\t%d\t%d\n", a.Sqid, a.Name, len(apps[a.Sqid]), a.ID) } w.Flush() } func runAccountsMigrate(args []string) { fs := flag.NewFlagSet("accounts migrate", flag.ExitOnError) regPath := fs.String("registry", defaultRegistry(), "registry database") app := appFlag(fs, "app to move") to := fs.String("to", "", "destination account sqid") from := fs.String("from", "", "source account sqid, or \"legacy\" for the pre-accounts keys (default: ops, then legacy)") dryRun := fs.Bool("dry-run", false, "print the plan and touch nothing") fs.Usage = flagUsage(fs, "") fs.Parse(args) if *app == "" || *to == "" { log.Fatal("usage: carlos accounts migrate --app --to [--from ] [--dry-run]") } if *to == release.LegacySource { log.Fatalf("accounts migrate: %q is a source, never a destination — the account-less key space is what this verb exists to empty", release.LegacySource) } if !release.ValidAccount(*to) { log.Fatalf("accounts migrate: %q is not an account sqid", *to) } r, err := registry.Open(*regPath) if err != nil { log.Fatal(err) } defer r.Close() // The destination must be MATERIALIZED first: moving an app into an // account the bucket has never described would put it at a prefix nothing // can resolve an identity for. if id, _, err := r.AccountBySqid(*to); err != nil { log.Fatal(err) } else if id == 0 { log.Fatalf("accounts migrate: no account %s is materialized here — run `carlos accounts create` (or reconcile) first", *to) } dst := release.AppPrefix(*to, *app) ctx := context.Background() store := openStore() // Where the app's objects are RIGHT NOW. This is the whole hazard of the // verb, so it is resolved the way the runtime resolves them and not by // assuming a prefix. plan, srcAcct, hadProduction := migrationPlan(ctx, store, *app, *from, *to, dst) if err := validateMigrationRecords(ctx, store, plan); err != nil { log.Fatal(err) } if len(plan) == 0 { mutedf(os.Stdout, "nothing under %s — nothing to copy\n", release.AppPrefix(srcAcct, *app)) } rebind := pointerRebinder(*app, srcAcct, *to) // Pointers the legacy-source path refused to overwrite because a newer // qualified pointer already exists; verification must not compare these // against the legacy copy they deliberately did not take. var keptPointers []string for _, c := range plan { if *dryRun { warnf(os.Stdout, "would copy %s -> %s\n", c.from, c.to) continue } body, _, err := store.Get(ctx, c.from) if err != nil { log.Fatalf("accounts migrate: read %s: %v", c.from, err) } if c.pointer { body = rebind(c.to, body) } // Channel pointers CONVERGE; everything else is immutable and is // claimed create-only. The split matters: a release manifest or // artifact at an existing path must never change meaning, so // different bytes there is a hard refusal. A channel pointer is the // one mutable object in the bucket — it moves on every promote — so a // destination pointer that disagrees is this migration's own // leftover, and the verb's whole job is to make the destination serve // what the source serves. Refusing there would strand the operator: // the pointer exists, so a re-run would hit exactly this branch // again, with no way forward that did not involve hand-deleting // objects mid-migration. if c.pointer { // EXCEPT when the source is the legacy key space. There, the // converge rationale inverts: an ordinary migration's destination // pointer can only be this migration's own leftover, but a // QUALIFIED pointer that already exists alongside legacy keys is // live state — promotes have been landing on it — and the legacy // copy is the stale one. Converging would move production // BACKWARDS, which is precisely what happened to the console on // 2026-08-03 (qualified canary/console at 3c7c7bc overwritten by a // day-old legacy 771c2f0; caught by the next adopt pass's // "updated" line reading like a rollback). Keep the qualified // pointer, say so, and count it — the operator is told the legacy // pointer was NOT taken rather than left to find out from adopt. if srcAcct == release.LegacySource { if cur, _, err := store.Get(ctx, c.to); err == nil && string(cur) != string(body) { fmt.Printf("%s %s — a qualified pointer already exists and differs from the legacy\n"+ "copy; the qualified one is live state and wins. The legacy pointer was not taken.\n", colorFor(os.Stdout).warn("KEPT"), c.to) keptPointers = append(keptPointers, c.to) continue } } switch changed, err := putOverwrite(ctx, store, c.to, body); { case err != nil: log.Fatalf("accounts migrate: write %s: %v", c.to, err) case changed: fmt.Printf("%s %s -> %s (the destination pointer disagreed)\n", colorFor(os.Stdout).ok("converged"), c.from, c.to) default: mutedf(os.Stdout, "already copied %s -> %s\n", c.from, c.to) } continue } if _, err := putMigrationObject(ctx, store, c.to, body); err != nil { cur, _, gerr := store.Get(ctx, c.to) if gerr != nil { log.Fatalf("accounts migrate: write %s: %v", c.to, err) } if string(cur) != string(body) { log.Fatalf("accounts migrate: %s already exists with different content — refusing to\n"+ "overwrite an immutable release object. Nothing has been deleted; work out which\n"+ "of the two is the real %s before going further.", c.to, *app) } mutedf(os.Stdout, "already copied %s -> %s\n", c.from, c.to) continue } fmt.Printf("%s %s -> %s\n", colorFor(os.Stdout).ok("copied"), c.from, c.to) } // The one object that does not travel. Loud, because nothing downstream // will notice: a destination with no flag simply defaults to "not // production", so the app stops baking silently rather than failing. if hadProduction { fmt.Printf("\n%s\n", colorFor(os.Stdout).warn("NOT COPIED:")) fmt.Printf(" %s: carried a production declaration — re-declare it at the destination "+ "(the flag is signed per-account and cannot be copied)\n", *app) } if !*dryRun { verifyMigratedPointers(ctx, store, *app, srcAcct, *to, keptPointers, rebind) } // The routes' account column: what adopt and the edge build keys from. routes, err := r.List() if err != nil { log.Fatal(err) } for _, rt := range routes { if rt.App != *app || rt.Account == *to { continue } if *dryRun { warnf(os.Stdout, "would stamp account %s on route %s (was %q)\n", *to, rt.Host, rt.Account) continue } if err := r.SetAccount(rt.Host, *to); err != nil { log.Fatalf("accounts migrate: stamp %s: %v", rt.Host, err) } fmt.Printf("%s account %s on route %s\n", colorFor(os.Stdout).ok("stamped"), *to, rt.Host) } // The console keeps its OWN app→account row, and this binary deliberately // does not open the console's database — console state is the console's // authority, exactly like grants. So the move is not finished until the // operator runs the box-side verb, and the exact command goes in the // output rather than in a runbook somebody has to find. followUp := fmt.Sprintf("console migrate-app --app %s --to %s", *app, *to) if *dryRun { warnf(os.Stdout, "dry run: nothing was written") fmt.Printf("\nafterwards, on the console's box: %s\n", followUp) return } fmt.Printf("\n%s now lives under %s. Everything it was copied from is UNTOUCHED —\n", *app, dst) fmt.Println("verify first (every channel pointer resolves qualified, one adopt pass is a") fmt.Println("no-op), and only then delete the old objects by hand.") fmt.Printf("\nThe console keeps its own app→account row: run this on the console's box to\ncomplete the move:\n\n %s\n", followUp) } // copyOp is one object the migration moves. pointer marks a channel pointer — // the only mutable object shape in the bucket, and so the only one the // migration is allowed to overwrite. type copyOp struct { from, to string pointer bool } // isChannelPointer reports whether a key under an app prefix is a channel // pointer rather than an immutable release object. func isChannelPointer(rel string) bool { return strings.HasPrefix(rel, "channels/") } // productionFlagRel is the production flag's key relative to an app prefix — // the ONE object this verb must not carry across. Its signed payload NAMES // the source account, so a byte copy fails validateProductionFlag at the // destination forever: the console shows an unreadable flag, and every box // fails toward baking, holding the app's beta and stable heads with no expiry // and nothing that reads like an error (verifyMigratedPointers checks channel // pointers only). It is re-declared at the destination instead — one POST on // the app page, or the same by hand. const productionFlagRel = "production.json" // planCopy adds one object to the migration plan, dropping the production // flag and recording that the source had one so the verb can say so. func planCopy(out []copyOp, hadProduction *bool, from, rel, dst string) []copyOp { if rel == productionFlagRel { *hadProduction = true return out } return append(out, copyOp{from, dst + rel, isChannelPointer(rel)}) } // putOverwrite writes body at key whatever is there now: read the etag, put // with it, one retry on a lost race (the CAS-guarded read-then-write the // fleet publisher and the console's manifest writer both use). Reports // whether the bytes actually changed, so the caller can say so. func putOverwrite(ctx context.Context, s release.Store, key string, body []byte) (changed bool, err error) { attempt := func() (bool, error) { etag := "" cur, cetag, gerr := s.Get(ctx, key) switch { case gerr == nil: if string(cur) == string(body) { return false, nil // already right; do not churn the etag } etag = cetag case !errors.Is(gerr, os.ErrNotExist): return false, gerr } if _, err := s.Put(ctx, key, body, etag); err != nil { return false, err } return true, nil } changed, err = attempt() if errors.Is(err, release.ErrCASConflict) { return attempt() } return changed, err } // migrationPlan works out which objects to copy, and from where. This is the // most dangerous decision the verb makes. // // Once this binary is adopted, every ship and promote writes to the // account-qualified keys — so an app that was half-migrated when the // accounts build landed has objects at BOTH prefixes, and its LEGACY pointer // went stale the first time anyone deployed. Copying the legacy prefix at // that point would move a stale pointer into the new account, lose every // post-adopt release, and let the next agent tick silently downgrade the app // to a pre-adopt build. // // So the default source is not a prefix but a PER-KEY PRECEDENCE: for each // relative key, the ops-qualified object wins, and legacy-only objects still // come across so no rollback target is lost. (That precedence outlived the // read-fallback it was modelled on — the runtime resolves qualified keys only // now, but the buckets this verb repairs are exactly the ones with objects // under both.) `--from` overrides it with a single explicit prefix, for moving // between two non-ops accounts. // // The production flag is the one object deliberately NOT copied — see // productionFlagRel. The third return says the source carried one, so the // verb can tell the operator to re-declare it. // // Returns the copies to make, the account the app is being moved FROM (for // the post-copy verification), and whether a production flag was skipped. func migrationPlan(ctx context.Context, store release.Store, app, from, to, dst string) ([]copyOp, string, bool) { // The account-less keys, named explicitly. Checked BEFORE ValidAccount: // acctRe is [a-z]{3,16}, which "legacy" satisfies, so the order is what // keeps the sentinel from being read as an account sqid. if from == release.LegacySource { src := release.LegacyAppPrefix(app) keys, err := store.List(ctx, src) if err != nil { log.Fatalf("accounts migrate: list %s: %v", src, err) } if len(keys) == 0 { log.Fatalf("accounts migrate: nothing under %s — %s has no legacy objects to move", src, app) } mutedf(os.Stdout, "source: %s (the legacy, account-less keys)\n", src) var out []copyOp hadProduction := false for _, k := range keys { rel := strings.TrimPrefix(k, src) out = planCopy(out, &hadProduction, k, rel, dst) } return out, release.LegacySource, hadProduction } if from != "" { if !release.ValidAccount(from) { log.Fatalf("accounts migrate: %q is not an account sqid", from) } src := release.AppPrefix(from, app) if src == dst { log.Fatalf("accounts migrate: %s is already under %s", app, to) } keys, err := store.List(ctx, src) if err != nil { log.Fatalf("accounts migrate: list %s: %v", src, err) } var out []copyOp hadProduction := false for _, k := range keys { rel := strings.TrimPrefix(k, src) out = planCopy(out, &hadProduction, k, rel, dst) } return out, from, hadProduction } if to == release.OpsAccount { log.Fatalf("accounts migrate: moving into ops needs an explicit --from — ops is also the default\n" + "SOURCE, and guessing which way you meant is not something this verb will do.\n" + "For an app still on the pre-accounts keys (apps//…), name them:\n\n" + " carlos accounts migrate --app --from legacy --to bab\n") } opsPrefix := release.AppPrefix(release.OpsAccount, app) legacyPrefix := release.LegacyAppPrefix(app) opsKeys, err := store.List(ctx, opsPrefix) if err != nil { log.Fatalf("accounts migrate: list %s: %v", opsPrefix, err) } legacyKeys, err := store.List(ctx, legacyPrefix) if err != nil { log.Fatalf("accounts migrate: list %s: %v", legacyPrefix, err) } if len(opsKeys) > 0 && len(legacyKeys) > 0 { fmt.Printf("%s has objects under BOTH %s and %s — this app was shipped to since the\n", app, opsPrefix, legacyPrefix) fmt.Printf("accounts build was adopted. Taking the qualified copy of every key that\n") fmt.Printf("exists in both (that is what the runtime reads), and the legacy copy of the\n") fmt.Printf("rest, so no rollback target is lost.\n") } else if len(opsKeys) > 0 { mutedf(os.Stdout, "source: %s\n", opsPrefix) } else if len(legacyKeys) > 0 { mutedf(os.Stdout, "source: %s\n", legacyPrefix) } // Qualified first, then legacy for anything it did not cover — the // per-key precedence described above. seen := map[string]bool{} var out []copyOp hadProduction := false for _, k := range opsKeys { rel := strings.TrimPrefix(k, opsPrefix) seen[rel] = true out = planCopy(out, &hadProduction, k, rel, dst) } for _, k := range legacyKeys { rel := strings.TrimPrefix(k, legacyPrefix) if seen[rel] { continue // the qualified copy is the live one } out = planCopy(out, &hadProduction, k, rel, dst) } return out, release.OpsAccount, hadProduction } // pointerRebinder returns how a channel pointer crosses from srcAcct to // dstAcct. A pointer signed before the account binding names no account and // travels as bytes, exactly as before. A pointer that names its account no // longer verifies anywhere else (discussion #220), so it is re-signed for the // destination with the root key — CARLOS_RELEASE_KEY, as for any promote. // The key is read only if such a pointer turns up, and a scoped key is // refused: moving a pointer between accounts is a root decision. func pointerRebinder(app, srcAcct, dstAcct string) func(dstKey string, body []byte) []byte { var root ed25519.PrivateKey prefix := release.ChannelsPrefix(dstAcct, app) return func(dstKey string, body []byte) []byte { acct, err := release.PointerAccount(body) if err != nil || acct == "" || acct == dstAcct { // Unparseable bytes are copied as they always were; the // verification pass is what judges them. return body } channel := strings.TrimSuffix(strings.TrimPrefix(dstKey, prefix), ".json") if root == nil { raw := os.Getenv("CARLOS_RELEASE_KEY") if strings.TrimSpace(raw) == "" || release.IsScopedSeed(raw) { log.Fatalf("accounts migrate: channel %s of %s is signed for account %s, so it has to be\n"+ "re-signed for %s, which needs the root release key in CARLOS_RELEASE_KEY.\n"+ "Nothing has been deleted; set it and re-run.", channel, app, acct, dstAcct) } root = signingKey() } out, err := release.RebindPointer(root, body, srcAcct, dstAcct, app, channel) if err != nil { log.Fatalf("accounts migrate: %v", err) } return out } } // verifyMigratedPointers re-reads every channel pointer after the copy and // refuses if the destination does not now serve exactly what the source // serves. Belt and braces over migrationPlan's precedence — and the one check // that catches a promote landing DURING the migration, which would otherwise // leave the new account one version behind with nothing to notice it. // // Byte comparison is the gate, so no signing key is needed. When // CARLOS_RELEASE_PUBKEY is set the versions are resolved and printed too, so // the operator sees what they just moved rather than a bare "ok". // // "What the source serves" is the source pointer as rebind carries it: a // pointer bound to the source account is re-signed for the destination // (pointerRebinder), so the bytes compared are the rebound ones. func verifyMigratedPointers(ctx context.Context, store release.Store, app, srcAcct, dstAcct string, kept []string, rebind func(dstKey string, body []byte) []byte) { prefix := release.ChannelsPrefix(dstAcct, app) keys, err := store.List(ctx, prefix) if err != nil { log.Fatalf("accounts migrate: verify: list %s: %v", prefix, err) } var pub ed25519.PublicKey if b := strings.TrimSpace(os.Getenv("CARLOS_RELEASE_PUBKEY")); b != "" { if p, err := release.ParsePublicKey(b, "CARLOS_RELEASE_PUBKEY"); err == nil { pub = p } } for _, key := range keys { // A pointer the legacy-source path deliberately KEPT (a newer // qualified pointer outranked the stale legacy copy) must not be // compared against the source it refused — it is exempt by // construction, and the exemption was already printed. if slices.Contains(kept, key) { channel := strings.TrimSuffix(strings.TrimPrefix(key, prefix), ".json") fmt.Printf("%s %s: kept the existing qualified pointer (legacy copy was stale)\n", colorFor(os.Stdout).ok("verified"), channel) continue } channel := strings.TrimSuffix(strings.TrimPrefix(key, prefix), ".json") got, _, err := store.Get(ctx, key) if err != nil { log.Fatalf("accounts migrate: verify %s: %v", key, err) } // What the OLD account still holds, by this verb's own per-key // precedence — the migration's explicit read of the legacy space, // which outlives the retired runtime fallback. A legacy source has no // qualified key at all, so both halves are the legacy one: passing the // sentinel to ChannelKey would build apps/legacy/… and quietly depend // on that never existing. srcKey := release.ChannelKey(srcAcct, app, channel) if srcAcct == release.LegacySource { srcKey = release.LegacyChannelKey(app, channel) } want, _, _, err := release.GetMigrationSource(ctx, store, srcKey, release.LegacyChannelKey(app, channel)) if err != nil { log.Fatalf("accounts migrate: verify: re-read %s/%s at the source: %v", app, channel, err) } want = rebind(key, want) if string(got) != string(want) { // A write landed between the copy and this read — a promote // racing the migration. CONVERGE rather than refuse: the pointer // is this migration's own object, and telling an operator to // "re-run" while leaving a destination pointer that the create-only // copy would then reject is an instruction with no exit. log.Printf("accounts migrate: %s/%s moved during the migration — re-copying", app, channel) if _, err := putOverwrite(ctx, store, key, want); err != nil { log.Fatalf("accounts migrate: converge %s: %v", key, err) } got, _, err = store.Get(ctx, key) if err != nil { log.Fatalf("accounts migrate: verify %s: %v", key, err) } if string(got) != string(want) { // Twice in a row means something is actively writing. Name the // exact recovery rather than a vague "try again". log.Fatalf("accounts migrate: VERIFICATION FAILED for channel %s: %s still does not serve\n"+ "what %s serves after a re-copy, so something is writing to this app right now.\n"+ "Nothing has been deleted. Stop the writer, then either re-run this command or\n"+ "delete this one key and re-run:\n\n %s\n", channel, dstAcct, srcAcct, key) } } if pub != nil { p, err := release.Channel(ctx, store, pub, dstAcct, app, channel) if err != nil { log.Fatalf("accounts migrate: verify: %s/%s does not resolve under %s: %v", app, channel, dstAcct, err) } fmt.Printf("%s %s: %s serves %s under %s\n", colorFor(os.Stdout).ok("verified"), channel, app, p.Version, dstAcct) continue } fmt.Printf("%s %s: byte-identical to what %s serves (set CARLOS_RELEASE_PUBKEY to see versions)\n", colorFor(os.Stdout).ok("verified"), channel, srcAcct) } } // pickAccount is resolveAccount's core, symmetric over the two identity // spaces: want matches an account by sqid OR by name (case-insensitively), // with NO precedence between them. Precedence is how a script's --account // silently changes target the day a colliding account materializes; a // conflict here is loud instead, and names --account-id, the reference that // never guesses. A sqid-shaped want that matches nothing passes through (a // fresh box may not have reconciled; CI has no registry at all). func pickAccount(accts []registry.Account, want string) (string, error) { var candidates []registry.Account for _, a := range accts { if a.Sqid == want || strings.EqualFold(a.Name, want) { candidates = append(candidates, a) } } switch len(candidates) { case 1: return candidates[0].Sqid, nil case 0: // A sqid this registry hasn't materialized yet is still usable: the // bucket is the control plane, and a fresh box may simply not have // reconciled. A NAME, though, can only ever be resolved here. if release.ValidAccount(want) { return want, nil } return "", fmt.Errorf("no account %q here — `carlos accounts list` shows what this box knows", want) default: alts := make([]string, len(candidates)) for i, a := range candidates { alts[i] = fmt.Sprintf("%s (%q)", a.Sqid, a.Name) } return "", fmt.Errorf("account %q is ambiguous — pass --account-id with one of: %s", want, strings.Join(alts, ", ")) } } // checkAccountID is the ONE gate --account-id passes through, shared by // resolveAccount (direct-bucket mode) and apiAccount (API mode) so the two // modes can never disagree about what a sqid is. It returns the trimmed id, // or "" when the flag was not given. // // The check is domains.ValidSqid, NOT release.ValidAccount. ValidAccount // accepts any lowercase word because it gates bucket path segments, and // every account NAME is one — so it accepts "ops", which the frozen encoder // (no i, l, o) can never emit for any id. Waved through here, `--account-id // ops` would have shipped to apps/ops/..., exited 0, and served nothing: the // real ops account is "bab". A name in --account-id is a mistake with no // honest reading, so it is refused by shape before anything else happens. func checkAccountID(wantID string) string { wantID = strings.TrimSpace(wantID) if wantID == "" || domains.ValidSqid(wantID) { return wantID } // "ops" is THE trap: it is the default --account value, the one account // name every operator types, and it is name-shaped in a way that reads // exactly like a short sqid. Name its actual sqid rather than making // someone go look it up. if wantID == "ops" { log.Fatalf(`--account-id takes a sqid; "ops" is a name — use --account ops, or --account-id %s`, release.OpsAccount) } log.Fatalf("--account-id %q is not a sqid (sqids use only the letters %s — no i, l, o) — "+ "it looks like a NAME; names go in --account", wantID, domains.SqidAlphabet) return "" } // resolveAccount turns a --account/--account-id pair into an account sqid. // --account-id supersedes --account outright: it is a sqid, matched as a sqid // only, never consulted against the registry at all — the reference that // cannot be shadowed by a name. Otherwise --account resolves through // pickAccount, symmetrically over sqids and names, and an ambiguity is fatal // rather than guessed: picking the wrong account here would ship to the // wrong tenant. func resolveAccount(regPath, want, wantID string) string { if wantID = checkAccountID(wantID); wantID != "" { return wantID } want = strings.TrimSpace(want) if want == "" || want == "ops" { return release.OpsAccount } // The release verbs run from CI as often as from a box, and a build // machine has no registry at all. Not having one is only fatal if a NAME // actually needs resolving: "ops" is answered above without opening // anything, and a sqid is already the answer. r, err := registry.Open(regPath) if err != nil { if release.ValidAccount(want) { return want } log.Fatalf("cannot resolve account %q: no registry at %s (%v) — pass the sqid instead of the name", want, regPath, err) } defer r.Close() accts, err := r.Accounts() if err != nil { log.Fatal(err) } sqid, err := pickAccount(accts, want) if err != nil { log.Fatal(err) } return sqid }