mirror of
https://github.com/Klipper4FlashForge/firmware.git
synced 2026-10-22 05:12:16 -05:00
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
257 lines
15 KiB
Markdown
257 lines
15 KiB
Markdown
# Testing: how we know it does not brick
|
|
|
|
**`qa/` is the suite.** One framework (pytest), two lanes, and a result per
|
|
assertion rather than per case script.
|
|
|
|
```sh
|
|
make qa # both lanes
|
|
make qa-static # seconds, any machine, any clone -- no docker
|
|
make qa-replica # the gates that decide whether a package bricks a printer
|
|
```
|
|
|
|
Selection is pytest's: `-k nginx`, `-m static`, or a single test id.
|
|
[qa-migration.md](qa-migration.md) is the design and the history.
|
|
|
|
## The lanes
|
|
|
|
| Lane | Needs | What it asks |
|
|
|---|---|---|
|
|
| `static` | the checkout; the feed for part of it | every shipped script parses and is free of bashisms, every name resolves, the recipe layout holds, and -- when `work/packages` exists -- the `.apk`s are what we mean to ship |
|
|
| `replica` | docker + qemu + the firmware | what the printer *does* -- on a machine the real `app_startup.sh` installed the real package onto |
|
|
|
|
The replica lane needs a built package in `work/out/*.tgz` (`make build`) and
|
|
`PRINTER_IMAGE` in `test.env` — that image *is* the replica, and there is no
|
|
other way to get one. `test.env.example` carries the published tag; `make
|
|
printer-image` builds your own from public firmware. The install is baked into
|
|
an image once per package, keyed on the package's md5, so rebuilding gets you
|
|
a fresh bake and not yesterday's.
|
|
|
|
**The static lane is worth running twice**, and CI does. A good part of it
|
|
asks what is inside the `.apk` files, and those questions do not fail on a
|
|
checkout that has no feed — they return quietly, and the lane reports a pass
|
|
having never opened a package. Run it once for the fast parse-and-name
|
|
feedback, then again after `make packages`, when the rest of it can bite.
|
|
|
|
### The replica lane, module by module
|
|
|
|
| module | asks |
|
|
|---|---|
|
|
| `test_install.py` | what the machine's own installer produced: the stock boot chain is untouched and still parses, the wrapper is installed and starts the supervisor, every installed script is `sh -n`-clean under the printer's own busybox, klipper and the UI are services in the compiled database, klipper depends on the bring-up, the config include set is wired up, the user's `printer.cfg` survived, and a boot with no stick does not go looking for an update |
|
|
| `test_abi.py` | the ABI gate, and the only one there is: every ELF object on the installed filesystem -- ours, the stock tree's and whatever `bin/payload.sh` staged -- is 32-bit little-endian MIPS, and every one the kernel's loader will handle is `nan2008/o32/mips32r2`. Two exemptions, both measured and both narrow: kernel modules are `ET_REL` and have no FP ABI to agree about, and FlashForge ships one inert ARM binary |
|
|
| `test_upgrade.py` | an update replaces the whole of `/usr/data/anvil` -- a renamed file leaves no stale twin, and a file nobody shipped goes too -- while HelixScreen's settings are carried across it and `/usr/data/config`, where `printer.cfg` and `moonraker-custom.conf` live, is never touched |
|
|
| `test_supervisor.py` | the s6 we cross-compiled: all 13 binaries load on the printer's kernel, and `s6-svwait -U` really waits for readiness rather than returning on the fork |
|
|
| `test_boot_screen.py` | the first-boot screen renders on our own CPython 3.13, one screen's worth of correctly packed bytes, and degrades to "no screen" rather than raising when there is no panel |
|
|
| `test_web.py` | nginx and moonraker come up under s6, nginx comes back from a kill and stays down after a stop, and moonraker serves on `:7125` on our interpreter |
|
|
| `test_s6rc.py` | the boot itself -- the scanner answers, `s6-rc-init` lays the scandir down, a killed daemon comes back |
|
|
| `test_mcu_bringup.py` | `ff_mcu_bringup.py` runs on `FF_PYTHON` in the shipped environment and names every port it could not open |
|
|
|
|
**Eight of `test_s6rc.py`'s twelve cannot pass on a replica**, and that is
|
|
hardware, not a bug: there are no `/dev/ttyS4,5,7` and no `/dev/video*`, so
|
|
klippy never connects, `camera` times out, and the `ok-all` bundle is
|
|
unreachable. See [qa-migration.md](qa-migration.md). Everything else is green.
|
|
|
|
## `test/` is gone
|
|
|
|
`test/run-tests.py` went first, and `make test` with it. The 28 host-side unit
|
|
tests that outlived it -- `test_startup.py`, `test_tool_transform.py`,
|
|
`test_ffscreen.py`, `test_chamber.py`, `test_gcode.py` and the two fixtures
|
|
they shared -- are deleted too, along with `make test-py` and the CI step that
|
|
ran them. `qa/` is the only suite.
|
|
|
|
What this costs is worth naming rather than leaving to be discovered. The
|
|
replica lane covers the boot screen on the real interpreter
|
|
(`qa/replica/test_boot_screen.py`) and the install end to end, but nothing now
|
|
checks the tool-frame arithmetic in `ff_toolchange`, the chamber macros'
|
|
per-model branching, the stamp discipline in `ff-startup.py`, or the
|
|
verification G-code against the macros we ship. Those were host-side unit
|
|
tests over our own Python, and they have no replacement.
|
|
|
|
## The replica is a tool, not a test
|
|
|
|
`tools/replica/` builds the machine both suites run against, and the build
|
|
uses it too -- `bin/payload.sh` assembles the payload by running the printer's
|
|
own `apk` inside it. It is not under `test/` because nothing in it is a test:
|
|
|
|
```
|
|
tools/replica/
|
|
printer/ the replica itself: the Dockerfile, binfmt.sh,
|
|
assemble.sh, entrypoint.sh, seed-prog.sh, bake.sh
|
|
ffsim/ the host half -- config loading and the docker
|
|
plumbing. NOT a test framework; it was one, and
|
|
what is left of it launches containers
|
|
build-printer-image.sh bakes the replica image -- fetches the firmware
|
|
itself, so it needs no stock package on disk
|
|
sim-boot-screen.py `make boot-screen-sim`
|
|
```
|
|
|
|
`qa/lib/replica.py` drives `tools/replica/printer/` unmodified; it holds a
|
|
container open where `ffsim` runs one case script and exits.
|
|
|
|
## Why the harness is Python
|
|
|
|
The host half of the harness — the orchestrator and the replica launchers —
|
|
was shell until August 2026. It moved to Python for one specific reason, and
|
|
it is worth stating because the rule it enforces is the one above.
|
|
|
|
In the shell suite, "this gate did not run" travelled between processes as the
|
|
string `SKIP:` on stdout, and the runner decided by grepping for it:
|
|
|
|
```sh
|
|
if "$@" >"$out" 2>&1; then
|
|
if grep -qE '^[[:space:]]*SKIP:|[0-9]+ skipped' "$out"
|
|
then skip "$name"; else pass "$name"; fi
|
|
```
|
|
|
|
Text on stdout cannot tell you what happened inside a process. A launcher that
|
|
had **already failed** would reach the same line by a different road: the file
|
|
that sources `config.env` failed to load, so `STOCK_TGZ_*` was never set, so
|
|
the launcher concluded there was nothing to test, printed those five
|
|
characters and exited 0 — on a machine with docker and the firmware sitting
|
|
right there. That is not a hypothetical; it is how five broken launchers
|
|
shipped and stayed broken.
|
|
|
|
What changed:
|
|
|
|
- **A skip is an exception** (`ffsim.Skip`), not a string. Output cannot
|
|
imitate it, and a gate that fails cannot accidentally claim it was skipped.
|
|
- **The repo root is found by searching** upward for `bin/common.sh`, not by
|
|
counting `..` from `$0`. Moving a launcher to a different depth used to
|
|
break it silently; now it cannot.
|
|
- **A broken config is not an absent one.** Sourcing a file with a syntax
|
|
error returns non-zero and does *not* stop the shell, so the old code
|
|
carried on with half a config. `ffsim.config` checks each source and names
|
|
the file that failed.
|
|
- **pytest results are read from JUnit XML**, not from grepping `N skipped`.
|
|
- **The suite calls the gates as functions.** There is no subprocess between a
|
|
gate and the thing counting results, so there is no format to agree on.
|
|
|
|
The four things that used to be reported as skips and are now failures:
|
|
a `config.env` that does not parse, a missing case script, a missing package,
|
|
and a missing baseline. A genuinely absent precondition — no docker, no stock
|
|
package — is still a skip, because that is still the truth.
|
|
|
|
## Speed
|
|
|
|
Almost all of a replica run used to be setup, repeated per test case:
|
|
|
|
| | per run |
|
|
|---|---|
|
|
| unpack the 182MB factory image into `/usr/prog` | 22s |
|
|
| install the stock package into it, under qemu | 37s |
|
|
| **the test itself** (three boots, install, re-install) | ~30s |
|
|
|
|
`make printer-image` does both of those once, at build time, and publishes the
|
|
result. `tools/replica/printer/bake.sh` is the part that cannot be a `docker build`
|
|
step — the stock install needs `binfmt_misc` and `chroot`, so it runs in a
|
|
privileged container and the result is committed. The md5 of the package that
|
|
was installed is recorded in `/usr/prog/.BASELINE`; `entrypoint.sh` reinstalls
|
|
only if a run asks for a different one.
|
|
|
|
A replica now starts in **0.7s** and the whole end-to-end update test takes
|
|
**~70s**. That image contains proprietary FlashForge firmware.
|
|
|
|
There used to be a second way in, and it is where the numbers in that table
|
|
came from: `make rootfs` unsquashed `rootfs.squashfs` out of a stock package
|
|
into `work/rootfs`, and a thin container mounted it and unpacked the factory
|
|
image for `/usr/prog` on every single case. It is gone. Every caller set
|
|
`PRINTER_IMAGE` anyway — CI extracted a rootfs on every push and read none of
|
|
it — and `make printer-image` builds the image from the same public firmware
|
|
without needing a stock package at all, so the slow path was a second route to
|
|
the same machine that nothing chose. When the image goes stale, rebuild it.
|
|
|
|
What takes the time now is real work: two full package builds (the payload is
|
|
55MB through `xz`) and two replica runs. If it needs to get faster again,
|
|
that is where to look — not in the harness.
|
|
|
|
## What was dropped, and why
|
|
|
|
Tests that cannot fail are worse than no tests, because they read as coverage:
|
|
|
|
- **`test-abi`'s execution half.** It was `qemu "$f" -h || [ $? -lt 126 ]`,
|
|
which accepts nearly every exit status — it could only fail if qemu itself
|
|
was missing. Running the binaries for real is what the replica does.
|
|
- **`test-ash`'s `md5sum -s` probe.** It printed "inconclusive" on every path
|
|
and could not fail.
|
|
- **The per-file `sh -n` "POSIX" loop in `run-tests.sh`.** `sh` is bash on the
|
|
build image, so it proved nothing that `test-ash` does not prove properly
|
|
with the printer's own busybox. Its bashism grep also flagged `local`, which
|
|
busybox ash supports.
|
|
- **`test-install`'s hand-written replay of `app_startup.sh`.** It re-derived
|
|
the glob, `MACHINE` and `PID` with `sed` and then called the installer
|
|
itself — so a mistake in our reading of the boot script could never be
|
|
caught. The replica now runs the boot script itself, and
|
|
`qa/replica/test_install.py` asserts against what it produced.
|
|
- **The release workflow's final `sim-install` loop** ignored the exit status
|
|
of every run, so the last gate before publishing could not fail. It stops on
|
|
the first failure now, and it is `REAL_PKG=<pkg> pytest ./qa/replica` per
|
|
model -- 22 named results for each package that ships, where the loop gave
|
|
one exit code it was throwing away.
|
|
- **`test-printer-db`, all of it.** It began as a re-implementation of
|
|
PrinterDetector's scoring formula, mirroring `printer_detector.cpp` line for
|
|
line, so a change to the HelixScreen fork's formula left the test passing
|
|
while reality moved. Narrowing it to scoring-independent invariants kept it
|
|
honest but not useful enough to earn its place, and the whole check is gone.
|
|
- **The hand-rolled bashism grep in `run-tests.sh`.** It knew five constructs;
|
|
`shellcheck -s dash` knows the whole SC3xxx family and now stands in its
|
|
place. The same pass gave every replica launcher one shared home for the
|
|
docker plumbing — now `tools/replica/ffsim/replica.py` — which also fixed two `make
|
|
test-ash` bugs: it ran without the docker socket (so it always silently
|
|
skipped), and it never read `test.env`, so it ignored `PRINTER_IMAGE` and
|
|
rebuilt the local sim image every run.
|
|
|
|
- **Eight checks, in one pass, once each had been read properly.** The suite
|
|
had grown to twelve gates and most of them were the weaker copy of a gate
|
|
that survives:
|
|
- `test-abi` had **never asserted anything in CI**. `run-tests.sh` deleted
|
|
`work/modpayload` immediately before it ran and CI set `KLIPPER_FORK=""`,
|
|
so it had no targets on any run, skipped, exited 0 and was printed as
|
|
`ok`. `qa/replica/test_abi.py` now reads the ELF header of every object
|
|
that actually landed, which is the check `test-abi` was aiming at.
|
|
- `test-macros` used `jinja2.Environment()` — the default `{{ }}` syntax.
|
|
The configs contain **no** `{{` at all, only single-brace expressions, so
|
|
it validated `{% %}` block structure and never once looked inside an
|
|
expression. `test_default_delimiters_are_blind` now pins that.
|
|
- `test-ash-conformance` parsed the payload with the printer's busybox;
|
|
`qa/replica/test_install.py` runs `sh -n` over every *installed* script
|
|
with the same qemu'd busybox.
|
|
- `test-model-gate` checked what `bin/verify.sh` §8b/§9 checked and what
|
|
`pack.sh` already refuses to build. Its header claimed it proved the two
|
|
models ship different files; no such check existed in it. `verify.sh` has
|
|
since been retired; the model gate is now enforced where it always
|
|
mattered, by `runFirmwareExe.sh` refusing a foreign package -- which the
|
|
replica lane exercises on every install.
|
|
- `test-base-cfg` compared our `printer.base.cfg` against
|
|
`work/software/.../printer.base.cfg` — which the build overwrote *with our
|
|
own file* before the test read it. It had been diffing our file against
|
|
itself: green on a cold tree, red on a second build, and blind to real
|
|
drift either way. The comparison moved into `bin/unpack.sh`, where a
|
|
pristine stock tree actually exists.
|
|
- `test-applets`' command-word scan only extracted the first word of a
|
|
simple, unprefixed command: `if timeout 5 foo; then` yielded
|
|
`['if','echo','fi']`, so the one failure its docstring named was invisible
|
|
to it. Its allowlist had drifted to nine entries that excused nothing. The
|
|
absolute-path half survives, because `S50wifi`'s binaries sit on a branch
|
|
no simulation reaches.
|
|
- `lint-danger`'s structural greps were satisfiable by comments — the
|
|
comments in `firmwareExe` and `S80ui` *mention* `helix` and (at the time)
|
|
`SAFE-MODE`, so the checks passed with the logic deleted.
|
|
- `case-ui` went with the helix-only decision.
|
|
|
|
## Bugs these caught
|
|
|
|
Not theoretical. Writing them caught real bugs before any hardware was
|
|
involved:
|
|
|
|
- backups were taken *after* the stock `run.sh` had already overwritten the
|
|
files, so a restore would have restored the modified versions
|
|
- `rm -rf $MODDIR/bin` with an unset `MODDIR` expands to `rm -rf /bin`
|
|
- a crashed UI is a *zombie*, and `kill -0` succeeds on a zombie — the crash
|
|
detector called a dead UI healthy
|
|
- the packer emitted both models' filenames from one build, which would have
|
|
handed a Creator 5 the Pro's firmware
|
|
- three checks in the brick lint had been quietly dead for months: two were
|
|
guarded on a `payload/boot.sh` that no longer existed, and the file glob
|
|
never matched `firmwareExe` or the `init.d/S*` scripts at all
|
|
- a skip counted as a pass for the whole life of the suite, which is how
|
|
`test-abi` sat in it for months checking nothing while printing green
|