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
150 lines
3.8 KiB
TypeScript
150 lines
3.8 KiB
TypeScript
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);
|
|
});
|
|
});
|