carlos / platform Public

console: the in-process replica door and stores marked with their seed (Plan 5 Task 4)

replicaDoer serves the db door's list and get tails from a replica reader
in process, so --from restores through dbdoor.RestoreToTarget, the code
carlos db pull runs. Its listing is the door's own: listReplicaLevel is
factored out of handleAPIDBList, which keeps its dbStoreFailure mapping
(preflight M11). The response helper is doerResponse, not status, which
would collide with the internal/status import (M4).

objstore.Declared gains Seed. instanceStoreProvisionFor is
instanceStoreProvision with a seed id; with "" it is the old function,
which is now a wrapper. With a seed it re-checks seedOwnsStore on the
record it reads under the store lock, before any declaration or grant,
and refuses a store that holds estate and is not this seed's with
errSeedStoreTaken, answering nothing so the seed door can speak SD-N34
(M5). A declared-only record is declared again for the seed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev f20cbab3c8840eeaf070f67c647b2215e1252272 parent 7b70b31
6 files changed, +420 −18
  • internal/console/apidb.go +16 −4
  • internal/console/instanceprivatestore.go +61 −14
  • internal/console/instanceprivatestore_test.go +171 −0
  • internal/console/seeddoer.go +80 −0
  • internal/console/seeddoer_test.go +88 −0
  • internal/objstore/objstore.go +4 −0
