carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// CAS-guarded reads and writes of a Manifest, on top of release.Store — the
2// same read-modify-write-with-If-Match discipline release.Promote already
3// uses for channel pointers, without that function's signing (config
4// manifests aren't a fleet trust boundary the way a channel pointer is:
5// nothing executes because a manifest says so).
6package appconfig
7
8import (
9 "context"
10 "encoding/json"
11 "errors"
12 "fmt"
13 "os"
14
15 "github.com/carlosframework/platform/internal/release"
16)
17
18// Get fetches app's config manifest, an empty one (not an error) if it has
19// never been configured — a valid state for `list`, which has nothing to
20// print either way.
21func Get(ctx context.Context, s release.Store, acct, app string) (*Manifest, error) {
22 m, found, err := getIfExists(ctx, s, acct, app)
23 if err != nil {
24 return nil, err
25 }
26 if !found {
27 return &Manifest{Schema: 1, App: app, Vars: map[string]Var{}}, nil
28 }
29 return m, nil
30}
31
32// getIfExists is Get plus the one bit Get deliberately folds away: whether a
33// manifest object exists in the bucket at all. An empty manifest is
34// ambiguous on its own — "never configured" and "configured, then fully
35// unset" both read as zero vars — and Sync must tell them apart: the first
36// has to leave a host's hand-staged env file untouched, the second has to
37// converge it to empty. found==false means no object, and the returned
38// manifest is nil.
39func getIfExists(ctx context.Context, s release.Store, acct, app string) (*Manifest, bool, error) {
40 if !release.ValidAccount(acct) {
41 return nil, false, fmt.Errorf("config: invalid account %q", acct)
42 }
43 if !release.ValidApp(app) {
44 return nil, false, fmt.Errorf("config: invalid app %q", app)
45 }
46 return getIfExistsAt(ctx, s, Key(acct, app))
47}
48
49// getIfExistsAt is getIfExists generalized to any manifest key — Key(acct,
50// app) for the default bundle, EnvironmentKey(acct, app, name) for a named
51// one (see environments.go), InstanceConfigKey(acct, app, host) for an
52// instance overlay (instances.go). Same shape either way: all three are the
53// identical manifest format at sibling bucket paths, so one read path
54// serves them.
55func getIfExistsAt(ctx context.Context, s release.Store, key string) (*Manifest, bool, error) {
56 m, _, found, err := getIfExistsAtETag(ctx, s, key)
57 return m, found, err
58}
59
60// getIfExistsAtETag is getIfExistsAt plus the object's etag, which the
61// instance layer's reader needs and the other two bundles' readers do not:
62// the delivery ack names the etag of the overlay the agent rendered
63// (senders design §6.5), and an etag dropped here could not be recovered
64// later without a second read racing the first.
65func getIfExistsAtETag(ctx context.Context, s release.Store, key string) (*Manifest, string, bool, error) {
66 raw, etag, err := s.Get(ctx, key)
67 if err != nil {
68 if errors.Is(err, os.ErrNotExist) {
69 return nil, "", false, nil
70 }
71 return nil, "", false, err
72 }
73 var m Manifest
74 if err := json.Unmarshal(raw, &m); err != nil {
75 return nil, "", false, fmt.Errorf("config %s: %w", key, err)
76 }
77 // json.Unmarshal has already dropped any fields this binary doesn't
78 // know about, so acting on a newer-schema manifest would silently
79 // discard whatever those fields meant. Refuse instead.
80 if m.Schema > 1 {
81 return nil, "", false, fmt.Errorf("config %s: schema %d is newer than this binary understands — upgrade carlos", key, m.Schema)
82 }
83 return &m, etag, true, nil
84}
85
86// getForWriteAt is Get plus the two bits a caller needs to write back: the
87// etag (a not-found manifest reads as empty with etag "", a create-only Put)
88// and whether the object exists at all. found is NOT inferable from the etag
89// — a store is free to hand back an empty etag for an object that exists —
90// and applyAt's refusal to create a bundle on an unset-only call turns on it.
91// Generalized to any manifest key; see getIfExistsAt's doc comment for why
92// one path serves both the default bundle and a named environment.
93func getForWriteAt(ctx context.Context, s release.Store, key, app string) (Manifest, string, bool, error) {
94 raw, etag, err := s.Get(ctx, key)
95 switch {
96 case err == nil:
97 var m Manifest
98 if uerr := json.Unmarshal(raw, &m); uerr != nil {
99 return Manifest{}, "", false, fmt.Errorf("config %s: %w", key, uerr)
100 }
101 // Same newer-schema refusal as getIfExists — writing back through
102 // this path would downgrade the object to schema 1 minus the fields
103 // unmarshal already dropped.
104 if m.Schema > 1 {
105 return Manifest{}, "", false, fmt.Errorf("config %s: schema %d is newer than this binary understands — upgrade carlos", key, m.Schema)
106 }
107 return m, etag, true, nil
108 case errors.Is(err, os.ErrNotExist):
109 return Manifest{Schema: 1, App: app, Vars: map[string]Var{}}, "", false, nil
110 default:
111 return Manifest{}, "", false, err
112 }
113}
114
115// apply reads, applies sets and unsets, and writes back via CAS, reporting
116// how many of unsets were actually present. A racing concurrent write
117// surfaces as release.ErrCASConflict — callers report it and let the operator
118// re-run; there is no automatic retry, matching release.Promote's own
119// behavior.
120//
121// On success it also touches the app's generation object (generation.go), so
122// the change is visible to the agent's fast loop within seconds rather than
123// at the next hourly sweep. Best-effort by construction: the manifest write
124// has already landed by then, so a failed touch is logged, never returned
125// (see touchLogged).
126func apply(ctx context.Context, s release.Store, acct, app, bundle string, sets map[string]Var, unsets []string) (int, error) {
127 if !release.ValidAccount(acct) {
128 return 0, fmt.Errorf("config: invalid account %q", acct)
129 }
130 if !release.ValidApp(app) {
131 return 0, fmt.Errorf("config: invalid app %q", app)
132 }
133 removed, _, err := applyAt(ctx, s, Key(acct, app), app, bundle, sets, unsets)
134 if err != nil {
135 return 0, err
136 }
137 touchLogged(ctx, s, acct, app)
138 return removed, nil
139}
140
141// applyAt is apply generalized to any manifest key — see getIfExistsAt's doc
142// comment for why one path serves both the default bundle and a named
143// environment. Callers validate acct/app/environment-name themselves before
144// reaching here, same division as apply does for Key(acct, app). bundle is
145// how to name this object to a human ("default", or the environment name);
146// it never affects which key is written.
147//
148// An unset-only call against an object that does not exist is REFUSED, not
149// silently turned into a create (2026-08-02 review, CRITICAL 2). Creating it
150// would mint an empty manifest, and an empty manifest that EXISTS means
151// "configured, then fully unset" by this package's own contract — which is
152// exactly the state Sync converges every one of that app's route env files
153// to empty for, as root, restarting each unit. It also minted phantom
154// environment bundles that then satisfied `carlos route -environment`'s
155// existence rail. Sets still create, which is how a bundle is meant to come
156// into being.
157func applyAt(ctx context.Context, s release.Store, key, app, bundle string, sets map[string]Var, unsets []string) (int, string, error) {
158 for k, v := range sets {
159 if !ValidVarName(k) {
160 return 0, "", fmt.Errorf("config: invalid var name %q", k)
161 }
162 // Env values are validated here, while the operator can still see
163 // the plaintext — a bad value that only failed at render time would
164 // stall the whole app's config convergence on every host, hours
165 // later and out of sight. Secret values get the same check in the
166 // CLI, before sealing; by this point they're opaque envelopes.
167 if v.Kind == "env" {
168 if err := ValidVarValue(v.Value); err != nil {
169 return 0, "", fmt.Errorf("config: var %s: %w", k, err)
170 }
171 }
172 }
173 m, etag, found, err := getForWriteAt(ctx, s, key, app)
174 if err != nil {
175 return 0, "", err
176 }
177 if !found && len(sets) == 0 {
178 return 0, "", fmt.Errorf("config: %s has no %s bundle — nothing to unset", app, bundle)
179 }
180 if m.Vars == nil {
181 m.Vars = map[string]Var{}
182 }
183 removed := 0
184 for _, k := range unsets {
185 if _, ok := m.Vars[k]; ok {
186 removed++
187 }
188 delete(m.Vars, k)
189 }
190 for k, v := range sets {
191 m.Vars[k] = v
192 }
193 m.Schema, m.App = 1, app
194 raw, err := json.MarshalIndent(m, "", " ")
195 if err != nil {
196 return 0, "", err
197 }
198 // The new etag is returned because the instance layer's writer (design
199 // §6.5) has to hand it to whoever will later compare it with an agent's
200 // delivery ack. The other two bundles' callers ignore it.
201 newETag, err := s.Put(ctx, key, raw, etag)
202 if err != nil {
203 return 0, "", err
204 }
205 return removed, newETag, nil
206}
207
208// DefaultBundle is how the default bundle (config.json) is named to a human
209// in messages — the same vocabulary ValidEnvironmentName reserves and the CLI
210// prints, kept in one place so an error from this package and a `list` header
211// can never drift apart.
212const DefaultBundle = "default"
213
214// SetEnv writes one or more plaintext config vars.
215func claimRawSetEnv(ctx context.Context, s release.Store, acct, app string, kv map[string]string) error {
216 sets := make(map[string]Var, len(kv))
217 for k, v := range kv {
218 sets[k] = Var{Kind: "env", Value: v}
219 }
220 _, err := apply(ctx, s, acct, app, DefaultBundle, sets, nil)
221 return err
222}
223
224// SetSecret writes one or more secrets. sealed values must already be
225// Seal-produced envelopes — this function stores them opaquely and never
226// sees plaintext.
227func claimRawSetSecret(ctx context.Context, s release.Store, acct, app string, sealed map[string]string) error {
228 sets := make(map[string]Var, len(sealed))
229 for k, v := range sealed {
230 sets[k] = Var{Kind: "secret", Envelope: v}
231 }
232 _, err := apply(ctx, s, acct, app, DefaultBundle, sets, nil)
233 return err
234}
235
236// Unset removes vars (env or secret) by key from the default bundle,
237// returning how many of keys were actually there. Unsetting a key that isn't
238// set is a no-op success, matching heroku config:unset's idempotence —
239// unsetting from a bundle that does not EXIST is not (see applyAt).
240func claimRawUnset(ctx context.Context, s release.Store, acct, app string, keys []string) (int, error) {
241 return apply(ctx, s, acct, app, DefaultBundle, nil, keys)
242}
243
244// SetExecDelivery writes (`file`) or clears (`""`) the DEFAULT bundle's
245// exec_delivery field — issue #18's TCP-addr exec route opt-in (see
246// Manifest.ExecDelivery). The app-level door, unchanged in behaviour and
247// signature; SetExecDeliveryAt is the general one.
248func claimRawSetExecDelivery(ctx context.Context, s release.Store, acct, app, value string) error {
249 return SetExecDeliveryAt(ctx, s, acct, app, "", value)
250}
251
252// SetExecDeliveryAt writes or clears exec_delivery in ONE bundle: the
253// default bundle (envName == "") or a named environment's, via the same
254// CAS read-modify-write path SetEnv/SetSecret/Unset use. value must be
255// ValidExecDelivery; anything else is refused here, before it ever reaches
256// the bucket, mirroring applyAt's var-name/var-value validation.
257//
258// Environment scope is issue #195 (ruled 2026-08-16). The app-level flag
259// answered "does this APP get files", so opting one relay route in wrote a
260// file for every router route of the same app too — a fleet-wide switch
261// thrown to deliver one service's credential. The flag now lives wherever
262// the route's config comes from, and clearing it in a named bundle means
263// INHERIT the app default, never "off": a bundle that says nothing has not
264// asked for delivery to stop. Route scope is still refused — see
265// Manifest.ExecDelivery.
266//
267// Turning the field off against a bundle that does not exist is refused,
268// not silently turned into a create — the same "unset-only against a
269// nonexistent object" refusal applyAt documents, for the same reason:
270// minting an empty bundle just to hold an absent flag would satisfy
271// `carlos route -environment`'s existence rail and Sync's "configured,
272// then fully unset" state for nothing the operator asked for.
273func claimRawSetExecDeliveryAt(ctx context.Context, s release.Store, acct, app, envName, value string) error {
274 if !release.ValidAccount(acct) {
275 return fmt.Errorf("config: invalid account %q", acct)
276 }
277 if !release.ValidApp(app) {
278 return fmt.Errorf("config: invalid app %q", app)
279 }
280 if !ValidExecDelivery(value) {
281 return fmt.Errorf("config: invalid exec_delivery %q — want %q or \"\" (off/inherit)", value, ExecDeliveryFile)
282 }
283 key, label := Key(acct, app), "default"
284 if envName != "" {
285 if !ValidEnvironmentName(envName) {
286 return fmt.Errorf("config: invalid environment name %q", envName)
287 }
288 key, label = EnvironmentKey(acct, app, envName), envName
289 }
290 m, etag, found, err := getForWriteAt(ctx, s, key, app)
291 if err != nil {
292 return err
293 }
294 if !found && value == "" {
295 return fmt.Errorf("config: %s has no %s bundle — nothing to turn off", app, label)
296 }
297 m.ExecDelivery = value
298 m.Schema, m.App = 1, app
299 raw, err := json.MarshalIndent(m, "", " ")
300 if err != nil {
301 return err
302 }
303 if _, err := s.Put(ctx, key, raw, etag); err != nil {
304 return err
305 }
306 // The opt-in flag changes what SyncExec delivers for this app's routes,
307 // so it is a config change like any other and earns the same fast
308 // convergence — see apply's comment for why the touch cannot fail this
309 // call. The generation object is per-APP, not per-bundle
310 // (generation.go), so an environment-scoped write signals through the
311 // same key an app-level one does — which is why the fast loop needs no
312 // new bundle awareness for this half.
313 touchLogged(ctx, s, acct, app)
314 return nil
315}
316func SetEnv(ctx context.Context, s release.Store, acct, app string, kv map[string]string) (claimResult0 error) {
317 claimResult0 = release.WithAppMutation(ctx, s, acct, app, "appconfig.SetEnv", func(claimContext context.Context) error {
318 claimResult0 = claimRawSetEnv(claimContext, s, acct, app, kv)
319 return claimResult0
320 })
321 return
322}
323func SetExecDelivery(ctx context.Context, s release.Store, acct, app, value string) (claimResult0 error) {
324 claimResult0 = release.WithAppMutation(ctx, s, acct, app, "appconfig.SetExecDelivery", func(claimContext context.Context) error {
325 claimResult0 = claimRawSetExecDelivery(claimContext, s, acct, app, value)
326 return claimResult0
327 })
328 return
329}
330func SetExecDeliveryAt(ctx context.Context, s release.Store, acct, app, envName, value string) (claimResult0 error) {
331 claimResult0 = release.WithAppMutation(ctx, s, acct, app, "appconfig.SetExecDeliveryAt", func(claimContext context.Context) error {
332 claimResult0 = claimRawSetExecDeliveryAt(claimContext, s, acct, app, envName, value)
333 return claimResult0
334 })
335 return
336}
337func SetSecret(ctx context.Context, s release.Store, acct, app string, sealed map[string]string) (claimResult0 error) {
338 claimResult0 = release.WithAppMutation(ctx, s, acct, app, "appconfig.SetSecret", func(claimContext context.Context) error {
339 claimResult0 = claimRawSetSecret(claimContext, s, acct, app, sealed)
340 return claimResult0
341 })
342 return
343}
344func Unset(ctx context.Context, s release.Store, acct, app string, keys []string) (claimResult0 int, claimResult1 error) {
345 claimResult1 = release.WithAppMutation(ctx, s, acct, app, "appconfig.Unset", func(claimContext context.Context) error {
346 claimResult0, claimResult1 = claimRawUnset(claimContext, s, acct, app, keys)
347 return claimResult1
348 })
349 return
350}
351