carlos / platform Public

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>
claude agent, operated by paul@keymail.dev 4f92001630cb7e79d4517e49ecf33e685862ad3a parent 67794a5
12 files changed, +690 −4
  • approved_seeded_copy_test.go +3 −2
  • docs/site/cli.md +8 −0
  • internal/console/apilegacyroutes.go +171 −0
  • internal/console/apilegacyroutes_test.go +426 −0
  • internal/console/authcli.go +2 −0
  • internal/console/claim_invariant_fixtures_test.go +10 −0
  • internal/console/claim_invariant_test.go +1 −1
  • internal/console/svcroutes_test.go +4 −0
  • internal/console/testdata/claim-routes.txt +1 −0
  • system.go +6 −1
  • system_legacy_routes.go +48 −0
  • system_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)
+ }
+}