chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
"""
|
||||
Special step name for the finalization phase of template building.
|
||||
This is the last step that runs after all user-defined instructions.
|
||||
"""
|
||||
|
||||
FINALIZE_STEP_NAME = "finalize"
|
||||
|
||||
"""
|
||||
Special step name for the base image phase of template building.
|
||||
This is the first step that sets up the base image.
|
||||
"""
|
||||
BASE_STEP_NAME = "base"
|
||||
|
||||
"""
|
||||
Stack trace depth for capturing caller information.
|
||||
|
||||
Depth levels:
|
||||
1. TemplateClass
|
||||
2. Caller method (e.g., copy(), from_image(), etc.)
|
||||
|
||||
This depth is used to determine the original caller's location
|
||||
for stack traces.
|
||||
"""
|
||||
STACK_TRACE_DEPTH = 2
|
||||
|
||||
"""
|
||||
Default setting for whether to resolve symbolic links when copying files.
|
||||
When False, symlinks are copied as symlinks rather than following them.
|
||||
"""
|
||||
RESOLVE_SYMLINKS = False
|
||||
|
||||
"""
|
||||
Default setting for whether to gzip files when copying them into the
|
||||
template. When True, the upload archive is gzipped before being uploaded.
|
||||
"""
|
||||
GZIP = True
|
||||
|
||||
"""
|
||||
Default timeout (in seconds) for uploading the build-context archive to the
|
||||
S3 presigned URL. Uploads of large archives can take far longer than the 60s
|
||||
general API request timeout, so the upload uses a 1-hour default unless the
|
||||
caller passes an explicit ``request_timeout``. This matches the JS SDK's
|
||||
``FILE_UPLOAD_TIMEOUT_MS``.
|
||||
"""
|
||||
FILE_UPLOAD_TIMEOUT_SECONDS = 3600
|
||||
@@ -0,0 +1,286 @@
|
||||
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))
|
||||
@@ -0,0 +1,232 @@
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Optional, TypedDict, Callable, Dict, Literal
|
||||
|
||||
from rich.console import Console
|
||||
from rich.style import Style
|
||||
from rich.text import Text
|
||||
|
||||
from e2b.template.utils import strip_ansi_escape_codes
|
||||
|
||||
"""Log entry severity levels."""
|
||||
LogEntryLevel = Literal["debug", "info", "warn", "error"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class LogEntry:
|
||||
"""
|
||||
Represents a single log entry from the template build process.
|
||||
"""
|
||||
|
||||
timestamp: datetime
|
||||
level: LogEntryLevel
|
||||
message: str
|
||||
|
||||
def __post_init__(self):
|
||||
self.message = strip_ansi_escape_codes(self.message)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"[{self.timestamp.isoformat()}] [{self.level}] {self.message}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class LogEntryStart(LogEntry):
|
||||
"""
|
||||
Special log entry indicating the start of a build process.
|
||||
"""
|
||||
|
||||
level: LogEntryLevel = field(default="debug", init=False)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LogEntryEnd(LogEntry):
|
||||
"""
|
||||
Special log entry indicating the end of a build process.
|
||||
"""
|
||||
|
||||
level: LogEntryLevel = field(default="debug", init=False)
|
||||
|
||||
|
||||
"""
|
||||
Interval in milliseconds for updating the build timer display.
|
||||
"""
|
||||
TIMER_UPDATE_INTERVAL_MS = 150
|
||||
|
||||
"""
|
||||
Default minimum log level to display.
|
||||
"""
|
||||
DEFAULT_LEVEL: LogEntryLevel = "info"
|
||||
|
||||
"""
|
||||
Colored labels for each log level.
|
||||
"""
|
||||
levels: Dict[LogEntryLevel, tuple[str, Style]] = {
|
||||
"error": ("ERROR", Style(color="red")),
|
||||
"warn": ("WARN ", Style(color="#FF4400")),
|
||||
"info": ("INFO ", Style(color="#FF8800")),
|
||||
"debug": ("DEBUG", Style(color="bright_black")),
|
||||
}
|
||||
|
||||
"""
|
||||
Numeric ordering of log levels for comparison (lower = less severe).
|
||||
"""
|
||||
level_order = {
|
||||
"debug": 0,
|
||||
"info": 1,
|
||||
"warn": 2,
|
||||
"error": 3,
|
||||
}
|
||||
|
||||
|
||||
def set_interval(func, interval):
|
||||
"""
|
||||
Returns a stop function that can be called to cancel the interval.
|
||||
|
||||
Similar to JavaScript's setInterval.
|
||||
|
||||
:param func: Function to execute at each interval
|
||||
:param interval: Interval duration in **seconds**
|
||||
|
||||
:return: Stop function that can be called to cancel the interval
|
||||
"""
|
||||
stopped = threading.Event()
|
||||
|
||||
def loop():
|
||||
while not stopped.is_set():
|
||||
if stopped.wait(interval): # wait returns True if stopped
|
||||
break
|
||||
if not stopped.is_set(): # Double-check before executing
|
||||
func()
|
||||
|
||||
threading.Thread(target=loop, daemon=True).start()
|
||||
return stopped.set # Return the stop function
|
||||
|
||||
|
||||
class DefaultBuildLoggerInitialState(TypedDict):
|
||||
start_time: float
|
||||
animation_frame: int
|
||||
timer: Optional[Callable[[], None]]
|
||||
|
||||
|
||||
class DefaultBuildLogger:
|
||||
__console = Console()
|
||||
|
||||
__min_level: LogEntryLevel
|
||||
__state: DefaultBuildLoggerInitialState
|
||||
|
||||
def __init__(self, min_level: Optional[LogEntryLevel] = None):
|
||||
self.__min_level = min_level if min_level is not None else DEFAULT_LEVEL
|
||||
self.__reset_initial_state()
|
||||
|
||||
def logger(self, log):
|
||||
if isinstance(log, LogEntryStart):
|
||||
self.__start_timer()
|
||||
return
|
||||
|
||||
if isinstance(log, LogEntryEnd):
|
||||
if self.__state["timer"] is not None:
|
||||
self.__state["timer"]()
|
||||
return
|
||||
|
||||
# Filter by minimum level
|
||||
if level_order[log.level] < level_order[self.__min_level]:
|
||||
return
|
||||
|
||||
formatted_line = self.__format_log_line(log)
|
||||
self.__console.print(formatted_line)
|
||||
|
||||
# Redraw the timer line
|
||||
self.__update_timer()
|
||||
|
||||
def __reset_initial_state(self, timer: Optional[Callable[[], None]] = None):
|
||||
self.__state = {
|
||||
"start_time": time.time(),
|
||||
"animation_frame": 0,
|
||||
"timer": timer,
|
||||
}
|
||||
|
||||
def __format_timer_line(self) -> str:
|
||||
elapsed_seconds = time.time() - self.__state["start_time"]
|
||||
return f"{elapsed_seconds:.1f}s"
|
||||
|
||||
def __animate_status(self) -> str:
|
||||
frames = ["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"]
|
||||
idx = self.__state["animation_frame"] % len(frames)
|
||||
return frames[idx]
|
||||
|
||||
def __format_log_line(self, line: LogEntry) -> Text:
|
||||
timer = self.__format_timer_line().ljust(5)
|
||||
timestamp = line.timestamp.strftime("%H:%M:%S")
|
||||
level_text, level_style = levels.get(line.level, levels[DEFAULT_LEVEL])
|
||||
|
||||
# Build a rich Text object
|
||||
text = Text.assemble(
|
||||
timer,
|
||||
" | ",
|
||||
(timestamp, "dim"),
|
||||
" ",
|
||||
(level_text, level_style),
|
||||
" ",
|
||||
line.message,
|
||||
)
|
||||
|
||||
return text
|
||||
|
||||
def __start_timer(self):
|
||||
if not sys.stdout.isatty():
|
||||
return
|
||||
|
||||
# Start the timer interval
|
||||
stop_timer = set_interval(
|
||||
self.__update_timer, TIMER_UPDATE_INTERVAL_MS / 1000.0
|
||||
)
|
||||
|
||||
self.__reset_initial_state(stop_timer)
|
||||
|
||||
# Initial timer display
|
||||
self.__update_timer()
|
||||
|
||||
def __update_timer(self):
|
||||
if not sys.stdout.isatty():
|
||||
return
|
||||
|
||||
self.__state["animation_frame"] += 1
|
||||
jumping_squares = self.__animate_status()
|
||||
|
||||
timer_text = Text.assemble(
|
||||
jumping_squares, " Building ", self.__format_timer_line()
|
||||
)
|
||||
|
||||
# Print with carriage return
|
||||
self.__console.print(timer_text, end="\r")
|
||||
|
||||
|
||||
def default_build_logger(
|
||||
min_level: Optional[LogEntryLevel] = None,
|
||||
) -> Callable[[LogEntry], None]:
|
||||
"""
|
||||
Create a default build logger with animated timer display.
|
||||
|
||||
:param min_level: Minimum log level to display (default: 'info')
|
||||
|
||||
:return: Logger function that accepts LogEntry instances
|
||||
|
||||
Example
|
||||
```python
|
||||
from e2b import Template, default_build_logger
|
||||
|
||||
template = Template().from_python_image()
|
||||
|
||||
# Use with build - implementation would be in build_async module
|
||||
# await Template.build(template,
|
||||
# alias='my-template',
|
||||
# on_build_logs=default_build_logger(min_level='debug')
|
||||
# )
|
||||
```
|
||||
"""
|
||||
build_logger = DefaultBuildLogger(min_level)
|
||||
|
||||
return build_logger.logger
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,144 @@
|
||||
import shlex
|
||||
|
||||
|
||||
class ReadyCmd:
|
||||
"""
|
||||
Wrapper class for ready check commands.
|
||||
"""
|
||||
|
||||
def __init__(self, cmd: str):
|
||||
self.__cmd = cmd
|
||||
|
||||
def get_cmd(self):
|
||||
return self.__cmd
|
||||
|
||||
|
||||
def wait_for_port(port: int):
|
||||
"""
|
||||
Wait for a port to be listening.
|
||||
|
||||
Uses `ss` command to check if a port is open and listening.
|
||||
|
||||
:param port: Port number to wait for
|
||||
|
||||
:return: ReadyCmd that checks for the port
|
||||
|
||||
Example
|
||||
```python
|
||||
from e2b import Template, wait_for_port
|
||||
|
||||
template = (
|
||||
Template()
|
||||
.from_python_image()
|
||||
.set_start_cmd('python -m http.server 8000', wait_for_port(8000))
|
||||
)
|
||||
```
|
||||
"""
|
||||
# Match the exact listening port via ss's source-port filter (so e.g. port
|
||||
# 80 doesn't match 8080). ss exits 0 regardless of matches, so test for
|
||||
# non-empty output to signal readiness.
|
||||
cmd = f'[ -n "$(ss -Htuln sport = :{port})" ]'
|
||||
return ReadyCmd(cmd)
|
||||
|
||||
|
||||
def wait_for_url(url: str, status_code: int = 200):
|
||||
"""
|
||||
Wait for a URL to return a specific HTTP status code.
|
||||
|
||||
Uses `curl` to make HTTP requests and check the response status.
|
||||
|
||||
:param url: URL to check (e.g., 'http://localhost:3000/health')
|
||||
:param status_code: Expected HTTP status code (default: 200)
|
||||
|
||||
:return: ReadyCmd that checks the URL
|
||||
|
||||
Example
|
||||
```python
|
||||
from e2b import Template, wait_for_url
|
||||
|
||||
template = (
|
||||
Template()
|
||||
.from_node_image()
|
||||
.set_start_cmd('npm start', wait_for_url('http://localhost:3000/health'))
|
||||
)
|
||||
```
|
||||
"""
|
||||
cmd = f'curl -s -o /dev/null -w "%{{http_code}}" {shlex.quote(url)} | grep -q "{status_code}"'
|
||||
return ReadyCmd(cmd)
|
||||
|
||||
|
||||
def wait_for_process(process_name: str):
|
||||
"""
|
||||
Wait for a process with a specific name to be running.
|
||||
|
||||
Uses `pgrep` to check if a process exists.
|
||||
|
||||
:param process_name: Name of the process to wait for
|
||||
|
||||
:return: ReadyCmd that checks for the process
|
||||
|
||||
Example
|
||||
```python
|
||||
from e2b import Template, wait_for_process
|
||||
|
||||
template = (
|
||||
Template()
|
||||
.from_base_image()
|
||||
.set_start_cmd('./my-daemon', wait_for_process('my-daemon'))
|
||||
)
|
||||
```
|
||||
"""
|
||||
cmd = f"pgrep {shlex.quote(process_name)} > /dev/null"
|
||||
return ReadyCmd(cmd)
|
||||
|
||||
|
||||
def wait_for_file(filename: str):
|
||||
"""
|
||||
Wait for a file to exist.
|
||||
|
||||
Uses shell test command to check file existence.
|
||||
|
||||
:param filename: Path to the file to wait for
|
||||
|
||||
:return: ReadyCmd that checks for the file
|
||||
|
||||
Example
|
||||
```python
|
||||
from e2b import Template, wait_for_file
|
||||
|
||||
template = (
|
||||
Template()
|
||||
.from_base_image()
|
||||
.set_start_cmd('./init.sh', wait_for_file('/tmp/ready'))
|
||||
)
|
||||
```
|
||||
"""
|
||||
cmd = f"[ -f {shlex.quote(filename)} ]"
|
||||
return ReadyCmd(cmd)
|
||||
|
||||
|
||||
def wait_for_timeout(timeout: int):
|
||||
"""
|
||||
Wait for a specified timeout before considering the sandbox ready.
|
||||
|
||||
Uses `sleep` command to wait for a fixed duration.
|
||||
|
||||
:param timeout: Time to wait in **milliseconds** (minimum: 1000ms / 1 second)
|
||||
|
||||
:return: ReadyCmd that waits for the specified duration
|
||||
|
||||
Example
|
||||
```python
|
||||
from e2b import Template, wait_for_timeout
|
||||
|
||||
template = (
|
||||
Template()
|
||||
.from_node_image()
|
||||
.set_start_cmd('npm start', wait_for_timeout(5000)) # Wait 5 seconds
|
||||
)
|
||||
```
|
||||
"""
|
||||
# convert to seconds, but ensure minimum of 1 second
|
||||
seconds = max(1, timeout // 1000)
|
||||
cmd = f"sleep {seconds}"
|
||||
return ReadyCmd(cmd)
|
||||
@@ -0,0 +1,194 @@
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import List, Literal, Optional, TypedDict, Union
|
||||
|
||||
from typing_extensions import NotRequired
|
||||
|
||||
from e2b.template.logger import LogEntry
|
||||
|
||||
|
||||
class TemplateBuildStatus(str, Enum):
|
||||
"""
|
||||
Status of a template build.
|
||||
"""
|
||||
|
||||
BUILDING = "building"
|
||||
WAITING = "waiting"
|
||||
READY = "ready"
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
@dataclass
|
||||
class BuildStatusReason:
|
||||
"""
|
||||
Reason for the current build status (typically for errors).
|
||||
"""
|
||||
|
||||
message: str
|
||||
"""Message with the status reason."""
|
||||
|
||||
step: Optional[str] = None
|
||||
"""Step that failed."""
|
||||
|
||||
log_entries: List[LogEntry] = field(default_factory=list)
|
||||
"""Log entries related to the status reason."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class TemplateBuildStatusResponse:
|
||||
"""
|
||||
Response from getting build status.
|
||||
"""
|
||||
|
||||
build_id: str
|
||||
"""Build identifier."""
|
||||
|
||||
template_id: str
|
||||
"""Template identifier."""
|
||||
|
||||
status: TemplateBuildStatus
|
||||
"""Current status of the build."""
|
||||
|
||||
log_entries: List[LogEntry]
|
||||
"""Build log entries."""
|
||||
|
||||
logs: List[str]
|
||||
"""Build logs (raw strings). Deprecated: use log_entries instead."""
|
||||
|
||||
reason: Optional[BuildStatusReason] = None
|
||||
"""Reason for the current status (typically for errors)."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class TemplateTagInfo:
|
||||
"""
|
||||
Information about assigned template tags.
|
||||
"""
|
||||
|
||||
build_id: str
|
||||
"""Build identifier associated with this tag."""
|
||||
|
||||
tags: List[str]
|
||||
"""Assigned tags of the template."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class TemplateTag:
|
||||
"""
|
||||
Detailed information about a single template tag.
|
||||
"""
|
||||
|
||||
tag: str
|
||||
"""Name of the tag."""
|
||||
|
||||
build_id: str
|
||||
"""Build identifier associated with this tag."""
|
||||
|
||||
created_at: datetime
|
||||
"""When this tag was assigned."""
|
||||
|
||||
|
||||
class InstructionType(str, Enum):
|
||||
"""
|
||||
Types of instructions that can be used in a template.
|
||||
"""
|
||||
|
||||
COPY = "COPY"
|
||||
ENV = "ENV"
|
||||
RUN = "RUN"
|
||||
WORKDIR = "WORKDIR"
|
||||
USER = "USER"
|
||||
|
||||
|
||||
class CopyItem(TypedDict):
|
||||
"""
|
||||
Configuration for a single file/directory copy operation.
|
||||
"""
|
||||
|
||||
src: Union[Union[str, Path], List[Union[str, Path]]]
|
||||
dest: Union[str, Path]
|
||||
forceUpload: NotRequired[Optional[Literal[True]]]
|
||||
user: NotRequired[Optional[str]]
|
||||
mode: NotRequired[Optional[int]]
|
||||
resolveSymlinks: NotRequired[Optional[bool]]
|
||||
gzip: NotRequired[Optional[bool]]
|
||||
|
||||
|
||||
class Instruction(TypedDict):
|
||||
"""
|
||||
Represents a single instruction in the template build process.
|
||||
"""
|
||||
|
||||
type: InstructionType
|
||||
args: List[str]
|
||||
force: bool
|
||||
forceUpload: NotRequired[Optional[Literal[True]]]
|
||||
filesHash: NotRequired[Optional[str]]
|
||||
resolveSymlinks: NotRequired[Optional[bool]]
|
||||
gzip: NotRequired[Optional[bool]]
|
||||
|
||||
|
||||
class GenericDockerRegistry(TypedDict):
|
||||
"""
|
||||
Configuration for a generic Docker registry with basic authentication.
|
||||
"""
|
||||
|
||||
type: Literal["registry"]
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class AWSRegistry(TypedDict):
|
||||
"""
|
||||
Configuration for AWS Elastic Container Registry (ECR).
|
||||
"""
|
||||
|
||||
type: Literal["aws"]
|
||||
awsAccessKeyId: str
|
||||
awsSecretAccessKey: str
|
||||
awsRegion: str
|
||||
|
||||
|
||||
class GCPRegistry(TypedDict):
|
||||
"""
|
||||
Configuration for Google Container Registry (GCR) or Artifact Registry.
|
||||
"""
|
||||
|
||||
type: Literal["gcp"]
|
||||
serviceAccountJson: str
|
||||
|
||||
|
||||
"""
|
||||
Union type for all supported container registry configurations.
|
||||
"""
|
||||
RegistryConfig = Union[GenericDockerRegistry, AWSRegistry, GCPRegistry]
|
||||
|
||||
|
||||
class TemplateType(TypedDict):
|
||||
"""
|
||||
Internal representation of a template for the E2B build API.
|
||||
"""
|
||||
|
||||
fromImage: NotRequired[str]
|
||||
fromTemplate: NotRequired[str]
|
||||
fromImageRegistry: NotRequired[RegistryConfig]
|
||||
startCmd: NotRequired[str]
|
||||
readyCmd: NotRequired[str]
|
||||
steps: List[Instruction]
|
||||
force: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class BuildInfo:
|
||||
"""
|
||||
Information about a built template.
|
||||
"""
|
||||
|
||||
template_id: str
|
||||
build_id: str
|
||||
name: str
|
||||
# Deprecated: use name instead
|
||||
alias: str
|
||||
tags: List[str] = field(default_factory=list)
|
||||
@@ -0,0 +1,426 @@
|
||||
import hashlib
|
||||
import os
|
||||
import tarfile
|
||||
import tempfile
|
||||
import json
|
||||
import stat
|
||||
from wcmatch import glob
|
||||
import re
|
||||
import inspect
|
||||
from types import TracebackType, FrameType
|
||||
from typing import IO, List, Optional, Union
|
||||
|
||||
from e2b.exceptions import TemplateException
|
||||
from e2b.template.consts import BASE_STEP_NAME, FINALIZE_STEP_NAME
|
||||
|
||||
|
||||
def make_traceback(caller_frame: Optional[FrameType]) -> Optional[TracebackType]:
|
||||
"""
|
||||
Create a TracebackType from a caller frame for error reporting.
|
||||
|
||||
:param caller_frame: The caller's frame object, or None
|
||||
:return: A TracebackType object for use with exception.with_traceback(), or None
|
||||
"""
|
||||
if caller_frame is None:
|
||||
return None
|
||||
return TracebackType(
|
||||
tb_next=None,
|
||||
tb_frame=caller_frame,
|
||||
tb_lasti=caller_frame.f_lasti,
|
||||
tb_lineno=caller_frame.f_lineno,
|
||||
)
|
||||
|
||||
|
||||
def validate_relative_path(
|
||||
src: str,
|
||||
stack_trace: Optional[TracebackType],
|
||||
) -> None:
|
||||
"""
|
||||
Validate that a source path for copy operations is a relative path that stays
|
||||
within the context directory. This prevents path traversal attacks and ensures
|
||||
files are copied from within the expected directory.
|
||||
|
||||
:param src: The source path to validate
|
||||
:param stack_trace: Optional stack trace for error reporting
|
||||
|
||||
:raises TemplateException: If the path is absolute or escapes the context directory
|
||||
|
||||
Invalid paths:
|
||||
- Absolute paths: /absolute/path, C:\\Windows\\path
|
||||
- Parent directory escapes: ../foo, foo/../../bar, ./foo/../../../bar
|
||||
|
||||
Valid paths:
|
||||
- Simple relative: foo, foo/bar
|
||||
- Current directory prefix: ./foo, ./foo/bar
|
||||
- Internal parent refs that don't escape: foo/../bar (stays within context)
|
||||
"""
|
||||
# Check for absolute paths using Python's cross-platform implementation
|
||||
if os.path.isabs(src):
|
||||
raise TemplateException(
|
||||
f'Invalid source path "{src}": absolute paths are not allowed. '
|
||||
"Use a relative path within the context directory."
|
||||
).with_traceback(stack_trace)
|
||||
|
||||
# Normalize the path and check if it escapes the context directory
|
||||
normalized = os.path.normpath(src)
|
||||
|
||||
# After normalization, a path that escapes would be '..' or start with '../'
|
||||
# We check for '..' followed by path separator to avoid false positives on filenames like '..myconfig'
|
||||
# Examples:
|
||||
# - '../foo' -> '../foo' (escapes)
|
||||
# - 'foo/../../bar' -> '../bar' (escapes)
|
||||
# - './foo/../../../bar' -> '../../bar' (escapes)
|
||||
# - 'foo/../bar' -> 'bar' (doesn't escape)
|
||||
# - './foo/bar' -> 'foo/bar' (doesn't escape)
|
||||
# - '..myconfig' -> '..myconfig' (valid filename, doesn't escape)
|
||||
escapes = normalized == ".." or normalized.startswith(".." + os.sep)
|
||||
|
||||
if escapes:
|
||||
raise TemplateException(
|
||||
f'Invalid source path "{src}": path escapes the context directory. '
|
||||
"The path must stay within the context directory."
|
||||
).with_traceback(stack_trace)
|
||||
|
||||
|
||||
def normalize_build_arguments(
|
||||
name: Optional[str] = None,
|
||||
alias: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Normalize build arguments from different parameter signatures.
|
||||
Handles string name or legacy alias parameter.
|
||||
|
||||
:param name: Template name in 'name' or 'name:tag' format
|
||||
:param alias: (Deprecated) Alias name for the template. Use name instead.
|
||||
:return: Normalized template name
|
||||
:raises TemplateException: If no template name is provided
|
||||
"""
|
||||
if name and len(name) > 0:
|
||||
return name
|
||||
if alias and len(alias) > 0:
|
||||
return alias
|
||||
raise TemplateException("Name must be provided")
|
||||
|
||||
|
||||
def read_dockerignore(context_path: str) -> List[str]:
|
||||
"""
|
||||
Read and parse a .dockerignore file.
|
||||
|
||||
:param context_path: Directory path containing the .dockerignore file
|
||||
|
||||
:return: Array of ignore patterns (empty lines and comments are filtered out)
|
||||
"""
|
||||
dockerignore_path = os.path.join(context_path, ".dockerignore")
|
||||
if not os.path.exists(dockerignore_path):
|
||||
return []
|
||||
|
||||
with open(dockerignore_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
return [
|
||||
line.strip()
|
||||
for line in content.split("\n")
|
||||
if line.strip() and not line.strip().startswith("#")
|
||||
]
|
||||
|
||||
|
||||
def normalize_path(path: str) -> str:
|
||||
"""
|
||||
Normalize path separators to forward slashes for glob patterns (glob expects / even on Windows).
|
||||
|
||||
:param path: The path to normalize
|
||||
:return: The normalized path
|
||||
"""
|
||||
return path.replace(os.sep, "/")
|
||||
|
||||
|
||||
def get_all_files_in_path(
|
||||
src: str,
|
||||
context_path: str,
|
||||
ignore_patterns: List[str],
|
||||
include_directories: bool = True,
|
||||
) -> List[str]:
|
||||
"""
|
||||
Get all files for a given path and ignore patterns.
|
||||
|
||||
:param src: Path to the source directory
|
||||
:param context_path: Base directory for resolving relative paths
|
||||
:param ignore_patterns: Ignore patterns
|
||||
:param include_directories: Whether to include directories
|
||||
:return: Array of files
|
||||
"""
|
||||
files = set()
|
||||
|
||||
# Use glob to find all files/directories matching the pattern under context_path
|
||||
abs_context_path = os.path.abspath(context_path)
|
||||
files_glob = glob.glob(
|
||||
src,
|
||||
flags=glob.GLOBSTAR | glob.DOTMATCH,
|
||||
root_dir=abs_context_path,
|
||||
exclude=ignore_patterns,
|
||||
)
|
||||
|
||||
for file in files_glob:
|
||||
# Join it with abs_context_path to get the absolute path
|
||||
file_path = os.path.join(abs_context_path, file)
|
||||
|
||||
if os.path.isdir(file_path):
|
||||
# If it's a directory, add the directory and all entries recursively
|
||||
if include_directories:
|
||||
files.add(file_path)
|
||||
dir_files = glob.glob(
|
||||
normalize_path(file) + "/**/*",
|
||||
flags=glob.GLOBSTAR | glob.DOTMATCH,
|
||||
root_dir=abs_context_path,
|
||||
exclude=ignore_patterns,
|
||||
)
|
||||
for dir_file in dir_files:
|
||||
dir_file_path = os.path.join(abs_context_path, dir_file)
|
||||
files.add(dir_file_path)
|
||||
else:
|
||||
files.add(file_path)
|
||||
|
||||
return sorted(list(files))
|
||||
|
||||
|
||||
def calculate_files_hash(
|
||||
src: str,
|
||||
dest: str,
|
||||
context_path: str,
|
||||
ignore_patterns: List[str],
|
||||
resolve_symlinks: bool,
|
||||
stack_trace: Optional[TracebackType],
|
||||
) -> str:
|
||||
"""
|
||||
Calculate a hash of files being copied to detect changes for cache invalidation.
|
||||
|
||||
The hash includes file content, metadata (mode, size), and relative paths.
|
||||
Note: uid, gid, and mtime are excluded to ensure stable hashes across environments.
|
||||
|
||||
:param src: Source path pattern for files to copy
|
||||
:param dest: Destination path where files will be copied
|
||||
:param context_path: Base directory for resolving relative paths
|
||||
:param ignore_patterns: Glob patterns to ignore
|
||||
:param resolve_symlinks: Whether to resolve symbolic links when hashing
|
||||
:param stack_trace: Optional stack trace for error reporting
|
||||
|
||||
:return: Hex string hash of all files
|
||||
|
||||
:raises ValueError: If no files match the source pattern
|
||||
"""
|
||||
src_path = os.path.join(context_path, src)
|
||||
hash_obj = hashlib.sha256()
|
||||
content = f"COPY {src} {dest}"
|
||||
|
||||
hash_obj.update(content.encode())
|
||||
|
||||
files = get_all_files_in_path(src, context_path, ignore_patterns, True)
|
||||
|
||||
if len(files) == 0:
|
||||
raise ValueError(f"No files found in {src_path}").with_traceback(stack_trace)
|
||||
|
||||
def hash_stats(stat_info: os.stat_result) -> None:
|
||||
# Only include stable metadata (mode, size)
|
||||
# Exclude uid, gid, and mtime to ensure consistent hashes across environments
|
||||
hash_obj.update(str(stat_info.st_mode).encode())
|
||||
hash_obj.update(str(stat_info.st_size).encode())
|
||||
|
||||
for file in files:
|
||||
# Hash the relative path
|
||||
relative_path = os.path.relpath(file, context_path)
|
||||
hash_obj.update(relative_path.encode())
|
||||
|
||||
# Add stat information to hash calculation
|
||||
if os.path.islink(file):
|
||||
stats = os.lstat(file)
|
||||
should_follow = resolve_symlinks and (
|
||||
os.path.isfile(file) or os.path.isdir(file)
|
||||
)
|
||||
|
||||
if not should_follow:
|
||||
hash_stats(stats)
|
||||
|
||||
content = os.readlink(file)
|
||||
hash_obj.update(content.encode())
|
||||
continue
|
||||
|
||||
stats = os.stat(file)
|
||||
hash_stats(stats)
|
||||
|
||||
if stat.S_ISREG(stats.st_mode):
|
||||
with open(file, "rb") as f:
|
||||
hash_obj.update(f.read())
|
||||
|
||||
return hash_obj.hexdigest()
|
||||
|
||||
|
||||
def tar_file_stream(
|
||||
file_name: str,
|
||||
file_context_path: str,
|
||||
ignore_patterns: List[str],
|
||||
resolve_symlinks: bool,
|
||||
gzip: bool,
|
||||
) -> IO[bytes]:
|
||||
"""
|
||||
Create a tar archive of files matching a pattern in a temporary file.
|
||||
|
||||
The archive is spooled to disk so it can be uploaded as a stream instead
|
||||
of being buffered in memory. The temporary file is deleted when closed.
|
||||
|
||||
:param file_name: Glob pattern for files to include
|
||||
:param file_context_path: Base directory for resolving file paths
|
||||
:param ignore_patterns: Ignore patterns
|
||||
:param resolve_symlinks: Whether to resolve symbolic links
|
||||
:param gzip: Whether to gzip the archive
|
||||
|
||||
:return: Binary file object positioned at the start of the archive
|
||||
"""
|
||||
tar_file = tempfile.TemporaryFile()
|
||||
try:
|
||||
with tarfile.open(
|
||||
fileobj=tar_file,
|
||||
mode="w:gz" if gzip else "w",
|
||||
dereference=resolve_symlinks,
|
||||
) as tar:
|
||||
files = get_all_files_in_path(
|
||||
file_name, file_context_path, ignore_patterns, True
|
||||
)
|
||||
for file in files:
|
||||
tar.add(
|
||||
file,
|
||||
arcname=os.path.relpath(file, file_context_path),
|
||||
recursive=False,
|
||||
)
|
||||
|
||||
tar_file.seek(0)
|
||||
return tar_file
|
||||
except Exception:
|
||||
# Best-effort cleanup: a close failure must not replace the real
|
||||
# archive-creation error.
|
||||
try:
|
||||
tar_file.close()
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def strip_ansi_escape_codes(text: str) -> str:
|
||||
"""
|
||||
Strip ANSI escape codes from a string.
|
||||
|
||||
Source: https://github.com/chalk/ansi-regex/blob/main/index.js
|
||||
|
||||
:param text: String with ANSI escape codes
|
||||
|
||||
:return: String without ANSI escape codes
|
||||
"""
|
||||
# Valid string terminator sequences are BEL, ESC\, and 0x9c
|
||||
st = r"(?:\u0007|\u001B\u005C|\u009C)"
|
||||
pattern = [
|
||||
rf"[\u001B\u009B][\[\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\d/#&.:=?%@~_]+)*|[a-zA-Z\d]+(?:;[-a-zA-Z\d/#&.:=?%@~_]*)*)?{st})",
|
||||
r"(?:(?:\d{1,4}(?:[;:]\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))",
|
||||
]
|
||||
ansi_escape = re.compile("|".join(pattern), re.UNICODE)
|
||||
return ansi_escape.sub("", text)
|
||||
|
||||
|
||||
def get_caller_frame(depth: int) -> Optional[FrameType]:
|
||||
"""
|
||||
Get the caller's stack frame at a specific depth.
|
||||
|
||||
This is used to provide better error messages and debugging information
|
||||
by tracking where template methods were called from in user code.
|
||||
|
||||
:param depth: The depth of the stack trace to retrieve
|
||||
|
||||
:return: The caller frame, or None if not available
|
||||
"""
|
||||
stack = inspect.stack()[1:]
|
||||
if len(stack) < depth + 1:
|
||||
return None
|
||||
return stack[depth].frame
|
||||
|
||||
|
||||
def get_caller_directory(depth: int) -> Optional[str]:
|
||||
"""
|
||||
Get the directory of the caller at a specific stack depth.
|
||||
|
||||
This is used to determine the file_context_path when creating a template,
|
||||
so file paths are resolved relative to the user's template file location.
|
||||
|
||||
:param depth: The depth of the stack trace
|
||||
|
||||
:return: The caller's directory path, or None if not available
|
||||
"""
|
||||
try:
|
||||
# Get the stack trace
|
||||
caller_frame = get_caller_frame(depth)
|
||||
if caller_frame is None:
|
||||
return None
|
||||
|
||||
caller_file = caller_frame.f_code.co_filename
|
||||
|
||||
# Return the directory of the caller file
|
||||
return os.path.dirname(os.path.abspath(caller_file))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def pad_octal(mode: int) -> str:
|
||||
"""
|
||||
Convert a numeric file mode to a zero-padded octal string.
|
||||
|
||||
:param mode: File mode as a number (e.g., 493 for 0o755)
|
||||
|
||||
:return: Zero-padded 4-digit octal string (e.g., "0755")
|
||||
|
||||
Example
|
||||
```python
|
||||
pad_octal(0o755) # Returns "0755"
|
||||
pad_octal(0o644) # Returns "0644"
|
||||
```
|
||||
"""
|
||||
return f"{mode:04o}"
|
||||
|
||||
|
||||
def get_build_step_index(step: str, stack_traces_length: int) -> int:
|
||||
"""
|
||||
Get the array index for a build step based on its name.
|
||||
|
||||
Special steps:
|
||||
- BASE_STEP_NAME: Returns 0 (first step)
|
||||
- FINALIZE_STEP_NAME: Returns the last index
|
||||
- Numeric strings: Converted to number
|
||||
|
||||
:param step: Build step name or number as string
|
||||
:param stack_traces_length: Total number of stack traces (used for FINALIZE_STEP_NAME)
|
||||
|
||||
:return: Index for the build step
|
||||
"""
|
||||
if step == BASE_STEP_NAME:
|
||||
return 0
|
||||
|
||||
if step == FINALIZE_STEP_NAME:
|
||||
return stack_traces_length - 1
|
||||
|
||||
return int(step)
|
||||
|
||||
|
||||
def read_gcp_service_account_json(
|
||||
context_path: str, path_or_content: Union[str, dict]
|
||||
) -> str:
|
||||
"""
|
||||
Read GCP service account JSON from a file or object.
|
||||
|
||||
:param context_path: Base directory for resolving relative file paths
|
||||
:param path_or_content: Either a path to a JSON file or a service account object
|
||||
|
||||
:return: Service account JSON as a string
|
||||
"""
|
||||
if isinstance(path_or_content, str):
|
||||
with open(
|
||||
os.path.join(context_path, path_or_content), "r", encoding="utf-8"
|
||||
) as f:
|
||||
return f.read()
|
||||
else:
|
||||
return json.dumps(path_or_content)
|
||||
Reference in New Issue
Block a user