diff --git a/Cargo.lock b/Cargo.lock index 85250cd..57259cf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -397,6 +397,7 @@ dependencies = [ "chrono", "regex", "serde", + "serde_json", "test-case", ] diff --git a/docs/usage/build.md b/docs/usage/build.md index 488f745..4031c70 100644 --- a/docs/usage/build.md +++ b/docs/usage/build.md @@ -117,6 +117,8 @@ If the changelog has a single unambiguous entry, omit it. Suite aliases in the changelog (or via `--distro`) resolve to a concrete release: Debian `stable` / `oldstable` / `sid` (→ `unstable`), and Ubuntu `devel`. Alias targets are updated manually when Debian/Ubuntu roll. +Non-Debian/Ubuntu suites (still apt/dpkg-based) are supported when declared for the active container Driver via `base_images`, e.g. `driver.docker.base_images = { "yocto:kirkstone" = "my-registry/yocto-kirkstone:latest" }`. The changelog/`--distro` value stays the bare codename (`kirkstone`). On the Bare driver, the host `/etc/os-release` must match: built-in Debian/Ubuntu need matching `ID` and codename; other suites need a matching `VERSION_CODENAME` only. + ## Proposed dependencies If needed, build dependencies can be used from `-proposed`. diff --git a/docs/usage/config.md b/docs/usage/config.md index 36d7510..4289c48 100644 --- a/docs/usage/config.md +++ b/docs/usage/config.md @@ -23,9 +23,9 @@ All keys are optional. | `driver.persistent` | bool | `false` | Keep and reuse the build environment across runs instead of tearing it down. | | `driver.apt_mirror` | string | — | Mirror used for build-dependency resolution. Not used by the `bare` driver. | | `driver.proposed` | bool | `false` | Also enable the `-proposed` pocket. Not used by the `bare` driver. | -| `driver.docker.base_images` | map | — | Base image per distro, keyed by `":"` (e.g. `"debian:trixie"`). Falls back to `docker.io/:`. | +| `driver.docker.base_images` | map | — | Base image per distro, keyed by `":"` (e.g. `"debian:trixie"`). Falls back to `docker.io/:`. For non-Debian/Ubuntu suites (e.g. `"yocto:kirkstone"`), the map entry is what makes the suite a known DistroVersion for Docker builds. | | `driver.lxd.project` | string | — | LXD/Incus project to use. `None` uses the default project. | -| `driver.lxd.base_images` | map | — | Base image per distro, keyed by `":"`. Falls back to the driver's default remote image. | +| `driver.lxd.base_images` | map | — | Base image per distro, keyed by `":"`. Falls back to the driver's default remote image. Same custom-suite registry role as Docker's map for LXD/Incus. | | `temp_build_dir` | path | `/tmp/debmagic` | Where build trees are staged. | | `incremental` | bool | `false` | Retain the environment and sync only source changes, preserving generated files. Binary-only; implies `persistent`; incompatible with `clean`. | | `source_sync_mode` | enum | `tracked` | Which source files are staged (see below). | @@ -65,6 +65,11 @@ clean = false persistent = true apt_mirror = "http:///ubuntu" +[driver.docker] +# Optional image overrides for known Debian/Ubuntu releases, and the registry +# for custom apt/dpkg suites (family:codename): +# base_images = { "debian:trixie" = "my-trixie:latest", "yocto:kirkstone" = "my-yocto:latest" } + [driver.lxd] # project = "my=lxd-project-id" ``` diff --git a/packages/debmagic-common/Cargo.toml b/packages/debmagic-common/Cargo.toml index c58e16d..7e2fc1d 100644 --- a/packages/debmagic-common/Cargo.toml +++ b/packages/debmagic-common/Cargo.toml @@ -14,3 +14,4 @@ serde = { workspace = true, features = ["derive"] } [dev-dependencies] test-case = { workspace = true } +serde_json = { workspace = true } diff --git a/packages/debmagic-common/src/distro.rs b/packages/debmagic-common/src/distro.rs index ee39916..0a2b482 100644 --- a/packages/debmagic-common/src/distro.rs +++ b/packages/debmagic-common/src/distro.rs @@ -1,11 +1,65 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use std::fmt; use std::sync::LazyLock; -#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq, Hash)] -pub enum Distro { - Debian, - Ubuntu, +/// Open-ended distribution family name (`debian`, `ubuntu`, `yocto`, …). +#[derive(Clone, Debug, Eq, PartialEq, Hash)] +pub struct Distro(String); + +impl Distro { + pub const DEBIAN: &'static str = "debian"; + pub const UBUNTU: &'static str = "ubuntu"; + + pub fn new(name: impl Into) -> Self { + Self(name.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn is_debian(&self) -> bool { + self.0 == Self::DEBIAN + } + + pub fn is_ubuntu(&self) -> bool { + self.0 == Self::UBUNTU + } + + pub fn is_debian_or_ubuntu(&self) -> bool { + self.is_debian() || self.is_ubuntu() + } +} + +impl fmt::Display for Distro { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl Serialize for Distro { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> Deserialize<'de> for Distro { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + // Accept legacy PascalCase unit-enum spellings from older build.json. + Ok(match s.as_str() { + "Debian" | "debian" => Distro::new(Self::DEBIAN), + "Ubuntu" | "ubuntu" => Distro::new(Self::UBUNTU), + other => Distro::new(other), + }) + } } #[derive(Serialize, Deserialize, Debug, Clone, Eq, PartialEq)] @@ -20,7 +74,7 @@ pub struct DistroVersion { } impl DistroVersion { - fn new(distro: Distro, codename: &str, version: &str) -> Self { + pub fn new(distro: Distro, codename: &str, version: &str) -> Self { Self { distro, codename: codename.to_string(), @@ -29,71 +83,69 @@ impl DistroVersion { } } + /// Custom (non-built-in) target: family + codename, empty version. + pub fn custom(distro: Distro, codename: &str) -> Self { + Self::new(distro, codename, "") + } + fn devel(mut self) -> Self { self.is_devel = true; self } -} -impl Distro { - pub fn as_str(&self) -> &'static str { - match self { - Distro::Debian => "debian", - Distro::Ubuntu => "ubuntu", - } - } -} - -impl std::fmt::Display for Distro { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.as_str()) + pub fn key(&self) -> String { + format!("{}:{}", self.distro, self.codename) } } static DISTRO_INFO_MAP: LazyLock> = LazyLock::new(|| { - use Distro::{Debian, Ubuntu}; + let debian = || Distro::new(Distro::DEBIAN); + let ubuntu = || Distro::new(Distro::UBUNTU); HashMap::from([ // debian ( "experimental", - DistroVersion::new(Debian, "experimental", ""), + DistroVersion::new(debian(), "experimental", ""), ), - ("unstable", DistroVersion::new(Debian, "unstable", "")), + ("unstable", DistroVersion::new(debian(), "unstable", "")), // Suite alias: sid → unstable (concrete release identity). - ("sid", DistroVersion::new(Debian, "unstable", "")), - ("testing", DistroVersion::new(Debian, "testing", "")), - ("duke", DistroVersion::new(Debian, "duke", "15")), - ("forky", DistroVersion::new(Debian, "forky", "14")), - ("trixie", DistroVersion::new(Debian, "trixie", "13")), + ("sid", DistroVersion::new(debian(), "unstable", "")), + ("testing", DistroVersion::new(debian(), "testing", "")), + ("duke", DistroVersion::new(debian(), "duke", "15")), + ("forky", DistroVersion::new(debian(), "forky", "14")), + ("trixie", DistroVersion::new(debian(), "trixie", "13")), // Suite alias: stable → current stable release (update when Debian rolls). - ("stable", DistroVersion::new(Debian, "trixie", "13")), - ("bookworm", DistroVersion::new(Debian, "bookworm", "12")), + ("stable", DistroVersion::new(debian(), "trixie", "13")), + ("bookworm", DistroVersion::new(debian(), "bookworm", "12")), // Suite alias: oldstable → current oldstable release. - ("oldstable", DistroVersion::new(Debian, "bookworm", "12")), - ("bullseye", DistroVersion::new(Debian, "bullseye", "11")), - ("buster", DistroVersion::new(Debian, "buster", "10")), - ("stretch", DistroVersion::new(Debian, "stretch", "9")), + ("oldstable", DistroVersion::new(debian(), "bookworm", "12")), + ("bullseye", DistroVersion::new(debian(), "bullseye", "11")), + ("buster", DistroVersion::new(debian(), "buster", "10")), + ("stretch", DistroVersion::new(debian(), "stretch", "9")), // ubuntu ( "stonking", - DistroVersion::new(Ubuntu, "stonking", "26.10").devel(), + DistroVersion::new(ubuntu(), "stonking", "26.10").devel(), ), // Suite alias: devel → current Ubuntu development release. ( "devel", - DistroVersion::new(Ubuntu, "stonking", "26.10").devel(), + DistroVersion::new(ubuntu(), "stonking", "26.10").devel(), + ), + ( + "resolute", + DistroVersion::new(ubuntu(), "resolute", "26.04"), ), - ("resolute", DistroVersion::new(Ubuntu, "resolute", "26.04")), - ("noble", DistroVersion::new(Ubuntu, "noble", "24.04")), - ("jammy", DistroVersion::new(Ubuntu, "jammy", "22.04")), - ("focal", DistroVersion::new(Ubuntu, "focal", "20.04")), - ("bionic", DistroVersion::new(Ubuntu, "bionic", "18.04")), - ("xenial", DistroVersion::new(Ubuntu, "xenial", "16.04")), - ("trusty", DistroVersion::new(Ubuntu, "trusty", "14.04")), + ("noble", DistroVersion::new(ubuntu(), "noble", "24.04")), + ("jammy", DistroVersion::new(ubuntu(), "jammy", "22.04")), + ("focal", DistroVersion::new(ubuntu(), "focal", "20.04")), + ("bionic", DistroVersion::new(ubuntu(), "bionic", "18.04")), + ("xenial", DistroVersion::new(ubuntu(), "xenial", "16.04")), + ("trusty", DistroVersion::new(ubuntu(), "trusty", "14.04")), ]) }); -/// Look up a distribution by codename or suite alias. +/// Look up a built-in distribution by codename or suite alias. /// /// Suite aliases are map keys that resolve to a concrete release [`DistroVersion`]: /// - Debian: `stable` → current stable release, `oldstable` → current oldstable, @@ -101,6 +153,127 @@ static DISTRO_INFO_MAP: LazyLock> = LazyLoc /// - Ubuntu: `devel` → current development release /// /// Alias targets are maintained manually when Debian/Ubuntu roll. +/// Non-built-in suites are not returned here; callers resolve those via Driver +/// `base_images` or Bare `/etc/os-release` checks. pub fn get_distro_version(name: &str) -> Option { DISTRO_INFO_MAP.get(name).cloned() } + +/// True if `name` is a built-in release codename or suite alias. +pub fn is_built_in_suite(name: &str) -> bool { + DISTRO_INFO_MAP.contains_key(name) +} + +/// Parse a `base_images` map key (`family:codename`). +pub fn parse_base_image_key(key: &str) -> Option<(&str, &str)> { + let (family, codename) = key.split_once(':')?; + if family.is_empty() || codename.is_empty() || codename.contains(':') { + return None; + } + Some((family, codename)) +} + +/// Reject custom (non-debian/ubuntu family) map keys whose codename collides +/// with a built-in release or suite alias. +pub fn validate_base_images_keys<'a, I>(keys: I) -> Result<(), String> +where + I: IntoIterator, +{ + for key in keys { + let Some((family, codename)) = parse_base_image_key(key) else { + return Err(format!( + "invalid base_images key '{key}'; expected 'family:codename'" + )); + }; + if family == Distro::DEBIAN || family == Distro::UBUNTU { + continue; + } + if is_built_in_suite(codename) { + return Err(format!( + "base_images key '{key}' uses codename '{codename}' which is a built-in \ + Debian/Ubuntu suite; use a different codename or a debian:/ubuntu: image override" + )); + } + } + Ok(()) +} + +/// Resolve a non-built-in suite from a Driver `base_images` map by unique +/// `*:codename` match. +/// +/// `config_key` is the config path shown in errors (e.g. `driver.docker.base_images`). +pub fn custom_distro_from_base_images( + codename: &str, + base_images: &HashMap, + config_key: &str, +) -> Result { + let mut matches: Vec<(&str, &str)> = base_images + .keys() + .filter_map(|key| parse_base_image_key(key)) + .filter(|(_, key_codename)| *key_codename == codename) + .collect(); + + matches.sort_by_key(|(family, _)| *family); + matches.dedup(); + + match matches.as_slice() { + [] => Err(format!( + "unknown distro codename '{codename}'. To use a non-Debian/Ubuntu suite, declare it \ + in the active driver's base_images map, e.g. \ + {config_key} = {{ \"yocto:{codename}\" = \"\" }}" + )), + [(family, _)] => Ok(DistroVersion::custom(Distro::new(*family), codename)), + many => { + let keys: Vec = many + .iter() + .map(|(family, c)| format!("{family}:{c}")) + .collect(); + Err(format!( + "ambiguous distro codename '{codename}' matches multiple base_images keys: {}", + keys.join(", ") + )) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validate_allows_debian_image_override() { + assert!(validate_base_images_keys(["debian:trixie"]).is_ok()); + } + + #[test] + fn validate_rejects_custom_family_with_built_in_codename() { + let err = validate_base_images_keys(["yocto:trixie"]).unwrap_err(); + assert!(err.contains("built-in")); + } + + #[test] + fn validate_allows_custom_suite() { + assert!(validate_base_images_keys(["yocto:kirkstone"]).is_ok()); + } + + #[test] + fn custom_from_map_unique() { + let mut map = HashMap::new(); + map.insert( + "yocto:kirkstone".to_string(), + "registry/yocto:latest".to_string(), + ); + let v = + custom_distro_from_base_images("kirkstone", &map, "driver.docker.base_images").unwrap(); + assert_eq!(v.distro.as_str(), "yocto"); + assert_eq!(v.codename, "kirkstone"); + assert_eq!(v.version, ""); + } + + #[test] + fn distro_serde_accepts_legacy_pascal_case() { + let d: Distro = serde_json::from_str("\"Debian\"").unwrap(); + assert!(d.is_debian()); + assert_eq!(serde_json::to_string(&d).unwrap(), "\"debian\""); + } +} diff --git a/packages/debmagic/src/build/common.rs b/packages/debmagic/src/build/common.rs index 796e708..d40097a 100644 --- a/packages/debmagic/src/build/common.rs +++ b/packages/debmagic/src/build/common.rs @@ -262,7 +262,7 @@ mod tests { source_dir: PathBuf::from("/tmp/src"), output_dir: PathBuf::from("/tmp/out"), distro: DistroVersion { - distro: Distro::Debian, + distro: Distro::new(Distro::DEBIAN), codename: "forky".to_string(), version: "15".to_string(), is_devel: false, diff --git a/packages/debmagic/src/build/driver_lxd.rs b/packages/debmagic/src/build/driver_lxd.rs index b571303..8899c8f 100644 --- a/packages/debmagic/src/build/driver_lxd.rs +++ b/packages/debmagic/src/build/driver_lxd.rs @@ -5,7 +5,6 @@ use std::{ }; use anyhow::Context as _; -use debmagic_common::distro::Distro; use serde::{Deserialize, Serialize}; use crate::build::{ @@ -71,20 +70,21 @@ fn default_base_image( variant: LxdVariant, distro: &debmagic_common::distro::DistroVersion, ) -> String { - use debmagic_common::distro::Distro; - match (&distro.distro, variant, distro.is_devel) { + match (distro.distro.as_str(), variant, distro.is_devel) { // LXD ships a dedicated ubuntu: remote; daily builds are on ubuntu-daily:. - (Distro::Ubuntu, LxdVariant::Lxd, false) => format!("ubuntu:{}", distro.version), - (Distro::Ubuntu, LxdVariant::Lxd, true) => format!("ubuntu-daily:{}", distro.version), + ("ubuntu", LxdVariant::Lxd, false) => format!("ubuntu:{}", distro.version), + ("ubuntu", LxdVariant::Lxd, true) => format!("ubuntu-daily:{}", distro.version), // Incus uses the images: remote for everything; daily via /daily variant. - (Distro::Ubuntu, LxdVariant::Incus, false) => format!("images:ubuntu/{}", distro.version), - (Distro::Ubuntu, LxdVariant::Incus, true) => { + ("ubuntu", LxdVariant::Incus, false) => format!("images:ubuntu/{}", distro.version), + ("ubuntu", LxdVariant::Incus, true) => { format!("images:ubuntu/{}/daily", distro.codename) } // Debian images live on images: for both variants, released and devel alike. - (Distro::Debian, _, _) => { + ("debian", _, _) => { format!("images:debian/{}", debian_image_codename(&distro.codename)) } + // Custom families must be declared in base_images; this is only a last-resort fallback. + (family, _, _) => format!("images:{family}/{}", distro.codename), } } @@ -329,7 +329,7 @@ impl DriverLxd { &format!("starting {} container", variant.binary()), )?; - if config.distro.distro == Distro::Ubuntu { + if config.distro.distro.is_ubuntu() { base.exec_in_container(&["cloud-init", "status", "--wait"], None, true, &[]) .map_err(|e| { anyhow::anyhow!("Error waiting for cloud-init to finish: {e}") diff --git a/packages/debmagic/src/main.rs b/packages/debmagic/src/main.rs index 5efad7d..e8143cb 100644 --- a/packages/debmagic/src/main.rs +++ b/packages/debmagic/src/main.rs @@ -11,7 +11,7 @@ use crate::{ }, build_intent::{BuildIntentInput, load_config, resolve_build_intent}, cli::{BuildTarget, Cli, Commands}, - package::{load_package_identity, resolve_package_target}, + package::{distro_resolve_mode_for_driver, load_package_identity, resolve_package_target}, }; pub mod build; @@ -75,8 +75,16 @@ fn main() -> anyhow::Result<()> { }, })?; - let target = resolve_package_target(&intent.source_dir, build_args.distro.as_deref()) - .context("failed to determine package target")?; + let target = resolve_package_target( + &intent.source_dir, + build_args.distro.as_deref(), + distro_resolve_mode_for_driver( + intent.driver, + &intent.config.driver.docker.base_images, + &intent.config.driver.lxd.base_images, + ), + ) + .context("failed to determine package target")?; if is_source { build_source_package(&intent, &target) diff --git a/packages/debmagic/src/package.rs b/packages/debmagic/src/package.rs index 7da6a31..4336377 100644 --- a/packages/debmagic/src/package.rs +++ b/packages/debmagic/src/package.rs @@ -1,8 +1,14 @@ +use std::collections::HashMap; use std::path::{Path, PathBuf}; use anyhow::anyhow; use debmagic_common::debian::version::PackageVersion; -use debmagic_common::distro::DistroVersion; +use debmagic_common::distro::{ + Distro, DistroVersion, custom_distro_from_base_images, get_distro_version, + validate_base_images_keys, +}; + +use crate::build::common::BuildDriverType; /// Who/what is being built, as read from the source tree changelog. #[derive(Debug, Clone)] @@ -19,6 +25,23 @@ pub struct PackageTarget { pub distro: DistroVersion, } +/// How to resolve non-built-in suites for the active Driver. +#[derive(Debug, Clone, Copy)] +pub enum DistroResolveMode<'a> { + /// Container Drivers: custom suites come from this driver's `base_images`. + Container { + base_images: &'a HashMap, + /// e.g. `driver.docker.base_images` — used in error messages. + config_key: &'a str, + }, + /// Bare: custom suites require a host `/etc/os-release` codename match; + /// built-in Debian/Ubuntu also require family (`ID`) match. + Bare { + /// Usually `/etc/os-release`; overridable in tests. + os_release_path: &'a Path, + }, +} + struct ChangelogPackage { identity: PackageIdentity, /// Raw distribution names from the changelog entry (not looked up yet). @@ -70,30 +93,170 @@ pub fn load_package_identity(dir: &Path) -> anyhow::Result { pub fn resolve_package_target( dir: &Path, explicit_distro: Option<&str>, + mode: DistroResolveMode<'_>, ) -> anyhow::Result { let parsed = read_changelog_package(dir)?; - let distro = select_distro_version(&parsed.changelog_distros, explicit_distro)?; + let distro = select_distro_version(&parsed.changelog_distros, explicit_distro, mode)?; Ok(PackageTarget { identity: parsed.identity, distro, }) } -fn lookup_distro(name: &str) -> anyhow::Result { - debmagic_common::distro::get_distro_version(name) - .ok_or_else(|| anyhow!("unknown distro codename '{}'", name)) +/// Pick the resolve mode for the active Driver from its config maps. +pub fn distro_resolve_mode_for_driver<'a>( + driver: BuildDriverType, + docker_base_images: &'a HashMap, + lxd_base_images: &'a HashMap, +) -> DistroResolveMode<'a> { + match driver { + BuildDriverType::Docker => DistroResolveMode::Container { + base_images: docker_base_images, + config_key: "driver.docker.base_images", + }, + BuildDriverType::Lxd | BuildDriverType::Incus => DistroResolveMode::Container { + base_images: lxd_base_images, + config_key: "driver.lxd.base_images", + }, + BuildDriverType::Bare => DistroResolveMode::Bare { + os_release_path: Path::new("/etc/os-release"), + }, + } +} + +fn lookup_distro(name: &str, mode: DistroResolveMode<'_>) -> anyhow::Result { + if let DistroResolveMode::Container { base_images, .. } = mode { + validate_base_images_keys(base_images.keys().map(|k| k.as_str())) + .map_err(|e| anyhow!("{e}"))?; + } + + if let Some(builtin) = get_distro_version(name) { + if let DistroResolveMode::Bare { os_release_path } = mode { + check_bare_os_release_for_builtin(os_release_path, &builtin)?; + } + return Ok(builtin); + } + + match mode { + DistroResolveMode::Container { + base_images, + config_key, + } => custom_distro_from_base_images(name, base_images, config_key).map_err(|e| anyhow!(e)), + DistroResolveMode::Bare { os_release_path } => { + let os = read_os_release(os_release_path)?; + let host_codename = os.codename().ok_or_else(|| { + anyhow!("host {} has no VERSION_CODENAME", os_release_path.display()) + })?; + if host_codename != name { + return Err(anyhow!( + "unknown distro codename '{name}' does not match host VERSION_CODENAME \ + '{host_codename}'. For Bare builds of non-Debian/Ubuntu suites, the host \ + codename must match; for container Drivers, declare the suite in \ + base_images (e.g. driver.docker.base_images = {{ \"yocto:{name}\" = \"\" }})" + )); + } + let family = os + .id + .ok_or_else(|| anyhow!("host {} has no ID", os_release_path.display()))?; + Ok(DistroVersion::custom(Distro::new(family), name)) + } + } +} + +fn check_bare_os_release_for_builtin( + os_release_path: &Path, + target: &DistroVersion, +) -> anyhow::Result<()> { + if !target.distro.is_debian_or_ubuntu() { + return Ok(()); + } + let os = read_os_release(os_release_path)?; + let host_codename = os + .codename() + .ok_or_else(|| anyhow!("host {} has no VERSION_CODENAME", os_release_path.display()))?; + let host_id = os + .id + .as_deref() + .ok_or_else(|| anyhow!("host {} has no ID", os_release_path.display()))?; + + if host_id != target.distro.as_str() { + return Err(anyhow!( + "Bare build targets {} but host {} ID is '{}'", + target.distro, + os_release_path.display(), + host_id + )); + } + if host_codename != target.codename { + return Err(anyhow!( + "Bare build targets {} {} but host VERSION_CODENAME is '{}'", + target.distro, + target.codename, + host_codename + )); + } + Ok(()) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct OsRelease { + id: Option, + version_codename: Option, + ubuntu_codename: Option, +} + +impl OsRelease { + fn codename(&self) -> Option<&str> { + self.version_codename + .as_deref() + .or(self.ubuntu_codename.as_deref()) + } +} + +fn read_os_release(path: &Path) -> anyhow::Result { + let contents = std::fs::read_to_string(path) + .map_err(|e| anyhow!("failed to read {}: {e}", path.display()))?; + Ok(parse_os_release(&contents)) +} + +fn parse_os_release(contents: &str) -> OsRelease { + let mut id = None; + let mut version_codename = None; + let mut ubuntu_codename = None; + for line in contents.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let Some((key, value)) = line.split_once('=') else { + continue; + }; + let value = value.trim().trim_matches('"'); + match key { + "ID" => id = Some(value.to_string()), + "VERSION_CODENAME" => version_codename = Some(value.to_string()), + "UBUNTU_CODENAME" => ubuntu_codename = Some(value.to_string()), + _ => {} + } + } + OsRelease { + id, + version_codename, + ubuntu_codename, + } } fn select_distro_version( changelog_distros: &[String], explicit_distro: Option<&str>, + mode: DistroResolveMode<'_>, ) -> anyhow::Result { match (changelog_distros.len(), explicit_distro) { (0, _) => Err(anyhow!("changelog contains no distributions")), - (1, None) => lookup_distro(&changelog_distros[0]), + (1, None) => lookup_distro(&changelog_distros[0], mode), (1, Some(explicit)) => { - let from_changelog = lookup_distro(&changelog_distros[0])?; - let from_explicit = lookup_distro(explicit)?; + let from_changelog = lookup_distro(&changelog_distros[0], mode)?; + let from_explicit = lookup_distro(explicit, mode)?; if from_changelog == from_explicit { Ok(from_explicit) } else { @@ -109,9 +272,10 @@ fn select_distro_version( changelog_distros.join(", ") )), (_, Some(explicit)) => { - let from_explicit = lookup_distro(explicit)?; + let from_explicit = lookup_distro(explicit, mode)?; let matched = changelog_distros.iter().any(|name| { - lookup_distro(name).is_ok_and(|from_changelog| from_changelog == from_explicit) + lookup_distro(name, mode) + .is_ok_and(|from_changelog| from_changelog == from_explicit) }); if matched { Ok(from_explicit) @@ -128,8 +292,6 @@ fn select_distro_version( #[cfg(test)] mod tests { - use debmagic_common::distro::Distro; - use super::*; fn test_package_dir() -> PathBuf { @@ -146,6 +308,13 @@ mod tests { .join("test_package_multi_distro") } + fn docker_mode(map: &HashMap) -> DistroResolveMode<'_> { + DistroResolveMode::Container { + base_images: map, + config_key: "driver.docker.base_images", + } + } + #[test] fn load_package_identity_from_changelog() -> anyhow::Result<()> { let dir = test_package_dir(); @@ -159,29 +328,36 @@ mod tests { #[test] fn resolve_package_target_stable_aliases_to_trixie() -> anyhow::Result<()> { - let target = resolve_package_target(&test_package_dir(), None)?; + let empty = HashMap::new(); + let target = resolve_package_target(&test_package_dir(), None, docker_mode(&empty))?; assert_eq!(target.distro.codename, "trixie"); - assert_eq!(target.distro.distro, Distro::Debian); + assert!(target.distro.distro.is_debian()); Ok(()) } #[test] fn select_distro_version_alias_matches_canonical_explicit() -> anyhow::Result<()> { - let distro = select_distro_version(&["stable".to_string()], Some("trixie"))?; + let empty = HashMap::new(); + let distro = + select_distro_version(&["stable".to_string()], Some("trixie"), docker_mode(&empty))?; assert_eq!(distro.codename, "trixie"); Ok(()) } #[test] fn select_distro_version_sid_matches_unstable_explicit() -> anyhow::Result<()> { - let distro = select_distro_version(&["sid".to_string()], Some("unstable"))?; + let empty = HashMap::new(); + let distro = + select_distro_version(&["sid".to_string()], Some("unstable"), docker_mode(&empty))?; assert_eq!(distro.codename, "unstable"); Ok(()) } #[test] fn resolve_package_target_multiple_distros_requires_explicit() { - let result = resolve_package_target(&test_package_multi_distro_dir(), None); + let empty = HashMap::new(); + let result = + resolve_package_target(&test_package_multi_distro_dir(), None, docker_mode(&empty)); assert!(result.is_err()); assert!( result @@ -193,31 +369,41 @@ mod tests { #[test] fn resolve_package_target_multiple_distros_with_explicit() -> anyhow::Result<()> { - let target = resolve_package_target(&test_package_multi_distro_dir(), Some("unstable"))?; + let empty = HashMap::new(); + let target = resolve_package_target( + &test_package_multi_distro_dir(), + Some("unstable"), + docker_mode(&empty), + )?; assert_eq!(target.identity.name, "test-package"); assert_eq!(target.distro.codename, "unstable"); - assert_eq!(target.distro.distro, Distro::Debian); + assert!(target.distro.distro.is_debian()); Ok(()) } #[test] fn select_distro_version_single_no_explicit() -> anyhow::Result<()> { - let distro = select_distro_version(&["forky".to_string()], None)?; + let empty = HashMap::new(); + let distro = select_distro_version(&["forky".to_string()], None, docker_mode(&empty))?; assert_eq!(distro.codename, "forky"); - assert_eq!(distro.distro, Distro::Debian); + assert!(distro.distro.is_debian()); Ok(()) } #[test] fn select_distro_version_single_matching_explicit() -> anyhow::Result<()> { - let distro = select_distro_version(&["forky".to_string()], Some("forky"))?; + let empty = HashMap::new(); + let distro = + select_distro_version(&["forky".to_string()], Some("forky"), docker_mode(&empty))?; assert_eq!(distro.codename, "forky"); Ok(()) } #[test] fn select_distro_version_single_conflicting_explicit() { - let result = select_distro_version(&["forky".to_string()], Some("duke")); + let empty = HashMap::new(); + let result = + select_distro_version(&["forky".to_string()], Some("duke"), docker_mode(&empty)); assert!(result.is_err()); assert!( result @@ -229,7 +415,12 @@ mod tests { #[test] fn select_distro_version_multiple_no_explicit() { - let result = select_distro_version(&["forky".to_string(), "duke".to_string()], None); + let empty = HashMap::new(); + let result = select_distro_version( + &["forky".to_string(), "duke".to_string()], + None, + docker_mode(&empty), + ); assert!(result.is_err()); assert!( result @@ -241,16 +432,24 @@ mod tests { #[test] fn select_distro_version_multiple_explicit_valid() -> anyhow::Result<()> { - let distro = - select_distro_version(&["forky".to_string(), "duke".to_string()], Some("duke"))?; + let empty = HashMap::new(); + let distro = select_distro_version( + &["forky".to_string(), "duke".to_string()], + Some("duke"), + docker_mode(&empty), + )?; assert_eq!(distro.codename, "duke"); Ok(()) } #[test] fn select_distro_version_multiple_explicit_invalid() { - let result = - select_distro_version(&["forky".to_string(), "duke".to_string()], Some("trixie")); + let empty = HashMap::new(); + let result = select_distro_version( + &["forky".to_string(), "duke".to_string()], + Some("trixie"), + docker_mode(&empty), + ); assert!(result.is_err()); assert!( result @@ -262,7 +461,8 @@ mod tests { #[test] fn select_distro_version_empty_distros() { - let result = select_distro_version(&[], None); + let empty = HashMap::new(); + let result = select_distro_version(&[], None, docker_mode(&empty)); assert!(result.is_err()); assert!( result @@ -271,4 +471,92 @@ mod tests { .contains("changelog contains no distributions") ); } + + #[test] + fn select_custom_distro_from_docker_base_images() -> anyhow::Result<()> { + let mut map = HashMap::new(); + map.insert( + "yocto:kirkstone".to_string(), + "registry.example/yocto-kirkstone:latest".to_string(), + ); + let distro = select_distro_version(&["kirkstone".to_string()], None, docker_mode(&map))?; + assert_eq!(distro.distro.as_str(), "yocto"); + assert_eq!(distro.codename, "kirkstone"); + assert_eq!(distro.version, ""); + Ok(()) + } + + #[test] + fn select_unknown_without_base_images_errors_with_hint() { + let empty = HashMap::new(); + let err = select_distro_version(&["kirkstone".to_string()], None, docker_mode(&empty)) + .unwrap_err() + .to_string(); + assert!(err.contains("unknown distro codename 'kirkstone'")); + assert!(err.contains("driver.docker.base_images")); + assert!(err.contains("yocto:kirkstone")); + } + + #[test] + fn select_rejects_colliding_custom_base_images_key() { + let mut map = HashMap::new(); + map.insert("yocto:trixie".to_string(), "img".to_string()); + let err = + select_distro_version(&["trixie".to_string()], None, docker_mode(&map)).unwrap_err(); + assert!(err.to_string().contains("built-in")); + } + + #[test] + fn parse_os_release_prefers_version_codename() { + let os = parse_os_release( + r#" +ID=debian +VERSION_CODENAME=trixie +UBUNTU_CODENAME=ignored +"#, + ); + assert_eq!(os.id.as_deref(), Some("debian")); + assert_eq!(os.codename(), Some("trixie")); + } + + #[test] + fn bare_builtin_requires_id_and_codename_match() -> anyhow::Result<()> { + let os_path = std::env::temp_dir().join(format!( + "debmagic-os-release-builtin-{}", + std::process::id() + )); + std::fs::write(&os_path, "ID=debian\nVERSION_CODENAME=bookworm\n")?; + let mode = DistroResolveMode::Bare { + os_release_path: &os_path, + }; + let err = select_distro_version(&["trixie".to_string()], None, mode).unwrap_err(); + assert!(err.to_string().contains("VERSION_CODENAME")); + + std::fs::write(&os_path, "ID=ubuntu\nVERSION_CODENAME=trixie\n")?; + let err = select_distro_version(&["trixie".to_string()], None, mode).unwrap_err(); + assert!(err.to_string().contains("ID is 'ubuntu'")); + + std::fs::write(&os_path, "ID=debian\nVERSION_CODENAME=trixie\n")?; + let distro = select_distro_version(&["trixie".to_string()], None, mode)?; + assert!(distro.distro.is_debian()); + assert_eq!(distro.codename, "trixie"); + let _ = std::fs::remove_file(&os_path); + Ok(()) + } + + #[test] + fn bare_custom_matches_codename_only() -> anyhow::Result<()> { + let os_path = + std::env::temp_dir().join(format!("debmagic-os-release-custom-{}", std::process::id())); + std::fs::write(&os_path, "ID=yocto\nVERSION_CODENAME=kirkstone\n")?; + let mode = DistroResolveMode::Bare { + os_release_path: &os_path, + }; + let distro = select_distro_version(&["kirkstone".to_string()], None, mode)?; + assert_eq!(distro.distro.as_str(), "yocto"); + assert_eq!(distro.codename, "kirkstone"); + assert_eq!(distro.version, ""); + let _ = std::fs::remove_file(&os_path); + Ok(()) + } }