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.
209fn is_false(v: &bool) -> bool {
210    !v
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    #[test]
218    fn test_pipeline_image_defaults() {
219        let yaml = r#"
220file: Dockerfile
221"#;
222        let img: PipelineImage = serde_yaml::from_str(yaml).unwrap();
223        assert_eq!(img.file, PathBuf::from("Dockerfile"));
224        assert_eq!(img.context, PathBuf::from("."));
225        assert!(img.tags.is_empty());
226        assert!(img.build_args.is_empty());
227        assert!(img.depends_on.is_empty());
228        assert!(img.no_cache.is_none());
229        assert!(img.format.is_none());
230        assert!(img.cache_mounts.is_empty());
231        assert!(img.retries.is_none());
232    }
233
234    #[test]
235    fn test_pipeline_defaults_empty() {
236        let yaml = "{}";
237        let defaults: PipelineDefaults = serde_yaml::from_str(yaml).unwrap();
238        assert!(defaults.format.is_none());
239        assert!(defaults.build_args.is_empty());
240        assert!(!defaults.no_cache);
241        assert!(defaults.cache_mounts.is_empty());
242        assert!(defaults.retries.is_none());
243    }
244
245    #[test]
246    fn test_pipeline_defaults_full() {
247        let yaml = r#"
248format: oci
249build_args:
250  RUST_VERSION: "1.90"
251no_cache: true
252"#;
253        let defaults: PipelineDefaults = serde_yaml::from_str(yaml).unwrap();
254        assert_eq!(defaults.format, Some("oci".to_string()));
255        assert_eq!(
256            defaults.build_args.get("RUST_VERSION"),
257            Some(&"1.90".to_string())
258        );
259        assert!(defaults.no_cache);
260    }
261
262    #[test]
263    fn test_push_config_defaults() {
264        let yaml = "{}";
265        let push: PushConfig = serde_yaml::from_str(yaml).unwrap();
266        assert!(!push.after_all);
267    }
268
269    #[test]
270    fn test_push_config_after_all() {
271        let yaml = "after_all: true";
272        let push: PushConfig = serde_yaml::from_str(yaml).unwrap();
273        assert!(push.after_all);
274    }
275
276    #[test]
277    fn test_deny_unknown_fields_pipeline_image() {
278        let yaml = r#"
279file: Dockerfile
280unknown_field: "should fail"
281"#;
282        let result: Result<PipelineImage, _> = serde_yaml::from_str(yaml);
283        assert!(result.is_err(), "Should reject unknown fields");
284    }
285
286    #[test]
287    fn test_deny_unknown_fields_pipeline_defaults() {
288        let yaml = r#"
289format: oci
290bogus: "nope"
291"#;
292        let result: Result<PipelineDefaults, _> = serde_yaml::from_str(yaml);
293        assert!(result.is_err(), "Should reject unknown fields");
294    }
295
296    #[test]
297    fn test_cache_mounts_and_retries_deserialize_defaults() {
298        let yaml = r#"
299format: oci
300no_cache: true
301cache_mounts:
302  - target: /root/.cargo/registry
303    id: cargo-registry
304    sharing: shared
305  - target: /root/.cache/pip
306retries: 3
307"#;
308        let defaults: PipelineDefaults = serde_yaml::from_str(yaml).unwrap();
309        assert_eq!(defaults.cache_mounts.len(), 2);
310        assert_eq!(defaults.cache_mounts[0].target, "/root/.cargo/registry");
311        assert_eq!(
312            defaults.cache_mounts[0].id,
313            Some("cargo-registry".to_string())
314        );
315        assert_eq!(defaults.cache_mounts[0].sharing, Some("shared".to_string()));
316        assert_eq!(defaults.cache_mounts[1].target, "/root/.cache/pip");
317        assert!(defaults.cache_mounts[1].id.is_none());
318        assert_eq!(defaults.retries, Some(3));
319    }
320
321    #[test]
322    fn test_cache_mounts_and_retries_deserialize_image() {
323        let yaml = r#"
324file: Dockerfile
325cache_mounts:
326  - target: /tmp/build-cache
327    readonly: true
328retries: 5
329"#;
330        let img: PipelineImage = serde_yaml::from_str(yaml).unwrap();
331        assert_eq!(img.cache_mounts.len(), 1);
332        assert_eq!(img.cache_mounts[0].target, "/tmp/build-cache");
333        assert!(img.cache_mounts[0].readonly);
334        assert_eq!(img.retries, Some(5));
335    }
336
337    #[test]
338    fn test_deny_unknown_fields_push_config() {
339        let yaml = r#"
340after_all: true
341extra: "bad"
342"#;
343        let result: Result<PushConfig, _> = serde_yaml::from_str(yaml);
344        assert!(result.is_err(), "Should reject unknown fields");
345    }
346
347    #[test]
348    fn test_serialization_skips_defaults() {
349        let img = PipelineImage {
350            file: PathBuf::from("Dockerfile"),
351            context: PathBuf::from("."),
352            tags: vec![],
353            build_args: HashMap::new(),
354            depends_on: vec![],
355            no_cache: None,
356            format: None,
357            cache_mounts: vec![],
358            retries: None,
359        };
360        let serialized = serde_yaml::to_string(&img).unwrap();
361        // Should only contain "file" since everything else is default/empty
362        assert!(serialized.contains("file:"));
363        assert!(!serialized.contains("context:"));
364        assert!(!serialized.contains("tags:"));
365        assert!(!serialized.contains("build_args:"));
366        assert!(!serialized.contains("depends_on:"));
367        assert!(!serialized.contains("no_cache:"));
368        assert!(!serialized.contains("format:"));
369        assert!(!serialized.contains("cache_mounts:"));
370        assert!(!serialized.contains("retries:"));
371    }
372
373    #[test]
374    fn test_serialization_includes_non_defaults() {
375        let img = PipelineImage {
376            file: PathBuf::from("Dockerfile"),
377            context: PathBuf::from("./subdir"),
378            tags: vec!["myimage:latest".to_string()],
379            build_args: HashMap::from([("KEY".to_string(), "value".to_string())]),
380            depends_on: vec!["base".to_string()],
381            no_cache: Some(true),
382            format: Some("docker".to_string()),
383            cache_mounts: vec![crate::zimage::types::ZCacheMount {
384                target: "/root/.cache".to_string(),
385                id: Some("mycache".to_string()),
386                sharing: None,
387                readonly: false,
388            }],
389            retries: Some(3),
390        };
391        let serialized = serde_yaml::to_string(&img).unwrap();
392        assert!(serialized.contains("context:"));
393        assert!(serialized.contains("tags:"));
394        assert!(serialized.contains("build_args:"));
395        assert!(serialized.contains("depends_on:"));
396        assert!(serialized.contains("no_cache:"));
397        assert!(serialized.contains("format:"));
398        assert!(serialized.contains("cache_mounts:"));
399        assert!(serialized.contains("retries:"));
400    }
401}