carlos / platform Public

per-environment object storage: the console doors

Every store door takes ?environment=, and drives the matching *In walk under
a lock keyed on the triple. Absent is "" — the app's own store, and every
request made before environment stores existed.

The spelling is a query parameter on all six doors rather than a body field
on the ones that have bodies. Rotate and finish document that they read no
body 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 ?environment= the config plane already uses: a store delivers INTO a
bundle, so the two planes naming it differently would be a seam with no
meaning.

storeReply carries the environment and asks EnvDeliveredIn about the bundle
the store actually delivers into. Against the default bundle, an environment
store would have reported every delivered variable missing, on every status
call, forever — and the grant walk's idempotence short-circuit reads the
same seam, so it would have minted a fresh key on every single walk.

Asked about the app's own store, status now also names every environment
this app has a store for. An environment store nobody remembers is a bucket
nobody remembers, and with teardown explicit by ruling, this is the screen
where it can be seen.

The lock key gained the environment, and the literal was restated in two
test files. Both went on compiling while reading a refcount that could never
move, failing as "the waiter never registered" — a sentence about the lock
rather than about the key. storeWalkKey is now the one place it is spelled.

Spec: docs/superpowers/specs/2026-09-19-per-environment-store-design.md §6
Discussion: amadan carlos/platform #159
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
James Adam pushed by james@keymail.dev b747a7f2775f7ca730aef32ef1c39b92c6062846 parent 96d4dac
9 files changed, +356 −50
  • internal/console/apistore.go +109 −24
  • internal/console/apistore_env_test.go +146 −0
  • internal/console/apistore_provision.go +9 −5
  • internal/console/apistore_provision_test.go +1 −1
  • internal/console/appsettings.go +1 −1
  • internal/console/ownership.go +1 −1
  • internal/console/storelock.go +18 −8
  • internal/console/storelock_test.go +32 −10
  • internal/objstore/objstore.go +39 −0
diff --git a/internal/console/apistore.go b/internal/console/apistore.go
index 1db526b03..6187568c4 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,47 @@ 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.
+ if env != "" {
+ jsonError(w, http.StatusNotFound,
+ "no store is declared for this app's "+env+" environment — run `carlos store create --environment "+env+"`")
+ return
+ }
jsonError(w, http.StatusNotFound, "no store is declared for this app — run `carlos store create`")
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 +675,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 +756,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 +784,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 +823,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 +861,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 +891,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 +921,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_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..f6a9bef49 100644
--- a/internal/console/appsettings.go
+++ b/internal/console/appsettings.go
@@ -987,7 +987,7 @@ 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)()
+ defer s.storeWalks.lock(acct.Sqid, app, "")()
rec, _, err := objstore.Get(ctx, s.cfg.Releases, acct.Sqid, app)
if err != nil {
log.Printf("console: store destroy read %s/%s: %v", acct.Sqid, app, err)
diff --git a/internal/console/ownership.go b/internal/console/ownership.go
index d183623bb..641ce50a2 100644
--- a/internal/console/ownership.go
+++ b/internal/console/ownership.go
@@ -408,7 +408,7 @@ func (s *Server) handlePurgeApp(w http.ResponseWriter, r *http.Request) {
// 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)
+ 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 {
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/objstore/objstore.go b/internal/objstore/objstore.go
index 514bc0cc8..77737ff86 100644
--- a/internal/objstore/objstore.go
+++ b/internal/objstore/objstore.go
@@ -12,6 +12,8 @@ import (
"fmt"
"os"
"regexp"
+ "sort"
+ "strings"
"time"
"github.com/carlosframework/platform/internal/appconfig"
@@ -168,6 +170,43 @@ func KeyIn(acct, app, env string) string {
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) }