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/<YY.MM>/release/ubuntu-base-<YY.MM>-base-<arch>.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.
This commit is contained in:
vhaudiquet committed 2026-10-30 00:12:51 +01:00
1 parent 28f24abb6f
commit 73eaeb57ac
2 files changed
+177 -90

No files matched your search

+168 -86
View File
@@ -250,34 +250,129 @@ pub fn checksum_artifact_url(distro: &Distro, tarball_url: &str) -> Option<Strin
} }
} }
/// Fetch the latest Ubuntu codename from a changelogs.ubuntu.com meta-release file. /// Ubuntu releases with base images: `(codename, YY.MM)`, oldest
/// Pass the LTS-only URL to get the latest LTS, or the full URL for the latest release. /// first. Static on purpose — `ubuntu:26.04` and `ubuntu:resolute`
fn fetch_ubuntu_codename(meta_release_url: &str) -> anyhow::Result<String> { /// must resolve identically and offline, and a codename never changes
let client = crate::download::http_client(Some(Duration::from_secs(30)))?; /// meaning. A newer release than this table needs an entry only for
let text = client /// its codename; its number always resolves without one.
.get(meta_release_url) const UBUNTU_CODENAMES: &[(&str, &str)] = &[
.send() ("xenial", "16.04"),
.with_context(|| format!("Failed to fetch {}", meta_release_url))? ("bionic", "18.04"),
.text() ("cosmic", "18.10"),
.with_context(|| format!("Failed to read {}", meta_release_url))?; ("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<String> = None; /// One entry of a meta-release file.
let mut latest: Option<String> = None; #[derive(Debug, Clone, PartialEq)]
struct UbuntuRelease {
codename: String,
number: String,
supported: bool,
}
/// Parse a changelogs.ubuntu.com meta-release body into releases, in
/// file order (oldest first).
fn parse_ubuntu_meta_release(text: &str) -> Vec<UbuntuRelease> {
let mut releases = Vec::new();
let mut current: Option<(String, String, bool)> = None;
fn flush(current: &mut Option<(String, String, bool)>, releases: &mut Vec<UbuntuRelease>) {
if let Some((codename, number, supported)) = current.take() {
if !number.is_empty() {
releases.push(UbuntuRelease {
codename,
number,
supported,
});
}
}
}
for line in text.lines() { for line in text.lines() {
if let Some(dist) = line.strip_prefix("Dist: ") { if let Some(dist) = line.strip_prefix("Dist: ") {
current_dist = Some(dist.trim().to_string()); flush(&mut current, &mut releases);
} else if line.trim_start().starts_with("Supported: 1") { current = Some((dist.trim().to_string(), String::new(), false));
if let Some(dist) = current_dist.take() { } else if let Some(version) = line.strip_prefix("Version: ") {
latest = Some(dist); // "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<Vec<UbuntuRelease>> {
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<String> {
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(),
} }
} }
latest.ok_or_else(|| { /// 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<String> {
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!( anyhow!(
"Could not determine Ubuntu codename from {}", "Unknown Ubuntu version {codename:?}: use the YY.MM number (e.g. 26.04) \
meta_release_url or a known codename (e.g. resolute, noble)"
) )
}) })
} }
@@ -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<std::collections::HashMap<String, String>> {
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<String> = 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) = &current_dist {
map.insert(normalised, dist.clone());
}
}
}
Ok(map)
}
fn resolve_ubuntu_version(version: Option<&str>) -> anyhow::Result<String> { fn resolve_ubuntu_version(version: Option<&str>) -> anyhow::Result<String> {
let version = version.unwrap_or("latest"); let version = version.unwrap_or("latest");
match version { 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" => { "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 => { 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) { if is_ubuntu_version_number(other) {
let map = fetch_ubuntu_version_map()?; Ok(normalise_ubuntu_number(other))
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
)
})
} else { } else {
// Treat as a codename and pass through (e.g. "noble", "jammy") ubuntu_codename_to_number(other)
Ok(other.to_string())
} }
} }
} }
} }
/// 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 { fn is_ubuntu_version_number(s: &str) -> bool {
let mut parts = s.splitn(3, '.'); let parts: Vec<&str> = s.split('.').collect();
matches!( (2..=3).contains(&parts.len())
(parts.next(), parts.next(), parts.next()), && parts
(Some(a), Some(b), None) .iter()
if !a.is_empty() && !b.is_empty() .all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()))
&& a.chars().all(|c| c.is_ascii_digit())
&& b.chars().all(|c| c.is_ascii_digit())
)
} }
fn resolve_alpine_version(version: Option<&str>, arch: &str) -> anyhow::Result<String> { fn resolve_alpine_version(version: Option<&str>, arch: &str) -> anyhow::Result<String> {
@@ -463,11 +507,10 @@ fn resolve_alpine_version(version: Option<&str>, arch: &str) -> anyhow::Result<S
} }
fn resolve_ubuntu_url(version: Option<&str>, arch: &str) -> anyhow::Result<String> { fn resolve_ubuntu_url(version: Option<&str>, arch: &str) -> anyhow::Result<String> {
let codename = resolve_ubuntu_version(version)?; let number = resolve_ubuntu_version(version)?;
let arch = map_arch(Distro::Ubuntu, arch); let arch = map_arch(Distro::Ubuntu, arch);
Ok(format!( Ok(format!(
"https://cdimage.ubuntu.com/ubuntu-base/{}/daily/current/{}-base-{}.tar.gz", "https://cdimage.ubuntu.com/ubuntu-base/releases/{number}/release/ubuntu-base-{number}-base-{arch}.tar.gz"
codename, codename, arch
)) ))
} }
@@ -522,10 +565,12 @@ mod tests {
assert_eq!( assert_eq!(
checksum_artifact_url( checksum_artifact_url(
&Distro::Ubuntu, &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(), .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") 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()
);
}
} }
+9 -4
View File
@@ -624,7 +624,12 @@ fn generate_cache_filename(source: &ImageSource, arch: &str) -> String {
Distro::Alpine => "alpine", Distro::Alpine => "alpine",
}; };
let distro_arch = map_arch(*distro, arch); 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 url = resolve_distro_url(distro, version.as_deref(), arch).unwrap_or_default();
let ext = get_tarball_extension(&url); let ext = get_tarball_extension(&url);
format!( format!(
@@ -723,11 +728,11 @@ mod tests {
#[test] #[test]
fn cache_filename_direct_tarball() { 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"); let cache = RootfsCache::new("/cache");
assert_eq!( assert_eq!(
cache.tarball_path(&direct_tarball(Distro::Ubuntu, "noble"), "amd64"), cache.tarball_path(&direct_tarball(Distro::Ubuntu, "24.04"), "amd64"),
PathBuf::from("/cache/ubuntu-noble-amd64.tar.gz") PathBuf::from("/cache/ubuntu-24.04-amd64.tar.gz")
); );
assert_eq!( assert_eq!(
cache.tarball_path(&direct_tarball(Distro::Alpine, "3.23.1"), "amd64"), cache.tarball_path(&direct_tarball(Distro::Alpine, "3.23.1"), "amd64"),