feat(gateway): persist profile operation build logs
This commit is contained in:
@@ -1088,6 +1088,44 @@ export const adminRouter = router({
|
|||||||
.sort((left, right) => right.createdAt.localeCompare(left.createdAt));
|
.sort((left, right) => right.createdAt.localeCompare(left.createdAt));
|
||||||
return operations.slice(0, input?.limit ?? 50);
|
return operations.slice(0, input?.limit ?? 50);
|
||||||
}),
|
}),
|
||||||
|
logs: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
afterCursor: z.string().regex(/^\d+$/u).optional(),
|
||||||
|
limit: z.number().int().min(1).max(500).default(200),
|
||||||
|
timeoutMs: z.number().int().min(0).max(25_000).default(20_000),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const adminAuth = requireAdminAuth(ctx);
|
||||||
|
const initialOperation = await ctx.profiles.getOperation(input.id);
|
||||||
|
if (!initialOperation) {
|
||||||
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile operation not found.' });
|
||||||
|
}
|
||||||
|
if (!canReadProfile(adminAuth, initialOperation.profileName)) {
|
||||||
|
throw new TRPCError({ code: 'FORBIDDEN', message: 'Permission denied.' });
|
||||||
|
}
|
||||||
|
const deadline = Date.now() + input.timeoutMs;
|
||||||
|
while (true) {
|
||||||
|
const [operation, entries] = await Promise.all([
|
||||||
|
ctx.profiles.getOperation(input.id),
|
||||||
|
ctx.profiles.listOperationLogs(input.id, input.afterCursor, input.limit),
|
||||||
|
]);
|
||||||
|
if (!operation) {
|
||||||
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile operation not found.' });
|
||||||
|
}
|
||||||
|
const terminal = ['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(operation.status);
|
||||||
|
if (entries.length || terminal || Date.now() >= deadline) {
|
||||||
|
return {
|
||||||
|
operation,
|
||||||
|
entries,
|
||||||
|
nextCursor: entries.at(-1)?.cursor ?? input.afterCursor,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, 250));
|
||||||
|
}
|
||||||
|
}),
|
||||||
requestReset: adminProcedure
|
requestReset: adminProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import fs from 'node:fs/promises';
|
|||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
||||||
|
import { stripVTControlCharacters } from 'node:util';
|
||||||
|
|
||||||
import type { ScenarioInstallOptions } from '@sammo-ts/game-engine/scenario/scenarioSeeder.js';
|
import type { ScenarioInstallOptions } from '@sammo-ts/game-engine/scenario/scenarioSeeder.js';
|
||||||
import {
|
import {
|
||||||
@@ -12,7 +13,13 @@ import {
|
|||||||
} from '@sammo-ts/infra';
|
} from '@sammo-ts/infra';
|
||||||
import { isRecord } from '@sammo-ts/common';
|
import { isRecord } from '@sammo-ts/common';
|
||||||
|
|
||||||
import { buildTurboReleaseCommand, type BuildCommand, type BuildRunner } from './buildRunner.js';
|
import {
|
||||||
|
buildTurboReleaseCommand,
|
||||||
|
type BuildCommand,
|
||||||
|
type BuildProgressEvent,
|
||||||
|
type BuildProgressObserver,
|
||||||
|
type BuildRunner,
|
||||||
|
} from './buildRunner.js';
|
||||||
import { sanitizeManagedProcessEnv, type ProcessManager } from './processManager.js';
|
import { sanitizeManagedProcessEnv, type ProcessManager } from './processManager.js';
|
||||||
import type {
|
import type {
|
||||||
GatewayClaimedProfileUpdate,
|
GatewayClaimedProfileUpdate,
|
||||||
@@ -76,6 +83,8 @@ export interface GatewayOrchestratorHandle {
|
|||||||
listRuntimeStates(profileNames: string[]): Promise<ProfileRuntimeSnapshot[]>;
|
listRuntimeStates(profileNames: string[]): Promise<ProfileRuntimeSnapshot[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SENSITIVE_ENV_NAME = /(SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE_KEY|CLIENT_SECRET|DATABASE_URL|REDIS_URL)/iu;
|
||||||
|
|
||||||
export const planProfileReconcile = (
|
export const planProfileReconcile = (
|
||||||
status: GatewayProfileStatus,
|
status: GatewayProfileStatus,
|
||||||
runtime: ProfileRuntimeState
|
runtime: ProfileRuntimeState
|
||||||
@@ -589,6 +598,57 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
((profileName) => this.clearTournamentRuntimeStateFromRedis(profileName));
|
((profileName) => this.clearTournamentRuntimeStateFromRedis(profileName));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sanitizeOperationLogMessage(message: string): string {
|
||||||
|
let sanitized = stripVTControlCharacters(message);
|
||||||
|
const sensitiveValues = new Set([
|
||||||
|
this.processConfig.gameTokenSecret,
|
||||||
|
...Object.entries(this.processConfig.baseEnv ?? {})
|
||||||
|
.filter(([name]) => SENSITIVE_ENV_NAME.test(name))
|
||||||
|
.map(([, value]) => value),
|
||||||
|
]);
|
||||||
|
for (const secret of sensitiveValues) {
|
||||||
|
if (secret && secret.length >= 4) sanitized = sanitized.replaceAll(secret, '[REDACTED]');
|
||||||
|
}
|
||||||
|
return sanitized.replace(/(:\/\/[^:\s/@]+:)[^@\s/]+@/gu, '$1[REDACTED]@').slice(0, 4_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async appendOperationLog(
|
||||||
|
operationId: string,
|
||||||
|
phase: string,
|
||||||
|
message: string,
|
||||||
|
level: 'INFO' | 'OUTPUT' | 'ERROR' = 'INFO'
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.repository.appendOperationLog(operationId, {
|
||||||
|
level,
|
||||||
|
phase,
|
||||||
|
message: this.sanitizeOperationLogMessage(message),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Progress logging must not make an otherwise recoverable profile operation fail.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly buildProgress =
|
||||||
|
(operationId: string, phase: string): BuildProgressObserver =>
|
||||||
|
async (event: BuildProgressEvent) => {
|
||||||
|
if (event.type === 'OUTPUT') {
|
||||||
|
if (event.message) await this.appendOperationLog(operationId, phase, event.message, 'OUTPUT');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const command = [event.command.command, ...event.command.args].join(' ');
|
||||||
|
if (event.type === 'COMMAND_START') {
|
||||||
|
await this.appendOperationLog(operationId, phase, `$ ${command}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.appendOperationLog(
|
||||||
|
operationId,
|
||||||
|
phase,
|
||||||
|
`${command} 종료 (exit ${event.exitCode ?? 'unknown'})`,
|
||||||
|
event.exitCode === 0 ? 'INFO' : 'ERROR'
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
start(): void {
|
start(): void {
|
||||||
this.stopping = false;
|
this.stopping = false;
|
||||||
this.trackTask(this.reconcileNow());
|
this.trackTask(this.reconcileNow());
|
||||||
@@ -837,8 +897,14 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
|
|
||||||
private async handleOperation(operation: GatewayOperationRecord): Promise<void> {
|
private async handleOperation(operation: GatewayOperationRecord): Promise<void> {
|
||||||
const assertLease = () => this.assertOperationLease(operation.id);
|
const assertLease = () => this.assertOperationLease(operation.id);
|
||||||
|
await this.appendOperationLog(
|
||||||
|
operation.id,
|
||||||
|
'claim',
|
||||||
|
`${operation.type} 작업을 시작합니다. 시도 ${operation.attempts ?? 1}회차.`
|
||||||
|
);
|
||||||
const profile = await this.repository.getProfile(operation.profileName);
|
const profile = await this.repository.getProfile(operation.profileName);
|
||||||
if (!profile) {
|
if (!profile) {
|
||||||
|
await this.appendOperationLog(operation.id, 'failed', 'Profile not found.', 'ERROR');
|
||||||
await this.repository.completeOperation(
|
await this.repository.completeOperation(
|
||||||
operation.id,
|
operation.id,
|
||||||
'FAILED',
|
'FAILED',
|
||||||
@@ -871,6 +937,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
let resolvedCommitSha: string | undefined;
|
let resolvedCommitSha: string | undefined;
|
||||||
try {
|
try {
|
||||||
if (operation.type === 'START') {
|
if (operation.type === 'START') {
|
||||||
|
await this.appendOperationLog(operation.id, 'runtime', '프로필 process를 시작합니다.');
|
||||||
const updated = await updateOperationProfile(
|
const updated = await updateOperationProfile(
|
||||||
{
|
{
|
||||||
status: 'RUNNING',
|
status: 'RUNNING',
|
||||||
@@ -892,10 +959,12 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
);
|
);
|
||||||
throw new Error('Failed to start profile processes.');
|
throw new Error('Failed to start profile processes.');
|
||||||
}
|
}
|
||||||
|
await this.appendOperationLog(operation.id, 'runtime', '프로필 process 시작을 완료했습니다.');
|
||||||
await updateOperationProfile({ lastError: null }, async () => {
|
await updateOperationProfile({ lastError: null }, async () => {
|
||||||
await this.repository.updateLastError(profile.profileName, null);
|
await this.repository.updateLastError(profile.profileName, null);
|
||||||
return this.repository.getProfile(profile.profileName);
|
return this.repository.getProfile(profile.profileName);
|
||||||
});
|
});
|
||||||
|
await this.appendOperationLog(operation.id, 'complete', 'START 작업이 완료되었습니다.');
|
||||||
await this.repository.completeOperation(
|
await this.repository.completeOperation(
|
||||||
operation.id,
|
operation.id,
|
||||||
'SUCCEEDED',
|
'SUCCEEDED',
|
||||||
@@ -905,10 +974,12 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (operation.type === 'STOP') {
|
if (operation.type === 'STOP') {
|
||||||
|
await this.appendOperationLog(operation.id, 'runtime', '프로필 process를 정지합니다.');
|
||||||
await updateOperationProfile({ status: 'STOPPED' }, () =>
|
await updateOperationProfile({ status: 'STOPPED' }, () =>
|
||||||
this.repository.updateStatus(profile.profileName, 'STOPPED')
|
this.repository.updateStatus(profile.profileName, 'STOPPED')
|
||||||
);
|
);
|
||||||
await this.stopProfile(profile, assertLease);
|
await this.stopProfile(profile, assertLease);
|
||||||
|
await this.appendOperationLog(operation.id, 'complete', 'STOP 작업이 완료되었습니다.');
|
||||||
await this.repository.completeOperation(
|
await this.repository.completeOperation(
|
||||||
operation.id,
|
operation.id,
|
||||||
'SUCCEEDED',
|
'SUCCEEDED',
|
||||||
@@ -921,6 +992,11 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
if (!operation.sourceMode || !operation.sourceRef) {
|
if (!operation.sourceMode || !operation.sourceRef) {
|
||||||
throw new Error('Reset source mode and ref are required.');
|
throw new Error('Reset source mode and ref are required.');
|
||||||
}
|
}
|
||||||
|
await this.appendOperationLog(
|
||||||
|
operation.id,
|
||||||
|
'resolve',
|
||||||
|
`${operation.sourceMode} ${operation.sourceRef} 커밋을 해석합니다.`
|
||||||
|
);
|
||||||
const commitSha =
|
const commitSha =
|
||||||
operation.resolvedCommitSha ??
|
operation.resolvedCommitSha ??
|
||||||
(await this.workspaceManager.resolveCommit(operation.sourceMode, operation.sourceRef));
|
(await this.workspaceManager.resolveCommit(operation.sourceMode, operation.sourceRef));
|
||||||
@@ -935,12 +1011,14 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
throw new OperationLeaseLostError(`Operation lease lost while pinning commit: ${operation.id}`);
|
throw new OperationLeaseLostError(`Operation lease lost while pinning commit: ${operation.id}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
await this.appendOperationLog(operation.id, 'resolve', `대상 커밋을 ${commitSha}로 고정했습니다.`);
|
||||||
await assertLease();
|
await assertLease();
|
||||||
if (operation.type === 'DEPLOY') {
|
if (operation.type === 'DEPLOY') {
|
||||||
const result = await this.handleProfileDeploy(profile, commitSha, assertLease, operation.id);
|
const result = await this.handleProfileDeploy(profile, commitSha, assertLease, operation.id);
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
throw new Error(result.detail);
|
throw new Error(result.detail);
|
||||||
}
|
}
|
||||||
|
await this.appendOperationLog(operation.id, 'complete', 'DB 보존 버전 업데이트가 완료되었습니다.');
|
||||||
await this.repository.completeOperation(
|
await this.repository.completeOperation(
|
||||||
operation.id,
|
operation.id,
|
||||||
'SUCCEEDED',
|
'SUCCEEDED',
|
||||||
@@ -964,12 +1042,18 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
const result = await this.handleResetAction(profile, resetAction, commitSha, assertLease, operation.id);
|
const result = await this.handleResetAction(profile, resetAction, commitSha, assertLease, operation.id);
|
||||||
if (result.status === 'REQUESTED') {
|
if (result.status === 'REQUESTED') {
|
||||||
const retryAt = new Date(this.now().getTime() + this.adminActionIntervalMs).toISOString();
|
const retryAt = new Date(this.now().getTime() + this.adminActionIntervalMs).toISOString();
|
||||||
|
await this.appendOperationLog(
|
||||||
|
operation.id,
|
||||||
|
'wait',
|
||||||
|
`${result.detail ?? '작업을 다시 시도합니다.'} 다음 시도: ${retryAt}`
|
||||||
|
);
|
||||||
await this.repository.requeueOperation(operation.id, result.detail, retryAt, this.operationLeaseOwner);
|
await this.repository.requeueOperation(operation.id, result.detail, retryAt, this.operationLeaseOwner);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (result.status !== 'APPLIED') {
|
if (result.status !== 'APPLIED') {
|
||||||
throw new Error(result.detail ?? 'Reset failed.');
|
throw new Error(result.detail ?? 'Reset failed.');
|
||||||
}
|
}
|
||||||
|
await this.appendOperationLog(operation.id, 'complete', '시나리오 초기화가 완료되었습니다.');
|
||||||
await this.repository.completeOperation(
|
await this.repository.completeOperation(
|
||||||
operation.id,
|
operation.id,
|
||||||
'SUCCEEDED',
|
'SUCCEEDED',
|
||||||
@@ -987,6 +1071,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const detail = error instanceof Error ? error.message : String(error);
|
const detail = error instanceof Error ? error.message : String(error);
|
||||||
|
await this.appendOperationLog(operation.id, 'failed', detail, 'ERROR');
|
||||||
try {
|
try {
|
||||||
await this.repository.completeOperation(
|
await this.repository.completeOperation(
|
||||||
operation.id,
|
operation.id,
|
||||||
@@ -1044,7 +1129,9 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
buildStartedAt: startedAt,
|
buildStartedAt: startedAt,
|
||||||
buildError: null,
|
buildError: null,
|
||||||
});
|
});
|
||||||
|
await this.appendOperationLog(operationId, 'workspace', `커밋 ${commitSha}의 worktree를 준비합니다.`);
|
||||||
const workspace = await this.workspaceManager.prepare(commitSha);
|
const workspace = await this.workspaceManager.prepare(commitSha);
|
||||||
|
await this.appendOperationLog(operationId, 'workspace', `worktree 준비 완료: ${workspace.root}`);
|
||||||
const manifest = await readReleaseManifest(workspace.root);
|
const manifest = await readReleaseManifest(workspace.root);
|
||||||
assertReleaseComponents(manifest, ['game-api', 'game-engine', 'game-frontend']);
|
assertReleaseComponents(manifest, ['game-api', 'game-engine', 'game-frontend']);
|
||||||
const commands = [
|
const commands = [
|
||||||
@@ -1056,7 +1143,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
),
|
),
|
||||||
...buildProfileFrontendCommands(workspace.root, profile, this.processConfig.baseEnv),
|
...buildProfileFrontendCommands(workspace.root, profile, this.processConfig.baseEnv),
|
||||||
];
|
];
|
||||||
const result = await this.buildRunner.run(commands);
|
await this.appendOperationLog(operationId, 'build', `${profile.profileName} 구성 요소를 빌드합니다.`);
|
||||||
|
const result = await this.buildRunner.run(commands, this.buildProgress(operationId, 'build'));
|
||||||
await assertLease();
|
await assertLease();
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
const detail = result.output.slice(-4000) || 'selected workspace build failed';
|
const detail = result.output.slice(-4000) || 'selected workspace build failed';
|
||||||
@@ -1068,10 +1156,16 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
return { ok: false, detail };
|
return { ok: false, detail };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.appendOperationLog(operationId, 'switch', '기존 profile process를 정지합니다.');
|
||||||
await this.stopProfile(profile, assertLease);
|
await this.stopProfile(profile, assertLease);
|
||||||
oldRuntimeStopped = true;
|
oldRuntimeStopped = true;
|
||||||
const profileDatabaseUrl = this.resolveProfileDatabaseUrl(profile);
|
const profileDatabaseUrl = this.resolveProfileDatabaseUrl(profile);
|
||||||
const migration = await this.runProfileMigration(workspace.root, profileDatabaseUrl);
|
await this.appendOperationLog(operationId, 'migration', '선택 버전의 game migration을 적용합니다.');
|
||||||
|
const migration = await this.runProfileMigration(
|
||||||
|
workspace.root,
|
||||||
|
profileDatabaseUrl,
|
||||||
|
this.buildProgress(operationId, 'migration')
|
||||||
|
);
|
||||||
await assertLease();
|
await assertLease();
|
||||||
if (!migration.ok) {
|
if (!migration.ok) {
|
||||||
const detail = migration.output.slice(-4000) || 'profile database migration failed';
|
const detail = migration.output.slice(-4000) || 'profile database migration failed';
|
||||||
@@ -1086,6 +1180,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
});
|
});
|
||||||
return { ok: false, detail };
|
return { ok: false, detail };
|
||||||
}
|
}
|
||||||
|
await this.appendOperationLog(operationId, 'migration', 'game migration이 완료되었습니다.');
|
||||||
|
|
||||||
const completedAt = this.now().toISOString();
|
const completedAt = this.now().toISOString();
|
||||||
const candidate: GatewayProfileRecord = {
|
const candidate: GatewayProfileRecord = {
|
||||||
@@ -1098,7 +1193,9 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
buildError: undefined,
|
buildError: undefined,
|
||||||
};
|
};
|
||||||
if (shouldRun) {
|
if (shouldRun) {
|
||||||
|
await this.appendOperationLog(operationId, 'switch', '새 버전의 profile process를 시작합니다.');
|
||||||
const started = await this.startProfile(candidate, assertLease);
|
const started = await this.startProfile(candidate, assertLease);
|
||||||
|
await this.appendOperationLog(operationId, 'readiness', 'profile process readiness를 확인합니다.');
|
||||||
const ready = started && (await this.waitForProfileReadiness(candidate, assertLease));
|
const ready = started && (await this.waitForProfileReadiness(candidate, assertLease));
|
||||||
if (!ready) {
|
if (!ready) {
|
||||||
if (started) {
|
if (started) {
|
||||||
@@ -1107,6 +1204,14 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
const rollbackStarted =
|
const rollbackStarted =
|
||||||
(await this.startProfile(profile, assertLease)) &&
|
(await this.startProfile(profile, assertLease)) &&
|
||||||
(await this.waitForProfileReadiness(profile, assertLease));
|
(await this.waitForProfileReadiness(profile, assertLease));
|
||||||
|
await this.appendOperationLog(
|
||||||
|
operationId,
|
||||||
|
'rollback',
|
||||||
|
rollbackStarted
|
||||||
|
? '새 버전 readiness 실패 후 이전 runtime을 복구했습니다.'
|
||||||
|
: '새 버전 readiness 실패 후 이전 runtime 복구도 실패했습니다.',
|
||||||
|
rollbackStarted ? 'INFO' : 'ERROR'
|
||||||
|
);
|
||||||
oldRuntimeStopped = !rollbackStarted;
|
oldRuntimeStopped = !rollbackStarted;
|
||||||
const detail = rollbackStarted
|
const detail = rollbackStarted
|
||||||
? 'new profile release failed readiness; previous runtime restored'
|
? 'new profile release failed readiness; previous runtime restored'
|
||||||
@@ -1120,6 +1225,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
});
|
});
|
||||||
return { ok: false, detail };
|
return { ok: false, detail };
|
||||||
}
|
}
|
||||||
|
await this.appendOperationLog(operationId, 'readiness', 'profile readiness 확인을 통과했습니다.');
|
||||||
}
|
}
|
||||||
await assertLease();
|
await assertLease();
|
||||||
await updateClaimedProfile({
|
await updateClaimedProfile({
|
||||||
@@ -1131,6 +1237,11 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
buildError: null,
|
buildError: null,
|
||||||
lastError: null,
|
lastError: null,
|
||||||
});
|
});
|
||||||
|
await this.appendOperationLog(
|
||||||
|
operationId,
|
||||||
|
'publish',
|
||||||
|
`${commitSha}를 active profile 버전으로 게시했습니다.`
|
||||||
|
);
|
||||||
oldRuntimeStopped = false;
|
oldRuntimeStopped = false;
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1246,6 +1357,15 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
assertLease?: () => Promise<void>,
|
assertLease?: () => Promise<void>,
|
||||||
operationId?: string
|
operationId?: string
|
||||||
): Promise<GatewayAdminActionResult> {
|
): Promise<GatewayAdminActionResult> {
|
||||||
|
const appendLog = async (
|
||||||
|
phase: string,
|
||||||
|
message: string,
|
||||||
|
level: 'INFO' | 'OUTPUT' | 'ERROR' = 'INFO'
|
||||||
|
): Promise<void> => {
|
||||||
|
if (operationId) await this.appendOperationLog(operationId, phase, message, level);
|
||||||
|
};
|
||||||
|
const buildProgress = (phase: string): BuildProgressObserver | undefined =>
|
||||||
|
operationId ? this.buildProgress(operationId, phase) : undefined;
|
||||||
// 리셋 요청을 빌드+재기동 흐름으로 처리한다.
|
// 리셋 요청을 빌드+재기동 흐름으로 처리한다.
|
||||||
if (this.resetInFlight.has(profile.profileName)) {
|
if (this.resetInFlight.has(profile.profileName)) {
|
||||||
return { status: 'REQUESTED', detail: 'reset already in progress' };
|
return { status: 'REQUESTED', detail: 'reset already in progress' };
|
||||||
@@ -1329,7 +1449,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
commitSha,
|
commitSha,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
const { result, workspace } = await this.runBuildCommands(commitSha, profile);
|
const { result, workspace } = await this.runBuildCommands(commitSha, profile, operationId);
|
||||||
await assertLease?.();
|
await assertLease?.();
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
const completedAt = this.now().toISOString();
|
const completedAt = this.now().toISOString();
|
||||||
@@ -1347,11 +1467,17 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
);
|
);
|
||||||
return { status: 'FAILED', detail: 'selected workspace build failed' };
|
return { status: 'FAILED', detail: 'selected workspace build failed' };
|
||||||
}
|
}
|
||||||
|
await appendLog('seed', '선택 버전의 profile seed CLI를 확인합니다.');
|
||||||
await this.assertProfileSeedCli(workspace.root);
|
await this.assertProfileSeedCli(workspace.root);
|
||||||
// A newly provisioned profile schema has no world_state row (or table) yet.
|
// A newly provisioned profile schema has no world_state row (or table) yet.
|
||||||
// Apply the selected release's migrations before reading optional prior-season
|
// Apply the selected release's migrations before reading optional prior-season
|
||||||
// metadata; existing profiles still expose the same season/tick values afterward.
|
// metadata; existing profiles still expose the same season/tick values afterward.
|
||||||
const migrationResult = await this.runProfileMigration(workspace.root, profileDatabaseUrl);
|
await appendLog('migration', '선택 버전의 game migration을 적용합니다.');
|
||||||
|
const migrationResult = await this.runProfileMigration(
|
||||||
|
workspace.root,
|
||||||
|
profileDatabaseUrl,
|
||||||
|
buildProgress('migration')
|
||||||
|
);
|
||||||
await assertLease?.();
|
await assertLease?.();
|
||||||
if (!migrationResult.ok) {
|
if (!migrationResult.ok) {
|
||||||
const completedAt = this.now().toISOString();
|
const completedAt = this.now().toISOString();
|
||||||
@@ -1369,6 +1495,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
);
|
);
|
||||||
return { status: 'FAILED', detail: 'profile database migration failed' };
|
return { status: 'FAILED', detail: 'profile database migration failed' };
|
||||||
}
|
}
|
||||||
|
await appendLog('migration', 'game migration이 완료되었습니다.');
|
||||||
|
await appendLog('seed', '기존 season과 tick metadata를 확인합니다.');
|
||||||
const seedInfo = await this.resolveResetSeedInfo(
|
const seedInfo = await this.resolveResetSeedInfo(
|
||||||
profile,
|
profile,
|
||||||
{
|
{
|
||||||
@@ -1384,27 +1512,33 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
await updateClaimedProfile({ status: 'STOPPED' }, () =>
|
await updateClaimedProfile({ status: 'STOPPED' }, () =>
|
||||||
this.repository.updateStatus(profile.profileName, 'STOPPED')
|
this.repository.updateStatus(profile.profileName, 'STOPPED')
|
||||||
);
|
);
|
||||||
|
await appendLog('switch', '기존 profile process를 정지합니다.');
|
||||||
await this.stopProfile(profile, assertLease);
|
await this.stopProfile(profile, assertLease);
|
||||||
await assertLease?.();
|
await assertLease?.();
|
||||||
const serverId = buildServerId(profile.profileName, seedTime, installOptions?.installOperationId);
|
const serverId = buildServerId(profile.profileName, seedTime, installOptions?.installOperationId);
|
||||||
const seedResult = await this.runSelectedProfileSeed({
|
await appendLog('seed', `시나리오 ${scenarioId}, 시즌 ${season} 초기 데이터를 생성합니다.`);
|
||||||
workspaceRoot: workspace.root,
|
const seedResult = await this.runSelectedProfileSeed(
|
||||||
databaseUrl: seedInfo.databaseUrl,
|
{
|
||||||
scenarioId,
|
workspaceRoot: workspace.root,
|
||||||
tickSeconds: seedInfo.tickSeconds,
|
databaseUrl: seedInfo.databaseUrl,
|
||||||
now: seedTime,
|
scenarioId,
|
||||||
installOptions: {
|
tickSeconds: seedInfo.tickSeconds,
|
||||||
...(installOptions ?? {}),
|
now: seedTime,
|
||||||
season,
|
installOptions: {
|
||||||
serverId,
|
...(installOptions ?? {}),
|
||||||
installCommitSha: commitSha,
|
season,
|
||||||
|
serverId,
|
||||||
|
installCommitSha: commitSha,
|
||||||
|
},
|
||||||
|
adminUser,
|
||||||
},
|
},
|
||||||
adminUser,
|
buildProgress('seed')
|
||||||
});
|
);
|
||||||
await assertLease?.();
|
await assertLease?.();
|
||||||
if (!seedResult.ok) {
|
if (!seedResult.ok) {
|
||||||
throw new Error(`Selected profile seed failed: ${seedResult.output.slice(-4000)}`);
|
throw new Error(`Selected profile seed failed: ${seedResult.output.slice(-4000)}`);
|
||||||
}
|
}
|
||||||
|
await appendLog('seed', '시나리오 초기 데이터 생성을 완료했습니다.');
|
||||||
await this.clearTournamentRuntimeState(profile.profileName);
|
await this.clearTournamentRuntimeState(profile.profileName);
|
||||||
await assertLease?.();
|
await assertLease?.();
|
||||||
const completedAt = this.now().toISOString();
|
const completedAt = this.now().toISOString();
|
||||||
@@ -1447,7 +1581,9 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
status: desiredStatus,
|
status: desiredStatus,
|
||||||
buildWorkspace: workspace.root,
|
buildWorkspace: workspace.root,
|
||||||
};
|
};
|
||||||
|
await appendLog('switch', '초기화된 profile process를 시작합니다.');
|
||||||
const started = await this.startProfile(builtProfile, assertLease);
|
const started = await this.startProfile(builtProfile, assertLease);
|
||||||
|
await appendLog('readiness', 'profile process readiness를 확인합니다.');
|
||||||
const ready = started && (await this.waitForProfileReadiness(builtProfile, assertLease));
|
const ready = started && (await this.waitForProfileReadiness(builtProfile, assertLease));
|
||||||
if (!ready) {
|
if (!ready) {
|
||||||
if (started) {
|
if (started) {
|
||||||
@@ -1461,10 +1597,12 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
);
|
);
|
||||||
return { status: 'FAILED', detail };
|
return { status: 'FAILED', detail };
|
||||||
}
|
}
|
||||||
|
await appendLog('readiness', 'profile readiness 확인을 통과했습니다.');
|
||||||
await updateClaimedProfile({ lastError: null }, async () => {
|
await updateClaimedProfile({ lastError: null }, async () => {
|
||||||
await this.repository.updateLastError(profile.profileName, null);
|
await this.repository.updateLastError(profile.profileName, null);
|
||||||
return this.repository.getProfile(profile.profileName);
|
return this.repository.getProfile(profile.profileName);
|
||||||
});
|
});
|
||||||
|
await appendLog('publish', `${commitSha}와 시나리오 ${scenarioId} 초기화 상태를 게시했습니다.`);
|
||||||
return { status: 'APPLIED', detail: 'reset completed via rebuild' };
|
return { status: 'APPLIED', detail: 'reset completed via rebuild' };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof OperationLeaseLostError) {
|
if (error instanceof OperationLeaseLostError) {
|
||||||
@@ -1537,12 +1675,19 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
|
|
||||||
private async runBuildCommands(
|
private async runBuildCommands(
|
||||||
commitSha: string,
|
commitSha: string,
|
||||||
profile?: GatewayProfileRecord
|
profile?: GatewayProfileRecord,
|
||||||
|
operationId?: string
|
||||||
): Promise<{
|
): Promise<{
|
||||||
result: Awaited<ReturnType<BuildRunner['run']>>;
|
result: Awaited<ReturnType<BuildRunner['run']>>;
|
||||||
workspace: Awaited<ReturnType<GitWorkspaceManager['prepare']>>;
|
workspace: Awaited<ReturnType<GitWorkspaceManager['prepare']>>;
|
||||||
}> {
|
}> {
|
||||||
|
if (operationId) {
|
||||||
|
await this.appendOperationLog(operationId, 'workspace', `커밋 ${commitSha}의 worktree를 준비합니다.`);
|
||||||
|
}
|
||||||
const workspace = await this.workspaceManager.prepare(commitSha);
|
const workspace = await this.workspaceManager.prepare(commitSha);
|
||||||
|
if (operationId) {
|
||||||
|
await this.appendOperationLog(operationId, 'workspace', `worktree 준비 완료: ${workspace.root}`);
|
||||||
|
}
|
||||||
const commands = [
|
const commands = [
|
||||||
...buildWorkspaceCommands(
|
...buildWorkspaceCommands(
|
||||||
workspace.root,
|
workspace.root,
|
||||||
@@ -1552,7 +1697,20 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
),
|
),
|
||||||
...(profile ? buildProfileFrontendCommands(workspace.root, profile, this.processConfig.baseEnv) : []),
|
...(profile ? buildProfileFrontendCommands(workspace.root, profile, this.processConfig.baseEnv) : []),
|
||||||
];
|
];
|
||||||
return { result: await this.buildRunner.run(commands), workspace };
|
if (operationId) {
|
||||||
|
await this.appendOperationLog(
|
||||||
|
operationId,
|
||||||
|
'build',
|
||||||
|
`${profile?.profileName ?? 'profile'} 구성 요소를 빌드합니다.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
result: await this.buildRunner.run(
|
||||||
|
commands,
|
||||||
|
operationId ? this.buildProgress(operationId, 'build') : undefined
|
||||||
|
),
|
||||||
|
workspace,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async assertProfileSeedCli(workspaceRoot: string): Promise<void> {
|
private async assertProfileSeedCli(workspaceRoot: string): Promise<void> {
|
||||||
@@ -1566,22 +1724,27 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
|
|
||||||
private async runProfileMigration(
|
private async runProfileMigration(
|
||||||
workspaceRoot: string,
|
workspaceRoot: string,
|
||||||
profileDatabaseUrl: string
|
profileDatabaseUrl: string,
|
||||||
|
onProgress?: BuildProgressObserver
|
||||||
): Promise<Awaited<ReturnType<BuildRunner['run']>>> {
|
): Promise<Awaited<ReturnType<BuildRunner['run']>>> {
|
||||||
return this.buildRunner.run([
|
return this.buildRunner.run(
|
||||||
buildProfileMigrationCommand(workspaceRoot, profileDatabaseUrl, this.processConfig.baseEnv),
|
[buildProfileMigrationCommand(workspaceRoot, profileDatabaseUrl, this.processConfig.baseEnv)],
|
||||||
]);
|
onProgress
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async runSelectedProfileSeed(options: {
|
private async runSelectedProfileSeed(
|
||||||
workspaceRoot: string;
|
options: {
|
||||||
databaseUrl: string;
|
workspaceRoot: string;
|
||||||
scenarioId: number;
|
databaseUrl: string;
|
||||||
tickSeconds?: number;
|
scenarioId: number;
|
||||||
now: Date;
|
tickSeconds?: number;
|
||||||
installOptions?: ScenarioInstallOptions;
|
now: Date;
|
||||||
adminUser?: AdminSeedUser | null;
|
installOptions?: ScenarioInstallOptions;
|
||||||
}): Promise<Awaited<ReturnType<BuildRunner['run']>>> {
|
adminUser?: AdminSeedUser | null;
|
||||||
|
},
|
||||||
|
onProgress?: BuildProgressObserver
|
||||||
|
): Promise<Awaited<ReturnType<BuildRunner['run']>>> {
|
||||||
const tempDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-profile-seed-'));
|
const tempDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-profile-seed-'));
|
||||||
const requestFile = path.join(tempDirectory, 'request.json');
|
const requestFile = path.join(tempDirectory, 'request.json');
|
||||||
try {
|
try {
|
||||||
@@ -1601,19 +1764,22 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
}),
|
}),
|
||||||
{ encoding: 'utf8', mode: 0o600 }
|
{ encoding: 'utf8', mode: 0o600 }
|
||||||
);
|
);
|
||||||
return await this.buildRunner.run([
|
return await this.buildRunner.run(
|
||||||
{
|
[
|
||||||
command: process.execPath,
|
{
|
||||||
args: [path.join(options.workspaceRoot, 'app', 'gateway-api', 'dist', 'index.js')],
|
command: process.execPath,
|
||||||
cwd: options.workspaceRoot,
|
args: [path.join(options.workspaceRoot, 'app', 'gateway-api', 'dist', 'index.js')],
|
||||||
env: {
|
cwd: options.workspaceRoot,
|
||||||
...(this.processConfig.baseEnv ?? {}),
|
env: {
|
||||||
DATABASE_URL: options.databaseUrl,
|
...(this.processConfig.baseEnv ?? {}),
|
||||||
GATEWAY_ROLE: 'profile-seed',
|
DATABASE_URL: options.databaseUrl,
|
||||||
PROFILE_SEED_REQUEST_FILE: requestFile,
|
GATEWAY_ROLE: 'profile-seed',
|
||||||
|
PROFILE_SEED_REQUEST_FILE: requestFile,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
],
|
||||||
]);
|
onProgress
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
await fs.rm(tempDirectory, { recursive: true, force: true });
|
await fs.rm(tempDirectory, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,24 @@ export interface GatewayOperationCreateInput {
|
|||||||
scheduledAt?: string;
|
scheduledAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const GATEWAY_OPERATION_LOG_LEVELS = ['INFO', 'OUTPUT', 'ERROR'] as const;
|
||||||
|
export type GatewayOperationLogLevel = (typeof GATEWAY_OPERATION_LOG_LEVELS)[number];
|
||||||
|
|
||||||
|
export interface GatewayOperationLogRecord {
|
||||||
|
cursor: string;
|
||||||
|
operationId: string;
|
||||||
|
level: GatewayOperationLogLevel;
|
||||||
|
phase: string;
|
||||||
|
message: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GatewayOperationLogInput {
|
||||||
|
level: GatewayOperationLogLevel;
|
||||||
|
phase: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface GatewayProfileRecord {
|
export interface GatewayProfileRecord {
|
||||||
profileName: string;
|
profileName: string;
|
||||||
profile: string;
|
profile: string;
|
||||||
@@ -145,6 +163,8 @@ export interface GatewayProfileRepository {
|
|||||||
listOperations(options?: { profileName?: string; limit?: number }): Promise<GatewayOperationRecord[]>;
|
listOperations(options?: { profileName?: string; limit?: number }): Promise<GatewayOperationRecord[]>;
|
||||||
listActiveOperationProfileNames?(now: Date): Promise<string[]>;
|
listActiveOperationProfileNames?(now: Date): Promise<string[]>;
|
||||||
getOperation(id: string): Promise<GatewayOperationRecord | null>;
|
getOperation(id: string): Promise<GatewayOperationRecord | null>;
|
||||||
|
listOperationLogs(id: string, afterCursor?: string, limit?: number): Promise<GatewayOperationLogRecord[]>;
|
||||||
|
appendOperationLog(id: string, input: GatewayOperationLogInput): Promise<GatewayOperationLogRecord>;
|
||||||
createOperation(input: GatewayOperationCreateInput): Promise<GatewayOperationRecord>;
|
createOperation(input: GatewayOperationCreateInput): Promise<GatewayOperationRecord>;
|
||||||
claimNextOperation(
|
claimNextOperation(
|
||||||
now: Date,
|
now: Date,
|
||||||
@@ -290,6 +310,24 @@ const mapOperation = (row: GatewayOperationRow): GatewayOperationRecord => ({
|
|||||||
updatedAt: row.updatedAt.toISOString(),
|
updatedAt: row.updatedAt.toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const mapOperationLog = (row: {
|
||||||
|
id: bigint;
|
||||||
|
operationId: string;
|
||||||
|
level: string;
|
||||||
|
phase: string;
|
||||||
|
message: string;
|
||||||
|
createdAt: Date;
|
||||||
|
}): GatewayOperationLogRecord => ({
|
||||||
|
cursor: row.id.toString(),
|
||||||
|
operationId: row.operationId,
|
||||||
|
level: GATEWAY_OPERATION_LOG_LEVELS.includes(row.level as GatewayOperationLogLevel)
|
||||||
|
? (row.level as GatewayOperationLogLevel)
|
||||||
|
: 'INFO',
|
||||||
|
phase: row.phase,
|
||||||
|
message: row.message,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): GatewayProfileRepository => ({
|
export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): GatewayProfileRepository => ({
|
||||||
async listProfiles(): Promise<GatewayProfileRecord[]> {
|
async listProfiles(): Promise<GatewayProfileRecord[]> {
|
||||||
const rows = await prisma.gatewayProfile.findMany({
|
const rows = await prisma.gatewayProfile.findMany({
|
||||||
@@ -511,18 +549,51 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat
|
|||||||
const row = await prisma.gatewayOperation.findUnique({ where: { id } });
|
const row = await prisma.gatewayOperation.findUnique({ where: { id } });
|
||||||
return row ? mapOperation(row) : null;
|
return row ? mapOperation(row) : null;
|
||||||
},
|
},
|
||||||
async createOperation(input: GatewayOperationCreateInput): Promise<GatewayOperationRecord> {
|
async listOperationLogs(id, afterCursor, limit = 200) {
|
||||||
const row = await prisma.gatewayOperation.create({
|
const rows = await prisma.gatewayOperationLog.findMany({
|
||||||
data: {
|
where: {
|
||||||
profileName: input.profileName,
|
operationId: id,
|
||||||
type: input.type,
|
...(afterCursor ? { id: { gt: BigInt(afterCursor) } } : {}),
|
||||||
sourceMode: input.sourceMode,
|
|
||||||
sourceRef: input.sourceRef,
|
|
||||||
payload: (input.payload ?? {}) as GatewayPrisma.JsonObject,
|
|
||||||
reason: input.reason,
|
|
||||||
requestedBy: input.requestedBy,
|
|
||||||
scheduledAt: input.scheduledAt ? new Date(input.scheduledAt) : null,
|
|
||||||
},
|
},
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
take: Math.min(Math.max(limit, 1), 500),
|
||||||
|
});
|
||||||
|
return rows.map(mapOperationLog);
|
||||||
|
},
|
||||||
|
async appendOperationLog(id, input) {
|
||||||
|
const row = await prisma.gatewayOperationLog.create({
|
||||||
|
data: {
|
||||||
|
operationId: id,
|
||||||
|
level: input.level,
|
||||||
|
phase: input.phase.slice(0, 64),
|
||||||
|
message: input.message.slice(0, 4_000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return mapOperationLog(row);
|
||||||
|
},
|
||||||
|
async createOperation(input: GatewayOperationCreateInput): Promise<GatewayOperationRecord> {
|
||||||
|
const row = await prisma.$transaction(async (tx) => {
|
||||||
|
const operation = await tx.gatewayOperation.create({
|
||||||
|
data: {
|
||||||
|
profileName: input.profileName,
|
||||||
|
type: input.type,
|
||||||
|
sourceMode: input.sourceMode,
|
||||||
|
sourceRef: input.sourceRef,
|
||||||
|
payload: (input.payload ?? {}) as GatewayPrisma.JsonObject,
|
||||||
|
reason: input.reason,
|
||||||
|
requestedBy: input.requestedBy,
|
||||||
|
scheduledAt: input.scheduledAt ? new Date(input.scheduledAt) : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await tx.gatewayOperationLog.create({
|
||||||
|
data: {
|
||||||
|
operationId: operation.id,
|
||||||
|
level: 'INFO',
|
||||||
|
phase: 'queue',
|
||||||
|
message: `${input.type} 작업이 등록되었습니다.`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return operation;
|
||||||
});
|
});
|
||||||
return mapOperation(row);
|
return mapOperation(row);
|
||||||
},
|
},
|
||||||
@@ -697,11 +768,24 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat
|
|||||||
return mapOperation(row);
|
return mapOperation(row);
|
||||||
},
|
},
|
||||||
async cancelOperation(id: string): Promise<boolean> {
|
async cancelOperation(id: string): Promise<boolean> {
|
||||||
const result = await prisma.gatewayOperation.updateMany({
|
const count = await prisma.$transaction(async (tx) => {
|
||||||
where: { id, status: 'QUEUED' },
|
const result = await tx.gatewayOperation.updateMany({
|
||||||
data: { status: 'CANCELLED', completedAt: new Date() },
|
where: { id, status: 'QUEUED' },
|
||||||
|
data: { status: 'CANCELLED', completedAt: new Date() },
|
||||||
|
});
|
||||||
|
if (result.count === 1) {
|
||||||
|
await tx.gatewayOperationLog.create({
|
||||||
|
data: {
|
||||||
|
operationId: id,
|
||||||
|
level: 'INFO',
|
||||||
|
phase: 'cancel',
|
||||||
|
message: '대기 중인 작업이 취소되었습니다.',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result.count;
|
||||||
});
|
});
|
||||||
return result.count === 1;
|
return count === 1;
|
||||||
},
|
},
|
||||||
async retryOperation(id: string, requestedBy: string): Promise<GatewayOperationRecord | null> {
|
async retryOperation(id: string, requestedBy: string): Promise<GatewayOperationRecord | null> {
|
||||||
const row = await prisma.$transaction(async (tx) => {
|
const row = await prisma.$transaction(async (tx) => {
|
||||||
@@ -711,7 +795,7 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat
|
|||||||
}
|
}
|
||||||
const previousPayload = previous.payload as GatewayPrisma.JsonObject;
|
const previousPayload = previous.payload as GatewayPrisma.JsonObject;
|
||||||
const retrySource = buildRetryOperationSource(previous);
|
const retrySource = buildRetryOperationSource(previous);
|
||||||
return tx.gatewayOperation.create({
|
const operation = await tx.gatewayOperation.create({
|
||||||
data: {
|
data: {
|
||||||
profileName: previous.profileName,
|
profileName: previous.profileName,
|
||||||
type: previous.type,
|
type: previous.type,
|
||||||
@@ -723,6 +807,15 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat
|
|||||||
scheduledAt: null,
|
scheduledAt: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
await tx.gatewayOperationLog.create({
|
||||||
|
data: {
|
||||||
|
operationId: operation.id,
|
||||||
|
level: 'INFO',
|
||||||
|
phase: 'queue',
|
||||||
|
message: `작업 ${previous.id}의 재시도가 등록되었습니다.`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return operation;
|
||||||
});
|
});
|
||||||
return row ? mapOperation(row) : null;
|
return row ? mapOperation(row) : null;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { InMemoryGatewaySessionService } from '../src/auth/inMemorySessionServic
|
|||||||
import { createInMemoryUserRepository } from '../src/auth/inMemoryUserRepository.js';
|
import { createInMemoryUserRepository } from '../src/auth/inMemoryUserRepository.js';
|
||||||
import type {
|
import type {
|
||||||
GatewayOperationCreateInput,
|
GatewayOperationCreateInput,
|
||||||
|
GatewayOperationRecord,
|
||||||
GatewayProfileRecord,
|
GatewayProfileRecord,
|
||||||
GatewayProfileRepository,
|
GatewayProfileRepository,
|
||||||
} from '../src/orchestrator/profileRepository.js';
|
} from '../src/orchestrator/profileRepository.js';
|
||||||
@@ -30,6 +31,8 @@ const buildCaller = async (
|
|||||||
initialProfileStatus?: GatewayProfileRecord['status'];
|
initialProfileStatus?: GatewayProfileRecord['status'];
|
||||||
profileScenario?: string;
|
profileScenario?: string;
|
||||||
profileMeta?: GatewayProfileRecord['meta'];
|
profileMeta?: GatewayProfileRecord['meta'];
|
||||||
|
initialOperation?: GatewayOperationRecord;
|
||||||
|
profileLogVisibilityAfterPolls?: number;
|
||||||
releaseLogVisibilityAfterPolls?: number;
|
releaseLogVisibilityAfterPolls?: number;
|
||||||
} = {}
|
} = {}
|
||||||
) => {
|
) => {
|
||||||
@@ -49,6 +52,14 @@ const buildCaller = async (
|
|||||||
const createdInputs: GatewayOperationCreateInput[] = [];
|
const createdInputs: GatewayOperationCreateInput[] = [];
|
||||||
const createdReleaseInputs: GatewayReleaseOperationCreateInput[] = [];
|
const createdReleaseInputs: GatewayReleaseOperationCreateInput[] = [];
|
||||||
const appendedReleaseLogs: Array<{ operationId: string; phase: string; message: string }> = [];
|
const appendedReleaseLogs: Array<{ operationId: string; phase: string; message: string }> = [];
|
||||||
|
const profileLogs: Array<{
|
||||||
|
cursor: string;
|
||||||
|
operationId: string;
|
||||||
|
level: 'INFO' | 'OUTPUT' | 'ERROR';
|
||||||
|
phase: string;
|
||||||
|
message: string;
|
||||||
|
createdAt: string;
|
||||||
|
}> = [];
|
||||||
const releaseLogs = [
|
const releaseLogs = [
|
||||||
{
|
{
|
||||||
cursor: '1',
|
cursor: '1',
|
||||||
@@ -60,7 +71,9 @@ const buildCaller = async (
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
let releaseLogPollCount = 0;
|
let releaseLogPollCount = 0;
|
||||||
|
let profileLogPollCount = 0;
|
||||||
const operationRecords = new Map<string, Awaited<ReturnType<GatewayProfileRepository['createOperation']>>>();
|
const operationRecords = new Map<string, Awaited<ReturnType<GatewayProfileRepository['createOperation']>>>();
|
||||||
|
if (options.initialOperation) operationRecords.set(options.initialOperation.id, options.initialOperation);
|
||||||
const createdRuntimeActions: Array<Record<string, unknown>> = [];
|
const createdRuntimeActions: Array<Record<string, unknown>> = [];
|
||||||
const flushes: Array<{ userId: string; reason?: string; iconRevision?: string }> = [];
|
const flushes: Array<{ userId: string; reason?: string; iconRevision?: string }> = [];
|
||||||
const updatedStatuses: GatewayProfileRecord['status'][] = [];
|
const updatedStatuses: GatewayProfileRecord['status'][] = [];
|
||||||
@@ -102,6 +115,28 @@ const buildCaller = async (
|
|||||||
clearWorkspaceUsage: async () => {},
|
clearWorkspaceUsage: async () => {},
|
||||||
listOperations: async () => [],
|
listOperations: async () => [],
|
||||||
getOperation: async (id) => operationRecords.get(id) ?? null,
|
getOperation: async (id) => operationRecords.get(id) ?? null,
|
||||||
|
listOperationLogs: async (id, afterCursor) => {
|
||||||
|
profileLogPollCount += 1;
|
||||||
|
if (
|
||||||
|
options.profileLogVisibilityAfterPolls !== undefined &&
|
||||||
|
profileLogPollCount < options.profileLogVisibilityAfterPolls
|
||||||
|
) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return profileLogs.filter(
|
||||||
|
(entry) => entry.operationId === id && (!afterCursor || BigInt(entry.cursor) > BigInt(afterCursor))
|
||||||
|
);
|
||||||
|
},
|
||||||
|
appendOperationLog: async (operationId, input) => {
|
||||||
|
const entry = {
|
||||||
|
cursor: String(profileLogs.length + 1),
|
||||||
|
operationId,
|
||||||
|
createdAt: new Date(Date.UTC(2026, 7, 1, 0, 0, profileLogs.length + 1)).toISOString(),
|
||||||
|
...input,
|
||||||
|
};
|
||||||
|
profileLogs.push(entry);
|
||||||
|
return entry;
|
||||||
|
},
|
||||||
createOperation: async (input) => {
|
createOperation: async (input) => {
|
||||||
createdInputs.push(input);
|
createdInputs.push(input);
|
||||||
const operation = await createOperation(input);
|
const operation = await createOperation(input);
|
||||||
@@ -295,6 +330,7 @@ const buildCaller = async (
|
|||||||
createdInputs,
|
createdInputs,
|
||||||
createdReleaseInputs,
|
createdReleaseInputs,
|
||||||
appendedReleaseLogs,
|
appendedReleaseLogs,
|
||||||
|
profileLogs,
|
||||||
createdRuntimeActions,
|
createdRuntimeActions,
|
||||||
users,
|
users,
|
||||||
admin,
|
admin,
|
||||||
@@ -306,6 +342,7 @@ const buildCaller = async (
|
|||||||
getRuntimeStateListCount: () => runtimeStateListCount,
|
getRuntimeStateListCount: () => runtimeStateListCount,
|
||||||
getStoredNotice: () => storedNotice,
|
getStoredNotice: () => storedNotice,
|
||||||
getReleaseLogPollCount: () => releaseLogPollCount,
|
getReleaseLogPollCount: () => releaseLogPollCount,
|
||||||
|
getProfileLogPollCount: () => profileLogPollCount,
|
||||||
setStoredNotice: (notice: string) => {
|
setStoredNotice: (notice: string) => {
|
||||||
storedNotice = notice;
|
storedNotice = notice;
|
||||||
},
|
},
|
||||||
@@ -702,6 +739,78 @@ describe('admin operation API', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('profile operation progress API', () => {
|
||||||
|
it('long-polls durable build logs with the current profile operation state', async () => {
|
||||||
|
const operationId = '33333333-3333-4333-8333-333333333333';
|
||||||
|
const harness = await buildCaller(
|
||||||
|
async (input) => ({
|
||||||
|
id: operationId,
|
||||||
|
profileName: input.profileName,
|
||||||
|
type: 'DEPLOY',
|
||||||
|
status: 'RUNNING',
|
||||||
|
sourceMode: input.sourceMode,
|
||||||
|
sourceRef: input.sourceRef,
|
||||||
|
payload: {},
|
||||||
|
requestedBy: input.requestedBy,
|
||||||
|
createdAt: '2026-08-11T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-08-11T00:00:00.000Z',
|
||||||
|
}),
|
||||||
|
{ profileScenario: '1010', profileLogVisibilityAfterPolls: 2 }
|
||||||
|
);
|
||||||
|
await harness.caller.admin.operations.requestDeploy({
|
||||||
|
profileName: 'che:2',
|
||||||
|
sourceMode: 'COMMIT',
|
||||||
|
sourceRef: 'HEAD',
|
||||||
|
});
|
||||||
|
harness.profileLogs.push({
|
||||||
|
cursor: '1',
|
||||||
|
operationId,
|
||||||
|
level: 'OUTPUT',
|
||||||
|
phase: 'build',
|
||||||
|
message: 'game-frontend build complete',
|
||||||
|
createdAt: '2026-08-11T00:00:01.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
harness.caller.admin.operations.logs({ id: operationId, timeoutMs: 1_000 })
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
nextCursor: '1',
|
||||||
|
operation: { status: 'RUNNING', profileName: 'che:2' },
|
||||||
|
entries: [{ cursor: '1', phase: 'build', message: 'game-frontend build complete' }],
|
||||||
|
});
|
||||||
|
expect(harness.getProfileLogPollCount()).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not expose operation logs outside the caller profile scope', async () => {
|
||||||
|
const operationId = '33333333-3333-4333-8333-333333333333';
|
||||||
|
const harness = await buildCaller(
|
||||||
|
async () => {
|
||||||
|
throw new Error('not used');
|
||||||
|
},
|
||||||
|
{
|
||||||
|
adminRoles: ['admin.scenarios.reset:hwe:1'],
|
||||||
|
firstUserIsAdmin: false,
|
||||||
|
initialOperation: {
|
||||||
|
id: operationId,
|
||||||
|
profileName: 'che:2',
|
||||||
|
type: 'RESET',
|
||||||
|
status: 'RUNNING',
|
||||||
|
sourceMode: 'COMMIT',
|
||||||
|
sourceRef: '1111111111111111111111111111111111111111',
|
||||||
|
payload: {},
|
||||||
|
requestedBy: 'admin',
|
||||||
|
createdAt: '2026-08-11T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-08-11T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(harness.caller.admin.operations.logs({ id: operationId, timeoutMs: 0 })).rejects.toMatchObject({
|
||||||
|
code: 'FORBIDDEN',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('gateway release API', () => {
|
describe('gateway release API', () => {
|
||||||
it('waits until a new release log becomes visible', async () => {
|
it('waits until a new release log becomes visible', async () => {
|
||||||
const harness = await buildCaller(
|
const harness = await buildCaller(
|
||||||
|
|||||||
@@ -39,6 +39,13 @@ const profiles: GatewayProfileRepository = {
|
|||||||
clearWorkspaceUsage: async () => {},
|
clearWorkspaceUsage: async () => {},
|
||||||
listOperations: async () => [],
|
listOperations: async () => [],
|
||||||
getOperation: async () => null,
|
getOperation: async () => null,
|
||||||
|
listOperationLogs: async () => [],
|
||||||
|
appendOperationLog: async (operationId, input) => ({
|
||||||
|
cursor: '1',
|
||||||
|
operationId,
|
||||||
|
createdAt: '2026-08-11T00:00:00.000Z',
|
||||||
|
...input,
|
||||||
|
}),
|
||||||
createOperation: async () => {
|
createOperation: async () => {
|
||||||
throw new Error('not used');
|
throw new Error('not used');
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { appRouter } from '../src/router.js';
|
|||||||
import type { GatewayPrismaClient } from '@sammo-ts/infra';
|
import type { GatewayPrismaClient } from '@sammo-ts/infra';
|
||||||
import { decryptGameSessionToken, type UserSanctions } from '@sammo-ts/common/auth/gameToken';
|
import { decryptGameSessionToken, type UserSanctions } from '@sammo-ts/common/auth/gameToken';
|
||||||
import { createPasswordEnvelopeService } from '../src/auth/passwordEnvelope.js';
|
import { createPasswordEnvelopeService } from '../src/auth/passwordEnvelope.js';
|
||||||
|
import type { GatewayProfileRepository } from '../src/orchestrator/profileRepository.js';
|
||||||
|
|
||||||
const buildCaller = (
|
const buildCaller = (
|
||||||
options: {
|
options: {
|
||||||
@@ -111,7 +112,7 @@ const buildCaller = (
|
|||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const profiles = {
|
const profiles: GatewayProfileRepository = {
|
||||||
listProfiles: async () => profileRows,
|
listProfiles: async () => profileRows,
|
||||||
getProfile: async (profileName: string) =>
|
getProfile: async (profileName: string) =>
|
||||||
profileRows.find((profile) => profile.profileName === profileName) ?? null,
|
profileRows.find((profile) => profile.profileName === profileName) ?? null,
|
||||||
@@ -129,6 +130,13 @@ const buildCaller = (
|
|||||||
clearWorkspaceUsage: async () => {},
|
clearWorkspaceUsage: async () => {},
|
||||||
listOperations: async () => [],
|
listOperations: async () => [],
|
||||||
getOperation: async () => null,
|
getOperation: async () => null,
|
||||||
|
listOperationLogs: async () => [],
|
||||||
|
appendOperationLog: async (operationId, input) => ({
|
||||||
|
cursor: '1',
|
||||||
|
operationId,
|
||||||
|
createdAt: '2026-08-11T00:00:00.000Z',
|
||||||
|
...input,
|
||||||
|
}),
|
||||||
createOperation: async () => {
|
createOperation: async () => {
|
||||||
throw new Error('not implemented');
|
throw new Error('not implemented');
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ const createHarness = (
|
|||||||
const started: ProcessDefinition[] = [];
|
const started: ProcessDefinition[] = [];
|
||||||
const stopped: string[] = [];
|
const stopped: string[] = [];
|
||||||
const deleted: string[] = [];
|
const deleted: string[] = [];
|
||||||
|
const logs: Array<{ phase: string; message: string; level: string }> = [];
|
||||||
|
|
||||||
const repository: GatewayProfileRepository = {
|
const repository: GatewayProfileRepository = {
|
||||||
listProfiles: async () => [profile],
|
listProfiles: async () => [profile],
|
||||||
@@ -72,6 +73,16 @@ const createHarness = (
|
|||||||
listOperations: async () => [],
|
listOperations: async () => [],
|
||||||
listActiveOperationProfileNames: async () => [profile.profileName],
|
listActiveOperationProfileNames: async () => [profile.profileName],
|
||||||
getOperation: async () => operation,
|
getOperation: async () => operation,
|
||||||
|
listOperationLogs: async () => [],
|
||||||
|
appendOperationLog: async (operationId, input) => {
|
||||||
|
logs.push(input);
|
||||||
|
return {
|
||||||
|
cursor: String(logs.length),
|
||||||
|
operationId,
|
||||||
|
createdAt: '2026-08-11T00:00:00.000Z',
|
||||||
|
...input,
|
||||||
|
};
|
||||||
|
},
|
||||||
createOperation: async () => operation,
|
createOperation: async () => operation,
|
||||||
claimNextOperation: async () => {
|
claimNextOperation: async () => {
|
||||||
const result = nextOperation;
|
const result = nextOperation;
|
||||||
@@ -146,7 +157,7 @@ const createHarness = (
|
|||||||
adminActionIntervalMs: 60_000,
|
adminActionIntervalMs: 60_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { orchestrator, statuses, completions, completionFields, started, stopped, deleted };
|
return { orchestrator, statuses, completions, completionFields, started, stopped, deleted, logs };
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('GatewayOrchestrator first-class operations', () => {
|
describe('GatewayOrchestrator first-class operations', () => {
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ describe('profile DEPLOY operation', () => {
|
|||||||
let nextOperation: GatewayOperationRecord | null = operation;
|
let nextOperation: GatewayOperationRecord | null = operation;
|
||||||
const patches: GatewayClaimedProfileUpdate[] = [];
|
const patches: GatewayClaimedProfileUpdate[] = [];
|
||||||
const completions: string[] = [];
|
const completions: string[] = [];
|
||||||
|
const logs: Array<{ phase: string; message: string; level: string }> = [];
|
||||||
const repository: GatewayProfileRepository = {
|
const repository: GatewayProfileRepository = {
|
||||||
listProfiles: async () => [profile],
|
listProfiles: async () => [profile],
|
||||||
getProfile: async () => profile,
|
getProfile: async () => profile,
|
||||||
@@ -90,6 +91,16 @@ describe('profile DEPLOY operation', () => {
|
|||||||
clearWorkspaceUsage: async () => {},
|
clearWorkspaceUsage: async () => {},
|
||||||
listOperations: async () => [],
|
listOperations: async () => [],
|
||||||
getOperation: async () => operation,
|
getOperation: async () => operation,
|
||||||
|
listOperationLogs: async () => [],
|
||||||
|
appendOperationLog: async (operationId, input) => {
|
||||||
|
logs.push(input);
|
||||||
|
return {
|
||||||
|
cursor: String(logs.length),
|
||||||
|
operationId,
|
||||||
|
createdAt: '2026-08-11T00:00:00.000Z',
|
||||||
|
...input,
|
||||||
|
};
|
||||||
|
},
|
||||||
createOperation: async () => operation,
|
createOperation: async () => operation,
|
||||||
claimNextOperation: async () => {
|
claimNextOperation: async () => {
|
||||||
const value = nextOperation;
|
const value = nextOperation;
|
||||||
@@ -138,8 +149,17 @@ describe('profile DEPLOY operation', () => {
|
|||||||
repository,
|
repository,
|
||||||
processManager,
|
processManager,
|
||||||
buildRunner: {
|
buildRunner: {
|
||||||
run: async (commands) => {
|
run: async (commands, onProgress) => {
|
||||||
commandGroups.push(commands);
|
commandGroups.push(commands);
|
||||||
|
for (const command of commands) {
|
||||||
|
await onProgress?.({ type: 'COMMAND_START', command });
|
||||||
|
await onProgress?.({
|
||||||
|
type: 'OUTPUT',
|
||||||
|
stream: 'stdout',
|
||||||
|
message: 'built profile with postgresql://user:pass@integration.invalid/sammo',
|
||||||
|
});
|
||||||
|
await onProgress?.({ type: 'COMMAND_END', command, exitCode: 0 });
|
||||||
|
}
|
||||||
return { ok: true, exitCode: 0, output: '' };
|
return { ok: true, exitCode: 0, output: '' };
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -149,7 +169,7 @@ describe('profile DEPLOY operation', () => {
|
|||||||
redisKeyPrefix: 'sammo:test',
|
redisKeyPrefix: 'sammo:test',
|
||||||
gameTokenSecret: 'test-secret',
|
gameTokenSecret: 'test-secret',
|
||||||
gatewayInternalApiUrl: 'http://127.0.0.1:15001',
|
gatewayInternalApiUrl: 'http://127.0.0.1:15001',
|
||||||
baseEnv: { DATABASE_URL: 'postgresql://integration.invalid/sammo' },
|
baseEnv: { DATABASE_URL: 'postgresql://user:pass@integration.invalid/sammo' },
|
||||||
},
|
},
|
||||||
reconcileIntervalMs: 60_000,
|
reconcileIntervalMs: 60_000,
|
||||||
scheduleIntervalMs: 60_000,
|
scheduleIntervalMs: 60_000,
|
||||||
@@ -173,6 +193,17 @@ describe('profile DEPLOY operation', () => {
|
|||||||
buildWorkspace: workspace,
|
buildWorkspace: workspace,
|
||||||
});
|
});
|
||||||
expect(completions).toEqual(['SUCCEEDED']);
|
expect(completions).toEqual(['SUCCEEDED']);
|
||||||
|
expect(logs).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ phase: 'resolve', message: `대상 커밋을 ${SHA}로 고정했습니다.` }),
|
||||||
|
expect.objectContaining({ phase: 'build', level: 'OUTPUT' }),
|
||||||
|
expect.objectContaining({ phase: 'migration', level: 'OUTPUT' }),
|
||||||
|
expect.objectContaining({ phase: 'readiness', message: 'profile readiness 확인을 통과했습니다.' }),
|
||||||
|
expect.objectContaining({ phase: 'complete', message: 'DB 보존 버전 업데이트가 완료되었습니다.' }),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
expect(logs.map((entry) => entry.message).join('\n')).not.toContain('pass@integration.invalid');
|
||||||
|
expect(logs.map((entry) => entry.message).join('\n')).toContain('[REDACTED]');
|
||||||
expect([...running].sort()).toEqual([...processNames].sort());
|
expect([...running].sort()).toEqual([...processNames].sort());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -69,6 +69,28 @@ describeDatabase('gateway operation lease and profile serialization', () => {
|
|||||||
await expect(repository.listOperations({ profileName })).resolves.toHaveLength(1);
|
await expect(repository.listOperations({ profileName })).resolves.toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('stores durable cursor logs for profile operations', async () => {
|
||||||
|
const operation = await repository.createOperation({
|
||||||
|
profileName,
|
||||||
|
type: 'DEPLOY',
|
||||||
|
sourceMode: 'BRANCH',
|
||||||
|
sourceRef: 'main',
|
||||||
|
requestedBy: 'admin-a',
|
||||||
|
});
|
||||||
|
|
||||||
|
const queued = await repository.listOperationLogs(operation.id);
|
||||||
|
expect(queued).toHaveLength(1);
|
||||||
|
expect(queued[0]).toMatchObject({ phase: 'queue', level: 'INFO' });
|
||||||
|
|
||||||
|
const build = await repository.appendOperationLog(operation.id, {
|
||||||
|
level: 'OUTPUT',
|
||||||
|
phase: 'build',
|
||||||
|
message: 'game-frontend build complete',
|
||||||
|
});
|
||||||
|
await expect(repository.listOperationLogs(operation.id, queued[0]?.cursor)).resolves.toEqual([build]);
|
||||||
|
await expect(repository.listOperationLogs(operation.id, build.cursor)).resolves.toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it('serializes running operations globally across profiles', async () => {
|
it('serializes running operations globally across profiles', async () => {
|
||||||
const first = await repository.createOperation({
|
const first = await repository.createOperation({
|
||||||
profileName,
|
profileName,
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ describe('readReleaseManifest', () => {
|
|||||||
|
|
||||||
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
|
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
|
||||||
controllerProtocol: RELEASE_CONTROLLER_PROTOCOL,
|
controllerProtocol: RELEASE_CONTROLLER_PROTOCOL,
|
||||||
gatewaySchemaHead: '20260809000000_add_gateway_release_logs',
|
gatewaySchemaHead: '20260811000000_add_gateway_operation_logs',
|
||||||
gameSchemaHead: '20260803000000_add_logical_game_clock',
|
gameSchemaHead: '20260803000000_add_logical_game_clock',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ type FixtureState = {
|
|||||||
}>;
|
}>;
|
||||||
runtimeRunning: boolean;
|
runtimeRunning: boolean;
|
||||||
requestBodies: Array<{ operation: string; body: unknown }>;
|
requestBodies: Array<{ operation: string; body: unknown }>;
|
||||||
|
profileLogPollCount?: number;
|
||||||
|
profileLogProgress?: boolean;
|
||||||
|
profileLogsEmpty?: boolean;
|
||||||
gatewayLogPollCount?: number;
|
gatewayLogPollCount?: number;
|
||||||
gatewayLogsEmpty?: boolean;
|
gatewayLogsEmpty?: boolean;
|
||||||
capabilities?: Array<{ permission: string; scope: 'GLOBAL' | 'PROFILE'; scopes: string[] }>;
|
capabilities?: Array<{ permission: string; scope: 'GLOBAL' | 'PROFILE'; scopes: string[] }>;
|
||||||
@@ -136,6 +139,9 @@ const installFixture = async (page: Page, state: FixtureState) => {
|
|||||||
await route.abort('failed');
|
await route.abort('failed');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (names.includes('admin.operations.logs') && !state.profileLogProgress) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
}
|
||||||
const results = names.map((name) => {
|
const results = names.map((name) => {
|
||||||
if (route.request().method() === 'POST') {
|
if (route.request().method() === 'POST') {
|
||||||
state.requestBodies.push({ operation: name, body });
|
state.requestBodies.push({ operation: name, body });
|
||||||
@@ -167,6 +173,54 @@ const installFixture = async (page: Page, state: FixtureState) => {
|
|||||||
if (name === 'admin.operations.list') {
|
if (name === 'admin.operations.list') {
|
||||||
return response(state.operations);
|
return response(state.operations);
|
||||||
}
|
}
|
||||||
|
if (name === 'admin.operations.logs') {
|
||||||
|
const operation = state.operations[0];
|
||||||
|
if (!operation) throw new Error('Profile operation fixture is missing');
|
||||||
|
state.profileLogPollCount = (state.profileLogPollCount ?? 0) + 1;
|
||||||
|
if (['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(operation.status)) {
|
||||||
|
return response({ operation, entries: [] });
|
||||||
|
}
|
||||||
|
if (!state.profileLogProgress) {
|
||||||
|
return response({ operation, entries: [] });
|
||||||
|
}
|
||||||
|
if (state.profileLogsEmpty) {
|
||||||
|
return response({ operation, entries: [] });
|
||||||
|
}
|
||||||
|
const completed = state.profileLogPollCount > 1;
|
||||||
|
const nextOperation = {
|
||||||
|
...operation,
|
||||||
|
status: completed ? ('SUCCEEDED' as const) : ('RUNNING' as const),
|
||||||
|
};
|
||||||
|
if (completed) state.operations[0] = nextOperation;
|
||||||
|
return response({
|
||||||
|
operation: nextOperation,
|
||||||
|
entries: completed
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
cursor: '2',
|
||||||
|
operationId: operation.id,
|
||||||
|
level: 'OUTPUT',
|
||||||
|
phase: operation.type === 'RESET' ? 'seed' : 'build',
|
||||||
|
message:
|
||||||
|
operation.type === 'RESET'
|
||||||
|
? '시나리오 초기 데이터 생성을 완료했습니다.'
|
||||||
|
: 'game-frontend build complete',
|
||||||
|
createdAt: '2026-08-01T01:00:02.000Z',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
cursor: '1',
|
||||||
|
operationId: operation.id,
|
||||||
|
level: 'INFO',
|
||||||
|
phase: 'build',
|
||||||
|
message: `${operation.profileName} 구성 요소를 빌드합니다.`,
|
||||||
|
createdAt: '2026-08-01T01:00:01.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
nextCursor: completed ? '2' : '1',
|
||||||
|
});
|
||||||
|
}
|
||||||
if (name === 'admin.releases.gatewayState') {
|
if (name === 'admin.releases.gatewayState') {
|
||||||
return response({
|
return response({
|
||||||
id: 'gateway',
|
id: 'gateway',
|
||||||
@@ -336,7 +390,13 @@ const installFixture = async (page: Page, state: FixtureState) => {
|
|||||||
test('separates branch and commit semantics and submits a reset from the dedicated page', async ({
|
test('separates branch and commit semantics and submits a reset from the dedicated page', async ({
|
||||||
page,
|
page,
|
||||||
}, testInfo) => {
|
}, testInfo) => {
|
||||||
const state: FixtureState = { operations: [], gatewayOperations: [], runtimeRunning: false, requestBodies: [] };
|
const state: FixtureState = {
|
||||||
|
operations: [],
|
||||||
|
gatewayOperations: [],
|
||||||
|
runtimeRunning: false,
|
||||||
|
requestBodies: [],
|
||||||
|
profileLogProgress: true,
|
||||||
|
};
|
||||||
await installFixture(page, state);
|
await installFixture(page, state);
|
||||||
page.on('dialog', (dialog) => dialog.accept());
|
page.on('dialog', (dialog) => dialog.accept());
|
||||||
|
|
||||||
@@ -415,10 +475,15 @@ test('separates branch and commit semantics and submits a reset from the dedicat
|
|||||||
|
|
||||||
await expect(page.getByText('초기화 작업을 등록했습니다.').first()).toBeVisible();
|
await expect(page.getByText('초기화 작업을 등록했습니다.').first()).toBeVisible();
|
||||||
await expect(page.getByTestId('operations-table')).toContainText('RESET');
|
await expect(page.getByTestId('operations-table')).toContainText('RESET');
|
||||||
|
await expect(page.getByTestId('profile-operation-log-panel')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('profile-operation-log')).toContainText('che:2 구성 요소를 빌드합니다.');
|
||||||
|
await expect(page.getByTestId('profile-operation-log')).toContainText('시나리오 초기 데이터 생성을 완료했습니다.');
|
||||||
|
await expect(page.getByTestId('profile-operation-log-status')).toContainText('SUCCEEDED');
|
||||||
const resetRequest = state.requestBodies.find((entry) => entry.operation === 'admin.operations.requestReset');
|
const resetRequest = state.requestBodies.find((entry) => entry.operation === 'admin.operations.requestReset');
|
||||||
expect(JSON.stringify(resetRequest?.body)).toContain('"sourceMode":"COMMIT"');
|
expect(JSON.stringify(resetRequest?.body)).toContain('"sourceMode":"COMMIT"');
|
||||||
expect(JSON.stringify(resetRequest?.body)).toContain('0123456789abcdef0123456789abcdef01234567');
|
expect(JSON.stringify(resetRequest?.body)).toContain('0123456789abcdef0123456789abcdef01234567');
|
||||||
expect(JSON.stringify(resetRequest?.body)).toContain('"scenarioId":5');
|
expect(JSON.stringify(resetRequest?.body)).toContain('"scenarioId":5');
|
||||||
|
await page.screenshot({ path: testInfo.outputPath('reset-operation-log-desktop.png'), fullPage: true });
|
||||||
|
|
||||||
await page.setViewportSize({ width: 390, height: 844 });
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
const mobileGeometry = await page
|
const mobileGeometry = await page
|
||||||
@@ -449,7 +514,13 @@ test('separates branch and commit semantics and submits a reset from the dedicat
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('separates DB-preserving profile deployment from DB reset', async ({ page }) => {
|
test('separates DB-preserving profile deployment from DB reset', async ({ page }) => {
|
||||||
const state: FixtureState = { operations: [], gatewayOperations: [], runtimeRunning: true, requestBodies: [] };
|
const state: FixtureState = {
|
||||||
|
operations: [],
|
||||||
|
gatewayOperations: [],
|
||||||
|
runtimeRunning: true,
|
||||||
|
requestBodies: [],
|
||||||
|
profileLogProgress: true,
|
||||||
|
};
|
||||||
await installFixture(page, state);
|
await installFixture(page, state);
|
||||||
page.on('dialog', (dialog) => dialog.accept());
|
page.on('dialog', (dialog) => dialog.accept());
|
||||||
|
|
||||||
@@ -464,6 +535,10 @@ test('separates DB-preserving profile deployment from DB reset', async ({ page }
|
|||||||
|
|
||||||
await expect(page.getByText('DB 보존 배포 작업을 등록했습니다.').first()).toBeVisible();
|
await expect(page.getByText('DB 보존 배포 작업을 등록했습니다.').first()).toBeVisible();
|
||||||
await expect(page.getByTestId('operations-table')).toContainText('DEPLOY');
|
await expect(page.getByTestId('operations-table')).toContainText('DEPLOY');
|
||||||
|
await expect(page.getByTestId('profile-operation-log-panel')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('profile-operation-log')).toContainText('che:2 구성 요소를 빌드합니다.');
|
||||||
|
await expect(page.getByTestId('profile-operation-log')).toContainText('game-frontend build complete');
|
||||||
|
await expect(page.getByTestId('profile-operation-log-status')).toContainText('SUCCEEDED');
|
||||||
expect(state.requestBodies.some((entry) => entry.operation === 'admin.operations.requestDeploy')).toBe(true);
|
expect(state.requestBodies.some((entry) => entry.operation === 'admin.operations.requestDeploy')).toBe(true);
|
||||||
expect(state.requestBodies.some((entry) => entry.operation === 'admin.operations.requestReset')).toBe(false);
|
expect(state.requestBodies.some((entry) => entry.operation === 'admin.operations.requestReset')).toBe(false);
|
||||||
});
|
});
|
||||||
@@ -766,16 +841,16 @@ test('renders a failed reset, retries it as a new operation, and reaches success
|
|||||||
page.on('dialog', (dialog) => dialog.accept());
|
page.on('dialog', (dialog) => dialog.accept());
|
||||||
|
|
||||||
await page.goto('admin/servers/che%3A2/scenario');
|
await page.goto('admin/servers/che%3A2/scenario');
|
||||||
await expect(page.getByText('FAILED', { exact: true })).toBeVisible();
|
await expect(page.getByTestId('operations-table').getByText('FAILED', { exact: true })).toBeVisible();
|
||||||
await expect(page.getByRole('cell', { name: 'fedcba987654', exact: true })).toBeVisible();
|
await expect(page.getByRole('cell', { name: 'fedcba987654', exact: true })).toBeVisible();
|
||||||
const failure = page.getByText(longError);
|
const failure = page.getByTestId('operations-table').getByText(longError);
|
||||||
await expect(failure).toBeVisible();
|
await expect(failure).toBeVisible();
|
||||||
expect(await failure.evaluate((element) => getComputedStyle(element).color)).toBe('oklch(0.704 0.191 22.216)');
|
expect(await failure.evaluate((element) => getComputedStyle(element).color)).toBe('oklch(0.704 0.191 22.216)');
|
||||||
|
|
||||||
await page.getByRole('button', { name: '재시도' }).click();
|
await page.getByRole('button', { name: '재시도' }).click();
|
||||||
await expect(page.getByText('재시도 작업을 등록했습니다.').first()).toBeVisible();
|
await expect(page.getByText('재시도 작업을 등록했습니다.').first()).toBeVisible();
|
||||||
await expect(page.getByText('FAILED', { exact: true })).toBeVisible();
|
await expect(page.getByTestId('operations-table').getByText('FAILED', { exact: true })).toBeVisible();
|
||||||
await expect(page.getByText('QUEUED', { exact: true })).toBeVisible();
|
await expect(page.getByTestId('operations-table').getByText('QUEUED', { exact: true })).toBeVisible();
|
||||||
await expect(page.getByTestId('operations-table').locator('tbody tr')).toHaveCount(2);
|
await expect(page.getByTestId('operations-table').locator('tbody tr')).toHaveCount(2);
|
||||||
|
|
||||||
state.operations[0] = {
|
state.operations[0] = {
|
||||||
@@ -787,7 +862,7 @@ test('renders a failed reset, retries it as a new operation, and reaches success
|
|||||||
};
|
};
|
||||||
state.runtimeRunning = true;
|
state.runtimeRunning = true;
|
||||||
await page.getByTestId('refresh-operations').click();
|
await page.getByTestId('refresh-operations').click();
|
||||||
await expect(page.getByText('SUCCEEDED', { exact: true })).toBeVisible();
|
await expect(page.getByTestId('operations-table').getByText('SUCCEEDED', { exact: true })).toBeVisible();
|
||||||
await expect(page.getByText('운영 프로필', { exact: true })).toHaveCount(0);
|
await expect(page.getByText('운영 프로필', { exact: true })).toHaveCount(0);
|
||||||
|
|
||||||
await page.screenshot({ path: testInfo.outputPath('failed-retry-succeeded-desktop.png'), fullPage: true });
|
await page.screenshot({ path: testInfo.outputPath('failed-retry-succeeded-desktop.png'), fullPage: true });
|
||||||
|
|||||||
@@ -79,6 +79,12 @@ type GatewayReleaseLog = {
|
|||||||
};
|
};
|
||||||
const scenarios = ref<Scenario[]>([]);
|
const scenarios = ref<Scenario[]>([]);
|
||||||
const operations = ref<Operation[]>([]);
|
const operations = ref<Operation[]>([]);
|
||||||
|
const selectedProfileOperationId = ref('');
|
||||||
|
const profileOperationLogs = ref<GatewayReleaseLog[]>([]);
|
||||||
|
const profileOperationLogCursor = ref<string>();
|
||||||
|
const profileOperationLogStatus = ref('');
|
||||||
|
const profileOperationLogConnection = ref<'idle' | 'connected' | 'reconnecting'>('idle');
|
||||||
|
const profileOperationLogViewport = ref<HTMLElement>();
|
||||||
const gatewayReleaseState = ref<GatewayReleaseState | null>(null);
|
const gatewayReleaseState = ref<GatewayReleaseState | null>(null);
|
||||||
const gatewayReleaseOperations = ref<GatewayReleaseOperation[]>([]);
|
const gatewayReleaseOperations = ref<GatewayReleaseOperation[]>([]);
|
||||||
const selectedGatewayOperationId = ref('');
|
const selectedGatewayOperationId = ref('');
|
||||||
@@ -104,6 +110,7 @@ const resetDefaultsSource = ref<'SYSTEM' | 'PROFILE'>('SYSTEM');
|
|||||||
let pollTimer: ReturnType<typeof setInterval> | undefined;
|
let pollTimer: ReturnType<typeof setInterval> | undefined;
|
||||||
let stateRequestInFlight = false;
|
let stateRequestInFlight = false;
|
||||||
let releaseLogLoopGeneration = 0;
|
let releaseLogLoopGeneration = 0;
|
||||||
|
let profileLogLoopGeneration = 0;
|
||||||
let componentMounted = false;
|
let componentMounted = false;
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
@@ -140,6 +147,20 @@ const gatewayForm = reactive({
|
|||||||
const selectedGatewayOperation = computed(
|
const selectedGatewayOperation = computed(
|
||||||
() => gatewayReleaseOperations.value.find((operation) => operation.id === selectedGatewayOperationId.value) ?? null
|
() => gatewayReleaseOperations.value.find((operation) => operation.id === selectedGatewayOperationId.value) ?? null
|
||||||
);
|
);
|
||||||
|
const selectedProfileOperation = computed(
|
||||||
|
() => operations.value.find((operation) => operation.id === selectedProfileOperationId.value) ?? null
|
||||||
|
);
|
||||||
|
const profileOperationLogEmptyMessage = computed(() => {
|
||||||
|
const operation = selectedProfileOperation.value;
|
||||||
|
const status = profileOperationLogStatus.value || operation?.status;
|
||||||
|
if (!operation || !status || ['QUEUED', 'RUNNING'].includes(status)) {
|
||||||
|
return '오케스트레이터 로그를 기다리고 있습니다…';
|
||||||
|
}
|
||||||
|
if (operation.error) {
|
||||||
|
return `이 작업에는 진행 로그가 기록되지 않았습니다. 작업 오류: ${operation.error}`;
|
||||||
|
}
|
||||||
|
return '이 작업에는 진행 로그가 기록되지 않았습니다. 로그 기능 적용 전 작업일 수 있습니다.';
|
||||||
|
});
|
||||||
const gatewayReleaseLogEmptyMessage = computed(() => {
|
const gatewayReleaseLogEmptyMessage = computed(() => {
|
||||||
const operation = selectedGatewayOperation.value;
|
const operation = selectedGatewayOperation.value;
|
||||||
const status = gatewayReleaseLogStatus.value || operation?.status;
|
const status = gatewayReleaseLogStatus.value || operation?.status;
|
||||||
@@ -289,6 +310,15 @@ const loadState = async (quiet = false) => {
|
|||||||
limit: 100,
|
limit: 100,
|
||||||
});
|
});
|
||||||
operations.value = operationResult as Operation[];
|
operations.value = operationResult as Operation[];
|
||||||
|
const active = operations.value.find((operation) => ['QUEUED', 'RUNNING'].includes(operation.status));
|
||||||
|
if (active && selectedProfileOperationId.value !== active.id) {
|
||||||
|
selectedProfileOperationId.value = active.id;
|
||||||
|
} else if (
|
||||||
|
!selectedProfileOperationId.value ||
|
||||||
|
!operations.value.some((operation) => operation.id === selectedProfileOperationId.value)
|
||||||
|
) {
|
||||||
|
selectedProfileOperationId.value = operations.value[0]?.id ?? '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorMessage.value = error instanceof Error ? error.message : '운영 상태를 불러오지 못했습니다.';
|
errorMessage.value = error instanceof Error ? error.message : '운영 상태를 불러오지 못했습니다.';
|
||||||
@@ -298,6 +328,61 @@ const loadState = async (quiet = false) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const scrollProfileOperationLogToEnd = async () => {
|
||||||
|
await nextTick();
|
||||||
|
const viewport = profileOperationLogViewport.value;
|
||||||
|
if (viewport) viewport.scrollTop = viewport.scrollHeight;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pollProfileOperationLogs = async (operationId: string, generation: number) => {
|
||||||
|
while (
|
||||||
|
componentMounted &&
|
||||||
|
generation === profileLogLoopGeneration &&
|
||||||
|
selectedProfileOperationId.value === operationId
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const result = await adminClient.operations.logs.query({
|
||||||
|
id: operationId,
|
||||||
|
afterCursor: profileOperationLogCursor.value,
|
||||||
|
limit: 200,
|
||||||
|
timeoutMs: 20_000,
|
||||||
|
});
|
||||||
|
if (generation !== profileLogLoopGeneration || selectedProfileOperationId.value !== operationId) return;
|
||||||
|
profileOperationLogConnection.value = 'connected';
|
||||||
|
const entries = result.entries as GatewayReleaseLog[];
|
||||||
|
if (entries.length) {
|
||||||
|
const known = new Set(profileOperationLogs.value.map((entry) => entry.cursor));
|
||||||
|
profileOperationLogs.value.push(...entries.filter((entry) => !known.has(entry.cursor)));
|
||||||
|
profileOperationLogs.value = profileOperationLogs.value.slice(-1_000);
|
||||||
|
profileOperationLogCursor.value = result.nextCursor;
|
||||||
|
await scrollProfileOperationLogToEnd();
|
||||||
|
}
|
||||||
|
const operation = result.operation as Operation;
|
||||||
|
profileOperationLogStatus.value = operation.status;
|
||||||
|
const index = operations.value.findIndex((entry) => entry.id === operation.id);
|
||||||
|
if (index >= 0) operations.value[index] = operation;
|
||||||
|
if (['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(operation.status)) return;
|
||||||
|
} catch {
|
||||||
|
if (generation !== profileLogLoopGeneration || !componentMounted) return;
|
||||||
|
profileOperationLogConnection.value = 'reconnecting';
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, 1_000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectProfileOperation = (operationId: string) => {
|
||||||
|
if (selectedProfileOperationId.value === operationId) {
|
||||||
|
profileLogLoopGeneration += 1;
|
||||||
|
profileOperationLogs.value = [];
|
||||||
|
profileOperationLogCursor.value = undefined;
|
||||||
|
profileOperationLogStatus.value = '';
|
||||||
|
profileOperationLogConnection.value = 'idle';
|
||||||
|
void pollProfileOperationLogs(operationId, profileLogLoopGeneration);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selectedProfileOperationId.value = operationId;
|
||||||
|
};
|
||||||
|
|
||||||
const scrollReleaseLogToEnd = async () => {
|
const scrollReleaseLogToEnd = async () => {
|
||||||
await nextTick();
|
await nextTick();
|
||||||
const viewport = gatewayReleaseLogViewport.value;
|
const viewport = gatewayReleaseLogViewport.value;
|
||||||
@@ -372,12 +457,13 @@ const requestDeploy = async () => {
|
|||||||
}
|
}
|
||||||
submitting.value = true;
|
submitting.value = true;
|
||||||
try {
|
try {
|
||||||
await adminClient.operations.requestDeploy.mutate({
|
const operation = await adminClient.operations.requestDeploy.mutate({
|
||||||
profileName: selectedProfileName.value,
|
profileName: selectedProfileName.value,
|
||||||
sourceMode: form.sourceMode,
|
sourceMode: form.sourceMode,
|
||||||
sourceRef: form.sourceRef.trim(),
|
sourceRef: form.sourceRef.trim(),
|
||||||
reason: form.reason.trim() || undefined,
|
reason: form.reason.trim() || undefined,
|
||||||
});
|
});
|
||||||
|
selectedProfileOperationId.value = operation.id;
|
||||||
message.value = 'DB 보존 배포 작업을 등록했습니다.';
|
message.value = 'DB 보존 배포 작업을 등록했습니다.';
|
||||||
await loadState(true);
|
await loadState(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -491,7 +577,7 @@ const requestReset = async () => {
|
|||||||
}
|
}
|
||||||
submitting.value = true;
|
submitting.value = true;
|
||||||
try {
|
try {
|
||||||
await adminClient.operations.requestReset.mutate({
|
const operation = await adminClient.operations.requestReset.mutate({
|
||||||
profileName: selectedProfileName.value,
|
profileName: selectedProfileName.value,
|
||||||
sourceMode: form.sourceMode,
|
sourceMode: form.sourceMode,
|
||||||
sourceRef: form.sourceMode === 'CURRENT' ? undefined : form.sourceRef.trim(),
|
sourceRef: form.sourceMode === 'CURRENT' ? undefined : form.sourceRef.trim(),
|
||||||
@@ -518,6 +604,7 @@ const requestReset = async () => {
|
|||||||
preopenAt: toIso(form.preopenAt),
|
preopenAt: toIso(form.preopenAt),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
selectedProfileOperationId.value = operation.id;
|
||||||
message.value = form.scheduledAt ? '예약 초기화 작업을 등록했습니다.' : '초기화 작업을 등록했습니다.';
|
message.value = form.scheduledAt ? '예약 초기화 작업을 등록했습니다.' : '초기화 작업을 등록했습니다.';
|
||||||
await loadState(true);
|
await loadState(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -534,6 +621,7 @@ const cancelOperation = async (operation: Operation) => {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await adminClient.operations.cancel.mutate({ id: operation.id });
|
await adminClient.operations.cancel.mutate({ id: operation.id });
|
||||||
|
selectedProfileOperationId.value = operation.id;
|
||||||
message.value = '작업을 취소했습니다.';
|
message.value = '작업을 취소했습니다.';
|
||||||
await loadState(true);
|
await loadState(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -547,7 +635,8 @@ const retryOperation = async (operation: Operation) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await adminClient.operations.retry.mutate({ id: operation.id });
|
const retried = await adminClient.operations.retry.mutate({ id: operation.id });
|
||||||
|
selectedProfileOperationId.value = retried.id;
|
||||||
message.value = '재시도 작업을 등록했습니다.';
|
message.value = '재시도 작업을 등록했습니다.';
|
||||||
await loadState(true);
|
await loadState(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -555,6 +644,15 @@ const retryOperation = async (operation: Operation) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
watch(selectedProfileOperationId, (operationId) => {
|
||||||
|
profileLogLoopGeneration += 1;
|
||||||
|
profileOperationLogs.value = [];
|
||||||
|
profileOperationLogCursor.value = undefined;
|
||||||
|
profileOperationLogStatus.value = '';
|
||||||
|
profileOperationLogConnection.value = operationId ? 'connected' : 'idle';
|
||||||
|
if (operationId && componentMounted) void pollProfileOperationLogs(operationId, profileLogLoopGeneration);
|
||||||
|
});
|
||||||
|
|
||||||
watch(selectedGatewayOperationId, (operationId) => {
|
watch(selectedGatewayOperationId, (operationId) => {
|
||||||
releaseLogLoopGeneration += 1;
|
releaseLogLoopGeneration += 1;
|
||||||
gatewayReleaseLogs.value = [];
|
gatewayReleaseLogs.value = [];
|
||||||
@@ -589,6 +687,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
componentMounted = false;
|
componentMounted = false;
|
||||||
|
profileLogLoopGeneration += 1;
|
||||||
releaseLogLoopGeneration += 1;
|
releaseLogLoopGeneration += 1;
|
||||||
if (pollTimer) {
|
if (pollTimer) {
|
||||||
clearInterval(pollTimer);
|
clearInterval(pollTimer);
|
||||||
@@ -1073,6 +1172,64 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
v-if="mode !== 'gateway' && selectedProfileOperationId"
|
||||||
|
class="overflow-hidden rounded border border-zinc-700 bg-zinc-950"
|
||||||
|
data-testid="profile-operation-log-panel"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2 border-b border-zinc-800 px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold text-zinc-100">빌드·작업 로그</h3>
|
||||||
|
<p class="mt-1 font-mono text-[11px] text-zinc-500">
|
||||||
|
{{ selectedProfileOperationId }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 text-xs">
|
||||||
|
<span
|
||||||
|
class="h-2 w-2 rounded-full"
|
||||||
|
:class="
|
||||||
|
profileOperationLogConnection === 'reconnecting'
|
||||||
|
? 'animate-pulse bg-amber-400'
|
||||||
|
: ['QUEUED', 'RUNNING'].includes(
|
||||||
|
profileOperationLogStatus || selectedProfileOperation?.status || ''
|
||||||
|
)
|
||||||
|
? 'animate-pulse bg-emerald-400'
|
||||||
|
: 'bg-zinc-500'
|
||||||
|
"
|
||||||
|
></span>
|
||||||
|
<span data-testid="profile-operation-log-status">
|
||||||
|
{{ profileOperationLogStatus || selectedProfileOperation?.status || '연결 중' }}
|
||||||
|
<template v-if="profileOperationLogConnection === 'reconnecting'"> · 재연결 중</template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref="profileOperationLogViewport"
|
||||||
|
class="h-72 overflow-y-auto px-4 py-3 font-mono text-xs leading-5"
|
||||||
|
data-testid="profile-operation-log"
|
||||||
|
>
|
||||||
|
<div v-if="!profileOperationLogs.length" class="text-zinc-500">
|
||||||
|
{{ profileOperationLogEmptyMessage }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="entry in profileOperationLogs"
|
||||||
|
:key="entry.cursor"
|
||||||
|
:class="
|
||||||
|
entry.level === 'ERROR'
|
||||||
|
? 'text-red-300'
|
||||||
|
: entry.level === 'OUTPUT'
|
||||||
|
? 'text-zinc-300'
|
||||||
|
: 'text-cyan-300'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<span class="text-zinc-600">{{ formatLogTime(entry.createdAt) }}</span>
|
||||||
|
<span class="ml-2 text-violet-300">[{{ entry.phase }}]</span>
|
||||||
|
<span class="ml-2 whitespace-pre-wrap break-all">{{ entry.message }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section v-if="mode !== 'gateway'" class="rounded-lg border border-zinc-800 bg-zinc-900 p-5">
|
<section v-if="mode !== 'gateway'" class="rounded-lg border border-zinc-800 bg-zinc-900 p-5">
|
||||||
<div class="mb-4 flex items-center justify-between">
|
<div class="mb-4 flex items-center justify-between">
|
||||||
<h3 class="text-lg font-semibold">작업 이력</h3>
|
<h3 class="text-lg font-semibold">작업 이력</h3>
|
||||||
@@ -1091,7 +1248,7 @@ onBeforeUnmount(() => {
|
|||||||
<th class="p-2">해석 커밋</th>
|
<th class="p-2">해석 커밋</th>
|
||||||
<th class="p-2">요청자/사유</th>
|
<th class="p-2">요청자/사유</th>
|
||||||
<th class="p-2">완료/오류</th>
|
<th class="p-2">완료/오류</th>
|
||||||
<th class="p-2"></th>
|
<th class="p-2">동작</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -1129,20 +1286,36 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="p-2">
|
<td class="p-2">
|
||||||
<button
|
<div class="flex flex-wrap gap-2">
|
||||||
v-if="operation.status === 'QUEUED'"
|
<button
|
||||||
class="rounded border border-red-800 px-2 py-1 text-xs text-red-300 hover:bg-red-950"
|
type="button"
|
||||||
@click="cancelOperation(operation)"
|
class="rounded border border-zinc-700 px-2 py-1 text-xs text-zinc-300 hover:bg-zinc-800"
|
||||||
>
|
:class="
|
||||||
취소
|
operation.id === selectedProfileOperationId
|
||||||
</button>
|
? 'border-violet-500 text-violet-200'
|
||||||
<button
|
: ''
|
||||||
v-else-if="operation.status === 'FAILED' || operation.status === 'CANCELLED'"
|
"
|
||||||
class="rounded border border-amber-700 px-2 py-1 text-xs text-amber-300 hover:bg-amber-950"
|
@click="selectProfileOperation(operation.id)"
|
||||||
@click="retryOperation(operation)"
|
>
|
||||||
>
|
로그
|
||||||
재시도
|
</button>
|
||||||
</button>
|
<button
|
||||||
|
v-if="operation.status === 'QUEUED'"
|
||||||
|
class="rounded border border-red-800 px-2 py-1 text-xs text-red-300 hover:bg-red-950"
|
||||||
|
@click="cancelOperation(operation)"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-else-if="
|
||||||
|
operation.status === 'FAILED' || operation.status === 'CANCELLED'
|
||||||
|
"
|
||||||
|
class="rounded border border-amber-700 px-2 py-1 text-xs text-amber-300 hover:bg-amber-950"
|
||||||
|
@click="retryOperation(operation)"
|
||||||
|
>
|
||||||
|
재시도
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="operations.length === 0">
|
<tr v-if="operations.length === 0">
|
||||||
|
|||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
CREATE TABLE "gateway_operation_log" (
|
||||||
|
"id" BIGSERIAL NOT NULL,
|
||||||
|
"operation_id" TEXT NOT NULL,
|
||||||
|
"level" TEXT NOT NULL,
|
||||||
|
"phase" TEXT NOT NULL,
|
||||||
|
"message" TEXT NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "gateway_operation_log_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "gateway_operation_log_operation_id_id_idx"
|
||||||
|
ON "gateway_operation_log"("operation_id", "id");
|
||||||
|
|
||||||
|
ALTER TABLE "gateway_operation_log"
|
||||||
|
ADD CONSTRAINT "gateway_operation_log_operation_id_fkey"
|
||||||
|
FOREIGN KEY ("operation_id") REFERENCES "gateway_operation"("id")
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -280,6 +280,7 @@ model GatewayOperation {
|
|||||||
attempts Int @default(0)
|
attempts Int @default(0)
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
logs GatewayOperationLog[]
|
||||||
|
|
||||||
@@index([status, scheduledAt, createdAt])
|
@@index([status, scheduledAt, createdAt])
|
||||||
@@index([status, leaseUntil, createdAt])
|
@@index([status, leaseUntil, createdAt])
|
||||||
@@ -287,6 +288,19 @@ model GatewayOperation {
|
|||||||
@@map("gateway_operation")
|
@@map("gateway_operation")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model GatewayOperationLog {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
operationId String @map("operation_id")
|
||||||
|
level String
|
||||||
|
phase String
|
||||||
|
message String @db.Text
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
operation GatewayOperation @relation(fields: [operationId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([operationId, id])
|
||||||
|
@@map("gateway_operation_log")
|
||||||
|
}
|
||||||
|
|
||||||
model GatewayReleaseState {
|
model GatewayReleaseState {
|
||||||
id String @id @default("gateway")
|
id String @id @default("gateway")
|
||||||
activeCommitSha String? @map("active_commit_sha")
|
activeCommitSha String? @map("active_commit_sha")
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"formatVersion": 1,
|
"formatVersion": 1,
|
||||||
"controllerProtocol": 2,
|
"controllerProtocol": 2,
|
||||||
"gatewaySchemaHead": "20260809000000_add_gateway_release_logs",
|
"gatewaySchemaHead": "20260811000000_add_gateway_operation_logs",
|
||||||
"gameSchemaHead": "20260803000000_add_logical_game_clock",
|
"gameSchemaHead": "20260803000000_add_logical_game_clock",
|
||||||
"components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"]
|
"components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user