feat(release): 정적 프런트엔드와 격리 빌더를 도입한다
Gateway와 프로필 릴리스가 불변 프런트엔드 아티팩트를 원자적으로 게시하고 실패 시 이전 포인터를 복구하도록 한다. 릴리스 빌드는 전용 builder protocol로 분리하고 성공한 Gateway 커밋을 재기동용 Git ref에 고정한다.
This commit is contained in:
@@ -33,6 +33,22 @@ export interface BuildRunner {
|
||||
|
||||
export const MAX_BUILD_OUTPUT_CHARS = 64 * 1024;
|
||||
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 => {
|
||||
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 BuildProgressObserver,
|
||||
type BuildRunner,
|
||||
createReleaseBuildRunner,
|
||||
sanitizeReleaseBuildEnv,
|
||||
} from './buildRunner.js';
|
||||
import { sanitizeManagedProcessEnv, type ProcessManager } from './processManager.js';
|
||||
import type {
|
||||
@@ -51,12 +53,21 @@ import {
|
||||
} from './workspaceManager.js';
|
||||
import type { AdminSeedUser } from './seedProfileDatabase.js';
|
||||
import { assertReleaseComponents, readReleaseManifest } from './releaseManifest.js';
|
||||
import {
|
||||
FrontendArtifactManager,
|
||||
resolveFrontendServeMode,
|
||||
type FrontendServeMode,
|
||||
} from './frontendArtifactManager.js';
|
||||
|
||||
export interface GatewayProcessConfig {
|
||||
workspaceRoot: string;
|
||||
redisKeyPrefix: string;
|
||||
gameTokenSecret: string;
|
||||
gatewayInternalApiUrl: string;
|
||||
frontendServeMode?: FrontendServeMode;
|
||||
frontendArtifactRoot?: string;
|
||||
frontendReadinessOrigin?: string;
|
||||
releaseBuilderUrl?: string;
|
||||
baseEnv?: Record<string, string>;
|
||||
}
|
||||
|
||||
@@ -567,14 +578,14 @@ export const buildProfileFrontendCommands = (
|
||||
throw new Error('Profile frontend build requires a full commit SHA.');
|
||||
}
|
||||
const profileFrontendBuildNodeOptions = env?.PROFILE_FRONTEND_BUILD_NODE_OPTIONS?.trim();
|
||||
const buildEnv = {
|
||||
const buildEnv = sanitizeReleaseBuildEnv({
|
||||
...(env ?? {}),
|
||||
...(profileFrontendBuildNodeOptions ? { NODE_OPTIONS: profileFrontendBuildNodeOptions } : {}),
|
||||
VITE_APP_BASE_PATH: `/${profile.profile}`,
|
||||
VITE_GAME_API_URL: `/${profile.profile}/api/trpc`,
|
||||
VITE_GAME_SSE_URL: `/${profile.profile}/api/events`,
|
||||
VITE_BUILD_COMMIT_SHA: buildCommitSha.trim().toLowerCase(),
|
||||
};
|
||||
});
|
||||
return [
|
||||
buildTurboReleaseTaskCommand(
|
||||
workspaceRoot,
|
||||
@@ -599,16 +610,17 @@ export const buildWorkspaceCommands = (
|
||||
cacheAnchorRoot: string = workspaceRoot,
|
||||
packageNames: string[] = ['@sammo-ts/game-api', '@sammo-ts/gateway-api']
|
||||
): BuildCommand[] => {
|
||||
const buildEnv = sanitizeReleaseBuildEnv(env);
|
||||
const commands: BuildCommand[] = [];
|
||||
if (needsInstall) {
|
||||
commands.push({
|
||||
command: 'pnpm',
|
||||
args: ['install', '--frozen-lockfile'],
|
||||
cwd: workspaceRoot,
|
||||
env,
|
||||
env: buildEnv,
|
||||
});
|
||||
}
|
||||
commands.push(buildTurboReleaseCommand(workspaceRoot, cacheAnchorRoot, packageNames, env));
|
||||
commands.push(buildTurboReleaseCommand(workspaceRoot, cacheAnchorRoot, packageNames, buildEnv));
|
||||
return commands;
|
||||
};
|
||||
|
||||
@@ -646,8 +658,11 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
private readonly repository: GatewayProfileRepository;
|
||||
private readonly processManager: ProcessManager;
|
||||
private readonly buildRunner: BuildRunner;
|
||||
private readonly releaseBuildRunner: BuildRunner;
|
||||
private readonly workspaceManager: GitWorkspaceManager;
|
||||
private readonly processConfig: GatewayProcessConfig;
|
||||
private readonly frontendServeMode: FrontendServeMode;
|
||||
private readonly artifactManager: FrontendArtifactManager;
|
||||
private readonly reconcileIntervalMs: number;
|
||||
private readonly scheduleIntervalMs: number;
|
||||
private readonly buildIntervalMs: number;
|
||||
@@ -679,8 +694,17 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
this.repository = options.repository;
|
||||
this.processManager = options.processManager;
|
||||
this.buildRunner = options.buildRunner;
|
||||
this.releaseBuildRunner = createReleaseBuildRunner(
|
||||
options.processConfig.releaseBuilderUrl,
|
||||
options.buildRunner,
|
||||
options.fetchImpl ?? fetch
|
||||
);
|
||||
this.workspaceManager = options.workspaceManager;
|
||||
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.scheduleIntervalMs = options.scheduleIntervalMs;
|
||||
this.buildIntervalMs = options.buildIntervalMs;
|
||||
@@ -802,7 +826,17 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
|
||||
async listRuntimeStates(profileNames: string[]): Promise<ProfileRuntimeSnapshot[]> {
|
||||
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[]> {
|
||||
@@ -885,6 +919,10 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
continue;
|
||||
}
|
||||
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);
|
||||
if (plan.shouldStart) {
|
||||
await this.startProfile(profile);
|
||||
@@ -1521,7 +1559,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
),
|
||||
];
|
||||
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,
|
||||
});
|
||||
if (!result.ok) {
|
||||
@@ -1933,6 +1971,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
currentScenario: String(scenarioId),
|
||||
status: desiredStatus,
|
||||
buildStatus: 'SUCCEEDED',
|
||||
buildCommitSha: commitSha,
|
||||
buildWorkspace: workspace.root,
|
||||
buildLastUsedAt: completedAt,
|
||||
buildCompletedAt: completedAt,
|
||||
@@ -1970,6 +2009,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
currentScenario: String(scenarioId),
|
||||
scenario: String(scenarioId),
|
||||
status: desiredStatus,
|
||||
buildCommitSha: commitSha,
|
||||
buildWorkspace: workspace.root,
|
||||
};
|
||||
await appendLog('switch', '초기화된 profile process를 시작합니다.');
|
||||
@@ -2119,7 +2159,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
);
|
||||
}
|
||||
return {
|
||||
result: await this.buildRunner.run(
|
||||
result: await this.releaseBuildRunner.run(
|
||||
commands,
|
||||
operationId ? this.buildProgress(operationId, 'build') : undefined,
|
||||
{ signal: this.activeOperationAbortSignal }
|
||||
@@ -2281,7 +2321,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
private async startProfile(profile: GatewayProfileRecord, assertLease?: () => Promise<void>): Promise<boolean> {
|
||||
const definitions = buildProcessDefinitions(profile, this.processConfig);
|
||||
const orderedDefinitions = [
|
||||
definitions.frontend,
|
||||
...(this.frontendServeMode === 'preview' ? [definitions.frontend] : []),
|
||||
definitions.api,
|
||||
definitions.daemon,
|
||||
definitions.auction,
|
||||
@@ -2290,10 +2330,26 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
];
|
||||
const attemptedNames: string[] = [];
|
||||
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 obsoleteNames =
|
||||
this.frontendServeMode === 'static' ? new Set([definitions.frontend.name]) : new Set<string>();
|
||||
const existingNames = new Set(
|
||||
(await this.processManager.list())
|
||||
.filter((process) => expectedNames.has(process.name))
|
||||
.filter((process) => expectedNames.has(process.name) || obsoleteNames.has(process.name))
|
||||
.map((process) => process.name)
|
||||
);
|
||||
for (const name of existingNames) {
|
||||
@@ -2307,6 +2363,9 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
await this.processManager.start(definition);
|
||||
await assertLease?.();
|
||||
}
|
||||
if (stagedArtifact) {
|
||||
await this.artifactManager.activate(profile.profile, stagedArtifact.releaseId);
|
||||
}
|
||||
if (!assertLease) {
|
||||
await this.repository.updateLastError(profile.profileName, null);
|
||||
}
|
||||
@@ -2341,9 +2400,17 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
): Promise<boolean> {
|
||||
const deadline = Date.now() + this.profileReadinessTimeoutMs;
|
||||
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 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) {
|
||||
await assertLease?.();
|
||||
try {
|
||||
@@ -2381,6 +2448,10 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
const battleSimName = buildProcessName(profile.profileName, 'battle-sim');
|
||||
const tournamentName = buildProcessName(profile.profileName, 'tournament');
|
||||
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));
|
||||
await assertLease?.();
|
||||
const failures: string[] = [];
|
||||
|
||||
@@ -41,6 +41,10 @@ export const createGatewayOrchestrator = (
|
||||
redisKeyPrefix: config.redisKeyPrefix,
|
||||
gameTokenSecret: config.gameTokenSecret,
|
||||
gatewayInternalApiUrl: config.gatewayInternalApiUrl,
|
||||
frontendServeMode: config.frontendServeMode,
|
||||
frontendArtifactRoot: config.frontendArtifactRoot,
|
||||
frontendReadinessOrigin: config.frontendReadinessOrigin,
|
||||
releaseBuilderUrl: config.releaseBuilderUrl,
|
||||
baseEnv,
|
||||
},
|
||||
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 GIT_REF_PATTERN = /^[0-9A-Za-z._/-]+$/;
|
||||
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 ref = value.trim();
|
||||
@@ -123,6 +125,47 @@ export class GitWorkspaceManager {
|
||||
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> {
|
||||
if (!COMMIT_SHA_PATTERN.test(commitSha)) {
|
||||
throw new Error('Invalid commit SHA.');
|
||||
|
||||
Reference in New Issue
Block a user