Skip to content

Commit

Permalink
Option to run container as root
Browse files Browse the repository at this point in the history
  • Loading branch information
krasserm committed Jan 4, 2025
1 parent 3f7271f commit c6c63c6
Show file tree
Hide file tree
Showing 11 changed files with 176 additions and 69 deletions.
39 changes: 39 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
**This guide is work in progress ...**

Clone the repository:

```bash
git clone https://github.com/gradion-ai/ipybox.git
cd ipybox
```

Create a new Conda environment and activate it:

```bash
conda env create -f environment.yml
conda activate ipybox
```

Install dependencies with Poetry:

```bash
poetry install --with docs
```

Install pre-commit hooks:

```bash
invoke precommit-install
```

Enforce coding conventions (done automatically by pre-commit hooks):

```bash
invoke cc
```

Run tests:

```bash
pytest -s tests
```
46 changes: 3 additions & 43 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

## Documentation

The official documentation is available [here](https://gradion-ai.github.io/ipybox/).
The `ipybox` documentation is available [here](https://gradion-ai.github.io/ipybox/).

## Quickstart

Expand All @@ -28,20 +28,14 @@ Install `ipybox` Python package:
pip install ipybox
```

Build a `gradion-ai/ipybox` Docker image:

```bash
python -m ipybox build -t gradion-ai/ipybox
```

Print something inside `ipybox`:
Execute Python code inside `ipybox`:

```python
import asyncio
from ipybox import ExecutionClient, ExecutionContainer

async def main():
async with ExecutionContainer(tag="gradion-ai/ipybox") as container:
async with ExecutionContainer(tag="ghcr.io/gradion-ai/ipybox:minimal") as container:
async with ExecutionClient(port=container.port) as client:
result = await client.execute("print('Hello, world!')")
print(f"Output: {result.text}")
Expand All @@ -51,37 +45,3 @@ if __name__ == "__main__":
```

Find out more in the [user guide](https://gradion-ai.github.io/ipybox/).

## Development

Clone the repository:

```bash
git clone https://github.com/gradion-ai/ipybox.git
cd ipybox
```

Create a new Conda environment and activate it:

```bash
conda env create -f environment.yml
conda activate ipybox
```

Install dependencies with Poetry:

```bash
poetry install --with docs
```

Install pre-commit hooks:

```bash
invoke precommit-install
```

Run tests:

```bash
pytest -s tests
```
4 changes: 0 additions & 4 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,3 @@
- **Flexible Dependency Management**: Supports package installation and updates during runtime or at build time
- **Resource Management**: Controls container lifecycle with built-in timeout and resource management features
- **Reproducible Environments**: Ensures consistent execution environments across different systems

## Status

`ipybox` is in active early development, with ongoing refinements and enhancements to its core features. Community feedback and contributions are welcome as we continue to evolve the project.
8 changes: 4 additions & 4 deletions docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,6 @@ pip install ipybox

Before using `ipybox`, you need to build a Docker image. This image contains all required dependencies for executing Python code in stateful and isolated sessions.

!!! note

Building an `ipybox` Docker image requires [Docker](https://www.docker.com/) to be installed on your system. Containers created from this image will run with the same user and group IDs as the user who built the image, ensuring proper file permissions on mounted host directories.

### Default build

To build an `ipybox` Docker image with default settings:
Expand All @@ -22,6 +18,10 @@ python -m ipybox build

This creates a Docker image tagged as `gradion-ai/ipybox` containing the base Python dependencies required for the code execution environment.

!!! note

By default, containers created from this image will run with the same user and group IDs as the user who built the image, ensuring proper file permissions on mounted host directories. If you use the `-r` or `--root` option when building the image, the container will run as root.

### Custom build

To create a custom `ipybox` Docker image with additional dependencies, create a dependencies file (e.g., `dependencies.txt`). For example:
Expand Down
2 changes: 1 addition & 1 deletion docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ The default image used by `ExecutionContainer` is `gradion-ai/ipybox`. You can s

!!! Note

Instead of letting the `ExecutionContainer` context manager handle the lifecycle of the container, you can also [manually run and kill the container](usage.md#manual-container-lifecycle-management).
Instead of letting the `ExecutionContainer` context manager handle the lifecycle of the container, you can also [manually manage the container lifecycle](usage.md#manual-container-lifecycle-management).


## State management
Expand Down
31 changes: 25 additions & 6 deletions ipybox/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,14 @@ def build(
dir_okay=False,
),
] = Path(__file__).parent / "config" / "default" / "dependencies.txt",
root: Annotated[
bool,
typer.Option(
"--root",
"-r",
help="Run container as root",
),
] = False,
):
with tempfile.TemporaryDirectory() as tmp_dir:
tmp_path = Path(tmp_dir)
Expand All @@ -50,24 +58,35 @@ def build(
ipybox_path = tmp_path / "ipybox"
ipybox_path.mkdir()

if root:
dockerfile = "Dockerfile.root"
build_cmd_args = []
else:
dockerfile = "Dockerfile"
build_cmd_args = [
"--build-arg",
f"UID={os.getuid()}",
"--build-arg",
f"GID={os.getgid()}",
]

shutil.copy(pkg_path / "modinfo.py", tmp_path / "ipybox")
shutil.copy(pkg_path / "config" / "default" / "environment.yml", tmp_path)
shutil.copy(pkg_path / "docker" / "Dockerfile", tmp_path)
shutil.copy(pkg_path / "docker" / dockerfile, tmp_path)
shutil.copy(pkg_path / "scripts" / "server.sh", tmp_path)

build_cmd = [
"docker",
"build",
"-f",
tmp_path / dockerfile,
"-t",
tag,
str(tmp_path),
"--build-arg",
f"UID={os.getuid()}",
"--build-arg",
f"GID={os.getgid()}",
*build_cmd_args,
]

process = subprocess.Popen(build_cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
process = subprocess.Popen(build_cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) # type: ignore

while True:
output = process.stdout.readline() # type: ignore
Expand Down
50 changes: 48 additions & 2 deletions ipybox/container.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from collections import defaultdict
from pathlib import Path

from aiodocker import Docker
Expand All @@ -17,10 +18,11 @@ class ExecutionContainer:
tag: Tag of the Docker image to use (defaults to gradion-ai/ipybox)
binds: Mapping of host paths to container paths for volume mounting.
Host paths may be relative or absolute. Container paths must be relative
and are created as subdirectories of `/home/appuser` in the container.
and are created as subdirectories of `/app` in the container.
env: Environment variables to set in the container
port: Host port to map to the container's executor port. If not provided,
a random port will be allocated.
show_pull_progress: Whether to show progress when pulling the Docker image.
Attributes:
port: Host port mapped to the container's executor port. This port is dynamically
Expand All @@ -47,10 +49,12 @@ def __init__(
binds: dict[str, str] | None = None,
env: dict[str, str] | None = None,
port: int | None = None,
show_pull_progress: bool = True,
):
self.tag = tag
self.binds = binds or {}
self.env = env or {}
self.show_pull_progress = show_pull_progress

self._docker = None
self._container = None
Expand Down Expand Up @@ -111,6 +115,9 @@ async def _run(self, executor_port: int = 8888):
"ExposedPorts": {f"{executor_port}/tcp": {}},
}

if not await self._local_image():
await self._pull_image()

container = await self._docker.containers.create(config=config) # type: ignore
await container.start()

Expand All @@ -119,11 +126,50 @@ async def _run(self, executor_port: int = 8888):

return container

async def _local_image(self) -> bool:
tag = self.tag if ":" in self.tag else f"{self.tag}:latest"

images = await self._docker.images.list() # type: ignore
for img in images:
if "RepoTags" in img and tag in img["RepoTags"]:
return True

return False

async def _pull_image(self):
# Track progress by layer ID
layer_progress = defaultdict(str)

async for message in self._docker.images.pull(self.tag, stream=True): # type: ignore
if not self.show_pull_progress:
continue

if "status" in message:
status = message["status"]
if "id" in message:
layer_id = message["id"]
if "progress" in message:
layer_progress[layer_id] = f"{status}: {message['progress']}"
else:
layer_progress[layer_id] = status

# Clear screen and move cursor to top
print("\033[2J\033[H", end="")
# Print all layer progress
for layer_id, progress in layer_progress.items():
print(f"{layer_id}: {progress}")
else:
# Status without layer ID (like "Downloading" or "Complete")
print(f"\r{status}", end="")

if self.show_pull_progress:
print()

async def _container_binds(self) -> list[str]:
container_binds = []
for host_path, container_path in self.binds.items():
host_path_resolved = await arun(self._prepare_host_path, host_path)
container_binds.append(f"{host_path_resolved}:/home/appuser/{container_path}")
container_binds.append(f"{host_path_resolved}:/app/{container_path}")
return container_binds

def _prepare_host_path(self, host_path: str) -> Path:
Expand Down
4 changes: 3 additions & 1 deletion ipybox/docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ RUN conda run -n ipybox poetry install --only main
COPY --chown=appuser:appuser server.sh ${HOME}/
RUN chmod +x ${HOME}/server.sh

COPY --chown=appuser:appuser ipybox/modinfo.py ${HOME}/ipybox/
COPY --chown=appuser:appuser ipybox/modinfo.py /app/ipybox/

WORKDIR /app

CMD ["/bin/bash", "-c", "source ${HOME}/conda/etc/profile.d/conda.sh && conda activate ipybox && ${HOME}/server.sh"]
39 changes: 39 additions & 0 deletions ipybox/docker/Dockerfile.root
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
FROM ubuntu:22.04

ENV HOME=/root

RUN apt-get update && apt-get install -y \
python3.10 \
python3-pip \
python3.10-venv \
curl \
wget \
&& rm -rf /var/lib/apt/lists/*

WORKDIR ${HOME}

RUN wget https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh -O miniconda.sh \
&& bash miniconda.sh -b -p ${HOME}/conda \
&& rm miniconda.sh

RUN curl -sSL https://install.python-poetry.org | POETRY_HOME=${HOME}/poetry python3 -

ENV PATH="${HOME}/conda/bin:${PATH}"
ENV PATH="${HOME}/poetry/bin:${PATH}"
ENV PATH="${HOME}/.local/bin:${PATH}"

COPY environment.yml ${HOME}/
RUN conda env create -f ${HOME}/environment.yml -n ipybox && conda init bash

COPY pyproject.toml ${HOME}/
RUN poetry config virtualenvs.create false
RUN conda run -n ipybox poetry install --only main

COPY server.sh ${HOME}/
RUN chmod +x ${HOME}/server.sh

COPY ipybox/modinfo.py /app/ipybox/

WORKDIR /app

CMD ["/bin/bash", "-c", "source ${HOME}/conda/etc/profile.d/conda.sh && conda activate ipybox && ${HOME}/server.sh"]
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"

[tool.poetry]
name = "ipybox"
version = "0.2.6"
version = "0.3.0"
description = "Python code execution sandbox based on IPython and Docker"
homepage = "https://github.com/gradion-ai/ipybox"
readme = "README.md"
Expand Down
20 changes: 13 additions & 7 deletions tests/ipybox_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,22 @@ async def workspace():
yield temp_dir


@pytest.fixture(scope="module")
def executor_image() -> Generator[str, None, None]:
tag = f"{DEFAULT_TAG}-test"
@pytest.fixture(
scope="module",
params=["test-root", "test"],
)
def executor_image(request) -> Generator[str, None, None]:
tag_suffix = request.param
tag = f"{DEFAULT_TAG}-{tag_suffix}"
deps_path = Path(__file__).parent / "dependencies.txt"

cmd = ["python", "-m", "ipybox", "build", "-t", tag, "-d", str(deps_path)]

if tag_suffix == "test-root":
cmd.append("-r")

# Build the image using the CLI
subprocess.run(
["python", "-m", "ipybox", "build", "-t", tag, "-d", str(deps_path)],
check=True,
)
subprocess.run(cmd, check=True)

yield tag

Expand Down

0 comments on commit c6c63c6

Please sign in to comment.