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