diff --git a/.github/workflows/database-docs.yml b/.github/workflows/database-docs.yml new file mode 100644 index 0000000..a58726d --- /dev/null +++ b/.github/workflows/database-docs.yml @@ -0,0 +1,108 @@ +name: Database Documentation + +on: + push: + branches: [main] + paths: + - 'src/main/resources/db/migration/**' + - 'scripts/db-docs/**' + - '.github/workflows/database-docs.yml' + pull_request: + branches: [main] + paths: + - 'src/main/resources/db/migration/**' + - 'scripts/db-docs/**' + - '.github/workflows/database-docs.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: database-docs-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-docs: + name: Build database documentation + runs-on: ubuntu-latest + timeout-minutes: 15 + + services: + postgres: + image: postgres:17-alpine + env: + POSTGRES_DB: fowoco_docs + POSTGRES_USER: fowoco_docs + POSTGRES_PASSWORD: fowoco_docs_ci_only + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U fowoco_docs -d fowoco_docs" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Checkout repository + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + + - name: Set up Node.js 24 + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: '24' + + - name: Test documentation generator + run: node --test scripts/db-docs/generate-site.test.mjs + + - name: Generate database documentation + run: ./scripts/db-docs/generate.sh + env: + DB_DOCS_CONTAINER_HOST: 127.0.0.1 + DB_DOCS_EPHEMERAL: 'true' + DB_DOCS_PORT: '5432' + DB_DOCS_DATABASE: fowoco_docs + DB_DOCS_USER: fowoco_docs + DB_DOCS_PASSWORD: fowoco_docs_ci_only + DB_DOCS_GIT_COMMIT: ${{ github.event.pull_request.head.sha || github.sha }} + + - name: Upload database documentation preview + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: database-docs-site + path: build/db-docs/site + if-no-files-found: error + retention-days: 14 + + deploy-pages: + name: Deploy database documentation + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + needs: build-docs + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + steps: + - name: Download database documentation + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: database-docs-site + path: site + + - name: Configure GitHub Pages + uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5 + + - name: Upload GitHub Pages artifact + uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4 + with: + path: site + + - name: Deploy GitHub Pages + id: deployment + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4 diff --git a/README.md b/README.md index 0dc9cc2..5e5fab1 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,12 @@ FOWOCO는 단순 번역 서비스가 아닙니다. 체류·계약·서류·신 계획 문서는 현재 동작하는 API가 아닙니다. 구현의 원본은 코드·테스트와 실행 시 생성되는 OpenAPI이고, 장기 아키텍처 결정은 [ADR](docs/adr/README.md), 계획 범위와 예시는 [API 카탈로그](https://github.com/fowoco/server/wiki/09-API-Specification)와 Issue에서 확인합니다. +데이터베이스 구조는 [DB 문서 사이트](https://fowoco.github.io/server/)에서 전체 +ERD, 테이블·컬럼·제약조건과 Flyway 적용 이력을 확인할 수 있습니다. 이 사이트는 +`main`의 Migration을 일회용 빈 PostgreSQL에 적용해 자동 생성하며 실제 데이터는 +포함하지 않습니다. PR 미리보기와 로컬 생성 방법은 +[데이터베이스 문서 사용법](docs/database-documentation.md)을 봅니다. + ## 5분 실행 ### 필요한 것 diff --git a/docs/database-documentation.md b/docs/database-documentation.md new file mode 100644 index 0000000..e76c3b7 --- /dev/null +++ b/docs/database-documentation.md @@ -0,0 +1,104 @@ +# 데이터베이스 문서 사용법 + +FOWOCO Server의 데이터베이스 문서는 `main`의 Flyway Migration을 일회용 +PostgreSQL에 처음부터 적용한 결과로 생성합니다. + +- 팀 공유 사이트: +- 변경의 원본: `src/main/resources/db/migration` +- 구조 결정의 원본: `docs/adr` + +문서는 구조를 쉽게 찾기 위한 보조 수단입니다. 문서 화면에서 DB를 변경할 수 +없으며, Flyway SQL을 거치지 않은 변경은 정식 변경으로 인정하지 않습니다. + +## 무엇을 볼 수 있나요? + +| 메뉴 | 확인할 수 있는 내용 | +| --- | --- | +| 테이블 구조 | 전체 ERD, 컬럼 타입, Nullable, 기본값 | +| 관계 | PK, FK, UNIQUE, CHECK, INDEX | +| Migration 이력 | 적용 버전, 상태, 적용 시각, 실행 시간 | +| 생성 정보 | 기준 Git commit, Flyway·Schema version, 생성 시각 | + +운영 DB의 데이터, 계정, 접속 주소, 비밀번호는 문서에 포함하지 않습니다. + +## 언제 갱신되나요? + +다음 경로가 변경되어 `main`에 병합되면 `Database Documentation` Workflow가 +자동 실행됩니다. + +```text +src/main/resources/db/migration/** +scripts/db-docs/** +.github/workflows/database-docs.yml +``` + +Workflow는 다음 순서로 동작합니다. + +```text +빈 PostgreSQL 시작 +→ Flyway migrate +→ Flyway validate +→ SchemaSpy HTML 생성 +→ Migration 이력 페이지 생성 +→ GitHub Pages 배포 +``` + +Migration 적용이나 `validate`가 실패하면 기존 Pages를 덮어쓰지 않습니다. + +## PR에서 먼저 확인하기 + +Migration을 바꾼 PR에서는 Pages를 배포하지 않습니다. + +1. PR의 `Checks`에서 `Database Documentation`을 엽니다. +2. 실행 결과 아래의 `Artifacts`로 이동합니다. +3. `database-docs-site`를 내려받습니다. +4. 압축을 풀고 `index.html`을 브라우저로 엽니다. + +Artifact는 14일 동안 보관합니다. PR 작성자는 ERD 변경이 의도한 구조인지 +확인한 뒤 리뷰를 요청합니다. + +## 로컬에서 생성하기 + +Docker Desktop 또는 Docker Engine과 Node.js 24 이상이 필요합니다. + +```bash +./scripts/db-docs/generate-local.sh +``` + +이 명령은 작업마다 별도의 Docker network와 PostgreSQL container를 만들고, +완료되거나 실패하면 정확히 그 임시 자원만 제거합니다. + +생성 결과는 Git에 포함되지 않는 아래 경로에 있습니다. + +```text +build/db-docs/site/index.html +``` + +macOS에서는 다음 명령으로 열 수 있습니다. + +```bash +open build/db-docs/site/index.html +``` + +## 실패했을 때 확인할 것 + +| 증상 | 확인 | +| --- | --- | +| Docker를 찾지 못함 | Docker Desktop 설치·실행 여부 | +| PostgreSQL 준비 실패 | 기존 container와 Docker 자원 상태 | +| Flyway migrate 실패 | 가장 최근 Migration SQL과 PostgreSQL 문법 | +| Flyway validate 실패 | 이미 적용된 Migration을 수정·삭제했는지 | +| SchemaSpy 실패 | 테이블·FK·제약조건 오류와 container 로그 | +| Pages 배포 실패 | Repository Pages Source와 `github-pages` Environment | + +적용된 Migration을 수정하거나 `flyway repair`로 실패를 숨기지 않습니다. 새 +버전의 Migration을 추가해 정정하고, 위험한 변경은 ADR과 리뷰를 먼저 거칩니다. + +## 보안 원칙 + +- 운영·Staging DB에 연결하지 않습니다. +- 실제 사용자·근로자·Demo Seed 데이터를 넣지 않습니다. +- 임시 DB 값은 Workflow 내부에서만 사용합니다. +- Personal Access Token과 별도 Cloud Secret은 사용하지 않습니다. +- Workflow Action은 full-length commit SHA로 고정합니다. +- 생성 사이트에는 Schema 구조와 비민감 build metadata만 게시합니다. diff --git a/scripts/db-docs/generate-local.sh b/scripts/db-docs/generate-local.sh new file mode 100755 index 0000000..4b551f3 --- /dev/null +++ b/scripts/db-docs/generate-local.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +RUN_ID="$$" +NETWORK_NAME="fowoco-db-docs-${RUN_ID}" +CONTAINER_NAME="fowoco-db-docs-postgres-${RUN_ID}" +POSTGRES_IMAGE="${DB_DOCS_POSTGRES_IMAGE:-postgres:17-alpine}" +DATABASE_NAME="fowoco_docs" +DATABASE_USER="fowoco_docs" +DATABASE_PASSWORD="fowoco_docs_local_only" + +if ! command -v docker >/dev/null 2>&1 || ! docker info >/dev/null 2>&1; then + echo "[db-docs] 실행 중인 Docker Desktop 또는 Docker Engine이 필요합니다." >&2 + exit 1 +fi + +cleanup() { + docker rm -f "${CONTAINER_NAME}" >/dev/null 2>&1 || true + docker network rm "${NETWORK_NAME}" >/dev/null 2>&1 || true +} +trap cleanup EXIT INT TERM + +docker network create "${NETWORK_NAME}" >/dev/null +docker run -d --rm \ + --name "${CONTAINER_NAME}" \ + --network "${NETWORK_NAME}" \ + -e "POSTGRES_DB=${DATABASE_NAME}" \ + -e "POSTGRES_USER=${DATABASE_USER}" \ + -e "POSTGRES_PASSWORD=${DATABASE_PASSWORD}" \ + "${POSTGRES_IMAGE}" >/dev/null + +for attempt in $(seq 1 30); do + if docker exec "${CONTAINER_NAME}" pg_isready -U "${DATABASE_USER}" -d "${DATABASE_NAME}" >/dev/null 2>&1; then + break + fi + if [[ "${attempt}" == "30" ]]; then + echo "[db-docs] PostgreSQL이 준비되지 않았습니다." >&2 + exit 1 + fi + sleep 1 +done + +DB_DOCS_DOCKER_NETWORK="${NETWORK_NAME}" \ +DB_DOCS_CONTAINER_HOST="${CONTAINER_NAME}" \ +DB_DOCS_EPHEMERAL=true \ +DB_DOCS_DATABASE="${DATABASE_NAME}" \ +DB_DOCS_USER="${DATABASE_USER}" \ +DB_DOCS_PASSWORD="${DATABASE_PASSWORD}" \ +"${SCRIPT_DIR}/generate.sh" diff --git a/scripts/db-docs/generate-site.mjs b/scripts/db-docs/generate-site.mjs new file mode 100644 index 0000000..eb189ba --- /dev/null +++ b/scripts/db-docs/generate-site.mjs @@ -0,0 +1,262 @@ +import { readFile, writeFile, mkdir, access } from 'node:fs/promises' +import path from 'node:path' +import process from 'node:process' + +function parseArguments(argv) { + const result = {} + for (let index = 0; index < argv.length; index += 2) { + const key = argv[index] + const value = argv[index + 1] + if (!key?.startsWith('--') || value === undefined) { + throw new Error(`잘못된 인자입니다: ${key ?? '(없음)'}`) + } + result[key.slice(2)] = value + } + return result +} + +function escapeHtml(value) { + return String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') +} + +function stateClass(state) { + const normalized = String(state ?? '').toLowerCase() + if (normalized === 'success') return 'success' + if (normalized === 'pending') return 'pending' + return 'warning' +} + +function displayValue(value, fallback = '—') { + return value === null || value === undefined || value === '' ? fallback : String(value) +} + +function validateGeneratedAt(value) { + const parsed = new Date(value) + if (Number.isNaN(parsed.getTime())) { + throw new Error(`생성 시각이 ISO-8601 형식이 아닙니다: ${value}`) + } + return parsed.toISOString() +} + +function migrationRows(migrations) { + return migrations + .map((migration) => { + const executionTime = Number.isFinite(migration.executionTime) + ? `${migration.executionTime} ms` + : '—' + return ` + + ${escapeHtml(displayValue(migration.version, 'Repeatable'))} + ${escapeHtml(displayValue(migration.description))} + ${escapeHtml(displayValue(migration.type))} + ${escapeHtml(displayValue(migration.state))} + ${escapeHtml(displayValue(migration.installedOnUTC))} + ${escapeHtml(executionTime)} + ` + }) + .join('') +} + +function pageShell({ title, description, body, assetPrefix = '' }) { + return ` + + + + + + ${escapeHtml(title)} + + + +
+ FOWOCO Database + +
+
${body}
+ + +` +} + +const styles = ` +:root { + color-scheme: light; + --ink: #172b2d; + --muted: #5f7375; + --line: #dbe5e4; + --surface: #f5f9f8; + --brand: #0c6a65; + --brand-dark: #084d49; + --success: #16794b; + --pending: #936300; + --warning: #a33a2b; +} +* { box-sizing: border-box; } +body { + margin: 0; + color: var(--ink); + background: #fff; + font-family: Inter, Pretendard, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + line-height: 1.6; +} +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 1rem clamp(1rem, 5vw, 4rem); + color: #fff; + background: var(--brand-dark); +} +.topbar a { color: inherit; text-decoration: none; } +.brand { font-size: 1.1rem; font-weight: 800; letter-spacing: .02em; } +nav { display: flex; gap: 1rem; font-size: .95rem; } +main { width: min(1120px, calc(100% - 2rem)); margin: 0 auto; padding: 3.5rem 0; } +.hero { max-width: 760px; margin-bottom: 2rem; } +.eyebrow { color: var(--brand); font-weight: 800; text-transform: uppercase; letter-spacing: .08em; } +h1 { margin: .25rem 0 1rem; font-size: clamp(2rem, 5vw, 3.2rem); line-height: 1.18; } +h2 { margin-top: 2.25rem; } +.lead { color: var(--muted); font-size: 1.08rem; } +.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 1rem; } +.card { + display: block; + padding: 1.3rem; + color: inherit; + text-decoration: none; + border: 1px solid var(--line); + border-radius: 14px; + background: var(--surface); +} +.card:hover { border-color: var(--brand); transform: translateY(-1px); } +.card h2 { margin: 0 0 .35rem; } +.meta { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: .75rem; + margin: 2rem 0; +} +.meta div { padding: 1rem; border-left: 3px solid var(--brand); background: var(--surface); } +.meta dt { color: var(--muted); font-size: .85rem; } +.meta dd { margin: .25rem 0 0; font-weight: 700; overflow-wrap: anywhere; } +.table-wrap { overflow-x: auto; border: 1px solid var(--line); border-radius: 12px; } +table { width: 100%; border-collapse: collapse; font-size: .92rem; } +th, td { padding: .8rem; border-bottom: 1px solid var(--line); text-align: left; white-space: nowrap; } +th { background: var(--surface); } +tbody tr:last-child td { border-bottom: 0; } +.badge { display: inline-block; padding: .15rem .55rem; border-radius: 999px; font-weight: 700; } +.badge.success { color: var(--success); background: #def4e8; } +.badge.pending { color: var(--pending); background: #fff0bd; } +.badge.warning { color: var(--warning); background: #ffe2dd; } +.notice { padding: 1rem 1.2rem; border-radius: 10px; background: #e9f5f3; } +code { font-family: "SFMono-Regular", Consolas, monospace; } +footer { padding: 2rem 1rem; color: var(--muted); text-align: center; border-top: 1px solid var(--line); } +@media (max-width: 640px) { + .topbar { align-items: flex-start; flex-direction: column; } + main { padding-top: 2rem; } +} +` + +async function main() { + const args = parseArguments(process.argv.slice(2)) + if (!args['flyway-info'] || !args.output) { + throw new Error('--flyway-info와 --output은 필수입니다.') + } + + const generatedAt = validateGeneratedAt(args['generated-at'] ?? new Date().toISOString()) + const commit = args.commit ?? 'unknown' + const repositoryUrl = args['repository-url'] ?? 'https://github.com/fowoco/server' + const info = JSON.parse(await readFile(path.resolve(args['flyway-info']), 'utf8')) + const migrations = Array.isArray(info.migrations) ? info.migrations : null + if (!migrations) { + throw new Error('Flyway info JSON에 migrations 배열이 없습니다.') + } + + const output = path.resolve(args.output) + await access(path.join(output, 'schema', 'index.html')) + await mkdir(path.join(output, 'migrations'), { recursive: true }) + await mkdir(path.join(output, 'assets'), { recursive: true }) + + const successCount = migrations.filter((migration) => migration.state === 'Success').length + const pendingCount = migrations.filter((migration) => migration.state === 'Pending').length + const warningCount = migrations.length - successCount - pendingCount + const commitHref = /^[0-9a-f]{7,40}$/i.test(commit) + ? `${repositoryUrl}/commit/${commit}` + : repositoryUrl + + const homeBody = ` +
+

Generated from Flyway

+

FOWOCO 데이터베이스 문서

+

main의 Flyway Migration을 일회용 PostgreSQL에 적용한 뒤 자동 생성한 문서입니다. 실제 구조를 이해하는 보조 자료이며 변경의 원본은 Migration SQL과 ADR입니다.

+
+
+ +

테이블 구조 보기

+

전체 ERD, 컬럼, PK·FK·UNIQUE·CHECK·INDEX를 확인합니다.

+
+ +

Migration 이력 보기

+

적용된 버전, 상태, 실행 시각과 검증 결과를 확인합니다.

+
+
+
+
현재 Schema Version
${escapeHtml(displayValue(info.schemaVersion))}
+
Schema
${escapeHtml(displayValue(info.schemaName))}
+
Flyway
${escapeHtml(displayValue(info.flywayVersion))}
+
생성 시각
${escapeHtml(generatedAt)}
+
Git commit
${escapeHtml(commit)}
+
+

성공 ${successCount}개 · 대기 ${pendingCount}개 · 확인 필요 ${warningCount}개. 문서 생성 전에 flyway validate를 통과했습니다.

` + + const migrationBody = ` +
+

Flyway History

+

Migration 적용 이력

+

빈 PostgreSQL을 처음부터 구성한 결과입니다. 운영 DB의 데이터나 접속정보는 포함하지 않습니다.

+
+
+ + + ${migrationRows(migrations)} +
Version설명유형상태적용 시각(UTC)실행 시간
+
` + + await writeFile(path.join(output, 'index.html'), pageShell({ + title: 'FOWOCO Database Documentation', + description: 'FOWOCO Server 데이터베이스 ERD와 Flyway Migration 문서', + body: homeBody, + })) + await writeFile(path.join(output, 'migrations', 'index.html'), pageShell({ + title: 'FOWOCO Flyway Migration History', + description: 'FOWOCO Server Flyway Migration 적용 이력', + body: migrationBody, + assetPrefix: '../', + })) + await writeFile(path.join(output, 'assets', 'styles.css'), styles.trimStart()) + await writeFile(path.join(output, '.nojekyll'), '') + await writeFile(path.join(output, 'metadata.json'), JSON.stringify({ + generated_at: generatedAt, + git_commit: commit, + schema_version: info.schemaVersion ?? null, + schema_name: info.schemaName ?? null, + flyway_version: info.flywayVersion ?? null, + migration_counts: { + success: successCount, + pending: pendingCount, + attention_required: warningCount, + }, + }, null, 2)) +} + +main().catch((error) => { + console.error(`[db-docs] ${error.message}`) + process.exitCode = 1 +}) diff --git a/scripts/db-docs/generate-site.test.mjs b/scripts/db-docs/generate-site.test.mjs new file mode 100644 index 0000000..47b65f2 --- /dev/null +++ b/scripts/db-docs/generate-site.test.mjs @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict' +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { spawnSync } from 'node:child_process' +import test from 'node:test' + +const script = path.resolve('scripts/db-docs/generate-site.mjs') + +test('Flyway JSON을 안전한 DB 문서 사이트로 변환한다', async () => { + const temporaryRoot = await mkdtemp(path.join(os.tmpdir(), 'fowoco-db-docs-')) + try { + const infoFile = path.join(temporaryRoot, 'flyway-info.json') + const output = path.join(temporaryRoot, 'site') + await mkdir(path.join(output, 'schema'), { recursive: true }) + await writeFile(path.join(output, 'schema', 'index.html'), 'SchemaSpy') + await writeFile(infoFile, JSON.stringify({ + schemaVersion: '5', + schemaName: 'public', + flywayVersion: '12.4.0', + migrations: [ + { + version: '1', + description: 'baseline ', + type: 'SQL', + state: 'Success', + installedOnUTC: '2026-07-24T00:00:00Z', + executionTime: 14, + filepath: '/flyway/sql/V1__baseline.sql', + }, + { + version: '6', + description: 'next', + type: 'SQL', + state: 'Pending', + installedOnUTC: '', + executionTime: 0, + filepath: '/private/path/V6__next.sql', + }, + ], + })) + + const result = spawnSync(process.execPath, [ + script, + '--flyway-info', infoFile, + '--output', output, + '--commit', '1234567890abcdef1234567890abcdef12345678', + '--generated-at', '2026-07-24T01:02:03Z', + '--repository-url', 'https://github.com/fowoco/server', + ], { encoding: 'utf8' }) + + assert.equal(result.status, 0, result.stderr) + const index = await readFile(path.join(output, 'index.html'), 'utf8') + const migrations = await readFile(path.join(output, 'migrations', 'index.html'), 'utf8') + const metadata = JSON.parse(await readFile(path.join(output, 'metadata.json'), 'utf8')) + + assert.match(index, /현재 Schema Version/) + assert.match(index, /성공 1개 · 대기 1개/) + assert.match(migrations, /baseline <safe>/) + assert.doesNotMatch(migrations, /private\/path/) + assert.equal(metadata.schema_version, '5') + assert.deepEqual(metadata.migration_counts, { + success: 1, + pending: 1, + attention_required: 0, + }) + } finally { + await rm(temporaryRoot, { recursive: true, force: true }) + } +}) + +test('SchemaSpy 결과가 없으면 불완전한 사이트 생성을 거부한다', async () => { + const temporaryRoot = await mkdtemp(path.join(os.tmpdir(), 'fowoco-db-docs-')) + try { + const infoFile = path.join(temporaryRoot, 'flyway-info.json') + await writeFile(infoFile, JSON.stringify({ migrations: [] })) + + const result = spawnSync(process.execPath, [ + script, + '--flyway-info', infoFile, + '--output', path.join(temporaryRoot, 'site'), + ], { encoding: 'utf8' }) + + assert.notEqual(result.status, 0) + assert.match(result.stderr, /\[db-docs\]/) + } finally { + await rm(temporaryRoot, { recursive: true, force: true }) + } +}) diff --git a/scripts/db-docs/generate.sh b/scripts/db-docs/generate.sh new file mode 100755 index 0000000..c0e49b4 --- /dev/null +++ b/scripts/db-docs/generate.sh @@ -0,0 +1,146 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPOSITORY_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +OUTPUT_ROOT="${REPOSITORY_ROOT}/build/db-docs" +MIGRATION_DIRECTORY="${REPOSITORY_ROOT}/src/main/resources/db/migration" + +FLYWAY_IMAGE="${DB_DOCS_FLYWAY_IMAGE:-flyway/flyway:12.4.0}" +SCHEMASPY_IMAGE="${DB_DOCS_SCHEMASPY_IMAGE:-schemaspy/schemaspy:7.0.2}" +DATABASE_HOST="${DB_DOCS_CONTAINER_HOST:-127.0.0.1}" +DATABASE_PORT="${DB_DOCS_PORT:-5432}" +DATABASE_NAME="${DB_DOCS_DATABASE:-fowoco_docs}" +DATABASE_USER="${DB_DOCS_USER:-fowoco_docs}" +: "${DB_DOCS_PASSWORD:?DB_DOCS_PASSWORD를 설정해 주세요. 실제 운영 DB 비밀번호를 사용하면 안 됩니다.}" + +if [[ "${DB_DOCS_EPHEMERAL:-false}" != "true" ]]; then + echo "[db-docs] 일회용 DB 확인값(DB_DOCS_EPHEMERAL=true)이 필요합니다." >&2 + exit 1 +fi +if [[ "${DATABASE_NAME}" != "fowoco_docs" || "${DATABASE_USER}" != "fowoco_docs" ]]; then + echo "[db-docs] 문서 생성 전용 DB와 사용자(fowoco_docs)만 사용할 수 있습니다." >&2 + exit 1 +fi + +if ! command -v docker >/dev/null 2>&1; then + echo "[db-docs] Docker를 찾지 못했습니다. Docker Desktop 또는 Docker Engine이 필요합니다." >&2 + exit 1 +fi +if ! command -v node >/dev/null 2>&1; then + echo "[db-docs] Node.js를 찾지 못했습니다. Node.js 24 이상을 설치해 주세요." >&2 + exit 1 +fi +if ! docker info >/dev/null 2>&1; then + echo "[db-docs] Docker가 실행 중이 아닙니다." >&2 + exit 1 +fi + +NETWORK_ARGUMENTS=() +if [[ -n "${DB_DOCS_DOCKER_NETWORK:-}" ]]; then + case "${DB_DOCS_DOCKER_NETWORK}" in + fowoco-db-docs-*) ;; + *) + echo "[db-docs] 문서 생성 전용 Docker network만 사용할 수 있습니다." >&2 + exit 1 + ;; + esac + NETWORK_ARGUMENTS=(--network "${DB_DOCS_DOCKER_NETWORK}") +elif [[ "$(uname -s)" == "Linux" ]]; then + case "${DATABASE_HOST}" in + 127.0.0.1|localhost) ;; + *) + echo "[db-docs] Host network에서는 localhost DB만 사용할 수 있습니다." >&2 + exit 1 + ;; + esac + NETWORK_ARGUMENTS=(--network host) +else + case "${DATABASE_HOST}" in + 127.0.0.1|localhost) DATABASE_HOST="host.docker.internal" ;; + host.docker.internal) ;; + *) + echo "[db-docs] Docker Desktop에서는 host.docker.internal DB만 사용할 수 있습니다." >&2 + exit 1 + ;; + esac +fi + +case "${OUTPUT_ROOT}" in + "${REPOSITORY_ROOT}/build/db-docs") ;; + *) + echo "[db-docs] 허용되지 않은 출력 경로입니다: ${OUTPUT_ROOT}" >&2 + exit 1 + ;; +esac + +rm -rf "${OUTPUT_ROOT}" +mkdir -p "${OUTPUT_ROOT}/site/schema" +chmod 0777 "${OUTPUT_ROOT}/site/schema" + +JDBC_URL="jdbc:postgresql://${DATABASE_HOST}:${DATABASE_PORT}/${DATABASE_NAME}" +FLYWAY_ARGUMENTS=( + "-url=${JDBC_URL}" + "-user=${DATABASE_USER}" + "-password=${DB_DOCS_PASSWORD}" + "-locations=filesystem:/flyway/sql" + "-defaultSchema=public" + "-schemas=public" + "-connectRetries=20" +) + +echo "[db-docs] 빈 PostgreSQL에 Flyway Migration을 적용합니다." +docker run --rm \ + "${NETWORK_ARGUMENTS[@]}" \ + -v "${MIGRATION_DIRECTORY}:/flyway/sql:ro" \ + "${FLYWAY_IMAGE}" \ + "${FLYWAY_ARGUMENTS[@]}" \ + migrate + +echo "[db-docs] 적용된 Migration과 저장소 checksum을 검증합니다." +docker run --rm \ + "${NETWORK_ARGUMENTS[@]}" \ + -v "${MIGRATION_DIRECTORY}:/flyway/sql:ro" \ + "${FLYWAY_IMAGE}" \ + "${FLYWAY_ARGUMENTS[@]}" \ + validate + +docker run --rm \ + "${NETWORK_ARGUMENTS[@]}" \ + -v "${MIGRATION_DIRECTORY}:/flyway/sql:ro" \ + "${FLYWAY_IMAGE}" \ + "${FLYWAY_ARGUMENTS[@]}" \ + -outputType=json \ + info > "${OUTPUT_ROOT}/flyway-info.json" + +echo "[db-docs] SchemaSpy로 테이블·관계 문서를 생성합니다." +docker run --rm \ + "${NETWORK_ARGUMENTS[@]}" \ + --user "$(id -u):$(id -g)" \ + -v "${OUTPUT_ROOT}/site/schema:/output" \ + "${SCHEMASPY_IMAGE}" \ + -t pgsql11 \ + -host "${DATABASE_HOST}" \ + -port "${DATABASE_PORT}" \ + -db "${DATABASE_NAME}" \ + -u "${DATABASE_USER}" \ + -p "${DB_DOCS_PASSWORD}" \ + -s public + +chmod -R a+rX "${OUTPUT_ROOT}" + +GIT_COMMIT="${DB_DOCS_GIT_COMMIT:-${GITHUB_SHA:-$(git -C "${REPOSITORY_ROOT}" rev-parse HEAD)}}" +if [[ -n "${GITHUB_SERVER_URL:-}" && -n "${GITHUB_REPOSITORY:-}" ]]; then + REPOSITORY_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}" +else + REPOSITORY_URL="https://github.com/fowoco/server" +fi + +node "${SCRIPT_DIR}/generate-site.mjs" \ + --flyway-info "${OUTPUT_ROOT}/flyway-info.json" \ + --output "${OUTPUT_ROOT}/site" \ + --commit "${GIT_COMMIT}" \ + --generated-at "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \ + --repository-url "${REPOSITORY_URL}" + +echo "[db-docs] 생성 완료: ${OUTPUT_ROOT}/site/index.html"