// The swap policy door (zero-downtime spec §4.1, §9.1): `carlos apps set // --swap overlap|hold --swap-concurrency N`. // // GATE: apiAccountApp proves membership, and the route is registered with // the ZERO role set. The policy decides whether a release runs beside the // previous build on the instance's database (spec §2 decision 1), and §11 // says it is "changed only by app members": a credential the app itself // holds must not choose what its own migrations run beside. package console import ( "context" "fmt" "net/http" "strings" "time" "github.com/carlosframework/platform/internal/appconfig" ) const ( apiSwapNothing = "nothing to set: send swap, swap_concurrency, or both" apiSwapBad = `swap must be "overlap" or "hold"` apiSwapConcurrencyBad = "swap_concurrency must be a whole number, 1 or more" // apiSwapLimited is this door's own draft: the sibling doors' sentence // carries an em dash (Astra plan 6 R1 #9). apiSwapLimited = "too many requests. wait a minute and try again" // apiSwapAudit is app, mode, number, then "cli" or "console". apiSwapAudit = "set the swap policy for %s to %s, %d at a time (%s)" ) func (s *Server) handleAPISwapPut(w http.ResponseWriter, r *http.Request) { addr, acct, app, ok := s.apiAccountApp(w, r) if !ok { return } p, _ := principalFrom(r.Context()) if !s.allowWrite(p, time.Now()) { jsonError(w, http.StatusTooManyRequests, apiSwapLimited) return } // Pointers, so an absent half ("leave it") differs from a present one. // The strict reader refuses a misspelt key by name: this door's body is // two optional fields, so without it `swap_conurrency` would be dropped // and the request read as having said less than the caller meant. var req struct { Swap *string `json:"swap"` SwapConcurrency *int `json:"swap_concurrency"` } if !readJSONStrictLimit(w, r, &req, 8<<10) { return } if req.Swap == nil && req.SwapConcurrency == nil { jsonError(w, http.StatusBadRequest, apiSwapNothing) return } swap := "" if req.Swap != nil { swap = strings.TrimSpace(*req.Swap) if swap != appconfig.SwapOverlap && swap != appconfig.SwapHold { jsonError(w, http.StatusBadRequest, apiSwapBad) return } } concurrency := 0 if req.SwapConcurrency != nil { if *req.SwapConcurrency < 1 { jsonError(w, http.StatusBadRequest, apiSwapConcurrencyBad) return } concurrency = *req.SwapConcurrency } ctx, cancel := context.WithTimeout(r.Context(), configStoreTimeout) defer cancel() got, err := appconfig.SetSwapPolicy(ctx, s.cfg.Releases, acct.Sqid, app, swap, concurrency) if err != nil { s.configStoreError(w, fmt.Sprintf("api swap %s/%s", acct.Sqid, app), err) return } s.audit(acct, app, addr, "swap-policy", fmt.Sprintf(apiSwapAudit, app, got.Swap, got.Concurrency, "cli")) // Box-consumed like every config write: the serial makes the fast loop // read it within seconds. s.bumpSerial(ctx) writeJSON(w, http.StatusOK, map[string]any{"swap": got.Swap, "swap_concurrency": got.Concurrency}) }