carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

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
11package appconfig
12
13import (
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.
28func 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.
37func 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.
46var 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.
53func 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.
64func 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.
88func 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).
99func 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.
109func 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.
131func 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).
170func 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.
179func 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}
236func 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}
243func 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