NAS 정식 스택과 배포 파이프라인 도구를 고정
This commit is contained in:
parent
f1b80676c1
commit
d6d9dc5f61
8 changed files with 812 additions and 1 deletions
70
docs/ops/deployment-pipeline.md
Normal file
70
docs/ops/deployment-pipeline.md
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
# 배포 파이프라인 (Deployment Pipeline)
|
||||||
|
|
||||||
|
> 작성: 2026-08-31 · 문서 소유: docs/ops (SSOT 방향)
|
||||||
|
> 상태: **초기 이관 완료 / NAS 자동배포 구성 잔여**
|
||||||
|
|
||||||
|
## 목표 아키텍처 (소유자 지시)
|
||||||
|
|
||||||
|
```
|
||||||
|
이 PC(개발 워크스테이션)
|
||||||
|
│ push (master)
|
||||||
|
▼
|
||||||
|
Forgejo git.chanpaca.net ────── 소스 SSOT (이관 완료 2026-08-31)
|
||||||
|
│ ├─ 미러(백업) ── github.com (private 유지) [현행 origin]
|
||||||
|
│ └─ 배포 ──▶ NAS Production (vignette-prod)
|
||||||
|
│ docker-compose.nas.yml (SSOT-host=nas, runtime-class=production)
|
||||||
|
│
|
||||||
|
Cloudflare = 서비스 공개 서빙용만 유지 (tunnel 제거 대상)
|
||||||
|
```
|
||||||
|
|
||||||
|
- 나는 이 PC를 **개발 전용**으로만 사용한다. 배포 대상은 반드시 **NAS Production**이다.
|
||||||
|
- github.com은 **private**로 백업/미러만 유지한다 (제거 아님).
|
||||||
|
- git 관리·배포 파이프라인은 **git.chanpaca.net (Forgejo)** 이 소유한다.
|
||||||
|
|
||||||
|
## 1. 저장소 (소스 SSOT)
|
||||||
|
|
||||||
|
| 원격 | URL | 역할 | 상태 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `origin` | https://github.com/yunchan8804-blip/vignette.git | private 백업/미러 | 유지 |
|
||||||
|
| `forgejo` | ssh://git@git.chanpaca.net:2222/yunchan/vignette.git | **소스 SSOT·배포** | ✅ 이관 완료 |
|
||||||
|
|
||||||
|
- Forgejo 레포: `yunchan/vignette` (id 14, public, default master)
|
||||||
|
- 이관 기준 커밋: `be08c0b5` (master 최신, 로컬 HEAD와 일치)
|
||||||
|
- Forgejo 접근: SSH 키(`~/.ssh/id_ed25519`, Host `git.chanpaca.net` → `192.168.0.38:2222`) · API 토큰(`infra/.env.deploy`의 `FORGEJO_API_TOKEN`, gitignore)
|
||||||
|
|
||||||
|
## 2. 로컬 브랜치 규칙
|
||||||
|
|
||||||
|
- `master` = Forgejo `master`와 동기화 (배포 기준선)
|
||||||
|
- 기능/scoped 브랜치 = 개발용, 검증 후 master로 합침
|
||||||
|
|
||||||
|
## 3. NAS Production 배포 흐름 (구성 예정)
|
||||||
|
|
||||||
|
```
|
||||||
|
[이 PC] git push forgejo master
|
||||||
|
▼
|
||||||
|
[Forgejo] master = be08c0b5 (SSOT)
|
||||||
|
▼
|
||||||
|
[NAS 192.168.0.38] git pull origin master (vignette-prod 소스)
|
||||||
|
▼ docker compose -f docker-compose.nas.yml up -d --build
|
||||||
|
vignette-prod (api/web/engine/db/proxy) 재기동
|
||||||
|
▼
|
||||||
|
[Cloudflare] 서빙 (api-vignette.chanpaca.net / vignette.chanpaca.net)
|
||||||
|
```
|
||||||
|
|
||||||
|
NAS 배포는 Forgejo에서 직접 pull하는 방식으로 전환하고, cloudflare tunnel에 의존하지 않는다.
|
||||||
|
|
||||||
|
## 4. 검증 게이트 (배포 전)
|
||||||
|
|
||||||
|
- `apps/web`: `npm run typecheck`, `npm run build`
|
||||||
|
- `apps/api`: 관련 test suite
|
||||||
|
- SSOT checker `scripts/test_dev_dashboard_ssot.py`
|
||||||
|
- Forgejo master == 로컬 master (배포 무결성)
|
||||||
|
- NAS: `docker compose ps` healthy, `/api/health` ok·db/engine true
|
||||||
|
|
||||||
|
## 5. 남은 구성 작업 (TODO)
|
||||||
|
|
||||||
|
- [ ] Forgejo master를 기준으로 NAS 자동배포 스크립트/후크 구성 (또는 NAS에서 git pull)
|
||||||
|
- [ ] `origin`(github) private 유지하나 Forgejo가 SSOT임을 문서·SSOT 대시보드에 반영
|
||||||
|
- [ ] cloudflare tunnel 제거 (서빙용만 유지)
|
||||||
|
- [ ] 배포 시 NAS 접근 자격(SSH 키/배포 계정)을 `.env.deploy`/시크릿으로 관리
|
||||||
|
```
|
||||||
22
infra/Caddyfile.nas
Normal file
22
infra/Caddyfile.nas
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
{
|
||||||
|
admin off
|
||||||
|
auto_https off
|
||||||
|
}
|
||||||
|
|
||||||
|
:80 {
|
||||||
|
@voice path /api/voice/ws*
|
||||||
|
reverse_proxy @voice api:8000
|
||||||
|
|
||||||
|
@sse path /api/*/stream*
|
||||||
|
reverse_proxy @sse api:8000 {
|
||||||
|
flush_interval -1
|
||||||
|
}
|
||||||
|
|
||||||
|
handle_path /api/* {
|
||||||
|
reverse_proxy api:8000
|
||||||
|
}
|
||||||
|
|
||||||
|
handle {
|
||||||
|
reverse_proxy web:80
|
||||||
|
}
|
||||||
|
}
|
||||||
308
infra/docker-compose.nas.yml
Normal file
308
infra/docker-compose.nas.yml
Normal file
|
|
@ -0,0 +1,308 @@
|
||||||
|
name: vignette-prod
|
||||||
|
|
||||||
|
# NAS 운영 전용 완결 스택이다. infra/docker-compose.yml의 로컬 개발 스택과 합치지 않는다.
|
||||||
|
# 외부 볼륨은 백업/격리 복구/휴먼 게이트를 통과한 뒤 운영자가 미리 생성해야 한다.
|
||||||
|
|
||||||
|
x-api-image: &api-image
|
||||||
|
image: ${VIGNETTE_NAS_API_IMAGE:?Immutable NAS API image reference is required}
|
||||||
|
|
||||||
|
x-web-image: &web-image
|
||||||
|
image: ${VIGNETTE_NAS_WEB_IMAGE:?Immutable NAS web image reference is required}
|
||||||
|
|
||||||
|
x-production-labels: &production-labels
|
||||||
|
com.vignette.owner: vignette
|
||||||
|
com.vignette.runtime-class: production
|
||||||
|
com.vignette.ssot-host: nas
|
||||||
|
com.vignette.stack: vignette-prod
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: ${VIGNETTE_NAS_POSTGRES_IMAGE:?Immutable NAS PostgreSQL image ID is required}
|
||||||
|
restart: unless-stopped
|
||||||
|
stop_grace_period: 1m
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-vignette_owner}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?NAS PostgreSQL owner password is required}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-vignette}
|
||||||
|
APP_DB_USER: ${APP_DB_USER:-vignette_app}
|
||||||
|
APP_DB_PASSWORD: ${APP_DB_PASSWORD:?NAS application DB password is required}
|
||||||
|
volumes:
|
||||||
|
- type: volume
|
||||||
|
source: pgdata
|
||||||
|
target: /var/lib/postgresql/data
|
||||||
|
- type: bind
|
||||||
|
source: ./db/init
|
||||||
|
target: /docker-entrypoint-initdb.d
|
||||||
|
read_only: true
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
- CMD-SHELL
|
||||||
|
- >-
|
||||||
|
PGPASSWORD=$${APP_DB_PASSWORD} psql -U $${APP_DB_USER} -d $${POSTGRES_DB}
|
||||||
|
-tAc "SELECT to_regclass('app.ci_regression_dag_node') IS NOT NULL"
|
||||||
|
| grep -qx t
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
start_period: 5m
|
||||||
|
labels:
|
||||||
|
<<: *production-labels
|
||||||
|
com.vignette.data-role: database-ssot
|
||||||
|
networks: [backend]
|
||||||
|
|
||||||
|
engine:
|
||||||
|
<<: *api-image
|
||||||
|
command:
|
||||||
|
- python
|
||||||
|
- -m
|
||||||
|
- uvicorn
|
||||||
|
- engine_gateway.gateway:app
|
||||||
|
- --host
|
||||||
|
- 0.0.0.0
|
||||||
|
- --port
|
||||||
|
- "9099"
|
||||||
|
restart: unless-stopped
|
||||||
|
stop_grace_period: 30s
|
||||||
|
environment:
|
||||||
|
OPENAI_API_KEY: ${OPENAI_API_KEY:?OpenAI key is required for the NAS engine}
|
||||||
|
OPENAI_BASE_URL: ${OPENAI_BASE_URL:-https://api.openai.com/v1}
|
||||||
|
OPENAI_ENGINE_MODEL: ${OPENAI_ENGINE_MODEL:?An approved OpenAI engine model is required}
|
||||||
|
OPENAI_ENGINE_MODELS: ${OPENAI_ENGINE_MODELS:-gpt-5.6-terra,gpt-5.6-luna,gpt-4.1}
|
||||||
|
ENGINE_GATEWAY_SHARED_SECRET: ${ENGINE_GATEWAY_SHARED_SECRET:?Gateway shared secret is required}
|
||||||
|
ENGINE_CAPABILITY_CACHE_TTL_SECONDS: ${ENGINE_CAPABILITY_CACHE_TTL_SECONDS:-1800}
|
||||||
|
ENGINE_READY_TTL_SECONDS: ${ENGINE_READY_TTL_SECONDS:-1800}
|
||||||
|
ENGINE_READY_FAILURE_TTL_SECONDS: ${ENGINE_READY_FAILURE_TTL_SECONDS:-30}
|
||||||
|
ENGINE_READY_TIMEOUT_SECONDS: ${ENGINE_READY_TIMEOUT_SECONDS:-45}
|
||||||
|
ENGINE_GENERATE_TIMEOUT_SECONDS: ${ENGINE_GENERATE_TIMEOUT_SECONDS:-300}
|
||||||
|
expose:
|
||||||
|
- "9099"
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp:rw,noexec,nosuid,size=128m
|
||||||
|
cap_drop: [ALL]
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
- CMD
|
||||||
|
- python
|
||||||
|
- -c
|
||||||
|
- >-
|
||||||
|
import json, urllib.request;
|
||||||
|
payload=json.load(urllib.request.urlopen('http://localhost:9099/health'));
|
||||||
|
assert payload.get('ok') is True
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 8
|
||||||
|
start_period: 30s
|
||||||
|
labels:
|
||||||
|
<<: *production-labels
|
||||||
|
com.vignette.data-role: engine-ssot
|
||||||
|
networks: [backend, app-egress]
|
||||||
|
|
||||||
|
api:
|
||||||
|
<<: *api-image
|
||||||
|
restart: unless-stopped
|
||||||
|
stop_grace_period: 1m
|
||||||
|
# NAS 호스트 터널만 접근할 수 있도록 loopback에만 고정 바인딩한다.
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:18080:8000"
|
||||||
|
depends_on:
|
||||||
|
db: { condition: service_healthy }
|
||||||
|
engine: { condition: service_healthy }
|
||||||
|
environment:
|
||||||
|
ENVIRONMENT: prod
|
||||||
|
DATABASE_URL: postgresql://${APP_DB_USER:-vignette_app}:${APP_DB_PASSWORD:?NAS application DB password is required}@db:5432/${POSTGRES_DB:-vignette}
|
||||||
|
ENGINE_URL: http://engine:9099
|
||||||
|
ENGINE_MODE: openai
|
||||||
|
VIGNETTE_LIVE_CLIENT_PROVIDER: openai
|
||||||
|
ENGINE_GATEWAY_SHARED_SECRET: ${ENGINE_GATEWAY_SHARED_SECRET:?Gateway shared secret is required}
|
||||||
|
OPENAI_API_KEY: ${OPENAI_API_KEY:?OpenAI key is required for production voice and engine paths}
|
||||||
|
OPENAI_BASE_URL: ${OPENAI_BASE_URL:-https://api.openai.com/v1}
|
||||||
|
VIGNETTE_VOICE_STT_PROVIDER: openai
|
||||||
|
VIGNETTE_VOICE_TTS_PROVIDER: openai
|
||||||
|
SESSION_SECRET: ${SESSION_SECRET:?Production session secret is required}
|
||||||
|
VIGNETTE_RUPTURE_INTERNAL_TOKEN: ${VIGNETTE_RUPTURE_INTERNAL_TOKEN:?G3 token is required}
|
||||||
|
VIGNETTE_PRACTICE_INTERNAL_TOKEN: ${VIGNETTE_PRACTICE_INTERNAL_TOKEN:?G4 token is required}
|
||||||
|
VIGNETTE_CALIBRATION_TRANSFER_INTERNAL_TOKEN: ${VIGNETTE_CALIBRATION_TRANSFER_INTERNAL_TOKEN:?G5 token is required}
|
||||||
|
VIGNETTE_SUPERVISION_RESEARCH_INTERNAL_TOKEN: ${VIGNETTE_SUPERVISION_RESEARCH_INTERNAL_TOKEN:?G6 token is required}
|
||||||
|
VIGNETTE_MULTIMODAL_ALLIANCE_INTERNAL_TOKEN: ${VIGNETTE_MULTIMODAL_ALLIANCE_INTERNAL_TOKEN:?G7 token is required}
|
||||||
|
VIGNETTE_CONTINUOUS_IMPROVEMENT_INTERNAL_TOKEN: ${VIGNETTE_CONTINUOUS_IMPROVEMENT_INTERNAL_TOKEN:?G8 token is required}
|
||||||
|
VIGNETTE_CONTINUOUS_IMPROVEMENT_PRODUCER_ENABLED: ${VIGNETTE_CONTINUOUS_IMPROVEMENT_PRODUCER_ENABLED:-false}
|
||||||
|
VIGNETTE_CONTINUOUS_IMPROVEMENT_PRODUCER_INTERVAL_SECONDS: ${VIGNETTE_CONTINUOUS_IMPROVEMENT_PRODUCER_INTERVAL_SECONDS:-3600}
|
||||||
|
VIGNETTE_CONTINUOUS_IMPROVEMENT_PRODUCER_STARTUP_DELAY_SECONDS: ${VIGNETTE_CONTINUOUS_IMPROVEMENT_PRODUCER_STARTUP_DELAY_SECONDS:-30}
|
||||||
|
VIGNETTE_CONTINUOUS_IMPROVEMENT_PRODUCER_RETRY_DELAY_SECONDS: ${VIGNETTE_CONTINUOUS_IMPROVEMENT_PRODUCER_RETRY_DELAY_SECONDS:-300}
|
||||||
|
VIGNETTE_CONTINUOUS_IMPROVEMENT_PRODUCER_LEASE_TIMEOUT_SECONDS: ${VIGNETTE_CONTINUOUS_IMPROVEMENT_PRODUCER_LEASE_TIMEOUT_SECONDS:-1800}
|
||||||
|
VIGNETTE_CONTINUOUS_IMPROVEMENT_PRODUCER_ENGINE_TIMEOUT_SECONDS: ${VIGNETTE_CONTINUOUS_IMPROVEMENT_PRODUCER_ENGINE_TIMEOUT_SECONDS:-300}
|
||||||
|
VIGNETTE_CONTINUOUS_IMPROVEMENT_PRODUCER_BATCH_SIZE: ${VIGNETTE_CONTINUOUS_IMPROVEMENT_PRODUCER_BATCH_SIZE:-1}
|
||||||
|
VIGNETTE_CONTINUOUS_IMPROVEMENT_DRIFT_TRIGGER_ENABLED: ${VIGNETTE_CONTINUOUS_IMPROVEMENT_DRIFT_TRIGGER_ENABLED:-false}
|
||||||
|
VIGNETTE_CONTINUOUS_IMPROVEMENT_ROLLBACK_EXECUTOR_ENABLED: "false"
|
||||||
|
OAUTH_GOOGLE_CLIENT_ID: ${OAUTH_GOOGLE_CLIENT_ID:?Google OAuth client id is required}
|
||||||
|
OAUTH_GOOGLE_CLIENT_SECRET: ${OAUTH_GOOGLE_CLIENT_SECRET:?Google OAuth client secret is required}
|
||||||
|
OAUTH_REDIRECT_URI: ${OAUTH_REDIRECT_URI:-https://api-vignette.chanpaca.net/auth/callback}
|
||||||
|
AUTH_ALLOWED_EMAIL_DOMAINS: '${AUTH_ALLOWED_EMAIL_DOMAINS:-["hs.ac.kr","twentyoz.kr"]}'
|
||||||
|
AUTH_TEACHER_EMAILS: '${AUTH_TEACHER_EMAILS:-[]}'
|
||||||
|
AUTH_ADMIN_EMAILS: '${AUTH_ADMIN_EMAILS:-[]}'
|
||||||
|
AUTH_SUPER_ADMIN_EMAILS: '${AUTH_SUPER_ADMIN_EMAILS:?Super admin allowlist is required}'
|
||||||
|
AUTH_APPROVED_EMAILS: '${AUTH_APPROVED_EMAILS:-[]}'
|
||||||
|
AUTH_NEW_USER_DEFAULT_STATUS: pending
|
||||||
|
AUTH_DEV_LOGIN_ENABLED: "false"
|
||||||
|
AUTH_DEV_LOGIN_EXTRA_ORIGINS: '[]'
|
||||||
|
DEFAULT_AFFILIATION: ${DEFAULT_AFFILIATION:-}
|
||||||
|
AUTO_SEED_PERSONAS: "false"
|
||||||
|
ALLOW_SEED_PERSONA_FALLBACK: "false"
|
||||||
|
EVALUATOR_GOLDEN_FEWSHOT_ENABLED: ${EVALUATOR_GOLDEN_FEWSHOT_ENABLED:-false}
|
||||||
|
FRONTEND_BASE_URL: https://vignette.chanpaca.net
|
||||||
|
FRONTEND_ORIGIN_MAP: '${FRONTEND_ORIGIN_MAP:?Set FRONTEND_ORIGIN_MAP in the NAS secret environment}'
|
||||||
|
CORS_ORIGINS: '${CORS_ORIGINS:-["https://vignette.chanpaca.net","https://vignette-b1q.pages.dev"]}'
|
||||||
|
USER_UPLOAD_DIR: /app/uploads
|
||||||
|
NOTIFICATION_EMAIL_PROVIDER: ${NOTIFICATION_EMAIL_PROVIDER:-disabled}
|
||||||
|
NOTIFICATION_EMAIL_MAX_ATTEMPTS: ${NOTIFICATION_EMAIL_MAX_ATTEMPTS:-3}
|
||||||
|
NOTIFICATION_EMAIL_RETRY_SECONDS: ${NOTIFICATION_EMAIL_RETRY_SECONDS:-900}
|
||||||
|
SMTP_HOST: ${SMTP_HOST:-}
|
||||||
|
SMTP_PORT: ${SMTP_PORT:-587}
|
||||||
|
SMTP_USERNAME: ${SMTP_USERNAME:-}
|
||||||
|
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
|
||||||
|
SMTP_FROM_EMAIL: ${SMTP_FROM_EMAIL:-}
|
||||||
|
SMTP_FROM_NAME: ${SMTP_FROM_NAME:-Vignette}
|
||||||
|
SMTP_STARTTLS: ${SMTP_STARTTLS:-true}
|
||||||
|
SMTP_SSL: ${SMTP_SSL:-false}
|
||||||
|
VIGNETTE_RUNTIME_CLASS: production
|
||||||
|
VIGNETTE_SSOT_HOST: nas
|
||||||
|
VIGNETTE_NAS_PGDATA_VOLUME: ${VIGNETTE_NAS_PGDATA_VOLUME:?Explicit NAS PostgreSQL volume name is required}
|
||||||
|
VIGNETTE_NAS_APIUPLOADS_VOLUME: ${VIGNETTE_NAS_APIUPLOADS_VOLUME:?Explicit NAS uploads volume name is required}
|
||||||
|
volumes:
|
||||||
|
- type: volume
|
||||||
|
source: apiuploads
|
||||||
|
target: /app/uploads
|
||||||
|
volume:
|
||||||
|
nocopy: true
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp:rw,noexec,nosuid,size=128m
|
||||||
|
cap_drop: [ALL]
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
- CMD
|
||||||
|
- python
|
||||||
|
- -c
|
||||||
|
- >-
|
||||||
|
import json, urllib.request;
|
||||||
|
payload=json.load(urllib.request.urlopen('http://localhost:8000/health'));
|
||||||
|
assert payload.get('status') == 'ok' and payload.get('db') is True and payload.get('engine') is True
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 8
|
||||||
|
start_period: 2m
|
||||||
|
labels:
|
||||||
|
<<: *production-labels
|
||||||
|
com.vignette.data-role: api-ssot
|
||||||
|
networks: [backend, app-egress]
|
||||||
|
|
||||||
|
web:
|
||||||
|
<<: *web-image
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
api: { condition: service_healthy }
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp:rw,noexec,nosuid,size=16m
|
||||||
|
# nginx가 client/proxy/fastcgi 임시 파일과 PID를 기록해야 한다.
|
||||||
|
# 루트 FS는 계속 read-only로 유지한다.
|
||||||
|
- /var/cache/nginx:rw,noexec,nosuid,size=16m
|
||||||
|
- /var/run:rw,noexec,nosuid,size=1m
|
||||||
|
cap_drop: [ALL]
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
labels:
|
||||||
|
<<: *production-labels
|
||||||
|
com.vignette.data-role: static-web-replica
|
||||||
|
networks: [backend]
|
||||||
|
|
||||||
|
private-proxy:
|
||||||
|
image: caddy@sha256:5f5c8640aae01df9654968d946d8f1a56c497f1dd5c5cda4cf95ab7c14d58648
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
web: { condition: service_started }
|
||||||
|
api: { condition: service_healthy }
|
||||||
|
ports:
|
||||||
|
- "${VIGNETTE_NAS_PRIVATE_BIND_ADDRESS:?Explicit NAS Tailnet or private bind address is required}:${VIGNETTE_NAS_PRIVATE_HTTP_PORT:-18088}:80"
|
||||||
|
volumes:
|
||||||
|
- type: bind
|
||||||
|
source: ./Caddyfile.nas
|
||||||
|
target: /etc/caddy/Caddyfile
|
||||||
|
read_only: true
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /data:rw,noexec,nosuid,size=16m
|
||||||
|
- /config:rw,noexec,nosuid,size=16m
|
||||||
|
- /tmp:rw,noexec,nosuid,size=16m
|
||||||
|
cap_drop: [ALL]
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
labels:
|
||||||
|
<<: *production-labels
|
||||||
|
com.vignette.data-role: tailnet-private-validation
|
||||||
|
networks: [backend]
|
||||||
|
|
||||||
|
cloudflared:
|
||||||
|
image: cloudflare/cloudflared@sha256:d9ff527dc19799e24d3689058161fb01e752a24be1823577bae54c9c2489275f
|
||||||
|
profiles: [public-cutover-gate]
|
||||||
|
command:
|
||||||
|
- tunnel
|
||||||
|
- --no-autoupdate
|
||||||
|
- --config
|
||||||
|
- /etc/cloudflared/config.yml
|
||||||
|
- run
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
api: { condition: service_healthy }
|
||||||
|
volumes:
|
||||||
|
- type: bind
|
||||||
|
source: ${VIGNETTE_TUNNEL_CONFIG_PATH:?Rendered cloudflared config path is required}
|
||||||
|
target: /etc/cloudflared/config.yml
|
||||||
|
read_only: true
|
||||||
|
- type: bind
|
||||||
|
source: ${VIGNETTE_TUNNEL_CREDENTIAL_PATH:?Tunnel credential JSON path is required}
|
||||||
|
target: /run/secrets/vignette-tunnel.json
|
||||||
|
read_only: true
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp:rw,noexec,nosuid,size=32m
|
||||||
|
cap_drop: [ALL]
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
labels:
|
||||||
|
<<: *production-labels
|
||||||
|
com.vignette.data-role: gated-public-edge
|
||||||
|
# API는 NAS loopback에만 열리고, 터널은 호스트 네트워크로 이를 호출한다.
|
||||||
|
# Docker 재시작 뒤에도 restart 정책으로 공개 연결을 자동 복구한다.
|
||||||
|
network_mode: host
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
|
external: true
|
||||||
|
name: ${VIGNETTE_NAS_PGDATA_VOLUME:?Explicit NAS PostgreSQL volume name is required}
|
||||||
|
apiuploads:
|
||||||
|
external: true
|
||||||
|
name: ${VIGNETTE_NAS_APIUPLOADS_VOLUME:?Explicit NAS uploads volume name is required}
|
||||||
|
|
||||||
|
networks:
|
||||||
|
backend:
|
||||||
|
name: vignette-prod-backend
|
||||||
|
driver: bridge
|
||||||
|
internal: true
|
||||||
|
labels:
|
||||||
|
<<: *production-labels
|
||||||
|
app-egress:
|
||||||
|
name: vignette-prod-app-egress
|
||||||
|
driver: bridge
|
||||||
|
labels:
|
||||||
|
<<: *production-labels
|
||||||
|
tunnel-egress:
|
||||||
|
name: vignette-prod-tunnel-egress
|
||||||
|
driver: bridge
|
||||||
|
labels:
|
||||||
|
<<: *production-labels
|
||||||
39
scripts/dist-snap.py
Normal file
39
scripts/dist-snap.py
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
"""dist 결정성 스냅샷 — sha256 파일 지도를 만들고 두 빌드를 비교한다.
|
||||||
|
|
||||||
|
사용: python -X utf8 dist-snap.py snap1 | python -X utf8 dist-snap.py compare dist-snap1.json
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def snap(root: str) -> dict:
|
||||||
|
out = {}
|
||||||
|
for dp, _, fns in os.walk(root):
|
||||||
|
for fn in fns:
|
||||||
|
p = os.path.join(dp, fn)
|
||||||
|
rel = os.path.relpath(p, root).replace(os.sep, "/")
|
||||||
|
with open(p, "rb") as fh:
|
||||||
|
out[rel] = hashlib.sha256(fh.read()).hexdigest()
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
if sys.argv[1] == "snap":
|
||||||
|
s = snap("dist")
|
||||||
|
name = sys.argv[2]
|
||||||
|
with open(name, "w", encoding="utf-8") as fh:
|
||||||
|
json.dump(s, fh)
|
||||||
|
print("files:", len(s))
|
||||||
|
elif sys.argv[1] == "compare":
|
||||||
|
with open(sys.argv[2], encoding="utf-8") as fh:
|
||||||
|
s1 = json.load(fh)
|
||||||
|
s2 = snap("dist")
|
||||||
|
only1 = sorted(set(s1) - set(s2))
|
||||||
|
only2 = sorted(set(s2) - set(s1))
|
||||||
|
diff = sorted(k for k in set(s1) & set(s2) if s1[k] != s2[k])
|
||||||
|
print("build1 files:", len(s1), "build2 files:", len(s2))
|
||||||
|
print("only_in_build1:", only1[:10])
|
||||||
|
print("only_in_build2:", only2[:10])
|
||||||
|
print("content_diff:", diff[:10])
|
||||||
|
print("DETERMINISTIC" if not (only1 or only2 or diff) else "NON_DETERMINISTIC")
|
||||||
90
scripts/preserve-assets.mjs
Normal file
90
scripts/preserve-assets.mjs
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
/**
|
||||||
|
* 이전 Pages 배포 세대의 자산 보존 — immutable preview URL(들)의 HTML/JS/CSS 의존
|
||||||
|
* 그래프를 따라 내려받아, 새 빌드 dist 에 없는 파일만 추가한다(기존 파일은 덮어쓰지 않는다).
|
||||||
|
* 사용: node preserve-assets.mjs
|
||||||
|
*/
|
||||||
|
import { mkdir, writeFile, readFile } from "node:fs/promises";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const ORIGINS = [
|
||||||
|
"https://vignette.chanpaca.net",
|
||||||
|
"https://5a525329.vignette-b1q.pages.dev",
|
||||||
|
"https://e1c73735.vignette-b1q.pages.dev",
|
||||||
|
"https://bbebb2d3.vignette-b1q.pages.dev",
|
||||||
|
"https://44edecc8.vignette-b1q.pages.dev",
|
||||||
|
"https://0ffdc694.vignette-b1q.pages.dev",
|
||||||
|
"https://7bd59055.vignette-b1q.pages.dev",
|
||||||
|
];
|
||||||
|
|
||||||
|
const DIST = "dist";
|
||||||
|
|
||||||
|
async function exists(p) {
|
||||||
|
try {
|
||||||
|
await readFile(p);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collect(origin) {
|
||||||
|
const seen = new Map(); // path -> sha256 hex
|
||||||
|
const html = await (await fetch(origin + "/")).text();
|
||||||
|
// index.html 이 참조하는 엔트리 자산
|
||||||
|
const refs = new Set();
|
||||||
|
for (const m of html.matchAll(/(?:src|href)="(\/[^"]+\.(?:js|css))"/g)) refs.add(m[1]);
|
||||||
|
for (const m of html.matchAll(/(?:src|href)="(\/[^"]+\.(?:png|svg|ico|webmanifest|json|woff2?))"/g))
|
||||||
|
refs.add(m[1]);
|
||||||
|
// JS 청크 의존 그래프 폐쇄
|
||||||
|
const queue = [...refs];
|
||||||
|
while (queue.length) {
|
||||||
|
const ref = queue.pop();
|
||||||
|
if (seen.has(ref)) continue;
|
||||||
|
const res = await fetch(origin + ref);
|
||||||
|
if (!res.ok) {
|
||||||
|
seen.set(ref, "FETCH_FAILED_" + res.status);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const buf = Buffer.from(await res.arrayBuffer());
|
||||||
|
seen.set(ref, createHash("sha256").update(buf).digest("hex"));
|
||||||
|
if (ref.endsWith(".js")) {
|
||||||
|
const text = buf.toString("utf8");
|
||||||
|
for (const m of text.matchAll(/["'`](\.\/[A-Za-z0-9_.-]+\.(?:js|css))["'`]/g)) {
|
||||||
|
const p = path.posix.normalize(path.posix.join(path.posix.dirname(ref), m[1]));
|
||||||
|
if (!seen.has(p)) queue.push(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return seen;
|
||||||
|
}
|
||||||
|
|
||||||
|
const perOrigin = [];
|
||||||
|
let added = 0;
|
||||||
|
const addedList = [];
|
||||||
|
for (const origin of ORIGINS) {
|
||||||
|
const map = await collect(origin);
|
||||||
|
let ok = 0;
|
||||||
|
for (const [p, sha] of map) {
|
||||||
|
if (sha.startsWith("FETCH_FAILED")) continue;
|
||||||
|
const rel = p.replace(/^\//, "");
|
||||||
|
const target = path.join(DIST, rel);
|
||||||
|
if (await exists(target)) {
|
||||||
|
ok += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await mkdir(path.dirname(target), { recursive: true });
|
||||||
|
const res = await fetch(origin + p);
|
||||||
|
await writeFile(target, Buffer.from(await res.arrayBuffer()));
|
||||||
|
added += 1;
|
||||||
|
addedList.push(rel);
|
||||||
|
}
|
||||||
|
perOrigin.push({ origin, assets: map.size, reused_in_new_build: ok });
|
||||||
|
console.log(`origin ${origin}: ${map.size} assets, ${ok} already in new build`);
|
||||||
|
}
|
||||||
|
console.log(`assets_added: ${added}`);
|
||||||
|
await writeFile(
|
||||||
|
"preserve-report.json",
|
||||||
|
JSON.stringify({ origins: perOrigin, assets_added: added, added: addedList }, null, 1),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
@ -124,8 +124,17 @@ RELEASE_DB_MIGRATIONS = (
|
||||||
"17_improvement_workbook_contracts.sql",
|
"17_improvement_workbook_contracts.sql",
|
||||||
"18_admin_usage_ledger_index.sql",
|
"18_admin_usage_ledger_index.sql",
|
||||||
"19_auth_identity_alias.sql",
|
"19_auth_identity_alias.sql",
|
||||||
|
"20_public_bootstrap_ticket_events.sql",
|
||||||
|
"21_single_active_session.sql",
|
||||||
|
"22_case_profile_multi_case.sql",
|
||||||
|
)
|
||||||
|
RELEASE_DB_ONLINE_MIGRATIONS = frozenset(
|
||||||
|
{
|
||||||
|
"18_admin_usage_ledger_index.sql",
|
||||||
|
"21_single_active_session.sql",
|
||||||
|
"22_case_profile_multi_case.sql",
|
||||||
|
}
|
||||||
)
|
)
|
||||||
RELEASE_DB_ONLINE_MIGRATIONS = frozenset({"18_admin_usage_ledger_index.sql"})
|
|
||||||
|
|
||||||
# These files are runtime-critical but were added after the first manifest
|
# These files are runtime-critical but were added after the first manifest
|
||||||
# snapshot. A release must classify them as whole-file payloads; otherwise a
|
# snapshot. A release must classify them as whole-file payloads; otherwise a
|
||||||
|
|
@ -138,6 +147,9 @@ REQUIRED_RELEASE_PAYLOAD_PATHS = (
|
||||||
"infra/db/init/17_improvement_workbook_contracts.sql",
|
"infra/db/init/17_improvement_workbook_contracts.sql",
|
||||||
"infra/db/init/18_admin_usage_ledger_index.sql",
|
"infra/db/init/18_admin_usage_ledger_index.sql",
|
||||||
"infra/db/init/19_auth_identity_alias.sql",
|
"infra/db/init/19_auth_identity_alias.sql",
|
||||||
|
"infra/db/init/20_public_bootstrap_ticket_events.sql",
|
||||||
|
"infra/db/init/21_single_active_session.sql",
|
||||||
|
"infra/db/init/22_case_profile_multi_case.sql",
|
||||||
)
|
)
|
||||||
|
|
||||||
REQUIRED_OPENAPI_PATHS = {
|
REQUIRED_OPENAPI_PATHS = {
|
||||||
|
|
|
||||||
|
|
@ -419,6 +419,122 @@ class OutcomeReleaseAgentTests(unittest.TestCase):
|
||||||
self.agent_module.REQUIRED_RELEASE_PAYLOAD_PATHS,
|
self.agent_module.REQUIRED_RELEASE_PAYLOAD_PATHS,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_single_active_session_index_is_online_and_release_required(self) -> None:
|
||||||
|
migration_path = (
|
||||||
|
SCRIPT_PATH.parent.parent
|
||||||
|
/ "infra"
|
||||||
|
/ "db"
|
||||||
|
/ "init"
|
||||||
|
/ "21_single_active_session.sql"
|
||||||
|
)
|
||||||
|
migration = migration_path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
self.assertNotIn("BEGIN;", migration)
|
||||||
|
self.assertIn(
|
||||||
|
"CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS uq_sessions_one_active_learner_persona",
|
||||||
|
migration,
|
||||||
|
)
|
||||||
|
self.assertIn("ON app.sessions (learner_id, persona_id)", migration)
|
||||||
|
self.assertIn("WHERE ended_at IS NULL AND persona_id IS NOT NULL;", migration)
|
||||||
|
self.assertIn(
|
||||||
|
"duplicate active learner-persona sessions exist",
|
||||||
|
migration,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"target index exists but is invalid or has a different definition",
|
||||||
|
migration,
|
||||||
|
)
|
||||||
|
self.assertIn("index_meta.indisvalid", migration)
|
||||||
|
self.assertIn("index_meta.indisready", migration)
|
||||||
|
self.assertIn("valid target unique index was not created", migration)
|
||||||
|
self.assertNotIn("COMMIT;", migration)
|
||||||
|
self.assertIn(
|
||||||
|
"21_single_active_session.sql",
|
||||||
|
self.agent_module.RELEASE_DB_MIGRATIONS,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"21_single_active_session.sql",
|
||||||
|
self.agent_module.RELEASE_DB_ONLINE_MIGRATIONS,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"infra/db/init/21_single_active_session.sql",
|
||||||
|
self.agent_module.REQUIRED_RELEASE_PAYLOAD_PATHS,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"20_public_bootstrap_ticket_events.sql",
|
||||||
|
self.agent_module.RELEASE_DB_MIGRATIONS,
|
||||||
|
)
|
||||||
|
self.assertNotIn(
|
||||||
|
"20_public_bootstrap_ticket_events.sql",
|
||||||
|
self.agent_module.RELEASE_DB_ONLINE_MIGRATIONS,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"infra/db/init/20_public_bootstrap_ticket_events.sql",
|
||||||
|
self.agent_module.REQUIRED_RELEASE_PAYLOAD_PATHS,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_case_profile_multi_case_migration_is_online_safe_and_release_required(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
migration_path = (
|
||||||
|
SCRIPT_PATH.parent.parent
|
||||||
|
/ "infra"
|
||||||
|
/ "db"
|
||||||
|
/ "init"
|
||||||
|
/ "22_case_profile_multi_case.sql"
|
||||||
|
)
|
||||||
|
migration = migration_path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
self.assertNotIn("BEGIN;", migration)
|
||||||
|
self.assertNotIn("COMMIT;", migration)
|
||||||
|
self.assertIn("legacy_constraints text[];", migration)
|
||||||
|
self.assertIn("constraint_meta.contype = 'u'", migration)
|
||||||
|
self.assertIn(
|
||||||
|
"ARRAY['persona_id', 'learner_id']::text[]",
|
||||||
|
migration,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"multiple legacy case_profile persona-learner unique constraints exist",
|
||||||
|
migration,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"ALTER TABLE app.case_profile DROP CONSTRAINT %I",
|
||||||
|
migration,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"legacy case_profile persona-learner unique constraint remains",
|
||||||
|
migration,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_case_profile_learner_persona_activity",
|
||||||
|
migration,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"ON app.case_profile (learner_id, persona_id, updated_at DESC, case_id);",
|
||||||
|
migration,
|
||||||
|
)
|
||||||
|
self.assertIn("index_meta.indisvalid", migration)
|
||||||
|
self.assertIn("index_meta.indisready", migration)
|
||||||
|
self.assertIn("NOT index_meta.indisunique", migration)
|
||||||
|
self.assertIn("index_meta.indnkeyatts = 4", migration)
|
||||||
|
self.assertIn(
|
||||||
|
"target index exists but is invalid or has a different definition",
|
||||||
|
migration,
|
||||||
|
)
|
||||||
|
self.assertIn("valid case activity index was not created", migration)
|
||||||
|
self.assertIn(
|
||||||
|
"22_case_profile_multi_case.sql",
|
||||||
|
self.agent_module.RELEASE_DB_MIGRATIONS,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"22_case_profile_multi_case.sql",
|
||||||
|
self.agent_module.RELEASE_DB_ONLINE_MIGRATIONS,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"infra/db/init/22_case_profile_multi_case.sql",
|
||||||
|
self.agent_module.REQUIRED_RELEASE_PAYLOAD_PATHS,
|
||||||
|
)
|
||||||
|
|
||||||
def test_two_patch_runs_must_be_byte_identical_before_any_deploy(self) -> None:
|
def test_two_patch_runs_must_be_byte_identical_before_any_deploy(self) -> None:
|
||||||
agent, runner, deployment, _, _ = self.make_agent(
|
agent, runner, deployment, _, _ = self.make_agent(
|
||||||
active_sha=None,
|
active_sha=None,
|
||||||
|
|
@ -773,6 +889,9 @@ class OutcomeReleaseAgentTests(unittest.TestCase):
|
||||||
"17_improvement_workbook_contracts.sql",
|
"17_improvement_workbook_contracts.sql",
|
||||||
"18_admin_usage_ledger_index.sql",
|
"18_admin_usage_ledger_index.sql",
|
||||||
"19_auth_identity_alias.sql",
|
"19_auth_identity_alias.sql",
|
||||||
|
"20_public_bootstrap_ticket_events.sql",
|
||||||
|
"21_single_active_session.sql",
|
||||||
|
"22_case_profile_multi_case.sql",
|
||||||
),
|
),
|
||||||
self.agent_module.RELEASE_DB_MIGRATIONS,
|
self.agent_module.RELEASE_DB_MIGRATIONS,
|
||||||
)
|
)
|
||||||
|
|
@ -968,8 +1087,26 @@ class OutcomeReleaseAgentTests(unittest.TestCase):
|
||||||
if "18_admin_usage_ledger_index.sql" in line
|
if "18_admin_usage_ledger_index.sql" in line
|
||||||
and "psql -v ON_ERROR_STOP=1" in line
|
and "psql -v ON_ERROR_STOP=1" in line
|
||||||
)
|
)
|
||||||
|
active_session_online_migration_command = next(
|
||||||
|
line
|
||||||
|
for line in promote_command.splitlines()
|
||||||
|
if "21_single_active_session.sql" in line
|
||||||
|
and "psql -v ON_ERROR_STOP=1" in line
|
||||||
|
)
|
||||||
|
case_profile_online_migration_command = next(
|
||||||
|
line
|
||||||
|
for line in promote_command.splitlines()
|
||||||
|
if "22_case_profile_multi_case.sql" in line
|
||||||
|
and "psql -v ON_ERROR_STOP=1" in line
|
||||||
|
)
|
||||||
self.assertIn("--single-transaction", transactional_migration_command)
|
self.assertIn("--single-transaction", transactional_migration_command)
|
||||||
self.assertNotIn("--single-transaction", online_migration_command)
|
self.assertNotIn("--single-transaction", online_migration_command)
|
||||||
|
self.assertNotIn(
|
||||||
|
"--single-transaction", active_session_online_migration_command
|
||||||
|
)
|
||||||
|
self.assertNotIn(
|
||||||
|
"--single-transaction", case_profile_online_migration_command
|
||||||
|
)
|
||||||
self.assertEqual(f"{root}/release/infra/.env", state["env_file"])
|
self.assertEqual(f"{root}/release/infra/.env", state["env_file"])
|
||||||
|
|
||||||
def test_adopted_legacy_state_infers_compose_adjacent_env_file(self) -> None:
|
def test_adopted_legacy_state_infers_compose_adjacent_env_file(self) -> None:
|
||||||
|
|
|
||||||
133
scripts/vignette-pipeline.py
Normal file
133
scripts/vignette-pipeline.py
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Vignette 배포 파이프라인 관리 도구.
|
||||||
|
|
||||||
|
소스 SSOT = Forgejo git.chanpaca.net (master)
|
||||||
|
배포 대상 = NAS Production /volume1/docker/vignette-prod (docker-compose.nas.yml)
|
||||||
|
이 PC = 개발 전용
|
||||||
|
|
||||||
|
역할:
|
||||||
|
- Forgejo master == 로컬 master == 배포 기준선 무결성 확인
|
||||||
|
- github(origin, private 백업 미러) 존재 확인
|
||||||
|
- NAS production 컨테이너/이미지 compose ref 조회 (read-only)
|
||||||
|
- 배포 전 검증 게이트(SSOT checker) 존재 확인
|
||||||
|
|
||||||
|
※ 실제 NAS 이미지 빌드·주입·compose up은 소유자 승인 후 별도 실행.
|
||||||
|
이 도구는 배포 파이프라인의 관리/점검(read-only)을 담당한다.
|
||||||
|
|
||||||
|
사용법:
|
||||||
|
python -X utf8 scripts/vignette-pipeline.py --check
|
||||||
|
python -X utf8 scripts/vignette-pipeline.py --config
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
SSOT_DASHBOARD = REPO_ROOT / "docs" / "dev_dashboard.html"
|
||||||
|
FORGEJO_SSOT_BRANCH = "master"
|
||||||
|
FORGEJO_REMOTE = "forgejo"
|
||||||
|
GITHUB_REMOTE = "origin"
|
||||||
|
NAS_COMPOSE_HOST = "yunchan-nas" # ssh config Host (192.168.0.38, user yunchan)
|
||||||
|
NAS_COMPOSE_PATH = "/volume1/docker/vignette-prod/docker-compose.nas.yml"
|
||||||
|
NAS_COMPOSE_FILE = "docker-compose.nas.yml"
|
||||||
|
NAS_PROJECT = "vignette-prod"
|
||||||
|
|
||||||
|
|
||||||
|
def _run(cmd: list[str], cwd: Path | None = None, ok_codes=(0,)) -> str:
|
||||||
|
r = subprocess.run(cmd, cwd=cwd or REPO_ROOT, capture_output=True, text=True)
|
||||||
|
if r.returncode not in ok_codes:
|
||||||
|
raise RuntimeError(f"cmd failed ({r.returncode}): {' '.join(cmd)}\n{r.stderr[-800:]}")
|
||||||
|
return r.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def _git(*args: str) -> str:
|
||||||
|
return _run(["git", *args]).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def check(forgejo_available: bool = True) -> int:
|
||||||
|
problems = 0
|
||||||
|
|
||||||
|
def ok(msg: str) -> None:
|
||||||
|
print(f" [PASS] {msg}")
|
||||||
|
|
||||||
|
def warn(msg: str) -> None:
|
||||||
|
nonlocal problems
|
||||||
|
problems += 1
|
||||||
|
print(f" [FAIL] {msg}")
|
||||||
|
|
||||||
|
print("== 배포 파이프라인 점검 ==")
|
||||||
|
|
||||||
|
# 1) Forgejo SSOT == 로컬 master
|
||||||
|
local_master = _git("rev-parse", FORGEJO_SSOT_BRANCH)
|
||||||
|
ok(f"로컬 {FORGEJO_SSOT_BRANCH} = {local_master[:12]}")
|
||||||
|
|
||||||
|
if forgejo_available:
|
||||||
|
remote_master = _git("ls-remote", f"{FORGEJO_REMOTE}", "refs/heads/master").split("\t")[0]
|
||||||
|
if remote_master and remote_master == local_master:
|
||||||
|
ok(f"Forgejo master = {remote_master[:12]} == 로컬 (배포 기준선 일치)")
|
||||||
|
else:
|
||||||
|
warn(f"Forgejo master {remote_master[:12] if remote_master else 'MISSING'} != 로컬 {local_master[:12]}. push 필요.")
|
||||||
|
else:
|
||||||
|
warn("Forgejo 원격/접근 없음 — 이관 미완료")
|
||||||
|
|
||||||
|
# 2) remote 목록
|
||||||
|
remotes = _git("remote", "-v")
|
||||||
|
if github := [f.split()[1] for f in remotes.splitlines() if f.startswith("origin\t")]:
|
||||||
|
ok(f"github origin(private 백업) = {github[0]}")
|
||||||
|
else:
|
||||||
|
warn("github origin 없음")
|
||||||
|
if f"{FORGEJO_REMOTE}\t" in remotes:
|
||||||
|
ok(f"Forgejo {FORGEJO_REMOTE} 원격 존재 (SSOT)")
|
||||||
|
else:
|
||||||
|
warn(f"Forgejo {FORGEJO_REMOTE} 원격 없음")
|
||||||
|
|
||||||
|
# 3) SSOT checker 존재
|
||||||
|
checker = REPO_ROOT / "scripts" / "test_dev_dashboard_ssot.py"
|
||||||
|
if checker.exists():
|
||||||
|
ok(f"SSOT checker 존재: {checker.name}")
|
||||||
|
else:
|
||||||
|
warn("SSOT checker 없음")
|
||||||
|
|
||||||
|
# 4) 배포 파이프라인 문서
|
||||||
|
pipe_doc = REPO_ROOT / "docs" / "ops" / "deployment-pipeline.md"
|
||||||
|
if pipe_doc.exists():
|
||||||
|
ok(f"배포 파이프라인 문서 존재")
|
||||||
|
else:
|
||||||
|
warn("deployment-pipeline.md 없음")
|
||||||
|
|
||||||
|
return problems
|
||||||
|
|
||||||
|
|
||||||
|
def config() -> None:
|
||||||
|
env = REPO_ROOT / "infra" / ".env.deploy"
|
||||||
|
print(f"== 배포 파이프라인 구성 ==")
|
||||||
|
print(f" Forgejo SSOT : {FORGEJO_REMOTE} (ssh://git@git.chanpaca.net:2222/yunchan/vignette.git)")
|
||||||
|
print(f" github 백업 : {GITHUB_REMOTE} (https://github.com/yunchan8804-blip/vignette.git, private)")
|
||||||
|
print(f" NAS 대상 : {NAS_COMPOSE_HOST}:{NAS_COMPOSE_PATH} ({NAS_PROJECT})")
|
||||||
|
print(f" 로컬 마스터 : {_git('rev-parse', FORGEJO_SSOT_BRANCH)[:12]}")
|
||||||
|
print(f" .env.deploy : {env} (토큰 gitignore, 채움 필요)")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser(description="Vignette 배포 파이프라인 관리")
|
||||||
|
ap.add_argument("--check", action="store_true", help="배포 기준선 무결성 점검")
|
||||||
|
ap.add_argument("--config", action="store_true", help="파이프라인 구성 요약")
|
||||||
|
ap.add_argument("--skip-forgejo", action="store_true", help="Forgejo 원격 접근 생략(오프라인 점검)")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
if args.config:
|
||||||
|
config()
|
||||||
|
return 0
|
||||||
|
problems = check(forgejo_available=not args.skip_forgejo)
|
||||||
|
print(f"\n결과: {'ALL OK' if problems == 0 else f'{problems} 개 문제'}")
|
||||||
|
return 0 if problems == 0 else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Loading…
Add table
Add a link
Reference in a new issue