console: store declare/status member endpoints + backend config plumbing
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5 files changed,
+816
−10
cmd/console/main.go+83 −10internal/console/apistore.go+287 −0internal/console/apistore_test.go+417 −0internal/console/authcli.go+6 −0internal/console/server.go+23 −0
diff --git a/cmd/console/main.go b/cmd/console/main.go| index 467268d8d..9696def09 100644 |
| --- a/cmd/console/main.go |
| +++ b/cmd/console/main.go |
| @@ -39,6 +39,14 @@ |
| // (comma-separated retired hostnames this console 301/308s to CONSOLE_ORIGIN |
| // — opt-in per host, so a host the API must keep answering on, like |
| // platform.carlosframework.com for CLI tokens, is simply left off). |
| +// |
| +// Object storage (issue #55) is off unless CARLOS_STORE_BACKEND is set, in |
| +// which case it must be "aws" and CARLOS_STORE_REGION and |
| +// CARLOS_STORE_BUCKET_PREFIX are required (CARLOS_STORE_ENDPOINT is optional |
| +// — set it for an S3-compatible backend). CARLOS_STORE_OPERATORS |
| +// (comma-separated addresses) is who may grant, rotate and destroy a store; |
| +// it is read whether or not a backend is configured. |
| +// |
| // grant/revoke/owners need only CONSOLE_DB. |
| package main |
| @@ -55,6 +63,7 @@ import ( |
| "github.com/carlosframework/platform/internal/appconfig" |
| "github.com/carlosframework/platform/internal/console" |
| + "github.com/carlosframework/platform/internal/objstore" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -225,17 +234,21 @@ func serve() { |
| log.Print("console: CARLOS_RELEASE_KEY unset — promotions disabled, running read-only") |
| } |
| st := openConsoleDB() |
| + storeProvisioner, storeDeployment := storeBackend() |
| srv, err := console.NewServer(console.Config{ |
| - Origin: origin, |
| - Keymail: envOr("CONSOLE_KEYMAIL", "https://keymail.dev"), |
| - Registry: envOr("CARLOS_REGISTRY", "/data/registry.db"), |
| - Releases: openStore(), |
| - ReleasePub: pub, |
| - Key: key, |
| - EdgeIPs: edgeIPs(), |
| - SecretsPub: secretsPub(), |
| - AppsDomain: appsDomain(), |
| - RedirectHosts: redirectHosts(), |
| + Origin: origin, |
| + Keymail: envOr("CONSOLE_KEYMAIL", "https://keymail.dev"), |
| + Registry: envOr("CARLOS_REGISTRY", "/data/registry.db"), |
| + Releases: openStore(), |
| + ReleasePub: pub, |
| + Key: key, |
| + EdgeIPs: edgeIPs(), |
| + SecretsPub: secretsPub(), |
| + AppsDomain: appsDomain(), |
| + RedirectHosts: redirectHosts(), |
| + StoreProvisioner: storeProvisioner, |
| + StoreDeployment: storeDeployment, |
| + StoreOperators: storeOperators(), |
| }, st) |
| if err != nil { |
| log.Fatal(err) |
| @@ -331,6 +344,66 @@ func appsDomain() string { |
| return d |
| } |
| +// storeBackend builds the object-storage half of the console's config from |
| +// CARLOS_STORE_* (issue #55): the Provisioner that creates buckets and mints |
| +// keys, and the deployment-wide coordinates every store inherits. Unset |
| +// CARLOS_STORE_BACKEND is a legal deployment — members may still declare |
| +// stores, and the operator doors refuse with a nil provisioner. |
| +// |
| +// EVERY DEFECT IS FATAL HERE, at boot, rather than surfaced as a refusal |
| +// later, and the backend name most of all: a typo'd backend that fell back to |
| +// "unconfigured" would boot a console that answers "this deployment has no |
| +// object storage" to an operator who configured one — the single most |
| +// confusing answer available, on the box least able to explain it. The |
| +// coordinates are fatal for a harder reason: region and bucket prefix are |
| +// stamped into the bucket names and IAM policies this console creates, so a |
| +// console booted without them would create an estate somewhere the operator |
| +// did not choose, and the RECORD would then say that is where the app's data |
| +// lives. CARLOS_STORE_ENDPOINT stays optional: an empty endpoint is what |
| +// "real AWS S3" means (objstore.NewAWSProvisioner). |
| +func storeBackend() (objstore.Provisioner, objstore.Deployment) { |
| + backend := strings.TrimSpace(os.Getenv("CARLOS_STORE_BACKEND")) |
| + if backend == "" { |
| + return nil, objstore.Deployment{} |
| + } |
| + if backend != "aws" { |
| + log.Fatalf("console: CARLOS_STORE_BACKEND %q is not a backend this build knows — the only value is \"aws\" (leave it unset for a deployment with no object storage)", backend) |
| + } |
| + region := strings.TrimSpace(os.Getenv("CARLOS_STORE_REGION")) |
| + if region == "" { |
| + log.Fatal("console: CARLOS_STORE_BACKEND is set — also set CARLOS_STORE_REGION (the region buckets are created in, e.g. eu-west-1)") |
| + } |
| + prefix := strings.TrimSpace(os.Getenv("CARLOS_STORE_BUCKET_PREFIX")) |
| + if prefix == "" { |
| + log.Fatal("console: CARLOS_STORE_BACKEND is set — also set CARLOS_STORE_BUCKET_PREFIX (bucket names are globally unique; e.g. carlos-)") |
| + } |
| + endpoint := strings.TrimSpace(os.Getenv("CARLOS_STORE_ENDPOINT")) |
| + p, err := objstore.NewAWSProvisioner(context.Background(), region, endpoint) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + return p, objstore.Deployment{Backend: backend, Region: region, Endpoint: endpoint, BucketPrefix: prefix} |
| +} |
| + |
| +// storeOperators parses CARLOS_STORE_OPERATORS (comma-separated addresses) — |
| +// who may grant, rotate and destroy an app's store. Read REGARDLESS of the |
| +// backend, and deliberately so: the list is an authority statement about |
| +// people, it costs nothing to hold, and an operator who sets it before wiring |
| +// the backend should not have to remember to come back for it. |
| +// |
| +// Unset is fail-closed rather than fail-open — no operators means no grants — |
| +// which is the right direction for a list whose whole purpose is to gate |
| +// spending money in the deployment's own cloud account. |
| +func storeOperators() []string { |
| + var out []string |
| + for _, a := range strings.Split(os.Getenv("CARLOS_STORE_OPERATORS"), ",") { |
| + if a = strings.TrimSpace(a); a != "" { |
| + out = append(out, a) |
| + } |
| + } |
| + return out |
| +} |
| + |
| // edgeIPs parses PLATFORM_EDGE_IPS (comma-separated) — the SAME variable the |
| // agent's domains reconcile loop reads, so the console's point-your-DNS-here |
| // guidance and the agent's DNS health checks can never name different |
diff --git a/internal/console/apistore.go b/internal/console/apistore.go| new file mode 100644 |
| index 000000000..e1aae5bc6 |
| --- /dev/null |
| +++ b/internal/console/apistore.go |
| @@ -0,0 +1,287 @@ |
| +// The CLI's object-storage API, member half (issue #55): `carlos store |
| +// create` declares that an app wants a bucket, and `carlos store status` |
| +// answers what became of that request. Both behind the same apiAccountApp |
| +// gate as ship/promote/config — an unknown account, an unknown app and |
| +// someone else's account are one byte-identical 404. |
| +// |
| +// THE MEMBER HALF WRITES ONE BLOCK AND READS ALL THREE. A store record has |
| +// three authors (spec §4): the member DECLARES, a deployment operator GRANTS, |
| +// and the console's own walk records what it PROVISIONED. This file only ever |
| +// writes `declared` — nothing here creates a bucket, mints a key or spends a |
| +// cent, which is why declaring is a member action needing nothing but a |
| +// bearer token. The operator doors (Task 9) are a separate authority. |
| +// |
| +// NOTHING SECRET CROSSES THIS DOOR, in either direction. The record holds |
| +// bucket names, an IAM user, and access key IDs; the secret half of a key |
| +// exists only sealed inside the app's config bundle and is never written to |
| +// the record at all (objstore.Record's doc comment). Key IDs are echoed |
| +// deliberately — `status` prints them so a member can tell which credential |
| +// their app is running on, and match it against a rotation. |
| +// |
| +// NO SERIAL BUMP on the write, unlike promote/config/restart: a declaration |
| +// changes nothing a box reads. Delivery happens later, through the app's |
| +// config bundle, and THAT write does the announcing (objstore.Grant). |
| +// |
| +// Spec: docs/superpowers/specs/2026-08-10-object-storage-primitive.md §4, §7. |
| +package console |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + "log" |
| + "net/http" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/objstore" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// storeTimeout bounds every store round trip these handlers make — the same |
| +// 15s its siblings in apicli.go and apiconfig.go use, for the same reason: a |
| +// wedged bucket must cost one request 15 seconds, not a console goroutine |
| +// forever. Each handler derives its context from the request's, so a client |
| +// that hangs up still cancels immediately; this only adds a ceiling. |
| +const storeTimeout = 15 * time.Second |
| + |
| +// storeNoBucket is the 503 both doors give when this console has no |
| +// deployment bucket. The record IS a bucket object: there is nowhere to write |
| +// one and nothing to read one from, and answering "no store is declared" |
| +// instead would be a lie a member could act on (they'd declare again, forever). |
| +func storeNoBucket(w http.ResponseWriter) { |
| + jsonError(w, http.StatusServiceUnavailable, |
| + "no deployment bucket on this host — this console cannot serve object storage") |
| +} |
| + |
| +// storeEffectivePrefix is the prefix a record's variables are delivered |
| +// under. A record with no declaration at all is not a shape this file can |
| +// write — the create door always writes `declared` — but the operator walks |
| +// and hand-repaired records both exist, so the fallback is the same default a |
| +// fresh declaration would take rather than an empty prefix that would render |
| +// variables named "_BUCKET". |
| +func storeEffectivePrefix(rec *objstore.Record) string { |
| + if rec != nil && rec.Declared != nil && rec.Declared.EnvPrefix != "" { |
| + return rec.Declared.EnvPrefix |
| + } |
| + return objstore.DefaultEnvPrefix |
| +} |
| + |
| +// storeReply is the ONE answer shape both doors give, and the one the CLI |
| +// (`carlos store status`) and the operator doors parse. Kept in a single |
| +// function so create and status can never drift into describing the same |
| +// record differently — a create that reported a different `pending_finish` |
| +// from the status call right after it would be worse than no field at all. |
| +// |
| +// The record is echoed whole. Its three blocks are null-or-present, which is |
| +// exactly the tri-state a client needs to say "declared, waiting for an |
| +// operator" apart from "provisioned": an empty object for an absent block |
| +// would make those indistinguishable without inspecting fields. |
| +// |
| +// env_names is the SET OF NAMES this store delivers under its effective |
| +// prefix, in delivery order, and it includes the endpoint name only when the |
| +// store is provisioned WITH one. A deployment that provisions no endpoint |
| +// never writes that variable, so listing it would send someone hunting for a |
| +// variable nothing will ever set. |
| +// |
| +// missing_vars is the same list filtered to what has NOT arrived in the app's |
| +// config bundle, and it is computed ONLY for a provisioned store: before |
| +// provisioning there is no bucket, no key and nothing owed, so reporting four |
| +// missing variables would describe a healthy waiting-for-an-operator store as |
| +// a broken one. Both lists are always present and never null — a client reads |
| +// them rather than testing for absence. |
| +func (s *Server) storeReply(ctx context.Context, acct *Account, app string, rec *objstore.Record) (map[string]any, error) { |
| + prefix := storeEffectivePrefix(rec) |
| + wantEndpoint := rec.Provisioned != nil && rec.Provisioned.Endpoint != "" |
| + bucket, region, endpoint, accessKey, secretKey := objstore.EnvNames(prefix) |
| + names := []string{bucket, region} |
| + if wantEndpoint { |
| + names = append(names, endpoint) |
| + } |
| + names = append(names, accessKey, secretKey) |
| + missing := []string{} |
| + if rec.Provisioned != nil { |
| + got, err := objstore.EnvDelivered(ctx, s.cfg.Releases, acct.Sqid, app, prefix, wantEndpoint) |
| + if err != nil { |
| + return nil, err |
| + } |
| + missing = append(missing, got...) |
| + } |
| + return map[string]any{ |
| + "app": app, |
| + "declared": rec.Declared, |
| + "grant": rec.Grant, |
| + "provisioned": rec.Provisioned, |
| + "env_names": names, |
| + // A rotation that minted a new key but has not yet deleted the old |
| + // one. The member-visible half of §6's two-step: until it is finished, |
| + // two credentials open this bucket. |
| + "pending_finish": rec.Provisioned != nil && rec.Provisioned.PrevKeyID != "", |
| + "missing_vars": missing, |
| + }, nil |
| +} |
| + |
| +// handleAPIStoreCreate answers `carlos store create`: record that this app |
| +// wants object storage, under a chosen (or default) env prefix. It is a |
| +// REQUEST, not a provisioning — nothing exists in the backend when this |
| +// returns 200, and on a deployment with no backend configured at all the |
| +// declaration simply waits for an operator who can grant it. That is |
| +// deliberate: a member cannot see whether their deployment has a backend, and |
| +// making them find out by being refused would teach them nothing they could |
| +// act on, while the declaration itself is what an operator needs to act. |
| +// |
| +// The doors, in order, and why this order — handleAPIRestart's exactly: |
| +// |
| +// token — cliUser's one 401, before any name is resolved. |
| +// account+app 404 — apiAccountApp: an unknown account, someone else's |
| +// account and an app that isn't there are one answer, so |
| +// probing this door teaches a stranger nothing. It also |
| +// means a TRASHED app cannot declare a store: the live-apps |
| +// lookup simply doesn't answer for it. |
| +// rate limit — the shared `write` budget, keyed on the ADDRESS like |
| +// promote's: a budget belongs to the person, not the |
| +// terminal. |
| +// Releases nil — 503. The declaration IS a bucket object; a console with |
| +// no bucket has nowhere to put it and must not answer as |
| +// if it had. |
| +// prefix — refused before the first store call, so a corrected |
| +// retry starts from exactly the state the caller left. |
| +// |
| +// A JSON BODY IS REQUIRED, even to take the default: readJSON's |
| +// content-type-and-decode is shared by every CLI door, so `{}` is the empty |
| +// request here — not a zero-length body, which decodes as malformed like |
| +// anywhere else. |
| +// |
| +// IDEMPOTENT by design: re-declaring the same prefix refreshes `at`/`by` and |
| +// answers 200. `carlos store create` is a thing people re-run — after a failed |
| +// grant, from a second terminal, from a deploy script — and turning the second |
| +// run into an error would make the safe verb the scary one. |
| +func (s *Server) handleAPIStoreCreate(w http.ResponseWriter, r *http.Request) { |
| + addr, acct, app, ok := s.apiAccountApp(w, r) |
| + if !ok { |
| + return |
| + } |
| + if !s.cli.write.allow(addr, time.Now()) { |
| + jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") |
| + return |
| + } |
| + if s.cfg.Releases == nil { |
| + storeNoBucket(w) |
| + return |
| + } |
| + var req struct { |
| + EnvPrefix string `json:"env_prefix"` |
| + } |
| + if !readJSON(w, r, &req) { |
| + return |
| + } |
| + prefix := strings.TrimSpace(req.EnvPrefix) |
| + if prefix == "" { |
| + prefix = objstore.DefaultEnvPrefix |
| + } |
| + if !objstore.ValidEnvPrefix(prefix) { |
| + // The charset is named rather than described: the caller has to be |
| + // able to fix their own prefix from this sentence alone, and the |
| + // regexp is shorter than the English for it. |
| + jsonError(w, http.StatusBadRequest, |
| + fmt.Sprintf("invalid env prefix %q — want ^[A-Z][A-Z0-9_]{0,31}$ (e.g. %s)", req.EnvPrefix, objstore.DefaultEnvPrefix)) |
| + return |
| + } |
| + ctx, cancel := context.WithTimeout(r.Context(), storeTimeout) |
| + defer cancel() |
| + rec, etag, err := objstore.Get(ctx, s.cfg.Releases, acct.Sqid, app) |
| + if err != nil { |
| + // Logged, not returned: a store error carries bucket names, endpoints |
| + // and request ids. The caller gets the fixed sentence, and a retry is |
| + // safe — nothing was written. |
| + log.Printf("console: api store read %s/%s: %v", acct.Sqid, app, err) |
| + jsonError(w, http.StatusBadGateway, "could not read the store record — try again") |
| + return |
| + } |
| + if rec == nil { |
| + rec = &objstore.Record{} |
| + } |
| + // THE PREFIX IS FIXED ONCE A BUCKET EXISTS. The delivered names are what a |
| + // RUNNING app reads; renaming them would leave it looking for variables |
| + // nothing will write again, while the old ones linger in its config |
| + // pointing at a live credential. The refusal names the only honest way out |
| + // rather than pretending this is a retry-able race. |
| + if rec.Provisioned != nil && storeEffectivePrefix(rec) != prefix { |
| + jsonError(w, http.StatusConflict, |
| + "store already provisioned — the env prefix is fixed; destroy and re-create to change it") |
| + return |
| + } |
| + rec.Declared = &objstore.Declared{EnvPrefix: prefix, At: time.Now().UTC(), By: addr} |
| + if _, err := objstore.Put(ctx, s.cfg.Releases, acct.Sqid, app, rec, etag); err != nil { |
| + if errors.Is(err, release.ErrCASConflict) { |
| + // Someone else wrote this record between our read and our write — |
| + // plausibly the operator's grant landing. Re-read and re-ask; this |
| + // door deliberately does not retry, because the thing it would be |
| + // overwriting is the half it doesn't own. |
| + jsonError(w, http.StatusConflict, "the store record changed underneath you — try again") |
| + return |
| + } |
| + log.Printf("console: api store create %s/%s: %v", acct.Sqid, app, err) |
| + jsonError(w, http.StatusBadGateway, "could not record the store — nothing was declared; try again") |
| + return |
| + } |
| + // Audited, unlike a read: this is the request an operator will act on by |
| + // spending money in the deployment's own cloud account, so who asked and |
| + // under what prefix has to be visible to anyone reading rows. |
| + s.audit(acct, app, addr, "store-create", "declared a store with env prefix "+prefix+" (cli)") |
| + // No bumpSerial: nothing a box reads has changed. See the file comment. |
| + out, err := s.storeReply(ctx, acct, app, rec) |
| + if err != nil { |
| + log.Printf("console: api store reply %s/%s: %v", acct.Sqid, app, err) |
| + jsonError(w, http.StatusBadGateway, "the store was declared, but its status could not be read — try `carlos store status`") |
| + return |
| + } |
| + writeJSON(w, http.StatusOK, out) |
| +} |
| + |
| +// handleAPIStoreGet answers `carlos store status`: the whole record plus the |
| +// two things a member with no box access cannot see any other way — which |
| +// delivered variables have not arrived in the app's config, and whether a |
| +// rotation is still half-done. |
| +// |
| +// THE 404 HERE SPEAKS, and that is not a weakening of the gate above it. It |
| +// fires only AFTER apiAccountApp has proved the caller is a member of the |
| +// account and that the app exists, so the sentence tells them nothing they |
| +// were not already entitled to. apiAccountApp's OWN refusals stay |
| +// byte-identical with http.NotFound's, which is what keeps a stranger from |
| +// learning that an account, an app or a store exists at all. |
| +// |
| +// Not rate limited, matching every other read door: an exhausted write budget |
| +// must not blind a member to the state of what they already asked for. |
| +func (s *Server) handleAPIStoreGet(w http.ResponseWriter, r *http.Request) { |
| + _, acct, app, ok := s.apiAccountApp(w, r) |
| + if !ok { |
| + return |
| + } |
| + if s.cfg.Releases == nil { |
| + storeNoBucket(w) |
| + return |
| + } |
| + ctx, cancel := context.WithTimeout(r.Context(), storeTimeout) |
| + defer cancel() |
| + rec, _, err := objstore.Get(ctx, s.cfg.Releases, acct.Sqid, app) |
| + if err != nil { |
| + log.Printf("console: api store status %s/%s: %v", acct.Sqid, app, err) |
| + jsonError(w, http.StatusBadGateway, "could not read the store record — try again") |
| + return |
| + } |
| + if rec == nil { |
| + // A state, not a failure — and a 404 rather than an empty record, |
| + // because "no store" and "a store with nothing filled in yet" are |
| + // genuinely different things for the CLI to print. |
| + jsonError(w, http.StatusNotFound, "no store is declared for this app — run `carlos store create`") |
| + return |
| + } |
| + out, err := s.storeReply(ctx, acct, app, rec) |
| + if err != nil { |
| + log.Printf("console: api store delivery %s/%s: %v", acct.Sqid, app, err) |
| + jsonError(w, http.StatusBadGateway, "could not read the store's config delivery — try again") |
| + return |
| + } |
| + writeJSON(w, http.StatusOK, out) |
| +} |
diff --git a/internal/console/apistore_test.go b/internal/console/apistore_test.go| new file mode 100644 |
| index 000000000..86d87c813 |
| --- /dev/null |
| +++ b/internal/console/apistore_test.go |
| @@ -0,0 +1,417 @@ |
| +package console |
| + |
| +// The member half of the object-storage door (issue #55): declare a store, |
| +// then ask what became of it. What is pinned here, in the order it would hurt |
| +// if lost: a stranger probing either door learns nothing (the byte-identical |
| +// 404 the whole /api/cli surface keeps); the env prefix a provisioned store |
| +// delivers under cannot be changed out from under the app; and `status` tells |
| +// the truth about delivery — which variables have NOT arrived, and whether a |
| +// rotation is still half-done — because that is the only sight a member with |
| +// no box access has of either. |
| + |
| +import ( |
| + "context" |
| + "net/http" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/appconfig" |
| + "github.com/carlosframework/platform/internal/objstore" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func storeFixture(t *testing.T, code string) (*consoleFixture, string) { |
| + t.Helper() |
| + fx := newConsoleFixture(t) |
| + now := time.Now() |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + // A MEMBER, not an owner: declaring your own app's store is a member |
| + // action, like promote below stable — the operator half (grant, rotate, |
| + // destroy) is a different door with a different authority. |
| + if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, now); err != nil { |
| + t.Fatal(err) |
| + } |
| + return fx, cliToken(t, fx, "alice@keymail.dev", code) |
| +} |
| + |
| +func storeURL(fx *consoleFixture, app string) string { |
| + return fx.ts.URL + "/api/cli/apps/bab/" + app + "/store" |
| +} |
| + |
| +// storedRecord reads the record straight out of the bucket — the check that a |
| +// 200 actually landed, and that a refusal left nothing behind. nil means no |
| +// object at all. |
| +func storedRecord(t *testing.T, fx *consoleFixture, app string) *objstore.Record { |
| + t.Helper() |
| + rec, _, err := objstore.Get(context.Background(), fx.rel, release.OpsAccount, app) |
| + if err != nil { |
| + t.Fatalf("read store record: %v", err) |
| + } |
| + return rec |
| +} |
| + |
| +// putRecord hand-writes a record the console itself would only reach through |
| +// the operator doors (Task 9's grant/rotate) — the state this door has to |
| +// REPORT on, and refuse to contradict, long before that door exists. |
| +func putRecord(t *testing.T, fx *consoleFixture, app string, rec *objstore.Record) { |
| + t.Helper() |
| + if _, err := objstore.Put(context.Background(), fx.rel, release.OpsAccount, app, rec, ""); err != nil { |
| + t.Fatalf("seed store record: %v", err) |
| + } |
| +} |
| + |
| +func strList(t *testing.T, out map[string]any, key string) []string { |
| + t.Helper() |
| + raw, ok := out[key].([]any) |
| + if !ok { |
| + t.Fatalf("%s = %#v, want a list", key, out[key]) |
| + } |
| + got := make([]string, len(raw)) |
| + for i, v := range raw { |
| + s, ok := v.(string) |
| + if !ok { |
| + t.Fatalf("%s[%d] = %#v, want a string", key, i, v) |
| + } |
| + got[i] = s |
| + } |
| + return got |
| +} |
| + |
| +func block(t *testing.T, out map[string]any, key string) map[string]any { |
| + t.Helper() |
| + m, ok := out[key].(map[string]any) |
| + if !ok { |
| + t.Fatalf("%s = %#v, want an object", key, out[key]) |
| + } |
| + return m |
| +} |
| + |
| +// ---- declare ---------------------------------------------------------------- |
| + |
| +// TestAPIStoreDeclareWritesTheRecord: the whole happy path. An empty body |
| +// means the default prefix, the record lands in the bucket with the caller as |
| +// its author, the answer names the variables the app will eventually be given, |
| +// and NO serial is bumped — nothing a box reads has changed, so waking the |
| +// fleet would be noise. |
| +func TestAPIStoreDeclareWritesTheRecord(t *testing.T) { |
| + fx, token := storeFixture(t, "code-store-declare") |
| + if rec := storedRecord(t, fx, "titogo"); rec != nil { |
| + t.Fatalf("a store record exists before any declaration: %+v", rec) |
| + } |
| + |
| + status, out := postJSON(t, storeURL(fx, "titogo"), map[string]any{}, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("declare = %d %v, want 200", status, out) |
| + } |
| + if str(t, out, "app") != "titogo" { |
| + t.Fatalf("app = %v, want titogo", out["app"]) |
| + } |
| + declared := block(t, out, "declared") |
| + if declared["env_prefix"] != objstore.DefaultEnvPrefix { |
| + t.Fatalf("env_prefix = %v, want the default %q", declared["env_prefix"], objstore.DefaultEnvPrefix) |
| + } |
| + if declared["by"] != "alice@keymail.dev" { |
| + t.Fatalf("declared.by = %v, want the caller", declared["by"]) |
| + } |
| + if out["grant"] != nil || out["provisioned"] != nil { |
| + t.Fatalf("grant/provisioned = %v/%v, want null on a fresh declaration", out["grant"], out["provisioned"]) |
| + } |
| + // No endpoint name: this deployment provisions none, and a name the app |
| + // will never be given would send someone hunting for a missing variable. |
| + wantNames := []string{"CARLOS_STORE_BUCKET", "CARLOS_STORE_REGION", "CARLOS_STORE_ACCESS_KEY", "CARLOS_STORE_SECRET_KEY"} |
| + if got := strList(t, out, "env_names"); strings.Join(got, ",") != strings.Join(wantNames, ",") { |
| + t.Fatalf("env_names = %v, want %v", got, wantNames) |
| + } |
| + if out["pending_finish"] != false { |
| + t.Fatalf("pending_finish = %v, want false", out["pending_finish"]) |
| + } |
| + if got := strList(t, out, "missing_vars"); len(got) != 0 { |
| + t.Fatalf("missing_vars = %v, want empty until the store is provisioned", got) |
| + } |
| + |
| + rec := storedRecord(t, fx, "titogo") |
| + if rec == nil || rec.Declared == nil { |
| + t.Fatalf("record after a 200 = %+v, want a declaration in the bucket", rec) |
| + } |
| + if rec.App != "titogo" || rec.Schema != objstore.Schema { |
| + t.Fatalf("record = %+v, want app titogo at schema %d", rec, objstore.Schema) |
| + } |
| + if rec.Declared.EnvPrefix != objstore.DefaultEnvPrefix || rec.Declared.By != "alice@keymail.dev" { |
| + t.Fatalf("record declaration = %+v", rec.Declared) |
| + } |
| + // Nothing box-visible changed: no version moved, no config was written. |
| + if got := serial(t, fx); got != 0 { |
| + t.Fatalf("serial after a declaration = %d, want 0 — declaring delivers nothing to a box", got) |
| + } |
| + auditedBy(t, fx, "titogo", "store-create", "alice@keymail.dev", objstore.DefaultEnvPrefix) |
| +} |
| + |
| +// TestAPIStoreDeclareHonoursThePrefixAndIsIdempotent: a chosen prefix renames |
| +// every delivered variable, and re-declaring the SAME prefix is a refresh, not |
| +// a conflict — `carlos store create` has to be safe to run twice, including by |
| +// a second member of the same account. |
| +func TestAPIStoreDeclareHonoursThePrefixAndIsIdempotent(t *testing.T) { |
| + fx, token := storeFixture(t, "code-store-prefix") |
| + |
| + status, out := postJSON(t, storeURL(fx, "titogo"), map[string]any{"env_prefix": "BLOBS"}, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("declare = %d %v", status, out) |
| + } |
| + wantNames := []string{"BLOBS_BUCKET", "BLOBS_REGION", "BLOBS_ACCESS_KEY", "BLOBS_SECRET_KEY"} |
| + if got := strList(t, out, "env_names"); strings.Join(got, ",") != strings.Join(wantNames, ",") { |
| + t.Fatalf("env_names = %v, want %v", got, wantNames) |
| + } |
| + |
| + if err := fx.st.AddMember(OpsAccountID, "bob@keymail.dev", RoleMember, time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + bob := cliToken(t, fx, "bob@keymail.dev", "code-store-prefix-bob") |
| + status, out = postJSON(t, storeURL(fx, "titogo"), map[string]any{"env_prefix": "BLOBS"}, bob) |
| + if status != http.StatusOK { |
| + t.Fatalf("same-prefix re-declare = %d %v, want 200", status, out) |
| + } |
| + rec := storedRecord(t, fx, "titogo") |
| + if rec == nil || rec.Declared == nil || rec.Declared.EnvPrefix != "BLOBS" { |
| + t.Fatalf("record after re-declare = %+v", rec) |
| + } |
| + // Refreshed, not merely tolerated: the trail says who asked most recently. |
| + if rec.Declared.By != "bob@keymail.dev" { |
| + t.Fatalf("declared.by = %q, want the second caller", rec.Declared.By) |
| + } |
| +} |
| + |
| +// TestAPIStoreDeclareRefusesAnInvalidPrefix: the refusal names the charset, |
| +// because a caller who typed a lowercase prefix has to learn the rule from the |
| +// answer — and nothing is written, so a corrected retry starts from nothing. |
| +func TestAPIStoreDeclareRefusesAnInvalidPrefix(t *testing.T) { |
| + fx, token := storeFixture(t, "code-store-badprefix") |
| + |
| + for _, bad := range []string{"blobs", "9BLOBS", "BLOBS-X", strings.Repeat("A", 33)} { |
| + status, out := postJSON(t, storeURL(fx, "titogo"), map[string]any{"env_prefix": bad}, token) |
| + if status != http.StatusBadRequest { |
| + t.Fatalf("declare %q = %d %v, want 400", bad, status, out) |
| + } |
| + if !strings.Contains(str(t, out, "error"), "^[A-Z][A-Z0-9_]{0,31}$") { |
| + t.Fatalf("declare %q error = %q, want the charset named", bad, out["error"]) |
| + } |
| + if rec := storedRecord(t, fx, "titogo"); rec != nil { |
| + t.Fatalf("a refused declaration wrote a record: %+v", rec) |
| + } |
| + } |
| +} |
| + |
| +// TestAPIStorePrefixIsFixedOnceProvisioned: the delivered variable names are |
| +// what a RUNNING app reads. Renaming them under a provisioned store would |
| +// leave the app looking for variables nothing will ever write again, so the |
| +// door refuses and says how to actually change it. Same prefix still passes: |
| +// a re-run of `carlos store create` must not become an error the day the |
| +// store gets provisioned. |
| +func TestAPIStorePrefixIsFixedOnceProvisioned(t *testing.T) { |
| + fx, token := storeFixture(t, "code-store-locked") |
| + now := time.Now() |
| + putRecord(t, fx, "titogo", &objstore.Record{ |
| + Declared: &objstore.Declared{EnvPrefix: "BLOBS", At: now, By: "alice@keymail.dev"}, |
| + Grant: &objstore.GrantBlock{Backend: "aws", At: now, By: "paul@keymail.dev"}, |
| + Provisioned: &objstore.Provisioned{Bucket: "carlos-bab-titogo", Region: "eu-west-1", IAMUser: "carlos-store-bab-titogo", KeyID: "AKIAEXAMPLE", At: now}, |
| + }) |
| + |
| + status, out := postJSON(t, storeURL(fx, "titogo"), map[string]any{"env_prefix": "OTHER"}, token) |
| + if status != http.StatusConflict { |
| + t.Fatalf("re-declare with a new prefix = %d %v, want 409", status, out) |
| + } |
| + if msg := str(t, out, "error"); !strings.Contains(msg, "destroy and re-create") { |
| + t.Fatalf("error = %q, want the way out named", msg) |
| + } |
| + rec := storedRecord(t, fx, "titogo") |
| + if rec.Declared.EnvPrefix != "BLOBS" { |
| + t.Fatalf("a refused re-declare changed the prefix to %q", rec.Declared.EnvPrefix) |
| + } |
| + |
| + status, out = postJSON(t, storeURL(fx, "titogo"), map[string]any{"env_prefix": "BLOBS"}, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("same-prefix re-declare on a provisioned store = %d %v, want 200", status, out) |
| + } |
| + // The provisioning survives the refresh — the reply echoes the whole |
| + // record, not just the half this door writes. |
| + if got := block(t, out, "provisioned")["bucket"]; got != "carlos-bab-titogo" { |
| + t.Fatalf("provisioned.bucket = %v after a re-declare", got) |
| + } |
| + if rec := storedRecord(t, fx, "titogo"); rec.Provisioned == nil || rec.Provisioned.KeyID != "AKIAEXAMPLE" { |
| + t.Fatalf("re-declare clobbered the provisioning: %+v", rec) |
| + } |
| +} |
| + |
| +// ---- status ----------------------------------------------------------------- |
| + |
| +// TestAPIStoreStatusIs404BeforeAnyDeclaration: an app with no store answers |
| +// 404 — but a SPEAKING one. This 404 fires only after the member gate has |
| +// passed, so the caller is entitled to the sentence; the gate's own refusals |
| +// (below) stay byte-identical with the stdlib's. |
| +func TestAPIStoreStatusIs404BeforeAnyDeclaration(t *testing.T) { |
| + fx, token := storeFixture(t, "code-store-absent") |
| + |
| + status, out := getJSON(t, storeURL(fx, "titogo"), token) |
| + if status != http.StatusNotFound { |
| + t.Fatalf("status with no store = %d %v, want 404", status, out) |
| + } |
| + if msg := str(t, out, "error"); !strings.Contains(msg, "no store is declared") { |
| + t.Fatalf("status error = %q, want it to say no store is declared", msg) |
| + } |
| +} |
| + |
| +// TestAPIStoreStatusReportsDeliveryAndPendingFinish: the two facts a member |
| +// with no box access cannot see any other way — which delivered variables have |
| +// NOT arrived in the app's config, and whether a rotation is still holding the |
| +// old key alive. missing_vars is computed against the app's real manifest, so |
| +// a half-delivered store reads as half-delivered rather than "provisioned". |
| +func TestAPIStoreStatusReportsDeliveryAndPendingFinish(t *testing.T) { |
| + fx, token := storeFixture(t, "code-store-status") |
| + now := time.Now() |
| + putRecord(t, fx, "titogo", &objstore.Record{ |
| + Declared: &objstore.Declared{EnvPrefix: "BLOBS", At: now, By: "alice@keymail.dev"}, |
| + Grant: &objstore.GrantBlock{Backend: "aws", At: now, By: "paul@keymail.dev"}, |
| + Provisioned: &objstore.Provisioned{ |
| + Bucket: "carlos-bab-titogo", Region: "eu-west-1", Endpoint: "https://s3.example.com", |
| + IAMUser: "carlos-store-bab-titogo", UserARN: "arn:aws:iam::1:user/carlos-store-bab-titogo", |
| + KeyID: "AKIANEW", PrevKeyID: "AKIAOLD", At: now, |
| + }, |
| + }) |
| + // Only the bucket coordinate ever landed in the app's config. |
| + if _, err := appconfig.Apply(context.Background(), fx.rel, release.OpsAccount, "titogo", |
| + map[string]appconfig.Var{"BLOBS_BUCKET": {Kind: "env", Value: "carlos-bab-titogo"}}, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + status, out := getJSON(t, storeURL(fx, "titogo"), token) |
| + if status != http.StatusOK { |
| + t.Fatalf("status = %d %v", status, out) |
| + } |
| + prov := block(t, out, "provisioned") |
| + if prov["key_id"] != "AKIANEW" || prov["prev_key_id"] != "AKIAOLD" { |
| + t.Fatalf("provisioned = %v, want both key ids echoed (they are not secrets)", prov) |
| + } |
| + if out["pending_finish"] != true { |
| + t.Fatalf("pending_finish = %v, want true while a previous key is still live", out["pending_finish"]) |
| + } |
| + // The endpoint name appears only because this store HAS an endpoint. |
| + wantNames := []string{"BLOBS_BUCKET", "BLOBS_REGION", "BLOBS_ENDPOINT", "BLOBS_ACCESS_KEY", "BLOBS_SECRET_KEY"} |
| + if got := strList(t, out, "env_names"); strings.Join(got, ",") != strings.Join(wantNames, ",") { |
| + t.Fatalf("env_names = %v, want %v", got, wantNames) |
| + } |
| + wantMissing := []string{"BLOBS_REGION", "BLOBS_ENDPOINT", "BLOBS_ACCESS_KEY", "BLOBS_SECRET_KEY"} |
| + if got := strList(t, out, "missing_vars"); strings.Join(got, ",") != strings.Join(wantMissing, ",") { |
| + t.Fatalf("missing_vars = %v, want %v", got, wantMissing) |
| + } |
| +} |
| + |
| +// TestAPIStoreStatusOfADeclaredButUnprovisionedStore: no bucket exists yet, so |
| +// there is nothing to be missing — an app that has merely ASKED for a store |
| +// must not read as four undelivered variables, which is what a caller would |
| +// chase down. |
| +func TestAPIStoreStatusOfADeclaredButUnprovisionedStore(t *testing.T) { |
| + fx, token := storeFixture(t, "code-store-declared-only") |
| + if status, out := postJSON(t, storeURL(fx, "titogo"), map[string]any{}, token); status != http.StatusOK { |
| + t.Fatalf("declare = %d %v", status, out) |
| + } |
| + |
| + status, out := getJSON(t, storeURL(fx, "titogo"), token) |
| + if status != http.StatusOK { |
| + t.Fatalf("status = %d %v", status, out) |
| + } |
| + if got := strList(t, out, "missing_vars"); len(got) != 0 { |
| + t.Fatalf("missing_vars = %v, want empty for a store nobody has provisioned", got) |
| + } |
| + if out["pending_finish"] != false { |
| + t.Fatalf("pending_finish = %v, want false", out["pending_finish"]) |
| + } |
| +} |
| + |
| +// ---- the gate --------------------------------------------------------------- |
| + |
| +// TestAPIStoreGateIsByteIdentical: on BOTH doors, a token that belongs to no |
| +// account, an account segment that doesn't exist and an app the account never |
| +// claimed are one and the same 404 — byte-identical to http.NotFound's own |
| +// output, so probing this API teaches a token holder nothing about which |
| +// accounts or apps exist, and never that one has a store. |
| +func TestAPIStoreGateIsByteIdentical(t *testing.T) { |
| + fx, token := storeFixture(t, "code-store-gate") |
| + eve := cliToken(t, fx, "eve@keymail.dev", "code-store-gate-eve") |
| + |
| + doors := []struct { |
| + name string |
| + call func(u, bearer string) (int, string) |
| + }{ |
| + {"GET", func(u, bearer string) (int, string) { return getRaw(t, u, bearer) }}, |
| + {"POST", func(u, bearer string) (int, string) { |
| + return postRaw(t, u, map[string]any{"env_prefix": "BLOBS"}, bearer) |
| + }}, |
| + } |
| + for _, d := range doors { |
| + t.Run(d.name, func(t *testing.T) { |
| + // (a) a member of nothing, against a real account and a real app. |
| + statusA, bodyA := d.call(storeURL(fx, "titogo"), eve) |
| + // (b) a member, against an account that does not exist. |
| + statusB, bodyB := d.call(fx.ts.URL+"/api/cli/apps/zzzz/titogo/store", token) |
| + // (c) a member of the account, against an app it never claimed. |
| + statusC, bodyC := d.call(storeURL(fx, "ghost-app"), token) |
| + if statusA != http.StatusNotFound || statusB != http.StatusNotFound || statusC != http.StatusNotFound { |
| + t.Fatalf("%s gate = %d / %d / %d, want 404 for all three", d.name, statusA, statusB, statusC) |
| + } |
| + if bodyA != bodyB || bodyB != bodyC { |
| + t.Fatalf("%s refusals are not byte-identical:\n(a) %q\n(b) %q\n(c) %q", d.name, bodyA, bodyB, bodyC) |
| + } |
| + if bodyA != "404 page not found\n" { |
| + t.Fatalf("%s refusal body = %q, want the stdlib http.NotFound output", d.name, bodyA) |
| + } |
| + }) |
| + } |
| + if rec := storedRecord(t, fx, "titogo"); rec != nil { |
| + t.Fatalf("a refused request wrote a record: %+v", rec) |
| + } |
| +} |
| + |
| +// TestAPIStoreNoTokenIs401: the token gate is first, before any name is |
| +// resolved — cliUser's one 401 on both doors. |
| +func TestAPIStoreNoTokenIs401(t *testing.T) { |
| + fx, _ := storeFixture(t, "code-store-notoken") |
| + |
| + if status, _ := getJSON(t, storeURL(fx, "titogo"), ""); status != http.StatusUnauthorized { |
| + t.Fatalf("status with no token = %d, want 401", status) |
| + } |
| + if status, _ := postJSON(t, storeURL(fx, "titogo"), map[string]any{}, ""); status != http.StatusUnauthorized { |
| + t.Fatalf("declare with no token = %d, want 401", status) |
| + } |
| +} |
| + |
| +// TestAPIStoreDeclareIsRateLimited: the shared write budget, keyed on the |
| +// ADDRESS like every other write door here — a budget belongs to the person, |
| +// not the terminal they typed on. |
| +func TestAPIStoreDeclareIsRateLimited(t *testing.T) { |
| + fx, token := storeFixture(t, "code-store-limit") |
| + fx.srv.cli.write = newRateLimiter(1, time.Minute) |
| + |
| + if status, out := postJSON(t, storeURL(fx, "titogo"), map[string]any{}, token); status != http.StatusOK { |
| + t.Fatalf("first declare = %d %v", status, out) |
| + } |
| + if status, out := postJSON(t, storeURL(fx, "titogo"), map[string]any{}, token); status != http.StatusTooManyRequests { |
| + t.Fatalf("second declare on an exhausted budget = %d %v, want 429", status, out) |
| + } |
| +} |
| + |
| +// TestAPIStoreWithoutABucketIs503: the record IS a bucket object. A console |
| +// with nowhere to put it — or to read it from — must say so rather than |
| +// answer for a store it cannot see. |
| +func TestAPIStoreWithoutABucketIs503(t *testing.T) { |
| + fx, token := storeFixture(t, "code-store-nobucket") |
| + fx.srv.cfg.Releases = nil |
| + |
| + if status, out := postJSON(t, storeURL(fx, "titogo"), map[string]any{}, token); status != http.StatusServiceUnavailable { |
| + t.Fatalf("declare with no bucket = %d %v, want 503", status, out) |
| + } |
| + if status, out := getJSON(t, storeURL(fx, "titogo"), token); status != http.StatusServiceUnavailable { |
| + t.Fatalf("status with no bucket = %d %v, want 503", status, out) |
| + } |
| +} |
diff --git a/internal/console/authcli.go b/internal/console/authcli.go| index 168d53380..6dd477e03 100644 |
| --- a/internal/console/authcli.go |
| +++ b/internal/console/authcli.go |
| @@ -280,6 +280,12 @@ func (s *Server) registerCLIRoutes() { |
| s.mux.HandleFunc("GET /api/cli/apps/{account}/{app}/config", s.handleAPIConfigGet) |
| s.mux.HandleFunc("PUT /api/cli/apps/{account}/{app}/config", s.handleAPIConfigPut) |
| s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/config/sync", s.handleAPIConfigSync) |
| + // The object-storage API, member half (apistore.go, issue #55) — declare |
| + // a store and read what became of it. Beside the config routes on |
| + // purpose: a store's credentials are DELIVERED as config, so these two |
| + // doors are two halves of the same story for whoever reads this list. |
| + s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/store", s.handleAPIStoreCreate) |
| + s.mux.HandleFunc("GET /api/cli/apps/{account}/{app}/store", s.handleAPIStoreGet) |
| } |
| // clientIP is who the rate limiter counts against. |
diff --git a/internal/console/server.go b/internal/console/server.go| index 931103b06..03a6a4fd8 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -24,6 +24,7 @@ import ( |
| "github.com/carlosframework/platform/internal/applog" |
| "github.com/carlosframework/platform/internal/domains" |
| + "github.com/carlosframework/platform/internal/objstore" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/release" |
| "github.com/carlosframework/platform/internal/status" |
| @@ -190,6 +191,28 @@ type Config struct { |
| // host-agnostic, and POSTs through an unexpected redirect are how |
| // clients break) simply aren't listed. |
| RedirectHosts []string |
| + // StoreProvisioner is the backend the object-storage doors provision |
| + // through (issue #55), built at boot from CARLOS_STORE_BACKEND. nil is an |
| + // ordinary deployment shape, not a broken one: members may still DECLARE |
| + // a store — a declaration is a request an operator fulfils, and a member |
| + // cannot see whether a backend exists — while the operator doors that |
| + // create buckets and mint keys refuse. The console holds the identity |
| + // that MINTS app credentials and never an app's own. |
| + StoreProvisioner objstore.Provisioner |
| + // StoreDeployment is the deployment-wide half of every store: which |
| + // backend, which region and endpoint buckets are made in, and the prefix |
| + // minted bucket names carry. Threaded into every walk so this config is |
| + // the single place that knows where this deployment's object estate |
| + // lives — a walk that guessed would create the next bucket somewhere |
| + // else, and the record would say so afterwards. |
| + StoreDeployment objstore.Deployment |
| + // StoreOperators are the addresses allowed to drive the grant, rotate and |
| + // destroy doors (CARLOS_STORE_OPERATORS). Its own list rather than |
| + // console ownership on purpose: provisioning spends real money in the |
| + // DEPLOYMENT's cloud account, so the authority belongs to whoever runs |
| + // the deployment, not to a tenant who owns an account inside it. Empty is |
| + // fail-closed — nobody may grant. |
| + StoreOperators []string |
| } |
| // Server is the console's http.Handler. |