feat(infra): commit live infra-audit/cluster work to reconcile git with prod

This work (network_hosts inventory + infra_audit MCP tool, /api/cluster +
Sacred Valley cluster card, topbar cluster-health pill + SW self-heal) was
built in an earlier session and DEPLOYED to CT 311 as alpha.24–26, but was
never committed to git — prod was running code absent from the repo. Commits
it as-is (already prod-validated) so git matches the live state, and restores
its alpha.24/25/26 CHANGELOG entries. Files are disjoint from the fold-in
work; both now ship together under alpha.27.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
root
2026-06-08 15:20:38 +10:00
parent ae2ea09f0c
commit b0b23ba05d
19 changed files with 606 additions and 4 deletions

View File

@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { listActionsTool, proposeActionTool } from '../../../../lib/ai/agent/tools/blue/actions.js';
import { infraAuditTool } from '../../../../lib/ai/agent/tools/blue/infra_audit.js';
beforeEach(() => { process.env.VOID_API_URL = 'http://127.0.0.1:3000'; process.env.VOID_AGENT_TOKEN = 'blue-tok'; });
@@ -19,4 +20,13 @@ describe('blue action tools', () => {
expect(fetchMock.mock.calls[0][0]).toBe('http://127.0.0.1:3000/api/actions/stop-ct107/run');
expect(fetchMock.mock.calls[0][1].method).toBe('POST');
});
it('infra_audit GETs the read-only audit with the agent bearer', async () => {
const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ ok: false, docs: { summary: { unreachable: 1 } } }) }));
const out = await infraAuditTool.handler({}, {}, { fetchImpl: fetchMock });
expect(out.ok).toBe(false);
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toBe('http://127.0.0.1:3000/api/infra/audit');
expect(opts.headers.Authorization).toBe('Bearer blue-tok');
});
});

100
tests/infra/audit.test.js Normal file
View File

@@ -0,0 +1,100 @@
import { describe, it, expect } from 'vitest';
import { extractEndpoints, auditDocs, auditServices, parseUrl, runAudit } from '../../lib/infra/audit.js';
// Doc-drift sanity check: pull every LAN endpoint referenced in the wiki and
// confirm it's actually live. Catches stale IPs/ports (e.g. a CT that moved
// off 192.168.1.13 but is still documented there). Pure logic, injected probe.
describe('extractEndpoints', () => {
it('pulls host:port and host-only LAN refs, deduped', () => {
const eps = extractEndpoints('see http://192.168.1.27:8080 and 192.168.1.27:8080 plus 192.168.1.99 alone');
expect(eps).toContainEqual({ host: '192.168.1.27', port: 8080 });
expect(eps).toContainEqual({ host: '192.168.1.99', port: null });
// deduped: the repeated .27:8080 appears once
expect(eps.filter(e => e.host === '192.168.1.27' && e.port === 8080)).toHaveLength(1);
});
it('ignores non-LAN addresses and bare version-like numbers', () => {
const eps = extractEndpoints('cloudflare 49.185.140.110:8006 and version 7.0.2 and 10.0.0.1');
expect(eps).toHaveLength(0);
});
it('does not treat a CIDR mask as a port', () => {
const eps = extractEndpoints('192.168.1.230/24 is the host');
expect(eps).toContainEqual({ host: '192.168.1.230', port: null });
});
});
describe('auditDocs', () => {
const pages = [
{ title: 'Network map', body_md: 'magicmirror 192.168.1.13:8080' },
{ title: 'Overview', body_md: 'magicmirror 192.168.1.13:8080 and ollama 192.168.1.185:11434' },
{ title: 'Host', body_md: 'gramps 192.168.1.99 alone' }
];
it('flags doc-referenced endpoints that are unreachable, grouped by citing page', async () => {
const probe = async (host, port) => !(host === '192.168.1.13' && port === 8080); // .13:8080 is dead
const report = await auditDocs({ pages, probe });
expect(report.summary.probed).toBe(2); // .13:8080 and .185:11434 (host-only .99 not probed)
expect(report.summary.unreachable).toBe(1);
const dead = report.unreachable.find(u => u.host === '192.168.1.13' && u.port === 8080);
expect(dead).toBeTruthy();
expect(dead.pages.sort()).toEqual(['Network map', 'Overview']);
});
it('reports a clean bill when everything resolves', async () => {
const report = await auditDocs({ pages, probe: async () => true });
expect(report.summary.unreachable).toBe(0);
expect(report.unreachable).toEqual([]);
expect(report.ok).toBe(true);
});
it('lists host-only references separately as not-probed', async () => {
const report = await auditDocs({ pages, probe: async () => true });
expect(report.unprobed).toContainEqual(
expect.objectContaining({ host: '192.168.1.99', port: null })
);
});
});
describe('parseUrl', () => {
it('extracts host + explicit port', () => {
expect(parseUrl('http://192.168.1.225:8384')).toEqual({ host: '192.168.1.225', port: 8384 });
});
it('defaults port by scheme', () => {
expect(parseUrl('https://gramps.hynesy.com')).toEqual({ host: 'gramps.hynesy.com', port: 443 });
expect(parseUrl('http://192.168.1.99')).toEqual({ host: '192.168.1.99', port: 80 });
});
it('returns null for junk', () => { expect(parseUrl('not a url')).toBeNull(); });
});
describe('auditServices', () => {
const services = [
{ id: 'gitea', url: 'http://192.168.1.223:3000' },
{ id: 'magicmirror', url: 'http://192.168.1.27:8080' } // moved away — should be unreachable
];
it('flags services whose url does not answer', async () => {
const probe = async (host) => host !== '192.168.1.27';
const r = await auditServices({ services, probe });
expect(r.summary.probed).toBe(2);
expect(r.ok).toBe(false);
expect(r.unreachable).toEqual([
expect.objectContaining({ id: 'magicmirror', host: '192.168.1.27', port: 8080 })
]);
});
});
describe('runAudit', () => {
it('is ok only when both docs and services are clean', async () => {
const r = await runAudit({
pages: [{ title: 'P', body_md: '192.168.1.223:3000' }],
services: [{ id: 'gitea', url: 'http://192.168.1.223:3000' }],
probe: async () => true
});
expect(r.ok).toBe(true);
expect(r.docs.ok).toBe(true);
expect(r.services.ok).toBe(true);
});
});

