1use 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#[derive(Debug, Clone)]
19pub struct DockerImage {
20 pub tag: String,
22 pub used_fallback: bool,
24}
25
26pub struct DockerImageManager {
28 docker_client: Arc<dyn DockerClient>,
29 template_manager: DockerTemplateManager,
30 composer: DockerComposer,
31}
32
33impl DockerImageManager {
34 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 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 let dockerfile_agent = agent;
64
65 let config = DockerImageConfig::new(
67 tech_stack.to_string(),
68 dockerfile_agent.to_string(),
69 project.to_string(),
70 );
71
72 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 missing_layers.len() == 1
86 && missing_layers[0].layer_type == DockerLayerType::Project
87 && project != "default"
88 {
89 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 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 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 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 let image = self.get_image(tech_stack, agent, project, build_root)?;
157
158 if !force_rebuild && self.image_exists(&image.tag).await? {
160 return Ok(image);
162 }
163
164 println!("Building Docker image: {}", image.tag);
166
167 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 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 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 let dockerfile_agent = agent;
199
200 let config = DockerImageConfig::new(
202 tech_stack.to_string(),
203 dockerfile_agent.to_string(),
204 project.to_string(),
205 );
206
207 let composed = self
209 .composer
210 .compose(&config, build_root)
211 .with_context(|| format!("Failed to compose Dockerfile for {}", config.image_tag()))?;
212
213 self.composer
215 .validate_dockerfile(&composed.dockerfile_content)
216 .with_context(|| "Dockerfile validation failed")?;
217
218 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 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 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 pub async fn build_proxy_image(&self, no_cache: bool) -> Result<DockerImage> {
254 println!("Building proxy image: tsk/proxy");
255
256 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 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 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 let tar_archive = self
277 .create_tar_archive_from_directory(&dockerfile_dir)
278 .context("Failed to create tar archive for proxy build")?;
279
280 let _ = std::fs::remove_dir_all(&dockerfile_dir);
282
283 let options = BuildImageOptions {
285 dockerfile: "Dockerfile".to_string(),
286 t: "tsk/proxy".to_string(),
287 nocache: no_cache,
288 ..Default::default()
289 };
290
291 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 use futures_util::StreamExt;
300 while let Some(result) = build_stream.next().await {
301 match result {
302 Ok(line) => {
303 print!("{line}");
304 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 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 builder.append_dir_all(".", dir_path)?;
327
328 builder.finish()?;
329 }
330
331 Ok(tar_data)
332 }
333
334 async fn image_exists(&self, tag: &str) -> Result<bool> {
336 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 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 let tar_archive = self
358 .create_tar_archive(composed, build_root)
359 .context("Failed to create tar archive for Docker build")?;
360
361 let mut build_args = std::collections::HashMap::new();
363
364 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 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 use futures_util::StreamExt;
390 while let Some(result) = build_stream.next().await {
391 match result {
392 Ok(line) => {
393 print!("{line}");
394 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 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 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 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 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
450async 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 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 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 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 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 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}