New Features: - Add proxmox_create_vm tool for QEMU VM creation - Add test-basic-tools.js for validating basic operations (7/7 tests) - Add test-workflows.js for lifecycle workflow testing (19/22 tests) - Add TEST-WORKFLOWS.md with comprehensive test documentation Bug Fixes: - Fix regex patterns in test scripts for MCP formatted output - Increase snapshot wait times and add retry logic - Handle markdown formatting in tool responses Documentation: - Update tool count from 54 to 55 - Add Claude Desktop integration with OS-specific paths - Document proxmox_create_vm with full parameters and examples - Add testing section with usage examples Other: - Update .gitignore for Node.js (node_modules, package-lock.json)
949 lines
30 KiB
JavaScript
Executable File
949 lines
30 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
|
|
/**
|
|
* Comprehensive workflow test script for Proxmox MCP Server
|
|
* Tests complete lifecycle workflows for VMs, containers, networking, disks, snapshots, and backups
|
|
*
|
|
* Usage:
|
|
* node test-workflows.js [options]
|
|
*
|
|
* Options:
|
|
* --dry-run Show what would be done without executing
|
|
* --workflow=NAME Run specific workflow (lxc, vm, network, disk, snapshot, backup, all)
|
|
* --no-cleanup Skip cleanup after tests
|
|
* --interactive Prompt before each destructive operation
|
|
*/
|
|
|
|
import { spawn } from 'child_process';
|
|
import path from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
import readline from 'readline';
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
|
|
// ANSI color codes
|
|
const colors = {
|
|
reset: '\x1b[0m',
|
|
bright: '\x1b[1m',
|
|
dim: '\x1b[2m',
|
|
green: '\x1b[32m',
|
|
red: '\x1b[31m',
|
|
yellow: '\x1b[33m',
|
|
blue: '\x1b[34m',
|
|
cyan: '\x1b[36m',
|
|
magenta: '\x1b[35m'
|
|
};
|
|
|
|
// Configuration
|
|
const config = {
|
|
dryRun: process.argv.includes('--dry-run'),
|
|
interactive: process.argv.includes('--interactive'),
|
|
noCleanup: process.argv.includes('--no-cleanup'),
|
|
workflow: process.argv.find(arg => arg.startsWith('--workflow='))?.split('=')[1] || 'all',
|
|
testNode: null, // Will be auto-detected
|
|
testVmid: null, // Will be generated
|
|
testResources: [] // Track resources for cleanup
|
|
};
|
|
|
|
// Test results
|
|
const results = {
|
|
passed: [],
|
|
failed: [],
|
|
skipped: []
|
|
};
|
|
|
|
// Create readline interface for interactive prompts
|
|
const rl = readline.createInterface({
|
|
input: process.stdin,
|
|
output: process.stdout
|
|
});
|
|
|
|
// ============================================================================
|
|
// HELPER FUNCTIONS
|
|
// ============================================================================
|
|
|
|
function log(message, color = 'reset') {
|
|
const timestamp = new Date().toISOString().split('T')[1].split('.')[0];
|
|
console.log(`${colors.dim}[${timestamp}]${colors.reset} ${colors[color]}${message}${colors.reset}`);
|
|
}
|
|
|
|
function logSection(title) {
|
|
console.log(`\n${colors.cyan}${'═'.repeat(70)}${colors.reset}`);
|
|
console.log(`${colors.cyan}${colors.bright} ${title}${colors.reset}`);
|
|
console.log(`${colors.cyan}${'═'.repeat(70)}${colors.reset}\n`);
|
|
}
|
|
|
|
function logStep(step, detail = '') {
|
|
console.log(`${colors.blue}▶${colors.reset} ${colors.bright}${step}${colors.reset}`);
|
|
if (detail) console.log(` ${colors.dim}${detail}${colors.reset}`);
|
|
}
|
|
|
|
function logSuccess(message) {
|
|
console.log(` ${colors.green}✓ ${message}${colors.reset}`);
|
|
}
|
|
|
|
function logError(message) {
|
|
console.log(` ${colors.red}✗ ${message}${colors.reset}`);
|
|
}
|
|
|
|
function logWarning(message) {
|
|
console.log(` ${colors.yellow}⚠ ${message}${colors.reset}`);
|
|
}
|
|
|
|
async function prompt(question) {
|
|
return new Promise((resolve) => {
|
|
rl.question(`${colors.yellow}${question} (y/n): ${colors.reset}`, (answer) => {
|
|
resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
|
|
});
|
|
});
|
|
}
|
|
|
|
function sleep(ms) {
|
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
}
|
|
|
|
// Call MCP tool
|
|
async function callTool(toolName, args = {}) {
|
|
if (config.dryRun) {
|
|
log(`[DRY-RUN] Would call: ${toolName} with ${JSON.stringify(args)}`, 'dim');
|
|
return { success: true, dryRun: true, content: 'Dry run - no actual call made' };
|
|
}
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const request = {
|
|
jsonrpc: '2.0',
|
|
id: Date.now(),
|
|
method: 'tools/call',
|
|
params: {
|
|
name: toolName,
|
|
arguments: args
|
|
}
|
|
};
|
|
|
|
const serverProcess = spawn('node', [path.join(__dirname, 'index.js')], {
|
|
stdio: ['pipe', 'pipe', 'pipe']
|
|
});
|
|
|
|
let stdout = '';
|
|
let stderr = '';
|
|
|
|
serverProcess.stdout.on('data', (data) => {
|
|
stdout += data.toString();
|
|
});
|
|
|
|
serverProcess.stderr.on('data', (data) => {
|
|
stderr += data.toString();
|
|
});
|
|
|
|
serverProcess.on('close', (code) => {
|
|
try {
|
|
const lines = stdout.split('\n');
|
|
let response = null;
|
|
for (const line of lines) {
|
|
if (line.trim().startsWith('{')) {
|
|
try {
|
|
response = JSON.parse(line);
|
|
break;
|
|
} catch (e) {
|
|
// Not JSON, continue
|
|
}
|
|
}
|
|
}
|
|
|
|
if (response) {
|
|
if (response.error) {
|
|
resolve({
|
|
success: false,
|
|
error: response.error.message,
|
|
errorCode: response.error.code
|
|
});
|
|
} else if (response.result && response.result.content) {
|
|
const content = response.result.content[0];
|
|
// Check if it's an error message about permissions
|
|
if (content.text && content.text.includes('Requires Elevated Permissions')) {
|
|
resolve({
|
|
success: false,
|
|
error: 'Elevated permissions required',
|
|
content: content.text
|
|
});
|
|
} else {
|
|
resolve({
|
|
success: true,
|
|
content: content.text || '',
|
|
isError: response.result.isError || false
|
|
});
|
|
}
|
|
} else {
|
|
resolve({
|
|
success: false,
|
|
error: 'No content in response'
|
|
});
|
|
}
|
|
} else {
|
|
reject(new Error(`No JSON response. stdout: ${stdout.substring(0, 200)}`));
|
|
}
|
|
} catch (error) {
|
|
reject(error);
|
|
}
|
|
});
|
|
|
|
serverProcess.on('error', reject);
|
|
serverProcess.stdin.write(JSON.stringify(request) + '\n');
|
|
serverProcess.stdin.end();
|
|
});
|
|
}
|
|
|
|
// Track resource for cleanup
|
|
function trackResource(type, node, vmid) {
|
|
config.testResources.push({ type, node, vmid });
|
|
log(`Tracking resource for cleanup: ${type} ${vmid} on ${node}`, 'dim');
|
|
}
|
|
|
|
// Record test result
|
|
function recordResult(workflow, testName, success, message, details = null) {
|
|
const result = { workflow, test: testName, message, details };
|
|
if (success) {
|
|
results.passed.push(result);
|
|
logSuccess(`${testName}: ${message}`);
|
|
} else {
|
|
results.failed.push(result);
|
|
logError(`${testName}: ${message}`);
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// WORKFLOW TESTS
|
|
// ============================================================================
|
|
|
|
async function testLXCWorkflow() {
|
|
logSection('LXC Container Complete Workflow');
|
|
|
|
try {
|
|
// Step 1: Get next available VMID
|
|
logStep('Step 1: Get next available VMID');
|
|
const vmidResult = await callTool('proxmox_get_next_vmid');
|
|
if (!vmidResult.success) {
|
|
recordResult('lxc', 'Get VMID', false, vmidResult.error);
|
|
return;
|
|
}
|
|
const vmidMatch = vmidResult.content.match(/\d{3,}/);
|
|
if (!vmidMatch) {
|
|
recordResult('lxc', 'Get VMID', false, 'Could not parse VMID from response');
|
|
return;
|
|
}
|
|
config.testVmid = vmidMatch[0];
|
|
logSuccess(`Got VMID: ${config.testVmid}`);
|
|
|
|
// Step 2: List available templates
|
|
logStep('Step 2: List available templates', `Node: ${config.testNode}`);
|
|
const templatesResult = await callTool('proxmox_list_templates', {
|
|
node: config.testNode,
|
|
storage: 'local'
|
|
});
|
|
if (!templatesResult.success) {
|
|
recordResult('lxc', 'List Templates', false, templatesResult.error);
|
|
return;
|
|
}
|
|
logSuccess('Templates listed successfully');
|
|
|
|
// Try to find a Debian template - handles markdown format: **local:vztmpl/debian-...**
|
|
const templateMatch = templatesResult.content.match(/\*\*local:vztmpl\/(debian-\d+-standard[^\s*]+)\*\*/i) ||
|
|
templatesResult.content.match(/\*\*local:vztmpl\/([^\s*]+\.tar\.[gxz]+)\*\*/i) ||
|
|
templatesResult.content.match(/\*\*local:vztmpl\/(debian[^\s*]+)\*\*/i) ||
|
|
templatesResult.content.match(/\*\*local:vztmpl\/([^\s*]+)\*\*/i);
|
|
|
|
if (!templateMatch) {
|
|
recordResult('lxc', 'Find Template', false, 'No suitable template found', templatesResult.content);
|
|
return;
|
|
}
|
|
const template = `local:vztmpl/${templateMatch[1]}`;
|
|
logSuccess(`Found template: ${template}`);
|
|
|
|
// Step 3: Create LXC container
|
|
if (config.interactive) {
|
|
const proceed = await prompt(`Create LXC container ${config.testVmid}?`);
|
|
if (!proceed) {
|
|
recordResult('lxc', 'Create Container', false, 'Skipped by user');
|
|
return;
|
|
}
|
|
}
|
|
|
|
logStep('Step 3: Create LXC container', `VMID: ${config.testVmid}, Template: ${template}`);
|
|
const createResult = await callTool('proxmox_create_lxc', {
|
|
node: config.testNode,
|
|
vmid: config.testVmid,
|
|
ostemplate: template,
|
|
hostname: `test-mcp-${config.testVmid}`,
|
|
password: 'Test123!@#',
|
|
memory: 512,
|
|
storage: 'local-lvm',
|
|
rootfs: '4'
|
|
});
|
|
|
|
if (!createResult.success) {
|
|
recordResult('lxc', 'Create Container', false, createResult.error || 'Creation failed', createResult.content);
|
|
return;
|
|
}
|
|
trackResource('lxc', config.testNode, config.testVmid);
|
|
recordResult('lxc', 'Create Container', true, `Container ${config.testVmid} created`);
|
|
await sleep(2000); // Wait for creation to complete
|
|
|
|
// Step 4: Start container
|
|
logStep('Step 4: Start LXC container', `VMID: ${config.testVmid}`);
|
|
const startResult = await callTool('proxmox_start_lxc', {
|
|
node: config.testNode,
|
|
vmid: config.testVmid
|
|
});
|
|
|
|
if (!startResult.success) {
|
|
recordResult('lxc', 'Start Container', false, startResult.error);
|
|
} else {
|
|
recordResult('lxc', 'Start Container', true, `Container ${config.testVmid} started`);
|
|
await sleep(3000); // Wait for startup
|
|
}
|
|
|
|
// Step 5: Check status
|
|
logStep('Step 5: Check container status');
|
|
const statusResult = await callTool('proxmox_get_vm_status', {
|
|
node: config.testNode,
|
|
vmid: config.testVmid,
|
|
type: 'lxc'
|
|
});
|
|
|
|
if (!statusResult.success) {
|
|
recordResult('lxc', 'Check Status', false, statusResult.error);
|
|
} else {
|
|
const isRunning = statusResult.content.toLowerCase().includes('running');
|
|
recordResult('lxc', 'Check Status', isRunning,
|
|
isRunning ? 'Container is running' : 'Container is not running',
|
|
statusResult.content.substring(0, 200));
|
|
}
|
|
|
|
// Step 6: Create snapshot
|
|
logStep('Step 6: Create snapshot', `Name: test-snapshot`);
|
|
const snapshotResult = await callTool('proxmox_create_snapshot_lxc', {
|
|
node: config.testNode,
|
|
vmid: config.testVmid,
|
|
snapname: 'test-snapshot'
|
|
});
|
|
|
|
if (!snapshotResult.success) {
|
|
recordResult('lxc', 'Create Snapshot', false, snapshotResult.error);
|
|
} else {
|
|
recordResult('lxc', 'Create Snapshot', true, 'Snapshot created');
|
|
await sleep(5000); // Increased wait time for Proxmox to register snapshot
|
|
}
|
|
|
|
// Step 7: List snapshots (with retry logic)
|
|
logStep('Step 7: List snapshots');
|
|
|
|
let hasSnapshot = false;
|
|
let listSuccess = false;
|
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
const listSnapshotsResult = await callTool('proxmox_list_snapshots_lxc', {
|
|
node: config.testNode,
|
|
vmid: config.testVmid
|
|
});
|
|
|
|
if (!listSnapshotsResult.success) {
|
|
if (attempt === 3) {
|
|
recordResult('lxc', 'List Snapshots', false, listSnapshotsResult.error);
|
|
}
|
|
break;
|
|
}
|
|
|
|
hasSnapshot = listSnapshotsResult.content.includes('test-snapshot');
|
|
if (hasSnapshot) {
|
|
listSuccess = true;
|
|
recordResult('lxc', 'List Snapshots', true, 'Snapshot found in list');
|
|
break;
|
|
}
|
|
|
|
if (attempt < 3) {
|
|
logWarning(`Snapshot not found yet, retrying in 2 seconds... (attempt ${attempt}/3)`);
|
|
await sleep(2000);
|
|
}
|
|
}
|
|
|
|
if (listSuccess === false && hasSnapshot === false) {
|
|
recordResult('lxc', 'List Snapshots', false, 'Snapshot not found in list after 3 attempts');
|
|
}
|
|
|
|
// Step 8: Stop container
|
|
logStep('Step 8: Stop LXC container');
|
|
const stopResult = await callTool('proxmox_stop_lxc', {
|
|
node: config.testNode,
|
|
vmid: config.testVmid
|
|
});
|
|
|
|
if (!stopResult.success) {
|
|
recordResult('lxc', 'Stop Container', false, stopResult.error);
|
|
} else {
|
|
recordResult('lxc', 'Stop Container', true, `Container ${config.testVmid} stopped`);
|
|
await sleep(3000);
|
|
}
|
|
|
|
// Step 9: Delete snapshot
|
|
logStep('Step 9: Delete snapshot');
|
|
const deleteSnapshotResult = await callTool('proxmox_delete_snapshot_lxc', {
|
|
node: config.testNode,
|
|
vmid: config.testVmid,
|
|
snapname: 'test-snapshot'
|
|
});
|
|
|
|
if (!deleteSnapshotResult.success) {
|
|
recordResult('lxc', 'Delete Snapshot', false, deleteSnapshotResult.error);
|
|
} else {
|
|
recordResult('lxc', 'Delete Snapshot', true, 'Snapshot deleted');
|
|
await sleep(2000);
|
|
}
|
|
|
|
} catch (error) {
|
|
recordResult('lxc', 'Workflow', false, `Exception: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
async function testVMLifecycleWorkflow() {
|
|
logSection('VM Lifecycle Operations Workflow');
|
|
|
|
try {
|
|
// Find an existing VM to test with
|
|
logStep('Step 1: Find existing VM for testing');
|
|
const vmsResult = await callTool('proxmox_get_vms');
|
|
|
|
if (!vmsResult.success) {
|
|
recordResult('vm-lifecycle', 'Find VM', false, vmsResult.error);
|
|
return;
|
|
}
|
|
|
|
// Try to find a stopped VM, or any VM - handles format: (ID: 100) ... • Node: pve1 ... • Status: running
|
|
const vmMatch = vmsResult.content.match(/\(ID:\s*(\d+)\).*?[•\s]*Node:\s*(\S+).*?[•\s]*Status:\s*(\S+)/is);
|
|
|
|
if (!vmMatch) {
|
|
recordResult('vm-lifecycle', 'Find VM', false, 'No VMs found for testing');
|
|
return;
|
|
}
|
|
|
|
const existingVmid = vmMatch[1];
|
|
const existingNode = vmMatch[2];
|
|
const existingStatus = vmMatch[3];
|
|
|
|
logSuccess(`Found VM ${existingVmid} on ${existingNode} (status: ${existingStatus})`);
|
|
recordResult('vm-lifecycle', 'Find VM', true, `Testing with VM ${existingVmid}`);
|
|
|
|
// Determine VM type
|
|
logStep('Step 2: Detect VM type');
|
|
let vmType = 'lxc';
|
|
if (vmsResult.content.includes(`ID: ${existingVmid}`) && vmsResult.content.includes('QEMU')) {
|
|
vmType = 'qemu';
|
|
}
|
|
logSuccess(`VM type: ${vmType}`);
|
|
|
|
// Step 3: Get detailed status
|
|
logStep('Step 3: Get VM status');
|
|
const statusResult = await callTool('proxmox_get_vm_status', {
|
|
node: existingNode,
|
|
vmid: existingVmid,
|
|
type: vmType
|
|
});
|
|
|
|
if (!statusResult.success) {
|
|
recordResult('vm-lifecycle', 'Get Status', false, statusResult.error);
|
|
} else {
|
|
recordResult('vm-lifecycle', 'Get Status', true, 'Status retrieved successfully');
|
|
}
|
|
|
|
// Test start/stop based on current state
|
|
if (existingStatus.toLowerCase().includes('stopped')) {
|
|
logStep('Step 4: Start VM (currently stopped)');
|
|
const startTool = vmType === 'lxc' ? 'proxmox_start_lxc' : 'proxmox_start_vm';
|
|
const startResult = await callTool(startTool, {
|
|
node: existingNode,
|
|
vmid: existingVmid
|
|
});
|
|
|
|
recordResult('vm-lifecycle', 'Start VM', startResult.success,
|
|
startResult.success ? 'VM started' : startResult.error);
|
|
|
|
if (startResult.success) {
|
|
await sleep(3000);
|
|
|
|
// Now test reboot
|
|
logStep('Step 5: Reboot VM');
|
|
const rebootTool = vmType === 'lxc' ? 'proxmox_reboot_lxc' : 'proxmox_reboot_vm';
|
|
const rebootResult = await callTool(rebootTool, {
|
|
node: existingNode,
|
|
vmid: existingVmid
|
|
});
|
|
|
|
recordResult('vm-lifecycle', 'Reboot VM', rebootResult.success,
|
|
rebootResult.success ? 'VM rebooted' : rebootResult.error);
|
|
}
|
|
} else if (existingStatus.toLowerCase().includes('running')) {
|
|
logStep('Step 4: VM already running, testing reboot');
|
|
const rebootTool = vmType === 'lxc' ? 'proxmox_reboot_lxc' : 'proxmox_reboot_vm';
|
|
const rebootResult = await callTool(rebootTool, {
|
|
node: existingNode,
|
|
vmid: existingVmid
|
|
});
|
|
|
|
recordResult('vm-lifecycle', 'Reboot VM', rebootResult.success,
|
|
rebootResult.success ? 'VM rebooted' : rebootResult.error);
|
|
}
|
|
|
|
} catch (error) {
|
|
recordResult('vm-lifecycle', 'Workflow', false, `Exception: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
async function testNetworkWorkflow() {
|
|
logSection('Network Management Workflow');
|
|
|
|
try {
|
|
// Find an existing stopped VM to test network operations
|
|
logStep('Step 1: Find stopped VM for network testing');
|
|
const vmsResult = await callTool('proxmox_get_vms');
|
|
|
|
if (!vmsResult.success) {
|
|
recordResult('network', 'Find VM', false, vmsResult.error);
|
|
return;
|
|
}
|
|
|
|
const vmMatch = vmsResult.content.match(/\(ID:\s*(\d+)\).*?[•\s]*Node:\s*(\S+).*?[•\s]*Status:\s*stopped/is);
|
|
|
|
if (!vmMatch) {
|
|
logWarning('No stopped VMs found, skipping network workflow (VM must be stopped)');
|
|
recordResult('network', 'Find VM', false, 'No stopped VMs available');
|
|
return;
|
|
}
|
|
|
|
const vmid = vmMatch[1];
|
|
const node = vmMatch[2];
|
|
|
|
logSuccess(`Using VM ${vmid} on ${node}`);
|
|
|
|
// Determine VM type
|
|
let vmType = vmsResult.content.includes('QEMU') ? 'qemu' : 'lxc';
|
|
|
|
// Step 2: Add network interface
|
|
logStep('Step 2: Add network interface', 'Bridge: vmbr0, Interface: net1');
|
|
const addTool = vmType === 'qemu' ? 'proxmox_add_network_vm' : 'proxmox_add_network_lxc';
|
|
const addResult = await callTool(addTool, {
|
|
node: node,
|
|
vmid: vmid,
|
|
net: 'net1',
|
|
bridge: 'vmbr0',
|
|
firewall: true
|
|
});
|
|
|
|
if (!addResult.success) {
|
|
recordResult('network', 'Add Interface', false, addResult.error);
|
|
return;
|
|
}
|
|
recordResult('network', 'Add Interface', true, 'Network interface net1 added');
|
|
|
|
// Step 3: Update network interface
|
|
logStep('Step 3: Update network interface', 'Add rate limit');
|
|
const updateTool = vmType === 'qemu' ? 'proxmox_update_network_vm' : 'proxmox_update_network_lxc';
|
|
const updateResult = await callTool(updateTool, {
|
|
node: node,
|
|
vmid: vmid,
|
|
net: 'net1',
|
|
rate: 100
|
|
});
|
|
|
|
recordResult('network', 'Update Interface', updateResult.success,
|
|
updateResult.success ? 'Network interface updated' : updateResult.error);
|
|
|
|
// Step 4: Remove network interface
|
|
logStep('Step 4: Remove network interface');
|
|
const removeTool = vmType === 'qemu' ? 'proxmox_remove_network_vm' : 'proxmox_remove_network_lxc';
|
|
const removeResult = await callTool(removeTool, {
|
|
node: node,
|
|
vmid: vmid,
|
|
net: 'net1'
|
|
});
|
|
|
|
recordResult('network', 'Remove Interface', removeResult.success,
|
|
removeResult.success ? 'Network interface removed' : removeResult.error);
|
|
|
|
} catch (error) {
|
|
recordResult('network', 'Workflow', false, `Exception: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
async function testDiskWorkflow() {
|
|
logSection('Disk Management Workflow');
|
|
|
|
try {
|
|
logStep('Step 1: Find VM for disk testing');
|
|
const vmsResult = await callTool('proxmox_get_vms');
|
|
|
|
if (!vmsResult.success) {
|
|
recordResult('disk', 'Find VM', false, vmsResult.error);
|
|
return;
|
|
}
|
|
|
|
// Find a QEMU VM (disk operations are more reliable on QEMU) - handles format: (ID: 100) ... • Node: pve1 ... • Type: QEMU
|
|
const vmMatch = vmsResult.content.match(/\(ID:\s*(\d+)\).*?[•\s]*Node:\s*(\S+).*?[•\s]*Type:\s*QEMU/is);
|
|
|
|
if (!vmMatch) {
|
|
logWarning('No QEMU VMs found, skipping disk workflow');
|
|
recordResult('disk', 'Find VM', false, 'No QEMU VMs available');
|
|
return;
|
|
}
|
|
|
|
const vmid = vmMatch[1];
|
|
const node = vmMatch[2];
|
|
|
|
logSuccess(`Using VM ${vmid} on ${node}`);
|
|
|
|
// Step 2: Add disk
|
|
logStep('Step 2: Add disk to VM', 'Size: 10G, Storage: local-lvm');
|
|
const addResult = await callTool('proxmox_add_disk_vm', {
|
|
node: node,
|
|
vmid: vmid,
|
|
disk: 'scsi1',
|
|
size: '10G',
|
|
storage: 'local-lvm'
|
|
});
|
|
|
|
if (!addResult.success) {
|
|
recordResult('disk', 'Add Disk', false, addResult.error);
|
|
return;
|
|
}
|
|
recordResult('disk', 'Add Disk', true, 'Disk added successfully');
|
|
|
|
// Step 3: Resize disk
|
|
logStep('Step 3: Resize disk', 'New size: +2G');
|
|
const resizeResult = await callTool('proxmox_resize_disk_vm', {
|
|
node: node,
|
|
vmid: vmid,
|
|
disk: 'scsi1',
|
|
size: '+2G'
|
|
});
|
|
|
|
recordResult('disk', 'Resize Disk', resizeResult.success,
|
|
resizeResult.success ? 'Disk resized' : resizeResult.error);
|
|
|
|
// Step 4: Remove disk (cleanup)
|
|
logStep('Step 4: Remove disk');
|
|
const removeResult = await callTool('proxmox_remove_disk_vm', {
|
|
node: node,
|
|
vmid: vmid,
|
|
disk: 'scsi1'
|
|
});
|
|
|
|
recordResult('disk', 'Remove Disk', removeResult.success,
|
|
removeResult.success ? 'Disk removed' : removeResult.error);
|
|
|
|
} catch (error) {
|
|
recordResult('disk', 'Workflow', false, `Exception: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
async function testSnapshotWorkflow() {
|
|
logSection('Snapshot Workflow');
|
|
|
|
try {
|
|
logStep('Step 1: Find VM for snapshot testing');
|
|
const vmsResult = await callTool('proxmox_get_vms');
|
|
|
|
if (!vmsResult.success) {
|
|
recordResult('snapshot', 'Find VM', false, vmsResult.error);
|
|
return;
|
|
}
|
|
|
|
const vmMatch = vmsResult.content.match(/\(ID:\s*(\d+)\).*?[•\s]*Node:\s*(\S+)/is);
|
|
|
|
if (!vmMatch) {
|
|
recordResult('snapshot', 'Find VM', false, 'No VMs found');
|
|
return;
|
|
}
|
|
|
|
const vmid = vmMatch[1];
|
|
const node = vmMatch[2];
|
|
const vmType = vmsResult.content.includes('QEMU') ? 'qemu' : 'lxc';
|
|
|
|
logSuccess(`Using ${vmType} ${vmid} on ${node}`);
|
|
|
|
const snapname = `test-snap-${Date.now()}`;
|
|
|
|
// Step 2: Create snapshot
|
|
logStep('Step 2: Create snapshot', `Name: ${snapname}`);
|
|
const createTool = vmType === 'qemu' ? 'proxmox_create_snapshot_vm' : 'proxmox_create_snapshot_lxc';
|
|
const createResult = await callTool(createTool, {
|
|
node: node,
|
|
vmid: vmid,
|
|
snapname: snapname
|
|
});
|
|
|
|
if (!createResult.success) {
|
|
recordResult('snapshot', 'Create Snapshot', false, createResult.error);
|
|
return;
|
|
}
|
|
recordResult('snapshot', 'Create Snapshot', true, `Snapshot ${snapname} created`);
|
|
await sleep(5000); // Increased wait time for Proxmox to register snapshot
|
|
|
|
// Step 3: List snapshots (with retry logic)
|
|
logStep('Step 3: List snapshots');
|
|
const listTool = vmType === 'qemu' ? 'proxmox_list_snapshots_vm' : 'proxmox_list_snapshots_lxc';
|
|
|
|
let hasSnapshot = false;
|
|
let listSuccess = false;
|
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
const listResult = await callTool(listTool, {
|
|
node: node,
|
|
vmid: vmid
|
|
});
|
|
|
|
if (!listResult.success) {
|
|
if (attempt === 3) {
|
|
recordResult('snapshot', 'List Snapshots', false, listResult.error);
|
|
}
|
|
break;
|
|
}
|
|
|
|
hasSnapshot = listResult.content.includes(snapname);
|
|
if (hasSnapshot) {
|
|
listSuccess = true;
|
|
recordResult('snapshot', 'List Snapshots', true, `Snapshot ${snapname} found`);
|
|
break;
|
|
}
|
|
|
|
if (attempt < 3) {
|
|
logWarning(`Snapshot not found yet, retrying in 2 seconds... (attempt ${attempt}/3)`);
|
|
await sleep(2000);
|
|
}
|
|
}
|
|
|
|
if (listSuccess === false && hasSnapshot === false) {
|
|
recordResult('snapshot', 'List Snapshots', false, 'Snapshot not found in list after 3 attempts');
|
|
}
|
|
|
|
// Step 4: Delete snapshot
|
|
logStep('Step 4: Delete snapshot');
|
|
const deleteTool = vmType === 'qemu' ? 'proxmox_delete_snapshot_vm' : 'proxmox_delete_snapshot_lxc';
|
|
const deleteResult = await callTool(deleteTool, {
|
|
node: node,
|
|
vmid: vmid,
|
|
snapname: snapname
|
|
});
|
|
|
|
recordResult('snapshot', 'Delete Snapshot', deleteResult.success,
|
|
deleteResult.success ? 'Snapshot deleted' : deleteResult.error);
|
|
|
|
} catch (error) {
|
|
recordResult('snapshot', 'Workflow', false, `Exception: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
async function testBackupWorkflow() {
|
|
logSection('Backup Workflow');
|
|
|
|
try {
|
|
logStep('Step 1: Find VM for backup testing');
|
|
const vmsResult = await callTool('proxmox_get_vms');
|
|
|
|
if (!vmsResult.success) {
|
|
recordResult('backup', 'Find VM', false, vmsResult.error);
|
|
return;
|
|
}
|
|
|
|
const vmMatch = vmsResult.content.match(/\(ID:\s*(\d+)\).*?[•\s]*Node:\s*(\S+)/is);
|
|
|
|
if (!vmMatch) {
|
|
recordResult('backup', 'Find VM', false, 'No VMs found');
|
|
return;
|
|
}
|
|
|
|
const vmid = vmMatch[1];
|
|
const node = vmMatch[2];
|
|
const vmType = vmsResult.content.includes('QEMU') ? 'qemu' : 'lxc';
|
|
|
|
logSuccess(`Using ${vmType} ${vmid} on ${node}`);
|
|
|
|
// Step 2: Create backup
|
|
logStep('Step 2: Create backup', 'Storage: local');
|
|
const createTool = vmType === 'qemu' ? 'proxmox_create_backup_vm' : 'proxmox_create_backup_lxc';
|
|
const createResult = await callTool(createTool, {
|
|
node: node,
|
|
vmid: vmid,
|
|
storage: 'local'
|
|
});
|
|
|
|
if (!createResult.success) {
|
|
recordResult('backup', 'Create Backup', false, createResult.error);
|
|
return;
|
|
}
|
|
recordResult('backup', 'Create Backup', true, 'Backup job started');
|
|
|
|
logWarning('Backup runs in background. Waiting 5 seconds before listing...');
|
|
await sleep(5000);
|
|
|
|
// Step 3: List backups
|
|
logStep('Step 3: List backups');
|
|
const listResult = await callTool('proxmox_list_backups', {
|
|
node: node
|
|
});
|
|
|
|
if (!listResult.success) {
|
|
recordResult('backup', 'List Backups', false, listResult.error);
|
|
} else {
|
|
recordResult('backup', 'List Backups', true, 'Backups listed successfully');
|
|
}
|
|
|
|
logWarning('Note: Backup deletion requires the backup filename from the list above');
|
|
|
|
} catch (error) {
|
|
recordResult('backup', 'Workflow', false, `Exception: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// CLEANUP
|
|
// ============================================================================
|
|
|
|
async function cleanup() {
|
|
if (config.noCleanup || config.dryRun) {
|
|
logWarning('Cleanup skipped');
|
|
return;
|
|
}
|
|
|
|
if (config.testResources.length === 0) {
|
|
log('No resources to clean up', 'dim');
|
|
return;
|
|
}
|
|
|
|
logSection('Cleanup');
|
|
|
|
for (const resource of config.testResources) {
|
|
logStep(`Deleting ${resource.type} ${resource.vmid} on ${resource.node}`);
|
|
|
|
const deleteTool = resource.type === 'lxc' ? 'proxmox_delete_lxc' : 'proxmox_delete_vm';
|
|
const deleteResult = await callTool(deleteTool, {
|
|
node: resource.node,
|
|
vmid: resource.vmid
|
|
});
|
|
|
|
if (deleteResult.success) {
|
|
logSuccess(`Deleted ${resource.type} ${resource.vmid}`);
|
|
} else {
|
|
logError(`Failed to delete ${resource.type} ${resource.vmid}: ${deleteResult.error}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// MAIN
|
|
// ============================================================================
|
|
|
|
async function main() {
|
|
console.log(`${colors.bright}${colors.magenta}`);
|
|
console.log('╔═══════════════════════════════════════════════════════════════════╗');
|
|
console.log('║ Proxmox MCP Server - Workflow Test Suite ║');
|
|
console.log('╚═══════════════════════════════════════════════════════════════════╝');
|
|
console.log(colors.reset);
|
|
|
|
// Check for elevated permissions
|
|
log('Checking environment...', 'cyan');
|
|
|
|
if (config.dryRun) {
|
|
logWarning('DRY-RUN MODE: No actual operations will be performed');
|
|
}
|
|
|
|
if (config.interactive) {
|
|
log('Interactive mode enabled', 'yellow');
|
|
}
|
|
|
|
// Get test node
|
|
log('Auto-detecting node...', 'cyan');
|
|
const nodesResult = await callTool('proxmox_get_nodes');
|
|
if (!nodesResult.success) {
|
|
logError('Failed to get nodes. Ensure .env is configured and server is accessible.');
|
|
process.exit(1);
|
|
}
|
|
|
|
// Handle node name format: 🟢 **pve1**
|
|
const nodeMatch = nodesResult.content.match(/\*\*([a-zA-Z0-9-]+)\*\*/i) ||
|
|
nodesResult.content.match(/Node:\s*(\S+)/i) ||
|
|
nodesResult.content.match(/^([a-zA-Z0-9-]+)/m);
|
|
|
|
if (!nodeMatch) {
|
|
logError('Could not detect node name');
|
|
process.exit(1);
|
|
}
|
|
|
|
config.testNode = nodeMatch[1].replace(/[^a-zA-Z0-9-]/g, '');
|
|
logSuccess(`Using node: ${config.testNode}`);
|
|
|
|
// Run workflows
|
|
const workflows = {
|
|
lxc: testLXCWorkflow,
|
|
vm: testVMLifecycleWorkflow,
|
|
network: testNetworkWorkflow,
|
|
disk: testDiskWorkflow,
|
|
snapshot: testSnapshotWorkflow,
|
|
backup: testBackupWorkflow
|
|
};
|
|
|
|
if (config.workflow === 'all') {
|
|
for (const [name, func] of Object.entries(workflows)) {
|
|
await func();
|
|
}
|
|
} else if (workflows[config.workflow]) {
|
|
await workflows[config.workflow]();
|
|
} else {
|
|
logError(`Unknown workflow: ${config.workflow}`);
|
|
log(`Available workflows: ${Object.keys(workflows).join(', ')}, all`, 'yellow');
|
|
process.exit(1);
|
|
}
|
|
|
|
// Cleanup
|
|
await cleanup();
|
|
|
|
// Print summary
|
|
logSection('Test Summary');
|
|
|
|
console.log(`${colors.green}✓ Passed: ${results.passed.length}${colors.reset}`);
|
|
results.passed.forEach(r => {
|
|
console.log(` ${colors.dim}[${r.workflow}]${colors.reset} ${r.test}`);
|
|
});
|
|
|
|
if (results.failed.length > 0) {
|
|
console.log(`\n${colors.red}✗ Failed: ${results.failed.length}${colors.reset}`);
|
|
results.failed.forEach(r => {
|
|
console.log(` ${colors.dim}[${r.workflow}]${colors.reset} ${r.test}: ${r.message}`);
|
|
});
|
|
}
|
|
|
|
if (results.skipped.length > 0) {
|
|
console.log(`\n${colors.yellow}⊘ Skipped: ${results.skipped.length}${colors.reset}`);
|
|
}
|
|
|
|
const total = results.passed.length + results.failed.length;
|
|
const passRate = total > 0 ? ((results.passed.length / total) * 100).toFixed(1) : 0;
|
|
|
|
console.log(`\n${colors.cyan}${colors.bright}Total: ${results.passed.length}/${total} passed (${passRate}%)${colors.reset}\n`);
|
|
|
|
rl.close();
|
|
process.exit(results.failed.length > 0 ? 1 : 0);
|
|
}
|
|
|
|
// Handle errors
|
|
process.on('unhandledRejection', (error) => {
|
|
logError(`Unhandled error: ${error.message}`);
|
|
console.error(error);
|
|
rl.close();
|
|
process.exit(1);
|
|
});
|
|
|
|
// Run
|
|
main().catch(error => {
|
|
logError(`Fatal error: ${error.message}`);
|
|
console.error(error);
|
|
rl.close();
|
|
process.exit(1);
|
|
});
|