| 1 | # Seeded Instances, Plan 8: Sweeps, Move Refusals and Legacy Route Assignments Implementation Plan |
| 2 | |
| 3 | > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
| 4 | |
| 5 | **Goal:** Close the series: the console's deadline sweep that abandons stale reservations and imports, finishes every rolled-back seed and deletes only snapshots no live record needs; moves refused until a seed is placed or an import committed, in both move doors and in the move worker; and `carlos system legacy-routes assign`, the operator's mapping that lets an import take over a route filed under the operator account. |
| 6 | |
| 7 | **Architecture:** `StartSeedsPass` runs every five minutes from `cmd/console/main.go` through the console's worker registry. It reads every record strictly (refusing to act on a partial set, and treating a host whose record body is unreadable as referenced), abandons stale `reserving` and `awaiting-upload`/`uploaded` seeds by CAS to `aborted`, calls Plan 6's `finishSeed` for every `rolled-back` seed, and walks `seeds/` with last-modified times. The move refusals live in `provision` (the transitions, under the record CAS) and in a console helper that both move doors call before their transition, so each door answers the deck's sentence; the worker skips a blocked record before its first effect. The assignment is a console door that writes `control/legacy-route-owners.json` by CAS, refusing to re-assign a name while an import under its current entry is between `ready` and `committed`. |
| 8 | |
| 9 | **Tech Stack:** Go 1.25, `release.ModLister`, `provision.ListAllRecords`, `seeds.ReadLegacyOwners`. |
| 10 | |
| 11 | **Spec:** `docs/superpowers/specs/2026-10-08-seeded-instances-design.md` rev 7: §3.1 (the sweep's three cases, codex R2-5, R3-2, R4-1), §5.6 (the sweep as the console finish's fallback, codex R6-1), §5.8 (codex R4-2), §6.4 (codex 9), §7 (`legacy-route-assign` audit row, the move errors). Index: `2026-10-08-seeded-instances-0-index.md`. Copy deck rows SD-N100 to SD-N110 and SD-N116. |
| 12 | |
| 13 | ## Global Constraints |
| 14 | |
| 15 | - Every user-facing string (CLI output, errors, console messages, docs prose) is copy-reviewed by Paul before it is written into code; implementation tasks use only strings from the copy deck. Copy voice: no em dashes; full stops and new sentences instead. |
| 16 | - CI cross-compiles darwin and windows: linux-only code needs `//go:build linux` plus a `!linux` stub. |
| 17 | - Local test commands: targeted tests (`go test ./internal/<pkg>/ -run TestX`), not the full suite in a loop. Inside the sandbox the default Go build cache may be read-only; if so use `GOCACHE=$TMPDIR/gocache` re-exported per command. Socket tests need a short TMPDIR (`TMPDIR=/tmp/g`). The local gate may need `GOFLAGS=-buildvcs=false`. |
| 18 | - Heavy loops run under `nice`; no -race full suite until the end of a plan. |
| 19 | - Production use needs nested boxes; the claim-armed path is refused here and left to #250 (spec §3.4). |
| 20 | - Commits end with `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`. |
| 21 | - No new file in this series imports `internal/moves`. New sentences go in new files (`internal/console/seedsweep.go`, `seedmove.go`, `apilegacyroutes.go`, `system_legacy_routes.go`) or in `internal/provision/seed.go`; files listed in `movesScanFiles` (`apiinstancemove.go`, `movespass.go`, `movesphase1.go`, `provision.go`, `authcli.go`, `cmd/console/main.go`) gain only calls and routes. |
| 22 | - `TestClaimAuthoritySourceCensus` inventories console and `cmd/console` calls in its `selected` set; add each new site to `internal/console/testdata/claim-authority-sites.txt`. |
| 23 | - When a task wires a deck row, append its id to `seededWiredIDs` in `approved_seeded_copy_test.go` in the same commit. |
| 24 | - Write each deck sentence as exactly one string literal or one format string holding the row's text, with `%s` or `%d` at each `<placeholder>` and nothing else: no prefix, no trailing `\n`, no leading spaces. The gate's matcher (`movesRowSpoken`, `approved_moves_copy_test.go:811`) anchors both ends. Print with `fmt.Fprintln(w, sentence)`. |
| 25 | - The sweep deletes a snapshot only on positive knowledge: a complete record set, and, for "no record names it", a last-modified time more than an hour old (spec §3.1). |
| 26 | |
| 27 | ## Review Focus |
| 28 | |
| 29 | 1. A record whose body cannot be decoded must count as naming whatever snapshots exist under its host: the sweep must never delete a snapshot because a record was unreadable for a pass. Test in Task 2 (`TestSweepKeepsSnapshotsOfAnUnreadableRecord`). |
| 30 | 2. A `--from` record in `reserving` for less than an hour, with its snapshot already uploaded, must keep both: the door is between upload and publish. Test in Task 2 (`TestSweepLeavesALiveReservation`). |
| 31 | 3. A stale import is abandoned by CAS first, and if the CAS loses (the app committed or uploaded that second), nothing is deleted. Test in Task 2 (`TestSweepAbandonsOnlyByCAS`). |
| 32 | 4. A move request on a `--from` instance whose seed is `ready` is refused in both the legacy door and the Phase 1 door with the deck's sentence, and the record's `move` stays absent. Test in Task 3 (`TestMoveDoorsRefuseAnUnplacedSeed`). |
| 33 | 5. Re-assigning a legacy name while an import under the old mapping is `swapped` is refused, and the file is unchanged. Test in Task 5 (`TestAssignRefusedWhileAnImportIsInFlight`). |
| 34 | |
| 35 | --- |
| 36 | |
| 37 | ## File structure |
| 38 | |
| 39 | | path | responsibility | |
| 40 | |---|---| |
| 41 | | `internal/provision/reconcile.go` | `ListAllRecordsStrict` beside `ListAllRecords` | |
| 42 | | `internal/console/seedsweep.go` | `StartSeedsPass`, `seedsPass` | |
| 43 | | `internal/console/claim_workers.go`, `cmd/console/main.go` | the worker registered and started | |
| 44 | | `internal/provision/seed.go` | `SeedMoveSentence`; the move transitions' refusal | |
| 45 | | `internal/provision/provision.go` | `MoveRequest` and `MoveStartTransition` call `seedMoveRefusal` | |
| 46 | | `internal/console/seedmove.go` | `refuseSeedMove`, called by both move doors | |
| 47 | | `internal/console/apiinstancemove.go` | two calls | |
| 48 | | `internal/host/movespass.go` | the worker's skip | |
| 49 | | `internal/console/apilegacyroutes.go` | `POST /api/cli/system/legacy-routes` | |
| 50 | | `system_legacy_routes.go`, `system.go` | `carlos system legacy-routes assign` | |
| 51 | | `docs/site/cli.md` | SD-N110 | |
| 52 | |
| 53 | --- |
| 54 | |
| 55 | ### Task 1: A strict record listing |
| 56 | |
| 57 | **Files:** |
| 58 | - Modify: `internal/provision/reconcile.go:1732` (`ListAllRecords`) |
| 59 | - Test: `internal/provision/reconcile_test.go` |
| 60 | |
| 61 | **Interfaces:** |
| 62 | - Produces: `ListAllRecordsStrict(ctx context.Context, s release.Store) (recs []Record, unreadable map[string]bool, err error)`: `ListAllRecords`' set, plus the hosts whose record key parsed but whose body did not decode or disagreed with its path. `ListAllRecords` becomes a wrapper that drops the second value. |
| 63 | |
| 64 | - [ ] **Step 1: Write the failing test** |
| 65 | |
| 66 | ```go |
| 67 | func TestListAllRecordsStrictNamesUnreadableHosts(t *testing.T) { |
| 68 | s, sqid := setup(t) |
| 69 | if err := CreateRecord(ctx, s, acmeRecord(sqid)); err != nil { |
| 70 | t.Fatal(err) |
| 71 | } |
| 72 | bad := acmeRecord(sqid) |
| 73 | bad.Host, bad.Slug = "bad.go.tito.io", "bad" |
| 74 | if err := CreateRecord(ctx, s, bad); err != nil { |
| 75 | t.Fatal(err) |
| 76 | } |
| 77 | putReplace(t, s, RecordKey(sqid, "tito", "bad.go.tito.io"), []byte("{not json")) |
| 78 | recs, unreadable, err := ListAllRecordsStrict(ctx, s) |
| 79 | if err != nil || len(recs) != 1 || recs[0].Host != "acme.go.tito.io" || !unreadable["bad.go.tito.io"] { |
| 80 | t.Fatalf("recs %v unreadable %v err %v", recs, unreadable, err) |
| 81 | } |
| 82 | } |
| 83 | ``` |
| 84 | |
| 85 | - [ ] **Step 2: Run it to verify it fails** |
| 86 | |
| 87 | Run: `go test ./internal/provision/ -run TestListAllRecordsStrict -count=1` |
| 88 | Expected: FAIL to compile. |
| 89 | |
| 90 | - [ ] **Step 3: Split the function** |
| 91 | |
| 92 | Rename `ListAllRecords`' body to `ListAllRecordsStrict`, add `unreadable := map[string]bool{}` and set `unreadable[host] = true` in each of its two `continue` branches (malformed, disagrees with its path), and return `recs, unreadable, nil`. Then: |
| 93 | |
| 94 | ```go |
| 95 | // ListAllRecords is ListAllRecordsStrict without the unreadable hosts. |
| 96 | func ListAllRecords(ctx context.Context, s release.Store) ([]Record, error) { |
| 97 | recs, _, err := ListAllRecordsStrict(ctx, s) |
| 98 | return recs, err |
| 99 | } |
| 100 | ``` |
| 101 | |
| 102 | - [ ] **Step 4: Run the package** |
| 103 | |
| 104 | Run: `go test ./internal/provision/ -count=1` |
| 105 | Expected: PASS. |
| 106 | |
| 107 | - [ ] **Step 5: Commit** |
| 108 | |
| 109 | ```bash |
| 110 | git add internal/provision/reconcile.go internal/provision/reconcile_test.go |
| 111 | git commit -m "provision: ListAllRecordsStrict names the hosts it could not read (Plan 8 Task 1) |
| 112 | |
| 113 | Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| 114 | ``` |
| 115 | |
| 116 | ### Task 2: The seeds sweep |
| 117 | |
| 118 | **Files:** |
| 119 | - Create: `internal/console/seedsweep.go`, `internal/console/seedsweep_test.go` |
| 120 | - Modify: `internal/console/claim_workers.go` (`"StartSeedsPass": s.StartSeedsPass`), `cmd/console/main.go` (`go srv.RunClaimWorker(context.Background(), "StartSeedsPass")` beside `StartExpiryPass`) |
| 121 | |
| 122 | **Interfaces:** |
| 123 | - Consumes: `provision.ListAllRecordsStrict`, `provision.SetRecordSeed`, `provision.SeedAbort`, `s.finishSeed(ctx, sqid, app, host, id)` (Plan 6), `release.ModLister`, `seeds.ParseSnapshotKey`, `seeds.Prefix`. |
| 124 | - Produces: `(s *Server) StartSeedsPass(ctx context.Context)`; `(s *Server) seedsPass(ctx context.Context, now time.Time)`; `seedsPassInterval = 5 * time.Minute`, `seedReserveTTL = time.Hour`, `seedImportTTL = 7 * 24 * time.Hour`, `seedOrphanAge = time.Hour`. |
| 125 | |
| 126 | One pass (spec §3.1, §5.6): |
| 127 | |
| 128 | 1. Records, strictly; an error ends the pass (nothing is deleted on a partial set). |
| 129 | 2. For each seeded record: `reserving` and older than an hour, or an import `awaiting-upload`/`uploaded` older than seven days, is CASed to `aborted` (`bumpSerial` after; the box finishes its part). `rolled-back` is finished (`finishSeed`). |
| 130 | 3. Snapshots under `seeds/` with their last-modified times (`ListMod`; a store without it skips the walk). For each `seeds/<host>/<id>/snapshot.db`: |
| 131 | - the host is unreadable this pass: keep; |
| 132 | - the host's record names this `id`: keep, unless its seed is `placed` or `committed` (the host's own replica exists, so the box's delete was lost), or the record was just abandoned by this pass (step 2) and its CAS landed; |
| 133 | - the host's record names another `id` and is `placed` or `committed`: delete; |
| 134 | - otherwise (no record, or a record naming another `id` in any other state): delete when the object is more than an hour old. |
| 135 | A delete names the exact key, so it never reaches another seed's snapshot. |
| 136 | |
| 137 | The "placed or committed with this `id`" case is not one of spec §3.1's three; it is the same safety argument (placed means the host's own replica exists, §3.4 step 5) applied to a snapshot whose delete the box lost, which otherwise would never be collected. |
| 138 | |
| 139 | - [ ] **Step 1: Write the failing tests** |
| 140 | |
| 141 | ```go |
| 142 | package console |
| 143 | |
| 144 | import ( |
| 145 | "context" |
| 146 | "errors" |
| 147 | "os" |
| 148 | "strings" |
| 149 | "testing" |
| 150 | "time" |
| 151 | |
| 152 | "github.com/carlosframework/platform/internal/provision" |
| 153 | "github.com/carlosframework/platform/internal/release" |
| 154 | "github.com/carlosframework/platform/internal/seeds" |
| 155 | ) |
| 156 | |
| 157 | var sweepDataRoot string // the DirStore's root, to age its files |
| 158 | |
| 159 | func sweepFixture(t *testing.T) (*consoleFixture, release.Store) { |
| 160 | t.Helper() |
| 161 | fx := newConsoleFixture(t) |
| 162 | sweepDataRoot = t.TempDir() |
| 163 | data := release.NewDirStore(sweepDataRoot) |
| 164 | fx.srv.cfg.SeedData = data |
| 165 | return fx, data |
| 166 | } |
| 167 | |
| 168 | func seededRec(t *testing.T, fx *consoleFixture, host, kind, state string, at time.Time) string { |
| 169 | t.Helper() |
| 170 | id, _ := seeds.NewID() |
| 171 | rec := provision.Record{Schema: 1, Host: host, Slug: strings.Split(host, ".")[0], App: "titogo", Account: "bab", |
| 172 | Channel: "stable", CreatedAt: at, Seed: &provision.SeedSource{ID: id, Kind: kind, State: state, By: "t", At: at}} |
| 173 | if err := provision.CreateRecord(context.Background(), fx.rel, rec); err != nil { |
| 174 | t.Fatal(err) |
| 175 | } |
| 176 | return id |
| 177 | } |
| 178 | |
| 179 | func putSnapshotAged(t *testing.T, data release.Store, host, id string, age time.Duration) string { |
| 180 | t.Helper() |
| 181 | key := seeds.SnapshotKey(host, id) |
| 182 | if _, err := data.Put(context.Background(), key, []byte("db"), ""); err != nil { |
| 183 | t.Fatal(err) |
| 184 | } |
| 185 | // DirStore keeps a key at root/key and its ListMod reports the file's |
| 186 | // mtime (store.go:338, :509): age the file. |
| 187 | path := filepath.Join(sweepDataRoot, filepath.FromSlash(key)) |
| 188 | when := time.Now().Add(-age) |
| 189 | if err := os.Chtimes(path, when, when); err != nil { |
| 190 | t.Fatal(err) |
| 191 | } |
| 192 | return key |
| 193 | } |
| 194 | |
| 195 | func exists(t *testing.T, data release.Store, key string) bool { |
| 196 | t.Helper() |
| 197 | _, _, err := data.Get(context.Background(), key) |
| 198 | return !errors.Is(err, os.ErrNotExist) |
| 199 | } |
| 200 | |
| 201 | func TestSweepLeavesALiveReservation(t *testing.T) { |
| 202 | fx, data := sweepFixture(t) |
| 203 | now := time.Now() |
| 204 | id := seededRec(t, fx, "canary.go.tito.io", seeds.KindReplica, seeds.StateReserving, now.Add(-10*time.Minute)) |
| 205 | key := putSnapshotAged(t, data, "canary.go.tito.io", id, 10*time.Minute) |
| 206 | fx.srv.seedsPass(context.Background(), now) |
| 207 | rec, _ := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "canary.go.tito.io") |
| 208 | if rec.Seed.State != seeds.StateReserving || !exists(t, data, key) { |
| 209 | t.Fatalf("a live reservation was touched: %s, snapshot kept %v", rec.Seed.State, exists(t, data, key)) |
| 210 | } |
| 211 | // An hour later it is abandoned by CAS. |
| 212 | fx.srv.seedsPass(context.Background(), now.Add(2*time.Hour)) |
| 213 | rec, _ = provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "canary.go.tito.io") |
| 214 | if rec.Seed.State != seeds.StateAborted { |
| 215 | t.Fatalf("a stale reservation = %s, want aborted", rec.Seed.State) |
| 216 | } |
| 217 | } |
| 218 | |
| 219 | func TestSweepDeletesOnlyUnreferencedOrSupersededSnapshots(t *testing.T) { |
| 220 | fx, data := sweepFixture(t) |
| 221 | now := time.Now() |
| 222 | live := seededRec(t, fx, "a.go.tito.io", seeds.KindReplica, seeds.StateReady, now) |
| 223 | liveKey := putSnapshotAged(t, data, "a.go.tito.io", live, 3*time.Hour) |
| 224 | stale, _ := seeds.NewID() |
| 225 | staleKey := putSnapshotAged(t, data, "a.go.tito.io", stale, 3*time.Hour) // another id, record not placed: an orphan |
| 226 | fresh, _ := seeds.NewID() |
| 227 | freshKey := putSnapshotAged(t, data, "nobody.go.tito.io", fresh, 10*time.Minute) // no record yet, too young |
| 228 | seededRec(t, fx, "b.go.tito.io", seeds.KindReplica, seeds.StatePlaced, now) |
| 229 | old, _ := seeds.NewID() |
| 230 | supersededKey := putSnapshotAged(t, data, "b.go.tito.io", old, 5*time.Minute) // another id, record placed |
| 231 | fx.srv.seedsPass(context.Background(), now) |
| 232 | if !exists(t, data, liveKey) { |
| 233 | t.Error("a snapshot a live record needs was deleted") |
| 234 | } |
| 235 | if exists(t, data, staleKey) { |
| 236 | t.Error("an orphan over an hour old survived") |
| 237 | } |
| 238 | if !exists(t, data, freshKey) { |
| 239 | t.Error("a young unreferenced snapshot was deleted (its door may be between record and upload)") |
| 240 | } |
| 241 | if exists(t, data, supersededKey) { |
| 242 | t.Error("a snapshot superseded by a placed seed survived") |
| 243 | } |
| 244 | } |
| 245 | |
| 246 | func TestSweepKeepsSnapshotsOfAnUnreadableRecord(t *testing.T) { |
| 247 | fx, data := sweepFixture(t) |
| 248 | id := seededRec(t, fx, "a.go.tito.io", seeds.KindReplica, seeds.StateReady, time.Now()) |
| 249 | key := putSnapshotAged(t, data, "a.go.tito.io", id, 3*time.Hour) |
| 250 | _, etag, _ := fx.rel.Get(context.Background(), provision.RecordKey("bab", "titogo", "a.go.tito.io")) |
| 251 | fx.rel.Put(context.Background(), provision.RecordKey("bab", "titogo", "a.go.tito.io"), []byte("{not json"), etag) |
| 252 | fx.srv.seedsPass(context.Background(), time.Now()) |
| 253 | if !exists(t, data, key) { |
| 254 | t.Fatal("an unreadable record's snapshot was deleted") |
| 255 | } |
| 256 | } |
| 257 | |
| 258 | func TestSweepAbandonsOnlyByCAS(t *testing.T) { |
| 259 | fx, data := sweepFixture(t) |
| 260 | old := time.Now().Add(-8 * 24 * time.Hour) |
| 261 | id := seededRec(t, fx, "imp.go.tito.io", seeds.KindImport, seeds.StateUploaded, old) |
| 262 | key := putSnapshotAged(t, data, "imp.go.tito.io", id, 8*24*time.Hour) |
| 263 | // The app marks it stopped in the same instant: the CAS the sweep makes |
| 264 | // must lose and nothing be deleted. |
| 265 | seedsPassBeforeAbort = func() { |
| 266 | provision.SetRecordSeed(context.Background(), fx.rel, "bab", "titogo", "imp.go.tito.io", provision.SeedStopped(id)) |
| 267 | } |
| 268 | defer func() { seedsPassBeforeAbort = nil }() |
| 269 | fx.srv.seedsPass(context.Background(), time.Now()) |
| 270 | rec, _ := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "imp.go.tito.io") |
| 271 | if rec.Seed.State != seeds.StateReady || !exists(t, data, key) { |
| 272 | t.Fatalf("state %s, snapshot kept %v; want the app's stop to win and nothing deleted", rec.Seed.State, exists(t, data, key)) |
| 273 | } |
| 274 | } |
| 275 | |
| 276 | func TestSweepFinishesARolledBackSeed(t *testing.T) { |
| 277 | fx, _ := sweepFixture(t) |
| 278 | seededRec(t, fx, "gone.go.tito.io", seeds.KindImport, seeds.StateRolledBack, time.Now()) |
| 279 | fx.srv.seedsPass(context.Background(), time.Now()) |
| 280 | if _, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "gone.go.tito.io"); !errors.Is(err, os.ErrNotExist) { |
| 281 | t.Fatalf("a rolled-back record after the sweep: %v", err) |
| 282 | } |
| 283 | } |
| 284 | ``` |
| 285 | |
| 286 | `seedsPassBeforeAbort` is a test seam in `seedsweep.go`: a `func()` called just before each abandonment CAS, nil in production. Because `SetRecordSeed` retries once on a lost etag and re-applies `SeedAbort`, which accepts `ready` for an import, the test as written would see the abort land on the retry; make the sweep's abandonment use a guarded mutation that re-checks staleness on the fresh record (`rec.Seed.State` still `awaiting-upload`/`uploaded`/`reserving` and still older than its limit), so the retry refuses once the app has moved it on. |
| 287 | |
| 288 | - [ ] **Step 2: Run the tests to verify they fail** |
| 289 | |
| 290 | Run: `go test ./internal/console/ -run 'TestSweep' -count=1` |
| 291 | Expected: FAIL to compile. |
| 292 | |
| 293 | - [ ] **Step 3: Write `seedsweep.go`** |
| 294 | |
| 295 | ```go |
| 296 | package console |
| 297 | |
| 298 | import ( |
| 299 | "context" |
| 300 | "errors" |
| 301 | "log" |
| 302 | "time" |
| 303 | |
| 304 | "github.com/carlosframework/platform/internal/provision" |
| 305 | "github.com/carlosframework/platform/internal/release" |
| 306 | "github.com/carlosframework/platform/internal/seeds" |
| 307 | ) |
| 308 | |
| 309 | const ( |
| 310 | seedsPassInterval = 5 * time.Minute |
| 311 | seedReserveTTL = time.Hour |
| 312 | seedImportTTL = 7 * 24 * time.Hour |
| 313 | seedOrphanAge = time.Hour |
| 314 | ) |
| 315 | |
| 316 | // seedsPassBeforeAbort is the tests' pause before an abandonment CAS. |
| 317 | var seedsPassBeforeAbort func() |
| 318 | |
| 319 | var errNotStale = errors.New("console: the seed moved on") |
| 320 | |
| 321 | // StartSeedsPass runs the seeds sweep now and every five minutes |
| 322 | // (seeded-instances spec §3.1, §5.6), StartExpiryPass's shape. |
| 323 | func (s *Server) StartSeedsPass(ctx context.Context) { |
| 324 | if s.cfg.Releases == nil || s.cfg.SeedData == nil { |
| 325 | return |
| 326 | } |
| 327 | t := time.NewTicker(seedsPassInterval) |
| 328 | defer t.Stop() |
| 329 | for { |
| 330 | s.seedsPass(ctx, time.Now()) |
| 331 | select { |
| 332 | case <-ctx.Done(): |
| 333 | return |
| 334 | case <-t.C: |
| 335 | } |
| 336 | } |
| 337 | } |
| 338 | |
| 339 | func stale(seed *provision.SeedSource, now time.Time) bool { |
| 340 | switch { |
| 341 | case seed.State == seeds.StateReserving: |
| 342 | return now.Sub(seed.At) > seedReserveTTL |
| 343 | case seed.Import() && (seed.State == seeds.StateAwaitingUpload || seed.State == seeds.StateUploaded): |
| 344 | return now.Sub(seed.At) > seedImportTTL |
| 345 | } |
| 346 | return false |
| 347 | } |
| 348 | |
| 349 | func (s *Server) seedsPass(ctx context.Context, now time.Time) { |
| 350 | recs, unreadable, err := provision.ListAllRecordsStrict(ctx, s.cfg.Releases) |
| 351 | if err != nil { |
| 352 | log.Printf("console: seeds pass: %v (nothing swept this pass)", err) |
| 353 | return |
| 354 | } |
| 355 | byHost := map[string]provision.Record{} |
| 356 | abandoned := map[string]string{} // host -> seed id aborted by this pass |
| 357 | for _, rec := range recs { |
| 358 | byHost[rec.Host] = rec |
| 359 | seed := rec.Seed |
| 360 | if seed == nil { |
| 361 | continue |
| 362 | } |
| 363 | switch { |
| 364 | case stale(seed, now): |
| 365 | if seedsPassBeforeAbort != nil { |
| 366 | seedsPassBeforeAbort() |
| 367 | } |
| 368 | _, err := provision.SetRecordSeed(ctx, s.cfg.Releases, rec.Account, rec.App, rec.Host, func(r *provision.Record) error { |
| 369 | if r.Seed == nil || r.Seed.ID != seed.ID || !stale(r.Seed, now) { |
| 370 | return errNotStale |
| 371 | } |
| 372 | return provision.SeedAbort(seed.ID)(r) |
| 373 | }) |
| 374 | if err != nil { |
| 375 | if !errors.Is(err, errNotStale) && !errors.Is(err, provision.ErrSeedConflict) { |
| 376 | log.Printf("console: seeds pass: abandon %s: %v", rec.Host, err) |
| 377 | } |
| 378 | continue |
| 379 | } |
| 380 | abandoned[rec.Host] = seed.ID |
| 381 | s.bumpSerial(ctx) |
| 382 | log.Printf("console: seeds pass: abandoned seed %s of %s (%s since %s)", seed.ID, rec.Host, seed.State, seed.At.Format(time.RFC3339)) |
| 383 | case seed.State == seeds.StateRolledBack: |
| 384 | if err := s.finishSeed(ctx, rec.Account, rec.App, rec.Host, seed.ID); err != nil { |
| 385 | log.Printf("console: seeds pass: finish %s: %v", rec.Host, err) |
| 386 | } |
| 387 | } |
| 388 | } |
| 389 | ml, ok := s.cfg.SeedData.(release.ModLister) |
| 390 | if !ok { |
| 391 | return |
| 392 | } |
| 393 | objs, err := ml.ListMod(ctx, seeds.Prefix) |
| 394 | if err != nil { |
| 395 | log.Printf("console: seeds pass: list snapshots: %v", err) |
| 396 | return |
| 397 | } |
| 398 | for _, o := range objs { |
| 399 | host, id, ok := seeds.ParseSnapshotKey(o.Key) |
| 400 | if !ok || unreadable[host] { |
| 401 | continue |
| 402 | } |
| 403 | rec, has := byHost[host] |
| 404 | del := false |
| 405 | switch { |
| 406 | case has && rec.Seed != nil && rec.Seed.ID == id: |
| 407 | placed := rec.Seed.State == seeds.StatePlaced || rec.Seed.State == seeds.StateCommitted |
| 408 | del = placed || abandoned[host] == id |
| 409 | case has && rec.Seed != nil && (rec.Seed.State == seeds.StatePlaced || rec.Seed.State == seeds.StateCommitted): |
| 410 | del = true |
| 411 | default: |
| 412 | del = now.Sub(o.Mod) > seedOrphanAge |
| 413 | } |
| 414 | if !del { |
| 415 | continue |
| 416 | } |
| 417 | if err := s.cfg.SeedData.Delete(ctx, o.Key); err != nil { |
| 418 | log.Printf("console: seeds pass: delete %s: %v", o.Key, err) |
| 419 | continue |
| 420 | } |
| 421 | log.Printf("console: seeds pass: deleted %s", o.Key) |
| 422 | } |
| 423 | } |
| 424 | ``` |
| 425 | |
| 426 | Register the worker in `claim_workers.go`'s map (`"StartSeedsPass": s.StartSeedsPass,`) and start it in `cmd/console/main.go` beside `StartExpiryPass`: |
| 427 | |
| 428 | ```go |
| 429 | // The seeds sweep (seeded-instances spec §3.1, §5.6): stale |
| 430 | // reservations and imports abandoned, rolled-back seeds finished, |
| 431 | // unreferenced snapshots deleted. Same placement rule as every pass |
| 432 | // above. |
| 433 | go srv.RunClaimWorker(context.Background(), "StartSeedsPass") |
| 434 | ``` |
| 435 | |
| 436 | If a test pins the worker names (`grep -rn '"StartExpiryPass"' internal/console/*_test.go`), add `StartSeedsPass` there. |
| 437 | |
| 438 | - [ ] **Step 4: Run the tests to verify they pass** |
| 439 | |
| 440 | Run: `go test ./internal/console/ -run 'TestSweep|TestClaimWorker|TestClaimAuthoritySourceCensus' -count=1` |
| 441 | Expected: PASS. |
| 442 | |
| 443 | - [ ] **Step 5: Commit** |
| 444 | |
| 445 | ```bash |
| 446 | git add internal/console/seedsweep.go internal/console/seedsweep_test.go internal/console/claim_workers.go cmd/console/main.go internal/console/testdata/claim-authority-sites.txt |
| 447 | git commit -m "console: the seeds sweep (Plan 8 Task 2) |
| 448 | |
| 449 | Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| 450 | ``` |
| 451 | |
| 452 | ### Task 3: Moves wait for placement, in both doors |
| 453 | |
| 454 | **Files:** |
| 455 | - Modify: `internal/provision/seed.go` (`SeedMoveSentence`, `seedMoveRefusal`), `internal/provision/provision.go:1146` (`MoveRequest`), `:1177` (`MoveStartTransition`) |
| 456 | - Create: `internal/console/seedmove.go` |
| 457 | - Modify: `internal/console/apiinstancemove.go` (`legacyHandleAPIInstanceMoveRequest` `:260`, after `moveGate`; `handleAPIInstanceMoveRequest` `:396`, after `moveOperatorGate`) |
| 458 | - Test: `internal/provision/seed_test.go`, `internal/console/seedmove_test.go` |
| 459 | |
| 460 | **Interfaces:** |
| 461 | - Produces: `provision.SeedMoveSentence(block string) string` (SD-N100 for `SeedMoveUnplaced`, SD-N101 for `SeedMoveUncommitted`, `""` otherwise); `seedMoveRefusal(rec *Record) error` (an `ErrMoveConflict` carrying the sentence, or nil); `(s *Server) refuseSeedMove(w http.ResponseWriter, rec *provision.Record) bool`. |
| 462 | |
| 463 | The refusal sits in two places (spec §5.8): inside the two move transitions, under the record CAS; and in a console helper both doors call after their gate and before their transition, so each door answers the deck's sentence (the Phase 1 door's `phase1Conflict` maps unknown provision refusals to one fixed sentence, and the legacy door prints the wrapped error). The record has already been read by both gates. |
| 464 | |
| 465 | - [ ] **Step 1: Write the failing tests** |
| 466 | |
| 467 | In `internal/provision/seed_test.go`: |
| 468 | |
| 469 | ```go |
| 470 | func TestMoveTransitionsRefuseAnUnplacedSeed(t *testing.T) { |
| 471 | ctx := context.Background() |
| 472 | for _, c := range []struct { |
| 473 | kind, state, want string |
| 474 | }{ |
| 475 | {seeds.KindReplica, seeds.StateReady, "wake the instance once before moving it"}, |
| 476 | {seeds.KindImport, seeds.StatePlaced, "commit or abort the import first"}, |
| 477 | } { |
| 478 | s := release.NewDirStore(t.TempDir()) |
| 479 | rec := seedTestRecord(t, s, testSeed(t, c.kind, c.state)) |
| 480 | _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, MoveRequest("uk", time.Now())) |
| 481 | if !errors.Is(err, ErrMoveConflict) || !strings.Contains(err.Error(), c.want) { |
| 482 | t.Fatalf("%s %s: err = %v", c.kind, c.state, err) |
| 483 | } |
| 484 | _, err = SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, MoveStartTransition(MoveStart{ID: "mv_aaaaaaaaaaaaaaaaaaaaaaaaaa", Incarnation: "i", From: "", To: "uk", Cut: "auto", By: "op", Now: time.Now()})) |
| 485 | if !errors.Is(err, ErrMoveConflict) || !strings.Contains(err.Error(), c.want) { |
| 486 | t.Fatalf("%s %s start: err = %v", c.kind, c.state, err) |
| 487 | } |
| 488 | } |
| 489 | s := release.NewDirStore(t.TempDir()) |
| 490 | rec := seedTestRecord(t, s, testSeed(t, seeds.KindReplica, seeds.StatePlaced)) |
| 491 | if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, MoveRequest("uk", time.Now())); err != nil { |
| 492 | t.Fatalf("a placed seed: %v", err) |
| 493 | } |
| 494 | } |
| 495 | ``` |
| 496 | |
| 497 | `internal/console/seedmove_test.go` (the legacy door is the one a default console runs; the Phase 1 door needs `MovesEnabled` and an operator, which `apiinstancemove_test.go` sets up with a helper: read it and use the same helper for the second half): |
| 498 | |
| 499 | ```go |
| 500 | package console |
| 501 | |
| 502 | import ( |
| 503 | "context" |
| 504 | "net/http" |
| 505 | "strings" |
| 506 | "testing" |
| 507 | "time" |
| 508 | |
| 509 | "github.com/carlosframework/platform/internal/provision" |
| 510 | "github.com/carlosframework/platform/internal/seeds" |
| 511 | ) |
| 512 | |
| 513 | func TestMoveDoorsRefuseAnUnplacedSeed(t *testing.T) { |
| 514 | fx, token := instanceFixture(t, "code-seed-move") |
| 515 | m := titoManifest() |
| 516 | m.Pool = "" |
| 517 | enableInstances(t, fx, "titogo", m) |
| 518 | id, _ := seeds.NewID() |
| 519 | rec := provision.Record{Schema: 1, Host: "canary.go.tito.io", Slug: "canary", App: "titogo", Account: "bab", |
| 520 | Region: "ie", Channel: "stable", CreatedAt: time.Now().UTC(), |
| 521 | Seed: &provision.SeedSource{ID: id, Kind: seeds.KindReplica, State: seeds.StateReady, By: "t", At: time.Now().UTC()}} |
| 522 | if err := provision.CreateRecord(context.Background(), fx.rel, rec); err != nil { |
| 523 | t.Fatal(err) |
| 524 | } |
| 525 | code, _, raw := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo")+"/canary.go.tito.io/move", token, `{"region":"uk"}`) |
| 526 | if code != http.StatusConflict || !strings.Contains(raw, "wake the instance once before moving it") { |
| 527 | t.Fatalf("legacy door = %d %s", code, raw) |
| 528 | } |
| 529 | got, _ := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "canary.go.tito.io") |
| 530 | if got.Move != nil { |
| 531 | t.Fatalf("a refused move left a journal: %+v", got.Move) |
| 532 | } |
| 533 | } |
| 534 | ``` |
| 535 | |
| 536 | Add the Phase 1 half with `apiinstancemove_test.go`'s operator fixture (it sets `fx.srv.cfg.MovesEnabled = true` and an operator token); the assertion is the same 409 and sentence. |
| 537 | |
| 538 | - [ ] **Step 2: Run the tests to verify they fail** |
| 539 | |
| 540 | Run: `go test ./internal/provision/ -run TestMoveTransitionsRefuseAnUnplacedSeed -count=1; go test ./internal/console/ -run TestMoveDoorsRefuseAnUnplacedSeed -count=1` |
| 541 | Expected: FAIL: the moves are accepted. |
| 542 | |
| 543 | - [ ] **Step 3: Write the refusal** |
| 544 | |
| 545 | Append to `internal/provision/seed.go`: |
| 546 | |
| 547 | ```go |
| 548 | // SeedMoveSentence is a move refusal's sentence (spec §5.8). |
| 549 | func SeedMoveSentence(block string) string { |
| 550 | switch block { |
| 551 | case SeedMoveUnplaced: |
| 552 | return "wake the instance once before moving it" |
| 553 | case SeedMoveUncommitted: |
| 554 | return "commit or abort the import first" |
| 555 | } |
| 556 | return "" |
| 557 | } |
| 558 | |
| 559 | // seedMoveRefusal is the move transitions' seed rule, under the record CAS. |
| 560 | func seedMoveRefusal(rec *Record) error { |
| 561 | if b := rec.Seed.MoveBlock(); b != "" { |
| 562 | return fmt.Errorf("%w: %s", ErrMoveConflict, SeedMoveSentence(b)) |
| 563 | } |
| 564 | return nil |
| 565 | } |
| 566 | ``` |
| 567 | |
| 568 | In `MoveRequest`'s and `MoveStartTransition`'s closures, first statement: `if err := seedMoveRefusal(rec); err != nil { return err }`. |
| 569 | |
| 570 | `internal/console/seedmove.go`: |
| 571 | |
| 572 | ```go |
| 573 | package console |
| 574 | |
| 575 | import ( |
| 576 | "net/http" |
| 577 | |
| 578 | "github.com/carlosframework/platform/internal/provision" |
| 579 | ) |
| 580 | |
| 581 | // refuseSeedMove answers a move of a seed that is not placed (--from) or |
| 582 | // not committed (import) with the deck's sentence (spec §5.8). The |
| 583 | // transitions refuse it again under the record CAS. |
| 584 | func (s *Server) refuseSeedMove(w http.ResponseWriter, rec *provision.Record) bool { |
| 585 | if b := rec.Seed.MoveBlock(); b != "" { |
| 586 | jsonError(w, http.StatusConflict, provision.SeedMoveSentence(b)) |
| 587 | return true |
| 588 | } |
| 589 | return false |
| 590 | } |
| 591 | ``` |
| 592 | |
| 593 | In `apiinstancemove.go`: after `moveGate` in `legacyHandleAPIInstanceMoveRequest`, `if s.refuseSeedMove(w, rec) { return }`; after `moveOperatorGate` in `handleAPIInstanceMoveRequest`, the same. Both gates return the record they read as a `*provision.Record` (`moveGate` `:44`, `moveOperatorGate` `:357`). |
| 594 | |
| 595 | - [ ] **Step 4: Run the tests to verify they pass** |
| 596 | |
| 597 | Run: `go test ./internal/provision/ -run 'TestMove|TestSeed' -count=1 && go test ./internal/console/ -run 'TestMove|TestAPIInstanceMove' -count=1 && go test . -run TestMoves -count=1` |
| 598 | Expected: PASS, the moves copy gate included (the doors gained one call each). |
| 599 | |
| 600 | - [ ] **Step 5: Wire SD-N100, SD-N101 and commit** |
| 601 | |
| 602 | ```bash |
| 603 | git add internal/provision/seed.go internal/provision/seed_test.go internal/provision/provision.go internal/console/seedmove.go internal/console/seedmove_test.go internal/console/apiinstancemove.go approved_seeded_copy_test.go |
| 604 | git commit -m "moves wait for a seed's placement or an import's commit, in both doors (Plan 8 Task 3) |
| 605 | |
| 606 | Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| 607 | ``` |
| 608 | |
| 609 | ### Task 4: The move worker refuses before any effect |
| 610 | |
| 611 | **Files:** |
| 612 | - Modify: `internal/host/movespass.go:596` (`movesPass`'s record loop) |
| 613 | - Test: `internal/host/movespass_test.go` |
| 614 | |
| 615 | **Interfaces:** |
| 616 | - Consumes: `(*provision.SeedSource).MoveBlock`, `provision.SeedMoveSentence`, `w.notice(key, format string, args ...any)`. |
| 617 | |
| 618 | A record that slipped past an old console (a move journaled while its seed is unplaced or its import uncommitted) is skipped while its move is `preparing`, before the first mirror (legacy) or staging write (Phase 1), with one rate-limited log line. A move already past `preparing` has had its effects and is not this rule's to stop. |
| 619 | |
| 620 | - [ ] **Step 1: Write the failing test** |
| 621 | |
| 622 | ```go |
| 623 | func TestMovesPassSkipsAnUnplacedSeed(t *testing.T) { |
| 624 | h := newMoveHarness(t) |
| 625 | host := "a.go.example.com" |
| 626 | id, _ := seeds.NewID() |
| 627 | rec := provision.Record{Schema: 1, Host: host, Slug: "s", App: "app", Account: "bab", Region: "ie", Channel: "stable", |
| 628 | CreatedAt: time.Now().UTC(), |
| 629 | Move: &provision.MoveState{To: "uk", State: provision.MovePreparing, RequestedAt: time.Now().UTC()}, |
| 630 | Seed: &provision.SeedSource{ID: id, Kind: seeds.KindReplica, State: seeds.StateReady, By: "t", At: time.Now().UTC()}} |
| 631 | if err := provision.CreateRecord(context.Background(), h.s, rec); err != nil { |
| 632 | t.Fatal(err) |
| 633 | } |
| 634 | h.f.put("data-ie", "instances/"+host+"/generations/g/wal/000", "e1") |
| 635 | if err := movesPass(context.Background(), h.s, h.worker()); err != nil { |
| 636 | t.Fatal(err) |
| 637 | } |
| 638 | if staged, _ := h.f.List(context.Background(), "data-uk", "moves/"+host+"/"); len(staged) != 0 { |
| 639 | t.Fatalf("the worker mirrored an unplaced seed: %v", staged) |
| 640 | } |
| 641 | if got := h.read(t, host); got.Move.State != provision.MovePreparing { |
| 642 | t.Fatalf("state = %s", got.Move.State) |
| 643 | } |
| 644 | } |
| 645 | ``` |
| 646 | |
| 647 | (add the `seeds` import to `movespass_test.go`). |
| 648 | |
| 649 | - [ ] **Step 2: Run it to verify it fails** |
| 650 | |
| 651 | Run: `go test ./internal/host/ -run TestMovesPassSkipsAnUnplacedSeed -count=1` |
| 652 | Expected: FAIL: the round mirrors. |
| 653 | |
| 654 | - [ ] **Step 3: Add the skip** |
| 655 | |
| 656 | In `movesPass`'s `for _, rec := range recs {` loop, first statement: |
| 657 | |
| 658 | ```go |
| 659 | // A seed moves only once placed, an import only once committed |
| 660 | // (seeded-instances spec §5.8): a record that slipped past an old |
| 661 | // console is refused here, before the first copy. |
| 662 | if rec.Move != nil && rec.Move.State == provision.MovePreparing { |
| 663 | if b := rec.Seed.MoveBlock(); b != "" { |
| 664 | w.notice(rec.Host+"#seed", "moves: %s: %s", rec.Host, provision.SeedMoveSentence(b)) |
| 665 | continue |
| 666 | } |
| 667 | } |
| 668 | ``` |
| 669 | |
| 670 | - [ ] **Step 4: Run the tests to verify they pass** |
| 671 | |
| 672 | Run: `go test ./internal/host/ -run 'TestMovesPass' -count=1 && go test . -run TestMoves -count=1` |
| 673 | Expected: PASS. |
| 674 | |
| 675 | - [ ] **Step 5: Commit** |
| 676 | |
| 677 | ```bash |
| 678 | git add internal/host/movespass.go internal/host/movespass_test.go |
| 679 | git commit -m "moves worker: an unplaced seed waits before any copy (Plan 8 Task 4) |
| 680 | |
| 681 | Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| 682 | ``` |
| 683 | |
| 684 | ### Task 5: `carlos system legacy-routes assign` |
| 685 | |
| 686 | **Files:** |
| 687 | - Create: `internal/console/apilegacyroutes.go`, `internal/console/apilegacyroutes_test.go`, `system_legacy_routes.go`, `system_legacy_routes_test.go` |
| 688 | - Modify: `internal/console/authcli.go` (route beside the seeds-floor door), `system.go` (`systemVerb`, `runSystem`, `systemUsage`), `docs/site/cli.md` |
| 689 | |
| 690 | **Interfaces:** |
| 691 | - Consumes: `s.apiSystemRoller`, `s.auditSystem`, `release.ValidApp`, `s.store.AccountBySqid(sqid) (*Account, error)`, `s.store.HasAppIn(accountID int64, app string) (bool, error)`, `provision.ListRecords`, `seeds.ReadLegacyOwners`, `seeds.LegacyOwnersKey`. |
| 692 | - Produces: `POST /api/cli/system/legacy-routes` `{"name": "titogo", "to": "bae/titogo"}` answering 200 `{"name":…, "account":…, "app":…}`; `runSystemLegacyRoutesVerb(rest []string)`. |
| 693 | |
| 694 | The door (spec §6.4): operator-only (`apiSystemRoller`, `CARLOS_SYSTEM_OPERATORS` or an ops owner); `name` must be an app name (SD-N104); `to` must be `<account>/<app>` naming an existing app (SD-N105); the file is read with its etag; an existing entry for `name` that maps elsewhere is replaced only when no record of the old mapping's account and app holds an import between `ready` and `committed` (`ready`, `swapped`, `placed`: SD-N103); the same mapping again is a 200 no-op; the file is written by CAS (a lost race or a failed write answers SD-N116); audit SD-N106. |
| 695 | |
| 696 | - [ ] **Step 1: Write the failing tests** |
| 697 | |
| 698 | ```go |
| 699 | package console |
| 700 | |
| 701 | import ( |
| 702 | "context" |
| 703 | "net/http" |
| 704 | "strings" |
| 705 | "testing" |
| 706 | "time" |
| 707 | |
| 708 | "github.com/carlosframework/platform/internal/provision" |
| 709 | "github.com/carlosframework/platform/internal/seeds" |
| 710 | ) |
| 711 | |
| 712 | func TestAssignLegacyRoutes(t *testing.T) { |
| 713 | fx, _ := instanceFixture(t, "code-assign") // app titogo in bab |
| 714 | owner := cliToken(t, fx, "paul@keymail.dev", "code-assign-owner") |
| 715 | u := fx.ts.URL + "/api/cli/system/legacy-routes" |
| 716 | if st, out := postJSON(t, u, map[string]any{"name": "Not An App!", "to": "bab/titogo"}, owner); st != http.StatusBadRequest { |
| 717 | t.Fatalf("bad name = %d %v", st, out) |
| 718 | } |
| 719 | if st, out := postJSON(t, u, map[string]any{"name": "titogo", "to": "bab"}, owner); st != http.StatusBadRequest || !strings.Contains(str(t, out, "error"), "to must be") { |
| 720 | t.Fatalf("bad to = %d %v", st, out) |
| 721 | } |
| 722 | if st, out := postJSON(t, u, map[string]any{"name": "titogo", "to": "bab/titogo"}, owner); st != http.StatusOK || out["account"] != "bab" { |
| 723 | t.Fatalf("assign = %d %v", st, out) |
| 724 | } |
| 725 | o, _, err := seeds.ReadLegacyOwners(context.Background(), fx.rel) |
| 726 | if got, ok := o.Lookup("titogo"); err != nil || !ok || got != (seeds.LegacyOwner{Account: "bab", App: "titogo"}) { |
| 727 | t.Fatalf("file = %+v %v", o, err) |
| 728 | } |
| 729 | } |
| 730 | |
| 731 | func TestAssignRefusedWhileAnImportIsInFlight(t *testing.T) { |
| 732 | fx, _ := instanceFixture(t, "code-assign-busy") |
| 733 | owner := cliToken(t, fx, "paul@keymail.dev", "code-assign-busy-owner") |
| 734 | u := fx.ts.URL + "/api/cli/system/legacy-routes" |
| 735 | postJSON(t, u, map[string]any{"name": "titogo", "to": "bab/titogo"}, owner) |
| 736 | id, _ := seeds.NewID() |
| 737 | rec := provision.Record{Schema: 1, Host: "old.go.tito.io", Slug: "old", App: "titogo", Account: "bab", Channel: "stable", |
| 738 | CreatedAt: time.Now().UTC(), Seed: &provision.SeedSource{ID: id, Kind: seeds.KindImport, State: seeds.StateSwapped, By: "t", At: time.Now().UTC()}} |
| 739 | if err := provision.CreateRecord(context.Background(), fx.rel, rec); err != nil { |
| 740 | t.Fatal(err) |
| 741 | } |
| 742 | createApp(t, fx, "bab", "other") // the fixture helper that adds an app to an account |
| 743 | st, out := postJSON(t, u, map[string]any{"name": "titogo", "to": "bab/other"}, owner) |
| 744 | if st != http.StatusConflict || !strings.Contains(str(t, out, "error"), "has an import in flight") { |
| 745 | t.Fatalf("re-assign = %d %v", st, out) |
| 746 | } |
| 747 | o, _, _ := seeds.ReadLegacyOwners(context.Background(), fx.rel) |
| 748 | if got, _ := o.Lookup("titogo"); got.App != "titogo" { |
| 749 | t.Fatalf("the file changed: %+v", got) |
| 750 | } |
| 751 | } |
| 752 | ``` |
| 753 | |
| 754 | `createApp(t, fx, sqid, app)`: use the store call `instanceFixture` uses to create `titogo` (read `apiinstances_test.go:102`) for a second app. A member who is not an operator gets the bare 404 (the `apiSystemRoller` rule); add that assertion as `TestSeedsFloorDoorIsForSystemOperators` does. |
| 755 | |
| 756 | `system_legacy_routes_test.go`: |
| 757 | |
| 758 | ```go |
| 759 | package main |
| 760 | |
| 761 | import "testing" |
| 762 | |
| 763 | func TestSystemVerbKnowsLegacyRoutes(t *testing.T) { |
| 764 | verb, rest, _ := systemVerb([]string{"legacy-routes", "assign", "--name", "titogo", "--to", "bae/titogo"}) |
| 765 | if verb != "legacy-routes" || len(rest) != 5 || rest[0] != "assign" { |
| 766 | t.Fatalf("systemVerb = %q %q", verb, rest) |
| 767 | } |
| 768 | } |
| 769 | ``` |
| 770 | |
| 771 | - [ ] **Step 2: Run the tests to verify they fail** |
| 772 | |
| 773 | Run: `go test ./internal/console/ -run 'TestAssign' -count=1; go test . -run TestSystemVerbKnowsLegacyRoutes -count=1` |
| 774 | Expected: FAIL. |
| 775 | |
| 776 | - [ ] **Step 3: Write the door, the route, the verb and the docs** |
| 777 | |
| 778 | `internal/console/apilegacyroutes.go`: |
| 779 | |
| 780 | ```go |
| 781 | package console |
| 782 | |
| 783 | import ( |
| 784 | "encoding/json" |
| 785 | "errors" |
| 786 | "fmt" |
| 787 | "log" |
| 788 | "net/http" |
| 789 | "strings" |
| 790 | |
| 791 | "github.com/carlosframework/platform/internal/provision" |
| 792 | "github.com/carlosframework/platform/internal/release" |
| 793 | "github.com/carlosframework/platform/internal/seeds" |
| 794 | ) |
| 795 | |
| 796 | // handleAPISystemLegacyRoutes is `carlos system legacy-routes assign` |
| 797 | // (seeded-instances spec §6.4, codex 9): an operator maps an app name, as |
| 798 | // filed under the operator account, to one account and app. |
| 799 | func (s *Server) handleAPISystemLegacyRoutes(w http.ResponseWriter, r *http.Request) { |
| 800 | addr, _, ok := s.apiSystemRoller(w, r) |
| 801 | if !ok { |
| 802 | return |
| 803 | } |
| 804 | var req struct { |
| 805 | Name string `json:"name"` |
| 806 | To string `json:"to"` |
| 807 | } |
| 808 | if !readJSON(w, r, &req) { |
| 809 | return |
| 810 | } |
| 811 | name := strings.TrimSpace(req.Name) |
| 812 | if !release.ValidApp(name) { |
| 813 | jsonError(w, http.StatusBadRequest, "name must be an app name") |
| 814 | return |
| 815 | } |
| 816 | acctSqid, app, found := strings.Cut(strings.TrimSpace(req.To), "/") |
| 817 | if !found || !release.ValidApp(app) { |
| 818 | jsonError(w, http.StatusBadRequest, "to must be <account>/<app>") |
| 819 | return |
| 820 | } |
| 821 | acct, err := s.store.AccountBySqid(acctSqid) |
| 822 | if err != nil || acct == nil { |
| 823 | jsonError(w, http.StatusBadRequest, "to must be <account>/<app>") |
| 824 | return |
| 825 | } |
| 826 | if has, err := s.store.HasAppIn(acct.ID, app); err != nil || !has { |
| 827 | jsonError(w, http.StatusBadRequest, "to must be <account>/<app>") |
| 828 | return |
| 829 | } |
| 830 | ctx := r.Context() |
| 831 | owners, etag, err := seeds.ReadLegacyOwners(ctx, s.cfg.Releases) |
| 832 | if err != nil { |
| 833 | log.Printf("console: legacy routes: %v", err) |
| 834 | jsonError(w, http.StatusBadGateway, "could not read the legacy route assignments. Try again.") |
| 835 | return |
| 836 | } |
| 837 | want := seeds.LegacyOwner{Account: acct.Sqid, App: app} |
| 838 | if cur, ok := owners.Lookup(name); ok { |
| 839 | if cur == want { |
| 840 | writeJSON(w, http.StatusOK, map[string]string{"name": name, "account": want.Account, "app": want.App}) |
| 841 | return |
| 842 | } |
| 843 | busy, err := importInFlight(ctx, s.cfg.Releases, cur) |
| 844 | if err != nil { |
| 845 | log.Printf("console: legacy routes: in-flight check: %v", err) |
| 846 | jsonError(w, http.StatusBadGateway, "could not read the legacy route assignments. Try again.") |
| 847 | return |
| 848 | } |
| 849 | if busy { |
| 850 | jsonError(w, http.StatusConflict, fmt.Sprintf("%s has an import in flight under its current assignment. Commit or abort it first.", name)) |
| 851 | return |
| 852 | } |
| 853 | } |
| 854 | owners.Names[name] = want |
| 855 | body, _ := json.Marshal(owners) |
| 856 | if _, err := s.cfg.Releases.Put(ctx, seeds.LegacyOwnersKey, body, etag); err != nil { |
| 857 | if !errors.Is(err, release.ErrCASConflict) { |
| 858 | log.Printf("console: legacy routes: write: %v", err) |
| 859 | } |
| 860 | jsonError(w, http.StatusBadGateway, "could not save the legacy route assignment. Try again.") |
| 861 | return |
| 862 | } |
| 863 | s.auditSystem(addr, "legacy-route-assign", fmt.Sprintf("assigned the legacy routes named %s to %s/%s", name, want.Account, want.App)) |
| 864 | s.bumpSerial(ctx) |
| 865 | writeJSON(w, http.StatusOK, map[string]string{"name": name, "account": want.Account, "app": want.App}) |
| 866 | } |
| 867 | |
| 868 | // importInFlight reports an import of o's account and app between ready |
| 869 | // and committed (spec §6.4). |
| 870 | func importInFlight(ctx context.Context, rs release.Store, o seeds.LegacyOwner) (bool, error) { |
| 871 | recs, truncated, err := provision.ListRecords(ctx, rs, o.Account, o.App, 10000) |
| 872 | if err != nil { |
| 873 | return false, err |
| 874 | } |
| 875 | if truncated { |
| 876 | return true, nil // a listing that did not see every record never says "idle" |
| 877 | } |
| 878 | for _, rec := range recs { |
| 879 | if rec.Seed.Import() { |
| 880 | switch rec.Seed.State { |
| 881 | case seeds.StateReady, seeds.StateSwapped, seeds.StatePlaced: |
| 882 | return true, nil |
| 883 | } |
| 884 | } |
| 885 | } |
| 886 | return false, nil |
| 887 | } |
| 888 | ``` |
| 889 | |
| 890 | (add `"context"` to the imports). |
| 891 | |
| 892 | In `authcli.go`, beside the seeds-floor route: `s.cliRoute("POST /api/cli/system/legacy-routes", svcrole.Set{}, s.handleAPISystemLegacyRoutes)`. |
| 893 | |
| 894 | `system_legacy_routes.go`: |
| 895 | |
| 896 | ```go |
| 897 | package main |
| 898 | |
| 899 | import ( |
| 900 | "context" |
| 901 | "flag" |
| 902 | "fmt" |
| 903 | "log" |
| 904 | "net/http" |
| 905 | "os" |
| 906 | ) |
| 907 | |
| 908 | // runSystemLegacyRoutesVerb is `carlos system legacy-routes assign` |
| 909 | // (seeded-instances spec §6.4). |
| 910 | func runSystemLegacyRoutesVerb(rest []string) { |
| 911 | if len(rest) == 0 || rest[0] != "assign" { |
| 912 | systemUsage() |
| 913 | os.Exit(2) |
| 914 | } |
| 915 | fs := flag.NewFlagSet("system legacy-routes assign", flag.ExitOnError) |
| 916 | consoleFlag := consoleOption(fs, "console origin (default: the only logged-in one)") |
| 917 | name := fs.String("name", "", "the app name the legacy routes are filed under, in the operator account") |
| 918 | to := fs.String("to", "", "the account and app they belong to, as <account>/<app>") |
| 919 | fs.Parse(rest[1:]) |
| 920 | if *name == "" || *to == "" || fs.NArg() != 0 { |
| 921 | fs.Usage() |
| 922 | os.Exit(2) |
| 923 | } |
| 924 | sess := requireDeployAPISession(*consoleFlag) |
| 925 | printUsing(stdout, sess) |
| 926 | status, body, err := sess.postJSON(context.Background(), "/api/cli/system/legacy-routes", map[string]string{"name": *name, "to": *to}) |
| 927 | if err != nil { |
| 928 | log.Fatalf("carlos system legacy-routes assign: %v", err) |
| 929 | } |
| 930 | if status != http.StatusOK { |
| 931 | log.Fatalf("carlos system legacy-routes assign: %v", apiErrorFromBody(status, body)) |
| 932 | } |
| 933 | var out struct { |
| 934 | Name string `json:"name"` |
| 935 | Account string `json:"account"` |
| 936 | App string `json:"app"` |
| 937 | } |
| 938 | if err := json.Unmarshal(body, &out); err != nil { |
| 939 | log.Fatalf("carlos system legacy-routes assign: unreadable response: %v", err) |
| 940 | } |
| 941 | fmt.Fprintln(stdout, fmt.Sprintf("assigned the legacy routes named %s to %s/%s", out.Name, out.Account, out.App)) |
| 942 | } |
| 943 | ``` |
| 944 | |
| 945 | (add `"encoding/json"` to the imports). |
| 946 | |
| 947 | In `system.go`: `"legacy-routes"` in `systemVerb`'s case list; `case "legacy-routes": runSystemLegacyRoutesVerb(rest)` in `runSystem`; the usage line ` carlos system legacy-routes assign --name <name> --to <account>/<app>` at the top, and under "The platform's own binary (ops account owners and CARLOS_SYSTEM_OPERATORS):" |
| 948 | |
| 949 | ``` |
| 950 | legacy-routes assign --name <name> --to <account>/<app> |
| 951 | the account and app they belong to, as <account>/<app> |
| 952 | ``` |
| 953 | |
| 954 | In `docs/site/cli.md`, under `### carlos system`, a `#### Legacy route assignments` subsection with SD-N110's approved text and: |
| 955 | |
| 956 | ````markdown |
| 957 | ``` |
| 958 | carlos system legacy-routes assign --name titogo --to bae/titogo |
| 959 | ``` |
| 960 | ```` |
| 961 | |
| 962 | - [ ] **Step 4: Run the tests to verify they pass** |
| 963 | |
| 964 | Run: `go test ./internal/console/ -run 'TestAssign|TestSystemDoors' -count=1 && go test . -run 'TestSystemVerb|TestSeeded|TestMoves' -count=1` |
| 965 | Expected: PASS. |
| 966 | |
| 967 | - [ ] **Step 5: Wire SD-N102 to SD-N110, SD-N116 and commit** |
| 968 | |
| 969 | ```bash |
| 970 | git add internal/console/apilegacyroutes.go internal/console/apilegacyroutes_test.go internal/console/authcli.go system.go system_legacy_routes.go system_legacy_routes_test.go docs/site/cli.md approved_seeded_copy_test.go |
| 971 | git commit -m "carlos system legacy-routes assign (Plan 8 Task 5) |
| 972 | |
| 973 | Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| 974 | ``` |
| 975 | |
| 976 | ### Task 6: Series gate |
| 977 | |
| 978 | - [ ] **Step 1: Every deck row is wired** |
| 979 | |
| 980 | `seededWiredIDs` now names every id in `seededCopyIDs`. Add the test that holds it: |
| 981 | |
| 982 | ```go |
| 983 | func TestEverySeededRowIsWired(t *testing.T) { |
| 984 | wired := map[string]bool{} |
| 985 | for _, id := range seededWiredIDs { |
| 986 | wired[id] = true |
| 987 | } |
| 988 | for _, id := range seededCopyIDs { |
| 989 | if !wired[id] { |
| 990 | t.Errorf("%s is approved and never wired", id) |
| 991 | } |
| 992 | } |
| 993 | } |
| 994 | ``` |
| 995 | |
| 996 | Run: `go test . -run 'TestSeeded|TestEverySeededRowIsWired' -count=1` (PASS). |
| 997 | |
| 998 | - [ ] **Step 2: Cross-compile and run the full suite under -race once** |
| 999 | |
| 1000 | Run: |
| 1001 | ```bash |
| 1002 | GOOS=darwin go build ./... && GOOS=windows go build ./... && GOOS=linux GOARCH=arm64 go build ./... && \ |
| 1003 | nice go test -race ./... -count=1 |
| 1004 | ``` |
| 1005 | Expected: PASS. |
| 1006 | |
| 1007 | - [ ] **Step 3: Commit** |
| 1008 | |
| 1009 | ```bash |
| 1010 | git add approved_seeded_copy_test.go |
| 1011 | git commit -m "copy gate: every seeded row is wired (Plan 8 Task 6) |
| 1012 | |
| 1013 | Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| 1014 | ``` |
| 1015 | |
| 1016 | --- |
| 1017 | |
| 1018 | ## Self-review |
| 1019 | |
| 1020 | - Spec coverage: §3.1's three sweep cases with seed-ID-scoped deletes and the CAS-first abandonment (Task 2), §5.6's sweep as the finish's fallback (Task 2 calling Plan 6's `finishSeed`), §5.8 in both doors under the CAS and in the worker before effects (Tasks 3, 4), §6.4's operator-only assignment with its in-flight rule and audit row (Task 5), §7's move errors (Task 3). |
| 1021 | - §9's console tests: the seeds sweep deletes only unreferenced or superseded snapshots and never one a live `--from` needs (Task 2); the sweep abandons `reserving` only after an hour (Task 2); a move refused before placement or commit in both doors and the worker (Tasks 3, 4); `system legacy-routes assign` (Task 5). |
| 1022 | - Deviation: the sweep also deletes a snapshot whose own record is `placed` or `committed` (Task 2's note); spec §3.1 lists three cases and does not cover a box's lost delete. |
| 1023 | - Deviation: "more than an hour old" is the object's last-modified time from `ListMod`; a store without `ListMod` skips the snapshot walk. |
| 1024 | - The dev-box checks of spec §9 and the rollout of §10 need Paul and are not tasks here. |
| 1025 | |