feat(gateway): stream release progress logs

This commit is contained in:
2026-08-09 09:50:06 +00:00
parent 73edd0230f
commit d01be27828
15 changed files with 619 additions and 31 deletions
+30
View File
@@ -1351,6 +1351,36 @@ export const adminRouter = router({
list: releaseAdminProcedure
.input(z.object({ limit: z.number().int().min(1).max(200).optional() }).optional())
.query(({ ctx, input }) => ctx.releases.listOperations(input?.limit)),
logs: releaseAdminProcedure
.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 deadline = Date.now() + input.timeoutMs;
while (true) {
const [operation, entries] = await Promise.all([
ctx.releases.getOperation(input.id),
ctx.releases.listOperationLogs(input.id, input.afterCursor, input.limit),
]);
if (!operation) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Gateway release 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));
}
}),
requestGatewayDeploy: releaseAdminProcedure
.input(
z.object({
+46 -13
View File
@@ -13,8 +13,15 @@ export interface BuildResult {
output: string;
}
export type BuildProgressEvent =
| { type: 'COMMAND_START'; command: BuildCommand }
| { type: 'OUTPUT'; stream: 'stdout' | 'stderr'; message: string }
| { type: 'COMMAND_END'; command: BuildCommand; exitCode: number | null };
export type BuildProgressObserver = (event: BuildProgressEvent) => void | Promise<void>;
export interface BuildRunner {
run(commands: BuildCommand[]): Promise<BuildResult>;
run(commands: BuildCommand[], onProgress?: BuildProgressObserver): Promise<BuildResult>;
}
export const MAX_BUILD_OUTPUT_CHARS = 64 * 1024;
@@ -22,41 +29,67 @@ export const MAX_BUILD_OUTPUT_CHARS = 64 * 1024;
const appendOutputTail = (current: string, chunk: unknown): string =>
`${current}${String(chunk)}`.slice(-MAX_BUILD_OUTPUT_CHARS);
const runCommand = (command: BuildCommand): Promise<BuildResult> =>
const runCommand = (command: BuildCommand, onProgress?: BuildProgressObserver): Promise<BuildResult> =>
new Promise((resolve) => {
let progressQueue = Promise.resolve();
const emit = (event: BuildProgressEvent) => {
if (!onProgress) return;
progressQueue = progressQueue.then(() => onProgress(event)).catch(() => undefined);
};
emit({ type: 'COMMAND_START', command });
const child = spawn(command.command, command.args, {
cwd: command.cwd,
env: command.env,
stdio: ['ignore', 'pipe', 'pipe'],
});
let output = '';
let spawnFailed = false;
const lineBuffers = { stdout: '', stderr: '' };
const emitOutput = (stream: 'stdout' | 'stderr', chunk: unknown, flush = false) => {
if (flush && !lineBuffers[stream]) return;
lineBuffers[stream] += String(chunk);
const lines = lineBuffers[stream].split(/\r?\n/u);
lineBuffers[stream] = flush ? '' : (lines.pop() ?? '');
if (flush && lineBuffers[stream]) lines.push(lineBuffers[stream]);
for (const line of lines) {
for (let offset = 0; offset < line.length || (offset === 0 && line.length === 0); offset += 2_000) {
emit({ type: 'OUTPUT', stream, message: line.slice(offset, offset + 2_000) });
if (line.length === 0) break;
}
}
};
child.stdout.on('data', (chunk) => {
output = appendOutputTail(output, chunk);
emitOutput('stdout', chunk);
});
child.stderr.on('data', (chunk) => {
output = appendOutputTail(output, chunk);
emitOutput('stderr', chunk);
});
child.on('error', (error) => {
resolve({
ok: false,
exitCode: null,
output: appendOutputTail(output, error.message),
});
spawnFailed = true;
output = appendOutputTail(output, error.message);
});
child.on('close', (code) => {
resolve({
ok: code === 0,
exitCode: code,
output,
emitOutput('stdout', '', true);
emitOutput('stderr', '', true);
const exitCode = spawnFailed ? null : code;
emit({ type: 'COMMAND_END', command, exitCode });
void progressQueue.then(() => {
resolve({
ok: !spawnFailed && code === 0,
exitCode,
output,
});
});
});
});
export class PnpmBuildRunner implements BuildRunner {
async run(commands: BuildCommand[]): Promise<BuildResult> {
async run(commands: BuildCommand[], onProgress?: BuildProgressObserver): Promise<BuildResult> {
let mergedOutput = '';
for (const command of commands) {
const result = await runCommand(command);
const result = await runCommand(command, onProgress);
mergedOutput = appendOutputTail(mergedOutput, result.output);
if (!result.ok) {
return {
@@ -46,10 +46,30 @@ export interface GatewayReleaseOperationCreateInput {
requestedBy: string;
}
export const GATEWAY_RELEASE_LOG_LEVELS = ['INFO', 'OUTPUT', 'ERROR'] as const;
export type GatewayReleaseLogLevel = (typeof GATEWAY_RELEASE_LOG_LEVELS)[number];
export interface GatewayReleaseLogRecord {
cursor: string;
operationId: string;
level: GatewayReleaseLogLevel;
phase: string;
message: string;
createdAt: string;
}
export interface GatewayReleaseLogInput {
level: GatewayReleaseLogLevel;
phase: string;
message: string;
}
export interface GatewayReleaseRepository {
getState(): Promise<GatewayReleaseStateRecord>;
listOperations(limit?: number): Promise<GatewayReleaseOperationRecord[]>;
getOperation(id: string): Promise<GatewayReleaseOperationRecord | null>;
listOperationLogs(id: string, afterCursor?: string, limit?: number): Promise<GatewayReleaseLogRecord[]>;
appendOperationLog(id: string, input: GatewayReleaseLogInput): Promise<GatewayReleaseLogRecord>;
createOperation(input: GatewayReleaseOperationCreateInput): Promise<GatewayReleaseOperationRecord>;
claimNextOperation(
now: Date,
@@ -135,6 +155,24 @@ const mapOperation = (row: {
updatedAt: row.updatedAt.toISOString(),
});
const mapLog = (row: {
id: bigint;
operationId: string;
level: string;
phase: string;
message: string;
createdAt: Date;
}): GatewayReleaseLogRecord => ({
cursor: row.id.toString(),
operationId: row.operationId,
level: GATEWAY_RELEASE_LOG_LEVELS.includes(row.level as GatewayReleaseLogLevel)
? (row.level as GatewayReleaseLogLevel)
: 'INFO',
phase: row.phase,
message: row.message,
createdAt: row.createdAt.toISOString(),
});
export const createGatewayReleaseRepository = (prisma: GatewayPrismaClient): GatewayReleaseRepository => ({
async getState() {
const row = await prisma.gatewayReleaseState.upsert({
@@ -155,6 +193,28 @@ export const createGatewayReleaseRepository = (prisma: GatewayPrismaClient): Gat
const row = await prisma.gatewayReleaseOperation.findUnique({ where: { id } });
return row ? mapOperation(row) : null;
},
async listOperationLogs(id, afterCursor, limit = 200) {
const rows = await prisma.gatewayReleaseLog.findMany({
where: {
operationId: id,
...(afterCursor ? { id: { gt: BigInt(afterCursor) } } : {}),
},
orderBy: { id: 'asc' },
take: Math.min(Math.max(limit, 1), 500),
});
return rows.map(mapLog);
},
async appendOperationLog(id, input) {
const row = await prisma.gatewayReleaseLog.create({
data: {
operationId: id,
level: input.level,
phase: input.phase.slice(0, 64),
message: input.message.slice(0, 4_000),
},
});
return mapLog(row);
},
async createOperation(input) {
const row = await prisma.gatewayReleaseOperation.create({
data: {
+48 -1
View File
@@ -46,6 +46,16 @@ const buildCaller = async (
const session = await sessions.createSession({ ...admin, roles: adminRoles });
const createdInputs: GatewayOperationCreateInput[] = [];
const createdReleaseInputs: GatewayReleaseOperationCreateInput[] = [];
const releaseLogs = [
{
cursor: '1',
operationId: '44444444-4444-4444-8444-444444444444',
level: 'INFO' as const,
phase: 'build',
message: 'Gateway 구성 요소를 빌드합니다.',
createdAt: '2026-08-01T00:00:01.000Z',
},
];
const operationRecords = new Map<string, Awaited<ReturnType<GatewayProfileRepository['createOperation']>>>();
const createdRuntimeActions: Array<Record<string, unknown>> = [];
const flushes: Array<{ userId: string; reason?: string; iconRevision?: string }> = [];
@@ -113,7 +123,27 @@ const buildCaller = async (
updatedAt: '2026-08-01T00:00:00.000Z',
}),
listOperations: async () => [],
getOperation: async () => null,
getOperation: async (id) =>
id === '44444444-4444-4444-8444-444444444444'
? {
id,
type: 'DEPLOY',
status: 'RUNNING',
payload: {},
requestedBy: admin.id,
attempts: 1,
createdAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-01T00:00:00.000Z',
}
: null,
listOperationLogs: async (_id, afterCursor) =>
releaseLogs.filter((entry) => !afterCursor || BigInt(entry.cursor) > BigInt(afterCursor)),
appendOperationLog: async (_id, input) => ({
cursor: '2',
operationId: '44444444-4444-4444-8444-444444444444',
createdAt: '2026-08-01T00:00:02.000Z',
...input,
}),
createOperation: async (input) => {
createdReleaseInputs.push(input);
return {
@@ -517,6 +547,23 @@ describe('admin operation API', () => {
});
describe('gateway release API', () => {
it('long-polls ordered release logs with the current operation state', async () => {
const harness = await buildCaller(async () => {
throw new Error('not used');
});
await expect(
harness.caller.admin.releases.logs({
id: '44444444-4444-4444-8444-444444444444',
timeoutMs: 0,
})
).resolves.toMatchObject({
nextCursor: '1',
operation: { status: 'RUNNING' },
entries: [{ cursor: '1', phase: 'build', message: 'Gateway 구성 요소를 빌드합니다.' }],
});
});
it('queues a gateway deployment for the external release controller', async () => {
const harness = await buildCaller(async () => {
throw new Error('not used');
+26
View File
@@ -40,4 +40,30 @@ describe('PnpmBuildRunner', () => {
expect(result.output.length).toBe(MAX_BUILD_OUTPUT_CHARS);
expect(result.output.endsWith('tail-marker')).toBe(true);
});
it('streams command boundaries and line-buffered output to an observer', async () => {
const runner = new PnpmBuildRunner();
const events: Array<{ type: string; message?: string }> = [];
const result = await runner.run(
[
{
command: process.execPath,
args: ['-e', "process.stdout.write('first\\npartial');"],
cwd: process.cwd(),
},
],
async (event) => {
events.push({ type: event.type, ...('message' in event ? { message: event.message } : {}) });
}
);
expect(result.ok).toBe(true);
expect(events).toEqual([
{ type: 'COMMAND_START' },
{ type: 'OUTPUT', message: 'first' },
{ type: 'OUTPUT', message: 'partial' },
{ type: 'COMMAND_END' },
]);
});
});
@@ -50,6 +50,17 @@ describeDatabase('gateway release operation persistence', () => {
await expect(repository.pinOperationResolvedCommit(operation.id, 'controller-a', 'a'.repeat(40))).resolves.toBe(
true
);
const firstLog = await repository.appendOperationLog(operation.id, {
level: 'INFO',
phase: 'build',
message: 'build started',
});
const secondLog = await repository.appendOperationLog(operation.id, {
level: 'OUTPUT',
phase: 'build',
message: 'gateway-api build complete',
});
await expect(repository.listOperationLogs(operation.id, firstLog.cursor)).resolves.toEqual([secondLog]);
await expect(
repository.publishRelease(operation.id, 'stale-controller', {
commitSha: 'a'.repeat(40),