carlos / platform Public

cli: -capabilities ship flag; console: 409 a re-ship that changes the block

The spec says a manifest declares capabilities but never says how one gets
into a manifest — neither `carlos ship` nor the console ship API had any input
for it, so the feature was unreachable. This pins the smallest possible
surface: `carlos ship -capabilities <file.json>`, holding EXACTLY the
capabilities object.

The file is read and decoded BEFORE the direct/API mode split, so both doors
refuse the same bytes with the same message at the operator's terminal — the
strict decoder for an unknown capability name, ValidCapabilities for anything
semantically wrong. No flag means nil means a manifest byte-identical to
yesterday's, asserted against the stored bytes.

API mode sends the block in the EXISTING meta part, as the canonical
re-marshal of the decoded Set rather than the operator's file verbatim: the
same bytes that land in the manifest, bounded and deterministic, which matters
because handleAPIShip reads meta under an io.LimitReader(first, 8<<10) that a
whitespace-heavy file could otherwise blow. The limit stands unchanged. The
server decodes and validates it before reading a single artifact part, so a
bad declaration costs the caller nothing but the meta part, and answers 400
with caps's own message.

And the hole the plan named: handleAPIShip's "identical content already
shipped" short-circuit compares ARTIFACTS, not the manifest, so a second ship
of the same version with a DIFFERENT capability block answered 200 and
silently kept the FIRST block — the immutability promise broken for exactly
the field that decides which user the app runs as and which privileged ports
it binds. Now a 409 naming capabilities. Adding a block, changing one and
removing one are all pinned; a genuinely identical re-ship stays the
idempotent 200 it has to be.

shipCapabilitiesMatch compares CANONICAL JSON ENCODINGS, not the structs.
caps.Set carries an unexported notes slice, so a decoded Set is neither
==-comparable nor reflect.DeepEqual-safe against one built any other way once
those notes are non-empty — a struct comparison would 409 an honest,
idempotent re-ship of a block containing a field this binary tolerantly
ignored. There is a test that goes red if that hazard ever disappears.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 474734e2fdc636b567b73fe661e24df6a2a0b547 parent 9e872a6
6 files changed, +610 −30
  • apimode.go +30 −7
  • apimode_test.go +68 −10
  • cli.go +59 −4
  • cli_test.go +154 −0
  • internal/console/apicli.go +81 −9
  • internal/console/apicli_test.go +218 −0
