All checks were successful
Mostovik Backend CI/CD / Tests and lint (push) Successful in 3m28s
Mostovik Backend CI/CD / Build linux/amd64 release images (push) Successful in 3m23s
Mostovik Backend CI/CD / Deploy and verify internal main (push) Has been skipped
Mostovik Backend CI/CD / Deploy dev (push) Successful in 1m49s
303 lines
12 KiB
YAML
303 lines
12 KiB
YAML
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
|
|
|
|
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: Setup Node for Orval
|
|
uses: actions/setup-node@v4
|
|
with:
|
|
node-version: 24
|
|
|
|
- name: Setup contract toolchain
|
|
uses: oven-sh/setup-bun@v2
|
|
with:
|
|
bun-version: 1.3.9
|
|
|
|
- name: Run quality gate
|
|
run: |
|
|
set -euo pipefail
|
|
ci_image="mostovik-backend-ci:${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-1}"
|
|
ci_builder="mostovik-backend-ci-${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-1}"
|
|
ci_container="mostovik-contract-ci-${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-1}"
|
|
cleanup() {
|
|
docker rm --force "${ci_container}" >/dev/null 2>&1 || true
|
|
docker image rm --force "${ci_image}" >/dev/null 2>&1 || true
|
|
docker buildx rm "${ci_builder}" >/dev/null 2>&1 || true
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
docker buildx create \
|
|
--name "${ci_builder}" \
|
|
--driver docker-container
|
|
docker buildx inspect "${ci_builder}" --bootstrap
|
|
docker buildx build \
|
|
--builder "${ci_builder}" \
|
|
--file docker/Dockerfile \
|
|
--target ci-deps-base \
|
|
--tag "${ci_image}" \
|
|
--load \
|
|
.
|
|
|
|
mkdir -p .openapi-build
|
|
tar --exclude=.git --exclude=.venv --exclude=.ruff_cache --exclude=node_modules -cf - . \
|
|
| docker run --name "${ci_container}" -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 /openapi-build
|
|
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
|
|
OPENAPI_RUNTIME_FIXTURES=/openapi-build/fixtures.json python -m pytest tests/apps/organizations/test_openapi_runtime_fixtures.py -q
|
|
python src/manage.py generate_swagger /openapi-build/swagger.json --overwrite --format=json --settings=settings.test
|
|
'
|
|
docker cp "${ci_container}:/openapi-build/." .openapi-build
|
|
|
|
- name: Verify canonical OpenAPI and generated client
|
|
working-directory: tools/openapi
|
|
run: |
|
|
set -euo pipefail
|
|
bun install --frozen-lockfile --ignore-scripts
|
|
bun build.mjs ../../.openapi-build/swagger.json --check
|
|
bun run apigen
|
|
bun run type-check
|
|
bun run contract-check ../../.openapi-build/fixtures.json
|
|
|
|
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
|