carlos / platform Public

release: stage the manifest beside the binary — the converge pass reads capabilities off disk

StagedManifestPath/StageManifest/ReadStagedManifest (versions.go) put the
fetched manifest at <opt>/versions/<ver>/[<acct>/]<app>.manifest.json, a
sibling of the staged binary — no directory shape change, so an old box's
tree stays valid. ReadStagedManifest returns (nil, nil) on absence
("unknown", not "none" — a box staged before this feature existed has a
binary and no sibling) and runs the same validateManifest gate GetManifest
does on every read, so a stale sibling written by a newer binary refuses
cleanly on this one (the Task 2 review's named backstop; Task 6 wires the
actual refusal). The write is atomic (WriteFileAtomic: temp + rename).

ensureStaged now writes the sibling after a fresh download, and — the
named finding — back-fills it when the binary is already staged but the
sibling is missing, without re-downloading the artifact: without the
back-fill the converge pass would be permanently blind on exactly the
versions already running, since a staged binary never touches
ensureStaged's download branch again on its own. The sibling write is
best-effort; a failure to write it is not fatal to staging the binary,
since an unreadable sibling is the safe leave-alone state.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 4a9278786b9ce7ecd9f513231301a63f9754e18a parent ffe1ab5
4 files changed, +321 −12
  • internal/release/adopt.go +47 −12
  • internal/release/adopt_test.go +103 −0
  • internal/release/versions.go +51 −0
  • internal/release/versions_test.go +120 −0
