carlos / platform Public

zdd-6 task 7: carlos ship and deploy --swap hold, stored with the release

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev bcb065786fc0b36adb3703cac0bbfe7408abb0e2 parent c279daf
7 files changed, +289 −3
  • apimode.go +28 −1
  • cli.go +32 −1
  • deploy.go +7 −1
  • internal/console/apicli.go +12 −0
  • internal/console/apiship_swap.go +50 −0
  • internal/console/apiship_swap_test.go +72 −0
  • ship_swap_test.go +88 −0
diff --git a/apimode.go b/apimode.go
index 67ecb8b8b..8a813d42e 100644
--- a/apimode.go
+++ b/apimode.go
@@ -77,6 +77,10 @@ type apiSession struct {
// chooser): set by zero-arg deploy, whose ONE combined offer at the end
// of first-run resolution covers everything (spec: one save offer).
deferSaves bool
+ // shipSwap is the release's own swap demand for ship ("hold" or ""),
+ // set from --swap by runShip and runDeploy. A field for deferSaves'
+ // reason: ship has many callers and only those two take the flag.
+ shipSwap string
}
// resolveMode decides which mode the CLI is in. (nil, nil) means direct mode
@@ -4283,7 +4287,8 @@ func (s *apiSession) ship(ctx context.Context, a apiAcct, app, version, kind, la
Message string `json:"message,omitempty"`
Capabilities json.RawMessage `json:"capabilities,omitempty"`
ProgramChange bool `json:"program_change,omitempty"`
- }{version, kind, label, notes, message, capsRaw, programChange})
+ Swap string `json:"swap,omitempty"`
+ }{version, kind, label, notes, message, capsRaw, programChange, s.shipSwap})
if err != nil {
return err
}
@@ -4313,6 +4318,9 @@ func (s *apiSession) ship(ctx context.Context, a apiAcct, app, version, kind, la
if err := checkCapabilitiesEcho(capsSet, body); err != nil {
return apiShipResult{}, err
}
+ if err := checkSwapEcho(s.shipSwap, body); err != nil {
+ return apiShipResult{}, err
+ }
// Decoded from the response, not echoed back from the version this
// call sent: a minted ship (empty version in) has no other way to
// learn what the console actually shipped it as.
@@ -4381,6 +4389,25 @@ func checkCapabilitiesEcho(sent *caps.Set, body []byte) error {
return nil
}
+// TODO-COPY zdd.ship.no-echo
+const shipSwapNoEcho = "ship: this console predates --swap and stored the release without it, so it would swap in overlap mode. Nothing was promoted. Upgrade the console, then ship under a new version"
+
+// checkSwapEcho is checkCapabilitiesEcho's twin for the swap demand: a
+// console from before the field accepts the ship and drops the demand, and a
+// release stored without it swaps in overlap. Checked only when one was sent.
+func checkSwapEcho(sent string, body []byte) error {
+ if sent == "" {
+ return nil
+ }
+ var echo struct {
+ Swap string `json:"swap"`
+ }
+ if err := json.Unmarshal(body, &echo); err != nil || echo.Swap != sent {
+ return errors.New(shipSwapNoEcho)
+ }
+ return nil
+}
+
// writeFilePart streams one local file into a multipart part named name, so
// a 30MiB artifact is read a chunk at a time rather than buffered whole
// before it starts crossing the wire.
diff --git a/cli.go b/cli.go
index 0cd46eee7..c17a06ac2 100644
--- a/cli.go
+++ b/cli.go
@@ -2124,6 +2124,24 @@ func publicKey() ed25519.PublicKey {
// stored manifest, BuiltAt included, so a test of the re-ship path pins it.
var shipNow = time.Now
+// TODO-COPY zdd.ship.flag-swap
+// TODO-COPY zdd.ship.bad-swap
+// TODO-COPY zdd.ship.hold-note
+const (
+ shipSwapFlagHelp = "hold: this release stops each instance before starting it, for a migration the previous build cannot share the database with"
+ shipBadSwap = "--swap takes hold, or leave it off (%q is not a swap demand)"
+ shipHoldNote = " this release swaps in hold mode: each instance stops before the new build starts"
+)
+
+// validSwapFlag is --swap's client-side gate on ship and deploy (spec §4.2):
+// a release may demand hold, or nothing.
+func validSwapFlag(s string) error {
+ if s == "" || s == release.DemandHold {
+ return nil
+ }
+ return fmt.Errorf(shipBadSwap, s)
+}
+
func runShip(args []string) {
fs := flag.NewFlagSet("ship", flag.ExitOnError)
messageFlags := addMutationMessageFlags(fs)
@@ -2137,6 +2155,7 @@ func runShip(args []string) {
label := fs.String("label", "", "one-line human label for this release (\"fixing modals\")")
notes := fs.String("notes", "", "changelog fragment for this release")
notesFile := fs.String("notes-file", "", "file holding the changelog fragment (mutually exclusive with --notes)")
+ swap := fs.String("swap", "", shipSwapFlagHelp)
programChange := fs.Bool("program-change", false, "accept that this release's Go main package differs from the app's previous release (recorded in the manifest)")
fs.Usage = flagUsage(fs, "")
fs.Parse(args)
@@ -2145,7 +2164,7 @@ func runShip(args []string) {
// the target (release versioning spec, 2026-08-08). Everything else about
// this check is unchanged — an app and at least one file are still the
// minimum a ship can mean.
- usage := "usage: carlos ship --app <a> [--version <sha>] [--label <one line>] [--notes <text> | --notes-file <file>] [--kind binary|static] [--account <sqid-or-name>] [--account-id <sqid>] [--console <origin>] [--capabilities <file.json>] [--program-change] <files-or-site-dir>"
+ usage := "usage: carlos ship --app <a> [--version <sha>] [--label <one line>] [--notes <text> | --notes-file <file>] [--kind binary|static] [--account <sqid-or-name>] [--account-id <sqid>] [--console <origin>] [--capabilities <file.json>] [--program-change] [--swap hold] <files-or-site-dir>"
if fs.NArg() == 0 {
log.Fatal(usage)
}
@@ -2172,6 +2191,9 @@ func runShip(args []string) {
if err := release.ValidateLabelNotes(*label, noteText); err != nil {
log.Fatal(err)
}
+ if err := validSwapFlag(*swap); err != nil {
+ log.Fatal(err)
+ }
// Read and decode the declaration BEFORE the mode split, so both doors
// refuse the same bytes with the same message at the operator's terminal
@@ -2223,6 +2245,7 @@ func runShip(args []string) {
log.Fatal(err)
}
}
+ sess.shipSwap = *swap
if _, err := runShipAPI(sess, acct, fs, app2, *ver, *kind, *target, *label, noteText, capsSet, false, *programChange, message); err != nil {
log.Fatal(err)
}
@@ -2308,6 +2331,8 @@ func runShip(args []string) {
// nil when --capabilities was not given: omitempty then keeps the
// manifest bytes byte-identical to what this verb wrote yesterday.
Capabilities: capsSet,
+ // "" when --swap was not given: omitempty keeps the bytes as before.
+ Swap: *swap,
}
// The wrong-program rule, shared with the console's API door: refuse a
// release whose Go main package differs from the app's previous release
@@ -2328,6 +2353,9 @@ func runShip(args []string) {
log.Fatal(err)
}
fmt.Fprintf(stdout, "%s %s %s (%d artifacts)\n", colorFor(stdout).ok("shipped"), *app, shipVersion, len(files))
+ if *swap == release.DemandHold {
+ mutedf(stdout, shipHoldNote)
+ }
}
// readCapabilities is the ship-time declaration surface: a file holding
@@ -2472,6 +2500,9 @@ func runShipAPI(sess *apiSession, acct apiAcct, fs *flag.FlagSet, app, ver, kind
} else {
fmt.Fprintf(stdout, "%s %s %s\n", colorFor(stdout).ok("shipped"), app, res.Version)
}
+ if sess.shipSwap == release.DemandHold {
+ mutedf(stdout, shipHoldNote)
+ }
// res carries more than the print above needs: BuildProbe is the app's
// build-report path, which `carlos deploy` turns into its watch's exit
// criterion. Returned rather than acted on here — ship itself has no
diff --git a/deploy.go b/deploy.go
index 7ff7eb730..56606ddef 100644
--- a/deploy.go
+++ b/deploy.go
@@ -87,11 +87,13 @@ func runDeploy(args []string) {
temporary := fs.Bool("temporary", false, "create a new temporary app for this static site: named tmp-..., it moves to the Trash after --ttl and is purged 3 days later") // C11.21
ttlFlag := fs.String("ttl", "", "how long a temporary app lives, 1h to 30d (default 7d); implies --temporary") // C11.22
regionFlag := fs.String("region", "", flagRegionDeploy)
+ swap := fs.String("swap", "", shipSwapFlagHelp)
fs.Usage = flagUsage(fs, "")
fs.Parse(args)
usage := "usage: carlos deploy --app <a> [--version <sha>] [--kind binary|static] [--target linux-arm64] " +
"[--account <sqid-or-name>] [--account-id <sqid>] [--console <origin> | -d <destination>] [--capabilities <file.json>] " +
- "[--channel <ch>] [--host <host>] [--region <code>] [--label <text>] [--notes <text>] [--temporary] [--ttl 7d] [--timeout 120s] <files-or-site-dir>\n" + // C11.23
+ // TODO-COPY zdd.deploy.usage-flag
+ "[--channel <ch>] [--host <host>] [--region <code>] [--label <text>] [--notes <text>] [--swap hold] [--temporary] [--ttl 7d] [--timeout 120s] <files-or-site-dir>\n" + // C11.23
"or, in a project with saved defaults (or at a terminal, to create them): carlos deploy [-d <destination>]"
temp, ttl, terr := temporaryDeployFlags(*temporary, flagWasSet(fs, "ttl"), *ttlFlag, *hostFlag, flagWasSet(fs, "kind"), *kind, fs.NArg())
if terr != nil {
@@ -110,6 +112,9 @@ func runDeploy(args []string) {
if err := deployPreflight(".", flagWasSet(fs, "version")); err != nil {
log.Fatal(err)
}
+ if err := validSwapFlag(*swap); err != nil {
+ log.Fatal(err)
+ }
// A bad capability file is still refused before anything ships — but the
// app name may be left to resolution now, so with an omitted --app the
// read happens again once the name is known (ship's rule).
@@ -124,6 +129,7 @@ func runDeploy(args []string) {
sess := requireDeployAPISession(*consoleFlag) // API-mode only: member verb
sess.deferSaves = true // deploy makes ONE combined save offer, below
printUsing(stdout, sess) // printed HERE, once: runShipAPI's own banner is suppressed below
+ sess.shipSwap = *swap
acct := resolvedAccount(sess, acctRaw, acctIDRaw)
ctx := context.Background()
diff --git a/internal/console/apicli.go b/internal/console/apicli.go
index ae5ef819b..8b95788a4 100644
--- a/internal/console/apicli.go
+++ b/internal/console/apicli.go
@@ -1889,6 +1889,9 @@ func (s *Server) handleAPIShip(w http.ResponseWriter, r *http.Request) {
// the app's previous release. Absent from every request a CLI shipped
// before the flag existed, which then meets the refusal below.
ProgramChange bool `json:"program_change,omitempty"`
+ // Swap is the release's own demand (zero-downtime spec §4.2): "hold"
+ // or absent. ADDITIVE: absent from every request without --swap.
+ Swap string `json:"swap,omitempty"`
}
// The budget is release.MaxNotesLen — the biggest single field the meta
// part now carries — plus 8KiB of headroom for everything else in it:
@@ -1975,6 +1978,9 @@ func (s *Server) handleAPIShip(w http.ResponseWriter, r *http.Request) {
return
}
}
+ if refuseBadSwap(w, meta.Swap) {
+ return
+ }
maxParts := apiShipMaxPartsBinary
if meta.Kind == "static" {
maxParts = apiShipMaxPartsStatic
@@ -2137,6 +2143,7 @@ func (s *Server) handleAPIShip(w http.ResponseWriter, r *http.Request) {
Capabilities: declared, // nil when the meta part carried no key
Label: meta.Label,
Notes: meta.Notes,
+ Swap: meta.Swap,
}
// Longer than the other CLI doors' 15-20s: this one moves real bytes —
// up to apiShipMaxBytes of them — into the bucket, not a handful of
@@ -2180,6 +2187,9 @@ func (s *Server) handleAPIShip(w http.ResponseWriter, r *http.Request) {
"version "+ver+" was already shipped with different capabilities — versions are immutable; ship under a new version")
return
}
+ if refuseSwapConflict(w, existing.Swap, meta.Swap, ver) {
+ return
+ }
// Identical content already shipped: answer success without
// writing anything — calling release.Ship again would only
// rediscover this the slow way, and would in fact trip its OWN
@@ -2195,6 +2205,7 @@ func (s *Server) handleAPIShip(w http.ResponseWriter, r *http.Request) {
// above, and echoing what is ON RECORD is the honest answer to "what
// did this version end up declaring".
addCapabilitiesEcho(body, existing.Capabilities)
+ addSwapEcho(body, existing.Swap)
addBuildProbe(body, probe)
writeJSON(w, http.StatusOK, body)
return
@@ -2249,6 +2260,7 @@ func (s *Server) handleAPIShip(w http.ResponseWriter, r *http.Request) {
}
body := map[string]any{"result": "shipped", "version": ver, "artifacts": out}
addCapabilitiesEcho(body, m.Capabilities)
+ addSwapEcho(body, m.Swap)
addBuildProbe(body, probe)
writeJSON(w, http.StatusCreated, body)
}
diff --git a/internal/console/apiship_swap.go b/internal/console/apiship_swap.go
new file mode 100644
index 000000000..1b728c3d9
--- /dev/null
+++ b/internal/console/apiship_swap.go
@@ -0,0 +1,50 @@
+package console
+
+import (
+ "fmt"
+ "net/http"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// The ship door's swap-demand sentences live here, not in apicli.go: the
+// moves copy gate pins the jsonError text of that file.
+const (
+ // TODO-COPY zdd.api.ship-swap-bad
+ apiShipSwapBad = `swap must be "hold" or left out`
+ // TODO-COPY zdd.api.ship-swap-conflict
+ apiShipSwapConflict = "version %s was already shipped with a different swap demand. Versions are immutable: ship under a new version"
+)
+
+// refuseBadSwap answers 400 and reports true when the meta part carries a
+// swap demand other than "hold" (zero-downtime spec §4.2).
+func refuseBadSwap(w http.ResponseWriter, swap string) bool {
+ if swap != "" && swap != release.DemandHold {
+ jsonError(w, http.StatusBadRequest, apiShipSwapBad)
+ return true
+ }
+ return false
+}
+
+// refuseSwapConflict answers 409 and reports true when a re-ship of an
+// existing version adds or drops the demand. The demand decides how the bytes
+// are swapped in, so such a re-ship is a different release: the capability
+// block's rule.
+func refuseSwapConflict(w http.ResponseWriter, stored, sent, ver string) bool {
+ if stored != sent {
+ jsonError(w, http.StatusConflict, fmt.Sprintf(apiShipSwapConflict, ver))
+ return true
+ }
+ return false
+}
+
+// addSwapEcho adds the release's own swap demand to a ship response
+// (zero-downtime spec §4.2), closing the gap addCapabilitiesEcho closes: a
+// console from before the field ignores it and stores a release that demands
+// nothing, which would swap in overlap; a client that sent a demand refuses
+// an answer without its echo.
+func addSwapEcho(body map[string]any, swap string) {
+ if swap != "" {
+ body["swap"] = swap
+ }
+}
diff --git a/internal/console/apiship_swap_test.go b/internal/console/apiship_swap_test.go
new file mode 100644
index 000000000..b5b3e7656
--- /dev/null
+++ b/internal/console/apiship_swap_test.go
@@ -0,0 +1,72 @@
+package console
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func metaPartSwap(t *testing.T, version, swap string) shipPart {
+ t.Helper()
+ raw, err := json.Marshal(map[string]string{"version": version, "kind": "binary", "swap": swap})
+ if err != nil {
+ t.Fatal(err)
+ }
+ return shipPart{name: "meta", body: raw}
+}
+
+func TestAPIShipStoresTheSwapDemand(t *testing.T) {
+ fx := newConsoleFixture(t)
+ liftShipBudget(fx)
+ token := cliToken(t, fx, "paul@keymail.dev", "code-ship-swap")
+ u := fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/kass/releases"
+ art := shipPart{name: "linux-arm64", body: randomBytes(t, 64)}
+
+ code, out := postShip(t, u, token, []shipPart{metaPartSwap(t, "v9", "hold"), art})
+ if code != http.StatusCreated || out["swap"] != "hold" {
+ t.Fatalf("ship = %d %v, want 201 echoing swap hold", code, out)
+ }
+ m, err := release.GetManifest(context.Background(), fx.rel, fx.sqid, "kass", "v9")
+ if err != nil || m.Swap != release.DemandHold {
+ t.Fatalf("stored manifest swap = %q, %v", m.Swap, err)
+ }
+ if code, out = postShip(t, u, token, []shipPart{metaPartSwap(t, "v9", "hold"), art}); code != http.StatusOK || out["swap"] != "hold" {
+ t.Fatalf("identical re-ship = %d %v", code, out)
+ }
+ if code, out = postShip(t, u, token, []shipPart{metaPart(t, "v9", "binary"), art}); code != http.StatusConflict {
+ t.Fatalf("re-ship without the demand = %d %v, want 409", code, out)
+ }
+}
+
+func TestAPIShipRefusesAnyOtherDemand(t *testing.T) {
+ fx := newConsoleFixture(t)
+ liftShipBudget(fx)
+ token := cliToken(t, fx, "paul@keymail.dev", "code-ship-swap-bad")
+ u := fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/kass/releases"
+ for _, bad := range []string{"overlap", "fast", "HOLD"} {
+ code, out := postShip(t, u, token, []shipPart{metaPartSwap(t, "v10", bad), {name: "linux-arm64", body: randomBytes(t, 8)}})
+ if code != http.StatusBadRequest {
+ t.Errorf("swap %q = %d %v, want 400", bad, code, out)
+ }
+ }
+ if _, err := release.GetManifest(context.Background(), fx.rel, fx.sqid, "kass", "v10"); err == nil {
+ t.Fatal("a refused ship stored a manifest")
+ }
+}
+
+func TestAPIShipWithoutSwapAnswersAsBefore(t *testing.T) {
+ fx := newConsoleFixture(t)
+ liftShipBudget(fx)
+ token := cliToken(t, fx, "paul@keymail.dev", "code-ship-noswap")
+ u := fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/kass/releases"
+ code, out := postShip(t, u, token, []shipPart{metaPart(t, "v11", "binary"), {name: "linux-arm64", body: randomBytes(t, 8)}})
+ if code != http.StatusCreated {
+ t.Fatalf("ship = %d %v", code, out)
+ }
+ if _, present := out["swap"]; present {
+ t.Fatalf("a ship with no demand echoes swap: %v", out)
+ }
+}
diff --git a/ship_swap_test.go b/ship_swap_test.go
new file mode 100644
index 000000000..8de0bcab7
--- /dev/null
+++ b/ship_swap_test.go
@@ -0,0 +1,88 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestValidSwapFlag(t *testing.T) {
+ for _, ok := range []string{"", "hold"} {
+ if err := validSwapFlag(ok); err != nil {
+ t.Errorf("validSwapFlag(%q) = %v", ok, err)
+ }
+ }
+ for _, bad := range []string{"overlap", "Hold", "fast"} {
+ if err := validSwapFlag(bad); err == nil || err.Error() != fmt.Sprintf(shipBadSwap, bad) {
+ t.Errorf("validSwapFlag(%q) = %v", bad, err)
+ }
+ }
+}
+
+func TestCheckSwapEcho(t *testing.T) {
+ if err := checkSwapEcho("", []byte(`{}`)); err != nil {
+ t.Errorf("no demand sent: %v", err)
+ }
+ if err := checkSwapEcho("hold", []byte(`{"swap":"hold"}`)); err != nil {
+ t.Errorf("echoed: %v", err)
+ }
+ if err := checkSwapEcho("hold", []byte(`{"result":"shipped"}`)); err == nil || err.Error() != shipSwapNoEcho {
+ t.Errorf("an old console's answer passed: %v", err)
+ }
+}
+
+func TestAPISessionShipSendsTheDemand(t *testing.T) {
+ var meta string
+ echo := `,"swap":"hold"`
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ mr, err := r.MultipartReader()
+ if err != nil {
+ t.Fatal(err)
+ }
+ var p *multipart.Part
+ if p, err = mr.NextPart(); err != nil {
+ t.Fatal(err)
+ }
+ b, _ := io.ReadAll(p)
+ meta = string(b)
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+ io.WriteString(w, `{"result":"shipped","version":"1.2.3"`+echo+`}`)
+ }))
+ defer ts.Close()
+ file := filepath.Join(t.TempDir(), "bin")
+ if err := os.WriteFile(file, []byte("x"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ ship := func(s *apiSession) error {
+ _, err := s.ship(context.Background(), apiAcct{Seg: "acct"}, "app", "1.2.3", "binary", "", "",
+ map[string]string{"linux-arm64": file}, nil, false)
+ return err
+ }
+ s := newTestSession(ts.URL)
+ s.shipSwap = "hold"
+ if err := ship(s); err != nil {
+ t.Fatalf("ship: %v", err)
+ }
+ if !strings.Contains(meta, `"swap":"hold"`) {
+ t.Fatalf("meta = %s, want the demand", meta)
+ }
+ echo = ""
+ if err := ship(s); err == nil || err.Error() != shipSwapNoEcho {
+ t.Fatalf("an answer without the echo: %v", err)
+ }
+ s.shipSwap = ""
+ if err := ship(s); err != nil {
+ t.Fatalf("a ship with no demand needs no echo: %v", err)
+ }
+ if strings.Contains(meta, "swap") {
+ t.Fatalf("a ship with no demand sent one: %s", meta)
+ }
+}