Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
52 changes: 42 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ PostgreSQL 전문 클라우드 ERD(협업/공유) 소프트웨어의 **실행
- index (access method 포함, 예: btree/hash/gist/gin/spgist/brin + extension AM)
를 pg_catalog 기반으로 수집하고 스냅샷으로 저장
- **ERD UI**: React Flow(MIT)로 PK/FK를 그래픽으로 렌더링
- **Forward engineering(포워드)**: MVP 단계에서는 “스냅샷 기반 DDL(export)” 중심 (diff/변경 SQL 생성은 로드맵)
- **Forward engineering(포워드)**: MVP 단계에서는 “스냅샷 기반 DDL(export)” 중심
(diff/변경 SQL 생성은 로드맵)

- SQL export: `GET /api/snapshots/{snapshot_uuid}/export.sql`

Expand All @@ -28,7 +29,9 @@ curl -X POST "http://localhost:8000/api/projects/<project_uuid>/share-links" \

## 인덱스 타입 지원 원칙

PostgreSQL은 `CREATE INDEX ... USING <method>`의 `<method>`가 기본(btree/hash/gist/gin/spgist/brin)뿐 아니라 **확장(extension)으로 추가된 access method**도 될 수 있습니다.
PostgreSQL은 `CREATE INDEX ... USING <method>`의 `<method>`가
기본(btree/hash/gist/gin/spgist/brin)뿐 아니라 **확장(extension)으로 추가된 access
method**도 될 수 있습니다.

따라서 본 프로젝트는 **고정된 목록을 하드코딩하지 않고**,

