feat(release): 정적 프런트엔드와 격리 빌더를 도입한다
Gateway와 프로필 릴리스가 불변 프런트엔드 아티팩트를 원자적으로 게시하고 실패 시 이전 포인터를 복구하도록 한다. 릴리스 빌드는 전용 builder protocol로 분리하고 성공한 Gateway 커밋을 재기동용 Git ref에 고정한다.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import path from 'node:path';
|
||||
import { parseBooleanWithFallback, parseNumberWithFallback } from '@sammo-ts/common';
|
||||
import { resolveFrontendServeMode, type FrontendServeMode } from './orchestrator/frontendArtifactManager.js';
|
||||
|
||||
export interface GatewayApiConfig {
|
||||
host: string;
|
||||
@@ -36,6 +37,10 @@ export interface GatewayApiConfig {
|
||||
worktreeRoot: string;
|
||||
navigationConfigFile: string | null;
|
||||
defaultNavigationConfigFile: string;
|
||||
frontendServeMode: FrontendServeMode;
|
||||
frontendArtifactRoot: string;
|
||||
frontendReadinessOrigin: string;
|
||||
releaseBuilderUrl?: string;
|
||||
}
|
||||
|
||||
export interface GatewayOrchestratorConfig {
|
||||
@@ -49,6 +54,10 @@ export interface GatewayOrchestratorConfig {
|
||||
orchestratorAdminIntervalMs: number;
|
||||
workspaceRootHint: string;
|
||||
worktreeRoot: string;
|
||||
frontendServeMode?: FrontendServeMode;
|
||||
frontendArtifactRoot?: string;
|
||||
frontendReadinessOrigin?: string;
|
||||
releaseBuilderUrl?: 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'),
|
||||
navigationConfigFile: env.CORE_NAVIGATION_CONFIG_FILE?.trim() || '/srv/data/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(),
|
||||
worktreeRoot:
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ export * from './orchestrator/buildRunner.js';
|
||||
export * from './orchestrator/processManager.js';
|
||||
export * from './orchestrator/pm2ProcessManager.js';
|
||||
export * from './orchestrator/releaseManifest.js';
|
||||
export * from './orchestrator/frontendArtifactManager.js';
|
||||
export * from './auth/userRepository.js';
|
||||
export * from './auth/passwordHasher.js';
|
||||
export * from './auth/inMemoryUserRepository.js';
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { FrontendArtifactManager, resolveFrontendServeMode } from '../src/orchestrator/frontendArtifactManager.js';
|
||||
|
||||
const roots: string[] = [];
|
||||
const sha = 'a'.repeat(40);
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
const fixture = async (): Promise<{ source: string; artifacts: string }> => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-frontend-artifact-'));
|
||||
roots.push(root);
|
||||
const source = path.join(root, 'dist');
|
||||
const artifacts = path.join(root, 'artifacts');
|
||||
await fs.mkdir(path.join(source, 'assets'), { recursive: true });
|
||||
await fs.writeFile(path.join(source, 'index.html'), '<div>one</div>');
|
||||
await fs.writeFile(path.join(source, 'assets', 'app-deadbeef.js'), 'console.log(1)');
|
||||
return { source, artifacts };
|
||||
};
|
||||
|
||||
describe('resolveFrontendServeMode', () => {
|
||||
it('keeps preview as the compatibility default and accepts explicit static mode', () => {
|
||||
expect(resolveFrontendServeMode(undefined)).toBe('preview');
|
||||
expect(resolveFrontendServeMode('preview')).toBe('preview');
|
||||
expect(resolveFrontendServeMode('STATIC')).toBe('static');
|
||||
expect(() => resolveFrontendServeMode('server')).toThrow(/preview or static/u);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FrontendArtifactManager', () => {
|
||||
it('stages immutable releases and atomically switches current and previous pointers', async () => {
|
||||
const { source, artifacts } = await fixture();
|
||||
const manager = new FrontendArtifactManager(artifacts);
|
||||
const first = await manager.stageAndActivate({ frontendKey: 'gateway', sourceRoot: source, commitSha: sha });
|
||||
expect(await manager.readCurrentReleaseId('gateway')).toBe(first.releaseId);
|
||||
expect(await fs.readFile(path.join(artifacts, 'gateway', 'current', 'index.html'), 'utf8')).toContain('one');
|
||||
|
||||
await fs.writeFile(path.join(source, 'index.html'), '<div>two</div>');
|
||||
const second = await manager.stageAndActivate({
|
||||
frontendKey: 'gateway',
|
||||
sourceRoot: source,
|
||||
commitSha: 'b'.repeat(40),
|
||||
});
|
||||
expect(second.previousReleaseId).toBe(first.releaseId);
|
||||
expect(await fs.readFile(path.join(artifacts, 'gateway', 'current', 'index.html'), 'utf8')).toContain('two');
|
||||
expect(await fs.readFile(path.join(artifacts, 'gateway', 'previous', 'index.html'), 'utf8')).toContain('one');
|
||||
expect(await fs.readFile(path.join(first.releasePath, 'index.html'), 'utf8')).toContain('one');
|
||||
});
|
||||
|
||||
it('removes only the live pointer when a frontend is stopped', async () => {
|
||||
const { source, artifacts } = await fixture();
|
||||
const manager = new FrontendArtifactManager(artifacts);
|
||||
const staged = await manager.stageAndActivate({ frontendKey: 'che', sourceRoot: source, commitSha: sha });
|
||||
expect(await manager.deactivate('che')).toBe(staged.releaseId);
|
||||
expect(await manager.readCurrentReleaseId('che')).toBeNull();
|
||||
expect(await fs.readFile(path.join(staged.releasePath, 'index.html'), 'utf8')).toContain('one');
|
||||
});
|
||||
|
||||
it('rejects symlinks instead of copying files outside the build output', async () => {
|
||||
const { source, artifacts } = await fixture();
|
||||
await fs.symlink('/etc/passwd', path.join(source, 'assets', 'outside'));
|
||||
const manager = new FrontendArtifactManager(artifacts);
|
||||
await expect(manager.stage({ frontendKey: 'gateway', sourceRoot: source, commitSha: sha })).rejects.toThrow(
|
||||
/symbolic link/u
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { GatewayOrchestrator, type GatewayOrchestratorOptions } from '../src/orchestrator/gatewayOrchestrator.js';
|
||||
import type { ProcessDefinition, ProcessManager } from '../src/orchestrator/processManager.js';
|
||||
@@ -10,6 +14,12 @@ import type {
|
||||
} from '../src/orchestrator/profileRepository.js';
|
||||
import { GitWorkspaceManager } from '../src/orchestrator/workspaceManager.js';
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(temporaryDirectories.splice(0).map((directory) => fs.rm(directory, { recursive: true })));
|
||||
});
|
||||
|
||||
const profile: GatewayProfileRecord = {
|
||||
profileName: 'che:2',
|
||||
profile: 'che',
|
||||
@@ -52,6 +62,9 @@ const createHarness = (
|
||||
reservedToStart?: GatewayProfileRecord[];
|
||||
now?: () => Date;
|
||||
cancelGame?: GatewayOrchestratorOptions['cancelGame'];
|
||||
frontendServeMode?: 'static';
|
||||
frontendArtifactRoot?: string;
|
||||
activeOperationProfileNames?: string[];
|
||||
} = {}
|
||||
) => {
|
||||
const harnessProfile = options.profile ?? profile;
|
||||
@@ -85,7 +98,8 @@ const createHarness = (
|
||||
updateWorkspaceUsage: async () => {},
|
||||
clearWorkspaceUsage: async () => {},
|
||||
listOperations: async () => [],
|
||||
listActiveOperationProfileNames: async () => [harnessProfile.profileName],
|
||||
listActiveOperationProfileNames: async () =>
|
||||
options.activeOperationProfileNames ?? [harnessProfile.profileName],
|
||||
getOperation: async () => operation,
|
||||
listOperationLogs: async () => [],
|
||||
appendOperationLog: async (operationId, input) => {
|
||||
@@ -168,6 +182,8 @@ const createHarness = (
|
||||
redisKeyPrefix: 'sammo:test',
|
||||
gameTokenSecret: 'test-secret',
|
||||
gatewayInternalApiUrl: 'http://127.0.0.1:13000',
|
||||
frontendServeMode: options.frontendServeMode,
|
||||
frontendArtifactRoot: options.frontendArtifactRoot,
|
||||
baseEnv: { DATABASE_URL: 'postgresql://test:test@127.0.0.1:15432/test' },
|
||||
},
|
||||
reconcileIntervalMs: 60_000,
|
||||
@@ -375,6 +391,56 @@ describe('GatewayOrchestrator first-class operations', () => {
|
||||
expect(harness.completions).toEqual(['SUCCEEDED']);
|
||||
});
|
||||
|
||||
it('removes a legacy Vite process while publishing the first static artifact', async () => {
|
||||
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-static-cutover-'));
|
||||
temporaryDirectories.push(workspace);
|
||||
await fs.mkdir(path.join(workspace, '.release-dist', 'che_2', 'game-frontend'), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(workspace, '.release-dist', 'che_2', 'game-frontend', 'index.html'),
|
||||
'<!doctype html><title>static cutover</title>'
|
||||
);
|
||||
const artifactRoot = path.join(workspace, 'artifacts');
|
||||
const staticProfile = { ...profile, status: 'RUNNING' as const, buildWorkspace: workspace };
|
||||
const harness = createHarness(buildOperation('START'), false, false, true, false, undefined, undefined, {
|
||||
profile: staticProfile,
|
||||
frontendServeMode: 'static',
|
||||
frontendArtifactRoot: artifactRoot,
|
||||
activeOperationProfileNames: [],
|
||||
});
|
||||
|
||||
await harness.orchestrator.reconcileNow();
|
||||
|
||||
expect(harness.deleted).toContain('sammo:che:2:game-frontend');
|
||||
expect(harness.started.map((definition) => definition.name)).toEqual([
|
||||
'sammo:che:2:game-api',
|
||||
'sammo:che:2:turn-daemon',
|
||||
'sammo:che:2:auction-worker',
|
||||
'sammo:che:2:battle-sim-worker',
|
||||
'sammo:che:2:tournament-worker',
|
||||
]);
|
||||
expect(await fs.readFile(path.join(artifactRoot, 'che', 'current', 'index.html'), 'utf8')).toContain(
|
||||
'static cutover'
|
||||
);
|
||||
expect(harness.completions).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps legacy processes untouched when the first static artifact cannot be staged', async () => {
|
||||
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-static-cutover-missing-'));
|
||||
temporaryDirectories.push(workspace);
|
||||
const harness = createHarness(buildOperation('START'), false, false, true, false, undefined, undefined, {
|
||||
profile: { ...profile, status: 'RUNNING', buildWorkspace: workspace },
|
||||
frontendServeMode: 'static',
|
||||
frontendArtifactRoot: path.join(workspace, 'artifacts'),
|
||||
activeOperationProfileNames: [],
|
||||
});
|
||||
|
||||
await harness.orchestrator.reconcileNow();
|
||||
|
||||
expect(harness.started).toEqual([]);
|
||||
expect(harness.deleted).toEqual([]);
|
||||
expect(harness.completions).toEqual([]);
|
||||
});
|
||||
|
||||
it('stops every profile process and records success', async () => {
|
||||
const harness = createHarness(buildOperation('STOP'));
|
||||
|
||||
|
||||
@@ -37,6 +37,17 @@ const createReleaseWorkspace = async (): Promise<string> => {
|
||||
components: ['game-api', 'game-engine', 'game-frontend'],
|
||||
})
|
||||
);
|
||||
await fs.mkdir(path.join(workspace, '.release-dist', 'che_1010', 'game-frontend', 'assets'), {
|
||||
recursive: true,
|
||||
});
|
||||
await fs.writeFile(
|
||||
path.join(workspace, '.release-dist', 'che_1010', 'game-frontend', 'index.html'),
|
||||
'<!doctype html><title>static profile</title>'
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(workspace, '.release-dist', 'che_1010', 'game-frontend', 'assets', 'app-deadbeef.js'),
|
||||
'console.log("static")'
|
||||
);
|
||||
return workspace;
|
||||
};
|
||||
|
||||
@@ -45,7 +56,7 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe('profile DEPLOY operation', () => {
|
||||
it('migrates and switches the selected release without executing the reset seed path', async () => {
|
||||
it('migrates and atomically switches a static frontend without executing the reset seed path', async () => {
|
||||
const workspace = await createReleaseWorkspace();
|
||||
const profile: GatewayProfileRecord = {
|
||||
profileName: 'che:1010',
|
||||
@@ -131,6 +142,7 @@ describe('profile DEPLOY operation', () => {
|
||||
'sammo:che:1010:battle-sim-worker',
|
||||
'sammo:che:1010:tournament-worker',
|
||||
];
|
||||
const backendProcessNames = processNames.filter((name) => !name.endsWith(':game-frontend'));
|
||||
const running = new Set(processNames);
|
||||
const processManager: ProcessManager = {
|
||||
list: async () => [...running].map((name) => ({ name, status: 'online' })),
|
||||
@@ -171,6 +183,9 @@ describe('profile DEPLOY operation', () => {
|
||||
redisKeyPrefix: 'sammo:test',
|
||||
gameTokenSecret: 'test-secret',
|
||||
gatewayInternalApiUrl: 'http://127.0.0.1:15001',
|
||||
frontendServeMode: 'static',
|
||||
frontendArtifactRoot: path.join(workspace, 'artifact-volume'),
|
||||
frontendReadinessOrigin: 'http://caddy',
|
||||
baseEnv: { DATABASE_URL: 'postgresql://user:pass@integration.invalid/sammo' },
|
||||
},
|
||||
reconcileIntervalMs: 60_000,
|
||||
@@ -218,6 +233,9 @@ describe('profile DEPLOY operation', () => {
|
||||
);
|
||||
expect(logs.map((entry) => entry.message).join('\n')).not.toContain('pass@integration.invalid');
|
||||
expect(logs.map((entry) => entry.message).join('\n')).toContain('[REDACTED]');
|
||||
expect([...running].sort()).toEqual([...processNames].sort());
|
||||
expect([...running].sort()).toEqual([...backendProcessNames].sort());
|
||||
expect(
|
||||
await fs.readFile(path.join(workspace, 'artifact-volume', 'che', 'current', 'index.html'), 'utf8')
|
||||
).toContain('static profile');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
RemoteBuildRunner,
|
||||
sanitizeReleaseBuildEnv,
|
||||
type BuildProgressEvent,
|
||||
} from '../src/orchestrator/buildRunner.js';
|
||||
|
||||
describe('sanitizeReleaseBuildEnv', () => {
|
||||
it('keeps only public build controls and frontend values', () => {
|
||||
expect(
|
||||
sanitizeReleaseBuildEnv({
|
||||
CI: 'true',
|
||||
NODE_OPTIONS: '--max-old-space-size=1024',
|
||||
VITE_APP_BASE_PATH: '/gateway',
|
||||
GAME_TOKEN_SECRET: 'secret',
|
||||
DATABASE_URL: 'postgresql://private',
|
||||
REDIS_URL: 'redis://private',
|
||||
PATH: '/private/path',
|
||||
})
|
||||
).toEqual({
|
||||
CI: 'true',
|
||||
PATH: '/private/path',
|
||||
NODE_OPTIONS: '--max-old-space-size=1024',
|
||||
VITE_APP_BASE_PATH: '/gateway',
|
||||
});
|
||||
expect(
|
||||
sanitizeReleaseBuildEnv({
|
||||
NODE_OPTIONS: '--max-old-space-size=1536',
|
||||
RELEASE_BUILD_NODE_OPTIONS: '--max-old-space-size=3072',
|
||||
})
|
||||
).toEqual({ NODE_OPTIONS: '--max-old-space-size=3072' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('RemoteBuildRunner', () => {
|
||||
it('streams progress and returns the builder result without sending secrets', async () => {
|
||||
const messages = [
|
||||
{ event: { type: 'OUTPUT', stream: 'stdout', message: 'building' } },
|
||||
{ result: { ok: true, exitCode: 0, output: 'building' } },
|
||||
];
|
||||
const fetchImpl = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => {
|
||||
const request = JSON.parse(String(init?.body)) as {
|
||||
commands: Array<{ env: Record<string, string> }>;
|
||||
};
|
||||
expect(request.commands[0].env).toEqual({ VITE_APP_BASE_PATH: '/gateway' });
|
||||
return new Response(`${messages.map((message) => JSON.stringify(message)).join('\n')}\n`, {
|
||||
status: 200,
|
||||
});
|
||||
}) as unknown as typeof fetch;
|
||||
const progress: BuildProgressEvent[] = [];
|
||||
const result = await new RemoteBuildRunner('http://builder:15100', fetchImpl).run(
|
||||
[
|
||||
{
|
||||
command: 'pnpm',
|
||||
args: ['exec', 'turbo', 'run', 'build'],
|
||||
cwd: '/srv/core/repository',
|
||||
env: { VITE_APP_BASE_PATH: '/gateway', GAME_TOKEN_SECRET: 'do-not-send' },
|
||||
},
|
||||
],
|
||||
(event) => {
|
||||
progress.push(event);
|
||||
}
|
||||
);
|
||||
expect(result).toEqual({ ok: true, exitCode: 0, output: 'building' });
|
||||
expect(progress).toEqual([{ type: 'OUTPUT', stream: 'stdout', message: 'building' }]);
|
||||
});
|
||||
});
|
||||
@@ -103,6 +103,25 @@ describe('GitWorkspaceManager source resolution', () => {
|
||||
await expect(manager.resolveCommit('COMMIT', 'HEAD..main')).rejects.toThrow('Invalid git ref');
|
||||
});
|
||||
|
||||
it('atomically publishes only refs below the managed release namespace', async () => {
|
||||
const fixture = createRepositoryFixture();
|
||||
const manager = new GitWorkspaceManager({
|
||||
repoRoot: fixture.checkout,
|
||||
worktreeRoot: fixture.worktrees,
|
||||
});
|
||||
const releaseRef = 'refs/sammo/active-gateway';
|
||||
|
||||
await expect(manager.readPersistentReleaseRef(releaseRef)).resolves.toBeNull();
|
||||
await manager.compareAndSwapPersistentReleaseRef(releaseRef, null, fixture.firstCommit);
|
||||
await expect(manager.readPersistentReleaseRef(releaseRef)).resolves.toBe(fixture.firstCommit);
|
||||
await expect(
|
||||
manager.compareAndSwapPersistentReleaseRef(releaseRef, 'f'.repeat(40), fixture.firstCommit)
|
||||
).rejects.toThrow(/Failed to update persistent release ref/u);
|
||||
await manager.compareAndSwapPersistentReleaseRef(releaseRef, fixture.firstCommit, null);
|
||||
await expect(manager.readPersistentReleaseRef(releaseRef)).resolves.toBeNull();
|
||||
await expect(manager.readPersistentReleaseRef('refs/heads/main')).rejects.toThrow(/refs\/sammo/u);
|
||||
});
|
||||
|
||||
it('reuses only a clean registered worktree at the requested commit', async () => {
|
||||
const fixture = createRepositoryFixture();
|
||||
const manager = new GitWorkspaceManager({
|
||||
|
||||
Reference in New Issue
Block a user