Merge pull request #408 from carlosframework/per-instance-store
Per-environment object storage: a store an instance can own
26 files changed,
+2221
−213
apimode.go+35 −11docs/site/cli.md+35 −0docs/superpowers/specs/2026-09-19-per-environment-store-design.md+253 −0internal/console/apiscan_test.go+4 −5internal/console/apistore.go+136 −25internal/console/apistore_env_test.go+146 −0internal/console/apistore_envonly_test.go+52 −0internal/console/apistore_provision.go+9 −5internal/console/apistore_provision_test.go+1 −1internal/console/appsettings.go+177 −15internal/console/appsettings_env_test.go+88 −0internal/console/economicspass.go+32 −13internal/console/ownership.go+84 −40internal/console/server.go+1 −0internal/console/store_env_estate_test.go+187 −0internal/console/storelock.go+18 −8internal/console/storelock_test.go+32 −10internal/console/templates/app_settings.html+85 −0internal/objstore/objstore.go+107 −5internal/objstore/walk.go+187 −40internal/objstore/walk_env_test.go+304 −0internal/release/trash.go+30 −6store.go+108 −16store_env_guard_test.go+51 −0store_env_print_test.go+46 −0store_test.go+13 −13
diff --git a/apimode.go b/apimode.go| index 7e8d0cd6f..737cbe2e7 100644 |
| --- a/apimode.go |
| +++ b/apimode.go |
| @@ -422,6 +422,21 @@ func apiPath(a apiAcct, app, tail string) string { |
| return p |
| } |
| +// storeEnvTail names the environment a store verb is aimed at, as the query |
| +// parameter every store door reads. "" is the app's own store and adds |
| +// nothing at all, so this CLI's request to an older console is byte-identical |
| +// to the one it has always sent. |
| +// |
| +// It goes in the TAIL rather than after apiPath, because apiPath appends |
| +// `exact=sqid` with its own ?/& check — building the query here keeps one |
| +// place deciding how these paths are punctuated. |
| +func storeEnvTail(tail, env string) string { |
| + if env == "" { |
| + return tail |
| + } |
| + return tail + "?environment=" + url.QueryEscape(env) |
| +} |
| + |
| func apiAccountStorePath(a apiAcct, tail string) string { |
| p := "/api/cli/accounts/" + url.PathEscape(a.Seg) + "/store/" + tail |
| if a.Exact { |
| @@ -1644,7 +1659,16 @@ type apiStoreProvisioned struct { |
| } |
| type apiStoreReply struct { |
| - App string `json:"app"` |
| + App string `json:"app"` |
| + // Environment is the config environment this store belongs to, "" for the |
| + // app's own. Always sent by a console that knows about environment |
| + // stores; absent from an older one's reply, which decodes as "" — the |
| + // only store such a console has. |
| + Environment string `json:"environment"` |
| + // Environments names every environment this app has a store for, and is |
| + // sent ONLY on a reply about the app's own store. A reply aimed at one |
| + // environment answers about that environment and lists nothing. |
| + Environments []string `json:"environments,omitempty"` |
| Backend string `json:"backend"` |
| Declared *apiStoreDeclared `json:"declared"` |
| Grant *apiStoreGrantBlock `json:"grant"` |
| @@ -1948,11 +1972,11 @@ func (s *apiSession) scanEnforce(ctx context.Context, a apiAcct, app string, on |
| // is "", so a bare `carlos store create --app x` sends the literal `{}` body |
| // handleAPIStoreCreate documents as its own default case — never a |
| // zero-length body, which that door's readJSON would refuse as malformed. |
| -func (s *apiSession) storeCreate(ctx context.Context, a apiAcct, app, envPrefix string) (apiStoreReply, error) { |
| +func (s *apiSession) storeCreate(ctx context.Context, a apiAcct, app, env, envPrefix string) (apiStoreReply, error) { |
| in := struct { |
| EnvPrefix string `json:"env_prefix,omitempty"` |
| }{envPrefix} |
| - status, body, err := s.postJSON(ctx, apiPath(a, app, "/store"), in) |
| + status, body, err := s.postJSON(ctx, apiPath(a, app, storeEnvTail("/store", env)), in) |
| if err != nil { |
| return apiStoreReply{}, err |
| } |
| @@ -1970,7 +1994,7 @@ func (s *apiSession) storeCreate(ctx context.Context, a apiAcct, app, envPrefix |
| // spend its full 60-second storeGrantTimeout on backend work, so this one JSON |
| // door gets a scoped client with a slightly longer deadline; mutating the |
| // session's ordinary 30-second client would silently widen every later call. |
| -func (s *apiSession) storeProvision(ctx context.Context, a apiAcct, app string) (apiStoreReply, error) { |
| +func (s *apiSession) storeProvision(ctx context.Context, a apiAcct, app, env string) (apiStoreReply, error) { |
| clone := *s |
| client := *s.c |
| httpc := *s.c.httpc |
| @@ -1978,7 +2002,7 @@ func (s *apiSession) storeProvision(ctx context.Context, a apiAcct, app string) |
| client.httpc = &httpc |
| clone.c = &client |
| - status, body, err := clone.postJSON(ctx, apiPath(a, app, "/store/provision"), struct{}{}) |
| + status, body, err := clone.postJSON(ctx, apiPath(a, app, storeEnvTail("/store/provision", env)), struct{}{}) |
| if err != nil { |
| return apiStoreReply{}, err |
| } |
| @@ -1998,8 +2022,8 @@ func (s *apiSession) storeProvision(ctx context.Context, a apiAcct, app string) |
| // {"error": "no store is declared..."} body once membership is already |
| // proven, so apiErrorFromBody's json-first decode surfaces it verbatim like |
| // any other server diagnosis; no special case is needed here. |
| -func (s *apiSession) storeGet(ctx context.Context, a apiAcct, app string) (apiStoreReply, error) { |
| - status, body, err := s.getJSON(ctx, apiPath(a, app, "/store")) |
| +func (s *apiSession) storeGet(ctx context.Context, a apiAcct, app, env string) (apiStoreReply, error) { |
| + status, body, err := s.getJSON(ctx, apiPath(a, app, storeEnvTail("/store", env))) |
| if err != nil { |
| return apiStoreReply{}, err |
| } |
| @@ -2023,11 +2047,11 @@ func (s *apiSession) storeGet(ctx context.Context, a apiAcct, app string) (apiSt |
| // bucket == "" mints a fresh bucket; a non-empty bucket asks the walk to |
| // ADOPT that existing one instead. Same omitempty shape as storeCreate's |
| // prefix: a bare `--app x` with no `--bucket` sends `{}`. |
| -func (s *apiSession) storeGrant(ctx context.Context, a apiAcct, app, bucket string) (apiStoreReply, error) { |
| +func (s *apiSession) storeGrant(ctx context.Context, a apiAcct, app, env, bucket string) (apiStoreReply, error) { |
| in := struct { |
| Bucket string `json:"bucket,omitempty"` |
| }{bucket} |
| - status, body, err := s.postJSON(ctx, apiPath(a, app, "/store/grant"), in) |
| + status, body, err := s.postJSON(ctx, apiPath(a, app, storeEnvTail("/store/grant", env)), in) |
| if err != nil { |
| return apiStoreReply{}, err |
| } |
| @@ -2049,12 +2073,12 @@ func (s *apiSession) storeGrant(ctx context.Context, a apiAcct, app, bucket stri |
| // rather than by a body field, matching the server's own route table |
| // (internal/console/authcli.go) and restart's "no body, the whole request is |
| // which door you POST to" shape (handleAPIStoreRotate's doc comment). |
| -func (s *apiSession) storeRotate(ctx context.Context, a apiAcct, app string, finish bool) (apiStoreReply, error) { |
| +func (s *apiSession) storeRotate(ctx context.Context, a apiAcct, app, env string, finish bool) (apiStoreReply, error) { |
| tail := "/store/rotate" |
| if finish { |
| tail = "/store/rotate/finish" |
| } |
| - status, body, err := s.postJSON(ctx, apiPath(a, app, tail), struct{}{}) |
| + status, body, err := s.postJSON(ctx, apiPath(a, app, storeEnvTail(tail, env)), struct{}{}) |
| if err != nil { |
| return apiStoreReply{}, err |
| } |
diff --git a/docs/site/cli.md b/docs/site/cli.md| index 60d098f31..3df903a7b 100644 |
| --- a/docs/site/cli.md |
| +++ b/docs/site/cli.md |
| @@ -837,6 +837,41 @@ it was measured and can be up to a day old. Sizing a bucket means listing |
| every object in it, and `status` does not do that on each call. A fresh store |
| reads "not measured yet" until the next sweep. |
| +#### A store for one environment |
| + |
| +A store normally belongs to the app: one bucket, one credential, delivered |
| +into the app's default config bundle, and every instance of the app reads it. |
| +`--environment` declares a store that belongs to one named config bundle |
| +instead — its own bucket, its own credential, delivered into that bundle, so |
| +only the instances bound to it can reach it. An instance gets a bucket of its |
| +own by being the only instance bound to its environment. |
| + |
| +```sh |
| +carlos env set --app hello --environment acme APP_ORIGIN=https://acme.example.com |
| +carlos instances create --app hello --host acme.bab.oncarlos.com --channel edge --environment acme |
| +carlos domains attach --app hello --route acme.bab.oncarlos.com acme.example.com |
| +carlos store create --app hello --environment acme |
| +carlos store status --app hello --environment acme |
| +``` |
| + |
| +The instance sits at a free label under the account's platform domain; the |
| +customer's own hostname is attached to it afterwards. `--route` is required |
| +on `domains attach` once an app has more than one instance — the console |
| +refuses to guess which one a hostname belongs to. |
| + |
| +`create`, `status`, `grant` and `rotate` all take `--environment`, and each |
| +acts on that store alone. Without it they act on the app's own store, exactly |
| +as they always have — nothing about an app with no environment stores changes. |
| + |
| +`carlos store status --app hello` also names every environment this app has a |
| +store for. Tearing a store down is always an explicit act, so an environment |
| +nobody uses keeps its bucket and keeps billing until someone destroys it; |
| +this line is where you see one. |
| + |
| +Two instances bound to the same environment share its store, the same way |
| +they share its config. That is visible rather than refused: `status` names |
| +the environment a store belongs to. |
| + |
| `carlos store rotate` mints a fresh credential and leaves the old one live |
| until you run it again with `--finish`, so the app has a window to pick the |
| new one up. Rotating, declaring and reading status are member verbs and want |
diff --git a/docs/superpowers/specs/2026-09-19-per-environment-store-design.md b/docs/superpowers/specs/2026-09-19-per-environment-store-design.md| new file mode 100644 |
| index 000000000..a57845eab |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-09-19-per-environment-store-design.md |
| @@ -0,0 +1,253 @@ |
| +# A store per environment: object storage an instance can own |
| + |
| +**Date:** 2026-09-19 |
| +**Status:** approved in direction (James, 2026-09-19). Two **(ruling)**s were |
| +taken in that conversation; everything else is a **(decision)** and can be |
| +overturned cheaply. |
| +**Discussion:** amadan `carlos/platform` #159. |
| +**Extends:** `2026-09-02-object-storage-self-serve-design.md` (the account |
| +entitlement) and the object-storage primitive it builds on. |
| + |
| +## 1. The problem, as found |
| + |
| +`store.json` is per app. `MintedBucket` is `prefix + acct + "-" + app` and |
| +`UserName` is `"carlos-store-" + acct + "-" + app` |
| +(`internal/objstore/walk.go:62,69`), and `writeEnv` delivers the coordinates |
| +through `appconfig.Apply` (`walk.go:689`) — the app's **default** bundle. So |
| +every instance of an app shares one bucket and one credential, and there is |
| +no way to give one instance its own. |
| + |
| +That is correct for an app whose instances are one deployment. It is the |
| +blocker for a managed, multi-tenant product, where one app has one instance |
| +per customer: the only options are one bucket with per-tenant key prefixes, |
| +where isolation becomes app logic and one path bug crosses tenants, or the |
| +app bringing its own S3 and re-implementing what the platform already does. |
| + |
| +### What is already right, and must stay right |
| + |
| +- **The entitlement is per ACCOUNT, not per app** (`store/entitlements/<sqid>.json`). |
| + Approval is already orthogonal to whatever unit owns a store, so nothing |
| + here adds an approval step. |
| +- **The walks are idempotent and resumable**, and their lost-CAS retries are |
| + guarded on the key the walk started from — the one shape the walk comments |
| + call "NOT survivable" (`walk.go:258-289`). |
| +- **The record's three null-or-present blocks** are what make "declared", |
| + "granted" and "provisioned" distinguishable. |
| +- **`release.PurgeApp` hand-parses `grant`/`provisioned`** out of the store |
| + record and refuses an app's auto-purge while either is present |
| + (`internal/release/trash.go:202-221`). The field names are frozen. |
| +- **The per-app walk lock exists** (`internal/console/storelock.go`). |
| + |
| +## 2. The shape, in one paragraph |
| + |
| +A store may belong to a **named config environment** instead of to the app as |
| +a whole. `carlos store create --app a --environment e` declares one; the walk |
| +mints a bucket and an IAM user named for `(account, app, environment)` and |
| +delivers the coordinates into **that environment's bundle**, which is the |
| +bundle the instances bound to it already read. An instance gets its own |
| +bucket by being the only instance bound to its environment. Nothing on a box |
| +changes. |
| + |
| +## 3. Rulings |
| + |
| +- **(ruling, James 2026-09-19) A store is keyed on `(account, app, |
| + environment)`, not on a route host.** Config delivery already resolves per |
| + `(account, app, environment)` (`appconfig.LoadMerged`, `syncexec.go`'s |
| + `groupKey`); there is no per-host config source, and adding one means a new |
| + precedence layer inside the box-side sync that every route's config passes |
| + through. Keying on the environment reuses the delivery axis exactly as it |
| + is. |
| +- **(ruling, James 2026-09-19) Teardown stays an explicit verb.** Deleting an |
| + instance or abandoning an environment never destroys a bucket. |
| + `provision.DeleteRecord` already "deliberately leaves the parked database |
| + and blobs untouched", and `objstore.Teardown` is only ever called from a |
| + door a person drove. Nothing in this change becomes the platform's first |
| + automatic destroyer of tenant data. §8 covers what stops an abandoned |
| + store from billing silently. |
| + |
| +## 4. Wire shapes |
| + |
| +**(decision) The default store does not move.** With no `--environment`, every |
| +key, name and delivery target is byte-for-byte what it is today: |
| +`apps/<acct>/<app>/store.json`, bucket `<prefix><acct>-<app>`, user |
| +`carlos-store-<acct>-<app>`, delivered to `config.json`. Existing records, |
| +buckets and credentials are untouched, and every existing test stays green |
| +unchanged. |
| + |
| +**(decision) An environment store is a sibling collection.** |
| + |
| + apps/<acct>/<app>/store.json the app's own store (unchanged) |
| + apps/<acct>/<app>/stores/<env>.json one per named environment |
| + |
| +Nothing in the bucket nests keys under an instance today; the nearest |
| +precedent is `apps/<sqid>/<app>/sidecars/<name>.json`, a flat sibling |
| +collection, and this follows it. |
| + |
| +**(decision) The record shape is `objstore.Record`, unchanged**, with one |
| +additive field: |
| + |
| +```go |
| +// Environment is the named config bundle this store belongs to. "" is the |
| +// app's own store — the only shape that existed before 2026-09-19 — and is |
| +// omitted from the JSON, so every record written before this field existed |
| +// parses as the app store it is. |
| +Environment string `json:"environment,omitempty"` |
| +``` |
| + |
| +Like `App`, it is stamped by `Put` from the key it is written to and never |
| +trusted from a caller: a record that lies about its own identity is refused, |
| +not guessed at. |
| + |
| +**(decision) Names gain the environment as a third component.** |
| + |
| + MintedBucket: <prefix><acct>-<app>-<env> |
| + UserName: carlos-store-<acct>-<app>-<env> |
| + |
| +> **Implementation gate.** Both are bounded before this lands. S3 bucket |
| +> names are at most 63 characters and IAM user names at most 64; account, |
| +> app and environment names are each already bounded by their own |
| +> validators. The walk must refuse a combination that cannot be named, |
| +> **before** any backend call, with a sentence naming which part is too |
| +> long — never a half-provisioned estate discovered at `CreateBucket`. |
| + |
| +**(decision) Delivery targets the environment's bundle.** `writeEnv` calls |
| +`appconfig.ApplyEnvironment(..., env, ...)` when `Environment != ""`, and |
| +`appconfig.Apply` otherwise. The variable names are unchanged |
| +(`<PREFIX>_BUCKET`, `_REGION`, `_ENDPOINT`, `_ACCESS_KEY`, `_SECRET_KEY`), |
| +and the two key halves are still sealed before they are written. |
| + |
| +Because a named bundle overlays the default **per key**, an app with both an |
| +app store and an environment store delivers the environment's coordinates to |
| +instances bound to it and the app's to everyone else. That is the overlay |
| +doing exactly what it already does. |
| + |
| +## 5. What a member sees |
| + |
| +``` |
| +carlos store create --app a --environment e [--env-prefix P] |
| +carlos store status --app a [--environment e] |
| +carlos store grant --app a --environment e (operator, unchanged gates) |
| +carlos store rotate --app a --environment e [--finish] |
| +``` |
| + |
| +**(ruling, James 2026-09-21) There is no `carlos store destroy`.** An earlier |
| +draft of this section listed one. Destroying a store stays what it already |
| +was: a console action behind the heaviest ceremony in the platform — owner |
| +role, CSRF, the bucket name typed exactly, and fresh passkey auth. A CLI verb |
| +cannot ask for a passkey, so adding one would have quietly traded that |
| +ceremony for a bearer token on the one action with no trash and no undo. |
| + |
| +The console's destroy door takes the environment instead (§8), and the |
| +settings page grows one card per environment store, each with its own |
| +confirm-and-destroy form. |
| + |
| +**(decision) `status` with no `--environment` lists every store the app has**, |
| +the app's own first, then one line per environment: its bucket, its state, |
| +and which instances read it. An app with only the default store prints what |
| +it prints today. |
| + |
| +**(decision) The doors refuse what cannot be delivered.** A store for an |
| +environment name that fails `appconfig.ValidEnvironmentName` is refused. An |
| +environment bundle that does not exist yet is **not** refused: delivery |
| +creates it, matching "an environment does not require a default to exist". |
| + |
| +**(decision) Sharing is visible, not refused.** Two instances bound to one |
| +environment share its store, exactly as they share its config. `status` says |
| +which instances read each store, so a shared bucket is something you can see |
| +rather than something you discover. |
| + |
| +## 6. Authority |
| + |
| +Unchanged in every respect. `apiAccountApp` first, then the operator |
| +allowlist for `grant`, then the write budget, then the backend gates. The |
| +account entitlement remains the single approval: **in an approved account, |
| +creating an environment store provisions it with no new approval**, which is |
| +what lets a signup path mint one without an operator in the loop. |
| + |
| +**(decision) The walk lock key gains the environment** — |
| +`acct + "/" + app + "/" + env`. Two stores of one app share no estate: their |
| +buckets, users and delivered bundles are all distinct, so serialising across |
| +them would cost concurrency and buy nothing. That is the same argument |
| +`storelock.go` already makes for two apps in one account. |
| + |
| +## 7. The purge guard |
| + |
| +`release.PurgeApp` today does one `Get` of `store.json` and refuses the |
| +purge while `grant` or `provisioned` is present. With environment stores it |
| +must also **list `apps/<acct>/<app>/stores/`** and apply the same test to |
| +every record there. |
| + |
| +**This is the one change in this spec that is a safety property rather than a |
| +feature.** A miss means an app is auto-purged while a bucket, an IAM user and |
| +a live access key survive with nothing naming them. It lands in the first |
| +slice, with the test that proves it, and `release` keeps hand-parsing the two |
| +frozen field names rather than importing `objstore`. |
| + |
| +## 8. Metering, and the abandoned store |
| + |
| +**(decision) v1 sums every store of an app into the app's existing usage |
| +key.** `storagePass` gets one `Get` per app today and writes |
| +`day.Apps[acct+"/"+app]`. It will instead read the app store plus every |
| +record under `stores/`, size each, and sum. The `StorageDay` wire shape does |
| +not change, so nothing downstream of usage has to move, and an account's bill |
| +is the same arithmetic over more buckets. |
| + |
| +Per-store attribution — what discussion #140 asks for — is a later, additive |
| +change to the usage shape, and this spec deliberately does not make it. |
| + |
| +**Because teardown is explicit (§3), an abandoned environment store keeps |
| +billing.** Three things make that visible rather than silent: |
| + |
| +- `store status` lists every store of the app, including ones no instance |
| + reads. An orphan is on the screen. |
| +- A store whose environment no bundle and no instance references is named in |
| + `status` as **unused**, in those words. |
| +- The purge guard (§7) refuses to auto-purge the app while any store is |
| + provisioned, so the estate cannot be forgotten by being trashed. |
| + |
| +## 9. Scanning |
| + |
| +**(decision) v1 scans the app's own store only.** `Sweep` is |
| +`(acct, app)`-scoped and keeps one cursor per app |
| +(`internal/objstore/sweep.go`), and the scan-result namespace is the same |
| +pair. Extending both is real work with no bearing on whether a tenant can |
| +have its own bucket. |
| + |
| +What this spec will not do is let the surface lie about it: `store status` on |
| +an environment store says scanning is not available for it yet, and the scan |
| +doors refuse `--environment` with that sentence rather than silently scanning |
| +the wrong bucket or claiming a clean result. |
| + |
| +## 10. Slices |
| + |
| +1. **The primitive.** ✅ `Environment` on the record, the key layout, the name |
| + builders and their length refusal, `writeEnv`'s delivery target, and the |
| + walks threaded through. Plus §7's purge guard and its test. |
| +2. **The doors.** ✅ `?environment=` on create/status/grant/rotate/finish, the |
| + lock key, `storeReply` carrying the environment, and the refusals of §5. |
| +3. **The CLI and the docs.** ✅ `--environment` on create/status/grant/rotate, |
| + `status` listing every store, and `docs/site/cli.md`. |
| +4. **Teardown.** ✅ The account-teardown path covering every store, and the |
| + console destroy door taking an environment — with one card and one |
| + confirm-and-destroy form per environment store on the settings page. No |
| + CLI verb, per §5's ruling. |
| +5. **Metering.** ✅ §8's sum, over the app's own store and every environment |
| + store, into the one existing usage key. |
| + |
| +**Not built, and named here so it is not mistaken for shipped: §8's "unused" |
| +line.** `store status` lists every environment that has a store, which is |
| +where an abandoned one becomes visible — but it does not say whether any |
| +INSTANCE is bound to that environment, which is what "unused" claims. Doing |
| +it properly means reading the app's instance records and joining on their |
| +environment binding, and a line that said "unused" from anything less would |
| +be a guess about a live bucket. The listing is the honest half; the join is |
| +follow-up work. |
| + |
| +## 11. Non-goals |
| + |
| +- Per-host stores. §3's ruling. |
| +- Automatic teardown of anything. §3's ruling. |
| +- Per-store usage attribution (#140). |
| +- Scanning environment stores (§9). |
| +- Any change to the account entitlement, its approval queue or its |
| + notifications. |
diff --git a/internal/console/apiscan_test.go b/internal/console/apiscan_test.go| index 0dffa612f..976aa3d1d 100644 |
| --- a/internal/console/apiscan_test.go |
| +++ b/internal/console/apiscan_test.go |
| @@ -15,13 +15,12 @@ import ( |
| func scanURL(fx *consoleFixture, app string) string { return storeURL(fx, app) + "/scan" } |
| // grantStore drives the real operator grant so the store is provisioned — the |
| -// state every scan verb but `request` needs to act on. |
| +// state every scan verb but `request` needs to act on. The app's OWN store: |
| +// grantStoreIn (store_env_estate_test.go) is this for any store of the app, |
| +// and carries the implementation, so the two cannot drift. |
| func grantStore(t *testing.T, fx *consoleFixture, token string) { |
| t.Helper() |
| - declareStore(t, fx, token) |
| - if status, out := postJSON(t, storeURL(fx, "titogo")+"/grant", map[string]any{}, token); status != http.StatusOK { |
| - t.Fatalf("grant = %d %v", status, out) |
| - } |
| + grantStoreIn(t, fx, token, "") |
| } |
| // scanArmed is storeArmed plus a granted store and a working engine. |
diff --git a/internal/console/apistore.go b/internal/console/apistore.go| index 1db526b03..79a8fced7 100644 |
| --- a/internal/console/apistore.go |
| +++ b/internal/console/apistore.go |
| @@ -72,6 +72,34 @@ func storeNoBucket(w http.ResponseWriter) { |
| // 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". |
| +// storeEnvParam reads the environment a store door is aimed at, from |
| +// ?environment=. Absent is "" — the app's own store, and every request made |
| +// before environment stores existed. |
| +// |
| +// One helper for all six doors on purpose: the create door is the only one |
| +// with a body, and rotate and finish document that they read none at all |
| +// ("a rotation has no arguments"), so a body field would have made the |
| +// spelling differ per door for no reason a member could predict. It is the |
| +// same query parameter, with the same name, that the config plane already |
| +// uses (apiconfig.go's ?environment=) — a store delivers INTO a bundle, so |
| +// the two planes naming it differently would be a seam with no meaning. |
| +// |
| +// Validated here rather than in the walk: a refusal costs nothing, and an |
| +// invalid name reaching objstore would be refused there anyway, one round |
| +// trip later and with a less useful sentence. |
| +func (s *Server) storeEnvParam(w http.ResponseWriter, r *http.Request) (string, bool) { |
| + env := strings.TrimSpace(r.URL.Query().Get("environment")) |
| + if env == "" { |
| + return "", true |
| + } |
| + if !appconfig.ValidEnvironmentName(env) { |
| + jsonError(w, http.StatusBadRequest, |
| + fmt.Sprintf("invalid environment %q — want ^[a-z0-9-]{1,32}$, and not \"default\"", env)) |
| + return "", false |
| + } |
| + return env, true |
| +} |
| + |
| func storeEffectivePrefix(rec *objstore.Record) string { |
| if rec != nil && rec.Declared != nil && rec.Declared.EnvPrefix != "" { |
| return rec.Declared.EnvPrefix |
| @@ -182,7 +210,7 @@ func tenantEntitlement(e *objstore.Entitlement, ctx context.Context) *storeEntit |
| return out |
| } |
| -func (s *Server) storeReply(ctx context.Context, acct *Account, app string, rec *objstore.Record) (map[string]any, error) { |
| +func (s *Server) storeReply(ctx context.Context, acct *Account, app, env string, rec *objstore.Record) (map[string]any, error) { |
| entitlement, _, err := objstore.GetEntitlement(ctx, s.cfg.Releases, acct.Sqid) |
| if err != nil { |
| return nil, err |
| @@ -197,14 +225,22 @@ func (s *Server) storeReply(ctx context.Context, acct *Account, app string, rec |
| names = append(names, accessKey, secretKey) |
| missing := []string{} |
| if rec.Provisioned != nil { |
| - got, err := objstore.EnvDelivered(ctx, s.cfg.Releases, acct.Sqid, app, prefix, wantEndpoint) |
| + // The bundle this store DELIVERS into, which for an environment store |
| + // is not the app's default one. Asking the default bundle would |
| + // report every delivered variable missing, on every status call, for |
| + // every environment store. |
| + got, err := objstore.EnvDeliveredIn(ctx, s.cfg.Releases, acct.Sqid, app, env, prefix, wantEndpoint) |
| if err != nil { |
| return nil, err |
| } |
| missing = append(missing, got...) |
| } |
| return map[string]any{ |
| - "app": app, |
| + "app": app, |
| + // "" for the app's own store. Omitting it when empty would make an |
| + // old CLI and a new one disagree about which store a reply describes, |
| + // so it is always present and always says which. |
| + "environment": env, |
| "entitlement": tenantEntitlement(entitlement, ctx), |
| "backend": s.cfg.StoreDeployment.Backend, |
| "declared": rec.Declared, |
| @@ -356,9 +392,13 @@ func (s *Server) handleAPIStoreCreate(w http.ResponseWriter, r *http.Request) { |
| fmt.Sprintf("invalid env prefix %q — want ^[A-Z][A-Z0-9_]{0,31}$ (e.g. %s)", req.EnvPrefix, objstore.DefaultEnvPrefix)) |
| return |
| } |
| + env, ok := s.storeEnvParam(w, r) |
| + if !ok { |
| + return |
| + } |
| ctx, cancel := context.WithTimeout(r.Context(), storeTimeout) |
| defer cancel() |
| - rec, etag, err := objstore.Get(ctx, s.cfg.Releases, acct.Sqid, app) |
| + rec, etag, err := objstore.GetIn(ctx, s.cfg.Releases, acct.Sqid, app, env) |
| 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 |
| @@ -403,7 +443,7 @@ func (s *Server) handleAPIStoreCreate(w http.ResponseWriter, r *http.Request) { |
| } |
| if rec.Declared == nil || storeEffectivePrefix(rec) != prefix { |
| 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 _, err := objstore.PutIn(ctx, s.cfg.Releases, acct.Sqid, app, env, 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 |
| @@ -419,10 +459,10 @@ func (s *Server) handleAPIStoreCreate(w http.ResponseWriter, r *http.Request) { |
| // 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)") |
| + s.audit(acct, app, addr, "store-create", "declared a store with env prefix "+prefix+storeEnvSuffix(env)+" (cli)") |
| } |
| // No bumpSerial: nothing a box reads has changed. See the file comment. |
| - out, err := s.storeReply(ctx, acct, app, rec) |
| + out, err := s.storeReply(ctx, acct, app, env, 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`") |
| @@ -454,9 +494,13 @@ func (s *Server) handleAPIStoreGet(w http.ResponseWriter, r *http.Request) { |
| storeNoBucket(w) |
| return |
| } |
| + env, ok := s.storeEnvParam(w, r) |
| + if !ok { |
| + return |
| + } |
| ctx, cancel := context.WithTimeout(r.Context(), storeTimeout) |
| defer cancel() |
| - rec, _, err := objstore.Get(ctx, s.cfg.Releases, acct.Sqid, app) |
| + rec, _, err := objstore.GetIn(ctx, s.cfg.Releases, acct.Sqid, app, env) |
| 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") |
| @@ -466,18 +510,73 @@ func (s *Server) handleAPIStoreGet(w http.ResponseWriter, r *http.Request) { |
| // 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`") |
| + if env != "" { |
| + jsonError(w, http.StatusNotFound, |
| + "no store is declared for this app's "+env+" environment — run `carlos store create --environment "+env+"`") |
| + return |
| + } |
| + // The app has no store of its OWN — but it may have environment |
| + // stores, and this is the shape where answering "no store" is most |
| + // wrong. An app whose storage is entirely per-environment (a managed |
| + // product's, where every tenant is a bundle) would have every one of |
| + // its buckets hidden behind a 404 that says it has none. That is the |
| + // exact opposite of what this listing exists for: a store nobody |
| + // reads still bills, and teardown is explicit. |
| + envs, lerr := objstore.StoreEnvironments(ctx, s.cfg.Releases, acct.Sqid, app) |
| + if lerr != nil { |
| + log.Printf("console: api store environments %s/%s: %v", acct.Sqid, app, lerr) |
| + jsonError(w, http.StatusBadGateway, "could not read the store record — try again") |
| + return |
| + } |
| + if len(envs) == 0 { |
| + jsonError(w, http.StatusNotFound, "no store is declared for this app — run `carlos store create`") |
| + return |
| + } |
| + // A zero record, not a nil one: every block reads as absent, which is |
| + // true, and the environments are named beside it. |
| + out, rerr := s.storeReply(ctx, acct, app, "", &objstore.Record{}) |
| + if rerr != nil { |
| + log.Printf("console: api store delivery %s/%s: %v", acct.Sqid, app, rerr) |
| + jsonError(w, http.StatusBadGateway, "could not read the store's status. try again") |
| + return |
| + } |
| + out["environments"] = envs |
| + writeJSON(w, http.StatusOK, out) |
| return |
| } |
| - out, err := s.storeReply(ctx, acct, app, rec) |
| + out, err := s.storeReply(ctx, acct, app, env, 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 status. try again") |
| return |
| } |
| + // Asked about the app's own store, a member is told what ELSE this app |
| + // has: an environment store nobody remembers is a bucket nobody |
| + // remembers, and this is the one screen where it can be seen (spec §8). |
| + // Only on the app-store call — a request aimed at one environment |
| + // answers about that environment. |
| + if env == "" { |
| + envs, err := objstore.StoreEnvironments(ctx, s.cfg.Releases, acct.Sqid, app) |
| + if err != nil { |
| + log.Printf("console: api store environments %s/%s: %v", acct.Sqid, app, err) |
| + jsonError(w, http.StatusBadGateway, "could not read the store's status. try again") |
| + return |
| + } |
| + out["environments"] = envs |
| + } |
| writeJSON(w, http.StatusOK, out) |
| } |
| +// storeEnvSuffix names the environment in an audit row, or says nothing at |
| +// all for the app's own store — so rows written before environment stores |
| +// existed and rows written after read identically for the same action. |
| +func storeEnvSuffix(env string) string { |
| + if env == "" { |
| + return "" |
| + } |
| + return " in environment " + env |
| +} |
| + |
| // ---- the walk doors: grant, rotate, finish ----------------------------------- |
| // storeGrantTimeout bounds the grant walk. Longer than storeTimeout because a |
| @@ -602,8 +701,8 @@ func storeBucketName(rec *objstore.Record) string { |
| // events, and the sentence says so — the thing was done, go and look at it — |
| // because telling someone their grant failed when it didn't is how a second |
| // grant gets run over a live estate. |
| -func (s *Server) storeWalkReply(ctx context.Context, w http.ResponseWriter, acct *Account, app string, rec *objstore.Record) { |
| - out, err := s.storeReply(ctx, acct, app, rec) |
| +func (s *Server) storeWalkReply(ctx context.Context, w http.ResponseWriter, acct *Account, app, env string, rec *objstore.Record) { |
| + out, err := s.storeReply(ctx, acct, app, env, rec) |
| if err != nil { |
| log.Printf("console: api store reply %s/%s: %v", acct.Sqid, app, err) |
| jsonError(w, http.StatusBadGateway, |
| @@ -683,11 +782,15 @@ func (s *Server) handleAPIStoreGrant(w http.ResponseWriter, r *http.Request) { |
| fmt.Sprintf("invalid bucket name %q — want 3-63 characters of lowercase letters, digits, dots and hyphens, starting and ending alphanumeric", req.Bucket)) |
| return |
| } |
| + env, ok := s.storeEnvParam(w, r) |
| + if !ok { |
| + return |
| + } |
| ctx, cancel := context.WithTimeout(r.Context(), storeGrantTimeout) |
| defer cancel() |
| - defer s.storeWalks.lock(acct.Sqid, app)() |
| - rec, err := objstore.Grant(ctx, s.cfg.Releases, s.cfg.StoreProvisioner, s.cfg.StoreDeployment, |
| - pub, acct.Sqid, app, addr, objstore.GrantSourceOperator, bucket, time.Now()) |
| + defer s.storeWalks.lock(acct.Sqid, app, env)() |
| + rec, err := objstore.GrantIn(ctx, s.cfg.Releases, s.cfg.StoreProvisioner, s.cfg.StoreDeployment, |
| + pub, acct.Sqid, app, env, addr, objstore.GrantSourceOperator, bucket, time.Now()) |
| switch { |
| case errors.Is(err, objstore.ErrNotDeclared): |
| // The operator's verb never invents the member's half: the env prefix |
| @@ -707,12 +810,12 @@ func (s *Server) handleAPIStoreGrant(w http.ResponseWriter, r *http.Request) { |
| "could not provision the store — nothing unrecorded was left behind; re-run the grant to resume") |
| return |
| } |
| - s.audit(acct, app, addr, "store-grant", "granted a store in bucket "+storeBucketName(rec)+" (cli)") |
| + s.audit(acct, app, addr, "store-grant", "granted a store in bucket "+storeBucketName(rec)+storeEnvSuffix(env)+" (cli)") |
| // The app's config bundle changed: boxes read config only when the serial |
| // moves, so without this the credentials sit in the bucket until the hourly |
| // pass and the app keeps failing to reach a store it has been given. |
| s.bumpSerial(ctx) |
| - s.storeWalkReply(ctx, w, acct, app, rec) |
| + s.storeWalkReply(ctx, w, acct, app, env, rec) |
| } |
| // handleAPIStoreRotate answers `carlos store rotate`: mint a fresh key, |
| @@ -746,10 +849,14 @@ func (s *Server) handleAPIStoreRotate(w http.ResponseWriter, r *http.Request) { |
| // No body: a rotation has no arguments, and handleAPIRestart — the other |
| // verb whose whole request is "do the thing to this app" — reads none |
| // either, so a CLI can POST with nothing at all. |
| + env, ok := s.storeEnvParam(w, r) |
| + if !ok { |
| + return |
| + } |
| ctx, cancel := context.WithTimeout(r.Context(), storeRotateTimeout) |
| defer cancel() |
| - defer s.storeWalks.lock(acct.Sqid, app)() |
| - rec, err := objstore.Rotate(ctx, s.cfg.Releases, s.cfg.StoreProvisioner, pub, acct.Sqid, app, time.Now()) |
| + defer s.storeWalks.lock(acct.Sqid, app, env)() |
| + rec, err := objstore.RotateIn(ctx, s.cfg.Releases, s.cfg.StoreProvisioner, pub, acct.Sqid, app, env, time.Now()) |
| switch { |
| case errors.Is(err, objstore.ErrNotProvisioned), errors.Is(err, objstore.ErrNotDeclared): |
| // Rotation edits an existing estate; it never creates one, because the |
| @@ -780,9 +887,9 @@ func (s *Server) handleAPIStoreRotate(w http.ResponseWriter, r *http.Request) { |
| } |
| // Key ids are not secrets — `status` prints them — and the audit row is |
| // where a member later matches "which credential did we cut over to". |
| - s.audit(acct, app, addr, "store-rotate", "rotated the store credential to key "+key+" (cli)") |
| + s.audit(acct, app, addr, "store-rotate", "rotated the store credential to key "+key+storeEnvSuffix(env)+" (cli)") |
| s.bumpSerial(ctx) |
| - s.storeWalkReply(ctx, w, acct, app, rec) |
| + s.storeWalkReply(ctx, w, acct, app, env, rec) |
| } |
| // handleAPIStoreRotateFinish answers `carlos store rotate -finish`: delete the |
| @@ -810,10 +917,14 @@ func (s *Server) handleAPIStoreRotateFinish(w http.ResponseWriter, r *http.Reque |
| if !s.storeWalkGates(w, p) { |
| return |
| } |
| + env, ok := s.storeEnvParam(w, r) |
| + if !ok { |
| + return |
| + } |
| ctx, cancel := context.WithTimeout(r.Context(), storeRotateTimeout) |
| defer cancel() |
| - defer s.storeWalks.lock(acct.Sqid, app)() |
| - rec, err := objstore.RotateFinish(ctx, s.cfg.Releases, s.cfg.StoreProvisioner, acct.Sqid, app) |
| + defer s.storeWalks.lock(acct.Sqid, app, env)() |
| + rec, err := objstore.RotateFinishIn(ctx, s.cfg.Releases, s.cfg.StoreProvisioner, acct.Sqid, app, env) |
| switch { |
| case errors.Is(err, objstore.ErrNotProvisioned): |
| jsonError(w, http.StatusConflict, |
| @@ -836,7 +947,7 @@ func (s *Server) handleAPIStoreRotateFinish(w http.ResponseWriter, r *http.Reque |
| key = rec.Provisioned.KeyID |
| } |
| s.audit(acct, app, addr, "store-rotate-finish", |
| - "finished the rotation — the store now runs on key "+key+" alone (cli)") |
| + "finished the rotation — the store now runs on key "+key+" alone"+storeEnvSuffix(env)+" (cli)") |
| // No bumpSerial: see the doc comment. |
| - s.storeWalkReply(ctx, w, acct, app, rec) |
| + s.storeWalkReply(ctx, w, acct, app, env, rec) |
| } |
diff --git a/internal/console/apistore_env_test.go b/internal/console/apistore_env_test.go| new file mode 100644 |
| index 000000000..1c0362160 |
| --- /dev/null |
| +++ b/internal/console/apistore_env_test.go |
| @@ -0,0 +1,146 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "net/http" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/objstore" |
| +) |
| + |
| +// The store doors take the environment as ?environment=, the same spelling |
| +// the config plane uses — a store delivers INTO a bundle, so the two naming |
| +// it differently would be a seam with no meaning. These pin the doors that |
| +// need no backend: declare and status. |
| + |
| +func storeEnvURL(fx *consoleFixture, app, env string) string { |
| + return storeURL(fx, app) + "?environment=" + env |
| +} |
| + |
| +func decodeReply(t *testing.T, body string) map[string]any { |
| + t.Helper() |
| + var out map[string]any |
| + if err := json.Unmarshal([]byte(body), &out); err != nil { |
| + t.Fatalf("decode reply %q: %v", body, err) |
| + } |
| + return out |
| +} |
| + |
| +// Declaring into an environment writes THAT environment's record, stamped |
| +// with the environment it lives under, and leaves the app's own store absent. |
| +func TestAPIStoreCreateInEnvironment(t *testing.T) { |
| + fx, token := storeFixture(t, "code-store-env-create") |
| + |
| + status, body := postRaw(t, storeEnvURL(fx, "titogo", "beta"), map[string]any{"env_prefix": "BLOBS"}, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("create in an environment = %d %s, want 200", status, body) |
| + } |
| + if got := decodeReply(t, body)["environment"]; got != "beta" { |
| + t.Errorf("reply environment = %v, want %q", got, "beta") |
| + } |
| + |
| + ctx := context.Background() |
| + rec, _, err := objstore.GetIn(ctx, fx.rel, "bab", "titogo", "beta") |
| + if err != nil || rec == nil { |
| + t.Fatalf("environment record = %+v, %v", rec, err) |
| + } |
| + if rec.Environment != "beta" { |
| + t.Errorf("stored environment = %q, want %q", rec.Environment, "beta") |
| + } |
| + if rec.Declared == nil || rec.Declared.EnvPrefix != "BLOBS" { |
| + t.Errorf("declared = %+v, want the BLOBS prefix", rec.Declared) |
| + } |
| + // The app's own store is a different store, and nobody declared it. |
| + appRec, _, err := objstore.Get(ctx, fx.rel, "bab", "titogo") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if appRec != nil { |
| + t.Errorf("declaring in an environment also wrote the app's own store: %+v", appRec) |
| + } |
| +} |
| + |
| +// An environment name the config plane would refuse is refused here, before |
| +// anything is written — including "default", which names the app's own |
| +// bundle rather than a named overlay. |
| +func TestAPIStoreEnvironmentNameRefused(t *testing.T) { |
| + fx, token := storeFixture(t, "code-store-env-bad") |
| + |
| + for _, env := range []string{"Beta", "default", "a_b", "this-name-is-far-too-long-to-be-an-environment"} { |
| + status, body := postRaw(t, storeEnvURL(fx, "titogo", env), map[string]any{}, token) |
| + if status != http.StatusBadRequest { |
| + t.Errorf("create with environment %q = %d %s, want 400", env, status, body) |
| + } |
| + } |
| +} |
| + |
| +// Status answers about the store it was asked about, and an app-store status |
| +// additionally names every environment this app has a store for — an |
| +// environment store nobody remembers is a bucket nobody remembers. |
| +func TestAPIStoreStatusListsEnvironments(t *testing.T) { |
| + fx, token := storeFixture(t, "code-store-env-status") |
| + |
| + for _, env := range []string{"beta", "alpha"} { |
| + if status, body := postRaw(t, storeEnvURL(fx, "titogo", env), map[string]any{}, token); status != http.StatusOK { |
| + t.Fatalf("declare %s = %d %s", env, status, body) |
| + } |
| + } |
| + // The app's own store, so the app-store status has something to answer |
| + // about at all. |
| + if status, body := postRaw(t, storeURL(fx, "titogo"), map[string]any{}, token); status != http.StatusOK { |
| + t.Fatalf("declare the app store = %d %s", status, body) |
| + } |
| + |
| + status, body := getRaw(t, storeURL(fx, "titogo"), token) |
| + if status != http.StatusOK { |
| + t.Fatalf("app store status = %d %s", status, body) |
| + } |
| + reply := decodeReply(t, body) |
| + if got := reply["environment"]; got != "" { |
| + t.Errorf("app store status environment = %v, want \"\"", got) |
| + } |
| + envs, _ := reply["environments"].([]any) |
| + if len(envs) != 2 || envs[0] != "alpha" || envs[1] != "beta" { |
| + t.Errorf("environments = %v, want [alpha beta] sorted", envs) |
| + } |
| + |
| + // Asked about one environment, it answers about that one and lists none. |
| + status, body = getRaw(t, storeEnvURL(fx, "titogo", "beta"), token) |
| + if status != http.StatusOK { |
| + t.Fatalf("environment status = %d %s", status, body) |
| + } |
| + reply = decodeReply(t, body) |
| + if got := reply["environment"]; got != "beta" { |
| + t.Errorf("environment status environment = %v, want %q", got, "beta") |
| + } |
| + if _, ok := reply["environments"]; ok { |
| + t.Error("a status aimed at one environment listed the app's environments") |
| + } |
| +} |
| + |
| +// An environment with no store 404s with a sentence naming the verb that |
| +// would create it — the app-store door's own convention, carried over. |
| +func TestAPIStoreStatusUndeclaredEnvironment(t *testing.T) { |
| + fx, token := storeFixture(t, "code-store-env-404") |
| + |
| + status, body := getRaw(t, storeEnvURL(fx, "titogo", "ghost"), token) |
| + if status != http.StatusNotFound { |
| + t.Fatalf("status for an undeclared environment = %d %s, want 404", status, body) |
| + } |
| + if !jsonErrorContains(t, body, "--environment ghost") { |
| + t.Errorf("404 body = %q, want it to name the environment and the verb", body) |
| + } |
| +} |
| + |
| +func jsonErrorContains(t *testing.T, body, want string) bool { |
| + t.Helper() |
| + var out struct { |
| + Error string `json:"error"` |
| + } |
| + if err := json.Unmarshal([]byte(body), &out); err != nil { |
| + return false |
| + } |
| + return out.Error != "" && strings.Contains(out.Error, want) |
| +} |
diff --git a/internal/console/apistore_envonly_test.go b/internal/console/apistore_envonly_test.go| new file mode 100644 |
| index 000000000..b146006e8 |
| --- /dev/null |
| +++ b/internal/console/apistore_envonly_test.go |
| @@ -0,0 +1,52 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "net/http" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/objstore" |
| +) |
| + |
| +// An app whose storage is entirely per-environment has NO store of its own. |
| +// Asked about the app store, the door used to answer 404 "no store is |
| +// declared for this app" — hiding every bucket the app actually owns, in the |
| +// one shape where the listing matters most. A managed product is always this |
| +// shape: every tenant is a bundle, and the app itself never declares a store. |
| +// |
| +// Found against a live deployment, not here, which is why this test exists. |
| +func TestAPIStoreStatusListsEnvironmentsWithNoAppStore(t *testing.T) { |
| + ctx := context.Background() |
| + fx, token := storeFixture(t, "code-store-env-only") |
| + |
| + // Only an environment store. The app's own is never declared. |
| + if status, body := postRaw(t, storeEnvURL(fx, "titogo", "acme"), map[string]any{}, token); status != http.StatusOK { |
| + t.Fatalf("declare in acme = %d %s", status, body) |
| + } |
| + if rec, _, err := objstore.Get(ctx, fx.rel, fx.sqid, "titogo"); err != nil || rec != nil { |
| + t.Fatalf("fixture drift: the app's own store exists (%+v, %v)", rec, err) |
| + } |
| + |
| + status, body := getRaw(t, storeURL(fx, "titogo"), token) |
| + if status != http.StatusOK { |
| + t.Fatalf("app-store status with only an environment store = %d %s, want 200", status, body) |
| + } |
| + var reply map[string]any |
| + if err := json.Unmarshal([]byte(body), &reply); err != nil { |
| + t.Fatalf("decode %q: %v", body, err) |
| + } |
| + envs, _ := reply["environments"].([]any) |
| + if len(envs) != 1 || envs[0] != "acme" { |
| + t.Errorf("environments = %v, want [acme]", envs) |
| + } |
| + // Every block reads as absent, which is true: the APP has no store. |
| + for _, key := range []string{"declared", "grant", "provisioned"} { |
| + if reply[key] != nil { |
| + t.Errorf("%s = %v, want null — the app itself declared nothing", key, reply[key]) |
| + } |
| + } |
| + if reply["environment"] != "" { |
| + t.Errorf("environment = %v, want \"\" — this reply is about the app's own store", reply["environment"]) |
| + } |
| +} |
diff --git a/internal/console/apistore_provision.go b/internal/console/apistore_provision.go| index 090356ff2..f57110aa1 100644 |
| --- a/internal/console/apistore_provision.go |
| +++ b/internal/console/apistore_provision.go |
| @@ -38,7 +38,11 @@ func (s *Server) handleAPIStoreProvision(w http.ResponseWriter, r *http.Request) |
| if !readJSON(w, r, &req) { |
| return |
| } |
| - defer s.storeWalks.lock(acct.Sqid, app)() |
| + env, ok := s.storeEnvParam(w, r) |
| + if !ok { |
| + return |
| + } |
| + defer s.storeWalks.lock(acct.Sqid, app, env)() |
| // The walk's deadline starts HERE rather than at the top of the handler. |
| // This is the door that queues in ordinary use, and a request that waited |
| // out a full-length walk ahead of it would otherwise reach the lock |
| @@ -51,8 +55,8 @@ func (s *Server) handleAPIStoreProvision(w http.ResponseWriter, r *http.Request) |
| if !ok { |
| return |
| } |
| - rec, err := objstore.Grant(ctx, s.cfg.Releases, s.cfg.StoreProvisioner, s.cfg.StoreDeployment, |
| - pub, acct.Sqid, app, e.Decision.By, objstore.GrantSourceAccountApproval, "", time.Now()) |
| + rec, err := objstore.GrantIn(ctx, s.cfg.Releases, s.cfg.StoreProvisioner, s.cfg.StoreDeployment, |
| + pub, acct.Sqid, app, env, e.Decision.By, objstore.GrantSourceAccountApproval, "", time.Now()) |
| switch { |
| case errors.Is(err, objstore.ErrNotDeclared): |
| jsonError(w, http.StatusConflict, "no store is declared for this app. run `carlos store create` first") |
| @@ -62,9 +66,9 @@ func (s *Server) handleAPIStoreProvision(w http.ResponseWriter, r *http.Request) |
| jsonError(w, http.StatusBadGateway, "could not provision the store. re-run `carlos store create` to resume; after destroying a store, its bucket name may take time to become available again") |
| return |
| } |
| - s.audit(acct, app, actor, "store-provision", "provisioned a store in bucket "+storeBucketName(rec)+" (cli)") |
| + s.audit(acct, app, actor, "store-provision", "provisioned a store in bucket "+storeBucketName(rec)+storeEnvSuffix(env)+" (cli)") |
| s.bumpSerial(ctx) |
| - s.storeWalkReply(ctx, w, acct, app, rec) |
| + s.storeWalkReply(ctx, w, acct, app, env, rec) |
| } |
| func (s *Server) storeProvisionApproval(ctx context.Context, w http.ResponseWriter, acct *Account) (*objstore.Entitlement, bool) { |
diff --git a/internal/console/apistore_provision_test.go b/internal/console/apistore_provision_test.go| index 42b80ddce..05ab36886 100644 |
| --- a/internal/console/apistore_provision_test.go |
| +++ b/internal/console/apistore_provision_test.go |
| @@ -277,7 +277,7 @@ func waitStoreProvisionQueued(t *testing.T, fx *consoleFixture) { |
| deadline := time.Now().Add(3 * time.Second) |
| for { |
| fx.srv.storeWalks.mu.Lock() |
| - e := fx.srv.storeWalks.m[fx.sqid+"/titogo"] |
| + e := fx.srv.storeWalks.m[storeWalkKey(fx.sqid, "titogo", "")] |
| refs := 0 |
| if e != nil { |
| refs = e.refs |
diff --git a/internal/console/appsettings.go b/internal/console/appsettings.go| index 702388fb1..4d6798069 100644 |
| --- a/internal/console/appsettings.go |
| +++ b/internal/console/appsettings.go |
| @@ -39,6 +39,7 @@ import ( |
| "strings" |
| "time" |
| + "github.com/carlosframework/platform/internal/appconfig" |
| "github.com/carlosframework/platform/internal/gate" |
| "github.com/carlosframework/platform/internal/mail" |
| "github.com/carlosframework/platform/internal/objstore" |
| @@ -50,14 +51,15 @@ import ( |
| // this server-side means a typo gets the same 404 as any other unknown app |
| // page, rather than rendering an empty settings shell. |
| var appSettingsSections = map[string]bool{ |
| - "general": true, |
| - "domains": true, |
| - "pipeline": true, |
| - "storage": true, |
| - "email": true, |
| - "features": true, |
| - "advanced": true, |
| - "delete": true, |
| + "general": true, |
| + "domains": true, |
| + "pipeline": true, |
| + "environments": true, |
| + "storage": true, |
| + "email": true, |
| + "features": true, |
| + "advanced": true, |
| + "delete": true, |
| } |
| func appSettingsURL(acct, app, section string) string { |
| @@ -106,6 +108,24 @@ type SettingsView struct { |
| // record, so the section remains present and does not claim there is no |
| // store during a transient failure. |
| StoreUnavailable bool |
| + // Envs is one card per named config environment — the bundles themselves, |
| + // which until now had no console surface at all: the ladder shows which |
| + // environment a route is wired to, and nothing showed what the |
| + // environments ARE. Read-only, and variable NAMES only: this page has |
| + // never shown a config value and does not start here. |
| + Envs []*EnvCard |
| + // EnvsUnavailable distinguishes a failed listing from an app that |
| + // genuinely has none, so the section does not claim "no environments" |
| + // during a transient bucket failure — StoreUnavailable's rule. |
| + EnvsUnavailable bool |
| + |
| + // StoreEnvs is one card per ENVIRONMENT store (2026-09-19), oldest name |
| + // first, empty for the apps that have none — which is all of them until |
| + // someone asks for one. Each carries its own bucket, its own credential |
| + // and its own destroy form: this is the only screen where a store that |
| + // no instance reads is visible, and teardown is explicit by ruling, so |
| + // an unnoticed one keeps billing. |
| + StoreEnvs []*StoreCard |
| // Mail is the app's tenant email as this page sees it (issue #240) — |
| // object storage's sibling primitive, same declare -> provision -> |
| @@ -177,6 +197,10 @@ type GateRow struct { |
| // with, or when the record is a declaration nobody has acted on, which needs no |
| // backend at all because nothing outside the bucket exists yet. |
| type StoreCard struct { |
| + // Environment is the config environment this store belongs to, "" for |
| + // the app's own. The destroy form posts it back, so the door tears down |
| + // the store the card describes and no other. |
| + Environment string |
| Declared bool |
| EnvPrefix string |
| Granted bool |
| @@ -189,6 +213,39 @@ type StoreCard struct { |
| CanDestroy bool |
| } |
| +// EnvCard is one named config environment as the Settings page sees it. |
| +// |
| +// Variable NAMES only, never values. This page has never shown a config |
| +// value — `carlos env list` is where values live, behind a bearer token — |
| +// and an environment bundle is exactly where a per-tenant origin or a |
| +// delivered credential name sits, so starting here would be the wrong place |
| +// to start. |
| +type EnvCard struct { |
| + Name string |
| + // Vars are the names this bundle SETS, sorted. A named bundle overlays |
| + // the app's default per key, so this is what the environment overrides |
| + // or adds — not the whole set an instance bound to it ends up reading. |
| + Vars []EnvVarName |
| + // Hosts are the instances wired to this bundle. Empty is the case worth |
| + // looking at: a bundle nothing reads, which is where an abandoned store |
| + // hides. |
| + Hosts []string |
| + // HasStore and StoreBucket: whether this environment has an object store |
| + // of its own, and what it is called. An environment store bills whether |
| + // or not any instance reads the bundle, and teardown is explicit, so the |
| + // pairing of "no hosts" with "has a store" is the thing this section |
| + // exists to make visible. |
| + HasStore bool |
| + StoreBucket string |
| +} |
| + |
| +// EnvVarName is a variable's name and whether it is sealed — enough to see |
| +// the shape of a bundle without seeing into it. |
| +type EnvVarName struct { |
| + Name string |
| + Secret bool |
| +} |
| + |
| // MailCard is the Settings tab's view of the app sender, apps/<acct>/<app>/ |
| // email/senders/app.json with an email.json read fallback (issue #240, |
| // mail.Record): tenant email's Settings card, mirroring StoreCard verb |
| @@ -304,7 +361,9 @@ func (s *Server) assembleSettings(ctx context.Context, p *page, acct *Account) * |
| default: |
| sv.CanRename = true |
| } |
| + sv.Envs, sv.EnvsUnavailable = s.envCards(ctx, acct, p.AppName, p.Detail.Routes) |
| sv.Store = s.storeCard(ctx, acct, p.AppName) |
| + sv.StoreEnvs = s.storeEnvCards(ctx, acct, p.AppName) |
| sv.Mail = s.mailCard(ctx, acct, p.AppName) |
| sv.Features = s.featuresCard(ctx, acct, p.AppName) |
| sv.StoreUnavailable = sv.Store == nil |
| @@ -388,7 +447,101 @@ func (s *Server) storeCard(ctx context.Context, acct *Account, app string) *Stor |
| if rec == nil { |
| return &StoreCard{} |
| } |
| - card := &StoreCard{} |
| + return s.storeCardFor(rec, "") |
| +} |
| + |
| +// envCards is one card per named config environment, sorted by name, with |
| +// the second return saying the listing itself failed — the difference |
| +// between "this app has no environments" and "I could not find out", which |
| +// the section must not collapse. |
| +// |
| +// A single unreadable bundle is skipped and logged rather than blanking the |
| +// list: the other environments are real, their instances are really bound to |
| +// them, and their stores are really billing. Same trade storeEnvCards makes, |
| +// for the same reason. |
| +func (s *Server) envCards(ctx context.Context, acct *Account, app string, routes []RouteView) ([]*EnvCard, bool) { |
| + names, err := appconfig.ListEnvironments(ctx, s.cfg.Releases, acct.Sqid, app) |
| + if err != nil { |
| + log.Printf("console: settings environments %s/%s: %v", acct.Sqid, app, err) |
| + return nil, true |
| + } |
| + cards := make([]*EnvCard, 0, len(names)) |
| + for _, name := range names { |
| + m, merr := appconfig.LoadEnvironment(ctx, s.cfg.Releases, acct.Sqid, app, name) |
| + if merr != nil { |
| + log.Printf("console: settings environment %s/%s/%s: %v", acct.Sqid, app, name, merr) |
| + continue |
| + } |
| + card := &EnvCard{Name: name} |
| + varNames := make([]string, 0, len(m.Vars)) |
| + for k := range m.Vars { |
| + varNames = append(varNames, k) |
| + } |
| + sort.Strings(varNames) |
| + for _, k := range varNames { |
| + card.Vars = append(card.Vars, EnvVarName{Name: k, Secret: m.Vars[k].Kind == "secret"}) |
| + } |
| + for _, rt := range routes { |
| + if rt.Environment == name { |
| + card.Hosts = append(card.Hosts, rt.Host) |
| + } |
| + } |
| + // The store is read through the same door the storage section uses, |
| + // so the two sections cannot disagree about whether one exists. |
| + if rec, _, rerr := objstore.GetIn(ctx, s.cfg.Releases, acct.Sqid, app, name); rerr != nil { |
| + log.Printf("console: settings environment store %s/%s/%s: %v", acct.Sqid, app, name, rerr) |
| + } else if rec != nil { |
| + card.HasStore = true |
| + switch { |
| + case rec.Provisioned != nil && rec.Provisioned.Bucket != "": |
| + card.StoreBucket = rec.Provisioned.Bucket |
| + case rec.Grant != nil: |
| + card.StoreBucket = rec.Grant.Bucket |
| + } |
| + } |
| + cards = append(cards, card) |
| + } |
| + return cards, false |
| +} |
| + |
| +// storeEnvCards is one card per environment store, in the order |
| +// StoreEnvironments returns them (sorted). nil on a read failure, matching |
| +// storeCard's own discipline: the page says nothing rather than claiming an |
| +// app has no environment stores when it could not find out. |
| +// |
| +// A single unreadable record does NOT blank the whole list — the others are |
| +// real and their buckets are real — so it is skipped, logged, and the rest |
| +// render. That is the opposite trade to storeCard's, and deliberately: |
| +// there, a nil is the difference between "no store" and "unknown"; here, |
| +// dropping every card because one is malformed would hide estate that is |
| +// perfectly legible. |
| +func (s *Server) storeEnvCards(ctx context.Context, acct *Account, app string) []*StoreCard { |
| + envs, err := objstore.StoreEnvironments(ctx, s.cfg.Releases, acct.Sqid, app) |
| + if err != nil { |
| + log.Printf("console: settings store environments %s/%s: %v", acct.Sqid, app, err) |
| + return nil |
| + } |
| + cards := make([]*StoreCard, 0, len(envs)) |
| + for _, env := range envs { |
| + rec, _, rerr := objstore.GetIn(ctx, s.cfg.Releases, acct.Sqid, app, env) |
| + if rerr != nil { |
| + log.Printf("console: settings store read %s/%s%s: %v", acct.Sqid, app, storeEnvSuffix(env), rerr) |
| + continue |
| + } |
| + if rec == nil { |
| + continue |
| + } |
| + cards = append(cards, s.storeCardFor(rec, env)) |
| + } |
| + return cards |
| +} |
| + |
| +// storeCardFor maps one record to the card the settings page renders. Shared |
| +// by both callers so the app's own store and an environment's can never |
| +// disagree about what a grant, a provisioning or a half-finished rotation |
| +// looks like. |
| +func (s *Server) storeCardFor(rec *objstore.Record, env string) *StoreCard { |
| + card := &StoreCard{Environment: env} |
| if rec.Declared != nil { |
| card.Declared = true |
| card.EnvPrefix = rec.Declared.EnvPrefix |
| @@ -987,14 +1140,23 @@ func (s *Server) handleStoreDestroy(w http.ResponseWriter, r *http.Request) { |
| // landing between reading it and tearing it down would make both stale — |
| // the shape objstore's deleteRecordIfUnchanged is guarding against from |
| // the other end (objstore/walk.go:560). |
| - defer s.storeWalks.lock(acct.Sqid, app)() |
| - rec, _, err := objstore.Get(ctx, s.cfg.Releases, acct.Sqid, app) |
| + // Which store: the app's own, or one belonging to a named config |
| + // environment. The form carries it; an unknown or malformed name reads |
| + // back as no record at all, which is the honest answer to "destroy the |
| + // store that isn't there" and the same one a tampered field gets. |
| + env := strings.TrimSpace(r.FormValue("environment")) |
| + defer s.storeWalks.lock(acct.Sqid, app, env)() |
| + rec, _, err := objstore.GetIn(ctx, s.cfg.Releases, acct.Sqid, app, env) |
| if err != nil { |
| - log.Printf("console: store destroy read %s/%s: %v", acct.Sqid, app, err) |
| + log.Printf("console: store destroy read %s/%s%s: %v", acct.Sqid, app, storeEnvSuffix(env), err) |
| fail("Could not read “" + app + "”'s object-store record — nothing was destroyed. Try again.") |
| return |
| } |
| if rec == nil { |
| + if env != "" { |
| + fail("“" + app + "” has no object store for the " + env + " environment, so there is nothing to destroy.") |
| + return |
| + } |
| fail("“" + app + "” has no object store, so there is nothing to destroy.") |
| return |
| } |
| @@ -1035,8 +1197,8 @@ func (s *Server) handleStoreDestroy(w http.ResponseWriter, r *http.Request) { |
| // button says "Destroy store", and the success flash below names only |
| // the object store. Email teardown belongs to ownership.go's |
| // handlePurgeApp, which destroys the whole app and says so. |
| - if err := objstore.Teardown(ctx, s.cfg.Releases, s.cfg.StoreProvisioner, s.cfg.StoreScanner, acct.Sqid, app); err != nil { |
| - log.Printf("console: store destroy %s/%s: %v", acct.Sqid, app, err) |
| + if err := objstore.TeardownIn(ctx, s.cfg.Releases, s.cfg.StoreProvisioner, s.cfg.StoreScanner, acct.Sqid, app, env); err != nil { |
| + log.Printf("console: store destroy %s/%s%s: %v", acct.Sqid, app, storeEnvSuffix(env), err) |
| if errors.Is(err, release.ErrCASConflict) { |
| fail("The store changed while you confirmed — nothing was destroyed; reload and try again.") |
| return |
| @@ -1051,7 +1213,7 @@ func (s *Server) handleStoreDestroy(w http.ResponseWriter, r *http.Request) { |
| flash = "Destroyed “" + app + "”'s object store: bucket " + want + ", its credentials and the " + |
| "variables they were delivered in are gone." |
| } |
| - s.audit(acct, app, addr, "store-destroy", "destroyed the object store ("+what+")") |
| + s.audit(acct, app, addr, "store-destroy", "destroyed the object store ("+what+")"+storeEnvSuffix(env)) |
| // Box-consumed: the delivered variables were UNSET in the app's config |
| // (objstore.Teardown), so every host still serving this app has an env file |
| // naming a bucket and a key that authenticate against nothing. Without the |
diff --git a/internal/console/appsettings_env_test.go b/internal/console/appsettings_env_test.go| new file mode 100644 |
| index 000000000..db234834f |
| --- /dev/null |
| +++ b/internal/console/appsettings_env_test.go |
| @@ -0,0 +1,88 @@ |
| +package console |
| + |
| +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" |
| +) |
| + |
| +// The Environments section is the first console surface for the config |
| +// bundles themselves. Until it existed the ladder showed which environment a |
| +// route was wired to, and nothing showed what the environments WERE. |
| + |
| +func TestSettingsPageShowsEnvironments(t *testing.T) { |
| + ctx := context.Background() |
| + fx := newConsoleFixture(t) |
| + freshApp(t, fx, "sprout") |
| + |
| + // A bundle with one plain var and one sealed one. The envelope is opaque |
| + // to appconfig at set time, so it needs no real key here. |
| + if _, err := appconfig.ApplyEnvironment(ctx, fx.rel, release.OpsAccount, "sprout", "acme", |
| + map[string]appconfig.Var{ |
| + "APP_ORIGIN": {Kind: "env", Value: "https://acme.example.com"}, |
| + "ACME_API_KEY": {Kind: "secret", Envelope: "sealed-envelope-bytes"}, |
| + }, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + // ...and a store of its own, provisioned, which nothing reads. |
| + rec := &objstore.Record{ |
| + Declared: &objstore.Declared{EnvPrefix: "CARLOS_STORE", At: time.Now(), By: "member@example.com"}, |
| + Grant: &objstore.GrantBlock{Backend: "mem", Bucket: "carlos-bab-sprout-acme", Source: objstore.GrantSourceOperator, At: time.Now(), By: "op"}, |
| + Provisioned: &objstore.Provisioned{Bucket: "carlos-bab-sprout-acme", Region: "eu-west-1", IAMUser: "u", KeyID: "K", At: time.Now()}, |
| + } |
| + if _, err := objstore.PutIn(ctx, fx.rel, release.OpsAccount, "sprout", "acme", rec, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + c := signInPaul(t, fx) |
| + status, _, body := get(t, c, fx.u("/apps/sprout/settings/environments")) |
| + if status != http.StatusOK { |
| + t.Fatalf("environments page = %d:\n%s", status, body) |
| + } |
| + for _, want := range []string{ |
| + "acme", // the bundle |
| + "APP_ORIGIN", // a name it sets |
| + "ACME_API_KEY", // including the sealed one, by name |
| + "carlos-bab-sprout-acme", // the store that belongs to it |
| + "no instances", // nothing is bound to it |
| + "no instance reads it", // ...so the bucket is billing for nobody |
| + } { |
| + if !strings.Contains(body, want) { |
| + t.Fatalf("the environments section is missing %q:\n%s", want, body) |
| + } |
| + } |
| + // NAMES, never values. This page has never shown a config value, and an |
| + // environment bundle is exactly where a per-tenant origin or credential |
| + // sits — so this is the assertion that keeps it that way. |
| + if strings.Contains(body, "https://acme.example.com") { |
| + t.Fatalf("the environments section printed a config VALUE:\n%s", body) |
| + } |
| + if strings.Contains(body, "sealed-envelope-bytes") { |
| + t.Fatalf("the environments section printed a sealed envelope:\n%s", body) |
| + } |
| +} |
| + |
| +// An app with no named environments says so, and says how to make one — |
| +// rather than rendering an empty shell that reads like a failure. |
| +func TestSettingsPageEnvironmentsEmptyState(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + freshApp(t, fx, "bare") |
| + c := signInPaul(t, fx) |
| + |
| + status, _, body := get(t, c, fx.u("/apps/bare/settings/environments")) |
| + if status != http.StatusOK { |
| + t.Fatalf("environments page = %d:\n%s", status, body) |
| + } |
| + if !strings.Contains(body, "No named environments") { |
| + t.Fatalf("no empty-state line:\n%s", body) |
| + } |
| + if !strings.Contains(body, "carlos env set --app bare") { |
| + t.Fatalf("the empty state does not say how to make one:\n%s", body) |
| + } |
| +} |
diff --git a/internal/console/economicspass.go b/internal/console/economicspass.go| index bfcb8a251..ab8f31261 100644 |
| --- a/internal/console/economicspass.go |
| +++ b/internal/console/economicspass.go |
| @@ -168,25 +168,44 @@ func (s *Server) storagePass(ctx context.Context, date string) error { |
| for _, ks := range sizes { |
| st.BucketBytes += ks.Size |
| } |
| - rec, _, gerr := objstore.Get(ctx, s.cfg.Releases, acct, app) |
| - if gerr != nil { |
| - // Absent is (nil, "", nil) — objstore.Get's own idiom — so a |
| - // non-nil error here is a REAL failure (transient bucket |
| - // read, malformed store.json), not "no store". Treating it |
| - // as "no store" would write StoreBytes:0/StoreUnswept:false |
| - // into a create-only object, indistinguishable from an app |
| - // that never had a store and never correctable afterward. |
| - log.Printf("console: storage pass: store record %s/%s: %v", acct, app, gerr) |
| + // EVERY store this app has: its own, and one per config |
| + // environment (2026-09-19). They sum into ONE figure under the |
| + // app's usage key, so StorageDay's wire shape gains no dimension |
| + // and an account's bill is the same arithmetic over more buckets. |
| + // Per-store attribution is discussion #140's ask, and deliberately |
| + // not made here. |
| + envs, eerr := objstore.StoreEnvironments(ctx, s.cfg.Releases, acct, app) |
| + if eerr != nil { |
| + // Same rule as an unreadable record below: "could not ask" is |
| + // not "no store", and this object is create-only. |
| + log.Printf("console: storage pass: store environments %s/%s: %v", acct, app, eerr) |
| st.StoreUnswept = true |
| - } else if rec != nil && rec.Provisioned != nil { |
| + } |
| + for _, env := range append([]string{""}, envs...) { |
| + rec, _, gerr := objstore.GetIn(ctx, s.cfg.Releases, acct, app, env) |
| + if gerr != nil { |
| + // Absent is (nil, "", nil) — objstore.GetIn's own idiom — |
| + // so a non-nil error here is a REAL failure (transient |
| + // bucket read, malformed store.json), not "no store". |
| + // Treating it as "no store" would write |
| + // StoreBytes:0/StoreUnswept:false into a create-only |
| + // object, indistinguishable from an app that never had a |
| + // store and never correctable afterward. |
| + log.Printf("console: storage pass: store record %s/%s%s: %v", acct, app, storeEnvSuffix(env), gerr) |
| + st.StoreUnswept = true |
| + continue |
| + } |
| + if rec == nil || rec.Provisioned == nil { |
| + continue |
| + } |
| st.StoreProvisioned = true |
| n, serr := s.sizeStore(ctx, rec.Provisioned) |
| if serr != nil { |
| - log.Printf("console: storage pass: store %s/%s: %v", acct, app, serr) |
| + log.Printf("console: storage pass: store %s/%s%s: %v", acct, app, storeEnvSuffix(env), serr) |
| st.StoreUnswept = true |
| - } else { |
| - st.StoreBytes = n |
| + continue |
| } |
| + st.StoreBytes += n |
| } |
| day.Apps[acct+"/"+app] = st |
| } |
diff --git a/internal/console/ownership.go b/internal/console/ownership.go| index d183623bb..b0b355d81 100644 |
| --- a/internal/console/ownership.go |
| +++ b/internal/console/ownership.go |
| @@ -397,49 +397,16 @@ func (s *Server) handlePurgeApp(w http.ResponseWriter, r *http.Request) { |
| fail("“" + app + "” has a provisioned email credential, and this console has no SES backend configured — it cannot be deleted here until email is torn down.") |
| return |
| } |
| - // A provisioned store is torn down BEFORE the prefix sweep — this door |
| - // is the only purge path with backend authority (objstore spec §9; |
| + // EVERY store this app has is torn down BEFORE the prefix sweep — this |
| + // door is the only purge path with backend authority (objstore spec §9; |
| // box-side auto-purge refuses via release.ErrStoreProvisioned). Failure |
| - // aborts the purge with the record intact: re-entering resumes. |
| - // Guard on Provisioned OR Grant (controller ruling, Task 5 review): a |
| - // grant block without a provisioned block is a crashed grant whose |
| - // backend estate is real. |
| - // Held across the read and the walk both: the estate check below is made |
| - // from this record, and a grant landing in between would leave the purge |
| - // sweeping a prefix whose bucket and IAM user were just re-created |
| - // (objstore/walk.go:560's window, from the other end). |
| - unlockStoreWalk := s.storeWalks.lock(acct.Sqid, app) |
| - defer unlockStoreWalk() |
| - if rec, _, rerr := objstore.Get(r.Context(), s.cfg.Releases, acct.Sqid, app); rerr == nil && rec != nil && (rec.Provisioned != nil || rec.Grant != nil) { |
| - if s.cfg.StoreProvisioner == nil { |
| - fail("“" + app + "” has a provisioned object store, and this console has no storage backend configured (CARLOS_STORE_BACKEND) — it cannot be deleted here until the store is torn down.") |
| - return |
| - } |
| - // The walk gets its own ceiling rather than the request's open-ended |
| - // context: a teardown is many round trips (EmptyBucket is paginated), |
| - // and it must not be able to hold this handler open indefinitely. |
| - tctx, cancel := context.WithTimeout(r.Context(), storeTeardownTimeout) |
| - defer cancel() |
| - if terr := objstore.Teardown(tctx, s.cfg.Releases, s.cfg.StoreProvisioner, s.cfg.StoreScanner, acct.Sqid, app); terr != nil { |
| - log.Printf("console: purge store teardown %s/%s: %v", acct.Sqid, app, terr) |
| - fail("Could not tear down “" + app + "”'s object store — nothing was deleted; try again.") |
| - return |
| - } |
| - // rec.Provisioned may be nil here (crashed-grant shape) — name the |
| - // bucket from Grant, which the guard condition proves non-nil when |
| - // Provisioned is. |
| - bucket := "" |
| - if rec.Provisioned != nil { |
| - bucket = rec.Provisioned.Bucket |
| - } else if rec.Grant != nil { |
| - bucket = rec.Grant.Bucket |
| - } |
| - s.audit(acct, app, addr, "store-destroy", "tore down the object store (bucket "+bucket+") during permanent deletion") |
| - } else if rerr != nil { |
| - log.Printf("console: purge store read %s/%s: %v", acct.Sqid, app, rerr) |
| - fail("Could not read “" + app + "”'s object-store record — nothing was deleted; try again.") |
| + // aborts the purge with the records intact: re-entering resumes. |
| + if msg := s.purgeAppStores(r.Context(), acct, app, addr); msg != "" { |
| + fail(msg) |
| return |
| } |
| + // (purgeAppStores is below.) |
| + // |
| // An email estate is torn down BEFORE the prefix sweep too, for the |
| // identical reason the store is above (I2, review follow-up): |
| // mail.Teardown is the ONLY place that releases this app's domain |
| @@ -714,3 +681,80 @@ func (s *Store) DeleteAccount(accountID int64, now time.Time) error { |
| } |
| return tx.Commit() |
| } |
| + |
| +// purgeAppStores tears down every store this app has — its own and one per |
| +// config environment — and returns the flash to show on failure, "" on |
| +// success. |
| +// |
| +// Since 2026-09-19 an app may have more than one store, and release.PurgeApp |
| +// refuses the box-side auto-purge while ANY of them holds a grant or a |
| +// provisioned block. So a purge that tore down only the app's own store would |
| +// not leave a partial cleanup behind: it would leave an app that can never be |
| +// purged at all, by this door or any other. |
| +// |
| +// One lock per store, held across that store's read AND its walk — the estate |
| +// check is made from the record, and a grant landing in between would leave |
| +// the sweep deleting a prefix whose bucket and IAM user were just re-created |
| +// (objstore/walk.go's window, from the other end). Taken in turn rather than |
| +// all at once: two stores of one app share no estate, so there is nothing to |
| +// serialise between them, and holding several locks to do nothing with them |
| +// is how a deadlock gets written later. |
| +func (s *Server) purgeAppStores(ctx context.Context, acct *Account, app, addr string) string { |
| + envs, err := objstore.StoreEnvironments(ctx, s.cfg.Releases, acct.Sqid, app) |
| + if err != nil { |
| + // Refusing on an unreadable listing is the same direction as |
| + // refusing on an unreadable record: "I could not ask" is not "no |
| + // store", and the sweep below is irreversible. |
| + log.Printf("console: purge store list %s/%s: %v", acct.Sqid, app, err) |
| + return "Could not list “" + app + "”'s object stores — nothing was deleted; try again." |
| + } |
| + // The app's own store LAST. It is the one whose grant block the purge |
| + // guard has always keyed on, so a run that stops part way leaves a state |
| + // that still reads as "this app has a store" — which is true, and which |
| + // re-entering resumes. |
| + for _, env := range append(envs, "") { |
| + if msg := s.purgeOneStore(ctx, acct, app, env, addr); msg != "" { |
| + return msg |
| + } |
| + } |
| + return "" |
| +} |
| + |
| +// purgeOneStore is purgeAppStores' body for a single store. Guard on |
| +// Provisioned OR Grant (controller ruling, Task 5 review): a grant block |
| +// without a provisioned block is a crashed grant whose backend estate is |
| +// real. |
| +func (s *Server) purgeOneStore(ctx context.Context, acct *Account, app, env, addr string) string { |
| + defer s.storeWalks.lock(acct.Sqid, app, env)() |
| + rec, _, rerr := objstore.GetIn(ctx, s.cfg.Releases, acct.Sqid, app, env) |
| + if rerr != nil { |
| + log.Printf("console: purge store read %s/%s%s: %v", acct.Sqid, app, storeEnvSuffix(env), rerr) |
| + return "Could not read “" + app + "”'s object-store record — nothing was deleted; try again." |
| + } |
| + if rec == nil || (rec.Provisioned == nil && rec.Grant == nil) { |
| + return "" |
| + } |
| + if s.cfg.StoreProvisioner == nil { |
| + return "“" + app + "” has a provisioned object store, and this console has no storage backend configured (CARLOS_STORE_BACKEND) — it cannot be deleted here until the store is torn down." |
| + } |
| + // The walk gets its own ceiling rather than the request's open-ended |
| + // context: a teardown is many round trips (EmptyBucket is paginated), and |
| + // it must not be able to hold this handler open indefinitely. |
| + tctx, cancel := context.WithTimeout(ctx, storeTeardownTimeout) |
| + defer cancel() |
| + if terr := objstore.TeardownIn(tctx, s.cfg.Releases, s.cfg.StoreProvisioner, s.cfg.StoreScanner, acct.Sqid, app, env); terr != nil { |
| + log.Printf("console: purge store teardown %s/%s%s: %v", acct.Sqid, app, storeEnvSuffix(env), terr) |
| + return "Could not tear down “" + app + "”'s object store — nothing was deleted; try again." |
| + } |
| + // rec.Provisioned may be nil here (crashed-grant shape) — name the bucket |
| + // from Grant, which the guard above proves non-nil when Provisioned is. |
| + bucket := "" |
| + if rec.Provisioned != nil { |
| + bucket = rec.Provisioned.Bucket |
| + } else if rec.Grant != nil { |
| + bucket = rec.Grant.Bucket |
| + } |
| + s.audit(acct, app, addr, "store-destroy", |
| + "tore down the object store (bucket "+bucket+")"+storeEnvSuffix(env)+" during permanent deletion") |
| + return "" |
| +} |
diff --git a/internal/console/server.go b/internal/console/server.go| index f0fd18ef3..7c1aedd22 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -3228,6 +3228,7 @@ func setAppSettingsSection(p *page, section string) { |
| {"general", "General"}, |
| {"domains", "Domains"}, |
| {"pipeline", "Release pipeline"}, |
| + {"environments", "Environments"}, |
| {"storage", "Storage"}, |
| {"email", "Email"}, |
| {"features", "Feature flags"}, |
diff --git a/internal/console/store_env_estate_test.go b/internal/console/store_env_estate_test.go| new file mode 100644 |
| index 000000000..b3358f166 |
| --- /dev/null |
| +++ b/internal/console/store_env_estate_test.go |
| @@ -0,0 +1,187 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "net/http" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/objstore" |
| + "github.com/carlosframework/platform/internal/usage" |
| +) |
| + |
| +// An app may have several stores since 2026-09-19 — its own, and one per |
| +// config environment. Two places have to count them ALL: the metering pass, |
| +// which bills for them, and the purge path, which is the only door with the |
| +// authority to tear them down. |
| + |
| +func provisionedRecord(bucket string) *objstore.Record { |
| + now := time.Now().UTC() |
| + return &objstore.Record{ |
| + Declared: &objstore.Declared{EnvPrefix: "CARLOS_STORE", At: now, By: "member@example.com"}, |
| + Grant: &objstore.GrantBlock{Backend: "mem", Bucket: bucket, Source: objstore.GrantSourceOperator, At: now, By: "op"}, |
| + Provisioned: &objstore.Provisioned{Bucket: bucket, Region: "eu-west-1", IAMUser: "u-" + bucket, KeyID: "K", At: now}, |
| + } |
| +} |
| + |
| +func readStorageDay(t *testing.T, fx *consoleFixture, date string) usage.StorageDay { |
| + t.Helper() |
| + raw, _, err := fx.rel.Get(context.Background(), usage.StorageDayKey(date)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var day usage.StorageDay |
| + if err := json.Unmarshal(raw, &day); err != nil { |
| + t.Fatal(err) |
| + } |
| + return day |
| +} |
| + |
| +// The bill is the sum over every store of the app. Sizes differ per bucket so |
| +// this fails if either store is dropped, rather than passing on a coincidence. |
| +func TestStoragePassSumsEveryStoreOfAnApp(t *testing.T) { |
| + ctx := context.Background() |
| + fx := newConsoleFixture(t) |
| + |
| + if _, err := objstore.Put(ctx, fx.rel, fixtureAcct, fixtureApp, provisionedRecord("app-bucket"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := objstore.PutIn(ctx, fx.rel, fixtureAcct, fixtureApp, "beta", provisionedRecord("beta-bucket"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + fx.srv.storeSizer = func(_ context.Context, p *objstore.Provisioned) (int64, error) { |
| + switch p.Bucket { |
| + case "app-bucket": |
| + return 1000, nil |
| + case "beta-bucket": |
| + return 2000, nil |
| + } |
| + t.Errorf("sized an unexpected bucket %q", p.Bucket) |
| + return 0, nil |
| + } |
| + |
| + if err := fx.srv.storagePass(ctx, "2026-09-19"); err != nil { |
| + t.Fatal(err) |
| + } |
| + app := readStorageDay(t, fx, "2026-09-19").Apps[fixtureAcct+"/"+fixtureApp] |
| + if app.StoreBytes != 3000 { |
| + t.Errorf("StoreBytes = %d, want 3000 (1000 app + 2000 beta)", app.StoreBytes) |
| + } |
| + if !app.StoreProvisioned { |
| + t.Error("StoreProvisioned = false, want true") |
| + } |
| + if app.StoreUnswept { |
| + t.Error("StoreUnswept = true, but every store was sized") |
| + } |
| +} |
| + |
| +// "Could not ask" is not "no bytes". The day object is create-only, so a |
| +// zero written here can never be corrected — an unreadable environment store |
| +// has to mark the day unswept, exactly as an unreadable app store does. |
| +func TestStoragePassMarksUnsweptOnAnUnreadableEnvironmentStore(t *testing.T) { |
| + ctx := context.Background() |
| + fx := newConsoleFixture(t) |
| + |
| + if _, err := objstore.Put(ctx, fx.rel, fixtureAcct, fixtureApp, provisionedRecord("app-bucket"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := fx.rel.Put(ctx, objstore.KeyIn(fixtureAcct, fixtureApp, "beta"), []byte("{not valid json"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + fx.srv.storeSizer = func(_ context.Context, p *objstore.Provisioned) (int64, error) { |
| + return 1000, nil |
| + } |
| + |
| + if err := fx.srv.storagePass(ctx, "2026-09-19"); err != nil { |
| + t.Fatal(err) |
| + } |
| + app := readStorageDay(t, fx, "2026-09-19").Apps[fixtureAcct+"/"+fixtureApp] |
| + if !app.StoreUnswept { |
| + t.Error("StoreUnswept = false, but one of this app's stores could not be read") |
| + } |
| + // The store it COULD read is still counted: a partial figure marked |
| + // unswept is honest, a dropped one is not. |
| + if app.StoreBytes != 1000 { |
| + t.Errorf("StoreBytes = %d, want the app store's 1000", app.StoreBytes) |
| + } |
| +} |
| + |
| +// grantStoreIn drives the real doors, so the provisioner genuinely holds a |
| +// bucket, an IAM user and a key for each store — the estate a purge has to |
| +// find. grantStore (apiscan_test.go) is this with no environment. |
| +func grantStoreIn(t *testing.T, fx *consoleFixture, token, env string) { |
| + t.Helper() |
| + declareURL, grantURL := storeURL(fx, "titogo"), storeURL(fx, "titogo")+"/grant" |
| + if env != "" { |
| + declareURL = storeEnvURL(fx, "titogo", env) |
| + grantURL += "?environment=" + env |
| + } |
| + if status, out := postJSON(t, declareURL, map[string]any{}, token); status != http.StatusOK { |
| + t.Fatalf("declare %q = %d %v", env, status, out) |
| + } |
| + if status, out := postJSON(t, grantURL, map[string]any{}, token); status != http.StatusOK { |
| + t.Fatalf("grant %q = %d %v", env, status, out) |
| + } |
| +} |
| + |
| +// Purging an app tears down EVERY store it has. release.PurgeApp refuses the |
| +// box-side auto-purge while any of them holds a grant or provisioned block, |
| +// so a purge that missed one would not leave a partial cleanup — it would |
| +// leave an app that can never be purged at all. |
| +func TestPurgeAppStoresTearsDownEveryStore(t *testing.T) { |
| + ctx := context.Background() |
| + fx, fake, token, _ := storeArmed(t, "code-purge-every-store") |
| + grantStoreIn(t, fx, token, "") |
| + grantStoreIn(t, fx, token, "beta") |
| + |
| + if len(fake.Buckets) != 2 || len(fake.Users) != 2 { |
| + t.Fatalf("fixture drift: buckets %v users %v, want one of each per store", fake.Buckets, fake.Users) |
| + } |
| + |
| + acct := &Account{ID: OpsAccountID, Sqid: fx.sqid} |
| + if msg := fx.srv.purgeAppStores(ctx, acct, "titogo", "paul@keymail.dev"); msg != "" { |
| + t.Fatalf("purgeAppStores refused: %s", msg) |
| + } |
| + if len(fake.Buckets) != 0 { |
| + t.Errorf("buckets survived the purge: %v", fake.Buckets) |
| + } |
| + if len(fake.Users) != 0 { |
| + t.Errorf("IAM users survived the purge: %v", fake.Users) |
| + } |
| + for _, env := range []string{"", "beta"} { |
| + rec, _, err := objstore.GetIn(ctx, fx.rel, fx.sqid, "titogo", env) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if rec != nil { |
| + t.Errorf("store record for %q survived the purge: %+v", env, rec) |
| + } |
| + } |
| +} |
| + |
| +// A console with no storage backend cannot tear an estate down, so it must |
| +// refuse rather than let the prefix sweep delete the only records naming a |
| +// live bucket and a live credential. |
| +func TestPurgeAppStoresRefusesWithNoProvisioner(t *testing.T) { |
| + ctx := context.Background() |
| + fx, fake, token, _ := storeArmed(t, "code-purge-no-backend") |
| + grantStoreIn(t, fx, token, "beta") |
| + fx.srv.cfg.StoreProvisioner = nil |
| + |
| + acct := &Account{ID: OpsAccountID, Sqid: fx.sqid} |
| + msg := fx.srv.purgeAppStores(ctx, acct, "titogo", "paul@keymail.dev") |
| + if msg == "" { |
| + t.Fatal("purgeAppStores allowed a purge it could not carry out") |
| + } |
| + if len(fake.Buckets) != 1 { |
| + t.Errorf("the refused purge touched the backend: %v", fake.Buckets) |
| + } |
| + rec, _, err := objstore.GetIn(ctx, fx.rel, fx.sqid, "titogo", "beta") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if rec == nil { |
| + t.Error("the refused purge deleted the record naming a live estate") |
| + } |
| +} |
diff --git a/internal/console/storelock.go b/internal/console/storelock.go| index aaa350622..588f94690 100644 |
| --- a/internal/console/storelock.go |
| +++ b/internal/console/storelock.go |
| @@ -46,17 +46,27 @@ func newStoreWalkLocks() *storeWalkLocks { |
| return &storeWalkLocks{m: make(map[string]*storeWalkLock)} |
| } |
| -// lock blocks until this app's walk lock is held, and returns the release. |
| +// storeWalkKey is the map's key shape, in one place because tests inspect the |
| +// map to prove a second walk really queued. When the environment joined the |
| +// key on 2026-09-19 the literal was restated in two test files, and both went |
| +// on compiling while reading a refcount that could never move — they failed |
| +// as "the waiter never registered", which is a sentence about the lock rather |
| +// than about the key. Derive it here. |
| +func storeWalkKey(acct, app, env string) string { return acct + "/" + app + "/" + env } |
| + |
| +// lock blocks until this store's walk lock is held, and returns the release. |
| // The caller defers it: |
| // |
| -// defer s.storeWalks.lock(acct.Sqid, app)() |
| +// defer s.storeWalks.lock(acct.Sqid, app, env)() |
| // |
| -// The key is the account AND the app: two apps in one account share no estate |
| -// (one bucket and one IAM user each, both named for the pair — objstore's |
| -// MintedBucket and UserName), so serialising across them would cost |
| -// concurrency and buy nothing. |
| -func (l *storeWalkLocks) lock(acct, app string) func() { |
| - key := acct + "/" + app |
| +// The key is the account, the app AND the environment ("" for the app's own |
| +// store): two apps in one account share no estate, and since 2026-09-19 two |
| +// STORES of one app share none either — a bucket, an IAM user and a config |
| +// bundle each, all named for the triple (objstore's MintedBucketIn and |
| +// UserNameIn). Serialising across any of them would cost concurrency and buy |
| +// nothing. |
| +func (l *storeWalkLocks) lock(acct, app, env string) func() { |
| + key := storeWalkKey(acct, app, env) |
| l.mu.Lock() |
| e := l.m[key] |
| if e == nil { |
diff --git a/internal/console/storelock_test.go b/internal/console/storelock_test.go| index c04d75e24..ed8db43a4 100644 |
| --- a/internal/console/storelock_test.go |
| +++ b/internal/console/storelock_test.go |
| @@ -6,9 +6,9 @@ import ( |
| "time" |
| ) |
| -// The property the walks reason from: two holders of one app's lock never run |
| -// at once. Asserted by counting concurrent occupancy rather than by timing, |
| -// so the test fails on a broken lock rather than on a slow machine. |
| +// The property the walks reason from: two holders of one store's lock never |
| +// run at once. Asserted by counting concurrent occupancy rather than by |
| +// timing, so the test fails on a broken lock rather than on a slow machine. |
| func TestStoreWalkLocksExcludePerApp(t *testing.T) { |
| l := newStoreWalkLocks() |
| var mu sync.Mutex |
| @@ -19,7 +19,7 @@ func TestStoreWalkLocksExcludePerApp(t *testing.T) { |
| wg.Add(1) |
| go func() { |
| defer wg.Done() |
| - defer l.lock("bab", "titogo")() |
| + defer l.lock("bab", "titogo", "")() |
| mu.Lock() |
| live++ |
| if live > maxLive { |
| @@ -42,12 +42,12 @@ func TestStoreWalkLocksExcludePerApp(t *testing.T) { |
| // the (account, app) pair — so their walks must not queue behind each other. |
| func TestStoreWalkLocksDoNotSerialiseAcrossApps(t *testing.T) { |
| l := newStoreWalkLocks() |
| - release := l.lock("bab", "titogo") |
| + release := l.lock("bab", "titogo", "") |
| defer release() |
| done := make(chan struct{}) |
| go func() { |
| - l.lock("bab", "other")() |
| + l.lock("bab", "other", "")() |
| close(done) |
| }() |
| select { |
| @@ -57,10 +57,32 @@ func TestStoreWalkLocksDoNotSerialiseAcrossApps(t *testing.T) { |
| } |
| } |
| +// Two STORES of one app share no estate either (2026-09-19): a bucket, an IAM |
| +// user and a config bundle each, all named for the (account, app, |
| +// environment) triple. An environment store's walk must not queue behind the |
| +// app store's, or behind another environment's. |
| +func TestStoreWalkLocksDoNotSerialiseAcrossEnvironments(t *testing.T) { |
| + l := newStoreWalkLocks() |
| + release := l.lock("bab", "titogo", "") |
| + defer release() |
| + |
| + done := make(chan struct{}) |
| + go func() { |
| + l.lock("bab", "titogo", "beta")() |
| + l.lock("bab", "titogo", "alpha")() |
| + close(done) |
| + }() |
| + select { |
| + case <-done: |
| + case <-time.After(2 * time.Second): |
| + t.Fatal("a walk on another store of the same app blocked behind this one's lock") |
| + } |
| +} |
| + |
| // An app walked once must not hold a mutex for the life of the process. |
| func TestStoreWalkLocksDropEntriesAtZero(t *testing.T) { |
| l := newStoreWalkLocks() |
| - l.lock("bab", "titogo")() |
| + l.lock("bab", "titogo", "")() |
| l.mu.Lock() |
| n := len(l.m) |
| @@ -75,16 +97,16 @@ func TestStoreWalkLocksDropEntriesAtZero(t *testing.T) { |
| // mutex and silently defeat the exclusion above. |
| func TestStoreWalkLocksKeepEntryWhileWaitersQueue(t *testing.T) { |
| l := newStoreWalkLocks() |
| - first := l.lock("bab", "titogo") |
| + first := l.lock("bab", "titogo", "") |
| queued := make(chan func(), 1) |
| - go func() { queued <- l.lock("bab", "titogo") }() |
| + go func() { queued <- l.lock("bab", "titogo", "") }() |
| // Give the waiter time to register its reference before we look. |
| deadline := time.Now().Add(2 * time.Second) |
| for { |
| l.mu.Lock() |
| - e := l.m["bab/titogo"] |
| + e := l.m[storeWalkKey("bab", "titogo", "")] |
| refs := 0 |
| if e != nil { |
| refs = e.refs |
diff --git a/internal/console/templates/app_settings.html b/internal/console/templates/app_settings.html| index d6a17e9cd..1e7e857c3 100644 |
| --- a/internal/console/templates/app_settings.html |
| +++ b/internal/console/templates/app_settings.html |
| @@ -161,6 +161,45 @@ different account moves its bucket objects and re-stamps its routes — |
| offers the final console-record step.</p> |
| {{end}}{{end}} |
| {{end}} |
| +{{if eq .SettingsSection "environments"}} |
| +{{if .Settings.NoBucket}}<p class="banner error">no deployment bucket on this host — this console cannot read config</p>{{end}} |
| +{{if .Settings.EnvsUnavailable}}<p class="banner error">Could not read this app's environments. Please try again.</p>{{end}} |
| +{{if .Settings}} |
| +<h2>Environments</h2> |
| +<p class="small">A named environment is a config bundle that overlays this |
| +app's default one, per key. An instance bound to it reads the app's config |
| +with these values on top — which is how two instances of one app serve |
| +different origins, and how one of them gets a bucket of its own.</p> |
| +{{if .Settings.Envs}} |
| +{{range .Settings.Envs}} |
| +<h3><code>{{.Name}}</code></h3> |
| +<div class="tablewrap"> |
| +<table> |
| + <tr><th>Sets</th><th>Read by</th><th>Object store</th></tr> |
| + <tr> |
| + <td>{{if .Vars}}{{range $i, $v := .Vars}}{{if $i}}, {{end}}<code>{{$v.Name}}</code>{{if $v.Secret}} <span class="small">sealed</span>{{end}}{{end}}{{else}}<span title="the bundle exists but sets nothing">—</span>{{end}}</td> |
| + <td>{{if .Hosts}}{{range $i, $h := .Hosts}}{{if $i}}, {{end}}<a href="{{$.Nav}}/apps/{{$.Detail.App}}/instances/{{$h}}">{{$h}}</a>{{end}}{{else}}<span title="no instance is bound to this bundle">no instances</span>{{end}}</td> |
| + <td>{{if .HasStore}}{{if .StoreBucket}}<code>{{.StoreBucket}}</code>{{else}}declared{{end}}{{else}}<span title="this environment has no store of its own">—</span>{{end}}</td> |
| + </tr> |
| +</table> |
| +</div> |
| +{{if and .HasStore (not .Hosts)}}<p class="small">This environment has an object |
| +store but no instance reads it. The bucket still exists and still costs — |
| +destroying a store is always deliberate, so nothing has removed it. Its card is |
| +under <a href="{{$.Nav}}/apps/{{$.Detail.App}}/settings/storage">Storage</a>.</p>{{end}} |
| +{{end}} |
| +<p class="small">Values are not shown here. Read them with <code>carlos env list |
| +--app {{.Detail.App}} --environment <name></code>; secrets list by name only, |
| +wherever you ask.</p> |
| +{{else}}{{if not .Settings.EnvsUnavailable}} |
| +<p class="small">No named environments. Every instance of this app reads its |
| +default config bundle. Add one with <code>carlos env set --app {{.Detail.App}} |
| +--environment <name> KEY=value</code>, then bind an instance to it with |
| +<code>carlos instances set-environment --app {{.Detail.App}} --host <h> |
| +--environment <name></code>.</p> |
| +{{end}}{{end}} |
| +{{end}} |
| +{{end}} |
| {{if eq .SettingsSection "storage"}} |
| {{if .Settings.NoBucket}}<p class="banner error">no deployment bucket on this host — this console cannot serve object storage</p>{{end}} |
| {{if .Settings.StoreUnavailable}}<p class="banner error">Could not read platform state. Please try again.</p>{{end}} |
| @@ -220,6 +259,52 @@ credentials — an operator of this deployment has to tear it down.</p> |
| and delivers its credentials into this app's config.</p> |
| {{end}} |
| {{end}}{{end}} |
| +{{with .Settings}}{{range .StoreEnvs}} |
| +<h2>Object storage — {{.Environment}} environment</h2> |
| +<p class="small">Its own bucket and its own credential, delivered into the |
| +<code>{{.Environment}}</code> config bundle — so only the instances bound to |
| +that environment can reach it.</p> |
| +<div class="tablewrap"> |
| +<table> |
| + <tr><th>Env prefix</th><th>Backend</th><th>Bucket</th><th>Access key</th><th>Key age</th></tr> |
| + <tr> |
| + <td>{{if .EnvPrefix}}<code>{{.EnvPrefix}}_*</code>{{else}}<span title="no declaration on the record">—</span>{{end}}</td> |
| + <td>{{if .Backend}}{{.Backend}}{{else}}<span title="not granted yet">—</span>{{end}}</td> |
| + <td>{{if .Bucket}}<code>{{.Bucket}}</code>{{else}}<span title="not granted yet">—</span>{{end}}</td> |
| + <td>{{if .KeyID}}<code>{{.KeyID}}</code>{{else}}<span title="no credentials delivered yet">—</span>{{end}}</td> |
| + <td>{{if .KeyAge}}{{.KeyAge}}{{else}}<span title="unknown">—</span>{{end}}</td> |
| + </tr> |
| +</table> |
| +</div> |
| +{{if and (not .Granted) (not .Provisioned)}}<p class="small">Declared and waiting: |
| +an operator of this deployment creates the bucket and delivers the credentials.</p>{{end}} |
| +{{if .PendingFinish}}<p class="small">A rotation is half-finished — two keys open |
| +this bucket until you retire the old one:</p> |
| +<pre><code>carlos store rotate --finish --app {{$.Detail.App}} --environment {{.Environment}}</code></pre>{{end}} |
| +{{if $.IsOwner}} |
| +<h3 class="danger-head">Destroy this store</h3> |
| +{{if .CanDestroy}} |
| +<p class="small">Destroying empties and deletes |
| +{{if .Bucket}}<code>{{.Bucket}}</code>{{else}}the store{{end}}, deletes the |
| +credentials that opened it, and removes the delivered variables from the |
| +<code>{{.Environment}}</code> bundle. The app's own store is not touched. There is |
| +no trash for a store: the objects in it are gone.</p> |
| +<form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/store/destroy" class="settings-row"> |
| + <input type="hidden" name="csrf" value="{{$.CSRF}}"> |
| + <input type="hidden" name="environment" value="{{.Environment}}"> |
| + <label class="visually-hidden" for="store-destroy-confirm-{{.Environment}}">Type {{if .Bucket}}the bucket name{{else}}the app's name{{end}} to confirm</label> |
| + <input type="text" id="store-destroy-confirm-{{.Environment}}" name="confirm" |
| + placeholder="type {{if .Bucket}}{{.Bucket}}{{else}}{{$.Detail.App}}{{end}} to confirm" autocomplete="off" required> |
| + <button type="submit" class="btn-sm btn-danger">Destroy store</button> |
| +</form> |
| +<p class="small">Confirms with your passkey.</p> |
| +{{else}} |
| +<p class="small">This console has no storage backend configured |
| +(<code>CARLOS_STORE_BACKEND</code>), so it cannot delete this bucket or its |
| +credentials — an operator of this deployment has to tear it down.</p> |
| +{{end}} |
| +{{end}} |
| +{{end}}{{end}} |
| {{end}} |
| {{if eq .SettingsSection "general"}} |
| <h2>Errors</h2> |
diff --git a/internal/objstore/objstore.go b/internal/objstore/objstore.go| index 6299c2abf..77737ff86 100644 |
| --- a/internal/objstore/objstore.go |
| +++ b/internal/objstore/objstore.go |
| @@ -12,8 +12,11 @@ import ( |
| "fmt" |
| "os" |
| "regexp" |
| + "sort" |
| + "strings" |
| "time" |
| + "github.com/carlosframework/platform/internal/appconfig" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -128,16 +131,82 @@ type ScanBlock struct { |
| // it has never heard of, which TestPurgeGuardIgnoresScanningBlock asserts |
| // directly. That freedom does NOT extend to `grant` and `provisioned`. |
| type Record struct { |
| - Schema int `json:"schema"` |
| - App string `json:"app"` |
| + Schema int `json:"schema"` |
| + App string `json:"app"` |
| + // Environment names the config bundle this store belongs to and delivers |
| + // into. "" is the app's own store — the only shape that existed before |
| + // 2026-09-19 — and is omitted, so every record written before this field |
| + // existed parses as the app store it is. |
| + // |
| + // Stamped by Put from the key it is written to and never trusted from a |
| + // caller, exactly as App is: a record that disagrees with its own key is |
| + // refused rather than guessed at (GetIn). |
| + Environment string `json:"environment,omitempty"` |
| Declared *Declared `json:"declared,omitempty"` |
| Grant *GrantBlock `json:"grant,omitempty"` |
| Provisioned *Provisioned `json:"provisioned,omitempty"` |
| Scanning *ScanBlock `json:"scanning,omitempty"` |
| } |
| +// Key is the app's own store — KeyIn(acct, app, "") by another name, kept |
| +// because it is what every caller that has no environment in hand asks for. |
| func Key(acct, app string) string { return release.AppPrefix(acct, app) + "store.json" } |
| +// StoresPrefix lists an app's ENVIRONMENT stores. The app's own store is NOT |
| +// under it: store.json predates this collection and does not move (spec §4), |
| +// so a reader after every store of an app reads Key AND lists this prefix — |
| +// which is exactly what release.PurgeApp's guard does. |
| +func StoresPrefix(acct, app string) string { return release.AppPrefix(acct, app) + "stores/" } |
| + |
| +// KeyIn is the record key for one store of an app: the app's own when env is |
| +// "", else the store belonging to that named config environment. A sibling |
| +// collection, not a key nested under an instance — nothing in the bucket |
| +// nests under an instance, and apps/<acct>/<app>/sidecars/<name>.json is the |
| +// shape this follows. |
| +func KeyIn(acct, app, env string) string { |
| + if env == "" { |
| + return Key(acct, app) |
| + } |
| + return StoresPrefix(acct, app) + env + ".json" |
| +} |
| + |
| +// StoreEnvironments names every ENVIRONMENT this app has a store for, sorted, |
| +// from a prefix listing — the app's own store is not among them. An app with |
| +// none lists empty, not an error, the same convention as appconfig's |
| +// ListEnvironments and for the same reason: "none" is a state a status page |
| +// has to print either way. |
| +// |
| +// A key that is not <env>.json is skipped rather than guessed at: this |
| +// prefix is ours alone, so anything else under it is somebody's hand edit, |
| +// and a malformed name must not become a bucket coordinate. |
| +func StoreEnvironments(ctx context.Context, s release.Store, acct, app string) ([]string, error) { |
| + if !release.ValidAccount(acct) { |
| + return nil, fmt.Errorf("objstore: invalid account %q", acct) |
| + } |
| + if !release.ValidApp(app) { |
| + return nil, fmt.Errorf("objstore: invalid app %q", app) |
| + } |
| + prefix := StoresPrefix(acct, app) |
| + keys, err := s.List(ctx, prefix) |
| + if err != nil { |
| + return nil, err |
| + } |
| + out := make([]string, 0, len(keys)) |
| + for _, key := range keys { |
| + name, ok := strings.CutPrefix(key, prefix) |
| + if !ok { |
| + continue |
| + } |
| + name, ok = strings.CutSuffix(name, ".json") |
| + if !ok || !appconfig.ValidEnvironmentName(name) { |
| + continue |
| + } |
| + out = append(out, name) |
| + } |
| + sort.Strings(out) |
| + return out, nil |
| +} |
| + |
| var envPrefixRe = regexp.MustCompile(`^[A-Z][A-Z0-9_]{0,31}$`) |
| func ValidEnvPrefix(p string) bool { return envPrefixRe.MatchString(p) } |
| @@ -151,13 +220,31 @@ func EnvNames(prefix string) (bucket, region, endpoint, accessKey, secretKey str |
| // Get reads the record. Absent is (nil, "", nil) — same idiom as |
| // appconfig.getIfExists, because "no store" is a state, not an error. |
| func Get(ctx context.Context, s release.Store, acct, app string) (*Record, string, error) { |
| + return GetIn(ctx, s, acct, app, "") |
| +} |
| + |
| +// GetIn reads one store of an app: the app's own when env is "", else the |
| +// store belonging to that named config environment. Absent is (nil, "", nil), |
| +// as in Get — "no store" is a state, not an error, for either shape. |
| +// |
| +// A record whose Environment disagrees with the key it was read from is |
| +// REFUSED, not corrected: the field is stamped from the key by PutIn, so a |
| +// disagreement means bucket data was hand-edited, and guessing which half is |
| +// right would deliver one environment's credentials under another's name. |
| +// Same rule provision.GetRecord applies to a record that lies about its own |
| +// identity. |
| +func GetIn(ctx context.Context, s release.Store, acct, app, env string) (*Record, string, error) { |
| if !release.ValidAccount(acct) { |
| return nil, "", fmt.Errorf("objstore: invalid account %q", acct) |
| } |
| if !release.ValidApp(app) { |
| return nil, "", fmt.Errorf("objstore: invalid app %q", app) |
| } |
| - raw, etag, err := s.Get(ctx, Key(acct, app)) |
| + if env != "" && !appconfig.ValidEnvironmentName(env) { |
| + return nil, "", fmt.Errorf("objstore: invalid environment %q", env) |
| + } |
| + key := KeyIn(acct, app, env) |
| + raw, etag, err := s.Get(ctx, key) |
| if err != nil { |
| if errors.Is(err, os.ErrNotExist) { |
| return nil, "", nil |
| @@ -166,7 +253,11 @@ func Get(ctx context.Context, s release.Store, acct, app string) (*Record, strin |
| } |
| var rec Record |
| if err := json.Unmarshal(raw, &rec); err != nil { |
| - return nil, "", fmt.Errorf("objstore: %s: %w", Key(acct, app), err) |
| + return nil, "", fmt.Errorf("objstore: %s: %w", key, err) |
| + } |
| + if rec.Environment != env { |
| + return nil, "", fmt.Errorf("objstore: %s names environment %q, not %q — refusing to read a record that disagrees with its own key", |
| + key, rec.Environment, env) |
| } |
| return &rec, etag, nil |
| } |
| @@ -175,11 +266,22 @@ func Get(ctx context.Context, s release.Store, acct, app string) (*Record, strin |
| // create-only. Every writer is a read-modify-write, so a lost race is |
| // release.ErrCASConflict for the caller to surface, never to absorb. |
| func Put(ctx context.Context, s release.Store, acct, app string, rec *Record, etag string) (string, error) { |
| + return PutIn(ctx, s, acct, app, "", rec, etag) |
| +} |
| + |
| +// PutIn writes one store of an app, stamping App and Environment from the key |
| +// it is writing to — neither is trusted from the caller, so a record can never |
| +// disagree with where it lives (GetIn refuses one that does). |
| +func PutIn(ctx context.Context, s release.Store, acct, app, env string, rec *Record, etag string) (string, error) { |
| + if env != "" && !appconfig.ValidEnvironmentName(env) { |
| + return "", fmt.Errorf("objstore: invalid environment %q", env) |
| + } |
| rec.Schema = Schema |
| rec.App = app |
| + rec.Environment = env |
| body, err := json.MarshalIndent(rec, "", " ") |
| if err != nil { |
| return "", err |
| } |
| - return s.Put(ctx, Key(acct, app), append(body, '\n'), etag) |
| + return s.Put(ctx, KeyIn(acct, app, env), append(body, '\n'), etag) |
| } |
diff --git a/internal/objstore/walk.go b/internal/objstore/walk.go| index dd50dc0c2..be0be2403 100644 |
| --- a/internal/objstore/walk.go |
| +++ b/internal/objstore/walk.go |
| @@ -60,13 +60,51 @@ type Deployment struct { |
| // MintedBucket is the name a store gets when the deployment mints one — the |
| // adopt case (an existing bucket named in the grant) never comes through here. |
| func MintedBucket(d Deployment, acct, app string) string { |
| - return d.BucketPrefix + acct + "-" + app |
| + return MintedBucketIn(d, acct, app, "") |
| +} |
| + |
| +// MintedBucketIn is MintedBucket for one store of an app. An environment |
| +// store carries the environment as a third component, so two stores of one |
| +// app can never resolve to one bucket. |
| +func MintedBucketIn(d Deployment, acct, app, env string) string { |
| + name := d.BucketPrefix + acct + "-" + app |
| + if env != "" { |
| + name += "-" + env |
| + } |
| + return name |
| } |
| // UserName is the IAM principal that owns an app's store credentials. One |
| // user per app: a key can be reaped or rotated without touching any other |
| // app, and the user's inline policy names exactly one bucket. |
| -func UserName(acct, app string) string { return "carlos-store-" + acct + "-" + app } |
| +func UserName(acct, app string) string { return UserNameIn(acct, app, "") } |
| + |
| +// UserNameIn is UserName for one store of an app — one principal per STORE, |
| +// which is what keeps an environment store's key reapable and rotatable |
| +// without touching the app's own, and its inline policy naming exactly one |
| +// bucket. |
| +func UserNameIn(acct, app, env string) string { |
| + name := "carlos-store-" + acct + "-" + app |
| + if env != "" { |
| + name += "-" + env |
| + } |
| + return name |
| +} |
| + |
| +// MaxIAMUserName is AWS's hard ceiling on an IAM user name. A name over it is |
| +// refused by CreateUser, deep in the walk and against live AWS — the worst |
| +// place to learn a store cannot be named, since the grant block is already |
| +// written by then and the caller is left with a record naming an estate that |
| +// was never created. |
| +const MaxIAMUserName = 64 |
| + |
| +// ValidUserName gates the principal name the same way ValidBucketName gates |
| +// the bucket: in front of the walk, before anything is created or recorded. |
| +// |
| +// The bucket half needs no separate ceiling — bucketNameRe's own length bound |
| +// IS S3's 63 — but nothing bounded the IAM name before environment stores |
| +// added a third component to it. |
| +func ValidUserName(name string) bool { return name != "" && len(name) <= MaxIAMUserName } |
| // EnvDelivered reports which of the delivered variables are NOT present in |
| // the app's config manifest AS THIS PACKAGE WRITES THEM — an empty result |
| @@ -90,7 +128,21 @@ func UserName(acct, app string) string { return "carlos-store-" + acct + "-" + a |
| // unsets or clobbers a delivered var must see the same "not delivered" the |
| // walk acts on. |
| func EnvDelivered(ctx context.Context, s release.Store, acct, app, prefix string, wantEndpoint bool) (missing []string, err error) { |
| - m, err := appconfig.Get(ctx, s, acct, app) |
| + return EnvDeliveredIn(ctx, s, acct, app, "", prefix, wantEndpoint) |
| +} |
| + |
| +// EnvDeliveredIn is EnvDelivered against the bundle a store actually delivers |
| +// into: the app's default bundle for the app's own store, and the named |
| +// environment's bundle for an environment store. Reading the default bundle |
| +// for an environment store would report every name missing on every walk — |
| +// the short-circuit would never hold and every re-run would mint a key. |
| +func EnvDeliveredIn(ctx context.Context, s release.Store, acct, app, env, prefix string, wantEndpoint bool) (missing []string, err error) { |
| + var m *appconfig.Manifest |
| + if env == "" { |
| + m, err = appconfig.Get(ctx, s, acct, app) |
| + } else { |
| + m, err = appconfig.LoadEnvironment(ctx, s, acct, app, env) |
| + } |
| if err != nil { |
| return nil, err |
| } |
| @@ -138,8 +190,18 @@ func ValidBucketName(name string) bool { return bucketNameRe.MatchString(name) } |
| // it must not re-choose the bucket. |
| func Grant(ctx context.Context, s release.Store, p Provisioner, d Deployment, |
| sealPub *ecdh.PublicKey, acct, app, actor, source, adoptBucket string, now time.Time) (*Record, error) { |
| + return GrantIn(ctx, s, p, d, sealPub, acct, app, "", actor, source, adoptBucket, now) |
| +} |
| + |
| +// GrantIn is Grant for one store of an app: the app's own when env is "", |
| +// else the store belonging to that named config environment, whose bucket, |
| +// IAM principal and delivered bundle are all its own. Every property Grant's |
| +// doc comment states holds here unchanged — the environment is another |
| +// coordinate of WHICH estate, never a change to how the walk behaves. |
| +func GrantIn(ctx context.Context, s release.Store, p Provisioner, d Deployment, |
| + sealPub *ecdh.PublicKey, acct, app, env, actor, source, adoptBucket string, now time.Time) (*Record, error) { |
| // 1. resolve + write the grant block |
| - rec, etag, err := Get(ctx, s, acct, app) |
| + rec, etag, err := GetIn(ctx, s, acct, app, env) |
| if err != nil { |
| return nil, err |
| } |
| @@ -158,7 +220,7 @@ func Grant(ctx context.Context, s release.Store, p Provisioner, d Deployment, |
| bucket = rec.Grant.Bucket // a re-run never re-chooses the bucket |
| } |
| if bucket == "" { |
| - bucket = MintedBucket(d, acct, app) |
| + bucket = MintedBucketIn(d, acct, app, env) |
| } |
| // Before anything is created or recorded: the name is about to become the |
| // IAM policy's resource ARN, so it is checked whether it was minted from a |
| @@ -166,9 +228,20 @@ func Grant(ctx context.Context, s release.Store, p Provisioner, d Deployment, |
| if !ValidBucketName(bucket) { |
| return nil, fmt.Errorf("objstore: invalid bucket name %q", bucket) |
| } |
| + // The principal's name is checked in the same breath, and for the same |
| + // reason: an environment store's name carries a third component, so a |
| + // long enough (account, app, environment) is unnameable to IAM. Learning |
| + // that at CreateUser would mean a grant block already written over an |
| + // estate that was never created — the shape Teardown has to treat as |
| + // live. The sentence names the parts, since the member can shorten one. |
| + user := UserNameIn(acct, app, env) |
| + if !ValidUserName(user) { |
| + return nil, fmt.Errorf("objstore: IAM user name %q is %d characters, over AWS's %d — shorten the account, app or environment name", |
| + user, len(user), MaxIAMUserName) |
| + } |
| if rec.Grant == nil { |
| rec.Grant = &GrantBlock{Backend: d.Backend, Bucket: bucket, Source: source, At: now, By: actor} |
| - if etag, err = Put(ctx, s, acct, app, rec, etag); err != nil { |
| + if etag, err = PutIn(ctx, s, acct, app, env, rec, etag); err != nil { |
| return nil, err |
| } |
| } |
| @@ -193,7 +266,6 @@ func Grant(ctx context.Context, s release.Store, p Provisioner, d Deployment, |
| if rec.Scanning != nil { |
| deny = rec.Scanning.Enforce |
| } |
| - user := UserName(acct, app) |
| arn, err := p.EnsureUser(ctx, user, UserPath, "arn:aws:s3:::"+bucket, deny) |
| if err != nil { |
| return nil, fmt.Errorf("objstore: ensure user %s: %w", user, err) |
| @@ -206,7 +278,7 @@ func Grant(ctx context.Context, s release.Store, p Provisioner, d Deployment, |
| // wrong one, which only a re-walk (which rewrites the set, and unsets |
| // _ENDPOINT when there is no longer an endpoint) can put right. |
| if rec.Provisioned != nil && rec.Provisioned.Endpoint == d.Endpoint { |
| - missing, err := EnvDelivered(ctx, s, acct, app, rec.Declared.EnvPrefix, d.Endpoint != "") |
| + missing, err := EnvDeliveredIn(ctx, s, acct, app, env, rec.Declared.EnvPrefix, d.Endpoint != "") |
| if err != nil { |
| return nil, err |
| } |
| @@ -236,7 +308,7 @@ func Grant(ctx context.Context, s release.Store, p Provisioner, d Deployment, |
| return nil, fmt.Errorf("objstore: mint key for %s: %w", user, err) |
| } |
| // 5. deliver — the secret's ONLY landing place (§7) |
| - if err := writeEnv(ctx, s, sealPub, acct, app, rec.Declared.EnvPrefix, bucket, region, d.Endpoint, id, secret); err != nil { |
| + if err := writeEnv(ctx, s, sealPub, acct, app, env, rec.Declared.EnvPrefix, bucket, region, d.Endpoint, id, secret); err != nil { |
| return nil, err |
| } |
| // 6. record success — after the secret is recorded, never before (§5). |
| @@ -249,7 +321,7 @@ func Grant(ctx context.Context, s release.Store, p Provisioner, d Deployment, |
| } |
| rec.Provisioned = &Provisioned{Bucket: bucket, Region: region, Endpoint: d.Endpoint, |
| IAMUser: user, UserARN: arn, KeyID: id, PrevKeyID: prev, At: now} |
| - if _, err := Put(ctx, s, acct, app, rec, etag); err != nil { |
| + if _, err := PutIn(ctx, s, acct, app, env, rec, etag); err != nil { |
| if !errors.Is(err, release.ErrCASConflict) { |
| return nil, err |
| } |
| @@ -271,7 +343,7 @@ func Grant(ctx context.Context, s release.Store, p Provisioner, d Deployment, |
| // DELIVERED a credential of its own; overwriting it would strand |
| // exactly what this retry protects against, so that conflict is the |
| // caller's to see. |
| - fresh, freshEtag, gerr := Get(ctx, s, acct, app) |
| + fresh, freshEtag, gerr := GetIn(ctx, s, acct, app, env) |
| if gerr != nil { |
| return nil, err |
| } |
| @@ -283,7 +355,7 @@ func Grant(ctx context.Context, s release.Store, p Provisioner, d Deployment, |
| return nil, err |
| } |
| fresh.Provisioned = rec.Provisioned |
| - if _, perr := Put(ctx, s, acct, app, fresh, freshEtag); perr != nil { |
| + if _, perr := PutIn(ctx, s, acct, app, env, fresh, freshEtag); perr != nil { |
| return nil, perr |
| } |
| return fresh, nil |
| @@ -334,7 +406,15 @@ func Grant(ctx context.Context, s release.Store, p Provisioner, d Deployment, |
| // delivered key, which is the exact failure the retry exists to prevent. |
| func Rotate(ctx context.Context, s release.Store, p Provisioner, sealPub *ecdh.PublicKey, |
| acct, app string, now time.Time) (*Record, error) { |
| - rec, etag, err := Get(ctx, s, acct, app) |
| + return RotateIn(ctx, s, p, sealPub, acct, app, "", now) |
| +} |
| + |
| +// RotateIn is Rotate for one store of an app. Coordinates come from that |
| +// store's own record, so a rotation of an environment store never reaches for |
| +// the app store's bucket, principal or bundle. |
| +func RotateIn(ctx context.Context, s release.Store, p Provisioner, sealPub *ecdh.PublicKey, |
| + acct, app, env string, now time.Time) (*Record, error) { |
| + rec, etag, err := GetIn(ctx, s, acct, app, env) |
| if err != nil { |
| return nil, err |
| } |
| @@ -365,13 +445,13 @@ func Rotate(ctx context.Context, s release.Store, p Provisioner, sealPub *ecdh.P |
| } |
| // 4. deliver — same names, same single CAS write, same validation of the |
| // credential halves before sealing (writeEnv owns that rail). |
| - if err := writeEnv(ctx, s, sealPub, acct, app, rec.Declared.EnvPrefix, |
| + if err := writeEnv(ctx, s, sealPub, acct, app, env, rec.Declared.EnvPrefix, |
| pr.Bucket, pr.Region, pr.Endpoint, id, secret); err != nil { |
| return nil, err |
| } |
| // 5. record: the key just retired from live becomes the pending one. |
| pr.PrevKeyID, pr.KeyID, pr.At = pr.KeyID, id, now |
| - if _, err := Put(ctx, s, acct, app, rec, etag); err != nil { |
| + if _, err := PutIn(ctx, s, acct, app, env, rec, etag); err != nil { |
| if !errors.Is(err, release.ErrCASConflict) { |
| return nil, err |
| } |
| @@ -382,7 +462,7 @@ func Rotate(ctx context.Context, s release.Store, p Provisioner, sealPub *ecdh.P |
| // names another key, a concurrent rotation delivered a credential of |
| // its own; overwriting it would strand exactly what we are protecting |
| // against, so the conflict is the caller's to see. |
| - fresh, freshEtag, gerr := Get(ctx, s, acct, app) |
| + fresh, freshEtag, gerr := GetIn(ctx, s, acct, app, env) |
| if gerr != nil { |
| return nil, err |
| } |
| @@ -390,7 +470,7 @@ func Rotate(ctx context.Context, s release.Store, p Provisioner, sealPub *ecdh.P |
| return nil, err |
| } |
| fresh.Provisioned = pr |
| - if _, perr := Put(ctx, s, acct, app, fresh, freshEtag); perr != nil { |
| + if _, perr := PutIn(ctx, s, acct, app, env, fresh, freshEtag); perr != nil { |
| return nil, perr |
| } |
| return fresh, nil |
| @@ -408,7 +488,13 @@ func Rotate(ctx context.Context, s release.Store, p Provisioner, sealPub *ecdh.P |
| // timer: only the member knows every host, worker and hibernating tenant has |
| // picked the new pair up. |
| func RotateFinish(ctx context.Context, s release.Store, p Provisioner, acct, app string) (*Record, error) { |
| - rec, etag, err := Get(ctx, s, acct, app) |
| + return RotateFinishIn(ctx, s, p, acct, app, "") |
| +} |
| + |
| +// RotateFinishIn is RotateFinish for one store of an app — the pending key it |
| +// deletes is that store's own, named by that store's own record. |
| +func RotateFinishIn(ctx context.Context, s release.Store, p Provisioner, acct, app, env string) (*Record, error) { |
| + rec, etag, err := GetIn(ctx, s, acct, app, env) |
| if err != nil { |
| return nil, err |
| } |
| @@ -426,7 +512,7 @@ func RotateFinish(ctx context.Context, s release.Store, p Provisioner, acct, app |
| // backend did would strand a live credential nothing names, which is the |
| // one thing the reap exists to clean up and the estate exists to avoid. |
| pr.PrevKeyID = "" |
| - if _, err := Put(ctx, s, acct, app, rec, etag); err != nil { |
| + if _, err := PutIn(ctx, s, acct, app, env, rec, etag); err != nil { |
| return nil, err |
| } |
| return rec, nil |
| @@ -458,7 +544,15 @@ func RotateFinish(ctx context.Context, s release.Store, p Provisioner, acct, app |
| // which is precisely the failure §9's ordering rule exists to prevent. Only a |
| // record with NO grant at all — declared, never acted on — dies trivially. |
| func Teardown(ctx context.Context, s release.Store, p Provisioner, sc Scanner, acct, app string) error { |
| - rec, _, err := Get(ctx, s, acct, app) |
| + return TeardownIn(ctx, s, p, sc, acct, app, "") |
| +} |
| + |
| +// TeardownIn is Teardown for one store of an app. Tearing an environment |
| +// store down never touches the app's own store, or any other environment's: |
| +// the bucket, the principal and the bundle the vars come out of are all named |
| +// by this record alone. |
| +func TeardownIn(ctx context.Context, s release.Store, p Provisioner, sc Scanner, acct, app, env string) error { |
| + rec, _, err := GetIn(ctx, s, acct, app, env) |
| if err != nil { |
| return err |
| } |
| @@ -474,7 +568,7 @@ func Teardown(ctx context.Context, s release.Store, p Provisioner, sc Scanner, a |
| startBucket = rec.Grant.Bucket |
| } |
| if rec.Grant == nil && rec.Provisioned == nil { |
| - return deleteRecordIfUnchanged(ctx, s, acct, app, startKeyID, startBucket) |
| + return deleteRecordIfUnchanged(ctx, s, acct, app, env, startKeyID, startBucket) |
| } |
| // Everything below spends backend authority, and a nil Provisioner is a |
| // deployment that has none — an ordinary shape (console Config. |
| @@ -493,7 +587,7 @@ func Teardown(ctx context.Context, s release.Store, p Provisioner, sc Scanner, a |
| // name there is not a guess: EnsureUser is called with exactly |
| // UserName(acct, app), so a principal created by a grant that never |
| // recorded itself is still findable by name. |
| - bucket, user := "", UserName(acct, app) |
| + bucket, user := "", UserNameIn(acct, app, env) |
| switch { |
| case rec.Provisioned != nil: |
| bucket, user = rec.Provisioned.Bucket, rec.Provisioned.IAMUser |
| @@ -563,7 +657,52 @@ func Teardown(ctx context.Context, s release.Store, p Provisioner, sc Scanner, a |
| // root). An app that was never configured has nothing of ours to remove |
| // and must not have that refusal strand its backend teardown — so if none |
| // of the names are present, this step is already converged. |
| - m, err := appconfig.Get(ctx, s, acct, app) |
| + if err := unsetDelivered(ctx, s, acct, app, env, unsets); err != nil { |
| + return err |
| + } |
| + // The scan sidecar, before the record. It is the only other object under |
| + // this app's prefix that this package writes, and an orphan left behind |
| + // makes the app NAME unusable: the claim gate refuses a name whose prefix |
| + // still holds any key, and the box-side auto-purge never reaches it |
| + // because the tombstone goes last. Delete is idempotent on an absent key, |
| + // so a store that was never swept costs one no-op. |
| + // |
| + // Only the app's own store has one. Scanning is (acct, app)-scoped |
| + // throughout — Sweep's cursor, ScanResultKey, the queue — so an |
| + // environment store has no sidecar to orphan, and deleting ScanKey here |
| + // would destroy the APP store's sweep cursor while tearing an |
| + // environment's estate down (2026-09-19 spec §9). |
| + if env == "" { |
| + if err := s.Delete(ctx, ScanKey(acct, app)); err != nil { |
| + return fmt.Errorf("objstore: delete scan sidecar for %s/%s: %w", acct, app, err) |
| + } |
| + } |
| + // Last (§9). Everything it named is gone. |
| + return deleteRecordIfUnchanged(ctx, s, acct, app, env, startKeyID, startBucket) |
| +} |
| + |
| +// unsetDelivered removes the delivered set from the bundle a store delivers |
| +// INTO — the app's default bundle, or the named environment's. |
| +// |
| +// The presence check is why this is one helper rather than two call sites: |
| +// appconfig REFUSES an unset-only apply against a bundle that does not exist, |
| +// because creating one would mint an empty manifest, which means "configured, |
| +// then fully unset" to Sync — which converges every route's env file to |
| +// empty, as root. A store whose bundle was never configured has nothing of |
| +// ours to remove and must not have that refusal strand its backend teardown. |
| +// An environment store is the likelier shape to meet it: its bundle exists |
| +// only because delivery created it, so a crashed grant can leave an estate |
| +// with no bundle at all. |
| +func unsetDelivered(ctx context.Context, s release.Store, acct, app, env string, unsets []string) error { |
| + var ( |
| + m *appconfig.Manifest |
| + err error |
| + ) |
| + if env == "" { |
| + m, err = appconfig.Get(ctx, s, acct, app) |
| + } else { |
| + m, err = appconfig.LoadEnvironment(ctx, s, acct, app, env) |
| + } |
| if err != nil { |
| return fmt.Errorf("objstore: read config for %s/%s: %w", acct, app, err) |
| } |
| @@ -574,22 +713,19 @@ func Teardown(ctx context.Context, s release.Store, p Provisioner, sc Scanner, a |
| break |
| } |
| } |
| - if present { |
| + if !present { |
| + return nil |
| + } |
| + if env == "" { |
| if _, err := appconfig.Apply(ctx, s, acct, app, nil, unsets); err != nil { |
| return fmt.Errorf("objstore: remove store config for %s/%s: %w", acct, app, err) |
| } |
| + return nil |
| } |
| - // The scan sidecar, before the record. It is the only other object under |
| - // this app's prefix that this package writes, and an orphan left behind |
| - // makes the app NAME unusable: the claim gate refuses a name whose prefix |
| - // still holds any key, and the box-side auto-purge never reaches it |
| - // because the tombstone goes last. Delete is idempotent on an absent key, |
| - // so a store that was never swept costs one no-op. |
| - if err := s.Delete(ctx, ScanKey(acct, app)); err != nil { |
| - return fmt.Errorf("objstore: delete scan sidecar for %s/%s: %w", acct, app, err) |
| + if _, err := appconfig.ApplyEnvironment(ctx, s, acct, app, env, nil, unsets); err != nil { |
| + return fmt.Errorf("objstore: remove store config for %s/%s environment %s: %w", acct, app, env, err) |
| } |
| - // Last (§9). Everything it named is gone. |
| - return deleteRecordIfUnchanged(ctx, s, acct, app, startKeyID, startBucket) |
| + return nil |
| } |
| // deleteRecordIfUnchanged is the final step of Teardown: delete the record, |
| @@ -619,8 +755,8 @@ func Teardown(ctx context.Context, s release.Store, p Provisioner, sc Scanner, a |
| // kind guarded these walks until #381 — so if this reference ever stops |
| // resolving, the sentence above is a claim about nothing and this window is a |
| // whole walk wide again. |
| -func deleteRecordIfUnchanged(ctx context.Context, s release.Store, acct, app, startKeyID, startBucket string) error { |
| - fresh, _, err := Get(ctx, s, acct, app) |
| +func deleteRecordIfUnchanged(ctx context.Context, s release.Store, acct, app, env, startKeyID, startBucket string) error { |
| + fresh, _, err := GetIn(ctx, s, acct, app, env) |
| if err != nil { |
| return fmt.Errorf("objstore: re-read store record for %s/%s: %w", acct, app, err) |
| } |
| @@ -637,7 +773,7 @@ func deleteRecordIfUnchanged(ctx context.Context, s release.Store, acct, app, st |
| if keyID != startKeyID || bucket != startBucket { |
| return fmt.Errorf("objstore: store record for %s/%s changed during teardown — re-run: %w", acct, app, release.ErrCASConflict) |
| } |
| - if err := s.Delete(ctx, Key(acct, app)); err != nil { |
| + if err := s.Delete(ctx, KeyIn(acct, app, env)); err != nil { |
| return fmt.Errorf("objstore: delete store record for %s/%s: %w", acct, app, err) |
| } |
| return nil |
| @@ -655,7 +791,7 @@ func deleteRecordIfUnchanged(ctx context.Context, s release.Store, acct, app, st |
| // longer uses. The set half is always non-empty, so this never trips |
| // applyAt's refusal to create a bundle on an unset-only call. |
| func writeEnv(ctx context.Context, s release.Store, sealPub *ecdh.PublicKey, |
| - acct, app, prefix, bucket, region, endpoint, keyID, secret string) error { |
| + acct, app, env, prefix, bucket, region, endpoint, keyID, secret string) error { |
| bucketName, regionName, endpointName, accessKeyName, secretKeyName := EnvNames(prefix) |
| sets := map[string]appconfig.Var{ |
| bucketName: {Kind: "env", Value: bucket}, |
| @@ -686,8 +822,19 @@ func writeEnv(ctx context.Context, s release.Store, sealPub *ecdh.PublicKey, |
| } |
| sets[half.name] = appconfig.Var{Kind: "secret", Envelope: env} |
| } |
| - if _, err := appconfig.Apply(ctx, s, acct, app, sets, unsets); err != nil { |
| - return fmt.Errorf("objstore: deliver store config for %s/%s: %w", acct, app, err) |
| + // The bundle the store belongs to, which is the bundle the instances |
| + // bound to it already read. ApplyEnvironment is the same one-write |
| + // contract as Apply, touches the same per-app generation object, and |
| + // applies the same refusal to create a bundle on an unset-only call — |
| + // the set half here is always non-empty, so that refusal is never met. |
| + if env == "" { |
| + if _, err := appconfig.Apply(ctx, s, acct, app, sets, unsets); err != nil { |
| + return fmt.Errorf("objstore: deliver store config for %s/%s: %w", acct, app, err) |
| + } |
| + return nil |
| + } |
| + if _, err := appconfig.ApplyEnvironment(ctx, s, acct, app, env, sets, unsets); err != nil { |
| + return fmt.Errorf("objstore: deliver store config for %s/%s environment %s: %w", acct, app, env, err) |
| } |
| return nil |
| } |
diff --git a/internal/objstore/walk_env_test.go b/internal/objstore/walk_env_test.go| new file mode 100644 |
| index 000000000..56363f7fd |
| --- /dev/null |
| +++ b/internal/objstore/walk_env_test.go |
| @@ -0,0 +1,304 @@ |
| +package objstore |
| + |
| +import ( |
| + "context" |
| + "crypto/ed25519" |
| + "crypto/rand" |
| + "errors" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/appconfig" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// Environment stores (2026-09-19 spec): a store may belong to a named config |
| +// environment instead of to the app as a whole, and delivers into THAT |
| +// bundle. Everything here is about the boundary between the two — an |
| +// environment store and the app's own store share an app and nothing else. |
| + |
| +// declareIn is declare for an environment store: the member half of the |
| +// record, written at the environment's own key. |
| +func declareIn(t *testing.T, s release.Store, acct, app, env, prefix string) { |
| + t.Helper() |
| + rec := &Record{Declared: &Declared{EnvPrefix: prefix, At: time.Now().UTC(), By: "member@example.com"}} |
| + if _, err := PutIn(context.Background(), s, acct, app, env, rec, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func envVars(t *testing.T, s release.Store, acct, app, env string) map[string]appconfig.Var { |
| + t.Helper() |
| + m, err := appconfig.LoadEnvironment(context.Background(), s, acct, app, env) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return m.Vars |
| +} |
| + |
| +// TestGrantInDeliversToTheEnvironmentBundle is the whole feature in one test: |
| +// the estate is named for the environment, and the credentials land where the |
| +// instances bound to that environment actually read — not in the app's |
| +// default bundle, which is what every instance NOT bound to it reads. |
| +func TestGrantInDeliversToTheEnvironmentBundle(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + priv, pub := testSealKeypair(t) |
| + p := NewMemProvisioner("eu-west-1") |
| + // Fix the secret the backend hands back, so the assertion below is about |
| + // the value that actually reached the bundle rather than about two |
| + // generated strings agreeing. |
| + p.MintSecret = "beta-minted-secret" |
| + declareIn(t, s, "bab", "titogo", "beta", "TITO_S3") |
| + |
| + rec, err := GrantIn(ctx, s, p, testDeployment(), pub, "bab", "titogo", "beta", "op", GrantSourceOperator, "", time.Now().UTC()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got, want := rec.Provisioned.Bucket, "carlos-store-tito-bab-titogo-beta"; got != want { |
| + t.Errorf("bucket = %q, want %q", got, want) |
| + } |
| + if got, want := rec.Provisioned.IAMUser, "carlos-store-bab-titogo-beta"; got != want { |
| + t.Errorf("IAM user = %q, want %q", got, want) |
| + } |
| + if rec.Environment != "beta" { |
| + t.Errorf("record environment = %q, want %q", rec.Environment, "beta") |
| + } |
| + |
| + // Delivered into the environment's bundle, sealed, and openable to the |
| + // secret the backend actually minted. |
| + vars := envVars(t, s, "bab", "titogo", "beta") |
| + if got := vars["TITO_S3_BUCKET"].Value; got != "carlos-store-tito-bab-titogo-beta" { |
| + t.Errorf("TITO_S3_BUCKET in the beta bundle = %q", got) |
| + } |
| + sealed := vars["TITO_S3_SECRET_KEY"] |
| + if sealed.Kind != "secret" || sealed.Envelope == "" { |
| + t.Fatalf("TITO_S3_SECRET_KEY = %+v, want a sealed secret", sealed) |
| + } |
| + plain, err := appconfig.Open(priv, sealed.Envelope) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if string(plain) != p.MintSecret { |
| + t.Errorf("delivered secret = %q, want the minted %q", plain, p.MintSecret) |
| + } |
| + |
| + // And NOT into the default bundle. An instance with no environment |
| + // binding must not be handed another environment's credentials. |
| + for name := range mustVars(t, s, "bab", "titogo") { |
| + if strings.HasPrefix(name, "TITO_S3_") { |
| + t.Errorf("default bundle carries %s — an environment store delivered into the app's own bundle", name) |
| + } |
| + } |
| + |
| + // A re-run is a no-op. This is the assertion that fails if the walk's |
| + // idempotence short-circuit reads the DEFAULT bundle: every name would |
| + // read as missing, and every walk would reap and mint a fresh key. |
| + keyID := rec.Provisioned.KeyID |
| + again, err := GrantIn(ctx, s, p, testDeployment(), pub, "bab", "titogo", "beta", "op", GrantSourceOperator, "", time.Now().UTC()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if again.Provisioned.KeyID != keyID { |
| + t.Errorf("re-run minted a fresh key (%s -> %s)", keyID, again.Provisioned.KeyID) |
| + } |
| +} |
| + |
| +// TestEnvironmentStoreAndAppStoreAreSeparateEstates pins the isolation two |
| +// stores of one app must have: their own bucket, their own principal, their |
| +// own record, their own bundle. |
| +func TestEnvironmentStoreAndAppStoreAreSeparateEstates(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + _, pub := testSealKeypair(t) |
| + p := NewMemProvisioner("eu-west-1") |
| + d := testDeployment() |
| + |
| + declare(t, s, "bab", "titogo", "TITO_S3") |
| + appRec, err := Grant(ctx, s, p, d, pub, "bab", "titogo", "op", GrantSourceOperator, "", time.Now().UTC()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + declareIn(t, s, "bab", "titogo", "beta", "TITO_S3") |
| + envRec, err := GrantIn(ctx, s, p, d, pub, "bab", "titogo", "beta", "op", GrantSourceOperator, "", time.Now().UTC()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + if appRec.Provisioned.Bucket == envRec.Provisioned.Bucket { |
| + t.Errorf("both stores resolved to one bucket %q", appRec.Provisioned.Bucket) |
| + } |
| + if appRec.Provisioned.IAMUser == envRec.Provisioned.IAMUser { |
| + t.Errorf("both stores resolved to one IAM user %q", appRec.Provisioned.IAMUser) |
| + } |
| + if appRec.Provisioned.KeyID == envRec.Provisioned.KeyID { |
| + t.Error("both stores hold one access key") |
| + } |
| + if got := mustVars(t, s, "bab", "titogo")["TITO_S3_BUCKET"].Value; got != appRec.Provisioned.Bucket { |
| + t.Errorf("default bundle bucket = %q, want the app store's %q", got, appRec.Provisioned.Bucket) |
| + } |
| + if got := envVars(t, s, "bab", "titogo", "beta")["TITO_S3_BUCKET"].Value; got != envRec.Provisioned.Bucket { |
| + t.Errorf("beta bundle bucket = %q, want the environment store's %q", got, envRec.Provisioned.Bucket) |
| + } |
| +} |
| + |
| +// TestGetInRefusesARecordThatDisagreesWithItsKey: Environment is stamped from |
| +// the key, so a record naming another environment is hand-edited bucket data. |
| +// Guessing which half is right would deliver one environment's credentials |
| +// under another's name. |
| +func TestGetInRefusesARecordThatDisagreesWithItsKey(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + // Write a beta-stamped record at the APP store's key, past PutIn's |
| + // stamping, the way a hand edit would. |
| + body := []byte(`{"schema":1,"app":"titogo","environment":"beta"}` + "\n") |
| + if _, err := s.Put(ctx, Key("bab", "titogo"), body, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + _, _, err := Get(ctx, s, "bab", "titogo") |
| + if err == nil || !strings.Contains(err.Error(), "disagrees with its own key") { |
| + t.Fatalf("Get over a mis-stamped record = %v, want a refusal", err) |
| + } |
| +} |
| + |
| +// TestGrantInRefusesAnUnnameablePrincipal: the third name component can push |
| +// the IAM user name over AWS's ceiling. That must be refused in front of the |
| +// walk — a CreateUser failure would come after the grant block was written, |
| +// leaving a record naming an estate that was never created, which Teardown |
| +// then has to treat as live. |
| +// |
| +// The fixture needs a SHORT bucket prefix, and that is the whole shape of |
| +// this guard. Both names carry the same body: the bucket is |
| +// <prefix><body> against S3's 63, the principal is "carlos-store-"<body> |
| +// against IAM's 64. So the IAM ceiling is only reachable when the prefix is |
| +// shorter than the 13 characters of "carlos-store-" — under a longer one |
| +// (the flagship's "carlos-store-tito-") the bucket refusal always fires |
| +// first, and ValidUserName never speaks. Here: body 61, bucket exactly 63 |
| +// and legal, principal 74 and refused. |
| +func TestGrantInRefusesAnUnnameablePrincipal(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + _, pub := testSealKeypair(t) |
| + p := NewMemProvisioner("eu-west-1") |
| + d := Deployment{Backend: "aws", Region: "eu-west-1", BucketPrefix: "s-"} |
| + app := strings.Repeat("a", 24) // ValidApp caps at 32 |
| + env := strings.Repeat("e", 32) // ValidEnvironmentName caps at 32 |
| + declareIn(t, s, "bab", app, env, "TITO_S3") |
| + |
| + if got := len(MintedBucketIn(d, "bab", app, env)); got != 63 { |
| + t.Fatalf("fixture drift: bucket name is %d characters, want 63 (legal)", got) |
| + } |
| + _, err := GrantIn(ctx, s, p, d, pub, "bab", app, env, "op", GrantSourceOperator, "", time.Now().UTC()) |
| + if err == nil || !strings.Contains(err.Error(), "over AWS's 64") { |
| + t.Fatalf("GrantIn with an unnameable principal = %v, want the length refusal", err) |
| + } |
| + if len(p.Buckets) != 0 || len(p.Users) != 0 { |
| + t.Errorf("refused grant touched the backend: buckets %v users %v", p.Buckets, p.Users) |
| + } |
| + rec, _, err := GetIn(ctx, s, "bab", app, env) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if rec.Grant != nil { |
| + t.Error("refused grant wrote a grant block") |
| + } |
| +} |
| + |
| +// TestTeardownInLeavesTheAppStoreStanding is the blast-radius test. Tearing |
| +// an environment store down must not touch the app's own estate, its bundle, |
| +// or — the one this spec had to guard explicitly — its scan sidecar, which is |
| +// the app-scoped sweep cursor an environment store does not have. |
| +func TestTeardownInLeavesTheAppStoreStanding(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + _, pub := testSealKeypair(t) |
| + p := NewMemProvisioner("eu-west-1") |
| + d := testDeployment() |
| + |
| + declare(t, s, "bab", "titogo", "TITO_S3") |
| + appRec, err := Grant(ctx, s, p, d, pub, "bab", "titogo", "op", GrantSourceOperator, "", time.Now().UTC()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + declareIn(t, s, "bab", "titogo", "beta", "TITO_S3") |
| + envRec, err := GrantIn(ctx, s, p, d, pub, "bab", "titogo", "beta", "op", GrantSourceOperator, "", time.Now().UTC()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + // The app's sweep cursor, which only the app store owns. |
| + if _, err := s.Put(ctx, ScanKey("bab", "titogo"), []byte(`{"schema":1}`+"\n"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + if err := TeardownIn(ctx, s, p, nil, "bab", "titogo", "beta"); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + // The environment's estate is gone. |
| + if _, ok := p.Buckets[envRec.Provisioned.Bucket]; ok { |
| + t.Error("environment bucket survived teardown") |
| + } |
| + if _, ok := p.Users[envRec.Provisioned.IAMUser]; ok { |
| + t.Error("environment IAM user survived teardown") |
| + } |
| + if gone, _, err := GetIn(ctx, s, "bab", "titogo", "beta"); err != nil || gone != nil { |
| + t.Errorf("environment record survived teardown: %+v, %v", gone, err) |
| + } |
| + for name := range envVars(t, s, "bab", "titogo", "beta") { |
| + if strings.HasPrefix(name, "TITO_S3_") { |
| + t.Errorf("beta bundle still carries %s", name) |
| + } |
| + } |
| + |
| + // The app's own estate is untouched. |
| + if _, ok := p.Buckets[appRec.Provisioned.Bucket]; !ok { |
| + t.Error("app bucket was destroyed by an environment teardown") |
| + } |
| + if _, ok := p.Users[appRec.Provisioned.IAMUser]; !ok { |
| + t.Error("app IAM user was destroyed by an environment teardown") |
| + } |
| + still, _, err := Get(ctx, s, "bab", "titogo") |
| + if err != nil || still == nil || still.Provisioned == nil { |
| + t.Fatalf("app store record = %+v, %v — want it standing", still, err) |
| + } |
| + if got := mustVars(t, s, "bab", "titogo")["TITO_S3_BUCKET"].Value; got != appRec.Provisioned.Bucket { |
| + t.Errorf("app bundle lost its coordinates: TITO_S3_BUCKET = %q", got) |
| + } |
| + if _, _, err := s.Get(ctx, ScanKey("bab", "titogo")); err != nil { |
| + t.Errorf("an environment teardown deleted the app's scan sidecar: %v", err) |
| + } |
| +} |
| + |
| +// TestPurgeGuardSeesAnEnvironmentStore extends the parse agreement to the |
| +// newer shape. An app whose ONLY live estate is an environment store must |
| +// still refuse the box-side auto-purge: the guard reads store.json and every |
| +// stores/<env>.json, because a swept record is a paid bucket and a live IAM |
| +// user that nothing remembers. |
| +func TestPurgeGuardSeesAnEnvironmentStore(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + _, pub := testSealKeypair(t) |
| + p := NewMemProvisioner("eu-west-1") |
| + relPub, _, err := ed25519.GenerateKey(rand.Reader) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + // No app store at all — only an environment one. |
| + declareIn(t, s, "bab", "titogo", "beta", "TITO_S3") |
| + if _, err := GrantIn(ctx, s, p, testDeployment(), pub, "bab", "titogo", "beta", "op", GrantSourceOperator, "", time.Now().UTC()); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + backdated := time.Now().Add(-2 * release.TrashCooloff) |
| + if err := release.TrashApp(ctx, s, "bab", "titogo", "paul@keymail.dev", backdated); err != nil { |
| + t.Fatal(err) |
| + } |
| + err = release.PurgeApp(ctx, s, relPub, "bab", "titogo", time.Now()) |
| + if !errors.Is(err, release.ErrStoreProvisioned) { |
| + t.Fatalf("PurgeApp over a live environment store = %v, want release.ErrStoreProvisioned", err) |
| + } |
| + if _, _, err := s.Get(ctx, KeyIn("bab", "titogo", "beta")); err != nil { |
| + t.Fatalf("the refused purge destroyed the environment store record: %v", err) |
| + } |
| +} |
diff --git a/internal/release/trash.go b/internal/release/trash.go| index b802099de..bf4739902 100644 |
| --- a/internal/release/trash.go |
| +++ b/internal/release/trash.go |
| @@ -209,21 +209,45 @@ func PurgeApp(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app str |
| // Unreadable — transport error or bytes that will not parse — refuses too, |
| // the same direction as the production flag: "I could not ask" is not "no |
| // store". Absent is fine; most apps never declare one. |
| - if raw, _, err := s.Get(ctx, prefix+"store.json"); err == nil { |
| + // EVERY store of the app, not just store.json. Since 2026-09-19 a store |
| + // may belong to a named config environment, at |
| + // apps/<acct>/<app>/stores/<env>.json, and each one names backend estate |
| + // exactly as the app's own does. A guard that read store.json alone would |
| + // let a box sweep an app whose environment stores are live — this guard's |
| + // own failure, reached through the door the newer shape opened. |
| + // |
| + // The listing is part of the guard, so a listing that FAILS refuses the |
| + // purge, the same direction as an unreadable record: "I could not ask" is |
| + // not "no store". List returns nested keys under a prefix in both store |
| + // implementations (DirStore walks the root; S3Store paginates with no |
| + // delimiter), which is what makes one listing enough. |
| + storeKeys := []string{prefix + "store.json"} |
| + envStores, lerr := s.List(ctx, prefix+"stores/") |
| + if lerr != nil { |
| + return fmt.Errorf("release: %s/%s: cannot list %sstores/ — refusing to purge: %w: %v", |
| + acct, app, prefix, ErrStoreProvisioned, lerr) |
| + } |
| + storeKeys = append(storeKeys, envStores...) |
| + for _, storeKey := range storeKeys { |
| + raw, _, err := s.Get(ctx, storeKey) |
| + if err != nil { |
| + if errors.Is(err, os.ErrNotExist) { |
| + continue // absent is fine; most apps never declare one |
| + } |
| + return fmt.Errorf("release: %s/%s: cannot read %s — refusing to purge: %w: %v", |
| + acct, app, storeKey, ErrStoreProvisioned, err) |
| + } |
| var rec struct { |
| Grant *json.RawMessage `json:"grant"` |
| Provisioned *json.RawMessage `json:"provisioned"` |
| } |
| if jerr := json.Unmarshal(raw, &rec); jerr != nil { |
| - return fmt.Errorf("release: %s/%s: unparseable %sstore.json — refusing to purge: %w: %v", |
| - acct, app, prefix, ErrStoreProvisioned, jerr) |
| + return fmt.Errorf("release: %s/%s: unparseable %s — refusing to purge: %w: %v", |
| + acct, app, storeKey, ErrStoreProvisioned, jerr) |
| } |
| if rec.Provisioned != nil || rec.Grant != nil { |
| return fmt.Errorf("%s/%s: %w", acct, app, ErrStoreProvisioned) |
| } |
| - } else if !errors.Is(err, os.ErrNotExist) { |
| - return fmt.Errorf("release: %s/%s: cannot read %sstore.json — refusing to purge: %w: %v", |
| - acct, app, prefix, ErrStoreProvisioned, err) |
| } |
| marker := AppTombstoneKey(acct, app) |
| keys, err := s.List(ctx, prefix) |
diff --git a/store.go b/store.go| index cae6a08a1..7acdc3d4f 100644 |
| --- a/store.go |
| +++ b/store.go |
| @@ -48,14 +48,19 @@ import ( |
| const storeUsage = `usage: carlos store <create|status|apply|approvals|approve|decline|grant|rotate|scan> [flags] |
| - create --app <a> [--env-prefix <PREFIX>] |
| + create --app <a> [--env-prefix <PREFIX>] [--environment <e>] |
| declare that this app wants object storage, under |
| an env prefix (default CARLOS_STORE). For an |
| approved account, this also provisions the store. |
| - status --app <a> show what's declared, granted and provisioned, |
| + --environment declares a store belonging to one |
| + named config bundle, with its own bucket and its |
| + own credential, delivered into that bundle. |
| + status --app <a> [--environment <e>] |
| + show what's declared, granted and provisioned, |
| which delivered variables have not reached the |
| app's config yet, and whether a rotation is only |
| - half finished. |
| + half finished. Without --environment it also names |
| + every environment this app has a store for. |
| apply [--note <text>] apply for object storage for an account you own. |
| approvals list pending account applications (operator only). |
| approve --account <sqid> |
| @@ -64,13 +69,13 @@ const storeUsage = `usage: carlos store <create|status|apply|approvals|approve|d |
| decline --account <sqid> --reason <text> |
| decline an account application (operator only; |
| an explicit account sqid and reason are required). |
| - grant --app <a> [--bucket <name>] |
| + grant --app <a> [--bucket <name>] [--environment <e>] |
| provision the declared store and deliver its |
| credentials. Needs a deployment operator |
| (CARLOS_STORE_OPERATORS) — a 403 for anyone else, |
| member or not. --bucket adopts an existing bucket |
| instead of minting one. |
| - rotate --app <a> [--finish] |
| + rotate --app <a> [--finish] [--environment <e>] |
| mint a fresh credential, leaving the old one live |
| until --finish deletes it. A member verb: spends |
| nothing new, so there is no operator gate here. |
| @@ -164,12 +169,13 @@ func runStoreCreate(args []string) { |
| fs := flag.NewFlagSet("store create", flag.ExitOnError) |
| app := appFlag(fs, "app to declare a store for") |
| envPrefix := fs.String("env-prefix", "", "env var prefix to deliver under (default CARLOS_STORE)") |
| + environment := storeEnvironmentFlag(fs, "declare a store for this config environment instead of the app itself") |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| consoleFlag := consoleOption(fs, "console origin to declare through (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| name := strings.TrimSpace(*app) |
| - usage := "usage: carlos store create --app <a> [--env-prefix <PREFIX>] [--account <sqid-or-name>] [--account-id <sqid>] [--console <origin>]" |
| + usage := "usage: carlos store create --app <a> [--env-prefix <PREFIX>] [--environment <e>] [--account <sqid-or-name>] [--account-id <sqid>] [--console <origin>]" |
| // Client-side gate before the server's, the same one every write verb in |
| // this file applies: a name the console is certain to refuse should cost |
| // a typo nothing. |
| @@ -183,24 +189,67 @@ func runStoreCreate(args []string) { |
| printUsing(stdout, sess) |
| acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| name = resolvedApp(sess, acct, name, usage) |
| - if err := storeCreateAndProvision(context.Background(), sess, acct, name, strings.TrimSpace(*envPrefix), stdout); err != nil { |
| + if err := storeCreateAndProvision(context.Background(), sess, acct, name, |
| + strings.TrimSpace(*environment), strings.TrimSpace(*envPrefix), stdout); err != nil { |
| log.Fatalf("store create: %v", err) |
| } |
| } |
| -func storeCreateAndProvision(ctx context.Context, sess *apiSession, acct apiAcct, app, envPrefix string, out io.Writer) error { |
| - res, err := sess.storeCreate(ctx, acct, app, envPrefix) |
| +// storeEnvironmentFlag is the --environment flag every store verb takes: the |
| +// config environment whose store to act on, or none for the app's own. |
| +// |
| +// Declared in one place so the four verbs cannot drift on its spelling or on |
| +// what it means. The name matches `carlos env --environment` and |
| +// `carlos instances create --environment` deliberately: it is the same named |
| +// bundle in all three, and a store delivers INTO that bundle. |
| +func storeEnvironmentFlag(fs *flag.FlagSet, usage string) *string { |
| + return fs.String("environment", "", usage) |
| +} |
| + |
| +// storeEnvMismatch refuses a reply that does not name the environment we |
| +// asked about. |
| +// |
| +// A console older than per-environment stores does not read ?environment= at |
| +// all: it answers about the app's OWN store, with a 200 and a perfectly |
| +// ordinary body. So `carlos store create --app x --environment beta` against |
| +// an un-upgraded console would declare — or grant, or rotate — the WRONG |
| +// store and report success. The env prefix is fixed once granted, and a |
| +// rotation mints a credential, so that mistake is not always reversible. |
| +// |
| +// Every console that knows about environment stores echoes the environment |
| +// on every store reply, including "" for the app's own. An older one omits |
| +// the key, which decodes as "" — so a reply that does not say what we asked |
| +// about is the tell, and it is a refusal rather than a warning because the |
| +// caller cannot inspect what happened without the very verb that just lied. |
| +func storeEnvMismatch(res apiStoreReply, env string) error { |
| + if env == "" || res.Environment == env { |
| + return nil |
| + } |
| + return fmt.Errorf("this console answered about the app's own store, not the %q environment — "+ |
| + "it is older than per-environment object storage. Upgrade the console, or drop --environment", env) |
| +} |
| + |
| +func storeCreateAndProvision(ctx context.Context, sess *apiSession, acct apiAcct, app, env, envPrefix string, out io.Writer) error { |
| + res, err := sess.storeCreate(ctx, acct, app, env, envPrefix) |
| if err != nil { |
| return err |
| } |
| + // Before printing: a reply about the wrong store must not be narrated as |
| + // if it were the right one. |
| + if err := storeEnvMismatch(res, env); err != nil { |
| + return err |
| + } |
| printStoreCreate(out, res) |
| if res.Backend == "" || !res.Entitlement.Approved() { |
| return nil |
| } |
| - res, err = sess.storeProvision(ctx, acct, app) |
| + res, err = sess.storeProvision(ctx, acct, app, env) |
| if err != nil { |
| return err |
| } |
| + if err := storeEnvMismatch(res, env); err != nil { |
| + return err |
| + } |
| printStoreCreate(out, res) |
| return nil |
| } |
| @@ -208,12 +257,13 @@ func storeCreateAndProvision(ctx context.Context, sess *apiSession, acct apiAcct |
| func runStoreStatus(args []string) { |
| fs := flag.NewFlagSet("store status", flag.ExitOnError) |
| app := appFlag(fs, "app whose store to show") |
| + environment := storeEnvironmentFlag(fs, "show this config environment's store instead of the app's own") |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| consoleFlag := consoleOption(fs, "console origin to read through (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| name := strings.TrimSpace(*app) |
| - usage := "usage: carlos store status --app <a> [--account <sqid-or-name>] [--account-id <sqid>] [--console <origin>]" |
| + usage := "usage: carlos store status --app <a> [--environment <e>] [--account <sqid-or-name>] [--account-id <sqid>] [--console <origin>]" |
| if name != "" { |
| if err := validAppName(name); err != nil { |
| log.Fatalf("store status: %v", err) |
| @@ -224,10 +274,13 @@ func runStoreStatus(args []string) { |
| printUsing(stdout, sess) |
| acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| name = resolvedApp(sess, acct, name, usage) |
| - res, err := sess.storeGet(context.Background(), acct, name) |
| + res, err := sess.storeGet(context.Background(), acct, name, strings.TrimSpace(*environment)) |
| if err != nil { |
| log.Fatalf("store status: %v", err) |
| } |
| + if err := storeEnvMismatch(res, strings.TrimSpace(*environment)); err != nil { |
| + log.Fatalf("store status: %v", err) |
| + } |
| printStoreStatus(stdout, res) |
| } |
| @@ -235,12 +288,13 @@ func runStoreGrant(args []string) { |
| fs := flag.NewFlagSet("store grant", flag.ExitOnError) |
| app := appFlag(fs, "app to grant a store for") |
| bucket := fs.String("bucket", "", "adopt this existing bucket instead of minting one") |
| + environment := storeEnvironmentFlag(fs, "grant this config environment's store instead of the app's own") |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| consoleFlag := consoleOption(fs, "console origin to grant through (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| name := strings.TrimSpace(*app) |
| - usage := "usage: carlos store grant --app <a> [--bucket <name>] [--account <sqid-or-name>] [--account-id <sqid>] [--console <origin>]" |
| + usage := "usage: carlos store grant --app <a> [--bucket <name>] [--environment <e>] [--account <sqid-or-name>] [--account-id <sqid>] [--console <origin>]" |
| if name != "" { |
| if err := validAppName(name); err != nil { |
| log.Fatalf("store grant: %v", err) |
| @@ -251,10 +305,13 @@ func runStoreGrant(args []string) { |
| printUsing(stdout, sess) |
| acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| name = resolvedApp(sess, acct, name, usage) |
| - res, err := sess.storeGrant(context.Background(), acct, name, strings.TrimSpace(*bucket)) |
| + res, err := sess.storeGrant(context.Background(), acct, name, strings.TrimSpace(*environment), strings.TrimSpace(*bucket)) |
| if err != nil { |
| log.Fatalf("store grant: %v", err) |
| } |
| + if err := storeEnvMismatch(res, strings.TrimSpace(*environment)); err != nil { |
| + log.Fatalf("store grant: %v", err) |
| + } |
| printStoreGrant(stdout, res) |
| } |
| @@ -262,12 +319,13 @@ func runStoreRotate(args []string) { |
| fs := flag.NewFlagSet("store rotate", flag.ExitOnError) |
| app := appFlag(fs, "app whose store credential to rotate") |
| finish := fs.Bool("finish", false, "delete the previous key instead of minting a new one") |
| + environment := storeEnvironmentFlag(fs, "rotate this config environment's store instead of the app's own") |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| consoleFlag := consoleOption(fs, "console origin to rotate through (API mode only; default: the only logged-in one)") |
| fs.Usage = flagUsage(fs, "") |
| fs.Parse(args) |
| name := strings.TrimSpace(*app) |
| - usage := "usage: carlos store rotate --app <a> [--finish] [--account <sqid-or-name>] [--account-id <sqid>] [--console <origin>]" |
| + usage := "usage: carlos store rotate --app <a> [--finish] [--environment <e>] [--account <sqid-or-name>] [--account-id <sqid>] [--console <origin>]" |
| if name != "" { |
| if err := validAppName(name); err != nil { |
| log.Fatalf("store rotate: %v", err) |
| @@ -278,10 +336,13 @@ func runStoreRotate(args []string) { |
| printUsing(stdout, sess) |
| acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| name = resolvedApp(sess, acct, name, usage) |
| - res, err := sess.storeRotate(context.Background(), acct, name, *finish) |
| + res, err := sess.storeRotate(context.Background(), acct, name, strings.TrimSpace(*environment), *finish) |
| if err != nil { |
| log.Fatalf("store rotate: %v", err) |
| } |
| + if err := storeEnvMismatch(res, strings.TrimSpace(*environment)); err != nil { |
| + log.Fatalf("store rotate: %v", err) |
| + } |
| printStoreRotate(stdout, res, *finish) |
| } |
| @@ -420,6 +481,12 @@ func printStoreCreate(w io.Writer, res apiStoreReply) { |
| } |
| fmt.Fprintf(w, "%s %s under prefix %s — delivers %s\n", |
| c.ok("declared a store for"), res.App, prefix, strings.Join(res.EnvNames, ", ")) |
| + if res.Environment != "" { |
| + // Named on its own line rather than folded into the sentence above: |
| + // the variables are delivered into THAT bundle, so only the |
| + // instances bound to it read them. |
| + fmt.Fprintf(w, " environment: %s — delivered into that bundle, not the app's default one\n", res.Environment) |
| + } |
| if res.Provisioned != nil { |
| p := res.Provisioned |
| fmt.Fprintf(w, " provisioned: bucket %s in %s, user %s\n", p.Bucket, p.Region, p.IAMUser) |
| @@ -517,10 +584,17 @@ func printStoreSize(w io.Writer, sz *apiStoreSize) { |
| func printStoreStatus(w io.Writer, res apiStoreReply) { |
| c := colorFor(w) |
| fmt.Fprintf(w, "%s %s\n", c.head("store for"), res.App) |
| + if res.Environment != "" { |
| + fmt.Fprintf(w, " environment: %s\n", res.Environment) |
| + } |
| if res.Backend == "" { |
| warnf(w, "this deployment has no object storage backend configured yet (the operator sets CARLOS_STORE_BACKEND)") |
| } |
| printStoreEntitlement(w, res.Entitlement) |
| + // Before the not-declared exit: an app whose storage is entirely |
| + // per-environment has no store of its own, and stopping here would print |
| + // "not declared" over a set of live buckets. |
| + printStoreEnvironments(w, res) |
| if res.Declared == nil { |
| mutedf(w, "not declared — carlos store create --app %s", res.App) |
| return |
| @@ -582,6 +656,24 @@ func printStoreStatus(w io.Writer, res apiStoreReply) { |
| } |
| } |
| +// printStoreEnvironments names every OTHER store this app has. Each one is a |
| +// bucket of its own with a credential of its own — a store nobody remembers |
| +// is a bill nobody remembers, and teardown is explicit, so this line is where |
| +// an abandoned one becomes visible. |
| +// |
| +// Printed BEFORE the not-declared exit, because an app whose storage is |
| +// entirely per-environment has no store of its own: stopping at "not |
| +// declared" would say the app has no storage while it owns several buckets. |
| +// Only an app-store status carries these; a status aimed at one environment |
| +// answers about that environment and lists nothing. |
| +func printStoreEnvironments(w io.Writer, res apiStoreReply) { |
| + if len(res.Environments) == 0 { |
| + return |
| + } |
| + fmt.Fprintf(w, " environments with their own store: %s\n", strings.Join(res.Environments, ", ")) |
| + mutedf(w, " read one with carlos store status --app %s --environment %s", res.App, res.Environments[0]) |
| +} |
| + |
| func printStoreEntitlement(w io.Writer, entitlement *objstore.Entitlement) { |
| switch { |
| case entitlement.Approved(): |
diff --git a/store_env_guard_test.go b/store_env_guard_test.go| new file mode 100644 |
| index 000000000..b8fcd9363 |
| --- /dev/null |
| +++ b/store_env_guard_test.go |
| @@ -0,0 +1,51 @@ |
| +package main |
| + |
| +import ( |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// A console older than per-environment stores does not read ?environment= at |
| +// all: it answers about the app's OWN store, with a 200 and an ordinary body. |
| +// Every console that knows about them echoes the environment on every reply, |
| +// so a reply that does not name what we asked about is the tell. |
| +// |
| +// This matters because the verbs behind it are not all reversible: create |
| +// fixes the env prefix once granted, grant mints a bucket and an IAM user, |
| +// and rotate cycles a live credential. Acting on the wrong store and |
| +// reporting success is the failure mode being closed here. |
| +func TestStoreEnvMismatch(t *testing.T) { |
| + cases := []struct { |
| + name string |
| + reply apiStoreReply |
| + asked string |
| + wantErr bool |
| + }{ |
| + {"app store, nothing asked", apiStoreReply{Environment: ""}, "", false}, |
| + {"environment echoed back", apiStoreReply{Environment: "beta"}, "beta", false}, |
| + {"old console omits it", apiStoreReply{Environment: ""}, "beta", true}, |
| + {"answered about another environment", apiStoreReply{Environment: "alpha"}, "beta", true}, |
| + // An old console answering an app-store request is the ordinary case |
| + // and must stay silent: this guard exists for the environment ask. |
| + {"old console, app store asked", apiStoreReply{}, "", false}, |
| + } |
| + for _, tc := range cases { |
| + t.Run(tc.name, func(t *testing.T) { |
| + err := storeEnvMismatch(tc.reply, tc.asked) |
| + if tc.wantErr != (err != nil) { |
| + t.Fatalf("storeEnvMismatch(%+v, %q) = %v, wantErr %v", tc.reply, tc.asked, err, tc.wantErr) |
| + } |
| + if err == nil { |
| + return |
| + } |
| + // The sentence has to name the environment asked for and the fix, |
| + // because the caller cannot inspect what happened without the |
| + // very verb that just answered about the wrong store. |
| + for _, want := range []string{tc.asked, "older than per-environment", "Upgrade the console"} { |
| + if !strings.Contains(err.Error(), want) { |
| + t.Errorf("refusal %q does not contain %q", err, want) |
| + } |
| + } |
| + }) |
| + } |
| +} |
diff --git a/store_env_print_test.go b/store_env_print_test.go| new file mode 100644 |
| index 000000000..e3305d6af |
| --- /dev/null |
| +++ b/store_env_print_test.go |
| @@ -0,0 +1,46 @@ |
| +package main |
| + |
| +import ( |
| + "bytes" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// printStoreStatus must name an app's environment stores BEFORE it gives up |
| +// with "not declared". An app whose storage is entirely per-environment has |
| +// no store of its own, so stopping at the not-declared line would report that |
| +// it has no storage while it owns a bucket per environment. |
| +func TestPrintStoreStatusNamesEnvironmentsWithNoAppStore(t *testing.T) { |
| + var buf bytes.Buffer |
| + printStoreStatus(&buf, apiStoreReply{ |
| + App: "managed", |
| + Backend: "aws", |
| + Environments: []string{"acme", "globex"}, |
| + }) |
| + out := buf.String() |
| + for _, want := range []string{ |
| + "environments with their own store: acme, globex", |
| + "carlos store status --app managed --environment acme", |
| + "not declared", // still true of the app's OWN store |
| + } { |
| + if !strings.Contains(out, want) { |
| + t.Fatalf("status is missing %q:\n%s", want, out) |
| + } |
| + } |
| +} |
| + |
| +// ...and exactly once for an app that has both its own store and environment |
| +// stores — the line was inline before it was extracted, and printing it from |
| +// two places is the obvious way to get it twice. |
| +func TestPrintStoreStatusNamesEnvironmentsOnce(t *testing.T) { |
| + var buf bytes.Buffer |
| + printStoreStatus(&buf, apiStoreReply{ |
| + App: "both", |
| + Backend: "aws", |
| + Declared: &apiStoreDeclared{EnvPrefix: "CARLOS_STORE"}, |
| + Environments: []string{"acme"}, |
| + }) |
| + if n := strings.Count(buf.String(), "environments with their own store"); n != 1 { |
| + t.Fatalf("the environments line appears %d times, want 1:\n%s", n, buf.String()) |
| + } |
| +} |
diff --git a/store_test.go b/store_test.go| index f2172ca07..72e92216e 100644 |
| --- a/store_test.go |
| +++ b/store_test.go |
| @@ -60,7 +60,7 @@ func TestAPISessionStoreCreateWireShape(t *testing.T) { |
| })) |
| defer ts.Close() |
| - res, err := newTestSession(ts.URL).storeCreate(context.Background(), apiAcct{Seg: "keymail-team"}, "keymail", "") |
| + res, err := newTestSession(ts.URL).storeCreate(context.Background(), apiAcct{Seg: "keymail-team"}, "keymail", "", "") |
| if err != nil { |
| t.Fatalf("storeCreate: %v", err) |
| } |
| @@ -103,7 +103,7 @@ func TestAPISessionStoreCreateSendsChosenPrefix(t *testing.T) { |
| })) |
| defer ts.Close() |
| - if _, err := newTestSession(ts.URL).storeCreate(context.Background(), apiAcct{Seg: "acct"}, "keymail", "TITO_S3"); err != nil { |
| + if _, err := newTestSession(ts.URL).storeCreate(context.Background(), apiAcct{Seg: "acct"}, "keymail", "", "TITO_S3"); err != nil { |
| t.Fatalf("storeCreate: %v", err) |
| } |
| var sent struct { |
| @@ -138,7 +138,7 @@ func TestStoreCreateAndProvisionPrintsDeclarationBeforeProvisionFailure(t *testi |
| })) |
| defer ts.Close() |
| - err := storeCreateAndProvision(context.Background(), newTestSession(ts.URL), apiAcct{Seg: "bac"}, "keymail", "", &out) |
| + err := storeCreateAndProvision(context.Background(), newTestSession(ts.URL), apiAcct{Seg: "bac"}, "keymail", "", "", &out) |
| if err == nil || !strings.Contains(err.Error(), "provisioning failed") { |
| t.Fatalf("error = %v, want provision failure", err) |
| } |
| @@ -172,7 +172,7 @@ func TestStoreCreateAndProvisionBranchesOnReply(t *testing.T) { |
| json.NewEncoder(w).Encode(apiStoreReply{App: "keymail", Backend: tt.backend, Entitlement: tt.entitlement, Declared: &apiStoreDeclared{EnvPrefix: "CARLOS_STORE"}}) |
| })) |
| defer ts.Close() |
| - if err := storeCreateAndProvision(context.Background(), newTestSession(ts.URL), apiAcct{Seg: "bac"}, "keymail", "", new(bytes.Buffer)); err != nil { |
| + if err := storeCreateAndProvision(context.Background(), newTestSession(ts.URL), apiAcct{Seg: "bac"}, "keymail", "", "", new(bytes.Buffer)); err != nil { |
| t.Fatal(err) |
| } |
| if calls != tt.wantCalls { |
| @@ -190,7 +190,7 @@ func TestStoreProvisionUsesLongWalkClient(t *testing.T) { |
| defer ts.Close() |
| s := newTestSession(ts.URL) |
| s.c.httpc.Timeout = time.Millisecond |
| - if _, err := s.storeProvision(context.Background(), apiAcct{Seg: "bac"}, "keymail"); err != nil { |
| + if _, err := s.storeProvision(context.Background(), apiAcct{Seg: "bac"}, "keymail", ""); err != nil { |
| t.Fatalf("storeProvision inherited ordinary JSON timeout: %v", err) |
| } |
| if s.c.httpc.Timeout != time.Millisecond { |
| @@ -333,7 +333,7 @@ func TestAPISessionStoreGetWireShape(t *testing.T) { |
| })) |
| defer ts.Close() |
| - res, err := newTestSession(ts.URL).storeGet(context.Background(), apiAcct{Seg: "acct"}, "keymail") |
| + res, err := newTestSession(ts.URL).storeGet(context.Background(), apiAcct{Seg: "acct"}, "keymail", "") |
| if err != nil { |
| t.Fatalf("storeGet: %v", err) |
| } |
| @@ -364,7 +364,7 @@ func TestAPISessionStoreGetNotDeclaredSurfacesServerText(t *testing.T) { |
| })) |
| defer ts.Close() |
| - _, err := newTestSession(ts.URL).storeGet(context.Background(), apiAcct{Seg: "acct"}, "keymail") |
| + _, err := newTestSession(ts.URL).storeGet(context.Background(), apiAcct{Seg: "acct"}, "keymail", "") |
| if err == nil || !strings.Contains(err.Error(), "no store is declared") { |
| t.Fatalf("storeGet 404 = %v, want the server's own text", err) |
| } |
| @@ -390,7 +390,7 @@ func TestAPISessionStoreGrantWireShape(t *testing.T) { |
| })) |
| defer ts.Close() |
| - res, err := newTestSession(ts.URL).storeGrant(context.Background(), apiAcct{Seg: "acct"}, "keymail", "") |
| + res, err := newTestSession(ts.URL).storeGrant(context.Background(), apiAcct{Seg: "acct"}, "keymail", "", "") |
| if err != nil { |
| t.Fatalf("storeGrant: %v", err) |
| } |
| @@ -417,7 +417,7 @@ func TestAPISessionStoreGrantSendsChosenBucket(t *testing.T) { |
| })) |
| defer ts.Close() |
| - if _, err := newTestSession(ts.URL).storeGrant(context.Background(), apiAcct{Seg: "acct"}, "keymail", "my-existing-bucket"); err != nil { |
| + if _, err := newTestSession(ts.URL).storeGrant(context.Background(), apiAcct{Seg: "acct"}, "keymail", "", "my-existing-bucket"); err != nil { |
| t.Fatalf("storeGrant: %v", err) |
| } |
| var sent struct { |
| @@ -439,7 +439,7 @@ func TestAPISessionStoreGrant403SurfacesVerbatim(t *testing.T) { |
| })) |
| defer ts.Close() |
| - _, err := newTestSession(ts.URL).storeGrant(context.Background(), apiAcct{Seg: "acct"}, "keymail", "") |
| + _, err := newTestSession(ts.URL).storeGrant(context.Background(), apiAcct{Seg: "acct"}, "keymail", "", "") |
| if err == nil || err.Error() != "store grants need a deployment operator (CARLOS_STORE_OPERATORS)" { |
| t.Fatalf("storeGrant 403 = %v, want the console's own text byte-for-byte", err) |
| } |
| @@ -464,7 +464,7 @@ func TestAPISessionStoreRotateWireShape(t *testing.T) { |
| })) |
| defer ts.Close() |
| - res, err := newTestSession(ts.URL).storeRotate(context.Background(), apiAcct{Seg: "acct"}, "keymail", false) |
| + res, err := newTestSession(ts.URL).storeRotate(context.Background(), apiAcct{Seg: "acct"}, "keymail", "", false) |
| if err != nil { |
| t.Fatalf("storeRotate: %v", err) |
| } |
| @@ -496,7 +496,7 @@ func TestAPISessionStoreRotateFinishHitsFinishDoor(t *testing.T) { |
| })) |
| defer ts.Close() |
| - res, err := newTestSession(ts.URL).storeRotate(context.Background(), apiAcct{Seg: "acct"}, "keymail", true) |
| + res, err := newTestSession(ts.URL).storeRotate(context.Background(), apiAcct{Seg: "acct"}, "keymail", "", true) |
| if err != nil { |
| t.Fatalf("storeRotate(finish=true): %v", err) |
| } |
| @@ -517,7 +517,7 @@ func TestAPISessionStoreRotateConflictSurfacesVerbatim(t *testing.T) { |
| })) |
| defer ts.Close() |
| - _, err := newTestSession(ts.URL).storeRotate(context.Background(), apiAcct{Seg: "acct"}, "keymail", false) |
| + _, err := newTestSession(ts.URL).storeRotate(context.Background(), apiAcct{Seg: "acct"}, "keymail", "", false) |
| if err == nil || !strings.Contains(err.Error(), "grant one first") { |
| t.Fatalf("storeRotate 409 = %v, want the console's own text", err) |
| } |