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
49 changes: 48 additions & 1 deletion .github/workflows/backend-react-dev-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ on:

permissions:
contents: read
packages: write

concurrency:
group: backend-react-dev-deploy
Expand All @@ -33,6 +34,8 @@ jobs:
env:
DEPLOY_SHA: ${{ github.event.workflow_run.head_sha }}
DEPLOY_RUN_ID: ${{ github.run_id }}
DEPLOY_IMAGE_TAG: >-
ghcr.io/procollab-github/api:react-dev-${{ github.event.workflow_run.head_sha }}

steps:
- name: Validate deploy source
Expand All @@ -50,6 +53,11 @@ jobs:
exit 1
fi

if [[ ! "$DEPLOY_IMAGE_TAG" =~ ^ghcr\.io/procollab-github/api:react-dev-[0-9a-f]{40}$ ]]; then
echo "React-dev image tag is invalid." >&2
exit 1
fi

- name: Checkout tested commit
uses: actions/checkout@v4
with:
Expand All @@ -67,6 +75,45 @@ jobs:
exit 1
fi

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Build and publish tested image
id: build
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
push: true
cache-from: type=gha,scope=backend-react-dev
cache-to: type=gha,mode=max,scope=backend-react-dev
tags: ${{ env.DEPLOY_IMAGE_TAG }}
labels: |
org.opencontainers.image.revision=${{ env.DEPLOY_SHA }}
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}

- name: Pin published image by digest
shell: bash
env:
IMAGE_DIGEST: ${{ steps.build.outputs.digest }}
run: |
set -Eeuo pipefail

