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
@@ -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();