# Container Manager The Container Manager runs supervised container instances with full lifecycle control from the web UI, the HTTP API, and Devii. Every instance runs ONE shared, prebuilt image; there is no per-project image building inside the app. A reconciling `BaseService` supervises the instances. > Audience: administrators. All run, exec, lifecycle, and schedule operations require an administrator, > because running containers with bind mounts and access to the docker socket is root-equivalent on the > host. > Visibility scoping: container access inherits the project's visibility through > `content.can_view_project`. A container attached to a project hidden by a **member** is visible to > every administrator, but a container attached to a project hidden by an **administrator** is visible > only to that owner administrator - every other administrator is blocked from listing it, opening a > terminal on it, exec-ing in it, or managing it, on both the web UI and the REST API. The opt-in public > ingress at `/p/{slug}` and the generic admin raw DB API `/dbapi` are deliberate exceptions and are not > scoped this way. ## Pieces - **Backend abstraction** (`services/containers/backend/`): a `Backend` ABC with a `DockerCliBackend` that drives the `docker` CLI via `asyncio.create_subprocess_exec`, streaming stdout line by line. A `FakeBackend` implements the same interface for tests with no daemon. The active backend is resolved by `runtime.get_backend()` and is the pluggable seam for a future Kubernetes or remote backend. - **Data model** (indexed in `database.py`): `instances`, `instance_events` (audit), `instance_metrics` (ring-buffered), `instance_schedules`. There are no dockerfile or build tables. - **ContainerService** (`service.py`): a reconciler. Each tick it snapshots `docker ps` (filtered by the `devplace.instance` label), converges every instance to its `desired_state`, applies restart policies, reaps orphan containers whose DB row is gone, fires due schedules, and samples metrics. The `devplace.instance=` label is the join key, guaranteeing no orphans and no lost state. Only the service lock owner reconciles; HTTP handlers only edit `desired_state`. ## The shared `ppy` image Every instance runs `ppy:latest` (override with the `DEVPLACE_CONTAINER_IMAGE` env var), built **once** with `make ppy` from `ppy.Dockerfile`. The image is a Python + Playwright base with a broad set of common Python libraries preinstalled, and it bakes in the security model: the `pravda` user at uid/gid `1000:1000`, a `sudo` superclone that never escalates (it runs commands as `pravda`, so a container can never write a root-owned file into the bind-mounted workspace), and the stdlib `pagent` agent at `/usr/bin/pagent.py`. Creating an instance is then an instant `docker run` with no build wait; it fails fast with a clear error if the `ppy` image has not been built yet. Projects that need a library not in the image install it at runtime with `pip install` (pravda owns the site-packages, so no `sudo` is needed), or add it to `ppy.Dockerfile` and rerun `make ppy`. ## Instances and lifecycle An instance is created with a name, optional boot command, env vars, CPU/memory limits, port maps, and restart policy. The project's files are materialized to a persistent host workspace and bind-mounted read-write as `/app`; the sync action imports container-side changes back into the project filesystem. Lifecycle operations (start, stop, restart, pause, resume, delete) flip the instance's `desired_state` and the reconciler executes them. Schedules use the shared cron/interval/one-time scheduler to start or stop instances. One-shot exec runs over HTTP; an interactive shell runs over a PTY-backed WebSocket on the lock owner. Each launch also injects per-instance `DEVPLACE_*` env vars (`DEVPLACE_BASE_URL`, `DEVPLACE_OPENAI_URL`, `DEVPLACE_API_KEY`, `DEVPLACE_USER_UID`, `DEVPLACE_CONTAINER_NAME`, `DEVPLACE_CONTAINER_UID`) so code inside the container - and `pagent` - can call back into the platform and the AI gateway authenticated as the user. ## Runtime data and operations All generated data lives OUTSIDE the `devplacepy/` package and is never served via `/static`: persistent workspaces under `config.DATA_DIR/container_workspaces` (default `data/`, configurable with the `DEVPLACE_DATA_DIR` env var - point it at a volume in production). The docker daemon must be able to bind-mount `DATA_DIR` for the `/app` mount. The service is disabled by default (it needs the docker socket); an administrator enables **Containers** on `/admin/services`, and the `ppy` image must be built once with `make ppy`. ## Publishing a service (ingress) A running instance can be exposed by setting an `ingress_slug` plus an `ingress_port` (one of its mapped container ports). The `/p/` route (`routers/proxy.py`) then reverse-proxies both HTTP and WebSocket traffic to that container's published host port, stripping the `/p/` prefix. It is public but SSRF-safe: the target host and port are derived from the instance row, never from the request. A container WebSocket server on port 8899 becomes reachable at `wss:///p//ws`. Set the **Public URL** in admin settings (`/admin/settings`) to your production origin; the container tools then return an absolute `ingress_url` (for example `https://your-host/p/`) so Devii and API clients use the real public address rather than localhost. ## Production The manager drives the host docker daemon, so production needs the opt-in overlay `docker-compose.containers.yml`. The `make docker-build` and `make docker-up` targets apply it automatically and self-derive its inputs, so it works with no `sudo`, no `/srv` dir, and no manual `.env` editing; a plain `docker compose up -d` drops the overlay and re-breaks it. The overlay mounts the docker socket (root on the host - trusted admins only), installs the docker CLI via the `INSTALL_DOCKER_CLI` build arg, and adds the host docker group (gid read from the socket via `stat -c '%g' /var/run/docker.sock`). Two docker-in-docker details matter. First, the data dir must be bind-mounted at the **same absolute path** on host and in the app container (make sets `DEVPLACE_DATA_DIR` to the project's own `./data`) because `docker run -v` resolves the source against the host. Second, the ingress proxy reaches a published port through the container's own docker bridge gateway plus the published host port: the reconciler records each instance's `container_ip` and `container_gateway` from `docker inspect`, and the `/p/` proxy dials `gateway:host_port` by default. This avoids the loopback path, which fails on hosts where docker's `nat OUTPUT` rule excludes `127.0.0.0/8` from DNAT and no `docker-proxy` binds loopback for a `0.0.0.0`-published port. It also avoids the container's own bridge IP, which docker's bridge-isolation rule (`DOCKER ! -i docker0 -o docker0 -j DROP`) can drop from the host. Set `DEVPLACE_CONTAINER_PROXY_HOST` to override this (a containerized app via the docker socket uses `host.docker.internal`); the proxy then dials that host with the published port instead of the gateway. Before the reconciler has recorded a gateway, the proxy falls back to `127.0.0.1`. Run `make ppy` on the production host too. Full steps are in the README "Container Manager wiring". ## Observability, CLI, and Devii The service tile on `/admin/services` shows instance counts; per-instance aggregates (CPU/memory, p95 runtime) are computed on read over the ring buffer. The CLI exposes `devplace containers list | reconcile | prune | prune-builds | gc-workspaces` (`prune-builds` is a one-time cleanup that removes legacy per-project images and the old dockerfiles/builds tables). Devii's admin `container_*` tools cover the same instance operations in natural language.