Skip to content

Commit a20a263

Browse files
committed
Stage restore downloads on the data volume, not the OS disk (#344)
Restore-to-download extracted the full selection to /tmp — the (often small) root filesystem. A large download filled it, which starved the server-side borg serve of temp space and crashed every running backup with 'No space left on device' self-test failures, while the actual backup disk sat idle. Downloads (and the S3 server-restore staging) now stage under /var/bbs/tmp on the volume sized for backup data, with /tmp as a fallback when that isn't writable. A pre-flight check estimates the selection's uncompressed size (catalog sum for partial selections, the archive's original_size for full ones) and refuses with a clear message when the staging disk can't hold it, instead of dying mid-extract. Cleanup now survives the browser disconnecting mid-stream (ignore_user_abort + shutdown handler), and the scheduler sweeps staging directories older than 24h hourly as a crash safety net.
1 parent 7ff02cd commit a20a263

4 files changed

Lines changed: 120 additions & 14 deletions

File tree

bin/bbs-ssh-helper

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -191,9 +191,10 @@ case "$ACTION" in
191191
exit 1
192192
fi
193193

194-
# Safety: tmpdir must be under /tmp
195-
if [[ ! "$TMPDIR" =~ ^/tmp/ ]]; then
196-
echo "Error: tmpdir must be under /tmp/"
194+
# Safety: staging must be under the data volume's tmp (preferred —
195+
# sized for backup data, #344) or legacy /tmp
196+
if [[ ! "$TMPDIR" =~ ^(/var/bbs/tmp|/tmp)/ ]]; then
197+
echo "Error: tmpdir must be under /var/bbs/tmp/ or /tmp/"
197198
exit 1
198199
fi
199200

@@ -1163,8 +1164,8 @@ case "$ACTION" in
11631164
echo "Error: filename must match bbs-backup-*.tar.gz"
11641165
exit 1
11651166
fi
1166-
if [[ ! "$DEST_DIR" =~ ^/tmp/bbs-restore-[0-9a-f]+$ ]]; then
1167-
echo "Error: dest_dir must be a /tmp/bbs-restore-* staging directory"
1167+
if [[ ! "$DEST_DIR" =~ ^(/var/bbs/tmp|/tmp)/bbs-restore-[0-9a-f]+$ ]]; then
1168+
echo "Error: dest_dir must be a bbs-restore-* staging directory under /var/bbs/tmp or /tmp"
11681169
exit 1
11691170
fi
11701171

@@ -1205,8 +1206,8 @@ case "$ACTION" in
12051206
echo "Usage: bbs-ssh-helper server-restore <backup_file>"
12061207
exit 1
12071208
fi
1208-
if [[ ! "$BACKUP_FILE" =~ ^/tmp/bbs-restore-[0-9a-f]+/bbs-backup-[A-Za-z0-9._-]+\.tar\.gz$ ]]; then
1209-
echo "Error: backup file must be inside a /tmp/bbs-restore-* staging directory"
1209+
if [[ ! "$BACKUP_FILE" =~ ^(/var/bbs/tmp|/tmp)/bbs-restore-[0-9a-f]+/bbs-backup-[A-Za-z0-9._-]+\.tar\.gz$ ]]; then
1210+
echo "Error: backup file must be inside a bbs-restore-* staging directory under /var/bbs/tmp or /tmp"
12101211
exit 1
12111212
fi
12121213
if [ ! -f "$BACKUP_FILE" ]; then
@@ -1382,9 +1383,9 @@ SSHEOF
13821383
exit 1
13831384
fi
13841385

1385-
# Safety: tmpdir must be under /tmp
1386-
if [[ ! "$TMPDIR" =~ ^/tmp/bbs-download- ]]; then
1387-
echo "Error: tmpdir must be under /tmp/bbs-download-*"
1386+
# Safety: must be a download staging dir on the data volume (or legacy /tmp)
1387+
if [[ ! "$TMPDIR" =~ ^(/var/bbs/tmp|/tmp)/bbs-download- ]]; then
1388+
echo "Error: tmpdir must be under /var/bbs/tmp/bbs-download-* or /tmp/bbs-download-*"
13881389
exit 1
13891390
fi
13901391

scheduler.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2075,6 +2075,16 @@
20752075
}
20762076
}
20772077

