zdd-6 task 7: carlos ship and deploy --swap hold, stored with the release
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7 files changed,
+289
−3
apimode.go+28 −1cli.go+32 −1deploy.go+7 −1internal/console/apicli.go+12 −0internal/console/apiship_swap.go+50 −0internal/console/apiship_swap_test.go+72 −0ship_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) |
| + } |
| +} |