Skip to content

apko erofs with compression support via mkfs.erofs - #2406

Draft
smoser wants to merge 24 commits into
chainguard-dev:mainfrom
smoser:feat/apko-erofs-with-compress
Draft

apko erofs with compression support via mkfs.erofs#2406
smoser wants to merge 24 commits into
chainguard-dev:mainfrom
smoser:feat/apko-erofs-with-compress

Conversation

@smoser

@smoser smoser commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

this is a single commit on top of #2249 .
it separates out the compression support that is somewhat bolted on.

alternative is a temporary fork of go-erofs with something like erofs/go-erofs#45 ,
or wait for it upstream. see erofs/go-erofs#33

smoser and others added 24 commits August 17, 2026 14:20
Emit OCI image layers as EROFS filesystem images (application/vnd.erofs)
instead of tar+gzip. Selected via `--format=erofs` on `apko build` /
`apko publish` or `format: erofs` in apko.yaml. Tracks the draft
erofs/erofs-image-spec (PR chainguard-dev#1).

Single-layer and multi-layer (layering) builds are supported. Multi-layer
emits each non-final group with `org.erofs.role=overlay-lower` per spec
§3.8 and a per-group partial `usr/lib/apk/db/installed` so per-layer
scanners still work. Manifests declare `erofs` in os.features per §5.4.

Uses github.com/erofs/go-erofs (Apache-2.0, pure Go) for the writer.
Reproducibility via SOURCE_DATE_EPOCH. Tests cover roundtrip via
erofs.Open, byte-identical determinism, the full ImageLayoutToLayer
dispatch, OSFeatures plumbing, and end-to-end validation via
`fsck.erofs` (skipped when the binary isn't on PATH). `+zstd`, dm-verity,
and chunk indexes are not implemented in this round.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Step-by-step guide for producing EROFS images with --format=erofs,
inspecting the layer blob without root (fsck.erofs / dump.erofs /
fsck.erofs --extract), mounting it (kernel mount or erofsfuse),
pulling layer blobs from a registry, and assembling multi-layer
images via overlayfs. Includes the current limitations (no +zstd,
dm-verity, chunk index) and links from apko_file.md.

All commands shown were verified against a real `apko build` of
examples/wolfi-base.yaml.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds an `apko erofs` command group that wraps the EROFS mount workflow:
`mount` accepts a raw blob or an OCI image directory (auto-detected, or via
`erofs:`/`oci:`/`oci-dir:` prefixes), `umount` reads a per-mount state file
to unwind every layer, and `ls` produces a `tar tvf`-style listing without
leaving mounts behind. The new pkg/erofsmount library handles source
parsing, OCI layout reading, kernel/FUSE drivers with kernel-overlay-over-
fuse fallback to fuse-overlayfs, and state-file teardown.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`apko erofs ls` now opens each EROFS layer blob directly with go-erofs
and walks a layered fs.FS in user space, instead of mounting the layers
and walking the merged mountpoint. This removes the kernel/FUSE dependency
for `ls` (works on darwin/windows too), eliminates the mount log noise,
and is faster.

Introduces a reusable pkg/erofsmount.Stack: a layered fs.FS implementing
fs.ReadDirFS/StatFS/ReadLinkFS with full AUFS-style overlay semantics —
.wh.NAME whiteouts hide siblings, .wh..wh..opq markers hide all lower-
layer entries in a directory, ancestor whiteouts hide whole subtrees,
type-mismatch in a higher layer shadows lower contents. apko's writer
never emits whiteouts (it splits one rootfs into groups, doesn't merge),
so 15 unit tests synthesize the whiteout cases via testing/fstest.MapFS.

Mount and Unmount remain Linux-only since they genuinely need the kernel
or FUSE.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
writeERofs was the only identifier in the repo using mid-word
acronym-style "ERofs"; everywhere else treats it as a word ("Erofs").
Rename writeERofs / writeERofsViaMkfs and the related test names so the
codebase is uniform.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
apko_file.md still claimed EROFS layers were uncompressed and that +zstd
was unimplemented; the +ALGO variants have shipped since the format field
was first documented. Rewrite the format list to enumerate raw and
compressed variants, mention the uncompressed-digest annotation, and
note the mkfs.erofs runtime dependency.

erofs.md's manual-overlay reference snippet had two bugs that prevented
it from running end-to-end: $ROOT/../../blobs/sha256/$MANIFEST
double-traversed the OCI layout, and the lowerdir chain hard-coded a
four-layer count with explicit lower0/lower1 references that wouldn't
generalize. Rewrite the loop to derive $BLOBS and $MANIFEST cleanly and
accumulate $LOWERS as it mounts.

Also fix two small accuracy bugs: --arch on apko takes Go arches
(amd64, arm64), not uname -m output (x86_64, aarch64) — replace with
--arch=host, which is what the YAML examples in the same file use. And
on Debian/Ubuntu erofsfuse ships inside the erofs-utils package; the
separate erofsfuse package only exists on Wolfi/Alpine.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The application/vnd.erofs media type and the org.erofs.role / overlay-lower
/ org.erofs.uncompressed-digest annotation strings lived as parallel
unexported consts in pkg/build/erofs.go and pkg/erofsmount/oci.go with
a "keep in sync" comment guarding the duplicate. Promote them to a
single set of exported constants in pkg/build/types/erofs.go so both
the writer and the reader/mount tools reference the same source, and
test fixtures lock to the same strings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
mount(8) since util-linux 2.29 autodetects when the source is a regular
file and allocates a loop device with O_AUTOCLEAR, freeing it on umount.
Asking for "-o loop" explicitly relies on a separate code path whose
cleanup semantics differ across util-linux releases and busybox builds —
on older or non-GNU versions the loop device can leak after umount.

Drop "loop" from the argv. Keep "-o ro" to document intent (EROFS is
intrinsically read-only, but the explicit flag tells a reader who is
copy-pasting the equivalent shell command that we never plan to write).
Update the matching tests and the two "doing it manually" snippets in
docs/erofs.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
st.Mounts is recorded overlay-first then per-layer mounts in LIFO order.
If the overlay umount fails, every subsequent layer umount returns EBUSY
because the overlay still pins them — the previous loop collected and
errors.Join()'d every one of those, giving the user a long block of
identical "device busy" noise where only the first error described the
real problem.

Return on the first failed umount with a single error that names which
mountpoint the user needs to clear; leave the remaining mounts and the
state file in place so a follow-up `apko erofs umount` finishes the job.
Deliberately do not fall back to `umount -l`: lazy unmount would let the
process exit with the user believing things were torn down while the
mounts and pinned files quietly persist.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
erofsmount.Options.ReadOnly was already plumbed into the overlay
assembly but had no way to be set from the CLI; the only consumers
were external library callers. Wire it through 'apko erofs mount
--read-only', omitting the upperdir/workdir overlay just like a
library caller would.

For single-layer images, overlayfs adds nothing in the read-only case
and a lowerdir-only overlay over one EROFS mount has historically been
finicky across overlayfs releases. When --read-only is set and the
image has exactly one layer, skip the layers/upper/work directories
entirely and mount the lone layer straight at DEST/merged. The state
file's Mounts slice records that single mountpoint, so Unmount
naturally cleans up the same way as a multi-layer mount.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
emitErofsEntry passed mode.Perm() to Mkdir/Mknod/Chmod, and Go keeps
setuid/setgid/sticky outside the low 9 bits Perm() returns (fs.ModeSetuid
et al are separate high bits), so all three were silently dropped. An
EROFS layer built from any normal rootfs shipped a non-setuid sudo,
passwd and su, and a non-sticky /tmp. The tar path is unaffected because
archive/tar's FileInfoHeader does the translation itself.

go-erofs's Writer.Chmod already converts a full fs.FileMode to POSIX
mode bits and preserves the entry's type bits, so hand it the unmodified
mode once, after the entry exists, instead of at each creation site —
Mkdir, Mknod and Create only ever take permission bits. Symlinks are
skipped: EROFS pins them at 0777.

Verified against erofs-utils: fsck.erofs --extract of an image with a
04755 file, a 02755 file and a 01777 dir yields 4755/2755/sticky after
this change and 755/755/755 before it. Note that dump.erofs's "Access:"
line masks to 0777 and never shows these bits, even for images built by
mkfs.erofs itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Relevant to apko: the reader now reports char devices as
fs.ModeDevice|fs.ModeCharDevice, matching Go's own convention (os.Lstat
sets both), where 0.3.0 set only ModeCharDevice; Writer errors are now
sticky, so a failure inside a long CopyFrom/Create sequence surfaces
instead of being dropped; and maxBlockSize is capped at 64 KiB, which
images built by apko never exceed (the default 4096 is used).

Every apko consumer tests device bits with a mask rather than comparing
whole mode values, so the ModeCharDevice change is a no-op here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three display bugs in `apko erofs ls`, all in how the listing reads
metadata off the entry:

- Every line printed 0/0 for ownership. uidGidFromSys looked for
  UID()/GID() accessor methods on info.Sys(), but go-erofs returns an
  *erofs.Stat, which carries them as plain fields — the assertions never
  matched. This was actively misleading: it read as apko losing ownership
  when the image was fine.
- Devices printed their (zero) inode size where `tar tv` puts
  major,minor. Decode Stat.Rdev the way Linux's new_encode_dev() wrote
  it rather than with unix.Major/Minor, whose encoding is host-specific.
- setuid/setgid/sticky never rendered. fs.FileInfo.Mode() from the
  reader carries raw on-disk bits and no Go special-mode bits; Stat.Mode
  is the translated value. Take mode from there and render the s/S/t/T
  overloads of the execute columns as `ls -l` does.

Entries with no *erofs.Stat — the directories Stack synthesizes for
parents no layer contains — keep falling back to the plain FileInfo.

Also drop the `ls --help` text describing a temporary mount that has not
happened since ls became mount-less, and say plainly that --mode is
accepted only for symmetry with `mount` and ignored.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The xattr path in emitErofsEntry was never exercised for anything but
user.* on a small fixture, and the package set it was tried against
carries no capabilities at all. melange's guest init untars its rootfs
with --xattrs-include='security.capability', so an EROFS rootfs that
dropped them would break setcap'd binaries with no visible error.

Verified working as-is — this test locks it in. It writes a real
VFS_CAP_REVISION_2 payload plus a trusted.* and a user.* attribute (three
different EROFS name-index prefixes, and one binary value) and requires
them back byte-for-byte, then requires the same set from the tar writer
over the same source tree so the two layer formats cannot drift.

erofs-utils independently agrees the encoding is right: fsck.erofs
--extract --xattrs finds security.capability on the inode and declines
only because applying it needs root.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
DirFS mirrors the backing tree into an in-memory overrides FS at
construction, and those overrides are what every type and mode lookup on
a dirFS resolves against — Lstat reads them directly, Stat takes Mode()
from them, and dirEntry.Type() (what fs.WalkDir hands the tar and EROFS
layer writers) reports them.

The seeding walk dispatched on `switch mode.Type()` with a
`case fs.ModeCharDevice`. Go reports a character device as
ModeDevice|ModeCharDevice — os.Lstat("/dev/null").Mode().Type() has both
bits — so that case never matched and every device node in a pre-existing
tree fell through to the default branch and was seeded as an empty
regular file. A layer built from such a tree ships /dev/null as a
zero-byte file, and Readnod on it fails.

Dispatch on the bit instead. The switch body moves to seedOverride so
the branch is reachable from a test without CAP_MKNOD: the FileInfo comes
from the host's own /dev/null, which is exactly the shape the walk sees.

Two things left as they were, both marked in the code: block devices,
FIFOs and sockets still seed as regular files because the memFS overrides
cannot represent them (apk only ever creates character devices), and
memFS.getNode resolves the final path component, so dirFS.Lstat on a
symlink still reports its target.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An EROFS directory inode's size is the byte length of its dirent blocks,
so `ls` printed a number that varied with the child count of whichever
single layer won the lookup — for a stack, never the merged directory
actually being listed. A two-layer image whose lower layer holds five
files in /usr/bin and whose upper holds one printed 41, describing
neither. Meanwhile the directories Stack synthesizes for parents no layer
contains have no inode at all and reported 0, so one listing mixed both
conventions.

Print 0 for every directory, which is what the 'tar tvf'-style format
this claims to follow does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
formatEntry reads mode, ownership and rdev off the *erofs.Stat behind
Sys(). If a future go-erofs returned a different type there, formatEntry
would quietly fall back to the plain fs.FileInfo and regress to 0/0
ownership with no setuid/setgid/sticky — the exact pair of bugs fixed in
ed0b6c4, reintroduced by a dependency bump with nothing failing.

Assert the type, and assert that the UID()/GID() accessors go-erofs
documents on the fs.FileInfo agree with the Stat fields the listing
actually reads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review noted the asymmetry: the mkfs.erofs path closes outfile with an
error check, while the go-erofs path used a deferred Close plus a Sync,
with no stated reason for the Sync.

Both paths hash the finished file, so the close has to happen before the
hash and its error has to be reported -- a deferred Close would swallow
a write error surfaced at close time. Sync was never load-bearing:
*os.File does no userspace buffering, so once Close returns a fresh Open
sees every byte. Drop it and close explicitly in both branches, with a
comment covering the whole block.

Also note at both call sites that the discarded second return of
CompressionLevel reports whether "level=" was given, not a parse
failure; 0 means "let mkfs.erofs choose".
Two silently-dropped errors flagged in review.

emitErofsEntry ignored the error from ListXattrs, so a failed xattr
lookup produced a file with no xattrs rather than a build failure.
apko's FullFS implementations keep xattrs in memory for every node they
know about, so an error there means the entry we just walked has gone
missing -- a bug worth surfacing.

That makes the dirFS path load-bearing, and nothing covered it: every
existing erofs test writes from a memFS, while real builds write from a
dirFS whose xattr lookups resolve against overrides seeded by walking
the backing tree. Add TestWriteErofs_DirFS to pin that down.

writeErofsRegularBytes checked Write's error but not its count. The
io.Writer contract requires a short write to report an error, but
go-erofs's writer is young enough that trusting that silently is not
worth it; compare against len(data) and fail with io.ErrShortWrite.
Review asked whether fsckBin is optional, and flagged a discarded error
from the lookup helper. It is optional, but nothing said so: three tests
ran fsck.erofs only "if err == nil" and passed quietly when erofs-utils
was absent.

Replace the ad-hoc lookups and lookFsckErofs with optionalFsckErofs,
which returns "" and logs when the binary is missing. The doc comment
states why it is a second opinion rather than the only check -- every
caller has already parsed the image with go-erofs -- and points at
TestWriteErofs_FsckErofs as the test that skips outright instead.

Also quote the interpolated paths in the roundtrip test's failure
messages, per review, so they are easier to pick out of output.
Review asked how the erofs: prefix differs from oci: pointed at an image
with erofs layers, and what happens when erofs: names something that is
not an erofs blob. Document it where the prefixes are defined: the
prefixes select how the path is read, not what the bytes turn out to be,
so erofs: on an OCI layout directory fails at parse time ("not a regular
file"), while erofs: on a non-erofs regular file parses and only fails
at mount time.

Review also asked for specifics behind the claim that a lowerdir-only
overlay over a single erofs mount "has been flaky across overlayfs
versions". We do not have anything concrete to point at, so drop that
sentence and keep only the reason that stands on its own: overlay buys
nothing with one lower and no upper.
go-erofs cannot write compressed EROFS images yet, so the new compound
format value routes compressed builds through `mkfs.erofs`
(erofs-utils). ALGO is one of zstd|lz4|lz4hc|deflate, with an optional
,level=N. Plain `--format=erofs` keeps using the pure-Go writer.

LayerFormat gains Base/Compressor/CompressionLevel methods; Valid is
extended to whitelist the compressor names mkfs.erofs supports and to
reject unknown or unparseable options (erofs+zstd,level=oops and
erofs+zstd,foo=bar were previously accepted). Existing dispatch in
build.go/layers.go/oci/image.go switches from Resolved() to Base() so
the compressor suffix doesn't break format-kind comparisons, and the
--format help on `apko build`/`apko publish` spells out the compound
form.

Because `rootfs.diff_ids` identifies the uncompressed layer payload,
the mkfs path runs mkfs.erofs a second time without `-z` to
materialize the uncompressed-equivalent image, hashes it for DiffID,
persists it for the lifetime of the returned `v1.Layer`, and surfaces
the digest via the spec's `org.erofs.uncompressed-digest` annotation.
Raw EROFS layers keep DiffID == Digest as before.

`apko erofs ls` wraps go-erofs's ErrNotImplemented (returned for
compressed images on the read side) with a friendly message pointing
the user at `apko erofs mount`, which decompresses via the kernel or
erofsfuse. Once go-erofs gains read-side compression, `apko erofs ls`
will work against compressed images without code changes.

Squashes the original compression commits b49c2a91, ae6c4c8b and
cdfb7014 so the feature sits on top of the uncompressed EROFS work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@smoser
smoser marked this pull request as draft August 17, 2026 19:15
smoser added a commit that referenced this pull request Aug 18, 2026
Add an EROFS layer payload format alongside the default gzipped tar, per
the spec at https://github.com/erofs/erofs-image-spec (spec.md on main;
all section references below are to it). Opt in with `--format=erofs` on
`apko build` / `apko publish`, or `format: erofs` in apko.yaml.

An EROFS layer is a complete, kernel-mountable read-only filesystem
image rather than a tarball, so a consumer can mount it instead of
unpacking it, and can seek into it rather than streaming it. The writer
is pure Go via github.com/erofs/go-erofs v0.3.1 -- no CGO, and
mkfs.erofs is not required to produce images.

What lands here:

 * `--format=erofs` produces one raw `application/vnd.erofs` layer. For
   the raw media type the DiffID and the descriptor Digest are the same
   value, the SHA-256 of the blob bytes (§5.2).
 * `erofs` is declared in `os.features` in both places §5.4 requires it:
   the image config and the index platform descriptor. §8.2 item 1 has
   consumers refuse an image whose os.features they do not implement, so
   both halves matter -- a consumer filtering on the index selects a
   manifest before fetching any config.
 * The single layer carries no `org.erofs.role` annotation, which §3.8
   rule 1 allows for the final layer.
 * Images are byte-reproducible. The EROFS build time is always stamped
   into the superblock from SOURCE_DATE_EPOCH; go-erofs would otherwise
   substitute time.Now() and make the digest depend on the wall clock.
   A zero or pre-epoch timestamp clamps to epoch 0, since a zero
   time.Time has a negative Unix seconds value.
 * Extended attributes and the setuid/setgid/sticky bits survive the
   write. The latter needs an explicit Chmod after Mkdir/Mknod/Create,
   because the pinned go-erofs predates erofs/go-erofs#41; that fix
   merged 2026-08-02 and v0.3.1 was tagged 2026-07-21. The same bug's
   read half is unfixed in v0.3.1 too, so FileInfo.Mode() from that
   reader misreports those bits and callers must read *erofs.Stat.Mode
   off Sys() instead.
 * Hardlinks are materialized as independent copies: go-erofs has no API
   to point two names at one inode, and none is proposed. Each link
   therefore costs another full copy of the data, and st_nlink/st_ino
   identity is lost. §3.7 requires materializing or failing only for
   *cross-layer* hardlinks; for same-layer links the spec is silent, so
   this is conformant but not something it blesses. Documented in
   docs/erofs.md.
 * `apko erofs ls` prints a `tar tvf`-style listing of an EROFS blob or
   an OCI layout containing EROFS layers. It reads blobs directly with
   go-erofs, so it needs no root, no kernel module and no FUSE, and
   works on any platform. For a multi-layer image it merges the layers
   in user space using the overlayfs-native deletion encoding §3.6
   mandates -- a whiteout is a character device with rdev 0, an opaque
   directory sets trusted.overlay.opaque="y" -- and not the `.wh.`
   filename convention of tar layers, which §8.1 item 9 forbids in EROFS
   images. A single-layer image is listed as-is, because the kernel
   would mount it directly and apply no overlay semantics.
 * docs/erofs.md walks through building, verifying, inspecting, mounting
   and pulling an EROFS image with widely available tools.
 * CI installs erofs-utils, so the fsck.erofs cross-checks in the writer
   tests actually execute rather than degrading to a log line.

One behavior change reaches non-EROFS builds, and is worth calling out
because it is digest-visible: os.features is now copied from an image's
config onto its index platform descriptor for every format. A tar build
on top of a base image whose config already carries os.features will
therefore surface it on the index descriptor, changing that index's
digest. This is what §5.4 asks for, and it is a no-op for a base image
that declares no features, but it is not limited to erofs.

Scope: a single EROFS layer. Combining `layering` with `format: erofs`
is rejected during config validation rather than silently producing one
layer. Multi-layer splitting, `apko erofs mount`/`umount`, and several
smaller follow-ups are tracked in #2408. Compression
(`application/vnd.erofs+zstd`) is separate, in #2406; until it lands,
apko writes raw images only.

EROFS support is experimental. The spec is still in its draft phase --
no tagged release, and it states that media-type strings, annotation
keys and the binary chunk-index layout may change before the first
stable one -- so treat images built today accordingly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
smoser added a commit that referenced this pull request Aug 19, 2026
erofs: first batch of #2408 follow-ups

The #2408 follow-ups that touch merged `main` only, so none of them wait
on the descoped mount/umount or layering code coming back. Two behavior
changes, two tests, two rewords.

Both overlay tombstone checks now fail closed

`isOpaqueDir` returned false whenever `statOn` failed, so a layer whose
xattr region could not be read was treated as one that hides nothing.
That fails *open*: the entries an opaque directory was meant to hide
from lower layers get listed anyway, and nothing tells the caller the
answer was a guess. Layers come from an OCI image that may be untrusted,
so a damaged or hostile one should not be able to widen the merged view
by being unreadable. It now returns `(bool, error)` and propagates
anything that is not `fs.ErrNotExist` -- absent is the one benign case,
since a layer that lacks the directory genuinely is not opaque there.

`isWhiteout`, a screen above it, had the same shape: false on an
`e.Info()` error. That failure mode is milder -- an unreadable char
device is treated as live, so it occupies the name rather than leaking
lower entries -- but it is still a guess about what the merged view
shows, made from an inode that could not be read. It gets the same
`(bool, error)` treatment, so the two checks no longer sit next to each
other with opposite error philosophies.

Both callers, `lookup` and `mergeDir`, already returned errors.

The opacity test puts the unreadable layer in the *middle* of three on
purpose: that is the only position where `isOpaqueDir` is the first
thing to fail. With the broken layer on top, `lookup`'s ancestor stat
raises first and the fixture would pass either way. Against the old code
it reports `ReadDir(etc) = [foo secret], <nil>` -- the lower layer's
`secret` leaking into the merged view. The whiteout test fails the
matching way, listing the char device as a live `secret`.

`application/vnd.erofs+zstd` gets an accurate error

A compressed layer is a spec-legal EROFS image apko cannot read yet, but
it hit the mediaType check and was told the command "only handles EROFS
images". Now any `application/vnd.erofs+<codec>` names its codec and
points at #2406.

Matching on the suffix rather than adding a media-type constant is
deliberate: the draft spec's set of codecs isn't something apko should
pin down in `pkg/build/types` to produce one error message.

`os.features` propagation from a base image config, pinned

`generateIndexWithMediaType` copies the finished config's `os.features`
onto the index platform descriptor for every format, and
`BuildImageFromLayers` DeepCopies the base image's config. So a plain
*tar* build on a base image that already declares `os.features` surfaces
them on the index descriptor, changing that index's digest. Spec §5.4
asks for this and #2249's squash message calls it out, but the test
added there covered only the empty-base case.

Also `require.Empty` -> `require.Nil` for the "tar builds declare
nothing" assertion, which is what it means to check -- that the
propagation doesn't invent an empty slice. Harmless either way:
`os.features` carries `omitempty` in go-containerregistry, so an
empty-but-present slice can't change a digest.

Two claims softened to match reality

§3.7 was cited too strongly. Its materialize-or-fail rule governs
*cross-layer* hardlinks; for links within one layer the spec is silent,
so apko materializing them is conformant but not blessed by that
section. #2249's squash message has this right; `pkg/build/erofs.go` and
`docs/erofs.md` did not.

`docs/erofs.md` promised kernel parity for `apko erofs ls` ("the merged
view the kernel would assemble"). It approximates it, and diverges in
two corners: a middle-layer whiteout at a directory's own name doesn't
cut off lower layers when a higher layer recreates the directory, and
opacity isn't inherited by descendant directories. Both need 2+ layers,
so neither is reachable for an image apko produces today. The doc now
names them and links #2408, where the algorithm fix (a per-directory
layer horizon) is tracked.

`go test ./...` passes, `golangci-lint run -n` reports 0 issues,
`gofmt -l` is clean.

Refs #2408

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant