| 1 | // The sidecar pause: an operator allowlist in front of the two doors that |
| 2 | // bring a native sidecar into being. |
| 3 | // |
| 4 | // Why it exists: a native sidecar runs on the flat tier |
| 5 | // (activator.ExecBackend.StartWorkerStaged) as the box's carlos uid with no |
| 6 | // mount namespace, so on its box it can read and write every nested |
| 7 | // tenant's /data/instances/<iid>. The fix is nested sidecars (amadan |
| 8 | // discussion #370). Until that lands, an operator lists the accounts that |
| 9 | // may still enable or create one (CARLOS_SIDECAR_ACCOUNTS) and every other |
| 10 | // account is refused. |
| 11 | // |
| 12 | // The doors, and why there are only two. A sidecar runs only from a record |
| 13 | // at apps/<sqid>/<app>/sidecars/<name>.json, which the box's sidecar |
| 14 | // reconcile pass (or, under host claim, the root claim path) turns into a |
| 15 | // Kind "sidecar" route, and it takes its argv and ceilings from |
| 16 | // apps/<sqid>/<app>/sidecars.json. The console writes the record in exactly |
| 17 | // one place (handleAPISidecarCreate, both the CreateSidecarRecord and the |
| 18 | // host-claim beginClaimCreate arms) and the manifest in exactly one place |
| 19 | // (handleAPISidecarEnable). Both call refuseSidecarsPaused. Ship and promote |
| 20 | // publish releases and channel pointers, never a sidecar manifest; restore |
| 21 | // clears an app's trash marker and brings back only sidecars that already |
| 22 | // existed; carlos run needs a nested instance. Store mode (`carlos sidecars |
| 23 | // enable` against the bucket) is the operator's own bucket credential and |
| 24 | // is not a console door. |
| 25 | // |
| 26 | // What it leaves alone, on purpose: list, get, wake and delete. Existing |
| 27 | // sidecars keep running, and an account taken off the list must still be |
| 28 | // able to retire its own. |
| 29 | package console |
| 30 | |
| 31 | import ( |
| 32 | "log" |
| 33 | "net/http" |
| 34 | "slices" |
| 35 | ) |
| 36 | |
| 37 | // sidecarsPausedMsg is the one sentence a refused account reads. |
| 38 | // copy: approved by Paul 2026-10-24 (copy review). |
| 39 | const sidecarsPausedMsg = "sidecars are paused for this account. existing sidecars keep running, and you can still delete them" |
| 40 | |
| 41 | // sidecarsPausedCode is the refusal's machine-readable code, beside the |
| 42 | // sentence the way plan caps carry "cap". |
| 43 | const sidecarsPausedCode = "sidecars-paused" |
| 44 | |
| 45 | // sidecarsPaused reports whether acct is refused new sidecars: the |
| 46 | // allowlist is set and does not name the account's sqid. |
| 47 | func (s *Server) sidecarsPaused(acct *Account) bool { |
| 48 | return len(s.cfg.SidecarAccounts) > 0 && !slices.Contains(s.cfg.SidecarAccounts, acct.Sqid) |
| 49 | } |
| 50 | |
| 51 | // refuseSidecarsPaused answers 403 sidecars-paused and reports true when |
| 52 | // acct may not enable or create a sidecar. Call it AFTER the door's |
| 53 | // authentication, membership and validation checks, so a non-member still |
| 54 | // gets the byte-identical 404 and the gate never confirms that an account |
| 55 | // exists, and BEFORE any bucket write. Request-path refusals are not |
| 56 | // audited in this console (the plan cap's is not either), so it logs. |
| 57 | func (s *Server) refuseSidecarsPaused(w http.ResponseWriter, acct *Account, app, verb string) bool { |
| 58 | if !s.sidecarsPaused(acct) { |
| 59 | return false |
| 60 | } |
| 61 | log.Printf("console: api sidecars %s %s/%s: refused, account not in CARLOS_SIDECAR_ACCOUNTS", verb, acct.Sqid, app) |
| 62 | writeJSON(w, http.StatusForbidden, map[string]any{"error": sidecarsPausedMsg, "code": sidecarsPausedCode}) |
| 63 | return true |
| 64 | } |
| 65 | |