From 6cebd8216cdb9689c446f8f247d2807936290499 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 4 Apr 2026 03:08:22 +0500 Subject: [PATCH] Automate source deployment on main --- .gitlab-ci.yml | 48 ++----- deployment/docker-compose.server.registry.yml | 2 +- deployment/docker-compose.server.yml | 4 +- docs/runbooks/gitlab-cicd.md | 80 ++++++++++++ docs/runbooks/gitlab-server-deploy.md | 4 + scripts/bootstrap_gitlab_deploy.sh | 28 ++++ scripts/deploy_gitlab.sh | 123 ++++++++++++++++++ scripts/install_gitlab_runner.sh | 72 ++++++++++ 8 files changed, 320 insertions(+), 41 deletions(-) create mode 100644 docs/runbooks/gitlab-cicd.md create mode 100755 scripts/bootstrap_gitlab_deploy.sh create mode 100755 scripts/deploy_gitlab.sh create mode 100755 scripts/install_gitlab_runner.sh diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 7d33726..d8fd57d 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -197,49 +197,21 @@ deploy-production-source: - call-center-prod needs: - test + - job: postgres-readiness + optional: true + variables: + DEPLOY_DIR: /home/gitlab-runner/deploy/call-center + APP_IMAGE_NAME: call-center-app + HEALTHCHECK_URL: http://127.0.0.1:8080/health environment: name: production before_script: - command -v rsync >/dev/null - command -v docker >/dev/null - - | - if [ -n "$DEPLOY_HOST" ] && [ -n "$DEPLOY_USER" ]; then - command -v ssh >/dev/null - mkdir -p ~/.ssh - chmod 700 ~/.ssh - printf '%s\n' "$DEPLOY_SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519 - chmod 600 ~/.ssh/id_ed25519 - ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts - fi + - command -v python3 >/dev/null script: - - export DEPLOY_TARGET_PATH="${DEPLOY_PATH:-/home/mvpcc/call-center}" - - | - if [ -n "$DEPLOY_HOST" ] && [ -n "$DEPLOY_USER" ]; then - ssh "$DEPLOY_USER@$DEPLOY_HOST" "mkdir -p '$DEPLOY_TARGET_PATH'" - rsync -av --exclude '.git/' --exclude '.tmp/' --exclude '.codex_tmp/' --exclude '.local_stack/' --exclude '.data_pg/' ./ "$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_TARGET_PATH/" - ssh "$DEPLOY_USER@$DEPLOY_HOST" \ - "mkdir -p '$DEPLOY_TARGET_PATH/.data_local/generated_ivr_yandex/ivr' '$DEPLOY_TARGET_PATH/.data_local/recordings' '$DEPLOY_TARGET_PATH/.asterisk_assets'" - ssh "$DEPLOY_USER@$DEPLOY_HOST" \ - "cd '$DEPLOY_TARGET_PATH/deployment' && \ - docker compose -f docker-compose.server.yml up -d --build && \ - docker compose -f docker-compose.asterisk.server.yml up -d --build" - else - run_local() { - if command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then - sudo -n "$@" - else - "$@" - fi - } - run_local mkdir -p "$DEPLOY_TARGET_PATH" - run_local rsync -av --exclude '.git/' --exclude '.tmp/' --exclude '.codex_tmp/' --exclude '.local_stack/' --exclude '.data_pg/' ./ "$DEPLOY_TARGET_PATH/" - run_local mkdir -p "$DEPLOY_TARGET_PATH/.data_local/generated_ivr_yandex/ivr" "$DEPLOY_TARGET_PATH/.data_local/recordings" "$DEPLOY_TARGET_PATH/.asterisk_assets" - cd "$DEPLOY_TARGET_PATH/deployment" - run_local docker compose -f docker-compose.server.yml up -d --build - run_local docker compose -f docker-compose.asterisk.server.yml up -d --build - fi + - bash scripts/deploy_gitlab.sh rules: - - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' - when: manual - - when: never + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' + - if: '$CI_PIPELINE_SOURCE == "web" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' allow_failure: false diff --git a/deployment/docker-compose.server.registry.yml b/deployment/docker-compose.server.registry.yml index 887aee3..4e43417 100644 --- a/deployment/docker-compose.server.registry.yml +++ b/deployment/docker-compose.server.registry.yml @@ -173,7 +173,7 @@ services: api-gateway: <<: *service_defaults - container_name: call-center-api-gateway + container_name: call-center-app depends_on: - auth-service - audit-service diff --git a/deployment/docker-compose.server.yml b/deployment/docker-compose.server.yml index 555322a..4cc9487 100644 --- a/deployment/docker-compose.server.yml +++ b/deployment/docker-compose.server.yml @@ -26,7 +26,7 @@ x-app-env: &app_env x-service-defaults: &service_defaults build: context: .. - image: call-center-app:local + image: ${CALL_CENTER_IMAGE:-call-center-app:latest} restart: unless-stopped env_file: - ../.env.production @@ -175,7 +175,7 @@ services: api-gateway: <<: *service_defaults - container_name: call-center-api-gateway + container_name: call-center-app depends_on: - auth-service - audit-service diff --git a/docs/runbooks/gitlab-cicd.md b/docs/runbooks/gitlab-cicd.md new file mode 100644 index 0000000..92ca539 --- /dev/null +++ b/docs/runbooks/gitlab-cicd.md @@ -0,0 +1,80 @@ +# GitLab CI/CD for call-center + +## What this setup does + +- a push to `main` triggers GitLab CI +- the job runs on a dedicated `shell` runner with tag `call-center-prod` +- the runner syncs the repository into `/home/gitlab-runner/deploy/call-center` +- Docker builds `call-center-app:` and also tags `call-center-app:latest` +- `docker compose` recreates the stack from `deployment/docker-compose.server.yml` +- the public gateway container is named `call-center-app` + +## Required server prerequisites + +- Docker installed and running +- outbound access to `https://gitlab.konturai.kz` +- runner token from GitLab with prefix `glrt-...` or a valid project/group runner token + +## Install and register the runner + +Run on the target server as `root`: + +```bash +cd /path/to/call-center +RUNNER_TOKEN=glrt-xxxxxxxx bash scripts/install_gitlab_runner.sh +``` + +The script: + +- installs `gitlab-runner` from the official GitLab repository +- adds user `gitlab-runner` to the `docker` group +- creates deploy directory `/home/gitlab-runner/deploy/call-center` +- registers runner `call-center-prod-runner` with tag `call-center-prod` + +If you first want to install the service without registration: + +```bash +SKIP_REGISTER=1 bash scripts/install_gitlab_runner.sh +``` + +## Production environment file + +Create the production env file once on the server: + +```bash +install -m 600 /dev/null /home/gitlab-runner/deploy/call-center/.env.production +``` + +Then fill it with the values required by `deployment/docker-compose.server.yml`. + +If the project is already running from `/root/call-center`, migrate the current env file and SQLite/files before the first CI deploy: + +```bash +bash scripts/bootstrap_gitlab_deploy.sh +``` + +Alternative: + +- keep the env file elsewhere +- pass `DEPLOY_ENV_FILE=/absolute/path/to/.env.production` in GitLab CI/CD variables + +## GitLab CI/CD variables + +Optional project variables: + +- `DEPLOY_DIR` if you want a different deploy directory +- `APP_IMAGE_NAME` if you want a different Docker image name +- `HEALTHCHECK_URL` if the gateway health URL differs +- `DEPLOY_ENV_FILE` if `.env.production` should be copied from another location + +## First deployment + +1. Register the runner. +2. Add the production env file. +3. Push this configuration to the `main` branch. +4. Confirm the pipeline completes successfully. +5. Verify the container: + +```bash +docker ps --filter name=call-center-app +``` diff --git a/docs/runbooks/gitlab-server-deploy.md b/docs/runbooks/gitlab-server-deploy.md index 2590df3..c42cfcf 100644 --- a/docs/runbooks/gitlab-server-deploy.md +++ b/docs/runbooks/gitlab-server-deploy.md @@ -7,6 +7,10 @@ This project is prepared for a GitLab-first delivery flow: 3. GitLab CI builds and pushes container images to GitLab Container Registry. 4. The server only receives the deployment bundle and pulls images from the registry. +For a same-host shell-runner flow that rebuilds `call-center-app` directly on push to `main`, see: + +- `docs/runbooks/gitlab-cicd.md` + ## Files used - `.gitlab-ci.yml` diff --git a/scripts/bootstrap_gitlab_deploy.sh b/scripts/bootstrap_gitlab_deploy.sh new file mode 100755 index 0000000..dbb27ce --- /dev/null +++ b/scripts/bootstrap_gitlab_deploy.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +SOURCE_DIR="${SOURCE_DIR:-/root/call-center}" +DEPLOY_DIR="${DEPLOY_DIR:-/home/gitlab-runner/deploy/call-center}" + +if [[ "${EUID}" -ne 0 ]]; then + echo "Run this script as root so it can read the current deployment under /root." >&2 + exit 1 +fi + +if [[ ! -d "$SOURCE_DIR/.data_local" ]]; then + echo "Missing source data directory: $SOURCE_DIR/.data_local" >&2 + exit 1 +fi + +if [[ ! -f "$SOURCE_DIR/.env.production" ]]; then + echo "Missing source env file: $SOURCE_DIR/.env.production" >&2 + exit 1 +fi + +install -d -o gitlab-runner -g gitlab-runner "$DEPLOY_DIR" +rsync -a --delete "$SOURCE_DIR/.data_local/" "$DEPLOY_DIR/.data_local/" +install -m 600 -o gitlab-runner -g gitlab-runner "$SOURCE_DIR/.env.production" "$DEPLOY_DIR/.env.production" +chown -R gitlab-runner:gitlab-runner "$DEPLOY_DIR/.data_local" + +echo "Bootstrap complete: copied .data_local and .env.production into $DEPLOY_DIR" diff --git a/scripts/deploy_gitlab.sh b/scripts/deploy_gitlab.sh new file mode 100755 index 0000000..4733e6d --- /dev/null +++ b/scripts/deploy_gitlab.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +DEPLOY_DIR="${DEPLOY_DIR:-/home/gitlab-runner/deploy/call-center}" +APP_IMAGE_NAME="${APP_IMAGE_NAME:-call-center-app}" +APP_IMAGE_TAG="${APP_IMAGE_TAG:-${CI_COMMIT_SHORT_SHA:-latest}}" +COMPOSE_FILE="${COMPOSE_FILE:-$DEPLOY_DIR/deployment/docker-compose.server.yml}" +HEALTHCHECK_URL="${HEALTHCHECK_URL:-http://127.0.0.1:8080/health}" +HEALTHCHECK_TIMEOUT_SECONDS="${HEALTHCHECK_TIMEOUT_SECONDS:-120}" + +require_command() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "Missing required command: $1" >&2 + exit 1 + fi +} + +wait_for_health() { + python3 - "$HEALTHCHECK_URL" "$HEALTHCHECK_TIMEOUT_SECONDS" <<'PY' +import sys +import time +import urllib.request + +url = sys.argv[1] +timeout_seconds = int(sys.argv[2]) +deadline = time.time() + timeout_seconds +last_error = None + +while time.time() < deadline: + try: + with urllib.request.urlopen(url, timeout=5) as response: + if response.getcode() == 200: + print(f"Healthcheck passed: {url}") + sys.exit(0) + last_error = f"unexpected status {response.getcode()}" + except Exception as exc: + last_error = str(exc) + time.sleep(3) + +print(f"Healthcheck failed for {url}: {last_error}", file=sys.stderr) +sys.exit(1) +PY +} + +require_command docker +require_command rsync +require_command python3 + +if docker compose version >/dev/null 2>&1; then + COMPOSE_CMD=(docker compose) +elif command -v docker-compose >/dev/null 2>&1; then + COMPOSE_CMD=(docker-compose) +else + echo "Missing docker compose or docker-compose" >&2 + exit 1 +fi + +if [[ -z "${CI_PROJECT_DIR:-}" || ! -d "${CI_PROJECT_DIR:-}" ]]; then + echo "CI_PROJECT_DIR is not set or does not exist" >&2 + exit 1 +fi + +if ! docker info >/dev/null 2>&1; then + echo "Docker daemon is not reachable for the runner user" >&2 + exit 1 +fi + +install -d "$DEPLOY_DIR" +cd "$DEPLOY_DIR" + +if [[ "$CI_PROJECT_DIR" != "$DEPLOY_DIR" ]]; then + rsync -a --delete \ + --exclude '.git/' \ + --exclude '.env.production' \ + --exclude '.env.production.*' \ + --exclude '.data/' \ + --exclude '.data_local/' \ + --exclude '.data_gate3/' \ + --exclude '.data_gate4/' \ + --exclude '.data_uat_dry_run/' \ + --exclude '.data_uat_preflight/' \ + --exclude '.venv/' \ + --exclude '.pytest_cache/' \ + --exclude '.local_stack/' \ + --exclude 'test-results/' \ + --exclude '.codex_backup/' \ + --exclude '.db_backups/' \ + --exclude '.deploy_backups/' \ + "$CI_PROJECT_DIR/" "$DEPLOY_DIR/" +fi + +if [[ ! -f "$DEPLOY_DIR/.env.production" ]]; then + if [[ -n "${DEPLOY_ENV_FILE:-}" && -f "${DEPLOY_ENV_FILE}" ]]; then + install -m 600 "$DEPLOY_ENV_FILE" "$DEPLOY_DIR/.env.production" + else + echo "Missing $DEPLOY_DIR/.env.production. Create it once on the server or set DEPLOY_ENV_FILE." >&2 + exit 1 + fi +fi + +install -d "$DEPLOY_DIR/.data_local" + +export CALL_CENTER_IMAGE="${APP_IMAGE_NAME}:${APP_IMAGE_TAG}" +export COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-call-center}" + +echo "Deploy directory: $DEPLOY_DIR" +echo "Building image: $CALL_CENTER_IMAGE" + +docker build -t "$CALL_CENTER_IMAGE" -t "${APP_IMAGE_NAME}:latest" "$DEPLOY_DIR" + +if docker ps -a --format '{{.Names}}' | grep -Fxq 'call-center-api-gateway' \ + && ! docker ps -a --format '{{.Names}}' | grep -Fxq 'call-center-app'; then + echo "Removing legacy gateway container call-center-api-gateway before rename to call-center-app" + docker rm -f call-center-api-gateway +fi + +"${COMPOSE_CMD[@]}" -f "$COMPOSE_FILE" config -q +"${COMPOSE_CMD[@]}" -f "$COMPOSE_FILE" up -d --remove-orphans --force-recreate + +wait_for_health + +docker ps --filter "name=^call-center-app$" --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}' diff --git a/scripts/install_gitlab_runner.sh b/scripts/install_gitlab_runner.sh new file mode 100755 index 0000000..dc8fd1f --- /dev/null +++ b/scripts/install_gitlab_runner.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +GITLAB_URL="${GITLAB_URL:-https://gitlab.konturai.kz/}" +RUNNER_NAME="${RUNNER_NAME:-call-center-prod-runner}" +RUNNER_TAGS="${RUNNER_TAGS:-call-center-prod}" +RUNNER_EXECUTOR="${RUNNER_EXECUTOR:-shell}" +RUNNER_TOKEN="${RUNNER_TOKEN:-}" +DEPLOY_DIR="${DEPLOY_DIR:-/home/gitlab-runner/deploy/call-center}" +SKIP_REGISTER="${SKIP_REGISTER:-0}" + +if [[ "${EUID}" -ne 0 ]]; then + echo "Run this script as root." >&2 + exit 1 +fi + +if [[ "$SKIP_REGISTER" != "1" && -z "$RUNNER_TOKEN" ]]; then + echo "Set RUNNER_TOKEN from GitLab project/group runner settings before running this script." >&2 + exit 1 +fi + +apt-get update +apt-get install -y ca-certificates curl + +if ! command -v gitlab-runner >/dev/null 2>&1; then + curl -L "https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh" | bash + apt-get install -y gitlab-runner +fi + +if ! getent group docker >/dev/null 2>&1; then + echo "Docker group does not exist. Install Docker before registering the runner." >&2 + exit 1 +fi + +usermod -aG docker gitlab-runner +install -d -o gitlab-runner -g gitlab-runner "$(dirname "$DEPLOY_DIR")" +install -d -o gitlab-runner -g gitlab-runner "$DEPLOY_DIR" + +if [[ "$SKIP_REGISTER" == "1" ]]; then + echo "gitlab-runner installed. Registration skipped because SKIP_REGISTER=1." +elif gitlab-runner list 2>/dev/null | grep -Fq "$RUNNER_NAME"; then + echo "Runner $RUNNER_NAME is already registered." +else + register_args=( + --non-interactive + --url "$GITLAB_URL" + --executor "$RUNNER_EXECUTOR" + --description "$RUNNER_NAME" + --tag-list "$RUNNER_TAGS" + --run-untagged="false" + --locked="true" + ) + if [[ "$RUNNER_TOKEN" == glrt-* ]]; then + register_args+=(--token "$RUNNER_TOKEN") + else + register_args+=(--registration-token "$RUNNER_TOKEN") + fi + gitlab-runner register "${register_args[@]}" +fi + +systemctl enable --now gitlab-runner +systemctl restart gitlab-runner +if [[ "$SKIP_REGISTER" != "1" ]]; then + gitlab-runner verify +fi + +if [[ "$SKIP_REGISTER" == "1" ]]; then + echo "gitlab-runner service is installed and running." +else + echo "Runner $RUNNER_NAME is ready." +fi