From a87bb842953558762c3d3819450fd1010876444d Mon Sep 17 00:00:00 2001 From: itzjb Date: Thu, 6 Aug 2026 00:29:44 +0900 Subject: [PATCH 1/4] =?UTF-8?q?chore:=20=EC=8B=9C=ED=81=AC=EB=A6=BF=20?= =?UTF-8?q?=EB=A0=8C=EB=8D=94=EB=9F=AC=20=EC=8A=A4=ED=81=AC=EB=A6=BD?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/render-secrets.sh | 190 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100755 deploy/render-secrets.sh diff --git a/deploy/render-secrets.sh b/deploy/render-secrets.sh new file mode 100755 index 0000000..81c8e97 --- /dev/null +++ b/deploy/render-secrets.sh @@ -0,0 +1,190 @@ +#!/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 From 223b9242c62ecec3ea7339e59470055c4dfc7c39 Mon Sep 17 00:00:00 2001 From: itzjb Date: Thu, 6 Aug 2026 00:30:27 +0900 Subject: [PATCH 2/4] =?UTF-8?q?chore:=20forced=20command=EC=97=90=20sync-c?= =?UTF-8?q?onfig=20verb=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/receive-deploy.sh | 52 +++++++++++++++++++++++++++------------- 1 file changed, 36 insertions(+), 16 deletions(-) diff --git a/deploy/receive-deploy.sh b/deploy/receive-deploy.sh index 0a075b2..6f265f3 100755 --- a/deploy/receive-deploy.sh +++ b/deploy/receive-deploy.sh @@ -1,28 +1,48 @@ #!/usr/bin/env bash # deploy 계정의 SSH forced command 진입점. -# authorized_keys의 command= 로만 실행되며, 허용 형식("deploy [--force]") 외에는 -# 어떤 명령도 수행하지 않는다. +# authorized_keys의 command= 로만 실행되며, 허용 형식 외에는 어떤 명령도 수행하지 않는다. +# deploy [--force] → apply-release.sh (배포) +# sync-config [--dry-run] [--no-restart] → render-secrets.sh (시크릿 렌더, 본문은 stdin) # # 출력 계약: 이 스크립트의 stdout/stderr는 GitHub Actions 러너 로그로 그대로 전달되어 # 공개 레포에서 누구나 볼 수 있다. 단계 키워드와 커밋 SHA 외에는 아무것도 출력하지 말 것. set -euo pipefail -read -r command tag force _extra <<<"${SSH_ORIGINAL_COMMAND:-}" || true +read -r command arg1 arg2 _extra <<<"${SSH_ORIGINAL_COMMAND:-}" || true -if [[ "${command:-}" != "deploy" || -n "${_extra:-}" ]]; then +if [[ -n "${_extra:-}" ]]; then echo "DENIED" exit 1 fi -# 태그는 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} +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 From 3217a642c514f4f4476cfddfcac330afbdecacc9 Mon Sep 17 00:00:00 2001 From: itzjb Date: Thu, 6 Aug 2026 00:31:33 +0900 Subject: [PATCH 3/4] =?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=EC=9B=8C=ED=81=AC=ED=94=8C?= =?UTF-8?q?=EB=A1=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/sync-config.yml | 95 +++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 .github/workflows/sync-config.yml diff --git a/.github/workflows/sync-config.yml b/.github/workflows/sync-config.yml new file mode 100644 index 0000000..ca1e2d0 --- /dev/null +++ b/.github/workflows/sync-config.yml @@ -0,0 +1,95 @@ +# 프로덕션 시크릿 동기화 — 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" From 2c503108e12fadcd24c08a022b8975eb9a64d0dd Mon Sep 17 00:00:00 2001 From: itzjb Date: Thu, 6 Aug 2026 00:32:57 +0900 Subject: [PATCH 4/4] =?UTF-8?q?docs:=20=EC=8B=9C=ED=81=AC=EB=A6=BF=20?= =?UTF-8?q?=EB=8F=99=EA=B8=B0=ED=99=94=20=EC=9A=B4=EC=98=81=20=EC=A0=88?= =?UTF-8?q?=EC=B0=A8=20=EB=AC=B8=EC=84=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/README.md | 61 +++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 58 insertions(+), 3 deletions(-) diff --git a/deploy/README.md b/deploy/README.md index f12ceac..a6d667e 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -29,6 +29,59 @@ 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`. @@ -41,8 +94,8 @@ PR 머지 → main push → [CI: build-and-test(필수)] → [Deploy 워크플 INNOLIVE_APPLE_KEY_PATH= ``` - `/etc/innolive/dockerhub.token` (root 600): Docker Hub **Read-only** Access Token. -2. **배포 스크립트 설치**: 이 디렉토리의 `receive-deploy.sh`/`apply-release.sh`를 - `/opt/innolive/deploy/`에 root:root 755로 복사. `compose.prod.yaml`은 +2. **배포 스크립트 설치**: 이 디렉토리의 `receive-deploy.sh`/`apply-release.sh`/ + `render-secrets.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 머지 → 서버에 수동 복사" 순서로 한다(자동 동기화 금지 — @@ -56,15 +109,17 @@ PR 머지 → main push → [CI: build-and-test(필수)] → [Deploy 워크플 - 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): `DOCKERHUB_USERNAME`, `DOCKERHUB_TOKEN`(Read&Write), +- Secrets (Actions, environment `production`): `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 레포 전제)