Skip to main content

hyperi_rustlib/deployment/
native_deps.rs

1// Project:   hyperi-rustlib
2// File:      src/deployment/native_deps.rs
3// Purpose:   Runtime native dependency declarations for container images
4// Language:  Rust
5//
6// License:   BUSL-1.1
7// Copyright: (c) 2026 HYPERI PTY LIMITED
8
9//! Runtime native dependency contracts for Dockerfile generation.
10//!
11//! Maps hyperi-rustlib Cargo features to the system packages needed at runtime
12//! in the container image. The Dockerfile generator uses this to emit APT repo
13//! setup and `apt-get install` commands automatically.
14
15use serde::{Deserialize, Serialize};
16
17/// Runtime native dependencies for a container image.
18///
19/// Populated via [`NativeDepsContract::for_rustlib_features`] -- pass the list
20/// of hyperi-rustlib features your app enables, get back the runtime packages
21/// and any custom APT repos needed.
22#[derive(Debug, Clone, Default, Serialize, Deserialize)]
23pub struct NativeDepsContract {
24    /// Custom APT repositories to add before installing packages.
25    #[serde(default)]
26    pub apt_repos: Vec<AptRepoContract>,
27
28    /// APT packages to install from default repos.
29    #[serde(default)]
30    pub apt_packages: Vec<String>,
31}
32
33/// A custom APT repository (e.g., Confluent for librdkafka).
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct AptRepoContract {
36    /// GPG key URL for the repo.
37    pub key_url: String,
38
39    /// Local keyring file path (e.g., `/usr/share/keyrings/confluent-clients.gpg`).
40    pub keyring: String,
41
42    /// Repository base URL (e.g., `https://packages.confluent.io/clients/deb`).
43    pub url: String,
44
45    /// Distribution codename (e.g., `noble`, `bookworm`).
46    /// If empty, derived from the base image at generation time.
47    #[serde(default)]
48    pub codename: String,
49
50    /// APT packages to install from this specific repo.
51    pub packages: Vec<String>,
52}
53
54/// Confluent APT repository for librdkafka.
55fn confluent_repo(codename: &str) -> AptRepoContract {
56    AptRepoContract {
57        key_url: "https://packages.confluent.io/clients/deb/archive.key".into(),
58        keyring: "/usr/share/keyrings/confluent-clients.gpg".into(),
59        url: "https://packages.confluent.io/clients/deb".into(),
60        codename: codename.into(),
61        packages: vec!["librdkafka1".into()],
62    }
63}
64
65/// Derive the APT codename from a base image string.
66///
67/// Maps common base images to their codenames. Falls back to `noble` if
68/// the image is not recognised.
69fn codename_from_base_image(base_image: &str) -> &'static str {
70    if base_image.contains("bookworm") {
71        "bookworm"
72    } else if base_image.contains("jammy") {
73        "jammy"
74    } else if base_image.contains("focal") {
75        "focal"
76    } else {
77        // ubuntu:24.04 and anything else → noble
78        "noble"
79    }
80}
81
82impl NativeDepsContract {
83    /// Build runtime native deps from a list of hyperi-rustlib feature names.
84    ///
85    /// Pass the same feature strings you use in `Cargo.toml` (e.g.,
86    /// `"transport-kafka"`, `"spool"`, `"secrets-aws"`). The base image is
87    /// used to derive the APT codename for custom repos.
88    ///
89    /// # Example
90    ///
91    /// ```rust
92    /// use hyperi_rustlib::deployment::NativeDepsContract;
93    ///
94    /// let deps = NativeDepsContract::for_rustlib_features(
95    ///     &["transport-kafka", "spool", "tiered-sink", "secrets"],
96    ///     "ubuntu:24.04",
97    /// );
98    /// assert!(!deps.apt_packages.is_empty());
99    /// assert!(!deps.apt_repos.is_empty());
100    /// ```
101    #[must_use]
102    pub fn for_rustlib_features(features: &[&str], base_image: &str) -> Self {
103        let codename = codename_from_base_image(base_image);
104        let mut apt_repos = Vec::new();
105        let mut packages: Vec<String> = Vec::new();
106        let mut seen = std::collections::HashSet::new();
107
108        let mut add = |pkg: &str| {
109            if seen.insert(pkg.to_string()) {
110                packages.push(pkg.into());
111            }
112        };
113
114        let needs_kafka = features
115            .iter()
116            .any(|f| *f == "transport-kafka" || f.starts_with("dlq-kafka"));
117
118        if needs_kafka {
119            apt_repos.push(confluent_repo(codename));
120            add("libssl3");
121            add("zlib1g");
122        }
123
124        let needs_zstd = features
125            .iter()
126            .any(|f| *f == "spool" || *f == "tiered-sink");
127        if needs_zstd {
128            add("libzstd1");
129        }
130
131        // openssl is a transitive dep for many features (http, secrets, transport)
132        let needs_ssl = features.iter().any(|f| {
133            *f == "http"
134                || f.starts_with("secrets")
135                || f.starts_with("transport")
136                || *f == "config-postgres"
137                || f.starts_with("otel")
138        });
139        if needs_ssl {
140            add("libssl3");
141            add("zlib1g");
142        }
143
144        // directory-config-git needs libgit2
145        let needs_git2 = features.contains(&"directory-config-git");
146        if needs_git2 {
147            add("libgit2-1.7");
148        }
149
150        Self {
151            apt_repos,
152            apt_packages: packages,
153        }
154    }
155
156    /// Auto-detect native deps from the app's Cargo.toml.
157    ///
158    /// Reads `[dependencies.hyperi-rustlib]` features from the given Cargo.toml
159    /// and maps them to runtime packages. Falls back to empty deps if parsing fails.
160    #[must_use]
161    pub fn from_cargo_toml(cargo_toml_path: &std::path::Path, base_image: &str) -> Self {
162        let Ok(content) = std::fs::read_to_string(cargo_toml_path) else {
163            return Self::default();
164        };
165
166        // Parse features from the hyperi-rustlib dependency line
167        // Matches: features = ["transport-kafka", "spool", ...]
168        let features = extract_rustlib_features(&content);
169        if features.is_empty() {
170            return Self::default();
171        }
172
173        let feature_refs: Vec<&str> = features.iter().map(String::as_str).collect();
174        Self::for_rustlib_features(&feature_refs, base_image)
175    }
176
177    /// Returns true if there are no native deps to install.
178    #[must_use]
179    pub fn is_empty(&self) -> bool {
180        self.apt_repos.is_empty() && self.apt_packages.is_empty()
181    }
182}
183
184/// Extract hyperi-rustlib feature names from Cargo.toml content.
185///
186/// Parses the `features = [...]` array from the `hyperi-rustlib` dependency.
187/// Returns empty vec if not found or parsing fails.
188fn extract_rustlib_features(content: &str) -> Vec<String> {
189    // Find the hyperi-rustlib dependency line
190    let mut in_rustlib = false;
191    let mut features = Vec::new();
192
193    for line in content.lines() {
194        let trimmed = line.trim();
195
196        // Single-line: hyperi-rustlib = { version = "...", features = [...] }
197        if trimmed.starts_with("hyperi-rustlib")
198            && trimmed.contains("features")
199            && let Some(start) = trimmed.find("features = [")
200        {
201            let after = &trimmed[start + 12..];
202            if let Some(end) = after.find(']') {
203                let feature_str = &after[..end];
204                for feat in feature_str.split(',') {
205                    let f = feat.trim().trim_matches('"').trim();
206                    if !f.is_empty() {
207                        features.push(f.to_string());
208                    }
209                }
210                return features;
211            }
212        }
213
214        // Multi-line: features = [\n"transport-kafka",\n...\n]
215        if trimmed.starts_with("hyperi-rustlib") {
216            in_rustlib = true;
217            continue;
218        }
219        if in_rustlib {
220            if trimmed.starts_with(']') {
221                return features;
222            }
223            if trimmed.starts_with('"') {
224                let f = trimmed.trim_matches('"').trim_end_matches(',').trim();
225                if !f.is_empty() {
226                    features.push(f.to_string());
227                }
228            }
229            // End of dependency block
230            if trimmed.starts_with('[') && !trimmed.starts_with("[dependencies") {
231                return features;
232            }
233        }
234    }
235
236    features
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    #[test]
244    fn test_kafka_features_add_confluent_repo() {
245        let deps = NativeDepsContract::for_rustlib_features(&["transport-kafka"], "ubuntu:24.04");
246        assert_eq!(deps.apt_repos.len(), 1);
247        assert!(deps.apt_repos[0].url.contains("confluent"));
248        assert!(deps.apt_repos[0].packages.contains(&"librdkafka1".into()));
249        assert_eq!(deps.apt_repos[0].codename, "noble");
250        assert!(deps.apt_packages.contains(&"libssl3".into()));
251        assert!(deps.apt_packages.contains(&"zlib1g".into()));
252    }
253
254    #[test]
255    fn test_spool_adds_zstd() {
256        let deps = NativeDepsContract::for_rustlib_features(&["spool"], "ubuntu:24.04");
257        assert!(deps.apt_packages.contains(&"libzstd1".into()));
258    }
259
260    #[test]
261    fn test_tiered_sink_adds_zstd() {
262        let deps = NativeDepsContract::for_rustlib_features(&["tiered-sink"], "ubuntu:24.04");
263        assert!(deps.apt_packages.contains(&"libzstd1".into()));
264    }
265
266    #[test]
267    fn test_no_features_empty() {
268        let deps = NativeDepsContract::for_rustlib_features(&[], "ubuntu:24.04");
269        assert!(deps.is_empty());
270    }
271
272    #[test]
273    fn test_pure_rust_features_empty() {
274        let deps = NativeDepsContract::for_rustlib_features(
275            &["cli", "deployment", "logger"],
276            "ubuntu:24.04",
277        );
278        assert!(deps.is_empty());
279    }
280
281    #[test]
282    fn test_bookworm_codename() {
283        let deps =
284            NativeDepsContract::for_rustlib_features(&["transport-kafka"], "debian:bookworm-slim");
285        assert_eq!(deps.apt_repos[0].codename, "bookworm");
286    }
287
288    #[test]
289    fn test_no_duplicate_packages() {
290        let deps = NativeDepsContract::for_rustlib_features(
291            &["transport-kafka", "http", "secrets"],
292            "ubuntu:24.04",
293        );
294        let ssl_count = deps.apt_packages.iter().filter(|p| *p == "libssl3").count();
295        assert_eq!(ssl_count, 1);
296    }
297
298    #[test]
299    fn test_dlq_kafka_adds_confluent() {
300        let deps = NativeDepsContract::for_rustlib_features(&["dlq-kafka"], "ubuntu:24.04");
301        assert_eq!(deps.apt_repos.len(), 1);
302    }
303
304    #[test]
305    fn test_git2_feature() {
306        let deps =
307            NativeDepsContract::for_rustlib_features(&["directory-config-git"], "ubuntu:24.04");
308        assert!(deps.apt_packages.contains(&"libgit2-1.7".into()));
309    }
310
311    #[test]
312    fn test_full_receiver_features() {
313        let deps = NativeDepsContract::for_rustlib_features(
314            &[
315                "config",
316                "config-reload",
317                "logger",
318                "metrics",
319                "http-server",
320                "transport-kafka",
321                "transport-grpc",
322                "dlq-kafka",
323                "spool",
324                "tiered-sink",
325                "runtime",
326                "secrets",
327                "scaling",
328                "cli",
329                "deployment",
330            ],
331            "ubuntu:24.04",
332        );
333        assert_eq!(deps.apt_repos.len(), 1); // confluent only
334        assert!(!deps.apt_packages.contains(&"librdkafka1".to_string())); // in repo packages
335        assert!(deps.apt_repos[0].packages.contains(&"librdkafka1".into()));
336        assert!(deps.apt_packages.contains(&"libssl3".into()));
337        assert!(deps.apt_packages.contains(&"libzstd1".into()));
338        assert!(deps.apt_packages.contains(&"zlib1g".into()));
339    }
340}