| index fb10e8dd9..46ced56bf 100644 |
| --- a/internal/console/fleets.go |
| +++ b/internal/console/fleets.go |
| @@ -451,14 +451,14 @@ func (s *Server) handleAPIFleetDetachBox(w http.ResponseWriter, r *http.Request) |
| // |
| // 2026-08-06 ruling: the STORE's placement column (Store.AppPlacement) is |
| // consulted FIRST — the fact `carlos apps place` and `carlos apps create |
| -// -place` both write (handleAPIPlace, handleAPICreateApp) — because it is |
| +// -place` both write (placeAppTarget, handleAPICreateApp) — because it is |
| // the only source that exists the instant an app is placed with zero |
| // routes yet, which a route-derived reading structurally cannot answer (see |
| // the KNOWN LIMITATION this comment used to carry, below). Only when the |
| // store has never recorded a placement for this app (an app placed by an |
| // OLDER build, before this column existed, still carries its placement |
| // only in its routes) does this fall back to the original route-derived |
| -// reading: handleAPIPlace stamps every one of an app's routes with the SAME |
| +// reading: placeAppTarget stamps every one of an app's routes with the SAME |
| // target in one call, so by construction they are uniform once placed, and |
| // the first fleet-shaped Region found among them is authoritative. |
| // |
| @@ -466,7 +466,7 @@ func (s *Server) handleAPIFleetDetachBox(w http.ResponseWriter, r *http.Request) |
| // (apiinstances.go): a fleet-placed app's NEW instances must inherit its |
| // placement uniformly (spec's "one flag on the app, all its instances |
| // follow") rather than trusting whatever region a create request's body |
| -// happens to send — see handleAPIPlace's doc comment for why that |
| +// happens to send — see placeAppTarget's doc comment for why that |
| // inheritance has to be a separate mechanism from the route/record stamping |
| // below, not the same one. |
| func (s *Server) appPlacement(reg *registry.Registry, acct *Account, app string) (string, error) { |
| @@ -494,16 +494,33 @@ func (s *Server) appPlacement(reg *registry.Registry, acct *Account, app string) |
| return "", nil |
| } |
| |
| -// handleAPIPlace answers `carlos apps place`: repoints where an app's routes |
| -// (and its already-provisioned instances) should run. target "" clears |
| -// placement back to platform-served/unconstrained; any other value must |
| -// parse as "fleet/<name>" (registry.FleetPlacement) naming a fleet the |
| -// CALLER'S OWN account owns — placing onto another account's fleet is |
| -// refused with the same byte-identical 404 apiFleet gives an unowned one, |
| -// since fleet names are a flat, cross-account namespace (see this file's |
| -// package doc comment). |
| +// placeAppTarget is placement's whole mechanism, and the ONLY place it |
| +// lives: repoints where an app's routes (and its already-provisioned |
| +// instances) should run. target "" clears placement back to |
| +// platform-served/unconstrained; any other value must parse as |
| +// "fleet/<name>" (registry.FleetPlacement) naming a fleet acct ITSELF owns — |
| +// placing onto another account's fleet is refused with the same |
| +// byte-identical 404 apiFleet gives an unowned one, since fleet names are a |
| +// flat, cross-account namespace (see this file's package doc comment). |
| // |
| -// THE TRIPWIRE this handler exists to close: placement is stored in |
| +// Every door onto placement calls this — the CLI API's handleAPIPlace, the |
| +// app Instances tab's handleAppPlace, the fleet page's |
| +// handleFleetPlacePage — because the ordering discipline below is the whole |
| +// correctness of the operation, and a second copy of it is a second place |
| +// for it to be got wrong. Callers own authentication, the app-ownership |
| +// gate, rate limiting, the audit line and how the outcome is rendered; |
| +// nothing else. |
| +// |
| +// The return is a translation contract, not an HTTP response: (0, "") means |
| +// it landed. A non-zero status with a MESSAGE is an ordinary refusal the |
| +// caller renders in its own idiom (jsonError, or a flash). A non-zero status |
| +// with an EMPTY message is exactly one case — http.StatusNotFound, the |
| +// cross-account target refusal — and it means the caller must answer with a |
| +// byte-identical http.NotFound, never a message of its own: a distinguishable |
| +// error there would turn this door into an oracle for which fleet names exist |
| +// in someone else's account. |
| +// |
| +// THE TRIPWIRE this function exists to close: placement is stored in |
| // Route.Region (SetPlacement) AND, separately, in every one of the app's |
| // already-provisioned instance records' Region field |
| // (provision.SetRecordRegion) — both are read by provision.Reconcile's |
| @@ -513,9 +530,9 @@ func (s *Server) appPlacement(reg *registry.Registry, acct *Account, app string) |
| // Region from the REQUEST BODY (handleAPIInstanceCreate, apiinstances.go), |
| // and provision.ValidRegion's shape (no "/") would refuse a "fleet/<name>" |
| // value outright if it somehow arrived that way. A new instance inheriting |
| -// placement is a SEPARATE mechanism (appPlacement, below, consulted by |
| +// placement is a SEPARATE mechanism (appPlacement, above, consulted by |
| // handleAPIInstanceCreate directly) — spec's "one flag on the app, all its |
| -// instances follow". What THIS handler's record write closes is the gap |
| +// instances follow". What THIS function's record write closes is the gap |
| // that inheritance cannot: every instance that ALREADY EXISTS at the moment |
| // placement changes. Inheritance only ever applies going forward, at |
| // create time, so without the write below an already-provisioned record |
| @@ -536,54 +553,35 @@ func (s *Server) appPlacement(reg *registry.Registry, acct *Account, app string) |
| // A console with no deployment bucket configured (cfg.Releases == nil) |
| // refuses the whole request rather than silently doing the routes-only |
| // half. |
| -func (s *Server) handleAPIPlace(w http.ResponseWriter, r *http.Request) { |
| - addr, acct, app, ok := s.apiAccountApp(w, r) |
| - if !ok { |
| - return |
| - } |
| - if !s.cli.write.allow(addr, time.Now()) { |
| - jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") |
| - return |
| - } |
| +func (s *Server) placeAppTarget(ctx context.Context, acct *Account, app, target string) (status int, errMsg string) { |
| if s.cfg.Releases == nil { |
| - jsonError(w, http.StatusServiceUnavailable, |
| - "no deployment bucket on this host — placement needs it to stamp this app's instances") |
| - return |
| - } |
| - var req struct { |
| - Target string `json:"target"` |
| + return http.StatusServiceUnavailable, |
| + "no deployment bucket on this host — placement needs it to stamp this app's instances" |
| } |
| - if !readJSON(w, r, &req) { |
| - return |
| - } |
| - target := strings.TrimSpace(req.Target) |
| |
| reg, err := openRegistry(s.cfg.Registry) |
| if err != nil { |
| log.Printf("console: place %s/%s: open registry: %v", acct.Sqid, app, err) |
| - jsonError(w, http.StatusInternalServerError, "could not reach the registry") |
| - return |
| + return http.StatusInternalServerError, "could not reach the registry" |
| } |
| |
| if target != "" { |
| fleetName, validShape := registry.FleetPlacement(target) |
| if !validShape { |
| - jsonError(w, http.StatusBadRequest, `invalid target — want "" or "fleet/<name>"`) |
| - return |
| + return http.StatusBadRequest, `invalid target — want "" or "fleet/<name>"` |
| } |
| f, err := reg.GetFleet(fleetName) |
| if err != nil { |
| log.Printf("console: place %s/%s: get fleet %s: %v", acct.Sqid, app, fleetName, err) |
| - jsonError(w, http.StatusInternalServerError, "could not check the fleet") |
| - return |
| + return http.StatusInternalServerError, "could not check the fleet" |
| } |
| if f == nil || f.Account != acct.Sqid { |
| // Unknown and not-yours are the same refusal everywhere else in |
| // this file (apiFleet); place's target check is no exception — |
| // this door must not become a way to learn which fleet names |
| - // exist in someone else's account. |
| - http.NotFound(w, r) |
| - return |
| + // exist in someone else's account. The empty message is the |
| + // signal to the caller: answer http.NotFound, say nothing. |
| + return http.StatusNotFound, "" |
| } |
| } |
| |
| @@ -591,20 +589,18 @@ func (s *Server) handleAPIPlace(w http.ResponseWriter, r *http.Request) { |
| // THIS before it ever walks a route. It has to land before routes/records |
| // are stamped below, not after — the same "never observe an app in a |
| // getting-placed state that some OTHER path already trusts" reasoning |
| - // the ORDER MATTERS paragraph below applies to routes-before-records, one |
| + // the ORDER MATTERS paragraph above applies to routes-before-records, one |
| // step earlier: a concurrent instance-create must never inherit a target |
| // this request has not actually committed to. |
| if err := s.store.SetAppPlacement(acct.ID, app, target); err != nil { |
| log.Printf("console: place %s/%s: set placement: %v", acct.Sqid, app, err) |
| - jsonError(w, http.StatusInternalServerError, "could not record placement — try again") |
| - return |
| + return http.StatusInternalServerError, "could not record placement — try again" |
| } |
| |
| routes, err := reg.List() |
| if err != nil { |
| log.Printf("console: place %s/%s: list routes: %v", acct.Sqid, app, err) |
| - jsonError(w, http.StatusBadGateway, "could not read the app's routes — try again") |
| - return |
| + return http.StatusBadGateway, "could not read the app's routes — try again" |
| } |
| for _, rt := range routes { |
| if rt.Account != acct.Sqid || rt.App != app { |
| @@ -612,19 +608,17 @@ func (s *Server) handleAPIPlace(w http.ResponseWriter, r *http.Request) { |
| } |
| if err := reg.SetPlacement(rt.Host, target); err != nil { |
| log.Printf("console: place %s/%s: set placement on %s: %v", acct.Sqid, app, rt.Host, err) |
| - jsonError(w, http.StatusBadGateway, "could not update every route — try again") |
| - return |
| + return http.StatusBadGateway, "could not update every route — try again" |
| } |
| } |
| |
| - ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) |
| + ctx, cancel := context.WithTimeout(ctx, instanceStoreTimeout) |
| defer cancel() |
| prefix := provision.RecordsPrefix(acct.Sqid, app) |
| keys, err := s.cfg.Releases.List(ctx, prefix) |
| if err != nil { |
| log.Printf("console: place %s/%s: list instances: %v", acct.Sqid, app, err) |
| - jsonError(w, http.StatusBadGateway, "could not update this app's instances — try again") |
| - return |
| + return http.StatusBadGateway, "could not update this app's instances — try again" |
| } |
| for _, key := range keys { |
| rest, ok := strings.CutPrefix(key, prefix) |
| @@ -647,10 +641,45 @@ func (s *Server) handleAPIPlace(w http.ResponseWriter, r *http.Request) { |
| continue |
| } |
| log.Printf("console: place %s/%s: stamp instance %s: %v", acct.Sqid, app, host, err) |
| - jsonError(w, http.StatusBadGateway, "could not update every instance — try again") |
| - return |
| + return http.StatusBadGateway, "could not update every instance — try again" |
| } |
| } |
| + return 0, "" |
| +} |
| + |
| +// handleAPIPlace answers `carlos apps place`: the bearer-authenticated door |
| +// onto placeAppTarget. Everything that makes placement CORRECT lives in that |
| +// function (see its doc comment); what belongs here is the CLI API's own |
| +// share — bearer resolution, the write budget, the JSON body, the "(cli)" |
| +// audit line, and translating the core's (status, message) onto this API's |
| +// refusal shapes. |
| +func (s *Server) handleAPIPlace(w http.ResponseWriter, r *http.Request) { |
| + addr, acct, app, ok := s.apiAccountApp(w, r) |
| + if !ok { |
| + return |
| + } |
| + if !s.cli.write.allow(addr, time.Now()) { |
| + jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") |
| + return |
| + } |
| + var req struct { |
| + Target string `json:"target"` |
| + } |
| + if !readJSON(w, r, &req) { |
| + return |
| + } |
| + target := strings.TrimSpace(req.Target) |
| + |
| + switch status, msg := s.placeAppTarget(r.Context(), acct, app, target); { |
| + case status == http.StatusNotFound: |
| + // The cross-account target refusal — byte-identical to any other |
| + // not-found, never a JSON message of its own. |
| + http.NotFound(w, r) |
| + return |
| + case status != 0: |
| + jsonError(w, status, msg) |
| + return |
| + } |
| |
| detail := "cleared placement for " + app + " (cli)" |
| if target != "" { |