Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

An Alpine userland

The alpine feature provisions an Alpine Linux root filesystem: it resolves a package set from an apk repository and installs it, so a sandbox can build or run software from a userland the library assembles for it. It speaks to the archive directly — a pure-Rust replacement for apk add --root — and runs each package’s install scripts inside a cage of its own.

use ferroday_cage::provision::{self, alpine::Alpine};

fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut alpine = Alpine::builder("v3.23")
    .include(["alpine-base", "build-base"])
    .cache_dir("/var/cache/fcage/apk")
    .build()?;
provision::ensure("/var/lib/machines/alpine", &mut alpine)?;
Ok(())
}

Alpine implements Provisioner, so provision::ensure publishes the result atomically: the destination directory either does not exist or holds a complete, configured rootfs.

The surface mirrors the Debian layer’s closely enough that a consumer of one recognizes the other — the same builder setters, the same resolve/available/plan/pin pair of read and replay modes, the same layered build, and an observe() sink over AlpineEvent carrying the same progress. What differs is listed under Where the two layers differ, and every difference is one the archives themselves make.

The musl-build example works this into a consumer: it provisions a root with the toolchain and links a C project statically against musl, which is the thing an Alpine root is most often reached for.

How it works

A bootstrap runs in stages:

  1. The signed index. It fetches each repository’s APKINDEX.tar.gz, verifies the RSA signature against the repository’s keys, and parses the records. There is no release document above the index: the index is the signed thing.
  2. The package set. It resolves the closure over what include names — dependencies, virtual providers, and install_if conditions to a fixed point — against the merged records of every configured repository.
  3. Download and verify. It downloads each package and binds it to the index that named it before anything is unpacked. See Trust.
  4. Extraction and scripts. It lays every package’s files down in one wave and runs the install scripts in a second, both in dependency order, then fires the triggers whose watched directories the install changed. The scripts run inside a cage rooted at the staging tree, so they need no privilege on the host. When they have run, the rootfs is published.

The finished root carries /lib/apk/db/installed, /etc/apk/world, /etc/apk/repositories, /etc/apk/arch and the repositories’ trusted keys, so an apk inside it reads back the state this bootstrap wrote and can go on installing. world is what include named, which is what a later apk treats as wanted; a bootstrap installing a plan writes every package the plan names, since a plan is the whole closure and records no seeds.

Selecting packages

The install set is exactly what include names, closed over its dependencies, minus what exclude removes. There is no base system underneath it.

That is the sharpest practical difference from the Debian layer, and it follows from the archive: apk records no priority for a package, so there is no band to seed from and nothing corresponds to base_priority. A root that names only a compiler has only a compiler and its dependencies — no shell, no apk binary, no /etc/passwd — because nothing in that closure happens to require one.

use ferroday_cage::provision::alpine::Alpine;

fn main() -> Result<(), Box<dyn std::error::Error>> {
// A conventional minimal system, which is a package like any other.
let _system = Alpine::builder("v3.23").include(["alpine-base"]).build()?;

// A build root that is only what a build needs. `busybox-binsh` is named
// because it ships /bin/sh, which the toolchain's closure does not require.
let _build = Alpine::builder("v3.23")
    .include(["build-base", "busybox-binsh"])
    .build()?;
Ok(())
}

alpine-base is the package Alpine’s own minimal images are built from, and naming it gives the familiar system: busybox, apk-tools, the layout packages, and the signing keys. Naming nothing at all is refused rather than resolving an empty root.

exclude removes a package from the closure. An excluded package that something in the closure genuinely requires fails the resolve rather than producing a broken root.

Repositories

A bootstrap resolves against one or more repositories. The builder’s mirror, components and keys setters configure the primary — a single-mirror bootstrap needs no repository at all — and repository merges in additional sources, each with its own KeySet, so multiplying repositories repeats the authenticity check per source rather than weakening it.

