From 9613b482c11406b5315df46a4890c072686f2cae Mon Sep 17 00:00:00 2001 From: hided62 Date: Tue, 25 Aug 2026 09:19:12 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EC=9D=BC=EA=B4=84=20=EB=A6=B4?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=20=EC=8B=A4=ED=96=89=20=EA=B3=84=ED=9A=8D?= =?UTF-8?q?=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gateway와 권한 있는 프로필 작업을 하나의 고정 커밋과 순서로 영속화한다. 앞 대상 성공 뒤 다음 작업을 claim하고 실패 대상은 같은 작업 ID로 재시도한다. --- app/gateway-api/src/adminCapabilities.ts | 7 + app/gateway-api/src/adminRouter.ts | 256 +++++++++++++++++- .../orchestrator/gatewayReleaseRepository.ts | 75 ++++- .../src/orchestrator/profileRepository.ts | 75 ++++- app/gateway-api/test/adminOperations.test.ts | 24 ++ .../profileOperationLease.integration.test.ts | 93 +++++++ app/gateway-api/test/releaseManifest.test.ts | 2 +- docs/release-operations.md | 27 +- .../migration.sql | 38 +++ packages/infra/prisma/gateway.prisma | 24 ++ release-manifest.json | 2 +- 11 files changed, 609 insertions(+), 14 deletions(-) create mode 100644 packages/infra/prisma/gateway-migrations/20260825000000_add_bulk_release_batches/migration.sql diff --git a/app/gateway-api/src/adminCapabilities.ts b/app/gateway-api/src/adminCapabilities.ts index 71e7ca88..9f276389 100644 --- a/app/gateway-api/src/adminCapabilities.ts +++ b/app/gateway-api/src/adminCapabilities.ts @@ -120,6 +120,13 @@ export const resolveAdminActionCapability = (path: string, rawInput?: unknown): if (path.endsWith('.users.createLocal')) return 'admin.users.create'; if (path.includes('.users.')) return 'admin.users.manage'; if (path.includes('.system.')) return 'admin.notice.manage'; + if (path.endsWith('.bulkReleases.request')) { + const includeGateway = + rawInput && typeof rawInput === 'object' + ? (rawInput as { includeGateway?: unknown }).includeGateway + : false; + return includeGateway ? 'admin.releases.manage' : 'admin.profiles.deploy'; + } if (path.includes('.releases.')) return 'admin.releases.manage'; if (path.endsWith('.profiles.requestAction') && rawInput && typeof rawInput === 'object') { const action = (rawInput as { action?: unknown }).action; diff --git a/app/gateway-api/src/adminRouter.ts b/app/gateway-api/src/adminRouter.ts index 132f9ad1..bb1848fe 100644 --- a/app/gateway-api/src/adminRouter.ts +++ b/app/gateway-api/src/adminRouter.ts @@ -1,4 +1,4 @@ -import { randomBytes } from 'node:crypto'; +import { randomBytes, randomUUID } from 'node:crypto'; import { TRPCError } from '@trpc/server'; import { z } from 'zod'; @@ -1673,6 +1673,260 @@ export const adminRouter = router({ } }), }), + bulkReleases: router({ + targets: adminProcedure.query(async ({ ctx }) => { + const adminAuth = requireAdminAuth(ctx); + const profiles = orderGatewayProfiles(await ctx.profiles.listProfiles()).filter((profile) => + hasScopedPermission(adminAuth, ROLE_ADMIN_PROFILE_DEPLOY, profile.profileName) + ); + const activeOperations = await ctx.profiles.listOperations({ + statuses: ['QUEUED', 'RUNNING'], + limit: 200, + }); + const now = Date.now(); + const futureResetByProfile = new Map( + activeOperations + .filter( + (operation) => + operation.type === 'RESET' && + operation.status === 'QUEUED' && + Boolean(operation.scheduledAt) && + new Date(operation.scheduledAt ?? '').getTime() > now + ) + .map((operation) => [operation.profileName, operation]) + ); + const activeByProfile = new Map( + activeOperations + .filter((operation) => operation.id !== futureResetByProfile.get(operation.profileName)?.id) + .map((operation) => [operation.profileName, operation]) + ); + return { + gateway: hasScopedPermission(adminAuth, ROLE_ADMIN_RELEASES), + profiles: profiles.map((profile) => ({ + profileName: profile.profileName, + displayName: resolveGatewayProfileDisplayName( + profile.profile, + profile.instanceKey, + profile.meta.korName + ), + status: profile.status, + currentScenario: profile.currentScenario, + buildCommitSha: profile.buildCommitSha, + activeOperation: activeByProfile.get(profile.profileName) ?? null, + scheduledResetAt: futureResetByProfile.get(profile.profileName)?.scheduledAt, + })), + }; + }), + list: adminProcedure + .input(z.object({ limit: z.number().int().min(1).max(50).default(20) }).optional()) + .query(async ({ ctx, input }) => { + const adminAuth = requireAdminAuth(ctx); + const profileRecords = await ctx.profiles.listProfiles(); + const profileLabels = new Map( + profileRecords.map((profile) => [ + profile.profileName, + resolveGatewayProfileDisplayName(profile.profile, profile.instanceKey, profile.meta.korName), + ]) + ); + const batches = await ctx.prisma.gatewayBulkRelease.findMany({ + orderBy: { createdAt: 'desc' }, + take: input?.limit ?? 20, + include: { + gatewayOperations: true, + profileOperations: true, + }, + }); + return batches.flatMap((batch) => { + const targets = [ + ...batch.gatewayOperations + .filter(() => hasScopedPermission(adminAuth, ROLE_ADMIN_RELEASES)) + .map((operation) => ({ + kind: 'GATEWAY' as const, + order: operation.bulkOrder ?? 0, + label: 'Gateway', + operationId: operation.id, + status: operation.status, + error: operation.error ?? undefined, + startedAt: operation.startedAt?.toISOString(), + completedAt: operation.completedAt?.toISOString(), + })), + ...batch.profileOperations + .filter((operation) => + hasScopedPermission(adminAuth, ROLE_ADMIN_PROFILE_DEPLOY, operation.profileName) + ) + .map((operation) => ({ + kind: 'PROFILE' as const, + order: operation.bulkOrder ?? 0, + profileName: operation.profileName, + label: profileLabels.get(operation.profileName) ?? '삭제되었거나 접근할 수 없는 서버', + operationId: operation.id, + status: operation.status, + error: operation.error ?? undefined, + startedAt: operation.startedAt?.toISOString(), + completedAt: operation.completedAt?.toISOString(), + })), + ].sort((left, right) => left.order - right.order); + if (!targets.length) return []; + const statuses = targets.map((target) => target.status); + const status = statuses.every((value) => value === 'SUCCEEDED') + ? 'SUCCEEDED' + : statuses.some((value) => value === 'FAILED') + ? 'FAILED' + : statuses.some((value) => value === 'CANCELLED') + ? 'CANCELLED' + : statuses.some((value) => value === 'RUNNING') + ? 'RUNNING' + : 'QUEUED'; + return [ + { + id: batch.id, + sourceMode: batch.sourceMode, + sourceRef: batch.sourceRef, + resolvedCommitSha: batch.resolvedCommitSha, + reason: batch.reason ?? undefined, + requestedBy: batch.requestedBy, + createdAt: batch.createdAt.toISOString(), + status, + targets, + }, + ]; + }); + }), + request: adminProcedure + .input( + z + .object({ + includeGateway: z.boolean(), + profileNames: z.array(z.string().min(1).max(64)).max(50), + sourceMode: zSourceMode, + sourceRef: z.string().trim().min(1).max(128), + reason: z.string().trim().max(200).optional(), + }) + .refine((input) => input.includeGateway || input.profileNames.length > 0, { + message: 'At least one update target is required.', + }) + .refine((input) => new Set(input.profileNames).size === input.profileNames.length, { + message: 'Duplicate profile targets are not allowed.', + }) + ) + .mutation(async ({ ctx, input }) => { + const adminAuth = requireAdminAuth(ctx); + if (input.includeGateway) assertPermission(adminAuth, ROLE_ADMIN_RELEASES); + input.profileNames.forEach((profileName) => + assertPermission(adminAuth, ROLE_ADMIN_PROFILE_DEPLOY, profileName) + ); + + const profiles = orderGatewayProfiles( + ( + await Promise.all(input.profileNames.map((profileName) => ctx.profiles.getProfile(profileName))) + ).filter((profile): profile is NonNullable => profile !== null) + ); + if (profiles.length !== input.profileNames.length) { + throw new TRPCError({ code: 'NOT_FOUND', message: '선택한 서버를 찾을 수 없습니다.' }); + } + + let resolvedCommitSha: string; + try { + resolvedCommitSha = + input.sourceMode === 'BRANCH' + ? await resolveGitBranchCommitSha(input.sourceRef) + : await resolveGitCommitSha(input.sourceRef); + if (profiles.length) { + const scenarios = await listScenarioPreviews({ gitRef: resolvedCommitSha }); + const incompatibleProfile = profiles.find( + (profile) => + profile.currentScenario === null || + !scenarios.some((scenario) => String(scenario.id) === profile.currentScenario) + ); + if (incompatibleProfile) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `${resolveGatewayProfileDisplayName(incompatibleProfile.profile, incompatibleProfile.instanceKey, incompatibleProfile.meta.korName)}의 현재 시나리오가 대상 버전에 없습니다.`, + }); + } + } + } catch (error) { + if (error instanceof TRPCError) throw error; + throw new TRPCError({ code: 'BAD_REQUEST', message: '일괄 업데이트 소스가 올바르지 않습니다.' }); + } + + try { + return await ctx.prisma.$transaction(async (tx) => { + const batchId = randomUUID(); + const batch = await tx.gatewayBulkRelease.create({ + data: { + id: batchId, + sourceMode: input.sourceMode, + sourceRef: input.sourceRef, + resolvedCommitSha, + reason: input.reason, + requestedBy: adminAuth.user.id, + }, + }); + let order = 0; + if (input.includeGateway) { + const operation = await tx.gatewayReleaseOperation.create({ + data: { + type: 'DEPLOY', + sourceMode: 'COMMIT', + sourceRef: resolvedCommitSha, + payload: { bulkReleaseId: batchId }, + reason: input.reason, + requestedBy: adminAuth.user.id, + bulkReleaseId: batchId, + bulkOrder: order++, + }, + }); + await tx.gatewayReleaseLog.create({ + data: { + operationId: operation.id, + level: 'INFO', + phase: 'queue', + message: '일괄 업데이트의 Gateway 작업이 등록되었습니다.', + }, + }); + } + for (const profile of profiles) { + const operation = await tx.gatewayOperation.create({ + data: { + profileName: profile.profileName, + type: 'DEPLOY', + sourceMode: 'COMMIT', + sourceRef: resolvedCommitSha, + payload: { + bulkReleaseId: batchId, + releaseSource: { mode: 'COMMIT', ref: resolvedCommitSha }, + }, + reason: input.reason, + requestedBy: adminAuth.user.id, + bulkReleaseId: batchId, + bulkOrder: order++, + }, + }); + await tx.gatewayOperationLog.create({ + data: { + operationId: operation.id, + level: 'INFO', + phase: 'queue', + message: '일괄 업데이트의 DB 보존 버전 업데이트가 등록되었습니다.', + }, + }); + } + return { + id: batch.id, + resolvedCommitSha, + targetCount: order, + }; + }); + } catch (error) { + if (!isUniqueConstraintError(error)) throw error; + throw new TRPCError({ + code: 'CONFLICT', + message: '선택한 대상 중 이미 대기 또는 실행 중인 릴리스 작업이 있습니다.', + }); + } + }), + }), releases: router({ gatewayState: releaseAdminProcedure.query(({ ctx }) => ctx.releases.getState()), list: releaseAdminProcedure diff --git a/app/gateway-api/src/orchestrator/gatewayReleaseRepository.ts b/app/gateway-api/src/orchestrator/gatewayReleaseRepository.ts index 6033466b..5dbfdd5c 100644 --- a/app/gateway-api/src/orchestrator/gatewayReleaseRepository.ts +++ b/app/gateway-api/src/orchestrator/gatewayReleaseRepository.ts @@ -36,6 +36,8 @@ export interface GatewayReleaseOperationRecord { leaseUntil?: string; heartbeatAt?: string; attempts: number; + bulkReleaseId?: string; + bulkOrder?: number; createdAt: string; updatedAt: string; } @@ -135,6 +137,8 @@ const mapOperation = (row: { leaseUntil: Date | null; heartbeatAt: Date | null; attempts: number; + bulkReleaseId: string | null; + bulkOrder: number | null; createdAt: Date; updatedAt: Date; }): GatewayReleaseOperationRecord => ({ @@ -154,6 +158,8 @@ const mapOperation = (row: { leaseUntil: toIso(row.leaseUntil), heartbeatAt: toIso(row.heartbeatAt), attempts: row.attempts, + bulkReleaseId: row.bulkReleaseId ?? undefined, + bulkOrder: row.bulkOrder ?? undefined, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), }); @@ -263,12 +269,40 @@ export const createGatewayReleaseRepository = (prisma: GatewayPrismaClient): Gat if (running && !runningIsStale) { return null; } - const candidate = - running ?? - (await tx.gatewayReleaseOperation.findFirst({ + let candidate = running; + if (!candidate) { + const queuedCandidates = await tx.gatewayReleaseOperation.findMany({ where: { status: 'QUEUED' }, - orderBy: { createdAt: 'asc' }, - })); + orderBy: [{ createdAt: 'asc' }, { bulkOrder: 'asc' }], + take: 200, + }); + for (const queuedCandidate of queuedCandidates) { + if (!queuedCandidate.bulkReleaseId || queuedCandidate.bulkOrder === null) { + candidate = queuedCandidate; + break; + } + const [blockingGatewayTargets, blockingProfileTargets] = await Promise.all([ + tx.gatewayReleaseOperation.count({ + where: { + bulkReleaseId: queuedCandidate.bulkReleaseId, + bulkOrder: { lt: queuedCandidate.bulkOrder }, + status: { not: 'SUCCEEDED' }, + }, + }), + tx.gatewayOperation.count({ + where: { + bulkReleaseId: queuedCandidate.bulkReleaseId, + bulkOrder: { lt: queuedCandidate.bulkOrder }, + status: { not: 'SUCCEEDED' }, + }, + }), + ]); + if (blockingGatewayTargets + blockingProfileTargets === 0) { + candidate = queuedCandidate; + break; + } + } + } if (!candidate) { return null; } @@ -421,6 +455,37 @@ export const createGatewayReleaseRepository = (prisma: GatewayPrismaClient): Gat if (!previous || (previous.status !== 'FAILED' && previous.status !== 'CANCELLED')) { return null; } + if (previous.bulkReleaseId) { + const batch = await tx.gatewayBulkRelease.findUniqueOrThrow({ + where: { id: previous.bulkReleaseId }, + select: { resolvedCommitSha: true }, + }); + const operation = await tx.gatewayReleaseOperation.update({ + where: { id: previous.id }, + data: { + status: 'QUEUED', + sourceMode: 'COMMIT', + sourceRef: batch.resolvedCommitSha, + resolvedCommitSha: null, + requestedBy, + startedAt: null, + completedAt: null, + error: null, + leaseOwner: null, + leaseUntil: null, + heartbeatAt: null, + }, + }); + await tx.gatewayReleaseLog.create({ + data: { + operationId: operation.id, + level: 'INFO', + phase: 'queue', + message: '일괄 업데이트의 고정 커밋으로 재시도가 등록되었습니다.', + }, + }); + return operation; + } return tx.gatewayReleaseOperation.create({ data: { type: previous.type, diff --git a/app/gateway-api/src/orchestrator/profileRepository.ts b/app/gateway-api/src/orchestrator/profileRepository.ts index 9fc71f8a..ce26acb3 100644 --- a/app/gateway-api/src/orchestrator/profileRepository.ts +++ b/app/gateway-api/src/orchestrator/profileRepository.ts @@ -40,6 +40,8 @@ export interface GatewayOperationRecord { leaseUntil?: string; heartbeatAt?: string; attempts?: number; + bulkReleaseId?: string; + bulkOrder?: number; createdAt: string; updatedAt: string; } @@ -270,6 +272,8 @@ type GatewayOperationRow = { leaseUntil: Date | null; heartbeatAt: Date | null; attempts: number; + bulkReleaseId: string | null; + bulkOrder: number | null; createdAt: Date; updatedAt: Date; }; @@ -337,6 +341,8 @@ const mapOperation = (row: GatewayOperationRow): GatewayOperationRecord => ({ leaseUntil: toIso(row.leaseUntil), heartbeatAt: toIso(row.heartbeatAt), attempts: row.attempts, + bulkReleaseId: row.bulkReleaseId ?? undefined, + bulkOrder: row.bulkOrder ?? undefined, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), }); @@ -746,15 +752,43 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat })), }); } - const candidate = - running ?? - (await tx.gatewayOperation.findFirst({ + let candidate = running; + if (!candidate) { + const queuedCandidates = await tx.gatewayOperation.findMany({ where: { status: 'QUEUED', OR: [{ scheduledAt: null }, { scheduledAt: { lte: now } }], }, - orderBy: { createdAt: 'asc' }, - })); + orderBy: [{ createdAt: 'asc' }, { bulkOrder: 'asc' }], + take: 200, + }); + for (const queuedCandidate of queuedCandidates) { + if (!queuedCandidate.bulkReleaseId || queuedCandidate.bulkOrder === null) { + candidate = queuedCandidate; + break; + } + const [blockingGatewayTargets, blockingProfileTargets] = await Promise.all([ + tx.gatewayReleaseOperation.count({ + where: { + bulkReleaseId: queuedCandidate.bulkReleaseId, + bulkOrder: { lt: queuedCandidate.bulkOrder }, + status: { not: 'SUCCEEDED' }, + }, + }), + tx.gatewayOperation.count({ + where: { + bulkReleaseId: queuedCandidate.bulkReleaseId, + bulkOrder: { lt: queuedCandidate.bulkOrder }, + status: { not: 'SUCCEEDED' }, + }, + }), + ]); + if (blockingGatewayTargets + blockingProfileTargets === 0) { + candidate = queuedCandidate; + break; + } + } + } if (!candidate) { return null; } @@ -983,6 +1017,37 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat ) { throw new GatewayProfileOperationConflictError(); } + if (previous.bulkReleaseId) { + const batch = await tx.gatewayBulkRelease.findUniqueOrThrow({ + where: { id: previous.bulkReleaseId }, + select: { resolvedCommitSha: true }, + }); + const operation = await tx.gatewayOperation.update({ + where: { id: previous.id }, + data: { + status: 'QUEUED', + sourceMode: 'COMMIT', + sourceRef: batch.resolvedCommitSha, + resolvedCommitSha: null, + requestedBy, + startedAt: null, + completedAt: null, + error: null, + leaseOwner: null, + leaseUntil: null, + heartbeatAt: null, + }, + }); + await tx.gatewayOperationLog.create({ + data: { + operationId: operation.id, + level: 'INFO', + phase: 'queue', + message: '일괄 업데이트의 고정 커밋으로 재시도가 등록되었습니다.', + }, + }); + return operation; + } const previousPayload = previous.payload as GatewayPrisma.JsonObject; const retrySource = buildRetryOperationSource(previous); const operation = await tx.gatewayOperation.create({ diff --git a/app/gateway-api/test/adminOperations.test.ts b/app/gateway-api/test/adminOperations.test.ts index e0659d90..4be8c797 100644 --- a/app/gateway-api/test/adminOperations.test.ts +++ b/app/gateway-api/test/adminOperations.test.ts @@ -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', () => { diff --git a/app/gateway-api/test/profileOperationLease.integration.test.ts b/app/gateway-api/test/profileOperationLease.integration.test.ts index c016e0be..3f970e98 100644 --- a/app/gateway-api/test/profileOperationLease.integration.test.ts +++ b/app/gateway-api/test/profileOperationLease.integration.test.ts @@ -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({ diff --git a/app/gateway-api/test/releaseManifest.test.ts b/app/gateway-api/test/releaseManifest.test.ts index 818a2ee0..a08dbb67 100644 --- a/app/gateway-api/test/releaseManifest.test.ts +++ b/app/gateway-api/test/releaseManifest.test.ts @@ -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', }); }); diff --git a/docs/release-operations.md b/docs/release-operations.md index 1af71f89..f7b4958a 100644 --- a/docs/release-operations.md +++ b/docs/release-operations.md @@ -14,12 +14,37 @@ Gateway 전체는 별도 release-controller가 처리합니다. Profile 화면은 `/gateway/admin/servers/:profileName/version`과 `/gateway/admin/servers/:profileName/scenario`, Gateway 화면은 -`/gateway/admin/releases`입니다. 이전 `/gateway/admin/server-operations`는 +`/gateway/admin/releases`, 통합 화면은 `/gateway/admin/releases/batch`입니다. +이전 `/gateway/admin/server-operations`는 호환성을 위해 서버 목록으로 이동합니다. Profile 작업은 runtime/settings/deploy/reset capability로 분리되며 포괄 운영 권한은 사용하지 않습니다. Gateway 전체 릴리스에는 profile 범위 권한과 별개인 전역 `admin.releases.manage` 권한이 필요합니다. 일반 사용자와 권한이 없는 관리자는 Gateway 릴리스 영역을 사용할 수 없습니다. +### 일괄 업데이트 + +일괄 업데이트는 새 종류의 배포 엔진이 아니라 기존 Gateway release와 profile +`DEPLOY` operation을 `GatewayBulkRelease`로 묶는 durable 실행 계획입니다. + +1. 브랜치 또는 commit을 요청 시점에 하나의 full commit SHA로 고정합니다. +2. 인증 session의 `admin.releases.manage`와 각 + `admin.profiles.deploy:`을 대상별로 다시 검사합니다. +3. 묶음, Gateway release operation과 profile `DEPLOY` operation을 한 Gateway DB + transaction으로 등록합니다. 한 대상이라도 활성 작업과 충돌하면 아무것도 + 등록하지 않습니다. +4. Gateway가 포함되면 첫 순서로 실행하고, profile은 표시 순서대로 실행합니다. + 기존 전역 advisory lock은 그대로 사용하므로 동시에 여러 release build를 + 실행하지 않습니다. +5. 앞 대상이 `FAILED` 또는 `CANCELLED`이면 뒤 대상은 claim하지 않습니다. 실패 + 대상을 재시도하면 새 branch head가 아니라 묶음의 고정 SHA로 같은 operation을 + 다시 queue하고, 성공 후 다음 대상을 진행합니다. + +일괄 업데이트는 여러 대상에 대한 원자적 runtime 전환이나 자동 rollback을 약속하지 +않습니다. 이미 성공한 profile의 forward migration을 자동으로 되돌리지 않으며, +화면은 묶음 전체와 대상별 상태를 함께 표시합니다. 같은 commit의 후속 frontend와 +server build는 기존 Turbo cache를 재사용할 수 있지만, 자원 보호를 위한 기본 build +동시성 1 계약은 변경하지 않습니다. + 운영 전에 다음을 확인해 주세요. - 대상 branch 또는 전체 commit SHA가 Core2026 저장소에 존재합니다. diff --git a/packages/infra/prisma/gateway-migrations/20260825000000_add_bulk_release_batches/migration.sql b/packages/infra/prisma/gateway-migrations/20260825000000_add_bulk_release_batches/migration.sql new file mode 100644 index 00000000..74fdfe7c --- /dev/null +++ b/packages/infra/prisma/gateway-migrations/20260825000000_add_bulk_release_batches/migration.sql @@ -0,0 +1,38 @@ +CREATE TABLE "gateway_bulk_release" ( + "id" UUID NOT NULL, + "source_mode" "GatewaySourceMode" NOT NULL, + "source_ref" TEXT NOT NULL, + "resolved_commit_sha" TEXT NOT NULL, + "reason" TEXT, + "requested_by" TEXT NOT NULL, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL, + CONSTRAINT "gateway_bulk_release_pkey" PRIMARY KEY ("id") +); + +ALTER TABLE "gateway_operation" + ADD COLUMN "bulk_release_id" UUID, + ADD COLUMN "bulk_order" INTEGER; + +ALTER TABLE "gateway_release_operation" + ADD COLUMN "bulk_release_id" UUID, + ADD COLUMN "bulk_order" INTEGER; + +ALTER TABLE "gateway_operation" + ADD CONSTRAINT "gateway_operation_bulk_release_id_fkey" + FOREIGN KEY ("bulk_release_id") REFERENCES "gateway_bulk_release"("id") + ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "gateway_release_operation" + ADD CONSTRAINT "gateway_release_operation_bulk_release_id_fkey" + FOREIGN KEY ("bulk_release_id") REFERENCES "gateway_bulk_release"("id") + ON DELETE SET NULL ON UPDATE CASCADE; + +CREATE INDEX "gateway_bulk_release_created_at_idx" + ON "gateway_bulk_release"("created_at"); + +CREATE INDEX "gateway_operation_bulk_release_id_bulk_order_idx" + ON "gateway_operation"("bulk_release_id", "bulk_order"); + +CREATE INDEX "gateway_release_operation_bulk_release_id_bulk_order_idx" + ON "gateway_release_operation"("bulk_release_id", "bulk_order"); diff --git a/packages/infra/prisma/gateway.prisma b/packages/infra/prisma/gateway.prisma index f0f0d6e4..2c69dfb0 100644 --- a/packages/infra/prisma/gateway.prisma +++ b/packages/infra/prisma/gateway.prisma @@ -402,6 +402,9 @@ model GatewayOperation { leaseUntil DateTime? @map("lease_until") heartbeatAt DateTime? @map("heartbeat_at") attempts Int @default(0) + bulkReleaseId String? @map("bulk_release_id") + bulkOrder Int? @map("bulk_order") + bulkRelease GatewayBulkRelease? @relation(fields: [bulkReleaseId], references: [id], onDelete: SetNull) createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") logs GatewayOperationLog[] @@ -409,6 +412,7 @@ model GatewayOperation { @@index([status, scheduledAt, createdAt]) @@index([status, leaseUntil, createdAt]) @@index([profileName, createdAt]) + @@index([bulkReleaseId, bulkOrder]) @@map("gateway_operation") } @@ -457,15 +461,35 @@ model GatewayReleaseOperation { leaseUntil DateTime? @map("lease_until") @db.Timestamptz(6) heartbeatAt DateTime? @map("heartbeat_at") @db.Timestamptz(6) attempts Int @default(0) + bulkReleaseId String? @map("bulk_release_id") + bulkOrder Int? @map("bulk_order") + bulkRelease GatewayBulkRelease? @relation(fields: [bulkReleaseId], references: [id], onDelete: SetNull) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) logs GatewayReleaseLog[] @@index([status, leaseUntil, createdAt]) @@index([createdAt]) + @@index([bulkReleaseId, bulkOrder]) @@map("gateway_release_operation") } +model GatewayBulkRelease { + id String @id @default(uuid()) + sourceMode GatewaySourceMode @map("source_mode") + sourceRef String @map("source_ref") + resolvedCommitSha String @map("resolved_commit_sha") + reason String? + requestedBy String @map("requested_by") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + gatewayOperations GatewayReleaseOperation[] + profileOperations GatewayOperation[] + + @@index([createdAt]) + @@map("gateway_bulk_release") +} + model GatewayReleaseLog { id BigInt @id @default(autoincrement()) operationId String @map("operation_id") diff --git a/release-manifest.json b/release-manifest.json index 977c72ae..5e0bb889 100644 --- a/release-manifest.json +++ b/release-manifest.json @@ -1,7 +1,7 @@ { "formatVersion": 1, "controllerProtocol": 2, - "gatewaySchemaHead": "20260824120000_add_account_identity_management", + "gatewaySchemaHead": "20260825000000_add_bulk_release_batches", "gameSchemaHead": "20260824080000_vote_utc_wall_timestamps", "components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"] } From 7fa14b2b2083c3f93b1ddfe0a209431384936dd3 Mon Sep 17 00:00:00 2001 From: hided62 Date: Tue, 25 Aug 2026 09:19:20 +0000 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EC=9D=BC=EA=B4=84=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 권한 있는 Gateway와 서버를 체크박스로 선택하고 진행 상태와 실패 재시도를 desktop 및 mobile에서 제공한다. --- .../e2e/server-operations.spec.ts | 162 +++++ .../src/layouts/AdminConsoleLayout.vue | 8 + app/gateway-frontend/src/router/index.ts | 6 + .../src/views/AdminOverviewView.vue | 8 + app/gateway-frontend/src/views/AdminView.vue | 25 +- .../src/views/BulkReleaseView.vue | 680 ++++++++++++++++++ docs/admin-console.md | 31 +- 7 files changed, 900 insertions(+), 20 deletions(-) create mode 100644 app/gateway-frontend/src/views/BulkReleaseView.vue diff --git a/app/gateway-frontend/e2e/server-operations.spec.ts b/app/gateway-frontend/e2e/server-operations.spec.ts index b57ed750..3ae70edb 100644 --- a/app/gateway-frontend/e2e/server-operations.spec.ts +++ b/app/gateway-frontend/e2e/server-operations.spec.ts @@ -66,6 +66,23 @@ type FixtureState = { scenarioFailuresRemaining?: number; resetDefaults?: Record; updateMetaFails?: boolean; + bulkBatches?: Array<{ + id: string; + sourceMode: 'BRANCH' | 'COMMIT'; + sourceRef: string; + resolvedCommitSha: string; + requestedBy: string; + createdAt: string; + status: OperationStatus; + targets: Array<{ + kind: 'GATEWAY' | 'PROFILE'; + order: number; + label: string; + profileName?: string; + operationId: string; + status: OperationStatus; + }>; + }>; }; const profile = (runtimeRunning: boolean, resetDefaults?: Record) => ({ @@ -220,6 +237,66 @@ const installFixture = async (page: Page, state: FixtureState) => { ] ); } + if (name === 'admin.bulkReleases.targets') { + return response({ + gateway: true, + profiles: [ + { + profileName: 'che:default', + displayName: '체', + status: 'RUNNING', + currentScenario: '2', + buildCommitSha: '0123456789abcdef0123456789abcdef01234567', + activeOperation: null, + }, + { + profileName: 'hwe:default', + displayName: '환상', + status: 'RUNNING', + currentScenario: '1010', + buildCommitSha: 'fedcba9876543210fedcba9876543210fedcba98', + activeOperation: { + id: '99999999-9999-4999-8999-999999999999', + type: 'DEPLOY', + status: 'RUNNING', + }, + }, + ], + }); + } + if (name === 'admin.bulkReleases.list') { + return response(state.bulkBatches ?? []); + } + if (name === 'admin.bulkReleases.request') { + const batch = { + id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + sourceMode: 'BRANCH' as const, + sourceRef: 'main', + resolvedCommitSha: '1234567890abcdef1234567890abcdef12345678', + requestedBy: 'admin', + createdAt: '2026-08-25T01:00:00.000Z', + status: 'QUEUED' as const, + targets: [ + { + kind: 'GATEWAY' as const, + order: 0, + label: 'Gateway', + operationId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + status: 'QUEUED' as const, + }, + { + kind: 'PROFILE' as const, + order: 1, + label: '체', + profileName: 'che:default', + operationId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + status: 'QUEUED' as const, + }, + ], + }; + state.bulkBatches = [batch]; + return response({ id: batch.id, resolvedCommitSha: batch.resolvedCommitSha, targetCount: 2 }); + } if (name === 'admin.operations.list') { return response(state.operations); } @@ -530,6 +607,91 @@ const deferred = () => { return { promise, resolve }; }; +test('selects authorized Gateway and profile targets and registers one pinned bulk update', async ({ + page, +}, testInfo) => { + const state: FixtureState = { + operations: [], + gatewayOperations: [], + runtimeRunning: true, + requestBodies: [], + }; + await installFixture(page, state); + page.on('dialog', (dialog) => dialog.accept()); + + await page.goto('admin/releases/batch'); + await expect(page.getByRole('heading', { name: '일괄 업데이트', level: 1 })).toBeVisible(); + await expect(page.getByTestId('bulk-target-gateway')).toBeEnabled(); + await expect(page.getByTestId('bulk-target-che:default')).toBeEnabled(); + await expect(page.getByTestId('bulk-target-hwe:default')).toBeDisabled(); + await page.getByTestId('bulk-target-gateway').check(); + await page.getByTestId('bulk-target-che:default').check(); + await page.getByTestId('submit-bulk-release').click(); + + await expect(page.getByText('1234567890ab', { exact: true })).toBeVisible(); + await expect(page.getByText('Gateway', { exact: true }).last()).toBeVisible(); + await expect(page.getByText('체', { exact: true }).last()).toBeVisible(); + const request = state.requestBodies.find((entry) => entry.operation === 'admin.bulkReleases.request'); + expect(JSON.stringify(request?.body)).toContain('che:default'); + expect(JSON.stringify(request?.body)).toContain('includeGateway'); + + const formBox = await page.getByTestId('bulk-release-form').boundingBox(); + expect(formBox?.width ?? 0).toBeGreaterThan(700); + await page.screenshot({ path: testInfo.outputPath('bulk-release-desktop.png'), fullPage: true }); +}); + +test('keeps bulk target selection and progress readable on a 390px mobile viewport', async ({ page }, testInfo) => { + await page.setViewportSize({ width: 390, height: 844 }); + const state: FixtureState = { + operations: [], + gatewayOperations: [], + runtimeRunning: true, + requestBodies: [], + bulkBatches: [ + { + id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + sourceMode: 'COMMIT', + sourceRef: '1234567890abcdef1234567890abcdef12345678', + resolvedCommitSha: '1234567890abcdef1234567890abcdef12345678', + requestedBy: 'admin', + createdAt: '2026-08-25T01:00:00.000Z', + status: 'FAILED', + targets: [ + { + kind: 'GATEWAY', + order: 0, + label: 'Gateway', + operationId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + status: 'SUCCEEDED', + }, + { + kind: 'PROFILE', + order: 1, + label: '체', + profileName: 'che:default', + operationId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + status: 'FAILED', + }, + ], + }, + ], + }; + await installFixture(page, state); + await page.goto('admin/releases/batch'); + await page.getByText('1234567890ab', { exact: true }).click(); + await expect(page.getByRole('button', { name: '재시도' })).toBeVisible(); + + const metrics = await page.evaluate(() => ({ + scrollWidth: document.documentElement.scrollWidth, + clientWidth: document.documentElement.clientWidth, + checkboxSize: getComputedStyle(document.querySelector('[data-testid="bulk-target-gateway"]')!) + .width, + })); + expect(metrics.scrollWidth).toBeLessThanOrEqual(metrics.clientWidth); + expect(Number.parseFloat(metrics.checkboxSize)).toBeGreaterThanOrEqual(18); + await page.screenshot({ path: testInfo.outputPath('bulk-release-mobile.png'), fullPage: true }); +}); + test('separates branch and commit semantics and submits a reset from the dedicated page', async ({ page, }, testInfo) => { diff --git a/app/gateway-frontend/src/layouts/AdminConsoleLayout.vue b/app/gateway-frontend/src/layouts/AdminConsoleLayout.vue index 1e05104e..01615a26 100644 --- a/app/gateway-frontend/src/layouts/AdminConsoleLayout.vue +++ b/app/gateway-frontend/src/layouts/AdminConsoleLayout.vue @@ -80,6 +80,14 @@ const navigation = computed(() => [ { label: 'Gateway', items: [ + { + to: '/admin/releases/batch', + label: '일괄 업데이트', + icon: '⇈', + exact: false, + visible: hasCapability('admin.releases.manage') || hasCapability('admin.profiles.deploy'), + child: false, + }, { to: '/admin/releases', label: 'Gateway 릴리스', diff --git a/app/gateway-frontend/src/router/index.ts b/app/gateway-frontend/src/router/index.ts index c3f03239..10c81ba2 100644 --- a/app/gateway-frontend/src/router/index.ts +++ b/app/gateway-frontend/src/router/index.ts @@ -6,6 +6,7 @@ const OpenSuggestionView = () => import('../views/OpenSuggestionView.vue'); const AdminOverviewView = () => import('../views/AdminOverviewView.vue'); const AdminView = () => import('../views/AdminView.vue'); const ServerOperationsView = () => import('../views/ServerOperationsView.vue'); +const BulkReleaseView = () => import('../views/BulkReleaseView.vue'); const AccountView = () => import('../views/AccountView.vue'); const OAuthCallbackView = () => import('../views/OAuthCallbackView.vue'); const SignupView = () => import('../views/SignupView.vue'); @@ -86,6 +87,11 @@ const router = createRouter({ component: AdminView, props: { section: 'audit' }, }, + { + path: '/admin/releases/batch', + name: 'admin-bulk-releases', + component: BulkReleaseView, + }, { path: '/admin/releases', name: 'admin-releases', diff --git a/app/gateway-frontend/src/views/AdminOverviewView.vue b/app/gateway-frontend/src/views/AdminOverviewView.vue index 9460ad9e..3351fe71 100644 --- a/app/gateway-frontend/src/views/AdminOverviewView.vue +++ b/app/gateway-frontend/src/views/AdminOverviewView.vue @@ -35,6 +35,14 @@ const sections = computed( tone: 'emerald', visible: isRootAdmin.value || profileCount.value > 0, }, + { + to: '/admin/releases/batch', + eyebrow: 'Release batch', + title: '일괄 업데이트', + description: 'Gateway와 권한 있는 서버를 동일한 고정 커밋으로 순차 업데이트합니다.', + tone: 'blue', + visible: hasCapability('admin.releases.manage') || hasCapability('admin.profiles.deploy'), + }, { to: '/admin/releases', eyebrow: 'Releases', diff --git a/app/gateway-frontend/src/views/AdminView.vue b/app/gateway-frontend/src/views/AdminView.vue index 27b13bb8..d5b10923 100644 --- a/app/gateway-frontend/src/views/AdminView.vue +++ b/app/gateway-frontend/src/views/AdminView.vue @@ -2384,15 +2384,24 @@ onMounted(() => {
-
+

서버별 관리

- +
+ + 일괄 업데이트 + + +
+import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime'; +import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'; + +import AdminConsoleLayout from '../layouts/AdminConsoleLayout.vue'; +import { useToast } from '../composables/useToast'; +import { trpc } from '../utils/trpc'; + +type OperationStatus = 'QUEUED' | 'RUNNING' | 'SUCCEEDED' | 'FAILED' | 'CANCELLED'; +type BulkTarget = { + kind: 'GATEWAY' | 'PROFILE'; + order: number; + label: string; + profileName?: string; + operationId: string; + status: OperationStatus; + error?: string; + startedAt?: string; + completedAt?: string; +}; +type BulkRelease = { + id: string; + sourceMode: 'BRANCH' | 'COMMIT'; + sourceRef: string; + resolvedCommitSha: string; + reason?: string; + requestedBy: string; + createdAt: string; + status: OperationStatus; + targets: BulkTarget[]; +}; +type AvailableProfile = { + profileName: string; + displayName: string; + status: string; + currentScenario: string | null; + buildCommitSha?: string; + activeOperation?: { id: string; type: string; status: OperationStatus } | null; + scheduledResetAt?: string; +}; + +const adminClient = trpc.admin as unknown as { + bulkReleases: { + targets: { query: () => Promise<{ gateway: boolean; profiles: AvailableProfile[] }> }; + list: { query: (input: { limit: number }) => Promise }; + request: { + mutate: (input: { + includeGateway: boolean; + profileNames: string[]; + sourceMode: 'BRANCH' | 'COMMIT'; + sourceRef: string; + reason?: string; + }) => Promise<{ id: string; resolvedCommitSha: string; targetCount: number }>; + }; + }; + operations: { retry: { mutate: (input: { id: string }) => Promise } }; + releases: { retry: { mutate: (input: { id: string }) => Promise } }; +}; + +const form = reactive({ + sourceMode: 'BRANCH' as 'BRANCH' | 'COMMIT', + sourceRef: 'main', + reason: '', +}); +const gatewayAvailable = ref(false); +const includeGateway = ref(false); +const profiles = ref([]); +const selectedProfileNames = ref([]); +const batches = ref([]); +const loading = ref(false); +const submitting = ref(false); +const errorMessage = ref(''); +const expandedBatchId = ref(''); +const { success: showSuccessToast, error: showErrorToast } = useToast(); +let pollTimer: ReturnType | undefined; + +const selectableProfiles = computed(() => profiles.value.filter((profile) => !profile.activeOperation)); +const selectedCount = computed(() => selectedProfileNames.value.length + (includeGateway.value ? 1 : 0)); +const allProfilesSelected = computed( + () => + selectableProfiles.value.length > 0 && + selectableProfiles.value.every((profile) => selectedProfileNames.value.includes(profile.profileName)) +); +const hasActiveBatch = computed(() => + batches.value.some((batch) => batch.status === 'QUEUED' || batch.status === 'RUNNING') +); + +const statusLabel = (status: OperationStatus): string => + ({ + QUEUED: '대기 중', + RUNNING: '진행 중', + SUCCEEDED: '완료', + FAILED: '실패', + CANCELLED: '중단됨', + })[status]; + +const shortSha = (value?: string): string => value?.slice(0, 12) ?? '-'; + +const toggleAllProfiles = () => { + selectedProfileNames.value = allProfilesSelected.value + ? [] + : selectableProfiles.value.map((profile) => profile.profileName); +}; + +const loadTargets = async () => { + const result = await adminClient.bulkReleases.targets.query(); + gatewayAvailable.value = result.gateway; + profiles.value = result.profiles; + if (!gatewayAvailable.value) includeGateway.value = false; + const availableNames = new Set(selectableProfiles.value.map((profile) => profile.profileName)); + selectedProfileNames.value = selectedProfileNames.value.filter((profileName) => availableNames.has(profileName)); +}; + +const loadBatches = async () => { + batches.value = await adminClient.bulkReleases.list.query({ limit: 20 }); +}; + +const loadState = async () => { + if (loading.value) return; + loading.value = true; + try { + await Promise.all([loadTargets(), loadBatches()]); + errorMessage.value = ''; + } catch (error) { + errorMessage.value = error instanceof Error ? error.message : '일괄 업데이트 정보를 불러오지 못했습니다.'; + } finally { + loading.value = false; + } +}; + +const submit = async () => { + errorMessage.value = ''; + const sourceRef = form.sourceRef.trim(); + if (!selectedCount.value || !sourceRef) return; + const labels = [ + ...(includeGateway.value ? ['Gateway'] : []), + ...profiles.value + .filter((profile) => selectedProfileNames.value.includes(profile.profileName)) + .map((profile) => profile.displayName), + ]; + if ( + !window.confirm( + `${labels.join(' · ')}을(를) ${sourceRef}의 동일 커밋으로 순차 업데이트하시겠습니까? 각 profile의 게임 DB는 유지됩니다.` + ) + ) { + return; + } + submitting.value = true; + try { + const result = await adminClient.bulkReleases.request.mutate({ + includeGateway: includeGateway.value, + profileNames: selectedProfileNames.value, + sourceMode: form.sourceMode, + sourceRef, + reason: form.reason.trim() || undefined, + }); + includeGateway.value = false; + selectedProfileNames.value = []; + expandedBatchId.value = result.id; + showSuccessToast( + `${result.targetCount}개 대상의 일괄 업데이트를 ${shortSha(result.resolvedCommitSha)}로 등록했습니다.` + ); + await loadState(); + } catch (error) { + errorMessage.value = error instanceof Error ? error.message : '일괄 업데이트 등록에 실패했습니다.'; + showErrorToast(errorMessage.value); + } finally { + submitting.value = false; + } +}; + +const retryTarget = async (target: BulkTarget) => { + if (!window.confirm(`${target.label} 작업을 일괄 업데이트의 고정 커밋으로 다시 실행하시겠습니까?`)) return; + try { + if (target.kind === 'GATEWAY') { + await adminClient.releases.retry.mutate({ id: target.operationId }); + } else { + await adminClient.operations.retry.mutate({ id: target.operationId }); + } + showSuccessToast(`${target.label} 재시도를 등록했습니다.`); + await loadState(); + } catch (error) { + showErrorToast(error instanceof Error ? error.message : '재시도 등록에 실패했습니다.'); + } +}; + +onMounted(async () => { + await loadState(); + pollTimer = setInterval(() => void loadState(), 2_000); +}); + +onBeforeUnmount(() => { + if (pollTimer) clearInterval(pollTimer); +}); + + + + + diff --git a/docs/admin-console.md b/docs/admin-console.md index 9fd82d1a..de89dfd0 100644 --- a/docs/admin-console.md +++ b/docs/admin-console.md @@ -8,18 +8,19 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로 좌측 메뉴는 관리 책임을 다음과 같이 분리합니다. -| 메뉴 | 경로 | 책임 | -| --------------- | ---------------------------------------------- | ------------------------------------------------------------------------- | -| 운영 개요 | `/gateway/admin` | 현재 권한으로 접근할 수 있는 관리 영역 안내 | -| 사용자 관리 | `/gateway/admin/users` | 계정 식별자·Kakao 교체, 권한, 특수 접근·제재, 아이콘 복구와 탈퇴 예약 | -| 서버 관리 | `/gateway/admin/servers` | 접근 가능한 profile 목록 | -| 서버 상태·설정 | `/gateway/admin/servers/:profileName` | 해당 profile의 공개 정보, 계정 정책, 실행 상태와 게임 운영 동작 | -| 버전 업데이트 | `/gateway/admin/servers/:profileName/version` | 현 DB를 보존하는 profile 코드·migration 배포 | -| 시나리오 초기화 | `/gateway/admin/servers/:profileName/scenario` | 서버 지정 branch 최신 또는 고정 commit으로 현 시즌 DB와 시나리오 교체 | -| 게임 취소 | `/gateway/admin/servers/:profileName/cancel` | 잘못 연 게임을 닫고 기록·유산 포인트를 취소 정책에 따라 원자적으로 정산 | -| Gateway 릴리스 | `/gateway/admin/releases` | Gateway control plane 배포와 rollback | -| 공지 · 접속 | `/gateway/admin/system` | 로비 공지와 관리자 세션 연결 | -| 감사 로그 | `/gateway/admin/audit` | 관리자 조치 결과, 대상과 사유 조회 | +| 메뉴 | 경로 | 책임 | +| --------------- | ---------------------------------------------- | ----------------------------------------------------------------------- | +| 운영 개요 | `/gateway/admin` | 현재 권한으로 접근할 수 있는 관리 영역 안내 | +| 사용자 관리 | `/gateway/admin/users` | 계정 식별자·Kakao 교체, 권한, 특수 접근·제재, 아이콘 복구와 탈퇴 예약 | +| 서버 관리 | `/gateway/admin/servers` | 접근 가능한 profile 목록 | +| 서버 상태·설정 | `/gateway/admin/servers/:profileName` | 해당 profile의 공개 정보, 계정 정책, 실행 상태와 게임 운영 동작 | +| 버전 업데이트 | `/gateway/admin/servers/:profileName/version` | 현 DB를 보존하는 profile 코드·migration 배포 | +| 시나리오 초기화 | `/gateway/admin/servers/:profileName/scenario` | 서버 지정 branch 최신 또는 고정 commit으로 현 시즌 DB와 시나리오 교체 | +| 게임 취소 | `/gateway/admin/servers/:profileName/cancel` | 잘못 연 게임을 닫고 기록·유산 포인트를 취소 정책에 따라 원자적으로 정산 | +| 일괄 업데이트 | `/gateway/admin/releases/batch` | Gateway와 권한 있는 profile을 하나의 고정 commit으로 순차 DB 보존 배포 | +| Gateway 릴리스 | `/gateway/admin/releases` | Gateway control plane 배포와 rollback | +| 공지 · 접속 | `/gateway/admin/system` | 로비 공지와 관리자 세션 연결 | +| 감사 로그 | `/gateway/admin/audit` | 관리자 조치 결과, 대상과 사유 조회 | 기존 `/gateway/admin/server-operations` 링크는 query string을 보존한 채 `/gateway/admin/servers`로 이동합니다. 즐겨찾기와 이전 운영 보고서의 링크를 @@ -106,6 +107,12 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로 사용하며 외부 release-controller가 실행합니다. 선택한 릴리스 작업의 단계와 명령 출력을 관리자 화면이 long polling으로 이어 받아 표시하며, 완료된 이력의 로그도 다시 열 수 있습니다. +- 일괄 업데이트는 `admin.releases.manage`가 있는 경우에만 Gateway를, 각 + `admin.profiles.deploy:` 범위 안의 profile만 선택 대상으로 표시합니다. + 브랜치는 묶음 등록 시 서버에서 한 번 full commit SHA로 해석하며 Gateway를 먼저, + profile은 관리자 목록 순서대로 실행합니다. 앞 대상이 실패하거나 중단되면 뒤 대상은 + `QUEUED`로 유지되고, 실패 대상을 같은 고정 commit으로 재시도하면 이어서 실행합니다. + 묶음은 원자적 rollback이 아니므로 이미 성공한 대상은 그대로 유지합니다. - 브라우저의 메뉴 노출은 편의 기능입니다. 권한 판단의 기준은 서버가 인증 session에서 해석한 capability입니다.