Files
Void-Homelab/lib/proxmox/cluster.js
root b0b23ba05d 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>
2026-06-08 15:20:38 +10:00

77 lines
3.1 KiB
JavaScript

import { Agent } from 'undici';
// Read-only Proxmox cluster health for the Sacred Valley card. Uses a dedicated
// PVEAuditor token (PROXMOX_RO_TOKEN) — never the power-action token. PVE's REST
// API has no vote-count endpoint, so "quorum" here = the corosync `quorate` flag
// (from /cluster/status) plus the HA-manager quorum status (/cluster/ha/status).
let insecure;
function tlsDispatcher() {
if (process.env.PROXMOX_INSECURE_TLS !== '1') return undefined;
insecure ??= new Agent({ connect: { rejectUnauthorized: false } });
return insecure;
}
async function pveGet(path, { apiUrl, token, fetchImpl = fetch }) {
const res = await fetchImpl(`${apiUrl}/api2/json${path}`, {
headers: { Authorization: `PVEAPIToken=${token}` },
dispatcher: tlsDispatcher()
});
if (!res.ok) throw new Error(`pve ${path} -> ${res.status}`);
return (await res.json())?.data ?? [];
}
const SETTLED_STATES = new Set(['started', 'stopped', 'ignored', 'disabled']);
// Pure: fold /cluster/status + /cluster/ha/status/current into the card shape.
export function normalizeCluster(statusData = [], haData = []) {
const cluster = statusData.find(e => e.type === 'cluster') || {};
const nodes = statusData
.filter(e => e.type === 'node')
.map(n => ({ name: n.name, online: n.online === 1 || n.online === true, local: !!n.local, ip: n.ip || null }))
.sort((a, b) => a.name.localeCompare(b.name));
const quorum = haData.find(e => e.type === 'quorum') || {};
const master = haData.find(e => e.type === 'master') || {};
const fencing = haData.find(e => e.type === 'fencing') || {};
const services = haData
.filter(e => e.type === 'service')
.map(s => ({ sid: s.sid || (s.id || '').replace(/^service:/, ''), state: s.state || s.crm_state || 'unknown', node: s.node || null }))
.sort((a, b) => a.sid.localeCompare(b.sid));
const servicesError = services.filter(s => !SETTLED_STATES.has(s.state));
return {
name: cluster.name || null,
quorate: cluster.quorate === 1 || cluster.quorate === true,
nodes_total: cluster.nodes ?? nodes.length,
nodes_online: nodes.filter(n => n.online).length,
nodes,
ha: {
quorum_ok: quorum.quorate === 1 || quorum.status === 'OK',
master: master.node || null,
fencing: fencing['armed-state'] || (fencing.status ? 'armed' : null),
services_total: services.length,
services_error: servicesError.length,
services
}
};
}
export async function clusterHealth(opts = {}) {
const cfg = {
apiUrl: opts.apiUrl || process.env.PROXMOX_API_URL,
token: opts.token || process.env.PROXMOX_RO_TOKEN || process.env.PROXMOX_API_TOKEN,
fetchImpl: opts.fetchImpl || fetch
};
if (!cfg.apiUrl || !cfg.token) return { error: 'proxmox_not_configured', at: Date.now() };
try {
const [status, ha] = await Promise.all([
pveGet('/cluster/status', cfg),
pveGet('/cluster/ha/status/current', cfg).catch(() => []) // HA may be absent on a bare cluster
]);
return { ...normalizeCluster(status, ha), at: Date.now() };
} catch (e) {
return { error: String(e.message || e), at: Date.now() };
}
}