1use std::collections::HashMap;
13use std::path::{Path, PathBuf};
14
15use indexmap::IndexMap;
16use serde::{Deserialize, Serialize};
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
56#[serde(deny_unknown_fields)]
57pub struct ZPipeline {
58 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub version: Option<String>,
61
62 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
65 pub vars: HashMap<String, String>,
66
67 #[serde(default)]
70 pub defaults: PipelineDefaults,
71
72 pub images: IndexMap<String, PipelineImage>,
75
76 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub cache: Option<PipelineCacheConfig>,
80
81 #[serde(default)]
83 pub push: PushConfig,
84}
85
86#[derive(Debug, Clone, Default, Serialize, Deserialize)]
94#[serde(deny_unknown_fields)]
95pub struct PipelineDefaults {
96 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub format: Option<String>,
99
100 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
102 pub build_args: HashMap<String, String>,
103
104 #[serde(default, skip_serializing_if = "is_false")]
106 pub no_cache: bool,
107
108 #[serde(default, skip_serializing_if = "Vec::is_empty")]
111 pub cache_mounts: Vec<crate::zimage::types::ZCacheMount>,
112
113 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub retries: Option<u32>,
116
117 #[serde(default, skip_serializing_if = "Vec::is_empty")]
120 pub platforms: Vec<String>,
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize)]
145#[serde(deny_unknown_fields)]
146pub struct PipelineImage {
147 pub file: PathBuf,
149
150 #[serde(
152 default = "default_context",
153 skip_serializing_if = "is_default_context"
154 )]
155 pub context: PathBuf,
156
157 #[serde(default, skip_serializing_if = "Vec::is_empty")]
159 pub tags: Vec<String>,
160
161 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
164 pub build_args: HashMap<String, String>,
165
166 #[serde(default, skip_serializing_if = "Vec::is_empty")]
169 pub depends_on: Vec<String>,
170
171 #[serde(default, skip_serializing_if = "Option::is_none")]
174 pub no_cache: Option<bool>,
175
176 #[serde(default, skip_serializing_if = "Option::is_none")]
179 pub format: Option<String>,
180
181 #[serde(default, skip_serializing_if = "Vec::is_empty")]
183 pub cache_mounts: Vec<crate::zimage::types::ZCacheMount>,
184
185 #[serde(default, skip_serializing_if = "Option::is_none")]
187 pub retries: Option<u32>,
188
189 #[serde(default, skip_serializing_if = "Vec::is_empty")]
192 pub platforms: Vec<String>,
193}
194
195#[derive(Debug, Clone, Default, Serialize, Deserialize)]
201#[serde(deny_unknown_fields)]
202pub struct PushConfig {
203 #[serde(default, skip_serializing_if = "is_false")]
206 pub after_all: bool,
207}
208
209#[derive(Debug, Clone, Default, Serialize, Deserialize)]
227pub struct PipelineCacheConfig {
228 #[serde(default, rename = "type", skip_serializing_if = "Option::is_none")]
230 pub cache_type: Option<String>,
231
232 #[serde(default, skip_serializing_if = "Option::is_none")]
234 pub path: Option<PathBuf>,
235
236 #[serde(default, skip_serializing_if = "Option::is_none")]
238 pub bucket: Option<String>,
239
240 #[serde(default, skip_serializing_if = "Option::is_none")]
242 pub region: Option<String>,
243
244 #[serde(default, skip_serializing_if = "Option::is_none")]
246 pub endpoint: Option<String>,
247
248 #[serde(default, skip_serializing_if = "Option::is_none")]
250 pub prefix: Option<String>,
251}
252
253fn default_context() -> PathBuf {
259 PathBuf::from(".")
260}
261
262fn is_default_context(path: &Path) -> bool {
264 path.as_os_str() == "."
265}
266
267#[allow(clippy::trivially_copy_pass_by_ref)]
269fn is_false(v: &bool) -> bool {
270 !v
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276
277 #[test]
278 fn test_pipeline_image_defaults() {
279 let yaml = r"
280file: Dockerfile
281";
282 let img: PipelineImage = serde_yaml::from_str(yaml).unwrap();
283 assert_eq!(img.file, PathBuf::from("Dockerfile"));
284 assert_eq!(img.context, PathBuf::from("."));
285 assert!(img.tags.is_empty());
286 assert!(img.build_args.is_empty());
287 assert!(img.depends_on.is_empty());
288 assert!(img.no_cache.is_none());
289 assert!(img.format.is_none());
290 assert!(img.cache_mounts.is_empty());
291 assert!(img.retries.is_none());
292 assert!(img.platforms.is_empty());
293 }
294
295 #[test]
296 fn test_pipeline_defaults_empty() {
297 let yaml = "{}";
298 let defaults: PipelineDefaults = serde_yaml::from_str(yaml).unwrap();
299 assert!(defaults.format.is_none());
300 assert!(defaults.build_args.is_empty());
301 assert!(!defaults.no_cache);
302 assert!(defaults.cache_mounts.is_empty());
303 assert!(defaults.retries.is_none());
304 assert!(defaults.platforms.is_empty());
305 }
306
307 #[test]
308 fn test_pipeline_defaults_full() {
309 let yaml = r#"
310format: oci
311build_args:
312 RUST_VERSION: "1.90"
313no_cache: true
314"#;
315 let defaults: PipelineDefaults = serde_yaml::from_str(yaml).unwrap();
316 assert_eq!(defaults.format, Some("oci".to_string()));
317 assert_eq!(
318 defaults.build_args.get("RUST_VERSION"),
319 Some(&"1.90".to_string())
320 );
321 assert!(defaults.no_cache);
322 }
323
324 #[test]
325 fn test_push_config_defaults() {
326 let yaml = "{}";
327 let push: PushConfig = serde_yaml::from_str(yaml).unwrap();
328 assert!(!push.after_all);
329 }
330
331 #[test]
332 fn test_push_config_after_all() {
333 let yaml = "after_all: true";
334 let push: PushConfig = serde_yaml::from_str(yaml).unwrap();
335 assert!(push.after_all);
336 }
337
338 #[test]
339 fn test_pipeline_cache_config() {
340 let yaml = r"
341version: '1'
342cache:
343 type: persistent
344 path: /tmp/test-cache
345images:
346 test:
347 file: Dockerfile
348";
349 let pipeline: ZPipeline = serde_yaml::from_str(yaml).unwrap();
350 let cache = pipeline.cache.unwrap();
351 assert_eq!(cache.cache_type.as_deref(), Some("persistent"));
352 assert_eq!(cache.path, Some(PathBuf::from("/tmp/test-cache")));
353 }
354
355 #[test]
356 fn test_deny_unknown_fields_pipeline_image() {
357 let yaml = r#"
358file: Dockerfile
359unknown_field: "should fail"
360"#;
361 let result: Result<PipelineImage, _> = serde_yaml::from_str(yaml);
362 assert!(result.is_err(), "Should reject unknown fields");
363 }
364
365 #[test]
366 fn test_deny_unknown_fields_pipeline_defaults() {
367 let yaml = r#"
368format: oci
369bogus: "nope"
370"#;
371 let result: Result<PipelineDefaults, _> = serde_yaml::from_str(yaml);
372 assert!(result.is_err(), "Should reject unknown fields");
373 }
374
375 #[test]
376 fn test_cache_mounts_and_retries_deserialize_defaults() {
377 let yaml = r"
378format: oci
379no_cache: true
380cache_mounts:
381 - target: /root/.cargo/registry
382 id: cargo-registry
383 sharing: shared
384 - target: /root/.cache/pip
385retries: 3
386";
387 let defaults: PipelineDefaults = serde_yaml::from_str(yaml).unwrap();
388 assert_eq!(defaults.cache_mounts.len(), 2);
389 assert_eq!(defaults.cache_mounts[0].target, "/root/.cargo/registry");
390 assert_eq!(
391 defaults.cache_mounts[0].id,
392 Some("cargo-registry".to_string())
393 );
394 assert_eq!(defaults.cache_mounts[0].sharing, Some("shared".to_string()));
395 assert_eq!(defaults.cache_mounts[1].target, "/root/.cache/pip");
396 assert!(defaults.cache_mounts[1].id.is_none());
397 assert_eq!(defaults.retries, Some(3));
398 }
399
400 #[test]
401 fn test_cache_mounts_and_retries_deserialize_image() {
402 let yaml = r"
403file: Dockerfile
404cache_mounts:
405 - target: /tmp/build-cache
406 readonly: true
407retries: 5
408";
409 let img: PipelineImage = serde_yaml::from_str(yaml).unwrap();
410 assert_eq!(img.cache_mounts.len(), 1);
411 assert_eq!(img.cache_mounts[0].target, "/tmp/build-cache");
412 assert!(img.cache_mounts[0].readonly);
413 assert_eq!(img.retries, Some(5));
414 }
415
416 #[test]
417 fn test_deny_unknown_fields_push_config() {
418 let yaml = r#"
419after_all: true
420extra: "bad"
421"#;
422 let result: Result<PushConfig, _> = serde_yaml::from_str(yaml);
423 assert!(result.is_err(), "Should reject unknown fields");
424 }
425
426 #[test]
427 fn test_serialization_skips_defaults() {
428 let img = PipelineImage {
429 file: PathBuf::from("Dockerfile"),
430 context: PathBuf::from("."),
431 tags: vec![],
432 build_args: HashMap::new(),
433 depends_on: vec![],
434 no_cache: None,
435 format: None,
436 cache_mounts: vec![],
437 retries: None,
438 platforms: vec![],
439 };
440 let serialized = serde_yaml::to_string(&img).unwrap();
441 assert!(serialized.contains("file:"));
443 assert!(!serialized.contains("context:"));
444 assert!(!serialized.contains("tags:"));
445 assert!(!serialized.contains("build_args:"));
446 assert!(!serialized.contains("depends_on:"));
447 assert!(!serialized.contains("no_cache:"));
448 assert!(!serialized.contains("format:"));
449 assert!(!serialized.contains("cache_mounts:"));
450 assert!(!serialized.contains("retries:"));
451 assert!(!serialized.contains("platforms:"));
452 }
453
454 #[test]
455 fn test_serialization_includes_non_defaults() {
456 let img = PipelineImage {
457 file: PathBuf::from("Dockerfile"),
458 context: PathBuf::from("./subdir"),
459 tags: vec!["myimage:latest".to_string()],
460 build_args: HashMap::from([("KEY".to_string(), "value".to_string())]),
461 depends_on: vec!["base".to_string()],
462 no_cache: Some(true),
463 format: Some("docker".to_string()),
464 cache_mounts: vec![crate::zimage::types::ZCacheMount {
465 target: "/root/.cache".to_string(),
466 id: Some("mycache".to_string()),
467 sharing: None,
468 readonly: false,
469 }],
470 retries: Some(3),
471 platforms: vec!["linux/amd64".to_string(), "linux/arm64".to_string()],
472 };
473 let serialized = serde_yaml::to_string(&img).unwrap();
474 assert!(serialized.contains("context:"));
475 assert!(serialized.contains("tags:"));
476 assert!(serialized.contains("build_args:"));
477 assert!(serialized.contains("depends_on:"));
478 assert!(serialized.contains("no_cache:"));
479 assert!(serialized.contains("format:"));
480 assert!(serialized.contains("cache_mounts:"));
481 assert!(serialized.contains("retries:"));
482 assert!(serialized.contains("platforms:"));
483 }
484}