InstanceSettings::firstOrCreate(['id' => 0])); config(['constants.ssh.mux_enabled' => false]); Bus::fake(); Queue::fake(); Process::fake(['*' => Process::result(output: '')]); $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]); $this->destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); $project = Project::factory()->create(['team_id' => $team->id]); $this->environment = $project->environments()->first() ?? Environment::factory()->create(['project_id' => $project->id]); }); function externalVolumeApplication(string $compose, ?string $parsingVersion = null): Application { $application = Application::factory()->create([ 'build_pack' => 'dockercompose', 'docker_compose_raw' => $compose, 'environment_id' => test()->environment->id, 'destination_id' => test()->destination->id, 'destination_type' => test()->destination->getMorphClass(), ]); if ($parsingVersion !== null) { $application->update(['compose_parsing_version' => $parsingVersion]); } return $application->fresh(); } function externalVolumeService(string $compose, ?string $parsingVersion = null): Service { $service = Service::factory()->create([ 'docker_compose_raw' => $compose, 'environment_id' => test()->environment->id, 'server_id' => test()->destination->server_id, 'destination_id' => test()->destination->id, 'destination_type' => test()->destination->getMorphClass(), ]); if ($parsingVersion !== null) { $service->update(['compose_parsing_version' => $parsingVersion]); } return $service->fresh(); } function externalVolumePreview(Application $application, int $pullRequestId = 42): ApplicationPreview { return ApplicationPreview::create([ 'uuid' => 'external-volume-preview-'.$pullRequestId, 'application_id' => $application->id, 'pull_request_id' => $pullRequestId, 'pull_request_html_url' => 'https://github.com/example/repository/pull/'.$pullRequestId, ]); } /** * @return list */ function persistentVolumeNames(): array { return LocalPersistentVolume::query()->pluck('name')->sort()->values()->all(); } /** * The storage entry that the parsers made for an external volume before they used it as written. */ function legacyExternalVolumeRow(Model $owner, string $name, string $mountPath = '/data'): LocalPersistentVolume { return $owner->persistentStorages()->create(['name' => $name, 'mount_path' => $mountPath]); } function legacyExternalVolumeServiceResource(Service $service, string $name, string $compose = EXTERNAL_VOLUME_SHORT_COMPOSE): ServiceApplication|ServiceDatabase { $image = data_get(Yaml::parse($compose), "services.{$name}.image"); return isDatabaseImage($image) ? ServiceDatabase::create(['name' => $name, 'service_id' => $service->id, 'image' => $image]) : ServiceApplication::create(['name' => $name, 'service_id' => $service->id, 'image' => $image]); } function legacyExternalVolumeWarning(string $oldName, ?string $dockerVolume = null): string { $external = $dockerVolume === null ? 'external' : "external (Docker volume '{$dockerVolume}')"; return "Volume 'shared-data' is declared as {$external}, but Coolify still uses '{$oldName}' because this resource used it before. To use the external volume, copy your data into it and delete the storage entry '{$oldName}', then redeploy."; } /** * Runs the Compose parse step of a deployment job. * * @return array{0: Collection, 1: list} */ function parseExternalVolumeDeployment(Application $application, int $pullRequestId = 0, ?ApplicationPreview $preview = null): array { $job = (new ReflectionClass(ApplicationDeploymentJob::class))->newInstanceWithoutConstructor(); $logEntries = new ArrayObject; $queue = Mockery::mock(ApplicationDeploymentQueue::class)->makePartial(); $queue->shouldReceive('addLogEntry')->andReturnUsing(function (string $message, string $type = 'stdout') use ($logEntries): void { $logEntries[] = [$message, $type]; }); foreach ([ 'application' => $application, 'application_deployment_queue' => $queue, 'pull_request_id' => $pullRequestId, 'preview' => $preview, 'commit' => 'HEAD', ] as $property => $value) { (new ReflectionProperty(ApplicationDeploymentJob::class, $property))->setValue($job, $value); } $composeFile = (new ReflectionMethod(ApplicationDeploymentJob::class, 'parseComposeFileForDeployment'))->invoke($job); return [$composeFile, $logEntries->getArrayCopy()]; } function loginExternalVolumeOwner(): void { test()->withoutVite(); $team = test()->environment->project->team; $user = User::factory()->create(); $team->members()->attach($user->id, ['role' => 'owner']); test()->actingAs($user); session(['currentTeam' => $team]); } /** * @param array $commands */ function fakeExternalVolumeServer(array &$commands): void { Process::fake(function ($process) use (&$commands) { $commands[] = is_array($process->command) ? implode(' ', $process->command) : $process->command; return Process::result(output: ''); }); } describe('applicationParser', function () { it('keeps an external volume in short syntax and renames only the other volumes', function () { $application = externalVolumeApplication(EXTERNAL_VOLUME_SHORT_COMPOSE); $uuid = $application->uuid; $compose = applicationParser($application)->toArray(); expect($compose['services']['web']['volumes'])->toContain('shared-data:/data:ro') ->toContain("{$uuid}_app-data:/app") ->and($compose['volumes']['shared-data'])->toBe(['external' => true]) ->and($compose['volumes'])->toHaveKey("{$uuid}_app-data") ->not->toHaveKey("{$uuid}_shared-data") ->and(persistentVolumeNames())->toBe(["{$uuid}_app-data"]); }); it('keeps an external volume in long syntax with its name', function () { $application = externalVolumeApplication(EXTERNAL_VOLUME_LONG_COMPOSE); $uuid = $application->uuid; $compose = applicationParser($application)->toArray(); $sources = collect($compose['services']['web']['volumes'])->pluck('source')->all(); expect($sources)->toBe(['shared-data', "{$uuid}_app-data"]) ->and($compose['volumes']['shared-data'])->toBe(['external' => true, 'name' => 'existing-shared-volume']) ->and($compose['volumes'])->not->toHaveKey("{$uuid}_shared-data") ->and(persistentVolumeNames())->toBe(["{$uuid}_app-data"]); }); it('keeps an external volume in the old external name syntax', function () { $application = externalVolumeApplication(EXTERNAL_VOLUME_OLD_SYNTAX_COMPOSE); $compose = applicationParser($application)->toArray(); expect($compose['services']['web']['volumes'])->toBe(['shared-data:/data']) ->and($compose['volumes'])->toBe(['shared-data' => ['external' => ['name' => 'legacy-shared-volume']]]) ->and(persistentVolumeNames())->toBe([]); }); it('shares the external volume with a preview deployment', function () { $application = externalVolumeApplication(EXTERNAL_VOLUME_SHORT_COMPOSE); $uuid = $application->uuid; $preview = externalVolumePreview($application); $compose = applicationParser($application, 42, $preview->id)->toArray(); expect($compose['services']['web-pr-42']['volumes'])->toContain('shared-data:/data:ro') ->toContain("{$uuid}_app-data-pr-42:/app") ->and($compose['volumes']['shared-data'])->toBe(['external' => true]) ->and($compose['volumes'])->not->toHaveKey("{$uuid}_shared-data-pr-42") ->and(persistentVolumeNames())->toBe(["{$uuid}_app-data-pr-42"]); }); it('does not show a warning when the resource has no old storage entry', function () { $application = externalVolumeApplication(EXTERNAL_VOLUME_SHORT_COMPOSE); applicationParser($application); expect($application->composeVolumeWarnings())->toBe([]); }); it('keeps the old prefixed volume and shows a warning when the resource has its old storage entry', function () { $application = externalVolumeApplication(EXTERNAL_VOLUME_SHORT_COMPOSE); $uuid = $application->uuid; $oldVolume = legacyExternalVolumeRow($application, "{$uuid}_shared-data"); $compose = applicationParser($application)->toArray(); expect($compose['services']['web']['volumes'])->toContain("{$uuid}_shared-data:/data:ro") ->not->toContain('shared-data:/data:ro') ->and($compose['volumes']["{$uuid}_shared-data"])->toBe(['name' => "{$uuid}_shared-data"]) ->and(LocalPersistentVolume::find($oldVolume->id)?->name)->toBe("{$uuid}_shared-data") ->and(persistentVolumeNames())->toBe(["{$uuid}_app-data", "{$uuid}_shared-data"]) ->and($application->composeVolumeWarnings())->toBe([legacyExternalVolumeWarning("{$uuid}_shared-data")]); }); it('keeps the old prefixed volume in long syntax and names the external Docker volume in the warning', function () { $application = externalVolumeApplication(EXTERNAL_VOLUME_LONG_COMPOSE); $uuid = $application->uuid; legacyExternalVolumeRow($application, "{$uuid}_shared-data"); $compose = applicationParser($application)->toArray(); $sources = collect($compose['services']['web']['volumes'])->pluck('source')->all(); expect($sources)->toBe(["{$uuid}_shared-data", "{$uuid}_app-data"]) ->and($compose['volumes']["{$uuid}_shared-data"])->toBe(['name' => "{$uuid}_shared-data"]) ->and($application->composeVolumeWarnings())->toBe([legacyExternalVolumeWarning("{$uuid}_shared-data", 'existing-shared-volume')]); }); it('keeps the old prefixed preview volume when the resource has its old storage entry', function () { $application = externalVolumeApplication(EXTERNAL_VOLUME_SHORT_COMPOSE); $uuid = $application->uuid; $preview = externalVolumePreview($application); legacyExternalVolumeRow($application, "{$uuid}_shared-data-pr-42"); $compose = applicationParser($application, 42, $preview->id)->toArray(); expect($compose['services']['web-pr-42']['volumes'])->toContain("{$uuid}_shared-data-pr-42:/data:ro") ->and($compose['volumes']["{$uuid}_shared-data-pr-42"])->toBe(['name' => "{$uuid}_shared-data-pr-42"]) ->and(persistentVolumeNames())->toBe(["{$uuid}_app-data-pr-42", "{$uuid}_shared-data-pr-42"]) ->and($application->composeVolumeWarnings())->toBe([legacyExternalVolumeWarning("{$uuid}_shared-data-pr-42")]); }); it('uses the external volume in a preview when only the production volume has an old storage entry', function () { $application = externalVolumeApplication(EXTERNAL_VOLUME_SHORT_COMPOSE); $uuid = $application->uuid; $preview = externalVolumePreview($application); legacyExternalVolumeRow($application, "{$uuid}_shared-data"); $compose = applicationParser($application, 42, $preview->id)->toArray(); expect($compose['services']['web-pr-42']['volumes'])->toContain('shared-data:/data:ro') ->and($application->composeVolumeWarnings())->toBe([]); }); it('uses the external volume after the old storage entry is deleted', function () { $application = externalVolumeApplication(EXTERNAL_VOLUME_SHORT_COMPOSE); $uuid = $application->uuid; $oldVolume = legacyExternalVolumeRow($application, "{$uuid}_shared-data"); applicationParser($application); $oldVolume->delete(); $compose = applicationParser($application)->toArray(); expect($compose['services']['web']['volumes'])->toContain('shared-data:/data:ro') ->and($compose['volumes'])->not->toHaveKey("{$uuid}_shared-data") ->and(persistentVolumeNames())->toBe(["{$uuid}_app-data"]) ->and($application->composeVolumeWarnings())->toBe([]); }); it('does not change nfs volumes', function () { $application = externalVolumeApplication(EXTERNAL_VOLUME_NFS_COMPOSE); $compose = applicationParser($application)->toArray(); expect($compose['services']['web']['volumes'])->toBe(['nfs-data:/data']) ->and($compose['volumes']['nfs-data']['driver_opts']['type'])->toBe('nfs') ->and(persistentVolumeNames())->toBe([]); }); }); describe('serviceParser', function () { it('keeps an external volume in short syntax', function () { $service = externalVolumeService(EXTERNAL_VOLUME_SHORT_COMPOSE); $uuid = $service->uuid; $compose = serviceParser($service)->toArray(); expect($compose['services']['web']['volumes'])->toContain('shared-data:/data:ro') ->toContain("{$uuid}_app-data:/app") ->and($compose['volumes']['shared-data'])->toBe(['external' => true]) ->and($compose['volumes'])->not->toHaveKey("{$uuid}_shared-data") ->and(persistentVolumeNames())->toBe(["{$uuid}_app-data"]); }); it('keeps an external volume in long syntax with its name', function () { $service = externalVolumeService(EXTERNAL_VOLUME_LONG_COMPOSE); $uuid = $service->uuid; $compose = serviceParser($service)->toArray(); $sources = collect($compose['services']['web']['volumes'])->pluck('source')->all(); expect($sources)->toBe(['shared-data', "{$uuid}_app-data"]) ->and($compose['volumes']['shared-data'])->toBe(['external' => true, 'name' => 'existing-shared-volume']) ->and(persistentVolumeNames())->toBe(["{$uuid}_app-data"]); }); it('keeps the old prefixed volume and shows a warning when the service has its old storage entry', function (string $compose, string $serviceName, string $mountPath, string $mode) { $service = externalVolumeService($compose); $uuid = $service->uuid; $owner = legacyExternalVolumeServiceResource($service, $serviceName, $compose); $oldVolume = legacyExternalVolumeRow($owner, "{$uuid}_shared-data", $mountPath); $parsed = serviceParser($service)->toArray(); expect($parsed['services'][$serviceName]['volumes'])->toContain("{$uuid}_shared-data:{$mountPath}{$mode}") ->and($parsed['volumes']["{$uuid}_shared-data"])->toBe(['name' => "{$uuid}_shared-data"]) ->and(LocalPersistentVolume::find($oldVolume->id)?->name)->toBe("{$uuid}_shared-data") ->and(LocalPersistentVolume::query()->where('name', "{$uuid}_shared-data")->count())->toBe(1) ->and($service->composeVolumeWarnings())->toBe([legacyExternalVolumeWarning("{$uuid}_shared-data")]); })->with([ 'service application' => [EXTERNAL_VOLUME_SHORT_COMPOSE, 'web', '/data', ':ro'], 'service database' => [EXTERNAL_VOLUME_DATABASE_COMPOSE, 'db', '/var/lib/postgresql/data', ''], ]); it('uses the external volume after the old storage entry of the service is deleted', function () { $service = externalVolumeService(EXTERNAL_VOLUME_SHORT_COMPOSE); $uuid = $service->uuid; $oldVolume = legacyExternalVolumeRow(legacyExternalVolumeServiceResource($service, 'web'), "{$uuid}_shared-data"); serviceParser($service); $oldVolume->delete(); $parsed = serviceParser($service)->toArray(); expect($parsed['services']['web']['volumes'])->toContain('shared-data:/data:ro') ->and($parsed['volumes'])->not->toHaveKey("{$uuid}_shared-data") ->and(persistentVolumeNames())->toBe(["{$uuid}_app-data"]) ->and($service->composeVolumeWarnings())->toBe([]); }); it('does not change nfs volumes', function () { $service = externalVolumeService(EXTERNAL_VOLUME_NFS_COMPOSE); $compose = serviceParser($service)->toArray(); expect($compose['volumes']['nfs-data']['driver_opts']['type'])->toBe('nfs') ->and(persistentVolumeNames())->toBe([]); }); }); describe('legacy parsers', function () { it('keeps an external volume in a legacy service', function (string $compose, string $expectedVolume) { $service = externalVolumeService($compose, '2'); $uuid = $service->uuid; $parsed = parseDockerComposeFile($service)->toArray(); $volumes = collect($parsed['services']['web']['volumes']) ->map(fn (mixed $volume): string => is_array($volume) ? $volume['source'] : $volume) ->all(); expect($volumes)->toContain($expectedVolume) ->toContain(str_ends_with($expectedVolume, ':ro') ? "{$uuid}_app-data:/app" : "{$uuid}_app-data") ->and($parsed['volumes']['shared-data']['external'])->toBeTrue() ->and($parsed['volumes'])->not->toHaveKey("{$uuid}_shared-data") ->and(persistentVolumeNames())->toBe(["{$uuid}_app-data"]); })->with([ 'short syntax' => [EXTERNAL_VOLUME_SHORT_COMPOSE, 'shared-data:/data:ro'], 'long syntax' => [EXTERNAL_VOLUME_LONG_COMPOSE, 'shared-data'], ]); it('keeps the old prefixed volume of a legacy service that has its old storage entry', function (string $compose, string $expectedVolume) { $service = externalVolumeService($compose, '2'); $uuid = $service->uuid; $oldVolume = legacyExternalVolumeRow(legacyExternalVolumeServiceResource($service, 'web'), "{$uuid}_shared-data"); $parsed = parseDockerComposeFile($service)->toArray(); $volumes = collect($parsed['services']['web']['volumes']) ->map(fn (mixed $volume): string => is_array($volume) ? $volume['source'] : $volume) ->all(); expect($volumes)->toContain(str_replace('{uuid}', $uuid, $expectedVolume)) ->and($parsed['volumes']["{$uuid}_shared-data"])->toBe(['name' => "{$uuid}_shared-data"]) ->and(LocalPersistentVolume::find($oldVolume->id)?->name)->toBe("{$uuid}_shared-data") ->and(LocalPersistentVolume::query()->where('name', "{$uuid}_shared-data")->count())->toBe(1) ->and($service->composeVolumeWarnings())->toHaveCount(1) ->and($service->composeVolumeWarnings()[0])->toStartWith("Volume 'shared-data' is declared as external") ->toContain("Coolify still uses '{$uuid}_shared-data'"); })->with([ 'short syntax' => [EXTERNAL_VOLUME_SHORT_COMPOSE, '{uuid}_shared-data:/data'], 'long syntax' => [EXTERNAL_VOLUME_LONG_COMPOSE, '{uuid}_shared-data'], ]); it('uses the external volume after the old storage entry of a legacy service is deleted', function () { $service = externalVolumeService(EXTERNAL_VOLUME_SHORT_COMPOSE, '2'); $uuid = $service->uuid; $oldVolume = legacyExternalVolumeRow(legacyExternalVolumeServiceResource($service, 'web'), "{$uuid}_shared-data"); parseDockerComposeFile($service); $oldVolume->delete(); $parsed = parseDockerComposeFile($service)->toArray(); expect($parsed['services']['web']['volumes'])->toContain('shared-data:/data:ro') ->and($parsed['volumes'])->not->toHaveKey("{$uuid}_shared-data") ->and(persistentVolumeNames())->toBe(["{$uuid}_app-data"]) ->and($service->composeVolumeWarnings())->toBe([]); }); it('keeps the old volume names of a legacy application, because it has no storage entries to check', function (string $parsingVersion, int $pullRequestId, string $compose, string $expectedVolume) { // Legacy Compose applications (parser versions 1 and 2) never stored their volumes, so Coolify // cannot tell which external volume already holds data: they always keep the old names. $application = externalVolumeApplication($compose, $parsingVersion); $uuid = $application->uuid; $previewId = $pullRequestId === 0 ? null : externalVolumePreview($application, $pullRequestId)->id; $parsed = parseDockerComposeFile($application, pull_request_id: $pullRequestId, preview_id: $previewId)->toArray(); $serviceName = $pullRequestId === 0 ? 'web' : "web-pr-{$pullRequestId}"; $expectedVolume = str_replace('{uuid}', $uuid, $expectedVolume); $expectedName = explode(':', $expectedVolume)[0]; expect($parsed['services'][$serviceName]['volumes'])->toContain($expectedVolume) ->and(array_keys($parsed['volumes'] ?? []))->toContain($expectedName) ->and($application->composeVolumeWarnings())->toBe([]); })->with([ 'v1 short' => ['1', 0, EXTERNAL_VOLUME_SHORT_COMPOSE, 'shared-data:/data:ro'], 'v1 short preview' => ['1', 42, EXTERNAL_VOLUME_SHORT_COMPOSE, 'shared-data-pr-42:/data:ro'], 'v1 long preview' => ['1', 42, EXTERNAL_VOLUME_LONG_COMPOSE, 'shared-data-pr-42:/data'], 'v2 short' => ['2', 0, EXTERNAL_VOLUME_SHORT_COMPOSE, '{uuid}-shared-data:/data:ro'], 'v2 short preview' => ['2', 42, EXTERNAL_VOLUME_SHORT_COMPOSE, '{uuid}-shared-data-pr-42:/data:ro'], 'v2 long' => ['2', 0, EXTERNAL_VOLUME_LONG_COMPOSE, '{uuid}-shared-data:/data'], 'v2 long preview' => ['2', 42, EXTERNAL_VOLUME_LONG_COMPOSE, '{uuid}-shared-data-pr-42:/data'], ]); }); describe('validation', function () { it('rejects an external volume name with a variable', function (string $compose) { expect(fn () => validateDockerComposeForInjection($compose)) ->toThrow(Exception::class, 'Coolify does not resolve variables in the name of an external volume'); $application = externalVolumeApplication($compose); expect(fn () => applicationParser($application)) ->toThrow(Exception::class, 'Coolify does not resolve variables in the name of an external volume'); $service = externalVolumeService($compose); expect(fn () => serviceParser($service)) ->toThrow(Exception::class, 'Coolify does not resolve variables in the name of an external volume'); })->with([ 'name field' => ["services:\n web:\n image: nginx\n volumes:\n - 'shared-data:/data'\nvolumes:\n shared-data:\n external: true\n name: \${SHARED_VOLUME}\n"], 'name field with default' => ["services:\n web:\n image: nginx\n volumes:\n - 'shared-data:/data'\nvolumes:\n shared-data:\n external: true\n name: \${SHARED_VOLUME:-shared}\n"], 'old syntax' => ["services:\n web:\n image: nginx\n volumes:\n - 'shared-data:/data'\nvolumes:\n shared-data:\n external:\n name: \$SHARED_VOLUME\n"], ]); it('rejects an external volume name that is not a valid Docker volume name', function () { $compose = "services:\n web:\n image: nginx\nvolumes:\n shared-data:\n external: true\n name: 'shared data;rm -rf /'\n"; expect(fn () => validateDockerComposeForInjection($compose)) ->toThrow(Exception::class, 'Invalid external Docker Compose volume shared-data'); }); it('accepts external volumes with a literal name', function (string $compose) { validateDockerComposeForInjection($compose); expect(true)->toBeTrue(); })->with([ 'short' => [EXTERNAL_VOLUME_SHORT_COMPOSE], 'long' => [EXTERNAL_VOLUME_LONG_COMPOSE], 'old syntax' => [EXTERNAL_VOLUME_OLD_SYNTAX_COMPOSE], ]); }); describe('docker volume names', function () { it('lists the Docker volumes that a compose file declares as external', function () { expect(composeExternalVolumeDockerNames(EXTERNAL_VOLUME_SHORT_COMPOSE))->toBe(['shared-data']) ->and(composeExternalVolumeDockerNames(EXTERNAL_VOLUME_LONG_COMPOSE))->toBe(['existing-shared-volume']) ->and(composeExternalVolumeDockerNames(EXTERNAL_VOLUME_OLD_SYNTAX_COMPOSE))->toBe(['legacy-shared-volume']) ->and(composeExternalVolumeDockerNames(EXTERNAL_VOLUME_NFS_COMPOSE))->toBe([]) ->and(composeExternalVolumeDockerNames("services:\n web: [\n"))->toBe([]) ->and(composeExternalVolumeDockerNames(null))->toBe([]); }); }); describe('delete', function () { it('never removes the external volume when a service is deleted with its volumes', function () { $service = externalVolumeService(EXTERNAL_VOLUME_SHORT_COMPOSE); $uuid = $service->uuid; serviceParser($service); $service->delete(); $commands = []; fakeExternalVolumeServer($commands); (new DeleteResourceJob($service))->handle(); $all = implode("\n", $commands); expect($all)->toContain("docker volume rm -f '{$uuid}_app-data'") ->not->toContain("'shared-data'") ->not->toContain("{$uuid}_shared-data"); }); it('lets Docker Compose keep the external volume when a Compose application is deleted with its volumes', function () { $application = externalVolumeApplication(EXTERNAL_VOLUME_SHORT_COMPOSE); applicationParser($application); $application->delete(); $commands = []; fakeExternalVolumeServer($commands); (new DeleteResourceJob($application))->handle(); $all = implode("\n", $commands); expect($all)->toContain('docker compose down -v') ->not->toContain('docker volume rm') ->and($application->docker_compose)->toContain("shared-data:\n external: true"); }); it('removes only the old prefixed volume when a service with an old storage entry is deleted with its volumes', function () { $service = externalVolumeService(EXTERNAL_VOLUME_SHORT_COMPOSE); $uuid = $service->uuid; legacyExternalVolumeRow(legacyExternalVolumeServiceResource($service, 'web'), "{$uuid}_shared-data"); serviceParser($service); $service->delete(); $commands = []; fakeExternalVolumeServer($commands); (new DeleteResourceJob($service))->handle(); $all = implode("\n", $commands); expect($all)->toContain("docker volume rm -f '{$uuid}_shared-data'") ->toContain("docker volume rm -f '{$uuid}_app-data'") ->not->toContain("'shared-data'"); }); it('lets the storage list delete the old storage entry without removing the external volume', function (bool $deleteDockerVolume) { $application = externalVolumeApplication(EXTERNAL_VOLUME_SHORT_COMPOSE); $uuid = $application->uuid; $oldVolume = legacyExternalVolumeRow($application, "{$uuid}_shared-data"); applicationParser($application); loginExternalVolumeOwner(); $commands = []; fakeExternalVolumeServer($commands); Livewire::test(All::class, ['resource' => $application->fresh()]) ->assertSee('Replaces the external volume') ->call('delete', $oldVolume->id, 'password', $deleteDockerVolume ? ['deleteDockerVolume'] : []) ->assertHasNoErrors() ->assertReturned(true); $volumeCommands = collect($commands)->filter(fn (string $command): bool => str_contains($command, 'docker volume'))->values(); expect(LocalPersistentVolume::find($oldVolume->id))->toBeNull() ->and($volumeCommands)->toHaveCount($deleteDockerVolume ? 1 : 0) ->and($volumeCommands->implode("\n"))->not->toContain("'shared-data'"); if ($deleteDockerVolume) { expect($volumeCommands->first())->toContain("docker volume rm -f '{$uuid}_shared-data'"); } $compose = applicationParser($application)->toArray(); expect($compose['services']['web']['volumes'])->toContain('shared-data:/data:ro') ->and($application->composeVolumeWarnings())->toBe([]); })->with([ 'keep the Docker volume' => [false], 'delete the old Docker volume' => [true], ]); it('does not let the storage list delete a storage entry of a volume that is not external', function () { $application = externalVolumeApplication(EXTERNAL_VOLUME_SHORT_COMPOSE); applicationParser($application); $volume = LocalPersistentVolume::query()->where('name', "{$application->uuid}_app-data")->firstOrFail(); loginExternalVolumeOwner(); Livewire::test(All::class, ['resource' => $application->fresh()]) ->assertDontSee('Replaces the external volume') ->call('delete', $volume->id, 'password', []) ->assertHasNoErrors() ->assertReturned(false); expect(LocalPersistentVolume::find($volume->id))->not->toBeNull(); }); it('does not remove the external volume when a preview is deleted', function () { $application = externalVolumeApplication(EXTERNAL_VOLUME_SHORT_COMPOSE); $uuid = $application->uuid; $preview = externalVolumePreview($application); $commands = []; fakeExternalVolumeServer($commands); $preview->forceDelete(); $volumeCommands = collect($commands)->filter(fn (string $command): bool => str_contains($command, 'docker volume'))->implode("\n"); expect($volumeCommands)->toContain("docker volume rm -f '{$uuid}_app-data-pr-42'") ->not->toContain('shared-data'); }); it('removes the old prefixed preview volume, but not the external volume, when a preview is deleted', function () { $application = externalVolumeApplication(EXTERNAL_VOLUME_SHORT_COMPOSE); $uuid = $application->uuid; $preview = externalVolumePreview($application); legacyExternalVolumeRow($application, "{$uuid}_shared-data-pr-42"); $commands = []; fakeExternalVolumeServer($commands); $preview->forceDelete(); $volumeCommands = collect($commands)->filter(fn (string $command): bool => str_contains($command, 'docker volume'))->implode("\n"); expect($volumeCommands)->toContain("docker volume rm -f '{$uuid}_shared-data-pr-42'") ->not->toContain("'shared-data'"); }); }); describe('warnings', function () { it('writes the warning to the deployment log after the parse', function (int $pullRequestId, string $oldName, string $expectedVolume) { $application = externalVolumeApplication(EXTERNAL_VOLUME_SHORT_COMPOSE); $uuid = $application->uuid; $preview = $pullRequestId === 0 ? null : externalVolumePreview($application, $pullRequestId); legacyExternalVolumeRow($application, str_replace('{uuid}', $uuid, $oldName)); [$composeFile, $logEntries] = parseExternalVolumeDeployment($application, $pullRequestId, $preview); $serviceName = $pullRequestId === 0 ? 'web' : "web-pr-{$pullRequestId}"; expect(data_get($composeFile, "services.{$serviceName}.volumes"))->toContain(str_replace('{uuid}', $uuid, $expectedVolume)) ->and($logEntries)->toBe([['Warning: '.legacyExternalVolumeWarning(str_replace('{uuid}', $uuid, $oldName)), 'stderr']]); })->with([ 'production' => [0, '{uuid}_shared-data', '{uuid}_shared-data:/data:ro'], 'preview' => [42, '{uuid}_shared-data-pr-42', '{uuid}_shared-data-pr-42:/data:ro'], ]); it('writes no warning to the deployment log when the external volume is used as written', function () { $application = externalVolumeApplication(EXTERNAL_VOLUME_SHORT_COMPOSE); [$composeFile, $logEntries] = parseExternalVolumeDeployment($application); expect(data_get($composeFile, 'services.web.volumes'))->toContain('shared-data:/data:ro') ->and($logEntries)->toBe([]); }); it('shows the warning before docker compose up when a service starts', function () { $service = externalVolumeService(EXTERNAL_VOLUME_SHORT_COMPOSE); $uuid = $service->uuid; legacyExternalVolumeRow(legacyExternalVolumeServiceResource($service, 'web'), "{$uuid}_shared-data"); $command = StartService::run($service->fresh())->getExtraProperty('command'); $echo = 'echo '.escapeshellarg('Warning: '.legacyExternalVolumeWarning("{$uuid}_shared-data")); expect($command)->toContain($echo) ->and(strpos($command, $echo))->toBeLessThan(strpos($command, ' up -d')); }); it('shows the warning before docker compose up when a service application is deployed', function () { $service = externalVolumeService(EXTERNAL_VOLUME_SHORT_COMPOSE); $uuid = $service->uuid; $serviceApplication = legacyExternalVolumeServiceResource($service, 'web'); legacyExternalVolumeRow($serviceApplication, "{$uuid}_shared-data"); $command = DeployServiceApplication::run($serviceApplication->fresh())->getExtraProperty('command'); $echo = 'echo '.escapeshellarg('Warning: '.legacyExternalVolumeWarning("{$uuid}_shared-data")); expect($command)->toContain($echo) ->and(strpos($command, $echo))->toBeLessThan(strpos($command, ' up -d')); }); it('keeps the warning line unchanged for a server with a non-root user', function () { $service = externalVolumeService(EXTERNAL_VOLUME_LONG_COMPOSE); $uuid = $service->uuid; legacyExternalVolumeRow(legacyExternalVolumeServiceResource($service, 'web'), "{$uuid}_shared-data"); serviceParser($service); $service->server->update(['user' => 'ubuntu']); $commands = StartService::composeVolumeWarningCommands($service); expect($commands)->toBe(['echo '.escapeshellarg('Warning: '.legacyExternalVolumeWarning("{$uuid}_shared-data", 'existing-shared-volume'))]) ->and(parseCommandsByLineForSudo(collect($commands), $service->server->fresh()))->toBe($commands); }); it('shows no warning when a service starts with the external volume as written', function () { $service = externalVolumeService(EXTERNAL_VOLUME_SHORT_COMPOSE); $command = StartService::run($service->fresh())->getExtraProperty('command'); expect($command)->not->toContain('declared as external'); }); });