perf: 릴리스 프론트엔드 빌드를 안전하게 캐시
Gateway와 Profile 릴리스의 타입검사와 Vite 번들을 별도 Turbo 작업으로 캐시한다. DB 보존 DEPLOY는 game-api만 빌드하고 기본 동시성 1과 기존 산출물 계약은 유지한다.
This commit is contained in:
@@ -173,3 +173,4 @@ playwright-report/
|
|||||||
test-results/
|
test-results/
|
||||||
uploads/
|
uploads/
|
||||||
.release-dist/
|
.release-dist/
|
||||||
|
app/game-frontend/.release-build/
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vue-tsc && vite build",
|
"build": "vue-tsc && vite build",
|
||||||
|
"build:release": "vite build --outDir .release-build",
|
||||||
|
"typecheck:release": "vue-tsc --noEmit",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "pnpm test:unit",
|
"test": "pnpm test:unit",
|
||||||
"test:unit": "node --test test/**/*.test.ts",
|
"test:unit": "node --test test/**/*.test.ts",
|
||||||
|
|||||||
@@ -49,13 +49,21 @@ export const buildTurboReleaseCommand = (
|
|||||||
cacheAnchorRoot: string,
|
cacheAnchorRoot: string,
|
||||||
packageNames: string[],
|
packageNames: string[],
|
||||||
env?: Record<string, string>
|
env?: Record<string, string>
|
||||||
|
): BuildCommand => buildTurboReleaseTaskCommand(workspaceRoot, cacheAnchorRoot, 'build', packageNames, env);
|
||||||
|
|
||||||
|
export const buildTurboReleaseTaskCommand = (
|
||||||
|
workspaceRoot: string,
|
||||||
|
cacheAnchorRoot: string,
|
||||||
|
taskName: string,
|
||||||
|
packageNames: string[],
|
||||||
|
env?: Record<string, string>
|
||||||
): BuildCommand => ({
|
): BuildCommand => ({
|
||||||
command: 'pnpm',
|
command: 'pnpm',
|
||||||
args: [
|
args: [
|
||||||
'exec',
|
'exec',
|
||||||
'turbo',
|
'turbo',
|
||||||
'run',
|
'run',
|
||||||
'build',
|
taskName,
|
||||||
...packageNames.map((packageName) => `--filter=${packageName}`),
|
...packageNames.map((packageName) => `--filter=${packageName}`),
|
||||||
`--cache-dir=${resolveReleaseTurboCacheDir(cacheAnchorRoot, env)}`,
|
`--cache-dir=${resolveReleaseTurboCacheDir(cacheAnchorRoot, env)}`,
|
||||||
`--concurrency=${resolveReleaseTurboConcurrency(env)}`,
|
`--concurrency=${resolveReleaseTurboConcurrency(env)}`,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { isRecord } from '@sammo-ts/common';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
buildTurboReleaseCommand,
|
buildTurboReleaseCommand,
|
||||||
|
buildTurboReleaseTaskCommand,
|
||||||
type BuildCommand,
|
type BuildCommand,
|
||||||
type BuildProgressEvent,
|
type BuildProgressEvent,
|
||||||
type BuildProgressObserver,
|
type BuildProgressObserver,
|
||||||
@@ -530,7 +531,8 @@ const buildProfileFrontendOutDir = (workspaceRoot: string, profileName: string):
|
|||||||
export const buildProfileFrontendCommands = (
|
export const buildProfileFrontendCommands = (
|
||||||
workspaceRoot: string,
|
workspaceRoot: string,
|
||||||
profile: Pick<GatewayProfileRecord, 'profileName' | 'profile' | 'apiPort'>,
|
profile: Pick<GatewayProfileRecord, 'profileName' | 'profile' | 'apiPort'>,
|
||||||
env?: Record<string, string>
|
env?: Record<string, string>,
|
||||||
|
cacheAnchorRoot: string = workspaceRoot
|
||||||
): BuildCommand[] => {
|
): BuildCommand[] => {
|
||||||
const profileFrontendBuildNodeOptions = env?.PROFILE_FRONTEND_BUILD_NODE_OPTIONS?.trim();
|
const profileFrontendBuildNodeOptions = env?.PROFILE_FRONTEND_BUILD_NODE_OPTIONS?.trim();
|
||||||
const buildEnv = {
|
const buildEnv = {
|
||||||
@@ -540,17 +542,17 @@ export const buildProfileFrontendCommands = (
|
|||||||
VITE_GAME_API_URL: `/${profile.profile}/api/trpc`,
|
VITE_GAME_API_URL: `/${profile.profile}/api/trpc`,
|
||||||
VITE_GAME_SSE_URL: `/${profile.profile}/api/events`,
|
VITE_GAME_SSE_URL: `/${profile.profile}/api/events`,
|
||||||
};
|
};
|
||||||
const outDir = buildProfileFrontendOutDir(workspaceRoot, profile.profileName);
|
|
||||||
return [
|
return [
|
||||||
|
buildTurboReleaseTaskCommand(
|
||||||
|
workspaceRoot,
|
||||||
|
cacheAnchorRoot,
|
||||||
|
'build:release',
|
||||||
|
['@sammo-ts/game-frontend'],
|
||||||
|
buildEnv
|
||||||
|
),
|
||||||
{
|
{
|
||||||
command: 'pnpm',
|
command: 'node',
|
||||||
args: ['--filter', '@sammo-ts/game-frontend', 'exec', 'vue-tsc', '--noEmit'],
|
args: ['tools/build-scripts/materialize-profile-frontend.mjs', profile.profileName],
|
||||||
cwd: workspaceRoot,
|
|
||||||
env: buildEnv,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
command: 'pnpm',
|
|
||||||
args: ['--filter', '@sammo-ts/game-frontend', 'exec', 'vite', 'build', '--outDir', outDir],
|
|
||||||
cwd: workspaceRoot,
|
cwd: workspaceRoot,
|
||||||
env: buildEnv,
|
env: buildEnv,
|
||||||
},
|
},
|
||||||
@@ -561,7 +563,8 @@ export const buildWorkspaceCommands = (
|
|||||||
workspaceRoot: string,
|
workspaceRoot: string,
|
||||||
needsInstall: boolean,
|
needsInstall: boolean,
|
||||||
env?: Record<string, string>,
|
env?: Record<string, string>,
|
||||||
cacheAnchorRoot: string = workspaceRoot
|
cacheAnchorRoot: string = workspaceRoot,
|
||||||
|
packageNames: string[] = ['@sammo-ts/game-api', '@sammo-ts/gateway-api']
|
||||||
): BuildCommand[] => {
|
): BuildCommand[] => {
|
||||||
const commands: BuildCommand[] = [];
|
const commands: BuildCommand[] = [];
|
||||||
if (needsInstall) {
|
if (needsInstall) {
|
||||||
@@ -572,9 +575,7 @@ export const buildWorkspaceCommands = (
|
|||||||
env,
|
env,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
commands.push(
|
commands.push(buildTurboReleaseCommand(workspaceRoot, cacheAnchorRoot, packageNames, env));
|
||||||
buildTurboReleaseCommand(workspaceRoot, cacheAnchorRoot, ['@sammo-ts/game-api', '@sammo-ts/gateway-api'], env)
|
|
||||||
);
|
|
||||||
return commands;
|
return commands;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1430,9 +1431,15 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
workspace.root,
|
workspace.root,
|
||||||
workspace.needsInstall,
|
workspace.needsInstall,
|
||||||
this.processConfig.baseEnv,
|
this.processConfig.baseEnv,
|
||||||
|
this.processConfig.workspaceRoot,
|
||||||
|
['@sammo-ts/game-api']
|
||||||
|
),
|
||||||
|
...buildProfileFrontendCommands(
|
||||||
|
workspace.root,
|
||||||
|
profile,
|
||||||
|
this.processConfig.baseEnv,
|
||||||
this.processConfig.workspaceRoot
|
this.processConfig.workspaceRoot
|
||||||
),
|
),
|
||||||
...buildProfileFrontendCommands(workspace.root, profile, this.processConfig.baseEnv),
|
|
||||||
];
|
];
|
||||||
await this.appendOperationLog(operationId, 'build', `${profile.profileName} 구성 요소를 빌드합니다.`);
|
await this.appendOperationLog(operationId, 'build', `${profile.profileName} 구성 요소를 빌드합니다.`);
|
||||||
const result = await this.buildRunner.run(commands, this.buildProgress(operationId, 'build'));
|
const result = await this.buildRunner.run(commands, this.buildProgress(operationId, 'build'));
|
||||||
@@ -2004,7 +2011,14 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
this.processConfig.baseEnv,
|
this.processConfig.baseEnv,
|
||||||
this.processConfig.workspaceRoot
|
this.processConfig.workspaceRoot
|
||||||
),
|
),
|
||||||
...(profile ? buildProfileFrontendCommands(workspace.root, profile, this.processConfig.baseEnv) : []),
|
...(profile
|
||||||
|
? buildProfileFrontendCommands(
|
||||||
|
workspace.root,
|
||||||
|
profile,
|
||||||
|
this.processConfig.baseEnv,
|
||||||
|
this.processConfig.workspaceRoot
|
||||||
|
)
|
||||||
|
: []),
|
||||||
];
|
];
|
||||||
if (operationId) {
|
if (operationId) {
|
||||||
await this.appendOperationLog(
|
await this.appendOperationLog(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
buildTurboReleaseCommand,
|
buildTurboReleaseCommand,
|
||||||
|
buildTurboReleaseTaskCommand,
|
||||||
MAX_BUILD_OUTPUT_CHARS,
|
MAX_BUILD_OUTPUT_CHARS,
|
||||||
PnpmBuildRunner,
|
PnpmBuildRunner,
|
||||||
resolveReleaseTurboCacheDir,
|
resolveReleaseTurboCacheDir,
|
||||||
@@ -58,6 +59,28 @@ describe('Turbo release build plan', () => {
|
|||||||
env: { NODE_ENV: 'production' },
|
env: { NODE_ENV: 'production' },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses the same bounded cache policy for a release-specific task', () => {
|
||||||
|
expect(
|
||||||
|
buildTurboReleaseTaskCommand(
|
||||||
|
'/srv/core/profile-worktrees/commit',
|
||||||
|
'/srv/core/repository',
|
||||||
|
'build:release',
|
||||||
|
['@sammo-ts/game-frontend'],
|
||||||
|
{ VITE_APP_BASE_PATH: '/che' }
|
||||||
|
).args
|
||||||
|
).toEqual([
|
||||||
|
'exec',
|
||||||
|
'turbo',
|
||||||
|
'run',
|
||||||
|
'build:release',
|
||||||
|
'--filter=@sammo-ts/game-frontend',
|
||||||
|
'--cache-dir=/srv/core/repository/.turbo/release-cache',
|
||||||
|
'--concurrency=1',
|
||||||
|
'--ui=stream',
|
||||||
|
'--output-logs=new-only',
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('PnpmBuildRunner', () => {
|
describe('PnpmBuildRunner', () => {
|
||||||
|
|||||||
@@ -315,6 +315,20 @@ describe('buildWorkspaceCommands', () => {
|
|||||||
expect(commands.every(({ cwd }) => cwd === workspaceRoot)).toBe(true);
|
expect(commands.every(({ cwd }) => cwd === workspaceRoot)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('can limit a DB-preserving deploy to the game runtime server target', () => {
|
||||||
|
const commands = buildWorkspaceCommands(
|
||||||
|
'/srv/sammo/worktrees/0123456789abcdef',
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
'/srv/sammo/controller',
|
||||||
|
['@sammo-ts/game-api']
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(commands[0]?.args).toContain('--filter=@sammo-ts/game-api');
|
||||||
|
expect(commands[0]?.args).not.toContain('--filter=@sammo-ts/gateway-api');
|
||||||
|
expect(commands[0]?.args).toContain('--concurrency=1');
|
||||||
|
});
|
||||||
|
|
||||||
it('deploys the game schema migration after building the selected workspace', () => {
|
it('deploys the game schema migration after building the selected workspace', () => {
|
||||||
const workspaceRoot = '/srv/sammo/worktrees/0123456789abcdef';
|
const workspaceRoot = '/srv/sammo/worktrees/0123456789abcdef';
|
||||||
const databaseUrl = 'postgresql://integration.invalid/sammo?schema=che';
|
const databaseUrl = 'postgresql://integration.invalid/sammo?schema=che';
|
||||||
@@ -347,6 +361,18 @@ describe('buildProfileFrontendCommands', () => {
|
|||||||
(command) => command.env?.PROFILE_FRONTEND_BUILD_NODE_OPTIONS === '--max-old-space-size=2048'
|
(command) => command.env?.PROFILE_FRONTEND_BUILD_NODE_OPTIONS === '--max-old-space-size=2048'
|
||||||
)
|
)
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
|
expect(commands[0]?.args).toEqual([
|
||||||
|
'exec',
|
||||||
|
'turbo',
|
||||||
|
'run',
|
||||||
|
'build:release',
|
||||||
|
'--filter=@sammo-ts/game-frontend',
|
||||||
|
'--cache-dir=/srv/sammo/worktrees/0123456789abcdef/.turbo/release-cache',
|
||||||
|
'--concurrency=1',
|
||||||
|
'--ui=stream',
|
||||||
|
'--output-logs=new-only',
|
||||||
|
]);
|
||||||
|
expect(commands[1]?.args).toEqual(['tools/build-scripts/materialize-profile-frontend.mjs', 'che:2']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps the shared Node heap when no frontend build override is configured', () => {
|
it('keeps the shared Node heap when no frontend build override is configured', () => {
|
||||||
|
|||||||
@@ -185,6 +185,15 @@ describe('profile DEPLOY operation', () => {
|
|||||||
|
|
||||||
expect(commandGroups).toHaveLength(2);
|
expect(commandGroups).toHaveLength(2);
|
||||||
expect(commandGroups[0]?.[0]?.args).toEqual(['install', '--frozen-lockfile']);
|
expect(commandGroups[0]?.[0]?.args).toEqual(['install', '--frozen-lockfile']);
|
||||||
|
expect(commandGroups[0]?.[1]?.args).toContain('--filter=@sammo-ts/game-api');
|
||||||
|
expect(commandGroups[0]?.[1]?.args).not.toContain('--filter=@sammo-ts/gateway-api');
|
||||||
|
expect(commandGroups[0]?.[2]?.args).toContain('build:release');
|
||||||
|
expect(commandGroups[0]?.[2]?.args).toContain('--cache-dir=/srv/sammo/controller/.turbo/release-cache');
|
||||||
|
expect(commandGroups[0]?.[2]?.args).toContain('--concurrency=1');
|
||||||
|
expect(commandGroups[0]?.[3]?.args).toEqual([
|
||||||
|
'tools/build-scripts/materialize-profile-frontend.mjs',
|
||||||
|
'che:1010',
|
||||||
|
]);
|
||||||
expect(commandGroups[1]?.map((command) => command.args)).toEqual([
|
expect(commandGroups[1]?.map((command) => command.args)).toEqual([
|
||||||
['--filter', '@sammo-ts/infra', 'prisma:migrate:deploy:game'],
|
['--filter', '@sammo-ts/infra', 'prisma:migrate:deploy:game'],
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { execFile } from 'node:child_process';
|
||||||
|
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
const materializer = path.resolve(import.meta.dirname, '../../../tools/build-scripts/materialize-profile-frontend.mjs');
|
||||||
|
const temporaryRoots: string[] = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('profile frontend materializer', () => {
|
||||||
|
it('replaces an existing profile artifact from the cached release build', async () => {
|
||||||
|
const workspaceRoot = await mkdtemp(path.join(os.tmpdir(), 'profile-frontend-materializer-'));
|
||||||
|
temporaryRoots.push(workspaceRoot);
|
||||||
|
const source = path.join(workspaceRoot, 'app', 'game-frontend', '.release-build');
|
||||||
|
const target = path.join(workspaceRoot, '.release-dist', 'che_2', 'game-frontend');
|
||||||
|
await mkdir(source, { recursive: true });
|
||||||
|
await mkdir(target, { recursive: true });
|
||||||
|
await writeFile(path.join(source, 'index.html'), 'new release');
|
||||||
|
await writeFile(path.join(target, 'index.html'), 'old release');
|
||||||
|
|
||||||
|
await execFileAsync(process.execPath, [materializer, 'che:2'], { cwd: workspaceRoot });
|
||||||
|
|
||||||
|
await expect(readFile(path.join(target, 'index.html'), 'utf8')).resolves.toBe('new release');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps an existing artifact untouched when the cached build is missing', async () => {
|
||||||
|
const workspaceRoot = await mkdtemp(path.join(os.tmpdir(), 'profile-frontend-materializer-'));
|
||||||
|
temporaryRoots.push(workspaceRoot);
|
||||||
|
const target = path.join(workspaceRoot, '.release-dist', 'che_2', 'game-frontend');
|
||||||
|
await mkdir(target, { recursive: true });
|
||||||
|
await writeFile(path.join(target, 'index.html'), 'old release');
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
execFileAsync(process.execPath, [materializer, 'che:2'], { cwd: workspaceRoot })
|
||||||
|
).rejects.toThrow();
|
||||||
|
await expect(readFile(path.join(target, 'index.html'), 'utf8')).resolves.toBe('old release');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,6 +10,8 @@
|
|||||||
"test:e2e:hwe-lifecycle": "playwright test --config e2e/hwe-lifecycle.playwright.config.mjs",
|
"test:e2e:hwe-lifecycle": "playwright test --config e2e/hwe-lifecycle.playwright.config.mjs",
|
||||||
"test:e2e:general-icons": "playwright test --config e2e/general-icon-lifecycle.playwright.config.mjs",
|
"test:e2e:general-icons": "playwright test --config e2e/general-icon-lifecycle.playwright.config.mjs",
|
||||||
"build": "vue-tsc && vite build",
|
"build": "vue-tsc && vite build",
|
||||||
|
"build:release": "vite build",
|
||||||
|
"typecheck:release": "vue-tsc --noEmit",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"lint:fix": "eslint . --fix",
|
"lint:fix": "eslint . --fix",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { stripVTControlCharacters } from 'node:util';
|
|||||||
import {
|
import {
|
||||||
assertReleaseComponents,
|
assertReleaseComponents,
|
||||||
buildTurboReleaseCommand,
|
buildTurboReleaseCommand,
|
||||||
|
buildTurboReleaseTaskCommand,
|
||||||
type BuildCommand,
|
type BuildCommand,
|
||||||
type BuildProgressEvent,
|
type BuildProgressEvent,
|
||||||
type BuildRunner,
|
type BuildRunner,
|
||||||
@@ -43,10 +44,12 @@ const buildGatewayReleaseCommands = (
|
|||||||
};
|
};
|
||||||
return [
|
return [
|
||||||
...(needsInstall ? [{ command: 'pnpm', args: ['install', '--frozen-lockfile'], cwd: workspaceRoot, env }] : []),
|
...(needsInstall ? [{ command: 'pnpm', args: ['install', '--frozen-lockfile'], cwd: workspaceRoot, env }] : []),
|
||||||
buildTurboReleaseCommand(
|
buildTurboReleaseCommand(workspaceRoot, config.workspaceRoot, ['@sammo-ts/gateway-api'], env),
|
||||||
|
buildTurboReleaseTaskCommand(
|
||||||
workspaceRoot,
|
workspaceRoot,
|
||||||
config.workspaceRoot,
|
config.workspaceRoot,
|
||||||
['@sammo-ts/gateway-api', '@sammo-ts/gateway-frontend'],
|
'build:release',
|
||||||
|
['@sammo-ts/gateway-frontend'],
|
||||||
env
|
env
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -220,6 +220,11 @@ describe('GatewayReleaseController', () => {
|
|||||||
expect(commandGroups[0]?.[0]).toBe('install --frozen-lockfile');
|
expect(commandGroups[0]?.[0]).toBe('install --frozen-lockfile');
|
||||||
expect(commandGroups[0]?.[1]).toContain('turbo run build');
|
expect(commandGroups[0]?.[1]).toContain('turbo run build');
|
||||||
expect(commandGroups[0]?.[1]).toContain('--cache-dir=/srv/sammo/controller/.turbo/release-cache');
|
expect(commandGroups[0]?.[1]).toContain('--cache-dir=/srv/sammo/controller/.turbo/release-cache');
|
||||||
|
expect(commandGroups[0]?.[1]).toContain('--filter=@sammo-ts/gateway-api');
|
||||||
|
expect(commandGroups[0]?.[1]).not.toContain('--filter=@sammo-ts/gateway-frontend');
|
||||||
|
expect(commandGroups[0]?.[2]).toContain('turbo run build:release');
|
||||||
|
expect(commandGroups[0]?.[2]).toContain('--filter=@sammo-ts/gateway-frontend');
|
||||||
|
expect(commandGroups[0]?.[2]).toContain('--concurrency=1');
|
||||||
expect(commandGroups[1]).toEqual(['--filter @sammo-ts/infra prisma:migrate:deploy:gateway']);
|
expect(commandGroups[1]).toEqual(['--filter @sammo-ts/infra prisma:migrate:deploy:gateway']);
|
||||||
expect([...running.keys()].sort()).toEqual([...gatewayNames].sort());
|
expect([...running.keys()].sort()).toEqual([...gatewayNames].sort());
|
||||||
expect(harness.published).toEqual([
|
expect(harness.published).toEqual([
|
||||||
|
|||||||
@@ -51,15 +51,19 @@ profile 범위 권한과 별개인 전역 `admin.releases.manage` 권한이 필
|
|||||||
`.turbo/release-cache`이므로 commit별 worktree가 달라도 재사용됩니다. 별도
|
`.turbo/release-cache`이므로 commit별 worktree가 달라도 재사용됩니다. 별도
|
||||||
persistent 경로가 필요하면 controller/orchestrator 환경에 `TURBO_CACHE_DIR`을
|
persistent 경로가 필요하면 controller/orchestrator 환경에 `TURBO_CACHE_DIR`을
|
||||||
설정합니다. Cache는 재생성 가능한 build artifact이며 DB/Redis backup이 아닙니다.
|
설정합니다. Cache는 재생성 가능한 build artifact이며 DB/Redis backup이 아닙니다.
|
||||||
- `NODE_ENV`와 Vite가 추론한 `VITE_*`는 build hash에 포함됩니다. 따라서 base path나
|
- Server build 뒤 frontend release는 `typecheck:release`와 Vite bundle을 별도 Turbo
|
||||||
API URL이 다른 frontend artifact를 cache hit로 잘못 복원하지 않습니다.
|
task로 실행합니다. 타입검사 hash에는 frontend 자체와 직접 해석하는 common/infra/
|
||||||
|
logic/game-engine/game-api/gateway-api source, Prisma schema와 기본 navigation resource가
|
||||||
|
들어가며, bundle hash에는 `NODE_ENV`와 모든 `VITE_*`가 포함됩니다. 따라서 내부 type
|
||||||
|
source, base path나 API URL이 다른 frontend artifact를 cache hit로 잘못 복원하지
|
||||||
|
않습니다. 일반 개발용 `pnpm --filter <frontend> build`의 typecheck 계약은 그대로입니다.
|
||||||
`NODE_OPTIONS`와 `RAYON_NUM_THREADS`는 출력에는 영향을 주지 않는 resource 제한으로
|
`NODE_OPTIONS`와 `RAYON_NUM_THREADS`는 출력에는 영향을 주지 않는 resource 제한으로
|
||||||
build child에 전달됩니다.
|
build child에 전달됩니다.
|
||||||
- `TURN_DAEMON_NODE_OPTIONS`가 설정되어 있으면 Gateway orchestrator는 그 값을
|
- `TURN_DAEMON_NODE_OPTIONS`가 설정되어 있으면 Gateway orchestrator는 그 값을
|
||||||
turn-daemon PM2 process의 `NODE_OPTIONS`로만 덮어씁니다. API·frontend·worker는
|
turn-daemon PM2 process의 `NODE_OPTIONS`로만 덮어씁니다. API·frontend·worker는
|
||||||
공용 `NODE_OPTIONS`를 계속 사용합니다. Profile의 `vue-tsc`와 Vite build만 더 큰
|
공용 `NODE_OPTIONS`를 계속 사용합니다. Profile의 `vue-tsc`와 Vite build만 더 큰
|
||||||
heap이 필요하면 `PROFILE_FRONTEND_BUILD_NODE_OPTIONS`를 지정합니다. 이 값은
|
heap이 필요하면 `PROFILE_FRONTEND_BUILD_NODE_OPTIONS`를 지정합니다. 이 값은
|
||||||
profile frontend build child의 `NODE_OPTIONS`만 덮어쓰며 server package Turbo
|
profile frontend release task의 `NODE_OPTIONS`만 덮어쓰며 server package Turbo
|
||||||
build와 배포 후 PM2 process의 heap은 바꾸지 않습니다. 전용 heap을 늘릴 때는
|
build와 배포 후 PM2 process의 heap은 바꾸지 않습니다. 전용 heap을 늘릴 때는
|
||||||
runtime container hard limit과 전체 process RSS를 먼저 확인합니다.
|
runtime container hard limit과 전체 process RSS를 먼저 확인합니다.
|
||||||
- migration 이후 이전 애플리케이션으로 돌아갈 때 schema 하위 호환성이
|
- migration 이후 이전 애플리케이션으로 돌아갈 때 schema 하위 호환성이
|
||||||
@@ -80,7 +84,8 @@ Gateway process 전환이 진행 중인 profile migration·seed 실행자를 중
|
|||||||
|
|
||||||
`DB 유지 배포`는 현재 시즌을 계속 운영하면서 코드를 교체할 때 사용합니다.
|
`DB 유지 배포`는 현재 시즌을 계속 운영하면서 코드를 교체할 때 사용합니다.
|
||||||
|
|
||||||
1. 대상 commit의 game frontend, API, engine과 worker artifact를 빌드합니다.
|
1. 대상 commit의 game API target과 그 transitive engine/worker artifact를 빌드한 뒤,
|
||||||
|
profile frontend typecheck와 bundle을 공유 Turbo cache에서 복원하거나 생성합니다.
|
||||||
2. 기존 profile PM2 process를 정지합니다.
|
2. 기존 profile PM2 process를 정지합니다.
|
||||||
3. profile game schema에 `prisma migrate deploy`를 실행합니다.
|
3. profile game schema에 `prisma migrate deploy`를 실행합니다.
|
||||||
4. Scenario seed를 실행하지 않고 frontend, API, daemon과 worker를 시작합니다.
|
4. Scenario seed를 실행하지 않고 frontend, API, daemon과 worker를 시작합니다.
|
||||||
@@ -90,6 +95,15 @@ Gateway process 전환이 진행 중인 profile migration·seed 실행자를 중
|
|||||||
데이터를 변환할 수 있으므로 대상 migration의 운영 데이터 영향은 배포 전에
|
데이터를 변환할 수 있으므로 대상 migration의 운영 데이터 영향은 배포 전에
|
||||||
별도로 검토해 주세요.
|
별도로 검토해 주세요.
|
||||||
|
|
||||||
|
Profile frontend bundle은 package의 `.release-build`에 고정 생성되어 profile base
|
||||||
|
path별 Turbo cache에 저장됩니다. Orchestrator는 cache 복원 후 이를
|
||||||
|
`.release-dist/<profileName>/game-frontend`에 staging directory를 거쳐 교체합니다.
|
||||||
|
따라서 같은 commit·같은 공개 prefix의 재배포는 `vue-tsc`와 Vite를 다시 실행하지
|
||||||
|
않고, 여러 instance가 같은 prefix를 쓰더라도 각 runtime target은 따로 materialize됩니다.
|
||||||
|
`RESET`은 선택 worktree의 Gateway profile-seed CLI도 실행하므로 기존처럼
|
||||||
|
`gateway-api`까지 server build에 포함하고, seed를 호출하지 않는 `DEPLOY`만 명시적
|
||||||
|
server target을 `game-api`로 제한합니다.
|
||||||
|
|
||||||
Profile process 전환 중에는 frontend/API port가 잠시 닫힐 수 있습니다. 이때 이미
|
Profile process 전환 중에는 frontend/API port가 잠시 닫힐 수 있습니다. 이때 이미
|
||||||
열린 Gateway 로비의 profile 상세 조회가 실패하면 로비는 10초 request timeout과
|
열린 Gateway 로비의 profile 상세 조회가 실패하면 로비는 10초 request timeout과
|
||||||
1·2·3·5·8·15초(이후 15초 상한) 재시도로 자동 복구를 시도합니다. 상세가 아직
|
1·2·3·5·8·15초(이후 15초 상한) 재시도로 자동 복구를 시도합니다. 상세가 아직
|
||||||
@@ -208,7 +222,8 @@ Gateway는 자기 process를 직접 교체하지 않습니다. 관리자 화면
|
|||||||
|
|
||||||
1. Source ref를 commit SHA로 고정하고 commit worktree를 준비합니다.
|
1. Source ref를 commit SHA로 고정하고 commit worktree를 준비합니다.
|
||||||
2. Release manifest의 protocol, component와 migration head를 검증합니다.
|
2. Release manifest의 protocol, component와 migration head를 검증합니다.
|
||||||
3. Gateway API와 frontend를 공유 Turbo cache로 빌드하고 gateway migration을 적용합니다.
|
3. Gateway API server build를 공유 Turbo cache에서 준비하고, frontend는 별도 cached
|
||||||
|
typecheck와 Vite bundle task로 한 번씩 실행한 뒤 gateway migration을 적용합니다.
|
||||||
4. `sammo:gateway-api`, `sammo:gateway-frontend`,
|
4. `sammo:gateway-api`, `sammo:gateway-frontend`,
|
||||||
`sammo:gateway-orchestrator`를 새 worktree definition으로 전환합니다.
|
`sammo:gateway-orchestrator`를 새 worktree definition으로 전환합니다.
|
||||||
5. Gateway API `/healthz`, `/gateway/`와 세 PM2 process의 `online` 상태를
|
5. Gateway API `/healthz`, `/gateway/`와 세 PM2 process의 `online` 상태를
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { cp, mkdir, rename, rm, stat } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import process from 'node:process';
|
||||||
|
import { pathToFileURL } from 'node:url';
|
||||||
|
|
||||||
|
const sanitizeArtifactName = (value) => value.replace(/[^0-9A-Za-z._-]+/g, '_');
|
||||||
|
|
||||||
|
const pathExists = async (target) => {
|
||||||
|
try {
|
||||||
|
await stat(target);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code === 'ENOENT') return false;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const materializeProfileFrontend = async (workspaceRoot, profileName) => {
|
||||||
|
const normalizedProfileName = profileName.trim();
|
||||||
|
if (!normalizedProfileName) throw new Error('profileName is required.');
|
||||||
|
|
||||||
|
const source = path.join(workspaceRoot, 'app', 'game-frontend', '.release-build');
|
||||||
|
const target = path.join(
|
||||||
|
workspaceRoot,
|
||||||
|
'.release-dist',
|
||||||
|
sanitizeArtifactName(normalizedProfileName),
|
||||||
|
'game-frontend'
|
||||||
|
);
|
||||||
|
await stat(path.join(source, 'index.html'));
|
||||||
|
|
||||||
|
const parent = path.dirname(target);
|
||||||
|
const nonce = `${process.pid}-${randomUUID()}`;
|
||||||
|
const staging = path.join(parent, `.game-frontend-staging-${nonce}`);
|
||||||
|
const previous = path.join(parent, `.game-frontend-previous-${nonce}`);
|
||||||
|
await mkdir(parent, { recursive: true });
|
||||||
|
await cp(source, staging, { recursive: true, errorOnExist: true, force: false });
|
||||||
|
|
||||||
|
let movedPrevious = false;
|
||||||
|
try {
|
||||||
|
if (await pathExists(target)) {
|
||||||
|
await rename(target, previous);
|
||||||
|
movedPrevious = true;
|
||||||
|
}
|
||||||
|
await rename(staging, target);
|
||||||
|
if (movedPrevious) await rm(previous, { recursive: true, force: true });
|
||||||
|
} catch (error) {
|
||||||
|
await rm(staging, { recursive: true, force: true });
|
||||||
|
if (movedPrevious && !(await pathExists(target))) await rename(previous, target);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return target;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDirectExecution = process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
|
||||||
|
if (isDirectExecution) {
|
||||||
|
const profileName = process.argv[2];
|
||||||
|
if (!profileName) throw new Error('Usage: materialize-profile-frontend.mjs <profileName>');
|
||||||
|
const target = await materializeProfileFrontend(process.cwd(), profileName);
|
||||||
|
process.stdout.write(`Profile frontend materialized at ${target}\n`);
|
||||||
|
}
|
||||||
+22
@@ -9,6 +9,28 @@
|
|||||||
"inputs": ["$TURBO_DEFAULT$", ".env*"],
|
"inputs": ["$TURBO_DEFAULT$", ".env*"],
|
||||||
"outputs": ["dist/**"]
|
"outputs": ["dist/**"]
|
||||||
},
|
},
|
||||||
|
"build:release": {
|
||||||
|
"dependsOn": ["typecheck:release"],
|
||||||
|
"env": ["NODE_ENV", "VITE_*"],
|
||||||
|
"inputs": ["$TURBO_DEFAULT$", ".env*"],
|
||||||
|
"outputs": ["dist/**", ".release-build/**"]
|
||||||
|
},
|
||||||
|
"typecheck:release": {
|
||||||
|
"dependsOn": ["@sammo-ts/infra#prisma:generate"],
|
||||||
|
"inputs": [
|
||||||
|
"$TURBO_DEFAULT$",
|
||||||
|
"$TURBO_ROOT$/tsconfig*.json",
|
||||||
|
"$TURBO_ROOT$/resources/navigation.json",
|
||||||
|
"$TURBO_ROOT$/packages/common/src/**",
|
||||||
|
"$TURBO_ROOT$/packages/infra/src/**",
|
||||||
|
"$TURBO_ROOT$/packages/infra/prisma/*.prisma",
|
||||||
|
"$TURBO_ROOT$/packages/logic/src/**",
|
||||||
|
"$TURBO_ROOT$/app/game-engine/src/**",
|
||||||
|
"$TURBO_ROOT$/app/game-api/src/**",
|
||||||
|
"$TURBO_ROOT$/app/gateway-api/src/**"
|
||||||
|
],
|
||||||
|
"cache": true
|
||||||
|
},
|
||||||
"typecheck": {
|
"typecheck": {
|
||||||
"dependsOn": ["^build", "prisma:generate"],
|
"dependsOn": ["^build", "prisma:generate"],
|
||||||
"cache": true
|
"cache": true
|
||||||
|
|||||||
Reference in New Issue
Block a user