diff --git a/apimode.go b/apimode.go
index 6145af533..41e063f6a 100644
--- a/apimode.go
+++ b/apimode.go
@@ -29,6 +29,8 @@ import (
"path/filepath"
"sort"
"time"
+
+ "github.com/carlosframework/platform/internal/caps"
)
// apiSession is the resolved API mode: which console, as whom. console and
@@ -368,10 +370,22 @@ func (s *apiSession) channels(ctx context.Context, account, app string) ([]apiCh
const shipTimeout = 15 * time.Minute
// ship uploads one release's artifacts as a multipart POST: the meta part
-// ({"version","kind"}) first, then one file part per entry in files (partName
-// -> local path), in map-key-sorted order — sorted so the wire bytes are
-// deterministic, since Go's map iteration order is not.
-func (s *apiSession) ship(ctx context.Context, account, app, version, kind string, files map[string]string) (apiShipResult, error) {
+// ({"version","kind"[,"capabilities"]}) first, then one file part per entry
+// in files (partName -> local path), in map-key-sorted order — sorted so the
+// wire bytes are deterministic, since Go's map iteration order is not.
+//
+// capsSet rides in the meta part rather than a part of its own, so the
+// server reads the whole declaration before it reads a single artifact byte
+// and can refuse the upload at its first part. It is the CANONICAL
+// re-marshal of the decoded Set, not the operator's file verbatim: the same
+// bytes that will land in the manifest, bounded (a few hundred) and
+// deterministic, which matters because the server reads meta under an
+// io.LimitReader(first, 8<<10) that a whitespace-heavy file could otherwise
+// blow. Nothing is lost — the fields a re-marshal drops are exactly the
+// unknown-inside-a-known ones the strict decoder already reported and
+// ignored. nil (no -capabilities) omits the key entirely, so the meta part's
+// bytes stay byte-identical to what this verb sent yesterday.
+func (s *apiSession) ship(ctx context.Context, account, app, version, kind string, files map[string]string, capsSet *caps.Set) (apiShipResult, error) {
ctx, cancel := context.WithTimeout(ctx, shipTimeout)
defer cancel()
@@ -382,10 +396,19 @@ func (s *apiSession) ship(ctx context.Context, account, app, version, kind strin
sort.Strings(names)
status, body, err := s.c.doMultipart(ctx, apiPath(account, app, "/releases"), func(mw *multipart.Writer) error {
+ var capsRaw json.RawMessage
+ if capsSet != nil {
+ b, err := json.Marshal(capsSet)
+ if err != nil {
+ return err
+ }
+ capsRaw = b
+ }
metaJSON, err := json.Marshal(struct {
- Version string `json:"version"`
- Kind string `json:"kind"`
- }{version, kind})
+ Version string `json:"version"`
+ Kind string `json:"kind"`
+ Capabilities json.RawMessage `json:"capabilities,omitempty"`
+ }{version, kind, capsRaw})
if err != nil {
return err
}
diff --git a/apimode_test.go b/apimode_test.go
index 88d5c7cb0..0160f24ef 100644
--- a/apimode_test.go
+++ b/apimode_test.go
@@ -19,6 +19,7 @@ import (
"testing"
"time"
+ "github.com/carlosframework/platform/internal/caps"
"github.com/carlosframework/platform/internal/edge"
"github.com/carlosframework/platform/internal/release"
)
@@ -261,7 +262,7 @@ func TestAPISessionShipMultipart(t *testing.T) {
res, err := s.ship(context.Background(), "acct", "app", "1.2.3", "binary", map[string]string{
"zeta-amd64": fileB,
"alpha-amd64": fileA,
- })
+ }, nil)
if err != nil {
t.Fatalf("ship: %v", err)
}
@@ -321,7 +322,7 @@ func TestAPISessionShipNoOp(t *testing.T) {
t.Fatal(err)
}
s := newTestSession(ts.URL)
- res, err := s.ship(context.Background(), "acct", "app", "1.2.3", "binary", map[string]string{"alpha-amd64": file})
+ res, err := s.ship(context.Background(), "acct", "app", "1.2.3", "binary", map[string]string{"alpha-amd64": file}, nil)
if err != nil {
t.Fatalf("ship: %v", err)
}
@@ -345,7 +346,7 @@ func TestAPISessionShipErrorSurfacesServerMessage(t *testing.T) {
t.Fatal(err)
}
s := newTestSession(ts.URL)
- _, err := s.ship(context.Background(), "acct", "app", "1.2.3", "binary", map[string]string{"alpha-amd64": file})
+ _, err := s.ship(context.Background(), "acct", "app", "1.2.3", "binary", map[string]string{"alpha-amd64": file}, nil)
if err == nil || !strings.Contains(err.Error(), "already shipped with different content") {
t.Fatalf("ship error = %v, want the server's conflict message", err)
}
@@ -387,7 +388,7 @@ func TestAPISessionShipSurvivesSlowServer(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
- _, err := s.ship(ctx, "acct", "app", "1.2.3", "binary", map[string]string{"alpha-amd64": file})
+ _, err := s.ship(ctx, "acct", "app", "1.2.3", "binary", map[string]string{"alpha-amd64": file}, nil)
if err == nil {
t.Fatal("ship with a tiny absolute client Timeout against a slow-to-answer server must fail")
}
@@ -411,7 +412,7 @@ func TestAPISessionShipSurvivesSlowServer(t *testing.T) {
// shipHTTPClientTimeout is back at its zero production default here
// (the sibling subtest above restored it) — only the context bounds
// this call.
- res, err := s.ship(context.Background(), "acct", "app", "1.2.3", "binary", map[string]string{"alpha-amd64": file})
+ res, err := s.ship(context.Background(), "acct", "app", "1.2.3", "binary", map[string]string{"alpha-amd64": file}, nil)
if err != nil {
t.Fatalf("ship against a slow-but-healthy server = %v, want success", err)
}
@@ -1042,7 +1043,7 @@ func TestShipAPIStaticNamesSiteTarGz(t *testing.T) {
sess := newTestSession(ts.URL)
fs, acctRaw := newShipFlagSet(t, apiTestAccount, siteDir)
- if err := runShipAPI(sess, acctRaw, fs, "titesite", "abc1234", "static", ""); err != nil {
+ if err := runShipAPI(sess, acctRaw, fs, "titesite", "abc1234", "static", "", nil); err != nil {
t.Fatalf("runShipAPI: %v", err)
}
@@ -1089,7 +1090,7 @@ func TestShipAPIStaticCleansUpTempFileOnServerError(t *testing.T) {
sess := newTestSession(ts.URL)
fs, acctRaw := newShipFlagSet(t, apiTestAccount, siteDir)
- if err := runShipAPI(sess, acctRaw, fs, "titesite", "abc1234", "static", ""); err == nil {
+ if err := runShipAPI(sess, acctRaw, fs, "titesite", "abc1234", "static", "", nil); err == nil {
t.Fatal("runShipAPI against a failing server must error")
}
@@ -1155,7 +1156,7 @@ func TestShipAPIMultiFileGoodBasenames(t *testing.T) {
sess := newTestSession(ts.URL)
fs, acctRaw := newShipFlagSet(t, apiTestAccount, fileArm, fileAmd)
- if err := runShipAPI(sess, acctRaw, fs, "titogo", "abc1234", "binary", "linux-arm64"); err != nil {
+ if err := runShipAPI(sess, acctRaw, fs, "titogo", "abc1234", "binary", "linux-arm64", nil); err != nil {
t.Fatalf("runShipAPI: %v", err)
}
@@ -1197,7 +1198,7 @@ func TestShipAPIMultiFileBadBasenameRefused(t *testing.T) {
sess := newTestSession(ts.URL)
fs, acctRaw := newShipFlagSet(t, apiTestAccount, fileGood, fileBad)
- err := runShipAPI(sess, acctRaw, fs, "titogo", "abc1234", "binary", "linux-arm64")
+ err := runShipAPI(sess, acctRaw, fs, "titogo", "abc1234", "binary", "linux-arm64", nil)
if err == nil {
t.Fatal("runShipAPI with a bad basename must error")
}
@@ -1232,7 +1233,7 @@ func TestShipAPIMissingArtifactRefusedBeforeDialing(t *testing.T) {
sess := newTestSession(ts.URL)
fs, acctRaw := newShipFlagSet(t, apiTestAccount, missing)
- err := runShipAPI(sess, acctRaw, fs, "titogo", "abc1234", "binary", "linux-arm64")
+ err := runShipAPI(sess, acctRaw, fs, "titogo", "abc1234", "binary", "linux-arm64", nil)
if err == nil {
t.Fatal("runShipAPI with a missing artifact path must error")
}
@@ -1241,3 +1242,60 @@ func TestShipAPIMissingArtifactRefusedBeforeDialing(t *testing.T) {
}
checkNoRegistry()
}
+
+// TestAPISessionShipCarriesCapabilitiesInMeta pins the API-mode half of the
+// declaration surface: the block rides the EXISTING meta part (so the server
+// has read the whole declaration before it reads an artifact byte and can
+// refuse the upload at its first part), as the CANONICAL re-marshal of the
+// decoded Set rather than the operator's file verbatim — bounded and
+// deterministic, which is what keeps it comfortably inside the 8KiB
+// io.LimitReader handleAPIShip reads meta under.
+//
+// TestAPISessionShipMultipart above pins the other half by omission: with no
+// capabilities the meta part is still exactly {"version":..,"kind":..}, byte
+// for byte what this verb sent before the field existed.
+func TestAPISessionShipCarriesCapabilitiesInMeta(t *testing.T) {
+ var metaBody string
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ mr, err := r.MultipartReader()
+ if err != nil {
+ t.Errorf("request was not multipart: %v", err)
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+ for {
+ p, err := mr.NextPart()
+ if err != nil {
+ break
+ }
+ b, _ := io.ReadAll(p)
+ if p.FormName() == "meta" {
+ metaBody = string(b)
+ }
+ }
+ w.WriteHeader(http.StatusCreated)
+ w.Write([]byte(`{"result":"shipped"}`))
+ }))
+ defer ts.Close()
+
+ dir := t.TempDir()
+ file := filepath.Join(dir, "a")
+ if err := os.WriteFile(file, []byte("A"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ var set *caps.Set
+ if err := json.Unmarshal([]byte(`{"v":1,"daemon":true,"ports":[{"port":25,"name":"mx"}]}`), &set); err != nil {
+ t.Fatal(err)
+ }
+
+ s := newTestSession(ts.URL)
+ if _, err := s.ship(context.Background(), "acct", "app", "1.2.3", "binary",
+ map[string]string{"alpha-amd64": file}, set); err != nil {
+ t.Fatalf("ship: %v", err)
+ }
+ want := `{"version":"1.2.3","kind":"binary","capabilities":{"v":1,"daemon":true,"ports":[{"port":25,"name":"mx"}]}}`
+ if metaBody != want {
+ t.Fatalf("meta part = %q, want %q", metaBody, want)
+ }
+}
diff --git a/cli.go b/cli.go
index fdccb2676..c68110f21 100644
--- a/cli.go
+++ b/cli.go
@@ -9,6 +9,7 @@ import (
"crypto/ed25519"
"crypto/sha256"
"encoding/hex"
+ "encoding/json"
"errors"
"flag"
"fmt"
@@ -22,6 +23,7 @@ import (
"time"
"github.com/carlosframework/platform/internal/appconfig"
+ "github.com/carlosframework/platform/internal/caps"
"github.com/carlosframework/platform/internal/edge"
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
@@ -815,9 +817,21 @@ func runShip(args []string) {
kind := fs.String("kind", "binary", "binary | static")
ver := fs.String("version", "", "version (git short sha)")
target := fs.String("target", "linux-arm64", "GOOS-GOARCH the binary was built for (binary kind, single file)")
+ capsFile := fs.String("capabilities", "", "path to a JSON capability block (internal/caps) to embed in the manifest")
fs.Parse(args)
if *app == "" || *ver == "" || fs.NArg() == 0 {
- log.Fatal("usage: carlos ship -app <a> -version <sha> [-kind binary|static] [-console <origin>] <files-or-site-dir>")
+ log.Fatal("usage: carlos ship -app <a> -version <sha> [-kind binary|static] [-console <origin>] [-capabilities <file.json>] <files-or-site-dir>")
+ }
+
+ // 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
+ // — the strict decoder for an unknown capability name, ValidCapabilities
+ // for anything semantically wrong. Direct mode would find out again in
+ // release.Ship and API mode again in the console, but neither of those is
+ // where a human is standing.
+ capsSet, err := readCapabilities(*capsFile, *app)
+ if err != nil {
+ log.Fatal(err)
}
sess, err := resolveMode(*consoleFlag)
@@ -825,7 +839,7 @@ func runShip(args []string) {
log.Fatal(err)
}
if sess != nil {
- if err := runShipAPI(sess, acctRaw, fs, *app, *ver, *kind, *target); err != nil {
+ if err := runShipAPI(sess, acctRaw, fs, *app, *ver, *kind, *target, capsSet); err != nil {
log.Fatal(err)
}
return
@@ -872,6 +886,9 @@ func runShip(args []string) {
Schema: 1, App: *app, Version: *ver, Kind: *kind,
BuiltBy: "carlos " + version.Build, BuiltAt: time.Now().UTC().Format(time.RFC3339),
Artifacts: arts,
+ // nil when -capabilities was not given: omitempty then keeps the
+ // manifest bytes byte-identical to what this verb wrote yesterday.
+ Capabilities: capsSet,
}
if err := release.Ship(context.Background(), openStore(), acctOf(), m, files); err != nil {
log.Fatal(err)
@@ -879,6 +896,44 @@ func runShip(args []string) {
fmt.Fprintf(stdout, "shipped %s %s (%d artifacts)\n", *app, *ver, len(files))
}
+// readCapabilities is the ship-time declaration surface: a file holding
+// EXACTLY the manifest's "capabilities" object (not a whole manifest, not a
+// wrapper), decoded through caps.Set's own strict door and then through the
+// same ValidCapabilities gate the box runs at adopt.
+//
+// The file is the whole surface on purpose. There are no per-capability
+// flags, because a flag per capability is a vocabulary that drifts from the
+// one in internal/caps, and the block belongs with the app's source anyway —
+// it is a property of the build, versioned with it.
+//
+// An empty path means no declaration: nil, and a manifest byte-identical to
+// yesterday's. An explicit `null` in the file is refused rather than folded
+// into nil — a human who passed -capabilities meant to declare something, and
+// silently shipping nothing is the config-that-lies failure mode.
+func readCapabilities(path, app string) (*caps.Set, error) {
+ if path == "" {
+ return nil, nil
+ }
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ return nil, fmt.Errorf("ship: -capabilities: %w", err)
+ }
+ var set *caps.Set
+ if err := json.Unmarshal(raw, &set); err != nil {
+ return nil, fmt.Errorf("ship: -capabilities %s: %w", path, err)
+ }
+ if set == nil {
+ return nil, fmt.Errorf("ship: -capabilities %s: the file declares null — omit the flag to ship no capabilities", path)
+ }
+ if err := caps.ValidCapabilities(app, set); err != nil {
+ return nil, fmt.Errorf("ship: -capabilities %s: %w", path, err)
+ }
+ for _, n := range set.Notes() {
+ fmt.Fprintf(stdout, "%s\n", n)
+ }
+ return set, nil
+}
+
// shipTargetRe validates a binary artifact's part name client-side, against
// the exact shape the server enforces (targetRe, internal/console/apicli.go)
// — checked here too so a malformed name (a build filename shipped without
@@ -900,7 +955,7 @@ var shipTargetRe = regexp.MustCompile(`^[a-z0-9]+-[a-z0-9]+$`)
// function — including the static kind's scratch temp file cleanup below,
// up to 256MiB per invocation. Returning lets that defer actually run on
// every exit path, fatal or not.
-func runShipAPI(sess *apiSession, acctRaw *string, fs *flag.FlagSet, app, ver, kind, target string) error {
+func runShipAPI(sess *apiSession, acctRaw *string, fs *flag.FlagSet, app, ver, kind, target string, capsSet *caps.Set) error {
acct := *acctRaw
files := map[string]string{}
if kind == "static" {
@@ -957,7 +1012,7 @@ func runShipAPI(sess *apiSession, acctRaw *string, fs *flag.FlagSet, app, ver, k
}
fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address)
- res, err := sess.ship(context.Background(), acct, app, ver, kind, files)
+ res, err := sess.ship(context.Background(), acct, app, ver, kind, files, capsSet)
if err != nil {
return err
}
diff --git a/cli_test.go b/cli_test.go
new file mode 100644
index 000000000..73ccdd373
--- /dev/null
+++ b/cli_test.go
@@ -0,0 +1,154 @@
+package main
+
+// The ship-time capability declaration surface (cli.go): the one door an
+// app's capability block gets into a release manifest through, and the
+// refusals a human meets at their own terminal rather than on a box an hour
+// later.
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/caps"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// writeCapsFile drops a capability block at a temp path and returns it.
+func writeCapsFile(t *testing.T, body string) string {
+ t.Helper()
+ p := filepath.Join(t.TempDir(), "capabilities.json")
+ if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ return p
+}
+
+// TestReadCapabilitiesRefusals: everything the strict decoder and
+// ValidCapabilities refuse is refused HERE, before resolveMode, before a byte
+// crosses any wire and before release.Ship is called — and every refusal
+// names the offending thing, because "invalid capabilities" tells an operator
+// nothing about which line of their block to fix.
+func TestReadCapabilitiesRefusals(t *testing.T) {
+ cases := []struct {
+ name string
+ body string
+ names string
+ }{
+ {"unknown capability name", `{"v":1,"daemon":true,"gpu":true}`, "gpu"},
+ {"unsupported block version", `{"v":2,"daemon":true}`, "update the platform binary"},
+ {"missing v", `{"daemon":true}`, `"v"`},
+ {"not JSON at all", `not json`, "capabilities"},
+ // The file holds EXACTLY the capabilities object — handing it a whole
+ // manifest is refused (on the missing "v", which is checked before
+ // the unknown-name sweep), never half-decoded into something.
+ {"a whole manifest instead of the block", `{"schema":1,"app":"keymail"}`, `"v"`},
+ {"non-privileged port", `{"v":1,"ports":[{"port":8080}]}`, "8080"},
+ {"reserved port", `{"v":1,"ports":[{"port":22}]}`, "22"},
+ {"reserved runtime_dir", `{"v":1,"runtime_dir":"carlos"}`, "carlos"},
+ {"runtime_dir traversal", `{"v":1,"runtime_dir":"../etc"}`, "runtime_dir"},
+ {"dbs with replicate app", `{"v":1,"data":{"replicate":"app","dbs":["x.db"]}}`, "dbs"},
+ // An explicit null is refused rather than folded into "no
+ // capabilities": a human who passed the flag meant to declare
+ // something, and silently shipping nothing is config-that-lies.
+ {"explicit null", `null`, "omit the flag"},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ got, err := readCapabilities(writeCapsFile(t, c.body), "keymail")
+ if err == nil {
+ t.Fatalf("accepted %s, got %+v", c.body, got)
+ }
+ if !strings.Contains(err.Error(), c.names) {
+ t.Fatalf("refusal %q does not contain %q", err, c.names)
+ }
+ })
+ }
+
+ if _, err := readCapabilities(filepath.Join(t.TempDir(), "nope.json"), "keymail"); err == nil {
+ t.Fatal("a missing -capabilities file must be an error, not a silent nil")
+ }
+
+ // The app name reaches the gate: host_role derives a system user from it,
+ // and an app name too long for one is refused at ship, where a human can
+ // rename, rather than on a box where nobody can.
+ long := strings.Repeat("a", 30)
+ if _, err := readCapabilities(writeCapsFile(t, `{"v":1,"host_role":true}`), long); err == nil {
+ t.Fatal("host_role on an over-long app name must be refused at ship")
+ }
+}
+
+// TestReadCapabilitiesHappy: no flag means nil (and therefore a manifest
+// byte-identical to yesterday's), and a valid block decodes whole.
+func TestReadCapabilitiesHappy(t *testing.T) {
+ got, err := readCapabilities("", "keymail")
+ if err != nil || got != nil {
+ t.Fatalf("no -capabilities = (%+v, %v), want (nil, nil)", got, err)
+ }
+
+ got, err = readCapabilities(writeCapsFile(t,
+ `{"v":1,"command":"relay","daemon":true,"children":true,"runtime_dir":"keymail",`+
+ `"ports":[{"port":25,"name":"mx"}],"data":{"replicate":"app"},"host_role":true}`), "keymail")
+ if err != nil {
+ t.Fatalf("readCapabilities: %v", err)
+ }
+ if got.V != 1 || got.Command != "relay" || !got.Daemon || !got.Children ||
+ got.RuntimeDir != "keymail" || !got.HostRole {
+ t.Fatalf("capabilities = %+v, want the declared block", got)
+ }
+ if len(got.Ports) != 1 || got.Ports[0].Port != 25 || got.Ports[0].Name != "mx" {
+ t.Fatalf("ports = %+v, want [{25 mx}]", got.Ports)
+ }
+ if got.Data == nil || got.Data.Replicate != caps.DataReplicateApp {
+ t.Fatalf("data = %+v, want replicate=app", got.Data)
+ }
+}
+
+// TestShipDirectModeEmbedsTheCapabilityFile is the end-to-end direct-mode
+// path: the flag's file lands in the immutable manifest in the bucket, and —
+// the half that matters more — a ship WITHOUT the flag writes a manifest with
+// no "capabilities" key at all. That second assertion is what keeps every
+// re-ship of a pre-capabilities release idempotent: release.Ship's
+// putImmutable compares bytes, so a manifest that grew an empty object would
+// turn a harmless repeat into a permanent immutable-object conflict.
+func TestShipDirectModeEmbedsTheCapabilityFile(t *testing.T) {
+ clearDeploymentEnv(t)
+ dir := t.TempDir()
+ t.Setenv("CARLOS_DEPLOYMENT_DIR", dir)
+
+ bin := filepath.Join(t.TempDir(), "carlos")
+ if err := os.WriteFile(bin, []byte("binary bytes"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ capsFile := writeCapsFile(t, `{"v":1,"daemon":true,"data":{"replicate":"platform","dbs":["console.db"]}}`)
+
+ captureStdout(t)
+ runShip([]string{"-app", "console", "-version", "abc1234", "-target", "linux-arm64",
+ "-capabilities", capsFile, bin})
+ runShip([]string{"-app", "console", "-version", "def5678", "-target", "linux-arm64", bin})
+
+ ctx := context.Background()
+ store := release.NewDirStore(dir)
+
+ m, err := release.GetManifest(ctx, store, release.OpsAccount, "console", "abc1234")
+ if err != nil {
+ t.Fatalf("manifest: %v", err)
+ }
+ if m.Capabilities == nil || !m.Capabilities.Daemon {
+ t.Fatalf("capabilities = %+v, want the declared block", m.Capabilities)
+ }
+ if d := m.Capabilities.Data; d == nil || d.Replicate != caps.DataReplicatePlatform ||
+ len(d.DBs) != 1 || d.DBs[0] != "console.db" {
+ t.Fatalf("data = %+v, want platform replication of console.db", d)
+ }
+
+ raw, _, err := store.Get(ctx, release.ManifestKey(release.OpsAccount, "console", "def5678"))
+ if err != nil {
+ t.Fatalf("read the flagless manifest: %v", err)
+ }
+ if strings.Contains(string(raw), "capabilities") {
+ t.Fatalf("a ship with no -capabilities grew a key:\n%s", raw)
+ }
+}
diff --git a/internal/console/apicli.go b/internal/console/apicli.go
index 9ab77f6da..1da3642cc 100644
--- a/internal/console/apicli.go
+++ b/internal/console/apicli.go
@@ -9,6 +9,7 @@
package console
import (
+ "bytes"
"context"
"crypto/sha256"
"encoding/hex"
@@ -23,6 +24,7 @@ import (
"strings"
"time"
+ "github.com/carlosframework/platform/internal/caps"
"github.com/carlosframework/platform/internal/release"
"github.com/carlosframework/platform/internal/version"
)
@@ -427,13 +429,40 @@ func shipArtifactsMatch(a, b map[string]release.Artifact) bool {
return true
}
+// shipCapabilitiesMatch reports whether two capability blocks say the same
+// thing, comparing their CANONICAL JSON ENCODINGS rather than the structs.
+//
+// That is not a stylistic choice. caps.Set carries an unexported notes slice
+// (the unknown-field-inside-a-known-capability lines its decoder collected),
+// so a decoded Set is neither ==-comparable nor reflect.DeepEqual-safe
+// against one built any other way once those notes are non-empty — two blocks
+// that mean identically the same thing would compare unequal because one of
+// them was written with a field this binary ignored. The JSON encoding
+// carries exactly the fields that reach the manifest and nothing else, and
+// Set has no custom marshaller, so the encoding is deterministic (struct
+// field order) and total. A nil block encodes as "null" on both sides, which
+// is why nil == nil falls out with no special case.
+func shipCapabilitiesMatch(a, b *caps.Set) bool {
+ ja, aerr := json.Marshal(a)
+ jb, berr := json.Marshal(b)
+ if aerr != nil || berr != nil {
+ // Unreachable for a Set (no marshaller to fail), and if it ever
+ // happens the honest answer is "these are not known to match" — which
+ // refuses the re-ship rather than waving it through.
+ return false
+ }
+ return bytes.Equal(ja, jb)
+}
+
// handleAPIShip answers `carlos ship`: a multipart upload of one release's
// artifacts. Hashes and sizes are computed HERE, from the bytes that
// actually arrived on the wire, and handed to release.Ship as
// already-filled Artifact{SHA256,Size} — Ship itself recomputes and
// cross-checks them (release.go), so there is no way for a caller to lie
// about either even in principle: the wire shape below has no field to lie
-// in (the meta part carries only version and kind).
+// in (the meta part carries only version, kind, and the app's capability
+// declaration — and that last one is re-validated here against caps's own
+// closed vocabulary before an artifact byte is read).
//
// Streamed, never buffered to disk: r.MultipartReader() reads one part at a
// time. ParseMultipartForm was ruled out on purpose — it spills whatever
@@ -516,7 +545,16 @@ func (s *Server) handleAPIShip(w http.ResponseWriter, r *http.Request) {
var meta struct {
Version string `json:"version"`
Kind string `json:"kind"`
- }
+ // Capabilities is the app's declaration (internal/caps), ADDITIVE:
+ // absent from every request a CLI shipped before this field existed,
+ // and absent from every request that does not pass -capabilities. It
+ // is held raw so the refusal below carries caps's own message rather
+ // than "malformed meta part" — an unknown capability name is a
+ // specific, actionable thing to be told.
+ Capabilities json.RawMessage `json:"capabilities,omitempty"`
+ }
+ // The 8KiB limit stands unchanged: a capability block is a few hundred
+ // bytes, and the CLI sends the canonical re-marshal, not a file verbatim.
if err := json.NewDecoder(io.LimitReader(first, 8<<10)).Decode(&meta); err != nil {
jsonError(w, http.StatusBadRequest, "malformed meta part")
return
@@ -530,6 +568,26 @@ func (s *Server) handleAPIShip(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusBadRequest, `invalid kind (want "binary" or "static")`)
return
}
+ // Decoded and validated HERE — before a single artifact part is read, let
+ // alone buffered — so a bad declaration costs the caller nothing but the
+ // meta part. release.Ship would reach the same verdict through
+ // validateManifest, but only after every byte had crossed the wire, and
+ // not at all on the already-shipped short-circuit below.
+ var declared *caps.Set
+ if len(meta.Capabilities) > 0 {
+ if err := json.Unmarshal(meta.Capabilities, &declared); err != nil {
+ jsonError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+ if declared == nil {
+ jsonError(w, http.StatusBadRequest, "capabilities: null — omit the key to ship no capabilities")
+ return
+ }
+ if err := caps.ValidCapabilities(app, declared); err != nil {
+ jsonError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+ }
maxParts := apiShipMaxPartsBinary
if meta.Kind == "static" {
maxParts = apiShipMaxPartsStatic
@@ -601,13 +659,14 @@ func (s *Server) handleAPIShip(w http.ResponseWriter, r *http.Request) {
}
m := release.Manifest{
- Schema: 1,
- App: app,
- Version: ver,
- Kind: meta.Kind,
- BuiltBy: "console-api " + version.Build,
- BuiltAt: time.Now().UTC().Format(time.RFC3339),
- Artifacts: artifacts,
+ Schema: 1,
+ App: app,
+ Version: ver,
+ Kind: meta.Kind,
+ BuiltBy: "console-api " + version.Build,
+ BuiltAt: time.Now().UTC().Format(time.RFC3339),
+ Artifacts: artifacts,
+ Capabilities: declared, // nil when the meta part carried no key
}
// 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
@@ -632,6 +691,19 @@ func (s *Server) handleAPIShip(w http.ResponseWriter, r *http.Request) {
"version "+ver+" was already shipped with different content — versions are immutable; ship under a new version")
return
}
+ // Artifacts alone are not the whole of what a version means any more.
+ // The capability block decides the SHAPE OF THE UNIT the bytes run
+ // in — which user, which privileged ports, which runtime dir — so a
+ // re-ship of the same bytes with a different block is a different
+ // release in every way that matters on a box. Without this check the
+ // short-circuit below would answer 200 and silently keep the FIRST
+ // block, which is the immutability promise broken for exactly the
+ // field that changes the most about how the app runs.
+ if !shipCapabilitiesMatch(existing.Capabilities, declared) {
+ jsonError(w, http.StatusConflict,
+ "version "+ver+" was already shipped with different capabilities — versions are immutable; ship under a new version")
+ 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
diff --git a/internal/console/apicli_test.go b/internal/console/apicli_test.go
index 60afc5b60..497ef6d02 100644
--- a/internal/console/apicli_test.go
+++ b/internal/console/apicli_test.go
@@ -19,11 +19,13 @@ import (
"io"
"mime/multipart"
"net/http"
+ "reflect"
"strings"
"sync"
"testing"
"time"
+ "github.com/carlosframework/platform/internal/caps"
"github.com/carlosframework/platform/internal/domains"
"github.com/carlosframework/platform/internal/release"
)
@@ -1132,3 +1134,219 @@ func TestAPIShipConcurrencyLimit(t *testing.T) {
}
}
}
+
+// metaPartCaps is metaPart with an app's capability declaration attached —
+// the shape `carlos ship -capabilities <file>` puts on the wire. capsJSON is
+// spliced in raw so a test can send a block the strict decoder must refuse,
+// which a typed caps.Set could not even express.
+func metaPartCaps(t *testing.T, version, kind, capsJSON string) shipPart {
+ t.Helper()
+ raw, err := json.Marshal(map[string]any{
+ "version": version, "kind": kind, "capabilities": json.RawMessage(capsJSON),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ return shipPart{name: "meta", body: raw}
+}
+
+// TestAPIShipCarriesCapabilitiesIntoTheManifest: the declaration rides the
+// meta part and lands in the immutable manifest, decoded through caps's own
+// strict door. This is the console half of the ship-time declaration surface;
+// the direct CLI path's half is readCapabilities (cli.go).
+func TestAPIShipCarriesCapabilitiesIntoTheManifest(t *testing.T) {
+ fx := newConsoleFixture(t)
+ now := time.Now()
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "keymail", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ token := cliToken(t, fx, "paul@keymail.dev", "code-ship-caps")
+ payload := randomBytes(t, 128)
+
+ const block = `{"v":1,"command":"relay","daemon":true,"children":true,` +
+ `"runtime_dir":"keymail","ports":[{"port":25,"name":"mx"}],` +
+ `"data":{"replicate":"app"},"host_role":true}`
+ status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/keymail/releases", token,
+ []shipPart{metaPartCaps(t, "cap01234", "binary", block), {name: "linux-arm64", body: payload}})
+ if status != http.StatusCreated {
+ t.Fatalf("ship with capabilities = %d %v", status, out)
+ }
+ m, err := release.GetManifest(context.Background(), fx.rel, release.OpsAccount, "keymail", "cap01234")
+ if err != nil {
+ t.Fatalf("manifest: %v", err)
+ }
+ if m.Capabilities == nil {
+ t.Fatal("the manifest carries no capability block")
+ }
+ c := m.Capabilities
+ if c.V != 1 || c.Command != "relay" || !c.Daemon || !c.Children || c.RuntimeDir != "keymail" || !c.HostRole {
+ t.Fatalf("capabilities = %+v, want the declared block", c)
+ }
+ if len(c.Ports) != 1 || c.Ports[0].Port != 25 || c.Ports[0].Name != "mx" {
+ t.Fatalf("capabilities ports = %+v, want [{25 mx}]", c.Ports)
+ }
+ if c.Data == nil || c.Data.Replicate != caps.DataReplicateApp {
+ t.Fatalf("capabilities data = %+v, want replicate=app", c.Data)
+ }
+}
+
+// TestAPIShipRefusesABadCapabilityBlock: everything ValidCapabilities and the
+// strict decoder refuse is refused HERE, at ship, with the caps message
+// itself — a 400 that names the offending capability, not a generic
+// "malformed meta part". And release.Ship is never reached, so no manifest
+// exists afterwards for the box to trip over.
+func TestAPIShipRefusesABadCapabilityBlock(t *testing.T) {
+ fx := newConsoleFixture(t)
+ now := time.Now()
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ token := cliToken(t, fx, "paul@keymail.dev", "code-ship-badcaps")
+ payload := randomBytes(t, 128)
+
+ cases := []struct {
+ name string
+ version string
+ block string
+ names string // a substring the refusal must contain
+ }{
+ {"unknown capability name", "cap10001", `{"v":1,"daemon":true,"gpu":true}`, "gpu"},
+ {"unsupported block version", "cap10002", `{"v":2,"daemon":true}`, "update the platform binary"},
+ {"missing v", "cap10003", `{"daemon":true}`, `"v"`},
+ {"non-privileged port", "cap10004", `{"v":1,"ports":[{"port":8080}]}`, "8080"},
+ {"reserved port", "cap10005", `{"v":1,"ports":[{"port":443}]}`, "443"},
+ {"reserved runtime_dir", "cap10006", `{"v":1,"runtime_dir":"carlos"}`, "carlos"},
+ {"runtime_dir traversal", "cap10007", `{"v":1,"runtime_dir":"../etc"}`, "runtime_dir"},
+ {"dbs with replicate app", "cap10008", `{"v":1,"data":{"replicate":"app","dbs":["x.db"]}}`, "dbs"},
+ {"db name with a separator", "cap10009", `{"v":1,"data":{"replicate":"platform","dbs":["a/b.db"]}}`, "a/b.db"},
+ {"explicit null", "cap10010", `null`, "null"},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token,
+ []shipPart{metaPartCaps(t, c.version, "binary", c.block), {name: "linux-arm64", body: payload}})
+ if status != http.StatusBadRequest {
+ t.Fatalf("%s = %d %v, want 400", c.name, status, out)
+ }
+ if msg := str(t, out, "error"); !strings.Contains(msg, c.names) {
+ t.Fatalf("%s: refusal %q does not contain %q", c.name, msg, c.names)
+ }
+ if _, err := release.GetManifest(context.Background(), fx.rel, release.OpsAccount, "titogo", c.version); err == nil {
+ t.Fatalf("%s: a manifest exists for %s — release.Ship must never have been reached", c.name, c.version)
+ }
+ })
+ }
+}
+
+// TestAPIShipRefusesARecapabilitiedReship closes the hole the plan named: the
+// "identical content already shipped" short-circuit compares ARTIFACTS, so
+// without a capability comparison a second ship of the same version with a
+// DIFFERENT block would answer 200 and silently keep the FIRST block — the
+// immutability promise broken for exactly the field that decides which user
+// the app runs as and which privileged ports it binds.
+//
+// All four transitions are pinned, because three of them are the ones a
+// mistake actually takes: adding a block, changing one, and removing one.
+// Re-shipping the SAME block stays the idempotent 200 it has to be, or a
+// caller retrying a dropped connection could never land.
+func TestAPIShipRefusesARecapabilitiedReship(t *testing.T) {
+ fx := newConsoleFixture(t)
+ now := time.Now()
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "keymail", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ token := cliToken(t, fx, "paul@keymail.dev", "code-ship-recaps")
+ payload := randomBytes(t, 256)
+ u := fx.ts.URL + "/api/cli/apps/bab/keymail/releases"
+
+ const first = `{"v":1,"daemon":true,"ports":[{"port":25,"name":"mx"}]}`
+ status, out := postShip(t, u, token,
+ []shipPart{metaPartCaps(t, "recap001", "binary", first), {name: "linux-arm64", body: payload}})
+ if status != http.StatusCreated {
+ t.Fatalf("first ship = %d %v", status, out)
+ }
+
+ // Same bytes, same version, a DIFFERENT block: 409, naming capabilities.
+ const changed = `{"v":1,"daemon":true,"ports":[{"port":587,"name":"submission"}]}`
+ status, out = postShip(t, u, token,
+ []shipPart{metaPartCaps(t, "recap001", "binary", changed), {name: "linux-arm64", body: payload}})
+ if status != http.StatusConflict {
+ t.Fatalf("re-ship with different capabilities = %d %v, want 409", status, out)
+ }
+ if msg := str(t, out, "error"); !strings.Contains(msg, "capabilities") || !strings.Contains(msg, "immutable") {
+ t.Fatalf("conflict error = %q, want it to name capabilities and immutability", msg)
+ }
+
+ // Same bytes, no block at all: also a 409 — dropping a declaration is as
+ // much a change of what the version means as adding one.
+ status, out = postShip(t, u, token,
+ []shipPart{metaPart(t, "recap001", "binary"), {name: "linux-arm64", body: payload}})
+ if status != http.StatusConflict {
+ t.Fatalf("re-ship with the block removed = %d %v, want 409", status, out)
+ }
+
+ // And the stored block is still the FIRST one: no refused re-ship ever
+ // rewrote it.
+ m, err := release.GetManifest(context.Background(), fx.rel, release.OpsAccount, "keymail", "recap001")
+ if err != nil {
+ t.Fatalf("manifest: %v", err)
+ }
+ if m.Capabilities == nil || len(m.Capabilities.Ports) != 1 || m.Capabilities.Ports[0].Port != 25 {
+ t.Fatalf("stored capabilities = %+v, want the first block (port 25)", m.Capabilities)
+ }
+
+ // The genuinely identical re-ship is still the idempotent 200.
+ status, out = postShip(t, u, token,
+ []shipPart{metaPartCaps(t, "recap001", "binary", first), {name: "linux-arm64", body: payload}})
+ if status != http.StatusOK {
+ t.Fatalf("identical re-ship = %d %v, want 200", status, out)
+ }
+
+ // The mirror case: a version shipped with NO block cannot grow one.
+ status, out = postShip(t, u, token,
+ []shipPart{metaPart(t, "recap002", "binary"), {name: "linux-arm64", body: payload}})
+ if status != http.StatusCreated {
+ t.Fatalf("plain ship = %d %v", status, out)
+ }
+ status, out = postShip(t, u, token,
+ []shipPart{metaPartCaps(t, "recap002", "binary", first), {name: "linux-arm64", body: payload}})
+ if status != http.StatusConflict {
+ t.Fatalf("re-ship that ADDS capabilities = %d %v, want 409", status, out)
+ }
+}
+
+// TestShipCapabilitiesMatchComparesEncodingsNotStructs pins WHY the 409's
+// comparison is done on canonical JSON: caps.Set carries an unexported notes
+// slice, so a Set decoded from a block containing an unknown-but-tolerated
+// field is neither ==-comparable nor reflect.DeepEqual-equal to the same
+// capabilities expressed without it — even though the two mean identically
+// the same thing and marshal to identically the same manifest bytes. A
+// struct comparison here would 409 an honest, idempotent re-ship.
+func TestShipCapabilitiesMatchComparesEncodingsNotStructs(t *testing.T) {
+ decode := func(s string) *caps.Set {
+ t.Helper()
+ var set *caps.Set
+ if err := json.Unmarshal([]byte(s), &set); err != nil {
+ t.Fatalf("decode %s: %v", s, err)
+ }
+ return set
+ }
+ withNote := decode(`{"v":1,"data":{"replicate":"platform","dbs":["console.db"],"future":7}}`)
+ plain := decode(`{"v":1,"data":{"replicate":"platform","dbs":["console.db"]}}`)
+
+ if len(withNote.Notes()) == 0 {
+ t.Fatal("fixture no longer produces a note — this test is not testing what it claims")
+ }
+ if reflect.DeepEqual(withNote, plain) {
+ t.Fatal("the two Sets are DeepEqual — the unexported notes hazard is gone and this test can be simplified")
+ }
+ if !shipCapabilitiesMatch(withNote, plain) {
+ t.Fatal("shipCapabilitiesMatch called two identical-meaning blocks different — an honest re-ship would 409")
+ }
+ if !shipCapabilitiesMatch(nil, nil) {
+ t.Fatal("two capability-free manifests must match")
+ }
+ if shipCapabilitiesMatch(nil, plain) || shipCapabilitiesMatch(plain, nil) {
+ t.Fatal("nil and a declared block must not match — adding or removing a block changes the version")
+ }
+}