Resolution is highest-version-wins across the merged repositories. An exact tie resolves to the later repository, which is what makes an additional repository an overlay: a package it republishes at the version the primary already offers is the one that wins.

Two layouts, one type

Alpine publishes <release>/<component>/<architecture>; postmarketOS publishes <release>/<architecture>, with no component level at all. A repository that names no components takes the second layout, and that is the whole of the difference between them:

use ferroday_cage::provision::{self, alpine::{Alpine, KeySet, Repository}};

fn main() -> Result<(), Box<dyn std::error::Error>> {
let postmarketos = Repository::builder("v26.06")
    .mirror("http://mirror.postmarketos.org/postmarketos")
    .keys(KeySet::postmarketos())
    .build()?;

let mut alpine = Alpine::builder("v3.23")
    .repository(postmarketos)
    .include(["alpine-base", "postmarketos-baselayout"])
    .build()?;
provision::ensure("/var/lib/machines/pmos", &mut alpine)?;
Ok(())
}

postmarketOS is an overlay rather than a distribution — a postmarketOS root is Alpine’s repositories plus its own — so it is configured as an additional repository and never as the primary. Its release cycle is its own: v26.06 against Alpine’s v3.23.

Both of its ways of overriding an Alpine package fall out of the merge rather than needing anything of their own. It republishes akms at 99990.3.0-r1, a version no Alpine release will reach, so the higher version wins wherever the repository sits in the order; and postmarketos-base provides alpine-base=1000-r0, so asking for alpine-base with the overlay configured resolves postmarketOS’s base system instead of Alpine’s. Both are what apk does with the same two repositories configured.

Where a package’s bytes come from

An apk repository publishes one index per component, and a package is addressed relative to the directory its own index sits in. So the unit a resolution attributes a package to is the index, not the repository: a repository configured with main and community contributes two, and plan.indexes records each one’s mirror, component, digest, and the key whose signature over it verified.

use ferroday_cage::provision::alpine::Alpine;

fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut alpine = Alpine::builder("v3.23").include(["alpine-base"]).build()?;
let plan = alpine.resolve()?;
for package in &plan.packages {
    let index = &plan.indexes[package.index];
    println!(
        "{} {} <- {} ({})",
        package.name,
        package.version,
        index.mirror,
        index.component.as_deref().unwrap_or("no component"),
    );
}
Ok(())
}

The package cache

cache_dir keeps downloaded packages for reuse across bootstraps. Every entry is re-verified in full on every read — signature segment, the binding to the plan, and datahash over the file tree — and an entry that does not read back as the package the plan names is downloaded again rather than refused.

It has to work that way, and the reason is a real difference from the Debian pool: an apk index publishes no digest of the whole .apk file, so there is no content address to name a cache entry by. Entries are keyed by published file name instead, and the full re-verification is what makes that safe.

A package is never held in memory: the bytes are written to a staging file as they arrive, and installing reads that file back.

Resolving, replaying, and pinning

resolve reports what a bootstrap would install without installing it, and a full bootstrap emits the same plan through AlpineEvent::Resolved before the first download. plan hands a resolved Plan back to the bootstrap, which then fetches exactly what it records and never reads an index; pin instead constrains a fresh resolution to the versions a plan recorded. The three behave exactly as their Debian counterparts do, including what a plan refuses to compose with and the trust model a plan carries, so that chapter is the reference for all of it.

One refusal is this layer’s own, and it follows from where an apk package lives. A package is fetched from the directory of the index that named it, so the correspondence between a plan’s indexes and the configured repositories is positional: index i of the plan is served by whichever repository publishes the ith index. build therefore refuses a plan that records more indexes than the configuration publishes, and one whose index i is not the release and component the configured repository publishes there — both of which would otherwise compose URLs no mirror serves and fail as an exhausted mirror walk. The mirror a plan records is deliberately not compared: a plan carried to another configuration fetches over that configuration’s mirrors, which is what makes it portable.

A plan is written and read as a document with to_document and parse_document. The syntax is the same field-and-stanza form the Debian plan uses — it reads in a terminal and diffs in a review — but the vocabulary and the format version are this layer’s, and the two are separate formats:

Format: ferroday-cage-alpine-plan 1
Release: v3.23
Architecture: x86_64

Index: 0
Mirror: http://dl-cdn.alpinelinux.org/alpine
Release: v3.23
Component: main
SHA256: 4c1d...
Signed-By: alpine-devel@lists.alpinelinux.org-6165ee59.rsa.pub
Description: v3.23.5-112-gd78b27bd62b

Package: musl
Version: 1.2.5-r23
Architecture: x86_64
Control-Identity: Q1G2xQQm08BlbpxaVnpaWWAcf0MHo=
Index: 0
Size: 405504
Installed-Size: 622592

An index’s stanza records what an apk repository can be pinned to and no more. There is no Date and no Valid-Until, because the format carries neither; Description is the archive’s own build string, recorded as a fact about the publication rather than read for meaning. Component is absent rather than empty for the componentless layout, a repository that publishes no component and one that publishes an unnamed one not being the same repository.

Size, Installed-Size and Origin are optional. Origin names the source package a package was split from and is absent for the common case of a source that shares its name, which is how the index itself says it — the same reading the Debian document’s Source takes.

The rest behaves as the Debian document does, and for the same reasons: a field a reader does not know is carried and re-emitted after the ones it does, both lists are already ordered so two renderings are byte-identical, and no line ends in whitespace.

Layered build roots

base_layer, resolve_layer and stage_layer work as the Debian layer’s do: provision a shared base once, then stage each increment over it into a disposable overlay upper, and root a cage on overlay_rootfs with the base as the lower.

use ferroday_cage::Cage;
use ferroday_cage::provision::{self, alpine::Alpine};

fn main() -> Result<(), Box<dyn std::error::Error>> {
let base = "/var/cache/build/base-alpine";
provision::ensure(
    base,
    &mut Alpine::builder("v3.23").include(["alpine-base", "build-base"]).build()?,
)?;

let layer = Alpine::builder("v3.23")
    .base_layer(base)
    .include(["openssl-dev"])
    .build()?
    .stage_layer("/var/cache/build/component-upper")?;

let status = Cage::builder()
    .overlay_rootfs(base, layer.path())
    .command("/usr/bin/make")
    .build()?
    .run()?;
let _ = status;

// Dropping the layer discards the increment; the base stays pristine.
drop(layer);
Ok(())
}

Two things this layer has to do for itself that dpkg does for the Debian one, because nothing here runs a package manager inside the root:

The database in the upper describes the merged root. An overlay unions files, so the installed an increment writes shadows the base’s outright rather than adding to it. The base’s records are read and carried through byte for byte — they may be records apk wrote — with the increment’s sorted in among them, and /etc/apk/world, /etc/apk/repositories, triggers and the script archive are merged the same way. apk audit --system inside the merged root passes, which is the check that the file describes the tree.

The base’s own triggers fire. A trigger belongs to the installed root rather than to the transaction, so a directory the increment creates runs the base’s watcher for it — busybox relinking applets, ca-certificates rebuilding its bundle.

The increment resolves against the base’s installed set read at the versions it records, not merely at its names, because apk spells a soname so:libc.musl-x86_64.so.1=1.2.5-r23. A dependency the base’s version cannot meet is reported as the upgrade it would be, naming both versions and the entry that forced it: an increment installs over a base rather than replacing part of one.

A file the increment ships over one the base owns simply wins in the merged view, since the base is read-only and the shadow lives in the disposable upper. A collision within the increment is refused exactly as a full bootstrap refuses one.

Trust

Authenticity comes from the archive’s own signatures, not the transport, so packages are fetched over plain HTTP by default. Both archives this layer is written against serve it, and an https:// mirror needs a transport of the caller’s own supplied through fetcher.

The chain has three links, and it is worth stating plainly because two of them rest on SHA-1:

  1. The index is signed with RSA-4096 over SHA-1 (.SIGN.RSA.), verified against the repository’s key set.
  2. The index’s C: field identifies each package’s control segment by SHA-1 of its compressed bytes.
  3. .PKGINFO’s datahash binds the file tree to that control segment by SHA-256.

So the payload is bound by a strong digest and the two links above it are not. There is nothing stronger available in the published v2 format, and verifying less than apk does would be worse rather than safer, so the layer verifies exactly this and offers no knob: a knob would be a choice between verifying and not verifying. Forging a package against this chain requires a chosen-prefix SHA-1 collision whose second preimage is a well-formed gzip of a tar containing a .PKGINFO — expensive, but no longer theoretical. The question is revisited when Alpine publishes apk v3 indexes, which no release does today.

What binds a package to what was asked for

A signature says that some package was signed. Without more, a mirror could answer a request for musl-1.2.5-r23 with a validly signed musl-1.2.5-r0. So before anything is unpacked, the layer recomputes the control identity over the bytes that arrived and compares it to the C: the verified index recorded, and compares the package’s own pkgname, pkgver and arch to what was planned.

A package’s own .SIGN member is read but not verified, and this is apk’s rule rather than a relaxation of it. The identity above already fixes exactly the bytes such a signature would cover, while the signature itself sits in the segment before them, which no digest in the format accounts for — so whoever served the file could replace it without anything downstream differing. It is also the only rule under which postmarketOS can be installed at all: its index is signed by build.postmarketos.org, the key postmarketos-keys ships as the trust anchor, while each of its packages is signed by whichever per-builder key made it. apk installs them from the index and refuses the same file offered as a bare apk add ./file.apk, where no index vouches for it.

The keyring

The bundled key set is the whole trust anchor for the repository it is attached to, and it differs from an OpenPGP keyring in a way worth knowing: an Alpine signing key is a bare RSA public key. It carries no expiry, no self-signature and no revocation, so none of the freshness and validity rules the Debian layer enforces on a certificate has an analogue here. What the set holds is what is trusted, and the only way to withdraw a key is to stop shipping it — which means a bundled set is refreshed by a release of this crate. A caller who needs a different answer sooner assembles a set with KeySet::insert.

The bundle is partitioned by architecture, because Alpine is: alpine-keys installs three keys on x86_64, two entirely different ones on aarch64, two more on armv7, and so on — eighteen keys across ten architectures, with only the two oldest serving more than one. An x86_64 signature does not verify on an aarch64 root under apk, and it does not here. KeySet::alpine takes the architecture for that reason, and the bundle mirrors that package’s own key tree rather than selecting from it.

A private repository signs its index

There is no equivalent of the Debian layer’s trust_unsigned, and none is planned. apk has no unsigned mode in practice, so admitting one would be this layer’s invention rather than interoperability with anything.

The route for a caller with a repository of their own is the one apk itself takes: sign the index with their own key, as abuild-sign does, and pass that key through the repository’s keys. Supplying a transport over file:// is not on its own enough — an unsigned index fails verification wherever it comes from.

use ferroday_cage::provision::alpine::{Alpine, KeySet, Repository};

fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut keys = KeySet::new();
keys.insert("build@example.com-64f0c0a1.rsa.pub", &std::fs::read_to_string("build.rsa.pub")?)?;

let local = Repository::builder("v3.23")
    .mirror("file:///srv/my-apks")
    .keys(keys)
    .build()?;

let _alpine = Alpine::builder("v3.23")
    .repository(local)
    .include(["my-package"])
    .build()?;
Ok(())
}

The key’s name matters as much as its bytes: a signature names its key by exactly the file name the repository publishes it under, so a set holding the right key under the wrong name verifies nothing.

No freshness policy

