From c82cac626fe64820ae35a5cb20e2d7284cce1595 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:26:44 +0200 Subject: [PATCH] fix(volume-backups): skip containers removed during the stop-during-backup scan Closes #12167 --- app/Jobs/VolumeBackupJob.php | 8 +++- tests/Feature/VolumeBackupTest.php | 63 +++++++++++++++++++++++++++++- 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/app/Jobs/VolumeBackupJob.php b/app/Jobs/VolumeBackupJob.php index 7b1406233c..aa34999397 100644 --- a/app/Jobs/VolumeBackupJob.php +++ b/app/Jobs/VolumeBackupJob.php @@ -291,10 +291,16 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue /** * @return array */ + /** + * 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. + * + * @return array + */ private function containersUsingVolume(string $source, Server $server): array { $output = instant_remote_process( - ["containers=\$(docker ps -q) || exit 1; for container in \$containers; do paused=\$(docker inspect --format '{{.State.Paused}}' \"\$container\") || exit 1; [ \"\$paused\" = true ] && continue; mounts=\$(docker inspect --format '{{range .Mounts}}{{println .Source}}{{println .Name}}{{end}}' \"\$container\") || exit 1; if printf '%s\\n' \"\$mounts\" | grep -Fqx -- ".escapeshellarg($source).'; then echo "$container"; fi; done'], + ["containers=\$(docker ps -q) || exit 1; for container in \$containers; do mounts=\$(docker inspect --format '{{if not .State.Paused}}{{range .Mounts}}{{println .Source}}{{println .Name}}{{end}}{{end}}' \"\$container\" 2>/dev/null) || { remaining=\$(docker ps -aq --filter \"id=\$container\") || exit 1; [ -z \"\$remaining\" ] && continue; docker inspect \"\$container\" >/dev/null; exit 1; }; if printf '%s\\n' \"\$mounts\" | grep -Fqx -- ".escapeshellarg($source).'; then echo "$container"; fi; done'], $server, disableMultiplexing: true, ); diff --git a/tests/Feature/VolumeBackupTest.php b/tests/Feature/VolumeBackupTest.php index c254cea724..799e067d3f 100644 --- a/tests/Feature/VolumeBackupTest.php +++ b/tests/Feature/VolumeBackupTest.php @@ -40,6 +40,7 @@ use Illuminate\Routing\Redirector; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Notification; use Illuminate\Support\Facades\Process; @@ -49,6 +50,7 @@ use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Storage; use Livewire\Livewire; use Symfony\Component\HttpKernel\Exception\HttpException; +use Symfony\Component\Process\Process as SymfonyProcess; uses(RefreshDatabase::class); @@ -2354,10 +2356,69 @@ it('stops and restarts containers that use the volume when requested', function && str_contains($process->command, 'state_file=')); Process::assertRan(fn ($process) => str_contains($process->command, '{{println .Source}}{{println .Name}}') && str_contains($process->command, "grep -Fqx -- 'app-data'") - && str_contains($process->command, '{{.State.Paused}}') + && str_contains($process->command, '{{if not .State.Paused}}') && str_contains($process->command, 'continue')); }); +/** + * Runs the recorded container scan script in a local shell with a fake `docker` binary. + * + * @return array{exit_code: int, output: string} + */ +function runVolumeContainerScan(string $fakeDocker): array +{ + $scan = ''; + Process::assertRan(function ($process) use (&$scan): bool { + if (! str_contains($process->command, 'docker ps -q')) { + return false; + } + $scan = $process->command; + + return true; + }); + $script = implode("\n", array_slice(explode("\n", $scan), 1, -1)); + + $bin = sys_get_temp_dir().'/volume-scan-'.uniqid(); + mkdir($bin); + file_put_contents("{$bin}/docker", "#!/bin/sh\n{$fakeDocker}"); + chmod("{$bin}/docker", 0755); + + $result = new SymfonyProcess(['sh', '-c', $script], env: ['PATH' => $bin.':'.getenv('PATH')]); + $result->run(); + File::deleteDirectory($bin); + + return ['exit_code' => $result->getExitCode(), 'output' => trim($result->getOutput())]; +} + +it('skips containers that are removed while scanning for volume users', function () { + config(['broadcasting.default' => 'null']); + InstanceSettings::unguarded(fn () => InstanceSettings::create(['id' => 0])); + $team = Team::factory()->create(); + [$application, $volume] = createVolumeBackupApplication($team); + $backup = $volume->scheduledBackups()->create([ + 'team_id' => $team->id, + 'frequency' => 'daily', + 'stop_during_backup' => true, + ]); + + Process::fake(['*du -b*' => '128', '*' => '']); + (new VolumeBackupJob($backup))->handle(); + + $docker = <<<'SH' +case "$*" in + "ps -q") printf 'aaa111\nbbb222\nccc333\n' ;; + "ps -aq --filter id=aaa111") ;; + "ps -aq --filter id=ccc333") echo ccc333 ;; + *aaa111*) echo 'error: no such object: aaa111' >&2; exit 1 ;; + *bbb222*) printf '/var/lib/docker/volumes/app-data/_data\napp-data\n' ;; + *ccc333*) [ "$CCC_BROKEN" = 1 ] && { echo 'daemon error' >&2; exit 1; }; printf 'other\n' ;; +esac +SH; + + expect(runVolumeContainerScan($docker))->toBe(['exit_code' => 0, 'output' => 'bbb222']) + ->and(runVolumeContainerScan("CCC_BROKEN=1\n".$docker)['exit_code'])->not->toBe(0); +}); + it('finds containers using a bind mounted host path before stopping', function () { config(['broadcasting.default' => 'null']); InstanceSettings::unguarded(fn () => InstanceSettings::create(['id' => 0]));