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 instance provisioning API: the verbs that turn "place an
2// instance" into a data write in the deployment bucket, so a signup path
3// holding no AWS credentials and no box access can provision one in
4// seconds. The record it writes is the whole authorization; the agent's
5// reconcile pass (internal/provision) is what turns it into a route, on its
6// own box only.
7//
8// This was the FIRST console family a non-human principal could speak to,
9// and for a while it carried its own resolver (apiInstancePrincipal) to do
10// it. That resolver is gone: service principals are a platform-wide idea now
11// (svcprincipal.go, spec 2026-08-18), and its rules — the sqid-only match,
12// the exact app binding, the `app:<sqid>/<app>` audit actor — live in the
13// shared path (apiAccountApp) where every family gets them. What is left
14// here is the same gate with the instance BUDGET bound to it.
15//
16// Two shapes of bearer reach it: a person's token, resolved through exactly
17// the same membership-proving lookup every other CLI verb uses, or a service
18// credential bound to one account and — for the legacy app tokens this
19// family minted — one app, which must match the path it is used on EXACTLY.
20// Every refusal before that proof is the family's byte-identical 404 — an
21// unknown account, someone else's account, an unclaimed app and a service
22// credential probing a foreign path are all one answer, so probing teaches
23// nothing.
24//
25// Spec: docs/superpowers/specs/2026-08-04-instance-provisioning-design.md
26// §7 (the verbs), §8 (the credential), §10 (the DNS seam), §11 (security).
27package console
28
29import (
30 "context"
31 "encoding/json"
32 "errors"
33 "log"
34 "net/http"
35 "os"
36 "slices"
37 "strconv"
38 "strings"
39 "time"
40
41 "github.com/carlosframework/platform/internal/gate"
42 "github.com/carlosframework/platform/internal/hostops"
43 "github.com/carlosframework/platform/internal/provision"
44 "github.com/carlosframework/platform/internal/registry"
45 "github.com/carlosframework/platform/internal/release"
46 "github.com/carlosframework/platform/internal/status"
47)
48
49// instanceStoreTimeout bounds every store round trip these handlers make,
50// the same 15s the rest of the CLI API uses: a wedged bucket costs one
51// request 15 seconds, not a console goroutine forever.
52const instanceStoreTimeout = 15 * time.Second
53
54// instanceListMax caps one list answer (spec §7): instance fleets can be
55// large, and a caller that needs more than 500 is not using this door for
56// what it is. The response says so with `truncated` rather than silently
57// serving a prefix.
58const instanceListMax = 500
59
60// instanceGate is the preamble every instance verb shares, in the order the
61// family fixed (handleAPIPromote): principal FIRST, budget second, then the
62// deployment's own readiness. The order is load-bearing — a rate limit
63// answered before the principal is proven would be a 429 where a 404
64// belongs, and "this path is rate limited" is itself the answer that an app
65// exists here.
66//
67// The principal proof is apiAccountApp's, which is to say the general one:
68// this family no longer resolves its own callers. The string it returns is
69// the audit actor either way — an address for a person, app:<sqid>/<app> for
70// a legacy app token exactly as before, service:<sqid>/<name> for a named
71// service credential.
72func (s *Server) instanceGate(w http.ResponseWriter, r *http.Request) (string, *Account, string, bool) {
73 return s.instanceGateFor(w, r, "instance")
74}
75
76// instanceGateFor is instanceGate with the noun its 429 uses. The BUDGET is
77// deliberately shared across every family that gates here — one credential
78// doing one kind of work, and one place for the ordering to live — but a
79// member creating sidecars should not be told they made too many "instance
80// requests" when they hit it. Only the sentence varies; nothing about the
81// order, the key or the limiter does.
82func (s *Server) instanceGateFor(w http.ResponseWriter, r *http.Request, noun string) (string, *Account, string, bool) {
83 return s.instanceGateSaying(w, r, noun, mainGateCopy())
84}
85
86// instanceGateSaying is instanceGateFor with the sentences of c.
87func (s *Server) instanceGateSaying(w http.ResponseWriter, r *http.Request, noun string, c gateCopy) (string, *Account, string, bool) {
88 principal, acct, app, ok := s.apiAccountAppSaying(w, r, s.store.HasAppIn, c)
89 if !ok {
90 return "", nil, "", false
91 }
92 // Keyed on the TOKEN, not the address (see cliInstanceBurst): the
93 // principal here is frequently an app, which has no address of its own,
94 // and a signup burst is the workload rather than the abuse.
95 if !s.cli.instances.allow(hashToken(bearer(r)), time.Now()) {
96 jsonError(w, http.StatusTooManyRequests, c.tooMany(noun))
97 return "", nil, "", false
98 }
99 // A console with no deployment bucket configured can answer none of
100 // these. Same shape as promote's signing-key check, and in the same
101 // place: after the gate, before anything reaches for the store.
102 if s.cfg.Releases == nil {
103 jsonError(w, http.StatusServiceUnavailable, c.noBucket)
104 return "", nil, "", false
105 }
106 return principal, acct, app, true
107}
108
109// instanceManifest reads the app's instances manifest and answers the
110// caller itself when it cannot be had. ok=false means a response has
111// already been written.
112//
113// CREATE is its only caller, and deliberately so: the manifest is policy
114// about what may be BROUGHT INTO existence (which domain, which pool, which
115// regions, which channel). Reads and deletes are about what already exists,
116// which no version of this manifest can retract.
117//
118// The two failures are kept apart on purpose: ABSENCE is an answer — the
119// feature does not exist for this app — while a read failure is the bucket
120// being unreachable. Reporting a hiccup as "instances are not enabled"
121// would tell an app its feature had been switched off.
122func (s *Server) instanceManifest(ctx context.Context, w http.ResponseWriter, acct *Account, app string) (*provision.Manifest, bool) {
123 m, err := provision.GetManifest(ctx, s.cfg.Releases, acct.Sqid, app)
124 switch {
125 case err == nil:
126 return m, true
127 case errors.Is(err, os.ErrNotExist):
128 // Membership (or binding) is already proven, so this may name its
129 // cause and the operator act that fixes it.
130 jsonError(w, http.StatusConflict,
131 "instances are not enabled for "+app+" — an operator writes apps/"+
132 acct.Sqid+"/"+app+"/instances.json (carlos instances enable)")
133 return nil, false
134 default:
135 log.Printf("console: api instances manifest %s/%s: %v", acct.Sqid, app, err)
136 jsonError(w, http.StatusBadGateway, "could not read instance settings")
137 return nil, false
138 }
139}
140
141// instanceListRecord wraps provision.Record with the console's own
142// staged_version join (Task 8, issue #78) rather than adding the field to
143// Record itself: Record is the wire shape CreateRecord persists to the
144// bucket, and staged_version is never written there — it is computed fresh
145// at every list call from status/boxes/*.json, so giving Record the field
146// would risk a future write path serializing a stale or zero value into a
147// stored record that this join was never meant to touch.
148type instanceListRecord struct {
149 provision.Record
150 // StagedVersion is registry.Route.Version as last reported by whichever
151 // box's adopt pass claimed this host, read from that box's own
152 // status/boxes/<label>.json (internal/status's bucket-private Metrics
153 // block — see its package doc). Empty both for a host no box has ever
154 // reported AND for one whose last-reporting box has gone stale (see
155 // loadBoxJoin below, which asks status.BoxStale the same question
156 // the operator fleet page does): fresh-ish for as long as some box keeps
157 // reporting it, but a box that has gone silent drops OUT of the join
158 // rather than going on serving that box's last-known version as if it
159 // were current. The deploy watch does NOT read this field.
160 StagedVersion string `json:"staged_version"`
161 // Upstreams is the per-upstream probe state the box's edge currently
162 // believes for this host (status.BoxMetrics.Upstreams), joined from the
163 // same snapshots and under the same staleness rule as StagedVersion — a
164 // box that has gone silent drops out rather than going on answering for
165 // upstreams it may no longer be probing. It is what lets `carlos
166 // instances` say WHICH upstream is down without a box session, and it is
167 // omitempty because that is the honest shape for the overwhelming
168 // majority of records: only a route with more than one addr (or an
169 // explicit probe block) is probed at all, so every single-upstream record
170 // in production carries no such key and parses exactly as it always did.
171 Upstreams []status.UpstreamState `json:"upstreams,omitempty"`
172 // Health is the same one-word state the Running tab's column shows,
173 // computed by instanceHealth below from the registry row joined with the
174 // metrics sweep. Additive on the wire (a client that never heard of it
175 // keeps parsing the record it always parsed) and annotation-grade: a
176 // record whose health could not be computed still lists, reading
177 // "Setting up" rather than failing the call.
178 Health string `json:"health"`
179 // ServedBy is where this host is OBSERVED to be served (issue #337):
180 // every box whose control/edges/<box>.json names it, with that box's AWS
181 // region — the same files the edges backhaul on, so this is what the
182 // platform itself routes by, not a guess. A list, not a scalar: an
183 // everywhere-class route is genuinely served on every box. Distinct from
184 // Record.Region, which is the REQUESTED placement code ("" =
185 // unconstrained, "fleet/<name>"), and absent for a host no file names.
186 ServedBy []servedBy `json:"served_by,omitempty"`
187 // HiddenGate shadows Record.Gate for encoding/json (a shallower field of
188 // the same name wins), so the bcrypt hash never leaves the console; it
189 // is always "" and omitted. Gated is what a member may know.
190 HiddenGate string `json:"gate,omitempty"`
191 Gated bool `json:"gated"`
192 // DBBytes is the on-disk size of this instance's SQLite database (file
193 // plus -wal) as its box last measured it (status.BoxMetrics.RouteDBBytes),
194 // under the same staleness rule as StagedVersion. Absent when no fresh
195 // box has sized it: not minted yet, no database file yet, or a route
196 // whose database lives somewhere the platform does not know about.
197 DBBytes *int64 `json:"db_bytes,omitempty"`
198 // Rollout is this host's rollout as its box last reported it
199 // (zero-downtime spec §9.1), joined under StagedVersion's staleness rule.
200 // Absent when no fresh box reports one: a box from before plan 5, one
201 // with CARLOS_SWAP off, or one gone dark. The deploy watch reads it.
202 Rollout *rolloutView `json:"rollout,omitempty"`
203 // UpdateReport is this host's update report as its box last gave it
204 // (status.BoxMetrics.Updates and UpdateSettings), joined under the same
205 // staleness and tenant rules as Rollout. It is named update_report
206 // because the embedded record already owns "updates" (its own setting).
207 UpdateReport *updatesJoinView `json:"update_report,omitempty"`
208 // SettingsDefault says the record and the app's policy together ask for
209 // today's behaviour (provision.EffectiveUpdates(...).Default()), so the
210 // CLI can tell an instance on a box that ignores settings from one that
211 // never asked for any.
212 SettingsDefault bool `json:"settings_default"`
213}
214
215// instanceHealth computes each record host's health word the same way the
216// Running tab does — registry row joined with the metrics sweep — so the
217// API, the CLI and the page can never disagree about an instance's state.
218// A record with no registry row yet is still provisioning: "Setting up".
219// Failures degrade to that same word rather than erroring the list: health
220// here is an annotation on the record, not the record.
221//
222// The tenant boundary is the recs slice and nothing else. recs arrives
223// already scoped to one account+app (provision.ListRecords under that app's
224// own prefix), and every lookup below is keyed on a host drawn FROM recs —
225// the registry read returns the whole box's routes, including other tenants',
226// and none of them can enter hosts, unitByHost or the answer. That matters
227// twice over: OwnerAppMetrics' allowlist is exactly the hosts and stamped
228// units it is handed, so a foreign unit name reaching unitByHost would widen
229// the metrics view past the caller's own app.
230func (s *Server) instanceHealth(ctx context.Context, recs []provision.Record) map[string]string {
231 out := make(map[string]string, len(recs))
232 for _, rec := range recs {
233 out[rec.Host] = "Setting up"
234 }
235 if s.cfg.Registry == "" {
236 // A console with no registry configured cannot know any route's
237 // state; opening "" would hand sqlite a throwaway temp DB and answer
238 // the same "Setting up" a page later, having created a file for it.
239 return out
240 }
241 reg, err := openRegistry(s.cfg.Registry)
242 if err != nil {
243 log.Printf("console: api instances health: registry %s: %v", s.cfg.Registry, err)
244 return out
245 }
246 routes, err := reg.List()
247 if err != nil {
248 log.Printf("console: api instances health: registry list: %v", err)
249 return out
250 }
251 byHost := make(map[string]registry.Route, len(routes))
252 for _, rt := range routes {
253 byHost[rt.Host] = rt
254 }
255 // routeFor is the join reduced to the routes that ANSWER for these
256 // records, built once and used by both loops so the two can never drift.
257 routeFor := make(map[string]registry.Route, len(recs))
258 var hosts []string
259 unitByHost := map[string]string{}
260 for _, rec := range recs {
261 rt, ok := byHost[rec.Host]
262 if !ok {
263 continue // no route minted yet: the record stays "Setting up"
264 }
265 // A row stamped to another app or account is not this record's route.
266 // The box's own reconcile pass says so — it refuses to converge such
267 // a row ("exists but was not minted by this pass — leaving it
268 // alone", reconcile.go) — and reporting it here would be worse than
269 // useless twice: the caller would read a neighbour's health word as
270 // its own instance's, and the neighbour's stamped Unit would enter
271 // unitByHost, widening OwnerAppMetrics' allowlist past this app's own
272 // units. An UNSTAMPED row (either column empty: hand-added, or minted
273 // before the column existed) still joins — refusing those would read
274 // live instances as forever "Setting up".
275 if (rt.App != "" && rt.App != rec.App) || (rt.Account != "" && rt.Account != rec.Account) {
276 continue
277 }
278 routeFor[rec.Host] = rt
279 hosts = append(hosts, rec.Host)
280 if rt.Unit != "" {
281 unitByHost[rec.Host] = rt.Unit
282 }
283 }
284 for h, word := range s.healthByHost(ctx, routeFor) {
285 out[h] = word
286 }
287 return out
288}
289
290// healthByHost is the metrics half of a health answer, shared by
291// instanceHealth (records joined to routes) and handleAPIRoutes (routes
292// directly, issue #64) so the two — and the Running tab, which uses the
293// same healthWord — can never disagree about an instance's state.
294//
295// routeFor arrives ALREADY SCOPED to the caller's own account and app.
296// This function performs no tenant check of its own and must never be
297// handed an unfiltered registry listing: OwnerAppMetrics' allowlist is
298// exactly the hosts and stamped units it is given, so a foreign unit name
299// reaching unitByHost below would widen the metrics view past the caller's
300// own app. The scoping lives in each caller, beside the reason that
301// caller's rows are the caller's to see.
302func (s *Server) healthByHost(ctx context.Context, routeFor map[string]registry.Route) map[string]string {
303 out := make(map[string]string, len(routeFor))
304 hosts := make([]string, 0, len(routeFor))
305 unitByHost := map[string]string{}
306 for h, rt := range routeFor {
307 hosts = append(hosts, h)
308 if rt.Unit != "" {
309 unitByHost[h] = rt.Unit
310 }
311 }
312 am, err := s.OwnerAppMetrics(ctx, hosts, unitByHost)
313 if err != nil {
314 // An unreadable sweep is not knowledge that these instances are
315 // down: every host reads as not-reporting below, which healthWord
316 // turns into "No readings" or "Not responding" per expectsReadings —
317 // the same answer the page gives when the sweep is unavailable.
318 log.Printf("console: health by host: metrics: %v", err)
319 am = nil
320 }
321 rep := make(map[string]bool, len(am))
322 for _, m := range am {
323 if m.Reporting() {
324 rep[m.Host] = true
325 }
326 }
327 for h, rt := range routeFor {
328 status := rt.Status
329 if status == "" {
330 status = "ready" // registry contract: empty status means ready
331 }
332 out[h] = healthWord(rt.Kind, status, rt.Hibernate, rep[h],
333 expectsReadings(rt.Provisioned, rt.Unit, rt.Backing))
334 }
335 return out
336}
337
338// boxJoin is everything the list annotates a record with from the FLEET's own
339// snapshots, gathered in one pass: the staged version and the per-upstream
340// probe table, both host-keyed.
341//
342// One struct and one loader rather than two functions, because the two joins
343// must vote on staleness with the same reading of the same objects: a second
344// LoadBoxes call could see a box go stale between them and answer a record's
345// version from a box whose upstream table it had just refused, which is a
346// disagreement no reader could diagnose.
347type boxJoin struct {
348 versions map[string]string
349 serving map[string]string
350 upstreams map[string][]status.UpstreamState
351 dbBytes map[string]int64
352 rollouts map[string]rolloutView
353 updates map[string]updatesJoinView
354}
355
356// updatesJoinView is one host's update report as a member sees it. account
357// and app are the report's tenant: the entry's own (status.HostUpdates.Account
358// and .App) when it names one, else the one the SAME box's rollout report
359// gives for the host (status.HostRollout.Account and .App), else none. They
360// decide whether the report may be joined (update).
361type updatesJoinView struct {
362 Desired string `json:"desired,omitempty"`
363 Reason string `json:"reason,omitempty"`
364 Detail string `json:"detail,omitempty"`
365 Baking string `json:"baking,omitempty"`
366 Major *int `json:"major,omitempty"`
367 Pending uint64 `json:"pending,omitempty"`
368 Completed uint64 `json:"completed,omitempty"`
369 CompletedFailed bool `json:"completed_failed,omitempty"`
370 // Enforced says the box applies update settings at all
371 // (status.BoxMetrics.UpdateSettings); a box from before them reports
372 // nothing, and an empty view must not read as "following its channel".
373 Enforced bool `json:"enforced"`
374 account, app string
375}
376
377// update is the join's answer for host, or nil when the box has nothing on
378// it or names another tenant for it. The cross-tenant rule is rollout's: a
379// box's report about a host never surfaces on another tenant's list or page.
380// A report is this account's and this app's only when its tenant is known
381// and is this one (Astra r1, R18). Owning the hostname now is not enough: a
382// snapshot stays fresh for minutes, and the host may have been another
383// tenant's when the box wrote it, on a platform box as on a fleet box. A
384// report with no known tenant yields whether the box enforces settings and
385// nothing else, so an old box still reads "settings not enforced" and nothing
386// about any instance crosses.
387func (j boxJoin) update(host, acct, app string) *updatesJoinView {
388 v, ok := j.updates[host]
389 if !ok {
390 return nil
391 }
392 if v.account == "" && v.app == "" {
393 return &updatesJoinView{Enforced: v.Enforced}
394 }
395 if v.account != acct || v.app != app {
396 return nil
397 }
398 return &v
399}
400
401// rolloutView is one host's rollout as a member sees it. account and app are
402// the report's own, kept so the join can refuse to cross tenants (rollout).
403type rolloutView struct {
404 Box string `json:"box"`
405 Installed string `json:"installed"`
406 Serving string `json:"serving,omitempty"`
407 Good string `json:"good,omitempty"`
408 Awake bool `json:"awake"`
409 Attempt string `json:"attempt,omitempty"`
410 AttemptBuild string `json:"attempt_build,omitempty"`
411 HeldBy string `json:"held_by,omitempty"`
412 Reason string `json:"reason,omitempty"`
413 ConfigPending bool `json:"config_pending,omitempty"`
414 // Transient is the box's word that the host's process is starting or
415 // stopping (status.HostRollout.Transient): neither asleep nor awake.
416 Transient bool `json:"transient,omitempty"`
417 // SchedulerIdle is the box's scheduler for this app: nothing queued,
418 // running or settling, first-wake observations included (spec §9.1's
419 // "a box is done" half).
420 SchedulerIdle bool `json:"scheduler_idle"`
421 // Unavailable is the reason code a box gives for being unable to arm its
422 // rollout scheduler (RolloutReport.Unavailable). When set, the view is
423 // only Box, Installed and this: the box reports no per-host state. The
424 // one exception is a host a box that declined the flag reports held by
425 // the failure book it honours (Task 10), whose view also carries Good,
426 // the attempt, HeldBy and its epoch.
427 Unavailable string `json:"unavailable,omitempty"`
428 account, app string
429 // heldEpoch is the box's epoch for HeldBy's restriction
430 // (status.HostRollout.HeldEpoch), and heldBook the failure book it
431 // counts in (HeldBook, final review round 1, finding 3). Neither is
432 // shown: the retry doors sign both into their grant (retryHostBuild).
433 heldEpoch uint64
434 heldBook string
435 // retryV is the retry protocol the box takes (status.HostRollout.RetryV):
436 // 0 is a box built before the host-retry log, which reads no grant
437 // there, so the retry doors write it none (retryHostBuild).
438 retryV int
439 // refused marks a view built from an Unavailable box, which has no
440 // account or app to check (hosts are unique; the caller filtered by app).
441 refused bool
442}
443
444// rollout is the join's answer for host, or nil unless the report is this
445// account's and this app's: a report is the box's word about a route, and a
446// route stamped to another app is not this record's (instanceHealth's rule).
447func (j boxJoin) rollout(host, acct, app string) *rolloutView {
448 v, ok := j.rollouts[host]
449 if !ok || (!v.refused && (v.account != acct || v.app != app)) {
450 return nil
451 }
452 return &v
453}
454
455// dbSize is the join's database size for host, nil when no fresh box
456// reported one — so the wire field is absent rather than a 0 that reads as
457// an empty database.
458func (j boxJoin) dbSize(host string) *int64 {
459 n, ok := j.dbBytes[host]
460 if !ok {
461 return nil
462 }
463 return &n
464}
465
466// loadBoxJoin builds boxJoin from every NON-STALE box's own
467// private Metrics block (status/boxes/*.json), through status.LoadBoxes —
468// the same loader the operator fleet page's HostRows reads through
469// (internal/console/view.go) — and status.BoxStale, the fleet's one
470// staleness rule (internal/status/fleet.go: "Every caller ... asks this
471// function, never its own subtraction"). A box that stopped reporting is
472// EXCLUDED from the join entirely rather than contributing its last-known
473// reading: the alternative would let a route adopted a week ago on a now-
474// dark box keep answering staged_version queries as if it were as current
475// as a box that reported thirty seconds ago, which is exactly the kind of
476// reassuring-but-wrong answer internal/status's whole design refuses to
477// give (see BoxStale's own doc). now is passed in, not read internally, so
478// this and BuildFleet/HostRows always vote on staleness using the request's
479// own clock rather than three independent time.Now() calls that could
480// disagree at a period boundary.
481//
482// A store that cannot be listed at all — not one corrupt object, the whole
483// read failing — degrades to "no staged versions known" rather than
484// answering a 500: this join is best-effort observability on top of the
485// list, never load-bearing for it. A nil store (a console with no
486// deployment bucket configured) is not reachable via handleAPIInstanceList
487// today — instanceGate already refuses those with a 503 before this ever
488// runs — but the nil check is kept here too so this helper degrades safely
489// for any future caller that reaches it before that gate.
490func loadBoxJoin(ctx context.Context, s release.Store, now time.Time) boxJoin {
491 j, _ := loadBoxJoinComplete(ctx, s, now)
492 return j
493}
494
495// loadBoxJoinComplete is loadBoxJoin that also says whether every box's
496// snapshot was read (status.LoadBoxesComplete): false when the listing
497// failed, was capped, or skipped an object it could not read. The join is
498// still best-effort; completeness is what lets a reader tell "no box reports
499// this host" from "the report may be the one that was not read" (Astra plan
500// 6 R1 #4: the deploy watch must not URL-poll on the second).
501func loadBoxJoinComplete(ctx context.Context, s release.Store, now time.Time) (boxJoin, bool) {
502 if s == nil {
503 return boxJoin{}, false
504 }
505 boxes, complete, err := status.LoadBoxesComplete(ctx, s)
506 if err != nil {
507 log.Printf("console: api instances: could not load status boxes for the list join: %v", err)
508 return boxJoin{}, false
509 }
510 return joinBoxes(boxes, now), complete
511}
512
513// loadRolloutJoin is the box join the instance list, the instance page and
514// the host-retry door share (Astra plan 6 R1 #6), so the three never
515// disagree about a host's rollout: the platform's boxes (status/boxes/),
516// then the rollout reports of the customer-fleet boxes of THIS account's
517// fleets (fleets/<name>/status/boxes/, the prefix systemBoxes reads). A
518// fleet another account owns is never read: fleet names are one flat
519// namespace across accounts, and its boxes are written by machines this
520// account does not control. boxJoin.rollout still checks the report's own
521// account and app.
522//
523// Hosts are assumed unique to one box. When two boxes name the same host (a
524// move in flight, a report not yet stale), the last platform box read wins,
525// as loadBoxJoin always did, and a platform box wins over any fleet box,
526// then the first fleet box read wins: the operator's boxes are the ones this
527// console trusts most.
528//
529// complete is false when a read failed on a source that could hold the
530// reports in question: any platform box snapshot, and the fleets relevant
531// names (Astra plan 6 R2: an unreadable snapshot on a fleet none of the
532// app's instances is placed on says nothing about the app, and must not
533// block its deploy watch's fallback). relevant nil counts every fleet of the
534// account. A registry that cannot be read counts only when relevant names
535// some fleet. A console with no route registry, or none yet, has no fleets
536// and is complete on its platform boxes alone. Every fleet of the account is
537// still READ, whatever relevant says: completeness is scoped, the join's
538// content is not.
539func (s *Server) loadRolloutJoin(ctx context.Context, acct string, now time.Time, relevant map[string]bool) (boxJoin, bool) {
540 j, complete := loadBoxJoinComplete(ctx, s.cfg.Releases, now)
541 if s.cfg.Releases == nil || s.cfg.Registry == "" {
542 return j, complete
543 }
544 counts := func(fleet string) bool { return relevant == nil || relevant[fleet] }
545 unreadable := relevant == nil || len(relevant) > 0
546 reg, err := s.openPassRegistry()
547 switch {
548 case errors.Is(err, os.ErrNotExist):
549 return j, complete // no registry file yet: no fleets
550 case err != nil:
551 log.Printf("console: rollout join %s: open registry: %v", acct, err)
552 return j, complete && !unreadable
553 }
554 fleets, err := reg.ListFleets()
555 if err != nil {
556 log.Printf("console: rollout join %s: list fleets: %v", acct, err)
557 return j, complete && !unreadable
558 }
559 for _, f := range fleets {
560 if f.Account != acct {
561 continue
562 }
563 boxes, fleetComplete, err := status.LoadBoxesComplete(ctx, release.NewPrefixStore(s.cfg.Releases, "fleets/"+f.Name+"/"))
564 if err != nil {
565 log.Printf("console: rollout join %s: fleet %s boxes: %v", acct, f.Name, err)
566 complete = complete && !counts(f.Name)
567 continue
568 }
569 complete = complete && (fleetComplete || !counts(f.Name))
570 j.joinFleetRollouts(boxes, now)
571 }
572 return j, complete
573}
574
575// recordFleets is the set of customer fleets that could hold reports for
576// recs: the fleet each record is placed on (its Region, "fleet/<name>"),
577// and either end of a move in flight. Never nil, so an app with no
578// fleet-placed record counts no fleet at all.
579func recordFleets(recs []provision.Record) map[string]bool {
580 out := map[string]bool{}
581 for _, rec := range recs {
582 places := []string{rec.Region}
583 if rec.Move != nil {
584 places = append(places, rec.Move.From, rec.Move.To)
585 }
586 for _, p := range places {
587 if f, ok := registry.FleetPlacement(p); ok {
588 out[f] = true
589 }
590 }
591 }
592 return out
593}
594
595// joinBoxes is loadBoxJoin's fold over snapshots already read, so a page that
596// has its boxes reads them once.
597func joinBoxes(boxes []status.Box, now time.Time) boxJoin {
598 j := boxJoin{versions: map[string]string{}, serving: map[string]string{}, upstreams: map[string][]status.UpstreamState{},
599 dbBytes: map[string]int64{}, rollouts: map[string]rolloutView{}}
600 for _, b := range boxes {
601 if b.Metrics == nil || status.BoxStale(b, now, status.SnapshotPeriod) {
602 continue
603 }
604 for host, v := range b.Metrics.RouteVersions {
605 j.versions[host] = v
606 }
607 for host, v := range b.Metrics.RouteServing {
608 j.serving[host] = v
609 }
610 for host, ups := range b.Metrics.Upstreams {
611 j.upstreams[host] = ups
612 }
613 for host, n := range b.Metrics.RouteDBBytes {
614 j.dbBytes[host] = n
615 }
616 j.foldRollout(b, true)
617 j.foldUpdates(b, true)
618 }
619 return j
620}
621
622// foldUpdates adds box b's update report to the join, under foldRollout's
623// override rule (a fleet box never replaces a host a platform box already
624// reported). Every host the box serves gets a view saying whether the box
625// enforces settings; the hosts it reports on carry the report. The tenant is
626// the report's own, else whatever the same box's rollout report names for
627// the host (updatesJoinView).
628func (j *boxJoin) foldUpdates(b status.Box, override bool) {
629 m := b.Metrics
630 enforced := m.UpdateSettings >= status.UpdateSettingsProtocol
631 if j.updates == nil {
632 j.updates = map[string]updatesJoinView{}
633 }
634 put := func(host string) {
635 if _, had := j.updates[host]; had && !override {
636 return
637 }
638 v := updatesJoinView{Enforced: enforced}
639 if m.Rollout != nil {
640 if hr, ok := m.Rollout.Hosts[host]; ok {
641 v.account, v.app = hr.Account, hr.App
642 }
643 }
644 if u, ok := m.Updates[host]; ok {
645 v.Desired, v.Reason, v.Detail, v.Baking, v.Major = u.Desired, u.Reason, u.Detail, u.Baking, u.Major
646 v.Pending, v.Completed, v.CompletedFailed = u.Pending, u.Completed, u.CompletedFailed
647 if u.Account != "" || u.App != "" {
648 v.account, v.app = u.Account, u.App
649 }
650 }
651 j.updates[host] = v
652 }
653 for host := range m.RouteVersions {
654 put(host)
655 }
656 for host := range m.Updates {
657 put(host)
658 }
659}
660
661// foldRollout adds box b's rollout report to the join. override false keeps
662// a host the join already has: the caller folds platform boxes first, and a
663// fleet box's word about a host never replaces a platform box's.
664func (j *boxJoin) foldRollout(b status.Box, override bool) {
665 ro := b.Metrics.Rollout
666 if ro == nil {
667 return
668 }
669 if j.rollouts == nil {
670 j.rollouts = map[string]rolloutView{}
671 }
672 put := func(host string, v rolloutView) {
673 if _, had := j.rollouts[host]; had && !override {
674 return
675 }
676 j.rollouts[host] = v
677 }
678 if ro.Unavailable != "" {
679 // The scheduler could not arm. Say so for every host this box
680 // serves, from the version it reports for it. A box that declined
681 // the flag but honours its failure book also reports the hosts the
682 // book holds (Task 10, plan review #5): those keep the code, and
683 // carry the hold and its epoch so a retry can be signed for them
684 // (retryHostBuild), under the report's own account and app.
685 for host, v := range b.Metrics.RouteVersions {
686 if _, held := ro.Hosts[host]; held {
687 continue
688 }
689 put(host, rolloutView{Box: b.Label, Installed: v, Unavailable: ro.Unavailable, refused: true})
690 }
691 for host, hr := range ro.Hosts {
692 put(host, rolloutView{Box: b.Label, Installed: hr.Installed, Good: hr.Good, Attempt: hr.Attempt, AttemptBuild: hr.AttemptBuild,
693 HeldBy: hr.HeldBy, Reason: hr.Reason, Unavailable: ro.Unavailable, account: hr.Account, app: hr.App, heldEpoch: hr.HeldEpoch, heldBook: hr.HeldBook, retryV: hr.RetryV})
694 }
695 return
696 }
697 // Plan 5 marks every host a failure record holds as failed. The
698 // record's own Host is the one the build failed on; the rest are held
699 // by it (spec §9.1), so their attempt is cleared and HeldBy kept. A
700 // hold-mode host left down (AttemptDown) stays as it is.
701 failedOn := map[[4]string]bool{}
702 for _, f := range ro.Failures {
703 failedOn[[4]string{f.Account, f.App, f.Build, f.Host}] = true
704 }
705 for host, hr := range ro.Hosts {
706 if hr.Attempt == status.AttemptFailed && !failedOn[[4]string{hr.Account, hr.App, hr.AttemptBuild, host}] {
707 hr.Attempt, hr.AttemptBuild = "", ""
708 }
709 a := ro.Apps[hr.Account+"/"+hr.App]
710 put(host, rolloutView{
711 Box: b.Label, Installed: hr.Installed, Serving: hr.Serving, Good: hr.Good, Awake: hr.Awake,
712 Attempt: hr.Attempt, AttemptBuild: hr.AttemptBuild, HeldBy: hr.HeldBy, Reason: hr.Reason,
713 ConfigPending: hr.ConfigPending, Transient: hr.Transient, SchedulerIdle: a.Queued == 0 && a.Running == 0 && a.Settling == 0,
714 account: hr.Account, app: hr.App, heldEpoch: hr.HeldEpoch, heldBook: hr.HeldBook, retryV: hr.RetryV,
715 })
716 }
717}
718
719// joinFleetRollouts folds customer-fleet boxes into the join: their rollout
720// reports only, and never over a host a platform box already reported. Their
721// route versions, upstreams and database sizes stay out, so the list's other
722// columns are the platform join's, as before. Staleness is the platform's
723// rule (status.BoxStale).
724func (j *boxJoin) joinFleetRollouts(boxes []status.Box, now time.Time) {
725 for _, b := range boxes {
726 if b.Metrics == nil || status.BoxStale(b, now, status.SnapshotPeriod) {
727 continue
728 }
729 j.foldRollout(b, false)
730 j.foldUpdates(b, false)
731 }
732}
733
734// handleAPIInstanceList answers `carlos instances list`: this app's records,
735// capped and honest about the cap, each carrying its best-effort
736// staged_version (Task 8, issue #78) — fresh-ish while some box keeps
737// reporting the host, dropped rather than stale once none does — and its
738// health word, plus the same q/health/limit/cursor controls the Running tab
739// has, applied SERVER-side so the CLI and the page filter identically.
740//
741// Every addition is additive on the wire and defaults to today's answer: no
742// params means the full `instanceListMax` page, `instances` and `truncated`
743// exactly as before, with `health` on each record and `total`/`counts`/
744// `next_cursor` alongside. Counts cover every record regardless of the
745// filter — they are the whole app's health story, and a filtered count could
746// not lead anywhere.
747func (s *Server) handleAPIInstanceList(w http.ResponseWriter, r *http.Request) {
748 _, acct, app, ok := s.instanceGate(w, r)
749 if !ok {
750 return
751 }
752 ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
753 defer cancel()
754 recs, truncated, skipped, err := provision.ListRecordsComplete(ctx, s.cfg.Releases, acct.Sqid, app, instanceListMax)
755 if err != nil {
756 log.Printf("console: api instances list %s/%s: %v", acct.Sqid, app, err)
757 jsonError(w, http.StatusBadGateway, "could not list instances")
758 return
759 }
760 relevant := recordFleets(recs)
761 if len(skipped) > 0 {
762 // A skipped record's placement is unknown, so every fleet the account
763 // has, and the platform, counts toward completeness (nil means all).
764 // Astra plan 6 R3: an unreadable record must not drop its fleet.
765 log.Printf("console: api instances list %s/%s: %d record(s) unreadable %v; every fleet counts toward the rollout join",
766 acct.Sqid, app, len(skipped), skipped)
767 relevant = nil
768 }
769 join, joinComplete := s.loadRolloutJoin(ctx, acct.Sqid, time.Now(), relevant)
770 served := loadEdgesJoin(ctx, s.cfg.Releases)
771 // The app's update policy, read once: an absent manifest is no policy,
772 // and a read error is logged and treated the same. The list never fails
773 // for it; SettingsDefault is then computed from the record alone.
774 var policy *provision.UpdatePolicy
775 if m, err := provision.GetManifest(ctx, s.cfg.Releases, acct.Sqid, app); err == nil {
776 policy = m.Updates
777 } else if !errors.Is(err, os.ErrNotExist) {
778 log.Printf("console: api instances list %s/%s: update policy: %v; settings_default computed from the records alone", acct.Sqid, app, err)
779 }
780 params := r.URL.Query()
781 q := parseInstanceQuery(params)
782 // The default stays the whole cap, so a client that sends no limit gets
783 // the answer it always got. A limit outside (0, instanceListMax) is
784 // ignored rather than refused — a bad page size is not worth an error
785 // where the honest full page is right there.
786 limit := instanceListMax
787 if raw := params.Get("limit"); raw != "" {
788 if n, err := strconv.Atoi(raw); err == nil && n > 0 && n < instanceListMax {
789 limit = n
790 }
791 }
792 health := s.instanceHealth(ctx, recs)
793 counts := map[string]int{}
794 matched := make([]instanceListRecord, 0, len(recs))
795 for _, rec := range recs {
796 h := health[rec.Host]
797 counts[h]++
798 if q.Q != "" && !strings.Contains(rec.Host, q.Q) {
799 continue
800 }
801 if q.Channel != "" && rec.Channel != q.Channel {
802 continue
803 }
804 if q.Health != "" && h != q.Health {
805 continue
806 }
807 matched = append(matched, instanceListRecord{Record: rec,
808 StagedVersion: join.versions[rec.Host], Health: h, Upstreams: join.upstreams[rec.Host],
809 ServedBy: served[rec.Host], Gated: rec.Gate != "", DBBytes: join.dbSize(rec.Host),
810 Rollout: join.rollout(rec.Host, acct.Sqid, app),
811 UpdateReport: join.update(rec.Host, acct.Sqid, app),
812 SettingsDefault: provision.EffectiveUpdates(rec, policy).Default()})
813 }
814 total := len(matched)
815 // provision.ListRecords returns records host-sorted and nothing above
816 // re-orders them, which is the whole basis of the cursor: "resume after
817 // this host" only means one page in a stable order.
818 start := 0
819 if after, ok := decodeInstanceCursor(q.Cursor); ok {
820 for i, rec := range matched {
821 if rec.Host > after {
822 start = i
823 break
824 }
825 start = i + 1
826 }
827 }
828 next := ""
829 end := start + limit
830 if end < total {
831 next = encodeInstanceCursor(matched[end-1].Host)
832 } else {
833 end = total
834 }
835 answer := map[string]any{
836 "instances": matched[start:end], "truncated": truncated,
837 "total": total, "counts": counts, "next_cursor": next,
838 }
839 // Additive and only when true, so a complete join's answer is the bytes
840 // it always was: the box join behind the rollout views could not read
841 // every box's report, so a record with no rollout may yet have one.
842 if !joinComplete {
843 answer["rollout_incomplete"] = true
844 }
845 if s.cfg.HostClaim {
846 pending, err := s.claimPendingViews(ctx, acct.Sqid, app, "instance")
847 if err != nil {
848 writeClaimResponseError(w, err)
849 return
850 }
851 answer["operations"] = pending
852 }
853 writeJSON(w, http.StatusOK, answer)
854}
855
856// handleAPIInstanceGet answers one record. A host with no record is the
857// byte-identical 404 — a record's existence is not a thing to probe for
858// either, and this way "no such instance" and "no such app" read alike.
859func (s *Server) handleAPIInstanceGet(w http.ResponseWriter, r *http.Request) {
860 _, acct, app, ok := s.instanceGate(w, r)
861 if !ok {
862 return
863 }
864 host := r.PathValue("host")
865 // Shape-checked before the key builder, not after. A {host} segment
866 // arrives percent-decoded, so "..%2f..%2fchannels" reaches this handler
867 // as a path with separators in it; RecordKey would happily join it. A
868 // host that cannot name a record is simply not found — the same answer
869 // a host that names no record gets.
870 if !provision.ValidHostShape(host) {
871 http.NotFound(w, r)
872 return
873 }
874 ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
875 defer cancel()
876 rec, err := provision.GetRecord(ctx, s.cfg.Releases, acct.Sqid, app, host)
877 if errors.Is(err, os.ErrNotExist) {
878 http.NotFound(w, r)
879 return
880 }
881 if err != nil {
882 log.Printf("console: api instances get %s/%s/%s: %v", acct.Sqid, app, host, err)
883 jsonError(w, http.StatusBadGateway, "could not read the instance")
884 return
885 }
886 writeJSON(w, http.StatusOK, instanceGetRecord{Record: *rec,
887 ServedBy: loadEdgesJoin(ctx, s.cfg.Releases)[host], Gated: rec.Gate != ""})
888}
889
890// instanceGetRecord is the single-record answer: the stored record plus the
891// same observed-placement join the list carries (issue #337). Only that —
892// the get has never carried staged_version/health, and growing it to the
893// list's full row is a separate decision from telling a consumer where one
894// instance lives.
895type instanceGetRecord struct {
896 provision.Record
897 ServedBy []servedBy `json:"served_by,omitempty"`
898 // HiddenGate shadows Record.Gate for encoding/json (a shallower field of
899 // the same name wins), so the bcrypt hash never leaves the console; it
900 // is always "" and omitted. Gated is what a member may know.
901 HiddenGate string `json:"gate,omitempty"`
902 Gated bool `json:"gated"`
903}
904
905// apiRecordView is a record as the write doors answer it: the whole record
906// with the gate hash shadowed and a gated flag in its place. Every handler
907// that used to writeJSON a bare record answers through this, so there is one
908// place — not five — where "the hash never leaves" has to hold.
909type apiRecordView struct {
910 provision.Record
911 HiddenGate string `json:"gate,omitempty"`
912 Gated bool `json:"gated"`
913 // PrivateStoreBucket is the bucket the create door provisioned for this
914 // instance under the app's private-storage policy (issue #412), empty on
915 // every other record and every other door. Not part of the record: it is
916 // a fact about what this one request did, and the record's own
917 // Environment is what names the store afterwards.
918 PrivateStoreBucket string `json:"private_store_bucket,omitempty"`
919 // RegionFrom and RegionName say how this create chose its region and
920 // what the place is called (spec §7): facts about this request, not
921 // part of the record.
922 RegionFrom string `json:"region_from,omitempty"`
923 RegionName string `json:"region_name,omitempty"`
924}
925
926func recordView(rec provision.Record) apiRecordView {
927 return apiRecordView{Record: rec, Gated: rec.Gate != ""}
928}
929
930// replicaFields is the #162 slice of the instance-create body. Role and
931// placement travel together or not at all — "a replica without a placement"
932// and "a placement without a replica role" are both half a decision, and
933// each half alone produces a record some other box will act on while the
934// writer thinks they asked for nothing. Replicas (the home's own placement
935// set) rides the same body so one call can provision the replica AND publish
936// where the account's replicas live.
937type replicaFields struct {
938 Role string `json:"role,omitempty"`
939 Replicas []string `json:"replicas,omitempty"`
940}
941
942// checkReplicaCreate is the create door's replica rails (#162). Returns nil
943// when the request carries no replica fields at all — the overwhelmingly
944// common shape, byte-identical to every create before this door existed —
945// and a refusal when it carries half of one. The home's own placement set is
946// canonicalized (sorted, deduped, ValidRegion-checked) in place, so the wire
947// column never carries a non-canonical order and the reconciler's converge
948// has nothing to re-canonicalize on the next pass.
949func checkReplicaCreate(req replicaFields, rec *provision.Record) *doorRefusal {
950 if req.Role == "" && len(req.Replicas) == 0 {
951 return nil
952 }
953 // A replica's placement IS its region — one placement, the create's own
954 // --region — so an empty one is half a replica: a mirror with nowhere to
955 // live. (The armed-placement gate below refuses one that names no real
956 // box; this refuses one that names nothing at all.)
957 if req.Role != "" && rec.Region == "" {
958 return &doorRefusal{http.StatusBadRequest,
959 "role replica needs a placement — pass the placement code the replica lives in as the create's region"}
960 }
961 // A replica does not carry a replica set of its own: the set is the
962 // HOME's opt-in naming where its replicas live, and a replica of a
963 // replica is not a shape the platform carries.
964 if req.Role != "" && len(req.Replicas) > 0 {
965 return &doorRefusal{http.StatusBadRequest,
966 "role replica with replicas: the placement set is the HOME record's opt-in — a replica does not carry one"}
967 }
968 // The home's own placement set is canonicalized (sorted, deduped,
969 // ValidRegion-checked) in place, so the wire column never carries a
970 // non-canonical order and the reconciler's converge has nothing to
971 // re-canonicalize on the next pass.
972 if len(req.Replicas) > 0 {
973 norm, err := provision.ValidReplicas(req.Replicas)
974 if err != nil {
975 return &doorRefusal{http.StatusBadRequest, err.Error()}
976 }
977 rec.Replicas = norm
978 }
979 return nil
980}
981
982// handleAPIInstanceCreate is the point of the feature: one create-only
983// write to the bucket, in spec §7's refusal order — gate, budget, manifest,
984// host, region, channel, write. Every field the record carries that policy
985// governs (pool, and the channel default) is stamped from the MANIFEST, not
986// from the request: the body below has no pool field to lie in at all.
987func (s *Server) handleAPIInstanceCreate(w http.ResponseWriter, r *http.Request) {
988 principal, acct, app, ok := s.instanceGate(w, r)
989 if !ok {
990 return
991 }
992 // A PERSON must be able to write the control plane's own instance
993 // record — that is the act that makes `carlos deploy -app console`
994 // resolve a host at all, and refusing it here would close the door this
995 // family exists to open. A SERVICE credential must not: the record it
996 // would write is the thing the reconciler then owns, and ownership of
997 // the console's route is not a CI token's to acquire (controlplane.go).
998 //
999 // Straight after instanceGate, which is to say after the family's
1000 // byte-identical 404 and before the manifest read: a non-member must
1001 // never learn from this door that an app here carries the flag.
1002 // principalFrom's ok is discarded for the reason handleAPIPromote's
1003 // comment gives — cliRoute stashed the principal before the handler
1004 // started, so it is already present.
1005 p, _ := principalFrom(r.Context())
1006 if s.refuseServiceControlPlane(w, p, acct.ID, app, "create its instance records") {
1007 return
1008 }
1009 ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
1010 defer cancel()
1011 m, ok := s.instanceManifest(ctx, w, acct, app)
1012 if !ok {
1013 return
1014 }
1015 var req struct {
1016 Host string `json:"host"`
1017 Region string `json:"region"`
1018 Channel string `json:"channel"`
1019 Message string `json:"message"`
1020 // The route SHAPE fields (route-records design §3, HA design §II).
1021 // All optional, all defaulting to the exact route this door minted
1022 // before they existed, and all gated by provision.ValidateShape
1023 // below — the same door the reconcile pass re-checks them at, because
1024 // neither side trusts the other about what is in the bucket.
1025 //
1026 // What is deliberately NOT here: pool (manifest policy), hibernate
1027 // and unit (operator columns with their own verbs), and anything
1028 // that could widen which HOST this app may claim — the suffix check
1029 // below is unchanged, so a "kind":"static" body cannot reach for a
1030 // hostname outside the app's own domain.
1031 //
1032 // Environment WAS on that list until 2026-09-04, and the omission
1033 // had a cost worth recording. Config in CARLOS is per-app, so a
1034 // second instance standing alongside a live one inherits the
1035 // default bundle — including whatever single origin the app's
1036 // framework compares the request's Origin header against. That
1037 // makes every POST to the new host fail closed: sign-in, magic
1038 // links, WebAuthn RP ID, the cookie Secure flag, all keyed off a
1039 // scalar that names the OTHER host. `carlos canary` mints exactly
1040 // such a route, so without this field the verb hands you a live
1041 // hostname that cannot accept a form submission for any app that
1042 // carries config, which is most of them.
1043 //
1044 // It is not an operator column in the way pool and unit are. Those
1045 // govern host resources the member has no standing over; a config
1046 // bundle is the app's own, written by the app's own `carlos env
1047 // --environment`, and binding one of an app's routes to one of its
1048 // own bundles is squarely inside what a member already controls.
1049 // ValidateShape (provision.go) checks the name, and the same
1050 // control-plane guard the PATCH door applies is applied above.
1051 Kind string `json:"kind"`
1052 Environment string `json:"environment"`
1053 Addrs []string `json:"addrs"`
1054 Health *provision.Health `json:"probe"`
1055 // The replica fields (#162), embedded so their wire keys sit beside
1056 // kind/environment's — one flat body, no nesting for the caller to
1057 // discover.
1058 replicaFields
1059 // The seed doors (seeded-instances spec §4, §5.1): a create that
1060 // starts from a sibling's replica or an uploaded snapshot. Both run
1061 // every check below, then leave this handler (apiinstanceseed.go,
1062 // apiinstanceimport.go).
1063 From *seedFromReq `json:"from,omitempty"`
1064 Import *seedImportReq `json:"import,omitempty"`
1065 }
1066 if !readJSON(w, r, &req) {
1067 return
1068 }
1069 message, ok := checkedActivityMessage(w, req.Message)
1070 if !ok {
1071 return
1072 }
1073 seeded := req.From != nil || req.Import != nil
1074 if seeded {
1075 if ref := seedOneDoorRefusal(req.From != nil, req.Import != nil); ref != nil {
1076 jsonError(w, ref.Status, ref.Msg)
1077 return
1078 }
1079 if ref := seedCommonRefusal(strings.TrimSpace(req.Kind), req.Addrs, strings.TrimSpace(req.Role), req.Replicas); ref != nil {
1080 jsonError(w, ref.Status, ref.Msg)
1081 return
1082 }
1083 }
1084 host := strings.TrimSpace(req.Host)
1085 // Path-traversal defence stated as such (spec §11): this host becomes a
1086 // bucket key segment here and, on the box, the <host>.sock and
1087 // <host>.db path segments. Nothing below runs until it is one label
1088 // plus the app's own domain.
1089 if !provision.ValidHost(host, m.Domain) {
1090 jsonError(w, http.StatusBadRequest,
1091 "invalid host — an instance host is one DNS label followed by ."+m.Domain)
1092 return
1093 }
1094 // A temporary app serves one static site at its own platform address
1095 // (spec §4, ruling 5): anything else is estate an automatic purge would
1096 // have to tear down, or a host outside what its expiry drops.
1097 if apex := s.apexHost(acct.Sqid, app); strings.TrimSpace(req.Kind) != "static" || apex == "" || host != apex {
1098 if s.refuseTemporary(w, acct, app, "serve anything but a static site at "+app+"'s own platform address") {
1099 return
1100 }
1101 }
1102 // The account's platform subdomain is a SHARED namespace: every app's
1103 // apex lives at <app>.<sqid>.<AppsDomain>, and a manifest pinned there
1104 // (the member-facing enable door) makes every sibling's apex a valid
1105 // host shape for THIS app. A record at a sibling's apex would mint a
1106 // real route there first, and the domains pass would then skip the
1107 // sibling's own apex mint forever (byHost) — one app quietly serving
1108 // another's public URL (review 2026-08-06 #1). Within-account only, but
1109 // reachable by an app token, so it is refused rather than trusted to
1110 // membership: a label naming another claimed app in the account belongs
1111 // to that app. Custom-domain manifests never enter apex space (ValidHost
1112 // pinned the suffix above), so this check cannot touch them.
1113 //
1114 // "Claimed" includes an app in the TRASH. A trashed app's name is
1115 // reserved until purge — CreateAppIn and RenameAppIn both refuse it — and
1116 // so is its apex: were the check live apps only, a sibling could take the
1117 // apex while the app sat in the trash and keep it across a restore, with
1118 // the domains pass deferring to the sibling's route for ever. That is
1119 // also where `carlos email events push` sends the restored app's
1120 // recipients' addresses (discussion 153). Only a purge frees the apex.
1121 // The name arriving after the instance — a create, rename or move — is
1122 // the other way in, and apex.go closes it.
1123 //
1124 // The same question answers a second one further down (the steering
1125 // default), so it is asked once, here, and named: is this manifest
1126 // pinned to the account's platform subdomain, or to a domain of the
1127 // app's own?
1128 platformNamespace := s.cfg.AppsDomain != "" && m.Domain == acct.Sqid+"."+s.cfg.AppsDomain
1129 // siblingLabel is set when this host sits at a label that is not the
1130 // app's own — the case re-checked after the record is written, below.
1131 siblingLabel := ""
1132 if platformNamespace {
1133 if label := strings.TrimSuffix(host, "."+m.Domain); label != app {
1134 siblingLabel = label
1135 live, trashed, err := s.store.AppNameState(acct.ID, label)
1136 if err != nil {
1137 log.Printf("console: api instances create sibling check %s/%s: %v", acct.Sqid, label, err)
1138 jsonError(w, http.StatusInternalServerError, "could not check the host — try again")
1139 return
1140 }
1141 switch {
1142 case live:
1143 jsonError(w, http.StatusConflict,
1144 host+" is app "+label+"'s platform apex — create it as an instance of "+label+" instead")
1145 return
1146 case trashed:
1147 jsonError(w, http.StatusConflict,
1148 host+" is app "+label+"'s platform apex, and "+label+" is in the trash — its apex stays reserved until it is purged")
1149 return
1150 }
1151 }
1152 }
1153 // The sibling check above names apps; this one names HOSTS. Records are
1154 // keyed per app, so another app of this account may already hold the very
1155 // host this one is asking for without being called by its label: canary
1156 // "a" of app "b-c" and canary "a-b" of app "c" are both a-b-c (the flat
1157 // canary host, discussion 207). The provisioning pass would keep one
1158 // record and skip the other, so the loser would look created and serve
1159 // nothing. Refused here, for every host, on the same fail-closed terms as
1160 // the apex checks; a host this app already holds falls through to the
1161 // create's own "already exists".
1162 //
1163 // KNOWN LIMIT, filed as amadan discussion 232: this is a check, then a
1164 // write, and CreateRecord's conditional write protects only this app's
1165 // own account/app/host key. Two creates for one host from two different
1166 // apps, racing, can both pass and both be written; the provisioning pass
1167 // then keeps one and skips the other ("duplicate host"). Closing that
1168 // needs host-scoped atomic ownership, which is a gap in the instance door
1169 // that predates the flat canary host and is not built here. The
1170 // sequential case, the common one, is closed.
1171 switch hold, err := s.hostHolder(ctx, acct, host, app); {
1172 case err != nil:
1173 log.Printf("console: api instances create %s/%s: host holder check %s: %v", acct.Sqid, app, host, err)
1174 jsonError(w, http.StatusInternalServerError, "could not check the host — try again")
1175 return
1176 case hold != nil:
1177 jsonError(w, http.StatusConflict, hostHeldSentence(host, hold))
1178 return
1179 }
1180 // An attached alias has no record and may have no route yet, so the
1181 // holder check cannot see it; its authoritative claim is one read.
1182 switch hold, err := s.hostClaimHolder(ctx, acct, host, app); {
1183 case err != nil:
1184 log.Printf("console: api instances create %s/%s: host claim check %s: %v", acct.Sqid, app, host, err)
1185 jsonError(w, http.StatusInternalServerError, "could not check the host — try again")
1186 return
1187 case hold != nil:
1188 jsonError(w, http.StatusConflict, claimHeldSentence(host, hold))
1189 return
1190 }
1191 // A fleet-placed app (customer-fleets plan, `carlos apps place`) governs
1192 // every one of its instances' Region uniformly — spec's "one flag on the
1193 // app, all its instances follow" (appPlacement, fleets.go) — so when a
1194 // placement exists it OVERRIDES whatever the request body sent, and the
1195 // ordinary region/allow-list validation below never runs at all: a
1196 // "fleet/<name>" value would fail provision.ValidRegion's shape anyway
1197 // (no "/"), which is exactly why this has to be a check made BEFORE
1198 // that validation, not alongside it.
1199 reg, err := openRegistry(s.cfg.Registry)
1200 if err != nil {
1201 log.Printf("console: api instances create %s/%s: open registry: %v", acct.Sqid, app, err)
1202 jsonError(w, http.StatusBadGateway, "could not check this app's placement")
1203 return
1204 }
1205 placed, err := s.appPlacement(reg, acct, app)
1206 if err != nil {
1207 log.Printf("console: api instances create %s/%s: check placement: %v", acct.Sqid, app, err)
1208 jsonError(w, http.StatusBadGateway, "could not check this app's placement")
1209 return
1210 }
1211 // The app's region is the default for a create that names none
1212 // (nearest-region spec §7, R2). It runs BEFORE every check an explicit
1213 // region meets, and never for a fleet-placed app, a pooled manifest, a
1214 // replica, or anything but a socket-backed instance.
1215 raw := strings.TrimSpace(req.Region)
1216 regionFrom := ""
1217 if raw != "" {
1218 regionFrom = "request"
1219 }
1220 if raw == "" && placed == "" && m.Pool == "" && strings.TrimSpace(req.Role) == "" &&
1221 (provision.Record{Kind: strings.TrimSpace(req.Kind), Addrs: req.Addrs}).DerivesPaths() {
1222 appRegion, _, err := s.appRegion(acct.ID, app)
1223 if err != nil {
1224 log.Printf("console: api instances create %s/%s: read app region: %v", acct.Sqid, app, err)
1225 jsonError(w, http.StatusBadGateway, msgAppRegionUnreadable)
1226 return
1227 }
1228 if appRegion != "" {
1229 raw, regionFrom = appRegion, "app"
1230 }
1231 }
1232 if placed != "" {
1233 regionFrom = "fleet"
1234 }
1235 // The home region's NAME normalizes to the record's "" before any
1236 // validation (issue #346): pickers speak the code, records keep the
1237 // unconstrained value reconcile has always scoped by.
1238 region := s.homeRegionIn(raw)
1239 // An APP-DERIVED region needs positive eligibility — the secrets key
1240 // included (R9) — and never rides the dormant gate (codex R2-2).
1241 if regionFrom == "app" {
1242 snap, err := s.liveEdges(ctx)
1243 if err != nil {
1244 log.Printf("console: api instances create %s/%s: read edges: %v", acct.Sqid, app, err)
1245 jsonError(w, http.StatusServiceUnavailable, msgAppRegionUnconfirmed(s.homeRegionOut(region)))
1246 return
1247 }
1248 // Incomplete evidence for a non-home region is "could not confirm"
1249 // (C28, the same answer as a failed listing), not C16: the region
1250 // may well be fine, and a box that could not be read is the one
1251 // that may not be (re-review r1). The home needs no evidence.
1252 if region != "" && !snap.EvidenceComplete() {
1253 log.Printf("console: api instances create %s/%s: incomplete edges or snapshot read, region %s unconfirmed", acct.Sqid, app, region)
1254 jsonError(w, http.StatusServiceUnavailable, msgAppRegionUnconfirmed(s.homeRegionOut(region)))
1255 return
1256 }
1257 cands := s.regionCands(snap)
1258 reason := "absent"
1259 if c, ok := findCandidate(cands, region); ok {
1260 reason = c.unavailableReason()
1261 }
1262 // Region HA (spec 2 §7, H27): the region is judged by the eligible
1263 // set the create door's chooser will choose from, never by every
1264 // sibling's own answer, and an empty set is the door's no-room
1265 // refusal, alert included.
1266 if x := s.placementChooser(""); x != nil {
1267 switch boxes, err := x.PlaceableBoxes(ctx, s.cfg.claimPlacementRegion(region)); {
1268 case errors.Is(err, hostops.ErrContract):
1269 reason = "absent"
1270 case err != nil:
1271 log.Printf("console: api instances create %s/%s: region %s placeable boxes: %v", acct.Sqid, app, region, err)
1272 jsonError(w, http.StatusServiceUnavailable, msgAppRegionUnconfirmed(s.homeRegionOut(region)))
1273 return
1274 case len(boxes) == 0:
1275 writeClaimResponseError(w, s.noRoom(ctx, region))
1276 return
1277 default:
1278 reason = ""
1279 }
1280 }
1281 // Each list names only what an explicit request for this same
1282 // instance would pass (codex plan-R4 2): instanceChoices.
1283 if reason != "" {
1284 jsonError(w, http.StatusBadRequest, msgAppRegionUnavailable(s.homeRegionOut(region), reason, s.instanceChoices(ctx, m.Regions, cands, region)))
1285 return
1286 }
1287 if !instanceRegionAllowed(m.Regions, region) {
1288 jsonError(w, http.StatusBadRequest, msgAppRegionNotAllowed(s.homeRegionOut(region), s.instanceChoices(ctx, m.Regions, cands, region)))
1289 return
1290 }
1291 for _, rep := range req.Replicas {
1292 if s.homeRegionIn(strings.TrimSpace(rep)) == region {
1293 jsonError(w, http.StatusBadRequest, msgReplicaOverlap(s.homeRegionOut(region)))
1294 return
1295 }
1296 }
1297 }
1298 // The replica rails (#162) run BEFORE the region allow-list, because
1299 // half a decision is a different refusal from "wrong region": a role
1300 // with no placement, or a set on a replica, is a shape error the
1301 // caller can fix from the message alone, and the record must not
1302 // carry either into the shape validation below. The rails also
1303 // canonicalize the placement set, so the record ValidateShape sees —
1304 // and the bucket receives — is the canonical form.
1305 probeRec := provision.Record{Kind: strings.TrimSpace(req.Kind), Region: region, Role: strings.TrimSpace(req.Role)}
1306 if ref := checkReplicaCreate(req.replicaFields, &probeRec); ref != nil {
1307 jsonError(w, ref.Status, ref.Msg)
1308 return
1309 }
1310 if placed != "" {
1311 // A fleet-placed app governs every instance's Region uniformly, and
1312 // the armed/hosting gate below is skipped for it — the fleet is the
1313 // placement, validated by its own rail. A replica under that regime
1314 // would land wherever the fleet says, unchecked by anything that
1315 // knows what a replica placement means (#162): refused, not guessed.
1316 // Delete the app's placement first, or place the replica's account
1317 // on a fleet that spans the placement you want.
1318 if probeRec.Role == "replica" {
1319 jsonError(w, http.StatusBadRequest,
1320 "role replica on a fleet-placed app: the app's fleet placement ("+placed+") governs every instance's region, and a replica's placement must be an armed, hosting platform region — remove the app's placement first")
1321 return
1322 }
1323 region = placed
1324 } else if !provision.ValidRegion(region) || !instanceRegionAllowed(m.Regions, region) {
1325 msg := "invalid region"
1326 if len(m.Regions) > 0 {
1327 msg = "region " + region + " is not one this app runs in — allowed: " + strings.Join(m.Regions, ", ")
1328 }
1329 jsonError(w, http.StatusBadRequest, msg)
1330 return
1331 }
1332 // The armed-placement gate (issue #329): a non-empty region that names
1333 // no armed box is an instance NOTHING will ever mint — the record would
1334 // sit dark forever, which is worse than a refusal that lists the real
1335 // choices. Placed apps skipped (their value is a fleet, validated by its
1336 // own rail above); the unconstrained "" region skipped (it means "the
1337 // deployment's home", which always exists); and the whole gate is
1338 // dormant until some box publishes a placement code, so a console rolled
1339 // ahead of its fleet changes nothing.
1340 //
1341 // The gate has TWO bars, and which one applies depends on what is being
1342 // created. Every kind needs the region to be REACHABLE — a box nothing
1343 // can dial homes nothing. Only a socket-backed instance additionally
1344 // needs the region to HOST, because only that kind is spawned by an
1345 // activator: a static route is served from bucket bytes by every armed
1346 // edge with no process at all, and an addr-backed service proxies to a
1347 // process the platform never starts. Gating those two on hosting would
1348 // refuse a perfectly serveable static site in a region for want of a
1349 // hibernation bucket it would never have used. The predicate is
1350 // provision.Record's own (DerivesPaths), asked of a probe record rather
1351 // than restated here, so it cannot drift from what reconcile will mint.
1352 if placed == "" && region != "" {
1353 // Region HA (spec 2 §7, H27): a region with no box that has room is
1354 // the no-room refusal, before this gate answers it as a 400.
1355 if err := s.roomIn(ctx, region, m.Pool); err != nil {
1356 writeClaimResponseError(w, err)
1357 return
1358 }
1359 probe := provision.Record{Kind: strings.TrimSpace(req.Kind), Addrs: req.Addrs, Role: probeRec.Role}
1360 caps, published := placements(ctx, s.cfg.Releases)
1361 // The replica placement rides the same gate as the record's own
1362 // region (#162): a replica named into a placement no box announces
1363 // is a mirror nothing will ever provision — an order ledger in a
1364 // region that does not exist. Dormant with the rest of the gate
1365 // until a box publishes a placement code.
1366 if published && probe.Role == "replica" {
1367 switch {
1368 case !caps[region].Reachable:
1369 jsonError(w, http.StatusBadRequest,
1370 "replica placement "+region+" is not armed in this deployment — armed: "+armedCodes(reachableSet(caps)))
1371 return
1372 case !caps[region].Hosting:
1373 jsonError(w, http.StatusBadRequest,
1374 "replica placement "+region+" is reachable but cannot host an instance — no box there has its hibernation activator configured. Regions that can: "+armedCodes(hostingSet(caps)))
1375 return
1376 }
1377 }
1378 switch {
1379 case !published:
1380 // Dormant: the fleet predates the placement field.
1381 case !caps[region].Reachable:
1382 jsonError(w, http.StatusBadRequest,
1383 "region "+region+" is not armed in this deployment — armed: "+armedCodes(reachableSet(caps)))
1384 return
1385 case probe.DerivesPaths() && !caps[region].Hosting:
1386 jsonError(w, http.StatusBadRequest,
1387 "region "+region+" is reachable but cannot host an instance — no box there has its hibernation activator configured. Regions that can: "+armedCodes(hostingSet(caps)))
1388 return
1389 }
1390 // R9: a socket-backed instance or a replica needs its config,
1391 // secrets and store binding rendered on the box, which only a box
1392 // holding the deployment's secrets key can do. Refused only when a
1393 // CURRENT writer proves the region keyless; unknown keeps today's
1394 // behaviour, so roll order never refuses a working region.
1395 if probe.DerivesPaths() || probe.Role == "replica" {
1396 if snap, err := s.liveEdges(ctx); err == nil {
1397 if c, ok := findCandidate(s.regionCands(snap), region); ok && c.NoKeyProven {
1398 jsonError(w, http.StatusBadRequest, msgNoSecretsKey(s.homeRegionOut(region)))
1399 return
1400 }
1401 }
1402 }
1403 }
1404 channel := strings.TrimSpace(req.Channel)
1405 if channel == "" {
1406 resolved, ref := s.instanceChannel(ctx, acct.Sqid, app, m)
1407 if ref != nil {
1408 jsonError(w, ref.Status, ref.Msg)
1409 return
1410 }
1411 channel = resolved
1412 }
1413 if !release.ValidChannel(channel) {
1414 jsonError(w, http.StatusBadRequest, "invalid channel name")
1415 return
1416 }
1417 if ref := s.staticHeadRefusal(ctx, acct.Sqid, app, channel, strings.TrimSpace(req.Kind), host); ref != nil {
1418 jsonError(w, ref.Status, ref.Msg)
1419 return
1420 }
1421 rec := provision.Record{
1422 Schema: 1,
1423 Host: host,
1424 Slug: strings.TrimSuffix(host, "."+m.Domain),
1425 App: app,
1426 Account: acct.Sqid,
1427 Pool: m.Pool,
1428 Region: region,
1429 Channel: channel,
1430 // UTC, like every other timestamp written to the bucket: these
1431 // records are read on boxes in other regions.
1432 CreatedAt: time.Now().UTC(),
1433 CreatedBy: principal,
1434 Kind: strings.TrimSpace(req.Kind),
1435 Environment: strings.TrimSpace(req.Environment),
1436 Addrs: req.Addrs,
1437 Health: req.Health,
1438 }
1439 // The replica rails already ran above (before the region allow-list),
1440 // on a probe record; the canonicalized answers carry onto the real one
1441 // here. The role lands only as "replica" — checkReplicaCreate refused
1442 // everything else before this point.
1443 rec.Role = probeRec.Role
1444 rec.Replicas = probeRec.Replicas
1445 // A static platform host is HA by default. Static is the one serving
1446 // class every platform box materializes unconditionally
1447 // (provision.everywhereClass): no unit, no process, content read from
1448 // the bucket channel pointer, so a far edge serves it with no home box
1449 // and no backhaul. The content half of that is already true — every
1450 // armed edge holds the site and its account wildcard cert. What was
1451 // missing is DNS: with no opt-in the host resolves to one edge, so
1452 // several healthy boxes hold the site and every visitor is sent to one
1453 // of them.
1454 //
1455 // Scoped to the platform namespace deliberately. internal/steering's
1456 // steeredHosts only ever builds latency records for hosts under the
1457 // apps domain, so defaulting a custom domain's record to "latency"
1458 // would write an opt-in no pass will ever honour (#306's shape); a
1459 // custom domain reaches the edges by its owner's own DNS, and the
1460 // console's attach guidance already tells them to CNAME the steering
1461 // hostname.
1462 //
1463 // Scoped to static deliberately too: a home-boxed route reaches a far
1464 // edge by backhaul, a different trade with a different cost, not one to
1465 // make silently for every app on the deployment.
1466 //
1467 // And it honours v1's pool rail, the same one the PATCH door enforces
1468 // with a 409 (steering needs the shared pool, because a pool's own
1469 // edges are not the deployment's). A default that quietly wrote what
1470 // the update door refuses would be the worse kind of inconsistency: the
1471 // record would carry an opt-in nobody could have asked for through the
1472 // front door, and the converger would skip it anyway.
1473 //
1474 // This is a value at MINT, never a converge: `carlos steering --app x
1475 // --host y off` is the escape hatch, and nothing re-stamps a record
1476 // that already exists. The opt-in is inert on a deployment that has not
1477 // set CARLOS_STEERING_ZONE_ID, so a single-box deployment states the
1478 // intent harmlessly and becomes HA the day it arms a second edge.
1479 if rec.Kind == "static" && platformNamespace && rec.Pool == "" {
1480 rec.Steering = "latency"
1481 }
1482 // The app's private-storage policy (issue #412, spec §6): every instance
1483 // gets a config environment of its own, named for the host's first
1484 // label, with an object store bound to it.
1485 //
1486 // An EXPLICIT environment in the request wins, and the policy still
1487 // applies to it. The policy fills a blank; it does not seize the axis.
1488 // What it promises is that this record's bundle carries the store
1489 // coordinates — not that the bundle has any particular name — so
1490 // honouring the caller's own name keeps `carlos canary` working on such
1491 // an app, and two instances naming one environment share its store
1492 // exactly as they already share its config (#408 §5: sharing is
1493 // visible, not refused).
1494 //
1495 // Stamped BEFORE ValidateShape so the shape rail sees the same
1496 // environment the box will, and before the ceiling and the walk below so
1497 // neither can be asked about a record that was never legal.
1498 //
1499 // A seeded instance takes no explicit environment under the policy: its
1500 // store must be new and empty, and a named one may be shared (spec §4).
1501 if seeded && m.PrivateStorage && strings.TrimSpace(req.Environment) != "" {
1502 s.refuseSeededEnvironment(w)
1503 return
1504 }
1505 if m.PrivateStorage {
1506 env, derived := rec.Environment, false
1507 if env == "" {
1508 env, derived = rec.Slug, true
1509 }
1510 if ref := s.instanceStoreNames(acct, app, env, derived); ref != nil {
1511 jsonError(w, ref.Status, ref.Msg)
1512 return
1513 }
1514 rec.Environment = env
1515 }
1516 // The shape rails, at the door — kind vocabulary, one upstream shape per
1517 // record, addr syntax, and the combinations that would wedge a host (a
1518 // static route with an upstream, a hibernating addr-backed one). The
1519 // message is ValidateShape's own: it names WHICH upstream is wrong, which
1520 // is the only thing the caller can act on, and it is the identical
1521 // sentence the box's reconcile pass would print if a record carrying this
1522 // shape ever reached it from another writer.
1523 if err := provision.ValidateShape(rec); err != nil {
1524 jsonError(w, http.StatusBadRequest, err.Error())
1525 return
1526 }
1527 if s.refuseMemberUpstreams(w, p, rec.Addrs, nil) {
1528 return
1529 }
1530 // THE STORE IS PROVISIONED BEFORE THE RECORD IS WRITTEN, and a failure
1531 // here means no record at all (issue #412, spec §7). This ordering is
1532 // the load-bearing decision of the feature, so it is argued here rather
1533 // than assumed:
1534 //
1535 // Record first, and a failed walk leaves an instance whose bundle has no
1536 // store coordinates. It wakes, finds none, and runs on local disk — the
1537 // exact outcome the policy was declared to prevent, arrived at silently,
1538 // with no verb to repair it (there is no `instances create --resume`).
1539 //
1540 // Store first, and a failed record write leaves a store for an
1541 // environment no instance is bound to. That is visible — `store status`
1542 // lists every store of the app — and, because the environment is DERIVED
1543 // FROM THE HOST, a retry of the same host reuses it exactly: GrantIn is
1544 // idempotent, so the second attempt short-circuits and costs nothing.
1545 //
1546 // So: no instance record is ever created under this policy without its
1547 // store already provisioned and delivered.
1548 privateBucket := ""
1549 if m.PrivateStorage {
1550 if ref := s.storeCeiling(ctx, acct, app, rec.Environment); ref != nil {
1551 jsonError(w, ref.Status, ref.Msg)
1552 return
1553 }
1554 }
1555 // The leftover-replica rail (#199, createleftovers.go): a host still
1556 // holding a deleted instance's replica or move staging is refused, so
1557 // the new record cannot inherit that database. Last of the checks, after
1558 // every validation above, and BEFORE the store walk below, so a refused
1559 // create never provisions a bucket either.
1560 if ref := s.leftoverReplicaRefusal(r.Context(), leftoverCreate{
1561 logTag: "api instances create " + acct.Sqid + "/" + app,
1562 host: host, region: region, pool: rec.Pool,
1563 exists: func(ctx context.Context) (bool, error) {
1564 _, err := provision.GetRecord(ctx, s.cfg.Releases, acct.Sqid, app, host)
1565 if errors.Is(err, os.ErrNotExist) {
1566 return false, nil
1567 }
1568 return err == nil, err
1569 },
1570 // The same two answers the CreateRecord call below gives.
1571 duplicate: doorRefusal{http.StatusConflict, "instance " + host + " already exists"},
1572 readFailed: doorRefusal{http.StatusBadGateway, "could not create the instance — try again"},
1573 taken: host + " still holds data from a deleted instance. Choose another address, or ask an operator to purge it.",
1574 unsure: "could not check whether " + host + " is free. Try again.",
1575 }); ref != nil {
1576 jsonError(w, ref.Status, ref.Msg)
1577 return
1578 }
1579 // Plans 3a: an instance is a running thing. Checked once here, so plain,
1580 // seeded and imported creates all meet the cap, under the account lock.
1581 defer s.planLock(acct)()
1582 if err := s.checkRunningThings(r.Context(), acct, 1); err != nil {
1583 if isPlanCap(err) {
1584 writeJSON(w, http.StatusConflict, map[string]any{"error": err.Error(), "code": "cap"})
1585 return
1586 }
1587 log.Printf("console: api instances create %s/%s: plan cap: %v", acct.Sqid, app, err)
1588 jsonError(w, http.StatusBadGateway, "could not create the instance — try again")
1589 return
1590 }
1591 // Every ordinary check has passed: a seed door takes over from here, and
1592 // writes its own record (apiinstanceseed.go).
1593 if req.From != nil {
1594 s.createFrom(w, r, seedCreate{acct: acct, app: app, principal: principal, message: message, m: m,
1595 rec: rec, placed: placed, regionFrom: regionFrom, siblingLabel: siblingLabel}, *req.From)
1596 return
1597 }
1598 if req.Import != nil {
1599 s.createImport(w, r, seedCreate{acct: acct, app: app, principal: principal, message: message, m: m,
1600 rec: rec, placed: placed, regionFrom: regionFrom, siblingLabel: siblingLabel}, *req.Import)
1601 return
1602 }
1603 // A fresh budget for the write and its rollback: validation and the
1604 // rail's listings may have spent most of the original one.
1605 cancel()
1606 ctx, cancel = context.WithTimeout(r.Context(), instanceStoreTimeout)
1607 defer cancel()
1608 buildClaim := func(now time.Time, acquisition string) (hostops.Record, error) {
1609 rec.CreatedAt = time.Time{}
1610 rec.Acquisition = acquisition
1611 region := s.cfg.claimPlacementRegion(rec.Region)
1612 prepared, err := hostops.PrepareInstance(rec, now, region)
1613 if err == nil {
1614 rec.CreatedAt = now
1615 }
1616 return prepared, err
1617 }
1618 // Region HA (spec 2 §7, H27): the box is chosen before the door's first
1619 // write, the environment binding below, so a create refused for want of
1620 // room changes nothing. beginClaimCreate keeps this choice.
1621 createContext := r.Context()
1622 if s.cfg.HostClaim {
1623 var chooseErr error
1624 if createContext, chooseErr = s.chooseCreateFirst(createContext, host, buildClaim); chooseErr != nil {
1625 writeClaimResponseError(w, chooseErr)
1626 return
1627 }
1628 }
1629 // An environment named without a store walk of its own takes the store
1630 // from a seed that made it (final review F3, seedenvbind.go).
1631 if !m.PrivateStorage && !s.bindEnvironmentCreate(ctx, w, rec) {
1632 return
1633 }
1634 var claimRequest *claimCreateRequest
1635 if s.cfg.HostClaim {
1636 var createErr error
1637 claimRequest, createErr = s.beginClaimCreate(createContext, acct, app, host, principal, buildClaim)
1638 if createErr != nil {
1639 writeClaimResponseError(w, createErr)
1640 return
1641 }
1642 defer claimRequest.Close()
1643 createContext = claimRequest.Context
1644 }
1645 if m.PrivateStorage {
1646 // r.Context(), not ctx: the walk takes storeGrantTimeout, which is
1647 // longer than this handler's own deadline.
1648 var storeResponse http.ResponseWriter = w
1649 if claimRequest != nil {
1650 storeResponse = &claimPrivateResponse{header: make(http.Header)}
1651 }
1652 storeRec, ok := s.instanceStoreProvision(createContext, storeResponse, acct, app, rec.Environment, principal)
1653 if !ok {
1654 if claimRequest != nil {
1655 writeJSON(w, http.StatusAccepted, map[string]string{"host": host, "operation_id": claimRequest.Observed.Claim.Op.ID, "phase": claimRequest.Observed.Claim.Op.Kind})
1656 }
1657 return
1658 }
1659 privateBucket = storeBucketName(storeRec)
1660 // And a fresh deadline for what is left. The walk can have consumed
1661 // every second of the handler's original budget, and the record
1662 // write below inheriting a spent context would fail immediately
1663 // after the bucket had already been paid for — the one shape of
1664 // failure this whole ordering exists to avoid.
1665 cancel()
1666 var recancel context.CancelFunc
1667 ctx, recancel = context.WithTimeout(r.Context(), instanceStoreTimeout)
1668 defer recancel()
1669 }
1670 var recordWriteErr error
1671 if claimRequest != nil {
1672 if !s.finishClaimCreate(w, claimRequest) {
1673 return
1674 }
1675 } else {
1676 recordWriteErr = provision.CreateRecord(ctx, s.cfg.Releases, rec)
1677 }
1678 if err := recordWriteErr; err != nil {
1679 if errors.Is(err, release.ErrCASConflict) {
1680 // A lost race against a second signup for the same host, or a
1681 // plain duplicate. Either way the winner's record stands.
1682 jsonError(w, http.StatusConflict, "instance "+host+" already exists")
1683 return
1684 }
1685 log.Printf("console: api instances create %s/%s/%s: %v", acct.Sqid, app, host, err)
1686 jsonError(w, http.StatusBadGateway, "could not create the instance — try again")
1687 return
1688 }
1689 // The sibling check above and this write are two stores, so an app can be
1690 // created under the label between them (claimApp asked apexHolder before
1691 // the record existed). Re-read after writing, and on a hit take the record
1692 // back: the name that arrived owns its apex. The same after-the-commit
1693 // re-check a rename makes (appsettings.go).
1694 if siblingLabel != "" {
1695 if live, trashed, err := s.store.AppNameState(acct.ID, siblingLabel); err == nil && (live || trashed) {
1696 if derr := s.retireInstanceRecord(ctx, acct.Sqid, app, host, principal, mustCreateIncarnation(acct.Sqid, app, host, rec.CreatedAt)); derr != nil {
1697 log.Printf("console: api instances create %s/%s/%s: %s was claimed mid-create and UNDO FAILED: %v", acct.Sqid, app, host, siblingLabel, derr)
1698 }
1699 if privateBucket != "" {
1700 // The one window where this feature's ordering leaves
1701 // residue, logged in those words rather than papered over:
1702 // tearing the bucket down from a path nobody drove would
1703 // make this the platform's first automatic destroyer of
1704 // tenant data, which the per-environment store spec rules
1705 // out (#408 §3). It is listed by `carlos store status`.
1706 log.Printf("console: api instances create %s/%s/%s: %s was claimed mid-create; the record was withdrawn and its store %s WAS LEFT BEHIND", acct.Sqid, app, host, siblingLabel, privateBucket)
1707 }
1708 jsonError(w, http.StatusConflict,
1709 host+" is app "+siblingLabel+"'s platform apex — it was created while this instance was being written; the instance was not kept")
1710 return
1711 }
1712 }
1713 // DNS seam (spec §10): on a single-region deployment nothing writes
1714 // DNS — the app's wildcard already resolves every instance host to the
1715 // edge. Multi-region wants <slug>.<domain> CNAME -> <region>.<domain>
1716 // (TTL 60) written HERE, after the record write and before the
1717 // response, behind an interface the deployment configures (nil =
1718 // wildcard-covered). Recorded, not built.
1719 s.auditActivity(acct, app, principal, Activity{
1720 Action: "instance-create", TargetType: "instance", Target: host, Message: message,
1721 Detail: "created instance " + host + " (cli)",
1722 })
1723 // The record is what a box's reconcile leg mints a route from, so this is
1724 // a box-consumed write (carlos-deploy §3): announce it, and the instance
1725 // is serving seconds after signup instead of on the hour.
1726 s.bumpSerial(ctx)
1727 out := recordView(rec)
1728 out.PrivateStoreBucket = privateBucket
1729 out.RegionFrom = regionFrom
1730 // Every non-fleet placement is named, replicas included (codex
1731 // plan-R3 6); a fleet-placed record's Region is the fleet itself.
1732 if placed == "" {
1733 out.RegionName = s.placeOf(rec.Region, s.candsOrHome(ctx))
1734 }
1735 writeJSON(w, http.StatusCreated, out)
1736}
1737
1738// instanceChannel answers "which channel is this record born on" for a
1739// create that named none — amadan carlos/platform#5, where a brand-new app's
1740// first instance landed on "stable" and the first deploy then met stable's
1741// passkey, as did the obvious repair of moving it to edge.
1742//
1743// The rungs, in order, and why each one is above the next:
1744//
1745// manifest channel app policy, written at `carlos instances enable
1746// -channel`. An app that declared one is not re-decided
1747// here, in either direction — this is what keeps tito's
1748// per-customer creates landing where every sibling is.
1749// sibling records the channel this app's OTHER instances follow, when
1750// they agree. A record per customer is the flagship's
1751// shape, and a new one quietly landing on the pipeline
1752// entry would serve that customer the edge build.
1753// canary/* siblings do not vote: a canary instance is
1754// made to verify a build and deleted after, so the
1755// throwaway must not set policy for the real one.
1756// pipeline entry where this app's releases actually land
1757// (release.Pipeline.Entry), which is what `carlos
1758// deploy` resolves its own channel from. An app with no
1759// pipeline.json falls to the starter pipeline's entry,
1760// which is also the legacy ladder's: nothing is promoted
1761// ONTO edge — merging is what puts a build there.
1762//
1763// Two cases refuse rather than guess, because a record's channel is chosen
1764// once and this door cannot take it back:
1765//
1766// an unreadable pipeline is NOT an absent one (apipipeline.go's rule) — a
1767// bucket hiccup must not mint a production route by falling through, and
1768// instances that disagree have no default worth inventing (attachChannel's
1769// refusal, same words, same reason).
1770//
1771// A caller who named a channel never reaches here: an explicit channel is
1772// the caller's decision, is shape-checked only, and costs no bucket reads.
1773func (s *Server) instanceChannel(ctx context.Context, sqid, app string, m *provision.Manifest) (string, *doorRefusal) {
1774 if m.Channel != "" {
1775 return m.Channel, nil
1776 }
1777 recs, _, err := provision.ListRecords(ctx, s.cfg.Releases, sqid, app, instanceListMax)
1778 if err != nil {
1779 log.Printf("console: api instances create %s/%s: list siblings: %v", sqid, app, err)
1780 return "", &doorRefusal{http.StatusBadGateway,
1781 "could not read this app's instances — try again, or pass -channel"}
1782 }
1783 var found []string
1784 for _, rec := range recs {
1785 if rung, ok := release.Rung(rec.Channel); rec.Channel == "" || (ok && rung == release.RungCanary) {
1786 continue
1787 }
1788 if !slices.Contains(found, rec.Channel) {
1789 found = append(found, rec.Channel)
1790 }
1791 }
1792 slices.Sort(found)
1793 if len(found) > 1 {
1794 return "", &doorRefusal{http.StatusUnprocessableEntity,
1795 "this app's instances follow several channels (" + strings.Join(found, ", ") +
1796 ") — pass -channel to say which one this instance follows"}
1797 }
1798 if len(found) == 1 {
1799 return found[0], nil
1800 }
1801 p, err := release.GetPipeline(ctx, s.cfg.Releases, s.cfg.ReleasePub, sqid, app)
1802 if err != nil {
1803 log.Printf("console: api instances create %s/%s: pipeline read: %v", sqid, app, err)
1804 return "", &doorRefusal{http.StatusBadGateway,
1805 "could not read this app's pipeline — try again, or pass -channel"}
1806 }
1807 if p != nil {
1808 return p.Entry(), nil
1809 }
1810 return release.DefaultPipelineChannels()[0].Name, nil
1811}
1812
1813// staticHeadRefusal refuses the one instance record that can never work: a
1814// process-backed route on a channel whose head is a STATIC release.
1815//
1816// The record would be created happily, provisioned into a real route, and
1817// then fail every wake forever — a static release's manifest carries a "site"
1818// tar and no build for any box's platform, so nothing is ever staged and no
1819// live symlink is ever written. What the member saw before this door existed
1820// was "nothing is promoted for this instance's channel" against a `carlos
1821// channels` that showed the promote plainly there, with no path from either
1822// to the real problem (skills#29). internal/release's noHeadReason now names
1823// that state honestly when a box is already in it; this is the door that
1824// stops it being reachable.
1825//
1826// Only the exec shapes are gated. A "service" record proxies to declared
1827// upstreams and a "static" record reads the channel pointer directly —
1828// neither stages a binary, so neither has anything to mismatch. An empty kind
1829// is the default, which IS "instance": that is precisely the request the
1830// reporter made.
1831//
1832// The check spends two bucket reads on a create, and only a create. Every
1833// failure to read degrades to nil — this is a guard against a shape mistake,
1834// not a rail against a hostile caller, and a bucket hiccup must not stop an
1835// ordinary member declaring an ordinary instance. A brand-new app with
1836// nothing promoted yet reads as nil too, which is the common first-instance
1837// case and stays exactly as fast as it was.
1838func (s *Server) staticHeadRefusal(ctx context.Context, sqid, app, channel, kind, host string) *doorRefusal {
1839 if kind != "" && kind != "instance" {
1840 return nil
1841 }
1842 ptr, err := release.Channel(ctx, s.cfg.Releases, s.cfg.ReleasePub, sqid, app, channel)
1843 if err != nil || ptr == nil || ptr.Version == "" {
1844 return nil
1845 }
1846 m, err := release.GetManifest(ctx, s.cfg.Releases, sqid, app, ptr.Version)
1847 if err != nil || m == nil || m.Kind != "static" {
1848 return nil
1849 }
1850 return &doorRefusal{http.StatusUnprocessableEntity,
1851 "channel " + channel + " serves a static release (" + ptr.Version + "), and an instance runs a process — " +
1852 "a static site is served straight off the channel pointer, so it needs no instance at all. " +
1853 "Pass --kind static, or let one command do the whole thing: carlos deploy --app " + app +
1854 " --kind static --host " + host + " <site-dir>"}
1855}
1856
1857// refuseMemberUpstreams answers whether this caller may point a route at
1858// addrs, and writes the refusal when not. Stating an upstream is an
1859// operator's act: the edge dials these addresses from inside the platform,
1860// so a member's could reach the box's own services (the console on
1861// 127.0.0.1:8090) or another tenant's private upstream (discussion 214,
1862// item 4). Paul's ruling on discussion 50 (option a): members
1863// state none until the "own fleet" allowance exists, public addresses
1864// included, and operators keep today's rail.
1865//
1866// kept lists the addresses the record already carries. Those pass whoever
1867// sends them, so a member whose route an operator pointed somewhere can
1868// keep it, change its probe, or drop a dead upstream; only adding one is
1869// refused.
1870//
1871// "Operator" is a person holding the operator bit. A service credential
1872// never is one, whichever account it belongs to. A failed operator lookup
1873// refuses, so a database blip is not the way past it.
1874func (s *Server) refuseMemberUpstreams(w http.ResponseWriter, p Principal, addrs, kept []string) bool {
1875 added := false
1876 for _, a := range addrs {
1877 if !slices.Contains(kept, a) {
1878 added = true
1879 break
1880 }
1881 }
1882 if !added {
1883 return false
1884 }
1885 if p.Kind == PrincipalUser {
1886 op, err := s.store.IsOperator(p.Address)
1887 if err != nil {
1888 log.Printf("console: upstream rail: is-operator %s: %v", p.Address, err)
1889 jsonError(w, http.StatusServiceUnavailable, "could not check who may set these upstreams — nothing was changed. Try again.")
1890 return true
1891 }
1892 if op {
1893 return false
1894 }
1895 }
1896 jsonError(w, http.StatusForbidden, "only a platform operator can point an instance at its own backends")
1897 return true
1898}
1899
1900// handleAPIInstanceUpdate repoints a live instance's UPSTREAM SET, probe
1901// config and (Task 3, multi-region-steering plan) DNS STEERING opt-in — the
1902// fields of a record that are meant to change while the instance keeps
1903// serving (HA design, issue #152; steering, 2026-08-20 multi-region ingress
1904// design Part 4). Everything else about a record stays where it is: identity
1905// (host/app/account) is checked at create and never touched again, policy
1906// (pool, domain) is the manifest's, and region has its own writer in
1907// placement.
1908//
1909// It is a PATCH, and the body's fields are independently optional, because
1910// "leave the probe alone while I add an upstream" is the ordinary request. A
1911// body carrying NONE of them is refused rather than treated as a no-op write:
1912// it would cost a bucket round trip and a fleet-wide serial bump to change
1913// nothing.
1914//
1915// The write is guarded by the etag the record was READ under
1916// (provision.UpdateRecord): the caller decided this upstream set (or this
1917// steering opt-in) given the record they were shown, so a concurrent writer
1918// means the decision was made about bytes that no longer exist, and the
1919// honest answer is 409 — the same answer the enable door gives for the same
1920// reason.
1921//
1922// Steering carries its own two rails, beyond the CAS guard every field here
1923// shares: an invalid value is ValidateShape's own 400 (ValidSteering is
1924// already one of its checks, so a bad vocabulary word needs no extra code
1925// here), and asking to turn latency ON for a record whose Pool != "" is a
1926// 409 naming the v1 constraint verbatim (plan-2 ledger: a steered
1927// pool-scoped route would get latency records pointing at edges that
1928// structurally cannot serve it — the per-pool-record-scoping alternative is
1929// a recorded future, not v1). The rail reads the FETCHED RECORD's Pool —
1930// stamped once at create and never touched by this door — not the app's
1931// manifest, which may have moved to a different pool since; the record is
1932// the source of truth this door edits, and the manifest's current value is
1933// not it. Turning steering OFF is never blocked by this rail: only the
1934// latency-on request needs the shared pool.
1935//
1936// Every refusal before the principal is proven is the family's byte-identical
1937// 404 (instanceGate), including a host that names no record: a record's
1938// existence is not a thing to probe for here any more than it is on GET.
1939func (s *Server) handleAPIInstanceUpdate(w http.ResponseWriter, r *http.Request) {
1940 principal, acct, app, ok := s.instanceGate(w, r)
1941 if !ok {
1942 return
1943 }
1944 // Same split as create, for a sharper reason: the provisioning
1945 // reconciler converges a route's Channel and Environment FROM this
1946 // record on every pass (AGENTS.md), so a PATCH here repoints the
1947 // control plane. An operator doing that deliberately is legitimate; a CI
1948 // credential doing it is the console being moved by something with no
1949 // standing to move it. Placed before the host shape check so that no
1950 // branch of this handler reaches a write without passing it.
1951 p, _ := principalFrom(r.Context())
1952 if s.refuseServiceControlPlane(w, p, acct.ID, app, "change its instance records") {
1953 return
1954 }
1955 host := r.PathValue("host")
1956 // Shape-checked before the key builder, exactly as GET does it and for
1957 // the same reason: a {host} segment arrives percent-decoded, so
1958 // "..%2f..%2fchannels" reaches this handler as a path with separators in
1959 // it and RecordKey would happily join it. The manifest's suffix rule is
1960 // deliberately NOT applied — that is create-time policy ("which hosts may
1961 // this app claim"), and an app that has since moved domains must still be
1962 // able to repoint the upstreams of the instances it already has.
1963 if !provision.ValidHostShape(host) {
1964 http.NotFound(w, r)
1965 return
1966 }
1967 // Addrs is a POINTER and Probe a RawMessage because absent, empty and
1968 // null are three different requests (the apiConfigApply precedent): an
1969 // omitted field leaves what the record carries, `"addrs":[]` clears the
1970 // upstream set, and `"probe":null` clears the probe while `"probe":{}`
1971 // asks for the platform defaults.
1972 //
1973 // The two are deliberately NOT symmetric about null, and the asymmetry is
1974 // worth stating because it is invisible from the types: `"probe":null`
1975 // CLEARS the probe (a RawMessage records that the key was present), while
1976 // `"addrs":null` is indistinguishable from an absent key to a *[]string
1977 // and therefore means "leave the upstream set alone". Clearing the set is
1978 // spelled `"addrs":[]`, which is also the only spelling that says what it
1979 // means.
1980 //
1981 // Steering is a *string, plainer than either: it is one scalar fact, not
1982 // a set or a block, so there is no empty-vs-null distinction to carry.
1983 // Absent (nil) leaves the record's steering alone; `"steering":""` turns
1984 // it off; `"steering":"latency"` turns it on (subject to the pool rail
1985 // below).
1986 // Environment is a *string for the same reason Steering is: one scalar
1987 // fact, so absent (nil) leaves the record's binding alone and
1988 // `"environment":""` returns the route to the app's default bundle.
1989 // Rebinding is exactly what the handler doc above describes as
1990 // legitimate-when-deliberate — this door repoints the control plane, and
1991 // refuseServiceControlPlane has already decided whether this caller may.
1992 var req struct {
1993 Addrs *[]string `json:"addrs"`
1994 Probe json.RawMessage `json:"probe"`
1995 Steering *string `json:"steering"`
1996 Environment *string `json:"environment"`
1997 // Gate is a *string like Steering: absent leaves the gate alone,
1998 // "" opens the host, anything else is the new password — hashed
1999 // here, never stored or echoed (spec §5.2). The plaintext crosses
2000 // the wire once, over TLS, the same as a sign-in.
2001 Gate *string `json:"gate"`
2002 }
2003 if !readJSON(w, r, &req) {
2004 return
2005 }
2006 if req.Addrs == nil && req.Probe == nil && req.Steering == nil && req.Environment == nil && req.Gate == nil {
2007 jsonError(w, http.StatusBadRequest,
2008 "nothing to update — send addrs, probe, steering, environment, gate, or a combination")
2009 return
2010 }
2011 var probe *provision.Health
2012 if req.Probe != nil {
2013 if err := json.Unmarshal(req.Probe, &probe); err != nil {
2014 jsonError(w, http.StatusBadRequest,
2015 `invalid probe — want an object like {"path":"/healthz","status":200} or null to stop probing`)
2016 return
2017 }
2018 }
2019 ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
2020 defer cancel()
2021 rec, etag, err := provision.GetRecordETag(ctx, s.cfg.Releases, acct.Sqid, app, host)
2022 if errors.Is(err, os.ErrNotExist) {
2023 http.NotFound(w, r)
2024 return
2025 }
2026 if err != nil {
2027 log.Printf("console: api instances update read %s/%s/%s: %v", acct.Sqid, app, host, err)
2028 jsonError(w, http.StatusBadGateway, "could not read the instance")
2029 return
2030 }
2031 // The record as it stands, kept whole before anything below mutates it:
2032 // the shape rail past ValidateShape is a comparison BETWEEN the stored
2033 // route and the patched one, not a property of either alone.
2034 stored := *rec
2035 if req.Addrs != nil {
2036 // This door states the upstream set WHOLE, so it also retires the
2037 // legacy single-addr spelling of the same fact. Leaving Addr standing
2038 // would make every patch of a record minted before Addrs existed fail
2039 // provision.ValidateShape's "addr and addrs both set" rail — a refusal
2040 // about a field the caller never sent, on the exact records (kass,
2041 // keymail, the consoles) this feature exists to give a second upstream
2042 // to. Clearing it is not a widening: AddrsOrDefault already folds Addr
2043 // into the same one-element set this replaces.
2044 rec.Addrs, rec.Addr = *req.Addrs, ""
2045 }
2046 if req.Probe != nil {
2047 rec.Health = probe
2048 }
2049 if req.Steering != nil {
2050 rec.Steering = *req.Steering
2051 }
2052 if req.Environment != nil {
2053 rec.Environment = strings.TrimSpace(*req.Environment)
2054 }
2055 // The gate is the one field this door does not store as it was sent: the
2056 // record carries a bcrypt hash and nothing else, so the plaintext lives
2057 // only in this stack frame. Not trimmed either — a password's leading and
2058 // trailing spaces are part of it, and the login POST compares the bytes
2059 // the member typed.
2060 if req.Gate != nil {
2061 if *req.Gate == "" {
2062 rec.Gate = ""
2063 } else {
2064 // CheckPassword's sentences name the rule and never the value:
2065 // nothing below (and nothing logged) can echo the password back.
2066 if err := gate.CheckPassword(*req.Gate); err != nil {
2067 jsonError(w, http.StatusBadRequest, err.Error())
2068 return
2069 }
2070 h, err := gate.Hash(*req.Gate)
2071 if err != nil {
2072 log.Printf("console: api instances update %s/%s/%s: hash gate: %v", acct.Sqid, app, host, err)
2073 jsonError(w, http.StatusInternalServerError, "could not set the password — try again")
2074 return
2075 }
2076 rec.Gate = h
2077 }
2078 }
2079 // The same door the create path and the box's reconcile pass check
2080 // against, so a shape that could not have been created cannot be patched
2081 // into existence either — and the caller gets ValidateShape's own
2082 // sentence, which names the offending upstream. It is also this
2083 // door's own invalid-value 400 for steering: ValidSteering is already
2084 // one of ValidateShape's checks, so "geo" or "Latency" is refused here
2085 // with no code of this handler's own.
2086 if err := provision.ValidateShape(*rec); err != nil {
2087 jsonError(w, http.StatusBadRequest, err.Error())
2088 return
2089 }
2090 // An address the record already carries is not a new statement, so a
2091 // member may keep it: dropping one dead upstream from an operator-made
2092 // set is theirs to do.
2093 if req.Addrs != nil && s.refuseMemberUpstreams(w, p, *req.Addrs, stored.AddrsOrDefault()) {
2094 return
2095 }
2096 // v1's pool guardrail (multi-region-steering plan, plan-2 ledger):
2097 // latency steering needs the SHARED pool, because a pool's own edges are
2098 // the only ones that can serve a pool-scoped route — a steered
2099 // non-shared-pool route would advertise latency records pointing at
2100 // edges that structurally cannot answer for it. The per-pool-record
2101 // alternative is a recorded future, not v1.
2102 //
2103 // rec.Pool is read straight off the bytes GetRecordETag fetched moments
2104 // ago — this door never writes Pool and never re-derives it from the
2105 // app's manifest, which may have moved to a different pool since the
2106 // record was created. The record is the source of truth this door
2107 // edits; the manifest's CURRENT value is not it, and using it here would
2108 // let an operator's later `instances enable` silently flip what an
2109 // already-provisioned route is allowed to do. Turning steering OFF is
2110 // never blocked: only a request that leaves this record at "latency"
2111 // needs the shared pool.
2112 if rec.Steering == "latency" && rec.Pool != "" {
2113 jsonError(w, http.StatusConflict,
2114 "steering requires the shared pool — pool-scoped steering is a recorded future")
2115 return
2116 }
2117 // The rail this door needs beyond ValidateShape: a patch may repoint a
2118 // route's upstreams, never change what KIND of thing the route is.
2119 // DerivesPaths is that line exactly — it decides whether the route owns a
2120 // <host>.sock and a <host>.db — and the reconcile pass refuses to converge
2121 // a row across it in place (the "record describes a different route SHAPE"
2122 // log). Allowing the patch anyway would answer 200 for a change that can
2123 // never take effect: a socket-backed instance handed addrs would keep
2124 // serving its socket while the box logged the same complaint every pass,
2125 // and an addrs-backed route emptied to nothing would become a route that
2126 // serves nothing at all. Transitions go through delete + create, which
2127 // this API already supports, and which lets the box retire the old route's
2128 // derived paths honestly.
2129 if stored.DerivesPaths() != rec.DerivesPaths() {
2130 jsonError(w, http.StatusBadRequest,
2131 "that changes the instance's route SHAPE, not just its upstreams — a socket-backed instance "+
2132 "and an upstream-backed one are different routes, and a box cannot convert one into the other "+
2133 "in place; delete "+host+" and create it again with the shape you want")
2134 return
2135 }
2136 // A new environment binding is serialised with a seed's store teardown,
2137 // and takes the store from the seed (final review F3, seedenvbind.go).
2138 unlockEnv, ok := s.lockEnvironmentUpdate(ctx, w, stored, *rec)
2139 if !ok {
2140 return
2141 }
2142 defer unlockEnv()
2143 // provision.UpdateRecord marshals the whole record through the canonical
2144 // writer: nothing here hand-builds an object body, so upstream ORDER —
2145 // which the reconcile pass's shapeMismatch compares verbatim against the
2146 // registry's joined column — lands exactly as the caller stated it.
2147 if err := provision.UpdateRecord(ctx, s.cfg.Releases, *rec, etag); err != nil {
2148 if errors.Is(err, release.ErrCASConflict) {
2149 jsonError(w, http.StatusConflict,
2150 "instance "+host+" changed while this request was in flight — read it again and re-apply")
2151 return
2152 }
2153 log.Printf("console: api instances update %s/%s/%s: %v", acct.Sqid, app, host, err)
2154 jsonError(w, http.StatusBadGateway, "could not update the instance — try again")
2155 return
2156 }
2157 // "upstreams" no longer names the whole of what this door can change
2158 // (steering joined it in Task 3), so the audit line names exactly the
2159 // fields THIS request carried rather than either overclaiming ("upstreams"
2160 // on a steering-only patch) or going field-agnostic ("updated instance
2161 // <host> (cli)" alone) — this is a security surface, and "did anyone turn
2162 // on steering for this host last week?" has to stay answerable from the
2163 // audit log. Built from the same req.* nil checks the mutation above
2164 // already made, so it can never drift from what was actually applied.
2165 var changed []string
2166 if req.Addrs != nil {
2167 changed = append(changed, "addrs")
2168 }
2169 if req.Probe != nil {
2170 changed = append(changed, "probe")
2171 }
2172 if req.Steering != nil {
2173 changed = append(changed, "steering")
2174 }
2175 if req.Environment != nil {
2176 changed = append(changed, "environment")
2177 }
2178 // "gate" names that the host's password was set or cleared, never which
2179 // it was: the audit log answers "did anyone change the gate on this host
2180 // last week?" without becoming a place a password can be read.
2181 if req.Gate != nil {
2182 changed = append(changed, "gate")
2183 }
2184 s.audit(acct, app, principal, "instance-update",
2185 "updated instance "+host+" ("+strings.Join(changed, ", ")+") (cli)")
2186 // Strictly after the write landed, and exactly once: the record is what a
2187 // box's reconcile leg converges upstreams from (provision.convergeOwned),
2188 // so this announcement is what makes the new set live in seconds instead
2189 // of on the hour.
2190 s.bumpSerial(ctx)
2191 writeJSON(w, http.StatusOK, recordView(*rec))
2192}
2193
2194// handleAPIInstanceDelete retires an instance's route authorization. The
2195// record is deleted; the parked database and blobs are deliberately
2196// untouched (spec §3.2) — retiring a route is not destroying a customer's
2197// data. Idempotent: the caller's goal is "not there", and a second delete
2198// has reached it.
2199func (s *Server) handleAPIInstanceDelete(w http.ResponseWriter, r *http.Request) {
2200 principal, acct, app, ok := s.instanceGate(w, r)
2201 if !ok {
2202 return
2203 }
2204 message, ok := checkedActivityMessage(w, r.Header.Get(activityMessageHeader))
2205 if !ok {
2206 return
2207 }
2208 // The eighth destructive door, and the one with the shortest fuse:
2209 // deleting a control-plane app's record is how the provisioning
2210 // reconciler's removal phase is TOLD to retire the console's route, so a
2211 // single call here takes the control plane off the air. That is the same
2212 // class of act as trash, purge and store-destroy, and it wears their
2213 // refusal — outright, for anyone, service or person alike, until the
2214 // flag is cleared (controlplane.go).
2215 //
2216 // FIRST, ahead of the shape check and the store: the app is already
2217 // past instanceGate's byte-identical 404, and nothing below this line
2218 // may run for a flagged app.
2219 if s.refuseControlPlane(w, acct, app, "deleted") {
2220 return
2221 }
2222 host := r.PathValue("host")
2223 ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
2224 defer cancel()
2225 // SHAPE ONLY, always — and the manifest is not read here at all. The
2226 // domain-suffix rule is CREATE-time policy ("which hosts may this app
2227 // claim"), and deletion has to outlive every version of it: a retired
2228 // manifest, an app that moved domains, a record written under a domain
2229 // the manifest no longer names. Any of those checked against today's
2230 // manifest would refuse to delete records that exist, stranding
2231 // provisioned routes with nothing left able to retire them. What
2232 // deletion actually needs from validation is the traversal defence: a
2233 // {host} arrives percent-decoded, so "..%2f..%2finstances.json" reaches
2234 // here with separators in it and RecordKey would happily join it.
2235 // ValidHostShape is exactly that check, and there is no path through
2236 // this handler on which an unvalidated host reaches RecordKey.
2237 if !provision.ValidHostShape(host) {
2238 jsonError(w, http.StatusBadRequest, "invalid host")
2239 return
2240 }
2241 // With CARLOS_MOVES_PHASE1 on, a host being moved is not deleted
2242 // (M-C19, apiinstancemove.go); with it off this is main's door.
2243 if s.refuseDeleteDuringMove(ctx, w, acct.Sqid, app, host) {
2244 return
2245 }
2246 if answered, finished := s.refuseImportDelete(w, r, acct, app, host); answered {
2247 if finished {
2248 s.auditActivity(acct, app, principal, Activity{
2249 Action: "instance-delete", TargetType: "instance", Target: host, Message: message,
2250 Detail: "deleted instance " + host + " (cli)",
2251 })
2252 s.bumpSerial(ctx)
2253 writeJSON(w, http.StatusOK, map[string]any{"result": "deleted"})
2254 }
2255 return
2256 }
2257 if err := s.retireInstanceRecord(ctx, acct.Sqid, app, host, principal, ""); err != nil {
2258 // An accepted delete is this principal's durable request: audit it
2259 // and announce it before answering 202 with the operation.
2260 if accepted, ok := claimRetireAcceptedOf(err); ok {
2261 s.auditActivity(acct, app, principal, Activity{
2262 Action: "instance-delete", TargetType: "instance", Target: host, Message: message,
2263 Detail: "delete of instance " + host + " accepted, operation " + accepted.operation + " (cli)",
2264 })
2265 s.bumpSerial(ctx)
2266 }
2267 if s.writeClaimPending(w, err) {
2268 return
2269 }
2270 log.Printf("console: api instances delete %s/%s/%s: %v", acct.Sqid, app, host, err)
2271 jsonError(w, http.StatusBadGateway, "could not delete the instance — try again")
2272 return
2273 }
2274 // DNS seam (spec §10): a multi-region deployment retires the instance's
2275 // CNAME here, after the record is gone.
2276 //
2277 // Audited whether or not an object was actually there to remove:
2278 // idempotency makes "deleted" and "already gone" indistinguishable from
2279 // the store, and the fact worth recording is that this principal asked
2280 // for this host to stop existing.
2281 s.auditActivity(acct, app, principal, Activity{
2282 Action: "instance-delete", TargetType: "instance", Target: host, Message: message,
2283 Detail: "deleted instance " + host + " (cli)",
2284 })
2285 // Retiring a route is reconcile's work too, and the direction people want
2286 // FAST: the announcement is what takes the route down in seconds.
2287 s.bumpSerial(ctx)
2288 writeJSON(w, http.StatusOK, map[string]any{"result": "deleted"})
2289}
2290
2291// handleAPIInstanceToken mints the app-held credential (spec §8). Gated by
2292// apiPerson and then apiAccountApp DIRECTLY, not by the instance gate:
2293// minting is a human act, and a credential may never mint a credential —
2294// including itself. A service principal meets the 401 a fabricated token
2295// meets, the same one it met when this door's only resolver was cliUser.
2296// Budgeted on the address-keyed write limiter for the same reason: the
2297// principal a mint belongs to is a person.
2298//
2299// That 401 is the INNER of two walls, and it is the one nothing can reach:
2300// the outer is cliRoute, where this door's zero Set refuses every role,
2301// admin included (TestNoRoleReachesAMintFleetOrPolicyDoor,
2302// TestAdminIsRefusedAtAClosedDoor). Both are kept, because the inner one
2303// used to be a side effect of resolving through cliUser and would otherwise
2304// have vanished the day that resolution became principal-aware.
2305func (s *Server) handleAPIInstanceToken(w http.ResponseWriter, r *http.Request) {
2306 if !s.apiPerson(w, r) {
2307 return
2308 }
2309 addr, acct, app, ok := s.apiAccountApp(w, r)
2310 if !ok {
2311 return
2312 }
2313 p, _ := principalFrom(r.Context())
2314 if !s.allowWrite(p, time.Now()) {
2315 jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
2316 return
2317 }
2318 var req struct {
2319 Label string `json:"label"`
2320 }
2321 if !readJSON(w, r, &req) {
2322 return
2323 }
2324 // Same de-fanging every terminal label gets: this string is rendered on
2325 // the minter's tokens page, where it is the one field a human reads to
2326 // decide whether to revoke.
2327 label := cleanLabel(req.Label)
2328 if label == "" {
2329 label = app + " instance token"
2330 }
2331 raw, err := s.store.NewAppTokenContext(r.Context(), acct.ID, app, addr, label, time.Now())
2332 if err != nil {
2333 log.Printf("console: api instance token %s/%s: %v", acct.Sqid, app, err)
2334 jsonError(w, http.StatusInternalServerError, "could not mint the token — try again")
2335 return
2336 }
2337 s.audit(acct, app, addr, "instance-token", "minted an instance token for "+app+" (cli)")
2338 // Shown exactly once: only the hash is stored, so there is no second
2339 // copy to hand out and no way to recover this one. Delivery to the app
2340 // is the config-env layer, like any other app secret —
2341 // `carlos secrets set -app <app> CARLOS_INSTANCE_TOKEN=...`.
2342 writeJSON(w, http.StatusCreated, map[string]any{
2343 "token": raw, "account": acct.Sqid, "app": app,
2344 })
2345}
2346
2347// instanceSeed bounds (issue #66). Seed becomes the argv the activator execs
2348// the app's OWN binary with on first wake — the caller already controls that
2349// binary via ship, so the bound here is manifest hygiene, not privilege: a
2350// manifest is read forever, and an unbounded argv written once would be
2351// permanent.
2352const (
2353 instanceSeedMaxArgs = 16
2354 instanceSeedMaxArgLen = 256
2355)
2356
2357// handleAPIInstanceEnable is the member-facing half of `carlos instances
2358// enable` (issue #66): the one bucket-only prerequisite that kept the
2359// otherwise-API path from "promoted" to "reachable" needing a box shell.
2360//
2361// The security line it holds: the manifest's DOMAIN is pinned server-side to
2362// <account-sqid>.<AppsDomain> — the account's own platform subdomain — and
2363// nothing the request carries can name another. Custom domains (tito's
2364// go.tito.io) stay an operator act in store mode, and an existing manifest
2365// under any other domain is refused with a 409, never overwritten: this door
2366// can hand an account keys only to hostnames that were already hers.
2367//
2368// Gated by apiPerson and then apiAccountApp DIRECTLY, not the instance gate:
2369// enabling is policy, a human act — a service credential meets the 401 here
2370// that it meets at the token mint, and for the same reason. The manifest
2371// this door writes is standing policy about what may ever be BROUGHT INTO
2372// existence for the app; a credential the app itself holds must not be able
2373// to rewrite the bounds it runs inside. Budgeted on the address-keyed write
2374// limiter for the same reason.
2375//
2376// The door is registered with the zero Set (authcli.go), so no role reaches
2377// it and a service credential is refused out at cliRoute with the same 403
2378// the mint door gives. That registration is the load-bearing wall; apiPerson
2379// is the second one, stated here because the person-only rule is a fact
2380// about the SUBJECT of this door rather than about any caller's role, and
2381// because it used to hold only as a side effect of resolving through
2382// cliUser. Pinned by TestAPIInstanceEnableRefusals, TestEnableStaysAHumanAct
2383// and TestApiPersonIsTheInnerWall.
2384func (s *Server) handleAPIInstanceEnable(w http.ResponseWriter, r *http.Request) {
2385 if !s.apiPerson(w, r) {
2386 return
2387 }
2388 addr, acct, app, ok := s.apiAccountApp(w, r)
2389 if !ok {
2390 return
2391 }
2392 p, _ := principalFrom(r.Context())
2393 if !s.allowWrite(p, time.Now()) {
2394 jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
2395 return
2396 }
2397 if s.cfg.Releases == nil {
2398 jsonError(w, http.StatusServiceUnavailable,
2399 "no deployment bucket on this host — the console is running without one")
2400 return
2401 }
2402 if s.cfg.AppsDomain == "" {
2403 // Membership is proven, so this may name the operator act that fixes
2404 // it. 503, not 409: the missing piece is deployment configuration
2405 // (PLATFORM_APPS_DOMAIN on the console), not anything about this app.
2406 jsonError(w, http.StatusServiceUnavailable,
2407 "this console has no platform apps domain configured — an operator enables instances in store mode (carlos instances enable) or sets PLATFORM_APPS_DOMAIN on the console")
2408 return
2409 }
2410 // Channel and Seed are pointers because "absent" and "empty" are
2411 // different requests on the wire (the apiConfigApply precedent): an
2412 // omitted field preserves what the manifest already carries, an explicit
2413 // value replaces it — including seed's explicit [] meaning "clear".
2414 //
2415 // PrivateStorage joined them on 2026-09-22 (issue #412) as a pointer for
2416 // exactly the same reason: an app that already gives its instances their
2417 // own storage must not lose the policy to a `carlos instances enable
2418 // -channel beta` that never mentioned it.
2419 var req struct {
2420 Channel *string `json:"channel"`
2421 Seed *[]string `json:"seed"`
2422 PrivateStorage *bool `json:"private_storage"`
2423 }
2424 if !readJSON(w, r, &req) {
2425 return
2426 }
2427 if req.Channel != nil && !release.ValidChannel(*req.Channel) {
2428 jsonError(w, http.StatusBadRequest, "invalid channel name")
2429 return
2430 }
2431 if req.Seed != nil {
2432 if len(*req.Seed) > instanceSeedMaxArgs {
2433 jsonError(w, http.StatusBadRequest, "seed takes at most 16 arguments")
2434 return
2435 }
2436 for _, arg := range *req.Seed {
2437 if arg == "" || len(arg) > instanceSeedMaxArgLen {
2438 jsonError(w, http.StatusBadRequest, "each seed argument must be 1-256 bytes")
2439 return
2440 }
2441 }
2442 }
2443 domain := acct.Sqid + "." + s.cfg.AppsDomain
2444 // The domain is about to be stamped into a manifest every future host
2445 // gets checked against, and ValidHost only regex-checks the leading
2446 // label — the suffix rides into bucket keys verbatim. Store mode
2447 // validates its -domain for exactly this reason (instances_cli.go); a
2448 // misconfigured PLATFORM_APPS_DOMAIN ("*.oncarlos.com" copied off a DNS
2449 // record) must refuse here, not surface as records reconcile skips
2450 // forever. The console also refuses garbage at boot (cmd/console); this
2451 // guard covers embedders that build Config directly.
2452 if !provision.ValidHostShape(domain) {
2453 jsonError(w, http.StatusServiceUnavailable,
2454 "this console's PLATFORM_APPS_DOMAIN is not a valid domain — fix the console configuration")
2455 return
2456 }
2457 ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
2458 defer cancel()
2459 // Read WITH the etag and write guarded by it below: the domain-conflict
2460 // refusal is only honest if nothing can land between this read and the
2461 // write. PutManifest's upsert re-reads inside, so an operator's
2462 // store-mode manifest written into that window would be silently
2463 // re-homed — the exact overwrite the 409 exists to prevent.
2464 m, etag, err := provision.GetManifestETag(ctx, s.cfg.Releases, acct.Sqid, app)
2465 switch {
2466 case errors.Is(err, os.ErrNotExist):
2467 m, etag = &provision.Manifest{Schema: 1, App: app, Domain: domain}, ""
2468 case err != nil:
2469 log.Printf("console: api instances enable read %s/%s: %v", acct.Sqid, app, err)
2470 jsonError(w, http.StatusBadGateway, "could not read instance settings")
2471 return
2472 case m.Domain != domain:
2473 // Never overwritten from here: a manifest naming any other domain
2474 // was written by an operator in store mode, and re-homing every
2475 // FUTURE host check under the platform domain would be this door
2476 // rewriting operator policy. Membership is proven, so naming the
2477 // standing domain leaks nothing the caller cannot already read.
2478 jsonError(w, http.StatusConflict,
2479 "instances for "+app+" are managed under "+m.Domain+
2480 " — changing an app's instance domain is an operator act (carlos instances enable, store mode)")
2481 return
2482 }
2483 // Pool and Regions survive untouched on an update: they are operator
2484 // policy this door never speaks for, in either direction. (Recorded
2485 // debt: fields ADDED to schema 1 after this binary would be dropped by
2486 // this read-modify-write, the same property the store-mode upsert has —
2487 // an additive manifest field needs the console upgraded before members
2488 // enable through it.)
2489 if req.Channel != nil {
2490 m.Channel = *req.Channel
2491 }
2492 if req.Seed != nil {
2493 m.Seed = *req.Seed
2494 }
2495 // Turning the policy ON is checked against what this deployment and this
2496 // account can actually do; turning it OFF never is (spec §5). The
2497 // asymmetry is deliberate: a policy that cannot be honoured fails once
2498 // per signup in a door whose caller is a machine, so the person
2499 // declaring it is told now — but an account whose entitlement was later
2500 // declined must be able to get its create door working again without
2501 // waiting on an operator.
2502 if req.PrivateStorage != nil && *req.PrivateStorage &&
2503 s.refuseTemporary(w, acct, app, "give its instances private storage") {
2504 return
2505 }
2506 if req.PrivateStorage != nil && *req.PrivateStorage && !m.PrivateStorage {
2507 if !s.instancePrivateStorageAllowed(ctx, w, acct) {
2508 return
2509 }
2510 }
2511 if req.PrivateStorage != nil {
2512 m.PrivateStorage = *req.PrivateStorage
2513 }
2514 if err := provision.PutManifestIfMatch(ctx, s.cfg.Releases, acct.Sqid, app, *m, etag); err != nil {
2515 if errors.Is(err, release.ErrCASConflict) {
2516 jsonError(w, http.StatusConflict,
2517 "instance settings changed while this request was in flight — try again")
2518 return
2519 }
2520 log.Printf("console: api instances enable %s/%s: %v", acct.Sqid, app, err)
2521 jsonError(w, http.StatusBadGateway, "could not enable instances — try again")
2522 return
2523 }
2524 s.audit(acct, app, addr, "instance-enable", "enabled instances under "+domain+privateStorageAuditSuffix(req.PrivateStorage)+" (cli)")
2525 // The manifest names the channel every minted route follows, so a change
2526 // here changes what boxes converge onto — reconcile reads it each pass.
2527 s.bumpSerial(ctx)
2528 writeJSON(w, http.StatusOK, map[string]any{
2529 "app": app, "domain": m.Domain, "pool": m.Pool, "channel": m.Channel,
2530 "regions": m.Regions, "seed": m.Seed, "private_storage": m.PrivateStorage,
2531 })
2532}
2533
2534// privateStorageAuditSuffix names the policy in the audit line only when
2535// this request SPOKE to it. An enable that changed a channel says nothing
2536// about storage, and a row that restated the standing policy every time
2537// would make the one request that changed it impossible to find.
2538func privateStorageAuditSuffix(req *bool) string {
2539 switch {
2540 case req == nil:
2541 return ""
2542 case *req:
2543 return ", every instance with storage of its own"
2544 default:
2545 return ", instances sharing the app's storage"
2546 }
2547}
2548
2549// instanceRegionAllowed applies the manifest's region allow-list. An empty
2550// list means any shape-valid region (spec §3.1) — including the empty,
2551// unconstrained one, which is what a single-region deployment uses.
2552func instanceRegionAllowed(allowed []string, region string) bool {
2553 return len(allowed) == 0 || slices.Contains(allowed, region)
2554}
2555