| 1 | // Named environments: overlays on an app's default config.json bundle, at |
| 2 | // the pipelines design's sibling bucket path |
| 3 | // (2026-08-02-pipelines-design.md §4a). Same manifest shape, same CAS |
| 4 | // discipline, same schema-1 checks as the default bundle (store.go) — a |
| 5 | // named environment is not a new format, just a second bucket key an app |
| 6 | // can write the same shape to. |
| 7 | // |
| 8 | // Bucket layout addition: |
| 9 | // |
| 10 | // apps/<acct>/<app>/environments/<name>.json CAS-guarded, schema-1 manifest |
| 11 | package appconfig |
| 12 | |
| 13 | import ( |
| 14 | "context" |
| 15 | "fmt" |
| 16 | "regexp" |
| 17 | "sort" |
| 18 | "strings" |
| 19 | "sync" |
| 20 | |
| 21 | "github.com/carlosframework/platform/internal/release" |
| 22 | ) |
| 23 | |
| 24 | // EnvironmentKey is the bucket key of app's envName environment bundle — |
| 25 | // account-scoped and app-scoped the same way Key is, at a sibling path so a |
| 26 | // prefix listing (ListEnvironments) can enumerate bundles independently of |
| 27 | // config.json. |
| 28 | func EnvironmentKey(acct, app, name string) string { |
| 29 | return release.AppPrefix(acct, app) + "environments/" + name + ".json" |
| 30 | } |
| 31 | |
| 32 | // EnvironmentsPrefix is the list prefix behind "what named environments does |
| 33 | // this app have" — every apps/<acct>/<app>/environments/<name>.json key |
| 34 | // shares it. Environments were born account-scoped and never had a legacy |
| 35 | // unqualified shape — which, since the read-fallback's retirement, is now |
| 36 | // true of every read path rather than a distinction worth drawing. |
| 37 | func EnvironmentsPrefix(acct, app string) string { |
| 38 | return release.AppPrefix(acct, app) + "environments/" |
| 39 | } |
| 40 | |
| 41 | // envNameRe: lowercase letters, digits and hyphens, 1-32 characters — the |
| 42 | // pipelines design's environment-name shape (§4a). Case-restricted (unlike |
| 43 | // ValidVarName) because these names appear in bucket paths and CLI |
| 44 | // arguments an operator types by hand across many apps; one case convention |
| 45 | // keeps "Demo" and "demo" from silently being two different bundles. |
| 46 | var envNameRe = regexp.MustCompile(`^[a-z0-9-]{1,32}$`) |
| 47 | |
| 48 | // ValidEnvironmentName reports whether name is usable as a named |
| 49 | // environment bundle name. "default" is refused — it names the implicit |
| 50 | // base bundle (config.json), not a named overlay — and so is "", since an |
| 51 | // empty name is how callers ask for "no environment" (LoadMerged's |
| 52 | // envName == "" case), never a bundle to write. |
| 53 | func ValidEnvironmentName(name string) bool { |
| 54 | if name == "default" { |
| 55 | return false |
| 56 | } |
| 57 | return envNameRe.MatchString(name) |
| 58 | } |
| 59 | |
| 60 | // LoadEnvironment fetches app's envName bundle, an empty manifest (not an |
| 61 | // error) if it has never been configured — same "never configured is not an |
| 62 | // error" convention as Get, for the same reason: `carlos env list |
| 63 | // -environment <name>` has nothing to print either way. |
| 64 | func LoadEnvironment(ctx context.Context, s release.Store, acct, app, envName string) (*Manifest, error) { |
| 65 | if !release.ValidAccount(acct) { |
| 66 | return nil, fmt.Errorf("config: invalid account %q", acct) |
| 67 | } |
| 68 | if !release.ValidApp(app) { |
| 69 | return nil, fmt.Errorf("config: invalid app %q", app) |
| 70 | } |
| 71 | if !ValidEnvironmentName(envName) { |
| 72 | return nil, fmt.Errorf("config: invalid environment name %q", envName) |
| 73 | } |
| 74 | m, found, err := getIfExistsAt(ctx, s, EnvironmentKey(acct, app, envName)) |
| 75 | if err != nil { |
| 76 | return nil, err |
| 77 | } |
| 78 | if !found { |
| 79 | return &Manifest{Schema: 1, App: app, Vars: map[string]Var{}}, nil |
| 80 | } |
| 81 | return m, nil |
| 82 | } |
| 83 | |
| 84 | // SaveEnvironment applies sets/unsets to app's envName bundle via the exact |
| 85 | // same CAS read-modify-write path SetEnv/SetSecret/Unset use for the default |
| 86 | // bundle (applyAt) — a named environment gets the same racing-write |
| 87 | // ErrCASConflict discipline the default bundle already has. |
| 88 | func claimRawSaveEnvironment(ctx context.Context, s release.Store, acct, app, envName string, sets map[string]Var, unsets []string) error { |
| 89 | _, err := applyEnvironment(ctx, s, acct, app, envName, sets, unsets) |
| 90 | return err |
| 91 | } |
| 92 | |
| 93 | // UnsetEnvironment removes vars by key from app's envName bundle, returning |
| 94 | // how many of keys were actually there. Split out from SaveEnvironment |
| 95 | // because the count is the difference between "unset 1 var(s)" and the truth |
| 96 | // (2026-08-02 review, MINOR 9) — and because a bundle that does not exist is |
| 97 | // refused here rather than minted (applyAt's doc comment has the why; a |
| 98 | // phantom bundle satisfies `carlos route -environment`'s existence rail). |
| 99 | func claimRawUnsetEnvironment(ctx context.Context, s release.Store, acct, app, envName string, keys []string) (int, error) { |
| 100 | return applyEnvironment(ctx, s, acct, app, envName, nil, keys) |
| 101 | } |
| 102 | |
| 103 | // applyEnvironment is apply's environment-scoped twin — same validation, |
| 104 | // same applyAt, and the same post-success generation touch. The generation |
| 105 | // object is per-APP, not per-bundle (generation.go): SyncExec resolves each |
| 106 | // route's own (app, environment) pair itself, so one signal per app is |
| 107 | // enough, and a per-bundle signal would mean the watcher had to enumerate |
| 108 | // bundles to know what to watch. |
| 109 | func applyEnvironment(ctx context.Context, s release.Store, acct, app, envName string, sets map[string]Var, unsets []string) (int, error) { |
| 110 | if !release.ValidAccount(acct) { |
| 111 | return 0, fmt.Errorf("config: invalid account %q", acct) |
| 112 | } |
| 113 | if !release.ValidApp(app) { |
| 114 | return 0, fmt.Errorf("config: invalid app %q", app) |
| 115 | } |
| 116 | if !ValidEnvironmentName(envName) { |
| 117 | return 0, fmt.Errorf("config: invalid environment name %q", envName) |
| 118 | } |
| 119 | removed, _, err := applyAt(ctx, s, EnvironmentKey(acct, app, envName), app, envName, sets, unsets) |
| 120 | if err != nil { |
| 121 | return 0, err |
| 122 | } |
| 123 | touchLogged(ctx, s, acct, app) |
| 124 | return removed, nil |
| 125 | } |
| 126 | |
| 127 | // ListEnvironments returns app's named environment bundle names (bare |
| 128 | // names, not full keys), sorted, from a prefix listing — the source |
| 129 | // `carlos env environments -app <a>` (and the route -environment existence |
| 130 | // rail) reads. An app with none configured lists empty, not an error. |
| 131 | func ListEnvironments(ctx context.Context, s release.Store, acct, app string) ([]string, error) { |
| 132 | if !release.ValidAccount(acct) { |
| 133 | return nil, fmt.Errorf("config: invalid account %q", acct) |
| 134 | } |
| 135 | if !release.ValidApp(app) { |
| 136 | return nil, fmt.Errorf("config: invalid app %q", app) |
| 137 | } |
| 138 | prefix := EnvironmentsPrefix(acct, app) |
| 139 | keys, err := s.List(ctx, prefix) |
| 140 | if err != nil { |
| 141 | return nil, err |
| 142 | } |
| 143 | names := make([]string, 0, len(keys)) |
| 144 | for _, k := range keys { |
| 145 | names = append(names, strings.TrimSuffix(strings.TrimPrefix(k, prefix), ".json")) |
| 146 | } |
| 147 | sort.Strings(names) |
| 148 | return names, nil |
| 149 | } |
| 150 | |
| 151 | // LoadMerged resolves the manifest a route actually gets at materialization |
| 152 | // time: the default bundle (config.json) overlaid by envName's named |
| 153 | // bundle, the environment winning per key — one overlay, never a chain |
| 154 | // (pipelines design §4a). |
| 155 | // |
| 156 | // envName == "" resolves to the default bundle alone (or an empty manifest |
| 157 | // if there is no default bundle either — matches Get/Sync's existing |
| 158 | // "never configured" convention: sync.go's getIfExists-then-skip path never |
| 159 | // treats an unconfigured app as an error). A present default with no named |
| 160 | // environment, or a present named environment with no default, both resolve |
| 161 | // to just that side — an environment does not require a default to exist. |
| 162 | // |
| 163 | // A NAMED environment that is absent is different: the caller (a route |
| 164 | // bound to it via `carlos route -environment`) asserted it exists, so its |
| 165 | // absence is a defect the operator needs to see, not a silently empty |
| 166 | // overlay. This is what makes the route-binding existence rail meaningful — |
| 167 | // see cli.go's runRoute, which checks existence at BIND time so this error |
| 168 | // is rare in practice (it means the bundle was deleted after binding, not |
| 169 | // that the bind was unchecked). |
| 170 | func LoadMerged(ctx context.Context, s release.Store, acct, app, envName string) (*Manifest, error) { |
| 171 | m, _, _, err := loadMergedETags(ctx, s, acct, app, envName) |
| 172 | return m, err |
| 173 | } |
| 174 | |
| 175 | // loadMergedETags is LoadMerged plus the etag of each object it actually |
| 176 | // merged ("" for one it did not read or found absent), taken from the same |
| 177 | // read that returned the bytes. LoadMergedHostVersions needs them: a later, |
| 178 | // independent read could not say which versions were rendered. |
| 179 | func loadMergedETags(ctx context.Context, s release.Store, acct, app, envName string) (*Manifest, string, string, error) { |
| 180 | if !release.ValidAccount(acct) { |
| 181 | return nil, "", "", fmt.Errorf("config: invalid account %q", acct) |
| 182 | } |
| 183 | if !release.ValidApp(app) { |
| 184 | return nil, "", "", fmt.Errorf("config: invalid app %q", app) |
| 185 | } |
| 186 | // The environment is read beside the default bundle, not after it: two |
| 187 | // independent objects, and a wake's config admission waits on both |
| 188 | // before its lease (wake latency, pass 2). The answers are taken in the |
| 189 | // order the reads used to run, so every error is the one it was. |
| 190 | var ( |
| 191 | env *Manifest |
| 192 | envETag string |
| 193 | envFound bool |
| 194 | envErr error |
| 195 | envRead = envName != "" && ValidEnvironmentName(envName) |
| 196 | wg sync.WaitGroup |
| 197 | ) |
| 198 | if envRead { |
| 199 | wg.Add(1) |
| 200 | go func() { |
| 201 | defer wg.Done() |
| 202 | env, envETag, envFound, envErr = getIfExistsAtETag(ctx, s, EnvironmentKey(acct, app, envName)) |
| 203 | }() |
| 204 | } |
| 205 | def, defETag, defFound, err := getIfExistsAtETag(ctx, s, Key(acct, app)) |
| 206 | wg.Wait() |
| 207 | if err != nil { |
| 208 | return nil, "", "", err |
| 209 | } |
| 210 | if envName == "" { |
| 211 | if !defFound { |
| 212 | return &Manifest{Schema: 1, App: app, Vars: map[string]Var{}}, "", "", nil |
| 213 | } |
| 214 | return def, defETag, "", nil |
| 215 | } |
| 216 | if !envRead { |
| 217 | return nil, "", "", fmt.Errorf("config: invalid environment name %q", envName) |
| 218 | } |
| 219 | if err := envErr; err != nil { |
| 220 | return nil, "", "", err |
| 221 | } |
| 222 | if !envFound { |
| 223 | 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) |
| 224 | } |
| 225 | merged := &Manifest{Schema: 1, App: app, Vars: map[string]Var{}} |
| 226 | if defFound { |
| 227 | for k, v := range def.Vars { |
| 228 | merged.Vars[k] = v |
| 229 | } |
| 230 | } |
| 231 | for k, v := range env.Vars { |
| 232 | merged.Vars[k] = v |
| 233 | } |
| 234 | return merged, defETag, envETag, nil |
| 235 | } |
| 236 | func SaveEnvironment(ctx context.Context, s release.Store, acct, app, envName string, sets map[string]Var, unsets []string) (claimResult0 error) { |
| 237 | claimResult0 = release.WithAppMutation(ctx, s, acct, app, "appconfig.SaveEnvironment", func(claimContext context.Context) error { |
| 238 | claimResult0 = claimRawSaveEnvironment(claimContext, s, acct, app, envName, sets, unsets) |
| 239 | return claimResult0 |
| 240 | }) |
| 241 | return |
| 242 | } |
| 243 | func UnsetEnvironment(ctx context.Context, s release.Store, acct, app, envName string, keys []string) (claimResult0 int, claimResult1 error) { |
| 244 | claimResult1 = release.WithAppMutation(ctx, s, acct, app, "appconfig.UnsetEnvironment", func(claimContext context.Context) error { |
| 245 | claimResult0, claimResult1 = claimRawUnsetEnvironment(claimContext, s, acct, app, envName, keys) |
| 246 | return claimResult1 |
| 247 | }) |
| 248 | return |
| 249 | } |
| 250 | |