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>
6 files changed,
+610
−30
apimode.go+30 −7apimode_test.go+68 −10cli.go+59 −4cli_test.go+154 −0internal/console/apicli.go+81 −9internal/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") |
| + } |
| +} |