diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..23167d7 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,124 @@ +name: Deploy + +# workflow_dispatch only, deliberately (see docs/PLAN.md's "the gate is manual dispatch"). No +# automatic trigger on tag push: release.yml already runs there, and racing a build/push against +# a deploy on the same event is worse than requiring one manual click after confirming the +# release succeeded. +on: + workflow_dispatch: + inputs: + tag: + description: Image tag to deploy (as pushed by release.yml) + required: false + default: latest + +env: + # Pins the compose project name so the web_build named volume is predictably + # `velodrome_web_build` regardless of which directory `docker compose` is invoked from, and so + # it's the same name across the populate step and the `up -d` step below. + COMPOSE_PROJECT_NAME: velodrome + TAG: ${{ gitea.event.inputs.tag }} + # The real .env (POSTGRES_SUPERUSER_PASSWORD, the VELODROME_* DSNs and secrets) is NOT in git — + # see deploy/.env.example for the documented shape. It must already exist at this exact path on + # the Unraid host before this workflow can succeed. See the PR description for the full manual + # setup checklist. + ENV_FILE: /mnt/user/appdata/velodrome/.env + +jobs: + deploy: + name: Deploy to production + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # Same registry, same REGISTRY_TOKEN as release.yml — secrets.GITEA_TOKEN cannot push (and, + # per the Gitea docs for this same limitation, should not be assumed to reliably pull + # private packages either) from the container registry. + - uses: docker/login-action@v3 + with: + registry: 192.168.0.3:3000 + username: ${{ gitea.actor }} + password: ${{ secrets.REGISTRY_TOKEN }} + + - name: Pull images for this tag + working-directory: deploy + run: | + set -euo pipefail + docker compose --env-file "$ENV_FILE" pull + + # DooD-safe: this is a one-shot `docker run` whose *command* (not a client-side path + # argument) does the copying, into a named volume the daemon manages. No `docker cp`, no + # `mv`/`rm -rf` on a host path, no bind-mount source path on this command line — all of + # which would silently resolve against the ephemeral job container's own throwaway + # filesystem instead of the real host under this act_runner's Docker-outside-of-Docker + # setup. See deploy/docker-compose.yml's header comment for the full explanation. + - name: Populate web_build volume from the web image + run: | + set -euo pipefail + # `web` isn't a compose service, so the "Pull images" step above never touches it — + # pull it explicitly here. Matters most when TAG=latest is reused across deploys: a + # `docker run` on an already-cached tag would otherwise silently reuse stale local + # content instead of fetching what release.yml just pushed. + docker pull "192.168.0.3:3000/bbergle/velodrome-web:${TAG}" + docker run --rm -v velodrome_web_build:/dest \ + "192.168.0.3:3000/bbergle/velodrome-web:${TAG}" \ + sh -c 'rm -rf /dest/* /dest/.[!.]* 2>/dev/null; cp -a /app/build/. /dest/' + + # Explicit one-off, run and must succeed *before* `up -d` — never from the api image's + # entrypoint, so a failed migration fails this job loudly instead of crash-looping in a + # container nobody's watching. VELODROME_DATABASE_URL_MIGRATE (the superuser DSN) is passed + # only as an inline override here, never in the compose file's permanent `api` environment + # block — see docker-compose.yml's comment and apps/api/README.md's "Why two database + # connections" for why the long-running app process must never hold it. + - name: Run database migrations + working-directory: deploy + run: | + set -euo pipefail + read_var() { + grep -E "^$1=" "$ENV_FILE" | head -1 | cut -d= -f2- + } + SUPERUSER_PW="$(read_var POSTGRES_SUPERUSER_PASSWORD)" + APP_PW="$(read_var VELODROME_DB_APP_PASSWORD)" + AUTH_PW="$(read_var VELODROME_DB_AUTH_PASSWORD)" + for name in SUPERUSER_PW:POSTGRES_SUPERUSER_PASSWORD APP_PW:VELODROME_DB_APP_PASSWORD AUTH_PW:VELODROME_DB_AUTH_PASSWORD; do + var="${name%%:*}"; label="${name##*:}" + if [ -z "${!var}" ]; then + echo "::error::$label not found in $ENV_FILE" + exit 1 + fi + done + # VELODROME_DB_APP_PASSWORD/VELODROME_DB_AUTH_PASSWORD are needed here (Alembic's + # baseline migration uses them to create the two runtime roles, per + # apps/api/README.md's env var table) but are deliberately absent from the `api` + # service's permanent `environment:` block in docker-compose.yml. `--env-file` only + # feeds ${...} substitution *in the compose YAML* — it does NOT inject variables into + # the container unless the service's `environment:` block names them. So they must be + # passed here explicitly, the same way VELODROME_DATABASE_URL_MIGRATE is. (Verified + # locally: omitting these two makes the migration fail loudly with "must be set before + # running this migration" from alembic/versions/0001_baseline.py — good, it fails + # closed rather than silently skipping role creation.) + docker compose --env-file "$ENV_FILE" run --rm \ + -e VELODROME_DATABASE_URL_MIGRATE="postgresql+asyncpg://postgres:${SUPERUSER_PW}@db:5432/velodrome" \ + -e VELODROME_DB_APP_PASSWORD="${APP_PW}" \ + -e VELODROME_DB_AUTH_PASSWORD="${AUTH_PW}" \ + api alembic upgrade head + + - name: Bring up the stack + working-directory: deploy + run: | + set -euo pipefail + docker compose --env-file "$ENV_FILE" up -d + + - name: Wait for the API to become healthy + run: | + set -euo pipefail + for i in $(seq 1 10); do + if curl -fsS http://localhost:8090/api/v1/healthz; then + echo "Healthy." + exit 0 + fi + echo "Not ready yet (attempt $i/10), retrying..." + sleep 3 + done + echo "::error::API did not become healthy after deploy." + exit 1 diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..8e032c0 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,52 @@ +name: Release + +on: + push: + tags: ['v*'] + workflow_dispatch: + +jobs: + build-and-push: + name: Build and push images + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Determine image tag + id: tag + run: | + if [ "${{ gitea.ref_type }}" = "tag" ]; then + echo "tag=${{ gitea.ref_name }}" >> "$GITHUB_OUTPUT" + else + echo "tag=manual-$(date -u +%Y%m%d%H%M%S)" >> "$GITHUB_OUTPUT" + fi + + - uses: docker/setup-buildx-action@v3 + + # NOT secrets.GITEA_TOKEN — it cannot push to the Gitea container registry (documented + # Gitea limitation, see CLAUDE.md). REGISTRY_TOKEN is a separate PAT with `package:write` + # scope that a human must create in Settings -> Applications and add as a repo Actions + # secret before this workflow can succeed. + - uses: docker/login-action@v3 + with: + registry: 192.168.0.3:3000 + username: ${{ gitea.actor }} + password: ${{ secrets.REGISTRY_TOKEN }} + + - name: Build and push API image + uses: docker/build-push-action@v6 + with: + context: apps/api + push: true + tags: | + 192.168.0.3:3000/bbergle/velodrome-api:${{ steps.tag.outputs.tag }} + 192.168.0.3:3000/bbergle/velodrome-api:latest + + - name: Build and push web image + uses: docker/build-push-action@v6 + with: + context: apps/web + push: true + tags: | + 192.168.0.3:3000/bbergle/velodrome-web:${{ steps.tag.outputs.tag }} + 192.168.0.3:3000/bbergle/velodrome-web:latest diff --git a/deploy/.env.example b/deploy/.env.example new file mode 100644 index 0000000..f3a8fa5 --- /dev/null +++ b/deploy/.env.example @@ -0,0 +1,34 @@ +# Copy this to /mnt/user/appdata/velodrome/.env on the Unraid host and fill in real values. +# Never commit the real .env — only this example, with placeholders, belongs in git. + +# --- Postgres superuser (used to create the `velodrome_app`/`velodrome_auth` roles at migration +# time, and as the container's own POSTGRES_PASSWORD; never held by the long-running api process) --- +POSTGRES_SUPERUSER_PASSWORD=changeme-superuser + +# --- Per-role app passwords (used to build the DSNs below) --- +VELODROME_DB_APP_PASSWORD=changeme-app +VELODROME_DB_AUTH_PASSWORD=changeme-auth + +# --- DSNs. `db` is the in-compose-network hostname of the `db` service (not localhost, not the +# host's IP) — Docker's embedded DNS resolves it for any container on the same compose network. --- +VELODROME_DATABASE_URL_APP=postgresql+asyncpg://velodrome_app:changeme-app@db:5432/velodrome +VELODROME_DATABASE_URL_AUTH=postgresql+asyncpg://velodrome_auth:changeme-auth@db:5432/velodrome +# VELODROME_DATABASE_URL_MIGRATE is deliberately NOT set here. It's the Postgres superuser DSN, +# used only for the one-off `alembic upgrade head` step in deploy.yml, passed as an inline `-e` +# override built from POSTGRES_SUPERUSER_PASSWORD above. The long-running api service never gets +# it. See apps/api/README.md's "Why two database connections". + +# --- App settings --- +# Not yet used by anything (arrives with Bryton credential encryption in a later phase) — declared +# now so the settings shape is stable. Generate with e.g. `openssl rand -hex 32`. +VELODROME_SECRET_KEY=changeme-secret-key +VELODROME_ENVIRONMENT=production +# Must exactly match how the app is actually reached — it's compared against the request's Origin +# header on cookie-authenticated mutations (CSRF check; see velodrome/auth/dependencies.py). If +# this doesn't match byte-for-byte how a browser reaches the app, authenticated POST/PUT/DELETE +# requests will be rejected. +VELODROME_PUBLIC_URL=http://192.168.0.103:8090 + +# --- Image tag to deploy. release.yml pushes both the git ref name and `latest`; deploy.yml's +# `tag` workflow_dispatch input picks which one to pull. --- +TAG=latest diff --git a/deploy/Caddyfile b/deploy/Caddyfile new file mode 100644 index 0000000..3db6fbe --- /dev/null +++ b/deploy/Caddyfile @@ -0,0 +1,14 @@ +# Plain HTTP on :80 (mapped to host port 8090 by docker-compose.yml). No TLS here — the user's +# Nginx Proxy Manager instance is a separate concern and explicitly out of scope for Phase 0; this +# is reachable directly at http://192.168.0.103:8090. +:80 { + handle /api/* { + reverse_proxy api:8000 + } + + handle { + root * /srv/web + try_files {path} /index.html + file_server + } +} diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..207d7a4 --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,67 @@ +# Phase 0 deployment stack: caddy + api + db. +# +# Deliberately NOT included yet: a `worker` service running `procrastinate worker`. Nothing in +# Phase 0/1 enqueues background jobs — that arrives with the ingestion pipeline. Adding it now +# would just be an idle container. See docs/PLAN.md's "Service topology". +# +# The `web` image (apps/web/Dockerfile) is built and pushed by release.yml but never runs as a +# service here — its only output is a static /app/build directory. deploy.yml populates the +# `web_build` named volume from it with a one-shot `docker run`, entirely server-side against the +# Docker daemon, before `caddy` starts. See deploy.yml for why this has to be done that way +# (Gitea's runners are Docker-outside-of-Docker: job containers don't share a filesystem with the +# host, so a client-side host path on a `docker cp`/`docker run -v` issued from inside a job +# silently resolves against the wrong filesystem and does nothing). +# +# Invoke with: docker compose --env-file /mnt/user/appdata/velodrome/.env -f deploy/docker-compose.yml ... + +services: + caddy: + image: caddy:2-alpine + restart: unless-stopped + ports: + - "8090:80" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - web_build:/srv/web:ro + depends_on: + - api + + api: + image: 192.168.0.3:3000/bbergle/velodrome-api:${TAG:-latest} + restart: unless-stopped + environment: + # Superuser DSN (VELODROME_DATABASE_URL_MIGRATE) is deliberately NOT here — the + # long-running app process must never hold superuser DB credentials. It's passed only as a + # one-off `-e` override on the migration step in deploy.yml. See apps/api/README.md's "Why + # two database connections". + VELODROME_DATABASE_URL_APP: ${VELODROME_DATABASE_URL_APP} + VELODROME_DATABASE_URL_AUTH: ${VELODROME_DATABASE_URL_AUTH} + VELODROME_SECRET_KEY: ${VELODROME_SECRET_KEY} + VELODROME_ENVIRONMENT: production + VELODROME_PUBLIC_URL: http://192.168.0.103:8090 + depends_on: + db: + condition: service_healthy + + db: + image: postgis/postgis:16-3.4 + restart: unless-stopped + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: ${POSTGRES_SUPERUSER_PASSWORD} + POSTGRES_DB: velodrome + volumes: + # Bind-mounted directly (not a named volume) — this is safe under DooD because it's + # resolved by the Docker daemon when `docker compose up` creates the service, not by any + # client-side path handling inside a job container. See the header comment above. + - /mnt/user/appdata/velodrome/pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 10 + +volumes: + # Populated from the `web` image's /app/build by a one-shot `docker run` in deploy.yml — never + # mounted read-write, never written to by any long-running service. + web_build: