1369 lines
44 KiB
Python
1369 lines
44 KiB
Python
import json
|
|
import shlex
|
|
from typing import Dict, List, Optional, Union, Literal
|
|
from pathlib import Path
|
|
|
|
|
|
from e2b.exceptions import BuildException, InvalidArgumentException
|
|
from e2b.template.consts import STACK_TRACE_DEPTH, RESOLVE_SYMLINKS
|
|
from e2b.template.dockerfile_parser import parse_dockerfile
|
|
from e2b.template.readycmd import ReadyCmd, wait_for_file
|
|
from e2b.template.types import (
|
|
CopyItem,
|
|
Instruction,
|
|
TemplateType,
|
|
RegistryConfig,
|
|
InstructionType,
|
|
)
|
|
from e2b.template.utils import (
|
|
calculate_files_hash,
|
|
get_caller_directory,
|
|
make_traceback,
|
|
pad_octal,
|
|
read_dockerignore,
|
|
read_gcp_service_account_json,
|
|
get_caller_frame,
|
|
validate_relative_path,
|
|
)
|
|
from types import TracebackType
|
|
|
|
|
|
class TemplateBuilder:
|
|
"""
|
|
Builder class for adding instructions to an E2B template.
|
|
|
|
All methods return self to allow method chaining.
|
|
"""
|
|
|
|
def __init__(self, template: "TemplateBase"):
|
|
self._template = template
|
|
|
|
def copy(
|
|
self,
|
|
src: Union[Union[str, Path], List[Union[str, Path]]],
|
|
dest: Union[str, Path],
|
|
force_upload: Optional[Literal[True]] = None,
|
|
user: Optional[str] = None,
|
|
mode: Optional[int] = None,
|
|
resolve_symlinks: Optional[bool] = None,
|
|
gzip: Optional[bool] = None,
|
|
) -> "TemplateBuilder":
|
|
"""
|
|
Copy files or directories from the local filesystem into the template.
|
|
|
|
:param src: Source file(s) or directory path(s) to copy
|
|
:param dest: Destination path in the template
|
|
:param force_upload: Force upload even if files are cached
|
|
:param user: User and optionally group (user:group) to own the files
|
|
:param mode: File permissions in octal format (e.g., 0o755)
|
|
:param resolve_symlinks: Whether to resolve symlinks
|
|
:param gzip: Whether to gzip the files before upload (default: True)
|
|
|
|
:return: `TemplateBuilder` class
|
|
|
|
Example
|
|
```python
|
|
template.copy('requirements.txt', '/home/user/')
|
|
template.copy(['app.py', 'config.py'], '/app/', mode=0o755)
|
|
```
|
|
"""
|
|
srcs = [src] if isinstance(src, (str, Path)) else src
|
|
|
|
# Get the caller frame for stack trace in validation errors
|
|
caller_frame = get_caller_frame(STACK_TRACE_DEPTH - 1)
|
|
stack_trace = make_traceback(caller_frame)
|
|
|
|
for src_item in srcs:
|
|
src_string = str(src_item)
|
|
|
|
# Validate that the source path is a relative path within the context directory
|
|
validate_relative_path(src_string, stack_trace)
|
|
|
|
args = [
|
|
src_string,
|
|
str(dest),
|
|
user or "",
|
|
pad_octal(mode) if mode else "",
|
|
]
|
|
|
|
instruction: Instruction = {
|
|
"type": InstructionType.COPY,
|
|
"args": args,
|
|
"force": force_upload or self._template._force_next_layer,
|
|
"forceUpload": force_upload,
|
|
"resolveSymlinks": resolve_symlinks,
|
|
"gzip": gzip,
|
|
}
|
|
|
|
self._template._instructions.append(instruction)
|
|
|
|
# Collect one stack trace per pushed instruction so build steps
|
|
# stay aligned with their stack traces when copying multiple
|
|
# sources
|
|
self._template._collect_stack_trace()
|
|
|
|
return self
|
|
|
|
def copy_items(self, items: List[CopyItem]) -> "TemplateBuilder":
|
|
"""
|
|
Copy multiple files or directories using a list of copy items.
|
|
|
|
:param items: List of CopyItem dictionaries with src, dest, and optional parameters
|
|
|
|
:return: `TemplateBuilder` class
|
|
|
|
Example
|
|
```python
|
|
template.copy_items([
|
|
{'src': 'app.py', 'dest': '/app/'},
|
|
{'src': 'config.py', 'dest': '/app/', 'mode': 0o644}
|
|
])
|
|
```
|
|
"""
|
|
# Get the stack trace at the copy_items call site
|
|
caller_frame = get_caller_frame(STACK_TRACE_DEPTH - 1)
|
|
stack_trace = make_traceback(caller_frame)
|
|
|
|
def _copy_items():
|
|
for item in items:
|
|
try:
|
|
self.copy(
|
|
item["src"],
|
|
item["dest"],
|
|
item.get("forceUpload"),
|
|
item.get("user"),
|
|
item.get("mode"),
|
|
item.get("resolveSymlinks"),
|
|
item.get("gzip"),
|
|
)
|
|
except Exception as error:
|
|
# Re-raise the error with the captured stack trace
|
|
if stack_trace is not None:
|
|
raise error.with_traceback(stack_trace)
|
|
raise
|
|
|
|
# Use the override so each copied item collects this stack trace,
|
|
# keeping build steps aligned with their stack traces
|
|
self._template._run_in_stack_trace_override_context(_copy_items, stack_trace)
|
|
return self
|
|
|
|
def remove(
|
|
self,
|
|
path: Union[Union[str, Path], List[Union[str, Path]]],
|
|
force: bool = False,
|
|
recursive: bool = False,
|
|
user: Optional[str] = None,
|
|
) -> "TemplateBuilder":
|
|
"""
|
|
Remove files or directories in the template.
|
|
|
|
:param path: File(s) or directory path(s) to remove
|
|
:param force: Force removal without prompting
|
|
:param recursive: Remove directories recursively
|
|
:param user: User to run the command as
|
|
|
|
:return: `TemplateBuilder` class
|
|
|
|
Example
|
|
```python
|
|
template.remove('/tmp/cache', recursive=True, force=True)
|
|
template.remove('/tmp/cache', recursive=True, force=True, user='root')
|
|
```
|
|
"""
|
|
paths = [path] if isinstance(path, (str, Path)) else path
|
|
args = ["rm"]
|
|
if recursive:
|
|
args.append("-r")
|
|
if force:
|
|
args.append("-f")
|
|
args.extend([shlex.quote(str(p)) for p in paths])
|
|
|
|
return self._template._run_in_new_stack_trace_context(
|
|
lambda: self.run_cmd(" ".join(args), user=user)
|
|
)
|
|
|
|
def rename(
|
|
self,
|
|
src: Union[str, Path],
|
|
dest: Union[str, Path],
|
|
force: bool = False,
|
|
user: Optional[str] = None,
|
|
) -> "TemplateBuilder":
|
|
"""
|
|
Rename or move a file or directory in the template.
|
|
|
|
:param src: Source path
|
|
:param dest: Destination path
|
|
:param force: Force rename without prompting
|
|
:param user: User to run the command as
|
|
|
|
:return: `TemplateBuilder` class
|
|
|
|
Example
|
|
```python
|
|
template.rename('/tmp/old.txt', '/tmp/new.txt')
|
|
template.rename('/tmp/old.txt', '/tmp/new.txt', user='root')
|
|
```
|
|
"""
|
|
args = ["mv", shlex.quote(str(src)), shlex.quote(str(dest))]
|
|
if force:
|
|
args.append("-f")
|
|
|
|
return self._template._run_in_new_stack_trace_context(
|
|
lambda: self.run_cmd(" ".join(args), user=user)
|
|
)
|
|
|
|
def make_dir(
|
|
self,
|
|
path: Union[Union[str, Path], List[Union[str, Path]]],
|
|
mode: Optional[int] = None,
|
|
user: Optional[str] = None,
|
|
) -> "TemplateBuilder":
|
|
"""
|
|
Create directory(ies) in the template.
|
|
|
|
:param path: Directory path(s) to create
|
|
:param mode: Directory permissions in octal format (e.g., 0o755)
|
|
:param user: User to run the command as
|
|
|
|
:return: `TemplateBuilder` class
|
|
|
|
Example
|
|
```python
|
|
template.make_dir('/app/data', mode=0o755)
|
|
template.make_dir(['/app/logs', '/app/cache'])
|
|
template.make_dir('/app/data', mode=0o755, user='root')
|
|
```
|
|
"""
|
|
path_list = [path] if isinstance(path, (str, Path)) else path
|
|
args = ["mkdir", "-p"]
|
|
if mode:
|
|
args.append(f"-m {pad_octal(mode)}")
|
|
args.extend([shlex.quote(str(p)) for p in path_list])
|
|
|
|
return self._template._run_in_new_stack_trace_context(
|
|
lambda: self.run_cmd(" ".join(args), user=user)
|
|
)
|
|
|
|
def make_symlink(
|
|
self,
|
|
src: Union[str, Path],
|
|
dest: Union[str, Path],
|
|
user: Optional[str] = None,
|
|
force: bool = False,
|
|
) -> "TemplateBuilder":
|
|
"""
|
|
Create a symbolic link in the template.
|
|
|
|
:param src: Source path (target of the symlink)
|
|
:param dest: Destination path (location of the symlink)
|
|
:param user: User to run the command as
|
|
:param force: Force symlink without prompting
|
|
|
|
:return: `TemplateBuilder` class
|
|
|
|
Example
|
|
```python
|
|
template.make_symlink('/usr/bin/python3', '/usr/bin/python')
|
|
template.make_symlink('/usr/bin/python3', '/usr/bin/python', user='root')
|
|
template.make_symlink('/usr/bin/python3', '/usr/bin/python', force=True)
|
|
```
|
|
"""
|
|
args = ["ln", "-s"]
|
|
if force:
|
|
args.append("-f")
|
|
args.extend([shlex.quote(str(src)), shlex.quote(str(dest))])
|
|
return self._template._run_in_new_stack_trace_context(
|
|
lambda: self.run_cmd(" ".join(args), user=user)
|
|
)
|
|
|
|
def run_cmd(
|
|
self, command: Union[str, List[str]], user: Optional[str] = None
|
|
) -> "TemplateBuilder":
|
|
"""
|
|
Run a shell command during template build.
|
|
|
|
:param command: Command string or list of commands to run (joined with &&)
|
|
:param user: User to run the command as
|
|
|
|
:return: `TemplateBuilder` class
|
|
|
|
Example
|
|
```python
|
|
template.run_cmd('apt-get update')
|
|
template.run_cmd(['pip install numpy', 'pip install pandas'])
|
|
template.run_cmd('apt-get install vim', user='root')
|
|
```
|
|
"""
|
|
commands = [command] if isinstance(command, str) else command
|
|
args = [" && ".join(commands)]
|
|
|
|
if user:
|
|
args.append(user)
|
|
|
|
instruction: Instruction = {
|
|
"type": InstructionType.RUN,
|
|
"args": args,
|
|
"force": self._template._force_next_layer,
|
|
"forceUpload": None,
|
|
}
|
|
self._template._instructions.append(instruction)
|
|
self._template._collect_stack_trace()
|
|
return self
|
|
|
|
def set_workdir(self, workdir: Union[str, Path]) -> "TemplateBuilder":
|
|
"""
|
|
Set the working directory for subsequent commands in the template.
|
|
|
|
:param workdir: Path to set as the working directory
|
|
|
|
:return: `TemplateBuilder` class
|
|
|
|
Example
|
|
```python
|
|
template.set_workdir('/app')
|
|
```
|
|
"""
|
|
instruction: Instruction = {
|
|
"type": InstructionType.WORKDIR,
|
|
"args": [str(workdir)],
|
|
"force": self._template._force_next_layer,
|
|
"forceUpload": None,
|
|
}
|
|
self._template._instructions.append(instruction)
|
|
self._template._collect_stack_trace()
|
|
return self
|
|
|
|
def set_user(self, user: str) -> "TemplateBuilder":
|
|
"""
|
|
Set the user for subsequent commands in the template.
|
|
|
|
:param user: Username to set
|
|
|
|
:return: `TemplateBuilder` class
|
|
|
|
Example
|
|
```python
|
|
template.set_user('root')
|
|
```
|
|
"""
|
|
instruction: Instruction = {
|
|
"type": InstructionType.USER,
|
|
"args": [user],
|
|
"force": self._template._force_next_layer,
|
|
"forceUpload": None,
|
|
}
|
|
self._template._instructions.append(instruction)
|
|
self._template._collect_stack_trace()
|
|
return self
|
|
|
|
def pip_install(
|
|
self, packages: Optional[Union[str, List[str]]] = None, g: bool = True
|
|
) -> "TemplateBuilder":
|
|
"""
|
|
Install Python packages using pip.
|
|
|
|
:param packages: Package name(s) to install. If None, runs 'pip install .' in the current directory
|
|
:param g: Install packages globally (default: True). If False, installs for user only
|
|
|
|
:return: `TemplateBuilder` class
|
|
|
|
Example
|
|
```python
|
|
template.pip_install('numpy')
|
|
template.pip_install(['pandas', 'scikit-learn'])
|
|
template.pip_install('numpy', g=False) # Install for user only
|
|
template.pip_install() # Installs from current directory
|
|
```
|
|
"""
|
|
if isinstance(packages, str):
|
|
packages = [packages]
|
|
|
|
args = ["pip", "install"]
|
|
if not g:
|
|
args.append("--user")
|
|
if packages:
|
|
args.extend(packages)
|
|
else:
|
|
args.append(".")
|
|
|
|
return self._template._run_in_new_stack_trace_context(
|
|
lambda: self.run_cmd(" ".join(args), user="root" if g else None)
|
|
)
|
|
|
|
def npm_install(
|
|
self,
|
|
packages: Optional[Union[str, List[str]]] = None,
|
|
g: Optional[bool] = False,
|
|
dev: Optional[bool] = False,
|
|
) -> "TemplateBuilder":
|
|
"""
|
|
Install Node.js packages using npm.
|
|
|
|
:param packages: Package name(s) to install. If None, installs from package.json
|
|
:param g: Install packages globally
|
|
:param dev: Install packages as dev dependencies
|
|
|
|
:return: `TemplateBuilder` class
|
|
|
|
Example
|
|
```python
|
|
template.npm_install('express')
|
|
template.npm_install(['lodash', 'axios'])
|
|
template.npm_install('typescript', g=True)
|
|
template.npm_install() # Installs from package.json
|
|
```
|
|
"""
|
|
if isinstance(packages, str):
|
|
packages = [packages]
|
|
|
|
args = ["npm", "install"]
|
|
if g:
|
|
args.append("-g")
|
|
if dev:
|
|
args.append("--save-dev")
|
|
if packages:
|
|
args.extend(packages)
|
|
|
|
return self._template._run_in_new_stack_trace_context(
|
|
lambda: self.run_cmd(" ".join(args), user="root" if g else None)
|
|
)
|
|
|
|
def bun_install(
|
|
self,
|
|
packages: Optional[Union[str, List[str]]] = None,
|
|
g: Optional[bool] = False,
|
|
dev: Optional[bool] = False,
|
|
) -> "TemplateBuilder":
|
|
"""
|
|
Install Bun packages using bun.
|
|
|
|
:param packages: Package name(s) to install. If None, installs from package.json
|
|
:param g: Install packages globally
|
|
:param dev: Install packages as dev dependencies
|
|
|
|
:return: `TemplateBuilder` class
|
|
|
|
Example
|
|
```python
|
|
template.bun_install('express')
|
|
template.bun_install(['lodash', 'axios'])
|
|
template.bun_install('tsx', g=True)
|
|
template.bun_install('typescript', dev=True)
|
|
template.bun_install() // Installs from package.json
|
|
```
|
|
"""
|
|
if isinstance(packages, str):
|
|
packages = [packages]
|
|
|
|
args = ["bun", "install"]
|
|
if g:
|
|
args.append("-g")
|
|
if dev:
|
|
args.append("--dev")
|
|
if packages:
|
|
args.extend(packages)
|
|
|
|
return self._template._run_in_new_stack_trace_context(
|
|
lambda: self.run_cmd(" ".join(args), user="root" if g else None)
|
|
)
|
|
|
|
def apt_install(
|
|
self,
|
|
packages: Union[str, List[str]],
|
|
no_install_recommends: bool = False,
|
|
fix_missing: bool = False,
|
|
) -> "TemplateBuilder":
|
|
"""
|
|
Install system packages using apt-get.
|
|
|
|
:param packages: Package name(s) to install
|
|
:param no_install_recommends: Whether to install recommended packages
|
|
:param fix_missing: Whether to fix missing packages
|
|
|
|
:return: `TemplateBuilder` class
|
|
|
|
Example
|
|
```python
|
|
template.apt_install('vim')
|
|
template.apt_install(['git', 'curl', 'wget'])
|
|
template.apt_install('vim', fix_missing=True)
|
|
```
|
|
"""
|
|
if isinstance(packages, str):
|
|
packages = [packages]
|
|
|
|
return self._template._run_in_new_stack_trace_context(
|
|
lambda: self.run_cmd(
|
|
[
|
|
"apt-get update",
|
|
f"DEBIAN_FRONTEND=noninteractive DEBCONF_NOWARNINGS=yes apt-get install -y {'--no-install-recommends ' if no_install_recommends else ''}{'--fix-missing ' if fix_missing else ''}{' '.join(packages)}",
|
|
],
|
|
user="root",
|
|
)
|
|
)
|
|
|
|
def add_mcp_server(self, servers: Union[str, List[str]]) -> "TemplateBuilder":
|
|
"""
|
|
Install MCP servers using mcp-gateway.
|
|
|
|
Note: Requires a base image with mcp-gateway pre-installed (e.g., mcp-gateway).
|
|
|
|
:param servers: MCP server name(s)
|
|
|
|
:return: `TemplateBuilder` class
|
|
|
|
Example
|
|
```python
|
|
template.add_mcp_server('exa')
|
|
template.add_mcp_server(['brave', 'firecrawl', 'duckduckgo'])
|
|
```
|
|
"""
|
|
if self._template._base_template != "mcp-gateway":
|
|
caller_frame = get_caller_frame(STACK_TRACE_DEPTH - 1)
|
|
stack_trace = make_traceback(caller_frame)
|
|
raise BuildException(
|
|
"MCP servers can only be added to mcp-gateway template"
|
|
).with_traceback(stack_trace)
|
|
|
|
if isinstance(servers, str):
|
|
servers = [servers]
|
|
|
|
return self._template._run_in_new_stack_trace_context(
|
|
lambda: self.run_cmd(f"mcp-gateway pull {' '.join(servers)}", user="root")
|
|
)
|
|
|
|
def git_clone(
|
|
self,
|
|
url: str,
|
|
path: Optional[Union[str, Path]] = None,
|
|
branch: Optional[str] = None,
|
|
depth: Optional[int] = None,
|
|
user: Optional[str] = None,
|
|
) -> "TemplateBuilder":
|
|
"""
|
|
Clone a git repository into the template.
|
|
|
|
:param url: Git repository URL
|
|
:param path: Destination path for the clone
|
|
:param branch: Branch to clone
|
|
:param depth: Clone depth for shallow clones
|
|
:param user: User to run the command as
|
|
|
|
:return: `TemplateBuilder` class
|
|
|
|
Example
|
|
```python
|
|
template.git_clone('https://github.com/user/repo.git', '/app/repo')
|
|
template.git_clone('https://github.com/user/repo.git', branch='main', depth=1)
|
|
template.git_clone('https://github.com/user/repo.git', '/app/repo', user='root')
|
|
```
|
|
"""
|
|
args = ["git", "clone", shlex.quote(url)]
|
|
if branch:
|
|
args.append(f"--branch {shlex.quote(branch)}")
|
|
args.append("--single-branch")
|
|
if depth:
|
|
args.append(f"--depth {depth}")
|
|
if path:
|
|
args.append(shlex.quote(str(path)))
|
|
return self._template._run_in_new_stack_trace_context(
|
|
lambda: self.run_cmd(" ".join(args), user=user)
|
|
)
|
|
|
|
def beta_dev_container_prebuild(
|
|
self,
|
|
devcontainer_directory: Union[str, Path],
|
|
) -> "TemplateBuilder":
|
|
"""
|
|
Prebuild a devcontainer from the specified directory during the build process.
|
|
|
|
:param devcontainer_directory: Path to the devcontainer directory
|
|
|
|
:return: `TemplateBuilder` class
|
|
|
|
Example
|
|
```python
|
|
template.git_clone('https://myrepo.com/project.git', '/my-devcontainer')
|
|
template.beta_dev_container_prebuild('/my-devcontainer')
|
|
```
|
|
"""
|
|
if self._template._base_template != "devcontainer":
|
|
caller_frame = get_caller_frame(STACK_TRACE_DEPTH - 1)
|
|
stack_trace = make_traceback(caller_frame)
|
|
raise BuildException(
|
|
"Devcontainers can only used in the devcontainer template"
|
|
).with_traceback(stack_trace)
|
|
|
|
return self._template._run_in_new_stack_trace_context(
|
|
lambda: self.run_cmd(
|
|
f"devcontainer build --workspace-folder {shlex.quote(str(devcontainer_directory))}",
|
|
user="root",
|
|
)
|
|
)
|
|
|
|
def beta_set_dev_container_start(
|
|
self,
|
|
devcontainer_directory: Union[str, Path],
|
|
) -> "TemplateFinal":
|
|
"""
|
|
Start a devcontainer from the specified directory and set it as the start command.
|
|
|
|
This method returns `TemplateFinal`, which means it must be the last method in the chain.
|
|
|
|
:param devcontainer_directory: Path to the devcontainer directory
|
|
|
|
:return: `TemplateFinal` class
|
|
|
|
Example
|
|
```python
|
|
# Simple start
|
|
template.git_clone('https://myrepo.com/project.git', '/my-devcontainer')
|
|
template.beta_set_devcontainer_start('/my-devcontainer')
|
|
|
|
# With prebuild
|
|
template.git_clone('https://myrepo.com/project.git', '/my-devcontainer')
|
|
template.beta_dev_container_prebuild('/my-devcontainer')
|
|
template.beta_set_dev_container_start('/my-devcontainer')
|
|
```
|
|
"""
|
|
if self._template._base_template != "devcontainer":
|
|
caller_frame = get_caller_frame(STACK_TRACE_DEPTH - 1)
|
|
stack_trace = make_traceback(caller_frame)
|
|
raise BuildException(
|
|
"Devcontainers can only used in the devcontainer template"
|
|
).with_traceback(stack_trace)
|
|
|
|
def _set_start():
|
|
dir_ = shlex.quote(str(devcontainer_directory))
|
|
return self.set_start_cmd(
|
|
"sudo devcontainer up --workspace-folder "
|
|
+ dir_
|
|
+ " && sudo /prepare-exec.sh "
|
|
+ dir_
|
|
+ " | sudo tee /devcontainer.sh > /dev/null && sudo chmod +x /devcontainer.sh && sudo touch /devcontainer.up",
|
|
wait_for_file("/devcontainer.up"),
|
|
)
|
|
|
|
return self._template._run_in_new_stack_trace_context(_set_start)
|
|
|
|
def set_envs(self, envs: Dict[str, str]) -> "TemplateBuilder":
|
|
"""
|
|
Set environment variables.
|
|
Note: Environment variables defined here are available only during template build.
|
|
|
|
:param envs: Dictionary of environment variable names and values
|
|
|
|
:return: `TemplateBuilder` class
|
|
|
|
Example
|
|
```python
|
|
template.set_envs({'NODE_ENV': 'production', 'PORT': '8080'})
|
|
```
|
|
"""
|
|
if len(envs) == 0:
|
|
return self
|
|
|
|
instruction: Instruction = {
|
|
"type": InstructionType.ENV,
|
|
"args": [item for key, value in envs.items() for item in [key, value]],
|
|
"force": self._template._force_next_layer,
|
|
"forceUpload": None,
|
|
}
|
|
self._template._instructions.append(instruction)
|
|
self._template._collect_stack_trace()
|
|
return self
|
|
|
|
def skip_cache(self) -> "TemplateBuilder":
|
|
"""
|
|
Skip cache for all subsequent build instructions from this point.
|
|
|
|
Call this before any instruction to force it and all following layers
|
|
to be rebuilt, ignoring any cached layers.
|
|
|
|
:return: `TemplateBuilder` class
|
|
|
|
Example
|
|
```python
|
|
template.skip_cache().run_cmd('apt-get update')
|
|
```
|
|
"""
|
|
self._template._force_next_layer = True
|
|
return self
|
|
|
|
def set_start_cmd(
|
|
self, start_cmd: str, ready_cmd: Union[str, ReadyCmd]
|
|
) -> "TemplateFinal":
|
|
"""
|
|
Set the command to start when the sandbox launches and the ready check command.
|
|
|
|
:param start_cmd: Command to run when the sandbox starts
|
|
:param ready_cmd: Command or ReadyCmd to check if the sandbox is ready
|
|
|
|
:return: `TemplateFinal` class
|
|
|
|
Example
|
|
```python
|
|
# Using a string command
|
|
template.set_start_cmd(
|
|
'python app.py',
|
|
'curl http://localhost:8000/health'
|
|
)
|
|
|
|
# Using ReadyCmd helpers
|
|
from e2b import wait_for_port, wait_for_url
|
|
|
|
template.set_start_cmd(
|
|
'python -m http.server 8000',
|
|
wait_for_port(8000)
|
|
)
|
|
|
|
template.set_start_cmd(
|
|
'npm start',
|
|
wait_for_url('http://localhost:3000/health', 200)
|
|
)
|
|
```
|
|
"""
|
|
self._template._start_cmd = start_cmd
|
|
|
|
if isinstance(ready_cmd, ReadyCmd):
|
|
ready_cmd = ready_cmd.get_cmd()
|
|
|
|
self._template._ready_cmd = ready_cmd
|
|
self._template._collect_stack_trace()
|
|
return TemplateFinal(self._template)
|
|
|
|
def set_ready_cmd(self, ready_cmd: Union[str, ReadyCmd]) -> "TemplateFinal":
|
|
"""
|
|
Set the command to check if the sandbox is ready.
|
|
|
|
:param ready_cmd: Command or ReadyCmd to check if the sandbox is ready
|
|
|
|
:return: `TemplateFinal` class
|
|
|
|
Example
|
|
```python
|
|
# Using a string command
|
|
template.set_ready_cmd('curl http://localhost:8000/health')
|
|
|
|
# Using ReadyCmd helpers
|
|
from e2b import wait_for_port, wait_for_file, wait_for_process
|
|
|
|
template.set_ready_cmd(wait_for_port(3000))
|
|
|
|
template.set_ready_cmd(wait_for_file('/tmp/ready'))
|
|
|
|
template.set_ready_cmd(wait_for_process('nginx'))
|
|
```
|
|
"""
|
|
if isinstance(ready_cmd, ReadyCmd):
|
|
ready_cmd = ready_cmd.get_cmd()
|
|
|
|
self._template._ready_cmd = ready_cmd
|
|
self._template._collect_stack_trace()
|
|
return TemplateFinal(self._template)
|
|
|
|
|
|
class TemplateFinal:
|
|
"""
|
|
Final template state after start/ready commands are set.
|
|
"""
|
|
|
|
def __init__(self, template: "TemplateBase"):
|
|
self._template = template
|
|
|
|
|
|
class TemplateBase:
|
|
"""
|
|
Base class for building E2B sandbox templates.
|
|
"""
|
|
|
|
_logs_refresh_frequency = 0.2
|
|
|
|
def __init__(
|
|
self,
|
|
file_context_path: Optional[Union[str, Path]] = None,
|
|
file_ignore_patterns: Optional[List[str]] = None,
|
|
):
|
|
"""
|
|
Create a new template builder instance.
|
|
|
|
:param file_context_path: Base path for resolving relative file paths in copy operations
|
|
:param file_ignore_patterns: List of glob patterns to ignore when copying files
|
|
"""
|
|
self._default_base_image: str = "e2bdev/base"
|
|
self._base_image: Optional[str] = self._default_base_image
|
|
self._base_template: Optional[str] = None
|
|
self._registry_config: Optional[RegistryConfig] = None
|
|
self._start_cmd: Optional[str] = None
|
|
self._ready_cmd: Optional[str] = None
|
|
# Force the whole template to be rebuilt
|
|
self._force: bool = False
|
|
# Force the next layer to be rebuilt
|
|
self._force_next_layer: bool = False
|
|
self._instructions: List[Instruction] = []
|
|
# If no file_context_path is provided, use the caller's directory
|
|
self._file_context_path = (
|
|
file_context_path.as_posix()
|
|
if isinstance(file_context_path, Path)
|
|
else (file_context_path or get_caller_directory(STACK_TRACE_DEPTH) or ".")
|
|
)
|
|
self._file_ignore_patterns: List[str] = file_ignore_patterns or []
|
|
self._stack_traces: List[Union[TracebackType, None]] = []
|
|
self._stack_traces_enabled: bool = True
|
|
self._stack_traces_override: Optional[Union[TracebackType, None]] = None
|
|
|
|
def skip_cache(self) -> "TemplateBase":
|
|
"""
|
|
Skip cache for all subsequent build instructions from this point.
|
|
|
|
:return: `TemplateBase` class
|
|
|
|
Example
|
|
```python
|
|
template.skip_cache().from_python_image('3.11')
|
|
```
|
|
"""
|
|
self._force_next_layer = True
|
|
return self
|
|
|
|
def _collect_stack_trace(
|
|
self, stack_traces_depth: int = STACK_TRACE_DEPTH
|
|
) -> "TemplateBase":
|
|
"""
|
|
Collect the current stack trace for debugging purposes.
|
|
|
|
:param stack_traces_depth: Depth to traverse in the call stack
|
|
|
|
:return: `TemplateBase` class
|
|
"""
|
|
if not self._stack_traces_enabled:
|
|
return self
|
|
|
|
# Use the override if set, otherwise get the caller frame
|
|
if self._stack_traces_override is not None:
|
|
self._stack_traces.append(self._stack_traces_override)
|
|
return self
|
|
|
|
stack = get_caller_frame(stack_traces_depth)
|
|
self._stack_traces.append(make_traceback(stack))
|
|
return self
|
|
|
|
def _disable_stack_trace(self) -> "TemplateBase":
|
|
"""
|
|
Temporarily disable stack trace collection.
|
|
|
|
:return: `TemplateBase` class
|
|
"""
|
|
self._stack_traces_enabled = False
|
|
return self
|
|
|
|
def _enable_stack_trace(self) -> "TemplateBase":
|
|
"""
|
|
Re-enable stack trace collection.
|
|
|
|
:return: `TemplateBase` class
|
|
"""
|
|
self._stack_traces_enabled = True
|
|
return self
|
|
|
|
def _run_in_new_stack_trace_context(self, fn):
|
|
"""
|
|
Execute a function in a clean stack trace context.
|
|
|
|
:param fn: Function to execute
|
|
|
|
:return: The result of the function
|
|
"""
|
|
self._disable_stack_trace()
|
|
try:
|
|
result = fn()
|
|
finally:
|
|
self._enable_stack_trace()
|
|
self._collect_stack_trace(STACK_TRACE_DEPTH + 1)
|
|
return result
|
|
|
|
def _run_in_stack_trace_override_context(
|
|
self, fn, stack_trace_override: Optional[Union[TracebackType, None]]
|
|
):
|
|
"""
|
|
Execute a function with a manual stack trace override.
|
|
|
|
:param fn: Function to execute
|
|
:param stack_trace_override: Stack trace to use instead of auto-collecting
|
|
|
|
:return: The result of the function
|
|
"""
|
|
self._stack_traces_override = stack_trace_override
|
|
try:
|
|
return fn()
|
|
finally:
|
|
self._stack_traces_override = None
|
|
|
|
# Built-in image mixins
|
|
def from_debian_image(self, variant: str = "stable") -> TemplateBuilder:
|
|
"""
|
|
Start template from a Debian base image.
|
|
|
|
:param variant: Debian image variant
|
|
|
|
:return: `TemplateBuilder` class
|
|
|
|
Example
|
|
```python
|
|
Template().from_debian_image('bookworm')
|
|
```
|
|
"""
|
|
return self._run_in_new_stack_trace_context(
|
|
lambda: self.from_image(f"debian:{variant}")
|
|
)
|
|
|
|
def from_ubuntu_image(self, variant: str = "latest") -> TemplateBuilder:
|
|
"""
|
|
Start template from an Ubuntu base image.
|
|
|
|
:param variant: Ubuntu image variant (default: 'latest')
|
|
|
|
:return: `TemplateBuilder` class
|
|
|
|
Example
|
|
```python
|
|
Template().from_ubuntu_image('24.04')
|
|
```
|
|
"""
|
|
return self._run_in_new_stack_trace_context(
|
|
lambda: self.from_image(f"ubuntu:{variant}")
|
|
)
|
|
|
|
def from_python_image(self, version: str = "3") -> TemplateBuilder:
|
|
"""
|
|
Start template from a Python base image.
|
|
|
|
:param version: Python version (default: '3')
|
|
|
|
:return: `TemplateBuilder` class
|
|
|
|
Example
|
|
```python
|
|
Template().from_python_image('3')
|
|
```
|
|
"""
|
|
return self._run_in_new_stack_trace_context(
|
|
lambda: self.from_image(f"python:{version}")
|
|
)
|
|
|
|
def from_node_image(self, variant: str = "lts") -> TemplateBuilder:
|
|
"""
|
|
Start template from a Node.js base image.
|
|
|
|
:param variant: Node.js image variant (default: 'lts')
|
|
|
|
:return: `TemplateBuilder` class
|
|
|
|
Example
|
|
```python
|
|
Template().from_node_image('24')
|
|
```
|
|
"""
|
|
return self._run_in_new_stack_trace_context(
|
|
lambda: self.from_image(f"node:{variant}")
|
|
)
|
|
|
|
def from_bun_image(self, variant: str = "latest") -> TemplateBuilder:
|
|
"""
|
|
Start template from a Bun base image.
|
|
|
|
:param variant: Bun image variant (default: 'latest')
|
|
|
|
:return: `TemplateBuilder` class
|
|
"""
|
|
return self._run_in_new_stack_trace_context(
|
|
lambda: self.from_image(f"oven/bun:{variant}")
|
|
)
|
|
|
|
def from_base_image(self) -> TemplateBuilder:
|
|
"""
|
|
Start template from the E2B base image (e2bdev/base:latest).
|
|
|
|
:return: `TemplateBuilder` class
|
|
|
|
Example
|
|
```python
|
|
Template().from_base_image()
|
|
```
|
|
"""
|
|
return self._run_in_new_stack_trace_context(
|
|
lambda: self.from_image(self._default_base_image)
|
|
)
|
|
|
|
def from_image(
|
|
self,
|
|
image: str,
|
|
username: Optional[str] = None,
|
|
password: Optional[str] = None,
|
|
) -> TemplateBuilder:
|
|
"""
|
|
Start template from a Docker image.
|
|
|
|
:param image: Docker image name (e.g., 'ubuntu:24.04')
|
|
:param username: Username for private registry authentication
|
|
:param password: Password for private registry authentication
|
|
|
|
:return: `TemplateBuilder` class
|
|
|
|
Example
|
|
```python
|
|
Template().from_image('python:3')
|
|
|
|
# With credentials (optional)
|
|
Template().from_image('myregistry.com/myimage:latest', username='user', password='pass')
|
|
```
|
|
"""
|
|
# Validate (and resolve the registry config) before mutating the builder.
|
|
if username is not None or password is not None:
|
|
if not username or not password:
|
|
caller_frame = get_caller_frame(STACK_TRACE_DEPTH - 1)
|
|
stack_trace = make_traceback(caller_frame)
|
|
raise InvalidArgumentException(
|
|
"Both username and password are required when providing registry credentials"
|
|
).with_traceback(stack_trace)
|
|
|
|
self._registry_config = {
|
|
"type": "registry",
|
|
"username": username,
|
|
"password": password,
|
|
}
|
|
|
|
self._base_image = image
|
|
self._base_template = None
|
|
|
|
# If we should force the next layer and it's a FROM command, invalidate whole template
|
|
if self._force_next_layer:
|
|
self._force = True
|
|
|
|
self._collect_stack_trace()
|
|
return TemplateBuilder(self)
|
|
|
|
def from_template(self, template: str) -> TemplateBuilder:
|
|
"""
|
|
Start template from an existing E2B template.
|
|
|
|
:param template: E2B template ID or alias
|
|
|
|
:return: `TemplateBuilder` class
|
|
|
|
Example
|
|
```python
|
|
Template().from_template('my-base-template')
|
|
```
|
|
"""
|
|
self._base_template = template
|
|
self._base_image = None
|
|
|
|
# If we should force the next layer and it's a FROM command, invalidate whole template
|
|
if self._force_next_layer:
|
|
self._force = True
|
|
|
|
self._collect_stack_trace()
|
|
return TemplateBuilder(self)
|
|
|
|
def from_dockerfile(self, dockerfile_content_or_path: str) -> TemplateBuilder:
|
|
"""
|
|
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
|
|
|
|
:return: `TemplateBuilder` class
|
|
|
|
Example
|
|
```python
|
|
Template().from_dockerfile('Dockerfile')
|
|
Template().from_dockerfile('FROM python:3\\nRUN pip install numpy')
|
|
```
|
|
"""
|
|
# Create a TemplateBuilder first to use its methods
|
|
builder = TemplateBuilder(self)
|
|
|
|
# Get the caller frame to use for stack trace override
|
|
# -1 as we're going up the call stack from the parse_dockerfile function
|
|
caller_frame = get_caller_frame(STACK_TRACE_DEPTH - 1)
|
|
stack_trace_override = make_traceback(caller_frame)
|
|
|
|
# Parse the dockerfile using the builder as the interface
|
|
base_image = self._run_in_stack_trace_override_context(
|
|
lambda: parse_dockerfile(dockerfile_content_or_path, builder),
|
|
stack_trace_override,
|
|
)
|
|
self._base_image = base_image
|
|
|
|
# If we should force the next layer and it's a FROM command, invalidate whole template
|
|
if self._force_next_layer:
|
|
self._force = True
|
|
|
|
self._collect_stack_trace()
|
|
return builder
|
|
|
|
def from_aws_registry(
|
|
self,
|
|
image: str,
|
|
access_key_id: str,
|
|
secret_access_key: str,
|
|
region: str,
|
|
) -> TemplateBuilder:
|
|
"""
|
|
Start template from an AWS ECR registry image.
|
|
|
|
:param image: Docker image name from AWS ECR
|
|
:param access_key_id: AWS access key ID
|
|
:param secret_access_key: AWS secret access key
|
|
:param region: AWS region
|
|
|
|
:return: `TemplateBuilder` class
|
|
|
|
Example
|
|
```python
|
|
Template().from_aws_registry(
|
|
'123456789.dkr.ecr.us-west-2.amazonaws.com/myimage:latest',
|
|
access_key_id='AKIA...',
|
|
secret_access_key='...',
|
|
region='us-west-2'
|
|
)
|
|
```
|
|
"""
|
|
self._base_image = image
|
|
self._base_template = None
|
|
|
|
# Set the registry config if provided
|
|
self._registry_config = {
|
|
"type": "aws",
|
|
"awsAccessKeyId": access_key_id,
|
|
"awsSecretAccessKey": secret_access_key,
|
|
"awsRegion": region,
|
|
}
|
|
|
|
# If we should force the next layer and it's a FROM command, invalidate whole template
|
|
if self._force_next_layer:
|
|
self._force = True
|
|
|
|
self._collect_stack_trace()
|
|
return TemplateBuilder(self)
|
|
|
|
def from_gcp_registry(
|
|
self, image: str, service_account_json: Union[str, dict]
|
|
) -> TemplateBuilder:
|
|
"""
|
|
Start template from a GCP Artifact Registry or Container Registry image.
|
|
|
|
:param image: Docker image name from GCP registry
|
|
:param service_account_json: Service account JSON string, dict, or path to JSON file
|
|
|
|
:return: `TemplateBuilder` class
|
|
|
|
Example
|
|
```python
|
|
Template().from_gcp_registry(
|
|
'gcr.io/myproject/myimage:latest',
|
|
service_account_json='path/to/service-account.json'
|
|
)
|
|
```
|
|
"""
|
|
self._base_image = image
|
|
self._base_template = None
|
|
|
|
# Set the registry config if provided
|
|
self._registry_config = {
|
|
"type": "gcp",
|
|
"serviceAccountJson": read_gcp_service_account_json(
|
|
self._file_context_path, service_account_json
|
|
),
|
|
}
|
|
|
|
# If we should force the next layer and it's a FROM command, invalidate whole template
|
|
if self._force_next_layer:
|
|
self._force = True
|
|
|
|
self._collect_stack_trace()
|
|
return TemplateBuilder(self)
|
|
|
|
@staticmethod
|
|
def to_json(template: "TemplateClass") -> str:
|
|
"""
|
|
Convert a template to JSON representation.
|
|
|
|
:param template: The template to convert (TemplateBuilder or TemplateFinal instance)
|
|
|
|
:return: JSON string representation of the template
|
|
|
|
Example
|
|
```python
|
|
template = Template().from_python_image('3').copy('app.py', '/app/')
|
|
json_str = TemplateBase.to_json(template)
|
|
```
|
|
"""
|
|
return json.dumps(
|
|
template._template._serialize(
|
|
template._template._instructions_with_hashes()
|
|
),
|
|
indent=2,
|
|
)
|
|
|
|
@staticmethod
|
|
def to_dockerfile(template: "TemplateClass") -> str:
|
|
"""
|
|
Convert a template to Dockerfile format.
|
|
|
|
Note: Templates based on other E2B templates cannot be converted to Dockerfile.
|
|
|
|
:param template: The template to convert (TemplateBuilder or TemplateFinal instance)
|
|
|
|
:return: Dockerfile string representation
|
|
|
|
:raises ValueError: If the template is based on another E2B template or has no base image
|
|
|
|
Example
|
|
```python
|
|
template = Template().from_python_image('3').copy('app.py', '/app/')
|
|
dockerfile = TemplateBase.to_dockerfile(template)
|
|
```
|
|
"""
|
|
if template._template._base_template is not None:
|
|
raise ValueError(
|
|
"Cannot convert template built from another template to Dockerfile. "
|
|
"Templates based on other templates can only be built using the E2B API."
|
|
)
|
|
|
|
if template._template._base_image is None:
|
|
raise ValueError("No base image specified for template")
|
|
|
|
dockerfile = f"FROM {template._template._base_image}\n"
|
|
|
|
for instruction in template._template._instructions:
|
|
if instruction["type"] == InstructionType.RUN:
|
|
dockerfile += f"RUN {instruction['args'][0]}\n"
|
|
continue
|
|
|
|
if instruction["type"] == InstructionType.COPY:
|
|
dockerfile += (
|
|
f"COPY {instruction['args'][0]} {instruction['args'][1]}\n"
|
|
)
|
|
continue
|
|
|
|
if instruction["type"] == InstructionType.ENV:
|
|
args = instruction["args"]
|
|
values = []
|
|
for i in range(0, len(args), 2):
|
|
values.append(f"{args[i]}={args[i + 1]}")
|
|
dockerfile += f"ENV {' '.join(values)}\n"
|
|
continue
|
|
|
|
dockerfile += (
|
|
f"{instruction['type'].value} {' '.join(instruction['args'])}\n"
|
|
)
|
|
|
|
if template._template._start_cmd:
|
|
dockerfile += f"ENTRYPOINT {template._template._start_cmd}\n"
|
|
|
|
return dockerfile
|
|
|
|
def _instructions_with_hashes(
|
|
self,
|
|
) -> List[Instruction]:
|
|
"""
|
|
Add file hashes to COPY instructions for cache invalidation.
|
|
|
|
:return: Copy of instructions list with filesHash added to COPY instructions
|
|
"""
|
|
steps: List[Instruction] = []
|
|
|
|
for index, instruction in enumerate(self._instructions):
|
|
step: Instruction = {
|
|
"type": instruction["type"],
|
|
"args": instruction["args"],
|
|
"force": instruction["force"],
|
|
"forceUpload": instruction.get("forceUpload"),
|
|
"resolveSymlinks": instruction.get("resolveSymlinks"),
|
|
"gzip": instruction.get("gzip"),
|
|
}
|
|
|
|
if instruction["type"] == InstructionType.COPY:
|
|
stack_trace = None
|
|
if index + 1 < len(self._stack_traces):
|
|
stack_trace = self._stack_traces[index + 1]
|
|
|
|
args = instruction.get("args", [])
|
|
src = args[0] if len(args) > 0 else None
|
|
dest = args[1] if len(args) > 1 else None
|
|
if src is None or dest is None:
|
|
raise ValueError("Source path and destination path are required")
|
|
|
|
resolve_symlinks = instruction.get("resolveSymlinks")
|
|
step["filesHash"] = calculate_files_hash(
|
|
src,
|
|
dest,
|
|
self._file_context_path,
|
|
[
|
|
*self._file_ignore_patterns,
|
|
*read_dockerignore(self._file_context_path),
|
|
],
|
|
resolve_symlinks
|
|
if resolve_symlinks is not None
|
|
else RESOLVE_SYMLINKS,
|
|
stack_trace,
|
|
)
|
|
|
|
steps.append(step)
|
|
|
|
return steps
|
|
|
|
def _serialize(self, steps: List[Instruction]) -> TemplateType:
|
|
"""
|
|
Serialize the template to the API request format.
|
|
|
|
:param steps: List of build instructions with file hashes
|
|
|
|
:return: Template data formatted for the API
|
|
"""
|
|
_steps: List[Instruction] = []
|
|
|
|
for _, instruction in enumerate(steps):
|
|
step: Instruction = {
|
|
"type": instruction.get("type"),
|
|
"args": instruction.get("args"),
|
|
"force": instruction.get("force"),
|
|
}
|
|
|
|
files_hash = instruction.get("filesHash")
|
|
if files_hash is not None:
|
|
step["filesHash"] = files_hash
|
|
|
|
force_upload = instruction.get("forceUpload")
|
|
if force_upload is not None:
|
|
step["forceUpload"] = force_upload
|
|
|
|
_steps.append(step)
|
|
|
|
template_data: TemplateType = {
|
|
"steps": _steps,
|
|
"force": self._force,
|
|
}
|
|
|
|
if self._base_image is not None:
|
|
template_data["fromImage"] = self._base_image
|
|
|
|
if self._base_template is not None:
|
|
template_data["fromTemplate"] = self._base_template
|
|
|
|
if self._registry_config is not None:
|
|
template_data["fromImageRegistry"] = self._registry_config
|
|
|
|
if self._start_cmd is not None:
|
|
template_data["startCmd"] = self._start_cmd
|
|
|
|
if self._ready_cmd is not None:
|
|
template_data["readyCmd"] = self._ready_cmd
|
|
|
|
return template_data
|
|
|
|
|
|
TemplateClass = Union[TemplateFinal, TemplateBuilder]
|