blob: 1c271e58a09c6637053a62bbb2f4aea1ea0dc2a4 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { z } from 'zod';
import { requireEncodedByteLimit, requireExactRecord } from './codec.js';
import { invalidProtocolFrame } from './errors.js';
import { defineOperation } from './operation-spec.js';
export const HOST_RESOURCE_RESULT_MAX_BYTES = 128 * 1024;
export const HOST_RESOURCE_MAX_ENTRIES = 64;
export const HOST_RESOURCE_KINDS = {
cpu: 'maka.system.cpu',
memory: 'maka.system.memory',
graphics: 'maka.system.graphics',
network: 'maka.system.network',
storage: 'maka.system.storage',
} as const;
export type HostResourceJsonValue =
| null
| boolean
| number
| string
| readonly HostResourceJsonValue[]
| HostResourceJsonObject;
export interface HostResourceJsonObject {
readonly [key: string]: HostResourceJsonValue;
}
export type HostResourceUnavailableReason = 'unsupported' | 'permission_denied' | 'probe_failed';
export type HostResourceEnvelope =
| {
readonly kind: string;
readonly schemaVersion: number;
readonly status: 'available';
readonly payload: HostResourceJsonObject;
}
| {
readonly kind: string;
readonly schemaVersion: number;
readonly status: 'unavailable';
readonly reason: HostResourceUnavailableReason;
};
const POSITIVE_COUNT = z.number().int().positive().safe();
const COUNT = z.number().int().nonnegative().safe();
const PERCENTAGE = z.number().min(0).max(100);
const NON_EMPTY_TEXT = z.string().min(1).max(512);
const CPU_RESOURCE_V1_SCHEMA = z
.strictObject({
capacity: z.strictObject({
model: NON_EMPTY_TEXT,
logicalProcessors: POSITIVE_COUNT,
availableParallelism: POSITIVE_COUNT,
}),
observation: z.strictObject({
busyTimeMilliseconds: COUNT,
totalTimeMilliseconds: COUNT,
}),
})
.refine(
(value) =>
value.capacity.availableParallelism <= value.capacity.logicalProcessors &&
value.observation.busyTimeMilliseconds <= value.observation.totalTimeMilliseconds,
'CPU resource counters exceed capacity',
);
const MEMORY_RESOURCE_V1_SCHEMA = z
.strictObject({
capacity: z.strictObject({
visibleTotalBytes: POSITIVE_COUNT,
effectiveTotalBytes: POSITIVE_COUNT,
}),
observation: z.strictObject({ availableBytes: COUNT }),
})
.refine(
(value) =>
value.capacity.effectiveTotalBytes <= value.capacity.visibleTotalBytes &&
value.observation.availableBytes <= value.capacity.effectiveTotalBytes,
'memory resource counters exceed capacity',
);
const GRAPHICS_RESOURCE_V1_SCHEMA = z
.strictObject({
detection: z.enum(['detected', 'not_detected', 'unknown']),
devices: z
.array(
z.strictObject({
id: z.string().min(1).max(128),
model: NON_EMPTY_TEXT,
vendor: z.string().min(1).max(256).optional(),
memory: z.discriminatedUnion('kind', [
z.strictObject({ kind: z.literal('dedicated'), bytes: POSITIVE_COUNT }),
z.strictObject({ kind: z.literal('shared') }),
z.strictObject({ kind: z.literal('unknown') }),
]),
utilizationPercent: PERCENTAGE.optional(),
}),
)
.max(32),
})
.refine(
(value) => (value.detection === 'detected') === value.devices.length > 0,
'graphics adapter detection does not match its devices',
);
const NETWORK_RESOURCE_V1_SCHEMA = z.strictObject({
observation: z.strictObject({
interfaceName: NON_EMPTY_TEXT,
monotonicTimeMilliseconds: z.number().nonnegative().finite(),
receivedBytes: COUNT,
transmittedBytes: COUNT,
}),
});
const STORAGE_RESOURCE_V1_SCHEMA = z.strictObject({
volumes: z
.array(
z
.strictObject({
mount: z
.string()
.min(1)
.max(2 * 1024),
filesystem: z.string().min(1).max(128).optional(),
totalBytes: POSITIVE_COUNT,
availableBytes: COUNT,
})
.refine(
(volume) => volume.availableBytes <= volume.totalBytes,
'storage availability exceeds capacity',
),
)
.max(64),
});
export type HostCpuResourceV1 = z.infer<typeof CPU_RESOURCE_V1_SCHEMA>;
export type HostMemoryResourceV1 = z.infer<typeof MEMORY_RESOURCE_V1_SCHEMA>;
export type HostGraphicsResourceV1 = z.infer<typeof GRAPHICS_RESOURCE_V1_SCHEMA>;
export type HostNetworkResourceV1 = z.infer<typeof NETWORK_RESOURCE_V1_SCHEMA>;
export type HostStorageResourceV1 = z.infer<typeof STORAGE_RESOURCE_V1_SCHEMA>;
export interface HostResourcePayloads {
readonly [HOST_RESOURCE_KINDS.cpu]: HostCpuResourceV1;
readonly [HOST_RESOURCE_KINDS.memory]: HostMemoryResourceV1;
readonly [HOST_RESOURCE_KINDS.graphics]: HostGraphicsResourceV1;
readonly [HOST_RESOURCE_KINDS.network]: HostNetworkResourceV1;
readonly [HOST_RESOURCE_KINDS.storage]: HostStorageResourceV1;
}
export type HostResourceKind = keyof HostResourcePayloads;
export interface HostResourcesResult {
readonly hostEpoch: string;
readonly observedAt: string;
readonly monotonicTimeMilliseconds: number;
readonly resources: readonly HostResourceEnvelope[];
}
export type HostResourcesQueryInput = Record<string, never>;
export const HOST_RESOURCE_OPERATION_SPECS = {
'host.resources.query': defineOperation({
mode: 'query',
availability: 'bootstrap',
errors: ['host_draining', 'internal_failure'] as const,
decodeInput: (value) => {
requireExactRecord(value, 'host.resources.query input', []);
return {};
},
decodeOutput: decodeHostResourcesResult,
}),
} as const;
export function availableHostResource<K extends HostResourceKind>(
snapshot: HostResourcesResult | undefined,
kind: K,
): HostResourcePayloads[K] | undefined {
const resource = snapshot?.resources.find(
(candidate) =>
candidate.kind === kind && candidate.schemaVersion === 1 && candidate.status === 'available',
);
return resource?.status === 'available'
? (resource.payload as HostResourcePayloads[K])
: undefined;
}
export interface HostResourceUtilization {
readonly cpuPercent?: number;
readonly memoryPercent?: number;
readonly receivedBytesPerSecond?: number;
readonly transmittedBytesPerSecond?: number;
}
export function deriveHostResourceUtilization(
current: HostResourcesResult,
previous?: HostResourcesResult,
): HostResourceUtilization {
const memory = availableHostResource(current, HOST_RESOURCE_KINDS.memory);
const utilization: HostResourceUtilization = {
...(memory
? {
memoryPercent: percentage(
memory.capacity.effectiveTotalBytes - memory.observation.availableBytes,
memory.capacity.effectiveTotalBytes,
),
}
: {}),
};
if (!previous || previous.hostEpoch !== current.hostEpoch) return utilization;
const elapsedMilliseconds =
current.monotonicTimeMilliseconds - previous.monotonicTimeMilliseconds;
if (elapsedMilliseconds <= 0) return utilization;
const cpu = availableHostResource(current, HOST_RESOURCE_KINDS.cpu);
const previousCpu = availableHostResource(previous, HOST_RESOURCE_KINDS.cpu);
if (cpu && previousCpu) {
const busyDelta =
cpu.observation.busyTimeMilliseconds - previousCpu.observation.busyTimeMilliseconds;
const totalDelta =
cpu.observation.totalTimeMilliseconds - previousCpu.observation.totalTimeMilliseconds;
if (busyDelta >= 0 && totalDelta > 0) {
Object.assign(utilization, { cpuPercent: percentage(busyDelta, totalDelta) });
}
}
const network = availableHostResource(current, HOST_RESOURCE_KINDS.network);
const previousNetwork = availableHostResource(previous, HOST_RESOURCE_KINDS.network);
if (
network &&
previousNetwork &&
network.observation.interfaceName === previousNetwork.observation.interfaceName
) {
const networkElapsedMilliseconds =
network.observation.monotonicTimeMilliseconds -
previousNetwork.observation.monotonicTimeMilliseconds;
if (networkElapsedMilliseconds <= 0) return utilization;
const elapsedSeconds = networkElapsedMilliseconds / 1_000;
assignRate(
utilization,
'receivedBytesPerSecond',
network.observation.receivedBytes - previousNetwork.observation.receivedBytes,
elapsedSeconds,
);
assignRate(
utilization,
'transmittedBytesPerSecond',
network.observation.transmittedBytes - previousNetwork.observation.transmittedBytes,
elapsedSeconds,
);
}
return utilization;
}
const ENVELOPE_SCHEMA = z.discriminatedUnion('status', [
z.strictObject({
kind: z.string().min(1).max(128),
schemaVersion: POSITIVE_COUNT,
status: z.literal('available'),
payload: z.unknown(),
}),
z.strictObject({
kind: z.string().min(1).max(128),
schemaVersion: POSITIVE_COUNT,
status: z.literal('unavailable'),
reason: z.enum(['unsupported', 'permission_denied', 'probe_failed']),
}),
]);
const RESULT_SCHEMA = z.strictObject({
hostEpoch: z.string().min(1).max(128),
observedAt: z.iso.datetime(),
monotonicTimeMilliseconds: z.number().nonnegative().finite(),
resources: z.array(ENVELOPE_SCHEMA).max(HOST_RESOURCE_MAX_ENTRIES),
});
const KNOWN_PAYLOAD_SCHEMAS: Readonly<Record<HostResourceKind, z.ZodType>> = {
[HOST_RESOURCE_KINDS.cpu]: CPU_RESOURCE_V1_SCHEMA,
[HOST_RESOURCE_KINDS.memory]: MEMORY_RESOURCE_V1_SCHEMA,
[HOST_RESOURCE_KINDS.graphics]: GRAPHICS_RESOURCE_V1_SCHEMA,
[HOST_RESOURCE_KINDS.network]: NETWORK_RESOURCE_V1_SCHEMA,
[HOST_RESOURCE_KINDS.storage]: STORAGE_RESOURCE_V1_SCHEMA,
};
function decodeHostResourcesResult(value: unknown): HostResourcesResult {
requireEncodedByteLimit(value, 'host.resources.query result', HOST_RESOURCE_RESULT_MAX_BYTES);
const result = parse(RESULT_SCHEMA, value, 'Runtime Host resources');
if (new Set(result.resources.map((resource) => resource.kind)).size !== result.resources.length) {
throw invalidProtocolFrame('Duplicate Runtime Host resource kind');
}
return {
...result,
resources: result.resources.map((resource): HostResourceEnvelope => {
if (resource.status === 'unavailable') return resource;
const payload = decodeJsonObject(resource.payload);
const schema =
resource.schemaVersion === 1
? KNOWN_PAYLOAD_SCHEMAS[resource.kind as HostResourceKind]
: undefined;
return {
...resource,
payload: schema
? (parse(schema, payload, `${resource.kind} resource`) as HostResourceJsonObject)
: payload,
};
}),
};
}
function decodeJsonObject(value: unknown): HostResourceJsonObject {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw invalidProtocolFrame('Invalid Runtime Host resource payload');
}
return decodeJsonValue(value, 0) as HostResourceJsonObject;
}
function decodeJsonValue(value: unknown, depth: number): HostResourceJsonValue {
if (depth > 8) throw invalidProtocolFrame('Runtime Host resource payload is too deeply nested');
if (value === null || typeof value === 'boolean' || typeof value === 'string') return value;
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (Array.isArray(value)) {
if (value.length > 256) throw invalidProtocolFrame('Invalid Runtime Host resource payload');
return value.map((child) => decodeJsonValue(child, depth + 1));
}
if (!value || typeof value !== 'object') {
throw invalidProtocolFrame('Invalid Runtime Host resource payload');
}
const entries = Object.entries(value);
if (entries.length > 128) throw invalidProtocolFrame('Invalid Runtime Host resource payload');
return Object.fromEntries(
entries.map(([key, child]) => {
if (!key || key.length > 128) {
throw invalidProtocolFrame('Invalid Runtime Host resource payload');
}
return [key, decodeJsonValue(child, depth + 1)];
}),
);
}
function parse<T>(schema: z.ZodType<T>, value: unknown, label: string): T {
const result = schema.safeParse(value);
if (!result.success) throw invalidProtocolFrame(`Invalid ${label}`);
return result.data;
}
function percentage(numerator: number, denominator: number): number {
if (denominator <= 0) return 0;
return Math.max(0, Math.min(100, (numerator / denominator) * 100));
}
function assignRate(
target: HostResourceUtilization,
key: 'receivedBytesPerSecond' | 'transmittedBytesPerSecond',
delta: number,
elapsedSeconds: number,
): void {
if (delta >= 0) Object.assign(target, { [key]: delta / elapsedSeconds });
}