feat: 오래된 프런트엔드 빌드 자산을 안전하게 정리한다
현재·이전 release와 공유 asset 의존성, 진행 중 commit을 보호하고 24시간 유예 및 최신 2개 cache를 적용한다. Gateway와 profile daemon의 기존 24시간 관리 주기에 artifact cleanup을 포함하고 fail-closed 회귀 테스트와 운영 문서를 보강한다.
This commit is contained in:
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user