View File

@@ -0,0 +1,63 @@
import { describe, it, expect } from 'vitest';
import { normalizeCluster, clusterHealth } from '../../lib/proxmox/cluster.js';
// Fixtures mirror the real PVE payload shapes from this cluster.
const STATUS = [
{ type: 'cluster', name: 'HZ-cluster', quorate: 1, nodes: 2 },
{ type: 'node', name: 'z', online: 1, local: 1, ip: '192.168.1.124' },
{ type: 'node', name: 'Z3', online: 1, local: 0, ip: '192.168.1.125' }
];
const HA = [
{ type: 'quorum', id: 'quorum', quorate: 1, status: 'OK' },
{ type: 'master', id: 'master', node: 'Z3', status: 'Z3 (active, ...)' },
{ type: 'fencing', id: 'fencing', 'armed-state': 'armed' },
{ type: 'lrm', id: 'lrm:z', node: 'z' },
{ type: 'service', id: 'service:ct:104', sid: 'ct:104', state: 'started', node: 'z' },
{ type: 'service', id: 'service:ct:111', sid: 'ct:111', state: 'error', node: 'z' }
];
describe('normalizeCluster', () => {
it('reports quorate, node online counts, master and HA service errors', () => {
const r = normalizeCluster(STATUS, HA);
expect(r.name).toBe('HZ-cluster');
expect(r.quorate).toBe(true);
expect(r.nodes_total).toBe(2);
expect(r.nodes_online).toBe(2);
expect(r.nodes.map(n => n.name).sort()).toEqual(['Z3', 'z']); // both nodes present
expect(r.ha.quorum_ok).toBe(true);
expect(r.ha.master).toBe('Z3');
expect(r.ha.fencing).toBe('armed');
expect(r.ha.services_total).toBe(2);
expect(r.ha.services_error).toBe(1); // the ct:111 'error'
});
it('flags loss of quorum and an offline node', () => {
const r = normalizeCluster(
[{ type: 'cluster', name: 'HZ-cluster', quorate: 0, nodes: 2 },
{ type: 'node', name: 'z', online: 0 }, { type: 'node', name: 'Z3', online: 1 }],
[{ type: 'quorum', quorate: 0, status: 'No quorum!' }]
);
expect(r.quorate).toBe(false);
expect(r.nodes_online).toBe(1);
expect(r.ha.quorum_ok).toBe(false);
});
});
describe('clusterHealth', () => {
it('returns proxmox_not_configured without a token', async () => {
const r = await clusterHealth({ apiUrl: '', token: '' });
expect(r.error).toBe('proxmox_not_configured');
});
it('fetches + normalizes via injected fetch', async () => {
const fetchImpl = async (url) => ({
ok: true,
json: async () => ({ data: url.includes('ha/status') ? HA : STATUS })
});
const r = await clusterHealth({ apiUrl: 'https://pve:8006', token: 'tok', fetchImpl });
expect(r.quorate).toBe(true);
expect(r.nodes_online).toBe(2);
expect(r.ha.master).toBe('Z3');
expect(typeof r.at).toBe('number');
});
});