diff --git a/internal/release/adopt.go b/internal/release/adopt.go
index 895ecf69d..ae787f630 100644
--- a/internal/release/adopt.go
+++ b/internal/release/adopt.go
@@ -7,6 +7,7 @@ package release
import (
"context"
"crypto/ed25519"
+ "encoding/json"
"errors"
"fmt"
"io"
@@ -352,27 +353,61 @@ func adoptHost(ctx context.Context, reg *registry.Registry, s Store, optDir stri
// one pass. The manifest supplies the sha; the bytes are refused unless they
// hash to it — the channel pointer's signature plus this gate is the whole
// trust chain from operator to disk.
+//
+// It also keeps the staged manifest sibling (versions.go's StageManifest)
+// current: a fresh download writes the sibling alongside the binary, and a
+// binary that is ALREADY staged but missing its sibling gets one written
+// without a re-download — the back-fill for every version staged before this
+// sibling existed. Without the back-fill, the converge pass would be
+// permanently blind on exactly the versions already running: those binaries
+// are staged once, at the version change that put them there, and never
+// touch ensureStaged again on their own account.
func ensureStaged(ctx context.Context, s Store, optDir, acct, app, ver string) error {
+ staged := false
if _, err := os.Stat(VersionPath(optDir, acct, ver, app)); err == nil {
- return nil
+ staged = true
+ if _, err := os.Stat(StagedManifestPath(optDir, acct, ver, app)); err == nil {
+ return nil // binary and sibling both present — nothing to do
+ }
+ // Binary staged, sibling missing: fall through to fetch the manifest
+ // and write ONLY the sibling. This is the one path where an
+ // already-staged version's manifest is re-fetched — kept cheap by
+ // only ever taking it when the sibling itself is absent.
}
m, err := GetManifest(ctx, s, acct, app, ver)
if err != nil {
return fmt.Errorf("manifest for %s/%s: %w", app, ver, err)
}
- art, ok := m.Artifacts[adoptPlatform]
- if !ok {
- // A box facing a release with no build for its platform: skip, never
- // install the wrong arch. The next release ships it and the box
- // catches up.
- return fmt.Errorf("release %s has no %s build — skipping", ver, adoptPlatform)
+ if !staged {
+ art, ok := m.Artifacts[adoptPlatform]
+ if !ok {
+ // A box facing a release with no build for its platform: skip, never
+ // install the wrong arch. The next release ships it and the box
+ // catches up.
+ return fmt.Errorf("release %s has no %s build — skipping", ver, adoptPlatform)
+ }
+ body, _, err := GetQualified(ctx, s, ArtifactKey(acct, app, ver, art.File))
+ if err != nil {
+ return fmt.Errorf("download %s: %w", ver, err)
+ }
+ if got := etagOf(body); got != art.SHA256 {
+ return fmt.Errorf("download %s: sha256 mismatch (refusing to stage)", ver)
+ }
+ if err := StageArtifact(optDir, acct, ver, app, body); err != nil {
+ return err
+ }
}
- body, _, err := GetQualified(ctx, s, ArtifactKey(acct, app, ver, art.File))
+ // The sibling write is best-effort: a failure here is not fatal to
+ // staging the binary — log and continue. A binary that runs with an
+ // unreadable capability sibling is the leave-alone state, which is safe
+ // (ReadStagedManifest's nil-nil-on-absence contract).
+ raw, err := json.MarshalIndent(m, "", " ")
if err != nil {
- return fmt.Errorf("download %s: %w", ver, err)
+ fmt.Printf(" %s/%s: marshal staged manifest: %v\n", app, ver, err)
+ return nil
}
- if got := etagOf(body); got != art.SHA256 {
- return fmt.Errorf("download %s: sha256 mismatch (refusing to stage)", ver)
+ if err := StageManifest(optDir, acct, ver, app, raw); err != nil {
+ fmt.Printf(" %s/%s: stage manifest sibling: %v\n", app, ver, err)
}
- return StageArtifact(optDir, acct, ver, app, body)
+ return nil
}
diff --git a/internal/release/adopt_test.go b/internal/release/adopt_test.go
index 299463bdc..9226975a8 100644
--- a/internal/release/adopt_test.go
+++ b/internal/release/adopt_test.go
@@ -14,6 +14,7 @@ import (
"testing"
"time"
+ "github.com/carlosframework/platform/internal/caps"
"github.com/carlosframework/platform/internal/registry"
)
@@ -746,3 +747,105 @@ func TestAdoptRefusesTheVersionWhenAManifestIsSemanticallyInvalid(t *testing.T)
t.Fatalf("printed %q, want a line naming what was refused", line)
}
}
+
+// TestEnsureStagedWritesTheSiblingManifest pins the fresh-download half of
+// Task 3: after ensureStaged fetches and stages a version for the first
+// time, ReadStagedManifest returns a manifest whose App/Version match and
+// whose Capabilities round-trip byte for byte — this is what lets the
+// converge pass read a running version's capabilities off disk, with no
+// bucket round-trip on every tick.
+func TestEnsureStagedWritesTheSiblingManifest(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ opt := t.TempDir()
+
+ m := Manifest{
+ App: "keymail", Version: "v1abc", Kind: "binary",
+ Artifacts: map[string]Artifact{adoptPlatform: {}},
+ Capabilities: &caps.Set{V: 1, Command: "relay", Daemon: true, RuntimeDir: "keymail"},
+ }
+ if err := Ship(ctx, s, "bab", m, map[string][]byte{adoptPlatform: []byte("relay binary")}); err != nil {
+ t.Fatalf("Ship: %v", err)
+ }
+
+ if err := ensureStaged(ctx, s, opt, "bab", "keymail", "v1abc"); err != nil {
+ t.Fatalf("ensureStaged: %v", err)
+ }
+
+ got, err := ReadStagedManifest(opt, "bab", "v1abc", "keymail")
+ if err != nil {
+ t.Fatalf("ReadStagedManifest: %v", err)
+ }
+ if got == nil || got.App != "keymail" || got.Version != "v1abc" {
+ t.Fatalf("got = %+v, want App/Version to match", got)
+ }
+ if got.Capabilities == nil || got.Capabilities.Command != "relay" || !got.Capabilities.Daemon || got.Capabilities.RuntimeDir != "keymail" {
+ t.Fatalf("Capabilities did not round-trip: %+v", got.Capabilities)
+ }
+}
+
+// TestEnsureStagedBackfillsASiblingForAnAlreadyStagedBinary pins the named
+// finding: a box that staged a version's binary before this feature existed
+// (or before the sibling write otherwise happened) must have the sibling
+// back-filled on the next pass WITHOUT the artifact being re-downloaded —
+// otherwise the converge pass is permanently blind on exactly the versions
+// already running, since a staged binary never touches ensureStaged's
+// download branch again on its own.
+func TestEnsureStagedBackfillsASiblingForAnAlreadyStagedBinary(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ opt := t.TempDir()
+
+ m := Manifest{
+ App: "keymail", Version: "v1abc", Kind: "binary",
+ Artifacts: map[string]Artifact{adoptPlatform: {}},
+ Capabilities: &caps.Set{V: 1, Daemon: true, RuntimeDir: "keymail"},
+ }
+ if err := Ship(ctx, s, "bab", m, map[string][]byte{adoptPlatform: []byte("relay binary")}); err != nil {
+ t.Fatalf("Ship: %v", err)
+ }
+
+ // Stage the binary BY HAND, as a pre-Task-3 box would have — no sibling.
+ if err := StageArtifact(opt, "bab", "v1abc", "keymail", []byte("relay binary")); err != nil {
+ t.Fatalf("StageArtifact: %v", err)
+ }
+ binPath := VersionPath(opt, "bab", "v1abc", "keymail")
+ beforeInfo, err := os.Stat(binPath)
+ if err != nil {
+ t.Fatalf("stat before backfill: %v", err)
+ }
+ beforeBytes, err := os.ReadFile(binPath)
+ if err != nil {
+ t.Fatalf("read before backfill: %v", err)
+ }
+ if m2, err := ReadStagedManifest(opt, "bab", "v1abc", "keymail"); err != nil || m2 != nil {
+ t.Fatalf("sibling should be absent before backfill: %+v, %v", m2, err)
+ }
+
+ if err := ensureStaged(ctx, s, opt, "bab", "keymail", "v1abc"); err != nil {
+ t.Fatalf("ensureStaged (backfill): %v", err)
+ }
+
+ got, err := ReadStagedManifest(opt, "bab", "v1abc", "keymail")
+ if err != nil {
+ t.Fatalf("ReadStagedManifest after backfill: %v", err)
+ }
+ if got == nil || got.Capabilities == nil || !got.Capabilities.Daemon || got.Capabilities.RuntimeDir != "keymail" {
+ t.Fatalf("backfilled sibling missing capabilities: %+v", got)
+ }
+
+ afterInfo, err := os.Stat(binPath)
+ if err != nil {
+ t.Fatalf("stat after backfill: %v", err)
+ }
+ afterBytes, err := os.ReadFile(binPath)
+ if err != nil {
+ t.Fatalf("read after backfill: %v", err)
+ }
+ if !beforeInfo.ModTime().Equal(afterInfo.ModTime()) {
+ t.Fatalf("backfill re-touched the binary: mtime %v -> %v", beforeInfo.ModTime(), afterInfo.ModTime())
+ }
+ if string(beforeBytes) != string(afterBytes) {
+ t.Fatalf("backfill re-downloaded the binary bytes")
+ }
+}
diff --git a/internal/release/versions.go b/internal/release/versions.go
index 9212f2ce4..826fd8ea5 100644
--- a/internal/release/versions.go
+++ b/internal/release/versions.go
@@ -148,6 +148,57 @@ func StageArtifact(optDir, acct, ver, app string, data []byte) error {
return WriteFileAtomic(VersionPath(optDir, acct, ver, app), data, 0o755)
}
+// StagedManifestPath is the staged binary's SIBLING file:
+//
+// <opt>/versions/<ver>/[<acct>/]<app>.manifest.json
+//
+// A sibling, not a new directory, so an old box's tree stays valid and no
+// directory shape changes. This is what lets the converge pass read a
+// RUNNING version's capabilities without a bucket round-trip on every tick.
+func StagedManifestPath(optDir, acct, ver, app string) string {
+ return VersionPath(optDir, acct, ver, app) + ".manifest.json"
+}
+
+// StageManifest writes the manifest bytes atomically beside the binary,
+// 0644 — root-writable, world-readable, never executable (it's JSON, not a
+// unit the sandboxed service execs).
+func StageManifest(optDir, acct, ver, app string, raw []byte) error {
+ return WriteFileAtomic(StagedManifestPath(optDir, acct, ver, app), raw, 0o644)
+}
+
+// ReadStagedManifest parses the sibling. It returns (nil, nil) — NOT an
+// error — when the file is absent: a box staged before this feature existed
+// has a binary and no sibling, and the converge pass must read that as
+// "unknown", not as "no capabilities". The distinction is load-bearing:
+// unknown LEAVES THE DROP-INS ALONE (spec §4d), none REMOVES them.
+//
+// A present-but-unreadable or unparsable sibling is a real error (fail
+// toward leave-alone, the same three-state rule): the caller cannot tell
+// "unknown" from "corrupt" if both return nil, and the converge pass must
+// not treat a half-written file as "nothing declared".
+//
+// The parsed manifest runs through the same validateManifest gate every
+// other manifest read does (GetManifest's), so a stale sibling written by a
+// NEWER binary — one that understood a capability this binary does not —
+// refuses cleanly here instead of being silently trusted.
+func ReadStagedManifest(optDir, acct, ver, app string) (*Manifest, error) {
+ raw, err := os.ReadFile(StagedManifestPath(optDir, acct, ver, app))
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil, nil
+ }
+ return nil, err
+ }
+ var m Manifest
+ if err := json.Unmarshal(raw, &m); err != nil {
+ return nil, fmt.Errorf("staged manifest %s/%s: %v", app, ver, err)
+ }
+ if err := validateManifest(&m); err != nil {
+ return nil, fmt.Errorf("staged manifest %s/%s: %w", app, ver, err)
+ }
+ return &m, nil
+}
+
// PointLive atomically repoints a host's live symlink at ver's build of app.
// It verifies ver is staged first, so a typo can't leave a dangling link
// that would fail the unit's ExecStart on the next restart.
diff --git a/internal/release/versions_test.go b/internal/release/versions_test.go
index 60fc06978..291d3dc59 100644
--- a/internal/release/versions_test.go
+++ b/internal/release/versions_test.go
@@ -1,6 +1,7 @@
package release
import (
+ "encoding/json"
"errors"
"net"
"net/http"
@@ -9,6 +10,8 @@ import (
"strings"
"testing"
"time"
+
+ "github.com/carlosframework/platform/internal/caps"
)
func TestStagePointLiveRoundTrip(t *testing.T) {
@@ -110,3 +113,120 @@ func TestProbeAndWaitHealthyVersion(t *testing.T) {
t.Fatalf("ProbeVersion answered on a dead socket")
}
}
+
+// TestStagedManifestPathIsASiblingForBothAccountShapes pins the exact path
+// StageManifest/ReadStagedManifest agree on: the ops account keeps the
+// pre-accounts shape byte for byte (<opt>/versions/<ver>/<app>...), and every
+// other account gets a level in between — the same split VersionPath already
+// makes, so the manifest is always a SIBLING of the binary, never a new
+// directory shape.
+func TestStagedManifestPathIsASiblingForBothAccountShapes(t *testing.T) {
+ opt := t.TempDir()
+ if got, want := StagedManifestPath(opt, OpsAccount, "v1", "myapp"),
+ filepath.Join(opt, "versions", "v1", "myapp.manifest.json"); got != want {
+ t.Fatalf("ops path = %q, want %q", got, want)
+ }
+ if got, want := StagedManifestPath(opt, "acme", "v1", "myapp"),
+ filepath.Join(opt, "versions", "v1", "acme", "myapp.manifest.json"); got != want {
+ t.Fatalf("non-ops path = %q, want %q", got, want)
+ }
+ // It is always exactly VersionPath + ".manifest.json" — a sibling, not a
+ // new directory.
+ if got, want := StagedManifestPath(opt, "acme", "v1", "myapp"), VersionPath(opt, "acme", "v1", "myapp")+".manifest.json"; got != want {
+ t.Fatalf("sibling path = %q, want %q", got, want)
+ }
+}
+
+// TestReadStagedManifestIsNilNilWhenAbsent pins the three-state contract:
+// absence is (nil, nil) — "unknown", not "none" — so a caller can tell a box
+// staged before this feature existed apart from a manifest that legitimately
+// declares no capabilities.
+func TestReadStagedManifestIsNilNilWhenAbsent(t *testing.T) {
+ opt := t.TempDir()
+ m, err := ReadStagedManifest(opt, OpsAccount, "v1", "myapp")
+ if err != nil || m != nil {
+ t.Fatalf("ReadStagedManifest on an absent sibling = %+v, %v; want nil, nil", m, err)
+ }
+}
+
+// TestStageManifestRoundTripsCapabilities exercises the plain write/read
+// path a fresh ensureStaged download takes, including that a re-stage
+// OVERWRITES rather than appending (the reviewer's named concern).
+func TestStageManifestRoundTripsCapabilities(t *testing.T) {
+ opt := t.TempDir()
+ // StageManifest is always called once the version directory exists (via
+ // StageArtifact, or because the binary is already staged in the
+ // back-fill path) — it is a sibling writer, not a directory creator.
+ if err := StageArtifact(opt, OpsAccount, "v1abc", "keymail", []byte("binary")); err != nil {
+ t.Fatalf("StageArtifact: %v", err)
+ }
+ m := Manifest{
+ Schema: 1, App: "keymail", Version: "v1abc", Kind: "binary",
+ Artifacts: map[string]Artifact{"linux-arm64": {File: "linux-arm64", SHA256: strings.Repeat("ab", 32), Size: 12}},
+ Capabilities: &caps.Set{V: 1, Command: "relay", Daemon: true, RuntimeDir: "keymail"},
+ }
+ raw, err := json.MarshalIndent(&m, "", " ")
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ if err := StageManifest(opt, OpsAccount, "v1abc", "keymail", raw); err != nil {
+ t.Fatalf("StageManifest: %v", err)
+ }
+ got, err := ReadStagedManifest(opt, OpsAccount, "v1abc", "keymail")
+ if err != nil {
+ t.Fatalf("ReadStagedManifest: %v", err)
+ }
+ if got == nil || got.App != "keymail" || got.Version != "v1abc" {
+ t.Fatalf("got = %+v, want App/Version to round-trip", got)
+ }
+ if got.Capabilities == nil || got.Capabilities.Command != "relay" || !got.Capabilities.Daemon || got.Capabilities.RuntimeDir != "keymail" {
+ t.Fatalf("Capabilities = %+v, did not round-trip", got.Capabilities)
+ }
+
+ // Re-staging the SAME version overwrites, it does not append: a stale
+ // half of an old sibling left dangling after the rename would otherwise
+ // corrupt the JSON.
+ m2 := m
+ m2.Capabilities = &caps.Set{V: 1, Daemon: true}
+ raw2, err := json.MarshalIndent(&m2, "", " ")
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ if err := StageManifest(opt, OpsAccount, "v1abc", "keymail", raw2); err != nil {
+ t.Fatalf("re-stage: %v", err)
+ }
+ got2, err := ReadStagedManifest(opt, OpsAccount, "v1abc", "keymail")
+ if err != nil {
+ t.Fatalf("ReadStagedManifest after re-stage: %v", err)
+ }
+ if got2.Capabilities.Command != "" || !got2.Capabilities.Daemon {
+ t.Fatalf("re-stage did not overwrite: %+v", got2.Capabilities)
+ }
+}
+
+// TestReadStagedManifestRunsTheValidationGate pins the Task-2-review
+// backstop: a stale sibling written by a NEWER binary — one that understood
+// a "v" this binary does not — must refuse cleanly here, the same gate
+// GetManifest runs on every bucket read. Written directly to disk (bypassing
+// StageManifest's own marshal), exactly as a newer binary's bytes would
+// arrive.
+func TestReadStagedManifestRunsTheValidationGate(t *testing.T) {
+ opt := t.TempDir()
+ raw := []byte(`{
+ "schema": 1,
+ "app": "keymail",
+ "version": "v1abc",
+ "kind": "binary",
+ "artifacts": { "linux-arm64": { "file": "linux-arm64", "sha256": "` + strings.Repeat("ab", 32) + `", "size": 12 } },
+ "capabilities": { "v": 2, "daemon": true }
+}`)
+ if err := os.MkdirAll(filepath.Dir(StagedManifestPath(opt, OpsAccount, "v1abc", "keymail")), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(StagedManifestPath(opt, OpsAccount, "v1abc", "keymail"), raw, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := ReadStagedManifest(opt, OpsAccount, "v1abc", "keymail"); err == nil {
+ t.Fatal("a sibling declaring a v this binary does not speak was accepted")
+ }
+}