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
18use crate::backend::ImageOs;
19
20// ---------------------------------------------------------------------------
21// Top-level ZPipeline
22// ---------------------------------------------------------------------------
23
24/// Top-level ZPipeline.yaml representation.
25///
26/// A pipeline coordinates building multiple container images with dependency
27/// ordering, shared configuration, and coordinated push operations.
28///
29/// # YAML Example
30///
31/// ```yaml
32/// version: "1"
33///
34/// vars:
35///   VERSION: "1.0.0"
36///   REGISTRY: "ghcr.io/myorg"
37///
38/// defaults:
39///   format: oci
40///   build_args:
41///     RUST_VERSION: "1.90"
42///
43/// images:
44///   base:
45///     file: images/Dockerfile.base
46///     tags:
47///       - "${REGISTRY}/base:${VERSION}"
48///   app:
49///     file: images/Dockerfile.app
50///     depends_on: [base]
51///     tags:
52///       - "${REGISTRY}/app:${VERSION}"
53///
54/// push:
55///   after_all: true
56/// ```
57#[derive(Debug, Clone, Serialize, Deserialize)]
58#[serde(deny_unknown_fields)]
59pub struct ZPipeline {
60    /// Pipeline format version (currently "1")
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub version: Option<String>,
63
64    /// Global variables for template substitution.
65    /// Referenced in tags and other string fields as `${VAR_NAME}`.
66    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
67    pub vars: HashMap<String, String>,
68
69    /// Default settings inherited by all images.
70    /// Individual image settings override these defaults.
71    #[serde(default)]
72    pub defaults: PipelineDefaults,
73
74    /// Named images to build. Order is preserved (`IndexMap`).
75    /// Keys are image names used for `depends_on` references.
76    pub images: IndexMap<String, PipelineImage>,
77
78    /// Cache configuration for storing built image layers.
79    /// Enables pipeline-built images to be used as bases by downstream images.
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub cache: Option<PipelineCacheConfig>,
82
83    /// Push configuration.
84    #[serde(default)]
85    pub push: PushConfig,
86}
87
88// ---------------------------------------------------------------------------
89// Pipeline defaults
90// ---------------------------------------------------------------------------
91
92/// Default settings applied to all images in the pipeline.
93///
94/// Individual image settings take precedence over these defaults.
95#[derive(Debug, Clone, Default, Serialize, Deserialize)]
96#[serde(deny_unknown_fields)]
97pub struct PipelineDefaults {
98    /// Default output format: "oci" or "docker"
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub format: Option<String>,
101
102    /// Default build arguments passed to all image builds
103    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
104    pub build_args: HashMap<String, String>,
105
106    /// Whether to skip cache by default
107    #[serde(default, skip_serializing_if = "is_false")]
108    pub no_cache: bool,
109
110    /// Default cache mounts applied to all RUN steps in all images.
111    /// Individual `ZImagefile` step-level cache mounts are additive.
112    #[serde(default, skip_serializing_if = "Vec::is_empty")]
113    pub cache_mounts: Vec<crate::zimage::types::ZCacheMount>,
114
115    /// Default retry count for failed RUN steps (0 = no retries)
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub retries: Option<u32>,
118
119    /// Default target platforms for multi-arch builds (e.g., `linux/amd64`, `linux/arm64`).
120    /// When set, each image is built once per platform and a manifest list is created.
121    #[serde(default, skip_serializing_if = "Vec::is_empty")]
122    pub platforms: Vec<String>,
123}
124
125// ---------------------------------------------------------------------------
126// Pipeline image
127// ---------------------------------------------------------------------------
128
129/// Configuration for a single image in the pipeline.
130///
131/// # YAML Example
132///
133/// ```yaml
134/// zlayer-app:
135///   file: images/ZImagefile.app
136///   context: "."
137///   tags:
138///     - "${REGISTRY}/app:${VERSION}"
139///     - "${REGISTRY}/app:latest"
140///   depends_on: [zlayer-base]
141///   build_args:
142///     EXTRA_ARG: "value"
143///   no_cache: false
144///   format: oci
145/// ```
146#[derive(Debug, Clone, Serialize, Deserialize)]
147#[serde(deny_unknown_fields)]
148pub struct PipelineImage {
149    /// Path to the build file (Dockerfile, `ZImagefile`, etc.)
150    pub file: PathBuf,
151
152    /// Build context directory. Defaults to "."
153    #[serde(
154        default = "default_context",
155        skip_serializing_if = "is_default_context"
156    )]
157    pub context: PathBuf,
158
159    /// Image tags to apply. Supports variable substitution.
160    #[serde(default, skip_serializing_if = "Vec::is_empty")]
161    pub tags: Vec<String>,
162
163    /// Build arguments specific to this image.
164    /// Merged with (and overrides) `defaults.build_args`.
165    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
166    pub build_args: HashMap<String, String>,
167
168    /// Names of images that must be built before this one.
169    /// Creates a dependency graph for build ordering.
170    #[serde(default, skip_serializing_if = "Vec::is_empty")]
171    pub depends_on: Vec<String>,
172
173    /// Override `no_cache` setting for this image.
174    /// If None, inherits from defaults.
175    #[serde(default, skip_serializing_if = "Option::is_none")]
176    pub no_cache: Option<bool>,
177
178    /// Override output format for this image.
179    /// If None, inherits from defaults.
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub format: Option<String>,
182
183    /// Cache mounts for this image's RUN steps, merged with defaults
184    #[serde(default, skip_serializing_if = "Vec::is_empty")]
185    pub cache_mounts: Vec<crate::zimage::types::ZCacheMount>,
186
187    /// Override retry count for this image
188    #[serde(default, skip_serializing_if = "Option::is_none")]
189    pub retries: Option<u32>,
190
191    /// Target platforms for this image (overrides defaults.platforms).
192    /// If empty, inherits from defaults.platforms.
193    #[serde(default, skip_serializing_if = "Vec::is_empty")]
194    pub platforms: Vec<String>,
195
196    /// Target OS for this image. Overrides any OS inferred from `platforms`. Default: inferred or Linux.
197    #[serde(default, skip_serializing_if = "Option::is_none")]
198    pub os: Option<ImageOs>,
199}
200
201// ---------------------------------------------------------------------------
202// Push configuration
203// ---------------------------------------------------------------------------
204
205/// Configuration for pushing built images to registries.
206#[derive(Debug, Clone, Default, Serialize, Deserialize)]
207#[serde(deny_unknown_fields)]
208pub struct PushConfig {
209    /// If true, push all images only after all builds succeed.
210    /// If false (default), images are not pushed automatically.
211    #[serde(default, skip_serializing_if = "is_false")]
212    pub after_all: bool,
213}
214
215// ---------------------------------------------------------------------------
216// Cache configuration
217// ---------------------------------------------------------------------------
218
219/// Cache backend configuration for the build pipeline.
220///
221/// Controls how built image layers are stored between pipeline stages.
222/// When set, pipeline-built images are registered in a local OCI registry
223/// so downstream images can reference them as base images.
224///
225/// # YAML Example
226///
227/// ```yaml
228/// cache:
229///   type: persistent
230///   path: ~/.zlayer/cache
231/// ```
232#[derive(Debug, Clone, Default, Serialize, Deserialize)]
233pub struct PipelineCacheConfig {
234    /// Cache type: "memory", "persistent", or "s3"
235    #[serde(default, rename = "type", skip_serializing_if = "Option::is_none")]
236    pub cache_type: Option<String>,
237
238    /// Path for persistent cache (only used when type is "persistent")
239    #[serde(default, skip_serializing_if = "Option::is_none")]
240    pub path: Option<PathBuf>,
241
242    /// S3 bucket name (only used when type is "s3")
243    #[serde(default, skip_serializing_if = "Option::is_none")]
244    pub bucket: Option<String>,
245
246    /// S3 region
247    #[serde(default, skip_serializing_if = "Option::is_none")]
248    pub region: Option<String>,
249
250    /// S3 endpoint URL
251    #[serde(default, skip_serializing_if = "Option::is_none")]
252    pub endpoint: Option<String>,
253
254    /// S3 key prefix for cache objects
255    #[serde(default, skip_serializing_if = "Option::is_none")]
256    pub prefix: Option<String>,
257}
258
259// ---------------------------------------------------------------------------
260// Helpers
261// ---------------------------------------------------------------------------
262
263/// Default build context directory.
264fn default_context() -> PathBuf {
265    PathBuf::from(".")
266}
267
268/// Check if path is the default context.
269fn is_default_context(path: &Path) -> bool {
270    path.as_os_str() == "."
271}
272
273/// Helper for `skip_serializing_if` on boolean fields.
274#[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        // Should only contain "file" since everything else is default/empty
470        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}