/** * Reconcile a module's ACTUAL node placement from Proxmox (ISS-0060 pt 2). * * "The DB stores intent, Proxmox reports reality." `module status` / `list` * must show where a container ACTUALLY lives — queried live from the Proxmox * cluster — not a cached `__infra_target_node` that drifts (caddy recorded * node3, ran on node2). Machine-pool systems and non-Proxmox container services * have no Proxmox node to reconcile. * * Never throws: a Proxmox outage yields an 'unreachable' resolution so a status * or list command still renders instead of erroring. */ import type { DeployedSystem } from '@celilo/capabilities'; import { ProxmoxClient, type ProxmoxCredentials } from '../api-clients/proxmox'; import { getContainerService, getServiceCredentials } from './container-service'; export type PlacementResolution = | { kind: 'node'; node: string } // reconciled: lives on | { kind: 'unreachable' } // a Proxmox container, but the API couldn't be reached | { kind: 'absent' } // a Proxmox container with a vmid not present in the cluster | { kind: 'uncreated' } // a container_service system not yet created (no vmid) | { kind: 'machine' } // a machine-pool system (no Proxmox node) | { kind: 'other' }; // a non-Proxmox container service (e.g. DigitalOcean) /** One-line placement description for `module status`. Pure (Rule 10). */ export function formatPlacementLine(sys: DeployedSystem, res: PlacementResolution): string { const zone = `zone ${sys.zone}`; const vmid = sys.infrastructure.vmid; switch (res.kind) { case 'machine': return `${sys.hostname} — machine (${zone})`; case 'other': return `${sys.hostname} — container_service, non-Proxmox (${zone})`; case 'uncreated': return `${sys.hostname} — not yet created (${zone})`; case 'unreachable': return `${sys.hostname} (vmid ${vmid}) → node unknown — Proxmox unreachable (${zone})`; case 'absent': return `${sys.hostname} (vmid ${vmid}) → not found in Proxmox — not created? (${zone})`; case 'node': return `${sys.hostname} (vmid ${vmid}) → ${res.node} (${zone})`; } } /** Pure resolution of one system given the reconciled vmid→node map + service classification. */ export function resolveOnePlacement( sys: DeployedSystem, vmidToNode: Map, unreachable: Set, nonProxmox: Set, ): PlacementResolution { const infra = sys.infrastructure; if (infra.type !== 'container_service') return { kind: 'machine' }; if (infra.serviceId && nonProxmox.has(infra.serviceId)) return { kind: 'other' }; if (infra.vmid == null) return { kind: 'uncreated' }; if (infra.serviceId && unreachable.has(infra.serviceId)) return { kind: 'unreachable' }; const node = vmidToNode.get(infra.vmid); return node ? { kind: 'node', node } : { kind: 'absent' }; } /** * Reconcile each system's real node from Proxmox. One `/cluster/resources` fetch * per distinct Proxmox service (usually one); machine + non-Proxmox systems need * no call. Returns each system paired with its resolution, in input order. */ export async function reconcilePlacement( systems: DeployedSystem[], ): Promise> { const serviceIds = [ ...new Set( systems .filter((s) => s.infrastructure.type === 'container_service') .map((s) => s.infrastructure.serviceId) .filter((id): id is string => !!id), ), ]; const vmidToNode = new Map(); const unreachable = new Set(); const nonProxmox = new Set(); for (const serviceId of serviceIds) { const service = await getContainerService(serviceId); if (!service || service.providerName !== 'proxmox') { nonProxmox.add(serviceId); continue; } try { const creds = (await getServiceCredentials(serviceId)) as ProxmoxCredentials; const result = await new ProxmoxClient(creds).clusterResources(); if (!result.success) { unreachable.add(serviceId); continue; } for (const r of result.data) { if (typeof r.vmid === 'number' && r.node) vmidToNode.set(r.vmid, r.node); } } catch { unreachable.add(serviceId); } } return systems.map((system) => ({ system, resolution: resolveOnePlacement(system, vmidToNode, unreachable, nonProxmox), })); }