Skip to main content

zlayer_builder/pipeline/
mod.rs

1//! Pipeline build support for building multiple images from a manifest
2//!
3//! This module provides types and execution for ZPipeline.yaml files,
4//! which coordinate building multiple `ZImagefiles` with dependency ordering,
5//! shared caches, and coordinated pushing.
6//!
7//! # Overview
8//!
9//! A `ZPipeline` defines:
10//! - **Global variables** for template substitution (`${VAR}` syntax)
11//! - **Default settings** inherited by all image builds
12//! - **Multiple images** with optional dependency relationships
13//! - **Push configuration** for coordinated registry operations
14//!
15//! # Execution Model
16//!
17//! The [`PipelineExecutor`] builds images in "waves" based on dependency depth:
18//! - **Wave 0**: Images with no dependencies (run in parallel)
19//! - **Wave 1**: Images depending only on Wave 0 images
20//! - **Wave N**: Images depending only on earlier waves
21//!
22//! # Example
23//!
24//! ```yaml
25//! version: "1"
26//!
27//! vars:
28//!   VERSION: "dev"
29//!   REGISTRY: "ghcr.io/myorg"
30//!
31//! defaults:
32//!   format: oci
33//!   build_args:
34//!     RUST_VERSION: "1.90"
35//!
36//! images:
37//!   base:
38//!     file: images/Dockerfile.base
39//!     tags:
40//!       - "${REGISTRY}/base:${VERSION}"
41//!   app:
42//!     file: images/Dockerfile.app
43//!     depends_on: [base]
44//!     tags:
45//!       - "${REGISTRY}/app:${VERSION}"
46//!
47//! push:
48//!   after_all: true
49//! ```
50//!
51//! # Usage
52//!
53//! ```no_run
54//! use zlayer_builder::pipeline::{PipelineExecutor, parse_pipeline};
55//! use zlayer_builder::BuildahExecutor;
56//! use std::path::PathBuf;
57//!
58//! # async fn example() -> Result<(), zlayer_builder::BuildError> {
59//! let yaml = std::fs::read_to_string("ZPipeline.yaml")?;
60//! let pipeline = parse_pipeline(&yaml)?;
61//!
62//! let executor = BuildahExecutor::new_async().await?;
63//! let result = PipelineExecutor::new(pipeline, PathBuf::from("."), executor)
64//!     .fail_fast(true)
65//!     .run()
66//!     .await?;
67//!
68//! println!("Built {} images in {}ms", result.succeeded.len(), result.total_time_ms);
69//! # Ok(())
70//! # }
71//! ```
72
73pub mod executor;
74pub mod types;
75
76pub use executor::{PipelineExecutor, PipelineResult};
77pub use types::{PipelineCacheConfig, PipelineDefaults, PipelineImage, PushConfig, ZPipeline};
78
79use crate::error::{BuildError, Result};
80
81/// Parse a `ZPipeline` YAML file from its contents.
82///
83/// # Arguments
84///
85/// * `content` - The YAML content to parse
86///
87/// # Returns
88///
89/// The parsed `ZPipeline` or a `BuildError` if parsing fails.
90///
91/// # Example
92///
93/// ```
94/// use zlayer_builder::pipeline::parse_pipeline;
95///
96/// let yaml = r#"
97/// images:
98///   app:
99///     file: Dockerfile
100/// "#;
101///
102/// let pipeline = parse_pipeline(yaml).unwrap();
103/// assert!(pipeline.images.contains_key("app"));
104/// ```
105///
106/// # Errors
107///
108/// Returns an error if the YAML content cannot be parsed as a valid pipeline definition.
109pub fn parse_pipeline(content: &str) -> Result<ZPipeline> {
110    serde_yaml::from_str(content)
111        .map_err(|e| BuildError::zimagefile_parse(format!("Pipeline parse error: {e}")))
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    #[test]
119    fn test_parse_minimal_pipeline() {
120        let yaml = r"
121images:
122  app:
123    file: Dockerfile
124";
125        let pipeline = parse_pipeline(yaml).unwrap();
126        assert_eq!(pipeline.images.len(), 1);
127        assert!(pipeline.images.contains_key("app"));
128    }
129
130    #[test]
131    fn test_parse_full_pipeline() {
132        let yaml = r#"
133version: "1"
134vars:
135  VERSION: "1.0.0"
136defaults:
137  format: oci
138images:
139  base:
140    file: images/Dockerfile.base
141    tags:
142      - "myapp/base:${VERSION}"
143  app:
144    file: images/Dockerfile.app
145    context: "."
146    depends_on: [base]
147    tags:
148      - "myapp/app:${VERSION}"
149push:
150  after_all: true
151"#;
152        let pipeline = parse_pipeline(yaml).unwrap();
153        assert_eq!(pipeline.version, Some("1".to_string()));
154        assert_eq!(pipeline.vars.get("VERSION"), Some(&"1.0.0".to_string()));
155        assert_eq!(pipeline.defaults.format, Some("oci".to_string()));
156        assert_eq!(pipeline.images.len(), 2);
157        assert!(pipeline.push.after_all);
158
159        let app = &pipeline.images["app"];
160        assert_eq!(app.depends_on, vec!["base"]);
161    }
162
163    #[test]
164    fn test_rejects_unknown_fields() {
165        let yaml = r#"
166images:
167  app:
168    file: Dockerfile
169    unknown_field: "should fail"
170"#;
171        assert!(parse_pipeline(yaml).is_err());
172    }
173
174    #[test]
175    fn test_image_order_preserved() {
176        let yaml = r"
177images:
178  third:
179    file: Dockerfile.third
180  first:
181    file: Dockerfile.first
182  second:
183    file: Dockerfile.second
184";
185        let pipeline = parse_pipeline(yaml).unwrap();
186        let keys: Vec<&String> = pipeline.images.keys().collect();
187        assert_eq!(keys, vec!["third", "first", "second"]);
188    }
189
190    #[test]
191    fn test_vars_and_defaults() {
192        let yaml = r#"
193vars:
194  REGISTRY: "ghcr.io/myorg"
195  VERSION: "v1.2.3"
196defaults:
197  format: docker
198  build_args:
199    RUST_VERSION: "1.90"
200  no_cache: true
201images:
202  app:
203    file: Dockerfile
204"#;
205        let pipeline = parse_pipeline(yaml).unwrap();
206        assert_eq!(
207            pipeline.vars.get("REGISTRY"),
208            Some(&"ghcr.io/myorg".to_string())
209        );
210        assert_eq!(pipeline.vars.get("VERSION"), Some(&"v1.2.3".to_string()));
211        assert_eq!(pipeline.defaults.format, Some("docker".to_string()));
212        assert_eq!(
213            pipeline.defaults.build_args.get("RUST_VERSION"),
214            Some(&"1.90".to_string())
215        );
216        assert!(pipeline.defaults.no_cache);
217    }
218
219    #[test]
220    fn test_image_with_all_fields() {
221        let yaml = r#"
222images:
223  app:
224    file: images/Dockerfile.app
225    context: "./app"
226    tags:
227      - "myapp:latest"
228      - "myapp:v1.0.0"
229    build_args:
230      NODE_ENV: production
231      DEBUG: "false"
232    depends_on:
233      - base
234      - utils
235    no_cache: true
236    format: oci
237"#;
238        let pipeline = parse_pipeline(yaml).unwrap();
239        let app = &pipeline.images["app"];
240
241        assert_eq!(app.file.to_string_lossy(), "images/Dockerfile.app");
242        assert_eq!(app.context.to_string_lossy(), "./app");
243        assert_eq!(app.tags.len(), 2);
244        assert_eq!(
245            app.build_args.get("NODE_ENV"),
246            Some(&"production".to_string())
247        );
248        assert_eq!(app.depends_on, vec!["base", "utils"]);
249        assert_eq!(app.no_cache, Some(true));
250        assert_eq!(app.format, Some("oci".to_string()));
251    }
252
253    #[test]
254    fn test_empty_vars_and_defaults() {
255        let yaml = r"
256images:
257  app:
258    file: Dockerfile
259";
260        let pipeline = parse_pipeline(yaml).unwrap();
261        assert!(pipeline.vars.is_empty());
262        assert!(pipeline.defaults.format.is_none());
263        assert!(pipeline.defaults.build_args.is_empty());
264        assert!(!pipeline.defaults.no_cache);
265        assert!(!pipeline.push.after_all);
266    }
267
268    #[test]
269    fn test_roundtrip_serialization() {
270        let yaml = r#"
271version: "1"
272vars:
273  VERSION: "1.0.0"
274images:
275  app:
276    file: Dockerfile
277    tags:
278      - "myapp:latest"
279"#;
280        let pipeline = parse_pipeline(yaml).unwrap();
281        let serialized = serde_yaml::to_string(&pipeline).unwrap();
282        let pipeline2 = parse_pipeline(&serialized).unwrap();
283
284        assert_eq!(pipeline.version, pipeline2.version);
285        assert_eq!(pipeline.vars, pipeline2.vars);
286        assert_eq!(pipeline.images.len(), pipeline2.images.len());
287    }
288
289    #[test]
290    fn test_parse_error_message() {
291        let yaml = r"
292images:
293  - this is invalid yaml structure
294";
295        let result = parse_pipeline(yaml);
296        assert!(result.is_err());
297        let err = result.unwrap_err();
298        assert!(err.to_string().contains("Pipeline parse error"));
299    }
300}