Skip to content

Commit ab18152

Browse files
HuggeKclaudefrahlg
authored
ci: notice when a new Debian stable leaves the pinned suite behind (#733)
* feat(net): resolve device .local names over mDNS 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> * build: run one base across the whole stack (debian:bookworm-slim) 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> * build: pin the stack to Debian 13 trixie, the current stable 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> * ci: notice when the container base falls behind Debian stable The images pin a Debian codename rather than a suite alias, so a major version jump can never arrive silently on a rebuild. The cost of pinning is that nothing notices when a new stable ships, and a suite quietly stops receiving full security support once it becomes oldstable -- which is exactly the state bookworm was already in. Truth comes from Debian's own Release file for the stable suite, not from registry tag listings: tags are noisy, rate-limited, and say nothing about which suite Debian considers stable. The check reads the pin out of the three Dockerfiles rather than hard- coding it, so it cannot drift from what actually ships, and it fails the run rather than reporting "current" if it could not reach Debian. It also flags the images having drifted apart, since a split pin silently costs the shared base layer they were aligned for. When a new stable is found it reports whether debian:<suite>-slim and the matching python:<ver>-slim-<suite> are actually published yet, because the python image can lag by days and moving core alone would split the pin. Follows the bundled-driver-pin workflow: one issue, rewritten each run and closed automatically once the pin is current, so a suite left alone for a year costs one thread rather than fifty-two. Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com> * build: bump the optimizer to 1.4.0 for the new base 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> * build: leave .local name resolution to its own change @ The base move and the resolver were stacked in one branch, which made the resolver look like a consequence of choosing Debian. It is not: the two are independent, and the resolver is the one with the device-facing risk. Take the resolver back out so this branch is only the base move, and restate what libnss-mdns actually buys here — tools inside the image, not the FTW process, which is CGO_ENABLED=0 and never consults NSS. Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com> @ * chore(release): add a changeset for the base-currency check The `no-changeset` label is the right escape hatch here, but applying a label needs write access to srcfl/ftw. A patch entry saying no runtime behaviour changes keeps the gate honest without one. Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com> * ci: keep the debian base schedule from running on forks Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>
1 parent fed482a commit ab18152

4 files changed

Lines changed: 209 additions & 1 deletion

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"ftw": patch
3+
---
4+
5+
Notice when a new Debian stable leaves the container base behind.
6+
7+
The images pin a codename (`debian:trixie-slim`, `python:3.12-slim-trixie`)
8+
rather than `stable`, so a major-version jump can never arrive silently on a
9+
rebuild. Nothing noticed when a new stable shipped, and Dependabot cannot: it
10+
orders numeric tags, and a suite codename has no numeric component to order.
11+
12+
A weekly check now reads the pin out of the three Dockerfiles and compares it
13+
with Debian's own `stable` release, failing when the pin falls behind or when
14+
the three images stop agreeing on one suite.
15+
16+
No runtime behaviour changes.
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
name: debian base currency
2+
3+
# The container images pin a Debian codename (debian:trixie-slim) rather than a
4+
# suite alias, so that a major-version jump can never arrive silently on some
5+
# future rebuild. The cost of pinning is that nothing notices when a new stable
6+
# ships, and a base left behind quietly stops receiving security updates once
7+
# its suite leaves LTS.
8+
#
9+
# This notices. It opens a single issue and keeps editing it, so a suite left
10+
# alone for a year costs one thread rather than fifty-two.
11+
12+
on:
13+
schedule:
14+
# 07:00 UTC on Mondays. Debian stable ships roughly every two years, so a
15+
# daily check would be noise; weekly still surfaces a new release within
16+
# days of it landing, which is far inside the window that matters.
17+
- cron: "0 7 * * 1"
18+
workflow_dispatch:
19+
20+
permissions:
21+
contents: read
22+
issues: write
23+
24+
concurrency:
25+
group: debian-base-currency
26+
cancel-in-progress: false
27+
28+
jobs:
29+
currency:
30+
name: pinned base follows Debian stable
31+
# Forks inherit this schedule as soon as they sync master, and the run can
32+
# only misfire there: the tracking issue belongs on srcfl/ftw, and a fork
33+
# with issues disabled turns the Monday check into a failure email for its
34+
# owner. A manual dispatch is someone asking, so that still runs.
35+
if: github.repository == 'srcfl/ftw' || github.event_name != 'schedule'
36+
runs-on: ubuntu-latest
37+
steps:
38+
- name: Checkout
39+
uses: actions/checkout@v5
40+
41+
- name: Compare the pinned suite against Debian stable
42+
id: compare
43+
run: |
44+
set +e
45+
output="$(bash scripts/check-debian-base.sh 2>&1)"
46+
status=$?
47+
set -e
48+
echo "$output"
49+
{
50+
echo "status=${status}"
51+
echo "report<<REPORT_EOF"
52+
echo "$output"
53+
echo "REPORT_EOF"
54+
} >> "$GITHUB_OUTPUT"
55+
# A network or parse failure must not read as "the base is current",
56+
# and must not open an issue claiming a new release we never saw.
57+
# 1 means a newer stable (or a split pin), 0 means current.
58+
if [ "$status" != "0" ] && [ "$status" != "1" ]; then
59+
echo "::error::the Debian currency check could not run (exit ${status})"
60+
exit "$status"
61+
fi
62+
63+
- name: Open or update the tracking issue
64+
if: steps.compare.outputs.status == '1'
65+
env:
66+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
67+
REPORT: ${{ steps.compare.outputs.report }}
68+
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
69+
run: |
70+
title="Container base is behind Debian stable"
71+
body="$(printf '%s\n\n```\n%s\n```\n\n%s\n\nFrom %s\n\nThis issue is rewritten by each run of the `debian base currency` workflow. Closing it without moving the pin means it comes back next Monday.\n' \
72+
"The Debian suite pinned by the container images is no longer the current Debian stable, or the three images have drifted apart. Core, updater and optimizer are pinned to one suite on purpose: they share a single base layer, so a host pulls that rootfs once instead of three times." \
73+
"${REPORT}" \
74+
"Moving the pin means updating the FROM lines in \`Dockerfile\`, \`Dockerfile.updater\` and \`Dockerfile.optimizer\` together, then rebuilding all three and confirming the optimizer still resolves CVXPY and HiGHS wheels on the new suite. Do not move core alone — that splits the shared layer. If the readiness lines above say a tag is not published yet, wait for it rather than splitting the pin." \
75+
"${RUN_URL}")"
76+
77+
existing="$(gh issue list --state open --search "in:title \"${title}\"" \
78+
--json number,title --jq "[.[] | select(.title == \"${title}\")][0].number")"
79+
80+
if [ -n "$existing" ] && [ "$existing" != "null" ]; then
81+
gh issue edit "$existing" --body "$body"
82+
echo "updated issue #${existing}"
83+
else
84+
gh issue create --title "$title" --body "$body"
85+
fi
86+
87+
- name: Close the tracking issue once the pin is current
88+
if: steps.compare.outputs.status == '0'
89+
env:
90+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
91+
run: |
92+
title="Container base is behind Debian stable"
93+
existing="$(gh issue list --state open --search "in:title \"${title}\"" \
94+
--json number,title --jq "[.[] | select(.title == \"${title}\")][0].number")"
95+
if [ -n "$existing" ] && [ "$existing" != "null" ]; then
96+
gh issue close "$existing" --comment "Every image now pins the current Debian stable. Closed by the \`debian base currency\` workflow."
97+
echo "closed issue #${existing}"
98+
else
99+
echo "base is current and no issue is open"
100+
fi

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ optimizer-test: optimizer/.venv/.installed
100100
optimizer/.venv/bin/pytest -q optimizer/tests
101101

