diff --git a/.github/workflows/backend-react-dev-deploy.yml b/.github/workflows/backend-react-dev-deploy.yml new file mode 100644 index 00000000..df305882 --- /dev/null +++ b/.github/workflows/backend-react-dev-deploy.yml @@ -0,0 +1,150 @@ +# Roadmap: DEV-075 +name: Backend React-dev deploy + +on: + workflow_run: + workflows: + - Backend PostgreSQL CI + types: + - completed + branches: + - master + +permissions: + contents: read + +concurrency: + group: backend-react-dev-deploy + cancel-in-progress: false + +jobs: + deploy: + name: Deploy backend to React-dev + if: >- + ${{ + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_branch == 'master' && + github.event.workflow_run.head_repository.full_name == github.repository && + github.event.workflow_run.head_sha != '' + }} + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + DEPLOY_SHA: ${{ github.event.workflow_run.head_sha }} + DEPLOY_RUN_ID: ${{ github.run_id }} + + steps: + - name: Validate deploy source + shell: bash + run: | + set -Eeuo pipefail + + if [[ ! "$DEPLOY_SHA" =~ ^[0-9a-f]{40}$ ]]; then + echo "Successful CI run did not provide a valid commit SHA." >&2 + exit 1 + fi + + if [[ ! "$DEPLOY_RUN_ID" =~ ^[0-9]+$ ]]; then + echo "GitHub Actions run ID is invalid." >&2 + exit 1 + fi + + - name: Checkout tested commit + uses: actions/checkout@v4 + with: + ref: ${{ github.event.workflow_run.head_sha }} + fetch-depth: 1 + + - name: Verify checked out commit + shell: bash + run: | + set -Eeuo pipefail + + actual_sha="$(git rev-parse HEAD)" + if [[ "$actual_sha" != "$DEPLOY_SHA" ]]; then + echo "Checked out commit does not match the successful CI run." >&2 + exit 1 + fi + + - name: Prepare pinned SSH configuration + shell: bash + env: + REACT_DEV_SSH_HOST: ${{ secrets.REACT_DEV_SSH_HOST }} + REACT_DEV_SSH_PORT: ${{ secrets.REACT_DEV_SSH_PORT }} + REACT_DEV_SSH_USER: ${{ secrets.REACT_DEV_SSH_USER }} + REACT_DEV_SSH_PRIVATE_KEY: ${{ secrets.REACT_DEV_SSH_PRIVATE_KEY }} + REACT_DEV_SSH_KNOWN_HOSTS: ${{ secrets.REACT_DEV_SSH_KNOWN_HOSTS }} + run: | + set -Eeuo pipefail + + required_secrets=( + REACT_DEV_SSH_HOST + REACT_DEV_SSH_PORT + REACT_DEV_SSH_USER + REACT_DEV_SSH_PRIVATE_KEY + REACT_DEV_SSH_KNOWN_HOSTS + ) + for secret_name in "${required_secrets[@]}"; do + if [[ -z "${!secret_name:-}" ]]; then + echo "Required GitHub Secret is empty: ${secret_name}" >&2 + exit 1 + fi + done + + if [[ ! "$REACT_DEV_SSH_PORT" =~ ^[0-9]+$ ]] || + ((REACT_DEV_SSH_PORT < 1 || REACT_DEV_SSH_PORT > 65535)); then + echo "React-dev SSH port is invalid." >&2 + exit 1 + fi + + if [[ ! "$REACT_DEV_SSH_USER" =~ ^[a-z_][a-z0-9_.-]*$ ]]; then + echo "React-dev SSH user is invalid." >&2 + exit 1 + fi + + if [[ "$REACT_DEV_SSH_HOST" =~ [[:space:]] ]]; then + echo "React-dev SSH host is invalid." >&2 + exit 1 + fi + + install -d -m 700 "$HOME/.ssh" + printf '%s\n' "$REACT_DEV_SSH_PRIVATE_KEY" | + tr -d '\r' > "$HOME/.ssh/react_dev_deploy_key" + printf '%s\n' "$REACT_DEV_SSH_KNOWN_HOSTS" | + tr -d '\r' > "$HOME/.ssh/react_dev_known_hosts" + chmod 600 \ + "$HOME/.ssh/react_dev_deploy_key" \ + "$HOME/.ssh/react_dev_known_hosts" + + test -s "$HOME/.ssh/react_dev_deploy_key" + test -s "$HOME/.ssh/react_dev_known_hosts" + ssh-keygen -y -f "$HOME/.ssh/react_dev_deploy_key" >/dev/null + + - name: Deploy tested commit to React-dev + shell: bash + env: + REACT_DEV_SSH_HOST: ${{ secrets.REACT_DEV_SSH_HOST }} + REACT_DEV_SSH_PORT: ${{ secrets.REACT_DEV_SSH_PORT }} + REACT_DEV_SSH_USER: ${{ secrets.REACT_DEV_SSH_USER }} + run: | + set -Eeuo pipefail + + ssh_options=( + -i "$HOME/.ssh/react_dev_deploy_key" + -p "$REACT_DEV_SSH_PORT" + -o BatchMode=yes + -o IdentitiesOnly=yes + -o PasswordAuthentication=no + -o KbdInteractiveAuthentication=no + -o StrictHostKeyChecking=yes + -o UserKnownHostsFile="$HOME/.ssh/react_dev_known_hosts" + -o ConnectTimeout=10 + -o ServerAliveInterval=15 + -o ServerAliveCountMax=3 + ) + + ssh "${ssh_options[@]}" \ + -- "$REACT_DEV_SSH_USER@$REACT_DEV_SSH_HOST" \ + "bash -s -- '$DEPLOY_SHA' '$DEPLOY_RUN_ID'" \ + < scripts/deploy_react_dev.sh diff --git a/docs/backend-react-dev-autodeploy.md b/docs/backend-react-dev-autodeploy.md new file mode 100644 index 00000000..af367485 --- /dev/null +++ b/docs/backend-react-dev-autodeploy.md @@ -0,0 +1,171 @@ +# Backend React-dev autodeploy + + + +## Назначение + +DEV-075 автоматически доставляет backend в отдельный React-dev контур после +успешного PostgreSQL CI: + +```text +push master → Backend PostgreSQL CI → Backend React-dev deploy +``` + +Deploy запускается только для успешного `push` в `master`. Проверка pull request +не запускает deploy, поэтому непроверенный или еще не объединенный commit не +попадает на сервер. + +## Exact SHA + +Workflow получает SHA из завершившегося `Backend PostgreSQL CI`, проверяет формат +из 40 lowercase hexadecimal символов и выполняет checkout именно этого commit. +Перед SSH фактический `git rev-parse HEAD` сравнивается с SHA успешного CI. На +сервер передаются только проверенные SHA и GitHub Actions run ID. + +Серверный script выполняет `git fetch origin master --prune`, проверяет наличие +commit и его принадлежность текущему `origin/master`. `git pull` не используется. +Если `origin/master` уже указывает на более новый commit, deploy признается +устаревшим и завершается без изменения code или containers: следующий workflow +доставит актуальную версию. + +## GitHub Secrets и SSH + +Workflow использует только следующие GitHub Secrets: + +- `REACT_DEV_SSH_HOST`; +- `REACT_DEV_SSH_PORT`; +- `REACT_DEV_SSH_USER`; +- `REACT_DEV_SSH_PRIVATE_KEY`; +- `REACT_DEV_SSH_KNOWN_HOSTS`. + +Значения не хранятся в репозитории и не передаются в `.env`. Private key и +`known_hosts` записываются во временный `~/.ssh` runner с правами `600`; +Windows CRLF удаляются. SSH использует `BatchMode`, `IdentitiesOnly`, +`StrictHostKeyChecking=yes` и отдельный `UserKnownHostsFile`. + +`REACT_DEV_SSH_KNOWN_HOSTS` должен содержать заранее проверенный pinned host key. +Workflow намеренно не использует `ssh-keyscan` и не принимает новый ключ +автоматически. + +## Изоляция + +Единственный разрешенный repository: + +```text +/root/api-react-dev +``` + +Script проверяет точный `realpath`, наличие `.git` и origin +`PROCOLLAB-github/api`. Старый backend в `/root/api`, production containers, +production database, nginx и production URL не используются. + +Tracked staged/unstaged изменения блокируют deploy. Untracked и ignored файлы не +удаляются, `git clean` не выполняется, серверный `.env` сохраняется. + +## Docker Compose + +Repository `docker-compose.yml` является legacy и запрещен для автодеплоя. +Script находит уже работающий `web` container по labels: + +- `com.docker.compose.project=api-react`; +- `com.docker.compose.service=web`. + +Из labels читаются Compose project, working directory и config files. Все пути +проверяются через `realpath`: working directory должен совпадать с +`/root/api-react-dev`, каждый config file должен находиться внутри этого каталога, +а legacy `docker-compose.yml` отклоняется. + +Compose-команда собирается как Bash array без `eval`. До любых изменений +containers проверяется наличие точных сервисов `web`, `celery` и `redis`. Иное +имя celery не угадывается: deploy завершается с явной ошибкой. + +## Порядок deploy + +1. Получение deployment lock через `flock`. +2. Проверка repository, origin, git state и stale deploy. +3. Сохранение предыдущих SHA, container IDs, image IDs и image references. +4. Сборка новых `web` и `celery` images без остановки текущего backend. +5. `python manage.py check` во временном container нового `web` image. +6. `python manage.py migrate --noinput` с существующим React-dev `.env`. +7. Пересоздание только `web` и `celery` через `up -d --no-deps --force-recreate`. +8. Ожидание running state с ограниченным timeout. +9. Публичный HTTPS health-check. + +Redis, database и nginx не пересоздаются. `docker compose down`, prune-команды и +удаление старых images не выполняются. + +## Health-check + +Проверяется только: + +```text +https://api-react-dev.procollab.ru/programs/?limit=1 +``` + +Успех требует одновременно: + +- HTTP `200` без следования redirect; +- `Content-Type` с `application/json`; +- непустой body; +- отсутствие HTML/SPA fallback; +- корректный JSON; +- running state `web` и `celery`. + +Используются ограниченные connect/total timeout и восемь попыток с паузой. +Полный API response в лог не выводится. + +## Rollback + +При ошибке build, Django check или migration работающие containers не +пересоздаются; repository и image references возвращаются к предыдущему +состоянию. + +Если ошибка возникла после начала пересоздания containers или на health-check, +script: + +1. возвращает предыдущий code SHA; +2. возвращает предыдущие image IDs на сохраненные image references; +3. пересоздает только `web` и `celery`; +4. повторяет ограниченный React-dev health-check; +5. сообщает результат rollback; +6. завершает deploy с ошибкой даже при успешном rollback. + +Redis не затрагивается. Ошибка rollback выводится отдельно и не маскирует +исходную ошибку. + +Rollback возвращает code и containers, но **не откатывает уже примененные +миграции автоматически**. Новые миграции обязаны быть backward-compatible с +предыдущим image, чтобы предыдущая версия могла работать после container +rollback. + +## Concurrency + +GitHub Actions использует одну общую concurrency group с +`cancel-in-progress: false`: новый deploy ждет завершения текущего. На сервере +дополнительно действует `flock` с ограниченным временем ожидания. + +## Типовые ошибки + +- отсутствует один из GitHub Secrets; +- pinned host key не совпадает с ключом сервера; +- SSH недоступен или запрещает key authentication; +- repository имеет tracked изменения или неверный origin; +- workflow устарел относительно текущего `origin/master`; +- Compose labels отсутствуют или указывают вне React-dev; +- сервисы называются не `web`, `celery`, `redis`; +- build, Django check или migration завершились ошибкой; +- containers не перешли в running state; +- HTTPS endpoint вернул redirect, HTML, не-JSON или статус не `200`; +- rollback не смог восстановить containers или health-check. + +Workflow не подключается к production, не изменяет старый backend и не управляет +nginx. После первого успешного deploy нужно вручную проверить React-dev API и +основной React-dev пользовательский сценарий в браузере. + +После merge и первого подтвержденного deploy PR можно дополнить маркером +`Roadmap-Complete: DEV-075`. До этого используются: + +```text +Roadmap-IDs: DEV-075 +Roadmap-Partial: DEV-075 +``` diff --git a/scripts/deploy_react_dev.sh b/scripts/deploy_react_dev.sh new file mode 100644 index 00000000..eb221bd8 --- /dev/null +++ b/scripts/deploy_react_dev.sh @@ -0,0 +1,527 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +# Roadmap: DEV-075 + +readonly DEPLOY_DIR="/root/api-react-dev" +readonly FORBIDDEN_DIR="/root/api" +readonly EXPECTED_REPOSITORY="PROCOLLAB-github/api" +readonly EXPECTED_COMPOSE_PROJECT="api-react" +readonly HEALTH_URL="https://api-react-dev.procollab.ru/programs/?limit=1" +readonly LOCK_FILE="${DEPLOY_DIR}/.react-dev-deploy.lock" +readonly LOCK_TIMEOUT_SECONDS=10 +readonly SERVICE_WAIT_ATTEMPTS=30 +readonly SERVICE_WAIT_DELAY_SECONDS=2 +readonly HEALTH_ATTEMPTS=8 +readonly HEALTH_RETRY_DELAY_SECONDS=5 + +DEPLOY_SHA="${1:-}" +GITHUB_ACTIONS_RUN_ID="${2:-}" + +PREVIOUS_SHA="" +PREVIOUS_WEB_CONTAINER_ID="" +PREVIOUS_CELERY_CONTAINER_ID="" +PREVIOUS_WEB_IMAGE_ID="" +PREVIOUS_CELERY_IMAGE_ID="" +PREVIOUS_WEB_IMAGE_REF="" +PREVIOUS_CELERY_IMAGE_REF="" +HEALTH_JSON_IMAGE_ID="" +HEALTH_BODY_FILE="" +REPOSITORY_MOVED=false +BUILD_ATTEMPTED=false +MIGRATION_STARTED=false +MIGRATION_COMPLETED=false +CONTAINERS_MAY_HAVE_CHANGED=false +ERROR_HANDLING=false +COMPOSE_PROJECT="" +COMPOSE_CMD=() + +log() { + printf '[react-dev-deploy] %s\n' "$*" +} + +fail() { + printf '[react-dev-deploy] ERROR: %s\n' "$*" >&2 + return 1 +} + +cleanup_health_file() { + if [[ -n "$HEALTH_BODY_FILE" && -f "$HEALTH_BODY_FILE" ]]; then + rm -f -- "$HEALTH_BODY_FILE" + fi + HEALTH_BODY_FILE="" +} + +restore_repository() { + if [[ "$REPOSITORY_MOVED" == true && -n "$PREVIOUS_SHA" ]]; then + log "Возврат repository к предыдущему commit." + if ! git reset --hard "$PREVIOUS_SHA" >/dev/null; then + return 1 + fi + REPOSITORY_MOVED=false + fi +} + +restore_image_references() { + local result=0 + + if [[ "$BUILD_ATTEMPTED" != true ]]; then + return 0 + fi + + if [[ -n "$PREVIOUS_WEB_IMAGE_ID" && -n "$PREVIOUS_WEB_IMAGE_REF" ]]; then + docker image tag "$PREVIOUS_WEB_IMAGE_ID" "$PREVIOUS_WEB_IMAGE_REF" || + result=1 + fi + if [[ -n "$PREVIOUS_CELERY_IMAGE_ID" && -n "$PREVIOUS_CELERY_IMAGE_REF" ]]; then + docker image tag "$PREVIOUS_CELERY_IMAGE_ID" "$PREVIOUS_CELERY_IMAGE_REF" || + result=1 + fi + + return "$result" +} + +compose_service_container_id() { + local service="$1" + local -n result_variable="$2" + local output + local container_ids=() + + output="$("${COMPOSE_CMD[@]}" ps --all --quiet "$service")" + if [[ -n "$output" ]]; then + mapfile -t container_ids <<< "$output" + fi + if ((${#container_ids[@]} != 1)); then + printf '[react-dev-deploy] Service %s has %d containers; expected 1.\n' \ + "$service" "${#container_ids[@]}" >&2 + return 1 + fi + + result_variable="${container_ids[0]}" +} + +wait_for_service_running() { + local service="$1" + local attempt + local container_id="" + local status="" + local restarting="" + + for ((attempt = 1; attempt <= SERVICE_WAIT_ATTEMPTS; attempt++)); do + if compose_service_container_id "$service" container_id; then + status="$(docker inspect --format '{{.State.Status}}' "$container_id")" + restarting="$( + docker inspect --format '{{.State.Restarting}}' "$container_id" + )" + if [[ "$status" == "running" && "$restarting" == "false" ]]; then + return 0 + fi + fi + sleep "$SERVICE_WAIT_DELAY_SECONDS" + done + + printf '[react-dev-deploy] Service %s did not reach running state; status=%s.\n' \ + "$service" "${status:-missing}" >&2 + return 1 +} + +health_check() { + local label="$1" + local attempt + local metadata="" + local http_status="" + local content_type="" + + if [[ -z "$HEALTH_JSON_IMAGE_ID" ]]; then + printf '[react-dev-deploy] JSON parser image is not initialized.\n' >&2 + return 1 + fi + + HEALTH_BODY_FILE="$(mktemp /tmp/procollab-react-dev-health.XXXXXX)" + for ((attempt = 1; attempt <= HEALTH_ATTEMPTS; attempt++)); do + metadata="" + if metadata="$( + curl \ + --silent \ + --show-error \ + --proto '=https' \ + --max-redirs 0 \ + --connect-timeout 5 \ + --max-time 15 \ + --output "$HEALTH_BODY_FILE" \ + --write-out '%{http_code}|%{content_type}' \ + "$HEALTH_URL" + )"; then + http_status="${metadata%%|*}" + content_type="${metadata#*|}" + log "${label} health-check attempt ${attempt}: HTTP ${http_status}, content-type=${content_type:-unknown}." + + if [[ "$http_status" == "200" ]] && + [[ "$content_type" == *"application/json"* ]] && + [[ -s "$HEALTH_BODY_FILE" ]] && + ! grep -Eiq '^[[:space:]]*<(html|!doctype)' "$HEALTH_BODY_FILE" && + docker run \ + --rm \ + --interactive \ + --entrypoint python \ + "$HEALTH_JSON_IMAGE_ID" \ + -c 'import json, sys; json.load(sys.stdin)' \ + < "$HEALTH_BODY_FILE" \ + >/dev/null; then + cleanup_health_file + return 0 + fi + else + log "${label} health-check attempt ${attempt}: connection failed." + fi + + sleep "$HEALTH_RETRY_DELAY_SECONDS" + done + + cleanup_health_file + return 1 +} + +rollback_deployment() { + local rollback_failed=0 + + log "Запуск rollback React-dev code и container images." + restore_repository || rollback_failed=1 + restore_image_references || rollback_failed=1 + + if [[ "$CONTAINERS_MAY_HAVE_CHANGED" == true && "$rollback_failed" -eq 0 ]]; then + "${COMPOSE_CMD[@]}" up \ + --detach \ + --no-deps \ + --force-recreate \ + web celery || + rollback_failed=1 + + if ((rollback_failed == 0)); then + wait_for_service_running web || rollback_failed=1 + wait_for_service_running celery || rollback_failed=1 + fi + + if ((rollback_failed == 0)); then + HEALTH_JSON_IMAGE_ID="$PREVIOUS_WEB_IMAGE_ID" + health_check "Rollback" || rollback_failed=1 + fi + fi + + if ((rollback_failed != 0)); then + printf '[react-dev-deploy] ERROR: rollback React-dev завершился ошибкой.\n' >&2 + return 1 + fi + + log "Rollback React-dev успешно завершен." + return 0 +} + +handle_error() { + local exit_code="$?" + local line_number="${1:-unknown}" + local rollback_status=0 + + if [[ "$ERROR_HANDLING" == true ]]; then + exit "$exit_code" + fi + + ERROR_HANDLING=true + trap - ERR + set +e + cleanup_health_file + printf '[react-dev-deploy] ERROR: deploy failed near line %s.\n' \ + "$line_number" >&2 + + if [[ "$REPOSITORY_MOVED" == true || + "$BUILD_ATTEMPTED" == true || + "$CONTAINERS_MAY_HAVE_CHANGED" == true ]]; then + rollback_deployment + rollback_status="$?" + fi + + if [[ "$MIGRATION_STARTED" == true ]]; then + printf '%s\n' \ + '[react-dev-deploy] NOTICE: примененные миграции автоматически назад не откатывались.' >&2 + fi + if ((rollback_status != 0)); then + printf '%s\n' \ + '[react-dev-deploy] ERROR: исходная ошибка deploy и ошибка rollback требуют ручной проверки React-dev.' >&2 + fi + + exit "$exit_code" +} + +trap 'handle_error "$LINENO"' ERR + +if [[ ! "$DEPLOY_SHA" =~ ^[0-9a-f]{40}$ ]]; then + fail "DEPLOY_SHA должен состоять ровно из 40 lowercase hexadecimal символов." +fi +if [[ ! "$GITHUB_ACTIONS_RUN_ID" =~ ^[0-9]+$ ]]; then + fail "GitHub Actions run ID должен быть числовым." +fi +if [[ "$(uname -s)" != "Linux" ]]; then + fail "Deploy script разрешено запускать только на Linux." +fi + +for required_command in git docker curl flock realpath mktemp grep; do + if ! command -v "$required_command" >/dev/null 2>&1; then + fail "Не найдена обязательная команда: ${required_command}." + fi +done +if ! docker compose version >/dev/null 2>&1; then + fail "Docker Compose plugin недоступен." +fi + +if [[ ! -d "$DEPLOY_DIR" || ! -d "$DEPLOY_DIR/.git" ]]; then + fail "React-dev repository не найден в ${DEPLOY_DIR}." +fi +actual_deploy_dir="$(realpath -e "$DEPLOY_DIR")" +if [[ "$actual_deploy_dir" != "$DEPLOY_DIR" ]]; then + fail "React-dev repository должен иметь точный realpath ${DEPLOY_DIR}." +fi +if [[ -e "$FORBIDDEN_DIR" ]] && + [[ "$(realpath -e "$FORBIDDEN_DIR")" == "$actual_deploy_dir" ]]; then + fail "Production/legacy backend path запрещен." +fi + +cd -- "$actual_deploy_dir" +if [[ "$(pwd -P)" != "$DEPLOY_DIR" ]]; then + fail "Текущий каталог не является разрешенным React-dev repository." +fi + +exec 9> "$LOCK_FILE" +if ! flock --wait "$LOCK_TIMEOUT_SECONDS" 9; then + fail "Другой React-dev deploy уже выполняется; lock не получен." +fi + +origin_url="$(git remote get-url origin)" +case "$origin_url" in + "https://github.com/${EXPECTED_REPOSITORY}" | \ + "https://github.com/${EXPECTED_REPOSITORY}.git" | \ + "git@github.com:${EXPECTED_REPOSITORY}.git" | \ + "ssh://git@github.com/${EXPECTED_REPOSITORY}" | \ + "ssh://git@github.com/${EXPECTED_REPOSITORY}.git") + ;; + *) + fail "Origin не соответствует разрешенному backend repository." + ;; +esac + +PREVIOUS_SHA="$(git rev-parse HEAD)" +if ! git diff --quiet --ignore-submodules --; then + fail "В React-dev repository есть unstaged tracked changes." +fi +if ! git diff --cached --quiet --ignore-submodules --; then + fail "В React-dev repository есть staged tracked changes." +fi + +mapfile -t current_web_containers < <( + docker ps \ + --quiet \ + --filter "label=com.docker.compose.project=${EXPECTED_COMPOSE_PROJECT}" \ + --filter "label=com.docker.compose.service=web" +) +if ((${#current_web_containers[@]} != 1)); then + fail "Не найден ровно один running web container проекта api-react." +fi +PREVIOUS_WEB_CONTAINER_ID="${current_web_containers[0]}" + +COMPOSE_PROJECT="$( + docker inspect \ + --format '{{ index .Config.Labels "com.docker.compose.project" }}' \ + "$PREVIOUS_WEB_CONTAINER_ID" +)" +compose_working_dir="$( + docker inspect \ + --format '{{ index .Config.Labels "com.docker.compose.project.working_dir" }}' \ + "$PREVIOUS_WEB_CONTAINER_ID" +)" +compose_config_files_label="$( + docker inspect \ + --format '{{ index .Config.Labels "com.docker.compose.project.config_files" }}' \ + "$PREVIOUS_WEB_CONTAINER_ID" +)" + +if [[ "$COMPOSE_PROJECT" != "$EXPECTED_COMPOSE_PROJECT" ]]; then + fail "Обнаружен неожиданный Docker Compose project." +fi +if [[ -z "$compose_working_dir" || -z "$compose_config_files_label" ]]; then + fail "У running web container отсутствуют обязательные Compose labels." +fi + +compose_working_dir="$(realpath -e "$compose_working_dir")" +if [[ "$compose_working_dir" != "$DEPLOY_DIR" ]]; then + fail "Docker Compose working_dir находится вне React-dev repository." +fi + +legacy_compose_file="$(realpath -m "${DEPLOY_DIR}/docker-compose.yml")" +compose_config_files=() +IFS=',' read -r -a raw_compose_config_files <<< "$compose_config_files_label" +for raw_config_file in "${raw_compose_config_files[@]}"; do + config_file="${raw_config_file#"${raw_config_file%%[![:space:]]*}"}" + config_file="${config_file%"${config_file##*[![:space:]]}"}" + if [[ -z "$config_file" ]]; then + fail "Compose config_files label содержит пустой путь." + fi + if [[ "$config_file" != /* ]]; then + config_file="${compose_working_dir}/${config_file}" + fi + config_file="$(realpath -e "$config_file")" + if [[ "$config_file" != "${DEPLOY_DIR}/"* ]]; then + fail "Compose config file находится вне React-dev repository." + fi + if [[ "$config_file" == "$legacy_compose_file" ]]; then + fail "Repository legacy docker-compose.yml запрещен для React-dev deploy." + fi + compose_config_files+=("$config_file") +done +if ((${#compose_config_files[@]} == 0)); then + fail "Compose config files не обнаружены." +fi + +COMPOSE_CMD=( + docker compose + --project-name "$COMPOSE_PROJECT" + --project-directory "$compose_working_dir" +) +for config_file in "${compose_config_files[@]}"; do + COMPOSE_CMD+=(--file "$config_file") +done + +validate_compose_services() { + local services_output + local service + local expected_service + local services=() + local found + + services_output="$("${COMPOSE_CMD[@]}" config --services)" + mapfile -t services <<< "$services_output" + for expected_service in web celery redis; do + found=false + for service in "${services[@]}"; do + if [[ "$service" == "$expected_service" ]]; then + found=true + break + fi + done + if [[ "$found" != true ]]; then + printf '[react-dev-deploy] Expected Compose service is missing: %s.\n' \ + "$expected_service" >&2 + return 1 + fi + done +} + +validate_compose_services + +mapfile -t current_celery_containers < <( + docker ps \ + --quiet \ + --filter "label=com.docker.compose.project=${COMPOSE_PROJECT}" \ + --filter "label=com.docker.compose.service=celery" +) +if ((${#current_celery_containers[@]} != 1)); then + fail "Не найден ровно один running celery container проекта api-react." +fi +PREVIOUS_CELERY_CONTAINER_ID="${current_celery_containers[0]}" + +PREVIOUS_WEB_IMAGE_ID="$( + docker inspect --format '{{.Image}}' "$PREVIOUS_WEB_CONTAINER_ID" +)" +PREVIOUS_CELERY_IMAGE_ID="$( + docker inspect --format '{{.Image}}' "$PREVIOUS_CELERY_CONTAINER_ID" +)" +PREVIOUS_WEB_IMAGE_REF="$( + docker inspect --format '{{.Config.Image}}' "$PREVIOUS_WEB_CONTAINER_ID" +)" +PREVIOUS_CELERY_IMAGE_REF="$( + docker inspect --format '{{.Config.Image}}' "$PREVIOUS_CELERY_CONTAINER_ID" +)" +if [[ -z "$PREVIOUS_WEB_IMAGE_ID" || -z "$PREVIOUS_CELERY_IMAGE_ID" || + -z "$PREVIOUS_WEB_IMAGE_REF" || -z "$PREVIOUS_CELERY_IMAGE_REF" ]]; then + fail "Не удалось сохранить image state текущих React-dev containers." +fi + +git fetch origin master --prune +if ! git cat-file -e "${DEPLOY_SHA}^{commit}"; then + fail "DEPLOY_SHA отсутствует в backend repository." +fi +origin_master_sha="$(git rev-parse --verify 'origin/master^{commit}')" +if ! git merge-base --is-ancestor "$DEPLOY_SHA" "$origin_master_sha"; then + fail "DEPLOY_SHA не является commit из текущего origin/master." +fi +if [[ "$origin_master_sha" != "$DEPLOY_SHA" ]]; then + log "Deploy устарел: origin/master уже содержит более новый commit." + log "Код и containers React-dev не изменялись." + trap - ERR + exit 0 +fi + +REPOSITORY_MOVED=true +git reset --hard "$DEPLOY_SHA" >/dev/null +actual_sha="$(git rev-parse HEAD)" +if [[ "$actual_sha" != "$DEPLOY_SHA" ]]; then + fail "Repository не перешел на DEPLOY_SHA." +fi + +validate_compose_services + +BUILD_ATTEMPTED=true +log "Сборка новых web и celery images без остановки текущих containers." +"${COMPOSE_CMD[@]}" build web celery + +new_web_image_id="$( + docker image inspect --format '{{.Id}}' "$PREVIOUS_WEB_IMAGE_REF" +)" +new_celery_image_id="$( + docker image inspect --format '{{.Id}}' "$PREVIOUS_CELERY_IMAGE_REF" +)" +if [[ -z "$new_web_image_id" || -z "$new_celery_image_id" ]]; then + fail "Не удалось определить новые web/celery images после build." +fi +HEALTH_JSON_IMAGE_ID="$new_web_image_id" + +log "Django system check на новом web image." +"${COMPOSE_CMD[@]}" run --rm --no-deps web python manage.py check + +MIGRATION_STARTED=true +log "Применение миграций React-dev на новом web image." +"${COMPOSE_CMD[@]}" run \ + --rm \ + --no-deps \ + web python manage.py migrate --noinput +MIGRATION_COMPLETED=true + +CONTAINERS_MAY_HAVE_CHANGED=true +log "Пересоздание только React-dev web и celery." +"${COMPOSE_CMD[@]}" up \ + --detach \ + --no-deps \ + --force-recreate \ + web celery + +wait_for_service_running web +wait_for_service_running celery +if ! health_check "Deploy"; then + fail "Публичный React-dev HTTPS health-check не пройден." +fi + +web_container_id="" +celery_container_id="" +compose_service_container_id web web_container_id +compose_service_container_id celery celery_container_id +web_status="$(docker inspect --format '{{.State.Status}}' "$web_container_id")" +celery_status="$(docker inspect --format '{{.State.Status}}' "$celery_container_id")" + +trap - ERR +log "Deploy React-dev успешно завершен." +printf '%s\n' \ + "DEPLOY_SHA=${DEPLOY_SHA}" \ + "PREVIOUS_SHA=${PREVIOUS_SHA}" \ + "GITHUB_RUN_ID=${GITHUB_ACTIONS_RUN_ID}" \ + "COMPOSE_PROJECT=${COMPOSE_PROJECT}" \ + "WEB_STATUS=${web_status}" \ + "CELERY_STATUS=${celery_status}" \ + "MIGRATION_COMPLETED=${MIGRATION_COMPLETED}" \ + "HEALTH_CHECK_COMPLETED=true"