carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

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.
9package console
10
11import (
12 "context"
13 "fmt"
14 "net/http"
15 "strings"
16 "time"
17
18 "github.com/carlosframework/platform/internal/appconfig"
19)
20
21const (
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
32func (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