From 3e3145457128f3b8c6e77e19eae41270a1080449 Mon Sep 17 00:00:00 2001 From: getBoolean <19920697+getBoolean@users.noreply.github.com> Date: Sun, 21 Jun 2026 04:46:46 -0500 Subject: [PATCH 1/7] combine dev and prod droplets --- .github/workflows/provision-and-deploy.yml | 131 ++++++++++++-- .github/workflows/restart-bot.yml | 5 +- INFRA.md | 199 ++++++++++++--------- README-dev.md | 11 +- docker-compose.app.yml | 16 ++ infra/digitalocean/cloud-init.yml | 44 ++++- 6 files changed, 287 insertions(+), 119 deletions(-) create mode 100644 docker-compose.app.yml diff --git a/.github/workflows/provision-and-deploy.yml b/.github/workflows/provision-and-deploy.yml index 37e61302..3db9eb8a 100644 --- a/.github/workflows/provision-and-deploy.yml +++ b/.github/workflows/provision-and-deploy.yml @@ -36,10 +36,43 @@ jobs: - name: Await reviewer approval run: echo "Approved for ${{ github.ref_name }}." - discover: + build-and-push: needs: gate + if: github.ref_name == 'master' || github.ref_name == 'prod' + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v6.0.2 + + - uses: docker/setup-buildx-action@v4 + + - uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - id: meta + uses: docker/metadata-action@v6 + with: + images: ghcr.io/${{ github.repository }} + tags: type=ref,event=branch + + - uses: docker/build-push-action@v7 + with: + context: . + push: true + platforms: linux/amd64 + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + discover: + needs: build-and-push runs-on: ubuntu-latest - environment: ${{ github.ref_name == 'prod' && 'prod' || 'dev' }} permissions: contents: read @@ -99,13 +132,11 @@ jobs: needs: discover if: needs.discover.outputs.droplet_exists != 'true' runs-on: ubuntu-latest - environment: ${{ github.ref_name == 'prod' && 'prod' || 'dev' }} permissions: contents: read env: DEPLOY_BRANCH: ${{ github.ref_name }} - APP_PATH: ${{ vars.APP_PATH || '/opt/event-queue-bot' }} DO_REGION: ${{ vars.DO_REGION || 'nyc3' }} DO_SIZE: ${{ vars.DO_SIZE || 's-1vcpu-1gb' }} DO_IMAGE: ${{ vars.DO_IMAGE || 'ubuntu-24-04-x64' }} @@ -164,12 +195,17 @@ jobs: && (needs.provision.result == 'success' || needs.provision.result == 'skipped') runs-on: ubuntu-latest environment: ${{ github.ref_name == 'prod' && 'prod' || 'dev' }} + concurrency: + group: provision-and-deploy-bot-droplet + cancel-in-progress: false permissions: contents: read env: DEPLOY_BRANCH: ${{ github.ref_name }} - APP_PATH: ${{ vars.APP_PATH || '/opt/event-queue-bot' }} + APP_PATH: ${{ github.ref_name == 'prod' && '/opt/event-queue-bot' || '/opt/event-queue-bot-nightly' }} + CONTAINER_NAME: ${{ github.ref_name == 'prod' && 'queue-bot' || 'queue-bot-nightly' }} + IMAGE_TAG: ${{ github.ref_name }} # Coalesce: fresh-provision output wins; otherwise discover's lookup. BOT_HOST: ${{ needs.provision.outputs.bot_host || needs.discover.outputs.bot_host }} @@ -191,6 +227,12 @@ jobs: with: ref: ${{ env.DEPLOY_BRANCH }} + - name: Install doctl + uses: digitalocean/action-doctl@v2.5.2 + with: + token: ${{ secrets.DIGITALOCEAN_TOKEN }} + version: 1.159.0 + - name: Configure SSH env: SSH_DEPLOY_PRIVATE_KEY: ${{ secrets.SSH_DEPLOY_PRIVATE_KEY }} @@ -229,6 +271,57 @@ jobs: echo "Timed out waiting for cloud-init" >&2 exit 1 + # One-time, idempotent migration of the legacy dev droplet's database onto + # the shared droplet's nightly app dir. No-op once the nightly database + # exists or after the legacy event-queue-bot-dev droplet is deleted. + - name: Migrate legacy dev database (one-time) + if: github.ref_name != 'prod' + env: + SSH_HOST_PUBLIC_KEY: ${{ secrets.SSH_HOST_PUBLIC_KEY }} + run: | + set -euo pipefail + + if ssh -i ~/.ssh/bot_deploy_key deploy@"${BOT_HOST}" "test -s /opt/event-queue-bot-nightly/data/main.sqlite"; then + echo "Nightly database already present — skipping migration." + exit 0 + fi + + old_ip=$(doctl compute droplet list \ + --format Name,Status,PublicIPv4 \ + --no-header \ + | awk '$1 == "event-queue-bot-dev" && $2 == "active" && $3 != "" { print $3; exit }') + if [ -z "${old_ip}" ]; then + echo "No active event-queue-bot-dev droplet found — nothing to migrate." + exit 0 + fi + + echo "Migrating database from legacy dev droplet ${old_ip}" + printf '%s %s\n' "${old_ip}" "${SSH_HOST_PUBLIC_KEY}" >> ~/.ssh/known_hosts + + # Stop the legacy container so the SQLite files are a consistent snapshot. + ssh -i ~/.ssh/bot_deploy_key deploy@"${old_ip}" \ + "cd /opt/event-queue-bot-dev && (docker compose down || docker stop queue-bot || true)" || true + + tmpdir="$(mktemp -d)" + rsync -az -e "ssh -i ~/.ssh/bot_deploy_key" \ + --include='main.sqlite' \ + --include='main.sqlite-wal' \ + --include='main.sqlite-shm' \ + --exclude='*' \ + deploy@"${old_ip}":/opt/event-queue-bot-dev/data/ "${tmpdir}/" + + if [ ! -s "${tmpdir}/main.sqlite" ]; then + echo "Legacy main.sqlite missing or empty — nothing to migrate." + exit 0 + fi + + ssh -i ~/.ssh/bot_deploy_key deploy@"${BOT_HOST}" "mkdir -p /opt/event-queue-bot-nightly/data" + rsync -az -e "ssh -i ~/.ssh/bot_deploy_key" \ + "${tmpdir}/" deploy@"${BOT_HOST}":/opt/event-queue-bot-nightly/data/ + + ssh -i ~/.ssh/bot_deploy_key deploy@"${BOT_HOST}" "test -s /opt/event-queue-bot-nightly/data/main.sqlite" + echo "Migration complete. Delete the legacy event-queue-bot-dev droplet manually once verified." + - name: Build bot environment env: BOT_APP_ID: ${{ secrets.BOT_APP_ID }} @@ -252,26 +345,30 @@ jobs: printf 'ENABLE_LEGACY_MIGRATION=%s\n' "${BOT_ENABLE_LEGACY_MIGRATION:-false}" printf 'FORCE_SEND_PATCH_NOTES=%s\n' "${BOT_FORCE_SEND_PATCH_NOTES:-false}" printf 'SILENT=%s\n' "${BOT_SILENT:-false}" + printf 'CONTAINER_NAME=%s\n' "${CONTAINER_NAME}" + printf 'IMAGE_TAG=%s\n' "${IMAGE_TAG}" } > "${RUNNER_TEMP}/bot.env" - - name: Sync repository to VPS + - name: Sync compose file to VPS run: | - rsync -az --delete \ - --exclude '.git' \ - --exclude '.github' \ - --exclude 'node_modules' \ - --exclude 'data/main.sqlite' \ - --exclude 'data/backups' \ - --exclude 'data/migrations/legacy-export' \ - --exclude 'logs' \ - --exclude '.env' \ + ssh -i ~/.ssh/bot_deploy_key deploy@"${BOT_HOST}" "mkdir -p '${APP_PATH}'" + rsync -az \ -e "ssh -i ~/.ssh/bot_deploy_key" \ - ./ deploy@"${BOT_HOST}":"${APP_PATH}/" + docker-compose.app.yml deploy@"${BOT_HOST}":"${APP_PATH}/" - name: Write bot environment run: | ssh -i ~/.ssh/bot_deploy_key deploy@"${BOT_HOST}" "umask 077 && cat > '${APP_PATH}/.env.tmp' && mv '${APP_PATH}/.env.tmp' '${APP_PATH}/.env'" < "${RUNNER_TEMP}/bot.env" + - name: Log in to GHCR on VPS + env: + GHCR_PULL_TOKEN: ${{ secrets.GHCR_PULL_TOKEN }} + run: | + if [ -n "${GHCR_PULL_TOKEN}" ]; then + ssh -i ~/.ssh/bot_deploy_key deploy@"${BOT_HOST}" \ + "echo '${GHCR_PULL_TOKEN}' | docker login ghcr.io -u '${{ github.actor }}' --password-stdin" + fi + - name: Deploy bot run: | - ssh -i ~/.ssh/bot_deploy_key deploy@"${BOT_HOST}" "sudo /usr/local/bin/deploy-event-queue-bot" + ssh -i ~/.ssh/bot_deploy_key deploy@"${BOT_HOST}" "sudo /usr/local/bin/deploy-event-queue-bot '${APP_PATH}'" diff --git a/.github/workflows/restart-bot.yml b/.github/workflows/restart-bot.yml index d6f9a1c9..3526436c 100644 --- a/.github/workflows/restart-bot.yml +++ b/.github/workflows/restart-bot.yml @@ -35,7 +35,8 @@ jobs: env: DO_DROPLET_NAME: ${{ vars.DO_DROPLET_NAME || 'event-queue-bot' }} - APP_PATH: ${{ vars.APP_PATH || '/opt/event-queue-bot' }} + APP_PATH: ${{ inputs.environment == 'prod' && '/opt/event-queue-bot' || '/opt/event-queue-bot-nightly' }} + CONTAINER_NAME: ${{ inputs.environment == 'prod' && 'queue-bot' || 'queue-bot-nightly' }} steps: - name: Validate required secrets @@ -87,4 +88,4 @@ jobs: BOT_HOST: ${{ steps.lookup.outputs.bot_host }} run: | ssh -i ~/.ssh/bot_deploy_key deploy@"${BOT_HOST}" \ - "cd '${APP_PATH}' && docker compose restart && docker logs --tail 100 queue-bot" + "cd '${APP_PATH}' && docker compose -f docker-compose.app.yml restart && docker logs --tail 100 '${CONTAINER_NAME}'" diff --git a/INFRA.md b/INFRA.md index 7c714cf1..c4ebba4e 100644 --- a/INFRA.md +++ b/INFRA.md @@ -1,13 +1,22 @@ # Infrastructure Setup -GitHub Actions provisions a DigitalOcean VPS with the official `doctl` CLI and -deploys the bot. Pushes to `master` deploy to a throwaway **dev** droplet; -promotion to prod is a deliberate `master → prod` PR merge. No local Terraform -or server setup is required. - -Each environment is split into a **gate** env (required reviewers, no secrets; -attached to the `discover` job) and a **secrets** env (no reviewers; attached to -`provision` and `deploy`). Approval is requested once per run. +GitHub Actions builds the bot image, pushes it to GHCR, provisions a single +DigitalOcean VPS with the official `doctl` CLI, and deploys the bot. Pushes to +`master` deploy the **dev** container; promotion to prod is a deliberate +`master → prod` PR merge. No local Terraform or server setup is required. + +Both environments share **one droplet**. Prod runs the `queue-bot` container +from `/opt/event-queue-bot`; dev runs the `queue-bot-nightly` container from +`/opt/event-queue-bot-nightly`. Each has its own `data/main.sqlite`, so they +share the box but not state. The `deploy` job derives the container name, app +path, and image tag from the branch and serializes prod/dev deploys via a shared +concurrency group. + +Each environment has a **gate** env (required reviewers, no secrets; attached to +the `gate` job) and a **secrets** env (no reviewers; attached to `deploy`). +Approval is requested once per run. The `build-and-push`, `discover`, and +`provision` jobs are not environment-scoped — they read repo-level secrets/vars +and target the single shared droplet. Shared infra secrets (DO token, SSH keys) live at the **repository** level and fall through from any environment. Bot identity (`BOT_APP_ID`, `BOT_TOKEN`) @@ -107,37 +116,53 @@ When the prod promotion path is set up, the prod bot's `BOT_APP_ID` / The workflow generates the server `.env` file from these secrets during deploy. -## 5. Optional GitHub Variables +## 4b. GHCR image access -GitHub variables (not secrets). Set at repo level for shared values, or on a -specific environment (`prod`, `dev`) to override. Unset → falls back to -the default below. +The `build-and-push` job pushes the image to `ghcr.io/getboolean/event-queue-bot` +and the droplet pulls it during deploy. Make the pull work one of two ways: -| Variable | Default | -| --- | --- | -| `DO_REGION` | `nyc3` | -| `DO_SIZE` | `s-1vcpu-1gb` | -| `DO_IMAGE` | `ubuntu-24-04-x64` | -| `DO_DROPLET_NAME` | `event-queue-bot` | -| `DO_ENABLE_BACKUPS` | `false` | -| `DO_SWAP_SIZE` | `1G` | -| `APP_PATH` | `/opt/event-queue-bot` | -| `BOT_TOP_GG_TOKEN` | empty | -| `BOT_PATCH_NOTES_CHANNEL_ID` | empty | -| `BOT_DEFAULT_COLOR` | `Random` | -| `BOT_DEFAULT_SCHEDULE_TIMEZONE` | `america/chicago` | -| `BOT_ENABLE_LEGACY_MIGRATION` | `false` | -| `BOT_FORCE_SEND_PATCH_NOTES` | `false` | -| `BOT_SILENT` | `false` | +- **Public package (simplest):** in the GHCR package settings, set the package + visibility to public. No extra secret is needed. +- **Private package:** create a classic PAT with the `read:packages` scope and + save it as the repository secret `GHCR_PULL_TOKEN`. The deploy job uses it to + `docker login ghcr.io` on the droplet. If the secret is empty, the login step + is skipped (so it is safe to leave unset for a public package). + +## 5. Optional GitHub Variables + +GitHub variables (not secrets). The `DO_*` infra variables drive the single +shared droplet, so set them at the **repository** level. The `BOT_*` variables +are per-environment so prod and dev can differ. Unset → falls back to the +default below. + +| Variable | Scope | Default | +| --- | --- | --- | +| `DO_REGION` | repo | `nyc3` | +| `DO_SIZE` | repo | `s-1vcpu-1gb` | +| `DO_IMAGE` | repo | `ubuntu-24-04-x64` | +| `DO_DROPLET_NAME` | repo | `event-queue-bot` | +| `DO_ENABLE_BACKUPS` | repo | `false` | +| `DO_SWAP_SIZE` | repo | `1G` | +| `BOT_TOP_GG_TOKEN` | env | empty | +| `BOT_PATCH_NOTES_CHANNEL_ID` | env | empty | +| `BOT_DEFAULT_COLOR` | env | `Random` | +| `BOT_DEFAULT_SCHEDULE_TIMEZONE` | env | `america/chicago` | +| `BOT_ENABLE_LEGACY_MIGRATION` | env | `false` | +| `BOT_FORCE_SEND_PATCH_NOTES` | env | `false` | +| `BOT_SILENT` | env | `false` | + +The app path, container name, and image tag are derived from the branch by the +`deploy` job (prod → `/opt/event-queue-bot` / `queue-bot` / `prod`; dev → +`/opt/event-queue-bot-nightly` / `queue-bot-nightly` / `master`) and are not +configurable via variables. `DO_SWAP_SIZE` accepts a positive integer optionally suffixed `K`/`M`/`G`, or `0` to disable. Applied only at first boot via cloud-init — changing it doesn't affect existing droplets. -- **Prod (`s-1vcpu-1gb`)**: leave at `1G` default — gives node-gyp/`better-sqlite3` headroom - during `docker compose up --build` and lets the kernel evict idle anon pages in favor of FS - cache. Set to `0` to disable if you prefer prod to fail loudly on memory pressure rather than swap. -- **Dev (`s-1vcpu-512mb-10gb`)**: leave at `1G` default — without swap, `npm ci` OOMs during - native compile. +Leave `DO_SWAP_SIZE` at the `1G` default. The shared droplet runs both the prod +and dev containers; since images are now built in CI and only pulled on the box, +build-time memory pressure is gone, but swap still gives the two resident bots +headroom. If memory proves tight, bump `DO_SIZE` to `s-1vcpu-2gb`. Set `DO_ENABLE_BACKUPS` to `true` before the first deploy if you want DigitalOcean Droplet backups. Backups add 20% to the droplet cost. You can also back up the @@ -151,23 +176,28 @@ In GitHub: 2. Select `Provision and Deploy Bot`. 3. Run the workflow. -The workflow creates or reuses the VPS, writes `.env`, syncs the repo, and runs -Docker Compose. +The workflow builds and pushes the image to GHCR, creates or reuses the VPS, +writes `.env`, syncs `docker-compose.app.yml`, pulls the image, and runs Docker +Compose. Future pushes to `master` deploy to dev automatically; each run pauses at -`gate` for `dev-gate` reviewer approval before `discover`, `provision`, and -`deploy` proceed. Prod is reached only by merging `master → prod` — see +`gate` for `dev-gate` reviewer approval before `build-and-push`, `discover`, +`provision`, and `deploy` proceed. Prod is reached only by merging +`master → prod` — see [Setting up the prod promotion path](#setting-up-the-prod-promotion-path). ## Setting up the prod promotion path Required for prod deploys. Without this, the workflow only ever targets dev. -Adds the prod-side droplet and the `master → prod` merge gate so feature work -auto-validates on dev and only reaches users when explicitly promoted. +Adds the prod environment and the `master → prod` merge gate so feature work +auto-validates on dev and only reaches users when explicitly promoted. Both +environments deploy to the **same** droplet (provisioned on the first dev or +prod run), so no second droplet is created — only the prod-side credentials and +the promotion workflow. -The default `dev` environment from §4 already covers the dev droplet (running -the dev Discord application from §4's `BOT_APP_ID`/`BOT_TOKEN`). What follows -sets up the *prod* side and the promotion workflow. +The default `dev` environment from §4 already runs the dev container (the dev +Discord application from §4's `BOT_APP_ID`/`BOT_TOKEN`). What follows sets up the +*prod* side and the promotion workflow. Maintainer's dev bot invite (for reference; install this on your own test guild so you can poke at it): @@ -178,35 +208,13 @@ guild so you can poke at it): 2. Create two GitHub environments: - `prod-gate` — required reviewers, no secrets/vars. - `prod` — no reviewers. -3. On `prod`, add `BOT_APP_ID` and `BOT_TOKEN` from step 1. -4. On `prod`, add these vars (shared infra secrets stay at repo level; the dev - environment from §4 carries the dev-droplet overrides): - - | Variable | Value | - | --- | --- | - | `DO_DROPLET_NAME` | `event-queue-bot` | - | `APP_PATH` | `/opt/event-queue-bot` | - | `DO_PROJECT_NAME` | `Event Queue Bot` | - | `DO_PROJECT_ENVIRONMENT` | `Production` | - | `DO_SIZE` | `s-1vcpu-1gb` | - - The dev environment should mirror the inverse (dev droplet name/path/size). - The relevant dev overrides (set on the `dev` environment): - - | Variable | Value | - | --- | --- | - | `DO_DROPLET_NAME` | `event-queue-bot-dev` | - | `APP_PATH` | `/opt/event-queue-bot-dev` | - | `DO_PROJECT_NAME` | `Event Queue Bot Dev` | - | `DO_PROJECT_ENVIRONMENT` | `Development` | - | `DO_SIZE` | `s-1vcpu-512mb-10gb` (cheapest Basic droplet, ~$4/mo; the bot fits in 512MB for dev) | - - At the 512 MB dev size, leave `DO_SWAP_SIZE` at its `1G` default — without swap, - `npm ci` OOMs during `better-sqlite3`'s native compile and the build wedges silently. - -5. Create the `prod` branch from `master` and push it. Pushes and merges to - `prod` deploy to the prod droplet, gated by `prod-gate`. -6. Add branch protection on `prod`: +3. On `prod`, add `BOT_APP_ID` and `BOT_TOKEN` from step 1, plus any per-env + `BOT_*` variables you want to differ from dev. Infra `DO_*` vars and secrets + stay at the repository level (shared droplet); there are no per-environment + droplet/path/size overrides. +4. Create the `prod` branch from `master` and push it. Pushes and merges to + `prod` deploy the prod container to the shared droplet, gated by `prod-gate`. +5. Add branch protection on `prod`: - Require a pull request before merging. - Require deployments to succeed before merging → add `dev`. This forces the head SHA to have already passed a dev deploy before it can land on @@ -221,7 +229,8 @@ protection confirms the head SHA succeeded on dev → merge → prod deploys [promote-pr]: https://github.com/getBoolean/Event-Queue-Bot/compare/prod...getBoolean:Event-Queue-Bot:master -Prod and dev share no state: separate droplets, separate `data/main.sqlite`, +Prod and dev share one droplet but no state: separate containers +(`queue-bot` vs `queue-bot-nightly`), separate app dirs and `data/main.sqlite`, separate Discord applications. ## Connect to the Droplet @@ -243,35 +252,47 @@ re-run the workflow. ## Backup Before Deleting -The dev database is: +Both databases live on the shared droplet: ```text -/opt/event-queue-bot-dev/data/main.sqlite +Prod: /opt/event-queue-bot/data/main.sqlite +Dev: /opt/event-queue-bot-nightly/data/main.sqlite ``` -Download it before deleting the Droplet: +Download them before deleting the droplet: ```bash -scp deploy@your_server_ip:/opt/event-queue-bot-dev/data/main.sqlite ./main.sqlite.backup -``` - -To remove the dev deployment, delete these DigitalOcean resources: - -```text -Droplet: event-queue-bot-dev -Firewall: event-queue-bot-dev-ssh -SSH key: event-queue-bot-dev-deploy -Tag: event-queue-bot-dev +scp deploy@your_server_ip:/opt/event-queue-bot/data/main.sqlite ./main.sqlite.prod.backup +scp deploy@your_server_ip:/opt/event-queue-bot-nightly/data/main.sqlite ./main.sqlite.dev.backup ``` -If the prod promotion path is configured, prod uses the same resource names -without the `-dev` suffix (the suffix is derived from `DO_DROPLET_NAME` in -`scripts/provision-digitalocean.sh`): +To remove the deployment entirely, delete these DigitalOcean resources (names +derived from `DO_DROPLET_NAME` in `scripts/provision-digitalocean.sh`): ```text -Database: /opt/event-queue-bot/data/main.sqlite Droplet: event-queue-bot Firewall: event-queue-bot-ssh SSH key: event-queue-bot-deploy Tag: event-queue-bot ``` + +## Migrating from the old dual-droplet setup + +If you previously ran separate `event-queue-bot` (prod) and `event-queue-bot-dev` +(dev) droplets, the shared droplet reuses the existing prod droplet, so prod data +stays in place. The `deploy` job migrates the old dev database automatically: on +the first dev (`master`) deploy it detects an active `event-queue-bot-dev` +droplet, stops its container for a consistent snapshot, and copies +`main.sqlite` (plus any `-wal`/`-shm`) into `/opt/event-queue-bot-nightly/data` +on the shared droplet. The step is idempotent — it is a no-op once the nightly +database exists or the old droplet is gone. + +After confirming the `queue-bot-nightly` container is healthy on the shared +droplet, delete the now-orphaned dev resources manually: + +```text +Droplet: event-queue-bot-dev +Firewall: event-queue-bot-dev-ssh +SSH key: event-queue-bot-dev-deploy +Tag: event-queue-bot-dev +``` diff --git a/README-dev.md b/README-dev.md index 38844603..a82e39e5 100644 --- a/README-dev.md +++ b/README-dev.md @@ -92,11 +92,14 @@ npm start ## Deploying via GitHub Actions -Pushes to `master` trigger `.github/workflows/provision-and-deploy.yml`, which runs three jobs: +Pushes to `master` trigger `.github/workflows/provision-and-deploy.yml`, which runs: -1. **`discover`** — looks up the DigitalOcean droplet by `DO_DROPLET_NAME`. -2. **`provision`** — creates the droplet via cloud-init only if none exists. -3. **`deploy`** — rsyncs the repo, writes `.env`, and runs `docker compose up -d --build` on the droplet. Pending Drizzle migrations apply automatically on container start. +1. **`build-and-push`** — builds the Docker image and pushes it to GHCR (`ghcr.io/getboolean/event-queue-bot`) tagged with the branch name (`master` for dev, `prod` for prod). +2. **`discover`** — looks up the single shared DigitalOcean droplet by `DO_DROPLET_NAME`. +3. **`provision`** — creates the droplet via cloud-init only if none exists. +4. **`deploy`** — syncs `docker-compose.app.yml`, writes `.env`, pulls the GHCR image, and runs `docker compose -f docker-compose.app.yml up -d` on the droplet. Pending Drizzle migrations apply automatically on container start. + +Both environments share one droplet: prod runs the `queue-bot` container from `/opt/event-queue-bot`, and dev runs the `queue-bot-nightly` container from `/opt/event-queue-bot-nightly`, each with its own `data/main.sqlite`. The `deploy` job derives the container name, app path, and image tag from the branch and serializes prod/dev deploys via a shared concurrency group. Secrets, variables, and SSH key setup live in [`INFRA.md`](INFRA.md). For SSH access to the droplet, see [`INFRA.md` → "Connect to the Droplet"](INFRA.md#connect-to-the-droplet). diff --git a/docker-compose.app.yml b/docker-compose.app.yml new file mode 100644 index 00000000..8e8eac70 --- /dev/null +++ b/docker-compose.app.yml @@ -0,0 +1,16 @@ +services: + app: + image: ghcr.io/getboolean/event-queue-bot:${IMAGE_TAG:-master} + container_name: ${CONTAINER_NAME:-queue-bot} + restart: always + env_file: .env + volumes: + - ./data:/app/data # Bind mount for database + - ./.env:/app/.env # Bind mount for .env file + stdin_open: true # Allow stdin to be open + tty: true # Allocate a pseudo-TTY + logging: + driver: json-file + options: + max-size: "10m" + max-file: "5" diff --git a/infra/digitalocean/cloud-init.yml b/infra/digitalocean/cloud-init.yml index aba7772e..bccdf9f7 100644 --- a/infra/digitalocean/cloud-init.yml +++ b/infra/digitalocean/cloud-init.yml @@ -32,15 +32,20 @@ write_files: #!/usr/bin/env bash set -euo pipefail - APP_DIR={{APP_PATH_SHELL}} + if [ "$#" -ne 1 ]; then + echo "Usage: deploy-event-queue-bot " >&2 + exit 1 + fi + + APP_DIR="$1" mkdir -p "$APP_DIR/data" "$APP_DIR/logs" chown -R deploy:deploy "$APP_DIR" cd "$APP_DIR" - if [ ! -f docker-compose.yml ]; then - echo "Missing $APP_DIR/docker-compose.yml; sync the repository before deploying." >&2 + if [ ! -f docker-compose.app.yml ]; then + echo "Missing $APP_DIR/docker-compose.app.yml; sync deploy artifacts before deploying." >&2 exit 1 fi @@ -49,9 +54,34 @@ write_files: exit 1 fi - docker compose up -d --build + # shellcheck disable=SC1091 + set -a + source .env + set +a + + if [ -z "${CONTAINER_NAME:-}" ]; then + echo "CONTAINER_NAME is required in $APP_DIR/.env" >&2 + exit 1 + fi + + docker compose -f docker-compose.app.yml pull + docker compose -f docker-compose.app.yml up -d docker image prune -f --filter "until=72h" - docker logs --tail 100 queue-bot + + for attempt in {1..12}; do + if docker inspect -f '{{.State.Running}}' "$CONTAINER_NAME" 2>/dev/null | grep -qx true; then + break + fi + if [ "$attempt" -eq 12 ]; then + echo "Bot container did not start after deploy" >&2 + docker ps -a --filter "name=$CONTAINER_NAME" || true + docker logs --tail 200 "$CONTAINER_NAME" 2>&1 || true + exit 1 + fi + sleep 2 + done + + docker logs --tail 100 "$CONTAINER_NAME" - path: /etc/sudoers.d/event-queue-bot-deploy owner: root:root @@ -81,5 +111,5 @@ runcmd: - apt-get update - apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin - systemctl enable --now docker - - mkdir -p {{APP_PATH_SHELL}}/data {{APP_PATH_SHELL}}/logs - - chown -R deploy:deploy {{APP_PATH_SHELL}} + - mkdir -p /opt/event-queue-bot/data /opt/event-queue-bot/logs /opt/event-queue-bot-nightly/data /opt/event-queue-bot-nightly/logs + - chown -R deploy:deploy /opt/event-queue-bot /opt/event-queue-bot-nightly From ae917a1f69aaa50c07508a76e5027e961269f64d Mon Sep 17 00:00:00 2001 From: getBoolean <19920697+getBoolean@users.noreply.github.com> Date: Sun, 21 Jun 2026 04:50:14 -0500 Subject: [PATCH 2/7] remove migration code and docs --- .github/workflows/provision-and-deploy.yml | 57 ---------------------- INFRA.md | 21 -------- 2 files changed, 78 deletions(-) diff --git a/.github/workflows/provision-and-deploy.yml b/.github/workflows/provision-and-deploy.yml index 3db9eb8a..72d1b8ff 100644 --- a/.github/workflows/provision-and-deploy.yml +++ b/.github/workflows/provision-and-deploy.yml @@ -227,12 +227,6 @@ jobs: with: ref: ${{ env.DEPLOY_BRANCH }} - - name: Install doctl - uses: digitalocean/action-doctl@v2.5.2 - with: - token: ${{ secrets.DIGITALOCEAN_TOKEN }} - version: 1.159.0 - - name: Configure SSH env: SSH_DEPLOY_PRIVATE_KEY: ${{ secrets.SSH_DEPLOY_PRIVATE_KEY }} @@ -271,57 +265,6 @@ jobs: echo "Timed out waiting for cloud-init" >&2 exit 1 - # One-time, idempotent migration of the legacy dev droplet's database onto - # the shared droplet's nightly app dir. No-op once the nightly database - # exists or after the legacy event-queue-bot-dev droplet is deleted. - - name: Migrate legacy dev database (one-time) - if: github.ref_name != 'prod' - env: - SSH_HOST_PUBLIC_KEY: ${{ secrets.SSH_HOST_PUBLIC_KEY }} - run: | - set -euo pipefail - - if ssh -i ~/.ssh/bot_deploy_key deploy@"${BOT_HOST}" "test -s /opt/event-queue-bot-nightly/data/main.sqlite"; then - echo "Nightly database already present — skipping migration." - exit 0 - fi - - old_ip=$(doctl compute droplet list \ - --format Name,Status,PublicIPv4 \ - --no-header \ - | awk '$1 == "event-queue-bot-dev" && $2 == "active" && $3 != "" { print $3; exit }') - if [ -z "${old_ip}" ]; then - echo "No active event-queue-bot-dev droplet found — nothing to migrate." - exit 0 - fi - - echo "Migrating database from legacy dev droplet ${old_ip}" - printf '%s %s\n' "${old_ip}" "${SSH_HOST_PUBLIC_KEY}" >> ~/.ssh/known_hosts - - # Stop the legacy container so the SQLite files are a consistent snapshot. - ssh -i ~/.ssh/bot_deploy_key deploy@"${old_ip}" \ - "cd /opt/event-queue-bot-dev && (docker compose down || docker stop queue-bot || true)" || true - - tmpdir="$(mktemp -d)" - rsync -az -e "ssh -i ~/.ssh/bot_deploy_key" \ - --include='main.sqlite' \ - --include='main.sqlite-wal' \ - --include='main.sqlite-shm' \ - --exclude='*' \ - deploy@"${old_ip}":/opt/event-queue-bot-dev/data/ "${tmpdir}/" - - if [ ! -s "${tmpdir}/main.sqlite" ]; then - echo "Legacy main.sqlite missing or empty — nothing to migrate." - exit 0 - fi - - ssh -i ~/.ssh/bot_deploy_key deploy@"${BOT_HOST}" "mkdir -p /opt/event-queue-bot-nightly/data" - rsync -az -e "ssh -i ~/.ssh/bot_deploy_key" \ - "${tmpdir}/" deploy@"${BOT_HOST}":/opt/event-queue-bot-nightly/data/ - - ssh -i ~/.ssh/bot_deploy_key deploy@"${BOT_HOST}" "test -s /opt/event-queue-bot-nightly/data/main.sqlite" - echo "Migration complete. Delete the legacy event-queue-bot-dev droplet manually once verified." - - name: Build bot environment env: BOT_APP_ID: ${{ secrets.BOT_APP_ID }} diff --git a/INFRA.md b/INFRA.md index c4ebba4e..7e0d1b3c 100644 --- a/INFRA.md +++ b/INFRA.md @@ -275,24 +275,3 @@ Firewall: event-queue-bot-ssh SSH key: event-queue-bot-deploy Tag: event-queue-bot ``` - -## Migrating from the old dual-droplet setup - -If you previously ran separate `event-queue-bot` (prod) and `event-queue-bot-dev` -(dev) droplets, the shared droplet reuses the existing prod droplet, so prod data -stays in place. The `deploy` job migrates the old dev database automatically: on -the first dev (`master`) deploy it detects an active `event-queue-bot-dev` -droplet, stops its container for a consistent snapshot, and copies -`main.sqlite` (plus any `-wal`/`-shm`) into `/opt/event-queue-bot-nightly/data` -on the shared droplet. The step is idempotent — it is a no-op once the nightly -database exists or the old droplet is gone. - -After confirming the `queue-bot-nightly` container is healthy on the shared -droplet, delete the now-orphaned dev resources manually: - -```text -Droplet: event-queue-bot-dev -Firewall: event-queue-bot-dev-ssh -SSH key: event-queue-bot-dev-deploy -Tag: event-queue-bot-dev -``` From 34d1befebb85d82ead09a56d0892ac22d26904e8 Mon Sep 17 00:00:00 2001 From: getBoolean <19920697+getBoolean@users.noreply.github.com> Date: Sun, 21 Jun 2026 22:47:30 -0500 Subject: [PATCH 3/7] provision dir helper --- .github/workflows/provision-and-deploy.yml | 4 +-- infra/digitalocean/cloud-init.yml | 30 ++++++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/.github/workflows/provision-and-deploy.yml b/.github/workflows/provision-and-deploy.yml index 72d1b8ff..cba8b55f 100644 --- a/.github/workflows/provision-and-deploy.yml +++ b/.github/workflows/provision-and-deploy.yml @@ -254,7 +254,7 @@ jobs: - name: Wait for cloud-init run: | for attempt in {1..30}; do - if ssh -i ~/.ssh/bot_deploy_key -o ConnectTimeout=10 deploy@"${BOT_HOST}" "cloud-init status --wait && test -x /usr/local/bin/deploy-event-queue-bot"; then + if ssh -i ~/.ssh/bot_deploy_key -o ConnectTimeout=10 deploy@"${BOT_HOST}" "cloud-init status --wait && test -x /usr/local/bin/deploy-event-queue-bot && test -x /usr/local/bin/prepare-event-queue-bot-dir"; then exit 0 fi @@ -294,7 +294,7 @@ jobs: - name: Sync compose file to VPS run: | - ssh -i ~/.ssh/bot_deploy_key deploy@"${BOT_HOST}" "mkdir -p '${APP_PATH}'" + ssh -i ~/.ssh/bot_deploy_key deploy@"${BOT_HOST}" "sudo /usr/local/bin/prepare-event-queue-bot-dir '${APP_PATH}'" rsync -az \ -e "ssh -i ~/.ssh/bot_deploy_key" \ docker-compose.app.yml deploy@"${BOT_HOST}":"${APP_PATH}/" diff --git a/infra/digitalocean/cloud-init.yml b/infra/digitalocean/cloud-init.yml index bccdf9f7..f4fdd6a1 100644 --- a/infra/digitalocean/cloud-init.yml +++ b/infra/digitalocean/cloud-init.yml @@ -25,6 +25,35 @@ users: - {{SSH_PUBLIC_KEY_YAML}} write_files: + - path: /usr/local/bin/prepare-event-queue-bot-dir + owner: root:root + permissions: "0755" + content: | + #!/usr/bin/env bash + # Create an app directory (data + logs) owned by the deploy user. Runs as + # root via sudo so the deploy workflow never needs to mkdir under /opt + # itself, and so app dirs can be added to a living droplet without a + # re-provision. + set -euo pipefail + + if [ "$#" -ne 1 ]; then + echo "Usage: prepare-event-queue-bot-dir " >&2 + exit 1 + fi + + APP_DIR="$1" + + case "$APP_DIR" in + /opt/event-queue-bot|/opt/event-queue-bot-nightly) ;; + *) + echo "Refusing to prepare unexpected path: $APP_DIR" >&2 + exit 1 + ;; + esac + + mkdir -p "$APP_DIR/data" "$APP_DIR/logs" + chown -R deploy:deploy "$APP_DIR" + - path: /usr/local/bin/deploy-event-queue-bot owner: root:root permissions: "0755" @@ -88,6 +117,7 @@ write_files: permissions: "0440" content: | deploy ALL=(root) NOPASSWD: /usr/local/bin/deploy-event-queue-bot + deploy ALL=(root) NOPASSWD: /usr/local/bin/prepare-event-queue-bot-dir - path: /etc/ssh/ssh_host_ed25519_key owner: root:root From 74bfd1ea2cf7d3d6d2a64ff30cf8ff052b2c7691 Mon Sep 17 00:00:00 2001 From: getBoolean <19920697+getBoolean@users.noreply.github.com> Date: Sun, 21 Jun 2026 22:53:02 -0500 Subject: [PATCH 4/7] sync cloud deploy scripts --- .gitattributes | 2 + .github/workflows/provision-and-deploy.yml | 10 +++ INFRA.md | 7 +- README-dev.md | 4 +- infra/digitalocean/cloud-init.yml | 87 ++++--------------- infra/digitalocean/deploy-event-queue-bot.sh | 58 +++++++++++++ .../prepare-event-queue-bot-dir.sh | 25 ++++++ 7 files changed, 119 insertions(+), 74 deletions(-) create mode 100644 .gitattributes create mode 100644 infra/digitalocean/deploy-event-queue-bot.sh create mode 100644 infra/digitalocean/prepare-event-queue-bot-dir.sh diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..f7e2def6 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +* text=auto eol=lf +*.sh text eol=lf diff --git a/.github/workflows/provision-and-deploy.yml b/.github/workflows/provision-and-deploy.yml index cba8b55f..47df2656 100644 --- a/.github/workflows/provision-and-deploy.yml +++ b/.github/workflows/provision-and-deploy.yml @@ -265,6 +265,16 @@ jobs: echo "Timed out waiting for cloud-init" >&2 exit 1 + - name: Sync deploy scripts to VPS + run: | + rsync -az \ + -e "ssh -i ~/.ssh/bot_deploy_key" \ + infra/digitalocean/prepare-event-queue-bot-dir.sh \ + infra/digitalocean/deploy-event-queue-bot.sh \ + deploy@"${BOT_HOST}":/opt/event-queue-bot-bin/ + ssh -i ~/.ssh/bot_deploy_key deploy@"${BOT_HOST}" \ + "chmod +x /opt/event-queue-bot-bin/prepare-event-queue-bot-dir.sh /opt/event-queue-bot-bin/deploy-event-queue-bot.sh" + - name: Build bot environment env: BOT_APP_ID: ${{ secrets.BOT_APP_ID }} diff --git a/INFRA.md b/INFRA.md index 7e0d1b3c..da08e064 100644 --- a/INFRA.md +++ b/INFRA.md @@ -177,8 +177,11 @@ In GitHub: 3. Run the workflow. The workflow builds and pushes the image to GHCR, creates or reuses the VPS, -writes `.env`, syncs `docker-compose.app.yml`, pulls the image, and runs Docker -Compose. +syncs the deploy scripts and `docker-compose.app.yml`, writes `.env`, pulls the +image, and runs Docker Compose. The droplet's deploy logic lives in +`infra/digitalocean/*.sh` and is rsynced on every deploy (cloud-init installs +thin root wrappers that exec them), so logic changes roll out without +re-provisioning. Future pushes to `master` deploy to dev automatically; each run pauses at `gate` for `dev-gate` reviewer approval before `build-and-push`, `discover`, diff --git a/README-dev.md b/README-dev.md index a82e39e5..a2ee991c 100644 --- a/README-dev.md +++ b/README-dev.md @@ -97,7 +97,9 @@ Pushes to `master` trigger `.github/workflows/provision-and-deploy.yml`, which r 1. **`build-and-push`** — builds the Docker image and pushes it to GHCR (`ghcr.io/getboolean/event-queue-bot`) tagged with the branch name (`master` for dev, `prod` for prod). 2. **`discover`** — looks up the single shared DigitalOcean droplet by `DO_DROPLET_NAME`. 3. **`provision`** — creates the droplet via cloud-init only if none exists. -4. **`deploy`** — syncs `docker-compose.app.yml`, writes `.env`, pulls the GHCR image, and runs `docker compose -f docker-compose.app.yml up -d` on the droplet. Pending Drizzle migrations apply automatically on container start. +4. **`deploy`** — syncs the deploy scripts (`infra/digitalocean/*.sh`) and `docker-compose.app.yml`, writes `.env`, pulls the GHCR image, and runs `docker compose -f docker-compose.app.yml up -d` on the droplet. Pending Drizzle migrations apply automatically on container start. + +The droplet's deploy logic lives in `infra/digitalocean/prepare-event-queue-bot-dir.sh` and `infra/digitalocean/deploy-event-queue-bot.sh`. cloud-init installs thin root wrappers (`/usr/local/bin/*`) that exec these scripts from `/opt/event-queue-bot-bin/`, and the `deploy` job rsyncs the latest copies on every run — so changes to that logic roll out without re-provisioning. Only the wrappers, the sudoers entry, and the cloud-init `runcmd` require a re-provision to change. Both environments share one droplet: prod runs the `queue-bot` container from `/opt/event-queue-bot`, and dev runs the `queue-bot-nightly` container from `/opt/event-queue-bot-nightly`, each with its own `data/main.sqlite`. The `deploy` job derives the container name, app path, and image tag from the branch and serializes prod/dev deploys via a shared concurrency group. diff --git a/infra/digitalocean/cloud-init.yml b/infra/digitalocean/cloud-init.yml index f4fdd6a1..43474c7f 100644 --- a/infra/digitalocean/cloud-init.yml +++ b/infra/digitalocean/cloud-init.yml @@ -25,34 +25,24 @@ users: - {{SSH_PUBLIC_KEY_YAML}} write_files: + # Thin root wrappers. They exec the repo-synced scripts under + # /opt/event-queue-bot-bin so the prepare/deploy logic can be updated on every + # deploy without re-provisioning. The deploy user is already in the docker + # group (root-equivalent), so running the deploy-owned scripts as root via + # sudo adds no extra privilege. Only these wrappers, the sudoers entry, and + # the cloud-init runcmd require a re-provision to change. - path: /usr/local/bin/prepare-event-queue-bot-dir owner: root:root permissions: "0755" content: | #!/usr/bin/env bash - # Create an app directory (data + logs) owned by the deploy user. Runs as - # root via sudo so the deploy workflow never needs to mkdir under /opt - # itself, and so app dirs can be added to a living droplet without a - # re-provision. set -euo pipefail - - if [ "$#" -ne 1 ]; then - echo "Usage: prepare-event-queue-bot-dir " >&2 + SCRIPT=/opt/event-queue-bot-bin/prepare-event-queue-bot-dir.sh + if [ ! -x "$SCRIPT" ]; then + echo "Missing $SCRIPT; the deploy workflow syncs it before use." >&2 exit 1 fi - - APP_DIR="$1" - - case "$APP_DIR" in - /opt/event-queue-bot|/opt/event-queue-bot-nightly) ;; - *) - echo "Refusing to prepare unexpected path: $APP_DIR" >&2 - exit 1 - ;; - esac - - mkdir -p "$APP_DIR/data" "$APP_DIR/logs" - chown -R deploy:deploy "$APP_DIR" + exec "$SCRIPT" "$@" - path: /usr/local/bin/deploy-event-queue-bot owner: root:root @@ -60,57 +50,12 @@ write_files: content: | #!/usr/bin/env bash set -euo pipefail - - if [ "$#" -ne 1 ]; then - echo "Usage: deploy-event-queue-bot " >&2 - exit 1 - fi - - APP_DIR="$1" - - mkdir -p "$APP_DIR/data" "$APP_DIR/logs" - chown -R deploy:deploy "$APP_DIR" - - cd "$APP_DIR" - - if [ ! -f docker-compose.app.yml ]; then - echo "Missing $APP_DIR/docker-compose.app.yml; sync deploy artifacts before deploying." >&2 + SCRIPT=/opt/event-queue-bot-bin/deploy-event-queue-bot.sh + if [ ! -x "$SCRIPT" ]; then + echo "Missing $SCRIPT; the deploy workflow syncs it before use." >&2 exit 1 fi - - if [ ! -s .env ]; then - echo "Missing $APP_DIR/.env; refusing to start the bot." >&2 - exit 1 - fi - - # shellcheck disable=SC1091 - set -a - source .env - set +a - - if [ -z "${CONTAINER_NAME:-}" ]; then - echo "CONTAINER_NAME is required in $APP_DIR/.env" >&2 - exit 1 - fi - - docker compose -f docker-compose.app.yml pull - docker compose -f docker-compose.app.yml up -d - docker image prune -f --filter "until=72h" - - for attempt in {1..12}; do - if docker inspect -f '{{.State.Running}}' "$CONTAINER_NAME" 2>/dev/null | grep -qx true; then - break - fi - if [ "$attempt" -eq 12 ]; then - echo "Bot container did not start after deploy" >&2 - docker ps -a --filter "name=$CONTAINER_NAME" || true - docker logs --tail 200 "$CONTAINER_NAME" 2>&1 || true - exit 1 - fi - sleep 2 - done - - docker logs --tail 100 "$CONTAINER_NAME" + exec "$SCRIPT" "$@" - path: /etc/sudoers.d/event-queue-bot-deploy owner: root:root @@ -141,5 +86,5 @@ runcmd: - apt-get update - apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin - systemctl enable --now docker - - mkdir -p /opt/event-queue-bot/data /opt/event-queue-bot/logs /opt/event-queue-bot-nightly/data /opt/event-queue-bot-nightly/logs - - chown -R deploy:deploy /opt/event-queue-bot /opt/event-queue-bot-nightly + - mkdir -p /opt/event-queue-bot-bin /opt/event-queue-bot/data /opt/event-queue-bot/logs /opt/event-queue-bot-nightly/data /opt/event-queue-bot-nightly/logs + - chown -R deploy:deploy /opt/event-queue-bot-bin /opt/event-queue-bot /opt/event-queue-bot-nightly diff --git a/infra/digitalocean/deploy-event-queue-bot.sh b/infra/digitalocean/deploy-event-queue-bot.sh new file mode 100644 index 00000000..c7df6544 --- /dev/null +++ b/infra/digitalocean/deploy-event-queue-bot.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# Pull the published image and (re)start the bot container for an app dir. +# +# Installed on the droplet at /opt/event-queue-bot-bin/ and run as root via the +# /usr/local/bin/deploy-event-queue-bot wrapper. The deploy workflow rsyncs this +# file on every deploy, so changes here roll out without re-provisioning. +set -euo pipefail + +if [ "$#" -ne 1 ]; then + echo "Usage: deploy-event-queue-bot " >&2 + exit 1 +fi + +APP_DIR="$1" + +mkdir -p "$APP_DIR/data" "$APP_DIR/logs" +chown -R deploy:deploy "$APP_DIR" + +cd "$APP_DIR" + +if [ ! -f docker-compose.app.yml ]; then + echo "Missing $APP_DIR/docker-compose.app.yml; sync deploy artifacts before deploying." >&2 + exit 1 +fi + +if [ ! -s .env ]; then + echo "Missing $APP_DIR/.env; refusing to start the bot." >&2 + exit 1 +fi + +# shellcheck disable=SC1091 +set -a +source .env +set +a + +if [ -z "${CONTAINER_NAME:-}" ]; then + echo "CONTAINER_NAME is required in $APP_DIR/.env" >&2 + exit 1 +fi + +docker compose -f docker-compose.app.yml pull +docker compose -f docker-compose.app.yml up -d +docker image prune -f --filter "until=72h" + +for attempt in {1..12}; do + if docker inspect -f '{{.State.Running}}' "$CONTAINER_NAME" 2>/dev/null | grep -qx true; then + break + fi + if [ "$attempt" -eq 12 ]; then + echo "Bot container did not start after deploy" >&2 + docker ps -a --filter "name=$CONTAINER_NAME" || true + docker logs --tail 200 "$CONTAINER_NAME" 2>&1 || true + exit 1 + fi + sleep 2 +done + +docker logs --tail 100 "$CONTAINER_NAME" diff --git a/infra/digitalocean/prepare-event-queue-bot-dir.sh b/infra/digitalocean/prepare-event-queue-bot-dir.sh new file mode 100644 index 00000000..6b81f6df --- /dev/null +++ b/infra/digitalocean/prepare-event-queue-bot-dir.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Create an app directory (data + logs) owned by the deploy user. +# +# Installed on the droplet at /opt/event-queue-bot-bin/ and run as root via the +# /usr/local/bin/prepare-event-queue-bot-dir wrapper. The deploy workflow rsyncs +# this file on every deploy, so changes here roll out without re-provisioning. +set -euo pipefail + +if [ "$#" -ne 1 ]; then + echo "Usage: prepare-event-queue-bot-dir " >&2 + exit 1 +fi + +APP_DIR="$1" + +case "$APP_DIR" in + /opt/event-queue-bot|/opt/event-queue-bot-nightly) ;; + *) + echo "Refusing to prepare unexpected path: $APP_DIR" >&2 + exit 1 + ;; +esac + +mkdir -p "$APP_DIR/data" "$APP_DIR/logs" +chown -R deploy:deploy "$APP_DIR" From bac3d8f06a5bb6b4b9bbf3c3568a2b2c00833595 Mon Sep 17 00:00:00 2001 From: getBoolean <19920697+getBoolean@users.noreply.github.com> Date: Mon, 22 Jun 2026 00:53:08 -0500 Subject: [PATCH 5/7] Mirror dlsite_opds deploy workflow: cloud-init deploy script, ensure-firewall.sh Co-authored-by: Cursor --- .github/workflows/provision-and-deploy.yml | 30 ++++---- .github/workflows/restart-bot.yml | 2 +- INFRA.md | 44 +++++++++-- README-dev.md | 4 +- infra/digitalocean/cloud-init.yml | 73 +++++++++++++------ infra/digitalocean/deploy-event-queue-bot.sh | 58 --------------- .../prepare-event-queue-bot-dir.sh | 25 ------- scripts/ensure-firewall.sh | 58 +++++++++++++++ scripts/provision-digitalocean.sh | 41 +---------- 9 files changed, 164 insertions(+), 171 deletions(-) delete mode 100644 infra/digitalocean/deploy-event-queue-bot.sh delete mode 100644 infra/digitalocean/prepare-event-queue-bot-dir.sh create mode 100644 scripts/ensure-firewall.sh diff --git a/.github/workflows/provision-and-deploy.yml b/.github/workflows/provision-and-deploy.yml index 47df2656..5a6c9162 100644 --- a/.github/workflows/provision-and-deploy.yml +++ b/.github/workflows/provision-and-deploy.yml @@ -203,7 +203,7 @@ jobs: env: DEPLOY_BRANCH: ${{ github.ref_name }} - APP_PATH: ${{ github.ref_name == 'prod' && '/opt/event-queue-bot' || '/opt/event-queue-bot-nightly' }} + APP_PATH: ${{ vars.APP_PATH || (github.ref_name == 'prod' && '/opt/event-queue-bot' || '/opt/event-queue-bot-nightly') }} CONTAINER_NAME: ${{ github.ref_name == 'prod' && 'queue-bot' || 'queue-bot-nightly' }} IMAGE_TAG: ${{ github.ref_name }} # Coalesce: fresh-provision output wins; otherwise discover's lookup. @@ -227,6 +227,17 @@ jobs: with: ref: ${{ env.DEPLOY_BRANCH }} + - name: Install doctl + uses: digitalocean/action-doctl@v2.5.2 + with: + token: ${{ secrets.DIGITALOCEAN_TOKEN }} + version: 1.159.0 + + - name: Ensure firewall allows bot ports + env: + DO_DROPLET_NAME: ${{ vars.DO_DROPLET_NAME || 'event-queue-bot' }} + run: bash scripts/ensure-firewall.sh + - name: Configure SSH env: SSH_DEPLOY_PRIVATE_KEY: ${{ secrets.SSH_DEPLOY_PRIVATE_KEY }} @@ -254,7 +265,8 @@ jobs: - name: Wait for cloud-init run: | for attempt in {1..30}; do - if ssh -i ~/.ssh/bot_deploy_key -o ConnectTimeout=10 deploy@"${BOT_HOST}" "cloud-init status --wait && test -x /usr/local/bin/deploy-event-queue-bot && test -x /usr/local/bin/prepare-event-queue-bot-dir"; then + if ssh -i ~/.ssh/bot_deploy_key -o ConnectTimeout=10 deploy@"${BOT_HOST}" \ + "cloud-init status --wait && test -x /usr/local/bin/deploy-event-queue-bot"; then exit 0 fi @@ -265,16 +277,6 @@ jobs: echo "Timed out waiting for cloud-init" >&2 exit 1 - - name: Sync deploy scripts to VPS - run: | - rsync -az \ - -e "ssh -i ~/.ssh/bot_deploy_key" \ - infra/digitalocean/prepare-event-queue-bot-dir.sh \ - infra/digitalocean/deploy-event-queue-bot.sh \ - deploy@"${BOT_HOST}":/opt/event-queue-bot-bin/ - ssh -i ~/.ssh/bot_deploy_key deploy@"${BOT_HOST}" \ - "chmod +x /opt/event-queue-bot-bin/prepare-event-queue-bot-dir.sh /opt/event-queue-bot-bin/deploy-event-queue-bot.sh" - - name: Build bot environment env: BOT_APP_ID: ${{ secrets.BOT_APP_ID }} @@ -302,9 +304,9 @@ jobs: printf 'IMAGE_TAG=%s\n' "${IMAGE_TAG}" } > "${RUNNER_TEMP}/bot.env" - - name: Sync compose file to VPS + - name: Sync deploy artifacts to VPS run: | - ssh -i ~/.ssh/bot_deploy_key deploy@"${BOT_HOST}" "sudo /usr/local/bin/prepare-event-queue-bot-dir '${APP_PATH}'" + ssh -i ~/.ssh/bot_deploy_key deploy@"${BOT_HOST}" "mkdir -p '${APP_PATH}'" rsync -az \ -e "ssh -i ~/.ssh/bot_deploy_key" \ docker-compose.app.yml deploy@"${BOT_HOST}":"${APP_PATH}/" diff --git a/.github/workflows/restart-bot.yml b/.github/workflows/restart-bot.yml index 3526436c..59189999 100644 --- a/.github/workflows/restart-bot.yml +++ b/.github/workflows/restart-bot.yml @@ -35,7 +35,7 @@ jobs: env: DO_DROPLET_NAME: ${{ vars.DO_DROPLET_NAME || 'event-queue-bot' }} - APP_PATH: ${{ inputs.environment == 'prod' && '/opt/event-queue-bot' || '/opt/event-queue-bot-nightly' }} + APP_PATH: ${{ vars.APP_PATH || (inputs.environment == 'prod' && '/opt/event-queue-bot' || '/opt/event-queue-bot-nightly') }} CONTAINER_NAME: ${{ inputs.environment == 'prod' && 'queue-bot' || 'queue-bot-nightly' }} steps: diff --git a/INFRA.md b/INFRA.md index da08e064..87a9d9f5 100644 --- a/INFRA.md +++ b/INFRA.md @@ -35,12 +35,16 @@ A `prod` environment and `prod` branch are required for prod deploys — see Create a DigitalOcean API token with these custom scopes: -- `droplet:read`, `droplet:create` +- `droplet:read`, `droplet:create`, `droplet:delete` - `ssh_key:read`, `ssh_key:create` - `firewall:read`, `firewall:create`, `firewall:update` - `tag:read`, `tag:create` - `project:read`, `project:create`, `project:update` +`droplet:delete` is required only when tearing down the droplet for a +re-provision (see [Re-provisioning via the CLI](#re-provisioning-via-the-cli)); +CI day-to-day deploys use the read/create scopes. + Save it as this GitHub repository secret: ```text @@ -143,6 +147,7 @@ default below. | `DO_DROPLET_NAME` | repo | `event-queue-bot` | | `DO_ENABLE_BACKUPS` | repo | `false` | | `DO_SWAP_SIZE` | repo | `1G` | +| `APP_PATH` | env | branch-derived (see above) | | `BOT_TOP_GG_TOKEN` | env | empty | | `BOT_PATCH_NOTES_CHANNEL_ID` | env | empty | | `BOT_DEFAULT_COLOR` | env | `Random` | @@ -153,8 +158,8 @@ default below. The app path, container name, and image tag are derived from the branch by the `deploy` job (prod → `/opt/event-queue-bot` / `queue-bot` / `prod`; dev → -`/opt/event-queue-bot-nightly` / `queue-bot-nightly` / `master`) and are not -configurable via variables. +`/opt/event-queue-bot-nightly` / `queue-bot-nightly` / `master`). Override the +app path per environment with the optional `APP_PATH` variable. `DO_SWAP_SIZE` accepts a positive integer optionally suffixed `K`/`M`/`G`, or `0` to disable. Applied only at first boot via cloud-init — changing it doesn't affect existing droplets. @@ -177,11 +182,12 @@ In GitHub: 3. Run the workflow. The workflow builds and pushes the image to GHCR, creates or reuses the VPS, -syncs the deploy scripts and `docker-compose.app.yml`, writes `.env`, pulls the -image, and runs Docker Compose. The droplet's deploy logic lives in -`infra/digitalocean/*.sh` and is rsynced on every deploy (cloud-init installs -thin root wrappers that exec them), so logic changes roll out without -re-provisioning. +syncs `docker-compose.app.yml`, writes `.env`, pulls the image, and runs Docker +Compose via `/usr/local/bin/deploy-event-queue-bot` (installed by cloud-init). +The deploy script and sudoers entry live in cloud-init — changing them requires +a re-provision. Firewall rules are reconciled by `scripts/ensure-firewall.sh` +in both the `provision` and `deploy` jobs, so firewall changes apply even when +provision is skipped. Future pushes to `master` deploy to dev automatically; each run pauses at `gate` for `dev-gate` reviewer approval before `build-and-push`, `discover`, @@ -236,6 +242,28 @@ Prod and dev share one droplet but no state: separate containers (`queue-bot` vs `queue-bot-nightly`), separate app dirs and `data/main.sqlite`, separate Discord applications. +## Re-provisioning via the CLI + +When cloud-init changes (deploy script, sudoers, swap size, etc.), delete the +droplet and re-run the workflow so provision creates a fresh one. The same +`DIGITALOCEAN_TOKEN` secret CI uses works locally with `doctl`: + +```bash +export DIGITALOCEAN_TOKEN= +doctl auth init -t "$DIGITALOCEAN_TOKEN" +``` + +The token needs the scopes listed in [§1](#1-create-digitalocean-token), +including **`droplet:delete`** for teardown. Typical sequence: + +1. Back up both databases (see [Backup Before Deleting](#backup-before-deleting)). +2. Delete the droplet: `doctl compute droplet delete event-queue-bot` (or the + DO console). +3. Push the updated cloud-init and run `Provision and Deploy Bot` — provision + recreates the droplet, then deploy starts the containers. +4. Restore each database if needed (stop container, copy `main.sqlite` back, + restart). + ## Connect to the Droplet Get the droplet IPv4 from the latest workflow's `discover` job, `doctl compute diff --git a/README-dev.md b/README-dev.md index a2ee991c..637a0c72 100644 --- a/README-dev.md +++ b/README-dev.md @@ -97,9 +97,9 @@ Pushes to `master` trigger `.github/workflows/provision-and-deploy.yml`, which r 1. **`build-and-push`** — builds the Docker image and pushes it to GHCR (`ghcr.io/getboolean/event-queue-bot`) tagged with the branch name (`master` for dev, `prod` for prod). 2. **`discover`** — looks up the single shared DigitalOcean droplet by `DO_DROPLET_NAME`. 3. **`provision`** — creates the droplet via cloud-init only if none exists. -4. **`deploy`** — syncs the deploy scripts (`infra/digitalocean/*.sh`) and `docker-compose.app.yml`, writes `.env`, pulls the GHCR image, and runs `docker compose -f docker-compose.app.yml up -d` on the droplet. Pending Drizzle migrations apply automatically on container start. +4. **`deploy`** — syncs `docker-compose.app.yml`, writes `.env`, pulls the GHCR image, and runs `sudo /usr/local/bin/deploy-event-queue-bot` on the droplet. Pending Drizzle migrations apply automatically on container start. -The droplet's deploy logic lives in `infra/digitalocean/prepare-event-queue-bot-dir.sh` and `infra/digitalocean/deploy-event-queue-bot.sh`. cloud-init installs thin root wrappers (`/usr/local/bin/*`) that exec these scripts from `/opt/event-queue-bot-bin/`, and the `deploy` job rsyncs the latest copies on every run — so changes to that logic roll out without re-provisioning. Only the wrappers, the sudoers entry, and the cloud-init `runcmd` require a re-provision to change. +The deploy script is installed by cloud-init at `/usr/local/bin/deploy-event-queue-bot`. Changing it requires a re-provision. Firewall rules are reconciled by `scripts/ensure-firewall.sh` on every deploy. Both environments share one droplet: prod runs the `queue-bot` container from `/opt/event-queue-bot`, and dev runs the `queue-bot-nightly` container from `/opt/event-queue-bot-nightly`, each with its own `data/main.sqlite`. The `deploy` job derives the container name, app path, and image tag from the branch and serializes prod/dev deploys via a shared concurrency group. diff --git a/infra/digitalocean/cloud-init.yml b/infra/digitalocean/cloud-init.yml index 43474c7f..7be4fff2 100644 --- a/infra/digitalocean/cloud-init.yml +++ b/infra/digitalocean/cloud-init.yml @@ -25,44 +25,69 @@ users: - {{SSH_PUBLIC_KEY_YAML}} write_files: - # Thin root wrappers. They exec the repo-synced scripts under - # /opt/event-queue-bot-bin so the prepare/deploy logic can be updated on every - # deploy without re-provisioning. The deploy user is already in the docker - # group (root-equivalent), so running the deploy-owned scripts as root via - # sudo adds no extra privilege. Only these wrappers, the sudoers entry, and - # the cloud-init runcmd require a re-provision to change. - - path: /usr/local/bin/prepare-event-queue-bot-dir + - path: /usr/local/bin/deploy-event-queue-bot owner: root:root permissions: "0755" content: | #!/usr/bin/env bash set -euo pipefail - SCRIPT=/opt/event-queue-bot-bin/prepare-event-queue-bot-dir.sh - if [ ! -x "$SCRIPT" ]; then - echo "Missing $SCRIPT; the deploy workflow syncs it before use." >&2 + + if [ "$#" -ne 1 ]; then + echo "Usage: deploy-event-queue-bot " >&2 exit 1 fi - exec "$SCRIPT" "$@" - - path: /usr/local/bin/deploy-event-queue-bot - owner: root:root - permissions: "0755" - content: | - #!/usr/bin/env bash - set -euo pipefail - SCRIPT=/opt/event-queue-bot-bin/deploy-event-queue-bot.sh - if [ ! -x "$SCRIPT" ]; then - echo "Missing $SCRIPT; the deploy workflow syncs it before use." >&2 + APP_DIR="$1" + + mkdir -p "$APP_DIR/data" + chown -R deploy:deploy "$APP_DIR" + + cd "$APP_DIR" + + if [ ! -f docker-compose.app.yml ]; then + echo "Missing $APP_DIR/docker-compose.app.yml; sync deploy artifacts before deploying." >&2 exit 1 fi - exec "$SCRIPT" "$@" + + if [ ! -s .env ]; then + echo "Missing $APP_DIR/.env; refusing to start the bot." >&2 + exit 1 + fi + + # shellcheck disable=SC1091 + set -a + source .env + set +a + + if [ -z "${CONTAINER_NAME:-}" ]; then + echo "CONTAINER_NAME is required in $APP_DIR/.env" >&2 + exit 1 + fi + + docker compose -f docker-compose.app.yml pull + docker compose -f docker-compose.app.yml up -d + docker image prune -f --filter "until=72h" + + for attempt in {1..12}; do + if docker inspect -f '{{.State.Running}}' "$CONTAINER_NAME" 2>/dev/null | grep -qx true; then + break + fi + if [ "$attempt" -eq 12 ]; then + echo "Bot container did not start after deploy" >&2 + docker ps -a --filter "name=$CONTAINER_NAME" || true + docker logs --tail 200 "$CONTAINER_NAME" 2>&1 || true + exit 1 + fi + sleep 2 + done + + docker logs --tail 100 "$CONTAINER_NAME" - path: /etc/sudoers.d/event-queue-bot-deploy owner: root:root permissions: "0440" content: | deploy ALL=(root) NOPASSWD: /usr/local/bin/deploy-event-queue-bot - deploy ALL=(root) NOPASSWD: /usr/local/bin/prepare-event-queue-bot-dir - path: /etc/ssh/ssh_host_ed25519_key owner: root:root @@ -86,5 +111,5 @@ runcmd: - apt-get update - apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin - systemctl enable --now docker - - mkdir -p /opt/event-queue-bot-bin /opt/event-queue-bot/data /opt/event-queue-bot/logs /opt/event-queue-bot-nightly/data /opt/event-queue-bot-nightly/logs - - chown -R deploy:deploy /opt/event-queue-bot-bin /opt/event-queue-bot /opt/event-queue-bot-nightly + - mkdir -p /opt/event-queue-bot/data /opt/event-queue-bot-nightly/data + - chown -R deploy:deploy /opt/event-queue-bot /opt/event-queue-bot-nightly diff --git a/infra/digitalocean/deploy-event-queue-bot.sh b/infra/digitalocean/deploy-event-queue-bot.sh deleted file mode 100644 index c7df6544..00000000 --- a/infra/digitalocean/deploy-event-queue-bot.sh +++ /dev/null @@ -1,58 +0,0 @@ -#!/usr/bin/env bash -# Pull the published image and (re)start the bot container for an app dir. -# -# Installed on the droplet at /opt/event-queue-bot-bin/ and run as root via the -# /usr/local/bin/deploy-event-queue-bot wrapper. The deploy workflow rsyncs this -# file on every deploy, so changes here roll out without re-provisioning. -set -euo pipefail - -if [ "$#" -ne 1 ]; then - echo "Usage: deploy-event-queue-bot " >&2 - exit 1 -fi - -APP_DIR="$1" - -mkdir -p "$APP_DIR/data" "$APP_DIR/logs" -chown -R deploy:deploy "$APP_DIR" - -cd "$APP_DIR" - -if [ ! -f docker-compose.app.yml ]; then - echo "Missing $APP_DIR/docker-compose.app.yml; sync deploy artifacts before deploying." >&2 - exit 1 -fi - -if [ ! -s .env ]; then - echo "Missing $APP_DIR/.env; refusing to start the bot." >&2 - exit 1 -fi - -# shellcheck disable=SC1091 -set -a -source .env -set +a - -if [ -z "${CONTAINER_NAME:-}" ]; then - echo "CONTAINER_NAME is required in $APP_DIR/.env" >&2 - exit 1 -fi - -docker compose -f docker-compose.app.yml pull -docker compose -f docker-compose.app.yml up -d -docker image prune -f --filter "until=72h" - -for attempt in {1..12}; do - if docker inspect -f '{{.State.Running}}' "$CONTAINER_NAME" 2>/dev/null | grep -qx true; then - break - fi - if [ "$attempt" -eq 12 ]; then - echo "Bot container did not start after deploy" >&2 - docker ps -a --filter "name=$CONTAINER_NAME" || true - docker logs --tail 200 "$CONTAINER_NAME" 2>&1 || true - exit 1 - fi - sleep 2 -done - -docker logs --tail 100 "$CONTAINER_NAME" diff --git a/infra/digitalocean/prepare-event-queue-bot-dir.sh b/infra/digitalocean/prepare-event-queue-bot-dir.sh deleted file mode 100644 index 6b81f6df..00000000 --- a/infra/digitalocean/prepare-event-queue-bot-dir.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/usr/bin/env bash -# Create an app directory (data + logs) owned by the deploy user. -# -# Installed on the droplet at /opt/event-queue-bot-bin/ and run as root via the -# /usr/local/bin/prepare-event-queue-bot-dir wrapper. The deploy workflow rsyncs -# this file on every deploy, so changes here roll out without re-provisioning. -set -euo pipefail - -if [ "$#" -ne 1 ]; then - echo "Usage: prepare-event-queue-bot-dir " >&2 - exit 1 -fi - -APP_DIR="$1" - -case "$APP_DIR" in - /opt/event-queue-bot|/opt/event-queue-bot-nightly) ;; - *) - echo "Refusing to prepare unexpected path: $APP_DIR" >&2 - exit 1 - ;; -esac - -mkdir -p "$APP_DIR/data" "$APP_DIR/logs" -chown -R deploy:deploy "$APP_DIR" diff --git a/scripts/ensure-firewall.sh b/scripts/ensure-firewall.sh new file mode 100644 index 00000000..fb7e322c --- /dev/null +++ b/scripts/ensure-firewall.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# Ensure the DigitalOcean firewall allows SSH (idempotent). +set -euo pipefail + +if ! command -v doctl >/dev/null 2>&1; then + echo "doctl is required" >&2 + exit 1 +fi + +if ! command -v jq >/dev/null 2>&1; then + echo "jq is required" >&2 + exit 1 +fi + +DO_DROPLET_NAME="${DO_DROPLET_NAME:-event-queue-bot}" +DO_FIREWALL_NAME="${DO_FIREWALL_NAME:-${DO_DROPLET_NAME}-ssh}" + +droplet_id="$(doctl compute droplet list \ + --format Name,ID \ + --no-header \ + | awk -v name="${DO_DROPLET_NAME}" '$1 == name { print $2; exit }')" + +if [ -z "${droplet_id}" ]; then + echo "No droplet named ${DO_DROPLET_NAME}; skipping firewall update." >&2 + exit 0 +fi + +inbound_rules="protocol:tcp,ports:22,address:0.0.0.0/0,address:::/0" +outbound_rules="protocol:icmp,address:0.0.0.0/0,address:::/0 protocol:tcp,ports:all,address:0.0.0.0/0,address:::/0 protocol:udp,ports:all,address:0.0.0.0/0,address:::/0" + +firewalls_json="$(doctl compute firewall list --output json)" +firewall_count="$( + jq -r --arg name "$DO_FIREWALL_NAME" '[.[] | select(.name == $name)] | length' <<< "$firewalls_json" +)" + +if [ "$firewall_count" -gt 1 ]; then + echo "Found multiple DigitalOcean Firewalls named ${DO_FIREWALL_NAME}" >&2 + exit 1 +fi + +if [ "$firewall_count" -eq 1 ]; then + firewall_id="$(jq -r --arg name "$DO_FIREWALL_NAME" '.[] | select(.name == $name) | .id' <<< "$firewalls_json")" + echo "Updating Firewall ${DO_FIREWALL_NAME}" + doctl compute firewall update "$firewall_id" \ + --name "$DO_FIREWALL_NAME" \ + --inbound-rules "$inbound_rules" \ + --outbound-rules "$outbound_rules" \ + --droplet-ids "$droplet_id" +else + echo "Creating Firewall ${DO_FIREWALL_NAME}" + doctl compute firewall create \ + --name "$DO_FIREWALL_NAME" \ + --inbound-rules "$inbound_rules" \ + --outbound-rules "$outbound_rules" \ + --droplet-ids "$droplet_id" +fi + +echo "Firewall ${DO_FIREWALL_NAME} allows SSH on port 22." diff --git a/scripts/provision-digitalocean.sh b/scripts/provision-digitalocean.sh index b6e112f6..d871c13d 100644 --- a/scripts/provision-digitalocean.sh +++ b/scripts/provision-digitalocean.sh @@ -49,14 +49,8 @@ DO_PROJECT_NAME="${DO_PROJECT_NAME:-Event Queue Bot}" DO_PROJECT_PURPOSE="${DO_PROJECT_PURPOSE:-Service or API}" DO_PROJECT_ENVIRONMENT="${DO_PROJECT_ENVIRONMENT:-Production}" DO_PROJECT_DESCRIPTION="${DO_PROJECT_DESCRIPTION:-}" -APP_PATH="${APP_PATH:-/opt/event-queue-bot}" DO_SWAP_SIZE="${DO_SWAP_SIZE:-1G}" -if [[ ! "$APP_PATH" =~ ^/[A-Za-z0-9._/-]+$ ]]; then - echo "APP_PATH must be an absolute path containing only letters, numbers, dots, underscores, dashes, and slashes" >&2 - exit 1 -fi - if [[ ! "$DO_SWAP_SIZE" =~ ^(0|[1-9][0-9]*[KMG]?)$ ]]; then echo "DO_SWAP_SIZE must match ^(0|[1-9][0-9]*[KMG]?)$ (e.g. 0 to disable, 512M, 1G)" >&2 exit 1 @@ -74,17 +68,14 @@ printf '%s\n' "$SSH_DEPLOY_PUBLIC_KEY" > "$public_key_file" ssh_key_fingerprint="$(ssh-keygen -E md5 -lf "$public_key_file" | awk '{print $2}' | sed 's/^MD5://')" ssh_public_key_yaml="$(yaml_quote "$SSH_DEPLOY_PUBLIC_KEY")" -app_path_shell="'$APP_PATH'" ssh_host_private_key_b64="$(printf '%s' "$SSH_HOST_PRIVATE_KEY" | base64 -w0)" SSH_PUBLIC_KEY_YAML="$ssh_public_key_yaml" \ -APP_PATH_SHELL="$app_path_shell" \ SSH_HOST_PRIVATE_KEY_B64="$ssh_host_private_key_b64" \ SSH_HOST_PUBLIC_KEY="$SSH_HOST_PUBLIC_KEY" \ DO_SWAP_SIZE="$DO_SWAP_SIZE" \ perl -0pe ' s/\{\{SSH_PUBLIC_KEY_YAML\}\}/$ENV{SSH_PUBLIC_KEY_YAML}/g; - s/\{\{APP_PATH_SHELL\}\}/$ENV{APP_PATH_SHELL}/g; s/\{\{SSH_HOST_PRIVATE_KEY_B64\}\}/$ENV{SSH_HOST_PRIVATE_KEY_B64}/g; s/\{\{SSH_HOST_PUBLIC_KEY\}\}/$ENV{SSH_HOST_PUBLIC_KEY}/g; s/\{\{DO_SWAP_SIZE\}\}/$ENV{DO_SWAP_SIZE}/g; @@ -247,36 +238,8 @@ if [ -n "$DO_PROJECT_NAME" ]; then doctl projects resources assign "$project_id" --resource="do:droplet:${droplet_id}" >/dev/null fi -echo "Listing Firewalls" -firewalls_json="$(doctl compute firewall list --output json)" -firewall_count="$( - jq -r --arg name "$DO_FIREWALL_NAME" '[.[] | select(.name == $name)] | length' <<< "$firewalls_json" -)" - -if [ "$firewall_count" -gt 1 ]; then - echo "Found multiple DigitalOcean Firewalls named ${DO_FIREWALL_NAME}" >&2 - exit 1 -fi - -inbound_rules="protocol:tcp,ports:22,address:0.0.0.0/0,address:::/0" -outbound_rules="protocol:icmp,address:0.0.0.0/0,address:::/0 protocol:tcp,ports:all,address:0.0.0.0/0,address:::/0 protocol:udp,ports:all,address:0.0.0.0/0,address:::/0" - -if [ "$firewall_count" -eq 1 ]; then - firewall_id="$(jq -r --arg name "$DO_FIREWALL_NAME" '.[] | select(.name == $name) | .id' <<< "$firewalls_json")" - echo "Updating Firewall ${DO_FIREWALL_NAME}" - doctl compute firewall update "$firewall_id" \ - --name "$DO_FIREWALL_NAME" \ - --inbound-rules "$inbound_rules" \ - --outbound-rules "$outbound_rules" \ - --droplet-ids "$droplet_id" -else - echo "Creating Firewall ${DO_FIREWALL_NAME}" - doctl compute firewall create \ - --name "$DO_FIREWALL_NAME" \ - --inbound-rules "$inbound_rules" \ - --outbound-rules "$outbound_rules" \ - --droplet-ids "$droplet_id" -fi +DO_DROPLET_NAME="$DO_DROPLET_NAME" DO_FIREWALL_NAME="$DO_FIREWALL_NAME" \ + bash "$(dirname "$0")/ensure-firewall.sh" for attempt in {1..60}; do droplet_json="$(doctl compute droplet get "$droplet_id" --output json)" From dc8f9fbfe30012f7f0796c0a531898de6778c5a9 Mon Sep 17 00:00:00 2001 From: getBoolean <19920697+getBoolean@users.noreply.github.com> Date: Mon, 22 Jun 2026 01:17:26 -0500 Subject: [PATCH 6/7] Move migrations out of bind-mounted data dir so they ship in the image The deploy bind-mounts ./data over /app/data, which shadowed the data/migrations baked into the image and crashed the bot at startup (Can't find meta/_journal.json). Relocate migrations to repo-root migrations/ (code, in image) and keep data/ purely for runtime state, mirroring dlsite's code-vs-state separation. Legacy CSV drop dir moves to data/legacy-export (stays on the mounted volume). Co-authored-by: Cursor --- .gitignore | 2 +- README-dev.md | 6 +++--- docs/image/event-queue-bot-dev-logo.png | Bin 0 -> 38750 bytes drizzle.config.ts | 2 +- .../0000_sour_roulette.sql | 0 .../0001_optimal_piledriver.sql | 0 .../0002_wild_clea.sql | 0 .../0003_gigantic_george_stacy.sql | 0 .../0004_fine_roughhouse.sql | 0 .../0005_groovy_northstar.sql | 0 .../0006_free_echo.sql | 0 .../0007_black_turbo.sql | 0 .../0008_lumpy_serpent_society.sql | 0 .../0009_spicy_songbird.sql | 0 .../0010_stiff_masque.sql | 0 .../0011_complete_ted_forrester.sql | 0 .../0012_powerful_songbird.sql | 0 .../0013_mature_steve_rogers.sql | 0 .../0014_narrow_wind_dancer.sql | 0 .../meta/0000_snapshot.json | 0 .../meta/0001_snapshot.json | 0 .../meta/0002_snapshot.json | 0 .../meta/0003_snapshot.json | 0 .../meta/0004_snapshot.json | 0 .../meta/0005_snapshot.json | 0 .../meta/0006_snapshot.json | 0 .../meta/0007_snapshot.json | 0 .../meta/0008_snapshot.json | 0 .../meta/0009_snapshot.json | 0 .../meta/0010_snapshot.json | 0 .../meta/0011_snapshot.json | 0 .../meta/0012_snapshot.json | 0 .../meta/0013_snapshot.json | 0 .../meta/0014_snapshot.json | 0 .../meta/_journal.json | 0 src/db/db.ts | 2 +- src/db/legacy-migration/migrate.ts | 2 +- 37 files changed, 7 insertions(+), 7 deletions(-) create mode 100644 docs/image/event-queue-bot-dev-logo.png rename {data/migrations => migrations}/0000_sour_roulette.sql (100%) rename {data/migrations => migrations}/0001_optimal_piledriver.sql (100%) rename {data/migrations => migrations}/0002_wild_clea.sql (100%) rename {data/migrations => migrations}/0003_gigantic_george_stacy.sql (100%) rename {data/migrations => migrations}/0004_fine_roughhouse.sql (100%) rename {data/migrations => migrations}/0005_groovy_northstar.sql (100%) rename {data/migrations => migrations}/0006_free_echo.sql (100%) rename {data/migrations => migrations}/0007_black_turbo.sql (100%) rename {data/migrations => migrations}/0008_lumpy_serpent_society.sql (100%) rename {data/migrations => migrations}/0009_spicy_songbird.sql (100%) rename {data/migrations => migrations}/0010_stiff_masque.sql (100%) rename {data/migrations => migrations}/0011_complete_ted_forrester.sql (100%) rename {data/migrations => migrations}/0012_powerful_songbird.sql (100%) rename {data/migrations => migrations}/0013_mature_steve_rogers.sql (100%) rename {data/migrations => migrations}/0014_narrow_wind_dancer.sql (100%) rename {data/migrations => migrations}/meta/0000_snapshot.json (100%) rename {data/migrations => migrations}/meta/0001_snapshot.json (100%) rename {data/migrations => migrations}/meta/0002_snapshot.json (100%) rename {data/migrations => migrations}/meta/0003_snapshot.json (100%) rename {data/migrations => migrations}/meta/0004_snapshot.json (100%) rename {data/migrations => migrations}/meta/0005_snapshot.json (100%) rename {data/migrations => migrations}/meta/0006_snapshot.json (100%) rename {data/migrations => migrations}/meta/0007_snapshot.json (100%) rename {data/migrations => migrations}/meta/0008_snapshot.json (100%) rename {data/migrations => migrations}/meta/0009_snapshot.json (100%) rename {data/migrations => migrations}/meta/0010_snapshot.json (100%) rename {data/migrations => migrations}/meta/0011_snapshot.json (100%) rename {data/migrations => migrations}/meta/0012_snapshot.json (100%) rename {data/migrations => migrations}/meta/0013_snapshot.json (100%) rename {data/migrations => migrations}/meta/0014_snapshot.json (100%) rename {data/migrations => migrations}/meta/_journal.json (100%) diff --git a/.gitignore b/.gitignore index 24be7ce0..008e22e8 100644 --- a/.gitignore +++ b/.gitignore @@ -179,7 +179,7 @@ dist data/manual/main.sqlite data/backups/ -data/migrations/legacy-export/ +data/legacy-export/ # VS Code Workspace .vscode/ diff --git a/README-dev.md b/README-dev.md index 637a0c72..47aac639 100644 --- a/README-dev.md +++ b/README-dev.md @@ -147,7 +147,7 @@ If you need to add or modify database tables or columns: 1. Update `src/db/schema.ts`. Use plain numeric defaults (`.default(0)`) — `.default(0n)` trips a `drizzle-kit` BigInt-serialization bug. `$type()` still types the column as `bigint`. 2. For new tables or query patterns, update `src/db/store.ts` and `src/db/queries.ts`. -3. Run `npx drizzle-kit generate`. Commit the new `data/migrations/*.sql` and `data/migrations/meta/*` files — the runtime migrator applies them on next startup. +3. Run `npx drizzle-kit generate`. Commit the new `migrations/*.sql` and `migrations/meta/*` files — the runtime migrator applies them on next startup. Migrations live at the repo root (not under `data/`) so they ship inside the Docker image; the deploy bind-mounts only `data/`, which would otherwise shadow them. ## Misc @@ -161,7 +161,7 @@ This project is designed to run without compiling thanks to `@swc-node/register/ ## Migrating from the legacy project (pre June 2024) -Open a terminal and navigate to the following directory in this project: `data/migrations/legacy-export`. +Open a terminal and navigate to the following directory in this project: `data/legacy-export`. Export the old database tables to csv files. The following command will perform the export for Postgres: @@ -178,6 +178,6 @@ Then in the `.env` file, set `ENABLE_LEGACY_MIGRATION` to true: ENABLE_LEGACY_MIGRATION=true ``` -When `ENABLE_LEGACY_MIGRATION` is true, the bot checks the `data/migrations/legacy-export` directory on startup. If it finds the csv files, it will prompt you via console input to confirm the import. If confirmed, it creates a dated backup of `data/main.sqlite`, then merges the legacy data into the database. +When `ENABLE_LEGACY_MIGRATION` is true, the bot checks the `data/legacy-export` directory on startup. If it finds the csv files, it will prompt you via console input to confirm the import. If confirmed, it creates a dated backup of `data/main.sqlite`, then merges the legacy data into the database. Once the data is imported, set `ENABLE_LEGACY_MIGRATION` back to false. diff --git a/docs/image/event-queue-bot-dev-logo.png b/docs/image/event-queue-bot-dev-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..ca6a0d4aede5cd13640b11f07dd0aaf4e03889fe GIT binary patch literal 38750 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 Date: Mon, 22 Jun 2026 01:28:56 -0500 Subject: [PATCH 7/7] Mount db + backups subpaths instead of moving migrations Revert the migrations relocation. Keep migrations at data/migrations (shipped in the image) and instead bind-mount only the persistent state (data/main.sqlite and data/backups) so the image's data/migrations is no longer shadowed by the host mount. The deploy pre-creates the sqlite file and backups dir so Docker mounts a file/dir rather than creating a directory in place of the db file. Co-authored-by: Cursor --- .github/workflows/provision-and-deploy.yml | 7 ++++++- .gitignore | 2 +- README-dev.md | 6 +++--- {migrations => data/migrations}/0000_sour_roulette.sql | 0 .../migrations}/0001_optimal_piledriver.sql | 0 {migrations => data/migrations}/0002_wild_clea.sql | 0 .../migrations}/0003_gigantic_george_stacy.sql | 0 {migrations => data/migrations}/0004_fine_roughhouse.sql | 0 {migrations => data/migrations}/0005_groovy_northstar.sql | 0 {migrations => data/migrations}/0006_free_echo.sql | 0 {migrations => data/migrations}/0007_black_turbo.sql | 0 .../migrations}/0008_lumpy_serpent_society.sql | 0 {migrations => data/migrations}/0009_spicy_songbird.sql | 0 {migrations => data/migrations}/0010_stiff_masque.sql | 0 .../migrations}/0011_complete_ted_forrester.sql | 0 {migrations => data/migrations}/0012_powerful_songbird.sql | 0 .../migrations}/0013_mature_steve_rogers.sql | 0 .../migrations}/0014_narrow_wind_dancer.sql | 0 {migrations => data/migrations}/meta/0000_snapshot.json | 0 {migrations => data/migrations}/meta/0001_snapshot.json | 0 {migrations => data/migrations}/meta/0002_snapshot.json | 0 {migrations => data/migrations}/meta/0003_snapshot.json | 0 {migrations => data/migrations}/meta/0004_snapshot.json | 0 {migrations => data/migrations}/meta/0005_snapshot.json | 0 {migrations => data/migrations}/meta/0006_snapshot.json | 0 {migrations => data/migrations}/meta/0007_snapshot.json | 0 {migrations => data/migrations}/meta/0008_snapshot.json | 0 {migrations => data/migrations}/meta/0009_snapshot.json | 0 {migrations => data/migrations}/meta/0010_snapshot.json | 0 {migrations => data/migrations}/meta/0011_snapshot.json | 0 {migrations => data/migrations}/meta/0012_snapshot.json | 0 {migrations => data/migrations}/meta/0013_snapshot.json | 0 {migrations => data/migrations}/meta/0014_snapshot.json | 0 {migrations => data/migrations}/meta/_journal.json | 0 docker-compose.app.yml | 5 ++++- drizzle.config.ts | 2 +- infra/digitalocean/cloud-init.yml | 7 ++++++- src/db/db.ts | 2 +- src/db/legacy-migration/migrate.ts | 2 +- 39 files changed, 23 insertions(+), 10 deletions(-) rename {migrations => data/migrations}/0000_sour_roulette.sql (100%) rename {migrations => data/migrations}/0001_optimal_piledriver.sql (100%) rename {migrations => data/migrations}/0002_wild_clea.sql (100%) rename {migrations => data/migrations}/0003_gigantic_george_stacy.sql (100%) rename {migrations => data/migrations}/0004_fine_roughhouse.sql (100%) rename {migrations => data/migrations}/0005_groovy_northstar.sql (100%) rename {migrations => data/migrations}/0006_free_echo.sql (100%) rename {migrations => data/migrations}/0007_black_turbo.sql (100%) rename {migrations => data/migrations}/0008_lumpy_serpent_society.sql (100%) rename {migrations => data/migrations}/0009_spicy_songbird.sql (100%) rename {migrations => data/migrations}/0010_stiff_masque.sql (100%) rename {migrations => data/migrations}/0011_complete_ted_forrester.sql (100%) rename {migrations => data/migrations}/0012_powerful_songbird.sql (100%) rename {migrations => data/migrations}/0013_mature_steve_rogers.sql (100%) rename {migrations => data/migrations}/0014_narrow_wind_dancer.sql (100%) rename {migrations => data/migrations}/meta/0000_snapshot.json (100%) rename {migrations => data/migrations}/meta/0001_snapshot.json (100%) rename {migrations => data/migrations}/meta/0002_snapshot.json (100%) rename {migrations => data/migrations}/meta/0003_snapshot.json (100%) rename {migrations => data/migrations}/meta/0004_snapshot.json (100%) rename {migrations => data/migrations}/meta/0005_snapshot.json (100%) rename {migrations => data/migrations}/meta/0006_snapshot.json (100%) rename {migrations => data/migrations}/meta/0007_snapshot.json (100%) rename {migrations => data/migrations}/meta/0008_snapshot.json (100%) rename {migrations => data/migrations}/meta/0009_snapshot.json (100%) rename {migrations => data/migrations}/meta/0010_snapshot.json (100%) rename {migrations => data/migrations}/meta/0011_snapshot.json (100%) rename {migrations => data/migrations}/meta/0012_snapshot.json (100%) rename {migrations => data/migrations}/meta/0013_snapshot.json (100%) rename {migrations => data/migrations}/meta/0014_snapshot.json (100%) rename {migrations => data/migrations}/meta/_journal.json (100%) diff --git a/.github/workflows/provision-and-deploy.yml b/.github/workflows/provision-and-deploy.yml index 5a6c9162..c22445b5 100644 --- a/.github/workflows/provision-and-deploy.yml +++ b/.github/workflows/provision-and-deploy.yml @@ -306,7 +306,12 @@ jobs: - name: Sync deploy artifacts to VPS run: | - ssh -i ~/.ssh/bot_deploy_key deploy@"${BOT_HOST}" "mkdir -p '${APP_PATH}'" + # Pre-create the bind-mount targets so Docker mounts a file/dir, not a + # new directory in place of the sqlite file. The compose file mounts + # data/main.sqlite and data/backups individually (not the whole data + # dir) so the image's data/migrations is not shadowed. + ssh -i ~/.ssh/bot_deploy_key deploy@"${BOT_HOST}" \ + "mkdir -p '${APP_PATH}/data/backups' && touch '${APP_PATH}/data/main.sqlite'" rsync -az \ -e "ssh -i ~/.ssh/bot_deploy_key" \ docker-compose.app.yml deploy@"${BOT_HOST}":"${APP_PATH}/" diff --git a/.gitignore b/.gitignore index 008e22e8..24be7ce0 100644 --- a/.gitignore +++ b/.gitignore @@ -179,7 +179,7 @@ dist data/manual/main.sqlite data/backups/ -data/legacy-export/ +data/migrations/legacy-export/ # VS Code Workspace .vscode/ diff --git a/README-dev.md b/README-dev.md index 47aac639..637a0c72 100644 --- a/README-dev.md +++ b/README-dev.md @@ -147,7 +147,7 @@ If you need to add or modify database tables or columns: 1. Update `src/db/schema.ts`. Use plain numeric defaults (`.default(0)`) — `.default(0n)` trips a `drizzle-kit` BigInt-serialization bug. `$type()` still types the column as `bigint`. 2. For new tables or query patterns, update `src/db/store.ts` and `src/db/queries.ts`. -3. Run `npx drizzle-kit generate`. Commit the new `migrations/*.sql` and `migrations/meta/*` files — the runtime migrator applies them on next startup. Migrations live at the repo root (not under `data/`) so they ship inside the Docker image; the deploy bind-mounts only `data/`, which would otherwise shadow them. +3. Run `npx drizzle-kit generate`. Commit the new `data/migrations/*.sql` and `data/migrations/meta/*` files — the runtime migrator applies them on next startup. ## Misc @@ -161,7 +161,7 @@ This project is designed to run without compiling thanks to `@swc-node/register/ ## Migrating from the legacy project (pre June 2024) -Open a terminal and navigate to the following directory in this project: `data/legacy-export`. +Open a terminal and navigate to the following directory in this project: `data/migrations/legacy-export`. Export the old database tables to csv files. The following command will perform the export for Postgres: @@ -178,6 +178,6 @@ Then in the `.env` file, set `ENABLE_LEGACY_MIGRATION` to true: ENABLE_LEGACY_MIGRATION=true ``` -When `ENABLE_LEGACY_MIGRATION` is true, the bot checks the `data/legacy-export` directory on startup. If it finds the csv files, it will prompt you via console input to confirm the import. If confirmed, it creates a dated backup of `data/main.sqlite`, then merges the legacy data into the database. +When `ENABLE_LEGACY_MIGRATION` is true, the bot checks the `data/migrations/legacy-export` directory on startup. If it finds the csv files, it will prompt you via console input to confirm the import. If confirmed, it creates a dated backup of `data/main.sqlite`, then merges the legacy data into the database. Once the data is imported, set `ENABLE_LEGACY_MIGRATION` back to false. diff --git a/migrations/0000_sour_roulette.sql b/data/migrations/0000_sour_roulette.sql similarity index 100% rename from migrations/0000_sour_roulette.sql rename to data/migrations/0000_sour_roulette.sql diff --git a/migrations/0001_optimal_piledriver.sql b/data/migrations/0001_optimal_piledriver.sql similarity index 100% rename from migrations/0001_optimal_piledriver.sql rename to data/migrations/0001_optimal_piledriver.sql diff --git a/migrations/0002_wild_clea.sql b/data/migrations/0002_wild_clea.sql similarity index 100% rename from migrations/0002_wild_clea.sql rename to data/migrations/0002_wild_clea.sql diff --git a/migrations/0003_gigantic_george_stacy.sql b/data/migrations/0003_gigantic_george_stacy.sql similarity index 100% rename from migrations/0003_gigantic_george_stacy.sql rename to data/migrations/0003_gigantic_george_stacy.sql diff --git a/migrations/0004_fine_roughhouse.sql b/data/migrations/0004_fine_roughhouse.sql similarity index 100% rename from migrations/0004_fine_roughhouse.sql rename to data/migrations/0004_fine_roughhouse.sql diff --git a/migrations/0005_groovy_northstar.sql b/data/migrations/0005_groovy_northstar.sql similarity index 100% rename from migrations/0005_groovy_northstar.sql rename to data/migrations/0005_groovy_northstar.sql diff --git a/migrations/0006_free_echo.sql b/data/migrations/0006_free_echo.sql similarity index 100% rename from migrations/0006_free_echo.sql rename to data/migrations/0006_free_echo.sql diff --git a/migrations/0007_black_turbo.sql b/data/migrations/0007_black_turbo.sql similarity index 100% rename from migrations/0007_black_turbo.sql rename to data/migrations/0007_black_turbo.sql diff --git a/migrations/0008_lumpy_serpent_society.sql b/data/migrations/0008_lumpy_serpent_society.sql similarity index 100% rename from migrations/0008_lumpy_serpent_society.sql rename to data/migrations/0008_lumpy_serpent_society.sql diff --git a/migrations/0009_spicy_songbird.sql b/data/migrations/0009_spicy_songbird.sql similarity index 100% rename from migrations/0009_spicy_songbird.sql rename to data/migrations/0009_spicy_songbird.sql diff --git a/migrations/0010_stiff_masque.sql b/data/migrations/0010_stiff_masque.sql similarity index 100% rename from migrations/0010_stiff_masque.sql rename to data/migrations/0010_stiff_masque.sql diff --git a/migrations/0011_complete_ted_forrester.sql b/data/migrations/0011_complete_ted_forrester.sql similarity index 100% rename from migrations/0011_complete_ted_forrester.sql rename to data/migrations/0011_complete_ted_forrester.sql diff --git a/migrations/0012_powerful_songbird.sql b/data/migrations/0012_powerful_songbird.sql similarity index 100% rename from migrations/0012_powerful_songbird.sql rename to data/migrations/0012_powerful_songbird.sql diff --git a/migrations/0013_mature_steve_rogers.sql b/data/migrations/0013_mature_steve_rogers.sql similarity index 100% rename from migrations/0013_mature_steve_rogers.sql rename to data/migrations/0013_mature_steve_rogers.sql diff --git a/migrations/0014_narrow_wind_dancer.sql b/data/migrations/0014_narrow_wind_dancer.sql similarity index 100% rename from migrations/0014_narrow_wind_dancer.sql rename to data/migrations/0014_narrow_wind_dancer.sql diff --git a/migrations/meta/0000_snapshot.json b/data/migrations/meta/0000_snapshot.json similarity index 100% rename from migrations/meta/0000_snapshot.json rename to data/migrations/meta/0000_snapshot.json diff --git a/migrations/meta/0001_snapshot.json b/data/migrations/meta/0001_snapshot.json similarity index 100% rename from migrations/meta/0001_snapshot.json rename to data/migrations/meta/0001_snapshot.json diff --git a/migrations/meta/0002_snapshot.json b/data/migrations/meta/0002_snapshot.json similarity index 100% rename from migrations/meta/0002_snapshot.json rename to data/migrations/meta/0002_snapshot.json diff --git a/migrations/meta/0003_snapshot.json b/data/migrations/meta/0003_snapshot.json similarity index 100% rename from migrations/meta/0003_snapshot.json rename to data/migrations/meta/0003_snapshot.json diff --git a/migrations/meta/0004_snapshot.json b/data/migrations/meta/0004_snapshot.json similarity index 100% rename from migrations/meta/0004_snapshot.json rename to data/migrations/meta/0004_snapshot.json diff --git a/migrations/meta/0005_snapshot.json b/data/migrations/meta/0005_snapshot.json similarity index 100% rename from migrations/meta/0005_snapshot.json rename to data/migrations/meta/0005_snapshot.json diff --git a/migrations/meta/0006_snapshot.json b/data/migrations/meta/0006_snapshot.json similarity index 100% rename from migrations/meta/0006_snapshot.json rename to data/migrations/meta/0006_snapshot.json diff --git a/migrations/meta/0007_snapshot.json b/data/migrations/meta/0007_snapshot.json similarity index 100% rename from migrations/meta/0007_snapshot.json rename to data/migrations/meta/0007_snapshot.json diff --git a/migrations/meta/0008_snapshot.json b/data/migrations/meta/0008_snapshot.json similarity index 100% rename from migrations/meta/0008_snapshot.json rename to data/migrations/meta/0008_snapshot.json diff --git a/migrations/meta/0009_snapshot.json b/data/migrations/meta/0009_snapshot.json similarity index 100% rename from migrations/meta/0009_snapshot.json rename to data/migrations/meta/0009_snapshot.json diff --git a/migrations/meta/0010_snapshot.json b/data/migrations/meta/0010_snapshot.json similarity index 100% rename from migrations/meta/0010_snapshot.json rename to data/migrations/meta/0010_snapshot.json diff --git a/migrations/meta/0011_snapshot.json b/data/migrations/meta/0011_snapshot.json similarity index 100% rename from migrations/meta/0011_snapshot.json rename to data/migrations/meta/0011_snapshot.json diff --git a/migrations/meta/0012_snapshot.json b/data/migrations/meta/0012_snapshot.json similarity index 100% rename from migrations/meta/0012_snapshot.json rename to data/migrations/meta/0012_snapshot.json diff --git a/migrations/meta/0013_snapshot.json b/data/migrations/meta/0013_snapshot.json similarity index 100% rename from migrations/meta/0013_snapshot.json rename to data/migrations/meta/0013_snapshot.json diff --git a/migrations/meta/0014_snapshot.json b/data/migrations/meta/0014_snapshot.json similarity index 100% rename from migrations/meta/0014_snapshot.json rename to data/migrations/meta/0014_snapshot.json diff --git a/migrations/meta/_journal.json b/data/migrations/meta/_journal.json similarity index 100% rename from migrations/meta/_journal.json rename to data/migrations/meta/_journal.json diff --git a/docker-compose.app.yml b/docker-compose.app.yml index 8e8eac70..6d51fd40 100644 --- a/docker-compose.app.yml +++ b/docker-compose.app.yml @@ -5,7 +5,10 @@ services: restart: always env_file: .env volumes: - - ./data:/app/data # Bind mount for database + # Mount only persistent state, not the whole data dir, so the + # data/migrations baked into the image is not shadowed by the host mount. + - ./data/main.sqlite:/app/data/main.sqlite # Bind mount for database + - ./data/backups:/app/data/backups # Bind mount for DB backups - ./.env:/app/.env # Bind mount for .env file stdin_open: true # Allow stdin to be open tty: true # Allocate a pseudo-TTY diff --git a/drizzle.config.ts b/drizzle.config.ts index 030b6fc5..ae112057 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -3,7 +3,7 @@ import { defineConfig } from "drizzle-kit"; export default defineConfig({ dialect: "sqlite", schema: "./src/db/schema.ts", - out: "./migrations", + out: "./data/migrations", dbCredentials: { url: "./data/main.sqlite", }, diff --git a/infra/digitalocean/cloud-init.yml b/infra/digitalocean/cloud-init.yml index 7be4fff2..fdfc992b 100644 --- a/infra/digitalocean/cloud-init.yml +++ b/infra/digitalocean/cloud-init.yml @@ -39,7 +39,12 @@ write_files: APP_DIR="$1" - mkdir -p "$APP_DIR/data" + # The compose file mounts data/main.sqlite and data/backups individually + # (not the whole data dir) so the image's data/migrations is not shadowed. + # Pre-create both so Docker mounts a file/dir rather than creating a new + # directory in place of the sqlite file. + mkdir -p "$APP_DIR/data/backups" + touch "$APP_DIR/data/main.sqlite" chown -R deploy:deploy "$APP_DIR" cd "$APP_DIR" diff --git a/src/db/db.ts b/src/db/db.ts index 818a231c..c94bcc50 100644 --- a/src/db/db.ts +++ b/src/db/db.ts @@ -6,7 +6,7 @@ import * as schema from "./schema.ts"; export const DB_FILEPATH = "data/main.sqlite"; export const DB_BACKUP_DIRECTORY = "data/backups"; -export const MIGRATIONS_FOLDER = "migrations"; +export const MIGRATIONS_FOLDER = "data/migrations"; function connect() { const conn = drizzle(Database(DB_FILEPATH).defaultSafeIntegers(), { schema }); diff --git a/src/db/legacy-migration/migrate.ts b/src/db/legacy-migration/migrate.ts index 76e35936..b468f3c7 100644 --- a/src/db/legacy-migration/migrate.ts +++ b/src/db/legacy-migration/migrate.ts @@ -23,7 +23,7 @@ import type { Schedules, } from "./migrate.types.ts"; -export const LEGACY_EXPORT_DIR = "data/legacy-export"; +export const LEGACY_EXPORT_DIR = "data/migrations/legacy-export"; const legacyAdminPermission: AdminPermission[] = []; const legacyBlackWhiteList: BlackWhiteList[] = [];