Skip to content

build: run one base across the whole stack (debian:trixie-slim) - #731

Draft
HuggeK wants to merge 5 commits into
srcfl:masterfrom
HuggeK:debian-base-migration
Draft

build: run one base across the whole stack (debian:trixie-slim)#731
HuggeK wants to merge 5 commits into
srcfl:masterfrom
HuggeK:debian-base-migration

Conversation

@HuggeK

@HuggeK HuggeK commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Moves core, the updater sidecar and the optimizer onto one base —
debian:trixie-slim, current Debian stable (13). Findings and rationale: #730.
Version safeguard: #733.

Stacked on #728 — that branch is included here. Merge #728 first and this
diff reduces to the base change alone.

Why

Core was alpine:3.22, the updater was docker:27-cli (also alpine), and the
optimizer was python:3.12-slim-bookworm — two libcs, three unrelated base
images, three security streams. And bookworm is oldstable, so part of the
stack was already off full security support.

All three now sit on debian:trixie-slim. Verified, not assumed: the three
built images resolve to one identical base layer digest, so a host pulls that
rootfs once. Trixie also matches the Raspberry Pi OS release the SD image is
built from (deploy/pi-gen/config: RELEASE=trixie).

Beyond consistency the move buys glibc, so the image can run ordinary
prebuilt vendor binaries — the one thing musl genuinely blocks; a full
userland
, so docker exec debugging on a live site stops fighting busybox;
and libnss-mdns wired into /etc/nsswitch.conf.

Being precise about what does and does not deliver mDNS

