diff --git a/lib/backup.ts b/lib/backup.ts index 937093b..e8d50f2 100644 --- a/lib/backup.ts +++ b/lib/backup.ts @@ -1,112 +1,125 @@ -import { spawn } from 'child_process'; import { createWriteStream, mkdirSync } from 'fs'; +import { createGzip } from 'zlib'; import { join } from 'path'; +import { spawn } from 'child_process'; +import { getPool } from './db'; export function triggerBackup(): void { setImmediate(() => runBackup().catch((e) => console.error('[backup] Fehler:', e))); } +async function dumpToFile(filePath: string): Promise { + const dbName = process.env.DB_NAME || 'sternwarte'; + const pool = getPool(); + + const gzip = createGzip(); + const file = createWriteStream(filePath); + gzip.pipe(file); + + const write = (s: string) => new Promise((res, rej) => + gzip.write(s, (e) => e ? rej(e) : res()) + ); + + const now = new Date().toISOString(); + await write(`-- Führungsbuch Backup ${now}\n-- Datenbank: ${dbName} (ohne Tabelle beos)\n\nSET FOREIGN_KEY_CHECKS=0;\n\n`); + + const [tableRows] = await pool.query('SHOW TABLES') as [Record[], unknown]; + const tables = tableRows + .map((r) => Object.values(r)[0]) + .filter((t) => t !== 'beos'); + + for (const table of tables) { + const [[createRow]] = await pool.query(`SHOW CREATE TABLE \`${table}\``) as [Record[], unknown]; + const createSql = Object.values(createRow)[1]; + + await write(`DROP TABLE IF EXISTS \`${table}\`;\n`); + await write(`${createSql};\n\n`); + + const [rows] = await pool.query(`SELECT * FROM \`${table}\``) as [Record[], unknown]; + if (rows.length > 0) { + const cols = Object.keys(rows[0]).map((c) => `\`${c}\``).join(', '); + const batchSize = 200; + for (let i = 0; i < rows.length; i += batchSize) { + const batch = rows.slice(i, i + batchSize); + const values = batch.map((row) => + '(' + Object.values(row).map((v) => { + if (v === null) return 'NULL'; + if (typeof v === 'number') return String(v); + if (v instanceof Date) return `'${v.toISOString().slice(0, 19).replace('T', ' ')}'`; + return `'${String(v).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`; + }).join(', ') + ')' + ).join(',\n '); + await write(`INSERT INTO \`${table}\` (${cols}) VALUES\n ${values};\n`); + } + await write('\n'); + } + } + + await write('SET FOREIGN_KEY_CHECKS=1;\n'); + + await new Promise((resolve, reject) => { + gzip.end(); + file.on('close', resolve); + file.on('error', reject); + }); +} + async function runBackup(): Promise { const sshUrl = process.env.BACKUP_SSH_URL || ''; const localDir = process.env.BACKUP_LOCAL_DIR || '/tmp/logbuch-backup'; + const ts = new Date().toISOString().replace('T', '_').replace(/:/g, '-').slice(0, 19); + const filename = `sternwarte_${ts}.sql.gz`; + + mkdirSync(localDir, { recursive: true }); + const localPath = join(localDir, filename); + + await dumpToFile(localPath); + console.log(`[backup] Dump geschrieben: ${localPath}`); + + if (!sshUrl) return; + + 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 keyPath = rawKeyPath.startsWith('~') ? rawKeyPath.replace('~', process.env.HOME || '/root') : rawKeyPath; - const ts = new Date().toISOString().replace('T', '_').replace(/:/g, '-').slice(0, 19); - const filename = `sternwarte_${ts}.sql.gz`; + const sshOpts = [ + ...(keyPath ? ['-i', keyPath] : []), + '-o', 'StrictHostKeyChecking=no', + '-o', 'BatchMode=yes', + '-o', 'ConnectTimeout=15', + ]; - const dbHost = process.env.DB_HOST || 'db'; - const dbPort = process.env.DB_PORT || '3306'; - const dbUser = process.env.DB_USER || ''; - const dbPass = process.env.DB_PASS || ''; - const dbName = process.env.DB_NAME || 'sternwarte'; - 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((resolve, reject) => { - const dump = spawn(dumpBin, [ - `-h${dbHost}`, `-P${dbPort}`, `-u${dbUser}`, - '--skip-ssl', - '--default-auth=mysql_native_password', - `--ignore-table=${dbName}.beos`, - dbName, - ], { env: { ...process.env, MYSQL_PWD: dbPass } }); - - const gzip = spawn('gzip'); - const file = createWriteStream(localPath); - - dump.stdout.pipe(gzip.stdin); - gzip.stdout.pipe(file); - - let dumpErr = ''; - let dumpCode: number | null = null; - dump.stderr.on('data', (d: Buffer) => { dumpErr += d.toString(); }); - dump.on('error', reject); - gzip.on('error', reject); - file.on('error', reject); - dump.on('close', (code) => { - dumpCode = code; - if (code !== 0) gzip.stdin.end(); - }); - file.on('close', () => { - if (dumpCode !== 0) { - reject(new Error(`${dumpBin} exit ${dumpCode}: ${dumpErr.trim()}`)); - } else { - resolve(); - } - }); + 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) => + code === 0 ? resolve() : reject(new Error(`scp exit ${code}${scpErr ? ': ' + scpErr.trim() : ''}`)) + ); }); - console.log(`[backup] Dump geschrieben: ${localPath}`); + console.log(`[backup] ${filename} → ${sshHost}:${remotePath}`); - // 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((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) { - resolve(); - } else { - reject(new Error(`scp exit ${code}${scpErr ? ': ' + scpErr.trim() : ''}`)); - } - }); - }); - - console.log(`[backup] ${filename} → ${sshHost}:${remotePath}`); - - // Backups älter als 30 Tage auf Remote löschen - await new Promise((resolve) => { - const ssh = spawn('ssh', [ - ...sshOpts, sshHost, - `find ${remotePath} -name 'sternwarte_*.sql.gz' -mtime +30 -delete`, - ]); - ssh.on('error', (e) => { console.error('[backup] Cleanup spawn-Fehler:', e.message); resolve(); }); - ssh.on('close', (code) => { - if (code !== 0) console.error('[backup] Cleanup fehlgeschlagen (exit ' + code + ')'); - resolve(); - }); - }); - } - } + // Backups älter als 30 Tage auf Remote löschen + await new Promise((resolve) => { + const ssh = spawn('ssh', [ + ...sshOpts, sshHost, + `find ${remotePath} -name 'sternwarte_*.sql.gz' -mtime +30 -delete`, + ]); + ssh.on('error', (e) => { console.error('[backup] Cleanup spawn-Fehler:', e.message); resolve(); }); + ssh.on('close', (code) => { + if (code !== 0) console.error('[backup] Cleanup fehlgeschlagen (exit ' + code + ')'); + resolve(); + }); + }); }