feat: 오래된 프런트엔드 빌드 자산을 안전하게 정리한다

현재·이전 release와 공유 asset 의존성, 진행 중 commit을 보호하고 24시간 유예 및 최신 2개 cache를 적용한다.

Gateway와 profile daemon의 기존 24시간 관리 주기에 artifact cleanup을 포함하고 fail-closed 회귀 테스트와 운영 문서를 보강한다.
This commit is contained in:
2026-08-23 01:55:22 +00:00
parent ade543f936
commit 0e3292f591
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> {