From 731c3e5d1417db27473033393cc149f9012f73b0 Mon Sep 17 00:00:00 2001 From: bala Date: Tue, 20 Oct 2026 11:04:01 +0000 Subject: [PATCH] refactor(env): run stock MCR images, prepare the sandbox on the fly Stop building and publishing a custom shell-server image. Sandboxes now run an unmodified image from the Microsoft Container Registry (default: mcr.microsoft.com/devcontainers/python:3.11, the old image's base) and are prepared when the environment starts: - shell_server/: the distro-agnostic server (dockerShell.py, ShellCommunicator.py, moved unchanged from image_builder/) plus prepare.sh, which installs missing prerequisites with apt-get, tdnf, dnf or yum, removes accounts holding the agent's uid/gid and the devcontainer 'vscode' sudo user, creates the unprivileged `agent` account, and pip-installs fastapi/uvicorn/pydantic into a root-owned venv at /opt/microbots/venv. - LocalDockerEnvironment starts the image idle (`sleep infinity` under init), copies shell_server/ into /opt/microbots (root-owned) over the root control plane, runs prepare.sh, launches dockerShell.py as root (it still drops to `agent`), and waits until the server answers instead of sleeping. Setup failures stop the container. - images.py: DEBIAN_IMAGE / DEFAULT_IMAGE constants. - Remove the Dockerfile, the Docker Hub publish workflow and the CI image build; CI pulls the stock image instead. Preparation takes ~9s on the default image (pip install). Any image with sh, bash and python3 or a supported package manager works. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/dockerBuildPush.yml | 36 ----- .github/workflows/test.yml | 28 +--- .../image_builder/ShellCommunicator.md | 1 - .../local_docker/image_builder/dockerShell.md | 1 - .../environment/local_docker/images.md | 1 + .../shell_server/ShellCommunicator.md | 1 + .../local_docker/shell_server/dockerShell.md | 1 + docs/getting-started/microbots-logging.md | 3 +- mkdocs.yml | 7 +- .../local_docker/LocalDockerEnvironment.py | 140 ++++++++++++++++- .../local_docker/image_builder/Dockerfile | 29 ---- .../local_docker/image_builder/__init__.py | 0 .../environment/local_docker/images.py | 18 +++ .../ShellCommunicator.py | 0 .../local_docker/shell_server/__init__.py | 12 ++ .../dockerShell.py | 2 +- .../local_docker/shell_server/prepare.sh | 96 ++++++++++++ .../local_docker/test_docker_shell.py | 4 +- test/environment/local_docker/test_images.py | 41 +++++ .../test_local_docker_environment.py | 147 +++++++++++++++++- .../local_docker/test_shell_communicator.py | 6 +- 21 files changed, 459 insertions(+), 115 deletions(-) delete mode 100644 .github/workflows/dockerBuildPush.yml delete mode 100644 docs/api-reference/microbots/environment/local_docker/image_builder/ShellCommunicator.md delete mode 100644 docs/api-reference/microbots/environment/local_docker/image_builder/dockerShell.md create mode 100644 docs/api-reference/microbots/environment/local_docker/images.md create mode 100644 docs/api-reference/microbots/environment/local_docker/shell_server/ShellCommunicator.md create mode 100644 docs/api-reference/microbots/environment/local_docker/shell_server/dockerShell.md delete mode 100644 src/microbots/environment/local_docker/image_builder/Dockerfile delete mode 100644 src/microbots/environment/local_docker/image_builder/__init__.py create mode 100644 src/microbots/environment/local_docker/images.py rename src/microbots/environment/local_docker/{image_builder => shell_server}/ShellCommunicator.py (100%) create mode 100644 src/microbots/environment/local_docker/shell_server/__init__.py rename src/microbots/environment/local_docker/{image_builder => shell_server}/dockerShell.py (97%) create mode 100755 src/microbots/environment/local_docker/shell_server/prepare.sh create mode 100644 test/environment/local_docker/test_images.py diff --git a/.github/workflows/dockerBuildPush.yml b/.github/workflows/dockerBuildPush.yml deleted file mode 100644 index 5f2f9738..00000000 --- a/.github/workflows/dockerBuildPush.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: Build and Push to Docker Hub - -on: - workflow_dispatch: - push: - paths: - - 'src/microbots/environment/local_docker/image_builder/Dockerfile' - - 'src/microbots/environment/local_docker/image_builder/dockerShell.py' - - 'src/microbots/environment/local_docker/image_builder/ShellCommunicator.py' - - '.github/workflows/dockerBuildPush.yml' - branches: - - main - -jobs: - build-and-push: - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Log in to Docker Hub - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Build and push Docker image - run: | - IMAGE_NAME=${{ secrets.DOCKERHUB_USERNAME }}/shell_server - TAG=${{ github.sha }} - - # build with both commit SHA and latest tag - docker build -f src/microbots/environment/local_docker/image_builder/Dockerfile -t $IMAGE_NAME:$TAG -t $IMAGE_NAME:latest . - docker push $IMAGE_NAME:$TAG - docker push $IMAGE_NAME:latest diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 652950c0..3cbc9c72 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -141,19 +141,6 @@ jobs: with: python-version: "3.12" - - name: Set up Docker Buildx - if: matrix.test-type != 'unit' - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - - - name: Cache Docker layers - if: matrix.test-type != 'unit' - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: /tmp/.buildx-cache - key: ${{ runner.os }}-buildx-${{ hashFiles('src/microbots/environment/local_docker/image_builder/Dockerfile') }} - restore-keys: | - ${{ runner.os }}-buildx- - - name: Cache pip dependencies uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: @@ -194,19 +181,10 @@ jobs: if: matrix.test-type == 'unit' run: npm test --prefix azure-pipelines/MicrobotsLogAnalyzerTask - - name: Build Docker images for integration tests + - name: Pull the stock sandbox image for integration tests if: matrix.test-type != 'unit' - run: | - # Build the shell server image needed for Docker tests - docker buildx build \ - --cache-from type=local,src=/tmp/.buildx-cache \ - --cache-to type=local,dest=/tmp/.buildx-cache-new,mode=max \ - --load \ - -f src/microbots/environment/local_docker/image_builder/Dockerfile \ - -t kavyasree261002/shell_server:latest . - # Prevent cache from growing indefinitely - rm -rf /tmp/.buildx-cache - mv /tmp/.buildx-cache-new /tmp/.buildx-cache + # Sandboxes run this unmodified MCR image and are prepared on the fly. + run: docker pull mcr.microsoft.com/devcontainers/python:3.11 - name: Check disk space before ollama installation if: matrix.test-type == 'ollama_local' diff --git a/docs/api-reference/microbots/environment/local_docker/image_builder/ShellCommunicator.md b/docs/api-reference/microbots/environment/local_docker/image_builder/ShellCommunicator.md deleted file mode 100644 index a36aeb42..00000000 --- a/docs/api-reference/microbots/environment/local_docker/image_builder/ShellCommunicator.md +++ /dev/null @@ -1 +0,0 @@ -::: microbots.environment.local_docker.image_builder.ShellCommunicator diff --git a/docs/api-reference/microbots/environment/local_docker/image_builder/dockerShell.md b/docs/api-reference/microbots/environment/local_docker/image_builder/dockerShell.md deleted file mode 100644 index 4a5a8bcd..00000000 --- a/docs/api-reference/microbots/environment/local_docker/image_builder/dockerShell.md +++ /dev/null @@ -1 +0,0 @@ -::: microbots.environment.local_docker.image_builder.dockerShell diff --git a/docs/api-reference/microbots/environment/local_docker/images.md b/docs/api-reference/microbots/environment/local_docker/images.md new file mode 100644 index 00000000..e083ec3e --- /dev/null +++ b/docs/api-reference/microbots/environment/local_docker/images.md @@ -0,0 +1 @@ +::: microbots.environment.local_docker.images diff --git a/docs/api-reference/microbots/environment/local_docker/shell_server/ShellCommunicator.md b/docs/api-reference/microbots/environment/local_docker/shell_server/ShellCommunicator.md new file mode 100644 index 00000000..3759d5a2 --- /dev/null +++ b/docs/api-reference/microbots/environment/local_docker/shell_server/ShellCommunicator.md @@ -0,0 +1 @@ +::: microbots.environment.local_docker.shell_server.ShellCommunicator diff --git a/docs/api-reference/microbots/environment/local_docker/shell_server/dockerShell.md b/docs/api-reference/microbots/environment/local_docker/shell_server/dockerShell.md new file mode 100644 index 00000000..f63268bf --- /dev/null +++ b/docs/api-reference/microbots/environment/local_docker/shell_server/dockerShell.md @@ -0,0 +1 @@ +::: microbots.environment.local_docker.shell_server.dockerShell diff --git a/docs/getting-started/microbots-logging.md b/docs/getting-started/microbots-logging.md index c35995bd..72a99b37 100644 --- a/docs/getting-started/microbots-logging.md +++ b/docs/getting-started/microbots-logging.md @@ -38,7 +38,8 @@ Working directory creation, the host-to-container volume mapping, container star ```log INFO:microbots.environment.local_docker.LocalDockerEnvironment:🗂️ Created working directory at /home/user/MICROBOTS_WORKDIR_ INFO:microbots.environment.local_docker.LocalDockerEnvironment:📦 Volume mapping: /home/user/microbots-introduction/code → /ro/code -INFO:microbots.environment.local_docker.LocalDockerEnvironment:🚀 Started container with image microbots/shell_server:latest on host port +INFO:microbots.environment.local_docker.LocalDockerEnvironment:🚀 Started container with image mcr.microsoft.com/devcontainers/python:3.11 on host port +INFO:microbots.environment.local_docker.LocalDockerEnvironment:🛠️ Prepared mcr.microsoft.com/devcontainers/python:3.11 for the shell server in 9.0s INFO:microbots.environment.local_docker.LocalDockerEnvironment:🔒 Set up overlay mount for read-only directory at /workdir/code INFO:microbots.environment.local_docker.LocalDockerEnvironment:✅ Successfully copied /home/user/microbots-introduction/code/build.log to container:/var/log ``` diff --git a/mkdocs.yml b/mkdocs.yml index 0bf693b6..071a91b7 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -106,9 +106,10 @@ nav: - Environment: api-reference/microbots/environment/Environment.md - local_docker: - LocalDockerEnvironment: api-reference/microbots/environment/local_docker/LocalDockerEnvironment.md - - image_builder: - - dockerShell: api-reference/microbots/environment/local_docker/image_builder/dockerShell.md - - ShellCommunicator: api-reference/microbots/environment/local_docker/image_builder/ShellCommunicator.md + - images: api-reference/microbots/environment/local_docker/images.md + - shell_server: + - dockerShell: api-reference/microbots/environment/local_docker/shell_server/dockerShell.md + - ShellCommunicator: api-reference/microbots/environment/local_docker/shell_server/ShellCommunicator.md - extras: - mount: api-reference/microbots/extras/mount.md - llm: diff --git a/src/microbots/environment/local_docker/LocalDockerEnvironment.py b/src/microbots/environment/local_docker/LocalDockerEnvironment.py index 255dd4c7..85eee665 100644 --- a/src/microbots/environment/local_docker/LocalDockerEnvironment.py +++ b/src/microbots/environment/local_docker/LocalDockerEnvironment.py @@ -1,7 +1,9 @@ +import io import logging import os import shlex import subprocess +import tarfile import time from pathlib import Path from typing import Optional @@ -10,18 +12,63 @@ import requests from microbots.environment.Environment import CmdReturn, Environment +from microbots.environment.local_docker import shell_server +from microbots.environment.local_docker.images import DEFAULT_IMAGE from microbots.constants import DOCKER_WORKING_DIR, WORKING_DIR, PermissionLabels from microbots.extras.mount import Mount logger = logging.getLogger(__name__) +# Where the shell server and its venv live inside the sandbox (root-owned). +MICROBOTS_HOME = "/opt/microbots" +_SHELL_SERVER_DIR = Path(shell_server.__file__).parent +_SHELL_SERVER_FILES = ("dockerShell.py", "ShellCommunicator.py", "prepare.sh") +_SHELL_SERVER_LOG = "/var/log/microbots/shell_server.log" +# Preparation may install packages and pip-install the server's dependencies. +_SHELL_SERVER_READY_TIMEOUT = 120 + + +def _shell_server_archive() -> bytes: + """Tar ``shell_server/`` for ``put_archive``, root-owned so the bot cannot modify it.""" + buffer = io.BytesIO() + with tarfile.open(fileobj=buffer, mode="w") as tar: + entries = [("shell_server", _SHELL_SERVER_DIR)] + entries += [(f"shell_server/{name}", _SHELL_SERVER_DIR / name) for name in _SHELL_SERVER_FILES] + for arcname, path in entries: + info = tar.gettarinfo(str(path), arcname=arcname) + info.uid = info.gid = 0 + info.uname = info.gname = "root" + info.mode = 0o755 if info.isdir() or arcname.endswith(".sh") else 0o644 + if info.isfile(): + with open(path, "rb") as f: + tar.addfile(info, f) + else: + tar.addfile(info) + return buffer.getvalue() + class LocalDockerEnvironment(Environment): + """Sandboxed Docker container that hosts the bot's shell. + + Parameters + ---------- + port : int + Host loopback port mapped to the in-container shell server. + folder_to_mount : Optional[Mount] + Host folder to expose inside the sandbox. + image : str + Container image to run, for example ``images.DEBIAN_IMAGE``. Stock + images are prepared on the fly: the shell server is copied in and + its prerequisites are installed when the environment starts. Its + distribution is detected from the container's ``/etc/os-release`` + when needed. Defaults to ``DEFAULT_IMAGE``. + """ + def __init__( self, port: int, folder_to_mount: Optional[Mount] = None, - image: str = "kavyasree261002/shell_server:latest", + image: str = DEFAULT_IMAGE, ): self.image = image @@ -85,19 +132,18 @@ def start(self): self.container = self.client.containers.run( self.image, + # Keep the stock image idle; the shell server is started over the + # control plane once the image has been prepared. init forwards + # SIGTERM so the container stops promptly. + entrypoint=["sleep", "infinity"], + init=True, volumes=volumes_config, ports=port_mapping, detach=True, - working_dir="/app", # SYS_ADMIN is the narrowest grant that allows the overlayfs mount; # the docker-default AppArmor profile denies mount regardless of caps. cap_add=["SYS_ADMIN"], security_opt=["no-new-privileges:true", "apparmor=unconfined"], - environment={ - "BOT_PORT": str(self.container_port), - "BOT_WORKDIR": DOCKER_WORKING_DIR, - **self._host_identity(), - }, ) logger.info( "🚀 Started container %s with image %s on host port %s", @@ -105,7 +151,12 @@ def start(self): self.image, self.port, ) - time.sleep(2) # Give some time for the server to start + try: + self._prepare_shell_server() + self._launch_shell_server() + except Exception: + self.stop() + raise if self.folder_to_mount and self.folder_to_mount.permission == PermissionLabels.READ_ONLY: self._setup_overlay_mount() @@ -115,6 +166,79 @@ def start(self): else: self.execute("cd /") + def _shell_server_environment(self) -> dict: + """Environment for the shell server process inside the container.""" + return { + "BOT_PORT": str(self.container_port), + "BOT_WORKDIR": DOCKER_WORKING_DIR, + **self._host_identity(), + } + + def _prepare_shell_server(self): + """Copy the shell server into the container and prepare the image for it. + + Runs over the root control plane: ``prepare.sh`` installs missing + prerequisites, creates the ``agent`` account and the server's venv. + + Raises + ------ + RuntimeError + If the files cannot be copied or preparation fails. + """ + started = time.perf_counter() + ret = self.execute_privileged(f"mkdir -p {MICROBOTS_HOME}") + if ret.return_code != 0: + raise RuntimeError(f"Failed to create {MICROBOTS_HOME} in the sandbox: {ret.stderr}") + + if not self.container.put_archive(MICROBOTS_HOME, _shell_server_archive()): + raise RuntimeError("Failed to copy the shell server into the sandbox") + + identity = " ".join(f"{k}={v}" for k, v in self._host_identity().items()) + ret = self.execute_privileged( + f"{identity} sh {MICROBOTS_HOME}/shell_server/prepare.sh".strip() + ) + if ret.return_code != 0: + raise RuntimeError( + f"Failed to prepare image '{self.image}' for the shell server:\n" + f"{ret.stdout}\n{ret.stderr}" + ) + logger.info( + "🛠️ Prepared %s for the shell server in %.1fs", self.image, time.perf_counter() - started + ) + + def _launch_shell_server(self): + """Start the shell server as root (it drops to ``agent`` itself) and + wait until it answers on the published port. + + Raises + ------ + RuntimeError + If the server does not become ready in time. + """ + self.container.exec_run( + [ + "sh", + "-c", + f"cd {MICROBOTS_HOME}/shell_server && exec {MICROBOTS_HOME}/venv/bin/python " + f"dockerShell.py >> {_SHELL_SERVER_LOG} 2>&1", + ], + user="root", + detach=True, + environment=self._shell_server_environment(), + ) + deadline = time.monotonic() + _SHELL_SERVER_READY_TIMEOUT + while time.monotonic() < deadline: + try: + # Any HTTP answer (FastAPI rejects GET with 405) means it is up. + requests.get(f"http://localhost:{self.port}/", timeout=2) + return + except requests.exceptions.RequestException: + time.sleep(0.5) + log = self.execute_privileged(f"tail -n 50 {_SHELL_SERVER_LOG}").stdout + raise RuntimeError( + f"Shell server did not start within {_SHELL_SERVER_READY_TIMEOUT}s:\n{log}" + ) + @staticmethod def _host_identity() -> dict: """Run the bot's shell under the host uid so anything it writes to the diff --git a/src/microbots/environment/local_docker/image_builder/Dockerfile b/src/microbots/environment/local_docker/image_builder/Dockerfile deleted file mode 100644 index 8ae9e1ed..00000000 --- a/src/microbots/environment/local_docker/image_builder/Dockerfile +++ /dev/null @@ -1,29 +0,0 @@ -FROM mcr.microsoft.com/devcontainers/python:3.11 - -WORKDIR /app - -# Install required runtime dependencies -RUN pip install --no-cache-dir fastapi uvicorn pydantic - -# Bind-mounted repos are owned by the host user, not whoever runs inside the -# container, which trips git's "dubious ownership" safety check on every git -# command. -RUN git config --system --add safe.directory '*' - -# The bot shell runs as this unprivileged account. The devcontainer base image -# ships a 'vscode' user with NOPASSWD sudo, which would hand root straight back. -RUN (userdel -r vscode 2>/dev/null || true) && \ - rm -f /etc/sudoers.d/vscode && \ - groupadd -g 1001 agent && \ - useradd -m -u 1001 -g 1001 -s /bin/bash agent && \ - mkdir -p /var/log/microbots && \ - chown agent:agent /var/log/microbots - -# Copy application files. These stay root-owned so the bot cannot patch the -# shell server it talks to. -COPY src/microbots/environment/local_docker/image_builder/dockerShell.py . -COPY src/microbots/environment/local_docker/image_builder/ShellCommunicator.py . - -EXPOSE 8080 - -CMD ["python", "dockerShell.py"] diff --git a/src/microbots/environment/local_docker/image_builder/__init__.py b/src/microbots/environment/local_docker/image_builder/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/microbots/environment/local_docker/images.py b/src/microbots/environment/local_docker/images.py new file mode 100644 index 00000000..21c7ecef --- /dev/null +++ b/src/microbots/environment/local_docker/images.py @@ -0,0 +1,18 @@ +"""Stock container images for LocalDockerEnvironment sandboxes. + +Sandboxes run unmodified images from the Microsoft Container Registry. When +an environment starts, Microbots copies its shell server into the container +and prepares the image on the fly (``shell_server/prepare.sh``): it installs +anything the server needs that is missing, creates the unprivileged ``agent`` +account and starts the server. + +Any image works if it provides ``sh`` and ``bash`` plus either ``python3`` or +a package manager (``apt-get``, ``tdnf``, ``dnf`` or ``yum``) to install it. +The sandbox's distribution is read from the container's ``/etc/os-release`` +when needed; it is never configured separately. +""" + +# Debian with Python and a full developer toolset (git, curl, build tools). +DEBIAN_IMAGE = "mcr.microsoft.com/devcontainers/python:3.11" + +DEFAULT_IMAGE = DEBIAN_IMAGE diff --git a/src/microbots/environment/local_docker/image_builder/ShellCommunicator.py b/src/microbots/environment/local_docker/shell_server/ShellCommunicator.py similarity index 100% rename from src/microbots/environment/local_docker/image_builder/ShellCommunicator.py rename to src/microbots/environment/local_docker/shell_server/ShellCommunicator.py diff --git a/src/microbots/environment/local_docker/shell_server/__init__.py b/src/microbots/environment/local_docker/shell_server/__init__.py new file mode 100644 index 00000000..f9fd0eaa --- /dev/null +++ b/src/microbots/environment/local_docker/shell_server/__init__.py @@ -0,0 +1,12 @@ +"""Shell server that runs inside every LocalDockerEnvironment sandbox. + +LocalDockerEnvironment copies this directory into a stock container image +when the environment starts, runs ``prepare.sh`` as root to install missing +prerequisites and create the unprivileged ``agent`` account, then starts +``dockerShell.py``. It exposes a FastAPI endpoint that relays commands to a +persistent bash session managed by ``ShellCommunicator.py``. + +The two Python files run inside the container as top-level scripts, so they +must only use the Python standard library plus fastapi, uvicorn and pydantic +(installed by ``prepare.sh`` into a root-owned venv). +""" diff --git a/src/microbots/environment/local_docker/image_builder/dockerShell.py b/src/microbots/environment/local_docker/shell_server/dockerShell.py similarity index 97% rename from src/microbots/environment/local_docker/image_builder/dockerShell.py rename to src/microbots/environment/local_docker/shell_server/dockerShell.py index 97335b1a..4b414128 100644 --- a/src/microbots/environment/local_docker/image_builder/dockerShell.py +++ b/src/microbots/environment/local_docker/shell_server/dockerShell.py @@ -23,7 +23,7 @@ logger = logging.getLogger(__name__) -# Must match the account created in the Dockerfile. +# Must match the account created by prepare.sh. AGENT_USER = "agent" DEFAULT_AGENT_UID = 1001 DEFAULT_AGENT_GID = 1001 diff --git a/src/microbots/environment/local_docker/shell_server/prepare.sh b/src/microbots/environment/local_docker/shell_server/prepare.sh new file mode 100755 index 00000000..8cce1cdb --- /dev/null +++ b/src/microbots/environment/local_docker/shell_server/prepare.sh @@ -0,0 +1,96 @@ +#!/bin/sh +# Prepare a stock image to host the Microbots shell server. +# +# LocalDockerEnvironment copies this directory to /opt/microbots/shell_server +# and runs this script as root before starting dockerShell.py. It is +# idempotent, so images that already provide everything start faster. +set -eu + +MICROBOTS_HOME=/opt/microbots +AGENT_USER=agent +AGENT_UID="${AGENT_UID:-1001}" +AGENT_GID="${AGENT_GID:-1001}" + +log() { echo "[microbots-prepare] $*"; } +have() { command -v "$1" >/dev/null 2>&1; } + +# ---- Packages the server and the privilege drop need, installed only if missing. +missing="" +have python3 || missing="$missing python3" +{ have useradd && have usermod && have groupmod; } || missing="$missing shadow" +have mount || missing="$missing mount" +have git || missing="$missing git" +python3 -c "import venv, ensurepip" >/dev/null 2>&1 || missing="$missing venv" + +if [ -n "$missing" ]; then + log "installing:$missing" + pkgs="" + if have apt-get; then + for m in $missing; do case $m in + python3|venv) pkgs="$pkgs python3 python3-venv";; + shadow) pkgs="$pkgs passwd";; + mount) pkgs="$pkgs mount";; + git) pkgs="$pkgs git";; + esac; done + # Stale third-party sources with expired keys would break apt-get update. + rm -f /etc/apt/sources.list.d/yarn.list + apt-get update -qq + DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \ + $pkgs ca-certificates >/dev/null + elif have tdnf || have dnf || have yum; then + pm=$(command -v tdnf || command -v dnf || command -v yum) + for m in $missing; do case $m in + python3|venv) pkgs="$pkgs python3 python3-pip";; + shadow) pkgs="$pkgs shadow-utils";; + mount) pkgs="$pkgs util-linux";; + git) pkgs="$pkgs git";; + esac; done + "$pm" install -y $pkgs ca-certificates >/dev/null + else + # Unknown distro: carry on and let the first command that needs a + # missing tool report the failure. + log "no supported package manager (apt-get, tdnf, dnf, yum) to install:$missing" + fi +fi + +# ---- Agent account. Other non-root accounts holding the agent's uid/gid are +# removed so the agent cannot inherit them, along with devcontainer images' +# passwordless-sudo 'vscode' user. +remove_user() { + log "removing user $1" + userdel -r "$1" >/dev/null 2>&1 || userdel "$1" + rm -f "/etc/sudoers.d/$1" +} +id vscode >/dev/null 2>&1 && remove_user vscode +while IFS=: read -r name _ uid _; do + if [ "$uid" = "$AGENT_UID" ] && [ "$name" != "$AGENT_USER" ] && [ "$uid" != 0 ]; then + remove_user "$name" + fi +done < /etc/passwd +while IFS=: read -r name _ gid _; do + if [ "$gid" = "$AGENT_GID" ] && [ "$name" != "$AGENT_USER" ] && [ "$gid" != 0 ]; then + log "removing group $name" + groupdel "$name" || log "could not remove group $name" + fi +done < /etc/group +if ! id "$AGENT_USER" >/dev/null 2>&1; then + getent group "$AGENT_USER" >/dev/null || groupadd -g "$AGENT_GID" "$AGENT_USER" + useradd -m -u "$AGENT_UID" -g "$AGENT_USER" -s /bin/bash "$AGENT_USER" +fi +mkdir -p /var/log/microbots + +# Bind-mounted repos are owned by the host user, not whoever runs inside the +# container, which trips git's "dubious ownership" safety check. +if have git && ! git config --system --get-all safe.directory 2>/dev/null | grep -qx '\*'; then + git config --system --add safe.directory '*' +fi + +# ---- Root-owned venv with the server's dependencies, isolated from the +# agent's Python. Root ownership also keeps the bot from patching its server. +if ! "$MICROBOTS_HOME/venv/bin/python" -c "import fastapi, uvicorn, pydantic" >/dev/null 2>&1; then + log "creating $MICROBOTS_HOME/venv" + python3 -m venv "$MICROBOTS_HOME/venv" + "$MICROBOTS_HOME/venv/bin/pip" install --no-cache-dir --disable-pip-version-check -q \ + fastapi uvicorn pydantic +fi +log "ready" diff --git a/test/environment/local_docker/test_docker_shell.py b/test/environment/local_docker/test_docker_shell.py index 0b2908e4..53953dfe 100644 --- a/test/environment/local_docker/test_docker_shell.py +++ b/test/environment/local_docker/test_docker_shell.py @@ -11,7 +11,7 @@ import pytest -from microbots.environment.local_docker import image_builder +from microbots.environment.local_docker import shell_server pytestmark = pytest.mark.unit @@ -19,7 +19,7 @@ @pytest.fixture def shell_runtime(monkeypatch): - builder = Path(image_builder.__file__).parent + builder = Path(shell_server.__file__).parent runtime = Mock() runtime.geteuid.return_value = 0 for name in ("geteuid", "setgroups", "setgid", "setuid"): diff --git a/test/environment/local_docker/test_images.py b/test/environment/local_docker/test_images.py new file mode 100644 index 00000000..d54bf589 --- /dev/null +++ b/test/environment/local_docker/test_images.py @@ -0,0 +1,41 @@ +"""Unit tests for the stock image constants and the image preparation script.""" + +import os +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../src"))) + +from microbots.environment.local_docker import shell_server +from microbots.environment.local_docker.images import DEBIAN_IMAGE, DEFAULT_IMAGE + +pytestmark = pytest.mark.unit + +PREPARE = Path(shell_server.__file__).parent / "prepare.sh" + + +def test_default_image_is_stock_mcr_debian(): + assert DEFAULT_IMAGE == DEBIAN_IMAGE + assert DEBIAN_IMAGE.startswith("mcr.microsoft.com/") + + +def test_prepare_script_is_executable_posix_sh(): + assert os.access(PREPARE, os.X_OK) + assert PREPARE.read_text().startswith("#!/bin/sh\n") + + +@pytest.mark.skipif(shutil.which("sh") is None, reason="needs a POSIX shell") +def test_prepare_script_parses(): + subprocess.run(["sh", "-n", str(PREPARE)], check=True) + + +def test_prepare_script_installs_server_dependencies_into_root_venv(): + text = PREPARE.read_text() + assert 'python3 -m venv "$MICROBOTS_HOME/venv"' in text + assert "fastapi uvicorn pydantic" in text + # The devcontainer image's passwordless-sudo account must not survive. + assert "remove_user vscode" in text diff --git a/test/environment/local_docker/test_local_docker_environment.py b/test/environment/local_docker/test_local_docker_environment.py index 71d31125..a4aa293c 100644 --- a/test/environment/local_docker/test_local_docker_environment.py +++ b/test/environment/local_docker/test_local_docker_environment.py @@ -7,6 +7,7 @@ import re import logging import time +import requests from unittest.mock import patch, Mock, MagicMock, call # Add src to path for imports @@ -15,6 +16,7 @@ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../src"))) from microbots.environment.local_docker.LocalDockerEnvironment import LocalDockerEnvironment +from microbots.environment.local_docker.images import DEBIAN_IMAGE, DEFAULT_IMAGE from microbots.environment.Environment import CmdReturn from microbots.extras.mount import Mount from microbots.constants import DOCKER_WORKING_DIR, WORKING_DIR @@ -587,25 +589,45 @@ def test_start_restricts_container_privileges_and_published_port(self, env): env.container_port = 8080 env.client = Mock() env.client.containers.run.return_value.id = "test_container" - identity = {"AGENT_UID": "2001", "AGENT_GID": "2002"} + calls = [] - with patch.object(env, "_host_identity", return_value=identity), \ - patch("microbots.environment.local_docker.LocalDockerEnvironment.time.sleep"): + with patch.object(env, "_prepare_shell_server", side_effect=lambda: calls.append("prepare")), \ + patch.object(env, "_launch_shell_server", side_effect=lambda: calls.append("launch")): env.start() env.client.containers.run.assert_called_once_with( "test_image", + entrypoint=["sleep", "infinity"], + init=True, volumes={"/tmp/test_workdir": {"bind": DOCKER_WORKING_DIR, "mode": "rw"}}, ports={"8080/tcp": ("127.0.0.1", 12345)}, detach=True, - working_dir="/app", cap_add=["SYS_ADMIN"], security_opt=["no-new-privileges:true", "apparmor=unconfined"], - environment={"BOT_PORT": "8080", "BOT_WORKDIR": DOCKER_WORKING_DIR, **identity}, ) assert env.container is env.client.containers.run.return_value + assert calls == ["prepare", "launch"] env.execute.assert_called_once_with("cd /") + @pytest.mark.parametrize("failing", ["_prepare_shell_server", "_launch_shell_server"]) + def test_start_stops_container_when_shell_server_setup_fails(self, env, failing): + env.image = "test_image" + env.working_dir = "/tmp/test_workdir" + env.folder_to_mount = None + env.port = 12345 + env.container_port = 8080 + env.client = Mock() + env.client.containers.run.return_value.id = "test_container" + env.stop = Mock() + + with patch.object(env, "_prepare_shell_server"), patch.object(env, "_launch_shell_server"), \ + patch.object(env, failing, side_effect=RuntimeError("boom")): + with pytest.raises(RuntimeError, match="boom"): + env.start() + + env.stop.assert_called_once_with() + env.execute.assert_not_called() + @pytest.mark.parametrize( "exit_code, stdout, stderr, expected", [ @@ -709,3 +731,118 @@ def test_teardown_overlay_exception_resets_state(self, env, caplog): assert "Failed to teardown overlay mount: container unavailable" in caplog.text assert env.overlay_mount is False env.execute.assert_not_called() + + +@pytest.mark.unit +class TestImageSelection: + """The caller picks the image; it defaults to the Debian shell-server image.""" + + @pytest.fixture + def make_env(self): + def _make(**kwargs): + with patch.object(LocalDockerEnvironment, "_create_working_dir"), \ + patch.object(LocalDockerEnvironment, "start"), \ + patch("microbots.environment.local_docker.LocalDockerEnvironment.docker"): + env = LocalDockerEnvironment(port=1234, **kwargs) + env.deleted = True + return env + return _make + + def test_defaults_to_debian_image(self, make_env): + assert DEFAULT_IMAGE == DEBIAN_IMAGE == "mcr.microsoft.com/devcontainers/python:3.11" + assert make_env().image == DEFAULT_IMAGE + + def test_explicit_image_is_used(self, make_env): + assert make_env(image="custom/shell:dev").image == "custom/shell:dev" + + +@pytest.mark.unit +class TestShellServerPreparation: + """The stock image is prepared and the shell server started over the root control plane.""" + + @pytest.fixture + def env(self): + env = LocalDockerEnvironment.__new__(LocalDockerEnvironment) + env.deleted = True + env.image = "mcr.example/stock:1" + env.port = 12345 + env.container_port = 8080 + env.container = Mock() + env.container.put_archive.return_value = True + env.execute_privileged = Mock(return_value=CmdReturn("ok", "", 0)) + return env + + def test_archive_contains_root_owned_shell_server(self): + import io + import tarfile + from microbots.environment.local_docker.LocalDockerEnvironment import _shell_server_archive + + with tarfile.open(fileobj=io.BytesIO(_shell_server_archive())) as tar: + members = {m.name: m for m in tar.getmembers()} + assert set(members) == { + "shell_server", + "shell_server/dockerShell.py", + "shell_server/ShellCommunicator.py", + "shell_server/prepare.sh", + } + assert all(m.uid == 0 and m.gid == 0 for m in members.values()) + assert members["shell_server"].mode == 0o755 + assert members["shell_server/prepare.sh"].mode == 0o755 + assert members["shell_server/dockerShell.py"].mode == 0o644 + + @pytest.mark.parametrize("identity, prefix", [ + ({}, ""), + ({"AGENT_UID": "2001", "AGENT_GID": "2002"}, "AGENT_UID=2001 AGENT_GID=2002 "), + ]) + def test_prepare_copies_server_and_runs_prepare_script_as_root(self, env, identity, prefix): + with patch.object(env, "_host_identity", return_value=identity): + env._prepare_shell_server() + + env.container.put_archive.assert_called_once() + assert env.container.put_archive.call_args.args[0] == "/opt/microbots" + assert env.execute_privileged.call_args_list == [ + call("mkdir -p /opt/microbots"), + call(f"{prefix}sh /opt/microbots/shell_server/prepare.sh"), + ] + + def test_prepare_failure_reports_script_output(self, env): + env.execute_privileged.side_effect = [CmdReturn("", "", 0), CmdReturn("out", "no package manager", 1)] + with patch.object(env, "_host_identity", return_value={}): + with pytest.raises(RuntimeError, match="Failed to prepare image 'mcr.example/stock:1'(.|\n)*no package manager"): + env._prepare_shell_server() + + def test_prepare_fails_when_copy_fails(self, env): + env.container.put_archive.return_value = False + with patch.object(env, "_host_identity", return_value={}): + with pytest.raises(RuntimeError, match="Failed to copy the shell server"): + env._prepare_shell_server() + + def test_launch_starts_server_as_root_and_waits_until_it_answers(self, env): + identity = {"AGENT_UID": "2001", "AGENT_GID": "2002"} + with patch.object(env, "_host_identity", return_value=identity), \ + patch("microbots.environment.local_docker.LocalDockerEnvironment.requests.get", + side_effect=[requests.exceptions.ConnectionError(), Mock(status_code=405)]) as get, \ + patch("microbots.environment.local_docker.LocalDockerEnvironment.time.sleep"): + env._launch_shell_server() + + args, kwargs = env.container.exec_run.call_args + assert args[0][:2] == ["sh", "-c"] + assert "exec /opt/microbots/venv/bin/python dockerShell.py" in args[0][2] + assert kwargs == { + "user": "root", + "detach": True, + "environment": {"BOT_PORT": "8080", "BOT_WORKDIR": DOCKER_WORKING_DIR, **identity}, + } + assert get.call_count == 2 + get.assert_called_with("http://localhost:12345/", timeout=2) + + def test_launch_times_out_with_server_log(self, env): + env.execute_privileged.return_value = CmdReturn("Traceback: boom", "", 0) + clock = iter([0, 0, 200]) + with patch.object(env, "_host_identity", return_value={}), \ + patch("microbots.environment.local_docker.LocalDockerEnvironment.requests.get", + side_effect=requests.exceptions.ConnectionError()), \ + patch("microbots.environment.local_docker.LocalDockerEnvironment.time.sleep"), \ + patch("microbots.environment.local_docker.LocalDockerEnvironment.time.monotonic", side_effect=lambda: next(clock)): + with pytest.raises(RuntimeError, match="did not start within 120s(.|\n)*Traceback: boom"): + env._launch_shell_server() diff --git a/test/environment/local_docker/test_shell_communicator.py b/test/environment/local_docker/test_shell_communicator.py index 0bd02320..841cd54d 100644 --- a/test/environment/local_docker/test_shell_communicator.py +++ b/test/environment/local_docker/test_shell_communicator.py @@ -10,7 +10,7 @@ import pytest -from microbots.environment.local_docker import image_builder +from microbots.environment.local_docker import shell_server pytestmark = pytest.mark.unit @@ -19,7 +19,7 @@ @pytest.fixture def shell(monkeypatch): spec = importlib.util.spec_from_file_location( - "ShellCommunicator", Path(image_builder.__file__).parent / "ShellCommunicator.py" + "ShellCommunicator", Path(shell_server.__file__).parent / "ShellCommunicator.py" ) module = importlib.util.module_from_spec(spec) monkeypatch.setitem(sys.modules, "ShellCommunicator", module) @@ -39,7 +39,7 @@ def shell(monkeypatch): def unstarted_shell(monkeypatch): """A ShellCommunicator instance that has never had start_session() called.""" spec = importlib.util.spec_from_file_location( - "ShellCommunicator", Path(image_builder.__file__).parent / "ShellCommunicator.py" + "ShellCommunicator", Path(shell_server.__file__).parent / "ShellCommunicator.py" ) module = importlib.util.module_from_spec(spec) monkeypatch.setitem(sys.modules, "ShellCommunicator", module)