//! What a machine IS, declared in the topology rather than discovered. //! //! Bento and Sando know a host's name, ssh target, architecture and //! capabilities. Neither knew what the box was, so everything downstream of //! that got rediscovered by probing or by a build failing: an `ldd` guard on //! the node before the symlink swap, a `glibc_check` in a recipe, a rebuilt //! astra regressing silently because its toolchain lived in a `.bashrc` line. //! Each of those asks "what is this machine" at runtime, on the far side of the //! build. //! //! This is the declared half. A host or node states its base image and its libc //! in the topology, and the declaration is checked against the machine before //! anything is built or shipped. //! //! ## Declared, and verified against reality. The two are not in tension //! //! The rule the topology follows is DECLARED, NOT SNIFFED: the value the //! pipelines reason about is the one written in config, because deriving it //! from the host trades drift you can see for drift you assume away. //! //! Verifying that declaration against the machine is the opposite of deriving //! it. Nothing here reads a host to decide what it is; it reads a host to decide //! whether the config is still telling the truth. A declaration nobody checks //! rots into a comment, and the failure it was written to prevent (a rebuilt box //! quietly becoming something else) is exactly the one it would then miss. //! //! ## Silence is a skip here, not a refusal //! //! [`crate::base_image`] deliberately does NOT copy `Placement::check`'s //! four-case rule, where one side stating and the other silent is a refusal. //! That rule is right for a platform, where `linux/aarch64` and `linux/x86_64` //! are a hard incompatibility and the pairing is a choice the system makes. It //! is wrong here for two reasons: //! //! - A host that declares nothing is the fleet as it stands. macOS and Windows //! build hosts have no `/etc/os-release`, and refusing them would take the //! Apple and Windows pipelines down to buy nothing. //! - Base-image equality is not the compatibility requirement. fw13 is //! `pop/24.04` and production is `ubuntu/24.04`, two different bases that //! share glibc 2.39, and binaries built on one run on the other today. A //! check that demanded equal bases would refuse every MNW deploy while //! describing a problem that does not exist. //! //! So the contract is narrow and true: a host that declares a base image must //! match it. A host that declares nothing is not checked, and says so. //! //! ## What this does not do //! //! It does not compare a build host's libc against a deploy node's. That is the //! real compatibility question and it is still answered at runtime, by Sando's //! `ldd` guard before the symlink swap. Hoisting it to preflight is the natural //! next step and needs the declarations this module adds, which is why it is //! filed separately rather than smuggled in here. //! //! use std::fmt; use std::str::FromStr; use serde::{Deserialize, Serialize}; /// A machine's base image, as `id/version`: `alloy/0.1`, `ubuntu/24.04`, /// `pop/24.04`. /// /// The two halves are `ID` and `VERSION_ID` from `/etc/os-release`, which every /// mainstream Linux sets and which Alloy sets deliberately (`ID=alloy`, /// `VERSION_ID` moving by hand on a release). Parsed rather than stringly so a /// comparison is of two values and not of two spellings, on the same reasoning /// as `Platform`. /// /// Deliberately NOT the whole of Alloy's identity. Alloy also stamps /// `IMAGE_VERSION` (which build) and `ALLOY_BASE` (which Fedora), on three /// separate clocks. `IMAGE_VERSION` is too fine to compare: it moves every /// build, so requiring it to match would fail a deploy because the image was /// rebuilt, which is not a fact about compatibility. `ID` and `VERSION_ID` are /// the pair that decides what a binary can link against. #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(try_from = "String", into = "String")] pub struct BaseImage { id: String, version: String, } #[derive(Debug, thiserror::Error, PartialEq, Eq)] pub enum BaseImageParseError { #[error("base image `{0}` is not `id/version` (e.g. `ubuntu/24.04`, `alloy/0.1`)")] BadShape(String), } impl BaseImage { pub fn parse(s: &str) -> Result { let bad = || BaseImageParseError::BadShape(s.to_owned()); let (id, version) = s.split_once('/').ok_or_else(bad)?; // Same character class `Platform` accepts. `VERSION_ID` is routinely // dotted (`24.04`), and Alloy's is `0.1`. let part_ok = |p: &str| { !p.is_empty() && p.bytes() .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'.') }; if !part_ok(id) || !part_ok(version) { return Err(bad()); } Ok(Self { id: id.to_ascii_lowercase(), version: version.to_ascii_lowercase(), }) } pub fn id(&self) -> &str { &self.id } pub fn version(&self) -> &str { &self.version } } impl fmt::Display for BaseImage { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}/{}", self.id, self.version) } } impl FromStr for BaseImage { type Err = BaseImageParseError; fn from_str(s: &str) -> Result { Self::parse(s) } } impl TryFrom for BaseImage { type Error = BaseImageParseError; fn try_from(s: String) -> Result { Self::parse(&s) } } impl From for String { fn from(b: BaseImage) -> Self { b.to_string() } } /// The shell one-liner a host runs to report what it is. /// /// Sources `/etc/os-release` rather than parsing it here, because the file is /// defined as shell-sourceable and quoting varies (`VERSION_ID="24.04"` on /// Ubuntu, unquoted elsewhere). Sourcing hands the quoting to the shell that /// owns the format. /// /// Failure is reported in the output rather than in the exit status: every /// branch prints its key with an empty value and the command exits 0, so a /// missing file or a missing `ldd` comes back as an unreadable identity that /// the caller can describe, not as an opaque non-zero from a step that also /// runs other things. pub fn probe_cmd() -> String { // `ldd --version` writes the version line to stdout on glibc and is absent // entirely on musl images; `2>/dev/null` plus the empty default covers both. "if [ -r /etc/os-release ]; then . /etc/os-release; fi; \ printf 'id=%s\\n' \"${ID:-}\"; \ printf 'version_id=%s\\n' \"${VERSION_ID:-}\"; \ printf 'libc=%s\\n' \"$(ldd --version 2>/dev/null | head -1 | awk '{print $NF}')\"" .to_string() } /// What a host said about itself in answer to [`probe_cmd`]. /// /// Every field is optional because every field can legitimately be absent: a /// machine with no `/etc/os-release`, or one with no `ldd`. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct ReportedIdentity { pub base: Option, /// The glibc version as `ldd --version` states it, e.g. `2.39`. The trailing /// field of that line, which is the upstream version on both spellings seen /// in the fleet (`ldd (Ubuntu GLIBC 2.39-0ubuntu8.7) 2.39` and /// `ldd (GNU libc) 2.42`). pub libc: Option, } /// Read [`probe_cmd`]'s output. /// /// Tolerant by construction: unknown keys are ignored and a malformed base /// image reads as absent rather than as an error, because this parses a remote /// machine's answer and the useful failure is "that host could not tell me what /// it is", raised by [`check`] against what was declared. pub fn parse_probe(stdout: &str) -> ReportedIdentity { let mut id = String::new(); let mut version = String::new(); let mut libc = None; for line in stdout.lines() { let Some((k, v)) = line.split_once('=') else { continue; }; let v = v.trim(); match k.trim() { "id" => id = v.to_string(), "version_id" => version = v.to_string(), "libc" if !v.is_empty() => libc = Some(v.to_string()), _ => {} } } let base = if id.is_empty() || version.is_empty() { None } else { BaseImage::parse(&format!("{id}/{version}")).ok() }; ReportedIdentity { base, libc } } /// Why a host's declaration and the host itself disagree. #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub enum IdentityDrift { #[error( "`{host}` is declared as {declared} and reports {reported}. \ Either the machine was rebuilt or the topology is stale; fix whichever \ is wrong before building here" )] BaseMismatch { host: String, declared: BaseImage, reported: BaseImage, }, #[error( "`{host}` is declared as {declared} but could not say what it is \ (no readable /etc/os-release). A declaration that cannot be checked is \ the drift this exists to catch; remove the declaration or fix the host" )] BaseUnreadable { host: String, declared: BaseImage }, #[error( "`{host}` is declared to have glibc {declared} and reports {reported}. \ The libc floor a binary built here carries would be wrong" )] LibcMismatch { host: String, declared: String, reported: String, }, #[error( "`{host}` is declared to have glibc {declared} and reports none \ (no ldd). Remove the declaration or fix the host" )] LibcUnreadable { host: String, declared: String }, } /// Compare what a host was declared to be against what it says it is. /// /// `Ok(None)` means nothing was declared and nothing was checked; the caller /// reports that rather than passing silently, so an unchecked host is visible /// in a build log instead of looking like a checked one. /// /// `libc` is checked only when declared, independently of the base image. The /// two are separate facts: a base image can be pinned while the point release /// under it moves, and it is the libc number that decides whether a binary /// loads. pub fn check( host: &str, declared: Option<&BaseImage>, declared_libc: Option<&str>, reported: &ReportedIdentity, ) -> Result, IdentityDrift> { if let Some(declared) = declared { match &reported.base { None => { return Err(IdentityDrift::BaseUnreadable { host: host.to_string(), declared: declared.clone(), }); } Some(reported) if reported != declared => { return Err(IdentityDrift::BaseMismatch { host: host.to_string(), declared: declared.clone(), reported: reported.clone(), }); } Some(_) => {} } } if let Some(want) = declared_libc { match reported.libc.as_deref() { None => { return Err(IdentityDrift::LibcUnreadable { host: host.to_string(), declared: want.to_string(), }); } Some(got) if got != want => { return Err(IdentityDrift::LibcMismatch { host: host.to_string(), declared: want.to_string(), reported: got.to_string(), }); } Some(_) => {} } } Ok(match (declared, declared_libc) { (None, None) => None, (Some(b), None) => Some(format!("{host} is {b}")), (Some(b), Some(l)) => Some(format!("{host} is {b}, glibc {l}")), (None, Some(l)) => Some(format!("{host} has glibc {l}")), }) } #[cfg(test)] mod tests { use super::*; fn img(s: &str) -> BaseImage { BaseImage::parse(s).expect("test base image must parse") } #[test] fn a_base_image_is_a_shape_not_a_spelling() { assert_eq!(img("Ubuntu/24.04").to_string(), "ubuntu/24.04"); assert_eq!(img("alloy/0.1").id(), "alloy"); assert_eq!(img("alloy/0.1").version(), "0.1"); for bad in ["ubuntu", "ubuntu/", "/24.04", "ubuntu/24 04", "a/b/c"] { assert!( BaseImage::parse(bad).is_err(), "`{bad}` must not parse as a base image" ); } } /// The two `ldd --version` spellings actually present in the fleet. Ubuntu /// and Pop!_OS put the distro and the package release in the parenthesis; /// Fedora (so Alloy) does not. The trailing field is the upstream version /// on both, which is why it is the field taken. #[test] fn libc_is_read_from_both_ldd_spellings() { let ubuntu = parse_probe("id=ubuntu\nversion_id=24.04\nlibc=2.39\n"); assert_eq!(ubuntu.libc.as_deref(), Some("2.39")); assert_eq!(ubuntu.base, Some(img("ubuntu/24.04"))); let alloy = parse_probe("id=alloy\nversion_id=0.1\nlibc=2.42\n"); assert_eq!(alloy.libc.as_deref(), Some("2.42")); assert_eq!(alloy.base, Some(img("alloy/0.1"))); } #[test] fn an_unreadable_host_reports_nothing_rather_than_a_half_answer() { let none = parse_probe("id=\nversion_id=\nlibc=\n"); assert_eq!(none, ReportedIdentity::default()); // A half-answer is not a base image: `ID` with no `VERSION_ID` cannot be // compared against a declaration that carries both. let half = parse_probe("id=ubuntu\nversion_id=\nlibc=2.39\n"); assert_eq!(half.base, None); assert_eq!(half.libc.as_deref(), Some("2.39")); } #[test] fn a_host_that_declares_nothing_is_not_checked_and_says_so() { let reported = parse_probe("id=ubuntu\nversion_id=24.04\nlibc=2.39\n"); assert_eq!(check("windows-x86", None, None, &reported), Ok(None)); } #[test] fn a_declared_host_must_match() { let reported = parse_probe("id=ubuntu\nversion_id=26.04\nlibc=2.43\n"); assert_eq!( check("testnot", Some(&img("ubuntu/24.04")), None, &reported), Err(IdentityDrift::BaseMismatch { host: "testnot".into(), declared: img("ubuntu/24.04"), reported: img("ubuntu/26.04"), }) ); assert!( check("testnot", Some(&img("ubuntu/26.04")), None, &reported) .expect("a matching declaration must pass") .is_some(), "a checked host must report what it was checked against" ); } /// The case this whole module exists for: a box rebuilt into something else /// while the topology still describes the old one. Before the declaration /// this surfaced as a build failing with a resolver error naming the wrong /// cause. #[test] fn a_rebuilt_host_is_caught_rather_than_discovered_by_a_build_failing() { let rebuilt = parse_probe("id=fedora\nversion_id=43\nlibc=2.42\n"); let err = check("astra", Some(&img("pop/24.04")), None, &rebuilt) .expect_err("a rebuilt host must not pass its old declaration"); assert!( err.to_string().contains("pop/24.04") && err.to_string().contains("fedora/43"), "the refusal must name both what was declared and what is there: {err}" ); } /// A declaration that cannot be verified is refused rather than waved /// through. Waving it through is how a declaration decays into a comment. #[test] fn a_declaration_that_cannot_be_checked_is_a_refusal() { let silent = ReportedIdentity::default(); assert!(matches!( check("prod", Some(&img("ubuntu/24.04")), None, &silent), Err(IdentityDrift::BaseUnreadable { .. }) )); assert!(matches!( check("prod", None, Some("2.39"), &silent), Err(IdentityDrift::LibcUnreadable { .. }) )); } /// libc is checked independently of the base image, because a point release /// can move under a pinned base and it is the libc number that decides /// whether a binary loads. #[test] fn libc_drifts_independently_of_the_base_image() { let drifted = parse_probe("id=ubuntu\nversion_id=24.04\nlibc=2.41\n"); assert_eq!( check("prod", Some(&img("ubuntu/24.04")), Some("2.39"), &drifted), Err(IdentityDrift::LibcMismatch { host: "prod".into(), declared: "2.39".into(), reported: "2.41".into(), }) ); } /// The probe must survive a host with no `/etc/os-release` without failing /// the step it rides in, so the shape of the command matters: it prints all /// three keys unconditionally. #[test] fn the_probe_prints_every_key_and_sources_rather_than_parses() { let cmd = probe_cmd(); assert!(cmd.contains(". /etc/os-release"), "{cmd}"); for key in ["id=%s", "version_id=%s", "libc=%s"] { assert!(cmd.contains(key), "probe must print {key}: {cmd}"); } } }