Files
ProxmoxVE-Local/src/server/services/storageService.ts
Michel Roegl-Brunner dd17d2cbec feat: Add VM/LXC cloning functionality
- Add CloneCountInputModal component for specifying clone count
- Implement clone handlers and state management in InstalledScriptsTab
- Add clone menu item to ScriptInstallationCard
- Extend StorageSelectionModal to support clone storage selection (rootdir only)
- Add clone terminal support to Terminal component
- Implement startSSHCloneExecution in server.js with sequential ID retrieval
- Add clone-related API endpoints (getClusterNextId, getContainerType, getCloneStorages, generateCloneHostnames, executeClone, addClonedContainerToDatabase)
- Integrate with VM/LXC detection from main branch
- Fix storage fetching to use correct serverId parameter
- Fix clone execution to pass storage parameter correctly
- Remove unused eslint-disable comments
2025-11-29 16:53:58 +01:00

221 lines
6.1 KiB
TypeScript

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<number, CachedStorageData> = 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<Storage> | 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>): 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<Storage[]> {
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<void>((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<Storage[]> {
return this.fetchStoragesFromServer(server, forceRefresh);
}
/**
* Get only backup-capable storages
*/
async getBackupStorages(server: Server, forceRefresh = false): Promise<Storage[]> {
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;
}