// 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///environments/.json CAS-guarded, schema-1 manifest package appconfig import ( "context" "fmt" "regexp" "sort" "strings" "sync" "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///environments/.json key // shares it. Environments were born account-scoped and never had a legacy // unqualified shape — which, since the read-fallback's retirement, is now // true of every read path rather than a distinction worth drawing. 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 ` 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 claimRawSaveEnvironment(ctx context.Context, s release.Store, acct, app, envName string, sets map[string]Var, unsets []string) error { _, err := applyEnvironment(ctx, s, acct, app, envName, sets, unsets) return err } // UnsetEnvironment removes vars by key from app's envName bundle, returning // how many of keys were actually there. Split out from SaveEnvironment // because the count is the difference between "unset 1 var(s)" and the truth // (2026-08-02 review, MINOR 9) — and because a bundle that does not exist is // refused here rather than minted (applyAt's doc comment has the why; a // phantom bundle satisfies `carlos route -environment`'s existence rail). func claimRawUnsetEnvironment(ctx context.Context, s release.Store, acct, app, envName string, keys []string) (int, error) { return applyEnvironment(ctx, s, acct, app, envName, nil, keys) } // applyEnvironment is apply's environment-scoped twin — same validation, // same applyAt, and the same post-success generation touch. The generation // object is per-APP, not per-bundle (generation.go): SyncExec resolves each // route's own (app, environment) pair itself, so one signal per app is // enough, and a per-bundle signal would mean the watcher had to enumerate // bundles to know what to watch. func applyEnvironment(ctx context.Context, s release.Store, acct, app, envName string, sets map[string]Var, unsets []string) (int, error) { if !release.ValidAccount(acct) { return 0, fmt.Errorf("config: invalid account %q", acct) } if !release.ValidApp(app) { return 0, fmt.Errorf("config: invalid app %q", app) } if !ValidEnvironmentName(envName) { return 0, fmt.Errorf("config: invalid environment name %q", envName) } removed, _, err := applyAt(ctx, s, EnvironmentKey(acct, app, envName), app, envName, sets, unsets) if err != nil { return 0, err } touchLogged(ctx, s, acct, app) return removed, nil } // ListEnvironments returns app's named environment bundle names (bare // names, not full keys), sorted, from a prefix listing — the source // `carlos env environments -app ` (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) { m, _, _, err := loadMergedETags(ctx, s, acct, app, envName) return m, err } // loadMergedETags is LoadMerged plus the etag of each object it actually // merged ("" for one it did not read or found absent), taken from the same // read that returned the bytes. LoadMergedHostVersions needs them: a later, // independent read could not say which versions were rendered. func loadMergedETags(ctx context.Context, s release.Store, acct, app, envName string) (*Manifest, 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) } // The environment is read beside the default bundle, not after it: two // independent objects, and a wake's config admission waits on both // before its lease (wake latency, pass 2). The answers are taken in the // order the reads used to run, so every error is the one it was. var ( env *Manifest envETag string envFound bool envErr error envRead = envName != "" && ValidEnvironmentName(envName) wg sync.WaitGroup ) if envRead { wg.Add(1) go func() { defer wg.Done() env, envETag, envFound, envErr = getIfExistsAtETag(ctx, s, EnvironmentKey(acct, app, envName)) }() } def, defETag, defFound, err := getIfExistsAtETag(ctx, s, Key(acct, app)) wg.Wait() if err != nil { return nil, "", "", err } if envName == "" { if !defFound { return &Manifest{Schema: 1, App: app, Vars: map[string]Var{}}, "", "", nil } return def, defETag, "", nil } if !envRead { return nil, "", "", fmt.Errorf("config: invalid environment name %q", envName) } if err := envErr; 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, defETag, envETag, nil } func SaveEnvironment(ctx context.Context, s release.Store, acct, app, envName string, sets map[string]Var, unsets []string) (claimResult0 error) { claimResult0 = release.WithAppMutation(ctx, s, acct, app, "appconfig.SaveEnvironment", func(claimContext context.Context) error { claimResult0 = claimRawSaveEnvironment(claimContext, s, acct, app, envName, sets, unsets) return claimResult0 }) return } func UnsetEnvironment(ctx context.Context, s release.Store, acct, app, envName string, keys []string) (claimResult0 int, claimResult1 error) { claimResult1 = release.WithAppMutation(ctx, s, acct, app, "appconfig.UnsetEnvironment", func(claimContext context.Context) error { claimResult0, claimResult1 = claimRawUnsetEnvironment(claimContext, s, acct, app, envName, keys) return claimResult1 }) return }