feat(gateway): persist profile operation build logs
This commit is contained in:
@@ -6,6 +6,7 @@ import { InMemoryGatewaySessionService } from '../src/auth/inMemorySessionServic
|
||||
import { createInMemoryUserRepository } from '../src/auth/inMemoryUserRepository.js';
|
||||
import type {
|
||||
GatewayOperationCreateInput,
|
||||
GatewayOperationRecord,
|
||||
GatewayProfileRecord,
|
||||
GatewayProfileRepository,
|
||||
} from '../src/orchestrator/profileRepository.js';
|
||||
@@ -30,6 +31,8 @@ const buildCaller = async (
|
||||
initialProfileStatus?: GatewayProfileRecord['status'];
|
||||
profileScenario?: string;
|
||||
profileMeta?: GatewayProfileRecord['meta'];
|
||||
initialOperation?: GatewayOperationRecord;
|
||||
profileLogVisibilityAfterPolls?: number;
|
||||
releaseLogVisibilityAfterPolls?: number;
|
||||
} = {}
|
||||
) => {
|
||||
@@ -49,6 +52,14 @@ const buildCaller = async (
|
||||
const createdInputs: GatewayOperationCreateInput[] = [];
|
||||
const createdReleaseInputs: GatewayReleaseOperationCreateInput[] = [];
|
||||
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 = [
|
||||
{
|
||||
cursor: '1',
|
||||
@@ -60,7 +71,9 @@ const buildCaller = async (
|
||||
},
|
||||
];
|
||||
let releaseLogPollCount = 0;
|
||||
let profileLogPollCount = 0;
|
||||
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 flushes: Array<{ userId: string; reason?: string; iconRevision?: string }> = [];
|
||||
const updatedStatuses: GatewayProfileRecord['status'][] = [];
|
||||
@@ -102,6 +115,28 @@ const buildCaller = async (
|
||||
clearWorkspaceUsage: async () => {},
|
||||
listOperations: async () => [],
|
||||
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) => {
|
||||
createdInputs.push(input);
|
||||
const operation = await createOperation(input);
|
||||
@@ -295,6 +330,7 @@ const buildCaller = async (
|
||||
createdInputs,
|
||||
createdReleaseInputs,
|
||||
appendedReleaseLogs,
|
||||
profileLogs,
|
||||
createdRuntimeActions,
|
||||
users,
|
||||
admin,
|
||||
@@ -306,6 +342,7 @@ const buildCaller = async (
|
||||
getRuntimeStateListCount: () => runtimeStateListCount,
|
||||
getStoredNotice: () => storedNotice,
|
||||
getReleaseLogPollCount: () => releaseLogPollCount,
|
||||
getProfileLogPollCount: () => profileLogPollCount,
|
||||
setStoredNotice: (notice: string) => {
|
||||
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', () => {
|
||||
it('waits until a new release log becomes visible', async () => {
|
||||
const harness = await buildCaller(
|
||||
|
||||
@@ -39,6 +39,13 @@ const profiles: GatewayProfileRepository = {
|
||||
clearWorkspaceUsage: async () => {},
|
||||
listOperations: async () => [],
|
||||
getOperation: async () => null,
|
||||
listOperationLogs: async () => [],
|
||||
appendOperationLog: async (operationId, input) => ({
|
||||
cursor: '1',
|
||||
operationId,
|
||||
createdAt: '2026-08-11T00:00:00.000Z',
|
||||
...input,
|
||||
}),
|
||||
createOperation: async () => {
|
||||
throw new Error('not used');
|
||||
},
|
||||
|
||||
@@ -15,6 +15,7 @@ import { appRouter } from '../src/router.js';
|
||||
import type { GatewayPrismaClient } from '@sammo-ts/infra';
|
||||
import { decryptGameSessionToken, type UserSanctions } from '@sammo-ts/common/auth/gameToken';
|
||||
import { createPasswordEnvelopeService } from '../src/auth/passwordEnvelope.js';
|
||||
import type { GatewayProfileRepository } from '../src/orchestrator/profileRepository.js';
|
||||
|
||||
const buildCaller = (
|
||||
options: {
|
||||
@@ -111,7 +112,7 @@ const buildCaller = (
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
const profiles = {
|
||||
const profiles: GatewayProfileRepository = {
|
||||
listProfiles: async () => profileRows,
|
||||
getProfile: async (profileName: string) =>
|
||||
profileRows.find((profile) => profile.profileName === profileName) ?? null,
|
||||
@@ -129,6 +130,13 @@ const buildCaller = (
|
||||
clearWorkspaceUsage: async () => {},
|
||||
listOperations: async () => [],
|
||||
getOperation: async () => null,
|
||||
listOperationLogs: async () => [],
|
||||
appendOperationLog: async (operationId, input) => ({
|
||||
cursor: '1',
|
||||
operationId,
|
||||
createdAt: '2026-08-11T00:00:00.000Z',
|
||||
...input,
|
||||
}),
|
||||
createOperation: async () => {
|
||||
throw new Error('not implemented');
|
||||
},
|
||||
|
||||
@@ -52,6 +52,7 @@ const createHarness = (
|
||||
const started: ProcessDefinition[] = [];
|
||||
const stopped: string[] = [];
|
||||
const deleted: string[] = [];
|
||||
const logs: Array<{ phase: string; message: string; level: string }> = [];
|
||||
|
||||
const repository: GatewayProfileRepository = {
|
||||
listProfiles: async () => [profile],
|
||||
@@ -72,6 +73,16 @@ const createHarness = (
|
||||
listOperations: async () => [],
|
||||
listActiveOperationProfileNames: async () => [profile.profileName],
|
||||
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,
|
||||
claimNextOperation: async () => {
|
||||
const result = nextOperation;
|
||||
@@ -146,7 +157,7 @@ const createHarness = (
|
||||
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', () => {
|
||||
|
||||
@@ -75,6 +75,7 @@ describe('profile DEPLOY operation', () => {
|
||||
let nextOperation: GatewayOperationRecord | null = operation;
|
||||
const patches: GatewayClaimedProfileUpdate[] = [];
|
||||
const completions: string[] = [];
|
||||
const logs: Array<{ phase: string; message: string; level: string }> = [];
|
||||
const repository: GatewayProfileRepository = {
|
||||
listProfiles: async () => [profile],
|
||||
getProfile: async () => profile,
|
||||
@@ -90,6 +91,16 @@ describe('profile DEPLOY operation', () => {
|
||||
clearWorkspaceUsage: async () => {},
|
||||
listOperations: async () => [],
|
||||
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,
|
||||
claimNextOperation: async () => {
|
||||
const value = nextOperation;
|
||||
@@ -138,8 +149,17 @@ describe('profile DEPLOY operation', () => {
|
||||
repository,
|
||||
processManager,
|
||||
buildRunner: {
|
||||
run: async (commands) => {
|
||||
run: async (commands, onProgress) => {
|
||||
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: '' };
|
||||
},
|
||||
},
|
||||
@@ -149,7 +169,7 @@ describe('profile DEPLOY operation', () => {
|
||||
redisKeyPrefix: 'sammo:test',
|
||||
gameTokenSecret: 'test-secret',
|
||||
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,
|
||||
scheduleIntervalMs: 60_000,
|
||||
@@ -173,6 +193,17 @@ describe('profile DEPLOY operation', () => {
|
||||
buildWorkspace: workspace,
|
||||
});
|
||||
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());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -69,6 +69,28 @@ describeDatabase('gateway operation lease and profile serialization', () => {
|
||||
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 () => {
|
||||
const first = await repository.createOperation({
|
||||
profileName,
|
||||
|
||||
@@ -38,7 +38,7 @@ describe('readReleaseManifest', () => {
|
||||
|
||||
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
|
||||
controllerProtocol: RELEASE_CONTROLLER_PROTOCOL,
|
||||
gatewaySchemaHead: '20260809000000_add_gateway_release_logs',
|
||||
gatewaySchemaHead: '20260811000000_add_gateway_operation_logs',
|
||||
gameSchemaHead: '20260803000000_add_logical_game_clock',
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user