Files
core2026/app/gateway-api/src/adminRouter.ts
T

159 lines
5.9 KiB
TypeScript

import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { procedure, router } from './trpc.js';
import {
GATEWAY_BUILD_STATUSES,
GATEWAY_PROFILE_STATUSES,
} from './orchestrator/profileRepository.js';
const zProfileStatus = z.enum(GATEWAY_PROFILE_STATUSES);
const zBuildStatus = z.enum(GATEWAY_BUILD_STATUSES);
const adminProcedure = procedure.use(({ ctx, next }) => {
if (!ctx.adminToken) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Admin token is not configured.',
});
}
const provided =
ctx.requestHeaders['x-admin-token'] ??
ctx.requestHeaders['authorization'] ??
'';
const token =
Array.isArray(provided) ? provided[0] ?? '' : (provided as string);
if (!token || token !== ctx.adminToken) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'Invalid admin token.',
});
}
return next();
});
export const adminRouter = router({
profiles: router({
list: adminProcedure.query(async ({ ctx }) => {
const profiles = await ctx.profiles.listProfiles();
const runtimeStates = await ctx.orchestrator.listRuntimeStates(
profiles.map((profile) => profile.profileName)
);
const runtimeMap = new Map(
runtimeStates.map((state) => [state.profileName, state])
);
return profiles.map((profile) => ({
...profile,
runtime: runtimeMap.get(profile.profileName) ?? {
profileName: profile.profileName,
apiRunning: false,
daemonRunning: false,
},
}));
}),
upsert: adminProcedure
.input(
z.object({
profile: z.string().min(1).max(32),
scenario: z.string().min(1).max(64),
apiPort: z.number().int().min(1).max(65535),
status: zProfileStatus.optional(),
preopenAt: z.string().datetime().optional(),
openAt: z.string().datetime().optional(),
scheduledStartAt: z.string().datetime().optional(),
buildCommitSha: z.string().min(7).max(64).optional(),
})
)
.mutation(async ({ ctx, input }) => {
const status = input.status ?? 'STOPPED';
return ctx.profiles.upsertProfile({
profile: input.profile,
scenario: input.scenario,
apiPort: input.apiPort,
status,
preopenAt: input.preopenAt,
openAt: input.openAt,
scheduledStartAt: input.scheduledStartAt,
buildCommitSha: input.buildCommitSha,
});
}),
setStatus: adminProcedure
.input(
z.object({
profileName: z.string().min(1),
status: zProfileStatus,
preopenAt: z.string().datetime().optional(),
openAt: z.string().datetime().optional(),
scheduledStartAt: z.string().datetime().optional(),
buildCommitSha: z.string().min(7).max(64).optional(),
})
)
.mutation(async ({ ctx, input }) => {
if (input.status === 'RESERVED' && (!input.preopenAt || !input.openAt)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'preopenAt and openAt are required for RESERVED status.',
});
}
const result = await ctx.profiles.updateStatus(
input.profileName,
input.status,
{
preopenAt: input.preopenAt,
openAt: input.openAt,
scheduledStartAt:
input.status === 'RESERVED' ? input.scheduledStartAt : null,
}
);
if (input.buildCommitSha) {
await ctx.profiles.updateBuildStatus(input.profileName, 'IDLE', {
commitSha: input.buildCommitSha,
});
}
await ctx.orchestrator.reconcileNow();
return result;
}),
requestBuild: adminProcedure
.input(
z.object({
profileName: z.string().min(1),
commitSha: z.string().min(7).max(64),
})
)
.mutation(async ({ ctx, input }) => {
const requestedAt = new Date().toISOString();
const result = await ctx.profiles.updateBuildStatus(
input.profileName,
'QUEUED',
{
requestedAt,
error: null,
commitSha: input.commitSha,
}
);
return result;
}),
setBuildStatus: adminProcedure
.input(
z.object({
profileName: z.string().min(1),
status: zBuildStatus,
})
)
.mutation(async ({ ctx, input }) =>
ctx.profiles.updateBuildStatus(input.profileName, input.status)
),
reconcileNow: adminProcedure.mutation(async ({ ctx }) => {
await ctx.orchestrator.reconcileNow();
return { ok: true };
}),
cleanupWorkspaces: adminProcedure.mutation(async ({ ctx }) => {
const result = await ctx.orchestrator.cleanupStaleWorkspaces();
return {
removed: result.removed,
skipped: result.skipped,
};
}),
}),
});