Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions .github/workflows/sync-config.yml
Original file line number Diff line number Diff line change
@@ -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"
61 changes: 58 additions & 3 deletions deploy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -41,8 +94,8 @@ PR 머지 → main push → [CI: build-and-test(필수)] → [Deploy 워크플
INNOLIVE_APPLE_KEY_PATH=<server-secrets.env의 APPLE_PRIVATE_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 머지 → 서버에 수동 복사" 순서로 한다(자동 동기화 금지 —
Expand All @@ -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 <port> <host>` 고정값).
- 앱 시크릿(environment `production`): `PROD_*` 10종 — 목록·매핑은 위 "시크릿 동기화" 절 참조.
- 개인 명의 자격증명은 어디에도 사용하지 않는다(팀 서비스 계정만).

## 로그 위생 (public 레포 전제)
Expand Down
52 changes: 36 additions & 16 deletions deploy/receive-deploy.sh
Original file line number Diff line number Diff line change
@@ -1,28 +1,48 @@
#!/usr/bin/env bash
# deploy 계정의 SSH forced command 진입점.
# authorized_keys의 command= 로만 실행되며, 허용 형식("deploy <sha> [--force]") 외에는
# 어떤 명령도 수행하지 않는다.
# authorized_keys의 command= 로만 실행되며, 허용 형식 외에는 어떤 명령도 수행하지 않는다.
# deploy <sha> [--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
Loading
Loading