210 lines
7.6 KiB
Markdown
210 lines
7.6 KiB
Markdown
|
|
# Continuous Deployment: Auto-deploy to staging when CI passes
|
||
|
|
|
||
|
|
## Context
|
||
|
|
|
||
|
|
CI is complete (Percival ch. 25). The Woodpecker pipeline runs 57 UTs + 9 FTs on every push to Gitea, but deployment to staging is still manual (`ansible-playbook infra/deploy-playbook.yaml`). This plan adds CD: when tests pass on the `main` branch, the pipeline automatically builds the Docker image, pushes it to the Gitea container registry, and deploys it to `staging.earthmanrpg.me`.
|
||
|
|
|
||
|
|
**Architecture:** Build in CI pipeline -> push to Gitea registry -> SSH to staging -> pull + restart
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Stage 1: Enable Docker socket in Woodpecker pipeline steps
|
||
|
|
|
||
|
|
The Woodpecker agent already has the Docker socket mounted (line 53 of `infra/cicd/docker-compose.yaml`), but pipeline steps (containers the agent launches) don't inherit it. We need to tell the agent to pass it through.
|
||
|
|
|
||
|
|
### 1a. Edit `infra/cicd/docker-compose.yaml` -- add env var to `woodpecker-agent`
|
||
|
|
|
||
|
|
Add this to the agent's `environment:` block:
|
||
|
|
|
||
|
|
```yaml
|
||
|
|
- WOODPECKER_BACKEND_DOCKER_VOLUMES=/var/run/docker.sock:/var/run/docker.sock
|
||
|
|
```
|
||
|
|
|
||
|
|
**Why:** This makes the host Docker socket available inside every pipeline step container. Without it, the `docker build` and `docker push` commands in our pipeline step would fail with "cannot connect to Docker daemon."
|
||
|
|
|
||
|
|
### 1b. Re-deploy CI stack
|
||
|
|
|
||
|
|
From WSL:
|
||
|
|
|
||
|
|
```bash
|
||
|
|
ansible-playbook infra/cicd-playbook.yaml -i infra/inventory.ini --ask-vault-pass
|
||
|
|
```
|
||
|
|
|
||
|
|
This uploads the updated docker-compose and restarts the CI services.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Stage 2: Prepare staging server for CD
|
||
|
|
|
||
|
|
Currently `deploy-playbook.yaml` does everything in one shot: installs Docker, builds the image locally, uploads it, runs it with inline `env:` vars, copies static files, migrates. For CD, we need to split "one-time setup" from "per-deploy actions."
|
||
|
|
|
||
|
|
### 2a. Create `infra/gamearray.env.j2` -- container env file template (new file)
|
||
|
|
|
||
|
|
```
|
||
|
|
DJANGO_DEBUG_FALSE=1
|
||
|
|
DJANGO_SECRET_KEY={{ secret_key.content | b64decode }}
|
||
|
|
DJANGO_ALLOWED_HOST={{ django_allowed_host }}
|
||
|
|
DJANGO_DB_PATH=/home/nonroot/db.sqlite3
|
||
|
|
EMAIL_HOST_USER={{ email_host_user }}
|
||
|
|
EMAIL_HOST_PASSWORD={{ email_host_password }}
|
||
|
|
MAILGUN_API_KEY={{ mailgun_api_key }}
|
||
|
|
```
|
||
|
|
|
||
|
|
**Why:** Instead of passing env vars inline in `docker run`, we template a `.env` file onto the staging server. The deploy script references it with `--env-file`. This keeps secrets out of the pipeline YAML and on the server, managed by Ansible vault.
|
||
|
|
|
||
|
|
### 2b. Create `infra/deploy.sh.j2` -- deploy script template (new file)
|
||
|
|
|
||
|
|
```bash
|
||
|
|
#!/bin/bash
|
||
|
|
set -euo pipefail
|
||
|
|
|
||
|
|
IMAGE=gitea.earthmanrpg.me/discoman/gamearray:latest
|
||
|
|
|
||
|
|
echo "==> Pulling latest image..."
|
||
|
|
docker pull "$IMAGE"
|
||
|
|
|
||
|
|
echo "==> Stopping old container..."
|
||
|
|
docker stop gamearray 2>/dev/null || true
|
||
|
|
docker rm gamearray 2>/dev/null || true
|
||
|
|
|
||
|
|
echo "==> Starting new container..."
|
||
|
|
docker run -d --name gamearray \
|
||
|
|
--env-file /opt/gamearray/gamearray.env \
|
||
|
|
-p 127.0.0.1:8888:8888 \
|
||
|
|
"$IMAGE"
|
||
|
|
|
||
|
|
echo "==> Running migrations..."
|
||
|
|
docker exec gamearray ./manage.py migrate
|
||
|
|
|
||
|
|
echo "==> Copying static files..."
|
||
|
|
sudo docker cp gamearray:/src/static/. /var/www/gamearray/static/
|
||
|
|
|
||
|
|
echo "==> Deploy complete."
|
||
|
|
```
|
||
|
|
|
||
|
|
**Why:** A single script the pipeline calls via SSH. Keeps the pipeline YAML clean and lets us test deploys manually too (`ssh staging /opt/gamearray/deploy.sh`).
|
||
|
|
|
||
|
|
### 2c. Update `infra/deploy-playbook.yaml` -- add env file, deploy script, and registry login
|
||
|
|
|
||
|
|
Add these tasks (after the existing "Read secret key" task):
|
||
|
|
|
||
|
|
1. **Create `/opt/gamearray/` directory** on staging
|
||
|
|
2. **Template `gamearray.env.j2`** to `/opt/gamearray/gamearray.env` (mode 0600)
|
||
|
|
3. **Template `deploy.sh.j2`** to `/opt/gamearray/deploy.sh` (mode 0755)
|
||
|
|
4. **Docker login to Gitea registry** -- so the deploy script can `docker pull`
|
||
|
|
|
||
|
|
For the Docker login, use a Gitea access token (created in the browser, stored in the staging vault):
|
||
|
|
|
||
|
|
```yaml
|
||
|
|
- name: Login to Gitea container registry
|
||
|
|
ansible.builtin.command:
|
||
|
|
cmd: docker login gitea.earthmanrpg.me -u discoman -p {{ gitea_registry_token }}
|
||
|
|
no_log: true
|
||
|
|
```
|
||
|
|
|
||
|
|
**Vault additions** needed for staging (`infra/group_vars/staging/vault.yaml`):
|
||
|
|
- `gitea_registry_token` -- a Gitea personal access token with `package:read` scope
|
||
|
|
|
||
|
|
### 2d. Run the deploy playbook
|
||
|
|
|
||
|
|
```bash
|
||
|
|
ansible-playbook infra/deploy-playbook.yaml -i infra/inventory.ini -l staging --ask-vault-pass
|
||
|
|
```
|
||
|
|
|
||
|
|
This sets up the staging server: env file, deploy script, and registry auth.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Stage 3: Woodpecker secrets
|
||
|
|
|
||
|
|
Two secrets need to be added via the Woodpecker UI (`https://ci.earthmanrpg.me` -> repo settings -> Secrets):
|
||
|
|
|
||
|
|
### 3a. `deploy_ssh_key`
|
||
|
|
|
||
|
|
Generate a dedicated SSH key pair for CD:
|
||
|
|
|
||
|
|
```bash
|
||
|
|
ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519_cd_deploy -C "woodpecker-cd"
|
||
|
|
```
|
||
|
|
|
||
|
|
- Add the **public key** to `~/.ssh/authorized_keys` on staging (as `discoman` user)
|
||
|
|
- Paste the **private key** contents into a Woodpecker secret named `deploy_ssh_key`
|
||
|
|
|
||
|
|
### 3b. `gitea_registry_password`
|
||
|
|
|
||
|
|
The Gitea access token (same one from Stage 2c, or a separate one with `package:read,package:write` scope).
|
||
|
|
|
||
|
|
- Paste into a Woodpecker secret named `gitea_registry_password`
|
||
|
|
- The `build-and-push` step will use this to `docker login` + `docker push`
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Stage 4: Update `.woodpecker.yaml`
|
||
|
|
|
||
|
|
Add two new steps after `screendumps`:
|
||
|
|
|
||
|
|
```yaml
|
||
|
|
- name: build-and-push
|
||
|
|
image: docker:cli
|
||
|
|
environment:
|
||
|
|
REGISTRY_PASSWORD:
|
||
|
|
from_secret: gitea_registry_password
|
||
|
|
commands:
|
||
|
|
- echo "$REGISTRY_PASSWORD" | docker login gitea.earthmanrpg.me -u discoman --password-stdin
|
||
|
|
- docker build -t gitea.earthmanrpg.me/discoman/gamearray:latest .
|
||
|
|
- docker push gitea.earthmanrpg.me/discoman/gamearray:latest
|
||
|
|
when:
|
||
|
|
- branch: main
|
||
|
|
- event: push
|
||
|
|
|
||
|
|
- name: deploy
|
||
|
|
image: alpine
|
||
|
|
environment:
|
||
|
|
SSH_KEY:
|
||
|
|
from_secret: deploy_ssh_key
|
||
|
|
commands:
|
||
|
|
- apk add --no-cache openssh-client
|
||
|
|
- mkdir -p ~/.ssh
|
||
|
|
- printf '%s\n' "$SSH_KEY" > ~/.ssh/id_ed25519
|
||
|
|
- chmod 600 ~/.ssh/id_ed25519
|
||
|
|
- ssh -o StrictHostKeyChecking=no discoman@staging.earthmanrpg.me /opt/gamearray/deploy.sh
|
||
|
|
when:
|
||
|
|
- branch: main
|
||
|
|
- event: push
|
||
|
|
```
|
||
|
|
|
||
|
|
**Why `docker:cli` instead of a Docker plugin?** Using plain Docker CLI with the socket pass-through is more transparent than a third-party plugin. You can see exactly what's happening.
|
||
|
|
|
||
|
|
**Why `alpine` + manual SSH instead of `appleboy/drone-ssh`?** Same reason -- fewer moving parts, easier to debug. The `printf` + `ssh` pattern is dead simple.
|
||
|
|
|
||
|
|
**Branch gating:** Both steps only run on pushes to `main`. Feature branch pushes still run tests but don't deploy.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Stage 5: Test end-to-end
|
||
|
|
|
||
|
|
1. Push a small visible change (e.g., tweak a template's page title) to `main` on Gitea
|
||
|
|
2. Watch the Woodpecker pipeline at `https://ci.earthmanrpg.me`:
|
||
|
|
- `test-UTs` passes
|
||
|
|
- `test-FTs` passes
|
||
|
|
- `build-and-push` builds the image and pushes to the registry
|
||
|
|
- `deploy` SSHes to staging and runs the deploy script
|
||
|
|
3. Visit `https://staging.earthmanrpg.me` and verify the change is live
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Files modified
|
||
|
|
|
||
|
|
| File | Action |
|
||
|
|
|------|--------|
|
||
|
|
| `infra/cicd/docker-compose.yaml` | Add `WOODPECKER_BACKEND_DOCKER_VOLUMES` to agent |
|
||
|
|
| `infra/gamearray.env.j2` | **New** -- container env file template |
|
||
|
|
| `infra/deploy.sh.j2` | **New** -- deploy script template |
|
||
|
|
| `infra/deploy-playbook.yaml` | Add tasks for env file, deploy script, registry login |
|
||
|
|
| `infra/group_vars/staging/vault.yaml` | Add `gitea_registry_token` |
|
||
|
|
| `.woodpecker.yaml` | Add `build-and-push` and `deploy` steps |
|
||
|
|
|
||
|
|
## Known limitation
|
||
|
|
|
||
|
|
**Ephemeral database:** The SQLite database still lives inside the container (deferred issue from memory). Every deploy wipes it. This is fine for staging right now but will need a volume mount or PostgreSQL before production CD.
|