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>
8 files changed,
+1216
−51
approved_rootfs_copy_test.go+2 −0internal/console/apicli.go+135 −41internal/console/apiship_rootfs.go+358 −0internal/console/apiship_rootfs_test.go+695 −0internal/console/server.go+5 −0internal/release/rootfs.go+5 −1internal/release/rootfs_ship.go+12 −6internal/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 { |