Hamza4100/ai-workflow-agent
0
1# Docker Helper Tool
2"""
3Clone repositories and manage Docker builds.
4Includes error analysis and fix suggestions.
5"""
6
7import httpx
8import subprocess
9import os
10import shutil
11import logging
12from typing import Dict, Any, Optional, List
13from pathlib import Path
14
15from config import settings
16
17logger = logging.getLogger(__name__)
18
19
20class DockerHelper:
21 """
22 Docker automation helper.
23 Clones repos, builds containers, analyzes errors, suggests fixes.
24 """
25
26 def __init__(self):
27 self.projects_dir = Path(settings.PROJECTS_DIR)
28 self.ollama_host = settings.OLLAMA_HOST
29 self.ollama_model = settings.OLLAMA_MODEL
30 self.client = httpx.AsyncClient(timeout=120.0)
31
32 # Ensure projects directory exists
33 self.projects_dir.mkdir(parents=True, exist_ok=True)
34
35 async def clone_and_build(
36 self,
37 repo_url: str,
38 branch: str = "main"
39 ) -> Dict[str, Any]:
40 """
41 Clone repository and attempt Docker build.
42
43 Args:
44 repo_url: GitHub repository URL
45 branch: Branch to clone (default: main)
46
47 Returns:
48 Dict with success status, logs, and fix suggestions
49 """
50 project_name = self._extract_project_name(repo_url)
51 project_path = self.projects_dir / project_name
52
53 try:
54 # Step 1: Clone repository
55 clone_result = await self._clone_repo(repo_url, project_path, branch)
56 if not clone_result["success"]:
57 return clone_result
58
59 # Step 2: Detect project structure
60 structure = await self._detect_structure(project_path)
61
62 # Step 3: Attempt Docker build
63 build_result = await self._docker_build(project_path, structure)
64
65 if build_result["success"]:
66 return {
67 "success": True,
68 "message": f"Project {project_name} built successfully",
69 "container_id": build_result.get("container_id"),
70 "logs": build_result.get("logs", "")
71 }
72 else:
73 # Step 4: Analyze error and suggest fix
74 fix = await self._analyze_and_suggest_fix(
75 build_result.get("logs", ""),
76 project_path
77 )
78
79 return {
80 "success": False,
81 "message": f"Build failed for {project_name}",
82 "logs": build_result.get("logs", ""),
83 "fix_suggestion": fix
84 }
85
86 except Exception as e:
87 logger.error(f"Clone and build error: {e}")
88 return {
89 "success": False,
90 "message": f"Error: {str(e)}",
91 "logs": str(e)
92 }
93
94 def _extract_project_name(self, repo_url: str) -> str:
95 """Extract project name from repository URL."""
96 # Handle various URL formats
97 url = repo_url.rstrip("/")
98 if url.endswith(".git"):
99 url = url[:-4]
100 return url.split("/")[-1]
101
102 async def _clone_repo(
103 self,
104 repo_url: str,
105 project_path: Path,
106 branch: str
107 ) -> Dict[str, Any]:
108 """Clone repository to local directory."""
109 try:
110 # Remove existing directory if present
111 if project_path.exists():
112 shutil.rmtree(project_path)
113
114 # Clone repository
115 result = subprocess.run(
116 ["git", "clone", "--depth", "1", "-b", branch, repo_url, str(project_path)],
117 capture_output=True,
118 text=True,
119 timeout=120
120 )
121
122 if result.returncode == 0:
123 logger.info(f"Cloned {repo_url} to {project_path}")
124 return {"success": True, "message": "Repository cloned"}
125 else:
126 # Try without branch specification (use default)
127 result = subprocess.run(
128 ["git", "clone", "--depth", "1", repo_url, str(project_path)],
129 capture_output=True,
130 text=True,
131 timeout=120
132 )
133
134 if result.returncode == 0:
135 return {"success": True, "message": "Repository cloned (default branch)"}
136 else:
137 return {
138 "success": False,
139 "message": f"Clone failed: {result.stderr}",
140 "logs": result.stderr
141 }
142
143 except subprocess.TimeoutExpired:
144 return {"success": False, "message": "Clone timed out"}
145 except Exception as e:
146 return {"success": False, "message": f"Clone error: {str(e)}"}
147
148 async def _detect_structure(self, project_path: Path) -> Dict[str, Any]:
149 """Detect project structure and configuration files."""
150 structure = {
151 "has_dockerfile": False,
152 "has_compose": False,
153 "has_requirements": False,
154 "has_package_json": False,
155 "has_makefile": False,
156 "dockerfile_path": None,
157 "compose_path": None,
158 "language": "unknown"
159 }
160
161 files_to_check = {
162 "Dockerfile": ("has_dockerfile", "dockerfile_path"),
163 "docker-compose.yml": ("has_compose", "compose_path"),
164 "docker-compose.yaml": ("has_compose", "compose_path"),
165 "compose.yml": ("has_compose", "compose_path"),
166 "compose.yaml": ("has_compose", "compose_path"),
167 "requirements.txt": ("has_requirements", None),
168 "package.json": ("has_package_json", None),
169 "Makefile": ("has_makefile", None)
170 }
171
172 for filename, (flag, path_key) in files_to_check.items():
173 file_path = project_path / filename
174 if file_path.exists():
175 structure[flag] = True
176 if path_key:
177 structure[path_key] = str(file_path)
178
179 # Detect language
180 if structure["has_requirements"]:
181 structure["language"] = "python"
182 elif structure["has_package_json"]:
183 structure["language"] = "javascript"
184
185 return structure
186
187 async def _docker_build(
188 self,
189 project_path: Path,
190 structure: Dict[str, Any]
191 ) -> Dict[str, Any]:
192 """Attempt to build Docker container."""
193 try:
194 project_name = project_path.name.lower().replace("_", "-").replace(".", "-")
195
196 # Prefer docker-compose if available
197 if structure["has_compose"]:
198 compose_path = structure["compose_path"]
199
200 result = subprocess.run(
201 ["docker", "compose", "-f", compose_path, "build"],
202 capture_output=True,
203 text=True,
204 cwd=str(project_path),
205 timeout=600 # 10 minute timeout
206 )
207
208 if result.returncode == 0:
209 # Start containers
210 start_result = subprocess.run(
211 ["docker", "compose", "-f", compose_path, "up", "-d"],
212 capture_output=True,
213 text=True,
214 cwd=str(project_path),
215 timeout=300
216 )
217
218 return {
219 "success": start_result.returncode == 0,
220 "logs": result.stdout + start_result.stdout,
221 "method": "docker-compose"
222 }
223 else:
224 return {
225 "success": False,
226 "logs": result.stderr,
227 "method": "docker-compose"
228 }
229
230 # Fall back to Dockerfile
231 elif structure["has_dockerfile"]:
232 result = subprocess.run(
233 ["docker", "build", "-t", project_name, "."],
234 capture_output=True,
235 text=True,
236 cwd=str(project_path),
237 timeout=600
238 )
239
240 if result.returncode == 0:
241 # Run container
242 run_result = subprocess.run(
243 ["docker", "run", "-d", "--name", f"{project_name}-container", project_name],
244 capture_output=True,
245 text=True,
246 timeout=60
247 )
248
249 return {
250 "success": run_result.returncode == 0,
251 "container_id": run_result.stdout.strip()[:12] if run_result.returncode == 0 else None,
252 "logs": result.stdout + run_result.stdout,
253 "method": "dockerfile"
254 }
255 else:
256 return {
257 "success": False,
258 "logs": result.stderr,
259 "method": "dockerfile"
260 }
261
262 # No Docker configuration found - generate Dockerfile
263 else:
264 generated = await self._generate_dockerfile(project_path, structure)
265 if generated:
266 # Retry build with generated Dockerfile
267 structure["has_dockerfile"] = True
268 structure["dockerfile_path"] = str(project_path / "Dockerfile")
269 return await self._docker_build(project_path, structure)
270 else:
271 return {
272 "success": False,
273 "logs": "No Dockerfile found and auto-generation failed",
274 "method": "none"
275 }
276
277 except subprocess.TimeoutExpired:
278 return {"success": False, "logs": "Build timed out (>10 minutes)"}
279 except Exception as e:
280 return {"success": False, "logs": f"Build error: {str(e)}"}
281
282 async def _generate_dockerfile(
283 self,
284 project_path: Path,
285 structure: Dict[str, Any]
286 ) -> bool:
287 """Generate a Dockerfile based on project structure."""
288 try:
289 dockerfile_content = ""
290
291 if structure["language"] == "python":
292 dockerfile_content = """# Auto-generated Dockerfile
293FROM python:3.11-slim
294
295WORKDIR /app
296
297COPY requirements.txt .
298RUN pip install --no-cache-dir -r requirements.txt
299
300COPY . .
301
302CMD ["python", "main.py"]
303"""
304 elif structure["language"] == "javascript":
305 dockerfile_content = """# Auto-generated Dockerfile
306FROM node:20-alpine
307
308WORKDIR /app
309
310COPY package*.json ./
311RUN npm install
312
313COPY . .
314
315EXPOSE 3000
316
317CMD ["npm", "start"]
318"""
319 else:
320 # Generic fallback
321 dockerfile_content = """# Auto-generated Dockerfile
322FROM ubuntu:22.04
323
324WORKDIR /app
325
326COPY . .
327
328CMD ["bash"]
329"""
330
331 dockerfile_path = project_path / "Dockerfile"
332 dockerfile_path.write_text(dockerfile_content)
333
334 logger.info(f"Generated Dockerfile for {project_path.name}")
335 return True
336
337 except Exception as e:
338 logger.error(f"Dockerfile generation error: {e}")
339 return False
340
341 async def _analyze_and_suggest_fix(
342 self,
343 error_logs: str,
344 project_path: Path
345 ) -> str:
346 """
347 Analyze build error and suggest fix using LLM.
348
349 NOTE: This only suggests ONE fix, no infinite loops.
350 """
351 try:
352 prompt = f"""Analyze this Docker build error and suggest ONE specific fix.
353
354Error logs:
355```
356{error_logs[:2000]}
357```
358
359Project: {project_path.name}
360
361Provide a concise fix suggestion. If multiple issues, focus on the first/most critical one.
362Format:
3631. Problem: [what went wrong]
3642. Fix: [specific action to take]
3653. Command: [if applicable, the command to run]"""
366
367 response = await self.client.post(
368 f"{self.ollama_host}/api/generate",
369 json={
370 "model": self.ollama_model,
371 "prompt": prompt,
372 "stream": False
373 }
374 )
375
376 if response.status_code == 200:
377 result = response.json()
378 return result.get("response", "Unable to analyze error")
379 else:
380 return "Error analysis unavailable (LLM request failed)"
381
382 except Exception as e:
383 logger.error(f"Error analysis failed: {e}")
384 return f"Error analysis failed: {str(e)}"
385
386 async def get_container_logs(self, container_id: str, lines: int = 100) -> str:
387 """Get logs from a running container."""
388 try:
389 result = subprocess.run(
390 ["docker", "logs", "--tail", str(lines), container_id],
391 capture_output=True,
392 text=True,
393 timeout=30
394 )
395 return result.stdout + result.stderr
396 except Exception as e:
397 return f"Failed to get logs: {str(e)}"
398
399 async def list_containers(self, all_containers: bool = False) -> List[Dict[str, str]]:
400 """List Docker containers."""
401 try:
402 cmd = ["docker", "ps", "--format", "{{.ID}}\t{{.Names}}\t{{.Status}}\t{{.Image}}"]
403 if all_containers:
404 cmd.append("-a")
405
406 result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
407
408 containers = []
409 for line in result.stdout.strip().split("\n"):
410 if line:
411 parts = line.split("\t")
412 if len(parts) >= 4:
413 containers.append({
414 "id": parts[0],
415 "name": parts[1],
416 "status": parts[2],
417 "image": parts[3]
418 })
419 return containers
420
421 except Exception as e:
422 logger.error(f"List containers error: {e}")
423 return []
424
425 async def stop_container(self, container_id: str) -> bool:
426 """Stop a running container."""
427 try:
428 result = subprocess.run(
429 ["docker", "stop", container_id],
430 capture_output=True,
431 text=True,
432 timeout=60
433 )
434 return result.returncode == 0
435 except Exception:
436 return False
437
438 async def remove_container(self, container_id: str) -> bool:
439 """Remove a container."""
440 try:
441 result = subprocess.run(
442 ["docker", "rm", "-f", container_id],
443 capture_output=True,
444 text=True,
445 timeout=60
446 )
447 return result.returncode == 0
448 except Exception:
449 return False
450 