Add LXC container backup functionality

- Add backup capability before updates or as standalone action
- Implement storage service to fetch and parse backup-capable storages from PVE nodes
- Add backup storage selection modal for user choice
- Support backup+update flow with sequential execution
- Add standalone backup option in Actions menu
- Add storage viewer in server section to show available storages
- Parse /etc/pve/storage.cfg to identify backup-capable storages
- Cache storage data for performance
- Handle backup failures gracefully (warn but allow update to proceed)
This commit is contained in:
Michel Roegl-Brunner
2025-11-14 10:30:27 +01:00
parent 4ea49be97d
commit d50ea55e6d
4 changed files with 173 additions and 136 deletions

228
server.js
View File

@@ -705,70 +705,112 @@ class ScriptExecutionHandler {
* @param {string} executionId * @param {string} executionId
* @param {string} storage * @param {string} storage
* @param {ServerInfo} server * @param {ServerInfo} server
* @param {Function} [onComplete] - Optional callback when backup completes
*/ */
async startSSHBackupExecution(ws, containerId, executionId, storage, server) { startSSHBackupExecution(ws, containerId, executionId, storage, server, onComplete = null) {
const sshService = getSSHExecutionService(); const sshService = getSSHExecutionService();
try { return new Promise((resolve, reject) => {
const backupCommand = `vzdump ${containerId} --storage ${storage} --mode snapshot`; try {
const backupCommand = `vzdump ${containerId} --storage ${storage} --mode snapshot`;
const execution = await sshService.executeCommand( // Wrap the onExit callback to resolve our promise
server, let promiseResolved = false;
backupCommand,
/** @param {string} data */ sshService.executeCommand(
(data) => { server,
this.sendMessage(ws, { backupCommand,
type: 'output', /** @param {string} data */
data: data, (data) => {
timestamp: Date.now()
});
},
/** @param {string} error */
(error) => {
this.sendMessage(ws, {
type: 'error',
data: error,
timestamp: Date.now()
});
},
/** @param {number} code */
(code) => {
if (code === 0) {
this.sendMessage(ws, { this.sendMessage(ws, {
type: 'end', type: 'output',
data: `Backup completed successfully with exit code: ${code}`, data: data,
timestamp: Date.now() timestamp: Date.now()
}); });
} else { },
/** @param {string} error */
(error) => {
this.sendMessage(ws, { this.sendMessage(ws, {
type: 'error', type: 'error',
data: `Backup failed with exit code: ${code}`, data: error,
timestamp: Date.now() timestamp: Date.now()
}); });
},
/** @param {number} code */
(code) => {
// Don't send 'end' message here if this is part of a backup+update flow
// The update flow will handle completion messages
const success = code === 0;
if (!success) {
this.sendMessage(ws, {
type: 'error',
data: `Backup failed with exit code: ${code}`,
timestamp: Date.now()
});
}
// Send a completion message (but not 'end' type to avoid stopping terminal)
this.sendMessage(ws, { this.sendMessage(ws, {
type: 'end', type: 'output',
data: `Backup execution ended with exit code: ${code}`, data: `\n[Backup ${success ? 'completed' : 'failed'} with exit code: ${code}]\n`,
timestamp: Date.now() timestamp: Date.now()
}); });
if (onComplete) onComplete(success);
// Resolve the promise when backup completes
// Use setImmediate to ensure resolution happens in the right execution context
if (!promiseResolved) {
promiseResolved = true;
const result = { success, code };
// Use setImmediate to ensure promise resolution happens in the next tick
// This ensures the await in startUpdateExecution can properly resume
setImmediate(() => {
try {
resolve(result);
} catch (resolveError) {
console.error('Error resolving backup promise:', resolveError);
reject(resolveError);
}
});
}
this.activeExecutions.delete(executionId);
} }
).then((execution) => {
// Store the execution
this.activeExecutions.set(executionId, {
process: /** @type {any} */ (execution).process,
ws
});
// Note: Don't resolve here - wait for onExit callback
}).catch((error) => {
console.error('Error starting backup execution:', error);
this.sendMessage(ws, {
type: 'error',
data: `SSH backup execution failed: ${error instanceof Error ? error.message : String(error)}`,
timestamp: Date.now()
});
if (onComplete) onComplete(false);
if (!promiseResolved) {
promiseResolved = true;
reject(error);
}
});
this.activeExecutions.delete(executionId); } catch (error) {
} console.error('Error in startSSHBackupExecution:', error);
); this.sendMessage(ws, {
type: 'error',
// Store the execution data: `SSH backup execution failed: ${error instanceof Error ? error.message : String(error)}`,
this.activeExecutions.set(executionId, { timestamp: Date.now()
process: /** @type {any} */ (execution).process, });
ws if (onComplete) onComplete(false);
}); reject(error);
}
} catch (error) { });
this.sendMessage(ws, {
type: 'error',
data: `SSH backup execution failed: ${error instanceof Error ? error.message : String(error)}`,
timestamp: Date.now()
});
}
} }
/** /**
@@ -792,72 +834,48 @@ class ScriptExecutionHandler {
// Create a separate execution ID for backup // Create a separate execution ID for backup
const backupExecutionId = `backup_${executionId}`; const backupExecutionId = `backup_${executionId}`;
let backupCompleted = false;
let backupSucceeded = false;
// Run backup and wait for it to complete // Run backup and wait for it to complete
await new Promise<void>((resolve) => { try {
// Create a wrapper websocket that forwards messages and tracks completion const backupResult = await this.startSSHBackupExecution(
const backupWs = { ws,
send: (data) => { containerId,
try { backupExecutionId,
const message = typeof data === 'string' ? JSON.parse(data) : data; backupStorage,
server
);
// Forward all messages to the main websocket // Backup completed (successfully or not)
ws.send(JSON.stringify(message)); if (!backupResult || !backupResult.success) {
// Backup failed, but we'll still allow update (per requirement 1b)
// Check for completion this.sendMessage(ws, {
if (message.type === 'end') { type: 'output',
backupCompleted = true; data: '\n⚠ Backup failed, but proceeding with update as requested...\n',
backupSucceeded = !message.data.includes('failed') && !message.data.includes('exit code:'); timestamp: Date.now()
if (!backupSucceeded) {
// Backup failed, but we'll still allow update (per requirement 1b)
ws.send(JSON.stringify({
type: 'output',
data: '\n⚠ Backup failed, but proceeding with update as requested...\n',
timestamp: Date.now()
}));
}
resolve();
} else if (message.type === 'error' && message.data.includes('Backup failed')) {
backupCompleted = true;
backupSucceeded = false;
ws.send(JSON.stringify({
type: 'output',
data: '\n⚠ Backup failed, but proceeding with update as requested...\n',
timestamp: Date.now()
}));
resolve();
}
} catch (e) {
// If parsing fails, just forward the raw data
ws.send(data);
}
}
};
// Start backup execution
this.startSSHBackupExecution(backupWs, containerId, backupExecutionId, backupStorage, server)
.catch((error) => {
// Backup failed to start, but allow update to proceed
if (!backupCompleted) {
backupCompleted = true;
backupSucceeded = false;
ws.send(JSON.stringify({
type: 'output',
data: `\n⚠️ Backup error: ${error.message}. Proceeding with update...\n`,
timestamp: Date.now()
}));
resolve();
}
}); });
}); } else {
// Backup succeeded
this.sendMessage(ws, {
type: 'output',
data: '\n✅ Backup completed successfully. Starting update...\n',
timestamp: Date.now()
});
}
} catch (error) {
console.error('Backup error before update:', error);
// Backup failed to start, but allow update to proceed
this.sendMessage(ws, {
type: 'output',
data: `\n⚠️ Backup error: ${error instanceof Error ? error.message : String(error)}. Proceeding with update...\n`,
timestamp: Date.now()
});
}
// Small delay before starting update // Small delay before starting update
await new Promise(resolve => setTimeout(resolve, 1000)); await new Promise(resolve => setTimeout(resolve, 1000));
} }
// Send start message for update // Send start message for update (only if we're actually starting an update)
this.sendMessage(ws, { this.sendMessage(ws, {
type: 'start', type: 'start',
data: `Starting update for container ${containerId}...`, data: `Starting update for container ${containerId}...`,

View File

@@ -61,6 +61,7 @@ export function InstalledScriptsTab() {
const [backupStorages, setBackupStorages] = useState<Storage[]>([]); const [backupStorages, setBackupStorages] = useState<Storage[]>([]);
const [isLoadingStorages, setIsLoadingStorages] = useState(false); const [isLoadingStorages, setIsLoadingStorages] = useState(false);
const [showBackupWarning, setShowBackupWarning] = useState(false); const [showBackupWarning, setShowBackupWarning] = useState(false);
const [isPreUpdateBackup, setIsPreUpdateBackup] = useState(false); // Track if storage selection is for pre-update backup
const [editingScriptId, setEditingScriptId] = useState<number | null>(null); const [editingScriptId, setEditingScriptId] = useState<number | null>(null);
const [editFormData, setEditFormData] = useState<{ script_name: string; container_id: string; web_ui_ip: string; web_ui_port: string }>({ script_name: '', container_id: '', web_ui_ip: '', web_ui_port: '' }); const [editFormData, setEditFormData] = useState<{ script_name: string; container_id: string; web_ui_ip: string; web_ui_port: string }>({ script_name: '', container_id: '', web_ui_ip: '', web_ui_port: '' });
const [showAddForm, setShowAddForm] = useState(false); const [showAddForm, setShowAddForm] = useState(false);
@@ -660,6 +661,7 @@ export function InstalledScriptsTab() {
if (wantsBackup) { if (wantsBackup) {
// User wants backup - fetch storages and show selection // User wants backup - fetch storages and show selection
if (pendingUpdateScript.server_id) { if (pendingUpdateScript.server_id) {
setIsPreUpdateBackup(true); // Mark that this is for pre-update backup
void fetchStorages(pendingUpdateScript.server_id, false); void fetchStorages(pendingUpdateScript.server_id, false);
setShowStorageSelection(true); setShowStorageSelection(true);
} else { } else {
@@ -682,12 +684,13 @@ export function InstalledScriptsTab() {
setShowStorageSelection(false); setShowStorageSelection(false);
// Check if this is for a standalone backup or pre-update backup // Check if this is for a standalone backup or pre-update backup
if (pendingUpdateScript && !showBackupPrompt) { if (isPreUpdateBackup) {
// Pre-update backup - proceed with update
setIsPreUpdateBackup(false); // Reset flag
proceedWithUpdate(storage.name);
} else if (pendingUpdateScript) {
// Standalone backup - execute backup directly // Standalone backup - execute backup directly
executeStandaloneBackup(pendingUpdateScript, storage.name); executeStandaloneBackup(pendingUpdateScript, storage.name);
} else {
// Pre-update backup - proceed with update
proceedWithUpdate(storage.name);
} }
}; };
@@ -718,6 +721,7 @@ export function InstalledScriptsTab() {
}); });
// Reset state // Reset state
setIsPreUpdateBackup(false); // Reset flag
setPendingUpdateScript(null); setPendingUpdateScript(null);
setBackupStorages([]); setBackupStorages([]);
}; };
@@ -745,7 +749,8 @@ export function InstalledScriptsTab() {
id: pendingUpdateScript.id, id: pendingUpdateScript.id,
containerId: pendingUpdateScript.container_id!, containerId: pendingUpdateScript.container_id!,
server: server, server: server,
backupStorage: backupStorage ?? undefined backupStorage: backupStorage ?? undefined,
isBackupOnly: false // Explicitly set to false for update operations
}); });
// Reset state // Reset state
@@ -779,6 +784,7 @@ export function InstalledScriptsTab() {
} }
// Store the script and fetch storages // Store the script and fetch storages
setIsPreUpdateBackup(false); // This is a standalone backup, not pre-update
setPendingUpdateScript(script); setPendingUpdateScript(script);
void fetchStorages(script.server_id, false); void fetchStorages(script.server_id, false);
setShowStorageSelection(true); setShowStorageSelection(true);

View File

@@ -4,7 +4,6 @@ import { getDatabase } from "~/server/database-prisma";
import { createHash } from "crypto"; import { createHash } from "crypto";
import type { Server } from "~/types/server"; import type { Server } from "~/types/server";
import { getStorageService } from "~/server/services/storageService"; import { getStorageService } from "~/server/services/storageService";
import { SSHService } from "~/server/ssh-service";
// Helper function to parse raw LXC config into structured data // Helper function to parse raw LXC config into structured data
function parseRawConfig(rawConfig: string): any { function parseRawConfig(rawConfig: string): any {
@@ -2063,6 +2062,7 @@ EOFCONFIG`;
} }
const storageService = getStorageService(); const storageService = getStorageService();
const { default: SSHService } = await import('~/server/ssh-service');
const sshService = new SSHService(); const sshService = new SSHService();
// Test SSH connection first // Test SSH connection first
@@ -2118,6 +2118,7 @@ EOFCONFIG`;
}; };
} }
const { default: SSHService } = await import('~/server/ssh-service');
const sshService = new SSHService(); const sshService = new SSHService();
// Test SSH connection first // Test SSH connection first

View File

@@ -29,7 +29,12 @@ class StorageService {
let currentStorage: Partial<Storage> | null = null; let currentStorage: Partial<Storage> | null = null;
for (let i = 0; i < lines.length; i++) { for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim(); const rawLine = lines[i];
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 // Skip empty lines and comments
if (!line || line.startsWith('#')) { if (!line || line.startsWith('#')) {
@@ -37,29 +42,34 @@ class StorageService {
} }
// Check if this is a storage definition line (format: "type: name") // Check if this is a storage definition line (format: "type: name")
const storageMatch = line.match(/^(\w+):\s*(.+)$/); // Storage definitions are NOT indented
if (storageMatch) { if (!isIndented) {
// Save previous storage if exists const storageMatch = line.match(/^(\w+):\s*(.+)$/);
if (currentStorage && currentStorage.name) { if (storageMatch && storageMatch[1] && storageMatch[2]) {
storages.push(this.finalizeStorage(currentStorage)); // Save previous storage if exists
} if (currentStorage && currentStorage.name) {
storages.push(this.finalizeStorage(currentStorage));
}
// Start new storage // Start new storage
currentStorage = { currentStorage = {
type: storageMatch[1], type: storageMatch[1],
name: storageMatch[2], name: storageMatch[2],
content: [], content: [],
supportsBackup: false, supportsBackup: false,
}; };
continue; continue;
}
} }
// Parse storage properties (indented lines) // Parse storage properties (indented lines - can be tabs or spaces)
if (currentStorage && /^\s/.test(line)) { if (currentStorage && isIndented) {
const propertyMatch = line.match(/^\s+(\w+)\s+(.+)$/); // Split on first whitespace (space or tab) to separate key and value
if (propertyMatch) { const match = line.match(/^(\S+)\s+(.+)$/);
const key = propertyMatch[1];
const value = propertyMatch[2]; if (match && match[1] && match[2]) {
const key = match[1];
const value = match[2].trim();
switch (key) { switch (key) {
case 'content': case 'content':
@@ -73,7 +83,9 @@ class StorageService {
break; break;
default: default:
// Store other properties // Store other properties
(currentStorage as any)[key] = value; if (key) {
(currentStorage as any)[key] = value;
}
} }
} }
} }