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
@@ -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,