merge: 일괄 업데이트 기능을 통합한다

This commit is contained in:
2026-08-25 09:19:57 +00:00
18 changed files with 1509 additions and 34 deletions
+7
View File
@@ -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;
+255 -1
View File
@@ -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<typeof profile> => 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
@@ -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,
@@ -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({
@@ -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({
+1 -1
View File
@@ -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',
});
});
@@ -66,6 +66,23 @@ type FixtureState = {
scenarioFailuresRemaining?: number;
resetDefaults?: Record<string, unknown>;
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<string, unknown>) => ({
@@ -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<HTMLInputElement>('[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) => {
@@ -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 릴리스',
+6
View File
@@ -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',
@@ -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',
+17 -8
View File
@@ -2384,15 +2384,24 @@ onMounted(() => {
</div>
<div v-if="section === 'servers'" class="space-y-4">
<div class="flex items-center justify-between">
<div class="flex flex-wrap items-center justify-between gap-2">
<h3 class="text-lg font-semibold">서버별 관리</h3>
<button
class="bg-zinc-700 hover:bg-zinc-600 text-white text-sm px-3 py-1.5 rounded"
:disabled="profilesLoading"
@click="loadProfiles"
>
새로고침
</button>
<div class="flex items-center gap-2">
<RouterLink
v-if="hasCapability('admin.profiles.deploy')"
to="/admin/releases/batch"
class="rounded bg-sky-700 px-3 py-1.5 text-sm font-semibold text-white hover:bg-sky-600"
>
일괄 업데이트
</RouterLink>
<button
class="bg-zinc-700 hover:bg-zinc-600 text-white text-sm px-3 py-1.5 rounded"
:disabled="profilesLoading"
@click="loadProfiles"
>
새로고침
</button>
</div>
</div>
<div
v-for="profile in visibleProfiles"
@@ -0,0 +1,680 @@
<script setup lang="ts">
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<BulkRelease[]> };
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<unknown> } };
releases: { retry: { mutate: (input: { id: string }) => Promise<unknown> } };
};
const form = reactive({
sourceMode: 'BRANCH' as 'BRANCH' | 'COMMIT',
sourceRef: 'main',
reason: '',
});
const gatewayAvailable = ref(false);
const includeGateway = ref(false);
const profiles = ref<AvailableProfile[]>([]);
const selectedProfileNames = ref<string[]>([]);
const batches = ref<BulkRelease[]>([]);
const loading = ref(false);
const submitting = ref(false);
const errorMessage = ref('');
const expandedBatchId = ref('');
const { success: showSuccessToast, error: showErrorToast } = useToast();
let pollTimer: ReturnType<typeof setInterval> | 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);
});
</script>
<template>
<AdminConsoleLayout
title="일괄 업데이트"
description="Gateway와 권한이 있는 서버를 하나의 고정 커밋으로 순차 업데이트합니다."
eyebrow="Release batch"
>
<div class="space-y-6">
<section class="batch-panel space-y-5" data-testid="bulk-release-form">
<div class="batch-heading">
<div>
<h2> 일괄 업데이트</h2>
<p>Gateway를 먼저 처리하고, 선택한 서버는 표시 순서대로 DB 유지 배포합니다.</p>
</div>
<button type="button" class="secondary-button" :disabled="loading" @click="loadState">
새로고침
</button>
</div>
<div class="source-grid">
<label>
<span>소스 종류</span>
<select v-model="form.sourceMode" data-testid="bulk-source-mode">
<option value="BRANCH">브랜치</option>
<option value="COMMIT">커밋</option>
</select>
</label>
<label>
<span>브랜치 또는 전체 commit SHA</span>
<input v-model="form.sourceRef" class="font-mono" data-testid="bulk-source-ref" />
</label>
<label>
<span>작업 사유</span>
<input v-model="form.reason" maxlength="200" placeholder="운영 메모" />
</label>
</div>
<fieldset class="target-fieldset">
<legend>업데이트 대상</legend>
<label v-if="gatewayAvailable" class="target-row gateway-target">
<input v-model="includeGateway" type="checkbox" data-testid="bulk-target-gateway" />
<span class="target-copy">
<strong>Gateway</strong>
<small>API · frontend · orchestrator</small>
</span>
<span class="target-badge">먼저 실행</span>
</label>
<div class="target-toolbar">
<span>서버 {{ selectableProfiles.length }}</span>
<button
type="button"
class="text-button"
:disabled="!selectableProfiles.length"
@click="toggleAllProfiles"
>
{{ allProfilesSelected ? '서버 선택 해제' : '권한 있는 서버 전체 선택' }}
</button>
</div>
<label
v-for="profile in profiles"
:key="profile.profileName"
class="target-row"
:class="{ blocked: Boolean(profile.activeOperation) }"
>
<input
v-model="selectedProfileNames"
type="checkbox"
:value="profile.profileName"
:disabled="Boolean(profile.activeOperation)"
:data-testid="`bulk-target-${profile.profileName}`"
/>
<span class="target-copy">
<strong>{{ profile.displayName }}</strong>
<small>현재 {{ shortSha(profile.buildCommitSha) }} · {{ profile.status }}</small>
</span>
<span v-if="profile.activeOperation" class="target-badge warning">
{{ statusLabel(profile.activeOperation.status) }} 작업 있음
</span>
<span v-else-if="profile.scheduledResetAt" class="target-badge warning">초기화 예약 유지</span>
<span v-else-if="profile.currentScenario === null" class="target-badge warning"
>시나리오 미설정</span
>
<span v-else class="target-badge">DB 유지</span>
</label>
</fieldset>
<div v-if="errorMessage" class="error-box" role="alert">{{ errorMessage }}</div>
<div class="submit-row">
<p>
선택 {{ selectedCount }} · branch도 등록 하나의 commit SHA로 고정됩니다. 실패한 대상 뒤의
작업은 재시도 전까지 대기합니다.
</p>
<button
type="button"
class="primary-button"
:disabled="submitting || !selectedCount || !form.sourceRef.trim() || hasActiveBatch"
data-testid="submit-bulk-release"
@click="submit"
>
{{ submitting ? '등록 중…' : `선택 ${selectedCount}개 일괄 업데이트` }}
</button>
</div>
<p v-if="hasActiveBatch" class="active-notice">
진행 중인 일괄 업데이트가 끝난 묶음을 등록할 있습니다.
</p>
</section>
<section class="space-y-3" aria-labelledby="bulk-history-title">
<div class="batch-heading">
<div>
<h2 id="bulk-history-title">일괄 업데이트 이력</h2>
<p>묶음은 원자적 rollback이 아니며, 성공한 대상은 그대로 유지됩니다.</p>
</div>
</div>
<div v-if="!batches.length" class="empty-state">등록된 일괄 업데이트가 없습니다.</div>
<article v-for="batch in batches" :key="batch.id" class="batch-history" :data-status="batch.status">
<button
type="button"
class="batch-summary"
:aria-expanded="expandedBatchId === batch.id"
:aria-controls="`bulk-release-${batch.id}`"
@click="expandedBatchId = expandedBatchId === batch.id ? '' : batch.id"
>
<span>
<strong>{{ shortSha(batch.resolvedCommitSha) }}</strong>
<small
>{{ batch.sourceMode }} {{ batch.sourceRef }} ·
{{ formatServerDateTime(batch.createdAt) }}</small
>
</span>
<span class="status-pill" :data-status="batch.status">{{ statusLabel(batch.status) }}</span>
</button>
<div v-if="expandedBatchId === batch.id" :id="`bulk-release-${batch.id}`" class="batch-details">
<p v-if="batch.reason" class="batch-reason">사유: {{ batch.reason }}</p>
<ol class="target-progress">
<li v-for="target in batch.targets" :key="target.operationId">
<span class="target-order">{{ target.order + 1 }}</span>
<span class="target-progress-copy">
<strong>{{ target.label }}</strong>
<small v-if="target.error">{{ target.error }}</small>
<small v-else-if="target.completedAt"
>완료 {{ formatServerDateTime(target.completedAt) }}</small
>
<small v-else-if="target.startedAt"
>시작 {{ formatServerDateTime(target.startedAt) }}</small
>
<small v-else> 작업 완료 대기</small>
</span>
<span class="status-pill" :data-status="target.status">{{
statusLabel(target.status)
}}</span>
<button
v-if="target.status === 'FAILED' || target.status === 'CANCELLED'"
type="button"
class="secondary-button compact"
@click="retryTarget(target)"
>
재시도
</button>
<RouterLink
v-else
class="detail-link"
:to="
target.kind === 'GATEWAY'
? '/admin/releases'
: `/admin/servers/${encodeURIComponent(target.profileName ?? '')}/version`
"
>
상세
</RouterLink>
</li>
</ol>
</div>
</article>
</section>
</div>
</AdminConsoleLayout>
</template>
<style scoped>
.batch-panel,
.batch-history,
.empty-state {
border: 1px solid #27272a;
border-radius: 10px;
background: #111113;
}
.batch-panel {
padding: 20px;
}
.batch-heading,
.submit-row,
.target-toolbar,
.batch-summary,
.target-row,
.target-progress li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
}
.batch-heading h2 {
margin: 0;
font-size: 18px;
}
.batch-heading p,
.submit-row p {
margin: 5px 0 0;
color: #a1a1aa;
font-size: 12px;
line-height: 1.6;
}
.source-grid {
display: grid;
grid-template-columns: minmax(130px, 0.6fr) minmax(220px, 1.4fr) minmax(200px, 1fr);
gap: 12px;
}
.source-grid label {
display: grid;
gap: 6px;
color: #a1a1aa;
font-size: 12px;
}
.source-grid input,
.source-grid select {
min-width: 0;
border: 1px solid #3f3f46;
border-radius: 6px;
background: #09090b;
padding: 10px 12px;
color: #fafafa;
}
.target-fieldset {
border: 0;
padding: 0;
}
.target-fieldset legend {
margin-bottom: 10px;
color: #d4d4d8;
font-size: 13px;
font-weight: 700;
}
.target-row {
min-height: 58px;
border: 1px solid #27272a;
border-radius: 7px;
background: #09090b;
padding: 10px 12px;
cursor: pointer;
}
.target-row + .target-row {
margin-top: 8px;
}
.target-row.gateway-target {
margin-bottom: 14px;
border-color: #5b21b6;
background: #1e1234;
}
.target-row.blocked {
cursor: not-allowed;
opacity: 0.62;
}
.target-row input {
width: 18px;
height: 18px;
flex: 0 0 auto;
accent-color: #0ea5e9;
}
.target-copy,
.target-progress-copy,
.batch-summary > span:first-child {
display: grid;
min-width: 0;
flex: 1;
gap: 3px;
}
.target-copy small,
.target-progress-copy small,
.batch-summary small {
overflow-wrap: anywhere;
color: #71717a;
font-size: 11px;
}
.target-toolbar {
margin: 0 0 8px;
color: #a1a1aa;
font-size: 12px;
}
.target-badge,
.status-pill {
flex: 0 0 auto;
border: 1px solid #3f3f46;
border-radius: 999px;
padding: 4px 8px;
color: #d4d4d8;
font-size: 10px;
}
.target-badge.warning {
border-color: #92400e;
color: #fcd34d;
}
.primary-button,
.secondary-button,
.text-button {
border: 0;
border-radius: 6px;
font-weight: 700;
cursor: pointer;
}
.primary-button {
min-width: 220px;
background: #0369a1;
padding: 11px 16px;
color: white;
}
.secondary-button {
background: #3f3f46;
padding: 8px 12px;
color: white;
}
.secondary-button.compact {
padding: 6px 9px;
font-size: 11px;
}
.text-button {
background: transparent;
padding: 4px;
color: #7dd3fc;
}
button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.error-box,
.active-notice {
border: 1px solid #7f1d1d;
border-radius: 6px;
background: #450a0a66;
padding: 10px 12px;
color: #fecaca;
font-size: 12px;
}
.active-notice {
margin: 0;
border-color: #854d0e;
background: #42200666;
color: #fde68a;
}
.empty-state {
padding: 28px;
color: #71717a;
text-align: center;
}
.batch-summary {
width: 100%;
border: 0;
background: transparent;
padding: 15px 16px;
color: #fafafa;
text-align: left;
cursor: pointer;
}
.batch-details {
border-top: 1px solid #27272a;
padding: 14px 16px 16px;
}
.batch-reason {
margin: 0 0 12px;
color: #a1a1aa;
font-size: 12px;
}
.target-progress {
display: grid;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.target-progress li {
border-radius: 6px;
background: #09090b;
padding: 10px;
}
.target-order {
display: grid;
width: 24px;
height: 24px;
flex: 0 0 auto;
place-items: center;
border-radius: 50%;
background: #27272a;
color: #d4d4d8;
font-size: 11px;
}
.status-pill[data-status='RUNNING'] {
border-color: #047857;
color: #6ee7b7;
}
.status-pill[data-status='SUCCEEDED'] {
border-color: #155e75;
color: #67e8f9;
}
.status-pill[data-status='FAILED'] {
border-color: #991b1b;
color: #fca5a5;
}
.status-pill[data-status='QUEUED'] {
border-color: #92400e;
color: #fcd34d;
}
.detail-link {
color: #7dd3fc;
font-size: 11px;
font-weight: 700;
text-decoration: none;
}
@media (max-width: 760px) {
.source-grid {
grid-template-columns: 1fr;
}
.batch-heading,
.submit-row {
align-items: stretch;
flex-direction: column;
}
.primary-button {
width: 100%;
min-width: 0;
}
.target-progress li {
display: grid;
grid-template-columns: auto 1fr auto;
}
.target-progress li .secondary-button,
.target-progress li .detail-link {
grid-column: 2 / -1;
justify-self: start;
}
}
</style>
+19 -12
View File
@@ -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:<name>` 범위 안의 profile만 선택 대상으로 표시합니다.
브랜치는 묶음 등록 시 서버에서 한 번 full commit SHA로 해석하며 Gateway를 먼저,
profile은 관리자 목록 순서대로 실행합니다. 앞 대상이 실패하거나 중단되면 뒤 대상은
`QUEUED`로 유지되고, 실패 대상을 같은 고정 commit으로 재시도하면 이어서 실행합니다.
묶음은 원자적 rollback이 아니므로 이미 성공한 대상은 그대로 유지합니다.
- 브라우저의 메뉴 노출은 편의 기능입니다. 권한 판단의 기준은 서버가 인증
session에서 해석한 capability입니다.
+26 -1
View File
@@ -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:<profileName>`을 대상별로 다시 검사합니다.
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 저장소에 존재합니다.
@@ -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");
+24
View File
@@ -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")
+1 -1
View File
@@ -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"]
}