carlos / platform Public

release: kind and hibernation fixed at the first ship, atomically; ShipSpooled streams the image (rootfs Plan 1 Task 3)

AppKind reads an app's kind from its newest readable release;
CheckShipKind and establishShipKind refuse a rootfs release to a binary or
static app, the reverse, and a hibernation change (RFS-1-C18 to C21). An
app's first ship writes apps/<acct>/<app>/kind.json create-only before its
first artifact, so of two first ships of different kinds one wins and the
other is refused before it writes a byte (RFS-1-C80 to C83). An app that
lists versions but has no readable manifest and no marker is refused,
never given a marker (preflight 2.3c). EstablishedKind and
EstablishFirstKind are the marker-aware names Plan 4 uses.

ShipSpooled is Ship over files on disk: each artifact hashed by streaming,
then streamed create-only (putImmutableFrom). The claim boundary streams a
create-only put of an exact rootfs image key, bounded by MaxRootfsBytes
(preflight 3.3); the record store lets it past the record checks.

Record-write census: rootfsApps1WriteCensusInventory adds putImmutableFrom
and establishShipKind (nonrecord) and re-pins both PutFrom units, PROPOSED
(census-ruling-rootfs-apps-1).

Existing test changed: TestAccountsMigrateLegacyKeepsNewerQualifiedPointer
copies a scratch ship's keys into legacy keys; it now skips the kind
marker, which no legacy-era ship wrote (the qualified v2 ship writes its
own, so the copy conflicted in accounts migrate).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 51e45feb1c6a3d1ad68acfd567249586b2de1d56 parent 937fba7
10 files changed, +1322 −12
  • approved_rootfs_copy_test.go +2 −0
  • e2e_test.go +5 −0
  • internal/hostops/mutation_boundary_putfrom.go +29 −11
  • internal/hostops/mutation_boundary_putfrom_test.go +127 −0
  • internal/provision/plan9_b8_census_test.go +1 −1
  • internal/provision/rootfs_apps_1_census_test.go +51 −0
  • internal/release/claim_app_writes.go +9 −0
  • internal/release/release.go +6 −0
  • internal/release/rootfs_ship.go +436 −0
  • internal/release/rootfs_ship_test.go +656 −0
