carlos / platform Public

carlos instances import, with --verify, and import commit and abort (Plan 6 Task 6)

The CLI hashes the file, begins on a 75 s client (M4), streams the upload
with Expect: 100-continue, follows the 202 to "uploaded", marks it stopped,
waits for the swap and then either prints the commands to finish by hand or
verifies (502/503/504 retried, redirects never followed, M16) and commits or
aborts. One poller (pollSeed) now serves --from and the import (M18); the
token is shown right after begin without --verify, and with --verify only
before an exit that leaves the import live (M17). A refused stopped, commit
or abort is settled by re-reading the record. Wires SD-N68 to SD-N82,
SD-N89 and SD-N90.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 87a1ead8b5d234d31a4975ae4ca26d8b1c911ed7 parent 76f7e49
7 files changed, +1246 −19
  • apimode.go +82 −0
  • approved_seeded_copy_test.go +5 −2
  • cli.go +3 −0
  • docs/site/cli.md +8 −0
  • instances_import.go +370 −0
  • instances_import_test.go +733 −0
  • instances_seed.go +45 −17
diff --git a/apimode.go b/apimode.go
index ff08da522..01c52e3ff 100644
--- a/apimode.go
+++ b/apimode.go
@@ -3714,6 +3714,88 @@ func (s *apiSession) instanceGet(ctx context.Context, a apiAcct, app, host strin
return out, true, nil
}
+// importBegin is the import door's begin (seeded-instances spec §5.1): the
+// ordinary create body plus "import". The verify token comes back once and
+// is returned, never stored or logged. Begin may provision a private store,
+// which can take the console up to a minute, so it gets storeProvision's
+// scoped client rather than the session's 30-second one (a cut begin would
+// lose the only copy of the token).
+func (s *apiSession) importBegin(ctx context.Context, a apiAcct, app, host, environment, sha string, bytes int64, replaces string, messages ...string) (apiInstanceRecord, string, error) {
+ in := struct {
+ Host string `json:"host"`
+ Environment string `json:"environment,omitempty"`
+ Message string `json:"message,omitempty"`
+ Import struct {
+ SHA256 string `json:"sha256"`
+ Bytes int64 `json:"bytes"`
+ Replaces string `json:"replaces"`
+ } `json:"import"`
+ }{Host: host, Environment: environment, Message: firstMutationMessage(messages)}
+ in.Import.SHA256, in.Import.Bytes, in.Import.Replaces = sha, bytes, replaces
+ clone := *s
+ client := *s.c
+ httpc := *s.c.httpc
+ httpc.Timeout = storeProvisionHTTPTimeout
+ client.httpc = &httpc
+ clone.c = &client
+ status, body, err := clone.postJSON(ctx, apiPath(a, app, "/instances"), in)
+ if err != nil {
+ return apiInstanceRecord{}, "", err
+ }
+ if status != http.StatusCreated {
+ return apiInstanceRecord{}, "", apiErrorFromBody(status, body)
+ }
+ var out struct {
+ apiInstanceRecord
+ VerifyToken string `json:"verify_token"`
+ }
+ if err := json.Unmarshal(body, &out); err != nil {
+ return apiInstanceRecord{}, "", fmt.Errorf("instances import: unreadable response: %w", err)
+ }
+ if out.VerifyToken == "" {
+ return apiInstanceRecord{}, "", errors.New("instances import: unreadable response")
+ }
+ return out.apiInstanceRecord, out.VerifyToken, nil
+}
+
+// importUploadDeadline bounds the upload: the console reads the body for
+// up to 60 minutes and answers 202 straight after the last byte.
+const importUploadDeadline = 65 * time.Minute
+
+// importUpload streams the snapshot (spec §5.2) on doStream's client, which
+// has no overall timeout, under importUploadDeadline. Expect: 100-continue
+// lets a refusal (busy scratch, wrong state) arrive before gigabytes are
+// sent. A 202 means the console checks and stores it on its own; the
+// caller follows the record to "uploaded".
+func (s *apiSession) importUpload(ctx context.Context, a apiAcct, app, host string, f *os.File, size int64) error {
+ ctx, cancel := context.WithTimeout(ctx, importUploadDeadline)
+ defer cancel()
+ res, err := s.c.doStream(ctx, http.MethodPut, apiPath(a, app, "/instances/"+url.PathEscape(host)+"/import"), f, size,
+ http.Header{"Content-Type": {"application/octet-stream"}, "Expect": {"100-continue"}})
+ if err != nil {
+ return err
+ }
+ defer res.Body.Close()
+ raw, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20))
+ if res.StatusCode != http.StatusAccepted && res.StatusCode != http.StatusOK {
+ return apiErrorFromBody(res.StatusCode, raw)
+ }
+ return nil
+}
+
+// importVerb is the import's stopped, commit or abort (spec §5.3, §5.4,
+// §5.6): a bodiless POST that answers 200.
+func (s *apiSession) importVerb(ctx context.Context, a apiAcct, app, host, verb string) error {
+ status, body, err := s.postJSON(ctx, apiPath(a, app, "/instances/"+url.PathEscape(host)+"/import/"+verb), struct{}{})
+ if err != nil {
+ return err
+ }
+ if status != http.StatusOK {
+ return apiErrorFromBody(status, body)
+ }
+ return nil
+}
+
// apiSidecarManifest mirrors the console's sidecars-manifest response
// (handleAPISidecarEnable's writeJSON, internal/console/apisidecars.go) —
// read back after a write so the confirmation prints what the console
diff --git a/approved_seeded_copy_test.go b/approved_seeded_copy_test.go
index 0de3b1b31..499dcfd02 100644
--- a/approved_seeded_copy_test.go
+++ b/approved_seeded_copy_test.go
@@ -36,14 +36,17 @@ var seededCopyIDs = func() []string {
// SD-N111 and SD-N112; Plan 4 Task 3 SD-N14 to SD-N19; Plan 5 Task 3 SD-N27 to SD-N29,
// Task 5 SD-N20 to SD-N26, SD-N30 to SD-N39 and SD-N41; Plan 5 Task 6 SD-N40, SD-N42 to SD-N47, SD-N83 to SD-N88 and SD-N114;
// 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 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.
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",
"SD-N48", "SD-N49", "SD-N50", "SD-N62", "SD-N67",
"SD-N51", "SD-N52", "SD-N53", "SD-N59", "SD-N61", "SD-N63",
"SD-N54", "SD-N55", "SD-N60", "SD-N64", "SD-N65", "SD-N115",
- "SD-N56", "SD-N57", "SD-N58", "SD-N66"}
+ "SD-N56", "SD-N57", "SD-N58", "SD-N66",
+ "SD-N68", "SD-N69", "SD-N70", "SD-N71", "SD-N72", "SD-N73", "SD-N74", "SD-N75", "SD-N76", "SD-N77", "SD-N78", "SD-N79",
+ "SD-N80", "SD-N81", "SD-N82", "SD-N89", "SD-N90"}
func mustParseSeededRecord(t *testing.T) []copyRow {
t.Helper()
diff --git a/cli.go b/cli.go
index a4020850d..68b27cfda 100644
--- a/cli.go
+++ b/cli.go
@@ -373,6 +373,9 @@ func runInstances(args []string) {
case "create":
runInstancesCreate(args[1:])
return
+ case "import":
+ runInstancesImport(args[1:])
+ return
case "list":
runInstancesList(args[1:])
return
diff --git a/docs/site/cli.md b/docs/site/cli.md
index e9c9c5a1c..bd431e15c 100644
--- a/docs/site/cli.md
+++ b/docs/site/cli.md
@@ -701,6 +701,14 @@ carlos instances create --app shop --host canary.example.com --from www.example.
carlos instances create --app shop --host canary.example.com --from www.example.com --at 4211
```
+carlos instances import moves an instance the platform never ran onto the platform. Stop the old process, take a consistent snapshot of its database (VACUUM INTO works), and run the import with the address of the route it replaces. The console checks the file, the box swaps the route on its next pass, and from then until you commit, the host answers only requests that carry the verify token. With --verify, the CLI requests that path with the token and commits on a 2xx or aborts otherwise. Without it, the CLI prints the token and the two commands to finish by hand. Aborting puts the old route back exactly as it was.
+
+```sh
+carlos instances import --app shop --host main.shop.example.com --file main.db --replaces 127.0.0.1:8401 --verify /up
+carlos instances import commit --app shop --host main.shop.example.com
+carlos instances import abort --app shop --host main.shop.example.com
+```
+
#### What an instance is started with
A hibernating instance is started by the box when a request arrives for it,
diff --git a/instances_import.go b/instances_import.go
new file mode 100644
index 000000000..8b8ed7690
--- /dev/null
+++ b/instances_import.go
@@ -0,0 +1,370 @@
+package main
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "os"
+ "slices"
+ "strconv"
+ "strings"
+ "time"
+)
+
+const (
+ // importCheckWait bounds the wait for "uploaded" after the 202: the
+ // console's integrity check (30 minutes) and its put (20), and a CAS.
+ importCheckWait = 55 * time.Minute
+ // importSwapWait and importPlaceWait are spec §5's waits for the box.
+ importSwapWait = 10 * time.Minute
+ importPlaceWait = 5 * time.Minute
+ // importVerifyTries is how many times a verify answered 502, 503 or 504
+ // (the gate while the instance wakes, or a backhaul) is asked again,
+ // waiting Retry-After, or importVerifyRetry without one.
+ importVerifyTries = 20
+ importVerifyRetry = 3 * time.Second
+)
+
+// 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")
+
+// importOver is an import that ended on the console or the box, not by
+// this command: refused, aborted, rolled back, or no longer on the record.
+// line is what to print. The token is not shown: there is nothing left to
+// verify.
+type importOver struct{ line string }
+
+func (e importOver) Error() string { return e.line }
+
+// importLive is an exit that leaves the import live and uncommitted
+// (spec §5, ruling M17): the token is shown first, then lines.
+type importLive struct{ lines []string }
+
+func (e importLive) Error() string { return strings.Join(e.lines, "\n") }
+
+// importJob is one `carlos instances import`.
+type importJob struct {
+ acct apiAcct
+ app, host, file, replaces, environment, verify, message string
+ verifyClient *http.Client // nil: a client with a 2-minute timeout
+}
+
+// runInstancesImport is `carlos instances import` (seeded-instances spec §5).
+func runInstancesImport(args []string) {
+ if len(args) > 0 && (args[0] == "commit" || args[0] == "abort") {
+ runInstancesImportVerb(args[0], args[1:])
+ return
+ }
+ fs := flag.NewFlagSet("instances import", flag.ExitOnError)
+ messageFlags := addMutationMessageFlags(fs)
+ app := appFlag(fs, "app the instance belongs to")
+ host := fs.String("host", "", "instance host — one DNS label + \".\" + the app's instance domain")
+ file := fs.String("file", "", "the snapshot to import: a consistent SQLite file taken after the old process stopped")
+ replaces := fs.String("replaces", "", "the address of the route this import takes over, as host:port")
+ environment := fs.String("environment", "", "named config bundle this route binds to (default: the app's default bundle) — carlos env environments lists them")
+ verify := fs.String("verify", "", "a path to request with the verify token once swapped. A 2xx commits the import, anything else aborts it")
+ 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.Parse(args)
+ if *host == "" || *file == "" || *replaces == "" {
+ fs.Usage()
+ os.Exit(2)
+ }
+ sess := requireAPISession("import", *consoleFlag)
+ printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
+ app2 := resolvedApp(sess, acct, *app, instancesRecordsUsage)
+ message, merr := resolveMutationMessage(fs, messageFlags)
+ if merr != nil {
+ log.Fatalf("instances import: %v", merr)
+ }
+ err := sess.runImport(context.Background(), stdout, importJob{acct: acct, app: app2, host: strings.TrimSpace(*host), file: *file,
+ replaces: strings.TrimSpace(*replaces), environment: strings.TrimSpace(*environment), verify: strings.TrimSpace(*verify), message: message})
+ switch {
+ case errors.Is(err, errImportFailed):
+ os.Exit(1)
+ case err != nil:
+ log.Fatalf("instances import: %v", err)
+ }
+}
+
+// runImport hashes the file and begins, then drives the import (driveImport)
+// and settles what its ending prints. The verify token stays in memory. It
+// is printed (SD-N72) right after begin without --verify, and with --verify
+// only before an exit that leaves the import live and uncommitted.
+func (s *apiSession) runImport(ctx context.Context, w io.Writer, j importJob) error {
+ f, err := os.Open(j.file)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ h := sha256.New()
+ size, err := io.Copy(h, f)
+ if err != nil {
+ return err
+ }
+ fmt.Fprintln(w, fmt.Sprintf("uploading %s (%s)", j.file, humanBytes(&size)))
+ rec, token, err := s.importBegin(ctx, j.acct, j.app, j.host, j.environment, hex.EncodeToString(h.Sum(nil)), size, j.replaces, j.message)
+ if err != nil {
+ return err
+ }
+ id := ""
+ if rec.Seed != nil {
+ id = rec.Seed.ID
+ }
+ shown := false
+ showToken := func() {
+ if !shown {
+ fmt.Fprintln(w, fmt.Sprintf("verify token: %s. It is shown only this once.", token))
+ shown = true
+ }
+ }
+ if j.verify == "" {
+ showToken()
+ }
+ err = s.driveImport(ctx, w, j, f, size, id, token)
+ var over importOver
+ var live importLive
+ switch {
+ case err == nil, errors.Is(err, errImportFailed):
+ return err
+ case errors.As(err, &over):
+ fmt.Fprintln(w, over.line)
+ return errImportFailed
+ }
+ showToken()
+ if errors.As(err, &live) {
+ for _, l := range live.lines {
+ fmt.Fprintln(w, l)
+ }
+ return errImportFailed
+ }
+ return err
+}
+
+// driveImport is everything after begin: upload, wait for the check,
+// stopped, wait for the swap, and then either the hand-finish commands or
+// the verify and its commit or abort.
+func (s *apiSession) driveImport(ctx context.Context, w io.Writer, j importJob, f *os.File, size int64, id, token string) error {
+ if _, err := f.Seek(0, io.SeekStart); err != nil {
+ return err
+ }
+ if err := s.importUpload(ctx, j.acct, j.app, j.host, f, size); err != nil {
+ return err
+ }
+ // 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")); err != nil {
+ return err
+ }
+ if err := s.importStep(ctx, j.acct, j.app, j.host, id, "stopped"); err != nil {
+ return err
+ }
+ 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))
+ if _, err := s.pollSeed(ctx, j.acct, j.app, j.host, importSwapWait, importLook(id, "swapped", "placed")); 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.",
+ j.host, int(importSwapWait/time.Minute))}}
+ }
+ return err
+ }
+ fmt.Fprintln(w, fmt.Sprintf("swapped. Until it is committed, %s answers only requests that carry the verify token.", j.host))
+ if j.verify == "" {
+ fmt.Fprintln(w, importFinishLine(j.app, j.host))
+ return nil
+ }
+ client := j.verifyClient
+ if client == nil {
+ client = &http.Client{Timeout: 2 * time.Minute}
+ }
+ code, verr := verifyImport(ctx, client, j.host, j.verify, token)
+ 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 errors.Is(err, errSeedWaitOver) {
+ return importLive{lines: []string{importFinishLine(j.app, j.host)}}
+ }
+ return err
+ }
+ if err := s.importStep(ctx, j.acct, j.app, j.host, id, "commit"); err != nil {
+ return err
+ }
+ fmt.Fprintln(w, importCommittedLine(j.host))
+ return nil
+ }
+ status := strconv.Itoa(code)
+ if verr != nil {
+ status = verr.Error()
+ }
+ fmt.Fprintln(w, fmt.Sprintf("verify failed (%s). Aborting the import.", status))
+ if err := s.importStep(ctx, j.acct, j.app, j.host, id, "abort"); err != nil {
+ return err
+ }
+ fmt.Fprintln(w, importAbortedLine(j.host))
+ return errImportFailed
+}
+
+// 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) {
+ 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"}})}
+ }
+ switch sd.State {
+ case "refused":
+ return false, importOver{line: fmt.Sprintf("the box refused the import: %s", sd.Reason)}
+ case "aborted", "rolled-back":
+ if sd.Reason != "" {
+ return false, importOver{line: sd.Reason}
+ }
+ return false, importOver{line: seedCell(rec)}
+ }
+ return slices.Contains(states, sd.State), nil
+ }
+}
+
+// importLanded is, per verb, the states that prove it landed.
+var importLanded = map[string][]string{
+ "stopped": {"ready", "swapped", "placed", "committed"},
+ "commit": {"committed"},
+ "abort": {"aborted", "rolled-back"},
+}
+
+// importStep runs one of the import's verbs. When the console refuses it,
+// the record says whether it landed anyway: a stopped whose first answer
+// was lost is refused naming a later state (SD-N54), and a commit or abort
+// that overlapped another is refused as changed (SD-N60). id, when known,
+// is this import's seed; an abort whose record has then gone is done, since
+// the abort finishes a rolled-back import and deletes its record.
+func (s *apiSession) importStep(ctx context.Context, a apiAcct, app, host, id, verb string) error {
+ err := s.importVerb(ctx, a, app, host, verb)
+ if err == nil {
+ return nil
+ }
+ rec, found, rerr := s.instanceGet(ctx, a, app, host)
+ switch {
+ case rerr != nil:
+ return err
+ case !found:
+ if verb == "abort" && id != "" {
+ return nil
+ }
+ return err
+ case rec.Seed == nil || (id != "" && rec.Seed.ID != id):
+ return err
+ case slices.Contains(importLanded[verb], rec.Seed.State):
+ return nil
+ }
+ return err
+}
+
+func importFinishLine(app, host string) string {
+ return fmt.Sprintf("commit with: carlos instances import commit --app %s --host %s. Abort with: carlos instances import abort --app %s --host %s.", app, host, app, host)
+}
+
+func importCommittedLine(host string) string {
+ return fmt.Sprintf("committed. %s is served by the platform now.", host)
+}
+
+func importAbortedLine(host string) string {
+ return fmt.Sprintf("aborted. The box puts the old route of %s back on its next pass.", host)
+}
+
+// runInstancesImportVerb is `carlos instances import commit|abort`: the
+// verb alone, for an import left to be finished by hand.
+func runInstancesImportVerb(verb string, args []string) {
+ fs := flag.NewFlagSet("instances import "+verb, flag.ExitOnError)
+ app := appFlag(fs, "app the instance belongs to")
+ host := fs.String("host", "", "instance host — one DNS label + \".\" + the app's instance domain")
+ 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.Parse(args)
+ if *host == "" {
+ fs.Usage()
+ os.Exit(2)
+ }
+ sess := requireAPISession("import", *consoleFlag)
+ printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
+ app2 := resolvedApp(sess, acct, *app, instancesRecordsUsage)
+ if err := sess.runImportVerb(context.Background(), stdout, acct, app2, strings.TrimSpace(*host), verb); err != nil {
+ log.Fatalf("instances import %s: %v", verb, err)
+ }
+}
+
+func (s *apiSession) runImportVerb(ctx context.Context, w io.Writer, a apiAcct, app, host, verb string) error {
+ if err := s.importStep(ctx, a, app, host, "", verb); err != nil {
+ return err
+ }
+ if verb == "commit" {
+ fmt.Fprintln(w, importCommittedLine(host))
+ return nil
+ }
+ fmt.Fprintln(w, importAbortedLine(host))
+ return nil
+}
+
+// verifyImport is the verify request through the edge (spec §5.5) to
+// https://<host><path>.
+func verifyImport(ctx context.Context, client *http.Client, host, path, token string) (int, error) {
+ if !strings.HasPrefix(path, "/") {
+ path = "/" + path
+ }
+ return verifyImportURL(ctx, client, "https://"+host+path, token)
+}
+
+// verifyImportURL asks target with the verify token and returns the
+// status that decides the import. 502, 503 and 504 are asked again (the
+// gate answers 503 with Retry-After while the instance wakes, and a
+// backhaul can answer 502 or 504 meanwhile), up to importVerifyTries
+// times. A redirect is never followed: only a 2xx of the path itself
+// commits.
+func verifyImportURL(ctx context.Context, client *http.Client, target, token string) (int, error) {
+ c := *client
+ c.CheckRedirect = neverFollow
+ for try := 0; ; try++ {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
+ if err != nil {
+ return 0, err
+ }
+ req.Header.Set("Carlos-Import-Verify", token)
+ res, err := c.Do(req)
+ if err != nil {
+ return 0, err
+ }
+ io.Copy(io.Discard, io.LimitReader(res.Body, 1<<20))
+ res.Body.Close()
+ switch res.StatusCode {
+ case http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout:
+ default:
+ return res.StatusCode, nil
+ }
+ if try >= importVerifyTries {
+ return res.StatusCode, nil
+ }
+ wait := importVerifyRetry
+ if n, err := strconv.Atoi(res.Header.Get("Retry-After")); err == nil && n >= 0 && n <= 30 {
+ wait = time.Duration(n) * time.Second
+ }
+ if err := seedWait(ctx, wait); err != nil {
+ return 0, err
+ }
+ }
+}
diff --git a/instances_import_test.go b/instances_import_test.go
new file mode 100644
index 000000000..c10be46e5
--- /dev/null
+++ b/instances_import_test.go
@@ -0,0 +1,733 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+func TestImportVerifyRetriesThroughTheWake(t *testing.T) {
+ var calls atomic.Int32
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Header.Get("Carlos-Import-Verify") != "tok" {
+ w.WriteHeader(http.StatusForbidden)
+ return
+ }
+ if calls.Add(1) < 3 {
+ w.Header().Set("Retry-After", "0")
+ w.WriteHeader(http.StatusServiceUnavailable)
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+ }))
+ defer ts.Close()
+ code, err := verifyImportURL(context.Background(), ts.Client(), ts.URL+"/up", "tok")
+ if err != nil || code != http.StatusNoContent || calls.Load() != 3 {
+ t.Fatalf("code %d err %v calls %d", code, err, calls.Load())
+ }
+}
+
+func TestImportVerifyStopsOnAFailure(t *testing.T) {
+ var calls atomic.Int32
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ calls.Add(1)
+ w.WriteHeader(http.StatusInternalServerError)
+ }))
+ defer ts.Close()
+ code, err := verifyImportURL(context.Background(), ts.Client(), ts.URL+"/up", "tok")
+ if err != nil || code != http.StatusInternalServerError || calls.Load() != 1 {
+ t.Fatalf("code %d err %v calls %d", code, err, calls.Load())
+ }
+}
+
+// fakeSeedWait replaces the poll seam for one test: no real sleeps, and
+// every wait is recorded.
+func fakeSeedWait(t *testing.T, every time.Duration) *[]time.Duration {
+ t.Helper()
+ var mu sync.Mutex
+ waits := &[]time.Duration{}
+ oldWait, oldEvery := seedWait, seedPollEvery
+ seedWait = func(ctx context.Context, d time.Duration) error {
+ mu.Lock()
+ *waits = append(*waits, d)
+ mu.Unlock()
+ return ctx.Err()
+ }
+ seedPollEvery = every
+ t.Cleanup(func() { seedWait, seedPollEvery = oldWait, oldEvery })
+ return waits
+}
+
+// A gateway answer (502, 504) while the instance wakes is retried as a 503
+// is, honouring Retry-After (M16).
+func TestImportVerifyRetriesGatewayAnswers(t *testing.T) {
+ waits := fakeSeedWait(t, 2*time.Second)
+ var calls atomic.Int32
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch calls.Add(1) {
+ case 1:
+ w.WriteHeader(http.StatusBadGateway)
+ case 2:
+ w.Header().Set("Retry-After", "7")
+ w.WriteHeader(http.StatusGatewayTimeout)
+ default:
+ w.WriteHeader(http.StatusOK)
+ }
+ }))
+ defer ts.Close()
+ code, err := verifyImportURL(context.Background(), ts.Client(), ts.URL+"/up", "tok")
+ if err != nil || code != http.StatusOK || calls.Load() != 3 {
+ t.Fatalf("code %d err %v calls %d", code, err, calls.Load())
+ }
+ if len(*waits) != 2 || (*waits)[0] != 3*time.Second || (*waits)[1] != 7*time.Second {
+ t.Fatalf("waits = %v, want 3s (no Retry-After) then 7s", *waits)
+ }
+}
+
+func TestImportVerifyGivesUpAfterItsTries(t *testing.T) {
+ fakeSeedWait(t, 2*time.Second)
+ var calls atomic.Int32
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ calls.Add(1)
+ w.WriteHeader(http.StatusServiceUnavailable)
+ }))
+ defer ts.Close()
+ code, err := verifyImportURL(context.Background(), ts.Client(), ts.URL+"/up", "tok")
+ if err != nil || code != http.StatusServiceUnavailable || calls.Load() != importVerifyTries+1 {
+ t.Fatalf("code %d err %v calls %d", code, err, calls.Load())
+ }
+}
+
+// A redirect is the verify's answer, never followed: /up answering 302 to a
+// login page that says 200 must not commit (M16).
+func TestImportVerifyDoesNotFollowARedirect(t *testing.T) {
+ var landed atomic.Bool
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == "/login" {
+ landed.Store(true)
+ w.WriteHeader(http.StatusOK)
+ return
+ }
+ http.Redirect(w, r, "/login", http.StatusFound)
+ }))
+ defer ts.Close()
+ client := ts.Client() // follows redirects by default
+ code, err := verifyImportURL(context.Background(), client, ts.URL+"/up", "tok")
+ if err != nil || code != http.StatusFound || landed.Load() {
+ t.Fatalf("code %d err %v followed %v", code, err, landed.Load())
+ }
+ if client.CheckRedirect != nil {
+ t.Fatal("verifyImportURL changed the caller's client")
+ }
+}
+
+func TestImportVerifyBuildsTheHostURL(t *testing.T) {
+ var path, tok string
+ ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ path, tok = r.URL.Path, r.Header.Get("Carlos-Import-Verify")
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer ts.Close()
+ host := strings.TrimPrefix(ts.URL, "https://")
+ code, err := verifyImport(context.Background(), ts.Client(), host, "up", "tok")
+ if err != nil || code != http.StatusOK || path != "/up" || tok != "tok" {
+ t.Fatalf("code %d err %v path %q token %q", code, err, path, tok)
+ }
+}
+
+func TestImportClientCallsTheDoor(t *testing.T) {
+ var seen []string
+ var begin map[string]any
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ seen = append(seen, r.Method+" "+r.URL.Path)
+ switch {
+ case r.Method == http.MethodPost && r.URL.Path == "/api/cli/apps/bab/shop/instances":
+ raw, _ := io.ReadAll(r.Body)
+ _ = json.Unmarshal(raw, &begin)
+ w.WriteHeader(http.StatusCreated)
+ w.Write([]byte(`{"host":"old.shop.example.com","verify_token":"tok","seed":{"id":"sd_imp","kind":"import","state":"awaiting-upload"}}`))
+ default:
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte(`{"state":"ok"}`))
+ }
+ }))
+ defer ts.Close()
+ s := newTestSession(ts.URL)
+ a := apiAcct{Seg: "bab"}
+ rec, tok, err := s.importBegin(context.Background(), a, "shop", "old.shop.example.com", "staging", "aa", 3, "127.0.0.1:8401", "moving the shop")
+ if err != nil || tok != "tok" || rec.Seed == nil || rec.Seed.ID != "sd_imp" {
+ t.Fatalf("begin: %q %+v %v", tok, rec, err)
+ }
+ imp, _ := begin["import"].(map[string]any)
+ if begin["host"] != "old.shop.example.com" || begin["environment"] != "staging" || begin["message"] != "moving the shop" ||
+ imp["sha256"] != "aa" || imp["bytes"] != float64(3) || imp["replaces"] != "127.0.0.1:8401" {
+ t.Fatalf("begin body = %v", begin)
+ }
+ for _, verb := range []string{"stopped", "commit", "abort"} {
+ if err := s.importVerb(context.Background(), a, "shop", "old.shop.example.com", verb); err != nil {
+ t.Fatalf("%s: %v", verb, err)
+ }
+ }
+ want := []string{"POST /api/cli/apps/bab/shop/instances", "POST /api/cli/apps/bab/shop/instances/old.shop.example.com/import/stopped",
+ "POST /api/cli/apps/bab/shop/instances/old.shop.example.com/import/commit", "POST /api/cli/apps/bab/shop/instances/old.shop.example.com/import/abort"}
+ if len(seen) != len(want) {
+ t.Fatalf("calls = %q", seen)
+ }
+ for i := range want {
+ if seen[i] != want[i] {
+ t.Fatalf("call %d = %q, want %q", i, seen[i], want[i])
+ }
+ }
+}
+
+func TestImportBeginRefusalAndUnreadableAnswer(t *testing.T) {
+ for _, c := range []struct {
+ status int
+ body string
+ wantErr string
+ }{
+ {http.StatusBadRequest, `{"error":"the sha256 is not 64 hex characters"}`, "the sha256 is not 64 hex characters"},
+ {http.StatusCreated, `{"host":"old.shop.example.com"}`, "instances import: unreadable response"},
+ {http.StatusCreated, `not json`, "instances import: unreadable response"},
+ } {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(c.status)
+ io.WriteString(w, c.body)
+ }))
+ _, tok, err := newTestSession(ts.URL).importBegin(context.Background(), apiAcct{Seg: "bab"}, "shop", "old.shop.example.com", "", "aa", 3, "127.0.0.1:8401")
+ ts.Close()
+ if err == nil || !strings.HasPrefix(err.Error(), c.wantErr) || tok != "" {
+ t.Errorf("%d %s: token %q err %v", c.status, c.body, tok, err)
+ }
+ }
+}
+
+// Begin can provision a private store (up to about 75 s), so it must not
+// ride the session's 30 s JSON client (M4). The session's client is shrunk
+// below the server's delay to show begin does not use it, and is left as
+// it was.
+func TestImportBeginOutlastsTheJSONClient(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ time.Sleep(150 * time.Millisecond)
+ w.WriteHeader(http.StatusCreated)
+ io.WriteString(w, `{"host":"old.shop.example.com","verify_token":"tok"}`)
+ }))
+ defer ts.Close()
+ s := newTestSession(ts.URL)
+ s.c.httpc.Timeout = 20 * time.Millisecond
+ if _, tok, err := s.importBegin(context.Background(), apiAcct{Seg: "bab"}, "shop", "old.shop.example.com", "", "aa", 3, "127.0.0.1:8401"); err != nil || tok != "tok" {
+ t.Fatalf("token %q err %v", tok, err)
+ }
+ if s.c.httpc.Timeout != 20*time.Millisecond {
+ t.Fatalf("the session's client timeout became %v", s.c.httpc.Timeout)
+ }
+}
+
+func snapshotFile(t *testing.T, body string) *os.File {
+ t.Helper()
+ p := filepath.Join(t.TempDir(), "main.db")
+ if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ f, err := os.Open(p)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { f.Close() })
+ return f
+}
+
+func TestImportUploadStreamsTheFile(t *testing.T) {
+ var got struct {
+ method, path, expect, ctype string
+ length int64
+ body string
+ }
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ raw, _ := io.ReadAll(r.Body)
+ got.method, got.path, got.expect, got.ctype = r.Method, r.URL.Path, r.Header.Get("Expect"), r.Header.Get("Content-Type")
+ got.length, got.body = r.ContentLength, string(raw)
+ time.Sleep(150 * time.Millisecond)
+ w.WriteHeader(http.StatusAccepted)
+ io.WriteString(w, `{"state":"checking"}`)
+ }))
+ defer ts.Close()
+ s := newTestSession(ts.URL)
+ s.c.httpc.Timeout = 20 * time.Millisecond // the upload never rides the JSON client
+ if err := s.importUpload(context.Background(), apiAcct{Seg: "bab"}, "shop", "old.shop.example.com", snapshotFile(t, "abc"), 3); err != nil {
+ t.Fatal(err)
+ }
+ if got.method != http.MethodPut || got.path != "/api/cli/apps/bab/shop/instances/old.shop.example.com/import" ||
+ got.expect != "100-continue" || got.ctype != "application/octet-stream" || got.length != 3 || got.body != "abc" {
+ t.Fatalf("request = %+v", got)
+ }
+}
+
+func TestImportUploadRefusal(t *testing.T) {
+ const sdN51 = "the upload does not match the length and sha256 this import began with, so it was not kept."
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ io.Copy(io.Discard, r.Body)
+ w.WriteHeader(http.StatusUnprocessableEntity)
+ fmt.Fprintf(w, `{"error":%q}`, sdN51)
+ }))
+ defer ts.Close()
+ err := newTestSession(ts.URL).importUpload(context.Background(), apiAcct{Seg: "bab"}, "shop", "old.shop.example.com", snapshotFile(t, "abc"), 3)
+ if err == nil || err.Error() != sdN51 {
+ t.Fatalf("err = %v", err)
+ }
+}
+
+// importConsole is a console for one import: begin, upload and the verbs
+// answer as configured, and the record walks through gets, one per look
+// (the last one repeats).
+type importConsole struct {
+ mu sync.Mutex
+ calls []string
+ beginStatus int
+ beginBody string
+ upload func(w http.ResponseWriter)
+ verbs map[string]func(w http.ResponseWriter)
+ gets []func(w http.ResponseWriter)
+ looks int
+}
+
+const importBeginBody = `{"host":"main.shop.example.com","verify_token":"s3cret-token","seed":{"id":"sd_imp","kind":"import","state":"awaiting-upload"}}`
+
+func newImportConsole(gets ...func(w http.ResponseWriter)) *importConsole {
+ return &importConsole{beginStatus: http.StatusCreated, beginBody: importBeginBody, gets: gets, verbs: map[string]func(http.ResponseWriter){}}
+}
+
+func (c *importConsole) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ io.Copy(io.Discard, r.Body)
+ path := r.URL.Path
+ if i := strings.Index(path, "/instances"); i >= 0 {
+ path = path[i:]
+ }
+ switch {
+ case r.Method == http.MethodPost && path == "/instances":
+ c.calls = append(c.calls, "begin")
+ w.WriteHeader(c.beginStatus)
+ io.WriteString(w, c.beginBody)
+ case r.Method == http.MethodPut:
+ c.calls = append(c.calls, "upload")
+ if c.upload != nil {
+ c.upload(w)
+ return
+ }
+ w.WriteHeader(http.StatusAccepted)
+ io.WriteString(w, `{"state":"checking"}`)
+ case r.Method == http.MethodPost:
+ verb := path[strings.LastIndex(path, "/")+1:]
+ c.calls = append(c.calls, verb)
+ if f := c.verbs[verb]; f != nil {
+ f(w)
+ return
+ }
+ fmt.Fprintf(w, `{"state":%q}`, verb)
+ case r.Method == http.MethodGet:
+ c.calls = append(c.calls, "get")
+ i := c.looks
+ if c.looks++; i >= len(c.gets) {
+ i = len(c.gets) - 1
+ }
+ c.gets[i](w)
+ }
+}
+
+func (c *importConsole) called() []string {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return append([]string(nil), c.calls...)
+}
+
+// called reports whether the console saw verb.
+func (c *importConsole) saw(verb string) bool {
+ for _, v := range c.called() {
+ if v == verb {
+ return true
+ }
+ }
+ return false
+}
+
+func importRecord(state, reason string) func(w http.ResponseWriter) {
+ return importRecordID("sd_imp", state, reason)
+}
+
+func importRecordID(id, state, reason string) func(w http.ResponseWriter) {
+ return func(w http.ResponseWriter) {
+ fmt.Fprintf(w, `{"host":"main.shop.example.com","seed":{"id":%q,"kind":"import","sha256":"ab","bytes":3,"state":%q,"reason":%q}}`, id, state, reason)
+ }
+}
+
+// importRun runs an import of a three-byte file against c, with verify
+// (when not empty) answered by verifyStatus through a TLS server standing
+// in for the instance's host. It returns the output and the error.
+func importRun(t *testing.T, c *importConsole, verify string, verifyStatus ...int) (string, error) {
+ t.Helper()
+ ts := httptest.NewServer(c)
+ defer ts.Close()
+ j := importJob{acct: apiAcct{Seg: "bab"}, app: "shop", host: "main.shop.example.com", file: filepath.Join(t.TempDir(), "main.db"),
+ replaces: "127.0.0.1:8401", verify: verify}
+ if err := os.WriteFile(j.file, []byte("abc"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if verify != "" {
+ var n atomic.Int32
+ vs := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Header.Get("Carlos-Import-Verify") != "s3cret-token" {
+ w.WriteHeader(http.StatusForbidden)
+ return
+ }
+ i := int(n.Add(1)) - 1
+ if i >= len(verifyStatus) {
+ i = len(verifyStatus) - 1
+ }
+ w.WriteHeader(verifyStatus[i])
+ }))
+ defer vs.Close()
+ j.host, j.verifyClient = strings.TrimPrefix(vs.URL, "https://"), vs.Client()
+ }
+ var out bytes.Buffer
+ err := newTestSession(ts.URL).runImport(context.Background(), &out, j)
+ // The output names the file and the host as the tests know them.
+ return strings.NewReplacer(j.file, "main.db", j.host, "main.shop.example.com").Replace(out.String()), err
+}
+
+func wantLines(t *testing.T, out string, want ...string) {
+ t.Helper()
+ got := strings.Split(strings.TrimSuffix(out, "\n"), "\n")
+ if len(got) != len(want) {
+ t.Fatalf("output:\n%s\nwant:\n%s", out, strings.Join(want, "\n"))
+ }
+ for i := range want {
+ if got[i] != want[i] {
+ t.Fatalf("line %d = %q, want %q\noutput:\n%s", i, got[i], want[i], out)
+ }
+ }
+}
+
+const (
+ importCommitLine = "commit with: carlos instances import commit --app shop --host main.shop.example.com. Abort with: carlos instances import abort --app shop --host main.shop.example.com."
+ importTokenLine = "verify token: s3cret-token. It is shown only this once."
+)
+
+func TestImportWithoutVerifyPrintsTheTokenAndTheCommands(t *testing.T) {
+ waits := fakeSeedWait(t, 2*time.Second)
+ c := newImportConsole(importRecord("awaiting-upload", ""), importRecord("uploaded", ""),
+ importRecord("ready", ""), importRecord("swapped", ""))
+ out, err := importRun(t, c, "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ wantLines(t, out,
+ "uploading main.db (3B)",
+ importTokenLine,
+ "uploaded. The old process of main.shop.example.com is marked stopped, and the box swaps it on its next pass.",
+ "waiting for the box to swap main.shop.example.com",
+ "swapped. Until it is committed, main.shop.example.com answers only requests that carry the verify token.",
+ importCommitLine)
+ if got := strings.Join(c.called(), " "); got != "begin upload get get stopped get get" {
+ t.Fatalf("calls = %s", got)
+ }
+ for _, d := range *waits {
+ if d != 2*time.Second {
+ t.Fatalf("waits = %v", *waits)
+ }
+ }
+}
+
+func TestImportWithVerifyCommits(t *testing.T) {
+ fakeSeedWait(t, 2*time.Second)
+ c := newImportConsole(importRecord("uploaded", ""), importRecord("swapped", ""), importRecord("swapped", ""), importRecord("placed", ""))
+ out, err := importRun(t, c, "/up", http.StatusServiceUnavailable, http.StatusNoContent)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.Contains(out, "s3cret-token") {
+ t.Fatalf("a verified import never shows the token:\n%s", out)
+ }
+ lines := strings.Split(strings.TrimSuffix(out, "\n"), "\n")
+ wantLines(t, strings.Join(lines[len(lines)-3:], "\n"),
+ "verify passed (204). Committing.",
+ "waiting for the box to finish placing the snapshot",
+ "committed. main.shop.example.com is served by the platform now.")
+ if !c.saw("commit") || c.saw("abort") {
+ t.Fatalf("calls = %v", c.called())
+ }
+}
+
+func TestImportWithVerifyAbortsOnAFailure(t *testing.T) {
+ fakeSeedWait(t, 2*time.Second)
+ c := newImportConsole(importRecord("uploaded", ""), importRecord("swapped", ""))
+ out, err := importRun(t, c, "/up", http.StatusInternalServerError)
+ if !errors.Is(err, errImportFailed) {
+ t.Fatalf("err = %v", err)
+ }
+ lines := strings.Split(strings.TrimSuffix(out, "\n"), "\n")
+ wantLines(t, strings.Join(lines[len(lines)-2:], "\n"),
+ "verify failed (500). Aborting the import.",
+ "aborted. The box puts the old route of main.shop.example.com back on its next pass.")
+ if strings.Contains(out, "s3cret-token") || !c.saw("abort") || c.saw("commit") {
+ t.Fatalf("calls = %v, output:\n%s", c.called(), out)
+ }
+}
+
+func TestImportRefusedByTheBox(t *testing.T) {
+ fakeSeedWait(t, 2*time.Second)
+ c := newImportConsole(importRecord("uploaded", ""), importRecord("ready", ""), importRecord("refused", "the route belongs to app kass"))
+ out, err := importRun(t, c, "/up", http.StatusOK)
+ if !errors.Is(err, errImportFailed) {
+ t.Fatalf("err = %v", err)
+ }
+ if !strings.HasSuffix(out, "waiting for the box to swap "+hostOf(out)+"\nthe box refused the import: the route belongs to app kass\n") {
+ t.Fatalf("output:\n%s", out)
+ }
+ if strings.Contains(out, "s3cret-token") || c.saw("commit") || c.saw("abort") {
+ t.Fatalf("calls = %v, output:\n%s", c.called(), out)
+ }
+}
+
+// hostOf reads the host back out of the "waiting for the box to swap" line,
+// for the tests whose host is a TLS server's address.
+func hostOf(out string) string {
+ const p = "waiting for the box to swap "
+ i := strings.Index(out, p)
+ if i < 0 {
+ return ""
+ }
+ rest := out[i+len(p):]
+ return rest[:strings.Index(rest, "\n")]
+}
+
+// The swap wait is ten minutes of looks; at its end the token is shown
+// (with --verify) and SD-N79 names the way on (M17).
+func TestImportSwapDeadline(t *testing.T) {
+ waits := fakeSeedWait(t, time.Minute)
+ c := newImportConsole(importRecord("uploaded", ""), importRecord("ready", ""))
+ out, err := importRun(t, c, "/up", http.StatusOK)
+ if !errors.Is(err, errImportFailed) {
+ t.Fatalf("err = %v", err)
+ }
+ host := hostOf(out)
+ if !strings.HasSuffix(out, "waiting for the box to swap "+host+"\n"+importTokenLine+"\n"+
+ "the box has not swapped "+host+" after 10 minutes. Check carlos instances list, then commit or abort.\n") {
+ t.Fatalf("output:\n%s", out)
+ }
+ // One wait before the upload's look, then ten minutes of one-minute waits.
+ if len(*waits) != 11 {
+ t.Fatalf("waits = %v", *waits)
+ }
+}
+
+func TestImportPlaceDeadline(t *testing.T) {
+ fakeSeedWait(t, time.Minute)
+ c := newImportConsole(importRecord("uploaded", ""), importRecord("swapped", ""))
+ out, err := importRun(t, c, "/up", http.StatusOK)
+ if !errors.Is(err, errImportFailed) {
+ t.Fatalf("err = %v", err)
+ }
+ host := hostOf(out)
+ if !strings.HasSuffix(out, "waiting for the box to finish placing the snapshot\n"+importTokenLine+"\n"+
+ "commit with: carlos instances import commit --app shop --host "+host+". Abort with: carlos instances import abort --app shop --host "+host+".\n") {
+ t.Fatalf("output:\n%s", out)
+ }
+ if c.saw("commit") || c.saw("abort") {
+ t.Fatalf("calls = %v", c.called())
+ }
+}
+
+func TestImportAbortedPrintsItsReason(t *testing.T) {
+ const sdN52 = "the upload is not a database that passes an integrity check, so it was not kept."
+ for _, c := range []struct {
+ name, reason, want string
+ }{
+ {"with a reason", sdN52, sdN52},
+ {"without one", "", "aborting"},
+ } {
+ t.Run(c.name, func(t *testing.T) {
+ fakeSeedWait(t, 2*time.Second)
+ ic := newImportConsole(importRecord("awaiting-upload", ""), importRecord("aborted", c.reason))
+ out, err := importRun(t, ic, "/up", http.StatusOK)
+ if !errors.Is(err, errImportFailed) {
+ t.Fatalf("err = %v", err)
+ }
+ if !strings.HasSuffix(out, "\n"+c.want+"\n") || strings.Contains(out, "s3cret-token") || ic.saw("stopped") {
+ t.Fatalf("calls = %v, output:\n%s", ic.called(), out)
+ }
+ })
+ }
+}
+
+func TestImportRolledBackWhileWaitingForTheSwap(t *testing.T) {
+ fakeSeedWait(t, 2*time.Second)
+ c := newImportConsole(importRecord("uploaded", ""), importRecord("ready", ""), importRecord("rolled-back", ""))
+ out, err := importRun(t, c, "")
+ if !errors.Is(err, errImportFailed) || !strings.HasSuffix(out, "waiting for the box to swap main.shop.example.com\naborting\n") {
+ t.Fatalf("err = %v, output:\n%s", err, out)
+ }
+}
+
+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") {
+ t.Fatalf("err = %v, output:\n%s", err, out)
+ }
+}
+
+// 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) {
+ fakeSeedWait(t, 2*time.Second)
+ c := newImportConsole(importRecord("uploaded", ""), importRecord("ready", ""), importRecord("swapped", ""))
+ c.verbs["stopped"] = answerWith(http.StatusConflict, `{"error":"main.shop.example.com is ready. Mark it stopped once, after its upload."}`)
+ out, err := importRun(t, c, "")
+ if err != nil || !strings.HasSuffix(out, importCommitLine+"\n") {
+ t.Fatalf("err = %v, output:\n%s", err, out)
+ }
+}
+
+func TestImportStoppedRefusedForReal(t *testing.T) {
+ fakeSeedWait(t, 2*time.Second)
+ const refusal = "carlos run is busy on main.shop.example.com. Wait for it to finish, then abort."
+ c := newImportConsole(importRecord("uploaded", ""))
+ c.verbs["stopped"] = answerWith(http.StatusConflict, fmt.Sprintf(`{"error":%q}`, refusal))
+ out, err := importRun(t, c, "/up", http.StatusOK)
+ if err == nil || err.Error() != refusal {
+ t.Fatalf("err = %v", err)
+ }
+ // With --verify, an exit that leaves the import live shows the token (M17).
+ if !strings.HasSuffix(out, importTokenLine+"\n") {
+ t.Fatalf("output:\n%s", out)
+ }
+}
+
+func TestImportUploadRefusedShowsTheTokenWithVerify(t *testing.T) {
+ fakeSeedWait(t, 2*time.Second)
+ const sdN51 = "the upload does not match the length and sha256 this import began with, so it was not kept."
+ c := newImportConsole(importRecord("awaiting-upload", ""))
+ c.upload = answerWith(http.StatusUnprocessableEntity, fmt.Sprintf(`{"error":%q}`, sdN51))
+ out, err := importRun(t, c, "/up", http.StatusOK)
+ if err == nil || err.Error() != sdN51 || strings.Contains(err.Error(), "s3cret-token") {
+ t.Fatalf("err = %v", err)
+ }
+ if !strings.HasSuffix(out, importTokenLine+"\n") || c.saw("get") {
+ t.Fatalf("calls = %v, output:\n%s", c.called(), out)
+ }
+}
+
+func TestImportBeginRefusedShowsNothingMore(t *testing.T) {
+ fakeSeedWait(t, 2*time.Second)
+ c := newImportConsole()
+ c.beginStatus, c.beginBody = http.StatusConflict, `{"error":"instance main.shop.example.com already exists"}`
+ out, err := importRun(t, c, "")
+ if err == nil || err.Error() != "instance main.shop.example.com already exists" {
+ t.Fatalf("err = %v", err)
+ }
+ wantLines(t, out, "uploading main.db (3B)")
+ if got := c.called(); len(got) != 1 {
+ t.Fatalf("calls = %v", got)
+ }
+}
+
+// A commit that lost its answer is settled by the record (an overlapping
+// commit answers SD-N60; the record is committed).
+func TestImportCommitSettledByTheRecord(t *testing.T) {
+ fakeSeedWait(t, 2*time.Second)
+ c := newImportConsole(importRecord("uploaded", ""), importRecord("swapped", ""), importRecord("placed", ""), importRecord("committed", ""))
+ c.verbs["commit"] = answerWith(http.StatusConflict, `{"error":"main.shop.example.com changed while this request was made. Read its state with carlos instances list and try again."}`)
+ out, err := importRun(t, c, "/up", http.StatusOK)
+ if err != nil || !strings.HasSuffix(out, " is served by the platform now.\n") {
+ t.Fatalf("err = %v, output:\n%s", err, out)
+ }
+}
+
+// An abort that answers 404 after the import's own record has gone is done.
+func TestImportAbortOfAFinishedImport(t *testing.T) {
+ fakeSeedWait(t, 2*time.Second)
+ c := newImportConsole(importRecord("uploaded", ""), importRecord("swapped", ""), answerWith(http.StatusNotFound, `{"error":"x is not an instance of shop"}`))
+ c.verbs["abort"] = answerWith(http.StatusNotFound, `{"error":"x is not an instance of shop"}`)
+ out, err := importRun(t, c, "/up", http.StatusBadGateway, http.StatusForbidden)
+ if !errors.Is(err, errImportFailed) || !strings.HasSuffix(out, "Aborting the import.\naborted. The box puts the old route of "+hostOf(out)+" back on its next pass.\n") {
+ t.Fatalf("err = %v, output:\n%s", err, out)
+ }
+}
+
+func TestImportVerbAlone(t *testing.T) {
+ for _, c := range []struct {
+ name, verb string
+ answer func(http.ResponseWriter)
+ record func(http.ResponseWriter)
+ want string
+ wantErr string
+ }{
+ {"commit", "commit", nil, nil, "committed. main.shop.example.com is served by the platform now.", ""},
+ {"abort", "abort", nil, nil, "aborted. The box puts the old route of main.shop.example.com back on its next pass.", ""},
+ {"commit refused", "commit", answerWith(http.StatusConflict, `{"error":"main.shop.example.com is swapped. Commit it once it is placed."}`),
+ importRecord("swapped", ""), "", "main.shop.example.com is swapped. Commit it once it is placed."},
+ {"abort that lost to its own retry", "abort", answerWith(http.StatusConflict, `{"error":"changed"}`),
+ importRecord("aborted", ""), "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"},
+ } {
+ t.Run(c.name, func(t *testing.T) {
+ ic := newImportConsole(c.record)
+ if c.answer != nil {
+ ic.verbs[c.verb] = c.answer
+ }
+ ts := httptest.NewServer(ic)
+ defer ts.Close()
+ var out bytes.Buffer
+ err := newTestSession(ts.URL).runImportVerb(context.Background(), &out, apiAcct{Seg: "bab"}, "shop", "main.shop.example.com", c.verb)
+ if c.wantErr != "" {
+ if err == nil || err.Error() != c.wantErr || out.Len() != 0 {
+ t.Fatalf("err = %v, output %q", err, out.String())
+ }
+ return
+ }
+ if err != nil || out.String() != c.want+"\n" {
+ t.Fatalf("err = %v, output %q", err, out.String())
+ }
+ })
+ }
+}
+
+// The shared poller (M18): a bounded wait ends with DeadlineExceeded after
+// its share of looks; failed looks count as Plan 5's do.
+func TestPollSeedLimitAndFailures(t *testing.T) {
+ waits := fakeSeedWait(t, 2*time.Second)
+ c := newImportConsole(importRecord("ready", ""))
+ 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"))
+ 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"))
+ bad.mu.Lock()
+ looks := bad.looks
+ bad.mu.Unlock()
+ if err == nil || err.Error() != "could not read the instance" || looks != seedPollFailures {
+ t.Fatalf("err = %v, looks = %d", err, looks)
+ }
+}
diff --git a/instances_seed.go b/instances_seed.go
index 79391f5da..08fe02c1a 100644
--- a/instances_seed.go
+++ b/instances_seed.go
@@ -56,8 +56,8 @@ func seedCell(r apiInstanceRecord) string {
return fmt.Sprintf("imported %s", sha)
}
-// Seams for the tests: how often the create follows the record, and how it
-// waits between looks.
+// Seams for the tests: how often a seeded create or an import follows the
+// record, and how it waits between looks.
var (
seedPollEvery = 2 * time.Second
seedWait = func(ctx context.Context, d time.Duration) error {
@@ -72,6 +72,11 @@ var (
}
)
+// errSeedWaitOver is pollSeed's limit running out. It wraps
+// context.DeadlineExceeded, and only pollSeed returns this value, so a
+// request's own timeout is never mistaken for it.
+var errSeedWaitOver = fmt.Errorf("%w", context.DeadlineExceeded)
+
// seedPollFailures is how many looks in a row may fail before the create
// gives up and returns the last error.
const seedPollFailures = 5
@@ -117,23 +122,50 @@ func seedFailure(s *apiSeed, source string) error {
return fmt.Errorf("could not copy %s. Nothing was created. Try again.", source)
}
-// followSeed polls the instance record every seedPollEvery until its seed
-// leaves reserving, and returns the record it last read. An aborted seed
-// returns its reason as the error. A failed look is tried again on the next
-// tick, up to seedPollFailures in a row; a record that has gone returns at
-// once. There is no overall timeout: the console's deadlines and its sweep
+// followSeed polls the instance record until its seed leaves reserving, and
+// returns the record it last read. An aborted seed returns its reason as the
+// error. There is no overall timeout: the console's deadlines and its sweep
// bring every seed to an end.
func (s *apiSession) followSeed(ctx context.Context, a apiAcct, app string, first apiInstanceRecord) (apiInstanceRecord, error) {
source, id := strings.TrimSpace(first.Host), ""
if first.Seed != nil {
source, id = first.Seed.From, first.Seed.ID
}
+ return s.pollSeed(ctx, a, app, first.Host, 0, func(rec apiInstanceRecord) (bool, error) {
+ if rec.Seed == nil || rec.Seed.ID != id {
+ // The record no longer carries this seed.
+ return false, seedFailure(nil, source)
+ }
+ switch rec.Seed.State {
+ case "reserving":
+ return false, nil
+ case "aborted", "rolled-back", "refused":
+ return false, seedFailure(rec.Seed, source)
+ }
+ return true, nil
+ })
+}
+
+// pollSeed is the one poller both seeded paths use (--from's create and the
+// import): it reads host's record every seedPollEvery and hands it to look,
+// until look says done or returns an error, which pollSeed returns as it
+// is. A failed read is tried again on the next tick, up to seedPollFailures
+// in a row; a record that has gone returns at once. limit bounds the wait
+// (0 means none) and its end returns errSeedWaitOver. It counts both the
+// waits taken and the clock, so the seam alone can run it out.
+func (s *apiSession) pollSeed(ctx context.Context, a apiAcct, app, host string, limit time.Duration, look func(apiInstanceRecord) (bool, error)) (apiInstanceRecord, error) {
+ start := time.Now()
+ var waited time.Duration
failures := 0
for {
+ if limit > 0 && (waited >= limit || time.Since(start) >= limit) {
+ return apiInstanceRecord{}, errSeedWaitOver
+ }
if err := seedWait(ctx, seedPollEvery); err != nil {
return apiInstanceRecord{}, err
}
- rec, found, err := s.instanceGet(ctx, a, app, first.Host)
+ waited += seedPollEvery
+ rec, found, err := s.instanceGet(ctx, a, app, host)
switch {
case err == nil && !found:
return apiInstanceRecord{}, notFoundErr()
@@ -144,17 +176,13 @@ func (s *apiSession) followSeed(ctx context.Context, a apiAcct, app string, firs
continue
}
failures = 0
- if rec.Seed == nil || rec.Seed.ID != id {
- // The record no longer carries this seed.
- return apiInstanceRecord{}, seedFailure(nil, source)
+ done, err := look(rec)
+ if err != nil {
+ return apiInstanceRecord{}, err
}
- switch rec.Seed.State {
- case "reserving":
- continue
- case "aborted", "rolled-back", "refused":
- return apiInstanceRecord{}, seedFailure(rec.Seed, source)
+ if done {
+ return rec, nil
}
- return rec, nil
}
}