diff --git a/.env.example b/.env.example index 8c90e1e..a23a2eb 100644 --- a/.env.example +++ b/.env.example @@ -65,6 +65,13 @@ GATEWAY_ADMIN_LOCAL_ACCOUNT_ENABLED=true GATEWAY_LOCAL_ACCOUNT_GRACE_DAYS=7 GATEWAY_LEGACY_PASSWORD_GLOBAL_SALT= +# Shared image service. The two secret files must contain the values configured +# for the core2026 caller on the image server and must not be committed. +IMAGE_SERVICE_URL=https://sam-image.hided.net +IMAGE_PUBLIC_URL=https://sam-image.hided.net +IMAGE_UPLOAD_CORE2026_SECRET_FILE=./secrets/image_upload_core2026_secret +IMAGE_SYNC_CORE2026_SECRET_FILE=./secrets/image_sync_core2026_secret + # 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/README.md b/README.md index 9a14694..868eaa0 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,13 @@ password 24자, game/bootstrap token 32자, 최초 관리자 password 16자입 시즌을 보존하고, `DB 초기화 배포`는 scenario를 다시 seed합니다. Gateway 자체의 배포와 이전 commit rollback은 같은 화면의 별도 release 영역에서 처리됩니다. +이미지 서비스 연동에는 서버 간 접근 URL `IMAGE_SERVICE_URL`, 브라우저 공개 URL +`IMAGE_PUBLIC_URL`과 서로 다른 두 secret 파일이 필요합니다. 업로드 secret은 +Gateway 전용 아이콘과 game-api 편집기 첨부에만 사용하고, sync secret은 Gitea +webhook 누락 시 `docker compose exec runtime pnpm sync:image`로 현재 이미지 +branch의 fast-forward를 요청할 때만 사용합니다. 두 파일은 runtime에 read-only로 +mount되며 원문은 환경 변수나 브라우저 bundle에 들어가지 않습니다. + ## 데이터와 복구 경계 - PostgreSQL, Redis, Core clone/worktree, PM2 상태, Caddy 인증서와 user icon은 diff --git a/compose.e2e.yaml b/compose.e2e.yaml new file mode 100644 index 0000000..a08c928 --- /dev/null +++ b/compose.e2e.yaml @@ -0,0 +1,23 @@ +services: + postgres: + restart: "no" + redis: + restart: "no" + runtime: + environment: + CORE_SOURCE_MODE: bind + CORE_BIND_ROOT: /workspace/core2026 + volumes: + - /home/letrhee/sam_rebuild/core2026:/workspace/core2026 + - /home/letrhee/.cache/ms-playwright:/root/.cache/ms-playwright:ro + networks: + - default + - image-internal + restart: "no" + caddy: + restart: "no" + +networks: + image-internal: + external: true + name: sam-image_image-internal diff --git a/compose.yaml b/compose.yaml index 3f529e4..8917125 100644 --- a/compose.yaml +++ b/compose.yaml @@ -73,6 +73,14 @@ services: 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_IMAGE_UPLOAD_URL: ${IMAGE_SERVICE_URL:-https://sam-image.hided.net} + GATEWAY_IMAGE_UPLOAD_SECRET_FILE: /run/secrets/image_upload_core2026_secret + GATEWAY_SHARED_ICON_PUBLIC_URL: ${IMAGE_PUBLIC_URL:-https://sam-image.hided.net}/icons + GAME_IMAGE_UPLOAD_URL: ${IMAGE_SERVICE_URL:-https://sam-image.hided.net} + GAME_IMAGE_UPLOAD_SECRET_FILE: /run/secrets/image_upload_core2026_secret + GAME_CONTENT_IMAGE_PUBLIC_URL: ${IMAGE_PUBLIC_URL:-https://sam-image.hided.net}/uploads/core2026 + IMAGE_SYNC_URL: ${IMAGE_SERVICE_URL:-https://sam-image.hided.net} + IMAGE_SYNC_SECRET_FILE: /run/secrets/image_sync_core2026_secret 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} @@ -99,6 +107,8 @@ services: volumes: - core-source:/srv/core - runtime-data:/srv/data + - ${IMAGE_UPLOAD_CORE2026_SECRET_FILE:?set IMAGE_UPLOAD_CORE2026_SECRET_FILE in .env}:/run/secrets/image_upload_core2026_secret:ro + - ${IMAGE_SYNC_CORE2026_SECRET_FILE:?set IMAGE_SYNC_CORE2026_SECRET_FILE in .env}:/run/secrets/image_sync_core2026_secret:ro depends_on: postgres: condition: service_healthy diff --git a/runtime/validate-compose-model.mjs b/runtime/validate-compose-model.mjs index 3fd57ca..d02f2e7 100644 --- a/runtime/validate-compose-model.mjs +++ b/runtime/validate-compose-model.mjs @@ -11,6 +11,34 @@ export const validateComposeModel = (model, mode) => { const runtime = model?.services?.runtime; if (!runtime) return ['runtime service is missing']; + const requiredImageEnvironment = { + GATEWAY_IMAGE_UPLOAD_SECRET_FILE: '/run/secrets/image_upload_core2026_secret', + GAME_IMAGE_UPLOAD_SECRET_FILE: '/run/secrets/image_upload_core2026_secret', + IMAGE_SYNC_SECRET_FILE: '/run/secrets/image_sync_core2026_secret', + }; + for (const [key, expected] of Object.entries(requiredImageEnvironment)) { + if (runtime.environment?.[key] !== expected) errors.push(`${key} must be ${expected}`); + } + for (const key of [ + 'GATEWAY_IMAGE_UPLOAD_URL', + 'GATEWAY_SHARED_ICON_PUBLIC_URL', + 'GAME_IMAGE_UPLOAD_URL', + 'GAME_CONTENT_IMAGE_PUBLIC_URL', + 'IMAGE_SYNC_URL', + ]) { + if (!runtime.environment?.[key]) errors.push(`${key} must be configured`); + } + const mounts = Array.isArray(runtime.volumes) ? runtime.volumes : []; + for (const target of [ + '/run/secrets/image_upload_core2026_secret', + '/run/secrets/image_sync_core2026_secret', + ]) { + const mount = mounts.find((candidate) => candidate?.target === target); + if (!mount || mount.type !== 'bind' || mount.read_only !== true) { + errors.push(`${target} must be a read-only bind mount`); + } + } + if (!positive(runtime.mem_limit)) errors.push('runtime memory limit must be positive'); if (!positive(runtime.memswap_limit)) errors.push('runtime memory+swap limit must be positive'); if (Number(runtime.memswap_limit) !== Number(runtime.mem_limit)) { diff --git a/scripts/check.sh b/scripts/check.sh index 6a927fc..34e11d0 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -10,6 +10,22 @@ if [ ! -f "$env_file" ]; then exit 66 fi +for key in IMAGE_UPLOAD_CORE2026_SECRET_FILE IMAGE_SYNC_CORE2026_SECRET_FILE; do + value=$(sed -n "s/^${key}=//p" "$env_file" | tail -n 1) + if [ -z "$value" ]; then + echo "$key must name a readable, non-empty secret file." >&2 + exit 66 + fi + case "$value" in + /*) secret_path=$value ;; + *) secret_path=$stack_dir/$value ;; + esac + if [ ! -r "$secret_path" ] || [ ! -s "$secret_path" ]; then + echo "$key must name a readable, non-empty secret file." >&2 + exit 66 + fi +done + docker run --rm --network=none --memory=128m --memory-swap=128m --cpus=1 --pids-limit=64 --env-file "$env_file" \ -v "$stack_dir/runtime/validate-env.mjs:/opt/sammo/validate-env.mjs:ro" \ node:24.18.0-bookworm-slim node /opt/sammo/validate-env.mjs diff --git a/test/validate-compose-model.test.mjs b/test/validate-compose-model.test.mjs index dbc7b75..a24fe78 100644 --- a/test/validate-compose-model.test.mjs +++ b/test/validate-compose-model.test.mjs @@ -8,7 +8,29 @@ const safeRuntime = { CORE_SOURCE_MODE: 'clone', NODE_OPTIONS: '--max-old-space-size=1536', RAYON_NUM_THREADS: '2', + GATEWAY_IMAGE_UPLOAD_URL: 'https://sam-image.hided.net', + GATEWAY_IMAGE_UPLOAD_SECRET_FILE: '/run/secrets/image_upload_core2026_secret', + GATEWAY_SHARED_ICON_PUBLIC_URL: 'https://sam-image.hided.net/icons', + GAME_IMAGE_UPLOAD_URL: 'https://sam-image.hided.net', + GAME_IMAGE_UPLOAD_SECRET_FILE: '/run/secrets/image_upload_core2026_secret', + GAME_CONTENT_IMAGE_PUBLIC_URL: 'https://sam-image.hided.net/uploads/core2026', + IMAGE_SYNC_URL: 'https://sam-image.hided.net', + IMAGE_SYNC_SECRET_FILE: '/run/secrets/image_sync_core2026_secret', }, + volumes: [ + { + type: 'bind', + source: '/secrets/image_upload_core2026_secret', + target: '/run/secrets/image_upload_core2026_secret', + read_only: true, + }, + { + type: 'bind', + source: '/secrets/image_sync_core2026_secret', + target: '/run/secrets/image_sync_core2026_secret', + read_only: true, + }, + ], restart: 'unless-stopped', mem_limit: String(4 * 1024 * 1024 * 1024), memswap_limit: String(4 * 1024 * 1024 * 1024), @@ -26,10 +48,9 @@ test('accepts a literal, non-restarting development model', () => { ...safeRuntime, restart: 'no', environment: { + ...safeRuntime.environment, CORE_SOURCE_MODE: 'bind', RUNTIME_MODE: 'development', - NODE_OPTIONS: '--max-old-space-size=1536', - RAYON_NUM_THREADS: '2', }, }; assert.deepEqual(validateComposeModel({ services }, 'development'), []);