|
13 | 13 | import type { SSHKeyConnection } from '../types'; |
14 | 14 | import type { BackupDbType, BackupAccessoryConfig } from '../utils/config'; |
15 | 15 | import { ok, err, type Result } from '../types'; |
16 | | -import { sshExec, shellEscape } from '../utils/ssh'; |
| 16 | +import { sshExec, sshExecChannel, shellEscape } from '../utils/ssh'; |
17 | 17 | import { formatBytes, printDebug } from '../utils/output'; |
18 | 18 | import { findSwarmContainer } from './orchestrator/swarm/swarm-utils'; |
19 | 19 | import { SwarmStackBackend } from './orchestrator/swarm/swarm-stack'; |
@@ -167,7 +167,7 @@ const DB_STRATEGIES: Record<Exclude<BackupDbType, 'raw' | 'volume'>, DbStrategy> |
167 | 167 | buildDumpCommand() { |
168 | 168 | // Trigger BGSAVE and wait for it to complete by polling LASTSAVE. |
169 | 169 | // The BGSAVE/LASTSAVE output is redirected to /dev/null — only the raw RDB bytes are emitted. |
170 | | - return 'BEFORE=$(redis-cli LASTSAVE) && redis-cli BGSAVE > /dev/null && for i in $(seq 1 30); do AFTER=$(redis-cli LASTSAVE); [ "$AFTER" != "$BEFORE" ] && break; sleep 1; done && cat /data/dump.rdb'; |
| 170 | + return 'BEFORE=$(redis-cli LASTSAVE) && OUT=$(redis-cli BGSAVE) && echo "$OUT" | grep -q "Background saving started" || { echo "BGSAVE failed: $OUT" >&2; exit 1; } && for i in $(seq 1 30); do AFTER=$(redis-cli LASTSAVE); [ "$AFTER" != "$BEFORE" ] && break; sleep 1; done && cat /data/dump.rdb'; |
171 | 171 | }, |
172 | 172 | buildRestoreCommand() { |
173 | 173 | // Write the RDB file, then SHUTDOWN NOSAVE so Redis doesn't overwrite it |
@@ -392,10 +392,9 @@ export class Backup { |
392 | 392 | nodePort: nodeConn.port, |
393 | 393 | }; |
394 | 394 |
|
395 | | - await sshExec( |
396 | | - nodeConn, |
397 | | - `cat > '${shellEscape(metaPath)}' << 'DOCKFLOW_EOF'\n${JSON.stringify(metadata, null, 2)}\nDOCKFLOW_EOF` |
398 | | - ); |
| 395 | + const { stream, done } = await sshExecChannel(nodeConn, `cat > '${shellEscape(metaPath)}'`); |
| 396 | + stream.end(JSON.stringify(metadata, null, 2)); |
| 397 | + await done; |
399 | 398 |
|
400 | 399 | return ok(metadata); |
401 | 400 | } |
@@ -554,10 +553,9 @@ export class Backup { |
554 | 553 | // Extended metadata includes per-volume/bind details |
555 | 554 | const extendedMeta = { ...metadata, volumes: volumeEntries }; |
556 | 555 | const metaPath = `${backupDir}/${backupId}.meta.json`; |
557 | | - await sshExec( |
558 | | - nodeConn, |
559 | | - `cat > '${shellEscape(metaPath)}' << 'DOCKFLOW_EOF'\n${JSON.stringify(extendedMeta, null, 2)}\nDOCKFLOW_EOF` |
560 | | - ); |
| 556 | + const { stream, done } = await sshExecChannel(nodeConn, `cat > '${shellEscape(metaPath)}'`); |
| 557 | + stream.end(JSON.stringify(extendedMeta, null, 2)); |
| 558 | + await done; |
561 | 559 |
|
562 | 560 | return ok(metadata); |
563 | 561 | } |
@@ -607,7 +605,7 @@ export class Backup { |
607 | 605 | if (mountType === 'bind' && entry.sourcePath) { |
608 | 606 | // Bind mount: clear target and extract directly on the host |
609 | 607 | const src = shellEscape(entry.sourcePath); |
610 | | - restoreCmd = `rm -rf '${src}'/* '${src}'/..?* '${src}'/.[!.]* 2>/dev/null; tar xf - -C '${src}'`; |
| 608 | + restoreCmd = `find '${src}' -mindepth 1 -delete && tar xf - -C '${src}'`; |
611 | 609 | } else { |
612 | 610 | // Named volume: extract via temporary alpine container |
613 | 611 | const fullVolumeName = existingVolumes.find( |
|
0 commit comments