image service

This commit is contained in:
2026-08-08 04:04:39 +00:00
parent 5b3a22d9da
commit b61efccd01
7 changed files with 114 additions and 2 deletions
+7
View File
@@ -65,6 +65,13 @@ GATEWAY_ADMIN_LOCAL_ACCOUNT_ENABLED=true
GATEWAY_LOCAL_ACCOUNT_GRACE_DAYS=7 GATEWAY_LOCAL_ACCOUNT_GRACE_DAYS=7
GATEWAY_LEGACY_PASSWORD_GLOBAL_SALT= 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. # 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 BOOTSTRAP_PROFILES=gateway,che,kwe,pwe,twe,nya,pya,hwe
TZ=Asia/Seoul TZ=Asia/Seoul
+7
View File
@@ -51,6 +51,13 @@ password 24자, game/bootstrap token 32자, 최초 관리자 password 16자입
시즌을 보존하고, `DB 초기화 배포`는 scenario를 다시 seed합니다. Gateway 자체의 시즌을 보존하고, `DB 초기화 배포`는 scenario를 다시 seed합니다. Gateway 자체의
배포와 이전 commit rollback은 같은 화면의 별도 release 영역에서 처리됩니다. 배포와 이전 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은 - PostgreSQL, Redis, Core clone/worktree, PM2 상태, Caddy 인증서와 user icon은
+23
View File
@@ -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
+10
View File
@@ -73,6 +73,14 @@ services:
GATEWAY_PUBLIC_URL: ${PUBLIC_SCHEME:-https}://${DOMAIN:?set DOMAIN in .env}/gateway/ GATEWAY_PUBLIC_URL: ${PUBLIC_SCHEME:-https}://${DOMAIN:?set DOMAIN in .env}/gateway/
GATEWAY_USER_ICON_DIR: /srv/data/user-icons 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_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_LOCAL_REGISTRATION_ENABLED: ${GATEWAY_LOCAL_REGISTRATION_ENABLED:-true}
GATEWAY_ADMIN_LOCAL_ACCOUNT_ENABLED: ${GATEWAY_ADMIN_LOCAL_ACCOUNT_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_LOCAL_ACCOUNT_GRACE_DAYS: ${GATEWAY_LOCAL_ACCOUNT_GRACE_DAYS:-7}
@@ -99,6 +107,8 @@ services:
volumes: volumes:
- core-source:/srv/core - core-source:/srv/core
- runtime-data:/srv/data - 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: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
+28
View File
@@ -11,6 +11,34 @@ export const validateComposeModel = (model, mode) => {
const runtime = model?.services?.runtime; const runtime = model?.services?.runtime;
if (!runtime) return ['runtime service is missing']; 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.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 (!positive(runtime.memswap_limit)) errors.push('runtime memory+swap limit must be positive');
if (Number(runtime.memswap_limit) !== Number(runtime.mem_limit)) { if (Number(runtime.memswap_limit) !== Number(runtime.mem_limit)) {
+16
View File
@@ -10,6 +10,22 @@ if [ ! -f "$env_file" ]; then
exit 66 exit 66
fi 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" \ 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" \ -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 node:24.18.0-bookworm-slim node /opt/sammo/validate-env.mjs
+23 -2
View File
@@ -8,7 +8,29 @@ const safeRuntime = {
CORE_SOURCE_MODE: 'clone', CORE_SOURCE_MODE: 'clone',
NODE_OPTIONS: '--max-old-space-size=1536', NODE_OPTIONS: '--max-old-space-size=1536',
RAYON_NUM_THREADS: '2', 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', restart: 'unless-stopped',
mem_limit: String(4 * 1024 * 1024 * 1024), mem_limit: String(4 * 1024 * 1024 * 1024),
memswap_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, ...safeRuntime,
restart: 'no', restart: 'no',
environment: { environment: {
...safeRuntime.environment,
CORE_SOURCE_MODE: 'bind', CORE_SOURCE_MODE: 'bind',
RUNTIME_MODE: 'development', RUNTIME_MODE: 'development',
NODE_OPTIONS: '--max-old-space-size=1536',
RAYON_NUM_THREADS: '2',
}, },
}; };
assert.deepEqual(validateComposeModel({ services }, 'development'), []); assert.deepEqual(validateComposeModel({ services }, 'development'), []);