관리자 서버 진단에 lease 상태와 영속 장애 이력 추가
This commit is contained in:
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user