관리자 서버 진단에 lease 상태와 영속 장애 이력 추가

This commit is contained in:
2026-09-09 23:36:16 +00:00
parent 9c73085353
commit a00f46dfc8
19 changed files with 627 additions and 43 deletions
+20
View File
@@ -2078,6 +2078,26 @@ export const adminRouter = router({
}),
}),
profiles: router({
diagnostics: adminProcedure
.input(z.object({ profileName: z.string().min(1).max(100) }))
.query(async ({ ctx, input }) => {
const auth = requireAdminAuth(ctx);
if (!canReadProfile(auth, input.profileName)) throw new TRPCError({ code: 'FORBIDDEN' });
const profile = await ctx.profiles.getProfile(input.profileName);
if (!profile) throw new TRPCError({ code: 'NOT_FOUND' });
const [observation, incidents, processes] = await Promise.all([
ctx.orchestrator.inspectRuntime?.(input.profileName) ?? Promise.resolve(null),
ctx.adminAudit.list({ profileName: input.profileName, targetType: 'profile-runtime', limit: 20 }),
ctx.orchestrator.listRuntimeStates([input.profileName]).catch(() => []),
]);
return {
profileName: input.profileName,
status: profile.status,
observation,
runtime: processes[0] ?? null,
incidents,
};
}),
getResetDefaults: adminProcedure
.input(z.object({ profileName: z.string().min(1) }))
.query(async ({ ctx, input }) => {
@@ -20,7 +20,7 @@ import {
type GameCancellationHistoryMode,
type GameCancellationResult,
} from '@sammo-ts/game-engine/scenario/gameCancellation.js';
import { gatewayProfileCapabilities } from '@sammo-ts/common';
import { gatewayProfileCapabilities, type ProfileRuntimeDiagnostics } from '@sammo-ts/common';
import {
createGamePostgresConnector,
createRedisConnector,
@@ -177,6 +177,7 @@ export interface GatewayOrchestratorHandle {
}>;
listRuntimeStates(profileNames: string[]): Promise<ProfileRuntimeSnapshot[]>;
listRuntimeSettings?(profileNames: string[]): Promise<ProfileRuntimeSettingsSnapshot[]>;
inspectRuntime?(profileName: string): Promise<ProfileRuntimeDiagnostics>;
transitionProfileClock(
profileName: string,
action: 'SUSPEND' | 'RESUME',
@@ -1164,6 +1165,94 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
return snapshots;
}
async inspectRuntime(profileName: string): Promise<ProfileRuntimeDiagnostics> {
const processes = await this.processManager.list().catch(() => null);
const empty: ProfileRuntimeDiagnostics = {
profileName,
checkedAt: new Date().toISOString(),
database: 'UNINITIALIZED',
processObservation: processes ? 'AVAILABLE' : 'UNAVAILABLE',
processes: (processes ?? [])
.filter((process) => process.name.startsWith(`sammo:${profileName}:`))
.map((process) => ({
name: process.name,
status: process.status,
restartCount: process.restartCount ?? 0,
exitCode: process.exitCode ?? null,
})),
lease: null,
clock: null,
};
const profile = await this.repository.getProfile(profileName);
if (!profile || profile.currentScenario === null) return empty;
const connector = createGamePostgresConnector({
url: this.resolveProfileDatabaseUrl(profile),
maxConnections: 1,
connectionTimeoutMillis: 3000,
});
try {
await connector.connect();
return await connector.prisma.$transaction(
async (db) => {
await db.$executeRaw`SET LOCAL statement_timeout = '3000ms'`;
const [time] = await db.$queryRaw<
Array<{ now: Date }>
>`SELECT clock_timestamp() AT TIME ZONE 'UTC' AS now`;
const lease = await db.turnDaemonLease.findUnique({ where: { profile: profileName } });
const clock = await db.worldState.findFirst({
orderBy: { id: 'asc' },
select: {
clockPhase: true,
clockRevision: true,
clockTick: true,
lastTurnTick: true,
currentYear: true,
currentMonth: true,
clockWallAnchor: true,
clockRecoveryStartWallAt: true,
clockRecoveryEndTick: true,
},
});
const now = time!.now;
return {
...empty,
checkedAt: now.toISOString(),
database: 'AVAILABLE' as const,
lease: lease
? {
ownerId: lease.ownerId,
fencingEpoch: lease.fencingEpoch.toString(),
heartbeatAt: lease.heartbeatAt.toISOString(),
leaseUntil: lease.leaseUntil.toISOString(),
heartbeatAgeMs: Math.max(0, now.getTime() - lease.heartbeatAt.getTime()),
valid: lease.leaseUntil > now,
clockReady: lease.clockReady,
}
: null,
clock: clock
? {
phase: clock.clockPhase,
revision: clock.clockRevision.toString(),
tick: clock.clockTick?.toString() ?? null,
lastTurnTick: clock.lastTurnTick?.toString() ?? null,
year: clock.currentYear,
month: clock.currentMonth,
wallAnchor: clock.clockWallAnchor?.toISOString() ?? null,
recoveryStartWallAt: clock.clockRecoveryStartWallAt?.toISOString() ?? null,
recoveryEndTick: clock.clockRecoveryEndTick?.toString() ?? null,
}
: null,
};
},
{ timeout: 5000, maxWait: 3000 }
);
} catch {
return { ...empty, checkedAt: new Date().toISOString(), database: 'UNAVAILABLE' };
} finally {
await connector.disconnect().catch(() => undefined);
}
}
async listRuntimeSettings(profileNames: string[]): Promise<ProfileRuntimeSettingsSnapshot[]> {
const allowedAutorunOptions = new Set<RuntimeAutorunOption>([
'develop',
@@ -123,6 +123,12 @@ export class Pm2ProcessManager implements ProcessManager {
cwd: item.pm2_env?.pm_cwd ?? undefined,
script: item.pm2_env?.pm_exec_path ?? undefined,
restartCount: item.pm2_env?.restart_time ?? 0,
exitCode:
item.pm2_env &&
'exit_code' in item.pm2_env &&
typeof item.pm2_env.exit_code === 'number'
? item.pm2_env.exit_code
: undefined,
})) ?? [];
resolve(normalized);
});
@@ -145,16 +151,13 @@ export class Pm2ProcessManager implements ProcessManager {
reject(new Error(`PM2 process name already exists: ${definition.name}`));
return;
}
pm2.start(
buildPm2StartOptions(definition),
(error) => {
if (error) {
reject(error);
return;
}
resolve();
pm2.start(buildPm2StartOptions(definition), (error) => {
if (error) {
reject(error);
return;
}
);
resolve();
});
});
})
);
@@ -5,6 +5,7 @@ export interface ManagedProcessInfo {
cwd?: string;
script?: string;
restartCount?: number;
exitCode?: number;
}
export interface ProcessDefinition {
@@ -435,6 +435,35 @@ describe('admin profile navigation API', () => {
});
});
describe('runtime diagnostics authorization', () => {
it('allows the scoped administrator and rejects another profile scope', async () => {
const harness = await buildCaller(
async () => {
throw new Error('not used');
},
{ adminRoles: ['admin.profiles.runtime:che:2'], firstUserIsAdmin: false }
);
await expect(harness.caller.admin.profiles.diagnostics({ profileName: 'che:2' })).resolves.toMatchObject({
profileName: 'che:2',
incidents: [],
});
await expect(harness.caller.admin.profiles.diagnostics({ profileName: 'kwe:2' })).rejects.toMatchObject({
code: 'FORBIDDEN',
});
});
it('rejects users without profile administration permission', async () => {
const harness = await buildCaller(
async () => {
throw new Error('not used');
},
{ adminRoles: [], firstUserIsAdmin: false }
);
await expect(harness.caller.admin.profiles.diagnostics({ profileName: 'che:2' })).rejects.toMatchObject({
code: 'FORBIDDEN',
});
});
});
describe('admin scenario catalog API', () => {
it('marks scenario zero as the current selectable scenario', async () => {
const harness = await buildCaller(
@@ -174,6 +174,36 @@ const postTrpc = async (
};
describe('admin security over HTTP transport', () => {
it('protects runtime diagnostics at the HTTP authentication and profile scope boundaries', async () => {
const harness = await createHarness(['admin.profiles.runtime:che:default']);
const input = { profileName: 'che:default' };
expect((await postTrpc(harness.baseUrl, 'admin.profiles.diagnostics', input)).response.status).toBe(401);
expect(
(await postTrpc(harness.baseUrl, 'admin.profiles.diagnostics', input, harness.adminSessionToken)).response
.status
).toBe(200);
expect(
(
await postTrpc(
harness.baseUrl,
'admin.profiles.diagnostics',
{ profileName: 'kwe:default' },
harness.adminSessionToken
)
).response.status
).toBe(403);
expect(
(
await postTrpc(
harness.baseUrl,
'admin.profiles.diagnostics',
{ profileName: '' },
harness.adminSessionToken
)
).response.status
).toBe(400);
});
it('accepts query input from a POST JSON body but still rejects a mutation sent as GET', async () => {
const harness = await createHarness();