if [[ ! "$IMAGE_DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]; then
echo "Published image digest is invalid." >&2
exit 1
fi

deploy_image="ghcr.io/procollab-github/api@${IMAGE_DIGEST}"
echo "DEPLOY_IMAGE=${deploy_image}" >> "$GITHUB_ENV"

- name: Prepare pinned SSH configuration
shell: bash
env:
Expand Down Expand Up @@ -146,5 +193,5 @@ jobs:

ssh "${ssh_options[@]}" \
-- "$REACT_DEV_SSH_USER@$REACT_DEV_SSH_HOST" \
"bash -s -- '$DEPLOY_SHA' '$DEPLOY_RUN_ID'" \
"bash -s -- '$DEPLOY_SHA' '$DEPLOY_RUN_ID' '$DEPLOY_IMAGE'" \
< scripts/deploy_react_dev.sh
33 changes: 24 additions & 9 deletions docs/backend-react-dev-autodeploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@ Deploy запускается только для успешного `push` в `

Workflow получает SHA из завершившегося `Backend PostgreSQL CI`, проверяет формат
из 40 lowercase hexadecimal символов и выполняет checkout именно этого commit.
После checkout GitHub Actions собирает Docker image, публикует его в GHCR с
revision label, равным tested SHA, и фиксирует образ по immutable `sha256` digest.
Перед SSH фактический `git rev-parse HEAD` сравнивается с SHA успешного CI. На
сервер передаются только проверенные SHA и GitHub Actions run ID.
сервер передаются проверенные SHA, GitHub Actions run ID и digest image.

Серверный script выполняет `git fetch origin master --prune`, проверяет наличие
commit и его принадлежность текущему `origin/master`. `git pull` не используется.
Expand All @@ -47,6 +49,11 @@ Windows CRLF удаляются. SSH использует `BatchMode`, `Identiti
Workflow намеренно не использует `ssh-keyscan` и не принимает новый ключ
автоматически.

Для публикации image используется стандартный `GITHUB_TOKEN` с минимальными
permissions `contents: read` и `packages: write`. Дополнительный registry secret
на React-dev не передается: сервер скачивает тот же GHCR package
`ghcr.io/procollab-github/api`, который уже используется release pipeline.

## Изоляция

Единственный разрешенный repository:
Expand Down Expand Up @@ -87,14 +94,19 @@ containers проверяется наличие точных сервисов `
1. Получение deployment lock через `flock`.
2. Проверка repository, origin, git state и stale deploy.
3. Сохранение предыдущих SHA, container IDs, image IDs и image references.
4. Сборка новых `web` и `celerys` images без остановки текущего backend.
5. `python manage.py check` во временном container нового `web` image без TTY
4. Сборка backend image в GitHub Actions и публикация в GHCR по SHA-тегу.
5. Загрузка image на React-dev по immutable digest и проверка revision label.
6. Переназначение существующих Compose image references без server-side build.
7. `python manage.py check` во временном container нового `web` image без TTY
и без доступа к stdin deploy-скрипта.
6. `python manage.py migrate --noinput` с существующим React-dev `.env`, также без TTY
8. `python manage.py migrate --noinput` с существующим React-dev `.env`, также без TTY
и с stdin, подключенным к `/dev/null`.
7. Пересоздание только `web` и `celerys` через `up -d --no-deps --force-recreate`.
8. Ожидание running state с ограниченным timeout.
9. Публичный HTTPS health-check.
9. Пересоздание только `web` и `celerys` с явным запретом server-side build.
10. Проверка image ID, running state и публичный HTTPS health-check.

React-dev сервер больше не устанавливает Python-зависимости и не обращается к
PyPI во время deploy. Доступ к PyPI требуется только GitHub-hosted runner на
стадии сборки image.

Redis, database и nginx не пересоздаются. `docker compose down`, prune-команды и
удаление старых images не выполняются.
Expand All @@ -121,7 +133,7 @@ https://api-react-dev.procollab.ru/programs/?limit=1

## Rollback

При ошибке build, Django check или migration работающие containers не
При ошибке pull, проверки image, Django check или migration работающие containers не
пересоздаются; repository и image references возвращаются к предыдущему
состоянию.

Expand Down Expand Up @@ -158,7 +170,10 @@ GitHub Actions использует одну общую concurrency group с
- workflow устарел относительно текущего `origin/master`;
- Compose labels отсутствуют или указывают вне React-dev;
- сервисы называются не `web`, `celerys`, `redis`;
- build, Django check или migration завершились ошибкой;
- GitHub Actions не смог собрать или опубликовать image;
- React-dev не смог скачать digest из GHCR;
- revision label image не совпал с tested commit;
- Django check или migration завершились ошибкой;
- containers не перешли в running state;
- HTTPS endpoint вернул redirect, HTML, не-JSON или статус не `200`;
- rollback не смог восстановить containers или health-check.
Expand Down
98 changes: 82 additions & 16 deletions scripts/deploy_react_dev.sh
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ readonly HEALTH_RETRY_DELAY_SECONDS=5

DEPLOY_SHA="${1:-}"
GITHUB_ACTIONS_RUN_ID="${2:-}"
DEPLOY_IMAGE="${3:-}"

PREVIOUS_SHA=""
PREVIOUS_WEB_CONTAINER_ID=""
Expand All @@ -25,10 +26,12 @@ PREVIOUS_WEB_IMAGE_ID=""
PREVIOUS_CELERY_IMAGE_ID=""
PREVIOUS_WEB_IMAGE_REF=""
PREVIOUS_CELERY_IMAGE_REF=""
TARGET_WEB_IMAGE_REF=""
TARGET_CELERY_IMAGE_REF=""
HEALTH_JSON_IMAGE_ID=""
HEALTH_BODY_FILE=""
REPOSITORY_MOVED=false
BUILD_ATTEMPTED=false
IMAGE_REFERENCES_CHANGED=false
MIGRATION_STARTED=false
MIGRATION_COMPLETED=false
CONTAINERS_MAY_HAVE_CHANGED=false
Expand Down Expand Up @@ -65,7 +68,7 @@ restore_repository() {
restore_image_references() {
local result=0

if [[ "$BUILD_ATTEMPTED" != true ]]; then
if [[ "$IMAGE_REFERENCES_CHANGED" != true ]]; then
return 0
fi

Expand Down Expand Up @@ -100,6 +103,40 @@ compose_service_container_id() {
result_variable="${container_ids[0]}"
}

compose_service_image_ref() {
local service="$1"
local -n result_variable="$2"
local output=""

output="$(
"${COMPOSE_CMD[@]}" config --format json |
docker run \
--rm \
--interactive \
--entrypoint python \
"$PREVIOUS_WEB_IMAGE_ID" \
-c '
import json
import sys

service = sys.argv[1]
config = json.load(sys.stdin)
image = config.get("services", {}).get(service, {}).get("image")
if not isinstance(image, str) or not image:
raise SystemExit(f"Image reference is missing for service {service}")
print(image)
' \
"$service"
)"
if [[ -z "$output" || "$output" == *$'\n'* ]]; then
printf '[react-dev-deploy] Service %s has invalid image reference.\n' \
"$service" >&2
return 1
fi

result_variable="$output"
}

wait_for_service_running() {
local service="$1"
local attempt
Expand Down Expand Up @@ -193,6 +230,7 @@ rollback_deployment() {
"${COMPOSE_CMD[@]}" up \
--detach \
--no-deps \
--no-build \
--force-recreate \
web "$CELERY_SERVICE" ||
rollback_failed=1
Expand Down Expand Up @@ -234,7 +272,7 @@ handle_error() {
"$line_number" >&2

if [[ "$REPOSITORY_MOVED" == true ||
"$BUILD_ATTEMPTED" == true ||
"$IMAGE_REFERENCES_CHANGED" == true ||
"$CONTAINERS_MAY_HAVE_CHANGED" == true ]]; then
rollback_deployment
rollback_status="$?"
Expand All @@ -260,6 +298,9 @@ fi
if [[ ! "$GITHUB_ACTIONS_RUN_ID" =~ ^[0-9]+$ ]]; then
fail "GitHub Actions run ID должен быть числовым."
fi
if [[ ! "$DEPLOY_IMAGE" =~ ^ghcr\.io/procollab-github/api@sha256:[0-9a-f]{64}$ ]]; then
fail "DEPLOY_IMAGE должен быть immutable GHCR reference по sha256 digest."
fi
if [[ "$(uname -s)" != "Linux" ]]; then
fail "Deploy script разрешено запускать только на Linux."
fi
Expand Down Expand Up @@ -444,6 +485,13 @@ if [[ -z "$PREVIOUS_WEB_IMAGE_ID" || -z "$PREVIOUS_CELERY_IMAGE_ID" ||
fail "Не удалось сохранить image state текущих React-dev containers."
fi

compose_service_image_ref web configured_web_image_ref
compose_service_image_ref "$CELERY_SERVICE" configured_celery_image_ref
if [[ "$configured_web_image_ref" != "$PREVIOUS_WEB_IMAGE_REF" ]] ||
[[ "$configured_celery_image_ref" != "$PREVIOUS_CELERY_IMAGE_REF" ]]; then
fail "Running image references не совпадают с текущей Compose-конфигурацией."
fi

git fetch origin master --prune
if ! git cat-file -e "${DEPLOY_SHA}^{commit}"; then
fail "DEPLOY_SHA отсутствует в backend repository."
Expand All @@ -467,21 +515,32 @@ if [[ "$actual_sha" != "$DEPLOY_SHA" ]]; then
fi

validate_compose_services

BUILD_ATTEMPTED=true
log "Сборка новых web и ${CELERY_SERVICE} images без остановки текущих containers."
"${COMPOSE_CMD[@]}" build web "$CELERY_SERVICE"

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"
compose_service_image_ref web TARGET_WEB_IMAGE_REF
compose_service_image_ref "$CELERY_SERVICE" TARGET_CELERY_IMAGE_REF

log "Загрузка проверенного backend image из GHCR по immutable digest."
docker pull "$DEPLOY_IMAGE"
new_image_id="$(docker image inspect --format '{{.Id}}' "$DEPLOY_IMAGE")"
image_revision="$(
docker image inspect \
--format '{{ index .Config.Labels "org.opencontainers.image.revision" }}' \
"$DEPLOY_IMAGE"
)"
if [[ -z "$new_web_image_id" || -z "$new_celery_image_id" ]]; then
fail "Не удалось определить новые web/celery images после build."
if [[ -z "$new_image_id" ]]; then
fail "Не удалось определить загруженный backend image."
fi
HEALTH_JSON_IMAGE_ID="$new_web_image_id"
if [[ "$image_revision" != "$DEPLOY_SHA" ]]; then
fail "Revision label загруженного image не совпадает с DEPLOY_SHA."
fi

IMAGE_REFERENCES_CHANGED=true
docker image tag "$new_image_id" "$TARGET_WEB_IMAGE_REF"
docker image tag "$new_image_id" "$TARGET_CELERY_IMAGE_REF"
if [[ "$(docker image inspect --format '{{.Id}}' "$TARGET_WEB_IMAGE_REF")" != "$new_image_id" ]] ||
[[ "$(docker image inspect --format '{{.Id}}' "$TARGET_CELERY_IMAGE_REF")" != "$new_image_id" ]]; then
fail "Compose image references не указывают на загруженный backend image."
fi
HEALTH_JSON_IMAGE_ID="$new_image_id"

log "Django system check на новом web image."
"${COMPOSE_CMD[@]}" run \
Expand All @@ -504,6 +563,7 @@ log "Пересоздание только React-dev web и ${CELERY_SERVICE}."
"${COMPOSE_CMD[@]}" up \
--detach \
--no-deps \
--no-build \
--force-recreate \
web "$CELERY_SERVICE"

Expand All @@ -517,6 +577,11 @@ web_container_id=""
celery_container_id=""
compose_service_container_id web web_container_id
compose_service_container_id "$CELERY_SERVICE" celery_container_id
web_image_id="$(docker inspect --format '{{.Image}}' "$web_container_id")"
celery_image_id="$(docker inspect --format '{{.Image}}' "$celery_container_id")"
if [[ "$web_image_id" != "$new_image_id" || "$celery_image_id" != "$new_image_id" ]]; then
fail "React-dev containers запущены не из ожидаемого backend image."
fi
web_status="$(docker inspect --format '{{.State.Status}}' "$web_container_id")"
celery_status="$(docker inspect --format '{{.State.Status}}' "$celery_container_id")"

Expand All @@ -526,6 +591,7 @@ printf '%s\n' \
"DEPLOY_SHA=${DEPLOY_SHA}" \
"PREVIOUS_SHA=${PREVIOUS_SHA}" \
"GITHUB_RUN_ID=${GITHUB_ACTIONS_RUN_ID}" \
"DEPLOY_IMAGE=${DEPLOY_IMAGE}" \
"COMPOSE_PROJECT=${COMPOSE_PROJECT}" \
"WEB_STATUS=${web_status}" \
"CELERY_STATUS=${celery_status}" \
Expand Down
Loading