merge: 일괄 업데이트 기능을 통합한다
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,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',
|
||||
|
||||
@@ -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
@@ -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입니다.
|
||||
|
||||
|
||||
@@ -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 저장소에 존재합니다.
|
||||
|
||||
+38
@@ -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");
|
||||
@@ -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,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"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user