From 73eaeb57acba6a8b1e060ffa2ab17644ee165011 Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Fri, 30 Oct 2026 00:12:51 +0100 Subject: [PATCH] feat(distro): ubuntu-base from real releases, codename and number aliased Ubuntu tarballs came from the daily/current directories - moving targets, no published checksums guarantee, gone when a release EOLs. resolve_ubuntu_url now builds the cdimage release URL: ubuntu-base/releases//release/ubuntu-base--base-.tar.gz, whose SHA256SUMS the existing checksum verification consumes. Resolution returns the YY.MM number (the cache key follows), and accepts the codename interchangeably: a static table maps the known codenames offline - ubuntu:resolute and ubuntu:26.04 are the same image and the same entry - with meta-release as a fallback for codenames newer than the table. Floating aliases (latest/lts) still consult meta-release; explicit numbers never touch the network, which also removes the per-cache-path network lookup generate_cache_filename used to trigger for codename inputs. URLs verified live: tarball and SHA256SUMS both 200. --- crates/ecrou/src/distro.rs | 264 ++++++++++++++++++++++++------------- crates/ecrou/src/rootfs.rs | 13 +- 2 files changed, 182 insertions(+), 95 deletions(-) diff --git a/crates/ecrou/src/distro.rs b/crates/ecrou/src/distro.rs index 786ad8e..c5d2924 100644 --- a/crates/ecrou/src/distro.rs +++ b/crates/ecrou/src/distro.rs @@ -250,36 +250,131 @@ pub fn checksum_artifact_url(distro: &Distro, tarball_url: &str) -> Option anyhow::Result { - let client = crate::download::http_client(Some(Duration::from_secs(30)))?; - let text = client - .get(meta_release_url) - .send() - .with_context(|| format!("Failed to fetch {}", meta_release_url))? - .text() - .with_context(|| format!("Failed to read {}", meta_release_url))?; +/// Ubuntu releases with base images: `(codename, YY.MM)`, oldest +/// first. Static on purpose — `ubuntu:26.04` and `ubuntu:resolute` +/// must resolve identically and offline, and a codename never changes +/// meaning. A newer release than this table needs an entry only for +/// its codename; its number always resolves without one. +const UBUNTU_CODENAMES: &[(&str, &str)] = &[ + ("xenial", "16.04"), + ("bionic", "18.04"), + ("cosmic", "18.10"), + ("disco", "19.04"), + ("eoan", "19.10"), + ("focal", "20.04"), + ("groovy", "20.10"), + ("hirsute", "21.04"), + ("impish", "21.10"), + ("jammy", "22.04"), + ("kinetic", "22.10"), + ("lunar", "23.04"), + ("mantic", "23.10"), + ("noble", "24.04"), + ("oracular", "24.10"), + ("plucky", "25.04"), + ("questing", "25.10"), + ("resolute", "26.04"), +]; - let mut current_dist: Option = None; - let mut latest: Option = None; +/// One entry of a meta-release file. +#[derive(Debug, Clone, PartialEq)] +struct UbuntuRelease { + codename: String, + number: String, + supported: bool, +} - for line in text.lines() { - if let Some(dist) = line.strip_prefix("Dist: ") { - current_dist = Some(dist.trim().to_string()); - } else if line.trim_start().starts_with("Supported: 1") { - if let Some(dist) = current_dist.take() { - latest = Some(dist); +/// Parse a changelogs.ubuntu.com meta-release body into releases, in +/// file order (oldest first). +fn parse_ubuntu_meta_release(text: &str) -> Vec { + let mut releases = Vec::new(); + let mut current: Option<(String, String, bool)> = None; + + fn flush(current: &mut Option<(String, String, bool)>, releases: &mut Vec) { + if let Some((codename, number, supported)) = current.take() { + if !number.is_empty() { + releases.push(UbuntuRelease { + codename, + number, + supported, + }); } } } - latest.ok_or_else(|| { - anyhow!( - "Could not determine Ubuntu codename from {}", - meta_release_url - ) - }) + for line in text.lines() { + if let Some(dist) = line.strip_prefix("Dist: ") { + flush(&mut current, &mut releases); + current = Some((dist.trim().to_string(), String::new(), false)); + } else if let Some(version) = line.strip_prefix("Version: ") { + // "22.04", "22.04 LTS", "24.04.1 LTS" — normalise to YY.MM, + // the granularity ubuntu-base publishes. + let raw = version.split_whitespace().next().unwrap_or(""); + if let Some((_, number, _)) = current.as_mut() { + *number = normalise_ubuntu_number(raw); + } + } else if line.trim_start().starts_with("Supported: 1") { + if let Some((_, _, supported)) = current.as_mut() { + *supported = true; + } + } + } + flush(&mut current, &mut releases); + releases +} + +/// Fetch a meta-release file and return its releases. +fn fetch_ubuntu_meta_releases(url: &str) -> anyhow::Result> { + let client = crate::download::http_client(Some(Duration::from_secs(30)))?; + let text = client + .get(url) + .send() + .with_context(|| format!("Failed to fetch {}", url))? + .text() + .with_context(|| format!("Failed to read {}", url))?; + Ok(parse_ubuntu_meta_release(&text)) +} + +/// The YY.MM number of the newest supported release in a meta-release file. +/// Pass the LTS-only URL for the latest LTS, the full URL for the latest +/// release. +fn fetch_ubuntu_latest_number(url: &str) -> anyhow::Result { + fetch_ubuntu_meta_releases(url)? + .into_iter() + .filter(|r| r.supported) + .next_back() + .map(|r| r.number) + .ok_or_else(|| anyhow!("no supported Ubuntu release listed in {}", url)) +} + +/// "24.04.1" names the same base release as "24.04": ubuntu-base +/// publishes one tarball per YY.MM series. +fn normalise_ubuntu_number(raw: &str) -> String { + let mut parts = raw.splitn(3, '.'); + match (parts.next(), parts.next()) { + (Some(a), Some(b)) => format!("{a}.{b}"), + _ => raw.to_string(), + } +} + +/// A codename becomes its YY.MM number: from the static table, or — +/// for a release younger than this build's table — from meta-release. +fn ubuntu_codename_to_number(codename: &str) -> anyhow::Result { + let lower = codename.to_lowercase(); + if let Some((_, number)) = UBUNTU_CODENAMES.iter().find(|(name, _)| *name == lower) { + return Ok((*number).to_string()); + } + let releases = fetch_ubuntu_meta_releases("https://changelogs.ubuntu.com/meta-release")?; + releases + .iter() + .find(|r| r.codename == lower) + .map(|r| r.number.clone()) + .ok_or_else(|| { + anyhow!( + "Unknown Ubuntu version {codename:?}: use the YY.MM number (e.g. 26.04) \ + or a known codename (e.g. resolute, noble)" + ) + }) } /// Fetch the current Alpine minirootfs version from latest-releases.yaml on the CDN. @@ -362,82 +457,31 @@ pub fn resolve_distro_version( }?) } -/// Build a map of YY.MM version strings to codenames by parsing the Ubuntu -/// meta-release file (e.g. "24.04" → "noble", "22.04" → "jammy"). -/// Uses the full meta-release (not -lts) so non-LTS versions are also covered. -fn fetch_ubuntu_version_map() -> anyhow::Result> { - let client = crate::download::http_client(Some(Duration::from_secs(30)))?; - let text = client - .get("https://changelogs.ubuntu.com/meta-release") - .send() - .context("Failed to fetch Ubuntu meta-release")? - .text() - .context("Failed to read Ubuntu meta-release")?; - - let mut map = std::collections::HashMap::new(); - let mut current_dist: Option = None; - - for line in text.lines() { - if let Some(dist) = line.strip_prefix("Dist: ") { - current_dist = Some(dist.trim().to_string()); - } else if let Some(version_str) = line.strip_prefix("Version: ") { - // Version field may be "22.04", "22.04 LTS", or "24.04.1 LTS". - // Normalise to YY.MM by taking the first two dot-separated components. - let raw = version_str.split_whitespace().next().unwrap_or(""); - let normalised: String = { - let mut parts = raw.splitn(3, '.'); - match (parts.next(), parts.next()) { - (Some(a), Some(b)) => format!("{}.{}", a, b), - _ => raw.to_string(), - } - }; - if let Some(dist) = ¤t_dist { - map.insert(normalised, dist.clone()); - } - } - } - - Ok(map) -} - fn resolve_ubuntu_version(version: Option<&str>) -> anyhow::Result { let version = version.unwrap_or("latest"); match version { - "latest" => fetch_ubuntu_codename("https://changelogs.ubuntu.com/meta-release"), + "latest" => fetch_ubuntu_latest_number("https://changelogs.ubuntu.com/meta-release"), "lts" | "latest-lts" => { - fetch_ubuntu_codename("https://changelogs.ubuntu.com/meta-release-lts") + fetch_ubuntu_latest_number("https://changelogs.ubuntu.com/meta-release-lts") } other => { - // If it looks like a YY.MM version number, resolve it to a codename - // via the meta-release file so the mapping never goes stale. if is_ubuntu_version_number(other) { - let map = fetch_ubuntu_version_map()?; - map.get(other).cloned().ok_or_else(|| { - anyhow!( - "Unknown Ubuntu version '{}'. \ - Use the codename directly (e.g. noble, jammy) or check \ - https://changelogs.ubuntu.com/meta-release", - other - ) - }) + Ok(normalise_ubuntu_number(other)) } else { - // Treat as a codename and pass through (e.g. "noble", "jammy") - Ok(other.to_string()) + ubuntu_codename_to_number(other) } } } } -/// Returns true for strings of the form "YY.MM" (two numeric dot-separated components). +/// Returns true for strings of the form "YY.MM" or "YY.MM.Z" — every +/// dot-separated component numeric. Point releases normalise to YY.MM. fn is_ubuntu_version_number(s: &str) -> bool { - let mut parts = s.splitn(3, '.'); - matches!( - (parts.next(), parts.next(), parts.next()), - (Some(a), Some(b), None) - if !a.is_empty() && !b.is_empty() - && a.chars().all(|c| c.is_ascii_digit()) - && b.chars().all(|c| c.is_ascii_digit()) - ) + let parts: Vec<&str> = s.split('.').collect(); + (2..=3).contains(&parts.len()) + && parts + .iter() + .all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit())) } fn resolve_alpine_version(version: Option<&str>, arch: &str) -> anyhow::Result { @@ -463,11 +507,10 @@ fn resolve_alpine_version(version: Option<&str>, arch: &str) -> anyhow::Result, arch: &str) -> anyhow::Result { - let codename = resolve_ubuntu_version(version)?; + let number = resolve_ubuntu_version(version)?; let arch = map_arch(Distro::Ubuntu, arch); Ok(format!( - "https://cdimage.ubuntu.com/ubuntu-base/{}/daily/current/{}-base-{}.tar.gz", - codename, codename, arch + "https://cdimage.ubuntu.com/ubuntu-base/releases/{number}/release/ubuntu-base-{number}-base-{arch}.tar.gz" )) } @@ -522,10 +565,12 @@ mod tests { assert_eq!( checksum_artifact_url( &Distro::Ubuntu, - "https://cdimage.ubuntu.com/ubuntu-base/noble/daily/current/noble-base-amd64.tar.gz" + "https://cdimage.ubuntu.com/ubuntu-base/releases/26.04/release/ubuntu-base-26.04-base-amd64.tar.gz" ) .as_deref(), - Some("https://cdimage.ubuntu.com/ubuntu-base/noble/daily/current/SHA256SUMS") + Some( + "https://cdimage.ubuntu.com/ubuntu-base/releases/26.04/release/SHA256SUMS" + ) ); } @@ -701,4 +746,41 @@ mod tests { oci("docker.io", "mydir/image", "latest") ); } + #[test] + fn meta_release_parsing_extracts_codename_number_pairs() { + let text = "Dist: noble\nVersion: 24.04 LTS\nSupported: 1\n\ + Dist: questing\nVersion: 25.10\nSupported: 1\n\ + Dist: plucky\nVersion: 25.04\nSupported: 0\n"; + let releases = parse_ubuntu_meta_release(text); + assert_eq!(releases.len(), 3); + assert_eq!(releases[0].codename, "noble"); + assert_eq!(releases[0].number, "24.04"); + assert!(releases[0].supported); + // A point release normalises to YY.MM + let text = "Dist: noble\nVersion: 24.04.2 LTS\nSupported: 1\n"; + assert_eq!(parse_ubuntu_meta_release(text)[0].number, "24.04"); + } + + #[test] + fn ubuntu_numbers_and_codenames_resolve_offline_and_identically() { + // The alias promise: number and codename are the same image — + // same resolved version, same URL, same cache key. No network. + assert_eq!( + resolve_ubuntu_version(Some("26.04")).unwrap(), + resolve_ubuntu_version(Some("resolute")).unwrap() + ); + assert_eq!(resolve_ubuntu_version(Some("resolute")).unwrap(), "26.04"); + assert_eq!(resolve_ubuntu_version(Some("NOBLE")).unwrap(), "24.04"); + // Point releases normalise to the series. + assert_eq!(resolve_ubuntu_version(Some("24.04.2")).unwrap(), "24.04"); + let url = resolve_ubuntu_url(Some("resolute"), "amd64").unwrap(); + assert_eq!( + url, + "https://cdimage.ubuntu.com/ubuntu-base/releases/26.04/release/ubuntu-base-26.04-base-amd64.tar.gz" + ); + assert_eq!( + resolve_ubuntu_url(Some("26.04"), "amd64").unwrap(), + resolve_ubuntu_url(Some("resolute"), "amd64").unwrap() + ); + } } diff --git a/crates/ecrou/src/rootfs.rs b/crates/ecrou/src/rootfs.rs index 3dd19f1..e6713c0 100644 --- a/crates/ecrou/src/rootfs.rs +++ b/crates/ecrou/src/rootfs.rs @@ -624,7 +624,12 @@ fn generate_cache_filename(source: &ImageSource, arch: &str) -> String { Distro::Alpine => "alpine", }; let distro_arch = map_arch(*distro, arch); - // Get extension from URL + // The extension comes from the URL. `version` is the + // resolved value (resolve_source ran first), so this + // derivation is offline: numbers and patch-resolved + // strings pass through without lookups. A resolution + // failure here degrades to the plain .tar.gz name rather + // than blocking every cache-path computation. let url = resolve_distro_url(distro, version.as_deref(), arch).unwrap_or_default(); let ext = get_tarball_extension(&url); format!( @@ -723,11 +728,11 @@ mod tests { #[test] fn cache_filename_direct_tarball() { - // Explicit codename/version: no network lookups + // Explicit version, already resolved to a number: no network lookups let cache = RootfsCache::new("/cache"); assert_eq!( - cache.tarball_path(&direct_tarball(Distro::Ubuntu, "noble"), "amd64"), - PathBuf::from("/cache/ubuntu-noble-amd64.tar.gz") + cache.tarball_path(&direct_tarball(Distro::Ubuntu, "24.04"), "amd64"), + PathBuf::from("/cache/ubuntu-24.04-amd64.tar.gz") ); assert_eq!( cache.tarball_path(&direct_tarball(Distro::Alpine, "3.23.1"), "amd64"),