diff --git a/.github/workflows/deploy-develop.yml b/.github/workflows/deploy-develop.yml new file mode 100644 index 0000000..678731b --- /dev/null +++ b/.github/workflows/deploy-develop.yml @@ -0,0 +1,62 @@ +name: Deploy Develop to GSMSV + +on: + push: + branches: + - develop + workflow_dispatch: + inputs: + dry_run: + description: Run build and validation without SSH, deployment, or Discord notification + required: true + type: boolean + default: true + +permissions: + contents: read + packages: write + +concurrency: + group: gsmsv-develop-deployment + cancel-in-progress: false + +jobs: + deploy: + if: >- + github.repository == 'CKLOB/MUDDA-Server' && + github.ref == 'refs/heads/develop' && + ((github.event_name == 'workflow_dispatch' && inputs.dry_run == true) || + vars.CD_ENABLED == 'true') + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + packages: write + steps: + - name: Check out trusted develop revision + uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + persist-credentials: false + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and deploy + env: + CD_ENABLED: ${{ vars.CD_ENABLED }} + DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }} + DEPLOY_USER: ${{ secrets.DEPLOY_USER }} + DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} + DISCORD_DEPLOY_WEBHOOK_URL: ${{ secrets.DISCORD_DEPLOY_WEBHOOK_URL }} + DEPLOY_PATH: ${{ vars.DEPLOY_PATH || '/opt/mudda' }} + DRY_RUN: ${{ inputs.dry_run || false }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_ACTOR: ${{ github.actor }} + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_SHA: ${{ github.sha }} + GITHUB_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GITHUB_REF_NAME: ${{ github.ref_name }} + GITHUB_EVENT_NAME: ${{ github.event_name }} + run: bash ops/deploy/deploy.sh diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index de89e4e..5b1453f 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -11,7 +11,7 @@ services: build: context: . dockerfile: Dockerfile - image: mudda-app:prod + image: ${APP_IMAGE:-mudda-app:prod} env_file: - .env.production environment: diff --git a/docs/GSMSV_CD.md b/docs/GSMSV_CD.md new file mode 100644 index 0000000..f5695b2 --- /dev/null +++ b/docs/GSMSV_CD.md @@ -0,0 +1,50 @@ +# GSMSV CD 운영 가이드 + +이 문서는 `develop`에 반영된 MUDDA 이미지를 GHCR에 저장하고 GSMSV VM으로 배포하는 수동 확인 가능한 CD Workflow의 운영 절차를 정의한다. 실제 Secret 등록과 서버 배포는 이 변경에 포함하지 않는다. + +## 동작 범위 + +`.github/workflows/deploy-develop.yml`은 `develop` push와 수동 `workflow_dispatch`를 대상으로 한다. 자동 배포는 Repository Variable `CD_ENABLED`가 정확히 `true`일 때만 실행된다. 값이 없거나 `false`이면 push 배포 Job은 skip된다. 수동 실행은 기본 `dry_run=true`이며, 이 모드에서는 Docker 이미지 빌드와 검증만 수행하고 SSH, 서버 변경, GHCR push, Discord 전송을 수행하지 않는다. + +실제 배포를 활성화하기 전 다음 Secret을 등록해야 한다. + +- `DEPLOY_HOST` +- `DEPLOY_PORT` +- `DEPLOY_USER` +- `DEPLOY_SSH_KEY` (비밀번호가 아닌 전용 SSH private key) +- `DISCORD_DEPLOY_WEBHOOK_URL` + +선택 Variable은 `DEPLOY_PATH`이며 기본값은 `/opt/mudda`이다. `CD_ENABLED=true` 설정 전에는 dry-run으로 Workflow와 이미지 빌드만 확인한다. Secret 값은 이 저장소, Workflow 로그, Issue, PR, Discord에 기록하지 않는다. + +## 이미지와 서버 구조 + +기존 `Dockerfile`을 Buildx로 빌드하며 commit의 40자리 SHA를 불변 이미지 태그로 사용한다. + +```text +ghcr.io/cklob/mudda-server: +ghcr.io/cklob/mudda-server:develop +``` + +배포 서버에는 `/opt/mudda/docker-compose.prod.yml`, `/opt/mudda/.env.production`, PostGIS 초기화 SQL 파일과 Firebase credential 디렉터리가 이미 준비되어 있어야 한다. 서버의 `.env.production`은 Actions로 가져오거나 출력하지 않는다. GHCR private package를 사용할 경우 서버 Docker daemon에 최소 read 권한의 사전 인증을 구성해야 하며, 이 작업에서 PAT나 Secret을 생성하지 않는다. + +`docker-compose.prod.yml`은 `APP_IMAGE`가 있으면 해당 이미지를 사용하고, 없으면 기존 `mudda-app:prod` fallback을 사용한다. 따라서 기존 수동 `docker compose -f docker-compose.prod.yml build` 및 `up -d` 방식은 유지된다. 배포 스크립트는 PostgreSQL·Redis 볼륨을 삭제하거나 재생성하지 않으며 `down -v`, prune 명령을 실행하지 않는다. + +## 배포 판정과 rollback + +새 이미지를 pull한 뒤 app만 갱신하고 Compose healthcheck를 최대 30회, 5초 간격으로 확인한다. readiness가 healthy가 되어야 성공으로 판정한다. 실패하면 이전에 실행 중이던 SHA 이미지가 확인되는 경우에만 app을 이전 이미지로 되돌리고 readiness를 다시 확인한다. rollback은 app 컨테이너에만 적용되며 DB migration을 되돌리지 않는다. + +Flyway migration이 이미 적용된 뒤 이전 애플리케이션으로 rollback하면 스키마와 애플리케이션 간 비호환이 생길 수 있다. 따라서 breaking migration은 사전 호환성을 검토하고, 자동 DB rollback은 수행하지 않는다. 실패 시 Actions 로그에는 Compose 상태와 app의 최근 일부 정제 로그만 남긴다. + +## Discord 알림 + +배포 시작·성공·실패를 `DISCORD_DEPLOY_WEBHOOK_URL` Secret으로만 전송한다. 실패 알림에는 실패 단계, 종료 코드, rollback 결과, 실행 URL과 최대 약 2,500자의 정제 로그만 포함한다. webhook URL, JWT, Authorization, Cookie, password, AWS key, 이메일 등 민감정보 패턴을 마스킹하며 전체 로그나 `.env.production`은 전송하지 않는다. Webhook 전송 실패는 원래 배포 결과를 성공으로 바꾸지 않는다. + +## 최초 활성화 절차 + +1. 서버에 Docker Compose 파일, 운영 `.env.production`, credential 파일을 준비하고 권한을 확인한다. +2. GitHub Actions에 전용 SSH key와 위 Secret을 등록한다. private GHCR package라면 서버 pull 인증도 별도로 준비한다. +3. `workflow_dispatch`에서 기본 `dry_run=true`를 실행해 Workflow와 이미지 빌드를 확인한다. +4. 테스트 결과를 확인한 뒤 Repository Variable `CD_ENABLED=true`를 설정한다. +5. 다음 `develop` 반영에서 실제 배포와 readiness, Discord 알림을 확인한다. + +실제 Secret 등록, Workflow 실행, SSH 접속, 서버 배포는 이 작업에서 수행하지 않았다. 장애 시 서버에서 `docker compose -f docker-compose.prod.yml ps`, `docker compose -f docker-compose.prod.yml logs --tail 100 app`으로 확인하고, 필요하면 문서화된 SHA 이미지로 수동 rollback하되 DB migration 위험을 먼저 검토한다. Nginx와 Certbot은 사용하지 않는다. diff --git a/ops/deploy/deploy.sh b/ops/deploy/deploy.sh new file mode 100755 index 0000000..557a88b --- /dev/null +++ b/ops/deploy/deploy.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +root_dir="$(cd "$script_dir/../.." && pwd)" +step="initialization" +remote_output="" +rollback_result="not attempted" +started_at="$(date +%s)" + +mask_value() { + local value="$1" line + while IFS= read -r line || [[ -n "$line" ]]; do + [[ -n "$line" ]] && printf '::add-mask::%s\n' "$line" + done <<< "$value" +} + +required_secret() { + local name="$1" value="${!1:-}" + if [[ -z "$value" ]]; then + echo "Missing required secret: $name" >&2 + return 1 + fi + mask_value "$value" +} + +redact_output() { + MAX_CHARS=2500 "$script_dir/redact-log.sh" +} + +notify() { + local event="$1" + [[ "$DRY_RUN" == true ]] && return 0 + set +e + DEPLOY_LOG_EXCERPT="${REMOTE_OUTPUT:-}" \ + DEPLOY_ROLLBACK_RESULT="$rollback_result" \ + DEPLOY_FAILED_STEP="$step" \ + DEPLOY_EXIT_CODE="${DEPLOY_EXIT_CODE:-1}" \ + "$script_dir/discord-notify.sh" "$event" + local result=$? + set -e + if (( result != 0 )); then echo "Discord notification status: failed" >&2; fi + return 0 +} + +on_error() { + local exit_code=$? + DEPLOY_EXIT_CODE="$exit_code" + if [[ "$DRY_RUN" != true ]]; then + REMOTE_OUTPUT="$(printf '%s' "${REMOTE_OUTPUT:-}" | redact_output)" + notify failed + fi + echo "Deployment failed at step: $step (exit code: $exit_code)" >&2 + exit "$exit_code" +} +trap on_error ERR + +DRY_RUN="${DRY_RUN:-false}" +[[ "$DRY_RUN" == true || "$DRY_RUN" == false ]] || { echo "DRY_RUN must be true or false" >&2; exit 2; } + +git_sha="${GITHUB_SHA:-$(git rev-parse HEAD)}" +[[ "$git_sha" =~ ^[0-9a-f]{40}$ ]] || { echo "GITHUB_SHA must be a 40-character SHA" >&2; exit 2; } +short_sha="${git_sha:0:7}" +commit_message="$(git -C "$root_dir" log -1 --format=%s "$git_sha" 2>/dev/null || git -C "$root_dir" log -1 --format=%s)" +repo="${GITHUB_REPOSITORY:-CKLOB/MUDDA-Server}" +repo_lc="${repo,,}" +image_sha="ghcr.io/${repo_lc}:${git_sha}" +image_branch="ghcr.io/${repo_lc}:develop" + +export DEPLOY_SHORT_SHA="$short_sha" +export DEPLOY_COMMIT_MESSAGE="$commit_message" +export DEPLOY_IMAGE_TAG="$image_sha" +export DEPLOY_TARGET="${DEPLOY_HOST:-GSMSV}" +export DEPLOY_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +export DEPLOY_ENVIRONMENT="develop" + +step="validation" +if [[ "$DRY_RUN" == true ]]; then + echo "Dry-run enabled: SSH, GHCR push, deployment, and Discord notification are disabled." +else + required_secret DEPLOY_HOST + required_secret DEPLOY_PORT + required_secret DEPLOY_USER + required_secret DEPLOY_SSH_KEY + required_secret DISCORD_DEPLOY_WEBHOOK_URL + [[ "$DEPLOY_HOST" != *[[:space:]]* ]] || { echo "DEPLOY_HOST contains whitespace" >&2; exit 2; } + [[ "$DEPLOY_PORT" =~ ^[1-9][0-9]{0,4}$ ]] || { echo "DEPLOY_PORT is invalid" >&2; exit 2; } + [[ "$DEPLOY_USER" =~ ^[A-Za-z_][A-Za-z0-9_.-]*$ ]] || { echo "DEPLOY_USER is invalid" >&2; exit 2; } + [[ "${DEPLOY_PATH:-/opt/mudda}" =~ ^/opt/mudda(/[A-Za-z0-9._-]+)*$ ]] || { echo "DEPLOY_PATH is invalid" >&2; exit 2; } +fi + +step="build" +docker buildx build --file "$root_dir/Dockerfile" --tag "$image_sha" --load "$root_dir" +if [[ "$DRY_RUN" == true ]]; then + echo "Dry-run build completed for commit $short_sha." + exit 0 +fi + +step="notification-start" +notify started +step="registry-login" +printf '%s' "$GITHUB_TOKEN" | docker login ghcr.io --username "${GITHUB_ACTOR:-github-actions[bot]}" --password-stdin >/dev/null +step="image-push" +docker buildx build --file "$root_dir/Dockerfile" \ + --tag "$image_sha" --tag "$image_branch" \ + --cache-from type=gha --cache-to type=gha,mode=max --push "$root_dir" + +step="server-deployment" +key_file="$(mktemp)" +remote_output_file="$(mktemp)" +cleanup() { rm -f "$key_file" "$remote_output_file"; } +trap cleanup EXIT +chmod 600 "$key_file" +printf '%s\n' "$DEPLOY_SSH_KEY" > "$key_file" +DEPLOY_PATH="${DEPLOY_PATH:-/opt/mudda}" +ssh -i "$key_file" -p "$DEPLOY_PORT" \ + -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new \ + "$DEPLOY_USER@$DEPLOY_HOST" bash -s -- "$image_sha" "$DEPLOY_PATH" \ + < "$script_dir/remote-deploy.sh" > "$remote_output_file" 2>&1 || { + REMOTE_OUTPUT="$(cat "$remote_output_file")" + rollback_result="$(printf '%s\n' "$REMOTE_OUTPUT" | sed -n 's/^ROLLBACK_RESULT=//p' | tail -n 1)" + rollback_result="${rollback_result:-not attempted}" + exit 1 + } +REMOTE_OUTPUT="$(cat "$remote_output_file")" +rollback_result="$(printf '%s\n' "$REMOTE_OUTPUT" | sed -n 's/^ROLLBACK_RESULT=//p' | tail -n 1)" +rollback_result="${rollback_result:-not needed}" + +step="notification-success" +finished_at="$(date +%s)" +export DEPLOY_DURATION="$((finished_at - started_at)) seconds" +notify succeeded +echo "Deployment succeeded: $image_sha" diff --git a/ops/deploy/discord-notify.sh b/ops/deploy/discord-notify.sh new file mode 100755 index 0000000..eb3d2d8 --- /dev/null +++ b/ops/deploy/discord-notify.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +set -euo pipefail + +event="${1:-}" +case "$event" in + started|succeeded|failed) ;; + *) echo "usage: discord-notify.sh " >&2; exit 2 ;; +esac + +webhook_url="${DISCORD_DEPLOY_WEBHOOK_URL:-}" +[[ -n "$webhook_url" ]] || { echo "Discord deployment webhook is not configured" >&2; exit 1; } +[[ "$webhook_url" =~ ^https://discord(app)?\.com/api/webhooks/ ]] || { + echo "Discord deployment webhook has an invalid host" >&2 + exit 1 +} +command -v jq >/dev/null || { echo "jq is required for Discord deployment notifications" >&2; exit 1; } +command -v curl >/dev/null || { echo "curl is required for Discord deployment notifications" >&2; exit 1; } + +payload_file="$(mktemp)" +config_file="$(mktemp)" +error_file="$(mktemp)" +trap 'rm -f "$payload_file" "$config_file" "$error_file"' EXIT + +trim() { + local value="$1" + printf '%s' "$value" | awk 'BEGIN { ORS="" } { if (length(out)) out=out "\\n"; out=out $0 } END { print substr(out, 1, 1000) }' +} + +commit_message="$(trim "${DEPLOY_COMMIT_MESSAGE:-unknown}")" +log_excerpt="$(printf '%s' "${DEPLOY_LOG_EXCERPT:-}" | MAX_CHARS=2500 "$(dirname "$0")/redact-log.sh")" +image_tag="${DEPLOY_IMAGE_TAG:-unknown}" +target="${DEPLOY_TARGET:-GSMSV}" +run_url="${GITHUB_RUN_URL:-unknown}" +now="${DEPLOY_TIME:-$(date -u +%Y-%m-%dT%H:%M:%SZ)}" + +case "$event" in + started) + payload="$(jq -n \ + --arg env "${DEPLOY_ENVIRONMENT:-develop}" \ + --arg branch "${GITHUB_REF_NAME:-develop}" \ + --arg sha "${DEPLOY_SHORT_SHA:-unknown}" \ + --arg message "$commit_message" \ + --arg actor "${GITHUB_ACTOR:-unknown}" \ + --arg url "$run_url" \ + --arg time "$now" \ + '{embeds: [{title: "GSMSV 배포 시작", color: 16753920, fields: [ + {name: "환경", value: $env, inline: true}, + {name: "브랜치", value: $branch, inline: true}, + {name: "Commit", value: ($sha + "\\n" + $message), inline: false}, + {name: "요청자", value: $actor, inline: true}, + {name: "실행", value: ("[GitHub Actions]\\(" + $url + "\\)"), inline: true}, + {name: "시작 시각", value: $time, inline: true} + ]}]}')" ;; + succeeded) + payload="$(jq -n \ + --arg image "$image_tag" \ + --arg target "$target" \ + --arg endpoint "${DEPLOY_HTTPS_URL:-https://mudda-api.https.gsmsv.site}" \ + --arg duration "${DEPLOY_DURATION:-unknown}" \ + --arg url "$run_url" \ + --arg time "$now" \ + '{embeds: [{title: "GSMSV 배포 성공", color: 5763719, fields: [ + {name: "상태", value: "성공", inline: true}, + {name: "이미지", value: $image, inline: false}, + {name: "대상", value: $target, inline: true}, + {name: "주소", value: $endpoint, inline: false}, + {name: "소요 시간", value: $duration, inline: true}, + {name: "실행", value: ("[GitHub Actions]\\(" + $url + "\\)"), inline: true}, + {name: "완료 시각", value: $time, inline: true} + ]}]}')" ;; + failed) + payload="$(jq -n \ + --arg step "${DEPLOY_FAILED_STEP:-unknown}" \ + --arg code "${DEPLOY_EXIT_CODE:-unknown}" \ + --arg rollback "${DEPLOY_ROLLBACK_RESULT:-not attempted}" \ + --arg logs "$log_excerpt" \ + --arg url "$run_url" \ + --arg time "$now" \ + '{embeds: [{title: "GSMSV 배포 실패", color: 15548997, fields: [ + {name: "실패 단계", value: $step, inline: true}, + {name: "종료 코드", value: $code, inline: true}, + {name: "Rollback", value: $rollback, inline: true}, + {name: "요약 로그", value: (if $logs == "" then "(없음)" else ("```\\n" + $logs + "\\n```") end), inline: false}, + {name: "실행", value: ("[GitHub Actions]\\(" + $url + "\\)"), inline: true}, + {name: "발생 시각", value: $time, inline: true} + ]}]}')" ;; +esac + +printf '%s' "$payload" > "$payload_file" +{ + printf 'url = "' + printf '%s' "$webhook_url" + printf '"\nrequest = "POST"\nheader = "Content-Type: application/json"\ndata-binary = @%s\n' "$payload_file" +} > "$config_file" + +if ! curl --silent --show-error --fail --max-time 15 --config "$config_file" >/dev/null 2>"$error_file"; then + echo "Discord deployment notification failed" >&2 + exit 1 +fi diff --git a/ops/deploy/redact-log.sh b/ops/deploy/redact-log.sh new file mode 100755 index 0000000..76a708d --- /dev/null +++ b/ops/deploy/redact-log.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail + +max_chars="${MAX_CHARS:-2500}" +[[ "$max_chars" =~ ^[1-9][0-9]*$ ]] || max_chars=2500 + +sed -E \ + -e 's#https://discord(app)?\.com/api/webhooks/[[:alnum:]_/-]+#[REDACTED_URL]#gI' \ + -e 's/(Bearer[[:space:]]+)[^[:space:]]+/\1[REDACTED]/gI' \ + -e 's/((password|passwd|secret|token|jwt|authorization|cookie|api[-_]?key|client[-_]?secret|access[-_]?key|secret[-_]?key)[[:space:]]*[:=][[:space:]]*)[^,[:space:]]+/\1[REDACTED]/gI' \ + -e 's/AKIA[0-9A-Z]{16}/[REDACTED_AWS_KEY]/g' \ + -e 's/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/[REDACTED_JWT]/g' \ + -e 's/[[:alnum:]._%+-]+@[[:alnum:].-]+\.[A-Za-z]{2,}/[REDACTED_EMAIL]/g' \ + -e 's/::/ : :/g' | + awk -v limit="$max_chars" 'BEGIN { ORS="" } { remaining=limit-length(out); if (remaining > 0) out=out substr($0, 1, remaining) "\n" } END { printf "%s", substr(out, 1, limit) }' diff --git a/ops/deploy/remote-deploy.sh b/ops/deploy/remote-deploy.sh new file mode 100755 index 0000000..0361af3 --- /dev/null +++ b/ops/deploy/remote-deploy.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +set -euo pipefail + +image="${1:-}" +deploy_path="${2:-}" +[[ "$image" =~ ^ghcr\.io/cklob/mudda-server:[0-9a-f]{40}$ ]] || { echo "invalid deployment image"; exit 2; } +[[ "$deploy_path" =~ ^/opt/mudda(/[A-Za-z0-9._-]+)*$ ]] || { echo "invalid deployment path"; exit 2; } +cd "$deploy_path" + +compose_file="$deploy_path/docker-compose.prod.yml" +env_file="$deploy_path/.env.production" +init_sql="$deploy_path/src/main/resources/db/init/001_enable_postgis.sql" +[[ -f "$compose_file" && -f "$env_file" && -f "$init_sql" ]] || { echo "required deployment files are missing"; exit 1; } + +compose=(docker compose --env-file "$env_file" -f "$compose_file") +previous_image="" +app_id="$("${compose[@]}" ps -q app 2>/dev/null || true)" +if [[ -n "$app_id" ]]; then + previous_image="$(docker inspect --format '{{.Config.Image}}' "$app_id" 2>/dev/null || true)" +fi + +redact() { + MAX_CHARS=2500 "$(dirname "$0")/redact-log.sh" +} + +diagnostics() { + echo "COMPOSE_STATUS_START" + "${compose[@]}" ps --all 2>/dev/null | redact || true + echo "COMPOSE_STATUS_END" + echo "APP_LOGS_START" + app_id="$("${compose[@]}" ps -q app 2>/dev/null || true)" + if [[ -n "$app_id" ]]; then docker logs --tail 100 "$app_id" 2>&1 | redact || true; fi + echo "APP_LOGS_END" +} + +wait_for_readiness() { + local attempt=1 status health + while (( attempt <= 30 )); do + app_id="$("${compose[@]}" ps -q app 2>/dev/null || true)" + if [[ -n "$app_id" ]]; then + status="$(docker inspect --format '{{.State.Status}}' "$app_id" 2>/dev/null || true)" + health="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$app_id" 2>/dev/null || true)" + if [[ "$status" == running && "$health" == healthy ]]; then return 0; fi + fi + sleep 5 + ((attempt++)) + done + return 1 +} + +rollback() { + if [[ "$previous_image" =~ ^ghcr\.io/cklob/mudda-server:[0-9a-f]{40}$ ]]; then + echo "ROLLBACK_ATTEMPTED=true" + if APP_IMAGE="$previous_image" "${compose[@]}" pull app >/dev/null 2>&1 && + APP_IMAGE="$previous_image" "${compose[@]}" up -d --no-deps app >/dev/null 2>&1 && + wait_for_readiness; then + echo "ROLLBACK_RESULT=succeeded" + return 0 + fi + echo "ROLLBACK_RESULT=failed" + return 1 + fi + echo "ROLLBACK_ATTEMPTED=false" + echo "ROLLBACK_RESULT=unavailable" + return 1 +} + +echo "DEPLOY_STEP=pull" +if ! APP_IMAGE="$image" "${compose[@]}" pull app; then + echo "DEPLOY_RESULT=failed" + diagnostics + exit 1 +fi +echo "DEPLOY_STEP=update" +if APP_IMAGE="$image" "${compose[@]}" up -d --no-deps app && wait_for_readiness; then + echo "DEPLOY_RESULT=succeeded" + echo "DEPLOYED_IMAGE=$image" + exit 0 +fi + +echo "DEPLOY_STEP=readiness" +diagnostics +rollback || true +echo "DEPLOY_RESULT=failed" +exit 1 diff --git a/ops/deploy/tests/test.sh b/ops/deploy/tests/test.sh new file mode 100755 index 0000000..5b837b8 --- /dev/null +++ b/ops/deploy/tests/test.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +set -euo pipefail + +test_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +deploy_dir="$(cd "$test_dir/.." && pwd)" +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT + +bash -n "$deploy_dir"/*.sh + +redacted="$(printf '%s' 'Bearer abc password=secret AKIA1234567890ABCDEF https://discord.com/api/webhooks/123/token a@b.example :: 1234567890' | MAX_CHARS=2500 "$deploy_dir/redact-log.sh")" +[[ "$redacted" != *'abc'* && "$redacted" != *'secret'* && "$redacted" != *'AKIA1234567890ABCDEF'* ]] +[[ "$redacted" != *'discord.com/api/webhooks/123/token'* && "$redacted" != *'a@b.example'* && "$redacted" != *'::'* ]] +[[ "${#redacted}" -le 2500 ]] + +fake_bin="$tmp_dir/bin" +mkdir -p "$fake_bin" +cat > "$fake_bin/curl" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +config="" +while (($#)); do + [[ "$1" == --config ]] && config="$2" && shift 2 && continue + shift +done +data_file="$(sed -n 's/^data-binary = @//p' "$config")" +cp "$data_file" "${FAKE_CURL_PAYLOAD:?}" +EOF +chmod +x "$fake_bin/curl" + +payload="$tmp_dir/payload.json" +PATH="$fake_bin:$PATH" \ +FAKE_CURL_PAYLOAD="$payload" \ +DISCORD_DEPLOY_WEBHOOK_URL='https://discord.com/api/webhooks/test/token' \ +DEPLOY_COMMIT_MESSAGE=$'message "quoted"\nsecond line' \ +GITHUB_RUN_URL='https://github.com/CKLOB/MUDDA-Server/actions/runs/1' \ + "$deploy_dir/discord-notify.sh" started +jq -e '.embeds | length == 1 and (.[0].fields | length == 6)' "$payload" >/dev/null +jq -e '.embeds[0].fields[2].value | contains("quoted")' "$payload" >/dev/null + +if DISCORD_DEPLOY_WEBHOOK_URL='' "$deploy_dir/discord-notify.sh" started 2>"$tmp_dir/missing.err"; then + echo "missing webhook unexpectedly succeeded" >&2 + exit 1 +fi +! grep -q 'discord.com' "$tmp_dir/missing.err" + +cat > "$fake_bin/docker" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +printf 'docker %s\n' "$*" >> "${FAKE_CALLS:?}" +EOF +cat > "$fake_bin/git" <<'EOF' +#!/usr/bin/env bash +if [[ "$*" == *'log -1 --format=%s'* ]]; then printf 'safe test commit'; else printf '0000000000000000000000000000000000000000'; fi +EOF +cat > "$fake_bin/ssh" <<'EOF' +#!/usr/bin/env bash +printf 'ssh called\n' >> "${FAKE_CALLS:?}" +exit 1 +EOF +chmod +x "$fake_bin/docker" "$fake_bin/git" "$fake_bin/ssh" + +calls="$tmp_dir/calls" +fake_sha=0123456789012345678901234567890123456789 +PATH="$fake_bin:$PATH" FAKE_CALLS="$calls" DRY_RUN=true GITHUB_SHA="$fake_sha" GITHUB_REPOSITORY=CKLOB/MUDDA-Server \ + "$deploy_dir/deploy.sh" >"$tmp_dir/dry-run.out" +grep -q 'Dry-run build completed' "$tmp_dir/dry-run.out" +grep -q 'docker buildx build' "$calls" +! grep -q '^ssh called' "$calls" + +echo "deployment automation tests passed"