diff --git a/app/Models/LocalPersistentVolume.php b/app/Models/LocalPersistentVolume.php index 6f194d0323..a7b2795db5 100644 --- a/app/Models/LocalPersistentVolume.php +++ b/app/Models/LocalPersistentVolume.php @@ -211,7 +211,8 @@ class LocalPersistentVolume extends BaseModel $resourceUuid = $resource instanceof Application ? $resource->uuid : data_get($resource, 'service.uuid'); $generatedName = $source ? $resourceUuid.'_'.Str::slug($source, '-') : null; - if ($generatedName === $this->name && $target && str($target)->start('/')->value() === $this->mount_path) { + if ($generatedName && $target && str($target)->start('/')->value() === $this->mount_path + && ($generatedName === $this->name || $this->isVolumeOfOpenPreview($resource, $generatedName))) { return true; } } @@ -223,6 +224,20 @@ class LocalPersistentVolume extends BaseModel } } + /** + * Whether this storage entry is the preview copy ("{volume}-pr-{id}") of the given volume for a + * preview that still exists. The preview cleanup removes the entry when the preview is closed. + */ + private function isVolumeOfOpenPreview(mixed $resource, string $generatedName): bool + { + if (! $resource instanceof Application + || preg_match('/^'.preg_quote($generatedName, '/').'-pr-(\d+)$/', $this->name, $matches) !== 1) { + return false; + } + + return $resource->previews()->where('pull_request_id', (int) $matches[1])->exists(); + } + /** * The external Compose volume that this storage entry still replaces, or null. Before Coolify * used external volumes as written, the parsers gave them a generated name, for example diff --git a/tests/Feature/ComposeStaleVolumeTest.php b/tests/Feature/ComposeStaleVolumeTest.php new file mode 100644 index 0000000000..17b2b251d4 --- /dev/null +++ b/tests/Feature/ComposeStaleVolumeTest.php @@ -0,0 +1,162 @@ + InstanceSettings::firstOrCreate(['id' => 0])); + config(['constants.ssh.mux_enabled' => false]); + Bus::fake(); + Queue::fake(); + Process::fake(['*' => Process::result(output: '')]); + $this->withoutVite(); + + $team = Team::factory()->create(); + $privateKey = PrivateKey::factory()->create(['team_id' => $team->id]); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'private_key_id' => $privateKey->id, + ]); + $server->settings()->update(['is_reachable' => true, 'is_usable' => true]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = $project->environments()->first() + ?? Environment::factory()->create(['project_id' => $project->id]); + + $user = User::factory()->create(); + $team->members()->attach($user->id, ['role' => 'owner']); + $this->actingAs($user); + session(['currentTeam' => $team]); + + $this->application = Application::factory()->create([ + 'build_pack' => 'dockercompose', + 'docker_compose_raw' => STALE_VOLUME_THREE_VOLUMES_COMPOSE, + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ])->fresh(); + applicationParser($this->application); + + $this->application->update(['docker_compose_raw' => STALE_VOLUME_TWO_VOLUMES_COMPOSE]); + applicationParser($this->application->fresh()); +}); + +function staleVolume(string $volume): LocalPersistentVolume +{ + return LocalPersistentVolume::where('name', test()->application->uuid.'_'.$volume)->sole(); +} + +it('offers to delete only the volume that the Compose file no longer declares', function () { + $forms = Livewire::test(All::class, ['resource' => $this->application->fresh()])->get('forms'); + + expect($forms[staleVolume('third')->id]['canDeleteStale'])->toBeTrue() + ->and($forms[staleVolume('first')->id]['canDeleteStale'])->toBeFalse() + ->and($forms[staleVolume('second')->id]['canDeleteStale'])->toBeFalse(); +}); + +it('deletes the entry of a volume that the Compose file no longer declares', function (bool $deleteDockerVolume) { + $name = staleVolume('third')->name; + $commands = []; + Process::fake(function ($process) use (&$commands) { + $commands[] = is_array($process->command) ? implode(' ', $process->command) : $process->command; + + return Process::result(output: ''); + }); + + Livewire::test(All::class, ['resource' => $this->application->fresh()]) + ->call('delete', staleVolume('third')->id, 'password', $deleteDockerVolume ? ['deleteDockerVolume'] : []) + ->assertReturned(true); + + expect(LocalPersistentVolume::where('name', $name)->exists())->toBeFalse() + ->and(implode("\n", $commands))->{$deleteDockerVolume ? 'toContain' : 'not'}("docker volume rm -f '{$name}'"); + + applicationParser($this->application->fresh()); + expect(LocalPersistentVolume::where('name', $name)->exists())->toBeFalse(); +})->with([ + 'keep the Docker volume' => [false], + 'delete the Docker volume' => [true], +]); + +it('does not delete the entry of a volume that the Compose file declares', function () { + $volume = staleVolume('first'); + + Livewire::test(All::class, ['resource' => $this->application->fresh()]) + ->call('delete', $volume->id, 'password') + ->assertReturned(false); + + expect(LocalPersistentVolume::find($volume->id))->not->toBeNull(); +}); + +it('does not offer to delete the preview volume of a volume that the Compose file declares', function () { + ApplicationPreview::create([ + 'uuid' => 'stale-volume-preview', + 'application_id' => $this->application->id, + 'pull_request_id' => 42, + 'pull_request_html_url' => 'https://github.com/example/repository/pull/42', + ]); + applicationParser($this->application->fresh(), 42); + $previewVolume = staleVolume('first-pr-42'); + + Livewire::test(All::class, ['resource' => $this->application->fresh()]) + ->assertSet("forms.{$previewVolume->id}.canDeleteStale", false) + ->call('delete', $previewVolume->id, 'password') + ->assertReturned(false); + + expect(LocalPersistentVolume::find($previewVolume->id))->not->toBeNull(); +}); + +it('offers to delete the left-over volume of a closed preview', function () { + $preview = ApplicationPreview::create([ + 'uuid' => 'stale-volume-closed-preview', + 'application_id' => $this->application->id, + 'pull_request_id' => 7, + 'pull_request_html_url' => 'https://github.com/example/repository/pull/7', + ]); + applicationParser($this->application->fresh(), 7); + $previewVolume = staleVolume('first-pr-7'); + $preview->deleteQuietly(); + + Livewire::test(All::class, ['resource' => $this->application->fresh()]) + ->assertSet("forms.{$previewVolume->id}.canDeleteStale", true) + ->call('delete', $previewVolume->id, 'password') + ->assertReturned(true); + + expect(LocalPersistentVolume::find($previewVolume->id))->toBeNull(); +});