// The CLI's instance provisioning API: the verbs that turn "place an // instance" into a data write in the deployment bucket, so a signup path // holding no AWS credentials and no box access can provision one in // seconds. The record it writes is the whole authorization; the agent's // reconcile pass (internal/provision) is what turns it into a route, on its // own box only. // // This was the FIRST console family a non-human principal could speak to, // and for a while it carried its own resolver (apiInstancePrincipal) to do // it. That resolver is gone: service principals are a platform-wide idea now // (svcprincipal.go, spec 2026-08-18), and its rules — the sqid-only match, // the exact app binding, the `app:/` audit actor — live in the // shared path (apiAccountApp) where every family gets them. What is left // here is the same gate with the instance BUDGET bound to it. // // Two shapes of bearer reach it: a person's token, resolved through exactly // the same membership-proving lookup every other CLI verb uses, or a service // credential bound to one account and — for the legacy app tokens this // family minted — one app, which must match the path it is used on EXACTLY. // Every refusal before that proof is the family's byte-identical 404 — an // unknown account, someone else's account, an unclaimed app and a service // credential probing a foreign path are all one answer, so probing teaches // nothing. // // Spec: docs/superpowers/specs/2026-08-04-instance-provisioning-design.md // §7 (the verbs), §8 (the credential), §10 (the DNS seam), §11 (security). package console import ( "context" "encoding/json" "errors" "log" "net/http" "os" "slices" "strconv" "strings" "time" "github.com/carlosframework/platform/internal/gate" "github.com/carlosframework/platform/internal/hostops" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/status" ) // instanceStoreTimeout bounds every store round trip these handlers make, // the same 15s the rest of the CLI API uses: a wedged bucket costs one // request 15 seconds, not a console goroutine forever. const instanceStoreTimeout = 15 * time.Second // instanceListMax caps one list answer (spec §7): instance fleets can be // large, and a caller that needs more than 500 is not using this door for // what it is. The response says so with `truncated` rather than silently // serving a prefix. const instanceListMax = 500 // instanceGate is the preamble every instance verb shares, in the order the // family fixed (handleAPIPromote): principal FIRST, budget second, then the // deployment's own readiness. The order is load-bearing — a rate limit // answered before the principal is proven would be a 429 where a 404 // belongs, and "this path is rate limited" is itself the answer that an app // exists here. // // The principal proof is apiAccountApp's, which is to say the general one: // this family no longer resolves its own callers. The string it returns is // the audit actor either way — an address for a person, app:/ for // a legacy app token exactly as before, service:/ for a named // service credential. func (s *Server) instanceGate(w http.ResponseWriter, r *http.Request) (string, *Account, string, bool) { return s.instanceGateFor(w, r, "instance") } // instanceGateFor is instanceGate with the noun its 429 uses. The BUDGET is // deliberately shared across every family that gates here — one credential // doing one kind of work, and one place for the ordering to live — but a // member creating sidecars should not be told they made too many "instance // requests" when they hit it. Only the sentence varies; nothing about the // order, the key or the limiter does. func (s *Server) instanceGateFor(w http.ResponseWriter, r *http.Request, noun string) (string, *Account, string, bool) { return s.instanceGateSaying(w, r, noun, mainGateCopy()) } // instanceGateSaying is instanceGateFor with the sentences of c. func (s *Server) instanceGateSaying(w http.ResponseWriter, r *http.Request, noun string, c gateCopy) (string, *Account, string, bool) { principal, acct, app, ok := s.apiAccountAppSaying(w, r, s.store.HasAppIn, c) if !ok { return "", nil, "", false } // Keyed on the TOKEN, not the address (see cliInstanceBurst): the // principal here is frequently an app, which has no address of its own, // and a signup burst is the workload rather than the abuse. if !s.cli.instances.allow(hashToken(bearer(r)), time.Now()) { jsonError(w, http.StatusTooManyRequests, c.tooMany(noun)) return "", nil, "", false } // A console with no deployment bucket configured can answer none of // these. Same shape as promote's signing-key check, and in the same // place: after the gate, before anything reaches for the store. if s.cfg.Releases == nil { jsonError(w, http.StatusServiceUnavailable, c.noBucket) return "", nil, "", false } return principal, acct, app, true } // instanceManifest reads the app's instances manifest and answers the // caller itself when it cannot be had. ok=false means a response has // already been written. // // CREATE is its only caller, and deliberately so: the manifest is policy // about what may be BROUGHT INTO existence (which domain, which pool, which // regions, which channel). Reads and deletes are about what already exists, // which no version of this manifest can retract. // // The two failures are kept apart on purpose: ABSENCE is an answer — the // feature does not exist for this app — while a read failure is the bucket // being unreachable. Reporting a hiccup as "instances are not enabled" // would tell an app its feature had been switched off. func (s *Server) instanceManifest(ctx context.Context, w http.ResponseWriter, acct *Account, app string) (*provision.Manifest, bool) { m, err := provision.GetManifest(ctx, s.cfg.Releases, acct.Sqid, app) switch { case err == nil: return m, true case errors.Is(err, os.ErrNotExist): // Membership (or binding) is already proven, so this may name its // cause and the operator act that fixes it. jsonError(w, http.StatusConflict, "instances are not enabled for "+app+" — an operator writes apps/"+ acct.Sqid+"/"+app+"/instances.json (carlos instances enable)") return nil, false default: log.Printf("console: api instances manifest %s/%s: %v", acct.Sqid, app, err) jsonError(w, http.StatusBadGateway, "could not read instance settings") return nil, false } } // instanceListRecord wraps provision.Record with the console's own // staged_version join (Task 8, issue #78) rather than adding the field to // Record itself: Record is the wire shape CreateRecord persists to the // bucket, and staged_version is never written there — it is computed fresh // at every list call from status/boxes/*.json, so giving Record the field // would risk a future write path serializing a stale or zero value into a // stored record that this join was never meant to touch. type instanceListRecord struct { provision.Record // StagedVersion is registry.Route.Version as last reported by whichever // box's adopt pass claimed this host, read from that box's own // status/boxes/