Skip to main content

max / alloy

Add a DB mint-time dial carrying postgresql 16
Author: Max Johnson <me@maxj.phd> · 2026-08-25 20:47 UTC
Signed with PGP, not checked
Commit: 2df9496d3d8d58ebe2ad80e36f5f2efafa078dff
Parent: 7b42fc2
4 files changed, +236 insertions, -15 deletions
M Containerfile +69 -1
@@ -828,6 +828,32 @@
828 828 # estimate, is confirmed absent from the built image.
829 829 ARG LANGS=rust
830 830
831 + # DB is the database the image carries, and it exists because the version
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.
836 + #
837 + # Fedora's own `postgresql-server` is 18.4 and is deliberately not what this
838 + # 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
840 + # majors ahead tests a database nobody deploys.
841 + #
842 + # The default is `none` and the image carries neither binary today
843 + # (`command -v psql postgres` finds nothing, `rpm -q postgresql` says not
844 + # installed), so a default mint is unchanged by this dial existing.
845 + #
846 + # No `systemctl enable`, no initdb. The cluster is machine state rather than
847 + # image state and belongs with the machine that runs it. This arm puts the
848 + # binaries on PATH and stops.
849 + #
850 + # A dial rather than a gate on `PROFILE=server` (Max, 2026-08-25): whether the
851 + # server profile is a build-host surface is a separate open question, and
852 + # hanging the database off the profile would have answered it by accident. If
853 + # it is later ruled that way, `DB` can default to `postgres16` under that
854 + # profile without changing anything here.
855 + ARG DB=none
856 +
831 857 # TRIM is the one builder choice about the *base* rather than about Alloy.
832 858 # fedora-bootc is a general-purpose server base, and three of the things it
833 859 # carries are unreachable from any Alloy install however the machine is used:
@@ -891,6 +917,10 @@
891 917 *) echo "unknown language '$lang'; the builder offers rust, go, python, zig and js" >&2; exit 1 ;; \
892 918 esac; \
893 919 done; \
920 + case "$DB" in \
921 + none|postgres16) ;; \
922 + *) echo "unknown DB '$DB'; expected 'none' or 'postgres16'" >&2; exit 1 ;; \
923 + esac; \
894 924 case "$TRIM" in \
895 925 unused|keep) ;; \
896 926 *) echo "unknown TRIM '$TRIM'; expected 'unused' or 'keep'" >&2; exit 1 ;; \
@@ -899,7 +929,7 @@
899 929 client:*|server:none) ;; \
900 930 server:*) echo "PROFILE=server ships no graphical session and cannot carry BROWSER=$BROWSER" >&2; exit 1 ;; \
901 931 esac; \
902 - echo "building profile=$PROFILE browser=$BROWSER langs=$LANGS trim=$TRIM"
932 + echo "building profile=$PROFILE browser=$BROWSER langs=$LANGS trim=$TRIM db=$DB"
903 933
904 934 # =====================================================================
905 935 # Install-time filtering: translations and documentation
@@ -1911,6 +1941,43 @@
1911 1941 done; \
1912 1942 echo "languages: ${LANGS:-none}"
1913 1943
1944 + # =====================================================================
1945 + # The database, from the DB choice.
1946 + # =====================================================================
1947 + # `ARG DB` above argues the version: this installs postgresql16 rather than
1948 + # Fedora's default postgresql-server, because the scratch cluster Sando runs
1949 + # has to be the major production runs. The packages, the weight and the
1950 + # measurement date are on the ARG.
1951 + #
1952 + # Binaries only. Nothing here enables a unit or runs initdb, so a mint with
1953 + # DB=postgres16 boots with psql and postgres on PATH and no cluster. Creating
1954 + # one is the machine's business.
1955 + #
1956 + # Asserted on both arms, the way the language block above is: `none` has to
1957 + # stay empty, or the dial is decorative and an image is carrying a database
1958 + # nobody asked for.
1959 + RUN set -eu; \
1960 + case "$DB" in \
1961 + postgres16) \
1962 + dnf install -y postgresql16 postgresql16-server; \
1963 + dnf clean all; \
1964 + command -v psql >/dev/null \
1965 + || { echo "DB=postgres16 was asked for and psql is not in the image" >&2; exit 1; }; \
1966 + command -v postgres >/dev/null \
1967 + || { echo "DB=postgres16 was asked for and postgres is not in the image" >&2; exit 1; }; \
1968 + psql --version | grep -q " 16\." \
1969 + || { echo "DB=postgres16 installed $(psql --version), which is not the major production runs" >&2; exit 1; } \
1970 + ;; \
1971 + none) \
1972 + ! command -v psql >/dev/null \
1973 + || { echo "DB=none and psql is in the image anyway" >&2; exit 1; }; \
1974 + ! command -v postgres >/dev/null \
1975 + || { echo "DB=none and postgres is in the image anyway" >&2; exit 1; } \
1976 + ;; \
1977 + esac; \
1978 + echo "db: $DB"
1979 +
1980 +
1914 1981 # =====================================================================
1915 1982 # Printing — driverless only, and deliberately in its own layer.
1916 1983 #
@@ -3615,6 +3682,7 @@
3615 3682 echo "browser = \"$BROWSER\""; \
3616 3683 echo "langs = $langs"; \
3617 3684 echo "trim = \"$TRIM\""; \
3685 + echo "db = \"$DB\""; \
3618 3686 echo "hostname = \"$ALLOY_HOSTNAME\""; \
3619 3687 echo "pubkey = \"\""; \
3620 3688 } > /usr/lib/alloy/build.toml; \
@@ -344,6 +344,47 @@
344 344 }
345 345 }
346 346
347 + /// Whether the image carries a database, and which major.
348 + ///
349 + /// The major is the whole point of the dial. Fedora's default
350 + /// `postgresql-server` is 18, and Sando's scratch cluster exists to test MNW
351 + /// against what production runs, which is PostgreSQL 16.14 on `alpha-west-1`.
352 + /// So `postgres16` names `postgresql16` and `postgresql16-server` rather than
353 + /// the unversioned packages: three packages, 38 MiB installed.
354 + ///
355 + /// Binaries only. Nothing in the image enables a unit or runs initdb; a
356 + /// cluster is machine state and belongs to whoever runs the machine.
357 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
358 + pub(crate) enum Db {
359 + /// No database. The default, and what every image before this dial was.
360 + #[default]
361 + None,
362 + /// PostgreSQL 16, the major production runs.
363 + Postgres16,
364 + }
365 +
366 + impl Db {
367 + const ALL: [Db; 2] = [Db::None, Db::Postgres16];
368 +
369 + const fn value(self) -> &'static str {
370 + match self {
371 + Db::None => "none",
372 + Db::Postgres16 => "postgres16",
373 + }
374 + }
375 +
376 + const fn label(self) -> &'static str {
377 + match self {
378 + Db::None => "none",
379 + Db::Postgres16 => "postgresql 16 (38 MiB; the major prod runs)",
380 + }
381 + }
382 +
383 + fn parse(value: &str) -> Option<Self> {
384 + Self::ALL.into_iter().find(|d| d.value() == value)
385 + }
386 + }
387 +
347 388 /// What the build produces.
348 389 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
349 390 pub(crate) enum Artifact {
@@ -410,6 +451,7 @@
410 451 pub(crate) pubkey: String,
411 452 pub(crate) artifact: Artifact,
412 453 pub(crate) trim: Trim,
454 + pub(crate) db: Db,
413 455 }
414 456
415 457 impl Default for Choices {
@@ -434,6 +476,7 @@
434 476 pubkey: String::new(),
435 477 artifact: Artifact::default(),
436 478 trim: Trim::default(),
479 + db: Db::default(),
437 480 }
438 481 }
439 482 }
@@ -460,6 +503,7 @@
460 503 .join(","),
461 504 ),
462 505 ("TRIM".to_string(), self.trim.value().to_string()),
506 + ("DB".to_string(), self.db.value().to_string()),
463 507 ];
464 508 if !self.hostname.is_empty() {
465 509 args.push(("ALLOY_HOSTNAME".to_string(), self.hostname.clone()));
@@ -587,6 +631,7 @@
587 631 .collect();
588 632 let _ = writeln!(out, "langs = [{}]", langs.join(", "));
589 633 let _ = writeln!(out, "trim = {:?}", self.trim.value());
634 + let _ = writeln!(out, "db = {:?}", self.db.value());
590 635 let _ = writeln!(out, "artifact = {:?}", self.artifact.value());
591 636 let _ = writeln!(out, "hostname = {:?}", self.hostname);
592 637 let _ = writeln!(out, "pubkey = {:?}", self.pubkey);
@@ -618,6 +663,9 @@
618 663 if let Some(value) = word("trim") {
619 664 choices.trim = Trim::parse(value).with_context(|| format!("unknown trim `{value}`"))?;
620 665 }
666 + if let Some(value) = word("db") {
667 + choices.db = Db::parse(value).with_context(|| format!("unknown db `{value}`"))?;
668 + }
621 669 if let Some(value) = word("artifact") {
622 670 choices.artifact =
623 671 Artifact::parse(value).with_context(|| format!("unknown artifact `{value}`"))?;
@@ -799,6 +847,7 @@
799 847 /// things you can see the state of.
800 848 Lang(Lang),
801 849 Trim,
850 + Db,
802 851 Artifact,
803 852 Hostname,
804 853 Pubkey,
@@ -811,7 +860,13 @@
811 860 fn all() -> Vec<Row> {
812 861 let mut rows = vec![Row::Profile, Row::Browser];
813 862 rows.extend(Lang::ALL.map(Row::Lang));
814 - rows.extend([Row::Trim, Row::Artifact, Row::Hostname, Row::Pubkey]);
863 + rows.extend([
864 + Row::Trim,
865 + Row::Db,
866 + Row::Artifact,
867 + Row::Hostname,
868 + Row::Pubkey,
869 + ]);
815 870 rows
816 871 }
817 872
@@ -821,6 +876,7 @@
821 876 Row::Browser => "browser".to_string(),
822 877 Row::Lang(lang) => lang.value().to_string(),
823 878 Row::Trim => "base trim".to_string(),
879 + Row::Db => "database".to_string(),
824 880 Row::Artifact => "artifact".to_string(),
825 881 Row::Hostname => "hostname".to_string(),
826 882 Row::Pubkey => "ssh pubkey".to_string(),
@@ -835,6 +891,7 @@
835 891 Row::Browser => "the one stack pick Alloy declines to make for you",
836 892 Row::Lang(lang) => lang.label(),
837 893 Row::Trim => "base packages nothing in Alloy reaches. Never firmware",
894 + Row::Db => "the binaries, not a cluster. 16 is what production runs",
838 895 Row::Artifact => "ISO boots into the installer; raw and qcow2 are installed systems",
839 896 Row::Hostname => "baked in, so a headless box is found at <name>.local",
840 897 Row::Pubkey => "a PUBLIC key from ~/.ssh, or a path. The installer's only credential",
@@ -970,6 +1027,9 @@
970 1027 Row::Trim => {
971 1028 self.choices.trim = step(&Trim::ALL, self.choices.trim, forward);
972 1029 }
1030 + Row::Db => {
1031 + self.choices.db = step(&Db::ALL, self.choices.db, forward);
1032 + }
973 1033 Row::Artifact => {
974 1034 self.choices.artifact = step(&Artifact::ALL, self.choices.artifact, forward);
975 1035 }
@@ -1235,6 +1295,13 @@
1235 1295 label: self.choices.trim.label(),
1236 1296 },
1237 1297 ),
1298 + Row::Db => alloy_tui::AlloyField::new(
1299 + theme,
1300 + label,
1301 + FieldKind::Enum {
1302 + label: self.choices.db.label(),
1303 + },
1304 + ),
1238 1305 Row::Artifact => alloy_tui::AlloyField::new(
1239 1306 theme,
1240 1307 label,
@@ -1410,6 +1477,9 @@
1410 1477 // Trimmed by default. What it costs is foreign-architecture emulation,
1411 1478 // which the house rules forbid using in the first place.
1412 1479 assert_eq!(choices.trim, Trim::Unused);
1480 + // No database. It is opt-in for the build-host role and every image
1481 + // before the dial existed carried none.
1482 + assert_eq!(choices.db, Db::None);
1413 1483 }
1414 1484
1415 1485 /// The build args are the whole contract with the Containerfile, so their
@@ -1419,7 +1489,7 @@
1419 1489 fn the_build_args_name_what_the_containerfile_reads() {
1420 1490 let args = Choices::default().build_args();
1421 1491 let names: Vec<&str> = args.iter().map(|(k, _)| k.as_str()).collect();
1422 - assert_eq!(names, ["PROFILE", "BROWSER", "LANGS", "TRIM"]);
1492 + assert_eq!(names, ["PROFILE", "BROWSER", "LANGS", "TRIM", "DB"]);
1423 1493 assert_eq!(args[0].1, "client");
1424 1494 // Comma-joined in the enum's declared order, and this is also the
1425 1495 // literal the Containerfile's own `ARG LANGS` default has to match:
@@ -1428,6 +1498,7 @@
1428 1498 // asked for.
1429 1499 assert_eq!(args[2].1, "rust");
1430 1500 assert_eq!(args[3].1, "unused");
1501 + assert_eq!(args[4].1, "none");
1431 1502 }
1432 1503
1433 1504 #[test]
@@ -1494,6 +1565,7 @@
1494 1565 langs: BTreeSet::from([Lang::Go]),
1495 1566 artifact: Artifact::Qcow2,
1496 1567 trim: Trim::Keep,
1568 + db: Db::Postgres16,
1497 1569 hostname: "bench".to_string(),
1498 1570 pubkey: "/home/max/.ssh/id_ed25519.pub".to_string(),
1499 1571 };
@@ -1504,6 +1576,7 @@
1504 1576 assert_eq!(parsed.langs, BTreeSet::from([Lang::Go]));
1505 1577 assert_eq!(parsed.artifact, Artifact::Qcow2);
1506 1578 assert_eq!(parsed.trim, Trim::Keep);
1579 + assert_eq!(parsed.db, Db::Postgres16);
1507 1580 assert_eq!(parsed.hostname, "bench");
1508 1581 assert_eq!(parsed.pubkey, "/home/max/.ssh/id_ed25519.pub");
1509 1582 }
@@ -80,7 +80,14 @@
80 80 /// `HOME` is not enough because the path is `/usr/lib/alloy`. Rewriting the
81 81 /// path for the test rather than mocking it keeps the script byte-identical to
82 82 /// the one in the image apart from its prefix.
83 - fn record_for(profile: &str, browser: &str, langs: &str, trim: &str, hostname: &str) -> String {
83 + fn record_for(
84 + profile: &str,
85 + browser: &str,
86 + langs: &str,
87 + trim: &str,
88 + db: &str,
89 + hostname: &str,
90 + ) -> String {
84 91 // Unique per call, not per set of arguments. Tests run concurrently and
85 92 // three of them ask for the same combination, so a name derived from the
86 93 // arguments had two threads sharing one directory and removing it from
@@ -106,6 +113,7 @@
106 113 .env("BROWSER", browser)
107 114 .env("LANGS", langs)
108 115 .env("TRIM", trim)
116 + .env("DB", db)
109 117 .env("ALLOY_HOSTNAME", hostname)
110 118 .env("ALLOY_SSH_KEY", "")
111 119 .output()
@@ -113,7 +121,7 @@
113 121
114 122 assert!(
115 123 output.status.success(),
116 - "the record writer failed for profile={profile} browser={browser} langs={langs} trim={trim}:\n{}",
124 + "the record writer failed for profile={profile} browser={browser} langs={langs} trim={trim} db={db}:\n{}",
117 125 String::from_utf8_lossy(&output.stderr),
118 126 );
119 127
@@ -129,15 +137,29 @@
129 137 #[test]
130 138 fn every_record_the_build_can_write_parses() {
131 139 let cases = [
132 - ("client", "firefox", "rust", "unused", "bench"),
133 - ("client", "firefox", "rust,go,python,zig", "keep", ""),
134 - ("client", "none", "", "unused", "laptop"),
135 - ("server", "none", "rust", "keep", "build-host-2"),
136 - ("server", "none", "", "unused", ""),
140 + ("client", "firefox", "rust", "unused", "none", "bench"),
141 + (
142 + "client",
143 + "firefox",
144 + "rust,go,python,zig",
145 + "keep",
146 + "none",
147 + "",
148 + ),
149 + ("client", "none", "", "unused", "postgres16", "laptop"),
150 + (
151 + "server",
152 + "none",
153 + "rust",
154 + "keep",
155 + "postgres16",
156 + "build-host-2",
157 + ),
158 + ("server", "none", "", "unused", "none", ""),
137 159 ];
138 160
139 - for (profile, browser, langs, trim, hostname) in cases {
140 - let record = record_for(profile, browser, langs, trim, hostname);
161 + for (profile, browser, langs, trim, db, hostname) in cases {
162 + let record = record_for(profile, browser, langs, trim, db, hostname);
141 163
142 164 let parsed = alloy_build_record::parse(&record).unwrap_or_else(|err| {
143 165 panic!(
@@ -150,6 +172,7 @@
150 172 assert_eq!(parsed.browser, browser, "browser round trip\n{record}");
151 173 assert_eq!(parsed.hostname, hostname, "hostname round trip\n{record}");
152 174 assert_eq!(parsed.trim, trim, "trim round trip\n{record}");
175 + assert_eq!(parsed.db, db, "db round trip\n{record}");
153 176
154 177 let expected: Vec<&str> = if langs.is_empty() {
155 178 Vec::new()
@@ -165,7 +188,7 @@
165 188 /// empty string. It parses either way as TOML; only one of them is true.
166 189 #[test]
167 190 fn no_languages_is_an_empty_list_rather_than_one_empty_entry() {
168 - let record = record_for("server", "none", "", "unused", "");
191 + let record = record_for("server", "none", "", "unused", "none", "");
169 192 assert!(
170 193 record.contains("langs = []"),
171 194 "an empty LANGS must write an empty array:\n{record}"
@@ -177,7 +200,7 @@
177 200 /// it reproduces decisions rather than an image.
178 201 #[test]
179 202 fn the_record_disclaims_being_a_lockfile() {
180 - let record = record_for("client", "firefox", "rust", "unused", "bench");
203 + let record = record_for("client", "firefox", "rust", "unused", "none", "bench");
181 204 assert!(
182 205 record.contains("not a lockfile"),
183 206 "the record must say it pins choices and not resolutions:\n{record}"
@@ -189,7 +212,7 @@
189 212 /// shape and nobody reads a missing key as a key that was lost.
190 213 #[test]
191 214 fn the_image_record_carries_no_pubkey_path() {
192 - let record = record_for("client", "firefox", "rust", "unused", "bench");
215 + let record = record_for("client", "firefox", "rust", "unused", "none", "bench");
193 216 assert!(record.contains("pubkey = \"\""), "{record}");
194 217 }
195 218
@@ -208,6 +231,7 @@
208 231 pub(crate) browser: String,
209 232 pub(crate) langs: Vec<String>,
210 233 pub(crate) trim: String,
234 + pub(crate) db: String,
211 235 pub(crate) hostname: String,
212 236 }
213 237
@@ -241,6 +265,7 @@
241 265 browser: word("browser")?,
242 266 langs,
243 267 trim: word("trim")?,
268 + db: word("db")?,
244 269 hostname: word("hostname")?,
245 270 })
246 271 }
@@ -282,3 +282,58 @@
282 282 "ARG LANGS={arg} and image.rs's default disagree; they are one decision",
283 283 );
284 284 }
285 +
286 + /// `DB` is validated against its set, and its default is the same word in the
287 + /// Containerfile and in the builder.
288 + ///
289 + /// The dial names a major rather than a package: `postgres16` installs
290 + /// `postgresql16`, not Fedora's default `postgresql-server`, because the
291 + /// scratch cluster it exists for has to match what production runs. A typo
292 + /// that built an image with no database, or with the wrong major, would be
293 + /// found by whoever went looking for `psql` months later.
294 + #[test]
295 + fn the_db_dial_is_validated_and_defaults_the_same_in_both_files() {
296 + let text = containerfile();
297 + assert!(
298 + text.contains(r#"case "$DB" in"#),
299 + "DB is not validated against its set; a typo would build the wrong image silently"
300 + );
301 +
302 + let arg = text
303 + .lines()
304 + .find_map(|line| line.strip_prefix("ARG DB="))
305 + .expect("no ARG DB in the Containerfile")
306 + .trim();
307 +
308 + let source =
309 + std::fs::read_to_string(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/image.rs"))
310 + .expect("cannot read image.rs");
311 + assert!(
312 + source.contains("db: Db::default()"),
313 + "the builder does not default its db field"
314 + );
315 +
316 + // Which variant carries `#[default]`, read out of the enum rather than
317 + // assumed: the field defaulting is half the claim, and the other half is
318 + // what it defaults to.
319 + let body = source
320 + .split_once("pub(crate) enum Db {")
321 + .and_then(|(_, rest)| rest.split_once('}'))
322 + .map(|(body, _)| body)
323 + .expect("no Db enum in image.rs");
324 + let variant = body
325 + .split_once("#[default]")
326 + .and_then(|(_, rest)| rest.split(',').next())
327 + .map(str::trim)
328 + .expect("no variant in Db carries #[default]");
329 +
330 + let expected = match arg {
331 + "none" => "None",
332 + "postgres16" => "Postgres16",
333 + other => panic!("ARG DB={other} is not a word the builder offers"),
334 + };
335 + assert_eq!(
336 + variant, expected,
337 + "ARG DB={arg} and the default variant of Db disagree; they are one decision",
338 + );
339 + }