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
+17
View File
@@ -1,5 +1,6 @@
import path from 'node:path'; import path from 'node:path';
import { parseBooleanWithFallback, parseNumberWithFallback } from '@sammo-ts/common'; import { parseBooleanWithFallback, parseNumberWithFallback } from '@sammo-ts/common';
import { resolveFrontendServeMode, type FrontendServeMode } from './orchestrator/frontendArtifactManager.js';
export interface GatewayApiConfig { export interface GatewayApiConfig {
host: string; host: string;
@@ -36,6 +37,10 @@ export interface GatewayApiConfig {
worktreeRoot: string; worktreeRoot: string;
navigationConfigFile: string | null; navigationConfigFile: string | null;
defaultNavigationConfigFile: string; defaultNavigationConfigFile: string;
frontendServeMode: FrontendServeMode;
frontendArtifactRoot: string;
frontendReadinessOrigin: string;
releaseBuilderUrl?: string;
} }
export interface GatewayOrchestratorConfig { export interface GatewayOrchestratorConfig {
@@ -49,6 +54,10 @@ export interface GatewayOrchestratorConfig {
orchestratorAdminIntervalMs: number; orchestratorAdminIntervalMs: number;
workspaceRootHint: string; workspaceRootHint: string;
worktreeRoot: string; worktreeRoot: string;
frontendServeMode?: FrontendServeMode;
frontendArtifactRoot?: string;
frontendReadinessOrigin?: string;
releaseBuilderUrl?: string;
} }
const resolveSchemaName = (value: string | undefined): string => { const resolveSchemaName = (value: string | undefined): string => {
@@ -136,6 +145,10 @@ export const resolveGatewayApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.
worktreeRoot: env.GATEWAY_WORKTREE_ROOT ?? path.resolve(workspaceRootHint, '.worktrees'), worktreeRoot: env.GATEWAY_WORKTREE_ROOT ?? path.resolve(workspaceRootHint, '.worktrees'),
navigationConfigFile: env.CORE_NAVIGATION_CONFIG_FILE?.trim() || '/srv/data/navigation.json', navigationConfigFile: env.CORE_NAVIGATION_CONFIG_FILE?.trim() || '/srv/data/navigation.json',
defaultNavigationConfigFile: path.resolve(workspaceRootHint, 'resources/navigation.json'), defaultNavigationConfigFile: path.resolve(workspaceRootHint, 'resources/navigation.json'),
frontendServeMode: resolveFrontendServeMode(env.FRONTEND_SERVE_MODE),
frontendArtifactRoot: path.resolve(env.FRONTEND_ARTIFACT_ROOT ?? '/srv/frontend-artifacts'),
frontendReadinessOrigin: env.FRONTEND_READINESS_ORIGIN?.trim() || 'http://caddy',
releaseBuilderUrl: env.RELEASE_BUILDER_URL?.trim() || undefined,
}; };
}; };
@@ -176,5 +189,9 @@ export const resolveGatewayOrchestratorConfigFromEnv = (
workspaceRootHint: env.GATEWAY_WORKSPACE_ROOT ?? process.cwd(), workspaceRootHint: env.GATEWAY_WORKSPACE_ROOT ?? process.cwd(),
worktreeRoot: worktreeRoot:
env.GATEWAY_WORKTREE_ROOT ?? path.resolve(env.GATEWAY_WORKSPACE_ROOT ?? process.cwd(), '.worktrees'), env.GATEWAY_WORKTREE_ROOT ?? path.resolve(env.GATEWAY_WORKSPACE_ROOT ?? process.cwd(), '.worktrees'),
frontendServeMode: resolveFrontendServeMode(env.FRONTEND_SERVE_MODE),
frontendArtifactRoot: path.resolve(env.FRONTEND_ARTIFACT_ROOT ?? '/srv/frontend-artifacts'),
frontendReadinessOrigin: env.FRONTEND_READINESS_ORIGIN?.trim() || 'http://caddy',
releaseBuilderUrl: env.RELEASE_BUILDER_URL?.trim() || undefined,
}; };
}; };
+1
View File
@@ -18,6 +18,7 @@ export * from './orchestrator/buildRunner.js';
export * from './orchestrator/processManager.js'; export * from './orchestrator/processManager.js';
export * from './orchestrator/pm2ProcessManager.js'; export * from './orchestrator/pm2ProcessManager.js';
export * from './orchestrator/releaseManifest.js'; export * from './orchestrator/releaseManifest.js';
export * from './orchestrator/frontendArtifactManager.js';
export * from './auth/userRepository.js'; export * from './auth/userRepository.js';
export * from './auth/passwordHasher.js'; export * from './auth/passwordHasher.js';
export * from './auth/inMemoryUserRepository.js'; export * from './auth/inMemoryUserRepository.js';
@@ -33,6 +33,22 @@ export interface BuildRunner {
export const MAX_BUILD_OUTPUT_CHARS = 64 * 1024; export const MAX_BUILD_OUTPUT_CHARS = 64 * 1024;
const DEFAULT_RELEASE_TURBO_CONCURRENCY = 1; const DEFAULT_RELEASE_TURBO_CONCURRENCY = 1;
const RELEASE_BUILD_ENV_NAME = /^(?:CI|PATH|NODE_OPTIONS|RELEASE_BUILD_NODE_OPTIONS|PROFILE_FRONTEND_BUILD_NODE_OPTIONS|RAYON_NUM_THREADS|RELEASE_TURBO_CONCURRENCY|TURBO_CACHE_DIR|TZ|VITE_[A-Z0-9_]+)$/u;
export const sanitizeReleaseBuildEnv = (
env: NodeJS.ProcessEnv | Record<string, string> | undefined
): Record<string, string> => {
const sanitized = Object.fromEntries(
Object.entries(env ?? {}).filter(
(entry): entry is [string, string] => typeof entry[1] === 'string' && RELEASE_BUILD_ENV_NAME.test(entry[0])
)
);
if (sanitized.RELEASE_BUILD_NODE_OPTIONS) {
sanitized.NODE_OPTIONS = sanitized.RELEASE_BUILD_NODE_OPTIONS;
delete sanitized.RELEASE_BUILD_NODE_OPTIONS;
}
return sanitized;
};
export const resolveReleaseTurboConcurrency = (env?: Record<string, string>): number => { export const resolveReleaseTurboConcurrency = (env?: Record<string, string>): number => {
const configured = env?.RELEASE_TURBO_CONCURRENCY?.trim(); const configured = env?.RELEASE_TURBO_CONCURRENCY?.trim();
@@ -213,3 +229,100 @@ export class PnpmBuildRunner implements BuildRunner {
}; };
} }
} }
interface RemoteBuildMessage {
event?: BuildProgressEvent;
result?: BuildResult;
error?: string;
}
export class RemoteBuildRunner implements BuildRunner {
private readonly endpoint: string;
constructor(baseUrl: string, private readonly fetchImpl: typeof fetch = fetch) {
this.endpoint = new URL('/v1/builds', baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`).toString();
}
async run(
commands: BuildCommand[],
onProgress?: BuildProgressObserver,
options?: BuildRunOptions
): Promise<BuildResult> {
let output = '';
try {
const response = await this.fetchImpl(this.endpoint, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
commands: commands.map((command) => ({
...command,
env: sanitizeReleaseBuildEnv(command.env),
})),
}),
signal: options?.signal,
});
if (!response.ok || !response.body) {
const detail = await response.text().catch(() => '');
return {
ok: false,
exitCode: null,
output: appendOutputTail(output, detail || `Release builder returned HTTP ${response.status}.`),
};
}
const decoder = new TextDecoder();
let buffer = '';
for await (const chunk of response.body) {
buffer += decoder.decode(chunk, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
const result = await this.handleRemoteMessage(line, onProgress);
if (result.event?.type === 'OUTPUT') {
output = appendOutputTail(output, `${result.event.message}\n`);
}
if (result.result) return result.result;
if (result.error) {
return { ok: false, exitCode: null, output: appendOutputTail(output, result.error) };
}
}
}
if (buffer.trim()) {
const result = await this.handleRemoteMessage(buffer, onProgress);
if (result.result) return result.result;
if (result.error) return { ok: false, exitCode: null, output: appendOutputTail(output, result.error) };
}
return { ok: false, exitCode: null, output: appendOutputTail(output, 'Release builder closed without a result.') };
} catch (error) {
const aborted = options?.signal?.aborted ?? false;
return {
ok: false,
exitCode: null,
output: appendOutputTail(
output,
aborted ? 'Build cancelled by operator.' : error instanceof Error ? error.message : String(error)
),
...(aborted ? { aborted: true } : {}),
};
}
}
private async handleRemoteMessage(
line: string,
onProgress?: BuildProgressObserver
): Promise<RemoteBuildMessage> {
let message: RemoteBuildMessage;
try {
message = JSON.parse(line) as RemoteBuildMessage;
} catch {
return { error: 'Release builder returned malformed progress data.' };
}
if (message.event && onProgress) await onProgress(message.event);
return message;
}
}
export const createReleaseBuildRunner = (
baseUrl: string | undefined,
localRunner: BuildRunner,
fetchImpl: typeof fetch = fetch
): BuildRunner => (baseUrl?.trim() ? new RemoteBuildRunner(baseUrl.trim(), fetchImpl) : localRunner);
@@ -0,0 +1,241 @@
import { createHash, randomUUID } from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
export type FrontendServeMode = 'preview' | 'static';
export interface FrontendArtifactManifest {
version: 1;
frontendKey: string;
commitSha: string;
digest: string;
releaseId: string;
files: number;
}
export interface StagedFrontendArtifact {
releaseId: string;
releasePath: string;
manifest: FrontendArtifactManifest;
}
const MANIFEST_FILE = '.sammo-artifact.json';
const FRONTEND_KEY = /^[a-z0-9][a-z0-9_-]{0,63}$/u;
const COMMIT_SHA = /^[0-9a-f]{40,64}$/iu;
export const resolveFrontendServeMode = (value: string | undefined): FrontendServeMode => {
const normalized = value?.trim().toLowerCase();
if (!normalized || normalized === 'preview') return 'preview';
if (normalized === 'static') return 'static';
throw new Error('FRONTEND_SERVE_MODE must be preview or static.');
};
const assertFrontendKey = (value: string): void => {
if (!FRONTEND_KEY.test(value)) throw new Error(`Invalid frontend artifact key: ${value}`);
};
const assertCommitSha = (value: string): void => {
if (!COMMIT_SHA.test(value)) throw new Error('Frontend artifact commit SHA must be a full hexadecimal SHA.');
};
const listSourceFiles = async (sourceRoot: string): Promise<string[]> => {
const rootStat = await fs.lstat(sourceRoot);
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) {
throw new Error(`Frontend artifact source must be a real directory: ${sourceRoot}`);
}
const files: string[] = [];
const visit = async (relativeDirectory: string): Promise<void> => {
const directory = path.join(sourceRoot, relativeDirectory);
const entries = await fs.readdir(directory, { withFileTypes: true });
entries.sort((left, right) => left.name.localeCompare(right.name));
for (const entry of entries) {
const relativePath = path.join(relativeDirectory, entry.name);
const absolutePath = path.join(sourceRoot, relativePath);
const stat = await fs.lstat(absolutePath);
if (stat.isSymbolicLink()) {
throw new Error(`Frontend artifact source contains a symbolic link: ${relativePath}`);
}
if (stat.isDirectory()) {
await visit(relativePath);
continue;
}
if (!stat.isFile()) {
throw new Error(`Frontend artifact source contains an unsupported entry: ${relativePath}`);
}
files.push(relativePath);
}
};
await visit('');
if (!files.includes('index.html')) {
throw new Error(`Frontend artifact source is missing index.html: ${sourceRoot}`);
}
return files;
};
const buildDigest = async (sourceRoot: string, files: string[]): Promise<string> => {
const hash = createHash('sha256');
for (const relativePath of files) {
hash.update(relativePath.split(path.sep).join('/'));
hash.update('\0');
hash.update(await fs.readFile(path.join(sourceRoot, relativePath)));
hash.update('\0');
}
return hash.digest('hex');
};
const readManifest = async (releasePath: string): Promise<FrontendArtifactManifest> => {
const raw = JSON.parse(await fs.readFile(path.join(releasePath, MANIFEST_FILE), 'utf8')) as Partial<FrontendArtifactManifest>;
if (
raw.version !== 1 ||
typeof raw.frontendKey !== 'string' ||
typeof raw.commitSha !== 'string' ||
typeof raw.digest !== 'string' ||
typeof raw.releaseId !== 'string' ||
typeof raw.files !== 'number'
) {
throw new Error(`Invalid frontend artifact manifest: ${releasePath}`);
}
await fs.access(path.join(releasePath, 'index.html'));
return raw as FrontendArtifactManifest;
};
const isMissing = (error: unknown): boolean =>
error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'ENOENT';
export class FrontendArtifactManager {
readonly root: string;
constructor(root: string) {
this.root = path.resolve(root);
}
private frontendRoot(frontendKey: string): string {
assertFrontendKey(frontendKey);
return path.join(this.root, frontendKey);
}
private releasePath(frontendKey: string, releaseId: string): string {
if (!/^[0-9a-f]{40,64}-[0-9a-f]{16}$/iu.test(releaseId)) {
throw new Error(`Invalid frontend artifact release id: ${releaseId}`);
}
return path.join(this.frontendRoot(frontendKey), 'releases', releaseId);
}
async stage(options: {
frontendKey: string;
sourceRoot: string;
commitSha: string;
}): Promise<StagedFrontendArtifact> {
assertFrontendKey(options.frontendKey);
assertCommitSha(options.commitSha);
const commitSha = options.commitSha.toLowerCase();
const sourceRoot = path.resolve(options.sourceRoot);
const files = await listSourceFiles(sourceRoot);
const digest = await buildDigest(sourceRoot, files);
const releaseId = `${commitSha}-${digest.slice(0, 16)}`;
const releasePath = this.releasePath(options.frontendKey, releaseId);
const manifest: FrontendArtifactManifest = {
version: 1,
frontendKey: options.frontendKey,
commitSha,
digest,
releaseId,
files: files.length,
};
try {
const existing = await readManifest(releasePath);
if (existing.digest !== digest || existing.commitSha !== commitSha) {
throw new Error(`Frontend artifact release collision: ${releaseId}`);
}
return { releaseId, releasePath, manifest: existing };
} catch (error) {
if (!isMissing(error)) throw error;
}
const releasesRoot = path.dirname(releasePath);
await fs.mkdir(releasesRoot, { recursive: true, mode: 0o755 });
const stagingPath = path.join(releasesRoot, `.staging-${randomUUID()}`);
await fs.mkdir(stagingPath, { mode: 0o755 });
try {
for (const relativePath of files) {
const targetPath = path.join(stagingPath, relativePath);
await fs.mkdir(path.dirname(targetPath), { recursive: true, mode: 0o755 });
await fs.copyFile(path.join(sourceRoot, relativePath), targetPath);
await fs.chmod(targetPath, 0o644);
}
await fs.writeFile(path.join(stagingPath, MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}\n`, {
encoding: 'utf8',
mode: 0o644,
});
try {
await fs.rename(stagingPath, releasePath);
} catch (error) {
if (!isMissing(error) && error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'EEXIST') {
const existing = await readManifest(releasePath);
if (existing.digest !== digest || existing.commitSha !== commitSha) throw error;
} else {
throw error;
}
}
} finally {
await fs.rm(stagingPath, { recursive: true, force: true });
}
return { releaseId, releasePath, manifest };
}
async readCurrentReleaseId(frontendKey: string): Promise<string | null> {
const frontendRoot = this.frontendRoot(frontendKey);
try {
const target = await fs.readlink(path.join(frontendRoot, 'current'));
const normalized = target.split(path.sep).join('/');
const match = /^releases\/([0-9a-f]{40,64}-[0-9a-f]{16})$/iu.exec(normalized);
if (!match) throw new Error(`Invalid current frontend artifact pointer for ${frontendKey}.`);
await readManifest(this.releasePath(frontendKey, match[1]));
return match[1];
} catch (error) {
if (isMissing(error)) return null;
throw error;
}
}
async activate(frontendKey: string, releaseId: string): Promise<string | null> {
const frontendRoot = this.frontendRoot(frontendKey);
const releasePath = this.releasePath(frontendKey, releaseId);
const manifest = await readManifest(releasePath);
if (manifest.frontendKey !== frontendKey || manifest.releaseId !== releaseId) {
throw new Error(`Frontend artifact manifest does not match ${frontendKey}/${releaseId}.`);
}
await fs.mkdir(frontendRoot, { recursive: true, mode: 0o755 });
const previousReleaseId = await this.readCurrentReleaseId(frontendKey);
const replacePointer = async (name: 'current' | 'previous', targetReleaseId: string): Promise<void> => {
const temporary = path.join(frontendRoot, `.${name}-${randomUUID()}`);
await fs.symlink(path.join('releases', targetReleaseId), temporary);
try {
await fs.rename(temporary, path.join(frontendRoot, name));
} finally {
await fs.rm(temporary, { force: true });
}
};
if (previousReleaseId && previousReleaseId !== releaseId) {
await replacePointer('previous', previousReleaseId);
}
await replacePointer('current', releaseId);
return previousReleaseId;
}
async stageAndActivate(options: {
frontendKey: string;
sourceRoot: string;
commitSha: string;
}): Promise<StagedFrontendArtifact & { previousReleaseId: string | null }> {
const staged = await this.stage(options);
const previousReleaseId = await this.activate(options.frontendKey, staged.releaseId);
return { ...staged, previousReleaseId };
}
async deactivate(frontendKey: string): Promise<string | null> {
const releaseId = await this.readCurrentReleaseId(frontendKey);
await fs.rm(path.join(this.frontendRoot(frontendKey), 'current'), { force: true });
return releaseId;
}
}
@@ -30,6 +30,8 @@ import {
type BuildProgressEvent, type BuildProgressEvent,
type BuildProgressObserver, type BuildProgressObserver,
type BuildRunner, type BuildRunner,
createReleaseBuildRunner,
sanitizeReleaseBuildEnv,
} from './buildRunner.js'; } from './buildRunner.js';
import { sanitizeManagedProcessEnv, type ProcessManager } from './processManager.js'; import { sanitizeManagedProcessEnv, type ProcessManager } from './processManager.js';
import type { import type {
@@ -51,12 +53,21 @@ import {
} from './workspaceManager.js'; } from './workspaceManager.js';
import type { AdminSeedUser } from './seedProfileDatabase.js'; import type { AdminSeedUser } from './seedProfileDatabase.js';
import { assertReleaseComponents, readReleaseManifest } from './releaseManifest.js'; import { assertReleaseComponents, readReleaseManifest } from './releaseManifest.js';
import {
FrontendArtifactManager,
resolveFrontendServeMode,
type FrontendServeMode,
} from './frontendArtifactManager.js';
export interface GatewayProcessConfig { export interface GatewayProcessConfig {
workspaceRoot: string; workspaceRoot: string;
redisKeyPrefix: string; redisKeyPrefix: string;
gameTokenSecret: string; gameTokenSecret: string;
gatewayInternalApiUrl: string; gatewayInternalApiUrl: string;
frontendServeMode?: FrontendServeMode;
frontendArtifactRoot?: string;
frontendReadinessOrigin?: string;
releaseBuilderUrl?: string;
baseEnv?: Record<string, string>; baseEnv?: Record<string, string>;
} }
@@ -567,14 +578,14 @@ export const buildProfileFrontendCommands = (
throw new Error('Profile frontend build requires a full commit SHA.'); throw new Error('Profile frontend build requires a full commit SHA.');
} }
const profileFrontendBuildNodeOptions = env?.PROFILE_FRONTEND_BUILD_NODE_OPTIONS?.trim(); const profileFrontendBuildNodeOptions = env?.PROFILE_FRONTEND_BUILD_NODE_OPTIONS?.trim();
const buildEnv = { const buildEnv = sanitizeReleaseBuildEnv({
...(env ?? {}), ...(env ?? {}),
...(profileFrontendBuildNodeOptions ? { NODE_OPTIONS: profileFrontendBuildNodeOptions } : {}), ...(profileFrontendBuildNodeOptions ? { NODE_OPTIONS: profileFrontendBuildNodeOptions } : {}),
VITE_APP_BASE_PATH: `/${profile.profile}`, VITE_APP_BASE_PATH: `/${profile.profile}`,
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`,
VITE_BUILD_COMMIT_SHA: buildCommitSha.trim().toLowerCase(), VITE_BUILD_COMMIT_SHA: buildCommitSha.trim().toLowerCase(),
}; });
return [ return [
buildTurboReleaseTaskCommand( buildTurboReleaseTaskCommand(
workspaceRoot, workspaceRoot,
@@ -599,16 +610,17 @@ export const buildWorkspaceCommands = (
cacheAnchorRoot: string = workspaceRoot, cacheAnchorRoot: string = workspaceRoot,
packageNames: string[] = ['@sammo-ts/game-api', '@sammo-ts/gateway-api'] packageNames: string[] = ['@sammo-ts/game-api', '@sammo-ts/gateway-api']
): BuildCommand[] => { ): BuildCommand[] => {
const buildEnv = sanitizeReleaseBuildEnv(env);
const commands: BuildCommand[] = []; const commands: BuildCommand[] = [];
if (needsInstall) { if (needsInstall) {
commands.push({ commands.push({
command: 'pnpm', command: 'pnpm',
args: ['install', '--frozen-lockfile'], args: ['install', '--frozen-lockfile'],
cwd: workspaceRoot, cwd: workspaceRoot,
env, env: buildEnv,
}); });
} }
commands.push(buildTurboReleaseCommand(workspaceRoot, cacheAnchorRoot, packageNames, env)); commands.push(buildTurboReleaseCommand(workspaceRoot, cacheAnchorRoot, packageNames, buildEnv));
return commands; return commands;
}; };
@@ -646,8 +658,11 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
private readonly repository: GatewayProfileRepository; private readonly repository: GatewayProfileRepository;
private readonly processManager: ProcessManager; private readonly processManager: ProcessManager;
private readonly buildRunner: BuildRunner; private readonly buildRunner: BuildRunner;
private readonly releaseBuildRunner: BuildRunner;
private readonly workspaceManager: GitWorkspaceManager; private readonly workspaceManager: GitWorkspaceManager;
private readonly processConfig: GatewayProcessConfig; private readonly processConfig: GatewayProcessConfig;
private readonly frontendServeMode: FrontendServeMode;
private readonly artifactManager: FrontendArtifactManager;
private readonly reconcileIntervalMs: number; private readonly reconcileIntervalMs: number;
private readonly scheduleIntervalMs: number; private readonly scheduleIntervalMs: number;
private readonly buildIntervalMs: number; private readonly buildIntervalMs: number;
@@ -679,8 +694,17 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
this.repository = options.repository; this.repository = options.repository;
this.processManager = options.processManager; this.processManager = options.processManager;
this.buildRunner = options.buildRunner; this.buildRunner = options.buildRunner;
this.releaseBuildRunner = createReleaseBuildRunner(
options.processConfig.releaseBuilderUrl,
options.buildRunner,
options.fetchImpl ?? fetch
);
this.workspaceManager = options.workspaceManager; this.workspaceManager = options.workspaceManager;
this.processConfig = options.processConfig; this.processConfig = options.processConfig;
this.frontendServeMode = resolveFrontendServeMode(options.processConfig.frontendServeMode);
this.artifactManager = new FrontendArtifactManager(
options.processConfig.frontendArtifactRoot ?? '/srv/frontend-artifacts'
);
this.reconcileIntervalMs = options.reconcileIntervalMs; this.reconcileIntervalMs = options.reconcileIntervalMs;
this.scheduleIntervalMs = options.scheduleIntervalMs; this.scheduleIntervalMs = options.scheduleIntervalMs;
this.buildIntervalMs = options.buildIntervalMs; this.buildIntervalMs = options.buildIntervalMs;
@@ -802,7 +826,17 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
async listRuntimeStates(profileNames: string[]): Promise<ProfileRuntimeSnapshot[]> { async listRuntimeStates(profileNames: string[]): Promise<ProfileRuntimeSnapshot[]> {
const processStates = await this.loadProcessStatusMap(); const processStates = await this.loadProcessStatusMap();
return mapRuntimeStates(profileNames, processStates); const snapshots = mapRuntimeStates(profileNames, processStates);
if (this.frontendServeMode === 'preview') return snapshots;
await Promise.all(
snapshots.map(async (snapshot) => {
const profile = await this.repository.getProfile(snapshot.profileName);
snapshot.frontendRunning = profile
? (await this.artifactManager.readCurrentReleaseId(profile.profile)) !== null
: false;
})
);
return snapshots;
} }
async listRuntimeSettings(profileNames: string[]): Promise<ProfileRuntimeSettingsSnapshot[]> { async listRuntimeSettings(profileNames: string[]): Promise<ProfileRuntimeSettingsSnapshot[]> {
@@ -885,6 +919,10 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
continue; continue;
} }
const runtime = mapRuntimeStates([profile.profileName], processStates)[0]; const runtime = mapRuntimeStates([profile.profileName], processStates)[0];
if (this.frontendServeMode === 'static') {
runtime.frontendRunning =
(await this.artifactManager.readCurrentReleaseId(profile.profile)) !== null;
}
const plan = planProfileReconcile(profile.status, runtime); const plan = planProfileReconcile(profile.status, runtime);
if (plan.shouldStart) { if (plan.shouldStart) {
await this.startProfile(profile); await this.startProfile(profile);
@@ -1521,7 +1559,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
), ),
]; ];
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.releaseBuildRunner.run(commands, this.buildProgress(operationId, 'build'), {
signal: this.activeOperationAbortSignal, signal: this.activeOperationAbortSignal,
}); });
if (!result.ok) { if (!result.ok) {
@@ -1933,6 +1971,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
currentScenario: String(scenarioId), currentScenario: String(scenarioId),
status: desiredStatus, status: desiredStatus,
buildStatus: 'SUCCEEDED', buildStatus: 'SUCCEEDED',
buildCommitSha: commitSha,
buildWorkspace: workspace.root, buildWorkspace: workspace.root,
buildLastUsedAt: completedAt, buildLastUsedAt: completedAt,
buildCompletedAt: completedAt, buildCompletedAt: completedAt,
@@ -1970,6 +2009,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
currentScenario: String(scenarioId), currentScenario: String(scenarioId),
scenario: String(scenarioId), scenario: String(scenarioId),
status: desiredStatus, status: desiredStatus,
buildCommitSha: commitSha,
buildWorkspace: workspace.root, buildWorkspace: workspace.root,
}; };
await appendLog('switch', '초기화된 profile process를 시작합니다.'); await appendLog('switch', '초기화된 profile process를 시작합니다.');
@@ -2119,7 +2159,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
); );
} }
return { return {
result: await this.buildRunner.run( result: await this.releaseBuildRunner.run(
commands, commands,
operationId ? this.buildProgress(operationId, 'build') : undefined, operationId ? this.buildProgress(operationId, 'build') : undefined,
{ signal: this.activeOperationAbortSignal } { signal: this.activeOperationAbortSignal }
@@ -2281,7 +2321,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
private async startProfile(profile: GatewayProfileRecord, assertLease?: () => Promise<void>): Promise<boolean> { private async startProfile(profile: GatewayProfileRecord, assertLease?: () => Promise<void>): Promise<boolean> {
const definitions = buildProcessDefinitions(profile, this.processConfig); const definitions = buildProcessDefinitions(profile, this.processConfig);
const orderedDefinitions = [ const orderedDefinitions = [
definitions.frontend, ...(this.frontendServeMode === 'preview' ? [definitions.frontend] : []),
definitions.api, definitions.api,
definitions.daemon, definitions.daemon,
definitions.auction, definitions.auction,
@@ -2290,10 +2330,26 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
]; ];
const attemptedNames: string[] = []; const attemptedNames: string[] = [];
try { try {
const stagedArtifact =
this.frontendServeMode === 'static'
? await (async () => {
if (!profile.buildCommitSha) {
throw new Error(`Profile ${profile.profileName} is missing the build commit SHA.`);
}
const runtimeWorkspace = profile.buildWorkspace ?? this.processConfig.workspaceRoot;
return this.artifactManager.stage({
frontendKey: profile.profile,
sourceRoot: buildProfileFrontendOutDir(runtimeWorkspace, profile.profileName),
commitSha: profile.buildCommitSha,
});
})()
: null;
const expectedNames = new Set(orderedDefinitions.map((definition) => definition.name)); const expectedNames = new Set(orderedDefinitions.map((definition) => definition.name));
const obsoleteNames =
this.frontendServeMode === 'static' ? new Set([definitions.frontend.name]) : new Set<string>();
const existingNames = new Set( const existingNames = new Set(
(await this.processManager.list()) (await this.processManager.list())
.filter((process) => expectedNames.has(process.name)) .filter((process) => expectedNames.has(process.name) || obsoleteNames.has(process.name))
.map((process) => process.name) .map((process) => process.name)
); );
for (const name of existingNames) { for (const name of existingNames) {
@@ -2307,6 +2363,9 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
await this.processManager.start(definition); await this.processManager.start(definition);
await assertLease?.(); await assertLease?.();
} }
if (stagedArtifact) {
await this.artifactManager.activate(profile.profile, stagedArtifact.releaseId);
}
if (!assertLease) { if (!assertLease) {
await this.repository.updateLastError(profile.profileName, null); await this.repository.updateLastError(profile.profileName, null);
} }
@@ -2341,9 +2400,17 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
): Promise<boolean> { ): Promise<boolean> {
const deadline = Date.now() + this.profileReadinessTimeoutMs; const deadline = Date.now() + this.profileReadinessTimeoutMs;
const definitions = buildProcessDefinitions(profile, this.processConfig); const definitions = buildProcessDefinitions(profile, this.processConfig);
const expectedNames = Object.values(definitions).map((definition) => definition.name); const expectedNames = Object.entries(definitions)
.filter(([role]) => this.frontendServeMode === 'preview' || role !== 'frontend')
.map(([, definition]) => definition.name);
const apiUrl = `http://127.0.0.1:${profile.apiPort}/healthz`; const apiUrl = `http://127.0.0.1:${profile.apiPort}/healthz`;
const frontendUrl = `http://127.0.0.1:${profile.apiPort - 1}/${profile.profile}/`; const frontendUrl =
this.frontendServeMode === 'static'
? new URL(
`/${profile.profile}/`,
this.processConfig.frontendReadinessOrigin ?? 'http://caddy'
).toString()
: `http://127.0.0.1:${profile.apiPort - 1}/${profile.profile}/`;
while (Date.now() < deadline) { while (Date.now() < deadline) {
await assertLease?.(); await assertLease?.();
try { try {
@@ -2381,6 +2448,10 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
const battleSimName = buildProcessName(profile.profileName, 'battle-sim'); const battleSimName = buildProcessName(profile.profileName, 'battle-sim');
const tournamentName = buildProcessName(profile.profileName, 'tournament'); const tournamentName = buildProcessName(profile.profileName, 'tournament');
await assertLease?.(); await assertLease?.();
if (this.frontendServeMode === 'static') {
await this.artifactManager.deactivate(profile.profile);
}
await assertLease?.();
const existingNames = new Set((await this.processManager.list()).map((process) => process.name)); const existingNames = new Set((await this.processManager.list()).map((process) => process.name));
await assertLease?.(); await assertLease?.();
const failures: string[] = []; const failures: string[] = [];
@@ -41,6 +41,10 @@ export const createGatewayOrchestrator = (
redisKeyPrefix: config.redisKeyPrefix, redisKeyPrefix: config.redisKeyPrefix,
gameTokenSecret: config.gameTokenSecret, gameTokenSecret: config.gameTokenSecret,
gatewayInternalApiUrl: config.gatewayInternalApiUrl, gatewayInternalApiUrl: config.gatewayInternalApiUrl,
frontendServeMode: config.frontendServeMode,
frontendArtifactRoot: config.frontendArtifactRoot,
frontendReadinessOrigin: config.frontendReadinessOrigin,
releaseBuilderUrl: config.releaseBuilderUrl,
baseEnv, baseEnv,
}, },
reconcileIntervalMs: config.orchestratorReconcileIntervalMs, reconcileIntervalMs: config.orchestratorReconcileIntervalMs,
@@ -66,6 +66,8 @@ const ensureDir = (dir: string): void => {
const hasInstallMarker = (dir: string): boolean => fs.existsSync(path.join(dir, 'node_modules', '.pnpm')); const hasInstallMarker = (dir: string): boolean => fs.existsSync(path.join(dir, 'node_modules', '.pnpm'));
const GIT_REF_PATTERN = /^[0-9A-Za-z._/-]+$/; const GIT_REF_PATTERN = /^[0-9A-Za-z._/-]+$/;
const COMMIT_SHA_PATTERN = /^[0-9a-f]{40}$/i; const COMMIT_SHA_PATTERN = /^[0-9a-f]{40}$/i;
const PERSISTENT_RELEASE_REF_PATTERN = /^refs\/sammo\/[a-z0-9][a-z0-9._/-]{0,127}$/u;
const NULL_COMMIT_SHA = '0'.repeat(40);
const assertGitRef = (value: string): string => { const assertGitRef = (value: string): string => {
const ref = value.trim(); const ref = value.trim();
@@ -123,6 +125,47 @@ export class GitWorkspaceManager {
throw new Error(`${sourceMode === 'BRANCH' ? 'Branch' : 'Commit'} not found.`); throw new Error(`${sourceMode === 'BRANCH' ? 'Branch' : 'Commit'} not found.`);
} }
async readPersistentReleaseRef(ref: string): Promise<string | null> {
if (!PERSISTENT_RELEASE_REF_PATTERN.test(ref)) {
throw new Error('Persistent release ref must be below refs/sammo/.');
}
const result = await runGit(['rev-parse', '--verify', `${ref}^{commit}`], this.repoRoot, this.baseEnv);
if (!result.ok) return null;
const commitSha = result.output.trim().split('\n')[0];
if (!COMMIT_SHA_PATTERN.test(commitSha)) {
throw new Error(`Persistent release ref does not resolve to a commit: ${ref}`);
}
return commitSha.toLowerCase();
}
async compareAndSwapPersistentReleaseRef(
ref: string,
expectedCommitSha: string | null,
nextCommitSha: string | null
): Promise<void> {
if (!PERSISTENT_RELEASE_REF_PATTERN.test(ref)) {
throw new Error('Persistent release ref must be below refs/sammo/.');
}
for (const commitSha of [expectedCommitSha, nextCommitSha]) {
if (commitSha !== null && !COMMIT_SHA_PATTERN.test(commitSha)) {
throw new Error('Persistent release ref commit must be a full SHA.');
}
}
if (nextCommitSha) {
const exists = await runGit(['cat-file', '-e', `${nextCommitSha}^{commit}`], this.repoRoot, this.baseEnv);
if (!exists.ok) throw new Error(`Persistent release commit is unavailable: ${nextCommitSha}`);
}
const args = nextCommitSha
? ['update-ref', ref, nextCommitSha, expectedCommitSha ?? NULL_COMMIT_SHA]
: ['update-ref', '-d', ref, expectedCommitSha ?? NULL_COMMIT_SHA];
const updated = await runGit(args, this.repoRoot, this.baseEnv);
if (!updated.ok) {
throw new Error(
`Failed to update persistent release ref ${ref}${updated.output ? `: ${updated.output.trim()}` : ''}`
);
}
}
async prepare(commitSha: string): Promise<WorkspaceInfo> { async prepare(commitSha: string): Promise<WorkspaceInfo> {
if (!COMMIT_SHA_PATTERN.test(commitSha)) { if (!COMMIT_SHA_PATTERN.test(commitSha)) {
throw new Error('Invalid commit SHA.'); throw new Error('Invalid commit SHA.');
@@ -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 { GatewayOrchestrator, type GatewayOrchestratorOptions } from '../src/orchestrator/gatewayOrchestrator.js';
import type { ProcessDefinition, ProcessManager } from '../src/orchestrator/processManager.js'; import type { ProcessDefinition, ProcessManager } from '../src/orchestrator/processManager.js';
@@ -10,6 +14,12 @@ import type {
} from '../src/orchestrator/profileRepository.js'; } from '../src/orchestrator/profileRepository.js';
import { GitWorkspaceManager } from '../src/orchestrator/workspaceManager.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 = { const profile: GatewayProfileRecord = {
profileName: 'che:2', profileName: 'che:2',
profile: 'che', profile: 'che',
@@ -52,6 +62,9 @@ const createHarness = (
reservedToStart?: GatewayProfileRecord[]; reservedToStart?: GatewayProfileRecord[];
now?: () => Date; now?: () => Date;
cancelGame?: GatewayOrchestratorOptions['cancelGame']; cancelGame?: GatewayOrchestratorOptions['cancelGame'];
frontendServeMode?: 'static';
frontendArtifactRoot?: string;
activeOperationProfileNames?: string[];
} = {} } = {}
) => { ) => {
const harnessProfile = options.profile ?? profile; const harnessProfile = options.profile ?? profile;
@@ -85,7 +98,8 @@ const createHarness = (
updateWorkspaceUsage: async () => {}, updateWorkspaceUsage: async () => {},
clearWorkspaceUsage: async () => {}, clearWorkspaceUsage: async () => {},
listOperations: async () => [], listOperations: async () => [],
listActiveOperationProfileNames: async () => [harnessProfile.profileName], listActiveOperationProfileNames: async () =>
options.activeOperationProfileNames ?? [harnessProfile.profileName],
getOperation: async () => operation, getOperation: async () => operation,
listOperationLogs: async () => [], listOperationLogs: async () => [],
appendOperationLog: async (operationId, input) => { appendOperationLog: async (operationId, input) => {
@@ -168,6 +182,8 @@ const createHarness = (
redisKeyPrefix: 'sammo:test', redisKeyPrefix: 'sammo:test',
gameTokenSecret: 'test-secret', gameTokenSecret: 'test-secret',
gatewayInternalApiUrl: 'http://127.0.0.1:13000', 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' }, baseEnv: { DATABASE_URL: 'postgresql://test:test@127.0.0.1:15432/test' },
}, },
reconcileIntervalMs: 60_000, reconcileIntervalMs: 60_000,
@@ -375,6 +391,56 @@ describe('GatewayOrchestrator first-class operations', () => {
expect(harness.completions).toEqual(['SUCCEEDED']); 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 () => { it('stops every profile process and records success', async () => {
const harness = createHarness(buildOperation('STOP')); const harness = createHarness(buildOperation('STOP'));
@@ -37,6 +37,17 @@ const createReleaseWorkspace = async (): Promise<string> => {
components: ['game-api', 'game-engine', 'game-frontend'], 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; return workspace;
}; };
@@ -45,7 +56,7 @@ afterEach(async () => {
}); });
describe('profile DEPLOY operation', () => { 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 workspace = await createReleaseWorkspace();
const profile: GatewayProfileRecord = { const profile: GatewayProfileRecord = {
profileName: 'che:1010', profileName: 'che:1010',
@@ -131,6 +142,7 @@ describe('profile DEPLOY operation', () => {
'sammo:che:1010:battle-sim-worker', 'sammo:che:1010:battle-sim-worker',
'sammo:che:1010:tournament-worker', 'sammo:che:1010:tournament-worker',
]; ];
const backendProcessNames = processNames.filter((name) => !name.endsWith(':game-frontend'));
const running = new Set(processNames); const running = new Set(processNames);
const processManager: ProcessManager = { const processManager: ProcessManager = {
list: async () => [...running].map((name) => ({ name, status: 'online' })), list: async () => [...running].map((name) => ({ name, status: 'online' })),
@@ -171,6 +183,9 @@ describe('profile DEPLOY operation', () => {
redisKeyPrefix: 'sammo:test', redisKeyPrefix: 'sammo:test',
gameTokenSecret: 'test-secret', gameTokenSecret: 'test-secret',
gatewayInternalApiUrl: 'http://127.0.0.1:15001', 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' }, baseEnv: { DATABASE_URL: 'postgresql://user:pass@integration.invalid/sammo' },
}, },
reconcileIntervalMs: 60_000, 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')).not.toContain('pass@integration.invalid');
expect(logs.map((entry) => entry.message).join('\n')).toContain('[REDACTED]'); 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'); 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 () => { it('reuses only a clean registered worktree at the requested commit', async () => {
const fixture = createRepositoryFixture(); const fixture = createRepositoryFixture();
const manager = new GitWorkspaceManager({ const manager = new GitWorkspaceManager({
+11
View File
@@ -1,6 +1,7 @@
import path from 'node:path'; import path from 'node:path';
import { sanitizeManagedProcessEnv } from '@sammo-ts/gateway-api'; import { sanitizeManagedProcessEnv } from '@sammo-ts/gateway-api';
import { resolveFrontendServeMode, type FrontendServeMode } from '@sammo-ts/gateway-api';
import { resolvePostgresPoolMax } from '@sammo-ts/infra'; import { resolvePostgresPoolMax } from '@sammo-ts/infra';
const parsePositiveInt = (value: string | undefined, fallback: number, name: string): number => { const parsePositiveInt = (value: string | undefined, fallback: number, name: string): number => {
@@ -24,6 +25,11 @@ export interface ReleaseControllerConfig {
gatewayApiPort: number; gatewayApiPort: number;
gatewayFrontendPort: number; gatewayFrontendPort: number;
gatewayBasePath: string; gatewayBasePath: string;
frontendServeMode?: FrontendServeMode;
frontendArtifactRoot?: string;
frontendReadinessOrigin?: string;
releaseBuilderUrl?: string;
activeReleaseGitRef?: string;
pollIntervalMs: number; pollIntervalMs: number;
readinessTimeoutMs: number; readinessTimeoutMs: number;
postgresPoolMax: number; postgresPoolMax: number;
@@ -50,6 +56,11 @@ export const resolveReleaseControllerConfig = (env: NodeJS.ProcessEnv = process.
gatewayApiPort: parsePositiveInt(env.GATEWAY_API_PORT, 15001, 'GATEWAY_API_PORT'), gatewayApiPort: parsePositiveInt(env.GATEWAY_API_PORT, 15001, 'GATEWAY_API_PORT'),
gatewayFrontendPort: parsePositiveInt(env.GATEWAY_FRONTEND_PORT, 15000, 'GATEWAY_FRONTEND_PORT'), gatewayFrontendPort: parsePositiveInt(env.GATEWAY_FRONTEND_PORT, 15000, 'GATEWAY_FRONTEND_PORT'),
gatewayBasePath: env.GATEWAY_BASE_PATH?.trim() || '/gateway', gatewayBasePath: env.GATEWAY_BASE_PATH?.trim() || '/gateway',
frontendServeMode: resolveFrontendServeMode(env.FRONTEND_SERVE_MODE),
frontendArtifactRoot: path.resolve(env.FRONTEND_ARTIFACT_ROOT ?? '/srv/frontend-artifacts'),
frontendReadinessOrigin: env.FRONTEND_READINESS_ORIGIN?.trim() || 'http://caddy',
releaseBuilderUrl: env.RELEASE_BUILDER_URL?.trim() || undefined,
activeReleaseGitRef: env.GATEWAY_ACTIVE_RELEASE_GIT_REF?.trim() || undefined,
pollIntervalMs: parsePositiveInt(env.RELEASE_CONTROLLER_POLL_MS, 5000, 'RELEASE_CONTROLLER_POLL_MS'), pollIntervalMs: parsePositiveInt(env.RELEASE_CONTROLLER_POLL_MS, 5000, 'RELEASE_CONTROLLER_POLL_MS'),
readinessTimeoutMs: parsePositiveInt( readinessTimeoutMs: parsePositiveInt(
env.RELEASE_CONTROLLER_READINESS_TIMEOUT_MS, env.RELEASE_CONTROLLER_READINESS_TIMEOUT_MS,
+4 -1
View File
@@ -4,6 +4,7 @@ import {
GitWorkspaceManager, GitWorkspaceManager,
Pm2ProcessManager, Pm2ProcessManager,
PnpmBuildRunner, PnpmBuildRunner,
createReleaseBuildRunner,
} from '@sammo-ts/gateway-api'; } from '@sammo-ts/gateway-api';
import { resolveReleaseControllerConfig } from './config.js'; import { resolveReleaseControllerConfig } from './config.js';
@@ -28,6 +29,7 @@ const main = async (): Promise<void> => {
baseEnv: config.baseEnv, baseEnv: config.baseEnv,
}); });
const buildRunner = new PnpmBuildRunner(); const buildRunner = new PnpmBuildRunner();
const releaseBuildRunner = createReleaseBuildRunner(config.releaseBuilderUrl, buildRunner);
const processManager = new Pm2ProcessManager(); const processManager = new Pm2ProcessManager();
const controller = new GatewayReleaseController(repository, workspaceManager, buildRunner, processManager, config); const controller = new GatewayReleaseController(repository, workspaceManager, buildRunner, processManager, config);
const command = process.argv[2] ?? 'daemon'; const command = process.argv[2] ?? 'daemon';
@@ -57,7 +59,8 @@ const main = async (): Promise<void> => {
sourceMode, sourceMode,
sourceRef, sourceRef,
workspaceManager, workspaceManager,
buildRunner, buildRunner: releaseBuildRunner,
migrationRunner: buildRunner,
processManager, processManager,
config, config,
}); });
+105 -30
View File
@@ -17,8 +17,11 @@ import {
type GitWorkspaceManager, type GitWorkspaceManager,
type ProcessDefinition, type ProcessDefinition,
type ProcessManager, type ProcessManager,
createReleaseBuildRunner,
FrontendArtifactManager,
readReleaseManifest, readReleaseManifest,
sanitizeManagedProcessEnv, sanitizeManagedProcessEnv,
sanitizeReleaseBuildEnv,
} from '@sammo-ts/gateway-api'; } from '@sammo-ts/gateway-api';
import { resolvePostgresPoolMax } from '@sammo-ts/infra'; import { resolvePostgresPoolMax } from '@sammo-ts/infra';
@@ -27,7 +30,7 @@ import type { ReleaseControllerConfig } from './config.js';
const LEASE_DURATION_MS = 10 * 60_000; const LEASE_DURATION_MS = 10 * 60_000;
const HEARTBEAT_INTERVAL_MS = 60_000; const HEARTBEAT_INTERVAL_MS = 60_000;
const CANCELLATION_POLL_INTERVAL_MS = 500; const CANCELLATION_POLL_INTERVAL_MS = 500;
const PROCESS_NAMES = ['sammo:gateway-api', 'sammo:gateway-frontend', 'sammo:gateway-orchestrator'] as const; const MANAGED_PROCESS_NAMES = ['sammo:gateway-api', 'sammo:gateway-frontend', 'sammo:gateway-orchestrator'] as const;
const SENSITIVE_ENV_NAME = /(SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE_KEY|CLIENT_SECRET|DATABASE_URL|REDIS_URL)/iu; const SENSITIVE_ENV_NAME = /(SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE_KEY|CLIENT_SECRET|DATABASE_URL|REDIS_URL)/iu;
export const RELEASE_WORKSPACE_CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1_000; export const RELEASE_WORKSPACE_CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1_000;
@@ -48,13 +51,14 @@ const buildGatewayReleaseCommands = (
needsInstall: boolean, needsInstall: boolean,
config: ReleaseControllerConfig config: ReleaseControllerConfig
): BuildCommand[] => { ): BuildCommand[] => {
const env = { const env = sanitizeReleaseBuildEnv({
...sanitizeManagedProcessEnv(config.baseEnv), ...config.baseEnv,
NODE_OPTIONS: config.baseEnv.RELEASE_BUILD_NODE_OPTIONS ?? config.baseEnv.NODE_OPTIONS,
VITE_APP_BASE_PATH: config.gatewayBasePath, VITE_APP_BASE_PATH: config.gatewayBasePath,
VITE_GATEWAY_API_URL: `${config.gatewayBasePath}/api/trpc`, VITE_GATEWAY_API_URL: `${config.gatewayBasePath}/api/trpc`,
VITE_GAME_API_URL_TEMPLATE: '/{profile}/api/trpc', VITE_GAME_API_URL_TEMPLATE: '/{profile}/api/trpc',
VITE_GAME_WEB_URL_TEMPLATE: '/{profile}/', VITE_GAME_WEB_URL_TEMPLATE: '/{profile}/',
}; });
return [ return [
...(needsInstall ? [{ command: 'pnpm', args: ['install', '--frozen-lockfile'], cwd: workspaceRoot, env }] : []), ...(needsInstall ? [{ command: 'pnpm', args: ['install', '--frozen-lockfile'], cwd: workspaceRoot, env }] : []),
buildTurboReleaseCommand(workspaceRoot, config.workspaceRoot, ['@sammo-ts/gateway-api'], env), buildTurboReleaseCommand(workspaceRoot, config.workspaceRoot, ['@sammo-ts/gateway-api'], env),
@@ -97,7 +101,7 @@ export const buildGatewayProcessDefinitions = (
GATEWAY_API_PORT: String(config.gatewayApiPort), GATEWAY_API_PORT: String(config.gatewayApiPort),
GATEWAY_DATABASE_URL: config.gatewayDatabaseUrl, GATEWAY_DATABASE_URL: config.gatewayDatabaseUrl,
}; };
return [ const definitions: ProcessDefinition[] = [
{ {
name: 'sammo:gateway-api', name: 'sammo:gateway-api',
script: apiScript, script: apiScript,
@@ -108,13 +112,6 @@ export const buildGatewayProcessDefinitions = (
GATEWAY_ROLE: 'api', GATEWAY_ROLE: 'api',
}, },
}, },
{
name: 'sammo:gateway-frontend',
script: frontendScript,
cwd: frontendCwd,
args: ['preview', '--host', '0.0.0.0', '--port', String(config.gatewayFrontendPort)],
env,
},
{ {
name: 'sammo:gateway-orchestrator', name: 'sammo:gateway-orchestrator',
script: apiScript, script: apiScript,
@@ -126,6 +123,16 @@ export const buildGatewayProcessDefinitions = (
}, },
}, },
]; ];
if (config.frontendServeMode !== 'static') {
definitions.splice(1, 0, {
name: 'sammo:gateway-frontend',
script: frontendScript,
cwd: frontendCwd,
args: ['preview', '--host', '0.0.0.0', '--port', String(config.gatewayFrontendPort)],
env,
});
}
return definitions;
}; };
const isMissingProcessError = (error: unknown): boolean => const isMissingProcessError = (error: unknown): boolean =>
@@ -133,6 +140,8 @@ const isMissingProcessError = (error: unknown): boolean =>
export class GatewayReleaseController { export class GatewayReleaseController {
private readonly ownerId = randomUUID(); private readonly ownerId = randomUUID();
private readonly releaseBuildRunner: BuildRunner;
private readonly artifactManager: FrontendArtifactManager;
constructor( constructor(
private readonly repository: GatewayReleaseRepository, private readonly repository: GatewayReleaseRepository,
@@ -142,7 +151,10 @@ export class GatewayReleaseController {
private readonly config: ReleaseControllerConfig, private readonly config: ReleaseControllerConfig,
private readonly now: () => Date = () => new Date(), private readonly now: () => Date = () => new Date(),
private readonly fetchImpl: typeof fetch = fetch private readonly fetchImpl: typeof fetch = fetch
) {} ) {
this.releaseBuildRunner = createReleaseBuildRunner(config.releaseBuilderUrl, buildRunner, fetchImpl);
this.artifactManager = new FrontendArtifactManager(config.frontendArtifactRoot ?? '/srv/frontend-artifacts');
}
async cleanupStaleWorkspaces(): Promise<{ removed: string[]; skipped: string[] }> { async cleanupStaleWorkspaces(): Promise<{ removed: string[]; skipped: string[] }> {
const [state, processes, workspaces] = await Promise.all([ const [state, processes, workspaces] = await Promise.all([
@@ -317,12 +329,20 @@ export class GatewayReleaseController {
const manifest = await readReleaseManifest(workspace.root); const manifest = await readReleaseManifest(workspace.root);
assertReleaseComponents(manifest, ['gateway-api', 'gateway-frontend']); assertReleaseComponents(manifest, ['gateway-api', 'gateway-frontend']);
await this.appendLog(operation.id, 'build', 'Gateway 구성 요소를 빌드합니다.'); await this.appendLog(operation.id, 'build', 'Gateway 구성 요소를 빌드합니다.');
const build = await this.buildRunner.run( const build = await this.releaseBuildRunner.run(
buildGatewayReleaseCommands(workspace.root, workspace.needsInstall, this.config), buildGatewayReleaseCommands(workspace.root, workspace.needsInstall, this.config),
this.buildProgress(operation.id, 'build'), this.buildProgress(operation.id, 'build'),
{ signal } { signal }
); );
if (!build.ok) throw new Error(`Gateway release build failed: ${build.output.slice(-4000)}`); if (!build.ok) throw new Error(`Gateway release build failed: ${build.output.slice(-4000)}`);
const stagedArtifact =
this.config.frontendServeMode === 'static'
? await this.artifactManager.stage({
frontendKey: 'gateway',
sourceRoot: path.join(workspace.root, 'app', 'gateway-frontend', 'dist'),
commitSha,
})
: null;
await this.appendLog(operation.id, 'migration', 'Gateway database migration을 적용합니다.'); await this.appendLog(operation.id, 'migration', 'Gateway database migration을 적용합니다.');
await this.assertOperationLease(operation.id); await this.assertOperationLease(operation.id);
const migration = await this.buildRunner.run( const migration = await this.buildRunner.run(
@@ -339,8 +359,15 @@ export class GatewayReleaseController {
await this.appendLog(operation.id, 'switch', '기존 Gateway process를 정지합니다.'); await this.appendLog(operation.id, 'switch', '기존 Gateway process를 정지합니다.');
await this.assertOperationLease(operation.id); await this.assertOperationLease(operation.id);
await this.stopManagedProcesses(operation.id); await this.stopManagedProcesses(operation.id);
const previousArtifactReleaseId =
this.config.frontendServeMode === 'static'
? await this.artifactManager.readCurrentReleaseId('gateway')
: null;
try { try {
await this.startDefinitions(buildGatewayProcessDefinitions(workspace.root, this.config), operation.id); await this.startDefinitions(buildGatewayProcessDefinitions(workspace.root, this.config), operation.id);
if (stagedArtifact) {
await this.artifactManager.activate('gateway', stagedArtifact.releaseId);
}
await this.waitForReadiness(operation.id); await this.waitForReadiness(operation.id);
} catch (error) { } catch (error) {
await this.appendLog( await this.appendLog(
@@ -350,6 +377,13 @@ export class GatewayReleaseController {
'ERROR' 'ERROR'
); );
await this.stopManagedProcesses(operation.id); await this.stopManagedProcesses(operation.id);
if (this.config.frontendServeMode === 'static') {
if (previousArtifactReleaseId) {
await this.artifactManager.activate('gateway', previousArtifactReleaseId);
} else {
await this.artifactManager.deactivate('gateway');
}
}
if (previousDefinitions.length) { if (previousDefinitions.length) {
await this.startDefinitions(previousDefinitions, operation.id); await this.startDefinitions(previousDefinitions, operation.id);
await this.waitForReadiness(operation.id); await this.waitForReadiness(operation.id);
@@ -357,12 +391,43 @@ export class GatewayReleaseController {
throw error; throw error;
} }
await this.appendLog(operation.id, 'publish', '검증된 Gateway 릴리스를 active 상태로 게시합니다.'); await this.appendLog(operation.id, 'publish', '검증된 Gateway 릴리스를 active 상태로 게시합니다.');
await this.repository.publishRelease(operation.id, this.ownerId, { const activeReleaseGitRef = this.config.activeReleaseGitRef;
commitSha, const previousPersistentCommit = activeReleaseGitRef
workspace: workspace.root, ? await this.workspaceManager.readPersistentReleaseRef(activeReleaseGitRef)
previousCommitSha: state.activeCommitSha, : null;
previousWorkspace: state.activeWorkspace, if (activeReleaseGitRef) {
}); await this.workspaceManager.compareAndSwapPersistentReleaseRef(
activeReleaseGitRef,
previousPersistentCommit,
commitSha
);
}
try {
await this.repository.publishRelease(operation.id, this.ownerId, {
commitSha,
workspace: workspace.root,
previousCommitSha: state.activeCommitSha,
previousWorkspace: state.activeWorkspace,
});
} catch (error) {
if (activeReleaseGitRef) {
try {
await this.workspaceManager.compareAndSwapPersistentReleaseRef(
activeReleaseGitRef,
commitSha,
previousPersistentCommit
);
} catch (rollbackError) {
await this.appendLog(
operation.id,
'rollback',
`Gateway bootstrap ref 복구 실패: ${String(rollbackError)}`,
'ERROR'
);
}
}
throw error;
}
} }
private async startDefinitions(definitions: ProcessDefinition[], operationId: string): Promise<void> { private async startDefinitions(definitions: ProcessDefinition[], operationId: string): Promise<void> {
@@ -388,7 +453,7 @@ export class GatewayReleaseController {
private async stopManagedProcesses(operationId: string): Promise<void> { private async stopManagedProcesses(operationId: string): Promise<void> {
const existing = new Set((await this.processManager.list()).map((process) => process.name)); const existing = new Set((await this.processManager.list()).map((process) => process.name));
const failures: string[] = []; const failures: string[] = [];
for (const name of [...PROCESS_NAMES].reverse()) { for (const name of [...MANAGED_PROCESS_NAMES].reverse()) {
if (!existing.has(name)) continue; if (!existing.has(name)) continue;
await this.appendLog(operationId, 'switch', `${name} process를 정리합니다.`); await this.appendLog(operationId, 'switch', `${name} process를 정리합니다.`);
try { try {
@@ -406,26 +471,36 @@ export class GatewayReleaseController {
} }
private async waitForReadiness(operationId: string): Promise<void> { private async waitForReadiness(operationId: string): Promise<void> {
await this.appendLog(operationId, 'readiness', 'Gateway API, frontend와 PM2 process readiness를 확인합니다.'); await this.appendLog(operationId, 'readiness', 'Gateway API, 정적 frontend와 PM2 process readiness를 확인합니다.');
const deadline = Date.now() + this.config.readinessTimeoutMs; const deadline = Date.now() + this.config.readinessTimeoutMs;
const apiUrl = `http://127.0.0.1:${this.config.gatewayApiPort}/healthz`; const apiUrl = `http://127.0.0.1:${this.config.gatewayApiPort}/healthz`;
const frontendUrl = `http://127.0.0.1:${this.config.gatewayFrontendPort}${this.config.gatewayBasePath}/`; const frontendUrl =
this.config.frontendServeMode === 'static'
? new URL(
`${this.config.gatewayBasePath.replace(/\/$/u, '')}/`,
this.config.frontendReadinessOrigin ?? 'http://caddy'
).toString()
: `http://127.0.0.1:${this.config.gatewayFrontendPort}${this.config.gatewayBasePath}/`;
const expectedNames = buildGatewayProcessDefinitions(this.config.workspaceRoot, this.config).map(
(definition) => definition.name
);
while (Date.now() < deadline) { while (Date.now() < deadline) {
try { try {
const [api, frontend] = await Promise.all([this.fetchImpl(apiUrl), this.fetchImpl(frontendUrl)]); const [api, frontend] = await Promise.all([
this.fetchImpl(apiUrl),
this.fetchImpl(frontendUrl),
]);
const processes = await this.processManager.list(); const processes = await this.processManager.list();
const expected = processes.filter((process) => const expected = processes.filter((process) => expectedNames.includes(process.name));
PROCESS_NAMES.includes(process.name as (typeof PROCESS_NAMES)[number])
);
const safe = expected.filter( const safe = expected.filter(
(process) => process.status.toLowerCase() === 'online' && (process.restartCount ?? 0) === 0 (process) => process.status.toLowerCase() === 'online' && (process.restartCount ?? 0) === 0
); );
if ( if (
api.ok && api.ok &&
frontend.ok && frontend.ok &&
expected.length === PROCESS_NAMES.length && expected.length === expectedNames.length &&
safe.length === PROCESS_NAMES.length && safe.length === expectedNames.length &&
new Set(safe.map((process) => process.name)).size === PROCESS_NAMES.length new Set(safe.map((process) => process.name)).size === expectedNames.length
) { ) {
await this.appendLog(operationId, 'readiness', 'Gateway readiness 확인을 통과했습니다.'); await this.appendLog(operationId, 'readiness', 'Gateway readiness 확인을 통과했습니다.');
return; return;
+6 -2
View File
@@ -10,6 +10,7 @@ import {
type ProcessManager, type ProcessManager,
readReleaseManifest, readReleaseManifest,
sanitizeManagedProcessEnv, sanitizeManagedProcessEnv,
sanitizeReleaseBuildEnv,
} from '@sammo-ts/gateway-api'; } from '@sammo-ts/gateway-api';
import type { ReleaseControllerConfig } from './config.js'; import type { ReleaseControllerConfig } from './config.js';
@@ -22,7 +23,7 @@ const buildReleaseControllerCommands = (
needsInstall: boolean, needsInstall: boolean,
config: ReleaseControllerConfig config: ReleaseControllerConfig
): BuildCommand[] => { ): BuildCommand[] => {
const env = sanitizeManagedProcessEnv(config.baseEnv); const env = sanitizeReleaseBuildEnv(config.baseEnv);
return [ return [
...(needsInstall ? [{ command: 'pnpm', args: ['install', '--frozen-lockfile'], cwd: workspaceRoot, env }] : []), ...(needsInstall ? [{ command: 'pnpm', args: ['install', '--frozen-lockfile'], cwd: workspaceRoot, env }] : []),
buildTurboReleaseCommand(workspaceRoot, config.workspaceRoot, ['@sammo-ts/release-controller'], env), buildTurboReleaseCommand(workspaceRoot, config.workspaceRoot, ['@sammo-ts/release-controller'], env),
@@ -56,6 +57,7 @@ export const upgradeReleaseController = async (options: {
sourceRef: string; sourceRef: string;
workspaceManager: GitWorkspaceManager; workspaceManager: GitWorkspaceManager;
buildRunner: BuildRunner; buildRunner: BuildRunner;
migrationRunner?: BuildRunner;
processManager: ProcessManager; processManager: ProcessManager;
config: ReleaseControllerConfig; config: ReleaseControllerConfig;
readinessTimeoutMs?: number; readinessTimeoutMs?: number;
@@ -71,7 +73,9 @@ export const upgradeReleaseController = async (options: {
buildReleaseControllerCommands(workspace.root, workspace.needsInstall, options.config) buildReleaseControllerCommands(workspace.root, workspace.needsInstall, options.config)
); );
if (!build.ok) throw new Error(`Release controller build failed: ${build.output.slice(-4000)}`); if (!build.ok) throw new Error(`Release controller build failed: ${build.output.slice(-4000)}`);
const migration = await options.buildRunner.run([buildGatewayMigrationCommand(workspace.root, options.config)]); const migration = await (options.migrationRunner ?? options.buildRunner).run([
buildGatewayMigrationCommand(workspace.root, options.config),
]);
if (!migration.ok) throw new Error(`Gateway migration failed: ${migration.output.slice(-4000)}`); if (!migration.ok) throw new Error(`Gateway migration failed: ${migration.output.slice(-4000)}`);
const existing = (await options.processManager.list()).find((process) => process.name === CONTROLLER_PROCESS_NAME); const existing = (await options.processManager.list()).find((process) => process.name === CONTROLLER_PROCESS_NAME);
@@ -43,6 +43,15 @@ const createReleaseWorkspace = async (): Promise<string> => {
components: ['gateway-api', 'gateway-frontend', 'release-controller'], components: ['gateway-api', 'gateway-frontend', 'release-controller'],
}) })
); );
await fs.mkdir(path.join(workspace, 'app', 'gateway-frontend', 'dist', 'assets'), { recursive: true });
await fs.writeFile(
path.join(workspace, 'app', 'gateway-frontend', 'dist', 'index.html'),
'<!doctype html><title>static gateway</title>'
);
await fs.writeFile(
path.join(workspace, 'app', 'gateway-frontend', 'dist', 'assets', 'app-deadbeef.js'),
'console.log("gateway")'
);
return workspace; return workspace;
}; };
@@ -148,6 +157,17 @@ it('runs Gateway preview from the frontend workspace dependency', () => {
); );
}); });
it('omits the Vite preview process when static artifact serving is selected', () => {
const definitions = buildGatewayProcessDefinitions('/srv/sammo/release', {
...config,
frontendServeMode: 'static',
});
expect(definitions.map((definition) => definition.name)).toEqual([
'sammo:gateway-api',
'sammo:gateway-orchestrator',
]);
});
it('does not forward release-controller PM2 identity to Gateway processes', () => { it('does not forward release-controller PM2 identity to Gateway processes', () => {
const definitions = buildGatewayProcessDefinitions('/srv/sammo/release', { const definitions = buildGatewayProcessDefinitions('/srv/sammo/release', {
...config, ...config,
@@ -181,6 +201,64 @@ it('rejects Gateway definitions before switching processes when Redis connection
}); });
describe('GatewayReleaseController', () => { describe('GatewayReleaseController', () => {
it('publishes the built Gateway frontend through the artifact pointer in static mode', async () => {
const workspace = await createReleaseWorkspace();
const harness = createRepository();
const running = new Map<string, string>();
const artifactRoot = path.join(workspace, 'artifact-volume');
const requests: string[] = [];
const releaseRefUpdates: Array<{ expected: string | null; next: string | null }> = [];
const controller = new GatewayReleaseController(
harness.repository,
{
resolveCommit: async () => SHA,
prepare: async () => ({ root: workspace, created: true, needsInstall: false }),
readPersistentReleaseRef: async () => OLD_SHA,
compareAndSwapPersistentReleaseRef: async (
_ref: string,
expected: string | null,
next: string | null
) => {
releaseRefUpdates.push({ expected, next });
},
} as unknown as GitWorkspaceManager,
{ run: async () => ({ ok: true, exitCode: 0, output: '' }) },
{
list: async () =>
[...running].map(([name, cwd]) => ({ name, cwd, status: 'online', restartCount: 0 })),
start: async (definition) => {
running.set(definition.name, definition.cwd);
},
stop: async () => {},
delete: async (name) => {
running.delete(name);
},
},
{
...config,
frontendServeMode: 'static',
frontendArtifactRoot: artifactRoot,
frontendReadinessOrigin: 'http://caddy',
activeReleaseGitRef: 'refs/sammo/active-gateway',
},
() => new Date('2026-08-01T00:00:00.000Z'),
async (input) => {
requests.push(String(input));
return new Response('', { status: 200 });
}
);
await controller.runOnce();
expect([...running.keys()].sort()).toEqual(['sammo:gateway-api', 'sammo:gateway-orchestrator']);
expect(await fs.readFile(path.join(artifactRoot, 'gateway', 'current', 'index.html'), 'utf8')).toContain(
'static gateway'
);
expect(requests).toContain('http://caddy/gateway/');
expect(releaseRefUpdates).toEqual([{ expected: OLD_SHA, next: SHA }]);
expect(harness.completions).toEqual(['SUCCEEDED']);
});
it('protects active, rollback, and running-process worktrees while delegating bounded cleanup', async () => { it('protects active, rollback, and running-process worktrees while delegating bounded cleanup', async () => {
const active = '/srv/sammo/releases/active'; const active = '/srv/sammo/releases/active';
const previous = '/srv/sammo/releases/previous'; const previous = '/srv/sammo/releases/previous';
@@ -0,0 +1,38 @@
#!/usr/bin/env node
import { FrontendArtifactManager } from '../../app/gateway-api/dist/index.js';
const readOptions = (args) => {
const options = new Map();
for (let index = 0; index < args.length; index += 2) {
const name = args[index];
const value = args[index + 1];
if (!name?.startsWith('--') || !value) {
throw new Error(
'usage: publish-frontend-artifact --artifact-root PATH --frontend-key KEY --source-root PATH --commit-sha SHA'
);
}
options.set(name.slice(2), value);
}
for (const required of ['artifact-root', 'frontend-key', 'source-root', 'commit-sha']) {
if (!options.get(required)) throw new Error(`--${required} is required`);
}
return options;
};
const options = readOptions(process.argv.slice(2));
const manager = new FrontendArtifactManager(options.get('artifact-root'));
const result = await manager.stageAndActivate({
frontendKey: options.get('frontend-key'),
sourceRoot: options.get('source-root'),
commitSha: options.get('commit-sha'),
});
console.log(
JSON.stringify({
frontendKey: result.manifest.frontendKey,
commitSha: result.manifest.commitSha,
digest: result.manifest.digest,
releaseId: result.releaseId,
previousReleaseId: result.previousReleaseId,
})
);