release: stage a rootfs image at the version path, read-only, by its own artifact key (rootfs apps plan 2, task 3)
Manifest.BoxArtifact picks the one artifact a box stages: Artifacts[platform] for a binary, Artifacts["rootfs-"+platform] for a rootfs release. stageFrom, the adopt pass's sha readers, RetainedStaged and the proof gate's manifestSHA read it (preflight 1.7); signedhead stays binary-only. An image is staged 0644 at VersionPath, streamed and sha256-checked; a binary keeps 0755. A row stages only the kind its runtime runs (Astra R2 finding 6): stageCheck.runtime, runtimeMatches/errRuntimeMismatch first in stageFrom (before any read), and adoptHost re-checks the manifest it holds, which covers stageAuthorized's retained-copy recovery. For a rootfs release a staged-manifest sibling that cannot be written fails the stage (preflight 1.6). BoxPlatform, FleetOwnerKey and FleetOwner for later tasks. The sweep test is a pin (preflight 2.8).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
8 files changed,
+710
−33
internal/host/versionsweep_test.go+32 −0internal/release/adopt.go+97 −16internal/release/adopt_rootfs_test.go+514 −0internal/release/proofgate.go+5 −3internal/release/release.go+43 −0internal/release/stage_stream_test.go+5 −5internal/release/staged.go+9 −7internal/release/versions.go+5 −2
diff --git a/internal/host/versionsweep_test.go b/internal/host/versionsweep_test.go| index 5221bba79..df387844f 100644 |
| --- a/internal/host/versionsweep_test.go |
| +++ b/internal/host/versionsweep_test.go |
| @@ -394,3 +394,35 @@ func TestSweepStagedVersionsDeletesNothingWhenSettingsPinsAreUnknown(t *testing. |
| }) |
| } |
| } |
| + |
| +// Spec §5.1: a superseded image is swept after the grace, a referenced one |
| +// never. The image sits at the version path (contract deviation 3), so the |
| +// pin set that keeps a binary keeps it; this test is the proof that the |
| +// path choice holds, not a second mechanism. |
| +func TestSweepStagedVersionsTreatsRootfsImagesAsVersions(t *testing.T) { |
| + t.Parallel() |
| + optDir := t.TempDir() |
| + stage := func(ver string, age time.Duration) { |
| + p := release.VersionPath(optDir, "acct1", ver, "shop") |
| + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(p, []byte("squashfs"), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + when := time.Now().Add(-age) |
| + if err := os.Chtimes(p, when, when); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + stage("img-old", 48*time.Hour) |
| + stage("img-live", 48*time.Hour) |
| + reg := fakeLister{routes: []registry.Route{{Host: "shop.example.com", Kind: "instance", Version: "img-live", Runtime: registry.RuntimeRootfsV1}}} |
| + sweepStagedVersions(reg, optDir, noGenerations(t), release.DefaultSweepGrace, time.Now()) |
| + if staged(t, optDir, "img-old") { |
| + t.Error("the superseded image survived the sweep") |
| + } |
| + if !staged(t, optDir, "img-live") { |
| + t.Error("the image a route runs was swept") |
| + } |
| +} |
diff --git a/internal/release/adopt.go b/internal/release/adopt.go| index d3b5c56b3..049ff3561 100644 |
| --- a/internal/release/adopt.go |
| +++ b/internal/release/adopt.go |
| @@ -30,6 +30,11 @@ import ( |
| // tests can exercise resolution without faking runtime.GOOS/GOARCH. |
| var adoptPlatform = runtime.GOOS + "-" + runtime.GOARCH |
| +// BoxPlatform is the platform this box stages builds for (adoptPlatform): |
| +// a binary release's artifact key, and the suffix of a rootfs release's |
| +// (Manifest.BoxArtifact). For the control service. |
| +func BoxPlatform() string { return adoptPlatform } |
| + |
| // PassOption configures one call to Pass or PassFilter beyond their required |
| // arguments — NagExpiredCanaries and WithCapabilities today. The type exists |
| // so a call site opts in explicitly and a future option composes the same |
| @@ -1608,6 +1613,10 @@ func adoptHost(ctx context.Context, work hostwrite.Work, reg *registry.Registry, |
| // has no pointer digest, and staging it unbound on a digested channel is |
| // the #253 replay (invariant 3). |
| var m *Manifest |
| + // The row's runtime rides with the stage check (a copy: verified stays |
| + // the pass's shared map), so this row stages only the kind it runs. |
| + chk := cfg.stage |
| + chk.runtime = rt.Runtime |
| switch { |
| case !settings || dec.Evidence == EvidencePointer: |
| if ptr == nil || ver != ptr.Version { |
| @@ -1615,9 +1624,9 @@ func adoptHost(ctx context.Context, work hostwrite.Work, reg *registry.Registry, |
| cfg.proof.clear(rt.Host) |
| return |
| } |
| - m, err = ensureStagedDigest(ctx, s, optDir, RouteAccount(rt), rt.App, ver, rt.Host, ptr.DigestOf(ver), cfg.stage) |
| + m, err = ensureStagedDigest(ctx, s, optDir, RouteAccount(rt), rt.App, ver, rt.Host, ptr.DigestOf(ver), chk) |
| case dec.Evidence == EvidenceRecord: |
| - m, err = stageAuthorized(ctx, s, cfg.pub, optDir, RouteAccount(rt), rt.App, ver, rt.Host, cfg.stage) |
| + m, err = stageAuthorized(ctx, s, cfg.pub, optDir, RouteAccount(rt), rt.App, ver, rt.Host, chk) |
| default: |
| fmt.Printf(" %s: %s: no evidence this pass can check; not staged\n", rt.Host, ver) |
| cfg.proof.clear(rt.Host) |
| @@ -1637,6 +1646,13 @@ func adoptHost(ctx context.Context, work hostwrite.Work, reg *registry.Registry, |
| } |
| return |
| } |
| + // Again on the manifest this pass ends up holding: stageAuthorized's |
| + // retained-copy recovery (RetainedStaged) answers one that never passed |
| + // stageFrom's check (Astra R2 finding 6). |
| + if err := runtimeMatches(m, rt.Runtime); err != nil { |
| + fmt.Printf(" %s: %s: %v; not staged for this row\n", rt.Host, ver, err) |
| + return |
| + } |
| id, err := releaseIdentity(m) |
| if err != nil { |
| fmt.Printf(" %s: release identity: %v\n", rt.Host, err) |
| @@ -1693,7 +1709,8 @@ func adoptHost(ctx context.Context, work hostwrite.Work, reg *registry.Registry, |
| if (len(hist) > 0 || cfg.requireRootHistory && !fresh) && !slices.Contains(hist, newProg) && !programChange { |
| fmt.Printf(" %s: %s is %s, but this route has only run %s. not pointed live (ship a new version with carlos ship --program-change and promote it, if the change is intended)\n", |
| rt.Host, ver, newProg, strings.Join(hist, ", ")) |
| - sha := m.Artifacts[adoptPlatform].SHA256 |
| + art, _ := m.BoxArtifact(adoptPlatform) |
| + sha := art.SHA256 |
| // Nothing was restarted, so the box is still on its old build. |
| if err := cfg.recordRefusal(ctx, rt, Refusal{Host: rt.Host, App: rt.App, Version: ver, SHA256: sha, |
| Platform: adoptPlatform, PointerPromotedAt: sel.PromotedAt, RefusedAt: now.UTC(), |
| @@ -1713,7 +1730,11 @@ func adoptHost(ctx context.Context, work hostwrite.Work, reg *registry.Registry, |
| } |
| var historyAttempt string |
| if cfg.requireRootHistory { |
| - if m == nil || m.Artifacts[adoptPlatform].SHA256 == "" { |
| + var art Artifact |
| + if m != nil { |
| + art, _ = m.BoxArtifact(adoptPlatform) |
| + } |
| + if art.SHA256 == "" { |
| cfg.historyError(os.ErrPermission) |
| return |
| } |
| @@ -1721,7 +1742,7 @@ func adoptHost(ctx context.Context, work hostwrite.Work, reg *registry.Registry, |
| if !cfg.hostWrites.Enabled { |
| historyIdentity = hostwrite.Identity{} |
| } |
| - historyAttempt, err = cfg.rootHistory.begin(ctx, rt, cfg.historySelected[rt.Host], sel, strings.ToLower(m.Artifacts[adoptPlatform].SHA256), now, historyIdentity) |
| + historyAttempt, err = cfg.rootHistory.begin(ctx, rt, cfg.historySelected[rt.Host], sel, strings.ToLower(art.SHA256), now, historyIdentity) |
| if err != nil { |
| cfg.historyError(err) |
| return |
| @@ -2104,7 +2125,8 @@ func adoptHost(ctx context.Context, work hostwrite.Work, reg *registry.Registry, |
| } |
| sha := "" |
| if m != nil { |
| - sha = m.Artifacts[adoptPlatform].SHA256 |
| + art, _ := m.BoxArtifact(adoptPlatform) |
| + sha = art.SHA256 |
| } |
| writeGroupRefusals(cfg, group, rt.App, sel, ver, sha, adoptPlatform, notUp, rec, now) |
| } |
| @@ -2217,6 +2239,32 @@ type stageCheck struct { |
| // only for the sha its own checked manifest names (stageFrom). nil |
| // remembers nothing. |
| verified map[string]string |
| + // runtime is the Route.Runtime of the row this staging is for: "" a |
| + // binary row, registry.RuntimeRootfsV1 a rootfs row. A row stages only |
| + // the kind it runs (runtimeMatches), so the zero value, a binary row, |
| + // never stages a rootfs image (Astra R2 finding 6). |
| + runtime string |
| +} |
| + |
| +// errRuntimeMismatch is a release of one kind meeting a row that runs the |
| +// other: a rootfs image for a binary row, or a binary for a rootfs row. |
| +var errRuntimeMismatch = errors.New("release: the release's kind does not match the row's runtime") |
| + |
| +// runtimeMatches is nil when m is a release the row whose Route.Runtime is |
| +// runtime can run: a rootfs manifest for a rootfs row, a binary or static |
| +// one for a binary row. Anything else, a runtime this binary does not know |
| +// included, is errRuntimeMismatch. A nil m (a staged copy |
| +// whose manifest neither disk nor bucket answered, ensureStagedChecked) is |
| +// a binary row's today's answer and a refusal for a rootfs row, which never |
| +// runs without its manifest. |
| +func runtimeMatches(m *Manifest, runtime string) error { |
| + switch { |
| + case runtime == "" && (m == nil || !m.IsRootfs()): |
| + return nil |
| + case runtime == registry.RuntimeRootfsV1 && m != nil && m.IsRootfs(): |
| + return nil |
| + } |
| + return errRuntimeMismatch |
| } |
| // ensureStaged makes ver's build of app available in the version store, |
| @@ -2319,16 +2367,42 @@ func ensureStagedChecked(ctx context.Context, s Store, optDir, acct, app, ver, h |
| // and the sibling is made exactly raw (syncSibling) rather than repaired only |
| // when it does not read. Unbound, it is ensureStagedChecked as zdd-4 wrote it. |
| func stageFrom(ctx context.Context, s Store, optDir, acct, app, ver, host string, chk stageCheck, staged bool, m *Manifest, raw []byte, bound bool) (*Manifest, error) { |
| + // Before the staged-copy check and before any download: a row stages |
| + // only the kind it runs (Astra R2 finding 6). |
| + if err := runtimeMatches(m, chk.runtime); err != nil { |
| + return nil, fmt.Errorf("%s/%s: %w", app, ver, err) |
| + } |
| path := VersionPath(optDir, acct, ver, app) |
| sibling := repairSibling |
| if bound { |
| sibling = syncSibling |
| } |
| - art, hasBuild := m.Artifacts[adoptPlatform] |
| + // A rootfs start is validated against the staged sibling (root refuses |
| + // one without it), so for a rootfs release a sibling that is not there |
| + // fails the stage and nothing is pointed live; a binary's stays |
| + // best-effort (preflight 1.6). |
| + reused := func() (*Manifest, error) { |
| + sibling(optDir, acct, app, ver, host, raw, chk) |
| + if !m.IsRootfs() { |
| + return m, nil |
| + } |
| + sib, err := ReadStagedManifest(optDir, acct, ver, app) |
| + switch { |
| + case err != nil: |
| + case sib == nil: |
| + err = os.ErrNotExist |
| + case !sib.IsRootfs(): |
| + err = errRuntimeMismatch |
| + } |
| + if err != nil { |
| + return nil, fmt.Errorf("%s/%s: stage manifest sibling: %w", app, ver, err) |
| + } |
| + return m, nil |
| + } |
| + art, hasBuild := m.BoxArtifact(adoptPlatform) |
| if staged { |
| if v := chk.verified[path]; v != "" && (!bound || hasBuild && strings.EqualFold(v, art.SHA256)) { |
| - sibling(optDir, acct, app, ver, host, raw, chk) |
| - return m, nil |
| + return reused() |
| } |
| sum, herr := FileSHA256(path) |
| if herr != nil { |
| @@ -2338,8 +2412,7 @@ func stageFrom(ctx context.Context, s Store, optDir, acct, app, ver, host string |
| if chk.verified != nil { |
| chk.verified[path] = art.SHA256 |
| } |
| - sibling(optDir, acct, app, ver, host, raw, chk) |
| - return m, nil |
| + return reused() |
| } |
| // Different bytes under the same name. Overwrite only a copy nothing |
| // refers to: overwriting a referenced one would change the bytes under |
| @@ -2372,7 +2445,7 @@ func stageFrom(ctx context.Context, s Store, optDir, acct, app, ver, host string |
| // 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 nil, fmt.Errorf("release %s has no %s build — skipping", ver, adoptPlatform) |
| + return nil, fmt.Errorf("release %s has no %s build — skipping", ver, boxArtifactKey(m, adoptPlatform)) |
| } |
| // Streamed to disk and hashed on the way (#269): a build read whole |
| // outgrew the stage pass's memory cap. |
| @@ -2381,7 +2454,11 @@ func stageFrom(ctx context.Context, s Store, optDir, acct, app, ver, host string |
| return nil, err |
| } |
| } |
| - err := stageArtifactFrom(optDir, acct, ver, app, art.SHA256, func() (io.ReadCloser, error) { |
| + mode := os.FileMode(0o755) |
| + if m.IsRootfs() { |
| + mode = 0o644 // an image is mounted, never executed |
| + } |
| + err := stageArtifactFrom(optDir, acct, ver, app, art.SHA256, mode, func() (io.ReadCloser, error) { |
| rc, _, err := OpenQualified(ctx, s, ArtifactKey(acct, app, ver, art.File)) |
| return rc, err |
| }) |
| @@ -2392,10 +2469,14 @@ func stageFrom(ctx context.Context, s Store, optDir, acct, app, ver, host string |
| chk.verified[path] = art.SHA256 |
| } |
| // The sibling is the CANONICAL bytes (spec §4.2), and a restage replaces |
| - // whatever sibling the stale copy had. Best-effort: a failure here is not |
| - // fatal to staging the binary, and backfillManifest gets another chance |
| - // at it on every later pass. |
| + // whatever sibling the stale copy had. Best-effort for a binary: a |
| + // failure here is not fatal to staging it, and backfillManifest gets |
| + // another chance at it on every later pass. Fatal for a rootfs image, |
| + // which root never starts without it (preflight 1.6). |
| if err := StageManifest(optDir, acct, ver, app, raw); err != nil { |
| + if m.IsRootfs() { |
| + return nil, fmt.Errorf("%s/%s: stage manifest sibling: %w", app, ver, err) |
| + } |
| fmt.Printf(" %s: stage manifest sibling for %s: %v\n", host, ver, err) |
| } |
| return m, nil |
diff --git a/internal/release/adopt_rootfs_test.go b/internal/release/adopt_rootfs_test.go| new file mode 100644 |
| index 000000000..3cd12807e |
| --- /dev/null |
| +++ b/internal/release/adopt_rootfs_test.go |
| @@ -0,0 +1,514 @@ |
| +package release |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/ed25519" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "io" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "sync/atomic" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// stagedRootfsManifest is a valid rootfs manifest as Plan 1's ship writes one: |
| +// kind rootfs, the artifact under RootfsArtifactKey, and never under the |
| +// binary key. (Named apart from Plan 1's rootfsManifest() fixture in the |
| +// same package.) |
| +func stagedRootfsManifest(t *testing.T, image []byte) *Manifest { |
| + t.Helper() |
| + sum := sha256.Sum256(image) |
| + return &Manifest{Schema: 1, App: "shop", Version: "v1", Kind: KindRootfs, |
| + Artifacts: map[string]Artifact{RootfsArtifactKey: {File: "rootfs.squashfs", SHA256: hex.EncodeToString(sum[:]), Size: int64(len(image))}}, |
| + Processes: map[string]Process{ProcessWeb: {Command: []string{"/app/serve"}}}} |
| +} |
| + |
| +// onArm64 makes this box a linux-arm64 one for the test, the one platform a |
| +// rootfs release names (RootfsArtifactKey). Not parallel: adoptPlatform is a |
| +// package var. |
| +func onArm64(t *testing.T) { |
| + t.Helper() |
| + old := adoptPlatform |
| + adoptPlatform = "linux-arm64" |
| + t.Cleanup(func() { adoptPlatform = old }) |
| +} |
| + |
| +// putArtifact puts body at the release's artifact key, as ship leaves it. |
| +func putArtifact(t *testing.T, s Store, acct, app, ver, file string, body []byte) { |
| + t.Helper() |
| + if _, err := s.Put(context.Background(), ArtifactKey(acct, app, ver, file), body, ""); err != nil { |
| + t.Fatalf("put artifact: %v", err) |
| + } |
| +} |
| + |
| +// artifactReadCounter wraps a store and counts every Get or Open of a |
| +// release's artifact (any key under a release that is not its manifest). |
| +type artifactReadCounter struct { |
| + Store |
| + n atomic.Int64 |
| +} |
| + |
| +func (c *artifactReadCounter) count(key string) { |
| + if strings.Contains(key, "/releases/") && !strings.HasSuffix(key, "/manifest.json") { |
| + c.n.Add(1) |
| + } |
| +} |
| + |
| +func (c *artifactReadCounter) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + c.count(key) |
| + return c.Store.Get(ctx, key) |
| +} |
| + |
| +func (c *artifactReadCounter) Open(ctx context.Context, key string) (io.ReadCloser, string, error) { |
| + c.count(key) |
| + return OpenObject(ctx, c.Store, key) |
| +} |
| + |
| +func countArtifactReads(s Store) (Store, func() int64) { |
| + c := &artifactReadCounter{Store: s} |
| + return c, c.n.Load |
| +} |
| + |
| +// C2: a box from before this feature looks up Artifacts[adoptPlatform]. On a |
| +// rootfs manifest that key must not exist, so such a box stages nothing |
| +// (stageFrom's "no build for this platform, skipping" branch), whatever |
| +// else it understood. Pinned on the manifest shape, not on behaviour, so a |
| +// later change that adds a "linux-arm64" entry to a rootfs manifest fails |
| +// here before it reaches a box. |
| +func TestRootfsManifestNeverCarriesTheBinaryArtifactKey(t *testing.T) { |
| + m := stagedRootfsManifest(t, []byte("squashfs")) |
| + if _, ok := m.Artifacts["linux-arm64"]; ok { |
| + t.Fatal("a rootfs manifest carries the binary key; an old box would stage the image as a binary") |
| + } |
| + if _, ok := m.Artifacts[adoptPlatform]; ok { |
| + t.Fatal("a rootfs manifest carries this box's binary key") |
| + } |
| + if validateManifest(m) != nil { |
| + t.Fatalf("fixture is not a valid rootfs manifest: %v", validateManifest(m)) |
| + } |
| +} |
| + |
| +func TestBoxArtifactPicksTheKindsOwnKey(t *testing.T) { |
| + m := stagedRootfsManifest(t, []byte("squashfs")) |
| + if a, ok := m.BoxArtifact("linux-arm64"); !ok || a.File != "rootfs.squashfs" { |
| + t.Errorf("BoxArtifact(rootfs) = %+v %v", a, ok) |
| + } |
| + b := &Manifest{Kind: "binary", Artifacts: map[string]Artifact{"linux-arm64": {File: "shop"}, RootfsArtifactKey: {File: "rootfs.squashfs"}}} |
| + if a, ok := b.BoxArtifact("linux-arm64"); !ok || a.File != "shop" { |
| + t.Errorf("BoxArtifact(binary) = %+v %v", a, ok) |
| + } |
| + if _, ok := b.BoxArtifact("linux-amd64"); ok { |
| + t.Error("BoxArtifact found a build for a platform the manifest lacks") |
| + } |
| + if _, ok := m.BoxArtifact("linux-amd64"); ok { |
| + t.Error("BoxArtifact found an image for a platform the manifest lacks") |
| + } |
| + if rootfsArtifactKeyFor("linux-arm64") != RootfsArtifactKey { |
| + t.Errorf("rootfsArtifactKeyFor(linux-arm64) = %q, want %q", rootfsArtifactKeyFor("linux-arm64"), RootfsArtifactKey) |
| + } |
| + if !m.IsRootfs() || b.IsRootfs() { |
| + t.Error("IsRootfs does not follow Kind") |
| + } |
| +} |
| + |
| +// The image is staged where a binary is (contract deviation 3), hashed on |
| +// the way in, and never executable. |
| +func TestStageFromStagesTheImageAtTheVersionPathReadOnly(t *testing.T) { |
| + onArm64(t) |
| + image := []byte("not really a squashfs, but the bytes are the test") |
| + m := stagedRootfsManifest(t, image) |
| + optDir := t.TempDir() |
| + s := NewDirStore(t.TempDir()) |
| + putArtifact(t, s, "bab", "shop", "v1", "rootfs.squashfs", image) |
| + raw, _ := json.Marshal(m) |
| + got, err := stageFrom(context.Background(), s, optDir, "bab", "shop", "v1", "shop.example.com", stageCheck{runtime: registry.RuntimeRootfsV1}, false, m, raw, false) |
| + if err != nil || got == nil { |
| + t.Fatalf("stageFrom: %v", err) |
| + } |
| + fi, err := os.Stat(VersionPath(optDir, "bab", "v1", "shop")) |
| + if err != nil { |
| + t.Fatalf("image not at the version path: %v", err) |
| + } |
| + if fi.Mode().Perm() != 0o644 { |
| + t.Errorf("image mode %o, want 0644 (an image is mounted, never executed)", fi.Mode().Perm()) |
| + } |
| + if sum, _ := FileSHA256(VersionPath(optDir, "bab", "v1", "shop")); !strings.EqualFold(sum, m.Artifacts[RootfsArtifactKey].SHA256) { |
| + t.Error("staged bytes do not hash to the manifest's digest") |
| + } |
| + // Corrupt bytes are refused and nothing is left under the name. |
| + putArtifact(t, s, "bab", "shop", "v2", "rootfs.squashfs", []byte("tampered")) |
| + m2 := stagedRootfsManifest(t, image) |
| + m2.Version = "v2" |
| + raw2, _ := json.Marshal(m2) |
| + if _, err := stageFrom(context.Background(), s, optDir, "bab", "shop", "v2", "shop.example.com", stageCheck{runtime: registry.RuntimeRootfsV1}, false, m2, raw2, false); !errors.Is(err, errArtifactMismatch) { |
| + t.Fatalf("tampered image: err = %v, want errArtifactMismatch", err) |
| + } |
| + if _, err := os.Stat(VersionPath(optDir, "bab", "v2", "shop")); err == nil { |
| + t.Error("a tampered image was left staged") |
| + } |
| +} |
| + |
| +// A binary keeps its executable mode: the mode follows the kind. |
| +func TestStageFromStagesABinaryExecutable(t *testing.T) { |
| + onArm64(t) |
| + body := []byte("a binary") |
| + sum := sha256.Sum256(body) |
| + m := &Manifest{Schema: 1, App: "shop", Version: "v1", Kind: "binary", |
| + Artifacts: map[string]Artifact{"linux-arm64": {File: "linux-arm64", SHA256: hex.EncodeToString(sum[:]), Size: int64(len(body))}}} |
| + optDir := t.TempDir() |
| + s := NewDirStore(t.TempDir()) |
| + putArtifact(t, s, "bab", "shop", "v1", "linux-arm64", body) |
| + raw, _ := json.Marshal(m) |
| + if _, err := stageFrom(context.Background(), s, optDir, "bab", "shop", "v1", "shop.example.com", stageCheck{}, false, m, raw, false); err != nil { |
| + t.Fatalf("stageFrom: %v", err) |
| + } |
| + fi, err := os.Stat(VersionPath(optDir, "bab", "v1", "shop")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if fi.Mode().Perm() != 0o755 { |
| + t.Errorf("binary mode %o, want 0755", fi.Mode().Perm()) |
| + } |
| +} |
| + |
| +// Astra R2 finding 6, the stage half: a row stages only its own kind, and |
| +// the refusal comes before a byte is downloaded. |
| +func TestStageFromRefusesAReleaseOfTheOtherRuntime(t *testing.T) { |
| + onArm64(t) |
| + image := []byte("squashfs") |
| + rfs := stagedRootfsManifest(t, image) |
| + bin := &Manifest{Schema: 1, App: "shop", Version: "v1", Kind: "binary", |
| + Artifacts: map[string]Artifact{"linux-arm64": {File: "linux-arm64", SHA256: rfs.Artifacts[RootfsArtifactKey].SHA256, Size: int64(len(image))}}} |
| + for _, c := range []struct { |
| + name, file, runtime string |
| + m *Manifest |
| + }{ |
| + {"rootfs release, binary row", "rootfs.squashfs", "", rfs}, |
| + {"binary release, rootfs row", "linux-arm64", registry.RuntimeRootfsV1, bin}, |
| + {"rootfs release, unknown runtime", "rootfs.squashfs", "rootfs-v2", rfs}, |
| + } { |
| + optDir := t.TempDir() |
| + s, reads := countArtifactReads(NewDirStore(t.TempDir())) |
| + putArtifact(t, s, "bab", "shop", "v1", c.file, image) |
| + raw, _ := json.Marshal(c.m) |
| + before := reads() |
| + if _, err := stageFrom(context.Background(), s, optDir, "bab", "shop", "v1", "shop.example.com", stageCheck{runtime: c.runtime}, false, c.m, raw, false); !errors.Is(err, errRuntimeMismatch) { |
| + t.Errorf("%s: err = %v, want errRuntimeMismatch", c.name, err) |
| + } |
| + if reads() != before { |
| + t.Errorf("%s: the artifact was read before the refusal", c.name) |
| + } |
| + if _, err := os.Lstat(VersionPath(optDir, "bab", "v1", "shop")); err == nil { |
| + t.Errorf("%s: something was staged", c.name) |
| + } |
| + } |
| +} |
| + |
| +// binaryRowOnV1 is the adopt pass's starting point for a binary row: v1, a |
| +// binary, staged and live on host; v2 a rootfs release promoted onto the |
| +// channel. It returns the image's bytes. |
| +func binaryRowOnV1(t *testing.T, s Store, priv ed25519.PrivateKey, opt string, reg *registry.Registry, host string, settings bool) []byte { |
| + t.Helper() |
| + ctx := context.Background() |
| + shipRelease(t, s, "bab", "shop", "v1", adoptPlatform, []byte("good")) |
| + if err := Promote(ctx, s, priv, "bab", "shop", "canary/x", "v1"); err != nil { |
| + t.Fatalf("Promote v1: %v", err) |
| + } |
| + if err := StageArtifact(opt, "bab", "v1", "shop", []byte("good")); err != nil { |
| + t.Fatal(err) |
| + } |
| + if settings { |
| + settingsRoute(t, reg, host, "shop", "canary/x", UpdateSettings{Updates: UpdatesAuto, Channel: "canary/x", Pin: "v2"}, BuildStateCopy{Known: true}) |
| + } else { |
| + addRoute(t, reg, host, "shop", "canary/x", false) |
| + } |
| + if err := PointLive(opt, host, "bab", "v1", "shop"); err != nil { |
| + t.Fatal(err) |
| + } |
| + image := []byte("a rootfs image") |
| + putRootfsRelease(t, s, "bab", "shop", "v2", image, false) |
| + if err := Promote(ctx, s, priv, "bab", "shop", "canary/x", "v2"); err != nil { |
| + t.Fatalf("Promote v2: %v", err) |
| + } |
| + return image |
| +} |
| + |
| +// adoptPassOut runs one adopt pass, recording restarts, and returns what it |
| +// printed. |
| +func adoptPassOut(t *testing.T, reg *registry.Registry, s Store, pub ed25519.PublicKey, opt string, restarted *[]string, opts ...PassOption) string { |
| + t.Helper() |
| + var out bytes.Buffer |
| + withCapturedStdout(t, &out, func() { |
| + if err := Pass(context.Background(), reg, s, pub, opt, func(h string) error { |
| + *restarted = append(*restarted, h) |
| + return nil |
| + }, opts...); err != nil { |
| + t.Fatalf("Pass: %v", err) |
| + } |
| + }) |
| + return out.String() |
| +} |
| + |
| +// refusalLine is the printed line that names host, ver and the runtime |
| +// refusal, or "". |
| +func refusalLine(out, host, ver string) string { |
| + for _, l := range strings.Split(out, "\n") { |
| + if strings.Contains(l, host) && strings.Contains(l, ver) && strings.Contains(l, "runtime") { |
| + return l |
| + } |
| + } |
| + return "" |
| +} |
| + |
| +// Astra R2 finding 6, at the layer that receives the row: the adopt pass. |
| +// A binary row whose channel moves to a rootfs release keeps serving what |
| +// it has, whether the image would arrive by a fresh stage or from a copy |
| +// already staged and verified on the box (stageAuthorized's retained-copy |
| +// recovery, which never passes stageFrom's check). |
| +func TestAdoptPassRefusesARootfsReleaseForABinaryRow(t *testing.T) { |
| + onArm64(t) |
| + const host = "shop.example.com" |
| + |
| + t.Run("fresh", func(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + priv, pub := testKeys(t) |
| + opt, reg := t.TempDir(), testRegistry(t) |
| + binaryRowOnV1(t, s, priv, opt, reg, host, false) |
| + var restarted []string |
| + out := adoptPassOut(t, reg, s, pub, opt, &restarted) |
| + if v, _ := LiveVersion(opt, host); v != "v1" { |
| + t.Errorf("live = %q, want v1", v) |
| + } |
| + if _, err := os.Lstat(VersionPath(opt, "bab", "v2", "shop")); err == nil { |
| + t.Error("the rootfs image was staged for a binary row") |
| + } |
| + if len(restarted) != 0 { |
| + t.Errorf("restarted = %v, want none", restarted) |
| + } |
| + if refusalLine(out, host, "v2") == "" { |
| + t.Errorf("no line names %s, v2 and the runtime:\n%s", host, out) |
| + } |
| + }) |
| + |
| + t.Run("retained copy", func(t *testing.T) { |
| + ctx := context.Background() |
| + s := NewDirStore(t.TempDir()) |
| + priv, pub := testKeys(t) |
| + opt, reg := t.TempDir(), testRegistry(t) |
| + image := binaryRowOnV1(t, s, priv, opt, reg, host, true) |
| + // The box already has v2's image and its sibling, verified; the record |
| + // is gone (promoted "before the feature"), so the pin's evidence is |
| + // the retained copy alone. |
| + raw, _, err := s.Get(ctx, ManifestKey("bab", "shop", "v2")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := StageArtifact(opt, "bab", "v2", "shop", image); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := StageManifest(opt, "bab", "v2", "shop", raw); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := s.Delete(ctx, PromotionRecordKey("bab", "shop", "v2")); err != nil { |
| + t.Fatal(err) |
| + } |
| + if id, _ := RetainedStaged(opt, "bab", "shop", "v2"); id == nil { |
| + t.Fatal("fixture: the retained copy does not verify, so this case would not reach the recovery path") |
| + } |
| + var restarted []string |
| + out := adoptPassOut(t, reg, s, pub, opt, &restarted, WithUpdateSettings(nil, nil, true)) |
| + if !strings.Contains(out, "recovered from this box's verified staged copy") { |
| + t.Fatalf("the pass did not take the retained-copy path:\n%s", out) |
| + } |
| + if v, _ := LiveVersion(opt, host); v != "v1" { |
| + t.Errorf("live = %q, want v1", v) |
| + } |
| + if _, err := os.Lstat(VersionPath(opt, "bab", "v2", "shop")); err != nil { |
| + t.Errorf("the retained copy went away: %v", err) |
| + } |
| + if len(restarted) != 0 { |
| + t.Errorf("restarted = %v, want none", restarted) |
| + } |
| + if refusalLine(out, host, "v2") == "" { |
| + t.Errorf("no line names %s, v2 and the runtime:\n%s", host, out) |
| + } |
| + }) |
| +} |
| + |
| +// The other direction: a rootfs row moving to a rootfs v2 stages it at the |
| +// version path, read-only, with its sibling, and points it live. |
| +func TestAdoptPassStagesARootfsReleaseForARootfsRow(t *testing.T) { |
| + onArm64(t) |
| + const host = "shop.example.com" |
| + ctx := context.Background() |
| + s := NewDirStore(t.TempDir()) |
| + priv, pub := testKeys(t) |
| + opt, reg := t.TempDir(), testRegistry(t) |
| + if err := reg.Upsert(registry.Route{Host: host, Kind: "instance", App: "shop", Channel: "canary/x", Runtime: registry.RuntimeRootfsV1}); err != nil { |
| + t.Fatal(err) |
| + } |
| + var restarted []string |
| + putRootfsRelease(t, s, "bab", "shop", "v1", []byte("image one"), false) |
| + if err := Promote(ctx, s, priv, "bab", "shop", "canary/x", "v1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + adoptPassOut(t, reg, s, pub, opt, &restarted) |
| + if v, _ := LiveVersion(opt, host); v != "v1" { |
| + t.Fatalf("live = %q after the first pass, want v1", v) |
| + } |
| + image := []byte("image two") |
| + putRootfsRelease(t, s, "bab", "shop", "v2", image, false) |
| + if err := Promote(ctx, s, priv, "bab", "shop", "canary/x", "v2"); err != nil { |
| + t.Fatal(err) |
| + } |
| + out := adoptPassOut(t, reg, s, pub, opt, &restarted) |
| + p := VersionPath(opt, "bab", "v2", "shop") |
| + fi, err := os.Stat(p) |
| + if err != nil { |
| + t.Fatalf("v2 not staged: %v\n%s", err, out) |
| + } |
| + if fi.Mode().Perm() != 0o644 { |
| + t.Errorf("image mode %o, want 0644", fi.Mode().Perm()) |
| + } |
| + if sum, _ := FileSHA256(p); sum != etagOf(image) { |
| + t.Error("staged bytes do not hash to the manifest's rootfs artifact") |
| + } |
| + sib, err := ReadStagedManifest(opt, "bab", "v2", "shop") |
| + if err != nil || sib == nil || sib.Kind != KindRootfs { |
| + t.Errorf("staged sibling = %+v, %v; want a rootfs manifest", sib, err) |
| + } |
| + if v, _ := LiveVersion(opt, host); v != "v2" { |
| + t.Errorf("live = %q, want v2\n%s", v, out) |
| + } |
| +} |
| + |
| +// Preflight 1.6: the sibling is root's source of truth for a rootfs start, |
| +// so a rootfs stage whose sibling cannot be written fails; a binary stage |
| +// with the same fault still succeeds (best-effort, as today). |
| +func TestStageFromFailsARootfsStageWhoseSiblingCannotBeWritten(t *testing.T) { |
| + onArm64(t) |
| + body := []byte("bytes") |
| + sum := sha256.Sum256(body) |
| + rfs := stagedRootfsManifest(t, body) |
| + bin := &Manifest{Schema: 1, App: "shop", Version: "v1", Kind: "binary", |
| + Artifacts: map[string]Artifact{"linux-arm64": {File: "linux-arm64", SHA256: hex.EncodeToString(sum[:]), Size: int64(len(body))}}} |
| + for _, c := range []struct { |
| + name, file, runtime string |
| + m *Manifest |
| + staged, wantErr bool |
| + }{ |
| + {"rootfs", "rootfs.squashfs", registry.RuntimeRootfsV1, rfs, false, true}, |
| + {"binary", "linux-arm64", "", bin, false, false}, |
| + // The staged-copy reuse path: the bytes are already there and verify. |
| + {"rootfs, staged copy", "rootfs.squashfs", registry.RuntimeRootfsV1, rfs, true, true}, |
| + {"binary, staged copy", "linux-arm64", "", bin, true, false}, |
| + } { |
| + optDir := t.TempDir() |
| + s := NewDirStore(t.TempDir()) |
| + putArtifact(t, s, "bab", "shop", "v1", c.file, body) |
| + raw, _ := json.Marshal(c.m) |
| + if c.staged { |
| + if err := StageArtifact(optDir, "bab", "v1", "shop", body); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + // A directory where the sibling goes: the write's rename fails. |
| + sibDir := StagedManifestPath(optDir, "bab", "v1", "shop") |
| + if err := os.MkdirAll(sibDir, 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(filepath.Join(sibDir, "occupied"), nil, 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + var err error |
| + var out bytes.Buffer |
| + withCapturedStdout(t, &out, func() { |
| + _, err = stageFrom(context.Background(), s, optDir, "bab", "shop", "v1", "shop.example.com", stageCheck{runtime: c.runtime}, c.staged, c.m, raw, false) |
| + }) |
| + switch { |
| + case c.wantErr && (err == nil || !strings.Contains(err.Error(), "sibling")): |
| + t.Errorf("%s: err = %v, want one naming the sibling", c.name, err) |
| + case !c.wantErr && err != nil: |
| + t.Errorf("%s: err = %v, want nil (the binary sibling is best-effort)", c.name, err) |
| + } |
| + } |
| +} |
| + |
| +// Preflight 1.7: retained-copy recovery finds a staged image by its own key, |
| +// and only while its bytes still hash to the sibling's digest. |
| +func TestRetainedStagedFindsAStagedRootfsImage(t *testing.T) { |
| + onArm64(t) |
| + opt := t.TempDir() |
| + image := []byte("a staged image") |
| + m := stagedRootfsManifest(t, image) |
| + raw, _ := json.Marshal(m) |
| + if err := StageArtifact(opt, "bab", "v1", "shop", image); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := StageManifest(opt, "bab", "v1", "shop", raw); err != nil { |
| + t.Fatal(err) |
| + } |
| + id, got := RetainedStaged(opt, "bab", "shop", "v1") |
| + if id == nil || got == nil { |
| + t.Fatal("RetainedStaged found no staged image") |
| + } |
| + if id.Digest != etagOf(image) { |
| + t.Errorf("digest = %q, want the image's sha256", id.Digest) |
| + } |
| + if !got.IsRootfs() { |
| + t.Error("the recovered manifest is not a rootfs one") |
| + } |
| + if err := os.WriteFile(VersionPath(opt, "bab", "v1", "shop"), []byte("changed"), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + if id, _ := RetainedStaged(opt, "bab", "shop", "v1"); id != nil { |
| + t.Error("RetainedStaged vouched for bytes that no longer match") |
| + } |
| +} |
| + |
| +// Preflight 1.7: the proof gate reads a rootfs release's digest, so an |
| +// unproven image is held like an unproven binary. |
| +func TestProofGateHoldsAnUnprovenRootfsRelease(t *testing.T) { |
| + onArm64(t) |
| + e := newProofEnv(t) |
| + image := []byte("bad image") |
| + // "new" becomes a rootfs release: its binary manifest goes, the image's |
| + // comes. |
| + if err := e.store.Delete(e.ctx, ManifestKey("bab", "console", "new")); err != nil { |
| + t.Fatal(err) |
| + } |
| + putRootfsRelease(t, e.store, "bab", "console", "new", image, false) |
| + if err := Promote(e.ctx, e.store, e.priv, "bab", "console", "canary/x", "new"); err != nil { |
| + t.Fatalf("Promote new: %v", err) |
| + } |
| + if err := e.reg.Delete(proofHost); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := e.reg.Upsert(registry.Route{Host: proofHost, Kind: "instance", App: "console", Channel: "canary/x", Runtime: registry.RuntimeRootfsV1}); err != nil { |
| + t.Fatal(err) |
| + } |
| + e.requireDev(t) |
| + out := e.pass(t, e.store) |
| + if !strings.Contains(out, "is not proven on dev; staying on old") { |
| + t.Fatalf("printed %q, want the not-proven line", out) |
| + } |
| + if e.live() != "old" { |
| + t.Errorf("live = %q, want old", e.live()) |
| + } |
| + if w := e.gate.Waits(); len(w) != 1 || w[0].SHA256 != etagOf(image) { |
| + t.Errorf("waits = %+v, want one naming the image's sha256", w) |
| + } |
| +} |
| + |
| +func TestFleetOwnerKey(t *testing.T) { |
| + if got := FleetOwnerKey("acct1", "pi"); got != "accounts/acct1/fleets/pi.json" { |
| + t.Errorf("FleetOwnerKey = %q", got) |
| + } |
| +} |
diff --git a/internal/release/proofgate.go b/internal/release/proofgate.go| index 2f83a657a..318179aa8 100644 |
| --- a/internal/release/proofgate.go |
| +++ b/internal/release/proofgate.go |
| @@ -306,8 +306,9 @@ func (g *ProofGate) admit(ctx context.Context, s Store, optDir string, rt regist |
| return v, false |
| } |
| -// manifestSHA is the manifest's sha256 for this platform, from the staged |
| -// sibling when there is one and the bucket otherwise, cached per promotion. |
| +// manifestSHA is the manifest's sha256 for this platform (its binary, or its |
| +// rootfs image: Manifest.BoxArtifact), from the staged sibling when there is |
| +// one and the bucket otherwise, cached per promotion. |
| func (g *ProofGate) manifestSHA(ctx context.Context, s Store, optDir, acct, app string, ptr *ChannelPointer) (string, error) { |
| k := promotionKey{acct, app, ptr.Version, ptr.PromotedAt.Unix()} |
| g.mu.Lock() |
| @@ -323,7 +324,8 @@ func (g *ProofGate) manifestSHA(ctx context.Context, s Store, optDir, acct, app |
| return "", err |
| } |
| } |
| - sha = m.Artifacts[adoptPlatform].SHA256 |
| + art, _ := m.BoxArtifact(adoptPlatform) |
| + sha = art.SHA256 |
| g.mu.Lock() |
| g.shas[k] = sha |
| g.mu.Unlock() |
diff --git a/internal/release/release.go b/internal/release/release.go| index da8c02546..37703b24a 100644 |
| --- a/internal/release/release.go |
| +++ b/internal/release/release.go |
| @@ -126,6 +126,49 @@ type Artifact struct { |
| Size int64 `json:"size"` |
| } |
| +// rootfsArtifactKeyFor is the MANIFEST artifact key a rootfs release's image |
| +// sits under for a box of platform: "rootfs-" + platform. On linux-arm64 it |
| +// is Plan 1's RootfsArtifactKey; on any other platform it names a key no |
| +// rootfs manifest Plan 1 accepts carries, so such a box finds no image. |
| +// Distinct from IsRootfsArtifactKey (rootfs_ship.go), which tests a BUCKET |
| +// key. |
| +func rootfsArtifactKeyFor(platform string) string { return "rootfs-" + platform } |
| + |
| +// IsRootfs reports whether m is a rootfs release. |
| +func (m *Manifest) IsRootfs() bool { return m.Kind == KindRootfs } |
| + |
| +// boxArtifactKey is the artifact key a box of platform stages from m: the |
| +// platform itself for a binary or static release, rootfsArtifactKeyFor for |
| +// a rootfs one. Never the other kind's key. |
| +func boxArtifactKey(m *Manifest, platform string) string { |
| + if m.IsRootfs() { |
| + return rootfsArtifactKeyFor(platform) |
| + } |
| + return platform |
| +} |
| + |
| +// BoxArtifact is the one artifact a box of platform stages from m: |
| +// Artifacts[platform] for a binary, Artifacts[rootfsArtifactKeyFor(platform)] |
| +// for a rootfs release. Never the other key, so a rootfs release that also |
| +// carried a binary entry (Plan 1 refuses one) would still stage its image. |
| +func (m *Manifest) BoxArtifact(platform string) (Artifact, bool) { |
| + a, ok := m.Artifacts[boxArtifactKey(m, platform)] |
| + return a, ok |
| +} |
| + |
| +// FleetOwnerKey is the bucket key of the record naming the account that owns |
| +// fleet (contract deviation 2; Plan 4 writes it). |
| +func FleetOwnerKey(acct, fleet string) string { |
| + return "accounts/" + acct + "/fleets/" + fleet + ".json" |
| +} |
| + |
| +// FleetOwner is the record at FleetOwnerKey. |
| +type FleetOwner struct { |
| + Fleet string `json:"fleet"` |
| + Account string `json:"account"` |
| + CreatedAt int64 `json:"created_at,omitempty"` |
| +} |
| + |
| // ChannelPointer says which version a channel currently serves. This is the |
| // document the fleet trusts, so it ships wrapped in signedWrapper and only |
| // moves via CAS. |
diff --git a/internal/release/stage_stream_test.go b/internal/release/stage_stream_test.go| index 60fffa519..d78a3c799 100644 |
| --- a/internal/release/stage_stream_test.go |
| +++ b/internal/release/stage_stream_test.go |
| @@ -84,7 +84,7 @@ func TestStageArtifactFromKeepsMemoryFlat(t *testing.T) { |
| runtime.GC() |
| var before, after runtime.MemStats |
| runtime.ReadMemStats(&before) |
| - err := stageArtifactFrom(opt, "bab", "v1", "big", want, func() (io.ReadCloser, error) { |
| + err := stageArtifactFrom(opt, "bab", "v1", "big", want, 0o755, func() (io.ReadCloser, error) { |
| return io.NopCloser(&patternReader{n: size}), nil |
| }) |
| runtime.ReadMemStats(&after) |
| @@ -131,7 +131,7 @@ func TestStageArtifactFromLeavesTheStagedCopyOnFailure(t *testing.T) { |
| if err := StageArtifact(opt, "bab", "v1", "km", []byte("old build")); err != nil { |
| t.Fatal(err) |
| } |
| - err := stageArtifactFrom(opt, "bab", "v1", "km", tc.want, func() (io.ReadCloser, error) { |
| + err := stageArtifactFrom(opt, "bab", "v1", "km", tc.want, 0o755, func() (io.ReadCloser, error) { |
| return io.NopCloser(tc.r()), nil |
| }) |
| if err == nil || !strings.Contains(err.Error(), "download v1") { |
| @@ -159,7 +159,7 @@ func TestStageArtifactFromChecksTheStoreBeforeOpening(t *testing.T) { |
| if err := os.WriteFile(opt, nil, 0o644); err != nil { |
| t.Fatal(err) |
| } |
| - err := stageArtifactFrom(opt, "bab", "v1", "km", "", func() (io.ReadCloser, error) { |
| + err := stageArtifactFrom(opt, "bab", "v1", "km", "", 0o755, func() (io.ReadCloser, error) { |
| t.Error("the build was opened though the version store cannot be written") |
| return io.NopCloser(strings.NewReader("")), nil |
| }) |
| @@ -182,13 +182,13 @@ func TestStageArtifactFromOverlappingWriters(t *testing.T) { |
| bWrote, release, bDone := make(chan struct{}), make(chan struct{}), make(chan error, 1) |
| a := &overlapReader{body: good, atEOF: func() { |
| go func() { |
| - bDone <- stageArtifactFrom(opt, "bab", "v1", "km", want, func() (io.ReadCloser, error) { |
| + bDone <- stageArtifactFrom(opt, "bab", "v1", "km", want, 0o755, func() (io.ReadCloser, error) { |
| return io.NopCloser(&stallingReader{wrote: bWrote, release: release}), nil |
| }) |
| }() |
| <-bWrote |
| }} |
| - if err := stageArtifactFrom(opt, "bab", "v1", "km", want, func() (io.ReadCloser, error) { |
| + if err := stageArtifactFrom(opt, "bab", "v1", "km", want, 0o755, func() (io.ReadCloser, error) { |
| return io.NopCloser(a), nil |
| }); err != nil { |
| t.Fatalf("writer A: %v", err) |
diff --git a/internal/release/staged.go b/internal/release/staged.go| index df1e43e28..cf0ddc256 100644 |
| --- a/internal/release/staged.go |
| +++ b/internal/release/staged.go |
| @@ -140,19 +140,21 @@ func ResolveLiveLink(optDir, host string) (StagedIdentity, error) { |
| return StagedIdentity{Version: versionOfTarget(optDir, raw), StagedPath: hops[len(hops)-1]}, nil |
| } |
| -// RetainedStaged is ver's staged executable of app on this box, when its |
| -// bytes still hash to this platform's artifact digest in its staged manifest |
| -// sibling: the manifest the copy was verified against when it was staged |
| -// (settings spec §4.1's recovery check; root's stageAuthorized and the held |
| -// wake check both use it). nil when the sibling is absent or unreadable, |
| -// names no artifact for this platform, or the bytes differ. Demand is left |
| +// RetainedStaged is ver's staged build of app on this box (a binary, or a |
| +// rootfs image), when its bytes still hash to this platform's artifact |
| +// digest in its staged manifest sibling (Manifest.BoxArtifact): the manifest |
| +// the copy was verified against when it was staged (settings spec §4.1's |
| +// recovery check; root's stageAuthorized and the held wake check both use |
| +// it). nil when the sibling is absent or unreadable, names no artifact for |
| +// this platform, or the bytes differ. The manifest's kind is the caller's to |
| +// check against the row (adoptHost's runtimeMatches). Demand is left |
| // empty, as ResolveLive leaves it: only CanonicalDemand can vouch for it. |
| func RetainedStaged(optDir, acct, app, ver string) (*StagedIdentity, *Manifest) { |
| m, err := ReadStagedManifest(optDir, acct, ver, app) |
| if err != nil || m == nil { |
| return nil, nil |
| } |
| - a, ok := m.Artifacts[adoptPlatform] |
| + a, ok := m.BoxArtifact(adoptPlatform) |
| if !ok { |
| return nil, nil |
| } |
diff --git a/internal/release/versions.go b/internal/release/versions.go| index 25b7886f1..8ae314c7d 100644 |
| --- a/internal/release/versions.go |
| +++ b/internal/release/versions.go |
| @@ -115,10 +115,13 @@ var errArtifactMismatch = errors.New("sha256 mismatch") |
| // leaves whatever was staged under the name untouched, exactly as verifying |
| // in memory first did. |
| // |
| +// mode is the staged file's mode: 0o755 for a binary, 0o644 for a rootfs |
| +// image, which is mounted and never executed. |
| +// |
| // open is called only once the version dir is writable, so a sandboxed |
| // agent fails on the mkdir (stageBlocked relays that to root) without |
| // opening the build at all. |
| -func stageArtifactFrom(optDir, acct, ver, app, wantSHA string, open func() (io.ReadCloser, error)) error { |
| +func stageArtifactFrom(optDir, acct, ver, app, wantSHA string, mode os.FileMode, open func() (io.ReadCloser, error)) error { |
| dst, err := stagePath(optDir, acct, ver, app) |
| if err != nil { |
| return err |
| @@ -128,7 +131,7 @@ func stageArtifactFrom(optDir, acct, ver, app, wantSHA string, open func() (io.R |
| return fmt.Errorf("download %s: %w", ver, err) |
| } |
| defer in.Close() |
| - return safefile.Write(dst, 0o755, func(out io.Writer) error { |
| + return safefile.Write(dst, mode, func(out io.Writer) error { |
| h := sha256.New() |
| if _, err := io.Copy(io.MultiWriter(out, h), in); err != nil { |
| return fmt.Errorf("download %s: %w", ver, err) |