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 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).
40package console
41
42import (
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.
61const 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.
70const (
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.
79const 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.
95func (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.
120type 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.
136func (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.
169func (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.
217func (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.
406func (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.
476func (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.
532func (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