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>
10 files changed,
+1322
−12
approved_rootfs_copy_test.go+2 −0e2e_test.go+5 −0internal/hostops/mutation_boundary_putfrom.go+29 −11internal/hostops/mutation_boundary_putfrom_test.go+127 −0internal/provision/plan9_b8_census_test.go+1 −1internal/provision/rootfs_apps_1_census_test.go+51 −0internal/release/claim_app_writes.go+9 −0internal/release/release.go+6 −0internal/release/rootfs_ship.go+436 −0internal/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) |
| + } |
| + } |
| +} |