# Docker Helper Tool """ Clone repositories and manage Docker builds. Includes error analysis and fix suggestions. """ import httpx import subprocess import os import shutil import logging from typing import Dict, Any, Optional, List from pathlib import Path from config import settings logger = logging.getLogger(__name__) class DockerHelper: """ Docker automation helper. Clones repos, builds containers, analyzes errors, suggests fixes. """ def __init__(self): self.projects_dir = Path(settings.PROJECTS_DIR) self.ollama_host = settings.OLLAMA_HOST self.ollama_model = settings.OLLAMA_MODEL self.client = httpx.AsyncClient(timeout=120.0) # Ensure projects directory exists self.projects_dir.mkdir(parents=True, exist_ok=True) async def clone_and_build( self, repo_url: str, branch: str = "main" ) -> Dict[str, Any]: """ Clone repository and attempt Docker build. Args: repo_url: GitHub repository URL branch: Branch to clone (default: main) Returns: Dict with success status, logs, and fix suggestions """ project_name = self._extract_project_name(repo_url) project_path = self.projects_dir / project_name try: # Step 1: Clone repository clone_result = await self._clone_repo(repo_url, project_path, branch) if not clone_result["success"]: return clone_result # Step 2: Detect project structure structure = await self._detect_structure(project_path) # Step 3: Attempt Docker build build_result = await self._docker_build(project_path, structure) if build_result["success"]: return { "success": True, "message": f"Project {project_name} built successfully", "container_id": build_result.get("container_id"), "logs": build_result.get("logs", "") } else: # Step 4: Analyze error and suggest fix fix = await self._analyze_and_suggest_fix( build_result.get("logs", ""), project_path ) return { "success": False, "message": f"Build failed for {project_name}", "logs": build_result.get("logs", ""), "fix_suggestion": fix } except Exception as e: logger.error(f"Clone and build error: {e}") return { "success": False, "message": f"Error: {str(e)}", "logs": str(e) } def _extract_project_name(self, repo_url: str) -> str: """Extract project name from repository URL.""" # Handle various URL formats url = repo_url.rstrip("/") if url.endswith(".git"): url = url[:-4] return url.split("/")[-1] async def _clone_repo( self, repo_url: str, project_path: Path, branch: str ) -> Dict[str, Any]: """Clone repository to local directory.""" try: # Remove existing directory if present if project_path.exists(): shutil.rmtree(project_path) # Clone repository result = subprocess.run( ["git", "clone", "--depth", "1", "-b", branch, repo_url, str(project_path)], capture_output=True, text=True, timeout=120 ) if result.returncode == 0: logger.info(f"Cloned {repo_url} to {project_path}") return {"success": True, "message": "Repository cloned"} else: # Try without branch specification (use default) result = subprocess.run( ["git", "clone", "--depth", "1", repo_url, str(project_path)], capture_output=True, text=True, timeout=120 ) if result.returncode == 0: return {"success": True, "message": "Repository cloned (default branch)"} else: return { "success": False, "message": f"Clone failed: {result.stderr}", "logs": result.stderr } except subprocess.TimeoutExpired: return {"success": False, "message": "Clone timed out"} except Exception as e: return {"success": False, "message": f"Clone error: {str(e)}"} async def _detect_structure(self, project_path: Path) -> Dict[str, Any]: """Detect project structure and configuration files.""" structure = { "has_dockerfile": False, "has_compose": False, "has_requirements": False, "has_package_json": False, "has_makefile": False, "dockerfile_path": None, "compose_path": None, "language": "unknown" } files_to_check = { "Dockerfile": ("has_dockerfile", "dockerfile_path"), "docker-compose.yml": ("has_compose", "compose_path"), "docker-compose.yaml": ("has_compose", "compose_path"), "compose.yml": ("has_compose", "compose_path"), "compose.yaml": ("has_compose", "compose_path"), "requirements.txt": ("has_requirements", None), "package.json": ("has_package_json", None), "Makefile": ("has_makefile", None) } for filename, (flag, path_key) in files_to_check.items(): file_path = project_path / filename if file_path.exists(): structure[flag] = True if path_key: structure[path_key] = str(file_path) # Detect language if structure["has_requirements"]: structure["language"] = "python" elif structure["has_package_json"]: structure["language"] = "javascript" return structure async def _docker_build( self, project_path: Path, structure: Dict[str, Any] ) -> Dict[str, Any]: """Attempt to build Docker container.""" try: project_name = project_path.name.lower().replace("_", "-").replace(".", "-") # Prefer docker-compose if available if structure["has_compose"]: compose_path = structure["compose_path"] result = subprocess.run( ["docker", "compose", "-f", compose_path, "build"], capture_output=True, text=True, cwd=str(project_path), timeout=600 # 10 minute timeout ) if result.returncode == 0: # Start containers start_result = subprocess.run( ["docker", "compose", "-f", compose_path, "up", "-d"], capture_output=True, text=True, cwd=str(project_path), timeout=300 ) return { "success": start_result.returncode == 0, "logs": result.stdout + start_result.stdout, "method": "docker-compose" } else: return { "success": False, "logs": result.stderr, "method": "docker-compose" } # Fall back to Dockerfile elif structure["has_dockerfile"]: result = subprocess.run( ["docker", "build", "-t", project_name, "."], capture_output=True, text=True, cwd=str(project_path), timeout=600 ) if result.returncode == 0: # Run container run_result = subprocess.run( ["docker", "run", "-d", "--name", f"{project_name}-container", project_name], capture_output=True, text=True, timeout=60 ) return { "success": run_result.returncode == 0, "container_id": run_result.stdout.strip()[:12] if run_result.returncode == 0 else None, "logs": result.stdout + run_result.stdout, "method": "dockerfile" } else: return { "success": False, "logs": result.stderr, "method": "dockerfile" } # No Docker configuration found - generate Dockerfile else: generated = await self._generate_dockerfile(project_path, structure) if generated: # Retry build with generated Dockerfile structure["has_dockerfile"] = True structure["dockerfile_path"] = str(project_path / "Dockerfile") return await self._docker_build(project_path, structure) else: return { "success": False, "logs": "No Dockerfile found and auto-generation failed", "method": "none" } except subprocess.TimeoutExpired: return {"success": False, "logs": "Build timed out (>10 minutes)"} except Exception as e: return {"success": False, "logs": f"Build error: {str(e)}"} async def _generate_dockerfile( self, project_path: Path, structure: Dict[str, Any] ) -> bool: """Generate a Dockerfile based on project structure.""" try: dockerfile_content = "" if structure["language"] == "python": dockerfile_content = """# Auto-generated Dockerfile FROM python:3.11-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . CMD ["python", "main.py"] """ elif structure["language"] == "javascript": dockerfile_content = """# Auto-generated Dockerfile FROM node:20-alpine WORKDIR /app COPY package*.json ./ RUN npm install COPY . . EXPOSE 3000 CMD ["npm", "start"] """ else: # Generic fallback dockerfile_content = """# Auto-generated Dockerfile FROM ubuntu:22.04 WORKDIR /app COPY . . CMD ["bash"] """ dockerfile_path = project_path / "Dockerfile" dockerfile_path.write_text(dockerfile_content) logger.info(f"Generated Dockerfile for {project_path.name}") return True except Exception as e: logger.error(f"Dockerfile generation error: {e}") return False async def _analyze_and_suggest_fix( self, error_logs: str, project_path: Path ) -> str: """ Analyze build error and suggest fix using LLM. NOTE: This only suggests ONE fix, no infinite loops. """ try: prompt = f"""Analyze this Docker build error and suggest ONE specific fix. Error logs: ``` {error_logs[:2000]} ``` Project: {project_path.name} Provide a concise fix suggestion. If multiple issues, focus on the first/most critical one. Format: 1. Problem: [what went wrong] 2. Fix: [specific action to take] 3. Command: [if applicable, the command to run]""" response = await self.client.post( f"{self.ollama_host}/api/generate", json={ "model": self.ollama_model, "prompt": prompt, "stream": False } ) if response.status_code == 200: result = response.json() return result.get("response", "Unable to analyze error") else: return "Error analysis unavailable (LLM request failed)" except Exception as e: logger.error(f"Error analysis failed: {e}") return f"Error analysis failed: {str(e)}" async def get_container_logs(self, container_id: str, lines: int = 100) -> str: """Get logs from a running container.""" try: result = subprocess.run( ["docker", "logs", "--tail", str(lines), container_id], capture_output=True, text=True, timeout=30 ) return result.stdout + result.stderr except Exception as e: return f"Failed to get logs: {str(e)}" async def list_containers(self, all_containers: bool = False) -> List[Dict[str, str]]: """List Docker containers.""" try: cmd = ["docker", "ps", "--format", "{{.ID}}\t{{.Names}}\t{{.Status}}\t{{.Image}}"] if all_containers: cmd.append("-a") result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) containers = [] for line in result.stdout.strip().split("\n"): if line: parts = line.split("\t") if len(parts) >= 4: containers.append({ "id": parts[0], "name": parts[1], "status": parts[2], "image": parts[3] }) return containers except Exception as e: logger.error(f"List containers error: {e}") return [] async def stop_container(self, container_id: str) -> bool: """Stop a running container.""" try: result = subprocess.run( ["docker", "stop", container_id], capture_output=True, text=True, timeout=60 ) return result.returncode == 0 except Exception: return False async def remove_container(self, container_id: str) -> bool: """Remove a container.""" try: result = subprocess.run( ["docker", "rm", "-f", container_id], capture_output=True, text=True, timeout=60 ) return result.returncode == 0 except Exception: return False