carlos system legacy-routes assign (Plan 8 Task 5)
The operator door that records which account and app own a legacy route name (seeded-instances spec §6.4, G17). Operator-only (apiSystemRoller), closed to every service credential, and refused on a host-claim console with SD-N8 before it reads anything (preflight M4). The file is read through the swap's strict reader and written by CAS on its etag (create-only when absent); a lost race answers SD-N116. Re-assignment is refused while the old mapping has an import in ready, swapped or placed; the check lists strictly (preflight M7), so a read failure or a listing past the cap answers SD-N107 and an undecodable record counts as busy. Claim inventories: claim-routes.txt, a refused fixture (SD-N8), and svcroutes_test's closed doors. The assignment file is not added to the mutation classifier as non-app (preflight M4: it widens app ownership). Wires SD-N102 to SD-N110 and SD-N116.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
12 files changed,
+690
−4
approved_seeded_copy_test.go+3 −2docs/site/cli.md+8 −0internal/console/apilegacyroutes.go+171 −0internal/console/apilegacyroutes_test.go+426 −0internal/console/authcli.go+2 −0internal/console/claim_invariant_fixtures_test.go+10 −0internal/console/claim_invariant_test.go+1 −1internal/console/svcroutes_test.go+4 −0internal/console/testdata/claim-routes.txt+1 −0system.go+6 −1system_legacy_routes.go+48 −0system_legacy_routes_test.go+10 −0
diff --git a/approved_seeded_copy_test.go b/approved_seeded_copy_test.go| index 01bc28133..2c5bd193b 100644 |
| --- a/approved_seeded_copy_test.go |
| +++ b/approved_seeded_copy_test.go |
| @@ -38,7 +38,7 @@ var seededCopyIDs = func() []string { |
| // Plan 6 Task 1 SD-N48 to SD-N50, SD-N62 and SD-N67; Plan 6 Task 2 SD-N51 to SD-N53, SD-N59, SD-N61 and SD-N63; |
| // Plan 6 Task 3 SD-N54, SD-N55, SD-N60, SD-N64, SD-N65 and SD-N115; Plan 6 Task 5 SD-N56 to SD-N58 and SD-N66; |
| // Plan 6 Task 6 SD-N68 to SD-N82, SD-N89 and SD-N90; Plan 7 Task 3 SD-N91 to SD-N98; Plan 7 Task 6 SD-N99; |
| -// Plan 8 Task 3 SD-N100 and SD-N101. |
| +// Plan 8 Task 3 SD-N100 and SD-N101; Plan 8 Task 5 SD-N102 to SD-N110 and SD-N116. |
| var seededWiredIDs = []string{"SD-N1", "SD-N2", "SD-N3", "SD-N4", "SD-N5", "SD-N6", "SD-N7", "SD-N8", "SD-N113", "SD-N9", "SD-N10", "SD-N11", "SD-N12", "SD-N13", "SD-N111", "SD-N112", "SD-N14", "SD-N15", "SD-N16", "SD-N17", "SD-N18", "SD-N19", "SD-N27", "SD-N28", "SD-N29", |
| "SD-N20", "SD-N21", "SD-N22", "SD-N23", "SD-N24", "SD-N25", "SD-N26", "SD-N30", "SD-N31", "SD-N32", "SD-N33", "SD-N34", "SD-N35", "SD-N36", "SD-N37", "SD-N38", "SD-N39", "SD-N41", |
| "SD-N40", "SD-N42", "SD-N43", "SD-N44", "SD-N45", "SD-N46", "SD-N47", "SD-N114", "SD-N83", "SD-N84", "SD-N85", "SD-N86", "SD-N87", "SD-N88", |
| @@ -50,7 +50,8 @@ var seededWiredIDs = []string{"SD-N1", "SD-N2", "SD-N3", "SD-N4", "SD-N5", "SD-N |
| "SD-N80", "SD-N81", "SD-N82", "SD-N89", "SD-N90", |
| "SD-N91", "SD-N92", "SD-N93", "SD-N94", "SD-N95", "SD-N96", "SD-N97", "SD-N98", |
| "SD-N99", |
| - "SD-N100", "SD-N101"} |
| + "SD-N100", "SD-N101", |
| + "SD-N102", "SD-N103", "SD-N104", "SD-N105", "SD-N106", "SD-N107", "SD-N108", "SD-N109", "SD-N110", "SD-N116"} |
| func mustParseSeededRecord(t *testing.T) []copyRow { |
| t.Helper() |
diff --git a/docs/site/cli.md b/docs/site/cli.md| index bd431e15c..324dab10d 100644 |
| --- a/docs/site/cli.md |
| +++ b/docs/site/cli.md |
| @@ -2055,6 +2055,14 @@ carlos system seeds-floor publish sets the first release whose boxes can place a |
| carlos system seeds-floor publish --min-release v0.27.0 |
| ``` |
| +#### Legacy route assignments |
| + |
| +carlos system legacy-routes assign says which account and app the routes an operator filed by hand belong to. A route added with carlos add in direct mode is filed under the operator account, so its app name alone does not say whose it is. An import takes over such a route only when an assignment maps its name to the importing app. A name has one assignment, and it cannot change while an import under it is between its swap and its commit. |
| + |
| +```sh |
| +carlos system legacy-routes assign --name shop --to bac/shop |
| +``` |
| + |
| ## On the host |
| These are what systemd starts. You will rarely type them, but knowing what |
diff --git a/internal/console/apilegacyroutes.go b/internal/console/apilegacyroutes.go| new file mode 100644 |
| index 000000000..7c0e51057 |
| --- /dev/null |
| +++ b/internal/console/apilegacyroutes.go |
| @@ -0,0 +1,171 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "log" |
| + "net/http" |
| + "strings" |
| + |
| + "github.com/carlosframework/platform/internal/controlname" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/seeds" |
| +) |
| + |
| +// errLegacyRouteBusy: the name's current assignment has an import between |
| +// ready and committed, so the assignment cannot change (spec §6.4). |
| +var errLegacyRouteBusy = errors.New("an import is in flight under the current assignment") |
| + |
| +// errLegacyRoutesUnread: the assignments, or the records the in-flight |
| +// check needs, could not be read completely. Never "no assignment" and |
| +// never "idle". |
| +var errLegacyRoutesUnread = errors.New("could not read the legacy route assignments") |
| + |
| +// handleAPISystemLegacyRoutes is `carlos system legacy-routes assign` |
| +// (seeded-instances spec §6.4, codex 9): an operator maps an app name, as |
| +// filed under the operator account, to one account and app. The swap reads |
| +// the file through seeds.ReadLegacyOwners. The update doors' authority: an |
| +// ops account owner or an address on CARLOS_SYSTEM_OPERATORS |
| +// (apiSystemRoller); the route is closed to every service credential. |
| +func (s *Server) handleAPISystemLegacyRoutes(w http.ResponseWriter, r *http.Request) { |
| + addr, _, ok := s.apiSystemRoller(w, r) |
| + if !ok { |
| + return |
| + } |
| + // The assignment widens what an import may take over, and the claim |
| + // protocol has no place for seeded instances yet (#250): a claim |
| + // console refuses before it reads anything (preflight M4). |
| + if s.cfg.HostClaim { |
| + ref := seedClaimConsoleRefusal() |
| + jsonError(w, ref.Status, ref.Msg) |
| + return |
| + } |
| + var req struct { |
| + Name string `json:"name"` |
| + To string `json:"to"` |
| + } |
| + if !readJSON(w, r, &req) { |
| + return |
| + } |
| + name := strings.TrimSpace(req.Name) |
| + if !release.ValidApp(name) { |
| + jsonError(w, http.StatusBadRequest, "name must be an app name") |
| + return |
| + } |
| + acctSqid, app, found := strings.Cut(strings.TrimSpace(req.To), "/") |
| + if !found || !controlname.ValidAccount(acctSqid) || !release.ValidApp(app) { |
| + jsonError(w, http.StatusBadRequest, "to must be <account>/<app>") |
| + return |
| + } |
| + acct, err := s.store.AccountBySqid(acctSqid) |
| + if err != nil { |
| + log.Printf("console: legacy routes: account %s: %v", acctSqid, err) |
| + } |
| + if err != nil || acct == nil { |
| + jsonError(w, http.StatusBadRequest, "to must be <account>/<app>") |
| + return |
| + } |
| + has, err := s.store.HasAppIn(acct.ID, app) |
| + if err != nil { |
| + log.Printf("console: legacy routes: has-app %s/%s: %v", acct.Sqid, app, err) |
| + } |
| + if err != nil || !has { |
| + jsonError(w, http.StatusBadRequest, "to must be <account>/<app>") |
| + return |
| + } |
| + ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) |
| + defer cancel() |
| + want := seeds.LegacyOwner{Account: acct.Sqid, App: app} |
| + changed, err := assignLegacyRoute(ctx, s.cfg.Releases, name, want) |
| + switch { |
| + case errors.Is(err, errLegacyRouteBusy): |
| + jsonError(w, http.StatusConflict, fmt.Sprintf("%s has an import in flight under its current assignment. Commit or abort it first.", name)) |
| + return |
| + case errors.Is(err, errLegacyRoutesUnread): |
| + log.Printf("console: legacy routes: %v", err) |
| + jsonError(w, http.StatusBadGateway, "could not read the legacy route assignments. Try again.") |
| + return |
| + case err != nil: |
| + if !errors.Is(err, release.ErrCASConflict) { |
| + log.Printf("console: legacy routes: %v", err) |
| + } |
| + jsonError(w, http.StatusBadGateway, "could not save the legacy route assignment. Try again.") |
| + return |
| + } |
| + if changed { |
| + s.auditSystem(addr, "legacy-route-assign", fmt.Sprintf("assigned the legacy routes named %s to %s/%s", name, want.Account, want.App)) |
| + s.bumpSerial(ctx) |
| + } |
| + writeJSON(w, http.StatusOK, map[string]string{"name": name, "account": want.Account, "app": want.App}) |
| +} |
| + |
| +// assignLegacyRoute maps name to want in the assignment file. It reads the |
| +// file through the swap's own strict reader, and writes it by CAS on the |
| +// etag that read returned (create-only when the file was absent), so two |
| +// assigns at once never lose one: the loser gets release.ErrCASConflict and |
| +// nothing is retried behind the operator's back. The same mapping again is |
| +// no write at all (changed false). |
| +// |
| +// The in-flight check and the write are not one atomic step: an import of |
| +// the old mapping can reach ready between them. The swap then compares |
| +// against the new entry and refuses the row (SD-N94), which fails closed. |
| +func assignLegacyRoute(ctx context.Context, rs release.Store, name string, want seeds.LegacyOwner) (changed bool, err error) { |
| + owners, etag, err := seeds.ReadLegacyOwners(ctx, rs) |
| + if err != nil { |
| + return false, fmt.Errorf("%w: %w", errLegacyRoutesUnread, err) |
| + } |
| + if cur, ok := owners.Lookup(name); ok { |
| + if cur == want { |
| + return false, nil |
| + } |
| + busy, err := importInFlight(ctx, rs, cur) |
| + if err != nil { |
| + return false, fmt.Errorf("%w: in-flight check of %s/%s: %w", errLegacyRoutesUnread, cur.Account, cur.App, err) |
| + } |
| + if busy { |
| + return false, errLegacyRouteBusy |
| + } |
| + } |
| + owners.Names[name] = want |
| + body, err := json.Marshal(owners) |
| + if err != nil { |
| + return false, err |
| + } |
| + if _, err := rs.Put(ctx, seeds.LegacyOwnersKey, body, etag); err != nil { |
| + return false, fmt.Errorf("write %s: %w", seeds.LegacyOwnersKey, err) |
| + } |
| + return true, nil |
| +} |
| + |
| +// importInFlight reports whether any record of o's account and app holds an |
| +// import between ready and committed (ready, swapped or placed; spec §6.4). |
| +// It reads strictly (provision.ListRecordsStrict, preflight M7): a listing |
| +// it cannot complete, or one past instanceListMax, is an error, and a record |
| +// that reads but will not decode counts as busy, since it could be such an |
| +// import. An entry the strict reader let through with a name no record path |
| +// can hold is an error too: no listing of it means anything. |
| +func importInFlight(ctx context.Context, rs release.Store, o seeds.LegacyOwner) (bool, error) { |
| + if !controlname.ValidAccount(o.Account) || !release.ValidApp(o.App) { |
| + return false, fmt.Errorf("the assignment names %q/%q, which is not an account and app", o.Account, o.App) |
| + } |
| + recs, unreadable, err := provision.ListRecordsStrict(ctx, rs, o.Account, o.App, instanceListMax) |
| + if err != nil { |
| + return false, err |
| + } |
| + if len(unreadable) > 0 { |
| + return true, nil |
| + } |
| + for _, rec := range recs { |
| + if !rec.Seed.Import() { |
| + continue |
| + } |
| + switch rec.Seed.State { |
| + case seeds.StateReady, seeds.StateSwapped, seeds.StatePlaced: |
| + return true, nil |
| + } |
| + } |
| + return false, nil |
| +} |
diff --git a/internal/console/apilegacyroutes_test.go b/internal/console/apilegacyroutes_test.go| new file mode 100644 |
| index 000000000..cd8e684ac |
| --- /dev/null |
| +++ b/internal/console/apilegacyroutes_test.go |
| @@ -0,0 +1,426 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + "net/http" |
| + "strings" |
| + "sync" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/seeds" |
| + "github.com/carlosframework/platform/internal/svcrole" |
| +) |
| + |
| +// legacyFx is a console whose ops account owner is paul@keymail.dev, with a |
| +// second account (sqid "cdef", not the operator account) holding the apps |
| +// shop and other. |
| +type legacyFx struct { |
| + *consoleFixture |
| + owner string |
| + url string |
| +} |
| + |
| +const legacyOtherID = 4242 |
| + |
| +func legacyFixture(t *testing.T, code string) *legacyFx { |
| + t.Helper() |
| + fx := newConsoleFixture(t) |
| + now := time.Now() |
| + if err := fx.st.CreateAccount(legacyOtherID, "cdef", "Other", "someone@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, app := range []string{"shop", "other"} { |
| + if _, err := fx.st.CreateAppIn(legacyOtherID, app, "someone@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + return &legacyFx{consoleFixture: fx, owner: cliToken(t, fx, "paul@keymail.dev", code), url: fx.ts.URL + "/api/cli/system/legacy-routes"} |
| +} |
| + |
| +func (fx *legacyFx) assign(t *testing.T, name, to string) (int, map[string]any) { |
| + t.Helper() |
| + return postJSON(t, fx.url, map[string]any{"name": name, "to": to}, fx.owner) |
| +} |
| + |
| +// legacyImportRecord files an import record of cdef/shop in state. |
| +func legacyImportRecord(t *testing.T, rs release.Store, host, state string) { |
| + t.Helper() |
| + id, err := seeds.NewID() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + now := time.Now().UTC() |
| + rec := provision.Record{Schema: 1, Host: host, Slug: strings.Split(host, ".")[0], App: "shop", Account: "cdef", Channel: "stable", |
| + CreatedAt: now, Seed: &provision.SeedSource{ID: id, Kind: seeds.KindImport, State: state, By: "t", At: now}} |
| + if err := provision.CreateRecord(context.Background(), rs, rec); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func readOwner(t *testing.T, rs release.Store, name string) (seeds.LegacyOwner, bool) { |
| + t.Helper() |
| + o, _, err := seeds.ReadLegacyOwners(context.Background(), rs) |
| + if err != nil { |
| + t.Fatalf("the strict reader refused the file: %v", err) |
| + } |
| + return o.Lookup(name) |
| +} |
| + |
| +func TestAssignLegacyRoutes(t *testing.T) { |
| + fx := legacyFixture(t, "code-assign-owner") |
| + for _, bad := range []string{"Not An App!", "", "a/b"} { |
| + if st, out := fx.assign(t, bad, "cdef/shop"); st != http.StatusBadRequest || str(t, out, "error") != "name must be an app name" { |
| + t.Fatalf("bad name %q = %d %v", bad, st, out) |
| + } |
| + } |
| + for _, bad := range []string{"cdef", "cdef/", "/shop", "cdef/Not An App", "zzzz/shop", "cdef/missing", "cdef/shop/x"} { |
| + if st, out := fx.assign(t, "shop", bad); st != http.StatusBadRequest || str(t, out, "error") != "to must be <account>/<app>" { |
| + t.Fatalf("bad to %q = %d %v", bad, st, out) |
| + } |
| + } |
| + if _, _, err := fx.rel.Get(context.Background(), seeds.LegacyOwnersKey); err == nil { |
| + t.Fatal("a refused assign wrote the file") |
| + } |
| + |
| + st, out := fx.assign(t, "shop", "cdef/shop") |
| + if st != http.StatusOK || out["name"] != "shop" || out["account"] != "cdef" || out["app"] != "shop" { |
| + t.Fatalf("assign = %d %v", st, out) |
| + } |
| + if got, ok := readOwner(t, fx.rel, "shop"); !ok || got != (seeds.LegacyOwner{Account: "cdef", App: "shop"}) { |
| + t.Fatalf("file = %+v %v", got, ok) |
| + } |
| + raw, etag, err := fx.rel.Get(context.Background(), seeds.LegacyOwnersKey) |
| + if err != nil || string(raw) != `{"v":1,"names":{"shop":{"account":"cdef","app":"shop"}}}` { |
| + t.Fatalf("the file is %q, %v; want exactly the strict reader's shape", raw, err) |
| + } |
| + |
| + // The same mapping again is a no-op: 200, the file untouched. |
| + if st, out := fx.assign(t, "shop", "cdef/shop"); st != http.StatusOK || out["account"] != "cdef" { |
| + t.Fatalf("again = %d %v", st, out) |
| + } |
| + if _, etag2, _ := fx.rel.Get(context.Background(), seeds.LegacyOwnersKey); etag2 != etag { |
| + t.Fatal("the same mapping again rewrote the file") |
| + } |
| + |
| + // A second name joins the first, and nothing is lost. |
| + if st, out := fx.assign(t, "other", "cdef/other"); st != http.StatusOK { |
| + t.Fatalf("second name = %d %v", st, out) |
| + } |
| + if got, ok := readOwner(t, fx.rel, "shop"); !ok || got.App != "shop" { |
| + t.Fatalf("the first name went missing: %+v", got) |
| + } |
| + if got, ok := readOwner(t, fx.rel, "other"); !ok || got != (seeds.LegacyOwner{Account: "cdef", App: "other"}) { |
| + t.Fatalf("second name = %+v %v", got, ok) |
| + } |
| + |
| + // Re-assigning with no import in flight replaces the entry. |
| + if st, out := fx.assign(t, "shop", "cdef/other"); st != http.StatusOK || out["app"] != "other" { |
| + t.Fatalf("re-assign = %d %v", st, out) |
| + } |
| + if got, _ := readOwner(t, fx.rel, "shop"); got.App != "other" { |
| + t.Fatalf("re-assign left %+v", got) |
| + } |
| + |
| + events, err := fx.st.Events(OpsAccountID, "platform", 10) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var n int |
| + for _, e := range events { |
| + if e.Action == "legacy-route-assign" { |
| + n++ |
| + if e.Address != "paul@keymail.dev" { |
| + t.Errorf("audit row by %q", e.Address) |
| + } |
| + } |
| + } |
| + if n != 3 { // shop, other, shop again; the no-op writes no row |
| + t.Fatalf("legacy-route-assign rows = %d, want 3: %+v", n, events) |
| + } |
| + var saw bool |
| + for _, e := range events { |
| + if e.Action == "legacy-route-assign" && e.Detail == "assigned the legacy routes named shop to cdef/other" { |
| + saw = true |
| + } |
| + } |
| + if !saw { |
| + t.Fatalf("no audit row names the re-assignment: %+v", events) |
| + } |
| +} |
| + |
| +func TestAssignRefusedWhileAnImportIsInFlight(t *testing.T) { |
| + for _, c := range []struct { |
| + state string |
| + busy bool |
| + }{ |
| + {seeds.StateAwaitingUpload, false}, |
| + {seeds.StateUploaded, false}, |
| + {seeds.StateReady, true}, |
| + {seeds.StateSwapped, true}, |
| + {seeds.StatePlaced, true}, |
| + {seeds.StateCommitted, false}, |
| + {seeds.StateRefused, false}, |
| + {seeds.StateAborted, false}, |
| + {seeds.StateRolledBack, false}, |
| + } { |
| + t.Run(c.state, func(t *testing.T) { |
| + fx := legacyFixture(t, "code-assign-busy-"+c.state) |
| + if st, out := fx.assign(t, "shop", "cdef/shop"); st != http.StatusOK { |
| + t.Fatalf("assign = %d %v", st, out) |
| + } |
| + legacyImportRecord(t, fx.rel, "old.example.com", c.state) |
| + st, out := fx.assign(t, "shop", "cdef/other") |
| + if !c.busy { |
| + if st != http.StatusOK { |
| + t.Fatalf("re-assign past an import in %s = %d %v", c.state, st, out) |
| + } |
| + return |
| + } |
| + if st != http.StatusConflict || str(t, out, "error") != "shop has an import in flight under its current assignment. Commit or abort it first." { |
| + t.Fatalf("re-assign = %d %v", st, out) |
| + } |
| + if got, _ := readOwner(t, fx.rel, "shop"); got.App != "shop" { |
| + t.Fatalf("the file changed: %+v", got) |
| + } |
| + // The same mapping is still a no-op while busy. |
| + if st, out := fx.assign(t, "shop", "cdef/shop"); st != http.StatusOK { |
| + t.Fatalf("the same mapping while busy = %d %v", st, out) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// A record that reads but will not decode could be an import between ready |
| +// and committed: it counts as busy (preflight M7). |
| +func TestAssignCountsAnUndecodableRecordAsBusy(t *testing.T) { |
| + fx := legacyFixture(t, "code-assign-garbled") |
| + if st, out := fx.assign(t, "shop", "cdef/shop"); st != http.StatusOK { |
| + t.Fatalf("assign = %d %v", st, out) |
| + } |
| + if _, err := fx.rel.Put(context.Background(), provision.RecordKey("cdef", "shop", "old.example.com"), []byte("{not json"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if st, out := fx.assign(t, "shop", "cdef/other"); st != http.StatusConflict || !strings.Contains(str(t, out, "error"), "has an import in flight") { |
| + t.Fatalf("re-assign past an undecodable record = %d %v", st, out) |
| + } |
| +} |
| + |
| +// A file the strict reader refuses is never read as "no assignment". |
| +func TestAssignRefusesAnUnreadableFile(t *testing.T) { |
| + fx := legacyFixture(t, "code-assign-unreadable") |
| + if _, err := fx.rel.Put(context.Background(), seeds.LegacyOwnersKey, []byte(`{"v":1,"names":{"shop":{"account":"cdef"}}}`), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if st, out := fx.assign(t, "shop", "cdef/shop"); st != http.StatusBadGateway || str(t, out, "error") != "could not read the legacy route assignments. Try again." { |
| + t.Fatalf("assign over an unreadable file = %d %v", st, out) |
| + } |
| + raw, _, _ := fx.rel.Get(context.Background(), seeds.LegacyOwnersKey) |
| + if string(raw) != `{"v":1,"names":{"shop":{"account":"cdef"}}}` { |
| + t.Fatalf("the unreadable file was overwritten: %q", raw) |
| + } |
| +} |
| + |
| +func TestLegacyRoutesDoorIsForSystemOperators(t *testing.T) { |
| + fx := legacyFixture(t, "code-assign-op") |
| + if err := fx.st.AddMember(OpsAccountID, "member@keymail.dev", RoleMember, time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + member := cliToken(t, fx.consoleFixture, "member@keymail.dev", "code-assign-member") |
| + _, want := getRaw(t, fx.ts.URL+"/api/cli/system/no-such-door", member) |
| + if st, body := postRaw(t, fx.url, map[string]string{"name": "shop", "to": "cdef/shop"}, member); st != http.StatusNotFound || body != want { |
| + t.Fatalf("a member = %d %q, want the bare 404", st, body) |
| + } |
| + // The owner of the target account is no operator either. |
| + someone := cliToken(t, fx.consoleFixture, "someone@keymail.dev", "code-assign-someone") |
| + if st, body := postRaw(t, fx.url, map[string]string{"name": "shop", "to": "cdef/shop"}, someone); st != http.StatusNotFound || body != want { |
| + t.Fatalf("the target account's owner = %d %q, want the bare 404", st, body) |
| + } |
| + // A service credential, of the operator account or of the target app, |
| + // meets the closed door. |
| + for _, svc := range []string{ |
| + serviceToken(t, fx.st, OpsAccountID, "svc-instance", "", svcrole.Instance), |
| + serviceToken(t, fx.st, legacyOtherID, "svc-admin", "shop", svcrole.Admin), |
| + } { |
| + if st, body := postRaw(t, fx.url, map[string]string{"name": "shop", "to": "cdef/shop"}, svc); st != http.StatusForbidden { |
| + t.Fatalf("a service credential = %d %q, want 403", st, body) |
| + } |
| + } |
| + if _, _, err := fx.rel.Get(context.Background(), seeds.LegacyOwnersKey); err == nil { |
| + t.Fatal("a refused caller wrote the file") |
| + } |
| +} |
| + |
| +func TestAssignRefusesOnAClaimConsole(t *testing.T) { |
| + fx := legacyFixture(t, "code-assign-claim") |
| + fx.srv.cfg.HostClaim = true |
| + st, out := fx.assign(t, "shop", "cdef/shop") |
| + if st != http.StatusConflict || str(t, out, "error") != seedClaimConsoleRefusal().Msg { |
| + t.Fatalf("assign on a claim console = %d %v", st, out) |
| + } |
| + if _, _, err := fx.rel.Get(context.Background(), seeds.LegacyOwnersKey); err == nil { |
| + t.Fatal("a claim console wrote the file") |
| + } |
| +} |
| + |
| +// Concurrent assigns of different names: every one that answered 200 is in |
| +// the file, and every other answered the save refusal (SD-N116). A blind |
| +// write would answer 200 to both and keep one. |
| +func TestConcurrentAssignsLoseNoName(t *testing.T) { |
| + fx := legacyFixture(t, "code-assign-race") |
| + names := []string{"shop", "other", "cart", "blog", "mail", "docs"} |
| + for _, n := range names[2:] { |
| + if _, err := fx.st.CreateAppIn(legacyOtherID, n, "someone@keymail.dev", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + type result struct { |
| + name string |
| + st int |
| + msg string |
| + } |
| + results := make(chan result, len(names)) |
| + var wg sync.WaitGroup |
| + for _, n := range names { |
| + wg.Add(1) |
| + go func() { |
| + defer wg.Done() |
| + st, body := postRaw(t, fx.url, map[string]string{"name": n, "to": "cdef/" + n}, fx.owner) |
| + results <- result{n, st, body} |
| + }() |
| + } |
| + wg.Wait() |
| + close(results) |
| + o, _, err := seeds.ReadLegacyOwners(context.Background(), fx.rel) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + ok := 0 |
| + for r := range results { |
| + switch r.st { |
| + case http.StatusOK: |
| + ok++ |
| + if got, found := o.Lookup(r.name); !found || got != (seeds.LegacyOwner{Account: "cdef", App: r.name}) { |
| + t.Errorf("%s answered 200 but the file has %+v %v", r.name, got, found) |
| + } |
| + case http.StatusBadGateway: |
| + if !strings.Contains(r.msg, "could not save the legacy route assignment. Try again.") { |
| + t.Errorf("%s = %d %s", r.name, r.st, r.msg) |
| + } |
| + if _, found := o.Lookup(r.name); found { |
| + t.Errorf("%s was refused but is in the file", r.name) |
| + } |
| + default: |
| + t.Errorf("%s = %d %s", r.name, r.st, r.msg) |
| + } |
| + } |
| + if ok == 0 || len(o.Names) != ok { |
| + t.Fatalf("%d answered 200, the file holds %d: %+v", ok, len(o.Names), o.Names) |
| + } |
| +} |
| + |
| +// interleaveStore runs between once, on the first read of the assignment |
| +// file, before the caller's write: the shape of two assigns at once. |
| +type interleaveStore struct { |
| + release.Store |
| + once sync.Once |
| + between func() |
| +} |
| + |
| +func (s *interleaveStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + b, etag, err := s.Store.Get(ctx, key) |
| + if key == seeds.LegacyOwnersKey { |
| + s.once.Do(s.between) |
| + } |
| + return b, etag, err |
| +} |
| + |
| +func TestAssignLosesARaceByCAS(t *testing.T) { |
| + ctx := context.Background() |
| + for _, existing := range []bool{false, true} { |
| + t.Run(fmt.Sprintf("file present %v", existing), func(t *testing.T) { |
| + fx := legacyFixture(t, fmt.Sprintf("code-assign-cas-%v", existing)) |
| + if existing { |
| + if st, out := fx.assign(t, "docs", "cdef/shop"); st != http.StatusOK { |
| + t.Fatalf("seed the file = %d %v", st, out) |
| + } |
| + } |
| + rs := &interleaveStore{Store: fx.rel} |
| + rs.between = func() { |
| + if _, err := assignLegacyRoute(ctx, fx.rel, "other", seeds.LegacyOwner{Account: "cdef", App: "other"}); err != nil { |
| + t.Errorf("the racing assign: %v", err) |
| + } |
| + } |
| + changed, err := assignLegacyRoute(ctx, rs, "shop", seeds.LegacyOwner{Account: "cdef", App: "shop"}) |
| + if changed || !errors.Is(err, release.ErrCASConflict) { |
| + t.Fatalf("the losing assign = %v, want a CAS conflict", err) |
| + } |
| + if got, ok := readOwner(t, fx.rel, "other"); !ok || got.App != "other" { |
| + t.Fatalf("the winner was lost: %+v %v", got, ok) |
| + } |
| + if _, ok := readOwner(t, fx.rel, "shop"); ok { |
| + t.Fatal("the loser was written") |
| + } |
| + if existing { |
| + if _, ok := readOwner(t, fx.rel, "docs"); !ok { |
| + t.Fatal("an entry from before the race was lost") |
| + } |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// failingRecordStore fails every record read, or lists more record keys than |
| +// the cap, for the in-flight check's strict listing. |
| +type failingRecordStore struct { |
| + release.Store |
| + failGet bool |
| + many int |
| +} |
| + |
| +func (s *failingRecordStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + if s.failGet && strings.HasPrefix(key, provision.RecordsPrefix("cdef", "shop")) { |
| + return nil, "", errors.New("injected read failure") |
| + } |
| + return s.Store.Get(ctx, key) |
| +} |
| + |
| +func (s *failingRecordStore) List(ctx context.Context, prefix string) ([]string, error) { |
| + keys, err := s.Store.List(ctx, prefix) |
| + if s.many > 0 && prefix == provision.RecordsPrefix("cdef", "shop") { |
| + for i := range s.many { |
| + keys = append(keys, provision.RecordKey("cdef", "shop", fmt.Sprintf("h%d.example.com", i))) |
| + } |
| + } |
| + return keys, err |
| +} |
| + |
| +func TestAssignFailsClosedOnAnIncompleteListing(t *testing.T) { |
| + ctx := context.Background() |
| + for _, c := range []struct { |
| + name string |
| + rs func(release.Store) release.Store |
| + }{ |
| + {"a record that will not read", func(s release.Store) release.Store { return &failingRecordStore{Store: s, failGet: true} }}, |
| + {"more records than the cap", func(s release.Store) release.Store { return &failingRecordStore{Store: s, many: instanceListMax + 1} }}, |
| + } { |
| + t.Run(c.name, func(t *testing.T) { |
| + fx := legacyFixture(t, "code-assign-incomplete-"+strings.ReplaceAll(c.name, " ", "-")) |
| + if st, out := fx.assign(t, "shop", "cdef/shop"); st != http.StatusOK { |
| + t.Fatalf("assign = %d %v", st, out) |
| + } |
| + legacyImportRecord(t, fx.rel, "old.example.com", seeds.StateCommitted) |
| + changed, err := assignLegacyRoute(ctx, c.rs(fx.rel), "shop", seeds.LegacyOwner{Account: "cdef", App: "other"}) |
| + if changed || !errors.Is(err, errLegacyRoutesUnread) || errors.Is(err, errLegacyRouteBusy) { |
| + t.Fatalf("re-assign over an incomplete listing = %v, want unread and not busy", err) |
| + } |
| + if got, _ := readOwner(t, fx.rel, "shop"); got.App != "shop" { |
| + t.Fatalf("the file changed: %+v", got) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/console/authcli.go b/internal/console/authcli.go| index 8686e5a50..f80e0e8ba 100644 |
| --- a/internal/console/authcli.go |
| +++ b/internal/console/authcli.go |
| @@ -951,6 +951,8 @@ func (s *Server) registerCLIRoutes() { |
| s.cliRoute("DELETE /api/cli/system/geoip", svcrole.Set{}, s.handleAPISystemGeoipDelete) |
| // The seeds floor (seeded-instances spec §3.3): apiSystemRoller. |
| s.cliRoute("POST /api/cli/system/seeds-floor", svcrole.Set{}, s.handleAPISystemSeedsFloor) |
| + // Legacy route assignments (seeded-instances spec §6.4): apiSystemRoller. |
| + s.cliRoute("POST /api/cli/system/legacy-routes", svcrole.Set{}, s.handleAPISystemLegacyRoutes) |
| } |
| // clientIP is who the rate limiter counts against. |
diff --git a/internal/console/claim_invariant_fixtures_test.go b/internal/console/claim_invariant_fixtures_test.go| index 88cf378d8..6cb810f57 100644 |
| --- a/internal/console/claim_invariant_fixtures_test.go |
| +++ b/internal/console/claim_invariant_fixtures_test.go |
| @@ -1176,6 +1176,16 @@ var claimFixtures = map[string]claimFixture{ |
| status: http.StatusConflict, |
| msg: seedClaimConsoleRefusal().Msg, |
| }}, |
| + // The legacy route assignment (seeded-instances spec §6.4) says which |
| + // app may import routes filed under the operator account. It waits for |
| + // #250 with the import doors, and refuses before it reads anything; the |
| + // app it names is the one whose imports it would widen. |
| + "route POST /api/cli/system/legacy-routes": {refused: &claimRefusalFixture{ |
| + apps: claimRefs("bab/kass"), |
| + do: claimRefusedAPI(http.MethodPost, "/api/cli/system/legacy-routes", `{"name":"kass","to":"bab/kass"}`), |
| + status: http.StatusConflict, |
| + msg: seedClaimConsoleRefusal().Msg, |
| + }}, |
| // carlos run waits for the claim's reservation protocol (#250): every |
| // run route refuses on a host-claim console (claimUnsupported). |
| "route POST /api/cli/apps/{account}/{app}/instances/{host}/runs": {refused: &claimRefusalFixture{ |
diff --git a/internal/console/claim_invariant_test.go b/internal/console/claim_invariant_test.go| index 6ca3f0786..d08236b07 100644 |
| --- a/internal/console/claim_invariant_test.go |
| +++ b/internal/console/claim_invariant_test.go |
| @@ -107,7 +107,7 @@ var claimUnsupported = map[string]string{ |
| claimMoveResolveUnavailableMsg: "the operator's door out of a stuck move (CARLOS_MOVES_PHASE1) has only its gate until " + |
| "the Moves resolution body lands; it answers this sentence and writes nothing", |
| seedClaimConsoleRefusal().Msg: "seeded instances need nested boxes, and the claim-armed path waits for #250 (seeded-instances spec §3.4); " + |
| - "every import door refuses before it reads the record", |
| + "every import door refuses before it reads the record, and the legacy route assignment before it reads anything", |
| } |
| // claimLifecycleRoot is the classifier's NonApp reason for a lifecycle CAS |
diff --git a/internal/console/svcroutes_test.go b/internal/console/svcroutes_test.go| index e69e34cef..e4ab9d6c4 100644 |
| --- a/internal/console/svcroutes_test.go |
| +++ b/internal/console/svcroutes_test.go |
| @@ -104,6 +104,10 @@ var closedDoors = []string{ |
| // The seeds floor (seeded-instances spec §3.3): deployment-wide and |
| // operator-only. Publishing arms a floor every box enforces. |
| "POST /api/cli/system/seeds-floor", |
| + // Legacy route assignments (seeded-instances spec §6.4): operator-only. |
| + // An assignment lets an import take over routes filed under the |
| + // operator account. |
| + "POST /api/cli/system/legacy-routes", |
| // A credential may never mint a credential, including itself. This is |
| // the escalation cut of spec §3. |
| "POST /api/cli/apps/{account}/{app}/instance-token", |
diff --git a/internal/console/testdata/claim-routes.txt b/internal/console/testdata/claim-routes.txt| index 72b4654b7..1a619069b 100644 |
| --- a/internal/console/testdata/claim-routes.txt |
| +++ b/internal/console/testdata/claim-routes.txt |
| @@ -225,6 +225,7 @@ route POST /api/cli/services/{account}/{name}/revoke |
| route POST /api/cli/services/{account}/{name}/rotate |
| route POST /api/cli/system/antivirus |
| route POST /api/cli/system/layout |
| +route POST /api/cli/system/legacy-routes |
| route POST /api/cli/system/rollback |
| route POST /api/cli/system/seeds-floor |
| route POST /api/cli/system/update |
diff --git a/system.go b/system.go| index e9faf5c7a..82ef5b189 100644 |
| --- a/system.go |
| +++ b/system.go |
| @@ -49,7 +49,7 @@ func systemVerb(args []string) (verb string, rest []string, component string) { |
| // untouched in rest for runSystemAntivirusVerb to read. |
| // layout is a family too: migrate|revert|status stay in rest for |
| // runSystemLayout. |
| - case "guard-arm", "guard-exec", "update", "status", "rollback", "apply", "antivirus", "layout", "geoip", "seeds-floor", "help", "-h", "--help": |
| + case "guard-arm", "guard-exec", "update", "status", "rollback", "apply", "antivirus", "layout", "geoip", "seeds-floor", "legacy-routes", "help", "-h", "--help": |
| return args[0], args[1:], component |
| } |
| return "", args, component |
| @@ -79,6 +79,8 @@ func runSystem(args []string) { |
| runSystemGeoipVerb(rest) |
| case "seeds-floor": |
| runSystemSeedsFloorVerb(rest) |
| + case "legacy-routes": |
| + runSystemLegacyRoutesVerb(rest) |
| case "help", "-h", "--help": |
| systemUsage() |
| default: |
| @@ -112,6 +114,7 @@ func systemUsage() { |
| carlos system antivirus <status|enable|disable> [flags] |
| carlos system geoip <publish|status|off> [flags] |
| carlos system seeds-floor publish --min-release <release> |
| + carlos system legacy-routes assign --name <name> --to <account>/<app> |
| The platform's own binary (ops account owners and CARLOS_SYSTEM_OPERATORS): |
| @@ -123,6 +126,8 @@ The platform's own binary (ops account owners and CARLOS_SYSTEM_OPERATORS): |
| and namespace, or back; see carlos system layout help |
| seeds-floor publish --min-release <release> |
| the first release whose boxes can place a seed, such as v0.27.0 |
| + legacy-routes assign --name <name> --to <account>/<app> |
| + the account and app they belong to, as <account>/<app> |
| Deployment-wide settings (CARLOS_STORE_OPERATORS): |
diff --git a/system_legacy_routes.go b/system_legacy_routes.go| new file mode 100644 |
| index 000000000..8ce56a3e7 |
| --- /dev/null |
| +++ b/system_legacy_routes.go |
| @@ -0,0 +1,48 @@ |
| +package main |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "flag" |
| + "fmt" |
| + "log" |
| + "net/http" |
| + "os" |
| +) |
| + |
| +// runSystemLegacyRoutesVerb is `carlos system legacy-routes assign` |
| +// (seeded-instances spec §6.4): which account and app the routes an |
| +// operator filed by hand under the operator account belong to. |
| +func runSystemLegacyRoutesVerb(rest []string) { |
| + if len(rest) == 0 || rest[0] != "assign" { |
| + systemUsage() |
| + os.Exit(2) |
| + } |
| + fs := flag.NewFlagSet("system legacy-routes assign", flag.ExitOnError) |
| + consoleFlag := consoleOption(fs, "console origin (default: the only logged-in one)") |
| + name := fs.String("name", "", "the app name the legacy routes are filed under, in the operator account") |
| + to := fs.String("to", "", "the account and app they belong to, as <account>/<app>") |
| + fs.Parse(rest[1:]) |
| + if *name == "" || *to == "" || fs.NArg() != 0 { |
| + fs.Usage() |
| + os.Exit(2) |
| + } |
| + sess := requireDeployAPISession(*consoleFlag) |
| + printUsing(stdout, sess) |
| + status, body, err := sess.postJSON(context.Background(), "/api/cli/system/legacy-routes", map[string]string{"name": *name, "to": *to}) |
| + if err != nil { |
| + log.Fatalf("carlos system legacy-routes assign: %v", err) |
| + } |
| + if status != http.StatusOK { |
| + log.Fatalf("carlos system legacy-routes assign: %v", apiErrorFromBody(status, body)) |
| + } |
| + var out struct { |
| + Name string `json:"name"` |
| + Account string `json:"account"` |
| + App string `json:"app"` |
| + } |
| + if err := json.Unmarshal(body, &out); err != nil { |
| + log.Fatalf("carlos system legacy-routes assign: unreadable response: %v", err) |
| + } |
| + fmt.Fprintln(stdout, fmt.Sprintf("assigned the legacy routes named %s to %s/%s", out.Name, out.Account, out.App)) |
| +} |
diff --git a/system_legacy_routes_test.go b/system_legacy_routes_test.go| new file mode 100644 |
| index 000000000..dea0c4d7b |
| --- /dev/null |
| +++ b/system_legacy_routes_test.go |
| @@ -0,0 +1,10 @@ |
| +package main |
| + |
| +import "testing" |
| + |
| +func TestSystemVerbKnowsLegacyRoutes(t *testing.T) { |
| + verb, rest, _ := systemVerb([]string{"legacy-routes", "assign", "--name", "shop", "--to", "bac/shop"}) |
| + if verb != "legacy-routes" || len(rest) != 5 || rest[0] != "assign" { |
| + t.Fatalf("systemVerb = %q %q", verb, rest) |
| + } |
| +} |