carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// The CLI's config API: `carlos env` and `carlos secrets` reading and
2// writing an app's config bundles THROUGH the console, so "give Vicky deploy
3// access" stops meaning "give Vicky the deployment bucket's credentials the
4// moment her app needs a secret" (issue #43). Three doors, all behind the
5// same apiAccountApp gate as ship/promote — an unknown account, an unknown
6// app and someone else's account are one byte-identical 404.
7//
8// WHAT THE CONSOLE LEARNS BY RELAYING THIS: nothing it could not already
9// see. Plain env vars it stores and serves in the clear, exactly as the
10// bucket does. SECRETS arrive already sealed — `carlos secrets set` seals
11// against CARLOS_SECRETS_PUBKEY on the operator's own machine — so what
12// crosses this door is an opaque envelope, strictly less sensitive than the
13// release artifacts the ship door already accepts. Only a box holding
14// CARLOS_SECRETS_KEY can open one, and the console holds no such key by
15// design. That is why a sealed envelope may pass through here at all, and
16// why an envelope must never come back OUT: the GET door has no field for
17// one (see handleAPIConfigGet), and a "secret" var carrying a plaintext
18// `value` is refused rather than quietly stripped.
19//
20// There is NO signing-key check on any door here, unlike promote/rollback:
21// config manifests aren't signed. Nothing in the fleet executes because a
22// manifest says so (store.go's package comment), so a read-only console —
23// one running without CARLOS_RELEASE_KEY — still serves config in full.
24//
25// Spec: docs/superpowers/specs/2026-08-04-config-console-api-design.md §2.
26package console
27
28import (
29 "context"
30 "errors"
31 "fmt"
32 "log"
33 "net/http"
34 "sort"
35 "strings"
36 "time"
37
38 "github.com/carlosframework/platform/internal/appconfig"
39 "github.com/carlosframework/platform/internal/release"
40)
41
42// configStoreTimeout bounds every store round trip these handlers make — the
43// same 15s its siblings in apicli.go and apiledger.go use, for the same
44// reason: a wedged bucket must cost one request 15 seconds, not a console
45// goroutine forever. Each handler derives its context from the request's, so
46// a client that hangs up still cancels immediately; this only adds a ceiling.
47const configStoreTimeout = 15 * time.Second
48
49// configPutMaxBytes caps the PUT door's body — its own cap, not readJSON's
50// 8KiB, because sealed envelopes make config writes legitimately larger than
51// every other CLI JSON body: an envelope runs ~4/3 the size of the plaintext
52// it seals, so one long base64 credential can pass 8KiB alone, and a bulk
53// import of a few dozen is routine. 256KiB keeps three orders of magnitude
54// of headroom over that while staying nothing like an upload — the ship door
55// (256MiB) is where real bytes belong.
56const configPutMaxBytes = 256 << 10
57
58// configWriteAllowed is the write budget both MUTATING doors pass, via
59// allowWrite (svcprincipal.go), the same picker every other write door here
60// uses.
61//
62// A person is keyed on the ADDRESS, not the token: the principal a write
63// budget belongs to is the PERSON, not the terminal they happen to be
64// typing on. Keying by token instead would give someone with several live
65// tokens an independent budget per token, which is the limit doing less
66// than it should, not more. A service is keyed on its NAME, its own budget
67// separate from the operator who minted it (issue #220).
68//
69// The GET door is deliberately unlimited, matching its read-only siblings:
70// an exhausted write budget must not blind a CLI to what it already wrote.
71func (s *Server) configWriteAllowed(w http.ResponseWriter, p Principal) bool {
72 if !s.allowWrite(p, time.Now()) {
73 jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
74 return false
75 }
76 return true
77}
78
79// configBundleName is how a bundle is named back to a human — the same
80// vocabulary appconfig.DefaultBundle and the CLI's own `list` header use, so
81// an audit row, an error and a response can never disagree about what "" is.
82func configBundleName(envName string) string {
83 if envName == "" {
84 return appconfig.DefaultBundle
85 }
86 return envName
87}
88
89// validAPIEnvironment refuses a bad environment name — from the GET door's
90// query parameter or the PUT door's body — before it can reach the store,
91// answering 400 itself and reporting whether the caller may proceed.
92//
93// "" is not a refusal: it is how both doors say "the default bundle". But
94// "default" IS refused, and with its own sentence, because it already NAMES
95// that bundle (config.json) — accepting it would either error confusingly
96// deep in appconfig or, worse, read and write a second bundle that merely
97// looks like the default one. Same rule the CLI enforces client-side
98// (checkEnvironmentFlag, appconfig_cli.go); enforced here too, because a
99// door must not depend on its client's manners.
100//
101// The name is NOT echoed back. There is exactly one of them per request, so
102// naming it buys a caller nothing they don't already know, and the shape
103// rule is the actionable half.
104func validAPIEnvironment(w http.ResponseWriter, name string) bool {
105 if name == "" {
106 return true
107 }
108 if name == appconfig.DefaultBundle {
109 jsonError(w, http.StatusBadRequest,
110 `"default" already names the base bundle (config.json) that every route gets when no `+
111 `environment is bound — omit the environment to work on it directly, don't name it`)
112 return false
113 }
114 if !appconfig.ValidEnvironmentName(name) {
115 jsonError(w, http.StatusBadRequest, "invalid environment name — want [a-z0-9-]{1,32}")
116 return false
117 }
118 return true
119}
120
121// configNothingToRemove reports whether err is appconfig's refusal to create
122// a bundle for a call that only REMOVES things — `nothing to unset` from
123// applyAt, or `nothing to turn off` from SetExecDelivery. A permanent
124// conflict a retry can never fix, so it earns a 409 rather than the
125// retry-flavored 502 every genuine store failure gets.
126//
127// A STRING match, like shipImmutableConflict's (apicli.go), and fragile in
128// exactly the same way: appconfig exposes no sentinel or typed error for
129// this case, so the literal wording of those two fmt.Errorf calls is the
130// only signal available (errors.Is/As have nothing to grab onto). If either
131// wording changes in appconfig, this silently stops firing and the refusal
132// falls back to the 502 below — worth revisiting if appconfig ever grows a
133// proper sentinel for it.
134//
135// No other error from these paths can match: the store's own failures are
136// IO/HTTP text, and appconfig's other refusals ("invalid account", "invalid
137// var name", the schema check) share no wording with either phrase.
138func configNothingToRemove(err error) bool {
139 msg := err.Error()
140 return strings.Contains(msg, "nothing to unset") || strings.Contains(msg, "nothing to turn off")
141}
142
143// configStoreError answers a config write that failed, mapping the three
144// outcomes a caller can act on differently and describing none of them in
145// bucket terms — the store's own error text (bucket names, key paths, AWS
146// request ids) goes to the log, where the operator who can act on it will
147// see it, and never to the client.
148func (s *Server) configStoreError(w http.ResponseWriter, what string, err error) {
149 log.Printf("console: %s: %v", what, err)
150 if errors.Is(err, release.ErrCASConflict) {
151 // Someone else wrote this bundle between our read and our write.
152 // Retry-worthy, and the operator has to re-read first — appconfig
153 // deliberately has no automatic retry (see apply's doc comment).
154 jsonError(w, http.StatusConflict, "config changed concurrently — try again")
155 return
156 }
157 if configNothingToRemove(err) {
158 // appconfig's own sentence, verbatim: it names the app and the
159 // bundle, which is exactly what the operator needs and carries
160 // nothing they didn't send.
161 jsonError(w, http.StatusConflict, err.Error())
162 return
163 }
164 jsonError(w, http.StatusBadGateway, "could not store the config — try again")
165}
166
167// handleAPIConfigGet answers `carlos env list` / `carlos secrets list` /
168// `carlos env environments`: one bundle's vars, the app's environment names,
169// and (when this console serves one) the public key a client seals with.
170//
171// A SECRET'S ENVELOPE IS STRUCTURALLY ABSENT from the answer, not blanked:
172// the row for a secret carries only `key` and `kind`, so there is no field
173// for ciphertext to occupy even if a future edit forgot why. Only "env"
174// vars get a `value` — fail-closed, so a var whose kind is neither (a
175// hand-edited manifest, a future kind this binary predates) reveals nothing.
176//
177// An app that has never been configured answers as an empty bundle rather
178// than a 404 — appconfig.Get's own "never configured is not an error"
179// convention, which `list` needs since it has nothing to print either way.
180// The same is true of a NAMED bundle: appconfig.LoadEnvironment returns an
181// empty manifest and a nil error when the object is absent (environments.go),
182// so there is no not-found case to translate here — a bundle you haven't
183// created yet lists empty.
184func (s *Server) handleAPIConfigGet(w http.ResponseWriter, r *http.Request) {
185 _, acct, app, ok := s.apiAccountApp(w, r)
186 if !ok {
187 return
188 }
189 envName := strings.TrimSpace(r.URL.Query().Get("environment"))
190 if !validAPIEnvironment(w, envName) {
191 return
192 }
193 ctx, cancel := context.WithTimeout(r.Context(), configStoreTimeout)
194 defer cancel()
195 var m *appconfig.Manifest
196 var err error
197 if envName == "" {
198 m, err = appconfig.Get(ctx, s.cfg.Releases, acct.Sqid, app)
199 } else {
200 m, err = appconfig.LoadEnvironment(ctx, s.cfg.Releases, acct.Sqid, app, envName)
201 }
202 if err != nil {
203 log.Printf("console: api config get %s/%s (%s): %v", acct.Sqid, app, configBundleName(envName), err)
204 jsonError(w, http.StatusBadGateway, "could not read the config — try again")
205 return
206 }
207 // Always present, possibly empty: it powers `carlos env environments`
208 // without a fourth door, and a client should read the key rather than
209 // test for its absence.
210 envs, err := appconfig.ListEnvironments(ctx, s.cfg.Releases, acct.Sqid, app)
211 if err != nil {
212 log.Printf("console: api config environments %s/%s: %v", acct.Sqid, app, err)
213 jsonError(w, http.StatusBadGateway, "could not read the config — try again")
214 return
215 }
216 if envs == nil {
217 envs = []string{}
218 }
219 keys := make([]string, 0, len(m.Vars))
220 for k := range m.Vars {
221 keys = append(keys, k)
222 }
223 sort.Strings(keys)
224 vars := make([]map[string]any, 0, len(keys))
225 for _, k := range keys {
226 v := m.Vars[k]
227 row := map[string]any{"key": k, "kind": v.Kind}
228 if v.Kind == "env" {
229 row["value"] = v.Value
230 }
231 vars = append(vars, row)
232 }
233 out := map[string]any{
234 "bundle": configBundleName(envName),
235 "environments": envs,
236 "vars": vars,
237 }
238 // exec_delivery is emitted for whichever bundle was asked for: since
239 // issue #195 it can be set in a named one, where it means "the TCP-addr
240 // exec routes bound to this environment get files". Omitted when the
241 // bundle is silent on it, rather than reported as the value it
242 // INHERITS — the answer describes this bundle, and an inherited value
243 // would read as a statement the bundle does not make.
244 if m.ExecDelivery != "" {
245 out["exec_delivery"] = m.ExecDelivery
246 }
247 // Public material by design — it only encrypts. Serving it is what makes
248 // `carlos auth login` sufficient for `carlos secrets set`; omitted when
249 // this deployment configured none, so the CLI can tell "no key here"
250 // from "empty key" and fall back to its own env var.
251 if s.cfg.SecretsPub != "" {
252 out["secrets_pubkey"] = s.cfg.SecretsPub
253 }
254 // The account a secret for this app is bound to (appconfig.Binding), as
255 // the sqid this console resolved the request to — the CLI may only know
256 // the account by name. Present ONLY while this deployment writes bound
257 // (v2) envelopes (CONSOLE_SECRETS_ENVELOPE_V2): its presence is how the
258 // CLI learns to seal v2, and absent — this console with the switch off,
259 // or an older console — it seals v1, which every box can open
260 // (discussion #220).
261 if appconfig.BoundSealing() {
262 out["secrets_account"] = acct.Sqid
263 }
264 writeJSON(w, http.StatusOK, out)
265}
266
267// handleAPIConfigPut answers `carlos env set/unset`, `carlos secrets
268// set/unset` and `carlos env exec-delivery`: one bundle, one request, one CAS
269// write for the set/unset half (appconfig.Apply) — two would mean a
270// half-applied request whenever the second lost a race.
271//
272// EVERY REFUSAL RUNS BEFORE THE FIRST STORE CALL. That is not tidiness: a
273// request carrying one good var and one malformed one must leave the bundle
274// exactly as it found it, or an operator retrying a corrected request would
275// be reasoning about a bundle that already half-moved. (The invariant is
276// about REFUSALS, not store failures: a request carrying both a set/unset
277// half and exec_delivery is two CAS writes — see the comment at the second
278// one — so a store failure between them can land the first half alone. The
279// CLI never sends both halves in one request.) It also means a
280// garbage envelope is caught HERE, while the person who pasted it is still
281// looking at their terminal — accepted now, it would stall the app's whole
282// config at render time, hours later, on every host, out of sight
283// (appconfig.CheckEnvelope's doc comment).
284//
285// NOTHING IN AN ERROR, A LOG LINE OR AN AUDIT ROW CARRIES CONTENT. Var NAMES
286// do appear in 400s — a caller who sent five vars has to learn which one was
287// refused, and the names are not secret (the secrets list shows them). Values
288// and envelopes never do.
289func (s *Server) handleAPIConfigPut(w http.ResponseWriter, r *http.Request) {
290 addr, acct, app, ok := s.apiAccountApp(w, r)
291 if !ok {
292 return
293 }
294 p, _ := principalFrom(r.Context())
295 if !s.configWriteAllowed(w, p) {
296 return
297 }
298 // No s.cfg.Key check, unlike promote/rollback: config manifests are not
299 // signed, so a console with no signing key writes them just as well.
300 var req struct {
301 Environment string `json:"environment"`
302 Message string `json:"message"`
303 Set map[string]struct {
304 Kind string `json:"kind"`
305 Value string `json:"value"`
306 Envelope string `json:"envelope"`
307 } `json:"set"`
308 Unset []string `json:"unset"`
309 // A POINTER, so an absent field ("leave it alone") is
310 // distinguishable from a present empty one. The wire vocabulary is
311 // "file"/"off" rather than "file"/"": a client that forgot to set
312 // the field would otherwise be indistinguishable from one asking to
313 // turn the flag OFF.
314 ExecDelivery *string `json:"exec_delivery"`
315 }
316 if !readJSONLimit(w, r, &req, configPutMaxBytes) {
317 return
318 }
319 message, ok := checkedActivityMessage(w, req.Message)
320 if !ok {
321 return
322 }
323 if len(req.Set) == 0 && len(req.Unset) == 0 && req.ExecDelivery == nil {
324 jsonError(w, http.StatusBadRequest, "nothing to do — send set, unset or exec_delivery")
325 return
326 }
327 envName := strings.TrimSpace(req.Environment)
328 if !validAPIEnvironment(w, envName) {
329 return
330 }
331 // Sorted, so a request with several defects always refuses on the same
332 // one — a map's own iteration order would make the error a caller sees
333 // depend on nothing they can reason about.
334 keys := make([]string, 0, len(req.Set))
335 for k := range req.Set {
336 keys = append(keys, k)
337 }
338 sort.Strings(keys)
339 sets := make(map[string]appconfig.Var, len(req.Set))
340 for _, k := range keys {
341 v := req.Set[k]
342 if !appconfig.ValidVarName(k) {
343 jsonError(w, http.StatusBadRequest,
344 fmt.Sprintf("invalid var name %q — want a letter or underscore, then letters, digits or underscores", k))
345 return
346 }
347 switch v.Kind {
348 case "env":
349 // The same check applyAt makes, made earlier: here the operator
350 // can still see the plaintext and fix it.
351 if err := appconfig.ValidVarValue(v.Value); err != nil {
352 jsonError(w, http.StatusBadRequest, fmt.Sprintf("var %s: %v", k, err))
353 return
354 }
355 if v.Envelope != "" {
356 jsonError(w, http.StatusBadRequest,
357 fmt.Sprintf(`var %s is kind "env" but carries an envelope — send it as kind "secret", or drop the envelope`, k))
358 return
359 }
360 // Built field by field, never copied wholesale from the wire
361 // shape: an "env" var cannot smuggle an envelope into the bucket
362 // even if the check above were ever loosened.
363 sets[k] = appconfig.Var{Kind: "env", Value: v.Value}
364 case "secret":
365 if err := appconfig.CheckEnvelope(v.Envelope); err != nil {
366 // CheckEnvelope's errors name the defect and never echo the
367 // envelope — deliberately, because they end up here.
368 jsonError(w, http.StatusBadRequest, fmt.Sprintf("var %s: %v", k, err))
369 return
370 }
371 if v.Value != "" {
372 // Refused, not silently dropped: a caller who sent plaintext
373 // to a door that stores ciphertext has to LEARN that their
374 // secret crossed the wire in the clear, so they can rotate
375 // it. The refusal never echoes the value it is refusing.
376 jsonError(w, http.StatusBadRequest,
377 fmt.Sprintf(`var %s is kind "secret" but carries a plaintext value — seal it client-side and send only the envelope`, k))
378 return
379 }
380 sets[k] = appconfig.Var{Kind: "secret", Envelope: v.Envelope}
381 default:
382 jsonError(w, http.StatusBadRequest,
383 fmt.Sprintf(`var %s: invalid kind %q — want "env" or "secret"`, k, v.Kind))
384 return
385 }
386 }
387 // Unset keys are deliberately NOT name-checked: a name that could never
388 // be SET can never be present to remove, so refusing one would only turn
389 // a harmless no-op into an error. Same division appconfig.applyAt makes.
390 execDelivery := ""
391 if req.ExecDelivery != nil {
392 // The wire's clearing word is the SCOPE's own, and the two are not
393 // interchangeable (issue #195): clearing the default bundle's flag
394 // stops delivery, while clearing a named bundle's hands the
395 // question back to the default. Accepting "off" for a named bundle
396 // would let a caller ask to stop delivery and get the opposite
397 // whenever the app default is on. Both refusals are worded here,
398 // once, so the CLI and any other client read the same sentence
399 // rather than a paraphrase of it.
400 switch *req.ExecDelivery {
401 case appconfig.ExecDeliveryFile:
402 execDelivery = appconfig.ExecDeliveryFile
403 case "off":
404 if envName != "" {
405 jsonError(w, http.StatusBadRequest,
406 "exec_delivery \"off\" is app-level — in a named environment, clearing the flag means \"inherit\" "+
407 "(routes bound to it fall back to the app's default bundle), which is not the same as turning "+
408 "delivery off. Send \"inherit\" for this bundle, or send \"off\" with no environment to turn "+
409 "the app's default off.")
410 return
411 }
412 // The wire's "off" is appconfig's "" — the vocabulary changes at
413 // this boundary and nowhere else.
414 execDelivery = ""
415 case "inherit":
416 if envName == "" {
417 jsonError(w, http.StatusBadRequest,
418 "exec_delivery \"inherit\" needs an environment — the default bundle has nothing above it to "+
419 "inherit from. Send \"off\" to clear it.")
420 return
421 }
422 execDelivery = ""
423 default:
424 want := `"off"`
425 if envName != "" {
426 want = `"inherit"`
427 }
428 jsonError(w, http.StatusBadRequest,
429 fmt.Sprintf("invalid exec_delivery %q — want %q or %s", *req.ExecDelivery, appconfig.ExecDeliveryFile, want))
430 return
431 }
432 }
433
434 ctx, cancel := context.WithTimeout(r.Context(), configStoreTimeout)
435 defer cancel()
436 bundle := configBundleName(envName)
437 removed := 0
438 // Skipped entirely for an exec_delivery-only request: Apply with neither
439 // sets nor unsets against an app with no bundle is appconfig's
440 // unset-only refusal (applyAt), and asking to set a flag is not asking
441 // to remove anything.
442 if len(sets) > 0 || len(req.Unset) > 0 {
443 var err error
444 if envName == "" {
445 removed, err = appconfig.Apply(ctx, s.cfg.Releases, acct.Sqid, app, sets, req.Unset)
446 } else {
447 removed, err = appconfig.ApplyEnvironment(ctx, s.cfg.Releases, acct.Sqid, app, envName, sets, req.Unset)
448 }
449 if err != nil {
450 s.configStoreError(w, fmt.Sprintf("api config apply %s/%s (%s)", acct.Sqid, app, bundle), err)
451 return
452 }
453 // Counts and the bundle, never keys and never values — an audit trail
454 // that recorded a secret's envelope would be a copy of the secret
455 // store in a database with different reach. The unset count is what
456 // was ACTUALLY removed, matching the wire's own `removed` (2026-08-02
457 // review, MINOR 9): a row claiming three removals when nothing was
458 // there would mislead exactly the person reading rows to find out
459 // what happened.
460 s.auditActivity(acct, app, addr, Activity{
461 Action: "config", TargetType: "environment", Target: bundle, Message: message,
462 Detail: fmt.Sprintf("config: %d set, %d unset (%s) (cli)", len(sets), removed, bundle),
463 })
464 // Box-consumed (carlos-deploy §3): the fast loop's config-sync leg
465 // renders this bundle onto every route that references it. Announced
466 // per successful write rather than once at the end, for the same
467 // reason the audit row is: the two writes below are separate CAS
468 // operations, and if the second fails the first has still landed and
469 // must still reach the boxes.
470 s.bumpSerial(ctx)
471 }
472 if req.ExecDelivery != nil {
473 // A SECOND CAS write, and deliberately not folded into the one
474 // above: it writes one FIELD of the bundle through a different
475 // setter, where the block above writes vars. The two cannot be one
476 // transaction, so the audit row for the set/unset half is written
477 // above, before this runs — if this fails, the trail still says
478 // truthfully what landed.
479 if err := appconfig.SetExecDeliveryAt(ctx, s.cfg.Releases, acct.Sqid, app, envName, execDelivery); err != nil {
480 s.configStoreError(w, fmt.Sprintf("api config exec-delivery %s/%s (%s)", acct.Sqid, app, bundle), err)
481 return
482 }
483 // The caller's own word, not one reconstructed from the stored
484 // value: "off" and "inherit" both store "", and a trail that
485 // recorded them identically would lose which scope was acted on.
486 s.audit(acct, app, addr, "exec-delivery", "exec-delivery "+*req.ExecDelivery+" ("+bundle+") (cli)")
487 s.bumpSerial(ctx)
488 }
489 out := map[string]any{"result": "ok", "removed": removed}
490 // Whether this write landed on a process that will never read it
491 // (discussion #16). Computed AFTER both writes, because it describes
492 // what they left behind, and only when something actually changed: an
493 // unset of keys that were not there removed nothing and moved no file,
494 // so there is nothing for anyone to restart over.
495 if len(sets) > 0 || removed > 0 || req.ExecDelivery != nil {
496 out["restart"] = s.configRestartNotice(ctx, acct.Sqid, app, envName, writtenKeys(sets, req.Unset))
497 }
498 writeJSON(w, http.StatusOK, out)
499}
500
501// apiConfigRestart is the config door's answer to "did that reach
502// anything?" — the wire half of appconfig.AwaitingRestart.
503//
504// Known is the field that carries the whole point. A console with no
505// registry, or one whose registry will not open, has not looked and must
506// not answer with an empty list: "nothing needs restarting" and "I could
507// not tell" rendering identically is the failure discussion #16 reported,
508// and answering it with a second instance of itself would be worse than
509// saying nothing. The CLI renders the two differently, and a client that
510// predates the field sees no `restart` key at all — see apimode.go, where
511// the field is a pointer for exactly that third state.
512type apiConfigRestart struct {
513 Known bool `json:"known"`
514 // Hosts got a file that nothing will bounce. Unit, when set, is the
515 // systemd unit that owns the process — the difference between an
516 // operator who can be pointed at a command and one who has to reach
517 // for their own supervisor.
518 Hosts []apiConfigRestartHost `json:"hosts,omitempty"`
519 // Unresolved got neither a yes nor a no: their bundle would not read,
520 // or the bundle they are bound to is missing.
521 Unresolved []string `json:"unresolved,omitempty"`
522}
523
524type apiConfigRestartHost struct {
525 Host string `json:"host"`
526 Unit string `json:"unit,omitempty"`
527}
528
529// configRestartNotice resolves the notice for one config write, or reports
530// that it could not.
531//
532// SCOPE: the console reads its OWN box's registry, so in a multi-box fleet
533// the other boxes' routes are not represented here — the same blind spot
534// handleAPIRoutes documents at the top of apiroutes.go, and internal/
535// fleetapi (#145) is the eventual fix. The CLI says what it looked at
536// rather than letting a short list read as a complete one.
537func (s *Server) configRestartNotice(ctx context.Context, acct, app, envName string, keys []string) apiConfigRestart {
538 if s.cfg.Registry == "" {
539 // Opening "" would hand sqlite a throwaway temp DB and answer
540 // "no routes" having created a file to say it — handleAPIRoutes'
541 // twin guard, and the same reasoning.
542 return apiConfigRestart{}
543 }
544 reg, err := openRegistry(s.cfg.Registry)
545 if err != nil {
546 log.Printf("console: api config: registry %s: %v", s.cfg.Registry, err)
547 return apiConfigRestart{}
548 }
549 routes, err := reg.List()
550 if err != nil {
551 log.Printf("console: api config: registry list: %v", err)
552 return apiConfigRestart{}
553 }
554 p := appconfig.AwaitingRestart(ctx, s.cfg.Releases, routes, acct, app, envName, keys)
555 out := apiConfigRestart{Known: true, Unresolved: p.Unresolved}
556 for _, h := range p.Hosts {
557 out.Hosts = append(out.Hosts, apiConfigRestartHost{Host: h.Host, Unit: h.Unit})
558 }
559 return out
560}
561
562// writtenKeys is the var names one config write touched, set and unset
563// together — what AwaitingRestart needs to tell whether a default-bundle
564// write can have changed anything a BOUND route resolves to. Sets and
565// unsets are one list because the shadowing question does not care which
566// it was: a key the route's own bundle states is a key this write did not
567// move for that route, whichever way it moved elsewhere.
568func writtenKeys(sets map[string]appconfig.Var, unset []string) []string {
569 out := make([]string, 0, len(sets)+len(unset))
570 for k := range sets {
571 out = append(out, k)
572 }
573 return append(out, unset...)
574}
575
576// handleAPIConfigSync answers `carlos env sync`: touch the app's generation
577// object so the agent's fast loop picks the CURRENT bucket contents up within
578// seconds, instead of waiting out the hourly configSyncLoop (issue #44). The
579// escape hatch for "apply what's already there, now" — and the alternative it
580// replaces is restarting the box's shared TLS terminator over SSM to deliver
581// one app's variable.
582//
583// Audited, unlike a read: this makes exec-backed instances converge and
584// bounce, which an operator reading audit rows must be able to see.
585//
586// No body is read. There is nothing to say beyond the app already named in
587// the path, and a bearer request carries no cookie for a cross-site form to
588// forge in the first place. An app with no config at all is not refused: the
589// touch is a signal, and SyncExec resolves what (if anything) that app's
590// routes actually need — a generation nobody's config references costs one
591// small read on the next tick.
592func (s *Server) handleAPIConfigSync(w http.ResponseWriter, r *http.Request) {
593 addr, acct, app, ok := s.apiAccountApp(w, r)
594 if !ok {
595 return
596 }
597 p, _ := principalFrom(r.Context())
598 if !s.configWriteAllowed(w, p) {
599 return
600 }
601 ctx, cancel := context.WithTimeout(r.Context(), configStoreTimeout)
602 defer cancel()
603 if err := appconfig.TouchGeneration(ctx, s.cfg.Releases, acct.Sqid, app); err != nil {
604 log.Printf("console: api config sync %s/%s: %v", acct.Sqid, app, err)
605 jsonError(w, http.StatusBadGateway, "could not request the sync — try again")
606 return
607 }
608 s.audit(acct, app, addr, "config-sync", "requested config sync (cli)")
609 // This door exists to make the fleet converge NOW; the generation touch
610 // without the announcement would leave it waiting on the hourly loop it
611 // was written to skip.
612 s.bumpSerial(ctx)
613 writeJSON(w, http.StatusOK, map[string]any{"result": "requested"})
614}
615