console: resolve an instance to one replica prefix, refusing before S3
The first half of the carlos db door (spec 2026-09-02 §6): an instance record becomes the activator's own prefix, or a refusal. Another region, a fleet placement, a pool and a hostile or mismatched host are all refused with no store in reach, so none of them can become an S3 call. Config gains the CARLOS_HIBERNATE_* triple and CARLOS_REGION.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3 files changed,
+183
−0
internal/console/apidb.go+57 −0internal/console/apidb_test.go+111 −0internal/console/server.go+15 −0
diff --git a/internal/console/apidb.go b/internal/console/apidb.go| new file mode 100644 |
| index 000000000..ea29755e6 |
| --- /dev/null |
| +++ b/internal/console/apidb.go |
| @@ -0,0 +1,57 @@ |
| +// The `carlos db` replica door (spec 2026-09-02 §6). It serves an |
| +// instance's replicated LTX files BY COORDINATE (level plus TXID span) from |
| +// the region data bucket, to a member of the account and nobody else, so the |
| +// CLI can restore a private copy on the member's own machine. The console |
| +// never executes tenant SQL and never holds a restored database. |
| +// |
| +// resolveReplica is the half that makes no S3 call: resolving an instance |
| +// record to one replica prefix, and refusing everything that is not this |
| +// console's to read. Every refusal there happens BEFORE the bucket is |
| +// touched. That is deliberate: a door that tried and mapped AccessDenied |
| +// would leak the attempt, the latency, and eventually a bucket name into a |
| +// log a member can provoke. |
| + |
| +package console |
| + |
| +import ( |
| + "errors" |
| + "fmt" |
| + |
| + "github.com/carlosframework/platform/internal/activator" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +var ( |
| + // ErrResidency: the data is in another region's bucket, or on a |
| + // customer's own fleet box. IAM makes those reads impossible by design, |
| + // so this is the design working rather than a fault to retry. |
| + ErrResidency = errors.New("db: the instance's data is outside this region") |
| + // ErrPooled: a pooled record's data lives on the pool box, the same |
| + // class the instance-move worker refuses. |
| + ErrPooled = errors.New("db: pooled instances have no replica of their own") |
| + // ErrBadHost: path.Join cleans "../x" to "x" rather than refusing it, |
| + // so a host becomes a key segment only after ValidHostShape, and only |
| + // when it is the host the record itself names. |
| + ErrBadHost = errors.New("db: invalid host") |
| +) |
| + |
| +func (s *Server) resolveReplica(rec provision.Record, host string) (string, error) { |
| + if !provision.ValidHostShape(host) || rec.Host != host { |
| + return "", fmt.Errorf("%w: %q", ErrBadHost, host) |
| + } |
| + if rec.Pool != "" { |
| + return "", fmt.Errorf("%w: %s is in pool %q", ErrPooled, host, rec.Pool) |
| + } |
| + // A fleet placement is checked on its own, not left to the region |
| + // compare: the data is on the customer's box whatever this console's |
| + // CARLOS_REGION says. |
| + if _, fleet := registry.FleetPlacement(rec.Region); fleet { |
| + return "", fmt.Errorf("%w: %s is placed on fleet %q", ErrResidency, host, rec.Region) |
| + } |
| + if rec.Region != s.cfg.Region { |
| + return "", fmt.Errorf("%w: %s is homed in %q, this console serves %q", |
| + ErrResidency, host, rec.Region, s.cfg.Region) |
| + } |
| + return activator.HibernatePrefix(s.cfg.HibernatePrefix, host), nil |
| +} |
diff --git a/internal/console/apidb_test.go b/internal/console/apidb_test.go| new file mode 100644 |
| index 000000000..cc5fea1ea |
| --- /dev/null |
| +++ b/internal/console/apidb_test.go |
| @@ -0,0 +1,111 @@ |
| +package console |
| + |
| +import ( |
| + "errors" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/provision" |
| +) |
| + |
| +// Every one of these is refused BEFORE any S3 call. A door that reached the |
| +// bucket and mapped AccessDenied would leak both latency and the fact that |
| +// a bucket was tried. resolveReplica takes no store at all, so it cannot. |
| +func TestResolveReplicaRefusesBeforeTouchingS3(t *testing.T) { |
| + s := &Server{} |
| + s.cfg.Region = "eu-west-1" |
| + s.cfg.HibernatePrefix = "instances" |
| + |
| + cases := []struct { |
| + name string |
| + rec provision.Record |
| + host string |
| + want error |
| + }{ |
| + { |
| + name: "another region", |
| + rec: provision.Record{Host: "a.bab.oncarlos.com", Region: "us-east-1"}, |
| + host: "a.bab.oncarlos.com", |
| + want: ErrResidency, |
| + }, |
| + { |
| + name: "the home region seen from a regional console", |
| + rec: provision.Record{Host: "a.bab.oncarlos.com"}, |
| + host: "a.bab.oncarlos.com", |
| + want: ErrResidency, |
| + }, |
| + { |
| + name: "fleet-homed", |
| + rec: provision.Record{Host: "a.bab.oncarlos.com", Region: "fleet/acme"}, |
| + host: "a.bab.oncarlos.com", |
| + want: ErrResidency, |
| + }, |
| + { |
| + name: "pooled", |
| + rec: provision.Record{Host: "a.bab.oncarlos.com", Region: "eu-west-1", Pool: "shared"}, |
| + host: "a.bab.oncarlos.com", |
| + want: ErrPooled, |
| + }, |
| + { |
| + name: "hostile host", |
| + rec: provision.Record{Host: "../../etc", Region: "eu-west-1"}, |
| + host: "../../etc", |
| + want: ErrBadHost, |
| + }, |
| + { |
| + name: "record for another host", |
| + rec: provision.Record{Host: "b.bab.oncarlos.com", Region: "eu-west-1"}, |
| + host: "a.bab.oncarlos.com", |
| + want: ErrBadHost, |
| + }, |
| + } |
| + for _, tc := range cases { |
| + t.Run(tc.name, func(t *testing.T) { |
| + _, err := s.resolveReplica(tc.rec, tc.host) |
| + if !errors.Is(err, tc.want) { |
| + t.Fatalf("resolveReplica = %v, want %v", err, tc.want) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// A fleet placement is refused even on a console whose own region string |
| +// happened to match it: the data is on the customer's box, whatever a |
| +// misconfigured CARLOS_REGION says. |
| +func TestResolveReplicaRefusesAFleetEvenWhenTheRegionMatches(t *testing.T) { |
| + s := &Server{} |
| + s.cfg.Region = "fleet/acme" |
| + s.cfg.HibernatePrefix = "instances" |
| + if _, err := s.resolveReplica(provision.Record{Host: "a.bab.oncarlos.com", Region: "fleet/acme"}, "a.bab.oncarlos.com"); !errors.Is(err, ErrResidency) { |
| + t.Fatalf("resolveReplica = %v, want ErrResidency", err) |
| + } |
| +} |
| + |
| +// The happy path is an in-region, non-pooled instance, and the prefix is |
| +// the activator's own layout, not a string this package invents. |
| +func TestResolveReplicaBuildsTheActivatorPrefix(t *testing.T) { |
| + s := &Server{} |
| + s.cfg.Region = "eu-west-1" |
| + s.cfg.HibernatePrefix = "instances" |
| + got, err := s.resolveReplica( |
| + provision.Record{Host: "a.bab.oncarlos.com", Region: "eu-west-1"}, |
| + "a.bab.oncarlos.com") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got != "instances/a.bab.oncarlos.com" { |
| + t.Fatalf("prefix = %q, want instances/a.bab.oncarlos.com", got) |
| + } |
| +} |
| + |
| +// A single-region deployment leaves both empty, and "" == "" must be a |
| +// match rather than a refusal, otherwise the door never works anywhere |
| +// that has not set CARLOS_REGION. Home-region records keep region "" too |
| +// (Config.HomeRegion), so this is also the flagship's home console. |
| +func TestResolveReplicaTreatsEmptyRegionsAsAMatch(t *testing.T) { |
| + s := &Server{} |
| + s.cfg.HibernatePrefix = "instances" |
| + if _, err := s.resolveReplica( |
| + provision.Record{Host: "a.bab.oncarlos.com"}, "a.bab.oncarlos.com"); err != nil { |
| + t.Fatalf("single-region deployment refused: %v", err) |
| + } |
| +} |
diff --git a/internal/console/server.go b/internal/console/server.go| index db0eea533..7f91d2001 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -482,6 +482,21 @@ type Config struct { |
| // and nothing about placement changes when it is set. "" is legal: the |
| // regions endpoint then reports no home row and an empty default. |
| HomeRegion string |
| + // HibernateBucket/Region/Prefix name the region data bucket the |
| + // `carlos db` door reads (apidb.go; spec 2026-09-02 §6, Option A): the |
| + // same CARLOS_HIBERNATE_* triple the activator on this box writes |
| + // instances/<host>/ with, read through activator.ConfigFromEnv so the |
| + // two cannot parse it differently. Read-only, in-region, LTX files by |
| + // coordinate only. An empty bucket disables the door (503) rather than |
| + // failing it open. |
| + HibernateBucket string |
| + HibernateRegion string |
| + HibernatePrefix string |
| + // Region is this console's own placement (CARLOS_REGION, the value the |
| + // box agent beside it reconciles with), compared against an instance |
| + // record's Region before any S3 call. Empty on the home region, whose |
| + // records keep region "" too (see HomeRegion). |
| + Region string |
| ReleasePub ed25519.PublicKey // pinned channel-pointer verification key |
| // Key signs channel pointers for promote/rollback (CARLOS_RELEASE_KEY). |
| // nil is a legal deployment: the console runs read-only, the promotion |