diff --git a/app/gateway-api/src/config.ts b/app/gateway-api/src/config.ts index 1d1c061e..45237f95 100644 --- a/app/gateway-api/src/config.ts +++ b/app/gateway-api/src/config.ts @@ -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, }; }; diff --git a/app/gateway-api/src/index.ts b/app/gateway-api/src/index.ts index 78c82ad0..11e97eee 100644 --- a/app/gateway-api/src/index.ts +++ b/app/gateway-api/src/index.ts @@ -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'; diff --git a/app/gateway-api/src/orchestrator/buildRunner.ts b/app/gateway-api/src/orchestrator/buildRunner.ts index 725d85af..1c63781d 100644 --- a/app/gateway-api/src/orchestrator/buildRunner.ts +++ b/app/gateway-api/src/orchestrator/buildRunner.ts @@ -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 | undefined +): Record => { + 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): 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 { + 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 { + 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); diff --git a/app/gateway-api/src/orchestrator/frontendArtifactManager.ts b/app/gateway-api/src/orchestrator/frontendArtifactManager.ts new file mode 100644 index 00000000..fdaa56e0 --- /dev/null +++ b/app/gateway-api/src/orchestrator/frontendArtifactManager.ts @@ -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 => { + 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 => { + 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 => { + 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 => { + const raw = JSON.parse(await fs.readFile(path.join(releasePath, MANIFEST_FILE), 'utf8')) as Partial; + 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 { + 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 { + 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 { + 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 => { + 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 { + const staged = await this.stage(options); + const previousReleaseId = await this.activate(options.frontendKey, staged.releaseId); + return { ...staged, previousReleaseId }; + } + + async deactivate(frontendKey: string): Promise { + const releaseId = await this.readCurrentReleaseId(frontendKey); + await fs.rm(path.join(this.frontendRoot(frontendKey), 'current'), { force: true }); + return releaseId; + } +} diff --git a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts index a7a1961b..668acf82 100644 --- a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts +++ b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts @@ -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; } @@ -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 { 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 { @@ -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): Promise { 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(); 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 { 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[] = []; diff --git a/app/gateway-api/src/orchestrator/orchestratorFactory.ts b/app/gateway-api/src/orchestrator/orchestratorFactory.ts index c6a8f29e..6a98f036 100644 --- a/app/gateway-api/src/orchestrator/orchestratorFactory.ts +++ b/app/gateway-api/src/orchestrator/orchestratorFactory.ts @@ -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, diff --git a/app/gateway-api/src/orchestrator/workspaceManager.ts b/app/gateway-api/src/orchestrator/workspaceManager.ts index 5257bcfe..c05c40df 100644 --- a/app/gateway-api/src/orchestrator/workspaceManager.ts +++ b/app/gateway-api/src/orchestrator/workspaceManager.ts @@ -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 { + 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 { + 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 { if (!COMMIT_SHA_PATTERN.test(commitSha)) { throw new Error('Invalid commit SHA.'); diff --git a/app/gateway-api/test/frontendArtifactManager.test.ts b/app/gateway-api/test/frontendArtifactManager.test.ts new file mode 100644 index 00000000..0eaab349 --- /dev/null +++ b/app/gateway-api/test/frontendArtifactManager.test.ts @@ -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'), '
one
'); + 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'), '
two
'); + 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 + ); + }); +}); diff --git a/app/gateway-api/test/orchestratorOperations.test.ts b/app/gateway-api/test/orchestratorOperations.test.ts index aab4f046..6f7be476 100644 --- a/app/gateway-api/test/orchestratorOperations.test.ts +++ b/app/gateway-api/test/orchestratorOperations.test.ts @@ -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'), + 'static cutover' + ); + 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')); diff --git a/app/gateway-api/test/profileDeployOperation.test.ts b/app/gateway-api/test/profileDeployOperation.test.ts index 7feecf1e..0998f2f5 100644 --- a/app/gateway-api/test/profileDeployOperation.test.ts +++ b/app/gateway-api/test/profileDeployOperation.test.ts @@ -37,6 +37,17 @@ const createReleaseWorkspace = async (): Promise => { 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'), + 'static profile' + ); + 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'); }); }); diff --git a/app/gateway-api/test/remoteBuildRunner.test.ts b/app/gateway-api/test/remoteBuildRunner.test.ts new file mode 100644 index 00000000..131615a2 --- /dev/null +++ b/app/gateway-api/test/remoteBuildRunner.test.ts @@ -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 }>; + }; + 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' }]); + }); +}); diff --git a/app/gateway-api/test/workspaceManager.test.ts b/app/gateway-api/test/workspaceManager.test.ts index 2fa37eb9..3730bad0 100644 --- a/app/gateway-api/test/workspaceManager.test.ts +++ b/app/gateway-api/test/workspaceManager.test.ts @@ -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({ diff --git a/app/release-controller/src/config.ts b/app/release-controller/src/config.ts index 9e23fd83..b734bbfe 100644 --- a/app/release-controller/src/config.ts +++ b/app/release-controller/src/config.ts @@ -1,6 +1,7 @@ import path from 'node:path'; import { sanitizeManagedProcessEnv } from '@sammo-ts/gateway-api'; +import { resolveFrontendServeMode, type FrontendServeMode } from '@sammo-ts/gateway-api'; import { resolvePostgresPoolMax } from '@sammo-ts/infra'; const parsePositiveInt = (value: string | undefined, fallback: number, name: string): number => { @@ -24,6 +25,11 @@ export interface ReleaseControllerConfig { gatewayApiPort: number; gatewayFrontendPort: number; gatewayBasePath: string; + frontendServeMode?: FrontendServeMode; + frontendArtifactRoot?: string; + frontendReadinessOrigin?: string; + releaseBuilderUrl?: string; + activeReleaseGitRef?: string; pollIntervalMs: number; readinessTimeoutMs: number; postgresPoolMax: number; @@ -50,6 +56,11 @@ export const resolveReleaseControllerConfig = (env: NodeJS.ProcessEnv = process. gatewayApiPort: parsePositiveInt(env.GATEWAY_API_PORT, 15001, 'GATEWAY_API_PORT'), gatewayFrontendPort: parsePositiveInt(env.GATEWAY_FRONTEND_PORT, 15000, 'GATEWAY_FRONTEND_PORT'), 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'), readinessTimeoutMs: parsePositiveInt( env.RELEASE_CONTROLLER_READINESS_TIMEOUT_MS, diff --git a/app/release-controller/src/index.ts b/app/release-controller/src/index.ts index c0810564..042ab0bc 100644 --- a/app/release-controller/src/index.ts +++ b/app/release-controller/src/index.ts @@ -4,6 +4,7 @@ import { GitWorkspaceManager, Pm2ProcessManager, PnpmBuildRunner, + createReleaseBuildRunner, } from '@sammo-ts/gateway-api'; import { resolveReleaseControllerConfig } from './config.js'; @@ -28,6 +29,7 @@ const main = async (): Promise => { baseEnv: config.baseEnv, }); const buildRunner = new PnpmBuildRunner(); + const releaseBuildRunner = createReleaseBuildRunner(config.releaseBuilderUrl, buildRunner); const processManager = new Pm2ProcessManager(); const controller = new GatewayReleaseController(repository, workspaceManager, buildRunner, processManager, config); const command = process.argv[2] ?? 'daemon'; @@ -57,7 +59,8 @@ const main = async (): Promise => { sourceMode, sourceRef, workspaceManager, - buildRunner, + buildRunner: releaseBuildRunner, + migrationRunner: buildRunner, processManager, config, }); diff --git a/app/release-controller/src/releaseController.ts b/app/release-controller/src/releaseController.ts index 4e8f5bf0..756f17d9 100644 --- a/app/release-controller/src/releaseController.ts +++ b/app/release-controller/src/releaseController.ts @@ -17,8 +17,11 @@ import { type GitWorkspaceManager, type ProcessDefinition, type ProcessManager, + createReleaseBuildRunner, + FrontendArtifactManager, readReleaseManifest, sanitizeManagedProcessEnv, + sanitizeReleaseBuildEnv, } from '@sammo-ts/gateway-api'; import { resolvePostgresPoolMax } from '@sammo-ts/infra'; @@ -27,7 +30,7 @@ import type { ReleaseControllerConfig } from './config.js'; const LEASE_DURATION_MS = 10 * 60_000; const HEARTBEAT_INTERVAL_MS = 60_000; 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; export const RELEASE_WORKSPACE_CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1_000; @@ -48,13 +51,14 @@ const buildGatewayReleaseCommands = ( needsInstall: boolean, config: ReleaseControllerConfig ): BuildCommand[] => { - const env = { - ...sanitizeManagedProcessEnv(config.baseEnv), + const env = sanitizeReleaseBuildEnv({ + ...config.baseEnv, + NODE_OPTIONS: config.baseEnv.RELEASE_BUILD_NODE_OPTIONS ?? config.baseEnv.NODE_OPTIONS, VITE_APP_BASE_PATH: config.gatewayBasePath, VITE_GATEWAY_API_URL: `${config.gatewayBasePath}/api/trpc`, VITE_GAME_API_URL_TEMPLATE: '/{profile}/api/trpc', VITE_GAME_WEB_URL_TEMPLATE: '/{profile}/', - }; + }); return [ ...(needsInstall ? [{ command: 'pnpm', args: ['install', '--frozen-lockfile'], cwd: workspaceRoot, env }] : []), buildTurboReleaseCommand(workspaceRoot, config.workspaceRoot, ['@sammo-ts/gateway-api'], env), @@ -97,7 +101,7 @@ export const buildGatewayProcessDefinitions = ( GATEWAY_API_PORT: String(config.gatewayApiPort), GATEWAY_DATABASE_URL: config.gatewayDatabaseUrl, }; - return [ + const definitions: ProcessDefinition[] = [ { name: 'sammo:gateway-api', script: apiScript, @@ -108,13 +112,6 @@ export const buildGatewayProcessDefinitions = ( 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', 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 => @@ -133,6 +140,8 @@ const isMissingProcessError = (error: unknown): boolean => export class GatewayReleaseController { private readonly ownerId = randomUUID(); + private readonly releaseBuildRunner: BuildRunner; + private readonly artifactManager: FrontendArtifactManager; constructor( private readonly repository: GatewayReleaseRepository, @@ -142,7 +151,10 @@ export class GatewayReleaseController { private readonly config: ReleaseControllerConfig, private readonly now: () => Date = () => new Date(), 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[] }> { const [state, processes, workspaces] = await Promise.all([ @@ -317,12 +329,20 @@ export class GatewayReleaseController { const manifest = await readReleaseManifest(workspace.root); assertReleaseComponents(manifest, ['gateway-api', 'gateway-frontend']); 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), this.buildProgress(operation.id, 'build'), { signal } ); 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.assertOperationLease(operation.id); const migration = await this.buildRunner.run( @@ -339,8 +359,15 @@ export class GatewayReleaseController { await this.appendLog(operation.id, 'switch', '기존 Gateway process를 정지합니다.'); await this.assertOperationLease(operation.id); await this.stopManagedProcesses(operation.id); + const previousArtifactReleaseId = + this.config.frontendServeMode === 'static' + ? await this.artifactManager.readCurrentReleaseId('gateway') + : null; try { await this.startDefinitions(buildGatewayProcessDefinitions(workspace.root, this.config), operation.id); + if (stagedArtifact) { + await this.artifactManager.activate('gateway', stagedArtifact.releaseId); + } await this.waitForReadiness(operation.id); } catch (error) { await this.appendLog( @@ -350,6 +377,13 @@ export class GatewayReleaseController { 'ERROR' ); 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) { await this.startDefinitions(previousDefinitions, operation.id); await this.waitForReadiness(operation.id); @@ -357,12 +391,43 @@ export class GatewayReleaseController { throw error; } await this.appendLog(operation.id, 'publish', '검증된 Gateway 릴리스를 active 상태로 게시합니다.'); - await this.repository.publishRelease(operation.id, this.ownerId, { - commitSha, - workspace: workspace.root, - previousCommitSha: state.activeCommitSha, - previousWorkspace: state.activeWorkspace, - }); + const activeReleaseGitRef = this.config.activeReleaseGitRef; + const previousPersistentCommit = activeReleaseGitRef + ? await this.workspaceManager.readPersistentReleaseRef(activeReleaseGitRef) + : null; + 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 { @@ -388,7 +453,7 @@ export class GatewayReleaseController { private async stopManagedProcesses(operationId: string): Promise { const existing = new Set((await this.processManager.list()).map((process) => process.name)); const failures: string[] = []; - for (const name of [...PROCESS_NAMES].reverse()) { + for (const name of [...MANAGED_PROCESS_NAMES].reverse()) { if (!existing.has(name)) continue; await this.appendLog(operationId, 'switch', `${name} process를 정리합니다.`); try { @@ -406,26 +471,36 @@ export class GatewayReleaseController { } private async waitForReadiness(operationId: string): Promise { - 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 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) { 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 expected = processes.filter((process) => - PROCESS_NAMES.includes(process.name as (typeof PROCESS_NAMES)[number]) - ); + const expected = processes.filter((process) => expectedNames.includes(process.name)); const safe = expected.filter( (process) => process.status.toLowerCase() === 'online' && (process.restartCount ?? 0) === 0 ); if ( api.ok && frontend.ok && - expected.length === PROCESS_NAMES.length && - safe.length === PROCESS_NAMES.length && - new Set(safe.map((process) => process.name)).size === PROCESS_NAMES.length + expected.length === expectedNames.length && + safe.length === expectedNames.length && + new Set(safe.map((process) => process.name)).size === expectedNames.length ) { await this.appendLog(operationId, 'readiness', 'Gateway readiness 확인을 통과했습니다.'); return; diff --git a/app/release-controller/src/selfUpgrade.ts b/app/release-controller/src/selfUpgrade.ts index da023d7a..2a37e5b2 100644 --- a/app/release-controller/src/selfUpgrade.ts +++ b/app/release-controller/src/selfUpgrade.ts @@ -10,6 +10,7 @@ import { type ProcessManager, readReleaseManifest, sanitizeManagedProcessEnv, + sanitizeReleaseBuildEnv, } from '@sammo-ts/gateway-api'; import type { ReleaseControllerConfig } from './config.js'; @@ -22,7 +23,7 @@ const buildReleaseControllerCommands = ( needsInstall: boolean, config: ReleaseControllerConfig ): BuildCommand[] => { - const env = sanitizeManagedProcessEnv(config.baseEnv); + const env = sanitizeReleaseBuildEnv(config.baseEnv); return [ ...(needsInstall ? [{ command: 'pnpm', args: ['install', '--frozen-lockfile'], cwd: workspaceRoot, env }] : []), buildTurboReleaseCommand(workspaceRoot, config.workspaceRoot, ['@sammo-ts/release-controller'], env), @@ -56,6 +57,7 @@ export const upgradeReleaseController = async (options: { sourceRef: string; workspaceManager: GitWorkspaceManager; buildRunner: BuildRunner; + migrationRunner?: BuildRunner; processManager: ProcessManager; config: ReleaseControllerConfig; readinessTimeoutMs?: number; @@ -71,7 +73,9 @@ export const upgradeReleaseController = async (options: { buildReleaseControllerCommands(workspace.root, workspace.needsInstall, options.config) ); 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)}`); const existing = (await options.processManager.list()).find((process) => process.name === CONTROLLER_PROCESS_NAME); diff --git a/app/release-controller/test/releaseController.test.ts b/app/release-controller/test/releaseController.test.ts index a77e970b..d6aea019 100644 --- a/app/release-controller/test/releaseController.test.ts +++ b/app/release-controller/test/releaseController.test.ts @@ -43,6 +43,15 @@ const createReleaseWorkspace = async (): Promise => { 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'), + 'static gateway' + ); + await fs.writeFile( + path.join(workspace, 'app', 'gateway-frontend', 'dist', 'assets', 'app-deadbeef.js'), + 'console.log("gateway")' + ); 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', () => { const definitions = buildGatewayProcessDefinitions('/srv/sammo/release', { ...config, @@ -181,6 +201,64 @@ it('rejects Gateway definitions before switching processes when Redis connection }); 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(); + 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 () => { const active = '/srv/sammo/releases/active'; const previous = '/srv/sammo/releases/previous'; diff --git a/tools/build-scripts/publish-frontend-artifact.mjs b/tools/build-scripts/publish-frontend-artifact.mjs new file mode 100644 index 00000000..45a2b406 --- /dev/null +++ b/tools/build-scripts/publish-frontend-artifact.mjs @@ -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, + }) +);