102102
compose-migration-test:
103-
bash -n scripts/enable-modular-stack.sh scripts/migrate-legacy-compose.sh scripts/install-macos.sh scripts/sync-bundled-drivers.sh scripts/check-driver-versions.sh
103+
bash -n scripts/enable-modular-stack.sh scripts/migrate-legacy-compose.sh scripts/install-macos.sh scripts/sync-bundled-drivers.sh scripts/check-driver-versions.sh scripts/check-debian-base.sh
104104
bash scripts/test-modular-compose.sh
105105

106106
container-boundary-test:

scripts/check-debian-base.sh

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
#!/usr/bin/env bash
2+
# Reports whether the Debian suite pinned by the container images is still the
3+
# current Debian stable.
4+
#
5+
# The images pin a codename (debian:trixie-slim) rather than a suite alias
6+
# (debian:stable-slim) so a major-version jump can never arrive silently on a
7+
# rebuild. The cost of pinning is that nothing notices when a new stable ships —
8+
# this is what notices.
9+
#
10+
# Truth comes from Debian's own Release file for the `stable` suite, not from
11+
# registry tag listings: tags are noisy, rate-limited and say nothing about
12+
# which suite Debian considers stable.
13+
#
14+
# Exit codes, matching scripts/sync-bundled-drivers.sh --behind:
15+
# 0 pinned suite is current stable
16+
# 1 a newer stable exists, or the Dockerfiles disagree with each other
17+
# 2 the check could not run (network, parse) — never reported as "fine"
18+
set -euo pipefail
19+
20+
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
21+
cd "$ROOT"
22+
23+
RELEASE_URL="${DEBIAN_RELEASE_URL:-https://deb.debian.org/debian/dists/stable/Release}"
24+
25+
# Read the pin out of the Dockerfiles instead of hard-coding it here, so this
26+
# check cannot drift away from what actually ships.
27+
pinned_debian() { sed -n 's/^FROM debian:\([a-z][a-z]*\)-slim.*/\1/p' "$1" | head -1; }
28+
pinned_python() { sed -n 's/^FROM python:[0-9.][0-9.]*-slim-\([a-z][a-z]*\).*/\1/p' "$1" | head -1; }
29+
30+
core=$(pinned_debian Dockerfile)
31+
updater=$(pinned_debian Dockerfile.updater)
32+
optimizer=$(pinned_python Dockerfile.optimizer)
33+
34+
for pair in "Dockerfile:$core" "Dockerfile.updater:$updater" "Dockerfile.optimizer:$optimizer"; do
35+
if [ -z "${pair#*:}" ]; then
36+
echo "could not read a Debian suite from ${pair%%:*}" >&2
37+
exit 2
38+
fi
39+
done
40+
41+
echo "pinned suite:"
42+
printf ' %-22s %s\n' "Dockerfile" "$core" "Dockerfile.updater" "$updater" "Dockerfile.optimizer" "$optimizer"
43+
44+
if [ "$core" != "$updater" ] || [ "$core" != "$optimizer" ]; then
45+
echo ""
46+
echo "The three images no longer agree on one Debian suite. Sharing a single"
47+
echo "base layer is the reason they were aligned, and that benefit is lost"
48+
echo "while they differ."
49+
exit 1
50+
fi
51+
52+
release=$(curl -fsSL --max-time 20 "$RELEASE_URL" 2>/dev/null) || {
53+
echo "could not fetch $RELEASE_URL" >&2
54+
exit 2
55+
}
56+
stable=$(printf '%s\n' "$release" | sed -n 's/^Codename: *//p' | head -1)
57+
version=$(printf '%s\n' "$release" | sed -n 's/^Version: *//p' | head -1)
58+
if [ -z "$stable" ]; then
59+
echo "no Codename field in $RELEASE_URL" >&2
60+
exit 2
61+
fi
62+
63+
echo ""
64+
echo "debian stable: $stable${version:+ (}${version}${version:+)}"
65+
66+
if [ "$core" = "$stable" ]; then
67+
echo ""
68+
echo "The pinned suite is current."
69+
exit 0
70+
fi
71+
72+
echo ""
73+
echo "A newer Debian stable is available: $core -> $stable"
74+
75+
# Advisory only. A new Debian stable is tagged in the official images promptly,
76+
# but python:<ver>-slim-<suite> can lag by days, and moving core without the
77+
# optimizer would split the shared base layer. Never fail the check on this —
78+
# it is a readiness note, not the finding.
79+
if command -v docker >/dev/null 2>&1; then
80+
echo ""
81+
echo "image readiness:"
82+
python_tag=$(sed -n 's/^FROM \(python:[0-9.][0-9.]*\)-slim-[a-z][a-z]*.*/\1/p' Dockerfile.optimizer | head -1)
83+
for image in "debian:${stable}-slim" "${python_tag}-slim-${stable}"; do
84+
if docker manifest inspect "$image" >/dev/null 2>&1; then
85+
printf ' %-32s available\n' "$image"
86+
else
87+
printf ' %-32s NOT PUBLISHED YET\n' "$image"
88+
fi
89+
done
90+
fi
91+
92+
exit 1

0 commit comments

Comments
 (0)