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