| 1 | // The swap policy door (zero-downtime spec §4.1, §9.1): `carlos apps set |
| 2 | // --swap overlap|hold --swap-concurrency N`. |
| 3 | // |
| 4 | // GATE: apiAccountApp proves membership, and the route is registered with |
| 5 | // the ZERO role set. The policy decides whether a release runs beside the |
| 6 | // previous build on the instance's database (spec §2 decision 1), and §11 |
| 7 | // says it is "changed only by app members": a credential the app itself |
| 8 | // holds must not choose what its own migrations run beside. |
| 9 | package console |
| 10 | |
| 11 | import ( |
| 12 | "context" |
| 13 | "fmt" |
| 14 | "net/http" |
| 15 | "strings" |
| 16 | "time" |
| 17 | |
| 18 | "github.com/carlosframework/platform/internal/appconfig" |
| 19 | ) |
| 20 | |
| 21 | const ( |
| 22 | apiSwapNothing = "nothing to set: send swap, swap_concurrency, or both" |
| 23 | apiSwapBad = `swap must be "overlap" or "hold"` |
| 24 | apiSwapConcurrencyBad = "swap_concurrency must be a whole number, 1 or more" |
| 25 | // apiSwapLimited is this door's own draft: the sibling doors' sentence |
| 26 | // carries an em dash (Astra plan 6 R1 #9). |
| 27 | apiSwapLimited = "too many requests. wait a minute and try again" |
| 28 | // apiSwapAudit is app, mode, number, then "cli" or "console". |
| 29 | apiSwapAudit = "set the swap policy for %s to %s, %d at a time (%s)" |
| 30 | ) |
| 31 | |
| 32 | func (s *Server) handleAPISwapPut(w http.ResponseWriter, r *http.Request) { |
| 33 | addr, acct, app, ok := s.apiAccountApp(w, r) |
| 34 | if !ok { |
| 35 | return |
| 36 | } |
| 37 | p, _ := principalFrom(r.Context()) |
| 38 | if !s.allowWrite(p, time.Now()) { |
| 39 | jsonError(w, http.StatusTooManyRequests, apiSwapLimited) |
| 40 | return |
| 41 | } |
| 42 | // Pointers, so an absent half ("leave it") differs from a present one. |
| 43 | // The strict reader refuses a misspelt key by name: this door's body is |
| 44 | // two optional fields, so without it `swap_conurrency` would be dropped |
| 45 | // and the request read as having said less than the caller meant. |
| 46 | var req struct { |
| 47 | Swap *string `json:"swap"` |
| 48 | SwapConcurrency *int `json:"swap_concurrency"` |
| 49 | } |
| 50 | if !readJSONStrictLimit(w, r, &req, 8<<10) { |
| 51 | return |
| 52 | } |
| 53 | if req.Swap == nil && req.SwapConcurrency == nil { |
| 54 | jsonError(w, http.StatusBadRequest, apiSwapNothing) |
| 55 | return |
| 56 | } |
| 57 | swap := "" |
| 58 | if req.Swap != nil { |
| 59 | swap = strings.TrimSpace(*req.Swap) |
| 60 | if swap != appconfig.SwapOverlap && swap != appconfig.SwapHold { |
| 61 | jsonError(w, http.StatusBadRequest, apiSwapBad) |
| 62 | return |
| 63 | } |
| 64 | } |
| 65 | concurrency := 0 |
| 66 | if req.SwapConcurrency != nil { |
| 67 | if *req.SwapConcurrency < 1 { |
| 68 | jsonError(w, http.StatusBadRequest, apiSwapConcurrencyBad) |
| 69 | return |
| 70 | } |
| 71 | concurrency = *req.SwapConcurrency |
| 72 | } |
| 73 | ctx, cancel := context.WithTimeout(r.Context(), configStoreTimeout) |
| 74 | defer cancel() |
| 75 | got, err := appconfig.SetSwapPolicy(ctx, s.cfg.Releases, acct.Sqid, app, swap, concurrency) |
| 76 | if err != nil { |
| 77 | s.configStoreError(w, fmt.Sprintf("api swap %s/%s", acct.Sqid, app), err) |
| 78 | return |
| 79 | } |
| 80 | s.audit(acct, app, addr, "swap-policy", fmt.Sprintf(apiSwapAudit, app, got.Swap, got.Concurrency, "cli")) |
| 81 | // Box-consumed like every config write: the serial makes the fast loop |
| 82 | // read it within seconds. |
| 83 | s.bumpSerial(ctx) |
| 84 | writeJSON(w, http.StatusOK, map[string]any{"swap": got.Swap, "swap_concurrency": got.Concurrency}) |
| 85 | } |
| 86 | |