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

234
server.js
View File

@@ -705,70 +705,112 @@ class ScriptExecutionHandler {
* @param {string} executionId
* @param {string} storage
* @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();
try {
const backupCommand = `vzdump ${containerId} --storage ${storage} --mode snapshot`;
const execution = await sshService.executeCommand(
server,
backupCommand,
/** @param {string} data */
(data) => {
this.sendMessage(ws, {
type: 'output',
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) {
return new Promise((resolve, reject) => {
try {
const backupCommand = `vzdump ${containerId} --storage ${storage} --mode snapshot`;
// Wrap the onExit callback to resolve our promise
let promiseResolved = false;
sshService.executeCommand(
server,
backupCommand,
/** @param {string} data */
(data) => {
this.sendMessage(ws, {
type: 'end',
data: `Backup completed successfully with exit code: ${code}`,
type: 'output',
data: data,
timestamp: Date.now()
});
} else {
},
/** @param {string} error */
(error) => {
this.sendMessage(ws, {
type: 'error',
data: `Backup failed with exit code: ${code}`,
data: error,
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, {
type: 'end',
data: `Backup execution ended with exit code: ${code}`,
type: 'output',
data: `\n[Backup ${success ? 'completed' : 'failed'} with exit code: ${code}]\n`,
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);
}
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);
}
});
// Store the execution
this.activeExecutions.set(executionId, {
process: /** @type {any} */ (execution).process,
ws
});
} catch (error) {
this.sendMessage(ws, {
type: 'error',
data: `SSH backup execution failed: ${error instanceof Error ? error.message : String(error)}`,
timestamp: Date.now()
});
}
} catch (error) {
console.error('Error in startSSHBackupExecution:', 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);
reject(error);
}
});
}
/**
@@ -792,72 +834,48 @@ class ScriptExecutionHandler {
// Create a separate execution ID for backup
const backupExecutionId = `backup_${executionId}`;
let backupCompleted = false;
let backupSucceeded = false;
// Run backup and wait for it to complete
await new Promise<void>((resolve) => {
// Create a wrapper websocket that forwards messages and tracks completion
const backupWs = {
send: (data) => {
try {
const message = typeof data === 'string' ? JSON.parse(data) : data;
// Forward all messages to the main websocket
ws.send(JSON.stringify(message));
// Check for completion
if (message.type === 'end') {
backupCompleted = true;
backupSucceeded = !message.data.includes('failed') && !message.data.includes('exit code:');
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();
}
try {
const backupResult = await this.startSSHBackupExecution(
ws,
containerId,
backupExecutionId,
backupStorage,
server
);
// Backup completed (successfully or not)
if (!backupResult || !backupResult.success) {
// Backup failed, but we'll still allow update (per requirement 1b)
this.sendMessage(ws, {
type: 'output',
data: '\n⚠ Backup failed, but proceeding with update as requested...\n',
timestamp: Date.now()
});
});
} 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
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, {
type: 'start',
data: `Starting update for container ${containerId}...`,