From 7b5a5e3c274443d367f456ef2591c5f492f185a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 15:39:10 +0000 Subject: [PATCH] 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//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. --- .dockerignore | 1 + .github/workflows/docker-control.yml | 68 ++++++++++++++++++++++++++++ Dockerfile.control | 65 ++++++++++++++++++++++++++ README.md | 26 +++++++++++ docker-compose.yml | 27 +++++++++++ 5 files changed, 187 insertions(+) create mode 100644 .github/workflows/docker-control.yml create mode 100644 Dockerfile.control diff --git a/.dockerignore b/.dockerignore index 3194e75..a8b1914 100644 --- a/.dockerignore +++ b/.dockerignore @@ -15,6 +15,7 @@ docs !.env.example *.db Dockerfile +Dockerfile.control docker-compose.yml .dockerignore .mise.toml diff --git a/.github/workflows/docker-control.yml b/.github/workflows/docker-control.yml new file mode 100644 index 0000000..746187d --- /dev/null +++ b/.github/workflows/docker-control.yml @@ -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 diff --git a/Dockerfile.control b/Dockerfile.control new file mode 100644 index 0000000..bdb2389 --- /dev/null +++ b/Dockerfile.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"] diff --git a/README.md b/README.md index 5da0730..90675e4 100644 --- a/README.md +++ b/README.md @@ -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): diff --git a/docker-compose.yml b/docker-compose.yml index 8af71b8..1714a1b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: