feat: 일괄 릴리스 실행 계획을 추가한다
Gateway와 권한 있는 프로필 작업을 하나의 고정 커밋과 순서로 영속화한다. 앞 대상 성공 뒤 다음 작업을 claim하고 실패 대상은 같은 작업 ID로 재시도한다.
This commit is contained in:
@@ -1071,6 +1071,30 @@ describe('admin operation API', () => {
|
||||
);
|
||||
expect(capabilities).not.toContainEqual(expect.objectContaining({ permission: 'admin.profiles.manage' }));
|
||||
});
|
||||
|
||||
it('lists only bulk-update targets covered by the authenticated release capabilities', async () => {
|
||||
const profileOperator = await buildCaller(
|
||||
async () => {
|
||||
throw new Error('not used');
|
||||
},
|
||||
{ adminRoles: ['admin.profiles.deploy:che:2'], firstUserIsAdmin: false }
|
||||
);
|
||||
await expect(profileOperator.caller.admin.bulkReleases.targets()).resolves.toMatchObject({
|
||||
gateway: false,
|
||||
profiles: [{ profileName: 'che:2' }],
|
||||
});
|
||||
|
||||
const gatewayOperator = await buildCaller(
|
||||
async () => {
|
||||
throw new Error('not used');
|
||||
},
|
||||
{ adminRoles: ['admin.releases.manage'], firstUserIsAdmin: false }
|
||||
);
|
||||
await expect(gatewayOperator.caller.admin.bulkReleases.targets()).resolves.toEqual({
|
||||
gateway: true,
|
||||
profiles: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('profile operation progress API', () => {
|
||||
|
||||
@@ -36,6 +36,7 @@ describeDatabase('gateway operation lease and profile serialization', () => {
|
||||
await connector.prisma.gatewayOperation.deleteMany({
|
||||
where: { profileName: { in: [profileName, secondProfileName] } },
|
||||
});
|
||||
await connector.prisma.gatewayBulkRelease.deleteMany();
|
||||
await connector.prisma.gatewayProfile.updateMany({
|
||||
where: { profileName: { in: [profileName, secondProfileName] } },
|
||||
data: { buildStatus: 'IDLE', buildError: null },
|
||||
@@ -46,6 +47,7 @@ describeDatabase('gateway operation lease and profile serialization', () => {
|
||||
await connector.prisma.gatewayOperation.deleteMany({
|
||||
where: { profileName: { in: [profileName, secondProfileName] } },
|
||||
});
|
||||
await connector.prisma.gatewayBulkRelease.deleteMany();
|
||||
await connector.prisma.gatewayProfile.deleteMany({
|
||||
where: { profileName: { in: [profileName, secondProfileName] } },
|
||||
});
|
||||
@@ -270,6 +272,97 @@ describeDatabase('gateway operation lease and profile serialization', () => {
|
||||
).resolves.toMatchObject({ id: profileOperation.id });
|
||||
});
|
||||
|
||||
it('runs a bulk release in Gateway-first order and pauses later profiles until a failed target retries', async () => {
|
||||
const fixedCommit = 'd'.repeat(40);
|
||||
const created = await connector.prisma.$transaction(async (tx) => {
|
||||
const batch = await tx.gatewayBulkRelease.create({
|
||||
data: {
|
||||
sourceMode: 'BRANCH',
|
||||
sourceRef: 'main',
|
||||
resolvedCommitSha: fixedCommit,
|
||||
requestedBy: 'bulk-admin',
|
||||
},
|
||||
});
|
||||
const gateway = await tx.gatewayReleaseOperation.create({
|
||||
data: {
|
||||
type: 'DEPLOY',
|
||||
sourceMode: 'COMMIT',
|
||||
sourceRef: fixedCommit,
|
||||
requestedBy: 'bulk-admin',
|
||||
bulkReleaseId: batch.id,
|
||||
bulkOrder: 0,
|
||||
},
|
||||
});
|
||||
const firstProfile = await tx.gatewayOperation.create({
|
||||
data: {
|
||||
profileName,
|
||||
type: 'DEPLOY',
|
||||
sourceMode: 'COMMIT',
|
||||
sourceRef: fixedCommit,
|
||||
requestedBy: 'bulk-admin',
|
||||
bulkReleaseId: batch.id,
|
||||
bulkOrder: 1,
|
||||
},
|
||||
});
|
||||
const secondProfile = await tx.gatewayOperation.create({
|
||||
data: {
|
||||
profileName: secondProfileName,
|
||||
type: 'DEPLOY',
|
||||
sourceMode: 'COMMIT',
|
||||
sourceRef: fixedCommit,
|
||||
requestedBy: 'bulk-admin',
|
||||
bulkReleaseId: batch.id,
|
||||
bulkOrder: 2,
|
||||
},
|
||||
});
|
||||
return { gateway, firstProfile, secondProfile };
|
||||
});
|
||||
const now = new Date('2030-01-01T00:00:00.000Z');
|
||||
|
||||
await expect(
|
||||
repository.claimNextOperation(now, { ownerId: 'profile-worker', durationMs: 10_000 })
|
||||
).resolves.toBeNull();
|
||||
await expect(
|
||||
releaseRepository.claimNextOperation(now, { ownerId: 'release-worker', durationMs: 10_000 })
|
||||
).resolves.toMatchObject({ id: created.gateway.id, sourceRef: fixedCommit });
|
||||
await releaseRepository.completeOperation(
|
||||
created.gateway.id,
|
||||
'SUCCEEDED',
|
||||
{ resolvedCommitSha: fixedCommit, error: null },
|
||||
'release-worker'
|
||||
);
|
||||
|
||||
await expect(
|
||||
repository.claimNextOperation(now, { ownerId: 'profile-worker', durationMs: 10_000 })
|
||||
).resolves.toMatchObject({ id: created.firstProfile.id, sourceRef: fixedCommit });
|
||||
await repository.completeOperation(
|
||||
created.firstProfile.id,
|
||||
'FAILED',
|
||||
{ resolvedCommitSha: fixedCommit, error: 'fixture failure' },
|
||||
'profile-worker'
|
||||
);
|
||||
await expect(
|
||||
repository.claimNextOperation(now, { ownerId: 'profile-worker', durationMs: 10_000 })
|
||||
).resolves.toBeNull();
|
||||
|
||||
await expect(repository.retryOperation(created.firstProfile.id, 'retry-admin')).resolves.toMatchObject({
|
||||
id: created.firstProfile.id,
|
||||
status: 'QUEUED',
|
||||
sourceMode: 'COMMIT',
|
||||
sourceRef: fixedCommit,
|
||||
});
|
||||
await repository.claimNextOperation(now, { ownerId: 'profile-worker', durationMs: 10_000 });
|
||||
await repository.completeOperation(
|
||||
created.firstProfile.id,
|
||||
'SUCCEEDED',
|
||||
{ resolvedCommitSha: fixedCommit, error: null },
|
||||
'profile-worker'
|
||||
);
|
||||
await expect(
|
||||
repository.claimNextOperation(now, { ownerId: 'profile-worker', durationMs: 10_000 })
|
||||
).resolves.toMatchObject({ id: created.secondProfile.id, sourceRef: fixedCommit });
|
||||
});
|
||||
|
||||
it('does not let a future queued operation suppress runtime reconciliation early', async () => {
|
||||
const now = new Date('2030-01-01T00:00:00.000Z');
|
||||
await repository.createOperation({
|
||||
|
||||
@@ -38,7 +38,7 @@ describe('readReleaseManifest', () => {
|
||||
|
||||
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
|
||||
controllerProtocol: RELEASE_CONTROLLER_PROTOCOL,
|
||||
gatewaySchemaHead: '20260824120000_add_account_identity_management',
|
||||
gatewaySchemaHead: '20260825000000_add_bulk_release_batches',
|
||||
gameSchemaHead: '20260824080000_vote_utc_wall_timestamps',
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user