use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub struct NativeDepsContract {
#[serde(default)]
pub apt_repos: Vec<AptRepoContract>,
#[serde(default)]
pub apt_packages: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub distro: Option<BaseDistro>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub unresolved_base_image: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub contradicted_base_image: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AptRepoContract {
pub key_url: String,
pub keyring: String,
pub url: String,
#[serde(default)]
pub codename: String,
#[serde(default)]
pub key_fingerprint: String,
pub packages: Vec<String>,
}
fn confluent_repo(codename: &str) -> AptRepoContract {
AptRepoContract {
key_url: "https://packages.confluent.io/clients/deb/archive.key".into(),
keyring: "/usr/share/keyrings/confluent-clients.gpg".into(),
url: "https://packages.confluent.io/clients/deb".into(),
codename: codename.into(),
key_fingerprint: "CBBB821E8FAF364F79835C438B1DA6120C2BF624".into(),
packages: vec!["librdkafka1".into()],
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum BaseDistro {
#[default]
Trixie,
Bookworm,
Noble,
Jammy,
Focal,
}
impl BaseDistro {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Trixie => "trixie",
Self::Bookworm => "bookworm",
Self::Noble => "noble",
Self::Jammy => "jammy",
Self::Focal => "focal",
}
}
#[must_use]
pub fn parse(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"trixie" | "debian13" | "debian-13" => Some(Self::Trixie),
"bookworm" | "debian12" | "debian-12" => Some(Self::Bookworm),
"noble" | "ubuntu24.04" | "ubuntu-24.04" => Some(Self::Noble),
"jammy" | "ubuntu22.04" | "ubuntu-22.04" => Some(Self::Jammy),
"focal" | "ubuntu20.04" | "ubuntu-20.04" => Some(Self::Focal),
_ => None,
}
}
#[must_use]
pub fn from_base_image(base_image: &str) -> Option<Self> {
let without_digest = base_image
.split_once('@')
.map_or(base_image, |(reference, _)| reference);
let (repository, tag) = without_digest.rsplit_once(':')?;
if tag.contains('/') || tag.is_empty() {
return None;
}
if let Some(distro) = tag.split('-').find_map(Self::from_codename) {
return Some(distro);
}
let image_name = repository.rsplit('/').next().unwrap_or(repository);
if image_name != "debian" && image_name != "ubuntu" {
return None;
}
tag.split('-')
.find_map(|part| Self::from_version(image_name, part))
}
fn from_codename(part: &str) -> Option<Self> {
match part {
"trixie" => Some(Self::Trixie),
"bookworm" => Some(Self::Bookworm),
"noble" => Some(Self::Noble),
"jammy" => Some(Self::Jammy),
"focal" => Some(Self::Focal),
_ => None,
}
}
fn from_version(image_name: &str, part: &str) -> Option<Self> {
match image_name {
"debian" => match part.split('.').next().unwrap_or(part) {
"13" => Some(Self::Trixie),
"12" => Some(Self::Bookworm),
_ => None,
},
"ubuntu" => match part {
"24.04" => Some(Self::Noble),
"22.04" => Some(Self::Jammy),
"20.04" => Some(Self::Focal),
_ => None,
},
_ => None,
}
}
#[must_use]
pub const fn confluent_suite(self) -> &'static str {
match self {
Self::Trixie | Self::Bookworm => "bookworm",
Self::Noble => "noble",
Self::Jammy => "jammy",
Self::Focal => "focal",
}
}
#[must_use]
pub const fn libgit2_package(self) -> &'static str {
match self {
Self::Trixie => "libgit2-1.9",
Self::Bookworm => "libgit2-1.5",
Self::Noble => "libgit2-1.7",
Self::Jammy => "libgit2-1.1",
Self::Focal => "libgit2-28",
}
}
#[must_use]
pub const fn libssl_package(self) -> &'static str {
match self {
Self::Trixie | Self::Noble => "libssl3t64",
Self::Bookworm | Self::Jammy => "libssl3",
Self::Focal => "libssl1.1",
}
}
}
impl std::fmt::Display for BaseDistro {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl NativeDepsContract {
#[must_use]
pub fn for_features(features: &[&str], distro: BaseDistro) -> Self {
Self::packages_for(features, distro)
}
#[must_use]
pub fn for_scalo_features(features: &[&str], base_image: &str) -> Self {
if let Some(distro) = crate::deployment::registry::resolve_base_distro(base_image) {
let mut deps = Self::for_features(features, distro);
if BaseDistro::from_base_image(base_image).is_some_and(|d| d != distro) {
deps.contradicted_base_image = Some(base_image.to_string());
}
deps
} else {
let mut deps =
Self::for_features(features, crate::deployment::registry::DEFAULT_BASE_DISTRO);
deps.unresolved_base_image = Some(base_image.to_string());
deps
}
}
fn packages_for(features: &[&str], distro: BaseDistro) -> Self {
let mut apt_repos = Vec::new();
let mut packages: Vec<String> = Vec::new();
let mut add = |pkg: &str| {
if !packages.iter().any(|p| p == pkg) {
packages.push(pkg.into());
}
};
let needs_kafka = features
.iter()
.any(|f| *f == "transport-kafka" || f.starts_with("dlq-kafka"));
if needs_kafka {
apt_repos.push(confluent_repo(distro.confluent_suite()));
add(distro.libssl_package());
add("zlib1g");
}
let needs_zstd = features
.iter()
.any(|f| *f == "spool" || *f == "tiered-sink");
if needs_zstd {
add("libzstd1");
}
let needs_ssl = features.iter().any(|f| {
*f == "http"
|| f.starts_with("secrets")
|| f.starts_with("transport")
|| f.starts_with("otel")
});
if needs_ssl {
add(distro.libssl_package());
add("zlib1g");
}
let needs_git2 = features.contains(&"directory-config-git");
if needs_git2 {
add(distro.libgit2_package());
}
Self {
apt_repos,
apt_packages: packages,
distro: Some(distro),
unresolved_base_image: None,
contradicted_base_image: None,
}
}
#[must_use]
pub fn from_cargo_toml(cargo_toml_path: &std::path::Path, base_image: &str) -> Self {
let Ok(content) = std::fs::read_to_string(cargo_toml_path) else {
return Self::default();
};
let features = extract_scalo_features(&content);
if features.is_empty() {
return Self::default();
}
let feature_refs: Vec<&str> = features.iter().map(String::as_str).collect();
Self::for_scalo_features(&feature_refs, base_image)
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.apt_repos.is_empty() && self.apt_packages.is_empty()
}
}
fn extract_scalo_features(content: &str) -> Vec<String> {
let mut in_scalo = false;
let mut features = Vec::new();
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with("scalo")
&& trimmed.contains("features")
&& let Some(start) = trimmed.find("features = [")
{
let after = &trimmed[start + 12..];
if let Some(end) = after.find(']') {
let feature_str = &after[..end];
for feat in feature_str.split(',') {
let f = feat.trim().trim_matches('"').trim();
if !f.is_empty() {
features.push(f.to_string());
}
}
return features;
}
}
if trimmed.starts_with("scalo") {
in_scalo = true;
continue;
}
if in_scalo {
if trimmed.starts_with(']') {
return features;
}
if trimmed.starts_with('"') {
let f = trimmed.trim_matches('"').trim_end_matches(',').trim();
if !f.is_empty() {
features.push(f.to_string());
}
}
if trimmed.starts_with('[') && !trimmed.starts_with("[dependencies") {
return features;
}
}
}
features
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_kafka_features_add_confluent_repo() {
let deps = NativeDepsContract::for_scalo_features(&["transport-kafka"], "ubuntu:24.04");
assert_eq!(deps.apt_repos.len(), 1);
assert!(deps.apt_repos[0].url.contains("confluent"));
assert!(deps.apt_repos[0].packages.contains(&"librdkafka1".into()));
assert_eq!(deps.apt_repos[0].codename, "noble");
assert!(deps.apt_packages.contains(&"libssl3t64".into()));
assert!(deps.apt_packages.contains(&"zlib1g".into()));
}
#[test]
fn test_spool_adds_zstd() {
let deps = NativeDepsContract::for_scalo_features(&["spool"], "ubuntu:24.04");
assert!(deps.apt_packages.contains(&"libzstd1".into()));
}
#[test]
fn test_tiered_sink_adds_zstd() {
let deps = NativeDepsContract::for_scalo_features(&["tiered-sink"], "ubuntu:24.04");
assert!(deps.apt_packages.contains(&"libzstd1".into()));
}
#[test]
fn test_no_features_empty() {
let deps = NativeDepsContract::for_scalo_features(&[], "ubuntu:24.04");
assert!(deps.is_empty());
}
#[test]
fn test_pure_rust_features_empty() {
let deps = NativeDepsContract::for_scalo_features(
&["cli", "deployment", "logger"],
"ubuntu:24.04",
);
assert!(deps.is_empty());
}
#[test]
fn test_bookworm_codename() {
let deps =
NativeDepsContract::for_scalo_features(&["transport-kafka"], "debian:bookworm-slim");
assert_eq!(deps.apt_repos[0].codename, "bookworm");
}
#[test]
fn test_trixie_kafka_uses_confluent_bookworm() {
let deps =
NativeDepsContract::for_scalo_features(&["transport-kafka"], "debian:trixie-slim");
assert_eq!(deps.apt_repos.len(), 1);
assert!(deps.apt_repos[0].url.contains("confluent"));
assert_eq!(deps.apt_repos[0].codename, "bookworm");
assert!(deps.apt_repos[0].packages.contains(&"librdkafka1".into()));
assert!(!deps.apt_packages.contains(&"librdkafka1".into()));
assert!(deps.apt_packages.contains(&"libssl3t64".into()));
assert!(deps.apt_packages.contains(&"zlib1g".into()));
}
#[test]
fn test_trixie_git2_soname() {
let deps =
NativeDepsContract::for_scalo_features(&["directory-config-git"], "debian:trixie-slim");
assert!(deps.apt_packages.contains(&"libgit2-1.9".into()));
}
#[test]
fn test_no_duplicate_packages() {
let deps = NativeDepsContract::for_scalo_features(
&["transport-kafka", "http", "secrets"],
"ubuntu:24.04",
);
let ssl_count = deps
.apt_packages
.iter()
.filter(|p| *p == "libssl3t64")
.count();
assert_eq!(ssl_count, 1);
}
#[test]
fn test_dlq_kafka_adds_confluent() {
let deps = NativeDepsContract::for_scalo_features(&["dlq-kafka"], "ubuntu:24.04");
assert_eq!(deps.apt_repos.len(), 1);
}
#[test]
fn test_git2_feature() {
let deps =
NativeDepsContract::for_scalo_features(&["directory-config-git"], "ubuntu:24.04");
assert!(deps.apt_packages.contains(&"libgit2-1.7".into()));
}
#[test]
fn test_full_receiver_features() {
let deps = NativeDepsContract::for_scalo_features(
&[
"config",
"config-reload",
"logger",
"metrics",
"http-server",
"transport-kafka",
"transport-grpc",
"dlq-kafka",
"spool",
"tiered-sink",
"runtime",
"secrets",
"scaling",
"cli",
"deployment",
],
"ubuntu:24.04",
);
assert_eq!(deps.apt_repos.len(), 1); assert!(!deps.apt_packages.contains(&"librdkafka1".to_string())); assert!(deps.apt_repos[0].packages.contains(&"librdkafka1".into()));
assert!(deps.apt_packages.contains(&"libssl3t64".into()));
assert!(deps.apt_packages.contains(&"libzstd1".into()));
assert!(deps.apt_packages.contains(&"zlib1g".into()));
}
#[test]
fn distro_from_codename_tags() {
assert_eq!(
BaseDistro::from_base_image("debian:trixie-slim"),
Some(BaseDistro::Trixie)
);
assert_eq!(
BaseDistro::from_base_image("debian:bookworm-slim"),
Some(BaseDistro::Bookworm)
);
assert_eq!(
BaseDistro::from_base_image("ubuntu:noble"),
Some(BaseDistro::Noble)
);
assert_eq!(
BaseDistro::from_base_image("rust:1-trixie"),
Some(BaseDistro::Trixie)
);
}
#[test]
fn distro_from_version_tags() {
assert_eq!(
BaseDistro::from_base_image("debian:13-slim"),
Some(BaseDistro::Trixie)
);
assert_eq!(
BaseDistro::from_base_image("debian:12"),
Some(BaseDistro::Bookworm)
);
assert_eq!(
BaseDistro::from_base_image("ubuntu:24.04"),
Some(BaseDistro::Noble)
);
assert_eq!(
BaseDistro::from_base_image("ubuntu:22.04"),
Some(BaseDistro::Jammy)
);
}
#[test]
fn distro_version_tags_only_count_on_the_debian_and_ubuntu_images() {
assert_eq!(
BaseDistro::from_base_image("postgres:13-bookworm"),
Some(BaseDistro::Bookworm),
"the codename must win over the leading version"
);
assert_eq!(
BaseDistro::from_base_image("postgres:12-trixie"),
Some(BaseDistro::Trixie)
);
assert_eq!(
BaseDistro::from_base_image("ghcr.io/hyperi-io/dfe-base:13"),
None
);
assert_eq!(BaseDistro::from_base_image("postgres:13"), None);
}
#[test]
fn distro_from_debian_point_release_tags() {
assert_eq!(
BaseDistro::from_base_image("debian:13.2-slim"),
Some(BaseDistro::Trixie)
);
assert_eq!(
BaseDistro::from_base_image("debian:12.11-slim"),
Some(BaseDistro::Bookworm)
);
assert_eq!(
BaseDistro::from_base_image("ubuntu:24.04-slim"),
Some(BaseDistro::Noble)
);
assert_eq!(BaseDistro::from_base_image("ubuntu:24"), None);
}
#[test]
fn distro_unresolvable_references_return_none() {
assert_eq!(BaseDistro::from_base_image("debian@sha256:abc123"), None);
assert_eq!(BaseDistro::from_base_image("debian:stable-slim"), None);
assert_eq!(BaseDistro::from_base_image("debian:latest"), None);
assert_eq!(BaseDistro::from_base_image("debian"), None);
assert_eq!(BaseDistro::from_base_image("localhost:5000/debian"), None);
}
#[test]
fn distro_survives_a_pinned_digest_beside_a_tag() {
assert_eq!(
BaseDistro::from_base_image("debian:trixie-slim@sha256:abc123"),
Some(BaseDistro::Trixie)
);
}
#[test]
fn digest_pinned_base_records_the_assumption() {
let deps = NativeDepsContract::for_scalo_features(
&["transport-kafka", "directory-config-git"],
"debian@sha256:abc123",
);
assert_eq!(
deps.unresolved_base_image.as_deref(),
Some("debian@sha256:abc123")
);
assert_eq!(deps.distro, Some(crate::deployment::DEFAULT_BASE_DISTRO));
assert!(deps.apt_packages.contains(&"libgit2-1.9".into()));
assert!(deps.apt_packages.contains(&"libssl3t64".into()));
}
#[test]
fn explicit_distro_contradicting_the_base_image_is_recorded() {
temp_env::with_var("DEPLOYMENT__BASE_DISTRO", Some("noble"), || {
let deps =
NativeDepsContract::for_scalo_features(&["transport-kafka"], "debian:trixie-slim");
assert_eq!(deps.distro, Some(BaseDistro::Noble), "explicit config wins");
assert_eq!(
deps.contradicted_base_image.as_deref(),
Some("debian:trixie-slim")
);
assert!(deps.unresolved_base_image.is_none());
});
}
#[test]
fn explicit_distro_agreeing_with_the_base_image_is_not_flagged() {
temp_env::with_var("DEPLOYMENT__BASE_DISTRO", Some("trixie"), || {
let deps =
NativeDepsContract::for_scalo_features(&["transport-kafka"], "debian:trixie-slim");
assert_eq!(deps.distro, Some(BaseDistro::Trixie));
assert!(deps.contradicted_base_image.is_none());
});
}
#[test]
fn recognised_base_records_no_assumption() {
let deps = NativeDepsContract::for_scalo_features(&["transport-kafka"], "debian:13-slim");
assert!(deps.unresolved_base_image.is_none());
assert_eq!(deps.distro, Some(BaseDistro::Trixie));
assert_eq!(deps.apt_repos[0].codename, "bookworm");
}
#[test]
fn for_features_takes_the_distro_verbatim() {
let deps = NativeDepsContract::for_features(
&["transport-kafka", "directory-config-git"],
BaseDistro::Bookworm,
);
assert_eq!(deps.distro, Some(BaseDistro::Bookworm));
assert!(deps.unresolved_base_image.is_none());
assert_eq!(deps.apt_repos[0].codename, "bookworm");
assert!(deps.apt_packages.contains(&"libgit2-1.5".into()));
assert!(deps.apt_packages.contains(&"libssl3".into()));
}
#[test]
fn distro_parse_accepts_cascade_spellings() {
assert_eq!(BaseDistro::parse("trixie"), Some(BaseDistro::Trixie));
assert_eq!(BaseDistro::parse(" Trixie "), Some(BaseDistro::Trixie));
assert_eq!(BaseDistro::parse("ubuntu24.04"), Some(BaseDistro::Noble));
assert_eq!(BaseDistro::parse("plucky"), None);
}
#[test]
fn distro_package_names_are_release_specific() {
assert_eq!(BaseDistro::Trixie.libgit2_package(), "libgit2-1.9");
assert_eq!(BaseDistro::Bookworm.libgit2_package(), "libgit2-1.5");
assert_eq!(BaseDistro::Noble.libgit2_package(), "libgit2-1.7");
assert_eq!(BaseDistro::Jammy.libgit2_package(), "libgit2-1.1");
assert_eq!(BaseDistro::Focal.libgit2_package(), "libgit2-28");
assert_eq!(BaseDistro::Trixie.libssl_package(), "libssl3t64");
assert_eq!(BaseDistro::Noble.libssl_package(), "libssl3t64");
assert_eq!(BaseDistro::Bookworm.libssl_package(), "libssl3");
assert_eq!(BaseDistro::Focal.libssl_package(), "libssl1.1");
}
#[test]
fn confluent_has_no_trixie_suite() {
assert_eq!(BaseDistro::Trixie.confluent_suite(), "bookworm");
assert_eq!(BaseDistro::Bookworm.confluent_suite(), "bookworm");
assert_eq!(BaseDistro::Noble.confluent_suite(), "noble");
}
}