build: one alpine base for core and updater, with .local resolved in Go - #746
Draft
HuggeK wants to merge 2 commits into
Draft
build: one alpine base for core and updater, with .local resolved in Go#746HuggeK wants to merge 2 commits into
HuggeK wants to merge 2 commits into
Conversation
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> @
This was referenced Jul 31, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Warning
Alternative to #731 + #728 — not a companion. These two routes solve the
same two problems (one base across the stack, and
.localnames thatresolve). Merge one or the other, never both.
Important
#714 is the other half of the feature. This PR makes
.localnamesresolvable; it does not change what the setup wizard writes. #714 is web-only
and is what actually binds a discovered device to its
.localname, so untilboth 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
.localconfigs, including thezap.localandsid-os.localexamples documented in
config.example.yaml, which cannot resolve onmastertoday. 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:
alpine:3.22, verifiedsharing base layer
6f09edfb3f6d7173733a..localresolved natively in Go — no avahi, no host daemon, no socket tobind-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:CVXPY publishes no musllinux wheels at all — 0.4.10 is the newest that
exists. Per-package:
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 beforereaching 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.
alpine:3.22, 8.29 MBdebian:trixie-slim, 78.6 MBpython:3.12-slim-trixiepython:3.12-slim-trixieThe updater shrinks either way — it was 204 MB on
docker:27-cli, which bundledfar more than the CLI.
Total bytes per host — the two routes are closer than the table suggests
python:3.12-slim-trixieis built fromdebian:trixie-slim: both reportfirst layer
f2ec4de84f559f5c7be4. So under #731 the optimizer shares its basewith core and the updater, while here it shares nothing.
Counting unique bytes, with
Ofor the optimizer image:52.5 + 130 − 8.29 + O=174.2 + O133 + 203 − 78.6 + (O − 78.6)=257.4 + O − 78.6Difference: +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 withoutneeding to build the optimizer.
Since
docker-compose.ymlshipsftw-optimizeras a default service, thecommon 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.
.localresolutionPure Go multicast query (
go/internal/mdnsresolve), wired into all six dialsites: 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-daemonon 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/curlinside the container cannot work. musl hasno NSS plugin mechanism, so there is no
libnss-mdnsequivalent to install.FTW resolves correctly; the shell next to it cannot.
Also here
_ "time/tzdata"incmd/ftw—time.Localsilently falls back to UTC whena base ships no zoneinfo, mis-timing price and plan windows with no error.
python:3.12-slim-trixie(bookworm is oldstable) and version1.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.shnow compares the two runtime stages andprints why it failed. It previously ran
grep -q '^FROM alpine:' Dockerfilewith no message, proving half the invariant and saying nothing when it broke.
Verification
bash scripts/test-container-boundaries.shpasses.go build ./...,go vet,go test ./internal/mdnsresolve/...pass.docker --versionanddocker compose versionrun correctly from the copiedbinaries on both alpine and debian, confirming they are static (musl's
loader reports "Not a valid dynamic program" for both).