zdd-6 task 4: the person-only swap-policy door
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7 files changed,
+175
−4
internal/console/apiswap.go+86 −0internal/console/apiswap_test.go+71 −0internal/console/authcli.go+4 −0internal/console/claim_invariant_fixtures_test.go+4 −0internal/console/svcroutes_test.go+8 −3internal/console/testdata/claim-routes.txt+1 −0internal/provision/plan9_b8_census_test.go+1 −1
diff --git a/internal/console/apiswap.go b/internal/console/apiswap.go| new file mode 100644 |
| index 000000000..644ff4853 |
| --- /dev/null |
| +++ b/internal/console/apiswap.go |
| @@ -0,0 +1,86 @@ |
| +// 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 ( |
| + // TODO-COPY zdd.api.swap-nothing |
| + apiSwapNothing = "nothing to set: send swap, swap_concurrency, or both" |
| + // TODO-COPY zdd.api.swap-bad |
| + apiSwapBad = `swap must be "overlap" or "hold"` |
| + // TODO-COPY zdd.api.concurrency-bad |
| + apiSwapConcurrencyBad = "swap_concurrency must be a whole number, 1 or more" |
| + // apiSwapAudit is app, mode, number, then "cli" or "console". |
| + // TODO-COPY zdd.api.swap-audit |
| + 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, "too many requests — wait a minute and try again") |
| + 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}) |
| +} |
diff --git a/internal/console/apiswap_test.go b/internal/console/apiswap_test.go| new file mode 100644 |
| index 000000000..80ff58e26 |
| --- /dev/null |
| +++ b/internal/console/apiswap_test.go |
| @@ -0,0 +1,71 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "net/http" |
| + "os" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/appconfig" |
| +) |
| + |
| +func TestAPISwapSetsAndEchoesThePolicy(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-swap") |
| + u := fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/kass/swap" |
| + |
| + code, _, body := rawReq(t, http.MethodPut, u, token, `{"swap":"hold","swap_concurrency":2}`) |
| + if code != http.StatusOK { |
| + t.Fatalf("set = %d %s", code, body) |
| + } |
| + var out struct { |
| + Swap string `json:"swap"` |
| + Concurrency int `json:"swap_concurrency"` |
| + } |
| + if err := json.Unmarshal([]byte(body), &out); err != nil || out.Swap != "hold" || out.Concurrency != 2 { |
| + t.Fatalf("echo = %s (%v), want hold/2", body, err) |
| + } |
| + got, err := appconfig.GetSwapPolicy(context.Background(), fx.rel, fx.sqid, "kass") |
| + if err != nil || got != (appconfig.SwapPolicy{Swap: "hold", Concurrency: 2}) { |
| + t.Fatalf("stored = %+v, %v", got, err) |
| + } |
| + if n := serial(t, fx); n != 1 { |
| + t.Fatalf("serial = %d, want 1: boxes act on the serial", n) |
| + } |
| + code, _, body = rawReq(t, http.MethodPut, u, token, `{"swap_concurrency":1}`) |
| + if code != http.StatusOK || json.Unmarshal([]byte(body), &out) != nil || out.Swap != "hold" || out.Concurrency != 1 { |
| + t.Fatalf("concurrency only = %d %s, want hold/1", code, body) |
| + } |
| +} |
| + |
| +func TestAPISwapRefusesBeforeWriting(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-swap-bad") |
| + u := fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/kass/swap" |
| + for _, bad := range []string{`{}`, `{"swap":"fast"}`, `{"swap":""}`, `{"swap_concurrency":0}`, |
| + `{"swap_concurrency":-1}`, `{"swap_concurrency":1.5}`, `{"swap":"hold","swap_concurrency":0}`, |
| + // A misspelt key is refused by name, not dropped: without the strict |
| + // reader this body would read as "nothing to set" at best, and as a |
| + // half-applied request at worst. |
| + `{"swap_conurrency":3}`, `{"swap":"hold","swap_conurrency":3}`} { |
| + if code, _, body := rawReq(t, http.MethodPut, u, token, bad); code != http.StatusBadRequest { |
| + t.Errorf("%s = %d %s, want 400", bad, code, body) |
| + } |
| + } |
| + if _, _, err := fx.rel.Get(context.Background(), appconfig.Key(fx.sqid, "kass")); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("a refused request wrote config.json: %v", err) |
| + } |
| +} |
| + |
| +func TestAPISwapNamesAMisspeltKey(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-swap-typo") |
| + u := fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/kass/swap" |
| + code, _, body := rawReq(t, http.MethodPut, u, token, `{"swap":"hold","swap_conurrency":3}`) |
| + if code != http.StatusBadRequest || !strings.Contains(body, "swap_conurrency") { |
| + t.Fatalf("typo = %d %s, want a 400 naming swap_conurrency", code, body) |
| + } |
| +} |
diff --git a/internal/console/authcli.go b/internal/console/authcli.go| index 0ede8f772..99eb8f66c 100644 |
| --- a/internal/console/authcli.go |
| +++ b/internal/console/authcli.go |
| @@ -849,6 +849,10 @@ func (s *Server) registerCLIRoutes() { |
| // success over a process that never restarted — which is the exact bug |
| // the probe exists to prevent, handed to the wrong caller. |
| s.cliRoute("POST /api/cli/apps/{account}/{app}/build-probe", svcrole.Set{}, s.handleAPIBuildProbe) |
| + // The swap policy (zero-downtime spec §4.1, apiswap.go). The ZERO Set: |
| + // §11 says only app members change it, and a credential the app holds |
| + // must not choose what its own migrations run beside. |
| + s.cliRoute("PUT /api/cli/apps/{account}/{app}/swap", svcrole.Set{}, s.handleAPISwapPut) |
| // The object-storage API (apistore.go, issue #55) — declare a store, read |
| // what became of it, and drive the walks that make it real. Beside the |
| // config routes on purpose: a store's credentials are DELIVERED as config, |
diff --git a/internal/console/claim_invariant_fixtures_test.go b/internal/console/claim_invariant_fixtures_test.go| index a4bfabbb2..90e4f9b80 100644 |
| --- a/internal/console/claim_invariant_fixtures_test.go |
| +++ b/internal/console/claim_invariant_fixtures_test.go |
| @@ -1420,6 +1420,10 @@ var claimFixtures = map[string]claimFixture{ |
| apps: claimRefs("bab/titogo"), |
| do: claimAPI(http.MethodPut, "/api/cli/apps/bab/titogo/sidecars-manifest", `{}`), |
| }}, |
| + "route PUT /api/cli/apps/{account}/{app}/swap": {door: &claimDoor{ |
| + apps: claimRefs("bab/titogo"), |
| + do: claimAPI(http.MethodPut, "/api/cli/apps/bab/titogo/swap", `{"swap":"hold"}`), |
| + }}, |
| "route PUT /api/cli/apps/{account}/{app}/telemetry": {door: &claimDoor{ |
| apps: claimRefs("bab/titogo"), |
| do: claimAPI(http.MethodPut, "/api/cli/apps/bab/titogo/telemetry", `{"errors":{"log_level":"warn"}}`), |
diff --git a/internal/console/svcroutes_test.go b/internal/console/svcroutes_test.go| index 3e01db093..4142e509c 100644 |
| --- a/internal/console/svcroutes_test.go |
| +++ b/internal/console/svcroutes_test.go |
| @@ -75,7 +75,7 @@ import ( |
| var ( |
| mintDoors = []string{"/instance-token", "/door-keys", "/api/cli/services/", "/email/credentials"} |
| fleetDoors = []string{"/api/cli/fleets/"} |
| - policyDoors = []string{"/instances-manifest", "/{app}/routes", "/email/pause", "/build-probe"} |
| + policyDoors = []string{"/instances-manifest", "/{app}/routes", "/email/pause", "/build-probe", "/{app}/swap"} |
| systemDoors = []string{"/api/cli/system/"} |
| ) |
| @@ -193,6 +193,11 @@ var closedDoors = []string{ |
| // over a process that never cycled, which is the precise failure the |
| // probe exists to prevent. Setting it is a person's one-time act. |
| "POST /api/cli/apps/{account}/{app}/build-probe", |
| + // The swap policy (zero-downtime spec §4.1, §11): whether a release runs |
| + // beside the previous build on the instance's database. "Changed only by |
| + // app members": a credential the app holds must not choose what its own |
| + // migrations run beside. |
| + "PUT /api/cli/apps/{account}/{app}/swap", |
| // The sidecar manifest, shut for the instances manifest's reason and one |
| // of its own. It is standing policy — which domain, pool, regions and |
| // channel a sidecar may ever come up on — and it also carries `args`, |
| @@ -911,8 +916,8 @@ func TestEveryClosedDoorRefusesAServiceCredential(t *testing.T) { |
| } |
| seen++ |
| } |
| - if seen != len(closedDoors) || seen != 46 { |
| - t.Errorf("walked %d closed doors, want all %d (and 46 today: instance-token, three door-key doors, the routes door, instances-manifest, sidecars-manifest, the route-channel move, two db replica doors, six fleets, four services, two email credentials, email pause, the build probe, the live tenancy read, the layout read, the activation-cancel door, seven system doors, two deployment-wide antivirus, three platform geoip doors, two pointer digest doors, the channel resign, and four store-entitlement doors)", |
| + if seen != len(closedDoors) || seen != 47 { |
| + t.Errorf("walked %d closed doors, want all %d (and 47 today: instance-token, three door-key doors, the routes door, instances-manifest, sidecars-manifest, the route-channel move, two db replica doors, six fleets, four services, two email credentials, email pause, the build probe, the swap policy, the live tenancy read, the layout read, the activation-cancel door, seven system doors, two deployment-wide antivirus, three platform geoip doors, two pointer digest doors, the channel resign, and four store-entitlement doors)", |
| seen, len(closedDoors)) |
| } |
| } |
diff --git a/internal/console/testdata/claim-routes.txt b/internal/console/testdata/claim-routes.txt| index 85afdee5a..dde451d13 100644 |
| --- a/internal/console/testdata/claim-routes.txt |
| +++ b/internal/console/testdata/claim-routes.txt |
| @@ -240,6 +240,7 @@ route PUT /api/cli/apps/{account}/{app}/email/events/push |
| route PUT /api/cli/apps/{account}/{app}/instances-manifest |
| route PUT /api/cli/apps/{account}/{app}/pipeline |
| route PUT /api/cli/apps/{account}/{app}/sidecars-manifest |
| +route PUT /api/cli/apps/{account}/{app}/swap |
| route PUT /api/cli/apps/{account}/{app}/telemetry |
| route PUT /api/cli/apps/{account}/{app}/version-target |
| route PUT /api/cli/system/geoip |
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go| index 825b70ebb..d49cda6c8 100644 |
| --- a/internal/provision/plan9_b8_census_test.go |
| +++ b/internal/provision/plan9_b8_census_test.go |
| @@ -570,7 +570,7 @@ var plan9B8CensusInventory = []censusEntry{ |
| {"internal/console/apisystemupdate.go", "Server.mirrorSystemArtifact", "nonrecord", "sel:Put", "76a8dcb558e3723ad5774788da37c22234c229dc3257ded5a6c7b04746c69c2f"}, |
| {"internal/console/appsettings.go", "Server.handleAppGate", "record-writer", "key:UpdateRecord", "011431f5459529c0abf354edc2cb772cb6a7f2816d3bb50d91598d7d241838ce"}, |
| // Registers instance and sidecar HTTP paths; record handlers use the reviewed provision writers. |
| - {"internal/console/authcli.go", "Server.registerCLIRoutes", "key-site", "lit:instances,lit:sidecars", "933a4200e75137c5093528da57cf4b3842fb251ce5cb5ac35fe8224fdde579eb"}, |
| + {"internal/console/authcli.go", "Server.registerCLIRoutes", "key-site", "lit:instances,lit:sidecars", "359b8c8f37aed115048ca82cf300bb7082a806fd22a07e1ee98dc6a5663461fe"}, |
| {"internal/console/browserrestart.go", "Server.handleBrowserRestart", "key-site", "lit:instances", "955d71813504f2cfec1e1f64817d5aa5a921aa38e1044c0e903d2cfbe4d073f4"}, |
| {"internal/console/channelmove.go", "Server.handleAPIInstanceChannel", "record-writer", "key:UpdateRecord", "91a2b3157bde9af832e8d95b17d0c384910229982945818f8b52f62dafddb342"}, |
| {"internal/console/channelmove.go", "Server.executePendingRouteChannel", "record-writer", "key:UpdateRecord", "b3b9a12213261a70b680ad5290957ebe7434eff633a73c2d80c75cc24d026d9a"}, |