/** * Proxmox VE Adapter * Implements the InfrastructureAdapter interface for Proxmox */ import { InfrastructureAdapter, NormalizedResource, ResourceSpec, NormalizedMetrics, TimeRange, HealthStatus, NormalizedRelationship } from '../types.js' import { ResourceProvider } from '../../types/resource.js' import { logger } from '../../lib/logger.js' import type { ProxmoxCluster, ProxmoxVM, ProxmoxVMConfig } from './types.js' export class ProxmoxAdapter implements InfrastructureAdapter { readonly provider: ResourceProvider = 'PROXMOX' private apiUrl: string private apiToken: string constructor(config: { apiUrl: string; apiToken: string }) { this.apiUrl = config.apiUrl this.apiToken = config.apiToken } async discoverResources(): Promise { try { const nodes = await this.getNodes() const allResources: NormalizedResource[] = [] for (const node of nodes) { try { const vms = await this.getVMs(node.node) for (const vm of vms) { allResources.push(this.normalizeVM(vm, node.node)) } } catch (error) { logger.error(`Error discovering VMs on node ${node.node}`, { error, node: node.node }) } } return allResources } catch (error) { logger.error('Error discovering Proxmox resources', { error }) throw error } } private async getNodes(): Promise { try { const response = await fetch(`${this.apiUrl}/api2/json/nodes`, { method: 'GET', headers: { 'Authorization': `PVEAPIToken ${this.apiToken}`, // Note: space after PVEAPIToken for Proxmox API 'Content-Type': 'application/json', }, }) if (!response.ok) { const errorBody = await response.text().catch(() => '') logger.error('Failed to get Proxmox nodes', { status: response.status, statusText: response.statusText, body: errorBody, url: `${this.apiUrl}/api2/json/nodes`, }) throw new Error(`Proxmox API error: ${response.status} ${response.statusText} - ${errorBody}`) } const data = await response.json() if (!data || !Array.isArray(data.data)) { logger.warn('Unexpected response format from Proxmox nodes API', { data }) return [] } return data.data } catch (error) { logger.error('Error getting Proxmox nodes', { error, apiUrl: this.apiUrl }) throw error } } private async getVMs(node: string): Promise { if (!node || typeof node !== 'string') { throw new Error(`Invalid node name: ${node}`) } try { const url = `${this.apiUrl}/api2/json/nodes/${encodeURIComponent(node)}/qemu` const response = await fetch(url, { method: 'GET', headers: { 'Authorization': `PVEAPIToken ${this.apiToken}`, // Note: space after PVEAPIToken for Proxmox API 'Content-Type': 'application/json', }, }) if (!response.ok) { const errorBody = await response.text().catch(() => '') logger.error('Failed to get VMs from Proxmox node', { node, status: response.status, statusText: response.statusText, body: errorBody, url, }) throw new Error(`Proxmox API error getting VMs from node ${node}: ${response.status} ${response.statusText}`) } const data = await response.json() if (!data || !Array.isArray(data.data)) { logger.warn('Unexpected response format from Proxmox VMs API', { node, data }) return [] } return data.data } catch (error) { logger.error('Error getting VMs from Proxmox node', { error, node, apiUrl: this.apiUrl }) throw error } } async getResource(providerId: string): Promise { if (!providerId || typeof providerId !== 'string') { logger.warn('Invalid providerId provided to getResource', { providerId }) return null } try { const [node, vmid] = providerId.split(':') if (!node || !vmid) { logger.warn('Invalid providerId format, expected "node:vmid"', { providerId }) return null } // Validate vmid is numeric const vmidNum = parseInt(vmid, 10) if (isNaN(vmidNum) || vmidNum < 100 || vmidNum > 999999999) { logger.warn('Invalid VMID in providerId', { providerId, vmid, vmidNum }) return null } const url = `${this.apiUrl}/api2/json/nodes/${encodeURIComponent(node)}/qemu/${encodeURIComponent(vmid)}` const response = await fetch(url, { method: 'GET', headers: { 'Authorization': `PVEAPIToken ${this.apiToken}`, // Note: space after PVEAPIToken for Proxmox API 'Content-Type': 'application/json', }, }) if (!response.ok) { if (response.status === 404) { logger.debug('VM not found', { providerId, node, vmid }) return null } const errorBody = await response.text().catch(() => '') logger.error('Failed to get Proxmox resource', { providerId, node, vmid, status: response.status, statusText: response.statusText, body: errorBody, url, }) throw new Error(`Proxmox API error getting resource ${providerId}: ${response.status} ${response.statusText}`) } const data = await response.json() if (!data || !data.data) { logger.warn('Empty response from Proxmox API', { providerId, data }) return null } return this.normalizeVM(data.data, node) } catch (error) { logger.error(`Error getting Proxmox resource ${providerId}`, { error, providerId }) return null } } async createResource(spec: ResourceSpec): Promise { if (!spec || !spec.name) { throw new Error('Invalid resource spec: name is required') } try { const nodes = await this.getNodes() if (!nodes || nodes.length === 0) { throw new Error('No Proxmox nodes available') } // Find first online node, or use first node if status unknown const node = nodes.find((n: any) => n.status === 'online') || nodes[0] if (!node || !node.node) { throw new Error('No valid Proxmox node found') } const targetNode = node.node // Validate config if (spec.config?.vmid && (spec.config.vmid < 100 || spec.config.vmid > 999999999)) { throw new Error(`Invalid VMID: ${spec.config.vmid} (must be between 100 and 999999999)`) } const config: any = { vmid: spec.config?.vmid || undefined, // Auto-assign if not specified name: spec.name, cores: spec.config?.cores || 2, memory: spec.config?.memory || 2048, net0: spec.config?.net0 || 'virtio,bridge=vmbr0', ostype: spec.config?.ostype || 'l26', } // Validate memory is positive if (config.memory <= 0) { throw new Error(`Invalid memory value: ${config.memory} (must be positive)`) } // Create VM const url = `${this.apiUrl}/api2/json/nodes/${encodeURIComponent(targetNode)}/qemu` const response = await fetch(url, { method: 'POST', headers: { 'Authorization': `PVEAPIToken ${this.apiToken}`, // Note: space after PVEAPIToken for Proxmox API 'Content-Type': 'application/json', }, body: JSON.stringify(config), }) if (!response.ok) { const errorBody = await response.text().catch(() => '') logger.error('Failed to create Proxmox VM', { spec, node: targetNode, status: response.status, statusText: response.statusText, body: errorBody, url, }) throw new Error(`Failed to create VM: ${response.status} ${response.statusText} - ${errorBody}`) } const data = await response.json() // VMID can be returned as string or number from Proxmox API const vmid = data.data || config.vmid if (!vmid) { throw new Error('VM creation succeeded but no VMID returned') } const vmidStr = String(vmid) // Ensure it's a string for providerId format // Get created VM with retry logic (VM may not be immediately available) let retries = 3 while (retries > 0) { const vm = await this.getResource(`${targetNode}:${vmidStr}`) if (vm) { return vm } await new Promise(resolve => setTimeout(resolve, 1000)) // Wait 1 second retries-- } throw new Error(`VM ${vmidStr} created but not found after retries`) } catch (error) { logger.error('Error creating Proxmox resource', { error }) throw error } } async updateResource(providerId: string, spec: Partial): Promise { if (!providerId || typeof providerId !== 'string') { throw new Error(`Invalid providerId: ${providerId}`) } try { const [node, vmid] = providerId.split(':') if (!node || !vmid) { throw new Error(`Invalid provider ID format, expected "node:vmid", got: ${providerId}`) } // Validate vmid is numeric const vmidNum = parseInt(vmid, 10) if (isNaN(vmidNum) || vmidNum < 100 || vmidNum > 999999999) { throw new Error(`Invalid VMID in providerId: ${vmid}`) } const updates: any = {} if (spec.config?.cores !== undefined) { if (spec.config.cores < 1) { throw new Error(`Invalid CPU cores: ${spec.config.cores} (must be at least 1)`) } updates.cores = spec.config.cores } if (spec.config?.memory !== undefined) { if (spec.config.memory <= 0) { throw new Error(`Invalid memory: ${spec.config.memory} (must be positive)`) } updates.memory = spec.config.memory } if (Object.keys(updates).length === 0) { logger.debug('No updates to apply', { providerId }) return this.getResource(providerId) as Promise } const url = `${this.apiUrl}/api2/json/nodes/${encodeURIComponent(node)}/qemu/${encodeURIComponent(vmid)}/config` const response = await fetch(url, { method: 'PUT', headers: { 'Authorization': `PVEAPIToken ${this.apiToken}`, // Note: space after PVEAPIToken for Proxmox API 'Content-Type': 'application/json', }, body: JSON.stringify(updates), }) if (!response.ok) { const errorBody = await response.text().catch(() => '') logger.error('Failed to update Proxmox VM', { providerId, node, vmid, updates, status: response.status, statusText: response.statusText, body: errorBody, url, }) throw new Error(`Failed to update VM ${providerId}: ${response.status} ${response.statusText} - ${errorBody}`) } return this.getResource(providerId) as Promise } catch (error) { logger.error(`Error updating Proxmox resource ${providerId}`, { error, providerId }) throw error } } async deleteResource(providerId: string): Promise { if (!providerId || typeof providerId !== 'string') { logger.warn('Invalid providerId provided to deleteResource', { providerId }) return false } try { const [node, vmid] = providerId.split(':') if (!node || !vmid) { logger.warn('Invalid provider ID format, expected "node:vmid"', { providerId }) return false } // Validate vmid is numeric const vmidNum = parseInt(vmid, 10) if (isNaN(vmidNum) || vmidNum < 100 || vmidNum > 999999999) { logger.warn('Invalid VMID in providerId', { providerId, vmid }) return false } const url = `${this.apiUrl}/api2/json/nodes/${encodeURIComponent(node)}/qemu/${encodeURIComponent(vmid)}` const response = await fetch(url, { method: 'DELETE', headers: { 'Authorization': `PVEAPIToken ${this.apiToken}`, // Note: space after PVEAPIToken for Proxmox API 'Content-Type': 'application/json', }, }) if (!response.ok) { const errorBody = await response.text().catch(() => '') logger.error('Failed to delete Proxmox VM', { providerId, node, vmid, status: response.status, statusText: response.statusText, body: errorBody, url, }) return false } return true } catch (error) { logger.error(`Error deleting Proxmox resource ${providerId}`, { error, providerId }) return false } } async getMetrics(providerId: string, timeRange: TimeRange): Promise { try { const [node, vmid] = providerId.split(':') if (!node || !vmid) return [] const response = await fetch( `${this.apiUrl}/api2/json/nodes/${node}/qemu/${vmid}/rrddata?timeframe=hour&cf=AVERAGE`, { method: 'GET', headers: { 'Authorization': `PVEAPIToken ${this.apiToken}`, // Note: space after PVEAPIToken for Proxmox API 'Content-Type': 'application/json', }, } ) if (!response.ok) return [] const data = await response.json() const metrics: NormalizedMetrics[] = [] if (data.data && Array.isArray(data.data)) { for (const point of data.data) { if (point.cpu) { metrics.push({ resourceId: providerId, metricType: 'CPU_USAGE', value: parseFloat(point.cpu) * 100, timestamp: new Date(point.time * 1000), }) } if (point.mem) { metrics.push({ resourceId: providerId, metricType: 'MEMORY_USAGE', value: parseFloat(point.mem), timestamp: new Date(point.time * 1000), }) } } } return metrics } catch (error) { logger.error(`Error getting Proxmox metrics for ${providerId}`, { error, providerId }) return [] } } async getRelationships(providerId: string): Promise { try { const [node, vmid] = providerId.split(':') if (!node || !vmid) return [] const vm = await this.getResource(providerId) if (!vm) return [] const relationships: NormalizedRelationship[] = [ { sourceId: providerId, targetId: `proxmox-node-${node}`, type: 'HOSTED_ON', metadata: { node }, }, ] // Add storage relationships if available if (vm.metadata?.storage) { relationships.push({ sourceId: providerId, targetId: `proxmox-storage-${vm.metadata.storage}`, type: 'USES_STORAGE', metadata: { storage: vm.metadata.storage }, }) } return relationships } catch (error) { logger.error(`Error getting Proxmox relationships for ${providerId}`, { error, providerId }) return [] } } async healthCheck(): Promise { try { const response = await fetch(`${this.apiUrl}/api2/json/version`, { method: 'GET', headers: { 'Authorization': `PVEAPIToken ${this.apiToken}`, // Note: space after PVEAPIToken for Proxmox API 'Content-Type': 'application/json', }, }) if (response.ok) { return { status: 'healthy', lastChecked: new Date(), } } return { status: 'unhealthy', message: `API returned status ${response.status}`, lastChecked: new Date(), } } catch (error) { return { status: 'unhealthy', message: error instanceof Error ? error.message : 'Unknown error', lastChecked: new Date(), } } } /** * Helper method to normalize Proxmox VM to unified resource format */ private normalizeVM(vm: ProxmoxVM, node: string): NormalizedResource { return { id: `proxmox-${node}-${vm.vmid}`, name: vm.name || `VM ${vm.vmid}`, type: 'virtual_machine', provider: 'PROXMOX', providerId: `${node}:${vm.vmid}`, providerResourceId: `proxmox://${node}/vm/${vm.vmid}`, status: vm.status, metadata: { node, vmid: vm.vmid, cpu: vm.cpu, memory: vm.mem, disk: vm.disk, uptime: vm.uptime, }, tags: [], createdAt: new Date(Date.now() - vm.uptime * 1000), updatedAt: new Date(), } } }