Expand All @@ -38,10 +41,15 @@ PostgreSQL은 `CREATE INDEX ... USING <method>`의 `<method>`가 기본(btree/ha
하는 방식으로 “현존(해당 DB에 설치된) 인덱스 타입”을 폭넓게 지원합니다.

근거(공식 문서):
- Index types / extension bloom 예시: https://www.postgresql.org/docs/current/indexes-types.html
- CREATE INDEX (user-installed access methods): https://www.postgresql.org/docs/current/sql-createindex.html
- pg_am / pg_class / pg_index: https://www.postgresql.org/docs/current/catalog-pg-am.html
- pg_get_indexdef / pg_get_expr: https://www.postgresql.org/docs/current/functions-info.html

- Index types / extension bloom 예시:
<https://www.postgresql.org/docs/current/indexes-types.html>
- CREATE INDEX (user-installed access methods):
<https://www.postgresql.org/docs/current/sql-createindex.html>
- pg_am / pg_class / pg_index:
<https://www.postgresql.org/docs/current/catalog-pg-am.html>
- pg_get_indexdef / pg_get_expr:
<https://www.postgresql.org/docs/current/functions-info.html>

## 실행(로컬, Docker)

Expand All @@ -50,33 +58,57 @@ cp .env.example .env
docker compose up -d --build
```

- Frontend: http://localhost:5173
- Backend: http://localhost:8000 (health: /healthz)
- Frontend: <http://localhost:5173>
- Backend: <http://localhost:8000> (health: /healthz)

## 실행(프로덕션 스타일, Docker)

프론트는 정적 빌드 + nginx로 서빙하며, `/api/*`는 백엔드로 프록시합니다.

```bash
cp .env.example .env

# 프로덕션 스타일에서는 Docker secret 파일로 APP_SECRET을 주입합니다.
# (이 파일은 커밋 금지: .gitignore에 **/secrets/** 포함)
mkdir -p secrets
python - <<'PY'
import secrets

with open('secrets/app_secret', 'w', encoding='utf-8') as f:
f.write(secrets.token_urlsafe(48) + "\n")
PY

Comment thread
coderabbitai[bot] marked this conversation as resolved.
# Restrict secret file permissions (owner read/write only)
chmod 600 secrets/app_secret

docker compose -f compose.prod.yaml up -d --build
```

- Frontend: http://localhost:8080
- Frontend: <http://localhost:8080>

## 개발(비-Docker)

### Backend

```bash
cd backend
python -m venv .venv && source .venv/bin/activate
pip install -U pip
pip install -e .
alembic upgrade head
uvicorn app.main:app --reload
hypercorn app.main:app --reload
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
```

#### 운영 팁

- Hypercorn worker 수는 `HYPERCORN_WORKERS`(또는 `WEB_CONCURRENCY`)로 조절할 수
있습니다.
- `APP_SECRET`은 앱 DB에 저장되는 DSN 암호화 키로 사용되므로, 변경 시 기존 데이터
복호화에 영향을 줄 수 있습니다. 가능하면 `APP_SECRET_FILE`(예:
`/run/secrets/app_secret`) 방식으로 안전하게 주입하세요.

### Frontend

```bash
cd frontend
npm ci
Expand Down
2 changes: 1 addition & 1 deletion backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,4 @@ EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/healthz').read()" || exit 1

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
CMD ["hypercorn", "--config", "python:app.hypercorn_config", "app.main:app", "--bind", "0.0.0.0:8000", "--access-logfile", "-", "--error-logfile", "-"]
10 changes: 3 additions & 7 deletions backend/app/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,7 @@ def get_sync_database_url() -> str:
return url


engine: AsyncEngine = create_async_engine(
settings.database_url, pool_pre_ping=True
)
engine: AsyncEngine = create_async_engine(settings.database_url, pool_pre_ping=True)
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)

read_only_engine: AsyncEngine | None = (
Expand Down Expand Up @@ -82,7 +80,7 @@ def _run() -> str | None:
with psycopg.connect(
dsn,
password=password,
connect_timeout=str(timeout_seconds),
connect_timeout=timeout_seconds,
) as conn:
Comment thread
coderabbitai[bot] marked this conversation as resolved.
with conn.cursor() as cur:
cur.execute("SHOW VERSION;")
Expand All @@ -109,9 +107,7 @@ async def get_pooler_detection() -> PoolerDetectionResult:
if settings.db_pooler_kind is not None:
kind = PoolerKind(settings.db_pooler_kind)
detected = kind is not PoolerKind.NONE
return PoolerDetectionResult(
kind=kind, detected=detected, version_text=None
)
return PoolerDetectionResult(kind=kind, detected=detected, version_text=None)

now = time.monotonic()
if (
Expand Down
27 changes: 27 additions & 0 deletions backend/app/hypercorn_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from __future__ import annotations

import os


def _int_env(name: str, default: str) -> int:
"""Parse an integer environment variable with a safe fallback.

Hypercorn config modules are imported at server startup. A bad env value
should not crash the process with an unhelpful ValueError.
"""

raw = os.getenv(name)
value = (raw if raw is not None else default).strip()
try:
parsed = int(value)
except (TypeError, ValueError):
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
try:
parsed = int(str(default).strip())
except (TypeError, ValueError):
parsed = 1

return max(1, parsed)
Comment thread
coderabbitai[bot] marked this conversation as resolved.


# Prefer explicit hypercorn knob; fall back to a common convention.
workers = _int_env("HYPERCORN_WORKERS", os.getenv("WEB_CONCURRENCY", "1"))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
51 changes: 51 additions & 0 deletions backend/app/settings.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
from __future__ import annotations

from pathlib import Path
from typing import Literal

from pydantic import Field
from pydantic import model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict


Expand All @@ -27,7 +29,56 @@ class Settings(BaseSettings):
# Best-effort pooler probe timeout. Keep it small to avoid blocking request
# paths.
db_pooler_probe_timeout_seconds: float = Field(0.7, ge=0.0)
# Required encryption key material.
#
# Supports the Docker/Podman *_FILE pattern (e.g. /run/secrets/app_secret)
# to avoid putting secrets directly into environment variables.
app_secret: str
app_secret_file: str | None = Field(
default=None, validation_alias="APP_SECRET_FILE"
)

@model_validator(mode="before")
@classmethod
def _load_app_secret_from_file(cls, data: object) -> object:
if not isinstance(data, dict):
return data

secret_file = data.get("APP_SECRET_FILE") or data.get("app_secret_file")
if not secret_file:
return data

path = Path(str(secret_file))
try:
resolved = path.resolve(strict=True)
except FileNotFoundError as exc:
raise ValueError(f"APP_SECRET_FILE does not exist: {path}") from exc

# Security hardening: avoid symlink tricks and restrict secret files to
# the expected secrets mount.
#
# Docker/Podman secrets are typically mounted under /run/secrets.
allowed_base = Path("/run/secrets").resolve()
if path.is_symlink():
raise ValueError("APP_SECRET_FILE must not be a symlink")
if not resolved.is_relative_to(allowed_base):
raise ValueError(
f"APP_SECRET_FILE must be under {allowed_base}: {resolved}"
)
if not resolved.is_file():
raise ValueError(
f"APP_SECRET_FILE does not exist or is not a file: {resolved}"
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# Important: secret files commonly include a trailing newline.
secret = resolved.read_text(encoding="utf-8").rstrip("\r\n")
if secret == "":
raise ValueError("APP_SECRET_FILE is empty")

# If APP_SECRET_FILE is provided, prefer it deterministically.
new_data = dict(data)
new_data["app_secret"] = secret
return new_data

cors_origins: str = "http://localhost:5173"

Expand Down
4 changes: 2 additions & 2 deletions backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,15 @@ version = "0.1.0"
requires-python = ">=3.10"
dependencies = [
"fastapi>=0.115.0",
"uvicorn[standard]>=0.30.0",
"hypercorn>=0.16.0",
"prometheus-client>=0.24.0,<0.25.0",
"pydantic>=2.8.0",
"pydantic-settings>=2.4.0",
"sqlalchemy[asyncio]>=2.0.30",
"asyncpg>=0.29.0",
"psycopg[binary]>=3.2.0",
"alembic>=1.13.0",
"cryptography>=42.0.0",
"cryptography>=44.0.1",
"httpx>=0.27.0",
"python-jose[cryptography]>=3.4.0",
]
Expand Down
Loading
Loading