@stage-labs/metro 0.1.0-beta.223 → 0.1.0-beta.225
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +2 -2
- package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +1 -2
- package/runtime/node_modules/@metro-labs/daemon/src/server/cloudwatch.ts +178 -0
- package/runtime/node_modules/@metro-labs/daemon/src/server/machine.ts +2 -7
- package/runtime/node_modules/@metro-labs/http/package.json +3 -2
- package/runtime/node_modules/@metro-labs/http/src/sigv4.ts +90 -0
- package/runtime/runtime.json +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/server/resources.ts +0 -160
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.225",
|
|
4
4
|
"description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, and the standing rules loaded at each session start. No vendor credential ever sits in a config file.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Bonustrack",
|
|
@@ -50,7 +50,7 @@ import { materializeFrom, reloadFrom } from '../stations/materialize.js';
|
|
|
50
50
|
import type { StationName } from '@metro-labs/core/station-names';
|
|
51
51
|
import { startUploadReaper } from '../files/upload-store.js';
|
|
52
52
|
import { startAttachReaper } from '../files/attach-reaper.js';
|
|
53
|
-
import {
|
|
53
|
+
import { startCloudWatchPublisher } from '../server/cloudwatch.js';
|
|
54
54
|
|
|
55
55
|
installCrashGuard();
|
|
56
56
|
acquireLock(join(STATE_DIR, '.tail-lock'));
|
|
@@ -143,7 +143,7 @@ installBearerSessions(agentsDir(), localOwner);
|
|
|
143
143
|
syncPluginServers(readLocalConnectors());
|
|
144
144
|
startUploadReaper();
|
|
145
145
|
startAttachReaper();
|
|
146
|
-
|
|
146
|
+
startCloudWatchPublisher();
|
|
147
147
|
announceLocalEndpoint();
|
|
148
148
|
tunnel?.start();
|
|
149
149
|
log.info(
|
|
@@ -44,7 +44,6 @@ import {
|
|
|
44
44
|
readLocalAgentFile,
|
|
45
45
|
} from '../agents/file-admin.js';
|
|
46
46
|
import { readModelConfig } from '../gateway/model-config.js';
|
|
47
|
-
import { resourcesFor } from '../server/resources.js';
|
|
48
47
|
import type { StationName } from '@metro-labs/core/station-names';
|
|
49
48
|
|
|
50
49
|
export interface LocalModeDeps {
|
|
@@ -198,7 +197,7 @@ export function localSessionApis(deps: LocalModeDeps): SessionApis {
|
|
|
198
197
|
updateApi: { restart: deps.restart },
|
|
199
198
|
controlApi: { restart: deps.restart, stop: deps.stop },
|
|
200
199
|
ownerApi: { setOwner: (owner) => setLocalOwner(owner) },
|
|
201
|
-
machineApi: {
|
|
200
|
+
machineApi: {},
|
|
202
201
|
modelApi: {},
|
|
203
202
|
gateway: { config: readModelConfig },
|
|
204
203
|
terminalApi: {},
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { readFile, statfs } from 'node:fs/promises';
|
|
2
|
+
import { uptime } from 'node:os';
|
|
3
|
+
import { errMsg, log } from '@metro-labs/core/log';
|
|
4
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
5
|
+
import { signV4 } from '@metro-labs/http/sigv4';
|
|
6
|
+
|
|
7
|
+
const IMDS = 'http://169.254.169.254/latest';
|
|
8
|
+
const EVERY_MS = 60_000;
|
|
9
|
+
const FIRST_MS = 15_000;
|
|
10
|
+
const IMDS_TIMEOUT_MS = 2_000;
|
|
11
|
+
const SEND_TIMEOUT_MS = 15_000;
|
|
12
|
+
const NAMESPACE = 'CWAgent';
|
|
13
|
+
const CONTENT_TYPE = 'application/x-www-form-urlencoded; charset=utf-8';
|
|
14
|
+
const BOOT_WINDOW_S = 300;
|
|
15
|
+
const SENT = 'sending memory and disk to CloudWatch every minute';
|
|
16
|
+
|
|
17
|
+
export interface Readings {
|
|
18
|
+
memory: number;
|
|
19
|
+
disk: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface Started {
|
|
23
|
+
name: 'server_booted' | 'metro_started';
|
|
24
|
+
at: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface RoleKeys {
|
|
28
|
+
accessKeyId: string;
|
|
29
|
+
secretAccessKey: string;
|
|
30
|
+
sessionToken: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface Instance {
|
|
34
|
+
instanceId: string;
|
|
35
|
+
region: string;
|
|
36
|
+
keys: RoleKeys | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface PublisherDeps {
|
|
40
|
+
fetch: (url: string, init: RequestInit) => Promise<Response>;
|
|
41
|
+
readings: () => Promise<Readings | null>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function memoryPercent(meminfo: string): number | null {
|
|
45
|
+
const kb = (name: string): number | null => {
|
|
46
|
+
const match = new RegExp(`^${name}:\\s+(\\d+) kB`, 'm').exec(meminfo);
|
|
47
|
+
return match === null ? null : Number(match[1]);
|
|
48
|
+
};
|
|
49
|
+
const total = kb('MemTotal');
|
|
50
|
+
const available = kb('MemAvailable');
|
|
51
|
+
if (total === null || available === null || total <= 0) return null;
|
|
52
|
+
return ((total - available) / total) * 100;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function diskPercent(fs: { blocks: number; bfree: number; bavail: number }): number | null {
|
|
56
|
+
const used = fs.blocks - fs.bfree;
|
|
57
|
+
const room = used + fs.bavail;
|
|
58
|
+
return room > 0 ? (used / room) * 100 : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function liveReadings(): Promise<Readings | null> {
|
|
62
|
+
const memory = memoryPercent(await readFile('/proc/meminfo', 'utf8'));
|
|
63
|
+
const disk = diskPercent(await statfs('/'));
|
|
64
|
+
return memory === null || disk === null ? null : { memory, disk };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const LIVE: PublisherDeps = { fetch: (url, init) => fetch(url, init), readings: liveReadings };
|
|
68
|
+
|
|
69
|
+
async function imdsGet(deps: PublisherDeps, token: string, path: string): Promise<string> {
|
|
70
|
+
const res = await deps.fetch(`${IMDS}/${path}`, { headers: { 'x-aws-ec2-metadata-token': token }, signal: AbortSignal.timeout(IMDS_TIMEOUT_MS) });
|
|
71
|
+
return res.ok ? (await res.text()).trim() : '';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function imdsToken(deps: PublisherDeps): Promise<string> {
|
|
75
|
+
try {
|
|
76
|
+
const res = await deps.fetch(`${IMDS}/api/token`, {
|
|
77
|
+
method: 'PUT',
|
|
78
|
+
headers: { 'x-aws-ec2-metadata-token-ttl-seconds': '300' },
|
|
79
|
+
signal: AbortSignal.timeout(IMDS_TIMEOUT_MS),
|
|
80
|
+
});
|
|
81
|
+
return res.ok ? (await res.text()).trim() : '';
|
|
82
|
+
} catch {
|
|
83
|
+
return '';
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function keysOf(raw: string): RoleKeys | null {
|
|
88
|
+
const parsed: unknown = JSON.parse(raw);
|
|
89
|
+
if (!isRecord(parsed)) return null;
|
|
90
|
+
const { AccessKeyId: accessKeyId, SecretAccessKey: secretAccessKey, Token: sessionToken } = parsed;
|
|
91
|
+
if (typeof accessKeyId !== 'string' || typeof secretAccessKey !== 'string' || typeof sessionToken !== 'string') return null;
|
|
92
|
+
return { accessKeyId, secretAccessKey, sessionToken };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function thisInstance(deps: PublisherDeps): Promise<Instance | null> {
|
|
96
|
+
const token = await imdsToken(deps);
|
|
97
|
+
if (token === '') return null;
|
|
98
|
+
const instanceId = await imdsGet(deps, token, 'meta-data/instance-id');
|
|
99
|
+
const region = await imdsGet(deps, token, 'meta-data/placement/region');
|
|
100
|
+
if (instanceId === '' || region === '') return null;
|
|
101
|
+
const role = (await imdsGet(deps, token, 'meta-data/iam/security-credentials/')).split('\n')[0] ?? '';
|
|
102
|
+
if (role === '') return { instanceId, region, keys: null };
|
|
103
|
+
return { instanceId, region, keys: keysOf(await imdsGet(deps, token, `meta-data/iam/security-credentials/${role}`)) };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function startedOf(nowMs: number, processSeconds: number, bootSeconds: number): Started {
|
|
107
|
+
const at = (secondsAgo: number): number => Math.round(nowMs / 1000 - secondsAgo);
|
|
108
|
+
return bootSeconds < BOOT_WINDOW_S ? { name: 'server_booted', at: at(bootSeconds) } : { name: 'metro_started', at: at(processSeconds) };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function readingParams(instanceId: string, readings: Readings, started: Started | null = null): Record<string, string> {
|
|
112
|
+
const datum = (at: number, name: string, value: number, dims: [string, string][], unit = 'Percent'): [string, string][] => [
|
|
113
|
+
[`MetricData.member.${String(at)}.MetricName`, name],
|
|
114
|
+
[`MetricData.member.${String(at)}.Unit`, unit],
|
|
115
|
+
[`MetricData.member.${String(at)}.Value`, value.toFixed(2)],
|
|
116
|
+
...dims.flatMap(([key, v], i): [string, string][] => [
|
|
117
|
+
[`MetricData.member.${String(at)}.Dimensions.member.${String(i + 1)}.Name`, key],
|
|
118
|
+
[`MetricData.member.${String(at)}.Dimensions.member.${String(i + 1)}.Value`, v],
|
|
119
|
+
]),
|
|
120
|
+
];
|
|
121
|
+
return Object.fromEntries([
|
|
122
|
+
['Action', 'PutMetricData'],
|
|
123
|
+
['Version', '2010-08-01'],
|
|
124
|
+
['Namespace', NAMESPACE],
|
|
125
|
+
...datum(1, 'mem_used_percent', readings.memory, [['InstanceId', instanceId]]),
|
|
126
|
+
...datum(2, 'disk_used_percent', readings.disk, [
|
|
127
|
+
['InstanceId', instanceId],
|
|
128
|
+
['path', '/'],
|
|
129
|
+
]),
|
|
130
|
+
...(started === null ? [] : datum(3, started.name, started.at, [['InstanceId', instanceId]], 'Seconds')),
|
|
131
|
+
]);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function send(deps: PublisherDeps, instance: Instance, keys: RoleKeys, readings: Readings, started: Started | null): Promise<void> {
|
|
135
|
+
const url = `https://monitoring.${instance.region}.amazonaws.com/`;
|
|
136
|
+
const body = new URLSearchParams(readingParams(instance.instanceId, readings, started)).toString();
|
|
137
|
+
const signed = await signV4({ method: 'POST', url, headers: { 'content-type': CONTENT_TYPE }, body, region: instance.region, service: 'monitoring', ...keys });
|
|
138
|
+
const res = await deps.fetch(url, { method: 'POST', headers: signed.headers, body, signal: AbortSignal.timeout(SEND_TIMEOUT_MS) });
|
|
139
|
+
if (res.ok) return;
|
|
140
|
+
const text = await res.text();
|
|
141
|
+
const code = /<Code>([^<]+)<\/Code>/.exec(text)?.[1] ?? `HTTP ${String(res.status)}`;
|
|
142
|
+
const message = /<Message>([^<]+)<\/Message>/.exec(text)?.[1];
|
|
143
|
+
throw new Error(`CloudWatch refused the readings: ${code}${message === undefined ? '' : ` (${message})`}`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function publishOnce(deps: PublisherDeps = LIVE, started: Started | null = null): Promise<string> {
|
|
147
|
+
const instance = await thisInstance(deps);
|
|
148
|
+
if (instance === null) return 'off: this server is not an EC2 instance';
|
|
149
|
+
if (instance.keys === null) return `waiting: instance ${instance.instanceId} has no IAM role, so memory and disk are not sent`;
|
|
150
|
+
const readings = await deps.readings();
|
|
151
|
+
if (readings === null) return 'skipped: could not read memory or disk';
|
|
152
|
+
await send(deps, instance, instance.keys, readings, started);
|
|
153
|
+
return SENT;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function startCloudWatchPublisher(): void {
|
|
157
|
+
if (process.platform !== 'linux') return;
|
|
158
|
+
let started: Started | null = startedOf(Date.now(), process.uptime(), uptime());
|
|
159
|
+
let last = '';
|
|
160
|
+
const note = (status: string, warn: boolean): void => {
|
|
161
|
+
if (status === last) return;
|
|
162
|
+
last = status;
|
|
163
|
+
if (warn) log.warn({ status }, 'cloudwatch: could not send memory and disk');
|
|
164
|
+
else log.info({ status }, 'cloudwatch');
|
|
165
|
+
};
|
|
166
|
+
const run = (): void => {
|
|
167
|
+
publishOnce(LIVE, started)
|
|
168
|
+
.then((status) => {
|
|
169
|
+
if (status === SENT) started = null;
|
|
170
|
+
note(status, false);
|
|
171
|
+
})
|
|
172
|
+
.catch((err: unknown) => {
|
|
173
|
+
note(errMsg(err), true);
|
|
174
|
+
});
|
|
175
|
+
};
|
|
176
|
+
setTimeout(run, FIRST_MS).unref?.();
|
|
177
|
+
setInterval(run, EVERY_MS).unref?.();
|
|
178
|
+
}
|
|
@@ -13,7 +13,6 @@ const PATH = '/api/server';
|
|
|
13
13
|
|
|
14
14
|
export interface MachineApiDeps {
|
|
15
15
|
startedAt?: string;
|
|
16
|
-
resources?: (range: string) => unknown;
|
|
17
16
|
}
|
|
18
17
|
|
|
19
18
|
const bootedAt = new Date(Date.now() - process.uptime() * 1000).toISOString();
|
|
@@ -24,7 +23,7 @@ interface Disk {
|
|
|
24
23
|
freeBytes: number;
|
|
25
24
|
}
|
|
26
25
|
|
|
27
|
-
|
|
26
|
+
async function diskInfo(path = homedir()): Promise<Disk | null> {
|
|
28
27
|
try {
|
|
29
28
|
const s = await statfs(path);
|
|
30
29
|
return { path, totalBytes: s.bsize * s.blocks, freeBytes: s.bsize * s.bavail };
|
|
@@ -55,9 +54,5 @@ export async function machineInfo(startedAt = bootedAt): Promise<Record<string,
|
|
|
55
54
|
}
|
|
56
55
|
|
|
57
56
|
export function handleMachineRequest(req: IncomingMessage, res: ServerResponse, deps: MachineApiDeps): boolean {
|
|
58
|
-
return sessionRoute(req, res, { methods: { [PATH]: ['GET'] }, admin: false, label: 'machine-api' },
|
|
59
|
-
const info = await machineInfo(deps.startedAt);
|
|
60
|
-
const range = new URL(req.url ?? '', 'http://localhost').searchParams.get('range');
|
|
61
|
-
return range === null || deps.resources === undefined ? info : { ...info, resources: deps.resources(range) };
|
|
62
|
-
});
|
|
57
|
+
return sessionRoute(req, res, { methods: { [PATH]: ['GET'] }, admin: false, label: 'machine-api' }, () => machineInfo(deps.startedAt));
|
|
63
58
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@metro-labs/http",
|
|
3
3
|
"version": "0.1.0",
|
|
4
4
|
"private": true,
|
|
5
|
-
"description": "How a metro server authenticates and answers a request: CORS, the JSON helpers, ApiError, the signed-identity header
|
|
5
|
+
"description": "How a metro server authenticates and answers a request: CORS, the JSON helpers, ApiError, the signed-identity header, the identity registry and AWS request signing. Shared by the daemon and api.metro.box, never by a station.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"exports": {
|
|
8
8
|
"./cors": "./src/cors.ts",
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
"./api-error": "./src/api-error.ts",
|
|
11
11
|
"./return-to": "./src/return-to.ts",
|
|
12
12
|
"./mode-api": "./src/mode-api.ts",
|
|
13
|
-
"./workos-token": "./src/workos-token.ts"
|
|
13
|
+
"./workos-token": "./src/workos-token.ts",
|
|
14
|
+
"./sigv4": "./src/sigv4.ts"
|
|
14
15
|
},
|
|
15
16
|
"scripts": {
|
|
16
17
|
"build": "tsc",
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
const enc = new TextEncoder();
|
|
2
|
+
|
|
3
|
+
const hex = (bytes: Uint8Array): string =>
|
|
4
|
+
[...bytes].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
5
|
+
|
|
6
|
+
export async function sha256Hex(data: string | Uint8Array<ArrayBuffer>): Promise<string> {
|
|
7
|
+
const bytes = typeof data === 'string' ? enc.encode(data) : data;
|
|
8
|
+
return hex(new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async function hmac(key: Uint8Array<ArrayBuffer>, data: string): Promise<Uint8Array<ArrayBuffer>> {
|
|
12
|
+
const cryptoKey = await crypto.subtle.importKey('raw', key, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
|
13
|
+
return new Uint8Array(await crypto.subtle.sign('HMAC', cryptoKey, enc.encode(data)));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const encodeRfc3986 = (value: string): string =>
|
|
17
|
+
encodeURIComponent(value).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
18
|
+
|
|
19
|
+
const byPair = (a: [string, string], b: [string, string]): number =>
|
|
20
|
+
a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0;
|
|
21
|
+
|
|
22
|
+
function canonicalQuery(url: URL): string {
|
|
23
|
+
const pairs: [string, string][] = [];
|
|
24
|
+
url.searchParams.forEach((value, key) => {
|
|
25
|
+
pairs.push([encodeRfc3986(key), encodeRfc3986(value)]);
|
|
26
|
+
});
|
|
27
|
+
return pairs.sort(byPair).map(([k, v]) => `${k}=${v}`).join('&');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const amzDate = (date: Date): string =>
|
|
31
|
+
date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
|
|
32
|
+
|
|
33
|
+
export interface SignInput {
|
|
34
|
+
method: string;
|
|
35
|
+
url: string;
|
|
36
|
+
headers: Record<string, string>;
|
|
37
|
+
body: string;
|
|
38
|
+
region: string;
|
|
39
|
+
service: string;
|
|
40
|
+
accessKeyId: string;
|
|
41
|
+
secretAccessKey: string;
|
|
42
|
+
sessionToken?: string;
|
|
43
|
+
date?: Date;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface Signed {
|
|
47
|
+
headers: Record<string, string>;
|
|
48
|
+
canonicalRequest: string;
|
|
49
|
+
stringToSign: string;
|
|
50
|
+
signature: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function canonicalHeaders(input: SignInput, url: URL, date: string): Record<string, string> {
|
|
54
|
+
const out: Record<string, string> = {};
|
|
55
|
+
for (const [key, value] of Object.entries(input.headers)) out[key.toLowerCase()] = value.trim().replace(/\s+/g, ' ');
|
|
56
|
+
out.host = url.host;
|
|
57
|
+
out['x-amz-date'] = date;
|
|
58
|
+
if (input.sessionToken !== undefined) out['x-amz-security-token'] = input.sessionToken;
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function signingKey(input: SignInput, day: string): Promise<Uint8Array<ArrayBuffer>> {
|
|
63
|
+
const kDate = await hmac(enc.encode(`AWS4${input.secretAccessKey}`), day);
|
|
64
|
+
const kRegion = await hmac(kDate, input.region);
|
|
65
|
+
const kService = await hmac(kRegion, input.service);
|
|
66
|
+
return hmac(kService, 'aws4_request');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function signV4(input: SignInput): Promise<Signed> {
|
|
70
|
+
const url = new URL(input.url);
|
|
71
|
+
const date = amzDate(input.date ?? new Date());
|
|
72
|
+
const day = date.slice(0, 8);
|
|
73
|
+
const headers = canonicalHeaders(input, url, date);
|
|
74
|
+
const names = Object.keys(headers).sort();
|
|
75
|
+
const signedHeaders = names.join(';');
|
|
76
|
+
const canonicalRequest = [
|
|
77
|
+
input.method.toUpperCase(),
|
|
78
|
+
url.pathname === '' ? '/' : url.pathname,
|
|
79
|
+
canonicalQuery(url),
|
|
80
|
+
names.map((name) => `${name}:${headers[name] ?? ''}\n`).join(''),
|
|
81
|
+
signedHeaders,
|
|
82
|
+
await sha256Hex(input.body),
|
|
83
|
+
].join('\n');
|
|
84
|
+
const scope = `${day}/${input.region}/${input.service}/aws4_request`;
|
|
85
|
+
const stringToSign = ['AWS4-HMAC-SHA256', date, scope, await sha256Hex(canonicalRequest)].join('\n');
|
|
86
|
+
const signature = hex(await hmac(await signingKey(input, day), stringToSign));
|
|
87
|
+
const authorization = `AWS4-HMAC-SHA256 Credential=${input.accessKeyId}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
|
|
88
|
+
const sendable = Object.fromEntries(Object.entries(headers).filter(([name]) => name !== 'host'));
|
|
89
|
+
return { headers: { ...sendable, authorization }, canonicalRequest, stringToSign, signature };
|
|
90
|
+
}
|
package/runtime/runtime.json
CHANGED
|
@@ -1,160 +0,0 @@
|
|
|
1
|
-
import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
-
import { cpus, freemem, homedir, totalmem } from 'node:os';
|
|
3
|
-
import { dirname, join } from 'node:path';
|
|
4
|
-
import { errMsg, log } from '@metro-labs/core/log';
|
|
5
|
-
import { ApiError } from '@metro-labs/http/api-error';
|
|
6
|
-
import { diskInfo } from './machine.js';
|
|
7
|
-
|
|
8
|
-
const SAMPLE_MS = 60_000;
|
|
9
|
-
const HOUR_MS = 3_600_000;
|
|
10
|
-
const KEEP_MS = 7 * 24 * HOUR_MS;
|
|
11
|
-
const POINTS = 240;
|
|
12
|
-
const COMPACT_EVERY = 1_440;
|
|
13
|
-
const RANGES: Readonly<Record<string, number>> = { '1h': HOUR_MS, '24h': 24 * HOUR_MS, '7d': KEEP_MS };
|
|
14
|
-
const FIELDS = ['at', 'cpu', 'memUsed', 'memTotal', 'diskUsed', 'diskTotal'] as const;
|
|
15
|
-
|
|
16
|
-
export type ResourceSample = Record<(typeof FIELDS)[number], number>;
|
|
17
|
-
|
|
18
|
-
interface CpuTicks {
|
|
19
|
-
busy: number;
|
|
20
|
-
total: number;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
const state: { samples: ResourceSample[]; appended: number } = { samples: [], appended: 0 };
|
|
24
|
-
|
|
25
|
-
const resourcesFile = (): string => join(homedir(), '.metro', 'resources.jsonl');
|
|
26
|
-
|
|
27
|
-
function cpuTicks(): CpuTicks {
|
|
28
|
-
let busy = 0;
|
|
29
|
-
let total = 0;
|
|
30
|
-
for (const { times } of cpus()) {
|
|
31
|
-
const sum = times.user + times.nice + times.sys + times.idle + times.irq;
|
|
32
|
-
total += sum;
|
|
33
|
-
busy += sum - times.idle;
|
|
34
|
-
}
|
|
35
|
-
return { busy, total };
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export function cpuPercent(before: CpuTicks, after: CpuTicks): number {
|
|
39
|
-
const total = after.total - before.total;
|
|
40
|
-
if (total <= 0) return 0;
|
|
41
|
-
return Math.min(100, Math.max(0, ((after.busy - before.busy) / total) * 100));
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function toSample(value: unknown): ResourceSample | null {
|
|
45
|
-
if (typeof value !== 'object' || value === null) return null;
|
|
46
|
-
const out: Partial<ResourceSample> = {};
|
|
47
|
-
for (const field of FIELDS) {
|
|
48
|
-
const v: unknown = (value as Record<string, unknown>)[field];
|
|
49
|
-
if (typeof v !== 'number' || !Number.isFinite(v)) return null;
|
|
50
|
-
out[field] = v;
|
|
51
|
-
}
|
|
52
|
-
return out as ResourceSample;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function parseLine(line: string): ResourceSample | null {
|
|
56
|
-
if (line.trim() === '') return null;
|
|
57
|
-
try {
|
|
58
|
-
return toSample(JSON.parse(line));
|
|
59
|
-
} catch {
|
|
60
|
-
return null;
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
const toLine = (sample: ResourceSample): string => `${JSON.stringify(sample)}\n`;
|
|
65
|
-
|
|
66
|
-
export function readSamples(file = resourcesFile(), now = Date.now()): ResourceSample[] {
|
|
67
|
-
let text: string;
|
|
68
|
-
try {
|
|
69
|
-
text = readFileSync(file, 'utf8');
|
|
70
|
-
} catch {
|
|
71
|
-
return [];
|
|
72
|
-
}
|
|
73
|
-
return text.split('\n').flatMap((line) => parseLine(line) ?? []).filter((s) => s.at > now - KEEP_MS);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function rewrite(file: string): void {
|
|
77
|
-
mkdirSync(dirname(file), { recursive: true });
|
|
78
|
-
writeFileSync(file, state.samples.map(toLine).join(''));
|
|
79
|
-
state.appended = 0;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
export function recordSample(sample: ResourceSample, file = resourcesFile()): void {
|
|
83
|
-
state.samples.push(sample);
|
|
84
|
-
const first = state.samples.findIndex((s) => s.at > sample.at - KEEP_MS);
|
|
85
|
-
if (first > 0) state.samples.splice(0, first);
|
|
86
|
-
if (state.appended >= COMPACT_EVERY) {
|
|
87
|
-
rewrite(file);
|
|
88
|
-
return;
|
|
89
|
-
}
|
|
90
|
-
mkdirSync(dirname(file), { recursive: true });
|
|
91
|
-
appendFileSync(file, toLine(sample));
|
|
92
|
-
state.appended += 1;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
export function loadSamples(file = resourcesFile()): void {
|
|
96
|
-
state.samples = readSamples(file);
|
|
97
|
-
state.appended = 0;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
export async function takeSample(before: CpuTicks): Promise<{ sample: ResourceSample; ticks: CpuTicks }> {
|
|
101
|
-
const ticks = cpuTicks();
|
|
102
|
-
const disk = await diskInfo();
|
|
103
|
-
const memTotal = totalmem();
|
|
104
|
-
return {
|
|
105
|
-
ticks,
|
|
106
|
-
sample: {
|
|
107
|
-
at: Date.now(),
|
|
108
|
-
cpu: cpuPercent(before, ticks),
|
|
109
|
-
memUsed: memTotal - freemem(),
|
|
110
|
-
memTotal,
|
|
111
|
-
diskUsed: disk === null ? 0 : disk.totalBytes - disk.freeBytes,
|
|
112
|
-
diskTotal: disk?.totalBytes ?? 0,
|
|
113
|
-
},
|
|
114
|
-
};
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
export function startResourceSampler(): void {
|
|
118
|
-
try {
|
|
119
|
-
loadSamples();
|
|
120
|
-
rewrite(resourcesFile());
|
|
121
|
-
} catch (err) {
|
|
122
|
-
log.warn({ err: errMsg(err) }, 'resources: could not load the history');
|
|
123
|
-
}
|
|
124
|
-
let ticks = cpuTicks();
|
|
125
|
-
const run = (): void => {
|
|
126
|
-
takeSample(ticks)
|
|
127
|
-
.then((next) => {
|
|
128
|
-
ticks = next.ticks;
|
|
129
|
-
recordSample(next.sample);
|
|
130
|
-
})
|
|
131
|
-
.catch((err: unknown) => {
|
|
132
|
-
log.warn({ err: errMsg(err) }, 'resources: could not record a sample');
|
|
133
|
-
});
|
|
134
|
-
};
|
|
135
|
-
setInterval(run, SAMPLE_MS).unref?.();
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
function average(at: number, group: ResourceSample[]): ResourceSample {
|
|
139
|
-
const mean = (field: (typeof FIELDS)[number]): number => group.reduce((sum, s) => sum + s[field], 0) / group.length;
|
|
140
|
-
return { at, cpu: mean('cpu'), memUsed: mean('memUsed'), memTotal: mean('memTotal'), diskUsed: mean('diskUsed'), diskTotal: mean('diskTotal') };
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
export function resourceSeries(samples: ResourceSample[], rangeMs: number, now = Date.now()): { stepMs: number; samples: ResourceSample[] } {
|
|
144
|
-
const stepMs = Math.max(1, Math.ceil(rangeMs / POINTS / SAMPLE_MS)) * SAMPLE_MS;
|
|
145
|
-
const buckets = new Map<number, ResourceSample[]>();
|
|
146
|
-
for (const sample of samples) {
|
|
147
|
-
if (sample.at <= now - rangeMs || sample.at > now) continue;
|
|
148
|
-
const key = Math.floor(sample.at / stepMs) * stepMs;
|
|
149
|
-
const group = buckets.get(key);
|
|
150
|
-
if (group === undefined) buckets.set(key, [sample]);
|
|
151
|
-
else group.push(sample);
|
|
152
|
-
}
|
|
153
|
-
return { stepMs, samples: [...buckets].map(([at, group]) => average(at, group)) };
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
export function resourcesFor(range: string): { range: string; sampleMs: number; stepMs: number; samples: ResourceSample[] } {
|
|
157
|
-
const rangeMs = Object.hasOwn(RANGES, range) ? RANGES[range] : undefined;
|
|
158
|
-
if (rangeMs === undefined) throw new ApiError('range must be 1h, 24h or 7d', 400);
|
|
159
|
-
return { range, sampleMs: SAMPLE_MS, ...resourceSeries(state.samples, rangeMs) };
|
|
160
|
-
}
|