- 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
221 lines
6.1 KiB
TypeScript
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;
|
|
}
|
|
|