Spaces:
Running
Running
| """ | |
| Dockerfile validation service | |
| Tests generated Dockerfiles for syntax and build errors | |
| """ | |
| import subprocess | |
| import tempfile | |
| import os | |
| from pathlib import Path | |
| from typing import Dict, List, Optional | |
| import shutil | |
| class DockerfileValidator: | |
| """Validates Dockerfiles without requiring actual Docker build""" | |
| def __init__(self, logger): | |
| self.logger = logger | |
| def validate_dockerfile(self, dockerfile_content: str, stack_type: str) -> Dict: | |
| """ | |
| Validate Dockerfile for syntax errors and best practices | |
| Returns: | |
| { | |
| "valid": bool, | |
| "errors": List[str], | |
| "warnings": List[str], | |
| "suggestions": List[str] | |
| } | |
| """ | |
| errors = [] | |
| warnings = [] | |
| suggestions = [] | |
| # Basic syntax validation | |
| lines = dockerfile_content.split('\n') | |
| # Check for required directives | |
| has_from = any('FROM' in line for line in lines) | |
| has_workdir = any('WORKDIR' in line for line in lines) | |
| has_expose = any('EXPOSE' in line for line in lines) | |
| has_cmd = any('CMD' in line or 'ENTRYPOINT' in line for line in lines) | |
| if not has_from: | |
| errors.append("Missing FROM directive - every Dockerfile must start with a base image") | |
| if not has_workdir: | |
| warnings.append("No WORKDIR directive found - consider setting a working directory") | |
| if not has_expose: | |
| warnings.append("No EXPOSE directive found - consider documenting the port your app uses") | |
| if not has_cmd: | |
| warnings.append("No CMD or ENTRYPOINT directive found - how will your container start?") | |
| # Check CMD format (should be exec form) | |
| for line in lines: | |
| if line.strip().startswith('CMD '): | |
| if 'CMD [' not in line: | |
| errors.append(f"CMD should use exec form: CMD [\"command\", \"arg\"] not shell form") | |
| elif '" "' in line or ' ' in line: | |
| errors.append(f"CMD format error: arguments should be separate array elements") | |
| # Check for common issues | |
| if 'apt-get update' in dockerfile_content and 'rm -rf /var/lib/apt/lists/*' not in dockerfile_content: | |
| suggestions.append("Consider cleaning apt cache: '&& rm -rf /var/lib/apt/lists/*'") | |
| if 'pip install' in dockerfile_content and '--no-cache-dir' not in dockerfile_content: | |
| suggestions.append("Consider using pip with --no-cache-dir to reduce image size") | |
| if 'npm install' in dockerfile_content and '--production' not in dockerfile_content and 'ci' not in dockerfile_content: | |
| suggestions.append("Consider using 'npm ci' or 'npm install --production' for production builds") | |
| # Check for security best practices | |
| user_found = any('USER ' in line for line in lines if not line.strip().startswith('#')) | |
| # Check if it's an Apache/Nginx container that handles user switching internally | |
| is_web_server = any(server in dockerfile_content.lower() for server in ['apache', 'nginx', 'httpd']) | |
| has_www_data = 'www-data' in dockerfile_content or 'nginx' in dockerfile_content | |
| if not user_found and not (is_web_server and has_www_data): | |
| warnings.append("No USER directive found - running as root is a security risk") | |
| # Try hadolint if available | |
| hadolint_results = self._run_hadolint(dockerfile_content) | |
| if hadolint_results: | |
| errors.extend(hadolint_results.get('errors', [])) | |
| warnings.extend(hadolint_results.get('warnings', [])) | |
| is_valid = len(errors) == 0 | |
| return { | |
| "valid": is_valid, | |
| "errors": errors, | |
| "warnings": warnings, | |
| "suggestions": suggestions, | |
| "score": self._calculate_score(errors, warnings, suggestions) | |
| } | |
| def _run_hadolint(self, dockerfile_content: str) -> Optional[Dict]: | |
| """Run hadolint if available""" | |
| if not shutil.which('hadolint'): | |
| return None | |
| try: | |
| with tempfile.NamedTemporaryFile(mode='w', suffix='.Dockerfile', delete=False) as f: | |
| f.write(dockerfile_content) | |
| temp_path = f.name | |
| result = subprocess.run( | |
| ['hadolint', temp_path], | |
| capture_output=True, | |
| text=True, | |
| timeout=5 | |
| ) | |
| os.unlink(temp_path) | |
| if result.returncode == 0: | |
| return {"errors": [], "warnings": []} | |
| # Parse hadolint output | |
| output_lines = result.stdout.split('\n') | |
| errors = [] | |
| warnings = [] | |
| for line in output_lines: | |
| if 'DL' in line or 'SC' in line: # Hadolint/ShellCheck codes | |
| if 'error' in line.lower(): | |
| errors.append(line.split(':', 1)[-1].strip() if ':' in line else line) | |
| elif 'warning' in line.lower(): | |
| warnings.append(line.split(':', 1)[-1].strip() if ':' in line else line) | |
| return {"errors": errors, "warnings": warnings} | |
| except Exception as e: | |
| self.logger.warning(f"Hadolint validation failed: {str(e)}") | |
| return None | |
| def _calculate_score(self, errors: List, warnings: List, suggestions: List) -> int: | |
| """Calculate quality score 0-100""" | |
| score = 100 | |
| score -= len(errors) * 20 # Critical issues | |
| score -= len(warnings) * 5 # Minor issues | |
| score -= len(suggestions) * 2 # Improvements | |
| return max(0, min(100, score)) | |
| def validate_docker_compose(self, compose_content: str) -> Dict: | |
| """Validate docker-compose.yml content""" | |
| errors = [] | |
| warnings = [] | |
| suggestions = [] | |
| lines = compose_content.split('\n') | |
| # Check for required fields | |
| has_version = any('version:' in line for line in lines) | |
| has_services = any('services:' in line for line in lines) | |
| if not has_version: | |
| warnings.append("No version specified - consider adding 'version: \"3.8\"'") | |
| if not has_services: | |
| errors.append("Missing 'services:' section - compose file must define services") | |
| # Check for common issues | |
| if 'build:' in compose_content and 'context:' not in compose_content: | |
| warnings.append("Build directive found without context - specify build context") | |
| if 'depends_on:' in compose_content: | |
| suggestions.append("Using depends_on: Consider adding healthchecks for better reliability") | |
| # Check for volumes without persistence warning | |
| if 'volumes:' not in compose_content and 'database' in compose_content.lower(): | |
| warnings.append("Database service found without volumes - data will be lost on restart") | |
| is_valid = len(errors) == 0 | |
| return { | |
| "valid": is_valid, | |
| "errors": errors, | |
| "warnings": warnings, | |
| "suggestions": suggestions, | |
| "score": self._calculate_score(errors, warnings, suggestions) | |
| } | |