Skip to main content

max / alloy

Bake the minted hostname into os-release, where a RUN can reach it A medium built with ALLOY_HOSTNAME=alloytest came up called `alloy`. The identity step printed its success line and exited 0, because podman bind mounts /etc/hostname into the container for the duration of every RUN: the write went to a throwaway file and was discarded at commit. Nothing reads the value back at build time, so the feature had never worked and nothing said so. /usr/lib/hostname, systemd's vendor default, does not fix it either. systemd consults that file only when /etc/hostname is absent, and podman leaves its mount target behind in the layer as an empty 0700 file. Present and empty is the worst shape available: enough for systemd to stop looking, not enough to be a hostname. A RUN cannot clear it, because unlinking a live mount fails with EBUSY. No container build can produce an image whose /etc/hostname is missing. So the name goes where systemd fell through to in both failures: DEFAULT_HOSTNAME in os-release, which arrives by COPY and which no mount covers. It is also the right meaning, a fallback for a machine nobody has named yet, which is exactly a freshly minted medium. The installer still wins on the machine it installs, through `systemd-firstboot --hostname`. etc/hostname leaves the repo for the same reason: COPY is not a RUN and does land, so shipping one would put a real static hostname on top of the baked fallback. Verified by booting the ISO: the medium now answers to alloytest.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-09 22:14 UTC
Signed with PGP, not checked
Commit: 56fa62793298fdf1b05a315216ebbac72208f874
Parent: ca77276
4 files changed, +93 insertions, -7 deletions
M Containerfile +34 -1
@@ -1799,16 +1799,49 @@
1799 1799 ARG ALLOY_HOSTNAME=
1800 1800 ARG ALLOY_SSH_KEY=
1801 1801
1802 + # The hostname is baked by rewriting DEFAULT_HOSTNAME in os-release, and
1803 + # NOT by writing /etc/hostname. Two measurements, 2026-08-09, forced that:
1804 + #
1805 + # 1. podman bind mounts /etc/hostname into the container for the duration
1806 + # of every RUN, so a write there lands on a throwaway file and is
1807 + # discarded at commit. The step printed "identity: hostname alloytest",
1808 + # exited 0, and shipped an image that still said `alloy`. Silent,
1809 + # because nothing reads the value back at build time.
1810 + #
1811 + # 2. Writing /usr/lib/hostname instead — systemd's vendor default — does
1812 + # not help either, and this is the part that is easy to get wrong twice.
1813 + # systemd consults it only when /etc/hostname is ABSENT, and podman
1814 + # leaves its mount target behind as an empty 0700 file in the layer. An
1815 + # empty /etc/hostname is not a hostname, so systemd falls through to the
1816 + # default; it does not go on to read /usr/lib/hostname. `rm` cannot
1817 + # clear it either: the file is a live mount during the RUN and unlink
1818 + # fails with EBUSY. A container build cannot produce an image whose
1819 + # /etc/hostname is missing.
1820 + #
1821 + # DEFAULT_HOSTNAME is what systemd fell through TO, so it is the one lever
1822 + # a RUN can still reach: /usr/lib/os-release arrives by `COPY usr/ /usr/`
1823 + # above and no bind mount covers it. It is also the right meaning — a
1824 + # fallback for a machine with no static hostname set, which is exactly what
1825 + # a freshly minted medium is. The installer still wins on the machine it
1826 + # installs: `systemd-firstboot --hostname` writes a real /etc/hostname
1827 + # there. Everything downstream (avahi's `<name>.local`, NetworkManager's
1828 + # DHCP option 12) asks hostnamed, so it sees this.
1829 + #
1830 + # `etc/hostname` stays out of this repo's `etc/` tree for the same reason:
1831 + # `COPY etc/ /etc/` is not a RUN and does land, so shipping one would put a
1832 + # real static hostname on top of the baked fallback and undo all of this.
1802 1833 RUN set -eu; \
1803 1834 mkdir -p /usr/lib/alloy; \
1804 1835 if [ -n "$ALLOY_HOSTNAME" ]; then \
1805 1836 echo "$ALLOY_HOSTNAME" | grep -qE '^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?$' \
1806 1837 || { echo "ALLOY_HOSTNAME '$ALLOY_HOSTNAME' is not a hostname" >&2; exit 1; }; \
1807 - echo "$ALLOY_HOSTNAME" > /etc/hostname; \
1838 + sed -i "s/^DEFAULT_HOSTNAME=.*/DEFAULT_HOSTNAME=$ALLOY_HOSTNAME/" /usr/lib/os-release; \
1808 1839 echo "identity: hostname $ALLOY_HOSTNAME"; \
1809 1840 else \
1810 1841 echo "identity: no hostname baked in"; \
1811 1842 fi; \
1843 + grep -q "^DEFAULT_HOSTNAME=${ALLOY_HOSTNAME:-alloy}$" /usr/lib/os-release \
1844 + || { echo "the baked hostname did not land in os-release" >&2; exit 1; }; \
1812 1845 if [ -n "$ALLOY_SSH_KEY" ]; then \
1813 1846 case "$ALLOY_SSH_KEY" in \
1814 1847 ssh-*|ecdsa-*|sk-*) ;; \
@@ -297,8 +297,11 @@
297 297
298 298 /// The hostname an install gets if the user does not change it.
299 299 ///
300 - /// Matches `etc/hostname` in the image, so the installer's default and the
301 - /// baked-in one cannot drift apart.
300 + /// Matches `DEFAULT_HOSTNAME` in `usr/lib/os-release`, which is the value the
301 + /// Containerfile's identity step rewrites when a medium is minted with a name,
302 + /// so the installer's default and the image's own name cannot drift apart. Not
303 + /// `/etc/hostname`: see the identity step for why neither that file nor
304 + /// `/usr/lib/hostname` can carry a baked value out of a container build.
302 305 const DEFAULT_HOSTNAME: &str = "alloy";
303 306
304 307 /// Longest single hostname label, per RFC 1123.
@@ -1749,9 +1752,14 @@
1749 1752 Invocation::new("systemd-firstboot")
1750 1753 .arg(format!("--root={root}"))
1751 1754 .arg(format!("--hostname={hostname}"))
1752 - // Without --force firstboot skips any setting already present in
1753 - // the image, and etc/hostname ships with "alloy" in it. The user's
1754 - // answer would be silently discarded.
1755 + // Without --force firstboot skips any setting already present
1756 + // in the image, and every Alloy image has an /etc/hostname: an
1757 + // empty one, which podman leaves behind as the target of the
1758 + // bind mount it makes over that path during each RUN. Present
1759 + // and empty is the worst shape for this, since it is enough for
1760 + // firstboot to skip and not enough for systemd to use, so the
1761 + // machine would answer to the fallback name with the user's
1762 + // answer discarded and nothing said about it.
1755 1763 .arg("--force"),
1756 1764 ),
1757 1765 // Before useradd, because useradd is what rejects a group it cannot
@@ -139,3 +139,49 @@
139 139 "build/make-iso.sh"
140 140 ));
141 141 }
142 +
143 + // The baked hostname has to go somewhere a `RUN` can actually reach, and no
144 + // static hostname may be shipped on top of it.
145 + //
146 + // Two ways this was got wrong on 2026-08-09, both silent, both found only by
147 + // booting the ISO. First, `echo > /etc/hostname`: podman bind mounts that path
148 + // for every RUN, so the write lands on a throwaway file and is discarded at
149 + // commit while the step prints its success line and exits 0. Second,
150 + // `/usr/lib/hostname`, systemd's vendor default: systemd reads it only when
151 + // /etc/hostname is ABSENT, and podman leaves its mount target behind as an
152 + // empty file that a RUN cannot remove (unlink fails, EBUSY). So the name lives
153 + // in os-release's DEFAULT_HOSTNAME, which is a plain COPY'd file no mount
154 + // covers, and which is what systemd fell through to in both failures.
155 + //
156 + // The last assertion matters as much as the first two. `COPY etc/ /etc/` is not
157 + // a RUN and does land, so an `etc/hostname` back in the repo would be a real
158 + // static hostname sitting on top of the baked fallback, undoing the fix without
159 + // touching the line that made it.
160 + #[test]
161 + fn the_baked_hostname_cannot_be_written_where_podman_discards_it() {
162 + let containerfile = read("Containerfile");
163 + assert!(
164 + containerfile.contains("DEFAULT_HOSTNAME=$ALLOY_HOSTNAME/\" /usr/lib/os-release"),
165 + "the identity step no longer bakes the hostname into os-release",
166 + );
167 + assert!(
168 + !containerfile.contains("> /etc/hostname"),
169 + "a RUN writes /etc/hostname, which podman bind mounts and throws away",
170 + );
171 + assert!(
172 + !containerfile.contains("> /usr/lib/hostname"),
173 + "a RUN writes /usr/lib/hostname, which systemd ignores while podman's \
174 + empty /etc/hostname exists",
175 + );
176 + assert!(
177 + !repo().join("etc/hostname").exists(),
178 + "etc/hostname is back in the repo; COPY etc/ would ship it over the baked name",
179 + );
180 + // The fallback the whole mechanism leans on has to be in the file it is
181 + // rewriting, or the sed matches nothing and the guard is the only thing
182 + // between that and a silent no-op.
183 + assert!(
184 + read("usr/lib/os-release").contains("\nDEFAULT_HOSTNAME=alloy\n"),
185 + "os-release has no DEFAULT_HOSTNAME line for the identity step to rewrite",
186 + );
187 + }
@@ -1,1 +1,0 @@
1 - alloy