feat(fleet): show instance resource metrics
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
export interface UpstreamRequest {
|
||||
readonly url: string;
|
||||
readonly method: 'POST';
|
||||
readonly method: 'GET' | 'POST';
|
||||
readonly headers: Readonly<Record<string, string>>;
|
||||
/** One-shot secret. Request implementations must not log or persist this field. */
|
||||
readonly secret?: string;
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { parseSim, parseStats } from './instance-resource-service.js';
|
||||
|
||||
const response = (body: unknown) => ({
|
||||
status: 200,
|
||||
headers: {},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
describe('instance resource allowlist parsing', () => {
|
||||
it('extracts CPU, memory and only the highest valid temperature', () => {
|
||||
expect(
|
||||
parseStats(
|
||||
response({
|
||||
data: {
|
||||
cpu_load: { load_percent: 23.4, secret: 'drop' },
|
||||
memory: { used_percent: 67.8, total_bytes: 123 },
|
||||
temperature: [
|
||||
{ label: 'a', temperature: 41.2 },
|
||||
{ label: 'b', temperature: 52.6 },
|
||||
{ label: 'bad', temperature: 900 },
|
||||
],
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toEqual({ cpuPercent: 23.4, memoryPercent: 67.8, maxTemperatureCelsius: 52.6 });
|
||||
});
|
||||
|
||||
it('extracts, validates, deduplicates and bounds phone numbers only', () => {
|
||||
expect(
|
||||
parseSim(
|
||||
response({
|
||||
data: {
|
||||
phone_numbers: ['+86 138-0000-0000', '+86 138-0000-0000', 'bad<script>'],
|
||||
imsi: 'drop',
|
||||
iccid: 'drop',
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toEqual({ phoneNumbers: ['+86 138-0000-0000'] });
|
||||
});
|
||||
|
||||
it('fails closed for malformed and oversized payloads', () => {
|
||||
expect(parseStats({ status: 200, headers: {}, body: '{' })).toEqual({});
|
||||
expect(parseSim({ status: 200, headers: {}, body: 'x'.repeat(40_000) })).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { InstanceService } from '../instances/instance-service.js';
|
||||
import type {
|
||||
InstanceSessionStore,
|
||||
UpstreamResponse,
|
||||
UpstreamSessionClientOptions,
|
||||
} from '../connections/upstream-session-client.js';
|
||||
|
||||
export interface InstanceResources {
|
||||
readonly cpuPercent?: number;
|
||||
readonly memoryPercent?: number;
|
||||
readonly maxTemperatureCelsius?: number;
|
||||
readonly phoneNumbers?: readonly string[];
|
||||
}
|
||||
|
||||
const MAX_BODY_BYTES = 32_768;
|
||||
const record = (value: unknown): Record<string, unknown> | undefined =>
|
||||
value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
const ranged = (value: unknown, minimum: number, maximum: number): number | undefined =>
|
||||
typeof value === 'number' && Number.isFinite(value) && value >= minimum && value <= maximum
|
||||
? value
|
||||
: undefined;
|
||||
|
||||
function data(response: UpstreamResponse): Record<string, unknown> | undefined {
|
||||
if (response.status < 200 || response.status >= 300) return undefined;
|
||||
if (Buffer.byteLength(response.body, 'utf8') > MAX_BODY_BYTES) return undefined;
|
||||
try {
|
||||
return record(record(JSON.parse(response.body))?.data);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseStats(response: UpstreamResponse): InstanceResources {
|
||||
const value = data(response);
|
||||
if (!value) return {};
|
||||
const cpuPercent = ranged(record(value.cpu_load)?.load_percent, 0, 100);
|
||||
const memoryPercent = ranged(record(value.memory)?.used_percent, 0, 100);
|
||||
const temperatures = Array.isArray(value.temperature)
|
||||
? value.temperature
|
||||
.map((item) => ranged(record(item)?.temperature, -100, 300))
|
||||
.filter((item): item is number => item !== undefined)
|
||||
: [];
|
||||
const maxTemperatureCelsius = temperatures.length > 0 ? Math.max(...temperatures) : undefined;
|
||||
return {
|
||||
...(cpuPercent === undefined ? {} : { cpuPercent }),
|
||||
...(memoryPercent === undefined ? {} : { memoryPercent }),
|
||||
...(maxTemperatureCelsius === undefined ? {} : { maxTemperatureCelsius }),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSim(response: UpstreamResponse): InstanceResources {
|
||||
const value = data(response);
|
||||
if (!value || !Array.isArray(value.phone_numbers)) return {};
|
||||
const phoneNumbers = value.phone_numbers.filter(
|
||||
(item): item is string =>
|
||||
typeof item === 'string' &&
|
||||
item.length > 0 &&
|
||||
item.length <= 32 &&
|
||||
/^[+0-9 ()-]+$/.test(item),
|
||||
);
|
||||
return phoneNumbers.length > 0 ? { phoneNumbers: [...new Set(phoneNumbers)].slice(0, 16) } : {};
|
||||
}
|
||||
|
||||
export class InstanceResourceService {
|
||||
constructor(
|
||||
private readonly options: {
|
||||
readonly instances: InstanceService;
|
||||
readonly sessions: InstanceSessionStore;
|
||||
readonly request: UpstreamSessionClientOptions['request'];
|
||||
},
|
||||
) {}
|
||||
|
||||
async get(instanceId: string): Promise<InstanceResources> {
|
||||
const [instance, session] = await Promise.all([
|
||||
this.options.instances.get(instanceId),
|
||||
Promise.resolve(this.options.sessions.sessionFor(instanceId)),
|
||||
]);
|
||||
if (!instance || !session || session.origin !== instance.origin) return {};
|
||||
const get = (path: '/api/stats' | '/api/sim') =>
|
||||
this.options.request({
|
||||
url: `${instance.origin}${path}`,
|
||||
method: 'GET',
|
||||
headers: { cookie: session.cookie, accept: 'application/json' },
|
||||
});
|
||||
const [stats, sim] = await Promise.allSettled([get('/api/stats'), get('/api/sim')]);
|
||||
return {
|
||||
...(stats.status === 'fulfilled' ? parseStats(stats.value) : {}),
|
||||
...(sim.status === 'fulfilled' ? parseSim(sim.value) : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import { JobQueryService } from './application/jobs/job-query-service.js';
|
||||
import { registerJobRoutes } from './interface/http/job-routes.js';
|
||||
import { AuditQueryService } from './application/audit/audit-query-service.js';
|
||||
import { registerAuditRoutes } from './interface/http/audit-routes.js';
|
||||
import { InstanceResourceService } from './application/resources/instance-resource-service.js';
|
||||
|
||||
export interface SafeControlPlaneUpstream extends ConnectionTransport {
|
||||
request: UpstreamSessionClientOptions['request'];
|
||||
@@ -61,6 +62,11 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane
|
||||
: new ConnectionProbe({ db: options.db, instances, transport: options.upstream });
|
||||
const sessions = new InstanceSessionStore();
|
||||
const client = new UpstreamSessionClient({ sessions, request: options.upstream.request });
|
||||
const resources = new InstanceResourceService({
|
||||
instances,
|
||||
sessions,
|
||||
request: options.upstream.request,
|
||||
});
|
||||
const resolver = new InstanceCredentialResolver({ db: options.db, store: options.store });
|
||||
const login = options.now
|
||||
? new InstanceLoginService({ db: options.db, client, resolver, now: options.now })
|
||||
@@ -89,6 +95,7 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane
|
||||
connections,
|
||||
login,
|
||||
deletion,
|
||||
resources,
|
||||
registerDeletionPreparationRoute: false,
|
||||
});
|
||||
registerOperationRoutes(app, operationCatalogRegistry, secureExecution, deletion);
|
||||
|
||||
@@ -68,8 +68,11 @@ const origin = (raw: string): URL => {
|
||||
};
|
||||
export class SafeInstanceTransport {
|
||||
constructor(private readonly options: SafeTransportOptions) {}
|
||||
async get(raw: string): Promise<TransportResponse> {
|
||||
return this.send({ url: raw, method: 'GET', headers: {} });
|
||||
async get(
|
||||
raw: string,
|
||||
headers: Readonly<Record<string, string>> = {},
|
||||
): Promise<TransportResponse> {
|
||||
return this.send({ url: raw, method: 'GET', headers });
|
||||
}
|
||||
async post(
|
||||
raw: string,
|
||||
|
||||
@@ -7,7 +7,7 @@ import { UpstreamError } from './upstream-error.js';
|
||||
import { OperationNotDispatchedError } from '../../application/operations/secure-operation-execution.js';
|
||||
|
||||
export interface SafeUpstreamTransport {
|
||||
get(url: string): Promise<TransportResponse>;
|
||||
get(url: string, headers?: Readonly<Record<string, string>>): Promise<TransportResponse>;
|
||||
post(
|
||||
url: string,
|
||||
headers: Readonly<Record<string, string>>,
|
||||
@@ -46,6 +46,21 @@ export class SafeUpstreamGateway {
|
||||
}
|
||||
async request(request: UpstreamRequest): Promise<UpstreamResponse> {
|
||||
const url = new URL(request.url);
|
||||
if (request.method === 'GET') {
|
||||
if (
|
||||
request.secret !== undefined ||
|
||||
request.body !== undefined ||
|
||||
(url.pathname !== '/api/stats' && url.pathname !== '/api/sim') ||
|
||||
url.search ||
|
||||
url.hash ||
|
||||
url.username ||
|
||||
url.password ||
|
||||
typeof request.headers.cookie !== 'string' ||
|
||||
!/^simadmin_session=[^;\s,]+$/.test(request.headers.cookie)
|
||||
)
|
||||
throw new UpstreamError('UPSTREAM_REQUEST_INVALID');
|
||||
return this.options.transport.get(request.url, request.headers);
|
||||
}
|
||||
if (url.protocol !== 'https:') throw new UpstreamError('UPSTREAM_INSECURE_AUTH');
|
||||
if (request.method !== 'POST') throw new UpstreamError('UPSTREAM_REQUEST_INVALID');
|
||||
if (request.url.endsWith('/api/auth/login')) {
|
||||
|
||||
@@ -19,12 +19,14 @@ import {
|
||||
DeleteInstanceOperation,
|
||||
DeleteInstanceOperationError,
|
||||
} from '../../application/operations/delete-instance-operation.js';
|
||||
import type { InstanceResourceService } from '../../application/resources/instance-resource-service.js';
|
||||
|
||||
export interface InstanceRoutesOptions {
|
||||
readonly instances: InstanceService;
|
||||
readonly connections?: ConnectionProbe;
|
||||
readonly login?: InstanceLoginService;
|
||||
readonly deletion?: DeleteInstanceOperation;
|
||||
readonly resources?: InstanceResourceService;
|
||||
readonly registerDeletionPreparationRoute?: boolean;
|
||||
}
|
||||
const problem = (
|
||||
@@ -336,6 +338,13 @@ export function registerInstanceRoutes(app: FastifyInstance, options: InstanceRo
|
||||
return result;
|
||||
}),
|
||||
);
|
||||
if (options.resources)
|
||||
app.get(
|
||||
'/api/v1/instances/:instanceId/resources',
|
||||
wrap(async (request) =>
|
||||
options.resources!.get((request.params as { instanceId: string }).instanceId),
|
||||
),
|
||||
);
|
||||
app.patch(
|
||||
'/api/v1/instances/:instanceId',
|
||||
{ schema: { body: instancePatchSchema } },
|
||||
|
||||
Reference in New Issue
Block a user