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>
3 files changed,
+489
−8
internal/appconfig/environments.go+179 −0internal/appconfig/environments_test.go+278 −0internal/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 |
| } |