| 1 | // The `carlos accounts` verb: create, list, and migrate accounts from the |
| 2 | // command line — for CLI-only deployments (the console does its own minting) |
| 3 | // and for the one deliberate data move the multi-account rollout needs. |
| 4 | // |
| 5 | // The migration is a VERB, not a runbook: `carlos accounts migrate --app kass |
| 6 | // --to <sqid>` copies an app's bucket objects to the account-qualified prefix |
| 7 | // and re-stamps its routes. It NEVER deletes a legacy object — deletion is a |
| 8 | // separate operator step, behind the verification gate (every channel pointer |
| 9 | // resolves qualified with the fallback disabled, and one adopt pass is a |
| 10 | // no-op). See docs/superpowers/specs/2026-08-02-multi-account-design.md §5. |
| 11 | package main |
| 12 | |
| 13 | import ( |
| 14 | "context" |
| 15 | "crypto/ed25519" |
| 16 | "errors" |
| 17 | "flag" |
| 18 | "fmt" |
| 19 | "io" |
| 20 | "log" |
| 21 | "net/http" |
| 22 | "os" |
| 23 | "slices" |
| 24 | "strings" |
| 25 | "time" |
| 26 | |
| 27 | "github.com/carlosframework/platform/internal/domains" |
| 28 | "github.com/carlosframework/platform/internal/registry" |
| 29 | "github.com/carlosframework/platform/internal/release" |
| 30 | ) |
| 31 | |
| 32 | const accountsUsage = `usage: carlos accounts <create|list|migrate|plan> |
| 33 | |
| 34 | create --name <name> mint an account: the next free id, its |
| 35 | frozen sqid, and the bucket manifest every |
| 36 | box materializes identity from |
| 37 | [--owner <address>] who owns it. On a box or in CI — no browser, |
| 38 | no login — this is the path: it needs no |
| 39 | identity of its own and takes precedence |
| 40 | over any logged-in one. |
| 41 | [--console <url>] which logged-in console to mint through |
| 42 | (only needed with several) |
| 43 | list your accounts. Signed-in (the usual case): |
| 44 | name, sqid and your role, from the console. |
| 45 | On a box with a registry: sqid, name and |
| 46 | app count from the registry itself. |
| 47 | [--console <url>] list through a console even where a |
| 48 | registry exists (or: which logged-in |
| 49 | console, with several) |
| 50 | migrate --app <a> --to <sqid> copy an app's objects to the account- |
| 51 | qualified prefix and re-stamp its routes |
| 52 | [--from <sqid>] source account (default: the legacy, |
| 53 | account-less keys) |
| 54 | [--dry-run] print the plan and touch nothing |
| 55 | plan --account <sqid> show or change an account's plan (deployment operators) |
| 56 | [--set <plan>] put the account on this plan. needs --reason |
| 57 | [--pack <name=quantity>] add a pack. repeat it for more than one |
| 58 | [--sponsored <file.json>] limits for a sponsored account, used where |
| 59 | they are more generous than the plan's |
| 60 | [--reason <text>] why the plan is changing |
| 61 | [--console <url>] which logged-in console to use |
| 62 | |
| 63 | plan only works where the console has a plan catalog (CARLOS_PLAN_CATALOG). |
| 64 | |
| 65 | create always names an owner, one way or the other: log this terminal in with |
| 66 | "carlos auth login" and you become the owner, or pass --owner. It refuses |
| 67 | rather than mint an account nobody can open in the console. |
| 68 | |
| 69 | migrate never deletes a legacy object. Deleting them is a separate operator |
| 70 | step, behind the verification gate: every channel pointer must resolve from |
| 71 | the qualified keys with the fallback disabled, and one adopt pass must be a |
| 72 | no-op, first. |
| 73 | ` |
| 74 | |
| 75 | // RunAccounts dispatches the accounts sub-verbs. |
| 76 | func RunAccounts(args []string) { |
| 77 | if len(args) == 0 { |
| 78 | fmt.Fprint(os.Stderr, accountsUsage) |
| 79 | os.Exit(1) |
| 80 | } |
| 81 | switch args[0] { |
| 82 | case "create": |
| 83 | runAccountsCreate(args[1:]) |
| 84 | case "list": |
| 85 | runAccountsList(args[1:]) |
| 86 | case "migrate": |
| 87 | runAccountsMigrate(args[1:]) |
| 88 | case "plan": |
| 89 | runAccountsPlan(args[1:]) |
| 90 | default: |
| 91 | fmt.Fprint(os.Stderr, accountsUsage) |
| 92 | os.Exit(1) |
| 93 | } |
| 94 | } |
| 95 | |
| 96 | func runAccountsCreate(args []string) { |
| 97 | fs := flag.NewFlagSet("accounts create", flag.ExitOnError) |
| 98 | regPath := fs.String("registry", defaultRegistry(), "registry database") |
| 99 | name := fs.String("name", "", "account name (a-z, 0-9, hyphen; need not be unique)") |
| 100 | owner := fs.String("owner", "", "keymail address that owns the account — the box-side/headless path, "+ |
| 101 | "and what to use when this terminal is not logged in") |
| 102 | console := consoleOption(fs, "which logged-in console to create it through (only needed with several)") |
| 103 | fs.Usage = flagUsage(fs, "") |
| 104 | fs.Parse(args) |
| 105 | *name = strings.TrimSpace(*name) |
| 106 | if *name == "" { |
| 107 | log.Fatal("usage: carlos accounts create --name <name> [--owner <address>]") |
| 108 | } |
| 109 | if !domains.ValidAccountName(*name) { |
| 110 | log.Fatalf("accounts create: %q is not a usable account name — 1-63 characters of a-z, 0-9\n"+ |
| 111 | "and hyphen, no leading or trailing hyphen. The name appears in console URLs and\n"+ |
| 112 | "resolves --account.", *name) |
| 113 | } |
| 114 | |
| 115 | // An ownerless account is the failure this whole path exists to prevent: |
| 116 | // it materializes into the console with no members, which means nobody can |
| 117 | // open it, invite anyone to it, or see the apps migrated into it. So there |
| 118 | // are exactly two ways to name an owner and no way to skip it. |
| 119 | // |
| 120 | // --owner WINS over the logged-in identity, deliberately: it is the explicit |
| 121 | // instruction, it is the documented headless path, and an operator creating |
| 122 | // an account FOR somebody else must not have their own address quietly |
| 123 | // substituted. |
| 124 | if addr := strings.TrimSpace(*owner); addr != "" { |
| 125 | createAccountInBucket(*regPath, *name, addr) |
| 126 | return |
| 127 | } |
| 128 | cred, credErr := loggedInCredential(*console) |
| 129 | if credErr != nil { |
| 130 | log.Fatalf("accounts create: this account would have no owner, and an ownerless account is one\n"+ |
| 131 | "nobody can open in the console. Name one, either way:\n\n"+ |
| 132 | " carlos auth login --console <url> log this terminal in; you become the owner\n"+ |
| 133 | " carlos accounts create --owner <address> --name %q\n"+ |
| 134 | " name the owner outright — the box-side and\n"+ |
| 135 | " headless path, no browser or login needed\n\n"+ |
| 136 | "(%v)", *name, credErr) |
| 137 | } |
| 138 | createAccountViaConsole(cred, *regPath, *name) |
| 139 | } |
| 140 | |
| 141 | // createAccountInBucket is the original path, plus an owner: mint the id with |
| 142 | // a create-only manifest write, then materialize the registry row. |
| 143 | // |
| 144 | // The bucket first, and not just for ordering: the manifest write IS the |
| 145 | // allocator. domains.MintAccount picks the next free integer and claims it |
| 146 | // with a create-only Put, so this CLI and a console minting at the same moment |
| 147 | // cannot land on the same id — and the registry, which only ever materializes |
| 148 | // what the bucket says, never has to assign anything. The owner rides along in |
| 149 | // that same manifest, which is how the console learns who to admit. |
| 150 | func createAccountInBucket(regPath, name, owner string) { |
| 151 | // Normalize before anything is written OR printed. MintAccount normalizes |
| 152 | // what it stores, so printing the raw input would tell the operator their |
| 153 | // membership is "Paul@Keymail.dev" when the row — and the session that must |
| 154 | // match it — is lowercase. That mismatch is exactly the confusion the |
| 155 | // `console member` verb was fixed for. |
| 156 | owner = domains.NormalizeAddress(owner) |
| 157 | store := openStore() |
| 158 | id, sqid, err := domains.MintAccount(context.Background(), store, name, owner, time.Now()) |
| 159 | if err != nil { |
| 160 | log.Fatalf("accounts create: %v", err) |
| 161 | } |
| 162 | r, err := registry.Open(regPath) |
| 163 | if err != nil { |
| 164 | log.Fatal(err) |
| 165 | } |
| 166 | defer r.Close() |
| 167 | if err := r.PutAccount(id, sqid, name); err != nil { |
| 168 | log.Fatalf("accounts create: materialize: %v", err) |
| 169 | } |
| 170 | fmt.Printf("%s %q\n id: %d\n sqid: %s\n owner: %s\n", |
| 171 | colorFor(os.Stdout).ok("created account"), name, id, sqid, owner) |
| 172 | mutedf(os.Stdout, "use it with: carlos ship --account %s --app <app> ...\n", sqid) |
| 173 | fmt.Printf("%s becomes its owner in the console the next time the console materializes\n"+ |
| 174 | "accounts from the bucket (on start, or on any `console member` command).\n", owner) |
| 175 | } |
| 176 | |
| 177 | // createAccountViaConsole mints through the console this terminal is logged in |
| 178 | // to, which makes the logged-in human the owner in one step. |
| 179 | // |
| 180 | // Through the console rather than straight into the bucket because the console |
| 181 | // is the identity authority: it writes the manifest AND the owner membership in |
| 182 | // its own database in one path, so the account is openable by its creator the |
| 183 | // instant it exists — rather than "once something materializes it", which is the |
| 184 | // gap that produced accounts nobody could reach. |
| 185 | func createAccountViaConsole(cred *credential, regPath, name string) { |
| 186 | if warn := credentialPermWarning(); warn != "" { |
| 187 | warnf(os.Stderr, "%s", warn) |
| 188 | } |
| 189 | c := newConsoleClient(cred.Console, cred.Token) |
| 190 | var out struct { |
| 191 | ID int64 `json:"id"` |
| 192 | Sqid string `json:"sqid"` |
| 193 | Name string `json:"name"` |
| 194 | Owner string `json:"owner"` |
| 195 | URL string `json:"url"` |
| 196 | } |
| 197 | if _, err := c.do(context.Background(), http.MethodPost, "/auth/cli/accounts", |
| 198 | map[string]string{"name": name}, &out); err != nil { |
| 199 | log.Fatalf("accounts create: %s: %v", cred.Console, err) |
| 200 | } |
| 201 | pal := colorFor(os.Stdout) |
| 202 | fmt.Printf("%s %q\n id: %d\n sqid: %s\n owner: %s (you)\n", |
| 203 | pal.ok("created account"), out.Name, out.ID, out.Sqid, out.Owner) |
| 204 | fmt.Printf(" open: %s\n", pal.link(out.URL)) |
| 205 | mutedf(os.Stdout, "use it with: carlos ship --account %s --app <app> ...\n", out.Sqid) |
| 206 | // The registry row is this box's local materialization of what the bucket |
| 207 | // already says. Best-effort, unlike the bucket-mint path: the account |
| 208 | // exists and is owned no matter what happens here, and a build machine |
| 209 | // with no registry at all is a perfectly normal place to run this. |
| 210 | r, err := registry.Open(regPath) |
| 211 | if err != nil { |
| 212 | mutedf(os.Stdout, "\n(no registry at %s, so nothing was materialized locally: %v —\n"+ |
| 213 | "harmless: the bucket is the control plane and every box reconciles from it.)", regPath, err) |
| 214 | return |
| 215 | } |
| 216 | defer r.Close() |
| 217 | if err := r.PutAccount(out.ID, out.Sqid, out.Name); err != nil { |
| 218 | mutedf(os.Stdout, "\n(could not materialize the registry row here: %v — harmless: the account\n"+ |
| 219 | "exists in the bucket and every box reconciles from it.)", err) |
| 220 | } |
| 221 | } |
| 222 | |
| 223 | func runAccountsList(args []string) { |
| 224 | fs := flag.NewFlagSet("accounts list", flag.ExitOnError) |
| 225 | regPath := fs.String("registry", defaultRegistry(), "registry database (box mode)") |
| 226 | consoleFlag := consoleOption(fs, "list through a console instead (default: the box registry when one exists, else your logged-in console)") |
| 227 | fs.Usage = flagUsage(fs, "") |
| 228 | fs.Parse(args) |
| 229 | // Mode split by what exists, not by resolveMode: this verb predates the |
| 230 | // console API and must keep working on a box that has a registry and no |
| 231 | // bucket env, no credentials — while a laptop with neither registry nor |
| 232 | // box access (the #157 case) asks its console. An explicit --console |
| 233 | // skips the registry even where one exists; an explicit --registry is a |
| 234 | // commitment, not a hint — a typo'd path or an EACCES must surface as |
| 235 | // registry.Open's own error, never as a silent hop to console mode |
| 236 | // answering "not logged in" to someone who asked about a database. |
| 237 | explicitRegistry := false |
| 238 | fs.Visit(func(f *flag.Flag) { |
| 239 | if f.Name == "registry" { |
| 240 | explicitRegistry = true |
| 241 | } |
| 242 | }) |
| 243 | if *consoleFlag == "" { |
| 244 | if explicitRegistry { |
| 245 | runAccountsListRegistry(*regPath) |
| 246 | return |
| 247 | } |
| 248 | if _, err := os.Stat(*regPath); err == nil { |
| 249 | runAccountsListRegistry(*regPath) |
| 250 | return |
| 251 | } |
| 252 | } |
| 253 | if err := accountsListAPI(os.Stdout, *consoleFlag); err != nil { |
| 254 | log.Fatal(err) |
| 255 | } |
| 256 | } |
| 257 | |
| 258 | // accountsListAPI is the signed-in form: the caller's own memberships from |
| 259 | // GET /api/cli/apps, as a NAME/SQID/ROLE table. The one 404 this path can |
| 260 | // see is a console too old to have the endpoint (no account/app segment |
| 261 | // here for the membership gate to refuse), so it gets a named refusal |
| 262 | // instead of notFoundErr's check-your-flags riddle. |
| 263 | func accountsListAPI(out io.Writer, consoleFlag string) error { |
| 264 | cred, err := loggedInCredential(consoleFlag) |
| 265 | if err != nil { |
| 266 | return err |
| 267 | } |
| 268 | var acc struct { |
| 269 | Accounts []apiAccountEntry `json:"accounts"` |
| 270 | } |
| 271 | status, err := newConsoleClient(cred.Console, cred.Token). |
| 272 | do(context.Background(), http.MethodGet, "/api/cli/apps", nil, &acc) |
| 273 | if status == http.StatusNotFound { |
| 274 | return fmt.Errorf("%s predates account listing — upgrade the console, or run this on a box with --registry", cred.Console) |
| 275 | } |
| 276 | if err != nil { |
| 277 | return err |
| 278 | } |
| 279 | w := newTable(out) |
| 280 | fmt.Fprintln(w, "NAME\tSQID\tROLE") |
| 281 | for _, a := range acc.Accounts { |
| 282 | fmt.Fprintf(w, "%s\t%s\t%s\n", a.Name, a.Sqid, a.Role) |
| 283 | } |
| 284 | w.Flush() |
| 285 | return nil |
| 286 | } |
| 287 | |
| 288 | func runAccountsListRegistry(regPath string) { |
| 289 | r, err := registry.Open(regPath) |
| 290 | if err != nil { |
| 291 | log.Fatal(err) |
| 292 | } |
| 293 | defer r.Close() |
| 294 | accts, err := r.Accounts() |
| 295 | if err != nil { |
| 296 | log.Fatal(err) |
| 297 | } |
| 298 | routes, err := r.List() |
| 299 | if err != nil { |
| 300 | log.Fatal(err) |
| 301 | } |
| 302 | // App count from the ROUTES, not the bucket: this verb must work on a box |
| 303 | // with no deployment store configured at all. |
| 304 | apps := map[string]map[string]bool{} |
| 305 | for _, rt := range routes { |
| 306 | if rt.App == "" { |
| 307 | continue |
| 308 | } |
| 309 | acct := release.RouteAccount(rt) |
| 310 | if apps[acct] == nil { |
| 311 | apps[acct] = map[string]bool{} |
| 312 | } |
| 313 | apps[acct][rt.App] = true |
| 314 | } |
| 315 | w := newTable(os.Stdout) |
| 316 | fmt.Fprintln(w, "SQID\tNAME\tAPPS\tID") |
| 317 | for _, a := range accts { |
| 318 | fmt.Fprintf(w, "%s\t%s\t%d\t%d\n", a.Sqid, a.Name, len(apps[a.Sqid]), a.ID) |
| 319 | } |
| 320 | w.Flush() |
| 321 | } |
| 322 | |
| 323 | func runAccountsMigrate(args []string) { |
| 324 | fs := flag.NewFlagSet("accounts migrate", flag.ExitOnError) |
| 325 | regPath := fs.String("registry", defaultRegistry(), "registry database") |
| 326 | app := appFlag(fs, "app to move") |
| 327 | to := fs.String("to", "", "destination account sqid") |
| 328 | from := fs.String("from", "", "source account sqid, or \"legacy\" for the pre-accounts keys (default: ops, then legacy)") |
| 329 | dryRun := fs.Bool("dry-run", false, "print the plan and touch nothing") |
| 330 | fs.Usage = flagUsage(fs, "") |
| 331 | fs.Parse(args) |
| 332 | if *app == "" || *to == "" { |
| 333 | log.Fatal("usage: carlos accounts migrate --app <a> --to <sqid> [--from <sqid>] [--dry-run]") |
| 334 | } |
| 335 | if *to == release.LegacySource { |
| 336 | 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) |
| 337 | } |
| 338 | if !release.ValidAccount(*to) { |
| 339 | log.Fatalf("accounts migrate: %q is not an account sqid", *to) |
| 340 | } |
| 341 | r, err := registry.Open(*regPath) |
| 342 | if err != nil { |
| 343 | log.Fatal(err) |
| 344 | } |
| 345 | defer r.Close() |
| 346 | // The destination must be MATERIALIZED first: moving an app into an |
| 347 | // account the bucket has never described would put it at a prefix nothing |
| 348 | // can resolve an identity for. |
| 349 | if id, _, err := r.AccountBySqid(*to); err != nil { |
| 350 | log.Fatal(err) |
| 351 | } else if id == 0 { |
| 352 | log.Fatalf("accounts migrate: no account %s is materialized here — run `carlos accounts create` (or reconcile) first", *to) |
| 353 | } |
| 354 | |
| 355 | dst := release.AppPrefix(*to, *app) |
| 356 | ctx := context.Background() |
| 357 | store := openStore() |
| 358 | |
| 359 | // Where the app's objects are RIGHT NOW. This is the whole hazard of the |
| 360 | // verb, so it is resolved the way the runtime resolves them and not by |
| 361 | // assuming a prefix. |
| 362 | plan, srcAcct, hadProduction := migrationPlan(ctx, store, *app, *from, *to, dst) |
| 363 | if err := validateMigrationRecords(ctx, store, plan); err != nil { |
| 364 | log.Fatal(err) |
| 365 | } |
| 366 | if len(plan) == 0 { |
| 367 | mutedf(os.Stdout, "nothing under %s — nothing to copy\n", release.AppPrefix(srcAcct, *app)) |
| 368 | } |
| 369 | rebind := pointerRebinder(*app, srcAcct, *to) |
| 370 | // Pointers the legacy-source path refused to overwrite because a newer |
| 371 | // qualified pointer already exists; verification must not compare these |
| 372 | // against the legacy copy they deliberately did not take. |
| 373 | var keptPointers []string |
| 374 | for _, c := range plan { |
| 375 | if *dryRun { |
| 376 | warnf(os.Stdout, "would copy %s -> %s\n", c.from, c.to) |
| 377 | continue |
| 378 | } |
| 379 | body, _, err := store.Get(ctx, c.from) |
| 380 | if err != nil { |
| 381 | log.Fatalf("accounts migrate: read %s: %v", c.from, err) |
| 382 | } |
| 383 | if c.pointer { |
| 384 | body = rebind(c.to, body) |
| 385 | } |
| 386 | // Channel pointers CONVERGE; everything else is immutable and is |
| 387 | // claimed create-only. The split matters: a release manifest or |
| 388 | // artifact at an existing path must never change meaning, so |
| 389 | // different bytes there is a hard refusal. A channel pointer is the |
| 390 | // one mutable object in the bucket — it moves on every promote — so a |
| 391 | // destination pointer that disagrees is this migration's own |
| 392 | // leftover, and the verb's whole job is to make the destination serve |
| 393 | // what the source serves. Refusing there would strand the operator: |
| 394 | // the pointer exists, so a re-run would hit exactly this branch |
| 395 | // again, with no way forward that did not involve hand-deleting |
| 396 | // objects mid-migration. |
| 397 | if c.pointer { |
| 398 | // EXCEPT when the source is the legacy key space. There, the |
| 399 | // converge rationale inverts: an ordinary migration's destination |
| 400 | // pointer can only be this migration's own leftover, but a |
| 401 | // QUALIFIED pointer that already exists alongside legacy keys is |
| 402 | // live state — promotes have been landing on it — and the legacy |
| 403 | // copy is the stale one. Converging would move production |
| 404 | // BACKWARDS, which is precisely what happened to the console on |
| 405 | // 2026-08-03 (qualified canary/console at 3c7c7bc overwritten by a |
| 406 | // day-old legacy 771c2f0; caught by the next adopt pass's |
| 407 | // "updated" line reading like a rollback). Keep the qualified |
| 408 | // pointer, say so, and count it — the operator is told the legacy |
| 409 | // pointer was NOT taken rather than left to find out from adopt. |
| 410 | if srcAcct == release.LegacySource { |
| 411 | if cur, _, err := store.Get(ctx, c.to); err == nil && string(cur) != string(body) { |
| 412 | fmt.Printf("%s %s — a qualified pointer already exists and differs from the legacy\n"+ |
| 413 | "copy; the qualified one is live state and wins. The legacy pointer was not taken.\n", |
| 414 | colorFor(os.Stdout).warn("KEPT"), c.to) |
| 415 | keptPointers = append(keptPointers, c.to) |
| 416 | continue |
| 417 | } |
| 418 | } |
| 419 | switch changed, err := putOverwrite(ctx, store, c.to, body); { |
| 420 | case err != nil: |
| 421 | log.Fatalf("accounts migrate: write %s: %v", c.to, err) |
| 422 | case changed: |
| 423 | fmt.Printf("%s %s -> %s (the destination pointer disagreed)\n", |
| 424 | colorFor(os.Stdout).ok("converged"), c.from, c.to) |
| 425 | default: |
| 426 | mutedf(os.Stdout, "already copied %s -> %s\n", c.from, c.to) |
| 427 | } |
| 428 | continue |
| 429 | } |
| 430 | if _, err := putMigrationObject(ctx, store, c.to, body); err != nil { |
| 431 | cur, _, gerr := store.Get(ctx, c.to) |
| 432 | if gerr != nil { |
| 433 | log.Fatalf("accounts migrate: write %s: %v", c.to, err) |
| 434 | } |
| 435 | if string(cur) != string(body) { |
| 436 | log.Fatalf("accounts migrate: %s already exists with different content — refusing to\n"+ |
| 437 | "overwrite an immutable release object. Nothing has been deleted; work out which\n"+ |
| 438 | "of the two is the real %s before going further.", c.to, *app) |
| 439 | } |
| 440 | mutedf(os.Stdout, "already copied %s -> %s\n", c.from, c.to) |
| 441 | continue |
| 442 | } |
| 443 | fmt.Printf("%s %s -> %s\n", colorFor(os.Stdout).ok("copied"), c.from, c.to) |
| 444 | } |
| 445 | // The one object that does not travel. Loud, because nothing downstream |
| 446 | // will notice: a destination with no flag simply defaults to "not |
| 447 | // production", so the app stops baking silently rather than failing. |
| 448 | if hadProduction { |
| 449 | fmt.Printf("\n%s\n", colorFor(os.Stdout).warn("NOT COPIED:")) |
| 450 | fmt.Printf(" %s: carried a production declaration — re-declare it at the destination "+ |
| 451 | "(the flag is signed per-account and cannot be copied)\n", *app) |
| 452 | } |
| 453 | if !*dryRun { |
| 454 | verifyMigratedPointers(ctx, store, *app, srcAcct, *to, keptPointers, rebind) |
| 455 | } |
| 456 | |
| 457 | // The routes' account column: what adopt and the edge build keys from. |
| 458 | routes, err := r.List() |
| 459 | if err != nil { |
| 460 | log.Fatal(err) |
| 461 | } |
| 462 | for _, rt := range routes { |
| 463 | if rt.App != *app || rt.Account == *to { |
| 464 | continue |
| 465 | } |
| 466 | if *dryRun { |
| 467 | warnf(os.Stdout, "would stamp account %s on route %s (was %q)\n", *to, rt.Host, rt.Account) |
| 468 | continue |
| 469 | } |
| 470 | if err := r.SetAccount(rt.Host, *to); err != nil { |
| 471 | log.Fatalf("accounts migrate: stamp %s: %v", rt.Host, err) |
| 472 | } |
| 473 | fmt.Printf("%s account %s on route %s\n", colorFor(os.Stdout).ok("stamped"), *to, rt.Host) |
| 474 | } |
| 475 | |
| 476 | // The console keeps its OWN app→account row, and this binary deliberately |
| 477 | // does not open the console's database — console state is the console's |
| 478 | // authority, exactly like grants. So the move is not finished until the |
| 479 | // operator runs the box-side verb, and the exact command goes in the |
| 480 | // output rather than in a runbook somebody has to find. |
| 481 | followUp := fmt.Sprintf("console migrate-app --app %s --to %s", *app, *to) |
| 482 | if *dryRun { |
| 483 | warnf(os.Stdout, "dry run: nothing was written") |
| 484 | fmt.Printf("\nafterwards, on the console's box: %s\n", followUp) |
| 485 | return |
| 486 | } |
| 487 | fmt.Printf("\n%s now lives under %s. Everything it was copied from is UNTOUCHED —\n", *app, dst) |
| 488 | fmt.Println("verify first (every channel pointer resolves qualified, one adopt pass is a") |
| 489 | fmt.Println("no-op), and only then delete the old objects by hand.") |
| 490 | 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) |
| 491 | } |
| 492 | |
| 493 | // copyOp is one object the migration moves. pointer marks a channel pointer — |
| 494 | // the only mutable object shape in the bucket, and so the only one the |
| 495 | // migration is allowed to overwrite. |
| 496 | type copyOp struct { |
| 497 | from, to string |
| 498 | pointer bool |
| 499 | } |
| 500 | |
| 501 | // isChannelPointer reports whether a key under an app prefix is a channel |
| 502 | // pointer rather than an immutable release object. |
| 503 | func isChannelPointer(rel string) bool { return strings.HasPrefix(rel, "channels/") } |
| 504 | |
| 505 | // productionFlagRel is the production flag's key relative to an app prefix — |
| 506 | // the ONE object this verb must not carry across. Its signed payload NAMES |
| 507 | // the source account, so a byte copy fails validateProductionFlag at the |
| 508 | // destination forever: the console shows an unreadable flag, and every box |
| 509 | // fails toward baking, holding the app's beta and stable heads with no expiry |
| 510 | // and nothing that reads like an error (verifyMigratedPointers checks channel |
| 511 | // pointers only). It is re-declared at the destination instead — one POST on |
| 512 | // the app page, or the same by hand. |
| 513 | const productionFlagRel = "production.json" |
| 514 | |
| 515 | // planCopy adds one object to the migration plan, dropping the production |
| 516 | // flag and recording that the source had one so the verb can say so. |
| 517 | func planCopy(out []copyOp, hadProduction *bool, from, rel, dst string) []copyOp { |
| 518 | if rel == productionFlagRel { |
| 519 | *hadProduction = true |
| 520 | return out |
| 521 | } |
| 522 | return append(out, copyOp{from, dst + rel, isChannelPointer(rel)}) |
| 523 | } |
| 524 | |
| 525 | // putOverwrite writes body at key whatever is there now: read the etag, put |
| 526 | // with it, one retry on a lost race (the CAS-guarded read-then-write the |
| 527 | // fleet publisher and the console's manifest writer both use). Reports |
| 528 | // whether the bytes actually changed, so the caller can say so. |
| 529 | func putOverwrite(ctx context.Context, s release.Store, key string, body []byte) (changed bool, err error) { |
| 530 | attempt := func() (bool, error) { |
| 531 | etag := "" |
| 532 | cur, cetag, gerr := s.Get(ctx, key) |
| 533 | switch { |
| 534 | case gerr == nil: |
| 535 | if string(cur) == string(body) { |
| 536 | return false, nil // already right; do not churn the etag |
| 537 | } |
| 538 | etag = cetag |
| 539 | case !errors.Is(gerr, os.ErrNotExist): |
| 540 | return false, gerr |
| 541 | } |
| 542 | if _, err := s.Put(ctx, key, body, etag); err != nil { |
| 543 | return false, err |
| 544 | } |
| 545 | return true, nil |
| 546 | } |
| 547 | changed, err = attempt() |
| 548 | if errors.Is(err, release.ErrCASConflict) { |
| 549 | return attempt() |
| 550 | } |
| 551 | return changed, err |
| 552 | } |
| 553 | |
| 554 | // migrationPlan works out which objects to copy, and from where. This is the |
| 555 | // most dangerous decision the verb makes. |
| 556 | // |
| 557 | // Once this binary is adopted, every ship and promote writes to the |
| 558 | // account-qualified keys — so an app that was half-migrated when the |
| 559 | // accounts build landed has objects at BOTH prefixes, and its LEGACY pointer |
| 560 | // went stale the first time anyone deployed. Copying the legacy prefix at |
| 561 | // that point would move a stale pointer into the new account, lose every |
| 562 | // post-adopt release, and let the next agent tick silently downgrade the app |
| 563 | // to a pre-adopt build. |
| 564 | // |
| 565 | // So the default source is not a prefix but a PER-KEY PRECEDENCE: for each |
| 566 | // relative key, the ops-qualified object wins, and legacy-only objects still |
| 567 | // come across so no rollback target is lost. (That precedence outlived the |
| 568 | // read-fallback it was modelled on — the runtime resolves qualified keys only |
| 569 | // now, but the buckets this verb repairs are exactly the ones with objects |
| 570 | // under both.) `--from` overrides it with a single explicit prefix, for moving |
| 571 | // between two non-ops accounts. |
| 572 | // |
| 573 | // The production flag is the one object deliberately NOT copied — see |
| 574 | // productionFlagRel. The third return says the source carried one, so the |
| 575 | // verb can tell the operator to re-declare it. |
| 576 | // |
| 577 | // Returns the copies to make, the account the app is being moved FROM (for |
| 578 | // the post-copy verification), and whether a production flag was skipped. |
| 579 | func migrationPlan(ctx context.Context, store release.Store, app, from, to, dst string) ([]copyOp, string, bool) { |
| 580 | // The account-less keys, named explicitly. Checked BEFORE ValidAccount: |
| 581 | // acctRe is [a-z]{3,16}, which "legacy" satisfies, so the order is what |
| 582 | // keeps the sentinel from being read as an account sqid. |
| 583 | if from == release.LegacySource { |
| 584 | src := release.LegacyAppPrefix(app) |
| 585 | keys, err := store.List(ctx, src) |
| 586 | if err != nil { |
| 587 | log.Fatalf("accounts migrate: list %s: %v", src, err) |
| 588 | } |
| 589 | if len(keys) == 0 { |
| 590 | log.Fatalf("accounts migrate: nothing under %s — %s has no legacy objects to move", src, app) |
| 591 | } |
| 592 | mutedf(os.Stdout, "source: %s (the legacy, account-less keys)\n", src) |
| 593 | var out []copyOp |
| 594 | hadProduction := false |
| 595 | for _, k := range keys { |
| 596 | rel := strings.TrimPrefix(k, src) |
| 597 | out = planCopy(out, &hadProduction, k, rel, dst) |
| 598 | } |
| 599 | return out, release.LegacySource, hadProduction |
| 600 | } |
| 601 | if from != "" { |
| 602 | if !release.ValidAccount(from) { |
| 603 | log.Fatalf("accounts migrate: %q is not an account sqid", from) |
| 604 | } |
| 605 | src := release.AppPrefix(from, app) |
| 606 | if src == dst { |
| 607 | log.Fatalf("accounts migrate: %s is already under %s", app, to) |
| 608 | } |
| 609 | keys, err := store.List(ctx, src) |
| 610 | if err != nil { |
| 611 | log.Fatalf("accounts migrate: list %s: %v", src, err) |
| 612 | } |
| 613 | var out []copyOp |
| 614 | hadProduction := false |
| 615 | for _, k := range keys { |
| 616 | rel := strings.TrimPrefix(k, src) |
| 617 | out = planCopy(out, &hadProduction, k, rel, dst) |
| 618 | } |
| 619 | return out, from, hadProduction |
| 620 | } |
| 621 | |
| 622 | if to == release.OpsAccount { |
| 623 | log.Fatalf("accounts migrate: moving into ops needs an explicit --from — ops is also the default\n" + |
| 624 | "SOURCE, and guessing which way you meant is not something this verb will do.\n" + |
| 625 | "For an app still on the pre-accounts keys (apps/<app>/…), name them:\n\n" + |
| 626 | " carlos accounts migrate --app <app> --from legacy --to bab\n") |
| 627 | } |
| 628 | opsPrefix := release.AppPrefix(release.OpsAccount, app) |
| 629 | legacyPrefix := release.LegacyAppPrefix(app) |
| 630 | opsKeys, err := store.List(ctx, opsPrefix) |
| 631 | if err != nil { |
| 632 | log.Fatalf("accounts migrate: list %s: %v", opsPrefix, err) |
| 633 | } |
| 634 | legacyKeys, err := store.List(ctx, legacyPrefix) |
| 635 | if err != nil { |
| 636 | log.Fatalf("accounts migrate: list %s: %v", legacyPrefix, err) |
| 637 | } |
| 638 | if len(opsKeys) > 0 && len(legacyKeys) > 0 { |
| 639 | fmt.Printf("%s has objects under BOTH %s and %s — this app was shipped to since the\n", app, opsPrefix, legacyPrefix) |
| 640 | fmt.Printf("accounts build was adopted. Taking the qualified copy of every key that\n") |
| 641 | fmt.Printf("exists in both (that is what the runtime reads), and the legacy copy of the\n") |
| 642 | fmt.Printf("rest, so no rollback target is lost.\n") |
| 643 | } else if len(opsKeys) > 0 { |
| 644 | mutedf(os.Stdout, "source: %s\n", opsPrefix) |
| 645 | } else if len(legacyKeys) > 0 { |
| 646 | mutedf(os.Stdout, "source: %s\n", legacyPrefix) |
| 647 | } |
| 648 | |
| 649 | // Qualified first, then legacy for anything it did not cover — the |
| 650 | // per-key precedence described above. |
| 651 | seen := map[string]bool{} |
| 652 | var out []copyOp |
| 653 | hadProduction := false |
| 654 | for _, k := range opsKeys { |
| 655 | rel := strings.TrimPrefix(k, opsPrefix) |
| 656 | seen[rel] = true |
| 657 | out = planCopy(out, &hadProduction, k, rel, dst) |
| 658 | } |
| 659 | for _, k := range legacyKeys { |
| 660 | rel := strings.TrimPrefix(k, legacyPrefix) |
| 661 | if seen[rel] { |
| 662 | continue // the qualified copy is the live one |
| 663 | } |
| 664 | out = planCopy(out, &hadProduction, k, rel, dst) |
| 665 | } |
| 666 | return out, release.OpsAccount, hadProduction |
| 667 | } |
| 668 | |
| 669 | // pointerRebinder returns how a channel pointer crosses from srcAcct to |
| 670 | // dstAcct. A pointer signed before the account binding names no account and |
| 671 | // travels as bytes, exactly as before. A pointer that names its account no |
| 672 | // longer verifies anywhere else (discussion #220), so it is re-signed for the |
| 673 | // destination with the root key — CARLOS_RELEASE_KEY, as for any promote. |
| 674 | // The key is read only if such a pointer turns up, and a scoped key is |
| 675 | // refused: moving a pointer between accounts is a root decision. |
| 676 | func pointerRebinder(app, srcAcct, dstAcct string) func(dstKey string, body []byte) []byte { |
| 677 | var root ed25519.PrivateKey |
| 678 | prefix := release.ChannelsPrefix(dstAcct, app) |
| 679 | return func(dstKey string, body []byte) []byte { |
| 680 | acct, err := release.PointerAccount(body) |
| 681 | if err != nil || acct == "" || acct == dstAcct { |
| 682 | // Unparseable bytes are copied as they always were; the |
| 683 | // verification pass is what judges them. |
| 684 | return body |
| 685 | } |
| 686 | channel := strings.TrimSuffix(strings.TrimPrefix(dstKey, prefix), ".json") |
| 687 | if root == nil { |
| 688 | raw := os.Getenv("CARLOS_RELEASE_KEY") |
| 689 | if strings.TrimSpace(raw) == "" || release.IsScopedSeed(raw) { |
| 690 | log.Fatalf("accounts migrate: channel %s of %s is signed for account %s, so it has to be\n"+ |
| 691 | "re-signed for %s, which needs the root release key in CARLOS_RELEASE_KEY.\n"+ |
| 692 | "Nothing has been deleted; set it and re-run.", channel, app, acct, dstAcct) |
| 693 | } |
| 694 | root = signingKey() |
| 695 | } |
| 696 | out, err := release.RebindPointer(root, body, srcAcct, dstAcct, app, channel) |
| 697 | if err != nil { |
| 698 | log.Fatalf("accounts migrate: %v", err) |
| 699 | } |
| 700 | return out |
| 701 | } |
| 702 | } |
| 703 | |
| 704 | // verifyMigratedPointers re-reads every channel pointer after the copy and |
| 705 | // refuses if the destination does not now serve exactly what the source |
| 706 | // serves. Belt and braces over migrationPlan's precedence — and the one check |
| 707 | // that catches a promote landing DURING the migration, which would otherwise |
| 708 | // leave the new account one version behind with nothing to notice it. |
| 709 | // |
| 710 | // Byte comparison is the gate, so no signing key is needed. When |
| 711 | // CARLOS_RELEASE_PUBKEY is set the versions are resolved and printed too, so |
| 712 | // the operator sees what they just moved rather than a bare "ok". |
| 713 | // |
| 714 | // "What the source serves" is the source pointer as rebind carries it: a |
| 715 | // pointer bound to the source account is re-signed for the destination |
| 716 | // (pointerRebinder), so the bytes compared are the rebound ones. |
| 717 | func verifyMigratedPointers(ctx context.Context, store release.Store, app, srcAcct, dstAcct string, kept []string, rebind func(dstKey string, body []byte) []byte) { |
| 718 | prefix := release.ChannelsPrefix(dstAcct, app) |
| 719 | keys, err := store.List(ctx, prefix) |
| 720 | if err != nil { |
| 721 | log.Fatalf("accounts migrate: verify: list %s: %v", prefix, err) |
| 722 | } |
| 723 | var pub ed25519.PublicKey |
| 724 | if b := strings.TrimSpace(os.Getenv("CARLOS_RELEASE_PUBKEY")); b != "" { |
| 725 | if p, err := release.ParsePublicKey(b, "CARLOS_RELEASE_PUBKEY"); err == nil { |
| 726 | pub = p |
| 727 | } |
| 728 | } |
| 729 | for _, key := range keys { |
| 730 | // A pointer the legacy-source path deliberately KEPT (a newer |
| 731 | // qualified pointer outranked the stale legacy copy) must not be |
| 732 | // compared against the source it refused — it is exempt by |
| 733 | // construction, and the exemption was already printed. |
| 734 | if slices.Contains(kept, key) { |
| 735 | channel := strings.TrimSuffix(strings.TrimPrefix(key, prefix), ".json") |
| 736 | fmt.Printf("%s %s: kept the existing qualified pointer (legacy copy was stale)\n", |
| 737 | colorFor(os.Stdout).ok("verified"), channel) |
| 738 | continue |
| 739 | } |
| 740 | channel := strings.TrimSuffix(strings.TrimPrefix(key, prefix), ".json") |
| 741 | got, _, err := store.Get(ctx, key) |
| 742 | if err != nil { |
| 743 | log.Fatalf("accounts migrate: verify %s: %v", key, err) |
| 744 | } |
| 745 | // What the OLD account still holds, by this verb's own per-key |
| 746 | // precedence — the migration's explicit read of the legacy space, |
| 747 | // which outlives the retired runtime fallback. A legacy source has no |
| 748 | // qualified key at all, so both halves are the legacy one: passing the |
| 749 | // sentinel to ChannelKey would build apps/legacy/… and quietly depend |
| 750 | // on that never existing. |
| 751 | srcKey := release.ChannelKey(srcAcct, app, channel) |
| 752 | if srcAcct == release.LegacySource { |
| 753 | srcKey = release.LegacyChannelKey(app, channel) |
| 754 | } |
| 755 | want, _, _, err := release.GetMigrationSource(ctx, store, |
| 756 | srcKey, release.LegacyChannelKey(app, channel)) |
| 757 | if err != nil { |
| 758 | log.Fatalf("accounts migrate: verify: re-read %s/%s at the source: %v", app, channel, err) |
| 759 | } |
| 760 | want = rebind(key, want) |
| 761 | if string(got) != string(want) { |
| 762 | // A write landed between the copy and this read — a promote |
| 763 | // racing the migration. CONVERGE rather than refuse: the pointer |
| 764 | // is this migration's own object, and telling an operator to |
| 765 | // "re-run" while leaving a destination pointer that the create-only |
| 766 | // copy would then reject is an instruction with no exit. |
| 767 | log.Printf("accounts migrate: %s/%s moved during the migration — re-copying", app, channel) |
| 768 | if _, err := putOverwrite(ctx, store, key, want); err != nil { |
| 769 | log.Fatalf("accounts migrate: converge %s: %v", key, err) |
| 770 | } |
| 771 | got, _, err = store.Get(ctx, key) |
| 772 | if err != nil { |
| 773 | log.Fatalf("accounts migrate: verify %s: %v", key, err) |
| 774 | } |
| 775 | if string(got) != string(want) { |
| 776 | // Twice in a row means something is actively writing. Name the |
| 777 | // exact recovery rather than a vague "try again". |
| 778 | log.Fatalf("accounts migrate: VERIFICATION FAILED for channel %s: %s still does not serve\n"+ |
| 779 | "what %s serves after a re-copy, so something is writing to this app right now.\n"+ |
| 780 | "Nothing has been deleted. Stop the writer, then either re-run this command or\n"+ |
| 781 | "delete this one key and re-run:\n\n %s\n", |
| 782 | channel, dstAcct, srcAcct, key) |
| 783 | } |
| 784 | } |
| 785 | if pub != nil { |
| 786 | p, err := release.Channel(ctx, store, pub, dstAcct, app, channel) |
| 787 | if err != nil { |
| 788 | log.Fatalf("accounts migrate: verify: %s/%s does not resolve under %s: %v", app, channel, dstAcct, err) |
| 789 | } |
| 790 | fmt.Printf("%s %s: %s serves %s under %s\n", |
| 791 | colorFor(os.Stdout).ok("verified"), channel, app, p.Version, dstAcct) |
| 792 | continue |
| 793 | } |
| 794 | fmt.Printf("%s %s: byte-identical to what %s serves (set CARLOS_RELEASE_PUBKEY to see versions)\n", |
| 795 | colorFor(os.Stdout).ok("verified"), channel, srcAcct) |
| 796 | } |
| 797 | } |
| 798 | |
| 799 | // pickAccount is resolveAccount's core, symmetric over the two identity |
| 800 | // spaces: want matches an account by sqid OR by name (case-insensitively), |
| 801 | // with NO precedence between them. Precedence is how a script's --account |
| 802 | // silently changes target the day a colliding account materializes; a |
| 803 | // conflict here is loud instead, and names --account-id, the reference that |
| 804 | // never guesses. A sqid-shaped want that matches nothing passes through (a |
| 805 | // fresh box may not have reconciled; CI has no registry at all). |
| 806 | func pickAccount(accts []registry.Account, want string) (string, error) { |
| 807 | var candidates []registry.Account |
| 808 | for _, a := range accts { |
| 809 | if a.Sqid == want || strings.EqualFold(a.Name, want) { |
| 810 | candidates = append(candidates, a) |
| 811 | } |
| 812 | } |
| 813 | switch len(candidates) { |
| 814 | case 1: |
| 815 | return candidates[0].Sqid, nil |
| 816 | case 0: |
| 817 | // A sqid this registry hasn't materialized yet is still usable: the |
| 818 | // bucket is the control plane, and a fresh box may simply not have |
| 819 | // reconciled. A NAME, though, can only ever be resolved here. |
| 820 | if release.ValidAccount(want) { |
| 821 | return want, nil |
| 822 | } |
| 823 | return "", fmt.Errorf("no account %q here — `carlos accounts list` shows what this box knows", want) |
| 824 | default: |
| 825 | alts := make([]string, len(candidates)) |
| 826 | for i, a := range candidates { |
| 827 | alts[i] = fmt.Sprintf("%s (%q)", a.Sqid, a.Name) |
| 828 | } |
| 829 | return "", fmt.Errorf("account %q is ambiguous — pass --account-id with one of: %s", |
| 830 | want, strings.Join(alts, ", ")) |
| 831 | } |
| 832 | } |
| 833 | |
| 834 | // checkAccountID is the ONE gate --account-id passes through, shared by |
| 835 | // resolveAccount (direct-bucket mode) and apiAccount (API mode) so the two |
| 836 | // modes can never disagree about what a sqid is. It returns the trimmed id, |
| 837 | // or "" when the flag was not given. |
| 838 | // |
| 839 | // The check is domains.ValidSqid, NOT release.ValidAccount. ValidAccount |
| 840 | // accepts any lowercase word because it gates bucket path segments, and |
| 841 | // every account NAME is one — so it accepts "ops", which the frozen encoder |
| 842 | // (no i, l, o) can never emit for any id. Waved through here, `--account-id |
| 843 | // ops` would have shipped to apps/ops/..., exited 0, and served nothing: the |
| 844 | // real ops account is "bab". A name in --account-id is a mistake with no |
| 845 | // honest reading, so it is refused by shape before anything else happens. |
| 846 | func checkAccountID(wantID string) string { |
| 847 | wantID = strings.TrimSpace(wantID) |
| 848 | if wantID == "" || domains.ValidSqid(wantID) { |
| 849 | return wantID |
| 850 | } |
| 851 | // "ops" is THE trap: it is the default --account value, the one account |
| 852 | // name every operator types, and it is name-shaped in a way that reads |
| 853 | // exactly like a short sqid. Name its actual sqid rather than making |
| 854 | // someone go look it up. |
| 855 | if wantID == "ops" { |
| 856 | log.Fatalf(`--account-id takes a sqid; "ops" is a name — use --account ops, or --account-id %s`, release.OpsAccount) |
| 857 | } |
| 858 | log.Fatalf("--account-id %q is not a sqid (sqids use only the letters %s — no i, l, o) — "+ |
| 859 | "it looks like a NAME; names go in --account", wantID, domains.SqidAlphabet) |
| 860 | return "" |
| 861 | } |
| 862 | |
| 863 | // resolveAccount turns a --account/--account-id pair into an account sqid. |
| 864 | // --account-id supersedes --account outright: it is a sqid, matched as a sqid |
| 865 | // only, never consulted against the registry at all — the reference that |
| 866 | // cannot be shadowed by a name. Otherwise --account resolves through |
| 867 | // pickAccount, symmetrically over sqids and names, and an ambiguity is fatal |
| 868 | // rather than guessed: picking the wrong account here would ship to the |
| 869 | // wrong tenant. |
| 870 | func resolveAccount(regPath, want, wantID string) string { |
| 871 | if wantID = checkAccountID(wantID); wantID != "" { |
| 872 | return wantID |
| 873 | } |
| 874 | want = strings.TrimSpace(want) |
| 875 | if want == "" || want == "ops" { |
| 876 | return release.OpsAccount |
| 877 | } |
| 878 | // The release verbs run from CI as often as from a box, and a build |
| 879 | // machine has no registry at all. Not having one is only fatal if a NAME |
| 880 | // actually needs resolving: "ops" is answered above without opening |
| 881 | // anything, and a sqid is already the answer. |
| 882 | r, err := registry.Open(regPath) |
| 883 | if err != nil { |
| 884 | if release.ValidAccount(want) { |
| 885 | return want |
| 886 | } |
| 887 | log.Fatalf("cannot resolve account %q: no registry at %s (%v) — pass the sqid instead of the name", want, regPath, err) |
| 888 | } |
| 889 | defer r.Close() |
| 890 | accts, err := r.Accounts() |
| 891 | if err != nil { |
| 892 | log.Fatal(err) |
| 893 | } |
| 894 | sqid, err := pickAccount(accts, want) |
| 895 | if err != nil { |
| 896 | log.Fatal(err) |
| 897 | } |
| 898 | return sqid |
| 899 | } |
| 900 | |