Skip to main content

zlayer_builder/pipeline/
types.rs

1//! `ZPipeline` types - YAML-based multi-image build pipeline format
2//!
3//! This module defines all serde-deserializable types for the `ZPipeline` format,
4//! which coordinates building multiple container images from a single manifest.
5//! The format supports:
6//!
7//! - **Global variables** - Template substitution via `${VAR}` syntax
8//! - **Default settings** - Shared configuration inherited by all images
9//! - **Dependency ordering** - `depends_on` declares build order constraints
10//! - **Coordinated push** - Push all images after successful builds
11
12use std::collections::HashMap;
13use std::path::{Path, PathBuf};
14
15use indexmap::IndexMap;
16use serde::{Deserialize, Serialize};
17
18// ---------------------------------------------------------------------------
19// Top-level ZPipeline
20// ---------------------------------------------------------------------------
21
22/// Top-level ZPipeline.yaml representation.
23///
24/// A pipeline coordinates building multiple container images with dependency
25/// ordering, shared configuration, and coordinated push operations.
26///
27/// # YAML Example
28///
29/// ```yaml
30/// version: "1"
31///
32/// vars:
33///   VERSION: "1.0.0"
34///   REGISTRY: "ghcr.io/myorg"
35///
36/// defaults:
37///   format: oci
38///   build_args:
39///     RUST_VERSION: "1.90"
40///
41/// images:
42///   base:
43///     file: docker/Dockerfile.base
44///     tags:
45///       - "${REGISTRY}/base:${VERSION}"
46///   app:
47///     file: docker/Dockerfile.app
48///     depends_on: [base]
49///     tags:
50///       - "${REGISTRY}/app:${VERSION}"
51///
52/// push:
53///   after_all: true
54/// ```
55#[derive(Debug, Clone, Serialize, Deserialize)]
56#[serde(deny_unknown_fields)]
57pub struct ZPipeline {
58    /// Pipeline format version (currently "1")
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub version: Option<String>,
61
62    /// Global variables for template substitution.
63    /// Referenced in tags and other string fields as `${VAR_NAME}`.
64    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
65    pub vars: HashMap<String, String>,
66
67    /// Default settings inherited by all images.
68    /// Individual image settings override these defaults.
69    #[serde(default)]
70    pub defaults: PipelineDefaults,
71
72    /// Named images to build. Order is preserved (`IndexMap`).
73    /// Keys are image names used for `depends_on` references.
74    pub images: IndexMap<String, PipelineImage>,
75
76    /// Push configuration.
77    #[serde(default)]
78    pub push: PushConfig,
79}
80
81// ---------------------------------------------------------------------------
82// Pipeline defaults
83// ---------------------------------------------------------------------------
84
85/// Default settings applied to all images in the pipeline.
86///
87/// Individual image settings take precedence over these defaults.
88#[derive(Debug, Clone, Default, Serialize, Deserialize)]
89#[serde(deny_unknown_fields)]
90pub struct PipelineDefaults {
91    /// Default output format: "oci" or "docker"
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub format: Option<String>,
94
95    /// Default build arguments passed to all image builds
96    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
97    pub build_args: HashMap<String, String>,
98
99    /// Whether to skip cache by default
100    #[serde(default, skip_serializing_if = "is_false")]
101    pub no_cache: bool,
102
103    /// Default cache mounts applied to all RUN steps in all images.
104    /// Individual `ZImagefile` step-level cache mounts are additive.
105    #[serde(default, skip_serializing_if = "Vec::is_empty")]
106    pub cache_mounts: Vec<crate::zimage::types::ZCacheMount>,
107
108    /// Default retry count for failed RUN steps (0 = no retries)
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub retries: Option<u32>,
111}
112
113// ---------------------------------------------------------------------------
114// Pipeline image
115// ---------------------------------------------------------------------------
116
117/// Configuration for a single image in the pipeline.
118///
119/// # YAML Example
120///
121/// ```yaml
122/// zlayer-app:
123///   file: docker/ZImagefile.app
124///   context: "."
125///   tags:
126///     - "${REGISTRY}/app:${VERSION}"
127///     - "${REGISTRY}/app:latest"
128///   depends_on: [zlayer-base]
129///   build_args:
130///     EXTRA_ARG: "value"
131///   no_cache: false
132///   format: oci
133/// ```
134#[derive(Debug, Clone, Serialize, Deserialize)]
135#[serde(deny_unknown_fields)]
136pub struct PipelineImage {
137    /// Path to the build file (Dockerfile, `ZImagefile`, etc.)
138    pub file: PathBuf,
139
140    /// Build context directory. Defaults to "."
141    #[serde(
142        default = "default_context",
143        skip_serializing_if = "is_default_context"
144    )]
145    pub context: PathBuf,
146
147    /// Image tags to apply. Supports variable substitution.
148    #[serde(default, skip_serializing_if = "Vec::is_empty")]
149    pub tags: Vec<String>,
150
151    /// Build arguments specific to this image.
152    /// Merged with (and overrides) `defaults.build_args`.
153    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
154    pub build_args: HashMap<String, String>,
155
156    /// Names of images that must be built before this one.
157    /// Creates a dependency graph for build ordering.
158    #[serde(default, skip_serializing_if = "Vec::is_empty")]
159    pub depends_on: Vec<String>,
160
161    /// Override `no_cache` setting for this image.
162    /// If None, inherits from defaults.
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub no_cache: Option<bool>,
165
166    /// Override output format for this image.
167    /// If None, inherits from defaults.
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub format: Option<String>,
170
171    /// Cache mounts for this image's RUN steps, merged with defaults
172    #[serde(default, skip_serializing_if = "Vec::is_empty")]
173    pub cache_mounts: Vec<crate::zimage::types::ZCacheMount>,
174
175    /// Override retry count for this image
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub retries: Option<u32>,
178}
179
180// ---------------------------------------------------------------------------
181// Push configuration
182// ---------------------------------------------------------------------------
183
184/// Configuration for pushing built images to registries.
185#[derive(Debug, Clone, Default, Serialize, Deserialize)]
186#[serde(deny_unknown_fields)]
187pub struct PushConfig {
188    /// If true, push all images only after all builds succeed.
189    /// If false (default), images are not pushed automatically.
190    #[serde(default, skip_serializing_if = "is_false")]
191    pub after_all: bool,
192}
193
194// ---------------------------------------------------------------------------
195// Helpers
196// ---------------------------------------------------------------------------
197
198/// Default build context directory.
199fn default_context() -> PathBuf {
200    PathBuf::from(".")
201}
202
203/// Check if path is the default context.
204fn is_default_context(path: &Path) -> bool {
205    path.as_os_str() == "."
206}
207
208/// Helper for `skip_serializing_if` on boolean fields.
209#[allow(clippy::trivially_copy_pass_by_ref)]
210fn is_false(v: &bool) -> bool {
211    !v
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    #[test]
219    fn test_pipeline_image_defaults() {
220        let yaml = r#"
221file: Dockerfile
222"#;
223        let img: PipelineImage = serde_yml::from_str(yaml).unwrap();
224        assert_eq!(img.file, PathBuf::from("Dockerfile"));
225        assert_eq!(img.context, PathBuf::from("."));
226        assert!(img.tags.is_empty());
227        assert!(img.build_args.is_empty());
228        assert!(img.depends_on.is_empty());
229        assert!(img.no_cache.is_none());
230        assert!(img.format.is_none());
231        assert!(img.cache_mounts.is_empty());
232        assert!(img.retries.is_none());
233    }
234
235    #[test]
236    fn test_pipeline_defaults_empty() {
237        let yaml = "{}";
238        let defaults: PipelineDefaults = serde_yml::from_str(yaml).unwrap();
239        assert!(defaults.format.is_none());
240        assert!(defaults.build_args.is_empty());
241        assert!(!defaults.no_cache);
242        assert!(defaults.cache_mounts.is_empty());
243        assert!(defaults.retries.is_none());
244    }
245
246    #[test]
247    fn test_pipeline_defaults_full() {
248        let yaml = r#"
249format: oci
250build_args:
251  RUST_VERSION: "1.90"
252no_cache: true
253"#;
254        let defaults: PipelineDefaults = serde_yml::from_str(yaml).unwrap();
255        assert_eq!(defaults.format, Some("oci".to_string()));
256        assert_eq!(
257            defaults.build_args.get("RUST_VERSION"),
258            Some(&"1.90".to_string())
259        );
260        assert!(defaults.no_cache);
261    }
262
263    #[test]
264    fn test_push_config_defaults() {
265        let yaml = "{}";
266        let push: PushConfig = serde_yml::from_str(yaml).unwrap();
267        assert!(!push.after_all);
268    }
269
270    #[test]
271    fn test_push_config_after_all() {
272        let yaml = "after_all: true";
273        let push: PushConfig = serde_yml::from_str(yaml).unwrap();
274        assert!(push.after_all);
275    }
276
277    #[test]
278    fn test_deny_unknown_fields_pipeline_image() {
279        let yaml = r#"
280file: Dockerfile
281unknown_field: "should fail"
282"#;
283        let result: Result<PipelineImage, _> = serde_yml::from_str(yaml);
284        assert!(result.is_err(), "Should reject unknown fields");
285    }
286
287    #[test]
288    fn test_deny_unknown_fields_pipeline_defaults() {
289        let yaml = r#"
290format: oci
291bogus: "nope"
292"#;
293        let result: Result<PipelineDefaults, _> = serde_yml::from_str(yaml);
294        assert!(result.is_err(), "Should reject unknown fields");
295    }
296
297    #[test]
298    fn test_cache_mounts_and_retries_deserialize_defaults() {
299        let yaml = r#"
300format: oci
301no_cache: true
302cache_mounts:
303  - target: /root/.cargo/registry
304    id: cargo-registry
305    sharing: shared
306  - target: /root/.cache/pip
307retries: 3
308"#;
309        let defaults: PipelineDefaults = serde_yml::from_str(yaml).unwrap();
310        assert_eq!(defaults.cache_mounts.len(), 2);
311        assert_eq!(defaults.cache_mounts[0].target, "/root/.cargo/registry");
312        assert_eq!(
313            defaults.cache_mounts[0].id,
314            Some("cargo-registry".to_string())
315        );
316        assert_eq!(defaults.cache_mounts[0].sharing, Some("shared".to_string()));
317        assert_eq!(defaults.cache_mounts[1].target, "/root/.cache/pip");
318        assert!(defaults.cache_mounts[1].id.is_none());
319        assert_eq!(defaults.retries, Some(3));
320    }
321
322    #[test]
323    fn test_cache_mounts_and_retries_deserialize_image() {
324        let yaml = r#"
325file: Dockerfile
326cache_mounts:
327  - target: /tmp/build-cache
328    readonly: true
329retries: 5
330"#;
331        let img: PipelineImage = serde_yml::from_str(yaml).unwrap();
332        assert_eq!(img.cache_mounts.len(), 1);
333        assert_eq!(img.cache_mounts[0].target, "/tmp/build-cache");
334        assert!(img.cache_mounts[0].readonly);
335        assert_eq!(img.retries, Some(5));
336    }
337
338    #[test]
339    fn test_deny_unknown_fields_push_config() {
340        let yaml = r#"
341after_all: true
342extra: "bad"
343"#;
344        let result: Result<PushConfig, _> = serde_yml::from_str(yaml);
345        assert!(result.is_err(), "Should reject unknown fields");
346    }
347
348    #[test]
349    fn test_serialization_skips_defaults() {
350        let img = PipelineImage {
351            file: PathBuf::from("Dockerfile"),
352            context: PathBuf::from("."),
353            tags: vec![],
354            build_args: HashMap::new(),
355            depends_on: vec![],
356            no_cache: None,
357            format: None,
358            cache_mounts: vec![],
359            retries: None,
360        };
361        let serialized = serde_yml::to_string(&img).unwrap();
362        // Should only contain "file" since everything else is default/empty
363        assert!(serialized.contains("file:"));
364        assert!(!serialized.contains("context:"));
365        assert!(!serialized.contains("tags:"));
366        assert!(!serialized.contains("build_args:"));
367        assert!(!serialized.contains("depends_on:"));
368        assert!(!serialized.contains("no_cache:"));
369        assert!(!serialized.contains("format:"));
370        assert!(!serialized.contains("cache_mounts:"));
371        assert!(!serialized.contains("retries:"));
372    }
373
374    #[test]
375    fn test_serialization_includes_non_defaults() {
376        let img = PipelineImage {
377            file: PathBuf::from("Dockerfile"),
378            context: PathBuf::from("./subdir"),
379            tags: vec!["myimage:latest".to_string()],
380            build_args: HashMap::from([("KEY".to_string(), "value".to_string())]),
381            depends_on: vec!["base".to_string()],
382            no_cache: Some(true),
383            format: Some("docker".to_string()),
384            cache_mounts: vec![crate::zimage::types::ZCacheMount {
385                target: "/root/.cache".to_string(),
386                id: Some("mycache".to_string()),
387                sharing: None,
388                readonly: false,
389            }],
390            retries: Some(3),
391        };
392        let serialized = serde_yml::to_string(&img).unwrap();
393        assert!(serialized.contains("context:"));
394        assert!(serialized.contains("tags:"));
395        assert!(serialized.contains("build_args:"));
396        assert!(serialized.contains("depends_on:"));
397        assert!(serialized.contains("no_cache:"));
398        assert!(serialized.contains("format:"));
399        assert!(serialized.contains("cache_mounts:"));
400        assert!(serialized.contains("retries:"));
401    }
402}