The FTW process does not get .local resolution from the base change. A
CGO_ENABLED=0 binary bypasses NSS entirely — net/conf.go:395-409 gates the
.local→libc path behind if canUseCgo {, false for every FTW build. That was
as true on glibc as it was on musl.

.local works because of #728, which resolves it in Go. The libnss-mdns here
makes .local resolvable for other glibc programs in the image (getent,
curl) and is what a future cgo build would use. It needs a mounted avahi
socket, documented in docs/operations.md. Full reasoning in #730.

Changes

  • Dockerfilealpine:3.22debian:trixie-slim; apk add → one
    apt-get layer with ca-certificates tzdata wget libnss-mdns.
  • Dockerfile.updaterdocker:27-cli → same base, with the docker CLI and
    compose plugin COPY --from=docker:27-cli. Same upstream artifact, explicitly
    pinned, and it keeps apt out of the emulated arm64 layer.
  • Dockerfile.optimizerpython:3.12-slim-bookworm
    python:3.12-slim-trixie. Bringing it along is what keeps the shared layer;
    leaving it behind would have split the pin.
  • scripts/test-container-boundaries.sh^FROM alpine: asserted a proxy
    with no error message. Replaced with the invariant it stood for (core must not
    build on a Python base — that is how Python reached core originally), plus a
    new assertion that wget is present, both with real diagnostics.
  • go/cmd/ftw/main.go — embed _ "time/tzdata". Production code reads
    time.Local, which silently degrades to UTC without zoneinfo, and the
    tzdata tests t.Skipf rather than fail, so that regression would ship green.
  • scripts/install.sh, deploy/pi-gen/.../00-run.sh — both claimed uid 100
    came from an alpine adduser -S. No account has ever been created, on any
    base. Corrected.
  • Docs + changeset (minor).

What I verified

All three images built and exercised, on both architectures where relevant:

Check Result
One shared base layer across all three images ✅ identical digest f2ec4de8…
Optimizer: CVXPY actually solves on trixie ✅ cvxpy 1.9.2, highspy imports, Python 3.12.13, status optimal
Updater: copied CLI works ✅ docker 27.5.1, compose v2.33.0
Core: wget present and runnable /usr/bin/wget
CA bundle / zoneinfo ✅ both present
nsswitch.conf files mdns4_minimal [NOTFOUND=return] dns
Runs as uid 100 / gid 101 ✅ amd64 and arm64
arm64 build under QEMU 79 s — the emulated-apt cost I expected to hurt does not
Boundary + compose gates
CGO_ENABLED=0 cross-compiles ✅ linux/arm64, linux/amd64, windows/amd64

Size — less bad than the headline

Image Before After
core 53 MB 133 MB
updater 204 MB (docker:27-cli) 203 MB
optimizer ~830 MB 833 MB

The core image is the real cost. But docker:27-cli was never a small image, so
the updater move is size-neutral, and all three now share a 78.6 MB base
layer that is pulled once rather than three unrelated ones. At the deployment
level this is far closer to a wash than the core figure alone suggests — worth
weighing against firstboot.sh, which retries the first-boot pull forever at
60 s backoff.

Two things deliberately preserved

wget stays in the image permanently. ftw-updater docker execs it to
decide whether an update commits, and the updater never updates itself — it
only knows the core and optimizer components — so old sidecars persist in
the field indefinitely. Dropping wget would pin those hosts in an
update → rollback loop, and the call sites are stubbed in tests so CI would not
catch it. The boundary test now asserts wget for exactly this reason.

uid 100 / gid 101 stay numeric and unchanged. gid 101 is what grants access
to the optimizer's 0660 socket (worker.py:101); changing it degrades the
planner to the Go DP fallback silently. Verified neither has a passwd/group
entry on this base, so ownership renders numerically — cosmetic only, and
ENV HOME=/app/data continues to cover the missing entry.

The base is pinned to the codename, not stable-slim, so a major-version jump
cannot arrive silently on a rebuild. #733 adds the weekly check that tells us
when to move it deliberately.

🤖 Generated with Claude Code

claude and others added 3 commits July 30, 2026 16:19
Go never resolves ".local" itself. net/conf.go routes those names to libc
only when cgo is available, and every FTW build sets CGO_ENABLED=0, so the
pure Go resolver is always selected: a configured "zap.local" became a
unicast DNS query to the site router and failed. That holds on every base
image and every libc, musl and glibc alike, so the container's distro was
never the variable here.

Add internal/mdnsresolve, which answers those names over multicast DNS, and
route every driver transport through it: Modbus TCP, MQTT (driver and Home
Assistant bridge), HTTP including the TLS-pinned client, WebSocket and raw
TCP. Only ".local" names take the new path; literal IPs and ordinary DNS
names dial exactly as before.

Resolution runs per dial rather than once at startup, so a device that moves
to a new DHCP lease is found again on the next reconnect with no config edit.
Answers are cached for the record TTL clamped to 30-120s so reconnect loops
cannot flood the LAN, and failures are cached for 5s so a still-booting
device is retried soon. Failures log "mDNS resolution failed" and name the
mechanism rather than surfacing as a generic dial error.

The scanner's reverse PTR lookup moves into the same package so there is one
mDNS implementation instead of two.

Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
Core was alpine:3.22 and the updater sidecar was docker:27-cli (also
alpine), while the optimizer was already python:3.12-slim-bookworm. That
is two libcs and two security streams in one deployment, and two base
rootfs blobs pulled per host. Put all three on debian:bookworm-slim so the
layer is pulled once and tracked once.

glibc additionally lets the image run ordinary prebuilt vendor binaries,
which musl cannot, and a full userland makes on-site docker exec debugging
practical. libnss-mdns is installed and wired into nsswitch.conf so .local
resolves for glibc tools inside the container when an avahi socket is
mounted; the FTW process does not depend on that, because a CGO_ENABLED=0
binary bypasses NSS entirely and resolves .local in Go instead.

The binary stays fully static and still cross-compiles on the build
platform, so only the small runtime layer is emulated for arm64. Measured:
79s for a full arm64 build, image 148MB (amd64 128MB, up from 53MB).

wget is now installed explicitly and asserted by the boundary test. It is
contractual rather than incidental: ftw-updater docker-execs it inside the
core image to decide whether an update commits, and updaters already in
the field will keep doing so, so the core image cannot stop shipping it.

The boundary test's `^FROM alpine:` line is replaced by the invariant it
was actually standing in for -- core must not build on a Python base --
and given a real error message.

Also embed tzdata in the binary as a fallback. Production code reads
time.Local, which silently degrades to UTC when zoneinfo is missing, and
the tzdata tests skip rather than fail, so that regression would ship
green. Verified uid 100 / gid 101 have no passwd entry on this base, and
corrected the two comments that attributed them to an alpine adduser.

Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
bookworm is oldstable. Move all three images to trixie so the deployment
tracks the suite that is actually receiving full security support, and so
the base matches the Raspberry Pi OS release the SD image is built from
(deploy/pi-gen/config: RELEASE=trixie).

This also brings the optimizer along. Leaving it on a bookworm-derived
python image would have split the shared base layer, which is the whole
reason the images were aligned -- verified after the move that core,
updater and optimizer resolve to one identical base layer digest.

Verified on trixie: CVXPY 1.9.2 solves and highspy imports under Python
3.12.13; the updater's copied docker CLI 27.5.1 and compose plugin v2.33.0
both run; core has wget, the CA bundle, zoneinfo and an nsswitch.conf
wired for mdns, and runs as uid 100 / gid 101.

Pinned to the codename rather than a stable alias, so a major-version jump
can never arrive silently on a rebuild.

Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
@HuggeK HuggeK changed the title build: run one base across the whole stack (debian:bookworm-slim) build: run one base across the whole stack (debian:trixie-slim) Jul 31, 2026
The optimizer releases independently of core, and optimizer-release.yml
skips the build entirely when an image already exists for the requested
version, then asserts that the published image's
org.opencontainers.image.revision label matches the commit being released.

Changing Dockerfile.optimizer without moving the version therefore does not
merely produce two different artifacts under one number -- it cannot ship
at all. Releasing 1.3.2 from this commit would either reuse the bookworm
image or fail metadata verification.

Minor rather than patch: the Python surface is untouched, but the runtime
underneath it moves a Debian major version, so consumers are getting a
materially different artifact.

Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.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.

2 participants