import json import os import re import tempfile from typing import Dict, List, Optional, Protocol, Union, Literal from dockerfile_parse import DockerfileParser class DockerfFileFinalParserInterface(Protocol): """Protocol defining the final interface for Dockerfile parsing callbacks.""" class DockerfileParserInterface(Protocol): """Protocol defining the interface for Dockerfile parsing callbacks.""" def run_cmd( self, command: Union[str, List[str]], user: Optional[str] = None ) -> "DockerfileParserInterface": """Handle RUN instruction.""" ... def copy( self, src: str, dest: str, force_upload: Optional[Literal[True]] = None, user: Optional[str] = None, mode: Optional[int] = None, resolve_symlinks: Optional[bool] = None, gzip: Optional[bool] = None, ) -> "DockerfileParserInterface": """Handle COPY instruction.""" ... def set_workdir(self, workdir: str) -> "DockerfileParserInterface": """Handle WORKDIR instruction.""" ... def set_user(self, user: str) -> "DockerfileParserInterface": """Handle USER instruction.""" ... def set_envs(self, envs: Dict[str, str]) -> "DockerfileParserInterface": """Handle ENV instruction.""" ... def set_start_cmd( self, start_cmd: str, ready_cmd: str ) -> "DockerfFileFinalParserInterface": """Handle CMD/ENTRYPOINT instruction.""" ... def parse_dockerfile( dockerfile_content_or_path: str, template_builder: DockerfileParserInterface ) -> str: """ Parse a Dockerfile and convert it to Template SDK format. :param dockerfile_content_or_path: Either the Dockerfile content as a string, or a path to a Dockerfile file :param template_builder: Interface providing template builder methods :return: The base image from the Dockerfile :raises ValueError: If the Dockerfile is invalid or unsupported """ # Check if input is a file path that exists if os.path.isfile(dockerfile_content_or_path): # Read the file content with open(dockerfile_content_or_path, "r", encoding="utf-8") as f: dockerfile_content = f.read() else: # Treat as content directly dockerfile_content = dockerfile_content_or_path # Use a temporary directory to avoid creating files in the current directory with tempfile.TemporaryDirectory() as temp_dir: # Create a temporary Dockerfile dockerfile_path = os.path.join(temp_dir, "Dockerfile") with open(dockerfile_path, "w") as f: f.write(dockerfile_content) dfp = DockerfileParser(path=temp_dir) # Check for multi-stage builds from_instructions = [ instruction for instruction in dfp.structure if instruction["instruction"] == "FROM" ] if len(from_instructions) > 1: raise ValueError("Multi-stage Dockerfiles are not supported") if len(from_instructions) == 0: raise ValueError("Dockerfile must contain a FROM instruction") # Set the base image from the first FROM instruction base_image = from_instructions[0]["value"] # Remove AS alias if present (e.g., "node:18 AS builder" -> "node:18") if " as " in base_image.lower(): base_image = base_image.split(" as ")[0].strip() user_changed = False workdir_changed = False # Set the user and workdir to the Docker defaults template_builder.set_user("root") template_builder.set_workdir("/") # Process all other instructions for instruction_data in dfp.structure: instruction = instruction_data["instruction"] value = instruction_data["value"] if instruction == "FROM": # Already handled above continue elif instruction == "RUN": _handle_run_instruction(value, template_builder) elif instruction in ["COPY", "ADD"]: _handle_copy_instruction(value, template_builder) elif instruction == "WORKDIR": _handle_workdir_instruction(value, template_builder) workdir_changed = True elif instruction == "USER": _handle_user_instruction(value, template_builder) user_changed = True elif instruction in ["ENV", "ARG"]: _handle_env_instruction(value, instruction, template_builder) elif instruction in ["CMD", "ENTRYPOINT"]: _handle_cmd_entrypoint_instruction(value, template_builder) else: print(f"Unsupported instruction: {instruction}") continue # Set the user and workdir to the E2B defaults if not user_changed: template_builder.set_user("user") if not workdir_changed: template_builder.set_workdir("/home/user") return base_image def _handle_run_instruction( value: str, template_builder: DockerfileParserInterface ) -> None: """Handle RUN instruction""" if not value.strip(): return # Remove line continuations and normalize whitespace command = re.sub(r"\\\s*\n\s*", " ", value).strip() template_builder.run_cmd(command) def _handle_copy_instruction( value: str, template_builder: DockerfileParserInterface ) -> None: """Handle COPY/ADD instruction""" if not value.strip(): return # Parse source and destination from COPY/ADD command # Handle both quoted and unquoted paths parts = [] current_part = "" in_quotes = False quote_char = None i = 0 while i < len(value): char = value[i] if char in ['"', "'"] and (i == 0 or value[i - 1] != "\\"): if not in_quotes: in_quotes = True quote_char = char elif char == quote_char: in_quotes = False quote_char = None else: current_part += char elif char == " " and not in_quotes: if current_part: parts.append(current_part) current_part = "" else: current_part += char i += 1 if current_part: parts.append(current_part) # Extract --chown flag and separate from paths user = None non_flag_parts = [] for part in parts: if part.startswith("--chown="): user = part[8:] # Extract value after "--chown=" elif not part.startswith("--"): non_flag_parts.append(part) if len(non_flag_parts) >= 2: dest = non_flag_parts[-1] # Last part is destination sources = non_flag_parts[:-1] for src in sources: template_builder.copy(src, dest, user=user) def _handle_workdir_instruction( value: str, template_builder: DockerfileParserInterface ) -> None: """Handle WORKDIR instruction""" if not value.strip(): return workdir = value.strip() template_builder.set_workdir(workdir) def _handle_user_instruction( value: str, template_builder: DockerfileParserInterface ) -> None: """Handle USER instruction""" if not value.strip(): return user = value.strip() template_builder.set_user(user) def _handle_env_instruction( value: str, instruction_type: str, template_builder: DockerfileParserInterface ) -> None: """Handle ENV/ARG instruction""" if not value.strip(): return # Parse environment variables from the value # Handle both "KEY=value" and "KEY value" formats env_vars = {} # First try to split on = for KEY=value format if "=" in value: # Handle multiple KEY=value pairs on one line pairs = re.findall(r"(\w+)=([^\s]*(?:\s+(?!\w+=)[^\s]*)*)", value) for key, val in pairs: env_vars[key] = val.strip("\"'") else: # Handle "KEY value" format parts = value.split(None, 1) if len(parts) == 2: key, val = parts env_vars[key] = val.strip("\"'") elif len(parts) == 1 and instruction_type == "ARG": # ARG without default value key = parts[0] env_vars[key] = "" # Add each environment variable if env_vars: template_builder.set_envs(env_vars) def _handle_cmd_entrypoint_instruction( value: str, template_builder: DockerfileParserInterface ) -> None: """Handle CMD/ENTRYPOINT instruction - convert to set_start_cmd with 20s timeout""" if not value.strip(): return command = value.strip() # Try to parse as JSON (for array format like CMD ["sleep", "infinity"]) try: parsed_command = json.loads(command) if isinstance(parsed_command, list): command = " ".join(str(item) for item in parsed_command) except Exception: pass # Import wait_for_timeout locally to avoid circular dependency def wait_for_timeout(timeout: int) -> str: # convert to seconds, but ensure minimum of 1 second seconds = max(1, timeout // 1000) return f"sleep {seconds}" template_builder.set_start_cmd(command, wait_for_timeout(20_000))