console: resolveDBSource, the db door's source checks, factored out (Plan 5 Task 1)
dbSource carries claimErr so the claim read's own error reaches dbClaimFailure (errors.Unwrap of a two-%w wrap is nil). errSourceResolve keeps the door's own answer for a resolveReplica failure.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 files changed,
+176
−37
internal/console/apidb.go+82 −37internal/console/apidb_source_test.go+94 −0
CI passed — run details
diff --git a/internal/console/apidb.go b/internal/console/apidb.go| index d474a3b88..7a3c82571 100644 |
| --- a/internal/console/apidb.go |
| +++ b/internal/console/apidb.go |
| @@ -128,63 +128,108 @@ func (s *Server) dbGate(w http.ResponseWriter, r *http.Request) (dbRequest, bool |
| return dbRequest{}, false |
| } |
| host := r.PathValue("host") |
| - if !provision.ValidHostShape(host) { |
| - http.NotFound(w, r) |
| - return dbRequest{}, false |
| - } |
| ctx := r.Context() |
| - rec, err := provision.GetRecord(ctx, s.cfg.Releases, acct.Sqid, app, host) |
| - if errors.Is(err, os.ErrNotExist) { |
| - http.NotFound(w, r) |
| - return dbRequest{}, false |
| - } |
| - if err != nil { |
| - log.Printf("console: api db %s/%s/%s: read record: %v", acct.Sqid, app, host, err) |
| - jsonError(w, http.StatusBadGateway, "could not read the instance. Try again.") |
| - return dbRequest{}, false |
| - } |
| - if msg := dbClassRefusal(*rec); msg != "" { |
| - jsonError(w, http.StatusConflict, msg) |
| - return dbRequest{}, false |
| - } |
| - prefix, err := s.resolveReplica(*rec, host) |
| + src, err := s.resolveDBSource(ctx, acct, actor, app, host) |
| switch { |
| - case errors.Is(err, ErrBadHost): |
| + case errors.Is(err, os.ErrNotExist), errors.Is(err, ErrBadHost): |
| http.NotFound(w, r) |
| return dbRequest{}, false |
| + case errors.Is(err, errSourceClass): |
| + jsonError(w, http.StatusConflict, dbClassRefusal(src.rec)) |
| + return dbRequest{}, false |
| case errors.Is(err, ErrPooled): |
| jsonError(w, http.StatusConflict, fmt.Sprintf( |
| - "%s runs in the pool %s, so its data is on the pool's own server. carlos db cannot read it.", host, rec.Pool)) |
| + "%s runs in the pool %s, so its data is on the pool's own server. carlos db cannot read it.", host, src.rec.Pool)) |
| return dbRequest{}, false |
| case errors.Is(err, ErrResidency): |
| - jsonError(w, http.StatusConflict, s.dbResidencyRefusal(*rec)) |
| - return dbRequest{}, false |
| - case err != nil: |
| - log.Printf("console: api db %s/%s/%s: resolve: %v", acct.Sqid, app, host, err) |
| - jsonError(w, http.StatusBadGateway, "could not read the replica. Try again.") |
| + jsonError(w, http.StatusConflict, s.dbResidencyRefusal(src.rec)) |
| return dbRequest{}, false |
| - } |
| - if s.cfg.HostClaim { |
| + case errors.Is(err, errSourceClaim): |
| g := dbRequest{acct: acct, actor: actor, app: app, host: host} |
| if s.cfg.DBVersions == nil { |
| jsonError(w, http.StatusServiceUnavailable, "this console has no versioned database reader") |
| return dbRequest{}, false |
| } |
| - g.claim, err = s.dbClaim(ctx, g, *rec) |
| - if err != nil { |
| - s.dbClaimFailure(w, r, g, err) |
| - return dbRequest{}, false |
| - } |
| - g.prefix = g.claim.Claim.Holder.Location |
| - return g, true |
| + s.dbClaimFailure(w, r, g, src.claimErr) |
| + return dbRequest{}, false |
| + case errors.Is(err, errSourceResolve): |
| + log.Printf("console: api db %s/%s/%s: resolve: %v", acct.Sqid, app, host, err) |
| + jsonError(w, http.StatusBadGateway, "could not read the replica. Try again.") |
| + return dbRequest{}, false |
| + case err != nil: |
| + log.Printf("console: api db %s/%s/%s: read record: %v", acct.Sqid, app, host, err) |
| + jsonError(w, http.StatusBadGateway, "could not read the instance. Try again.") |
| + return dbRequest{}, false |
| + } |
| + if s.cfg.HostClaim { |
| + return dbRequest{claim: src.claim, acct: acct, actor: actor, app: app, host: host, prefix: src.prefix}, true |
| } |
| - rd, err := s.dbReplica(ctx, prefix) |
| + rd, err := s.dbReplica(ctx, src.prefix) |
| if err != nil { |
| log.Printf("console: api db %s/%s/%s: replica client: %v", acct.Sqid, app, host, err) |
| jsonError(w, http.StatusBadGateway, "could not read the replica. Try again.") |
| return dbRequest{}, false |
| } |
| - return dbRequest{acct: acct, actor: actor, app: app, host: host, prefix: prefix, rd: rd}, true |
| + return dbRequest{acct: acct, actor: actor, app: app, host: host, prefix: src.prefix, rd: rd}, true |
| +} |
| + |
| +var ( |
| + errSourceClass = errors.New("db: not an instance the platform keeps a replica of") |
| + errSourceClaim = errors.New("db: the instance's claim could not be read") |
| + // errSourceResolve marks a resolveReplica failure that is none of the |
| + // named refusals, so the db door keeps its own log line and answer for |
| + // it apart from a failed record read. |
| + errSourceResolve = errors.New("db: the replica could not be resolved") |
| +) |
| + |
| +// dbSource is a replica source both the db door and the --from door may |
| +// read (seeded-instances spec §4, codex 17): a record of this account and |
| +// app, an instance with a replica of its own, homed in this console's |
| +// region, and, with claims on, its versioned claim. claimErr is the claim |
| +// read's own error, set beside the errSourceClaim wrap so a caller can hand |
| +// it to dbClaimFailure unwrapped. |
| +type dbSource struct { |
| + rec provision.Record |
| + prefix string |
| + claim hostclaim.Observed |
| + claimErr error |
| +} |
| + |
| +// resolveDBSource holds the source checks dbGate made inline. It does no |
| +// authorization: dbGate keeps its person-only gate, and the --from door |
| +// authorizes through cliRoute's instance role. |
| +func (s *Server) resolveDBSource(ctx context.Context, acct *Account, actor, app, host string) (dbSource, error) { |
| + if !provision.ValidHostShape(host) { |
| + return dbSource{}, fmt.Errorf("%w: %q", ErrBadHost, host) |
| + } |
| + rec, err := provision.GetRecord(ctx, s.cfg.Releases, acct.Sqid, app, host) |
| + if err != nil { |
| + return dbSource{}, err // os.ErrNotExist passes through as itself |
| + } |
| + if msg := dbClassRefusal(*rec); msg != "" { |
| + return dbSource{rec: *rec}, fmt.Errorf("%w: %s", errSourceClass, msg) |
| + } |
| + prefix, err := s.resolveReplica(*rec, host) |
| + if err != nil { |
| + switch { |
| + case errors.Is(err, ErrBadHost), errors.Is(err, ErrPooled), errors.Is(err, ErrResidency): |
| + return dbSource{rec: *rec}, err |
| + } |
| + return dbSource{rec: *rec}, fmt.Errorf("%w: %w", errSourceResolve, err) |
| + } |
| + src := dbSource{rec: *rec, prefix: prefix} |
| + if s.cfg.HostClaim { |
| + if s.cfg.DBVersions == nil { |
| + return src, fmt.Errorf("%w: this console has no versioned database reader", errSourceClaim) |
| + } |
| + g := dbRequest{acct: acct, actor: actor, app: app, host: host} |
| + if src.claim, err = s.dbClaim(ctx, g, *rec); err != nil { |
| + src.claimErr = err |
| + return src, fmt.Errorf("%w: %w", errSourceClaim, err) |
| + } |
| + src.prefix = src.claim.Claim.Holder.Location |
| + } |
| + return src, nil |
| } |
| // dbClassRefusal is the class rail as a sentence, or "" for an |
diff --git a/internal/console/apidb_source_test.go b/internal/console/apidb_source_test.go| new file mode 100644 |
| index 000000000..f07450037 |
| --- /dev/null |
| +++ b/internal/console/apidb_source_test.go |
| @@ -0,0 +1,94 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "net/http" |
| + "os" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/dbdoor" |
| + "github.com/carlosframework/platform/internal/lease/leasetest" |
| + "github.com/carlosframework/platform/internal/provision" |
| +) |
| + |
| +func TestResolveDBSource(t *testing.T) { |
| + fx, _ := instanceFixture(t, "code-db-source") |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "shop", "paul@keymail.dev", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + m := provision.Manifest{Schema: 1, App: "shop", Domain: "shop.example.com", |
| + Channel: "stable", Regions: []string{"ie"}, Seed: []string{"seed"}} |
| + enableInstances(t, fx, "shop", m) |
| + fx.srv.cfg.Region = "ie" |
| + fx.srv.cfg.HibernatePrefix = "instances" |
| + acct, err := fx.st.AccountBySqid("bab") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + ctx := context.Background() |
| + put := func(host string, mut func(*provision.Record)) { |
| + rec := provision.Record{Schema: 1, Host: host, Slug: strings.Split(host, ".")[0], App: "shop", Account: "bab", |
| + Region: "ie", Channel: "stable", CreatedAt: time.Now().UTC()} |
| + if mut != nil { |
| + mut(&rec) |
| + } |
| + if err := provision.CreateRecord(ctx, fx.rel, rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + put("acme.shop.example.com", nil) |
| + put("pooled.shop.example.com", func(r *provision.Record) { r.Pool = "shared" }) |
| + put("far.shop.example.com", func(r *provision.Record) { r.Region = "uk" }) |
| + put("addr.shop.example.com", func(r *provision.Record) { r.Addr = "127.0.0.1:9000" }) |
| + |
| + src, err := fx.srv.resolveDBSource(ctx, acct, "alice@keymail.dev", "shop", "acme.shop.example.com") |
| + if err != nil || src.prefix != "instances/acme.shop.example.com" || src.rec.Host != "acme.shop.example.com" { |
| + t.Fatalf("acme = %+v, %v", src, err) |
| + } |
| + for host, want := range map[string]error{ |
| + "nope.shop.example.com": os.ErrNotExist, |
| + "pooled.shop.example.com": ErrPooled, |
| + "far.shop.example.com": ErrResidency, |
| + "addr.shop.example.com": errSourceClass, |
| + "not a host": ErrBadHost, |
| + } { |
| + if _, err := fx.srv.resolveDBSource(ctx, acct, "alice@keymail.dev", "shop", host); !errors.Is(err, want) { |
| + t.Errorf("%s: err = %v, want %v", host, err, want) |
| + } |
| + } |
| +} |
| + |
| +// A claim-read failure is wrapped in errSourceClaim and also kept whole on |
| +// dbSource.claimErr: errors.Unwrap of a two-%w wrap is nil, and |
| +// dbClaimFailure needs the original to pick its answer. |
| +func TestResolveDBSourceClaimReadError(t *testing.T) { |
| + fx := dbFixture(t) |
| + fx.srv.cfg.HomeRegion = "ie" |
| + fx.srv.cfg.HostClaim = true |
| + fx.srv.cfg.DBVersions = leasetest.NewVersionedBucket() |
| + acct, err := fx.st.AccountBySqid("bab") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + app := titoManifest().App |
| + src, err := fx.srv.resolveDBSource(t.Context(), acct, "alice@keymail.dev", app, dbTestHost) |
| + if !errors.Is(err, errSourceClaim) { |
| + t.Fatalf("err = %v, want errSourceClaim", err) |
| + } |
| + if src.claimErr == nil || !errors.Is(src.claimErr, errDBNotAdopted) { |
| + t.Fatalf("claimErr = %v, want errDBNotAdopted", src.claimErr) |
| + } |
| + if errors.Unwrap(err) != nil { |
| + t.Fatalf("errors.Unwrap(err) = %v, expected nil for a two-%%w wrap", errors.Unwrap(err)) |
| + } |
| + |
| + // Through the door: the original error reaches dbClaimFailure, which |
| + // answers a missing adoption with a 409. A nil error would be a 502. |
| + status, out := getRaw(t, fx.url(dbdoor.ListPath("bab", app, dbTestHost, 0)), fx.member) |
| + if status != http.StatusConflict || !strings.Contains(string(out), errDBNotAdopted.Error()) { |
| + t.Fatalf("door answered %d %q, want 409 with %q", status, out, errDBNotAdopted) |
| + } |
| +} |