diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3194e75 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,20 @@ +# Keep the build context to what the image actually needs: pyproject.toml, +# README.md (referenced by pyproject), src/, alembic.ini, docker-entrypoint.sh. +.git +.github +.venv +.omc +.pytest_cache +.ruff_cache +**/__pycache__ +*.py[cod] +tests +docs +.env +.env.* +!.env.example +*.db +Dockerfile +docker-compose.yml +.dockerignore +.mise.toml diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..f934659 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,64 @@ +name: docker + +on: + push: + branches: [main] + tags: ["v*"] + pull_request: + branches: [main] + +env: + IMAGE: ghcr.io/${{ github.repository }} + +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: . + 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 + cache-to: type=gha,mode=max diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..dd59914 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,49 @@ +# syntax=docker/dockerfile:1 + +# ---- 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 + +ENV PATH="/opt/venv/bin:$PATH" \ + PYTHONUNBUFFERED=1 \ + # SQLite fallback lives on the /var/lib/handler volume; point DATABASE_URL at + # Postgres for real deploys (see .env.example / docker-compose.yml). + DATABASE_URL="sqlite:////var/lib/handler/handler.db" \ + PROJECTS_ROOT="/var/lib/handler/projects" + +COPY --from=builder /opt/venv /opt/venv + +# Alembic runs from /app: alembic.ini resolves script_location=src/handler/migrations +# relative to the cwd, so the migration tree is shipped alongside the installed package. +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 +EXPOSE 8000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=4)" + +ENTRYPOINT ["docker-entrypoint.sh"] +CMD ["uvicorn", "handler.api.app:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8af71b8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,42 @@ +# Local/dev deployment: API + Postgres. For a single-node SQLite deploy, drop the +# `db` service and DATABASE_URL override — the image defaults to SQLite on the +# handler-data volume. +services: + api: + image: ghcr.io/0xwheatyz/handler:latest + build: . + ports: + - "8000:8000" + environment: + DATABASE_URL: postgresql+psycopg://handler:handler@db:5432/handler + # Required — the API refuses to start without it. Set in .env or the shell. + AUTH_TOKEN: ${AUTH_TOKEN:?set AUTH_TOKEN in .env or the environment} + SHARED_CONTEXT_WRITE_TOKEN: ${SHARED_CONTEXT_WRITE_TOKEN:-} + WEBHOOK_URL: ${WEBHOOK_URL:-} + UI_ENABLED: ${UI_ENABLED:-true} + CORS_ORIGINS: ${CORS_ORIGINS:-} + volumes: + - handler-data:/var/lib/handler + depends_on: + db: + condition: service_healthy + restart: unless-stopped + + db: + image: postgres:16-alpine + environment: + POSTGRES_USER: handler + POSTGRES_PASSWORD: handler + POSTGRES_DB: handler + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U handler -d handler"] + interval: 5s + timeout: 3s + retries: 10 + restart: unless-stopped + +volumes: + handler-data: + postgres-data: diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100755 index 0000000..eb1040f --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,10 @@ +#!/bin/sh +# Apply migrations before starting the API. Set RUN_MIGRATIONS=false when running +# multiple replicas and migrating out-of-band (`alembic upgrade head`) instead. +set -e + +if [ "${RUN_MIGRATIONS:-true}" = "true" ]; then + alembic upgrade head +fi + +exec "$@"