From 449d099bd3f1d666472dd5753355ec48be16a1be Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:41:15 +0200 Subject: [PATCH] fix(backups): accept tar exit code 1 in volume backups GNU tar exits with 1 when a file changes while it is read. The archive is still valid, so only exit codes above 1 fail the backup. Co-Authored-By: Claude Opus 5.5 --- app/Jobs/VolumeBackupJob.php | 13 ++++++++++--- tests/Feature/VolumeBackupTest.php | 19 +++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/app/Jobs/VolumeBackupJob.php b/app/Jobs/VolumeBackupJob.php index 6d39ce6b42..0bf27ad35f 100644 --- a/app/Jobs/VolumeBackupJob.php +++ b/app/Jobs/VolumeBackupJob.php @@ -92,8 +92,7 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue ? 'docker volume inspect '.escapeshellarg($source).' >/dev/null' : 'test -d '.escapeshellarg($source); - $compressorCommand = BackupCompression::compressorCommand($compressionCpuPercentage); - $archiveScript = "compressor=\$({$compressorCommand}); tar -I \"\$compressor\" -cf - -C /volume ."; + $archiveScript = self::archiveScript($compressionCpuPercentage); if ($streamToS3) { $this->execution->update(['local_storage_deleted' => true]); $archiveCommand = $this->streamToS3Command($archiveScript, $backupLocation, $source, $containerName, $image); @@ -289,8 +288,16 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue } /** - * @return array + * GNU tar exits with 1 when a file changes while it is read (for example, an app that writes to the volume + * during a live backup). The archive is still valid, so only exit codes above 1 fail the backup. */ + public static function archiveScript(int $compressionCpuPercentage): string + { + $compressorCommand = BackupCompression::compressorCommand($compressionCpuPercentage); + + return "compressor=\$({$compressorCommand}); { tar -I \"\$compressor\" -cf - -C /volume . || { status=\$?; [ \$status -eq 1 ] || exit \$status; }; }"; + } + /** * Containers can exit and be removed (e.g. `--rm` backup helpers) between `docker ps` and * `docker inspect`. Skip those, but still fail when an existing container cannot be inspected. diff --git a/tests/Feature/VolumeBackupTest.php b/tests/Feature/VolumeBackupTest.php index 02b8676e11..4938287315 100644 --- a/tests/Feature/VolumeBackupTest.php +++ b/tests/Feature/VolumeBackupTest.php @@ -2969,3 +2969,22 @@ it('records a missed volume backup that is too old to run late as a failed execu Notification::assertSentToTimes($team, BackupFailed::class, 1); Notification::assertSentTo($team, BackupFailed::class, fn (BackupFailed $notification) => $notification->output === 'Skipped: the queued job did not start within 60 minutes.'); }); + +it('fails the volume archive only on fatal tar errors', function (int $tarExitCode, int $expectedExitCode) { + $bin = sys_get_temp_dir().'/volume-backup-tar-'.uniqid(); + mkdir($bin); + file_put_contents($bin.'/tar', "#!/bin/sh\nexit {$tarExitCode}\n"); + chmod($bin.'/tar', 0755); + + $result = Process::env(['PATH' => $bin.':/usr/bin:/bin']) + ->run(['sh', '-c', VolumeBackupJob::archiveScript(50)]); + + unlink($bin.'/tar'); + rmdir($bin); + + expect($result->exitCode())->toBe($expectedExitCode); +})->with([ + 'success' => [0, 0], + 'file changed as we read it' => [1, 0], + 'fatal error' => [2, 2], +]);