carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

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.
11package main
12
13import (
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
32const 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
63plan only works where the console has a plan catalog (CARLOS_PLAN_CATALOG).
64
65create 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
67rather than mint an account nobody can open in the console.
68
69migrate never deletes a legacy object. Deleting them is a separate operator
70step, behind the verification gate: every channel pointer must resolve from
71the qualified keys with the fallback disabled, and one adopt pass must be a
72no-op, first.
73`
74
75// RunAccounts dispatches the accounts sub-verbs.
76func 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
96func 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.
150func 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.
185func 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
223func 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.
263func 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
288func 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
323func 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.
496type 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.
503func 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.
513const 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.
517func 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.
529func 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.
579func 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.
676func 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.
717func 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).
806func 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.
846func 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.
870func 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