cli: -account everywhere, accounts create/list/migrate — the migration is a verb, not a runbook
-account takes a sqid or a name on ship/promote/channels/rollback/add, defaulting to ops so a single-tenant deployment never types it. Resolution is friendly without being guessy: an exact sqid wins, then a unique name; an ambiguous name lists the candidates and refuses, because picking one would ship to the wrong tenant. accounts create mints the id, writes the BUCKET MANIFEST FIRST and only then materializes the registry row — a registry row whose manifest never landed is an account that vanishes on the next rebuild. accounts migrate copies an app's objects to the qualified prefix and re-stamps its routes. It never deletes a legacy object; -dry-run prints the plan and touches nothing; re-running over identical bytes is a no-op, while different bytes at an existing destination are refused rather than overwritten. The closing message names the verification gate, because that is the step between this verb and any deletion.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4 files changed,
+489
−19
accounts.go+325 −0cli.go+28 −18e2e_test.go+132 −0main.go+4 −1
diff --git a/accounts.go b/accounts.go| new file mode 100644 |
| index 000000000..d1c09e842 |
| --- /dev/null |
| +++ b/accounts.go |
| @@ -0,0 +1,325 @@ |
| +// 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 <sqid>` 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/rand" |
| + "encoding/binary" |
| + "flag" |
| + "fmt" |
| + "log" |
| + "os" |
| + "strings" |
| + "text/tabwriter" |
| + "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|list|migrate> |
| + |
| + create -name <name> mint an account: a random 40-bit id, its |
| + frozen sqid, and the bucket manifest every |
| + box materializes identity from |
| + list sqid, name, and app count |
| + migrate -app <a> -to <sqid> copy an app's objects to the account- |
| + qualified prefix and re-stamp its routes |
| + [-from <sqid>] source account (default: the legacy, |
| + account-less keys) |
| + [-dry-run] print the plan and touch nothing |
| + |
| +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:]) |
| + default: |
| + fmt.Fprint(os.Stderr, accountsUsage) |
| + os.Exit(1) |
| + } |
| +} |
| + |
| +// newAccountID mints an account id the same way the console does: 40 random |
| +// bits, never 0 or 1 (id 1 is the built-in ops account, pinned by registry |
| +// migration). Duplicated rather than shared because internal/console is the |
| +// console's own package and the CLI must not depend on it — the RULE, not the |
| +// code, is what has to match, and both are pinned by tests. |
| +func newAccountID() int64 { |
| + for { |
| + var b [8]byte |
| + if _, err := rand.Read(b[:]); err != nil { |
| + log.Fatalf("accounts: read randomness: %v", err) |
| + } |
| + id := int64(binary.BigEndian.Uint64(b[:]) & ((1 << 40) - 1)) |
| + if id > 1 { |
| + return id |
| + } |
| + } |
| +} |
| + |
| +func runAccountsCreate(args []string) { |
| + fs := flag.NewFlagSet("accounts create", flag.ExitOnError) |
| + regPath := fs.String("registry", defaultRegistry(), "registry database") |
| + name := fs.String("name", "", "account name (free text, need not be unique)") |
| + fs.Parse(args) |
| + if strings.TrimSpace(*name) == "" { |
| + log.Fatal("usage: carlos accounts create -name <name>") |
| + } |
| + id := newAccountID() |
| + sqid := domains.Sqid(id) |
| + store := openStore() |
| + // The bucket first: identity lives there, and the registry only ever |
| + // materializes it. A registry row whose manifest never landed would be an |
| + // account that vanishes on the next rebuild. |
| + if err := writeAccountManifest(context.Background(), store, id, sqid, *name, time.Now()); err != nil { |
| + log.Fatalf("accounts create: write manifest: %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("created account %q\n id: %d\n sqid: %s\n", *name, id, sqid) |
| + fmt.Printf("use it with: carlos ship -account %s -app <app> ...\n", sqid) |
| +} |
| + |
| +// writeAccountManifest publishes an account's identity into the bucket at |
| +// accounts/<sqid>/account.json. Overwrite-safe (read etag, put, one retry), |
| +// the same shape the console's writer uses. |
| +func writeAccountManifest(ctx context.Context, s release.Store, id int64, sqid, name string, created time.Time) error { |
| + body := []byte(fmt.Sprintf(`{"id":%d,"sqid":%q,"name":%q,"created_at":%q}`, |
| + id, sqid, name, created.UTC().Format(time.RFC3339))) |
| + key := "accounts/" + sqid + "/account.json" |
| + put := func() error { |
| + etag := "" |
| + if _, cur, err := s.Get(ctx, key); err == nil { |
| + etag = cur |
| + } |
| + _, err := s.Put(ctx, key, body, etag) |
| + return err |
| + } |
| + if err := put(); err != nil { |
| + return put() // one retry on a lost race; a second loss surfaces |
| + } |
| + return nil |
| +} |
| + |
| +func runAccountsList(args []string) { |
| + fs := flag.NewFlagSet("accounts list", flag.ExitOnError) |
| + regPath := fs.String("registry", defaultRegistry(), "registry database") |
| + fs.Parse(args) |
| + 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 := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0) |
| + 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 := fs.String("app", "", "app to move") |
| + to := fs.String("to", "", "destination account sqid") |
| + from := fs.String("from", "", "source account sqid (default: the legacy, account-less keys)") |
| + dryRun := fs.Bool("dry-run", false, "print the plan and touch nothing") |
| + fs.Parse(args) |
| + if *app == "" || *to == "" { |
| + log.Fatal("usage: carlos accounts migrate -app <a> -to <sqid> [-from <sqid>] [-dry-run]") |
| + } |
| + 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) |
| + } |
| + |
| + src := release.LegacyAppPrefix(*app) |
| + if *from != "" { |
| + if !release.ValidAccount(*from) { |
| + log.Fatalf("accounts migrate: %q is not an account sqid", *from) |
| + } |
| + src = release.AppPrefix(*from, *app) |
| + } |
| + dst := release.AppPrefix(*to, *app) |
| + if src == dst { |
| + log.Fatalf("accounts migrate: %s is already under %s", *app, *to) |
| + } |
| + |
| + ctx := context.Background() |
| + store := openStore() |
| + keys, err := store.List(ctx, src) |
| + if err != nil { |
| + log.Fatalf("accounts migrate: list %s: %v", src, err) |
| + } |
| + if len(keys) == 0 { |
| + fmt.Printf("nothing under %s — nothing to copy\n", src) |
| + } |
| + for _, key := range keys { |
| + target := dst + strings.TrimPrefix(key, src) |
| + if *dryRun { |
| + fmt.Printf("would copy %s -> %s\n", key, target) |
| + continue |
| + } |
| + body, _, err := store.Get(ctx, key) |
| + if err != nil { |
| + log.Fatalf("accounts migrate: read %s: %v", key, err) |
| + } |
| + // Create-only, then verify: re-running the migration over identical |
| + // bytes is a no-op, and DIFFERENT bytes at a destination that already |
| + // exists is refused loudly rather than overwritten. |
| + if _, err := store.Put(ctx, target, body, ""); err != nil { |
| + cur, _, gerr := store.Get(ctx, target) |
| + if gerr != nil { |
| + log.Fatalf("accounts migrate: write %s: %v", target, err) |
| + } |
| + if string(cur) != string(body) { |
| + log.Fatalf("accounts migrate: %s already exists with different content — refusing to overwrite", target) |
| + } |
| + fmt.Printf("already copied %s -> %s\n", key, target) |
| + continue |
| + } |
| + fmt.Printf("copied %s -> %s\n", key, target) |
| + } |
| + |
| + // 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 { |
| + fmt.Printf("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("stamped account %s on route %s\n", *to, rt.Host) |
| + } |
| + |
| + if *dryRun { |
| + fmt.Println("dry run: nothing was written") |
| + return |
| + } |
| + fmt.Printf("\n%s now lives under %s. The legacy objects at %s are UNTOUCHED —\n", *app, dst, src) |
| + fmt.Println("verify first (every channel pointer resolves qualified, one adopt pass is a") |
| + fmt.Println("no-op), and only then delete them by hand.") |
| +} |
| + |
| +// resolveAccount turns a -account value into an account sqid: an exact sqid |
| +// match wins, then a unique name match; an ambiguous name is an error that |
| +// lists the candidates rather than picking one. Friendly without being |
| +// guessy — picking the wrong account here would ship to the wrong tenant. |
| +func resolveAccount(regPath, want string) string { |
| + want = strings.TrimSpace(want) |
| + if want == "" { |
| + return release.OpsAccount |
| + } |
| + r, err := registry.Open(regPath) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + defer r.Close() |
| + accts, err := r.Accounts() |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + for _, a := range accts { |
| + if a.Sqid == want { |
| + return a.Sqid |
| + } |
| + } |
| + var matches []registry.Account |
| + for _, a := range accts { |
| + if strings.EqualFold(a.Name, want) { |
| + matches = append(matches, a) |
| + } |
| + } |
| + switch len(matches) { |
| + case 1: |
| + return matches[0].Sqid |
| + 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 |
| + } |
| + log.Fatalf("no account %q here — `carlos accounts list` shows what this box knows", want) |
| + default: |
| + var names []string |
| + for _, a := range matches { |
| + names = append(names, fmt.Sprintf("%s (%s)", a.Sqid, a.Name)) |
| + } |
| + log.Fatalf("account %q is ambiguous — use a sqid: %s", want, strings.Join(names, ", ")) |
| + } |
| + return release.OpsAccount |
| +} |
diff --git a/cli.go b/cli.go| index 4aa0ca9ee..339f7f896 100644 |
| --- a/cli.go |
| +++ b/cli.go |
| @@ -50,6 +50,7 @@ func runAdd(args []string) { |
| fs := flag.NewFlagSet("add", flag.ExitOnError) |
| reg := fs.String("registry", defaultRegistry(), "registry database") |
| app := fs.String("app", "", "app whose releases this route runs") |
| + acct := fs.String("account", "ops", "account (sqid or name) the app belongs to") |
| slug := fs.String("slug", "", "root-domain short name (optional)") |
| kind := fs.String("kind", "instance", "instance | service | static") |
| socket := fs.String("socket", "", "unix socket upstream") |
| @@ -105,9 +106,13 @@ func runAdd(args []string) { |
| if err != nil { |
| log.Fatal(err) |
| } |
| + // The account is stamped on the row: it is what adopt and the edge build |
| + // this app's bucket keys from. Reads still fall back to the legacy |
| + // unqualified keys, so an unmigrated app added here keeps working. |
| if err := r.Upsert(registry.Route{ |
| Host: host, Slug: *slug, Kind: *kind, Socket: *socket, Addr: *addr, |
| - App: *app, Channel: *channel, Hibernate: *hibernate, StatusNamed: *statusNamed, Backing: *backing, |
| + App: *app, Account: resolveAccount(*reg, *acct), |
| + Channel: *channel, Hibernate: *hibernate, StatusNamed: *statusNamed, Backing: *backing, |
| DBPath: *dbPath, Status: "ready", |
| }); err != nil { |
| log.Fatal(err) |
| @@ -158,15 +163,19 @@ func runInstances(args []string) { |
| w.Flush() |
| } |
| -// accountFlag registers the -account flag every app verb carries. It holds |
| -// the account SQID the app's bucket keys live under, defaulting to the |
| -// built-in ops account so a single-tenant deployment never types it. |
| +// accountFlags registers the -account (and its -registry seam) every app verb |
| +// carries. -account takes a sqid OR a name, resolved against the registry's |
| +// materialized accounts; it defaults to the built-in ops account, so a |
| +// single-tenant deployment never types it. Reads fall back to the legacy |
| +// unqualified keys either way, so an unmigrated app keeps working. |
| // |
| -// Task 6 makes this accept a name as well (resolved against the registry); |
| -// today the value is used verbatim, and reads fall back to the legacy |
| -// unqualified keys, so an unmigrated app keeps working either way. |
| -func accountFlag(fs *flag.FlagSet) *string { |
| - return fs.String("account", release.OpsAccount, "account sqid the app belongs to") |
| +// Returns a thunk rather than the value because resolution has to happen |
| +// AFTER fs.Parse — and it opens the registry, which a verb that never uses |
| +// the account should not pay for. |
| +func accountFlags(fs *flag.FlagSet) func() string { |
| + acct := fs.String("account", "ops", "account (sqid or name) the app belongs to") |
| + regPath := fs.String("registry", defaultRegistry(), "registry database (resolves -account)") |
| + return func() string { return resolveAccount(*regPath, *acct) } |
| } |
| func openStore() release.Store { |
| @@ -203,7 +212,7 @@ func publicKey() ed25519.PublicKey { |
| func runShip(args []string) { |
| fs := flag.NewFlagSet("ship", flag.ExitOnError) |
| app := fs.String("app", "", "app name") |
| - acct := accountFlag(fs) |
| + acctOf := accountFlags(fs) |
| kind := fs.String("kind", "binary", "binary | static") |
| ver := fs.String("version", "", "version (git short sha)") |
| target := fs.String("target", "linux-arm64", "GOOS-GOARCH the binary was built for (binary kind, single file)") |
| @@ -253,7 +262,7 @@ func runShip(args []string) { |
| BuiltBy: "carlos " + version.Build, BuiltAt: time.Now().UTC().Format(time.RFC3339), |
| Artifacts: arts, |
| } |
| - if err := release.Ship(context.Background(), openStore(), *acct, m, files); err != nil { |
| + if err := release.Ship(context.Background(), openStore(), acctOf(), m, files); err != nil { |
| log.Fatal(err) |
| } |
| fmt.Printf("shipped %s %s (%d artifacts)\n", *app, *ver, len(files)) |
| @@ -262,12 +271,12 @@ func runShip(args []string) { |
| func runPromote(args []string) { |
| fs := flag.NewFlagSet("promote", flag.ExitOnError) |
| app := fs.String("app", "", "app name") |
| - acct := accountFlag(fs) |
| + acctOf := accountFlags(fs) |
| fs.Parse(args) |
| if *app == "" || fs.NArg() != 2 { |
| log.Fatal("usage: carlos promote -app <a> <version> <channel>") |
| } |
| - if err := release.Promote(context.Background(), openStore(), signingKey(), *acct, *app, fs.Arg(1), fs.Arg(0)); err != nil { |
| + if err := release.Promote(context.Background(), openStore(), signingKey(), acctOf(), *app, fs.Arg(1), fs.Arg(0)); err != nil { |
| log.Fatal(err) |
| } |
| fmt.Printf("promoted %s %s -> %s\n", *app, fs.Arg(0), fs.Arg(1)) |
| @@ -276,14 +285,15 @@ func runPromote(args []string) { |
| func runChannels(args []string) { |
| fs := flag.NewFlagSet("channels", flag.ExitOnError) |
| app := fs.String("app", "", "app name") |
| - acct := accountFlag(fs) |
| + acctOf := accountFlags(fs) |
| fs.Parse(args) |
| if *app == "" { |
| log.Fatal("usage: carlos channels -app <a> [-account <sqid-or-name>]") |
| } |
| + acct := acctOf() |
| s := openStore() |
| keys, prefix, _, err := release.ListWithFallback(context.Background(), s, |
| - release.ChannelsPrefix(*acct, *app), release.LegacyChannelsPrefix(*app)) |
| + release.ChannelsPrefix(acct, *app), release.LegacyChannelsPrefix(*app)) |
| if err != nil { |
| log.Fatal(err) |
| } |
| @@ -292,7 +302,7 @@ func runChannels(args []string) { |
| fmt.Fprintln(w, "CHANNEL\tVERSION\tPROMOTED") |
| for _, k := range keys { |
| name := strings.TrimSuffix(strings.TrimPrefix(k, prefix), ".json") |
| - cp, err := release.Channel(context.Background(), s, pub, *acct, *app, name) |
| + cp, err := release.Channel(context.Background(), s, pub, acct, *app, name) |
| if err != nil { |
| fmt.Fprintf(w, "%s\tERROR: %v\t\n", name, err) |
| continue |
| @@ -305,12 +315,12 @@ func runChannels(args []string) { |
| func runRollback(args []string) { |
| fs := flag.NewFlagSet("rollback", flag.ExitOnError) |
| app := fs.String("app", "", "app name") |
| - acct := accountFlag(fs) |
| + acctOf := accountFlags(fs) |
| fs.Parse(args) |
| if *app == "" || fs.NArg() != 1 { |
| log.Fatal("usage: carlos rollback -app <a> <channel>") |
| } |
| - if err := release.Rollback(context.Background(), openStore(), signingKey(), *acct, *app, fs.Arg(0)); err != nil { |
| + if err := release.Rollback(context.Background(), openStore(), signingKey(), acctOf(), *app, fs.Arg(0)); err != nil { |
| log.Fatal(err) |
| } |
| fmt.Printf("rolled back %s %s\n", *app, fs.Arg(0)) |
diff --git a/e2e_test.go b/e2e_test.go| index 6f25a4252..32f6e7d4b 100644 |
| --- a/e2e_test.go |
| +++ b/e2e_test.go |
| @@ -27,6 +27,8 @@ import ( |
| "os" |
| "os/exec" |
| "path/filepath" |
| + "reflect" |
| + "regexp" |
| "strings" |
| "testing" |
| "time" |
| @@ -490,6 +492,136 @@ func TestStatusCLI(t *testing.T) { |
| } |
| } |
| +// TestAccountsCLI drives the `carlos accounts` verbs as an operator would: |
| +// create mints identity, list shows it, and migrate moves an app's objects to |
| +// the account-qualified prefix WITHOUT ever deleting the legacy ones — |
| +// deletion is a separate operator step behind the verification gate. |
| +func TestAccountsCLI(t *testing.T) { |
| + goBin, err := exec.LookPath("go") |
| + if err != nil { |
| + t.Skip("go toolchain not found") |
| + } |
| + tmp := t.TempDir() |
| + carlosBin := filepath.Join(tmp, "carlos") |
| + build := exec.Command(goBin, "build", "-o", carlosBin, ".") |
| + build.Env = os.Environ() |
| + if out, err := build.CombinedOutput(); err != nil { |
| + t.Fatalf("build carlos: %v\n%s", err, out) |
| + } |
| + storeDir := filepath.Join(tmp, "store") |
| + regPath := filepath.Join(tmp, "registry.db") |
| + env := append(os.Environ(), "CARLOS_DEPLOYMENT_DIR="+storeDir) |
| + run := func(args ...string) string { |
| + t.Helper() |
| + cmd := exec.Command(carlosBin, args...) |
| + cmd.Env = env |
| + out, err := cmd.CombinedOutput() |
| + if err != nil { |
| + t.Fatalf("carlos %s: %v\n%s", strings.Join(args, " "), err, out) |
| + } |
| + return string(out) |
| + } |
| + |
| + // An app in the legacy, account-less shape — what every existing |
| + // deployment's bucket looks like before the migration. |
| + store := release.NewDirStore(storeDir) |
| + ctx := context.Background() |
| + for _, key := range []string{ |
| + "apps/demo/releases/v1/manifest.json", |
| + "apps/demo/releases/v1/demo", |
| + "apps/demo/channels/stable.json", |
| + } { |
| + if _, err := store.Put(ctx, key, []byte("body of "+key), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + reg, err := registry.Open(regPath) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.Upsert(registry.Route{Host: "demo.example.com", Kind: "instance", |
| + App: "demo", Status: "ready", Addr: "127.0.0.1:9000"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + reg.Close() |
| + |
| + // create: a minted id, its frozen sqid printed for the operator to use. |
| + out := run("accounts", "create", "-registry", regPath, "-name", "Kass") |
| + m := regexp.MustCompile(`(?m)^\s*sqid:\s*(\S+)`).FindStringSubmatch(out) |
| + if m == nil { |
| + t.Fatalf("accounts create printed no sqid:\n%s", out) |
| + } |
| + sqid := m[1] |
| + if !regexp.MustCompile(`^[a-z]{3,16}$`).MatchString(sqid) { |
| + t.Fatalf("created sqid = %q, want the frozen encoder's alphabet", sqid) |
| + } |
| + // The manifest is in the bucket — that is where identity lives. |
| + if _, _, err := store.Get(ctx, "accounts/"+sqid+"/account.json"); err != nil { |
| + t.Fatalf("create wrote no account manifest: %v", err) |
| + } |
| + |
| + if out := run("accounts", "list", "-registry", regPath); !strings.Contains(out, sqid) || |
| + !strings.Contains(out, "Kass") || !strings.Contains(out, "ops") { |
| + t.Fatalf("accounts list = %q, want ops and the new account", out) |
| + } |
| + |
| + // migrate -dry-run: says what it would do, touches nothing. |
| + before, err := store.List(ctx, "") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + out = run("accounts", "migrate", "-registry", regPath, "-app", "demo", "-to", sqid, "-dry-run") |
| + if !strings.Contains(out, "apps/demo/channels/stable.json") || |
| + !strings.Contains(out, "apps/"+sqid+"/demo/channels/stable.json") { |
| + t.Fatalf("dry run did not report the plan:\n%s", out) |
| + } |
| + after, err := store.List(ctx, "") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !reflect.DeepEqual(before, after) { |
| + t.Fatalf("dry run changed the store:\n%v\n%v", before, after) |
| + } |
| + |
| + // migrate for real: the qualified objects exist AND the legacy ones are |
| + // still there — nothing in this plan deletes a legacy object. |
| + run("accounts", "migrate", "-registry", regPath, "-app", "demo", "-to", sqid) |
| + for _, key := range []string{ |
| + "apps/" + sqid + "/demo/releases/v1/manifest.json", |
| + "apps/" + sqid + "/demo/releases/v1/demo", |
| + "apps/" + sqid + "/demo/channels/stable.json", |
| + } { |
| + body, _, err := store.Get(ctx, key) |
| + if err != nil { |
| + t.Errorf("migrated object missing: %s: %v", key, err) |
| + continue |
| + } |
| + legacy := "apps/demo/" + strings.TrimPrefix(key, "apps/"+sqid+"/demo/") |
| + want, _, err := store.Get(ctx, legacy) |
| + if err != nil { |
| + t.Errorf("migrate deleted the legacy object %s: %v", legacy, err) |
| + continue |
| + } |
| + if string(body) != string(want) { |
| + t.Errorf("%s = %q, want the legacy bytes %q", key, body, want) |
| + } |
| + } |
| + // The route's account moved with it, so adopt builds the new keys. |
| + reg2, err := registry.Open(regPath) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer reg2.Close() |
| + rt, err := reg2.Get("demo.example.com") |
| + if err != nil || rt == nil || rt.Account != sqid { |
| + t.Fatalf("route account = %+v %v, want %s", rt, err, sqid) |
| + } |
| + |
| + // Re-running is idempotent: identical bytes at an immutable path are a |
| + // no-op, not a refusal. |
| + run("accounts", "migrate", "-registry", regPath, "-app", "demo", "-to", sqid) |
| +} |
| + |
| // TestAddUnitBackingRequiresDBForHibernate pins the I1 fix's operator-facing |
| // half: `carlos add -backing unit -hibernate` with no -db must refuse, |
| // because ops.FleetDBs (the box-wide litestream unit) has no way to |
diff --git a/main.go b/main.go| index a1fcc8c06..b5b2e87f6 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -39,6 +39,8 @@ func main() { |
| runChannels(args) |
| case "rollback": |
| runRollback(args) |
| + case "accounts": |
| + RunAccounts(args) |
| case "release-keygen": |
| runKeygen(args) |
| case "ops": |
| @@ -69,7 +71,8 @@ commands: |
| edge run the mesh front door (router) |
| agent run edge + activator + release adoption on this host |
| add | remove | instances manage routes |
| - ship | promote | channels publish and promote releases |
| + accounts create | list | migrate — the tenancy primitive |
| + ship | promote | channels publish and promote releases (-account <sqid|name>) |
| adopt run one release-adoption pass (the hourly timer's verb) |
| canary ship this worktree to an ephemeral canary channel |
| bootstrap prepare a host (units, dirs, user) |