name: Mostovik Backend CI/CD on: push: branches: - dev # Add main only after the first manual main restore/rollback drill passes. pull_request: branches: - dev - main workflow_dispatch: concurrency: group: mostovik-backend-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} env: REGISTRY_HOST: registry.dev.nii-ecos.ru REGISTRY_NAMESPACE: ${{ github.repository_owner }} WEB_IMAGE: mostovik-backend-web CELERY_IMAGE: mostovik-backend-celery CUSTOMER_DEPLOY_HOST: 10.0.10.174 CUSTOMER_DEPLOY_USER: ecos CUSTOMER_SSH_PROXY_HOST: ecos-proxy@10.10.0.121 jobs: quality: name: Tests and lint runs-on: ubuntu-latest timeout-minutes: 30 steps: - name: Checkout exact event revision uses: actions/checkout@v4.2.2 with: persist-credentials: false - name: Run quality gate run: | set -euo pipefail ci_image="mostovik-backend-ci:${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-1}" docker build \ --file docker/Dockerfile \ --target ci-deps-base \ --tag "${ci_image}" \ . tar --exclude=.git --exclude=.venv --exclude=.ruff_cache -cf - . \ | docker run --rm -i \ --env DJANGO_SETTINGS_MODULE=settings.test \ --env SECRET_KEY=test-secret-key-for-ci \ "${ci_image}" \ bash -c ' set -euo pipefail mkdir -p /workspace tar -xf - -C /workspace cd /workspace export PATH="/app/.venv/bin:${PATH}" export PYTHONPATH="/workspace/src:${PYTHONPATH:-}" ruff check src ruff format src --check python -m pytest tests --ignore=tests/test_api_inventory_e2e.py -q python -m pytest tests/test_api_inventory_e2e.py -q ' build: name: Build linux/amd64 release images runs-on: [backend-docker] timeout-minutes: 60 needs: [quality] if: ${{ needs.quality.result == 'success' && github.event_name != 'pull_request' && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') }} outputs: web_ref: ${{ steps.release.outputs.web_ref }} celery_ref: ${{ steps.release.outputs.celery_ref }} web_tag_ref: ${{ steps.release.outputs.web_tag_ref }} celery_tag_ref: ${{ steps.release.outputs.celery_tag_ref }} steps: - name: Checkout exact event revision uses: actions/checkout@v4.2.2 with: persist-credentials: false - name: Build once and publish immutable digests id: release env: REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }} REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} run: | set -euo pipefail if [ -z "${REGISTRY_USERNAME:-}" ] || [ -z "${REGISTRY_PASSWORD:-}" ]; then echo "REGISTRY_USERNAME and REGISTRY_PASSWORD secrets are required" >&2 exit 1 fi printf '%s' "${REGISTRY_PASSWORD}" \ | docker login "${REGISTRY_HOST}" --username "${REGISTRY_USERNAME}" --password-stdin builder="mostovik-backend-${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-1}" docker buildx create --name "${builder}" --driver docker-container --use cleanup() { docker buildx rm "${builder}" >/dev/null 2>&1 || true rm -f web-metadata.json celery-metadata.json } trap cleanup EXIT docker buildx inspect --bootstrap sha_short="$(printf '%s' "${GITHUB_SHA}" | cut -c1-12)" image_tag="${GITHUB_REF_NAME}-${sha_short}" registry_path="${REGISTRY_HOST}/${REGISTRY_NAMESPACE}" web_repository="${registry_path}/${WEB_IMAGE}" celery_repository="${registry_path}/${CELERY_IMAGE}" build_time="$(date -u +%Y-%m-%dT%H:%M:%SZ)" docker buildx build \ --platform linux/amd64 \ --file docker/Dockerfile \ --target runtime-web \ --label "org.opencontainers.image.revision=${GITHUB_SHA}" \ --label "org.opencontainers.image.source=${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}" \ --label "org.opencontainers.image.created=${build_time}" \ --metadata-file web-metadata.json \ --push \ --tag "${web_repository}:${image_tag}" \ . docker buildx build \ --platform linux/amd64 \ --file docker/Dockerfile \ --target runtime-celery \ --label "org.opencontainers.image.revision=${GITHUB_SHA}" \ --label "org.opencontainers.image.source=${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}" \ --label "org.opencontainers.image.created=${build_time}" \ --metadata-file celery-metadata.json \ --push \ --tag "${celery_repository}:${image_tag}" \ . web_digest="$(jq -r '."containerimage.digest" // empty' web-metadata.json)" celery_digest="$(jq -r '."containerimage.digest" // empty' celery-metadata.json)" printf '%s\n' "${web_digest}" | grep -Eq '^sha256:[0-9a-f]{64}$' printf '%s\n' "${celery_digest}" | grep -Eq '^sha256:[0-9a-f]{64}$' { echo "web_ref=${web_repository}@${web_digest}" echo "celery_ref=${celery_repository}@${celery_digest}" echo "web_tag_ref=${web_repository}:${image_tag}" echo "celery_tag_ref=${celery_repository}:${image_tag}" } >> "${GITHUB_OUTPUT}" deploy_dev: name: Deploy dev runs-on: ubuntu-latest timeout-minutes: 15 needs: [build] if: ${{ github.ref == 'refs/heads/dev' && needs.build.result == 'success' }} steps: - name: Invoke dev release wrapper env: DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} DEPLOY_USER: ${{ secrets.DEPLOY_USER }} INTERNAL_DEPLOY_SSH_KEY_B64: ${{ secrets.INTERNAL_DEPLOY_SSH_KEY_B64 }} INTERNAL_KNOWN_HOSTS_B64: ${{ secrets.INTERNAL_KNOWN_HOSTS_B64 }} WEB_REF: ${{ needs.build.outputs.web_ref }} CELERY_REF: ${{ needs.build.outputs.celery_ref }} run: | set -euo pipefail if [ -z "${DEPLOY_HOST:-}" ] || [ -z "${DEPLOY_USER:-}" ] || [ -z "${INTERNAL_DEPLOY_SSH_KEY_B64:-}" ] || [ -z "${INTERNAL_KNOWN_HOSTS_B64:-}" ]; then echo "Internal deploy SSH secrets are required" >&2 exit 1 fi case "${DEPLOY_USER}" in *[!A-Za-z0-9._-]*) echo "DEPLOY_USER has unsupported characters" >&2; exit 1;; esac case "${DEPLOY_HOST}" in *[!A-Za-z0-9.:-]*) echo "DEPLOY_HOST has unsupported characters" >&2; exit 1;; esac printf '%s\n' "${WEB_REF}" | grep -Eq '@sha256:[0-9a-f]{64}$' printf '%s\n' "${CELERY_REF}" | grep -Eq '@sha256:[0-9a-f]{64}$' ssh_dir="$(mktemp -d)" trap 'rm -rf "${ssh_dir}"' EXIT key_path="${ssh_dir}/deploy_key" known_hosts_path="${ssh_dir}/known_hosts" printf '%s' "${INTERNAL_DEPLOY_SSH_KEY_B64}" | base64 -d > "${key_path}" printf '%s' "${INTERNAL_KNOWN_HOSTS_B64}" | base64 -d > "${known_hosts_path}" chmod 600 "${key_path}" "${known_hosts_path}" ssh-keygen -y -f "${key_path}" >/dev/null ssh \ -i "${key_path}" \ -o BatchMode=yes \ -o IdentitiesOnly=yes \ -o StrictHostKeyChecking=yes \ -o "UserKnownHostsFile=${known_hosts_path}" \ -o GlobalKnownHostsFile=/dev/null \ -o ConnectTimeout=15 \ "${DEPLOY_USER}@${DEPLOY_HOST}" \ /opt/ecos-dev/release-service \ mostovik-backend \ "${GITHUB_RUN_ID:-${GITHUB_SHA}}-${GITHUB_RUN_ATTEMPT:-1}" \ "${WEB_REF}" \ "${CELERY_REF}" deploy_internal_main: name: Deploy and verify internal main runs-on: ubuntu-latest timeout-minutes: 30 needs: [build] if: ${{ github.ref == 'refs/heads/main' && needs.build.result == 'success' }} steps: - name: Refresh clone and invoke internal-main release wrapper env: MAIN_DEPLOY_HOST: ${{ secrets.MAIN_DEPLOY_HOST }} DEPLOY_USER: ${{ secrets.DEPLOY_USER }} INTERNAL_DEPLOY_SSH_KEY_B64: ${{ secrets.INTERNAL_DEPLOY_SSH_KEY_B64 }} INTERNAL_KNOWN_HOSTS_B64: ${{ secrets.INTERNAL_KNOWN_HOSTS_B64 }} INTERNAL_MAIN_MOSTOVIK_BACKEND_URL: ${{ secrets.INTERNAL_MAIN_MOSTOVIK_BACKEND_URL }} WEB_REF: ${{ needs.build.outputs.web_ref }} CELERY_REF: ${{ needs.build.outputs.celery_ref }} run: | set -euo pipefail if [ -z "${MAIN_DEPLOY_HOST:-}" ] || [ -z "${DEPLOY_USER:-}" ] || [ -z "${INTERNAL_DEPLOY_SSH_KEY_B64:-}" ] || [ -z "${INTERNAL_KNOWN_HOSTS_B64:-}" ] || [ -z "${INTERNAL_MAIN_MOSTOVIK_BACKEND_URL:-}" ]; then echo "Internal-main deploy and smoke secrets are required" >&2 exit 1 fi case "${DEPLOY_USER}" in *[!A-Za-z0-9._-]*) echo "DEPLOY_USER has unsupported characters" >&2; exit 1;; esac case "${MAIN_DEPLOY_HOST}" in *[!A-Za-z0-9.:-]*) echo "MAIN_DEPLOY_HOST has unsupported characters" >&2; exit 1;; esac printf '%s\n' "${WEB_REF}" | grep -Eq '@sha256:[0-9a-f]{64}$' printf '%s\n' "${CELERY_REF}" | grep -Eq '@sha256:[0-9a-f]{64}$' ssh_dir="$(mktemp -d)" trap 'rm -rf "${ssh_dir}"' EXIT key_path="${ssh_dir}/deploy_key" known_hosts_path="${ssh_dir}/known_hosts" printf '%s' "${INTERNAL_DEPLOY_SSH_KEY_B64}" | base64 -d > "${key_path}" printf '%s' "${INTERNAL_KNOWN_HOSTS_B64}" | base64 -d > "${known_hosts_path}" chmod 600 "${key_path}" "${known_hosts_path}" ssh-keygen -y -f "${key_path}" >/dev/null ssh \ -i "${key_path}" \ -o BatchMode=yes \ -o IdentitiesOnly=yes \ -o StrictHostKeyChecking=yes \ -o "UserKnownHostsFile=${known_hosts_path}" \ -o GlobalKnownHostsFile=/dev/null \ -o ConnectTimeout=15 \ "${DEPLOY_USER}@${MAIN_DEPLOY_HOST}" \ /opt/ecos-main/release-service \ mostovik-backend \ "${GITHUB_RUN_ID:-${GITHUB_SHA}}-${GITHUB_RUN_ATTEMPT:-1}" \ "${WEB_REF}" \ "${CELERY_REF}" \ --refresh-data smoke_url="${INTERNAL_MAIN_MOSTOVIK_BACKEND_URL%/}/health/ready/" for attempt in $(seq 1 30); do if curl --fail --silent --output /dev/null --max-time 10 "${smoke_url}"; then echo "Internal-main backend readiness passed" exit 0 fi echo "Internal-main backend readiness attempt ${attempt}/30 failed" sleep 5 done echo "Internal-main backend readiness failed" >&2 exit 1 deploy_customer_main: name: Deploy customer main runs-on: ubuntu-latest timeout-minutes: 30 needs: [build, deploy_internal_main] if: ${{ github.ref == 'refs/heads/main' && needs.build.result == 'success' && needs.deploy_internal_main.result == 'success' }} steps: - name: Invoke customer release wrapper with the verified digests env: CUSTOMER_DEPLOY_SSH_KEY_B64: ${{ secrets.CUSTOMER_DEPLOY_SSH_KEY_B64 }} CUSTOMER_KNOWN_HOSTS_B64: ${{ secrets.CUSTOMER_KNOWN_HOSTS_B64 }} WEB_REF: ${{ needs.build.outputs.web_ref }} CELERY_REF: ${{ needs.build.outputs.celery_ref }} run: | set -euo pipefail for name in CUSTOMER_DEPLOY_HOST CUSTOMER_DEPLOY_USER CUSTOMER_SSH_PROXY_HOST CUSTOMER_DEPLOY_SSH_KEY_B64 CUSTOMER_KNOWN_HOSTS_B64; do if [ -z "${!name:-}" ]; then echo "Missing required customer SSH secret: ${name}" >&2 exit 1 fi done case "${CUSTOMER_DEPLOY_USER}" in *[!A-Za-z0-9._-]*) echo "Customer SSH user has unsupported characters" >&2; exit 1;; esac case "${CUSTOMER_DEPLOY_HOST}" in *[!A-Za-z0-9.:-]*) echo "Customer SSH host has unsupported characters" >&2; exit 1;; esac case "${CUSTOMER_SSH_PROXY_HOST}" in *[!A-Za-z0-9._@:-]*) echo "Customer SSH proxy has unsupported characters" >&2; exit 1;; esac printf '%s\n' "${WEB_REF}" | grep -Eq '@sha256:[0-9a-f]{64}$' printf '%s\n' "${CELERY_REF}" | grep -Eq '@sha256:[0-9a-f]{64}$' ssh_dir="$(mktemp -d)" trap 'rm -rf "${ssh_dir}"' EXIT key_path="${ssh_dir}/deploy_key" known_hosts_path="${ssh_dir}/known_hosts" printf '%s' "${CUSTOMER_DEPLOY_SSH_KEY_B64}" | base64 -d > "${key_path}" printf '%s' "${CUSTOMER_KNOWN_HOSTS_B64}" | base64 -d > "${known_hosts_path}" chmod 600 "${key_path}" "${known_hosts_path}" ssh-keygen -y -f "${key_path}" >/dev/null ssh_common=( -i "${key_path}" -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o "UserKnownHostsFile=${known_hosts_path}" -o GlobalKnownHostsFile=/dev/null -o ConnectTimeout=15 ) proxy_command="ssh -i ${key_path} -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=${known_hosts_path} -o GlobalKnownHostsFile=/dev/null -o ConnectTimeout=15 -W %h:%p ${CUSTOMER_SSH_PROXY_HOST}" ssh \ "${ssh_common[@]}" \ -o "ProxyCommand=${proxy_command}" \ "${CUSTOMER_DEPLOY_USER}@${CUSTOMER_DEPLOY_HOST}" \ /ecos/release-service \ mostovik-backend \ "${GITHUB_RUN_ID:-${GITHUB_SHA}}-${GITHUB_RUN_ATTEMPT:-1}" \ "${WEB_REF}" \ "${CELERY_REF}"