From e535604f0afd47e167f282fdfe2c504d9b32f7a0 Mon Sep 17 00:00:00 2001 From: Peter <81633644+TThanos3000@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:07:08 +0300 Subject: [PATCH] Use prebuilt image for React-dev deploy --- .../workflows/backend-react-dev-deploy.yml | 49 +++++++++- docs/backend-react-dev-autodeploy.md | 33 +++++-- scripts/deploy_react_dev.sh | 98 ++++++++++++++++--- 3 files changed, 154 insertions(+), 26 deletions(-) mode change 100644 => 100755 scripts/deploy_react_dev.sh diff --git a/.github/workflows/backend-react-dev-deploy.yml b/.github/workflows/backend-react-dev-deploy.yml index df305882..8e7f1a3a 100644 --- a/.github/workflows/backend-react-dev-deploy.yml +++ b/.github/workflows/backend-react-dev-deploy.yml @@ -12,6 +12,7 @@ on: permissions: contents: read + packages: write concurrency: group: backend-react-dev-deploy @@ -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 @@ -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: @@ -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: @@ -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 diff --git a/docs/backend-react-dev-autodeploy.md b/docs/backend-react-dev-autodeploy.md index bf95387b..34cf9c13 100644 --- a/docs/backend-react-dev-autodeploy.md +++ b/docs/backend-react-dev-autodeploy.md @@ -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` не используется. @@ -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: @@ -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 не выполняются. @@ -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 возвращаются к предыдущему состоянию. @@ -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. diff --git a/scripts/deploy_react_dev.sh b/scripts/deploy_react_dev.sh old mode 100644 new mode 100755 index d34db327..eb6a57b4 --- a/scripts/deploy_react_dev.sh +++ b/scripts/deploy_react_dev.sh @@ -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="" @@ -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 @@ -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 @@ -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 @@ -193,6 +230,7 @@ rollback_deployment() { "${COMPOSE_CMD[@]}" up \ --detach \ --no-deps \ + --no-build \ --force-recreate \ web "$CELERY_SERVICE" || rollback_failed=1 @@ -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="$?" @@ -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 @@ -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." @@ -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 \ @@ -504,6 +563,7 @@ log "Пересоздание только React-dev web и ${CELERY_SERVICE}." "${COMPOSE_CMD[@]}" up \ --detach \ --no-deps \ + --no-build \ --force-recreate \ web "$CELERY_SERVICE" @@ -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")" @@ -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}" \