deploymate / app /services /dockerfile_validator.py
shakauthossain's picture
V2.0.0
2df0cf9 verified
"""
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)
}