Skip to main content

max / alloy

Quote the os-release placeholder, and close the DB dial's gaps The VARIANT_ID placeholder shipped unquoted, so `<profile>` read as a redirect and the committed os-release stopped being shell-sourceable: `. /etc/os-release` aborted under set -e and left the field empty otherwise. Quote it, and add a test that every value in the file is either quoted or bare-safe, since the failure takes down every reader rather than only the field that caused it. Three more from the same review: - the DB validator was asserted by a `case "$DB" in` match that the install arm also satisfies, so deleting the validator kept the test green. Key it on the validator's own message instead. - neither new case had a `*)` arm. An unknown DB fell through the install arm running no assertion; an unknown PROFILE died on set -u with an unbound $variant and no line naming the cause. - DB was the fifth mint-time dial and appeared in no document. The manual's list of what `alloy image` asks for was two dials stale. Also pin the DB transaction with install_weak_deps=False, as every neighbouring conditional install does, and stop the comment quoting a patch level that has already moved to 16.15.
Author: Max Johnson <me@maxj.phd> · 2026-08-25 21:05 UTC
Signed with PGP, not checked
Commit: b6677a0311f56bf3f1906d69363fbe904b097c9a
Parent: 2df9496
5 files changed, +72 insertions, -11 deletions
M Containerfile +10 -6
@@ -830,13 +830,15 @@
830 830
831 831 # DB is the database the image carries, and it exists because the version
832 832 # matters more than the presence. `postgres16` installs postgresql16 and
833 - # postgresql16-server, both 16.14-1.fc43 out of `updates`: three packages,
834 - # 9 MiB of download and 38 MiB installed, measured with
835 - # `dnf install --assumeno` on 2026-08-21.
833 + # postgresql16-server out of `updates`, whatever 16.x they are on the day:
834 + # three packages, 9 MiB of download and 38 MiB installed, measured with
835 + # `dnf install --assumeno` against 16.14-1.fc43 on 2026-08-21. The install arm
836 + # greps for the major and not the patch, because the patch moves under us and
837 + # already has: 16.15-1.fc43 by 2026-08-25.
836 838 #
837 839 # Fedora's own `postgresql-server` is 18.4 and is deliberately not what this
838 840 # installs. Sando's scratch cluster exists to test MNW against what production
839 - # runs, and prod `alpha-west-1` is PostgreSQL 16.14; a scratch cluster two
841 + # runs, and prod `alpha-west-1` is PostgreSQL 16; a scratch cluster two
840 842 # majors ahead tests a database nobody deploys.
841 843 #
842 844 # The default is `none` and the image carries neither binary today
@@ -1959,7 +1961,8 @@
1959 1961 RUN set -eu; \
1960 1962 case "$DB" in \
1961 1963 postgres16) \
1962 - dnf install -y postgresql16 postgresql16-server; \
1964 + dnf install -y --setopt=install_weak_deps=False \
1965 + postgresql16 postgresql16-server; \
1963 1966 dnf clean all; \
1964 1967 command -v psql >/dev/null \
1965 1968 || { echo "DB=postgres16 was asked for and psql is not in the image" >&2; exit 1; }; \
@@ -1974,10 +1977,10 @@
1974 1977 ! command -v postgres >/dev/null \
1975 1978 || { echo "DB=none and postgres is in the image anyway" >&2; exit 1; } \
1976 1979 ;; \
1980 + *) echo "unknown DB '$DB' reached the install arm; the validator did not run" >&2; exit 1 ;; \
1977 1981 esac; \
1978 1982 echo "db: $DB"
1979 1983
1980 -
1981 1984 # =====================================================================
1982 1985 # Printing — driverless only, and deliberately in its own layer.
1983 1986 #
@@ -3742,6 +3745,7 @@
3742 3745 case "$PROFILE" in \
3743 3746 client) variant="Client" ;; \
3744 3747 server) variant="Server" ;; \
3748 + *) echo "unknown PROFILE '$PROFILE' reached the os-release stamp" >&2; exit 1 ;; \
3745 3749 esac; \
3746 3750 sed -i "s/^VARIANT=.*/VARIANT=\"$variant\"/; \
3747 3751 s/^VARIANT_ID=.*/VARIANT_ID=$PROFILE/" /usr/lib/os-release; \
@@ -90,8 +90,8 @@
90 90 the finished system unless you replace it.
91 91
92 92 `alloy image` is the same thing with a form around it: it asks for a profile, a
93 - browser, languages, a hostname and a path to a public key, and runs the script
94 - above with what you chose.
93 + browser, languages, a database, what to trim, a hostname and a path to a public
94 + key, and runs the script above with what you chose.
95 95
96 96 Chapter 3 covers the install itself, and what the SSH posture means once the
97 97 machine is up.
@@ -111,6 +111,24 @@
111 111 `crates/alloy/credits.toml`, which is what the installer's credits page
112 112 reads. It is hand-curated on purpose, so nothing adds itself.
113 113
114 + ## Carrying a database
115 +
116 + `DB` decides whether the image ships PostgreSQL, and it defaults to `none`. A
117 + stock mint carries no database and nothing about it changes by this dial
118 + existing.
119 +
120 + podman build --build-arg DB=postgres16 ...
121 +
122 + `postgres16` installs `postgresql16` and `postgresql16-server` from `updates`:
123 + three packages, 9 MiB of download and 38 MiB installed. It is deliberately not
124 + Fedora's own `postgresql-server`, which is 18.4. The build host runs Sando's
125 + scratch cluster to test MNW against what production runs, and production is on
126 + 16, so a cluster two majors ahead would test a database nobody deploys.
127 +
128 + The arm puts the binaries on `PATH` and stops. No `systemctl enable`, no
129 + `initdb`: a cluster is state belonging to the machine that runs it rather than
130 + to the image.
131 +
114 132 ## When the build cannot reach the fonts
115 133
116 134 Alloy cuts its two faces during the build from a pinned upstream base, and the
@@ -6,7 +6,7 @@
6 6 ID=alloy
7 7 ID_LIKE=fedora
8 8 VARIANT="<profile>"
9 - VARIANT_ID=<profile>
9 + VARIANT_ID="<profile>"
10 10 ANSI_COLOR="0;38;2;138;69;48"
11 11 LOGO=alloy
12 12 DEFAULT_HOSTNAME=alloy
@@ -226,7 +226,7 @@
226 226 #[test]
227 227 fn the_committed_os_release_carries_the_profile_placeholder() {
228 228 let os_release = read("usr/lib/os-release");
229 - for line in ["VARIANT=\"<profile>\"", "VARIANT_ID=<profile>"] {
229 + for line in ["VARIANT=\"<profile>\"", "VARIANT_ID=\"<profile>\""] {
230 230 assert!(
231 231 os_release.lines().any(|candidate| candidate == line),
232 232 "os-release has no `{line}` for the profile step to rewrite",
@@ -244,6 +244,36 @@
244 244 }
245 245 }
246 246
247 + // os-release is sourced by shell (`. /etc/os-release`) far more often than it
248 + // is parsed. An unquoted value carrying `<` is a redirect, not an assignment,
249 + // so a placeholder written bare turns the whole file into a syntax error and
250 + // takes every reader down with it rather than only the field it belongs to.
251 + #[test]
252 + fn every_committed_os_release_value_is_shell_safe() {
253 + for line in read("usr/lib/os-release").lines() {
254 + let line = line.trim();
255 + if line.is_empty() || line.starts_with('#') {
256 + continue;
257 + }
258 + let (key, value) = line
259 + .split_once('=')
260 + .unwrap_or_else(|| panic!("os-release line is not an assignment: `{line}`"));
261 + assert!(
262 + key.chars().all(|c| c.is_ascii_uppercase() || c == '_'),
263 + "os-release key `{key}` is not a plain shell name",
264 + );
265 + let quoted = value.len() >= 2 && value.starts_with('"') && value.ends_with('"');
266 + let bare_safe = !value.is_empty()
267 + && value
268 + .chars()
269 + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | ':' | '/'));
270 + assert!(
271 + quoted || bare_safe,
272 + "os-release value for {key} must be quoted to stay sourceable: `{value}`",
273 + );
274 + }
275 + }
276 +
247 277 #[test]
248 278 fn the_committed_os_release_carries_what_the_build_stamp_rewrites() {
249 279 let os_release = read("usr/lib/os-release");
@@ -294,10 +294,19 @@
294 294 #[test]
295 295 fn the_db_dial_is_validated_and_defaults_the_same_in_both_files() {
296 296 let text = containerfile();
297 + // The validator arm specifically, not any `case "$DB" in`. The install arm
298 + // is a second one, so a bare `contains` stays green with the validator
299 + // deleted, and an unknown value then reaches an install `case` that matches
300 + // nothing.
297 301 assert!(
298 - text.contains(r#"case "$DB" in"#),
302 + text.contains(r#"unknown DB '$DB'; expected 'none' or 'postgres16'"#),
299 303 "DB is not validated against its set; a typo would build the wrong image silently"
300 304 );
305 + assert_eq!(
306 + text.matches(r#"case "$DB" in"#).count(),
307 + 2,
308 + "expected exactly two `case \"$DB\" in`: the validator and the install arm"
309 + );
301 310
302 311 let arg = text
303 312 .lines()