diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index b29bffb..ef15af0 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -130,7 +130,14 @@ npm run build:frontend && npm run copy:frontend && npm run dev:backend ### Database Schema -SQLite schema defined in `backend/src/database/schema.sql`, migrations in `migrations.sql`. Key tables: +SQLite schema uses a pure migration-first approach: + +- All schema definitions are in numbered migrations: `backend/src/database/migrations/*.sql` +- Migration 000: Initial schema (executions, revoked_tokens) +- Migration 001: RBAC tables (users, roles, permissions, groups) +- Future changes: Always create a new numbered migration, never modify existing ones + +Key tables: - `executions`: Stores all command/task execution history with results - Auto-create on first run via `DatabaseService` diff --git a/.kiro/database-cleanup-prompt.md b/.kiro/database-cleanup-prompt.md new file mode 100644 index 0000000..f1a8a3e --- /dev/null +++ b/.kiro/database-cleanup-prompt.md @@ -0,0 +1,149 @@ +# Database Schema Cleanup - New Conversation Prompt + +## Task Overview + +Review and clean up the Pabawi database schema structure to eliminate duplicates, remove orphaned files, and establish a clear, maintainable approach for database schema management. + +## Background + +The project uses SQLite with a hybrid approach: base schema files + migration system. This has led to duplicate definitions and orphaned files that need cleanup. + +## Current File Structure + +``` +backend/src/database/ +├── schema.sql # Base schema: executions table +├── rbac-schema.sql # Base schema: RBAC tables (DUPLICATE of 001) +├── audit-schema.sql # ⚠️ ORPHANED - never loaded, duplicates 004 +├── migrations.sql # ⚠️ ORPHANED - never loaded, superseded +├── DatabaseService.ts # Loads schema.sql + rbac-schema.sql, then runs migrations +├── MigrationRunner.ts # Runs numbered migrations from migrations/ +└── migrations/ + ├── 001_initial_rbac.sql # Creates RBAC tables (duplicates rbac-schema.sql) + ├── 002_seed_rbac_data.sql # Seeds roles, permissions, config + ├── 003_failed_login_attempts.sql # Adds security tables + ├── 004_audit_logging.sql # Adds audit_logs (duplicates audit-schema.sql) + ├── 005_add_ssh_execution_tool.sql # Updates executions table + └── 006_add_batch_executions.sql # Adds batch support +``` + +## Key Files to Examine + +Please read these files to understand the current state: + +1. **Initialization Logic:** + - `backend/src/database/DatabaseService.ts` - How schemas are loaded + - `backend/src/database/MigrationRunner.ts` - How migrations run + +2. **Base Schemas:** + - `backend/src/database/schema.sql` - Executions table + - `backend/src/database/rbac-schema.sql` - RBAC tables + +3. **Orphaned Files (candidates for deletion):** + - `backend/src/database/audit-schema.sql` - Never loaded, duplicates migration 004 + - `backend/src/database/migrations.sql` - Never loaded, superseded by schema.sql + +4. **All Migrations:** + - `backend/src/database/migrations/001_initial_rbac.sql` through `006_add_batch_executions.sql` + +## Problems to Address + +### 1. Duplicate Definitions + +- `rbac-schema.sql` has identical content to `001_initial_rbac.sql` +- `audit-schema.sql` has identical content to `004_audit_logging.sql` +- Both use `CREATE TABLE IF NOT EXISTS` so they don't conflict, but it's confusing + +### 2. Orphaned Files + +- `audit-schema.sql` exists but is never loaded by DatabaseService.ts +- `migrations.sql` exists but is never loaded by DatabaseService.ts +- These should probably be deleted + +### 3. Unclear Pattern + +- Some tables created via base schemas (executions, RBAC) +- Other tables created via migrations (audit_logs, failed_login_attempts, batch_executions) +- No documented reason for the difference + +## Current Initialization Flow + +For a **new database**: + +1. DatabaseService loads `schema.sql` → creates executions table +2. DatabaseService loads `rbac-schema.sql` → creates RBAC tables +3. MigrationRunner runs pending migrations: + - 001: Tries to create RBAC tables (already exist, skipped due to IF NOT EXISTS) + - 002: Seeds default data + - 003: Creates failed_login_attempts tables + - 004: Tries to create audit_logs (doesn't exist yet, gets created) + - 005: Updates executions table + - 006: Creates batch_executions, updates executions + +For an **existing database**: + +1. Base schemas already applied (no-op due to IF NOT EXISTS) +2. MigrationRunner runs only new migrations since last run + +## Decision Points + +### Option A: Migration-First Approach (Clean, Standard) + +- Move ALL schema definitions to migrations +- Delete base schema files (schema.sql, rbac-schema.sql) +- Update DatabaseService to only run MigrationRunner +- Create migration 000 for initial executions table + +**Pros:** Single source of truth, standard approach, clear history +**Cons:** Requires more refactoring, slower initial setup + +### Option B: Keep Hybrid Approach (Current, Less Work) + +- Keep schema.sql and rbac-schema.sql for initial setup +- Accept that migrations 001 and 004 duplicate base schemas +- Delete only the orphaned files (audit-schema.sql, migrations.sql) +- Document the approach clearly + +**Pros:** Less refactoring, faster new DB setup +**Cons:** Duplicate definitions remain, less standard + +## Recommended Actions + +### Immediate (Safe, Low Risk) + +1. Delete `backend/src/database/audit-schema.sql` - never loaded, duplicates migration 004 +2. Delete `backend/src/database/migrations.sql` - never loaded, superseded +3. Add comments to DatabaseService.ts explaining the hybrid approach +4. Add comments to schema files noting their relationship to migrations + +### Optional (Requires Decision) + +1. Choose between Option A (migration-first) or Option B (hybrid) +2. If Option A: Refactor to pure migration approach +3. If Option B: Document and accept the hybrid approach +4. Update developer documentation with schema change policy + +## Testing Requirements + +After any changes: + +- ✅ Test fresh database initialization (no existing pabawi.db) +- ✅ Test migration from previous versions +- ✅ Verify all tables created correctly +- ✅ Run existing database tests +- ✅ Test Docker deployment with clean database +- ✅ Verify setup wizard works + +## Questions to Answer + +1. **Which approach should we use going forward?** Migration-first +2. **Are the orphaned files safe to delete?** Check for any hidden reference YES +3. **Should we keep duplicate migrations?** (001 and 004 duplicate base schemas) NO +4. **What's the policy for future changes?** Always use migrations? YES + +## Success Criteria + +- ✅ No orphaned files in the database directory +- ✅ Clear documentation of the schema management approach +- ✅ All tests pass +- ✅ Docker deployment works with cl diff --git a/.kiro/todo/batch-execution-missing-action-execution.md b/.kiro/done/batch-execution-missing-action-execution.md similarity index 100% rename from .kiro/todo/batch-execution-missing-action-execution.md rename to .kiro/done/batch-execution-missing-action-execution.md diff --git a/.kiro/done/database-schema-cleanup-task.md b/.kiro/done/database-schema-cleanup-task.md new file mode 100644 index 0000000..6e3c114 --- /dev/null +++ b/.kiro/done/database-schema-cleanup-task.md @@ -0,0 +1,275 @@ +# Database Schema Cleanup and Consolidation Task + +## Status: ✅ COMPLETED - Migration-First Approach + +All base schema files have been deleted and converted to migrations. The database now uses a pure migration-first approach. + +## Context + +The Pabawi project has database schema definitions in multiple places with overlapping content, creating maintenance issues and potential inconsistencies. This needs to be reviewed and cleaned up to follow a clear migration-based approach. + +## Current State Analysis + +### Files in `backend/src/database/` + +1. **schema.sql** - Base schema for executions table and revoked_tokens + - Contains: executions table, indexes, revoked_tokens table + - Used by: DatabaseService.ts (loaded first) + - Status: ✅ Currently used + +2. **rbac-schema.sql** - RBAC tables (users, roles, permissions, groups) + - Contains: Complete RBAC system tables and indexes + - Used by: DatabaseService.ts (loaded second) + - Status: ✅ Currently used + - **DUPLICATE**: Identical content exists in `migrations/001_initial_rbac.sql` + +3. **audit-schema.sql** - Audit logging tables + - Contains: audit_logs table and indexes + - Used by: NOT referenced in DatabaseService.ts + - Status: ⚠️ NOT LOADED - appears to be orphaned + - **DUPLICATE**: Identical content exists in `migrations/004_audit_logging.sql` + +4. **migrations.sql** - Legacy migration file with ALTER TABLE statements + - Contains: Column additions to executions table (command, expert_mode, original_execution_id, re_execution_count, stdout, stderr, execution_tool) + - Used by: NOT referenced in DatabaseService.ts + - Status: ⚠️ NOT LOADED - appears to be orphaned + - **SUPERSEDED**: These changes are now in schema.sql and handled by structured migrations + +### Files in `backend/src/database/migrations/` + +1. **001_initial_rbac.sql** - Creates RBAC tables + - Identical to rbac-schema.sql + - Properly tracked by MigrationRunner + +2. **002_seed_rbac_data.sql** - Seeds default roles, permissions, config + - Inserts default data (roles, permissions, config) + - Properly tracked by MigrationRunner + +3. **003_failed_login_attempts.sql** - Adds security tables + - Creates failed_login_attempts and account_lockouts tables + - Properly tracked by MigrationRunner + +4. **004_audit_logging.sql** - Adds audit logging + - Identical to audit-schema.sql + - Properly tracked by MigrationRunner + +5. **005_add_ssh_execution_tool.sql** - Updates executions table + - Recreates executions table with SSH support + - Properly tracked by MigrationRunner + +6. **006_add_batch_executions.sql** - Adds batch execution support + - Creates batch_executions table + - Adds batch_id and batch_position to executions + - Properly tracked by MigrationRunner + +## Current Database Initialization Flow + +From `DatabaseService.ts`: + +```typescript +1. Load and execute schema.sql (executions table) +2. Load and execute rbac-schema.sql (RBAC tables) +3. Run MigrationRunner.runPendingMigrations() + - Checks migrations table for applied migrations + - Runs any pending migrations from migrations/ directory +``` + +## Problems Identified + +### 1. Duplicate Definitions + +- `rbac-schema.sql` duplicates `001_initial_rbac.sql` +- `audit-schema.sql` duplicates `004_audit_logging.sql` +- Both base schemas AND migrations create the same tables + +### 2. Orphaned Files + +- `audit-schema.sql` is never loaded by DatabaseService +- `migrations.sql` is never loaded by DatabaseService +- These files exist but serve no purpose + +### 3. Inconsistent Approach + +- Some tables created via base schema files (executions, RBAC) +- Other tables created via migrations (audit_logs, failed_login_attempts, batch_executions) +- No clear pattern for when to use which approach + +### 4. Migration Confusion + +- For new databases: Base schemas create tables, then migrations run (but tables already exist due to CREATE IF NOT EXISTS) +- For existing databases: Migrations properly add new tables/columns +- This works but is confusing and error-prone + +## Recommended Approach + +### Option A: Migration-First (Recommended) + +Move all schema definitions to migrations, use base schemas only for the absolute minimum. + +**Pros:** + +- Single source of truth for all schema changes +- Clear history of database evolution +- Standard approach used by most frameworks +- Easy to understand and maintain + +**Cons:** + +- Requires refactoring existing code +- Need to ensure migration 001 creates ALL initial tables + +### Option B: Base Schema + Migrations (Current Hybrid) + +Keep base schemas for initial tables, use migrations only for changes. + +**Pros:** + +- Less refactoring needed +- Faster initial setup (no migration runner needed for new DBs) + +**Cons:** + +- Duplicate definitions between base schemas and migrations +- Confusing which file is the source of truth +- Current state has orphaned files + +## Recommended Actions + +### Phase 1: Immediate Cleanup (Remove Duplicates) + +1. **Delete orphaned files:** + - Delete `backend/src/database/audit-schema.sql` (duplicates migration 004) + - Delete `backend/src/database/migrations.sql` (superseded by schema.sql + migrations) + +2. **Update DatabaseService.ts:** + - Remove code that tries to load audit-schema.sql (if any) + - Verify rbac-schema.sql loading is still needed + +3. **Document the approach:** + - Add comments explaining why rbac-schema.sql exists alongside 001_initial_rbac.sql + - Clarify that base schemas are for new installations, migrations for upgrades + +### Phase 2: Long-term Consolidation (Optional) + +Choose between Option A or Option B and implement consistently: + +**If choosing Option A (Migration-First):** + +1. Create migration 000_initial_schema.sql with executions table +2. Ensure 001_initial_rbac.sql is complete +3. Remove schema.sql and rbac-schema.sql +4. Update DatabaseService to only run migrations +5. Update tests to use migration-based setup + +**If choosing Option B (Keep Current Hybrid):** + +1. Keep schema.sql and rbac-schema.sql as base schemas +2. Accept that migrations 001 and 004 duplicate base schemas +3. Document that migrations use CREATE IF NOT EXISTS for idempotency +4. Ensure all future changes go through migrations only + +## Files to Review + +- `pabawi/backend/src/database/DatabaseService.ts` - Initialization logic +- `pabawi/backend/src/database/MigrationRunner.ts` - Migration execution +- `pabawi/backend/src/database/schema.sql` - Base executions schema +- `pabawi/backend/src/database/rbac-schema.sql` - Base RBAC schema +- `pabawi/backend/src/database/audit-schema.sql` - ⚠️ Orphaned, should delete +- `pabawi/backend/src/database/migrations.sql` - ⚠️ Orphaned, should delete +- `pabawi/backend/src/database/migrations/*.sql` - All migration files +- `pabawi/Dockerfile` - Now copies entire database/ directory + +## Testing Requirements + +After cleanup: + +1. Test fresh database initialization (no existing DB) +2. Test migration from each previous version +3. Verify all tables are created correctly +4. Run existing database tests +5. Test Docker deployment with clean database + +## Questions to Answer + +1. Should we keep the hybrid approach or move to migration-first? +2. Are there any other references to the orphaned files? +3. Should migrations 001 and 004 be kept even though they duplicate base schemas? +4. What's the policy for future schema changes - always use migrations? + +## Related Issues + +- Docker deployment bug (fixed) - Missing schema files in Docker image +- Database initialization on clean setup + +--- + +## Final Completion Summary (March 11, 2026) - Migration-First Approach + +### Actions Taken + +1. **Deleted ALL base schema files:** + - ✅ `backend/src/database/schema.sql` - Converted to migration 000 + - ✅ `backend/src/database/rbac-schema.sql` - Already in migration 001 + - ✅ `backend/src/database/audit-schema.sql` - Already in migration 004 (orphaned) + - ✅ `backend/src/database/migrations.sql` - Orphaned, superseded + +2. **Created migration 000:** + - ✅ `migrations/000_initial_schema.sql` - Contains executions and revoked_tokens tables + - This is now the first migration that runs on a fresh database + +3. **Refactored DatabaseService.ts:** + - ✅ Removed all base schema loading code + - ✅ Removed unused `exec()` method + - ✅ Removed unused imports (readFileSync, join) + - ✅ Now only runs migrations via MigrationRunner + - ✅ Added comprehensive documentation explaining migration-first policy + +4. **Updated build configuration:** + - ✅ Modified `backend/package.json` build script + - Now only copies `migrations/` directory (no base schemas) + +5. **Updated all documentation:** + - ✅ `docs/development/BACKEND_CODE_ANALYSIS.md` - Migration-first approach + - ✅ `.github/copilot-instructions.md` - Migration-first approach + - ✅ `CLAUDE.md` - Migration-first approach + +### Final State - Pure Migration-First + +**Schema Management Policy:** + +- ALL schema definitions are in numbered migrations (000, 001, 002, ...) +- Migration 000: Initial schema (executions, revoked_tokens) +- Migration 001: RBAC tables (users, roles, permissions, groups) +- Migration 002: Seeds RBAC data +- Migration 003: Failed login attempts +- Migration 004: Audit logging +- Migration 005: SSH execution tool +- Migration 006: Batch executions +- Future changes: Always create a new numbered migration +- Never modify existing migrations after they've been applied + +**Files:** + +- ✅ `migrations/000_initial_schema.sql` through `006_add_batch_executions.sql` +- ✅ No base schema files +- ✅ No duplicate definitions +- ✅ Single source of truth: migrations directory + +**Testing:** + +- ✅ Build passes successfully +- ✅ TypeScript compilation clean +- ✅ No unused code or imports + +### Benefits of Migration-First Approach + +1. **Single source of truth** - All schema in one place (migrations/) +2. **Clear history** - Every change is tracked and numbered +3. **No duplicates** - Eliminated all duplicate table definitions +4. **Standard practice** - Follows industry-standard migration patterns +5. **Easy rollback** - Can track exactly what changed and when +6. **Clean codebase** - Simpler DatabaseService with less code + +### Next Steps + +None required. The migration-first approach is fully implemented and documented. diff --git a/.kiro/todo/default-user-permissions-fix.md b/.kiro/done/default-user-permissions-fix.md similarity index 100% rename from .kiro/todo/default-user-permissions-fix.md rename to .kiro/done/default-user-permissions-fix.md diff --git a/.kiro/done/docker-missing-schema-files.md b/.kiro/done/docker-missing-schema-files.md new file mode 100644 index 0000000..e1c8d52 --- /dev/null +++ b/.kiro/done/docker-missing-schema-files.md @@ -0,0 +1,49 @@ +# Bug: Docker Image Missing Database Schema Files + +## Issue + +On clean Docker setup using 0.8.0 image, the application fails to start with database errors: + +``` +ERROR [SetupService] [isSetupComplete] Failed to check setup status +Error: SQLITE_ERROR: no such table: users +``` + +Users see login page with 500 backend errors. + +## Root Cause + +The Dockerfile was only copying `schema.sql` but not the other required database files: + +- `rbac-schema.sql` (contains users, roles, permissions tables) +- `audit-schema.sql` (contains audit logging tables) +- `migrations/` directory (contains database migrations) + +TypeScript compiler only copies `.ts` files to `dist/`, so SQL files and migrations must be explicitly copied. + +## Fix Applied + +Updated Dockerfile to copy the entire database directory structure: + +```dockerfile +# Copy database directory with all SQL files and migrations (not copied by TypeScript compiler) +# This ensures schema files, migrations, and any future database-related files are included +COPY --from=backend-builder --chown=pabawi:pabawi /app/backend/src/database/ ./dist/database/ +``` + +This approach is future-proof - any new schema files or migrations added to `src/database/` will automatically be included in the Docker image. + +## Testing Required + +1. Rebuild Docker image with the fix: `docker build -t pabawi:0.8.0-fixed .` +2. Test clean deployment (no existing database) +3. Verify setup page loads correctly +4. Verify admin user creation works +5. Verify login functionality +6. Check that all migrations run successfully + +## Related Files + +- `pabawi/Dockerfile` +- `pabawi/backend/src/database/DatabaseService.ts` +- `pabawi/backend/src/database/` (entire directory now copied) diff --git a/.kiro/done/node-linking-redesign.md b/.kiro/done/node-linking-redesign.md new file mode 100644 index 0000000..3018f8d --- /dev/null +++ b/.kiro/done/node-linking-redesign.md @@ -0,0 +1,103 @@ +# Node Linking Redesign - IMPLEMENTED + +## Problem + +Current implementation tries to merge nodes into a single object with one ID, causing: + +- ManageTab can't find Proxmox nodes (wrong ID format) +- Puppet reports can't find nodes (looking for Proxmox ID instead of hostname) +- Complex logic trying to prioritize which ID to use + +## Solution Implemented + +### Backend Changes + +1. **Updated `LinkedNode` interface** to include `sourceData`: + + ```typescript + interface LinkedNode extends Node { + sources: string[]; + linked: boolean; + sourceData: Record; // NEW + } + + interface SourceNodeData { + id: string; + uri: string; + config?: Record; + metadata?: Record; + status?: string; + } + ``` + +2. **Simplified `NodeLinkingService.linkNodes()`**: + - Uses node `name` as the primary ID (common identifier across sources) + - Stores source-specific data in `sourceData` object + - Each source keeps its original ID, URI, metadata, etc. + +3. **Simplified `IntegrationManager.deduplicateNodes()`**: + - Now just calls `linkNodes()` directly + - No more complex priority-based merging + - Source data is already correctly organized + +### How It Works + +When nodes from different sources are linked: + +```typescript +// Input nodes: +// - Bolt: id="debian13.test.example42.com", name="debian13.test.example42.com" +// - Proxmox: id="proxmox:minis:100", name="debian13.test.example42.com" +// - PuppetDB: id="debian13.test.example42.com", name="debian13.test.example42.com" + +// Output linked node: +{ + id: "debian13.test.example42.com", // Primary ID (name) + name: "debian13.test.example42.com", + sources: ["bolt", "proxmox", "puppetdb"], + linked: true, + sourceData: { + bolt: { + id: "debian13.test.example42.com", + uri: "ssh://debian13.test.example42.com" + }, + proxmox: { + id: "proxmox:minis:100", + uri: "proxmox://minis/100", + metadata: { vmid: 100, node: "minis", type: "qemu", status: "running" } + }, + puppetdb: { + id: "debian13.test.example42.com", + uri: "ssh://debian13.test.example42.com" + } + } +} +``` + +### Frontend Usage (Next Step) + +Components should use source-specific data: + +```typescript +// ManageTab - use Proxmox ID +if (node.sourceData?.proxmox) { + await executeNodeAction(node.sourceData.proxmox.id, action); +} + +// Puppet Reports - use PuppetDB ID +if (node.sourceData?.puppetdb) { + const reports = await fetchReports(node.sourceData.puppetdb.id); +} +``` + +## Test Results + +✅ All tests pass (12/12) +✅ New test added: "should store source-specific data for each source" + +## Next Steps + +1. Update frontend components to use `sourceData` +2. Update API endpoints to search by any source ID or name +3. Update ManageTab to use `node.sourceData.proxmox.id` +4. Update Puppet components to use `node.sourceData.puppetdb.id` diff --git a/.kiro/done/provisioning-endpoint-fix.md b/.kiro/done/provisioning-endpoint-fix.md new file mode 100644 index 0000000..ef6f54e --- /dev/null +++ b/.kiro/done/provisioning-endpoint-fix.md @@ -0,0 +1,38 @@ +# Provisioning Endpoint Fix + +## Issue + +The frontend was calling `/api/integrations/provisioning` but this endpoint didn't exist in the backend, causing a JSON parse error: "Unexpected token '<', "; +} + +// Proxmox VM creation +POST /api/integrations/proxmox/provision/vm +Body: VMCreateParams +Response: { taskId: string; vmid: number; } + +// Proxmox LXC creation +POST /api/integrations/proxmox/provision/lxc +Body: LXCCreateParams +Response: { taskId: string; vmid: number; } + +// Node lifecycle actions +POST /api/integrations/proxmox/nodes/:nodeId/action +Body: { action: string; parameters?: Record } +Response: { taskId: string; status: string; } + +// Node destruction +DELETE /api/integrations/proxmox/nodes/:nodeId +Response: { taskId: string; status: string; } + +// Integration configuration +PUT /api/integrations/proxmox/config +Body: ProxmoxConfig +Response: { success: boolean; } + +// Connection test +POST /api/integrations/proxmox/test +Body: ProxmoxConfig +Response: { success: boolean; message: string; } +``` + +## Components and Interfaces + +### Core Types + +```typescript +// Integration capability metadata +interface ProvisioningCapability { + name: string; + description: string; + operation: 'create' | 'destroy'; + parameters: CapabilityParameter[]; +} + +interface CapabilityParameter { + name: string; + type: 'string' | 'number' | 'boolean' | 'object' | 'array'; + required: boolean; + description?: string; + default?: unknown; + validation?: { + min?: number; + max?: number; + pattern?: string; + enum?: string[]; + }; +} + +// Integration metadata +interface ProvisioningIntegration { + name: string; + displayName: string; + type: 'virtualization' | 'cloud' | 'container'; + status: 'connected' | 'degraded' | 'not_configured'; + capabilities: ProvisioningCapability[]; +} + +// Proxmox-specific types +interface ProxmoxVMParams { + vmid: number; + name: string; + node: string; + cores?: number; + memory?: number; + sockets?: number; + cpu?: string; + scsi0?: string; + ide2?: string; + net0?: string; + ostype?: string; +} + +interface ProxmoxLXCParams { + vmid: number; + hostname: string; + node: string; + ostemplate: string; + cores?: number; + memory?: number; + rootfs?: string; + net0?: string; + password?: string; +} + +// Lifecycle action types +interface LifecycleAction { + name: string; + displayName: string; + description: string; + requiresConfirmation: boolean; + destructive: boolean; + availableWhen: string[]; // Node states when action is available +} + +// Operation result +interface ProvisioningResult { + success: boolean; + taskId?: string; + vmid?: number; + nodeId?: string; + message: string; + error?: string; +} +``` + +### ProvisionPage Component + +**Purpose**: Main page for creating new VMs and containers + +**State Management**: + +```typescript +let integrations = $state([]); +let selectedIntegration = $state('proxmox'); +let loading = $state(true); +let error = $state(null); +``` + +**Key Functions**: + +- `fetchIntegrations()`: Load available provisioning integrations +- `selectIntegration(name: string)`: Switch between integrations +- `handleProvisionSuccess(result: ProvisioningResult)`: Navigate to new node + +**Routing**: `/provision` + +**RBAC**: Hidden from navigation if user lacks provisioning permissions + +### ProxmoxProvisionForm Component + +**Purpose**: Tabbed interface for VM and LXC creation + +**State Management**: + +```typescript +let activeTab = $state<'vm' | 'lxc'>('vm'); +let formData = $state({}); +let validationErrors = $state>({}); +let submitting = $state(false); +``` + +**Key Functions**: + +- `validateForm()`: Client-side validation before submission +- `submitForm()`: POST to provisioning endpoint +- `resetForm()`: Clear form after successful submission + +**Validation Rules**: + +- VMID: Required, positive integer, unique +- Name/Hostname: Required, alphanumeric with hyphens +- Node: Required, must be valid Proxmox node +- Memory: Optional, minimum 512MB +- Cores: Optional, minimum 1 + +### ManageTab Component + +**Purpose**: Lifecycle actions on node detail page + +**State Management**: + +```typescript +let availableActions = $state([]); +let nodeStatus = $state('unknown'); +let actionInProgress = $state(null); +let confirmDialog = $state<{ action: string; open: boolean }>({ action: '', open: false }); +``` + +**Key Functions**: + +- `fetchAvailableActions()`: Query backend for permitted actions +- `executeAction(action: string)`: Perform lifecycle operation +- `confirmDestructiveAction(action: string)`: Show confirmation dialog +- `pollActionStatus(taskId: string)`: Monitor operation completion + +**Action Availability Logic**: + +```typescript +const actionAvailability = { + start: ['stopped'], + stop: ['running'], + shutdown: ['running'], + reboot: ['running'], + suspend: ['running'], + resume: ['suspended'], + destroy: ['stopped', 'running', 'suspended'] +}; +``` + +### ProxmoxSetupGuide Component + +**Purpose**: Configuration form for Proxmox integration + +**State Management**: + +```typescript +let config = $state({ + host: '', + port: 8006, + username: '', + password: '', + realm: 'pam', + ssl: { rejectUnauthorized: true } +}); +let testResult = $state<{ success: boolean; message: string } | null>(null); +let saving = $state(false); +``` + +**Key Functions**: + +- `testConnection()`: Verify Proxmox connectivity +- `saveConfiguration()`: Persist config to backend +- `validateConfig()`: Client-side validation + +**Validation Rules**: + +- Host: Required, valid hostname or IP +- Port: Required, 1-65535 +- Authentication: Either (username + password + realm) OR token +- SSL: Warning if rejectUnauthorized is false + +### Form Validation Utilities + +**Purpose**: Reusable validation functions + +```typescript +// lib/validation.ts +export function validateVMID(vmid: number): string | null { + if (!vmid || vmid < 100 || vmid > 999999999) { + return 'VMID must be between 100 and 999999999'; + } + return null; +} + +export function validateHostname(hostname: string): string | null { + const pattern = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/; + if (!pattern.test(hostname)) { + return 'Hostname must contain only lowercase letters, numbers, and hyphens'; + } + return null; +} + +export function validateMemory(memory: number): string | null { + if (memory < 512) { + return 'Memory must be at least 512 MB'; + } + return null; +} + +export function validateRequired(value: unknown, fieldName: string): string | null { + if (value === null || value === undefined || value === '') { + return `${fieldName} is required`; + } + return null; +} +``` + +## Data Models + +### Frontend State Models + +```typescript +// Provisioning state +interface ProvisioningState { + integrations: ProvisioningIntegration[]; + selectedIntegration: string | null; + loading: boolean; + error: string | null; +} + +// Form state +interface FormState { + data: T; + errors: Record; + touched: Record; + submitting: boolean; + submitError: string | null; +} + +// Action state +interface ActionState { + availableActions: LifecycleAction[]; + executingAction: string | null; + lastResult: ProvisioningResult | null; +} +``` + +### API Response Models + +```typescript +// Integration list response +interface IntegrationListResponse { + integrations: ProvisioningIntegration[]; + _debug?: DebugInfo; +} + +// Provisioning response +interface ProvisioningResponse { + success: boolean; + taskId: string; + vmid?: number; + nodeId?: string; + message: string; + _debug?: DebugInfo; +} + +// Action response +interface ActionResponse { + success: boolean; + taskId: string; + status: string; + message: string; + _debug?: DebugInfo; +} + +// Configuration response +interface ConfigResponse { + success: boolean; + message: string; + _debug?: DebugInfo; +} +``` + +### Navigation Updates + +```typescript +// Add to Router.svelte routes +const routes = { + '/': HomePage, + '/provision': { component: ProvisionPage, requiresAuth: true }, + '/nodes/:id': NodeDetailPage, + '/setup/:integration': IntegrationSetupPage, + // ... existing routes +}; +``` + +### Navigation Component Updates + +```typescript +// Add to Navigation.svelte +{#if authManager.isAuthenticated && hasProvisioningPermission} + + + Provision + +{/if} +``` + +## Data Models (continued) + +### Permission Model + +```typescript +// User permissions (from auth context) +interface UserPermissions { + canProvision: boolean; + canManageVMs: boolean; + canDestroyVMs: boolean; + allowedIntegrations: string[]; + allowedActions: string[]; +} + +// Permission check utility +function hasPermission(action: string, integration: string): boolean { + const permissions = authManager.user?.permissions; + if (!permissions) return false; + + return permissions.allowedActions.includes(action) && + permissions.allowedIntegrations.includes(integration); +} +``` + +## Correctness Properties + +*A property is a characteristic or behavior that should hold true across all valid executions of a system—essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.* + +### Property Reflection + +After analyzing all acceptance criteria, I identified the following redundancies: + +- Properties 6.4-6.7 cover action execution, success handling, error handling, and loading states for all lifecycle actions +- Properties 11.2-11.5 all test form validation for different field types and can be combined into comprehensive validation properties +- Properties 12.1-12.7 cover error and success notification patterns that apply across all operations +- Multiple "example" tests for VM and LXC operations follow the same patterns and can be consolidated + +The properties below represent the unique, non-redundant correctness guarantees for this feature. + +### Property 1: Integration Discovery and Display + +*For any* list of provisioning integrations returned by the backend, the Provision page should display all integrations that have at least one provisioning capability, and hide integrations with zero capabilities. + +**Validates: Requirements 1.4, 2.2, 2.3** + +### Property 2: Permission-Based UI Visibility + +*For any* UI element (menu item, button, form, tab) that requires a specific permission, if the current user lacks that permission, the element should not be rendered in the DOM. + +**Validates: Requirements 1.3, 5.4, 9.2, 9.3** + +### Property 3: Action Button Availability + +*For any* lifecycle action and node state combination, action buttons should only be displayed when the node's current state matches one of the action's `availableWhen` states. + +**Validates: Requirements 6.1, 6.2, 6.3** + +### Property 4: Action Execution Triggers API Call + +*For any* lifecycle action button that is clicked, the frontend should send an API request to the integration endpoint with the correct action name and node identifier. + +**Validates: Requirements 6.4** + +### Property 5: Successful Action Handling + +*For any* action that completes successfully, the frontend should display a success notification containing relevant details and refresh the node status data. + +**Validates: Requirements 6.5, 12.5** + +### Property 6: Failed Action Error Display + +*For any* action that fails, the frontend should display an error notification containing the error message from the backend response. + +**Validates: Requirements 6.6, 12.1, 12.2** + +### Property 7: Loading State During Actions + +*For any* action that is in progress, all action buttons should be disabled and a loading indicator should be visible until the action completes or fails. + +**Validates: Requirements 3.6, 4.6, 6.7** + +### Property 8: Form Validation Completeness + +*For any* form field with validation rules (required, format, range, length), submitting the form with invalid data should display an error message for that field and prevent submission. + +**Validates: Requirements 11.1, 11.2, 11.3, 11.4, 11.5** + +### Property 9: Valid Form Enables Submission + +*For any* form where all fields pass their validation rules, the submit button should be enabled and submission should be allowed. + +**Validates: Requirements 11.6** + +### Property 10: Configuration Validation + +*For any* Proxmox configuration form submission, all required fields (host, port, authentication) must be validated before the configuration is sent to the backend. + +**Validates: Requirements 10.3** + +### Property 11: Error Notification Persistence + +*For any* error notification displayed to the user, it should remain visible until explicitly dismissed by the user (no auto-dismiss). + +**Validates: Requirements 12.7** + +### Property 12: Success Notification Auto-Dismiss + +*For any* success notification displayed to the user, it should automatically dismiss after exactly 5 seconds. + +**Validates: Requirements 12.6** + +### Property 13: Error Details Expandability + +*For any* error response that includes additional details beyond the main message, those details should be available in an expandable section of the error notification. + +**Validates: Requirements 12.3** + +### Property 14: Error Logging + +*For any* error that occurs (API failure, validation error, unexpected exception), the error should be logged to the browser console with sufficient context for debugging. + +**Validates: Requirements 12.4** + +### Property 15: Dynamic Form Generation + +*For any* integration capability with parameter metadata, the frontend should generate form fields matching the parameter types, validation rules, and default values specified in the metadata. + +**Validates: Requirements 13.1, 13.4** + +### Property 16: Integration Extensibility + +*For any* new provisioning integration added to the backend with valid capability metadata, the frontend should automatically discover it on the next page load and render appropriate UI without code changes. + +**Validates: Requirements 13.3** + +### Property 17: Dynamic Action Rendering + +*For any* set of lifecycle capabilities returned by the backend for a node, the Manage tab should render action buttons based on the capability metadata rather than hardcoded action names. + +**Validates: Requirements 13.5** + +## Error Handling + +### Error Categories + +The frontend will handle these error categories: + +1. **Network Errors**: Connection failures, timeouts +2. **Authentication Errors**: 401 responses, expired tokens +3. **Authorization Errors**: 403 responses, insufficient permissions +4. **Validation Errors**: 400 responses, invalid input +5. **Not Found Errors**: 404 responses, missing resources +6. **Server Errors**: 500+ responses, backend failures + +### Error Handling Strategy + +```typescript +// Centralized error handler +function handleApiError(error: unknown, context: string): void { + // Log to console for debugging + logger.error(context, 'API error', error); + + // Extract error message + const message = error instanceof Error ? error.message : 'Unknown error'; + + // Categorize and display appropriate notification + if (message.includes('401') || message.includes('unauthorized')) { + showError('Authentication required', 'Please log in and try again'); + router.navigate('/login'); + } else if (message.includes('403') || message.includes('permission')) { + showError('Permission denied', 'You do not have permission for this action'); + } else if (message.includes('404')) { + showError('Not found', 'The requested resource does not exist'); + } else if (message.includes('timeout')) { + showError('Request timed out', 'The operation took too long. Please try again'); + } else { + showError('Operation failed', message); + } +} +``` + +### Form Validation Errors + +```typescript +// Validation error display +interface ValidationResult { + valid: boolean; + errors: Record; +} + +function validateForm(data: Record, rules: ValidationRules): ValidationResult { + const errors: Record = {}; + + for (const [field, rule] of Object.entries(rules)) { + const value = data[field]; + + // Required validation + if (rule.required && !value) { + errors[field] = `${rule.label} is required`; + continue; + } + + // Type-specific validation + if (value) { + if (rule.type === 'number') { + const num = Number(value); + if (isNaN(num)) { + errors[field] = `${rule.label} must be a number`; + } else if (rule.min !== undefined && num < rule.min) { + errors[field] = `${rule.label} must be at least ${rule.min}`; + } else if (rule.max !== undefined && num > rule.max) { + errors[field] = `${rule.label} must be at most ${rule.max}`; + } + } else if (rule.type === 'string') { + const str = String(value); + if (rule.minLength && str.length < rule.minLength) { + errors[field] = `${rule.label} must be at least ${rule.minLength} characters`; + } else if (rule.maxLength && str.length > rule.maxLength) { + errors[field] = `${rule.label} must be at most ${rule.maxLength} characters`; + } else if (rule.pattern && !rule.pattern.test(str)) { + errors[field] = rule.patternMessage || `${rule.label} format is invalid`; + } + } + } + } + + return { + valid: Object.keys(errors).length === 0, + errors + }; +} +``` + +### Retry Logic + +The existing `api.ts` module provides retry logic for transient failures. Provisioning operations will use custom retry settings: + +```typescript +// Provisioning operations - no retries (user-initiated) +await post('/api/integrations/proxmox/provision/vm', params, { + maxRetries: 0, + showRetryNotifications: false +}); + +// Status queries - retry with backoff +await get('/api/integrations/provisioning', { + maxRetries: 2, + retryDelay: 1000 +}); +``` + +### User Feedback + +All operations provide immediate feedback: + +1. **Loading States**: Spinners, disabled buttons, progress indicators +2. **Success Messages**: Toast notifications with action details +3. **Error Messages**: Toast notifications with actionable guidance +4. **Validation Feedback**: Inline error messages below form fields +5. **Confirmation Dialogs**: For destructive actions (destroy VM/LXC) + +## Testing Strategy + +### Dual Testing Approach + +This feature will use both unit tests and property-based tests for comprehensive coverage: + +- **Unit Tests**: Verify specific examples, edge cases, and integration points +- **Property Tests**: Verify universal properties across all inputs + +### Unit Testing Focus + +Unit tests will cover: + +1. **Component Rendering**: Specific UI elements render correctly +2. **User Interactions**: Click handlers, form submissions, navigation +3. **Edge Cases**: Empty states, loading states, error states +4. **Integration Points**: API client calls, router navigation, auth checks +5. **Specific Examples**: VM creation with valid data, LXC destruction flow + +Example unit tests: + +```typescript +// Component rendering +test('ProvisionPage displays Proxmox integration when available', async () => { + const mockIntegrations = [{ name: 'proxmox', capabilities: [...] }]; + // Mock API response and verify rendering +}); + +// User interaction +test('clicking Start button calls executeAction with correct parameters', async () => { + const mockExecuteAction = vi.fn(); + // Render component, click button, verify API call +}); + +// Edge case +test('ManageTab shows "no actions available" when user has no permissions', () => { + // Render with empty permissions, verify message +}); +``` + +### Property-Based Testing Configuration + +**Library**: fast-check (JavaScript/TypeScript property-based testing) + +**Configuration**: + +- Minimum 100 iterations per property test +- Each test tagged with feature name and property reference +- Custom generators for domain types (integrations, capabilities, permissions) + +**Tag Format**: `Feature: proxmox-frontend-ui, Property {number}: {property_text}` + +Example property tests: + +```typescript +import fc from 'fast-check'; + +// Property 1: Integration Discovery and Display +test('Feature: proxmox-frontend-ui, Property 1: displays integrations with capabilities', () => { + fc.assert( + fc.property( + fc.array(integrationArbitrary()), + (integrations) => { + const displayed = filterDisplayableIntegrations(integrations); + const expected = integrations.filter(i => i.capabilities.length > 0); + expect(displayed).toEqual(expected); + } + ), + { numRuns: 100 } + ); +}); + +// Property 8: Form Validation Completeness +test('Feature: proxmox-frontend-ui, Property 8: invalid fields prevent submission', () => { + fc.assert( + fc.property( + fc.record({ + vmid: fc.integer({ min: -1000, max: 1000000000 }), + name: fc.string(), + memory: fc.integer({ min: 0, max: 100000 }) + }), + (formData) => { + const result = validateVMForm(formData); + const hasInvalidVMID = formData.vmid < 100 || formData.vmid > 999999999; + const hasInvalidMemory = formData.memory < 512; + const hasInvalidName = !formData.name || formData.name.length === 0; + + if (hasInvalidVMID || hasInvalidMemory || hasInvalidName) { + expect(result.valid).toBe(false); + expect(Object.keys(result.errors).length).toBeGreaterThan(0); + } + } + ), + { numRuns: 100 } + ); +}); + +// Property 15: Dynamic Form Generation +test('Feature: proxmox-frontend-ui, Property 15: generates fields from metadata', () => { + fc.assert( + fc.property( + fc.array(capabilityParameterArbitrary()), + (parameters) => { + const fields = generateFormFields(parameters); + expect(fields.length).toBe(parameters.length); + + parameters.forEach((param, index) => { + expect(fields[index].name).toBe(param.name); + expect(fields[index].type).toBe(param.type); + expect(fields[index].required).toBe(param.required); + }); + } + ), + { numRuns: 100 } + ); +}); +``` + +### Custom Generators + +```typescript +// Generator for provisioning integrations +function integrationArbitrary(): fc.Arbitrary { + return fc.record({ + name: fc.constantFrom('proxmox', 'ec2', 'azure', 'terraform'), + displayName: fc.string(), + type: fc.constantFrom('virtualization', 'cloud', 'container'), + status: fc.constantFrom('connected', 'degraded', 'not_configured'), + capabilities: fc.array(capabilityArbitrary(), { minLength: 0, maxLength: 10 }) + }); +} + +// Generator for capability parameters +function capabilityParameterArbitrary(): fc.Arbitrary { + return fc.record({ + name: fc.string({ minLength: 1 }), + type: fc.constantFrom('string', 'number', 'boolean', 'object', 'array'), + required: fc.boolean(), + description: fc.option(fc.string()), + default: fc.anything() + }); +} + +// Generator for user permissions +function permissionsArbitrary(): fc.Arbitrary { + return fc.record({ + canProvision: fc.boolean(), + canManageVMs: fc.boolean(), + canDestroyVMs: fc.boolean(), + allowedIntegrations: fc.array(fc.string()), + allowedActions: fc.array(fc.constantFrom('start', 'stop', 'reboot', 'destroy')) + }); +} +``` + +### Test Organization + +``` +frontend/src/ +├── pages/ +│ ├── ProvisionPage.test.ts (unit + property tests) +│ └── NodeDetailPage.test.ts (unit tests for ManageTab) +├── components/ +│ ├── ProxmoxProvisionForm.test.ts (unit + property tests) +│ ├── ManageTab.test.ts (unit + property tests) +│ └── ProxmoxSetupGuide.test.ts (unit tests) +├── lib/ +│ ├── validation.test.ts (property tests) +│ └── provisioning.test.ts (property tests) +└── __tests__/ + └── generators.ts (custom fast-check generators) +``` + +### Integration Testing + +Integration tests will verify: + +1. Full provisioning flow from form submission to success notification +2. Error handling across component boundaries +3. Navigation between pages +4. Permission checks across multiple components + +### Test Execution + +```bash +# Run all tests +npm test -- --silent + +# Run specific test file +npm test -- ProvisionPage.test.ts --silent + +# Run property tests only +npm test -- --grep "Property [0-9]+" --silent + +# Run with coverage +npm test -- --coverage --silent +``` + +### Coverage Goals + +- **Line Coverage**: > 80% +- **Branch Coverage**: > 75% +- **Function Coverage**: > 85% +- **Property Test Coverage**: All 17 properties implemented diff --git a/.kiro/specs/proxmox-frontend-ui/requirements.md b/.kiro/specs/proxmox-frontend-ui/requirements.md new file mode 100644 index 0000000..35cb03b --- /dev/null +++ b/.kiro/specs/proxmox-frontend-ui/requirements.md @@ -0,0 +1,200 @@ +# Requirements Document + +## Introduction + +This document specifies the requirements for adding Proxmox provisioning capabilities to the Pabawi frontend. The feature enables users with appropriate permissions to provision and manage virtual machines through available integrations (initially Proxmox, with future support for EC2, Azure, and Terraform). The system will dynamically discover provisioning capabilities from backend integrations and enforce role-based access control for all provisioning operations. + +## Glossary + +- **Pabawi_Frontend**: The web-based user interface for the Pabawi infrastructure management system +- **Integration_Manager**: Backend service that manages and exposes capabilities from various infrastructure integrations +- **Provisioning_Integration**: An integration that provides VM/container creation and lifecycle management capabilities +- **Proxmox_Integration**: The backend integration for Proxmox virtualization platform +- **VM**: Virtual Machine - a virtualized compute instance +- **LXC**: Linux Container - a lightweight virtualized environment +- **RBAC_System**: Role-Based Access Control system that manages user permissions +- **Provisioning_Capability**: A specific action that an integration can perform (create_vm, destroy_vm, start, stop, etc.) +- **Node_Detail_Page**: The page displaying information about a specific VM or LXC instance +- **Provision_Page**: The new page where users can create VMs using available integrations +- **Setup_Page**: The configuration page for integrations +- **Top_Menu**: The main navigation menu in the Pabawi frontend + +## Requirements + +### Requirement 1: Provision Page Navigation + +**User Story:** As a user with provisioning permissions, I want to access a dedicated provisioning page from the main menu, so that I can easily create new VMs and containers. + +#### Acceptance Criteria + +1. THE Pabawi_Frontend SHALL display a "Provision" entry in the Top_Menu +2. WHEN a user clicks the "Provision" menu entry, THE Pabawi_Frontend SHALL navigate to the Provision_Page +3. WHERE a user lacks provisioning permissions, THE Pabawi_Frontend SHALL hide the "Provision" menu entry +4. THE Provision_Page SHALL display all available Provisioning_Integrations + +### Requirement 2: Dynamic Integration Discovery + +**User Story:** As a system administrator, I want the frontend to automatically discover available provisioning integrations, so that new integrations work without frontend code changes. + +#### Acceptance Criteria + +1. WHEN the Provision_Page loads, THE Pabawi_Frontend SHALL query the Integration_Manager for available Provisioning_Integrations +2. FOR EACH Provisioning_Integration, THE Pabawi_Frontend SHALL retrieve the list of supported Provisioning_Capabilities +3. THE Pabawi_Frontend SHALL display only integrations that provide at least one provisioning capability +4. WHEN the Integration_Manager returns an error, THE Pabawi_Frontend SHALL display an error message and log the failure + +### Requirement 3: VM Creation Interface + +**User Story:** As a user with VM creation permissions, I want to create VMs through the Proxmox integration, so that I can provision infrastructure on demand. + +#### Acceptance Criteria + +1. WHERE Proxmox_Integration is available, THE Provision_Page SHALL display a VM creation form +2. THE VM creation form SHALL include fields for all required Proxmox VM parameters +3. WHEN a user submits the VM creation form with valid data, THE Pabawi_Frontend SHALL send a create_vm request to the Proxmox_Integration +4. WHEN the create_vm request succeeds, THE Pabawi_Frontend SHALL display a success message with the new VM identifier +5. IF the create_vm request fails, THEN THE Pabawi_Frontend SHALL display the error message returned by the Proxmox_Integration +6. WHILE a create_vm request is in progress, THE Pabawi_Frontend SHALL disable the submit button and display a loading indicator + +### Requirement 4: LXC Container Creation Interface + +**User Story:** As a user with container creation permissions, I want to create LXC containers through the Proxmox integration, so that I can provision lightweight compute resources. + +#### Acceptance Criteria + +1. WHERE Proxmox_Integration is available, THE Provision_Page SHALL display an LXC creation form +2. THE LXC creation form SHALL include fields for all required Proxmox LXC parameters +3. WHEN a user submits the LXC creation form with valid data, THE Pabawi_Frontend SHALL send a create_lxc request to the Proxmox_Integration +4. WHEN the create_lxc request succeeds, THE Pabawi_Frontend SHALL display a success message with the new LXC identifier +5. IF the create_lxc request fails, THEN THE Pabawi_Frontend SHALL display the error message returned by the Proxmox_Integration +6. WHILE a create_lxc request is in progress, THE Pabawi_Frontend SHALL disable the submit button and display a loading indicator + +### Requirement 5: Node Management Tab + +**User Story:** As a user managing VMs, I want to access lifecycle actions from the node detail page, so that I can control my VMs without navigating away. + +#### Acceptance Criteria + +1. THE Node_Detail_Page SHALL display a "Manage" tab +2. WHEN a user selects the "Manage" tab, THE Pabawi_Frontend SHALL display available lifecycle actions for the node +3. THE Pabawi_Frontend SHALL query the Integration_Manager for actions available for the specific node type +4. THE Pabawi_Frontend SHALL display only actions that the current user has permission to perform +5. WHERE no actions are available or permitted, THE Pabawi_Frontend SHALL display a message indicating no actions are available + +### Requirement 6: VM Lifecycle Actions + +**User Story:** As a user with VM management permissions, I want to start, stop, and control VMs from the manage tab, so that I can operate my infrastructure. + +#### Acceptance Criteria + +1. WHERE a VM is stopped, THE Manage_Tab SHALL display a "Start" action button +2. WHERE a VM is running, THE Manage_Tab SHALL display "Stop", "Shutdown", "Reboot", and "Suspend" action buttons +3. WHERE a VM is suspended, THE Manage_Tab SHALL display a "Resume" action button +4. WHEN a user clicks an action button, THE Pabawi_Frontend SHALL send the corresponding request to the Proxmox_Integration +5. WHEN an action request succeeds, THE Pabawi_Frontend SHALL display a success message and refresh the node status +6. IF an action request fails, THEN THE Pabawi_Frontend SHALL display the error message returned by the Proxmox_Integration +7. WHILE an action request is in progress, THE Pabawi_Frontend SHALL disable all action buttons and display a loading indicator + +### Requirement 7: VM Destruction + +**User Story:** As a user with VM destruction permissions, I want to delete VMs that are no longer needed, so that I can free up resources. + +#### Acceptance Criteria + +1. WHERE a user has destroy permissions, THE Manage_Tab SHALL display a "Destroy" action button +2. WHEN a user clicks the "Destroy" button, THE Pabawi_Frontend SHALL display a confirmation dialog with the VM identifier +3. WHEN a user confirms destruction, THE Pabawi_Frontend SHALL send a destroy_vm request to the Proxmox_Integration +4. WHEN the destroy_vm request succeeds, THE Pabawi_Frontend SHALL display a success message and navigate away from the Node_Detail_Page +5. IF the destroy_vm request fails, THEN THE Pabawi_Frontend SHALL display the error message and keep the user on the Node_Detail_Page +6. WHEN a user cancels the confirmation dialog, THE Pabawi_Frontend SHALL take no action + +### Requirement 8: LXC Container Destruction + +**User Story:** As a user with container destruction permissions, I want to delete LXC containers that are no longer needed, so that I can free up resources. + +#### Acceptance Criteria + +1. WHERE a user has destroy permissions for LXC, THE Manage_Tab SHALL display a "Destroy" action button +2. WHEN a user clicks the "Destroy" button for an LXC, THE Pabawi_Frontend SHALL display a confirmation dialog with the LXC identifier +3. WHEN a user confirms destruction, THE Pabawi_Frontend SHALL send a destroy_lxc request to the Proxmox_Integration +4. WHEN the destroy_lxc request succeeds, THE Pabawi_Frontend SHALL display a success message and navigate away from the Node_Detail_Page +5. IF the destroy_lxc request fails, THEN THE Pabawi_Frontend SHALL display the error message and keep the user on the Node_Detail_Page +6. WHEN a user cancels the confirmation dialog, THE Pabawi_Frontend SHALL take no action + +### Requirement 9: Role-Based Access Control + +**User Story:** As a system administrator, I want provisioning actions to respect user roles, so that users can only perform authorized operations. + +#### Acceptance Criteria + +1. WHEN the Pabawi_Frontend requests available actions, THE RBAC_System SHALL return only actions the user is permitted to perform +2. THE Pabawi_Frontend SHALL verify user permissions before displaying any provisioning UI elements +3. WHERE a user lacks permission for an action, THE Pabawi_Frontend SHALL hide the corresponding UI control +4. IF a user attempts an unauthorized action through API manipulation, THEN THE Integration_Manager SHALL reject the request with an authorization error +5. THE Pabawi_Frontend SHALL display authorization errors with a clear message indicating insufficient permissions + +### Requirement 10: Proxmox Integration Setup UI + +**User Story:** As a system administrator, I want to configure the Proxmox integration through a user interface, so that I can set up the integration without editing configuration files. + +#### Acceptance Criteria + +1. THE Setup_Page SHALL display a configuration form for Proxmox_Integration +2. THE Proxmox configuration form SHALL include fields for host, port, authentication credentials, and connection options +3. WHEN a user submits the Proxmox configuration form, THE Pabawi_Frontend SHALL validate all required fields are populated +4. WHEN validation passes, THE Pabawi_Frontend SHALL send the configuration to the Integration_Manager +5. WHEN the configuration is saved successfully, THE Pabawi_Frontend SHALL display a success message +6. IF the configuration save fails, THEN THE Pabawi_Frontend SHALL display the error message returned by the Integration_Manager +7. THE Proxmox configuration form SHALL include a "Test Connection" button that verifies connectivity before saving + +### Requirement 11: Input Validation + +**User Story:** As a user, I want the system to validate my inputs before submission, so that I receive immediate feedback on errors. + +#### Acceptance Criteria + +1. THE Pabawi_Frontend SHALL validate all form inputs before enabling the submit button +2. WHEN a required field is empty, THE Pabawi_Frontend SHALL display a validation error message below the field +3. WHEN a field contains invalid data format, THE Pabawi_Frontend SHALL display a format error message below the field +4. THE Pabawi_Frontend SHALL validate numeric fields are within acceptable ranges +5. THE Pabawi_Frontend SHALL validate string fields meet length requirements +6. WHEN all validations pass, THE Pabawi_Frontend SHALL enable the submit button + +### Requirement 12: Error Handling and User Feedback + +**User Story:** As a user, I want clear feedback when operations fail, so that I can understand and resolve issues. + +#### Acceptance Criteria + +1. WHEN any API request fails, THE Pabawi_Frontend SHALL display an error notification +2. THE error notification SHALL include the error message returned by the backend +3. WHERE the backend provides error details, THE Pabawi_Frontend SHALL display them in an expandable section +4. THE Pabawi_Frontend SHALL log all errors to the browser console for debugging +5. WHEN an operation succeeds, THE Pabawi_Frontend SHALL display a success notification with relevant details +6. THE Pabawi_Frontend SHALL automatically dismiss success notifications after 5 seconds +7. THE Pabawi_Frontend SHALL keep error notifications visible until the user dismisses them + +### Requirement 13: Extensibility for Future Integrations + +**User Story:** As a developer, I want the provisioning UI to be integration-agnostic, so that adding new provisioning integrations requires minimal frontend changes. + +#### Acceptance Criteria + +1. THE Provision_Page SHALL render provisioning forms based on integration capability metadata +2. THE Pabawi_Frontend SHALL not contain hardcoded logic specific to Proxmox_Integration +3. WHEN a new Provisioning_Integration is added to the backend, THE Pabawi_Frontend SHALL automatically discover and display it +4. THE Pabawi_Frontend SHALL support dynamic form generation based on integration-provided parameter schemas +5. THE Manage_Tab SHALL render action buttons based on capability metadata rather than hardcoded integration names + +### Requirement 14: Documentation Updates + +**User Story:** As a user or administrator, I want up-to-date documentation, so that I can understand how to use the new provisioning features. + +#### Acceptance Criteria + +1. THE documentation SHALL include a guide for using the Provision_Page +2. THE documentation SHALL include instructions for configuring the Proxmox_Integration +3. THE documentation SHALL explain the required permissions for each provisioning action +4. THE documentation SHALL include screenshots of the provisioning UI +5. THE documentation SHALL describe how to use the Manage_Tab for VM lifecycle operations +6. THE documentation SHALL include troubleshooting steps for common provisioning errors diff --git a/.kiro/specs/proxmox-frontend-ui/tasks.md b/.kiro/specs/proxmox-frontend-ui/tasks.md new file mode 100644 index 0000000..11459e3 --- /dev/null +++ b/.kiro/specs/proxmox-frontend-ui/tasks.md @@ -0,0 +1,322 @@ +# Implementation Plan: Proxmox Frontend UI + +## Overview + +This implementation plan adds Proxmox provisioning capabilities to the Pabawi frontend. The feature includes a new Provision page for creating VMs and LXC containers, a Manage tab on node detail pages for lifecycle operations, and integration setup UI for Proxmox configuration. The implementation follows a dynamic, integration-agnostic architecture using Svelte 5 with TypeScript. + +## Tasks + +- [x] 1. Create core type definitions and API client methods + - [x] 1.1 Define TypeScript interfaces for provisioning types + - Create `frontend/src/lib/types/provisioning.ts` with interfaces for ProvisioningIntegration, ProvisioningCapability, CapabilityParameter, ProxmoxVMParams, ProxmoxLXCParams, LifecycleAction, ProvisioningResult, and API response types + - _Requirements: 2.1, 2.2, 13.1_ + + - [x] 1.2 Add provisioning API methods to api.ts + - Add methods: `getProvisioningIntegrations()`, `createProxmoxVM()`, `createProxmoxLXC()`, `executeNodeAction()`, `destroyNode()`, `saveProxmoxConfig()`, `testProxmoxConnection()` + - Configure retry logic: no retries for provisioning operations, 2 retries for status queries + - _Requirements: 2.1, 3.3, 4.3, 6.4, 7.3, 8.3, 10.4_ + + - [x] 1.3 Write property test for API client methods + - **Property 4: Action Execution Triggers API Call** + - **Validates: Requirements 6.4** + +- [x] 2. Create form validation utilities + - [x] 2.1 Implement validation functions in lib/validation.ts + - Create validation functions: `validateVMID()`, `validateHostname()`, `validateMemory()`, `validateRequired()`, `validateNumericRange()`, `validateStringPattern()` + - Each function returns error message string or null + - _Requirements: 11.1, 11.2, 11.3, 11.4, 11.5_ + + - [x] 2.2 Implement generic form validation utility + - Create `validateForm()` function that accepts data and validation rules, returns ValidationResult with errors object + - Support validation types: required, number (min/max), string (minLength/maxLength/pattern) + - _Requirements: 11.1, 11.6_ + + - [x] 2.3 Write property tests for validation utilities + - **Property 8: Form Validation Completeness** + - **Property 9: Valid Form Enables Submission** + - **Validates: Requirements 11.1, 11.2, 11.3, 11.4, 11.5, 11.6** + +- [x] 3. Implement ProvisionPage component + - [x] 3.1 Create ProvisionPage.svelte with integration discovery + - Create `frontend/src/pages/ProvisionPage.svelte` with state management using Svelte 5 runes + - Implement `fetchIntegrations()` to query `/api/integrations/provisioning` + - Display loading state, error state, and integration list + - Filter and display only integrations with at least one capability + - _Requirements: 1.2, 1.4, 2.1, 2.2, 2.3, 2.4_ + + - [x] 3.2 Add integration selector for multiple integrations + - Implement tab or dropdown selector when multiple integrations are available + - Default to first available integration + - _Requirements: 1.4, 2.2_ + + - [x] 3.3 Write unit tests for ProvisionPage + - Test integration discovery, loading states, error handling, empty states + - _Requirements: 1.2, 1.4, 2.1, 2.3, 2.4_ + + - [x] 3.4 Write property tests for ProvisionPage + - **Property 1: Integration Discovery and Display** + - **Property 16: Integration Extensibility** + - **Validates: Requirements 1.4, 2.2, 2.3, 13.3** + +- [x] 4. Checkpoint - Ensure all tests pass + - Ensure all tests pass, ask the user if questions arise. + +- [x] 5. Implement ProxmoxProvisionForm component + - [x] 5.1 Create ProxmoxProvisionForm.svelte with tabbed interface + - Create `frontend/src/components/ProxmoxProvisionForm.svelte` with VM and LXC tabs + - Implement state management for activeTab, formData, validationErrors, submitting + - Add tab switching between 'vm' and 'lxc' modes + - _Requirements: 3.1, 4.1_ + + - [x] 5.2 Implement VM creation form + - Add form fields: vmid (required), name (required), node (required), cores, memory, sockets, cpu, scsi0, ide2, net0, ostype + - Implement real-time validation using validation utilities + - Display validation errors inline below each field + - Disable submit button when validation fails or submission in progress + - _Requirements: 3.2, 3.3, 3.6, 11.1, 11.2, 11.6_ + + - [x] 5.3 Implement LXC creation form + - Add form fields: vmid (required), hostname (required), node (required), ostemplate (required), cores, memory, rootfs, net0, password + - Implement real-time validation using validation utilities + - Display validation errors inline below each field + - Disable submit button when validation fails or submission in progress + - _Requirements: 4.2, 4.3, 4.6, 11.1, 11.2, 11.6_ + + - [x] 5.4 Implement form submission handlers + - Create `submitVMForm()` and `submitLXCForm()` functions + - Call appropriate API methods with form data + - Handle success: display success notification with VM/LXC ID, reset form + - Handle errors: display error notification with backend message + - Show loading indicator during submission + - _Requirements: 3.3, 3.4, 3.5, 3.6, 4.3, 4.4, 4.5, 4.6, 12.1, 12.2, 12.5_ + + - [x] 5.5 Write unit tests for ProxmoxProvisionForm + - Test form rendering, tab switching, field validation, submission success/error handling + - _Requirements: 3.1, 3.2, 4.1, 4.2, 11.1_ + + - [x] 5.6 Write property tests for form validation + - **Property 8: Form Validation Completeness** + - **Property 9: Valid Form Enables Submission** + - **Validates: Requirements 11.1, 11.6** + +- [x] 6. Implement ManageTab component for node lifecycle actions + - [x] 6.1 Create ManageTab.svelte component + - Create `frontend/src/components/ManageTab.svelte` with state management for availableActions, nodeStatus, actionInProgress, confirmDialog + - Accept props: nodeId, nodeType, currentStatus + - Implement `fetchAvailableActions()` to query backend for permitted actions + - _Requirements: 5.1, 5.2, 5.3, 5.4_ + + - [x] 6.2 Implement action button rendering with availability logic + - Define actionAvailability mapping (start: ['stopped'], stop: ['running'], etc.) + - Render action buttons only when node state matches availableWhen conditions + - Display "no actions available" message when appropriate + - _Requirements: 5.5, 6.1, 6.2, 6.3_ + + - [x] 6.3 Implement action execution handlers + - Create `executeAction(action: string)` function + - Call API with action name and node identifier + - Disable all buttons and show loading indicator during execution + - Handle success: display success notification, refresh node status + - Handle errors: display error notification with backend message + - _Requirements: 6.4, 6.5, 6.6, 6.7, 12.1, 12.2, 12.5_ + + - [x] 6.4 Implement confirmation dialog for destructive actions + - Create confirmation dialog component for destroy actions + - Show VM/LXC identifier in confirmation message + - Handle confirm: execute destroy action, navigate away on success + - Handle cancel: close dialog, take no action + - _Requirements: 7.1, 7.2, 7.3, 7.4, 7.5, 7.6, 8.1, 8.2, 8.3, 8.4, 8.5, 8.6_ + + - [x] 6.5 Write unit tests for ManageTab + - Test action button rendering, availability logic, execution handlers, confirmation dialogs + - _Requirements: 5.1, 5.2, 6.1, 6.2, 6.3, 7.1, 8.1_ + + - [x] 6.6 Write property tests for ManageTab + - **Property 3: Action Button Availability** + - **Property 5: Successful Action Handling** + - **Property 6: Failed Action Error Display** + - **Property 7: Loading State During Actions** + - **Property 17: Dynamic Action Rendering** + - **Validates: Requirements 6.1, 6.2, 6.3, 6.5, 6.6, 6.7, 13.5** + +- [x] 7. Checkpoint - Ensure all tests pass + - Ensure all tests pass, ask the user if questions arise. + +- [x] 8. Integrate ManageTab into NodeDetailPage + - [x] 8.1 Add ManageTab to NodeDetailPage.svelte + - Import ManageTab component + - Add "Manage" tab to existing tab navigation + - Pass nodeId, nodeType, and currentStatus props to ManageTab + - _Requirements: 5.1, 5.2_ + + - [x] 8.2 Write integration tests for NodeDetailPage with ManageTab + - Test tab navigation, prop passing, action execution flow + - _Requirements: 5.1, 5.2_ + +- [x] 9. Implement ProxmoxSetupGuide component + - [x] 9.1 Create ProxmoxSetupGuide.svelte configuration form + - Create `frontend/src/components/ProxmoxSetupGuide.svelte` with state management for config, testResult, saving + - Add form fields: host (required), port (required, 1-65535), username, password, realm, token, ssl.rejectUnauthorized + - Implement validation: host (valid hostname/IP), port (numeric range), authentication (username+password+realm OR token) + - Display warning when ssl.rejectUnauthorized is false + - _Requirements: 10.1, 10.2, 10.3_ + + - [x] 9.2 Implement connection test functionality + - Add "Test Connection" button + - Call `testProxmoxConnection()` API method with current config + - Display test result (success/failure) with message + - _Requirements: 10.7_ + + - [x] 9.3 Implement configuration save handler + - Create `saveConfiguration()` function + - Validate all required fields before submission + - Call `saveProxmoxConfig()` API method + - Handle success: display success notification + - Handle errors: display error notification with backend message + - _Requirements: 10.3, 10.4, 10.5, 10.6_ + + - [x] 9.4 Write unit tests for ProxmoxSetupGuide + - Test form rendering, validation, connection test, save handler + - _Requirements: 10.1, 10.2, 10.3, 10.7_ + + - [x] 9.5 Write property tests for configuration validation + - **Property 10: Configuration Validation** + - **Validates: Requirements 10.3** + +- [x] 10. Update navigation and routing + - [x] 10.1 Add Provision route to Router.svelte + - Add route: '/provision': { component: ProvisionPage, requiresAuth: true } + - _Requirements: 1.2_ + + - [x] 10.2 Add Provision menu item to Navigation.svelte + - Add "Provision" link to top menu with icon + - Conditionally render based on user provisioning permissions + - Hide menu item if user lacks provisioning permissions + - _Requirements: 1.1, 1.3, 9.2, 9.3_ + + - [x] 10.3 Add permission check utility + - Create `hasProvisioningPermission()` function in auth context + - Check user permissions from auth manager + - _Requirements: 1.3, 9.1, 9.2, 9.3_ + + - [x] 10.4 Write unit tests for navigation updates + - Test route registration, menu item rendering, permission checks + - _Requirements: 1.1, 1.2, 1.3_ + + - [x] 10.5 Write property tests for permission-based UI visibility + - **Property 2: Permission-Based UI Visibility** + - **Validates: Requirements 1.3, 5.4, 9.2, 9.3** + +- [x] 11. Implement notification system enhancements + - [x] 11.1 Add error notification with expandable details + - Enhance toast notification to support expandable error details section + - Display main error message prominently + - Show additional details in collapsible section when available + - _Requirements: 12.1, 12.2, 12.3_ + + - [x] 11.2 Implement notification persistence logic + - Error notifications: remain visible until user dismisses + - Success notifications: auto-dismiss after exactly 5 seconds + - _Requirements: 12.6, 12.7_ + + - [x] 11.3 Add error logging to console + - Log all errors to browser console with context + - Include error type, message, stack trace, and operation context + - _Requirements: 12.4_ + + - [x] 11.4 Write unit tests for notification system + - Test error display, success display, auto-dismiss timing, expandable details + - _Requirements: 12.1, 12.2, 12.3, 12.6, 12.7_ + + - [x] 11.5 Write property tests for notification behavior + - **Property 11: Error Notification Persistence** + - **Property 12: Success Notification Auto-Dismiss** + - **Property 13: Error Details Expandability** + - **Property 14: Error Logging** + - **Validates: Requirements 12.1, 12.3, 12.4, 12.6, 12.7** + +- [x] 12. Checkpoint - Ensure all tests pass + - Ensure all tests pass, ask the user if questions arise. + +- [x] 13. Implement dynamic form generation utilities + - [x] 13.1 Create form field generator from capability metadata + - Create `frontend/src/lib/formGenerator.ts` with `generateFormFields()` function + - Accept CapabilityParameter[] and return form field configuration + - Map parameter types to appropriate input types + - Apply validation rules from parameter metadata + - _Requirements: 13.1, 13.4_ + + - [x] 13.2 Write property tests for dynamic form generation + - **Property 15: Dynamic Form Generation** + - **Validates: Requirements 13.1, 13.4** + +- [x] 14. Create custom fast-check generators for property tests + - [x] 14.1 Implement test data generators + - Create `frontend/src/__tests__/generators.ts` with custom arbitraries + - Implement: `integrationArbitrary()`, `capabilityParameterArbitrary()`, `permissionsArbitrary()`, `nodeStateArbitrary()` + - Configure generators to produce realistic test data + - _Requirements: Testing strategy_ + + - [x] 14.2 Write tests for generators + - Verify generators produce valid data structures + - _Requirements: Testing strategy_ + +- [x] 15. Integration and wiring + - [x] 15.1 Wire all components together + - Verify ProvisionPage renders ProxmoxProvisionForm correctly + - Verify NodeDetailPage renders ManageTab correctly + - Verify IntegrationSetupPage renders ProxmoxSetupGuide correctly + - Test navigation flow: menu → provision page → form submission → success + - Test management flow: node detail → manage tab → action execution → status refresh + - _Requirements: All requirements_ + + - [x] 15.2 Write end-to-end integration tests + - Test complete provisioning flow from navigation to VM creation + - Test complete management flow from node detail to action execution + - Test error handling across component boundaries + - _Requirements: All requirements_ + +- [x] 16. Update documentation + - [x] 16.1 Create user guide for Provision page + - Document how to access and use the Provision page + - Include screenshots of VM and LXC creation forms + - Explain form fields and validation requirements + - _Requirements: 14.1, 14.4_ + + - [x] 16.2 Create Proxmox integration setup guide + - Document configuration steps for Proxmox integration + - Include connection test instructions + - Explain authentication options (username/password vs token) + - _Requirements: 14.2_ + + - [x] 16.3 Document permissions and RBAC + - List required permissions for each provisioning action + - Explain how permissions affect UI visibility + - _Requirements: 14.3_ + + - [x] 16.4 Create Manage tab usage guide + - Document lifecycle operations available in Manage tab + - Explain action availability based on node state + - Include screenshots of action buttons and confirmation dialogs + - _Requirements: 14.5_ + + - [x] 16.5 Add troubleshooting section + - Document common provisioning errors and solutions + - Include API error codes and meanings + - Provide debugging tips + - _Requirements: 14.6_ + +- [x] 17. Final checkpoint - Ensure all tests pass + - Ensure all tests pass, ask the user if questions arise. + +## Notes + +- Tasks marked with `*` are optional and can be skipped for faster MVP +- Each task references specific requirements for traceability +- Checkpoints ensure incremental validation +- Property tests validate universal correctness properties (17 properties total) +- Unit tests validate specific examples and edge cases +- The implementation uses Svelte 5 with runes-based reactivity and TypeScript +- All API interactions use the existing api.ts client with proper retry logic +- RBAC is enforced at both backend and frontend levels +- The design is integration-agnostic to support future provisioning integrations diff --git a/.kiro/specs/proxmox-integration/.config.kiro b/.kiro/specs/proxmox-integration/.config.kiro new file mode 100644 index 0000000..2f35c2e --- /dev/null +++ b/.kiro/specs/proxmox-integration/.config.kiro @@ -0,0 +1 @@ +{"specId": "14090baf-461c-4a8b-a016-ce48ca39edfc", "workflowType": "requirements-first", "specType": "feature"} diff --git a/.kiro/specs/proxmox-integration/design.md b/.kiro/specs/proxmox-integration/design.md new file mode 100644 index 0000000..eff2d00 --- /dev/null +++ b/.kiro/specs/proxmox-integration/design.md @@ -0,0 +1,1975 @@ +# Proxmox Integration Design Document + +## Overview + +This document describes the design for integrating Proxmox Virtual Environment (VE) into Pabawi. The integration follows the established plugin architecture pattern used by existing integrations (PuppetDB, Bolt, Ansible, SSH) and introduces a new "provisioning" capability type to enable VM and container lifecycle management. + +### Key Components + +- **ProxmoxIntegration**: Plugin class that implements both InformationSourcePlugin and ExecutionToolPlugin interfaces +- **ProxmoxService**: Business logic layer that orchestrates API calls and data transformation +- **ProxmoxClient**: Low-level HTTP client for Proxmox API communication with authentication and retry logic +- **ProvisioningCapability**: New capability interface for infrastructure provisioning operations + +### Design Goals + +1. Follow existing plugin architecture patterns for consistency +2. Support both information retrieval (inventory, facts, groups) and execution (actions, provisioning) +3. Provide robust error handling and resilience through retry logic and circuit breakers +4. Enable efficient operations through caching and parallel API calls +5. Support both password and token-based authentication +6. Introduce provisioning capabilities for VM and container lifecycle management + +## Architecture + +### High-Level Component Diagram + +```mermaid +graph TB + IM[IntegrationManager] + PI[ProxmoxIntegration
BasePlugin] + PS[ProxmoxService] + PC[ProxmoxClient] + PVE[Proxmox VE API] + + IM -->|registers| PI + PI -->|delegates to| PS + PS -->|uses| PC + PC -->|HTTP/HTTPS| PVE + + PI -.->|implements| ISP[InformationSourcePlugin] + PI -.->|implements| ETP[ExecutionToolPlugin] + + PS -->|uses| Cache[SimpleCache] + PS -->|uses| Logger[LoggerService] + PS -->|uses| Perf[PerformanceMonitorService] +``` + +### Layer Responsibilities + +**ProxmoxIntegration (Plugin Layer)** + +- Extends BasePlugin +- Implements InformationSourcePlugin and ExecutionToolPlugin interfaces +- Handles plugin lifecycle (initialization, health checks) +- Delegates business logic to ProxmoxService +- Manages configuration validation + +**ProxmoxService (Business Logic Layer)** + +- Orchestrates API calls through ProxmoxClient +- Transforms Proxmox API responses to Pabawi data models +- Implements caching strategy for inventory, groups, and facts +- Handles data aggregation and grouping logic +- Manages provisioning operations (create/destroy VMs and containers) + +**ProxmoxClient (HTTP Client Layer)** + +- Handles HTTP/HTTPS communication with Proxmox API +- Manages authentication (ticket-based and token-based) +- Implements retry logic with exponential backoff +- Handles task polling for long-running operations +- Transforms HTTP errors into domain-specific exceptions + +### Data Flow Diagrams + +#### Inventory Retrieval Flow + +```mermaid +sequenceDiagram + participant IM as IntegrationManager + participant PI as ProxmoxIntegration + participant PS as ProxmoxService + participant Cache as SimpleCache + participant PC as ProxmoxClient + participant PVE as Proxmox API + + IM->>PI: getInventory() + PI->>PS: getInventory() + PS->>Cache: get("inventory:all") + alt Cache Hit + Cache-->>PS: cached nodes + PS-->>PI: Node[] + else Cache Miss + PS->>PC: query("/api2/json/cluster/resources", type=vm) + PC->>PVE: GET /api2/json/cluster/resources?type=vm + PVE-->>PC: guest data + PC-->>PS: raw response + PS->>PS: transform to Node[] + PS->>Cache: set("inventory:all", nodes, 60s) + PS-->>PI: Node[] + end + PI-->>IM: Node[] +``` + +#### VM Provisioning Flow + +```mermaid +sequenceDiagram + participant User + participant PI as ProxmoxIntegration + participant PS as ProxmoxService + participant PC as ProxmoxClient + participant PVE as Proxmox API + + User->>PI: executeAction({type: "provision", action: "create_vm", params}) + PI->>PS: createVM(params) + PS->>PC: post("/api2/json/nodes/{node}/qemu", params) + PC->>PVE: POST /api2/json/nodes/{node}/qemu + PVE-->>PC: {data: "UPID:..."} + PC->>PC: waitForTask(taskId) + loop Poll every 2s + PC->>PVE: GET /api2/json/nodes/{node}/tasks/{upid}/status + PVE-->>PC: {status: "running"} + end + PVE-->>PC: {status: "stopped", exitstatus: "OK"} + PC-->>PS: success + PS->>Cache: clear inventory cache + PS-->>PI: ExecutionResult{success: true, vmid} + PI-->>User: ExecutionResult +``` + +#### Authentication Flow + +```mermaid +sequenceDiagram + participant PS as ProxmoxService + participant PC as ProxmoxClient + participant PVE as Proxmox API + + PS->>PC: initialize(config) + alt Token Authentication + PC->>PC: store token + Note over PC: Use token in Authorization header + else Password Authentication + PC->>PVE: POST /api2/json/access/ticket + Note over PC,PVE: {username, password, realm} + PVE-->>PC: {ticket, CSRFPreventionToken} + PC->>PC: store ticket & CSRF token + end + + PS->>PC: query(endpoint) + PC->>PVE: GET endpoint (with auth) + alt Ticket Expired (401) + PVE-->>PC: 401 Unauthorized + PC->>PVE: POST /api2/json/access/ticket + PVE-->>PC: new ticket + PC->>PVE: GET endpoint (with new ticket) + PVE-->>PC: success + else Success + PVE-->>PC: data + end + PC-->>PS: data +``` + +## Components and Interfaces + +### ProxmoxIntegration Class + +**File**: `pabawi/backend/src/integrations/proxmox/ProxmoxIntegration.ts` + +```typescript +export class ProxmoxIntegration extends BasePlugin + implements InformationSourcePlugin, ExecutionToolPlugin { + + type = "both" as const; + private service: ProxmoxService; + + constructor(logger?: LoggerService, performanceMonitor?: PerformanceMonitorService) { + super("proxmox", "both", logger, performanceMonitor); + } + + protected async performInitialization(): Promise { + // Extract and validate Proxmox configuration + const config = this.config.config as ProxmoxConfig; + this.validateProxmoxConfig(config); + + // Initialize service with configuration + this.service = new ProxmoxService(config, this.logger, this.performanceMonitor); + await this.service.initialize(); + } + + protected async performHealthCheck(): Promise> { + return await this.service.healthCheck(); + } + + // InformationSourcePlugin methods + async getInventory(): Promise { + return await this.service.getInventory(); + } + + async getGroups(): Promise { + return await this.service.getGroups(); + } + + async getNodeFacts(nodeId: string): Promise { + return await this.service.getNodeFacts(nodeId); + } + + async getNodeData(nodeId: string, dataType: string): Promise { + return await this.service.getNodeData(nodeId, dataType); + } + + // ExecutionToolPlugin methods + async executeAction(action: Action): Promise { + return await this.service.executeAction(action); + } + + listCapabilities(): Capability[] { + return this.service.listCapabilities(); + } + + listProvisioningCapabilities(): ProvisioningCapability[] { + return this.service.listProvisioningCapabilities(); + } + + private validateProxmoxConfig(config: ProxmoxConfig): void { + // Validate host (hostname or IP) + if (!config.host || typeof config.host !== 'string') { + throw new Error('Proxmox configuration must include a valid host'); + } + + // Validate port range + if (config.port && (config.port < 1 || config.port > 65535)) { + throw new Error('Proxmox port must be between 1 and 65535'); + } + + // Validate authentication + if (!config.token && !config.password) { + throw new Error('Proxmox configuration must include either token or password authentication'); + } + + // Validate realm for password auth + if (config.password && !config.realm) { + throw new Error('Proxmox password authentication requires a realm'); + } + + // Log security warning if cert verification disabled + if (config.ssl?.rejectUnauthorized === false) { + this.logger.warn('TLS certificate verification is disabled - this is insecure', { + component: 'ProxmoxIntegration', + operation: 'validateProxmoxConfig' + }); + } + } +} +``` + +### ProxmoxService Class + +**File**: `pabawi/backend/src/integrations/proxmox/ProxmoxService.ts` + +```typescript +export class ProxmoxService { + private client: ProxmoxClient; + private cache: SimpleCache; + private logger: LoggerService; + private performanceMonitor: PerformanceMonitorService; + private config: ProxmoxConfig; + + constructor( + config: ProxmoxConfig, + logger: LoggerService, + performanceMonitor: PerformanceMonitorService + ) { + this.config = config; + this.logger = logger; + this.performanceMonitor = performanceMonitor; + this.cache = new SimpleCache({ ttl: 60000 }); // Default 60s + } + + async initialize(): Promise { + this.client = new ProxmoxClient(this.config, this.logger); + await this.client.authenticate(); + } + + async healthCheck(): Promise> { + try { + const version = await this.client.get('/api2/json/version'); + return { + healthy: true, + message: 'Proxmox API is reachable', + details: { version } + }; + } catch (error) { + if (error instanceof ProxmoxAuthenticationError) { + return { + healthy: false, + degraded: true, + message: 'Authentication failed', + details: { error: error.message } + }; + } + return { + healthy: false, + message: 'Proxmox API is unreachable', + details: { error: error instanceof Error ? error.message : String(error) } + }; + } + } + + async getInventory(): Promise { + const cacheKey = 'inventory:all'; + const cached = this.cache.get(cacheKey); + if (cached) return cached as Node[]; + + const complete = this.performanceMonitor.startTimer('proxmox:getInventory'); + + try { + // Query all cluster resources (VMs and containers) + const resources = await this.client.get('/api2/json/cluster/resources?type=vm'); + + if (!Array.isArray(resources)) { + throw new Error('Unexpected response format from Proxmox API'); + } + + const nodes = resources.map(guest => this.transformGuestToNode(guest)); + + this.cache.set(cacheKey, nodes, 60000); // Cache for 60s + complete({ cached: false, nodeCount: nodes.length }); + + return nodes; + } catch (error) { + complete({ error: error instanceof Error ? error.message : String(error) }); + throw error; + } + } + + async getGroups(): Promise { + const cacheKey = 'groups:all'; + const cached = this.cache.get(cacheKey); + if (cached) return cached as NodeGroup[]; + + const inventory = await this.getInventory(); + const groups: NodeGroup[] = []; + + // Group by node + const nodeGroups = this.groupByNode(inventory); + groups.push(...nodeGroups); + + // Group by status + const statusGroups = this.groupByStatus(inventory); + groups.push(...statusGroups); + + // Group by type (VM vs LXC) + const typeGroups = this.groupByType(inventory); + groups.push(...typeGroups); + + this.cache.set(cacheKey, groups, 60000); // Cache for 60s + + return groups; + } + + async getNodeFacts(nodeId: string): Promise { + const cacheKey = `facts:${nodeId}`; + const cached = this.cache.get(cacheKey); + if (cached) return cached as Facts; + + // Parse VMID from nodeId (format: "proxmox:{node}:{vmid}") + const vmid = this.parseVMID(nodeId); + const node = this.parseNodeName(nodeId); + + // Determine guest type and fetch configuration + const guestType = await this.getGuestType(node, vmid); + const endpoint = guestType === 'lxc' + ? `/api2/json/nodes/${node}/lxc/${vmid}/config` + : `/api2/json/nodes/${node}/qemu/${vmid}/config`; + + const config = await this.client.get(endpoint); + + // Fetch current status + const statusEndpoint = guestType === 'lxc' + ? `/api2/json/nodes/${node}/lxc/${vmid}/status/current` + : `/api2/json/nodes/${node}/qemu/${vmid}/status/current`; + + const status = await this.client.get(statusEndpoint); + + const facts = this.transformToFacts(config, status, guestType); + + this.cache.set(cacheKey, facts, 30000); // Cache for 30s + + return facts; + } + + async executeAction(action: Action): Promise { + const { type, target, action: actionName, parameters } = action; + + if (type === 'provision') { + return await this.executeProvisioningAction(actionName, parameters); + } + + // Handle lifecycle actions (start, stop, etc.) + return await this.executeLifecycleAction(target, actionName); + } + + listCapabilities(): Capability[] { + return [ + { + name: 'start', + description: 'Start a VM or container', + parameters: [] + }, + { + name: 'stop', + description: 'Force stop a VM or container', + parameters: [] + }, + { + name: 'shutdown', + description: 'Gracefully shutdown a VM or container', + parameters: [] + }, + { + name: 'reboot', + description: 'Reboot a VM or container', + parameters: [] + }, + { + name: 'suspend', + description: 'Suspend a VM', + parameters: [] + }, + { + name: 'resume', + description: 'Resume a suspended VM', + parameters: [] + } + ]; + } + + listProvisioningCapabilities(): ProvisioningCapability[] { + return [ + { + name: 'create_vm', + description: 'Create a new virtual machine', + operation: 'create', + parameters: [ + { name: 'vmid', type: 'number', required: true }, + { name: 'name', type: 'string', required: true }, + { name: 'node', type: 'string', required: true }, + { name: 'cores', type: 'number', required: false, default: 1 }, + { name: 'memory', type: 'number', required: false, default: 512 }, + { name: 'disk', type: 'string', required: false }, + { name: 'network', type: 'object', required: false } + ] + }, + { + name: 'create_lxc', + description: 'Create a new LXC container', + operation: 'create', + parameters: [ + { name: 'vmid', type: 'number', required: true }, + { name: 'name', type: 'string', required: true }, + { name: 'node', type: 'string', required: true }, + { name: 'ostemplate', type: 'string', required: true }, + { name: 'cores', type: 'number', required: false, default: 1 }, + { name: 'memory', type: 'number', required: false, default: 512 }, + { name: 'rootfs', type: 'string', required: false }, + { name: 'network', type: 'object', required: false } + ] + }, + { + name: 'destroy_vm', + description: 'Destroy a virtual machine', + operation: 'destroy', + parameters: [ + { name: 'vmid', type: 'number', required: true }, + { name: 'node', type: 'string', required: true } + ] + }, + { + name: 'destroy_lxc', + description: 'Destroy an LXC container', + operation: 'destroy', + parameters: [ + { name: 'vmid', type: 'number', required: true }, + { name: 'node', type: 'string', required: true } + ] + } + ]; + } + + async createVM(params: VMCreateParams): Promise { + // Validate VMID is unique + const exists = await this.guestExists(params.node, params.vmid); + if (exists) { + return { + success: false, + error: `VM with VMID ${params.vmid} already exists on node ${params.node}` + }; + } + + // Call Proxmox API to create VM + const endpoint = `/api2/json/nodes/${params.node}/qemu`; + const taskId = await this.client.post(endpoint, params); + + // Wait for task completion + await this.client.waitForTask(params.node, taskId); + + // Clear inventory cache + this.cache.delete('inventory:all'); + this.cache.delete('groups:all'); + + return { + success: true, + output: `VM ${params.vmid} created successfully`, + metadata: { vmid: params.vmid, node: params.node } + }; + } + + async createLXC(params: LXCCreateParams): Promise { + // Similar to createVM but for LXC containers + const exists = await this.guestExists(params.node, params.vmid); + if (exists) { + return { + success: false, + error: `Container with VMID ${params.vmid} already exists on node ${params.node}` + }; + } + + const endpoint = `/api2/json/nodes/${params.node}/lxc`; + const taskId = await this.client.post(endpoint, params); + + await this.client.waitForTask(params.node, taskId); + + this.cache.delete('inventory:all'); + this.cache.delete('groups:all'); + + return { + success: true, + output: `Container ${params.vmid} created successfully`, + metadata: { vmid: params.vmid, node: params.node } + }; + } + + async destroyGuest(node: string, vmid: number): Promise { + // Check if guest exists + const exists = await this.guestExists(node, vmid); + if (!exists) { + return { + success: false, + error: `Guest ${vmid} not found on node ${node}` + }; + } + + // Determine guest type + const guestType = await this.getGuestType(node, vmid); + + // Stop guest if running + const statusEndpoint = guestType === 'lxc' + ? `/api2/json/nodes/${node}/lxc/${vmid}/status/current` + : `/api2/json/nodes/${node}/qemu/${vmid}/status/current`; + + const status = await this.client.get(statusEndpoint); + if (status.status === 'running') { + const stopEndpoint = guestType === 'lxc' + ? `/api2/json/nodes/${node}/lxc/${vmid}/status/stop` + : `/api2/json/nodes/${node}/qemu/${vmid}/status/stop`; + + const stopTaskId = await this.client.post(stopEndpoint, {}); + await this.client.waitForTask(node, stopTaskId); + } + + // Delete guest + const deleteEndpoint = guestType === 'lxc' + ? `/api2/json/nodes/${node}/lxc/${vmid}` + : `/api2/json/nodes/${node}/qemu/${vmid}`; + + const deleteTaskId = await this.client.delete(deleteEndpoint); + await this.client.waitForTask(node, deleteTaskId); + + // Clear caches + this.cache.delete('inventory:all'); + this.cache.delete('groups:all'); + this.cache.delete(`facts:proxmox:${node}:${vmid}`); + + return { + success: true, + output: `Guest ${vmid} destroyed successfully` + }; + } + + clearCache(): void { + this.cache.clear(); + } + + // Private helper methods + private transformGuestToNode(guest: ProxmoxGuest): Node { /* ... */ } + private groupByNode(nodes: Node[]): NodeGroup[] { /* ... */ } + private groupByStatus(nodes: Node[]): NodeGroup[] { /* ... */ } + private groupByType(nodes: Node[]): NodeGroup[] { /* ... */ } + private parseVMID(nodeId: string): number { /* ... */ } + private parseNodeName(nodeId: string): string { /* ... */ } + private async getGuestType(node: string, vmid: number): Promise<'qemu' | 'lxc'> { /* ... */ } + private transformToFacts(config: unknown, status: unknown, type: string): Facts { /* ... */ } + private async executeProvisioningAction(action: string, params: unknown): Promise { /* ... */ } + private async executeLifecycleAction(target: string, action: string): Promise { /* ... */ } + private async guestExists(node: string, vmid: number): Promise { /* ... */ } +} +``` + +### ProxmoxClient Class + +**File**: `pabawi/backend/src/integrations/proxmox/ProxmoxClient.ts` + +```typescript +export class ProxmoxClient { + private baseUrl: string; + private config: ProxmoxConfig; + private logger: LoggerService; + private ticket?: string; + private csrfToken?: string; + private httpsAgent?: https.Agent; + private retryConfig: RetryConfig; + + constructor(config: ProxmoxConfig, logger: LoggerService) { + this.config = config; + this.logger = logger; + this.baseUrl = `https://${config.host}:${config.port || 8006}`; + + // Configure HTTPS agent + if (config.ssl) { + this.httpsAgent = this.createHttpsAgent(config.ssl); + } + + // Configure retry logic + this.retryConfig = { + maxAttempts: 3, + initialDelay: 1000, + maxDelay: 10000, + backoffMultiplier: 2, + retryableErrors: ['ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND'] + }; + } + + async authenticate(): Promise { + if (this.config.token) { + // Token authentication - no need to fetch ticket + this.logger.info('Using token authentication', { + component: 'ProxmoxClient', + operation: 'authenticate' + }); + return; + } + + // Password authentication - fetch ticket + const endpoint = '/api2/json/access/ticket'; + const params = { + username: `${this.config.username}@${this.config.realm}`, + password: this.config.password + }; + + try { + const response = await this.request('POST', endpoint, params, false); + this.ticket = response.data.ticket; + this.csrfToken = response.data.CSRFPreventionToken; + + this.logger.info('Authentication successful', { + component: 'ProxmoxClient', + operation: 'authenticate' + }); + } catch (error) { + throw new ProxmoxAuthenticationError( + 'Failed to authenticate with Proxmox API', + error + ); + } + } + + async get(endpoint: string): Promise { + return await this.requestWithRetry('GET', endpoint); + } + + async post(endpoint: string, data: unknown): Promise { + const response = await this.requestWithRetry('POST', endpoint, data); + // Proxmox returns task ID (UPID) for async operations + return response.data as string; + } + + async delete(endpoint: string): Promise { + const response = await this.requestWithRetry('DELETE', endpoint); + return response.data as string; + } + + async waitForTask( + node: string, + taskId: string, + timeout: number = 300000 + ): Promise { + const startTime = Date.now(); + const pollInterval = 2000; // 2 seconds + + while (Date.now() - startTime < timeout) { + const endpoint = `/api2/json/nodes/${node}/tasks/${taskId}/status`; + const status = await this.get(endpoint); + + if (status.status === 'stopped') { + if (status.exitstatus === 'OK') { + return; + } else { + throw new ProxmoxError( + `Task failed: ${status.exitstatus}`, + 'TASK_FAILED', + status + ); + } + } + + await this.sleep(pollInterval); + } + + throw new ProxmoxError( + `Task timeout after ${timeout}ms`, + 'TASK_TIMEOUT', + { taskId, node } + ); + } + + private async requestWithRetry( + method: string, + endpoint: string, + data?: unknown + ): Promise { + let lastError: Error | undefined; + + for (let attempt = 1; attempt <= this.retryConfig.maxAttempts; attempt++) { + try { + return await this.request(method, endpoint, data); + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + + // Don't retry authentication errors + if (error instanceof ProxmoxAuthenticationError) { + throw error; + } + + // Don't retry 4xx errors except 429 + if (error instanceof ProxmoxError && error.code.startsWith('HTTP_4')) { + if (error.code !== 'HTTP_429') { + throw error; + } + // Handle rate limiting + const retryAfter = error.details?.retryAfter || 5000; + await this.sleep(retryAfter); + continue; + } + + // Check if error is retryable + const isRetryable = this.retryConfig.retryableErrors.some( + errCode => lastError?.message.includes(errCode) + ); + + if (!isRetryable || attempt === this.retryConfig.maxAttempts) { + throw error; + } + + // Calculate backoff delay + const delay = Math.min( + this.retryConfig.initialDelay * Math.pow(this.retryConfig.backoffMultiplier, attempt - 1), + this.retryConfig.maxDelay + ); + + this.logger.warn(`Request failed, retrying (attempt ${attempt}/${this.retryConfig.maxAttempts})`, { + component: 'ProxmoxClient', + operation: 'requestWithRetry', + metadata: { endpoint, attempt, delay } + }); + + await this.sleep(delay); + } + } + + throw lastError; + } + + private async request( + method: string, + endpoint: string, + data?: unknown, + useAuth: boolean = true + ): Promise { + const url = `${this.baseUrl}${endpoint}`; + const headers: Record = { + 'Content-Type': 'application/json' + }; + + // Add authentication + if (useAuth) { + if (this.config.token) { + headers['Authorization'] = `PVEAPIToken=${this.config.token}`; + } else if (this.ticket) { + headers['Cookie'] = `PVEAuthCookie=${this.ticket}`; + if (method !== 'GET' && this.csrfToken) { + headers['CSRFPreventionToken'] = this.csrfToken; + } + } + } + + try { + const response = await this.fetchWithTimeout(url, { + method, + headers, + body: data ? JSON.stringify(data) : undefined, + agent: this.httpsAgent + }); + + return await this.handleResponse(response); + } catch (error) { + // Handle ticket expiration + if (error instanceof ProxmoxAuthenticationError && this.ticket) { + this.logger.info('Authentication ticket expired, re-authenticating', { + component: 'ProxmoxClient', + operation: 'request' + }); + await this.authenticate(); + // Retry request with new ticket + return await this.request(method, endpoint, data, useAuth); + } + throw error; + } + } + + private async handleResponse(response: Response): Promise { + // Handle authentication errors + if (response.status === 401 || response.status === 403) { + throw new ProxmoxAuthenticationError( + 'Authentication failed', + { status: response.status } + ); + } + + // Handle not found + if (response.status === 404) { + throw new ProxmoxError( + 'Resource not found', + 'HTTP_404', + { status: response.status } + ); + } + + // Handle other errors + if (!response.ok) { + const errorText = await response.text(); + throw new ProxmoxError( + `Proxmox API error: ${response.statusText}`, + `HTTP_${response.status}`, + { + status: response.status, + statusText: response.statusText, + body: errorText + } + ); + } + + // Parse JSON response + const json = await response.json(); + return json.data; // Proxmox wraps responses in {data: ...} + } + + private createHttpsAgent(sslConfig: ProxmoxSSLConfig): https.Agent { + const agentOptions: https.AgentOptions = { + rejectUnauthorized: sslConfig.rejectUnauthorized ?? true + }; + + if (sslConfig.ca) { + agentOptions.ca = fs.readFileSync(sslConfig.ca); + } + + if (sslConfig.cert) { + agentOptions.cert = fs.readFileSync(sslConfig.cert); + } + + if (sslConfig.key) { + agentOptions.key = fs.readFileSync(sslConfig.key); + } + + return new https.Agent(agentOptions); + } + + private async fetchWithTimeout( + url: string, + options: RequestInit & { agent?: https.Agent }, + timeout: number = 30000 + ): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + try { + const response = await fetch(url, { + ...options, + signal: controller.signal + }); + return response; + } finally { + clearTimeout(timeoutId); + } + } + + private sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); + } +} +``` + +## Data Models + +### Type Definitions + +**File**: `pabawi/backend/src/integrations/proxmox/types.ts` + +```typescript +/** + * Proxmox configuration + */ +export interface ProxmoxConfig { + host: string; + port?: number; + username?: string; + password?: string; + realm?: string; + token?: string; + ssl?: ProxmoxSSLConfig; + timeout?: number; +} + +/** + * SSL configuration for Proxmox client + */ +export interface ProxmoxSSLConfig { + rejectUnauthorized?: boolean; + ca?: string; + cert?: string; + key?: string; +} + +/** + * Proxmox guest (VM or LXC) from API + */ +export interface ProxmoxGuest { + vmid: number; + name: string; + node: string; + type: 'qemu' | 'lxc'; + status: 'running' | 'stopped' | 'paused'; + maxmem?: number; + maxdisk?: number; + cpus?: number; + uptime?: number; + netin?: number; + netout?: number; + diskread?: number; + diskwrite?: number; +} + +/** + * Proxmox guest configuration + */ +export interface ProxmoxGuestConfig { + vmid: number; + name: string; + cores: number; + memory: number; + sockets?: number; + cpu?: string; + bootdisk?: string; + scsihw?: string; + net0?: string; + net1?: string; + ide2?: string; + [key: string]: unknown; +} + +/** + * Proxmox guest status + */ +export interface ProxmoxGuestStatus { + status: 'running' | 'stopped' | 'paused'; + vmid: number; + uptime?: number; + cpus?: number; + maxmem?: number; + mem?: number; + maxdisk?: number; + disk?: number; + netin?: number; + netout?: number; + diskread?: number; + diskwrite?: number; +} + +/** + * VM creation parameters + */ +export interface VMCreateParams { + vmid: number; + name: string; + node: string; + cores?: number; + memory?: number; + sockets?: number; + cpu?: string; + scsi0?: string; + ide2?: string; + net0?: string; + ostype?: string; + [key: string]: unknown; +} + +/** + * LXC creation parameters + */ +export interface LXCCreateParams { + vmid: number; + hostname: string; + node: string; + ostemplate: string; + cores?: number; + memory?: number; + rootfs?: string; + net0?: string; + password?: string; + [key: string]: unknown; +} + +/** + * Proxmox task status + */ +export interface ProxmoxTaskStatus { + status: 'running' | 'stopped'; + exitstatus?: string; + type: string; + node: string; + pid: number; + pstart: number; + starttime: number; + upid: string; +} + +/** + * Provisioning capability interface + */ +export interface ProvisioningCapability extends Capability { + operation: 'create' | 'destroy'; +} + +/** + * Retry configuration + */ +export interface RetryConfig { + maxAttempts: number; + initialDelay: number; + maxDelay: number; + backoffMultiplier: number; + retryableErrors: string[]; +} + +/** + * Proxmox error classes + */ +export class ProxmoxError extends Error { + constructor( + message: string, + public code: string, + public details?: unknown + ) { + super(message); + this.name = 'ProxmoxError'; + } +} + +export class ProxmoxAuthenticationError extends ProxmoxError { + constructor(message: string, details?: unknown) { + super(message, 'PROXMOX_AUTH_ERROR', details); + this.name = 'ProxmoxAuthenticationError'; + } +} + +export class ProxmoxConnectionError extends ProxmoxError { + constructor(message: string, details?: unknown) { + super(message, 'PROXMOX_CONNECTION_ERROR', details); + this.name = 'ProxmoxConnectionError'; + } +} +``` + +### API Endpoint Mappings + +| Operation | HTTP Method | Endpoint | Description | +|-----------|-------------|----------|-------------| +| Get version | GET | `/api2/json/version` | Get Proxmox VE version | +| Authenticate | POST | `/api2/json/access/ticket` | Get authentication ticket | +| List resources | GET | `/api2/json/cluster/resources?type=vm` | List all VMs and containers | +| Get VM config | GET | `/api2/json/nodes/{node}/qemu/{vmid}/config` | Get VM configuration | +| Get LXC config | GET | `/api2/json/nodes/{node}/lxc/{vmid}/config` | Get container configuration | +| Get VM status | GET | `/api2/json/nodes/{node}/qemu/{vmid}/status/current` | Get VM current status | +| Get LXC status | GET | `/api2/json/nodes/{node}/lxc/{vmid}/status/current` | Get container current status | +| Start VM | POST | `/api2/json/nodes/{node}/qemu/{vmid}/status/start` | Start a VM | +| Start LXC | POST | `/api2/json/nodes/{node}/lxc/{vmid}/status/start` | Start a container | +| Stop VM | POST | `/api2/json/nodes/{node}/qemu/{vmid}/status/stop` | Force stop a VM | +| Stop LXC | POST | `/api2/json/nodes/{node}/lxc/{vmid}/status/stop` | Force stop a container | +| Shutdown VM | POST | `/api2/json/nodes/{node}/qemu/{vmid}/status/shutdown` | Graceful shutdown VM | +| Shutdown LXC | POST | `/api2/json/nodes/{node}/lxc/{vmid}/status/shutdown` | Graceful shutdown container | +| Reboot VM | POST | `/api2/json/nodes/{node}/qemu/{vmid}/status/reboot` | Reboot a VM | +| Reboot LXC | POST | `/api2/json/nodes/{node}/lxc/{vmid}/status/reboot` | Reboot a container | +| Suspend VM | POST | `/api2/json/nodes/{node}/qemu/{vmid}/status/suspend` | Suspend a VM | +| Resume VM | POST | `/api2/json/nodes/{node}/qemu/{vmid}/status/resume` | Resume a VM | +| Create VM | POST | `/api2/json/nodes/{node}/qemu` | Create a new VM | +| Create LXC | POST | `/api2/json/nodes/{node}/lxc` | Create a new container | +| Delete VM | DELETE | `/api2/json/nodes/{node}/qemu/{vmid}` | Delete a VM | +| Delete LXC | DELETE | `/api2/json/nodes/{node}/lxc/{vmid}` | Delete a container | +| Get task status | GET | `/api2/json/nodes/{node}/tasks/{upid}/status` | Get task status | + +### New Type Definition in Core Types + +**File**: `pabawi/backend/src/integrations/types.ts` + +Add the following interface: + +```typescript +/** + * Provisioning capability for infrastructure creation/destruction + */ +export interface ProvisioningCapability extends Capability { + operation: 'create' | 'destroy'; +} +``` + +## Correctness Properties + +*A property is a characteristic or behavior that should hold true across all valid executions of a system—essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.* + +### Property Reflection Analysis + +After analyzing all acceptance criteria, I identified the following redundancies and consolidations: + +**Redundancy Group 1: Configuration Validation** + +- Requirements 2.3, 2.4, 16.1, 16.2, 16.3, 16.5, 16.6 all relate to configuration validation +- These can be consolidated into comprehensive properties about invalid configuration rejection + +**Redundancy Group 2: Node Transformation** + +- Requirements 5.3, 5.4, 5.5, 5.6, 5.7 all relate to guest-to-node transformation +- These can be consolidated into properties about transformation correctness + +**Redundancy Group 3: Group ID Formatting** + +- Requirements 6.5, 6.6, 6.7 all relate to group ID format validation +- These can be consolidated into a single property about ID format correctness + +**Redundancy Group 4: Error Handling** + +- Requirements 14.1, 14.7 relate to general error handling +- Requirements 3.5, 7.7, 8.10, 10.7, 11.7, 12.7 relate to specific error cases +- These can be consolidated into properties about error message descriptiveness + +**Redundancy Group 5: Authentication** + +- Requirements 3.1, 3.2, 3.6 relate to authentication behavior +- These can be consolidated into properties about authentication correctness + +### Property 1: Configuration Validation Rejects Invalid Inputs + +*For any* configuration object with missing required fields (host, authentication credentials), invalid port numbers (outside 1-65535), invalid hostnames, or missing realm for password authentication, initialization should fail with a descriptive error indicating the specific validation failure. + +**Validates: Requirements 2.3, 2.4, 16.1, 16.2, 16.3, 16.5, 16.6** + +### Property 2: HTTPS Protocol Usage + +*For any* API call made by ProxmoxClient, the request URL should use the HTTPS protocol. + +**Validates: Requirements 3.6** + +### Property 3: Authentication Ticket Persistence + +*For any* authenticated ProxmoxClient using password authentication, subsequent API calls should reuse the stored authentication ticket without re-authenticating until the ticket expires. + +**Validates: Requirements 3.2** + +### Property 4: Guest-to-Node Transformation Completeness + +*For any* Proxmox guest object returned from the API, the transformed Node object should contain all required fields (id, name, status, metadata with node and type), the type field should correctly distinguish between 'qemu' and 'lxc', and IP addresses should be included when available or omitted (not null) when unavailable. + +**Validates: Requirements 5.3, 5.4, 5.5, 5.6, 5.7** + +### Property 5: Group Creation by Node + +*For any* set of guests distributed across multiple Proxmox nodes, calling getGroups() should create one NodeGroup per unique node, where each group contains exactly the guests on that node. + +**Validates: Requirements 6.2** + +### Property 6: Group Creation by Status + +*For any* set of guests with various status values, calling getGroups() should create one NodeGroup per unique status, where each group contains exactly the guests with that status. + +**Validates: Requirements 6.3** + +### Property 7: Group Creation by Type + +*For any* set of guests containing both VMs and LXC containers, calling getGroups() should create exactly two type-based groups: one for VMs and one for LXC containers. + +**Validates: Requirements 6.4** + +### Property 8: Group ID Format Correctness + +*For any* NodeGroup created by the Proxmox integration, the group ID should follow the correct format: "proxmox:node:{nodename}" for node groups, "proxmox:status:{status}" for status groups, and "proxmox:type:{type}" for type groups. + +**Validates: Requirements 6.5, 6.6, 6.7** + +### Property 9: Facts Transformation Completeness + +*For any* guest configuration and status data from the Proxmox API, the transformed Facts object should include CPU, memory, disk, and network configuration fields, and should include current resource usage when the guest status is 'running'. + +**Validates: Requirements 7.4, 7.5, 7.6** + +### Property 10: Non-Existent Guest Error + +*For any* VMID that does not exist on a given node, calling getNodeFacts() or destroyGuest() should throw a descriptive error indicating the guest was not found. + +**Validates: Requirements 7.7, 12.7** + +### Property 11: Action Completion Waiting + +*For any* lifecycle action (start, stop, shutdown, reboot, suspend, resume) or provisioning action (create, destroy), the service should wait for the Proxmox task to complete before returning the result. + +**Validates: Requirements 8.9, 10.5, 11.5, 12.5** + +### Property 12: Failed Action Error Details + +*For any* action that fails (lifecycle or provisioning), the returned ExecutionResult should have success=false and include descriptive error details. + +**Validates: Requirements 8.10, 10.7, 11.7** + +### Property 13: VMID Uniqueness Validation + +*For any* VM or LXC creation request with a VMID that already exists on the target node, the creation should fail with an error indicating the VMID is already in use. + +**Validates: Requirements 10.4, 11.4** + +### Property 14: Running Guest Stop Before Destruction + +*For any* guest that is in 'running' status, calling destroyGuest() should first stop the guest before deleting it. + +**Validates: Requirements 12.3** + +### Property 15: Task Completion Detection + +*For any* Proxmox task, calling waitForTask() should poll the task status endpoint until the task status is 'stopped', then return success if exitstatus is 'OK' or throw an error with the task's error message otherwise. + +**Validates: Requirements 13.4, 13.5** + +### Property 16: Custom Timeout Respect + +*For any* custom timeout value provided to waitForTask(), the method should use that timeout instead of the default 300 seconds. + +**Validates: Requirements 13.7** + +### Property 17: HTTP Error Transformation + +*For any* HTTP error response from the Proxmox API, ProxmoxClient should transform it into a descriptive ProxmoxError with an appropriate error code and details. + +**Validates: Requirements 14.1** + +### Property 18: Retry Logic for Transient Failures + +*For any* transient network failure (ECONNRESET, ETIMEDOUT, ENOTFOUND), ProxmoxClient should retry the request up to 3 times with exponential backoff. + +**Validates: Requirements 15.2** + +### Property 19: No Retry for Authentication Failures + +*For any* authentication failure (401, 403 except ticket expiration), ProxmoxClient should not retry the request and should immediately throw a ProxmoxAuthenticationError. + +**Validates: Requirements 15.3** + +### Property 20: No Retry for Client Errors + +*For any* 4xx HTTP error except 429 (rate limiting), ProxmoxClient should not retry the request and should immediately throw an error. + +**Validates: Requirements 15.4** + +### Property 21: Error Logging + +*For any* error that occurs in ProxmoxService, the error should be logged using LoggerService with appropriate context including component name and operation. + +**Validates: Requirements 14.7** + +### Property 22: Inventory Cache Behavior + +*For any* two calls to getInventory() within 60 seconds, the second call should return cached results without querying the Proxmox API. + +**Validates: Requirements 20.1** + +### Property 23: Groups Cache Behavior + +*For any* two calls to getGroups() within 60 seconds, the second call should return cached results without querying the Proxmox API. + +**Validates: Requirements 20.2** + +### Property 24: Facts Cache Behavior + +*For any* two calls to getNodeFacts() for the same node within 30 seconds, the second call should return cached results without querying the Proxmox API. + +**Validates: Requirements 20.3** + +## Error Handling + +### Error Hierarchy + +``` +Error +├── ProxmoxError (base class for all Proxmox errors) +│ ├── ProxmoxAuthenticationError (401, 403 errors) +│ ├── ProxmoxConnectionError (network failures, timeouts) +│ └── ProxmoxTaskError (task execution failures) +``` + +### Error Handling Strategy + +**ProxmoxClient Layer** + +- Catches all HTTP errors and transforms them into domain-specific exceptions +- Maps HTTP status codes to appropriate error types: + - 401/403 → ProxmoxAuthenticationError + - 404 → ProxmoxError with HTTP_404 code + - 429 → ProxmoxError with HTTP_429 code (triggers retry with backoff) + - 5xx → ProxmoxError with HTTP_5xx code + - Network errors → ProxmoxConnectionError +- Implements automatic ticket refresh on 401 errors for password authentication +- Logs all errors with context before throwing + +**ProxmoxService Layer** + +- Catches errors from ProxmoxClient +- Adds business logic context to errors +- Logs errors with operation-specific details +- Transforms errors into ExecutionResult objects for action methods +- Re-throws errors for information retrieval methods (getInventory, getNodeFacts, etc.) + +**ProxmoxIntegration Layer** + +- Catches errors from ProxmoxService during health checks +- Returns degraded status for authentication errors +- Returns unhealthy status for other errors +- Allows errors to propagate for data retrieval and action execution + +### Error Response Examples + +**Authentication Failure** + +```typescript +throw new ProxmoxAuthenticationError( + 'Failed to authenticate with Proxmox API', + { + username: config.username, + realm: config.realm, + host: config.host + } +); +``` + +**Guest Not Found** + +```typescript +throw new ProxmoxError( + `Guest ${vmid} not found on node ${node}`, + 'GUEST_NOT_FOUND', + { vmid, node } +); +``` + +**Task Timeout** + +```typescript +throw new ProxmoxError( + `Task timeout after ${timeout}ms`, + 'TASK_TIMEOUT', + { taskId, node, timeout } +); +``` + +**VMID Already Exists** + +```typescript +return { + success: false, + error: `VM with VMID ${vmid} already exists on node ${node}`, + metadata: { vmid, node } +}; +``` + +### Retry Logic + +**Retryable Errors** + +- Network timeouts (ETIMEDOUT) +- Connection resets (ECONNRESET) +- DNS resolution failures (ENOTFOUND) +- 429 Rate Limiting (with Retry-After header respect) +- 5xx Server errors + +**Non-Retryable Errors** + +- Authentication failures (401, 403) +- Client errors (400, 404, etc.) +- Validation errors +- Resource not found errors + +**Retry Configuration** + +- Maximum attempts: 3 +- Initial delay: 1000ms +- Maximum delay: 10000ms +- Backoff multiplier: 2 (exponential backoff) +- Delay calculation: `min(initialDelay * (multiplier ^ attempt), maxDelay)` + +### Logging Strategy + +All errors are logged with structured context: + +```typescript +this.logger.error('Failed to create VM', { + component: 'ProxmoxService', + operation: 'createVM', + metadata: { + vmid: params.vmid, + node: params.node, + error: error.message + } +}, error); +``` + +## Testing Strategy + +### Dual Testing Approach + +The Proxmox integration requires both unit tests and property-based tests for comprehensive coverage: + +**Unit Tests** - Focus on: + +- Specific examples of successful operations +- Edge cases (empty responses, missing fields) +- Error conditions (network failures, auth failures) +- Integration points between components +- Mock Proxmox API responses + +**Property-Based Tests** - Focus on: + +- Universal properties across all inputs +- Data transformation correctness +- Configuration validation +- Caching behavior +- Retry logic +- Error handling patterns + +### Property-Based Testing Configuration + +**Testing Library**: Use `fast-check` for TypeScript property-based testing + +**Test Configuration**: + +- Minimum 100 iterations per property test +- Each test must reference its design document property +- Tag format: `Feature: proxmox-integration, Property {number}: {property_text}` + +**Example Property Test**: + +```typescript +import fc from 'fast-check'; + +describe('Proxmox Integration Properties', () => { + // Feature: proxmox-integration, Property 1: Configuration Validation Rejects Invalid Inputs + it('should reject configurations with invalid ports', () => { + fc.assert( + fc.property( + fc.integer({ min: -1000, max: 0 }).chain(n => + fc.constant(n).chain(port => + fc.record({ + host: fc.constant('proxmox.example.com'), + port: fc.constant(port), + username: fc.constant('root'), + password: fc.constant('password'), + realm: fc.constant('pam') + }) + ) + ), + (config) => { + const integration = new ProxmoxIntegration(); + expect(() => integration.initialize({ + enabled: true, + name: 'proxmox', + type: 'both', + config + })).toThrow(/port must be between 1 and 65535/); + } + ), + { numRuns: 100 } + ); + }); + + // Feature: proxmox-integration, Property 4: Guest-to-Node Transformation Completeness + it('should transform any guest to a complete Node object', () => { + fc.assert( + fc.property( + fc.record({ + vmid: fc.integer({ min: 100, max: 999999 }), + name: fc.string({ minLength: 1, maxLength: 50 }), + node: fc.string({ minLength: 1, maxLength: 20 }), + type: fc.constantFrom('qemu', 'lxc'), + status: fc.constantFrom('running', 'stopped', 'paused'), + maxmem: fc.option(fc.integer({ min: 0 })), + cpus: fc.option(fc.integer({ min: 1, max: 128 })) + }), + (guest) => { + const service = new ProxmoxService(mockConfig, mockLogger, mockPerfMonitor); + const node = service['transformGuestToNode'](guest); + + expect(node.id).toBeDefined(); + expect(node.name).toBe(guest.name); + expect(node.status).toBe(guest.status); + expect(node.metadata?.node).toBe(guest.node); + expect(node.metadata?.type).toBe(guest.type); + + // IP should be omitted (not null) when unavailable + if (node.ip !== undefined) { + expect(typeof node.ip).toBe('string'); + } + } + ), + { numRuns: 100 } + ); + }); +}); +``` + +### Unit Test Coverage Requirements + +**ProxmoxClient Tests**: + +- Authentication with password and token +- Ticket refresh on expiration +- HTTP error handling (401, 403, 404, 429, 5xx) +- Retry logic with exponential backoff +- Task polling and timeout +- Request/response transformation + +**ProxmoxService Tests**: + +- Inventory retrieval and caching +- Group creation (by node, status, type) +- Facts retrieval and caching +- VM creation and validation +- LXC creation and validation +- Guest destruction with stop-before-delete +- Action execution (start, stop, shutdown, etc.) +- Cache clearing + +**ProxmoxIntegration Tests**: + +- Plugin initialization +- Configuration validation +- Health check (healthy, degraded, unhealthy) +- Interface method delegation +- Capability listing + +### Test Data Generators + +Create reusable generators for property tests: + +```typescript +// Generator for valid Proxmox configurations +const validConfigArb = fc.record({ + host: fc.domain(), + port: fc.integer({ min: 1, max: 65535 }), + username: fc.string({ minLength: 1 }), + password: fc.string({ minLength: 1 }), + realm: fc.constantFrom('pam', 'pve') +}); + +// Generator for Proxmox guests +const guestArb = fc.record({ + vmid: fc.integer({ min: 100, max: 999999 }), + name: fc.string({ minLength: 1, maxLength: 50 }), + node: fc.string({ minLength: 1, maxLength: 20 }), + type: fc.constantFrom('qemu', 'lxc'), + status: fc.constantFrom('running', 'stopped', 'paused') +}); + +// Generator for VM creation parameters +const vmCreateParamsArb = fc.record({ + vmid: fc.integer({ min: 100, max: 999999 }), + name: fc.string({ minLength: 1, maxLength: 50 }), + node: fc.string({ minLength: 1, maxLength: 20 }), + cores: fc.integer({ min: 1, max: 128 }), + memory: fc.integer({ min: 512, max: 1048576 }) +}); +``` + +### Mock Strategy + +Use Jest mocks for external dependencies: + +```typescript +// Mock ProxmoxClient for ProxmoxService tests +jest.mock('./ProxmoxClient'); + +// Mock fetch for ProxmoxClient tests +global.fetch = jest.fn(); + +// Mock LoggerService +const mockLogger = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn() +}; + +// Mock PerformanceMonitorService +const mockPerfMonitor = { + startTimer: jest.fn(() => jest.fn()) +}; +``` + +### Integration Test Considerations + +While unit and property tests cover most scenarios, integration tests against a real Proxmox instance would validate: + +- Actual API compatibility +- Network behavior +- Authentication flows +- Task polling timing +- Real-world error scenarios + +These should be run in a CI/CD environment with a test Proxmox cluster. + +## Performance Considerations + +### Caching Strategy + +**Cache Implementation**: Use `SimpleCache` utility (same as PuppetDB integration) + +**Cache TTLs**: + +- Inventory: 60 seconds (configurable) +- Groups: 60 seconds (configurable) +- Facts: 30 seconds (configurable) +- Health checks: 30 seconds (inherited from BasePlugin) + +**Cache Keys**: + +- Inventory: `inventory:all` +- Groups: `groups:all` +- Facts: `facts:{nodeId}` + +**Cache Invalidation**: + +- Automatic expiration after TTL +- Manual clearing via `clearCache()` method +- Automatic clearing after provisioning operations (create/destroy) + +**Cache Benefits**: + +- Reduces load on Proxmox API +- Improves response times for repeated queries +- Prevents rate limiting issues +- Reduces network latency impact + +### Parallel API Calls + +When fetching data for multiple guests, use `Promise.all()` to execute requests in parallel: + +```typescript +async getMultipleGuestFacts(nodeIds: string[]): Promise { + const factsPromises = nodeIds.map(nodeId => this.getNodeFacts(nodeId)); + return await Promise.all(factsPromises); +} +``` + +### Connection Pooling + +**HTTP Agent Configuration**: + +- Reuse HTTPS agent across requests +- Configure keep-alive for connection reuse +- Set appropriate timeout values + +```typescript +const httpsAgent = new https.Agent({ + keepAlive: true, + keepAliveMsecs: 30000, + maxSockets: 50, + maxFreeSockets: 10, + timeout: 30000 +}); +``` + +### Performance Monitoring + +Use `PerformanceMonitorService` to track operation durations: + +```typescript +async getInventory(): Promise { + const complete = this.performanceMonitor.startTimer('proxmox:getInventory'); + + try { + // ... operation logic + complete({ cached: false, nodeCount: nodes.length }); + return nodes; + } catch (error) { + complete({ error: error.message }); + throw error; + } +} +``` + +**Monitored Operations**: + +- `proxmox:getInventory` +- `proxmox:getGroups` +- `proxmox:getNodeFacts` +- `proxmox:executeAction` +- `proxmox:createVM` +- `proxmox:createLXC` +- `proxmox:destroyGuest` + +### Optimization Recommendations + +1. **Batch Operations**: When possible, use cluster-wide endpoints instead of per-node queries +2. **Selective Field Retrieval**: Only request needed fields from Proxmox API +3. **Lazy Loading**: Defer expensive operations until actually needed +4. **Debouncing**: For UI-triggered operations, implement debouncing to prevent excessive API calls +5. **Background Refresh**: Consider background cache refresh for frequently accessed data + +## Security Considerations + +### Authentication Security + +**Token Authentication (Recommended)**: + +- More secure than password authentication +- No credential transmission after initial setup +- Supports fine-grained permissions +- Can be easily revoked + +**Password Authentication**: + +- Requires secure credential storage +- Credentials transmitted during ticket acquisition +- Ticket has limited lifetime (2 hours by default) +- Automatic ticket refresh on expiration + +### TLS/SSL Configuration + +**Certificate Verification**: + +- Enabled by default (`rejectUnauthorized: true`) +- Log security warning when disabled +- Support custom CA certificates for self-signed certs + +**Secure Communication**: + +- All API calls use HTTPS +- No fallback to HTTP +- Support for client certificates + +### Credential Management + +**Configuration Storage**: + +- Never log passwords or tokens +- Store credentials in environment variables or secure vaults +- Use configuration encryption at rest + +**Error Messages**: + +- Sanitize error messages to avoid credential leakage +- Don't include passwords in error details +- Log authentication failures without sensitive data + +### Input Validation + +**Configuration Validation**: + +- Validate host format (prevent injection) +- Validate port range +- Validate required fields +- Sanitize user inputs + +**API Parameter Validation**: + +- Validate VMID format and range +- Validate node names +- Sanitize guest names and descriptions +- Prevent command injection in parameters + +### Rate Limiting + +**Client-Side Rate Limiting**: + +- Respect Retry-After headers +- Implement exponential backoff +- Limit concurrent requests + +**Error Handling**: + +- Don't expose internal system details in errors +- Log security-relevant events +- Monitor for suspicious patterns + +## Deployment Considerations + +### Configuration Example + +```typescript +// In pabawi configuration +{ + integrations: { + proxmox: { + enabled: true, + name: 'proxmox', + type: 'both', + priority: 10, + config: { + host: process.env.PROXMOX_HOST || 'proxmox.example.com', + port: parseInt(process.env.PROXMOX_PORT || '8006'), + token: process.env.PROXMOX_TOKEN, // Preferred + // OR password authentication: + // username: process.env.PROXMOX_USERNAME, + // password: process.env.PROXMOX_PASSWORD, + // realm: process.env.PROXMOX_REALM || 'pam', + ssl: { + rejectUnauthorized: process.env.PROXMOX_SSL_VERIFY !== 'false', + ca: process.env.PROXMOX_CA_CERT, + cert: process.env.PROXMOX_CLIENT_CERT, + key: process.env.PROXMOX_CLIENT_KEY + }, + timeout: 30000 + } + } + } +} +``` + +### Environment Variables + +```bash +# Required +PROXMOX_HOST=proxmox.example.com +PROXMOX_PORT=8006 + +# Token authentication (recommended) +PROXMOX_TOKEN=user@realm!tokenid=uuid + +# OR password authentication +PROXMOX_USERNAME=root +PROXMOX_PASSWORD=secret +PROXMOX_REALM=pam + +# Optional SSL configuration +PROXMOX_SSL_VERIFY=true +PROXMOX_CA_CERT=/path/to/ca.pem +PROXMOX_CLIENT_CERT=/path/to/client.pem +PROXMOX_CLIENT_KEY=/path/to/client-key.pem +``` + +### Proxmox API Token Setup + +To create an API token in Proxmox: + +1. Navigate to Datacenter → Permissions → API Tokens +2. Click "Add" to create a new token +3. Select user and enter token ID +4. Optionally disable "Privilege Separation" for full user permissions +5. Copy the generated token (format: `user@realm!tokenid=uuid`) +6. Set appropriate permissions for the token user + +**Recommended Permissions**: + +- VM.Allocate (for creating VMs) +- VM.Config.* (for configuring VMs) +- VM.PowerMgmt (for start/stop operations) +- VM.Audit (for reading VM information) +- Datastore.Allocate (for disk operations) + +### Health Check Integration + +The integration provides health check endpoints that can be monitored: + +```typescript +// Check Proxmox integration health +const health = await integrationManager.healthCheckAll(); +const proxmoxHealth = health.get('proxmox'); + +if (!proxmoxHealth.healthy) { + // Alert or take corrective action + logger.error('Proxmox integration unhealthy', { health: proxmoxHealth }); +} +``` + +### Monitoring and Alerting + +**Key Metrics to Monitor**: + +- Health check status +- API response times +- Cache hit rates +- Error rates by type +- Task completion times +- Authentication failures + +**Alerting Thresholds**: + +- Health check failures > 3 consecutive +- API response time > 5 seconds +- Error rate > 10% of requests +- Authentication failures > 5 per hour + +## Migration and Compatibility + +### Backward Compatibility + +This integration introduces new functionality without breaking existing integrations: + +1. **New Interface**: `ProvisioningCapability` extends `Capability` without modifying existing interfaces +2. **Optional Methods**: Provisioning methods are optional additions to ExecutionToolPlugin +3. **Independent Operation**: Proxmox integration operates independently of other integrations + +### Integration with Existing Systems + +**IntegrationManager Compatibility**: + +- Follows existing plugin registration pattern +- Uses standard health check caching +- Participates in inventory aggregation +- Supports group linking across sources + +**Data Model Compatibility**: + +- Uses existing `Node`, `Facts`, `NodeGroup` types +- Follows existing node ID format: `{source}:{identifier}` +- Compatible with existing UI components + +### Future Extensibility + +**Planned Enhancements**: + +1. Support for Proxmox Backup Server integration +2. Storage management capabilities +3. Network configuration management +4. Snapshot management +5. Template management +6. Cluster management operations + +**Extension Points**: + +- Additional provisioning capabilities can be added to `listProvisioningCapabilities()` +- New action types can be added to `executeAction()` +- Additional data types can be supported in `getNodeData()` + +## References + +- [Proxmox VE API Documentation](https://pve.proxmox.com/pve-docs/api-viewer/) +- [Proxmox VE API Wiki](https://pve.proxmox.com/wiki/Proxmox_VE_API) +- [PuppetDB Integration](pabawi/backend/src/integrations/puppetdb/) - Reference implementation +- [BasePlugin](pabawi/backend/src/integrations/BasePlugin.ts) - Plugin base class +- [Integration Types](pabawi/backend/src/integrations/types.ts) - Core interfaces diff --git a/.kiro/specs/proxmox-integration/requirements.md b/.kiro/specs/proxmox-integration/requirements.md new file mode 100644 index 0000000..2e9bf7c --- /dev/null +++ b/.kiro/specs/proxmox-integration/requirements.md @@ -0,0 +1,302 @@ +# Requirements Document + +## Introduction + +This document specifies requirements for integrating Proxmox Virtual Environment (VE) into Pabawi. Proxmox VE is an open-source virtualization management platform that provides a REST API for managing virtual machines (VMs) and Linux containers (LXC). This integration introduces a new "provisioning" capability type to the system, enabling VM and container lifecycle management alongside existing inventory, facts, and action capabilities. + +The integration follows Pabawi's existing plugin architecture pattern used by PuppetDB, Bolt, Ansible, SSH, Hiera, and Puppetserver integrations. + +## Glossary + +- **Proxmox_Integration**: The plugin component that interfaces with Proxmox VE API +- **Proxmox_Service**: The service layer that handles API communication and data transformation +- **Proxmox_Client**: The HTTP client that executes REST API calls to Proxmox VE +- **VM**: Virtual Machine managed by Proxmox VE +- **LXC**: Linux Container managed by Proxmox VE +- **Guest**: Either a VM or LXC container +- **Node**: A physical Proxmox server in the cluster +- **Cluster**: A group of Proxmox nodes working together +- **VMID**: Unique numeric identifier for a guest (VM or LXC) +- **Integration_Manager**: The system component that orchestrates multiple integration plugins +- **Provisioning_Capability**: A new capability type for creating and destroying infrastructure resources +- **Inventory_Capability**: Capability to discover and list managed resources +- **Facts_Capability**: Capability to retrieve detailed information about specific resources +- **Action_Capability**: Capability to perform operations on existing resources + +## Requirements + +### Requirement 1: Plugin Architecture Compliance + +**User Story:** As a system architect, I want the Proxmox integration to follow the existing plugin architecture, so that it integrates seamlessly with other plugins. + +#### Acceptance Criteria + +1. THE Proxmox_Integration SHALL extend the BasePlugin class +2. THE Proxmox_Integration SHALL implement the InformationSourcePlugin interface +3. THE Proxmox_Integration SHALL implement the ExecutionToolPlugin interface +4. THE Proxmox_Integration SHALL register with the Integration_Manager during initialization +5. THE Proxmox_Integration SHALL provide a configuration schema matching the IntegrationConfig type +6. THE Proxmox_Integration SHALL use LoggerService for all logging operations +7. THE Proxmox_Integration SHALL use PerformanceMonitorService for performance tracking + +### Requirement 2: Configuration Management + +**User Story:** As a system administrator, I want to configure Proxmox connection settings, so that the integration can connect to my Proxmox cluster. + +#### Acceptance Criteria + +1. THE Proxmox_Integration SHALL accept a configuration object containing host, port, username, password, and realm fields +2. WHERE token authentication is configured, THE Proxmox_Integration SHALL use API token authentication instead of password authentication +3. THE Proxmox_Integration SHALL validate required configuration fields during initialization +4. WHEN invalid configuration is provided, THE Proxmox_Integration SHALL throw a descriptive error +5. THE Proxmox_Integration SHALL support TLS certificate verification configuration +6. WHERE certificate verification is disabled, THE Proxmox_Integration SHALL log a security warning + +### Requirement 3: Authentication and Connection + +**User Story:** As a system administrator, I want the integration to authenticate with Proxmox securely, so that API operations are authorized. + +#### Acceptance Criteria + +1. WHEN initialized, THE Proxmox_Client SHALL authenticate with the Proxmox API using provided credentials +2. THE Proxmox_Client SHALL store the authentication ticket for subsequent API calls +3. WHEN the authentication ticket expires, THE Proxmox_Client SHALL automatically re-authenticate +4. THE Proxmox_Client SHALL support both password-based and token-based authentication +5. WHEN authentication fails, THE Proxmox_Client SHALL return a descriptive error message +6. THE Proxmox_Client SHALL use HTTPS for all API communications + +### Requirement 4: Health Check + +**User Story:** As a system operator, I want to monitor the health of the Proxmox integration, so that I can detect connectivity issues. + +#### Acceptance Criteria + +1. THE Proxmox_Integration SHALL implement the performHealthCheck method +2. WHEN performHealthCheck is called, THE Proxmox_Service SHALL query the Proxmox API version endpoint +3. WHEN the API responds successfully, THE Proxmox_Integration SHALL return a healthy status +4. WHEN the API is unreachable, THE Proxmox_Integration SHALL return an unhealthy status with error details +5. WHEN authentication fails, THE Proxmox_Integration SHALL return a degraded status indicating authentication issues +6. THE Proxmox_Integration SHALL cache health check results for 30 seconds to prevent excessive API calls + +### Requirement 5: Inventory Discovery + +**User Story:** As a system operator, I want to discover all VMs and containers in Proxmox, so that I can manage them through Pabawi. + +#### Acceptance Criteria + +1. THE Proxmox_Service SHALL implement the getInventory method +2. WHEN getInventory is called, THE Proxmox_Service SHALL query all guests across all cluster nodes +3. THE Proxmox_Service SHALL transform each guest into a Node object with standardized fields +4. THE Proxmox_Service SHALL include VMID, name, status, node, and type in each Node object +5. THE Proxmox_Service SHALL distinguish between VMs and LXC containers using a type field +6. THE Proxmox_Service SHALL include IP addresses when available in the guest configuration +7. WHEN a guest has no IP address, THE Proxmox_Service SHALL omit the IP field rather than using null + +### Requirement 6: Group Management + +**User Story:** As a system operator, I want to organize guests by node, status, and type, so that I can manage groups of similar resources. + +#### Acceptance Criteria + +1. THE Proxmox_Service SHALL implement the getGroups method +2. THE Proxmox_Service SHALL create NodeGroup objects for each Proxmox node containing its guests +3. THE Proxmox_Service SHALL create NodeGroup objects for each status type containing guests with that status +4. THE Proxmox_Service SHALL create NodeGroup objects for VM and LXC types +5. THE Proxmox_Service SHALL use the format "proxmox:node:{nodename}" for node-based group IDs +6. THE Proxmox_Service SHALL use the format "proxmox:status:{status}" for status-based group IDs +7. THE Proxmox_Service SHALL use the format "proxmox:type:{type}" for type-based group IDs + +### Requirement 7: Facts Retrieval + +**User Story:** As a system operator, I want to retrieve detailed information about a specific guest, so that I can understand its configuration and state. + +#### Acceptance Criteria + +1. THE Proxmox_Service SHALL implement the getNodeFacts method +2. WHEN getNodeFacts is called with a VMID, THE Proxmox_Service SHALL query the guest configuration +3. THE Proxmox_Service SHALL query the guest status information +4. THE Proxmox_Service SHALL transform the configuration and status into a Facts object +5. THE Proxmox_Service SHALL include CPU, memory, disk, and network configuration in the Facts object +6. THE Proxmox_Service SHALL include current resource usage when the guest is running +7. WHEN the guest does not exist, THE Proxmox_Service SHALL throw a descriptive error + +### Requirement 8: VM Action Capabilities + +**User Story:** As a system operator, I want to start, stop, and pause VMs and containers, so that I can manage their lifecycle. + +#### Acceptance Criteria + +1. THE Proxmox_Integration SHALL implement the executeAction method +2. THE Proxmox_Integration SHALL support "start", "stop", "shutdown", "reboot", "suspend", and "resume" action types +3. WHEN executeAction is called with a start action, THE Proxmox_Service SHALL call the Proxmox start API endpoint +4. WHEN executeAction is called with a stop action, THE Proxmox_Service SHALL call the Proxmox stop API endpoint +5. WHEN executeAction is called with a shutdown action, THE Proxmox_Service SHALL call the Proxmox shutdown API endpoint +6. WHEN executeAction is called with a reboot action, THE Proxmox_Service SHALL call the Proxmox reboot API endpoint +7. WHEN executeAction is called with a suspend action, THE Proxmox_Service SHALL call the Proxmox suspend API endpoint +8. WHEN executeAction is called with a resume action, THE Proxmox_Service SHALL call the Proxmox resume API endpoint +9. THE Proxmox_Service SHALL wait for the action to complete before returning the result +10. WHEN an action fails, THE Proxmox_Service SHALL return an ExecutionResult with error details + +### Requirement 9: Provisioning Capability Type + +**User Story:** As a system architect, I want to define a new provisioning capability type, so that VM creation and destruction can be distinguished from other actions. + +#### Acceptance Criteria + +1. THE system SHALL define a ProvisioningCapability interface extending Capability +2. THE ProvisioningCapability interface SHALL include create and destroy operation types +3. THE Proxmox_Integration SHALL implement a listProvisioningCapabilities method +4. THE Proxmox_Integration SHALL return provisioning capabilities including "create_vm", "create_lxc", "destroy_vm", and "destroy_lxc" +5. THE Integration_Manager SHALL support querying plugins for provisioning capabilities +6. THE Integration_Manager SHALL aggregate provisioning capabilities from all plugins + +### Requirement 10: VM Creation + +**User Story:** As a system operator, I want to create new VMs through the Proxmox integration, so that I can provision infrastructure programmatically. + +#### Acceptance Criteria + +1. THE Proxmox_Service SHALL implement a createVM method +2. THE createVM method SHALL accept parameters for VMID, name, node, CPU cores, memory, disk size, and network configuration +3. WHEN createVM is called, THE Proxmox_Service SHALL call the Proxmox VM creation API endpoint +4. THE Proxmox_Service SHALL validate that the VMID is unique before creation +5. THE Proxmox_Service SHALL wait for the VM creation task to complete +6. WHEN VM creation succeeds, THE Proxmox_Service SHALL return the VMID and status +7. WHEN VM creation fails, THE Proxmox_Service SHALL return a descriptive error message + +### Requirement 11: LXC Container Creation + +**User Story:** As a system operator, I want to create new LXC containers through the Proxmox integration, so that I can provision lightweight containers. + +#### Acceptance Criteria + +1. THE Proxmox_Service SHALL implement a createLXC method +2. THE createLXC method SHALL accept parameters for VMID, name, node, CPU cores, memory, disk size, template, and network configuration +3. WHEN createLXC is called, THE Proxmox_Service SHALL call the Proxmox LXC creation API endpoint +4. THE Proxmox_Service SHALL validate that the VMID is unique before creation +5. THE Proxmox_Service SHALL wait for the LXC creation task to complete +6. WHEN LXC creation succeeds, THE Proxmox_Service SHALL return the VMID and status +7. WHEN LXC creation fails, THE Proxmox_Service SHALL return a descriptive error message + +### Requirement 12: Guest Destruction + +**User Story:** As a system operator, I want to destroy VMs and containers through the Proxmox integration, so that I can deprovision resources. + +#### Acceptance Criteria + +1. THE Proxmox_Service SHALL implement a destroyGuest method +2. WHEN destroyGuest is called with a VMID, THE Proxmox_Service SHALL verify the guest exists +3. WHEN the guest is running, THE Proxmox_Service SHALL stop it before destruction +4. THE Proxmox_Service SHALL call the Proxmox deletion API endpoint +5. THE Proxmox_Service SHALL wait for the deletion task to complete +6. WHEN destruction succeeds, THE Proxmox_Service SHALL return a success status +7. WHEN the guest does not exist, THE Proxmox_Service SHALL return an error indicating the guest was not found + +### Requirement 13: Task Status Monitoring + +**User Story:** As a system operator, I want to monitor the status of long-running Proxmox tasks, so that I know when operations complete. + +#### Acceptance Criteria + +1. THE Proxmox_Client SHALL implement a waitForTask method +2. WHEN waitForTask is called with a task ID, THE Proxmox_Client SHALL poll the task status endpoint +3. THE Proxmox_Client SHALL poll every 2 seconds until the task completes or fails +4. WHEN the task completes successfully, THE Proxmox_Client SHALL return a success status +5. WHEN the task fails, THE Proxmox_Client SHALL return the error message from the task +6. THE Proxmox_Client SHALL timeout after 300 seconds and return a timeout error +7. WHERE a custom timeout is provided, THE Proxmox_Client SHALL use the custom timeout value + +### Requirement 14: Error Handling + +**User Story:** As a developer, I want comprehensive error handling, so that failures are reported clearly and the system remains stable. + +#### Acceptance Criteria + +1. THE Proxmox_Client SHALL catch HTTP errors and transform them into descriptive error messages +2. WHEN a 401 error occurs, THE Proxmox_Client SHALL indicate an authentication failure +3. WHEN a 403 error occurs, THE Proxmox_Client SHALL indicate a permission denial +4. WHEN a 404 error occurs, THE Proxmox_Client SHALL indicate the resource was not found +5. WHEN a 500 error occurs, THE Proxmox_Client SHALL indicate a server error with details +6. WHEN a network error occurs, THE Proxmox_Client SHALL indicate a connectivity failure +7. THE Proxmox_Service SHALL log all errors using LoggerService with appropriate context + +### Requirement 15: API Client Resilience + +**User Story:** As a system operator, I want the integration to handle transient failures gracefully, so that temporary network issues do not cause permanent failures. + +#### Acceptance Criteria + +1. THE Proxmox_Client SHALL implement retry logic for transient failures +2. THE Proxmox_Client SHALL retry failed requests up to 3 times with exponential backoff +3. THE Proxmox_Client SHALL not retry authentication failures +4. THE Proxmox_Client SHALL not retry 4xx client errors except 429 rate limit errors +5. WHEN a 429 error occurs, THE Proxmox_Client SHALL wait for the retry-after duration before retrying +6. THE Proxmox_Client SHALL log retry attempts with the attempt number and reason + +### Requirement 16: Configuration Validation + +**User Story:** As a system administrator, I want configuration errors to be detected early, so that I can fix them before operations fail. + +#### Acceptance Criteria + +1. THE Proxmox_Integration SHALL validate the host field is a valid hostname or IP address +2. THE Proxmox_Integration SHALL validate the port field is a number between 1 and 65535 +3. THE Proxmox_Integration SHALL validate that either password or token authentication is configured +4. WHEN both password and token are provided, THE Proxmox_Integration SHALL prefer token authentication +5. THE Proxmox_Integration SHALL validate the realm field is not empty when using password authentication +6. WHEN validation fails, THE Proxmox_Integration SHALL throw an error with specific field information + +### Requirement 17: Type Safety + +**User Story:** As a developer, I want strong TypeScript types for all Proxmox data structures, so that I can catch errors at compile time. + +#### Acceptance Criteria + +1. THE Proxmox_Service SHALL define TypeScript interfaces for all Proxmox API response types +2. THE Proxmox_Service SHALL define TypeScript interfaces for VM configuration +3. THE Proxmox_Service SHALL define TypeScript interfaces for LXC configuration +4. THE Proxmox_Service SHALL define TypeScript interfaces for guest status +5. THE Proxmox_Service SHALL define TypeScript interfaces for task status +6. THE Proxmox_Service SHALL use type guards to validate API responses at runtime + +### Requirement 18: Documentation + +**User Story:** As a system administrator, I want comprehensive documentation for the Proxmox integration, so that I can configure and use it effectively. + +#### Acceptance Criteria + +1. THE integration SHALL include a markdown documentation file in docs/integrations/proxmox.md +2. THE documentation SHALL describe all configuration options with examples +3. THE documentation SHALL document all supported actions with parameter descriptions +4. THE documentation SHALL document all provisioning capabilities with examples +5. THE documentation SHALL include authentication setup instructions for both password and token methods +6. THE documentation SHALL include troubleshooting guidance for common issues +7. THE documentation SHALL include example configuration snippets + +### Requirement 19: Testing Requirements + +**User Story:** As a developer, I want comprehensive tests for the Proxmox integration, so that I can verify correctness and prevent regressions. + +#### Acceptance Criteria + +1. THE integration SHALL include unit tests for the Proxmox_Service class +2. THE integration SHALL include unit tests for the Proxmox_Client class +3. THE integration SHALL include unit tests for the Proxmox_Integration plugin class +4. THE integration SHALL mock Proxmox API responses in unit tests +5. THE integration SHALL test error handling for all API failure scenarios +6. THE integration SHALL test authentication token refresh logic +7. THE integration SHALL achieve at least 80% code coverage + +### Requirement 20: Performance Considerations + +**User Story:** As a system operator, I want the integration to perform efficiently, so that it does not slow down the system. + +#### Acceptance Criteria + +1. THE Proxmox_Service SHALL cache inventory results for 60 seconds +2. THE Proxmox_Service SHALL cache group results for 60 seconds +3. THE Proxmox_Service SHALL cache facts results for 30 seconds +4. THE Proxmox_Service SHALL provide a method to clear the cache manually +5. THE Proxmox_Service SHALL use PerformanceMonitorService to track API call durations +6. THE Proxmox_Client SHALL reuse HTTP connections for multiple requests +7. THE Proxmox_Service SHALL execute parallel API calls when fetching data for multiple guests diff --git a/.kiro/specs/proxmox-integration/tasks.md b/.kiro/specs/proxmox-integration/tasks.md new file mode 100644 index 0000000..2760310 --- /dev/null +++ b/.kiro/specs/proxmox-integration/tasks.md @@ -0,0 +1,413 @@ +# Implementation Plan: Proxmox Integration + +## Overview + +This implementation plan creates a new Proxmox Virtual Environment integration for Pabawi following the established plugin architecture. The integration enables VM and container lifecycle management, inventory discovery, and introduces a new "provisioning" capability type to the system. + +The implementation follows the existing patterns from PuppetDB, Bolt, and SSH integrations, using TypeScript with comprehensive error handling, caching, and retry logic. + +## Tasks + +- [x] 1. Set up project structure and type definitions + - Create `pabawi/backend/src/integrations/proxmox/` directory + - Create `types.ts` with all Proxmox-specific interfaces (ProxmoxConfig, ProxmoxGuest, ProxmoxGuestConfig, ProxmoxGuestStatus, VMCreateParams, LXCCreateParams, ProxmoxTaskStatus, RetryConfig, error classes) + - Update `pabawi/backend/src/integrations/types.ts` to add ProvisioningCapability interface + - _Requirements: 17.1, 17.2, 17.3, 17.4, 17.5, 9.1, 9.2_ + +- [x] 2. Implement ProxmoxClient HTTP layer + - [x] 2.1 Create ProxmoxClient class with authentication + - Implement constructor with config and logger + - Implement authenticate() method for both password and token authentication + - Implement ticket storage and HTTPS agent configuration + - _Requirements: 3.1, 3.2, 3.4, 3.6, 16.4_ + + - [x] 2.2 Implement HTTP request methods + - Implement get(), post(), delete() methods + - Implement request() method with authentication headers + - Implement handleResponse() with HTTP error transformation + - Implement automatic ticket refresh on 401 errors + - _Requirements: 3.3, 14.2, 14.3, 14.4, 14.5, 14.6_ + + - [x] 2.3 Implement retry logic with exponential backoff + - Implement requestWithRetry() method + - Configure retry for transient failures (ECONNRESET, ETIMEDOUT, ENOTFOUND) + - Implement exponential backoff calculation + - Handle 429 rate limiting with Retry-After header + - Skip retry for authentication and 4xx errors + - _Requirements: 15.1, 15.2, 15.3, 15.4, 15.5, 15.6_ + + - [x] 2.4 Implement task polling mechanism + - Implement waitForTask() method with configurable timeout + - Poll task status endpoint every 2 seconds + - Handle task completion (success/failure) + - Implement timeout after 300 seconds (default) + - _Requirements: 13.1, 13.2, 13.3, 13.4, 13.5, 13.6, 13.7_ + +- [x] 3. Checkpoint - Ensure ProxmoxClient tests pass + - Ensure all tests pass, ask the user if questions arise. + +- [x] 4. Implement ProxmoxService business logic layer + - [x] 4.1 Create ProxmoxService class with initialization + - Implement constructor with config, logger, and performanceMonitor + - Implement initialize() method to create ProxmoxClient + - Implement healthCheck() method querying version endpoint + - Initialize SimpleCache with 60s TTL + - _Requirements: 1.6, 1.7, 4.1, 4.2, 4.3, 4.4, 4.5, 4.6_ + + - [x] 4.2 Implement inventory discovery + - Implement getInventory() method with caching + - Query cluster resources endpoint for all VMs and containers + - Implement transformGuestToNode() helper method + - Cache results for 60 seconds + - Use PerformanceMonitorService to track duration + - _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6, 5.7, 20.1, 20.5_ + + - [x] 4.3 Implement group management + - Implement getGroups() method with caching + - Implement groupByNode() helper to create node-based groups + - Implement groupByStatus() helper to create status-based groups + - Implement groupByType() helper to create type-based groups + - Use correct group ID formats (proxmox:node:{name}, proxmox:status:{status}, proxmox:type:{type}) + - Cache results for 60 seconds + - _Requirements: 6.1, 6.2, 6.3, 6.4, 6.5, 6.6, 6.7, 20.2_ + + - [x] 4.4 Implement facts retrieval + - Implement getNodeFacts() method with caching + - Parse VMID and node name from nodeId + - Implement getGuestType() helper to determine qemu vs lxc + - Query guest config and status endpoints + - Implement transformToFacts() helper method + - Include CPU, memory, disk, network config and current usage + - Handle non-existent guests with descriptive errors + - Cache results for 30 seconds + - _Requirements: 7.1, 7.2, 7.3, 7.4, 7.5, 7.6, 7.7, 20.3_ + +- [x] 5. Implement lifecycle action capabilities + - [x] 5.1 Implement executeAction() dispatcher + - Implement executeAction() method to route actions + - Implement executeLifecycleAction() for start/stop/shutdown/reboot/suspend/resume + - Parse target nodeId to extract node and VMID + - Determine guest type and call appropriate endpoint + - Wait for action task completion + - Return ExecutionResult with success/error details + - _Requirements: 8.1, 8.2, 8.3, 8.4, 8.5, 8.6, 8.7, 8.8, 8.9, 8.10_ + + - [x] 5.2 Implement capability listing + - Implement listCapabilities() method + - Return array of Capability objects for all lifecycle actions + - Include name, description, and parameters for each capability + - _Requirements: 8.1, 8.2_ + +- [x] 6. Implement provisioning capabilities + - [x] 6.1 Implement VM creation + - Implement createVM() method + - Implement guestExists() helper to check VMID uniqueness + - Validate VMID is unique before creation + - Call Proxmox VM creation endpoint + - Wait for creation task completion + - Clear inventory and groups cache after creation + - Return ExecutionResult with VMID and status + - _Requirements: 10.1, 10.2, 10.3, 10.4, 10.5, 10.6, 10.7_ + + - [x] 6.2 Implement LXC container creation + - Implement createLXC() method + - Validate VMID is unique before creation + - Call Proxmox LXC creation endpoint + - Wait for creation task completion + - Clear inventory and groups cache after creation + - Return ExecutionResult with VMID and status + - _Requirements: 11.1, 11.2, 11.3, 11.4, 11.5, 11.6, 11.7_ + + - [x] 6.3 Implement guest destruction + - Implement destroyGuest() method + - Verify guest exists before destruction + - Stop guest if running before deletion + - Call Proxmox deletion endpoint + - Wait for deletion task completion + - Clear all related caches (inventory, groups, facts) + - Return success status or error + - _Requirements: 12.1, 12.2, 12.3, 12.4, 12.5, 12.6, 12.7_ + + - [x] 6.4 Implement provisioning action dispatcher + - Implement executeProvisioningAction() method + - Route create_vm, create_lxc, destroy_vm, destroy_lxc actions + - Validate parameters for each action type + - Call appropriate service method + - _Requirements: 9.3, 9.4_ + + - [x] 6.5 Implement provisioning capability listing + - Implement listProvisioningCapabilities() method + - Return ProvisioningCapability objects for create_vm, create_lxc, destroy_vm, destroy_lxc + - Include operation type (create/destroy) and parameters + - _Requirements: 9.3, 9.4, 9.5_ + +- [x] 7. Checkpoint - Ensure ProxmoxService tests pass + - Ensure all tests pass, ask the user if questions arise. + +- [x] 8. Implement ProxmoxIntegration plugin class + - [x] 8.1 Create ProxmoxIntegration class extending BasePlugin + - Extend BasePlugin with "both" type + - Implement InformationSourcePlugin interface + - Implement ExecutionToolPlugin interface + - Implement constructor with logger and performanceMonitor + - _Requirements: 1.1, 1.2, 1.3_ + + - [x] 8.2 Implement plugin initialization and validation + - Implement performInitialization() method + - Implement validateProxmoxConfig() method + - Validate host, port, authentication, and realm + - Log security warning if SSL verification disabled + - Initialize ProxmoxService with validated config + - _Requirements: 1.4, 1.5, 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 16.1, 16.2, 16.3, 16.4, 16.5, 16.6_ + + - [x] 8.3 Implement plugin interface methods + - Implement performHealthCheck() delegating to service + - Implement getInventory() delegating to service + - Implement getGroups() delegating to service + - Implement getNodeFacts() delegating to service + - Implement getNodeData() delegating to service + - Implement executeAction() delegating to service + - Implement listCapabilities() delegating to service + - Implement listProvisioningCapabilities() delegating to service + - _Requirements: 1.4, 4.1_ + +- [x] 9. Integrate with IntegrationManager + - [x] 9.1 Register ProxmoxIntegration with IntegrationManager + - Import ProxmoxIntegration in IntegrationManager + - Add proxmox to integration registry + - Ensure plugin participates in inventory aggregation + - _Requirements: 1.4, 9.5, 9.6_ + + - [x] 9.2 Update IntegrationManager for provisioning capabilities + - Add method to query provisioning capabilities from all plugins + - Aggregate provisioning capabilities across plugins + - _Requirements: 9.5, 9.6_ + +- [ ] 10. Write unit tests for ProxmoxClient + - [ ]* 10.1 Write unit tests for authentication + - Test password authentication with ticket storage + - Test token authentication + - Test authentication failure handling + - Test automatic ticket refresh on 401 + - Mock fetch responses + - _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5_ + + - [ ]* 10.2 Write unit tests for HTTP methods + - Test get(), post(), delete() methods + - Test request header construction + - Test response parsing + - Mock Proxmox API responses + - _Requirements: 3.6_ + + - [ ]* 10.3 Write unit tests for error handling + - Test 401/403 authentication errors + - Test 404 not found errors + - Test 429 rate limiting with retry + - Test 5xx server errors + - Test network errors (ECONNRESET, ETIMEDOUT) + - _Requirements: 14.1, 14.2, 14.3, 14.4, 14.5, 14.6_ + + - [ ]* 10.4 Write unit tests for retry logic + - Test retry with exponential backoff + - Test max retry attempts + - Test non-retryable errors (auth, 4xx) + - Test retryable errors (network, 5xx) + - Test retry logging + - _Requirements: 15.1, 15.2, 15.3, 15.4, 15.5, 15.6_ + + - [ ]* 10.5 Write unit tests for task polling + - Test waitForTask() success case + - Test waitForTask() failure case + - Test task timeout + - Test custom timeout values + - Test polling interval + - _Requirements: 13.1, 13.2, 13.3, 13.4, 13.5, 13.6, 13.7_ + +- [ ] 11. Write unit tests for ProxmoxService + - [ ]* 11.1 Write unit tests for initialization and health check + - Test service initialization + - Test health check with successful API response + - Test health check with authentication failure (degraded) + - Test health check with connection failure (unhealthy) + - Test health check caching + - Mock ProxmoxClient + - _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5, 4.6_ + + - [ ]* 11.2 Write unit tests for inventory discovery + - Test getInventory() with valid API response + - Test guest-to-node transformation + - Test inventory caching (60s TTL) + - Test cache hit vs cache miss + - Test empty inventory + - Mock cluster resources endpoint + - _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6, 5.7, 20.1_ + + - [ ]* 11.3 Write unit tests for group management + - Test getGroups() with multiple nodes + - Test groupByNode() creates correct groups + - Test groupByStatus() creates correct groups + - Test groupByType() creates correct groups + - Test group ID format correctness + - Test groups caching (60s TTL) + - _Requirements: 6.1, 6.2, 6.3, 6.4, 6.5, 6.6, 6.7, 20.2_ + + - [ ]* 11.4 Write unit tests for facts retrieval + - Test getNodeFacts() for VM + - Test getNodeFacts() for LXC + - Test facts transformation with config and status + - Test facts caching (30s TTL) + - Test non-existent guest error + - Test running vs stopped guest facts + - _Requirements: 7.1, 7.2, 7.3, 7.4, 7.5, 7.6, 7.7, 20.3_ + + - [ ]* 11.5 Write unit tests for lifecycle actions + - Test executeAction() for start action + - Test executeAction() for stop action + - Test executeAction() for shutdown action + - Test executeAction() for reboot action + - Test executeAction() for suspend action + - Test executeAction() for resume action + - Test action failure with error details + - Test task completion waiting + - _Requirements: 8.1, 8.2, 8.3, 8.4, 8.5, 8.6, 8.7, 8.8, 8.9, 8.10_ + + - [ ]* 11.6 Write unit tests for VM creation + - Test createVM() success case + - Test VMID uniqueness validation + - Test VM creation failure + - Test cache clearing after creation + - Mock VM creation endpoint + - _Requirements: 10.1, 10.2, 10.3, 10.4, 10.5, 10.6, 10.7_ + + - [ ]* 11.7 Write unit tests for LXC creation + - Test createLXC() success case + - Test VMID uniqueness validation + - Test LXC creation failure + - Test cache clearing after creation + - Mock LXC creation endpoint + - _Requirements: 11.1, 11.2, 11.3, 11.4, 11.5, 11.6, 11.7_ + + - [ ]* 11.8 Write unit tests for guest destruction + - Test destroyGuest() success case + - Test non-existent guest error + - Test stop-before-delete for running guest + - Test cache clearing after destruction + - Mock deletion endpoint + - _Requirements: 12.1, 12.2, 12.3, 12.4, 12.5, 12.6, 12.7_ + + - [ ]* 11.9 Write unit tests for capability listing + - Test listCapabilities() returns all lifecycle actions + - Test listProvisioningCapabilities() returns all provisioning actions + - Test capability parameter definitions + - _Requirements: 8.1, 8.2, 9.3, 9.4_ + +- [ ] 12. Write unit tests for ProxmoxIntegration + - [ ]* 12.1 Write unit tests for plugin initialization + - Test plugin initialization with valid config + - Test config validation for missing host + - Test config validation for invalid port + - Test config validation for missing authentication + - Test config validation for missing realm + - Test SSL verification warning + - _Requirements: 1.1, 1.2, 1.3, 1.4, 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 16.1, 16.2, 16.3, 16.4, 16.5, 16.6_ + + - [ ]* 12.2 Write unit tests for plugin interface methods + - Test performHealthCheck() delegation + - Test getInventory() delegation + - Test getGroups() delegation + - Test getNodeFacts() delegation + - Test executeAction() delegation + - Test listCapabilities() delegation + - Test listProvisioningCapabilities() delegation + - Mock ProxmoxService + - _Requirements: 1.4, 4.1_ + +- [ ] 13. Write property-based tests + - [ ]* 13.1 Write property test for configuration validation + - **Property 1: Configuration Validation Rejects Invalid Inputs** + - **Validates: Requirements 2.3, 2.4, 16.1, 16.2, 16.3, 16.5, 16.6** + - Generate invalid configs (missing fields, invalid ports, invalid hosts) + - Verify initialization throws descriptive errors + - Use fast-check with 100 iterations + + - [ ]* 13.2 Write property test for guest-to-node transformation + - **Property 4: Guest-to-Node Transformation Completeness** + - **Validates: Requirements 5.3, 5.4, 5.5, 5.6, 5.7** + - Generate random Proxmox guest objects + - Verify transformed Node has all required fields + - Verify type field correctness + - Verify IP field handling (present or omitted, never null) + - Use fast-check with 100 iterations + + - [ ]* 13.3 Write property test for group ID format + - **Property 8: Group ID Format Correctness** + - **Validates: Requirements 6.5, 6.6, 6.7** + - Generate random node names, statuses, and types + - Verify group IDs match expected format + - Use fast-check with 100 iterations + + - [ ]* 13.4 Write property test for caching behavior + - **Property 22, 23, 24: Cache Behavior** + - **Validates: Requirements 20.1, 20.2, 20.3** + - Test inventory cache TTL (60s) + - Test groups cache TTL (60s) + - Test facts cache TTL (30s) + - Verify cache hits don't trigger API calls + - Use fast-check with 100 iterations + + - [ ]* 13.5 Write property test for retry logic + - **Property 18: Retry Logic for Transient Failures** + - **Validates: Requirements 15.2** + - Generate transient network errors + - Verify retry attempts with exponential backoff + - Verify max retry limit + - Use fast-check with 100 iterations + +- [x] 14. Create API routes for Proxmox endpoints + - [x] 14.1 Create provisioning API routes + - Add POST /api/integrations/proxmox/provision/vm endpoint + - Add POST /api/integrations/proxmox/provision/lxc endpoint + - Add DELETE /api/integrations/proxmox/provision/:vmid endpoint + - Validate request parameters + - Call ProxmoxIntegration methods + - Return appropriate HTTP status codes + + - [x] 14.2 Create action API routes + - Add POST /api/integrations/proxmox/action endpoint + - Support all lifecycle actions (start, stop, shutdown, reboot, suspend, resume) + - Validate action parameters + - Call ProxmoxIntegration executeAction method + +- [x] 15. Write documentation + - [x] 15.1 Create integration documentation + - Create `docs/integrations/proxmox.md` + - Document all configuration options with examples + - Document authentication setup (password and token) + - Document all supported actions with parameters + - Document all provisioning capabilities with examples + - Include troubleshooting section + - Include example configuration snippets + - _Requirements: 18.1, 18.2, 18.3, 18.4, 18.5, 18.6, 18.7_ + + - [x] 15.2 Create configuration examples + - Document environment variable setup + - Provide example .env configuration + - Document Proxmox API token creation steps + - Document required permissions + - _Requirements: 18.5_ + +- [x] 16. Final checkpoint - Ensure all tests pass + - Ensure all tests pass, ask the user if questions arise. + +## Notes + +- Tasks marked with `*` are optional and can be skipped for faster MVP +- Each task references specific requirements for traceability +- Checkpoints ensure incremental validation +- Property tests validate universal correctness properties +- Unit tests validate specific examples and edge cases +- The implementation follows existing patterns from PuppetDB and SSH integrations +- TypeScript is used throughout for type safety +- All API communication uses HTTPS +- Caching improves performance and reduces API load +- Retry logic handles transient failures gracefully diff --git a/.kiro/specs/puppet-pabawi-refactoring/.config.kiro b/.kiro/specs/puppet-pabawi-refactoring/.config.kiro new file mode 100644 index 0000000..e2f97a0 --- /dev/null +++ b/.kiro/specs/puppet-pabawi-refactoring/.config.kiro @@ -0,0 +1 @@ +{"specId": "d5a7de16-585a-4236-9911-1cb9ca3b2ffa", "workflowType": "requirements-first", "specType": "feature"} diff --git a/.kiro/specs/puppet-pabawi-refactoring/design.md b/.kiro/specs/puppet-pabawi-refactoring/design.md new file mode 100644 index 0000000..5ba92fe --- /dev/null +++ b/.kiro/specs/puppet-pabawi-refactoring/design.md @@ -0,0 +1,621 @@ +# Design Document: Puppet-Pabawi Refactoring + +## Overview + +This design document specifies the refactoring of the puppet-pabawi module to introduce a consistent settings hash pattern across all integration classes, add SSH integration support, and properly scope command whitelisting parameters. The refactoring improves configuration flexibility by clearly separating Pabawi application configuration (written to .env file) from Puppet infrastructure management (package installation, file deployment, git repository cloning). + +### Goals + +1. Introduce a consistent `settings` hash parameter pattern across all integration classes +2. Add SSH integration support for remote command execution +3. Move command whitelist parameters from bolt.pp to docker.pp and nginx.pp where they are actually used +4. Maintain backward compatibility where possible through parameter defaults +5. Improve code maintainability and configuration clarity + +### Non-Goals + +1. Changing the underlying Pabawi application behavior +2. Modifying the .env file format or structure +3. Altering existing concat fragment ordering +4. Changing how the main pabawi class orchestrates integrations + +## Architecture + +### Settings Hash Pattern + +The refactoring introduces a two-tier parameter structure: + +**Settings Hash (Application Configuration)** + +- Contains key-value pairs written to the .env file +- Flexible schema - accepts any keys the Pabawi application understands +- Values are transformed based on type (Arrays → JSON, Booleans → lowercase strings, etc.) +- Prefixed with integration name when written to .env (e.g., `ANSIBLE_`, `BOLT_`) + +**Regular Class Parameters (Puppet Management)** + +- Handle infrastructure concerns: package installation, file deployment, git cloning +- Examples: `manage_package`, `inventory_source`, `ssl_ca_source` +- Not written to .env file - used by Puppet resources + +### Component Relationships + +```mermaid +graph TD + A[Main pabawi Class] --> B[install::docker] + A --> C[proxy::nginx] + A --> D[Integration Classes] + + D --> E[integrations::ansible] + D --> F[integrations::bolt] + D --> G[integrations::hiera] + D --> H[integrations::puppetdb] + D --> I[integrations::puppetserver] + D --> J[integrations::ssh - NEW] + + E --> K[.env File via Concat] + F --> K + G --> K + H --> K + I --> K + J --> K + B --> K + + E --> L[vcsrepo Resources] + F --> L + G --> L + + H --> M[File Resources for SSL] + I --> M + + B --> N[Docker Container] + N --> K + + C --> O[Nginx Config] +``` + +### Environment File Generation + +All integration classes write to `/opt/pabawi/.env` using concat fragments with specific ordering: + +- Order 10: Base configuration (docker.pp) +- Order 20: Bolt integration +- Order 21: PuppetDB integration +- Order 22: PuppetServer integration +- Order 23: Hiera integration +- Order 24: Ansible integration +- Order 25: SSH integration (new) + +### Command Whitelist Relocation + +Command whitelisting moves from bolt.pp to the classes that actually enforce it: + +- **docker.pp**: Writes `COMMAND_WHITELIST` and `COMMAND_WHITELIST_ALLOW_ALL` to .env file (used by containerized application) +- **nginx.pp**: Uses command whitelist in nginx configuration context (for request filtering) +- **bolt.pp**: No longer manages command whitelist parameters + +## Components and Interfaces + +### Integration Class Interface Pattern + +All integration classes follow this consistent interface: + +```puppet +class pabawi::integrations:: ( + Boolean $enabled = true, + Hash $settings = {}, + Boolean $manage_package = false, + Optional[String[1]] $_source = undef, + # ... additional source parameters as needed +) { + # Validation + # Package management (if manage_package) + # Resource deployment (if *_source provided) + # Concat fragment for .env file +} +``` + +### New SSH Integration Class + +**File**: `puppet-pabawi/manifests/integrations/ssh.pp` + +**Parameters**: + +- `enabled` (Boolean, default: true) - Enable SSH integration +- `settings` (Hash, default: {}) - Application configuration for SSH + +**Settings Hash Keys** (examples, flexible schema): + +- `host` - SSH host to connect to +- `port` - SSH port (default 22) +- `username` - SSH username +- `private_key_path` - Path to SSH private key +- `timeout` - Connection timeout in milliseconds +- `known_hosts_path` - Path to known_hosts file + +**Behavior**: + +- Writes `SSH_ENABLED=true` when enabled +- Writes all settings hash keys with `SSH_` prefix to .env +- Uses concat fragment order 25 + +### Refactored Ansible Integration + +**File**: `puppet-pabawi/manifests/integrations/ansible.pp` + +**Parameters**: + +- `enabled` (Boolean, default: true) +- `settings` (Hash, default: {}) - Application configuration +- `manage_package` (Boolean, default: false) +- `inventory_source` (Optional[String[1]]) - Git URL for inventory +- `playbook_source` (Optional[String[1]]) - Git URL for playbooks + +**Settings Hash Keys**: + +- `inventory_path` - Path where inventory is located +- `playbook_path` - Path where playbooks are located +- `execution_timeout` - Timeout in milliseconds +- `config` - Path to ansible.cfg + +**Behavior**: + +- When `inventory_source` provided: clones to `settings['inventory_path']` +- When `playbook_source` provided: clones to `settings['playbook_path']` +- Writes all settings with `ANSIBLE_` prefix to .env + +### Refactored Bolt Integration + +**File**: `puppet-pabawi/manifests/integrations/bolt.pp` + +**Parameters**: + +- `enabled` (Boolean, default: true) +- `settings` (Hash, default: {}) - Application configuration +- `manage_package` (Boolean, default: false) +- `project_path_source` (Optional[String[1]]) - Git URL for bolt project + +**Settings Hash Keys**: + +- `project_path` - Path to bolt project directory +- `execution_timeout` - Timeout in milliseconds + +**Behavior**: + +- When `project_path_source` provided: clones to `settings['project_path']` +- Writes all settings with `BOLT_` prefix to .env +- **REMOVES**: `command_whitelist` and `command_whitelist_allow_all` parameters + +### Refactored Hiera Integration + +**File**: `puppet-pabawi/manifests/integrations/hiera.pp` + +**Parameters**: + +- `enabled` (Boolean, default: true) +- `settings` (Hash, default: {}) - Application configuration +- `manage_package` (Boolean, default: false) +- `control_repo_source` (Optional[String[1]]) - Git URL for control repo + +**Settings Hash Keys**: + +- `control_repo_path` - Path to control repository +- `config_path` - Relative path to hiera.yaml +- `environments` - Array of environment names +- `fact_source_prefer_puppetdb` - Boolean for fact source preference +- `fact_source_local_path` - Path to local fact files + +**Behavior**: + +- When `control_repo_source` provided: clones to `settings['control_repo_path']` +- Writes all settings with `HIERA_` prefix to .env + +### Refactored PuppetDB Integration + +**File**: `puppet-pabawi/manifests/integrations/puppetdb.pp` + +**Parameters**: + +- `enabled` (Boolean, default: true) +- `settings` (Hash, default: {}) - Application configuration +- `ssl_ca_source` (Optional[String[1]]) - Source for CA certificate +- `ssl_cert_source` (Optional[String[1]]) - Source for client certificate +- `ssl_key_source` (Optional[String[1]]) - Source for private key + +**Settings Hash Keys**: + +- `server_url` - PuppetDB server URL +- `port` - PuppetDB port +- `ssl_enabled` - Boolean for SSL usage +- `ssl_ca` - Path to CA certificate +- `ssl_cert` - Path to client certificate +- `ssl_key` - Path to private key +- `ssl_reject_unauthorized` - Boolean for certificate validation + +**Behavior**: + +- When `ssl_*_source` provided: deploys certificates to paths in `settings['ssl_*']` +- Supports file://, https://, and local path formats for sources +- Writes all settings with `PUPPETDB_` prefix to .env + +### Refactored PuppetServer Integration + +**File**: `puppet-pabawi/manifests/integrations/puppetserver.pp` + +**Parameters**: + +- `enabled` (Boolean, default: true) +- `settings` (Hash, default: {}) - Application configuration +- `ssl_ca_source` (Optional[String[1]]) - Source for CA certificate +- `ssl_cert_source` (Optional[String[1]]) - Source for client certificate +- `ssl_key_source` (Optional[String[1]]) - Source for private key + +**Settings Hash Keys**: + +- `server_url` - Puppet Server URL +- `port` - Puppet Server port +- `ssl_enabled` - Boolean for SSL usage +- `ssl_ca` - Path to CA certificate +- `ssl_cert` - Path to client certificate +- `ssl_key` - Path to private key +- `ssl_reject_unauthorized` - Boolean for certificate validation +- `inactivity_threshold` - Node inactivity threshold in seconds +- `cache_ttl` - Cache TTL in milliseconds +- `circuit_breaker_threshold` - Failure count before circuit opens +- `circuit_breaker_timeout` - Circuit breaker timeout in milliseconds +- `circuit_breaker_reset_timeout` - Circuit breaker reset timeout in milliseconds + +**Behavior**: + +- When `ssl_*_source` provided: deploys certificates to paths in `settings['ssl_*']` +- Supports file://, https://, and local path formats for sources +- Writes all settings with `PUPPETSERVER_` prefix to .env + +### Updated Docker Class + +**File**: `puppet-pabawi/manifests/install/docker.pp` + +**New Parameters**: + +- `command_whitelist` (Array[String[1]], default: []) - Allowed commands +- `command_whitelist_allow_all` (Boolean, default: false) - Bypass whitelist + +**Behavior**: + +- Writes `COMMAND_WHITELIST` as JSON array to .env +- Writes `COMMAND_WHITELIST_ALLOW_ALL` as boolean to .env +- Maintains existing base configuration fragment (order 10) + +### Updated Nginx Class + +**File**: `puppet-pabawi/manifests/proxy/nginx.pp` + +**New Parameters**: + +- `command_whitelist` (Array[String[1]], default: []) - Allowed commands +- `command_whitelist_allow_all` (Boolean, default: false) - Bypass whitelist + +**Behavior**: + +- Uses command whitelist in nginx configuration template +- Applies whitelist filtering at reverse proxy level + +## Data Models + +### Settings Hash Structure + +The settings hash is flexible and integration-specific. Each integration defines which keys it expects: + +```puppet +# Ansible example +$ansible_settings = { + 'inventory_path' => '/opt/pabawi/ansible/inventory', + 'playbook_path' => '/opt/pabawi/ansible/playbooks', + 'execution_timeout' => 300000, + 'config' => '/etc/ansible/ansible.cfg', +} + +# PuppetDB example +$puppetdb_settings = { + 'server_url' => 'https://puppetdb.example.com', + 'port' => 8081, + 'ssl_enabled' => true, + 'ssl_ca' => '/opt/pabawi/certs/ca.pem', + 'ssl_cert' => '/opt/pabawi/certs/client.pem', + 'ssl_key' => '/opt/pabawi/certs/client-key.pem', + 'ssl_reject_unauthorized' => true, +} + +# SSH example +$ssh_settings = { + 'host' => 'remote.example.com', + 'port' => 22, + 'username' => 'automation', + 'private_key_path' => '/opt/pabawi/ssh/id_rsa', + 'timeout' => 30000, +} +``` + +### Environment Variable Transformation Rules + +| Puppet Type | .env Format | Example | +|-------------|-------------|---------| +| String | As-is | `ANSIBLE_CONFIG=/etc/ansible/ansible.cfg` | +| Integer | String representation | `PUPPETDB_PORT=8081` | +| Boolean | Lowercase string | `SSH_ENABLED=true` | +| Array | JSON string | `HIERA_ENVIRONMENTS=["production","development"]` | +| Undef/Empty | 'not-set' | `ANSIBLE_CONFIG=not-set` | + +### Git Repository Source Mapping + +| Source Parameter | Settings Hash Key | Purpose | +|------------------|-------------------|---------| +| `inventory_source` | `inventory_path` | Ansible inventory clone destination | +| `playbook_source` | `playbook_path` | Ansible playbooks clone destination | +| `project_path_source` | `project_path` | Bolt project clone destination | +| `control_repo_source` | `control_repo_path` | Hiera control repo clone destination | + +### SSL Certificate Source Mapping + +| Source Parameter | Settings Hash Key | Purpose | +|------------------|-------------------|---------| +| `ssl_ca_source` | `ssl_ca` | CA certificate deployment destination | +| `ssl_cert_source` | `ssl_cert` | Client certificate deployment destination | +| `ssl_key_source` | `ssl_key` | Private key deployment destination | + +## Correctness Properties + +*A property is a characteristic or behavior that should hold true across all valid executions of a system-essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.* + +### Property 1: Settings Hash to Environment Variable Transformation + +*For any* integration class and any settings hash key-value pair, when the integration writes to the .env file, the value SHALL be transformed according to its type: Arrays to JSON format, Booleans to lowercase strings (true/false), Strings as-is, Integers to string representation, and undef/empty values to 'not-set'. + +**Validates: Requirements 9.1, 9.2, 9.3, 9.4, 9.5, 2.6, 2.7** + +### Property 2: Settings Hash Prefix Application + +*For any* integration class and any key-value pair in its settings hash, when written to the .env file, the key SHALL be prefixed with the uppercase integration name followed by an underscore (e.g., ANSIBLE_, BOLT_, SSH_, HIERA_, PUPPETDB_, PUPPETSERVER_). + +**Validates: Requirements 1.5, 3.9, 4.7, 5.7, 6.9, 7.9** + +### Property 3: Git Repository Cloning with Source Parameters + +*For any* integration class with a source parameter (inventory_source, playbook_source, project_path_source, control_repo_source) containing a git URL, the class SHALL create a vcsrepo resource that clones to the path specified in the corresponding settings hash key (inventory_path, playbook_path, project_path, control_repo_path), with ensure => present, and SHALL create the parent directory before cloning. + +**Validates: Requirements 3.7, 3.8, 4.6, 5.6, 10.1, 10.2, 10.3, 10.4** + +### Property 4: Git Repository Resource Dependencies + +*For any* vcsrepo resource created by an integration class, the resource SHALL have a require relationship to the exec resource that creates its parent directory. + +**Validates: Requirements 10.5** + +### Property 5: SSL Certificate Deployment + +*For any* integration class (PuppetDB or PuppetServer) with ssl_ca_source, ssl_cert_source, or ssl_key_source parameters provided, the class SHALL deploy the certificate files to the paths specified in the settings hash (ssl_ca, ssl_cert, ssl_key), and SHALL support file://, https://, and local path formats for source parameters. + +**Validates: Requirements 6.7, 6.8, 7.7, 7.8** + +### Property 6: SSL Certificate File Permissions + +*For any* SSL certificate file deployed by an integration class, the file SHALL have mode 0644 for CA and certificate files, and mode 0600 for private key files. + +**Validates: Requirements 6.7, 7.7** (implicit security requirement) + +### Property 7: Settings Validation with Descriptive Errors + +*For any* integration class that is enabled and has required settings missing from the settings hash, the class SHALL fail with an error message that specifies both the required setting key name and the integration class name that generated the error, and this validation SHALL occur before any resources are created. + +**Validates: Requirements 8.1, 8.2, 8.3, 8.4** + +### Property 8: Enabled Integration Environment Variable + +*For any* integration class where the enabled parameter is true, the class SHALL write an environment variable {INTEGRATION}_ENABLED=true to the .env file (e.g., SSH_ENABLED=true, ANSIBLE_ENABLED=true). + +**Validates: Requirements 1.4** + +### Property 9: Concat Fragment Ordering Consistency + +*For any* integration class, the concat fragment used to write to the .env file SHALL use the assigned order number: Bolt (20), PuppetDB (21), PuppetServer (22), Hiera (23), Ansible (24), SSH (25). + +**Validates: Requirements 1.6, 3.10, 4.8, 5.8, 6.10, 7.10** + +## Error Handling + +### Validation Errors + +All integration classes perform parameter validation at the start of execution, before creating any resources: + +1. **Required Settings Validation**: When an integration is enabled, required settings must be present in the settings hash +2. **Source-Path Consistency**: When a *_source parameter is provided, the corresponding path key must exist in the settings hash +3. **SSL Configuration Validation**: When SSL is enabled, all three SSL source parameters (ca, cert, key) should be provided together + +### Error Message Format + +Error messages follow this pattern: + +``` +pabawi::integrations::{integration_name}: {setting_key} is required when enabled is true +``` + +Example: + +``` +pabawi::integrations::ansible: settings['inventory_path'] is required when enabled is true +``` + +### Git Repository Cloning Errors + +When git repository cloning fails: + +- vcsrepo resource will fail with standard Puppet error +- Parent directory creation failures will prevent vcsrepo execution +- Invalid git URLs will cause vcsrepo provider errors + +### SSL Certificate Deployment Errors + +When SSL certificate deployment fails: + +- file:// URLs: Puppet will fail if source file doesn't exist +- https:// URLs: curl exec will fail if download fails +- Local paths: Puppet will fail if source file doesn't exist +- Invalid paths in settings hash will cause file resource failures + +### Type Transformation Errors + +The settings hash accepts any value types, but unexpected types may cause issues: + +- Hash values: Not explicitly handled, may cause concat fragment errors +- Complex nested structures: May not serialize correctly to .env format +- Recommendation: Use only String, Integer, Boolean, and Array types in settings hash + +## Testing Strategy + +### Dual Testing Approach + +This refactoring requires both unit tests and property-based tests to ensure correctness: + +**Unit Tests** focus on: + +- Specific examples of integration configurations +- Edge cases (empty settings hash, missing required settings) +- Error conditions (invalid git URLs, missing SSL certificates) +- Integration points between classes and concat fragments +- Specific concat fragment order values +- Parameter interface validation (correct types, defaults) + +**Property-Based Tests** focus on: + +- Universal transformation rules (type-based value conversion) +- Settings hash prefix application across all integrations +- Git repository cloning behavior with various URLs and paths +- SSL certificate deployment with different source formats +- Validation error messages with various missing settings + +### Property-Based Testing Configuration + +We will use **rspec-puppet** with **rspec-puppet-facts** for property-based testing of Puppet code. While not a traditional PBT library, we can use parameterized tests with multiple fact sets and input combinations. + +**Test Configuration**: + +- Minimum 100 iterations per property test (achieved through parameterized test cases) +- Each property test references its design document property +- Tag format: `# Feature: puppet-pabawi-refactoring, Property {number}: {property_text}` + +**Example Property Test Structure**: + +```ruby +# Feature: puppet-pabawi-refactoring, Property 1: Settings Hash to Environment Variable Transformation +describe 'pabawi::integrations::ansible' do + [ + { input: ['cmd1', 'cmd2'], expected: '["cmd1","cmd2"]' }, + { input: true, expected: 'true' }, + { input: false, expected: 'false' }, + { input: 'string_value', expected: 'string_value' }, + { input: 12345, expected: '12345' }, + { input: nil, expected: 'not-set' }, + ].each do |test_case| + context "with settings value #{test_case[:input].inspect}" do + let(:params) do + { settings: { 'test_key' => test_case[:input] } } + end + + it 'transforms value correctly in concat fragment' do + is_expected.to contain_concat__fragment('pabawi_env_ansible') + .with_content(/ANSIBLE_TEST_KEY=#{Regexp.escape(test_case[:expected])}/) + end + end + end +end +``` + +### Unit Test Coverage Areas + +1. **SSH Integration Class**: + - Class exists and is properly defined + - Accepts settings hash parameter + - Accepts enabled parameter with default true + - Writes SSH_ENABLED to .env when enabled + - Uses concat fragment order 25 + - Writes settings with SSH_ prefix + +2. **Command Whitelist Relocation**: + - bolt.pp does not have command_whitelist parameters + - docker.pp accepts command_whitelist and command_whitelist_allow_all + - nginx.pp accepts command_whitelist and command_whitelist_allow_all + - docker.pp writes COMMAND_WHITELIST as JSON array + - docker.pp writes COMMAND_WHITELIST_ALLOW_ALL as boolean + +3. **Ansible Integration Refactoring**: + - Accepts settings hash parameter + - Accepts inventory_source and playbook_source parameters + - Creates vcsrepo resources when sources provided + - Writes settings with ANSIBLE_ prefix + - Uses concat fragment order 24 + +4. **Bolt Integration Refactoring**: + - Accepts settings hash parameter + - Accepts project_path_source parameter + - Creates vcsrepo resource when source provided + - Writes settings with BOLT_ prefix + - Uses concat fragment order 20 + - Does not include command_whitelist parameters + +5. **Hiera Integration Refactoring**: + - Accepts settings hash parameter + - Accepts control_repo_source parameter + - Creates vcsrepo resource when source provided + - Writes settings with HIERA_ prefix + - Uses concat fragment order 23 + +6. **PuppetDB Integration Refactoring**: + - Accepts settings hash parameter + - Accepts ssl_ca_source, ssl_cert_source, ssl_key_source parameters + - Deploys SSL certificates when sources provided + - Supports file://, https://, and local path formats + - Writes settings with PUPPETDB_ prefix + - Uses concat fragment order 21 + +7. **PuppetServer Integration Refactoring**: + - Accepts settings hash parameter + - Accepts ssl_ca_source, ssl_cert_source, ssl_key_source parameters + - Deploys SSL certificates when sources provided + - Supports file://, https://, and local path formats + - Writes settings with PUPPETSERVER_ prefix + - Uses concat fragment order 22 + +### Integration Testing + +Integration tests should verify: + +- Complete .env file generation with multiple integrations enabled +- Concat fragment ordering produces correct file structure +- Docker container receives correct .env file +- Git repositories are cloned to correct locations +- SSL certificates are deployed with correct permissions +- Command whitelist is properly passed to Docker container + +### Test Execution + +Tests should be run with minimal verbosity: + +```bash +# Run all tests +bundle exec rake spec + +# Run specific integration tests +bundle exec rspec spec/classes/integrations/ssh_spec.rb + +# Run with specific fact sets +SPEC_FACTS_OS=ubuntu-20.04-x86_64 bundle exec rake spec +``` + +### Backward Compatibility Testing + +Since this is a refactoring, we need to ensure backward compatibility: + +- Test that existing configurations still work (with deprecation warnings if needed) +- Verify that default values maintain current behavior +- Check that .env file format remains unchanged +- Ensure concat fragment ordering is preserved diff --git a/.kiro/specs/puppet-pabawi-refactoring/requirements.md b/.kiro/specs/puppet-pabawi-refactoring/requirements.md new file mode 100644 index 0000000..e0e8dfb --- /dev/null +++ b/.kiro/specs/puppet-pabawi-refactoring/requirements.md @@ -0,0 +1,163 @@ +# Requirements Document + +## Introduction + +This document specifies requirements for refactoring the puppet-pabawi module to improve configuration flexibility, standardize parameter handling across integrations, add SSH integration support, and properly scope command whitelisting parameters. The refactoring will introduce a settings hash pattern for Pabawi application configuration (values written to .env file) while maintaining regular class parameters for Puppet-specific management tasks (package management, file deployment, git repository sources). + +## Glossary + +- **Integration_Class**: A Puppet class in the manifests/integrations directory that configures Pabawi integration with external tools (Ansible, Bolt, Hiera, PuppetDB, PuppetServer, SSH) +- **Settings_Hash**: A Hash parameter containing Pabawi application configuration that gets written to the .env file (e.g., paths used by the app, timeouts, URLs, ports) +- **Source_Parameter**: A regular class parameter used by Puppet to manage file deployment or git repository cloning (e.g., inventory_source, ssl_ca_source) +- **Command_Whitelist**: An array of allowed commands for execution control +- **Environment_File**: The .env file generated by concat fragments containing integration configuration +- **Docker_Class**: The manifests/install/docker.pp class that manages Docker-based Pabawi installation +- **Nginx_Class**: The manifests/proxy/nginx.pp class that manages nginx reverse proxy configuration +- **Bolt_Integration**: The manifests/integrations/bolt.pp class for Puppet Bolt integration +- **SSH_Integration**: A new integration class for SSH-based operations + +## Requirements + +### Requirement 1: SSH Integration Support + +**User Story:** As a Pabawi administrator, I want to configure SSH integration, so that I can execute commands on remote systems via SSH. + +#### Acceptance Criteria + +1. THE Module SHALL provide an SSH_Integration class at manifests/integrations/ssh.pp +2. THE SSH_Integration SHALL accept a Settings_Hash parameter for configuration +3. THE SSH_Integration SHALL accept an enabled Boolean parameter with default value true +4. WHEN enabled is true, THE SSH_Integration SHALL write SSH_ENABLED=true to the Environment_File +5. THE SSH_Integration SHALL write all Settings_Hash key-value pairs to the Environment_File with SSH_ prefix +6. THE SSH_Integration SHALL use concat fragment order 25 for Environment_File integration + +### Requirement 2: Command Whitelist Parameter Relocation + +**User Story:** As a Pabawi administrator, I want command whitelisting parameters in the classes that actually use them, so that the configuration is more intuitive and maintainable. + +#### Acceptance Criteria + +1. THE Bolt_Integration SHALL NOT include command_whitelist or command_whitelist_allow_all parameters +2. THE Docker_Class SHALL accept a command_whitelist Array parameter with default empty array +3. THE Docker_Class SHALL accept a command_whitelist_allow_all Boolean parameter with default value false +4. THE Nginx_Class SHALL accept a command_whitelist Array parameter with default empty array +5. THE Nginx_Class SHALL accept a command_whitelist_allow_all Boolean parameter with default value false +6. WHEN Docker_Class writes to Environment_File, THE Docker_Class SHALL include COMMAND_WHITELIST as JSON array +7. WHEN Docker_Class writes to Environment_File, THE Docker_Class SHALL include COMMAND_WHITELIST_ALLOW_ALL as Boolean +8. THE Nginx_Class SHALL write COMMAND_WHITELIST and COMMAND_WHITELIST_ALLOW_ALL to nginx configuration context + +### Requirement 3: Ansible Integration Settings Hash + +**User Story:** As a Pabawi administrator, I want to configure Ansible integration using a settings hash for application configuration and regular parameters for Puppet management, so that the distinction between app config and infrastructure management is clear. + +#### Acceptance Criteria + +1. THE Ansible_Integration SHALL accept a Settings_Hash parameter for Pabawi application configuration +2. THE Ansible_Integration SHALL accept inventory_source as a regular class parameter for git repository URL +3. THE Ansible_Integration SHALL accept playbook_source as a regular class parameter for git repository URL +4. THE Ansible_Integration SHALL accept a manage_package Boolean parameter with default value false +5. THE Ansible_Integration SHALL accept an enabled Boolean parameter with default value true +6. THE Settings_Hash SHALL support keys: inventory_path, playbook_path, execution_timeout, config +7. WHEN inventory_source parameter is provided, THE Ansible_Integration SHALL clone the git repository to the path specified in Settings_Hash inventory_path +8. WHEN playbook_source parameter is provided, THE Ansible_Integration SHALL clone the git repository to the path specified in Settings_Hash playbook_path +9. FOR ALL Settings_Hash key-value pairs, THE Ansible_Integration SHALL write them to Environment_File with ANSIBLE_ prefix +10. THE Ansible_Integration SHALL use concat fragment order 24 for Environment_File integration + +### Requirement 4: Bolt Integration Settings Hash + +**User Story:** As a Pabawi administrator, I want to configure Bolt integration using a settings hash for application configuration and regular parameters for Puppet management, so that the distinction between app config and infrastructure management is clear. + +#### Acceptance Criteria + +1. THE Bolt_Integration SHALL accept a Settings_Hash parameter for Pabawi application configuration +2. THE Bolt_Integration SHALL accept project_path_source as a regular class parameter for git repository URL +3. THE Bolt_Integration SHALL accept a manage_package Boolean parameter with default value false +4. THE Bolt_Integration SHALL accept an enabled Boolean parameter with default value true +5. THE Settings_Hash SHALL support keys: project_path, execution_timeout +6. WHEN project_path_source parameter is provided, THE Bolt_Integration SHALL clone the git repository to the path specified in Settings_Hash project_path +7. FOR ALL Settings_Hash key-value pairs, THE Bolt_Integration SHALL write them to Environment_File with BOLT_ prefix +8. THE Bolt_Integration SHALL use concat fragment order 20 for Environment_File integration + +### Requirement 5: Hiera Integration Settings Hash + +**User Story:** As a Pabawi administrator, I want to configure Hiera integration using a settings hash for application configuration and regular parameters for Puppet management, so that the distinction between app config and infrastructure management is clear. + +#### Acceptance Criteria + +1. THE Hiera_Integration SHALL accept a Settings_Hash parameter for Pabawi application configuration +2. THE Hiera_Integration SHALL accept control_repo_source as a regular class parameter for git repository URL +3. THE Hiera_Integration SHALL accept a manage_package Boolean parameter with default value false +4. THE Hiera_Integration SHALL accept an enabled Boolean parameter with default value true +5. THE Settings_Hash SHALL support keys: control_repo_path, config_path, environments, fact_source_prefer_puppetdb, fact_source_local_path +6. WHEN control_repo_source parameter is provided, THE Hiera_Integration SHALL clone the git repository to the path specified in Settings_Hash control_repo_path +7. FOR ALL Settings_Hash key-value pairs, THE Hiera_Integration SHALL write them to Environment_File with HIERA_ prefix +8. THE Hiera_Integration SHALL use concat fragment order 23 for Environment_File integration + +### Requirement 6: PuppetDB Integration Settings Hash + +**User Story:** As a Pabawi administrator, I want to configure PuppetDB integration using a settings hash for application configuration and regular parameters for Puppet file deployment, so that the distinction between app config and infrastructure management is clear. + +#### Acceptance Criteria + +1. THE PuppetDB_Integration SHALL accept a Settings_Hash parameter for Pabawi application configuration +2. THE PuppetDB_Integration SHALL accept ssl_ca_source as a regular class parameter for Puppet file source +3. THE PuppetDB_Integration SHALL accept ssl_cert_source as a regular class parameter for Puppet file source +4. THE PuppetDB_Integration SHALL accept ssl_key_source as a regular class parameter for Puppet file source +5. THE PuppetDB_Integration SHALL accept an enabled Boolean parameter with default value true +6. THE Settings_Hash SHALL support keys: server_url, port, ssl_enabled, ssl_ca, ssl_cert, ssl_key, ssl_reject_unauthorized +7. WHEN ssl_ca_source, ssl_cert_source, or ssl_key_source parameters are provided, THE PuppetDB_Integration SHALL deploy SSL certificates to the paths specified in Settings_Hash (ssl_ca, ssl_cert, ssl_key) +8. THE PuppetDB_Integration SHALL support file://, https://, and local path formats for SSL certificate source parameters +9. FOR ALL Settings_Hash key-value pairs, THE PuppetDB_Integration SHALL write them to Environment_File with PUPPETDB_ prefix +10. THE PuppetDB_Integration SHALL use concat fragment order 21 for Environment_File integration + +### Requirement 7: PuppetServer Integration Settings Hash + +**User Story:** As a Pabawi administrator, I want to configure PuppetServer integration using a settings hash for application configuration and regular parameters for Puppet file deployment, so that the distinction between app config and infrastructure management is clear. + +#### Acceptance Criteria + +1. THE PuppetServer_Integration SHALL accept a Settings_Hash parameter for Pabawi application configuration +2. THE PuppetServer_Integration SHALL accept ssl_ca_source as a regular class parameter for Puppet file source +3. THE PuppetServer_Integration SHALL accept ssl_cert_source as a regular class parameter for Puppet file source +4. THE PuppetServer_Integration SHALL accept ssl_key_source as a regular class parameter for Puppet file source +5. THE PuppetServer_Integration SHALL accept an enabled Boolean parameter with default value true +6. THE Settings_Hash SHALL support keys: server_url, port, ssl_enabled, ssl_ca, ssl_cert, ssl_key, ssl_reject_unauthorized, inactivity_threshold, cache_ttl, circuit_breaker_threshold, circuit_breaker_timeout, circuit_breaker_reset_timeout +7. WHEN ssl_ca_source, ssl_cert_source, or ssl_key_source parameters are provided, THE PuppetServer_Integration SHALL deploy SSL certificates to the paths specified in Settings_Hash (ssl_ca, ssl_cert, ssl_key) +8. THE PuppetServer_Integration SHALL support file://, https://, and local path formats for SSL certificate source parameters +9. FOR ALL Settings_Hash key-value pairs, THE PuppetServer_Integration SHALL write them to Environment_File with PUPPETSERVER_ prefix +10. THE PuppetServer_Integration SHALL use concat fragment order 22 for Environment_File integration + +### Requirement 8: Settings Hash Validation + +**User Story:** As a Pabawi administrator, I want clear error messages when required settings are missing, so that I can quickly identify and fix configuration issues. + +#### Acceptance Criteria + +1. WHEN an Integration_Class is enabled and required settings are missing from Settings_Hash, THE Integration_Class SHALL fail with a descriptive error message +2. THE error message SHALL specify which setting key is required +3. THE error message SHALL specify which Integration_Class generated the error +4. FOR ALL Integration_Classes, validation SHALL occur before any resources are created + +### Requirement 9: Settings Hash to Environment Variable Transformation + +**User Story:** As a Pabawi administrator, I want settings hash values properly formatted in the environment file, so that the application can parse them correctly. + +#### Acceptance Criteria + +1. WHEN a Settings_Hash value is an Array, THE Integration_Class SHALL convert it to JSON format in the Environment_File +2. WHEN a Settings_Hash value is a Boolean, THE Integration_Class SHALL write it as lowercase true or false +3. WHEN a Settings_Hash value is a String, THE Integration_Class SHALL write it as-is +4. WHEN a Settings_Hash value is an Integer, THE Integration_Class SHALL write it as a string representation +5. WHEN a Settings_Hash value is undef or empty, THE Integration_Class SHALL write 'not-set' to the Environment_File + +### Requirement 10: Git Repository Management + +**User Story:** As a Pabawi administrator, I want integration classes to automatically clone git repositories from source parameters, so that I can manage configuration as code. + +#### Acceptance Criteria + +1. WHEN a Source_Parameter (inventory_source, playbook_source, project_path_source, control_repo_source) contains a git URL, THE Integration_Class SHALL use vcsrepo to clone the repository +2. THE Integration_Class SHALL create parent directories before cloning repositories +3. THE Integration_Class SHALL use the corresponding Settings_Hash path key as the clone destination (e.g., inventory_source clones to inventory_path from Settings_Hash) +4. WHEN a repository is already cloned, THE Integration_Class SHALL ensure it remains present +5. THE vcsrepo resource SHALL require the parent directory creation exec resource diff --git a/.kiro/specs/puppet-pabawi-refactoring/tasks.md b/.kiro/specs/puppet-pabawi-refactoring/tasks.md new file mode 100644 index 0000000..3ee8822 --- /dev/null +++ b/.kiro/specs/puppet-pabawi-refactoring/tasks.md @@ -0,0 +1,236 @@ +# Implementation Plan: Puppet-Pabawi Refactoring + +## Overview + +This implementation plan refactors the puppet-pabawi module to introduce a consistent settings hash pattern across all integration classes, add SSH integration support, and relocate command whitelist parameters to the classes that actually use them. The refactoring separates Pabawi application configuration (written to .env file) from Puppet infrastructure management (package installation, file deployment, git repository cloning). + +## Tasks + +- [ ] 1. Create SSH integration class + - [x] 1.1 Implement manifests/integrations/ssh.pp with settings hash pattern + - Create class with enabled and settings parameters + - Implement concat fragment for .env file with SSH_ prefix + - Use concat fragment order 25 + - Write SSH_ENABLED when enabled is true + - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6_ + + - [ ]* 1.2 Write unit tests for SSH integration + - Test class parameter interface + - Test concat fragment creation and ordering + - Test SSH_ENABLED environment variable + - Test settings hash prefix application + - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6_ + + - [ ]* 1.3 Write property test for SSH settings transformation + - **Property 1: Settings Hash to Environment Variable Transformation** + - **Property 2: Settings Hash Prefix Application** + - **Validates: Requirements 1.5, 9.1, 9.2, 9.3, 9.4, 9.5** + +- [ ] 2. Refactor Ansible integration class + - [x] 2.1 Update manifests/integrations/ansible.pp with settings hash pattern + - Add settings hash parameter + - Rename source parameters (inventory_source, playbook_source) + - Implement git repository cloning with vcsrepo + - Create parent directory exec resources before vcsrepo + - Update concat fragment to use settings hash with ANSIBLE_ prefix + - Maintain concat fragment order 24 + - _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7, 3.8, 3.9, 3.10_ + + - [ ]* 2.2 Write unit tests for Ansible integration + - Test settings hash parameter interface + - Test git repository cloning with inventory_source and playbook_source + - Test parent directory creation + - Test concat fragment with ANSIBLE_ prefix + - Test manage_package parameter + - _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7, 3.8, 3.9, 3.10_ + + - [ ]* 2.3 Write property tests for Ansible integration + - **Property 3: Git Repository Cloning with Source Parameters** + - **Property 4: Git Repository Resource Dependencies** + - **Validates: Requirements 3.7, 3.8, 10.1, 10.2, 10.3, 10.4, 10.5** + +- [ ] 3. Refactor Bolt integration class + - [x] 3.1 Update manifests/integrations/bolt.pp with settings hash pattern + - Add settings hash parameter + - Remove command_whitelist and command_whitelist_allow_all parameters + - Rename project_path_source parameter + - Implement git repository cloning with vcsrepo + - Create parent directory exec resource before vcsrepo + - Update concat fragment to use settings hash with BOLT_ prefix + - Maintain concat fragment order 20 + - _Requirements: 2.1, 4.1, 4.2, 4.3, 4.4, 4.5, 4.6, 4.7, 4.8_ + + - [ ]* 3.2 Write unit tests for Bolt integration + - Test settings hash parameter interface + - Test git repository cloning with project_path_source + - Test parent directory creation + - Test concat fragment with BOLT_ prefix + - Verify command_whitelist parameters are removed + - _Requirements: 2.1, 4.1, 4.2, 4.3, 4.4, 4.5, 4.6, 4.7, 4.8_ + + - [ ]* 3.3 Write property tests for Bolt integration + - **Property 3: Git Repository Cloning with Source Parameters** + - **Property 4: Git Repository Resource Dependencies** + - **Validates: Requirements 4.6, 10.1, 10.2, 10.3, 10.4, 10.5** + +- [ ] 4. Refactor Hiera integration class + - [x] 4.1 Update manifests/integrations/hiera.pp with settings hash pattern + - Add settings hash parameter + - Rename control_repo_source parameter + - Implement git repository cloning with vcsrepo + - Create parent directory exec resource before vcsrepo + - Update concat fragment to use settings hash with HIERA_ prefix + - Maintain concat fragment order 23 + - _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6, 5.7, 5.8_ + + - [ ]* 4.2 Write unit tests for Hiera integration + - Test settings hash parameter interface + - Test git repository cloning with control_repo_source + - Test parent directory creation + - Test concat fragment with HIERA_ prefix + - Test array settings transformation (environments) + - _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6, 5.7, 5.8_ + + - [ ]* 4.3 Write property tests for Hiera integration + - **Property 3: Git Repository Cloning with Source Parameters** + - **Property 4: Git Repository Resource Dependencies** + - **Validates: Requirements 5.6, 10.1, 10.2, 10.3, 10.4, 10.5** + +- [x] 5. Checkpoint - Ensure all tests pass + - Ensure all tests pass, ask the user if questions arise. + +- [ ] 6. Refactor PuppetDB integration class + - [x] 6.1 Update manifests/integrations/puppetdb.pp with settings hash pattern + - Add settings hash parameter + - Add ssl_ca_source, ssl_cert_source, ssl_key_source parameters + - Implement SSL certificate deployment with file resources + - Support file://, https://, and local path formats for SSL sources + - Update concat fragment to use settings hash with PUPPETDB_ prefix + - Maintain concat fragment order 21 + - _Requirements: 6.1, 6.2, 6.3, 6.4, 6.5, 6.6, 6.7, 6.8, 6.9, 6.10_ + + - [ ]* 6.2 Write unit tests for PuppetDB integration + - Test settings hash parameter interface + - Test SSL certificate deployment with various source formats + - Test file permissions (0644 for ca/cert, 0600 for key) + - Test concat fragment with PUPPETDB_ prefix + - Test boolean settings transformation (ssl_enabled, ssl_reject_unauthorized) + - _Requirements: 6.1, 6.2, 6.3, 6.4, 6.5, 6.6, 6.7, 6.8, 6.9, 6.10_ + + - [ ]* 6.3 Write property tests for PuppetDB integration + - **Property 5: SSL Certificate Deployment** + - **Property 6: SSL Certificate File Permissions** + - **Validates: Requirements 6.7, 6.8** + +- [ ] 7. Refactor PuppetServer integration class + - [x] 7.1 Update manifests/integrations/puppetserver.pp with settings hash pattern + - Add settings hash parameter + - Add ssl_ca_source, ssl_cert_source, ssl_key_source parameters + - Implement SSL certificate deployment with file resources + - Support file://, https://, and local path formats for SSL sources + - Update concat fragment to use settings hash with PUPPETSERVER_ prefix + - Maintain concat fragment order 22 + - _Requirements: 7.1, 7.2, 7.3, 7.4, 7.5, 7.6, 7.7, 7.8, 7.9, 7.10_ + + - [ ]* 7.2 Write unit tests for PuppetServer integration + - Test settings hash parameter interface + - Test SSL certificate deployment with various source formats + - Test file permissions (0644 for ca/cert, 0600 for key) + - Test concat fragment with PUPPETSERVER_ prefix + - Test integer settings transformation (timeouts, thresholds) + - _Requirements: 7.1, 7.2, 7.3, 7.4, 7.5, 7.6, 7.7, 7.8, 7.9, 7.10_ + + - [ ]* 7.3 Write property tests for PuppetServer integration + - **Property 5: SSL Certificate Deployment** + - **Property 6: SSL Certificate File Permissions** + - **Validates: Requirements 7.7, 7.8** + +- [ ] 8. Update Docker class with command whitelist parameters + - [x] 8.1 Update manifests/install/docker.pp to add command whitelist parameters + - Add command_whitelist Array parameter with default empty array + - Add command_whitelist_allow_all Boolean parameter with default false + - Update concat fragment to write COMMAND_WHITELIST as JSON array + - Update concat fragment to write COMMAND_WHITELIST_ALLOW_ALL as boolean + - Maintain concat fragment order 10 + - _Requirements: 2.2, 2.3, 2.6, 2.7_ + + - [ ]* 8.2 Write unit tests for Docker class command whitelist + - Test command_whitelist parameter interface + - Test command_whitelist_allow_all parameter interface + - Test COMMAND_WHITELIST JSON array transformation + - Test COMMAND_WHITELIST_ALLOW_ALL boolean transformation + - _Requirements: 2.2, 2.3, 2.6, 2.7_ + + - [ ]* 8.3 Write property test for command whitelist transformation + - **Property 1: Settings Hash to Environment Variable Transformation** + - **Validates: Requirements 2.6, 2.7, 9.1, 9.2** + +- [ ] 9. Update Nginx class with command whitelist parameters + - [x] 9.1 Update manifests/proxy/nginx.pp to add command whitelist parameters + - Add command_whitelist Array parameter with default empty array + - Add command_whitelist_allow_all Boolean parameter with default false + - Update nginx configuration template to use command whitelist + - _Requirements: 2.4, 2.5, 2.8_ + + - [ ]* 9.2 Write unit tests for Nginx class command whitelist + - Test command_whitelist parameter interface + - Test command_whitelist_allow_all parameter interface + - Test nginx configuration template includes whitelist + - _Requirements: 2.4, 2.5, 2.8_ + +- [x] 10. Checkpoint - Ensure all tests pass + - Ensure all tests pass, ask the user if questions arise. + +- [ ] 11. Implement settings validation across all integration classes + - [x] 11.1 Add validation logic to all integration classes + - Implement validation for required settings when enabled is true + - Implement source-path consistency validation + - Implement SSL configuration validation (all three SSL sources together) + - Generate descriptive error messages with integration name and setting key + - Ensure validation occurs before resource creation + - _Requirements: 8.1, 8.2, 8.3, 8.4_ + + - [ ]* 11.2 Write unit tests for settings validation + - Test validation errors for missing required settings + - Test error message format includes integration name and setting key + - Test validation occurs before resource creation + - Test source-path consistency validation + - Test SSL configuration validation + - _Requirements: 8.1, 8.2, 8.3, 8.4_ + + - [ ]* 11.3 Write property test for validation error messages + - **Property 7: Settings Validation with Descriptive Errors** + - **Validates: Requirements 8.1, 8.2, 8.3, 8.4** + +- [ ] 12. Implement universal property tests for all integrations + - [ ]* 12.1 Write property test for enabled parameter behavior + - **Property 8: Enabled Integration Environment Variable** + - Test across all integration classes (SSH, Ansible, Bolt, Hiera, PuppetDB, PuppetServer) + - **Validates: Requirements 1.4, 3.5, 4.4, 5.4, 6.5, 7.5** + + - [ ]* 12.2 Write property test for concat fragment ordering + - **Property 9: Concat Fragment Ordering Consistency** + - Test all integration classes use correct order numbers + - **Validates: Requirements 1.6, 3.10, 4.8, 5.8, 6.10, 7.10** + + - [ ]* 12.3 Write property test for settings hash transformation across all integrations + - **Property 1: Settings Hash to Environment Variable Transformation** + - **Property 2: Settings Hash Prefix Application** + - Test with various data types (String, Integer, Boolean, Array, undef) + - Test across all integration classes + - **Validates: Requirements 9.1, 9.2, 9.3, 9.4, 9.5** + +- [x] 13. Final checkpoint - Ensure all tests pass + - Ensure all tests pass, ask the user if questions arise. + +## Notes + +- Tasks marked with `*` are optional and can be skipped for faster MVP +- Each task references specific requirements for traceability +- Checkpoints ensure incremental validation at reasonable breaks +- Property tests validate universal correctness properties across all integrations +- Unit tests validate specific examples, edge cases, and integration-specific behavior +- All integration classes follow the same settings hash pattern for consistency +- Git repository cloning requires parent directory creation first (Property 4) +- SSL certificate deployment supports multiple source formats (Property 5) +- Settings hash values are transformed based on type when written to .env (Property 1) diff --git a/.kiro/steering/security-best-practices.md b/.kiro/steering/security-best-practices.md index 545be8a..61a9114 100644 --- a/.kiro/steering/security-best-practices.md +++ b/.kiro/steering/security-best-practices.md @@ -10,6 +10,7 @@ inclusion: always - Validate all user inputs - Use parameterized queries to prevent SQL injection - Implement proper authentication and authorization +- Use pragma: allowlist secret comment to allowlist secrets caught by pre-commit ## Dependency Management diff --git a/.kiro/todo/REMAINING_TODOS_REPORT.md b/.kiro/todo/REMAINING_TODOS_REPORT.md new file mode 100644 index 0000000..79097a2 --- /dev/null +++ b/.kiro/todo/REMAINING_TODOS_REPORT.md @@ -0,0 +1,289 @@ +# Pabawi Remaining TODOs - Prioritized Report + +Generated: March 11, 2026 + +## Completed Items (Moved to done/) + +- ✅ Node Linking Redesign - Backend implementation complete +- ✅ Database Schema Cleanup - Migration-first approach implemented +- ✅ Provisioning Endpoint Fix - Backend endpoint created and working +- ✅ Default User Permissions Fix - Viewer role auto-assignment implemented +- ✅ Proxmox SSL Fix - Environment variable configuration working +- ✅ Batch Execution Missing Action - executeAction method added +- ✅ Docker Missing Schema Files - Dockerfile updated to copy database directory + +--- + +## HIGH PRIORITY + +### 1. Test Failures Analysis (47 remaining failures) + +**File**: `test-failures-analysis.md` +**Impact**: Blocking CI/CD, test suite reliability +**Effort**: Medium (2-3 hours) + +**Remaining Issues**: + +- User Roles Tests: Extra viewer role causing count mismatches (~17 failures) +- RBAC Middleware Logging: Log format doesn't match expectations (2 failures) +- SSH Plugin Test: Node not found in inventory (1 failure) +- Property Test: `__proto__` obfuscation returns undefined (1 failure) +- Brute Force Test: SQL syntax error (1 failure) +- Batch Execution Tests: Logic issues (2-3 failures) + +**Next Steps**: + +1. Fix users.test.ts role assignment expectations (highest impact) +2. Update RBAC logging test expectations +3. Fix remaining edge cases + +--- + +### 2. RBAC Test Failures (115 failures - Error Format Mismatch) + +**File**: `rbac-test-failures.md` +**Impact**: Test suite reliability, API consistency +**Effort**: Low (1-2 hours) + +**Issue**: Tests expect simple string errors but implementation returns structured error objects. + +**Recommended Fix**: Update test assertions to match structured error format: + +```javascript +// Change from: +expect(response.body.error).toBe('Unauthorized'); +// To: +expect(response.body.code).toBe('UNAUTHORIZED'); +expect(response.body.message).toBeDefined(); +``` + +**Affected Files**: + +- `test/routes/groups.test.ts` (10 failures) +- `test/routes/roles-permissions.test.ts` (2 failures) +- `test/routes/users.test.ts` (33 failures) +- Integration tests (6 failures - unrelated ansible integration) + +--- + +### 3. Auth Test Database Lifecycle (67 failures) + +**File**: `auth-test-database-lifecycle.md` +**Impact**: Test infrastructure, not blocking production +**Effort**: Medium (2-3 hours) + +**Issue**: `SQLITE_MISUSE: Database is closed` errors due to async operations running after database closes. + +**Recommended Solution**: Use single database per test suite instead of per-test: + +```typescript +beforeAll(async () => { + db = new Database(':memory:'); + await initializeSchema(db); +}); + +afterAll(async () => { + await closeDatabase(db); +}); + +beforeEach(async () => { + await clearTestData(db); +}); +``` + +--- + +## MEDIUM PRIORITY + +### 4. Environment Configuration Issues + +**File**: `env-configuration-issues.md` +**Impact**: Configuration clarity, potential confusion +**Effort**: Low (30 minutes) + +**Issues**: + +- `STREAMING_BUFFER_SIZE=1024` should be `STREAMING_BUFFER_MS=100` +- Unused priority variables: `BOLT_PRIORITY`, `PUPPETDB_PRIORITY` +- Missing documentation in `.env.example` + +**Actions**: + +1. Fix variable name in `.env` +2. Remove or implement priority variables +3. Update `.env.example` + +--- + +### 5. Inventory Multiple Source Tags Bug + +**File**: `inventory-multiple-source-tags-bug.md` +**Impact**: User experience, visibility of multi-source nodes +**Effort**: Medium (2-3 hours) + +**Issue**: `puppet.office.lab42` only shows "PuppetDB" tag but should also show "Bolt" tag. + +**Investigation Needed**: + +1. Check identifier extraction for this node from both sources +2. Verify both sources return this node +3. Debug node linking process +4. Test `/api/inventory` endpoint + +--- + +### 6. Expert Mode Prototype Pollution + +**File**: `expert-mode-prototype-pollution.md` +**Impact**: Security vulnerability (not actively exploited) +**Effort**: Low (1 hour) + +**Issue**: Property-based test reveals metadata handling doesn't sanitize dangerous property names like `__proto__`, `constructor`, `prototype`. + +**Fix**: + +```typescript +const DANGEROUS_KEYS = ['__proto__', 'constructor', 'prototype']; + +addMetadata(debugInfo: DebugInfo, key: string, value: unknown): void { + if (DANGEROUS_KEYS.includes(key)) { + return; // or sanitize + } + debugInfo.metadata[key] = value; +} +``` + +--- + +### 7. Proxmox Restart Required + +**File**: `proxmox-restart-required.md` +**Impact**: Deployment issue (one-time fix) +**Effort**: Minimal (restart server) + +**Issue**: Server running cached code with old undici import. + +**Solution**: Restart backend server to pick up updated code. + +--- + +## LOW PRIORITY + +### 8. Docker Improvements + +**File**: `docker-improvements.md` +**Impact**: Build optimization, security hardening +**Effort**: Medium (3-4 hours) + +**High Priority Items**: + +- Generate package-lock.json files for deterministic builds +- Add image metadata (LABEL instructions) +- Install only production dependencies in final stage + +**Medium Priority**: + +- Optimize image size (currently 440MB) +- Enhance .dockerignore + +**Low Priority**: + +- Build optimization with BuildKit cache +- Multi-platform support +- Security scanning automation + +--- + +### 9. Hiera Classification Mode Toggle + +**File**: `hiera-classification-mode-toggle.md` +**Impact**: Enhancement feature +**Effort**: Medium (2-3 hours) + +**Status**: Frontend UI implemented, backend needs work. + +**Backend Changes Needed**: + +1. Add `classificationMode` query parameter to API +2. Update `HieraService.classifyKeyUsage()` with mode parameter +3. Implement both classification strategies (found vs class-matched) + +**Dependencies**: Requires fixing class detection first. + +--- + +### 10. Proxmox Not Initialized Issue + +**File**: `proxmox-not-initialized-issue.md` +**Status**: Empty file - likely resolved or duplicate + +**Action**: Review and delete if no longer relevant. + +--- + +## Summary Statistics + +**Total TODOs Reviewed**: 17 +**Completed**: 7 (41%) +**Remaining**: 10 (59%) + +**By Priority**: + +- High: 3 items (test failures, RBAC tests, auth lifecycle) +- Medium: 5 items (env config, inventory bug, security, proxmox restart, docker) +- Low: 2 items (docker improvements, hiera toggle) + +**Estimated Total Effort**: 15-20 hours + +--- + +## Recommended Action Plan + +**Week 1 - Critical Path**: + +1. Fix test failures (users.test.ts role assignments) - 2 hours +2. Update RBAC test assertions - 1 hour +3. Fix remaining test edge cases - 2 hours +4. Fix auth test database lifecycle - 3 hours + +**Week 2 - Quality & Security**: +5. Fix environment configuration issues - 30 min +6. Fix expert mode prototype pollution - 1 hour +7. Investigate inventory multiple source tags - 2 hours +8. Restart Proxmox (if still needed) - 5 min + +**Week 3 - Enhancements**: +9. Docker improvements (package-lock, metadata) - 2 hours +10. Hiera classification mode (if needed) - 3 hours + +--- + +## Prompt for Next Session + +``` +Review and fix the remaining test failures in the Pabawi project: + +1. HIGH PRIORITY - Fix users.test.ts role assignment tests (~17 failures) + - Issue: Tests expect specific role counts but users get auto-assigned viewer role + - Solution: Either set defaultNewUserRole: null in test setup or adjust expectations + - File: test/routes/users.test.ts + +2. Update RBAC test assertions to match structured error format (115 failures) + - Change from: expect(response.body.error).toBe('Unauthorized') + - Change to: expect(response.body.code).toBe('UNAUTHORIZED') + - Files: test/routes/groups.test.ts, test/routes/roles-permissions.test.ts, test/routes/users.test.ts + +3. Fix auth test database lifecycle issues (67 failures) + - Issue: SQLITE_MISUSE errors due to async operations after database closes + - Solution: Use single database per test suite with data cleanup between tests + - File: test/routes/auth.test.ts + +4. Fix remaining edge cases: + - RBAC middleware logging format (2 failures) + - SSH plugin node not found (1 failure) + - Property test __proto__ obfuscation (1 failure) + - Brute force SQL syntax error (1 failure) + - Batch execution logic (2-3 failures) + +Start with #1 as it has the highest impact (17 tests). +``` diff --git a/.kiro/todo/expert-mode-prototype-pollution.md b/.kiro/todo/expert-mode-prototype-pollution.md new file mode 100644 index 0000000..031431e --- /dev/null +++ b/.kiro/todo/expert-mode-prototype-pollution.md @@ -0,0 +1,37 @@ +# Expert Mode Prototype Pollution Vulnerability + +## Issue + +Property-based test failure in `test/properties/expert-mode/property-6.test.ts` reveals a security vulnerability in the expert mode's metadata handling. + +## Details + +- **Test**: Property 6: Debug Info Completeness +- **Failure**: Metadata handling doesn't sanitize dangerous property names +- **Counterexample**: `[" "," ",0,[["__proto__",0],["",{}]]]` +- **Risk**: Prototype pollution vulnerability when adding metadata with keys like `__proto__`, `constructor`, or `prototype` + +## Recommendation + +Implement property name sanitization in `ExpertModeService.addMetadata()` to reject or sanitize dangerous property names: + +```typescript +const DANGEROUS_KEYS = ['__proto__', 'constructor', 'prototype']; + +addMetadata(debugInfo: DebugInfo, key: string, value: unknown): void { + if (DANGEROUS_KEYS.includes(key)) { + // Either reject or sanitize + return; + } + debugInfo.metadata[key] = value; +} +``` + +## Priority + +Medium - Security issue but not actively exploited in current usage + +## Related + +- Expert mode feature +- Not related to Proxmox Frontend UI spec diff --git a/.kiro/todo/proxmox-not-initialized-issue.md b/.kiro/todo/proxmox-not-initialized-issue.md new file mode 100644 index 0000000..e69de29 diff --git a/.kiro/todo/proxmox-restart-required.md b/.kiro/todo/proxmox-restart-required.md new file mode 100644 index 0000000..4a336c4 --- /dev/null +++ b/.kiro/todo/proxmox-restart-required.md @@ -0,0 +1,51 @@ +# Proxmox Integration - Restart Required + +## Issue + +The backend server is showing "Cannot find module 'undici'" error during Proxmox initialization, even though the undici import has been removed from the source code. + +## Root Cause + +The server is running with cached/compiled code that still contains the old undici import. TypeScript compilation or Node.js module caching is serving the old version. + +## Solution + +Restart the backend server to pick up the updated code: + +```bash +# Stop the current backend server (Ctrl+C if running in terminal) +# Then restart it +cd pabawi +npm run dev +``` + +## What Was Fixed + +1. Removed undici import from ProxmoxClient.ts +2. Changed SSL configuration to use `NODE_TLS_REJECT_UNAUTHORIZED` environment variable instead of undici's Agent +3. This approach works with Node.js 18+ native fetch API + +## Verification Steps + +After restarting: + +1. Check backend logs for successful Proxmox initialization +2. Visit the home page - Proxmox should show as "connected" (not "not initialized") +3. Health checks should pass without "fetch failed" errors + +## Current Configuration + +The .env file has correct Proxmox configuration: + +- PROXMOX_ENABLED=true +- PROXMOX_HOST=minis.office.lab42 +- PROXMOX_PORT=8006 +- PROXMOX_TOKEN configured +- PROXMOX_SSL_REJECT_UNAUTHORIZED=false + +## Expected Behavior After Restart + +- Proxmox integration initializes successfully +- Health checks pass +- Integration shows as "connected" on home page +- Provision page shows Proxmox resources diff --git a/CLAUDE.md b/CLAUDE.md index 7d3718a..bf7063f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,7 +56,7 @@ All infrastructure integrations (Bolt, PuppetDB, Puppetserver, Hiera, Ansible, S - **`services/`** — Cross-cutting services: `ExecutionQueue` (concurrent limiting, FIFO), `StreamingExecutionManager` (SSE real-time output), `CommandWhitelistService` (security), `DatabaseService`, `AuthenticationService`, `BatchExecutionService`, and RBAC services (`UserService`, `RoleService`, `PermissionService`, `GroupService`) - **`routes/`** — Express route handlers. All async handlers must be wrapped with `asyncHandler()` from `utils/` - **`middleware/`** — Auth (JWT), RBAC, error handler, rate limiting, security headers -- **`database/`** — `DatabaseService.ts` (SQLite, schema/migration on startup), `ExecutionRepository.ts` (CRUD for execution history). Schema in `database/schema.sql`, migrations in `database/migrations.sql` +- **`database/`** — `DatabaseService.ts` (SQLite, migration-first approach), `ExecutionRepository.ts` (CRUD for execution history). All schema in `migrations/*.sql` (000: initial, 001: RBAC, etc.) - **`errors/`** — Typed error classes extending base classes; use these instead of generic `Error` - **`validation/`** — Zod schemas for request body validation diff --git a/Dockerfile b/Dockerfile index b4b9e4c..438209a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -104,8 +104,9 @@ COPY --from=backend-builder --chown=pabawi:pabawi /app/backend/dist ./dist COPY --from=backend-deps --chown=pabawi:pabawi /app/backend/node_modules ./node_modules COPY --from=backend-builder --chown=pabawi:pabawi /app/backend/package*.json ./ -# Copy SQL schema file (not copied by TypeScript compiler) -COPY --from=backend-builder --chown=pabawi:pabawi /app/backend/src/database/schema.sql ./dist/database/ +# Copy database directory with all SQL files and migrations (not copied by TypeScript compiler) +# This ensures schema files, migrations, and any future database-related files are included +COPY --from=backend-builder --chown=pabawi:pabawi /app/backend/src/database/ ./dist/database/ # Copy built frontend to public directory COPY --from=frontend-builder --chown=pabawi:pabawi /app/frontend/dist ./public diff --git a/backend/package.json b/backend/package.json index 6016e1d..15c14de 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,11 +1,11 @@ { "name": "backend", - "version": "0.8.0", + "version": "0.9.0", "description": "Backend API server for Pabawi", "main": "dist/server.js", "scripts": { "dev": "tsx watch src/server.ts", - "build": "tsc && mkdir -p dist/database && cp src/database/schema.sql dist/database/ && cp src/database/migrations.sql dist/database/", + "build": "tsc && mkdir -p dist/database && cp -r src/database/migrations dist/database/", "start": "node dist/server.js", "test": "vitest --run --passWithNoTests", "test:watch": "vitest", diff --git a/backend/src/config/ConfigService.ts b/backend/src/config/ConfigService.ts index ca0cb9b..d3dfb4d 100644 --- a/backend/src/config/ConfigService.ts +++ b/backend/src/config/ConfigService.ts @@ -109,6 +109,23 @@ export class ConfigService { exclusionPatterns?: string[]; }; }; + proxmox?: { + enabled: boolean; + host: string; + port?: number; + username?: string; + password?: string; + realm?: string; + token?: string; + ssl?: { + rejectUnauthorized?: boolean; + ca?: string; + cert?: string; + key?: string; + }; + timeout?: number; + priority?: number; + }; } { const integrations: ReturnType = {}; @@ -396,6 +413,50 @@ export class ConfigService { } } + // Parse Proxmox configuration + if (process.env.PROXMOX_ENABLED === "true") { + const host = process.env.PROXMOX_HOST; + if (!host) { + throw new Error( + "PROXMOX_HOST is required when PROXMOX_ENABLED is true", + ); + } + + integrations.proxmox = { + enabled: true, + host, + port: process.env.PROXMOX_PORT + ? parseInt(process.env.PROXMOX_PORT, 10) + : undefined, + username: process.env.PROXMOX_USERNAME, + password: process.env.PROXMOX_PASSWORD, + realm: process.env.PROXMOX_REALM, + token: process.env.PROXMOX_TOKEN, + timeout: process.env.PROXMOX_TIMEOUT + ? parseInt(process.env.PROXMOX_TIMEOUT, 10) + : undefined, + priority: process.env.PROXMOX_PRIORITY + ? parseInt(process.env.PROXMOX_PRIORITY, 10) + : undefined, + }; + + // Parse SSL configuration if any SSL-related env vars are set + if ( + process.env.PROXMOX_SSL_REJECT_UNAUTHORIZED !== undefined || + process.env.PROXMOX_SSL_CA || + process.env.PROXMOX_SSL_CERT || + process.env.PROXMOX_SSL_KEY + ) { + integrations.proxmox.ssl = { + rejectUnauthorized: + process.env.PROXMOX_SSL_REJECT_UNAUTHORIZED !== "false", + ca: process.env.PROXMOX_SSL_CA, + cert: process.env.PROXMOX_SSL_CERT, + key: process.env.PROXMOX_SSL_KEY, + }; + } + } + return integrations; } diff --git a/backend/src/config/schema.ts b/backend/src/config/schema.ts index 87b3c1f..1b717de 100644 --- a/backend/src/config/schema.ts +++ b/backend/src/config/schema.ts @@ -269,6 +269,36 @@ export const HieraConfigSchema = z.object({ export type HieraConfig = z.infer; +/** + * Proxmox SSL configuration schema + */ +export const ProxmoxSSLConfigSchema = z.object({ + rejectUnauthorized: z.boolean().default(true), + ca: z.string().optional(), + cert: z.string().optional(), + key: z.string().optional(), +}); + +export type ProxmoxSSLConfig = z.infer; + +/** + * Proxmox integration configuration schema + */ +export const ProxmoxConfigSchema = z.object({ + enabled: z.boolean().default(false), + host: z.string(), + port: z.number().int().positive().max(65535).default(8006), + username: z.string().optional(), + password: z.string().optional(), + realm: z.string().optional(), + token: z.string().optional(), + ssl: ProxmoxSSLConfigSchema.optional(), + timeout: z.number().int().positive().default(30000), // 30 seconds + priority: z.number().int().nonnegative().default(7), +}); + +export type ProxmoxConfig = z.infer; + /** * Integrations configuration schema */ @@ -277,6 +307,7 @@ export const IntegrationsConfigSchema = z.object({ puppetdb: PuppetDBConfigSchema.optional(), puppetserver: PuppetserverConfigSchema.optional(), hiera: HieraConfigSchema.optional(), + proxmox: ProxmoxConfigSchema.optional(), }); export type IntegrationsConfig = z.infer; diff --git a/backend/src/database/DatabaseService.ts b/backend/src/database/DatabaseService.ts index 1f424f7..a833f43 100644 --- a/backend/src/database/DatabaseService.ts +++ b/backend/src/database/DatabaseService.ts @@ -1,6 +1,5 @@ import sqlite3 from "sqlite3"; -import { readFileSync } from "fs"; -import { dirname, join } from "path"; +import { dirname } from "path"; import { mkdirSync, existsSync } from "fs"; import { MigrationRunner } from "./MigrationRunner"; @@ -74,7 +73,15 @@ export class DatabaseService { } /** - * Initialize database schema from SQL file + * Initialize database schema using migration-first approach + * + * Schema Management Policy: + * - ALL schema definitions are in numbered migrations (migrations/*.sql) + * - Migration 000: Initial schema (executions, revoked_tokens) + * - Migration 001: RBAC tables (users, roles, permissions, groups) + * - Migration 002+: All subsequent schema changes + * - Future changes: Always create a new numbered migration + * - Never modify existing migrations after they've been applied */ private async initializeSchema(): Promise { if (!this.db) { @@ -82,57 +89,7 @@ export class DatabaseService { } try { - // Read and execute main schema file - const schemaPath = join(__dirname, "schema.sql"); - const schema = readFileSync(schemaPath, "utf-8"); - - // Split schema into statements - const statements = schema - .split(";") - .map((s) => s.trim()) - .filter((s) => s.length > 0); - - // Execute each statement separately to handle migration errors gracefully - for (const statement of statements) { - try { - await this.exec(statement); - } catch (error) { - // Ignore "duplicate column" errors from ALTER TABLE (migration already applied) - const errorMessage = error instanceof Error ? error.message : ""; - if (!errorMessage.includes("duplicate column")) { - throw error; - } - // Migration already applied, continue - } - } - - // Read and execute RBAC schema file - const rbacSchemaPath = join(__dirname, "rbac-schema.sql"); - if (existsSync(rbacSchemaPath)) { - const rbacSchema = readFileSync(rbacSchemaPath, "utf-8"); - - // Split RBAC schema into statements - const rbacStatements = rbacSchema - .split(";") - .map((s) => s.trim()) - .filter((s) => s.length > 0); - - // Execute each RBAC statement - for (const statement of rbacStatements) { - try { - await this.exec(statement); - } catch (error) { - // Ignore "duplicate column" errors from ALTER TABLE (migration already applied) - const errorMessage = error instanceof Error ? error.message : ""; - if (!errorMessage.includes("duplicate column")) { - throw error; - } - // Migration already applied, continue - } - } - } - - // Run migrations + // Run all migrations (including initial schema) await this.runMigrations(); } catch (error) { throw new Error( @@ -163,26 +120,6 @@ export class DatabaseService { } } - /** - * Execute SQL statement - */ - private exec(sql: string): Promise { - return new Promise((resolve, reject) => { - if (!this.db) { - reject(new Error("Database connection not established")); - return; - } - - this.db.exec(sql, (err) => { - if (err) { - reject(err); - } else { - resolve(); - } - }); - }); - } - /** * Get database connection */ diff --git a/backend/src/database/audit-schema.sql b/backend/src/database/audit-schema.sql deleted file mode 100644 index 7661d73..0000000 --- a/backend/src/database/audit-schema.sql +++ /dev/null @@ -1,33 +0,0 @@ --- Audit Logging Schema --- Comprehensive audit trail for security monitoring and compliance --- Logs authentication, authorization, and administrative actions - --- Audit logs table: Records all security-relevant events -CREATE TABLE IF NOT EXISTS audit_logs ( - id TEXT PRIMARY KEY, -- UUID - timestamp TEXT NOT NULL, -- ISO 8601 timestamp - eventType TEXT NOT NULL, -- Event category: 'auth', 'authz', 'admin', 'user', 'role', 'permission' - "action" TEXT NOT NULL, -- Specific action: 'login_success', 'login_failure', 'permission_denied', etc. - userId TEXT, -- User who performed the action (NULL for failed login attempts) - targetUserId TEXT, -- User affected by the action (for admin operations) - targetResourceType TEXT, -- Type of resource affected: 'user', 'role', 'group', 'permission' - targetResourceId TEXT, -- ID of the affected resource - ipAddress TEXT, -- Source IP address - userAgent TEXT, -- User agent string - details TEXT, -- JSON string with additional context - result TEXT NOT NULL, -- Result: 'success', 'failure', 'denied' - FOREIGN KEY (userId) REFERENCES users(id) ON DELETE SET NULL, - FOREIGN KEY (targetUserId) REFERENCES users(id) ON DELETE SET NULL -); - --- Performance indexes for audit log queries -CREATE INDEX IF NOT EXISTS idx_audit_logs_timestamp ON audit_logs(timestamp); -CREATE INDEX IF NOT EXISTS idx_audit_logs_event_type ON audit_logs(eventType); -CREATE INDEX IF NOT EXISTS idx_audit_logs_user_id ON audit_logs(userId); -CREATE INDEX IF NOT EXISTS idx_audit_logs_target_user_id ON audit_logs(targetUserId); -CREATE INDEX IF NOT EXISTS idx_audit_logs_action ON audit_logs("action"); -CREATE INDEX IF NOT EXISTS idx_audit_logs_result ON audit_logs(result); -CREATE INDEX IF NOT EXISTS idx_audit_logs_ip_address ON audit_logs(ipAddress); - --- Composite index for common queries (user activity over time) -CREATE INDEX IF NOT EXISTS idx_audit_logs_user_timestamp ON audit_logs(userId, timestamp); diff --git a/backend/src/database/migrations.sql b/backend/src/database/migrations.sql deleted file mode 100644 index 52594db..0000000 --- a/backend/src/database/migrations.sql +++ /dev/null @@ -1,33 +0,0 @@ --- Migration: Add command and expert_mode columns to executions table --- These columns were added to support command execution tracking and expert mode flag - --- Add command column if it doesn't exist -ALTER TABLE executions ADD COLUMN command TEXT; - --- Add expert_mode column if it doesn't exist -ALTER TABLE executions ADD COLUMN expert_mode INTEGER DEFAULT 0; - --- Migration: Add re-execution tracking fields --- These columns support linking re-executed actions to their original executions - --- Add original_execution_id column if it doesn't exist -ALTER TABLE executions ADD COLUMN original_execution_id TEXT; - --- Add re_execution_count column if it doesn't exist -ALTER TABLE executions ADD COLUMN re_execution_count INTEGER DEFAULT 0; - --- Create index for finding re-executions by original execution ID -CREATE INDEX IF NOT EXISTS idx_executions_original_id ON executions(original_execution_id); - --- Migration: Add stdout and stderr columns for expert mode complete output capture --- These columns store the full command output when expert mode is enabled - --- Add stdout column if it doesn't exist -ALTER TABLE executions ADD COLUMN stdout TEXT; - --- Add stderr column if it doesn't exist -ALTER TABLE executions ADD COLUMN stderr TEXT; - --- Migration: Add execution_tool column to indicate which execution engine was used --- Values: bolt, ansible -ALTER TABLE executions ADD COLUMN execution_tool TEXT DEFAULT 'bolt'; diff --git a/backend/src/database/schema.sql b/backend/src/database/migrations/000_initial_schema.sql similarity index 96% rename from backend/src/database/schema.sql rename to backend/src/database/migrations/000_initial_schema.sql index 1a262e7..2fd968c 100644 --- a/backend/src/database/schema.sql +++ b/backend/src/database/migrations/000_initial_schema.sql @@ -1,3 +1,7 @@ +-- Migration 000: Initial Schema +-- Creates the base executions table and revoked_tokens table +-- This is the foundation schema for the Pabawi application + -- Executions table for storing command and task execution history CREATE TABLE IF NOT EXISTS executions ( id TEXT PRIMARY KEY, diff --git a/backend/src/database/rbac-schema.sql b/backend/src/database/rbac-schema.sql deleted file mode 100644 index d31c8ed..0000000 --- a/backend/src/database/rbac-schema.sql +++ /dev/null @@ -1,145 +0,0 @@ --- RBAC Authorization System Database Schema --- This schema implements Role-Based Access Control with users, groups, roles, and permissions --- All IDs are UUIDs, timestamps are ISO 8601 format - --- Users table: Core user accounts with authentication credentials -CREATE TABLE IF NOT EXISTS users ( - id TEXT PRIMARY KEY, -- UUID - username TEXT NOT NULL UNIQUE, - email TEXT NOT NULL UNIQUE, - passwordHash TEXT NOT NULL, -- bcrypt hash - firstName TEXT NOT NULL, - lastName TEXT NOT NULL, - isActive INTEGER NOT NULL DEFAULT 1, -- Boolean: 1 = active, 0 = inactive - isAdmin INTEGER NOT NULL DEFAULT 0, -- Boolean: 1 = admin, 0 = regular user - createdAt TEXT NOT NULL, -- ISO 8601 timestamp - updatedAt TEXT NOT NULL, -- ISO 8601 timestamp - lastLoginAt TEXT -- ISO 8601 timestamp, NULL if never logged in -); - --- Groups table: Collections of users for permission management -CREATE TABLE IF NOT EXISTS groups ( - id TEXT PRIMARY KEY, -- UUID - name TEXT NOT NULL UNIQUE, - description TEXT NOT NULL, - createdAt TEXT NOT NULL, -- ISO 8601 timestamp - updatedAt TEXT NOT NULL -- ISO 8601 timestamp -); - --- Roles table: Named sets of permissions -CREATE TABLE IF NOT EXISTS roles ( - id TEXT PRIMARY KEY, -- UUID - name TEXT NOT NULL UNIQUE, - description TEXT NOT NULL, - isBuiltIn INTEGER NOT NULL DEFAULT 0, -- Boolean: 1 = system role (protected), 0 = custom role - createdAt TEXT NOT NULL, -- ISO 8601 timestamp - updatedAt TEXT NOT NULL -- ISO 8601 timestamp -); - --- Permissions table: Specific resource-action authorizations -CREATE TABLE IF NOT EXISTS permissions ( - id TEXT PRIMARY KEY, -- UUID - resource TEXT NOT NULL, -- Resource identifier (e.g., 'ansible', 'bolt', 'puppetdb') - "action" TEXT NOT NULL, -- Action identifier (e.g., 'read', 'write', 'execute', 'admin') - description TEXT NOT NULL, - createdAt TEXT NOT NULL, -- ISO 8601 timestamp - UNIQUE(resource, "action") -- Each resource-action combination must be unique -); - --- User-Group junction table: Many-to-many relationship between users and groups -CREATE TABLE IF NOT EXISTS user_groups ( - userId TEXT NOT NULL, - groupId TEXT NOT NULL, - assignedAt TEXT NOT NULL, -- ISO 8601 timestamp - PRIMARY KEY (userId, groupId), - FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE, - FOREIGN KEY (groupId) REFERENCES groups(id) ON DELETE CASCADE -); - --- User-Role junction table: Direct role assignments to users -CREATE TABLE IF NOT EXISTS user_roles ( - userId TEXT NOT NULL, - roleId TEXT NOT NULL, - assignedAt TEXT NOT NULL, -- ISO 8601 timestamp - PRIMARY KEY (userId, roleId), - FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE, - FOREIGN KEY (roleId) REFERENCES roles(id) ON DELETE CASCADE -); - --- Group-Role junction table: Role assignments to groups -CREATE TABLE IF NOT EXISTS group_roles ( - groupId TEXT NOT NULL, - roleId TEXT NOT NULL, - assignedAt TEXT NOT NULL, -- ISO 8601 timestamp - PRIMARY KEY (groupId, roleId), - FOREIGN KEY (groupId) REFERENCES groups(id) ON DELETE CASCADE, - FOREIGN KEY (roleId) REFERENCES roles(id) ON DELETE CASCADE -); - --- Role-Permission junction table: Permission assignments to roles -CREATE TABLE IF NOT EXISTS role_permissions ( - roleId TEXT NOT NULL, - permissionId TEXT NOT NULL, - assignedAt TEXT NOT NULL, -- ISO 8601 timestamp - PRIMARY KEY (roleId, permissionId), - FOREIGN KEY (roleId) REFERENCES roles(id) ON DELETE CASCADE, - FOREIGN KEY (permissionId) REFERENCES permissions(id) ON DELETE CASCADE -); - --- Revoked tokens table: JWT token revocation list for logout and security -CREATE TABLE IF NOT EXISTS revoked_tokens ( - token TEXT PRIMARY KEY, -- Hashed JWT token - userId TEXT NOT NULL, - revokedAt TEXT NOT NULL, -- ISO 8601 timestamp - expiresAt TEXT NOT NULL, -- ISO 8601 timestamp (token expiration) - FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE -); - --- Performance Indexes --- User lookups by username and email (authentication) -CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); -CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); -CREATE INDEX IF NOT EXISTS idx_users_active ON users(isActive); - --- Permission check optimization: Direct user-role path -CREATE INDEX IF NOT EXISTS idx_user_roles_user ON user_roles(userId); -CREATE INDEX IF NOT EXISTS idx_user_roles_role ON user_roles(roleId); - --- Permission check optimization: User-group-role path -CREATE INDEX IF NOT EXISTS idx_user_groups_user ON user_groups(userId); -CREATE INDEX IF NOT EXISTS idx_user_groups_group ON user_groups(groupId); -CREATE INDEX IF NOT EXISTS idx_group_roles_group ON group_roles(groupId); -CREATE INDEX IF NOT EXISTS idx_group_roles_role ON group_roles(roleId); - --- Permission check optimization: Role-permission lookup -CREATE INDEX IF NOT EXISTS idx_role_permissions_role ON role_permissions(roleId); -CREATE INDEX IF NOT EXISTS idx_role_permissions_perm ON role_permissions(permissionId); - --- Permission lookups by resource and action -CREATE INDEX IF NOT EXISTS idx_permissions_resource_action ON permissions(resource, "action"); - --- Token revocation checks -CREATE INDEX IF NOT EXISTS idx_revoked_tokens_token ON revoked_tokens(token); -CREATE INDEX IF NOT EXISTS idx_revoked_tokens_expires ON revoked_tokens(expiresAt); -CREATE INDEX IF NOT EXISTS idx_revoked_tokens_user ON revoked_tokens(userId); - --- Composite indexes for optimized permission checks --- Composite index for direct user-role-permission path lookup -CREATE INDEX IF NOT EXISTS idx_user_roles_composite ON user_roles(userId, roleId); - --- Composite index for user-group-role path lookup -CREATE INDEX IF NOT EXISTS idx_user_groups_composite ON user_groups(userId, groupId); -CREATE INDEX IF NOT EXISTS idx_group_roles_composite ON group_roles(groupId, roleId); - --- Composite index for role-permission lookup -CREATE INDEX IF NOT EXISTS idx_role_permissions_composite ON role_permissions(roleId, permissionId); - --- Configuration table: Application settings and setup configuration -CREATE TABLE IF NOT EXISTS config ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL, - updatedAt TEXT NOT NULL -- ISO 8601 timestamp -); - --- Index for config lookups -CREATE INDEX IF NOT EXISTS idx_config_key ON config(key); diff --git a/backend/src/integrations/IntegrationManager.ts b/backend/src/integrations/IntegrationManager.ts index c4b5721..691db3e 100644 --- a/backend/src/integrations/IntegrationManager.ts +++ b/backend/src/integrations/IntegrationManager.ts @@ -32,7 +32,7 @@ export interface HealthCheckCacheEntry { * Aggregated inventory from multiple sources */ export interface AggregatedInventory { - nodes: Node[]; + nodes: LinkedNode[]; /** Groups aggregated from all sources */ groups: NodeGroup[]; sources: Record< @@ -236,6 +236,75 @@ export class IntegrationManager { return Array.from(this.plugins.values()); } + /** + * Get provisioning capabilities from all execution tools + * + * Queries all execution tool plugins that support provisioning capabilities + * and aggregates them into a single list with source attribution. + * + * @returns Array of provisioning capabilities from all plugins + */ + getAllProvisioningCapabilities(): Array<{ + source: string; + capabilities: Array<{ + name: string; + description: string; + operation: "create" | "destroy"; + parameters: Array<{ + name: string; + type: string; + required: boolean; + default?: unknown; + }>; + }>; + }> { + const result: Array<{ + source: string; + capabilities: Array<{ + name: string; + description: string; + operation: "create" | "destroy"; + parameters: Array<{ + name: string; + type: string; + required: boolean; + default?: unknown; + }>; + }>; + }> = []; + + for (const [name, tool] of this.executionTools) { + // Check if the plugin has listProvisioningCapabilities method + if ( + "listProvisioningCapabilities" in tool && + typeof tool.listProvisioningCapabilities === "function" + ) { + try { + const capabilities = tool.listProvisioningCapabilities(); + if (capabilities && capabilities.length > 0) { + result.push({ + source: name, + capabilities, + }); + } + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + this.logger.error( + `Failed to get provisioning capabilities from '${name}'`, + { + component: "IntegrationManager", + operation: "getAllProvisioningCapabilities", + metadata: { sourceName: name }, + }, + err + ); + } + } + } + + return result; + } + /** * Execute an action using the specified execution tool * @@ -843,41 +912,19 @@ export class IntegrationManager { return true; } - /** - * Deduplicate nodes by ID, preferring nodes from higher priority sources + /** + * Deduplicate and link nodes by matching identifiers. * - * @param nodes - Array of nodes potentially with duplicates - * @returns Deduplicated array of nodes + * When multiple sources provide the same node (matched by identifiers like certname, + * hostname, or URI), merge them into a single node entry with all sources tracked. + * The node data is taken from the highest priority source, but all sources and URIs + * are recorded in sourceData. + * + * @param nodes - Array of nodes from all sources + * @returns Deduplicated and linked array of nodes with source attribution */ - private deduplicateNodes(nodes: Node[]): Node[] { - const nodeMap = new Map(); - - for (const node of nodes) { - const existing = nodeMap.get(node.id); - - if (!existing) { - nodeMap.set(node.id, node); - continue; - } - - // Get priority for both nodes - const existingSource = (existing as Node & { source?: string }).source; - const newSource = (node as Node & { source?: string }).source; - - const existingPriority = existingSource - ? (this.plugins.get(existingSource)?.config.priority ?? 0) - : 0; - const newPriority = newSource - ? (this.plugins.get(newSource)?.config.priority ?? 0) - : 0; - - // Keep node from higher priority source - if (newPriority > existingPriority) { - nodeMap.set(node.id, node); - } - } - - return Array.from(nodeMap.values()); + private deduplicateNodes(nodes: Node[]): LinkedNode[] { + return this.nodeLinkingService.linkNodes(nodes); } /** diff --git a/backend/src/integrations/NodeLinkingService.ts b/backend/src/integrations/NodeLinkingService.ts index 6f95cbe..e784022 100644 --- a/backend/src/integrations/NodeLinkingService.ts +++ b/backend/src/integrations/NodeLinkingService.ts @@ -10,13 +10,27 @@ import type { IntegrationManager } from "./IntegrationManager"; import { LoggerService } from "../services/LoggerService"; /** - * Linked node with source attribution + * Source-specific node data + */ +export interface SourceNodeData { + id: string; + uri: string; + config?: Record; + metadata?: Record; + status?: string; +} + +/** + * Linked node with source attribution and source-specific data */ export interface LinkedNode extends Node { sources: string[]; // List of sources this node appears in linked: boolean; // True if node exists in multiple sources certificateStatus?: "signed" | "requested" | "revoked"; lastCheckIn?: string; + + // Source-specific data (keeps original IDs and URIs per source) + sourceData: Record; } /** @@ -56,89 +70,129 @@ export class NodeLinkingService { * @returns Linked nodes with source attribution */ linkNodes(nodes: Node[]): LinkedNode[] { - // First, group nodes by their identifiers - const identifierToNodes = new Map(); + // First, group nodes by their identifiers + const identifierToNodes = new Map(); - for (const node of nodes) { - const identifiers = this.extractIdentifiers(node); + for (const node of nodes) { + const identifiers = this.extractIdentifiers(node); - // Add node to all matching identifier groups - for (const identifier of identifiers) { - const group = identifierToNodes.get(identifier) ?? []; - group.push(node); - identifierToNodes.set(identifier, group); + // Add node to all matching identifier groups + for (const identifier of identifiers) { + const group = identifierToNodes.get(identifier) ?? []; + group.push(node); + identifierToNodes.set(identifier, group); + } } - } - // Now merge nodes that share any identifier - const processedNodes = new Set(); - const linkedNodes: LinkedNode[] = []; + // Now merge nodes that share any identifier + const processedNodes = new Set(); + const linkedNodes: LinkedNode[] = []; - for (const node of nodes) { - if (processedNodes.has(node)) continue; + for (const node of nodes) { + if (processedNodes.has(node)) continue; - // Find all nodes that share any identifier with this node - const identifiers = this.extractIdentifiers(node); - const relatedNodes = new Set(); - relatedNodes.add(node); - - // Collect all nodes that share any identifier - for (const identifier of identifiers) { - const group = identifierToNodes.get(identifier) ?? []; - for (const relatedNode of group) { - relatedNodes.add(relatedNode); + // Find all nodes that share any identifier with this node + const identifiers = this.extractIdentifiers(node); + const relatedNodes = new Set(); + relatedNodes.add(node); + + // Collect all nodes that share any identifier + for (const identifier of identifiers) { + const group = identifierToNodes.get(identifier) ?? []; + for (const relatedNode of group) { + relatedNodes.add(relatedNode); + } } - } - // Create linked node from all related nodes - const linkedNode: LinkedNode = { - ...node, - sources: [], - linked: false, - }; + // Use the first node's name as the primary identifier + // (all related nodes should have the same name) + const primaryName = node.name; + + // Create linked node with common name + const linkedNode: LinkedNode = { + id: primaryName, // Use name as primary ID for lookups + name: primaryName, + uri: node.uri, // Will be overwritten with combined URIs + transport: node.transport, + config: node.config, + sources: [], + linked: false, + sourceData: {}, + }; - // Merge data from all related nodes - for (const relatedNode of relatedNodes) { - processedNodes.add(relatedNode); + // Collect source-specific data from all related nodes + const allUris: string[] = []; - const nodeSource = - (relatedNode as Node & { source?: string }).source ?? "bolt"; + for (const relatedNode of relatedNodes) { + processedNodes.add(relatedNode); - if (!linkedNode.sources.includes(nodeSource)) { - linkedNode.sources.push(nodeSource); - } + const nodeSource = + (relatedNode as Node & { source?: string }).source ?? "bolt"; + + if (!linkedNode.sources.includes(nodeSource)) { + linkedNode.sources.push(nodeSource); + } - // Merge certificate status (prefer from puppetserver) - if (nodeSource === "puppetserver") { - const nodeWithCert = relatedNode as Node & { - certificateStatus?: "signed" | "requested" | "revoked"; + // Store source-specific data + linkedNode.sourceData[nodeSource] = { + id: relatedNode.id, + uri: relatedNode.uri, + config: relatedNode.config, + metadata: (relatedNode as Node & { metadata?: Record }).metadata, + status: (relatedNode as Node & { status?: string }).status, }; - if (nodeWithCert.certificateStatus) { - linkedNode.certificateStatus = nodeWithCert.certificateStatus; + + // Collect URIs + allUris.push(relatedNode.uri); + + // Merge certificate status (prefer from puppetserver) + if (nodeSource === "puppetserver") { + const nodeWithCert = relatedNode as Node & { + certificateStatus?: "signed" | "requested" | "revoked"; + }; + if (nodeWithCert.certificateStatus) { + linkedNode.certificateStatus = nodeWithCert.certificateStatus; + } } - } - // Merge last check-in (use most recent) - const nodeWithCheckIn = relatedNode as Node & { lastCheckIn?: string }; - if (nodeWithCheckIn.lastCheckIn) { - if ( - !linkedNode.lastCheckIn || - new Date(nodeWithCheckIn.lastCheckIn) > - new Date(linkedNode.lastCheckIn) - ) { - linkedNode.lastCheckIn = nodeWithCheckIn.lastCheckIn; + // Merge last check-in (use most recent) + const nodeWithCheckIn = relatedNode as Node & { lastCheckIn?: string }; + if (nodeWithCheckIn.lastCheckIn) { + if ( + !linkedNode.lastCheckIn || + new Date(nodeWithCheckIn.lastCheckIn) > + new Date(linkedNode.lastCheckIn) + ) { + linkedNode.lastCheckIn = nodeWithCheckIn.lastCheckIn; + } } } - } - // Mark as linked if from multiple sources - linkedNode.linked = linkedNode.sources.length > 1; + // Keep uri as the primary URI from the first non-empty source. + // Source-specific URIs are preserved in sourceData[source].uri. + const primaryUri = allUris.find((u) => u) ?? linkedNode.uri; + linkedNode.uri = primaryUri; - linkedNodes.push(linkedNode); - } + // Mark as linked if from multiple sources + linkedNode.linked = linkedNode.sources.length > 1; - return linkedNodes; - } + this.logger.debug("Created linked node", { + component: "NodeLinkingService", + operation: "linkNodes", + metadata: { + nodeId: linkedNode.id, + nodeName: linkedNode.name, + sources: linkedNode.sources, + linked: linkedNode.linked, + sourceDataKeys: Object.keys(linkedNode.sourceData), + }, + }); + + linkedNodes.push(linkedNode); + } + + return linkedNodes; + } /** * Get all data for a linked node from all sources @@ -275,18 +329,20 @@ export class NodeLinkingService { private extractIdentifiers(node: Node): string[] { const identifiers: string[] = []; - // Add node ID + // Add node ID (always unique per source) if (node.id) { identifiers.push(node.id.toLowerCase()); } - // Add node name (certname) - if (node.name) { + // Add node name (certname) - used for cross-source linking + // Skip empty names to prevent incorrect linking + if (node.name && node.name.trim() !== "") { identifiers.push(node.name.toLowerCase()); } // Add URI hostname (extract from URI) - if (node.uri) { + // Skip Proxmox URIs as they use format proxmox://node/vmid where 'node' is not unique per VM + if (node.uri && !node.uri.startsWith("proxmox://")) { try { // Extract hostname from URI // URIs can be in formats like: diff --git a/backend/src/integrations/proxmox/ProxmoxClient.ts b/backend/src/integrations/proxmox/ProxmoxClient.ts new file mode 100644 index 0000000..8789537 --- /dev/null +++ b/backend/src/integrations/proxmox/ProxmoxClient.ts @@ -0,0 +1,468 @@ +/** + * Proxmox API Client + * + * Low-level HTTP client for communicating with the Proxmox VE API. + * Handles authentication, request/response transformation, and error handling. + */ + +import type { LoggerService } from "../../services/LoggerService"; +import type { + ProxmoxConfig, + ProxmoxTaskStatus, + RetryConfig, +} from "./types"; +import { + ProxmoxError, + ProxmoxAuthenticationError, +} from "./types"; + +/** + * ProxmoxClient - HTTP client for Proxmox VE API + * + * Responsibilities: + * - Manage authentication (ticket-based and token-based) + * - Execute HTTP requests with proper headers + * - Handle authentication ticket refresh + * - Configure HTTPS agent with SSL options + * - Transform HTTP errors into domain-specific exceptions + */ +export class ProxmoxClient { + private baseUrl: string; + private config: ProxmoxConfig; + private logger: LoggerService; + private ticket?: string; + private csrfToken?: string; + private retryConfig: RetryConfig; + + /** + * Create a new ProxmoxClient instance + * + * @param config - Proxmox configuration + * @param logger - Logger service instance + */ + constructor(config: ProxmoxConfig, logger: LoggerService) { + this.config = config; + this.logger = logger; + this.baseUrl = `https://${config.host}:${String(config.port ?? 8006)}`; + + // Configure SSL behavior + // NOTE: Setting NODE_TLS_REJECT_UNAUTHORIZED is process-wide and would disable TLS verification + // for ALL HTTPS traffic, not just Proxmox. Instead, log a warning to guide the operator. + // Per-client TLS bypass via a custom HTTPS agent/dispatcher is the correct solution. + if (config.ssl && config.ssl.rejectUnauthorized === false) { + this.logger.warn( + "Proxmox ssl.rejectUnauthorized=false is set, but per-client TLS bypass is not yet implemented. " + + "Proxmox connections will use the default TLS verification. " + + "Configure a trusted CA certificate (ssl.ca) to connect to Proxmox with self-signed certs.", + { + component: "ProxmoxClient", + operation: "constructor", + } + ); + } + + // Configure retry logic + this.retryConfig = { + maxAttempts: 3, + initialDelay: 1000, + maxDelay: 10000, + backoffMultiplier: 2, + retryableErrors: ["ECONNRESET", "ETIMEDOUT", "ENOTFOUND"], + }; + + this.logger.debug("ProxmoxClient initialized", { + component: "ProxmoxClient", + operation: "constructor", + metadata: { host: config.host, port: config.port ?? 8006 }, + }); + } + + /** + * Authenticate with the Proxmox API + * + * For token authentication: stores the token for use in Authorization header + * For password authentication: fetches and stores authentication ticket and CSRF token + * + * @throws {ProxmoxAuthenticationError} If authentication fails + */ + async authenticate(): Promise { + if (this.config.token) { + // Token authentication - no need to fetch ticket + this.logger.info("Using token authentication", { + component: "ProxmoxClient", + operation: "authenticate", + }); + return; + } + + // Password authentication - fetch ticket + const endpoint = "/api2/json/access/ticket"; + const params = { + username: `${this.config.username ?? ""}@${this.config.realm ?? ""}`, + password: this.config.password, + }; + + try { + this.logger.debug("Authenticating with password", { + component: "ProxmoxClient", + operation: "authenticate", + metadata: { + username: this.config.username, + realm: this.config.realm, + }, + }); + + const response = (await this.request( + "POST", + endpoint, + params, + false + )) as { ticket: string; CSRFPreventionToken: string }; + this.ticket = response.ticket; + this.csrfToken = response.CSRFPreventionToken; + + this.logger.info("Authentication successful", { + component: "ProxmoxClient", + operation: "authenticate", + }); + } catch (error) { + this.logger.error( + "Failed to authenticate with Proxmox API", + { + component: "ProxmoxClient", + operation: "authenticate", + }, + error instanceof Error ? error : undefined + ); + + throw new ProxmoxAuthenticationError( + "Failed to authenticate with Proxmox API", + error + ); + } + } + + /** + * Execute a GET request + * + * @param endpoint - API endpoint path + * @returns Response data + */ + async get(endpoint: string): Promise { + return await this.requestWithRetry("GET", endpoint); + } + + /** + * Execute a POST request + * + * @param endpoint - API endpoint path + * @param data - Request body data + * @returns Task ID (UPID) for async operations + */ + async post(endpoint: string, data: unknown): Promise { + const response = await this.requestWithRetry("POST", endpoint, data); + // Proxmox returns task ID (UPID) for async operations + return response as string; + } + + /** + * Execute a DELETE request + * + * @param endpoint - API endpoint path + * @returns Task ID (UPID) for async operations + */ + async delete(endpoint: string): Promise { + const response = await this.requestWithRetry("DELETE", endpoint); + return response as string; + } + + /** + * Wait for a Proxmox task to complete + * + * Polls the task status endpoint until the task completes or times out. + * + * @param node - Node name where the task is running + * @param taskId - Task ID (UPID) + * @param timeout - Timeout in milliseconds (default: 300000 = 5 minutes) + * @throws {ProxmoxError} If task fails or times out + */ + async waitForTask( + node: string, + taskId: string, + timeout = 300000 + ): Promise { + const startTime = Date.now(); + const pollInterval = 2000; // 2 seconds + + this.logger.debug("Waiting for task to complete", { + component: "ProxmoxClient", + operation: "waitForTask", + metadata: { node, taskId, timeout }, + }); + + while (Date.now() - startTime < timeout) { + const endpoint = `/api2/json/nodes/${node}/tasks/${taskId}/status`; + const status = (await this.get(endpoint)) as ProxmoxTaskStatus; + + if (status.status === "stopped") { + if (status.exitstatus === "OK") { + this.logger.info("Task completed successfully", { + component: "ProxmoxClient", + operation: "waitForTask", + metadata: { node, taskId }, + }); + return; + } else { + this.logger.error("Task failed", { + component: "ProxmoxClient", + operation: "waitForTask", + metadata: { node, taskId, exitstatus: status.exitstatus }, + }); + + throw new ProxmoxError( + `Task failed: ${status.exitstatus ?? "unknown"}`, + "TASK_FAILED", + status + ); + } + } + + await this.sleep(pollInterval); + } + + this.logger.error("Task timeout", { + component: "ProxmoxClient", + operation: "waitForTask", + metadata: { node, taskId, timeout }, + }); + + throw new ProxmoxError( + `Task timeout after ${String(timeout)}ms`, + "TASK_TIMEOUT", + { taskId, node } + ); + } + + /** + * Execute a request with retry logic + * + * @param method - HTTP method + * @param endpoint - API endpoint path + * @param data - Optional request body data + * @returns Response data + */ + private async requestWithRetry( + method: string, + endpoint: string, + data?: unknown + ): Promise { + let lastError: Error | undefined; + + for (let attempt = 1; attempt <= this.retryConfig.maxAttempts; attempt++) { + try { + return await this.request(method, endpoint, data); + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + + // Don't retry authentication errors + if (error instanceof ProxmoxAuthenticationError) { + throw error; + } + + // Don't retry 4xx errors except 429 + if (error instanceof ProxmoxError && error.code.startsWith("HTTP_4")) { + if (error.code !== "HTTP_429") { + throw error; + } + // Handle rate limiting + const details = error.details as { retryAfter?: number } | undefined; + const retryAfter = details?.retryAfter ?? 5000; + await this.sleep(retryAfter); + continue; + } + + // Check if error is retryable + const isRetryable = this.retryConfig.retryableErrors.some((errCode) => + lastError?.message.includes(errCode) + ); + + if (!isRetryable || attempt === this.retryConfig.maxAttempts) { + throw error; + } + + // Calculate backoff delay + const delay = Math.min( + this.retryConfig.initialDelay * + Math.pow(this.retryConfig.backoffMultiplier, attempt - 1), + this.retryConfig.maxDelay + ); + + this.logger.warn( + `Request failed, retrying (attempt ${String(attempt)}/${String(this.retryConfig.maxAttempts)})`, + { + component: "ProxmoxClient", + operation: "requestWithRetry", + metadata: { endpoint, attempt, delay }, + } + ); + + await this.sleep(delay); + } + } + + throw lastError ?? new Error("Request failed after retries"); + } + + /** + * Execute an HTTP request + * + * @param method - HTTP method + * @param endpoint - API endpoint path + * @param data - Optional request body data + * @param useAuth - Whether to include authentication (default: true) + * @returns Response data + */ + private async request( + method: string, + endpoint: string, + data?: unknown, + useAuth = true + ): Promise { + const url = `${this.baseUrl}${endpoint}`; + const headers: Record = {}; + + // Proxmox API expects form-urlencoded for POST/PUT/DELETE, not JSON + let body: string | undefined; + if (data && method !== "GET") { + headers["Content-Type"] = "application/x-www-form-urlencoded"; + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(data as Record)) { + if (value !== undefined && value !== null) { + params.append(key, String(value)); + } + } + body = params.toString(); + } else { + headers["Content-Type"] = "application/json"; + body = data ? JSON.stringify(data) : undefined; + } + + // Add authentication + if (useAuth) { + if (this.config.token) { + headers.Authorization = `PVEAPIToken=${this.config.token}`; + } else if (this.ticket) { + headers.Cookie = `PVEAuthCookie=${this.ticket}`; + if (method !== "GET" && this.csrfToken) { + headers.CSRFPreventionToken = this.csrfToken; + } + } + } + + try { + const response = await this.fetchWithTimeout(url, { + method, + headers, + body, + }); + + return await this.handleResponse(response); + } catch (error) { + // Handle ticket expiration + if (error instanceof ProxmoxAuthenticationError && this.ticket) { + this.logger.info("Authentication ticket expired, re-authenticating", { + component: "ProxmoxClient", + operation: "request", + }); + await this.authenticate(); + // Retry request with new ticket + return await this.request(method, endpoint, data, useAuth); + } + throw error; + } + } + + /** + * Handle HTTP response + * + * @param response - Fetch response object + * @returns Response data + * @throws {ProxmoxError} For HTTP errors + * @throws {ProxmoxAuthenticationError} For authentication errors + */ + private async handleResponse(response: Response): Promise { + // Handle authentication errors + if (response.status === 401 || response.status === 403) { + throw new ProxmoxAuthenticationError("Authentication failed", { + status: response.status, + }); + } + + // Handle not found + if (response.status === 404) { + throw new ProxmoxError("Resource not found", "HTTP_404", { + status: response.status, + }); + } + + // Handle other errors + if (!response.ok) { + const errorText = await response.text(); + // Include the body in the message for better diagnostics + const detail = errorText ? `: ${errorText}` : ""; + throw new ProxmoxError( + `Proxmox API error: ${response.statusText}${detail}`, + `HTTP_${String(response.status)}`, + { + status: response.status, + statusText: response.statusText, + body: errorText, + } + ); + } + + // Parse JSON response + const json = (await response.json()) as { data: unknown }; + return json.data; // Proxmox wraps responses in {data: ...} + } + + /** + * Fetch with timeout + * + * @param url - Request URL + * @param options - Fetch options + * @param timeout - Timeout in milliseconds (default: 30000) + * @returns Fetch response + */ + private async fetchWithTimeout( + url: string, + options: RequestInit, + timeout = 30000 + ): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => { + controller.abort(); + }, timeout); + + try { + const response = await fetch(url, { + ...options, + signal: controller.signal, + }); + return response; + } finally { + clearTimeout(timeoutId); + } + } + + /** + * Sleep for a specified duration + * + * @param ms - Duration in milliseconds + */ + private sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + } +} diff --git a/backend/src/integrations/proxmox/ProxmoxIntegration.ts b/backend/src/integrations/proxmox/ProxmoxIntegration.ts new file mode 100644 index 0000000..04585e1 --- /dev/null +++ b/backend/src/integrations/proxmox/ProxmoxIntegration.ts @@ -0,0 +1,362 @@ +/** + * Proxmox Integration Plugin + * + * Plugin class that integrates Proxmox Virtual Environment into Pabawi. + * Implements both InformationSourcePlugin and ExecutionToolPlugin interfaces. + */ + +import { BasePlugin } from "../BasePlugin"; +import type { + HealthStatus, + InformationSourcePlugin, + ExecutionToolPlugin, + NodeGroup, + Capability, + Action, +} from "../types"; +import type { Node, Facts, ExecutionResult } from "../bolt/types"; +import type { LoggerService } from "../../services/LoggerService"; +import type { PerformanceMonitorService } from "../../services/PerformanceMonitorService"; +import { ProxmoxService } from "./ProxmoxService"; +import type { ProxmoxConfig, ProvisioningCapability } from "./types"; + +/** + * ProxmoxIntegration - Plugin for Proxmox Virtual Environment + * + * Provides: + * - Inventory discovery of VMs and containers + * - Group management (by node, status, type) + * - Facts retrieval for guests + * - Lifecycle actions (start, stop, shutdown, reboot, suspend, resume) + * - Provisioning capabilities (create/destroy VMs and containers) + * + * Validates: Requirements 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 2.1-2.6, 4.1, 16.1-16.6 + */ +export class ProxmoxIntegration + extends BasePlugin + implements InformationSourcePlugin, ExecutionToolPlugin +{ + type = "both" as const; + private service?: ProxmoxService; + + /** + * Create a new ProxmoxIntegration instance + * + * @param logger - Logger service instance (optional) + * @param performanceMonitor - Performance monitor service instance (optional) + */ + constructor( + logger?: LoggerService, + performanceMonitor?: PerformanceMonitorService + ) { + super("proxmox", "both", logger, performanceMonitor); + + this.logger.debug("ProxmoxIntegration created", { + component: "ProxmoxIntegration", + operation: "constructor", + }); + } + + /** + * Perform plugin-specific initialization + * + * Validates Proxmox configuration and initializes ProxmoxService. + * Logs security warning if SSL certificate verification is disabled. + * + * Validates: Requirements 1.4, 1.5, 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 16.1-16.6 + * + * @throws Error if configuration is invalid + */ + protected async performInitialization(): Promise { + this.logger.info("Initializing Proxmox integration", { + component: "ProxmoxIntegration", + operation: "performInitialization", + }); + + // Extract and validate Proxmox configuration + const config = this.config.config as unknown as ProxmoxConfig; + this.validateProxmoxConfig(config); + + // Initialize service with configuration + this.service = new ProxmoxService( + config, + this.logger, + this.performanceMonitor + ); + await this.service.initialize(); + + this.logger.info("Proxmox integration initialized successfully", { + component: "ProxmoxIntegration", + operation: "performInitialization", + }); + } + + /** + * Validate Proxmox configuration + * + * Validates: + * - Host is a valid hostname or IP address + * - Port is in valid range (1-65535) + * - Either password or token authentication is configured + * - Realm is provided for password authentication + * - Logs security warning if SSL verification is disabled + * + * Validates: Requirements 2.3, 2.4, 2.6, 16.1, 16.2, 16.3, 16.4, 16.5, 16.6 + * + * @param config - Proxmox configuration to validate + * @throws Error if configuration is invalid + * @private + */ + private validateProxmoxConfig(config: ProxmoxConfig): void { + this.logger.debug("Validating Proxmox configuration", { + component: "ProxmoxIntegration", + operation: "validateProxmoxConfig", + }); + + // Validate host (hostname or IP) + if (!config.host || typeof config.host !== "string") { + throw new Error("Proxmox configuration must include a valid host"); + } + + // Validate port range + if (config.port !== undefined) { + if (typeof config.port !== "number" || config.port < 1 || config.port > 65535) { + throw new Error("Proxmox port must be between 1 and 65535"); + } + } + + // Validate authentication - either token or password must be provided + if (!config.token && !config.password) { + throw new Error( + "Proxmox configuration must include either token or password authentication" + ); + } + + // Validate realm for password authentication + if (config.password && !config.realm) { + throw new Error( + "Proxmox password authentication requires a realm" + ); + } + + // Log security warning if cert verification disabled + if (config.ssl?.rejectUnauthorized === false) { + this.logger.warn( + "TLS certificate verification is disabled - this is insecure", + { + component: "ProxmoxIntegration", + operation: "validateProxmoxConfig", + } + ); + } + + this.logger.debug("Proxmox configuration validated successfully", { + component: "ProxmoxIntegration", + operation: "validateProxmoxConfig", + }); + } + + /** + * Perform plugin-specific health check + * + * Delegates to ProxmoxService to check API connectivity. + * Returns healthy if API is reachable, degraded if authentication fails, + * and unhealthy if API is unreachable. + * + * Validates: Requirements 4.1, 4.2, 4.3, 4.4, 4.5 + * + * @returns Health status (without lastCheck timestamp) + */ + protected async performHealthCheck(): Promise< + Omit + > { + if (!this.service) { + return { + healthy: false, + message: "ProxmoxService not initialized", + }; + } + + return await this.service.healthCheck(); + } + + // ======================================== + // InformationSourcePlugin Interface Methods + // ======================================== + + /** + * Get inventory of all VMs and containers + * + * Delegates to ProxmoxService to retrieve all guests from the Proxmox cluster. + * Results are cached for 60 seconds to reduce API load. + * + * Validates: Requirements 5.1-5.7 + * + * @returns Array of Node objects representing all guests + * @throws Error if service is not initialized or API call fails + */ + async getInventory(): Promise { + this.ensureInitialized(); + return await this.service!.getInventory(); + } + + /** + * Get groups of VMs and containers + * + * Delegates to ProxmoxService to create NodeGroup objects organized by + * Proxmox node, status, and type. Results are cached for 60 seconds. + * + * Validates: Requirements 6.1-6.7 + * + * @returns Array of NodeGroup objects + * @throws Error if service is not initialized or API call fails + */ + async getGroups(): Promise { + this.ensureInitialized(); + return await this.service!.getGroups(); + } + + /** + * Get detailed facts for a specific guest + * + * Delegates to ProxmoxService to retrieve configuration and status information + * for a VM or container. Results are cached for 30 seconds. + * + * Validates: Requirements 7.1-7.7 + * + * @param nodeId - Node identifier in format proxmox:{node}:{vmid} + * @returns Facts object with CPU, memory, disk, network config and current usage + * @throws Error if service is not initialized, nodeId format is invalid, or guest doesn't exist + */ + async getNodeFacts(nodeId: string): Promise { + this.ensureInitialized(); + return await this.service!.getNodeFacts(nodeId); + } + + /** + * Get arbitrary data for a node + * + * Proxmox integration does not support additional data types beyond facts. + * This method returns null for all data type requests. + * + * @param nodeId - Node identifier + * @param dataType - Type of data to retrieve + * @returns null (no additional data types supported) + */ + async getNodeData(_nodeId: string, _dataType: string): Promise { + this.ensureInitialized(); + + // Proxmox integration doesn't support additional data types beyond facts + // Return null to indicate no data available for the requested type + return null; + } + + // ======================================== + // ExecutionToolPlugin Interface Methods + // ======================================== + + /** + * Execute an action on a guest or provision new infrastructure + * + * Delegates to ProxmoxService to execute lifecycle actions (start, stop, shutdown, + * reboot, suspend, resume) or provisioning actions (create_vm, create_lxc, + * destroy_vm, destroy_lxc). + * + * Validates: Requirements 8.1-8.10, 9.3, 9.4, 10.1-10.7, 11.1-11.7, 12.1-12.7 + * + * @param action - Action to execute + * @returns ExecutionResult with success/error details + * @throws Error if service is not initialized or action is invalid + */ + async executeAction(action: Action): Promise { + this.ensureInitialized(); + return await this.service!.executeAction(action); + } + + /** + * List lifecycle action capabilities + * + * Returns all lifecycle actions that can be performed on VMs and containers. + * + * Validates: Requirements 8.1, 8.2 + * + * @returns Array of Capability objects + */ + listCapabilities(): Capability[] { + this.ensureInitialized(); + return this.service!.listCapabilities(); + } + + /** + * List provisioning capabilities + * + * Returns all provisioning capabilities supported by this integration, + * including VM and LXC creation and destruction. + * + * Validates: Requirements 9.3, 9.4 + * + * @returns Array of ProvisioningCapability objects + */ + listProvisioningCapabilities(): ProvisioningCapability[] { + this.ensureInitialized(); + return this.service!.listProvisioningCapabilities(); + } + + /** + * Get list of PVE nodes in the cluster + */ + async getNodes(): Promise<{ node: string; status: string; maxcpu?: number; maxmem?: number }[]> { + this.ensureInitialized(); + return this.service!.getNodes(); + } + + /** + * Get the next available VMID + */ + async getNextVMID(): Promise { + this.ensureInitialized(); + return this.service!.getNextVMID(); + } + + /** + * Get ISO images available on a node + */ + async getISOImages(node: string, storage?: string): Promise<{ volid: string; format: string; size: number }[]> { + this.ensureInitialized(); + return this.service!.getISOImages(node, storage); + } + + /** + * Get OS templates available on a node + */ + async getTemplates(node: string, storage?: string): Promise<{ volid: string; format: string; size: number }[]> { + this.ensureInitialized(); + return this.service!.getTemplates(node, storage); + } + + async getStorages(node: string, contentType?: string): Promise<{ storage: string; type: string; content: string; active: number; total?: number; used?: number; avail?: number }[]> { + this.ensureInitialized(); + return this.service!.getStorages(node, contentType); + } + + async getNetworkBridges(node: string, type?: string): Promise<{ iface: string; type: string; active: number; address?: string; cidr?: string; bridge_ports?: string }[]> { + this.ensureInitialized(); + return this.service!.getNetworkBridges(node, type); + } + + // ======================================== + // Helper Methods + // ======================================== + + /** + * Ensure the plugin is initialized + * + * @throws Error if plugin is not initialized + * @private + */ + private ensureInitialized(): void { + if (!this.initialized || !this.service) { + throw new Error("Proxmox integration is not initialized"); + } + } +} diff --git a/backend/src/integrations/proxmox/ProxmoxService.ts b/backend/src/integrations/proxmox/ProxmoxService.ts new file mode 100644 index 0000000..274904a --- /dev/null +++ b/backend/src/integrations/proxmox/ProxmoxService.ts @@ -0,0 +1,1978 @@ +/** + * Proxmox Service + * + * Business logic layer for the Proxmox VE integration. + * Orchestrates API calls through ProxmoxClient and handles data transformation. + */ + +import type { LoggerService } from "../../services/LoggerService"; +import type { PerformanceMonitorService } from "../../services/PerformanceMonitorService"; +import type { HealthStatus, NodeGroup, Action, Capability } from "../types"; +import type { Node, Facts, ExecutionResult } from "../bolt/types"; +import { SimpleCache } from "../../utils/caching"; +import { ProxmoxClient } from "./ProxmoxClient"; +import type { + ProxmoxConfig, + ProxmoxGuest, + ProxmoxGuestConfig, + ProxmoxGuestStatus, + VMCreateParams, + LXCCreateParams, + ProvisioningCapability +} from "./types"; +import { ProxmoxAuthenticationError } from "./types"; + +/** + * ProxmoxService - Business logic layer for Proxmox integration + * + * Responsibilities: + * - Orchestrate API calls through ProxmoxClient + * - Transform Proxmox API responses to Pabawi data models + * - Implement caching strategy for inventory, groups, and facts + * - Handle data aggregation and grouping logic + * - Manage provisioning operations (create/destroy VMs and containers) + */ +export class ProxmoxService { + private client?: ProxmoxClient; + private cache: SimpleCache; + private logger: LoggerService; + private performanceMonitor: PerformanceMonitorService; + private config: ProxmoxConfig; + + /** + * Create a new ProxmoxService instance + * + * @param config - Proxmox configuration + * @param logger - Logger service instance + * @param performanceMonitor - Performance monitor service instance + */ + constructor( + config: ProxmoxConfig, + logger: LoggerService, + performanceMonitor: PerformanceMonitorService + ) { + this.config = config; + this.logger = logger; + this.performanceMonitor = performanceMonitor; + this.cache = new SimpleCache({ ttl: 60000 }); // Default 60s TTL + + this.logger.debug("ProxmoxService created", { + component: "ProxmoxService", + operation: "constructor", + }); + } + + /** + * Initialize the service + * + * Creates ProxmoxClient and authenticates with the Proxmox API. + */ + async initialize(): Promise { + this.logger.info("Initializing ProxmoxService", { + component: "ProxmoxService", + operation: "initialize", + }); + + this.client = new ProxmoxClient(this.config, this.logger); + await this.client.authenticate(); + + this.logger.info("ProxmoxService initialized successfully", { + component: "ProxmoxService", + operation: "initialize", + }); + } + + /** + * Perform health check + * + * Queries the Proxmox API version endpoint to verify connectivity. + * Returns healthy status if API is reachable, degraded if authentication fails, + * and unhealthy if API is unreachable. + * + * @returns Health status (without lastCheck timestamp) + */ + async healthCheck(): Promise> { + if (!this.client) { + return { + healthy: false, + message: "ProxmoxClient not initialized", + }; + } + + try { + this.logger.debug("Performing health check", { + component: "ProxmoxService", + operation: "healthCheck", + }); + + const version = await this.client.get("/api2/json/version"); + + this.logger.info("Health check successful", { + component: "ProxmoxService", + operation: "healthCheck", + metadata: { version }, + }); + + return { + healthy: true, + message: "Proxmox API is reachable", + details: { version }, + }; + } catch (error) { + if (error instanceof ProxmoxAuthenticationError) { + this.logger.warn("Health check failed: authentication error", { + component: "ProxmoxService", + operation: "healthCheck", + metadata: { error: error.message }, + }); + + return { + healthy: false, + degraded: true, + message: "Authentication failed", + details: { error: error.message }, + }; + } + + const errorMessage = + error instanceof Error ? error.message : String(error); + + this.logger.error( + "Health check failed: API unreachable", + { + component: "ProxmoxService", + operation: "healthCheck", + metadata: { error: errorMessage }, + }, + error instanceof Error ? error : undefined + ); + + return { + healthy: false, + message: "Proxmox API is unreachable", + details: { error: errorMessage }, + }; + } + } + + /** + * Get inventory of all VMs and containers + * + * Queries the Proxmox cluster resources endpoint for all guests (VMs and containers). + * Results are cached for 60 seconds to reduce API load. + * + * @returns Array of Node objects representing all guests + * @throws Error if client is not initialized or API call fails + */ + async getInventory(): Promise { + if (!this.client) { + throw new Error("ProxmoxClient not initialized"); + } + + const cacheKey = "inventory:all"; + const cached = this.cache.get(cacheKey); + if (cached) { + this.logger.debug("Returning cached inventory", { + component: "ProxmoxService", + operation: "getInventory", + metadata: { nodeCount: (cached as Node[]).length }, + }); + return cached as Node[]; + } + + const complete = this.performanceMonitor.startTimer("proxmox:getInventory"); + + try { + this.logger.debug("Fetching inventory from Proxmox API", { + component: "ProxmoxService", + operation: "getInventory", + }); + + // Query all cluster resources (VMs and containers) + const resources = await this.client.get( + "/api2/json/cluster/resources?type=vm" + ); + + if (!Array.isArray(resources)) { + throw new Error("Unexpected response format from Proxmox API"); + } + + // Transform each guest to a Node object, filtering out templates + const nodes = resources + .filter((guest) => { + const proxmoxGuest = guest as ProxmoxGuest; + // Filter out templates (template === 1) + if (proxmoxGuest.template === 1) { + this.logger.debug("Skipping template", { + component: "ProxmoxService", + operation: "getInventory", + metadata: { vmid: proxmoxGuest.vmid, name: proxmoxGuest.name }, + }); + return false; + } + return true; + }) + .map((guest) => + this.transformGuestToNode(guest as ProxmoxGuest) + ); + + // Cache for 60 seconds + this.cache.set(cacheKey, nodes, 60000); + + this.logger.info("Inventory fetched successfully", { + component: "ProxmoxService", + operation: "getInventory", + metadata: { nodeCount: nodes.length, cached: false }, + }); + + complete({ cached: false, nodeCount: nodes.length }); + + return nodes; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + + this.logger.error( + "Failed to fetch inventory", + { + component: "ProxmoxService", + operation: "getInventory", + metadata: { error: errorMessage }, + }, + error instanceof Error ? error : undefined + ); + + complete({ error: errorMessage }); + throw error; + } + } + + /** + * Transform a Proxmox guest to a Node object + * + * Converts Proxmox API guest data to Pabawi's Node format. + * Node ID format: proxmox:{node}:{vmid} + * + * @param guest - Proxmox guest object from API + * @returns Node object with standardized fields + * @private + */ + private transformGuestToNode(guest: ProxmoxGuest): Node { + // Node ID format: proxmox:{node}:{vmid} + const nodeId = `proxmox:${guest.node}:${guest.vmid}`; + + // Build metadata object + const metadata: Record = { + vmid: guest.vmid, + node: guest.node, + type: guest.type, + status: guest.status, + }; + + // Add optional fields if present + if (guest.maxmem !== undefined) { + metadata.maxmem = guest.maxmem; + } + if (guest.maxdisk !== undefined) { + metadata.maxdisk = guest.maxdisk; + } + if (guest.cpus !== undefined) { + metadata.cpus = guest.cpus; + } + if (guest.uptime !== undefined) { + metadata.uptime = guest.uptime; + } + + // Create Node object + const node: Node = { + id: nodeId, + name: guest.name, + uri: `proxmox://${guest.node}/${guest.vmid}`, + transport: "ssh" as const, // Default transport, can be overridden + config: {}, + source: "proxmox", + }; + + // Add metadata + (node as Node & { metadata?: Record }).metadata = metadata; + + // Add status if available (map to a custom field since Node doesn't have status) + if (guest.status) { + (node as Node & { status?: string }).status = guest.status; + } + + this.logger.debug("Transformed guest to node", { + component: "ProxmoxService", + operation: "transformGuestToNode", + metadata: { vmid: guest.vmid, nodeId }, + }); + + return node; + } + + /** + * Get groups of VMs and containers + * + * Creates NodeGroup objects organized by Proxmox node, status, and type. + * Results are cached for 60 seconds to reduce API load. + * + * @returns Array of NodeGroup objects + * @throws Error if client is not initialized or API call fails + */ + async getGroups(): Promise { + if (!this.client) { + throw new Error("ProxmoxClient not initialized"); + } + + const cacheKey = "groups:all"; + const cached = this.cache.get(cacheKey); + if (cached) { + this.logger.debug("Returning cached groups", { + component: "ProxmoxService", + operation: "getGroups", + metadata: { groupCount: (cached as NodeGroup[]).length }, + }); + return cached as NodeGroup[]; + } + + try { + this.logger.debug("Building groups from inventory", { + component: "ProxmoxService", + operation: "getGroups", + }); + + // Reuse inventory data + const inventory = await this.getInventory(); + const groups: NodeGroup[] = []; + + // Group by node + const nodeGroups = this.groupByNode(inventory); + groups.push(...nodeGroups); + + // Group by status + const statusGroups = this.groupByStatus(inventory); + groups.push(...statusGroups); + + // Group by type (VM vs LXC) + const typeGroups = this.groupByType(inventory); + groups.push(...typeGroups); + + // Cache for 60 seconds + this.cache.set(cacheKey, groups, 60000); + + this.logger.info("Groups built successfully", { + component: "ProxmoxService", + operation: "getGroups", + metadata: { groupCount: groups.length, cached: false }, + }); + + return groups; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + + this.logger.error( + "Failed to build groups", + { + component: "ProxmoxService", + operation: "getGroups", + metadata: { error: errorMessage }, + }, + error instanceof Error ? error : undefined + ); + + throw error; + } + } + + /** + * Group nodes by Proxmox node + * + * Creates one NodeGroup per physical Proxmox node. + * Group ID format: proxmox:node:{nodename} + * + * @param nodes - Array of Node objects from inventory + * @returns Array of NodeGroup objects grouped by node + * @private + */ + private groupByNode(nodes: Node[]): NodeGroup[] { + const nodeMap = new Map(); + + // Group nodes by their Proxmox node + for (const node of nodes) { + const proxmoxNode = (node as Node & { metadata?: Record }) + .metadata?.node as string; + + if (!proxmoxNode) { + continue; + } + + if (!nodeMap.has(proxmoxNode)) { + nodeMap.set(proxmoxNode, []); + } + nodeMap.get(proxmoxNode)!.push(node); + } + + // Create NodeGroup objects + const groups: NodeGroup[] = []; + for (const [nodeName, nodeList] of nodeMap.entries()) { + groups.push({ + id: `proxmox:node:${nodeName}`, + name: `Proxmox Node: ${nodeName}`, + source: "proxmox", + sources: ["proxmox"], + linked: false, + nodes: nodeList.map((n) => n.id), + metadata: { + description: `All guests on Proxmox node ${nodeName}`, + nodeType: "physical", + }, + }); + } + + this.logger.debug("Grouped nodes by Proxmox node", { + component: "ProxmoxService", + operation: "groupByNode", + metadata: { groupCount: groups.length }, + }); + + return groups; + } + + /** + * Group nodes by status + * + * Creates one NodeGroup per status type (running, stopped, paused). + * Group ID format: proxmox:status:{status} + * + * @param nodes - Array of Node objects from inventory + * @returns Array of NodeGroup objects grouped by status + * @private + */ + private groupByStatus(nodes: Node[]): NodeGroup[] { + const statusMap = new Map(); + + // Group nodes by their status + for (const node of nodes) { + const status = (node as Node & { status?: string }).status; + + if (!status) { + continue; + } + + if (!statusMap.has(status)) { + statusMap.set(status, []); + } + statusMap.get(status)!.push(node); + } + + // Create NodeGroup objects + const groups: NodeGroup[] = []; + for (const [status, nodeList] of statusMap.entries()) { + groups.push({ + id: `proxmox:status:${status}`, + name: `Status: ${status}`, + source: "proxmox", + sources: ["proxmox"], + linked: false, + nodes: nodeList.map((n) => n.id), + metadata: { + description: `All guests with status ${status}`, + statusType: status, + }, + }); + } + + this.logger.debug("Grouped nodes by status", { + component: "ProxmoxService", + operation: "groupByStatus", + metadata: { groupCount: groups.length }, + }); + + return groups; + } + + /** + * Group nodes by type + * + * Creates one NodeGroup per guest type (qemu for VMs, lxc for containers). + * Group ID format: proxmox:type:{type} + * + * @param nodes - Array of Node objects from inventory + * @returns Array of NodeGroup objects grouped by type + * @private + */ + private groupByType(nodes: Node[]): NodeGroup[] { + const typeMap = new Map(); + + // Group nodes by their type + for (const node of nodes) { + const type = (node as Node & { metadata?: Record }) + .metadata?.type as string; + + if (!type) { + continue; + } + + if (!typeMap.has(type)) { + typeMap.set(type, []); + } + typeMap.get(type)!.push(node); + } + + // Create NodeGroup objects + const groups: NodeGroup[] = []; + for (const [type, nodeList] of typeMap.entries()) { + const displayName = type === "qemu" ? "Virtual Machines" : "LXC Containers"; + groups.push({ + id: `proxmox:type:${type}`, + name: displayName, + source: "proxmox", + sources: ["proxmox"], + linked: false, + nodes: nodeList.map((n) => n.id), + metadata: { + description: `All ${displayName.toLowerCase()}`, + guestType: type, + }, + }); + } + + this.logger.debug("Grouped nodes by type", { + component: "ProxmoxService", + operation: "groupByType", + metadata: { groupCount: groups.length }, + }); + + return groups; + } + + /** + * Get detailed facts for a specific guest + * + * Retrieves configuration and status information for a VM or container. + * Results are cached for 30 seconds to reduce API load. + * + * Node ID format: proxmox:{node}:{vmid} + * + * @param nodeId - Node identifier in format proxmox:{node}:{vmid} + * @returns Facts object with CPU, memory, disk, network config and current usage + * @throws Error if client is not initialized, nodeId format is invalid, or guest doesn't exist + */ + async getNodeFacts(nodeId: string): Promise { + if (!this.client) { + throw new Error("ProxmoxClient not initialized"); + } + + const cacheKey = `facts:${nodeId}`; + const cached = this.cache.get(cacheKey); + if (cached) { + this.logger.debug("Returning cached facts", { + component: "ProxmoxService", + operation: "getNodeFacts", + metadata: { nodeId }, + }); + return cached as Facts; + } + + try { + this.logger.debug("Fetching facts from Proxmox API", { + component: "ProxmoxService", + operation: "getNodeFacts", + metadata: { nodeId }, + }); + + // Parse VMID and node name from nodeId (format: "proxmox:{node}:{vmid}") + const vmid = this.parseVMID(nodeId); + const node = this.parseNodeName(nodeId); + + // Determine guest type (qemu or lxc) + const guestType = await this.getGuestType(node, vmid); + + // Fetch configuration + const configEndpoint = + guestType === "lxc" + ? `/api2/json/nodes/${node}/lxc/${vmid}/config` + : `/api2/json/nodes/${node}/qemu/${vmid}/config`; + + const config = await this.client.get(configEndpoint); + + // Fetch current status + const statusEndpoint = + guestType === "lxc" + ? `/api2/json/nodes/${node}/lxc/${vmid}/status/current` + : `/api2/json/nodes/${node}/qemu/${vmid}/status/current`; + + const status = await this.client.get(statusEndpoint); + + // Transform to Facts object + const facts = this.transformToFacts(nodeId, config, status, guestType); + + // Cache for 30 seconds + this.cache.set(cacheKey, facts, 30000); + + this.logger.info("Facts fetched successfully", { + component: "ProxmoxService", + operation: "getNodeFacts", + metadata: { nodeId, guestType, cached: false }, + }); + + return facts; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + + this.logger.error( + "Failed to fetch facts", + { + component: "ProxmoxService", + operation: "getNodeFacts", + metadata: { nodeId, error: errorMessage }, + }, + error instanceof Error ? error : undefined + ); + + throw error; + } + } + + /** + * Parse VMID from nodeId + * + * Extracts the VMID from a nodeId in format proxmox:{node}:{vmid} + * + * @param nodeId - Node identifier + * @returns VMID as number + * @throws Error if nodeId format is invalid + * @private + */ + private parseVMID(nodeId: string): number { + const parts = nodeId.split(":"); + if (parts.length !== 3 || parts[0] !== "proxmox") { + throw new Error( + `Invalid nodeId format: ${nodeId}. Expected format: proxmox:{node}:{vmid}` + ); + } + + const vmid = parseInt(parts[2], 10); + if (isNaN(vmid)) { + throw new Error(`Invalid VMID in nodeId: ${nodeId}`); + } + + return vmid; + } + + /** + * Parse node name from nodeId + * + * Extracts the Proxmox node name from a nodeId in format proxmox:{node}:{vmid} + * + * @param nodeId - Node identifier + * @returns Proxmox node name + * @throws Error if nodeId format is invalid + * @private + */ + private parseNodeName(nodeId: string): string { + const parts = nodeId.split(":"); + if (parts.length !== 3 || parts[0] !== "proxmox") { + throw new Error( + `Invalid nodeId format: ${nodeId}. Expected format: proxmox:{node}:{vmid}` + ); + } + + return parts[1]; + } + + /** + * Determine guest type (qemu or lxc) + * + * Queries the cluster resources to determine if a guest is a VM (qemu) or container (lxc). + * This is necessary because we need to know the type to construct the correct API endpoints. + * + * @param node - Proxmox node name + * @param vmid - Guest VMID + * @returns Guest type ('qemu' or 'lxc') + * @throws Error if guest doesn't exist + * @private + */ + private async getGuestType( + node: string, + vmid: number + ): Promise<"qemu" | "lxc"> { + // Query cluster resources to find the guest + const resources = await this.client!.get( + "/api2/json/cluster/resources?type=vm" + ); + + if (!Array.isArray(resources)) { + throw new Error("Unexpected response format from Proxmox API"); + } + + // Find the guest by node and vmid + const guest = resources.find( + (r: ProxmoxGuest) => r.node === node && r.vmid === vmid + ); + + if (!guest) { + throw new Error( + `Guest with VMID ${vmid} not found on node ${node}` + ); + } + + return (guest as ProxmoxGuest).type; + } + + /** + * Transform Proxmox config and status to Facts object + * + * Converts Proxmox API responses to Pabawi's Facts format. + * Includes CPU, memory, disk, and network configuration. + * Includes current usage when guest is running. + * + * @param nodeId - Node identifier + * @param config - Guest configuration from Proxmox API + * @param status - Guest status from Proxmox API + * @param guestType - Guest type ('qemu' or 'lxc') + * @returns Facts object + * @private + */ + private transformToFacts( + nodeId: string, + config: unknown, + status: unknown, + guestType: "qemu" | "lxc" + ): Facts { + const configData = config as ProxmoxGuestConfig; + const statusData = status as ProxmoxGuestStatus; + + // Extract network interfaces + const interfaces: Record = {}; + let hostname = configData.name || "unknown"; + + // Parse network configuration (net0, net1, etc.) + for (const key of Object.keys(configData)) { + if (key.startsWith("net")) { + interfaces[key] = configData[key]; + } + } + + // For LXC, hostname might be in config + if (guestType === "lxc" && configData.hostname) { + hostname = configData.hostname as string; + } + + // Build facts object + const facts: Facts = { + nodeId, + gatheredAt: new Date().toISOString(), + source: "proxmox", + facts: { + os: { + family: guestType === "lxc" ? "linux" : "unknown", + name: (configData.ostype as string) || "unknown", + release: { + full: "unknown", + major: "unknown", + }, + }, + processors: { + count: configData.cores || 1, + models: configData.cpu ? [configData.cpu as string] : [], + }, + memory: { + system: { + total: this.formatBytes(configData.memory * 1024 * 1024), + available: + statusData.status === "running" && statusData.mem !== undefined + ? this.formatBytes((configData.memory - statusData.mem / (1024 * 1024)) * 1024 * 1024) + : this.formatBytes(configData.memory * 1024 * 1024), + }, + }, + networking: { + hostname, + interfaces, + }, + categories: { + system: { + vmid: configData.vmid, + type: guestType, + status: statusData.status, + uptime: statusData.uptime, + }, + hardware: { + cores: configData.cores, + sockets: configData.sockets, + memory: configData.memory, + cpu: configData.cpu, + }, + network: { + interfaces, + }, + custom: { + bootdisk: configData.bootdisk, + scsihw: configData.scsihw, + }, + }, + }, + }; + + // Add current usage if guest is running + if (statusData.status === "running") { + facts.facts.categories!.system = { + ...facts.facts.categories!.system, + currentMemory: statusData.mem, + currentMemoryFormatted: this.formatBytes(statusData.mem || 0), + currentDisk: statusData.disk, + currentDiskFormatted: this.formatBytes(statusData.disk || 0), + networkIn: statusData.netin, + networkOut: statusData.netout, + diskRead: statusData.diskread, + diskWrite: statusData.diskwrite, + }; + } + + this.logger.debug("Transformed config and status to facts", { + component: "ProxmoxService", + operation: "transformToFacts", + metadata: { nodeId, guestType }, + }); + + return facts; + } + + /** + * Format bytes to human-readable string + * + * @param bytes - Number of bytes + * @returns Formatted string (e.g., "1.5 GB") + * @private + */ + private formatBytes(bytes: number): string { + if (bytes === 0) return "0 B"; + + const k = 1024; + const sizes = ["B", "KB", "MB", "GB", "TB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`; + } + + /** + * Get list of PVE nodes in the cluster + * + * Queries the Proxmox API for all physical nodes. + * Results are cached for 60 seconds. + * + * @returns Array of node objects with name, status, and resource info + */ + async getNodes(): Promise<{ node: string; status: string; maxcpu?: number; maxmem?: number }[]> { + if (!this.client) { + throw new Error("ProxmoxClient not initialized"); + } + + const cacheKey = "pve:nodes"; + const cached = this.cache.get(cacheKey); + if (cached) { + return cached as { node: string; status: string; maxcpu?: number; maxmem?: number }[]; + } + + try { + const result = await this.client.get("/api2/json/nodes"); + if (!Array.isArray(result)) { + throw new Error("Unexpected response format from Proxmox API"); + } + + const nodes = result.map((n: Record) => ({ + node: n.node as string, + status: n.status as string, + maxcpu: n.maxcpu as number | undefined, + maxmem: n.maxmem as number | undefined, + })); + + this.cache.set(cacheKey, nodes, 60000); + return nodes; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + this.logger.error("Failed to fetch PVE nodes", { + component: "ProxmoxService", + operation: "getNodes", + metadata: { error: errorMessage }, + }, error instanceof Error ? error : undefined); + throw error; + } + } + + /** + * Get the next available VMID from Proxmox cluster + * + * Proxmox provides a cluster-wide endpoint that returns the next free VMID. + * + * @returns Next available VMID number + */ + async getNextVMID(): Promise { + if (!this.client) { + throw new Error("ProxmoxClient not initialized"); + } + + try { + const result = await this.client.get("/api2/json/cluster/nextid"); + const vmid = typeof result === "string" ? parseInt(result, 10) : result as number; + if (isNaN(vmid as number)) { + throw new Error("Unexpected response format for next VMID"); + } + return vmid as number; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + this.logger.error("Failed to fetch next VMID", { + component: "ProxmoxService", + operation: "getNextVMID", + metadata: { error: errorMessage }, + }, error instanceof Error ? error : undefined); + throw error; + } + } + + /** + * Get ISO images available on a specific node's storage + * + * Queries the Proxmox API for ISO content on the given node. + * Results are cached for 120 seconds. + * + * @param node - PVE node name + * @param storage - Storage name (defaults to 'local') + * @returns Array of ISO image objects + */ + async getISOImages(node: string, storage = "local"): Promise<{ volid: string; format: string; size: number }[]> { + if (!this.client) { + throw new Error("ProxmoxClient not initialized"); + } + + const cacheKey = `iso:${node}:${storage}`; + const cached = this.cache.get(cacheKey); + if (cached) { + return cached as { volid: string; format: string; size: number }[]; + } + + try { + const result = await this.client.get( + `/api2/json/nodes/${node}/storage/${storage}/content?content=iso` + ); + if (!Array.isArray(result)) { + throw new Error("Unexpected response format from Proxmox API"); + } + + const isos = result.map((item: Record) => ({ + volid: item.volid as string, + format: item.format as string, + size: item.size as number, + })); + + this.cache.set(cacheKey, isos, 120000); + return isos; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + this.logger.error("Failed to fetch ISO images", { + component: "ProxmoxService", + operation: "getISOImages", + metadata: { node, storage, error: errorMessage }, + }, error instanceof Error ? error : undefined); + throw error; + } + } + + /** + * Get OS templates available on a specific node's storage + * + * Queries the Proxmox API for container templates on the given node. + * Results are cached for 120 seconds. + * + * @param node - PVE node name + * @param storage - Storage name (defaults to 'local') + * @returns Array of template objects + */ + async getTemplates(node: string, storage = "local"): Promise<{ volid: string; format: string; size: number }[]> { + if (!this.client) { + throw new Error("ProxmoxClient not initialized"); + } + + const cacheKey = `templates:${node}:${storage}`; + const cached = this.cache.get(cacheKey); + if (cached) { + return cached as { volid: string; format: string; size: number }[]; + } + + try { + const result = await this.client.get( + `/api2/json/nodes/${node}/storage/${storage}/content?content=vztmpl` + ); + if (!Array.isArray(result)) { + throw new Error("Unexpected response format from Proxmox API"); + } + + const templates = result.map((item: Record) => ({ + volid: item.volid as string, + format: item.format as string, + size: item.size as number, + })); + + this.cache.set(cacheKey, templates, 120000); + return templates; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + this.logger.error("Failed to fetch OS templates", { + component: "ProxmoxService", + operation: "getTemplates", + metadata: { node, storage, error: errorMessage }, + }, error instanceof Error ? error : undefined); + throw error; + } + } + + /** + * Get available storages on a node, optionally filtered by content type + * + * Queries the Proxmox API for storages on the given node. + * Results are cached for 120 seconds. + * + * @param node - PVE node name + * @param contentType - Optional content filter (e.g. 'rootdir', 'images', 'vztmpl', 'iso') + * @returns Array of storage objects + */ + async getStorages(node: string, contentType?: string): Promise<{ storage: string; type: string; content: string; active: number; total?: number; used?: number; avail?: number }[]> { + if (!this.client) { + throw new Error("ProxmoxClient not initialized"); + } + + const cacheKey = `storages:${node}:${contentType ?? "all"}`; + const cached = this.cache.get(cacheKey); + if (cached) { + return cached as { storage: string; type: string; content: string; active: number; total?: number; used?: number; avail?: number }[]; + } + + try { + const query = contentType ? `?content=${contentType}` : ""; + const result = await this.client.get( + `/api2/json/nodes/${node}/storage${query}` + ); + if (!Array.isArray(result)) { + throw new Error("Unexpected response format from Proxmox API"); + } + + const storages = result.map((item: Record) => ({ + storage: item.storage as string, + type: item.type as string, + content: item.content as string, + active: item.active as number, + total: item.total as number | undefined, + used: item.used as number | undefined, + avail: item.avail as number | undefined, + })); + + this.cache.set(cacheKey, storages, 120000); + return storages; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + this.logger.error("Failed to fetch storages", { + component: "ProxmoxService", + operation: "getStorages", + metadata: { node, contentType, error: errorMessage }, + }, error instanceof Error ? error : undefined); + throw error; + } + } + + /** + * Get available network bridges/interfaces on a node + * + * Queries the Proxmox API for network devices on the given node, + * filtered to bridges only by default. + * Results are cached for 120 seconds. + * + * @param node - PVE node name + * @param type - Optional type filter (defaults to 'bridge') + * @returns Array of network interface objects + */ + async getNetworkBridges(node: string, type = "bridge"): Promise<{ iface: string; type: string; active: number; address?: string; cidr?: string; bridge_ports?: string }[]> { + if (!this.client) { + throw new Error("ProxmoxClient not initialized"); + } + + const cacheKey = `networks:${node}:${type}`; + const cached = this.cache.get(cacheKey); + if (cached) { + return cached as { iface: string; type: string; active: number; address?: string; cidr?: string; bridge_ports?: string }[]; + } + + try { + const query = type ? `?type=${type}` : ""; + const result = await this.client.get( + `/api2/json/nodes/${node}/network${query}` + ); + if (!Array.isArray(result)) { + throw new Error("Unexpected response format from Proxmox API"); + } + + const networks = result.map((item: Record) => ({ + iface: item.iface as string, + type: item.type as string, + active: item.active as number, + address: item.address as string | undefined, + cidr: item.cidr as string | undefined, + bridge_ports: item.bridge_ports as string | undefined, + })); + + this.cache.set(cacheKey, networks, 120000); + return networks; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + this.logger.error("Failed to fetch network bridges", { + component: "ProxmoxService", + operation: "getNetworkBridges", + metadata: { node, type, error: errorMessage }, + }, error instanceof Error ? error : undefined); + throw error; + } + } + + /** + * Clear all cached data + * + * Useful for forcing fresh data retrieval or after provisioning operations. + */ + clearCache(): void { + this.cache.clear(); + this.logger.debug("Cache cleared", { + component: "ProxmoxService", + operation: "clearCache", + }); + } + + /** + * Execute an action on a guest + * + * Routes actions to appropriate handlers based on action type. + * Supports lifecycle actions (start, stop, shutdown, reboot, suspend, resume) + * and provisioning actions (create_vm, create_lxc, destroy_vm, destroy_lxc). + * + * @param action - Action to execute + * @returns ExecutionResult with success/error details + * @throws Error if client is not initialized or action is invalid + */ + async executeAction(action: Action): Promise { + if (!this.client) { + throw new Error("ProxmoxClient not initialized"); + } + + this.logger.info("Executing action", { + component: "ProxmoxService", + operation: "executeAction", + metadata: { action: action.action, target: action.target }, + }); + + const complete = this.performanceMonitor.startTimer("proxmox:executeAction"); + + try { + let result: ExecutionResult; + + // Check if this is a provisioning action + const provisioningActions = ["create_vm", "create_lxc", "destroy_vm", "destroy_lxc"]; + if (provisioningActions.includes(action.action)) { + result = await this.executeProvisioningAction(action.action, action.parameters); + } else { + // Handle lifecycle actions + result = await this.executeLifecycleAction( + action.target as string, + action.action + ); + } + + complete({ success: result.status === "success" }); + return result; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + + this.logger.error( + "Failed to execute action", + { + component: "ProxmoxService", + operation: "executeAction", + metadata: { action: action.action, target: action.target, error: errorMessage }, + }, + error instanceof Error ? error : undefined + ); + + complete({ error: errorMessage }); + throw error; + } + } + + /** + * Execute a lifecycle action on a guest + * + * Handles start, stop, shutdown, reboot, suspend, and resume actions. + * Parses the target nodeId to extract node and VMID, determines guest type, + * calls the appropriate Proxmox API endpoint, and waits for task completion. + * + * @param target - Target node ID in format proxmox:{node}:{vmid} + * @param action - Action name (start, stop, shutdown, reboot, suspend, resume) + * @returns ExecutionResult with success/error details + * @private + */ + private async executeLifecycleAction( + target: string, + action: string + ): Promise { + const startedAt = new Date().toISOString(); + + try { + // Parse target nodeId to extract node and VMID + const vmid = this.parseVMID(target); + const node = this.parseNodeName(target); + + this.logger.debug("Executing lifecycle action", { + component: "ProxmoxService", + operation: "executeLifecycleAction", + metadata: { node, vmid, action }, + }); + + // Determine guest type (qemu or lxc) + const guestType = await this.getGuestType(node, vmid); + + // Map action to API endpoint + let endpoint: string; + switch (action) { + case "start": + endpoint = guestType === "lxc" + ? `/api2/json/nodes/${node}/lxc/${vmid}/status/start` + : `/api2/json/nodes/${node}/qemu/${vmid}/status/start`; + break; + case "stop": + endpoint = guestType === "lxc" + ? `/api2/json/nodes/${node}/lxc/${vmid}/status/stop` + : `/api2/json/nodes/${node}/qemu/${vmid}/status/stop`; + break; + case "shutdown": + endpoint = guestType === "lxc" + ? `/api2/json/nodes/${node}/lxc/${vmid}/status/shutdown` + : `/api2/json/nodes/${node}/qemu/${vmid}/status/shutdown`; + break; + case "reboot": + endpoint = guestType === "lxc" + ? `/api2/json/nodes/${node}/lxc/${vmid}/status/reboot` + : `/api2/json/nodes/${node}/qemu/${vmid}/status/reboot`; + break; + case "suspend": + if (guestType === "lxc") { + throw new Error("Suspend action is not supported for LXC containers"); + } + endpoint = `/api2/json/nodes/${node}/qemu/${vmid}/status/suspend`; + break; + case "resume": + if (guestType === "lxc") { + throw new Error("Resume action is not supported for LXC containers"); + } + endpoint = `/api2/json/nodes/${node}/qemu/${vmid}/status/resume`; + break; + case "snapshot": + // Snapshot requires special handling with a name parameter + const snapshotName = `snapshot-${Date.now()}`; + endpoint = guestType === "lxc" + ? `/api2/json/nodes/${node}/lxc/${vmid}/snapshot` + : `/api2/json/nodes/${node}/qemu/${vmid}/snapshot`; + + // For snapshot, we need to POST with a snapname parameter + const taskId = await this.client!.post(endpoint, { snapname: snapshotName }); + + this.logger.debug("Snapshot task started", { + component: "ProxmoxService", + operation: "executeLifecycleAction", + metadata: { node, vmid, action, taskId, snapshotName }, + }); + + // Wait for task completion + await this.client!.waitForTask(node, taskId); + + const completedAt = new Date().toISOString(); + + this.logger.info("Snapshot created successfully", { + component: "ProxmoxService", + operation: "executeLifecycleAction", + metadata: { node, vmid, snapshotName }, + }); + + // Return ExecutionResult + return { + id: taskId, + type: "task", + targetNodes: [target], + action, + status: "success", + startedAt, + completedAt, + results: [ + { + nodeId: target, + status: "success", + output: { + stdout: `Snapshot ${snapshotName} created successfully`, + }, + duration: new Date(completedAt).getTime() - new Date(startedAt).getTime(), + }, + ], + }; + default: + throw new Error(`Unsupported action: ${action}`); + } + + // Execute the action + const taskId = await this.client!.post(endpoint, {}); + + this.logger.debug("Action task started", { + component: "ProxmoxService", + operation: "executeLifecycleAction", + metadata: { node, vmid, action, taskId }, + }); + + // Wait for task completion + await this.client!.waitForTask(node, taskId); + + const completedAt = new Date().toISOString(); + + this.logger.info("Lifecycle action completed successfully", { + component: "ProxmoxService", + operation: "executeLifecycleAction", + metadata: { node, vmid, action }, + }); + + // Return ExecutionResult + return { + id: taskId, + type: "task", + targetNodes: [target], + action, + status: "success", + startedAt, + completedAt, + results: [ + { + nodeId: target, + status: "success", + output: { + stdout: `Action ${action} completed successfully`, + }, + duration: new Date(completedAt).getTime() - new Date(startedAt).getTime(), + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + + this.logger.error( + "Lifecycle action failed", + { + component: "ProxmoxService", + operation: "executeLifecycleAction", + metadata: { target, action, error: errorMessage }, + }, + error instanceof Error ? error : undefined + ); + + // Return ExecutionResult with error + return { + id: `error-${Date.now()}`, + type: "task", + targetNodes: [target], + action, + status: "failed", + startedAt, + completedAt: new Date().toISOString(), + results: [ + { + nodeId: target, + status: "failed", + error: errorMessage, + duration: 0, + }, + ], + error: errorMessage, + }; + } + } + + /** + * List capabilities supported by this integration + * + * Returns all lifecycle actions that can be performed on VMs and containers. + * + * @returns Array of Capability objects + */ + listCapabilities(): Capability[] { + return [ + { + name: "start", + description: "Start a VM or container", + parameters: [], + }, + { + name: "stop", + description: "Force stop a VM or container", + parameters: [], + }, + { + name: "shutdown", + description: "Gracefully shutdown a VM or container", + parameters: [], + }, + { + name: "reboot", + description: "Reboot a VM or container", + parameters: [], + }, + { + name: "suspend", + description: "Suspend a VM (not supported for LXC containers)", + parameters: [], + }, + { + name: "resume", + description: "Resume a suspended VM (not supported for LXC containers)", + parameters: [], + }, + { + name: "snapshot", + description: "Create a snapshot of the VM or container", + parameters: [], + }, + ]; + } + + /** + * Check if a guest exists on a node + * + * Queries the Proxmox API to determine if a guest with the given VMID exists. + * + * @param node - Node name + * @param vmid - VM/Container ID + * @returns True if guest exists, false otherwise + * @private + */ + private async guestExists(node: string, vmid: number): Promise { + try { + // Try to get guest status - if it exists, this will succeed + await this.getGuestType(node, vmid); + return true; + } catch (error) { + // If guest doesn't exist, getGuestType will throw + return false; + } + } + + /** + * Create a new VM + * + * Creates a new virtual machine on the specified node with the given parameters. + * Validates VMID uniqueness before creation, waits for task completion, + * and clears inventory/groups cache after successful creation. + * + * @param params - VM creation parameters + * @returns ExecutionResult with success/error details + * @throws Error if client is not initialized + */ + async createVM(params: VMCreateParams): Promise { + if (!this.client) { + throw new Error("ProxmoxClient not initialized"); + } + + this.logger.info("Creating VM", { + component: "ProxmoxService", + operation: "createVM", + metadata: { vmid: params.vmid, node: params.node, name: params.name }, + }); + + const complete = this.performanceMonitor.startTimer("proxmox:createVM"); + const startedAt = new Date().toISOString(); + + try { + // Validate VMID is unique + const exists = await this.guestExists(params.node, params.vmid); + if (exists) { + const errorMessage = `VM with VMID ${params.vmid} already exists on node ${params.node}`; + this.logger.warn(errorMessage, { + component: "ProxmoxService", + operation: "createVM", + metadata: { vmid: params.vmid, node: params.node }, + }); + + complete({ error: errorMessage }); + return { + id: `error-${Date.now()}`, + type: "task", + targetNodes: [`proxmox:${params.node}:${params.vmid}`], + action: "create_vm", + status: "failed", + startedAt, + completedAt: new Date().toISOString(), + error: errorMessage, + results: [], + }; + } + + // Call Proxmox API to create VM + const endpoint = `/api2/json/nodes/${params.node}/qemu`; + // Strip 'node' from the payload — it's already in the URL path + // and Proxmox rejects unknown parameters + const { node: _node, ...apiPayload } = params; + const taskId = await this.client.post(endpoint, apiPayload); + + this.logger.debug("VM creation task started", { + component: "ProxmoxService", + operation: "createVM", + metadata: { vmid: params.vmid, node: params.node, taskId }, + }); + + // Wait for task completion + await this.client.waitForTask(params.node, taskId); + + const completedAt = new Date().toISOString(); + + // Clear inventory and groups cache + this.cache.delete("inventory:all"); + this.cache.delete("groups:all"); + + this.logger.info("VM created successfully", { + component: "ProxmoxService", + operation: "createVM", + metadata: { vmid: params.vmid, node: params.node }, + }); + + complete({ success: true, vmid: params.vmid }); + + return { + id: taskId, + type: "task", + targetNodes: [`proxmox:${params.node}:${params.vmid}`], + action: "create_vm", + status: "success", + startedAt, + completedAt, + results: [ + { + nodeId: `proxmox:${params.node}:${params.vmid}`, + status: "success", + output: { + stdout: `VM ${params.vmid} created successfully`, + }, + duration: new Date(completedAt).getTime() - new Date(startedAt).getTime(), + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + + this.logger.error( + "Failed to create VM", + { + component: "ProxmoxService", + operation: "createVM", + metadata: { vmid: params.vmid, node: params.node, error: errorMessage }, + }, + error instanceof Error ? error : undefined + ); + + complete({ error: errorMessage }); + + return { + id: `error-${Date.now()}`, + type: "task", + targetNodes: [`proxmox:${params.node}:${params.vmid}`], + action: "create_vm", + status: "failed", + startedAt, + completedAt: new Date().toISOString(), + error: errorMessage, + results: [], + }; + } + } + + /** + * Create a new LXC container + * + * Creates a new LXC container on the specified node with the given parameters. + * Validates VMID uniqueness before creation, waits for task completion, + * and clears inventory/groups cache after successful creation. + * + * @param params - LXC creation parameters + * @returns ExecutionResult with success/error details + * @throws Error if client is not initialized + */ + async createLXC(params: LXCCreateParams): Promise { + if (!this.client) { + throw new Error("ProxmoxClient not initialized"); + } + + this.logger.info("Creating LXC container", { + component: "ProxmoxService", + operation: "createLXC", + metadata: { vmid: params.vmid, node: params.node, hostname: params.hostname }, + }); + + const complete = this.performanceMonitor.startTimer("proxmox:createLXC"); + const startedAt = new Date().toISOString(); + + try { + // Validate VMID is unique + const exists = await this.guestExists(params.node, params.vmid); + if (exists) { + const errorMessage = `Container with VMID ${params.vmid} already exists on node ${params.node}`; + this.logger.warn(errorMessage, { + component: "ProxmoxService", + operation: "createLXC", + metadata: { vmid: params.vmid, node: params.node }, + }); + + complete({ error: errorMessage }); + return { + id: `error-${Date.now()}`, + type: "task", + targetNodes: [`proxmox:${params.node}:${params.vmid}`], + action: "create_lxc", + status: "failed", + startedAt, + completedAt: new Date().toISOString(), + error: errorMessage, + results: [], + }; + } + + // Call Proxmox API to create LXC + const endpoint = `/api2/json/nodes/${params.node}/lxc`; + // Strip 'node' from the payload — it's already in the URL path + // and Proxmox rejects unknown parameters + const { node: _node, ...apiPayload } = params; + const taskId = await this.client.post(endpoint, apiPayload); + + this.logger.debug("LXC creation task started", { + component: "ProxmoxService", + operation: "createLXC", + metadata: { vmid: params.vmid, node: params.node, taskId }, + }); + + // Wait for task completion + await this.client.waitForTask(params.node, taskId); + + const completedAt = new Date().toISOString(); + + // Clear inventory and groups cache + this.cache.delete("inventory:all"); + this.cache.delete("groups:all"); + + this.logger.info("LXC container created successfully", { + component: "ProxmoxService", + operation: "createLXC", + metadata: { vmid: params.vmid, node: params.node }, + }); + + complete({ success: true, vmid: params.vmid }); + + return { + id: taskId, + type: "task", + targetNodes: [`proxmox:${params.node}:${params.vmid}`], + action: "create_lxc", + status: "success", + startedAt, + completedAt, + results: [ + { + nodeId: `proxmox:${params.node}:${params.vmid}`, + status: "success", + output: { + stdout: `Container ${params.vmid} created successfully`, + }, + duration: new Date(completedAt).getTime() - new Date(startedAt).getTime(), + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + + this.logger.error( + "Failed to create LXC container", + { + component: "ProxmoxService", + operation: "createLXC", + metadata: { vmid: params.vmid, node: params.node, error: errorMessage }, + }, + error instanceof Error ? error : undefined + ); + + complete({ error: errorMessage }); + + return { + id: `error-${Date.now()}`, + type: "task", + targetNodes: [`proxmox:${params.node}:${params.vmid}`], + action: "create_lxc", + status: "failed", + startedAt, + completedAt: new Date().toISOString(), + error: errorMessage, + results: [], + }; + } + } + + /** + * Destroy a guest (VM or LXC container) + * + * Destroys a guest by first stopping it if running, then deleting it. + * Clears all related caches (inventory, groups, facts) after successful deletion. + * + * @param node - Node name + * @param vmid - VM/Container ID + * @returns ExecutionResult with success/error details + * @throws Error if client is not initialized + */ + async destroyGuest(node: string, vmid: number): Promise { + if (!this.client) { + throw new Error("ProxmoxClient not initialized"); + } + + this.logger.info("Destroying guest", { + component: "ProxmoxService", + operation: "destroyGuest", + metadata: { node, vmid }, + }); + + const complete = this.performanceMonitor.startTimer("proxmox:destroyGuest"); + const startedAt = new Date().toISOString(); + const nodeId = `proxmox:${node}:${vmid}`; + + try { + // Check if guest exists + const exists = await this.guestExists(node, vmid); + if (!exists) { + const errorMessage = `Guest ${vmid} not found on node ${node}`; + this.logger.warn(errorMessage, { + component: "ProxmoxService", + operation: "destroyGuest", + metadata: { node, vmid }, + }); + + complete({ error: errorMessage }); + return { + id: `error-${Date.now()}`, + type: "task", + targetNodes: [nodeId], + action: "destroy_guest", + status: "failed", + startedAt, + completedAt: new Date().toISOString(), + error: errorMessage, + results: [], + }; + } + + // Determine guest type + const guestType = await this.getGuestType(node, vmid); + + // Check if guest is running and stop it first + const statusEndpoint = guestType === "lxc" + ? `/api2/json/nodes/${node}/lxc/${vmid}/status/current` + : `/api2/json/nodes/${node}/qemu/${vmid}/status/current`; + + const status = await this.client.get(statusEndpoint) as ProxmoxGuestStatus; + + if (status.status === "running") { + this.logger.debug("Stopping guest before deletion", { + component: "ProxmoxService", + operation: "destroyGuest", + metadata: { node, vmid, guestType }, + }); + + const stopEndpoint = guestType === "lxc" + ? `/api2/json/nodes/${node}/lxc/${vmid}/status/stop` + : `/api2/json/nodes/${node}/qemu/${vmid}/status/stop`; + + const stopTaskId = await this.client.post(stopEndpoint, {}); + await this.client.waitForTask(node, stopTaskId); + + this.logger.debug("Guest stopped successfully", { + component: "ProxmoxService", + operation: "destroyGuest", + metadata: { node, vmid }, + }); + } + + // Delete guest + const deleteEndpoint = guestType === "lxc" + ? `/api2/json/nodes/${node}/lxc/${vmid}` + : `/api2/json/nodes/${node}/qemu/${vmid}`; + + const deleteTaskId = await this.client.delete(deleteEndpoint); + await this.client.waitForTask(node, deleteTaskId); + + const completedAt = new Date().toISOString(); + + // Clear all related caches + this.cache.delete("inventory:all"); + this.cache.delete("groups:all"); + this.cache.delete(`facts:${nodeId}`); + + this.logger.info("Guest destroyed successfully", { + component: "ProxmoxService", + operation: "destroyGuest", + metadata: { node, vmid }, + }); + + complete({ success: true }); + + return { + id: deleteTaskId, + type: "task", + targetNodes: [nodeId], + action: "destroy_guest", + status: "success", + startedAt, + completedAt, + results: [ + { + nodeId, + status: "success", + output: { + stdout: `Guest ${vmid} destroyed successfully`, + }, + duration: new Date(completedAt).getTime() - new Date(startedAt).getTime(), + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + + this.logger.error( + "Failed to destroy guest", + { + component: "ProxmoxService", + operation: "destroyGuest", + metadata: { node, vmid, error: errorMessage }, + }, + error instanceof Error ? error : undefined + ); + + complete({ error: errorMessage }); + + return { + id: `error-${Date.now()}`, + type: "task", + targetNodes: [nodeId], + action: "destroy_guest", + status: "failed", + startedAt, + completedAt: new Date().toISOString(), + error: errorMessage, + results: [], + }; + } + } + + /** + * Execute a provisioning action + * + * Routes provisioning actions (create_vm, create_lxc, destroy_vm, destroy_lxc) + * to the appropriate handler methods. + * + * @param action - Action name + * @param params - Action parameters + * @returns ExecutionResult with success/error details + * @private + */ + private async executeProvisioningAction( + action: string, + params: unknown + ): Promise { + switch (action) { + case "create_vm": + return await this.createVM(params as VMCreateParams); + case "create_lxc": + return await this.createLXC(params as LXCCreateParams); + case "destroy_vm": + case "destroy_lxc": { + const destroyParams = params as { node: string; vmid: number }; + if (!destroyParams.node || !destroyParams.vmid) { + throw new Error("destroy action requires node and vmid parameters"); + } + return await this.destroyGuest(destroyParams.node, destroyParams.vmid); + } + default: + throw new Error(`Unsupported provisioning action: ${action}`); + } + } + + /** + * List provisioning capabilities + * + * Returns all provisioning capabilities supported by this integration, + * including VM and LXC creation and destruction. + * + * @returns Array of ProvisioningCapability objects + */ + listProvisioningCapabilities(): ProvisioningCapability[] { + return [ + { + name: "create_vm", + description: "Create a new virtual machine", + operation: "create", + parameters: [ + { name: "vmid", type: "number", required: true }, + { name: "name", type: "string", required: true }, + { name: "node", type: "string", required: true }, + { name: "cores", type: "number", required: false, default: 1 }, + { name: "memory", type: "number", required: false, default: 512 }, + { name: "disk", type: "string", required: false }, + { name: "network", type: "object", required: false }, + ], + }, + { + name: "create_lxc", + description: "Create a new LXC container", + operation: "create", + parameters: [ + { name: "vmid", type: "number", required: true }, + { name: "hostname", type: "string", required: true }, + { name: "node", type: "string", required: true }, + { name: "ostemplate", type: "string", required: true }, + { name: "cores", type: "number", required: false, default: 1 }, + { name: "memory", type: "number", required: false, default: 512 }, + { name: "rootfs", type: "string", required: false }, + { name: "network", type: "object", required: false }, + ], + }, + { + name: "destroy_vm", + description: "Destroy a virtual machine", + operation: "destroy", + parameters: [ + { name: "vmid", type: "number", required: true }, + { name: "node", type: "string", required: true }, + ], + }, + { + name: "destroy_lxc", + description: "Destroy an LXC container", + operation: "destroy", + parameters: [ + { name: "vmid", type: "number", required: true }, + { name: "node", type: "string", required: true }, + ], + }, + ]; + } + + +} diff --git a/backend/src/integrations/proxmox/__tests__/ProxmoxIntegration.test.ts b/backend/src/integrations/proxmox/__tests__/ProxmoxIntegration.test.ts new file mode 100644 index 0000000..5221499 --- /dev/null +++ b/backend/src/integrations/proxmox/__tests__/ProxmoxIntegration.test.ts @@ -0,0 +1,411 @@ +/** + * ProxmoxIntegration Unit Tests + * + * Tests for the ProxmoxIntegration plugin class. + */ + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { ProxmoxIntegration } from "../ProxmoxIntegration"; +import type { IntegrationConfig } from "../../types"; +import type { LoggerService } from "../../../services/LoggerService"; +import type { PerformanceMonitorService } from "../../../services/PerformanceMonitorService"; + +// Mock ProxmoxService +const mockService = { + initialize: vi.fn().mockResolvedValue(undefined), + healthCheck: vi.fn(), + getInventory: vi.fn(), + getGroups: vi.fn(), + getNodeFacts: vi.fn(), + executeAction: vi.fn(), + listCapabilities: vi.fn(), + listProvisioningCapabilities: vi.fn(), +}; + +vi.mock("../ProxmoxService", () => ({ + ProxmoxService: class { + initialize = mockService.initialize; + healthCheck = mockService.healthCheck; + getInventory = mockService.getInventory; + getGroups = mockService.getGroups; + getNodeFacts = mockService.getNodeFacts; + executeAction = mockService.executeAction; + listCapabilities = mockService.listCapabilities; + listProvisioningCapabilities = mockService.listProvisioningCapabilities; + }, +})); + +describe("ProxmoxIntegration", () => { + let plugin: ProxmoxIntegration; + let mockLogger: LoggerService; + let mockPerfMonitor: PerformanceMonitorService; + + beforeEach(() => { + // Reset mocks + vi.clearAllMocks(); + + // Create mock logger + mockLogger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as LoggerService; + + // Create mock performance monitor + const mockComplete = vi.fn(); + mockPerfMonitor = { + startTimer: vi.fn(() => mockComplete), + } as unknown as PerformanceMonitorService; + + // Create plugin instance + plugin = new ProxmoxIntegration(mockLogger, mockPerfMonitor); + }); + + describe("initialization", () => { + it("should initialize with valid configuration", async () => { + const config: IntegrationConfig = { + enabled: true, + name: "proxmox", + type: "both", + config: { + host: "proxmox.example.com", + port: 8006, + username: "root", + password: "password", // pragma: allowlist secret + realm: "pam", + }, + }; + + await plugin.initialize(config); + + expect(plugin.isInitialized()).toBe(true); + expect(mockService.initialize).toHaveBeenCalledOnce(); + }); + + it("should throw error for missing host", async () => { + const config: IntegrationConfig = { + enabled: true, + name: "proxmox", + type: "both", + config: { + port: 8006, + username: "root", + password: "password", // pragma: allowlist secret + realm: "pam", + }, + }; + + await expect(plugin.initialize(config)).rejects.toThrow( + "Proxmox configuration must include a valid host" + ); + }); + + it("should throw error for invalid port", async () => { + const config: IntegrationConfig = { + enabled: true, + name: "proxmox", + type: "both", + config: { + host: "proxmox.example.com", + port: 70000, + username: "root", + password: "password", // pragma: allowlist secret + realm: "pam", + }, + }; + + await expect(plugin.initialize(config)).rejects.toThrow( + "Proxmox port must be between 1 and 65535" + ); + }); + + it("should throw error for missing authentication", async () => { + const config: IntegrationConfig = { + enabled: true, + name: "proxmox", + type: "both", + config: { + host: "proxmox.example.com", + port: 8006, + username: "root", + realm: "pam", + }, + }; + + await expect(plugin.initialize(config)).rejects.toThrow( + "Proxmox configuration must include either token or password authentication" + ); + }); + + it("should throw error for missing realm with password auth", async () => { + const config: IntegrationConfig = { + enabled: true, + name: "proxmox", + type: "both", + config: { + host: "proxmox.example.com", + port: 8006, + username: "root", + password: "password", // pragma: allowlist secret + }, + }; + + await expect(plugin.initialize(config)).rejects.toThrow( + "Proxmox password authentication requires a realm" + ); + }); + + it("should log warning when SSL verification is disabled", async () => { + const config: IntegrationConfig = { + enabled: true, + name: "proxmox", + type: "both", + config: { + host: "proxmox.example.com", + port: 8006, + username: "root", + password: "password", // pragma: allowlist secret + realm: "pam", + ssl: { + rejectUnauthorized: false, + }, + }, + }; + + await plugin.initialize(config); + + expect(mockLogger.warn).toHaveBeenCalledWith( + "TLS certificate verification is disabled - this is insecure", + expect.objectContaining({ + component: "ProxmoxIntegration", + operation: "validateProxmoxConfig", + }) + ); + }); + + it("should accept token authentication", async () => { + const config: IntegrationConfig = { + enabled: true, + name: "proxmox", + type: "both", + config: { + host: "proxmox.example.com", + port: 8006, + token: "user@realm!tokenid=uuid", + }, + }; + + await plugin.initialize(config); + + expect(plugin.isInitialized()).toBe(true); + expect(mockService.initialize).toHaveBeenCalledOnce(); + }); + }); + + describe("health check", () => { + beforeEach(async () => { + const config: IntegrationConfig = { + enabled: true, + name: "proxmox", + type: "both", + config: { + host: "proxmox.example.com", + port: 8006, + token: "user@realm!tokenid=uuid", + }, + }; + await plugin.initialize(config); + }); + + it("should delegate health check to service", async () => { + mockService.healthCheck.mockResolvedValue({ + healthy: true, + message: "Proxmox API is reachable", + }); + + const health = await plugin.healthCheck(); + + expect(health.healthy).toBe(true); + expect(mockService.healthCheck).toHaveBeenCalledOnce(); + }); + }); + + describe("InformationSourcePlugin methods", () => { + beforeEach(async () => { + const config: IntegrationConfig = { + enabled: true, + name: "proxmox", + type: "both", + config: { + host: "proxmox.example.com", + port: 8006, + token: "user@realm!tokenid=uuid", + }, + }; + await plugin.initialize(config); + }); + + it("should delegate getInventory to service", async () => { + const mockNodes = [ + { + id: "proxmox:pve1:100", + name: "test-vm", + uri: "proxmox://pve1/100", + transport: "ssh" as const, + config: {}, + source: "proxmox", + }, + ]; + mockService.getInventory.mockResolvedValue(mockNodes); + + const inventory = await plugin.getInventory(); + + expect(inventory).toEqual(mockNodes); + expect(mockService.getInventory).toHaveBeenCalledOnce(); + }); + + it("should delegate getGroups to service", async () => { + const mockGroups = [ + { + id: "proxmox:node:pve1", + name: "Proxmox Node: pve1", + source: "proxmox", + sources: ["proxmox"], + linked: false, + nodes: ["proxmox:pve1:100"], + }, + ]; + mockService.getGroups.mockResolvedValue(mockGroups); + + const groups = await plugin.getGroups(); + + expect(groups).toEqual(mockGroups); + expect(mockService.getGroups).toHaveBeenCalledOnce(); + }); + + it("should delegate getNodeFacts to service", async () => { + const mockFacts = { + nodeId: "proxmox:pve1:100", + gatheredAt: "2024-01-01T00:00:00Z", + source: "proxmox", + facts: { + os: { family: "linux", name: "ubuntu", release: { full: "22.04", major: "22" } }, + processors: { count: 2, models: [] }, + memory: { system: { total: "2 GB", available: "1 GB" } }, + networking: { hostname: "test-vm", interfaces: {} }, + }, + }; + mockService.getNodeFacts.mockResolvedValue(mockFacts); + + const facts = await plugin.getNodeFacts("proxmox:pve1:100"); + + expect(facts).toEqual(mockFacts); + expect(mockService.getNodeFacts).toHaveBeenCalledWith("proxmox:pve1:100"); + }); + + it("should return null for getNodeData", async () => { + const data = await plugin.getNodeData("proxmox:pve1:100", "reports"); + + expect(data).toBeNull(); + }); + }); + + describe("ExecutionToolPlugin methods", () => { + beforeEach(async () => { + const config: IntegrationConfig = { + enabled: true, + name: "proxmox", + type: "both", + config: { + host: "proxmox.example.com", + port: 8006, + token: "user@realm!tokenid=uuid", + }, + }; + await plugin.initialize(config); + }); + + it("should delegate executeAction to service", async () => { + const mockResult = { + id: "task-123", + type: "task" as const, + targetNodes: ["proxmox:pve1:100"], + action: "start", + status: "success" as const, + startedAt: "2024-01-01T00:00:00Z", + completedAt: "2024-01-01T00:00:05Z", + results: [], + }; + mockService.executeAction.mockResolvedValue(mockResult); + + const action = { + type: "task" as const, + target: "proxmox:pve1:100", + action: "start", + }; + + const result = await plugin.executeAction(action); + + expect(result).toEqual(mockResult); + expect(mockService.executeAction).toHaveBeenCalledWith(action); + }); + + it("should delegate listCapabilities to service", () => { + const mockCapabilities = [ + { name: "start", description: "Start a VM or container", parameters: [] }, + { name: "stop", description: "Force stop a VM or container", parameters: [] }, + ]; + mockService.listCapabilities.mockReturnValue(mockCapabilities); + + const capabilities = plugin.listCapabilities(); + + expect(capabilities).toEqual(mockCapabilities); + expect(mockService.listCapabilities).toHaveBeenCalledOnce(); + }); + + it("should delegate listProvisioningCapabilities to service", () => { + const mockCapabilities = [ + { + name: "create_vm", + description: "Create a new virtual machine", + operation: "create" as const, + parameters: [], + }, + ]; + mockService.listProvisioningCapabilities.mockReturnValue(mockCapabilities); + + const capabilities = plugin.listProvisioningCapabilities(); + + expect(capabilities).toEqual(mockCapabilities); + expect(mockService.listProvisioningCapabilities).toHaveBeenCalledOnce(); + }); + }); + + describe("error handling", () => { + it("should throw error when calling methods before initialization", async () => { + await expect(plugin.getInventory()).rejects.toThrow( + "Proxmox integration is not initialized" + ); + await expect(plugin.getGroups()).rejects.toThrow( + "Proxmox integration is not initialized" + ); + await expect(plugin.getNodeFacts("proxmox:pve1:100")).rejects.toThrow( + "Proxmox integration is not initialized" + ); + await expect( + plugin.executeAction({ + type: "task", + target: "proxmox:pve1:100", + action: "start", + }) + ).rejects.toThrow("Proxmox integration is not initialized"); + expect(() => plugin.listCapabilities()).toThrow( + "Proxmox integration is not initialized" + ); + expect(() => plugin.listProvisioningCapabilities()).toThrow( + "Proxmox integration is not initialized" + ); + }); + }); +}); diff --git a/backend/src/integrations/proxmox/__tests__/ProxmoxService.test.ts b/backend/src/integrations/proxmox/__tests__/ProxmoxService.test.ts new file mode 100644 index 0000000..49848c9 --- /dev/null +++ b/backend/src/integrations/proxmox/__tests__/ProxmoxService.test.ts @@ -0,0 +1,1175 @@ +/** + * ProxmoxService Unit Tests + * + * Tests for the ProxmoxService business logic layer. + */ + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { ProxmoxService } from "../ProxmoxService"; +import type { ProxmoxConfig, ProxmoxGuest } from "../types"; +import type { LoggerService } from "../../../services/LoggerService"; +import type { PerformanceMonitorService } from "../../../services/PerformanceMonitorService"; + +// Create mock client +const mockClient = { + authenticate: vi.fn().mockResolvedValue(undefined), + get: vi.fn(), + post: vi.fn(), + delete: vi.fn(), + waitForTask: vi.fn(), +}; + +// Mock ProxmoxClient module +vi.mock("../ProxmoxClient", () => ({ + ProxmoxClient: class { + authenticate = mockClient.authenticate; + get = mockClient.get; + post = mockClient.post; + delete = mockClient.delete; + waitForTask = mockClient.waitForTask; + }, +})); + +describe("ProxmoxService", () => { + let service: ProxmoxService; + let mockLogger: LoggerService; + let mockPerfMonitor: PerformanceMonitorService; + let mockConfig: ProxmoxConfig; + + beforeEach(() => { + // Reset mocks + vi.clearAllMocks(); + + // Create mock logger + mockLogger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as LoggerService; + + // Create mock performance monitor + const mockComplete = vi.fn(); + mockPerfMonitor = { + startTimer: vi.fn(() => mockComplete), + } as unknown as PerformanceMonitorService; + + // Create mock config + mockConfig = { + host: "proxmox.example.com", + port: 8006, + username: "root", + password: "password", // pragma: allowlist secret + realm: "pam", + }; + + // Create service instance + service = new ProxmoxService(mockConfig, mockLogger, mockPerfMonitor); + }); + + describe("getInventory", () => { + it("should fetch and transform inventory from Proxmox API", async () => { + // Mock API response + const mockGuests: ProxmoxGuest[] = [ + { + vmid: 100, + name: "test-vm-1", + node: "pve1", + type: "qemu", + status: "running", + maxmem: 2147483648, + cpus: 2, + uptime: 3600, + }, + { + vmid: 101, + name: "test-container-1", + node: "pve1", + type: "lxc", + status: "stopped", + maxmem: 536870912, + cpus: 1, + }, + ]; + + mockClient.get.mockResolvedValue(mockGuests); + + // Initialize service + await service.initialize(); + + // Call getInventory + const nodes = await service.getInventory(); + + // Verify API call + expect(mockClient.get).toHaveBeenCalledWith( + "/api2/json/cluster/resources?type=vm" + ); + + // Verify results + expect(nodes).toHaveLength(2); + + // Verify first node (VM) + expect(nodes[0]).toMatchObject({ + id: "proxmox:pve1:100", + name: "test-vm-1", + uri: "proxmox://pve1/100", + transport: "ssh", + source: "proxmox", + status: "running", + }); + expect(nodes[0].metadata).toMatchObject({ + vmid: 100, + node: "pve1", + type: "qemu", + status: "running", + maxmem: 2147483648, + cpus: 2, + uptime: 3600, + }); + + // Verify second node (LXC) + expect(nodes[1]).toMatchObject({ + id: "proxmox:pve1:101", + name: "test-container-1", + uri: "proxmox://pve1/101", + transport: "ssh", + source: "proxmox", + status: "stopped", + }); + expect(nodes[1].metadata).toMatchObject({ + vmid: 101, + node: "pve1", + type: "lxc", + status: "stopped", + maxmem: 536870912, + cpus: 1, + }); + + // Verify performance monitoring + expect(mockPerfMonitor.startTimer).toHaveBeenCalledWith( + "proxmox:getInventory" + ); + }); + + it("should return cached inventory on subsequent calls within TTL", async () => { + // Mock API response + const mockGuests: ProxmoxGuest[] = [ + { + vmid: 100, + name: "test-vm-1", + node: "pve1", + type: "qemu", + status: "running", + }, + ]; + + mockClient.get.mockResolvedValue(mockGuests); + + // Initialize service + await service.initialize(); + + // First call - should hit API + const nodes1 = await service.getInventory(); + expect(mockClient.get).toHaveBeenCalledTimes(1); + expect(nodes1).toHaveLength(1); + + // Second call - should use cache + const nodes2 = await service.getInventory(); + expect(mockClient.get).toHaveBeenCalledTimes(1); // Still 1, not called again + expect(nodes2).toHaveLength(1); + expect(nodes2).toEqual(nodes1); + }); + + it("should throw error if client is not initialized", async () => { + // Don't initialize service + await expect(service.getInventory()).rejects.toThrow( + "ProxmoxClient not initialized" + ); + }); + + it("should throw error if API returns non-array response", async () => { + // Mock invalid API response + mockClient.get.mockResolvedValue({ invalid: "response" }); + + // Initialize service + await service.initialize(); + + // Call should throw + await expect(service.getInventory()).rejects.toThrow( + "Unexpected response format from Proxmox API" + ); + }); + + it("should handle empty inventory", async () => { + // Mock empty API response + mockClient.get.mockResolvedValue([]); + + // Initialize service + await service.initialize(); + + // Call getInventory + const nodes = await service.getInventory(); + + // Verify empty result + expect(nodes).toHaveLength(0); + expect(nodes).toEqual([]); + }); + + it("should omit optional fields when not present", async () => { + // Mock API response with minimal fields + const mockGuests: ProxmoxGuest[] = [ + { + vmid: 100, + name: "minimal-vm", + node: "pve1", + type: "qemu", + status: "stopped", + // No optional fields + }, + ]; + + mockClient.get.mockResolvedValue(mockGuests); + + // Initialize service + await service.initialize(); + + // Call getInventory + const nodes = await service.getInventory(); + + // Verify node has only required fields in metadata + expect(nodes[0].metadata).toMatchObject({ + vmid: 100, + node: "pve1", + type: "qemu", + status: "stopped", + }); + + // Verify optional fields are not present + expect(nodes[0].metadata).not.toHaveProperty("maxmem"); + expect(nodes[0].metadata).not.toHaveProperty("cpus"); + expect(nodes[0].metadata).not.toHaveProperty("uptime"); + }); + }); + + describe("getGroups", () => { + it("should create groups by node, status, and type", async () => { + // Mock API response with diverse guests + const mockGuests: ProxmoxGuest[] = [ + { + vmid: 100, + name: "vm-1", + node: "pve1", + type: "qemu", + status: "running", + }, + { + vmid: 101, + name: "vm-2", + node: "pve1", + type: "qemu", + status: "stopped", + }, + { + vmid: 200, + name: "container-1", + node: "pve2", + type: "lxc", + status: "running", + }, + { + vmid: 201, + name: "container-2", + node: "pve2", + type: "lxc", + status: "paused", + }, + ]; + + mockClient.get.mockResolvedValue(mockGuests); + + // Initialize service + await service.initialize(); + + // Call getGroups + const groups = await service.getGroups(); + + // Verify we have groups for nodes, statuses, and types + // 2 nodes (pve1, pve2) + 3 statuses (running, stopped, paused) + 2 types (qemu, lxc) = 7 groups + expect(groups).toHaveLength(7); + + // Verify node groups + const pve1Group = groups.find((g) => g.id === "proxmox:node:pve1"); + expect(pve1Group).toBeDefined(); + expect(pve1Group?.name).toBe("Proxmox Node: pve1"); + expect(pve1Group?.nodes).toHaveLength(2); + expect(pve1Group?.nodes).toContain("proxmox:pve1:100"); + expect(pve1Group?.nodes).toContain("proxmox:pve1:101"); + + const pve2Group = groups.find((g) => g.id === "proxmox:node:pve2"); + expect(pve2Group).toBeDefined(); + expect(pve2Group?.name).toBe("Proxmox Node: pve2"); + expect(pve2Group?.nodes).toHaveLength(2); + + // Verify status groups + const runningGroup = groups.find((g) => g.id === "proxmox:status:running"); + expect(runningGroup).toBeDefined(); + expect(runningGroup?.name).toBe("Status: running"); + expect(runningGroup?.nodes).toHaveLength(2); + + const stoppedGroup = groups.find((g) => g.id === "proxmox:status:stopped"); + expect(stoppedGroup).toBeDefined(); + expect(stoppedGroup?.nodes).toHaveLength(1); + + const pausedGroup = groups.find((g) => g.id === "proxmox:status:paused"); + expect(pausedGroup).toBeDefined(); + expect(pausedGroup?.nodes).toHaveLength(1); + + // Verify type groups + const qemuGroup = groups.find((g) => g.id === "proxmox:type:qemu"); + expect(qemuGroup).toBeDefined(); + expect(qemuGroup?.name).toBe("Virtual Machines"); + expect(qemuGroup?.nodes).toHaveLength(2); + + const lxcGroup = groups.find((g) => g.id === "proxmox:type:lxc"); + expect(lxcGroup).toBeDefined(); + expect(lxcGroup?.name).toBe("LXC Containers"); + expect(lxcGroup?.nodes).toHaveLength(2); + + // Verify all groups have correct source + groups.forEach((group) => { + expect(group.source).toBe("proxmox"); + expect(group.sources).toEqual(["proxmox"]); + expect(group.linked).toBe(false); + }); + }); + + it("should return cached groups on subsequent calls within TTL", async () => { + // Mock API response + const mockGuests: ProxmoxGuest[] = [ + { + vmid: 100, + name: "vm-1", + node: "pve1", + type: "qemu", + status: "running", + }, + ]; + + mockClient.get.mockResolvedValue(mockGuests); + + // Initialize service + await service.initialize(); + + // First call - should hit API + const groups1 = await service.getGroups(); + expect(mockClient.get).toHaveBeenCalledTimes(1); + expect(groups1.length).toBeGreaterThan(0); + + // Second call - should use cache + const groups2 = await service.getGroups(); + expect(mockClient.get).toHaveBeenCalledTimes(1); // Still 1, not called again + expect(groups2).toEqual(groups1); + }); + + it("should throw error if client is not initialized", async () => { + // Don't initialize service + await expect(service.getGroups()).rejects.toThrow( + "ProxmoxClient not initialized" + ); + }); + + it("should handle empty inventory gracefully", async () => { + // Mock empty API response + mockClient.get.mockResolvedValue([]); + + // Initialize service + await service.initialize(); + + // Call getGroups + const groups = await service.getGroups(); + + // Should return empty array + expect(groups).toHaveLength(0); + expect(groups).toEqual([]); + }); + + it("should use correct group ID formats", async () => { + // Mock API response + const mockGuests: ProxmoxGuest[] = [ + { + vmid: 100, + name: "test-vm", + node: "testnode", + type: "qemu", + status: "running", + }, + ]; + + mockClient.get.mockResolvedValue(mockGuests); + + // Initialize service + await service.initialize(); + + // Call getGroups + const groups = await service.getGroups(); + + // Verify ID formats + const nodeGroup = groups.find((g) => g.id.startsWith("proxmox:node:")); + expect(nodeGroup?.id).toBe("proxmox:node:testnode"); + + const statusGroup = groups.find((g) => g.id.startsWith("proxmox:status:")); + expect(statusGroup?.id).toBe("proxmox:status:running"); + + const typeGroup = groups.find((g) => g.id.startsWith("proxmox:type:")); + expect(typeGroup?.id).toBe("proxmox:type:qemu"); + }); + }); + + describe("executeAction", () => { + it("should execute start action on a VM", async () => { + // Mock cluster resources response to determine guest type + const mockGuests: ProxmoxGuest[] = [ + { + vmid: 100, + name: "test-vm", + node: "pve1", + type: "qemu", + status: "stopped", + }, + ]; + + mockClient.get.mockResolvedValue(mockGuests); + mockClient.post.mockResolvedValue("UPID:pve1:00001234:task123"); + mockClient.waitForTask.mockResolvedValue(undefined); + + // Initialize service + await service.initialize(); + + // Execute start action + const action = { + type: "task" as const, + target: "proxmox:pve1:100", + action: "start", + }; + + const result = await service.executeAction(action); + + // Verify API calls + expect(mockClient.get).toHaveBeenCalledWith( + "/api2/json/cluster/resources?type=vm" + ); + expect(mockClient.post).toHaveBeenCalledWith( + "/api2/json/nodes/pve1/qemu/100/status/start", + {} + ); + expect(mockClient.waitForTask).toHaveBeenCalledWith( + "pve1", + "UPID:pve1:00001234:task123" + ); + + // Verify result + expect(result.status).toBe("success"); + expect(result.targetNodes).toEqual(["proxmox:pve1:100"]); + expect(result.action).toBe("start"); + expect(result.results).toHaveLength(1); + expect(result.results[0].status).toBe("success"); + }); + + it("should execute stop action on an LXC container", async () => { + // Mock cluster resources response + const mockGuests: ProxmoxGuest[] = [ + { + vmid: 200, + name: "test-container", + node: "pve2", + type: "lxc", + status: "running", + }, + ]; + + mockClient.get.mockResolvedValue(mockGuests); + mockClient.post.mockResolvedValue("UPID:pve2:00005678:task456"); + mockClient.waitForTask.mockResolvedValue(undefined); + + // Initialize service + await service.initialize(); + + // Execute stop action + const action = { + type: "task" as const, + target: "proxmox:pve2:200", + action: "stop", + }; + + const result = await service.executeAction(action); + + // Verify API calls + expect(mockClient.post).toHaveBeenCalledWith( + "/api2/json/nodes/pve2/lxc/200/status/stop", + {} + ); + + // Verify result + expect(result.status).toBe("success"); + expect(result.targetNodes).toEqual(["proxmox:pve2:200"]); + expect(result.action).toBe("stop"); + }); + + it("should execute shutdown action", async () => { + // Mock cluster resources response + const mockGuests: ProxmoxGuest[] = [ + { + vmid: 100, + name: "test-vm", + node: "pve1", + type: "qemu", + status: "running", + }, + ]; + + mockClient.get.mockResolvedValue(mockGuests); + mockClient.post.mockResolvedValue("UPID:pve1:00001234:task123"); + mockClient.waitForTask.mockResolvedValue(undefined); + + // Initialize service + await service.initialize(); + + // Execute shutdown action + const action = { + type: "task" as const, + target: "proxmox:pve1:100", + action: "shutdown", + }; + + const result = await service.executeAction(action); + + // Verify API call + expect(mockClient.post).toHaveBeenCalledWith( + "/api2/json/nodes/pve1/qemu/100/status/shutdown", + {} + ); + + // Verify result + expect(result.status).toBe("success"); + }); + + it("should execute reboot action", async () => { + // Mock cluster resources response + const mockGuests: ProxmoxGuest[] = [ + { + vmid: 100, + name: "test-vm", + node: "pve1", + type: "qemu", + status: "running", + }, + ]; + + mockClient.get.mockResolvedValue(mockGuests); + mockClient.post.mockResolvedValue("UPID:pve1:00001234:task123"); + mockClient.waitForTask.mockResolvedValue(undefined); + + // Initialize service + await service.initialize(); + + // Execute reboot action + const action = { + type: "task" as const, + target: "proxmox:pve1:100", + action: "reboot", + }; + + const result = await service.executeAction(action); + + // Verify API call + expect(mockClient.post).toHaveBeenCalledWith( + "/api2/json/nodes/pve1/qemu/100/status/reboot", + {} + ); + + // Verify result + expect(result.status).toBe("success"); + }); + + it("should execute suspend action on a VM", async () => { + // Mock cluster resources response + const mockGuests: ProxmoxGuest[] = [ + { + vmid: 100, + name: "test-vm", + node: "pve1", + type: "qemu", + status: "running", + }, + ]; + + mockClient.get.mockResolvedValue(mockGuests); + mockClient.post.mockResolvedValue("UPID:pve1:00001234:task123"); + mockClient.waitForTask.mockResolvedValue(undefined); + + // Initialize service + await service.initialize(); + + // Execute suspend action + const action = { + type: "task" as const, + target: "proxmox:pve1:100", + action: "suspend", + }; + + const result = await service.executeAction(action); + + // Verify API call + expect(mockClient.post).toHaveBeenCalledWith( + "/api2/json/nodes/pve1/qemu/100/status/suspend", + {} + ); + + // Verify result + expect(result.status).toBe("success"); + }); + + it("should execute resume action on a VM", async () => { + // Mock cluster resources response + const mockGuests: ProxmoxGuest[] = [ + { + vmid: 100, + name: "test-vm", + node: "pve1", + type: "qemu", + status: "paused", + }, + ]; + + mockClient.get.mockResolvedValue(mockGuests); + mockClient.post.mockResolvedValue("UPID:pve1:00001234:task123"); + mockClient.waitForTask.mockResolvedValue(undefined); + + // Initialize service + await service.initialize(); + + // Execute resume action + const action = { + type: "task" as const, + target: "proxmox:pve1:100", + action: "resume", + }; + + const result = await service.executeAction(action); + + // Verify API call + expect(mockClient.post).toHaveBeenCalledWith( + "/api2/json/nodes/pve1/qemu/100/status/resume", + {} + ); + + // Verify result + expect(result.status).toBe("success"); + }); + + it("should reject suspend action on LXC container", async () => { + // Mock cluster resources response + const mockGuests: ProxmoxGuest[] = [ + { + vmid: 200, + name: "test-container", + node: "pve2", + type: "lxc", + status: "running", + }, + ]; + + mockClient.get.mockResolvedValue(mockGuests); + + // Initialize service + await service.initialize(); + + // Execute suspend action on LXC + const action = { + type: "task" as const, + target: "proxmox:pve2:200", + action: "suspend", + }; + + const result = await service.executeAction(action); + + // Verify result is failure + expect(result.status).toBe("failed"); + expect(result.error).toContain("not supported for LXC containers"); + expect(result.results[0].status).toBe("failed"); + }); + + it("should reject resume action on LXC container", async () => { + // Mock cluster resources response + const mockGuests: ProxmoxGuest[] = [ + { + vmid: 200, + name: "test-container", + node: "pve2", + type: "lxc", + status: "paused", + }, + ]; + + mockClient.get.mockResolvedValue(mockGuests); + + // Initialize service + await service.initialize(); + + // Execute resume action on LXC + const action = { + type: "task" as const, + target: "proxmox:pve2:200", + action: "resume", + }; + + const result = await service.executeAction(action); + + // Verify result is failure + expect(result.status).toBe("failed"); + expect(result.error).toContain("not supported for LXC containers"); + }); + + it("should handle action failure with error details", async () => { + // Mock cluster resources response + const mockGuests: ProxmoxGuest[] = [ + { + vmid: 100, + name: "test-vm", + node: "pve1", + type: "qemu", + status: "stopped", + }, + ]; + + mockClient.get.mockResolvedValue(mockGuests); + mockClient.post.mockRejectedValue(new Error("API error: VM is locked")); + + // Initialize service + await service.initialize(); + + // Execute start action + const action = { + type: "task" as const, + target: "proxmox:pve1:100", + action: "start", + }; + + const result = await service.executeAction(action); + + // Verify result contains error + expect(result.status).toBe("failed"); + expect(result.error).toContain("API error: VM is locked"); + expect(result.results[0].status).toBe("failed"); + expect(result.results[0].error).toContain("API error: VM is locked"); + }); + + it("should reject unsupported action", async () => { + // Mock cluster resources response + const mockGuests: ProxmoxGuest[] = [ + { + vmid: 100, + name: "test-vm", + node: "pve1", + type: "qemu", + status: "running", + }, + ]; + + mockClient.get.mockResolvedValue(mockGuests); + + // Initialize service + await service.initialize(); + + // Execute unsupported action + const action = { + type: "task" as const, + target: "proxmox:pve1:100", + action: "invalid-action", + }; + + const result = await service.executeAction(action); + + // Verify result is failure + expect(result.status).toBe("failed"); + expect(result.error).toContain("Unsupported action"); + }); + + it("should throw error if client is not initialized", async () => { + // Don't initialize service + const action = { + type: "task" as const, + target: "proxmox:pve1:100", + action: "start", + }; + + await expect(service.executeAction(action)).rejects.toThrow( + "ProxmoxClient not initialized" + ); + }); + + it("should handle invalid nodeId format", async () => { + // Initialize service + await service.initialize(); + + // Execute action with invalid nodeId + const action = { + type: "task" as const, + target: "invalid-node-id", + action: "start", + }; + + const result = await service.executeAction(action); + + // Verify result is failure + expect(result.status).toBe("failed"); + expect(result.error).toContain("Invalid nodeId format"); + }); +}); + +describe("listCapabilities", () => { + it("should return all lifecycle action capabilities", () => { + const capabilities = service.listCapabilities(); + + // Verify we have all 6 capabilities + expect(capabilities).toHaveLength(6); + + // Verify each capability has required fields + capabilities.forEach((cap) => { + expect(cap).toHaveProperty("name"); + expect(cap).toHaveProperty("description"); + expect(cap).toHaveProperty("parameters"); + expect(Array.isArray(cap.parameters)).toBe(true); + }); + + // Verify specific capabilities + const capabilityNames = capabilities.map((c) => c.name); + expect(capabilityNames).toContain("start"); + expect(capabilityNames).toContain("stop"); + expect(capabilityNames).toContain("shutdown"); + expect(capabilityNames).toContain("reboot"); + expect(capabilityNames).toContain("suspend"); + expect(capabilityNames).toContain("resume"); + + // Verify start capability details + const startCap = capabilities.find((c) => c.name === "start"); + expect(startCap?.description).toBe("Start a VM or container"); + expect(startCap?.parameters).toEqual([]); + + // Verify suspend capability mentions VM-only + const suspendCap = capabilities.find((c) => c.name === "suspend"); + expect(suspendCap?.description).toContain("VM"); + expect(suspendCap?.description).toContain("not supported for LXC"); + }); + + it("should return capabilities without requiring initialization", () => { + // Don't initialize service + const capabilities = service.listCapabilities(); + + // Should still return capabilities + expect(capabilities).toHaveLength(6); + }); +}); + +describe("Provisioning Capabilities", () => { + describe("createVM", () => { + it("should create a VM successfully", async () => { + // Mock guest existence check (should not exist) + mockClient.get.mockRejectedValueOnce(new Error("Guest not found")); + + // Mock VM creation + mockClient.post.mockResolvedValueOnce("UPID:pve1:00001234:task"); + mockClient.waitForTask.mockResolvedValueOnce(undefined); + + // Initialize service + await service.initialize(); + + // Create VM + const params = { + vmid: 100, + name: "test-vm", + node: "pve1", + cores: 2, + memory: 2048, + }; + + const result = await service.createVM(params); + + // Verify result + expect(result.status).toBe("success"); + expect(result.action).toBe("create_vm"); + expect(mockClient.post).toHaveBeenCalledWith( + "/api2/json/nodes/pve1/qemu", + params + ); + expect(mockClient.waitForTask).toHaveBeenCalledWith("pve1", "UPID:pve1:00001234:task"); + }); + + it("should reject creation if VMID already exists", async () => { + // Mock guest existence check (exists) - getGuestType queries cluster resources + mockClient.get.mockResolvedValueOnce([ + { vmid: 100, node: "pve1", type: "qemu" } + ]); + + // Initialize service + await service.initialize(); + + // Try to create VM with existing VMID + const params = { + vmid: 100, + name: "test-vm", + node: "pve1", + }; + + const result = await service.createVM(params); + + // Verify result is failure + expect(result.status).toBe("failed"); + expect(result.error).toContain("already exists"); + expect(mockClient.post).not.toHaveBeenCalled(); + }); + }); + + describe("createLXC", () => { + it("should create an LXC container successfully", async () => { + // Mock guest existence check (should not exist) + mockClient.get.mockRejectedValueOnce(new Error("Guest not found")); + + // Mock LXC creation + mockClient.post.mockResolvedValueOnce("UPID:pve1:00001235:task"); + mockClient.waitForTask.mockResolvedValueOnce(undefined); + + // Initialize service + await service.initialize(); + + // Create LXC + const params = { + vmid: 101, + hostname: "test-container", + node: "pve1", + ostemplate: "local:vztmpl/ubuntu-22.04-standard_22.04-1_amd64.tar.zst", + cores: 1, + memory: 512, + }; + + const result = await service.createLXC(params); + + // Verify result + expect(result.status).toBe("success"); + expect(result.action).toBe("create_lxc"); + expect(mockClient.post).toHaveBeenCalledWith( + "/api2/json/nodes/pve1/lxc", + params + ); + expect(mockClient.waitForTask).toHaveBeenCalledWith("pve1", "UPID:pve1:00001235:task"); + }); + + it("should reject creation if VMID already exists", async () => { + // Mock guest existence check (exists) - getGuestType queries cluster resources + mockClient.get.mockResolvedValueOnce([ + { vmid: 101, node: "pve1", type: "lxc" } + ]); + + // Initialize service + await service.initialize(); + + // Try to create LXC with existing VMID + const params = { + vmid: 101, + hostname: "test-container", + node: "pve1", + ostemplate: "local:vztmpl/ubuntu-22.04-standard_22.04-1_amd64.tar.zst", + }; + + const result = await service.createLXC(params); + + // Verify result is failure + expect(result.status).toBe("failed"); + expect(result.error).toContain("already exists"); + expect(mockClient.post).not.toHaveBeenCalled(); + }); + }); + + describe("destroyGuest", () => { + it("should destroy a running guest after stopping it", async () => { + // Mock guest existence check - getGuestType queries cluster resources (called twice) + mockClient.get + .mockResolvedValueOnce([{ vmid: 100, node: "pve1", type: "qemu" }]) // guestExists -> getGuestType + .mockResolvedValueOnce([{ vmid: 100, node: "pve1", type: "qemu" }]) // getGuestType again + .mockResolvedValueOnce({ status: "running" }); // status check + + // Mock stop and delete operations + mockClient.post.mockResolvedValueOnce("UPID:pve1:00001236:stop"); + mockClient.waitForTask.mockResolvedValueOnce(undefined); + mockClient.delete.mockResolvedValueOnce("UPID:pve1:00001237:delete"); + mockClient.waitForTask.mockResolvedValueOnce(undefined); + + // Initialize service + await service.initialize(); + + // Destroy guest + const result = await service.destroyGuest("pve1", 100); + + // Verify result + expect(result.status).toBe("success"); + expect(result.action).toBe("destroy_guest"); + expect(mockClient.post).toHaveBeenCalledWith( + "/api2/json/nodes/pve1/qemu/100/status/stop", + {} + ); + expect(mockClient.delete).toHaveBeenCalledWith( + "/api2/json/nodes/pve1/qemu/100" + ); + }); + + it("should destroy a stopped guest without stopping it first", async () => { + // Mock guest existence check - getGuestType queries cluster resources (called twice) + mockClient.get + .mockResolvedValueOnce([{ vmid: 100, node: "pve1", type: "qemu" }]) // guestExists -> getGuestType + .mockResolvedValueOnce([{ vmid: 100, node: "pve1", type: "qemu" }]) // getGuestType again + .mockResolvedValueOnce({ status: "stopped" }); // status check + + // Mock delete operation + mockClient.delete.mockResolvedValueOnce("UPID:pve1:00001238:delete"); + mockClient.waitForTask.mockResolvedValueOnce(undefined); + + // Initialize service + await service.initialize(); + + // Destroy guest + const result = await service.destroyGuest("pve1", 100); + + // Verify result + expect(result.status).toBe("success"); + expect(mockClient.post).not.toHaveBeenCalled(); // Should not stop + expect(mockClient.delete).toHaveBeenCalledWith( + "/api2/json/nodes/pve1/qemu/100" + ); + }); + + it("should return error if guest does not exist", async () => { + // Mock guest existence check (does not exist) + mockClient.get.mockRejectedValueOnce(new Error("Guest not found")); + + // Initialize service + await service.initialize(); + + // Try to destroy non-existent guest + const result = await service.destroyGuest("pve1", 999); + + // Verify result is failure + expect(result.status).toBe("failed"); + expect(result.error).toContain("not found"); + expect(mockClient.delete).not.toHaveBeenCalled(); + }); + }); + + describe("listProvisioningCapabilities", () => { + it("should return all provisioning capabilities", () => { + const capabilities = service.listProvisioningCapabilities(); + + // Verify we have all 4 provisioning capabilities + expect(capabilities).toHaveLength(4); + + // Verify each capability has required fields + capabilities.forEach((cap) => { + expect(cap).toHaveProperty("name"); + expect(cap).toHaveProperty("description"); + expect(cap).toHaveProperty("operation"); + expect(cap).toHaveProperty("parameters"); + expect(Array.isArray(cap.parameters)).toBe(true); + }); + + // Verify specific capabilities + const capabilityNames = capabilities.map((c) => c.name); + expect(capabilityNames).toContain("create_vm"); + expect(capabilityNames).toContain("create_lxc"); + expect(capabilityNames).toContain("destroy_vm"); + expect(capabilityNames).toContain("destroy_lxc"); + + // Verify create_vm capability details + const createVmCap = capabilities.find((c) => c.name === "create_vm"); + expect(createVmCap?.operation).toBe("create"); + expect(createVmCap?.description).toContain("virtual machine"); + expect(createVmCap?.parameters.length).toBeGreaterThan(0); + + // Verify destroy_vm capability details + const destroyVmCap = capabilities.find((c) => c.name === "destroy_vm"); + expect(destroyVmCap?.operation).toBe("destroy"); + expect(destroyVmCap?.description).toContain("virtual machine"); + }); + + it("should return capabilities without requiring initialization", () => { + // Don't initialize service + const capabilities = service.listProvisioningCapabilities(); + + // Should still return capabilities + expect(capabilities).toHaveLength(4); + }); + }); + + describe("executeAction with provisioning actions", () => { + it("should route create_vm action to createVM method", async () => { + // Mock guest existence check (should not exist) + mockClient.get.mockRejectedValueOnce(new Error("Guest not found")); + + // Mock VM creation + mockClient.post.mockResolvedValueOnce("UPID:pve1:00001239:task"); + mockClient.waitForTask.mockResolvedValueOnce(undefined); + + // Initialize service + await service.initialize(); + + // Execute create_vm action + const action = { + type: "task" as const, + target: "", + action: "create_vm", + parameters: { + vmid: 100, + name: "test-vm", + node: "pve1", + }, + }; + + const result = await service.executeAction(action); + + // Verify result + expect(result.status).toBe("success"); + expect(result.action).toBe("create_vm"); + }); + + it("should route destroy_vm action to destroyGuest method", async () => { + // Mock guest existence check - getGuestType queries cluster resources (called twice) + mockClient.get + .mockResolvedValueOnce([{ vmid: 100, node: "pve1", type: "qemu" }]) // guestExists -> getGuestType + .mockResolvedValueOnce([{ vmid: 100, node: "pve1", type: "qemu" }]) // getGuestType again + .mockResolvedValueOnce({ status: "stopped" }); // status check + + // Mock delete operation + mockClient.delete.mockResolvedValueOnce("UPID:pve1:00001240:delete"); + mockClient.waitForTask.mockResolvedValueOnce(undefined); + + // Initialize service + await service.initialize(); + + // Execute destroy_vm action + const action = { + type: "task" as const, + target: "", + action: "destroy_vm", + parameters: { + node: "pve1", + vmid: 100, + }, + }; + + const result = await service.executeAction(action); + + // Verify result + expect(result.status).toBe("success"); + expect(result.action).toBe("destroy_guest"); + }); + }); +}); +}); diff --git a/backend/src/integrations/proxmox/types.ts b/backend/src/integrations/proxmox/types.ts new file mode 100644 index 0000000..82f059e --- /dev/null +++ b/backend/src/integrations/proxmox/types.ts @@ -0,0 +1,178 @@ +/** + * Proxmox Virtual Environment Integration Types + * + * Type definitions for the Proxmox VE integration plugin. + */ + +import type { ProvisioningCapability } from "../types"; + +export type { ProvisioningCapability }; + +/** + * Proxmox configuration + */ +export interface ProxmoxConfig { + host: string; + port?: number; + username?: string; + password?: string; + realm?: string; + token?: string; + ssl?: ProxmoxSSLConfig; + timeout?: number; +} + +/** + * SSL configuration for Proxmox client + */ +export interface ProxmoxSSLConfig { + rejectUnauthorized?: boolean; + ca?: string; + cert?: string; + key?: string; +} + +/** + * Proxmox guest (VM or LXC) from API + */ +export interface ProxmoxGuest { + vmid: number; + name: string; + node: string; + type: "qemu" | "lxc"; + status: "running" | "stopped" | "paused"; + template?: number; // 1 if this is a template, 0 or undefined otherwise + maxmem?: number; + maxdisk?: number; + cpus?: number; + uptime?: number; + netin?: number; + netout?: number; + diskread?: number; + diskwrite?: number; +} + +/** + * Proxmox guest configuration + */ +export interface ProxmoxGuestConfig { + vmid: number; + name: string; + hostname?: string; + cores: number; + memory: number; + sockets?: number; + cpu?: string; + bootdisk?: string; + scsihw?: string; + ostype?: string; + net0?: string; + net1?: string; + ide2?: string; + [key: string]: unknown; +} + +/** + * Proxmox guest status + */ +export interface ProxmoxGuestStatus { + status: "running" | "stopped" | "paused"; + vmid: number; + uptime?: number; + cpus?: number; + maxmem?: number; + mem?: number; + maxdisk?: number; + disk?: number; + netin?: number; + netout?: number; + diskread?: number; + diskwrite?: number; +} + +/** + * VM creation parameters + */ +export interface VMCreateParams { + vmid: number; + name: string; + node: string; + cores?: number; + memory?: number; + sockets?: number; + cpu?: string; + scsi0?: string; + ide2?: string; + net0?: string; + ostype?: string; + [key: string]: unknown; +} + +/** + * LXC creation parameters + */ +export interface LXCCreateParams { + vmid: number; + hostname: string; + node: string; + ostemplate: string; + cores?: number; + memory?: number; + rootfs?: string; + net0?: string; + password?: string; + [key: string]: unknown; +} + +/** + * Proxmox task status + */ +export interface ProxmoxTaskStatus { + status: "running" | "stopped"; + exitstatus?: string; + type: string; + node: string; + pid: number; + pstart: number; + starttime: number; + upid: string; +} + +/** + * Retry configuration + */ +export interface RetryConfig { + maxAttempts: number; + initialDelay: number; + maxDelay: number; + backoffMultiplier: number; + retryableErrors: string[]; +} + +/** + * Proxmox error classes + */ +export class ProxmoxError extends Error { + constructor( + message: string, + public code: string, + public details?: unknown + ) { + super(message); + this.name = "ProxmoxError"; + } +} + +export class ProxmoxAuthenticationError extends ProxmoxError { + constructor(message: string, details?: unknown) { + super(message, "PROXMOX_AUTH_ERROR", details); + this.name = "ProxmoxAuthenticationError"; + } +} + +export class ProxmoxConnectionError extends ProxmoxError { + constructor(message: string, details?: unknown) { + super(message, "PROXMOX_CONNECTION_ERROR", details); + this.name = "ProxmoxConnectionError"; + } +} diff --git a/backend/src/integrations/types.ts b/backend/src/integrations/types.ts index c1c7af3..08c5f94 100644 --- a/backend/src/integrations/types.ts +++ b/backend/src/integrations/types.ts @@ -81,6 +81,13 @@ export interface Capability { parameters?: CapabilityParameter[]; } +/** + * Provisioning capability for infrastructure creation/destruction + */ +export interface ProvisioningCapability extends Capability { + operation: "create" | "destroy"; +} + /** * Parameter definition for a capability */ @@ -90,6 +97,12 @@ export interface CapabilityParameter { required: boolean; description?: string; default?: unknown; + validation?: { + min?: number; + max?: number; + pattern?: string; + enum?: string[]; + }; } /** diff --git a/backend/src/routes/integrations.ts b/backend/src/routes/integrations.ts index 0706593..9d278cc 100644 --- a/backend/src/routes/integrations.ts +++ b/backend/src/routes/integrations.ts @@ -7,6 +7,8 @@ import { createColorsRouter } from "./integrations/colors"; import { createStatusRouter } from "./integrations/status"; import { createPuppetDBRouter } from "./integrations/puppetdb"; import { createPuppetserverRouter } from "./integrations/puppetserver"; +import { createProxmoxRouter } from "./integrations/proxmox"; +import { createProvisioningRouter } from "./integrations/provisioning"; import { createAuthMiddleware } from "../middleware/authMiddleware"; import { createRbacMiddleware } from "../middleware/rbacMiddleware"; import { asyncHandler } from "./asyncHandler"; @@ -53,5 +55,25 @@ export function createIntegrationsRouter( // Mount Puppetserver router (handles not configured case internally) router.use("/puppetserver", createPuppetserverRouter(puppetserverService, puppetDBService)); + // Mount Proxmox router + router.use("/proxmox", createProxmoxRouter(integrationManager)); + + // Mount Provisioning router (integration discovery) with authentication + // Validates Requirements: 1.3, 2.1, 9.1, 9.2 + if (db) { + const authMiddleware = createAuthMiddleware(db, jwtSecret); + const rbacMiddleware = createRbacMiddleware(db); + + router.use( + "/provisioning", + asyncHandler(authMiddleware), + asyncHandler(rbacMiddleware('provisioning', 'read')), + createProvisioningRouter(integrationManager) + ); + } else { + // Fallback for cases where database is not available (e.g., tests) + router.use("/provisioning", createProvisioningRouter(integrationManager)); + } + return router; } diff --git a/backend/src/routes/integrations/provisioning.ts b/backend/src/routes/integrations/provisioning.ts new file mode 100644 index 0000000..b9f321c --- /dev/null +++ b/backend/src/routes/integrations/provisioning.ts @@ -0,0 +1,94 @@ +import { Router, type Request, type Response } from "express"; +import type { IntegrationManager } from "../../integrations/IntegrationManager"; +import type { ProxmoxIntegration } from "../../integrations/proxmox/ProxmoxIntegration"; +import type { ProvisioningCapability } from "../../integrations/types"; +import { asyncHandler } from "../asyncHandler"; +import { createLogger } from "./utils"; + +interface ProvisioningIntegration { + name: string; + displayName: string; + type: 'virtualization' | 'cloud' | 'container'; + status: 'connected' | 'degraded' | 'not_configured'; + capabilities: ProvisioningCapability[]; +} + +interface ListIntegrationsResponse { + integrations: ProvisioningIntegration[]; +} + +/** + * Create provisioning router for integration discovery + * Validates Requirements: 2.1, 2.2, 13.1, 13.3 + */ +export function createProvisioningRouter( + integrationManager: IntegrationManager +): Router { + const router = Router(); + const logger = createLogger(); + + /** + * GET /api/integrations/provisioning + * List all available provisioning integrations with their capabilities + * Validates Requirements: 2.1, 2.2 + */ + router.get( + "/", + asyncHandler(async (req: Request, res: Response): Promise => { + logger.info("Fetching provisioning integrations", { + component: "ProvisioningRouter", + operation: "listIntegrations", + }); + + const integrations: ProvisioningIntegration[] = []; + + // Check Proxmox integration + const proxmox = integrationManager.getExecutionTool("proxmox") as ProxmoxIntegration | null; + + if (proxmox) { + // Determine integration status based on health check + let status: 'connected' | 'degraded' | 'not_configured' = 'not_configured'; + const healthCheck = proxmox.getLastHealthCheck(); + + if (healthCheck) { + if (healthCheck.healthy) { + status = 'connected'; + } else if (healthCheck.message?.includes('not initialized') || healthCheck.message?.includes('disabled')) { + status = 'not_configured'; + } else { + status = 'degraded'; + } + } + + const proxmoxIntegration: ProvisioningIntegration = { + name: "proxmox", + displayName: "Proxmox VE", + type: "virtualization", + status, + capabilities: proxmox.listProvisioningCapabilities(), + }; + + integrations.push(proxmoxIntegration); + } + + // Future integrations (EC2, Azure, Terraform) would be added here + // Example: + // const ec2 = integrationManager.getExecutionTool("ec2"); + // if (ec2) { integrations.push(buildEC2Integration(ec2)); } + + const response: ListIntegrationsResponse = { + integrations, + }; + + logger.info("Provisioning integrations fetched", { + component: "ProvisioningRouter", + operation: "listIntegrations", + metadata: { count: integrations.length }, + }); + + res.status(200).json(response); + }) + ); + + return router; +} diff --git a/backend/src/routes/integrations/proxmox.ts b/backend/src/routes/integrations/proxmox.ts new file mode 100644 index 0000000..679dd1b --- /dev/null +++ b/backend/src/routes/integrations/proxmox.ts @@ -0,0 +1,1062 @@ +import { Router, type Request, type Response } from "express"; +import { z } from "zod"; +import type { IntegrationManager } from "../../integrations/IntegrationManager"; +import type { ProxmoxIntegration } from "../../integrations/proxmox/ProxmoxIntegration"; +import type { VMCreateParams, LXCCreateParams } from "../../integrations/proxmox/types"; +import { asyncHandler } from "../asyncHandler"; +import { ExpertModeService } from "../../services/ExpertModeService"; +import { createLogger } from "./utils"; + +/** + * Validation schemas for Proxmox API routes + */ + +// VM creation parameters schema +const VMCreateParamsSchema = z.object({ + vmid: z.number().int().min(100).max(999999999), + name: z.string().min(1).max(50), + node: z.string().min(1).max(20), + cores: z.number().int().min(1).max(128).optional(), + memory: z.number().int().min(16).optional(), + sockets: z.number().int().min(1).max(4).optional(), + cpu: z.string().optional(), + scsi0: z.string().optional(), + ide2: z.string().optional(), + net0: z.string().optional(), + ostype: z.string().optional(), +}); + +// LXC creation parameters schema +const LXCCreateParamsSchema = z.object({ + vmid: z.number().int().min(100).max(999999999), + hostname: z.string().min(1).max(50), + node: z.string().min(1).max(20), + ostemplate: z.string().min(1), + cores: z.number().int().min(1).max(128).optional(), + memory: z.number().int().min(16).optional(), + rootfs: z.string().optional(), + net0: z.string().optional(), + password: z.string().optional(), +}); + +// Action parameters schema +const ActionParamsSchema = z.object({ + nodeId: z.string().regex(/^proxmox:[^:]+:\d+$/), + action: z.enum(["start", "stop", "shutdown", "reboot", "suspend", "resume"]), +}); + +// Destroy parameters schema +const DestroyParamsSchema = z.object({ + vmid: z.string().regex(/^\d+$/), +}); + +/** + * Create Proxmox router for all Proxmox-related routes + */ +export function createProxmoxRouter( + integrationManager: IntegrationManager +): Router { + const router = Router(); + const logger = createLogger(); + + /** + * Helper function to get Proxmox integration + */ + const getProxmoxIntegration = (): ProxmoxIntegration | null => { + const plugin = integrationManager.getExecutionTool("proxmox"); + return plugin as ProxmoxIntegration | null; + }; + + /** + * GET /api/integrations/proxmox/nodes + * Get list of PVE nodes in the cluster + */ + router.get( + "/nodes", + asyncHandler(async (_req: Request, res: Response): Promise => { + const proxmox = getProxmoxIntegration(); + if (!proxmox) { + res.status(503).json({ error: { code: "PROXMOX_NOT_CONFIGURED", message: "Proxmox integration is not configured" } }); + return; + } + try { + const nodes = await proxmox.getNodes(); + res.json({ nodes }); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + logger.error("Failed to fetch PVE nodes", { component: "ProxmoxRouter", operation: "getNodes", metadata: { error: msg } }, error instanceof Error ? error : undefined); + res.status(500).json({ error: { code: "FETCH_NODES_FAILED", message: msg } }); + } + }) + ); + + /** + * GET /api/integrations/proxmox/nextid + * Get the next available VMID + */ + router.get( + "/nextid", + asyncHandler(async (_req: Request, res: Response): Promise => { + const proxmox = getProxmoxIntegration(); + if (!proxmox) { + res.status(503).json({ error: { code: "PROXMOX_NOT_CONFIGURED", message: "Proxmox integration is not configured" } }); + return; + } + try { + const vmid = await proxmox.getNextVMID(); + res.json({ vmid }); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + logger.error("Failed to fetch next VMID", { component: "ProxmoxRouter", operation: "getNextVMID", metadata: { error: msg } }, error instanceof Error ? error : undefined); + res.status(500).json({ error: { code: "FETCH_NEXTID_FAILED", message: msg } }); + } + }) + ); + + /** + * GET /api/integrations/proxmox/nodes/:node/isos + * Get ISO images available on a node + * Query params: storage (optional, defaults to 'local') + */ + router.get( + "/nodes/:node/isos", + asyncHandler(async (req: Request, res: Response): Promise => { + const proxmox = getProxmoxIntegration(); + if (!proxmox) { + res.status(503).json({ error: { code: "PROXMOX_NOT_CONFIGURED", message: "Proxmox integration is not configured" } }); + return; + } + const { node } = req.params; + const storage = (req.query.storage as string) || undefined; + try { + const isos = await proxmox.getISOImages(node, storage); + res.json({ isos }); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + logger.error("Failed to fetch ISOs", { component: "ProxmoxRouter", operation: "getISOImages", metadata: { node, error: msg } }, error instanceof Error ? error : undefined); + res.status(500).json({ error: { code: "FETCH_ISOS_FAILED", message: msg } }); + } + }) + ); + + /** + * GET /api/integrations/proxmox/nodes/:node/templates + * Get OS templates available on a node + * Query params: storage (optional, defaults to 'local') + */ + router.get( + "/nodes/:node/templates", + asyncHandler(async (req: Request, res: Response): Promise => { + const proxmox = getProxmoxIntegration(); + if (!proxmox) { + res.status(503).json({ error: { code: "PROXMOX_NOT_CONFIGURED", message: "Proxmox integration is not configured" } }); + return; + } + const { node } = req.params; + const storage = (req.query.storage as string) || undefined; + try { + const templates = await proxmox.getTemplates(node, storage); + res.json({ templates }); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + logger.error("Failed to fetch templates", { component: "ProxmoxRouter", operation: "getTemplates", metadata: { node, error: msg } }, error instanceof Error ? error : undefined); + res.status(500).json({ error: { code: "FETCH_TEMPLATES_FAILED", message: msg } }); + } + }) + ); + + /** + * GET /api/integrations/proxmox/nodes/:node/storages + * Get available storages on a node, optionally filtered by content type + */ + router.get( + "/nodes/:node/storages", + asyncHandler(async (req: Request, res: Response): Promise => { + const proxmox = getProxmoxIntegration(); + if (!proxmox) { + res.status(503).json({ error: { code: "PROXMOX_NOT_CONFIGURED", message: "Proxmox integration is not configured" } }); + return; + } + const { node } = req.params; + const content = (req.query.content as string) || undefined; + try { + const storages = await proxmox.getStorages(node, content); + res.json({ storages }); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + logger.error("Failed to fetch storages", { component: "ProxmoxRouter", operation: "getStorages", metadata: { node, error: msg } }, error instanceof Error ? error : undefined); + res.status(500).json({ error: { code: "FETCH_STORAGES_FAILED", message: msg } }); + } + }) + ); + + /** + * GET /api/integrations/proxmox/nodes/:node/networks + * Get available network bridges on a node + */ + router.get( + "/nodes/:node/networks", + asyncHandler(async (req: Request, res: Response): Promise => { + const proxmox = getProxmoxIntegration(); + if (!proxmox) { + res.status(503).json({ error: { code: "PROXMOX_NOT_CONFIGURED", message: "Proxmox integration is not configured" } }); + return; + } + const { node } = req.params; + const type = (req.query.type as string) || undefined; + try { + const networks = await proxmox.getNetworkBridges(node, type); + res.json({ networks }); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + logger.error("Failed to fetch networks", { component: "ProxmoxRouter", operation: "getNetworks", metadata: { node, error: msg } }, error instanceof Error ? error : undefined); + res.status(500).json({ error: { code: "FETCH_NETWORKS_FAILED", message: msg } }); + } + }) + ); + + /** + * POST /api/integrations/proxmox/provision/vm + * Create a new virtual machine + */ + router.post( + "/provision/vm", + asyncHandler(async (req: Request, res: Response): Promise => { + const startTime = Date.now(); + const expertModeService = new ExpertModeService(); + const requestId = req.id ?? expertModeService.generateRequestId(); + + // Create debug info once at the start if expert mode is enabled + const debugInfo = req.expertMode + ? expertModeService.createDebugInfo( + "POST /api/integrations/proxmox/provision/vm", + requestId, + 0 + ) + : null; + + logger.info("Creating Proxmox VM", { + component: "ProxmoxRouter", + integration: "proxmox", + operation: "createVM", + }); + + if (debugInfo) { + expertModeService.addInfo(debugInfo, { + message: "Creating Proxmox VM", + level: "info", + }); + } + + // Get Proxmox integration + const proxmox = getProxmoxIntegration(); + if (!proxmox) { + logger.warn("Proxmox integration is not configured", { + component: "ProxmoxRouter", + integration: "proxmox", + operation: "createVM", + }); + + if (debugInfo) { + debugInfo.duration = Date.now() - startTime; + expertModeService.addWarning(debugInfo, { + message: "Proxmox integration is not configured", + context: "Proxmox integration is not available", + level: "warn", + }); + debugInfo.performance = + expertModeService.collectPerformanceMetrics(); + debugInfo.context = expertModeService.collectRequestContext(req); + } + + const errorResponse = { + error: { + code: "PROXMOX_NOT_CONFIGURED", + message: "Proxmox integration is not configured", + }, + }; + + res + .status(503) + .json( + debugInfo + ? expertModeService.attachDebugInfo(errorResponse, debugInfo) + : errorResponse + ); + return; + } + + // Validate request body + const validation = VMCreateParamsSchema.safeParse(req.body); + if (!validation.success) { + logger.warn("Invalid VM creation parameters", { + component: "ProxmoxRouter", + integration: "proxmox", + operation: "createVM", + metadata: { errors: validation.error.errors }, + }); + + if (debugInfo) { + debugInfo.duration = Date.now() - startTime; + expertModeService.addWarning(debugInfo, { + message: "Invalid VM creation parameters", + context: JSON.stringify(validation.error.errors), + level: "warn", + }); + debugInfo.performance = + expertModeService.collectPerformanceMetrics(); + debugInfo.context = expertModeService.collectRequestContext(req); + } + + const errorResponse = { + error: { + code: "INVALID_PARAMETERS", + message: "Invalid VM creation parameters", + details: validation.error.errors, + }, + }; + + res + .status(400) + .json( + debugInfo + ? expertModeService.attachDebugInfo(errorResponse, debugInfo) + : errorResponse + ); + return; + } + + const params = validation.data as VMCreateParams; + + if (debugInfo) { + expertModeService.addDebug(debugInfo, { + message: "VM creation parameters validated", + context: JSON.stringify({ vmid: params.vmid, node: params.node }), + level: "debug", + }); + } + + try { + // Execute VM creation through integration + const result = await proxmox.executeAction({ + type: "task", + target: `proxmox:${params.node}:${params.vmid}`, + action: "create_vm", + parameters: params, + }); + + logger.info("VM creation completed", { + component: "ProxmoxRouter", + integration: "proxmox", + operation: "createVM", + metadata: { vmid: params.vmid, status: result.status }, + }); + + if (debugInfo) { + debugInfo.duration = Date.now() - startTime; + expertModeService.addInfo(debugInfo, { + message: "VM creation completed", + context: JSON.stringify({ status: result.status }), + level: "info", + }); + debugInfo.performance = + expertModeService.collectPerformanceMetrics(); + debugInfo.context = expertModeService.collectRequestContext(req); + } + + const responseData = { result }; + + res + .status(result.status === "success" ? 201 : 500) + .json( + debugInfo + ? expertModeService.attachDebugInfo(responseData, debugInfo) + : responseData + ); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + + logger.error( + "Failed to create VM", + { + component: "ProxmoxRouter", + integration: "proxmox", + operation: "createVM", + metadata: { vmid: params.vmid, error: errorMessage }, + }, + error instanceof Error ? error : undefined + ); + + if (debugInfo) { + debugInfo.duration = Date.now() - startTime; + expertModeService.addError(debugInfo, { + message: "Failed to create VM", + context: errorMessage, + level: "error", + stack: error instanceof Error ? error.stack : undefined, + }); + debugInfo.performance = + expertModeService.collectPerformanceMetrics(); + debugInfo.context = expertModeService.collectRequestContext(req); + } + + const errorResponse = { + error: { + code: "VM_CREATION_FAILED", + message: errorMessage, + }, + }; + + res + .status(500) + .json( + debugInfo + ? expertModeService.attachDebugInfo(errorResponse, debugInfo) + : errorResponse + ); + } + }) + ); + + /** + * POST /api/integrations/proxmox/provision/lxc + * Create a new LXC container + */ + router.post( + "/provision/lxc", + asyncHandler(async (req: Request, res: Response): Promise => { + const startTime = Date.now(); + const expertModeService = new ExpertModeService(); + const requestId = req.id ?? expertModeService.generateRequestId(); + + const debugInfo = req.expertMode + ? expertModeService.createDebugInfo( + "POST /api/integrations/proxmox/provision/lxc", + requestId, + 0 + ) + : null; + + logger.info("Creating Proxmox LXC container", { + component: "ProxmoxRouter", + integration: "proxmox", + operation: "createLXC", + }); + + if (debugInfo) { + expertModeService.addInfo(debugInfo, { + message: "Creating Proxmox LXC container", + level: "info", + }); + } + + const proxmox = getProxmoxIntegration(); + if (!proxmox) { + logger.warn("Proxmox integration is not configured", { + component: "ProxmoxRouter", + integration: "proxmox", + operation: "createLXC", + }); + + if (debugInfo) { + debugInfo.duration = Date.now() - startTime; + expertModeService.addWarning(debugInfo, { + message: "Proxmox integration is not configured", + context: "Proxmox integration is not available", + level: "warn", + }); + debugInfo.performance = + expertModeService.collectPerformanceMetrics(); + debugInfo.context = expertModeService.collectRequestContext(req); + } + + const errorResponse = { + error: { + code: "PROXMOX_NOT_CONFIGURED", + message: "Proxmox integration is not configured", + }, + }; + + res + .status(503) + .json( + debugInfo + ? expertModeService.attachDebugInfo(errorResponse, debugInfo) + : errorResponse + ); + return; + } + + const validation = LXCCreateParamsSchema.safeParse(req.body); + if (!validation.success) { + logger.warn("Invalid LXC creation parameters", { + component: "ProxmoxRouter", + integration: "proxmox", + operation: "createLXC", + metadata: { errors: validation.error.errors }, + }); + + if (debugInfo) { + debugInfo.duration = Date.now() - startTime; + expertModeService.addWarning(debugInfo, { + message: "Invalid LXC creation parameters", + context: JSON.stringify(validation.error.errors), + level: "warn", + }); + debugInfo.performance = + expertModeService.collectPerformanceMetrics(); + debugInfo.context = expertModeService.collectRequestContext(req); + } + + const errorResponse = { + error: { + code: "INVALID_PARAMETERS", + message: "Invalid LXC creation parameters", + details: validation.error.errors, + }, + }; + + res + .status(400) + .json( + debugInfo + ? expertModeService.attachDebugInfo(errorResponse, debugInfo) + : errorResponse + ); + return; + } + + const params = validation.data as LXCCreateParams; + + if (debugInfo) { + expertModeService.addDebug(debugInfo, { + message: "LXC creation parameters validated", + context: JSON.stringify({ vmid: params.vmid, node: params.node }), + level: "debug", + }); + } + + try { + const result = await proxmox.executeAction({ + type: "task", + target: `proxmox:${params.node}:${params.vmid}`, + action: "create_lxc", + parameters: params, + }); + + logger.info("LXC creation completed", { + component: "ProxmoxRouter", + integration: "proxmox", + operation: "createLXC", + metadata: { vmid: params.vmid, status: result.status }, + }); + + if (debugInfo) { + debugInfo.duration = Date.now() - startTime; + expertModeService.addInfo(debugInfo, { + message: "LXC creation completed", + context: JSON.stringify({ status: result.status }), + level: "info", + }); + debugInfo.performance = + expertModeService.collectPerformanceMetrics(); + debugInfo.context = expertModeService.collectRequestContext(req); + } + + const responseData = { result }; + + res + .status(result.status === "success" ? 201 : 500) + .json( + debugInfo + ? expertModeService.attachDebugInfo(responseData, debugInfo) + : responseData + ); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + + logger.error( + "Failed to create LXC container", + { + component: "ProxmoxRouter", + integration: "proxmox", + operation: "createLXC", + metadata: { vmid: params.vmid, error: errorMessage }, + }, + error instanceof Error ? error : undefined + ); + + if (debugInfo) { + debugInfo.duration = Date.now() - startTime; + expertModeService.addError(debugInfo, { + message: "Failed to create LXC container", + context: errorMessage, + level: "error", + stack: error instanceof Error ? error.stack : undefined, + }); + debugInfo.performance = + expertModeService.collectPerformanceMetrics(); + debugInfo.context = expertModeService.collectRequestContext(req); + } + + const errorResponse = { + error: { + code: "LXC_CREATION_FAILED", + message: errorMessage, + }, + }; + + res + .status(500) + .json( + debugInfo + ? expertModeService.attachDebugInfo(errorResponse, debugInfo) + : errorResponse + ); + } + }) + ); + + /** + * DELETE /api/integrations/proxmox/provision/:vmid + * Destroy a VM or LXC container + */ + router.delete( + "/provision/:vmid", + asyncHandler(async (req: Request, res: Response): Promise => { + const startTime = Date.now(); + const expertModeService = new ExpertModeService(); + const requestId = req.id ?? expertModeService.generateRequestId(); + + const debugInfo = req.expertMode + ? expertModeService.createDebugInfo( + "DELETE /api/integrations/proxmox/provision/:vmid", + requestId, + 0 + ) + : null; + + logger.info("Destroying Proxmox guest", { + component: "ProxmoxRouter", + integration: "proxmox", + operation: "destroyGuest", + }); + + if (debugInfo) { + expertModeService.addInfo(debugInfo, { + message: "Destroying Proxmox guest", + level: "info", + }); + } + + const proxmox = getProxmoxIntegration(); + if (!proxmox) { + logger.warn("Proxmox integration is not configured", { + component: "ProxmoxRouter", + integration: "proxmox", + operation: "destroyGuest", + }); + + if (debugInfo) { + debugInfo.duration = Date.now() - startTime; + expertModeService.addWarning(debugInfo, { + message: "Proxmox integration is not configured", + context: "Proxmox integration is not available", + level: "warn", + }); + debugInfo.performance = + expertModeService.collectPerformanceMetrics(); + debugInfo.context = expertModeService.collectRequestContext(req); + } + + const errorResponse = { + error: { + code: "PROXMOX_NOT_CONFIGURED", + message: "Proxmox integration is not configured", + }, + }; + + res + .status(503) + .json( + debugInfo + ? expertModeService.attachDebugInfo(errorResponse, debugInfo) + : errorResponse + ); + return; + } + + // Validate vmid parameter + const validation = DestroyParamsSchema.safeParse(req.params); + if (!validation.success) { + logger.warn("Invalid VMID parameter", { + component: "ProxmoxRouter", + integration: "proxmox", + operation: "destroyGuest", + metadata: { errors: validation.error.errors }, + }); + + if (debugInfo) { + debugInfo.duration = Date.now() - startTime; + expertModeService.addWarning(debugInfo, { + message: "Invalid VMID parameter", + context: JSON.stringify(validation.error.errors), + level: "warn", + }); + debugInfo.performance = + expertModeService.collectPerformanceMetrics(); + debugInfo.context = expertModeService.collectRequestContext(req); + } + + const errorResponse = { + error: { + code: "INVALID_PARAMETERS", + message: "Invalid VMID parameter", + details: validation.error.errors, + }, + }; + + res + .status(400) + .json( + debugInfo + ? expertModeService.attachDebugInfo(errorResponse, debugInfo) + : errorResponse + ); + return; + } + + // Get node from query parameter (required) + const node = req.query.node as string; + if (!node) { + logger.warn("Missing node parameter", { + component: "ProxmoxRouter", + integration: "proxmox", + operation: "destroyGuest", + }); + + if (debugInfo) { + debugInfo.duration = Date.now() - startTime; + expertModeService.addWarning(debugInfo, { + message: "Missing node parameter", + context: "Node parameter is required", + level: "warn", + }); + debugInfo.performance = + expertModeService.collectPerformanceMetrics(); + debugInfo.context = expertModeService.collectRequestContext(req); + } + + const errorResponse = { + error: { + code: "INVALID_PARAMETERS", + message: "Node parameter is required", + }, + }; + + res + .status(400) + .json( + debugInfo + ? expertModeService.attachDebugInfo(errorResponse, debugInfo) + : errorResponse + ); + return; + } + + const vmid = parseInt(validation.data.vmid, 10); + + if (debugInfo) { + expertModeService.addDebug(debugInfo, { + message: "Destroy parameters validated", + context: JSON.stringify({ vmid, node }), + level: "debug", + }); + } + + try { + const result = await proxmox.executeAction({ + type: "task", + target: `proxmox:${node}:${vmid}`, + action: "destroy_vm", + parameters: { vmid, node }, + }); + + logger.info("Guest destruction completed", { + component: "ProxmoxRouter", + integration: "proxmox", + operation: "destroyGuest", + metadata: { vmid, node, status: result.status }, + }); + + if (debugInfo) { + debugInfo.duration = Date.now() - startTime; + expertModeService.addInfo(debugInfo, { + message: "Guest destruction completed", + context: JSON.stringify({ status: result.status }), + level: "info", + }); + debugInfo.performance = + expertModeService.collectPerformanceMetrics(); + debugInfo.context = expertModeService.collectRequestContext(req); + } + + const responseData = { result }; + + res + .status(result.status === "success" ? 200 : 500) + .json( + debugInfo + ? expertModeService.attachDebugInfo(responseData, debugInfo) + : responseData + ); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + + logger.error( + "Failed to destroy guest", + { + component: "ProxmoxRouter", + integration: "proxmox", + operation: "destroyGuest", + metadata: { vmid, node, error: errorMessage }, + }, + error instanceof Error ? error : undefined + ); + + if (debugInfo) { + debugInfo.duration = Date.now() - startTime; + expertModeService.addError(debugInfo, { + message: "Failed to destroy guest", + context: errorMessage, + level: "error", + stack: error instanceof Error ? error.stack : undefined, + }); + debugInfo.performance = + expertModeService.collectPerformanceMetrics(); + debugInfo.context = expertModeService.collectRequestContext(req); + } + + const errorResponse = { + error: { + code: "GUEST_DESTRUCTION_FAILED", + message: errorMessage, + }, + }; + + res + .status(500) + .json( + debugInfo + ? expertModeService.attachDebugInfo(errorResponse, debugInfo) + : errorResponse + ); + } + }) + ); + + /** + * POST /api/integrations/proxmox/action + * Execute a lifecycle action on a VM or container + */ + router.post( + "/action", + asyncHandler(async (req: Request, res: Response): Promise => { + const startTime = Date.now(); + const expertModeService = new ExpertModeService(); + const requestId = req.id ?? expertModeService.generateRequestId(); + + const debugInfo = req.expertMode + ? expertModeService.createDebugInfo( + "POST /api/integrations/proxmox/action", + requestId, + 0 + ) + : null; + + logger.info("Executing Proxmox action", { + component: "ProxmoxRouter", + integration: "proxmox", + operation: "executeAction", + }); + + if (debugInfo) { + expertModeService.addInfo(debugInfo, { + message: "Executing Proxmox action", + level: "info", + }); + } + + const proxmox = getProxmoxIntegration(); + if (!proxmox) { + logger.warn("Proxmox integration is not configured", { + component: "ProxmoxRouter", + integration: "proxmox", + operation: "executeAction", + }); + + if (debugInfo) { + debugInfo.duration = Date.now() - startTime; + expertModeService.addWarning(debugInfo, { + message: "Proxmox integration is not configured", + context: "Proxmox integration is not available", + level: "warn", + }); + debugInfo.performance = + expertModeService.collectPerformanceMetrics(); + debugInfo.context = expertModeService.collectRequestContext(req); + } + + const errorResponse = { + error: { + code: "PROXMOX_NOT_CONFIGURED", + message: "Proxmox integration is not configured", + }, + }; + + res + .status(503) + .json( + debugInfo + ? expertModeService.attachDebugInfo(errorResponse, debugInfo) + : errorResponse + ); + return; + } + + // Validate request body + const validation = ActionParamsSchema.safeParse(req.body); + if (!validation.success) { + logger.warn("Invalid action parameters", { + component: "ProxmoxRouter", + integration: "proxmox", + operation: "executeAction", + metadata: { errors: validation.error.errors }, + }); + + if (debugInfo) { + debugInfo.duration = Date.now() - startTime; + expertModeService.addWarning(debugInfo, { + message: "Invalid action parameters", + context: JSON.stringify(validation.error.errors), + level: "warn", + }); + debugInfo.performance = + expertModeService.collectPerformanceMetrics(); + debugInfo.context = expertModeService.collectRequestContext(req); + } + + const errorResponse = { + error: { + code: "INVALID_PARAMETERS", + message: "Invalid action parameters", + details: validation.error.errors, + }, + }; + + res + .status(400) + .json( + debugInfo + ? expertModeService.attachDebugInfo(errorResponse, debugInfo) + : errorResponse + ); + return; + } + + const { nodeId, action } = validation.data; + + if (debugInfo) { + expertModeService.addDebug(debugInfo, { + message: "Action parameters validated", + context: JSON.stringify({ nodeId, action }), + level: "debug", + }); + } + + try { + const result = await proxmox.executeAction({ + type: "task", + target: nodeId, + action, + }); + + logger.info("Action execution completed", { + component: "ProxmoxRouter", + integration: "proxmox", + operation: "executeAction", + metadata: { nodeId, action, status: result.status }, + }); + + if (debugInfo) { + debugInfo.duration = Date.now() - startTime; + expertModeService.addInfo(debugInfo, { + message: "Action execution completed", + context: JSON.stringify({ status: result.status }), + level: "info", + }); + debugInfo.performance = + expertModeService.collectPerformanceMetrics(); + debugInfo.context = expertModeService.collectRequestContext(req); + } + + const responseData = { result }; + + res + .status(result.status === "success" ? 200 : 500) + .json( + debugInfo + ? expertModeService.attachDebugInfo(responseData, debugInfo) + : responseData + ); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + + logger.error( + "Failed to execute action", + { + component: "ProxmoxRouter", + integration: "proxmox", + operation: "executeAction", + metadata: { nodeId, action, error: errorMessage }, + }, + error instanceof Error ? error : undefined + ); + + if (debugInfo) { + debugInfo.duration = Date.now() - startTime; + expertModeService.addError(debugInfo, { + message: "Failed to execute action", + context: errorMessage, + level: "error", + stack: error instanceof Error ? error.stack : undefined, + }); + debugInfo.performance = + expertModeService.collectPerformanceMetrics(); + debugInfo.context = expertModeService.collectRequestContext(req); + } + + const errorResponse = { + error: { + code: "ACTION_EXECUTION_FAILED", + message: errorMessage, + }, + }; + + res + .status(500) + .json( + debugInfo + ? expertModeService.attachDebugInfo(errorResponse, debugInfo) + : errorResponse + ); + } + }) + ); + + return router; +} diff --git a/backend/src/routes/integrations/status.ts b/backend/src/routes/integrations/status.ts index 17bd109..560e5ee 100644 --- a/backend/src/routes/integrations/status.ts +++ b/backend/src/routes/integrations/status.ts @@ -214,6 +214,28 @@ export function createStatusRouter( }); } + // Check if Proxmox is not configured + if (!configuredNames.has("proxmox")) { + logger.debug("Proxmox integration is not configured", { + component: "StatusRouter", + integration: "proxmox", + operation: "getStatus", + }); + integrations.push({ + name: "proxmox", + type: "both", + status: "not_configured", + lastCheck: new Date().toISOString(), + message: "Proxmox integration is not configured", + details: { + setupRequired: true, + setupUrl: "/setup/proxmox", + }, + workingCapabilities: undefined, + failingCapabilities: undefined, + }); + } + const duration = Date.now() - startTime; const responseData = { integrations, diff --git a/backend/src/routes/inventory.ts b/backend/src/routes/inventory.ts index a5b172e..d6fdcca 100644 --- a/backend/src/routes/inventory.ts +++ b/backend/src/routes/inventory.ts @@ -13,6 +13,7 @@ import { ExpertModeService } from "../services/ExpertModeService"; import { LoggerService } from "../services/LoggerService"; import { requestDeduplication } from "../middleware/deduplication"; import { NodeIdParamSchema } from "../validation/commonSchemas"; +import type { ProxmoxIntegration } from "../integrations/proxmox/ProxmoxIntegration"; const InventoryQuerySchema = z.object({ sources: z.string().optional(), @@ -1115,5 +1116,298 @@ export function createInventoryRouter( }), ); + /** + * POST /api/nodes/:id/action + * Execute a lifecycle action on a node (start, stop, shutdown, reboot, suspend, resume, snapshot) + * + * Note: RBAC middleware should be applied at the route mounting level in server.ts + * Required permission: lifecycle:* or lifecycle:{action} + */ + router.post( + "/:id/action", + asyncHandler(async (req: Request, res: Response): Promise => { + const startTime = Date.now(); + const expertModeService = new ExpertModeService(); + const requestId = req.id ?? expertModeService.generateRequestId(); + + logger.info("Executing node action", { + component: "InventoryRouter", + operation: "executeNodeAction", + }); + + try { + // Validate request parameters + const params = NodeIdParamSchema.parse(req.params); + const nodeId = params.id; + + // Validate request body + const ActionSchema = z.object({ + action: z.enum(["start", "stop", "shutdown", "reboot", "suspend", "resume", "snapshot"]), + }); + const body = ActionSchema.parse(req.body); + + logger.debug("Executing action on node", { + component: "InventoryRouter", + operation: "executeNodeAction", + metadata: { nodeId, action: body.action }, + }); + + // Check if node is from Proxmox (nodeId format: proxmox:{node}:{vmid}) + if (!nodeId.startsWith("proxmox:")) { + const errorResponse = { + error: { + code: "UNSUPPORTED_NODE_TYPE", + message: "Lifecycle actions are only supported for Proxmox nodes", + }, + }; + + res.status(400).json(errorResponse); + return; + } + + // Get Proxmox service from integration manager + if (!integrationManager?.isInitialized()) { + const errorResponse = { + error: { + code: "INTEGRATION_NOT_AVAILABLE", + message: "Proxmox integration is not available", + }, + }; + + res.status(503).json(errorResponse); + return; + } + + const proxmoxTool = integrationManager.getExecutionTool("proxmox") as ProxmoxIntegration | null; + if (!proxmoxTool) { + const errorResponse = { + error: { + code: "PROXMOX_NOT_CONFIGURED", + message: "Proxmox integration is not configured", + }, + }; + + res.status(503).json(errorResponse); + return; + } + + // Execute the action with a properly-shaped Action object + const result = await proxmoxTool.executeAction({ + type: "task", + target: nodeId, + action: body.action, + }); + + const duration = Date.now() - startTime; + + logger.info("Node action executed successfully", { + component: "InventoryRouter", + integration: "proxmox", + operation: "executeNodeAction", + metadata: { nodeId, action: body.action, duration }, + }); + + const responseData = { + success: true, + message: `Action ${body.action} executed successfully`, + result, + }; + + res.json(responseData); + } catch (error) { + const duration = Date.now() - startTime; + + if (error instanceof z.ZodError) { + logger.warn("Invalid request parameters", { + component: "InventoryRouter", + operation: "executeNodeAction", + metadata: { errors: error.errors }, + }); + + res.status(400).json({ + error: { + code: "INVALID_REQUEST", + message: "Invalid request parameters", + details: error.errors, + }, + }); + return; + } + + logger.error("Error executing node action", { + component: "InventoryRouter", + integration: "proxmox", + operation: "executeNodeAction", + metadata: { duration }, + }, error instanceof Error ? error : undefined); + + res.status(500).json({ + error: { + code: "ACTION_EXECUTION_FAILED", + message: error instanceof Error ? error.message : "Failed to execute action", + }, + }); + } + }), + ); + + /** + * DELETE /api/nodes/:id + * Destroy a node (permanently delete VM or container) + * + * Note: RBAC middleware should be applied at the route mounting level in server.ts + * Required permission: lifecycle:destroy + */ + router.delete( + "/:id", + asyncHandler(async (req: Request, res: Response): Promise => { + const startTime = Date.now(); + const expertModeService = new ExpertModeService(); + const requestId = req.id ?? expertModeService.generateRequestId(); + + logger.info("Destroying node", { + component: "InventoryRouter", + operation: "destroyNode", + }); + + try { + // Validate request parameters + const params = NodeIdParamSchema.parse(req.params); + const nodeId = params.id; + + logger.debug("Destroying node", { + component: "InventoryRouter", + operation: "destroyNode", + metadata: { nodeId }, + }); + + // Check if node is from Proxmox (nodeId format: proxmox:{node}:{vmid}) + if (!nodeId.startsWith("proxmox:")) { + const errorResponse = { + error: { + code: "UNSUPPORTED_NODE_TYPE", + message: "Destroy action is only supported for Proxmox nodes", + }, + }; + + res.status(400).json(errorResponse); + return; + } + + // Get Proxmox service from integration manager + if (!integrationManager?.isInitialized()) { + const errorResponse = { + error: { + code: "INTEGRATION_NOT_AVAILABLE", + message: "Proxmox integration is not available", + }, + }; + + res.status(503).json(errorResponse); + return; + } + + const proxmoxTool = integrationManager.getExecutionTool("proxmox") as ProxmoxIntegration | null; + if (!proxmoxTool) { + const errorResponse = { + error: { + code: "PROXMOX_NOT_CONFIGURED", + message: "Proxmox integration is not configured", + }, + }; + + res.status(503).json(errorResponse); + return; + } + + // Parse node and vmid from nodeId + const parts = nodeId.split(":"); + if (parts.length !== 3) { + const errorResponse = { + error: { + code: "INVALID_NODE_ID", + message: "Invalid Proxmox node ID format", + }, + }; + + res.status(400).json(errorResponse); + return; + } + + const node = parts[1]; + const vmid = parseInt(parts[2], 10); + + if (!Number.isFinite(vmid)) { + const errorResponse = { + error: { + code: "INVALID_NODE_ID", + message: "Invalid Proxmox node ID: vmid is not a valid number", + }, + }; + + res.status(400).json(errorResponse); + return; + } + + const result = await proxmoxTool.executeAction({ + type: "task", + target: nodeId, + action: "destroy_vm", + parameters: { node, vmid }, + }); + + const duration = Date.now() - startTime; + + logger.info("Node destroyed successfully", { + component: "InventoryRouter", + integration: "proxmox", + operation: "destroyNode", + metadata: { nodeId, duration }, + }); + + const responseData = { + success: true, + message: "Node destroyed successfully", + result, + }; + + res.json(responseData); + } catch (error) { + const duration = Date.now() - startTime; + + if (error instanceof z.ZodError) { + logger.warn("Invalid request parameters", { + component: "InventoryRouter", + operation: "destroyNode", + metadata: { errors: error.errors }, + }); + + res.status(400).json({ + error: { + code: "INVALID_REQUEST", + message: "Invalid request parameters", + details: error.errors, + }, + }); + return; + } + + logger.error("Error destroying node", { + component: "InventoryRouter", + integration: "proxmox", + operation: "destroyNode", + metadata: { duration }, + }, error instanceof Error ? error : undefined); + + res.status(500).json({ + error: { + code: "DESTROY_FAILED", + message: error instanceof Error ? error.message : "Failed to destroy node", + }, + }); + } + }), + ); + return router; } diff --git a/backend/src/routes/streaming.ts b/backend/src/routes/streaming.ts index 9fa42eb..78d5a28 100644 --- a/backend/src/routes/streaming.ts +++ b/backend/src/routes/streaming.ts @@ -26,6 +26,9 @@ export function createStreamingRouter( /** * GET /api/executions/:id/stream * Subscribe to streaming events for an execution + * + * Note: EventSource API doesn't support custom headers, so authentication + * token can be passed via query parameter as a fallback */ router.get( "/:id/stream", @@ -46,6 +49,18 @@ export function createStreamingRouter( }); try { + // Handle token from query parameter (EventSource doesn't support headers) + // Only move to Authorization header when no Authorization header is already present, + // then remove from query to reduce the chance of it being logged downstream. + if ( + typeof req.query.token === "string" && + !req.headers.authorization + ) { + req.headers.authorization = `Bearer ${req.query.token}`; + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete (req.query as Record).token; + } + // Validate request parameters if (debugInfo) { expertModeService.addDebug(debugInfo, { diff --git a/backend/src/server.ts b/backend/src/server.ts index 7a008d1..ac17e0e 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -52,6 +52,7 @@ import { AnsibleService } from "./integrations/ansible/AnsibleService"; import { AnsiblePlugin } from "./integrations/ansible/AnsiblePlugin"; import { SSHPlugin } from "./integrations/ssh/SSHPlugin"; import { loadSSHConfig } from "./integrations/ssh/config"; +import { ProxmoxIntegration } from "./integrations/proxmox/ProxmoxIntegration"; import type { IntegrationConfig } from "./integrations/types"; import { LoggerService } from "./services/LoggerService"; import { PerformanceMonitorService } from "./services/PerformanceMonitorService"; @@ -678,6 +679,91 @@ async function startServer(): Promise { operation: "initializeSSH", }); + // Initialize Proxmox integration only if configured + let proxmoxPlugin: ProxmoxIntegration | undefined; + const proxmoxConfig = config.integrations.proxmox; + const proxmoxConfigured = proxmoxConfig?.enabled === true; + + logger.debug("=== Proxmox Integration Setup ===", { + component: "Server", + operation: "initializeProxmox", + metadata: { + configured: proxmoxConfigured, + enabled: proxmoxConfig?.enabled, + hasHost: !!proxmoxConfig?.host, + }, + }); + + if (proxmoxConfigured && proxmoxConfig) { + logger.info("Initializing Proxmox integration...", { + component: "Server", + operation: "initializeProxmox", + }); + try { + proxmoxPlugin = new ProxmoxIntegration(logger, performanceMonitor); + logger.debug("ProxmoxIntegration instance created", { + component: "Server", + operation: "initializeProxmox", + }); + + const integrationConfig: IntegrationConfig = { + enabled: true, + name: "proxmox", + type: "both", + config: proxmoxConfig as unknown as Record, + priority: proxmoxConfig.priority ?? 7, // Default 7: between Bolt/PuppetDB (10) and Hiera (6) + }; + + logger.debug("Registering Proxmox plugin", { + component: "Server", + operation: "initializeProxmox", + metadata: { config: integrationConfig }, + }); + integrationManager.registerPlugin( + proxmoxPlugin, + integrationConfig, + ); + + logger.info("Proxmox integration registered successfully", { + component: "Server", + operation: "initializeProxmox", + metadata: { + enabled: true, + host: proxmoxConfig.host, + port: proxmoxConfig.port ?? 8006, + hasToken: !!proxmoxConfig.token, + hasPassword: !!proxmoxConfig.password, + priority: proxmoxConfig.priority ?? 7, + }, + }); + } catch (error) { + logger.warn(`WARNING: Failed to initialize Proxmox integration: ${error instanceof Error ? error.message : "Unknown error"}`, { + component: "Server", + operation: "initializeProxmox", + }); + if (error instanceof Error && error.stack) { + logger.error("Proxmox initialization error stack", { + component: "Server", + operation: "initializeProxmox", + }, error); + } + proxmoxPlugin = undefined; + } + } else { + logger.warn("Proxmox integration not configured - skipping registration", { + component: "Server", + operation: "initializeProxmox", + }); + logger.info("Set PROXMOX_ENABLED=true and PROXMOX_HOST to enable Proxmox integration", { + component: "Server", + operation: "initializeProxmox", + }); + } + logger.debug("=== End Proxmox Integration Setup ===", { + component: "Server", + operation: "initializeProxmox", + }); + // Initialize all registered plugins logger.info("=== Initializing All Integration Plugins ===", { component: "Server", diff --git a/backend/test-migration.db-shm b/backend/test-migration.db-shm new file mode 100644 index 0000000..fe9ac28 Binary files /dev/null and b/backend/test-migration.db-shm differ diff --git a/backend/test-migration.db-wal b/backend/test-migration.db-wal new file mode 100644 index 0000000..e69de29 diff --git a/backend/test/integrations/IntegrationManager.test.ts b/backend/test/integrations/IntegrationManager.test.ts index a685f00..d82afc4 100644 --- a/backend/test/integrations/IntegrationManager.test.ts +++ b/backend/test/integrations/IntegrationManager.test.ts @@ -812,7 +812,7 @@ describe("IntegrationManager", () => { expect(inventory.sources.bad.status).toBe("unavailable"); }); - it("should deduplicate nodes by ID", async () => { + it("should deduplicate nodes by ID and track all sources", async () => { const node: Node = { id: "node1", name: "node1", @@ -845,10 +845,14 @@ describe("IntegrationManager", () => { const inventory = await manager.getAggregatedInventory(); expect(inventory.nodes).toHaveLength(1); - // Should prefer node from higher priority source (source2) - expect((inventory.nodes[0] as Node & { source?: string }).source).toBe( - "source2", - ); + // Should track all sources + expect(inventory.nodes[0].sources).toEqual(expect.arrayContaining(["source1", "source2"])); + expect(inventory.nodes[0].sources).toHaveLength(2); + // Should preserve source-specific data for each source + expect(inventory.nodes[0].sourceData["source1"]).toBeDefined(); + expect(inventory.nodes[0].sourceData["source2"]).toBeDefined(); + // Should mark as linked since it exists in multiple sources + expect(inventory.nodes[0].linked).toBe(true); }); }); @@ -1728,4 +1732,220 @@ describe("IntegrationManager", () => { // by checking that the next call fetches fresh data }); }); + + describe("Provisioning Capabilities", () => { + it("should return empty array when no plugins have provisioning capabilities", () => { + const logger = new LoggerService(); + const manager = new IntegrationManager({ logger }); + + const tool = new MockExecutionTool("tool", logger); + + manager.registerPlugin(tool, { + enabled: true, + name: "tool", + type: "execution", + config: {}, + }); + + const capabilities = manager.getAllProvisioningCapabilities(); + expect(capabilities).toEqual([]); + }); + + it("should return provisioning capabilities from plugins that support them", () => { + const logger = new LoggerService(); + const manager = new IntegrationManager({ logger }); + + // Create a mock plugin with provisioning capabilities + class MockProvisioningTool extends BasePlugin implements ExecutionToolPlugin { + constructor(name: string, logger: LoggerService) { + super(name, "execution", logger); + } + + protected async performInitialization(): Promise { + // Mock initialization + } + + protected async performHealthCheck(): Promise> { + return { + healthy: true, + message: "Mock provisioning tool is healthy", + }; + } + + async executeAction(_action: Action): Promise { + return { + success: true, + output: "Mock execution", + }; + } + + listCapabilities() { + return []; + } + + listProvisioningCapabilities() { + return [ + { + name: "create_vm", + description: "Create a new virtual machine", + operation: "create" as const, + parameters: [ + { name: "name", type: "string", required: true }, + { name: "memory", type: "number", required: false, default: 512 }, + ], + }, + { + name: "destroy_vm", + description: "Destroy a virtual machine", + operation: "destroy" as const, + parameters: [ + { name: "vmid", type: "number", required: true }, + ], + }, + ]; + } + } + + const tool = new MockProvisioningTool("proxmox", logger); + + manager.registerPlugin(tool, { + enabled: true, + name: "proxmox", + type: "execution", + config: {}, + }); + + const capabilities = manager.getAllProvisioningCapabilities(); + + expect(capabilities).toHaveLength(1); + expect(capabilities[0].source).toBe("proxmox"); + expect(capabilities[0].capabilities).toHaveLength(2); + expect(capabilities[0].capabilities[0].name).toBe("create_vm"); + expect(capabilities[0].capabilities[0].operation).toBe("create"); + expect(capabilities[0].capabilities[1].name).toBe("destroy_vm"); + expect(capabilities[0].capabilities[1].operation).toBe("destroy"); + }); + + it("should aggregate provisioning capabilities from multiple plugins", () => { + const logger = new LoggerService(); + const manager = new IntegrationManager({ logger }); + + // Create two mock plugins with provisioning capabilities + class MockProvisioningTool1 extends BasePlugin implements ExecutionToolPlugin { + constructor(name: string, logger: LoggerService) { + super(name, "execution", logger); + } + + protected async performInitialization(): Promise {} + protected async performHealthCheck(): Promise> { + return { healthy: true, message: "Healthy" }; + } + async executeAction(_action: Action): Promise { + return { success: true, output: "Mock" }; + } + listCapabilities() { + return []; + } + listProvisioningCapabilities() { + return [ + { + name: "create_vm", + description: "Create VM", + operation: "create" as const, + parameters: [], + }, + ]; + } + } + + class MockProvisioningTool2 extends BasePlugin implements ExecutionToolPlugin { + constructor(name: string, logger: LoggerService) { + super(name, "execution", logger); + } + + protected async performInitialization(): Promise {} + protected async performHealthCheck(): Promise> { + return { healthy: true, message: "Healthy" }; + } + async executeAction(_action: Action): Promise { + return { success: true, output: "Mock" }; + } + listCapabilities() { + return []; + } + listProvisioningCapabilities() { + return [ + { + name: "create_container", + description: "Create container", + operation: "create" as const, + parameters: [], + }, + ]; + } + } + + const tool1 = new MockProvisioningTool1("proxmox", logger); + const tool2 = new MockProvisioningTool2("docker", logger); + + manager.registerPlugin(tool1, { + enabled: true, + name: "proxmox", + type: "execution", + config: {}, + }); + + manager.registerPlugin(tool2, { + enabled: true, + name: "docker", + type: "execution", + config: {}, + }); + + const capabilities = manager.getAllProvisioningCapabilities(); + + expect(capabilities).toHaveLength(2); + expect(capabilities.find(c => c.source === "proxmox")).toBeDefined(); + expect(capabilities.find(c => c.source === "docker")).toBeDefined(); + }); + + it("should handle errors when getting provisioning capabilities", () => { + const logger = new LoggerService(); + const manager = new IntegrationManager({ logger }); + + // Create a mock plugin that throws an error + class MockFailingTool extends BasePlugin implements ExecutionToolPlugin { + constructor(name: string, logger: LoggerService) { + super(name, "execution", logger); + } + + protected async performInitialization(): Promise {} + protected async performHealthCheck(): Promise> { + return { healthy: true, message: "Healthy" }; + } + async executeAction(_action: Action): Promise { + return { success: true, output: "Mock" }; + } + listCapabilities() { + return []; + } + listProvisioningCapabilities() { + throw new Error("Failed to get provisioning capabilities"); + } + } + + const tool = new MockFailingTool("failing", logger); + + manager.registerPlugin(tool, { + enabled: true, + name: "failing", + type: "execution", + config: {}, + }); + + // Should not throw, should return empty array + const capabilities = manager.getAllProvisioningCapabilities(); + expect(capabilities).toEqual([]); + }); + }); }); diff --git a/backend/test/integrations/NodeLinkingService.test.ts b/backend/test/integrations/NodeLinkingService.test.ts index 729f1d8..d8d0d7e 100644 --- a/backend/test/integrations/NodeLinkingService.test.ts +++ b/backend/test/integrations/NodeLinkingService.test.ts @@ -240,6 +240,78 @@ describe("NodeLinkingService", () => { expect(linkedNodes[0].sources).toEqual(["bolt"]); expect(linkedNodes[0].linked).toBe(false); }); + + it("should store source-specific data for each source", () => { + // Test that source-specific IDs and URIs are preserved + const nodes: Node[] = [ + { + id: "debian13.test.example42.com", + name: "debian13.test.example42.com", + uri: "ssh://debian13.test.example42.com", + transport: "ssh", + config: {}, + source: "bolt", + } as Node & { source: string }, + { + id: "proxmox:minis:100", + name: "debian13.test.example42.com", + uri: "proxmox://minis/100", + transport: "ssh", + config: {}, + source: "proxmox", + metadata: { + vmid: 100, + node: "minis", + type: "qemu", + status: "running", + }, + } as Node & { source: string; metadata: Record }, + { + id: "debian13.test.example42.com", + name: "debian13.test.example42.com", + uri: "ssh://debian13.test.example42.com", + transport: "ssh", + config: {}, + source: "puppetdb", + } as Node & { source: string }, + ]; + + const linkedNodes = service.linkNodes(nodes); + + // Should have only one linked node + expect(linkedNodes).toHaveLength(1); + + const linkedNode = linkedNodes[0]; + + // Primary ID should be the name (common identifier) + expect(linkedNode.id).toBe("debian13.test.example42.com"); + expect(linkedNode.name).toBe("debian13.test.example42.com"); + + // Should include all sources + expect(linkedNode.sources).toContain("proxmox"); + expect(linkedNode.sources).toContain("bolt"); + expect(linkedNode.sources).toContain("puppetdb"); + expect(linkedNode.sources).toHaveLength(3); + + // Should be marked as linked + expect(linkedNode.linked).toBe(true); + + // Should have source-specific data + expect(linkedNode.sourceData).toBeDefined(); + expect(linkedNode.sourceData.proxmox).toBeDefined(); + expect(linkedNode.sourceData.proxmox.id).toBe("proxmox:minis:100"); + expect(linkedNode.sourceData.proxmox.uri).toBe("proxmox://minis/100"); + expect(linkedNode.sourceData.proxmox.metadata).toBeDefined(); + expect(linkedNode.sourceData.proxmox.metadata?.vmid).toBe(100); + + expect(linkedNode.sourceData.bolt).toBeDefined(); + expect(linkedNode.sourceData.bolt.id).toBe("debian13.test.example42.com"); + expect(linkedNode.sourceData.bolt.uri).toBe("ssh://debian13.test.example42.com"); + + expect(linkedNode.sourceData.puppetdb).toBeDefined(); + expect(linkedNode.sourceData.puppetdb.id).toBe("debian13.test.example42.com"); + expect(linkedNode.sourceData.puppetdb.uri).toBe("ssh://debian13.test.example42.com"); + }); }); describe("findMatchingNodes", () => { diff --git a/backend/vitest.config.ts b/backend/vitest.config.ts index 9a1863d..d27a3a8 100644 --- a/backend/vitest.config.ts +++ b/backend/vitest.config.ts @@ -4,7 +4,7 @@ export default defineConfig({ test: { globals: true, environment: 'node', - include: ['test/**/*.test.ts', 'src/integrations/ssh/__tests__/**/*.test.ts', 'src/integrations/ansible/__tests__/**/*.test.ts'], + include: ['test/**/*.test.ts', 'src/integrations/ssh/__tests__/**/*.test.ts', 'src/integrations/ansible/__tests__/**/*.test.ts', 'src/integrations/proxmox/__tests__/**/*.test.ts'], exclude: ['node_modules', 'dist'], env: { NODE_ENV: 'test', diff --git a/docs/development/BACKEND_CODE_ANALYSIS.md b/docs/development/BACKEND_CODE_ANALYSIS.md index f86d320..3f0245d 100644 --- a/docs/development/BACKEND_CODE_ANALYSIS.md +++ b/docs/development/BACKEND_CODE_ANALYSIS.md @@ -378,14 +378,22 @@ The Pabawi backend is a Node.js/Express/TypeScript infrastructure management sys - `getConnection()` - Get SQLite connection - `close()` - Close connection - `isInitialized()` - Check if DB is ready -- `initializeSchema()` - Create tables from schema.sql -- `runMigrations()` - Apply migrations from migrations.sql +- `initializeSchema()` - Runs all numbered migrations from migrations/ directory +- `runMigrations()` - Apply numbered migrations using MigrationRunner **Key Files:** -- Reads: `schema.sql`, `migrations.sql` +- Migrations: `migrations/*.sql` (all schema definitions, starting from 000) - Creates database at path from config +**Schema Management Policy (Migration-First):** + +- ALL schema definitions are in numbered migrations (000, 001, 002, etc.) +- Migration 000: Initial schema (executions, revoked_tokens) +- Migration 001: RBAC tables (users, roles, permissions, groups) +- Future changes: Always create a new numbered migration +- Never modify existing migrations after they've been applied + **Relationships:** - Used by: ExecutionRepository, all database operations diff --git a/docs/integrations/proxmox.md b/docs/integrations/proxmox.md new file mode 100644 index 0000000..a768c83 --- /dev/null +++ b/docs/integrations/proxmox.md @@ -0,0 +1,647 @@ +# Proxmox Integration + +The Proxmox integration enables Pabawi to manage Proxmox Virtual Environment (VE) infrastructure, including virtual machines (VMs) and Linux containers (LXC). This integration provides inventory discovery, lifecycle management, and provisioning capabilities for your Proxmox cluster. + +## Features + +- **Inventory Discovery**: Automatically discover all VMs and containers across your Proxmox cluster +- **Group Management**: Organize resources by node, status, and type +- **Facts Retrieval**: Get detailed configuration and status information for any guest +- **Lifecycle Actions**: Start, stop, shutdown, reboot, suspend, and resume VMs and containers +- **Provisioning**: Create and destroy VMs and LXC containers programmatically +- **Health Monitoring**: Monitor the health and connectivity of your Proxmox cluster + +## Configuration + +### Basic Configuration + +Add the Proxmox integration to your Pabawi configuration: + +```typescript +{ + integrations: { + proxmox: { + enabled: true, + name: 'proxmox', + type: 'both', + priority: 10, + config: { + host: 'proxmox.example.com', + port: 8006, + token: 'user@realm!tokenid=uuid' + } + } + } +} +``` + +### Configuration Options + +| Option | Type | Required | Default | Description | +|--------|------|----------|---------|-------------| +| `host` | string | Yes | - | Proxmox server hostname or IP address | +| `port` | number | No | 8006 | Proxmox API port | +| `token` | string | No* | - | API token for authentication (recommended) | +| `username` | string | No* | - | Username for password authentication | +| `password` | string | No* | - | Password for password authentication | +| `realm` | string | No | - | Authentication realm (required for password auth) | +| `ssl.rejectUnauthorized` | boolean | No | true | Verify TLS certificates | +| `ssl.ca` | string | No | - | Path to custom CA certificate | +| `ssl.cert` | string | No | - | Path to client certificate | +| `ssl.key` | string | No | - | Path to client certificate key | +| `timeout` | number | No | 30000 | Request timeout in milliseconds | + +*Either `token` or `username`/`password` must be provided. + +### Environment Variables + +You can use environment variables for sensitive configuration: + +```bash +# Required +PROXMOX_HOST=proxmox.example.com +PROXMOX_PORT=8006 + +# Token authentication (recommended) +PROXMOX_TOKEN=user@realm!tokenid=uuid + +# OR password authentication +PROXMOX_USERNAME=root +PROXMOX_PASSWORD=secret +PROXMOX_REALM=pam + +# Optional SSL configuration +PROXMOX_SSL_VERIFY=true +PROXMOX_CA_CERT=/path/to/ca.pem +PROXMOX_CLIENT_CERT=/path/to/client.pem +PROXMOX_CLIENT_KEY=/path/to/client-key.pem +``` + +Then reference them in your configuration: + +```typescript +{ + integrations: { + proxmox: { + enabled: true, + name: 'proxmox', + type: 'both', + config: { + host: process.env.PROXMOX_HOST, + port: parseInt(process.env.PROXMOX_PORT || '8006'), + token: process.env.PROXMOX_TOKEN, + ssl: { + rejectUnauthorized: process.env.PROXMOX_SSL_VERIFY !== 'false', + ca: process.env.PROXMOX_CA_CERT, + cert: process.env.PROXMOX_CLIENT_CERT, + key: process.env.PROXMOX_CLIENT_KEY + } + } + } + } +} +``` + +## Authentication + +### Token Authentication (Recommended) + +Token authentication is more secure and provides fine-grained permission control. + +#### Creating an API Token + +1. Log in to your Proxmox web interface +2. Navigate to **Datacenter → Permissions → API Tokens** +3. Click **Add** to create a new token +4. Select the user and enter a token ID +5. Optionally disable **Privilege Separation** for full user permissions +6. Click **Add** and copy the generated token +7. The token format is: `user@realm!tokenid=uuid` + +#### Required Permissions + +Grant the following permissions to the token user: + +- `VM.Allocate` - Create VMs and containers +- `VM.Config.*` - Configure VMs and containers +- `VM.PowerMgmt` - Start, stop, and manage power state +- `VM.Audit` - Read VM information +- `Datastore.Allocate` - Allocate disk space + +#### Configuration Example + +```typescript +config: { + host: 'proxmox.example.com', + port: 8006, + token: 'automation@pve!api-token=12345678-1234-1234-1234-123456789abc' +} +``` + +### Password Authentication + +Password authentication uses username and password to obtain a temporary authentication ticket. + +#### Configuration Example + +```typescript +config: { + host: 'proxmox.example.com', + port: 8006, + username: 'root', + password: 'your-secure-password', + realm: 'pam' +} +``` + +#### Available Realms + +- `pam` - Linux PAM authentication +- `pve` - Proxmox VE authentication + +**Note**: Authentication tickets expire after 2 hours by default. The integration automatically refreshes tickets when they expire. + +## Inventory Discovery + +The Proxmox integration automatically discovers all VMs and containers in your cluster. + +### Node Format + +Each discovered guest is represented as a Node with the following format: + +```typescript +{ + id: 'proxmox:node-name:vmid', + name: 'vm-name', + status: 'running' | 'stopped' | 'paused', + ip: '192.168.1.100', // Optional + metadata: { + node: 'node-name', + type: 'qemu' | 'lxc', + vmid: 100, + source: 'proxmox' + } +} +``` + +### Groups + +Resources are automatically organized into groups: + +- **By Node**: `proxmox:node:node-name` - All guests on a specific Proxmox node +- **By Status**: `proxmox:status:running` - All guests with a specific status +- **By Type**: `proxmox:type:qemu` or `proxmox:type:lxc` - All VMs or all containers + +### Caching + +Inventory data is cached for 60 seconds to reduce API load. Groups are also cached for 60 seconds. + +## Facts Retrieval + +Get detailed information about a specific VM or container: + +```typescript +const facts = await integrationManager.getNodeFacts('proxmox:node1:100'); +``` + +Facts include: + +- CPU configuration (cores, sockets, CPU type) +- Memory configuration (total, current usage) +- Disk configuration (size, usage) +- Network configuration (interfaces, IP addresses) +- Current status and uptime +- Resource usage statistics (when running) + +Facts are cached for 30 seconds. + +## Lifecycle Actions + +### Supported Actions + +| Action | Description | Applies To | +|--------|-------------|------------| +| `start` | Start a VM or container | VMs, LXC | +| `stop` | Force stop a VM or container | VMs, LXC | +| `shutdown` | Gracefully shutdown a VM or container | VMs, LXC | +| `reboot` | Reboot a VM or container | VMs, LXC | +| `suspend` | Suspend a VM (save state to disk) | VMs only | +| `resume` | Resume a suspended VM | VMs only | + +### Action Examples + +#### Start a VM + +```typescript +const result = await integrationManager.executeAction({ + type: 'lifecycle', + target: 'proxmox:node1:100', + action: 'start', + parameters: {} +}); +``` + +#### Graceful Shutdown + +```typescript +const result = await integrationManager.executeAction({ + type: 'lifecycle', + target: 'proxmox:node1:100', + action: 'shutdown', + parameters: {} +}); +``` + +#### Suspend a VM + +```typescript +const result = await integrationManager.executeAction({ + type: 'lifecycle', + target: 'proxmox:node1:100', + action: 'suspend', + parameters: {} +}); +``` + +### Action Results + +All actions return an `ExecutionResult`: + +```typescript +{ + success: true, + output: 'VM started successfully', + metadata: { + vmid: 100, + node: 'node1' + } +} +``` + +## Provisioning + +### Create a Virtual Machine + +```typescript +const result = await integrationManager.executeAction({ + type: 'provision', + action: 'create_vm', + parameters: { + vmid: 100, + name: 'my-vm', + node: 'node1', + cores: 2, + memory: 2048, + disk: 'local-lvm:32', + network: { + model: 'virtio', + bridge: 'vmbr0' + } + } +}); +``` + +#### VM Creation Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `vmid` | number | Yes | - | Unique VM ID (100-999999999) | +| `name` | string | Yes | - | VM name | +| `node` | string | Yes | - | Target Proxmox node | +| `cores` | number | No | 1 | Number of CPU cores | +| `memory` | number | No | 512 | Memory in MB | +| `sockets` | number | No | 1 | Number of CPU sockets | +| `cpu` | string | No | - | CPU type (e.g., 'host') | +| `disk` | string | No | - | Disk configuration (e.g., 'local-lvm:32') | +| `network` | object | No | - | Network configuration | +| `ostype` | string | No | - | OS type (e.g., 'l26' for Linux 2.6+) | + +### Create an LXC Container + +```typescript +const result = await integrationManager.executeAction({ + type: 'provision', + action: 'create_lxc', + parameters: { + vmid: 101, + hostname: 'my-container', + node: 'node1', + ostemplate: 'local:vztmpl/ubuntu-22.04-standard_22.04-1_amd64.tar.zst', + cores: 1, + memory: 512, + rootfs: 'local-lvm:8', + network: { + name: 'eth0', + bridge: 'vmbr0', + ip: 'dhcp' + } + } +}); +``` + +#### LXC Creation Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `vmid` | number | Yes | - | Unique container ID (100-999999999) | +| `hostname` | string | Yes | - | Container hostname | +| `node` | string | Yes | - | Target Proxmox node | +| `ostemplate` | string | Yes | - | OS template path | +| `cores` | number | No | 1 | Number of CPU cores | +| `memory` | number | No | 512 | Memory in MB | +| `rootfs` | string | No | - | Root filesystem (e.g., 'local-lvm:8') | +| `network` | object | No | - | Network configuration | +| `password` | string | No | - | Root password | + +### Destroy a Guest + +```typescript +const result = await integrationManager.executeAction({ + type: 'provision', + action: 'destroy_vm', // or 'destroy_lxc' + parameters: { + vmid: 100, + node: 'node1' + } +}); +``` + +**Note**: If the guest is running, it will be automatically stopped before deletion. + +## Health Monitoring + +Check the health of the Proxmox integration: + +```typescript +const health = await integrationManager.healthCheckAll(); +const proxmoxHealth = health.get('proxmox'); + +console.log(proxmoxHealth); +// { +// healthy: true, +// message: 'Proxmox API is reachable', +// details: { version: '7.4-3' }, +// lastCheck: 1234567890 +// } +``` + +### Health States + +- **Healthy**: API is reachable and responding +- **Degraded**: Authentication issues detected +- **Unhealthy**: API is unreachable or returning errors + +Health check results are cached for 30 seconds. + +## Error Handling + +### Error Types + +The integration provides specific error types for different failure scenarios: + +- `ProxmoxAuthenticationError` - Authentication failures (401, 403) +- `ProxmoxConnectionError` - Network connectivity issues +- `ProxmoxError` - General API errors + +### Common Errors + +#### Authentication Failed + +``` +ProxmoxAuthenticationError: Failed to authenticate with Proxmox API +``` + +**Solution**: Verify your credentials or token are correct and have not expired. + +#### Guest Not Found + +``` +ProxmoxError: Guest 100 not found on node node1 +``` + +**Solution**: Verify the VMID and node name are correct. + +#### VMID Already Exists + +``` +VM with VMID 100 already exists on node node1 +``` + +**Solution**: Choose a different VMID or destroy the existing guest first. + +#### Connection Timeout + +``` +ProxmoxConnectionError: Request timeout after 30000ms +``` + +**Solution**: Check network connectivity to the Proxmox server or increase the timeout value. + +### Retry Logic + +The integration automatically retries transient failures: + +- Network timeouts (ETIMEDOUT) +- Connection resets (ECONNRESET) +- DNS resolution failures (ENOTFOUND) +- Rate limiting (429) +- Server errors (5xx) + +Retry configuration: + +- Maximum attempts: 3 +- Initial delay: 1 second +- Exponential backoff with 2x multiplier +- Maximum delay: 10 seconds + +## Troubleshooting + +### Connection Issues + +**Problem**: Cannot connect to Proxmox API + +**Solutions**: + +1. Verify the host and port are correct +2. Check firewall rules allow access to port 8006 +3. Ensure Proxmox API is enabled and running +4. Test connectivity: `curl -k https://proxmox.example.com:8006/api2/json/version` + +### Authentication Issues + +**Problem**: Authentication fails with valid credentials + +**Solutions**: + +1. For token auth: Verify the token format is `user@realm!tokenid=uuid` +2. For password auth: Verify the realm is correct (`pam` or `pve`) +3. Check user permissions in Proxmox +4. Verify the user account is not locked or expired + +### SSL Certificate Issues + +**Problem**: SSL certificate verification fails + +**Solutions**: + +1. For self-signed certificates, provide the CA certificate path: + + ```typescript + ssl: { + ca: '/path/to/ca.pem' + } + ``` + +2. For testing only, disable certificate verification: + + ```typescript + ssl: { + rejectUnauthorized: false + } + ``` + + **Warning**: This is insecure and should not be used in production. + +### Permission Issues + +**Problem**: Operations fail with permission denied errors + +**Solutions**: + +1. Verify the user has the required permissions: + - `VM.Allocate` for creating VMs + - `VM.PowerMgmt` for lifecycle actions + - `VM.Config.*` for configuration changes +2. Check permissions at both user and token level +3. Ensure permissions are set on the correct path (/, /vms/, etc.) + +### Performance Issues + +**Problem**: Slow response times or timeouts + +**Solutions**: + +1. Check Proxmox server load and performance +2. Increase cache TTL to reduce API calls +3. Increase timeout value in configuration +4. Use token authentication instead of password authentication +5. Monitor network latency between Pabawi and Proxmox + +### Task Timeout + +**Problem**: Long-running operations timeout + +**Solutions**: + +1. Increase the timeout value in configuration +2. Check Proxmox task logs for the specific operation +3. Verify sufficient resources are available on the target node +4. For VM creation, ensure the storage is not slow or full + +## Best Practices + +### Security + +1. **Use Token Authentication**: More secure than password authentication +2. **Enable Certificate Verification**: Always verify TLS certificates in production +3. **Least Privilege**: Grant only required permissions to API tokens +4. **Rotate Credentials**: Regularly rotate API tokens and passwords +5. **Secure Storage**: Store credentials in environment variables or secure vaults + +### Performance + +1. **Use Caching**: Default cache TTLs are optimized for most use cases +2. **Batch Operations**: When possible, perform multiple operations in parallel +3. **Monitor Health**: Regularly check integration health to detect issues early +4. **Connection Pooling**: The integration reuses connections automatically + +### Reliability + +1. **Handle Errors**: Always check `ExecutionResult.success` before proceeding +2. **Retry Logic**: The integration handles transient failures automatically +3. **Health Checks**: Monitor health status and alert on failures +4. **Logging**: Enable debug logging for troubleshooting + +### Operations + +1. **Test First**: Test provisioning operations in a development environment +2. **Unique VMIDs**: Use a VMID allocation strategy to avoid conflicts +3. **Resource Limits**: Monitor Proxmox cluster resources before provisioning +4. **Backup**: Always backup important VMs before destructive operations + +## API Reference + +### Integration Methods + +#### getInventory() + +Returns all VMs and containers in the Proxmox cluster. + +```typescript +const nodes = await proxmoxIntegration.getInventory(); +``` + +#### getGroups() + +Returns groups organized by node, status, and type. + +```typescript +const groups = await proxmoxIntegration.getGroups(); +``` + +#### getNodeFacts(nodeId: string) + +Returns detailed facts for a specific guest. + +```typescript +const facts = await proxmoxIntegration.getNodeFacts('proxmox:node1:100'); +``` + +#### executeAction(action: Action) + +Executes a lifecycle or provisioning action. + +```typescript +const result = await proxmoxIntegration.executeAction({ + type: 'lifecycle', + target: 'proxmox:node1:100', + action: 'start', + parameters: {} +}); +``` + +#### listCapabilities() + +Returns available lifecycle actions. + +```typescript +const capabilities = proxmoxIntegration.listCapabilities(); +``` + +#### listProvisioningCapabilities() + +Returns available provisioning operations. + +```typescript +const capabilities = proxmoxIntegration.listProvisioningCapabilities(); +``` + +#### performHealthCheck() + +Checks the health of the Proxmox connection. + +```typescript +const health = await proxmoxIntegration.performHealthCheck(); +``` + +## Examples + +See the [Configuration Examples](../examples/proxmox-examples.md) document for complete working examples. + +## Support + +For issues, questions, or contributions: + +- GitHub Issues: [pabawi/issues](https://github.com/pabawi/pabawi/issues) +- Documentation: [pabawi.dev/docs](https://pabawi.dev/docs) +- Proxmox API Docs: [pve.proxmox.com/pve-docs/api-viewer](https://pve.proxmox.com/pve-docs/api-viewer/) diff --git a/docs/manage-tab-guide.md b/docs/manage-tab-guide.md new file mode 100644 index 0000000..90dd486 --- /dev/null +++ b/docs/manage-tab-guide.md @@ -0,0 +1,851 @@ +# Manage Tab Usage Guide + +## Overview + +The Manage tab on the Node Detail page provides lifecycle management controls for virtual machines and containers. This guide explains how to use the Manage tab to start, stop, reboot, and destroy resources through the Pabawi interface. + +## Table of Contents + +- [Accessing the Manage Tab](#accessing-the-manage-tab) +- [Understanding the Interface](#understanding-the-interface) +- [Lifecycle Actions](#lifecycle-actions) +- [Action Availability](#action-availability) +- [Destructive Actions](#destructive-actions) +- [Monitoring Operations](#monitoring-operations) +- [Best Practices](#best-practices) +- [Troubleshooting](#troubleshooting) + +## Accessing the Manage Tab + +### Prerequisites + +Before you can use the Manage tab: + +- **Management Permissions**: Your user account must have lifecycle management permissions +- **Configured Integration**: The resource must be managed by a configured integration (e.g., Proxmox) +- **Resource Access**: You must have access to the specific resource + +### Navigation + +1. **From Inventory**: + - Navigate to the Inventory page + - Click on a VM or container + - The Node Detail page opens + +2. **Locate the Manage Tab**: + - Look for the tab navigation on the Node Detail page + - Tabs may include: Overview, Facts, Manage, Reports + - Click on the **"Manage"** tab + +3. **Permission-Based Access**: + - The Manage tab only appears if you have lifecycle permissions + - If you don't see the tab, you may lack management permissions + - Contact your administrator for access + +## Understanding the Interface + +### Page Layout + +The Manage tab consists of several sections: + +1. **Resource Status**: + - Current state of the resource (running, stopped, suspended) + - Status indicator (green, red, yellow) + - Last updated timestamp + +2. **Available Actions**: + - Action buttons for lifecycle operations + - Buttons are enabled/disabled based on resource state + - Only actions you have permission for are shown + +3. **Action History** (optional): + - Recent actions performed on this resource + - Action status and timestamps + - Links to execution details + +4. **Resource Information**: + - Resource type (VM or LXC) + - Integration managing the resource + - Node location + +### Status Indicators + +**Resource States**: + +- **Running** (Green): Resource is active and operational +- **Stopped** (Red): Resource is powered off +- **Suspended** (Yellow): VM is suspended (saved to disk) +- **Paused** (Yellow): VM is paused (saved to memory) +- **Unknown** (Gray): Status cannot be determined + +**Action Status**: + +- **Available**: Button is enabled and clickable +- **Unavailable**: Button is disabled (grayed out) +- **In Progress**: Loading indicator, all buttons disabled +- **Hidden**: Action not available for this resource type or state + +## Lifecycle Actions + +### Start Action + +**Purpose**: Power on a stopped VM or container + +**When Available**: + +- Resource state is "stopped" +- You have `lifecycle:start` permission + +**How to Use**: + +1. Verify resource is stopped (red status indicator) +2. Click the **"Start"** button +3. Wait for operation to complete (typically 5-30 seconds) +4. Success notification appears +5. Resource status updates to "running" + +**What Happens**: + +- VM/container boots up +- Operating system starts +- Network interfaces activate +- Services start automatically + +**Use Cases**: + +- Starting a VM after maintenance +- Bringing a container online +- Recovering from a shutdown + +**Example**: + +``` +Resource: web-server-01 +Current State: Stopped +Action: Start +Result: Resource started successfully +New State: Running +``` + +### Stop Action + +**Purpose**: Force power off a running VM or container + +**When Available**: + +- Resource state is "running" +- You have `lifecycle:stop` permission + +**How to Use**: + +1. Verify resource is running (green status indicator) +2. Click the **"Stop"** button +3. Wait for operation to complete (typically 5-15 seconds) +4. Success notification appears +5. Resource status updates to "stopped" + +**What Happens**: + +- VM/container is immediately powered off +- Similar to pulling the power plug +- No graceful shutdown +- May cause data loss if not saved + +**Warning**: This is a forced stop. Use "Shutdown" for graceful shutdown. + +**Use Cases**: + +- Emergency stop +- Unresponsive VM/container +- When graceful shutdown fails + +**Example**: + +``` +Resource: app-server-02 +Current State: Running +Action: Stop +Result: Resource stopped successfully +New State: Stopped +``` + +### Shutdown Action + +**Purpose**: Gracefully shut down a running VM or container + +**When Available**: + +- Resource state is "running" +- You have `lifecycle:shutdown` permission + +**How to Use**: + +1. Verify resource is running (green status indicator) +2. Click the **"Shutdown"** button +3. Wait for operation to complete (typically 30-120 seconds) +4. Success notification appears +5. Resource status updates to "stopped" + +**What Happens**: + +- Shutdown signal sent to guest OS +- Operating system performs graceful shutdown +- Services stop cleanly +- Data is saved +- VM/container powers off + +**Advantages**: + +- Safe shutdown process +- No data loss +- Services stop cleanly +- Recommended method + +**Use Cases**: + +- Normal shutdown operations +- Maintenance preparation +- Before taking snapshots +- Planned downtime + +**Example**: + +``` +Resource: database-prod +Current State: Running +Action: Shutdown +Result: Resource shutdown successfully +New State: Stopped +Duration: 45 seconds +``` + +### Reboot Action + +**Purpose**: Restart a running VM or container + +**When Available**: + +- Resource state is "running" +- You have `lifecycle:reboot` permission + +**How to Use**: + +1. Verify resource is running (green status indicator) +2. Click the **"Reboot"** button +3. Wait for operation to complete (typically 30-90 seconds) +4. Success notification appears +5. Resource status remains "running" (after reboot) + +**What Happens**: + +- Reboot signal sent to guest OS +- Operating system performs graceful reboot +- Services restart +- VM/container comes back online + +**Use Cases**: + +- Applying system updates +- Clearing memory issues +- Restarting services +- Configuration changes + +**Example**: + +``` +Resource: web-server-03 +Current State: Running +Action: Reboot +Result: Resource rebooted successfully +New State: Running +Duration: 60 seconds +``` + +### Suspend Action + +**Purpose**: Suspend a running VM (save state to disk) + +**When Available**: + +- Resource type is VM (not available for LXC) +- Resource state is "running" +- You have `lifecycle:suspend` permission + +**How to Use**: + +1. Verify resource is a VM and running +2. Click the **"Suspend"** button +3. Wait for operation to complete (typically 10-60 seconds) +4. Success notification appears +5. Resource status updates to "suspended" + +**What Happens**: + +- VM state saved to disk +- Memory contents written to storage +- VM powered off +- Can be resumed later with exact state + +**Advantages**: + +- Faster than shutdown/start +- Preserves exact state +- No boot time when resuming +- Applications remain open + +**Use Cases**: + +- Temporary pause +- Saving work state +- Quick maintenance +- Resource conservation + +**Example**: + +``` +Resource: dev-workstation +Current State: Running +Action: Suspend +Result: VM suspended successfully +New State: Suspended +Duration: 25 seconds +``` + +### Resume Action + +**Purpose**: Resume a suspended VM + +**When Available**: + +- Resource type is VM +- Resource state is "suspended" +- You have `lifecycle:resume` permission + +**How to Use**: + +1. Verify resource is suspended (yellow status indicator) +2. Click the **"Resume"** button +3. Wait for operation to complete (typically 5-30 seconds) +4. Success notification appears +5. Resource status updates to "running" + +**What Happens**: + +- VM state restored from disk +- Memory contents loaded +- VM resumes exactly where it left off +- Applications continue running + +**Advantages**: + +- Instant resume +- No boot process +- Applications remain open +- Work state preserved + +**Use Cases**: + +- Resuming after suspend +- Quick return to work +- Continuing interrupted tasks + +**Example**: + +``` +Resource: dev-workstation +Current State: Suspended +Action: Resume +Result: VM resumed successfully +New State: Running +Duration: 15 seconds +``` + +### Destroy Action + +**Purpose**: Permanently delete a VM or container + +**When Available**: + +- Any resource state +- You have `lifecycle:destroy` permission + +**How to Use**: + +1. Click the **"Destroy"** button +2. **Confirmation dialog appears**: + - Shows resource name and ID + - Warns about permanent deletion + - Requires explicit confirmation +3. Review the warning carefully +4. Click **"Confirm"** to proceed or **"Cancel"** to abort +5. If confirmed, wait for operation to complete +6. Success notification appears +7. Redirected away from node detail page + +**What Happens**: + +- VM/container is stopped (if running) +- All data is deleted +- Disk images removed +- Configuration deleted +- Resource removed from inventory + +**Warning**: This action is permanent and cannot be undone! + +**Use Cases**: + +- Decommissioning resources +- Cleaning up test environments +- Removing failed deployments +- Freeing up resources + +**Safety Features**: + +- Confirmation dialog required +- Resource name displayed for verification +- Cannot be performed accidentally +- Logged for audit purposes + +**Example**: + +``` +Resource: test-vm-temp +Current State: Stopped +Action: Destroy +Confirmation: "Are you sure you want to destroy test-vm-temp (ID: 105)?" +User Action: Confirm +Result: Resource destroyed successfully +``` + +## Action Availability + +### State-Based Availability + +Actions are only available when the resource is in an appropriate state: + +**When Stopped**: + +- ✓ Start +- ✗ Stop +- ✗ Shutdown +- ✗ Reboot +- ✗ Suspend +- ✗ Resume +- ✓ Destroy + +**When Running**: + +- ✗ Start +- ✓ Stop +- ✓ Shutdown +- ✓ Reboot +- ✓ Suspend (VMs only) +- ✗ Resume +- ✓ Destroy + +**When Suspended**: + +- ✗ Start +- ✗ Stop +- ✗ Shutdown +- ✗ Reboot +- ✗ Suspend +- ✓ Resume +- ✓ Destroy + +### Permission-Based Availability + +Actions are only visible if you have the required permission: + +**Required Permissions**: + +- Start: `lifecycle:start` +- Stop: `lifecycle:stop` +- Shutdown: `lifecycle:shutdown` +- Reboot: `lifecycle:reboot` +- Suspend: `lifecycle:suspend` +- Resume: `lifecycle:resume` +- Destroy: `lifecycle:destroy` + +**Permission Wildcards**: + +- `lifecycle:*` - All lifecycle actions +- `*:lifecycle:*` - All lifecycle actions on all integrations + +### Resource Type Restrictions + +Some actions are only available for specific resource types: + +**VM Only**: + +- Suspend +- Resume + +**Both VM and LXC**: + +- Start +- Stop +- Shutdown +- Reboot +- Destroy + +### Integration-Specific Actions + +Different integrations may support different actions: + +**Proxmox**: + +- All actions supported + +**Future Integrations**: + +- EC2: Start, Stop, Reboot, Terminate +- Azure: Start, Stop, Restart, Delete +- May have integration-specific actions + +## Destructive Actions + +### Understanding Destructive Actions + +**Destructive Actions** are operations that permanently delete data or resources: + +- **Destroy**: Permanently deletes VM/container + +**Non-Destructive Actions**: + +- Start, Stop, Shutdown, Reboot, Suspend, Resume + +### Safety Mechanisms + +**Confirmation Dialogs**: + +- Required for all destructive actions +- Display resource name and ID +- Show warning message +- Require explicit confirmation +- Cannot be bypassed + +**Visual Indicators**: + +- Destroy button styled differently (red) +- Warning icons displayed +- Confirmation dialog uses warning colors + +**Audit Logging**: + +- All destructive actions logged +- User, timestamp, and resource recorded +- Available for compliance and auditing + +### Best Practices for Destructive Actions + +**Before Destroying**: + +1. **Verify Resource**: + - Confirm you have the correct resource + - Check resource name and ID + - Review resource details + +2. **Backup Data**: + - Take snapshots if needed + - Backup important data + - Export configurations + +3. **Check Dependencies**: + - Verify no other resources depend on this one + - Check for network dependencies + - Review application dependencies + +4. **Communicate**: + - Notify team members + - Update documentation + - Record the action + +**During Destruction**: + +1. Read confirmation dialog carefully +2. Verify resource name matches +3. Confirm you want to proceed +4. Wait for operation to complete +5. Don't interrupt the process + +**After Destruction**: + +1. Verify resource is removed +2. Check inventory is updated +3. Update documentation +4. Notify stakeholders + +## Monitoring Operations + +### Real-Time Feedback + +During lifecycle operations: + +1. **Loading Indicators**: + - All action buttons disabled + - Spinner or progress indicator appears + - Status shows "Operation in progress" + +2. **Status Updates**: + - Operation progress displayed + - Current step shown (if available) + - Estimated time remaining + +3. **Completion Notifications**: + - Success: Green toast notification + - Failure: Red toast notification with error + - Auto-dismiss (success) or manual dismiss (errors) + +### Status Refresh + +After operations complete: + +1. **Automatic Refresh**: + - Resource status automatically updates + - New state reflected in UI + - Available actions update + +2. **Manual Refresh**: + - Click refresh button if needed + - Reload page to force update + - Check execution history + +### Execution History + +View past operations: + +1. **On Node Detail Page**: + - Scroll to Execution History section + - View recent operations on this resource + - Filter by action type + +2. **On Executions Page**: + - Navigate to Executions from main menu + - Filter by node name + - View detailed execution logs + +## Best Practices + +### Planning Operations + +**Before Performing Actions**: + +1. **Verify Resource State**: + - Check current status + - Ensure resource is in expected state + - Review recent changes + +2. **Check Dependencies**: + - Identify dependent services + - Check for active connections + - Review application dependencies + +3. **Plan Timing**: + - Choose appropriate time window + - Consider user impact + - Schedule during maintenance windows + +4. **Communicate**: + - Notify affected users + - Update team members + - Document planned actions + +### Safe Operations + +**Operational Safety**: + +1. **Use Graceful Actions**: + - Prefer Shutdown over Stop + - Allow time for graceful shutdown + - Don't force stop unless necessary + +2. **Monitor Progress**: + - Watch for completion + - Check for errors + - Verify expected outcome + +3. **Verify Results**: + - Confirm resource is in expected state + - Test functionality after changes + - Check dependent services + +4. **Document Actions**: + - Record what was done + - Note any issues + - Update runbooks + +### Emergency Procedures + +**When Things Go Wrong**: + +1. **Unresponsive Resource**: + - Try graceful shutdown first + - Wait reasonable time + - Use force stop if necessary + - Document the issue + +2. **Failed Operations**: + - Review error message + - Check resource state + - Try again if appropriate + - Contact administrator if needed + +3. **Unexpected Behavior**: + - Don't panic + - Document what happened + - Check logs + - Seek help if needed + +## Troubleshooting + +### Problem: Manage Tab Not Visible + +**Symptoms**: + +- Manage tab missing from Node Detail page +- Cannot access lifecycle actions + +**Solutions**: + +1. **Check Permissions**: + - Verify you have lifecycle permissions + - Contact administrator for access + - Review your assigned roles + +2. **Check Resource Type**: + - Verify resource is managed by an integration + - Check integration is configured + - Ensure integration is connected + +3. **Refresh Page**: + - Reload the page + - Clear browser cache + - Log out and log back in + +### Problem: All Action Buttons Disabled + +**Symptoms**: + +- Action buttons appear but are grayed out +- Cannot click any actions +- No actions available message + +**Solutions**: + +1. **Check Resource State**: + - Verify resource state allows actions + - Example: Can't start a running VM + - Wait for current operation to complete + +2. **Check Permissions**: + - Verify you have required permissions + - Check specific action permissions + - Contact administrator if needed + +3. **Check Integration Health**: + - Verify integration is connected + - Test integration connectivity + - Check for integration errors + +### Problem: Action Fails with Error + +**Symptoms**: + +``` +Error: Failed to start VM +Error: Operation timeout +Error: Resource not found +``` + +**Solutions**: + +1. **Review Error Message**: + - Read error carefully + - Look for specific error codes + - Note any suggested actions + +2. **Check Resource State**: + - Verify resource exists + - Check resource is accessible + - Ensure resource is in expected state + +3. **Check Integration**: + - Verify integration is connected + - Test integration health + - Check integration logs + +4. **Retry Operation**: + - Wait a moment + - Try again + - Contact administrator if persists + +### Problem: Operation Hangs + +**Symptoms**: + +- Operation never completes +- Loading indicator stays forever +- No error or success message + +**Solutions**: + +1. **Wait Longer**: + - Some operations take time + - Shutdown can take 2-3 minutes + - Check resource directly if possible + +2. **Check Resource**: + - Navigate to integration directly (e.g., Proxmox web UI) + - Verify operation status + - Check for errors + +3. **Refresh Page**: + - Reload the page + - Check if operation completed + - Review execution history + +4. **Contact Administrator**: + - Report the issue + - Provide operation details + - Include error messages + +### Problem: Destroy Confirmation Not Appearing + +**Symptoms**: + +- Clicked Destroy but nothing happens +- No confirmation dialog +- Action seems to do nothing + +**Solutions**: + +1. **Check Browser**: + - Disable popup blockers + - Allow dialogs from Pabawi + - Try different browser + +2. **Check JavaScript**: + - Ensure JavaScript is enabled + - Check browser console for errors + - Clear browser cache + +3. **Refresh Page**: + - Reload the page + - Try action again + - Log out and log back in + +## Related Documentation + +- [Provisioning Guide](provisioning-guide.md) - How to create VMs and containers +- [Permissions and RBAC](permissions-rbac.md) - Permission requirements +- [Proxmox Integration](integrations/proxmox.md) - Proxmox-specific details +- [Troubleshooting Guide](troubleshooting.md) - General troubleshooting + +## Support + +For additional help: + +- **Documentation**: [pabawi.dev/docs](https://pabawi.dev/docs) +- **GitHub Issues**: [pabawi/issues](https://github.com/pabawi/pabawi/issues) +- **Administrator**: Contact your Pabawi administrator for assistance diff --git a/docs/permissions-rbac.md b/docs/permissions-rbac.md new file mode 100644 index 0000000..48ccc6b --- /dev/null +++ b/docs/permissions-rbac.md @@ -0,0 +1,736 @@ +# Permissions and RBAC Guide + +## Overview + +Pabawi implements Role-Based Access Control (RBAC) to manage user permissions for provisioning and infrastructure management operations. This guide explains the permission system, required permissions for each action, and how permissions affect the user interface. + +## Table of Contents + +- [Understanding RBAC](#understanding-rbac) +- [Permission Levels](#permission-levels) +- [Provisioning Permissions](#provisioning-permissions) +- [Management Permissions](#management-permissions) +- [UI Visibility Rules](#ui-visibility-rules) +- [Permission Enforcement](#permission-enforcement) +- [Configuring Permissions](#configuring-permissions) +- [Troubleshooting](#troubleshooting) + +## Understanding RBAC + +### What is RBAC? + +Role-Based Access Control (RBAC) is a security model that restricts system access based on user roles. In Pabawi: + +- **Users** are assigned **Roles** +- **Roles** contain **Permissions** +- **Permissions** grant access to specific **Actions** + +### Key Concepts + +**User**: + +- Individual account in Pabawi +- Can have one or more roles +- Inherits permissions from all assigned roles + +**Role**: + +- Named collection of permissions +- Examples: Administrator, Operator, Viewer +- Can be assigned to multiple users + +**Permission**: + +- Specific authorization to perform an action +- Examples: `provision:create_vm`, `vm:start`, `vm:destroy` +- Granular control over operations + +**Action**: + +- Specific operation in the system +- Examples: Create VM, Start VM, View Inventory +- Requires corresponding permission + +### Permission Model + +Pabawi uses a hierarchical permission model: + +``` +Integration Level + └─ Action Type + └─ Specific Action + └─ Resource Type (optional) +``` + +Examples: + +- `proxmox:provision:create_vm` - Create VMs via Proxmox +- `proxmox:lifecycle:start` - Start VMs/containers +- `proxmox:lifecycle:destroy` - Destroy VMs/containers +- `*:provision:*` - All provisioning actions on all integrations +- `*:*:*` - All actions (administrator) + +## Permission Levels + +### Administrator + +**Full Access**: All permissions across all integrations + +**Permissions**: + +- `*:*:*` (wildcard - all actions) + +**Capabilities**: + +- Create, modify, and delete VMs and containers +- Start, stop, and manage all resources +- Configure integrations +- Manage user permissions +- Access all features + +**UI Access**: + +- All menu items visible +- All actions available +- No restrictions + +### Operator + +**Operational Access**: Provision and manage resources + +**Permissions**: + +- `*:provision:*` - All provisioning actions +- `*:lifecycle:*` - All lifecycle actions +- `*:inventory:read` - View inventory + +**Capabilities**: + +- Create VMs and containers +- Start, stop, reboot resources +- View inventory and facts +- Cannot destroy resources +- Cannot configure integrations + +**UI Access**: + +- Provision menu visible +- Manage tab visible (limited actions) +- Setup menu hidden + +### Viewer + +**Read-Only Access**: View resources only + +**Permissions**: + +- `*:inventory:read` - View inventory +- `*:facts:read` - View facts + +**Capabilities**: + +- View inventory +- View node details +- View facts +- Cannot modify anything + +**UI Access**: + +- Inventory menu visible +- Node detail pages visible (read-only) +- Provision menu hidden +- Manage tab hidden + +### Custom Roles + +Organizations can create custom roles with specific permission combinations: + +**Example: VM Manager**: + +- `proxmox:provision:create_vm` - Create VMs only +- `proxmox:lifecycle:start` - Start VMs +- `proxmox:lifecycle:stop` - Stop VMs +- `proxmox:lifecycle:reboot` - Reboot VMs + +**Example: Container Manager**: + +- `proxmox:provision:create_lxc` - Create containers only +- `proxmox:lifecycle:*` - All lifecycle actions for containers + +**Example: Development Team**: + +- `proxmox:provision:*` - All provisioning (dev environment only) +- `proxmox:lifecycle:*` - All lifecycle actions +- `proxmox:lifecycle:destroy` - Can destroy resources + +## Provisioning Permissions + +### Create VM Permission + +**Permission**: `:provision:create_vm` + +**Grants Access To**: + +- Provision page (VM tab) +- VM creation form +- Submit VM creation requests + +**Required For**: + +- Creating new virtual machines +- Accessing VM provisioning interface + +**UI Impact**: + +- Provision menu item appears (if any provision permission exists) +- VM tab visible on Provision page +- VM creation form enabled + +**Example**: + +``` +proxmox:provision:create_vm +ec2:provision:create_vm +*:provision:create_vm (all integrations) +``` + +### Create LXC Permission + +**Permission**: `:provision:create_lxc` + +**Grants Access To**: + +- Provision page (LXC tab) +- LXC creation form +- Submit LXC creation requests + +**Required For**: + +- Creating new LXC containers +- Accessing LXC provisioning interface + +**UI Impact**: + +- Provision menu item appears (if any provision permission exists) +- LXC tab visible on Provision page +- LXC creation form enabled + +**Example**: + +``` +proxmox:provision:create_lxc +*:provision:create_lxc (all integrations) +``` + +### General Provisioning Permission + +**Permission**: `:provision:*` + +**Grants Access To**: + +- All provisioning actions for the integration +- Both VM and LXC creation +- Future provisioning capabilities + +**Required For**: + +- Full provisioning access +- Creating any resource type + +**UI Impact**: + +- Provision menu item appears +- All provisioning tabs visible +- All creation forms enabled + +**Example**: + +``` +proxmox:provision:* +*:provision:* (all integrations, all resource types) +``` + +## Management Permissions + +### Lifecycle Actions + +#### Start Permission + +**Permission**: `:lifecycle:start` + +**Grants Access To**: + +- Start button on Manage tab +- Start action for stopped VMs/containers + +**Required For**: + +- Starting stopped resources + +**UI Impact**: + +- Start button visible when resource is stopped +- Start action enabled in action menu + +#### Stop Permission + +**Permission**: `:lifecycle:stop` + +**Grants Access To**: + +- Stop button on Manage tab +- Force stop action for running VMs/containers + +**Required For**: + +- Stopping running resources (forced) + +**UI Impact**: + +- Stop button visible when resource is running +- Stop action enabled in action menu + +#### Shutdown Permission + +**Permission**: `:lifecycle:shutdown` + +**Grants Access To**: + +- Shutdown button on Manage tab +- Graceful shutdown action + +**Required For**: + +- Gracefully shutting down resources + +**UI Impact**: + +- Shutdown button visible when resource is running +- Shutdown action enabled in action menu + +#### Reboot Permission + +**Permission**: `:lifecycle:reboot` + +**Grants Access To**: + +- Reboot button on Manage tab +- Reboot action for running VMs/containers + +**Required For**: + +- Rebooting resources + +**UI Impact**: + +- Reboot button visible when resource is running +- Reboot action enabled in action menu + +#### Suspend/Resume Permissions + +**Permission**: + +- `:lifecycle:suspend` +- `:lifecycle:resume` + +**Grants Access To**: + +- Suspend button (VMs only) +- Resume button (suspended VMs) + +**Required For**: + +- Suspending running VMs +- Resuming suspended VMs + +**UI Impact**: + +- Suspend button visible when VM is running +- Resume button visible when VM is suspended + +#### Destroy Permission + +**Permission**: `:lifecycle:destroy` + +**Grants Access To**: + +- Destroy button on Manage tab +- Delete VM/container action +- Confirmation dialog + +**Required For**: + +- Permanently deleting resources + +**UI Impact**: + +- Destroy button visible (with confirmation) +- Destroy action enabled in action menu +- Warning indicators shown + +**Security Note**: This is a destructive action. Grant carefully. + +### General Lifecycle Permission + +**Permission**: `:lifecycle:*` + +**Grants Access To**: + +- All lifecycle actions for the integration +- Start, stop, reboot, suspend, resume, destroy + +**Required For**: + +- Full lifecycle management access + +**UI Impact**: + +- Manage tab visible +- All action buttons visible (based on resource state) +- All lifecycle operations enabled + +**Example**: + +``` +proxmox:lifecycle:* +*:lifecycle:* (all integrations) +``` + +## UI Visibility Rules + +### Menu Items + +**Provision Menu**: + +- **Visible**: User has any `*:provision:*` permission +- **Hidden**: User has no provisioning permissions + +**Inventory Menu**: + +- **Visible**: User has `*:inventory:read` permission +- **Hidden**: User has no inventory read permission + +**Setup Menu**: + +- **Visible**: User has administrator role +- **Hidden**: Non-administrator users + +### Page Elements + +**Provision Page**: + +- **VM Tab**: Visible if user has `*:provision:create_vm` +- **LXC Tab**: Visible if user has `*:provision:create_lxc` +- **Integration Selector**: Shows only integrations user can access + +**Node Detail Page**: + +- **Manage Tab**: Visible if user has any `*:lifecycle:*` permission +- **Facts Section**: Visible if user has `*:facts:read` permission +- **Configuration Section**: Always visible (read-only) + +**Manage Tab**: + +- **Action Buttons**: Only visible if user has corresponding permission +- **Destroy Button**: Only visible if user has `*:lifecycle:destroy` +- **No Actions Message**: Shown if user has no lifecycle permissions + +### Form Elements + +**Provisioning Forms**: + +- **Submit Button**: Enabled only if user has create permission +- **Form Fields**: All visible (validation applies) +- **Integration Dropdown**: Shows only permitted integrations + +**Action Buttons**: + +- **Enabled**: User has permission and resource state allows action +- **Disabled**: User lacks permission or resource state prevents action +- **Hidden**: User has no related permissions + +## Permission Enforcement + +### Frontend Enforcement + +**UI-Level Security**: + +- Menu items hidden based on permissions +- Buttons disabled or hidden +- Forms not rendered without permissions +- Provides user-friendly experience + +**Limitations**: + +- Not a security boundary +- Can be bypassed by API calls +- Relies on backend enforcement + +### Backend Enforcement + +**API-Level Security**: + +- All API endpoints check permissions +- Requests without permission return 403 Forbidden +- Cannot be bypassed +- True security boundary + +**Enforcement Points**: + +1. **Authentication**: Verify user is logged in +2. **Authorization**: Check user has required permission +3. **Resource Access**: Verify user can access specific resource +4. **Action Execution**: Validate permission before executing + +**Error Responses**: + +```json +{ + "error": { + "code": "PERMISSION_DENIED", + "message": "User does not have permission to perform this action", + "requiredPermission": "proxmox:provision:create_vm" + } +} +``` + +### Permission Checks + +**Before Every Action**: + +1. Extract user from authentication token +2. Load user's roles and permissions +3. Check if user has required permission +4. Allow or deny action +5. Log authorization decision + +**Permission Matching**: + +- Exact match: `proxmox:provision:create_vm` +- Wildcard integration: `*:provision:create_vm` +- Wildcard action: `proxmox:provision:*` +- Full wildcard: `*:*:*` + +## Configuring Permissions + +### User Management + +**Creating Users**: + +1. Navigate to Admin → Users +2. Click "Add User" +3. Enter user details +4. Assign roles +5. Save + +**Assigning Roles**: + +1. Navigate to user details +2. Click "Edit Roles" +3. Select roles from list +4. Save changes +5. User inherits all role permissions + +### Role Management + +**Creating Roles**: + +1. Navigate to Admin → Roles +2. Click "Add Role" +3. Enter role name and description +4. Select permissions +5. Save + +**Editing Roles**: + +1. Navigate to role details +2. Click "Edit Permissions" +3. Add or remove permissions +4. Save changes +5. All users with role get updated permissions + +### Permission Syntax + +**Format**: `::` + +**Components**: + +- **Integration**: `proxmox`, `ec2`, `azure`, or `*` (all) +- **Action Type**: `provision`, `lifecycle`, `inventory`, `facts`, or `*` (all) +- **Specific Action**: `create_vm`, `start`, `destroy`, or `*` (all) + +**Examples**: + +``` +proxmox:provision:create_vm # Specific permission +proxmox:provision:* # All provisioning on Proxmox +*:provision:create_vm # Create VMs on any integration +proxmox:lifecycle:* # All lifecycle actions on Proxmox +*:*:* # All permissions (admin) +``` + +### Best Practices + +**Principle of Least Privilege**: + +- Grant minimum permissions needed +- Start with restrictive permissions +- Add permissions as needed +- Review permissions regularly + +**Role Design**: + +- Create roles for job functions +- Don't create user-specific roles +- Use descriptive role names +- Document role purposes + +**Permission Auditing**: + +- Review permissions quarterly +- Remove unused permissions +- Check for over-privileged users +- Log permission changes + +**Separation of Duties**: + +- Separate provisioning and destruction +- Different roles for different environments +- Require approval for sensitive actions + +## Troubleshooting + +### Problem: "Permission Denied" Error + +**Symptoms**: + +```json +{ + "error": { + "code": "PERMISSION_DENIED", + "message": "User does not have permission to perform this action" + } +} +``` + +**Solutions**: + +1. **Check User Roles**: + - Navigate to user profile + - Review assigned roles + - Verify roles are active + +2. **Check Role Permissions**: + - Navigate to role details + - Review permissions list + - Verify required permission is included + +3. **Check Permission Format**: + - Verify permission syntax is correct + - Check for typos + - Ensure wildcards are used correctly + +4. **Contact Administrator**: + - Request required permission + - Explain use case + - Provide error details + +### Problem: Menu Items Not Visible + +**Symptoms**: + +- Provision menu missing +- Manage tab not showing +- Expected features hidden + +**Solutions**: + +1. **Verify Permissions**: + - Check user has required permissions + - Review role assignments + - Confirm permissions are active + +2. **Check Integration Status**: + - Verify integration is enabled + - Check integration is connected + - Test integration health + +3. **Clear Browser Cache**: + - Clear browser cache and cookies + - Refresh the page + - Log out and log back in + +4. **Check Permission Propagation**: + - Permissions may take time to propagate + - Wait a few minutes + - Refresh the page + +### Problem: Action Buttons Disabled + +**Symptoms**: + +- Buttons appear but are disabled +- Cannot click action buttons +- Grayed out controls + +**Solutions**: + +1. **Check Resource State**: + - Verify resource is in correct state for action + - Example: Can't start a running VM + - Check resource status + +2. **Check Permissions**: + - Verify user has permission for specific action + - Check role includes required permission + - Review permission wildcards + +3. **Check Integration Health**: + - Verify integration is connected + - Test integration connectivity + - Check for integration errors + +### Problem: Inconsistent Permissions + +**Symptoms**: + +- Permissions work in some places but not others +- Inconsistent UI behavior +- Some actions allowed, others denied + +**Solutions**: + +1. **Check Permission Wildcards**: + - Verify wildcard usage is correct + - Check for conflicting permissions + - Review permission hierarchy + +2. **Check Multiple Roles**: + - User may have multiple roles + - Permissions are combined + - Check all assigned roles + +3. **Check Backend Logs**: + - Review authorization logs + - Look for permission check failures + - Identify specific permission issues + +4. **Verify Permission Sync**: + - Ensure frontend and backend are in sync + - Check for caching issues + - Restart services if needed + +## Related Documentation + +- [Provisioning Guide](provisioning-guide.md) - How to use provisioning features +- [Manage Tab Guide](manage-tab-guide.md) - How to manage resources +- [Proxmox Setup Guide](proxmox-setup-guide.md) - Configure Proxmox integration +- [User Guide](user-guide.md) - General Pabawi usage + +## Support + +For additional help: + +- **Documentation**: [pabawi.dev/docs](https://pabawi.dev/docs) +- **GitHub Issues**: [pabawi/issues](https://github.com/pabawi/pabawi/issues) +- **Administrator**: Contact your Pabawi administrator for permission requests diff --git a/docs/provisioning-guide.md b/docs/provisioning-guide.md new file mode 100644 index 0000000..19d5397 --- /dev/null +++ b/docs/provisioning-guide.md @@ -0,0 +1,617 @@ +# Provisioning Guide + +## Overview + +The Provisioning page in Pabawi allows you to create new virtual machines (VMs) and Linux containers (LXC) through integrated provisioning systems. This guide covers how to access and use the provisioning interface to deploy new infrastructure resources. + +## Table of Contents + +- [Accessing the Provision Page](#accessing-the-provision-page) +- [Understanding the Interface](#understanding-the-interface) +- [Creating Virtual Machines](#creating-virtual-machines) +- [Creating LXC Containers](#creating-lxc-containers) +- [Monitoring Provisioning Operations](#monitoring-provisioning-operations) +- [Best Practices](#best-practices) +- [Troubleshooting](#troubleshooting) + +## Accessing the Provision Page + +### Prerequisites + +Before you can access the Provision page, you need: + +- **Provisioning Permissions**: Your user account must have provisioning permissions +- **Configured Integration**: At least one provisioning integration (e.g., Proxmox) must be configured and connected +- **Available Resources**: The target infrastructure must have available resources (CPU, memory, storage) + +### Navigation + +1. **From the Main Menu**: + - Look for the **"Provision"** menu item in the top navigation bar + - Click on "Provision" to access the provisioning interface + - If you don't see this menu item, you may lack provisioning permissions + +2. **Permission-Based Access**: + - The Provision menu item only appears for users with provisioning permissions + - Contact your administrator if you need access + +## Understanding the Interface + +### Page Layout + +The Provision page consists of several key sections: + +1. **Integration Selector** (if multiple integrations available): + - Dropdown or tabs to switch between different provisioning systems + - Shows available integrations (Proxmox, EC2, Azure, etc.) + - Displays connection status for each integration + +2. **Provisioning Forms**: + - Tabbed interface for different resource types (VM, LXC) + - Form fields for configuration parameters + - Real-time validation feedback + - Submit button (enabled when form is valid) + +3. **Status Indicators**: + - Loading indicators during operations + - Success/error notifications + - Progress feedback + +### Available Integrations + +The page automatically discovers and displays available provisioning integrations: + +- **Proxmox**: Create VMs and LXC containers on Proxmox Virtual Environment +- **EC2** (future): Create AWS EC2 instances +- **Azure** (future): Create Azure virtual machines +- **Terraform** (future): Deploy infrastructure as code + +Only integrations that are configured and connected will appear. + +## Creating Virtual Machines + +### VM Creation Workflow + +#### Step 1: Select VM Tab + +1. Navigate to the Provision page +2. If multiple integrations are available, select your target integration (e.g., Proxmox) +3. Click on the **"VM"** tab to access the VM creation form + +#### Step 2: Configure Required Parameters + +**VM ID (Required)**: + +- Unique identifier for the VM +- Must be between 100 and 999999999 +- Cannot conflict with existing VMs +- Example: `100`, `1001`, `5000` + +**VM Name (Required)**: + +- Descriptive name for the VM +- Used for identification and management +- Should be meaningful and follow your naming convention +- Example: `web-server-01`, `database-prod`, `app-staging` + +**Target Node (Required)**: + +- Proxmox node where the VM will be created +- Select from available nodes in your cluster +- Consider resource availability and location +- Example: `pve1`, `node-01`, `proxmox-host` + +#### Step 3: Configure Optional Parameters + +**CPU Configuration**: + +- **Cores**: Number of CPU cores (default: 1) + - Range: 1-128 (depending on host) + - Example: `2`, `4`, `8` +- **Sockets**: Number of CPU sockets (default: 1) + - Usually 1 for most workloads + - Example: `1`, `2` +- **CPU Type**: CPU model to emulate + - Options: `host`, `kvm64`, `qemu64` + - `host` provides best performance + - Example: `host` + +**Memory Configuration**: + +- **Memory**: RAM in megabytes (default: 512) + - Minimum: 512 MB + - Example: `2048` (2 GB), `4096` (4 GB), `8192` (8 GB) + +**Storage Configuration**: + +- **Disk (scsi0)**: Primary disk configuration + - Format: `storage:size` + - Example: `local-lvm:32` (32 GB on local-lvm storage) + - Example: `ceph-pool:100` (100 GB on Ceph storage) + +- **CD/DVD (ide2)**: ISO image for installation + - Format: `storage:iso/image.iso` + - Example: `local:iso/ubuntu-22.04-server.iso` + - Leave empty if not needed + +**Network Configuration**: + +- **Network (net0)**: Network interface configuration + - Format: `model=virtio,bridge=vmbr0` + - Example: `model=virtio,bridge=vmbr0,firewall=1` + - Common models: `virtio`, `e1000`, `rtl8139` + +**Operating System**: + +- **OS Type**: Operating system type + - Options: `l26` (Linux 2.6+), `win10`, `win11`, `other` + - Helps Proxmox optimize settings + - Example: `l26` + +#### Step 4: Review and Submit + +1. **Validate Configuration**: + - Check all required fields are filled + - Verify values are within acceptable ranges + - Review validation messages (if any) + +2. **Submit Creation Request**: + - Click the **"Create VM"** button + - A loading indicator appears + - Wait for the operation to complete + +3. **Review Results**: + - Success: Green notification with VM ID and details + - Failure: Red notification with error message + - Task ID for tracking the operation + +### VM Creation Examples + +**Example 1: Basic Web Server** + +``` +VM ID: 100 +Name: web-server-01 +Node: pve1 +Cores: 2 +Memory: 2048 +Disk: local-lvm:32 +Network: model=virtio,bridge=vmbr0 +OS Type: l26 +``` + +**Example 2: Database Server** + +``` +VM ID: 200 +Name: postgres-prod +Node: pve2 +Cores: 4 +Memory: 8192 +Sockets: 1 +CPU: host +Disk: ceph-pool:100 +Network: model=virtio,bridge=vmbr0,firewall=1 +OS Type: l26 +``` + +**Example 3: Windows Desktop** + +``` +VM ID: 300 +Name: win11-desktop +Node: pve1 +Cores: 4 +Memory: 8192 +Disk: local-lvm:64 +CD/DVD: local:iso/windows11.iso +Network: model=e1000,bridge=vmbr0 +OS Type: win11 +``` + +## Creating LXC Containers + +### LXC Creation Workflow + +#### Step 1: Select LXC Tab + +1. Navigate to the Provision page +2. Select your target integration (e.g., Proxmox) +3. Click on the **"LXC"** tab to access the container creation form + +#### Step 2: Configure Required Parameters + +**Container ID (Required)**: + +- Unique identifier for the container +- Must be between 100 and 999999999 +- Cannot conflict with existing containers or VMs +- Example: `101`, `1002`, `5001` + +**Hostname (Required)**: + +- Container hostname +- Must be lowercase alphanumeric with hyphens +- Used for network identification +- Example: `web-container`, `app-01`, `cache-server` + +**Target Node (Required)**: + +- Proxmox node where the container will be created +- Select from available nodes in your cluster +- Example: `pve1`, `node-01` + +**OS Template (Required)**: + +- Container template to use +- Format: `storage:vztmpl/template-name.tar.zst` +- Must exist on the target node +- Example: `local:vztmpl/ubuntu-22.04-standard_22.04-1_amd64.tar.zst` +- Example: `local:vztmpl/debian-11-standard_11.7-1_amd64.tar.zst` + +#### Step 3: Configure Optional Parameters + +**CPU Configuration**: + +- **Cores**: Number of CPU cores (default: 1) + - Range: 1-128 + - Example: `1`, `2`, `4` + +**Memory Configuration**: + +- **Memory**: RAM in megabytes (default: 512) + - Minimum: 512 MB + - Example: `512`, `1024`, `2048` + +**Storage Configuration**: + +- **Root Filesystem (rootfs)**: Root filesystem size + - Format: `storage:size` + - Example: `local-lvm:8` (8 GB) + - Example: `ceph-pool:16` (16 GB) + +**Network Configuration**: + +- **Network (net0)**: Network interface configuration + - Format: `name=eth0,bridge=vmbr0,ip=dhcp` + - Example: `name=eth0,bridge=vmbr0,ip=192.168.1.100/24,gw=192.168.1.1` + - Use `ip=dhcp` for automatic IP assignment + +**Security**: + +- **Root Password**: Root password for the container + - Optional but recommended + - Use a strong password + - Store securely + +#### Step 4: Review and Submit + +1. **Validate Configuration**: + - Check all required fields are filled + - Verify hostname format is correct + - Ensure template exists on target node + +2. **Submit Creation Request**: + - Click the **"Create LXC"** button + - A loading indicator appears + - Wait for the operation to complete + +3. **Review Results**: + - Success: Green notification with container ID + - Failure: Red notification with error message + - Task ID for tracking + +### LXC Creation Examples + +**Example 1: Basic Web Container** + +``` +Container ID: 101 +Hostname: web-container-01 +Node: pve1 +Template: local:vztmpl/ubuntu-22.04-standard_22.04-1_amd64.tar.zst +Cores: 1 +Memory: 1024 +Root FS: local-lvm:8 +Network: name=eth0,bridge=vmbr0,ip=dhcp +``` + +**Example 2: Application Container with Static IP** + +``` +Container ID: 102 +Hostname: app-backend +Node: pve2 +Template: local:vztmpl/debian-11-standard_11.7-1_amd64.tar.zst +Cores: 2 +Memory: 2048 +Root FS: ceph-pool:16 +Network: name=eth0,bridge=vmbr0,ip=192.168.1.50/24,gw=192.168.1.1 +Password: (set securely) +``` + +**Example 3: Development Container** + +``` +Container ID: 103 +Hostname: dev-env +Node: pve1 +Template: local:vztmpl/ubuntu-22.04-standard_22.04-1_amd64.tar.zst +Cores: 2 +Memory: 2048 +Root FS: local-lvm:16 +Network: name=eth0,bridge=vmbr0,ip=dhcp +``` + +## Monitoring Provisioning Operations + +### Real-Time Feedback + +During provisioning operations: + +1. **Loading Indicators**: + - Submit button becomes disabled + - Spinner or progress indicator appears + - Form fields are locked + +2. **Status Updates**: + - Operation progress displayed + - Estimated time remaining (if available) + - Current step in the process + +3. **Completion Notifications**: + - Success: Green toast notification with details + - Failure: Red toast notification with error + - Auto-dismiss after 5 seconds (success) or manual dismiss (errors) + +### Viewing Created Resources + +After successful provisioning: + +1. **Navigate to Inventory**: + - Click "Inventory" in the main menu + - New VM or container appears in the list + - May take a few moments to sync + +2. **Access Node Detail Page**: + - Click on the newly created resource + - View configuration and status + - Access management actions + +3. **Check Execution History**: + - View provisioning operation in execution history + - Review operation details and output + - Track task completion + +## Best Practices + +### Planning + +**Before Creating Resources**: + +1. **Plan Resource Allocation**: + - Determine CPU, memory, and storage requirements + - Check available resources on target nodes + - Consider future growth and scaling + +2. **Choose Appropriate IDs**: + - Use a consistent numbering scheme + - Document ID ranges for different purposes + - Example: 100-199 for web servers, 200-299 for databases + +3. **Follow Naming Conventions**: + - Use descriptive, meaningful names + - Include environment indicators (prod, dev, staging) + - Example: `web-prod-01`, `db-staging-02` + +4. **Select Appropriate Templates**: + - Use official, up-to-date templates + - Verify template compatibility with your needs + - Test templates in development first + +### Security + +**Secure Configuration**: + +1. **Use Strong Passwords**: + - Generate random, complex passwords + - Store passwords in a password manager + - Never hardcode passwords in scripts + +2. **Network Segmentation**: + - Place resources in appropriate network segments + - Use firewalls to restrict access + - Configure security groups properly + +3. **Minimal Permissions**: + - Grant only necessary permissions + - Use separate accounts for different purposes + - Audit permission usage regularly + +### Resource Management + +**Efficient Resource Usage**: + +1. **Right-Size Resources**: + - Don't over-provision CPU and memory + - Start small and scale up as needed + - Monitor resource utilization + +2. **Storage Planning**: + - Allocate appropriate disk space + - Use thin provisioning when possible + - Plan for backups and snapshots + +3. **Network Configuration**: + - Use DHCP for dynamic environments + - Use static IPs for servers + - Document IP allocations + +### Testing + +**Test Before Production**: + +1. **Development Environment**: + - Test provisioning in development first + - Verify configurations work as expected + - Document successful configurations + +2. **Validation**: + - Test VM/container starts successfully + - Verify network connectivity + - Check resource allocation + +3. **Documentation**: + - Document provisioning procedures + - Keep configuration templates + - Maintain inventory records + +## Troubleshooting + +### Common Issues + +#### Problem: "VMID already exists" + +**Symptoms**: + +``` +Error: VM with VMID 100 already exists on node pve1 +``` + +**Solutions**: + +1. Choose a different VMID +2. Check existing VMs: Navigate to Inventory and search +3. If the VM should be removed, delete it first via the Manage tab + +#### Problem: "Insufficient resources" + +**Symptoms**: + +``` +Error: Not enough memory available on node +Error: Storage full +``` + +**Solutions**: + +1. Check available resources on the target node +2. Choose a node with more available resources +3. Reduce resource allocation (CPU, memory, disk) +4. Clean up unused VMs or containers + +#### Problem: "Template not found" + +**Symptoms**: + +``` +Error: Template 'local:vztmpl/ubuntu-22.04.tar.zst' not found +``` + +**Solutions**: + +1. Verify the template name is correct +2. Check templates are downloaded on the target node +3. Download missing templates via Proxmox web interface +4. Use a different template that exists + +#### Problem: "Invalid hostname format" + +**Symptoms**: + +``` +Error: Hostname must contain only lowercase letters, numbers, and hyphens +``` + +**Solutions**: + +1. Use only lowercase letters (a-z) +2. Use numbers (0-9) +3. Use hyphens (-) but not at start or end +4. No underscores, spaces, or special characters +5. Example: `web-server-01` ✓, `Web_Server_01` ✗ + +#### Problem: "Network configuration error" + +**Symptoms**: + +``` +Error: Invalid network configuration +Error: Bridge 'vmbr1' does not exist +``` + +**Solutions**: + +1. Verify bridge name exists on target node +2. Check network configuration syntax +3. Use correct format: `model=virtio,bridge=vmbr0` +4. Consult Proxmox documentation for network options + +#### Problem: "Permission denied" + +**Symptoms**: + +``` +Error: User does not have permission to create VMs +Error: Insufficient privileges +``` + +**Solutions**: + +1. Contact your administrator for provisioning permissions +2. Verify your user account has the correct role +3. Check integration permissions are configured correctly + +#### Problem: "Operation timeout" + +**Symptoms**: + +``` +Error: Provisioning operation timed out +``` + +**Solutions**: + +1. Check target node is responsive +2. Verify network connectivity to Proxmox +3. Try again - the node may have been busy +4. Contact administrator if problem persists + +### Getting Help + +If you encounter issues not covered here: + +1. **Check Integration Status**: + - Navigate to Setup page + - Verify integration is connected + - Test connection + +2. **Review Error Messages**: + - Read error messages carefully + - Look for specific error codes + - Note any suggested actions + +3. **Check Logs**: + - Enable Expert Mode for detailed errors + - Review execution history + - Check Proxmox logs on the server + +4. **Contact Support**: + - Provide error messages + - Include configuration details (without sensitive data) + - Describe steps to reproduce + +## Related Documentation + +- [Proxmox Integration Setup](integrations/proxmox.md) - Configure Proxmox integration +- [Manage Tab Guide](manage-tab-guide.md) - Manage VM and container lifecycle +- [Permissions and RBAC](permissions-rbac.md) - Understand permission requirements +- [Troubleshooting Guide](troubleshooting.md) - General troubleshooting + +## Support + +For additional help: + +- **Documentation**: [pabawi.dev/docs](https://pabawi.dev/docs) +- **GitHub Issues**: [pabawi/issues](https://github.com/pabawi/pabawi/issues) +- **Proxmox Documentation**: [pve.proxmox.com/wiki](https://pve.proxmox.com/wiki) diff --git a/docs/proxmox-setup-guide.md b/docs/proxmox-setup-guide.md new file mode 100644 index 0000000..3807f1c --- /dev/null +++ b/docs/proxmox-setup-guide.md @@ -0,0 +1,737 @@ +# Proxmox Integration Setup Guide + +## Overview + +This guide walks you through configuring the Proxmox integration in Pabawi, enabling you to manage Proxmox Virtual Environment infrastructure through the web interface. The integration provides inventory discovery, lifecycle management, and provisioning capabilities for VMs and LXC containers. + +## Table of Contents + +- [Prerequisites](#prerequisites) +- [Configuration Methods](#configuration-methods) +- [Web Interface Setup](#web-interface-setup) +- [Environment Variable Setup](#environment-variable-setup) +- [Authentication Options](#authentication-options) +- [Testing the Connection](#testing-the-connection) +- [Troubleshooting](#troubleshooting) +- [Security Best Practices](#security-best-practices) + +## Prerequisites + +Before configuring the Proxmox integration, ensure you have: + +### Proxmox Requirements + +- **Proxmox VE**: Version 6.x or 7.x installed and running +- **API Access**: Proxmox API enabled (default on port 8006) +- **Network Access**: Pabawi server can reach Proxmox on port 8006 +- **Credentials**: Either API token or username/password for authentication + +### Pabawi Requirements + +- **Administrator Access**: You need administrator permissions in Pabawi +- **Integration Permissions**: Permission to configure integrations +- **Network Connectivity**: Pabawi server can reach Proxmox server + +### Proxmox Permissions + +The Proxmox user or token needs these permissions: + +- `VM.Allocate` - Create VMs and containers +- `VM.Config.*` - Configure VMs and containers +- `VM.PowerMgmt` - Start, stop, and manage power state +- `VM.Audit` - Read VM information +- `Datastore.Allocate` - Allocate disk space + +## Configuration Methods + +You can configure the Proxmox integration using two methods: + +1. **Web Interface** (Recommended): User-friendly form with validation and connection testing +2. **Environment Variables**: Configuration file for automated deployments + +Both methods achieve the same result. Choose based on your preference and deployment method. + +## Web Interface Setup + +### Step 1: Access Integration Setup + +1. **Log in to Pabawi**: + - Open your web browser + - Navigate to your Pabawi URL + - Log in with administrator credentials + +2. **Navigate to Setup Page**: + - Click on **"Setup"** or **"Integrations"** in the main menu + - Look for the **"Proxmox"** section + - Click to expand the Proxmox configuration form + +### Step 2: Configure Connection Settings + +**Host (Required)**: + +- Enter the Proxmox server hostname or IP address +- Do not include `https://` or port number +- Examples: + - `proxmox.example.com` + - `192.168.1.100` + - `pve.local` + +**Port (Required)**: + +- Default: `8006` +- Only change if you've customized Proxmox API port +- Must be between 1 and 65535 + +### Step 3: Choose Authentication Method + +You have two authentication options: + +#### Option A: API Token Authentication (Recommended) + +**Token (Required for this method)**: + +- Format: `user@realm!tokenid=uuid` +- Example: `automation@pve!api-token=12345678-1234-1234-1234-123456789abc` +- See [Creating an API Token](#creating-an-api-token) below + +**Advantages**: + +- More secure than password authentication +- Fine-grained permission control +- No password expiration issues +- Can be easily revoked + +#### Option B: Username/Password Authentication + +**Username (Required for this method)**: + +- Proxmox username +- Example: `root`, `admin`, `automation` + +**Password (Required for this method)**: + +- User's password +- Stored securely (encrypted) + +**Realm (Required for this method)**: + +- Authentication realm +- Options: + - `pam` - Linux PAM authentication + - `pve` - Proxmox VE authentication +- Default: `pam` + +**Note**: Authentication tickets expire after 2 hours. Pabawi automatically refreshes them. + +### Step 4: Configure SSL Options + +**Reject Unauthorized Certificates**: + +- Toggle: On (recommended) / Off +- When **On**: Verifies SSL certificates (secure) +- When **Off**: Accepts self-signed certificates (less secure) + +**Warning**: Disabling certificate verification is insecure and should only be used for testing. + +**For Self-Signed Certificates**: + +- Keep verification enabled +- Provide the CA certificate path (see Environment Variable Setup) +- Or add the certificate to your system's trust store + +### Step 5: Test Connection + +Before saving, test the connection: + +1. **Click "Test Connection"**: + - Button sends a test request to Proxmox + - Verifies credentials and connectivity + - Shows result message + +2. **Review Test Results**: + - **Success**: Green message "Connection successful" + - Proxmox version displayed + - Ready to save configuration + - **Failure**: Red message with error details + - Review error message + - Fix issues before saving + +3. **Common Test Errors**: + - "Connection refused": Check host and port + - "Authentication failed": Verify credentials + - "Certificate error": Check SSL settings + - "Timeout": Check network connectivity + +### Step 6: Save Configuration + +1. **Review All Settings**: + - Verify host and port are correct + - Confirm authentication method is configured + - Check SSL settings are appropriate + +2. **Click "Save Configuration"**: + - Button saves settings to backend + - Success message appears + - Integration becomes active + +3. **Verify Integration Status**: + - Integration status should show "Connected" + - Green indicator appears + - Ready to use + +## Environment Variable Setup + +For automated deployments or when you prefer configuration files: + +### Step 1: Edit Environment File + +1. **Locate the .env file**: + + ```bash + cd /path/to/pabawi + nano backend/.env + ``` + +2. **Add Proxmox Configuration**: + +### Step 2: Basic Configuration + +```bash +# Proxmox Integration +PROXMOX_ENABLED=true +PROXMOX_HOST=proxmox.example.com +PROXMOX_PORT=8006 +``` + +### Step 3: Choose Authentication Method + +**Option A: Token Authentication (Recommended)**: + +```bash +# Token format: user@realm!tokenid=uuid +PROXMOX_TOKEN=automation@pve!api-token=12345678-1234-1234-1234-123456789abc +``` + +**Option B: Username/Password Authentication**: + +```bash +PROXMOX_USERNAME=root +PROXMOX_PASSWORD=your-secure-password +PROXMOX_REALM=pam +``` + +### Step 4: SSL Configuration + +**For Production (Verified Certificates)**: + +```bash +PROXMOX_SSL_VERIFY=true +``` + +**For Self-Signed Certificates**: + +```bash +PROXMOX_SSL_VERIFY=true +PROXMOX_CA_CERT=/path/to/proxmox-ca.pem +``` + +**For Testing Only (Insecure)**: + +```bash +PROXMOX_SSL_VERIFY=false +``` + +### Step 5: Optional Advanced Settings + +```bash +# Request timeout (milliseconds) +PROXMOX_TIMEOUT=30000 + +# Client certificate authentication (optional) +PROXMOX_CLIENT_CERT=/path/to/client-cert.pem +PROXMOX_CLIENT_KEY=/path/to/client-key.pem +``` + +### Step 6: Restart Pabawi + +After editing the .env file: + +```bash +# For systemd +sudo systemctl restart pabawi + +# For Docker +docker-compose restart + +# For development +npm run dev:backend +``` + +## Authentication Options + +### Creating an API Token + +API tokens provide secure, fine-grained access control. + +#### Step 1: Access Proxmox Web Interface + +1. Open your browser +2. Navigate to `https://your-proxmox-host:8006` +3. Log in with administrator credentials + +#### Step 2: Navigate to API Tokens + +1. Click on **"Datacenter"** in the left sidebar +2. Expand **"Permissions"** +3. Click on **"API Tokens"** + +#### Step 3: Create New Token + +1. **Click "Add"** button +2. **Configure Token**: + - **User**: Select or create a user (e.g., `automation@pve`) + - **Token ID**: Enter a descriptive ID (e.g., `pabawi-api`) + - **Privilege Separation**: + - **Unchecked**: Token has full user permissions (recommended for Pabawi) + - **Checked**: Token has limited permissions (requires additional configuration) + - **Expire**: Set expiration date or leave empty for no expiration + - **Comment**: Optional description + +3. **Click "Add"** +4. **Copy the Token**: + - Token is displayed once + - Format: `user@realm!tokenid=uuid` + - Example: `automation@pve!pabawi-api=12345678-1234-1234-1234-123456789abc` + - **Save it securely** - you cannot retrieve it later + +#### Step 4: Configure Permissions + +If you enabled Privilege Separation, grant these permissions: + +1. **Navigate to Permissions**: + - Datacenter → Permissions → Add → API Token Permission + +2. **Grant Required Permissions**: + - Path: `/` + - API Token: Select your token + - Role: Create a custom role with: + - `VM.Allocate` + - `VM.Config.*` + - `VM.PowerMgmt` + - `VM.Audit` + - `Datastore.Allocate` + +3. **Click "Add"** + +### Using Username/Password + +If you prefer password authentication: + +#### Step 1: Create Dedicated User (Recommended) + +1. **Navigate to Users**: + - Datacenter → Permissions → Users + - Click "Add" + +2. **Configure User**: + - **User name**: `pabawi-automation` + - **Realm**: `pve` (Proxmox VE) + - **Password**: Generate a strong password + - **Email**: Optional + - **Enabled**: Checked + +3. **Click "Add"** + +#### Step 2: Grant Permissions + +1. **Navigate to Permissions**: + - Datacenter → Permissions → Add → User Permission + +2. **Configure Permissions**: + - Path: `/` + - User: `pabawi-automation@pve` + - Role: Create or select role with required permissions + +3. **Click "Add"** + +#### Step 3: Use in Pabawi + +Configure Pabawi with: + +- Username: `pabawi-automation` +- Password: (the password you set) +- Realm: `pve` + +## Testing the Connection + +### Via Web Interface + +1. **Navigate to Setup Page** +2. **Locate Proxmox Configuration** +3. **Click "Test Connection"** +4. **Review Results**: + - Success: Shows Proxmox version + - Failure: Shows error message + +### Via Command Line + +Test the connection manually: + +```bash +# Test with token +curl -k https://proxmox.example.com:8006/api2/json/version \ + -H "Authorization: PVEAPIToken=automation@pve!pabawi-api=your-token-uuid" + +# Test with username/password (get ticket first) +curl -k https://proxmox.example.com:8006/api2/json/access/ticket \ + -d "username=root@pam&password=your-password" +``` + +Expected response: + +```json +{ + "data": { + "version": "7.4-3", + "release": "7.4", + "repoid": "6f2f0a33" + } +} +``` + +### Verify Integration Status + +After configuration: + +1. **Check Integration Status**: + + ```bash + curl http://localhost:3000/api/integrations/status + ``` + +2. **Look for Proxmox**: + + ```json + { + "integrations": { + "proxmox": { + "enabled": true, + "connected": true, + "healthy": true, + "message": "Proxmox API is reachable" + } + } + } + ``` + +3. **Test Inventory Discovery**: + - Navigate to Inventory page + - Look for Proxmox nodes + - Verify VMs and containers appear + +## Troubleshooting + +### Connection Issues + +#### Problem: "Connection refused" + +**Symptoms**: + +``` +Error: connect ECONNREFUSED 192.168.1.100:8006 +``` + +**Solutions**: + +1. Verify Proxmox is running: + + ```bash + systemctl status pveproxy + ``` + +2. Check firewall allows port 8006: + + ```bash + # On Proxmox server + iptables -L -n | grep 8006 + ``` + +3. Test connectivity from Pabawi server: + + ```bash + telnet proxmox.example.com 8006 + nc -zv proxmox.example.com 8006 + ``` + +4. Verify host and port in configuration + +#### Problem: "Authentication failed" + +**Symptoms**: + +``` +Error: Authentication failed: 401 Unauthorized +Error: Authentication failed: 403 Forbidden +``` + +**Solutions**: + +1. **For Token Authentication**: + - Verify token format: `user@realm!tokenid=uuid` + - Check token hasn't expired + - Verify token exists in Proxmox + - Check token permissions + +2. **For Password Authentication**: + - Verify username is correct + - Check password is correct + - Verify realm is correct (`pam` or `pve`) + - Check user account isn't locked + +3. **Test Manually**: + + ```bash + # Test token + curl -k https://proxmox.example.com:8006/api2/json/version \ + -H "Authorization: PVEAPIToken=your-token" + + # Test password + curl -k https://proxmox.example.com:8006/api2/json/access/ticket \ + -d "username=root@pam&password=your-password" + ``` + +#### Problem: "Certificate verification failed" + +**Symptoms**: + +``` +Error: unable to verify the first certificate +Error: self signed certificate in certificate chain +``` + +**Solutions**: + +1. **Provide CA Certificate** (Recommended): + + ```bash + # Export Proxmox CA + scp root@proxmox:/etc/pve/pve-root-ca.pem ./proxmox-ca.pem + + # Configure in Pabawi + PROXMOX_CA_CERT=/path/to/proxmox-ca.pem + ``` + +2. **Disable Verification** (Testing Only): + + ```bash + PROXMOX_SSL_VERIFY=false + ``` + + **Warning**: This is insecure. Use only for testing. + +3. **Add Certificate to System Trust Store**: + + ```bash + # On Ubuntu/Debian + sudo cp proxmox-ca.pem /usr/local/share/ca-certificates/proxmox.crt + sudo update-ca-certificates + ``` + +#### Problem: "Timeout" + +**Symptoms**: + +``` +Error: Request timeout after 30000ms +``` + +**Solutions**: + +1. Check network latency: + + ```bash + ping proxmox.example.com + ``` + +2. Increase timeout: + + ```bash + PROXMOX_TIMEOUT=60000 # 60 seconds + ``` + +3. Check Proxmox server load: + + ```bash + ssh root@proxmox 'uptime' + ``` + +### Permission Issues + +#### Problem: "Permission denied" for operations + +**Symptoms**: + +``` +Error: Permission denied +Error: Insufficient privileges +``` + +**Solutions**: + +1. **Verify Token Permissions**: + - Log in to Proxmox web interface + - Navigate to Datacenter → Permissions + - Check token has required permissions + +2. **Grant Missing Permissions**: + - Add → API Token Permission + - Path: `/` + - Token: Your token + - Role: Administrator or custom role with required permissions + +3. **Check Privilege Separation**: + - If enabled, token needs explicit permissions + - If disabled, token inherits user permissions + +4. **Test Specific Operations**: + + ```bash + # Test VM creation permission + curl -k https://proxmox.example.com:8006/api2/json/nodes/pve/qemu \ + -H "Authorization: PVEAPIToken=your-token" \ + -X POST -d "vmid=999&name=test" + ``` + +### Configuration Issues + +#### Problem: "Integration not appearing" + +**Symptoms**: + +- Proxmox not listed in integrations +- No Proxmox nodes in inventory + +**Solutions**: + +1. Verify integration is enabled: + + ```bash + grep PROXMOX_ENABLED backend/.env + # Should show: PROXMOX_ENABLED=true + ``` + +2. Check configuration is valid: + + ```bash + # All required fields present + grep PROXMOX backend/.env + ``` + +3. Restart Pabawi: + + ```bash + sudo systemctl restart pabawi + ``` + +4. Check logs for errors: + + ```bash + sudo journalctl -u pabawi -f | grep -i proxmox + ``` + +## Security Best Practices + +### Authentication + +1. **Use API Tokens**: + - More secure than passwords + - Easier to rotate and revoke + - Fine-grained permissions + +2. **Dedicated User Account**: + - Create a separate user for Pabawi + - Don't use root account + - Limit permissions to what's needed + +3. **Strong Passwords**: + - Use password generator + - Minimum 16 characters + - Mix of letters, numbers, symbols + - Store in password manager + +4. **Regular Rotation**: + - Rotate tokens every 90 days + - Change passwords regularly + - Revoke unused tokens + +### Network Security + +1. **Use HTTPS**: + - Always use encrypted connections + - Never disable SSL in production + - Verify certificates + +2. **Firewall Rules**: + - Restrict access to Proxmox API + - Allow only Pabawi server IP + - Block public access + +3. **Network Segmentation**: + - Place Proxmox in management network + - Separate from production networks + - Use VPN for remote access + +### Access Control + +1. **Least Privilege**: + - Grant minimum required permissions + - Review permissions regularly + - Remove unused permissions + +2. **Audit Logging**: + - Enable Proxmox audit logging + - Monitor API access + - Review logs regularly + +3. **Multi-Factor Authentication**: + - Enable MFA for Proxmox web interface + - Use MFA for Pabawi access + - Protect administrator accounts + +### Configuration Security + +1. **Secure Storage**: + - Protect .env file permissions: + + ```bash + chmod 600 backend/.env + ``` + + - Don't commit secrets to git + - Use secrets management tools + +2. **Environment Variables**: + - Use environment variables for secrets + - Don't hardcode credentials + - Rotate secrets regularly + +3. **Backup Configuration**: + - Backup configuration securely + - Encrypt backups + - Test restore procedures + +## Related Documentation + +- [Proxmox Integration](integrations/proxmox.md) - Detailed integration documentation +- [Provisioning Guide](provisioning-guide.md) - How to create VMs and containers +- [Permissions and RBAC](permissions-rbac.md) - Permission requirements +- [Troubleshooting Guide](troubleshooting.md) - General troubleshooting + +## Support + +For additional help: + +- **Pabawi Documentation**: [pabawi.dev/docs](https://pabawi.dev/docs) +- **GitHub Issues**: [pabawi/issues](https://github.com/pabawi/pabawi/issues) +- **Proxmox Documentation**: [pve.proxmox.com/wiki](https://pve.proxmox.com/wiki) +- **Proxmox Forum**: [forum.proxmox.com](https://forum.proxmox.com) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 4e08cdf..3d28bea 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -2788,6 +2788,674 @@ If you can't find a solution in this guide: - Include Pabawi and Bolt versions - Describe steps to reproduce +## Proxmox Provisioning Issues + +### Problem: "VMID already exists" + +**Symptoms:** + +```json +{ + "error": { + "code": "PROXMOX_VMID_EXISTS", + "message": "VM with VMID 100 already exists on node pve1" + } +} +``` + +**Causes:** + +- VMID is already in use by another VM or container +- Previous VM with same ID wasn't fully deleted +- VMID conflict across nodes + +**Solutions:** + +1. **Choose a different VMID:** + - Use a unique ID between 100 and 999999999 + - Check existing VMs in inventory + - Follow your organization's VMID allocation scheme + +2. **Verify existing VM:** + + ```bash + # Via Proxmox CLI + qm list | grep 100 + pct list | grep 100 + + # Via Pabawi + # Navigate to Inventory and search for VMID + ``` + +3. **Delete existing VM if appropriate:** + - Navigate to the existing VM in inventory + - Use Manage tab → Destroy + - Confirm deletion + - Wait for deletion to complete + +4. **Check across all nodes:** + - VMIDs must be unique across the entire cluster + - Check all nodes for conflicts + +### Problem: "Insufficient resources" + +**Symptoms:** + +```json +{ + "error": { + "code": "PROXMOX_INSUFFICIENT_RESOURCES", + "message": "Not enough memory available on node pve1" + } +} +``` + +**Causes:** + +- Target node doesn't have enough CPU, memory, or storage +- Resources allocated but not yet freed +- Storage pool is full + +**Solutions:** + +1. **Check available resources:** + + ```bash + # Via Proxmox CLI + pvesh get /nodes/pve1/status + + # Check storage + pvesh get /nodes/pve1/storage/local-lvm/status + ``` + +2. **Choose a different node:** + - Select a node with more available resources + - Check resource availability in Proxmox web interface + - Balance load across cluster nodes + +3. **Reduce resource allocation:** + - Decrease CPU cores + - Reduce memory allocation + - Use smaller disk size + - Example: Change from 8GB to 4GB RAM + +4. **Free up resources:** + - Stop unused VMs + - Delete temporary VMs + - Clean up old snapshots + - Expand storage if needed + +5. **Check storage space:** + + ```bash + # Check disk usage + df -h + + # Check LVM space + lvs + vgs + ``` + +### Problem: "Template not found" + +**Symptoms:** + +```json +{ + "error": { + "code": "PROXMOX_TEMPLATE_NOT_FOUND", + "message": "Template 'local:vztmpl/ubuntu-22.04.tar.zst' not found" + } +} +``` + +**Causes:** + +- Template doesn't exist on target node +- Wrong template name or path +- Template not downloaded yet +- Storage location incorrect + +**Solutions:** + +1. **List available templates:** + + ```bash + # Via Proxmox CLI + pveam available + pveam list local + ``` + +2. **Download template:** + + ```bash + # Via Proxmox CLI + pveam download local ubuntu-22.04-standard_22.04-1_amd64.tar.zst + + # Or via Proxmox web interface: + # Node → local → CT Templates → Templates → Download + ``` + +3. **Verify template path:** + - Format: `storage:vztmpl/template-name.tar.zst` + - Example: `local:vztmpl/ubuntu-22.04-standard_22.04-1_amd64.tar.zst` + - Check exact filename including version numbers + +4. **Use a different template:** + - Select from available templates + - Use a template that exists on the target node + +5. **Check storage configuration:** + + ```bash + # Verify storage is configured for templates + pvesm status + ``` + +### Problem: "Invalid hostname format" + +**Symptoms:** + +```json +{ + "error": { + "code": "VALIDATION_ERROR", + "message": "Hostname must contain only lowercase letters, numbers, and hyphens" + } +} +``` + +**Causes:** + +- Hostname contains invalid characters +- Uppercase letters used +- Starts or ends with hyphen +- Contains underscores or spaces + +**Solutions:** + +1. **Use valid hostname format:** + - Only lowercase letters (a-z) + - Numbers (0-9) + - Hyphens (-) but not at start or end + - No underscores, spaces, or special characters + +2. **Valid examples:** + - ✓ `web-server-01` + - ✓ `app-prod` + - ✓ `db-staging-02` + - ✗ `Web_Server_01` (uppercase, underscore) + - ✗ `app server` (space) + - ✗ `-web-01` (starts with hyphen) + +3. **Convert invalid hostnames:** + - Replace underscores with hyphens + - Convert to lowercase + - Remove spaces + - Remove leading/trailing hyphens + +### Problem: "Network configuration error" + +**Symptoms:** + +```json +{ + "error": { + "code": "PROXMOX_NETWORK_ERROR", + "message": "Invalid network configuration" + } +} +``` + +**Causes:** + +- Bridge doesn't exist on target node +- Invalid network configuration syntax +- IP address format incorrect +- Gateway not specified for static IP + +**Solutions:** + +1. **Verify bridge exists:** + + ```bash + # List available bridges + ip link show | grep vmbr + + # Or via Proxmox web interface: + # Node → System → Network + ``` + +2. **Use correct network format:** + + **For VMs:** + + ``` + model=virtio,bridge=vmbr0 + model=virtio,bridge=vmbr0,firewall=1 + model=e1000,bridge=vmbr1 + ``` + + **For LXC:** + + ``` + name=eth0,bridge=vmbr0,ip=dhcp + name=eth0,bridge=vmbr0,ip=192.168.1.100/24,gw=192.168.1.1 + ``` + +3. **Check IP address format:** + - Use CIDR notation: `192.168.1.100/24` + - Include gateway for static IPs: `gw=192.168.1.1` + - Or use DHCP: `ip=dhcp` + +4. **Common network configurations:** + + ``` + # DHCP (automatic) + name=eth0,bridge=vmbr0,ip=dhcp + + # Static IP + name=eth0,bridge=vmbr0,ip=192.168.1.50/24,gw=192.168.1.1 + + # Multiple interfaces + name=eth0,bridge=vmbr0,ip=dhcp + name=eth1,bridge=vmbr1,ip=10.0.0.50/24 + ``` + +### Problem: "Permission denied for provisioning" + +**Symptoms:** + +```json +{ + "error": { + "code": "PERMISSION_DENIED", + "message": "User does not have permission to create VMs" + } +} +``` + +**Causes:** + +- User lacks provisioning permissions +- Proxmox user/token doesn't have required permissions +- Integration not configured with proper credentials + +**Solutions:** + +1. **Check Pabawi permissions:** + - Verify your user has `provision:create_vm` or `provision:create_lxc` permission + - Contact administrator to grant permissions + - See [Permissions and RBAC Guide](permissions-rbac.md) + +2. **Check Proxmox permissions:** + + ```bash + # Via Proxmox CLI + pveum user permissions @ + + # Required permissions: + # - VM.Allocate + # - VM.Config.* + # - Datastore.Allocate + ``` + +3. **Grant Proxmox permissions:** + - Log in to Proxmox web interface + - Navigate to Datacenter → Permissions + - Add permissions for the API user/token + - See [Proxmox Setup Guide](proxmox-setup-guide.md) + +4. **Verify API token permissions:** + - Check token has privilege separation disabled + - Or grant explicit permissions to token + - Test token with curl: + + ```bash + curl -k https://proxmox:8006/api2/json/nodes \ + -H "Authorization: PVEAPIToken=user@realm!token=uuid" + ``` + +### Problem: "Provisioning operation timeout" + +**Symptoms:** + +```json +{ + "error": { + "code": "OPERATION_TIMEOUT", + "message": "Provisioning operation timed out after 300s" + } +} +``` + +**Causes:** + +- VM/container creation takes longer than timeout +- Slow storage (network storage, spinning disks) +- Target node is overloaded +- Large disk allocation + +**Solutions:** + +1. **Increase timeout:** + + ```bash + # In backend/.env + PROXMOX_TIMEOUT=600000 # 10 minutes + ``` + +2. **Check target node load:** + + ```bash + # Via Proxmox CLI + uptime + top + iostat + ``` + +3. **Use faster storage:** + - Prefer local SSD over network storage + - Use thin provisioning + - Reduce initial disk size + +4. **Reduce resource allocation:** + - Smaller disk size provisions faster + - Fewer CPU cores + - Less memory + +5. **Try again:** + - Node may have been temporarily busy + - Wait a few minutes and retry + - Choose a different node + +### Problem: "Storage not available" + +**Symptoms:** + +```json +{ + "error": { + "code": "PROXMOX_STORAGE_ERROR", + "message": "Storage 'local-lvm' is not available" + } +} +``` + +**Causes:** + +- Storage doesn't exist on target node +- Storage is disabled +- Storage is full +- Wrong storage name + +**Solutions:** + +1. **List available storage:** + + ```bash + # Via Proxmox CLI + pvesm status + + # Or via Proxmox web interface: + # Datacenter → Storage + ``` + +2. **Verify storage is enabled:** + + ```bash + # Check storage configuration + cat /etc/pve/storage.cfg + ``` + +3. **Check storage space:** + + ```bash + # Check available space + pvesm status | grep local-lvm + df -h + ``` + +4. **Use different storage:** + - Select storage that exists on target node + - Common storage names: `local`, `local-lvm`, `ceph-pool` + - Check storage type supports VMs/containers + +5. **Enable storage:** + - Proxmox web interface: Datacenter → Storage + - Edit storage and enable it + - Ensure storage is available on target node + +### Problem: "Provision menu not visible" + +**Symptoms:** + +- Provision menu item missing from navigation +- Cannot access provisioning page +- No way to create VMs/containers + +**Causes:** + +- User lacks provisioning permissions +- No provisioning integrations configured +- Integration not connected + +**Solutions:** + +1. **Check permissions:** + - Verify you have any `provision:*` permission + - Contact administrator for access + - See [Permissions and RBAC Guide](permissions-rbac.md) + +2. **Verify integration is configured:** + + ```bash + # Check integration status + curl http://localhost:3000/api/integrations/status + ``` + +3. **Check Proxmox integration:** + - Navigate to Setup page + - Verify Proxmox integration is configured + - Test connection + - See [Proxmox Setup Guide](proxmox-setup-guide.md) + +4. **Check integration health:** + - Integration must be "connected" and "healthy" + - Green status indicator + - No error messages + +5. **Refresh page:** + - Clear browser cache + - Log out and log back in + - Try different browser + +### Problem: "Form validation errors" + +**Symptoms:** + +- Cannot submit provisioning form +- Red error messages below fields +- Submit button disabled + +**Causes:** + +- Required fields empty +- Invalid field values +- Values outside acceptable ranges + +**Solutions:** + +1. **Check required fields:** + - VMID (required, 100-999999999) + - Name/Hostname (required, valid format) + - Node (required, must exist) + - OS Template (required for LXC) + +2. **Verify field formats:** + - VMID: Positive integer + - Hostname: Lowercase, alphanumeric, hyphens + - Memory: Minimum 512 MB + - Cores: Minimum 1 + +3. **Check value ranges:** + - VMID: 100 to 999999999 + - Memory: At least 512 MB + - Cores: At least 1 + - Port: 1 to 65535 + +4. **Review error messages:** + - Read validation messages carefully + - Fix indicated issues + - Submit button enables when all valid + +### Problem: "VM starts but network doesn't work" + +**Symptoms:** + +- VM created successfully +- VM is running +- No network connectivity +- Cannot ping or SSH to VM + +**Causes:** + +- Wrong network configuration +- Bridge not connected +- Firewall blocking traffic +- Guest OS network not configured + +**Solutions:** + +1. **Verify network configuration:** + - Check VM network settings in Proxmox + - Verify bridge is correct + - Check cable is "connected" + +2. **Check bridge configuration:** + + ```bash + # On Proxmox node + ip link show vmbr0 + brctl show vmbr0 + ``` + +3. **Check guest OS network:** + - Access VM console in Proxmox + - Check network interface is up: `ip addr` + - Check DHCP client is running + - Configure static IP if needed + +4. **Check firewall:** + - Proxmox firewall settings + - Guest OS firewall + - Network firewall rules + +5. **Verify DHCP:** + - If using DHCP, check DHCP server is running + - Check DHCP leases + - Try static IP instead + +### Problem: "Cannot destroy VM" + +**Symptoms:** + +```json +{ + "error": { + "code": "PROXMOX_DESTROY_ERROR", + "message": "Cannot destroy VM: VM is locked" + } +} +``` + +**Causes:** + +- VM is locked by another operation +- VM has active snapshots +- VM is in use +- Backup is running + +**Solutions:** + +1. **Wait for operations to complete:** + - Check Proxmox task log + - Wait for running tasks to finish + - Try again after a few minutes + +2. **Stop VM first:** + - Use Manage tab → Stop + - Wait for VM to stop completely + - Then try destroy again + +3. **Check for locks:** + + ```bash + # Via Proxmox CLI + qm unlock + ``` + +4. **Remove snapshots:** + - Delete VM snapshots first + - Via Proxmox web interface + - Then try destroy again + +5. **Force unlock (careful!):** + + ```bash + # Via Proxmox CLI (use with caution) + qm unlock + qm destroy + ``` + +### Problem: "LXC container won't start" + +**Symptoms:** + +- Container created successfully +- Start action fails +- Error in Proxmox logs + +**Causes:** + +- Template incompatibility +- Missing kernel features +- Resource constraints +- Configuration error + +**Solutions:** + +1. **Check Proxmox logs:** + + ```bash + # On Proxmox node + journalctl -u pve-container@ + cat /var/log/pve/tasks/* + ``` + +2. **Verify template:** + - Use official Proxmox templates + - Check template is compatible with Proxmox version + - Try different template + +3. **Check kernel features:** + + ```bash + # Verify required kernel modules + lsmod | grep overlay + lsmod | grep nf_nat + ``` + +4. **Reduce resources:** + - Try with less memory + - Reduce CPU cores + - Use smaller disk + +5. **Check configuration:** + - Review container configuration in Proxmox + - Check for invalid settings + - Compare with working container + ## Additional Resources - [Bolt Documentation](https://puppet.com/docs/bolt/) @@ -2795,3 +3463,8 @@ If you can't find a solution in this guide: - [Pabawi GitHub Repository](https://github.com/example42/pabawi) - [Pabawi API Documentation](./api.md) - [Pabawi Configuration Guide](./configuration.md) +- [Provisioning Guide](provisioning-guide.md) +- [Proxmox Setup Guide](proxmox-setup-guide.md) +- [Manage Tab Guide](manage-tab-guide.md) +- [Permissions and RBAC Guide](permissions-rbac.md) +- [Proxmox Documentation](https://pve.proxmox.com/wiki) diff --git a/frontend/package.json b/frontend/package.json index 02cc9bd..386d41e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "frontend", - "version": "0.8.0", + "version": "0.9.0", "description": "Pabawi frontend web interface", "type": "module", "scripts": { @@ -17,9 +17,11 @@ }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^6.2.1", + "@testing-library/jest-dom": "^6.9.1", "@testing-library/svelte": "^5.0.0", "@tsconfig/svelte": "^5.0.4", "autoprefixer": "^10.4.19", + "fast-check": "^4.6.0", "jsdom": "^27.4.0", "postcss": "^8.4.38", "tailwindcss": "^3.4.3", diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index b00b3e5..3947698 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -16,6 +16,7 @@ import GroupManagementPage from './pages/GroupManagementPage.svelte'; import GroupDetailPage from './pages/GroupDetailPage.svelte'; import RoleManagementPage from './pages/RoleManagementPage.svelte'; + import ProvisionPage from './pages/ProvisionPage.svelte'; import { router } from './lib/router.svelte'; import type { RouteConfig } from './lib/router.svelte'; import { get } from './lib/api'; @@ -28,6 +29,7 @@ '/setup': SetupPage, '/inventory': { component: InventoryPage, requiresAuth: true }, '/executions': { component: ExecutionsPage, requiresAuth: true }, + '/provision': { component: ProvisionPage, requiresAuth: true }, '/puppet': { component: PuppetPage, requiresAuth: true }, '/users': { component: UserManagementPage, requiresAuth: true, requiresAdmin: true }, '/groups': { component: GroupManagementPage, requiresAuth: true, requiresAdmin: true }, @@ -96,7 +98,7 @@ {#if setupComplete}