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