Files
ai-code-app/etc/servers/work-server/src/routes/server-command.ts
2026-05-27 10:43:01 +09:00

381 lines
12 KiB
TypeScript

import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
import { env } from '../config/env.js';
import { getSharedResourceTokenDetailBySharePath } from '../services/shared-resource-token-service.js';
import {
deployTestServerCommand,
deployWorkServerCommand,
listServerCommands,
readWorkServerDeploymentState,
restartServerCommand,
serverCommandKeys,
} from '../services/server-command-service.js';
import { readTestServerDeploymentState } from '../services/test-server-deployment-service.js';
import {
cancelServerRestartReservation,
confirmServerRestartReservation,
getRestartReservationWorkloadSummary,
requestImmediateRestartRecovery,
getServerRestartReservation,
scheduleServerRestartReservation,
} from '../services/server-restart-reservation-service.js';
const serverCommandParamSchema = z.object({
key: z.enum(serverCommandKeys),
});
const restartReservationBodySchema = z.object({
target: z.enum(['all', 'test', 'work-server']).optional(),
autoExecuteDelaySeconds: z.number().int().min(1).max(300).optional(),
});
const CHAT_SHARE_PATH_PREFIX = '/chat/share/';
function getImmediateRestartBlockInfo(
key: z.infer<typeof serverCommandParamSchema>['key'],
workloadSummary: Awaited<ReturnType<typeof getRestartReservationWorkloadSummary>>,
) {
const codexPendingCount = workloadSummary.codexRunningCount + workloadSummary.codexQueuedCount;
const automationPendingCount = workloadSummary.automationRunningCount + workloadSummary.automationQueuedCount;
if (key === 'test') {
const pendingCount = codexPendingCount + automationPendingCount;
if (pendingCount > 0) {
return {
pendingCount,
message: `진행 중인 Codex Live/자동화 작업 ${pendingCount}건이 있어 즉시 재기동할 수 없습니다. 재기동 예약을 사용해 주세요.`,
};
}
return null;
}
if (key === 'work-server') {
return null;
}
return null;
}
function getRequestAccessToken(request: FastifyRequest) {
const tokenHeader = request.headers['x-access-token'];
return Array.isArray(tokenHeader) ? tokenHeader[0]?.trim() ?? '' : String(tokenHeader ?? '').trim();
}
function getRequestChatShareToken(request: FastifyRequest) {
const tokenHeader = request.headers['x-chat-share-token'];
return Array.isArray(tokenHeader) ? tokenHeader[0]?.trim() ?? '' : String(tokenHeader ?? '').trim();
}
function resolveChatSharePath(token: string) {
return `${CHAT_SHARE_PATH_PREFIX}${encodeURIComponent(token)}`;
}
async function resolveSharedServerCommandAccessContext(request: FastifyRequest) {
const shareToken = getRequestChatShareToken(request);
if (!shareToken) {
return null;
}
const managedResource = await getSharedResourceTokenDetailBySharePath(resolveChatSharePath(shareToken));
if (!managedResource || managedResource.token.enabled === false || managedResource.token.revokedAt) {
return null;
}
const allowedAppIds = managedResource.token.allowedAppIds ?? [];
const normalizedAllowedAppIds = new Set(allowedAppIds.map((item) => item.trim().toLowerCase()).filter(Boolean));
if (!managedResource.token.permissions.includes('manage') || !normalizedAllowedAppIds.has('server-command')) {
return null;
}
return {
scope: 'shared' as const,
allowedKeys: new Set<string>(['work-server', 'test']),
};
}
function getRequestClientId(request: FastifyRequest) {
const clientIdHeader = request.headers['x-client-id'];
return Array.isArray(clientIdHeader) ? clientIdHeader[0]?.trim() ?? '' : String(clientIdHeader ?? '').trim();
}
function getRequestAppOrigin(request: FastifyRequest) {
const appOriginHeader = request.headers['x-app-origin'];
const appOrigin = Array.isArray(appOriginHeader) ? appOriginHeader[0] : appOriginHeader;
if (appOrigin?.trim()) {
return appOrigin.trim();
}
const originHeader = request.headers.origin;
const origin = Array.isArray(originHeader) ? originHeader[0] : originHeader;
return origin?.trim() ?? '';
}
async function resolveServerCommandAccessContext(request: FastifyRequest) {
if (getRequestAccessToken(request) === env.SERVER_COMMAND_ACCESS_TOKEN) {
return { scope: 'full' as const };
}
return resolveSharedServerCommandAccessContext(request);
}
function sendAccessDenied(reply: FastifyReply) {
reply.status(403);
void reply.send({
message: '권한 토큰 또는 워크서버 재기동 권한이 있는 공유채팅 링크가 필요합니다.',
});
}
export async function registerServerCommandRoutes(app: FastifyInstance) {
app.get('/api/server-commands', async (request, reply) => {
const accessContext = await resolveServerCommandAccessContext(request);
if (!accessContext) {
sendAccessDenied(reply);
return;
}
const items = await listServerCommands();
return {
ok: true,
items: accessContext.scope === 'full' ? items : items.filter((item) => accessContext.allowedKeys.has(item.key)),
};
});
app.post('/api/server-commands/:key/actions/restart', async (request, reply) => {
const accessContext = await resolveServerCommandAccessContext(request);
if (!accessContext) {
sendAccessDenied(reply);
return;
}
const { key } = serverCommandParamSchema.parse(request.params);
if (accessContext.scope !== 'full' && !accessContext.allowedKeys.has(key)) {
reply.status(403);
return { ok: false, message: '현재 공유채팅 링크로는 이 서버를 재기동할 수 없습니다.' };
}
if (key === 'test' || key === 'work-server') {
const workloadSummary = await getRestartReservationWorkloadSummary();
const blockInfo = getImmediateRestartBlockInfo(key, workloadSummary);
if (blockInfo) {
reply.status(409);
return {
ok: false,
message: blockInfo.message,
workloadSummary,
};
}
}
try {
const result = await restartServerCommand(key);
return {
ok: true,
item: result.server,
commandOutput: result.commandOutput,
restartState: result.restartState,
};
} catch (error) {
const message = error instanceof Error ? error.message : '서버 재기동에 실패했습니다.';
const statusCode = error && typeof error === 'object' && 'statusCode' in error ? Number((error as { statusCode?: unknown }).statusCode) : null;
if (statusCode === 409) {
reply.status(409);
return { ok: false, message };
}
if (key !== 'test' && key !== 'work-server') {
throw error;
}
if (!/(build|vite|tsc|typescript|npm run build|pnpm build|rollup|esbuild|compilation|compile|exit:\d+)/i.test(message)) {
throw error;
}
await requestImmediateRestartRecovery(app.log, key, message);
const server = (await listServerCommands()).find((item) => item.key === key);
if (!server) {
throw new Error(`${key} 서버 상태를 다시 읽지 못했습니다.`);
}
return {
ok: true,
item: server,
commandOutput: `${message}\n\n빌드 실패를 감지해 Codex 자동 개선과 재기동 재시도를 시작했습니다.`,
restartState: 'accepted' as const,
};
}
});
app.get('/api/server-commands/work-server/deployment', async (request, reply) => {
const accessContext = await resolveServerCommandAccessContext(request);
if (!accessContext) {
sendAccessDenied(reply);
return;
}
if (accessContext.scope !== 'full' && !accessContext.allowedKeys.has('work-server')) {
reply.status(403);
return { ok: false, message: '현재 공유채팅 링크로는 WORK 서버 배포 상태를 확인할 수 없습니다.' };
}
return {
ok: true,
item: (await readWorkServerDeploymentState()) ?? null,
};
});
app.post('/api/server-commands/work-server/actions/deploy', async (request, reply) => {
const accessContext = await resolveServerCommandAccessContext(request);
if (!accessContext) {
sendAccessDenied(reply);
return;
}
if (accessContext.scope !== 'full' && !accessContext.allowedKeys.has('work-server')) {
reply.status(403);
return { ok: false, message: '현재 공유채팅 링크로는 WORK 서버를 배포할 수 없습니다.' };
}
const result = await deployWorkServerCommand();
return {
ok: true,
item: result.server,
commandOutput: result.commandOutput,
restartState: result.restartState,
deployment: result.deployment ?? result.server.deployment ?? null,
};
});
app.get('/api/server-commands/test/deployment', async (request, reply) => {
const accessContext = await resolveServerCommandAccessContext(request);
if (!accessContext) {
sendAccessDenied(reply);
return;
}
if (accessContext.scope !== 'full' && !accessContext.allowedKeys.has('test')) {
reply.status(403);
return { ok: false, message: '현재 공유채팅 링크로는 TEST 서버 배포 상태를 확인할 수 없습니다.' };
}
return {
ok: true,
item: (await readTestServerDeploymentState()) ?? null,
};
});
app.post('/api/server-commands/test/actions/deploy', async (request, reply) => {
const accessContext = await resolveServerCommandAccessContext(request);
if (!accessContext) {
sendAccessDenied(reply);
return;
}
if (accessContext.scope !== 'full' && !accessContext.allowedKeys.has('test')) {
reply.status(403);
return { ok: false, message: '현재 공유채팅 링크로는 TEST 서버를 배포할 수 없습니다.' };
}
try {
const result = await deployTestServerCommand();
return {
ok: true,
item: result.server,
commandOutput: result.commandOutput,
restartState: result.restartState,
testDeployment: result.testDeployment ?? null,
};
} catch (error) {
const statusCode = error && typeof error === 'object' && 'statusCode' in error ? Number((error as { statusCode?: unknown }).statusCode) : null;
if (statusCode === 409) {
reply.status(409);
return { ok: false, message: error instanceof Error ? error.message : 'TEST 서버 배포가 이미 진행 중입니다.' };
}
throw error;
}
});
app.get('/api/server-commands/restart-reservation', async (request, reply) => {
const accessContext = await resolveServerCommandAccessContext(request);
if (!accessContext) {
sendAccessDenied(reply);
return;
}
return {
ok: true,
item: await getServerRestartReservation(),
};
});
app.put('/api/server-commands/restart-reservation', async (request, reply) => {
const accessContext = await resolveServerCommandAccessContext(request);
if (!accessContext) {
sendAccessDenied(reply);
return;
}
let payload: unknown = request.body ?? {};
if (typeof payload === 'string') {
try {
payload = JSON.parse(payload);
} catch {
payload = {};
}
}
const parsed = restartReservationBodySchema.parse(payload ?? {});
if (accessContext.scope !== 'full' && parsed.target !== 'work-server') {
return reply.status(403).send({ message: '현재 공유채팅 링크로는 WORK 서버 재기동 예약만 사용할 수 있습니다.' });
}
return {
ok: true,
item: await scheduleServerRestartReservation({
target: parsed.target,
clientId: getRequestClientId(request),
appOrigin: getRequestAppOrigin(request),
autoExecuteDelaySeconds: parsed.autoExecuteDelaySeconds,
}),
};
});
app.post('/api/server-commands/restart-reservation/confirm', async (request, reply) => {
const accessContext = await resolveServerCommandAccessContext(request);
if (!accessContext) {
sendAccessDenied(reply);
return;
}
return {
ok: true,
item: await confirmServerRestartReservation(app.log),
};
});
app.delete('/api/server-commands/restart-reservation', async (request, reply) => {
const accessContext = await resolveServerCommandAccessContext(request);
if (!accessContext) {
sendAccessDenied(reply);
return;
}
return {
ok: true,
item: await cancelServerRestartReservation(),
};
});
}