carlos / platform Public

zdd-6 task 4: the person-only swap-policy door

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev ea2d668a85018c44c92e166bbc3c76a4f2f69516 parent d20e2eb
7 files changed, +175 −4
  • internal/console/apiswap.go +86 −0
  • internal/console/apiswap_test.go +71 −0
  • internal/console/authcli.go +4 −0
  • internal/console/claim_invariant_fixtures_test.go +4 −0
  • internal/console/svcroutes_test.go +8 −3
  • internal/console/testdata/claim-routes.txt +1 −0
  • internal/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"},