diff --git a/internal/console/apidb.go b/internal/console/apidb.go
index 7a3c82571..f1121beee 100644
--- a/internal/console/apidb.go
+++ b/internal/console/apidb.go
@@ -315,11 +315,24 @@ func (s *Server) handleAPIDBList(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, out)
return
}
- itr, err := g.rd.LTXFiles(r.Context(), level, 0, false)
+ out, err := listReplicaLevel(r.Context(), g.rd, level)
if err != nil {
s.dbStoreFailure(w, g, fmt.Sprintf("list level %d", level), err)
return
}
+ writeJSON(w, http.StatusOK, out)
+}
+
+// listReplicaLevel lists one level of rd in the door's wire shape, ordered
+// by MinTXID, with an empty level as an empty list. The db door and the
+// console's own in-process door (replicaDoer, for --from) both list
+// through it, so a seeded copy restores from the listing carlos db pull
+// reads.
+func listReplicaLevel(ctx context.Context, rd replicaReader, level int) (dbdoor.ListResponse, error) {
+ itr, err := rd.LTXFiles(ctx, level, 0, false)
+ if err != nil {
+ return dbdoor.ListResponse{}, err
+ }
out := dbdoor.ListResponse{Files: []dbdoor.LTXFile{}}
for itr.Next() {
f := itr.Item()
@@ -333,11 +346,10 @@ func (s *Server) handleAPIDBList(w http.ResponseWriter, r *http.Request) {
err = cerr
}
if err != nil {
- s.dbStoreFailure(w, g, fmt.Sprintf("list level %d", level), err)
- return
+ return dbdoor.ListResponse{}, err
}
sort.Slice(out.Files, func(i, j int) bool { return out.Files[i].MinTXID < out.Files[j].MinTXID })
- writeJSON(w, http.StatusOK, out)
+ return out, nil
}
// handleAPIDBGet streams one LTX file by coordinate. The bytes pass through
diff --git a/internal/console/instanceprivatestore.go b/internal/console/instanceprivatestore.go
index 2223ba834..4a9331df6 100644
--- a/internal/console/instanceprivatestore.go
+++ b/internal/console/instanceprivatestore.go
@@ -174,8 +174,34 @@ func (s *Server) instanceStoreNames(acct *Account, app, env string, derived bool
// create door's own deadline, and a walk that inherited a fifteen-second
// budget would abandon a bucket it had already paid for.
func (s *Server) instanceStoreProvision(ctx context.Context, w http.ResponseWriter, acct *Account, app, env, actor string) (*objstore.Record, bool) {
+ out, ok, _ := s.instanceStoreProvisionFor(ctx, w, acct, app, env, actor, "")
+ return out, ok
+}
+
+// errSeedStoreTaken is instanceStoreProvisionFor's one refusal it does not
+// answer itself: the environment already has a store that holds estate and
+// was not made by this seed (seeded-instances spec §4 Storage). The seed
+// door speaks that refusal, naming the store it got back.
+var errSeedStoreTaken = errors.New("console: the environment's store is not this seed's")
+
+// instanceStoreProvisionFor is instanceStoreProvision for a seeded create
+// when seedID is set. With seedID "" it is instanceStoreProvision exactly,
+// and its error is always nil.
+//
+// With a seed, the store must be new and empty, or this seed's own from an
+// earlier attempt. That is re-checked under the store lock, on the record
+// read there, before anything is declared or granted: a check made before
+// the lock (the seed door's own) can be overtaken by a concurrent `carlos
+// store create --environment`, and GrantIn would then reuse that grant.
+// On that refusal it answers nothing on w and returns the record it found,
+// false and errSeedStoreTaken. Every other refusal is answered on w and
+// returns a nil error, as instanceStoreProvision's do.
+//
+// A seed's declaration carries its id (objstore.Declared.Seed). A record
+// that is declared and holds no estate is declared again for this seed.
+func (s *Server) instanceStoreProvisionFor(ctx context.Context, w http.ResponseWriter, acct *Account, app, env, actor, seedID string) (*objstore.Record, bool, error) {
if s.refuseTemporary(w, acct, app, "give it object storage") {
- return nil, false
+ return nil, false, nil
}
effectStore, effectProvisioner := claimStoreAdapters(ctx, s.cfg.Releases, s.cfg.StoreProvisioner)
if s.cfg.StoreProvisioner == nil {
@@ -185,16 +211,16 @@ func (s *Server) instanceStoreProvision(ctx context.Context, w http.ResponseWrit
// fact, named as one.
jsonError(w, http.StatusServiceUnavailable,
"object storage is not configured for this deployment (CARLOS_STORE_BACKEND), and "+app+" gives every instance its own storage — no instance can be created until one is wired or the policy is turned off (carlos instances enable --private-storage=false)")
- return nil, false
+ return nil, false, nil
}
preCtx, preCancel := context.WithTimeout(ctx, storeTimeout)
defer preCancel()
if _, ok := s.storeProvisionApproval(preCtx, w, acct); !ok {
- return nil, false
+ return nil, false, nil
}
pub, ok := s.storeSealKey(w)
if !ok {
- return nil, false
+ return nil, false, nil
}
// The same lock handleAPIStoreProvision takes, keyed the same way, so a
// signup creating an instance and a member running `carlos store create
@@ -209,7 +235,7 @@ func (s *Server) instanceStoreProvision(ctx context.Context, w http.ResponseWrit
// operator declined the account.
e, ok := s.storeProvisionApproval(walkCtx, w, acct)
if !ok {
- return nil, false
+ return nil, false, nil
}
// The declaration the walk requires. GrantIn refuses a store nobody
// declared (ErrNotDeclared) because the env prefix is the member's
@@ -219,13 +245,16 @@ func (s *Server) instanceStoreProvision(ctx context.Context, w http.ResponseWrit
// who has no verb to write it with.
prefix, ok := s.storeInheritedPrefix(walkCtx, w, acct, app)
if !ok {
- return nil, false
+ return nil, false, nil
}
rec, etag, err := objstore.GetIn(walkCtx, effectStore, acct.Sqid, app, env)
if err != nil {
log.Printf("console: instance store read %s/%s/%s: %v", acct.Sqid, app, env, err)
jsonError(w, http.StatusBadGateway, "could not read this instance's store record — try again")
- return nil, false
+ return nil, false, nil
+ }
+ if seedID != "" && !seedOwnsStore(rec, seedID) {
+ return rec, false, errSeedStoreTaken
}
if rec == nil {
rec = &objstore.Record{}
@@ -234,17 +263,25 @@ func (s *Server) instanceStoreProvision(ctx context.Context, w http.ResponseWrit
// re-stamp its author and timestamp, and must not move a prefix the app
// is already reading — that is the one rewrite handleAPIStoreCreate
// refuses with a 409, and this path has no member to show it to.
- if rec.Declared == nil {
- rec.Declared = &objstore.Declared{EnvPrefix: prefix, At: time.Now().UTC(), By: actor}
+ //
+ // A seed is the exception: a declared-only record holds no estate, and
+ // one not marked with this seed is declared again, for this seed, so
+ // the store this seed makes is marked as its own.
+ redeclare := rec.Declared == nil
+ if seedID != "" && rec.Declared != nil && rec.Grant == nil && rec.Provisioned == nil && rec.Declared.Seed != seedID {
+ redeclare = true
+ }
+ if redeclare {
+ rec.Declared = &objstore.Declared{EnvPrefix: prefix, At: time.Now().UTC(), By: actor, Seed: seedID}
if _, err := objstore.PutIn(walkCtx, effectStore, acct.Sqid, app, env, rec, etag); err != nil {
if errors.Is(err, release.ErrCASConflict) {
jsonError(w, http.StatusConflict,
"this instance's store record changed while it was being created — try again")
- return nil, false
+ return nil, false, nil
}
log.Printf("console: instance store declare %s/%s/%s: %v", acct.Sqid, app, env, err)
jsonError(w, http.StatusBadGateway, "could not declare this instance's store — nothing was created; try again")
- return nil, false
+ return nil, false, nil
}
}
out, err := objstore.GrantIn(walkCtx, effectStore, effectProvisioner, s.cfg.StoreDeployment,
@@ -253,7 +290,7 @@ func (s *Server) instanceStoreProvision(ctx context.Context, w http.ResponseWrit
log.Printf("console: instance store provision %s/%s/%s: %v", acct.Sqid, app, env, err)
jsonError(w, http.StatusConflict,
"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")
- return nil, false
+ return nil, false, nil
}
if err != nil {
// Logged, not returned: a walk error carries bucket names, IAM ARNs
@@ -264,7 +301,7 @@ func (s *Server) instanceStoreProvision(ctx context.Context, w http.ResponseWrit
log.Printf("console: instance store provision %s/%s/%s: %v", acct.Sqid, app, env, err)
jsonError(w, http.StatusBadGateway,
"could not provision this instance's storage, so the instance was not created — run the same command again to resume it")
- return nil, false
+ return nil, false, nil
}
// Audited from here as well as from the store doors, because this is the
// one path where a bucket is minted by a caller that is not a person:
@@ -272,7 +309,17 @@ func (s *Server) instanceStoreProvision(ctx context.Context, w http.ResponseWrit
// path spent money, and by whom it was authorised.
s.audit(acct, app, actor, "store-provision",
"provisioned "+storeBucketName(out)+" for instance environment "+env+" (instance create)")
- return out, true
+ return out, true, nil
+}
+
+// seedOwnsStore reports whether a seeded create may use rec (spec §4
+// Storage): there is no store, the record holds no estate yet, or the
+// estate was made by this very seed.
+func seedOwnsStore(rec *objstore.Record, seedID string) bool {
+ if rec == nil || (rec.Grant == nil && rec.Provisioned == nil) {
+ return true
+ }
+ return rec.Declared != nil && rec.Declared.Seed == seedID
}
// storeInheritedPrefix reads the app's own store for the variable-name
diff --git a/internal/console/instanceprivatestore_test.go b/internal/console/instanceprivatestore_test.go
new file mode 100644
index 000000000..49efa5adf
--- /dev/null
+++ b/internal/console/instanceprivatestore_test.go
@@ -0,0 +1,171 @@
+package console
+
+import (
+ "context"
+ "errors"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/objstore"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func TestSeedOwnsStore(t *testing.T) {
+ id := "sd_aaaaaaaaaaaaaaaaaaaaaaaaaa"
+ for _, c := range []struct {
+ rec *objstore.Record
+ want bool
+ }{
+ {nil, true}, // no store: this seed may make one
+ {&objstore.Record{Declared: &objstore.Declared{}}, true}, // declared only: no estate yet
+ {&objstore.Record{Declared: &objstore.Declared{Seed: id}, Grant: &objstore.GrantBlock{}}, true},
+ {&objstore.Record{Declared: &objstore.Declared{Seed: "sd_bbbbbbbbbbbbbbbbbbbbbbbbbb"}, Grant: &objstore.GrantBlock{}}, false},
+ {&objstore.Record{Declared: &objstore.Declared{}, Provisioned: &objstore.Provisioned{}}, false},
+ {&objstore.Record{Grant: &objstore.GrantBlock{}}, false}, // estate nobody declared: not this seed's
+ } {
+ if got := seedOwnsStore(c.rec, id); got != c.want {
+ t.Errorf("seedOwnsStore(%+v) = %v, want %v", c.rec, got, c.want)
+ }
+ }
+}
+
+const (
+ seedA = "sd_aaaaaaaaaaaaaaaaaaaaaaaaaa"
+ seedB = "sd_bbbbbbbbbbbbbbbbbbbbbbbbbb"
+)
+
+// seededStoreFixture is privateStorageFixture with a neutral app, shop,
+// whose canary environment the seeded tests provision.
+func seededStoreFixture(t *testing.T, code string) (*consoleFixture, *objstore.MemProvisioner, *Account) {
+ t.Helper()
+ fx, fake, _, _ := privateStorageFixture(t, code)
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "shop", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ acct, err := fx.st.AccountByID(OpsAccountID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return fx, fake, acct
+}
+
+func putCanaryStore(t *testing.T, fx *consoleFixture, rec *objstore.Record) {
+ t.Helper()
+ if _, err := objstore.PutIn(context.Background(), fx.rel, release.OpsAccount, "shop", "canary", rec, ""); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func canaryStore(t *testing.T, fx *consoleFixture) *objstore.Record {
+ t.Helper()
+ rec, _, err := objstore.GetIn(context.Background(), fx.rel, release.OpsAccount, "shop", "canary")
+ if err != nil {
+ t.Fatal(err)
+ }
+ return rec
+}
+
+// A store that already holds estate and is not this seed's is refused
+// under the store lock, before any declaration or grant: a concurrent
+// `carlos store create --environment canary`, or another seed, must never
+// hand a seeded instance a store that is not new and empty. Nothing is
+// answered on w; the caller speaks the refusal.
+func TestInstanceStoreProvisionForRefusesAStoreThatIsNotThisSeeds(t *testing.T) {
+ at := time.Now().UTC().Add(-time.Hour)
+ for name, planted := range map[string]*objstore.Record{
+ "another seed's grant": {
+ Declared: &objstore.Declared{EnvPrefix: "CARLOS_STORE", At: at, By: "alice@keymail.dev", Seed: seedB},
+ Grant: &objstore.GrantBlock{Backend: "mem", Bucket: "carlos-bab-shop-canary", Source: objstore.GrantSourceAccountApproval, At: at, By: "operator@keymail.dev"},
+ },
+ "an ordinary store's grant": {
+ Declared: &objstore.Declared{EnvPrefix: "CARLOS_STORE", At: at, By: "alice@keymail.dev"},
+ Grant: &objstore.GrantBlock{Backend: "mem", Bucket: "carlos-bab-shop-canary", Source: objstore.GrantSourceAccountApproval, At: at, By: "operator@keymail.dev"},
+ },
+ "an ordinary provisioned store": {
+ Declared: &objstore.Declared{EnvPrefix: "CARLOS_STORE", At: at, By: "alice@keymail.dev"},
+ Provisioned: &objstore.Provisioned{Bucket: "carlos-bab-shop-canary", Region: "eu-west-1", IAMUser: "carlos-store-bab-shop-canary", At: at},
+ },
+ } {
+ t.Run(name, func(t *testing.T) {
+ fx, fake, acct := seededStoreFixture(t, "code-seed-taken")
+ putCanaryStore(t, fx, planted)
+ w := httptest.NewRecorder()
+ got, ok, err := fx.srv.instanceStoreProvisionFor(context.Background(), w, acct, "shop", "canary", "alice@keymail.dev", seedA)
+ if ok || !errors.Is(err, errSeedStoreTaken) {
+ t.Fatalf("provision = %v, %v; want errSeedStoreTaken", ok, err)
+ }
+ if got == nil || storeBucketName(got) != "carlos-bab-shop-canary" {
+ t.Fatalf("returned record = %+v, want the store that is there, for the caller to name", got)
+ }
+ if w.Body.Len() != 0 {
+ t.Fatalf("answered %d %q, want nothing (the caller speaks this refusal)", w.Code, w.Body.String())
+ }
+ if len(fake.Buckets) != 0 || len(fake.Users) != 0 {
+ t.Fatalf("backend buckets %v, users %v; want no grant walk", fake.Buckets, fake.Users)
+ }
+ after := canaryStore(t, fx)
+ if after.Declared == nil || after.Declared.Seed != planted.Declared.Seed || !after.Declared.At.Equal(at) {
+ t.Fatalf("declaration = %+v, want the planted one untouched", after.Declared)
+ }
+ })
+ }
+}
+
+// With no store, the seed declares one marked with its id and provisions
+// it; a retry of the same seed then resumes that store.
+func TestInstanceStoreProvisionForMarksANewStoreWithTheSeed(t *testing.T) {
+ fx, fake, acct := seededStoreFixture(t, "code-seed-new")
+ for range 2 {
+ w := httptest.NewRecorder()
+ out, ok, err := fx.srv.instanceStoreProvisionFor(context.Background(), w, acct, "shop", "canary", "alice@keymail.dev", seedA)
+ if !ok || err != nil {
+ t.Fatalf("provision = %v, %v (%d %s)", ok, err, w.Code, w.Body.String())
+ }
+ if out.Provisioned == nil || out.Declared == nil || out.Declared.Seed != seedA {
+ t.Fatalf("store = %+v, want a provisioned store declared by %s", out, seedA)
+ }
+ }
+ if _, ok := fake.Buckets["carlos-bab-shop-canary"]; !ok || len(fake.Buckets) != 1 {
+ t.Fatalf("backend buckets = %v, want the one canary bucket", fake.Buckets)
+ }
+}
+
+// A declared-only record holds no estate, so a seed takes it over: the
+// declaration is rewritten for this seed (keeping no other seed's mark)
+// and the store is provisioned.
+func TestInstanceStoreProvisionForRedeclaresADeclaredOnlyRecord(t *testing.T) {
+ for name, decl := range map[string]*objstore.Declared{
+ "another seed's": {EnvPrefix: "CARLOS_STORE", At: time.Now().UTC().Add(-time.Hour), By: "alice@keymail.dev", Seed: seedB},
+ "an ordinary": {EnvPrefix: "CARLOS_STORE", At: time.Now().UTC().Add(-time.Hour), By: "alice@keymail.dev"},
+ } {
+ t.Run(name, func(t *testing.T) {
+ fx, _, acct := seededStoreFixture(t, "code-seed-redeclare")
+ putCanaryStore(t, fx, &objstore.Record{Declared: decl})
+ w := httptest.NewRecorder()
+ out, ok, err := fx.srv.instanceStoreProvisionFor(context.Background(), w, acct, "shop", "canary", "bob@keymail.dev", seedA)
+ if !ok || err != nil {
+ t.Fatalf("provision = %v, %v (%d %s)", ok, err, w.Code, w.Body.String())
+ }
+ if out.Declared == nil || out.Declared.Seed != seedA || out.Declared.By != "bob@keymail.dev" || out.Provisioned == nil {
+ t.Fatalf("store = %+v / %+v, want provisioned and declared by %s", out, out.Declared, seedA)
+ }
+ })
+ }
+}
+
+// Without a seed, a declared-only record keeps its author and timestamp,
+// exactly as before: only a seeded create re-declares.
+func TestInstanceStoreProvisionKeepsAnExistingDeclaration(t *testing.T) {
+ fx, _, acct := seededStoreFixture(t, "code-unseeded-keep")
+ at := time.Now().UTC().Add(-time.Hour).Truncate(time.Second)
+ putCanaryStore(t, fx, &objstore.Record{Declared: &objstore.Declared{EnvPrefix: "CARLOS_STORE", At: at, By: "alice@keymail.dev", Seed: seedB}})
+ w := httptest.NewRecorder()
+ out, ok := fx.srv.instanceStoreProvision(context.Background(), w, acct, "shop", "canary", "bob@keymail.dev")
+ if !ok {
+ t.Fatalf("provision refused: %d %s", w.Code, w.Body.String())
+ }
+ if out.Declared == nil || out.Declared.By != "alice@keymail.dev" || !out.Declared.At.Equal(at) || out.Declared.Seed != seedB {
+ t.Fatalf("declaration = %+v, want the existing one untouched", out.Declared)
+ }
+}
diff --git a/internal/console/seeddoer.go b/internal/console/seeddoer.go
new file mode 100644
index 000000000..d58708123
--- /dev/null
+++ b/internal/console/seeddoer.go
@@ -0,0 +1,80 @@
+package console
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "os"
+ "strings"
+
+ "github.com/superfly/ltx"
+
+ "github.com/carlosframework/platform/internal/dbdoor"
+)
+
+// replicaDoer serves the db door's two tails (ListTail, GetTail) from rd in
+// process, so the console's own --from restore runs through
+// dbdoor.RestoreToTarget: its retry, its private 0700 directory, its
+// CheckDB, the same code carlos db pull runs (seeded-instances spec §4
+// step 6). The listing is the door's own (listReplicaLevel).
+//
+// It answers the status codes the client reads and no sentences: a tail
+// that is not a coordinate, or a file that is not there, is a 404 (the
+// client's os.ErrNotExist), and a range the door would refuse is a 416.
+// A failed bucket read is returned as the error it is.
+func replicaDoer(rd replicaReader) dbdoor.Doer {
+ return func(ctx context.Context, tail string, h http.Header) (*http.Response, error) {
+ rest, ok := strings.CutPrefix(tail, "/ltx/")
+ if !ok {
+ return doerResponse(http.StatusNotFound, nil), nil
+ }
+ parts := strings.Split(rest, "/")
+ level, ok := parseDBLevel(parts[0])
+ if !ok || len(parts) > 2 {
+ return doerResponse(http.StatusNotFound, nil), nil
+ }
+ if len(parts) == 1 {
+ out, err := listReplicaLevel(ctx, rd, level)
+ if err != nil {
+ return nil, err
+ }
+ body, err := json.Marshal(out)
+ if err != nil {
+ return nil, err
+ }
+ return doerResponse(http.StatusOK, io.NopCloser(bytes.NewReader(body))), nil
+ }
+ lo, hi, err := dbdoor.ParseSpan(parts[1])
+ if err != nil {
+ return doerResponse(http.StatusNotFound, nil), nil
+ }
+ off, size, err := dbdoor.ParseRange(h.Get("Range"))
+ if err != nil {
+ return doerResponse(http.StatusRequestedRangeNotSatisfiable, nil), nil
+ }
+ rc, err := rd.OpenLTXFile(ctx, level, ltx.TXID(lo), ltx.TXID(hi), off, size)
+ if errors.Is(err, os.ErrNotExist) {
+ return doerResponse(http.StatusNotFound, nil), nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ code := http.StatusOK
+ if h.Get("Range") != "" {
+ code = http.StatusPartialContent
+ }
+ return doerResponse(code, rc), nil
+ }
+}
+
+// doerResponse is one in-process answer. A nil body is an empty one, so
+// the client can always close what it is handed.
+func doerResponse(code int, body io.ReadCloser) *http.Response {
+ if body == nil {
+ body = io.NopCloser(strings.NewReader(""))
+ }
+ return &http.Response{StatusCode: code, Header: http.Header{}, Body: body}
+}
diff --git a/internal/console/seeddoer_test.go b/internal/console/seeddoer_test.go
new file mode 100644
index 000000000..3fbf5ba69
--- /dev/null
+++ b/internal/console/seeddoer_test.go
@@ -0,0 +1,88 @@
+package console
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "os"
+ "testing"
+
+ lsfile "github.com/benbjohnson/litestream/file"
+
+ "github.com/carlosframework/platform/internal/dbdoor"
+ "github.com/carlosframework/platform/internal/dbdoor/dbdoortest"
+)
+
+// The console's own --from restore runs dbdoor's planner and restore over
+// replicaDoer, and gets exactly the copy the plan promised.
+func TestReplicaDoerRestoresLikeTheDoor(t *testing.T) {
+ root := t.TempDir()
+ dbdoortest.Replicate(t, root, 25)
+ c := dbdoor.NewClient(replicaDoer(lsfile.NewReplicaClient(root)))
+ plan, err := dbdoor.PlanRestore(context.Background(), c, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ r, err := dbdoor.RestoreToTarget(context.Background(), c, t.TempDir(), 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer os.RemoveAll(r.Dir)
+ st, err := os.Stat(r.Path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if st.Size() != plan.RestoredBytes || r.TXID != plan.TXID {
+ t.Fatalf("restored %d at %d; plan %+v", st.Size(), r.TXID, plan)
+ }
+}
+
+// The status codes the client reads: a coordinate that is not one is a
+// 404, an empty level is an empty list, a file that is not there is a 404
+// (the client's os.ErrNotExist), and a range the door cannot serve is a 416.
+func TestReplicaDoerAnswersLikeTheDoor(t *testing.T) {
+ root := t.TempDir()
+ dbdoortest.Replicate(t, root, 2)
+ do := replicaDoer(lsfile.NewReplicaClient(root))
+ l0, err := listReplicaLevel(context.Background(), lsfile.NewReplicaClient(root), 0)
+ if err != nil || len(l0.Files) == 0 {
+ t.Fatalf("level 0 = %+v, %v; want files", l0, err)
+ }
+ span := "/ltx/0/" + dbdoor.Span(l0.Files[0].MinTXID, l0.Files[0].MaxTXID)
+ for _, c := range []struct {
+ tail, rng string
+ want int
+ }{
+ {"/nope/0", "", http.StatusNotFound},
+ {"/ltx/", "", http.StatusNotFound},
+ {"/ltx/10", "", http.StatusNotFound},
+ {span + "/extra", "", http.StatusNotFound},
+ {"/ltx/0/not-a-span", "", http.StatusNotFound},
+ {"/ltx/0/900-901", "bytes=0-", http.StatusNotFound},
+ {span, "items=0-", http.StatusRequestedRangeNotSatisfiable},
+ {span, "bytes=0-", http.StatusPartialContent},
+ {span, "", http.StatusOK},
+ {"/ltx/5", "", http.StatusOK},
+ } {
+ h := http.Header{}
+ if c.rng != "" {
+ h.Set("Range", c.rng)
+ }
+ res, err := do(context.Background(), c.tail, h)
+ if err != nil {
+ t.Fatalf("%s %q: %v", c.tail, c.rng, err)
+ }
+ body, _ := io.ReadAll(res.Body)
+ res.Body.Close()
+ if res.StatusCode != c.want {
+ t.Errorf("%s %q = %d, want %d", c.tail, c.rng, res.StatusCode, c.want)
+ }
+ if c.tail == "/ltx/5" {
+ var list dbdoor.ListResponse
+ if err := json.Unmarshal(body, &list); err != nil || list.Files == nil || len(list.Files) != 0 {
+ t.Errorf("empty level = %s (%v), want an empty list", body, err)
+ }
+ }
+ }
+}
diff --git a/internal/objstore/objstore.go b/internal/objstore/objstore.go
index 4e151a223..bdc957092 100644
--- a/internal/objstore/objstore.go
+++ b/internal/objstore/objstore.go
@@ -30,6 +30,10 @@ type Declared struct {
EnvPrefix string `json:"env_prefix"`
At time.Time `json:"at"`
By string `json:"by"`
+ // Seed is the seed id that created this store (seeded-instances spec §4
+ // Storage, codex R5-1, R6-1): only a retry of that seed may use it, and
+ // only that seed's abort may tear it down. Empty for every other store.
+ Seed string `json:"seed,omitempty"`
}
// Grant sources (#381). Source records WHY a grant exists, mirroring