From 09d849de07d83b4b95096e4028f467a013c37e55 Mon Sep 17 00:00:00 2001 From: hided62 Date: Tue, 4 Aug 2026 14:45:31 +0000 Subject: [PATCH] feat: add public Docker deployment stack --- .dockerignore | 5 ++ .env.example | 42 +++++++++++ .gitignore | 8 +++ README.md | 79 +++++++++++++++++++++ caddy/Caddyfile | 82 ++++++++++++++++++++++ compose.dev.yaml | 22 ++++++ compose.yaml | 132 +++++++++++++++++++++++++++++++++++ data/image/.gitkeep | 1 + runtime/Dockerfile | 14 ++++ runtime/bootstrap.mjs | 69 ++++++++++++++++++ runtime/ecosystem.config.cjs | 37 ++++++++++ runtime/entrypoint.sh | 112 +++++++++++++++++++++++++++++ runtime/redis-entrypoint.sh | 13 ++++ scripts/check.sh | 12 ++++ 14 files changed, 628 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 caddy/Caddyfile create mode 100644 compose.dev.yaml create mode 100644 compose.yaml create mode 100644 data/image/.gitkeep create mode 100644 runtime/Dockerfile create mode 100644 runtime/bootstrap.mjs create mode 100644 runtime/ecosystem.config.cjs create mode 100755 runtime/entrypoint.sh create mode 100755 runtime/redis-entrypoint.sh create mode 100755 scripts/check.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e543204 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.git +.env +.env.* +data +secrets diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..054e8a5 --- /dev/null +++ b/.env.example @@ -0,0 +1,42 @@ +# Public endpoint. Caddy obtains TLS certificates for DOMAIN automatically. +DOMAIN=game.example.com +PUBLIC_SCHEME=https +ACME_EMAIL=admin@example.com +HTTP_PORT=80 +HTTPS_PORT=443 + +# Core2026 source. A public HTTPS URL needs no extra credential. +CORE_REPOSITORY_URL=https://github.com/your-org/core2026.git +CORE_BOOTSTRAP_REF=main + +# Development bind override (used only with compose.dev.yaml). +# CORE_DEV_PATH=../core2026 +# DEV_UID=1000 +# DEV_GID=1000 + +# Persistent services. Use long random values; do not commit .env. +POSTGRES_DB=sammo +POSTGRES_USER=sammo +POSTGRES_PASSWORD=replace-with-a-long-random-database-password +REDIS_PASSWORD=replace-with-a-long-random-redis-password +GAME_TOKEN_SECRET=replace-with-at-least-32-random-bytes +GATEWAY_BOOTSTRAP_TOKEN=replace-with-a-separate-random-bootstrap-token + +# The first startup creates this superuser only when the user table is empty. +INITIAL_ADMIN_USERNAME=admin +INITIAL_ADMIN_PASSWORD=replace-with-a-long-random-admin-password +INITIAL_ADMIN_DISPLAY_NAME=Administrator + +# Kakao Developers app values. Register https://DOMAIN/gateway/oauth/callback. +KAKAO_REST_KEY=replace-with-kakao-rest-api-key +KAKAO_ADMIN_KEY= + +# Account policy and optional compatibility secret. +GATEWAY_LOCAL_REGISTRATION_ENABLED=true +GATEWAY_ADMIN_LOCAL_ACCOUNT_ENABLED=true +GATEWAY_LOCAL_ACCOUNT_GRACE_DAYS=7 +GATEWAY_LEGACY_PASSWORD_GLOBAL_SALT= + +# Initial stopped profile inventory. Each deployment can choose its own branch in the Admin GUI. +BOOTSTRAP_PROFILES=gateway,che,kwe,pwe,twe,nya,pya,hwe +TZ=Asia/Seoul diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..31ec611 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.env +.env.* +!.env.example +data/* +!data/image/ +!data/image/.gitkeep +secrets/ +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..0a0d345 --- /dev/null +++ b/README.md @@ -0,0 +1,79 @@ +# Sammo Core2026 Docker deployment + +이 저장소는 새 호스트에서 `.env` 하나로 Core2026 Gateway, PostgreSQL, Redis, +Caddy와 Git/PM2 release-controller를 기동하기 위한 공개 배포 골격입니다. 게임 +profile은 관리자 화면에서 각각 다른 branch 또는 commit을 선택해 DB 유지 배포, +DB 초기화 배포와 rollback을 수행합니다. + +## 빠른 시작 + +Docker Engine과 Compose plugin이 설치된 Linux 호스트에서 다음을 실행합니다. + +```sh +cp .env.example .env +# .env의 domain, Core2026 URL, Kakao key와 모든 비밀값을 교체합니다. +docker compose up -d --build --wait +docker compose ps +``` + +DNS의 `DOMAIN` A/AAAA record가 호스트를 가리키고 80/443 TCP 및 443 UDP가 +열려 있으면 Caddy가 인증서를 자동 발급합니다. Kakao Developers에는 +`https://DOMAIN/gateway/oauth/callback`을 redirect URI로 등록합니다. 첫 기동은 +Core2026 clone, frozen-lockfile install, build와 Gateway migration 때문에 수 분이 +걸릴 수 있습니다. + +`INITIAL_ADMIN_*`은 사용자 table이 비어 있을 때만 superuser를 한 번 생성합니다. +그 뒤 `/gateway/admin/server-operations`에서 `che`, `kwe`, `pwe`, `twe`, `nya`, +`pya`, `hwe`마다 source branch/commit을 독립 선택합니다. `DB 유지 배포`는 현 +시즌을 보존하고, `DB 초기화 배포`는 scenario를 다시 seed합니다. Gateway 자체의 +배포와 이전 commit rollback은 같은 화면의 별도 release 영역에서 처리됩니다. + +## 데이터와 복구 경계 + +- PostgreSQL, Redis, Core clone/worktree, PM2 상태, Caddy 인증서와 user icon은 + named volume 또는 `data/image`에 보존됩니다. +- `/image/*`는 앱 artifact가 아니며 `data/image/`의 별도 운영 자산을 제공합니다. +- 일반 `docker compose down`은 volume을 보존합니다. `down --volumes`는 DB와 + release 상태를 삭제하는 파괴적 명령이므로 backup 없이 실행하지 않습니다. +- 앱 rollback은 Prisma migration을 되돌리지 않습니다. 이전 앱과 새 schema의 + 호환성을 배포 전에 확인합니다. +- Core2026 저장소가 비공개라면 이 공개 저장소에 credential을 추가하지 말고, + 호스트의 read-only deploy key나 credential helper를 별도 override로 mount합니다. + +상태 확인과 로그: + +```sh +docker compose ps +docker compose logs --tail=200 runtime caddy +docker compose exec runtime pnpm --filter @sammo-ts/release-controller status +``` + +## 개발 bind 모드 + +로컬 Core2026 checkout을 container에 bind하고 DB/Redis/Caddy는 같은 구성으로 +사용할 수 있습니다. `.env`에 `CORE_DEV_PATH=/absolute/path/to/core2026`, +host 사용자의 `DEV_UID`/`DEV_GID`, `PUBLIC_SCHEME=http`, `DOMAIN=localhost`를 +추가한 뒤 실행합니다. 기본 UID/GID는 `1000:1000`입니다. + +```sh +docker compose -f compose.yaml -f compose.dev.yaml up -d --build +docker compose exec runtime pnpm --filter @sammo-ts/gateway-api dev +docker compose exec runtime pnpm --filter @sammo-ts/gateway-frontend dev --host 0.0.0.0 +``` + +개발 override의 runtime은 의존성과 Prisma client를 준비한 뒤 대기합니다. 필요한 +watch process를 별도 shell에서 실행합니다. 운영 release-controller를 시험하려면 +override 없이 production mode를 사용해야 하며, bind checkout의 Git metadata에 +container worktree 경로를 등록하지 않도록 주의합니다. + +## 설정 검증 + +`.env`를 채운 뒤 실제 값을 출력하지 않는 검사를 실행합니다. + +```sh +./scripts/check.sh +``` + +검사는 production/development Compose model과 Caddyfile 구문을 확인합니다. +`docker compose config` 전체 출력에는 펼쳐진 비밀값이 포함될 수 있으므로 CI +artifact나 이슈에 그대로 첨부하지 않습니다. diff --git a/caddy/Caddyfile b/caddy/Caddyfile new file mode 100644 index 0000000..b4ba1a9 --- /dev/null +++ b/caddy/Caddyfile @@ -0,0 +1,82 @@ +{ + email {$ACME_EMAIL} +} + +{$SITE_ADDRESS} { + encode zstd gzip + + redir /gateway /gateway/ 308 + redir /che /che/ 308 + redir /kwe /kwe/ 308 + redir /pwe /pwe/ 308 + redir /twe /twe/ 308 + redir /nya /nya/ 308 + redir /pya /pya/ 308 + redir /hwe /hwe/ 308 + + handle_path /image/* { + root * /srv/image + file_server + } + + handle /gateway/api/trpc* { + reverse_proxy runtime:15001 + } + handle /gateway/api/user-icons/* { + uri strip_prefix /gateway/api + reverse_proxy runtime:15001 + } + handle /gateway/api/* { + uri strip_prefix /gateway/api + reverse_proxy runtime:15001 + } + handle /gateway/* { + reverse_proxy runtime:15000 + } + + handle /che/api/* { + reverse_proxy runtime:15003 + } + handle /che/* { + reverse_proxy runtime:15002 + } + handle /kwe/api/* { + reverse_proxy runtime:15005 + } + handle /kwe/* { + reverse_proxy runtime:15004 + } + handle /pwe/api/* { + reverse_proxy runtime:15007 + } + handle /pwe/* { + reverse_proxy runtime:15006 + } + handle /twe/api/* { + reverse_proxy runtime:15009 + } + handle /twe/* { + reverse_proxy runtime:15008 + } + handle /nya/api/* { + reverse_proxy runtime:15011 + } + handle /nya/* { + reverse_proxy runtime:15010 + } + handle /pya/api/* { + reverse_proxy runtime:15013 + } + handle /pya/* { + reverse_proxy runtime:15012 + } + handle /hwe/api/* { + reverse_proxy runtime:15015 + } + handle /hwe/* { + reverse_proxy runtime:15014 + } + + respond /healthz 200 + redir / /gateway/ 302 +} diff --git a/compose.dev.yaml b/compose.dev.yaml new file mode 100644 index 0000000..aff9680 --- /dev/null +++ b/compose.dev.yaml @@ -0,0 +1,22 @@ +services: + runtime: + user: ${DEV_UID:-1000}:${DEV_GID:-1000} + environment: + CORE_SOURCE_MODE: bind + CORE_BIND_ROOT: /workspace/core2026 + RUNTIME_MODE: ${RUNTIME_MODE:-development} + VITE_PREVIEW_ALLOWED_HOSTS: '*' + volumes: + - ${CORE_DEV_PATH:-../core2026}:/workspace/core2026 + - runtime-data:/srv/data + - core-source:/srv/core + command: [sh, -c, sleep infinity] + healthcheck: + test: [CMD-SHELL, 'test -d /workspace/core2026/node_modules/.pnpm'] + interval: 10s + timeout: 5s + retries: 30 + start_period: 5m + caddy: + environment: + SITE_ADDRESS: :80 diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..dcc8242 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,132 @@ +name: ${COMPOSE_PROJECT_NAME:-sammo-public} + +services: + postgres: + image: postgres:18.4-bookworm + environment: + POSTGRES_DB: ${POSTGRES_DB:-sammo} + POSTGRES_USER: ${POSTGRES_USER:-sammo} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env} + TZ: ${TZ:-Asia/Seoul} + volumes: + - postgres-data:/var/lib/postgresql + healthcheck: + test: [CMD-SHELL, 'pg_isready -U "$${POSTGRES_USER}" -d "$${POSTGRES_DB}"'] + interval: 5s + timeout: 5s + retries: 24 + start_period: 10s + restart: unless-stopped + + redis: + image: redis:8.2.7-bookworm + entrypoint: [/usr/local/bin/sammo-redis-entrypoint] + environment: + REDIS_PASSWORD: ${REDIS_PASSWORD:?set REDIS_PASSWORD in .env} + volumes: + - redis-data:/data + - ./runtime/redis-entrypoint.sh:/usr/local/bin/sammo-redis-entrypoint:ro + healthcheck: + test: [CMD-SHELL, 'REDISCLI_AUTH="$${REDIS_PASSWORD}" redis-cli ping 2>/dev/null | grep -q PONG'] + interval: 5s + timeout: 5s + retries: 24 + start_period: 5s + restart: unless-stopped + + runtime: + build: + context: . + dockerfile: runtime/Dockerfile + environment: + CORE_SOURCE_MODE: clone + CORE_REPOSITORY_URL: ${CORE_REPOSITORY_URL:?set CORE_REPOSITORY_URL in .env} + CORE_BOOTSTRAP_REF: ${CORE_BOOTSTRAP_REF:-main} + POSTGRES_HOST: postgres + POSTGRES_PORT: '5432' + POSTGRES_DB: ${POSTGRES_DB:-sammo} + POSTGRES_USER: ${POSTGRES_USER:-sammo} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env} + GATEWAY_DB_SCHEMA: public + REDIS_HOST: redis + REDIS_PORT: '6379' + REDIS_DB: '0' + REDIS_PASSWORD: ${REDIS_PASSWORD:?set REDIS_PASSWORD in .env} + GAME_TOKEN_SECRET: ${GAME_TOKEN_SECRET:?set GAME_TOKEN_SECRET in .env} + GATEWAY_BOOTSTRAP_TOKEN: ${GATEWAY_BOOTSTRAP_TOKEN:?set GATEWAY_BOOTSTRAP_TOKEN in .env} + INITIAL_ADMIN_USERNAME: ${INITIAL_ADMIN_USERNAME:?set INITIAL_ADMIN_USERNAME in .env} + INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:?set INITIAL_ADMIN_PASSWORD in .env} + INITIAL_ADMIN_DISPLAY_NAME: ${INITIAL_ADMIN_DISPLAY_NAME:-Administrator} + BOOTSTRAP_PROFILES: ${BOOTSTRAP_PROFILES:-gateway,che,kwe,pwe,twe,nya,pya,hwe} + KAKAO_REST_KEY: ${KAKAO_REST_KEY:?set KAKAO_REST_KEY in .env} + KAKAO_ADMIN_KEY: ${KAKAO_ADMIN_KEY:-} + KAKAO_REDIRECT_URI: ${PUBLIC_SCHEME:-https}://${DOMAIN:?set DOMAIN in .env}/gateway/oauth/callback + GATEWAY_PUBLIC_URL: ${PUBLIC_SCHEME:-https}://${DOMAIN:?set DOMAIN in .env}/gateway/ + GATEWAY_USER_ICON_DIR: /srv/data/user-icons + GATEWAY_USER_ICON_PUBLIC_URL: ${PUBLIC_SCHEME:-https}://${DOMAIN:?set DOMAIN in .env}/gateway/api/user-icons + GATEWAY_LOCAL_REGISTRATION_ENABLED: ${GATEWAY_LOCAL_REGISTRATION_ENABLED:-true} + GATEWAY_ADMIN_LOCAL_ACCOUNT_ENABLED: ${GATEWAY_ADMIN_LOCAL_ACCOUNT_ENABLED:-true} + GATEWAY_LOCAL_ACCOUNT_GRACE_DAYS: ${GATEWAY_LOCAL_ACCOUNT_GRACE_DAYS:-7} + GATEWAY_LEGACY_PASSWORD_GLOBAL_SALT: ${GATEWAY_LEGACY_PASSWORD_GLOBAL_SALT:-} + GATEWAY_API_HOST: 0.0.0.0 + GATEWAY_API_PORT: '15001' + GATEWAY_FRONTEND_PORT: '15000' + GATEWAY_TRPC_PATH: /gateway/api/trpc + GATEWAY_INTERNAL_API_URL: http://127.0.0.1:15001 + GATEWAY_REDIS_PREFIX: sammo:gateway + GATEWAY_WORKSPACE_ROOT: /srv/core/repository + GATEWAY_WORKTREE_ROOT: /srv/core/profile-worktrees + RELEASE_CONTROLLER_WORKSPACE_ROOT: /srv/core/repository + RELEASE_CONTROLLER_WORKTREE_ROOT: /srv/core/release-worktrees + GATEWAY_BASE_PATH: /gateway + VITE_APP_BASE_PATH: /gateway + VITE_GATEWAY_API_URL: /gateway/api/trpc + VITE_GAME_API_URL_TEMPLATE: /{profile}/api/trpc + VITE_GAME_WEB_URL_TEMPLATE: /{profile}/ + VITE_GATEWAY_USER_ICON_BASE_URL: /gateway/api/user-icons + VITE_PREVIEW_ALLOWED_HOSTS: ${DOMAIN:?set DOMAIN in .env} + PM2_HOME: /srv/data/pm2 + TZ: ${TZ:-Asia/Seoul} + volumes: + - core-source:/srv/core + - runtime-data:/srv/data + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + healthcheck: + test: [CMD, curl, --fail, --silent, http://127.0.0.1:15001/healthz] + interval: 10s + timeout: 5s + retries: 30 + start_period: 5m + restart: unless-stopped + + caddy: + image: caddy:2.10.2-alpine + environment: + DOMAIN: ${DOMAIN:?set DOMAIN in .env} + SITE_ADDRESS: ${DOMAIN:?set DOMAIN in .env} + ACME_EMAIL: ${ACME_EMAIL:?set ACME_EMAIL in .env} + ports: + - ${HTTP_PORT:-80}:80 + - ${HTTPS_PORT:-443}:443 + - ${HTTPS_PORT:-443}:443/udp + volumes: + - ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro + - ./data/image:/srv/image:ro + - caddy-data:/data + - caddy-config:/config + depends_on: + runtime: + condition: service_healthy + restart: unless-stopped + +volumes: + postgres-data: + redis-data: + core-source: + runtime-data: + caddy-data: + caddy-config: diff --git a/data/image/.gitkeep b/data/image/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/data/image/.gitkeep @@ -0,0 +1 @@ + diff --git a/runtime/Dockerfile b/runtime/Dockerfile new file mode 100644 index 0000000..d9e2d57 --- /dev/null +++ b/runtime/Dockerfile @@ -0,0 +1,14 @@ +FROM node:24.18.0-bookworm-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl git openssh-client procps tini \ + && rm -rf /var/lib/apt/lists/* \ + && corepack enable \ + && corepack prepare pnpm@11.17.0 --activate + +COPY --chmod=755 runtime/entrypoint.sh /usr/local/bin/sammo-entrypoint +COPY --chmod=755 runtime/redis-entrypoint.sh /opt/sammo/redis-entrypoint.sh +COPY runtime/ecosystem.config.cjs runtime/bootstrap.mjs /opt/sammo/ + +WORKDIR /srv/core/repository +ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/sammo-entrypoint"] diff --git a/runtime/bootstrap.mjs b/runtime/bootstrap.mjs new file mode 100644 index 0000000..83cd248 --- /dev/null +++ b/runtime/bootstrap.mjs @@ -0,0 +1,69 @@ +const endpoint = 'http://127.0.0.1:15001/gateway/api/trpc'; + +const post = async (procedure, input, sessionToken) => { + const response = await fetch(`${endpoint}/${procedure}`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...(sessionToken ? { 'x-session-token': sessionToken } : {}), + }, + body: JSON.stringify(input), + }); + const body = await response.json(); + if (!response.ok) { + const code = body?.error?.data?.code; + const message = body?.error?.message ?? JSON.stringify(body); + const error = new Error(`${procedure} failed (${response.status}, ${code ?? 'unknown'}): ${message}`); + error.code = code; + throw error; + } + return body?.result?.data; +}; + +const token = process.env.GATEWAY_BOOTSTRAP_TOKEN; +const username = process.env.INITIAL_ADMIN_USERNAME; +const password = process.env.INITIAL_ADMIN_PASSWORD; +if (!token || !username || !password) { + console.log('Initial admin bootstrap is disabled because its environment values are incomplete.'); + process.exit(0); +} + +let sessionToken; +try { + const result = await post('auth.bootstrapLocal', { + token, + username, + password, + displayName: process.env.INITIAL_ADMIN_DISPLAY_NAME || 'Administrator', + }); + sessionToken = result?.sessionToken; + if (!sessionToken) throw new Error('bootstrap response did not include a session token'); + console.log('Created the initial superuser without printing credentials.'); +} catch (error) { + if (error?.code === 'CONFLICT') { + console.log('Initial superuser already exists; bootstrap was left unchanged.'); + process.exit(0); + } + throw error; +} + +const profilePorts = new Map([ + ['gateway', 15001], + ['che', 15003], + ['kwe', 15005], + ['pwe', 15007], + ['twe', 15009], + ['nya', 15011], + ['pya', 15013], + ['hwe', 15015], +]); +const profiles = (process.env.BOOTSTRAP_PROFILES || 'gateway,che,kwe,pwe,twe,nya,pya,hwe') + .split(',') + .map((value) => value.trim()) + .filter((value) => value && value !== 'gateway'); +for (const profile of profiles) { + const apiPort = profilePorts.get(profile); + if (!apiPort) throw new Error(`No reserved API port is defined for profile: ${profile}`); + await post('admin.profiles.upsert', { profile, scenario: 'default', apiPort, status: 'STOPPED' }, sessionToken); +} +console.log(`Registered ${profiles.length} stopped profiles for branch-selectable Admin deployments.`); diff --git a/runtime/ecosystem.config.cjs b/runtime/ecosystem.config.cjs new file mode 100644 index 0000000..298afcd --- /dev/null +++ b/runtime/ecosystem.config.cjs @@ -0,0 +1,37 @@ +const path = require('node:path'); + +const root = process.env.GATEWAY_WORKSPACE_ROOT || '/srv/core/repository'; +const common = { env: { ...process.env }, autorestart: true, kill_timeout: 15000 }; + +module.exports = { + apps: [ + { + ...common, + name: 'sammo:gateway-api', + cwd: path.join(root, 'app/gateway-api'), + script: path.join(root, 'app/gateway-api/dist/index.js'), + env: { ...process.env, GATEWAY_ROLE: 'api' }, + }, + { + ...common, + name: 'sammo:gateway-frontend', + cwd: path.join(root, 'app/gateway-frontend'), + script: path.join(root, 'app/gateway-frontend/node_modules/vite/bin/vite.js'), + args: 'preview --host 0.0.0.0 --port 15000', + }, + { + ...common, + name: 'sammo:gateway-orchestrator', + cwd: path.join(root, 'app/gateway-api'), + script: path.join(root, 'app/gateway-api/dist/index.js'), + env: { ...process.env, GATEWAY_ROLE: 'orchestrator' }, + }, + { + ...common, + name: 'sammo:release-controller', + cwd: path.join(root, 'app/release-controller'), + script: path.join(root, 'app/release-controller/dist/index.js'), + args: 'daemon', + }, + ], +}; diff --git a/runtime/entrypoint.sh b/runtime/entrypoint.sh new file mode 100755 index 0000000..0e75f91 --- /dev/null +++ b/runtime/entrypoint.sh @@ -0,0 +1,112 @@ +#!/bin/sh +set -eu + +# The container is always non-interactive. This also makes pnpm replace a +# modules directory created with a different store without waiting for a TTY. +export CI="${CI:-true}" + +require_env() { + eval "value=\${$1:-}" + if [ -z "$value" ]; then + echo "$1 is required" >&2 + exit 64 + fi +} + +for key in POSTGRES_PASSWORD REDIS_PASSWORD GAME_TOKEN_SECRET KAKAO_REST_KEY KAKAO_REDIRECT_URI; do + require_env "$key" +done + +case "${CORE_SOURCE_MODE:-clone}" in + clone) + require_env CORE_REPOSITORY_URL + core_root=/srv/core/repository + if [ ! -d "$core_root/.git" ]; then + mkdir -p /srv/core + git clone --no-single-branch "$CORE_REPOSITORY_URL" "$core_root" + git -C "$core_root" checkout "${CORE_BOOTSTRAP_REF:-main}" + fi + ;; + bind) + core_root=${CORE_BIND_ROOT:-/workspace/core2026} + if [ ! -e "$core_root/.git" ] || [ ! -f "$core_root/pnpm-lock.yaml" ]; then + echo "CORE_BIND_ROOT must be a Core2026 Git checkout" >&2 + exit 66 + fi + ;; + *) + echo 'CORE_SOURCE_MODE must be clone or bind' >&2 + exit 64 + ;; +esac + +git config --global --add safe.directory "$core_root" +export GATEWAY_WORKSPACE_ROOT="$core_root" +export RELEASE_CONTROLLER_WORKSPACE_ROOT="$core_root" +export DATABASE_URL="$(node -e 'const u=new URL("postgresql://localhost");u.username=process.env.POSTGRES_USER||"sammo";u.password=process.env.POSTGRES_PASSWORD;u.hostname=process.env.POSTGRES_HOST||"postgres";u.port=process.env.POSTGRES_PORT||"5432";u.pathname="/"+(process.env.POSTGRES_DB||"sammo");u.searchParams.set("schema","public");process.stdout.write(u.href)')" +export GATEWAY_DATABASE_URL="$DATABASE_URL" +export REDIS_URL="$(node -e 'const u=new URL("redis://localhost/0");u.password=process.env.REDIS_PASSWORD;u.hostname=process.env.REDIS_HOST||"redis";u.port=process.env.REDIS_PORT||"6379";process.stdout.write(u.href)')" + +cd "$core_root" +pnpm install --frozen-lockfile + +if [ "${RUNTIME_MODE:-production}" = development ]; then + pnpm --filter @sammo-ts/infra prisma:generate + echo 'Development container is ready. Start the desired pnpm dev processes with docker compose exec runtime.' + if [ "$#" -gt 0 ]; then + exec "$@" + fi + exec sleep infinity +fi + +pnpm --filter @sammo-ts/common build +pnpm --filter @sammo-ts/infra prisma:generate +pnpm --filter @sammo-ts/infra build +pnpm --filter @sammo-ts/logic build +pnpm --filter @sammo-ts/game-engine build +pnpm --filter @sammo-ts/gateway-api build +pnpm --filter @sammo-ts/gateway-frontend build +pnpm --filter @sammo-ts/release-controller build +pnpm --filter @sammo-ts/infra prisma:migrate:deploy:gateway + +GATEWAY_ROLE=api node app/gateway-api/dist/index.js & +bootstrap_pid=$! +cleanup_bootstrap() { + kill "$bootstrap_pid" 2>/dev/null || true + wait "$bootstrap_pid" 2>/dev/null || true +} +trap cleanup_bootstrap EXIT INT TERM + +attempt=0 +until curl --fail --silent http://127.0.0.1:15001/healthz >/dev/null; do + attempt=$((attempt + 1)) + if [ "$attempt" -ge 120 ]; then + echo 'Gateway API did not become ready for bootstrap' >&2 + exit 1 + fi + sleep 1 +done +node /opt/sammo/bootstrap.mjs +cleanup_bootstrap +trap - EXIT INT TERM + +pm2_bin="$core_root/app/gateway-api/node_modules/.bin/pm2" +"$pm2_bin" start /opt/sammo/ecosystem.config.cjs +pm2_pid=$(cat "$PM2_HOME/pm2.pid") +tail -n 0 -F "$PM2_HOME/pm2.log" "$PM2_HOME"/logs/*.log & +logs_pid=$! + +shutdown_pm2() { + kill "$logs_pid" 2>/dev/null || true + "$pm2_bin" kill >/dev/null 2>&1 || true + wait "$logs_pid" 2>/dev/null || true +} +trap 'shutdown_pm2; exit 0' INT TERM + +while kill -0 "$pm2_pid" 2>/dev/null; do + sleep 5 +done + +shutdown_pm2 +echo 'PM2 daemon exited unexpectedly' >&2 +exit 1 diff --git a/runtime/redis-entrypoint.sh b/runtime/redis-entrypoint.sh new file mode 100755 index 0000000..25a5f82 --- /dev/null +++ b/runtime/redis-entrypoint.sh @@ -0,0 +1,13 @@ +#!/bin/sh +set -eu + +if [ -z "${REDIS_PASSWORD:-}" ]; then + echo 'REDIS_PASSWORD is required' >&2 + exit 64 +fi + +umask 077 +install -d -o redis -g redis -m 700 /run/sammo +printf 'user default on >%s ~* &* +@all\n' "$REDIS_PASSWORD" >/run/sammo/users.acl +chown redis:redis /run/sammo/users.acl +exec /usr/local/bin/docker-entrypoint.sh redis-server --appendonly yes --aclfile /run/sammo/users.acl diff --git a/scripts/check.sh b/scripts/check.sh new file mode 100755 index 0000000..662f25a --- /dev/null +++ b/scripts/check.sh @@ -0,0 +1,12 @@ +#!/bin/sh +set -eu + +stack_dir=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +cd "$stack_dir" + +docker compose --env-file .env config --quiet +docker compose --env-file .env -f compose.yaml -f compose.dev.yaml config --quiet +docker run --rm -v "$stack_dir/caddy/Caddyfile:/etc/caddy/Caddyfile:ro" \ + -e SITE_ADDRESS=:80 -e ACME_EMAIL=validate@example.invalid caddy:2.10.2-alpine \ + caddy validate --config /etc/caddy/Caddyfile +echo 'Compose and Caddy configuration are valid.'