mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-08-30 20:06:24 +00:00
build: containerize the control layer and publish it to GHCR
Add a second image for the handler control CLI (spawn/list/kill, approve/ reject, forge-init, CI poller) alongside the existing API image. It shares the package, database, and /var/lib/handler volume but runs the control process instead of uvicorn. - Dockerfile.control: git + tmux baked in for live spawning; default CMD is the `poll-ci --watch` loop; RUN_MIGRATIONS toggle reuses docker-entrypoint.sh. - docker-control.yml: builds/pushes ghcr.io/<repo>/control (multi-arch), scoped gha cache so it doesn't clobber the API build. - docker-compose.yml: new `control` service, RUN_MIGRATIONS=false, depends on the API (which owns migrations) being healthy. - README: Containers section documenting both images and compose usage.
This commit is contained in:
@@ -15,6 +15,7 @@ docs
|
||||
!.env.example
|
||||
*.db
|
||||
Dockerfile
|
||||
Dockerfile.control
|
||||
docker-compose.yml
|
||||
.dockerignore
|
||||
.mise.toml
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
name: docker-control
|
||||
|
||||
# Builds the control-layer image (the `handler` CLI / CI poller) from Dockerfile.control
|
||||
# and publishes it to GHCR alongside the API image built by docker.yml.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
IMAGE: ghcr.io/${{ github.repository }}/control
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Tags: branch name on branch pushes, semver on v* tags, short SHA always,
|
||||
# `latest` only on the default branch. metadata-action lowercases the repo
|
||||
# name (GHCR requires lowercase).
|
||||
- name: Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.IMAGE }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=sha
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.control
|
||||
platforms: linux/amd64,linux/arm64
|
||||
# PRs build (to catch Dockerfile breakage) but never push.
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha,scope=control
|
||||
cache-to: type=gha,mode=max,scope=control
|
||||
@@ -0,0 +1,65 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# Control-layer image: the `handler` CLI (the write side — spawn/list/kill agents,
|
||||
# approve/reject branches, forge-init, and the CI poller). It shares the package, the
|
||||
# database, and the /var/lib/handler data volume with the API image (see Dockerfile),
|
||||
# but runs the control process instead of uvicorn.
|
||||
|
||||
# ---- build stage: install the package + deps into an isolated venv ----
|
||||
FROM python:3.11-slim AS builder
|
||||
|
||||
ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||
PIP_NO_CACHE_DIR=1
|
||||
|
||||
RUN python -m venv /opt/venv
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
WORKDIR /build
|
||||
COPY pyproject.toml README.md ./
|
||||
COPY src ./src
|
||||
RUN pip install .
|
||||
|
||||
# ---- runtime stage ----
|
||||
FROM python:3.11-slim
|
||||
|
||||
# git + tmux are the live-spawning dependencies the control layer shells out to
|
||||
# (README "Requirements"); openssh-client covers git-over-ssh remotes. The `claude`
|
||||
# and `forge` binaries are bring-your-own — layer or mount them in for live agent
|
||||
# spawning and CI resolution; the poller degrades gracefully when forge is absent.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends git tmux openssh-client \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV PATH="/opt/venv/bin:$PATH" \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
# SQLite fallback lives on the /var/lib/handler volume; point DATABASE_URL at the
|
||||
# same Postgres the API uses for a shared, real deploy (see docker-compose.yml).
|
||||
DATABASE_URL="sqlite:////var/lib/handler/handler.db" \
|
||||
PROJECTS_ROOT="/var/lib/handler/projects"
|
||||
|
||||
COPY --from=builder /opt/venv /opt/venv
|
||||
|
||||
# Ship alembic alongside the package so the image can migrate standalone if asked
|
||||
# (RUN_MIGRATIONS=true). In the compose stack the API owns migrations and the control
|
||||
# service runs with RUN_MIGRATIONS=false to avoid a startup race.
|
||||
WORKDIR /app
|
||||
COPY alembic.ini ./
|
||||
COPY src/handler/migrations ./src/handler/migrations
|
||||
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
RUN useradd --system --home-dir /var/lib/handler --create-home handler \
|
||||
&& mkdir -p /var/lib/handler/projects \
|
||||
&& chown -R handler:handler /var/lib/handler \
|
||||
&& chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
USER handler
|
||||
VOLUME /var/lib/handler
|
||||
|
||||
# Liveness: exercises the CLI end-to-end and confirms the database is reachable.
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD handler list >/dev/null 2>&1 || exit 1
|
||||
|
||||
# Default to the CI poller — the one long-running control process. Override the command
|
||||
# for one-shot control operations, e.g. `docker compose run --rm control handler list`.
|
||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||
CMD ["handler", "poll-ci", "--watch"]
|
||||
@@ -143,6 +143,32 @@ curl -s -X POST $BASE/projects/leeworks-api/agents/api/resume -H "$TOKEN" \
|
||||
-H 'Content-Type: application/json' -d '{}'
|
||||
```
|
||||
|
||||
## Containers
|
||||
|
||||
Two images are published to GHCR, one per process, sharing the package, the database, and
|
||||
the `/var/lib/handler` data volume:
|
||||
|
||||
| Image | Dockerfile | Runs | Workflow |
|
||||
|---|---|---|---|
|
||||
| `ghcr.io/0xwheatyz/handler` | [`Dockerfile`](Dockerfile) | the API (`uvicorn`) — also applies migrations on start | [`docker.yml`](.github/workflows/docker.yml) |
|
||||
| `ghcr.io/0xwheatyz/handler/control` | [`Dockerfile.control`](Dockerfile.control) | the control layer (`handler poll-ci --watch`) | [`docker-control.yml`](.github/workflows/docker-control.yml) |
|
||||
|
||||
The control image bakes in `git` + `tmux`; the `claude` and `forge` binaries are
|
||||
bring-your-own (layer or mount them in for live agent spawning — the CI poller degrades
|
||||
gracefully without `forge`).
|
||||
|
||||
[`docker-compose.yml`](docker-compose.yml) wires both up with Postgres. The API owns
|
||||
migrations, so the control service runs with `RUN_MIGRATIONS=false` and waits for the API:
|
||||
|
||||
```bash
|
||||
export AUTH_TOKEN="$(openssl rand -hex 32)"
|
||||
docker compose up -d # db + api + control (CI poller)
|
||||
|
||||
# One-shot control commands run against the same image:
|
||||
docker compose run --rm control handler list
|
||||
docker compose run --rm control handler spawn --project leeworks-api --name junior --task "…"
|
||||
```
|
||||
|
||||
## Control CLI
|
||||
|
||||
The `handler` command manages agent processes (the write side):
|
||||
|
||||
@@ -22,6 +22,33 @@ services:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
# Control layer: the `handler` CLI running the CI poller loop. Shares the database and
|
||||
# the handler-data volume with the API. It waits for the API (which owns migrations),
|
||||
# so RUN_MIGRATIONS is off here to avoid a startup race. Run one-shot control commands
|
||||
# against the same image with, e.g., `docker compose run --rm control handler list`.
|
||||
# Live agent spawning also needs `git`/`tmux` (baked in) plus bring-your-own
|
||||
# `claude`/`forge` binaries — layer or mount those in.
|
||||
control:
|
||||
image: ghcr.io/0xwheatyz/handler/control:latest
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.control
|
||||
environment:
|
||||
DATABASE_URL: postgresql+psycopg://handler:handler@db:5432/handler
|
||||
RUN_MIGRATIONS: "false"
|
||||
PROJECTS_ROOT: /var/lib/handler/projects
|
||||
# Per-project forge credentials are resolved from credential_ref pointers at spawn;
|
||||
# export the referenced vars here when spawning agents from this container.
|
||||
FORGE_VERSION: ${FORGE_VERSION:-}
|
||||
volumes:
|
||||
- handler-data:/var/lib/handler
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
api:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
|
||||
Reference in New Issue
Block a user