fix: support containerized release runtimes

This commit is contained in:
2026-08-04 14:45:47 +00:00
parent a044d97d77
commit 6e635a312f
17 changed files with 101 additions and 45 deletions
+2
View File
@@ -59,6 +59,8 @@ TRPC_PATH=/trpc
# Frontend public URLs
# These values are public and become part of the browser bundle.
VITE_GATEWAY_WEB_URL=/gateway/
# Comma-separated public hostnames accepted by Vite preview. Use * only behind a trusted reverse proxy.
VITE_PREVIEW_ALLOWED_HOSTS=localhost,dev-sam-e2e.hided.net
# Account-uploaded icons are served by the Gateway and remain a public browser URL.
VITE_GATEWAY_USER_ICON_BASE_URL=/gateway/api/user-icons
VITE_BOARD_COMMUNITY_URL=/xe/community
+1
View File
@@ -164,6 +164,7 @@ docs/.vitepress/dist/
docker-compose.override.yml
.turbo/
.pnpm-store/
*_errors.txt
docs/image-storage.md
playwright-report/
+5 -14
View File
@@ -1,6 +1,3 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { runGameApiServer } from './server.js';
import { runBattleSimWorker } from './battleSim/worker.js';
import { runAuctionWorker } from './auction/worker.js';
@@ -41,18 +38,12 @@ export type { TurnCommandTable } from './turns/commandTable.js';
export type { ReservedTurnView } from './turns/reservedTurns.js';
export type { JsonObject, JsonArray } from './context.js';
const isMain = (): boolean => {
if (typeof process.env.NODE_APP_INSTANCE === 'string') {
return true;
}
if (!process.argv[1]) {
return false;
}
return fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
};
const GAME_API_ROLES = ['server', 'battle-sim-worker', 'auction-worker', 'tournament-worker'] as const;
export const shouldRunGameApi = (role: string | undefined): boolean =>
typeof role === 'string' && GAME_API_ROLES.includes(role as (typeof GAME_API_ROLES)[number]);
if (isMain()) {
const role = process.env.GAME_API_ROLE ?? 'server';
if (shouldRunGameApi(process.env.GAME_API_ROLE)) {
const role = process.env.GAME_API_ROLE;
const run =
role === 'battle-sim-worker'
? runBattleSimWorker
+12
View File
@@ -0,0 +1,12 @@
import { describe, expect, it } from 'vitest';
import { shouldRunGameApi } from '../src/index.js';
describe('game-api entrypoint', () => {
it('starts only for an explicitly selected API or worker role', () => {
expect(shouldRunGameApi('server')).toBe(true);
expect(shouldRunGameApi('auction-worker')).toBe(true);
expect(shouldRunGameApi('gateway')).toBe(false);
expect(shouldRunGameApi(undefined)).toBe(false);
});
});
+1 -1
View File
@@ -8,7 +8,7 @@
"scripts": {
"build": "tsdown -c ../../tsdown.config.ts -F @sammo-ts/game-engine",
"dev": "tsdown -c ../../tsdown.config.ts -F @sammo-ts/game-engine --watch",
"start": "pnpm run build && node dist/index.js",
"start": "pnpm run build && GAME_ENGINE_ROLE=turn-daemon node dist/index.js",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"profile:npc-unification-memory": "node scripts/profile-npc-unification-memory.mjs",
+2 -13
View File
@@ -1,6 +1,3 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { runTurnDaemonCli } from './turn/cli.js';
export * from './lifecycle/types.js';
@@ -29,17 +26,9 @@ export * from './turn/selectPoolService.js';
export * from './turn/turnDaemon.js';
export * from './turn/cli.js';
const isMain = (): boolean => {
if (typeof process.env.NODE_APP_INSTANCE === 'string') {
return true;
}
if (!process.argv[1]) {
return false;
}
return fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
};
export const shouldRunTurnDaemon = (role: string | undefined): boolean => role === 'turn-daemon';
if (isMain()) {
if (shouldRunTurnDaemon(process.env.GAME_ENGINE_ROLE)) {
runTurnDaemonCli().catch((error) => {
console.error('[turn-daemon] failed to start', error);
process.exitCode = 1;
+11
View File
@@ -0,0 +1,11 @@
import { describe, expect, it } from 'vitest';
import { shouldRunTurnDaemon } from '../src/index.js';
describe('game-engine entrypoint', () => {
it('starts only for an explicitly selected turn-daemon role', () => {
expect(shouldRunTurnDaemon('turn-daemon')).toBe(true);
expect(shouldRunTurnDaemon('api')).toBe(false);
expect(shouldRunTurnDaemon(undefined)).toBe(false);
});
});
+13 -1
View File
@@ -11,6 +11,18 @@ const normalizeBasePath = (value: string | undefined): string => {
return `/${pathValue.replace(/^\/+|\/+$/g, '')}/`;
};
const resolvePreviewAllowedHosts = (value: string | undefined): true | string[] => {
const normalized = value?.trim();
if (normalized === '*') {
return true;
}
const hosts = (normalized ?? 'dev-sam-e2e.hided.net')
.split(',')
.map((host) => host.trim())
.filter(Boolean);
return hosts;
};
// https://vitejs.dev/config/
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '');
@@ -28,7 +40,7 @@ export default defineConfig(({ mode }) => {
},
preview: {
host: '0.0.0.0',
allowedHosts: ['dev-sam-e2e.hided.net'],
allowedHosts: resolvePreviewAllowedHosts(env.VITE_PREVIEW_ALLOWED_HOSTS),
},
};
});
+5 -11
View File
@@ -1,6 +1,3 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { runGatewayApiServer } from './server.js';
import { runGatewayOrchestrator } from './orchestrator/orchestratorServer.js';
import { runProfileSeedCli } from './orchestrator/profileSeedCli.js';
@@ -33,15 +30,12 @@ export * from './auth/kakaoClient.js';
export * from './auth/oauthSessionStore.js';
export * from './auth/postgresUserRepository.js';
const isMain = (): boolean => {
if (!process.argv[1]) {
return false;
}
return fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
};
const GATEWAY_ROLES = ['api', 'orchestrator', 'profile-seed'] as const;
export const shouldRunGateway = (role: string | undefined): boolean =>
typeof role === 'string' && GATEWAY_ROLES.includes(role as (typeof GATEWAY_ROLES)[number]);
if (isMain()) {
const role = process.env.GATEWAY_ROLE ?? 'api';
if (shouldRunGateway(process.env.GATEWAY_ROLE)) {
const role = process.env.GATEWAY_ROLE;
const run =
role === 'orchestrator'
? runGatewayOrchestrator
@@ -378,13 +378,14 @@ export const buildProcessDefinitions = (
const runtimeWorkspace = profile.buildWorkspace ?? config.workspaceRoot;
const frontendCwd = path.join(runtimeWorkspace, 'app', 'game-frontend');
const frontendOutDir = buildProfileFrontendOutDir(runtimeWorkspace, profile.profileName);
const frontendScript = path.join(runtimeWorkspace, 'node_modules', 'vite', 'bin', 'vite.js');
const frontendScript = path.join(frontendCwd, 'node_modules', 'vite', 'bin', 'vite.js');
const apiCwd = path.join(runtimeWorkspace, 'app', 'game-api');
const daemonCwd = path.join(runtimeWorkspace, 'app', 'game-engine');
const apiScript = path.join(apiCwd, 'dist', 'index.js');
const daemonScript = path.join(daemonCwd, 'dist', 'index.js');
const apiEnv = {
...baseEnv,
GAME_API_ROLE: 'server',
PROFILE: profile.profile,
SCENARIO: profile.scenario,
GAME_PROFILE_NAME: profile.profileName,
@@ -398,6 +399,7 @@ export const buildProcessDefinitions = (
};
const daemonEnv = {
...baseEnv,
GAME_ENGINE_ROLE: 'turn-daemon',
TURN_PROFILE: profile.profile,
PROFILE: profile.profile,
SCENARIO: profile.scenario,
+13
View File
@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest';
import { shouldRunGateway } from '../src/index.js';
describe('gateway entrypoint', () => {
it('starts only for an explicitly selected Gateway role', () => {
expect(shouldRunGateway('api')).toBe(true);
expect(shouldRunGateway('orchestrator')).toBe(true);
expect(shouldRunGateway('profile-seed')).toBe(true);
expect(shouldRunGateway('server')).toBe(false);
expect(shouldRunGateway(undefined)).toBe(false);
});
});
@@ -118,7 +118,7 @@ describe('buildProcessDefinitions', () => {
expect(definitions.frontend).toMatchObject({
cwd: path.join(buildWorkspace, 'app', 'game-frontend'),
script: path.join(buildWorkspace, 'node_modules', 'vite', 'bin', 'vite.js'),
script: path.join(buildWorkspace, 'app', 'game-frontend', 'node_modules', 'vite', 'bin', 'vite.js'),
args: [
'preview',
'--host',
+13 -1
View File
@@ -11,6 +11,18 @@ const normalizeBasePath = (value: string | undefined): string => {
return `/${pathValue.replace(/^\/+|\/+$/g, '')}/`;
};
const resolvePreviewAllowedHosts = (value: string | undefined): true | string[] => {
const normalized = value?.trim();
if (normalized === '*') {
return true;
}
const hosts = (normalized ?? 'dev-sam-e2e.hided.net')
.split(',')
.map((host) => host.trim())
.filter(Boolean);
return hosts;
};
// https://vitejs.dev/config/
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '');
@@ -28,7 +40,7 @@ export default defineConfig(({ mode }) => {
},
preview: {
host: '0.0.0.0',
allowedHosts: ['dev-sam-e2e.hided.net'],
allowedHosts: resolvePreviewAllowedHosts(env.VITE_PREVIEW_ALLOWED_HOSTS),
},
};
});
@@ -61,7 +61,7 @@ export const buildGatewayProcessDefinitions = (
const apiCwd = path.join(workspaceRoot, 'app', 'gateway-api');
const frontendCwd = path.join(workspaceRoot, 'app', 'gateway-frontend');
const apiScript = path.join(apiCwd, 'dist', 'index.js');
const frontendScript = path.join(workspaceRoot, 'node_modules', 'vite', 'bin', 'vite.js');
const frontendScript = path.join(frontendCwd, 'node_modules', 'vite', 'bin', 'vite.js');
const env = {
...config.baseEnv,
GATEWAY_API_HOST: '0.0.0.0',
@@ -14,7 +14,7 @@ import type {
import { afterEach, describe, expect, it } from 'vitest';
import { resolveReleaseControllerConfig, type ReleaseControllerConfig } from '../src/config.js';
import { GatewayReleaseController } from '../src/releaseController.js';
import { buildGatewayProcessDefinitions, GatewayReleaseController } from '../src/releaseController.js';
import { upgradeReleaseController } from '../src/selfUpgrade.js';
const SHA = '1111111111111111111111111111111111111111';
@@ -116,6 +116,14 @@ const createRepository = () => {
const gatewayNames = ['sammo:gateway-api', 'sammo:gateway-frontend', 'sammo:gateway-orchestrator'];
it('runs Gateway preview from the frontend workspace dependency', () => {
const definitions = buildGatewayProcessDefinitions('/srv/sammo/release', config);
const frontend = definitions.find((definition) => definition.name === 'sammo:gateway-frontend');
expect(frontend?.script).toBe(
'/srv/sammo/release/app/gateway-frontend/node_modules/vite/bin/vite.js'
);
});
describe('GatewayReleaseController', () => {
it('builds, migrates, switches all gateway roles, verifies readiness, and publishes atomically', async () => {
const workspace = await createReleaseWorkspace();
+2
View File
@@ -52,6 +52,7 @@ 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_PREVIEW_ALLOWED_HOSTS=dev-sam-e2e.hided.net \
pnpm --filter @sammo-ts/gateway-frontend build
```
@@ -61,6 +62,7 @@ Game frontend는 profile별 값으로 build합니다.
VITE_APP_BASE_PATH=/che \
VITE_GAME_API_URL=/che/api/trpc \
VITE_GAME_SSE_URL=/che/api/events \
VITE_PREVIEW_ALLOWED_HOSTS=dev-sam-e2e.hided.net \
pnpm --filter @sammo-ts/game-frontend build
```
+7
View File
@@ -25,6 +25,13 @@ profile 범위 권한과 별개인 전역 `admin.releases.manage` 권한이 필
- Gateway PostgreSQL, profile PostgreSQL, Redis와 PM2가 준비되어 있습니다.
- controller가 `GATEWAY_DATABASE_URL`, `GATEWAY_DB_SCHEMA`, workspace와
worktree 경로를 올바르게 읽습니다.
- 공개 hostname을 쉼표로 구분한 `VITE_PREVIEW_ALLOWED_HOSTS`가 Gateway와
profile frontend build 환경에 전달됩니다. 신뢰된 reverse proxy 뒤가 아니라면
`*`를 사용하지 않습니다.
- PM2 definition은 Gateway에 `GATEWAY_ROLE=api|orchestrator`, game API에
`GAME_API_ROLE=server|*-worker`, turn daemon에
`GAME_ENGINE_ROLE=turn-daemon`을 명시합니다. library import나 PM2 wrapper의
argv만으로 실행 역할을 추론하지 않습니다.
- migration 이후 이전 애플리케이션으로 돌아갈 때 schema 하위 호환성이
유지됩니다.