Add proxmox_create_vm tool, comprehensive test suite, and documentation improvements
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)
This commit is contained in:
948
test-workflows.js
Executable file
948
test-workflows.js
Executable file
@@ -0,0 +1,948 @@
|
||||
#!/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);
|
||||
});
|
||||
Reference in New Issue
Block a user