carlos / platform Public

console: the import door's begin, with a once-shown verify token (Plan 6 Task 1)

The create door with "import" in its body hands off to createImport
(apiinstanceimport.go). Begin checks placement, the data bucket, the size,
hash and replaces fields, this console's region and a one-box ready
region, and the store rules before it writes anything; then mints the seed
id and the verify token, writes the record awaiting-upload holding only
the token's hash, provisions an empty store marked with the seed, and
answers 201 with the token, once.

A failure after the record (the store walk, the walk's errSeedStoreTaken
mapped to SD-N34, a sibling app taking the apex) rolls the import back
pre-swap and runs finishSeed before the refusal is answered, so the host
is free for a retry (preflight M2, M3). SD-N36 and SD-N38 are shared
helpers both seed doors call (m1, m2). Wires SD-N48 to SD-N50, SD-N62 and
SD-N67.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 97b8cb2439648fa6c0608ecc2b6d812fdc1144a7 parent b001b05
5 files changed, +738 −7
  • approved_seeded_copy_test.go +4 −2
  • internal/console/apiinstanceimport.go +241 −0
  • internal/console/apiinstanceimport_test.go +465 −0
  • internal/console/apiinstances.go +14 −3
  • internal/console/apiinstanceseed.go +14 −2
diff --git a/approved_seeded_copy_test.go b/approved_seeded_copy_test.go
index 997f120c1..05b0ab82a 100644
--- a/approved_seeded_copy_test.go
+++ b/approved_seeded_copy_test.go
@@ -34,10 +34,12 @@ var seededCopyIDs = func() []string {
// seededWiredIDs grows as the plans wire rows. Plan 1 wires none; Plan 3
// Task 3 wires SD-N1, Task 5 SD-N2 to SD-N8 and SD-N113, Task 6 SD-N9 to SD-N13,
// 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.
+// 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.
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-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"}
func mustParseSeededRecord(t *testing.T) []copyRow {
t.Helper()
diff --git a/internal/console/apiinstanceimport.go b/internal/console/apiinstanceimport.go
new file mode 100644
index 000000000..41f8f3d1d
--- /dev/null
+++ b/internal/console/apiinstanceimport.go
@@ -0,0 +1,241 @@
+// The import door (seeded-instances spec §5): `carlos instances import`
+// moves an instance the platform does not host yet onto it, from an
+// uploaded snapshot. Begin is the create door with "import" in its body:
+// handleAPIInstanceCreate runs every ordinary check, then hands over here.
+// Begin writes the record awaiting-upload, provisions an empty store for a
+// private-storage app, and answers 201 with the verify token, once.
+
+package console
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log"
+ "net"
+ "net/http"
+ "os"
+ "regexp"
+ "strconv"
+ "time"
+
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/seeds"
+)
+
+// seedImportReq is the create body's "import" (spec §5.1): the snapshot's
+// length and hash, which the upload must match, and the address of the
+// addr-backed row the box swaps out.
+type seedImportReq struct {
+ SHA256 string `json:"sha256"`
+ Bytes int64 `json:"bytes"`
+ Replaces string `json:"replaces"`
+}
+
+// importFailed is the import door's retry sentence (SD-N67).
+const importFailed = "could not store the upload. Try again."
+
+// 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"
+
+var (
+ sha256Hex = regexp.MustCompile(`^[0-9a-f]{64}$`)
+ portShape = regexp.MustCompile(`^[0-9]{1,5}$`)
+)
+
+// seedOneDoorRefusal is the create door's rule that a body takes at most
+// one seed door: "from" and "import" together are refused (SD-N36).
+func seedOneDoorRefusal(from, imp bool) *doorRefusal {
+ if from && imp {
+ return seedOrdinaryRefusal()
+ }
+ return nil
+}
+
+// importShapeRefusal checks begin's own fields: the size against the
+// limit, the hash's spelling, and replaces as host:port with a port in
+// 1 to 65535.
+func importShapeRefusal(imp seedImportReq) *doorRefusal {
+ switch {
+ case imp.Bytes <= 0:
+ return &doorRefusal{http.StatusBadRequest, importSizeRule}
+ case imp.Bytes > seedMaxBytes:
+ return &doorRefusal{http.StatusRequestEntityTooLarge, importSizeRule}
+ case !sha256Hex.MatchString(imp.SHA256):
+ return &doorRefusal{http.StatusBadRequest, "sha256 must be 64 lowercase hex characters"}
+ case !validReplaces(imp.Replaces):
+ return &doorRefusal{http.StatusBadRequest, "replaces must name the address the import takes over, as host:port"}
+ }
+ return nil
+}
+
+func validReplaces(addr string) bool {
+ host, port, err := net.SplitHostPort(addr)
+ if err != nil || host == "" || !portShape.MatchString(port) {
+ return false
+ }
+ n, err := strconv.Atoi(port)
+ return err == nil && n > 0 && n < 65536
+}
+
+// createImport is the import door's begin (spec §5.1). Its answer is held
+// until the door is done, so that a begin which fails after writing its
+// record has rolled that record back and finished it (freeing the host)
+// before the refusal reaches the caller (preflight M2).
+func (s *Server) createImport(w http.ResponseWriter, r *http.Request, c seedCreate, imp seedImportReq) {
+ held := &heldResponse{header: w.Header()}
+ s.beginImport(held, r, c, imp)
+ held.flush(w, importFailed)
+}
+
+// beginImport runs begin's checks after the create door's own, then mints
+// the seed id and the verify token, writes the record awaiting-upload with
+// only the token's hash, provisions the seed's store, and answers 201 with
+// the token. The token is in that one body and nowhere else: never logged,
+// audited or stored.
+func (s *Server) beginImport(w http.ResponseWriter, r *http.Request, c seedCreate, imp seedImportReq) {
+ ctx := r.Context()
+ acct, app, host := c.acct, c.app, c.rec.Host
+ if ref := seedPlacementRefusal(c); ref != nil {
+ jsonError(w, ref.Status, ref.Msg)
+ return
+ }
+ if s.cfg.SeedData == nil || s.cfg.HibernateBucket == "" {
+ ref := seedNoDataRefusal()
+ jsonError(w, ref.Status, ref.Msg)
+ return
+ }
+ if ref := importShapeRefusal(imp); ref != nil {
+ jsonError(w, ref.Status, ref.Msg)
+ return
+ }
+ // 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))
+ return
+ }
+ checks, cancel := context.WithTimeout(ctx, instanceStoreTimeout)
+ defer cancel()
+ if ref := s.seedRegionReady(checks, c.rec.Region, true); ref != nil {
+ jsonError(w, ref.Status, ref.Msg)
+ return
+ }
+ id, err := seeds.NewID()
+ if err != nil {
+ log.Printf("console: import %s: seed id: %v", host, err)
+ jsonError(w, http.StatusBadGateway, importFailed)
+ return
+ }
+ token, hash, err := seeds.NewVerifyToken()
+ if err != nil {
+ log.Printf("console: import %s: verify token: %v", host, err)
+ jsonError(w, http.StatusBadGateway, importFailed)
+ return
+ }
+ if c.m.PrivateStorage {
+ // The store gates that write nothing, before the record (ruling
+ // R3): their refusals leave nothing holding the host.
+ if _, ok := s.instanceStorePreflight(ctx, w, acct, app); !ok {
+ return
+ }
+ storeCtx, storeCancel := context.WithTimeout(ctx, instanceStoreTimeout)
+ ref := s.seedStoreRefusal(storeCtx, c, id, importFailed)
+ storeCancel()
+ if ref != nil {
+ jsonError(w, ref.Status, ref.Msg)
+ return
+ }
+ }
+
+ rec := c.rec
+ rec.Seed = &provision.SeedSource{ID: id, Kind: seeds.KindImport, SHA256: imp.SHA256, Bytes: imp.Bytes,
+ State: seeds.StateAwaitingUpload, By: c.principal, At: time.Now().UTC(), Replaces: imp.Replaces, VerifyHash: hash}
+ // Not the request's cancellation: a client leaving now must not turn a
+ // write that landed into an error.
+ writeCtx, writeCancel := context.WithTimeout(context.WithoutCancel(ctx), instanceStoreTimeout)
+ err = provision.CreateRecord(writeCtx, s.cfg.Releases, rec)
+ writeCancel()
+ if err != nil {
+ if errors.Is(err, release.ErrCASConflict) {
+ jsonError(w, http.StatusConflict, "instance "+host+" already exists")
+ return
+ }
+ // The write may have landed all the same: undo it by its seed id,
+ // which touches nothing if it did not.
+ log.Printf("console: import %s: write the record: %v", host, err)
+ s.unbeginImport(ctx, c, id, "a failed record write")
+ jsonError(w, http.StatusBadGateway, importFailed)
+ return
+ }
+
+ // From here every failure rolls the import back and finishes it
+ // before the answer goes out.
+ privateBucket := ""
+ if c.m.PrivateStorage {
+ // The request's own context, as the ordinary create's walk: the
+ // walk takes storeGrantTimeout, and a client that leaves cancels a
+ // begin whose token it can no longer receive. A refusal is held on
+ // w already, except errSeedStoreTaken, which is spoken here.
+ storeRec, ok, err := s.instanceStoreProvisionFor(ctx, w, acct, app, rec.Environment, c.principal, id)
+ if !ok {
+ s.unbeginImport(ctx, c, id, "store provisioning")
+ if errors.Is(err, errSeedStoreTaken) {
+ ref := seedStoreTakenRefusal(rec.Environment, storeBucketName(storeRec))
+ jsonError(w, ref.Status, ref.Msg)
+ }
+ return
+ }
+ privateBucket = storeBucketName(storeRec)
+ }
+ // The create door's re-check after its write (apiinstances.go): an app
+ // created under the host's label since the door asked owns its apex.
+ 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")
+ return
+ }
+ }
+
+ s.auditActivity(acct, app, c.principal, Activity{
+ Action: "instance-seed", TargetType: "instance", Target: host, Message: c.message,
+ Detail: fmt.Sprintf("began an import of %s over %s (%s, %d bytes)", host, imp.Replaces, imp.SHA256[:12], imp.Bytes),
+ })
+ viewCtx, viewCancel := context.WithTimeout(context.WithoutCancel(ctx), instanceStoreTimeout)
+ defer viewCancel()
+ out := struct {
+ apiRecordView
+ VerifyToken string `json:"verify_token"`
+ }{recordView(rec), token}
+ out.PrivateStoreBucket = privateBucket
+ out.RegionFrom = c.regionFrom
+ out.RegionName = s.placeOf(rec.Region, s.candsOrHome(viewCtx)) // never fleet-placed: refused above
+ writeJSON(w, http.StatusCreated, out)
+}
+
+// unbeginImport undoes a begin that failed after its record was written:
+// the pre-swap rollback, then the console's finish, which tears down the
+// store this seed made and deletes the record (spec §5.6's pre-swap path,
+// preflight M2). Neither is cut by the client leaving. A failure is
+// logged, and the record is left rolled-back for the sweep to finish.
+func (s *Server) unbeginImport(ctx context.Context, c seedCreate, id, why string) {
+ sqid, app, host := c.acct.Sqid, c.app, c.rec.Host
+ bg := context.WithoutCancel(ctx)
+ rbCtx, rbCancel := context.WithTimeout(bg, instanceStoreTimeout)
+ _, err := provision.SetRecordSeed(rbCtx, s.cfg.Releases, sqid, app, host, provision.SeedRolledBackBeforeSwap(id))
+ rbCancel()
+ if errors.Is(err, os.ErrNotExist) {
+ return // the record was never written
+ }
+ if err != nil {
+ log.Printf("console: import %s: roll back seed %s after %s: %v", host, id, why, err)
+ }
+ finCtx, finCancel := context.WithTimeout(bg, storeTeardownTimeout+instanceStoreTimeout)
+ defer finCancel()
+ if err := s.finishSeed(finCtx, sqid, app, host, id); err != nil {
+ log.Printf("console: import %s: finish seed %s after %s: %v", host, id, why, err)
+ }
+}
diff --git a/internal/console/apiinstanceimport_test.go b/internal/console/apiinstanceimport_test.go
new file mode 100644
index 000000000..52530de47
--- /dev/null
+++ b/internal/console/apiinstanceimport_test.go
@@ -0,0 +1,465 @@
+package console
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "log"
+ "net/http"
+ "os"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/objstore"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/seeds"
+)
+
+const (
+ importHost = "old.shop.example.com"
+ importAddr = "127.0.0.1:8401"
+)
+
+var importSHA = strings.Repeat("a", 64)
+
+// newImportFixture is Plan 5's --from fixture: the app shop, a ready
+// one-box home region, a seeds floor, a scratch pool and a data bucket.
+func newImportFixture(t *testing.T) fromFixture {
+ t.Helper()
+ return newFromFixture(t, 0)
+}
+
+func importBody(host, sha string, n int64, replaces string) string {
+ body, _ := json.Marshal(map[string]any{"host": host, "import": map[string]any{"sha256": sha, "bytes": n, "replaces": replaces}})
+ return string(body)
+}
+
+func beginImport(t *testing.T, f fromFixture, host, sha string, n int64) (int, map[string]any) {
+ t.Helper()
+ return f.create(t, importBody(host, sha, n, importAddr))
+}
+
+// requireBegun: a 201 holding a 43-character token, and an awaiting-upload
+// import record that keeps only the token's hash.
+func (f fromFixture) requireBegun(t *testing.T, code int, out map[string]any, host string) (*provision.Record, string) {
+ t.Helper()
+ if code != http.StatusCreated {
+ t.Fatalf("begin = %d %v", code, out)
+ }
+ token := str(t, out, "verify_token")
+ if len(token) != 43 {
+ t.Fatalf("verify token %q", token)
+ }
+ rec, err := f.record(t, host)
+ if err != nil || rec.Seed == nil || rec.Seed.Kind != seeds.KindImport || rec.Seed.State != seeds.StateAwaitingUpload ||
+ !seeds.ValidID(rec.Seed.ID) || rec.Seed.VerifyHash != seeds.HashToken(token) || !seeds.TokenMatches(rec.Seed.VerifyHash, token) {
+ t.Fatalf("record = %+v, %v", rec, err)
+ }
+ return rec, token
+}
+
+func TestImportBegin(t *testing.T) {
+ f := newImportFixture(t)
+ var logs bytes.Buffer
+ log.SetOutput(&logs)
+ defer log.SetOutput(os.Stderr)
+ code, out := f.create(t, `{"host":"old.shop.example.com","message":"moving the shop","import":{"sha256":"`+importSHA+`","bytes":4096,"replaces":"127.0.0.1:8401"}}`)
+ rec, token := f.requireBegun(t, code, out, importHost)
+ if rec.Seed.SHA256 != importSHA || rec.Seed.Bytes != 4096 || rec.Seed.Replaces != importAddr || rec.Seed.By != "alice@keymail.dev" ||
+ rec.Seed.From != "" || rec.Seed.TXID != "" || rec.Seed.Reason != "" || rec.Seed.At.IsZero() {
+ t.Fatalf("record seed = %+v", rec.Seed)
+ }
+ if rec.Environment != "" || rec.Slug != "old" || rec.Channel != "stable" || rec.Region != "" {
+ t.Fatalf("record = %+v; want the ordinary create's fields", rec)
+ }
+ seed, _ := out["seed"].(map[string]any)
+ if out["host"] != importHost || seed == nil || seed["id"] != rec.Seed.ID || seed["state"] != seeds.StateAwaitingUpload {
+ t.Fatalf("201 body = %v", out)
+ }
+ if _, ok := out["private_store_bucket"]; ok {
+ t.Fatalf("201 body names a store on an app without private storage: %v", out)
+ }
+
+ // The token is answered once, here, and kept nowhere: not in the
+ // bucket, not in the log, not in the audit row.
+ raw, _, err := f.fx.rel.Get(context.Background(), provision.RecordKey("bab", fromApp, importHost))
+ if err != nil || strings.Contains(string(raw), token) {
+ t.Fatalf("the token itself reached the bucket (%v)", err)
+ }
+ if strings.Contains(logs.String(), token) {
+ t.Fatal("the token reached the log")
+ }
+ events, err := f.fx.st.Events(OpsAccountID, fromApp, 50)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var rows []string
+ for _, e := range events {
+ if strings.Contains(e.Detail+e.Message, token) {
+ t.Errorf("the token reached an audit row: %+v", e)
+ }
+ switch e.Action {
+ case "instance-seed":
+ rows = append(rows, e.Detail+" | "+e.Message+" | "+e.Address)
+ case "instance-create":
+ t.Errorf("an import also wrote an instance-create row: %+v", e)
+ }
+ }
+ want := "began an import of old.shop.example.com over 127.0.0.1:8401 (aaaaaaaaaaaa, 4096 bytes) | moving the shop | alice@keymail.dev"
+ if len(rows) != 1 || rows[0] != want {
+ t.Fatalf("instance-seed rows = %q, want [%q]", rows, want)
+ }
+
+ // A second begin for the same host: the record holds it.
+ code, out = beginImport(t, f, importHost, importSHA, 4096)
+ if code != http.StatusConflict || str(t, out, "error") != "instance old.shop.example.com already exists" {
+ t.Fatalf("second begin = %d %v, want 409", code, out)
+ }
+ if again, err := f.record(t, importHost); err != nil || again.Seed.ID != rec.Seed.ID || again.Seed.VerifyHash != rec.Seed.VerifyHash {
+ t.Fatalf("the second begin changed the record: %+v, %v", again, err)
+ }
+}
+
+// Each begin mints its own seed id and token.
+func TestImportBeginMintsFreshTokens(t *testing.T) {
+ f := newImportFixture(t)
+ code, out := beginImport(t, f, importHost, importSHA, 10)
+ a, ta := f.requireBegun(t, code, out, importHost)
+ code, out = beginImport(t, f, "new.shop.example.com", importSHA, 10)
+ b, tb := f.requireBegun(t, code, out, "new.shop.example.com")
+ if a.Seed.ID == b.Seed.ID || ta == tb || a.Seed.VerifyHash == b.Seed.VerifyHash {
+ t.Fatalf("two begins share a seed or a token: %+v %+v", a.Seed, b.Seed)
+ }
+}
+
+func TestImportBeginRefusals(t *testing.T) {
+ f := newImportFixture(t)
+ const ordinary = "a seeded instance is an ordinary instance. Drop --kind, --addr and --role."
+ 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"
+ for _, c := range []struct {
+ body string
+ code int
+ want string
+ }{
+ {importBody("a.shop.example.com", importSHA, 0, "127.0.0.1:1"), http.StatusBadRequest, size},
+ {importBody("a.shop.example.com", importSHA, -1, "127.0.0.1:1"), http.StatusBadRequest, size},
+ {importBody("a.shop.example.com", importSHA, 4294967297, "127.0.0.1:1"), http.StatusRequestEntityTooLarge, size},
+ {importBody("a.shop.example.com", "ABC", 10, "127.0.0.1:1"), http.StatusBadRequest, hash},
+ {importBody("a.shop.example.com", strings.Repeat("A", 64), 10, "127.0.0.1:1"), http.StatusBadRequest, hash},
+ {importBody("a.shop.example.com", strings.Repeat("a", 63), 10, "127.0.0.1:1"), http.StatusBadRequest, hash},
+ {importBody("a.shop.example.com", strings.Repeat("g", 64), 10, "127.0.0.1:1"), http.StatusBadRequest, hash},
+ {importBody("a.shop.example.com", importSHA, 10, "nope"), http.StatusBadRequest, replaces},
+ {importBody("a.shop.example.com", importSHA, 10, ":8401"), http.StatusBadRequest, replaces},
+ {importBody("a.shop.example.com", importSHA, 10, "127.0.0.1:http"), http.StatusBadRequest, replaces},
+ {importBody("a.shop.example.com", importSHA, 10, "127.0.0.1:0"), http.StatusBadRequest, replaces},
+ {importBody("a.shop.example.com", importSHA, 10, "127.0.0.1:65536"), http.StatusBadRequest, replaces},
+ {importBody("a.shop.example.com", importSHA, 10, ""), http.StatusBadRequest, replaces},
+ {`{"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},
+ } {
+ 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)
+ }
+ f.requireNoRecord(t, "a.shop.example.com")
+ }
+ // An addr with a host name and the largest size are taken.
+ code, out := f.create(t, importBody(importHost, importSHA, 4294967296, "legacy.internal:8401"))
+ f.requireBegun(t, code, out, importHost)
+}
+
+func TestImportBeginNeedsOneBox(t *testing.T) {
+ f := newImportFixture(t)
+ seedReadyBox(t, f.fx, "kass-2", "", 1, "v0.27.0", "v0.27.0", "nested", false)
+ code, out := beginImport(t, f, importHost, importSHA, 10)
+ if code != http.StatusConflict || str(t, out, "error") != "the home region has more than one box. An import needs a region with one box for now." {
+ t.Fatalf("= %d %v", code, out)
+ }
+ f.requireNoRecord(t, importHost)
+}
+
+func TestImportBeginRefusedBeforeTheFloor(t *testing.T) {
+ f := newImportFixture(t)
+ seedReadyBox(t, f.fx, "kass-1", "", 0, "v0.27.0", "v0.27.0", "nested", false)
+ code, out := beginImport(t, f, importHost, importSHA, 10)
+ if code != http.StatusConflict || !strings.Contains(str(t, out, "error"), "below the seeds floor") {
+ t.Fatalf("= %d %v", code, out)
+ }
+ f.requireNoRecord(t, importHost)
+}
+
+func TestImportBeginRefusedWithClaimsArmed(t *testing.T) {
+ f := newImportFixture(t)
+ f.fx.srv.cfg.HostClaim = true
+ code, out := beginImport(t, f, importHost, importSHA, 10)
+ if code != http.StatusConflict || str(t, out, "error") != "This console runs with claim control on. Seeded instances are not supported here yet." {
+ t.Fatalf("= %d %v", code, out)
+ }
+ f.requireNoRecord(t, importHost)
+}
+
+// The import is homed in this console's own region: its snapshot goes to
+// this region's data bucket.
+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." {
+ t.Fatalf("= %d %v", code, out)
+ }
+ f.requireNoRecord(t, importHost)
+}
+
+func TestImportBeginNeedsADataBucket(t *testing.T) {
+ f := newImportFixture(t)
+ f.fx.srv.cfg.SeedData = nil
+ code, out := beginImport(t, f, importHost, importSHA, 10)
+ if code != http.StatusServiceUnavailable || str(t, out, "error") != "this console has no data bucket set up, so it cannot create a seeded instance." {
+ t.Fatalf("= %d %v", code, out)
+ }
+ f.requireNoRecord(t, importHost)
+}
+
+func TestImportBeginRefusesAPooledApp(t *testing.T) {
+ f := newImportFixture(t)
+ m := fromManifest(false)
+ m.Pool = "shared"
+ enableInstances(t, f.fx, fromApp, m)
+ code, out := beginImport(t, f, importHost, importSHA, 10)
+ if code != http.StatusConflict || str(t, out, "error") != "shop places its instances in a pool or on a fleet, so it cannot take a seeded instance." {
+ t.Fatalf("= %d %v", code, out)
+ }
+ f.requireNoRecord(t, importHost)
+}
+
+// A failed record write answers SD-N67 and leaves the host free.
+func TestImportBeginRecordWriteFails(t *testing.T) {
+ f := newImportFixture(t)
+ fail := &failOnceOn{Store: f.fx.srv.cfg.Releases, key: provision.RecordKey("bab", fromApp, importHost)}
+ fail.armed.Store(true)
+ f.fx.srv.cfg.Releases = fail
+ code, out := beginImport(t, f, importHost, importSHA, 10)
+ if code != http.StatusBadGateway || str(t, out, "error") != "could not store the upload. Try again." {
+ t.Fatalf("= %d %v", code, out)
+ }
+ f.requireNoRecord(t, importHost)
+ code, out = beginImport(t, f, importHost, importSHA, 10)
+ f.requireBegun(t, code, out, importHost)
+}
+
+// With private storage the import gets the derived environment and a new
+// store marked with its seed, named on the 201 as the ordinary create does.
+func TestImportBeginPrivateStorage(t *testing.T) {
+ f := newImportFixture(t)
+ enableInstances(t, f.fx, fromApp, fromManifest(true))
+ code, out := f.create(t, `{"host":"old.shop.example.com","environment":"main","import":{"sha256":"`+importSHA+`","bytes":10,"replaces":"127.0.0.1:8401"}}`)
+ if code != http.StatusConflict || str(t, out, "error") != "a seeded instance gets its own environment and an empty store" {
+ t.Fatalf("explicit environment = %d %v", code, out)
+ }
+ f.requireNoRecord(t, importHost)
+
+ code, out = beginImport(t, f, importHost, importSHA, 10)
+ rec, _ := f.requireBegun(t, code, out, importHost)
+ if rec.Environment != "old" {
+ t.Fatalf("record environment = %q, want the host's own", rec.Environment)
+ }
+ store, _, err := objstore.GetIn(context.Background(), f.fx.rel, "bab", fromApp, "old")
+ if err != nil || store == nil || store.Declared == nil || store.Declared.Seed != rec.Seed.ID || storeBucketName(store) == "" {
+ t.Fatalf("old store = %+v, %v; want one marked with seed %s", store, err, rec.Seed.ID)
+ }
+ if str(t, out, "private_store_bucket") != storeBucketName(store) {
+ t.Fatalf("201 private_store_bucket = %v, want %s", out["private_store_bucket"], storeBucketName(store))
+ }
+}
+
+// An existing store for the derived environment is refused before the
+// record is written.
+func TestImportBeginRefusesATakenStore(t *testing.T) {
+ f := newImportFixture(t)
+ enableInstances(t, f.fx, fromApp, fromManifest(true))
+ foreign := grantedStore("carlos-bab-shop-old")
+ if _, err := objstore.PutIn(context.Background(), f.fx.rel, "bab", fromApp, "old", foreign, ""); err != nil {
+ t.Fatal(err)
+ }
+ code, out := beginImport(t, f, importHost, importSHA, 10)
+ if code != http.StatusConflict || str(t, out, "error") != "environment old already has a store (carlos-bab-shop-old). A seeded instance needs a new, empty one." {
+ t.Fatalf("= %d %v", code, out)
+ }
+ f.requireNoRecord(t, importHost)
+}
+
+// The store gates that write nothing refuse before the record is written
+// (ruling R3, preflight M2).
+func TestImportBeginStoreGatesRefuseBeforeTheRecord(t *testing.T) {
+ f := newImportFixture(t)
+ enableInstances(t, f.fx, fromApp, fromManifest(true))
+ f.fx.srv.cfg.SecretsPub = ""
+ code, out := beginImport(t, f, importHost, importSHA, 10)
+ if code != http.StatusServiceUnavailable || str(t, out, "error") != "this console has no secrets key (CARLOS_SECRETS_PUBKEY) — it cannot deliver a store credential, so it will not mint one" {
+ t.Fatalf("= %d %v", code, out)
+ }
+ f.requireNoRecord(t, importHost)
+}
+
+func grantedStore(bucket string) *objstore.Record {
+ at := time.Now().UTC().Add(-time.Hour)
+ return &objstore.Record{
+ Declared: &objstore.Declared{EnvPrefix: "S3", At: at, By: "bob@keymail.dev"},
+ Grant: &objstore.GrantBlock{Backend: "mem", Bucket: bucket, Source: objstore.GrantSourceAccountApproval, At: at, By: "operator@keymail.dev"},
+ }
+}
+
+// A store walk refused after the record was written rolls the import back
+// and finishes it before answering, so the same begin can run again at once.
+func TestImportBeginStoreWalkRefusalFreesTheHost(t *testing.T) {
+ f := newImportFixture(t)
+ enableInstances(t, f.fx, fromApp, fromManifest(true))
+ // Another environment's store already uses the name the walk derives.
+ if _, err := objstore.PutIn(context.Background(), f.fx.rel, "bab", fromApp, "staging", grantedStore("carlos-bab-shop-old"), ""); err != nil {
+ t.Fatal(err)
+ }
+ code, out := beginImport(t, f, importHost, importSHA, 10)
+ if code != http.StatusConflict || str(t, out, "error") != "another store already uses this instance's storage names (app and environment names are joined with \"-\"), so the instance was not created. use a different host name" {
+ t.Fatalf("= %d %v", code, out)
+ }
+ f.requireNoRecord(t, importHost)
+ if err := f.fx.rel.Delete(context.Background(), objstore.KeyIn("bab", fromApp, "staging")); err != nil {
+ t.Fatal(err)
+ }
+ code, out = beginImport(t, f, importHost, importSHA, 10)
+ f.requireBegun(t, code, out, importHost)
+}
+
+// storeOnRecordWrite writes a foreign, granted store for env right after
+// the record's create lands: the store another writer made between the
+// door's check and its walk.
+type storeOnRecordWrite struct {
+ release.Store
+ key, env string
+ once sync.Once
+}
+
+func (s *storeOnRecordWrite) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
+ tag, err := s.Store.Put(ctx, key, body, ifMatch)
+ if err == nil && key == s.key && ifMatch == "" {
+ s.once.Do(func() {
+ if _, perr := objstore.PutIn(ctx, s.Store, "bab", fromApp, s.env, grantedStore("carlos-bab-shop-"+s.env), ""); perr != nil {
+ panic(perr)
+ }
+ })
+ }
+ return tag, err
+}
+
+func (s *storeOnRecordWrite) DeleteIfMatch(ctx context.Context, key, etag string) error {
+ return release.DeleteIfMatch(ctx, s.Store, key, etag)
+}
+
+// The walk's own re-check under the store lock (errSeedStoreTaken) answers
+// SD-N34, rolls the import back and frees the host; the other writer's
+// store is left as it is.
+func TestImportBeginStoreTakenUnderTheLock(t *testing.T) {
+ f := newImportFixture(t)
+ enableInstances(t, f.fx, fromApp, fromManifest(true))
+ f.fx.srv.cfg.Releases = &storeOnRecordWrite{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") != "environment old already has a store (carlos-bab-shop-old). A seeded instance needs a new, empty one." {
+ t.Fatalf("= %d %v", code, out)
+ }
+ f.requireNoRecord(t, importHost)
+ store, _, err := objstore.GetIn(context.Background(), f.fx.rel, "bab", fromApp, "old")
+ if err != nil || store == nil || store.Grant == nil || store.Grant.Bucket != "carlos-bab-shop-old" {
+ t.Fatalf("the other writer's store = %+v, %v; want it kept", store, err)
+ }
+}
+
+// apexOnBucket creates the app label as the store walk makes its bucket:
+// a sibling app taking the host's platform apex mid-begin. It records the
+// seed the store was declared for, as the walk sees it.
+type apexOnBucket struct {
+ *objstore.MemProvisioner
+ f fromFixture
+ label string
+ declared string
+}
+
+func (p *apexOnBucket) EnsureBucket(ctx context.Context, name string) (string, error) {
+ if store, _, err := objstore.GetIn(ctx, p.f.fx.rel, "bab", fromApp, p.label); err == nil && store != nil && store.Declared != nil {
+ p.declared = store.Declared.Seed
+ }
+ if _, err := p.f.fx.st.CreateAppIn(OpsAccountID, p.label, "paul@keymail.dev", time.Now()); err != nil {
+ return "", err
+ }
+ return p.MemProvisioner.EnsureBucket(ctx, name)
+}
+
+// Preflight m11 and M3: a begin on a private-storage app whose host is
+// claimed as a sibling app's apex after the record was written. The store
+// was made for this seed; the begin rolls the import back, the finish
+// tears that store down and deletes the record, and the create door's own
+// sentence is answered.
+func TestImportBeginSiblingApexTearsDownTheStore(t *testing.T) {
+ f := newImportFixture(t)
+ f.fx.srv.cfg.AppsDomain = "apps.example.net"
+ m := fromManifest(true)
+ m.Domain = "bab.apps.example.net"
+ enableInstances(t, f.fx, fromApp, m)
+ p := &apexOnBucket{MemProvisioner: f.fake, f: f, label: "old"}
+ f.fx.srv.cfg.StoreProvisioner = p
+ var seen *provision.Record
+ 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" {
+ t.Fatalf("= %d %v", code, out)
+ }
+ if seen == nil || seen.Seed == nil || !seeds.ValidID(seen.Seed.ID) || p.declared != seen.Seed.ID {
+ t.Fatalf("the store was declared for seed %q; the record written was %+v", p.declared, seen)
+ }
+ f.requireNoRecord(t, "old.bab.apps.example.net")
+ if store, _, err := objstore.GetIn(context.Background(), f.fx.rel, "bab", fromApp, "old"); err != nil || store != nil {
+ t.Fatalf("the seed's store record = %+v, %v; want it torn down", store, err)
+ }
+ if _, ok := f.fake.Buckets["carlos-bab-shop-old"]; ok {
+ t.Fatal("the seed's bucket survived the finish")
+ }
+ if len(f.fake.Buckets) != 0 {
+ t.Fatalf("buckets left = %v", f.fake.Buckets)
+ }
+}
+
+// recordSpy keeps the first create-only write of key's body.
+type recordSpy struct {
+ release.Store
+ key string
+ seen **provision.Record
+}
+
+func (s *recordSpy) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
+ if key == s.key && ifMatch == "" && *s.seen == nil {
+ var rec provision.Record
+ if err := json.Unmarshal(body, &rec); err == nil {
+ *s.seen = &rec
+ }
+ }
+ return s.Store.Put(ctx, key, body, ifMatch)
+}
+
+func (s *recordSpy) DeleteIfMatch(ctx context.Context, key, etag string) error {
+ return release.DeleteIfMatch(ctx, s.Store, key, etag)
+}
+
+// Begin uploads nothing: the snapshot is the upload's (Task 2).
+func TestImportBeginUploadsNothing(t *testing.T) {
+ f := newImportFixture(t)
+ code, out := beginImport(t, f, importHost, importSHA, 10)
+ f.requireBegun(t, code, out, importHost)
+ f.requireNothingUploaded(t)
+ f.requireClean(t)
+}
diff --git a/internal/console/apiinstances.go b/internal/console/apiinstances.go
index ce81107e2..3fe55d376 100644
--- a/internal/console/apiinstances.go
+++ b/internal/console/apiinstances.go
@@ -698,8 +698,10 @@ func (s *Server) handleAPIInstanceCreate(w http.ResponseWriter, r *http.Request)
replicaFields
// The seed doors (seeded-instances spec §4, §5.1): a create that
// starts from a sibling's replica or an uploaded snapshot. Both run
- // every check below, then leave this handler (apiinstanceseed.go).
- From *seedFromReq `json:"from,omitempty"`
+ // every check below, then leave this handler (apiinstanceseed.go,
+ // apiinstanceimport.go).
+ From *seedFromReq `json:"from,omitempty"`
+ Import *seedImportReq `json:"import,omitempty"`
}
if !readJSON(w, r, &req) {
return
@@ -708,8 +710,12 @@ func (s *Server) handleAPIInstanceCreate(w http.ResponseWriter, r *http.Request)
if !ok {
return
}
- seeded := req.From != nil
+ seeded := req.From != nil || req.Import != nil
if seeded {
+ if ref := seedOneDoorRefusal(req.From != nil, req.Import != nil); ref != nil {
+ jsonError(w, ref.Status, ref.Msg)
+ return
+ }
if ref := seedCommonRefusal(strings.TrimSpace(req.Kind), req.Addrs, strings.TrimSpace(req.Role), req.Replicas); ref != nil {
jsonError(w, ref.Status, ref.Msg)
return
@@ -1192,6 +1198,11 @@ func (s *Server) handleAPIInstanceCreate(w http.ResponseWriter, r *http.Request)
rec: rec, placed: placed, regionFrom: regionFrom, siblingLabel: siblingLabel}, *req.From)
return
}
+ if req.Import != nil {
+ s.createImport(w, r, seedCreate{acct: acct, app: app, principal: principal, message: message, m: m,
+ rec: rec, placed: placed, regionFrom: regionFrom, siblingLabel: siblingLabel}, *req.Import)
+ return
+ }
// A fresh budget for the write and its rollback: validation and the
// rail's listings may have spent most of the original one.
cancel()
diff --git a/internal/console/apiinstanceseed.go b/internal/console/apiinstanceseed.go
index e35c53914..d9a79f7bd 100644
--- a/internal/console/apiinstanceseed.go
+++ b/internal/console/apiinstanceseed.go
@@ -76,11 +76,22 @@ type seedCreate struct {
// seedCommonRefusal is both seed doors' shape rule: an ordinary instance.
func seedCommonRefusal(kind string, addrs []string, role string, replicas []string) *doorRefusal {
if (kind != "" && kind != "instance") || len(addrs) > 0 || role != "" || len(replicas) > 0 {
- return &doorRefusal{http.StatusBadRequest, "a seeded instance is an ordinary instance. Drop --kind, --addr and --role."}
+ return seedOrdinaryRefusal()
}
return nil
}
+// seedOrdinaryRefusal is SD-N36, the shape rule's answer.
+func seedOrdinaryRefusal() *doorRefusal {
+ return &doorRefusal{http.StatusBadRequest, "a seeded instance is an ordinary instance. Drop --kind, --addr and --role."}
+}
+
+// seedNoDataRefusal is both doors' answer on a console with no data bucket
+// for seeds.
+func seedNoDataRefusal() *doorRefusal {
+ return &doorRefusal{http.StatusServiceUnavailable, "this console has no data bucket set up, so it cannot create a seeded instance."}
+}
+
// seedPlacementRefusal is both seed doors' placement rule: the platform
// keeps a replica only of an instance it hosts itself.
func seedPlacementRefusal(c seedCreate) *doorRefusal {
@@ -174,7 +185,8 @@ func (s *Server) beginFrom(w http.ResponseWriter, r *http.Request, c seedCreate,
return nil
}
if s.cfg.SeedData == nil || s.cfg.HibernateBucket == "" {
- jsonError(w, http.StatusServiceUnavailable, "this console has no data bucket set up, so it cannot create a seeded instance.")
+ ref := seedNoDataRefusal()
+ jsonError(w, ref.Status, ref.Msg)
return nil
}
checks, cancel := context.WithTimeout(ctx, instanceStoreTimeout)