console: migrate-app completes the move, box-side — and the rebuilds prove they lose nothing
Two things the coordinator flagged.
1. The migration gap, closed by layering rather than reach-across. The
console's app→account row is CONSOLE state, so the carlos binary must not
open the console's database to move it. `console migrate-app -app <a> -to
<sqid>` does it box-side, exactly like `console grant`: idempotent, refusing
an unknown app or account before anything is written, and refusing to guess
when several accounts legitimately claim the same name (they may — names
are unique per account, not globally). `carlos accounts migrate` now prints
the exact follow-up command, on success and in -dry-run, with one line
saying why there is a second half at all.
2. Both table rebuilds now have a test that seeds the OLD shape by hand,
re-opens through the real migration, and asserts what survived — twice
over, because the rebuild has to be idempotent across restarts.
- console apps: every row keeps app/created_by/created_at, gains
account_id=1, and the point of the rebuild (the same name in two
accounts) works afterwards.
- registry accounts: the ROUTES — the registry's actual data — come
through untouched, ops is back as id 1 / sqid bab, the legacy
assigned-id rows are gone (they are the bug), and an account
materialized after the rebuild survives the next open.
Both tests were checked against a deliberately broken migration to confirm
they are not vacuous: disabling the legacy detector fails the registry one,
skipping the rebuild fails the console one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8 files changed,
+517
−7
accounts.go+8 −0cmd/console/main.go+77 −1cmd/console/main_test.go+106 −0e2e_test.go+10 −1internal/console/account_test.go+52 −0internal/console/migrate_test.go+107 −0internal/console/store.go+55 −5internal/registry/registry_test.go+102 −0
diff --git a/accounts.go b/accounts.go| index 328741d64..6fb52326b 100644 |
| --- a/accounts.go |
| +++ b/accounts.go |
| @@ -265,13 +265,21 @@ func runAccountsMigrate(args []string) { |
| fmt.Printf("stamped account %s on route %s\n", *to, rt.Host) |
| } |
| + // The console keeps its OWN app→account row, and this binary deliberately |
| + // does not open the console's database — console state is the console's |
| + // authority, exactly like grants. So the move is not finished until the |
| + // operator runs the box-side verb, and the exact command goes in the |
| + // output rather than in a runbook somebody has to find. |
| + followUp := fmt.Sprintf("console migrate-app -app %s -to %s", *app, *to) |
| if *dryRun { |
| fmt.Println("dry run: nothing was written") |
| + fmt.Printf("\nafterwards, on the console's box: %s\n", followUp) |
| return |
| } |
| fmt.Printf("\n%s now lives under %s. 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.") |
| + 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) |
| } |
| // resolveAccount turns a -account value into an account sqid: an exact sqid |
diff --git a/cmd/console/main.go b/cmd/console/main.go| index 0f4f8e077..ed0e2280a 100644 |
| --- a/cmd/console/main.go |
| +++ b/cmd/console/main.go |
| @@ -13,6 +13,10 @@ |
| // console grant -operator <address> make address a fleet operator |
| // console revoke -operator <address> remove that operator role |
| // console owners list every owner and their apps |
| +// console migrate-app -app <a> -to <sqid> |
| +// re-home an app onto another account — |
| +// the console half of |
| +// `carlos accounts migrate` |
| // |
| // serve reads: CONSOLE_ADDR (default 127.0.0.1:8090), CONSOLE_DB (default |
| // /data/console/console.db), CONSOLE_ORIGIN (required, e.g. |
| @@ -75,6 +79,8 @@ func main() { |
| if err != nil { |
| log.Fatal(err) |
| } |
| + case "migrate-app": |
| + migrateApp(os.Args[2:]) |
| case "owners": |
| st := openConsoleDB() |
| owners, err := st.Owners() |
| @@ -95,7 +101,77 @@ func main() { |
| } |
| func usage() { |
| - log.Fatal("usage: console serve | grant [-operator] <address> [<app>] | revoke [-operator] <address> [<app>] | owners") |
| + log.Fatal("usage: console serve | grant [-operator] <address> [<app>] | revoke [-operator] <address> [<app>] | owners | migrate-app -app <app> -to <sqid> [-from <sqid>]") |
| +} |
| + |
| +// migrateApp completes the second half of `carlos accounts migrate`: that |
| +// verb moves the app's bucket objects and re-stamps its registry routes, but |
| +// the console's own app→account row is CONSOLE state, and the carlos binary |
| +// has no business opening this database. So the move finishes here, box-side, |
| +// exactly like `console grant`. |
| +// |
| +// Idempotent, so an operator who is unsure whether it landed can run it |
| +// again; an unknown app or account is refused before anything is written. |
| +func migrateApp(args []string) { |
| + fs := flag.NewFlagSet("migrate-app", flag.ExitOnError) |
| + app := fs.String("app", "", "app to move") |
| + to := fs.String("to", "", "destination account sqid") |
| + from := fs.String("from", "", "source account sqid (default: the one account that claims the app)") |
| + fs.Parse(args) |
| + if *app == "" || *to == "" { |
| + log.Fatal("usage: console migrate-app -app <app> -to <sqid> [-from <sqid>]") |
| + } |
| + st := openConsoleDB() |
| + defer st.Close() |
| + |
| + dst, err := st.AccountBySqid(*to) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + if dst == nil { |
| + log.Fatalf("no account %q in this console — create it first (the switcher's \"New account\", or carlos accounts create)", *to) |
| + } |
| + holders, err := st.AppAccounts(*app) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + if len(holders) == 0 { |
| + log.Fatalf("no app %q in this console", *app) |
| + } |
| + if len(holders) == 1 && holders[0] == dst.ID { |
| + fmt.Printf("%s is already in %s (%s) — nothing to do\n", *app, *to, dst.Name) |
| + return |
| + } |
| + |
| + var src int64 |
| + switch { |
| + case *from != "": |
| + a, err := st.AccountBySqid(*from) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + if a == nil { |
| + log.Fatalf("no account %q in this console", *from) |
| + } |
| + src = a.ID |
| + case len(holders) == 1: |
| + src = holders[0] |
| + default: |
| + // Several accounts claim this name — legitimate, since names are |
| + // unique per account rather than globally. Picking one would re-home |
| + // the wrong tenant's app, so say which ones and stop. |
| + var names []string |
| + for _, id := range holders { |
| + if a, _ := st.AccountByID(id); a != nil { |
| + names = append(names, a.Sqid+" ("+a.Name+")") |
| + } |
| + } |
| + log.Fatalf("%q is claimed by several accounts — pass -from <sqid>: %s", *app, strings.Join(names, ", ")) |
| + } |
| + if err := st.MoveApp(*app, src, dst.ID); err != nil { |
| + log.Fatal(err) |
| + } |
| + fmt.Printf("moved %s to %s (%s)\n", *app, *to, dst.Name) |
| } |
| func serve() { |
diff --git a/cmd/console/main_test.go b/cmd/console/main_test.go| new file mode 100644 |
| index 000000000..706d5d22c |
| --- /dev/null |
| +++ b/cmd/console/main_test.go |
| @@ -0,0 +1,106 @@ |
| +package main_test |
| + |
| +import ( |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/console" |
| +) |
| + |
| +// migrate-app is the CONSOLE half of `carlos accounts migrate`. It lives here |
| +// rather than in the carlos binary on purpose: the console's app→account row |
| +// is console state, and the carlos CLI has no business opening the console's |
| +// database — box-side authority changes go through this binary, exactly like |
| +// `console grant`. |
| +func TestMigrateAppVerb(t *testing.T) { |
| + goBin, err := exec.LookPath("go") |
| + if err != nil { |
| + t.Skip("go toolchain not found") |
| + } |
| + tmp := t.TempDir() |
| + bin := filepath.Join(tmp, "console") |
| + build := exec.Command(goBin, "build", "-o", bin, ".") |
| + build.Env = os.Environ() |
| + if out, err := build.CombinedOutput(); err != nil { |
| + t.Fatalf("build console: %v\n%s", err, out) |
| + } |
| + dbPath := filepath.Join(tmp, "console.db") |
| + env := append(os.Environ(), "CONSOLE_DB="+dbPath) |
| + run := func(args ...string) (string, error) { |
| + t.Helper() |
| + cmd := exec.Command(bin, args...) |
| + cmd.Env = env |
| + out, err := cmd.CombinedOutput() |
| + return string(out), err |
| + } |
| + |
| + // A console in the shape a deployment is in before the migration: ops |
| + // owns kass. |
| + st, err := console.Open(dbPath) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + now := time.Now() |
| + if err := st.CreateAccount(console.OpsAccountID, "bab", "ops", "paul@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := st.CreateAccount(793481920157, "kqmvexample", "Kass", "paul@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := st.CreateAppIn(console.OpsAccountID, "kass", "paul@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + st.Close() |
| + |
| + // Unknown app and unknown account are refused, clearly, before anything |
| + // is written. |
| + if out, err := run("migrate-app", "-app", "ghost", "-to", "kqmvexample"); err == nil { |
| + t.Errorf("migrating an unknown app succeeded: %s", out) |
| + } else if !strings.Contains(out, "ghost") { |
| + t.Errorf("unknown-app error does not name the app: %s", out) |
| + } |
| + if out, err := run("migrate-app", "-app", "kass", "-to", "nosuch"); err == nil { |
| + t.Errorf("migrating to an unknown account succeeded: %s", out) |
| + } else if !strings.Contains(out, "nosuch") { |
| + t.Errorf("unknown-account error does not name the account: %s", out) |
| + } |
| + |
| + // The move. |
| + if out, err := run("migrate-app", "-app", "kass", "-to", "kqmvexample"); err != nil { |
| + t.Fatalf("migrate-app: %v\n%s", err, out) |
| + } |
| + st, err = console.Open(dbPath) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + apps, err := st.AppsIn(793481920157) |
| + if err != nil || len(apps) != 1 || apps[0] != "kass" { |
| + t.Fatalf("AppsIn(Kass) = %v %v, want [kass]", apps, err) |
| + } |
| + if apps, _ := st.AppsIn(console.OpsAccountID); len(apps) != 0 { |
| + t.Errorf("app still in ops: %v", apps) |
| + } |
| + st.Close() |
| + |
| + // Idempotent: an operator who is not sure whether it landed can just run |
| + // it again. |
| + out, err := run("migrate-app", "-app", "kass", "-to", "kqmvexample") |
| + if err != nil { |
| + t.Fatalf("re-running migrate-app must be a no-op: %v\n%s", err, out) |
| + } |
| + if !strings.Contains(out, "already") { |
| + t.Errorf("re-run output does not say it was already done: %s", out) |
| + } |
| + st, err = console.Open(dbPath) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer st.Close() |
| + if apps, _ := st.AppsIn(793481920157); len(apps) != 1 { |
| + t.Errorf("re-run changed things: %v", apps) |
| + } |
| +} |
diff --git a/e2e_test.go b/e2e_test.go| index 3be8c847d..a7f5c497f 100644 |
| --- a/e2e_test.go |
| +++ b/e2e_test.go |
| @@ -723,6 +723,13 @@ func TestAccountsCLI(t *testing.T) { |
| !strings.Contains(out, "apps/"+sqid+"/demo/channels/stable.json") { |
| t.Fatalf("dry run did not report the plan:\n%s", out) |
| } |
| + // The move is not finished until the console's own app→account row moves, |
| + // and this binary deliberately will not reach into the console's DB to do |
| + // it — so it must tell the operator the exact command that does. |
| + wantFollowUp := "console migrate-app -app demo -to " + sqid |
| + if !strings.Contains(out, wantFollowUp) { |
| + t.Fatalf("dry run does not name the console follow-up %q:\n%s", wantFollowUp, out) |
| + } |
| after, err := store.List(ctx, "") |
| if err != nil { |
| t.Fatal(err) |
| @@ -733,7 +740,9 @@ func TestAccountsCLI(t *testing.T) { |
| // 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) |
| + if out := run("accounts", "migrate", "-registry", regPath, "-app", "demo", "-to", sqid); !strings.Contains(out, wantFollowUp) { |
| + t.Fatalf("migrate does not name the console follow-up %q:\n%s", wantFollowUp, out) |
| + } |
| for _, key := range []string{ |
| "apps/" + sqid + "/demo/releases/v1/manifest.json", |
| "apps/" + sqid + "/demo/releases/v1/demo", |
diff --git a/internal/console/account_test.go b/internal/console/account_test.go| index 7bd32d979..e15c1bef1 100644 |
| --- a/internal/console/account_test.go |
| +++ b/internal/console/account_test.go |
| @@ -220,6 +220,58 @@ func TestLastAccountRoundTrips(t *testing.T) { |
| } |
| } |
| +// The console half of `carlos accounts migrate`: the app→account row moves, |
| +// and re-running is a no-op rather than an error — an operator who is not |
| +// sure whether the move landed must be able to just run it again. |
| +func TestMoveAppBetweenAccounts(t *testing.T) { |
| + st := openTestStore(t) |
| + now := time.Now() |
| + if err := st.CreateAccount(OpsAccountID, opsSqid, "ops", "paul@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := st.CreateAccount(42, "cdef", "Kass", "paul@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := st.CreateAppIn(OpsAccountID, "kass", "paul@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + if got, err := st.AppAccounts("kass"); err != nil || len(got) != 1 || got[0] != OpsAccountID { |
| + t.Fatalf("AppAccounts = %v %v, want [1]", got, err) |
| + } |
| + if err := st.MoveApp("kass", OpsAccountID, 42); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got, _ := st.AppAccounts("kass"); len(got) != 1 || got[0] != 42 { |
| + t.Fatalf("after move AppAccounts = %v, want [42]", got) |
| + } |
| + if apps, _ := st.AppsIn(OpsAccountID); len(apps) != 0 { |
| + t.Errorf("app still in the source account: %v", apps) |
| + } |
| + // Idempotent: the row is already where it was asked to go. |
| + if err := st.MoveApp("kass", OpsAccountID, 42); err != nil { |
| + t.Fatalf("re-running the move must be a no-op: %v", err) |
| + } |
| + if got, _ := st.AppAccounts("kass"); len(got) != 1 || got[0] != 42 { |
| + t.Fatalf("re-run changed things: %v", got) |
| + } |
| + // Moving onto a name the destination already holds is refused, not |
| + // silently merged: two different apps would become one row. |
| + if _, err := st.CreateAppIn(OpsAccountID, "kass", "paul@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := st.MoveApp("kass", OpsAccountID, 42); err == nil { |
| + t.Error("moving onto an existing name must fail") |
| + } |
| + if got, _ := st.AppAccounts("kass"); len(got) != 2 { |
| + t.Errorf("refused move lost a row: %v", got) |
| + } |
| + // The app's other rows are untouched by a move of one of them. |
| + if got, err := st.AppAccounts("nosuchapp"); err != nil || len(got) != 0 { |
| + t.Errorf("AppAccounts(absent) = %v %v, want empty nil", got, err) |
| + } |
| +} |
| + |
| // Account names are deliberately not unique — identity is the sqid — and |
| // AccountsFor must order deterministically (creation order) so "your first |
| // membership" is a stable answer. |
diff --git a/internal/console/migrate_test.go b/internal/console/migrate_test.go| new file mode 100644 |
| index 000000000..6ab315691 |
| --- /dev/null |
| +++ b/internal/console/migrate_test.go |
| @@ -0,0 +1,107 @@ |
| +package console |
| + |
| +import ( |
| + "database/sql" |
| + "path/filepath" |
| + "testing" |
| + "time" |
| + |
| + _ "modernc.org/sqlite" |
| +) |
| + |
| +func nowForTest() time.Time { return time.Unix(1700000100, 0) } |
| + |
| +// The apps table is REBUILT on upgrade (SQLite cannot ALTER a primary key, |
| +// and "unique within an account" is not something PRIMARY KEY(app) can |
| +// express). A table rebuild is where data quietly dies, so this seeds the |
| +// pre-accounts shape by hand, re-opens through the real migration, and |
| +// asserts every row survived with every column intact. |
| +func TestAppsRebuildPreservesEveryRow(t *testing.T) { |
| + path := filepath.Join(t.TempDir(), "console.db") |
| + |
| + // The exact pre-multi-account shape: PRIMARY KEY(app), no account_id. |
| + raw, err := sql.Open("sqlite", path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := raw.Exec(`CREATE TABLE apps ( |
| + app TEXT PRIMARY KEY, |
| + created_by TEXT NOT NULL, |
| + created_at INTEGER NOT NULL |
| +);`); err != nil { |
| + t.Fatal(err) |
| + } |
| + seed := map[string][2]any{ |
| + "kass": {"paul@keymail.dev", int64(1700000000)}, |
| + "keymail": {"paul@keymail.dev", int64(1700000001)}, |
| + "woodstar": {"sam@keymail.dev", int64(1700000002)}, |
| + } |
| + for app, row := range seed { |
| + if _, err := raw.Exec(`INSERT INTO apps (app, created_by, created_at) VALUES (?, ?, ?)`, |
| + app, row[0], row[1]); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if err := raw.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + // Two opens: the rebuild must also be idempotent, so a console that |
| + // restarts twice does not rebuild twice (or lose anything on the second). |
| + for pass := 1; pass <= 2; pass++ { |
| + st, err := Open(path) |
| + if err != nil { |
| + t.Fatalf("pass %d: open: %v", pass, err) |
| + } |
| + rows, err := st.db.Query(`SELECT app, account_id, created_by, created_at FROM apps ORDER BY app`) |
| + if err != nil { |
| + t.Fatalf("pass %d: %v", pass, err) |
| + } |
| + got := map[string][3]any{} |
| + for rows.Next() { |
| + var app, by string |
| + var acct, at int64 |
| + if err := rows.Scan(&app, &acct, &by, &at); err != nil { |
| + t.Fatal(err) |
| + } |
| + got[app] = [3]any{acct, by, at} |
| + } |
| + rows.Close() |
| + st.Close() |
| + |
| + if len(got) != len(seed) { |
| + t.Fatalf("pass %d: %d rows survived, want %d: %+v", pass, len(got), len(seed), got) |
| + } |
| + for app, want := range seed { |
| + g, ok := got[app] |
| + if !ok { |
| + t.Errorf("pass %d: row %q vanished in the rebuild", pass, app) |
| + continue |
| + } |
| + // account_id defaults to 1 (ops): before accounts existed, every |
| + // app was in ops by definition. |
| + if g[0] != int64(OpsAccountID) { |
| + t.Errorf("pass %d: %s account_id = %v, want %d", pass, app, g[0], OpsAccountID) |
| + } |
| + if g[1] != want[0] { |
| + t.Errorf("pass %d: %s created_by = %v, want %v", pass, app, g[1], want[0]) |
| + } |
| + if g[2] != want[1] { |
| + t.Errorf("pass %d: %s created_at = %v, want %v", pass, app, g[2], want[1]) |
| + } |
| + } |
| + } |
| + |
| + // And the point of the rebuild: the same name in two accounts. |
| + st, err := Open(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer st.Close() |
| + if err := st.CreateAccount(42, "cdef", "Other", "sam@keymail.dev", nowForTest()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if created, err := st.CreateAppIn(42, "kass", "sam@keymail.dev", nowForTest()); err != nil || !created { |
| + t.Fatalf("same name in another account = %v %v, want it to be free", created, err) |
| + } |
| +} |
diff --git a/internal/console/store.go b/internal/console/store.go| index e9cf5a966..2d2d19280 100644 |
| --- a/internal/console/store.go |
| +++ b/internal/console/store.go |
| @@ -703,11 +703,61 @@ func (s *Store) HasAppIn(accountID int64, app string) (bool, error) { |
| return n > 0, err |
| } |
| -// SetAppAccount re-homes a claimed app onto another account — the console |
| -// half of `carlos accounts migrate`. |
| -func (s *Store) SetAppAccount(app string, accountID int64) error { |
| - _, err := s.db.Exec(`UPDATE apps SET account_id = ? WHERE app = ?`, accountID, app) |
| - return err |
| +// AppAccounts lists the accounts that claim an app NAME, ascending. Usually |
| +// one; several is legitimate (names are unique per account, not globally) and |
| +// is what makes MoveApp require an explicit source. |
| +func (s *Store) AppAccounts(app string) ([]int64, error) { |
| + rows, err := s.db.Query(`SELECT account_id FROM apps WHERE app = ? ORDER BY account_id`, app) |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer rows.Close() |
| + var out []int64 |
| + for rows.Next() { |
| + var id int64 |
| + if err := rows.Scan(&id); err != nil { |
| + return nil, err |
| + } |
| + out = append(out, id) |
| + } |
| + return out, rows.Err() |
| +} |
| + |
| +// MoveApp re-homes a claimed app from one account to another — the console |
| +// half of `carlos accounts migrate`, which moves the bucket objects and the |
| +// registry routes but deliberately never opens this database. |
| +// |
| +// Idempotent: if the row is already at the destination there is nothing to |
| +// do, so an operator who is unsure whether the move landed can simply run it |
| +// again. Moving onto a name the destination already claims is REFUSED rather |
| +// than merged — those are two different apps, and one of them would vanish. |
| +func (s *Store) MoveApp(app string, from, to int64) error { |
| + tx, err := s.db.Begin() |
| + if err != nil { |
| + return err |
| + } |
| + defer tx.Rollback() |
| + var atDest, atSrc int |
| + if err := tx.QueryRow(`SELECT COUNT(*) FROM apps WHERE app = ? AND account_id = ?`, app, to).Scan(&atDest); err != nil { |
| + return err |
| + } |
| + if err := tx.QueryRow(`SELECT COUNT(*) FROM apps WHERE app = ? AND account_id = ?`, app, from).Scan(&atSrc); err != nil { |
| + return err |
| + } |
| + if atDest > 0 { |
| + if atSrc > 0 { |
| + return fmt.Errorf("console: account %d already claims %q — moving onto it would merge two different apps", to, app) |
| + } |
| + return nil // already moved |
| + } |
| + if atSrc == 0 { |
| + return fmt.Errorf("console: account %d does not claim %q", from, app) |
| + } |
| + if _, err := tx.Exec(`UPDATE apps SET account_id = ? WHERE app = ? AND account_id = ?`, |
| + to, app, from); err != nil { |
| + return err |
| + } |
| + return tx.Commit() |
| } |
| // HasApp reports whether an app name is claimed in the apps table. |
diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go| index a633d2419..953a0e730 100644 |
| --- a/internal/registry/registry_test.go |
| +++ b/internal/registry/registry_test.go |
| @@ -293,6 +293,108 @@ func TestAccountNamesNeedNotBeUnique(t *testing.T) { |
| } |
| } |
| +// The accounts table is DROPPED and rebuilt on upgrade — the one deliberate |
| +// exception to additive-only migrations, because its legacy rows carry the |
| +// assigned ids this whole change exists to stop trusting, and its |
| +// UNIQUE(name) would refuse two accounts both called "Blog". A drop is where |
| +// data quietly dies, so this pins exactly what survives and what does not: |
| +// the ROUTES (the registry's actual data) are untouched, ops comes back as |
| +// id 1 / sqid bab, and re-opening is clean and idempotent. |
| +func TestAccountsRebuildKeepsRoutesAndRestoresOps(t *testing.T) { |
| + path := filepath.Join(t.TempDir(), "registry.db") |
| + |
| + // A registry in the pre-multi-account shape: the legacy accounts table |
| + // (AUTOINCREMENT id, UNIQUE name) with rows, plus real route data. |
| + first, err := Open(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := first.sql.Exec(`DROP TABLE accounts`); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := first.sql.Exec(`CREATE TABLE accounts ( |
| + id INTEGER PRIMARY KEY AUTOINCREMENT, |
| + name TEXT UNIQUE NOT NULL |
| +);`); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, name := range []string{"ops", "paul@keymail.dev"} { |
| + if _, err := first.sql.Exec(`INSERT INTO accounts (name) VALUES (?)`, name); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + routes := []Route{ |
| + {Host: "kass.training", Kind: "instance", App: "kass", Channel: "stable", |
| + Version: "v1", Addr: "127.0.0.1:9000", DBPath: "/data/kass.db", Hibernate: true}, |
| + {Host: "site.example.com", Kind: "static", App: "site", Channel: "canary/x", Status: "ready"}, |
| + } |
| + for _, rt := range routes { |
| + if err := first.Upsert(rt); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + first.Close() |
| + |
| + // Two opens: the rebuild must be idempotent, so restarting twice neither |
| + // rebuilds twice nor loses anything the second time. |
| + for pass := 1; pass <= 2; pass++ { |
| + r, err := Open(path) |
| + if err != nil { |
| + t.Fatalf("pass %d: open over the legacy shape: %v", pass, err) |
| + } |
| + id, name, err := r.AccountBySqid("bab") |
| + if err != nil || id != 1 || name != "ops" { |
| + t.Errorf("pass %d: ops = %d/%q (%v), want 1/ops", pass, id, name, err) |
| + } |
| + accts, err := r.Accounts() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + // The legacy assigned-id rows are deliberately NOT carried across: |
| + // they are the bug. Everything but ops re-materializes from the |
| + // bucket's own manifests on the next reconcile pass. |
| + for _, a := range accts { |
| + if a.Name == "paul@keymail.dev" { |
| + t.Errorf("pass %d: a legacy assigned-id row survived: %+v", pass, a) |
| + } |
| + } |
| + // The routes — the registry's actual data — are untouched. |
| + got, err := r.List() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(got) != len(routes) { |
| + t.Fatalf("pass %d: %d routes survived, want %d", pass, len(got), len(routes)) |
| + } |
| + byHost := map[string]Route{} |
| + for _, rt := range got { |
| + byHost[rt.Host] = rt |
| + } |
| + for _, want := range routes { |
| + g, ok := byHost[want.Host] |
| + if !ok { |
| + t.Errorf("pass %d: route %s vanished", pass, want.Host) |
| + continue |
| + } |
| + if g.App != want.App || g.Channel != want.Channel || g.Version != want.Version || |
| + g.Addr != want.Addr || g.DBPath != want.DBPath || g.Hibernate != want.Hibernate || |
| + g.Kind != want.Kind { |
| + t.Errorf("pass %d: route %s lost fields: %+v, want %+v", pass, want.Host, g, want) |
| + } |
| + } |
| + // A materialized account survives the SECOND open: only the legacy |
| + // shape is ever dropped, and it is gone after pass 1. |
| + if pass == 1 { |
| + if err := r.PutAccount(793481920157, "kqmvexample", "Kass"); err != nil { |
| + t.Fatal(err) |
| + } |
| + } else if id, _, _ := r.AccountBySqid("kqmvexample"); id != 793481920157 { |
| + t.Errorf("pass 2 dropped an account materialized after the rebuild (id %d)", id) |
| + } |
| + r.Close() |
| + } |
| +} |
| + |
| func TestDomainColumnsRoundTrip(t *testing.T) { |
| reg := openTestRegistry(t) |
| r := Route{Host: "kass.abs.oncarlos.com", Kind: "instance", App: "kass", |