| 1 | // The CLI's sidecar provisioning API: the verbs that turn "run a member's |
| 2 | // bot" into a data write in the deployment bucket, so an app process holding |
| 3 | // no AWS credentials and no box access can provision one per enrolled member |
| 4 | // in seconds. The record it writes is the whole authorization; the agent's |
| 5 | // sidecar reconcile pass (internal/provision/sidecar_reconcile.go) is what |
| 6 | // turns it into a route, on its own box only. |
| 7 | // |
| 8 | // Everything structural here is the instance family's, deliberately: the |
| 9 | // GATE is instanceGate verbatim (apiinstances.go), so the SAME two bearer |
| 10 | // shapes reach it — a member's user token, or an APP token bound to one |
| 11 | // account and one app at mint time — with the same ordering (principal, |
| 12 | // budget, readiness) and the same byte-identical 404 on every refusal before |
| 13 | // the principal is proven. That reuse is the point rather than a shortcut: |
| 14 | // Eleven's process calls this door with the CARLOS_INSTANCE_TOKEN it already |
| 15 | // holds, and a second hand-rolled gate would be a second place for the |
| 16 | // ordering to drift. `apidomains.go` shares it for the identical reason. |
| 17 | // |
| 18 | // Three things are NOT the instance family's, and each is a consequence of |
| 19 | // what a sidecar is (design: sidecar members): |
| 20 | // |
| 21 | // - There is no host in the request and no domain in the manifest. A |
| 22 | // sidecar's host is DERIVED — provision.SidecarHost(name, app, sqid), |
| 23 | // under the reserved, never-resolvable .sidecar.internal suffix — so |
| 24 | // there is no namespace for a caller to reach into and no sibling-apex |
| 25 | // hazard to refuse: the app and account are IN the host. The only thing |
| 26 | // the request names is one DNS label. |
| 27 | // - There is no PATCH. A sidecar has no upstream set to repoint, which is |
| 28 | // the one thing an instance record's update verb exists for. The family |
| 29 | // is create-only plus delete, which is also what makes CreateSidecarRecord's |
| 30 | // lost race a 409 rather than a silent overwrite. |
| 31 | // - CHANNEL is stamped on every record, always. Task 9's reconcile pass |
| 32 | // PROTECTS a channel-less sidecar record — it is deliberately never |
| 33 | // minted, because a route with no channel cannot resolve a release and |
| 34 | // so has no build to exec. A record written without one would be |
| 35 | // silently inert: minted by nothing, with no error anywhere. That makes |
| 36 | // this door load-bearing for it (see handleAPISidecarCreate). |
| 37 | // |
| 38 | // Spec: docs/superpowers/specs/2026-08-17-sidecar-members-design.md §11 (the |
| 39 | // verbs), §4 (the shapes), §7.4 (the wake), §8 (placement). |
| 40 | package console |
| 41 | |
| 42 | import ( |
| 43 | "context" |
| 44 | "errors" |
| 45 | "log" |
| 46 | "net/http" |
| 47 | "os" |
| 48 | "slices" |
| 49 | "strings" |
| 50 | "time" |
| 51 | |
| 52 | "github.com/carlosframework/platform/internal/hostops" |
| 53 | "github.com/carlosframework/platform/internal/provision" |
| 54 | "github.com/carlosframework/platform/internal/release" |
| 55 | ) |
| 56 | |
| 57 | // sidecarListMax caps one list answer, the instance family's number for the |
| 58 | // same reason: a sidecar fleet is one row per enrolled member and can be |
| 59 | // large, and the response says `truncated` rather than silently serving a |
| 60 | // prefix. |
| 61 | const sidecarListMax = 500 |
| 62 | |
| 63 | // The manifest's argv bounds (design §4.1). Args becomes the argv the |
| 64 | // activator execs the app's OWN binary with, so the bound here is manifest |
| 65 | // hygiene rather than privilege — the caller already controls that binary |
| 66 | // via ship — but a manifest is read forever, and an unbounded argv written |
| 67 | // once would be permanent. Same numbers as instanceSeedMaxArgs for the same |
| 68 | // reason, kept as their own constants so a change to one family's bound is |
| 69 | // never silently a change to the other's. |
| 70 | const ( |
| 71 | sidecarArgsMax = 16 |
| 72 | sidecarArgMaxLen = 256 |
| 73 | ) |
| 74 | |
| 75 | // sidecarMaxSeconds bounds the two duration knobs. Thirty days is far past |
| 76 | // any honest idle or max-run and short of the values that read as "never" — |
| 77 | // a manifest is read forever, so a nonsense value written once outlives |
| 78 | // whoever typed it. |
| 79 | const sidecarMaxSeconds = 30 * 24 * 60 * 60 |
| 80 | |
| 81 | // sidecarManifest reads the app's sidecar manifest and answers the caller |
| 82 | // itself when it cannot be had. ok=false means a response has already been |
| 83 | // written. |
| 84 | // |
| 85 | // CREATE is its only caller, exactly as instanceManifest's is: the manifest |
| 86 | // is policy about what may be BROUGHT INTO existence, and reads, wakes and |
| 87 | // deletes are about what already exists, which no version of it can retract. |
| 88 | // |
| 89 | // The two failures are kept apart on purpose. ABSENCE is an answer — the |
| 90 | // feature does not exist for this app — while a read failure is the bucket |
| 91 | // being unreachable. Reporting a hiccup as "sidecars are not enabled" would |
| 92 | // tell an app its feature had been switched off. The 409 names SIDECARS' own |
| 93 | // enable verb: an app with instances enabled and sidecars not is the ordinary |
| 94 | // state, so pointing at the other family's verb would be actively wrong. |
| 95 | func (s *Server) sidecarManifest(ctx context.Context, w http.ResponseWriter, acct *Account, app string) (*provision.SidecarManifest, bool) { |
| 96 | m, err := provision.GetSidecarManifest(ctx, s.cfg.Releases, acct.Sqid, app) |
| 97 | switch { |
| 98 | case err == nil: |
| 99 | return m, true |
| 100 | case errors.Is(err, os.ErrNotExist): |
| 101 | // Membership (or binding) is already proven, so this may name its |
| 102 | // cause and the act that fixes it. |
| 103 | jsonError(w, http.StatusConflict, |
| 104 | "sidecars are not enabled for "+app+" — enable them first (carlos sidecars enable -app "+app+")") |
| 105 | return nil, false |
| 106 | default: |
| 107 | log.Printf("console: api sidecars manifest %s/%s: %v", acct.Sqid, app, err) |
| 108 | jsonError(w, http.StatusBadGateway, "could not read sidecar settings") |
| 109 | return nil, false |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | // sidecarListRecord wraps provision.SidecarRecord with the console's own |
| 114 | // running-version join, kept OFF the record for the same reason |
| 115 | // instanceListRecord keeps staged_version off provision.Record: the record is |
| 116 | // the wire shape CreateSidecarRecord persists to the bucket, this field is |
| 117 | // never written there, and giving the record the field would risk a future |
| 118 | // write path serializing a stale value into stored bytes the join was never |
| 119 | // meant to touch. |
| 120 | type sidecarListRecord struct { |
| 121 | provision.SidecarRecord |
| 122 | // RunningVersion is the build the agent last started for the sidecar |
| 123 | // (registry ServingVersion, zero-downtime plan 5), read from whichever |
| 124 | // box carries its route via status/boxes/<label>.json's RouteServing, |
| 125 | // under loadBoxJoin's staleness rule. NOT RouteVersions: since plan 4 |
| 126 | // that is root's install stamp, which can name a build nothing runs yet, |
| 127 | // and the worker's own /v1/ready report is display metadata only. A |
| 128 | // sidecar that has not started anywhere reads EMPTY and is rendered |
| 129 | // "unknown", never as the channel's desired version. |
| 130 | RunningVersion string `json:"running_version"` |
| 131 | } |
| 132 | |
| 133 | // handleAPISidecarList answers `carlos sidecars ls`: this app's records, |
| 134 | // name-sorted, capped and honest about the cap, each carrying its |
| 135 | // best-effort running version. |
| 136 | func (s *Server) handleAPISidecarList(w http.ResponseWriter, r *http.Request) { |
| 137 | _, acct, app, ok := s.instanceGateFor(w, r, "sidecar") |
| 138 | if !ok { |
| 139 | return |
| 140 | } |
| 141 | ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) |
| 142 | defer cancel() |
| 143 | recs, truncated, err := provision.ListSidecarRecords(ctx, s.cfg.Releases, acct.Sqid, app, sidecarListMax) |
| 144 | if err != nil { |
| 145 | log.Printf("console: api sidecars list %s/%s: %v", acct.Sqid, app, err) |
| 146 | jsonError(w, http.StatusBadGateway, "could not list sidecars") |
| 147 | return |
| 148 | } |
| 149 | join := loadBoxJoin(ctx, s.cfg.Releases, time.Now()) |
| 150 | out := make([]sidecarListRecord, 0, len(recs)) |
| 151 | for _, rec := range recs { |
| 152 | out = append(out, sidecarListRecord{SidecarRecord: rec, RunningVersion: join.serving[rec.Host]}) |
| 153 | } |
| 154 | if s.cfg.HostClaim { |
| 155 | pending, err := s.claimPendingViews(ctx, acct.Sqid, app, "sidecar") |
| 156 | if err != nil { |
| 157 | writeClaimResponseError(w, err) |
| 158 | return |
| 159 | } |
| 160 | writeJSON(w, http.StatusOK, map[string]any{"sidecars": out, "truncated": truncated, "operations": pending}) |
| 161 | return |
| 162 | } |
| 163 | writeJSON(w, http.StatusOK, map[string]any{"sidecars": out, "truncated": truncated}) |
| 164 | } |
| 165 | |
| 166 | // handleAPISidecarGet answers one record. A name with no record is the |
| 167 | // byte-identical 404 — a sidecar's existence is not a thing to probe for |
| 168 | // either, and this way "no such sidecar" and "no such app" read alike. |
| 169 | func (s *Server) handleAPISidecarGet(w http.ResponseWriter, r *http.Request) { |
| 170 | _, acct, app, ok := s.instanceGateFor(w, r, "sidecar") |
| 171 | if !ok { |
| 172 | return |
| 173 | } |
| 174 | name := r.PathValue("name") |
| 175 | // Shape-checked before the key builder, not after. A {name} segment |
| 176 | // arrives percent-decoded, so "..%2f..%2fsidecars.json" reaches this |
| 177 | // handler as a path with separators in it; SidecarRecordKey would happily |
| 178 | // join it. A name that cannot name a record is simply not found — the |
| 179 | // same answer a name that names no record gets. |
| 180 | if !provision.ValidSidecarName(name) { |
| 181 | http.NotFound(w, r) |
| 182 | return |
| 183 | } |
| 184 | ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) |
| 185 | defer cancel() |
| 186 | rec, err := provision.GetSidecarRecord(ctx, s.cfg.Releases, acct.Sqid, app, name) |
| 187 | if errors.Is(err, os.ErrNotExist) { |
| 188 | http.NotFound(w, r) |
| 189 | return |
| 190 | } |
| 191 | if err != nil { |
| 192 | log.Printf("console: api sidecars get %s/%s/%s: %v", acct.Sqid, app, name, err) |
| 193 | jsonError(w, http.StatusBadGateway, "could not read the sidecar") |
| 194 | return |
| 195 | } |
| 196 | writeJSON(w, http.StatusOK, rec) |
| 197 | } |
| 198 | |
| 199 | // handleAPISidecarCreate is the point of the feature: one create-only write |
| 200 | // to the bucket, in the family's refusal order — gate, budget, readiness |
| 201 | // (instanceGate), manifest, name, region, channel, shape, pause |
| 202 | // (sidecarsgate.go), write. |
| 203 | // |
| 204 | // Every field policy governs is stamped from the MANIFEST, never from the |
| 205 | // request. The body has no pool field to lie in, no host field (the host is |
| 206 | // derived), and no placement field — that last one deliberately, and it is |
| 207 | // worth stating why: a record that restated the manifest's placement would |
| 208 | // pin itself against a later RELAXATION of it (sidecarPlacement takes the |
| 209 | // most restrictive of the two), so Task 9's placement evacuation — flip the |
| 210 | // manifest and the rows move — would stop working in one direction for every |
| 211 | // record this door ever wrote. Leaving the field empty keeps the manifest |
| 212 | // authoritative, which is what app-wide policy means. The other direction, |
| 213 | // an app token stating "any" under a no-inbound manifest to place a bot on |
| 214 | // boxes the operator meant to keep it off, is refused HERE and again in the |
| 215 | // reconciler (sidecarPlacement's most-restrictive rule): both doors hold the |
| 216 | // invariant, neither trusts the other. |
| 217 | func (s *Server) handleAPISidecarCreate(w http.ResponseWriter, r *http.Request) { |
| 218 | principal, acct, app, ok := s.instanceGateFor(w, r, "sidecar") |
| 219 | if !ok { |
| 220 | return |
| 221 | } |
| 222 | if s.refuseTemporary(w, acct, app, "serve anything but a static site at "+app+"'s own platform address") { |
| 223 | return |
| 224 | } |
| 225 | ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) |
| 226 | defer cancel() |
| 227 | m, ok := s.sidecarManifest(ctx, w, acct, app) |
| 228 | if !ok { |
| 229 | return |
| 230 | } |
| 231 | var req struct { |
| 232 | Name string `json:"name"` |
| 233 | Region string `json:"region"` |
| 234 | Channel string `json:"channel"` |
| 235 | } |
| 236 | if !readJSON(w, r, &req) { |
| 237 | return |
| 238 | } |
| 239 | name := strings.TrimSpace(req.Name) |
| 240 | // Path-traversal defence stated as such: this name becomes a bucket key |
| 241 | // segment here and, on the box, a segment of the derived host — which is |
| 242 | // itself the <host>.db and cgroup path segment. Nothing below runs until |
| 243 | // it is one DNS label. |
| 244 | if !provision.ValidSidecarName(name) { |
| 245 | jsonError(w, http.StatusBadRequest, |
| 246 | "invalid name — a sidecar name is a single DNS label (lowercase a-z, 0-9, hyphen, 1-63 chars)") |
| 247 | return |
| 248 | } |
| 249 | // A fleet-placed app governs every one of its records' Region uniformly — |
| 250 | // handleAPIInstanceCreate's rule, mirrored here because Eleven, the |
| 251 | // customer this whole family exists for, places its app onto a fleet. When |
| 252 | // a placement exists it OVERRIDES whatever the request body sent, and the |
| 253 | // ordinary region/allow-list validation below never runs at all: a |
| 254 | // "fleet/<name>" value would fail provision.ValidRegion's shape anyway, |
| 255 | // which is exactly why this has to be a check made BEFORE that validation |
| 256 | // rather than alongside it. A placed app's region is not user input. |
| 257 | reg, err := openRegistry(s.cfg.Registry) |
| 258 | if err != nil { |
| 259 | log.Printf("console: api sidecars create %s/%s: open registry: %v", acct.Sqid, app, err) |
| 260 | jsonError(w, http.StatusBadGateway, "could not check this app's placement") |
| 261 | return |
| 262 | } |
| 263 | placed, err := s.appPlacement(reg, acct, app) |
| 264 | if err != nil { |
| 265 | log.Printf("console: api sidecars create %s/%s: check placement: %v", acct.Sqid, app, err) |
| 266 | jsonError(w, http.StatusBadGateway, "could not check this app's placement") |
| 267 | return |
| 268 | } |
| 269 | region := strings.TrimSpace(req.Region) |
| 270 | if placed != "" { |
| 271 | region = placed |
| 272 | } else if !provision.ValidRegion(region) || !instanceRegionAllowed(m.Regions, region) { |
| 273 | msg := "invalid region" |
| 274 | if len(m.Regions) > 0 { |
| 275 | msg = "region " + region + " is not one this app's sidecars run in — allowed: " + strings.Join(m.Regions, ", ") |
| 276 | } |
| 277 | jsonError(w, http.StatusBadRequest, msg) |
| 278 | return |
| 279 | } |
| 280 | // The channel, resolved request -> manifest -> documented default, and |
| 281 | // NEVER left empty. See this file's package comment: the reconcile pass |
| 282 | // protects a channel-less record and never mints it, so an empty channel |
| 283 | // here is a sidecar that silently never runs. |
| 284 | channel := strings.TrimSpace(req.Channel) |
| 285 | if channel == "" { |
| 286 | if channel = strings.TrimSpace(m.Channel); channel == "" { |
| 287 | channel = "stable" |
| 288 | } |
| 289 | } |
| 290 | if !release.ValidChannel(channel) { |
| 291 | jsonError(w, http.StatusBadRequest, "invalid channel name") |
| 292 | return |
| 293 | } |
| 294 | rec := provision.SidecarRecord{ |
| 295 | Schema: 1, |
| 296 | Name: name, |
| 297 | Host: provision.SidecarHost(name, app, acct.Sqid), |
| 298 | App: app, |
| 299 | Account: acct.Sqid, |
| 300 | Pool: m.Pool, |
| 301 | Region: region, |
| 302 | Channel: channel, |
| 303 | // UTC, like every other timestamp written to the bucket: these |
| 304 | // records are read on boxes in other regions. |
| 305 | CreatedAt: time.Now().UTC(), |
| 306 | CreatedBy: principal, |
| 307 | } |
| 308 | // The shape rails, at the door — the same door the box's reconcile pass |
| 309 | // re-checks them at, because neither side trusts the other about what is |
| 310 | // in the bucket. The message is ValidateSidecarShape's own. |
| 311 | if err := provision.ValidateSidecarShape(rec); err != nil { |
| 312 | jsonError(w, http.StatusBadRequest, err.Error()) |
| 313 | return |
| 314 | } |
| 315 | // The sidecar pause (sidecarsgate.go): after every check above, so a |
| 316 | // stranger still meets the 404 and a bad request its 400, and before |
| 317 | // the cap's listing, the leftover rail and either write arm. |
| 318 | if s.refuseSidecarsPaused(w, acct, app, "create") { |
| 319 | return |
| 320 | } |
| 321 | // Plans 3a: the plan caps workers per app, under the account lock. |
| 322 | defer s.planLock(acct)() |
| 323 | if err := s.checkSidecars(r.Context(), acct, app); err != nil { |
| 324 | if isPlanCap(err) { |
| 325 | writeJSON(w, http.StatusConflict, map[string]any{"error": err.Error(), "code": "cap"}) |
| 326 | return |
| 327 | } |
| 328 | log.Printf("console: api sidecars create %s/%s: plan cap: %v", acct.Sqid, app, err) |
| 329 | jsonError(w, http.StatusBadGateway, "could not create the sidecar — try again") |
| 330 | return |
| 331 | } |
| 332 | // The leftover-replica rail (#199, createleftovers.go): a sidecar's host |
| 333 | // is derived from its name, so a name reused after a delete lands on the |
| 334 | // deleted sidecar's replica. Refused, after every validation above. |
| 335 | if ref := s.leftoverReplicaRefusal(r.Context(), leftoverCreate{ |
| 336 | logTag: "api sidecars create " + acct.Sqid + "/" + app, |
| 337 | host: rec.Host, region: region, pool: rec.Pool, |
| 338 | exists: func(ctx context.Context) (bool, error) { |
| 339 | _, err := provision.GetSidecarRecord(ctx, s.cfg.Releases, acct.Sqid, app, name) |
| 340 | if errors.Is(err, os.ErrNotExist) { |
| 341 | return false, nil |
| 342 | } |
| 343 | return err == nil, err |
| 344 | }, |
| 345 | // The same two answers the CreateSidecarRecord call below gives. |
| 346 | duplicate: doorRefusal{http.StatusConflict, "sidecar " + name + " already exists"}, |
| 347 | readFailed: doorRefusal{http.StatusBadGateway, "could not create the sidecar — try again"}, |
| 348 | taken: "the name " + name + " still holds data from a deleted sidecar. Choose another name, or ask an operator to purge it.", |
| 349 | unsure: "could not check whether the name " + name + " is free. Try again.", |
| 350 | }); ref != nil { |
| 351 | jsonError(w, ref.Status, ref.Msg) |
| 352 | return |
| 353 | } |
| 354 | // A fresh budget for the write: validation and the rail's listings may |
| 355 | // have spent most of the original one. |
| 356 | cancel() |
| 357 | ctx, cancel = context.WithTimeout(r.Context(), instanceStoreTimeout) |
| 358 | defer cancel() |
| 359 | var recordWriteErr error |
| 360 | if s.cfg.HostClaim { |
| 361 | request, err := s.beginClaimCreate(r.Context(), acct, app, rec.Host, principal, func(now time.Time, acquisition string) (hostops.Record, error) { |
| 362 | rec.CreatedAt = time.Time{} |
| 363 | rec.Acquisition = acquisition |
| 364 | region := s.cfg.claimPlacementRegion(rec.Region) |
| 365 | prepared, err := hostops.PrepareSidecar(rec, now, region) |
| 366 | if err == nil { |
| 367 | rec.CreatedAt = now |
| 368 | } |
| 369 | return prepared, err |
| 370 | }) |
| 371 | if err != nil { |
| 372 | writeClaimResponseError(w, err) |
| 373 | return |
| 374 | } |
| 375 | defer request.Close() |
| 376 | if !s.finishClaimCreate(w, request) { |
| 377 | return |
| 378 | } |
| 379 | } else { |
| 380 | recordWriteErr = provision.CreateSidecarRecord(ctx, s.cfg.Releases, rec) |
| 381 | } |
| 382 | if err := recordWriteErr; err != nil { |
| 383 | if errors.Is(err, release.ErrCASConflict) { |
| 384 | // A lost race against a second create for the same name, or a |
| 385 | // plain duplicate. Either way the winner's record stands. |
| 386 | jsonError(w, http.StatusConflict, "sidecar "+name+" already exists") |
| 387 | return |
| 388 | } |
| 389 | log.Printf("console: api sidecars create %s/%s/%s: %v", acct.Sqid, app, name, err) |
| 390 | jsonError(w, http.StatusBadGateway, "could not create the sidecar — try again") |
| 391 | return |
| 392 | } |
| 393 | s.audit(acct, app, principal, "sidecar-create", "created sidecar "+name+" (cli)") |
| 394 | // The record is what a box's sidecar reconcile leg mints a route from, so |
| 395 | // this is a box-consumed write (carlos-deploy §3): announce it, and the |
| 396 | // sidecar is running seconds after signup instead of on the hour. |
| 397 | s.bumpSerial(ctx) |
| 398 | writeJSON(w, http.StatusCreated, rec) |
| 399 | } |
| 400 | |
| 401 | // handleAPISidecarDelete retires a sidecar's provisioning authorization. The |
| 402 | // record is deleted; the parked database and blobs are deliberately |
| 403 | // untouched, exactly as an instance delete leaves them — retiring a member's |
| 404 | // bot is not destroying that member's data. Idempotent: the caller's goal is |
| 405 | // "not there", and a second delete has reached it. |
| 406 | func (s *Server) handleAPISidecarDelete(w http.ResponseWriter, r *http.Request) { |
| 407 | principal, acct, app, ok := s.instanceGateFor(w, r, "sidecar") |
| 408 | if !ok { |
| 409 | return |
| 410 | } |
| 411 | name := r.PathValue("name") |
| 412 | ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) |
| 413 | defer cancel() |
| 414 | // SHAPE ONLY, always — and the manifest is not read here at all. Policy |
| 415 | // is CREATE-time ("may this app have sidecars"), and deletion has to |
| 416 | // outlive every version of it: an app that retired its manifest still has |
| 417 | // records, and those are exactly the records that must be deletable, or |
| 418 | // provisioned rows are stranded with nothing left able to retire them. |
| 419 | // What deletion actually needs from validation is the traversal defence: |
| 420 | // a {name} arrives percent-decoded, so "..%2f..%2fsidecars.json" reaches |
| 421 | // here with separators in it and SidecarRecordKey would happily join it. |
| 422 | if !provision.ValidSidecarName(name) { |
| 423 | jsonError(w, http.StatusBadRequest, "invalid name") |
| 424 | return |
| 425 | } |
| 426 | if err := s.retireHostRecord(ctx, provision.RetireRef{Family: "sidecar", Account: acct.Sqid, App: app, Host: provision.SidecarHost(name, app, acct.Sqid), Name: name, By: principal}); err != nil { |
| 427 | // An accepted delete is this principal's durable request: audit it |
| 428 | // and announce it before answering 202 with the operation. |
| 429 | if accepted, ok := claimRetireAcceptedOf(err); ok { |
| 430 | s.audit(acct, app, principal, "sidecar-delete", "delete of sidecar "+name+" accepted, operation "+accepted.operation+" (cli)") |
| 431 | s.bumpSerial(ctx) |
| 432 | } |
| 433 | if s.writeClaimPending(w, err) { |
| 434 | return |
| 435 | } |
| 436 | log.Printf("console: api sidecars delete %s/%s/%s: %v", acct.Sqid, app, name, err) |
| 437 | jsonError(w, http.StatusBadGateway, "could not delete the sidecar — try again") |
| 438 | return |
| 439 | } |
| 440 | // The wake object is this record's, and it goes with it — best effort, |
| 441 | // AFTER the authorization is gone and never able to fail the request. An |
| 442 | // app shaped like Eleven's has one sidecar per enrolled member, so |
| 443 | // leaving these behind would accrue one object per retired member in the |
| 444 | // bucket forever. A leftover is harmless in itself (the box's first sight |
| 445 | // of a wake object is recorded silently and never replayed — |
| 446 | // host/sidecarwake.go), which is exactly why a failure here is logged |
| 447 | // rather than reported: the caller's goal is reached either way. |
| 448 | if err := s.deleteLegacySidecarWake(ctx, acct.Sqid, app, name); err != nil { |
| 449 | log.Printf("console: api sidecars delete %s/%s/%s: wake object: %v", acct.Sqid, app, name, err) |
| 450 | } |
| 451 | // Audited whether or not an object was actually there to remove: |
| 452 | // idempotency makes "deleted" and "already gone" indistinguishable from |
| 453 | // the store, and the fact worth recording is that this principal asked |
| 454 | // for this sidecar to stop existing. |
| 455 | s.audit(acct, app, principal, "sidecar-delete", "deleted sidecar "+name+" (cli)") |
| 456 | // Retiring the row is reconcile's work too, and the direction people want |
| 457 | // FAST: the announcement is what stops the process in seconds. |
| 458 | s.bumpSerial(ctx) |
| 459 | writeJSON(w, http.StatusOK, map[string]any{"result": "deleted"}) |
| 460 | } |
| 461 | |
| 462 | // handleAPISidecarWake asks every box serving this sidecar to wake it |
| 463 | // (design §7.4's second wake source). A sidecar has no socket and nothing |
| 464 | // dials it, so unlike an instance it has no "next request" to wake on: the |
| 465 | // signal has to be an object a pull-based box notices on its own. |
| 466 | // |
| 467 | // The write goes through release.TouchSidecarWake and nowhere else. Two |
| 468 | // writers for one generation counter is how that counter ends up written two |
| 469 | // different ways; the object is CAS-guarded, a lost race is success (the |
| 470 | // signal has already fired), and change detection downstream is by ETAG, not |
| 471 | // by the counter's value. |
| 472 | // |
| 473 | // A name with no record is the byte-identical 404, and the record read is |
| 474 | // what stands between an app token and an unbounded write: without it, any |
| 475 | // name at all would mint a wake object for a sidecar that does not exist. |
| 476 | func (s *Server) handleAPISidecarWake(w http.ResponseWriter, r *http.Request) { |
| 477 | principal, acct, app, ok := s.instanceGateFor(w, r, "sidecar") |
| 478 | if !ok { |
| 479 | return |
| 480 | } |
| 481 | name := r.PathValue("name") |
| 482 | if !provision.ValidSidecarName(name) { |
| 483 | http.NotFound(w, r) |
| 484 | return |
| 485 | } |
| 486 | ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) |
| 487 | defer cancel() |
| 488 | rec, err := provision.GetSidecarRecord(ctx, s.cfg.Releases, acct.Sqid, app, name) |
| 489 | if errors.Is(err, os.ErrNotExist) { |
| 490 | http.NotFound(w, r) |
| 491 | return |
| 492 | } |
| 493 | if err != nil { |
| 494 | log.Printf("console: api sidecars wake read %s/%s/%s: %v", acct.Sqid, app, name, err) |
| 495 | jsonError(w, http.StatusBadGateway, "could not read the sidecar") |
| 496 | return |
| 497 | } |
| 498 | if err := s.touchSidecarWake(ctx, acct.Sqid, app, name, |
| 499 | principal, "carlos sidecars wake"); err != nil { |
| 500 | log.Printf("console: api sidecars wake %s/%s/%s: %v", acct.Sqid, app, name, err) |
| 501 | jsonError(w, http.StatusBadGateway, "could not request the wake — try again") |
| 502 | return |
| 503 | } |
| 504 | s.audit(acct, app, principal, "sidecar-wake", "requested a wake for sidecar "+name+" (cli)") |
| 505 | // The wake object is watched on the fast convergence path, which a serial |
| 506 | // move is what drives: without this the box would not look until its next |
| 507 | // ordinary tick. |
| 508 | s.bumpSerial(ctx) |
| 509 | writeJSON(w, http.StatusOK, map[string]any{ |
| 510 | "result": "waking", "name": name, "host": rec.Host, |
| 511 | }) |
| 512 | } |
| 513 | |
| 514 | // handleAPISidecarEnable is the member-facing half of `carlos sidecars |
| 515 | // enable`: the app's opt-in to sidecar provisioning, and the policy every |
| 516 | // record is checked against. |
| 517 | // |
| 518 | // Gated by apiAccountApp DIRECTLY, not the instance gate: enabling is |
| 519 | // policy, a human act — an app token meets cliUser's 401 here, exactly as it |
| 520 | // does at the instance token mint and the instances-manifest door. Budgeted |
| 521 | // on the address-keyed write limiter for the same reason: the principal an |
| 522 | // enable belongs to is a person. |
| 523 | // |
| 524 | // It has NO domain to pin, which is the one security check its instances |
| 525 | // counterpart is mostly made of. A sidecar host is derived |
| 526 | // (provision.SidecarHost) under a reserved suffix nothing resolves, so there |
| 527 | // is no hostname namespace for this door to hand out and no operator-written |
| 528 | // custom domain for it to overwrite. What it can still do wrong is rewrite |
| 529 | // operator policy, so POOL and REGIONS ride through untouched in both |
| 530 | // directions — store mode (`carlos sidecars enable` against the bucket) is |
| 531 | // where those are set, exactly as with instances. |
| 532 | func (s *Server) handleAPISidecarEnable(w http.ResponseWriter, r *http.Request) { |
| 533 | addr, acct, app, ok := s.apiAccountApp(w, r) |
| 534 | if !ok { |
| 535 | return |
| 536 | } |
| 537 | if s.refuseTemporary(w, acct, app, "serve anything but a static site at "+app+"'s own platform address") { |
| 538 | return |
| 539 | } |
| 540 | if !s.cli.write.allow(addr, time.Now()) { |
| 541 | jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") |
| 542 | return |
| 543 | } |
| 544 | if s.cfg.Releases == nil { |
| 545 | jsonError(w, http.StatusServiceUnavailable, |
| 546 | "no deployment bucket on this host — the console is running without one") |
| 547 | return |
| 548 | } |
| 549 | // Every field is a POINTER because "absent" and "empty" are different |
| 550 | // requests on the wire (the apiConfigApply precedent): an omitted field |
| 551 | // preserves what the manifest already carries, an explicit value replaces |
| 552 | // it — including args' explicit [] meaning "clear" and hibernate's |
| 553 | // explicit false, which must win over HibernateOrDefault's true. |
| 554 | var req struct { |
| 555 | Channel *string `json:"channel"` |
| 556 | Args *[]string `json:"args"` |
| 557 | Placement *string `json:"placement"` |
| 558 | IdleSeconds *int `json:"idle_seconds"` |
| 559 | MaxRunSeconds *int `json:"max_run_seconds"` |
| 560 | Hibernate *bool `json:"hibernate"` |
| 561 | } |
| 562 | if !readJSON(w, r, &req) { |
| 563 | return |
| 564 | } |
| 565 | if req.Channel != nil && !release.ValidChannel(*req.Channel) { |
| 566 | jsonError(w, http.StatusBadRequest, "invalid channel name") |
| 567 | return |
| 568 | } |
| 569 | if req.Placement != nil && |
| 570 | !slices.Contains([]string{"", provision.PlacementAny, provision.PlacementNoInbound}, *req.Placement) { |
| 571 | jsonError(w, http.StatusBadRequest, |
| 572 | `invalid placement — want "any" or "no-inbound"`) |
| 573 | return |
| 574 | } |
| 575 | if req.Args != nil { |
| 576 | if len(*req.Args) > sidecarArgsMax { |
| 577 | jsonError(w, http.StatusBadRequest, "args takes at most 16 arguments") |
| 578 | return |
| 579 | } |
| 580 | for _, arg := range *req.Args { |
| 581 | if arg == "" || len(arg) > sidecarArgMaxLen { |
| 582 | jsonError(w, http.StatusBadRequest, "each argument must be 1-256 bytes") |
| 583 | return |
| 584 | } |
| 585 | } |
| 586 | } |
| 587 | for _, d := range []struct { |
| 588 | what string |
| 589 | v *int |
| 590 | }{{"idle_seconds", req.IdleSeconds}, {"max_run_seconds", req.MaxRunSeconds}} { |
| 591 | if d.v != nil && (*d.v < 0 || *d.v > sidecarMaxSeconds) { |
| 592 | jsonError(w, http.StatusBadRequest, d.what+" must be between 0 and 2592000 (30 days); 0 means the default") |
| 593 | return |
| 594 | } |
| 595 | } |
| 596 | // The sidecar pause (sidecarsgate.go): after authentication, membership |
| 597 | // and validation, before the manifest read and its write. |
| 598 | if s.refuseSidecarsPaused(w, acct, app, "enable") { |
| 599 | return |
| 600 | } |
| 601 | ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) |
| 602 | defer cancel() |
| 603 | // Read WITH the etag and write guarded by it below: a read-modify-write |
| 604 | // through the upsert (PutSidecarManifest re-reads inside) would silently |
| 605 | // swallow an operator's store-mode write landing in the same window, |
| 606 | // which is exactly the pool/regions policy this door promises not to |
| 607 | // touch. |
| 608 | m, etag, err := provision.GetSidecarManifestETag(ctx, s.cfg.Releases, acct.Sqid, app) |
| 609 | switch { |
| 610 | case errors.Is(err, os.ErrNotExist): |
| 611 | m, etag = &provision.SidecarManifest{Schema: 1, App: app}, "" |
| 612 | case err != nil: |
| 613 | log.Printf("console: api sidecars enable read %s/%s: %v", acct.Sqid, app, err) |
| 614 | jsonError(w, http.StatusBadGateway, "could not read sidecar settings") |
| 615 | return |
| 616 | } |
| 617 | // Recorded debt, the same the instances enable door carries: fields ADDED |
| 618 | // to schema 1 after this binary would be dropped by this |
| 619 | // read-modify-write, so an additive manifest field needs the console |
| 620 | // upgraded before members enable through it. |
| 621 | if req.Channel != nil { |
| 622 | m.Channel = *req.Channel |
| 623 | } |
| 624 | if req.Args != nil { |
| 625 | m.Args = *req.Args |
| 626 | } |
| 627 | if req.Placement != nil { |
| 628 | m.Placement = *req.Placement |
| 629 | } |
| 630 | if req.IdleSeconds != nil { |
| 631 | m.IdleSeconds = *req.IdleSeconds |
| 632 | } |
| 633 | if req.MaxRunSeconds != nil { |
| 634 | m.MaxRunSeconds = *req.MaxRunSeconds |
| 635 | } |
| 636 | if req.Hibernate != nil { |
| 637 | m.Hibernate = req.Hibernate |
| 638 | } |
| 639 | // App is stamped from the PATH, never from the body — there is no field |
| 640 | // for it above, and a manifest whose App disagreed with its own key would |
| 641 | // be a record family that lies about its identity. |
| 642 | m.Schema, m.App = 1, app |
| 643 | if err := provision.PutSidecarManifestIfMatch(ctx, s.cfg.Releases, acct.Sqid, app, *m, etag); err != nil { |
| 644 | if errors.Is(err, release.ErrCASConflict) { |
| 645 | jsonError(w, http.StatusConflict, |
| 646 | "sidecar settings changed while this request was in flight — try again") |
| 647 | return |
| 648 | } |
| 649 | log.Printf("console: api sidecars enable %s/%s: %v", acct.Sqid, app, err) |
| 650 | jsonError(w, http.StatusBadGateway, "could not enable sidecars — try again") |
| 651 | return |
| 652 | } |
| 653 | s.audit(acct, app, addr, "sidecar-enable", "enabled sidecars for "+app+" (cli)") |
| 654 | // The manifest is what the reconcile pass converges args, ceilings, |
| 655 | // hibernate and placement from on every pass, so a change here changes |
| 656 | // what boxes do — announce it. |
| 657 | s.bumpSerial(ctx) |
| 658 | writeJSON(w, http.StatusOK, map[string]any{ |
| 659 | "app": app, "pool": m.Pool, "channel": m.Channel, "args": m.Args, |
| 660 | "placement": m.PlacementOrDefault(), "regions": m.Regions, |
| 661 | "idle_seconds": m.IdleSeconds, "max_run_seconds": m.MaxRunSeconds, |
| 662 | "hibernate": m.HibernateOrDefault(), |
| 663 | }) |
| 664 | } |
| 665 | |