max / alloy
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
4 files changed,
+93 insertions,
-7 deletions
| @@ -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 |