mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
12 Commits
feature/ad
...
openhands-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e60c9b5cc6 | ||
|
|
52bbaf654b | ||
|
|
e11d2ab5d2 | ||
|
|
6c4ee14777 | ||
|
|
6df06a911e | ||
|
|
16c0c567a8 | ||
|
|
cbfb517aec | ||
|
|
cac71791a6 | ||
|
|
57b0e2e9a0 | ||
|
|
74bf5428ee | ||
|
|
b00741be6f | ||
|
|
dd81da9117 |
273
docs/design/runtime_design.md
Normal file
273
docs/design/runtime_design.md
Normal file
@@ -0,0 +1,273 @@
|
||||
# Runtime Building Procedure Design
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
The OpenHands Docker Runtime is a core component that enables secure and flexible execution of AI agent actions. It creates a sandboxed environment using Docker, where arbitrary code can be run safely without risking the host system.
|
||||
|
||||
### Traditional Building Process
|
||||
|
||||
The traditional runtime building procedure follows these steps:
|
||||
|
||||
1. **Base Image Selection**: Takes a base image (e.g., `nikolaik/python-nodejs:python3.12-nodejs22`)
|
||||
2. **Image Building**: Builds a new Docker image on top of it with OpenHands-specific code and dependencies
|
||||
3. **Tagging System**: Uses a sophisticated tagging system to optimize rebuilds:
|
||||
- **Source Tag** (`oh_v{version}_{lock_hash}_{source_hash}`): Most specific, includes source code hash
|
||||
- **Lock Tag** (`oh_v{version}_{lock_hash}`): Based on dependencies and base image
|
||||
- **Versioned Tag** (`oh_v{version}_{base_image}`): Most generic, based on OpenHands version and base image
|
||||
|
||||
4. **Dependency Installation**:
|
||||
- System dependencies via apt-get (including tmux, git, etc.)
|
||||
- Python dependencies via poetry and micromamba
|
||||
- Chromium via playwright install
|
||||
- VSCode server
|
||||
- Other tools and configurations
|
||||
|
||||
5. **Optimization Strategies**:
|
||||
- Reusing existing images when possible
|
||||
- Caching dependencies
|
||||
- Building in layers
|
||||
|
||||
### Key Components
|
||||
|
||||
1. **Action Execution Server**: Runs inside the Docker container and executes actions received from the OpenHands backend
|
||||
2. **Browser Environment**: Uses Chromium installed via Playwright
|
||||
3. **Bash Session**: Uses tmux for persistent terminal sessions
|
||||
4. **Plugin System**: Supports extensions like Jupyter notebooks
|
||||
|
||||
## Two-Stage Building Approach
|
||||
|
||||
### Overview
|
||||
|
||||
The two-stage approach simplifies the runtime building procedure by:
|
||||
|
||||
1. Building all dependencies into an intermediate Docker image with everything in `/openhands` folder
|
||||
2. For any arbitrary base image, simply copying the `/openhands` folder to form the final image
|
||||
|
||||
### Benefits
|
||||
|
||||
1. **Faster Builds**: Significantly reduces build time for new base images
|
||||
2. **Flexibility**: Makes it easier to use arbitrary base images
|
||||
3. **Cleaner Separation**: Clear distinction between OpenHands dependencies and the base image
|
||||
4. **Simplified Maintenance**: Easier to update dependencies independently of the base image
|
||||
5. **Reduced Duplication**: Avoids rebuilding the same dependencies for different base images
|
||||
|
||||
### Challenges and Solutions
|
||||
|
||||
#### 1. Binary Compatibility
|
||||
|
||||
**Challenge**: Binaries compiled in one environment might not work in another due to different system libraries.
|
||||
|
||||
**Solutions**:
|
||||
- Include all necessary shared libraries in the `/openhands` folder
|
||||
- Use wrapper scripts that set up the correct environment (LD_LIBRARY_PATH, etc.)
|
||||
- For critical components like Chromium, include all dependencies in a self-contained manner
|
||||
|
||||
#### 2. Chromium Considerations
|
||||
|
||||
**Challenge**: Chromium has extensive system dependencies and might not work when simply copied.
|
||||
|
||||
**Solutions**:
|
||||
- Use Playwright's self-contained Chromium distribution
|
||||
- Include all Chromium dependencies in the `/openhands` folder
|
||||
- Create a wrapper script that sets up the correct environment variables before launching Chromium
|
||||
- Consider using container-in-container approach for Chromium if necessary
|
||||
|
||||
#### 3. tmux Considerations
|
||||
|
||||
**Challenge**: tmux depends on system libraries like libevent and ncurses.
|
||||
|
||||
**Solutions**:
|
||||
- Include tmux and its dependencies in the `/openhands` folder
|
||||
- Create a wrapper script that sets LD_LIBRARY_PATH to find the included libraries
|
||||
- Consider statically linking tmux to reduce dependencies
|
||||
|
||||
#### 4. Path and Configuration Issues
|
||||
|
||||
**Challenge**: Hardcoded paths and configurations might break when moved.
|
||||
|
||||
**Solutions**:
|
||||
- Use relative paths where possible
|
||||
- Create configuration files at runtime based on the actual environment
|
||||
- Use environment variables to specify paths instead of hardcoding them
|
||||
|
||||
#### 5. Permission Issues
|
||||
|
||||
**Challenge**: Copying files might not preserve permissions correctly.
|
||||
|
||||
**Solutions**:
|
||||
- Explicitly set permissions after copying
|
||||
- Use archive mode when copying to preserve permissions
|
||||
- Handle user/group IDs consistently across images
|
||||
|
||||
## PyInstaller Approach
|
||||
|
||||
### Overview
|
||||
|
||||
The PyInstaller approach further simplifies the runtime building procedure by:
|
||||
|
||||
1. Using PyInstaller to bundle the action_execution_server and all its dependencies into a standalone binary
|
||||
2. Packaging Playwright's Chromium browser in a portable way
|
||||
3. Copying only the binary and browser components to the target runtime image
|
||||
4. Eliminating the need to install Python and other dependencies in the target image
|
||||
|
||||
### Benefits
|
||||
|
||||
1. **Smaller Image Size**: Only the binary and browser components are needed, not all Python dependencies
|
||||
2. **Faster Builds**: No need to install Python and dependencies in the target image
|
||||
3. **Better Compatibility**: The binary should work on any Linux distribution with compatible glibc
|
||||
4. **Simplified Maintenance**: Easier to update the binary independently of the base image
|
||||
|
||||
### Challenges and Solutions
|
||||
|
||||
#### 1. Binary Compatibility
|
||||
|
||||
**Challenge**: Binaries compiled in one environment might not work in another due to different system libraries.
|
||||
|
||||
**Solutions**:
|
||||
- Build the binary in a minimal environment (e.g., Ubuntu 20.04) for maximum compatibility
|
||||
- Include all necessary shared libraries in the binary
|
||||
- Use static linking where possible
|
||||
|
||||
#### 2. Browser Integration
|
||||
|
||||
**Challenge**: Playwright requires Chromium and its dependencies.
|
||||
|
||||
**Solutions**:
|
||||
- Extract Chromium from Playwright's cache
|
||||
- Create a portable browser package
|
||||
- Use wrapper scripts to set up the correct environment
|
||||
|
||||
#### 3. Plugin System
|
||||
|
||||
**Challenge**: The current plugin system might not work with a bundled binary.
|
||||
|
||||
**Solutions**:
|
||||
- Modify the plugin system to work with the binary
|
||||
- Include all plugins in the binary
|
||||
- Implement a mechanism to load plugins at runtime
|
||||
|
||||
### Implementation
|
||||
|
||||
The implementation consists of three main components:
|
||||
|
||||
1. **PyInstaller Binary Builder**: Uses PyInstaller to bundle the action_execution_server and its dependencies
|
||||
2. **Browser Packager**: Extracts and packages the Playwright browser for use with the binary
|
||||
3. **Docker Image Builder**: Creates a minimal Docker image with the binary and browser components
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Create PyInstaller Binary
|
||||
|
||||
1. Create a PyInstaller spec file for action_execution_server.py
|
||||
2. Build the binary using PyInstaller
|
||||
3. Extract and package Playwright's Chromium browser
|
||||
4. Create a new Dockerfile template for the PyInstaller approach
|
||||
|
||||
### Phase 2: Update Runtime Builder
|
||||
|
||||
1. Update `runtime_build.py` to implement the PyInstaller approach
|
||||
2. Add option to build using PyInstaller
|
||||
3. Implement the copying mechanism for the binary and browser components
|
||||
4. Simplify the tagging system for better clarity
|
||||
|
||||
### Phase 3: Integration and Testing
|
||||
|
||||
1. Test with various base images to ensure compatibility
|
||||
2. Benchmark performance improvements
|
||||
3. Verify all components work correctly (browser, bash, plugins, etc.)
|
||||
4. Update documentation
|
||||
|
||||
### Phase 4: Optimization
|
||||
|
||||
1. Analyze and optimize the size of the binary
|
||||
2. Implement selective features based on requirements
|
||||
3. Further improve build performance
|
||||
|
||||
## Technical Details
|
||||
|
||||
### PyInstaller Binary Structure
|
||||
|
||||
```
|
||||
/openhands/
|
||||
├── action-execution-server/ # PyInstaller binary
|
||||
│ ├── action-execution-server # Main executable
|
||||
│ ├── _internal/ # PyInstaller bundled dependencies
|
||||
│ └── ...
|
||||
├── browser/ # Packaged Chromium browser
|
||||
│ ├── ms-playwright/ # Playwright browser files
|
||||
│ └── chromium-wrapper.sh # Wrapper script for Chromium
|
||||
└── lib/ # Additional shared libraries if needed
|
||||
```
|
||||
|
||||
### Dockerfile Template for PyInstaller Approach
|
||||
|
||||
```dockerfile
|
||||
FROM {{ base_image }}
|
||||
|
||||
# Install minimal dependencies required by the base system
|
||||
RUN if command -v apt-get > /dev/null; then \
|
||||
apt-get update && apt-get install -y --no-install-recommends ca-certificates bash && \
|
||||
apt-get clean && rm -rf /var/lib/apt/lists/*; \
|
||||
elif command -v apk > /dev/null; then \
|
||||
apk add --no-cache ca-certificates bash gcompat libstdc++; \
|
||||
elif command -v yum > /dev/null; then \
|
||||
yum install -y ca-certificates bash; \
|
||||
yum clean all; \
|
||||
fi
|
||||
|
||||
# Create the openhands user if it doesn't exist
|
||||
RUN if ! id -u openhands > /dev/null 2>&1; then \
|
||||
if command -v useradd > /dev/null 2>&1; then \
|
||||
groupadd -g 1000 openhands 2>/dev/null || true; \
|
||||
useradd -u 1000 -g 1000 -m -s /bin/bash openhands 2>/dev/null || true; \
|
||||
elif command -v adduser > /dev/null 2>&1; then \
|
||||
addgroup -g 1000 openhands 2>/dev/null || true; \
|
||||
adduser -D -u 1000 -G openhands openhands 2>/dev/null || true; \
|
||||
fi; \
|
||||
fi
|
||||
|
||||
# Create directories
|
||||
RUN mkdir -p /openhands/bin /openhands/lib /workspace && \
|
||||
chown -R openhands:openhands /workspace /openhands 2>/dev/null || true
|
||||
|
||||
# Copy the bundled action execution server
|
||||
COPY ./dist/pyinstaller/action-execution-server /openhands/action-execution-server
|
||||
|
||||
# Copy Playwright browser
|
||||
COPY ./browser /openhands/browser
|
||||
|
||||
# Set environment variables
|
||||
ENV PATH=/openhands/bin:$PATH \
|
||||
LD_LIBRARY_PATH=/openhands/lib:$LD_LIBRARY_PATH \
|
||||
PLAYWRIGHT_BROWSERS_PATH=/openhands/browser \
|
||||
LANG=C.UTF-8 \
|
||||
LC_ALL=C.UTF-8
|
||||
|
||||
# Set the working directory
|
||||
WORKDIR /workspace
|
||||
|
||||
# Switch to the openhands user
|
||||
USER openhands
|
||||
|
||||
# Command to start the action execution server
|
||||
CMD ["/openhands/action-execution-server/action-execution-server", "8000", "/workspace"]
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
To build a runtime image using the PyInstaller approach:
|
||||
|
||||
```bash
|
||||
python runtime_build_pyinstaller.py --base-image ubuntu:22.04
|
||||
```
|
||||
|
||||
This will:
|
||||
1. Build the PyInstaller binary
|
||||
2. Package the Playwright browser
|
||||
3. Create a Docker image with the binary and browser components
|
||||
|
||||
## Conclusion
|
||||
|
||||
Both the two-stage building approach and the PyInstaller approach offer significant benefits in terms of build speed, flexibility, and maintainability. The PyInstaller approach provides additional advantages in terms of image size and build simplicity, but may have challenges with plugin support and binary compatibility.
|
||||
|
||||
By bundling the action_execution_server and its dependencies into a standalone binary, we can achieve an even more efficient runtime building process that supports a wider range of base images while maintaining the security and isolation properties of the traditional approach.
|
||||
@@ -67,55 +67,40 @@ OpenHands' approach to building and managing runtime images ensures efficiency,
|
||||
|
||||
Check out the [relevant code](https://github.com/All-Hands-AI/OpenHands/blob/main/openhands/runtime/utils/runtime_build.py) if you are interested in more details.
|
||||
|
||||
OpenHands uses a two-stage build process for runtime images. See [Runtime Building Approach](runtime_build.md) for details.
|
||||
|
||||
### Image Tagging System
|
||||
|
||||
OpenHands uses a three-tag system for its runtime images to balance reproducibility with flexibility.
|
||||
Tags may be in one of 2 formats:
|
||||
OpenHands uses a simple tagging system for its runtime images:
|
||||
|
||||
- **Versioned Tag**: `oh_v{openhands_version}_{base_image}` (e.g.: `oh_v0.9.9_nikolaik_s_python-nodejs_t_python3.12-nodejs22`)
|
||||
- **Lock Tag**: `oh_v{openhands_version}_{16_digit_lock_hash}` (e.g.: `oh_v0.9.9_1234567890abcdef`)
|
||||
- **Source Tag**: `oh_v{openhands_version}_{16_digit_lock_hash}_{16_digit_source_hash}`
|
||||
(e.g.: `oh_v0.9.9_1234567890abcdef_1234567890abcdef`)
|
||||
- **Dependencies Image**: `oh_deps_v{openhands_version}` (e.g.: `oh_deps_v0.9.9`)
|
||||
- **Runtime Image**: `oh_v{openhands_version}_image_{base_image}_tag_{tag}_{source_hash}`
|
||||
(e.g.: `oh_v0.9.9_image_nikolaik_s_python-nodejs_tag_python3.12-nodejs22_1234abcd`)
|
||||
|
||||
#### Source Tag - Most Specific
|
||||
#### Dependencies Image
|
||||
|
||||
This is the first 16 digits of the MD5 of the directory hash for the source directory. This gives a hash
|
||||
for only the openhands source
|
||||
This image contains all the dependencies needed by OpenHands, installed in the `/openhands` folder. It's built once per OpenHands version and can be reused for multiple runtime images.
|
||||
|
||||
#### Lock Tag
|
||||
#### Runtime Image
|
||||
|
||||
This hash is built from the first 16 digits of the MD5 of:
|
||||
|
||||
- The name of the base image upon which the image was built (e.g.: `nikolaik/python-nodejs:python3.12-nodejs22`)
|
||||
- The content of the `pyproject.toml` included in the image.
|
||||
- The content of the `poetry.lock` included in the image.
|
||||
|
||||
This effectively gives a hash for the dependencies of Openhands independent of the source code.
|
||||
|
||||
#### Versioned Tag - Most Generic
|
||||
|
||||
This tag is a concatenation of openhands version and the base image name (transformed to fit in tag standard).
|
||||
This image is built by copying the `/openhands` folder from the dependencies image into any base image. The tag includes:
|
||||
- The OpenHands version
|
||||
- The base image name (transformed to fit in tag standard)
|
||||
- A hash of the OpenHands source code
|
||||
|
||||
#### Build Process
|
||||
|
||||
When generating an image...
|
||||
When generating an image:
|
||||
|
||||
- **No re-build**: OpenHands first checks whether an image with the same **most specific source tag** exists. If there is such an image,
|
||||
no build is performed - the existing image is used.
|
||||
- **Fastest re-build**: OpenHands next checks whether an image with the **generic lock tag** exists. If there is such an image,
|
||||
OpenHands builds a new image based upon it, bypassing all installation steps (like `poetry install` and
|
||||
`apt-get`) except a final operation to copy the current source code. The new image is tagged with a
|
||||
**source** tag only.
|
||||
- **Ok-ish re-build**: If neither a **source** nor **lock** tag exists, an image will be built based upon the **versioned** tag image.
|
||||
In versioned tag image, most dependencies should already been installed hence saving time.
|
||||
- **Slowest re-build**: If all of the three tags don't exists, a brand new image is built based upon the base
|
||||
image (Which is a slower operation). This new image is tagged with all the **source**, **lock**, and **versioned** tags.
|
||||
1. **Dependencies Image**: If the dependencies image doesn't exist, it's built first
|
||||
2. **Runtime Image**: The runtime image is built by copying from the dependencies image
|
||||
3. **Caching**: If a runtime image with the same tag already exists, it's reused unless force_rebuild is specified
|
||||
|
||||
This tagging approach allows OpenHands to efficiently manage both development and production environments.
|
||||
|
||||
1. Identical source code and Dockerfile always produce the same image (via hash-based tags)
|
||||
2. The system can quickly rebuild images when minor changes occur (by leveraging recent compatible images)
|
||||
3. The **lock** tag (e.g., `runtime:oh_v0.9.3_1234567890abcdef`) always points to the latest build for a particular base image, dependency, and OpenHands version combination
|
||||
This approach offers several advantages:
|
||||
- Faster build times for new base images
|
||||
- Smaller final images (no duplicate dependencies)
|
||||
- Better compatibility with different base images
|
||||
- Easier maintenance and updates
|
||||
|
||||
## Runtime Plugin System
|
||||
|
||||
|
||||
97
docs/modules/usage/architecture/runtime_build.md
Normal file
97
docs/modules/usage/architecture/runtime_build.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# Runtime Building Approach
|
||||
|
||||
This document describes the approach to building OpenHands runtime images.
|
||||
|
||||
## Overview
|
||||
|
||||
The OpenHands runtime building approach uses a two-stage process:
|
||||
|
||||
1. **Dependencies Image**: Build a single image containing all dependencies in the `/openhands` folder
|
||||
2. **Runtime Image**: For any base image, copy the `/openhands` folder from the dependencies image
|
||||
|
||||
This approach offers several advantages:
|
||||
- Faster build times for new base images
|
||||
- Smaller final images (no duplicate dependencies)
|
||||
- Better compatibility with different base images
|
||||
- Easier maintenance and updates
|
||||
|
||||
## How It Works
|
||||
|
||||
### Dependencies Image
|
||||
|
||||
The dependencies image is built once and contains:
|
||||
- All Python dependencies installed via Poetry
|
||||
- Playwright and Chromium
|
||||
- VSCode Server
|
||||
- Tmux and other utilities
|
||||
- Wrapper scripts for compatibility
|
||||
|
||||
Everything is installed into the `/openhands` folder, which is self-contained and can be copied to any base image.
|
||||
|
||||
### Runtime Image
|
||||
|
||||
The runtime image is built by:
|
||||
1. Starting from any base image
|
||||
2. Copying the `/openhands` folder from the dependencies image
|
||||
3. Setting up environment variables to use the tools in `/openhands/bin`
|
||||
4. Installing minimal dependencies required by the base system
|
||||
|
||||
## Wrapper Scripts
|
||||
|
||||
To ensure compatibility across different base images, wrapper scripts are provided in `/openhands/bin`:
|
||||
|
||||
- `oh-tmux`: Wrapper for tmux with proper library paths
|
||||
- `oh-chromium`: Wrapper for Chromium with proper library paths
|
||||
- `oh-playwright`: Wrapper for Playwright
|
||||
- `oh-python`: Wrapper for Python with proper environment
|
||||
- `oh-action-execution-server`: Wrapper for the action execution server
|
||||
|
||||
## Usage
|
||||
|
||||
To build a runtime image:
|
||||
|
||||
```bash
|
||||
# Build the dependencies image (only needed once)
|
||||
python -m openhands.runtime.utils.runtime_build --build_deps_only
|
||||
|
||||
# Build a runtime image using the dependencies image
|
||||
python -m openhands.runtime.utils.runtime_build --base_image <base_image>
|
||||
```
|
||||
|
||||
You can also specify a custom dependencies image:
|
||||
|
||||
```bash
|
||||
python -m openhands.runtime.utils.runtime_build --base_image <base_image> --deps_image <deps_image>
|
||||
```
|
||||
|
||||
## Compatibility Considerations
|
||||
|
||||
### Library Dependencies
|
||||
|
||||
The wrapper scripts ensure that the correct library paths are set, so tools like tmux and Chromium can find their dependencies in `/openhands/lib`.
|
||||
|
||||
### Base Image Requirements
|
||||
|
||||
The base image must have:
|
||||
- Basic shell utilities (bash)
|
||||
- CA certificates for HTTPS connections
|
||||
- Compatible architecture (same as the dependencies image)
|
||||
|
||||
Most minimal base images (Alpine, Debian, Ubuntu) already meet these requirements.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
The implementation consists of:
|
||||
|
||||
1. New Dockerfile templates:
|
||||
- `Dockerfile.deps.j2`: Template for building the dependencies image
|
||||
- `Dockerfile.runtime.j2`: Template for building the runtime image
|
||||
|
||||
2. Wrapper scripts in `/openhands/bin`:
|
||||
- Ensure proper environment variables and library paths
|
||||
- Handle compatibility issues across different base images
|
||||
|
||||
3. Build process:
|
||||
- `BuildFromImageType.DEPS` option for the two-stage build
|
||||
- Functions to build and use the dependencies image
|
||||
- CLI options to control the build process
|
||||
@@ -19,26 +19,37 @@ from openhands.runtime.builder import DockerRuntimeBuilder, RuntimeBuilder
|
||||
|
||||
|
||||
class BuildFromImageType(Enum):
|
||||
SCRATCH = 'scratch' # Slowest: Build from base image (no dependencies are reused)
|
||||
VERSIONED = 'versioned' # Medium speed: Reuse the most recent image with the same base image & OH version (a lot of dependencies are already installed)
|
||||
LOCK = 'lock' # Fastest: Reuse the most recent image with the exact SAME dependencies (lock files)
|
||||
DEPS = 'deps' # Two-stage build: Use a pre-built dependencies image and copy /openhands folder
|
||||
|
||||
|
||||
def get_runtime_image_repo() -> str:
|
||||
return os.getenv('OH_RUNTIME_RUNTIME_IMAGE_REPO', 'ghcr.io/all-hands-ai/runtime')
|
||||
|
||||
|
||||
def get_deps_image_name() -> str:
|
||||
"""Get the name of the dependencies image.
|
||||
|
||||
Returns:
|
||||
str: The name of the dependencies image
|
||||
"""
|
||||
repo = get_runtime_image_repo()
|
||||
deps_tag = f'oh_deps_v{oh_version}'
|
||||
return f'{repo}:{deps_tag}'
|
||||
|
||||
|
||||
def _generate_dockerfile(
|
||||
base_image: str,
|
||||
build_from: BuildFromImageType = BuildFromImageType.SCRATCH,
|
||||
build_from: BuildFromImageType = BuildFromImageType.DEPS,
|
||||
extra_deps: str | None = None,
|
||||
deps_image: str | None = None,
|
||||
) -> str:
|
||||
"""Generate the Dockerfile content for the runtime image based on the base image.
|
||||
|
||||
Parameters:
|
||||
- base_image (str): The base image provided for the runtime image
|
||||
- build_from (BuildFromImageType): The build method for the runtime image.
|
||||
- extra_deps (str):
|
||||
- extra_deps (str): Extra dependencies to install
|
||||
- deps_image (str): The dependencies image to use (only for DEPS build method)
|
||||
|
||||
Returns:
|
||||
- str: The resulting Dockerfile content
|
||||
@@ -48,14 +59,14 @@ def _generate_dockerfile(
|
||||
searchpath=os.path.join(os.path.dirname(__file__), 'runtime_templates')
|
||||
)
|
||||
)
|
||||
template = env.get_template('Dockerfile.j2')
|
||||
|
||||
|
||||
template = env.get_template('Dockerfile.runtime.j2')
|
||||
dockerfile_content = template.render(
|
||||
base_image=base_image,
|
||||
build_from_scratch=build_from == BuildFromImageType.SCRATCH,
|
||||
build_from_versioned=build_from == BuildFromImageType.VERSIONED,
|
||||
deps_image=deps_image or get_deps_image_name(),
|
||||
extra_deps=extra_deps if extra_deps is not None else '',
|
||||
)
|
||||
|
||||
return dockerfile_content
|
||||
|
||||
|
||||
@@ -102,8 +113,7 @@ def get_runtime_image_repo_and_tag(base_image: str) -> tuple[str, str]:
|
||||
return get_runtime_image_repo(), new_tag
|
||||
|
||||
|
||||
def build_runtime_image(
|
||||
base_image: str,
|
||||
def build_deps_image(
|
||||
runtime_builder: RuntimeBuilder,
|
||||
platform: str | None = None,
|
||||
extra_deps: str | None = None,
|
||||
@@ -112,152 +122,92 @@ def build_runtime_image(
|
||||
force_rebuild: bool = False,
|
||||
extra_build_args: list[str] | None = None,
|
||||
) -> str:
|
||||
"""Prepares the final docker build folder.
|
||||
|
||||
If dry_run is False, it will also build the OpenHands runtime Docker image using the docker build folder.
|
||||
"""Build the dependencies image containing all OpenHands dependencies.
|
||||
|
||||
Parameters:
|
||||
- base_image (str): The name of the base Docker image to use
|
||||
- runtime_builder (RuntimeBuilder): The runtime builder to use
|
||||
- platform (str): The target platform for the build (e.g. linux/amd64, linux/arm64)
|
||||
- extra_deps (str):
|
||||
- extra_deps (str): Extra dependencies to install
|
||||
- build_folder (str): The directory to use for the build. If not provided a temporary directory will be used
|
||||
- dry_run (bool): if True, it will only ready the build folder. It will not actually build the Docker image
|
||||
- force_rebuild (bool): if True, it will create the Dockerfile which uses the base_image
|
||||
- extra_build_args (List[str]): Additional build arguments to pass to the builder
|
||||
|
||||
Returns:
|
||||
- str: <image_repo>:<MD5 hash>. Where MD5 hash is the hash of the docker build folder
|
||||
|
||||
See https://docs.all-hands.dev/modules/usage/architecture/runtime for more details.
|
||||
- str: The name of the dependencies image
|
||||
"""
|
||||
if build_folder is None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
result = build_runtime_image_in_folder(
|
||||
base_image=base_image,
|
||||
result = build_deps_image_in_folder(
|
||||
runtime_builder=runtime_builder,
|
||||
build_folder=Path(temp_dir),
|
||||
extra_deps=extra_deps,
|
||||
dry_run=dry_run,
|
||||
force_rebuild=force_rebuild,
|
||||
platform=platform,
|
||||
extra_build_args=extra_build_args,
|
||||
)
|
||||
return result
|
||||
|
||||
result = build_runtime_image_in_folder(
|
||||
base_image=base_image,
|
||||
result = build_deps_image_in_folder(
|
||||
runtime_builder=runtime_builder,
|
||||
build_folder=Path(build_folder),
|
||||
extra_deps=extra_deps,
|
||||
dry_run=dry_run,
|
||||
force_rebuild=force_rebuild,
|
||||
platform=platform,
|
||||
extra_build_args=extra_build_args,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def build_runtime_image_in_folder(
|
||||
base_image: str,
|
||||
def build_deps_image_in_folder(
|
||||
runtime_builder: RuntimeBuilder,
|
||||
build_folder: Path,
|
||||
extra_deps: str | None,
|
||||
dry_run: bool,
|
||||
force_rebuild: bool,
|
||||
platform: str | None = None,
|
||||
extra_build_args: list[str] | None = None,
|
||||
) -> str:
|
||||
runtime_image_repo, _ = get_runtime_image_repo_and_tag(base_image)
|
||||
lock_tag = f'oh_v{oh_version}_{get_hash_for_lock_files(base_image)}'
|
||||
versioned_tag = (
|
||||
# truncate the base image to 96 characters to fit in the tag max length (128 characters)
|
||||
f'oh_v{oh_version}_{get_tag_for_versioned_image(base_image)}'
|
||||
"""Prepares the build folder and builds the dependencies image.
|
||||
|
||||
Parameters:
|
||||
- runtime_builder (RuntimeBuilder): The runtime builder to use
|
||||
- build_folder (Path): The directory to use for the build
|
||||
- extra_deps (str): Extra dependencies to install
|
||||
- dry_run (bool): if True, it will only ready the build folder. It will not actually build the Docker image
|
||||
- platform (str): The target platform for the build (e.g. linux/amd64, linux/arm64)
|
||||
- extra_build_args (List[str]): Additional build arguments to pass to the builder
|
||||
|
||||
Returns:
|
||||
- str: The name of the dependencies image
|
||||
"""
|
||||
deps_image_name = get_deps_image_name()
|
||||
logger.info(f'Building dependencies image: {deps_image_name}')
|
||||
|
||||
# Create a Dockerfile for the dependencies image
|
||||
env = Environment(
|
||||
loader=FileSystemLoader(
|
||||
searchpath=os.path.join(os.path.dirname(__file__), 'runtime_templates')
|
||||
)
|
||||
)
|
||||
template = env.get_template('Dockerfile.deps.j2')
|
||||
dockerfile_content = template.render(
|
||||
extra_deps=extra_deps if extra_deps is not None else '',
|
||||
)
|
||||
versioned_image_name = f'{runtime_image_repo}:{versioned_tag}'
|
||||
source_tag = f'{lock_tag}_{get_hash_for_source_files()}'
|
||||
hash_image_name = f'{runtime_image_repo}:{source_tag}'
|
||||
|
||||
logger.info(f'Building image: {hash_image_name}')
|
||||
if force_rebuild:
|
||||
logger.debug(
|
||||
f'Force rebuild: [{runtime_image_repo}:{source_tag}] from scratch.'
|
||||
)
|
||||
prep_build_folder(
|
||||
build_folder,
|
||||
base_image,
|
||||
build_from=BuildFromImageType.SCRATCH,
|
||||
extra_deps=extra_deps,
|
||||
)
|
||||
if not dry_run:
|
||||
_build_sandbox_image(
|
||||
build_folder,
|
||||
runtime_builder,
|
||||
runtime_image_repo,
|
||||
source_tag,
|
||||
lock_tag,
|
||||
versioned_tag,
|
||||
platform,
|
||||
extra_build_args=extra_build_args,
|
||||
)
|
||||
return hash_image_name
|
||||
with open(Path(build_folder, 'Dockerfile'), 'w') as file:
|
||||
file.write(dockerfile_content)
|
||||
|
||||
lock_image_name = f'{runtime_image_repo}:{lock_tag}'
|
||||
build_from = BuildFromImageType.SCRATCH
|
||||
# Copy wrapper scripts
|
||||
wrappers_dir = os.path.join(os.path.dirname(__file__), 'wrappers')
|
||||
target_dir = os.path.join(build_folder, 'code', 'openhands', 'runtime', 'utils', 'wrappers')
|
||||
os.makedirs(target_dir, exist_ok=True)
|
||||
for file in os.listdir(wrappers_dir):
|
||||
shutil.copy(os.path.join(wrappers_dir, file), os.path.join(target_dir, file))
|
||||
|
||||
# If the exact image already exists, we do not need to build it
|
||||
if runtime_builder.image_exists(hash_image_name, False):
|
||||
logger.debug(f'Reusing Image [{hash_image_name}]')
|
||||
return hash_image_name
|
||||
|
||||
# We look for an existing image that shares the same lock_tag. If such an image exists, we
|
||||
# can use it as the base image for the build and just copy source files. This makes the build
|
||||
# much faster.
|
||||
if runtime_builder.image_exists(lock_image_name):
|
||||
logger.debug(f'Build [{hash_image_name}] from lock image [{lock_image_name}]')
|
||||
build_from = BuildFromImageType.LOCK
|
||||
base_image = lock_image_name
|
||||
elif runtime_builder.image_exists(versioned_image_name):
|
||||
logger.info(
|
||||
f'Build [{hash_image_name}] from versioned image [{versioned_image_name}]'
|
||||
)
|
||||
build_from = BuildFromImageType.VERSIONED
|
||||
base_image = versioned_image_name
|
||||
else:
|
||||
logger.debug(f'Build [{hash_image_name}] from scratch')
|
||||
|
||||
prep_build_folder(build_folder, base_image, build_from, extra_deps)
|
||||
if not dry_run:
|
||||
_build_sandbox_image(
|
||||
build_folder,
|
||||
runtime_builder,
|
||||
runtime_image_repo,
|
||||
source_tag=source_tag,
|
||||
lock_tag=lock_tag,
|
||||
# Only tag the versioned image if we are building from scratch.
|
||||
# This avoids too much layers when you lay one image on top of another multiple times
|
||||
versioned_tag=versioned_tag
|
||||
if build_from == BuildFromImageType.SCRATCH
|
||||
else None,
|
||||
platform=platform,
|
||||
extra_build_args=extra_build_args,
|
||||
)
|
||||
|
||||
return hash_image_name
|
||||
|
||||
|
||||
def prep_build_folder(
|
||||
build_folder: Path,
|
||||
base_image: str,
|
||||
build_from: BuildFromImageType,
|
||||
extra_deps: str | None,
|
||||
) -> None:
|
||||
# Copy project files
|
||||
# Copy the source code to directory. It will end up in build_folder/code
|
||||
# If package is not found, build from source code
|
||||
openhands_source_dir = Path(openhands.__file__).parent
|
||||
project_root = openhands_source_dir.parent
|
||||
logger.debug(f'Building source distribution using project root: {project_root}')
|
||||
|
||||
|
||||
# Copy the 'openhands' directory (Source code)
|
||||
shutil.copytree(
|
||||
openhands_source_dir,
|
||||
@@ -268,6 +218,7 @@ def prep_build_folder(
|
||||
'*.pyc',
|
||||
'*.md',
|
||||
),
|
||||
dirs_exist_ok=True,
|
||||
)
|
||||
|
||||
# Copy pyproject.toml and poetry.lock files
|
||||
@@ -275,13 +226,163 @@ def prep_build_folder(
|
||||
src = Path(openhands_source_dir, file)
|
||||
if not src.exists():
|
||||
src = Path(project_root, file)
|
||||
shutil.copy2(src, Path(build_folder, 'code', file))
|
||||
if src.exists():
|
||||
shutil.copy2(src, Path(build_folder, 'code', file))
|
||||
|
||||
if not dry_run:
|
||||
# Build the dependencies image
|
||||
runtime_builder.build_image(
|
||||
path=str(build_folder),
|
||||
tag=deps_image_name,
|
||||
platform=platform,
|
||||
extra_build_args=extra_build_args,
|
||||
)
|
||||
|
||||
return deps_image_name
|
||||
|
||||
|
||||
def build_runtime_image(
|
||||
base_image: str,
|
||||
runtime_builder: RuntimeBuilder,
|
||||
platform: str | None = None,
|
||||
extra_deps: str | None = None,
|
||||
build_folder: str | None = None,
|
||||
dry_run: bool = False,
|
||||
force_rebuild: bool = False,
|
||||
extra_build_args: List[str] | None = None,
|
||||
deps_image: str | None = None,
|
||||
) -> str:
|
||||
"""Prepares the final docker build folder.
|
||||
|
||||
If dry_run is False, it will also build the OpenHands runtime Docker image using the docker build folder.
|
||||
|
||||
Parameters:
|
||||
- base_image (str): The name of the base Docker image to use
|
||||
- runtime_builder (RuntimeBuilder): The runtime builder to use
|
||||
- platform (str): The target platform for the build (e.g. linux/amd64, linux/arm64)
|
||||
- extra_deps (str): Extra dependencies to install
|
||||
- build_folder (str): The directory to use for the build. If not provided a temporary directory will be used
|
||||
- dry_run (bool): if True, it will only ready the build folder. It will not actually build the Docker image
|
||||
- force_rebuild (bool): if True, it will force rebuilding even if the image already exists
|
||||
- extra_build_args (List[str]): Additional build arguments to pass to the builder
|
||||
- deps_image (str): The dependencies image to use (if None, will use the default)
|
||||
|
||||
Returns:
|
||||
- str: <image_repo>:<MD5 hash>. Where MD5 hash is the hash of the docker build folder
|
||||
|
||||
See https://docs.all-hands.dev/modules/usage/architecture/runtime_build for more details.
|
||||
"""
|
||||
# If using the dependencies image approach, first ensure the dependencies image exists
|
||||
if deps_image is None:
|
||||
deps_image = get_deps_image_name()
|
||||
|
||||
# Check if the dependencies image exists
|
||||
try:
|
||||
runtime_builder.get_image(deps_image)
|
||||
logger.info(f'Using existing dependencies image: {deps_image}')
|
||||
except Exception:
|
||||
# Dependencies image doesn't exist, build it
|
||||
logger.info(f'Dependencies image {deps_image} not found. Building it...')
|
||||
deps_image = build_deps_image(
|
||||
runtime_builder=runtime_builder,
|
||||
platform=platform,
|
||||
extra_deps=extra_deps,
|
||||
build_folder=build_folder,
|
||||
dry_run=dry_run,
|
||||
extra_build_args=extra_build_args,
|
||||
)
|
||||
|
||||
if build_folder is None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
result = build_runtime_image_from_deps(
|
||||
base_image=base_image,
|
||||
runtime_builder=runtime_builder,
|
||||
deps_image=deps_image,
|
||||
build_folder=Path(temp_dir),
|
||||
extra_deps=extra_deps,
|
||||
dry_run=dry_run,
|
||||
platform=platform,
|
||||
extra_build_args=extra_build_args,
|
||||
force_rebuild=force_rebuild,
|
||||
)
|
||||
return result
|
||||
|
||||
result = build_runtime_image_from_deps(
|
||||
base_image=base_image,
|
||||
runtime_builder=runtime_builder,
|
||||
deps_image=deps_image,
|
||||
build_folder=Path(build_folder),
|
||||
extra_deps=extra_deps,
|
||||
dry_run=dry_run,
|
||||
platform=platform,
|
||||
extra_build_args=extra_build_args,
|
||||
force_rebuild=force_rebuild,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def prep_build_folder(
|
||||
build_folder: Path,
|
||||
base_image: str,
|
||||
build_from: BuildFromImageType,
|
||||
extra_deps: str | None,
|
||||
deps_image: str | None = None,
|
||||
) -> None:
|
||||
"""Prepare the build folder with necessary files.
|
||||
|
||||
Parameters:
|
||||
- build_folder (Path): The directory to use for the build
|
||||
- base_image (str): The base image to use
|
||||
- build_from (BuildFromImageType): The build method to use
|
||||
- extra_deps (str): Extra dependencies to install
|
||||
- deps_image (str): The dependencies image to use (only for DEPS build method)
|
||||
"""
|
||||
# Copy the source code to directory. It will end up in build_folder/code
|
||||
openhands_source_dir = Path(openhands.__file__).parent
|
||||
project_root = openhands_source_dir.parent
|
||||
logger.debug(f'Building source distribution using project root: {project_root}')
|
||||
|
||||
# For DEPS build method, we only need to copy the wrapper scripts
|
||||
if build_from == BuildFromImageType.DEPS:
|
||||
# Copy the 'openhands' directory (Source code)
|
||||
os.makedirs(os.path.join(build_folder, 'code', 'openhands'), exist_ok=True)
|
||||
shutil.copytree(
|
||||
openhands_source_dir,
|
||||
Path(build_folder, 'code', 'openhands'),
|
||||
ignore=shutil.ignore_patterns(
|
||||
'.*/',
|
||||
'__pycache__/',
|
||||
'*.pyc',
|
||||
'*.md',
|
||||
),
|
||||
dirs_exist_ok=True,
|
||||
)
|
||||
else:
|
||||
# Copy the 'openhands' directory (Source code)
|
||||
shutil.copytree(
|
||||
openhands_source_dir,
|
||||
Path(build_folder, 'code', 'openhands'),
|
||||
ignore=shutil.ignore_patterns(
|
||||
'.*/',
|
||||
'__pycache__/',
|
||||
'*.pyc',
|
||||
'*.md',
|
||||
),
|
||||
)
|
||||
|
||||
# Copy pyproject.toml and poetry.lock files
|
||||
for file in ['pyproject.toml', 'poetry.lock']:
|
||||
src = Path(openhands_source_dir, file)
|
||||
if not src.exists():
|
||||
src = Path(project_root, file)
|
||||
shutil.copy2(src, Path(build_folder, 'code', file))
|
||||
|
||||
# Create a Dockerfile and write it to build_folder
|
||||
dockerfile_content = _generate_dockerfile(
|
||||
base_image,
|
||||
build_from=build_from,
|
||||
extra_deps=extra_deps,
|
||||
deps_image=deps_image,
|
||||
)
|
||||
dockerfile_path = Path(build_folder, 'Dockerfile')
|
||||
with open(str(dockerfile_path), 'w') as f:
|
||||
@@ -323,51 +424,83 @@ def get_tag_for_versioned_image(base_image: str) -> str:
|
||||
|
||||
|
||||
def get_hash_for_source_files() -> str:
|
||||
"""Get a hash of the source files.
|
||||
|
||||
Returns:
|
||||
- str: The hash of the source files
|
||||
"""
|
||||
openhands_source_dir = Path(openhands.__file__).parent
|
||||
dir_hash = dirhash(
|
||||
source_hash = dirhash(
|
||||
openhands_source_dir,
|
||||
'md5',
|
||||
ignore=[
|
||||
'.*/', # hidden directories
|
||||
'__pycache__/',
|
||||
'*.pyc',
|
||||
],
|
||||
ignore_hidden=True,
|
||||
excluded_extensions=['.pyc', '.md'],
|
||||
)
|
||||
# We get away with truncation because we want something that is unique
|
||||
# rather than something that is cryptographically secure
|
||||
result = truncate_hash(dir_hash)
|
||||
return result
|
||||
return source_hash[:8]
|
||||
|
||||
|
||||
def _build_sandbox_image(
|
||||
build_folder: Path,
|
||||
def build_runtime_image_from_deps(
|
||||
base_image: str,
|
||||
runtime_builder: RuntimeBuilder,
|
||||
runtime_image_repo: str,
|
||||
source_tag: str,
|
||||
lock_tag: str,
|
||||
versioned_tag: str | None,
|
||||
deps_image: str,
|
||||
build_folder: Path,
|
||||
extra_deps: str | None = None,
|
||||
dry_run: bool = False,
|
||||
platform: str | None = None,
|
||||
extra_build_args: list[str] | None = None,
|
||||
force_rebuild: bool = False,
|
||||
) -> str:
|
||||
"""Build and tag the sandbox image. The image will be tagged with all tags that do not yet exist."""
|
||||
names = [
|
||||
f'{runtime_image_repo}:{source_tag}',
|
||||
f'{runtime_image_repo}:{lock_tag}',
|
||||
]
|
||||
if versioned_tag is not None:
|
||||
names.append(f'{runtime_image_repo}:{versioned_tag}')
|
||||
names = [name for name in names if not runtime_builder.image_exists(name, False)]
|
||||
"""Build a runtime image using the dependencies image.
|
||||
|
||||
image_name = runtime_builder.build(
|
||||
path=str(build_folder),
|
||||
tags=names,
|
||||
platform=platform,
|
||||
extra_build_args=extra_build_args,
|
||||
Parameters:
|
||||
- base_image (str): The base image to use
|
||||
- runtime_builder (RuntimeBuilder): The runtime builder to use
|
||||
- deps_image (str): The dependencies image to use
|
||||
- build_folder (Path): The directory to use for the build
|
||||
- extra_deps (str): Extra dependencies to install
|
||||
- dry_run (bool): if True, it will only ready the build folder. It will not actually build the Docker image
|
||||
- platform (str): The target platform for the build (e.g. linux/amd64, linux/arm64)
|
||||
- extra_build_args (List[str]): Additional build arguments to pass to the builder
|
||||
- force_rebuild (bool): if True, it will force rebuilding even if the image already exists
|
||||
|
||||
Returns:
|
||||
- str: The name of the runtime image
|
||||
"""
|
||||
runtime_image_repo, runtime_image_tag = get_runtime_image_repo_and_tag(base_image)
|
||||
source_tag = f'{runtime_image_tag}_{get_hash_for_source_files()}'
|
||||
runtime_image_name = f'{runtime_image_repo}:{source_tag}'
|
||||
|
||||
logger.info(f'Building runtime image: {runtime_image_name}')
|
||||
|
||||
# Check if the image already exists
|
||||
if not force_rebuild:
|
||||
try:
|
||||
runtime_builder.get_image(runtime_image_name)
|
||||
logger.info(f'Runtime image {runtime_image_name} already exists. Reusing it.')
|
||||
return runtime_image_name
|
||||
except Exception:
|
||||
logger.info(f'Runtime image {runtime_image_name} not found. Building it...')
|
||||
|
||||
# Create a Dockerfile for the runtime image
|
||||
dockerfile_content = _generate_dockerfile(
|
||||
base_image=base_image,
|
||||
deps_image=deps_image,
|
||||
extra_deps=extra_deps,
|
||||
)
|
||||
if not image_name:
|
||||
raise AgentRuntimeBuildError(f'Build failed for image {names}')
|
||||
|
||||
return image_name
|
||||
with open(Path(build_folder, 'Dockerfile'), 'w') as file:
|
||||
file.write(dockerfile_content)
|
||||
|
||||
if not dry_run:
|
||||
# Build the runtime image
|
||||
runtime_builder.build_image(
|
||||
path=str(build_folder),
|
||||
tag=runtime_image_name,
|
||||
platform=platform,
|
||||
extra_build_args=extra_build_args,
|
||||
)
|
||||
|
||||
return runtime_image_name
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
@@ -378,8 +511,22 @@ if __name__ == '__main__':
|
||||
parser.add_argument('--build_folder', type=str, default=None)
|
||||
parser.add_argument('--force_rebuild', action='store_true', default=False)
|
||||
parser.add_argument('--platform', type=str, default=None)
|
||||
parser.add_argument('--deps_image', type=str, default=None,
|
||||
help='The dependencies image to use')
|
||||
parser.add_argument('--build_deps_only', action='store_true', default=False,
|
||||
help='Only build the dependencies image')
|
||||
args = parser.parse_args()
|
||||
|
||||
# If only building the dependencies image
|
||||
if args.build_deps_only:
|
||||
deps_image = build_deps_image(
|
||||
runtime_builder=DockerRuntimeBuilder(docker.from_env()),
|
||||
build_folder=args.build_folder,
|
||||
platform=args.platform,
|
||||
)
|
||||
logger.info(f'Dependencies image built: {deps_image}')
|
||||
exit(0)
|
||||
|
||||
if args.build_folder is not None:
|
||||
# If a build_folder is provided, we do not actually build the Docker image. We copy the necessary source code
|
||||
# and create a Dockerfile dynamically and place it in the build_folder only. This allows the Docker image to
|
||||
@@ -409,6 +556,7 @@ if __name__ == '__main__':
|
||||
dry_run=True,
|
||||
force_rebuild=args.force_rebuild,
|
||||
platform=args.platform,
|
||||
deps_image=args.deps_image,
|
||||
)
|
||||
|
||||
_runtime_image_repo, runtime_image_source_tag = (
|
||||
@@ -444,6 +592,10 @@ if __name__ == '__main__':
|
||||
logger.debug('Building image in a temporary folder')
|
||||
docker_builder = DockerRuntimeBuilder(docker.from_env())
|
||||
image_name = build_runtime_image(
|
||||
args.base_image, docker_builder, platform=args.platform
|
||||
args.base_image,
|
||||
docker_builder,
|
||||
platform=args.platform,
|
||||
force_rebuild=args.force_rebuild,
|
||||
deps_image=args.deps_image,
|
||||
)
|
||||
logger.debug(f'\nBuilt image: {image_name}\n')
|
||||
logger.debug(f'\nBuilt image: {image_name}\n')
|
||||
@@ -1,189 +0,0 @@
|
||||
FROM {{ base_image }}
|
||||
|
||||
# Shared environment variables
|
||||
ENV POETRY_VIRTUALENVS_PATH=/openhands/poetry \
|
||||
MAMBA_ROOT_PREFIX=/openhands/micromamba \
|
||||
LANG=C.UTF-8 \
|
||||
LC_ALL=C.UTF-8 \
|
||||
EDITOR=code \
|
||||
VISUAL=code \
|
||||
GIT_EDITOR="code --wait" \
|
||||
OPENVSCODE_SERVER_ROOT=/openhands/.openvscode-server
|
||||
|
||||
{% macro setup_base_system() %}
|
||||
|
||||
# Install base system dependencies
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
wget curl ca-certificates sudo apt-utils git jq tmux build-essential ripgrep \
|
||||
{%- if 'ubuntu' in base_image and (base_image.endswith(':latest') or base_image.endswith(':24.04')) -%}
|
||||
libgl1 \
|
||||
{%- else %}
|
||||
libgl1-mesa-glx \
|
||||
{% endif -%}
|
||||
libasound2-plugins libatomic1 && \
|
||||
{%- if 'ubuntu' in base_image -%}
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \
|
||||
TZ=Etc/UTC DEBIAN_FRONTEND=noninteractive \
|
||||
apt-get install -y --no-install-recommends nodejs python3.12 python-is-python3 python3-pip python3.12-venv && \
|
||||
corepack enable yarn && \
|
||||
{% endif -%}
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
{% if 'ubuntu' in base_image %}
|
||||
RUN ln -s "$(dirname $(which node))/corepack" /usr/local/bin/corepack && \
|
||||
npm install -g corepack && corepack enable yarn && \
|
||||
curl -fsSL --compressed https://install.python-poetry.org | python -
|
||||
{% endif %}
|
||||
|
||||
# Install uv (required by MCP)
|
||||
RUN curl -LsSf https://astral.sh/uv/install.sh | env UV_INSTALL_DIR="/openhands/bin" sh
|
||||
# Add /openhands/bin to PATH
|
||||
ENV PATH="/openhands/bin:${PATH}"
|
||||
|
||||
# Remove UID 1000 named pn or ubuntu, so the 'openhands' user can be created from ubuntu hosts
|
||||
RUN (if getent passwd 1000 | grep -q pn; then userdel pn; fi) && \
|
||||
(if getent passwd 1000 | grep -q ubuntu; then userdel ubuntu; fi)
|
||||
|
||||
|
||||
# Create necessary directories
|
||||
RUN mkdir -p /openhands && \
|
||||
mkdir -p /openhands/logs && \
|
||||
mkdir -p /openhands/poetry
|
||||
|
||||
{% endmacro %}
|
||||
|
||||
{% macro setup_vscode_server() %}
|
||||
# Reference:
|
||||
# 1. https://github.com/gitpod-io/openvscode-server
|
||||
# 2. https://github.com/gitpod-io/openvscode-releases
|
||||
|
||||
# Setup VSCode Server
|
||||
ARG RELEASE_TAG="openvscode-server-v1.98.2"
|
||||
ARG RELEASE_ORG="gitpod-io"
|
||||
# ARG USERNAME=openvscode-server
|
||||
# ARG USER_UID=1000
|
||||
# ARG USER_GID=1000
|
||||
|
||||
RUN if [ -z "${RELEASE_TAG}" ]; then \
|
||||
echo "The RELEASE_TAG build arg must be set." >&2 && \
|
||||
exit 1; \
|
||||
fi && \
|
||||
arch=$(uname -m) && \
|
||||
if [ "${arch}" = "x86_64" ]; then \
|
||||
arch="x64"; \
|
||||
elif [ "${arch}" = "aarch64" ]; then \
|
||||
arch="arm64"; \
|
||||
elif [ "${arch}" = "armv7l" ]; then \
|
||||
arch="armhf"; \
|
||||
fi && \
|
||||
wget https://github.com/${RELEASE_ORG}/openvscode-server/releases/download/${RELEASE_TAG}/${RELEASE_TAG}-linux-${arch}.tar.gz && \
|
||||
tar -xzf ${RELEASE_TAG}-linux-${arch}.tar.gz && \
|
||||
if [ -d "${OPENVSCODE_SERVER_ROOT}" ]; then rm -rf "${OPENVSCODE_SERVER_ROOT}"; fi && \
|
||||
mv ${RELEASE_TAG}-linux-${arch} ${OPENVSCODE_SERVER_ROOT} && \
|
||||
cp ${OPENVSCODE_SERVER_ROOT}/bin/remote-cli/openvscode-server ${OPENVSCODE_SERVER_ROOT}/bin/remote-cli/code && \
|
||||
rm -f ${RELEASE_TAG}-linux-${arch}.tar.gz
|
||||
|
||||
|
||||
|
||||
{% endmacro %}
|
||||
|
||||
{% macro install_vscode_extensions() %}
|
||||
# Install our custom extension
|
||||
RUN mkdir -p ${OPENVSCODE_SERVER_ROOT}/extensions/openhands-hello-world && \
|
||||
cp -r /openhands/code/openhands/runtime/utils/vscode-extensions/hello-world/* ${OPENVSCODE_SERVER_ROOT}/extensions/openhands-hello-world/
|
||||
|
||||
RUN mkdir -p ${OPENVSCODE_SERVER_ROOT}/extensions/openhands-memory-monitor && \
|
||||
cp -r /openhands/code/openhands/runtime/utils/vscode-extensions/memory-monitor/* ${OPENVSCODE_SERVER_ROOT}/extensions/openhands-memory-monitor/
|
||||
|
||||
# Some extension dirs are removed because they trigger false positives in vulnerability scans.
|
||||
RUN rm -rf ${OPENVSCODE_SERVER_ROOT}/extensions/{handlebars,pug,json,diff,grunt,ini,npm}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro install_dependencies() %}
|
||||
# Install all dependencies
|
||||
WORKDIR /openhands/code
|
||||
|
||||
# Configure micromamba and poetry
|
||||
RUN /openhands/micromamba/bin/micromamba config set changeps1 False && \
|
||||
/openhands/micromamba/bin/micromamba run -n openhands poetry config virtualenvs.path /openhands/poetry && \
|
||||
/openhands/micromamba/bin/micromamba run -n openhands poetry env use python3.12
|
||||
|
||||
# Install project dependencies in smaller chunks
|
||||
RUN /openhands/micromamba/bin/micromamba run -n openhands poetry install --only main --no-interaction --no-root
|
||||
|
||||
RUN /openhands/micromamba/bin/micromamba run -n openhands poetry install --only runtime --no-interaction --no-root
|
||||
|
||||
# Install playwright and its dependencies
|
||||
RUN apt-get update && \
|
||||
/openhands/micromamba/bin/micromamba run -n openhands poetry run pip install playwright && \
|
||||
/openhands/micromamba/bin/micromamba run -n openhands poetry run playwright install --with-deps chromium
|
||||
|
||||
# Set environment variables and permissions
|
||||
RUN /openhands/micromamba/bin/micromamba run -n openhands poetry run python -c "import sys; print('OH_INTERPRETER_PATH=' + sys.executable)" >> /etc/environment && \
|
||||
chmod -R g+rws /openhands/poetry && \
|
||||
mkdir -p /openhands/workspace && chmod -R g+rws,o+rw /openhands/workspace
|
||||
|
||||
# Clear caches
|
||||
RUN /openhands/micromamba/bin/micromamba run -n openhands poetry cache clear --all . -n && \
|
||||
apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* && \
|
||||
/openhands/micromamba/bin/micromamba clean --all
|
||||
|
||||
{% endmacro %}
|
||||
|
||||
{% if build_from_scratch %}
|
||||
# ================================================================
|
||||
# START: Build Runtime Image from Scratch
|
||||
# ================================================================
|
||||
# This is used in cases where the base image is something more generic like nikolaik/python-nodejs
|
||||
# rather than the current OpenHands release
|
||||
|
||||
{{ setup_base_system() }}
|
||||
|
||||
# Install micromamba
|
||||
RUN mkdir -p /openhands/micromamba/bin && \
|
||||
/bin/bash -c "PREFIX_LOCATION=/openhands/micromamba BIN_FOLDER=/openhands/micromamba/bin INIT_YES=no CONDA_FORGE_YES=yes $(curl -L https://micro.mamba.pm/install.sh)" && \
|
||||
/openhands/micromamba/bin/micromamba config remove channels defaults && \
|
||||
/openhands/micromamba/bin/micromamba config list
|
||||
|
||||
# Create the openhands virtual environment and install poetry and python
|
||||
RUN /openhands/micromamba/bin/micromamba create -n openhands -y && \
|
||||
/openhands/micromamba/bin/micromamba install -n openhands -c conda-forge poetry python=3.12 -y
|
||||
|
||||
# Create a clean openhands directory including only the pyproject.toml, poetry.lock and openhands/__init__.py
|
||||
RUN \
|
||||
if [ -d /openhands/code ]; then rm -rf /openhands/code; fi && \
|
||||
mkdir -p /openhands/code/openhands && \
|
||||
touch /openhands/code/openhands/__init__.py
|
||||
|
||||
COPY ./code/pyproject.toml ./code/poetry.lock /openhands/code/
|
||||
|
||||
{{ install_dependencies() }}
|
||||
|
||||
# ================================================================
|
||||
# END: Build Runtime Image from Scratch
|
||||
# ================================================================
|
||||
{% endif %}
|
||||
|
||||
# ================================================================
|
||||
# Copy Project source files
|
||||
# ================================================================
|
||||
RUN if [ -d /openhands/code/openhands ]; then rm -rf /openhands/code/openhands; fi
|
||||
COPY ./code/pyproject.toml ./code/poetry.lock /openhands/code/
|
||||
|
||||
COPY ./code/openhands /openhands/code/openhands
|
||||
RUN chmod a+rwx /openhands/code/openhands/__init__.py
|
||||
|
||||
{{ setup_vscode_server() }}
|
||||
|
||||
# ================================================================
|
||||
# END: Build from versioned image
|
||||
# ================================================================
|
||||
{% if build_from_versioned %}
|
||||
{{ install_dependencies() }}
|
||||
{{ install_vscode_extensions() }}
|
||||
{% endif %}
|
||||
|
||||
# Install extra dependencies if specified
|
||||
{% if extra_deps %}RUN {{ extra_deps }} {% endif %}
|
||||
@@ -0,0 +1,49 @@
|
||||
FROM {{ base_image }}
|
||||
|
||||
# Install minimal dependencies required by the base system
|
||||
RUN if command -v apt-get > /dev/null; then \
|
||||
apt-get update && apt-get install -y --no-install-recommends ca-certificates bash && \
|
||||
apt-get clean && rm -rf /var/lib/apt/lists/*; \
|
||||
elif command -v apk > /dev/null; then \
|
||||
apk add --no-cache ca-certificates bash gcompat libstdc++; \
|
||||
elif command -v yum > /dev/null; then \
|
||||
yum install -y ca-certificates bash; \
|
||||
yum clean all; \
|
||||
fi
|
||||
|
||||
# Create the openhands user if it doesn't exist
|
||||
RUN if ! id -u openhands > /dev/null 2>&1; then \
|
||||
if command -v useradd > /dev/null 2>&1; then \
|
||||
groupadd -g 1000 openhands 2>/dev/null || true; \
|
||||
useradd -u 1000 -g 1000 -m -s /bin/bash openhands 2>/dev/null || true; \
|
||||
elif command -v adduser > /dev/null 2>&1; then \
|
||||
addgroup -g 1000 openhands 2>/dev/null || true; \
|
||||
adduser -D -u 1000 -G openhands openhands 2>/dev/null || true; \
|
||||
fi; \
|
||||
fi
|
||||
|
||||
# Create directories
|
||||
RUN mkdir -p /openhands/bin /openhands/lib /workspace && \
|
||||
chown -R openhands:openhands /workspace /openhands 2>/dev/null || true
|
||||
|
||||
# Copy the bundled action execution server
|
||||
COPY ./dist/pyinstaller/action-execution-server /openhands/action-execution-server
|
||||
|
||||
# Copy Playwright browser
|
||||
COPY ./browser /openhands/browser
|
||||
|
||||
# Set environment variables
|
||||
ENV PATH=/openhands/bin:$PATH \
|
||||
LD_LIBRARY_PATH=/openhands/lib:$LD_LIBRARY_PATH \
|
||||
PLAYWRIGHT_BROWSERS_PATH=/openhands/browser \
|
||||
LANG=C.UTF-8 \
|
||||
LC_ALL=C.UTF-8
|
||||
|
||||
# Set the working directory
|
||||
WORKDIR /workspace
|
||||
|
||||
# Switch to the openhands user
|
||||
USER openhands
|
||||
|
||||
# Command to start the action execution server
|
||||
CMD ["/openhands/action-execution-server/action-execution-server", "8000", "/workspace"]
|
||||
@@ -0,0 +1,58 @@
|
||||
ARG DEPS_IMAGE
|
||||
FROM {{ deps_image }} as deps
|
||||
FROM {{ base_image }}
|
||||
|
||||
# Copy the /openhands folder from the deps image
|
||||
COPY --from=deps /openhands /openhands
|
||||
|
||||
# Set up environment variables
|
||||
ENV PATH=/openhands/bin:$PATH \
|
||||
LD_LIBRARY_PATH=/openhands/lib:$LD_LIBRARY_PATH \
|
||||
POETRY_VIRTUALENVS_PATH=/openhands/poetry \
|
||||
MAMBA_ROOT_PREFIX=/openhands/micromamba \
|
||||
PLAYWRIGHT_BROWSERS_PATH=/openhands/browser/ms-playwright \
|
||||
OPENVSCODE_SERVER_ROOT=/openhands/.openvscode-server \
|
||||
LANG=C.UTF-8 \
|
||||
LC_ALL=C.UTF-8 \
|
||||
EDITOR=code \
|
||||
VISUAL=code \
|
||||
GIT_EDITOR="code --wait"
|
||||
|
||||
# Install minimal dependencies required by the base system
|
||||
RUN if command -v apt-get > /dev/null; then \
|
||||
apt-get update && apt-get install -y --no-install-recommends ca-certificates bash && \
|
||||
apt-get clean && rm -rf /var/lib/apt/lists/*; \
|
||||
elif command -v apk > /dev/null; then \
|
||||
apk add --no-cache ca-certificates bash gcompat libstdc++; \
|
||||
elif command -v yum > /dev/null; then \
|
||||
yum install -y ca-certificates bash; \
|
||||
yum clean all; \
|
||||
fi
|
||||
|
||||
# Create the openhands user if it doesn't exist
|
||||
RUN if ! id -u openhands > /dev/null 2>&1; then \
|
||||
if command -v useradd > /dev/null 2>&1; then \
|
||||
groupadd -g 1000 openhands 2>/dev/null || true; \
|
||||
useradd -u 1000 -g 1000 -m -s /bin/bash openhands 2>/dev/null || true; \
|
||||
elif command -v adduser > /dev/null 2>&1; then \
|
||||
addgroup -g 1000 openhands 2>/dev/null || true; \
|
||||
adduser -D -u 1000 -G openhands openhands 2>/dev/null || true; \
|
||||
fi; \
|
||||
fi
|
||||
|
||||
# Create and set permissions for workspace directory
|
||||
RUN mkdir -p /workspace && \
|
||||
chown -R openhands:openhands /workspace /openhands 2>/dev/null || true
|
||||
|
||||
# Copy OpenHands source code
|
||||
COPY ./code/openhands /openhands/code/openhands
|
||||
RUN chmod a+rwx /openhands/code/openhands/__init__.py
|
||||
|
||||
# Set the working directory
|
||||
WORKDIR /workspace
|
||||
|
||||
# Switch to the openhands user
|
||||
USER openhands
|
||||
|
||||
# Command to start the action execution server
|
||||
CMD ["/openhands/bin/oh-action-execution-server", "8000", "/workspace"]
|
||||
106
package_browser.py
Normal file
106
package_browser.py
Normal file
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Script to extract and package the Playwright browser for use with the PyInstaller binary.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def find_playwright_browser_path():
|
||||
"""Find the Playwright browser path in the current environment."""
|
||||
try:
|
||||
# Try to get the path from the PLAYWRIGHT_BROWSERS_PATH environment variable
|
||||
if "PLAYWRIGHT_BROWSERS_PATH" in os.environ:
|
||||
browser_path = Path(os.environ["PLAYWRIGHT_BROWSERS_PATH"])
|
||||
if browser_path.exists():
|
||||
return browser_path
|
||||
|
||||
# Try to find it in the user's home directory
|
||||
home_dir = Path.home()
|
||||
playwright_path = home_dir / ".cache" / "ms-playwright"
|
||||
if playwright_path.exists():
|
||||
return playwright_path
|
||||
|
||||
# Try to find it in the root user's home directory
|
||||
root_playwright_path = Path("/root/.cache/ms-playwright")
|
||||
if root_playwright_path.exists():
|
||||
return root_playwright_path
|
||||
|
||||
# If not found, install Playwright and get the path
|
||||
print("Playwright browser not found. Installing...")
|
||||
subprocess.run([sys.executable, "-m", "pip", "install", "playwright"], check=True)
|
||||
subprocess.run([sys.executable, "-m", "playwright", "install", "chromium"], check=True)
|
||||
|
||||
# Try again to find the path
|
||||
if "PLAYWRIGHT_BROWSERS_PATH" in os.environ:
|
||||
browser_path = Path(os.environ["PLAYWRIGHT_BROWSERS_PATH"])
|
||||
if browser_path.exists():
|
||||
return browser_path
|
||||
|
||||
playwright_path = home_dir / ".cache" / "ms-playwright"
|
||||
if playwright_path.exists():
|
||||
return playwright_path
|
||||
|
||||
root_playwright_path = Path("/root/.cache/ms-playwright")
|
||||
if root_playwright_path.exists():
|
||||
return root_playwright_path
|
||||
|
||||
raise FileNotFoundError("Could not find Playwright browser path")
|
||||
except Exception as e:
|
||||
print(f"Error finding Playwright browser path: {e}")
|
||||
raise
|
||||
|
||||
|
||||
def package_browser(output_dir):
|
||||
"""Package the Playwright browser for use with the PyInstaller binary."""
|
||||
try:
|
||||
# Find the Playwright browser path
|
||||
browser_path = find_playwright_browser_path()
|
||||
print(f"Found Playwright browser at: {browser_path}")
|
||||
|
||||
# Create the output directory
|
||||
output_path = Path(output_dir)
|
||||
output_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Copy the browser files
|
||||
print(f"Copying browser files to {output_path}...")
|
||||
shutil.copytree(browser_path, output_path / "ms-playwright", dirs_exist_ok=True)
|
||||
|
||||
# Create a wrapper script for the browser
|
||||
wrapper_script = output_path / "chromium-wrapper.sh"
|
||||
with open(wrapper_script, "w") as f:
|
||||
f.write("""#!/bin/bash
|
||||
# Wrapper script for Chromium
|
||||
|
||||
# Set up environment
|
||||
export PLAYWRIGHT_BROWSERS_PATH="$(dirname "$0")/ms-playwright"
|
||||
|
||||
# Find the Chromium executable
|
||||
CHROMIUM_PATH=$(find "$PLAYWRIGHT_BROWSERS_PATH" -name "chrome" -type f | head -n 1)
|
||||
|
||||
if [ -z "$CHROMIUM_PATH" ]; then
|
||||
echo "Error: Chromium executable not found in $PLAYWRIGHT_BROWSERS_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Execute Chromium with all arguments passed to this script
|
||||
exec "$CHROMIUM_PATH" "$@"
|
||||
""")
|
||||
|
||||
# Make the wrapper script executable
|
||||
wrapper_script.chmod(0o755)
|
||||
|
||||
print(f"Browser packaged successfully to {output_path}")
|
||||
return output_path
|
||||
except Exception as e:
|
||||
print(f"Error packaging browser: {e}")
|
||||
raise
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
output_dir = sys.argv[1] if len(sys.argv) > 1 else "./browser"
|
||||
package_browser(output_dir)
|
||||
88
pyinstaller_runtime_README.md
Normal file
88
pyinstaller_runtime_README.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# PyInstaller Runtime for OpenHands
|
||||
|
||||
This directory contains the implementation of a PyInstaller-based approach for the OpenHands runtime. This approach bundles all required dependencies of the action_execution_server into a binary, which can then be copied into any base image to make it OpenHands compatible.
|
||||
|
||||
## Overview
|
||||
|
||||
The traditional runtime building procedure involves installing all dependencies (Python, Node.js, Playwright, etc.) in the target image, which can be time-consuming and may lead to compatibility issues. The PyInstaller approach simplifies this by:
|
||||
|
||||
1. Building a standalone binary with PyInstaller that includes all Python dependencies
|
||||
2. Copying only the binary and necessary browser components to the target runtime image
|
||||
3. Eliminating the need to install Python and other dependencies in the target image
|
||||
|
||||
## How It Works
|
||||
|
||||
### 1. Building the Binary
|
||||
|
||||
We use PyInstaller to bundle the action_execution_server and all its dependencies into a standalone binary. This can be done in two ways:
|
||||
|
||||
#### Option A: Using poetry-pyinstaller-plugin
|
||||
|
||||
```bash
|
||||
# Install the plugin
|
||||
pip install poetry-pyinstaller-plugin
|
||||
|
||||
# Add configuration to pyproject.toml
|
||||
# [tool.poetry-pyinstaller-plugin]
|
||||
# version = "6.13.0"
|
||||
#
|
||||
# [tool.poetry-pyinstaller-plugin.scripts]
|
||||
# action-execution-server = { source = "openhands/runtime/action_execution_server.py", type = "onedir", bundle = false }
|
||||
|
||||
# Build the binary
|
||||
poetry build --format pyinstaller
|
||||
```
|
||||
|
||||
#### Option B: Direct PyInstaller Usage
|
||||
|
||||
```bash
|
||||
# Install PyInstaller
|
||||
pip install pyinstaller
|
||||
|
||||
# Build the binary
|
||||
pyinstaller --onedir openhands/runtime/action_execution_server.py
|
||||
```
|
||||
|
||||
### 2. Packaging Browser Components
|
||||
|
||||
We extract Playwright's Chromium browser and package it for use with the binary:
|
||||
|
||||
```bash
|
||||
# Package the browser
|
||||
python package_browser.py browser
|
||||
```
|
||||
|
||||
### 3. Building the Runtime Image
|
||||
|
||||
We use a modified version of the runtime_build.py script to build the runtime image:
|
||||
|
||||
```bash
|
||||
# Build the runtime image
|
||||
python runtime_build_pyinstaller.py --base-image ubuntu:22.04
|
||||
```
|
||||
|
||||
## Files
|
||||
|
||||
- `pyinstaller_runtime_plan.md`: The implementation plan for the PyInstaller approach
|
||||
- `package_browser.py`: Script to extract and package the Playwright browser
|
||||
- `runtime_build_pyinstaller.py`: Modified runtime_build.py that implements the PyInstaller approach
|
||||
- `openhands/runtime/utils/runtime_templates/Dockerfile.pyinstaller.j2`: Dockerfile template for the PyInstaller approach
|
||||
|
||||
## Advantages
|
||||
|
||||
1. **Smaller Image Size**: Only the binary and browser components are needed, not all Python dependencies
|
||||
2. **Faster Builds**: No need to install Python and dependencies in the target image
|
||||
3. **Better Compatibility**: The binary should work on any Linux distribution with compatible glibc
|
||||
4. **Simplified Maintenance**: Easier to update the binary independently of the base image
|
||||
|
||||
## Limitations
|
||||
|
||||
1. **Binary Compatibility**: The binary may not work on all Linux distributions due to glibc version differences
|
||||
2. **Browser Integration**: Playwright requires Chromium and its dependencies, which may not be available on all images
|
||||
3. **Plugin System**: The current plugin system might not work with a bundled binary
|
||||
|
||||
## Future Work
|
||||
|
||||
1. **Improve Binary Compatibility**: Build the binary in a minimal environment for maximum compatibility
|
||||
2. **Enhance Browser Integration**: Create a more portable browser package
|
||||
3. **Modify Plugin System**: Update the plugin system to work with the bundled binary
|
||||
102
pyinstaller_runtime_plan.md
Normal file
102
pyinstaller_runtime_plan.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# PyInstaller Runtime Implementation Plan
|
||||
|
||||
## Overview
|
||||
|
||||
This plan outlines how to implement a PyInstaller-based approach for the OpenHands runtime, which will bundle all required dependencies of the action_execution_server into a binary. This approach will simplify the runtime building procedure by:
|
||||
|
||||
1. Building a standalone binary with PyInstaller that includes all Python dependencies
|
||||
2. Copying only the binary and necessary browser components to the target runtime image
|
||||
3. Eliminating the need to install Python and other dependencies in the target image
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### 1. Build the PyInstaller Binary
|
||||
|
||||
#### Option A: Using poetry-pyinstaller-plugin
|
||||
- Add configuration to pyproject.toml:
|
||||
```toml
|
||||
[tool.poetry-pyinstaller-plugin]
|
||||
version = "6.13.0"
|
||||
|
||||
[tool.poetry-pyinstaller-plugin.scripts]
|
||||
action-execution-server = { source = "openhands/runtime/action_execution_server.py", type = "onedir", bundle = false }
|
||||
```
|
||||
- Run `poetry build` to create the binary
|
||||
|
||||
#### Option B: Direct PyInstaller Usage
|
||||
- Create a spec file for action_execution_server.py
|
||||
- Run PyInstaller with the spec file
|
||||
- Ensure all dependencies are included
|
||||
|
||||
### 2. Package Browser Components
|
||||
|
||||
- Extract Playwright's Chromium browser from the cache
|
||||
- Create a portable browser package that can be copied to the target image
|
||||
- Include necessary wrapper scripts for browser execution
|
||||
|
||||
### 3. Update Runtime Builder
|
||||
|
||||
- Modify `runtime_build.py` to implement the PyInstaller approach
|
||||
- Add a new build method that uses the PyInstaller binary
|
||||
- Create a new Dockerfile template for the PyInstaller approach
|
||||
- Implement the copying mechanism for the binary and browser components
|
||||
|
||||
### 4. Create Wrapper Scripts
|
||||
|
||||
- Create wrapper scripts for the action_execution_server binary
|
||||
- Create wrapper scripts for browser execution
|
||||
- Ensure proper environment variables are set
|
||||
|
||||
### 5. Testing
|
||||
|
||||
- Test with various base images to ensure compatibility
|
||||
- Verify all components work correctly (browser, bash, plugins, etc.)
|
||||
- Benchmark performance improvements
|
||||
|
||||
## Advantages
|
||||
|
||||
1. **Smaller Image Size**: Only the binary and browser components are needed, not all Python dependencies
|
||||
2. **Faster Builds**: No need to install Python and dependencies in the target image
|
||||
3. **Better Compatibility**: The binary should work on any Linux distribution with compatible glibc
|
||||
4. **Simplified Maintenance**: Easier to update the binary independently of the base image
|
||||
|
||||
## Challenges and Solutions
|
||||
|
||||
### 1. Binary Compatibility
|
||||
|
||||
**Challenge**: Binaries compiled in one environment might not work in another due to different system libraries.
|
||||
|
||||
**Solutions**:
|
||||
- Build the binary in a minimal environment (e.g., Ubuntu 20.04) for maximum compatibility
|
||||
- Include all necessary shared libraries in the binary
|
||||
- Use static linking where possible
|
||||
|
||||
### 2. Browser Integration
|
||||
|
||||
**Challenge**: Playwright requires Chromium and its dependencies.
|
||||
|
||||
**Solutions**:
|
||||
- Extract Chromium from Playwright's cache
|
||||
- Create a portable browser package
|
||||
- Use wrapper scripts to set up the correct environment
|
||||
|
||||
### 3. Plugin System
|
||||
|
||||
**Challenge**: The current plugin system might not work with a bundled binary.
|
||||
|
||||
**Solutions**:
|
||||
- Modify the plugin system to work with the binary
|
||||
- Include all plugins in the binary
|
||||
- Implement a mechanism to load plugins at runtime
|
||||
|
||||
## Implementation Timeline
|
||||
|
||||
1. **Phase 1**: Create and test the PyInstaller binary (1-2 days)
|
||||
2. **Phase 2**: Package browser components (1 day)
|
||||
3. **Phase 3**: Update runtime builder (1-2 days)
|
||||
4. **Phase 4**: Create wrapper scripts (1 day)
|
||||
5. **Phase 5**: Testing and optimization (2-3 days)
|
||||
|
||||
## Conclusion
|
||||
|
||||
The PyInstaller approach offers significant benefits in terms of build speed, image size, and compatibility. While there are challenges related to binary compatibility and browser integration, these can be addressed with careful implementation of wrapper scripts and proper environment setup.
|
||||
@@ -110,6 +110,12 @@ notebook = "*"
|
||||
jupyter_kernel_gateway = "*"
|
||||
flake8 = "*"
|
||||
|
||||
[tool.poetry-pyinstaller-plugin]
|
||||
version = "6.13.0"
|
||||
|
||||
[tool.poetry-pyinstaller-plugin.scripts]
|
||||
action-execution-server = { source = "openhands/runtime/action_execution_server.py", type = "onedir", bundle = false }
|
||||
|
||||
[tool.poetry.group.evaluation.dependencies]
|
||||
streamlit = "*"
|
||||
whatthepatch = "*"
|
||||
@@ -162,3 +168,10 @@ lint.pydocstyle.convention = "google"
|
||||
|
||||
[tool.coverage.run]
|
||||
concurrency = [ "gevent" ]
|
||||
|
||||
[tool.poetry-pyinstaller-plugin]
|
||||
entry-point = "openhands.runtime.action_execution_server:main"
|
||||
name = "action-execution-server"
|
||||
strip = true
|
||||
onedir = true
|
||||
console = true
|
||||
|
||||
198
runtime_build_pyinstaller.py
Normal file
198
runtime_build_pyinstaller.py
Normal file
@@ -0,0 +1,198 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Modified runtime_build.py that implements the PyInstaller approach.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
import docker
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
import openhands
|
||||
from openhands import __version__ as oh_version
|
||||
from openhands.core.exceptions import AgentRuntimeBuildError
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
|
||||
class BuildMethod(Enum):
|
||||
PYINSTALLER = 'pyinstaller' # Use PyInstaller to bundle the action_execution_server
|
||||
|
||||
|
||||
def get_runtime_image_repo() -> str:
|
||||
return os.getenv('OH_RUNTIME_RUNTIME_IMAGE_REPO', 'ghcr.io/all-hands-ai/runtime')
|
||||
|
||||
|
||||
def get_runtime_image_repo_and_tag(base_image: str) -> tuple[str, str]:
|
||||
"""Retrieves the Docker repo and tag associated with the Docker image.
|
||||
|
||||
Parameters:
|
||||
- base_image (str): The name of the base Docker image
|
||||
|
||||
Returns:
|
||||
- tuple[str, str]: The Docker repo and tag of the Docker image
|
||||
"""
|
||||
if get_runtime_image_repo() in base_image:
|
||||
logger.debug(
|
||||
f'The provided image [{base_image}] is already a valid runtime image.\n'
|
||||
f'Will try to reuse it as is.'
|
||||
)
|
||||
|
||||
if ':' not in base_image:
|
||||
base_image = base_image + ':latest'
|
||||
repo, tag = base_image.split(':')
|
||||
return repo, tag
|
||||
else:
|
||||
if ':' not in base_image:
|
||||
base_image = base_image + ':latest'
|
||||
[repo, tag] = base_image.split(':')
|
||||
|
||||
# Hash the repo if it's too long
|
||||
if len(repo) > 32:
|
||||
repo_hash = hashlib.md5(repo[:-24].encode()).hexdigest()[:8]
|
||||
repo = f"{repo[-24:]}_{repo_hash}"
|
||||
|
||||
runtime_repo = get_runtime_image_repo()
|
||||
runtime_tag = f'oh_v{oh_version}_{tag}'
|
||||
return runtime_repo, runtime_tag
|
||||
|
||||
|
||||
def _generate_dockerfile(
|
||||
base_image: str,
|
||||
build_method: BuildMethod = BuildMethod.PYINSTALLER,
|
||||
) -> str:
|
||||
"""Generate the Dockerfile content for the runtime image based on the base image.
|
||||
|
||||
Parameters:
|
||||
- base_image (str): The base image provided for the runtime image
|
||||
- build_method (BuildMethod): The build method for the runtime image.
|
||||
|
||||
Returns:
|
||||
- str: The resulting Dockerfile content
|
||||
"""
|
||||
env = Environment(
|
||||
loader=FileSystemLoader(
|
||||
searchpath=os.path.join(os.path.dirname(__file__), 'openhands/runtime/utils/runtime_templates')
|
||||
)
|
||||
)
|
||||
|
||||
template = env.get_template('Dockerfile.pyinstaller.j2')
|
||||
dockerfile_content = template.render(
|
||||
base_image=base_image,
|
||||
)
|
||||
|
||||
return dockerfile_content
|
||||
|
||||
|
||||
def build_pyinstaller_binary():
|
||||
"""Build the PyInstaller binary for the action_execution_server."""
|
||||
logger.info("Building PyInstaller binary for action_execution_server...")
|
||||
|
||||
# Check if poetry-pyinstaller-plugin is installed
|
||||
try:
|
||||
subprocess.run(["pip", "show", "poetry-pyinstaller-plugin"], check=True, capture_output=True)
|
||||
logger.info("Using poetry-pyinstaller-plugin to build the binary")
|
||||
|
||||
# Build the binary using poetry
|
||||
subprocess.run(["poetry", "build", "--format", "pyinstaller"], check=True)
|
||||
except subprocess.CalledProcessError:
|
||||
logger.info("poetry-pyinstaller-plugin not found, using direct PyInstaller approach")
|
||||
|
||||
# Build the binary using PyInstaller directly
|
||||
subprocess.run(["pyinstaller", "--onedir", "openhands/runtime/action_execution_server.py"], check=True)
|
||||
|
||||
# Move the binary to the expected location
|
||||
os.makedirs("dist/pyinstaller", exist_ok=True)
|
||||
shutil.move("dist/action_execution_server", "dist/pyinstaller/action-execution-server")
|
||||
|
||||
logger.info("PyInstaller binary built successfully")
|
||||
|
||||
|
||||
def package_browser():
|
||||
"""Package the Playwright browser for use with the PyInstaller binary."""
|
||||
logger.info("Packaging Playwright browser...")
|
||||
|
||||
# Run the package_browser.py script
|
||||
subprocess.run(["python", "package_browser.py", "browser"], check=True)
|
||||
|
||||
logger.info("Playwright browser packaged successfully")
|
||||
|
||||
|
||||
def build_runtime_image(
|
||||
base_image: str,
|
||||
build_method: BuildMethod = BuildMethod.PYINSTALLER,
|
||||
no_cache: bool = False,
|
||||
) -> str:
|
||||
"""Build a runtime image based on the base image.
|
||||
|
||||
Parameters:
|
||||
- base_image (str): The base image provided for the runtime image
|
||||
- build_method (BuildMethod): The build method for the runtime image.
|
||||
- no_cache (bool): Whether to use Docker cache when building the image
|
||||
|
||||
Returns:
|
||||
- str: The name of the built runtime image
|
||||
"""
|
||||
logger.info(f"Building runtime image with {build_method.value} method...")
|
||||
|
||||
# Build the PyInstaller binary
|
||||
build_pyinstaller_binary()
|
||||
|
||||
# Package the browser
|
||||
package_browser()
|
||||
|
||||
# Generate the Dockerfile
|
||||
dockerfile_content = _generate_dockerfile(base_image, build_method)
|
||||
|
||||
# Create a temporary directory for the Docker build context
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# Write the Dockerfile to the temporary directory
|
||||
dockerfile_path = os.path.join(tmpdir, 'Dockerfile')
|
||||
with open(dockerfile_path, 'w') as f:
|
||||
f.write(dockerfile_content)
|
||||
|
||||
# Copy the PyInstaller binary to the temporary directory
|
||||
shutil.copytree("dist/pyinstaller/action-execution-server", os.path.join(tmpdir, "dist/pyinstaller/action-execution-server"))
|
||||
|
||||
# Copy the browser to the temporary directory
|
||||
shutil.copytree("browser", os.path.join(tmpdir, "browser"))
|
||||
|
||||
# Get the runtime image name
|
||||
runtime_repo, runtime_tag = get_runtime_image_repo_and_tag(base_image)
|
||||
runtime_image = f"{runtime_repo}:{runtime_tag}"
|
||||
|
||||
# Build the Docker image
|
||||
logger.info(f"Building Docker image {runtime_image}...")
|
||||
client = docker.from_env()
|
||||
try:
|
||||
client.images.build(
|
||||
path=tmpdir,
|
||||
tag=runtime_image,
|
||||
nocache=no_cache,
|
||||
)
|
||||
logger.info(f"Docker image {runtime_image} built successfully")
|
||||
return runtime_image
|
||||
except docker.errors.BuildError as e:
|
||||
logger.error(f"Error building Docker image: {e}")
|
||||
raise AgentRuntimeBuildError(f"Error building Docker image: {e}")
|
||||
|
||||
|
||||
def main():
|
||||
"""Main function for the runtime_build_pyinstaller.py script."""
|
||||
parser = argparse.ArgumentParser(description='Build a runtime image with PyInstaller')
|
||||
parser.add_argument('--base-image', type=str, default='ubuntu:22.04', help='Base image for the runtime')
|
||||
parser.add_argument('--no-cache', action='store_true', help='Do not use Docker cache when building the image')
|
||||
args = parser.parse_args()
|
||||
|
||||
runtime_image = build_runtime_image(args.base_image, BuildMethod.PYINSTALLER, args.no_cache)
|
||||
print(f"Runtime image built: {runtime_image}")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
98
tests/unit/test_runtime_refactored.py
Normal file
98
tests/unit/test_runtime_refactored.py
Normal file
@@ -0,0 +1,98 @@
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from openhands.runtime.utils.runtime_build import (
|
||||
BuildFromImageType,
|
||||
_generate_dockerfile,
|
||||
build_deps_image,
|
||||
build_runtime_image,
|
||||
build_runtime_image_from_deps,
|
||||
get_deps_image_name,
|
||||
)
|
||||
|
||||
|
||||
class TestRuntimeRefactored:
|
||||
def test_get_deps_image_name(self):
|
||||
"""Test that get_deps_image_name returns the expected name."""
|
||||
deps_image = get_deps_image_name()
|
||||
assert "oh_deps_v" in deps_image
|
||||
assert deps_image.startswith("ghcr.io/all-hands-ai/runtime:")
|
||||
|
||||
def test_generate_dockerfile_deps(self):
|
||||
"""Test that _generate_dockerfile generates the expected Dockerfile for DEPS build method."""
|
||||
dockerfile = _generate_dockerfile(
|
||||
base_image="ubuntu:22.04",
|
||||
build_from=BuildFromImageType.DEPS,
|
||||
deps_image="ghcr.io/all-hands-ai/runtime:oh_deps_v0.1.0",
|
||||
)
|
||||
assert "FROM ghcr.io/all-hands-ai/runtime:oh_deps_v0.1.0 as deps" in dockerfile
|
||||
assert "FROM ubuntu:22.04" in dockerfile
|
||||
assert "COPY --from=deps /openhands /openhands" in dockerfile
|
||||
|
||||
@mock.patch("openhands.runtime.utils.runtime_build.RuntimeBuilder")
|
||||
def test_build_deps_image(self, mock_runtime_builder):
|
||||
"""Test that build_deps_image calls the right methods."""
|
||||
mock_runtime_builder.build_image.return_value = "test_image"
|
||||
mock_runtime_builder.get_image.return_value = None
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
# Mock the build process
|
||||
with mock.patch(
|
||||
"openhands.runtime.utils.runtime_build.build_deps_image_in_folder",
|
||||
return_value="test_deps_image",
|
||||
):
|
||||
result = build_deps_image(
|
||||
runtime_builder=mock_runtime_builder,
|
||||
build_folder=temp_dir,
|
||||
dry_run=True,
|
||||
)
|
||||
assert result == "test_deps_image"
|
||||
|
||||
@mock.patch("openhands.runtime.utils.runtime_build.RuntimeBuilder")
|
||||
def test_build_runtime_image_from_deps(self, mock_runtime_builder):
|
||||
"""Test that build_runtime_image_from_deps calls the right methods."""
|
||||
mock_runtime_builder.image_exists.return_value = False
|
||||
mock_runtime_builder.build_image.return_value = "test_image"
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
# Create necessary directories
|
||||
os.makedirs(os.path.join(temp_dir, "code", "openhands"), exist_ok=True)
|
||||
|
||||
# Mock the build process
|
||||
with mock.patch(
|
||||
"openhands.runtime.utils.runtime_build._build_sandbox_image",
|
||||
return_value="test_runtime_image",
|
||||
):
|
||||
result = build_runtime_image_from_deps(
|
||||
base_image="ubuntu:22.04",
|
||||
runtime_builder=mock_runtime_builder,
|
||||
deps_image="test_deps_image",
|
||||
build_folder=Path(temp_dir),
|
||||
dry_run=True,
|
||||
)
|
||||
assert "oh_deps_" in result
|
||||
|
||||
@mock.patch("openhands.runtime.utils.runtime_build.RuntimeBuilder")
|
||||
def test_build_runtime_image_with_deps(self, mock_runtime_builder):
|
||||
"""Test that build_runtime_image with use_deps_image=True calls the right methods."""
|
||||
mock_runtime_builder.get_image.return_value = "test_deps_image"
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
# Mock the build process
|
||||
with mock.patch(
|
||||
"openhands.runtime.utils.runtime_build.build_runtime_image_from_deps",
|
||||
return_value="test_runtime_image",
|
||||
):
|
||||
result = build_runtime_image(
|
||||
base_image="ubuntu:22.04",
|
||||
runtime_builder=mock_runtime_builder,
|
||||
build_folder=temp_dir,
|
||||
dry_run=True,
|
||||
use_deps_image=True,
|
||||
deps_image="test_deps_image",
|
||||
)
|
||||
assert result == "test_runtime_image"
|
||||
Reference in New Issue
Block a user