carlos / platform Public

console: the ship door takes rootfs, with kind and hibernation fixed (rootfs Plan 1 Task 8)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 7efd79168dcf4a9d4e9d88ba5722847974b88ee1 parent 9db3202
8 files changed, +1216 −51
  • approved_rootfs_copy_test.go +2 −0
  • internal/console/apicli.go +135 −41
  • internal/console/apiship_rootfs.go +358 −0
  • internal/console/apiship_rootfs_test.go +695 −0
  • internal/console/server.go +5 −0
  • internal/release/rootfs.go +5 −1
  • internal/release/rootfs_ship.go +12 −6
  • internal/release/rootfs_ship_test.go +4 −3
diff --git a/approved_rootfs_copy_test.go b/approved_rootfs_copy_test.go
index 8d2793212..c2ee4b454 100644
--- a/approved_rootfs_copy_test.go
+++ b/approved_rootfs_copy_test.go
@@ -158,6 +158,8 @@ var rootfsCopyBuilt = []string{
"RFS-1-C86", "RFS-1-C87",
// Task 7: processes meet the image (internal/rootfsimage/errors.go)
"RFS-1-C48", "RFS-1-C54",
+ // Task 8: the console's ship door takes rootfs (internal/console/apicli.go, apiship_rootfs.go)
+ "RFS-1-C22", "RFS-1-C23", "RFS-1-C24", "RFS-1-C25", "RFS-1-C26", "RFS-1-C27", "RFS-1-C28", "RFS-1-C29", "RFS-1-C79",
}
func mustParseRootfsRecord(t *testing.T) []copyRow {
diff --git a/internal/console/apicli.go b/internal/console/apicli.go
index 69e5f77f4..205e88cd2 100644
--- a/internal/console/apicli.go
+++ b/internal/console/apicli.go
@@ -1668,10 +1668,26 @@ const (
// apiShipMaxBytes, which bounds the whole request (meta plus every
// artifact). release.MaxNotesLen is the biggest single field meta carries;
// the extra 8KiB is headroom for everything else in the same object
-// (version, kind, label, and a capability block). Still tiny next to
+// (version, kind, label, and a capability block). A rootfs release's
+// processes object adds up to release.MaxProcessesBytes. Still tiny next to
// apiShipMaxBytes, and still read inside the same semaphore-bounded
// handler, so this is a decode-shape bound, not a new resource exposure.
-const apiShipMetaMaxBytes = release.MaxNotesLen + 8<<10
+const apiShipMetaMaxBytes = release.MaxNotesLen + release.MaxProcessesBytes + 8<<10
+
+// The ship door's answers that its rootfs half (apiship_rootfs.go) gives
+// too. shipKindUnchecked is a kind check that could not be made
+// (RFS-1-C79): a store error, or an app whose releases cannot be read.
+const (
+ shipFlagsUnreadable = "could not check this app's control-plane status — nothing was shipped"
+ shipStoreFailed = "could not store the release — try again"
+ shipKindUnchecked = "could not check this app's releases. nothing was shipped. try again"
+)
+
+// shipContentConflict is the 409 for a version already shipped with other
+// artifacts.
+func shipContentConflict(ver string) string {
+ return "version " + ver + " was already shipped with different content — versions are immutable; ship under a new version"
+}
// targetRe validates a binary artifact's part NAME before it is used to
// build a bucket key: release.ArtifactKey joins it straight into the object
@@ -1790,6 +1806,44 @@ func shipCapabilitiesMatch(a, b *caps.Set) bool {
return bytes.Equal(ja, jb)
}
+// shipMeta is a ship request's "meta" part.
+type shipMeta struct {
+ Version string `json:"version"`
+ Kind string `json:"kind"`
+ // Label and Notes are the human half of a manifest (release
+ // versioning spec): a table-cell summary and a changelog fragment,
+ // both optional and both metadata rather than content — a re-ship of
+ // the same bytes tolerates a different value for either, the same
+ // way it already tolerates a different BuiltAt.
+ Label string `json:"label,omitempty"`
+ Notes string `json:"notes,omitempty"`
+ Message string `json:"message,omitempty"`
+ // Capabilities is the app's declaration (internal/caps), ADDITIVE:
+ // absent from every request a CLI shipped before this field existed,
+ // and absent from every request that does not pass -capabilities. It
+ // is held raw so the refusal below carries caps's own message rather
+ // than "malformed meta part" — an unknown capability name is a
+ // specific, actionable thing to be told.
+ Capabilities json.RawMessage `json:"capabilities,omitempty"`
+ // ProgramChange is `carlos ship --program-change`: the operator's
+ // statement that this release's Go main package really differs from
+ // the app's previous release. Absent from every request a CLI shipped
+ // before the flag existed, which then meets the refusal below.
+ ProgramChange bool `json:"program_change,omitempty"`
+ // Swap is the release's own demand (zero-downtime spec §4.2): "hold"
+ // or absent. ADDITIVE: absent from every request without --swap.
+ Swap string `json:"swap,omitempty"`
+ // Processes, Hibernate, JIT and RootfsSize belong to a rootfs release
+ // (rootfs apps Plan 1) and to no other kind. Processes is held raw so
+ // a refusal is release.DecodeProcesses' own sentence; RootfsSize is the
+ // image's length, declared so the door can refuse an image too large
+ // before reading it, and bound the body to it.
+ Processes json.RawMessage `json:"processes,omitempty"`
+ Hibernate bool `json:"hibernate,omitempty"`
+ JIT bool `json:"jit,omitempty"`
+ RootfsSize int64 `json:"rootfs_size,omitempty"`
+}
+
// handleAPIShip answers `carlos ship`: a multipart upload of one release's
// artifacts. Hashes and sizes are computed HERE, from the bytes that
// actually arrived on the wire, and handed to release.Ship as
@@ -1809,6 +1863,11 @@ func shipCapabilitiesMatch(a, b *caps.Set) bool {
// no smaller option — the same buffering the CLI's own direct-ship path
// already does with os.ReadFile — but the MaxBytesReader wrapped around the
// whole request body below is what actually caps the total.
+//
+// A rootfs release is the exception (rootfs apps Plan 1): its image, up to
+// 2 GiB, is spooled to the seed scratch disk instead of memory, and its
+// meta part declares the image's size so the body's limit can drop to fit
+// it (apiship_rootfs.go).
func (s *Server) handleAPIShip(w http.ResponseWriter, r *http.Request) {
addr, acct, app, ok := s.apiAccountApp(w, r)
if !ok {
@@ -1878,7 +1937,17 @@ func (s *Server) handleAPIShip(w http.ResponseWriter, r *http.Request) {
}
}()
- r.Body = http.MaxBytesReader(w, r.Body, apiShipMaxBytes)
+ // The body's ceiling is the largest request any kind can make: a rootfs
+ // image with its meta and framing. Once the meta part says the kind,
+ // the limit drops to what that kind may carry (below), so a binary or
+ // static ship is still bounded by apiShipMaxBytes.
+ ceiling := apiShipMaxBytesRootfs + apiShipMetaMaxBytes + apiShipFramingBytes
+ raw := r.Body
+ if s.shipBodyWrap != nil {
+ raw = s.shipBodyWrap(raw)
+ }
+ upload := newShipBody(http.MaxBytesReader(w, raw, ceiling), ceiling)
+ r.Body = upload
mr, err := r.MultipartReader()
if err != nil {
jsonError(w, http.StatusBadRequest, "expected a multipart/form-data body")
@@ -1897,33 +1966,7 @@ func (s *Server) handleAPIShip(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusBadRequest, `the first part must be named "meta"`)
return
}
- var meta struct {
- Version string `json:"version"`
- Kind string `json:"kind"`
- // Label and Notes are the human half of a manifest (release
- // versioning spec): a table-cell summary and a changelog fragment,
- // both optional and both metadata rather than content — a re-ship of
- // the same bytes tolerates a different value for either, the same
- // way it already tolerates a different BuiltAt.
- Label string `json:"label,omitempty"`
- Notes string `json:"notes,omitempty"`
- Message string `json:"message,omitempty"`
- // Capabilities is the app's declaration (internal/caps), ADDITIVE:
- // absent from every request a CLI shipped before this field existed,
- // and absent from every request that does not pass -capabilities. It
- // is held raw so the refusal below carries caps's own message rather
- // than "malformed meta part" — an unknown capability name is a
- // specific, actionable thing to be told.
- Capabilities json.RawMessage `json:"capabilities,omitempty"`
- // ProgramChange is `carlos ship --program-change`: the operator's
- // statement that this release's Go main package really differs from
- // the app's previous release. Absent from every request a CLI shipped
- // before the flag existed, which then meets the refusal below.
- ProgramChange bool `json:"program_change,omitempty"`
- // Swap is the release's own demand (zero-downtime spec §4.2): "hold"
- // or absent. ADDITIVE: absent from every request without --swap.
- Swap string `json:"swap,omitempty"`
- }
+ var meta shipMeta
// The budget is release.MaxNotesLen — the biggest single field the meta
// part now carries — plus 8KiB of headroom for everything else in it:
// version, kind, label, and a capability block (a few hundred bytes, the
@@ -1940,12 +1983,22 @@ func (s *Server) handleAPIShip(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
- if meta.Kind != "binary" && meta.Kind != "static" {
- jsonError(w, http.StatusBadRequest, `invalid kind (want "binary" or "static")`)
+ switch meta.Kind {
+ case "binary", "static", release.KindRootfs:
+ default:
+ jsonError(w, http.StatusBadRequest, `invalid kind (want "binary", "static" or "rootfs")`)
return
}
- if meta.Kind != "static" && s.refuseTemporary(w, acct, app, "ship a binary release to it") {
- return
+ rootfs := meta.Kind == release.KindRootfs
+ switch {
+ case rootfs:
+ if s.refuseTemporary(w, acct, app, "ship a rootfs release to it") {
+ return
+ }
+ case meta.Kind != "static":
+ if s.refuseTemporary(w, acct, app, "ship a binary release to it") {
+ return
+ }
}
// Checked before the version is minted or validated: NextIteration below
// lists the app's whole releases prefix, and a label/notes refusal should
@@ -2012,6 +2065,45 @@ func (s *Server) handleAPIShip(w http.ResponseWriter, r *http.Request) {
if refuseBadSwap(w, meta.Swap) {
return
}
+ // What the rest of the body may hold, now that the meta says the kind.
+ var procs map[string]release.Process
+ if rootfs {
+ var ref *doorRefusal
+ if procs, ref = rootfsMetaRefusal(meta); ref != nil {
+ refuseUnread(w, ref.Status, ref.Msg)
+ return
+ }
+ upload.lower(apiShipMetaMaxBytes + meta.RootfsSize + apiShipFramingBytes)
+ } else {
+ if len(meta.Processes) > 0 || meta.Hibernate || meta.JIT || meta.RootfsSize != 0 {
+ jsonError(w, http.StatusBadRequest, release.ErrRootfsFieldsOnly.Error())
+ return
+ }
+ upload.lower(apiShipMaxBytes)
+ }
+ // An app's kind and hibernation are fixed by its first ship, so a
+ // release of another kind, or a rootfs release with the other
+ // hibernation, is refused here, before any part is read. The check is
+ // advisory: release.Ship and release.ShipSpooled repeat it at
+ // publication, which is what decides a race between two first ships.
+ kctx, kcancel := context.WithTimeout(r.Context(), 20*time.Second)
+ stub := release.Manifest{App: app, Version: ver, Kind: meta.Kind, Hibernate: meta.Hibernate}
+ kerr := release.CheckShipKind(kctx, s.cfg.Releases, acct.Sqid, &stub)
+ kcancel()
+ if kerr != nil {
+ if msg, ok := shipKindConflict(kerr); ok {
+ refuseUnread(w, http.StatusConflict, msg)
+ return
+ }
+ log.Printf("console: api ship %s/%s %s: kind check: %v", acct.Sqid, app, ver, kerr)
+ refuseUnread(w, http.StatusServiceUnavailable, shipKindUnchecked)
+ return
+ }
+ if rootfs {
+ s.shipRootfsParts(w, r, mr, shipRootfsReq{addr: addr, acct: acct, app: app, ver: ver,
+ message: message, meta: meta, procs: procs})
+ return
+ }
maxParts := apiShipMaxPartsBinary
if meta.Kind == "static" {
maxParts = apiShipMaxPartsStatic
@@ -2128,8 +2220,7 @@ func (s *Server) handleAPIShip(w http.ResponseWriter, r *http.Request) {
cp, probe, err := s.store.AppFlags(acct.ID, app)
if err != nil {
log.Printf("console: api ship %s/%s: read app flags: %v", acct.Sqid, app, err)
- jsonError(w, http.StatusServiceUnavailable,
- "could not check this app's control-plane status — nothing was shipped")
+ jsonError(w, http.StatusServiceUnavailable, shipFlagsUnreadable)
return
}
// Read the bytes that actually arrived, before a single one is written.
@@ -2201,8 +2292,7 @@ func (s *Server) handleAPIShip(w http.ResponseWriter, r *http.Request) {
// is written on this path, so a later ship can never rewrite them.
if existing, gerr := release.GetManifest(ctx, s.cfg.Releases, acct.Sqid, app, ver); gerr == nil {
if !shipArtifactsMatch(existing.Artifacts, artifacts) {
- jsonError(w, http.StatusConflict,
- "version "+ver+" was already shipped with different content — versions are immutable; ship under a new version")
+ jsonError(w, http.StatusConflict, shipContentConflict(ver))
return
}
// Artifacts alone are not the whole of what a version means any more.
@@ -2263,6 +2353,11 @@ func (s *Server) handleAPIShip(w http.ResponseWriter, r *http.Request) {
}
if err := release.Ship(ctx, s.cfg.Releases, acct.Sqid, m, files); err != nil {
log.Printf("console: api ship %s %s: %v", app, ver, err)
+ // Publication repeats the kind check, so a lost race between two
+ // first ships is refused here, as it would have been at the door.
+ if shipKindRefusedAtPublication(w, err) {
+ return
+ }
if shipImmutableConflict(err) {
// A permanent conflict, not a transient one: reachable when this
// raced against another ship of the same version (the
@@ -2270,14 +2365,13 @@ func (s *Server) handleAPIShip(w http.ResponseWriter, r *http.Request) {
// below), not through the common path above. Retrying this
// exact request will never succeed, so it gets its own status
// rather than the retry-flavored 502 below.
- jsonError(w, http.StatusConflict,
- "version "+ver+" was already shipped with different content — versions are immutable; ship under a new version")
+ jsonError(w, http.StatusConflict, shipContentConflict(ver))
return
}
// Anything else is the store failing, not the caller: a 502,
// matching the other doors' convention that a bucket-side error is
// never described to the caller in bucket-side terms.
- jsonError(w, http.StatusBadGateway, "could not store the release — try again")
+ jsonError(w, http.StatusBadGateway, shipStoreFailed)
return
}
s.auditActivity(acct, app, addr, Activity{
diff --git a/internal/console/apiship_rootfs.go b/internal/console/apiship_rootfs.go
new file mode 100644
index 000000000..05e43f82a
--- /dev/null
+++ b/internal/console/apiship_rootfs.go
@@ -0,0 +1,358 @@
+package console
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "io"
+ "log"
+ "mime/multipart"
+ "net/http"
+ "os"
+ "path/filepath"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/version"
+)
+
+// The ship door's rootfs releases (rootfs apps Plan 1 Task 8). A rootfs
+// image is up to 2 GiB, so unlike a binary it is never held in memory: the
+// door spools it to the seed scratch disk, hashing as it writes, and
+// release.ShipSpooled streams it from there into the bucket.
+
+// apiShipMaxBytesRootfs is the largest image a rootfs release carries. A
+// var so a test can lower it.
+var apiShipMaxBytesRootfs = release.MaxRootfsBytes
+
+const (
+ // apiShipRootfsReadDeadline is how long the image may take to arrive:
+ // 2 GiB over a slow uplink, past the 10 minutes a binary gets.
+ apiShipRootfsReadDeadline = 60 * time.Minute
+ // apiShipRootfsStoreTimeout bounds the put of the spooled image and
+ // its manifest.
+ apiShipRootfsStoreTimeout = 15 * time.Minute
+ // apiShipFramingBytes is the multipart framing a ship body carries
+ // beside its parts' contents: boundaries and part headers.
+ apiShipFramingBytes = 64 << 10
+)
+
+// shipBody is the ship door's request body. It counts the bytes read and
+// refuses a read past its limit with the *http.MaxBytesError that
+// http.MaxBytesReader gives, so shipMaxBytesRefused answers both. The limit
+// starts at the ceiling for any kind and is lowered once the meta part says
+// what the rest of the body may hold.
+type shipBody struct {
+ r io.ReadCloser
+ n int64
+ limit int64
+ err error
+}
+
+func newShipBody(r io.ReadCloser, limit int64) *shipBody {
+ return &shipBody{r: r, limit: limit}
+}
+
+func (b *shipBody) Read(p []byte) (int, error) {
+ if b.err != nil {
+ return 0, b.err
+ }
+ if len(p) == 0 {
+ return 0, nil
+ }
+ left := b.limit - b.n
+ if left < 0 {
+ b.err = &http.MaxBytesError{Limit: b.limit}
+ return 0, b.err
+ }
+ // One byte past the limit is asked for, to tell a body that ends at
+ // the limit from one that goes on.
+ if int64(len(p)) > left+1 {
+ p = p[:left+1]
+ }
+ n, err := b.r.Read(p)
+ if int64(n) <= left {
+ b.n += int64(n)
+ b.err = err
+ return n, err
+ }
+ b.n += left
+ b.err = &http.MaxBytesError{Limit: b.limit}
+ return int(left), b.err
+}
+
+func (b *shipBody) Close() error { return b.r.Close() }
+
+// lower sets the limit to limit, counted from the body's first byte, unless
+// it is already lower.
+func (b *shipBody) lower(limit int64) { b.limit = min(b.limit, limit) }
+
+// refuseUnread answers a ship refused before its file parts are read, and
+// closes the connection after the answer. The rest of the body may be an
+// image of up to 2 GiB: left open, the server would read up to 256 KiB of
+// it before answering, from a client that may be waiting for the answer
+// before it sends more.
+func refuseUnread(w http.ResponseWriter, status int, msg string) {
+ w.Header().Set("Connection", "close")
+ jsonError(w, status, msg)
+}
+
+// rootfsMetaRefusal checks a rootfs meta part before any part is read: no
+// capabilities, a processes object that decodes and validates, and a
+// declared image size within the limit. It returns the processes.
+func rootfsMetaRefusal(meta shipMeta) (map[string]release.Process, *doorRefusal) {
+ if len(meta.Capabilities) > 0 {
+ return nil, &doorRefusal{http.StatusBadRequest, release.ErrRootfsCapabilities.Error()}
+ }
+ procs, err := release.DecodeProcesses(meta.Processes)
+ if err == nil {
+ err = release.ValidateProcesses(procs)
+ }
+ if err != nil {
+ return nil, &doorRefusal{http.StatusBadRequest, err.Error()}
+ }
+ if meta.RootfsSize <= 0 {
+ return nil, &doorRefusal{http.StatusBadRequest, "the request does not say how large the rootfs image is, so nothing was read. update carlos and ship again"}
+ }
+ if meta.RootfsSize > apiShipMaxBytesRootfs {
+ return nil, &doorRefusal{http.StatusRequestEntityTooLarge, "the rootfs image is over 2 GiB, the most one release can carry. make the image smaller and ship again"}
+ }
+ return procs, nil
+}
+
+// shipKindConflict is the sentence of a kind or hibernation refusal in err:
+// the conflict's own Error(), never the wrapping around it.
+func shipKindConflict(err error) (string, bool) {
+ var ke *release.KindConflictError
+ if errors.As(err, &ke) {
+ return ke.Error(), true
+ }
+ var he *release.HibernateConflictError
+ if errors.As(err, &he) {
+ return he.Error(), true
+ }
+ return "", false
+}
+
+// shipKindRefusedAtPublication answers a publication that release.Ship or
+// release.ShipSpooled refused on the app's kind: a conflict is the 409 the
+// door would have given (a lost first-ship race, which no retry can win),
+// and a kind check that could not be made is RFS-1-C79. It reports whether
+// it answered.
+func shipKindRefusedAtPublication(w http.ResponseWriter, err error) bool {
+ if msg, ok := shipKindConflict(err); ok {
+ jsonError(w, http.StatusConflict, msg)
+ return true
+ }
+ if errors.Is(err, release.ErrAppKindCheck) {
+ jsonError(w, http.StatusServiceUnavailable, shipKindUnchecked)
+ return true
+ }
+ return false
+}
+
+// shipRootfsReq is what handleAPIShip has settled when it hands a rootfs
+// release to shipRootfsParts: the caller, the app, the version, the meta
+// and its decoded processes.
+type shipRootfsReq struct {
+ addr string
+ acct *Account
+ app, ver, message string
+ meta shipMeta
+ procs map[string]release.Process
+}
+
+// shipRootfsParts reads a rootfs release's one part, the image, into the
+// seed scratch and publishes it. handleAPIShip has checked the meta, the
+// app's kind and the body's limit.
+func (s *Server) shipRootfsParts(w http.ResponseWriter, r *http.Request, mr *multipart.Reader, req shipRootfsReq) {
+ acct, app, ver, meta := req.acct, req.app, req.ver, req.meta
+ // The app's row, read once: the control-plane guard and the build
+ // probe's echo, failing closed as the binary path does.
+ cp, probe, err := s.store.AppFlags(acct.ID, app)
+ if err != nil {
+ log.Printf("console: api ship %s/%s: read app flags: %v", acct.Sqid, app, err)
+ refuseUnread(w, http.StatusServiceUnavailable, shipFlagsUnreadable)
+ return
+ }
+ if cp {
+ refuseUnread(w, http.StatusBadRequest, fmt.Sprintf("%s is a control-plane app, so its releases are binaries only", app))
+ return
+ }
+ free, ref := s.seedScratch.reserve(acct.Sqid+"/"+app, meta.RootfsSize)
+ if ref != nil {
+ refuseUnread(w, ref.Status, ref.Msg)
+ return
+ }
+ defer free()
+ dir, err := os.MkdirTemp(s.seedScratch.dir(), seedScratchPrefix)
+ if err != nil {
+ log.Printf("console: api ship %s/%s: scratch: %v", acct.Sqid, app, err)
+ ref := seedScratchFull()
+ refuseUnread(w, ref.Status, ref.Msg)
+ return
+ }
+ defer os.RemoveAll(dir) // before free: the spool goes, then its room
+ if err := http.NewResponseController(w).SetReadDeadline(time.Now().Add(apiShipRootfsReadDeadline)); err != nil && !errors.Is(err, http.ErrNotSupported) {
+ log.Printf("console: api ship %s/%s: extend the read deadline: %v", acct.Sqid, app, err)
+ }
+
+ part, err := mr.NextPart()
+ if err != nil {
+ if shipMaxBytesRefused(w, err) {
+ return
+ }
+ if err == io.EOF {
+ jsonError(w, http.StatusBadRequest, "no artifact parts in the upload")
+ return
+ }
+ jsonError(w, http.StatusBadRequest, "malformed multipart body")
+ return
+ }
+ if part.FormName() != release.RootfsArtifactKey {
+ refuseUnread(w, http.StatusBadRequest, `a rootfs release ships exactly one file part, named "rootfs-linux-arm64"`)
+ return
+ }
+ spool := filepath.Join(dir, release.RootfsArtifactFile)
+ sum, ok := spoolRootfsPart(w, part, spool, meta.RootfsSize)
+ if !ok {
+ return
+ }
+ if _, err := mr.NextPart(); err != io.EOF {
+ if err == nil {
+ jsonError(w, http.StatusBadRequest, fmt.Sprintf("too many file parts (max %d for a %s release)", 1, release.KindRootfs))
+ return
+ }
+ if shipMaxBytesRefused(w, err) {
+ return
+ }
+ jsonError(w, http.StatusBadRequest, "malformed multipart body")
+ return
+ }
+
+ m := release.Manifest{
+ Schema: 1,
+ App: app,
+ Version: ver,
+ Kind: release.KindRootfs,
+ BuiltBy: "console-api " + version.Build,
+ BuiltAt: time.Now().UTC().Format(time.RFC3339),
+ Artifacts: map[string]release.Artifact{release.RootfsArtifactKey: {
+ File: release.RootfsArtifactFile, SHA256: sum, Size: meta.RootfsSize,
+ }},
+ Processes: req.procs,
+ Hibernate: meta.Hibernate,
+ JIT: meta.JIT,
+ Label: meta.Label,
+ Notes: meta.Notes,
+ Swap: meta.Swap,
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), apiShipRootfsStoreTimeout)
+ defer cancel()
+
+ // A version already on record: the same release again is a 200 that
+ // writes nothing; anything that differs is a 409, since versions are
+ // immutable. Label and notes are metadata and may differ, as on the
+ // binary path.
+ if existing, gerr := release.GetManifest(ctx, s.cfg.Releases, acct.Sqid, app, ver); gerr == nil {
+ if !shipArtifactsMatch(existing.Artifacts, m.Artifacts) {
+ jsonError(w, http.StatusConflict, shipContentConflict(ver))
+ return
+ }
+ if !release.ProcessesEqual(existing.Processes, m.Processes) || existing.Hibernate != m.Hibernate || existing.JIT != m.JIT {
+ jsonError(w, http.StatusConflict, fmt.Sprintf("version %s was already shipped with different processes, hibernation or jit. versions are immutable, so ship under a new version", ver))
+ return
+ }
+ if refuseSwapConflict(w, existing.Swap, meta.Swap, ver) {
+ return
+ }
+ writeJSON(w, http.StatusOK, rootfsShipAnswer(ver, existing.Artifacts, existing.Swap, probe))
+ return
+ }
+ // The first-ship slot. Plan 4 Task 5 puts its first-rootfs-ship
+ // refusal and the first-kind boundary here: after the re-ship short
+ // circuit above, so a re-ship stays a 200, and before ShipSpooled,
+ // the only place a rootfs publication can still be refused. Keep the
+ // two adjacent.
+ if err := release.ShipSpooled(ctx, s.cfg.Releases, acct.Sqid, m, map[string]string{release.RootfsArtifactKey: spool}); err != nil {
+ log.Printf("console: api ship %s %s (rootfs): %v", app, ver, err)
+ if shipKindRefusedAtPublication(w, err) {
+ return
+ }
+ if shipImmutableConflict(err) {
+ jsonError(w, http.StatusConflict, shipContentConflict(ver))
+ return
+ }
+ jsonError(w, http.StatusBadGateway, shipStoreFailed)
+ return
+ }
+ s.auditActivity(acct, app, req.addr, Activity{
+ Action: "ship", TargetType: "release", Target: ver, Message: req.message,
+ Detail: "shipped " + ver + " (cli, rootfs)",
+ })
+ writeJSON(w, http.StatusCreated, rootfsShipAnswer(ver, m.Artifacts, m.Swap, probe))
+}
+
+// spoolRootfsPart copies the image part to spool, hashing as it writes and
+// never reading more than size and one byte, and returns its sha256. A
+// part of another length is refused. It has answered w when it reports
+// false.
+func spoolRootfsPart(w http.ResponseWriter, part io.Reader, spool string, size int64) (string, bool) {
+ f, err := os.OpenFile(spool, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
+ if err != nil {
+ log.Printf("console: api ship: spool: %v", err)
+ ref := seedScratchFull()
+ jsonError(w, ref.Status, ref.Msg)
+ return "", false
+ }
+ h := sha256.New()
+ n, cerr := io.Copy(io.MultiWriter(spoolFile{f}, h), io.LimitReader(part, size+1))
+ ferr := f.Close()
+ var werr spoolWriteError
+ switch {
+ case shipMaxBytesRefused(w, cerr):
+ return "", false
+ case errors.As(cerr, &werr) || cerr == nil && ferr != nil:
+ log.Printf("console: api ship: spool: %v", errors.Join(cerr, ferr))
+ ref := seedScratchFull()
+ jsonError(w, ref.Status, ref.Msg)
+ return "", false
+ case cerr != nil:
+ // The caller went away or the body broke off.
+ log.Printf("console: api ship: read the rootfs part: %v", cerr)
+ jsonError(w, http.StatusBadRequest, "could not read part "+release.RootfsArtifactKey)
+ return "", false
+ case n != size:
+ jsonError(w, http.StatusBadRequest, "the rootfs part does not match the size the request declared, so nothing was shipped")
+ return "", false
+ }
+ return hex.EncodeToString(h.Sum(nil)), true
+}
+
+// spoolFile marks the spool's write errors, so a full scratch disk is told
+// apart from a body that broke off.
+type spoolFile struct{ f *os.File }
+
+type spoolWriteError struct{ error }
+
+func (s spoolFile) Write(p []byte) (int, error) {
+ n, err := s.f.Write(p)
+ if err != nil {
+ err = spoolWriteError{err}
+ }
+ return n, err
+}
+
+// rootfsShipAnswer is a rootfs ship's answer, in the binary path's shape.
+func rootfsShipAnswer(ver string, artifacts map[string]release.Artifact, swap, probe string) map[string]any {
+ out := make(map[string]any, len(artifacts))
+ for name, a := range artifacts {
+ out[name] = map[string]any{"sha256": a.SHA256, "size": a.Size}
+ }
+ body := map[string]any{"result": "shipped", "version": ver, "artifacts": out}
+ addSwapEcho(body, swap)
+ addBuildProbe(body, probe)
+ return body
+}
diff --git a/internal/console/apiship_rootfs_test.go b/internal/console/apiship_rootfs_test.go
new file mode 100644
index 000000000..d4640edee
--- /dev/null
+++ b/internal/console/apiship_rootfs_test.go
@@ -0,0 +1,695 @@
+package console
+
+import (
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// The ship door's rootfs releases (rootfs apps Plan 1 Task 8), app shop in
+// account bab.
+
+type rootfsShipFx struct {
+ fx *consoleFixture
+ token string
+}
+
+// rootfsShipFixture is a console with the app shop, a token, a scratch pool
+// with room to spare and the ship budget lifted.
+func rootfsShipFixture(t *testing.T) rootfsShipFx {
+ t.Helper()
+ fx := newConsoleFixture(t)
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "shop", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ token := cliToken(t, fx, "paul@keymail.dev", "code-"+strings.ReplaceAll(t.Name(), "/", "-"))
+ liftShipBudget(fx)
+ scratch := filepath.Join(t.TempDir(), "scratch", "seeds")
+ if err := clearSeedScratch(scratch); err != nil {
+ t.Fatal(err)
+ }
+ fx.srv.seedScratch = newSeedScratch(scratch, func(string) (int64, error) { return 64 << 30, nil })
+ return rootfsShipFx{fx: fx, token: token}
+}
+
+func (f rootfsShipFx) url(app string) string {
+ return f.fx.ts.URL + "/api/cli/apps/bab/" + app + "/releases"
+}
+
+func (f rootfsShipFx) addApp(t *testing.T, app string) {
+ t.Helper()
+ if _, err := f.fx.st.CreateAppIn(OpsAccountID, app, "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// assertScratchEmpty: no spool outlives its request.
+func (f rootfsShipFx) assertScratchEmpty(t *testing.T) {
+ t.Helper()
+ if ents, _ := os.ReadDir(f.fx.srv.seedScratch.dir()); len(ents) != 0 {
+ t.Fatalf("scratch left behind: %v", ents)
+ }
+}
+
+// assertNothingWritten: the app's prefix in the bucket is empty.
+func (f rootfsShipFx) assertNothingWritten(t *testing.T, app string) {
+ t.Helper()
+ keys, err := f.fx.rel.List(context.Background(), "apps/bab/"+app+"/")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(keys) != 0 {
+ t.Fatalf("written under %s: %v", app, keys)
+ }
+}
+
+// metaOf is a meta part with exactly fields.
+func metaOf(t *testing.T, fields map[string]any) shipPart {
+ t.Helper()
+ raw, err := json.Marshal(fields)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return shipPart{name: "meta", body: raw}
+}
+
+// rootfsFields is the meta `carlos ship --rootfs` sends, as a map a test can
+// change before it is encoded.
+func rootfsFields(version string, size int, hibernate bool) map[string]any {
+ return map[string]any{"version": version, "kind": "rootfs", "rootfs_size": size,
+ "hibernate": hibernate, "processes": map[string]any{"web": map[string]any{"command": []string{"bin/web"}}}}
+}
+
+// rootfsMeta is the meta part `carlos ship --rootfs` sends.
+func rootfsMeta(t *testing.T, version string, size int, hibernate bool) shipPart {
+ t.Helper()
+ return metaOf(t, rootfsFields(version, size, hibernate))
+}
+
+func wantRefusal(t *testing.T, what string, status int, out map[string]any, wantStatus int, wantMsg string) {
+ t.Helper()
+ if status != wantStatus || str(t, out, "error") != wantMsg {
+ t.Fatalf("%s = %d %q, want %d %q", what, status, str(t, out, "error"), wantStatus, wantMsg)
+ }
+}
+
+func TestAPIShipRootfsHappy(t *testing.T) {
+ fx := rootfsShipFixture(t)
+ img := randomBytes(t, 3<<20)
+ status, out := postShip(t, fx.url("shop"), fx.token, []shipPart{rootfsMeta(t, "0.1.0-1", len(img), false), {name: "rootfs-linux-arm64", body: img}})
+ if status != http.StatusCreated {
+ t.Fatalf("ship = %d %v", status, out)
+ }
+ sum := sha256.Sum256(img)
+ if str(t, out, "result") != "shipped" || str(t, out, "version") != "0.1.0-1" {
+ t.Fatalf("body = %v", out)
+ }
+ arts, _ := out["artifacts"].(map[string]any)
+ if a, _ := arts[release.RootfsArtifactKey].(map[string]any); len(arts) != 1 || a["sha256"] != hex.EncodeToString(sum[:]) || a["size"] != float64(len(img)) {
+ t.Fatalf("artifacts = %v", out["artifacts"])
+ }
+ m, err := release.GetManifest(context.Background(), fx.fx.rel, "bab", "shop", "0.1.0-1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ a := m.Artifacts[release.RootfsArtifactKey]
+ if m.Kind != release.KindRootfs || len(m.Artifacts) != 1 || a.File != release.RootfsArtifactFile || a.Size != int64(len(img)) || a.SHA256 != hex.EncodeToString(sum[:]) {
+ t.Fatalf("manifest = %+v", m)
+ }
+ if m.Processes["web"].Command[0] != "bin/web" || m.Hibernate || m.JIT {
+ t.Fatalf("processes/hibernate/jit = %+v %v %v", m.Processes, m.Hibernate, m.JIT)
+ }
+ if !strings.HasPrefix(m.BuiltBy, "console-api ") {
+ t.Fatalf("built by = %q", m.BuiltBy)
+ }
+ rc, _, err := release.OpenObject(context.Background(), fx.fx.rel, release.ArtifactKey("bab", "shop", "0.1.0-1", release.RootfsArtifactFile))
+ if err != nil {
+ t.Fatal(err)
+ }
+ stored, _ := io.ReadAll(rc)
+ rc.Close()
+ if !bytes.Equal(stored, img) {
+ t.Fatal("the stored image is not the bytes shipped")
+ }
+ evs, err := fx.fx.st.Events(OpsAccountID, "shop", 10)
+ if err != nil || len(evs) == 0 || evs[0].Action != "ship" || evs[0].Detail != "shipped 0.1.0-1 (cli, rootfs)" {
+ t.Fatalf("activity = %+v, %v", evs, err)
+ }
+ fx.assertScratchEmpty(t)
+}
+
+// Review Focus 4: the same bytes and meta again are a 200 that writes nothing.
+func TestAPIShipRootfsReshipIsANoOp(t *testing.T) {
+ fx := rootfsShipFixture(t)
+ img := randomBytes(t, 64<<10)
+ parts := []shipPart{rootfsMeta(t, "0.1.0-1", len(img), false), {name: "rootfs-linux-arm64", body: img}}
+ if status, out := postShip(t, fx.url("shop"), fx.token, parts); status != http.StatusCreated {
+ t.Fatalf("first ship = %d %v", status, out)
+ }
+ before, err := fx.fx.rel.List(context.Background(), "apps/bab/shop/")
+ if err != nil {
+ t.Fatal(err)
+ }
+ status, out := postShip(t, fx.url("shop"), fx.token, parts)
+ if status != http.StatusOK || str(t, out, "result") != "shipped" || str(t, out, "version") != "0.1.0-1" {
+ t.Fatalf("re-ship = %d %v, want 200", status, out)
+ }
+ after, err := fx.fx.rel.List(context.Background(), "apps/bab/shop/")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.Join(before, ",") != strings.Join(after, ",") {
+ t.Fatalf("the re-ship wrote: %v then %v", before, after)
+ }
+ vers, _, err := release.VersionCandidates(context.Background(), fx.fx.rel, "bab", "shop", 20)
+ if err != nil || len(vers) != 1 {
+ t.Fatalf("versions = %v, %v; want one manifest", vers, err)
+ }
+ fx.assertScratchEmpty(t)
+}
+
+// A re-ship of a version with other processes or jit is C29. Hibernation is
+// fixed per app, so the kind check refuses that re-ship first, before the
+// image is read, with the first ship's sentence (RFS-1-C82).
+func TestAPIShipRootfsReshipWithOtherProcessesIsAConflict(t *testing.T) {
+ fx := rootfsShipFixture(t)
+ img := randomBytes(t, 64<<10)
+ if status, out := postShip(t, fx.url("shop"), fx.token, []shipPart{rootfsMeta(t, "0.1.0-1", len(img), false), {name: "rootfs-linux-arm64", body: img}}); status != http.StatusCreated {
+ t.Fatalf("first ship = %d %v", status, out)
+ }
+ c29 := "version 0.1.0-1 was already shipped with different processes, hibernation or jit. versions are immutable, so ship under a new version"
+
+ other := rootfsFields("0.1.0-1", len(img), false)
+ other["processes"] = map[string]any{"web": map[string]any{"command": []string{"bin/other"}}}
+ status, out := postShip(t, fx.url("shop"), fx.token, []shipPart{metaOf(t, other), {name: "rootfs-linux-arm64", body: img}})
+ wantRefusal(t, "other processes", status, out, http.StatusConflict, c29)
+
+ jit := rootfsFields("0.1.0-1", len(img), false)
+ jit["jit"] = true
+ status, out = postShip(t, fx.url("shop"), fx.token, []shipPart{metaOf(t, jit), {name: "rootfs-linux-arm64", body: img}})
+ wantRefusal(t, "jit", status, out, http.StatusConflict, c29)
+
+ status, out = postShip(t, fx.url("shop"), fx.token, []shipPart{rootfsMeta(t, "0.1.0-1", len(img), true), {name: "rootfs-linux-arm64", body: img}})
+ hib := (&release.HibernateConflictError{App: "shop", HaveVersion: "0.1.0-1", Hibernate: false, FirstShip: true}).Error()
+ wantRefusal(t, "hibernate", status, out, http.StatusConflict, hib)
+
+ m, err := release.GetManifest(context.Background(), fx.fx.rel, "bab", "shop", "0.1.0-1")
+ if err != nil || m.Processes["web"].Command[0] != "bin/web" || m.JIT || m.Hibernate {
+ t.Fatalf("stored manifest = %+v, %v; want the first ship's", m, err)
+ }
+ fx.assertScratchEmpty(t)
+}
+
+// R11: jit is per release.
+func TestAPIShipRootfsCarriesJIT(t *testing.T) {
+ fx := rootfsShipFixture(t)
+ img := randomBytes(t, 32<<10)
+ jit := rootfsFields("0.1.0-1", len(img), false)
+ jit["jit"] = true
+ if status, out := postShip(t, fx.url("shop"), fx.token, []shipPart{metaOf(t, jit), {name: "rootfs-linux-arm64", body: img}}); status != http.StatusCreated {
+ t.Fatalf("ship with jit = %d %v", status, out)
+ }
+ if status, out := postShip(t, fx.url("shop"), fx.token, []shipPart{rootfsMeta(t, "0.1.0-2", len(img), false), {name: "rootfs-linux-arm64", body: img}}); status != http.StatusCreated {
+ t.Fatalf("ship without jit = %d %v", status, out)
+ }
+ for ver, want := range map[string]bool{"0.1.0-1": true, "0.1.0-2": false} {
+ m, err := release.GetManifest(context.Background(), fx.fx.rel, "bab", "shop", ver)
+ if err != nil || m.JIT != want {
+ t.Fatalf("%s: jit = %v, %v; want %v", ver, m.JIT, err, want)
+ }
+ }
+}
+
+// markerBarrierStore holds each Put of the app's kind marker until two have
+// arrived, or 10 s have passed (preflight 3.6), so two first ships race on
+// the create-only put.
+type markerBarrierStore struct {
+ release.Store
+ t *testing.T
+ key string
+ mu sync.Mutex
+ arrived int
+ open chan struct{}
+ once sync.Once
+}
+
+func (s *markerBarrierStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
+ if key == s.key {
+ s.mu.Lock()
+ s.arrived++
+ if s.arrived == 2 {
+ s.once.Do(func() { close(s.open) })
+ }
+ s.mu.Unlock()
+ select {
+ case <-s.open:
+ case <-time.After(10 * time.Second):
+ s.t.Errorf("only one ship reached the kind marker within 10 s")
+ s.once.Do(func() { close(s.open) })
+ }
+ }
+ return s.Store.Put(ctx, key, body, ifMatch)
+}
+
+// Finding 9, through the real door: a binary and a rootfs first ship to the
+// empty app, both past the door's kind check, race at publication. One
+// wins; the other is refused naming the winner and writes nothing.
+func TestAPIShipFirstShipsOfTwoKindsAtOnce(t *testing.T) {
+ fx := rootfsShipFixture(t)
+ fx.fx.srv.cfg.Releases = &markerBarrierStore{Store: fx.fx.srv.cfg.Releases, t: t,
+ key: release.AppKindMarkerKey("bab", "shop"), open: make(chan struct{})}
+ bin := randomBytes(t, 4<<10)
+ img := randomBytes(t, 64<<10)
+ type answer struct {
+ status int
+ out map[string]any
+ }
+ var ans [2]answer
+ var wg sync.WaitGroup
+ wg.Go(func() {
+ s, o := postShip(t, fx.url("shop"), fx.token, []shipPart{metaPart(t, "0.1.0-1", "binary"), {name: "linux-arm64", body: bin}})
+ ans[0] = answer{s, o}
+ })
+ wg.Go(func() {
+ s, o := postShip(t, fx.url("shop"), fx.token, []shipPart{rootfsMeta(t, "0.1.0-2", len(img), false), {name: "rootfs-linux-arm64", body: img}})
+ ans[1] = answer{s, o}
+ })
+ wg.Wait()
+ if t.Failed() {
+ t.FailNow()
+ }
+ win, lose := 0, 1
+ if ans[0].status != http.StatusCreated {
+ win, lose = 1, 0
+ }
+ if ans[win].status != http.StatusCreated || ans[lose].status != http.StatusConflict {
+ t.Fatalf("answers = %+v, want one 201 and one 409", ans)
+ }
+ kinds := [2]string{"binary", release.KindRootfs}
+ vers := [2]string{"0.1.0-1", "0.1.0-2"}
+ want := (&release.KindConflictError{App: "shop", Have: kinds[win], HaveVersion: vers[win], Want: kinds[lose], FirstShip: true}).Error()
+ if got := str(t, ans[lose].out, "error"); got != want {
+ t.Fatalf("loser = %q, want %q", got, want)
+ }
+ ctx := context.Background()
+ got, _, err := release.VersionCandidates(ctx, fx.fx.rel, "bab", "shop", 20)
+ if err != nil || len(got) != 1 || got[0] != vers[win] {
+ t.Fatalf("versions = %v, %v; want only %s", got, err, vers[win])
+ }
+ if m, err := release.GetManifest(ctx, fx.fx.rel, "bab", "shop", vers[win]); err != nil || m.Kind != kinds[win] {
+ t.Fatalf("winner's manifest = %+v, %v", m, err)
+ }
+ keys, err := fx.fx.rel.List(ctx, "apps/bab/shop/releases/"+vers[lose]+"/")
+ if err != nil || len(keys) != 0 {
+ t.Fatalf("the loser wrote %v (%v)", keys, err)
+ }
+ fx.assertScratchEmpty(t)
+}
+
+// countingBody counts the bytes the door reads from the request body.
+type countingBody struct {
+ io.ReadCloser
+ n atomic.Int64
+}
+
+func (c *countingBody) Read(p []byte) (int, error) {
+ n, err := c.ReadCloser.Read(p)
+ c.n.Add(int64(n))
+ return n, err
+}
+
+// postShipUnread is a ship whose meta part is followed by a file part named
+// part that never ends before the answer (preflight 3.2): the body is a pipe
+// that writes the meta, then up to 8 MiB of the part, then blocks until the
+// answer arrives. It reports the answer, how many body bytes the door had
+// read when it answered (counted on the server), and how many the meta part
+// took on the wire with its framing.
+func postShipUnread(t *testing.T, f rootfsShipFx, app string, meta shipPart, part string) (int, map[string]any, int64, int64) {
+ t.Helper()
+ var counted atomic.Pointer[countingBody]
+ f.fx.srv.shipBodyWrap = func(rc io.ReadCloser) io.ReadCloser {
+ c := &countingBody{ReadCloser: rc}
+ counted.Store(c)
+ return c
+ }
+ t.Cleanup(func() { f.fx.srv.shipBodyWrap = nil })
+
+ pr, pw := io.Pipe()
+ wire := &countingWriter{w: pw}
+ mw := multipart.NewWriter(wire)
+ var metaBytes atomic.Int64
+ answered := make(chan struct{})
+ go func() {
+ fw, err := mw.CreateFormFile(meta.name, meta.name)
+ if err == nil {
+ _, err = fw.Write(meta.body)
+ }
+ if err != nil {
+ pw.CloseWithError(err)
+ return
+ }
+ metaBytes.Store(wire.n)
+ fw, err = mw.CreateFormFile(part, part)
+ chunk := make([]byte, 64<<10)
+ for sent := 0; err == nil && sent < 8<<20; sent += len(chunk) {
+ _, err = fw.Write(chunk)
+ }
+ if err == nil {
+ <-answered
+ }
+ }()
+ req, err := http.NewRequest(http.MethodPost, f.url(app), pr)
+ if err != nil {
+ t.Fatal(err)
+ }
+ req.Header.Set("Content-Type", mw.FormDataContentType())
+ req.Header.Set("Authorization", "Bearer "+f.token)
+ client := &http.Client{Timeout: 30 * time.Second}
+ res, err := client.Do(req)
+ close(answered)
+ pw.CloseWithError(errors.New("answered"))
+ if err != nil {
+ t.Fatalf("the door did not answer before the body: %v", err)
+ }
+ defer res.Body.Close()
+ raw, _ := io.ReadAll(res.Body)
+ out := map[string]any{}
+ _ = json.Unmarshal(raw, &out)
+ c := counted.Load()
+ if c == nil {
+ t.Fatal("the door never took the body")
+ }
+ return res.StatusCode, out, c.n.Load(), metaBytes.Load()
+}
+
+type countingWriter struct {
+ w io.Writer
+ n int64
+}
+
+func (c *countingWriter) Write(p []byte) (int, error) {
+ n, err := c.w.Write(p)
+ c.n += int64(n)
+ return n, err
+}
+
+// wantRefusedUnread is postShipUnread's assertion: the refusal, with no
+// more than 64 KiB past the meta read.
+func wantRefusedUnread(t *testing.T, f rootfsShipFx, what, app string, meta shipPart, part string, wantStatus int, wantMsg string) {
+ t.Helper()
+ status, out, read, metaLen := postShipUnread(t, f, app, meta, part)
+ wantRefusal(t, what, status, out, wantStatus, wantMsg)
+ if read > metaLen+64<<10 {
+ t.Fatalf("%s: the door read %d bytes, the meta is %d: it read the body", what, read, metaLen)
+ }
+}
+
+// Review Focus 3: the kind check refuses before any upload byte is read.
+func TestAPIShipRefusesABinaryReleaseToARootfsAppBeforeTheBody(t *testing.T) {
+ fx := rootfsShipFixture(t)
+ img := randomBytes(t, 32<<10)
+ if status, out := postShip(t, fx.url("shop"), fx.token, []shipPart{rootfsMeta(t, "0.1.0-1", len(img), false), {name: "rootfs-linux-arm64", body: img}}); status != http.StatusCreated {
+ t.Fatalf("rootfs ship = %d %v", status, out)
+ }
+ c19 := (&release.KindConflictError{App: "shop", Have: release.KindRootfs, HaveVersion: "0.1.0-1", Want: "binary"}).Error()
+ wantRefusedUnread(t, fx, "binary to a rootfs app", "shop", metaPart(t, "0.1.0-2", "binary"), "linux-arm64", http.StatusConflict, c19)
+ c20 := (&release.HibernateConflictError{App: "shop", HaveVersion: "0.1.0-1", Hibernate: false}).Error()
+ wantRefusedUnread(t, fx, "hibernation on", "shop", rootfsMeta(t, "0.1.0-2", 8<<20, true), "rootfs-linux-arm64", http.StatusConflict, c20)
+
+ fx.addApp(t, "blog")
+ if status, out := postShip(t, fx.url("blog"), fx.token, []shipPart{metaPart(t, "0.1.0-1", "binary"), {name: "linux-arm64", body: img}}); status != http.StatusCreated {
+ t.Fatalf("binary ship = %d %v", status, out)
+ }
+ c18 := (&release.KindConflictError{App: "blog", Have: "binary", HaveVersion: "0.1.0-1", Want: release.KindRootfs}).Error()
+ wantRefusedUnread(t, fx, "rootfs to a binary app", "blog", rootfsMeta(t, "0.1.0-2", 8<<20, false), "rootfs-linux-arm64", http.StatusConflict, c18)
+
+ fx.addApp(t, "cafe")
+ for _, ver := range []string{"0.1.0-1", "0.1.0-2"} {
+ if status, out := postShip(t, fx.url("cafe"), fx.token, []shipPart{rootfsMeta(t, ver, len(img), true), {name: "rootfs-linux-arm64", body: img}}); status != http.StatusCreated {
+ t.Fatalf("hibernating ship %s = %d %v", ver, status, out)
+ }
+ }
+ c21 := (&release.HibernateConflictError{App: "cafe", HaveVersion: "0.1.0-2", Hibernate: true}).Error()
+ wantRefusedUnread(t, fx, "hibernation off", "cafe", rootfsMeta(t, "0.1.0-3", 8<<20, false), "rootfs-linux-arm64", http.StatusConflict, c21)
+ fx.assertScratchEmpty(t)
+}
+
+// A first ship that fixed the kind and never published still fixes it.
+func TestAPIShipRefusesAfterAnUnpublishedFirstShip(t *testing.T) {
+ fx := rootfsShipFixture(t)
+ body, err := json.Marshal(release.AppKindMarker{Schema: 1, Account: "bab", App: "shop", Kind: "binary", Version: "0.1.0-1", CreatedAt: time.Now().UTC()})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := fx.fx.rel.Put(context.Background(), release.AppKindMarkerKey("bab", "shop"), body, ""); err != nil {
+ t.Fatal(err)
+ }
+ c80 := (&release.KindConflictError{App: "shop", Have: "binary", HaveVersion: "0.1.0-1", Want: release.KindRootfs, FirstShip: true}).Error()
+ wantRefusedUnread(t, fx, "rootfs after a binary first ship", "shop", rootfsMeta(t, "0.1.0-2", 8<<20, false), "rootfs-linux-arm64", http.StatusConflict, c80)
+ if status, out := postShip(t, fx.url("shop"), fx.token, []shipPart{metaPart(t, "0.1.0-1", "binary"), {name: "linux-arm64", body: randomBytes(t, 1024)}}); status != http.StatusCreated {
+ t.Fatalf("binary ship of the first ship's version = %d %v", status, out)
+ }
+}
+
+func TestAPIShipRootfsDeclaredTooBigIsRefusedBeforeTheBody(t *testing.T) {
+ old := apiShipMaxBytesRootfs
+ apiShipMaxBytesRootfs = 1 << 20
+ defer func() { apiShipMaxBytesRootfs = old }()
+ fx := rootfsShipFixture(t)
+ wantRefusedUnread(t, fx, "2 MiB declared", "shop", rootfsMeta(t, "0.1.0-1", 2<<20, false), "rootfs-linux-arm64", http.StatusRequestEntityTooLarge,
+ "the rootfs image is over 2 GiB, the most one release can carry. make the image smaller and ship again")
+ fx.assertNothingWritten(t, "shop")
+}
+
+// paddedRootfsBody is a ship body whose meta part carries pad bytes of
+// whitespace after its JSON, so a test can place the body's end exactly.
+// It reports the body, its content type, and where the image's bytes start.
+func paddedRootfsBody(t *testing.T, meta shipPart, pad int, img []byte) ([]byte, string, int) {
+ t.Helper()
+ var buf bytes.Buffer
+ mw := multipart.NewWriter(&buf)
+ if err := mw.SetBoundary("rootfs-ship-test-boundary"); err != nil {
+ t.Fatal(err)
+ }
+ fw, err := mw.CreateFormFile(meta.name, meta.name)
+ if err != nil {
+ t.Fatal(err)
+ }
+ fw.Write(meta.body)
+ fw.Write(bytes.Repeat([]byte(" "), pad))
+ fw, err = mw.CreateFormFile("rootfs-linux-arm64", "rootfs-linux-arm64")
+ if err != nil {
+ t.Fatal(err)
+ }
+ start := buf.Len()
+ fw.Write(img)
+ if err := mw.Close(); err != nil {
+ t.Fatal(err)
+ }
+ return buf.Bytes(), mw.FormDataContentType(), start
+}
+
+func postRawShip(t *testing.T, u, token string, body []byte, contentType string) (int, map[string]any) {
+ t.Helper()
+ req, err := http.NewRequest(http.MethodPost, u, bytes.NewReader(body))
+ if err != nil {
+ t.Fatal(err)
+ }
+ req.Header.Set("Content-Type", contentType)
+ req.Header.Set("Authorization", "Bearer "+token)
+ res, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer res.Body.Close()
+ raw, _ := io.ReadAll(res.Body)
+ out := map[string]any{}
+ _ = json.Unmarshal(raw, &out)
+ return res.StatusCode, out
+}
+
+func TestAPIShipRootfsPartLongerOrShorterThanDeclared(t *testing.T) {
+ fx := rootfsShipFixture(t)
+ img := randomBytes(t, 256<<10)
+ c28 := "the rootfs part does not match the size the request declared, so nothing was shipped"
+ for name, part := range map[string][]byte{"longer": append(bytes.Clone(img), 'x'), "shorter": img[:len(img)-1]} {
+ status, out := postShip(t, fx.url("shop"), fx.token, []shipPart{rootfsMeta(t, "0.1.0-1", len(img), false), {name: "rootfs-linux-arm64", body: part}})
+ wantRefusal(t, name, status, out, http.StatusBadRequest, c28)
+ }
+ fx.assertNothingWritten(t, "shop")
+ fx.assertScratchEmpty(t)
+
+ // Past the declared size the body limit drops to the meta's budget, the
+ // image and the framing: a body whose image ends one byte past that is
+ // cut short while the image is read.
+ meta := rootfsMeta(t, "0.1.0-1", len(img), false)
+ _, _, start := paddedRootfsBody(t, meta, 0, img)
+ limit := apiShipMetaMaxBytes + int64(len(img)) + apiShipFramingBytes
+ pad := int(limit+1) - len(img) - start
+ body, ct, start := paddedRootfsBody(t, meta, pad, img)
+ if int64(start+len(img)) != limit+1 {
+ t.Fatalf("the image ends at %d, want %d", start+len(img), limit+1)
+ }
+ status, out := postRawShip(t, fx.url("shop"), fx.token, body, ct)
+ wantRefusal(t, "one byte past the limit", status, out, http.StatusRequestEntityTooLarge, "upload too large")
+ fx.assertNothingWritten(t, "shop")
+ fx.assertScratchEmpty(t)
+}
+
+func TestAPIShipRootfsRefusesAnotherPartName(t *testing.T) {
+ fx := rootfsShipFixture(t)
+ img := randomBytes(t, 4<<10)
+ status, out := postShip(t, fx.url("shop"), fx.token, []shipPart{rootfsMeta(t, "0.1.0-1", len(img), false), {name: "linux-arm64", body: img}})
+ wantRefusal(t, "linux-arm64 part", status, out, http.StatusBadRequest, `a rootfs release ships exactly one file part, named "rootfs-linux-arm64"`)
+ fx.assertNothingWritten(t, "shop")
+ fx.assertScratchEmpty(t)
+}
+
+func TestAPIShipRootfsRefusesASecondPart(t *testing.T) {
+ fx := rootfsShipFixture(t)
+ img := randomBytes(t, 4<<10)
+ status, out := postShip(t, fx.url("shop"), fx.token, []shipPart{rootfsMeta(t, "0.1.0-1", len(img), false),
+ {name: "rootfs-linux-arm64", body: img}, {name: "rootfs-linux-arm64", body: img}})
+ wantRefusal(t, "second part", status, out, http.StatusBadRequest, "too many file parts (max 1 for a rootfs release)")
+ fx.assertNothingWritten(t, "shop")
+ fx.assertScratchEmpty(t)
+}
+
+func TestAPIShipRootfsRefusesBadProcesses(t *testing.T) {
+ fx := rootfsShipFixture(t)
+ img := randomBytes(t, 4<<10)
+ c16 := "malformed processes block. want the processes object: web, any workers, release and console"
+ c17 := "processes: null. a rootfs release needs its processes, starting with web"
+ for _, c := range []struct {
+ name string
+ procs any // nil drops the key
+ drop bool
+ want string
+ }{
+ {"no web", map[string]any{"worker": map[string]any{"command": []string{"bin/w"}}}, false,
+ "the processes declare no web process. a rootfs release needs exactly one, named web"},
+ {"unknown key", map[string]any{"web": map[string]any{"comand": []string{"bin/web"}}}, false, c16},
+ {"null", nil, false, c17},
+ {"absent", nil, true, c17},
+ } {
+ fields := rootfsFields("0.1.0-1", len(img), false)
+ fields["processes"] = c.procs
+ if c.drop {
+ delete(fields, "processes")
+ }
+ status, out := postShip(t, fx.url("shop"), fx.token, []shipPart{metaOf(t, fields), {name: "rootfs-linux-arm64", body: img}})
+ wantRefusal(t, c.name, status, out, http.StatusBadRequest, c.want)
+ }
+ fx.assertNothingWritten(t, "shop")
+}
+
+func TestAPIShipRootfsRefusesCapabilities(t *testing.T) {
+ fx := rootfsShipFixture(t)
+ img := randomBytes(t, 4<<10)
+ fields := rootfsFields("0.1.0-1", len(img), false)
+ fields["capabilities"] = json.RawMessage(`{"v":1,"daemon":true}`)
+ status, out := postShip(t, fx.url("shop"), fx.token, []shipPart{metaOf(t, fields), {name: "rootfs-linux-arm64", body: img}})
+ wantRefusal(t, "capabilities", status, out, http.StatusBadRequest, release.ErrRootfsCapabilities.Error())
+ fx.assertNothingWritten(t, "shop")
+}
+
+func TestAPIShipRootfsWithoutASizeIsRefused(t *testing.T) {
+ fx := rootfsShipFixture(t)
+ img := randomBytes(t, 4<<10)
+ c25 := "the request does not say how large the rootfs image is, so nothing was read. update carlos and ship again"
+ for name, size := range map[string]any{"absent": nil, "zero": 0, "negative": -1} {
+ fields := rootfsFields("0.1.0-1", len(img), false)
+ fields["rootfs_size"] = size
+ if size == nil {
+ delete(fields, "rootfs_size")
+ }
+ status, out := postShip(t, fx.url("shop"), fx.token, []shipPart{metaOf(t, fields), {name: "rootfs-linux-arm64", body: img}})
+ wantRefusal(t, name, status, out, http.StatusBadRequest, c25)
+ }
+ fx.assertNothingWritten(t, "shop")
+}
+
+// Preflight 2.8: rootfs_size on a binary meta is C13 too.
+func TestAPIShipBinaryWithProcessesIsRefused(t *testing.T) {
+ fx := rootfsShipFixture(t)
+ for name, extra := range map[string]any{
+ "processes": map[string]any{"web": map[string]any{"command": []string{"bin/web"}}},
+ "hibernate": true,
+ "jit": true,
+ "rootfs_size": 1024,
+ } {
+ fields := map[string]any{"version": "0.1.0-1", "kind": "binary", name: extra}
+ status, out := postShip(t, fx.url("shop"), fx.token, []shipPart{metaOf(t, fields), {name: "linux-arm64", body: randomBytes(t, 1024)}})
+ wantRefusal(t, name, status, out, http.StatusBadRequest, release.ErrRootfsFieldsOnly.Error())
+ }
+ fx.assertNothingWritten(t, "shop")
+}
+
+func TestAPIShipRootfsToATemporaryAppIsRefused(t *testing.T) {
+ fx := rootfsShipFixture(t)
+ fx.fx.srv.cfg.TemporaryApps = true
+ if status, out := createTemporary(t, fx.fx, fx.token, "tmp-shop", 0); status != http.StatusOK {
+ t.Fatalf("create temporary = %d %v", status, out)
+ }
+ img := randomBytes(t, 4<<10)
+ status, out := postShip(t, fx.url("tmp-shop"), fx.token, []shipPart{rootfsMeta(t, "0.1.0-1", len(img), false), {name: "rootfs-linux-arm64", body: img}})
+ wantRefusal(t, "temporary", status, out, http.StatusConflict,
+ "tmp-shop is temporary, so you cannot ship a rootfs release to it. Make it permanent first with: carlos apps keep --app tmp-shop")
+ // The temporary app's own pipeline is there; no release and no kind.
+ keys, err := fx.fx.rel.List(context.Background(), "apps/bab/tmp-shop/")
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, k := range keys {
+ if strings.Contains(k, "/releases/") || k == release.AppKindMarkerKey("bab", "tmp-shop") {
+ t.Fatalf("written: %s", k)
+ }
+ }
+}
+
+func TestAPIShipRootfsToAControlPlaneAppIsRefused(t *testing.T) {
+ fx := rootfsShipFixture(t)
+ if err := fx.fx.st.SetAppControlPlane(OpsAccountID, "shop", true); err != nil {
+ t.Fatal(err)
+ }
+ img := randomBytes(t, 4<<10)
+ status, out := postShip(t, fx.url("shop"), fx.token, []shipPart{rootfsMeta(t, "0.1.0-1", len(img), false), {name: "rootfs-linux-arm64", body: img}})
+ wantRefusal(t, "control plane", status, out, http.StatusBadRequest, "shop is a control-plane app, so its releases are binaries only")
+ fx.assertNothingWritten(t, "shop")
+ fx.assertScratchEmpty(t)
+}
+
+func TestAPIShipRootfsWaitsForTheScratchPool(t *testing.T) {
+ fx := rootfsShipFixture(t)
+ free, ref := fx.fx.srv.seedScratch.reserve("bab/shop", 0)
+ if ref != nil {
+ t.Fatal(ref.Msg)
+ }
+ defer free()
+ img := randomBytes(t, 4<<10)
+ status, out := postShip(t, fx.url("shop"), fx.token, []shipPart{rootfsMeta(t, "0.1.0-1", len(img), false), {name: "rootfs-linux-arm64", body: img}})
+ wantRefusal(t, "pool busy", status, out, http.StatusTooManyRequests, "another copy or upload is running for shop. Try again when it finishes.")
+ fx.assertNothingWritten(t, "shop")
+ fx.assertScratchEmpty(t)
+}
+
+func TestAPIShipInvalidKindNamesRootfs(t *testing.T) {
+ fx := rootfsShipFixture(t)
+ status, out := postShip(t, fx.url("shop"), fx.token, []shipPart{metaPart(t, "0.1.0-1", "oci"), {name: "linux-arm64", body: randomBytes(t, 1024)}})
+ wantRefusal(t, "oci", status, out, http.StatusBadRequest, `invalid kind (want "binary", "static" or "rootfs")`)
+}
diff --git a/internal/console/server.go b/internal/console/server.go
index e7c940087..c237163c2 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -15,6 +15,7 @@ import (
"errors"
"fmt"
"html/template"
+ "io"
"log"
"math"
mrand "math/rand/v2"
@@ -1071,6 +1072,10 @@ type Server struct {
// cliNotified: without this a test racing to fill shipSem would have no
// way to know the earlier requests actually hold it yet.
shipStarted chan struct{}
+ // shipBodyWrap, when non-nil, wraps a ship request's body before the
+ // door reads any of it. Tests only: it counts what the door read
+ // before it answered (rootfs Plan 1 preflight 3.2).
+ shipBodyWrap func(io.ReadCloser) io.ReadCloser
// delegationZone and lookupCNAME are the wildcard-capability door's two
// seams (apidomains.go). delegationZone is cfg.DelegationZone, shape-
// validated and normalized once here (normalizeDelegationZone);
diff --git a/internal/release/rootfs.go b/internal/release/rootfs.go
index 6b20da8c4..c666a5844 100644
--- a/internal/release/rootfs.go
+++ b/internal/release/rootfs.go
@@ -59,6 +59,10 @@ type Process struct {
// is not a rootfs release.
var ErrRootfsFieldsOnly = errors.New("processes, hibernate and jit belong to rootfs releases only")
+// ErrRootfsCapabilities refuses a capability block on a rootfs release. The
+// ship door answers it before the image is read.
+var ErrRootfsCapabilities = errors.New("a rootfs release cannot declare capabilities")
+
var (
errProcessesNull = errors.New("processes: null. a rootfs release needs its processes, starting with web")
errProcessesMalformed = errors.New("malformed processes block. want the processes object: web, any workers, release and console")
@@ -211,7 +215,7 @@ func validateRootfsShape(m *Manifest) error {
return errors.New("a rootfs release carries one artifact, rootfs-linux-arm64, and nothing else")
}
if m.Capabilities != nil {
- return errors.New("a rootfs release cannot declare capabilities")
+ return ErrRootfsCapabilities
}
if m.Program != "" || m.ProgramChange {
return errors.New("a rootfs release has no program stamp")
diff --git a/internal/release/rootfs_ship.go b/internal/release/rootfs_ship.go
index 485560307..79ead586b 100644
--- a/internal/release/rootfs_ship.go
+++ b/internal/release/rootfs_ship.go
@@ -170,6 +170,12 @@ func AppKindMarkerKey(acct, app string) string { return appPrefix(acct, app) + "
// failure to check the app's releases.
var errAppKindUnreadable = errors.New("the app has release manifests but none is readable, so its kind is unknown")
+// ErrAppKindCheck marks a kind check that could not be made: a store error,
+// an app whose manifests cannot be read, a marker that cannot be believed.
+// It is no refusal of the release, so a door answers it as a failure to
+// check the app's releases (RFS-1-C79), never with this text.
+var ErrAppKindCheck = errors.New("ship: app kind")
+
func validMarkerKind(kind string) bool {
return kind == "binary" || kind == "static" || kind == KindRootfs
}
@@ -280,11 +286,11 @@ func CheckShipKind(ctx context.Context, s Store, acct string, m *Manifest) error
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)
+ return false, fmt.Errorf("%w: %w", ErrAppKindCheck, err)
}
first, err = st.decide(m)
if errors.Is(err, errAppKindUnreadable) {
- return false, fmt.Errorf("ship: app kind: %w", err)
+ return false, fmt.Errorf("%w: %w", ErrAppKindCheck, err)
}
return first, err
}
@@ -298,7 +304,7 @@ func checkShipKind(ctx context.Context, s Store, acct string, m *Manifest) (firs
// 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)
+ return fmt.Errorf("%w: invalid account %q, app %q, version %q or kind %q", ErrAppKindCheck, acct, m.App, m.Version, m.Kind)
}
first, err := checkShipKind(ctx, s, acct, m)
if err != nil || !first {
@@ -314,14 +320,14 @@ func establishShipKind(ctx context.Context, s Store, acct string, m *Manifest) e
return nil
}
if !errors.Is(err, ErrCASConflict) {
- return fmt.Errorf("ship: app kind: %w", err)
+ return fmt.Errorf("%w: %w", ErrAppKindCheck, err)
}
mk, err := readAppKindMarker(ctx, s, acct, m.App)
if err != nil {
- return fmt.Errorf("ship: app kind: %w", err)
+ return fmt.Errorf("%w: %w", ErrAppKindCheck, err)
}
if mk == nil {
- return errors.New("ship: app kind: the kind marker conflicted and then could not be found")
+ return fmt.Errorf("%w: the kind marker conflicted and then could not be found", ErrAppKindCheck)
}
return kindRefusal(m, mk.Kind, mk.Version, mk.Hibernate, true)
}
diff --git a/internal/release/rootfs_ship_test.go b/internal/release/rootfs_ship_test.go
index 1f35ce5ab..a40f15f48 100644
--- a/internal/release/rootfs_ship_test.go
+++ b/internal/release/rootfs_ship_test.go
@@ -261,7 +261,7 @@ func TestCheckShipKindSkipsAnUnreadableManifestButFailsClosedOnTheStore(t *testi
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 {
+ if err := CheckShipKind(ctx, broken, "bab", m); !errors.Is(err, errDiskOnFire) || !errors.Is(err, ErrAppKindCheck) {
t.Fatalf("err = %v, want the store error wrapped", err)
}
}
@@ -291,8 +291,9 @@ func TestFirstShipRefusesAnAppWhoseManifestsAreUnreadable(t *testing.T) {
"ShipSpooled": ShipSpooled(ctx, s, "bab", m, paths),
"Ship": Ship(ctx, s, "bab", binaryManifest("0.1.0-3"), binaryFiles()),
} {
- if !errors.Is(err, errAppKindUnreadable) {
- t.Errorf("%s: %s: err = %v, want errAppKindUnreadable", name, call, err)
+ // ErrAppKindCheck is what the ship door maps to RFS-1-C79.
+ if !errors.Is(err, errAppKindUnreadable) || !errors.Is(err, ErrAppKindCheck) {
+ t.Errorf("%s: %s: err = %v, want errAppKindUnreadable marked ErrAppKindCheck", name, call, err)
}
}
if len(s.puts)+len(s.putFroms) != 0 {