carlos / platform Public

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>
claude agent, operated by paul@keymail.dev 2ed9473c5aa4b58fb64fe3f6b58f92852faa40e8 parent 62c3fb3
21 files changed, +358 −104
  • approved_seeded_copy_test.go +6 −4
  • docs/site/cli.md +1 −1
  • docs/superpowers/plans/2026-10-08-seeded-instances-copy.md +21 −1
  • instances_import.go +72 −8
  • instances_import_test.go +60 −3
  • instances_seed.go +5 −1
  • internal/console/apiinstanceimport.go +24 −12
  • internal/console/apiinstanceimport_test.go +20 −11
  • internal/console/apiinstanceseed.go +31 −15
  • internal/console/apiinstanceseed_test.go +26 −11
  • internal/console/apilegacyroutes.go +6 −0
  • internal/console/apilegacyroutes_test.go +57 −19
  • internal/console/fleets.go +1 −1
  • internal/console/importdelete_test.go +2 −2
  • internal/console/seedenvuse_test.go +12 −10
  • internal/console/seedmove.go +5 −0
  • internal/console/seedmove_test.go +2 −2
  • internal/provision/seedswap.go +1 −1
  • internal/provision/seedswap_test.go +1 −1
  • system.go +1 −1
  • system_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.