hyperi_rustlib/deployment/
native_deps.rs1use serde::{Deserialize, Serialize};
16
17#[derive(Debug, Clone, Default, Serialize, Deserialize)]
23pub struct NativeDepsContract {
24 #[serde(default)]
26 pub apt_repos: Vec<AptRepoContract>,
27
28 #[serde(default)]
30 pub apt_packages: Vec<String>,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct AptRepoContract {
36 pub key_url: String,
38
39 pub keyring: String,
41
42 pub url: String,
44
45 #[serde(default)]
48 pub codename: String,
49
50 pub packages: Vec<String>,
52}
53
54fn 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
65fn 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 "noble"
79 }
80}
81
82impl NativeDepsContract {
83 #[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 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 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 #[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 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 #[must_use]
179 pub fn is_empty(&self) -> bool {
180 self.apt_repos.is_empty() && self.apt_packages.is_empty()
181 }
182}
183
184fn extract_rustlib_features(content: &str) -> Vec<String> {
189 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 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 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 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); assert!(!deps.apt_packages.contains(&"librdkafka1".to_string())); 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}