feat(release): 정적 프런트엔드와 격리 빌더를 도입한다
Gateway와 프로필 릴리스가 불변 프런트엔드 아티팩트를 원자적으로 게시하고 실패 시 이전 포인터를 복구하도록 한다. 릴리스 빌드는 전용 builder protocol로 분리하고 성공한 Gateway 커밋을 재기동용 Git ref에 고정한다.
This commit is contained in:
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user