Files
firmware/tools/replica/ffsim/config.py
Oleksandr ShyshatskyiandClaude Opus 5 56b5b6c334 The printer image is the only replica; make rootfs was a second way nothing took
`make rootfs` unsquashed rootfs.squashfs out of the stock package into
work/rootfs, and a thin container bind-mounted it with PROG_DUMP supplying
/usr/prog from the factory image. Every caller set PRINTER_IMAGE anyway --
both workflows wrote it into test.env BEFORE running `make rootfs`, so CI
unpacked a 93MB package, unsquashed a rootfs and downloaded a 182MB factory
image on every push and read none of it. build-printer-image.sh never used
work/rootfs either: Dockerfile.full does its own extraction from the same
public firmware and needs no stock package at all.

So the fallback went, and PROG_DUMP with it -- it was only ever read when the
replica was built locally, and both implementations already passed it empty
otherwise. Gone: the target, extract-rootfs.py, gates.extract_rootfs and its
two tar helpers, the thin printer/Dockerfile, the `prebuilt` flag threaded
through both Replica implementations, and Replica.start's want_output, whose
only message was that the local build was about to be slow.

PRINTER_IMAGE is pinned to 1.9.7-1.2.9-20260810 rather than :latest. It is
now the entire substrate the brick-safety gates run on and the only input
nothing checksums, so a floating tag would let it change under a green suite
-- the drift the sha256 pins in both workflows exist to prevent.

THREE THINGS THAT COULD NOT HAVE PASSED, found while doing it:

  * Neither workflow built the feed. bin/patch.sh has refused to start
    without one since a1b4ea6, 45 commits after ci.yml was last touched.
    Both ran unpack/patch/pack straight into that guard.

  * `make release` ran bin/build.sh on $(RUN), which has no docker socket,
    while patch.sh assembles the payload inside the replica. `build` has
    used RUNBUILD all along; `release` was left behind. It also built no
    feed. It now builds one, once, outside the per-model loop -- nothing in
    it is model-specific.

  * ci.yml's shellcheck gate named pkgs/*/payload/init.d/S* and
    pkgs/*/prog/*.sh, neither of which exists any more; shellcheck exits 2
    on a path it cannot open. It is a `find` with an asserted count now,
    because the opposite failure is worse: a glob that quietly matches
    nothing checks nothing and still reports green.

Both workflows now go through `make` instead of hand-rolling `docker run`.
Hand-rolling meant CI exercised entry points no human uses, which is exactly
why the `release` runner bug could sit there unseen; it also ran as root and
left a work/ that the later `make release` could not write to.

ci.yml's test job builds the feed before the static lane. A good part of that
lane asks what is inside an .ipk and those questions do not fail without one
-- they return, and the lane reports a pass having never opened a package.
build-packages.sh needs no stock firmware, which is what makes that possible.

Docs, and the packages skill, which still documented pkg_buildopkg and
work/.opkg-host as how the payload is assembled -- the host opkg went in
dcd037c and the printer's own installs the feed now -- and still claimed
qa/static holds an ABI gate over every ELF in every package. It does not:
qa/replica/test_abi.py is the only one, and qa/static asserts a seventh does
not grow back.

Verified: qa/static is 122 passed / 110 errors here, identical to master --
every error is `vendor/opkg-utils is missing` in a checkout that has not run
`make vendor`. mkdocs build --strict passes. The new shellcheck gate was run
as written. The workflows themselves were not executed.

tools/replica/printer/*.sh live inside the published image, so those edits
take effect on the next `make printer-image`; behaviour is unchanged either
way, because nothing sets PROG_DUMP any more.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrhjKabxDihvVjNQGGT4MK
2026-08-30 14:56:26 +03:00

100 lines
3.9 KiB
Python

"""config.env and test.env, read the way the build reads them.
The two files are shell, and bin/*.sh still sources them, so they stay shell:
they use quoting, `$ROOT`, and occasionally a command substitution, and a
hand-rolled `KEY=VALUE` parser would agree with bash right up until the day it
did not. So bash reads them and reports back what it ended up with. That keeps
exactly one definition of what these files mean.
config.env the BUILD config. Everything that can reach a printer.
test.env replica-only: the factory image, the partition sizes.
Kept apart so nothing in it can end up inside a package.
THE ENVIRONMENT WINS OVER BOTH FILES, which is a rule the shell version had to
work for with a save-and-restore dance around the sourcing. An empty
`PRINTER_IMAGE=` sitting in test.env used to overwrite an explicit
`PRINTER_IMAGE=foo make boot-screen-sim`, so the override
silently did nothing and the run tested a different image than the one it was
told to. Note the rule is about being SET, not about being non-empty: an empty
string in the environment is still a decision, and `PRINTER_IMAGE= make
boot-screen-sim` deliberately means "build locally", not "use whatever the
file says".
"""
import os
import subprocess
from . import Fail, repo_root
# What the harness reads. Anything else in those files belongs to the build.
WANTED = (
"FF_KEY",
"PRINTER_IMAGE", "PROG_MB", "DATA_MB",
)
# Of those, the ones a caller is expected to override per-run.
OVERRIDABLE = ("PRINTER_IMAGE", "PROG_MB", "DATA_MB", "FF_KEY")
# `.` on a file with a syntax error returns non-zero and does NOT stop the
# shell, so the && form quietly carried on with half the file applied. That is
# the whole disease being cured here: a broken config that looks like an
# absent one. Each source is checked, and its own exit code says which file.
_DUMP = r'''
set -a
if [ -f "$1" ]; then . "$1" || exit 91; fi
if [ -f "$2" ]; then . "$2" || exit 92; fi
set +a
for key in %s; do
eval "value=\${$key-}"
printf '%%s=%%s\0' "$key" "$value"
done
''' % " ".join(WANTED)
class Config:
"""What the two files say, with the environment layered back on top."""
def __init__(self, values, root):
self.values = values
self.root = root
@classmethod
def load(cls, root=None):
root = root or repo_root()
config_env = os.environ.get("CONFIG_ENV") or str(root / "config.env")
test_env = os.environ.get("TEST_ENV") or str(root / "test.env")
dumped = subprocess.run(
["bash", "-c", _DUMP, "ffsim", config_env, test_env],
cwd=str(root), capture_output=True, text=True)
if dumped.returncode != 0:
# A syntax error in config.env is a BROKEN harness, not a missing
# precondition. The shell version could not tell the two apart:
# the half-sourced file left the settings empty, the launcher
# concluded there was nothing to test, and it reported a clean
# skip. Naming the file that failed is the entire point.
which = {91: config_env, 92: test_env}.get(dumped.returncode)
raise Fail("could not read %s:\n%s"
% (which or ("%s / %s" % (config_env, test_env)),
dumped.stderr.strip()))
values = {}
for item in dumped.stdout.split("\0"):
if "=" in item:
key, _, value = item.partition("=")
values[key] = value
# The environment wins -- see the module docstring. Being SET is what
# counts, so this tests membership rather than truthiness.
for key in OVERRIDABLE:
if key in os.environ:
values[key] = os.environ[key]
return cls(values, root)
def get(self, key, default=""):
return self.values.get(key, default) or default
@property
def ff_key(self):
return self.get("FF_KEY", "FFP0331&*%root")