import { getSSHExecutionService } from '../ssh-execution-service'; import type { Server } from '~/types/server'; export interface Storage { name: string; type: string; content: string[]; supportsBackup: boolean; nodes?: string[]; [key: string]: any; // For additional storage-specific properties } interface CachedStorageData { storages: Storage[]; lastFetched: Date; } class StorageService { private cache: Map = new Map(); private readonly CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour /** * Parse storage.cfg content and extract storage information */ private parseStorageConfig(configContent: string): Storage[] { const storages: Storage[] = []; const lines = configContent.split('\n'); let currentStorage: Partial | null = null; for (const rawLine of lines) { if (!rawLine) continue; // Check if line is indented (has leading whitespace/tabs) BEFORE trimming const isIndented = /^[\s\t]/.test(rawLine); const line = rawLine.trim(); // Skip empty lines and comments if (!line || line.startsWith('#')) { continue; } // Check if this is a storage definition line (format: "type: name") // Storage definitions are NOT indented if (!isIndented) { const storageMatch = /^(\w+):\s*(.+)$/.exec(line); if (storageMatch?.[1] && storageMatch[2]) { // Save previous storage if exists if (currentStorage?.name) { storages.push(this.finalizeStorage(currentStorage)); } // Start new storage currentStorage = { type: storageMatch[1], name: storageMatch[2], content: [], supportsBackup: false, }; continue; } } // Parse storage properties (indented lines - can be tabs or spaces) if (currentStorage && isIndented) { // Split on first whitespace (space or tab) to separate key and value const match = /^(\S+)\s+(.+)$/.exec(line); if (match?.[1] && match[2]) { const key = match[1]; const value = match[2].trim(); switch (key) { case 'content': // Content can be comma-separated: "images,rootdir" or "backup" currentStorage.content = value.split(',').map(c => c.trim()); currentStorage.supportsBackup = currentStorage.content.includes('backup'); break; case 'nodes': // Nodes can be comma-separated: "prox5" or "prox5,prox6" currentStorage.nodes = value.split(',').map(n => n.trim()); break; default: // Store other properties if (key) { (currentStorage as any)[key] = value; } } } } } // Don't forget the last storage if (currentStorage?.name) { storages.push(this.finalizeStorage(currentStorage)); } return storages; } /** * Finalize storage object with proper typing */ private finalizeStorage(storage: Partial): Storage { return { name: storage.name!, type: storage.type!, content: storage.content ?? [], supportsBackup: storage.supportsBackup ?? false, nodes: storage.nodes, ...Object.fromEntries( Object.entries(storage).filter(([key]) => !['name', 'type', 'content', 'supportsBackup', 'nodes'].includes(key) ) ), }; } /** * Fetch storage configuration from server via SSH */ async fetchStoragesFromServer(server: Server, forceRefresh = false): Promise { const serverId = server.id; // Check cache first (unless force refresh) if (!forceRefresh && this.cache.has(serverId)) { const cached = this.cache.get(serverId)!; const age = Date.now() - cached.lastFetched.getTime(); if (age < this.CACHE_TTL_MS) { return cached.storages; } } // Fetch from server const sshService = getSSHExecutionService(); let configContent = ''; await new Promise((resolve, reject) => { void sshService.executeCommand( server, 'cat /etc/pve/storage.cfg', (data: string) => { configContent += data; }, (error: string) => { reject(new Error(`Failed to read storage config: ${error}`)); }, (exitCode: number) => { if (exitCode === 0) { resolve(); } else { reject(new Error(`Command failed with exit code ${exitCode}`)); } } ); }); // Parse and cache const storages = this.parseStorageConfig(configContent); this.cache.set(serverId, { storages, lastFetched: new Date(), }); return storages; } /** * Get all storages for a server (cached or fresh) */ async getStorages(server: Server, forceRefresh = false): Promise { return this.fetchStoragesFromServer(server, forceRefresh); } /** * Get only backup-capable storages */ async getBackupStorages(server: Server, forceRefresh = false): Promise { const allStorages = await this.getStorages(server, forceRefresh); return allStorages.filter(s => s.supportsBackup); } /** * Get PBS storage information (IP and datastore) from storage config */ getPBSStorageInfo(storage: Storage): { pbs_ip: string | null; pbs_datastore: string | null } { if (storage.type !== 'pbs') { return { pbs_ip: null, pbs_datastore: null }; } return { pbs_ip: (storage as any).server ?? null, pbs_datastore: (storage as any).datastore ?? null, }; } /** * Clear cache for a specific server */ clearCache(serverId: number): void { this.cache.delete(serverId); } /** * Clear all caches */ clearAllCaches(): void { this.cache.clear(); } } // Singleton instance let storageServiceInstance: StorageService | null = null; export function getStorageService(): StorageService { storageServiceInstance ??= new StorageService(); return storageServiceInstance; }