carlos / platform Public

release: additive capabilities on the manifest — one gate shared by ship and adopt

Manifest gains `capabilities`, a *caps.Set POINTER with omitempty. Schema
stays 1 forever for this change, exactly as appconfig.Manifest.ExecDelivery
was added: every manifest already in a production bucket has no
"capabilities" key, parses with a nil pointer, and means no capabilities —
today's behaviour byte for byte. A pointer (not a value) so a capability-free
manifest also MARSHALS byte-identically to before, which is what keeps Ship's
putImmutable idempotent against bytes an older binary already wrote.

The reverse direction holds too, and the golden tests pin both: an OLD binary
with no caps package still parses a NEW manifest, because encoding/json
ignores unknown top-level keys and nothing on the manifest read path uses
DisallowUnknownFields. That makes the pinned deploy order (platform binaries
first, capability-bearing releases second) belt-and-braces rather than
load-bearing.

validateManifest gains caps.ValidCapabilities as its LAST check, which is what
makes ship and adopt share one gate: Ship calls validateManifest before
writing a byte (a human is present to read the refusal) and GetManifest calls
it on every read, so a box meeting a capability it does not understand fails
ensureStaged and prints one line per pass without staging or pointing. Spec
§3a/§3b.

Deviation from the plan's pinned line: the caps error is returned unwrapped
rather than through fmt.Errorf("capabilities: %w", err). Every caps refusal
already opens with "capabilities: " (pinned in Task 1's refusal table), and
wrapping renders it twice inside GetManifest's own "manifest %s/%s: %w".
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 9e872a6c6d5b92a0145380b5fab4075f95c2a43e parent b784c2b
4 files changed, +299 −0
  • internal/release/adopt_test.go +89 −0
  • internal/release/golden_test.go +72 −0
  • internal/release/release.go +42 −0
  • internal/release/release_test.go +96 −0
diff --git a/internal/release/adopt_test.go b/internal/release/adopt_test.go
index ab541f310..63f6cab48 100644
--- a/internal/release/adopt_test.go
+++ b/internal/release/adopt_test.go
@@ -9,6 +9,7 @@ import (
"errors"
"os"
"path/filepath"
+ "strconv"
"strings"
"testing"
"time"
@@ -622,3 +623,91 @@ func withCapturedStdout(t *testing.T, buf *bytes.Buffer, fn func()) {
w.Close()
<-done
}
+
+// TestAdoptRefusesTheVersionWhenAManifestDeclaresAnUnknownName is the
+// fail-closed direction, and it is the test that matters most in this task.
+//
+// The manifest bytes are written DIRECTLY to the store, bypassing Ship — that
+// is exactly what a newer platform binary would leave behind after shipping a
+// release declaring a capability this binary has never heard of. The box's
+// answer must be to refuse the VERSION: nothing staged, the live symlink
+// unmoved, one printed line naming the capability and the fix. The box keeps
+// serving what it has, which for a daemon is the safe direction; the
+// alternative — materializing a unit that is missing something the app says
+// it needs, and looking healthy — is the config-that-lies failure mode this
+// whole design exists to avoid.
+func TestAdoptRefusesTheVersionWhenAManifestDeclaresAnUnknownName(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ opt := t.TempDir()
+ reg := testRegistry(t)
+
+ // The box is up and running on v1.
+ shipRelease(t, s, "bab", "myapp", "v1", adoptPlatform, []byte("good"))
+ if err := StageArtifact(opt, "bab", "v1", "myapp", []byte("good")); err != nil {
+ t.Fatal(err)
+ }
+ addRoute(t, reg, "h.example.com", "myapp", "canary/x", false)
+ if err := PointLive(opt, "h.example.com", "bab", "v1", "myapp"); err != nil {
+ t.Fatal(err)
+ }
+
+ // v2 ships and the channel moves onto it while it is still a manifest this
+ // binary understands...
+ body := []byte("newer")
+ shipRelease(t, s, "bab", "myapp", "v2", adoptPlatform, body)
+ if err := Promote(ctx, s, priv, "bab", "myapp", "canary/x", "v2"); err != nil {
+ t.Fatalf("Promote v2: %v", err)
+ }
+ // ...and is then replaced by the bytes a NEWER binary would have written:
+ // same artifacts, same sha, plus a capability from a vocabulary this
+ // binary does not have.
+ key := ManifestKey("bab", "myapp", "v2")
+ _, etag, err := s.Get(ctx, key)
+ if err != nil {
+ t.Fatalf("read v2 manifest: %v", err)
+ }
+ future := []byte(`{
+ "schema": 1,
+ "app": "myapp",
+ "version": "v2",
+ "kind": "binary",
+ "artifacts": { "` + adoptPlatform + `": { "file": "` + adoptPlatform + `", "sha256": "` + etagOf(body) + `", "size": ` + strconv.Itoa(len(body)) + ` } },
+ "capabilities": { "v": 1, "daemon": true, "gpu": true }
+}`)
+ if _, err := s.Put(ctx, key, future, etag); err != nil {
+ t.Fatalf("replace v2 manifest: %v", err)
+ }
+
+ var out bytes.Buffer
+ var restarted []string
+ withCapturedStdout(t, &out, func() {
+ if err := Pass(ctx, reg, s, pub, opt, func(h string) error {
+ restarted = append(restarted, h)
+ return nil
+ }); err != nil {
+ t.Fatalf("Pass: %v", err)
+ }
+ })
+
+ if v, _ := LiveVersion(opt, "h.example.com"); v != "v1" {
+ t.Fatalf("live = %q, want v1 — the box must keep serving what it has", v)
+ }
+ if _, err := os.Stat(VersionPath(opt, "bab", "v2", "myapp")); !os.IsNotExist(err) {
+ t.Fatalf("v2 was staged (stat err = %v); a refused version must never reach disk", err)
+ }
+ if len(restarted) != 0 {
+ t.Fatalf("restarted = %v, want none", restarted)
+ }
+ if rt, _ := reg.Get("h.example.com"); rt == nil || rt.Version == "v2" {
+ t.Fatalf("registry recorded v2 as adopted: %+v", rt)
+ }
+ line := out.String()
+ if !strings.Contains(line, "gpu") {
+ t.Fatalf("printed %q, want a line naming the capability", line)
+ }
+ if !strings.Contains(line, "update the platform binary") {
+ t.Fatalf("printed %q, want a line naming the fix", line)
+ }
+}
diff --git a/internal/release/golden_test.go b/internal/release/golden_test.go
index 02a478959..4b4a0df76 100644
--- a/internal/release/golden_test.go
+++ b/internal/release/golden_test.go
@@ -1,12 +1,14 @@
package release
import (
+ "bytes"
"context"
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"reflect"
"sort"
+ "strings"
"testing"
"time"
)
@@ -141,3 +143,73 @@ func TestSignatureCoversTheBase64PayloadString(t *testing.T) {
t.Fatal("golden signature covers the DECODED payload — the scheme changed")
}
}
+
+// The manifest shape as it exists in every production bucket TODAY: schema 1,
+// no "capabilities" key, written by binaries that predate the field. It joins
+// the byte-identical guarantees above for the same reason they exist — these
+// objects are immutable at their bucket paths, so they can never be rewritten
+// into a newer shape, and a binary that stopped parsing them would be a fleet
+// that cannot stage a single running version.
+//
+// Mutation-mindedness: make Capabilities a non-pointer, make "v" required at
+// the manifest level, or put DisallowUnknownFields anywhere on the manifest
+// read path, and this test goes red.
+func TestYesterdaysManifestParsesWithNoCapabilities(t *testing.T) {
+ // A var, not a const: strings.Repeat is not a constant expression.
+ yesterday := `{
+ "schema": 1,
+ "app": "kass",
+ "version": "9f3c1ad",
+ "kind": "binary",
+ "artifacts": { "linux-arm64": { "file": "linux-arm64", "sha256": "` + strings.Repeat("ab", 32) + `", "size": 12 } }
+}`
+ var m Manifest
+ if err := json.Unmarshal([]byte(yesterday), &m); err != nil {
+ t.Fatalf("a manifest already in every production bucket stopped parsing: %v", err)
+ }
+ if m.Capabilities != nil {
+ t.Fatalf("Capabilities = %+v, want nil (absent means none)", m.Capabilities)
+ }
+ if err := validateManifest(&m); err != nil {
+ t.Fatalf("validate: %v", err)
+ }
+ // And a capability-free manifest still marshals with NO capabilities key —
+ // which is what keeps Ship's putImmutable idempotent against bytes an
+ // older binary already wrote.
+ raw, err := json.Marshal(&m)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if bytes.Contains(raw, []byte("capabilities")) {
+ t.Fatalf("a capability-free manifest grew a key: %s", raw)
+ }
+}
+
+// The other direction of the same forward-compatibility contract: a manifest
+// carrying keys THIS binary has never heard of at the TOP level still parses,
+// because encoding/json ignores unknown fields and nothing on the manifest
+// read path turns that off. That is what lets an old binary keep running
+// against a bucket a newer one has shipped into, and it is why the pinned
+// deploy order (platform binaries first, capability-bearing releases second)
+// is belt-and-braces rather than load-bearing.
+func TestAManifestWithFutureTopLevelKeysStillParses(t *testing.T) {
+ tomorrow := `{
+ "schema": 1,
+ "app": "kass",
+ "version": "9f3c1ad",
+ "kind": "binary",
+ "artifacts": { "linux-arm64": { "file": "linux-arm64", "sha256": "` + strings.Repeat("ab", 32) + `", "size": 12 } },
+ "capabilities": { "v": 1, "daemon": true },
+ "some_future_key": { "nested": [1, 2, 3] }
+}`
+ var m Manifest
+ if err := json.Unmarshal([]byte(tomorrow), &m); err != nil {
+ t.Fatalf("unknown TOP-LEVEL keys must be ignored, not refused: %v", err)
+ }
+ if err := validateManifest(&m); err != nil {
+ t.Fatalf("validate: %v", err)
+ }
+ if m.Capabilities == nil || !m.Capabilities.Daemon {
+ t.Fatalf("Capabilities = %+v, want the daemon block decoded", m.Capabilities)
+ }
+}
diff --git a/internal/release/release.go b/internal/release/release.go
index e67ac5ab9..53bc99469 100644
--- a/internal/release/release.go
+++ b/internal/release/release.go
@@ -35,6 +35,8 @@ import (
"regexp"
"strings"
"time"
+
+ "github.com/carlosframework/platform/internal/caps"
)
// Manifest describes one shipped release of one app. It is content-addressed
@@ -52,6 +54,30 @@ type Manifest struct {
// Artifacts is keyed "linux-arm64" etc for binaries, or "site" for a
// static tree tar.
Artifacts map[string]Artifact `json:"artifacts"`
+
+ // Capabilities is what this BUILD needs from the platform
+ // (internal/caps). ADDITIVE to schema 1, exactly as
+ // appconfig.Manifest.ExecDelivery was: a manifest written before this
+ // field existed has no "capabilities" key, parses with a nil pointer, and
+ // means no capabilities — which is today's behaviour byte for byte, and
+ // is most of every production bucket. A POINTER with omitempty, so a
+ // capability-free manifest also MARSHALS byte-identically to before (no
+ // "capabilities": {} appears), which is what keeps Ship's idempotent
+ // re-ship of an old release working.
+ //
+ // The reverse direction holds too: an OLD binary — one with no caps
+ // package at all — still parses a NEW manifest, because encoding/json
+ // ignores unknown top-level keys and nothing on the manifest read path
+ // uses DisallowUnknownFields. That is what makes the pinned deploy order
+ // (platform binaries first, capability-bearing releases second) a
+ // belt-and-braces rule rather than a load-bearing one.
+ //
+ // It lives in the release manifest and not the config manifest because it
+ // describes the unit the platform OWNS and it changes with the code: a
+ // rollback rolls the unit shape back with the binary, and the declaration
+ // rides the same trust chain as the bytes (signed pointer -> immutable
+ // manifest -> sha256). Spec §3a.
+ Capabilities *caps.Set `json:"capabilities,omitempty"`
}
// Artifact is one file of a release: its bucket file name and the sha256
@@ -229,6 +255,22 @@ func validateManifest(m *Manifest) error {
return fmt.Errorf("artifact %s: negative size", k)
}
}
+ // LAST, and deliberately the same call on both sides of the trust chain:
+ // Ship runs validateManifest before writing a byte (a human is present to
+ // read the refusal), and GetManifest runs it on every read (so a box
+ // meeting a capability it does not understand fails ensureStaged and
+ // prints one line per pass without staging or pointing). One gate, shared
+ // by ship and adopt, exactly as appconfig.ValidExecDelivery is shared by
+ // set-time and sync-time. A nil Capabilities is valid and means none.
+ //
+ // Returned unwrapped on purpose: every caps refusal already opens with
+ // "capabilities: " (that prefix is pinned in Task 1's refusal table), and
+ // the plan's `fmt.Errorf("capabilities: %w", err)` would render it twice
+ // inside GetManifest's own "manifest %s/%s: %w" wrap. The operator-facing
+ // string is the point of these messages, so the prefix is stated once.
+ if err := caps.ValidCapabilities(m.App, m.Capabilities); err != nil {
+ return err
+ }
return nil
}
diff --git a/internal/release/release_test.go b/internal/release/release_test.go
index a4385b872..bcad6c5ae 100644
--- a/internal/release/release_test.go
+++ b/internal/release/release_test.go
@@ -11,6 +11,8 @@ import (
"strings"
"testing"
"time"
+
+ "github.com/carlosframework/platform/internal/caps"
)
// testKeys mints a signing pair through the exported key helpers, so the
@@ -537,3 +539,97 @@ func TestPointerWrittenBeforeAuditStillParses(t *testing.T) {
t.Fatalf("stable audit = %+v", sp.Audit)
}
}
+
+// TestShipRefusesAnUnknownCapability: validateManifest's capability gate runs
+// at SHIP, where a human is standing at a terminal to read the refusal — not
+// only on the box hours later. The refusal has to name the offending value,
+// because "invalid capabilities" tells an operator nothing about which line
+// of their block to fix.
+func TestShipRefusesAnUnknownCapability(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ m := Manifest{
+ App: "myapp",
+ Version: "v1abc",
+ Kind: "binary",
+ Artifacts: map[string]Artifact{"linux-arm64": {}},
+ // 8080 is not a capability: only privileged ports are, and a high
+ // port needs no help from the platform.
+ Capabilities: &caps.Set{V: 1, Ports: []caps.Port{{Port: 8080, Name: "http"}}},
+ }
+ err := Ship(context.Background(), s, "bab", m, map[string][]byte{"linux-arm64": []byte("bin")})
+ if err == nil {
+ t.Fatal("Ship accepted a capability block the box would refuse — the ship and adopt gates have diverged")
+ }
+ if !strings.Contains(err.Error(), "8080") {
+ t.Fatalf("refusal %q does not name the offending port", err)
+ }
+ // And nothing was written: the gate runs before a single byte moves.
+ if _, err := GetManifest(context.Background(), s, "bab", "myapp", "v1abc"); err == nil {
+ t.Fatal("a refused ship still wrote a manifest")
+ }
+}
+
+// TestShipRefusesAnUnsupportedCapabilityVersion pins the other half of §3b's
+// fail-closed direction at ship time: a block this binary does not speak is
+// refused with a message naming the fix, rather than shipped for a box to
+// choke on later.
+func TestShipRefusesAnUnsupportedCapabilityVersion(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ m := Manifest{
+ App: "myapp",
+ Version: "v1abc",
+ Kind: "binary",
+ Artifacts: map[string]Artifact{"linux-arm64": {}},
+ Capabilities: &caps.Set{V: caps.Version + 1, Daemon: true},
+ }
+ err := Ship(context.Background(), s, "bab", m, map[string][]byte{"linux-arm64": []byte("bin")})
+ if err == nil {
+ t.Fatal("Ship accepted a capability block version this binary does not speak")
+ }
+ if !strings.Contains(err.Error(), "update the platform binary") {
+ t.Fatalf("refusal %q does not name the fix", err)
+ }
+}
+
+// TestShipRoundTripsACapabilityBlock: a valid declaration survives the whole
+// immutable-manifest round trip — marshalled by Ship, re-decoded through
+// caps.Set's strict door by GetManifest — with every field intact. This is
+// the trust chain the design turns on: the declaration rides the same signed
+// pointer -> immutable manifest -> sha256 path as the bytes it describes.
+func TestShipRoundTripsACapabilityBlock(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ want := &caps.Set{
+ V: 1,
+ Command: "relay",
+ Daemon: true,
+ Children: true,
+ RuntimeDir: "keymail",
+ Ports: []caps.Port{{Port: 25, Name: "mx"}},
+ Data: &caps.Data{Replicate: caps.DataReplicateApp},
+ HostRole: true,
+ }
+ m := Manifest{
+ App: "keymail", Version: "v1abc", Kind: "binary",
+ Artifacts: map[string]Artifact{"linux-arm64": {}},
+ Capabilities: want,
+ }
+ if err := Ship(ctx, s, "bab", m, map[string][]byte{"linux-arm64": []byte("bin")}); err != nil {
+ t.Fatalf("Ship: %v", err)
+ }
+ got, err := GetManifest(ctx, s, "bab", "keymail", "v1abc")
+ if err != nil {
+ t.Fatalf("GetManifest: %v", err)
+ }
+ if got.Capabilities == nil {
+ t.Fatal("the capability block did not survive the round trip")
+ }
+ // Compared through the canonical JSON encoding, not reflect.DeepEqual:
+ // caps.Set carries an unexported notes slice, so a decoded Set is not
+ // DeepEqual-safe against a literal once those notes are non-empty.
+ a, _ := json.Marshal(want)
+ b, _ := json.Marshal(got.Capabilities)
+ if string(a) != string(b) {
+ t.Fatalf("capabilities round-tripped as %s, want %s", b, a)
+ }
+}