fix: 실행 중인 릴리스 빌드를 안전하게 중단

Gateway와 프로필 DEPLOY의 빌드 단계만 취소하고 lease와 phase 전환을 직렬화한다. 관리자 화면에 중단·재시도 절차와 회귀 검증을 추가한다.
This commit is contained in:
2026-08-20 02:27:52 +00:00
parent 1bacdaef5f
commit 466f030889
11 changed files with 554 additions and 48 deletions
+24
View File
@@ -145,4 +145,28 @@ describe('PnpmBuildRunner', () => {
{ type: 'COMMAND_END' },
]);
});
it('terminates a running build process group when the operation is cancelled', async () => {
const runner = new PnpmBuildRunner();
const abortController = new AbortController();
const startedAt = Date.now();
const timer = setTimeout(() => abortController.abort(), 50);
const result = await runner.run(
[
{
command: process.execPath,
args: ['-e', "setInterval(() => process.stdout.write('still-running\\n'), 25);"],
cwd: process.cwd(),
},
],
undefined,
{ signal: abortController.signal, terminateGraceMs: 100 }
);
clearTimeout(timer);
expect(result).toMatchObject({ ok: false, aborted: true });
expect(result.output).toContain('Build cancelled by operator.');
expect(Date.now() - startedAt).toBeLessThan(2_000);
});
});
@@ -249,6 +249,72 @@ describeDatabase('gateway operation lease and profile serialization', () => {
).resolves.toMatchObject({ status: 'SUCCEEDED' });
});
it('cancels only a running profile DEPLOY build and fences its worker lease', async () => {
const operation = await repository.createOperation({
profileName,
type: 'DEPLOY',
sourceMode: 'BRANCH',
sourceRef: 'main',
requestedBy: 'admin',
});
const now = new Date('2030-01-01T00:00:00.000Z');
await repository.claimNextOperation(now, { ownerId: 'worker-a', durationMs: 10_000 });
await repository.updateProfileForOperation?.(operation.id, 'worker-a', profileName, {
buildStatus: 'RUNNING',
buildError: 'temporary build output',
});
await repository.appendOperationLog(operation.id, {
level: 'INFO',
phase: 'build',
message: 'building profile',
});
await expect(repository.cancelOperation(operation.id)).resolves.toBe(true);
await expect(repository.getOperation(operation.id)).resolves.toMatchObject({
status: 'CANCELLED',
leaseOwner: undefined,
});
await expect(repository.getProfile(profileName)).resolves.toMatchObject({
buildStatus: 'SUCCEEDED',
buildError: undefined,
});
await expect(repository.renewOperationLease?.(operation.id, 'worker-a', now, 10_000)).resolves.toBe(false);
});
it('cancels a running Gateway build but rejects cancellation after migration starts', async () => {
const buildOperation = await releaseRepository.createOperation({
type: 'DEPLOY',
sourceMode: 'BRANCH',
sourceRef: 'main',
requestedBy: 'admin',
});
const now = new Date('2030-01-01T00:00:00.000Z');
await releaseRepository.claimNextOperation(now, { ownerId: 'release-worker', durationMs: 10_000 });
await releaseRepository.appendOperationLog(buildOperation.id, {
level: 'INFO',
phase: 'build',
message: 'building Gateway',
});
await expect(releaseRepository.cancelOperation(buildOperation.id)).resolves.toBe(true);
await expect(
releaseRepository.renewOperationLease(buildOperation.id, 'release-worker', now, 10_000)
).resolves.toBe(false);
const migrationOperation = await releaseRepository.createOperation({
type: 'DEPLOY',
sourceMode: 'BRANCH',
sourceRef: 'main',
requestedBy: 'admin',
});
await releaseRepository.claimNextOperation(now, { ownerId: 'release-worker', durationMs: 10_000 });
await releaseRepository.appendOperationLog(migrationOperation.id, {
level: 'INFO',
phase: 'migration',
message: 'migrating Gateway',
});
await expect(releaseRepository.cancelOperation(migrationOperation.id)).resolves.toBe(false);
});
it('pins retry to the first resolved commit and preserves its install generation', async () => {
const operation = await repository.createOperation({
profileName,