seeded copy review round 2: apply the 20 approved strings (2026-10-10, approved unedited)
Writes Paul's approved text verbatim into the console, provision and CLI, and registers it with the seeded copy gate as SD-N117 to SD-N136 (SD-N110's last sentence is replaced in place). Behaviour changes: --from with --at past the latest replicated txid is refused with a 409 before anything is reserved; assigning a legacy route name to an app of another name is refused with a 400; an uncommitted import that is already aborted gets its own delete answer; the CLI's import ends a stuck check wait, a vanished record and a replaced import with real sentences; the swap's claim reason token is claim_owner. Kept as they are: the import 404 (RUN-C16's text) and the legacy-route audit detail (SD-N106's text).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
21 files changed,
+358
−104
approved_seeded_copy_test.go+6 −4docs/site/cli.md+1 −1docs/superpowers/plans/2026-10-08-seeded-instances-copy.md+21 −1instances_import.go+72 −8instances_import_test.go+60 −3instances_seed.go+5 −1internal/console/apiinstanceimport.go+24 −12internal/console/apiinstanceimport_test.go+20 −11internal/console/apiinstanceseed.go+31 −15internal/console/apiinstanceseed_test.go+26 −11internal/console/apilegacyroutes.go+6 −0internal/console/apilegacyroutes_test.go+57 −19internal/console/fleets.go+1 −1internal/console/importdelete_test.go+2 −2internal/console/seedenvuse_test.go+12 −10internal/console/seedmove.go+5 −0internal/console/seedmove_test.go+2 −2internal/provision/seedswap.go+1 −1internal/provision/seedswap_test.go+1 −1system.go+1 −1system_legacy_routes.go+4 −0
CI passed — run details
diff --git a/approved_seeded_copy_test.go b/approved_seeded_copy_test.go| index 9080f617d..f969c667c 100644 |
| --- a/approved_seeded_copy_test.go |
| +++ b/approved_seeded_copy_test.go |
| @@ -24,8 +24,8 @@ const seededCopyRecordPath = "docs/superpowers/plans/2026-10-08-seeded-instances |
| var seededCopyIDPattern = regexp.MustCompile(`^SD-N[0-9]+$`) |
| var seededCopyIDs = func() []string { |
| - ids := make([]string, 0, 116) |
| - for n := 1; n <= 116; n++ { |
| + ids := make([]string, 0, 136) |
| + for n := 1; n <= 136; n++ { |
| ids = append(ids, "SD-N"+strconv.Itoa(n)) |
| } |
| return ids |
| @@ -38,7 +38,8 @@ 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 5 SD-N102 to SD-N110 and SD-N116. |
| +// Plan 8 Task 3 SD-N100 and SD-N101; Plan 8 Task 5 SD-N102 to SD-N110 and SD-N116; |
| +// the 2026-10-10 copy review (round 2) SD-N117 to SD-N136, with SD-N110's last sentence replaced. |
| 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", |
| @@ -51,7 +52,8 @@ var seededWiredIDs = []string{"SD-N1", "SD-N2", "SD-N3", "SD-N4", "SD-N5", "SD-N |
| "SD-N91", "SD-N92", "SD-N93", "SD-N94", "SD-N95", "SD-N96", "SD-N97", "SD-N98", |
| "SD-N99", |
| "SD-N100", "SD-N101", |
| - "SD-N102", "SD-N103", "SD-N104", "SD-N105", "SD-N106", "SD-N107", "SD-N108", "SD-N109", "SD-N110", "SD-N116"} |
| + "SD-N102", "SD-N103", "SD-N104", "SD-N105", "SD-N106", "SD-N107", "SD-N108", "SD-N109", "SD-N110", "SD-N116", |
| + "SD-N117", "SD-N118", "SD-N119", "SD-N120", "SD-N121", "SD-N122", "SD-N123", "SD-N124", "SD-N125", "SD-N126", "SD-N127", "SD-N128", "SD-N129", "SD-N130", "SD-N131", "SD-N132", "SD-N133", "SD-N134", "SD-N135", "SD-N136"} |
| func mustParseSeededRecord(t *testing.T) []copyRow { |
| t.Helper() |
diff --git a/docs/site/cli.md b/docs/site/cli.md| index 324dab10d..a69ccb46f 100644 |
| --- a/docs/site/cli.md |
| +++ b/docs/site/cli.md |
| @@ -2057,7 +2057,7 @@ 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. |
| +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 in progress, from when its old process is marked stopped until it is committed or aborted. |
| ```sh |
| carlos system legacy-routes assign --name shop --to bac/shop |
diff --git a/docs/superpowers/plans/2026-10-08-seeded-instances-copy.md b/docs/superpowers/plans/2026-10-08-seeded-instances-copy.md| index f35ac1522..cc1575943 100644 |
| --- a/docs/superpowers/plans/2026-10-08-seeded-instances-copy.md |
| +++ b/docs/superpowers/plans/2026-10-08-seeded-instances-copy.md |
| @@ -165,6 +165,26 @@ Columns: id, file, text, status. |
| | SD-N107 | internal/console/apilegacyroutes.go | could not read the legacy route assignments. Try again. | approved | |
| | SD-N108 | system_legacy_routes.go | the app name the legacy routes are filed under, in the operator account | approved | |
| | SD-N109 | system_legacy_routes.go | the account and app they belong to, as <account>/<app> | approved | |
| -| SD-N110 | docs/site/cli.md | 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. | approved | |
| +| SD-N110 | docs/site/cli.md | 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 in progress, from when its old process is marked stopped until it is committed or aborted. | approved | |
| | SD-N116 | internal/console/apilegacyroutes.go | could not save the legacy route assignment. Try again. | approved | |
| | SD-N111 | docs/site/cli.md | carlos system seeds-floor publish sets the first release whose boxes can place a seed. Publish it once every box runs that release. Each box arms it within the hour, and from then on a box never recovers to an older build. Until every box in a region has armed it, carlos instances create --from and carlos instances import refuse there. | approved | |
| +| SD-N117 | internal/console/apiinstanceimport.go | a create takes from or import, not both. | approved | |
| +| SD-N118 | internal/console/apiinstanceimport.go | an import runs in this console's region, <console-region>. To run <host> elsewhere, import it here, commit it, and then run carlos move. | approved | |
| +| SD-N119 | internal/console/apiinstanceseed.go | <host> is app <label>'s platform apex. That app was created while this instance was being written, so the instance was not kept. | approved | |
| +| SD-N120 | internal/console/apiinstanceimport.go | could not update the import of <host>. Try again. | approved | |
| +| SD-N121 | internal/console/apiinstanceimport.go | the import of <host> is already aborted. Once the box puts the old route back on its next pass, delete <host> again. | approved | |
| +| SD-N122 | internal/console/apiinstanceimport.go | could not delete the instance. Try again. | approved | |
| +| SD-N123 | instances_import.go | usage: carlos instances import --app <a> --host <h> --file <snapshot> --replaces <host:port> | approved | |
| +| SD-N124 | instances_import.go | [--verify <path>] [--environment <e>] | approved | |
| +| SD-N125 | instances_import.go | carlos instances import commit --app <a> --host <h> | approved | |
| +| SD-N126 | instances_import.go | carlos instances import abort --app <a> --host <h> | approved | |
| +| SD-N127 | instances_import.go | carlos instances import moves an instance the platform never ran onto the platform. | approved | |
| +| SD-N128 | instances_import.go | the console has not kept the upload of <host> after <minutes> minutes. Check carlos instances list. To start over, run carlos instances import abort --app <app> --host <host>, then run the import again. | approved | |
| +| SD-N129 | instances_import.go | the import of <host> has ended. It was deleted, or another import of <host> began. Check carlos instances list. | approved | |
| +| SD-N130 | instances_import.go | <host> is not an instance of <app>, so there is no import to abort. An import that was aborted and cleaned up leaves no instance behind. | approved | |
| +| SD-N131 | internal/console/apiinstanceseed.go | <source> has replicated up to txid <latest>, so there is no txid <txid> to copy yet. Leave out --at to copy the latest. | approved | |
| +| SD-N132 | internal/console/apiinstanceseed.go | could not copy <source>, so no instance was created. <host> is free again within about 5 minutes. Try again then. | approved | |
| +| SD-N133 | internal/console/apiinstanceseed.go | environment <env> is already used by another instance of <app>. A seeded instance gets a new environment named after its host, so choose another host. | approved | |
| +| SD-N134 | system_legacy_routes.go | say which account and app the routes an operator filed by hand belong to | approved | |
| +| SD-N135 | internal/console/apilegacyroutes.go | the routes are filed under <name>, so they can only be assigned to an app named <name>. Use --to <account>/<name>. | approved | |
| +| SD-N136 | internal/console/seedmove.go | could not update this app's instances. Try again. | approved | |
diff --git a/instances_import.go b/instances_import.go| index 7ffe0c923..d408d7915 100644 |
| --- a/instances_import.go |
| +++ b/instances_import.go |
| @@ -34,6 +34,51 @@ const ( |
| importVerifyRetry = 3 * time.Second |
| ) |
| +// The usage block of carlos instances import, one line to a literal. The |
| +// continuation lines are indented when the block is put together. |
| +const ( |
| + importUsageSynopsis = "usage: carlos instances import --app <a> --host <h> --file <snapshot> --replaces <host:port>" |
| + importUsageOptions = "[--verify <path>] [--environment <e>]" |
| + importUsageCommit = "carlos instances import commit --app <a> --host <h>" |
| + importUsageAbort = "carlos instances import abort --app <a> --host <h>" |
| + importUsageDescription = "carlos instances import moves an instance the platform never ran onto the platform." |
| +) |
| + |
| +// importUsage is the block flagUsage prints above the flags of import, |
| +// import commit and import abort. |
| +func importUsage() string { |
| + optionsIndent := strings.Repeat(" ", 30) |
| + verbIndent := strings.Repeat(" ", 7) |
| + return strings.Join([]string{ |
| + importUsageSynopsis, |
| + optionsIndent + importUsageOptions, |
| + verbIndent + importUsageCommit, |
| + verbIndent + importUsageAbort, |
| + "", |
| + importUsageDescription, |
| + }, "\n") |
| +} |
| + |
| +// importCheckWaitOverLine is what the CLI says when the console has not |
| +// kept the upload by the end of importCheckWait. |
| +func importCheckWaitOverLine(app, host string) string { |
| + return fmt.Sprintf("the console has not kept the upload of %s after %d minutes. Check carlos instances list. To start over, run carlos instances import abort --app %s --host %s, then run the import again.", |
| + host, int(importCheckWait/time.Minute), app, host) |
| +} |
| + |
| +// importEndedLine is what the CLI says when the record it was following |
| +// has gone, or now carries another import. |
| +func importEndedLine(host string) string { |
| + return fmt.Sprintf("the import of %s has ended. It was deleted, or another import of %s began. Check carlos instances list.", host, host) |
| +} |
| + |
| +// importNothingToAbortError is import abort's error for a host with no |
| +// record. The CLI cannot tell a typo from an import that was aborted and |
| +// cleaned up, so the sentence says both. |
| +func importNothingToAbortError(host, app string) error { |
| + return fmt.Errorf("%s is not an instance of %s, so there is no import to abort. An import that was aborted and cleaned up leaves no instance behind.", host, app) |
| +} |
| + |
| // errImportFailed is an import that ended badly once its own line was |
| // printed: the command exits 1 and adds nothing. |
| var errImportFailed = errors.New("instances import failed") |
| @@ -84,7 +129,7 @@ func runInstancesImport(args []string) { |
| consoleFlag := consoleOption(fs, "console to create through (default: the only logged-in one)") |
| acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)") |
| acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account") |
| - fs.Usage = flagUsage(fs, "") |
| + fs.Usage = flagUsage(fs, importUsage()) |
| fs.Parse(args) |
| if *host == "" || *file == "" || *replaces == "" { |
| fs.Usage() |
| @@ -195,7 +240,10 @@ func (s *apiSession) driveImport(ctx context.Context, w io.Writer, j importJob, |
| } |
| // The console answered 202 and checks the file on its own; the record |
| // stays awaiting-upload until it is kept. |
| - if _, err := s.pollSeed(ctx, j.acct, j.app, j.host, importCheckWait, importLook(id, "uploaded", "ready", "swapped", "placed", "committed")); err != nil { |
| + if _, err := s.pollImport(ctx, j, importCheckWait, importLook(j.host, id, "uploaded", "ready", "swapped", "placed", "committed")); err != nil { |
| + if errors.Is(err, errSeedWaitOver) { |
| + return importLive{lines: []string{importCheckWaitOverLine(j.app, j.host)}} |
| + } |
| return err |
| } |
| if err := s.importStep(ctx, j.acct, j.app, j.host, id, "stopped"); err != nil { |
| @@ -203,7 +251,7 @@ func (s *apiSession) driveImport(ctx context.Context, w io.Writer, j importJob, |
| } |
| fmt.Fprintln(w, fmt.Sprintf("uploaded. The old process of %s is marked stopped, and the box swaps it on its next pass.", j.host)) |
| fmt.Fprintln(w, fmt.Sprintf("waiting for the box to swap %s", j.host)) |
| - swapped, err := s.pollSeed(ctx, j.acct, j.app, j.host, importSwapWait, importLook(id, "swapped", "placed", "committed")) |
| + swapped, err := s.pollImport(ctx, j, importSwapWait, importLook(j.host, id, "swapped", "placed", "committed")) |
| if err != nil { |
| if errors.Is(err, errSeedWaitOver) { |
| return importLive{lines: []string{fmt.Sprintf("the box has not swapped %s after %d minutes. Check carlos instances list, then commit or abort.", |
| @@ -233,7 +281,7 @@ func (s *apiSession) driveImport(ctx context.Context, w io.Writer, j importJob, |
| if verr == nil && code >= 200 && code < 300 { |
| fmt.Fprintln(w, fmt.Sprintf("verify passed (%d). Committing.", code)) |
| fmt.Fprintln(w, "waiting for the box to finish placing the snapshot") |
| - if _, err := s.pollSeed(ctx, j.acct, j.app, j.host, importPlaceWait, importLook(id, "placed", "committed")); err != nil { |
| + if _, err := s.pollImport(ctx, j, importPlaceWait, importLook(j.host, id, "placed", "committed")); err != nil { |
| if errors.Is(err, errSeedWaitOver) { |
| return importLive{lines: []string{importFinishLine(j.app, j.host)}} |
| } |
| @@ -257,15 +305,28 @@ func (s *apiSession) driveImport(ctx context.Context, w io.Writer, j importJob, |
| return errImportFailed |
| } |
| +// pollImport is pollSeed for an import: a record that has gone ends the |
| +// import with the line to print, importEndedLine, rather than the |
| +// membership sentence. |
| +func (s *apiSession) pollImport(ctx context.Context, j importJob, limit time.Duration, look func(apiInstanceRecord) (bool, error)) (apiInstanceRecord, error) { |
| + rec, err := s.pollSeed(ctx, j.acct, j.app, j.host, limit, look) |
| + var gone seedGone |
| + if errors.As(err, &gone) { |
| + return rec, importOver{line: importEndedLine(j.host)} |
| + } |
| + return rec, err |
| +} |
| + |
| // importLook is pollSeed's look for an import: done once the seed is in |
| // one of states. A refused, aborted or rolled-back seed, or a record that |
| // no longer carries this import (id, when known), ends the import with the |
| -// line to print: SD-N78, the seed's reason, or seedCell's "aborting". |
| -func importLook(id string, states ...string) func(apiInstanceRecord) (bool, error) { |
| +// line to print: SD-N78, the seed's reason, seedCell's "aborting", or, for |
| +// a record that carries no such import, importEndedLine. |
| +func importLook(host, id string, states ...string) func(apiInstanceRecord) (bool, error) { |
| return func(rec apiInstanceRecord) (bool, error) { |
| sd := rec.Seed |
| if sd == nil || (id != "" && sd.ID != id) { |
| - return false, importOver{line: seedCell(apiInstanceRecord{Seed: &apiSeed{Kind: "import", State: "aborted"}})} |
| + return false, importOver{line: importEndedLine(host)} |
| } |
| switch sd.State { |
| case "refused": |
| @@ -337,7 +398,7 @@ func runInstancesImportVerb(verb string, args []string) { |
| consoleFlag := consoleOption(fs, "console to create through (default: the only logged-in one)") |
| acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)") |
| acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account") |
| - fs.Usage = flagUsage(fs, "") |
| + fs.Usage = flagUsage(fs, importUsage()) |
| fs.Parse(args) |
| if *host == "" { |
| fs.Usage() |
| @@ -366,6 +427,9 @@ func (s *apiSession) runImportVerb(ctx context.Context, w io.Writer, a apiAcct, |
| id = rec.Seed.ID |
| } |
| if err := s.importStep(ctx, a, app, host, id, verb); err != nil { |
| + if verb == "abort" && !found && err.Error() == fmt.Sprintf("%s is not an instance of %s", host, app) { |
| + return importNothingToAbortError(host, app) |
| + } |
| return err |
| } |
| if verb == "commit" { |
diff --git a/instances_import_test.go b/instances_import_test.go| index b230047d9..152b9c978 100644 |
| --- a/instances_import_test.go |
| +++ b/instances_import_test.go |
| @@ -616,15 +616,70 @@ func TestImportRolledBackWhileWaitingForTheSwap(t *testing.T) { |
| } |
| } |
| +const importEnded = "the import of main.shop.example.com has ended. It was deleted, or another import of main.shop.example.com began. Check carlos instances list." |
| + |
| func TestImportAnotherImportOnTheHostEndsTheWait(t *testing.T) { |
| fakeSeedWait(t, 2*time.Second) |
| c := newImportConsole(importRecord("uploaded", ""), importRecordID("sd_other", "ready", "")) |
| out, err := importRun(t, c, "") |
| - if !errors.Is(err, errImportFailed) || !strings.HasSuffix(out, "\naborting\n") { |
| + if !errors.Is(err, errImportFailed) || !strings.HasSuffix(out, "\n"+importEnded+"\n") || strings.Contains(out, "aborting") { |
| t.Fatalf("err = %v, output:\n%s", err, out) |
| } |
| } |
| +// A record that goes while the CLI waits for the check, the swap or the |
| +// placing says the import ended, not the membership sentence. |
| +func TestImportRecordGoneEndsTheWait(t *testing.T) { |
| + gone := answerWith(http.StatusNotFound, "") |
| + for _, c := range []struct { |
| + name string |
| + gets []func(http.ResponseWriter) |
| + }{ |
| + {"waiting for the check", []func(http.ResponseWriter){importRecord("awaiting-upload", ""), gone}}, |
| + {"waiting for the swap", []func(http.ResponseWriter){importRecord("uploaded", ""), importRecord("ready", ""), gone}}, |
| + {"waiting for the placing", []func(http.ResponseWriter){importRecord("uploaded", ""), importRecord("swapped", ""), gone}}, |
| + } { |
| + t.Run(c.name, func(t *testing.T) { |
| + fakeSeedWait(t, 2*time.Second) |
| + out, err := importRun(t, newImportConsole(c.gets...), "/up", http.StatusOK) |
| + if !errors.Is(err, errImportFailed) || !strings.HasSuffix(out, "\n"+importEnded+"\n") || strings.Contains(out, "not found") { |
| + t.Fatalf("err = %v, output:\n%s", err, out) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// The check wait is 55 minutes of looks; at its end the token is shown and |
| +// the line names the way on, not Go's "context deadline exceeded". |
| +func TestImportCheckDeadline(t *testing.T) { |
| + waits := fakeSeedWait(t, time.Minute) |
| + c := newImportConsole(importRecord("awaiting-upload", "")) |
| + out, err := importRun(t, c, "/up", http.StatusOK) |
| + if !errors.Is(err, errImportFailed) { |
| + t.Fatalf("err = %v", err) |
| + } |
| + const want = "the console has not kept the upload of main.shop.example.com after 55 minutes. Check carlos instances list. To start over, run carlos instances import abort --app shop --host main.shop.example.com, then run the import again." |
| + if !strings.HasSuffix(out, importTokenLine+"\n"+want+"\n") || strings.Contains(out, "deadline") { |
| + t.Fatalf("output:\n%s", out) |
| + } |
| + if c.saw("stopped") || len(*waits) != 55 { |
| + t.Fatalf("calls = %v, waits = %d", c.called(), len(*waits)) |
| + } |
| +} |
| + |
| +// The usage block of carlos instances import, byte for byte. |
| +func TestImportUsageIsTheApprovedBlock(t *testing.T) { |
| + const want = "usage: carlos instances import --app <a> --host <h> --file <snapshot> --replaces <host:port>\n" + |
| + " [--verify <path>] [--environment <e>]\n" + |
| + " carlos instances import commit --app <a> --host <h>\n" + |
| + " carlos instances import abort --app <a> --host <h>\n" + |
| + "\n" + |
| + "carlos instances import moves an instance the platform never ran onto the platform." |
| + if got := importUsage(); got != want { |
| + t.Fatalf("usage = %q, want %q", got, want) |
| + } |
| +} |
| + |
| // A stopped whose first answer was lost answers 409 SD-N54 naming a state |
| // past it; the record says the stop landed, so the import goes on. |
| func TestImportStoppedThatAlreadyLanded(t *testing.T) { |
| @@ -724,6 +779,8 @@ func TestImportVerbAlone(t *testing.T) { |
| {"abort that lost to its own retry", "abort", answerWith(http.StatusConflict, `{"error":"changed"}`), |
| importRecord("aborted", ""), "sd_imp", "aborted. The box puts the old route of main.shop.example.com back on its next pass.", ""}, |
| {"abort of a host with no record", "abort", answerWith(http.StatusNotFound, `{"error":"main.shop.example.com is not an instance of shop"}`), |
| + answerWith(http.StatusNotFound, ""), "", "", "main.shop.example.com is not an instance of shop, so there is no import to abort. An import that was aborted and cleaned up leaves no instance behind."}, |
| + {"commit of a host with no record", "commit", answerWith(http.StatusNotFound, `{"error":"main.shop.example.com is not an instance of shop"}`), |
| answerWith(http.StatusNotFound, ""), "", "", "main.shop.example.com is not an instance of shop"}, |
| } { |
| t.Run(c.name, func(t *testing.T) { |
| @@ -760,14 +817,14 @@ func TestPollSeedLimitAndFailures(t *testing.T) { |
| ts := httptest.NewServer(c) |
| defer ts.Close() |
| s := newTestSession(ts.URL) |
| - _, err := s.pollSeed(context.Background(), apiAcct{Seg: "bab"}, "shop", "main.shop.example.com", 10*time.Second, importLook("sd_imp", "swapped")) |
| + _, err := s.pollSeed(context.Background(), apiAcct{Seg: "bab"}, "shop", "main.shop.example.com", 10*time.Second, importLook("main.shop.example.com", "sd_imp", "swapped")) |
| if !errors.Is(err, context.DeadlineExceeded) || len(*waits) != 5 { |
| t.Fatalf("err = %v, waits = %v", err, *waits) |
| } |
| bad := newImportConsole(answerWith(http.StatusBadGateway, `{"error":"could not read the instance"}`)) |
| ts2 := httptest.NewServer(bad) |
| defer ts2.Close() |
| - _, err = newTestSession(ts2.URL).pollSeed(context.Background(), apiAcct{Seg: "bab"}, "shop", "main.shop.example.com", 0, importLook("sd_imp", "swapped")) |
| + _, err = newTestSession(ts2.URL).pollSeed(context.Background(), apiAcct{Seg: "bab"}, "shop", "main.shop.example.com", 0, importLook("main.shop.example.com", "sd_imp", "swapped")) |
| bad.mu.Lock() |
| looks := bad.looks |
| bad.mu.Unlock() |
diff --git a/instances_seed.go b/instances_seed.go| index 08fe02c1a..8695ffbd7 100644 |
| --- a/instances_seed.go |
| +++ b/instances_seed.go |
| @@ -77,6 +77,10 @@ var ( |
| // request's own timeout is never mistaken for it. |
| var errSeedWaitOver = fmt.Errorf("%w", context.DeadlineExceeded) |
| +// seedGone is pollSeed's answer when the record it follows has gone. It |
| +// reads as the membership sentence, and an import tells it apart. |
| +type seedGone struct{ error } |
| + |
| // seedPollFailures is how many looks in a row may fail before the create |
| // gives up and returns the last error. |
| const seedPollFailures = 5 |
| @@ -168,7 +172,7 @@ func (s *apiSession) pollSeed(ctx context.Context, a apiAcct, app, host string, |
| rec, found, err := s.instanceGet(ctx, a, app, host) |
| switch { |
| case err == nil && !found: |
| - return apiInstanceRecord{}, notFoundErr() |
| + return apiInstanceRecord{}, seedGone{notFoundErr()} |
| case err != nil: |
| if failures++; failures >= seedPollFailures { |
| return apiInstanceRecord{}, err |
diff --git a/internal/console/apiinstanceimport.go b/internal/console/apiinstanceimport.go| index 693525c43..d5716143a 100644 |
| --- a/internal/console/apiinstanceimport.go |
| +++ b/internal/console/apiinstanceimport.go |
| @@ -42,6 +42,13 @@ type seedImportReq struct { |
| // importFailed is the import door's retry sentence (SD-N67). |
| const importFailed = "could not store the upload. Try again." |
| +// importStepFailure is the retry sentence of every import call that is not |
| +// storing the upload: the record read at the gate, and the stopped, commit |
| +// and abort steps (SD-N120). |
| +func importStepFailure(host string) string { |
| + return fmt.Sprintf("could not update the import of %s. Try again.", host) |
| +} |
| + |
| // importSizeRule is SD-N48, answered 400 below the range and 413 above it. |
| const importSizeRule = "bytes must be more than 0 and at most 4 GiB" |
| @@ -51,10 +58,10 @@ var ( |
| ) |
| // seedOneDoorRefusal is the create door's rule that a body takes at most |
| -// one seed door: "from" and "import" together are refused (SD-N36). |
| +// one seed door: "from" and "import" together are refused. |
| func seedOneDoorRefusal(from, imp bool) *doorRefusal { |
| if from && imp { |
| - return seedOrdinaryRefusal() |
| + return &doorRefusal{http.StatusBadRequest, "a create takes from or import, not both."} |
| } |
| return nil |
| } |
| @@ -119,7 +126,8 @@ func (s *Server) beginImport(w http.ResponseWriter, r *http.Request, c seedCreat |
| // The snapshot goes to this console's data bucket, and the import's |
| // rollback reads it there: the record is homed in this region. |
| if c.rec.Region != s.cfg.Region { |
| - jsonError(w, http.StatusConflict, s.dbResidencyRefusal(c.rec)) |
| + jsonError(w, http.StatusConflict, fmt.Sprintf("an import runs in this console's region, %s. To run %s elsewhere, import it here, commit it, and then run carlos move.", |
| + s.dbRegionName(s.cfg.Region), host)) |
| return |
| } |
| checks, cancel := context.WithTimeout(ctx, instanceStoreTimeout) |
| @@ -190,7 +198,7 @@ func (s *Server) beginImport(w http.ResponseWriter, r *http.Request, c seedCreat |
| s.unbeginImport(ctx, c, id, "store provisioning") |
| switch { |
| case errors.Is(err, errSeedStoreTaken): |
| - ref := seedStoreTakenRefusal(rec.Environment, storeRec) |
| + ref := seedStoreTakenRefusal(rec.Environment, app, storeRec) |
| jsonError(w, ref.Status, ref.Msg) |
| case errors.Is(err, errSeedEnvironmentUnread): |
| jsonError(w, http.StatusBadGateway, importFailed) |
| @@ -204,8 +212,7 @@ func (s *Server) beginImport(w http.ResponseWriter, r *http.Request, c seedCreat |
| if c.siblingLabel != "" { |
| if live, trashed, err := s.store.AppNameState(acct.ID, c.siblingLabel); err == nil && (live || trashed) { |
| s.unbeginImport(ctx, c, id, "a sibling app took the label") |
| - jsonError(w, http.StatusConflict, |
| - host+" is app "+c.siblingLabel+"'s platform apex — it was created while this instance was being written; the instance was not kept") |
| + jsonError(w, http.StatusConflict, seedSiblingApexReason(host, c.siblingLabel)) |
| return |
| } |
| } |
| @@ -301,7 +308,7 @@ func (s *Server) importGate(w http.ResponseWriter, r *http.Request) (principal s |
| } |
| if err != nil { |
| log.Printf("console: import %s/%s/%s: read the record: %v", acct.Sqid, app, host, err) |
| - jsonError(w, http.StatusBadGateway, importFailed) |
| + jsonError(w, http.StatusBadGateway, importStepFailure(host)) |
| return "", nil, "", "", nil, false |
| } |
| if !rec.Seed.Import() { |
| @@ -661,7 +668,7 @@ func (s *Server) importTransitionFailure(w http.ResponseWriter, app, host string |
| jsonError(w, http.StatusNotFound, notAnInstanceOf(host, app)) |
| default: |
| log.Printf("console: import %s: write the record: %v", host, err) |
| - jsonError(w, http.StatusBadGateway, importFailed) |
| + jsonError(w, http.StatusBadGateway, importStepFailure(host)) |
| } |
| } |
| @@ -763,10 +770,10 @@ func pastStopped(state string) bool { |
| return false |
| } |
| -// importDeleteFailed is main's own retry sentence of the delete door |
| -// (apiinstances.go), answered unchanged when the delete door's import rule |
| -// cannot read the record or finish the import (preflight M15). |
| -const importDeleteFailed = "could not delete the instance — try again" |
| +// importDeleteFailed is the delete door's retry sentence when its import |
| +// rule cannot read the record or finish the import (preflight M15). Main's |
| +// own delete sentence (apiinstances.go) is unchanged. |
| +const importDeleteFailed = "could not delete the instance. Try again." |
| // importFinishTimeout bounds a finish run inline for a request: the |
| // store's teardown has its own storeTeardownTimeout, and the reads, the |
| @@ -977,6 +984,11 @@ func (s *Server) importDeleteRule(w http.ResponseWriter, r *http.Request, acct * |
| return true, false |
| } |
| case seeds.StateRolledBack: |
| + case seeds.StateAborted: |
| + // Aborting is done: the box puts the old route back, and then a |
| + // delete finishes the import. |
| + jsonError(w, http.StatusConflict, fmt.Sprintf("the import of %s is already aborted. Once the box puts the old route back on its next pass, delete %s again.", host, host)) |
| + return true, false |
| default: |
| jsonError(w, http.StatusConflict, fmt.Sprintf("%s is an import that is not committed. Abort the import instead.", host)) |
| return true, false |
diff --git a/internal/console/apiinstanceimport_test.go b/internal/console/apiinstanceimport_test.go| index 237fc2cc1..469af89d5 100644 |
| --- a/internal/console/apiinstanceimport_test.go |
| +++ b/internal/console/apiinstanceimport_test.go |
| @@ -146,6 +146,7 @@ func TestImportBeginMintsFreshTokens(t *testing.T) { |
| func TestImportBeginRefusals(t *testing.T) { |
| f := newImportFixture(t) |
| const ordinary = "a seeded instance is an ordinary instance. Drop --kind, --addr and --role." |
| + const oneDoor = "a create takes from or import, not both." |
| const size = "bytes must be more than 0 and at most 4 GiB" |
| const hash = "sha256 must be 64 lowercase hex characters" |
| const replaces = "replaces must name the address the import takes over, as host:port" |
| @@ -170,7 +171,7 @@ func TestImportBeginRefusals(t *testing.T) { |
| {`{"host":"a.shop.example.com","kind":"service","import":{"sha256":"` + importSHA + `","bytes":10,"replaces":"127.0.0.1:1"}}`, http.StatusBadRequest, ordinary}, |
| {`{"host":"a.shop.example.com","addrs":["127.0.0.1:9"],"import":{"sha256":"` + importSHA + `","bytes":10,"replaces":"127.0.0.1:1"}}`, http.StatusBadRequest, ordinary}, |
| // One seed door per create. |
| - {`{"host":"a.shop.example.com","from":{"host":"main.shop.example.com"},"import":{"sha256":"` + importSHA + `","bytes":10,"replaces":"127.0.0.1:1"}}`, http.StatusBadRequest, ordinary}, |
| + {`{"host":"a.shop.example.com","from":{"host":"main.shop.example.com"},"import":{"sha256":"` + importSHA + `","bytes":10,"replaces":"127.0.0.1:1"}}`, http.StatusBadRequest, oneDoor}, |
| } { |
| if code, out := f.create(t, c.body); code != c.code || str(t, out, "error") != c.want { |
| t.Errorf("%s = %d %v, want %d %q", c.body, code, out, c.code, c.want) |
| @@ -218,7 +219,7 @@ func TestImportBeginRefusesAnotherRegion(t *testing.T) { |
| f := newImportFixture(t) |
| seedReadyBox(t, f.fx, "far-1", "us", 1, "v0.27.0", "v0.27.0", "nested", false) |
| code, out := f.create(t, `{"host":"old.shop.example.com","region":"us","import":{"sha256":"`+importSHA+`","bytes":10,"replaces":"127.0.0.1:8401"}}`) |
| - if code != http.StatusConflict || str(t, out, "error") != "old.shop.example.com keeps its data in region us. This console can only read data kept in the home region." { |
| + if code != http.StatusConflict || str(t, out, "error") != "an import runs in this console's region, the home region. To run old.shop.example.com elsewhere, import it here, commit it, and then run carlos move." { |
| t.Fatalf("= %d %v", code, out) |
| } |
| f.requireNoRecord(t, importHost) |
| @@ -434,7 +435,7 @@ func TestImportBeginSiblingApexTearsDownTheStore(t *testing.T) { |
| f.fx.srv.cfg.Releases = &recordSpy{Store: f.fx.rel, key: provision.RecordKey("bab", fromApp, "old.bab.apps.example.net"), seen: &seen} |
| code, out := beginImport(t, f, "old.bab.apps.example.net", importSHA, 10) |
| - if code != http.StatusConflict || str(t, out, "error") != "old.bab.apps.example.net is app old's platform apex — it was created while this instance was being written; the instance was not kept" { |
| + if code != http.StatusConflict || str(t, out, "error") != "old.bab.apps.example.net is app old's platform apex. That app was created while this instance was being written, so the instance was not kept." { |
| t.Fatalf("= %d %v", code, out) |
| } |
| if seen == nil || seen.Seed == nil || !seeds.ValidID(seen.Seed.ID) || p.declared != seen.Seed.ID { |
| @@ -1353,7 +1354,7 @@ func TestImportTransitionsWriteFailure(t *testing.T) { |
| f.fx.srv.cfg.Releases = rel |
| rel.armed.Store(true) |
| before := serial(t, f.fx) |
| - if code, out := postImport(t, f, importHost, "commit"); code != http.StatusBadGateway || str(t, out, "error") != importFailed { |
| + if code, out := postImport(t, f, importHost, "commit"); code != http.StatusBadGateway || str(t, out, "error") != "could not update the import of "+importHost+". Try again." { |
| t.Fatalf("commit = %d %v", code, out) |
| } |
| rec, _ := f.record(t, importHost) |
| @@ -1512,8 +1513,8 @@ func TestImportDelayedMutationsOfAnEarlierImport(t *testing.T) { |
| // ---- Abort and the delete door (spec §5.6, preflight M8, M14, M15) ---- |
| const ( |
| - mainDeleteFailed = "could not delete the instance — try again" |
| - importRunBusy = "carlos run is busy on " + importHost + ". Wait for it to finish, then abort." |
| + importDeleteRetry = "could not delete the instance. Try again." |
| + importRunBusy = "carlos run is busy on " + importHost + ". Wait for it to finish, then abort." |
| ) |
| func importChanged(host string) string { |
| @@ -1528,6 +1529,10 @@ func importAbortFirst(host string) string { |
| return host + " is an import that is not committed. Abort the import instead." |
| } |
| +func importAlreadyAborted(host string) string { |
| + return "the import of " + host + " is already aborted. Once the box puts the old route back on its next pass, delete " + host + " again." |
| +} |
| + |
| // postImportErr is postImport for a goroutine: it returns its error and |
| // never calls t.Fatal (preflight m9). |
| func postImportErr(f fromFixture, host, verb string) (int, map[string]any, error) { |
| @@ -1928,8 +1933,12 @@ func TestImportDeleteRefusedUntilCommittedOrUnswapped(t *testing.T) { |
| f.requireRecordGone(t, host) |
| continue |
| } |
| - if code != http.StatusConflict || str(t, out, "error") != importAbortFirst(host) { |
| - t.Errorf("delete while %s = %d %v, want 409 %q", c.state, code, out, importAbortFirst(host)) |
| + want := importAbortFirst(host) |
| + if c.state == seeds.StateAborted { |
| + want = importAlreadyAborted(host) |
| + } |
| + if code != http.StatusConflict || str(t, out, "error") != want { |
| + t.Errorf("delete while %s = %d %v, want 409 %q", c.state, code, out, want) |
| } |
| if rec, err := f.record(t, host); err != nil || rec.Seed.State != c.state { |
| t.Errorf("a refused delete while %s left %+v, %v", c.state, rec, err) |
| @@ -2089,7 +2098,7 @@ func TestImportDeleteFailsClosed(t *testing.T) { |
| rel := &failGetOn{Store: f.fx.rel, key: provision.RecordKey("bab", fromApp, importHost)} |
| rel.armed.Store(true) |
| f.fx.srv.cfg.Releases = rel |
| - if code, out := deleteInstance(t, f, importHost); code != http.StatusBadGateway || str(t, out, "error") != mainDeleteFailed { |
| + if code, out := deleteInstance(t, f, importHost); code != http.StatusBadGateway || str(t, out, "error") != importDeleteRetry { |
| t.Fatalf("delete with an unreadable record = %d %v", code, out) |
| } |
| f.fx.srv.cfg.Releases = f.fx.rel |
| @@ -2098,7 +2107,7 @@ func TestImportDeleteFailsClosed(t *testing.T) { |
| data := f.fx.srv.cfg.SeedData |
| f.fx.srv.cfg.SeedData = nil |
| - if code, out := deleteInstance(t, f, importHost); code != http.StatusBadGateway || str(t, out, "error") != mainDeleteFailed { |
| + if code, out := deleteInstance(t, f, importHost); code != http.StatusBadGateway || str(t, out, "error") != importDeleteRetry { |
| t.Fatalf("delete with a failing finish = %d %v", code, out) |
| } |
| rec, _ = f.record(t, importHost) |
| @@ -2119,7 +2128,7 @@ func TestImportDeleteFailsClosed(t *testing.T) { |
| t.Fatal(err) |
| } |
| f.seedRecord(t, "bad.shop.example.com", "", "not-a-seed-id", seeds.KindImport, seeds.StateUploaded) |
| - if code, out := deleteInstance(t, f, "bad.shop.example.com"); code != http.StatusBadGateway || str(t, out, "error") != mainDeleteFailed { |
| + if code, out := deleteInstance(t, f, "bad.shop.example.com"); code != http.StatusBadGateway || str(t, out, "error") != importDeleteRetry { |
| t.Fatalf("delete of a bad seed id = %d %v", code, out) |
| } |
| if rec, err := f.record(t, "bad.shop.example.com"); err != nil || rec.Seed.State != seeds.StateUploaded { |
diff --git a/internal/console/apiinstanceseed.go b/internal/console/apiinstanceseed.go| index e221a246d..eaf271824 100644 |
| --- a/internal/console/apiinstanceseed.go |
| +++ b/internal/console/apiinstanceseed.go |
| @@ -114,12 +114,19 @@ func (s *Server) refuseSeededEnvironment(w http.ResponseWriter) { |
| // seedStoreTakenRefusal is the refusal of a seeded create whose environment |
| // is not new: SD-N34 naming the store's bucket, or, when the environment |
| // has no store with a bucket and is only named by another instance (final |
| -// review F6), SD-N33's rule. |
| -func seedStoreTakenRefusal(env string, store *objstore.Record) *doorRefusal { |
| +// review F6), the sentence that says so and what to do. |
| +func seedStoreTakenRefusal(env, app string, store *objstore.Record) *doorRefusal { |
| if bucket := storeBucketName(store); bucket != "" { |
| return &doorRefusal{http.StatusConflict, fmt.Sprintf("environment %s already has a store (%s). A seeded instance needs a new, empty one.", env, bucket)} |
| } |
| - return &doorRefusal{http.StatusConflict, seedOwnEnvironment} |
| + return &doorRefusal{http.StatusConflict, fmt.Sprintf("environment %s is already used by another instance of %s. A seeded instance gets a new environment named after its host, so choose another host.", env, app)} |
| +} |
| + |
| +// seedSiblingApexReason is the sentence both seed doors speak when an app |
| +// created under the host's label while the instance was being written has |
| +// made the host its platform apex. Main's create door keeps its own. |
| +func seedSiblingApexReason(host, label string) string { |
| + return fmt.Sprintf("%s is app %s's platform apex. That app was created while this instance was being written, so the instance was not kept.", host, label) |
| } |
| // errSeedEnvironmentUnread is instanceStoreProvisionFor's answer when it |
| @@ -165,7 +172,7 @@ func (s *Server) seedStoreRefusal(ctx context.Context, c seedCreate, seedID, fai |
| return &doorRefusal{http.StatusBadGateway, failed} |
| } |
| if !seedOwnsStore(rec, seedID) { |
| - return seedStoreTakenRefusal(c.rec.Environment, rec) |
| + return seedStoreTakenRefusal(c.rec.Environment, c.app, rec) |
| } |
| user, err := s.seedEnvironmentUser(ctx, c.acct.Sqid, c.app, c.rec.Environment, seedID) |
| if err != nil { |
| @@ -174,7 +181,7 @@ func (s *Server) seedStoreRefusal(ctx context.Context, c seedCreate, seedID, fai |
| } |
| if user != "" { |
| log.Printf("console: seed store check %s/%s/%s: instance %s already names the environment", c.acct.Sqid, c.app, c.rec.Environment, user) |
| - return seedStoreTakenRefusal(c.rec.Environment, rec) |
| + return seedStoreTakenRefusal(c.rec.Environment, c.app, rec) |
| } |
| return nil |
| } |
| @@ -193,7 +200,6 @@ type fromCopy struct { |
| c seedCreate |
| from seedFromReq |
| source string |
| - failed string // SD-N39 for this source |
| id string |
| plan dbdoor.RestorePlan |
| client *dbdoor.Client |
| @@ -275,6 +281,12 @@ func (s *Server) beginFrom(w http.ResponseWriter, r *http.Request, c seedCreate, |
| log.Printf("console: from %s: plan: %v", source, err) |
| jsonError(w, http.StatusBadGateway, failed) |
| return nil |
| + case from.TXID > plan.TXID: |
| + // The plan clamps an --at past the latest transaction to the |
| + // latest copy. A caller who named a point later than anything the |
| + // source has replicated is told so, and nothing is reserved. |
| + jsonError(w, http.StatusConflict, fmt.Sprintf("%s has replicated up to txid %d, so there is no txid %d to copy yet. Leave out --at to copy the latest.", source, plan.TXID, from.TXID)) |
| + return nil |
| } |
| if plan.RestoredBytes > seedMaxBytes { |
| jsonError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("a copy of %s would be %s, over the 4 GiB limit.", source, seedGiB(plan.RestoredBytes))) |
| @@ -340,7 +352,7 @@ func (s *Server) beginFrom(w http.ResponseWriter, r *http.Request, c seedCreate, |
| out.RegionName = s.placeOf(rec.Region, s.candsOrHome(writeCtx)) // never fleet-placed: refused above |
| writeJSON(w, http.StatusAccepted, out) |
| handedOver = true |
| - return &fromCopy{ctx: context.WithoutCancel(ctx), c: c, from: from, source: source, failed: failed, |
| + return &fromCopy{ctx: context.WithoutCancel(ctx), c: c, from: from, source: source, |
| id: id, plan: plan, client: client, release: releaseScratch} |
| } |
| @@ -360,6 +372,10 @@ func (s *Server) runFrom(j *fromCopy) { |
| ctx := j.ctx |
| c := j.c |
| acct, app, host, source := c.acct, c.app, c.rec.Host, j.source |
| + // The reason a copy that failed after the answer leaves on its record. |
| + // The aborted record holds the host until the box and the next sweep |
| + // finish it, so a retry before then finds the host taken. |
| + failed := fmt.Sprintf("could not copy %s, so no instance was created. %s is free again within about 5 minutes. Try again then.", source, host) |
| abort := func(reason, why string) { |
| bg, cancel := context.WithTimeout(ctx, instanceStoreTimeout) |
| defer cancel() |
| @@ -377,7 +393,7 @@ func (s *Server) runFrom(j *fromCopy) { |
| defer func() { |
| if p := recover(); p != nil { |
| log.Printf("console: from %s: panic in the copy of %s (seed %s): %v\n%s", source, host, j.id, p, debug.Stack()) |
| - abort(j.failed, "a panic") |
| + abort(failed, "a panic") |
| } |
| }() |
| @@ -387,12 +403,12 @@ func (s *Server) runFrom(j *fromCopy) { |
| held := &heldResponse{header: http.Header{}} |
| storeRec, ok, err := s.instanceStoreProvisionFor(ctx, held, acct, app, c.rec.Environment, c.principal, j.id) |
| if !ok { |
| - reason := held.message(j.failed) |
| + reason := held.message(failed) |
| switch { |
| case errors.Is(err, errSeedStoreTaken): |
| - reason = seedStoreTakenRefusal(c.rec.Environment, storeRec).Msg |
| + reason = seedStoreTakenRefusal(c.rec.Environment, c.app, storeRec).Msg |
| case errors.Is(err, errSeedEnvironmentUnread): |
| - reason = j.failed |
| + reason = failed |
| } |
| abort(reason, "store provisioning") |
| return |
| @@ -428,7 +444,7 @@ func (s *Server) runFrom(j *fromCopy) { |
| } |
| } |
| if err != nil || overgrown { |
| - reason := j.failed |
| + reason := failed |
| switch { |
| case overgrown: |
| reason = fmt.Sprintf("the copy of %s grew past its planned size, so it was stopped and no instance was created.", source) |
| @@ -452,7 +468,7 @@ func (s *Server) runFrom(j *fromCopy) { |
| ucancel() |
| if err != nil { |
| log.Printf("console: from %s: upload %s: %v", source, host, err) |
| - abort(j.failed, "upload") |
| + abort(failed, "upload") |
| return |
| } |
| // The create door's re-check after its write (apiinstances.go), made |
| @@ -460,7 +476,7 @@ func (s *Server) runFrom(j *fromCopy) { |
| // publish is the point of no return. |
| if c.siblingLabel != "" { |
| if live, trashed, err := s.store.AppNameState(acct.ID, c.siblingLabel); err == nil && (live || trashed) { |
| - abort(host+" is app "+c.siblingLabel+"'s platform apex — it was created while this instance was being written; the instance was not kept", "a sibling app took the label") |
| + abort(seedSiblingApexReason(host, c.siblingLabel), "a sibling app took the label") |
| return |
| } |
| } |
| @@ -469,7 +485,7 @@ func (s *Server) runFrom(j *fromCopy) { |
| if _, err := provision.SetRecordSeed(pctx, s.cfg.Releases, acct.Sqid, app, host, |
| provision.SeedPublishReplica(j.id, strconv.FormatUint(restored.TXID, 10), sha, size)); err != nil { |
| log.Printf("console: from %s: publish %s: %v", source, host, err) |
| - abort(j.failed, "publish") |
| + abort(failed, "publish") |
| return |
| } |
| s.auditActivity(acct, app, c.principal, Activity{ |
diff --git a/internal/console/apiinstanceseed_test.go b/internal/console/apiinstanceseed_test.go| index c4bf66e96..9de1ff5e3 100644 |
| --- a/internal/console/apiinstanceseed_test.go |
| +++ b/internal/console/apiinstanceseed_test.go |
| @@ -7,6 +7,7 @@ import ( |
| "encoding/hex" |
| "encoding/json" |
| "errors" |
| + "fmt" |
| "net/http" |
| "net/http/httptest" |
| "os" |
| @@ -35,7 +36,10 @@ const ( |
| fromHost = "canary.shop.example.com" |
| fromBody = `{"host":"canary.shop.example.com","from":{"host":"main.shop.example.com"}}` |
| fromFailed = "could not copy main.shop.example.com. Nothing was created. Try again." |
| - fromGrew = "the copy of main.shop.example.com grew past its planned size, so it was stopped and no instance was created." |
| + // fromAbortedReason is what a copy that fails after the answer leaves on |
| + // its record: the host stays taken until the sweep finishes it. |
| + fromAbortedReason = "could not copy main.shop.example.com, so no instance was created. canary.shop.example.com is free again within about 5 minutes. Try again then." |
| + fromGrew = "the copy of main.shop.example.com grew past its planned size, so it was stopped and no instance was created." |
| ) |
| // fromFixture is a console ready for --from: the app shop (no pool, its own |
| @@ -270,8 +274,9 @@ func TestFromRefusals(t *testing.T) { |
| f.requireClean(t) |
| } |
| -// --at: a target inside the history restores exactly there; one at or |
| -// past the end is the latest copy (ruling R5 keeps that). |
| +// --at: a target inside the history restores exactly there, and one at the |
| +// end is the latest copy. One past the end is refused before anything is |
| +// reserved, naming the latest transaction the source has replicated. |
| func TestFromAt(t *testing.T) { |
| f := newFromFixture(t, 30) |
| latest, err := dbdoor.PlanRestore(context.Background(), dbdoor.NewClient(replicaDoer(lsfile.NewReplicaClient(filepath.Join(f.root, "instances", fromSource)))), 0) |
| @@ -284,7 +289,7 @@ func TestFromAt(t *testing.T) { |
| want uint64 |
| }{ |
| {"c3.shop.example.com", 1, 1}, |
| - {"c4.shop.example.com", 999999, latest.TXID}, |
| + {"c4.shop.example.com", latest.TXID, latest.TXID}, |
| } { |
| body, _ := json.Marshal(map[string]any{"host": c.host, "from": map[string]any{"host": fromSource, "txid": c.txid}}) |
| rec := f.copy(t, string(body)) |
| @@ -293,6 +298,12 @@ func TestFromAt(t *testing.T) { |
| t.Fatalf("--at %d: restored at txid %s, want %d", c.txid, rec.Seed.TXID, c.want) |
| } |
| } |
| + code, out := f.create(t, `{"host":"c5.shop.example.com","from":{"host":"main.shop.example.com","txid":999999}}`) |
| + want := fmt.Sprintf("main.shop.example.com has replicated up to txid %d, so there is no txid 999999 to copy yet. Leave out --at to copy the latest.", latest.TXID) |
| + if code != http.StatusConflict || str(t, out, "error") != want { |
| + t.Fatalf("--at past the latest = %d %v, want 409 %q", code, out, want) |
| + } |
| + f.requireNoRecord(t, "c5.shop.example.com") |
| f.requireClean(t) |
| } |
| @@ -393,10 +404,14 @@ func TestFromPlanFailures(t *testing.T) { |
| {dbdoor.ErrHistoryGone, `{"host":"canary.shop.example.com","from":{"host":"main.shop.example.com","txid":2}}`, http.StatusConflict, "history before 2 is no longer kept"}, |
| {dbdoor.ErrNothingReplicated, `{"host":"canary.shop.example.com","from":{"host":"main.shop.example.com","txid":2}}`, http.StatusConflict, "history before 2 is no longer kept"}, |
| {dbdoor.ErrNothingReplicated, fromBody, http.StatusConflict, "main.shop.example.com has not replicated anything yet. Wake it once, then try again."}, |
| + {nil, `{"host":"canary.shop.example.com","from":{"host":"main.shop.example.com","txid":9}}`, http.StatusConflict, "main.shop.example.com has replicated up to txid 5, so there is no txid 9 to copy yet. Leave out --at to copy the latest."}, |
| {errors.New("dbdoor: the last planned file's header does not describe it"), fromBody, http.StatusBadGateway, fromFailed}, |
| {os.ErrNotExist, fromBody, http.StatusBadGateway, fromFailed}, |
| } { |
| planRestore = func(context.Context, *dbdoor.Client, uint64) (dbdoor.RestorePlan, error) { |
| + if c.err == nil { |
| + return dbdoor.RestorePlan{TXID: 5}, nil // the plan clamps a later --at to the latest |
| + } |
| return dbdoor.RestorePlan{}, c.err |
| } |
| code, out := f.create(t, c.body) |
| @@ -419,9 +434,9 @@ func TestFromRestoreFailuresAbort(t *testing.T) { |
| reason string |
| }{ |
| {func(context.Context) error { return dbdoor.ErrCheckFailed }, fromBody, "the copy of main.shop.example.com failed its integrity check, so no instance was created."}, |
| - {func(context.Context) error { return dbdoor.ErrHistoryGone }, fromBody, fromFailed}, |
| + {func(context.Context) error { return dbdoor.ErrHistoryGone }, fromBody, fromAbortedReason}, |
| {func(context.Context) error { return dbdoor.ErrHistoryGone }, `{"host":"canary.shop.example.com","from":{"host":"main.shop.example.com","txid":2}}`, "history before 2 is no longer kept"}, |
| - {func(context.Context) error { return errors.New("bucket read failed") }, fromBody, fromFailed}, |
| + {func(context.Context) error { return errors.New("bucket read failed") }, fromBody, fromAbortedReason}, |
| {func(ctx context.Context) error { <-ctx.Done(); return ctx.Err() }, fromBody, "copying main.shop.example.com took longer than 30 minutes, so no instance was created."}, |
| } { |
| f := newFromFixture(t, 3) |
| @@ -458,7 +473,7 @@ func TestFromRestoresAtThePlannedTXID(t *testing.T) { |
| t.Fatal(err) |
| } |
| requireReady(t, f.copy(t, fromBody)) |
| - requireReady(t, f.copy(t, `{"host":"c2.shop.example.com","from":{"host":"main.shop.example.com","txid":999999}}`)) |
| + requireReady(t, f.copy(t, fmt.Sprintf(`{"host":"c2.shop.example.com","from":{"host":"main.shop.example.com","txid":%d}}`, plan.TXID))) |
| if len(got) != 2 || got[0] != plan.TXID || got[1] != plan.TXID { |
| t.Fatalf("restored at %v, want [%d %d]", got, plan.TXID, plan.TXID) |
| } |
| @@ -601,7 +616,7 @@ func TestFromPanicAbortsTheSeedAndSparesTheConsole(t *testing.T) { |
| } |
| panic("a decoder fault") |
| } |
| - requireAborted(t, f.copy(t, fromBody), fromFailed) |
| + requireAborted(t, f.copy(t, fromBody), fromAbortedReason) |
| f.requireClean(t) |
| f.requireNothingUploaded(t) |
| // Still serving: the next copy runs to ready. |
| @@ -613,7 +628,7 @@ func TestFromPanicAbortsTheSeedAndSparesTheConsole(t *testing.T) { |
| func TestFromUploadFailureAborts(t *testing.T) { |
| f := newFromFixture(t, 3) |
| f.fx.srv.cfg.SeedData = failingPuts{f.data} |
| - requireAborted(t, f.copy(t, fromBody), fromFailed) |
| + requireAborted(t, f.copy(t, fromBody), fromAbortedReason) |
| f.requireClean(t) |
| } |
| @@ -634,7 +649,7 @@ func TestFromPublishFailureAborts(t *testing.T) { |
| rel.armed.Store(true) // the next write of the record is the publish |
| return oldRestore(ctx, c, parent, target) |
| } |
| - requireAborted(t, f.copy(t, fromBody), fromFailed) |
| + requireAborted(t, f.copy(t, fromBody), fromAbortedReason) |
| f.requireClean(t) |
| } |
| @@ -828,7 +843,7 @@ func TestFromAbortsWhenASiblingTakesTheApex(t *testing.T) { |
| return oldRestore(ctx, c, parent, target) |
| } |
| rec := f.copy(t, `{"host":"canary.bab.apps.example.net","from":{"host":"main.bab.apps.example.net"}}`) |
| - requireAborted(t, rec, "canary.bab.apps.example.net is app canary's platform apex — it was created while this instance was being written; the instance was not kept") |
| + requireAborted(t, rec, "canary.bab.apps.example.net is app canary's platform apex. That app was created while this instance was being written, so the instance was not kept.") |
| f.requireClean(t) |
| } |
diff --git a/internal/console/apilegacyroutes.go b/internal/console/apilegacyroutes.go| index 68c8d6452..8c07e9336 100644 |
| --- a/internal/console/apilegacyroutes.go |
| +++ b/internal/console/apilegacyroutes.go |
| @@ -66,6 +66,12 @@ func (s *Server) handleAPISystemLegacyRoutes(w http.ResponseWriter, r *http.Requ |
| jsonError(w, http.StatusBadRequest, "to must be <account>/<app>") |
| return |
| } |
| + // The box's swap takes a legacy route only for the app that carries its |
| + // name, so an assignment to any other app could never be used. |
| + if app != name { |
| + jsonError(w, http.StatusBadRequest, fmt.Sprintf("the routes are filed under %s, so they can only be assigned to an app named %s. Use --to %s/%s.", name, name, acctSqid, name)) |
| + return |
| + } |
| acct, err := s.store.AccountBySqid(acctSqid) |
| if err != nil { |
| log.Printf("console: legacy routes: account %s: %v", acctSqid, err) |
diff --git a/internal/console/apilegacyroutes_test.go b/internal/console/apilegacyroutes_test.go| index f5878f12a..80ab5e58b 100644 |
| --- a/internal/console/apilegacyroutes_test.go |
| +++ b/internal/console/apilegacyroutes_test.go |
| @@ -19,14 +19,17 @@ import ( |
| // 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. |
| +// shop and other, and a third (sqid "efgh") holding an app shop of its own. |
| type legacyFx struct { |
| *consoleFixture |
| owner string |
| url string |
| } |
| -const legacyOtherID = 4242 |
| +const ( |
| + legacyOtherID = 4242 |
| + legacyThirdID = 4343 |
| +) |
| func legacyFixture(t *testing.T, code string) *legacyFx { |
| t.Helper() |
| @@ -40,6 +43,12 @@ func legacyFixture(t *testing.T, code string) *legacyFx { |
| t.Fatal(err) |
| } |
| } |
| + if err := fx.st.CreateAccount(legacyThirdID, "efgh", "Third", "third@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := fx.st.CreateAppIn(legacyThirdID, "shop", "third@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"} |
| } |
| @@ -85,11 +94,15 @@ func TestAssignLegacyRoutes(t *testing.T) { |
| 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"} { |
| + for _, bad := range []string{"cdef", "cdef/", "/shop", "cdef/Not An App", "zzzz/shop", "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) |
| } |
| } |
| + // An app that does not exist, under its own name. |
| + if st, out := fx.assign(t, "missing", "cdef/missing"); st != http.StatusBadRequest || str(t, out, "error") != "to must be <account>/<app>" { |
| + t.Fatalf("a missing app = %d %v", st, out) |
| + } |
| if _, _, err := fx.rel.Get(context.Background(), seeds.LegacyOwnersKey); err == nil { |
| t.Fatal("a refused assign wrote the file") |
| } |
| @@ -126,10 +139,10 @@ func TestAssignLegacyRoutes(t *testing.T) { |
| } |
| // 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" { |
| + if st, out := fx.assign(t, "shop", "efgh/shop"); st != http.StatusOK || out["account"] != "efgh" { |
| t.Fatalf("re-assign = %d %v", st, out) |
| } |
| - if got, _ := readOwner(t, fx.rel, "shop"); got.App != "other" { |
| + if got, _ := readOwner(t, fx.rel, "shop"); got.Account != "efgh" { |
| t.Fatalf("re-assign left %+v", got) |
| } |
| @@ -151,7 +164,7 @@ func TestAssignLegacyRoutes(t *testing.T) { |
| } |
| var saw bool |
| for _, e := range events { |
| - if e.Action == "legacy-route-assign" && e.Detail == "assigned the legacy routes named shop to cdef/other" { |
| + if e.Action == "legacy-route-assign" && e.Detail == "assigned the legacy routes named shop to efgh/shop" { |
| saw = true |
| } |
| } |
| @@ -160,6 +173,34 @@ func TestAssignLegacyRoutes(t *testing.T) { |
| } |
| } |
| +// The box's swap takes a route filed under a name only for the app of that |
| +// name, so an assignment to an app of another name could never be used: it |
| +// is refused at the door, and nothing is written or audited. |
| +func TestAssignRefusesAnAppOfAnotherName(t *testing.T) { |
| + fx := legacyFixture(t, "code-assign-mismatch") |
| + const want = "the routes are filed under shop, so they can only be assigned to an app named shop. Use --to cdef/shop." |
| + st, out := fx.assign(t, "shop", "cdef/other") |
| + if st != http.StatusBadRequest || str(t, out, "error") != want { |
| + t.Fatalf("assign to another app's name = %d %v, want 400 %q", st, out, want) |
| + } |
| + if _, _, err := fx.rel.Get(context.Background(), seeds.LegacyOwnersKey); err == nil { |
| + t.Fatal("a refused assign wrote the file") |
| + } |
| + events, err := fx.st.Events(OpsAccountID, "platform", 10) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, e := range events { |
| + if e.Action == "legacy-route-assign" { |
| + t.Fatalf("a refused assign was audited: %+v", e) |
| + } |
| + } |
| + // The name and the app agree: taken, in any account. |
| + if st, out := fx.assign(t, "shop", "efgh/shop"); st != http.StatusOK { |
| + t.Fatalf("assign to the app of that name = %d %v", st, out) |
| + } |
| +} |
| + |
| func TestAssignRefusedWhileAnImportIsInFlight(t *testing.T) { |
| for _, c := range []struct { |
| state string |
| @@ -181,12 +222,12 @@ func TestAssignRefusedWhileAnImportIsInFlight(t *testing.T) { |
| t.Fatalf("assign = %d %v", st, out) |
| } |
| legacyImportRecord(t, fx.rel, "old.example.com", c.state) |
| - st, out := fx.assign(t, "shop", "cdef/other") |
| + st, out := fx.assign(t, "shop", "efgh/shop") |
| if !c.busy { |
| - if st != http.StatusOK || out["app"] != "other" { |
| + if st != http.StatusOK || out["account"] != "efgh" { |
| t.Fatalf("re-assign past an import in %s = %d %v", c.state, st, out) |
| } |
| - if got, ok := readOwner(t, fx.rel, "shop"); !ok || got != (seeds.LegacyOwner{Account: "cdef", App: "other"}) { |
| + if got, ok := readOwner(t, fx.rel, "shop"); !ok || got != (seeds.LegacyOwner{Account: "efgh", App: "shop"}) { |
| t.Fatalf("re-assign past an import in %s left %+v %v", c.state, got, ok) |
| } |
| return |
| @@ -194,7 +235,7 @@ func TestAssignRefusedWhileAnImportIsInFlight(t *testing.T) { |
| 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" { |
| + if got, _ := readOwner(t, fx.rel, "shop"); got.Account != "cdef" { |
| t.Fatalf("the file changed: %+v", got) |
| } |
| // The same mapping is still a no-op while busy. |
| @@ -215,7 +256,7 @@ func TestAssignCountsAnUndecodableRecordAsBusy(t *testing.T) { |
| 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") { |
| + if st, out := fx.assign(t, "shop", "efgh/shop"); 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) |
| } |
| if got, ok := readOwner(t, fx.rel, "shop"); !ok || got != (seeds.LegacyOwner{Account: "cdef", App: "shop"}) { |
| @@ -392,7 +433,10 @@ func TestAssignLosesARaceByCAS(t *testing.T) { |
| 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 { |
| + if _, err := fx.st.CreateAppIn(legacyOtherID, "docs", "someone@keymail.dev", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if st, out := fx.assign(t, "docs", "cdef/docs"); st != http.StatusOK { |
| t.Fatalf("seed the file = %d %v", st, out) |
| } |
| } |
| @@ -478,13 +522,7 @@ func TestAssignFailsClosedOnAnIncompleteListing(t *testing.T) { |
| // re-assignment, for the app it was taken from. The system row stays. |
| func TestAssignAuditsTheAppsItMovesBetween(t *testing.T) { |
| fx := legacyFixture(t, "code-assign-audit") |
| - const thirdID = 4343 |
| - if err := fx.st.CreateAccount(thirdID, "efgh", "Third", "third@keymail.dev", time.Now()); err != nil { |
| - t.Fatal(err) |
| - } |
| - if _, err := fx.st.CreateAppIn(thirdID, "shop", "third@keymail.dev", time.Now()); err != nil { |
| - t.Fatal(err) |
| - } |
| + const thirdID = legacyThirdID |
| trail := func(acctID int64, app string) []string { |
| t.Helper() |
| events, err := fx.st.Events(acctID, app, activityCap) |
diff --git a/internal/console/fleets.go b/internal/console/fleets.go| index fc257c2e0..243e74552 100644 |
| --- a/internal/console/fleets.go |
| +++ b/internal/console/fleets.go |
| @@ -612,7 +612,7 @@ func (s *Server) placeAppTarget(ctx context.Context, acct *Account, app, target |
| switch block, err := placeSeedBlock(ctx, s.cfg.Releases, acct.Sqid, app); { |
| case err != nil: |
| log.Printf("console: place %s/%s: read instances: %v", acct.Sqid, app, err) |
| - return http.StatusBadGateway, placeInstancesFailed |
| + return http.StatusBadGateway, placeSeedReadFailed |
| case block != "": |
| return http.StatusConflict, provision.SeedMoveSentence(block) |
| } |
diff --git a/internal/console/importdelete_test.go b/internal/console/importdelete_test.go| index 69e431b98..c2d6b0e84 100644 |
| --- a/internal/console/importdelete_test.go |
| +++ b/internal/console/importdelete_test.go |
| @@ -113,8 +113,8 @@ func TestImportDeleteGivesUpAfterASecondChange(t *testing.T) { |
| t.Error(err) |
| } |
| }} |
| - if code, out := deleteInstance(t, f, importHost); code != http.StatusBadGateway || str(t, out, "error") != mainDeleteFailed { |
| - t.Fatalf("delete = %d %v, want 502 %q", code, out, mainDeleteFailed) |
| + if code, out := deleteInstance(t, f, importHost); code != http.StatusBadGateway || str(t, out, "error") != importDeleteRetry { |
| + t.Fatalf("delete = %d %v, want 502 %q", code, out, importDeleteRetry) |
| } |
| if _, err := f.record(t, importHost); errors.Is(err, os.ErrNotExist) { |
| t.Fatal("a record that kept changing was deleted") |
diff --git a/internal/console/seedenvuse_test.go b/internal/console/seedenvuse_test.go| index 393d909df..34194b9e0 100644 |
| --- a/internal/console/seedenvuse_test.go |
| +++ b/internal/console/seedenvuse_test.go |
| @@ -12,9 +12,11 @@ import ( |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| -// sdN33 is the rule sentence a seeded create answers when its derived |
| -// environment is named by another instance that has no store yet. |
| -const sdN33 = "a seeded instance gets its own environment and an empty store" |
| +// envInUse is the refusal of a seeded create whose derived environment |
| +// another instance of the app already names and no store holds. |
| +func envInUse(env string) string { |
| + return "environment " + env + " is already used by another instance of " + fromApp + ". A seeded instance gets a new environment named after its host, so choose another host." |
| +} |
| // Final review F6: a source created with --environment canary before its |
| // app took private storage, then a --from seed of canary.<domain>, whose |
| @@ -26,8 +28,8 @@ func TestSeededCreateRefusesAnEnvironmentAnotherInstanceNames(t *testing.T) { |
| f.addSource(t, provision.Record{Host: "src.shop.example.com", Slug: "src", Environment: "canary"}, 3) |
| enableInstances(t, f.fx, fromApp, fromManifest(true)) |
| code, out := f.create(t, `{"host":"`+fromHost+`","from":{"host":"src.shop.example.com"}}`) |
| - if code != http.StatusConflict || str(t, out, "error") != sdN33 { |
| - t.Fatalf("--from into a named environment = %d %v, want 409 SD-N33", code, out) |
| + if code != http.StatusConflict || str(t, out, "error") != envInUse("canary") { |
| + t.Fatalf("--from into a named environment = %d %v, want 409 %q", code, out, envInUse("canary")) |
| } |
| f.requireNoRecord(t, fromHost) |
| if store := f.canaryStore(t); store != nil { |
| @@ -36,8 +38,8 @@ func TestSeededCreateRefusesAnEnvironmentAnotherInstanceNames(t *testing.T) { |
| // The import door answers the same, for an instance of any shape. |
| code, out = f.create(t, importBody(fromHost, importSHA, 10, importAddr)) |
| - if code != http.StatusConflict || str(t, out, "error") != sdN33 { |
| - t.Fatalf("an import into a named environment = %d %v, want 409 SD-N33", code, out) |
| + if code != http.StatusConflict || str(t, out, "error") != envInUse("canary") { |
| + t.Fatalf("an import into a named environment = %d %v, want 409 %q", code, out, envInUse("canary")) |
| } |
| f.requireNoRecord(t, fromHost) |
| } |
| @@ -69,15 +71,15 @@ func (s *recordOnRecordWrite) DeleteIfMatch(ctx context.Context, key, etag strin |
| } |
| // The walk's own check under the store lock refuses an environment another |
| -// instance came to name after the door's first check: SD-N33, the import |
| +// instance came to name after the door's first check: the in-use sentence, the import |
| // rolled back and its host free, and no store declared for it. |
| func TestImportBeginEnvironmentNamedUnderTheLock(t *testing.T) { |
| f := newImportFixture(t) |
| enableInstances(t, f.fx, fromApp, fromManifest(true)) |
| f.fx.srv.cfg.Releases = &recordOnRecordWrite{Store: f.fx.rel, key: provision.RecordKey("bab", fromApp, importHost), env: "old"} |
| code, out := beginImport(t, f, importHost, importSHA, 10) |
| - if code != http.StatusConflict || str(t, out, "error") != sdN33 { |
| - t.Fatalf("= %d %v, want 409 SD-N33", code, out) |
| + if code != http.StatusConflict || str(t, out, "error") != envInUse("old") { |
| + t.Fatalf("= %d %v, want 409 %q", code, out, envInUse("old")) |
| } |
| f.requireNoRecord(t, importHost) |
| if store, _, err := objstore.GetIn(context.Background(), f.fx.rel, "bab", fromApp, "old"); err != nil || store != nil { |
diff --git a/internal/console/seedmove.go b/internal/console/seedmove.go| index 534b725b0..c400308d9 100644 |
| --- a/internal/console/seedmove.go |
| +++ b/internal/console/seedmove.go |
| @@ -26,6 +26,11 @@ func (s *Server) refuseSeedMove(w http.ResponseWriter, rec *provision.Record) bo |
| return false |
| } |
| +// placeSeedReadFailed is carlos apps place's answer when the strict read of |
| +// the app's records fails, before anything has moved. The listing after the |
| +// placement is written keeps main's sentence (placeInstancesFailed). |
| +const placeSeedReadFailed = "could not update this app's instances. Try again." |
| + |
| // placeSeedBlock is carlos apps place's seed rule (preflight M8(a)): a |
| // place re-homes every record of the app, so it is a move of each. It reads |
| // the app's records strictly and returns the first record's MoveBlock, "" |
diff --git a/internal/console/seedmove_test.go b/internal/console/seedmove_test.go| index 651992956..facae7699 100644 |
| --- a/internal/console/seedmove_test.go |
| +++ b/internal/console/seedmove_test.go |
| @@ -156,7 +156,7 @@ func TestPlaceRefusesWhenARecordCannotBeRead(t *testing.T) { |
| t.Fatal(err) |
| } |
| code, _, raw := rawReq(t, http.MethodPost, placeURL(fx, "shop"), token, `{"target":"fleet/pi"}`) |
| - if code != http.StatusBadGateway || errorText(raw) != "could not update this app's instances — try again" { |
| + if code != http.StatusBadGateway || errorText(raw) != "could not update this app's instances. Try again." { |
| t.Fatalf("an undecodable record: place = %d %s", code, raw) |
| } |
| assertNotPlaced(t, fx, "plain.example.com") |
| @@ -166,7 +166,7 @@ func TestPlaceRefusesWhenARecordCannotBeRead(t *testing.T) { |
| fx.srv.cfg.Releases = &failGetStore{Store: fx.rel, key: provision.RecordKey(fx.sqid, "shop", "plain.example.com")} |
| code, _, raw = rawReq(t, http.MethodPost, placeURL(fx, "shop"), token, `{"target":"fleet/pi"}`) |
| - if code != http.StatusBadGateway || errorText(raw) != "could not update this app's instances — try again" { |
| + if code != http.StatusBadGateway || errorText(raw) != "could not update this app's instances. Try again." { |
| t.Fatalf("a failed read: place = %d %s", code, raw) |
| } |
| assertNotPlaced(t, fx, "plain.example.com") |
diff --git a/internal/provision/seedswap.go b/internal/provision/seedswap.go| index e827a5538..4c9f93320 100644 |
| --- a/internal/provision/seedswap.go |
| +++ b/internal/provision/seedswap.go |
| @@ -50,7 +50,7 @@ func importShape(rt registry.Route) string { |
| case rt.Kind == "instance" && rt.Hibernate: |
| return "hibernate" |
| case rt.ClaimOwnership != (registry.ClaimOwnership{}): |
| - return "claim" |
| + return "claim_owner" |
| } |
| if _, fleet := registry.FleetPlacement(rt.Region); fleet { |
| return "region" |
diff --git a/internal/provision/seedswap_test.go b/internal/provision/seedswap_test.go| index ca815c4a6..d97687833 100644 |
| --- a/internal/provision/seedswap_test.go |
| +++ b/internal/provision/seedswap_test.go |
| @@ -215,7 +215,7 @@ func TestSwapRefusesEachCondition(t *testing.T) { |
| x := addrRow(r, a) |
| x.ClaimOwnership = registry.ClaimOwnership{ClaimIncarnation: "inc", ClaimStamp: 1, ClaimOwner: "owner", ClaimRowID: "row"} |
| return &x |
| - }, nil, nil, "(claim)"}, |
| + }, nil, nil, "(claim_owner)"}, |
| {"flat", func(r Record, a string) *registry.Route { x := addrRow(r, a); return &x }, nil, func(c *ReconcileConfig) { c.Layout = nil }, |
| "this box does not run instances nested"}, |
| {"flat intent", func(r Record, a string) *registry.Route { x := addrRow(r, a); return &x }, nil, func(c *ReconcileConfig) { |
diff --git a/system.go b/system.go| index 82ef5b189..ac42a6ac6 100644 |
| --- a/system.go |
| +++ b/system.go |
| @@ -127,7 +127,7 @@ The platform's own binary (ops account owners and CARLOS_SYSTEM_OPERATORS): |
| 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> |
| + `+systemLegacyRoutesVerb+` |
| Deployment-wide settings (CARLOS_STORE_OPERATORS): |
diff --git a/system_legacy_routes.go b/system_legacy_routes.go| index 8ce56a3e7..484f1c2ef 100644 |
| --- a/system_legacy_routes.go |
| +++ b/system_legacy_routes.go |
| @@ -10,6 +10,10 @@ import ( |
| "os" |
| ) |
| +// systemLegacyRoutesVerb is the line carlos system's usage puts under the |
| +// legacy-routes assign verb. |
| +const systemLegacyRoutesVerb = "say which account and app the routes an operator filed by hand belong to" |
| + |
| // 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. |