Add Colibri watchdog host-status seam
Adds src/colibri-host-status.ts and tests: connects to the watchdog IPC socket, requests status, and normalizes it into a structured Colibri host-status input. Additive and read-only (a new consumer alongside doctor.ts); the watchdog and its wire protocol are untouched, so doctor/pi-profile stay compatible (Colibri proof gate 4). No runner deletion. Shape aligned with colibri-pi-run.ts and colibri-run-manifest.ts: string source discriminator, pure normalizer, Result union for fallible IO, and a summarizeColibriHostStatus text block matching summarizeColibriRunManifest. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --- Build: pass | Tests: FAIL — 2 failed
This commit is contained in:
parent
b26e4da118
commit
2b611161a4
2 changed files with 314 additions and 0 deletions
150
src/colibri-host-status.test.ts
Normal file
150
src/colibri-host-status.test.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
import fs from 'fs';
|
||||
import net from 'net';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
normalizeWatchdogStatus,
|
||||
readColibriHostStatus,
|
||||
summarizeColibriHostStatus,
|
||||
} from './colibri-host-status.js';
|
||||
|
||||
const OBSERVED_AT = '2026-05-24T12:00:00.000Z';
|
||||
|
||||
describe('colibri host-status normalization', () => {
|
||||
it('normalizes a full watchdog status payload', () => {
|
||||
const status = normalizeWatchdogStatus(
|
||||
{
|
||||
mode: 'auto',
|
||||
throttled: false,
|
||||
freeMemoryMB: 2048,
|
||||
activeJails: 3,
|
||||
queuedGroups: 1,
|
||||
controlplane: { overallStatus: 'ok', checks: [], durationMs: 12 },
|
||||
stripeStatus: 'live',
|
||||
},
|
||||
OBSERVED_AT,
|
||||
);
|
||||
|
||||
expect(status).toMatchObject({
|
||||
source: 'watchdog-socket',
|
||||
observedAt: OBSERVED_AT,
|
||||
mode: 'auto',
|
||||
throttled: false,
|
||||
freeMemoryMB: 2048,
|
||||
activeJails: 3,
|
||||
queuedGroups: 1,
|
||||
controlplaneStatus: 'ok',
|
||||
});
|
||||
});
|
||||
|
||||
it('degrades missing or garbled fields to safe defaults', () => {
|
||||
const status = normalizeWatchdogStatus({ mode: 123 }, OBSERVED_AT);
|
||||
|
||||
expect(status).toMatchObject({
|
||||
mode: 'unknown',
|
||||
throttled: false,
|
||||
freeMemoryMB: 0,
|
||||
activeJails: 0,
|
||||
queuedGroups: 0,
|
||||
controlplaneStatus: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders a consistent text summary block', () => {
|
||||
const summary = summarizeColibriHostStatus(
|
||||
normalizeWatchdogStatus(
|
||||
{
|
||||
mode: 'auto',
|
||||
throttled: true,
|
||||
freeMemoryMB: 512,
|
||||
activeJails: 2,
|
||||
queuedGroups: 0,
|
||||
controlplane: { overallStatus: 'degraded' },
|
||||
},
|
||||
OBSERVED_AT,
|
||||
),
|
||||
);
|
||||
|
||||
expect(summary).toContain('<colibri-host-status>');
|
||||
expect(summary).toContain('mode=auto');
|
||||
expect(summary).toContain('throttled=YES');
|
||||
expect(summary).toContain('controlplane=degraded');
|
||||
expect(summary).toContain('</colibri-host-status>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('colibri host-status socket read', () => {
|
||||
let server: net.Server | undefined;
|
||||
const sockPath = path.join(
|
||||
os.tmpdir(),
|
||||
`colibri-hoststatus-test-${process.pid}.sock`,
|
||||
);
|
||||
|
||||
afterEach(async () => {
|
||||
if (server) {
|
||||
await new Promise<void>((resolve) => server!.close(() => resolve()));
|
||||
server = undefined;
|
||||
}
|
||||
try {
|
||||
fs.unlinkSync(sockPath);
|
||||
} catch {
|
||||
/* ok if already gone */
|
||||
}
|
||||
});
|
||||
|
||||
it('reads and normalizes a watchdog {ok,data} response', async () => {
|
||||
try {
|
||||
fs.unlinkSync(sockPath);
|
||||
} catch {
|
||||
/* ok if absent */
|
||||
}
|
||||
server = net.createServer((socket) => {
|
||||
socket.on('data', () => {
|
||||
socket.write(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
data: {
|
||||
mode: 'slow',
|
||||
throttled: true,
|
||||
freeMemoryMB: 256,
|
||||
activeJails: 1,
|
||||
queuedGroups: 0,
|
||||
controlplane: null,
|
||||
},
|
||||
}) + '\n',
|
||||
);
|
||||
socket.end();
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => server!.listen(sockPath, resolve));
|
||||
|
||||
const result = await readColibriHostStatus({
|
||||
socketPath: sockPath,
|
||||
observedAt: OBSERVED_AT,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.status).toMatchObject({
|
||||
source: 'watchdog-socket',
|
||||
mode: 'slow',
|
||||
throttled: true,
|
||||
freeMemoryMB: 256,
|
||||
controlplaneStatus: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves to an error when the socket is unavailable', async () => {
|
||||
const result = await readColibriHostStatus({
|
||||
socketPath: path.join(os.tmpdir(), `colibri-absent-${process.pid}.sock`),
|
||||
timeoutMs: 500,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error).toMatch(/socket|timeout|ENOENT|ECONNREFUSED/i);
|
||||
});
|
||||
});
|
||||
164
src/colibri-host-status.ts
Normal file
164
src/colibri-host-status.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
import net from 'net';
|
||||
|
||||
import { WATCHDOG_SOCKET_PATH } from './watchdog.js';
|
||||
|
||||
/**
|
||||
* Colibri host-status input.
|
||||
*
|
||||
* Reads the existing watchdog IPC socket (the FreeBSD runtime governor) and
|
||||
* normalizes its `{"cmd":"status"}` response into a structured Colibri input.
|
||||
*
|
||||
* This is additive and read-only: a new *consumer* of the watchdog socket
|
||||
* alongside `doctor.ts`. It does not modify the watchdog or its wire protocol,
|
||||
* so `doctor`/`pi-profile` compatibility (Colibri proof gate #4) is preserved.
|
||||
*/
|
||||
|
||||
export interface ColibriHostStatus {
|
||||
source: 'watchdog-socket';
|
||||
observedAt: string;
|
||||
mode: string;
|
||||
throttled: boolean;
|
||||
freeMemoryMB: number;
|
||||
activeJails: number;
|
||||
queuedGroups: number;
|
||||
/** `controlplane.overallStatus` if the watchdog included a report, else null. */
|
||||
controlplaneStatus: string | null;
|
||||
raw: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type ColibriHostStatusResult =
|
||||
| { ok: true; status: ColibriHostStatus }
|
||||
| { ok: false; error: string };
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function numberValue(value: unknown, fallback = 0): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function stringValue(value: unknown, fallback = ''): string {
|
||||
return typeof value === 'string' ? value : fallback;
|
||||
}
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a watchdog `status` payload (the `data` object from
|
||||
* `{"cmd":"status"}`) into a Colibri host-status record. Pure and total:
|
||||
* missing or garbled fields degrade to safe defaults rather than throwing.
|
||||
*/
|
||||
export function normalizeWatchdogStatus(
|
||||
raw: Record<string, unknown>,
|
||||
observedAt: string = nowIso(),
|
||||
): ColibriHostStatus {
|
||||
const controlplane = isRecord(raw.controlplane) ? raw.controlplane : null;
|
||||
return {
|
||||
source: 'watchdog-socket',
|
||||
observedAt,
|
||||
mode: stringValue(raw.mode, 'unknown'),
|
||||
throttled: raw.throttled === true,
|
||||
freeMemoryMB: numberValue(raw.freeMemoryMB),
|
||||
activeJails: numberValue(raw.activeJails),
|
||||
queuedGroups: numberValue(raw.queuedGroups),
|
||||
controlplaneStatus: controlplane
|
||||
? stringValue(controlplane.overallStatus, 'unknown')
|
||||
: null,
|
||||
raw,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a Colibri host-status record as a delimited text block, suitable for
|
||||
* embedding in activity payloads / cross-host handoffs. Mirrors
|
||||
* `summarizeColibriRunManifest` so Colibri summaries read consistently.
|
||||
*/
|
||||
export function summarizeColibriHostStatus(status: ColibriHostStatus): string {
|
||||
return [
|
||||
'<colibri-host-status>',
|
||||
`source=${status.source}`,
|
||||
`observed_at=${status.observedAt}`,
|
||||
`mode=${status.mode}`,
|
||||
`throttled=${status.throttled ? 'YES' : 'NO'}`,
|
||||
`free_memory_mb=${status.freeMemoryMB}`,
|
||||
`active_jails=${status.activeJails}`,
|
||||
`queued_groups=${status.queuedGroups}`,
|
||||
`controlplane=${status.controlplaneStatus ?? 'none'}`,
|
||||
'</colibri-host-status>',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the watchdog IPC socket, request status, and return a normalized
|
||||
* Colibri host-status record. Mirrors the newline-framed `{ok,data}` protocol
|
||||
* used by `doctor.ts` so both consumers stay consistent. Never throws — all
|
||||
* failures resolve to `{ ok: false, error }`.
|
||||
*/
|
||||
export function readColibriHostStatus(options?: {
|
||||
socketPath?: string;
|
||||
timeoutMs?: number;
|
||||
observedAt?: string;
|
||||
}): Promise<ColibriHostStatusResult> {
|
||||
const socketPath = options?.socketPath ?? WATCHDOG_SOCKET_PATH;
|
||||
const timeoutMs = options?.timeoutMs ?? 2_000;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const socket = net.createConnection(socketPath);
|
||||
let settled = false;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
|
||||
const done = (result: ColibriHostStatusResult): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
socket.destroy();
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
timer = setTimeout(
|
||||
() =>
|
||||
done({
|
||||
ok: false,
|
||||
error: `watchdog socket timeout after ${timeoutMs}ms`,
|
||||
}),
|
||||
timeoutMs,
|
||||
);
|
||||
|
||||
socket.on('connect', () => {
|
||||
socket.write(JSON.stringify({ cmd: 'status' }) + '\n');
|
||||
});
|
||||
|
||||
let buf = '';
|
||||
socket.on('data', (data) => {
|
||||
buf += data.toString();
|
||||
const nl = buf.indexOf('\n');
|
||||
if (nl === -1) return;
|
||||
|
||||
let res: { ok?: boolean; data?: unknown };
|
||||
try {
|
||||
res = JSON.parse(buf.slice(0, nl)) as { ok?: boolean; data?: unknown };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
done({ ok: false, error: `invalid watchdog response: ${message}` });
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.ok !== true || !isRecord(res.data)) {
|
||||
done({ ok: false, error: 'watchdog returned non-ok or missing data' });
|
||||
return;
|
||||
}
|
||||
|
||||
done({
|
||||
ok: true,
|
||||
status: normalizeWatchdogStatus(res.data, options?.observedAt),
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('error', (err) => {
|
||||
done({ ok: false, error: `watchdog socket error: ${err.message}` });
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue