Files
firmware/bin/build-payload.py
Oleksandr ShyshatskyiandClaude Opus 5 f8b925e48f Replace opkg with apk, and sign the feed
docs/notes/85-packaging.md chose opkg in 2026-08 on one argument: every package
is cross-built here anyway, so the winner is whichever format is cheapest to
PRODUCE -- and .ipk had an upstream tool where .apk meant abuild, a keypair and
an Alpine host. Two lines of that comparison were wrong.

apk mkpkg is an applet of apk itself: -I key:value metadata, -F directory, no
keypair, no abuild, no Alpine host. The "making a package" row described
apk-tools 2. And the on-device client was counted a wash when it is better than
one -- openssl and zlib were already cross-built for CPython, and libarchive
existed in this tree only for opkg, so adopting apk REMOVES a cross-build.

What made it possible at all is that apk resolves its database relative to
--root and uses the same root as the file destination, while / here is a
read-only squashfs -- measured: neither /lib/apk nor /etc/apk can be created.
pkgs/3rdparty/apk-tools/prefix.patch splits the two. Upstream already carries
root_fd and dest_fd separately and merely aliases them in context.c, so the
patch defaults the database root to $MODDIR and leaves files extracting at /.
That is opkg's compile-time VARDIR plus `dest root /`, in apk's spelling.

THE FEED IS SIGNED NOW, which it never was: opkg signed the index and
bin/build-packages.sh signed nothing, so nothing was verified on a printer.
apk verifies with raw PEM public keys, not X.509 -- measured on the machine,
where every certificate directory is empty. ./bin/apk-keygen.sh makes an RSA
pair, APK_SIGN_KEY in config.env points at the private half, and the public
half both seeds the USB stick (so the first install can verify) and ships in
anvil-core (so the printer keeps trusting). An empty APK_SIGN_KEY still builds
an unsigned feed, deliberately: `make packages` has to run on a bare checkout
or the packaging lane stops being a CI gate.

WHAT IT COST is a carried patch, against an internal with one consumer, on a
branch mid v2->v3 transition, on an architecture with no upstream apk port to
share the rebasing. That is the standing risk and it does not go away.

Four bugs this shook out, all silent, all found by a gate rather than by
reading:

  * The prune deleted seventeen packages it had just built. The all -> noarch
    mapping went into the emitter and not into pkg_pkgfile, so the prune looked
    for a name nothing wrote. `make packages` exited 0.
  * Whitespace-separated Depends dropped nineteen packages. opkg's pkg_parse.c
    splits that field on ',' and nothing else, so the whole list became one
    dependency whose name contained spaces and resolved to nothing -- and opkg
    neither warned nor failed. `make build` shipped a payload with no Tornado
    in it. test_the_payload_is_dependency_closed is what catches that now.
  * Ownership was recorded as `nobody`. apk resolves an owner to a NAME against
    <--root>/etc/passwd and the build lane runs --user with no passwd entry, so
    every file in every package would have installed as uid 65534, with no
    error at build or install time. build-packages.sh writes a two-line
    synthetic passwd naming the build user root and points --root at it.
  * Four recipes carried versions apk cannot parse -- two spelled a commit
    +git<sha> where the grammar's hash slot is ~, x264 had a snapshot name
    whose -2245 reads as a malformed release, and helixscreen had
    0.99.115.creator5.3 where apk allows one trailing letter and then only a
    suffix from a closed set of nine words. All four were valid opkg versions,
    which is why nothing had noticed. pkg_version_ok gates every recipe now.

A v3 .apk begins with ADBd magic and is not a tar stream, so there is no apk
equivalent of the `ar -p | gunzip | tar` that hand-unpacked anvil-opkg: you
cannot open a package without a package manager. bin/build-payload.py puts the
cross-built binary on the stick at /mnt/apk instead. It is the same build
anvil-apk-tools contains, that package installs normally, and the bootstrap
copy never lands.

Two gates retired rather than moved. apk's installed database is plain text
(F: sets a directory, R: names a file in it), so the payload-ownership check
reads one file where it used to glob info/*.list. And it carries no clock at
all -- apk records no install time and mkpkg sets no build-time -- so the
Installed-Time normalisation case-build-payload.sh did on every build is gone,
and the test asserts the ABSENCE of a t: line instead. Clock-free by
construction rather than by repair.

Also removed: PKG_SECTION, which 45 recipes declared and nothing read once
apk's schema had no field for it, and the `make apk-host` target -- the
packager is built on demand by bin/build-packages.sh and stamped on the pin,
the way pkg_toolchain and pkg_buildpython already work.

Verified from a cold work/host and work/packages: 47 packages built and signed,
`make build` installs 35 and verifies them against the shipped public key,
`make qa-static` 195 passed, `make qa-replica` 135 passed -- the printer
installing off a simulated USB stick and booting. The installed set was checked
against the declared closure of the eleven roots: 35 expected, 35 installed,
nothing extra and nothing missing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rbKEyLXrzSE6wm3iNApVp
2026-08-31 14:32:07 +03:00

138 lines
5.6 KiB
Python
Executable File

#!/usr/bin/env python3
"""Build $MODDIR by installing the feed inside the printer replica.
./bin/build-payload.py <root-package> [<root-package> ...]
Writes the installed tree to $PAYLOAD_DIR, replacing whatever was there.
The printer's OWN apk installs onto the printer's own filesystem, under
qemu-mipsel, and the tree is tarred back out -- so maintainer scripts run
where they will run on a machine, and the install that ships is the install
that was tested.
WHAT IT COSTS. A privileged container and the printer image, which is why
`make build` has its own docker lane. The feed itself
(bin/build-packages.sh) still needs nothing but a checkout.
"""
import os
import subprocess
import sys
from pathlib import Path
for _p in Path(__file__).resolve().parents:
if (_p / "bin" / "common.sh").is_file():
ROOT = _p
sys.path.insert(0, str(_p / "tools" / "replica"))
break
from ffsim import cli # noqa: E402
from ffsim.config import Config # noqa: E402
from ffsim.replica import Replica # noqa: E402
CASE = "tools/replica/printer/case-build-payload.sh"
def _sh(var, default=""):
"""One value out of bin/common.sh, asked of the shell that defines it.
Re-deriving IPK_ARCH or PKG_FEED here would be a second place for them to
be wrong, and the two would agree until somebody edited one.
"""
out = subprocess.run(
["bash", "-c", '. "%s/bin/common.sh" >/dev/null 2>&1; printf "%%s" "${%s:-%s}"'
% (ROOT, var, default)],
capture_output=True, text=True, cwd=str(ROOT))
return out.stdout.strip()
def run():
roots = sys.argv[1:]
if not roots:
raise SystemExit("usage: build-payload.py <root-package> ...")
feed = Path(_sh("PKG_FEED", str(ROOT / "work" / "packages")))
payload_dir = Path(_sh("PAYLOAD_DIR"))
if not payload_dir.is_absolute() and not str(payload_dir).startswith(str(ROOT)):
payload_dir = ROOT / payload_dir
# Asked of common.sh rather than spelled here -- the same rule the rest of
# this file follows for PKG_FEED and IPK_ARCH.
ext = _sh("PKG_EXT", "apk")
pkgs = sorted(feed.glob("*." + ext))
if not pkgs:
raise SystemExit("no feed at %s -- run ./bin/build-packages.sh" % feed)
index = feed / _sh("PKG_INDEX_NAME", "anvil.adb")
if not index.is_file():
raise SystemExit("no %s index at %s" % (index.name, feed))
# Everything the printer's package manager needs, on the simulated stick at
# /mnt: the archives and the index that names them. `packages` is the
# replica's own mechanism for this and copies each to /mnt/<name>.
packages = {p.name: str(p) for p in pkgs}
packages[index.name] = str(index)
# THE BOOTSTRAP BINARY. The package manager is one of the packages, and a
# v3 .apk is an ADB stream rather than a tar -- `tar` refuses it -- so
# there is no way to open the first package without an apk already
# present. The cross-built binary rides along at /mnt/apk.
#
# It is the same build anvil-apk-tools contains, and that package installs
# normally, so the tree that ships carries the packaged copy with a
# database row to match. This one is scaffolding and never lands.
boot = ROOT / "work" / "pkg" / "apk-tools" / "bin" / "apk"
if not boot.is_file():
raise SystemExit(
"no bootstrap apk at %s -- run `make packages PKG=apk-tools`" % boot)
packages["apk"] = str(boot)
# THE PUBLIC KEY HAS TO ARRIVE BEFORE THE PACKAGES IT VERIFIES. It ships
# in anvil-core so a printer keeps it, but anvil-core is one of the
# packages being installed -- so at bootstrap there is nowhere for apk to
# have read it from yet. It rides on the stick and the case copies it into
# the trust directory before the first `apk add`.
signkey = _sh("APK_SIGN_KEY")
if signkey and Path(signkey + ".pub").is_file():
packages["anvil.pub"] = signkey + ".pub"
config = Config.load()
replica = Replica.start(config)
out = ROOT / "work" / ".payload-out"
replica.run_case(
ROOT / CASE, packages=packages, out_dir=out, on_output=_echo,
env={
"MOD_ROOTS": " ".join(roots),
"IPK_ARCH": _sh("IPK_ARCH", "mipsel_xburst2"),
"SOURCE_DATE_EPOCH": os.environ.get("SOURCE_DATE_EPOCH", "1"),
# The case compiles the s6-rc database and stamps it db-$MOD_VER.
# Asked of common.sh like every other value here, so the database
# name and the package versions cannot come from two clocks.
"MOD_VER": _sh("MOD_VER"),
})
tar = out / "payload.tar"
if not tar.is_file():
raise SystemExit("the replica produced no payload.tar")
# Replace, never merge: a surviving file from a previous build is one no
# package accounts for, and the manifest would ship it as though it did.
if payload_dir.exists():
subprocess.run(["rm", "-rf", str(payload_dir)], check=True)
payload_dir.mkdir(parents=True)
# --strip-components=1 drops the leading anvil/ the case tarred with.
# No -p and no --same-owner: the archive carries the printer's root, this
# runs as the build user, and runFirmwareExe.sh extracts as root on the
# machine -- ownership in the payload means nothing.
subprocess.run(["tar", "-xf", str(tar), "-C", str(payload_dir),
"--strip-components=1"], check=True)
subprocess.run(["rm", "-rf", str(out)], check=True)
_echo("payload: %s" % payload_dir)
def _echo(text):
sys.stdout.write(str(text).rstrip("\n") + "\n")
sys.stdout.flush()
if __name__ == "__main__":
sys.exit(cli.main(run))