jester1177/cloud-native-debug-env
0
1"""Docker build/run simulator — deterministic, rule-based."""2 3from typing import Dict, List, Optional, Set4 5from server.models import FileContent6 7 8class DockerSimulator:9 VALID_INSTRUCTIONS: Set[str] = {10 "FROM",11 "RUN",12 "CMD",13 "LABEL",14 "MAINTAINER",15 "EXPOSE",16 "ENV",17 "ADD",18 "COPY",19 "ENTRYPOINT",20 "VOLUME",21 "USER",22 "WORKDIR",23 "ARG",24 "ONBUILD",25 "STOPSIGNAL",26 "HEALTHCHECK",27 "SHELL",28 }29 30 def _split_lines(self, content: str) -> List[str]:31 return [line.rstrip() for line in content.split("\n")]32 33 def _non_empty_non_comment_lines(self, lines: List[str]) -> List[str]:34 return [line.strip() for line in lines if line.strip() and not line.strip().startswith("#")]35 36 def _source_exists(self, source: str, context_files: Dict[str, FileContent]) -> bool:37 if source in {".", "./"}:38 return True39 if "*" in source:40 prefix = source.replace("*", "")41 return any(path.startswith(prefix) for path in context_files)42 return source in context_files43 44 def _join_continuation_lines(self, lines: List[str]) -> List[str]:45 """Join lines ending with backslash into single logical lines."""46 result: List[str] = []47 current = ""48 for line in lines:49 stripped = line.rstrip()50 if stripped.endswith("\\"):51 current += stripped[:-1] + " "52 else:53 current += stripped54 result.append(current)55 current = ""56 if current:57 result.append(current)58 return result59 60 def validate(self, dockerfile: Optional[FileContent], context_files: Dict[str, FileContent]):61 if dockerfile is None:62 return {"build_success": False, "run_success": False, "error": "Dockerfile missing"}63 64 content = dockerfile.content65 lines = self._split_lines(content)66 active_lines = self._non_empty_non_comment_lines(lines)67 68 if not active_lines:69 return {"build_success": False, "run_success": False, "error": "Dockerfile is empty"}70 71 # ARG before FROM is fine, but the first real instruction must be FROM72 first_non_arg = None73 for line in active_lines:74 token = line.split()[0].upper()75 if token == "ARG":76 continue77 first_non_arg = token78 break79 80 if first_non_arg is None or first_non_arg != "FROM":81 return {82 "build_success": False,83 "run_success": False,84 "error": "Dockerfile must start with FROM",85 }86 87 # validate instructions88 for idx, raw in enumerate(active_lines, start=1):89 token = raw.split()[0].upper()90 # Handle --platform= prefix on FROM91 if token.startswith("FROM"):92 token = "FROM"93 if token.startswith("&&"):94 return {95 "build_success": False,96 "run_success": False,97 "error": f"Dockerfile parse error: unknown instruction: {token}",98 "line": idx,99 }100 # Strip leading --flags (e.g. --platform=...) — the instruction is after101 if token.startswith("--"):102 continue103 if token not in self.VALID_INSTRUCTIONS:104 return {105 "build_success": False,106 "run_success": False,107 "error": f"Dockerfile parse error: unknown instruction: {token}",108 "line": idx,109 }110 111 # known-bad base image tags112 if "FROM python:3.9-slimm" in content:113 return {114 "build_success": False,115 "run_success": False,116 "error": "pull access denied for python:3.9-slimm",117 }118 119 # typo in requirements filename120 if "requirments.txt" in content:121 return {122 "build_success": False,123 "run_success": False,124 "error": "COPY failed: file not found in build context: requirments.txt",125 }126 127 # COPY source must exist in build context128 for raw in active_lines:129 upper = raw.upper()130 if upper.startswith("COPY "):131 parts = raw.split()132 if len(parts) < 3:133 return {134 "build_success": False,135 "run_success": False,136 "error": "COPY requires source and destination",137 }138 src = parts[1]139 if src.startswith("--from=") and len(parts) >= 4:140 src = parts[2]141 if src.startswith("--"):142 continue143 if not self._source_exists(src, context_files):144 return {145 "build_success": False,146 "run_success": False,147 "error": f"COPY failed: file not found in build context: {src}",148 }149 150 # platform ARGs need to be declared151 if "--platform=$BUILDPLATFORM" in content and "ARG BUILDPLATFORM" not in content:152 return {153 "build_success": False,154 "run_success": False,155 "error": "failed to parse platform: BUILDPLATFORM not declared",156 }157 if "--platform=$TARGETPLATFORM" in content and "ARG TARGETPLATFORM" not in content:158 return {159 "build_success": False,160 "run_success": False,161 "error": "failed to parse platform: TARGETPLATFORM not declared",162 }163 164 # multi-stage: output dir mismatch (dist vs build)165 if "COPY --from=builder /app/dist" in content:166 pkg = context_files.get("package.json")167 if pkg and "react-scripts build" in pkg.content:168 return {169 "build_success": False,170 "run_success": False,171 "error": "COPY failed: stat app/dist: file does not exist",172 }173 174 # EXPOSE must have a numeric port175 for raw in active_lines:176 upper = raw.upper()177 if upper.startswith("EXPOSE "):178 parts = raw.split()179 for part in parts[1:]:180 cleaned = part.strip('"').strip("'")181 port_proto = cleaned.split("/")[0]182 if not port_proto.isdigit():183 return {184 "build_success": False,185 "run_success": False,186 "error": f"EXPOSE requires numeric port or port/protocol, got: {cleaned}",187 }188 189 # ============================190 # runtime checks (build OK, run might fail)191 # ============================192 193 # no WORKDIR → module resolution fails194 has_workdir = "WORKDIR" in content195 if ("npm start" in content or 'CMD ["npm", "start"]' in content) and not has_workdir:196 return {197 "build_success": True,198 "run_success": False,199 "run_error": "Error: Cannot find module '/package.json'",200 }201 202 # ENTRYPOINT + CMD both specify python → conflict203 if 'ENTRYPOINT ["python"' in content and 'CMD ["python"' in content:204 return {205 "build_success": True,206 "run_success": False,207 "run_error": "container exits immediately; ENTRYPOINT and CMD both specify full command",208 }209 210 # entrypoint script needs chmod +x211 if 'ENTRYPOINT ["./start.sh"]' in content and "chmod +x" not in content:212 return {213 "build_success": True,214 "run_success": False,215 "run_error": "exec ./start.sh: permission denied",216 }217 218 # DATABASE_URL env var missing219 has_database_url_env = "ENV DATABASE_URL" in content220 needs_database_url = (221 "app.py" in content222 and "DATABASE_URL" not in content223 and any("gunicorn" in fc.content for fc in context_files.values() if fc.content)224 )225 if needs_database_url and not has_database_url_env:226 return {227 "build_success": True,228 "run_success": False,229 "run_error": "KeyError: 'DATABASE_URL' — Application requires DATABASE_URL environment variable",230 }231 232 # non-root user can't bind privileged ports233 has_user_switch = False234 expose_port = None235 for raw in active_lines:236 upper = raw.upper()237 if upper.startswith("USER ") and "root" not in raw.lower():238 has_user_switch = True239 if upper.startswith("EXPOSE "):240 parts = raw.split()241 if len(parts) >= 2:242 port_str = parts[1].split("/")[0].strip('"').strip("'")243 if port_str.isdigit():244 expose_port = int(port_str)245 246 if has_user_switch and expose_port is not None and expose_port < 1024:247 return {248 "build_success": True,249 "run_success": False,250 "run_error": f"PermissionError: [Errno 13] Permission denied — non-root user cannot bind to port {expose_port}",251 }252 253 return {"build_success": True, "run_success": True}254 