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>
6 files changed,
+420
−18
internal/console/apidb.go+16 −4internal/console/instanceprivatestore.go+61 −14internal/console/instanceprivatestore_test.go+171 −0internal/console/seeddoer.go+80 −0internal/console/seeddoer_test.go+88 −0internal/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 |