max / alloy
4 files changed,
+236 insertions,
-15 deletions
| @@ -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 | + | } |