carlos / platform Public

appconfig: named environment manifests + merged resolution

Task 1 of the environments plan (pipelines slice 2, spec §4a): a named
environment is the same manifest shape as the default config.json bundle,
at apps/<acct>/<app>/environments/<name>.json, sharing config.json's CAS
read-modify-write and schema-1 refusal (store.go's getIfExists/
getForWrite/apply generalized to take a bucket key, so both bundles go
through one path).

Adds EnvironmentKey, ValidEnvironmentName ([a-z0-9-]{1,32}, "default"
reserved), LoadEnvironment/SaveEnvironment, ListEnvironments (prefix
listing), and LoadMerged — default overlaid by the named environment,
environment winning per key, one overlay never a chain. envName == ""
resolves to the default alone (empty manifest if neither exists, matching
Sync's existing "never configured" convention); a NAMED environment that
doesn't exist is an error, since a caller naming one asserts it exists.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 8365bd88e45b86a72e4973e72701191867aa14e6 parent c8038ae
3 files changed, +489 −8
  • internal/appconfig/environments.go +179 −0
  • internal/appconfig/environments_test.go +278 −0
  • internal/appconfig/store.go +32 −8
diff --git a/internal/appconfig/environments.go b/internal/appconfig/environments.go
new file mode 100644
index 000000000..9c3a827c2
--- /dev/null
+++ b/internal/appconfig/environments.go
@@ -0,0 +1,179 @@
+// Named environments: overlays on an app's default config.json bundle, at
+// the pipelines design's sibling bucket path
+// (2026-08-02-pipelines-design.md §4a). Same manifest shape, same CAS
+// discipline, same schema-1 checks as the default bundle (store.go) — a
+// named environment is not a new format, just a second bucket key an app
+// can write the same shape to.
+//
+// Bucket layout addition:
+//
+// apps/<acct>/<app>/environments/<name>.json CAS-guarded, schema-1 manifest
+package appconfig
+
+import (
+ "context"
+ "fmt"
+ "regexp"
+ "sort"
+ "strings"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// EnvironmentKey is the bucket key of app's envName environment bundle —
+// account-scoped and app-scoped the same way Key is, at a sibling path so a
+// prefix listing (ListEnvironments) can enumerate bundles independently of
+// config.json.
+func EnvironmentKey(acct, app, name string) string {
+ return release.AppPrefix(acct, app) + "environments/" + name + ".json"
+}
+
+// EnvironmentsPrefix is the list prefix behind "what named environments does
+// this app have" — every apps/<acct>/<app>/environments/<name>.json key
+// shares it. Unlike release.ChannelsPrefix there is no legacy fallback:
+// environments are new, so there is nothing pre-migration to fall back to.
+func EnvironmentsPrefix(acct, app string) string {
+ return release.AppPrefix(acct, app) + "environments/"
+}
+
+// envNameRe: lowercase letters, digits and hyphens, 1-32 characters — the
+// pipelines design's environment-name shape (§4a). Case-restricted (unlike
+// ValidVarName) because these names appear in bucket paths and CLI
+// arguments an operator types by hand across many apps; one case convention
+// keeps "Demo" and "demo" from silently being two different bundles.
+var envNameRe = regexp.MustCompile(`^[a-z0-9-]{1,32}$`)
+
+// ValidEnvironmentName reports whether name is usable as a named
+// environment bundle name. "default" is refused — it names the implicit
+// base bundle (config.json), not a named overlay — and so is "", since an
+// empty name is how callers ask for "no environment" (LoadMerged's
+// envName == "" case), never a bundle to write.
+func ValidEnvironmentName(name string) bool {
+ if name == "default" {
+ return false
+ }
+ return envNameRe.MatchString(name)
+}
+
+// LoadEnvironment fetches app's envName bundle, an empty manifest (not an
+// error) if it has never been configured — same "never configured is not an
+// error" convention as Get, for the same reason: `carlos env list
+// -environment <name>` has nothing to print either way.
+func LoadEnvironment(ctx context.Context, s release.Store, acct, app, envName string) (*Manifest, error) {
+ if !release.ValidAccount(acct) {
+ return nil, fmt.Errorf("config: invalid account %q", acct)
+ }
+ if !release.ValidApp(app) {
+ return nil, fmt.Errorf("config: invalid app %q", app)
+ }
+ if !ValidEnvironmentName(envName) {
+ return nil, fmt.Errorf("config: invalid environment name %q", envName)
+ }
+ m, found, err := getIfExistsAt(ctx, s, EnvironmentKey(acct, app, envName))
+ if err != nil {
+ return nil, err
+ }
+ if !found {
+ return &Manifest{Schema: 1, App: app, Vars: map[string]Var{}}, nil
+ }
+ return m, nil
+}
+
+// SaveEnvironment applies sets/unsets to app's envName bundle via the exact
+// same CAS read-modify-write path SetEnv/SetSecret/Unset use for the default
+// bundle (applyAt) — a named environment gets the same racing-write
+// ErrCASConflict discipline the default bundle already has.
+func SaveEnvironment(ctx context.Context, s release.Store, acct, app, envName string, sets map[string]Var, unsets []string) error {
+ if !release.ValidAccount(acct) {
+ return fmt.Errorf("config: invalid account %q", acct)
+ }
+ if !release.ValidApp(app) {
+ return fmt.Errorf("config: invalid app %q", app)
+ }
+ if !ValidEnvironmentName(envName) {
+ return fmt.Errorf("config: invalid environment name %q", envName)
+ }
+ return applyAt(ctx, s, EnvironmentKey(acct, app, envName), app, sets, unsets)
+}
+
+// ListEnvironments returns app's named environment bundle names (bare
+// names, not full keys), sorted, from a prefix listing — the source
+// `carlos env environments -app <a>` (and the route -environment existence
+// rail) reads. An app with none configured lists empty, not an error.
+func ListEnvironments(ctx context.Context, s release.Store, acct, app string) ([]string, error) {
+ if !release.ValidAccount(acct) {
+ return nil, fmt.Errorf("config: invalid account %q", acct)
+ }
+ if !release.ValidApp(app) {
+ return nil, fmt.Errorf("config: invalid app %q", app)
+ }
+ prefix := EnvironmentsPrefix(acct, app)
+ keys, err := s.List(ctx, prefix)
+ if err != nil {
+ return nil, err
+ }
+ names := make([]string, 0, len(keys))
+ for _, k := range keys {
+ names = append(names, strings.TrimSuffix(strings.TrimPrefix(k, prefix), ".json"))
+ }
+ sort.Strings(names)
+ return names, nil
+}
+
+// LoadMerged resolves the manifest a route actually gets at materialization
+// time: the default bundle (config.json) overlaid by envName's named
+// bundle, the environment winning per key — one overlay, never a chain
+// (pipelines design §4a).
+//
+// envName == "" resolves to the default bundle alone (or an empty manifest
+// if there is no default bundle either — matches Get/Sync's existing
+// "never configured" convention: sync.go's getIfExists-then-skip path never
+// treats an unconfigured app as an error). A present default with no named
+// environment, or a present named environment with no default, both resolve
+// to just that side — an environment does not require a default to exist.
+//
+// A NAMED environment that is absent is different: the caller (a route
+// bound to it via `carlos route -environment`) asserted it exists, so its
+// absence is a defect the operator needs to see, not a silently empty
+// overlay. This is what makes the route-binding existence rail meaningful —
+// see cli.go's runRoute, which checks existence at BIND time so this error
+// is rare in practice (it means the bundle was deleted after binding, not
+// that the bind was unchecked).
+func LoadMerged(ctx context.Context, s release.Store, acct, app, envName string) (*Manifest, error) {
+ if !release.ValidAccount(acct) {
+ return nil, fmt.Errorf("config: invalid account %q", acct)
+ }
+ if !release.ValidApp(app) {
+ return nil, fmt.Errorf("config: invalid app %q", app)
+ }
+ def, defFound, err := getIfExistsAt(ctx, s, Key(acct, app))
+ if err != nil {
+ return nil, err
+ }
+ if envName == "" {
+ if !defFound {
+ return &Manifest{Schema: 1, App: app, Vars: map[string]Var{}}, nil
+ }
+ return def, nil
+ }
+ if !ValidEnvironmentName(envName) {
+ return nil, fmt.Errorf("config: invalid environment name %q", envName)
+ }
+ env, envFound, err := getIfExistsAt(ctx, s, EnvironmentKey(acct, app, envName))
+ if err != nil {
+ return nil, err
+ }
+ if !envFound {
+ return nil, fmt.Errorf("config: environment %q not found for %s/%s — bound but never created (`carlos env set -app %s -environment %s ...`)", envName, acct, app, app, envName)
+ }
+ merged := &Manifest{Schema: 1, App: app, Vars: map[string]Var{}}
+ if defFound {
+ for k, v := range def.Vars {
+ merged.Vars[k] = v
+ }
+ }
+ for k, v := range env.Vars {
+ merged.Vars[k] = v
+ }
+ return merged, nil
+}
diff --git a/internal/appconfig/environments_test.go b/internal/appconfig/environments_test.go
new file mode 100644
index 000000000..4d40663f6
--- /dev/null
+++ b/internal/appconfig/environments_test.go
@@ -0,0 +1,278 @@
+// internal/appconfig/environments_test.go
+package appconfig
+
+import (
+ "context"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func TestEnvironmentKeyIsAccountAndAppScoped(t *testing.T) {
+ got := EnvironmentKey("bab", "keymail", "demo")
+ want := "apps/bab/keymail/environments/demo.json"
+ if got != want {
+ t.Fatalf("EnvironmentKey(bab, keymail, demo) = %q, want %q", got, want)
+ }
+}
+
+func TestValidEnvironmentName(t *testing.T) {
+ valid := []string{"demo", "a", "connect-timeslots", "yste-demo", "0-9"}
+ invalid := []string{
+ "", "default", // reserved / empty
+ "Demo", // uppercase
+ "has space", // space
+ "has_us", // underscore not allowed
+ "UP",
+ string(make([]byte, 33)), // too long (33 NUL bytes, also fails the char class)
+ }
+ for _, n := range valid {
+ if !ValidEnvironmentName(n) {
+ t.Errorf("ValidEnvironmentName(%q) = false, want true", n)
+ }
+ }
+ for _, n := range invalid {
+ if ValidEnvironmentName(n) {
+ t.Errorf("ValidEnvironmentName(%q) = true, want false", n)
+ }
+ }
+}
+
+func TestValidEnvironmentNameLengthBoundary(t *testing.T) {
+ ok32 := "01234567890123456789012345678901" // 32 chars
+ if len(ok32) != 32 {
+ t.Fatalf("test fixture is %d chars, want 32", len(ok32))
+ }
+ if !ValidEnvironmentName(ok32) {
+ t.Errorf("ValidEnvironmentName(32 chars) = false, want true")
+ }
+ tooLong := ok32 + "x"
+ if ValidEnvironmentName(tooLong) {
+ t.Errorf("ValidEnvironmentName(33 chars) = true, want false")
+ }
+}
+
+func TestLoadEnvironmentOnMissingBundleReturnsEmpty(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ m, err := LoadEnvironment(context.Background(), s, "bab", "keymail", "demo")
+ if err != nil {
+ t.Fatalf("LoadEnvironment: %v", err)
+ }
+ if len(m.Vars) != 0 {
+ t.Fatalf("Vars = %v, want empty for a never-configured environment", m.Vars)
+ }
+}
+
+func TestLoadEnvironmentRejectsReservedName(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ if _, err := LoadEnvironment(context.Background(), s, "bab", "keymail", "default"); err == nil {
+ t.Fatal("LoadEnvironment(..., \"default\") accepted, want a refusal")
+ }
+}
+
+func TestSaveEnvironmentThenLoad(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ if err := SaveEnvironment(ctx, s, "bab", "keymail", "demo",
+ map[string]Var{"LOG_LEVEL": {Kind: "env", Value: "debug"}}, nil); err != nil {
+ t.Fatalf("SaveEnvironment: %v", err)
+ }
+ m, err := LoadEnvironment(ctx, s, "bab", "keymail", "demo")
+ if err != nil {
+ t.Fatalf("LoadEnvironment: %v", err)
+ }
+ if v := m.Vars["LOG_LEVEL"]; v.Kind != "env" || v.Value != "debug" {
+ t.Fatalf("LOG_LEVEL = %+v, want kind env value debug", v)
+ }
+ // The default bundle (config.json) must be untouched by a named-bundle write.
+ def, err := Get(ctx, s, "bab", "keymail")
+ if err != nil {
+ t.Fatalf("Get (default): %v", err)
+ }
+ if len(def.Vars) != 0 {
+ t.Fatalf("default bundle vars = %v, want empty — SaveEnvironment must not touch config.json", def.Vars)
+ }
+}
+
+func TestSaveEnvironmentRejectsReservedOrInvalidName(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ for _, bad := range []string{"", "default", "Has-Upper", "has_underscore"} {
+ if err := SaveEnvironment(ctx, s, "bab", "keymail", bad,
+ map[string]Var{"A": {Kind: "env", Value: "1"}}, nil); err == nil {
+ t.Errorf("SaveEnvironment(..., %q, ...) accepted, want a refusal", bad)
+ }
+ }
+}
+
+func TestSaveEnvironmentNewerSchemaRefused(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ raw := []byte(`{"schema": 2, "app": "keymail", "vars": {}}`)
+ if _, err := s.Put(ctx, EnvironmentKey("bab", "keymail", "demo"), raw, ""); err != nil {
+ t.Fatalf("Put: %v", err)
+ }
+ if _, err := LoadEnvironment(ctx, s, "bab", "keymail", "demo"); err == nil {
+ t.Fatal("LoadEnvironment read a schema-2 bundle without complaint, want a refusal")
+ }
+ if err := SaveEnvironment(ctx, s, "bab", "keymail", "demo",
+ map[string]Var{"A": {Kind: "env", Value: "1"}}, nil); err == nil {
+ t.Fatal("SaveEnvironment wrote over a schema-2 bundle, want a refusal")
+ }
+}
+
+func TestListEnvironments(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ for _, name := range []string{"demo", "connect", "timeslots"} {
+ if err := SaveEnvironment(ctx, s, "bab", "tito", name,
+ map[string]Var{"X": {Kind: "env", Value: "1"}}, nil); err != nil {
+ t.Fatalf("SaveEnvironment(%s): %v", name, err)
+ }
+ }
+ // A different app's environment must not leak in.
+ if err := SaveEnvironment(ctx, s, "bab", "other-app", "demo",
+ map[string]Var{"X": {Kind: "env", Value: "1"}}, nil); err != nil {
+ t.Fatal(err)
+ }
+ names, err := ListEnvironments(ctx, s, "bab", "tito")
+ if err != nil {
+ t.Fatalf("ListEnvironments: %v", err)
+ }
+ want := []string{"connect", "demo", "timeslots"}
+ if len(names) != len(want) {
+ t.Fatalf("ListEnvironments = %v, want %v", names, want)
+ }
+ for i := range want {
+ if names[i] != want[i] {
+ t.Fatalf("ListEnvironments = %v, want %v", names, want)
+ }
+ }
+}
+
+func TestListEnvironmentsEmptyWhenNoneConfigured(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ names, err := ListEnvironments(context.Background(), s, "bab", "keymail")
+ if err != nil {
+ t.Fatalf("ListEnvironments: %v", err)
+ }
+ if len(names) != 0 {
+ t.Fatalf("ListEnvironments = %v, want empty", names)
+ }
+}
+
+// --- LoadMerged ---------------------------------------------------------
+
+func TestLoadMergedEmptyEnvNameIsDefaultOnly(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ if err := SetEnv(ctx, s, "bab", "keymail", map[string]string{"A": "default-a"}); err != nil {
+ t.Fatal(err)
+ }
+ m, err := LoadMerged(ctx, s, "bab", "keymail", "")
+ if err != nil {
+ t.Fatalf("LoadMerged: %v", err)
+ }
+ if len(m.Vars) != 1 || m.Vars["A"].Value != "default-a" {
+ t.Fatalf("LoadMerged(envName=\"\") = %+v, want just the default bundle", m.Vars)
+ }
+}
+
+// Matches Sync's current behaviour for an unconfigured app: no default
+// object AND envName == "" resolves to an empty manifest, not an error.
+func TestLoadMergedNoDefaultNoEnvNameIsEmptyNotError(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ m, err := LoadMerged(context.Background(), s, "bab", "keymail", "")
+ if err != nil {
+ t.Fatalf("LoadMerged: %v, want no error for an unconfigured app", err)
+ }
+ if len(m.Vars) != 0 {
+ t.Fatalf("Vars = %v, want empty", m.Vars)
+ }
+}
+
+func TestLoadMergedEnvironmentOverlaysDefaultWinningPerKey(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ if err := SetEnv(ctx, s, "bab", "keymail", map[string]string{
+ "SHARED": "default-value", "DEFAULT_ONLY": "d",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if err := SaveEnvironment(ctx, s, "bab", "keymail", "demo", map[string]Var{
+ "SHARED": {Kind: "env", Value: "demo-value"},
+ "ENV_ONLY": {Kind: "env", Value: "e"},
+ }, nil); err != nil {
+ t.Fatal(err)
+ }
+ m, err := LoadMerged(ctx, s, "bab", "keymail", "demo")
+ if err != nil {
+ t.Fatalf("LoadMerged: %v", err)
+ }
+ if len(m.Vars) != 3 {
+ t.Fatalf("Vars = %+v, want 3 keys (SHARED, DEFAULT_ONLY, ENV_ONLY)", m.Vars)
+ }
+ if m.Vars["SHARED"].Value != "demo-value" {
+ t.Fatalf("SHARED = %q, want the environment's overlay value to win", m.Vars["SHARED"].Value)
+ }
+ if m.Vars["DEFAULT_ONLY"].Value != "d" {
+ t.Fatalf("DEFAULT_ONLY = %q, want the default's value preserved", m.Vars["DEFAULT_ONLY"].Value)
+ }
+ if m.Vars["ENV_ONLY"].Value != "e" {
+ t.Fatalf("ENV_ONLY = %q, want the environment-only value preserved", m.Vars["ENV_ONLY"].Value)
+ }
+}
+
+// A MISSING default with a present, named environment resolves to the
+// environment alone — not an error, and not folded into an empty manifest.
+func TestLoadMergedEnvironmentOnlyNoDefault(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ if err := SaveEnvironment(ctx, s, "bab", "keymail", "demo",
+ map[string]Var{"A": {Kind: "env", Value: "1"}}, nil); err != nil {
+ t.Fatal(err)
+ }
+ m, err := LoadMerged(ctx, s, "bab", "keymail", "demo")
+ if err != nil {
+ t.Fatalf("LoadMerged: %v", err)
+ }
+ if len(m.Vars) != 1 || m.Vars["A"].Value != "1" {
+ t.Fatalf("LoadMerged(env-only) = %+v, want just the environment's vars", m.Vars)
+ }
+}
+
+// A missing environment named EXPLICITLY is an error: the caller bound a
+// route to it, so its absence is a defect, not an empty set — distinct from
+// envName == "", which is a legitimate "no environment bound" state.
+func TestLoadMergedMissingNamedEnvironmentErrors(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ if err := SetEnv(ctx, s, "bab", "keymail", map[string]string{"A": "1"}); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := LoadMerged(ctx, s, "bab", "keymail", "demo"); err == nil {
+ t.Fatal("LoadMerged with a named-but-absent environment: got nil error, want one")
+ }
+}
+
+func TestLoadMergedRejectsInvalidEnvironmentName(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ if _, err := LoadMerged(context.Background(), s, "bab", "keymail", "default"); err == nil {
+ t.Fatal("LoadMerged(..., \"default\") accepted, want a refusal — \"default\" is not a named environment")
+ }
+}
+
+func TestLoadMergedSchemaTooNewOnEitherSideRefuses(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ raw := []byte(`{"schema": 2, "app": "keymail", "vars": {}}`)
+ if _, err := s.Put(ctx, EnvironmentKey("bab", "keymail", "demo"), raw, ""); err != nil {
+ t.Fatal(err)
+ }
+ if err := SetEnv(ctx, s, "bab", "keymail", map[string]string{"A": "1"}); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := LoadMerged(ctx, s, "bab", "keymail", "demo"); err == nil {
+ t.Fatal("LoadMerged over a schema-2 environment bundle: got nil error, want one")
+ }
+}
diff --git a/internal/appconfig/store.go b/internal/appconfig/store.go
index 7fdd51b86..3dc0be5ec 100644
--- a/internal/appconfig/store.go
+++ b/internal/appconfig/store.go
@@ -43,7 +43,16 @@ func getIfExists(ctx context.Context, s release.Store, acct, app string) (*Manif
if !release.ValidApp(app) {
return nil, false, fmt.Errorf("config: invalid app %q", app)
}
- raw, _, err := s.Get(ctx, Key(acct, app))
+ return getIfExistsAt(ctx, s, Key(acct, app))
+}
+
+// getIfExistsAt is getIfExists generalized to any manifest key — Key(acct,
+// app) for the default bundle, EnvironmentKey(acct, app, name) for a named
+// one (see environments.go). Same shape either way: a config.json and a
+// named environment bundle are the identical manifest format at sibling
+// bucket paths, so one read path serves both.
+func getIfExistsAt(ctx context.Context, s release.Store, key string) (*Manifest, bool, error) {
+ raw, _, err := s.Get(ctx, key)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, false, nil
@@ -52,13 +61,13 @@ func getIfExists(ctx context.Context, s release.Store, acct, app string) (*Manif
}
var m Manifest
if err := json.Unmarshal(raw, &m); err != nil {
- return nil, false, fmt.Errorf("config %s: %w", Key(acct, app), err)
+ return nil, false, fmt.Errorf("config %s: %w", key, err)
}
// json.Unmarshal has already dropped any fields this binary doesn't
// know about, so acting on a newer-schema manifest would silently
// discard whatever those fields meant. Refuse instead.
if m.Schema > 1 {
- return nil, false, fmt.Errorf("config %s: schema %d is newer than this binary understands — upgrade carlos", Key(acct, app), m.Schema)
+ return nil, false, fmt.Errorf("config %s: schema %d is newer than this binary understands — upgrade carlos", key, m.Schema)
}
return &m, true, nil
}
@@ -66,18 +75,25 @@ func getIfExists(ctx context.Context, s release.Store, acct, app string) (*Manif
// getForWrite is Get plus the etag a caller needs to write back — a
// not-found manifest reads as empty with etag "" (create-only Put).
func getForWrite(ctx context.Context, s release.Store, acct, app string) (Manifest, string, error) {
- raw, etag, err := s.Get(ctx, Key(acct, app))
+ return getForWriteAt(ctx, s, Key(acct, app), app)
+}
+
+// getForWriteAt is getForWrite generalized to any manifest key — see
+// getIfExistsAt's doc comment for why one path serves both the default
+// bundle and a named environment.
+func getForWriteAt(ctx context.Context, s release.Store, key, app string) (Manifest, string, error) {
+ raw, etag, err := s.Get(ctx, key)
switch {
case err == nil:
var m Manifest
if uerr := json.Unmarshal(raw, &m); uerr != nil {
- return Manifest{}, "", fmt.Errorf("config %s: %w", Key(acct, app), uerr)
+ return Manifest{}, "", fmt.Errorf("config %s: %w", key, uerr)
}
// Same newer-schema refusal as getIfExists — writing back through
// this path would downgrade the object to schema 1 minus the fields
// unmarshal already dropped.
if m.Schema > 1 {
- return Manifest{}, "", fmt.Errorf("config %s: schema %d is newer than this binary understands — upgrade carlos", Key(acct, app), m.Schema)
+ return Manifest{}, "", fmt.Errorf("config %s: schema %d is newer than this binary understands — upgrade carlos", key, m.Schema)
}
return m, etag, nil
case errors.Is(err, os.ErrNotExist):
@@ -98,6 +114,14 @@ func apply(ctx context.Context, s release.Store, acct, app string, sets map[stri
if !release.ValidApp(app) {
return fmt.Errorf("config: invalid app %q", app)
}
+ return applyAt(ctx, s, Key(acct, app), app, sets, unsets)
+}
+
+// applyAt is apply generalized to any manifest key — see getIfExistsAt's doc
+// comment for why one path serves both the default bundle and a named
+// environment. Callers validate acct/app/environment-name themselves before
+// reaching here, same division as apply does for Key(acct, app).
+func applyAt(ctx context.Context, s release.Store, key, app string, sets map[string]Var, unsets []string) error {
for k, v := range sets {
if !ValidVarName(k) {
return fmt.Errorf("config: invalid var name %q", k)
@@ -113,7 +137,7 @@ func apply(ctx context.Context, s release.Store, acct, app string, sets map[stri
}
}
}
- m, etag, err := getForWrite(ctx, s, acct, app)
+ m, etag, err := getForWriteAt(ctx, s, key, app)
if err != nil {
return err
}
@@ -131,7 +155,7 @@ func apply(ctx context.Context, s release.Store, acct, app string, sets map[stri
if err != nil {
return err
}
- _, err = s.Put(ctx, Key(acct, app), raw, etag)
+ _, err = s.Put(ctx, key, raw, etag)
return err
}