From fa6142d60052d92495e2a00a7f8025ba9c08daf8 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Wed, 18 Mar 2026 21:19:38 +0200 Subject: [PATCH 1/3] FIX GOVNO CI/CD --- .github/workflows/deploy.yml | 45 +------- .gitignore | 3 + README.md | 21 ++-- deploy/docker/Dockerfile | 3 +- deploy/docker/docker-compose.prod.yml | 14 ++- deploy/docker/docker-compose.staging.yml | 14 ++- deploy/env/.env.production.example | 6 +- deploy/env/.env.staging.example | 6 +- deploy/scripts/backup-compose-data.sh | 49 +++++++- deploy/scripts/deploy-stack.sh | 134 ++++++++++++++++++++++ deploy/scripts/prepare-persistent-data.sh | 106 +++++++++++++++++ docs/deployment/cicd.md | 72 ++++++++++++ scripts/setup_vps.sh | 46 +++----- 13 files changed, 420 insertions(+), 99 deletions(-) create mode 100644 deploy/scripts/deploy-stack.sh create mode 100644 deploy/scripts/prepare-persistent-data.sh create mode 100644 docs/deployment/cicd.md diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 530d8a2..b0274c7 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -18,9 +18,6 @@ jobs: environment: staging steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Deploy over SSH uses: appleboy/ssh-action@v1.2.0 with: @@ -31,26 +28,11 @@ jobs: command_timeout: 30m script: | set -euxo pipefail - compose_cmd='docker-compose -p borofone-staging' cd /opt/borofone-chat-staging - echo "[deploy] $(date -Is) start staging commit=${{ github.sha }}" git fetch --prune origin - git checkout dev + git checkout dev || git checkout -b dev --track origin/dev git reset --hard origin/dev - systemctl stop borofone-staging || true - rm -f deploy/docker/.env - $compose_cmd -f deploy/docker/docker-compose.staging.yml up -d --no-recreate postgres redis - $compose_cmd -f deploy/docker/docker-compose.staging.yml exec -T postgres sh -lc 'until pg_isready -U "${POSTGRES_USER:-app}" -d postgres -h 127.0.0.1; do sleep 1; done' - bash deploy/scripts/backup-compose-data.sh staging borofone-staging deploy/docker/docker-compose.staging.yml borofone_staging borofone-staging_uploads_data - $compose_cmd -f deploy/docker/docker-compose.staging.yml exec -T postgres sh -lc 'psql -U "${POSTGRES_USER:-app}" -d postgres -tAc "SELECT 1 FROM pg_database WHERE datname='\''${POSTGRES_DB:-borofone_staging}'\''" | grep -q 1 || psql -U "${POSTGRES_USER:-app}" -d postgres -c "CREATE DATABASE ${POSTGRES_DB:-borofone_staging}"' - $compose_cmd -f deploy/docker/docker-compose.staging.yml run --rm api alembic upgrade head - docker ps -q --filter "publish=8001" | xargs -r docker rm -f - $compose_cmd -f deploy/docker/docker-compose.staging.yml stop api || true - $compose_cmd -f deploy/docker/docker-compose.staging.yml rm -fsv api || true - docker ps -aq --filter "label=com.docker.compose.project=borofone-staging" --filter "label=com.docker.compose.service=api" | xargs -r docker rm -f - $compose_cmd -f deploy/docker/docker-compose.staging.yml up -d --no-deps --force-recreate --build api - $compose_cmd -f deploy/docker/docker-compose.staging.yml ps - echo "[deploy] $(date -Is) done staging commit=${{ github.sha }}" + SKIP_GIT_SYNC=1 bash deploy/scripts/deploy-stack.sh staging deploy-production: name: Deploy Production @@ -59,9 +41,6 @@ jobs: environment: production steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Deploy over SSH uses: appleboy/ssh-action@v1.2.0 with: @@ -72,24 +51,8 @@ jobs: command_timeout: 30m script: | set -euxo pipefail - compose_cmd='docker-compose -p borofone-prod' cd /opt/borofone-chat-prod - echo "[deploy] $(date -Is) start production commit=${{ github.sha }}" git fetch --prune origin - git checkout main + git checkout main || git checkout -b main --track origin/main git reset --hard origin/main - systemctl stop borofone-prod || true - rm -f deploy/docker/.env - $compose_cmd -f deploy/docker/docker-compose.prod.yml up -d --no-recreate postgres redis - $compose_cmd -f deploy/docker/docker-compose.prod.yml exec -T postgres sh -lc 'until pg_isready -U "${POSTGRES_USER:-app}" -d postgres -h 127.0.0.1; do sleep 1; done' - bash deploy/scripts/backup-compose-data.sh production borofone-prod deploy/docker/docker-compose.prod.yml borofone_prod borofone-prod_uploads_data - $compose_cmd -f deploy/docker/docker-compose.prod.yml exec -T postgres sh -lc 'psql -U "${POSTGRES_USER:-app}" -d postgres -tAc "SELECT 1 FROM pg_database WHERE datname='\''${POSTGRES_DB:-borofone_prod}'\''" | grep -q 1 || psql -U "${POSTGRES_USER:-app}" -d postgres -c "CREATE DATABASE ${POSTGRES_DB:-borofone_prod}"' - $compose_cmd -f deploy/docker/docker-compose.prod.yml run --rm api alembic upgrade head - docker ps -q --filter "publish=8000" | xargs -r docker rm -f - $compose_cmd -f deploy/docker/docker-compose.prod.yml stop api || true - $compose_cmd -f deploy/docker/docker-compose.prod.yml rm -fsv api || true - docker ps -aq --filter "label=com.docker.compose.project=borofone-prod" --filter "label=com.docker.compose.service=api" | xargs -r docker rm -f - $compose_cmd -f deploy/docker/docker-compose.prod.yml up -d --no-deps --force-recreate --build api - $compose_cmd -f deploy/docker/docker-compose.prod.yml ps - echo "[deploy] $(date -Is) done production commit=${{ github.sha }}" - + SKIP_GIT_SYNC=1 bash deploy/scripts/deploy-stack.sh production diff --git a/.gitignore b/.gitignore index 720d9cd..e2cbff6 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,9 @@ /not_tested /uploads/attachments /docs +!/docs/ +!/docs/deployment/ +!/docs/deployment/cicd.md /data/leaderboard/*.json !/data/leaderboard/.gitkeep diff --git a/README.md b/README.md index 77b191f..0c223e1 100644 --- a/README.md +++ b/README.md @@ -112,8 +112,8 @@ docker compose -f deploy/docker/docker-compose.infra.yml ps docker compose -f deploy/docker/docker-compose.infra.yml up -d ``` -Leaderboard for `tears-of-bfu` is persisted outside the container in `data/leaderboard/leaderboard.json`. -For staging/production compose stacks this directory is bind-mounted into `/code/pages/web_backend/data`. +Leaderboard for `tears-of-bfu` is persisted outside the container in `data/leaderboard/leaderboard.json` for local development. +For staging/production compose stacks, uploads and leaderboard data are stored under `HOST_DATA_ROOT` on the VPS and bind-mounted into the API container. **DOWN infra:** @@ -298,10 +298,10 @@ maybe next time ## for vps -git pull origin main -docker-compose -f deploy/docker/docker-compose.prod.yml build --no-cache api -docker-compose -f deploy/docker/docker-compose.prod.yml down -docker-compose -f deploy/docker/docker-compose.prod.yml up -d +```bash +cd /opt/borofone-chat-prod +bash deploy/scripts/deploy-stack.sh production +``` ## Sources @@ -320,7 +320,9 @@ dev -> staging - `.github/workflows/deploy.yml` - автодеплой по `push` в `dev` и `main` - `deploy/env/.env.production.example` и `deploy/env/.env.staging.example` - шаблоны окружений -- `deploy/systemd/` - systemd unit-файлы +- `deploy/scripts/deploy-stack.sh` - единая точка входа для staging/production deploy +- `deploy/scripts/prepare-persistent-data.sh` - one-time migration of uploads and leaderboard into host storage +- `deploy/scripts/backup-compose-data.sh` - backup of database, uploads, leaderboard and `.env` - `deploy/nginx/borofone.conf` - reverse proxy для production и staging - `scripts/setup_vps.sh` - первичная подготовка VPS - `docs/deployment/cicd.md` - пошаговые инструкции по setup и security @@ -336,7 +338,8 @@ merge dev main -> GitHub Actions -> production deploy 1. Добавь GitHub Secrets для `PROD_*` и `STAGING_*`. 2. Используй `deploy/env/.env.production.example` и `deploy/env/.env.staging.example`, затем скопируй их в `/opt/borofone-chat-prod/.env` и `/opt/borofone-chat-staging/.env`. -3. Установи `deploy/systemd/*.service` и `deploy/nginx/borofone.conf` на VPS. -4. Включи branch protection для `main`, запрети прямой push и оставь deploy только через PR. +3. Заполни `HOST_DATA_ROOT` и `BACKUP_ROOT` абсолютными путями на VPS. +4. Установи `deploy/nginx/borofone.conf` на VPS. +5. Включи branch protection для `main`, запрети прямой push и оставь deploy только через PR. Подробная инструкция: `docs/deployment/cicd.md`. diff --git a/deploy/docker/Dockerfile b/deploy/docker/Dockerfile index 4915810..d0ccc29 100644 --- a/deploy/docker/Dockerfile +++ b/deploy/docker/Dockerfile @@ -7,12 +7,11 @@ RUN pip install --no-cache-dir -r requirements.txt COPY app ./app COPY pages ./pages -COPY uploads ./uploads COPY alembic ./alembic COPY alembic.ini . COPY fix_sequences.sql . -RUN mkdir -p /code/pages/web_backend/data /code/uploads +RUN mkdir -p /code/pages/web_backend/data /code/uploads /code/uploads/avatars /code/uploads/attachments ENV APP_PORT=8000 diff --git a/deploy/docker/docker-compose.prod.yml b/deploy/docker/docker-compose.prod.yml index 5170637..eb8cf78 100644 --- a/deploy/docker/docker-compose.prod.yml +++ b/deploy/docker/docker-compose.prod.yml @@ -36,8 +36,16 @@ services: ports: - "127.0.0.1:8000:8000" volumes: - - uploads_data:/code/uploads - - ../../data/leaderboard:/code/pages/web_backend/data + - ${HOST_DATA_ROOT:?HOST_DATA_ROOT is required}/uploads:/code/uploads + - ${HOST_DATA_ROOT:?HOST_DATA_ROOT is required}/leaderboard:/code/pages/web_backend/data + healthcheck: + test: + - CMD-SHELL + - 'python -c "import os, urllib.request; urllib.request.urlopen(''http://127.0.0.1:{}/app-config.js''.format(os.environ.get(''APP_PORT'', ''8000'')), timeout=5)"' + interval: 10s + timeout: 5s + retries: 12 + start_period: 10s depends_on: postgres: condition: service_healthy @@ -51,8 +59,6 @@ volumes: name: borofone-prod_postgres_data redis_data: name: borofone-prod_redis_data - uploads_data: - name: borofone-prod_uploads_data networks: prod_network: diff --git a/deploy/docker/docker-compose.staging.yml b/deploy/docker/docker-compose.staging.yml index 5b76d43..608f618 100644 --- a/deploy/docker/docker-compose.staging.yml +++ b/deploy/docker/docker-compose.staging.yml @@ -36,8 +36,16 @@ services: ports: - "127.0.0.1:8001:8001" volumes: - - uploads_data:/code/uploads - - ../../data/leaderboard:/code/pages/web_backend/data + - ${HOST_DATA_ROOT:?HOST_DATA_ROOT is required}/uploads:/code/uploads + - ${HOST_DATA_ROOT:?HOST_DATA_ROOT is required}/leaderboard:/code/pages/web_backend/data + healthcheck: + test: + - CMD-SHELL + - 'python -c "import os, urllib.request; urllib.request.urlopen(''http://127.0.0.1:{}/app-config.js''.format(os.environ.get(''APP_PORT'', ''8000'')), timeout=5)"' + interval: 10s + timeout: 5s + retries: 12 + start_period: 10s depends_on: postgres: condition: service_healthy @@ -51,8 +59,6 @@ volumes: name: borofone-staging_postgres_data redis_data: name: borofone-staging_redis_data - uploads_data: - name: borofone-staging_uploads_data networks: staging_network: diff --git a/deploy/env/.env.production.example b/deploy/env/.env.production.example index 2c19154..a50e88a 100644 --- a/deploy/env/.env.production.example +++ b/deploy/env/.env.production.example @@ -21,13 +21,17 @@ COOKIE_SAMESITE=lax ACCESS_TOKEN_EXPIRE_DAYS=30 REFRESH_TOKEN_EXPIRE_DAYS=30 +HOST_DATA_ROOT=/var/lib/borofone/production +BACKUP_ROOT=/var/backups/borofone + +# Always backed by host bind mount from ${HOST_DATA_ROOT}/uploads. UPLOADS_DIR=/code/uploads PAGES_DIR=pages FAVICON_PATH=favicon.ico MAIN_PAGE_PATH=main.html LOGIN_PAGE_PATH=login.html REGISTER_PAGE_PATH=register.html -# Leaderboard JSON is persisted via docker bind mount: ../../data/leaderboard -> /code/pages/web_backend/data +# Leaderboard files are always backed by host bind mount from ${HOST_DATA_ROOT}/leaderboard. MAX_AVATAR_BYTES=3145728 MAX_UPLOAD_FILE_SIZE=10485760 diff --git a/deploy/env/.env.staging.example b/deploy/env/.env.staging.example index c736218..69c6b4e 100644 --- a/deploy/env/.env.staging.example +++ b/deploy/env/.env.staging.example @@ -21,13 +21,17 @@ COOKIE_SAMESITE=lax ACCESS_TOKEN_EXPIRE_DAYS=30 REFRESH_TOKEN_EXPIRE_DAYS=30 +HOST_DATA_ROOT=/var/lib/borofone/staging +BACKUP_ROOT=/var/backups/borofone + +# Always backed by host bind mount from ${HOST_DATA_ROOT}/uploads. UPLOADS_DIR=/code/uploads PAGES_DIR=pages FAVICON_PATH=favicon.ico MAIN_PAGE_PATH=main.html LOGIN_PAGE_PATH=login.html REGISTER_PAGE_PATH=register.html -# Leaderboard JSON is persisted via docker bind mount: ../../data/leaderboard -> /code/pages/web_backend/data +# Leaderboard files are always backed by host bind mount from ${HOST_DATA_ROOT}/leaderboard. MAX_AVATAR_BYTES=3145728 MAX_UPLOAD_FILE_SIZE=10485760 diff --git a/deploy/scripts/backup-compose-data.sh b/deploy/scripts/backup-compose-data.sh index 49bdbf8..d531c8b 100644 --- a/deploy/scripts/backup-compose-data.sh +++ b/deploy/scripts/backup-compose-data.sh @@ -12,12 +12,45 @@ compose_file="$3" db_name="$4" uploads_volume="$5" -backup_root="backups/${env_name}" +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "${script_dir}/../.." && pwd)" + +if [ ! -f "${repo_root}/.env" ]; then + echo "missing ${repo_root}/.env" >&2 + exit 1 +fi + +set -a +. "${repo_root}/.env" +set +a + +: "${BACKUP_ROOT:?BACKUP_ROOT must be set in .env}" +: "${HOST_DATA_ROOT:?HOST_DATA_ROOT must be set in .env}" + +backup_root="${BACKUP_ROOT%/}/${env_name}" timestamp="$(date +%Y%m%d-%H%M%S)" target_dir="${backup_root}/${timestamp}" mkdir -p "${target_dir}" -compose_cmd=(docker-compose -p "${compose_project}" -f "${compose_file}") +compose_cmd=(docker compose -p "${compose_project}" -f "${compose_file}") +host_uploads_dir="${HOST_DATA_ROOT%/}/uploads" +host_leaderboard_dir="${HOST_DATA_ROOT%/}/leaderboard" +legacy_leaderboard_dir="${repo_root}/data/leaderboard" + +tar_directory() { + local source_dir="$1" + local target_archive="$2" + + if [ ! -d "${source_dir}" ]; then + return 1 + fi + + if [ -z "$(find "${source_dir}" -mindepth 1 -print -quit 2>/dev/null)" ]; then + return 1 + fi + + tar -C "${source_dir}" -czf "${target_archive}" . +} "${compose_cmd[@]}" up -d --no-recreate postgres redis "${compose_cmd[@]}" exec -T postgres sh -lc 'until pg_isready -U "${POSTGRES_USER:-app}" -d postgres -h 127.0.0.1; do sleep 1; done' @@ -26,13 +59,21 @@ if "${compose_cmd[@]}" exec -T postgres sh -lc "psql -U \"\${POSTGRES_USER:-app} "${compose_cmd[@]}" exec -T postgres sh -lc "pg_dump -U \"\${POSTGRES_USER:-app}\" -d \"${db_name}\" --clean --if-exists --no-owner --no-privileges" | gzip -9 > "${target_dir}/${db_name}.sql.gz" fi -if docker volume inspect "${uploads_volume}" >/dev/null 2>&1; then +if tar_directory "${host_uploads_dir}" "${target_dir}/uploads.tar.gz"; then + : +elif docker volume inspect "${uploads_volume}" >/dev/null 2>&1; then docker run --rm \ -v "${uploads_volume}:/source:ro" \ - -v "${PWD}/${target_dir}:/backup" \ + -v "${target_dir}:/backup" \ alpine:3.20 \ sh -lc 'cd /source && tar -czf /backup/uploads.tar.gz .' fi +if ! tar_directory "${host_leaderboard_dir}" "${target_dir}/leaderboard.tar.gz"; then + tar_directory "${legacy_leaderboard_dir}" "${target_dir}/leaderboard.tar.gz" || true +fi + +cp "${repo_root}/.env" "${target_dir}/.env" + find "${backup_root}" -mindepth 1 -maxdepth 1 -type d | sort | head -n -10 | xargs -r rm -rf echo "[backup] ${env_name} saved to ${target_dir}" diff --git a/deploy/scripts/deploy-stack.sh b/deploy/scripts/deploy-stack.sh new file mode 100644 index 0000000..8738d34 --- /dev/null +++ b/deploy/scripts/deploy-stack.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ "$#" -ne 1 ]; then + echo "usage: $0 " >&2 + exit 1 +fi + +env_name="$1" + +case "${env_name}" in + production) + branch="main" + compose_project="borofone-prod" + compose_file="deploy/docker/docker-compose.prod.yml" + db_name="borofone_prod" + uploads_volume="borofone-prod_uploads_data" + ;; + staging) + branch="dev" + compose_project="borofone-staging" + compose_file="deploy/docker/docker-compose.staging.yml" + db_name="borofone_staging" + uploads_volume="borofone-staging_uploads_data" + ;; + *) + echo "unsupported environment: ${env_name}" >&2 + exit 1 + ;; +esac + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "${script_dir}/../.." && pwd)" +compose_cmd=(docker compose -p "${compose_project}" -f "${compose_file}") + +wait_for_service() { + local service_name="$1" + local timeout_seconds="$2" + local start_time + local container_id="" + local status + + start_time="$(date +%s)" + + while true; do + container_id="$("${compose_cmd[@]}" ps -q "${service_name}" | head -n 1)" + if [ -n "${container_id}" ]; then + status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "${container_id}" 2>/dev/null || true)" + case "${status}" in + healthy|running) + echo "[deploy] ${service_name} is ${status}" + return 0 + ;; + unhealthy|exited|dead) + echo "[deploy] ${service_name} became ${status}" >&2 + docker logs "${container_id}" --tail 50 || true + return 1 + ;; + esac + fi + + if [ "$(date +%s)" -ge "$((start_time + timeout_seconds))" ]; then + echo "[deploy] timed out waiting for ${service_name}" >&2 + if [ -n "${container_id}" ]; then + docker logs "${container_id}" --tail 50 || true + fi + return 1 + fi + + sleep 2 + done +} + +if ! command -v docker >/dev/null 2>&1; then + echo "docker is required" >&2 + exit 1 +fi + +if ! docker compose version >/dev/null 2>&1; then + echo "docker compose plugin is required" >&2 + exit 1 +fi + +cd "${repo_root}" + +if [ "${SKIP_GIT_SYNC:-0}" != "1" ]; then + git fetch --prune origin + git checkout "${branch}" || git checkout -b "${branch}" --track "origin/${branch}" + git reset --hard "origin/${branch}" +fi + +if [ ! -f .env ]; then + echo "missing ${repo_root}/.env" >&2 + exit 1 +fi + +set -a +. "${repo_root}/.env" +set +a + +: "${HOST_DATA_ROOT:?HOST_DATA_ROOT must be set in .env}" +: "${BACKUP_ROOT:?BACKUP_ROOT must be set in .env}" + +case "${HOST_DATA_ROOT}" in + /*) ;; + *) + echo "HOST_DATA_ROOT must be an absolute path: ${HOST_DATA_ROOT}" >&2 + exit 1 + ;; +esac + +case "${BACKUP_ROOT}" in + /*) ;; + *) + echo "BACKUP_ROOT must be an absolute path: ${BACKUP_ROOT}" >&2 + exit 1 + ;; +esac + +mkdir -p "${HOST_DATA_ROOT}" "${BACKUP_ROOT}" + +echo "[deploy] $(date -Is) start ${env_name}" +bash deploy/scripts/backup-compose-data.sh "${env_name}" "${compose_project}" "${compose_file}" "${db_name}" "${uploads_volume}" +bash deploy/scripts/prepare-persistent-data.sh "${env_name}" +"${compose_cmd[@]}" config >/dev/null +"${compose_cmd[@]}" up -d --no-recreate postgres redis +wait_for_service postgres 120 +wait_for_service redis 60 +"${compose_cmd[@]}" build api +"${compose_cmd[@]}" run --rm api alembic upgrade head +"${compose_cmd[@]}" up -d --no-deps --force-recreate api +wait_for_service api 120 +"${compose_cmd[@]}" ps +echo "[deploy] $(date -Is) done ${env_name}" diff --git a/deploy/scripts/prepare-persistent-data.sh b/deploy/scripts/prepare-persistent-data.sh new file mode 100644 index 0000000..c6433d4 --- /dev/null +++ b/deploy/scripts/prepare-persistent-data.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ "$#" -ne 1 ]; then + echo "usage: $0 " >&2 + exit 1 +fi + +env_name="$1" + +case "${env_name}" in + production) + uploads_volume="borofone-prod_uploads_data" + legacy_unit="borofone-prod" + ;; + staging) + uploads_volume="borofone-staging_uploads_data" + legacy_unit="borofone-staging" + ;; + *) + echo "unsupported environment: ${env_name}" >&2 + exit 1 + ;; +esac + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "${script_dir}/../.." && pwd)" + +if [ ! -f "${repo_root}/.env" ]; then + echo "missing ${repo_root}/.env" >&2 + exit 1 +fi + +set -a +. "${repo_root}/.env" +set +a + +: "${HOST_DATA_ROOT:?HOST_DATA_ROOT must be set in .env}" + +case "${HOST_DATA_ROOT}" in + /*) ;; + *) + echo "HOST_DATA_ROOT must be an absolute path: ${HOST_DATA_ROOT}" >&2 + exit 1 + ;; +esac + +host_root="${HOST_DATA_ROOT%/}" +uploads_dir="${host_root}/uploads" +leaderboard_dir="${host_root}/leaderboard" +marker_file="${host_root}/.persistent-data-migrated" +legacy_leaderboard_dir="${repo_root}/data/leaderboard" + +is_dir_empty() { + local dir_path="$1" + [ ! -d "${dir_path}" ] || [ -z "$(find "${dir_path}" -mindepth 1 -print -quit 2>/dev/null)" ] +} + +copy_volume_to_dir() { + local volume_name="$1" + local target_dir="$2" + + docker run --rm \ + -v "${volume_name}:/source:ro" \ + -v "${target_dir}:/target" \ + alpine:3.20 \ + sh -lc 'cd /source && tar -cf - . | tar -xf - -C /target' +} + +mkdir -p \ + "${uploads_dir}" \ + "${leaderboard_dir}" + +if [ ! -f "${marker_file}" ]; then + if is_dir_empty "${uploads_dir}" && docker volume inspect "${uploads_volume}" >/dev/null 2>&1; then + echo "[storage] migrating uploads from ${uploads_volume} to ${uploads_dir}" + copy_volume_to_dir "${uploads_volume}" "${uploads_dir}" + else + echo "[storage] uploads migration skipped" + fi + + if is_dir_empty "${leaderboard_dir}" && [ -d "${legacy_leaderboard_dir}" ]; then + echo "[storage] migrating leaderboard files from ${legacy_leaderboard_dir} to ${leaderboard_dir}" + cp -a "${legacy_leaderboard_dir}/." "${leaderboard_dir}/" + else + echo "[storage] leaderboard migration skipped" + fi + + cat > "${marker_file}" </dev/null 2>&1 && systemctl cat "${legacy_unit}" >/dev/null 2>&1; then + echo "[storage] disabling legacy unit ${legacy_unit}" + systemctl stop "${legacy_unit}" || true + systemctl disable "${legacy_unit}" || true + systemctl mask "${legacy_unit}" || true +fi diff --git a/docs/deployment/cicd.md b/docs/deployment/cicd.md new file mode 100644 index 0000000..7843380 --- /dev/null +++ b/docs/deployment/cicd.md @@ -0,0 +1,72 @@ +# CI/CD Deployment + +## Overview + +Production and staging run in Docker Compose only. +The API is no longer started by systemd services during normal deploys. + +Environment mapping: + +- `main` -> production +- `dev` -> staging + +Persistent runtime data lives outside the git checkout: + +- production: `/var/lib/borofone/production` +- staging: `/var/lib/borofone/staging` +- backups: `/var/backups/borofone` + +Each environment must define: + +- `HOST_DATA_ROOT` +- `BACKUP_ROOT` + +## Deploy flow + +GitHub Actions connects over SSH and runs: + +```bash +bash deploy/scripts/deploy-stack.sh production +``` + +or: + +```bash +bash deploy/scripts/deploy-stack.sh staging +``` + +The deploy script performs: + +1. git sync for the target branch +2. backup of database, uploads, leaderboard, and `.env` +3. one-time migration of legacy uploads volume and repo-local leaderboard files into host storage +4. `docker compose config` preflight validation +5. startup and health checks for Postgres and Redis +6. API image build +7. Alembic migrations +8. API recreation and health verification + +## First rollout / cutover + +On the first rollout after introducing this flow: + +1. Ensure `.env` exists in `/opt/borofone-chat-prod` and `/opt/borofone-chat-staging`. +2. Set `HOST_DATA_ROOT` and `BACKUP_ROOT` to absolute host paths. +3. Run the deploy once. + +During the first deploy, `prepare-persistent-data.sh` will: + +- create host-backed directories for uploads and leaderboard +- migrate uploads from the legacy Docker volume if the new target is empty +- migrate repo-local leaderboard files if the new target is empty +- write a migration marker file under `HOST_DATA_ROOT` +- stop, disable, and mask legacy `borofone-prod` / `borofone-staging` systemd units if present + +Legacy Docker volumes are kept in place for rollback safety. + +## Operational notes + +- Do not use `docker compose down` for normal application updates. +- Do not store runtime user files inside the repository checkout. +- Use `docker compose` instead of legacy `docker-compose`. +- Reverse proxy remains in nginx and continues to target `127.0.0.1:8000` and `127.0.0.1:8001`. diff --git a/scripts/setup_vps.sh b/scripts/setup_vps.sh index a892adf..a4d2884 100644 --- a/scripts/setup_vps.sh +++ b/scripts/setup_vps.sh @@ -4,10 +4,13 @@ set -euxo pipefail REPO_URL="${REPO_URL:-git@github.com:your-org/borofone_chat.git}" PROD_PATH="/opt/borofone-chat-prod" STAGING_PATH="/opt/borofone-chat-staging" +PROD_DATA_ROOT="/var/lib/borofone/production" +STAGING_DATA_ROOT="/var/lib/borofone/staging" +BACKUP_ROOT="/var/backups/borofone" SCRIPT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" BOOTSTRAP_PENDING=0 -mkdir -p "${PROD_PATH}" "${STAGING_PATH}" +mkdir -p "${PROD_PATH}" "${STAGING_PATH}" "${PROD_DATA_ROOT}" "${STAGING_DATA_ROOT}" "${BACKUP_ROOT}" if [ ! -d "${PROD_PATH}/.git" ]; then git clone "${REPO_URL}" "${PROD_PATH}" @@ -19,55 +22,32 @@ fi cd "${PROD_PATH}" git fetch --prune origin -git checkout main +git checkout main || git checkout -b main --track origin/main if [ ! -f .env ]; then - cp .env.production.example .env + cp deploy/env/.env.production.example .env BOOTSTRAP_PENDING=1 fi -python3 -m venv .venv -. .venv/bin/activate -python -m pip install --upgrade pip -pip install -r requirements.txt -mkdir -p uploads uploads/avatars uploads/attachments logs -if [ "${BOOTSTRAP_PENDING}" -eq 0 ]; then - alembic upgrade head -fi -deactivate +mkdir -p "${PROD_DATA_ROOT}/uploads/avatars" "${PROD_DATA_ROOT}/uploads/attachments" "${PROD_DATA_ROOT}/leaderboard" cd "${STAGING_PATH}" git fetch --prune origin -git checkout dev +git checkout dev || git checkout -b dev --track origin/dev if [ ! -f .env ]; then - cp .env.staging.example .env + cp deploy/env/.env.staging.example .env BOOTSTRAP_PENDING=1 fi -python3 -m venv .venv -. .venv/bin/activate -python -m pip install --upgrade pip -pip install -r requirements.txt -mkdir -p uploads uploads/avatars uploads/attachments logs -if [ "${BOOTSTRAP_PENDING}" -eq 0 ]; then - alembic upgrade head -fi -deactivate +mkdir -p "${STAGING_DATA_ROOT}/uploads/avatars" "${STAGING_DATA_ROOT}/uploads/attachments" "${STAGING_DATA_ROOT}/leaderboard" -install -m 644 "${SCRIPT_ROOT}/deploy/systemd/borofone-prod.service" /etc/systemd/system/borofone-prod.service -install -m 644 "${SCRIPT_ROOT}/deploy/systemd/borofone-staging.service" /etc/systemd/system/borofone-staging.service install -m 644 "${SCRIPT_ROOT}/deploy/nginx/borofone.conf" /etc/nginx/sites-available/borofone.conf - ln -sfn /etc/nginx/sites-available/borofone.conf /etc/nginx/sites-enabled/borofone.conf -systemctl daemon-reload -systemctl enable borofone-prod -systemctl enable borofone-staging nginx -t systemctl reload nginx if [ "${BOOTSTRAP_PENDING}" -eq 1 ]; then - echo "Edit ${PROD_PATH}/.env and ${STAGING_PATH}/.env, then rerun this script to apply migrations and restart services." + echo "Edit ${PROD_PATH}/.env and ${STAGING_PATH}/.env, then rerun this script to perform the first Docker-based deploy." exit 0 fi -systemctl restart borofone-prod -systemctl restart borofone-staging - +bash "${PROD_PATH}/deploy/scripts/deploy-stack.sh" production +bash "${STAGING_PATH}/deploy/scripts/deploy-stack.sh" staging From 50b1a4b86434af56f0f625d246a531f08a6a6064 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Wed, 18 Mar 2026 21:28:20 +0200 Subject: [PATCH 2/3] 123 --- README.md | 1 + deploy/scripts/backup-compose-data.sh | 18 +++++++++++++++++- deploy/scripts/deploy-stack.sh | 22 +++++++++++++++++----- docs/deployment/cicd.md | 4 ++-- 4 files changed, 37 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 0c223e1..d50c1e0 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,7 @@ docker compose -f deploy/docker/docker-compose.infra.yml up -d Leaderboard for `tears-of-bfu` is persisted outside the container in `data/leaderboard/leaderboard.json` for local development. For staging/production compose stacks, uploads and leaderboard data are stored under `HOST_DATA_ROOT` on the VPS and bind-mounted into the API container. +The deploy scripts support both `docker compose` and legacy `docker-compose`. **DOWN infra:** diff --git a/deploy/scripts/backup-compose-data.sh b/deploy/scripts/backup-compose-data.sh index d531c8b..591e1f7 100644 --- a/deploy/scripts/backup-compose-data.sh +++ b/deploy/scripts/backup-compose-data.sh @@ -15,6 +15,21 @@ uploads_volume="$5" script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" repo_root="$(cd "${script_dir}/../.." && pwd)" +resolve_compose_cmd() { + if docker compose version >/dev/null 2>&1; then + COMPOSE_BIN=(docker compose) + return 0 + fi + + if command -v docker-compose >/dev/null 2>&1; then + COMPOSE_BIN=(docker-compose) + return 0 + fi + + echo "docker compose or docker-compose is required" >&2 + exit 1 +} + if [ ! -f "${repo_root}/.env" ]; then echo "missing ${repo_root}/.env" >&2 exit 1 @@ -32,7 +47,8 @@ timestamp="$(date +%Y%m%d-%H%M%S)" target_dir="${backup_root}/${timestamp}" mkdir -p "${target_dir}" -compose_cmd=(docker compose -p "${compose_project}" -f "${compose_file}") +resolve_compose_cmd +compose_cmd=("${COMPOSE_BIN[@]}" -p "${compose_project}" -f "${compose_file}") host_uploads_dir="${HOST_DATA_ROOT%/}/uploads" host_leaderboard_dir="${HOST_DATA_ROOT%/}/leaderboard" legacy_leaderboard_dir="${repo_root}/data/leaderboard" diff --git a/deploy/scripts/deploy-stack.sh b/deploy/scripts/deploy-stack.sh index 8738d34..78cc876 100644 --- a/deploy/scripts/deploy-stack.sh +++ b/deploy/scripts/deploy-stack.sh @@ -31,7 +31,21 @@ esac script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" repo_root="$(cd "${script_dir}/../.." && pwd)" -compose_cmd=(docker compose -p "${compose_project}" -f "${compose_file}") + +resolve_compose_cmd() { + if docker compose version >/dev/null 2>&1; then + COMPOSE_BIN=(docker compose) + return 0 + fi + + if command -v docker-compose >/dev/null 2>&1; then + COMPOSE_BIN=(docker-compose) + return 0 + fi + + echo "docker compose or docker-compose is required" >&2 + exit 1 +} wait_for_service() { local service_name="$1" @@ -76,10 +90,8 @@ if ! command -v docker >/dev/null 2>&1; then exit 1 fi -if ! docker compose version >/dev/null 2>&1; then - echo "docker compose plugin is required" >&2 - exit 1 -fi +resolve_compose_cmd +compose_cmd=("${COMPOSE_BIN[@]}" -p "${compose_project}" -f "${compose_file}") cd "${repo_root}" diff --git a/docs/deployment/cicd.md b/docs/deployment/cicd.md index 7843380..7f2b78b 100644 --- a/docs/deployment/cicd.md +++ b/docs/deployment/cicd.md @@ -40,7 +40,7 @@ The deploy script performs: 1. git sync for the target branch 2. backup of database, uploads, leaderboard, and `.env` 3. one-time migration of legacy uploads volume and repo-local leaderboard files into host storage -4. `docker compose config` preflight validation +4. compose config preflight validation 5. startup and health checks for Postgres and Redis 6. API image build 7. Alembic migrations @@ -68,5 +68,5 @@ Legacy Docker volumes are kept in place for rollback safety. - Do not use `docker compose down` for normal application updates. - Do not store runtime user files inside the repository checkout. -- Use `docker compose` instead of legacy `docker-compose`. +- Deploy scripts auto-detect `docker compose` and fall back to `docker-compose` when needed. - Reverse proxy remains in nginx and continues to target `127.0.0.1:8000` and `127.0.0.1:8001`. From fca0eb6a3336eed4066507107cfe31b10bc5944a Mon Sep 17 00:00:00 2001 From: Dmitry Date: Wed, 18 Mar 2026 21:38:32 +0200 Subject: [PATCH 3/3] Update deploy-stack.sh --- deploy/scripts/deploy-stack.sh | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/deploy/scripts/deploy-stack.sh b/deploy/scripts/deploy-stack.sh index 78cc876..5d50d82 100644 --- a/deploy/scripts/deploy-stack.sh +++ b/deploy/scripts/deploy-stack.sh @@ -35,11 +35,13 @@ repo_root="$(cd "${script_dir}/../.." && pwd)" resolve_compose_cmd() { if docker compose version >/dev/null 2>&1; then COMPOSE_BIN=(docker compose) + COMPOSE_FLAVOR="plugin" return 0 fi if command -v docker-compose >/dev/null 2>&1; then COMPOSE_BIN=(docker-compose) + COMPOSE_FLAVOR="legacy" return 0 fi @@ -140,7 +142,14 @@ wait_for_service postgres 120 wait_for_service redis 60 "${compose_cmd[@]}" build api "${compose_cmd[@]}" run --rm api alembic upgrade head -"${compose_cmd[@]}" up -d --no-deps --force-recreate api +if [ "${COMPOSE_FLAVOR}" = "legacy" ]; then + echo "[deploy] using legacy docker-compose workaround for api recreation" + "${compose_cmd[@]}" stop api || true + "${compose_cmd[@]}" rm -f api || true + "${compose_cmd[@]}" up -d --no-deps api +else + "${compose_cmd[@]}" up -d --no-deps --force-recreate api +fi wait_for_service api 120 "${compose_cmd[@]}" ps echo "[deploy] $(date -Is) done ${env_name}"