Skip to content

Commit fe6cca5

Browse files
Merge pull request #331 from community-scripts/feat/lxc_backups
feat: Add LXC container backup functionality
2 parents 81c00f5 + 3a8088d commit fe6cca5

24 files changed

+4279
-58
lines changed
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
-- CreateTable
2+
CREATE TABLE IF NOT EXISTS "backups" (
3+
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
4+
"container_id" TEXT NOT NULL,
5+
"server_id" INTEGER NOT NULL,
6+
"hostname" TEXT NOT NULL,
7+
"backup_name" TEXT NOT NULL,
8+
"backup_path" TEXT NOT NULL,
9+
"size" BIGINT,
10+
"created_at" DATETIME,
11+
"storage_name" TEXT NOT NULL,
12+
"storage_type" TEXT NOT NULL,
13+
"discovered_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
14+
CONSTRAINT "backups_server_id_fkey" FOREIGN KEY ("server_id") REFERENCES "servers" ("id") ON DELETE CASCADE ON UPDATE CASCADE
15+
);
16+
17+
-- CreateTable
18+
CREATE TABLE IF NOT EXISTS "pbs_storage_credentials" (
19+
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
20+
"server_id" INTEGER NOT NULL,
21+
"storage_name" TEXT NOT NULL,
22+
"pbs_ip" TEXT NOT NULL,
23+
"pbs_datastore" TEXT NOT NULL,
24+
"pbs_password" TEXT NOT NULL,
25+
"pbs_fingerprint" TEXT NOT NULL,
26+
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
27+
"updated_at" DATETIME NOT NULL,
28+
CONSTRAINT "pbs_storage_credentials_server_id_fkey" FOREIGN KEY ("server_id") REFERENCES "servers" ("id") ON DELETE CASCADE ON UPDATE CASCADE
29+
);
30+
31+
-- CreateIndex
32+
CREATE INDEX IF NOT EXISTS "backups_container_id_idx" ON "backups"("container_id");
33+
34+
-- CreateIndex
35+
CREATE INDEX IF NOT EXISTS "backups_server_id_idx" ON "backups"("server_id");
36+
37+
-- CreateIndex
38+
CREATE INDEX IF NOT EXISTS "pbs_storage_credentials_server_id_idx" ON "pbs_storage_credentials"("server_id");
39+
40+
-- CreateIndex
41+
CREATE UNIQUE INDEX IF NOT EXISTS "pbs_storage_credentials_server_id_storage_name_key" ON "pbs_storage_credentials"("server_id", "storage_name");

prisma/schema.prisma

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ model Server {
4141
ssh_key_path String?
4242
key_generated Boolean? @default(false)
4343
installed_scripts InstalledScript[]
44+
backups Backup[]
45+
pbs_credentials PBSStorageCredential[]
4446
4547
@@map("servers")
4648
}
@@ -96,6 +98,42 @@ model LXCConfig {
9698
@@map("lxc_configs")
9799
}
98100

101+
model Backup {
102+
id Int @id @default(autoincrement())
103+
container_id String
104+
server_id Int
105+
hostname String
106+
backup_name String
107+
backup_path String
108+
size BigInt?
109+
created_at DateTime?
110+
storage_name String
111+
storage_type String // 'local', 'storage', or 'pbs'
112+
discovered_at DateTime @default(now())
113+
server Server @relation(fields: [server_id], references: [id], onDelete: Cascade)
114+
115+
@@index([container_id])
116+
@@index([server_id])
117+
@@map("backups")
118+
}
119+
120+
model PBSStorageCredential {
121+
id Int @id @default(autoincrement())
122+
server_id Int
123+
storage_name String
124+
pbs_ip String
125+
pbs_datastore String
126+
pbs_password String
127+
pbs_fingerprint String
128+
created_at DateTime @default(now())
129+
updated_at DateTime @updatedAt
130+
server Server @relation(fields: [server_id], references: [id], onDelete: Cascade)
131+
132+
@@unique([server_id, storage_name])
133+
@@index([server_id])
134+
@@map("pbs_storage_credentials")
135+
}
136+
99137
model Repository {
100138
id Int @id @default(autoincrement())
101139
url String @unique

restore.log

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
Starting restore...
2+
Reading container configuration...
3+
Stopping container...
4+
Destroying container...
5+
Logging into PBS...
6+
Downloading backup from PBS...
7+
Packing backup folder...
8+
Restoring container...
9+
Cleaning up temporary files...
10+
Restore completed successfully

server.js

Lines changed: 209 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -276,13 +276,15 @@ class ScriptExecutionHandler {
276276
* @param {WebSocketMessage} message
277277
*/
278278
async handleMessage(ws, message) {
279-
const { action, scriptPath, executionId, input, mode, server, isUpdate, isShell, containerId } = message;
279+
const { action, scriptPath, executionId, input, mode, server, isUpdate, isShell, isBackup, containerId, storage, backupStorage } = message;
280280

281281
switch (action) {
282282
case 'start':
283283
if (scriptPath && executionId) {
284-
if (isUpdate && containerId) {
285-
await this.startUpdateExecution(ws, containerId, executionId, mode, server);
284+
if (isBackup && containerId && storage) {
285+
await this.startBackupExecution(ws, containerId, executionId, storage, mode, server);
286+
} else if (isUpdate && containerId) {
287+
await this.startUpdateExecution(ws, containerId, executionId, mode, server, backupStorage);
286288
} else if (isShell && containerId) {
287289
await this.startShellExecution(ws, containerId, executionId, mode, server);
288290
} else {
@@ -660,18 +662,220 @@ class ScriptExecutionHandler {
660662
}
661663
}
662664

665+
/**
666+
* Start backup execution
667+
* @param {ExtendedWebSocket} ws
668+
* @param {string} containerId
669+
* @param {string} executionId
670+
* @param {string} storage
671+
* @param {string} mode
672+
* @param {ServerInfo|null} server
673+
*/
674+
async startBackupExecution(ws, containerId, executionId, storage, mode = 'local', server = null) {
675+
try {
676+
// Send start message
677+
this.sendMessage(ws, {
678+
type: 'start',
679+
data: `Starting backup for container ${containerId} to storage ${storage}...`,
680+
timestamp: Date.now()
681+
});
682+
683+
if (mode === 'ssh' && server) {
684+
await this.startSSHBackupExecution(ws, containerId, executionId, storage, server);
685+
} else {
686+
this.sendMessage(ws, {
687+
type: 'error',
688+
data: 'Backup is only supported via SSH',
689+
timestamp: Date.now()
690+
});
691+
}
692+
} catch (error) {
693+
this.sendMessage(ws, {
694+
type: 'error',
695+
data: `Failed to start backup: ${error instanceof Error ? error.message : String(error)}`,
696+
timestamp: Date.now()
697+
});
698+
}
699+
}
700+
701+
/**
702+
* Start SSH backup execution
703+
* @param {ExtendedWebSocket} ws
704+
* @param {string} containerId
705+
* @param {string} executionId
706+
* @param {string} storage
707+
* @param {ServerInfo} server
708+
* @param {Function} [onComplete] - Optional callback when backup completes
709+
*/
710+
startSSHBackupExecution(ws, containerId, executionId, storage, server, onComplete = null) {
711+
const sshService = getSSHExecutionService();
712+
713+
return new Promise((resolve, reject) => {
714+
try {
715+
const backupCommand = `vzdump ${containerId} --storage ${storage} --mode snapshot`;
716+
717+
// Wrap the onExit callback to resolve our promise
718+
let promiseResolved = false;
719+
720+
sshService.executeCommand(
721+
server,
722+
backupCommand,
723+
/** @param {string} data */
724+
(data) => {
725+
this.sendMessage(ws, {
726+
type: 'output',
727+
data: data,
728+
timestamp: Date.now()
729+
});
730+
},
731+
/** @param {string} error */
732+
(error) => {
733+
this.sendMessage(ws, {
734+
type: 'error',
735+
data: error,
736+
timestamp: Date.now()
737+
});
738+
},
739+
/** @param {number} code */
740+
(code) => {
741+
// Don't send 'end' message here if this is part of a backup+update flow
742+
// The update flow will handle completion messages
743+
const success = code === 0;
744+
745+
if (!success) {
746+
this.sendMessage(ws, {
747+
type: 'error',
748+
data: `Backup failed with exit code: ${code}`,
749+
timestamp: Date.now()
750+
});
751+
}
752+
753+
// Send a completion message (but not 'end' type to avoid stopping terminal)
754+
this.sendMessage(ws, {
755+
type: 'output',
756+
data: `\n[Backup ${success ? 'completed' : 'failed'} with exit code: ${code}]\n`,
757+
timestamp: Date.now()
758+
});
759+
760+
if (onComplete) onComplete(success);
761+
762+
// Resolve the promise when backup completes
763+
// Use setImmediate to ensure resolution happens in the right execution context
764+
if (!promiseResolved) {
765+
promiseResolved = true;
766+
const result = { success, code };
767+
768+
// Use setImmediate to ensure promise resolution happens in the next tick
769+
// This ensures the await in startUpdateExecution can properly resume
770+
setImmediate(() => {
771+
try {
772+
resolve(result);
773+
} catch (resolveError) {
774+
console.error('Error resolving backup promise:', resolveError);
775+
reject(resolveError);
776+
}
777+
});
778+
}
779+
780+
this.activeExecutions.delete(executionId);
781+
}
782+
).then((execution) => {
783+
// Store the execution
784+
this.activeExecutions.set(executionId, {
785+
process: /** @type {any} */ (execution).process,
786+
ws
787+
});
788+
// Note: Don't resolve here - wait for onExit callback
789+
}).catch((error) => {
790+
console.error('Error starting backup execution:', error);
791+
this.sendMessage(ws, {
792+
type: 'error',
793+
data: `SSH backup execution failed: ${error instanceof Error ? error.message : String(error)}`,
794+
timestamp: Date.now()
795+
});
796+
if (onComplete) onComplete(false);
797+
if (!promiseResolved) {
798+
promiseResolved = true;
799+
reject(error);
800+
}
801+
});
802+
803+
} catch (error) {
804+
console.error('Error in startSSHBackupExecution:', error);
805+
this.sendMessage(ws, {
806+
type: 'error',
807+
data: `SSH backup execution failed: ${error instanceof Error ? error.message : String(error)}`,
808+
timestamp: Date.now()
809+
});
810+
if (onComplete) onComplete(false);
811+
reject(error);
812+
}
813+
});
814+
}
815+
663816
/**
664817
* Start update execution (pct enter + update command)
665818
* @param {ExtendedWebSocket} ws
666819
* @param {string} containerId
667820
* @param {string} executionId
668821
* @param {string} mode
669822
* @param {ServerInfo|null} server
823+
* @param {string} [backupStorage] - Optional storage to backup to before update
670824
*/
671-
async startUpdateExecution(ws, containerId, executionId, mode = 'local', server = null) {
825+
async startUpdateExecution(ws, containerId, executionId, mode = 'local', server = null, backupStorage = null) {
672826
try {
827+
// If backup storage is provided, run backup first
828+
if (backupStorage && mode === 'ssh' && server) {
829+
this.sendMessage(ws, {
830+
type: 'start',
831+
data: `Starting backup before update for container ${containerId}...`,
832+
timestamp: Date.now()
833+
});
834+
835+
// Create a separate execution ID for backup
836+
const backupExecutionId = `backup_${executionId}`;
837+
838+
// Run backup and wait for it to complete
839+
try {
840+
const backupResult = await this.startSSHBackupExecution(
841+
ws,
842+
containerId,
843+
backupExecutionId,
844+
backupStorage,
845+
server
846+
);
847+
848+
// Backup completed (successfully or not)
849+
if (!backupResult || !backupResult.success) {
850+
// Backup failed, but we'll still allow update (per requirement 1b)
851+
this.sendMessage(ws, {
852+
type: 'output',
853+
data: '\n⚠️ Backup failed, but proceeding with update as requested...\n',
854+
timestamp: Date.now()
855+
});
856+
} else {
857+
// Backup succeeded
858+
this.sendMessage(ws, {
859+
type: 'output',
860+
data: '\n✅ Backup completed successfully. Starting update...\n',
861+
timestamp: Date.now()
862+
});
863+
}
864+
} catch (error) {
865+
console.error('Backup error before update:', error);
866+
// Backup failed to start, but allow update to proceed
867+
this.sendMessage(ws, {
868+
type: 'output',
869+
data: `\n⚠️ Backup error: ${error instanceof Error ? error.message : String(error)}. Proceeding with update...\n`,
870+
timestamp: Date.now()
871+
});
872+
}
873+
874+
// Small delay before starting update
875+
await new Promise(resolve => setTimeout(resolve, 1000));
876+
}
673877

674-
// Send start message
878+
// Send start message for update (only if we're actually starting an update)
675879
this.sendMessage(ws, {
676880
type: 'start',
677881
data: `Starting update for container ${containerId}...`,

0 commit comments

Comments
 (0)