Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions docs/usage/build.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<release>-proposed`.
Expand Down
9 changes: 7 additions & 2 deletions docs/usage/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<release>-proposed` pocket. Not used by the `bare` driver. |
| `driver.docker.base_images` | map | — | Base image per distro, keyed by `"<distro>:<codename>"` (e.g. `"debian:trixie"`). Falls back to `docker.io/<distro>:<codename>`. |
| `driver.docker.base_images` | map | — | Base image per distro, keyed by `"<distro>:<codename>"` (e.g. `"debian:trixie"`). Falls back to `docker.io/<distro>:<codename>`. 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 `"<distro>:<codename>"`. Falls back to the driver's default remote image. |
| `driver.lxd.base_images` | map | — | Base image per distro, keyed by `"<distro>:<codename>"`. 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). |
Expand Down Expand Up @@ -65,6 +65,11 @@ clean = false
persistent = true
apt_mirror = "http://<mirror-host>/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"
```
1 change: 1 addition & 0 deletions packages/debmagic-common/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ serde = { workspace = true, features = ["derive"] }

[dev-dependencies]
test-case = { workspace = true }
serde_json = { workspace = true }
257 changes: 215 additions & 42 deletions packages/debmagic-common/src/distro.rs
Original file line number Diff line number Diff line change
@@ -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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

instead of getting rid of this, why not have a third "custom" entry?
then we can get rid of all the is_ functions and can still support any custom approach incl other package managers someday.

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<String>) -> 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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(self.as_str())
}
}

impl<'de> Deserialize<'de> for Distro {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
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)]
Expand All @@ -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(),
Expand All @@ -29,78 +83,197 @@ 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<HashMap<&'static str, DistroVersion>> = 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,
/// `sid` → `unstable`
/// - 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<DistroVersion> {
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<Item = &'a str>,
{
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<String, String>,
config_key: &str,
) -> Result<DistroVersion, String> {
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}\" = \"<image>\" }}"
)),
[(family, _)] => Ok(DistroVersion::custom(Distro::new(*family), codename)),
many => {
let keys: Vec<String> = 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\"");
}
}
2 changes: 1 addition & 1 deletion packages/debmagic/src/build/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading