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
@@ -0,0 +1,73 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { FrontendArtifactManager, resolveFrontendServeMode } from '../src/orchestrator/frontendArtifactManager.js';
const roots: string[] = [];
const sha = 'a'.repeat(40);
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })));
});
const fixture = async (): Promise<{ source: string; artifacts: string }> => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-frontend-artifact-'));
roots.push(root);
const source = path.join(root, 'dist');
const artifacts = path.join(root, 'artifacts');
await fs.mkdir(path.join(source, 'assets'), { recursive: true });
await fs.writeFile(path.join(source, 'index.html'), '<div>one</div>');
await fs.writeFile(path.join(source, 'assets', 'app-deadbeef.js'), 'console.log(1)');
return { source, artifacts };
};
describe('resolveFrontendServeMode', () => {
it('keeps preview as the compatibility default and accepts explicit static mode', () => {
expect(resolveFrontendServeMode(undefined)).toBe('preview');
expect(resolveFrontendServeMode('preview')).toBe('preview');
expect(resolveFrontendServeMode('STATIC')).toBe('static');
expect(() => resolveFrontendServeMode('server')).toThrow(/preview or static/u);
});
});
describe('FrontendArtifactManager', () => {
it('stages immutable releases and atomically switches current and previous pointers', async () => {
const { source, artifacts } = await fixture();
const manager = new FrontendArtifactManager(artifacts);
const first = await manager.stageAndActivate({ frontendKey: 'gateway', sourceRoot: source, commitSha: sha });
expect(await manager.readCurrentReleaseId('gateway')).toBe(first.releaseId);
expect(await fs.readFile(path.join(artifacts, 'gateway', 'current', 'index.html'), 'utf8')).toContain('one');
await fs.writeFile(path.join(source, 'index.html'), '<div>two</div>');
const second = await manager.stageAndActivate({
frontendKey: 'gateway',
sourceRoot: source,
commitSha: 'b'.repeat(40),
});
expect(second.previousReleaseId).toBe(first.releaseId);
expect(await fs.readFile(path.join(artifacts, 'gateway', 'current', 'index.html'), 'utf8')).toContain('two');
expect(await fs.readFile(path.join(artifacts, 'gateway', 'previous', 'index.html'), 'utf8')).toContain('one');
expect(await fs.readFile(path.join(first.releasePath, 'index.html'), 'utf8')).toContain('one');
});
it('removes only the live pointer when a frontend is stopped', async () => {
const { source, artifacts } = await fixture();
const manager = new FrontendArtifactManager(artifacts);
const staged = await manager.stageAndActivate({ frontendKey: 'che', sourceRoot: source, commitSha: sha });
expect(await manager.deactivate('che')).toBe(staged.releaseId);
expect(await manager.readCurrentReleaseId('che')).toBeNull();
expect(await fs.readFile(path.join(staged.releasePath, 'index.html'), 'utf8')).toContain('one');
});
it('rejects symlinks instead of copying files outside the build output', async () => {
const { source, artifacts } = await fixture();
await fs.symlink('/etc/passwd', path.join(source, 'assets', 'outside'));
const manager = new FrontendArtifactManager(artifacts);
await expect(manager.stage({ frontendKey: 'gateway', sourceRoot: source, commitSha: sha })).rejects.toThrow(
/symbolic link/u
);
});
});
@@ -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'));
@@ -37,6 +37,17 @@ const createReleaseWorkspace = async (): Promise<string> => {
components: ['game-api', 'game-engine', 'game-frontend'],
})
);
await fs.mkdir(path.join(workspace, '.release-dist', 'che_1010', 'game-frontend', 'assets'), {
recursive: true,
});
await fs.writeFile(
path.join(workspace, '.release-dist', 'che_1010', 'game-frontend', 'index.html'),
'<!doctype html><title>static profile</title>'
);
await fs.writeFile(
path.join(workspace, '.release-dist', 'che_1010', 'game-frontend', 'assets', 'app-deadbeef.js'),
'console.log("static")'
);
return workspace;
};
@@ -45,7 +56,7 @@ afterEach(async () => {
});
describe('profile DEPLOY operation', () => {
it('migrates and switches the selected release without executing the reset seed path', async () => {
it('migrates and atomically switches a static frontend without executing the reset seed path', async () => {
const workspace = await createReleaseWorkspace();
const profile: GatewayProfileRecord = {
profileName: 'che:1010',
@@ -131,6 +142,7 @@ describe('profile DEPLOY operation', () => {
'sammo:che:1010:battle-sim-worker',
'sammo:che:1010:tournament-worker',
];
const backendProcessNames = processNames.filter((name) => !name.endsWith(':game-frontend'));
const running = new Set(processNames);
const processManager: ProcessManager = {
list: async () => [...running].map((name) => ({ name, status: 'online' })),
@@ -171,6 +183,9 @@ describe('profile DEPLOY operation', () => {
redisKeyPrefix: 'sammo:test',
gameTokenSecret: 'test-secret',
gatewayInternalApiUrl: 'http://127.0.0.1:15001',
frontendServeMode: 'static',
frontendArtifactRoot: path.join(workspace, 'artifact-volume'),
frontendReadinessOrigin: 'http://caddy',
baseEnv: { DATABASE_URL: 'postgresql://user:pass@integration.invalid/sammo' },
},
reconcileIntervalMs: 60_000,
@@ -218,6 +233,9 @@ describe('profile DEPLOY operation', () => {
);
expect(logs.map((entry) => entry.message).join('\n')).not.toContain('pass@integration.invalid');
expect(logs.map((entry) => entry.message).join('\n')).toContain('[REDACTED]');
expect([...running].sort()).toEqual([...processNames].sort());
expect([...running].sort()).toEqual([...backendProcessNames].sort());
expect(
await fs.readFile(path.join(workspace, 'artifact-volume', 'che', 'current', 'index.html'), 'utf8')
).toContain('static profile');
});
});
@@ -0,0 +1,68 @@
import { describe, expect, it, vi } from 'vitest';
import {
RemoteBuildRunner,
sanitizeReleaseBuildEnv,
type BuildProgressEvent,
} from '../src/orchestrator/buildRunner.js';
describe('sanitizeReleaseBuildEnv', () => {
it('keeps only public build controls and frontend values', () => {
expect(
sanitizeReleaseBuildEnv({
CI: 'true',
NODE_OPTIONS: '--max-old-space-size=1024',
VITE_APP_BASE_PATH: '/gateway',
GAME_TOKEN_SECRET: 'secret',
DATABASE_URL: 'postgresql://private',
REDIS_URL: 'redis://private',
PATH: '/private/path',
})
).toEqual({
CI: 'true',
PATH: '/private/path',
NODE_OPTIONS: '--max-old-space-size=1024',
VITE_APP_BASE_PATH: '/gateway',
});
expect(
sanitizeReleaseBuildEnv({
NODE_OPTIONS: '--max-old-space-size=1536',
RELEASE_BUILD_NODE_OPTIONS: '--max-old-space-size=3072',
})
).toEqual({ NODE_OPTIONS: '--max-old-space-size=3072' });
});
});
describe('RemoteBuildRunner', () => {
it('streams progress and returns the builder result without sending secrets', async () => {
const messages = [
{ event: { type: 'OUTPUT', stream: 'stdout', message: 'building' } },
{ result: { ok: true, exitCode: 0, output: 'building' } },
];
const fetchImpl = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => {
const request = JSON.parse(String(init?.body)) as {
commands: Array<{ env: Record<string, string> }>;
};
expect(request.commands[0].env).toEqual({ VITE_APP_BASE_PATH: '/gateway' });
return new Response(`${messages.map((message) => JSON.stringify(message)).join('\n')}\n`, {
status: 200,
});
}) as unknown as typeof fetch;
const progress: BuildProgressEvent[] = [];
const result = await new RemoteBuildRunner('http://builder:15100', fetchImpl).run(
[
{
command: 'pnpm',
args: ['exec', 'turbo', 'run', 'build'],
cwd: '/srv/core/repository',
env: { VITE_APP_BASE_PATH: '/gateway', GAME_TOKEN_SECRET: 'do-not-send' },
},
],
(event) => {
progress.push(event);
}
);
expect(result).toEqual({ ok: true, exitCode: 0, output: 'building' });
expect(progress).toEqual([{ type: 'OUTPUT', stream: 'stdout', message: 'building' }]);
});
});
@@ -103,6 +103,25 @@ describe('GitWorkspaceManager source resolution', () => {
await expect(manager.resolveCommit('COMMIT', 'HEAD..main')).rejects.toThrow('Invalid git ref');
});
it('atomically publishes only refs below the managed release namespace', async () => {
const fixture = createRepositoryFixture();
const manager = new GitWorkspaceManager({
repoRoot: fixture.checkout,
worktreeRoot: fixture.worktrees,
});
const releaseRef = 'refs/sammo/active-gateway';
await expect(manager.readPersistentReleaseRef(releaseRef)).resolves.toBeNull();
await manager.compareAndSwapPersistentReleaseRef(releaseRef, null, fixture.firstCommit);
await expect(manager.readPersistentReleaseRef(releaseRef)).resolves.toBe(fixture.firstCommit);
await expect(
manager.compareAndSwapPersistentReleaseRef(releaseRef, 'f'.repeat(40), fixture.firstCommit)
).rejects.toThrow(/Failed to update persistent release ref/u);
await manager.compareAndSwapPersistentReleaseRef(releaseRef, fixture.firstCommit, null);
await expect(manager.readPersistentReleaseRef(releaseRef)).resolves.toBeNull();
await expect(manager.readPersistentReleaseRef('refs/heads/main')).rejects.toThrow(/refs\/sammo/u);
});
it('reuses only a clean registered worktree at the requested commit', async () => {
const fixture = createRepositoryFixture();
const manager = new GitWorkspaceManager({