tsk/docker/
image_manager.rs

1//! Docker image management system
2//!
3//! This module provides centralized management for Docker images in TSK,
4//! handling image selection with intelligent fallback, automated rebuilding,
5//! and simplified APIs for the rest of the system.
6
7use anyhow::{Context, Result};
8use bollard::image::BuildImageOptions;
9use std::sync::Arc;
10use tokio::process::Command as ProcessCommand;
11
12use crate::context::docker_client::DockerClient;
13use crate::docker::composer::{ComposedDockerfile, DockerComposer};
14use crate::docker::layers::{DockerImageConfig, DockerLayerType};
15use crate::docker::template_manager::DockerTemplateManager;
16
17/// Information about a Docker image
18#[derive(Debug, Clone)]
19pub struct DockerImage {
20    /// The full image tag (e.g., "tsk/rust/claude/web-api")
21    pub tag: String,
22    /// Whether this image used fallback (project layer was missing)
23    pub used_fallback: bool,
24}
25
26/// Manages Docker images for TSK
27pub struct DockerImageManager {
28    docker_client: Arc<dyn DockerClient>,
29    template_manager: DockerTemplateManager,
30    composer: DockerComposer,
31}
32
33impl DockerImageManager {
34    /// Creates a new DockerImageManager
35    pub fn new(
36        docker_client: Arc<dyn DockerClient>,
37        template_manager: DockerTemplateManager,
38        composer: DockerComposer,
39    ) -> Self {
40        Self {
41            docker_client,
42            template_manager,
43            composer,
44        }
45    }
46
47    /// Get the appropriate Docker image for the given configuration
48    ///
49    /// This method implements intelligent fallback:
50    /// - If the project-specific layer doesn't exist and project != "default",
51    ///   it will try again with project="default"
52    /// - Returns error if tech_stack or agent layers are missing
53    pub fn get_image(
54        &self,
55        tech_stack: &str,
56        agent: &str,
57        project: Option<&str>,
58        project_root: Option<&std::path::Path>,
59    ) -> Result<DockerImage> {
60        let project = project.unwrap_or("default");
61
62        // Use agent name directly for dockerfile directories
63        let dockerfile_agent = agent;
64
65        // Create config with the requested layers
66        let config = DockerImageConfig::new(
67            tech_stack.to_string(),
68            dockerfile_agent.to_string(),
69            project.to_string(),
70        );
71
72        // Check if all layers exist
73        let mut missing_layers = Vec::new();
74        for layer in config.get_layers() {
75            if self
76                .template_manager
77                .get_layer_content(&layer, project_root)
78                .is_err()
79            {
80                missing_layers.push(layer);
81            }
82        }
83
84        // If only the project layer is missing and it's not "default", try fallback
85        if missing_layers.len() == 1
86            && missing_layers[0].layer_type == DockerLayerType::Project
87            && project != "default"
88        {
89            // Project layer not found, falling back to 'default'
90
91            // Try with default project
92            let _fallback_config = DockerImageConfig::new(
93                tech_stack.to_string(),
94                dockerfile_agent.to_string(),
95                "default".to_string(),
96            );
97
98            return Ok(DockerImage {
99                tag: format!("tsk/{tech_stack}/{agent}/default"),
100                used_fallback: true,
101            });
102        }
103
104        // Check for required layers - if we get here, there are missing layers
105        if let Some(layer) = missing_layers.first() {
106            match layer.layer_type {
107                DockerLayerType::Base => {
108                    return Err(anyhow::anyhow!(
109                        "Base layer is missing. This is a critical error - please reinstall TSK."
110                    ));
111                }
112                DockerLayerType::TechStack => {
113                    return Err(anyhow::anyhow!(
114                        "Technology stack '{tech_stack}' not found. Available tech stacks: {:?}",
115                        self.template_manager
116                            .list_available_layers(DockerLayerType::TechStack, project_root)
117                    ));
118                }
119                DockerLayerType::Agent => {
120                    return Err(anyhow::anyhow!(
121                        "Agent '{agent}' not found. Available agents: {:?}",
122                        self.template_manager
123                            .list_available_layers(DockerLayerType::Agent, project_root)
124                    ));
125                }
126                DockerLayerType::Project => {
127                    // This should have been handled by fallback above
128                    return Err(anyhow::anyhow!(
129                        "Project layer '{project}' not found and default fallback failed"
130                    ));
131                }
132            }
133        }
134
135        Ok(DockerImage {
136            tag: format!("tsk/{tech_stack}/{agent}/{project}"),
137            used_fallback: false,
138        })
139    }
140
141    /// Ensure a Docker image exists, rebuilding if necessary
142    ///
143    /// This method:
144    /// - Checks if the image exists in the Docker daemon
145    /// - If missing or force_rebuild is true, builds the image
146    /// - Returns the DockerImage information
147    pub async fn ensure_image(
148        &self,
149        tech_stack: &str,
150        agent: &str,
151        project: Option<&str>,
152        build_root: Option<&std::path::Path>,
153        force_rebuild: bool,
154    ) -> Result<DockerImage> {
155        // Get the image configuration (with fallback if needed)
156        let image = self.get_image(tech_stack, agent, project, build_root)?;
157
158        // Check if image exists unless force rebuild
159        if !force_rebuild && self.image_exists(&image.tag).await? {
160            // Image already exists
161            return Ok(image);
162        }
163
164        // Build the image
165        println!("Building Docker image: {}", image.tag);
166
167        // Determine actual project to use (considering fallback)
168        let actual_project = if image.used_fallback {
169            "default"
170        } else {
171            project.unwrap_or("default")
172        };
173
174        self.build_image(tech_stack, agent, Some(actual_project), build_root, false)
175            .await?;
176
177        Ok(image)
178    }
179
180    /// Build a Docker image for the given configuration
181    pub async fn build_image(
182        &self,
183        tech_stack: &str,
184        agent: &str,
185        project: Option<&str>,
186        build_root: Option<&std::path::Path>,
187        no_cache: bool,
188    ) -> Result<DockerImage> {
189        let project = project.unwrap_or("default");
190
191        // Log which repository context is being used
192        match build_root {
193            Some(root) => println!("Building Docker image using build root: {}", root.display()),
194            None => println!("Building Docker image without project-specific context"),
195        }
196
197        // Use agent name directly for dockerfile directories
198        let dockerfile_agent = agent;
199
200        // Create configuration
201        let config = DockerImageConfig::new(
202            tech_stack.to_string(),
203            dockerfile_agent.to_string(),
204            project.to_string(),
205        );
206
207        // Compose the Dockerfile
208        let composed = self
209            .composer
210            .compose(&config, build_root)
211            .with_context(|| format!("Failed to compose Dockerfile for {}", config.image_tag()))?;
212
213        // Validate the composed Dockerfile
214        self.composer
215            .validate_dockerfile(&composed.dockerfile_content)
216            .with_context(|| "Dockerfile validation failed")?;
217
218        // Get git configuration for build arguments
219        let git_user_name = get_git_config("user.name")
220            .await
221            .context("Failed to get git user.name")?;
222        let git_user_email = get_git_config("user.email")
223            .await
224            .context("Failed to get git user.email")?;
225
226        // Build the image
227        self.build_docker_image(
228            &composed,
229            &git_user_name,
230            &git_user_email,
231            no_cache,
232            build_root,
233        )
234        .await?;
235
236        // Check if we used fallback
237        let used_fallback = project != "default"
238            && self
239                .template_manager
240                .get_layer_content(
241                    &crate::docker::layers::DockerLayer::project(project),
242                    build_root,
243                )
244                .is_err();
245
246        Ok(DockerImage {
247            tag: format!("tsk/{tech_stack}/{agent}/{project}"),
248            used_fallback,
249        })
250    }
251
252    /// Build the proxy image
253    pub async fn build_proxy_image(&self, no_cache: bool) -> Result<DockerImage> {
254        println!("Building proxy image: tsk/proxy");
255
256        // Build the proxy image using the new approach
257        self.build_proxy_image_internal(no_cache).await?;
258
259        Ok(DockerImage {
260            tag: "tsk/proxy".to_string(),
261            used_fallback: false,
262        })
263    }
264
265    /// Internal method to build the proxy image using DockerClient
266    async fn build_proxy_image_internal(&self, no_cache: bool) -> Result<()> {
267        use crate::assets::embedded::EmbeddedAssetManager;
268        use crate::assets::utils::extract_dockerfile_to_temp;
269
270        // Extract dockerfile to temporary directory
271        let asset_manager = EmbeddedAssetManager;
272        let dockerfile_dir = extract_dockerfile_to_temp(&asset_manager, "tsk-proxy")
273            .context("Failed to extract proxy Dockerfile")?;
274
275        // Create tar archive from the proxy dockerfile directory
276        let tar_archive = self
277            .create_tar_archive_from_directory(&dockerfile_dir)
278            .context("Failed to create tar archive for proxy build")?;
279
280        // Clean up the temporary directory
281        let _ = std::fs::remove_dir_all(&dockerfile_dir);
282
283        // Build options for proxy
284        let options = BuildImageOptions {
285            dockerfile: "Dockerfile".to_string(),
286            t: "tsk/proxy".to_string(),
287            nocache: no_cache,
288            ..Default::default()
289        };
290
291        // Build the image using the DockerClient with streaming output
292        let mut build_stream = self
293            .docker_client
294            .build_image(options, tar_archive)
295            .await
296            .map_err(|e| anyhow::anyhow!("Failed to build proxy image: {e}"))?;
297
298        // Stream build output for real-time visibility
299        use futures_util::StreamExt;
300        while let Some(result) = build_stream.next().await {
301            match result {
302                Ok(line) => {
303                    print!("{line}");
304                    // Ensure output is flushed immediately
305                    use std::io::Write;
306                    std::io::stdout().flush().unwrap_or(());
307                }
308                Err(e) => {
309                    return Err(anyhow::anyhow!("Failed to build proxy image: {e}"));
310                }
311            }
312        }
313
314        Ok(())
315    }
316
317    /// Create a tar archive from a directory
318    fn create_tar_archive_from_directory(&self, dir_path: &std::path::Path) -> Result<Vec<u8>> {
319        use tar::Builder;
320
321        let mut tar_data = Vec::new();
322        {
323            let mut builder = Builder::new(&mut tar_data);
324
325            // Add all files from the directory to the tar archive
326            builder.append_dir_all(".", dir_path)?;
327
328            builder.finish()?;
329        }
330
331        Ok(tar_data)
332    }
333
334    /// Check if a Docker image exists
335    async fn image_exists(&self, tag: &str) -> Result<bool> {
336        // In test environments, always return true to avoid calling actual docker
337        if cfg!(test) {
338            return Ok(true);
339        }
340
341        self.docker_client
342            .image_exists(tag)
343            .await
344            .map_err(|e| anyhow::anyhow!(e))
345    }
346
347    /// Build a Docker image from composed content
348    async fn build_docker_image(
349        &self,
350        composed: &ComposedDockerfile,
351        git_user_name: &str,
352        git_user_email: &str,
353        no_cache: bool,
354        build_root: Option<&std::path::Path>,
355    ) -> Result<()> {
356        // Create tar archive from the composed content
357        let tar_archive = self
358            .create_tar_archive(composed, build_root)
359            .context("Failed to create tar archive for Docker build")?;
360
361        // Prepare build options
362        let mut build_args = std::collections::HashMap::new();
363
364        // Add build arguments if they exist in the Dockerfile
365        if composed.build_args.contains("GIT_USER_NAME") {
366            build_args.insert("GIT_USER_NAME".to_string(), git_user_name.to_string());
367        }
368
369        if composed.build_args.contains("GIT_USER_EMAIL") {
370            build_args.insert("GIT_USER_EMAIL".to_string(), git_user_email.to_string());
371        }
372
373        let options = BuildImageOptions {
374            dockerfile: "Dockerfile".to_string(),
375            t: composed.image_tag.clone(),
376            nocache: no_cache,
377            buildargs: build_args,
378            ..Default::default()
379        };
380
381        // Build the image using the DockerClient with streaming output
382        let mut build_stream = self
383            .docker_client
384            .build_image(options, tar_archive)
385            .await
386            .map_err(|e| anyhow::anyhow!("Docker build failed: {e}"))?;
387
388        // Stream build output for real-time visibility
389        use futures_util::StreamExt;
390        while let Some(result) = build_stream.next().await {
391            match result {
392                Ok(line) => {
393                    print!("{line}");
394                    // Ensure output is flushed immediately
395                    use std::io::Write;
396                    std::io::stdout().flush().unwrap_or(());
397                }
398                Err(e) => {
399                    return Err(anyhow::anyhow!("Docker build failed: {e}"));
400                }
401            }
402        }
403
404        Ok(())
405    }
406
407    /// Create a tar archive from composed Dockerfile content
408    fn create_tar_archive(
409        &self,
410        composed: &ComposedDockerfile,
411        build_root: Option<&std::path::Path>,
412    ) -> Result<Vec<u8>> {
413        use tar::Builder;
414
415        let mut tar_data = Vec::new();
416        {
417            let mut builder = Builder::new(&mut tar_data);
418
419            // Add Dockerfile
420            let dockerfile_bytes = composed.dockerfile_content.as_bytes();
421            let mut header = tar::Header::new_gnu();
422            header.set_path("Dockerfile")?;
423            header.set_size(dockerfile_bytes.len() as u64);
424            header.set_mode(0o644);
425            header.set_cksum();
426            builder.append(&header, dockerfile_bytes)?;
427
428            // Add additional files
429            for (filename, content) in &composed.additional_files {
430                let mut header = tar::Header::new_gnu();
431                header.set_path(filename)?;
432                header.set_size(content.len() as u64);
433                header.set_mode(0o644);
434                header.set_cksum();
435                builder.append(&header, content.as_slice())?;
436            }
437
438            // Add build_root files if provided
439            if let Some(build_root) = build_root {
440                builder.append_dir_all(".", build_root)?;
441            }
442
443            builder.finish()?;
444        }
445
446        Ok(tar_data)
447    }
448}
449
450/// Get git configuration value
451async fn get_git_config(key: &str) -> Result<String> {
452    let output = ProcessCommand::new("git")
453        .args(["config", "--global", key])
454        .output()
455        .await
456        .with_context(|| format!("Failed to execute git config for {key}"))?;
457
458    if !output.status.success() {
459        return Err(anyhow::anyhow!(
460            "Git config '{key}' not set. Please configure git with your name and email:\n\
461             git config --global user.name \"Your Name\"\n\
462             git config --global user.email \"your.email@example.com\""
463        ));
464    }
465
466    let value = String::from_utf8(output.stdout)
467        .context("Git config output is not valid UTF-8")?
468        .trim()
469        .to_string();
470
471    if value.is_empty() {
472        return Err(anyhow::anyhow!(
473            "Git config '{key}' is empty. Please configure git with your name and email."
474        ));
475    }
476
477    Ok(value)
478}
479
480#[cfg(test)]
481mod tests {
482    use super::*;
483    use crate::assets::embedded::EmbeddedAssetManager;
484    use crate::test_utils::TrackedDockerClient;
485    use std::sync::Arc;
486    use tempfile::TempDir;
487
488    fn create_test_manager() -> DockerImageManager {
489        let docker_client = Arc::new(TrackedDockerClient::default());
490        let temp_dir = TempDir::new().unwrap();
491        let xdg_dirs = crate::storage::xdg::XdgDirectories::new_with_paths(
492            temp_dir.path().to_path_buf(),
493            temp_dir.path().to_path_buf(),
494            temp_dir.path().to_path_buf(),
495            temp_dir.path().to_path_buf(),
496        );
497
498        let template_manager =
499            DockerTemplateManager::new(Arc::new(EmbeddedAssetManager), Arc::new(xdg_dirs.clone()));
500
501        let composer = DockerComposer::new(DockerTemplateManager::new(
502            Arc::new(EmbeddedAssetManager),
503            Arc::new(xdg_dirs),
504        ));
505
506        DockerImageManager::new(docker_client, template_manager, composer)
507    }
508
509    #[test]
510    fn test_get_image_success() {
511        let manager = create_test_manager();
512
513        // Test with all default layers (should exist in embedded assets)
514        let result = manager.get_image("default", "claude-code", Some("default"), None);
515        assert!(result.is_ok());
516
517        let image = result.unwrap();
518        assert_eq!(image.tag, "tsk/default/claude-code/default");
519        assert!(!image.used_fallback);
520    }
521
522    #[test]
523    fn test_get_image_fallback() {
524        let manager = create_test_manager();
525
526        // Test with non-existent project layer (should fall back to default)
527        let result =
528            manager.get_image("default", "claude-code", Some("non-existent-project"), None);
529        assert!(result.is_ok());
530
531        let image = result.unwrap();
532        assert_eq!(image.tag, "tsk/default/claude-code/default");
533        assert!(image.used_fallback);
534    }
535
536    #[test]
537    fn test_get_image_missing_tech_stack() {
538        let manager = create_test_manager();
539
540        // Test with non-existent tech stack (should fail)
541        let result = manager.get_image("non-existent-stack", "claude-code", Some("default"), None);
542        assert!(result.is_err());
543
544        let err = result.unwrap_err();
545        assert!(
546            err.to_string()
547                .contains("Technology stack 'non-existent-stack' not found")
548        );
549    }
550
551    #[test]
552    fn test_get_image_missing_agent() {
553        let manager = create_test_manager();
554
555        // Test with non-existent agent (should fail)
556        let result = manager.get_image("default", "non-existent-agent", Some("default"), None);
557        assert!(result.is_err());
558
559        let err = result.unwrap_err();
560        assert!(
561            err.to_string()
562                .contains("Agent 'non-existent-agent' not found")
563        );
564    }
565
566    #[test]
567    fn test_get_image_with_none_project() {
568        let manager = create_test_manager();
569
570        // Test with None project (should use "default")
571        let result = manager.get_image("default", "claude-code", None, None);
572        assert!(result.is_ok());
573
574        let image = result.unwrap();
575        assert_eq!(image.tag, "tsk/default/claude-code/default");
576        assert!(!image.used_fallback);
577    }
578}