feat(release): 정적 프런트엔드와 격리 빌더를 도입한다

Gateway와 프로필 릴리스가 불변 프런트엔드 아티팩트를 원자적으로 게시하고 실패 시 이전 포인터를 복구하도록 한다.

릴리스 빌드는 전용 builder protocol로 분리하고 성공한 Gateway 커밋을 재기동용 Git ref에 고정한다.
This commit is contained in:
2026-08-22 09:32:47 +00:00
parent d74a8e48bb
commit d3e5d12cbf
18 changed files with 991 additions and 48 deletions
@@ -1,4 +1,8 @@
import { describe, expect, it } from 'vitest';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { GatewayOrchestrator, type GatewayOrchestratorOptions } from '../src/orchestrator/gatewayOrchestrator.js';
import type { ProcessDefinition, ProcessManager } from '../src/orchestrator/processManager.js';
@@ -10,6 +14,12 @@ import type {
} from '../src/orchestrator/profileRepository.js';
import { GitWorkspaceManager } from '../src/orchestrator/workspaceManager.js';
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(temporaryDirectories.splice(0).map((directory) => fs.rm(directory, { recursive: true })));
});
const profile: GatewayProfileRecord = {
profileName: 'che:2',
profile: 'che',
@@ -52,6 +62,9 @@ const createHarness = (
reservedToStart?: GatewayProfileRecord[];
now?: () => Date;
cancelGame?: GatewayOrchestratorOptions['cancelGame'];
frontendServeMode?: 'static';
frontendArtifactRoot?: string;
activeOperationProfileNames?: string[];
} = {}
) => {
const harnessProfile = options.profile ?? profile;
@@ -85,7 +98,8 @@ const createHarness = (
updateWorkspaceUsage: async () => {},
clearWorkspaceUsage: async () => {},
listOperations: async () => [],
listActiveOperationProfileNames: async () => [harnessProfile.profileName],
listActiveOperationProfileNames: async () =>
options.activeOperationProfileNames ?? [harnessProfile.profileName],
getOperation: async () => operation,
listOperationLogs: async () => [],
appendOperationLog: async (operationId, input) => {
@@ -168,6 +182,8 @@ const createHarness = (
redisKeyPrefix: 'sammo:test',
gameTokenSecret: 'test-secret',
gatewayInternalApiUrl: 'http://127.0.0.1:13000',
frontendServeMode: options.frontendServeMode,
frontendArtifactRoot: options.frontendArtifactRoot,
baseEnv: { DATABASE_URL: 'postgresql://test:test@127.0.0.1:15432/test' },
},
reconcileIntervalMs: 60_000,
@@ -375,6 +391,56 @@ describe('GatewayOrchestrator first-class operations', () => {
expect(harness.completions).toEqual(['SUCCEEDED']);
});
it('removes a legacy Vite process while publishing the first static artifact', async () => {
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-static-cutover-'));
temporaryDirectories.push(workspace);
await fs.mkdir(path.join(workspace, '.release-dist', 'che_2', 'game-frontend'), { recursive: true });
await fs.writeFile(
path.join(workspace, '.release-dist', 'che_2', 'game-frontend', 'index.html'),
'<!doctype html><title>static cutover</title>'
);
const artifactRoot = path.join(workspace, 'artifacts');
const staticProfile = { ...profile, status: 'RUNNING' as const, buildWorkspace: workspace };
const harness = createHarness(buildOperation('START'), false, false, true, false, undefined, undefined, {
profile: staticProfile,
frontendServeMode: 'static',
frontendArtifactRoot: artifactRoot,
activeOperationProfileNames: [],
});
await harness.orchestrator.reconcileNow();
expect(harness.deleted).toContain('sammo:che:2:game-frontend');
expect(harness.started.map((definition) => definition.name)).toEqual([
'sammo:che:2:game-api',
'sammo:che:2:turn-daemon',
'sammo:che:2:auction-worker',
'sammo:che:2:battle-sim-worker',
'sammo:che:2:tournament-worker',
]);
expect(await fs.readFile(path.join(artifactRoot, 'che', 'current', 'index.html'), 'utf8')).toContain(
'static cutover'
);
expect(harness.completions).toEqual([]);
});
it('keeps legacy processes untouched when the first static artifact cannot be staged', async () => {
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-static-cutover-missing-'));
temporaryDirectories.push(workspace);
const harness = createHarness(buildOperation('START'), false, false, true, false, undefined, undefined, {
profile: { ...profile, status: 'RUNNING', buildWorkspace: workspace },
frontendServeMode: 'static',
frontendArtifactRoot: path.join(workspace, 'artifacts'),
activeOperationProfileNames: [],
});
await harness.orchestrator.reconcileNow();
expect(harness.started).toEqual([]);
expect(harness.deleted).toEqual([]);
expect(harness.completions).toEqual([]);
});
it('stops every profile process and records success', async () => {
const harness = createHarness(buildOperation('STOP'));