Merge remote-tracking branch 'origin/main' into perf/realtime-dashboard-delta-20260811
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
|
||||
export interface BuildCommand {
|
||||
command: string;
|
||||
@@ -25,6 +26,45 @@ export interface BuildRunner {
|
||||
}
|
||||
|
||||
export const MAX_BUILD_OUTPUT_CHARS = 64 * 1024;
|
||||
export const DEFAULT_RELEASE_TURBO_CONCURRENCY = 1;
|
||||
|
||||
export const resolveReleaseTurboConcurrency = (env?: Record<string, string>): number => {
|
||||
const configured = env?.RELEASE_TURBO_CONCURRENCY?.trim();
|
||||
if (!configured) return DEFAULT_RELEASE_TURBO_CONCURRENCY;
|
||||
const parsed = Number(configured);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
throw new Error('RELEASE_TURBO_CONCURRENCY must be a positive integer.');
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
|
||||
export const resolveReleaseTurboCacheDir = (cacheAnchorRoot: string, env?: Record<string, string>): string => {
|
||||
const configured = env?.TURBO_CACHE_DIR?.trim();
|
||||
if (!configured) return path.join(path.resolve(cacheAnchorRoot), '.turbo', 'release-cache');
|
||||
return path.isAbsolute(configured) ? configured : path.resolve(cacheAnchorRoot, configured);
|
||||
};
|
||||
|
||||
export const buildTurboReleaseCommand = (
|
||||
workspaceRoot: string,
|
||||
cacheAnchorRoot: string,
|
||||
packageNames: string[],
|
||||
env?: Record<string, string>
|
||||
): BuildCommand => ({
|
||||
command: 'pnpm',
|
||||
args: [
|
||||
'exec',
|
||||
'turbo',
|
||||
'run',
|
||||
'build',
|
||||
...packageNames.map((packageName) => `--filter=${packageName}`),
|
||||
`--cache-dir=${resolveReleaseTurboCacheDir(cacheAnchorRoot, env)}`,
|
||||
`--concurrency=${resolveReleaseTurboConcurrency(env)}`,
|
||||
'--ui=stream',
|
||||
'--output-logs=new-only',
|
||||
],
|
||||
cwd: workspaceRoot,
|
||||
env,
|
||||
});
|
||||
|
||||
const appendOutputTail = (current: string, chunk: unknown): string =>
|
||||
`${current}${String(chunk)}`.slice(-MAX_BUILD_OUTPUT_CHARS);
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from '@sammo-ts/infra';
|
||||
import { isRecord } from '@sammo-ts/common';
|
||||
|
||||
import type { BuildCommand, BuildRunner } from './buildRunner.js';
|
||||
import { buildTurboReleaseCommand, type BuildCommand, type BuildRunner } from './buildRunner.js';
|
||||
import { sanitizeManagedProcessEnv, type ProcessManager } from './processManager.js';
|
||||
import type {
|
||||
GatewayClaimedProfileUpdate,
|
||||
@@ -494,7 +494,8 @@ export const buildProfileFrontendCommands = (
|
||||
export const buildWorkspaceCommands = (
|
||||
workspaceRoot: string,
|
||||
needsInstall: boolean,
|
||||
env?: Record<string, string>
|
||||
env?: Record<string, string>,
|
||||
cacheAnchorRoot: string = workspaceRoot
|
||||
): BuildCommand[] => {
|
||||
const commands: BuildCommand[] = [];
|
||||
if (needsInstall) {
|
||||
@@ -505,23 +506,9 @@ export const buildWorkspaceCommands = (
|
||||
env,
|
||||
});
|
||||
}
|
||||
const buildSteps: Array<[filter: string, script: string]> = [
|
||||
['@sammo-ts/common', 'build'],
|
||||
['@sammo-ts/infra', 'prisma:generate'],
|
||||
['@sammo-ts/infra', 'build'],
|
||||
['@sammo-ts/logic', 'build'],
|
||||
['@sammo-ts/game-api', 'build'],
|
||||
['@sammo-ts/game-engine', 'build'],
|
||||
['@sammo-ts/gateway-api', 'build'],
|
||||
];
|
||||
for (const [filter, script] of buildSteps) {
|
||||
commands.push({
|
||||
command: 'pnpm',
|
||||
args: ['--filter', filter, script],
|
||||
cwd: workspaceRoot,
|
||||
env,
|
||||
});
|
||||
}
|
||||
commands.push(
|
||||
buildTurboReleaseCommand(workspaceRoot, cacheAnchorRoot, ['@sammo-ts/game-api', '@sammo-ts/gateway-api'], env)
|
||||
);
|
||||
return commands;
|
||||
};
|
||||
|
||||
@@ -1061,7 +1048,12 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
const manifest = await readReleaseManifest(workspace.root);
|
||||
assertReleaseComponents(manifest, ['game-api', 'game-engine', 'game-frontend']);
|
||||
const commands = [
|
||||
...buildWorkspaceCommands(workspace.root, workspace.needsInstall, this.processConfig.baseEnv),
|
||||
...buildWorkspaceCommands(
|
||||
workspace.root,
|
||||
workspace.needsInstall,
|
||||
this.processConfig.baseEnv,
|
||||
this.processConfig.workspaceRoot
|
||||
),
|
||||
...buildProfileFrontendCommands(workspace.root, profile, this.processConfig.baseEnv),
|
||||
];
|
||||
const result = await this.buildRunner.run(commands);
|
||||
@@ -1552,7 +1544,12 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
}> {
|
||||
const workspace = await this.workspaceManager.prepare(commitSha);
|
||||
const commands = [
|
||||
...buildWorkspaceCommands(workspace.root, workspace.needsInstall, this.processConfig.baseEnv),
|
||||
...buildWorkspaceCommands(
|
||||
workspace.root,
|
||||
workspace.needsInstall,
|
||||
this.processConfig.baseEnv,
|
||||
this.processConfig.workspaceRoot
|
||||
),
|
||||
...(profile ? buildProfileFrontendCommands(workspace.root, profile, this.processConfig.baseEnv) : []),
|
||||
];
|
||||
return { result: await this.buildRunner.run(commands), workspace };
|
||||
|
||||
@@ -2,7 +2,63 @@ import path from 'node:path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { MAX_BUILD_OUTPUT_CHARS, PnpmBuildRunner } from '../src/orchestrator/buildRunner.js';
|
||||
import {
|
||||
buildTurboReleaseCommand,
|
||||
MAX_BUILD_OUTPUT_CHARS,
|
||||
PnpmBuildRunner,
|
||||
resolveReleaseTurboCacheDir,
|
||||
resolveReleaseTurboConcurrency,
|
||||
} from '../src/orchestrator/buildRunner.js';
|
||||
|
||||
describe('Turbo release build plan', () => {
|
||||
it('anchors the default cache outside commit worktrees and allows an operator override', () => {
|
||||
expect(resolveReleaseTurboCacheDir('/srv/core/repository')).toBe('/srv/core/repository/.turbo/release-cache');
|
||||
expect(
|
||||
resolveReleaseTurboCacheDir('/srv/core/repository', {
|
||||
TURBO_CACHE_DIR: '/srv/core/cache/turbo',
|
||||
})
|
||||
).toBe('/srv/core/cache/turbo');
|
||||
expect(
|
||||
resolveReleaseTurboCacheDir('/srv/core/repository', {
|
||||
TURBO_CACHE_DIR: '.cache/turbo',
|
||||
})
|
||||
).toBe('/srv/core/repository/.cache/turbo');
|
||||
});
|
||||
|
||||
it('defaults to one worker for bounded runtimes and accepts a larger-host override', () => {
|
||||
expect(resolveReleaseTurboConcurrency()).toBe(1);
|
||||
expect(resolveReleaseTurboConcurrency({ RELEASE_TURBO_CONCURRENCY: '2' })).toBe(2);
|
||||
expect(() => resolveReleaseTurboConcurrency({ RELEASE_TURBO_CONCURRENCY: '0' })).toThrow(
|
||||
'RELEASE_TURBO_CONCURRENCY must be a positive integer.'
|
||||
);
|
||||
});
|
||||
|
||||
it('uses a bounded streaming Turbo build for the selected packages', () => {
|
||||
expect(
|
||||
buildTurboReleaseCommand(
|
||||
'/srv/core/profile-worktrees/commit',
|
||||
'/srv/core/repository',
|
||||
['@sammo-ts/game-api'],
|
||||
{ NODE_ENV: 'production' }
|
||||
)
|
||||
).toEqual({
|
||||
command: 'pnpm',
|
||||
args: [
|
||||
'exec',
|
||||
'turbo',
|
||||
'run',
|
||||
'build',
|
||||
'--filter=@sammo-ts/game-api',
|
||||
'--cache-dir=/srv/core/repository/.turbo/release-cache',
|
||||
'--concurrency=1',
|
||||
'--ui=stream',
|
||||
'--output-logs=new-only',
|
||||
],
|
||||
cwd: '/srv/core/profile-worktrees/commit',
|
||||
env: { NODE_ENV: 'production' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('PnpmBuildRunner', () => {
|
||||
it('returns a failed result when a command cannot be spawned', async () => {
|
||||
|
||||
@@ -225,17 +225,22 @@ describe('sanitizeManagedProcessEnv', () => {
|
||||
describe('buildWorkspaceCommands', () => {
|
||||
it('installs and builds runtime dependencies before the profile processes', () => {
|
||||
const workspaceRoot = '/srv/sammo/worktrees/0123456789abcdef';
|
||||
const commands = buildWorkspaceCommands(workspaceRoot, true);
|
||||
const commands = buildWorkspaceCommands(workspaceRoot, true, undefined, '/srv/sammo/controller');
|
||||
|
||||
expect(commands.map(({ args }) => args)).toEqual([
|
||||
['install', '--frozen-lockfile'],
|
||||
['--filter', '@sammo-ts/common', 'build'],
|
||||
['--filter', '@sammo-ts/infra', 'prisma:generate'],
|
||||
['--filter', '@sammo-ts/infra', 'build'],
|
||||
['--filter', '@sammo-ts/logic', 'build'],
|
||||
['--filter', '@sammo-ts/game-api', 'build'],
|
||||
['--filter', '@sammo-ts/game-engine', 'build'],
|
||||
['--filter', '@sammo-ts/gateway-api', 'build'],
|
||||
[
|
||||
'exec',
|
||||
'turbo',
|
||||
'run',
|
||||
'build',
|
||||
'--filter=@sammo-ts/game-api',
|
||||
'--filter=@sammo-ts/gateway-api',
|
||||
'--cache-dir=/srv/sammo/controller/.turbo/release-cache',
|
||||
'--concurrency=1',
|
||||
'--ui=stream',
|
||||
'--output-logs=new-only',
|
||||
],
|
||||
]);
|
||||
expect(commands.every(({ cwd }) => cwd === workspaceRoot)).toBe(true);
|
||||
});
|
||||
|
||||
@@ -224,7 +224,7 @@ test('operates OAuth grace and scheduled deletion with reasoned audit history',
|
||||
await page.getByPlaceholder('che 또는 che:2 (쉼표 구분, 비우면 전체)').fill('che');
|
||||
await page.getByPlaceholder('권한·제재·복구·탈퇴 조치 사유 (필수)').fill('휴대폰 분실 임시 복구');
|
||||
await page.getByRole('button', { name: '특수 접근 부여', exact: true }).click();
|
||||
await expect(page.getByText('특수 접근 자격을 부여했습니다.')).toBeVisible();
|
||||
await expect(page.getByText('특수 접근 자격을 부여했습니다.').first()).toBeVisible();
|
||||
await expect(page.getByText(/RECOVERY · che/)).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath('gateway-admin-special-access-granted.png'), fullPage: true });
|
||||
|
||||
@@ -232,7 +232,7 @@ test('operates OAuth grace and scheduled deletion with reasoned audit history',
|
||||
await page.getByPlaceholder('권한·제재·복구·탈퇴 조치 사유 (필수)').fill('본인 확인 처리 중');
|
||||
await gracePanel.locator('input[type="datetime-local"]').fill('2026-08-20T00:00');
|
||||
await page.getByRole('button', { name: '유예 연장', exact: true }).click();
|
||||
await expect(page.getByText('OAuth 유예 연장 완료')).toBeVisible();
|
||||
await expect(page.getByText('OAuth 유예 연장 완료').first()).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: /탈퇴 · 이력/ }).click();
|
||||
await expect(page.getByText('SUCCEEDED · admin.users.updateKakaoGrace').first()).toBeVisible();
|
||||
@@ -246,7 +246,7 @@ test('operates OAuth grace and scheduled deletion with reasoned audit history',
|
||||
await page.getByPlaceholder('권한·제재·복구·탈퇴 조치 사유 (필수)').fill('탈퇴 요청 접수');
|
||||
await page.getByLabel('탈퇴 전 보존 일수').fill('30');
|
||||
await deletionButton.click();
|
||||
await expect(page.getByText(/탈퇴 예약 완료/)).toBeVisible();
|
||||
await expect(page.getByText(/탈퇴 예약 완료/).first()).toBeVisible();
|
||||
expect(mutations.some(({ operation }) => operation === 'admin.users.updateKakaoGrace')).toBe(true);
|
||||
expect(mutations.some(({ operation }) => operation === 'admin.users.grantSpecialAccess')).toBe(true);
|
||||
expect(mutations.some(({ operation }) => operation === 'admin.users.scheduleDeletion')).toBe(true);
|
||||
|
||||
@@ -311,7 +311,7 @@ test('renders a failed terminal outcome without calling it applied', async ({ pa
|
||||
|
||||
const failed = page.getByText('FAILED · ACCELERATE 15분');
|
||||
await expect(failed).toBeVisible();
|
||||
await expect(page.getByText('DB 시간 조정 실패')).toBeVisible();
|
||||
await expect(page.getByText('DB 시간 조정 실패').first()).toBeVisible();
|
||||
expect(await failed.evaluate((element) => getComputedStyle(element).color)).toBe('oklch(0.704 0.191 22.216)');
|
||||
await expect(page.getByText(/적용됨|요청 완료/)).toHaveCount(0);
|
||||
});
|
||||
|
||||
@@ -98,7 +98,7 @@ test('admin resets and opens hwe, then two users create generals and reach main'
|
||||
const latestOperation = page.getByTestId('operations-table').locator('tbody tr').first();
|
||||
const previousLatestOperation = await latestOperation.textContent();
|
||||
await page.getByTestId('request-reset').click();
|
||||
await expect(page.getByText('초기화 작업을 등록했습니다.')).toBeVisible();
|
||||
await expect(page.getByText('초기화 작업을 등록했습니다.').first()).toBeVisible();
|
||||
|
||||
await expect
|
||||
.poll(() => latestOperation.textContent(), {
|
||||
|
||||
@@ -118,7 +118,7 @@ test('keeps the lobby and every token when server logout fails', async ({ page }
|
||||
await page.locator('#btn_logout').click();
|
||||
|
||||
await expect(page).toHaveURL(/\/gateway\/lobby$/);
|
||||
await expect(page.getByRole('alert')).toContainText('로그아웃 서버가 응답하지 않습니다.');
|
||||
await expect(page.getByTestId('action-toast')).toContainText('로그아웃 서버가 응답하지 않습니다.');
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => ({
|
||||
|
||||
@@ -42,6 +42,7 @@ type FixtureState = {
|
||||
profileNavigationResolved?: boolean;
|
||||
scenarioFailuresRemaining?: number;
|
||||
resetDefaults?: Record<string, unknown>;
|
||||
updateMetaFails?: boolean;
|
||||
};
|
||||
|
||||
const profile = (runtimeRunning: boolean, resetDefaults?: Record<string, unknown>) => ({
|
||||
@@ -131,6 +132,10 @@ const installFixture = async (page: Page, state: FixtureState) => {
|
||||
await route.abort('failed');
|
||||
return;
|
||||
}
|
||||
if (names.includes('admin.profiles.updateMeta') && state.updateMetaFails) {
|
||||
await route.abort('failed');
|
||||
return;
|
||||
}
|
||||
const results = names.map((name) => {
|
||||
if (route.request().method() === 'POST') {
|
||||
state.requestBodies.push({ operation: name, body });
|
||||
@@ -408,7 +413,7 @@ test('separates branch and commit semantics and submits a reset from the dedicat
|
||||
await page.getByTestId('request-reset').hover();
|
||||
await page.getByTestId('request-reset').click();
|
||||
|
||||
await expect(page.getByText('초기화 작업을 등록했습니다.')).toBeVisible();
|
||||
await expect(page.getByText('초기화 작업을 등록했습니다.').first()).toBeVisible();
|
||||
await expect(page.getByTestId('operations-table')).toContainText('RESET');
|
||||
const resetRequest = state.requestBodies.find((entry) => entry.operation === 'admin.operations.requestReset');
|
||||
expect(JSON.stringify(resetRequest?.body)).toContain('"sourceMode":"COMMIT"');
|
||||
@@ -457,7 +462,7 @@ test('separates DB-preserving profile deployment from DB reset', async ({ page }
|
||||
);
|
||||
await page.getByTestId('request-deploy').click();
|
||||
|
||||
await expect(page.getByText('DB 보존 배포 작업을 등록했습니다.')).toBeVisible();
|
||||
await expect(page.getByText('DB 보존 배포 작업을 등록했습니다.').first()).toBeVisible();
|
||||
await expect(page.getByTestId('operations-table')).toContainText('DEPLOY');
|
||||
expect(state.requestBodies.some((entry) => entry.operation === 'admin.operations.requestDeploy')).toBe(true);
|
||||
expect(state.requestBodies.some((entry) => entry.operation === 'admin.operations.requestReset')).toBe(false);
|
||||
@@ -505,7 +510,7 @@ test('loads server metadata defaults into the reset form and submits them', asyn
|
||||
expect(request).toContain('"options":["develop","train"]');
|
||||
});
|
||||
|
||||
test('edits server reset defaults through profile metadata settings', async ({ page }) => {
|
||||
test('edits server reset defaults through profile metadata settings', async ({ page }, testInfo) => {
|
||||
const state: FixtureState = { operations: [], gatewayOperations: [], runtimeRunning: true, requestBodies: [] };
|
||||
await installFixture(page, state);
|
||||
|
||||
@@ -513,10 +518,47 @@ test('edits server reset defaults through profile metadata settings', async ({ p
|
||||
await page.getByText('서버 리셋 기본 옵션').click();
|
||||
await page.getByTestId('meta-reset-turn-term').selectOption('10');
|
||||
await page.getByTestId('meta-reset-npc-mode').selectOption('2');
|
||||
|
||||
await page.getByRole('button', { name: '메타 저장' }).click();
|
||||
const validationToast = page.getByTestId('action-toast').filter({ hasText: '변경 사유를 입력하세요.' });
|
||||
await expect(validationToast).toHaveAttribute('data-toast-kind', 'error');
|
||||
await expect(validationToast).toHaveAttribute('role', 'alert');
|
||||
|
||||
await page.getByPlaceholder('변경 사유 (필수)').fill('set reset defaults');
|
||||
await page.getByRole('button', { name: '메타 저장' }).click();
|
||||
|
||||
await expect(page.getByText('메타 저장 완료')).toBeVisible();
|
||||
await expect(page.getByText('메타 저장 완료').first()).toBeVisible();
|
||||
const successToast = page.getByTestId('action-toast').filter({ hasText: '메타 저장 완료' });
|
||||
await expect(successToast).toHaveAttribute('data-toast-kind', 'success');
|
||||
await expect(successToast).toHaveAttribute('role', 'status');
|
||||
const toastGeometry = await successToast.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const viewport = element.parentElement?.parentElement;
|
||||
return {
|
||||
right: Math.round(window.innerWidth - rect.right),
|
||||
width: Math.round(rect.width),
|
||||
viewportPosition: viewport ? getComputedStyle(viewport).position : '',
|
||||
};
|
||||
});
|
||||
expect(toastGeometry.right).toBeGreaterThanOrEqual(0);
|
||||
expect(toastGeometry.width).toBeGreaterThan(250);
|
||||
expect(toastGeometry.viewportPosition).toBe('fixed');
|
||||
await page.screenshot({ path: testInfo.outputPath('meta-save-toast-desktop.png'), fullPage: true });
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const mobileToastGeometry = await successToast.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
left: Math.round(rect.left),
|
||||
right: Math.round(window.innerWidth - rect.right),
|
||||
bottom: Math.round(window.innerHeight - rect.bottom),
|
||||
};
|
||||
});
|
||||
expect(mobileToastGeometry.left).toBeGreaterThanOrEqual(0);
|
||||
expect(mobileToastGeometry.right).toBeGreaterThanOrEqual(0);
|
||||
expect(mobileToastGeometry.bottom).toBeGreaterThanOrEqual(0);
|
||||
expect(mobileToastGeometry.bottom).toBeLessThanOrEqual(20);
|
||||
await page.screenshot({ path: testInfo.outputPath('meta-save-toast-mobile.png'), fullPage: true });
|
||||
const request = JSON.stringify(
|
||||
state.requestBodies.find((entry) => entry.operation === 'admin.profiles.updateMeta')?.body
|
||||
);
|
||||
@@ -525,13 +567,35 @@ test('edits server reset defaults through profile metadata settings', async ({ p
|
||||
expect(request).toContain('"npcMode":2');
|
||||
});
|
||||
|
||||
test('shows a dismissible error toast when profile metadata persistence fails', async ({ page }, testInfo) => {
|
||||
const state: FixtureState = {
|
||||
operations: [],
|
||||
gatewayOperations: [],
|
||||
runtimeRunning: true,
|
||||
requestBodies: [],
|
||||
updateMetaFails: true,
|
||||
};
|
||||
await installFixture(page, state);
|
||||
|
||||
await page.goto('admin/servers/che%3A2');
|
||||
await page.getByPlaceholder('변경 사유 (필수)').fill('exercise persistence error');
|
||||
await page.getByRole('button', { name: '메타 저장' }).click();
|
||||
|
||||
const errorToast = page.getByTestId('action-toast').filter({ hasText: '메타 저장 실패' });
|
||||
await expect(errorToast).toBeVisible();
|
||||
await expect(errorToast).toHaveAttribute('data-toast-kind', 'error');
|
||||
await page.screenshot({ path: testInfo.outputPath('meta-save-toast-error.png'), fullPage: true });
|
||||
await errorToast.getByRole('button', { name: '알림 닫기' }).click();
|
||||
await expect(errorToast).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('renders the fixed-profile version form without waiting for the server list', async ({ page }) => {
|
||||
const state: FixtureState = {
|
||||
operations: [],
|
||||
gatewayOperations: [],
|
||||
runtimeRunning: true,
|
||||
requestBodies: [],
|
||||
profileNavigationDelayMs: 1500,
|
||||
profileNavigationDelayMs: 3000,
|
||||
profileNavigationResolved: false,
|
||||
};
|
||||
await installFixture(page, state);
|
||||
@@ -595,7 +659,7 @@ test('scenario-only operator resets the current version without Git or Gateway c
|
||||
await expect(page.getByTestId('source-commit')).toHaveCount(0);
|
||||
await expect(page.getByRole('link', { name: 'Gateway 릴리스' })).toHaveCount(0);
|
||||
await page.getByTestId('request-reset').click();
|
||||
await expect(page.getByText('초기화 작업을 등록했습니다.')).toBeVisible();
|
||||
await expect(page.getByText('초기화 작업을 등록했습니다.').first()).toBeVisible();
|
||||
await expect
|
||||
.poll(() => state.requestBodies.some((entry) => entry.operation === 'admin.operations.requestReset'))
|
||||
.toBe(true);
|
||||
@@ -618,7 +682,7 @@ test('controls gateway deployment and rollback through the external controller q
|
||||
await page.getByTestId('gateway-source-ref').fill('release/2026-08');
|
||||
await page.getByTestId('request-gateway-deploy').click();
|
||||
|
||||
await expect(page.getByText(/Gateway 배포 작업을 등록했습니다/)).toBeVisible();
|
||||
await expect(page.getByText(/Gateway 배포 작업을 등록했습니다/).first()).toBeVisible();
|
||||
await expect(page.getByTestId('gateway-release-table')).toContainText('DEPLOY');
|
||||
await expect(page.getByTestId('gateway-release-log-panel')).toBeVisible();
|
||||
await expect(page.getByTestId('gateway-release-log')).toContainText('Gateway 구성 요소를 빌드합니다.');
|
||||
@@ -640,7 +704,7 @@ test('controls gateway deployment and rollback through the external controller q
|
||||
state.gatewayOperations = [];
|
||||
await page.getByTestId('refresh-operations').click();
|
||||
await page.getByTestId('request-gateway-rollback').click();
|
||||
await expect(page.getByText('Gateway rollback 작업을 등록했습니다.')).toBeVisible();
|
||||
await expect(page.getByText('Gateway rollback 작업을 등록했습니다.').first()).toBeVisible();
|
||||
expect(state.requestBodies.some((entry) => entry.operation === 'admin.releases.requestGatewayRollback')).toBe(true);
|
||||
});
|
||||
|
||||
@@ -709,7 +773,7 @@ test('renders a failed reset, retries it as a new operation, and reaches success
|
||||
expect(await failure.evaluate((element) => getComputedStyle(element).color)).toBe('oklch(0.704 0.191 22.216)');
|
||||
|
||||
await page.getByRole('button', { name: '재시도' }).click();
|
||||
await expect(page.getByText('재시도 작업을 등록했습니다.')).toBeVisible();
|
||||
await expect(page.getByText('재시도 작업을 등록했습니다.').first()).toBeVisible();
|
||||
await expect(page.getByText('FAILED', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('QUEUED', { exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId('operations-table').locator('tbody tr')).toHaveCount(2);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router';
|
||||
import ToastViewport from './components/ToastViewport.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView />
|
||||
<ToastViewport />
|
||||
</template>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
<script setup lang="ts">
|
||||
import { useToast, type ToastKind } from '../composables/useToast';
|
||||
|
||||
const { toasts, dismiss } = useToast();
|
||||
|
||||
const titleFor = (kind: ToastKind): string => {
|
||||
if (kind === 'success') return '완료';
|
||||
if (kind === 'error') return '처리 실패';
|
||||
return '안내';
|
||||
};
|
||||
|
||||
const iconFor = (kind: ToastKind): string => {
|
||||
if (kind === 'success') return '✓';
|
||||
if (kind === 'error') return '!';
|
||||
return 'i';
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="toast-viewport" aria-label="작업 알림">
|
||||
<TransitionGroup name="toast" tag="div" class="toast-stack">
|
||||
<article
|
||||
v-for="toast in toasts"
|
||||
:key="toast.id"
|
||||
class="toast-card"
|
||||
:class="`toast-card--${toast.kind}`"
|
||||
:role="toast.kind === 'error' ? 'alert' : 'status'"
|
||||
:aria-live="toast.kind === 'error' ? 'assertive' : 'polite'"
|
||||
data-testid="action-toast"
|
||||
:data-toast-kind="toast.kind"
|
||||
>
|
||||
<span class="toast-icon" aria-hidden="true">{{ iconFor(toast.kind) }}</span>
|
||||
<span class="toast-copy">
|
||||
<strong>{{ titleFor(toast.kind) }}</strong>
|
||||
<span>{{ toast.message }}</span>
|
||||
</span>
|
||||
<button type="button" class="toast-close" aria-label="알림 닫기" @click="dismiss(toast.id)">
|
||||
×
|
||||
</button>
|
||||
</article>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toast-viewport {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
top: max(1rem, env(safe-area-inset-top));
|
||||
right: max(1rem, env(safe-area-inset-right));
|
||||
width: min(25rem, calc(100vw - 2rem));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast-stack {
|
||||
display: grid;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
.toast-card {
|
||||
display: grid;
|
||||
grid-template-columns: 1.75rem minmax(0, 1fr) 2rem;
|
||||
gap: 0.75rem;
|
||||
align-items: start;
|
||||
padding: 0.875rem;
|
||||
color: #f4f4f5;
|
||||
background: rgb(24 24 27 / 96%);
|
||||
border: 1px solid #52525b;
|
||||
border-left-width: 4px;
|
||||
border-radius: 0.625rem;
|
||||
box-shadow: 0 14px 38px rgb(0 0 0 / 45%);
|
||||
pointer-events: auto;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.toast-card--success {
|
||||
border-left-color: #34d399;
|
||||
}
|
||||
|
||||
.toast-card--error {
|
||||
border-left-color: #fb7185;
|
||||
}
|
||||
|
||||
.toast-card--info {
|
||||
border-left-color: #60a5fa;
|
||||
}
|
||||
|
||||
.toast-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
font-weight: 800;
|
||||
color: #09090b;
|
||||
background: #a1a1aa;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.toast-card--success .toast-icon {
|
||||
background: #34d399;
|
||||
}
|
||||
|
||||
.toast-card--error .toast-icon {
|
||||
background: #fb7185;
|
||||
}
|
||||
|
||||
.toast-card--info .toast-icon {
|
||||
background: #60a5fa;
|
||||
}
|
||||
|
||||
.toast-copy {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
min-width: 0;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.4;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.toast-copy strong {
|
||||
color: #fff;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.toast-close {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
margin: -0.35rem -0.35rem 0 0;
|
||||
color: #d4d4d8;
|
||||
font-size: 1.35rem;
|
||||
line-height: 1;
|
||||
border-radius: 0.35rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toast-close:hover,
|
||||
.toast-close:focus-visible {
|
||||
color: #fff;
|
||||
background: #3f3f46;
|
||||
outline: 2px solid #a1a1aa;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.toast-enter-active,
|
||||
.toast-leave-active,
|
||||
.toast-move {
|
||||
transition:
|
||||
transform 180ms ease,
|
||||
opacity 180ms ease;
|
||||
}
|
||||
|
||||
.toast-enter-from,
|
||||
.toast-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(1rem);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.toast-viewport {
|
||||
top: auto;
|
||||
right: max(0.75rem, env(safe-area-inset-right));
|
||||
bottom: max(0.75rem, env(safe-area-inset-bottom));
|
||||
left: max(0.75rem, env(safe-area-inset-left));
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.toast-enter-from,
|
||||
.toast-leave-to {
|
||||
transform: translateY(0.75rem);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.toast-enter-active,
|
||||
.toast-leave-active,
|
||||
.toast-move {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,59 @@
|
||||
import { readonly, ref } from 'vue';
|
||||
|
||||
export type ToastKind = 'success' | 'error' | 'info';
|
||||
|
||||
export type Toast = {
|
||||
id: number;
|
||||
kind: ToastKind;
|
||||
message: string;
|
||||
};
|
||||
|
||||
const visibleToasts = ref<Toast[]>([]);
|
||||
const dismissTimers = new Map<number, ReturnType<typeof setTimeout>>();
|
||||
let nextToastId = 1;
|
||||
|
||||
const dismiss = (id: number): void => {
|
||||
const timer = dismissTimers.get(id);
|
||||
if (timer) clearTimeout(timer);
|
||||
dismissTimers.delete(id);
|
||||
visibleToasts.value = visibleToasts.value.filter((toast) => toast.id !== id);
|
||||
};
|
||||
|
||||
const show = (message: string, kind: ToastKind = 'info', durationMs = 5_000): number => {
|
||||
const normalizedMessage = message.trim();
|
||||
if (!normalizedMessage) return -1;
|
||||
|
||||
const duplicate = visibleToasts.value.find(
|
||||
(toast) => toast.message === normalizedMessage && toast.kind === kind
|
||||
);
|
||||
if (duplicate) {
|
||||
dismiss(duplicate.id);
|
||||
}
|
||||
|
||||
const id = nextToastId++;
|
||||
visibleToasts.value = [...visibleToasts.value.slice(-3), { id, kind, message: normalizedMessage }];
|
||||
if (durationMs > 0) {
|
||||
dismissTimers.set(id, setTimeout(() => dismiss(id), durationMs));
|
||||
}
|
||||
return id;
|
||||
};
|
||||
|
||||
const feedback = (message: string): number => {
|
||||
if (/실패|오류|못했|필요|입력|선택|유효|일치하지|비활성화|없습니다|해야 합니다/.test(message)) {
|
||||
return show(message, 'error');
|
||||
}
|
||||
if (/완료|성공|저장|등록|적용|변경|해제|부여|생성|철회|예약/.test(message)) {
|
||||
return show(message, 'success');
|
||||
}
|
||||
return show(message, 'info');
|
||||
};
|
||||
|
||||
export const useToast = () => ({
|
||||
toasts: readonly(visibleToasts),
|
||||
show,
|
||||
success: (message: string, durationMs?: number) => show(message, 'success', durationMs),
|
||||
error: (message: string, durationMs?: number) => show(message, 'error', durationMs),
|
||||
info: (message: string, durationMs?: number) => show(message, 'info', durationMs),
|
||||
feedback,
|
||||
dismiss,
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { useToast } from '../composables/useToast';
|
||||
import DefaultLayout from '../layouts/DefaultLayout.vue';
|
||||
import { createGameTrpc } from '../utils/gameTrpc';
|
||||
import { trpc } from '../utils/trpc';
|
||||
@@ -22,6 +23,10 @@ const loading = ref(true);
|
||||
const busy = ref(false);
|
||||
const errorMessage = ref('');
|
||||
const successMessage = ref('');
|
||||
const { success: showSuccessToast, error: showErrorToast } = useToast();
|
||||
|
||||
watch(successMessage, (value) => value && showSuccessToast(value), { flush: 'sync' });
|
||||
watch(errorMessage, (value) => value && showErrorToast(value), { flush: 'sync' });
|
||||
const currentPassword = ref('');
|
||||
const newPassword = ref('');
|
||||
const newPasswordConfirm = ref('');
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import ServerProfileTabs from '../components/ServerProfileTabs.vue';
|
||||
import AdminConsoleLayout from '../layouts/AdminConsoleLayout.vue';
|
||||
import { useToast } from '../composables/useToast';
|
||||
import {
|
||||
normalizeProfileResetDefaults,
|
||||
type ProfileResetDefaults,
|
||||
@@ -513,6 +514,45 @@ const userHistory = ref<AdminAuditEvent[]>([]);
|
||||
const globalAuditHistory = ref<AdminAuditEvent[]>([]);
|
||||
const globalAuditStatus = ref('');
|
||||
|
||||
const { feedback: showFeedbackToast } = useToast();
|
||||
const actionFeedback = [
|
||||
noticeStatus,
|
||||
userError,
|
||||
kakaoGraceStatus,
|
||||
specialAccessStatus,
|
||||
passwordStatus,
|
||||
rolesStatus,
|
||||
banStatus,
|
||||
profileIconStatus,
|
||||
restrictionStatus,
|
||||
forceDeleteStatus,
|
||||
];
|
||||
|
||||
watch(
|
||||
actionFeedback,
|
||||
(current, previous) => {
|
||||
current.forEach((message, index) => {
|
||||
if (message && message !== previous[index]) showFeedbackToast(message);
|
||||
});
|
||||
},
|
||||
{ flush: 'sync' }
|
||||
);
|
||||
|
||||
const setLocalAccountFeedback = (message: string): void => {
|
||||
localAccountStatus.value = message;
|
||||
showFeedbackToast(message);
|
||||
};
|
||||
|
||||
watch(
|
||||
profileActionStatus,
|
||||
(current, previous) => {
|
||||
Object.entries(current).forEach(([profileName, message]) => {
|
||||
if (message && message !== previous[profileName]) showFeedbackToast(message);
|
||||
});
|
||||
},
|
||||
{ flush: 'sync' }
|
||||
);
|
||||
|
||||
const hasUser = computed(() => Boolean(userResult.value));
|
||||
|
||||
const loadLocalAccountStatus = async () => {
|
||||
@@ -723,9 +763,10 @@ const updateProfileMeta = async (profileName: string) => {
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : '';
|
||||
profileActionStatus.value = {
|
||||
...profileActionStatus.value,
|
||||
[profileName]: '메타 저장 실패',
|
||||
[profileName]: detail ? `메타 저장 실패: ${detail}` : '메타 저장 실패',
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -1183,14 +1224,14 @@ const createLocalAccount = async () => {
|
||||
localAccountStatus.value = '';
|
||||
localAccountResult.value = '';
|
||||
if (!localAccountEnabled.value) {
|
||||
localAccountStatus.value = 'ENV 설정이 비활성화 상태입니다.';
|
||||
setLocalAccountFeedback('ENV 설정이 비활성화 상태입니다.');
|
||||
return;
|
||||
}
|
||||
const username = localAccountForm.value.username.trim();
|
||||
const password = localAccountForm.value.password.trim();
|
||||
const displayName = localAccountForm.value.displayName.trim();
|
||||
if (!username || !password) {
|
||||
localAccountStatus.value = '아이디와 비밀번호를 입력하세요.';
|
||||
setLocalAccountFeedback('아이디와 비밀번호를 입력하세요.');
|
||||
return;
|
||||
}
|
||||
localAccountLoading.value = true;
|
||||
@@ -1201,7 +1242,7 @@ const createLocalAccount = async () => {
|
||||
displayName: displayName || undefined,
|
||||
});
|
||||
localAccountResult.value = `생성됨: ${result.user.username} (${result.user.id})`;
|
||||
localAccountStatus.value = '로컬 계정 생성 완료';
|
||||
setLocalAccountFeedback('로컬 계정 생성 완료');
|
||||
localAccountForm.value = {
|
||||
username: result.user.username,
|
||||
password: '',
|
||||
@@ -1211,7 +1252,7 @@ const createLocalAccount = async () => {
|
||||
userLookupValue.value = result.user.username;
|
||||
await Promise.all([lookupUser(), loadUserDirectory()]);
|
||||
} catch (error) {
|
||||
localAccountStatus.value = '로컬 계정 생성 실패';
|
||||
setLocalAccountFeedback('로컬 계정 생성 실패');
|
||||
} finally {
|
||||
localAccountLoading.value = false;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { inferRouterOutputs } from '@trpc/server';
|
||||
import type { AppRouter } from '@sammo-ts/gateway-api';
|
||||
import DefaultLayout from '../layouts/DefaultLayout.vue';
|
||||
import MapPreview from '../components/MapPreview.vue';
|
||||
import { useToast } from '../composables/useToast';
|
||||
import { trpc } from '../utils/trpc';
|
||||
import { createGameTrpc } from '../utils/gameTrpc';
|
||||
import type { GameRouter } from '../utils/gameTrpc';
|
||||
@@ -34,6 +35,9 @@ const selectedMapProfileName = ref<string | null>(null);
|
||||
const entryLoading = ref<Record<string, boolean>>({});
|
||||
const logoutLoading = ref(false);
|
||||
const logoutError = ref('');
|
||||
const { error: showErrorToast } = useToast();
|
||||
|
||||
watch(logoutError, (value) => value && showErrorToast(value), { flush: 'sync' });
|
||||
const canAccessAdmin = computed(
|
||||
() =>
|
||||
me.value?.roles.some(
|
||||
@@ -207,7 +211,7 @@ const handleKakaoVerification = async (): Promise<void> => {
|
||||
});
|
||||
window.location.assign(result.authUrl);
|
||||
} catch (error) {
|
||||
alert(error instanceof Error ? error.message : '카카오 인증을 시작하지 못했습니다.');
|
||||
showErrorToast(error instanceof Error ? error.message : '카카오 인증을 시작하지 못했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -245,13 +249,13 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
|
||||
});
|
||||
const url = resolveGameUrl(targetPath, issued.profile, issued.gameToken);
|
||||
if (!url) {
|
||||
alert('게임 프론트엔드 주소가 설정되지 않았습니다.');
|
||||
showErrorToast('게임 프론트엔드 주소가 설정되지 않았습니다.');
|
||||
return;
|
||||
}
|
||||
window.location.href = url;
|
||||
} catch (e) {
|
||||
console.error('Failed to issue game session', e);
|
||||
alert(e instanceof Error ? e.message : '게임 서버 접속에 실패했습니다.');
|
||||
showErrorToast(e instanceof Error ? e.message : '게임 서버 접속에 실패했습니다.');
|
||||
} finally {
|
||||
entryLoading.value[profile.profileName] = false;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||||
|
||||
import ServerProfileTabs from '../components/ServerProfileTabs.vue';
|
||||
import { useToast } from '../composables/useToast';
|
||||
import AdminConsoleLayout from '../layouts/AdminConsoleLayout.vue';
|
||||
import {
|
||||
normalizeProfileResetDefaults,
|
||||
@@ -95,6 +96,10 @@ const catalogAttempted = ref(false);
|
||||
const submitting = ref(false);
|
||||
const message = ref('');
|
||||
const errorMessage = ref('');
|
||||
const { success: showSuccessToast, error: showErrorToast } = useToast();
|
||||
|
||||
watch(message, (value) => value && showSuccessToast(value), { flush: 'sync' });
|
||||
watch(errorMessage, (value) => value && showErrorToast(value), { flush: 'sync' });
|
||||
const resetDefaultsSource = ref<'SYSTEM' | 'PROFILE'>('SYSTEM');
|
||||
let pollTimer: ReturnType<typeof setInterval> | undefined;
|
||||
let stateRequestInFlight = false;
|
||||
|
||||
@@ -29,6 +29,12 @@ Gateway process 환경에 전달하지 않습니다. 이 값이 frontend 정의
|
||||
frontend build 계약입니다.
|
||||
- `RELEASE_CONTROLLER_POLL_MS`, `RELEASE_CONTROLLER_READINESS_TIMEOUT_MS`: queue
|
||||
poll과 준비 제한 시간입니다.
|
||||
- `TURBO_CACHE_DIR`: 선택 사항인 공유 local cache 경로입니다. 없으면 원래
|
||||
`RELEASE_CONTROLLER_WORKSPACE_ROOT/.turbo/release-cache`를 사용합니다. 상대 경로는
|
||||
원래 workspace 기준으로 해석합니다.
|
||||
- `RELEASE_TURBO_CONCURRENCY`: Turbo worker 수입니다. 기본값 1은 실행 중인 game/Gateway
|
||||
process와 4 GiB runtime을 공유하는 production cold build의 OOM을 피합니다. 더 큰
|
||||
격리 build host에서만 측정 후 2 이상으로 올립니다.
|
||||
|
||||
비밀값은 Git에서 제외된 환경 파일 또는 process 환경으로 전달해 주세요.
|
||||
`VITE_*`에는 공개 URL만 넣어 주세요.
|
||||
@@ -46,13 +52,7 @@ DEPLOY의 rollback이 frontend build가 없는 controller worktree를 이전 Gat
|
||||
|
||||
```sh
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm --filter @sammo-ts/infra prisma:generate
|
||||
pnpm --filter @sammo-ts/common build
|
||||
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/release-controller build
|
||||
pnpm exec turbo run build --filter=@sammo-ts/release-controller --concurrency=1 --ui=stream
|
||||
pnpm --filter @sammo-ts/infra prisma:migrate:deploy:gateway
|
||||
pnpm --filter @sammo-ts/release-controller start
|
||||
```
|
||||
|
||||
@@ -4,6 +4,7 @@ import { stripVTControlCharacters } from 'node:util';
|
||||
|
||||
import {
|
||||
assertReleaseComponents,
|
||||
buildTurboReleaseCommand,
|
||||
type BuildCommand,
|
||||
type BuildProgressEvent,
|
||||
type BuildRunner,
|
||||
@@ -38,13 +39,12 @@ export const buildGatewayReleaseCommands = (
|
||||
};
|
||||
return [
|
||||
...(needsInstall ? [{ command: 'pnpm', args: ['install', '--frozen-lockfile'], cwd: workspaceRoot, env }] : []),
|
||||
{ command: 'pnpm', args: ['--filter', '@sammo-ts/common', 'build'], cwd: workspaceRoot, env },
|
||||
{ command: 'pnpm', args: ['--filter', '@sammo-ts/infra', 'prisma:generate'], cwd: workspaceRoot, env },
|
||||
{ command: 'pnpm', args: ['--filter', '@sammo-ts/infra', 'build'], cwd: workspaceRoot, env },
|
||||
{ command: 'pnpm', args: ['--filter', '@sammo-ts/logic', 'build'], cwd: workspaceRoot, env },
|
||||
{ command: 'pnpm', args: ['--filter', '@sammo-ts/game-engine', 'build'], cwd: workspaceRoot, env },
|
||||
{ command: 'pnpm', args: ['--filter', '@sammo-ts/gateway-api', 'build'], cwd: workspaceRoot, env },
|
||||
{ command: 'pnpm', args: ['--filter', '@sammo-ts/gateway-frontend', 'build'], cwd: workspaceRoot, env },
|
||||
buildTurboReleaseCommand(
|
||||
workspaceRoot,
|
||||
config.workspaceRoot,
|
||||
['@sammo-ts/gateway-api', '@sammo-ts/gateway-frontend'],
|
||||
env
|
||||
),
|
||||
];
|
||||
};
|
||||
|
||||
@@ -244,7 +244,12 @@ export class GatewayReleaseController {
|
||||
await this.startDefinitions(buildGatewayProcessDefinitions(workspace.root, this.config), operation.id);
|
||||
await this.waitForReadiness(operation.id);
|
||||
} catch (error) {
|
||||
await this.appendLog(operation.id, 'rollback', '새 Gateway 시작에 실패하여 이전 process를 복구합니다.', 'ERROR');
|
||||
await this.appendLog(
|
||||
operation.id,
|
||||
'rollback',
|
||||
'새 Gateway 시작에 실패하여 이전 process를 복구합니다.',
|
||||
'ERROR'
|
||||
);
|
||||
await this.stopManagedProcesses(operation.id);
|
||||
if (previousDefinitions.length) {
|
||||
await this.startDefinitions(previousDefinitions, operation.id);
|
||||
|
||||
@@ -2,6 +2,7 @@ import path from 'node:path';
|
||||
|
||||
import {
|
||||
assertReleaseComponents,
|
||||
buildTurboReleaseCommand,
|
||||
type BuildCommand,
|
||||
type BuildRunner,
|
||||
type GitWorkspaceManager,
|
||||
@@ -24,13 +25,7 @@ export const buildReleaseControllerCommands = (
|
||||
const env = sanitizeManagedProcessEnv(config.baseEnv);
|
||||
return [
|
||||
...(needsInstall ? [{ command: 'pnpm', args: ['install', '--frozen-lockfile'], cwd: workspaceRoot, env }] : []),
|
||||
{ command: 'pnpm', args: ['--filter', '@sammo-ts/common', 'build'], cwd: workspaceRoot, env },
|
||||
{ command: 'pnpm', args: ['--filter', '@sammo-ts/infra', 'prisma:generate'], cwd: workspaceRoot, env },
|
||||
{ command: 'pnpm', args: ['--filter', '@sammo-ts/infra', 'build'], cwd: workspaceRoot, env },
|
||||
{ command: 'pnpm', args: ['--filter', '@sammo-ts/logic', 'build'], cwd: workspaceRoot, env },
|
||||
{ command: 'pnpm', args: ['--filter', '@sammo-ts/game-engine', 'build'], cwd: workspaceRoot, env },
|
||||
{ command: 'pnpm', args: ['--filter', '@sammo-ts/gateway-api', 'build'], cwd: workspaceRoot, env },
|
||||
{ command: 'pnpm', args: ['--filter', '@sammo-ts/release-controller', 'build'], cwd: workspaceRoot, env },
|
||||
buildTurboReleaseCommand(workspaceRoot, config.workspaceRoot, ['@sammo-ts/release-controller'], env),
|
||||
];
|
||||
};
|
||||
|
||||
|
||||
@@ -209,6 +209,8 @@ describe('GatewayReleaseController', () => {
|
||||
|
||||
expect(commandGroups).toHaveLength(2);
|
||||
expect(commandGroups[0]?.[0]).toBe('install --frozen-lockfile');
|
||||
expect(commandGroups[0]?.[1]).toContain('turbo run build');
|
||||
expect(commandGroups[0]?.[1]).toContain('--cache-dir=/srv/sammo/controller/.turbo/release-cache');
|
||||
expect(commandGroups[1]).toEqual(['--filter @sammo-ts/infra prisma:migrate:deploy:gateway']);
|
||||
expect([...running.keys()].sort()).toEqual([...gatewayNames].sort());
|
||||
expect(harness.published).toEqual([
|
||||
@@ -216,7 +218,16 @@ describe('GatewayReleaseController', () => {
|
||||
]);
|
||||
expect(harness.completions).toEqual(['SUCCEEDED']);
|
||||
expect(harness.logs.map((entry) => entry.phase)).toEqual(
|
||||
expect.arrayContaining(['claim', 'resolve', 'workspace', 'build', 'migration', 'switch', 'readiness', 'publish'])
|
||||
expect.arrayContaining([
|
||||
'claim',
|
||||
'resolve',
|
||||
'workspace',
|
||||
'build',
|
||||
'migration',
|
||||
'switch',
|
||||
'readiness',
|
||||
'publish',
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
@@ -275,8 +286,7 @@ describe('GatewayReleaseController', () => {
|
||||
await onProgress?.({
|
||||
type: 'OUTPUT',
|
||||
stream: 'stdout',
|
||||
message:
|
||||
'bootstrap-secret-value postgresql://operator:visible-password@db.invalid/sammo',
|
||||
message: 'bootstrap-secret-value postgresql://operator:visible-password@db.invalid/sammo',
|
||||
});
|
||||
return { ok: true, exitCode: 0, output: '' };
|
||||
},
|
||||
@@ -405,7 +415,7 @@ describe('upgradeReleaseController', () => {
|
||||
).resolves.toEqual({ commitSha: SHA, workspace });
|
||||
|
||||
expect(commandGroups).toHaveLength(2);
|
||||
expect(commandGroups[0]?.at(-1)).toBe('--filter @sammo-ts/release-controller build');
|
||||
expect(commandGroups[0]?.at(-1)).toContain('turbo run build --filter=@sammo-ts/release-controller');
|
||||
expect(starts.at(-1)).toMatchObject({
|
||||
name: 'sammo:release-controller',
|
||||
cwd: path.join(workspace, 'app', 'release-controller'),
|
||||
|
||||
@@ -44,6 +44,17 @@ profile 범위 권한과 별개인 전역 `admin.releases.manage` 권한이 필
|
||||
- Root와 server package의 `tsdown`은 0.22.14 계열로 통일합니다. Docker runtime의
|
||||
Node heap/Rayon 상한을 상속한 동일 toolchain으로 초기 Gateway와 profile
|
||||
worktree를 빌드하여 구형 Rolldown의 과도한 native thread 생성을 피합니다.
|
||||
- Profile, Gateway와 controller self-upgrade의 server package build는 Turbo DAG를
|
||||
사용합니다. 실행 중인 game/Gateway process와 4 GiB runtime을 공유하는 기본
|
||||
동시성은 1이며, 더 큰 격리 build host에서는 `RELEASE_TURBO_CONCURRENCY`를 측정 후
|
||||
2 이상으로 올릴 수 있습니다. 기본 local cache는 원래 Core checkout의
|
||||
`.turbo/release-cache`이므로 commit별 worktree가 달라도 재사용됩니다. 별도
|
||||
persistent 경로가 필요하면 controller/orchestrator 환경에 `TURBO_CACHE_DIR`을
|
||||
설정합니다. Cache는 재생성 가능한 build artifact이며 DB/Redis backup이 아닙니다.
|
||||
- `NODE_ENV`와 Vite가 추론한 `VITE_*`는 build hash에 포함됩니다. 따라서 base path나
|
||||
API URL이 다른 frontend artifact를 cache hit로 잘못 복원하지 않습니다.
|
||||
`NODE_OPTIONS`와 `RAYON_NUM_THREADS`는 출력에는 영향을 주지 않는 resource 제한으로
|
||||
build child에 전달됩니다.
|
||||
- migration 이후 이전 애플리케이션으로 돌아갈 때 schema 하위 호환성이
|
||||
유지됩니다.
|
||||
|
||||
@@ -113,7 +124,7 @@ Gateway는 자기 process를 직접 교체하지 않습니다. 관리자 화면
|
||||
|
||||
1. Source ref를 commit SHA로 고정하고 commit worktree를 준비합니다.
|
||||
2. Release manifest의 protocol, component와 migration head를 검증합니다.
|
||||
3. Gateway API와 frontend를 빌드하고 gateway migration을 적용합니다.
|
||||
3. Gateway API와 frontend를 공유 Turbo cache로 빌드하고 gateway migration을 적용합니다.
|
||||
4. `sammo:gateway-api`, `sammo:gateway-frontend`,
|
||||
`sammo:gateway-orchestrator`를 새 worktree definition으로 전환합니다.
|
||||
5. Gateway API `/healthz`, `/gateway/`와 세 PM2 process의 `online` 상태를
|
||||
@@ -137,6 +148,8 @@ Gateway 전체에는 활성 릴리스 작업을 동시에 하나만 둘 수 있
|
||||
commit 해석, worktree 준비, build 명령 출력, migration, process 전환,
|
||||
readiness와 rollback 진행을 커서 순서대로 이어 붙입니다. 완료된 작업의 로그도
|
||||
같은 이력에서 다시 열 수 있으며 화면은 최근 1,000줄을 유지합니다.
|
||||
Build 로그의 `cache hit`/`cache miss`와 마지막 `Cached: N cached, M total`은 실제
|
||||
이번 릴리스의 cache 사용 여부를 나타냅니다.
|
||||
|
||||
로그 원본은 Gateway DB의 `GatewayReleaseLog`에 작업별로 저장되고 작업 삭제 시
|
||||
함께 제거됩니다. Controller는 ANSI 제어 문자를 제거하고 secret·token·password
|
||||
|
||||
+5
-2
@@ -1,17 +1,20 @@
|
||||
{
|
||||
"$schema": "https://turbo.build/schema.json",
|
||||
"ui": "tui",
|
||||
"globalPassThroughEnv": ["NODE_OPTIONS", "RAYON_NUM_THREADS"],
|
||||
"tasks": {
|
||||
"build": {
|
||||
"dependsOn": ["^build", "typecheck"],
|
||||
"env": ["NODE_ENV"],
|
||||
"inputs": ["$TURBO_DEFAULT$", ".env*"],
|
||||
"outputs": ["dist/**"]
|
||||
},
|
||||
"typecheck": {
|
||||
"dependsOn": ["^typecheck", "prisma:generate"],
|
||||
"dependsOn": ["^build", "prisma:generate"],
|
||||
"cache": true
|
||||
},
|
||||
"prisma:generate": {
|
||||
"inputs": ["prisma/*.prisma"],
|
||||
"inputs": ["$TURBO_DEFAULT$"],
|
||||
"outputs": ["node_modules/.prisma/client/**", "prisma/generated/**"],
|
||||
"cache": true
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user