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>
4 files changed,
+299
−0
internal/release/adopt_test.go+89 −0internal/release/golden_test.go+72 −0internal/release/release.go+42 −0internal/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) |
| + } |
| +} |