1use std::collections::HashMap;
13use std::path::{Path, PathBuf};
14
15use indexmap::IndexMap;
16use serde::{Deserialize, Serialize};
17
18use crate::backend::ImageOs;
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
58#[serde(deny_unknown_fields)]
59pub struct ZPipeline {
60 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub version: Option<String>,
63
64 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
67 pub vars: HashMap<String, String>,
68
69 #[serde(default)]
72 pub defaults: PipelineDefaults,
73
74 pub images: IndexMap<String, PipelineImage>,
77
78 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub cache: Option<PipelineCacheConfig>,
82
83 #[serde(default)]
85 pub push: PushConfig,
86}
87
88#[derive(Debug, Clone, Default, Serialize, Deserialize)]
96#[serde(deny_unknown_fields)]
97pub struct PipelineDefaults {
98 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub format: Option<String>,
101
102 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
104 pub build_args: HashMap<String, String>,
105
106 #[serde(default, skip_serializing_if = "is_false")]
108 pub no_cache: bool,
109
110 #[serde(default, skip_serializing_if = "Vec::is_empty")]
113 pub cache_mounts: Vec<crate::zimage::types::ZCacheMount>,
114
115 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub retries: Option<u32>,
118
119 #[serde(default, skip_serializing_if = "Vec::is_empty")]
122 pub platforms: Vec<String>,
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize)]
147#[serde(deny_unknown_fields)]
148pub struct PipelineImage {
149 pub file: PathBuf,
151
152 #[serde(
154 default = "default_context",
155 skip_serializing_if = "is_default_context"
156 )]
157 pub context: PathBuf,
158
159 #[serde(default, skip_serializing_if = "Vec::is_empty")]
161 pub tags: Vec<String>,
162
163 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
166 pub build_args: HashMap<String, String>,
167
168 #[serde(default, skip_serializing_if = "Vec::is_empty")]
171 pub depends_on: Vec<String>,
172
173 #[serde(default, skip_serializing_if = "Option::is_none")]
176 pub no_cache: Option<bool>,
177
178 #[serde(default, skip_serializing_if = "Option::is_none")]
181 pub format: Option<String>,
182
183 #[serde(default, skip_serializing_if = "Vec::is_empty")]
185 pub cache_mounts: Vec<crate::zimage::types::ZCacheMount>,
186
187 #[serde(default, skip_serializing_if = "Option::is_none")]
189 pub retries: Option<u32>,
190
191 #[serde(default, skip_serializing_if = "Vec::is_empty")]
194 pub platforms: Vec<String>,
195
196 #[serde(default, skip_serializing_if = "Option::is_none")]
198 pub os: Option<ImageOs>,
199}
200
201#[derive(Debug, Clone, Default, Serialize, Deserialize)]
207#[serde(deny_unknown_fields)]
208pub struct PushConfig {
209 #[serde(default, skip_serializing_if = "is_false")]
212 pub after_all: bool,
213}
214
215#[derive(Debug, Clone, Default, Serialize, Deserialize)]
233pub struct PipelineCacheConfig {
234 #[serde(default, rename = "type", skip_serializing_if = "Option::is_none")]
236 pub cache_type: Option<String>,
237
238 #[serde(default, skip_serializing_if = "Option::is_none")]
240 pub path: Option<PathBuf>,
241
242 #[serde(default, skip_serializing_if = "Option::is_none")]
244 pub bucket: Option<String>,
245
246 #[serde(default, skip_serializing_if = "Option::is_none")]
248 pub region: Option<String>,
249
250 #[serde(default, skip_serializing_if = "Option::is_none")]
252 pub endpoint: Option<String>,
253
254 #[serde(default, skip_serializing_if = "Option::is_none")]
256 pub prefix: Option<String>,
257}
258
259fn default_context() -> PathBuf {
265 PathBuf::from(".")
266}
267
268fn is_default_context(path: &Path) -> bool {
270 path.as_os_str() == "."
271}
272
273#[allow(clippy::trivially_copy_pass_by_ref)]
275fn is_false(v: &bool) -> bool {
276 !v
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282
283 #[test]
284 fn test_pipeline_image_defaults() {
285 let yaml = r"
286file: Dockerfile
287";
288 let img: PipelineImage = serde_yaml::from_str(yaml).unwrap();
289 assert_eq!(img.file, PathBuf::from("Dockerfile"));
290 assert_eq!(img.context, PathBuf::from("."));
291 assert!(img.tags.is_empty());
292 assert!(img.build_args.is_empty());
293 assert!(img.depends_on.is_empty());
294 assert!(img.no_cache.is_none());
295 assert!(img.format.is_none());
296 assert!(img.cache_mounts.is_empty());
297 assert!(img.retries.is_none());
298 assert!(img.platforms.is_empty());
299 assert!(img.os.is_none());
300 }
301
302 #[test]
303 fn test_pipeline_image_os_linux() {
304 let yaml = r"
305file: Dockerfile
306os: linux
307";
308 let img: PipelineImage = serde_yaml::from_str(yaml).unwrap();
309 assert_eq!(img.os, Some(ImageOs::Linux));
310 }
311
312 #[test]
313 fn test_pipeline_image_os_windows() {
314 let yaml = r"
315file: Dockerfile
316os: windows
317";
318 let img: PipelineImage = serde_yaml::from_str(yaml).unwrap();
319 assert_eq!(img.os, Some(ImageOs::Windows));
320 }
321
322 #[test]
323 fn test_pipeline_defaults_empty() {
324 let yaml = "{}";
325 let defaults: PipelineDefaults = serde_yaml::from_str(yaml).unwrap();
326 assert!(defaults.format.is_none());
327 assert!(defaults.build_args.is_empty());
328 assert!(!defaults.no_cache);
329 assert!(defaults.cache_mounts.is_empty());
330 assert!(defaults.retries.is_none());
331 assert!(defaults.platforms.is_empty());
332 }
333
334 #[test]
335 fn test_pipeline_defaults_full() {
336 let yaml = r#"
337format: oci
338build_args:
339 RUST_VERSION: "1.90"
340no_cache: true
341"#;
342 let defaults: PipelineDefaults = serde_yaml::from_str(yaml).unwrap();
343 assert_eq!(defaults.format, Some("oci".to_string()));
344 assert_eq!(
345 defaults.build_args.get("RUST_VERSION"),
346 Some(&"1.90".to_string())
347 );
348 assert!(defaults.no_cache);
349 }
350
351 #[test]
352 fn test_push_config_defaults() {
353 let yaml = "{}";
354 let push: PushConfig = serde_yaml::from_str(yaml).unwrap();
355 assert!(!push.after_all);
356 }
357
358 #[test]
359 fn test_push_config_after_all() {
360 let yaml = "after_all: true";
361 let push: PushConfig = serde_yaml::from_str(yaml).unwrap();
362 assert!(push.after_all);
363 }
364
365 #[test]
366 fn test_pipeline_cache_config() {
367 let yaml = r"
368version: '1'
369cache:
370 type: persistent
371 path: /tmp/test-cache
372images:
373 test:
374 file: Dockerfile
375";
376 let pipeline: ZPipeline = serde_yaml::from_str(yaml).unwrap();
377 let cache = pipeline.cache.unwrap();
378 assert_eq!(cache.cache_type.as_deref(), Some("persistent"));
379 assert_eq!(cache.path, Some(PathBuf::from("/tmp/test-cache")));
380 }
381
382 #[test]
383 fn test_deny_unknown_fields_pipeline_image() {
384 let yaml = r#"
385file: Dockerfile
386unknown_field: "should fail"
387"#;
388 let result: Result<PipelineImage, _> = serde_yaml::from_str(yaml);
389 assert!(result.is_err(), "Should reject unknown fields");
390 }
391
392 #[test]
393 fn test_deny_unknown_fields_pipeline_defaults() {
394 let yaml = r#"
395format: oci
396bogus: "nope"
397"#;
398 let result: Result<PipelineDefaults, _> = serde_yaml::from_str(yaml);
399 assert!(result.is_err(), "Should reject unknown fields");
400 }
401
402 #[test]
403 fn test_cache_mounts_and_retries_deserialize_defaults() {
404 let yaml = r"
405format: oci
406no_cache: true
407cache_mounts:
408 - target: /root/.cargo/registry
409 id: cargo-registry
410 sharing: shared
411 - target: /root/.cache/pip
412retries: 3
413";
414 let defaults: PipelineDefaults = serde_yaml::from_str(yaml).unwrap();
415 assert_eq!(defaults.cache_mounts.len(), 2);
416 assert_eq!(defaults.cache_mounts[0].target, "/root/.cargo/registry");
417 assert_eq!(
418 defaults.cache_mounts[0].id,
419 Some("cargo-registry".to_string())
420 );
421 assert_eq!(defaults.cache_mounts[0].sharing, Some("shared".to_string()));
422 assert_eq!(defaults.cache_mounts[1].target, "/root/.cache/pip");
423 assert!(defaults.cache_mounts[1].id.is_none());
424 assert_eq!(defaults.retries, Some(3));
425 }
426
427 #[test]
428 fn test_cache_mounts_and_retries_deserialize_image() {
429 let yaml = r"
430file: Dockerfile
431cache_mounts:
432 - target: /tmp/build-cache
433 readonly: true
434retries: 5
435";
436 let img: PipelineImage = serde_yaml::from_str(yaml).unwrap();
437 assert_eq!(img.cache_mounts.len(), 1);
438 assert_eq!(img.cache_mounts[0].target, "/tmp/build-cache");
439 assert!(img.cache_mounts[0].readonly);
440 assert_eq!(img.retries, Some(5));
441 }
442
443 #[test]
444 fn test_deny_unknown_fields_push_config() {
445 let yaml = r#"
446after_all: true
447extra: "bad"
448"#;
449 let result: Result<PushConfig, _> = serde_yaml::from_str(yaml);
450 assert!(result.is_err(), "Should reject unknown fields");
451 }
452
453 #[test]
454 fn test_serialization_skips_defaults() {
455 let img = PipelineImage {
456 file: PathBuf::from("Dockerfile"),
457 context: PathBuf::from("."),
458 tags: vec![],
459 build_args: HashMap::new(),
460 depends_on: vec![],
461 no_cache: None,
462 format: None,
463 cache_mounts: vec![],
464 retries: None,
465 platforms: vec![],
466 os: None,
467 };
468 let serialized = serde_yaml::to_string(&img).unwrap();
469 assert!(serialized.contains("file:"));
471 assert!(!serialized.contains("context:"));
472 assert!(!serialized.contains("tags:"));
473 assert!(!serialized.contains("build_args:"));
474 assert!(!serialized.contains("depends_on:"));
475 assert!(!serialized.contains("no_cache:"));
476 assert!(!serialized.contains("format:"));
477 assert!(!serialized.contains("cache_mounts:"));
478 assert!(!serialized.contains("retries:"));
479 assert!(!serialized.contains("platforms:"));
480 assert!(!serialized.contains("os:"));
481 }
482
483 #[test]
484 fn test_serialization_includes_non_defaults() {
485 let img = PipelineImage {
486 file: PathBuf::from("Dockerfile"),
487 context: PathBuf::from("./subdir"),
488 tags: vec!["myimage:latest".to_string()],
489 build_args: HashMap::from([("KEY".to_string(), "value".to_string())]),
490 depends_on: vec!["base".to_string()],
491 no_cache: Some(true),
492 format: Some("docker".to_string()),
493 cache_mounts: vec![crate::zimage::types::ZCacheMount {
494 target: "/root/.cache".to_string(),
495 id: Some("mycache".to_string()),
496 sharing: None,
497 readonly: false,
498 }],
499 retries: Some(3),
500 platforms: vec!["linux/amd64".to_string(), "linux/arm64".to_string()],
501 os: Some(ImageOs::Linux),
502 };
503 let serialized = serde_yaml::to_string(&img).unwrap();
504 assert!(serialized.contains("context:"));
505 assert!(serialized.contains("tags:"));
506 assert!(serialized.contains("build_args:"));
507 assert!(serialized.contains("depends_on:"));
508 assert!(serialized.contains("no_cache:"));
509 assert!(serialized.contains("format:"));
510 assert!(serialized.contains("cache_mounts:"));
511 assert!(serialized.contains("retries:"));
512 assert!(serialized.contains("platforms:"));
513 assert!(serialized.contains("os:"));
514 }
515}