There is no equivalent of allow_stale_release either, because there is nothing to be stale against. The apk index carries no validity window and no date — its DESCRIPTION member is a build string, not a signed expiry — so a freshness rule would be this layer’s fiction rather than the archive’s policy.

The same reasoning is why the index is never cached. A cache would need a staleness rule the format does not express. cache_dir holds packages, which are identified by digests the index states, and indexes are re-fetched.

Ownership

The identity map decides the ownership the finished rootfs carries, and Alpine asks less of it than Debian does.

The default single-identity map maps the calling user to root and nothing else, and the archive is content with that: every file of every package in an alpine-base closure is owned by root, so extraction needs none of the ownership emulation the Debian bootstrap installs stubs for. The one place it shows is that alpine-baselayout’s install script gives /etc/shadow to the shadow group, which the single map cannot represent. That call fails, harmlessly and exactly as it fails under apk in the same situation — Alpine’s scripts tolerate it — and the resulting root is a working userland with that one group ownership flattened.

A rootfs that is itself the product wants real ownership, and identity_map selects it. Under IdentityMap::Subordinate the system ids genuinely exist and that chown succeeds.

Extract-only and foreign architectures

The bootstrap targets the host architecture by default; architecture selects another. A full bootstrap runs that architecture’s install scripts and so needs a registered qemu-user binfmt handler (see Host requirements), which the bootstrap checks for before downloading anything.

Where the foreign binaries cannot run, extract_only lays the files down and still registers them. That is a real difference from the Debian layer, where an extract-only root has no configured dpkg database: an apk installed record is a file format rather than a program’s output, so writing it needs nothing executed. An extract-only Alpine root for a foreign architecture therefore knows exactly what it holds, and an apk running there later reads it back.

Where the two layers differ

Everything in this table is a difference the archives make, not a gap:

DebianAlpine
Base setA priority band, seeded by base_priorityNone: apk records no priority. The set is exactly include closed over its dependencies
Signed thingInRelease, which carries the indexes’ digestsThe index itself; there is nothing above it
Digest chainSHA-256 throughoutSHA-1 to the control segment, SHA-256 to the file tree
Trust anchorAn OpenPGP keyring, with expiry and revocationA set of bare RSA keys, with neither, partitioned by architecture
Unsigned modetrust_unsigned, for a local poolNone; a private repository signs its index
FreshnessValid-Until, and allow_stale_release to relax itNone; the format expresses no validity window
Package identityA SHA-256 of the whole file, from the indexA SHA-1 of the control segment, plus datahash over the tree
Extract-onlyFiles only; no configured databaseFiles and the installed database
Configurationdpkg runs in a cageThe install scripts run directly, in dependency order

The command line

fcage --provision-alpine RELEASE bootstraps the --rootfs directory before launching. Given without a command, it provisions and exits; the already-provisioned case is fast, so the flag can stay in an invocation permanently.

$ fcage --rootfs ./alpine --provision-alpine v3.23 \
        --alpine-include build-base,busybox-binsh --alpine-cache ./apk-cache
$ fcage --rootfs ./alpine /usr/bin/cc --version

The --alpine-arch, --alpine-mirror, --alpine-mirror-fallback, --alpine-components, --alpine-include, --alpine-exclude, --alpine-extract-only, --alpine-cache, --alpine-keys, --alpine-pre-configure-overlay, --alpine-identity-map and --alpine-repository options map onto the builder. --alpine-include, --alpine-exclude and --alpine-components take comma-separated lists and may be repeated.

--alpine-keys takes alpine, postmarketos, or a directory read the way apk reads /etc/apk/keys: every file in it is a PEM public key held under its own file name. A repository added with --alpine-repository must name its keys the same way, and naming no components selects the shallower layout:

$ fcage --rootfs ./pmos --provision-alpine v3.23 \
        --alpine-include alpine-base,postmarketos-baselayout \
        --alpine-repository 'release=v26.06 keys=postmarketos
                             mirror=http://mirror.postmarketos.org/postmarketos'