tsk/docker/
composer.rs

1//! Docker layer composition engine
2//!
3//! This module handles the composition of multiple Docker layers into a single
4//! Dockerfile, managing build arguments, environment variables, and layer ordering.
5
6use anyhow::{Context, Result};
7use std::collections::{HashMap, HashSet};
8use std::path::Path;
9
10use crate::docker::layers::DockerImageConfig;
11use crate::docker::template_manager::DockerTemplateManager;
12
13/// Composes multiple Docker layers into a complete Dockerfile
14pub struct DockerComposer {
15    template_manager: DockerTemplateManager,
16}
17
18impl DockerComposer {
19    /// Creates a new DockerComposer
20    pub fn new(template_manager: DockerTemplateManager) -> Self {
21        Self { template_manager }
22    }
23
24    /// Compose a complete Dockerfile and associated files from the given configuration
25    pub fn compose(
26        &self,
27        config: &DockerImageConfig,
28        project_root: Option<&Path>,
29    ) -> Result<ComposedDockerfile> {
30        // Get the composed Dockerfile content
31        let dockerfile_content = self
32            .template_manager
33            .compose_dockerfile(config, project_root)?;
34
35        // Collect all additional files from layers
36        let mut additional_files = HashMap::new();
37        let layers = config.get_layers();
38
39        for layer in &layers {
40            if let Ok(layer_content) = self.template_manager.get_layer_content(layer, project_root)
41            {
42                for (filename, content) in layer_content.additional_files {
43                    // Later layers override earlier ones for the same filename
44                    additional_files.insert(filename, content);
45                }
46            }
47        }
48
49        // Extract build arguments from the composed Dockerfile
50        let build_args = self.extract_build_args(&dockerfile_content)?;
51
52        Ok(ComposedDockerfile {
53            dockerfile_content,
54            additional_files,
55            build_args,
56            image_tag: config.image_tag(),
57        })
58    }
59
60    /// Extract build arguments from Dockerfile content
61    fn extract_build_args(&self, dockerfile_content: &str) -> Result<HashSet<String>> {
62        let mut build_args = HashSet::new();
63
64        for line in dockerfile_content.lines() {
65            let trimmed = line.trim();
66            if trimmed.starts_with("ARG ") {
67                if let Some(arg_def) = trimmed.strip_prefix("ARG ") {
68                    // Handle ARG NAME or ARG NAME=default_value
69                    let arg_name = arg_def
70                        .split_once('=')
71                        .map(|(name, _)| name)
72                        .unwrap_or(arg_def)
73                        .trim();
74
75                    if !arg_name.is_empty() {
76                        build_args.insert(arg_name.to_string());
77                    }
78                }
79            }
80        }
81
82        Ok(build_args)
83    }
84
85    /// Validate that a composed Dockerfile is valid
86    pub fn validate_dockerfile(&self, content: &str) -> Result<()> {
87        let mut has_from = false;
88        let mut has_workdir = false;
89        let mut has_user = false;
90
91        for line in content.lines() {
92            let trimmed = line.trim();
93
94            if trimmed.starts_with("FROM ") {
95                has_from = true;
96            } else if trimmed.starts_with("WORKDIR ") {
97                has_workdir = true;
98            } else if trimmed.starts_with("USER ") {
99                has_user = true;
100            }
101        }
102
103        if !has_from {
104            return Err(anyhow::anyhow!(
105                "Dockerfile must contain at least one FROM instruction"
106            ));
107        }
108
109        if !has_workdir {
110            return Err(anyhow::anyhow!(
111                "Dockerfile should contain a WORKDIR instruction"
112            ));
113        }
114
115        if !has_user {
116            return Err(anyhow::anyhow!(
117                "Dockerfile should contain a USER instruction for security"
118            ));
119        }
120
121        Ok(())
122    }
123
124    /// Write composed Dockerfile and associated files to a directory
125    pub fn write_to_directory(
126        &self,
127        composed: &ComposedDockerfile,
128        output_dir: &Path,
129    ) -> Result<()> {
130        // Ensure directory exists
131        std::fs::create_dir_all(output_dir)
132            .with_context(|| format!("Failed to create output directory: {output_dir:?}"))?;
133
134        // Write Dockerfile
135        let dockerfile_path = output_dir.join("Dockerfile");
136        std::fs::write(&dockerfile_path, &composed.dockerfile_content)
137            .with_context(|| format!("Failed to write Dockerfile to {dockerfile_path:?}"))?;
138
139        // Write additional files
140        for (filename, content) in &composed.additional_files {
141            let file_path = output_dir.join(filename);
142
143            // Create parent directories if needed
144            if let Some(parent) = file_path.parent() {
145                std::fs::create_dir_all(parent)
146                    .with_context(|| format!("Failed to create directory for {file_path:?}"))?;
147            }
148
149            std::fs::write(&file_path, content)
150                .with_context(|| format!("Failed to write file {file_path:?}"))?;
151        }
152
153        Ok(())
154    }
155}
156
157/// Result of composing Docker layers
158#[derive(Debug)]
159pub struct ComposedDockerfile {
160    /// The complete Dockerfile content
161    pub dockerfile_content: String,
162    /// Additional files to be placed alongside the Dockerfile
163    pub additional_files: HashMap<String, Vec<u8>>,
164    /// Build arguments extracted from the Dockerfile
165    pub build_args: HashSet<String>,
166    /// The Docker image tag for this composition
167    pub image_tag: String,
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use crate::assets::embedded::EmbeddedAssetManager;
174    use crate::storage::xdg::XdgDirectories;
175    use std::sync::Arc;
176    use tempfile::TempDir;
177
178    fn create_test_composer() -> DockerComposer {
179        let temp_dir = TempDir::new().unwrap();
180        let xdg_dirs = XdgDirectories::new_with_paths(
181            temp_dir.path().to_path_buf(),
182            temp_dir.path().to_path_buf(),
183            temp_dir.path().to_path_buf(),
184            temp_dir.path().to_path_buf(),
185        );
186
187        let template_manager =
188            DockerTemplateManager::new(Arc::new(EmbeddedAssetManager), Arc::new(xdg_dirs));
189
190        DockerComposer::new(template_manager)
191    }
192
193    #[test]
194    fn test_extract_build_args() {
195        let composer = create_test_composer();
196
197        let dockerfile = r#"
198FROM ubuntu:22.04
199ARG GIT_USER_NAME
200ARG GIT_USER_EMAIL=default@example.com
201ARG BUILD_VERSION
202RUN echo "Building..."
203"#;
204
205        let args = composer.extract_build_args(dockerfile).unwrap();
206        assert!(args.contains("GIT_USER_NAME"));
207        assert!(args.contains("GIT_USER_EMAIL"));
208        assert!(args.contains("BUILD_VERSION"));
209        assert_eq!(args.len(), 3);
210    }
211
212    #[test]
213    fn test_validate_dockerfile() {
214        let composer = create_test_composer();
215
216        // Valid Dockerfile
217        let valid = r#"
218FROM ubuntu:22.04
219WORKDIR /workspace
220USER agent
221RUN echo "Hello"
222"#;
223        assert!(composer.validate_dockerfile(valid).is_ok());
224
225        // Missing FROM
226        let no_from = r#"
227WORKDIR /workspace
228USER agent
229RUN echo "Hello"
230"#;
231        assert!(composer.validate_dockerfile(no_from).is_err());
232
233        // Missing WORKDIR
234        let no_workdir = r#"
235FROM ubuntu:22.04
236USER agent
237RUN echo "Hello"
238"#;
239        assert!(composer.validate_dockerfile(no_workdir).is_err());
240
241        // Missing USER
242        let no_user = r#"
243FROM ubuntu:22.04
244WORKDIR /workspace
245RUN echo "Hello"
246"#;
247        assert!(composer.validate_dockerfile(no_user).is_err());
248    }
249
250    #[test]
251    fn test_write_to_directory() {
252        let composer = create_test_composer();
253        let temp_dir = TempDir::new().unwrap();
254
255        let composed = ComposedDockerfile {
256            dockerfile_content: "FROM ubuntu:22.04\nRUN echo 'test'".to_string(),
257            additional_files: {
258                let mut files = HashMap::new();
259                files.insert("requirements.txt".to_string(), b"pytest==7.0.0\n".to_vec());
260                files.insert("config/app.json".to_string(), b"{\"test\": true}".to_vec());
261                files
262            },
263            build_args: HashSet::new(),
264            image_tag: "tsk/test/test/test".to_string(),
265        };
266
267        composer
268            .write_to_directory(&composed, temp_dir.path())
269            .unwrap();
270
271        // Check Dockerfile was written
272        let dockerfile_path = temp_dir.path().join("Dockerfile");
273        assert!(dockerfile_path.exists());
274        let content = std::fs::read_to_string(&dockerfile_path).unwrap();
275        assert!(content.contains("FROM ubuntu:22.04"));
276
277        // Check additional files were written
278        let requirements_path = temp_dir.path().join("requirements.txt");
279        assert!(requirements_path.exists());
280        let requirements = std::fs::read_to_string(&requirements_path).unwrap();
281        assert_eq!(requirements, "pytest==7.0.0\n");
282
283        let config_path = temp_dir.path().join("config/app.json");
284        assert!(config_path.exists());
285        let config = std::fs::read_to_string(&config_path).unwrap();
286        assert_eq!(config, "{\"test\": true}");
287    }
288}