diff --git a/approved_rootfs_copy_test.go b/approved_rootfs_copy_test.go
index 0af2d434f..a71e43a6b 100644
--- a/approved_rootfs_copy_test.go
+++ b/approved_rootfs_copy_test.go
@@ -148,6 +148,8 @@ var rootfsCopyStatuses = []string{"pending", "approved", "struck"}
var rootfsCopyBuilt = []string{
// Task 2: the manifest's rootfs kind (internal/release/rootfs.go)
"RFS-1-C01", "RFS-1-C02", "RFS-1-C03", "RFS-1-C04", "RFS-1-C05", "RFS-1-C06", "RFS-1-C07", "RFS-1-C08", "RFS-1-C09", "RFS-1-C10", "RFS-1-C11", "RFS-1-C12", "RFS-1-C13", "RFS-1-C14", "RFS-1-C15", "RFS-1-C16", "RFS-1-C17",
+ // Task 3: kind and hibernation fixed at ship (internal/release/rootfs_ship.go)
+ "RFS-1-C18", "RFS-1-C19", "RFS-1-C20", "RFS-1-C21", "RFS-1-C80", "RFS-1-C81", "RFS-1-C82", "RFS-1-C83",
}
func mustParseRootfsRecord(t *testing.T) []copyRow {
diff --git a/e2e_test.go b/e2e_test.go
index f4ed78414..ef2d54c4f 100644
--- a/e2e_test.go
+++ b/e2e_test.go
@@ -3484,6 +3484,11 @@ func TestAccountsMigrateLegacyKeepsNewerQualifiedPointer(t *testing.T) {
t.Fatal(err)
}
for _, k := range qualified {
+ // No legacy-era ship wrote a kind marker (rootfs apps Plan 1), and the
+ // qualified ship of v2 below writes its own, so the legacy copy has none.
+ if k == release.AppKindMarkerKey(release.OpsAccount, "consoleish") {
+ continue
+ }
body, _, err := scratch.Get(ctx, k)
if err != nil {
t.Fatal(err)
diff --git a/internal/hostops/mutation_boundary_putfrom.go b/internal/hostops/mutation_boundary_putfrom.go
index 5100dfe9c..8e405e837 100644
--- a/internal/hostops/mutation_boundary_putfrom.go
+++ b/internal/hostops/mutation_boundary_putfrom.go
@@ -12,14 +12,17 @@ import (
var _ release.PutStreamer = (*mutationBoundary)(nil)
// PutFrom is release.PutStreamer, written out so the boundary is never
-// bypassed by a streaming write and a 130 MB GeoIP database is never buffered
-// through it. Only a create-only put of an exact GeoIP blob key streams: it is
-// classified by key (its bytes were hashed by the publisher while staging, and
-// a create-only put cannot replace an object), then handed to the raw store.
-// Every other key does what release.PutObjectFrom does for a store with no
-// PutFrom: a bounded buffer and the ordinary checked Put.
+// bypassed by a streaming write and a large object is never buffered through
+// it. Only a create-only put of an exact GeoIP blob key or an exact release
+// rootfs image key streams: it is classified by key (a GeoIP blob was hashed
+// by its publisher while staging, a rootfs image by the ship, and a
+// create-only put cannot replace an object), bounded by the size its kind
+// allows, then handed to the raw store. Every other key does what
+// release.PutObjectFrom does for a store with no PutFrom: a bounded buffer and
+// the ordinary checked Put.
func (s *mutationBoundary) PutFrom(ctx context.Context, key string, body io.ReadSeeker, size int64, ifMatch string) (string, error) {
- if !IsGeoipBlobKey(key) || ifMatch != "" {
+ limit := streamLimit(key, ifMatch)
+ if limit == 0 {
if size > release.PutFromFallbackMax {
return "", fmt.Errorf("%s: %w", key, release.ErrTooLargeForPut)
}
@@ -32,7 +35,7 @@ func (s *mutationBoundary) PutFrom(ctx context.Context, key string, body io.Read
}
return s.Put(ctx, key, b, ifMatch)
}
- if size <= 0 || size > geoip.MaxBlobBytes {
+ if size <= 0 || size > limit {
err := fmt.Errorf("%w: %s: size %d out of range", ErrUnclassified, key, size)
s.report(MutationTarget{Surface: "bucket", Method: "Put", Key: key}, err)
return "", err
@@ -44,11 +47,26 @@ func (s *mutationBoundary) PutFrom(ctx context.Context, key string, body io.Read
return release.PutObjectFrom(ctx, s.raw, key, body, size, "")
}
+// streamLimit is the largest body a create-only put of key may stream past
+// the boundary, or 0 when key does not stream.
+func streamLimit(key, ifMatch string) int64 {
+ switch {
+ case ifMatch != "":
+ return 0
+ case IsGeoipBlobKey(key):
+ return geoip.MaxBlobBytes
+ case release.IsRootfsArtifactKey(key):
+ return release.MaxRootfsBytes
+ }
+ return 0
+}
+
// PutFrom preserves the streaming seam beneath the admission boundary. Only
-// immutable deployment GeoIP blobs bypass record-body parsing; app writes
-// still use the conditional record checks and the outer mutation boundary.
+// immutable deployment GeoIP blobs and release rootfs images bypass
+// record-body parsing (neither is ever a record body); app writes still use
+// the conditional record checks and the outer mutation boundary.
func (s conditionalRecordStore) PutFrom(ctx context.Context, key string, body io.ReadSeeker, size int64, ifMatch string) (string, error) {
- if IsGeoipBlobKey(key) && ifMatch == "" {
+ if (IsGeoipBlobKey(key) || release.IsRootfsArtifactKey(key)) && ifMatch == "" {
return release.PutObjectFrom(ctx, s.raw, key, body, size, ifMatch)
}
if size < 0 || size > release.PutFromFallbackMax {
diff --git a/internal/hostops/mutation_boundary_putfrom_test.go b/internal/hostops/mutation_boundary_putfrom_test.go
new file mode 100644
index 000000000..1c9be2f09
--- /dev/null
+++ b/internal/hostops/mutation_boundary_putfrom_test.go
@@ -0,0 +1,127 @@
+package hostops
+
+import (
+ "context"
+ "errors"
+ "io"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// putFromRecorder is a raw store that records which keys reached its PutFrom
+// and never reads the body, so a test can claim a large size with no bytes.
+type putFromRecorder struct {
+ release.Store
+ streamed []string
+}
+
+func (p *putFromRecorder) PutFrom(_ context.Context, key string, _ io.ReadSeeker, _ int64, _ string) (string, error) {
+ p.streamed = append(p.streamed, key)
+ return "streamed", nil
+}
+
+var rootfsTestKey = release.ArtifactKey("bab", "shop", "0.1.0-1", release.RootfsArtifactFile)
+
+// guardedRecorder is the claim boundary over a recording raw store, with
+// the real classifier as its checker. decided lists every key the checker
+// was asked about.
+func guardedRecorder(t *testing.T) (release.PutStreamer, *putFromRecorder, *[]string) {
+ t.Helper()
+ raw := &putFromRecorder{Store: release.NewDirStore(t.TempDir())}
+ var decided []string
+ b, err := NewMutationBoundary(true, raw, func(ctx context.Context, tg MutationTarget) error {
+ decided = append(decided, tg.Key)
+ _, err := ClassifyMutation(ctx, raw, nil, tg)
+ return err
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ ps, ok := b.(release.PutStreamer)
+ if !ok {
+ t.Fatalf("%T is not a PutStreamer", b)
+ }
+ return ps, raw, &decided
+}
+
+func TestMutationBoundaryStreamsACreateOnlyRootfsArtifact(t *testing.T) {
+ ps, raw, decided := guardedRecorder(t)
+ if _, err := ps.PutFrom(t.Context(), rootfsTestKey, strings.NewReader(""), release.PutFromFallbackMax+1, ""); err != nil {
+ t.Fatalf("PutFrom: %v", err)
+ }
+ if len(*decided) != 1 || (*decided)[0] != rootfsTestKey {
+ t.Fatalf("decided %v, want exactly the rootfs key", *decided)
+ }
+ if len(raw.streamed) != 1 || raw.streamed[0] != rootfsTestKey {
+ t.Fatalf("raw PutFrom saw %v", raw.streamed)
+ }
+ if _, err := ps.PutFrom(t.Context(), rootfsTestKey, strings.NewReader(""), release.MaxRootfsBytes, ""); err != nil {
+ t.Fatalf("PutFrom at the size limit: %v", err)
+ }
+}
+
+// Preflight 3.3: the rootfs stream keeps a size bound, as the GeoIP one does.
+func TestMutationBoundaryBoundsTheRootfsStream(t *testing.T) {
+ for _, size := range []int64{0, release.MaxRootfsBytes + 1} {
+ ps, raw, decided := guardedRecorder(t)
+ _, err := ps.PutFrom(t.Context(), rootfsTestKey, strings.NewReader(""), size, "")
+ if !errors.Is(err, ErrUnclassified) {
+ t.Errorf("size %d: err = %v, want ErrUnclassified", size, err)
+ }
+ if len(*decided) != 0 || len(raw.streamed) != 0 {
+ t.Errorf("size %d: decided %v, streamed %v; want neither", size, *decided, raw.streamed)
+ }
+ }
+}
+
+func TestMutationBoundaryStillBuffersEveryOtherLargePut(t *testing.T) {
+ for _, c := range []struct{ name, key, ifMatch string }{
+ {"a binary artifact", release.ArtifactKey("bab", "shop", "0.1.0-1", "linux-arm64"), ""},
+ {"a conditional rootfs put", rootfsTestKey, "etag"},
+ } {
+ ps, raw, _ := guardedRecorder(t)
+ _, err := ps.PutFrom(t.Context(), c.key, strings.NewReader(""), release.PutFromFallbackMax+1, c.ifMatch)
+ if !errors.Is(err, release.ErrTooLargeForPut) || len(raw.streamed) != 0 {
+ t.Errorf("%s: err = %v, streamed %v; want ErrTooLargeForPut and nothing streamed", c.name, err, raw.streamed)
+ }
+ }
+}
+
+func recordStoreOver(t *testing.T) (release.PutStreamer, *putFromRecorder) {
+ t.Helper()
+ raw := &putFromRecorder{Store: release.NewDirStore(t.TempDir())}
+ records, err := NewConditionalRecordStore(raw, make([]byte, 32))
+ if err != nil {
+ t.Fatal(err)
+ }
+ ps, ok := records.(release.PutStreamer)
+ if !ok {
+ t.Fatalf("%T is not a PutStreamer", records)
+ }
+ return ps, raw
+}
+
+func TestRecordStoreStreamsACreateOnlyRootfsArtifact(t *testing.T) {
+ ps, raw := recordStoreOver(t)
+ if _, err := ps.PutFrom(t.Context(), rootfsTestKey, strings.NewReader(""), release.PutFromFallbackMax+1, ""); err != nil {
+ t.Fatalf("PutFrom: %v", err)
+ }
+ if len(raw.streamed) != 1 || raw.streamed[0] != rootfsTestKey {
+ t.Fatalf("raw PutFrom saw %v", raw.streamed)
+ }
+}
+
+func TestRecordStoreStillBuffersEveryOtherLargePut(t *testing.T) {
+ for _, c := range []struct{ name, key, ifMatch string }{
+ {"a binary artifact", release.ArtifactKey("bab", "shop", "0.1.0-1", "linux-arm64"), ""},
+ {"a conditional rootfs put", rootfsTestKey, "etag"},
+ } {
+ ps, raw := recordStoreOver(t)
+ _, err := ps.PutFrom(t.Context(), c.key, strings.NewReader(""), release.PutFromFallbackMax+1, c.ifMatch)
+ if !errors.Is(err, release.ErrTooLargeForPut) || len(raw.streamed) != 0 {
+ t.Errorf("%s: err = %v, streamed %v; want ErrTooLargeForPut and nothing streamed", c.name, err, raw.streamed)
+ }
+ }
+}
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go
index 525edfb2c..40f0562e8 100644
--- a/internal/provision/plan9_b8_census_test.go
+++ b/internal/provision/plan9_b8_census_test.go
@@ -366,7 +366,7 @@ func TestPlan9B8RecordWriteCensus(t *testing.T) {
if len(got) < 100 {
t.Fatalf("census found only %d units under %s; the walk is broken", len(got), root)
}
- inventory := dbRegionForward3WriteCensusInventory()
+ inventory := rootfsApps1WriteCensusInventory()
problems := append(censusDiff(inventory, got), censusPolicy(inventory)...)
if len(problems) > 0 {
t.Fatalf("record-write census rejected %d site(s); update the explicit inventory only after reviewing the source:\n %s", len(problems), strings.Join(problems, "\n "))
diff --git a/internal/provision/rootfs_apps_1_census_test.go b/internal/provision/rootfs_apps_1_census_test.go
new file mode 100644
index 000000000..d84642442
--- /dev/null
+++ b/internal/provision/rootfs_apps_1_census_test.go
@@ -0,0 +1,51 @@
+package provision
+
+// Record-write census entries for rootfs apps Plan 1 Task 3 (an app's kind
+// fixed at its first ship, and a ship that streams its image from disk),
+// layered on dbRegionForward3WriteCensusInventory (main's newest). Reviewed
+// by the branch author; PROPOSED, the census owner rules on #280
+// (census-ruling-rootfs-apps-1).
+//
+// New, both nonrecord:
+// - rootfs_ship.go putImmutableFrom: the create-only streamed put of a
+// release artifact (apps/<acct>/<app>/releases/<version>/<file>), the
+// streaming twin of putImmutable.
+// - rootfs_ship.go establishShipKind: the create-only put of an app's kind
+// marker (apps/<acct>/<app>/kind.json), written once by the app's first
+// ship and never rewritten.
+//
+// Re-pinned, class and kinds unchanged:
+// - mutationBoundary.PutFrom: a create-only put of an exact release rootfs
+// image key now streams to the raw store after the boundary decides it,
+// bounded by release.MaxRootfsBytes, as a GeoIP blob already did.
+// - conditionalRecordStore.PutFrom: the same key streams past the record
+// checks, beside the GeoIP blob key; a rootfs image is never a record
+// body.
+//
+// None of these writes an instance or sidecar record.
+func rootfsApps1WriteCensusInventory() []censusEntry {
+ entries := append([]censusEntry(nil), dbRegionForward3WriteCensusInventory()...)
+ repins := []censusEntry{
+ {"internal/hostops/mutation_boundary_putfrom.go", "mutationBoundary.PutFrom", "record-transport", "def:PutFrom,sel:Put,sel:PutObjectFrom,str:Put", "e04f3976e679699e57cb69b4ada1e3af92cdd7ed99ee7a52b0c12252b4473866"},
+ {"internal/hostops/mutation_boundary_putfrom.go", "conditionalRecordStore.PutFrom", "record-transport", "def:PutFrom,sel:Put,sel:PutObjectFrom", "50fef94769c4e4d4c49d72c657ff96b0d49a8fd5f03bca4cedce19f6e5fd0067"},
+ }
+ for _, r := range repins {
+ found := false
+ for i, e := range entries {
+ if e.File == r.File && e.Unit == r.Unit {
+ if e.Class != r.Class || e.Kinds != r.Kinds {
+ panic("rootfs-apps-1 re-pin changes the class or kinds of " + e.File + ":" + e.Unit)
+ }
+ entries[i], found = r, true
+ break
+ }
+ }
+ if !found {
+ panic("rootfs-apps-1 re-pin of an unlisted unit " + r.File + ":" + r.Unit)
+ }
+ }
+ return append(entries,
+ censusEntry{"internal/release/rootfs_ship.go", "putImmutableFrom", "nonrecord", "id:PutObjectFrom", "a60dc283fe607f89671b55ff706fd3b656b303e356ac0060261c5cdbfd6855c6"},
+ censusEntry{"internal/release/rootfs_ship.go", "establishShipKind", "nonrecord", "sel:Put", "10112325518d4f7dff783adb0400148a77697c98cebe7a44e3198966a72eb224"},
+ )
+}
diff --git a/internal/release/claim_app_writes.go b/internal/release/claim_app_writes.go
index 326a51248..a2dcdc2a8 100644
--- a/internal/release/claim_app_writes.go
+++ b/internal/release/claim_app_writes.go
@@ -20,6 +20,15 @@ func Ship(ctx context.Context, s Store, acct string, m Manifest, files map[strin
})
}
+// ShipSpooled is claimRawShipSpooled under the app mutation of the
+// manifest's app: Ship for artifacts on disk (paths maps each artifact key to
+// a local file), each streamed into the bucket rather than held in memory.
+func ShipSpooled(ctx context.Context, s Store, acct string, m Manifest, paths map[string]string) error {
+ return WithAppMutation(ctx, s, acct, m.App, "release.ShipSpooled", func(ctx context.Context) error {
+ return claimRawShipSpooled(ctx, s, acct, m, paths)
+ })
+}
+
func (sg Signer) promote(ctx context.Context, s Store, acct, app, channel, version string, authorize func(*Pipeline) error, expectVersion string, rec ...Promotion) error {
return WithAppMutation(ctx, s, acct, app, "release.Promote", func(ctx context.Context) error {
return sg.claimRawPromote(ctx, s, acct, app, channel, version, authorize, expectVersion, rec...)
diff --git a/internal/release/release.go b/internal/release/release.go
index 12913c3e1..da8c02546 100644
--- a/internal/release/release.go
+++ b/internal/release/release.go
@@ -681,6 +681,12 @@ func claimRawShip(ctx context.Context, s Store, acct string, m Manifest, files m
if err := validateManifest(&m); err != nil {
return fmt.Errorf("ship: %w", err)
}
+ // The kind is checked, and for the app's first ship fixed, before the
+ // first artifact byte, so a ship that loses the first-kind race writes
+ // nothing (rootfs_ship.go).
+ if err := establishShipKind(ctx, s, acct, &m); err != nil {
+ return err
+ }
for k, a := range m.Artifacts {
if err := putImmutable(ctx, s, ArtifactKey(acct, m.App, m.Version, a.File), files[k]); err != nil {
return fmt.Errorf("ship: artifact %s: %w", k, err)
diff --git a/internal/release/rootfs_ship.go b/internal/release/rootfs_ship.go
new file mode 100644
index 000000000..5b8c819a0
--- /dev/null
+++ b/internal/release/rootfs_ship.go
@@ -0,0 +1,436 @@
+package release
+
+import (
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "log"
+ "maps"
+ "os"
+ "strings"
+ "time"
+)
+
+// An app's kind and hibernation are fixed by its first ship (rootfs apps
+// Plan 1, spec §5.0). A rootfs app takes only rootfs releases and a binary
+// or static app never takes one; a rootfs app's releases all hibernate or
+// are all always on. JIT is per release and is no part of the kind
+// (deviation 11).
+//
+// Two things speak for an app's kind: its newest readable release (AppKind)
+// and, before any release is published, the kind marker its first ship
+// wrote create-only (AppKindMarker). The marker is what makes two first
+// ships of different kinds safe: both may read an empty app, only one
+// create-only put of the marker wins, and the loser is refused before it
+// writes a byte (deviation 10).
+
+// AppKindInfo is an app's kind as its releases tell it. Kind == "" means the
+// app has no readable release.
+type AppKindInfo struct {
+ Kind, Version string
+ Hibernate bool
+}
+
+// AppKind is the one reader of an app's kind from its releases (deviation 5):
+// the newest readable, valid manifest among the app's 20 newest versions.
+// A missing manifest (an in-flight ship) is skipped, and so is one that does
+// not decode or validate (logged); a store error is returned, so a caller
+// fails closed.
+func AppKind(ctx context.Context, s Store, acct, app string) (AppKindInfo, error) {
+ info, _, err := appKind(ctx, s, acct, app)
+ return info, err
+}
+
+// appKind is AppKind that also counts the versions VersionCandidates listed,
+// so the first-ship rule can tell an empty app from one whose releases are
+// all unreadable (preflight 2.3c).
+func appKind(ctx context.Context, s Store, acct, app string) (AppKindInfo, int, error) {
+ vers, _, err := VersionCandidates(ctx, s, acct, app, 20)
+ if err != nil {
+ return AppKindInfo{}, 0, err
+ }
+ for _, v := range vers {
+ raw, _, err := GetQualified(ctx, s, ManifestKey(acct, app, v))
+ if errors.Is(err, os.ErrNotExist) {
+ continue
+ }
+ if err != nil {
+ return AppKindInfo{}, len(vers), err
+ }
+ var m Manifest
+ if err := json.Unmarshal(raw, &m); err != nil {
+ log.Printf("app kind: manifest %s/%s: decode error: %v", app, v, err)
+ continue
+ }
+ if err := validateManifest(&m); err != nil {
+ log.Printf("app kind: manifest %s/%s: validation error: %v", app, v, err)
+ continue
+ }
+ if m.App != app || m.Version != v {
+ log.Printf("app kind: manifest %s/%s: declares wrong app/version", app, v)
+ continue
+ }
+ return AppKindInfo{Kind: m.Kind, Version: m.Version, Hibernate: m.Hibernate}, len(vers), nil
+ }
+ return AppKindInfo{}, len(vers), nil
+}
+
+// EstablishedKind is AppKind, and when the app has no readable release, the
+// kind its first ship fixed in the marker: the reader for a door that must
+// honour a first ship whose publication is not complete (Plan 4's instance
+// create, placement and first-rootfs-ship refusal). A marker that cannot be
+// believed is an error.
+func EstablishedKind(ctx context.Context, s Store, acct, app string) (AppKindInfo, error) {
+ info, err := AppKind(ctx, s, acct, app)
+ if err != nil || info.Kind != "" {
+ return info, err
+ }
+ mk, err := readAppKindMarker(ctx, s, acct, app)
+ if err != nil || mk == nil {
+ return AppKindInfo{}, err
+ }
+ return AppKindInfo{Kind: mk.Kind, Version: mk.Version, Hibernate: mk.Hibernate}, nil
+}
+
+// KindConflictError refuses a release whose kind is not the app's: a rootfs
+// release to a binary or static app, or the reverse. Have and HaveVersion are
+// the app's (its newest release, or with FirstShip the ship that fixed its
+// kind, which may not have published); Want is the refused release's kind.
+type KindConflictError struct {
+ App, Have, HaveVersion, Want string
+ FirstShip bool
+}
+
+func (e *KindConflictError) Error() string {
+ switch {
+ case e.Want == KindRootfs && e.FirstShip:
+ return fmt.Sprintf("%s's first ship, %s, made it an app for %s releases, so it cannot take a rootfs release. ship rootfs releases to an app of their own, made with carlos apps create", e.App, e.HaveVersion, e.Have)
+ case e.Want == KindRootfs:
+ return fmt.Sprintf("%s ships %s releases (%s is one), so it cannot take a rootfs release. ship rootfs releases to an app of their own, made with carlos apps create", e.App, e.Have, e.HaveVersion)
+ case e.FirstShip:
+ return fmt.Sprintf("%s's first ship, %s, made it an app for rootfs releases, so it cannot take a %s release. ship that release to another app", e.App, e.HaveVersion, e.Want)
+ default:
+ return fmt.Sprintf("%s ships rootfs releases (%s is one), so it cannot take a %s release. ship that release to another app", e.App, e.HaveVersion, e.Want)
+ }
+}
+
+// HibernateConflictError refuses a rootfs release whose hibernation is not
+// the app's. Hibernate is the app's setting (the refused release asks for
+// the other); HaveVersion and FirstShip are as in KindConflictError.
+type HibernateConflictError struct {
+ App, HaveVersion string
+ Hibernate, FirstShip bool
+}
+
+func (e *HibernateConflictError) Error() string {
+ switch {
+ case e.Hibernate && e.FirstShip:
+ return fmt.Sprintf("%s's first ship, %s, made its rootfs releases hibernate, so this release must too. ship it with --hibernate, or use a new app", e.App, e.HaveVersion)
+ case e.Hibernate:
+ return fmt.Sprintf("%s's rootfs releases hibernate (%s is one), so this release must too. ship it with --hibernate, or use a new app", e.App, e.HaveVersion)
+ case e.FirstShip:
+ return fmt.Sprintf("%s's first ship, %s, made its rootfs releases always on, so this release cannot opt into hibernation. ship it without --hibernate, or use a new app", e.App, e.HaveVersion)
+ default:
+ return fmt.Sprintf("%s's rootfs releases are always on (%s is one), so this release cannot opt into hibernation. ship it without --hibernate, or use a new app", e.App, e.HaveVersion)
+ }
+}
+
+// AppKindMarker is the kind an app's first ship fixed, written create-only
+// at AppKindMarkerKey before that ship's first artifact and never rewritten
+// (deviation 10). Kind, Hibernate and Version are the first ship's
+// manifest's. The app's purge deletes it with the rest of the app's prefix.
+type AppKindMarker struct {
+ Schema int `json:"schema"`
+ Account string `json:"account"`
+ App string `json:"app"`
+ Kind string `json:"kind"`
+ Hibernate bool `json:"hibernate,omitempty"`
+ Version string `json:"version"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+// AppKindMarkerKey is where an app's kind marker lives in the bucket.
+func AppKindMarkerKey(acct, app string) string { return appPrefix(acct, app) + "kind.json" }
+
+// errAppKindUnreadable refuses a first ship to an app that lists versions
+// but has no readable manifest and no marker: its kind is unknown, so no
+// ship may fix it (preflight 2.3c). The console answers it as any other
+// failure to check the app's releases.
+var errAppKindUnreadable = errors.New("the app lists versions but none has a readable manifest, so its kind is unknown")
+
+func validMarkerKind(kind string) bool {
+ return kind == "binary" || kind == "static" || kind == KindRootfs
+}
+
+// readAppKindMarker reads and validates an app's kind marker. (nil, nil)
+// when absent. A body that does not decode strictly, has another schema,
+// names another account or app, or carries no valid kind or version is an
+// error: a kind check never skips a marker it cannot believe.
+func readAppKindMarker(ctx context.Context, s Store, acct, app string) (*AppKindMarker, error) {
+ body, _, err := s.Get(ctx, AppKindMarkerKey(acct, app))
+ if errors.Is(err, os.ErrNotExist) {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, fmt.Errorf("read kind marker for %s/%s: %w", acct, app, err)
+ }
+ dec := json.NewDecoder(bytes.NewReader(body))
+ dec.DisallowUnknownFields()
+ var mk AppKindMarker
+ if err := dec.Decode(&mk); err != nil {
+ return nil, fmt.Errorf("kind marker for %s/%s: %v", acct, app, err)
+ }
+ if _, err := dec.Token(); err != io.EOF {
+ return nil, fmt.Errorf("kind marker for %s/%s: trailing data", acct, app)
+ }
+ switch {
+ case mk.Schema != 1:
+ return nil, fmt.Errorf("kind marker for %s/%s: unsupported schema %d", acct, app, mk.Schema)
+ case mk.Account != acct || mk.App != app:
+ return nil, fmt.Errorf("kind marker at %s names %s/%s", AppKindMarkerKey(acct, app), mk.Account, mk.App)
+ case !validMarkerKind(mk.Kind) || !ValidVersion(mk.Version):
+ return nil, fmt.Errorf("kind marker for %s/%s: invalid kind %q or version %q", acct, app, mk.Kind, mk.Version)
+ }
+ return &mk, nil
+}
+
+// kindRefusal compares a release with the app's kind: rootfs-ness first,
+// then, between rootfs releases, hibernation. binary and static are one
+// family here, as they were before rootfs existed.
+func kindRefusal(m *Manifest, have, haveVersion string, haveHibernate, first bool) error {
+ if (have == KindRootfs) != (m.Kind == KindRootfs) {
+ return &KindConflictError{App: m.App, Have: have, HaveVersion: haveVersion, Want: m.Kind, FirstShip: first}
+ }
+ if have == KindRootfs && haveHibernate != m.Hibernate {
+ return &HibernateConflictError{App: m.App, HaveVersion: haveVersion, Hibernate: haveHibernate, FirstShip: first}
+ }
+ return nil
+}
+
+// shipKindState is what a kind check reads, once: the app's newest readable
+// release, how many versions were listed, and the marker when the release
+// does not decide.
+type shipKindState struct {
+ info AppKindInfo
+ listed int
+ marker *AppKindMarker
+}
+
+func readShipKindState(ctx context.Context, s Store, acct string, m *Manifest) (shipKindState, error) {
+ info, listed, err := appKind(ctx, s, acct, m.App)
+ if err != nil {
+ return shipKindState{}, err
+ }
+ st := shipKindState{info: info, listed: listed}
+ if info.Kind != "" && info.Version != m.Version {
+ return st, nil
+ }
+ st.marker, err = readAppKindMarker(ctx, s, acct, m.App)
+ return st, err
+}
+
+// decide answers the refusal for m, and whether m is the app's first ship
+// (no release, no marker, nothing listed), which must write the marker.
+// Another release decides; else the marker does, with no version skip (a
+// re-ship of the marker's version as the other kind is still the other
+// kind); else a readable re-ship of m's own version passes; else an app that
+// lists anything is refused (preflight 2.3c).
+func (st shipKindState) decide(m *Manifest) (first bool, err error) {
+ switch {
+ case st.info.Kind != "" && st.info.Version != m.Version:
+ return false, kindRefusal(m, st.info.Kind, st.info.Version, st.info.Hibernate, false)
+ case st.marker != nil:
+ return false, kindRefusal(m, st.marker.Kind, st.marker.Version, st.marker.Hibernate, true)
+ case st.info.Kind != "":
+ return false, nil
+ case st.listed > 0:
+ return false, errAppKindUnreadable
+ }
+ return true, nil
+}
+
+// CheckShipKind reports whether m may ship to its app as far as kind and
+// hibernation go: a KindConflictError, a HibernateConflictError, a store
+// error (wrapped), or nil. It writes nothing; the door runs it before
+// taking a body, and publication runs establishShipKind, which is
+// authoritative.
+func CheckShipKind(ctx context.Context, s Store, acct string, m *Manifest) error {
+ _, err := checkShipKind(ctx, s, acct, m)
+ return err
+}
+
+// checkShipKind is CheckShipKind that also reports whether m is the app's
+// first ship.
+func checkShipKind(ctx context.Context, s Store, acct string, m *Manifest) (first bool, err error) {
+ st, err := readShipKindState(ctx, s, acct, m)
+ if err != nil {
+ return false, fmt.Errorf("ship: app kind: %w", err)
+ }
+ first, err = st.decide(m)
+ if errors.Is(err, errAppKindUnreadable) {
+ return false, fmt.Errorf("ship: app kind: %w", err)
+ }
+ return first, err
+}
+
+// establishShipKind is the kind check at publication. It refuses as
+// CheckShipKind does, returning a conflict unwrapped so its Error() is the
+// deck sentence, and for the app's first ship puts the marker create-only.
+// Losing that put means another first ship won: its marker is read and
+// compared, so a ship of the other kind is refused before it writes a byte.
+// Apps whose history predates the marker never get one: their manifests
+// already fix their kind.
+func establishShipKind(ctx context.Context, s Store, acct string, m *Manifest) error {
+ if !ValidAccount(acct) || !ValidApp(m.App) || !ValidVersion(m.Version) || m.Version == "dev" || !validMarkerKind(m.Kind) {
+ return fmt.Errorf("ship: app kind: invalid account %q, app %q, version %q or kind %q", acct, m.App, m.Version, m.Kind)
+ }
+ first, err := checkShipKind(ctx, s, acct, m)
+ if err != nil || !first {
+ return err
+ }
+ body, err := json.Marshal(AppKindMarker{Schema: 1, Account: acct, App: m.App, Kind: m.Kind,
+ Hibernate: m.Hibernate, Version: m.Version, CreatedAt: time.Now().UTC()})
+ if err != nil {
+ return err
+ }
+ _, err = s.Put(ctx, AppKindMarkerKey(acct, m.App), body, "")
+ if err == nil {
+ return nil
+ }
+ if !errors.Is(err, ErrCASConflict) {
+ return fmt.Errorf("ship: app kind: %w", err)
+ }
+ mk, err := readAppKindMarker(ctx, s, acct, m.App)
+ if err != nil {
+ return fmt.Errorf("ship: app kind: %w", err)
+ }
+ if mk == nil {
+ return errors.New("ship: app kind: the kind marker conflicted and then could not be found")
+ }
+ return kindRefusal(m, mk.Kind, mk.Version, mk.Hibernate, true)
+}
+
+// EstablishFirstKind is establishShipKind for a caller that fixes an app's
+// first kind inside its own mutation boundary before a slow publication
+// (Plan 4 Task 5). The publication that follows runs establishShipKind
+// again, finds the marker of its own kind and passes; nothing is written
+// twice.
+func EstablishFirstKind(ctx context.Context, s Store, acct string, m *Manifest) error {
+ return establishShipKind(ctx, s, acct, m)
+}
+
+// IsRootfsArtifactKey reports whether key is exactly a release's rootfs
+// image: apps/<acct>/<app>/releases/<version>/rootfs.squashfs, each part
+// valid. The claim boundary streams a create-only put of such a key.
+func IsRootfsArtifactKey(key string) bool {
+ p := strings.Split(key, "/")
+ return len(p) == 6 && p[0] == "apps" && p[3] == "releases" && p[5] == RootfsArtifactFile &&
+ ValidAccount(p[1]) && ValidApp(p[2]) && ValidVersion(p[4])
+}
+
+// claimRawShipSpooled is claimRawShip over files on disk, for an image too
+// large to hold in memory: paths maps each artifact key to a local file.
+// The same checks, then each file hashed and sized by streaming it once,
+// the kind established before the first write, each artifact streamed
+// create-only, and the manifest last.
+func claimRawShipSpooled(ctx context.Context, s Store, acct string, m Manifest, paths map[string]string) error {
+ if !ValidAccount(acct) {
+ return fmt.Errorf("ship: invalid account %q", acct)
+ }
+ m.Schema = 1
+ if m.Swap != "" && m.Swap != DemandHold {
+ return fmt.Errorf("ship: swap %q: a release can demand %q or nothing", m.Swap, DemandHold)
+ }
+ m.Artifacts = maps.Clone(m.Artifacts)
+ for k, a := range m.Artifacts {
+ p, ok := paths[k]
+ if !ok {
+ return fmt.Errorf("ship: manifest artifact %q has no file", k)
+ }
+ sum, size, err := hashSpooledFile(p)
+ if err != nil {
+ return fmt.Errorf("ship: artifact %q: %w", k, err)
+ }
+ if a.SHA256 != "" && a.SHA256 != sum {
+ return fmt.Errorf("ship: artifact %q sha256 mismatch (manifest %s, bytes %s)", k, a.SHA256, sum)
+ }
+ a.SHA256, a.Size = sum, size
+ if a.File == "" {
+ a.File = k
+ }
+ m.Artifacts[k] = a
+ }
+ for k := range paths {
+ if _, ok := m.Artifacts[k]; !ok {
+ return fmt.Errorf("ship: file %q has no manifest artifact entry", k)
+ }
+ }
+ if err := validateManifest(&m); err != nil {
+ return fmt.Errorf("ship: %w", err)
+ }
+ if err := establishShipKind(ctx, s, acct, &m); err != nil {
+ return err
+ }
+ for k, a := range m.Artifacts {
+ if err := putImmutableFrom(ctx, s, ArtifactKey(acct, m.App, m.Version, a.File), paths[k], a.SHA256, a.Size); err != nil {
+ return fmt.Errorf("ship: artifact %s: %w", k, err)
+ }
+ }
+ raw, err := json.MarshalIndent(m, "", " ")
+ if err != nil {
+ return err
+ }
+ if err := putImmutable(ctx, s, ManifestKey(acct, m.App, m.Version), raw); err != nil {
+ return fmt.Errorf("ship: manifest: %w", err)
+ }
+ return nil
+}
+
+// hashSpooledFile streams a spooled file once: its hex sha256 and size.
+func hashSpooledFile(path string) (string, int64, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ return "", 0, err
+ }
+ defer f.Close()
+ h := sha256.New()
+ n, err := io.Copy(h, f)
+ if err != nil {
+ return "", 0, err
+ }
+ return hex.EncodeToString(h.Sum(nil)), n, nil
+}
+
+// putImmutableFrom is putImmutable for a file on disk: a create-only
+// streamed put, and on a lost race the existing object streamed through
+// sha256, which passes only for the same bytes.
+func putImmutableFrom(ctx context.Context, s Store, key, path, sha string, size int64) error {
+ f, err := os.Open(path)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ _, err = PutObjectFrom(ctx, s, key, f, size, "")
+ if !errors.Is(err, ErrCASConflict) {
+ return err
+ }
+ rc, _, err := OpenObject(ctx, s, key)
+ if err != nil {
+ return fmt.Errorf("%s exists but is unreadable: %w", key, err)
+ }
+ defer rc.Close()
+ h := sha256.New()
+ n, err := io.Copy(h, rc)
+ if err != nil {
+ return fmt.Errorf("%s exists but is unreadable: %w", key, err)
+ }
+ if n != size || hex.EncodeToString(h.Sum(nil)) != sha {
+ return fmt.Errorf("%s already exists with different content. refusing to overwrite an immutable release object", key)
+ }
+ return nil
+}
diff --git a/internal/release/rootfs_ship_test.go b/internal/release/rootfs_ship_test.go
new file mode 100644
index 000000000..ac8d5f1d5
--- /dev/null
+++ b/internal/release/rootfs_ship_test.go
@@ -0,0 +1,656 @@
+package release
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+)
+
+// shipCountingStore is a DirStore that counts every Put and PutFrom per key, and
+// can hold a Put before it reaches the bucket (hold, when set). Put and
+// PutFrom are the only writes a ship makes, so the counts are the whole
+// record of what a ship tried to write.
+type shipCountingStore struct {
+ *DirStore
+ mu sync.Mutex
+ puts map[string]int
+ putFroms map[string]int
+ hold func(key string)
+}
+
+func newShipCountingStore(t *testing.T) *shipCountingStore {
+ return &shipCountingStore{DirStore: NewDirStore(t.TempDir()), puts: map[string]int{}, putFroms: map[string]int{}}
+}
+
+func (c *shipCountingStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
+ c.mu.Lock()
+ c.puts[key]++
+ hold := c.hold
+ c.mu.Unlock()
+ if hold != nil {
+ hold(key)
+ }
+ return c.DirStore.Put(ctx, key, body, ifMatch)
+}
+
+func (c *shipCountingStore) PutFrom(ctx context.Context, key string, body io.ReadSeeker, size int64, ifMatch string) (string, error) {
+ c.mu.Lock()
+ c.putFroms[key]++
+ c.mu.Unlock()
+ return c.DirStore.PutFrom(ctx, key, body, size, ifMatch)
+}
+
+func (c *shipCountingStore) counts(key string) (puts, putFroms int) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return c.puts[key], c.putFroms[key]
+}
+
+// manifestGetFailStore fails every Get of one key with a store error.
+type manifestGetFailStore struct {
+ *DirStore
+ key string
+}
+
+var errDiskOnFire = errors.New("disk on fire")
+
+func (f manifestGetFailStore) Get(ctx context.Context, key string) ([]byte, string, error) {
+ if key == f.key {
+ return nil, "", errDiskOnFire
+ }
+ return f.DirStore.Get(ctx, key)
+}
+
+// listFailStore hides the DirStore's ListMod (it embeds only Store), so
+// VersionCandidates lists through the failing List.
+type listFailStore struct{ Store }
+
+func (listFailStore) List(context.Context, string) ([]string, error) { return nil, errDiskOnFire }
+
+func binaryManifest(ver string) Manifest {
+ return Manifest{App: "shop", Version: ver, Kind: "binary", Artifacts: map[string]Artifact{adoptPlatform: {}}}
+}
+
+func binaryFiles() map[string][]byte { return map[string][]byte{adoptPlatform: []byte("bin")} }
+
+// spooledRootfs is a rootfs manifest for ver whose artifact digest Ship
+// fills, and the path of an image file holding body.
+func spooledRootfs(t *testing.T, ver string, hibernate bool, body string) (Manifest, map[string]string) {
+ t.Helper()
+ m := *rootfsManifest()
+ m.Version, m.Hibernate = ver, hibernate
+ m.Artifacts = map[string]Artifact{RootfsArtifactKey: {File: RootfsArtifactFile}}
+ p := filepath.Join(t.TempDir(), RootfsArtifactFile)
+ if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ return m, map[string]string{RootfsArtifactKey: p}
+}
+
+func putMarker(t *testing.T, s Store, mk AppKindMarker) {
+ t.Helper()
+ raw, err := json.Marshal(mk)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.Put(context.Background(), AppKindMarkerKey(mk.Account, mk.App), raw, ""); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func readMarker(t *testing.T, s Store, acct, app string) *AppKindMarker {
+ t.Helper()
+ mk, err := readAppKindMarker(context.Background(), s, acct, app)
+ if err != nil {
+ t.Fatalf("readAppKindMarker: %v", err)
+ }
+ return mk
+}
+
+func TestCheckShipKindRefusesARootfsReleaseToABinaryApp(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ putRawRelease(t, s, "bab", "shop", "0.1.0-1", []byte("bin"), "")
+ m := rootfsManifest()
+ m.Version = "0.1.0-2"
+ var ke *KindConflictError
+ err := CheckShipKind(context.Background(), s, "bab", m)
+ if !errors.As(err, &ke) || ke.Have != "binary" || ke.HaveVersion != "0.1.0-1" || ke.Want != KindRootfs || ke.FirstShip {
+ t.Fatalf("err = %v, want a KindConflictError naming the binary 0.1.0-1", err)
+ }
+ if !strings.HasPrefix(err.Error(), "shop ships binary releases (0.1.0-1 is one), so it cannot take a rootfs release") {
+ t.Fatalf("message = %q", err)
+ }
+}
+
+func TestCheckShipKindRefusesABinaryReleaseToARootfsApp(t *testing.T) {
+ for _, want := range []string{"binary", "static"} {
+ s := NewDirStore(t.TempDir())
+ putRootfsRelease(t, s, "bab", "shop", "0.1.0-1", []byte("image"), false)
+ m := binaryManifest("0.1.0-2")
+ m.Kind = want
+ var ke *KindConflictError
+ err := CheckShipKind(context.Background(), s, "bab", &m)
+ if !errors.As(err, &ke) || ke.Have != KindRootfs || ke.HaveVersion != "0.1.0-1" || ke.Want != want {
+ t.Fatalf("%s: err = %v, want a KindConflictError naming the rootfs 0.1.0-1", want, err)
+ }
+ if got, wantMsg := err.Error(), "shop ships rootfs releases (0.1.0-1 is one), so it cannot take a "+want+" release. ship that release to another app"; got != wantMsg {
+ t.Fatalf("%s: message = %q, want %q", want, got, wantMsg)
+ }
+ }
+}
+
+func TestCheckShipKindRefusesAHibernationChange(t *testing.T) {
+ for _, c := range []struct {
+ history bool
+ want string
+ }{
+ {false, "shop's rootfs releases are always on (0.1.0-1 is one), so this release cannot opt into hibernation. ship it without --hibernate, or use a new app"},
+ {true, "shop's rootfs releases hibernate (0.1.0-1 is one), so this release must too. ship it with --hibernate, or use a new app"},
+ } {
+ s := NewDirStore(t.TempDir())
+ putRootfsRelease(t, s, "bab", "shop", "0.1.0-1", []byte("image"), c.history)
+ m := rootfsManifest()
+ m.Version, m.Hibernate = "0.1.0-2", !c.history
+ var he *HibernateConflictError
+ err := CheckShipKind(context.Background(), s, "bab", m)
+ if !errors.As(err, &he) || he.App != "shop" || he.HaveVersion != "0.1.0-1" || he.Hibernate != c.history || he.FirstShip {
+ t.Fatalf("history hibernate=%v: err = %v, want a HibernateConflictError", c.history, err)
+ }
+ if err.Error() != c.want {
+ t.Fatalf("message = %q, want %q", err, c.want)
+ }
+ }
+}
+
+func TestCheckShipKindAllowsAFirstReleaseAndTheSameKind(t *testing.T) {
+ ctx := context.Background()
+ empty := NewDirStore(t.TempDir())
+ if err := CheckShipKind(ctx, empty, "bab", rootfsManifest()); err != nil {
+ t.Errorf("first release: %v", err)
+ }
+ bins := NewDirStore(t.TempDir())
+ putRawRelease(t, bins, "bab", "shop", "0.1.0-1", []byte("bin"), "")
+ for _, kind := range []string{"binary", "static"} {
+ m := binaryManifest("0.1.0-2")
+ m.Kind = kind
+ if err := CheckShipKind(ctx, bins, "bab", &m); err != nil {
+ t.Errorf("a %s after binaries: %v", kind, err)
+ }
+ }
+ for _, hib := range []bool{false, true} {
+ s := NewDirStore(t.TempDir())
+ putRootfsRelease(t, s, "bab", "shop", "0.1.0-1", []byte("image"), hib)
+ m := rootfsManifest()
+ m.Version, m.Hibernate = "0.1.0-2", hib
+ if err := CheckShipKind(ctx, s, "bab", m); err != nil {
+ t.Errorf("a rootfs after rootfs, hibernate=%v: %v", hib, err)
+ }
+ }
+}
+
+func TestCheckShipKindSkipsTheVersionBeingShipped(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ putRawRelease(t, s, "bab", "shop", "0.1.0-1", []byte("bin"), "")
+ m := rootfsManifest() // 0.1.0-1: a re-ship of the only release's version
+ if err := CheckShipKind(context.Background(), s, "bab", m); err != nil {
+ t.Fatalf("re-check against its own version: %v", err)
+ }
+}
+
+func TestCheckShipKindSkipsAnUnreadableManifestButFailsClosedOnTheStore(t *testing.T) {
+ ctx := context.Background()
+ s := NewDirStore(t.TempDir())
+ putRawRelease(t, s, "bab", "shop", "0.1.0-1", []byte("bin"), "")
+ if _, err := s.Put(ctx, ManifestKey("bab", "shop", "0.1.0-2"), []byte("{not json"), ""); err != nil {
+ t.Fatal(err)
+ }
+ m := rootfsManifest()
+ m.Version = "0.1.0-3"
+ var ke *KindConflictError
+ if err := CheckShipKind(ctx, s, "bab", m); !errors.As(err, &ke) || ke.HaveVersion != "0.1.0-1" {
+ t.Fatalf("err = %v, want the unreadable 0.1.0-2 skipped for the binary 0.1.0-1", err)
+ }
+ broken := manifestGetFailStore{DirStore: s, key: ManifestKey("bab", "shop", "0.1.0-1")}
+ if err := CheckShipKind(ctx, broken, "bab", m); !errors.Is(err, errDiskOnFire) || err == errDiskOnFire {
+ t.Fatalf("err = %v, want the store error wrapped", err)
+ }
+}
+
+// Preflight 2.3c: an app that lists versions but has no readable manifest
+// and no marker is refused, never given a marker of the incoming kind.
+func TestFirstShipRefusesAnAppWhoseReleasesAreAllUnreadable(t *testing.T) {
+ ctx := context.Background()
+ s := newShipCountingStore(t)
+ if _, err := s.DirStore.Put(ctx, ManifestKey("bab", "shop", "0.1.0-1"), []byte("{not json"), ""); err != nil {
+ t.Fatal(err)
+ }
+ m, paths := spooledRootfs(t, "0.1.0-2", false, "image")
+ for name, err := range map[string]error{
+ "CheckShipKind": CheckShipKind(ctx, s, "bab", &m),
+ "establishShipKind": establishShipKind(ctx, s, "bab", &m),
+ "ShipSpooled": ShipSpooled(ctx, s, "bab", m, paths),
+ } {
+ if !errors.Is(err, errAppKindUnreadable) {
+ t.Errorf("%s: err = %v, want errAppKindUnreadable", name, err)
+ }
+ }
+ if p, _ := s.counts(AppKindMarkerKey("bab", "shop")); p != 0 {
+ t.Errorf("the marker was put %d times", p)
+ }
+ if p, f := s.counts(ArtifactKey("bab", "shop", "0.1.0-2", RootfsArtifactFile)); p+f != 0 {
+ t.Errorf("the image was written")
+ }
+}
+
+func TestAppKindIsTheNewestValidRelease(t *testing.T) {
+ ctx := context.Background()
+ s := NewDirStore(t.TempDir())
+ if info, err := AppKind(ctx, s, "bab", "shop"); err != nil || info != (AppKindInfo{}) {
+ t.Fatalf("no release: %+v, %v", info, err)
+ }
+ putRawRelease(t, s, "bab", "shop", "0.1.0-1", []byte("bin"), "")
+ putRootfsRelease(t, s, "bab", "shop", "0.1.0-2", []byte("image"), true)
+ want := AppKindInfo{Kind: KindRootfs, Version: "0.1.0-2", Hibernate: true}
+ if info, err := AppKind(ctx, s, "bab", "shop"); err != nil || info != want {
+ t.Fatalf("AppKind = %+v, %v; want %+v", info, err, want)
+ }
+ if _, err := s.Put(ctx, ManifestKey("bab", "shop", "0.1.0-3"), []byte("{not json"), ""); err != nil {
+ t.Fatal(err)
+ }
+ if info, err := AppKind(ctx, s, "bab", "shop"); err != nil || info != want {
+ t.Fatalf("with an unreadable newest: %+v, %v; want %+v", info, err, want)
+ }
+ if _, err := AppKind(ctx, listFailStore{s}, "bab", "shop"); !errors.Is(err, errDiskOnFire) {
+ t.Fatalf("a failing List: %v", err)
+ }
+}
+
+func TestShipSpooledStreamsTheArtifact(t *testing.T) {
+ ctx := context.Background()
+ s := newShipCountingStore(t)
+ m, paths := spooledRootfs(t, "0.1.0-1", false, "the image")
+ if err := ShipSpooled(ctx, s, "bab", m, paths); err != nil {
+ t.Fatalf("ShipSpooled: %v", err)
+ }
+ art := ArtifactKey("bab", "shop", "0.1.0-1", RootfsArtifactFile)
+ if p, f := s.counts(art); p != 0 || f != 1 {
+ t.Errorf("artifact: %d Puts and %d PutFroms, want 0 and 1", p, f)
+ }
+ if p, f := s.counts(ManifestKey("bab", "shop", "0.1.0-1")); p != 1 || f != 0 {
+ t.Errorf("manifest: %d Puts and %d PutFroms, want 1 and 0", p, f)
+ }
+ got, err := GetManifest(ctx, s, "bab", "shop", "0.1.0-1")
+ if err != nil {
+ t.Fatalf("GetManifest: %v", err)
+ }
+ a := got.Artifacts[RootfsArtifactKey]
+ if got.Kind != KindRootfs || a.Size != int64(len("the image")) || a.SHA256 != etagOf([]byte("the image")) {
+ t.Fatalf("manifest = %+v", got)
+ }
+ if body, _, err := s.Get(ctx, art); err != nil || string(body) != "the image" {
+ t.Fatalf("artifact = %q, %v", body, err)
+ }
+}
+
+func TestShipSpooledIsIdempotentAndRefusesOtherBytes(t *testing.T) {
+ ctx := context.Background()
+ s := NewDirStore(t.TempDir())
+ m, paths := spooledRootfs(t, "0.1.0-1", false, "the image")
+ for i := range 2 {
+ if err := ShipSpooled(ctx, s, "bab", m, paths); err != nil {
+ t.Fatalf("ship %d: %v", i+1, err)
+ }
+ }
+ other, otherPaths := spooledRootfs(t, "0.1.0-1", false, "another image")
+ err := ShipSpooled(ctx, s, "bab", other, otherPaths)
+ if err == nil || !strings.Contains(err.Error(), "already exists with different content") {
+ t.Fatalf("other bytes under the same version: %v", err)
+ }
+}
+
+func TestShipSpooledRefusesAFileThatDisagreesWithTheManifest(t *testing.T) {
+ ctx := context.Background()
+ s := newShipCountingStore(t)
+ m, paths := spooledRootfs(t, "0.1.0-1", false, "the image")
+ m.Artifacts[RootfsArtifactKey] = Artifact{File: RootfsArtifactFile, SHA256: etagOf([]byte("not the image"))}
+ if err := ShipSpooled(ctx, s, "bab", m, paths); err == nil || !strings.Contains(err.Error(), "sha256 mismatch") {
+ t.Fatalf("err = %v, want a sha256 mismatch", err)
+ }
+ if len(s.puts)+len(s.putFroms) != 0 {
+ t.Fatalf("writes before the refusal: puts %v, putFroms %v", s.puts, s.putFroms)
+ }
+}
+
+func TestFirstShipWritesTheKindMarker(t *testing.T) {
+ ctx := context.Background()
+ s := newShipCountingStore(t)
+ before := time.Now().Add(-time.Second)
+ if err := Ship(ctx, s, "bab", binaryManifest("0.1.0-1"), binaryFiles()); err != nil {
+ t.Fatalf("Ship: %v", err)
+ }
+ mk := readMarker(t, s, "bab", "shop")
+ if mk == nil || mk.CreatedAt.Before(before) {
+ t.Fatalf("marker = %+v", mk)
+ }
+ mk.CreatedAt = time.Time{}
+ if want := (AppKindMarker{Schema: 1, Account: "bab", App: "shop", Kind: "binary", Version: "0.1.0-1"}); *mk != want {
+ t.Fatalf("marker = %+v, want %+v", *mk, want)
+ }
+ rm, paths := spooledRootfs(t, "0.1.0-1", true, "image")
+ rm.App = "blog"
+ if err := ShipSpooled(ctx, s, "bab", rm, paths); err != nil {
+ t.Fatalf("ShipSpooled: %v", err)
+ }
+ if mk := readMarker(t, s, "bab", "blog"); mk == nil || mk.Kind != KindRootfs || !mk.Hibernate || mk.Version != "0.1.0-1" || mk.App != "blog" {
+ t.Fatalf("rootfs marker = %+v", mk)
+ }
+ if err := Ship(ctx, s, "bab", binaryManifest("0.1.0-2"), binaryFiles()); err != nil {
+ t.Fatalf("second Ship: %v", err)
+ }
+ rm2, paths2 := spooledRootfs(t, "0.1.0-2", true, "image 2")
+ rm2.App = "blog"
+ if err := ShipSpooled(ctx, s, "bab", rm2, paths2); err != nil {
+ t.Fatalf("second ShipSpooled: %v", err)
+ }
+ for _, app := range []string{"shop", "blog"} {
+ if p, f := s.counts(AppKindMarkerKey("bab", app)); p != 1 || f != 0 {
+ t.Errorf("%s marker: %d Puts and %d PutFroms, want exactly one Put", app, p, f)
+ }
+ }
+ old := newShipCountingStore(t)
+ putRawRelease(t, old, "bab", "shop", "0.1.0-1", []byte("bin"), "")
+ if err := Ship(ctx, old, "bab", binaryManifest("0.1.0-2"), binaryFiles()); err != nil {
+ t.Fatalf("Ship over history that predates the marker: %v", err)
+ }
+ if p, _ := old.counts(AppKindMarkerKey("bab", "shop")); p != 0 {
+ t.Errorf("history that predates the marker gained one (%d Puts)", p)
+ }
+}
+
+func TestCheckShipKindReadsTheMarkerOfAnUnpublishedFirstShip(t *testing.T) {
+ ctx := context.Background()
+ s := NewDirStore(t.TempDir())
+ putMarker(t, s, AppKindMarker{Schema: 1, Account: "bab", App: "shop", Kind: "binary", Version: "0.1.0-1", CreatedAt: time.Now()})
+ var ke *KindConflictError
+ err := CheckShipKind(ctx, s, "bab", rootfsManifest())
+ if !errors.As(err, &ke) || !ke.FirstShip || ke.HaveVersion != "0.1.0-1" || ke.Have != "binary" {
+ t.Fatalf("err = %v, want a first-ship KindConflictError", err)
+ }
+ if !strings.HasPrefix(err.Error(), "shop's first ship, 0.1.0-1, made it an app for binary releases, so it cannot take a rootfs release") {
+ t.Fatalf("message = %q", err)
+ }
+ for _, v := range []string{"0.1.0-1", "0.1.0-2"} {
+ m := binaryManifest(v)
+ if err := CheckShipKind(ctx, s, "bab", &m); err != nil {
+ t.Errorf("binary %s after a binary first ship: %v", v, err)
+ }
+ }
+ r := NewDirStore(t.TempDir())
+ putMarker(t, r, AppKindMarker{Schema: 1, Account: "bab", App: "shop", Kind: KindRootfs, Hibernate: true, Version: "0.1.0-1", CreatedAt: time.Now()})
+ m := rootfsManifest()
+ m.Version = "0.1.0-2"
+ err = CheckShipKind(ctx, r, "bab", m)
+ if want := "shop's first ship, 0.1.0-1, made its rootfs releases hibernate, so this release must too. ship it with --hibernate, or use a new app"; err == nil || err.Error() != want {
+ t.Fatalf("always on after a hibernating first ship: %v, want C83", err)
+ }
+ a := NewDirStore(t.TempDir())
+ putMarker(t, a, AppKindMarker{Schema: 1, Account: "bab", App: "shop", Kind: KindRootfs, Version: "0.1.0-1", CreatedAt: time.Now()})
+ m.Hibernate = true
+ var he *HibernateConflictError
+ err = CheckShipKind(ctx, a, "bab", m)
+ if want := "shop's first ship, 0.1.0-1, made its rootfs releases always on, so this release cannot opt into hibernation. ship it without --hibernate, or use a new app"; !errors.As(err, &he) || !he.FirstShip || err.Error() != want {
+ t.Fatalf("hibernating after an always-on first ship: %v, want C82", err)
+ }
+ // C81: a binary to an app whose first ship was rootfs.
+ b := binaryManifest("0.1.0-2")
+ if err := CheckShipKind(ctx, a, "bab", &b); err == nil || err.Error() != "shop's first ship, 0.1.0-1, made it an app for rootfs releases, so it cannot take a binary release. ship that release to another app" {
+ t.Fatalf("binary after a rootfs first ship: %v, want C81", err)
+ }
+}
+
+func TestCheckShipKindFailsClosedOnABadMarker(t *testing.T) {
+ ctx := context.Background()
+ good := AppKindMarker{Schema: 1, Account: "bab", App: "shop", Kind: "binary", Version: "0.1.0-1", CreatedAt: time.Now()}
+ otherApp, otherSchema, otherAcct := good, good, good
+ otherApp.App, otherSchema.Schema, otherAcct.Account = "blog", 2, "kpz"
+ bodies := map[string][]byte{"not json": []byte("{not json"), "unknown field": []byte(`{"schema":1,"account":"bab","app":"shop","kind":"binary","version":"0.1.0-1","created_at":"2026-10-24T00:00:00Z","extra":1}`)}
+ for name, mk := range map[string]AppKindMarker{"another app": otherApp, "another schema": otherSchema, "another account": otherAcct} {
+ raw, err := json.Marshal(mk)
+ if err != nil {
+ t.Fatal(err)
+ }
+ bodies[name] = raw
+ }
+ for name, body := range bodies {
+ s := newShipCountingStore(t)
+ if _, err := s.DirStore.Put(ctx, AppKindMarkerKey("bab", "shop"), body, ""); err != nil {
+ t.Fatal(err)
+ }
+ m := binaryManifest("0.1.0-1")
+ if err := CheckShipKind(ctx, s, "bab", &m); err == nil {
+ t.Errorf("%s: CheckShipKind passed", name)
+ }
+ if err := establishShipKind(ctx, s, "bab", &m); err == nil {
+ t.Errorf("%s: establishShipKind passed", name)
+ }
+ if err := Ship(ctx, s, "bab", m, binaryFiles()); err == nil {
+ t.Errorf("%s: Ship passed", name)
+ }
+ if len(s.puts)+len(s.putFroms) != 0 {
+ t.Errorf("%s: writes %v %v", name, s.puts, s.putFroms)
+ }
+ }
+}
+
+// twoPartyBarrier lets two goroutines pass only once both have arrived, or
+// after 10 s (preflight 3.6), when it fails the test and lets both go.
+type twoPartyBarrier struct {
+ mu sync.Mutex
+ arrived int
+ open chan struct{}
+ once sync.Once
+}
+
+func (b *twoPartyBarrier) wait(t *testing.T) {
+ b.mu.Lock()
+ b.arrived++
+ if b.arrived == 2 {
+ b.once.Do(func() { close(b.open) })
+ }
+ b.mu.Unlock()
+ select {
+ case <-b.open:
+ case <-time.After(10 * time.Second):
+ t.Errorf("only one ship reached the kind marker within 10 s")
+ b.once.Do(func() { close(b.open) })
+ }
+}
+
+// Astra R1 finding 9: two first ships of different kinds, both past their
+// reads (no history, no marker), race on the create-only marker. One wins;
+// the other writes nothing and is refused naming the winner.
+func TestFirstShipsOfTwoKindsRaceOneWinner(t *testing.T) {
+ ctx := context.Background()
+ marker := AppKindMarkerKey("bab", "shop")
+ for i := range 50 {
+ s := newShipCountingStore(t)
+ bar := &twoPartyBarrier{open: make(chan struct{})}
+ s.hold = func(key string) {
+ if key == marker {
+ bar.wait(t)
+ }
+ }
+ bin := binaryManifest("0.1.0-1")
+ rfs, paths := spooledRootfs(t, "0.1.0-2", false, "image")
+ var errs [2]error
+ var wg sync.WaitGroup
+ wg.Go(func() { errs[0] = Ship(ctx, s, "bab", bin, binaryFiles()) })
+ wg.Go(func() { errs[1] = ShipSpooled(ctx, s, "bab", rfs, paths) })
+ wg.Wait()
+ if t.Failed() {
+ t.FailNow()
+ }
+ if (errs[0] == nil) == (errs[1] == nil) {
+ t.Fatalf("round %d: errs = %v, want exactly one winner", i, errs)
+ }
+ winKind, winVer, loseVer, loseFile, loseErr := "binary", "0.1.0-1", "0.1.0-2", RootfsArtifactFile, errs[1]
+ if errs[0] != nil {
+ winKind, winVer, loseVer, loseFile, loseErr = KindRootfs, "0.1.0-2", "0.1.0-1", adoptPlatform, errs[0]
+ }
+ var ke *KindConflictError
+ if !errors.As(loseErr, &ke) || !ke.FirstShip || ke.Have != winKind || ke.HaveVersion != winVer {
+ t.Fatalf("round %d: loser err = %v, want a first-ship conflict naming %s %s", i, loseErr, winKind, winVer)
+ }
+ if mk := readMarker(t, s, "bab", "shop"); mk == nil || mk.Kind != winKind || mk.Version != winVer {
+ t.Fatalf("round %d: marker = %+v, want %s %s", i, mk, winKind, winVer)
+ }
+ vers, _, err := VersionCandidates(ctx, s, "bab", "shop", 20)
+ if err != nil || len(vers) != 1 || vers[0] != winVer {
+ t.Fatalf("round %d: versions = %v, %v; want only %s", i, vers, err, winVer)
+ }
+ for _, k := range []string{ArtifactKey("bab", "shop", loseVer, loseFile), ManifestKey("bab", "shop", loseVer)} {
+ if p, f := s.counts(k); p+f != 0 {
+ t.Fatalf("round %d: the loser wrote %s", i, k)
+ }
+ }
+ if p, _ := s.counts(marker); p != 2 {
+ t.Fatalf("round %d: %d marker Puts, want both ships' one each", i, p)
+ }
+ }
+}
+
+func TestEstablishedKindHonoursAnUnpublishedFirstShip(t *testing.T) {
+ ctx := context.Background()
+ s := newShipCountingStore(t)
+ if info, err := EstablishedKind(ctx, s, "bab", "shop"); err != nil || info != (AppKindInfo{}) {
+ t.Fatalf("no release, no marker: %+v, %v", info, err)
+ }
+ m := rootfsManifest()
+ m.Hibernate = true
+ if err := EstablishFirstKind(ctx, s, "bab", m); err != nil {
+ t.Fatalf("EstablishFirstKind: %v", err)
+ }
+ if n := len(s.puts) + len(s.putFroms); n != 1 {
+ t.Fatalf("EstablishFirstKind made %d writes, want only the marker", n)
+ }
+ if info, err := EstablishedKind(ctx, s, "bab", "shop"); err != nil || info != (AppKindInfo{Kind: KindRootfs, Version: "0.1.0-1", Hibernate: true}) {
+ t.Fatalf("EstablishedKind = %+v, %v", info, err)
+ }
+ if info, err := AppKind(ctx, s, "bab", "shop"); err != nil || info != (AppKindInfo{}) {
+ t.Fatalf("AppKind read the marker: %+v, %v", info, err)
+ }
+ putRawRelease(t, s, "bab", "blog", "0.1.0-1", []byte("bin"), "")
+ ak, err1 := AppKind(ctx, s, "bab", "blog")
+ ek, err2 := EstablishedKind(ctx, s, "bab", "blog")
+ if err1 != nil || err2 != nil || ek != ak || ak.Kind != "binary" {
+ t.Fatalf("published app: AppKind %+v %v, EstablishedKind %+v %v", ak, err1, ek, err2)
+ }
+ bad := NewDirStore(t.TempDir())
+ putMarker(t, bad, AppKindMarker{Schema: 1, Account: "bab", App: "blog", Kind: "binary", Version: "0.1.0-1"})
+ if _, err := bad.Put(ctx, AppKindMarkerKey("bab", "shop"), mustGet(t, bad, AppKindMarkerKey("bab", "blog")), ""); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := EstablishedKind(ctx, bad, "bab", "shop"); err == nil {
+ t.Fatal("a marker naming another app was believed")
+ }
+}
+
+func mustGet(t *testing.T, s Store, key string) []byte {
+ t.Helper()
+ b, _, err := s.Get(context.Background(), key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return b
+}
+
+func TestEstablishFirstKindThenShipSpooledWritesTheMarkerOnce(t *testing.T) {
+ ctx := context.Background()
+ s := newShipCountingStore(t)
+ m, paths := spooledRootfs(t, "0.1.0-1", false, "image")
+ if err := EstablishFirstKind(ctx, s, "bab", &m); err != nil {
+ t.Fatalf("EstablishFirstKind: %v", err)
+ }
+ if err := ShipSpooled(ctx, s, "bab", m, paths); err != nil {
+ t.Fatalf("ShipSpooled: %v", err)
+ }
+ if p, _ := s.counts(AppKindMarkerKey("bab", "shop")); p != 1 {
+ t.Fatalf("%d marker Puts, want one", p)
+ }
+ o := newShipCountingStore(t)
+ if err := EstablishFirstKind(ctx, o, "bab", rootfsManifest()); err != nil {
+ t.Fatal(err)
+ }
+ var ke *KindConflictError
+ err := Ship(ctx, o, "bab", binaryManifest("0.1.0-2"), binaryFiles())
+ if !errors.As(err, &ke) || !ke.FirstShip {
+ t.Fatalf("a binary Ship after a rootfs first kind: %v", err)
+ }
+ if err.Error() != ke.Error() {
+ t.Fatalf("Ship wrapped the refusal: %q", err) // preflight 1.17
+ }
+ if p, _ := o.counts(ArtifactKey("bab", "shop", "0.1.0-2", adoptPlatform)); p != 0 {
+ t.Fatal("the refused Ship wrote its artifact")
+ }
+}
+
+func TestJITIsNotPartOfTheKind(t *testing.T) {
+ ctx := context.Background()
+ for _, first := range []bool{false, true} {
+ s := NewDirStore(t.TempDir())
+ m1, p1 := spooledRootfs(t, "0.1.0-1", false, "image 1")
+ m1.JIT = first
+ if err := ShipSpooled(ctx, s, "bab", m1, p1); err != nil {
+ t.Fatalf("jit=%v first ship: %v", first, err)
+ }
+ raw := mustGet(t, s, AppKindMarkerKey("bab", "shop"))
+ if strings.Contains(string(raw), "jit") {
+ t.Fatalf("marker carries jit: %s", raw)
+ }
+ m2, p2 := spooledRootfs(t, "0.1.0-2", false, "image 2")
+ m2.JIT = !first
+ if err := CheckShipKind(ctx, s, "bab", &m2); err != nil {
+ t.Errorf("jit=%v after jit=%v: CheckShipKind %v", !first, first, err)
+ }
+ if err := establishShipKind(ctx, s, "bab", &m2); err != nil {
+ t.Errorf("jit=%v after jit=%v: establishShipKind %v", !first, first, err)
+ }
+ if err := ShipSpooled(ctx, s, "bab", m2, p2); err != nil {
+ t.Errorf("jit=%v after jit=%v: ShipSpooled %v", !first, first, err)
+ }
+ // The marker alone (no published release) ignores jit too.
+ u := NewDirStore(t.TempDir())
+ putMarker(t, u, AppKindMarker{Schema: 1, Account: "bab", App: "shop", Kind: KindRootfs, Version: "0.1.0-1", CreatedAt: time.Now()})
+ if err := CheckShipKind(ctx, u, "bab", &m2); err != nil {
+ t.Errorf("jit=%v against a marker: %v", !first, err)
+ }
+ }
+}
+
+func TestIsRootfsArtifactKey(t *testing.T) {
+ if !IsRootfsArtifactKey(ArtifactKey("bab", "shop", "0.1.0-1", RootfsArtifactFile)) {
+ t.Error("the rootfs artifact key is not one")
+ }
+ for _, k := range []string{
+ ArtifactKey("bab", "shop", "0.1.0-1", "linux-arm64"),
+ ManifestKey("bab", "shop", "0.1.0-1"),
+ ArtifactKey("", "shop", "0.1.0-1", RootfsArtifactFile),
+ ArtifactKey("bab", "Shop!", "0.1.0-1", RootfsArtifactFile),
+ ArtifactKey("bab", "shop", "../x", RootfsArtifactFile),
+ "apps/bab/shop/releases/0.1.0-1/x/" + RootfsArtifactFile,
+ "apps/bab/shop/channels/0.1.0-1/" + RootfsArtifactFile,
+ "ledgers/bab/shop/releases/0.1.0-1/" + RootfsArtifactFile,
+ AppKindMarkerKey("bab", "shop"),
+ } {
+ if IsRootfsArtifactKey(k) {
+ t.Errorf("%s counted as a rootfs artifact key", k)
+ }
+ }
+}