Skip to content

build: one alpine base for core and updater, with .local resolved in Go - #746

Draft
HuggeK wants to merge 2 commits into
srcfl:masterfrom
HuggeK:alpine-consolidation-native-mdns
Draft

build: one alpine base for core and updater, with .local resolved in Go#746
HuggeK wants to merge 2 commits into
srcfl:masterfrom
HuggeK:alpine-consolidation-native-mdns

Conversation

@HuggeK

@HuggeK HuggeK commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Warning

Alternative to #731 + #728 — not a companion. These two routes solve the
same two problems (one base across the stack, and .local names that
resolve). Merge one or the other, never both.

Important

#714 is the other half of the feature. This PR makes .local names
resolvable; it does not change what the setup wizard writes. #714 is web-only
and is what actually binds a discovered device to its .local name, so until
both are in, a normal install through the UI still gets a raw IP and sees no
difference.

So the feature needs either #731 + #728, or this PR — plus #714 either way.
Two PRs or one for the resolver, never both routes, and #714 on top.

Merging this without #714 is safe and still worth it on its own: it repairs
hand-written .local configs, including the zap.local and sid-os.local
examples documented in config.example.yaml, which cannot resolve on master
today. Merging #714 without a resolver is the combination that breaks device
installs.

What this is

The other route, built out so the choice can be made on measurements instead of
argument:

  • One base for core and the updater — both on alpine:3.22, verified
    sharing base layer 6f09edfb3f6d7173733a.
  • .local resolved natively in Go — no avahi, no host daemon, no socket to
    bind-mount, works identically on every install path including the Home
    Assistant add-on.

The finding that shapes it

The optimizer cannot join an Alpine base. Measured, not assumed, on
python:3.12-alpine:

pip install --only-binary=:all: cvxpy==1.9.2
  → Could not find a version that satisfies the requirement cvxpy==1.9.2
    (from versions: 0.4.10)

CVXPY publishes no musllinux wheels at all — 0.4.10 is the newest that
exists. Per-package:

Package musl wheel
numpy, scipy, highspy, scs
cvxpy, clarabel, osqp, ecos

Falling back to source builds means compiling a Rust solver (clarabel) and two
C++ solvers on every release, arm64 under emulation included. I tried it with a
full toolchain installed: it fails in sparsediffpy's build backend before
reaching any of them.

So this route consolidates two of three images. The stack keeps two bases and
two libcs. That is the honest headline, and it is the main thing to weigh against
#731.

Measurements

Both routes built in one session, same machine.

This PR (alpine) #731 (debian trixie)
Distinct bases in the deployment 2 1
libcs 2 (musl + glibc) 1
Core image 52.5 MB 133 MB
Updater 130 MB 203 MB
Shared base layer alpine:3.22, 8.29 MB debian:trixie-slim, 78.6 MB
Optimizer python:3.12-slim-trixie python:3.12-slim-trixie

The updater shrinks either way — it was 204 MB on docker:27-cli, which bundled
far more than the CLI.

Total bytes per host — the two routes are closer than the table suggests

python:3.12-slim-trixie is built from debian:trixie-slim: both report
first layer f2ec4de84f559f5c7be4. So under #731 the optimizer shares its base
with core and the updater, while here it shares nothing.

Counting unique bytes, with O for the optimizer image:

Difference: +4.6 MB for #731 when the optimizer is deployed, and
+83.2 MB when it is not. Both independent of O, so this holds without
needing to build the optimizer.

Since docker-compose.yml ships ftw-optimizer as a default service, the
common case is the 4.6 MB one — the Alpine size win largely evaporates as soon
as the optimizer is present, because trixie is then on the host anyway.

.local resolution

Pure Go multicast query (go/internal/mdnsresolve), wired into all six dial
sites: Modbus TCP, MQTT for driver and Home Assistant bridge, HTTP including the
TLS-pinned client, WebSocket and raw TCP. Per-dial, so a name survives a DHCP
move; answers cached 30–120 s from the record TTL, failures 5 s.

It deliberately does not bind port 5353 — it sends from an ephemeral socket
with the RFC 6762 unicast-response (QU) bit set, which is exactly what lets it
coexist with avahi-daemon on the Pi, where avahi owns that port.

What this route gives up, all of it a consequence of musl rather than of the
resolver:

  • getent hosts zap.local / curl inside the container cannot work. musl has
    no NSS plugin mechanism, so there is no libnss-mdns equivalent to install.
    FTW resolves correctly; the shell next to it cannot.
  • The image cannot run prebuilt glibc vendor binaries.
  • Debugging on a live site stays busybox.

Also here

  • _ "time/tzdata" in cmd/ftwtime.Local silently falls back to UTC when
    a base ships no zoneinfo, mis-timing price and plan windows with no error.
  • Optimizer → python:3.12-slim-trixie (bookworm is oldstable) and version
    1.4.0, since its release workflow verifies a published image's revision label
    against the commit and would otherwise refuse to ship the new base.
  • scripts/test-container-boundaries.sh now compares the two runtime stages and
    prints why it failed. It previously ran grep -q '^FROM alpine:' Dockerfile
    with no message, proving half the invariant and saying nothing when it broke.

Verification

  • bash scripts/test-container-boundaries.sh passes.
  • go build ./..., go vet, go test ./internal/mdnsresolve/... pass.
  • Both images built and their base layer digests compared directly.
  • docker --version and docker compose version run correctly from the copied
    binaries on both alpine and debian, confirming they are static (musl's
    loader reports "Not a valid dynamic program" for both).

claude and others added 2 commits July 31, 2026 14:41
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>
@
The updater sidecar ran docker:27-cli. That is alpine-based too, but it is a
different alpine on its own cadence, so the deployment pulled two rootfs blobs
and tracked two upgrade streams for what looked like one distribution. Put the
sidecar on the same alpine:3.22 tag and copy the docker CLI and compose plugin
out of the official image instead -- same artifact, version pinned, no package
repository added, and both are static Go binaries so neither cares about libc.

The optimizer cannot follow. Measured on python:3.12-alpine: cvxpy publishes no
musllinux wheel at all (newest is 0.4.10), and clarabel, osqp and ecos have none
either. Source builds mean compiling a Rust solver and two C++ solvers on every
release, arm64 under emulation included, and the attempt fails in sparsediffpy
before reaching them. It stays on Debian slim but leaves oldstable for trixie.

Make the boundary test assert all of that. It grepped one file for ^FROM alpine:
with no error message, which proved half the invariant and said nothing when it
broke.

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