- Added lock file exclusions for pnpm in .gitignore. - Removed obsolete package-lock.json from the api and portal directories. - Enhanced Cloudflare adapter with additional interfaces for zones and tunnels. - Improved Proxmox adapter error handling and logging for API requests. - Updated Proxmox VM parameters with validation rules in the API schema. - Enhanced documentation for Proxmox VM specifications and examples.
526 lines
16 KiB
TypeScript
526 lines
16 KiB
TypeScript
/**
|
|
* 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<NormalizedResource[]> {
|
|
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<any[]> {
|
|
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<any[]> {
|
|
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<NormalizedResource | null> {
|
|
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<NormalizedResource> {
|
|
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<ResourceSpec>): Promise<NormalizedResource> {
|
|
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<NormalizedResource>
|
|
}
|
|
|
|
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<NormalizedResource>
|
|
} catch (error) {
|
|
logger.error(`Error updating Proxmox resource ${providerId}`, { error, providerId })
|
|
throw error
|
|
}
|
|
}
|
|
|
|
async deleteResource(providerId: string): Promise<boolean> {
|
|
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<NormalizedMetrics[]> {
|
|
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<NormalizedRelationship[]> {
|
|
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<HealthStatus> {
|
|
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(),
|
|
}
|
|
}
|
|
}
|
|
|