2078+
// Step 4c: Sweep stale download/restore staging directories (hourly).
2079+
// Normally cleaned by the request that created them, but a PHP crash or
2080+
// container restart mid-download can strand multi-GB extractions (#344).
2081+
if ((int) date('i') === 0) {
2082+
foreach (['/var/bbs/tmp', '/tmp'] as $stagingBase) {
2083+
if (!is_dir($stagingBase)) continue;
2084+
exec('find ' . escapeshellarg($stagingBase) . ' -maxdepth 1 \\( -name "bbs-download-*" -o -name "bbs-restore-*" \\) -mmin +1440 -exec rm -rf {} + 2>/dev/null');
2085+
}
2086+
}
2087+
20782088
// Step 5: Bootstrap size for any local repo whose size_bytes is still 0
20792089
// (fresh install, newly added repo, or legacy migration). Runs every minute
20802090
// but only touches disks once per repo, since the UPDATE makes size_bytes > 0.

src/Controllers/ClientController.php

Lines changed: 93 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1374,6 +1374,56 @@ public function restoreMongoSubmit(int $id): void
13741374
* POST /clients/{id}/download
13751375
* Extracts selected paths from a borg archive on the server and streams as tar.gz.
13761376
*/
1377+
/**
1378+
* Estimate the uncompressed size of a download selection so the space
1379+
* pre-flight can refuse before extraction (#344). Full archive: the
1380+
* stored original_size. Selection: catalog sum over the selected path
1381+
* prefixes (normalized to the catalog's leading-slash form, nested
1382+
* selections deduped). Returns null when no estimate is possible.
1383+
*/
1384+
private function estimateDownloadBytes(int $agentId, int $archiveId, array $selectedFiles, int $archiveOriginalSize): ?int
1385+
{
1386+
if (empty($selectedFiles)) {
1387+
return $archiveOriginalSize > 0 ? $archiveOriginalSize : null;
1388+
}
1389+
try {
1390+
$ch = \BBS\Core\ClickHouse::getInstance();
1391+
if (!$ch->isAvailable()) {
1392+
return null;
1393+
}
1394+
$paths = array_values(array_filter(array_map(
1395+
fn($p) => '/' . trim((string) $p, '/'),
1396+
$selectedFiles
1397+
), fn($p) => $p !== '/'));
1398+
$roots = array_filter($paths, function ($p) use ($paths) {
1399+
foreach ($paths as $other) {
1400+
if ($other !== $p && str_starts_with($p, $other . '/')) return false;
1401+
}
1402+
return true;
1403+
});
1404+
1405+
$conds = [];
1406+
$params = [$agentId, $archiveId];
1407+
foreach ($roots as $p) {
1408+
$conds[] = '(path = ? OR startsWith(path, ?))';
1409+
$params[] = $p;
1410+
$params[] = $p . '/';
1411+
}
1412+
if (!$conds) {
1413+
return null;
1414+
}
1415+
$row = $ch->fetchOne(
1416+
"SELECT sum(file_size) AS bytes FROM file_catalog
1417+
WHERE agent_id = ? AND archive_id = ? AND (" . implode(' OR ', $conds) . ")",
1418+
$params
1419+
);
1420+
$bytes = (int) ($row['bytes'] ?? 0);
1421+
return $bytes > 0 ? $bytes : null;
1422+
} catch (\Exception $e) {
1423+
return null;
1424+
}
1425+
}
1426+
13771427
public function download(int $id): void
13781428
{
13791429
$this->requireAuth();
@@ -1428,12 +1478,53 @@ public function download(int $id): void
14281478
$localPath = $isRemoteSsh ? $archive['repo_path'] : \BBS\Services\BorgCommandBuilder::getLocalRepoPath($repo);
14291479
$env = \BBS\Services\BorgCommandBuilder::buildEnv($repo, false);
14301480

1431-
// Create temp directory for extraction
1432-
$tmpDir = '/tmp/bbs-download-' . bin2hex(random_bytes(8));
1481+
// Stage the extraction under the data volume, NOT the OS disk (#344):
1482+
// /tmp lives on the (often small) root filesystem — a large download
1483+
// filled it and starved borg-serve of temp space, crashing every
1484+
// running backup. /var/bbs is the volume actually sized for backups.
1485+
$stagingBase = '/var/bbs/tmp';
1486+
if (!is_dir($stagingBase) && !@mkdir($stagingBase, 0770, true)) {
1487+
$stagingBase = sys_get_temp_dir(); // fall back to /tmp if the volume is unwritable
1488+
}
1489+
1490+
// Pre-flight: refuse up front when the staging disk can't hold the
1491+
// selection, instead of dying mid-extract with a full disk.
1492+
$neededBytes = $this->estimateDownloadBytes($id, $archive_id, $selectedFiles, (int) $archive['original_size']);
1493+
$freeBytes = (float) @disk_free_space($stagingBase);
1494+
if ($neededBytes !== null && $freeBytes > 0 && $neededBytes * 1.05 > $freeBytes) {
1495+
$this->flash('danger', sprintf(
1496+
'Not enough space to prepare this download: it needs about %s, but only %s is free on the server (%s). Download a smaller selection or free up space.',
1497+
\BBS\Services\ServerStats::formatBytes((int) $neededBytes),
1498+
\BBS\Services\ServerStats::formatBytes((int) $freeBytes),
1499+
$stagingBase
1500+
));
1501+
$this->redirect("/clients/{$id}?tab=restore");
1502+
return;
1503+
}
1504+
1505+
$tmpDir = $stagingBase . '/bbs-download-' . bin2hex(random_bytes(8));
14331506
mkdir($tmpDir, 0700, true);
14341507

14351508
$remoteSshKeyFile = null; // Track temp SSH key for cleanup
14361509

1510+
// The user closing the browser mid-download kills this request after
1511+
// the extract has already landed on disk — make sure the staging dir
1512+
// is removed no matter how the request ends.
1513+
ignore_user_abort(true);
1514+
register_shutdown_function(function () use ($tmpDir, &$remoteSshKeyFile) {
1515+
if ($remoteSshKeyFile && file_exists($remoteSshKeyFile)) {
1516+
@unlink($remoteSshKeyFile);
1517+
}
1518+
if (is_dir($tmpDir)) {
1519+
exec('rm -rf ' . escapeshellarg($tmpDir) . ' 2>/dev/null');
1520+
if (is_dir($tmpDir)) {
1521+
// Files may be owned by the repo's bbs-* user — remove via helper perms fix + retry
1522+
exec('sudo /usr/local/bin/bbs-ssh-helper fix-download-perms ' . escapeshellarg($tmpDir) . ' 2>/dev/null');
1523+
exec('rm -rf ' . escapeshellarg($tmpDir) . ' 2>/dev/null');
1524+
}
1525+
}
1526+
});
1527+
14371528
try {
14381529
// Build borg extract args: repo::archive + selected paths
14391530
$borgArgs = [$localPath . '::' . $archive['archive_name']];

src/Controllers/StorageLocationController.php

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -351,8 +351,12 @@ public function restoreS3Backup(): void
351351
return;
352352
}
353353

354-
// Create temp directory
355-
$tmpDir = '/tmp/bbs-restore-' . bin2hex(random_bytes(8));
354+
// Stage under the data volume, not the OS disk (#344)
355+
$stagingBase = '/var/bbs/tmp';
356+
if (!is_dir($stagingBase) && !@mkdir($stagingBase, 0770, true)) {
357+
$stagingBase = '/tmp';
358+
}
359+
$tmpDir = $stagingBase . '/bbs-restore-' . bin2hex(random_bytes(8));
356360
mkdir($tmpDir, 0700, true);
357361

358362
$helper = '/usr/local/bin/bbs-ssh-helper';

0 commit comments

Comments
 (0)