merge: 최신 main 변경을 게임 시계 보정 브랜치에 통합한다

This commit is contained in:
2026-08-23 02:00:58 +00:00
9 changed files with 715 additions and 34 deletions
@@ -1,4 +1,5 @@
import { createHash, randomUUID } from 'node:crypto';
import type { Dirent } from 'node:fs';
import fs from 'node:fs/promises';
import path from 'node:path';
@@ -11,6 +12,12 @@ export interface FrontendArtifactManifest {
digest: string;
releaseId: string;
files: number;
dependencies?: FrontendArtifactDependency[];
}
export interface FrontendArtifactDependency {
frontendKey: string;
releaseId: string;
}
export interface StagedFrontendArtifact {
@@ -19,6 +26,21 @@ export interface StagedFrontendArtifact {
manifest: FrontendArtifactManifest;
}
export interface FrontendArtifactCleanupResult {
removed: string[];
retained: string[];
skipped: string[];
}
export interface FrontendArtifactCleanupOptions {
frontendKeys: string[];
protectedCommitShas?: Iterable<string>;
retentionMs: number;
keepNewest: number;
now?: Date;
cleanupProfileWrapperStaging?: boolean;
}
export interface ProfileFrontendRuntimeConfig {
version: 1;
profile: string;
@@ -34,11 +56,15 @@ export interface ProfileFrontendRuntimeConfig {
export const SHARED_GAME_FRONTEND_KEY = 'game-assets';
export const GAME_FRONTEND_RUNTIME_CONFIG_ID = 'sammo-runtime-config';
export const DEFAULT_FRONTEND_ARTIFACT_RETENTION_MS = 24 * 60 * 60 * 1_000;
export const DEFAULT_FRONTEND_ARTIFACT_KEEP_NEWEST = 2;
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;
const PUBLIC_ASSET_BASE = /^\/[0-9A-Za-z/_-]*$/u;
const RELEASE_ID = /^[0-9a-f]{40,64}-[0-9a-f]{16}$/iu;
const STAGING_DIRECTORY = /^\.staging-[0-9a-f-]+$/iu;
export const resolveFrontendServeMode = (value: string | undefined): FrontendServeMode => {
const normalized = value?.trim().toLowerCase();
@@ -55,6 +81,33 @@ const assertCommitSha = (value: string): void => {
if (!COMMIT_SHA.test(value)) throw new Error('Frontend artifact commit SHA must be a full hexadecimal SHA.');
};
const assertReleaseId = (value: string): void => {
if (!RELEASE_ID.test(value)) throw new Error(`Invalid frontend artifact release id: ${value}`);
};
const normalizeDependencies = (
dependencies: FrontendArtifactDependency[] | undefined
): FrontendArtifactDependency[] => {
if (!dependencies) return [];
const normalized = dependencies.map((dependency) => {
assertFrontendKey(dependency.frontendKey);
assertReleaseId(dependency.releaseId);
return {
frontendKey: dependency.frontendKey,
releaseId: dependency.releaseId.toLowerCase(),
};
});
normalized.sort((left, right) =>
`${left.frontendKey}/${left.releaseId}`.localeCompare(`${right.frontendKey}/${right.releaseId}`)
);
return normalized.filter(
(dependency, index) =>
index === 0 ||
dependency.frontendKey !== normalized[index - 1].frontendKey ||
dependency.releaseId !== normalized[index - 1].releaseId
);
};
const listSourceFiles = async (sourceRoot: string): Promise<string[]> => {
const rootStat = await fs.lstat(sourceRoot);
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) {
@@ -110,12 +163,14 @@ const readManifest = async (releasePath: string): Promise<FrontendArtifactManife
typeof raw.commitSha !== 'string' ||
typeof raw.digest !== 'string' ||
typeof raw.releaseId !== 'string' ||
typeof raw.files !== 'number'
typeof raw.files !== 'number' ||
(raw.dependencies !== undefined && !Array.isArray(raw.dependencies))
) {
throw new Error(`Invalid frontend artifact manifest: ${releasePath}`);
}
const dependencies = normalizeDependencies(raw.dependencies);
await fs.access(path.join(releasePath, 'index.html'));
return raw as FrontendArtifactManifest;
return { ...(raw as FrontendArtifactManifest), ...(dependencies.length > 0 ? { dependencies } : {}) };
};
const isMissing = (error: unknown): boolean =>
@@ -171,16 +226,77 @@ export class FrontendArtifactManager {
}
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}`);
}
assertReleaseId(releaseId);
return path.join(this.frontendRoot(frontendKey), 'releases', releaseId);
}
private async readPointerReleaseId(frontendKey: string, pointer: 'current' | 'previous'): Promise<string | null> {
const frontendRoot = this.frontendRoot(frontendKey);
let target: string;
try {
target = await fs.readlink(path.join(frontendRoot, pointer));
} catch (error) {
if (isMissing(error)) return null;
throw error;
}
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 ${pointer} frontend artifact pointer for ${frontendKey}.`);
const manifest = await readManifest(this.releasePath(frontendKey, match[1]));
if (manifest.frontendKey !== frontendKey || manifest.releaseId !== match[1]) {
throw new Error(`Frontend artifact manifest does not match ${frontendKey}/${match[1]}.`);
}
return match[1];
}
private async readReleaseDependencies(
frontendKey: string,
releaseId: string
): Promise<FrontendArtifactDependency[]> {
const releasePath = this.releasePath(frontendKey, releaseId);
const manifest = await readManifest(releasePath);
if (manifest.dependencies?.length) return manifest.dependencies;
const indexHtml = await fs.readFile(path.join(releasePath, 'index.html'), 'utf8');
if (!indexHtml.includes(GAME_FRONTEND_RUNTIME_CONFIG_ID)) return [];
const runtimeScript =
/<script\b(?=[^>]*\bid="sammo-runtime-config")(?=[^>]*\btype="application\/json")[^>]*>([\s\S]*?)<\/script>/iu.exec(
indexHtml
);
if (!runtimeScript) {
throw new Error(`Invalid profile frontend runtime config script: ${releasePath}`);
}
const runtimeConfig = JSON.parse(runtimeScript[1]) as Partial<ProfileFrontendRuntimeConfig>;
if (typeof runtimeConfig.assetReleaseId !== 'string') {
throw new Error(`Profile frontend runtime config has no shared asset release: ${releasePath}`);
}
assertReleaseId(runtimeConfig.assetReleaseId);
return [{ frontendKey: SHARED_GAME_FRONTEND_KEY, releaseId: runtimeConfig.assetReleaseId.toLowerCase() }];
}
private async touchRelease(frontendKey: string, releaseId: string, at: Date): Promise<void> {
const releasePath = this.releasePath(frontendKey, releaseId);
const dependencies = await this.readReleaseDependencies(frontendKey, releaseId);
for (const dependency of dependencies) {
const dependencyPath = this.releasePath(dependency.frontendKey, dependency.releaseId);
const dependencyManifest = await readManifest(dependencyPath);
if (
dependencyManifest.frontendKey !== dependency.frontendKey ||
dependencyManifest.releaseId !== dependency.releaseId
) {
throw new Error(
`Frontend artifact dependency manifest does not match ${dependency.frontendKey}/${dependency.releaseId}.`
);
}
await fs.utimes(dependencyPath, at, at);
}
await fs.utimes(releasePath, at, at);
}
async stage(options: {
frontendKey: string;
sourceRoot: string;
commitSha: string;
dependencies?: FrontendArtifactDependency[];
}): Promise<StagedFrontendArtifact> {
assertFrontendKey(options.frontendKey);
assertCommitSha(options.commitSha);
@@ -190,6 +306,20 @@ export class FrontendArtifactManager {
const digest = await buildDigest(sourceRoot, files);
const releaseId = `${commitSha}-${digest.slice(0, 16)}`;
const releasePath = this.releasePath(options.frontendKey, releaseId);
const dependencies = normalizeDependencies(options.dependencies);
for (const dependency of dependencies) {
const dependencyManifest = await readManifest(
this.releasePath(dependency.frontendKey, dependency.releaseId)
);
if (
dependencyManifest.frontendKey !== dependency.frontendKey ||
dependencyManifest.releaseId !== dependency.releaseId
) {
throw new Error(
`Frontend artifact dependency manifest does not match ${dependency.frontendKey}/${dependency.releaseId}.`
);
}
}
const manifest: FrontendArtifactManifest = {
version: 1,
frontendKey: options.frontendKey,
@@ -197,6 +327,7 @@ export class FrontendArtifactManager {
digest,
releaseId,
files: files.length,
...(dependencies.length > 0 ? { dependencies } : {}),
};
try {
const existing = await readManifest(releasePath);
@@ -283,6 +414,12 @@ export class FrontendArtifactManager {
frontendKey: options.frontendKey,
sourceRoot,
commitSha: options.sharedArtifact.manifest.commitSha,
dependencies: [
{
frontendKey: SHARED_GAME_FRONTEND_KEY,
releaseId: options.sharedArtifact.releaseId,
},
],
});
} finally {
await fs.rm(sourceRoot, { recursive: true, force: true });
@@ -290,18 +427,7 @@ export class FrontendArtifactManager {
}
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;
}
return this.readPointerReleaseId(frontendKey, 'current');
}
async activate(frontendKey: string, releaseId: string): Promise<string | null> {
@@ -322,7 +448,10 @@ export class FrontendArtifactManager {
await fs.rm(temporary, { force: true });
}
};
const activatedAt = new Date();
await this.touchRelease(frontendKey, releaseId, activatedAt);
if (previousReleaseId && previousReleaseId !== releaseId) {
await this.touchRelease(frontendKey, previousReleaseId, activatedAt);
await replacePointer('previous', previousReleaseId);
}
await replacePointer('current', releaseId);
@@ -344,4 +473,160 @@ export class FrontendArtifactManager {
await fs.rm(path.join(this.frontendRoot(frontendKey), 'current'), { force: true });
return releaseId;
}
async cleanup(options: FrontendArtifactCleanupOptions): Promise<FrontendArtifactCleanupResult> {
if (!Number.isFinite(options.retentionMs) || options.retentionMs < 0) {
throw new Error('Frontend artifact retention must be a non-negative finite duration.');
}
if (!Number.isInteger(options.keepNewest) || options.keepNewest < 0) {
throw new Error('Frontend artifact keepNewest must be a non-negative integer.');
}
const frontendKeys = [...new Set(options.frontendKeys)];
frontendKeys.forEach(assertFrontendKey);
const protectedCommitShas = new Set(
[...(options.protectedCommitShas ?? [])].map((commitSha) => {
assertCommitSha(commitSha);
return commitSha.toLowerCase();
})
);
const result: FrontendArtifactCleanupResult = { removed: [], retained: [], skipped: [] };
if (frontendKeys.length === 0) return result;
const collectProtectedReleases = async (): Promise<Map<string, Set<string>>> => {
const protectedReleases = new Map(frontendKeys.map((frontendKey) => [frontendKey, new Set<string>()]));
for (const frontendKey of frontendKeys) {
for (const pointer of ['current', 'previous'] as const) {
const releaseId = await this.readPointerReleaseId(frontendKey, pointer);
if (!releaseId) continue;
protectedReleases.get(frontendKey)?.add(releaseId);
for (const dependency of await this.readReleaseDependencies(frontendKey, releaseId)) {
protectedReleases.get(dependency.frontendKey)?.add(dependency.releaseId);
}
}
}
return protectedReleases;
};
let protectedReleases: Map<string, Set<string>>;
try {
protectedReleases = await collectProtectedReleases();
} catch {
for (const frontendKey of frontendKeys) result.skipped.push(this.frontendRoot(frontendKey));
return result;
}
const cutoffMs = (options.now ?? new Date()).getTime() - options.retentionMs;
const candidates: Array<{ frontendKey: string; releaseId: string; releasePath: string }> = [];
const staleStagingPaths: string[] = [];
for (const frontendKey of frontendKeys) {
const releasesRoot = path.join(this.frontendRoot(frontendKey), 'releases');
let entries: Dirent[];
try {
entries = await fs.readdir(releasesRoot, { withFileTypes: true });
} catch (error) {
if (isMissing(error)) continue;
result.skipped.push(releasesRoot);
continue;
}
const unprotected: Array<{ releaseId: string; releasePath: string; mtimeMs: number }> = [];
for (const entry of entries) {
const entryPath = path.join(releasesRoot, entry.name);
if (STAGING_DIRECTORY.test(entry.name)) {
if (!entry.isDirectory()) {
result.skipped.push(entryPath);
continue;
}
const stat = await fs.lstat(entryPath);
if (stat.mtimeMs <= cutoffMs) staleStagingPaths.push(entryPath);
else result.retained.push(entryPath);
continue;
}
if (!RELEASE_ID.test(entry.name) || !entry.isDirectory()) {
result.skipped.push(entryPath);
continue;
}
try {
const manifest = await readManifest(entryPath);
if (manifest.frontendKey !== frontendKey || manifest.releaseId !== entry.name) {
result.skipped.push(entryPath);
continue;
}
const stat = await fs.lstat(entryPath);
if (
protectedReleases.get(frontendKey)?.has(entry.name) ||
protectedCommitShas.has(manifest.commitSha.toLowerCase())
) {
result.retained.push(entryPath);
continue;
}
unprotected.push({ releaseId: entry.name, releasePath: entryPath, mtimeMs: stat.mtimeMs });
} catch {
result.skipped.push(entryPath);
}
}
unprotected.sort(
(left, right) => right.mtimeMs - left.mtimeMs || right.releaseId.localeCompare(left.releaseId)
);
unprotected.forEach((release, index) => {
if (index < options.keepNewest || release.mtimeMs > cutoffMs) {
result.retained.push(release.releasePath);
} else {
candidates.push({ frontendKey, releaseId: release.releaseId, releasePath: release.releasePath });
}
});
}
if (options.cleanupProfileWrapperStaging) {
let entries: Dirent[] = [];
try {
entries = await fs.readdir(this.root, { withFileTypes: true });
} catch (error) {
if (!isMissing(error)) result.skipped.push(this.root);
}
for (const entry of entries) {
if (!entry.name.startsWith('.profile-wrapper-')) continue;
const entryPath = path.join(this.root, entry.name);
if (!entry.isDirectory()) {
result.skipped.push(entryPath);
continue;
}
const stat = await fs.lstat(entryPath);
if (stat.mtimeMs <= cutoffMs) staleStagingPaths.push(entryPath);
else result.retained.push(entryPath);
}
}
for (const candidate of candidates) {
try {
protectedReleases = await collectProtectedReleases();
if (protectedReleases.get(candidate.frontendKey)?.has(candidate.releaseId)) {
result.retained.push(candidate.releasePath);
continue;
}
const stat = await fs.lstat(candidate.releasePath);
if (!stat.isDirectory() || stat.isSymbolicLink()) {
result.skipped.push(candidate.releasePath);
continue;
}
await fs.rm(candidate.releasePath, { recursive: true });
result.removed.push(candidate.releasePath);
} catch (error) {
if (!isMissing(error)) result.skipped.push(candidate.releasePath);
}
}
for (const stagingPath of staleStagingPaths) {
try {
const stat = await fs.lstat(stagingPath);
if (!stat.isDirectory() || stat.isSymbolicLink()) {
result.skipped.push(stagingPath);
continue;
}
await fs.rm(stagingPath, { recursive: true });
result.removed.push(stagingPath);
} catch (error) {
if (!isMissing(error)) result.skipped.push(stagingPath);
}
}
return result;
}
}
@@ -55,9 +55,12 @@ import {
import type { AdminSeedUser } from './seedProfileDatabase.js';
import { assertReleaseComponents, readReleaseManifest } from './releaseManifest.js';
import {
DEFAULT_FRONTEND_ARTIFACT_KEEP_NEWEST,
DEFAULT_FRONTEND_ARTIFACT_RETENTION_MS,
FrontendArtifactManager,
resolveFrontendServeMode,
SHARED_GAME_FRONTEND_KEY,
type FrontendArtifactCleanupResult,
type FrontendServeMode,
type StagedFrontendArtifact,
} from './frontendArtifactManager.js';
@@ -138,6 +141,11 @@ export interface GatewayOrchestratorHandle {
listRuntimeSettings?(profileNames: string[]): Promise<ProfileRuntimeSettingsSnapshot[]>;
}
export interface GatewayManagedCleanupResult {
workspaces: { removed: string[]; skipped: string[] };
artifacts: FrontendArtifactCleanupResult;
}
const SENSITIVE_ENV_NAME = /(SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE_KEY|CLIENT_SECRET|DATABASE_URL|REDIS_URL)/iu;
const managedPostgresPoolMax = (env: Record<string, string>, roleVariable: string, fallback: number): string =>
@@ -2318,15 +2326,21 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
}
}
async cleanupStaleWorkspaces(): Promise<{ removed: string[]; skipped: string[] }> {
async cleanupStaleResources(): Promise<GatewayManagedCleanupResult> {
if (this.buildInFlight || this.operationInFlight || this.workspaceCleanupInFlight) {
const managedWorkspaces = await this.workspaceManager.listManagedWorkspaces();
return { removed: [], skipped: managedWorkspaces.map((workspace) => workspace.root) };
return {
workspaces: { removed: [], skipped: managedWorkspaces.map((workspace) => workspace.root) },
artifacts: { removed: [], retained: [], skipped: [] },
};
}
this.workspaceCleanupInFlight = true;
try {
const managedWorkspaces = await this.workspaceManager.listManagedWorkspaces();
const profiles = await this.repository.listProfiles();
const [profiles, operations] = await Promise.all([
this.repository.listProfiles(),
this.repository.listOperations({ limit: 100 }),
]);
const protectedWorkspaces = new Set<string>();
for (const profile of profiles) {
if (profile.buildWorkspace) {
@@ -2353,22 +2367,56 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
}
}
return await this.workspaceManager.cleanup({
const workspaces = await this.workspaceManager.cleanup({
protectedPaths: [...protectedWorkspaces],
retentionMs: DEFAULT_MANAGED_WORKSPACE_RETENTION_MS,
keepNewest: DEFAULT_MANAGED_WORKSPACE_KEEP_NEWEST,
});
const artifacts: FrontendArtifactCleanupResult =
this.frontendServeMode === 'static'
? await this.artifactManager.cleanup({
frontendKeys: [
...new Set(profiles.map((profile) => profile.profile)),
SHARED_GAME_FRONTEND_KEY,
],
protectedCommitShas: [
...profiles
.filter(
(profile) =>
profile.buildCommitSha &&
(profile.buildStatus === 'QUEUED' || profile.buildStatus === 'RUNNING')
)
.map((profile) => profile.buildCommitSha as string),
...operations
.filter(
(operation) =>
operation.resolvedCommitSha &&
(operation.status === 'QUEUED' || operation.status === 'RUNNING')
)
.map((operation) => operation.resolvedCommitSha as string),
],
retentionMs: DEFAULT_FRONTEND_ARTIFACT_RETENTION_MS,
keepNewest: DEFAULT_FRONTEND_ARTIFACT_KEEP_NEWEST,
now: this.now(),
cleanupProfileWrapperStaging: true,
})
: { removed: [], retained: [], skipped: [] };
return { workspaces, artifacts };
} finally {
this.workspaceCleanupInFlight = false;
}
}
async cleanupStaleWorkspaces(): Promise<{ removed: string[]; skipped: string[] }> {
return (await this.cleanupStaleResources()).workspaces;
}
private async cleanupWorkspacesScheduled(): Promise<void> {
if (this.stopping || this.buildInFlight || this.operationInFlight || this.workspaceCleanupInFlight) return;
const result = await this.cleanupStaleWorkspaces();
if (result.removed.length > 0) {
console.info(`[gateway-orchestrator] removed ${result.removed.length} stale profile worktrees`);
}
const result = await this.cleanupStaleResources();
console.info(
`[gateway-orchestrator] managed cleanup completed: removed ${result.workspaces.removed.length} profile worktrees and ${result.artifacts.removed.length} frontend artifacts; retained ${result.artifacts.retained.length}, skipped ${result.artifacts.skipped.length}`
);
}
private async stageStaticProfileFrontend(profile: GatewayProfileRecord): Promise<StagedFrontendArtifact> {
@@ -14,6 +14,7 @@ import {
const roots: string[] = [];
const sha = 'a'.repeat(40);
const cleanupNow = new Date('2026-08-23T00:00:00.000Z');
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })));
@@ -145,4 +146,162 @@ describe('FrontendArtifactManager', () => {
expect(rendered).not.toContain('trpc?</script>');
expect(rendered).toContain('\\u003c/script\\u003e');
});
it('removes only expired unreferenced releases while preserving pointers, active commits, grace, and caches', async () => {
const { source, artifacts } = await fixture();
const manager = new FrontendArtifactManager(artifacts);
const stage = async (marker: string, commitMarker: string) => {
await fs.writeFile(path.join(source, 'index.html'), `<div>${marker}</div>`);
return manager.stage({
frontendKey: 'gateway',
sourceRoot: source,
commitSha: commitMarker.repeat(40),
});
};
const current = await stage('current', '1');
await manager.activate('gateway', current.releaseId);
const next = await stage('next', '2');
await manager.activate('gateway', next.releaseId);
const pinned = await stage('pinned', '3');
const recent = await stage('recent', '4');
const cached = await stage('cached', '5');
const stale = await stage('stale', '6');
const releasesRoot = path.join(artifacts, 'gateway', 'releases');
const staging = path.join(releasesRoot, '.staging-00000000-0000-0000-0000-000000000000');
const unknownSymlink = path.join(releasesRoot, `${'7'.repeat(40)}-${'7'.repeat(16)}`);
await fs.mkdir(staging);
await fs.symlink(stale.releasePath, unknownSymlink);
const old = new Date(cleanupNow.getTime() - 72 * 60 * 60 * 1_000);
for (const artifact of [current, next, pinned, cached, stale]) {
await fs.utimes(artifact.releasePath, old, old);
}
await fs.utimes(cached.releasePath, new Date(old.getTime() + 1_000), new Date(old.getTime() + 1_000));
await fs.utimes(staging, old, old);
const recentAt = new Date(cleanupNow.getTime() - 60 * 60 * 1_000);
await fs.utimes(recent.releasePath, recentAt, recentAt);
const result = await manager.cleanup({
frontendKeys: ['gateway'],
protectedCommitShas: [pinned.manifest.commitSha],
retentionMs: 24 * 60 * 60 * 1_000,
keepNewest: 2,
now: cleanupNow,
});
expect(result.removed.sort()).toEqual([staging, stale.releasePath].sort());
expect(result.retained).toEqual(
expect.arrayContaining([
current.releasePath,
next.releasePath,
pinned.releasePath,
recent.releasePath,
cached.releasePath,
])
);
expect(result.skipped).toContain(unknownSymlink);
await expect(fs.access(stale.releasePath)).rejects.toMatchObject({ code: 'ENOENT' });
await expect(fs.readFile(path.join(artifacts, 'gateway', 'current', 'index.html'), 'utf8')).resolves.toContain(
'next'
);
await expect(fs.readFile(path.join(artifacts, 'gateway', 'previous', 'index.html'), 'utf8')).resolves.toContain(
'current'
);
});
it('preserves shared assets referenced by current and previous profile wrappers, including old manifests', async () => {
const { source, artifacts } = await fixture();
await fs.writeFile(
path.join(source, 'index.html'),
'<!doctype html><head><script type="module" src="./assets/app-deadbeef.js"></script></head>'
);
await fs.writeFile(path.join(source, 'deployment-version.json'), `${JSON.stringify({ commitSha: sha })}\n`);
const manager = new FrontendArtifactManager(artifacts);
const publish = async (commitMarker: string, script: string) => {
const commitSha = commitMarker.repeat(40);
await fs.writeFile(path.join(source, 'assets', 'app-deadbeef.js'), script);
await fs.writeFile(path.join(source, 'deployment-version.json'), `${JSON.stringify({ commitSha })}\n`);
const shared = await manager.stage({
frontendKey: SHARED_GAME_FRONTEND_KEY,
sourceRoot: source,
commitSha,
});
const wrapper = await manager.stageProfileWrapper({
frontendKey: 'pya',
sharedArtifact: shared,
sharedAssetPublicBase: '/gateway/profile-assets',
runtimeConfig: {
version: 1,
profile: 'pya',
profileName: 'pya:default',
appBasePath: '/pya/',
gameApiUrl: '/pya/api/trpc',
gameSseUrl: '/pya/api/events',
gatewayApiUrl: '/gateway/api/trpc',
gatewayWebUrl: '/gateway/',
},
});
await manager.activate('pya', wrapper.releaseId);
return { shared, wrapper };
};
const first = await publish('1', 'console.log(1)');
const second = await publish('2', 'console.log(2)');
await fs.writeFile(path.join(source, 'assets', 'app-deadbeef.js'), 'console.log(3)');
const unused = await manager.stage({
frontendKey: SHARED_GAME_FRONTEND_KEY,
sourceRoot: source,
commitSha: '3'.repeat(40),
});
const firstManifestPath = path.join(first.wrapper.releasePath, '.sammo-artifact.json');
const firstManifest = JSON.parse(await fs.readFile(firstManifestPath, 'utf8')) as Record<string, unknown>;
delete firstManifest.dependencies;
await fs.writeFile(firstManifestPath, `${JSON.stringify(firstManifest, null, 2)}\n`);
const old = new Date(cleanupNow.getTime() - 72 * 60 * 60 * 1_000);
for (const artifact of [first.shared, first.wrapper, second.shared, second.wrapper, unused]) {
await fs.utimes(artifact.releasePath, old, old);
}
const result = await manager.cleanup({
frontendKeys: ['pya', SHARED_GAME_FRONTEND_KEY],
retentionMs: 24 * 60 * 60 * 1_000,
keepNewest: 0,
now: cleanupNow,
});
expect(result.removed).toEqual([unused.releasePath]);
expect(result.retained).toEqual(
expect.arrayContaining([
first.shared.releasePath,
first.wrapper.releasePath,
second.shared.releasePath,
second.wrapper.releasePath,
])
);
await expect(
fs.readFile(path.join(first.shared.releasePath, 'assets', 'app-deadbeef.js'), 'utf8')
).resolves.toBe('console.log(1)');
await expect(
fs.readFile(path.join(second.shared.releasePath, 'assets', 'app-deadbeef.js'), 'utf8')
).resolves.toBe('console.log(2)');
});
it('fails closed when a live pointer cannot be validated', async () => {
const { source, artifacts } = await fixture();
const manager = new FrontendArtifactManager(artifacts);
const stale = await manager.stage({ frontendKey: 'gateway', sourceRoot: source, commitSha: sha });
const old = new Date(cleanupNow.getTime() - 72 * 60 * 60 * 1_000);
await fs.utimes(stale.releasePath, old, old);
await fs.mkdir(path.join(artifacts, 'gateway'), { recursive: true });
await fs.symlink(`releases/${'f'.repeat(40)}-${'f'.repeat(16)}`, path.join(artifacts, 'gateway', 'current'));
const result = await manager.cleanup({
frontendKeys: ['gateway'],
retentionMs: 24 * 60 * 60 * 1_000,
keepNewest: 0,
now: cleanupNow,
});
expect(result.removed).toEqual([]);
expect(result.skipped).toEqual([path.join(artifacts, 'gateway')]);
await expect(fs.access(stale.releasePath)).resolves.toBeUndefined();
});
});
@@ -1,8 +1,11 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it } from 'vitest';
import { GatewayOrchestrator } from '../src/orchestrator/gatewayOrchestrator.js';
import { FrontendArtifactManager } from '../src/orchestrator/frontendArtifactManager.js';
import type { ProcessManager } from '../src/orchestrator/processManager.js';
import type { GatewayProfileRecord, GatewayProfileRepository } from '../src/orchestrator/profileRepository.js';
import {
@@ -14,6 +17,11 @@ import {
const COMMIT_SHA = '0123456789abcdef0123456789abcdef01234567';
const oldUsage = '2025-01-01T00:00:00.000Z';
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(temporaryDirectories.splice(0).map((directory) => fs.rm(directory, { recursive: true })));
});
const makeProfile = (
profileName: string,
@@ -40,10 +48,14 @@ const makeProfile = (
const createHarness = (
profiles: GatewayProfileRecord[],
processes: Awaited<ReturnType<ProcessManager['list']>>,
managedPaths: string[]
managedPaths: string[],
frontendArtifactRoot?: string
) => {
const cleanupCalls: ManagedWorkspaceCleanupOptions[] = [];
const repository = { listProfiles: async () => profiles } as unknown as GatewayProfileRepository;
const repository = {
listProfiles: async () => profiles,
listOperations: async () => [],
} as unknown as GatewayProfileRepository;
const processManager: ProcessManager = {
list: async () => processes,
start: async () => {},
@@ -73,11 +85,13 @@ const createHarness = (
redisKeyPrefix: 'sammo:test',
gameTokenSecret: 'test-secret',
gatewayInternalApiUrl: 'http://127.0.0.1:13000',
...(frontendArtifactRoot ? { frontendServeMode: 'static' as const, frontendArtifactRoot } : {}),
},
reconcileIntervalMs: 60_000,
scheduleIntervalMs: 60_000,
buildIntervalMs: 60_000,
adminActionIntervalMs: 60_000,
now: () => new Date('2026-08-23T00:00:00.000Z'),
});
return { orchestrator, cleanupCalls };
};
@@ -141,4 +155,47 @@ describe('GatewayOrchestrator workspace cleanup', () => {
skipped: [],
});
});
it('serializes profile worktree and frontend artifact cleanup under the same managed cycle', async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-profile-artifact-cleanup-'));
temporaryDirectories.push(root);
const sourceRoot = path.join(root, 'dist');
const artifactRoot = path.join(root, 'artifacts');
await fs.mkdir(sourceRoot, { recursive: true });
const manager = new FrontendArtifactManager(artifactRoot);
const stage = async (marker: string, commitMarker: string) => {
await fs.writeFile(path.join(sourceRoot, 'index.html'), `<div>${marker}</div>`);
return manager.stage({
frontendKey: 'che',
sourceRoot,
commitSha: commitMarker.repeat(40),
});
};
const active = await stage('active', '1');
await manager.activate('che', active.releaseId);
const cacheOne = await stage('cache-one', '2');
const cacheTwo = await stage('cache-two', '3');
const stale = await stage('stale', '4');
const now = new Date('2026-08-23T00:00:00.000Z');
const old = new Date(now.getTime() - 72 * 60 * 60 * 1_000);
for (const artifact of [active, cacheOne, cacheTwo, stale]) {
await fs.utimes(artifact.releasePath, old, old);
}
await fs.utimes(cacheTwo.releasePath, new Date(old.getTime() + 2_000), new Date(old.getTime() + 2_000));
await fs.utimes(cacheOne.releasePath, new Date(old.getTime() + 1_000), new Date(old.getTime() + 1_000));
const harness = createHarness(
[makeProfile('che:default', undefined, { buildCommitSha: active.manifest.commitSha })],
[],
[],
artifactRoot
);
const result = await harness.orchestrator.cleanupStaleResources();
expect(result.workspaces).toEqual({ removed: [], skipped: [] });
expect(result.artifacts.removed).toEqual([stale.releasePath]);
expect(result.artifacts.retained).toEqual(
expect.arrayContaining([active.releasePath, cacheOne.releasePath, cacheTwo.releasePath])
);
});
});
+4 -4
View File
@@ -89,10 +89,10 @@ const main = async (): Promise<void> => {
if (now >= nextWorkspaceCleanupAt) {
nextWorkspaceCleanupAt = now + RELEASE_WORKSPACE_CLEANUP_INTERVAL_MS;
try {
const result = await controller.cleanupStaleWorkspaces();
if (result.removed.length > 0) {
console.info(`[release-controller] removed ${result.removed.length} stale Gateway worktrees`);
}
const result = await controller.cleanupStaleResources();
console.info(
`[release-controller] managed cleanup completed: removed ${result.workspaces.removed.length} Gateway worktrees and ${result.artifacts.removed.length} frontend artifacts; retained ${result.artifacts.retained.length}, skipped ${result.artifacts.skipped.length}`
);
} catch (error) {
console.error('[release-controller] workspace cleanup failed', error);
}
@@ -6,6 +6,8 @@ import {
assertReleaseComponents,
buildTurboReleaseCommand,
buildTurboReleaseTaskCommand,
DEFAULT_FRONTEND_ARTIFACT_KEEP_NEWEST,
DEFAULT_FRONTEND_ARTIFACT_RETENTION_MS,
DEFAULT_MANAGED_WORKSPACE_KEEP_NEWEST,
DEFAULT_MANAGED_WORKSPACE_RETENTION_MS,
type BuildCommand,
@@ -14,6 +16,7 @@ import {
type GatewayReleaseOperationRecord,
type GatewayReleaseRepository,
type GatewayReleaseStateRecord,
type FrontendArtifactCleanupResult,
type GitWorkspaceManager,
type ProcessDefinition,
type ProcessManager,
@@ -34,6 +37,11 @@ const MANAGED_PROCESS_NAMES = ['sammo:gateway-api', 'sammo:gateway-frontend', 's
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 interface ReleaseManagedCleanupResult {
workspaces: { removed: string[]; skipped: string[] };
artifacts: FrontendArtifactCleanupResult;
}
const isRuntimeProcessActive = (status: string): boolean =>
['online', 'launching', 'stopping'].includes(status.toLowerCase());
@@ -183,6 +191,36 @@ export class GatewayReleaseController {
});
}
async cleanupStaleResources(): Promise<ReleaseManagedCleanupResult> {
const workspaces = await this.cleanupStaleWorkspaces();
let artifacts: FrontendArtifactCleanupResult = { removed: [], retained: [], skipped: [] };
if (this.config.frontendServeMode === 'static') {
const [state, operations] = await Promise.all([
this.repository.getState(),
this.repository.listOperations(100),
]);
artifacts = await this.artifactManager.cleanup({
frontendKeys: ['gateway'],
protectedCommitShas: [
...[state.activeCommitSha, state.previousCommitSha].filter((commitSha): commitSha is string =>
Boolean(commitSha)
),
...operations
.filter(
(operation) =>
operation.resolvedCommitSha &&
(operation.status === 'QUEUED' || operation.status === 'RUNNING')
)
.map((operation) => operation.resolvedCommitSha as string),
],
retentionMs: DEFAULT_FRONTEND_ARTIFACT_RETENTION_MS,
keepNewest: DEFAULT_FRONTEND_ARTIFACT_KEEP_NEWEST,
now: this.now(),
});
}
return { workspaces, artifacts };
}
private sanitizeLogMessage(message: string): string {
let sanitized = stripVTControlCharacters(message);
const sensitiveValues = new Set([
@@ -3,8 +3,11 @@ import os from 'node:os';
import path from 'node:path';
import {
DEFAULT_FRONTEND_ARTIFACT_KEEP_NEWEST,
DEFAULT_FRONTEND_ARTIFACT_RETENTION_MS,
DEFAULT_MANAGED_WORKSPACE_KEEP_NEWEST,
DEFAULT_MANAGED_WORKSPACE_RETENTION_MS,
FrontendArtifactManager,
type BuildRunner,
type GatewayReleaseOperationRecord,
type GatewayReleaseRepository,
@@ -224,8 +227,7 @@ describe('GatewayReleaseController', () => {
} as unknown as GitWorkspaceManager,
{ run: async () => ({ ok: true, exitCode: 0, output: '' }) },
{
list: async () =>
[...running].map(([name, cwd]) => ({ name, cwd, status: 'online', restartCount: 0 })),
list: async () => [...running].map(([name, cwd]) => ({ name, cwd, status: 'online', restartCount: 0 })),
start: async (definition) => {
running.set(definition.name, definition.cwd);
},
@@ -321,6 +323,61 @@ describe('GatewayReleaseController', () => {
});
});
it('cleans expired unreferenced Gateway frontend releases with the managed daily policy', async () => {
const workspace = await createReleaseWorkspace();
const artifactRoot = path.join(workspace, 'artifact-cleanup-volume');
const sourceRoot = path.join(workspace, 'gateway-cleanup-dist');
await fs.mkdir(sourceRoot, { recursive: true });
const manager = new FrontendArtifactManager(artifactRoot);
const stage = async (marker: string, commitSha: string) => {
await fs.writeFile(path.join(sourceRoot, 'index.html'), `<div>${marker}</div>`);
return manager.stage({ frontendKey: 'gateway', sourceRoot, commitSha });
};
const active = await stage('active', OLD_SHA);
await manager.activate('gateway', active.releaseId);
const cacheOne = await stage('cache-one', '3'.repeat(40));
const cacheTwo = await stage('cache-two', '4'.repeat(40));
const stale = await stage('stale', '5'.repeat(40));
const now = new Date('2026-08-23T00:00:00.000Z');
const old = new Date(now.getTime() - 72 * 60 * 60 * 1_000);
for (const artifact of [active, cacheOne, cacheTwo, stale]) {
await fs.utimes(artifact.releasePath, old, old);
}
await fs.utimes(cacheTwo.releasePath, new Date(old.getTime() + 2_000), new Date(old.getTime() + 2_000));
await fs.utimes(cacheOne.releasePath, new Date(old.getTime() + 1_000), new Date(old.getTime() + 1_000));
const harness = createRepository();
const controller = new GatewayReleaseController(
harness.repository,
{
listManagedWorkspaces: async () => [],
cleanup: async () => ({ removed: [], skipped: [] }),
} as unknown as GitWorkspaceManager,
{ run: async () => ({ ok: true, exitCode: 0, output: '' }) },
{
list: async () => [],
start: async () => {},
stop: async () => {},
delete: async () => {},
},
{
...config,
frontendServeMode: 'static',
frontendArtifactRoot: artifactRoot,
},
() => now
);
const result = await controller.cleanupStaleResources();
expect(result.workspaces).toEqual({ removed: [], skipped: [] });
expect(result.artifacts.removed).toEqual([stale.releasePath]);
expect(result.artifacts.retained).toEqual(
expect.arrayContaining([active.releasePath, cacheOne.releasePath, cacheTwo.releasePath])
);
expect(DEFAULT_FRONTEND_ARTIFACT_RETENTION_MS).toBe(24 * 60 * 60 * 1_000);
expect(DEFAULT_FRONTEND_ARTIFACT_KEEP_NEWEST).toBe(2);
});
it('builds, migrates, switches all gateway roles, verifies readiness, and publishes atomically', async () => {
const workspace = await createReleaseWorkspace();
const harness = createRepository();
+7
View File
@@ -25,6 +25,13 @@ cluster `exec_mode`가 없으므로 모두 단일 fork입니다. Frontend는 Cad
API는 하나의 Fastify process입니다. worker 역할 분리는 API event loop의 작업을
줄이지만 API replica나 장애 대체 backend를 제공하지는 않습니다.
정적 artifact 수명도 두 control-plane daemon이 소유합니다. Gateway orchestrator는
profile wrapper와 commit 공용 `game-assets`, release-controller는 Gateway release를
startup 직후와 24시간마다 operation/worktree 정리와 직렬화해 점검합니다. 현재·이전
pointer, wrapper dependency, 진행 중 commit, 24시간 grace와 key별 최신 cache 2개를
제외한 미참조 release만 제거합니다. 손상되거나 알 수 없는 pointer·manifest·symlink는
삭제하지 않으며, sourcemap은 참조하는 bundle과 같은 release 단위로 유지됩니다.
Profile은 PostgreSQL schema와 Redis namespace를 분리하지만 같은 database,
PostgreSQL instance, runtime cgroup을 공유합니다. 관리되는 PM2 정의는 game API 4,
turn daemon 2, auction/battle/tournament worker 각 1, Gateway API 4, Gateway
+30
View File
@@ -105,6 +105,36 @@ Timeout은 scheduled task 오류로 끝나 정리 flag를 해제하고 다음 5
queue를 다시 claim하게 합니다. PM2 mutation이 timeout된 경우에는 같은 mutation을 즉시
직접 반복하지 않고 실제 process 목록과 operation terminal 상태를 먼저 재조회합니다.
## Frontend artifact 자동 정리
정적 운영의 `frontend-artifacts/<key>/releases`도 commit worktree와 같은 daemon 주기에
정리합니다. Profile orchestrator는 profile wrapper와 `game-assets` 공용 bundle을,
release-controller는 Gateway bundle을 소유하며 startup 직후와 이후 24시간마다 한 번
점검합니다. 별도 상시 polling process나 cron은 두지 않습니다.
- `current``previous` pointer가 가리키는 release는 기간과 무관하게 보호합니다.
- Profile wrapper manifest는 참조하는 `game-assets` release를 dependency로 기록합니다.
전환 전에 생성된 manifest는 wrapper의 `sammo-runtime-config.assetReleaseId`를 읽어 같은
보호 집합을 복원하므로 기존 공용 bundle도 안전합니다.
- `QUEUED`/`RUNNING` operation과 profile build가 고정한 commit의 release도 보호합니다.
- 그 밖의 release는 마지막 stage/activation 이후 최소 24시간을 유예하고, key별 최신
2개를 재배포 cache로 추가 보존합니다. 두 조건 밖의 오래된 미참조 release만
재귀 삭제합니다.
- activation은 release와 dependency 디렉터리의 마지막 사용 시각을 갱신합니다. 오래된
commit으로 rollback한 직후 cache가 즉시 만료되는 일을 막습니다.
- 24시간이 지난 crash 잔여 `.staging-*`와 profile wrapper 임시 디렉터리도 같은 소유
daemon이 operation과 직렬화된 상태에서만 제거합니다.
- pointer, manifest, dependency 또는 symlink가 예상 형식이 아니면 해당 정리 주기는
fail-closed로 건너뜁니다. 알 수 없는 파일과 디렉터리를 일반 release로 간주해
삭제하지 않습니다.
이 정리는 Caddy가 읽는 재생성 가능한 frontend 산출물만 대상으로 하며 sourcemap도
bundle과 함께 동일한 release 단위로 보존·정리합니다. PostgreSQL, Redis, `/image/*`,
사용자 업로드, 현재/이전 rollback pointer에는 접근하지 않습니다. Profile 관리자 API의
`admin.profiles.cleanupWorkspaces`를 실행하면 같은 직렬화 경계에서 profile artifact도
점검하지만 응답의 기존 worktree 목록 계약은 유지합니다. 실제 삭제 수·보존 수·skip
수는 orchestrator와 release-controller의 일일 managed cleanup 로그에서 확인합니다.
## Profile 배포
버전 업데이트 화면에서 profile의 branch 또는 commit을 선택합니다. Branch는 worker가