carlos / platform Public

Merge pull request #408 from carlosframework/per-instance-store

Per-environment object storage: a store an instance can own
James Adam pushed by james@keymail.dev 1447716c8ab7e1fe790ba36525982f47242e29ad parent eb9ba0fparent 4d9d5dc
26 files changed, +2221 −213
  • apimode.go +35 −11
  • docs/site/cli.md +35 −0
  • docs/superpowers/specs/2026-09-19-per-environment-store-design.md +253 −0
  • internal/console/apiscan_test.go +4 −5
  • internal/console/apistore.go +136 −25
  • internal/console/apistore_env_test.go +146 −0
  • internal/console/apistore_envonly_test.go +52 −0
  • internal/console/apistore_provision.go +9 −5
  • internal/console/apistore_provision_test.go +1 −1
  • internal/console/appsettings.go +177 −15
  • internal/console/appsettings_env_test.go +88 −0
  • internal/console/economicspass.go +32 −13
  • internal/console/ownership.go +84 −40
  • internal/console/server.go +1 −0
  • internal/console/store_env_estate_test.go +187 −0
  • internal/console/storelock.go +18 −8
  • internal/console/storelock_test.go +32 −10
  • internal/console/templates/app_settings.html +85 −0
  • internal/objstore/objstore.go +107 −5
  • internal/objstore/walk.go +187 −40
  • internal/objstore/walk_env_test.go +304 −0
  • internal/release/trash.go +30 −6
  • store.go +108 −16
  • store_env_guard_test.go +51 −0
  • store_env_print_test.go +46 −0
  • store_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">&mdash;</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">&mdash;</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 &lt;name&gt;</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 &lt;name&gt; KEY=value</code>, then bind an instance to it with
+<code>carlos instances set-environment --app {{.Detail.App}} --host &lt;h&gt;
+--environment &lt;name&gt;</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 &mdash; {{.Environment}} environment</h2>
+<p class="small">Its own bucket and its own credential, delivered into the
+<code>{{.Environment}}</code> config bundle &mdash; 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">&mdash;</span>{{end}}</td>
+ <td>{{if .Backend}}{{.Backend}}{{else}}<span title="not granted yet">&mdash;</span>{{end}}</td>
+ <td>{{if .Bucket}}<code>{{.Bucket}}</code>{{else}}<span title="not granted yet">&mdash;</span>{{end}}</td>
+ <td>{{if .KeyID}}<code>{{.KeyID}}</code>{{else}}<span title="no credentials delivered yet">&mdash;</span>{{end}}</td>
+ <td>{{if .KeyAge}}{{.KeyAge}}{{else}}<span title="unknown">&mdash;</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 &mdash; 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 &mdash; 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)
}