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() }; } }