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:
228
server.js
228
server.js
@@ -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}...`,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user