| 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. |
| 26 | package console |
| 27 | |
| 28 | import ( |
| 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. |
| 47 | const 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. |
| 56 | const 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. |
| 71 | func (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. |
| 82 | func 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. |
| 104 | func 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. |
| 138 | func 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. |
| 148 | func (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. |
| 184 | func (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. |
| 289 | func (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. |
| 512 | type 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 | |
| 524 | type 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. |
| 537 | func (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. |
| 568 | func 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. |
| 592 | func (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 | |