fix(volume-backups): skip containers removed during the stop-during-backup scan

Closes #12167
This commit is contained in:
Andras Bacsai
2026-10-15 22:26:44 +02:00
parent 667ae3cdcb
commit c82cac626f
2 changed files with 69 additions and 2 deletions
+7 -1
View File
@@ -291,10 +291,16 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue
/**
* @return array<int, string>
*/
/**
* 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<int, string>
*/
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,
);
+62 -1
View File
@@ -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]));