refactor: backup schreibt Dump zuerst lokal, dann scp

Statt direkter pipe dump→gzip→ssh wird der Dump jetzt in
BACKUP_LOCAL_DIR (default /tmp/logbuch-backup) geschrieben
und danach per scp übertragen. So ist der Dump jederzeit
im Container einsehbar; SSH bleibt optional.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 14:23:59 +02:00
parent fb0b64c36c
commit c3bac456e7
+44 -29
View File
@@ -1,19 +1,15 @@
import { spawn } from 'child_process'; import { spawn } from 'child_process';
import { createWriteStream, mkdirSync } from 'fs';
import { join } from 'path';
export function triggerBackup(): void { export function triggerBackup(): void {
setImmediate(() => runBackup().catch((e) => console.error('[backup] Fehler:', e))); setImmediate(() => runBackup().catch((e) => console.error('[backup] Fehler:', e)));
} }
async function runBackup(): Promise<void> { async function runBackup(): Promise<void> {
const sshUrl = process.env.BACKUP_SSH_URL; const sshUrl = process.env.BACKUP_SSH_URL || '';
if (!sshUrl) return; const localDir = process.env.BACKUP_LOCAL_DIR || '/tmp/logbuch-backup';
const match = sshUrl.match(/^([^:]+):(.+)$/);
if (!match) {
console.error('[backup] BACKUP_SSH_URL muss das Format user@host:/pfad haben');
return;
}
const [, sshHost, remotePath] = match;
const rawKeyPath = process.env.BACKUP_SSH_KEY_PATH || ''; const rawKeyPath = process.env.BACKUP_SSH_KEY_PATH || '';
const keyPath = rawKeyPath.startsWith('~') const keyPath = rawKeyPath.startsWith('~')
? rawKeyPath.replace('~', process.env.HOME || '/root') ? rawKeyPath.replace('~', process.env.HOME || '/root')
@@ -27,16 +23,12 @@ async function runBackup(): Promise<void> {
const dbUser = process.env.DB_USER || ''; const dbUser = process.env.DB_USER || '';
const dbPass = process.env.DB_PASS || ''; const dbPass = process.env.DB_PASS || '';
const dbName = process.env.DB_NAME || 'sternwarte'; const dbName = process.env.DB_NAME || 'sternwarte';
const sshOpts = [
...(keyPath ? ['-i', keyPath] : []),
'-o', 'StrictHostKeyChecking=no',
'-o', 'BatchMode=yes',
'-o', 'ConnectTimeout=15',
];
const dumpBin = process.env.BACKUP_DUMP_CMD || 'mariadb-dump'; const dumpBin = process.env.BACKUP_DUMP_CMD || 'mariadb-dump';
mkdirSync(localDir, { recursive: true });
const localPath = join(localDir, filename);
// Schritt 1: Dump lokal schreiben
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
const dump = spawn(dumpBin, [ const dump = spawn(dumpBin, [
`-h${dbHost}`, `-P${dbPort}`, `-u${dbUser}`, `-h${dbHost}`, `-P${dbPort}`, `-u${dbUser}`,
@@ -44,34 +36,57 @@ async function runBackup(): Promise<void> {
`--ignore-table=${dbName}.beos`, `--ignore-table=${dbName}.beos`,
dbName, dbName,
], { env: { ...process.env, MYSQL_PWD: dbPass } }); ], { env: { ...process.env, MYSQL_PWD: dbPass } });
const gzip = spawn('gzip'); const gzip = spawn('gzip');
const ssh = spawn('ssh', [...sshOpts, sshHost, `cat > ${remotePath}/${filename}`]); const file = createWriteStream(localPath);
dump.stdout.pipe(gzip.stdin); dump.stdout.pipe(gzip.stdin);
gzip.stdout.pipe(ssh.stdin); gzip.stdout.pipe(file);
let dumpErr = ''; let dumpErr = '';
let sshErr = '';
dump.stderr.on('data', (d: Buffer) => { dumpErr += d.toString(); }); dump.stderr.on('data', (d: Buffer) => { dumpErr += d.toString(); });
ssh.stderr.on('data', (d: Buffer) => { sshErr += d.toString(); });
dump.on('error', reject); dump.on('error', reject);
gzip.on('error', reject); gzip.on('error', reject);
ssh.on('error', reject); file.on('error', reject);
file.on('close', () => {
if (dumpErr.trim()) console.log('[backup] dump stderr:', dumpErr.trim());
resolve();
});
});
dump.on('close', (code) => { if (code !== 0) gzip.stdin.end(); }); console.log(`[backup] Dump geschrieben: ${localPath}`);
gzip.on('close', () => ssh.stdin.end());
ssh.on('close', (code) => { // Schritt 2: Per SCP auf externen Server übertragen (optional)
if (sshUrl) {
const match = sshUrl.match(/^([^:]+):(.+)$/);
if (!match) {
console.error('[backup] BACKUP_SSH_URL muss das Format user@host:/pfad haben');
} else {
const [, sshHost, remotePath] = match;
const sshOpts = [
...(keyPath ? ['-i', keyPath] : []),
'-o', 'StrictHostKeyChecking=no',
'-o', 'BatchMode=yes',
'-o', 'ConnectTimeout=15',
];
await new Promise<void>((resolve, reject) => {
const scp = spawn('scp', [...sshOpts, localPath, `${sshHost}:${remotePath}/${filename}`]);
let scpErr = '';
scp.stderr.on('data', (d: Buffer) => { scpErr += d.toString(); });
scp.on('error', reject);
scp.on('close', (code) => {
if (code === 0) { if (code === 0) {
resolve(); resolve();
} else { } else {
reject(new Error(`ssh exit ${code}${sshErr ? ': ' + sshErr.trim() : ''}${dumpErr ? ' | dump: ' + dumpErr.trim() : ''}`)); reject(new Error(`scp exit ${code}${scpErr ? ': ' + scpErr.trim() : ''}`));
} }
}); });
}); });
// Backups älter als 30 Tage löschen console.log(`[backup] ${filename}${sshHost}:${remotePath}`);
// Backups älter als 30 Tage auf Remote löschen
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
const ssh = spawn('ssh', [ const ssh = spawn('ssh', [
...sshOpts, sshHost, ...sshOpts, sshHost,
@@ -83,6 +98,6 @@ async function runBackup(): Promise<void> {
resolve(); resolve();
}); });
}); });
}
console.log(`[backup] ${filename}${sshHost}:${remotePath}`); }
} }