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>
4 files changed,
+321
−12
internal/release/adopt.go+47 −12internal/release/adopt_test.go+103 −0internal/release/versions.go+51 −0internal/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") |
| + } |
| +} |