From 78c2425278c558a74603ed3d6a929ccb506f900f Mon Sep 17 00:00:00 2001 From: itzjb Date: Thu, 6 Aug 2026 01:18:22 +0900 Subject: [PATCH] =?UTF-8?q?chore:=20=EC=8B=9C=ED=81=AC=EB=A6=BF=20?= =?UTF-8?q?=EB=8F=99=EA=B8=B0=ED=99=94=20=ED=8C=8C=EC=9D=B4=ED=94=84?= =?UTF-8?q?=EB=9D=BC=EC=9D=B8=20=EC=9B=90=EB=B3=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #79(PR #80)로 추가한 render-secrets.sh·sync-config 워크플로·forced command sync-config verb·운영 문서를 되돌린다. 서버측 설정 파일·스크립트·sudoers와 GitHub Secrets(PROD_*)도 함께 원복했다. --- .github/workflows/sync-config.yml | 95 --------------- deploy/README.md | 61 +--------- deploy/receive-deploy.sh | 52 +++----- deploy/render-secrets.sh | 190 ------------------------------ 4 files changed, 19 insertions(+), 379 deletions(-) delete mode 100644 .github/workflows/sync-config.yml delete mode 100755 deploy/render-secrets.sh diff --git a/.github/workflows/sync-config.yml b/.github/workflows/sync-config.yml deleted file mode 100644 index ca1e2d0..0000000 --- a/.github/workflows/sync-config.yml +++ /dev/null @@ -1,95 +0,0 @@ -# 프로덕션 시크릿 동기화 — GitHub Secrets(PROD_*)를 정본으로 서버 server-secrets.env를 렌더한다. -# 수동 실행 전용. 본문은 SSH stdin으로만 전달되며 러너 로그에는 요약만 남는다. -name: Sync Config - -on: - workflow_dispatch: - inputs: - dry_run: - description: "변경 키 이름만 보고(파일·서버 무변경)" - type: boolean - default: false - no_restart: - description: "렌더만 하고 재시작 생략(다음 재시작 때 반영)" - type: boolean - default: false - -concurrency: - group: deploy-production - cancel-in-progress: false - -permissions: - contents: read - -jobs: - sync: - runs-on: ubuntu-latest - environment: production - steps: - - name: Assemble payload - env: - PROD_DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }} - PROD_AUTH_ACCESS_TOKEN_KEY_BASE64: ${{ secrets.PROD_AUTH_ACCESS_TOKEN_KEY_BASE64 }} - PROD_AUTH_PROVIDER_TOKEN_ENCRYPTION_KEY_BASE64: ${{ secrets.PROD_AUTH_PROVIDER_TOKEN_ENCRYPTION_KEY_BASE64 }} - PROD_SMTP_PASSWORD: ${{ secrets.PROD_SMTP_PASSWORD }} - PROD_REDIS_PASSWORD: ${{ secrets.PROD_REDIS_PASSWORD }} - PROD_TURN_USERNAME: ${{ secrets.PROD_TURN_USERNAME }} - PROD_TURN_CREDENTIAL: ${{ secrets.PROD_TURN_CREDENTIAL }} - PROD_YOUTUBE_STREAM_KEY: ${{ secrets.PROD_YOUTUBE_STREAM_KEY }} - PROD_APPLE_TEAM_ID: ${{ secrets.PROD_APPLE_TEAM_ID }} - PROD_APPLE_KEY_ID: ${{ secrets.PROD_APPLE_KEY_ID }} - run: | - fail=0 - require() { - if [ -z "$2" ]; then echo "MISSING SECRET: $1"; fail=1; fi - } - require PROD_DATABASE_URL "$PROD_DATABASE_URL" - require PROD_AUTH_ACCESS_TOKEN_KEY_BASE64 "$PROD_AUTH_ACCESS_TOKEN_KEY_BASE64" - require PROD_AUTH_PROVIDER_TOKEN_ENCRYPTION_KEY_BASE64 "$PROD_AUTH_PROVIDER_TOKEN_ENCRYPTION_KEY_BASE64" - require PROD_SMTP_PASSWORD "$PROD_SMTP_PASSWORD" - require PROD_TURN_USERNAME "$PROD_TURN_USERNAME" - require PROD_TURN_CREDENTIAL "$PROD_TURN_CREDENTIAL" - require PROD_APPLE_TEAM_ID "$PROD_APPLE_TEAM_ID" - require PROD_APPLE_KEY_ID "$PROD_APPLE_KEY_ID" - [ "$fail" -eq 0 ] || exit 1 - { - printf 'DATABASE_URL=%s\n' "$PROD_DATABASE_URL" - printf 'AUTH_ACCESS_TOKEN_KEY_BASE64=%s\n' "$PROD_AUTH_ACCESS_TOKEN_KEY_BASE64" - printf 'AUTH_PROVIDER_TOKEN_ENCRYPTION_KEY_BASE64=%s\n' "$PROD_AUTH_PROVIDER_TOKEN_ENCRYPTION_KEY_BASE64" - printf 'AUTH_EMAIL_SMTP_PASSWORD=%s\n' "$PROD_SMTP_PASSWORD" - printf 'WEBRTC_TURN_USERNAME=%s\n' "$PROD_TURN_USERNAME" - printf 'WEBRTC_TURN_CREDENTIAL=%s\n' "$PROD_TURN_CREDENTIAL" - printf 'APPLE_TEAM_ID=%s\n' "$PROD_APPLE_TEAM_ID" - printf 'APPLE_KEY_ID=%s\n' "$PROD_APPLE_KEY_ID" - printf 'YOUTUBE_STREAM_KEY=%s\n' "$PROD_YOUTUBE_STREAM_KEY" - printf 'AUTH_EMAIL_REDIS_PASSWORD=%s\n' "$PROD_REDIS_PASSWORD" - } > "$RUNNER_TEMP/secrets.env" - - - name: Setup SSH - run: | - mkdir -p ~/.ssh - chmod 700 ~/.ssh - printf '%s\n' "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/deploy_key - chmod 600 ~/.ssh/deploy_key - printf '%s\n' "${{ secrets.DEPLOY_SSH_KNOWN_HOSTS }}" > ~/.ssh/known_hosts - chmod 600 ~/.ssh/known_hosts - - # 서버의 forced command가 "sync-config [--dry-run] [--no-restart]"를 받아 - # stdin 본문을 render-secrets.sh로 위임한다. 서버 스크립트의 stdout(요약 키워드만 - # 출력하는 계약)이 이 스텝의 로그가 되고, exit code가 동기화 결과다. - - name: Sync - env: - DRY_RUN: ${{ inputs.dry_run }} - NO_RESTART: ${{ inputs.no_restart }} - run: | - flags="" - [ "$DRY_RUN" = "true" ] && flags="$flags --dry-run" - [ "$NO_RESTART" = "true" ] && flags="$flags --no-restart" - ssh -i ~/.ssh/deploy_key \ - -o UserKnownHostsFile=~/.ssh/known_hosts \ - -o StrictHostKeyChecking=yes \ - -o LogLevel=ERROR \ - -o ConnectTimeout=15 \ - -p "${{ secrets.DEPLOY_SSH_PORT }}" \ - "deploy@${{ secrets.DEPLOY_SSH_HOST }}" \ - "sync-config$flags" < "$RUNNER_TEMP/secrets.env" diff --git a/deploy/README.md b/deploy/README.md index a6d667e..f12ceac 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -29,59 +29,6 @@ PR 머지 → main push → [CI: build-and-test(필수)] → [Deploy 워크플 - **배포 실패 원인 확인**: 러너 로그에는 의도적으로 요약만 남는다. 상세는 서버의 `/opt/innolive/deploy/logs/`(root 600)와 `journalctl -u innolive-server`에서 본다. -## 시크릿 동기화 (Sync Config) - -앱 시크릿의 정본은 GitHub Actions Secrets(`PROD_*`, environment `production`)다. 서버의 -`/etc/innolive/server-secrets.env`는 Sync Config 워크플로가 만들어내는 **렌더 산출물**이며 -손으로 편집하지 않는다. 긴급하게 손편집했다면 사후에 반드시 같은 값을 Secrets에 반영한다 — -다음 동기화가 파일을 통째로 덮어쓴다. - -``` -Actions → Sync Config 실행(workflow_dispatch 전용) - → 러너가 PROD_* Secrets를 KEY=VALUE 본문으로 조립(필수 키가 비어 있으면 즉시 실패) - → SSH stdin → receive-deploy.sh "sync-config" → render-secrets.sh - → 키 화이트리스트·형식 검증(실패 시 무손상 중단) → 세션 게이트 → 원자 교체(+.bak) - → systemd 재시작 → /health 확인 → 헬스 실패 시 .bak 자동 복원 -``` - -### 값 교체 절차 - -1. GitHub → Settings → Environments → production에서 해당 `PROD_*` Secret 값을 갱신한다. -2. Actions → **Sync Config** → Run workflow. 먼저 `dry_run`을 켜고 실행해 변경될 키 - 이름을 확인한다(값·파일·서버 무변경). -3. 결과가 기대와 같으면 입력 없이 본 실행. 활성 방송 세션이 있으면 게이트가 대기하다 - `SYNC DEFERRED`로 보류될 수 있다(이 경우 파일도 바뀌지 않음 — 세션 종료 후 재실행). -4. `no_restart`를 켜면 렌더만 하고 재시작을 생략한다 — 값은 다음 재시작 때 반영된다. - -출력 판독: `SYNC OK (10 keys)` 성공 / `SYNC DRY-RUN changed:[…] added:[…] removed:[…]` -변경 예고 / `SYNC DEFERRED` 방송 중 보류 / `FAILED: …` 실패(상세는 서버 -`/opt/innolive/deploy/logs/`). - -### Secret ↔ env 키 매핑 - -| GitHub Secret | server-secrets.env 키 | 구분 | -|---|---|---| -| `PROD_DATABASE_URL` | `DATABASE_URL` | 필수 | -| `PROD_AUTH_ACCESS_TOKEN_KEY_BASE64` | `AUTH_ACCESS_TOKEN_KEY_BASE64` | 필수 | -| `PROD_AUTH_PROVIDER_TOKEN_ENCRYPTION_KEY_BASE64` | `AUTH_PROVIDER_TOKEN_ENCRYPTION_KEY_BASE64` | 필수 | -| `PROD_SMTP_PASSWORD` | `AUTH_EMAIL_SMTP_PASSWORD` | 필수 | -| `PROD_TURN_USERNAME` | `WEBRTC_TURN_USERNAME` | 필수 | -| `PROD_TURN_CREDENTIAL` | `WEBRTC_TURN_CREDENTIAL` | 필수 | -| `PROD_APPLE_TEAM_ID` | `APPLE_TEAM_ID` | 필수 | -| `PROD_APPLE_KEY_ID` | `APPLE_KEY_ID` | 필수 | -| `PROD_YOUTUBE_STREAM_KEY` | `YOUTUBE_STREAM_KEY` | 선택 — 빈 값이면 유튜브 송출 생략(기존 거동) | -| `PROD_REDIS_PASSWORD` | `AUTH_EMAIL_REDIS_PASSWORD` | 선택 — 로컬 전용·무인증 Redis 구성이면 빈 값 | - -렌더 후 `server-secrets.env`에는 위 10개 키만 존재한다. 비시크릿 설정(TTL·CORS·SMTP -호스트/포트·OAuth 클라이언트 ID 등)은 `server.env`가 정본이며 두 파일에 같은 키를 두지 -않는다. - -### 주의: GitHub Secrets는 다시 읽을 수 없다 (write-only) - -등록된 값은 워크플로 실행에서만 쓰이고 사람이 조회할 수 없다. 값의 원본 사본은 팀 내부 -금고에 보관하고, 서버에 남는 `server-secrets.env.bak-<시각>` 파일들이 직전 상태의 이력 -백업이다(렌더마다 자동 생성). - ## 서버 설치 절차 (1회, 이미 적용됨 — 재구축 시 참고) 전제: Docker, systemd 기반 innolive-ai@0/1, `/etc/innolive/server.env`·`server-secrets.env`. @@ -94,8 +41,8 @@ Actions → Sync Config 실행(workflow_dispatch 전용) INNOLIVE_APPLE_KEY_PATH= ``` - `/etc/innolive/dockerhub.token` (root 600): Docker Hub **Read-only** Access Token. -2. **배포 스크립트 설치**: 이 디렉토리의 `receive-deploy.sh`/`apply-release.sh`/ - `render-secrets.sh`를 `/opt/innolive/deploy/`에 root:root 755로 복사. `compose.prod.yaml`은 +2. **배포 스크립트 설치**: 이 디렉토리의 `receive-deploy.sh`/`apply-release.sh`를 + `/opt/innolive/deploy/`에 root:root 755로 복사. `compose.prod.yaml`은 `/opt/innolive/compose.prod.yaml`로, `innolive-server.service`는 `/etc/systemd/system/innolive-server.service`로 복사 후 `systemctl daemon-reload`. ※ 스크립트 갱신은 항상 "레포 PR 머지 → 서버에 수동 복사" 순서로 한다(자동 동기화 금지 — @@ -109,17 +56,15 @@ Actions → Sync Config 실행(workflow_dispatch 전용) - sudoers(`/etc/sudoers.d/innolive-deploy`, 440): ``` deploy ALL=(root) NOPASSWD: /opt/innolive/deploy/apply-release.sh * - deploy ALL=(root) NOPASSWD: /opt/innolive/deploy/render-secrets.sh * ``` 5. **첫 태그 파일**: `printf 'INNOLIVE_TAG=<현재 배포 커밋SHA>\n' > /opt/innolive/deploy/current_tag` 6. 구 preflight 우회(`/etc/innolive/preflight-off.env`)는 유닛 교체와 함께 제거한다. ## GitHub 설정 -- Secrets (Actions, environment `production`): `DOCKERHUB_USERNAME`, `DOCKERHUB_TOKEN`(Read&Write), +- Secrets (Actions): `DOCKERHUB_USERNAME`, `DOCKERHUB_TOKEN`(Read&Write), `DEPLOY_SSH_KEY`(deploy 계정 개인키), `DEPLOY_SSH_HOST`, `DEPLOY_SSH_PORT`, `DEPLOY_SSH_KNOWN_HOSTS`(`ssh-keyscan -p ` 고정값). -- 앱 시크릿(environment `production`): `PROD_*` 10종 — 목록·매핑은 위 "시크릿 동기화" 절 참조. - 개인 명의 자격증명은 어디에도 사용하지 않는다(팀 서비스 계정만). ## 로그 위생 (public 레포 전제) diff --git a/deploy/receive-deploy.sh b/deploy/receive-deploy.sh index 6f265f3..0a075b2 100755 --- a/deploy/receive-deploy.sh +++ b/deploy/receive-deploy.sh @@ -1,48 +1,28 @@ #!/usr/bin/env bash # deploy 계정의 SSH forced command 진입점. -# authorized_keys의 command= 로만 실행되며, 허용 형식 외에는 어떤 명령도 수행하지 않는다. -# deploy [--force] → apply-release.sh (배포) -# sync-config [--dry-run] [--no-restart] → render-secrets.sh (시크릿 렌더, 본문은 stdin) +# authorized_keys의 command= 로만 실행되며, 허용 형식("deploy [--force]") 외에는 +# 어떤 명령도 수행하지 않는다. # # 출력 계약: 이 스크립트의 stdout/stderr는 GitHub Actions 러너 로그로 그대로 전달되어 # 공개 레포에서 누구나 볼 수 있다. 단계 키워드와 커밋 SHA 외에는 아무것도 출력하지 말 것. set -euo pipefail -read -r command arg1 arg2 _extra <<<"${SSH_ORIGINAL_COMMAND:-}" || true +read -r command tag force _extra <<<"${SSH_ORIGINAL_COMMAND:-}" || true -if [[ -n "${_extra:-}" ]]; then +if [[ "${command:-}" != "deploy" || -n "${_extra:-}" ]]; then echo "DENIED" exit 1 fi -case "${command:-}" in - deploy) - # 태그는 git 커밋 SHA(7~40자리 소문자 16진수)만 허용한다. - if [[ ! "${arg1:-}" =~ ^[0-9a-f]{7,40}$ ]]; then - echo "DENIED" - exit 1 - fi - if [[ -n "${arg2:-}" && "${arg2}" != "--force" ]]; then - echo "DENIED" - exit 1 - fi - exec sudo /opt/innolive/deploy/apply-release.sh "${arg1}" ${arg2:+--force} - ;; - sync-config) - for flag in "${arg1:-}" "${arg2:-}"; do - if [[ -n "$flag" && "$flag" != "--dry-run" && "$flag" != "--no-restart" ]]; then - echo "DENIED" - exit 1 - fi - done - if [[ -n "${arg1:-}" && "${arg1}" == "${arg2:-}" ]]; then - echo "DENIED" - exit 1 - fi - exec sudo /opt/innolive/deploy/render-secrets.sh ${arg1:+"$arg1"} ${arg2:+"$arg2"} - ;; - *) - echo "DENIED" - exit 1 - ;; -esac +# 태그는 git 커밋 SHA(7~40자리 소문자 16진수)만 허용한다. +if [[ ! "${tag:-}" =~ ^[0-9a-f]{7,40}$ ]]; then + echo "DENIED" + exit 1 +fi + +if [[ -n "${force:-}" && "${force}" != "--force" ]]; then + echo "DENIED" + exit 1 +fi + +exec sudo /opt/innolive/deploy/apply-release.sh "${tag}" ${force:+--force} diff --git a/deploy/render-secrets.sh b/deploy/render-secrets.sh deleted file mode 100755 index 81c8e97..0000000 --- a/deploy/render-secrets.sh +++ /dev/null @@ -1,190 +0,0 @@ -#!/usr/bin/env bash -# GitHub Secrets → server-secrets.env 렌더러. -# receive-deploy.sh의 "sync-config" verb가 sudo로 실행하며, stdin으로 KEY=VALUE 본문을 받는다. -# -# 출력 계약: stdout/stderr는 GitHub Actions 러너 로그로 그대로 전달되어 공개된다. -# 요약 키워드와 키 "이름" 외에는 아무것도(특히 값·서버 상세) 출력하지 말 것. -# 상세 진단은 로그 디렉토리(600) 파일에만 기록한다. -set -euo pipefail - -SECRETS_FILE="${INNOLIVE_SECRETS_FILE:-/etc/innolive/server-secrets.env}" -LOCK_FILE="${INNOLIVE_SYNC_LOCK:-/opt/innolive/deploy/.sync-config.lock}" -LOG_DIR="${INNOLIVE_DEPLOY_LOG_DIR:-/opt/innolive/deploy/logs}" -HEALTH_URL="${INNOLIVE_HEALTH_URL:-http://127.0.0.1:8000/health}" -METRICS_URL="${INNOLIVE_METRICS_URL:-http://127.0.0.1:8000/metrics}" -SERVICE="${INNOLIVE_SERVICE:-innolive-server.service}" -GATE_ATTEMPTS="${INNOLIVE_GATE_ATTEMPTS:-20}" -GATE_INTERVAL="${INNOLIVE_GATE_INTERVAL:-30}" - -# 렌더 허용 키. REQUIRED는 비어 있으면 거부, OPTIONAL은 빈 값 허용. -# AUTH_EMAIL_REDIS_PASSWORD: 프로덕션 Redis가 로컬 전용·무인증 구성이면 빈 값이 정상이므로 OPTIONAL. -# YOUTUBE_STREAM_KEY: 빈 값이면 서버가 유튜브 송출을 건너뛰는 기존 거동을 그대로 따른다. -REQUIRED_KEYS="DATABASE_URL AUTH_ACCESS_TOKEN_KEY_BASE64 AUTH_PROVIDER_TOKEN_ENCRYPTION_KEY_BASE64 AUTH_EMAIL_SMTP_PASSWORD WEBRTC_TURN_USERNAME WEBRTC_TURN_CREDENTIAL APPLE_TEAM_ID APPLE_KEY_ID" -OPTIONAL_KEYS="YOUTUBE_STREAM_KEY AUTH_EMAIL_REDIS_PASSWORD" - -DRY_RUN=false -NO_RESTART=false -for arg in "$@"; do - case "$arg" in - --dry-run) DRY_RUN=true ;; - --no-restart) NO_RESTART=true ;; - *) echo "FAILED: unknown argument"; exit 1 ;; - esac -done - -mkdir -p "$LOG_DIR"; chmod 700 "$LOG_DIR" -DETAIL_LOG="$LOG_DIR/sync-$(date +%Y%m%d-%H%M%S).log" -touch "$DETAIL_LOG"; chmod 600 "$DETAIL_LOG" -log() { printf '%s %s\n' "$(date '+%F %T')" "$*" >>"$DETAIL_LOG"; } - -in_list() { # $1=key $2=공백구분 목록 - case " $2 " in *" $1 "*) return 0 ;; *) return 1 ;; esac -} - -# ── stdin 파싱·검증 (실패 시 어떤 파일도 건드리지 않는다) ───────────── -KEYS=() -VALUES=() -SEEN="" -line_no=0 -while IFS= read -r line || [ -n "$line" ]; do - line_no=$((line_no + 1)) - line="${line%$'\r'}" - if [ -z "$line" ]; then - echo "FAILED: invalid input (line $line_no)"; log "empty line $line_no"; exit 1 - fi - key="${line%%=*}" - value="${line#*=}" - if [ "$key" = "$line" ] || ! printf '%s' "$key" | grep -Eq '^[A-Z][A-Z0-9_]*$'; then - echo "FAILED: invalid input (line $line_no)"; log "malformed line $line_no"; exit 1 - fi - if ! in_list "$key" "$REQUIRED_KEYS $OPTIONAL_KEYS"; then - echo "FAILED: key not allowed: $key"; log "unknown key $key"; exit 1 - fi - if in_list "$key" "$SEEN"; then - echo "FAILED: duplicate key: $key"; log "duplicate key $key"; exit 1 - fi - if in_list "$key" "$REQUIRED_KEYS" && [ -z "$value" ]; then - echo "FAILED: required key empty: $key"; log "empty required $key"; exit 1 - fi - KEYS+=("$key"); VALUES+=("$value"); SEEN="$SEEN $key" -done - -for key in $REQUIRED_KEYS $OPTIONAL_KEYS; do - if ! in_list "$key" "$SEEN"; then - echo "FAILED: key missing: $key"; log "missing key $key"; exit 1 - fi -done - -lookup() { # $1=key → 대응 값 출력 - local i=0 - while [ $i -lt ${#KEYS[@]} ]; do - if [ "${KEYS[$i]}" = "$1" ]; then printf '%s' "${VALUES[$i]}"; return 0; fi - i=$((i + 1)) - done - return 1 -} - -current_value() { # $1=key → 현행 파일의 값(없으면 rc 1) - [ -f "$SECRETS_FILE" ] || return 1 - grep -m1 "^$1=" "$SECRETS_FILE" | cut -d= -f2- || return 1 -} - -# ── dry-run: 키 이름 수준 diff만 보고 ──────────────────────────────── -if $DRY_RUN; then - changed=""; added="" - for key in $REQUIRED_KEYS $OPTIONAL_KEYS; do - if old="$(current_value "$key")"; then - [ "$old" = "$(lookup "$key")" ] || changed="$changed $key" - else - added="$added $key" - fi - done - removed="" - if [ -f "$SECRETS_FILE" ]; then - while IFS= read -r existing_key; do - in_list "$existing_key" "$REQUIRED_KEYS $OPTIONAL_KEYS" || removed="$removed $existing_key" - done < <(grep -E '^[A-Z][A-Z0-9_]*=' "$SECRETS_FILE" | cut -d= -f1) - fi - echo "SYNC DRY-RUN changed:[${changed# }] added:[${added# }] removed:[${removed# }]" - log "dry-run changed=$changed added=$added removed=$removed" - exit 0 -fi - -# ── 잠금 ──────────────────────────────────────────────────────────── -# flock이 없는 환경(로컬 macOS 테스트)에서는 잠금을 생략한다 — 서버(Linux)에는 항상 존재. -if command -v flock >/dev/null 2>&1; then - exec 9>"$LOCK_FILE" - if ! flock -n 9; then - echo "FAILED: another sync in progress"; log "flock busy"; exit 1 - fi -fi - -# ── 세션 게이트 (재시작 예정일 때만, 교체 전에 수행) ────────────────── -active_sessions() { - curl -fsS --max-time 5 "$METRICS_URL" 2>/dev/null \ - | awk '/^innolive_active_sessions[ {]/{print $NF; found=1} END{if(!found) print 0}' \ - | head -1 -} -if ! $NO_RESTART; then - attempt=0 - while :; do - sessions="$(active_sessions)" - case "$sessions" in ''|*[!0-9.]*) sessions=0 ;; esac - if [ "${sessions%%.*}" -eq 0 ]; then break; fi - attempt=$((attempt + 1)) - if [ "$attempt" -ge "$GATE_ATTEMPTS" ]; then - echo "SYNC DEFERRED"; log "gate timeout sessions=$sessions"; exit 75 - fi - log "gate wait attempt=$attempt sessions=$sessions" - sleep "$GATE_INTERVAL" - done -fi - -# ── 원자 교체 + 백업 ───────────────────────────────────────────────── -umask 077 -tmp="$(mktemp "${SECRETS_FILE}.tmp.XXXXXX")" -{ - echo "# sync-config 렌더 산출물 — 손편집 금지(모든 변경은 GitHub Secrets 경유)" - echo "# rendered: $(date '+%F %T')" - for key in $REQUIRED_KEYS $OPTIONAL_KEYS; do - printf '%s=%s\n' "$key" "$(lookup "$key")" - done -} >"$tmp" -chmod 600 "$tmp" -BAK="" -if [ -f "$SECRETS_FILE" ]; then - BAK="${SECRETS_FILE}.bak-$(date +%Y%m%d-%H%M%S)" - cp -p "$SECRETS_FILE" "$BAK" -fi -mv -f "$tmp" "$SECRETS_FILE" -log "rendered ok bak=${BAK:-none}" - -key_count=$(( $(printf '%s' "$REQUIRED_KEYS $OPTIONAL_KEYS" | wc -w) )) - -if $NO_RESTART; then - echo "SYNC OK ($key_count keys, restart skipped — applies on next restart)" - exit 0 -fi - -# ── 재시작 + 헬스 확인, 실패 시 복원 ───────────────────────────────── -systemctl restart "$SERVICE" -healthy() { curl -fsS --max-time 3 "$HEALTH_URL" >/dev/null 2>&1; } -i=0 -while [ $i -lt 30 ]; do - sleep 3 - if healthy; then echo "SYNC OK ($key_count keys)"; log "health ok"; exit 0; fi - i=$((i + 1)) -done -log "health failed — restoring backup" -if [ -n "$BAK" ] && [ -f "$BAK" ]; then - cp -p "$BAK" "$SECRETS_FILE" - systemctl restart "$SERVICE" - i=0 - while [ $i -lt 30 ]; do - sleep 3 - if healthy; then log "rollback health ok"; break; fi - i=$((i + 1)) - done -fi -echo "FAILED: see server journal" -exit 1