Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 150 additions & 0 deletions .github/workflows/backend-react-dev-deploy.yml
Original file line number Diff line number Diff line change
@@ -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
171 changes: 171 additions & 0 deletions docs/backend-react-dev-autodeploy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
# Backend React-dev autodeploy

<!-- Roadmap: DEV-075 -->

## Назначение

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
```
Loading
Loading