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."; } function legacyParserExternalVolumeWarning(string $keptName, ?string $dockerVolume = null): string { $external = $dockerVolume === null ? 'external' : "external (Docker volume '{$dockerVolume}')"; return "Volume 'shared-data' is declared as {$external}, but this application uses an old Compose parser, so Coolify uses '{$keptName}'. To use the external volume, copy your data into it, then clone this application or create it again."; } /** * The "External volumes" card of the storage page, or an empty string when the page does not show it. */ function externalVolumesSection(string $html, string $resourceUuid): string { $pattern = '/]*id="external-volumes-'.preg_quote($resourceUuid, '/').'".*?<\/section>/s'; return preg_match($pattern, $html, $matches) === 1 ? $matches[0] : ''; } /** * 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(remoteProcessScript($process)) ? implode(' ', remoteProcessScript($process)) : remoteProcessScript($process); 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('gives a preview deployment its own volume instead of the external volume', 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("{$uuid}_shared-data-pr-42:/data:ro") ->toContain("{$uuid}_app-data-pr-42:/app") ->not->toContain('shared-data:/data:ro') ->and($compose['volumes']["{$uuid}_shared-data-pr-42"])->toBe(['name' => "{$uuid}_shared-data-pr-42"]) ->and($compose['volumes'])->not->toHaveKey('shared-data') ->and(persistentVolumeNames())->toBe(["{$uuid}_app-data-pr-42", "{$uuid}_shared-data-pr-42"]) ->and($application->composeVolumeWarnings())->toBe([]); }); it('gives a preview deployment its own volume instead of a named external volume in long syntax', function () { $application = externalVolumeApplication(EXTERNAL_VOLUME_LONG_COMPOSE); $uuid = $application->uuid; $preview = externalVolumePreview($application); $compose = applicationParser($application, 42, $preview->id); $sources = collect($compose->toArray()['services']['web-pr-42']['volumes'])->pluck('source')->all(); expect($sources)->toBe(["{$uuid}_shared-data-pr-42", "{$uuid}_app-data-pr-42"]) ->and($compose->toArray()['volumes']["{$uuid}_shared-data-pr-42"])->toBe(['name' => "{$uuid}_shared-data-pr-42"]) ->and(Yaml::dump(convertToArray($compose), 10, 2))->not->toContain('existing-shared-volume') ->and($application->composeVolumeWarnings())->toBe([]); }); 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('reuses the preview volume of an older Coolify version without a warning', function () { $application = externalVolumeApplication(EXTERNAL_VOLUME_SHORT_COMPOSE); $uuid = $application->uuid; $preview = externalVolumePreview($application); $oldVolume = 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(LocalPersistentVolume::find($oldVolume->id)?->name)->toBe("{$uuid}_shared-data-pr-42") ->and(persistentVolumeNames())->toBe(["{$uuid}_app-data-pr-42", "{$uuid}_shared-data-pr-42"]) ->and($application->composeVolumeWarnings())->toBe([]); }); it('gives a preview its own volume 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("{$uuid}_shared-data-pr-42:/data:ro") ->not->toContain('shared-data:/data:ro') ->not->toContain("{$uuid}_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 and shows a warning when production does not use the external name', function (string $parsingVersion, int $pullRequestId, string $compose, string $expectedVolume, ?string $dockerVolume) { // 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( // Parser version 1 keeps the name of a production volume, so it uses the external volume. // A preview always uses its own volume, so it gets no warning. $pullRequestId !== 0 || $expectedName === 'shared-data' ? [] : [legacyParserExternalVolumeWarning($expectedName, $dockerVolume)] ); })->with([ 'v1 short' => ['1', 0, EXTERNAL_VOLUME_SHORT_COMPOSE, 'shared-data:/data:ro', null], 'v1 short preview' => ['1', 42, EXTERNAL_VOLUME_SHORT_COMPOSE, 'shared-data-pr-42:/data:ro', null], 'v1 long preview' => ['1', 42, EXTERNAL_VOLUME_LONG_COMPOSE, 'shared-data-pr-42:/data', 'existing-shared-volume'], 'v2 short' => ['2', 0, EXTERNAL_VOLUME_SHORT_COMPOSE, '{uuid}-shared-data:/data:ro', null], 'v2 short preview' => ['2', 42, EXTERNAL_VOLUME_SHORT_COMPOSE, '{uuid}-shared-data-pr-42:/data:ro', null], 'v2 long' => ['2', 0, EXTERNAL_VOLUME_LONG_COMPOSE, '{uuid}-shared-data:/data', 'existing-shared-volume'], 'v2 long preview' => ['2', 42, EXTERNAL_VOLUME_LONG_COMPOSE, '{uuid}-shared-data-pr-42:/data', 'existing-shared-volume'], ]); it('writes the warning of a legacy application to the deployment log', function (string $parsingVersion, int $pullRequestId, string $keptName) { $application = externalVolumeApplication(EXTERNAL_VOLUME_SHORT_COMPOSE, $parsingVersion); $keptName = str_replace('{uuid}', $application->uuid, $keptName); $preview = $pullRequestId === 0 ? null : externalVolumePreview($application, $pullRequestId); [$composeFile, $logEntries] = parseExternalVolumeDeployment($application, $pullRequestId, $preview); $serviceName = $pullRequestId === 0 ? 'web' : "web-pr-{$pullRequestId}"; expect(data_get($composeFile, "services.{$serviceName}.volumes"))->toContain("{$keptName}:/data:ro") ->and($logEntries)->toBe([['Warning: '.legacyParserExternalVolumeWarning($keptName), 'stderr']]); })->with([ 'v2 production' => ['2', 0, '{uuid}-shared-data'], ]); it('writes no warning to the deployment log of a legacy application preview, which always uses its own volume', function (string $parsingVersion, string $keptName) { $application = externalVolumeApplication(EXTERNAL_VOLUME_SHORT_COMPOSE, $parsingVersion); $keptName = str_replace('{uuid}', $application->uuid, $keptName); $preview = externalVolumePreview($application); [$composeFile, $logEntries] = parseExternalVolumeDeployment($application, 42, $preview); expect(data_get($composeFile, 'services.web-pr-42.volumes'))->toContain("{$keptName}:/data:ro") ->and($logEntries)->toBe([]); })->with([ 'v2 preview' => ['2', '{uuid}-shared-data-pr-42'], 'v1 preview' => ['1', 'shared-data-pr-42'], ]); it('writes no warning to the deployment log for a production deployment of a parser version 1 application', function () { $application = externalVolumeApplication(EXTERNAL_VOLUME_SHORT_COMPOSE, '1'); [$composeFile, $logEntries] = parseExternalVolumeDeployment($application); expect(data_get($composeFile, 'services.web.volumes'))->toContain('shared-data:/data:ro') ->and($logEntries)->toBe([]); }); it('does not reject a variable in the external volume name of a legacy application', function () { $compose = "services:\n web:\n image: nginx\n volumes:\n - 'shared-data:/data'\nvolumes:\n shared-data:\n external: true\n name: \${SHARED_VOLUME}\n"; $application = externalVolumeApplication($compose, '2'); $uuid = $application->uuid; $parsed = parseDockerComposeFile($application)->toArray(); // The variable is not validated, so the warning does not show it. expect($parsed['services']['web']['volumes'])->toContain("{$uuid}-shared-data:/data") ->and($application->composeVolumeWarnings())->toBe([legacyParserExternalVolumeWarning("{$uuid}-shared-data")]); }); it('uses the external volume as written after a legacy application is cloned', function () { $application = externalVolumeApplication(EXTERNAL_VOLUME_SHORT_COMPOSE, '2'); loginExternalVolumeOwner(); $clone = clone_application($application, test()->destination, ['environment_id' => test()->environment->id])->fresh(); $parsed = $clone->parse()->toArray(); expect($clone->compose_parsing_version)->toBe('5') ->and($parsed['services']['web']['volumes'])->toContain('shared-data:/data:ro') ->and($clone->composeVolumeWarnings())->toBe([]); }); }); function variableExternalVolumeCompose(string $name, bool $oldSyntax = false): string { $declaration = $oldSyntax ? " external:\n name: '{$name}'\n" : " external: true\n name: '{$name}'\n"; return "services:\n web:\n image: nginx\n volumes:\n - 'shared-data:/data'\nvolumes:\n shared-data:\n{$declaration}"; } dataset('external volume names with a variable', [ 'variable' => ['${SHARED_VOLUME}', false], 'variable with a default' => ['${SHARED_VOLUME:-shared}', false], 'variable with text around it' => ['stack-${STACK}_data', false], 'two variables' => ['${PROJECT}-${ENVIRONMENT}', false], 'old syntax' => ['$SHARED_VOLUME', true], ]); describe('validation', function () { it('accepts an external volume name with a variable and leaves it to Docker Compose', function (string $name, bool $oldSyntax) { $compose = variableExternalVolumeCompose($name, $oldSyntax); $declaration = $oldSyntax ? ['external' => ['name' => $name]] : ['external' => true, 'name' => $name]; validateDockerComposeForInjection($compose); $application = applicationParser(externalVolumeApplication($compose))->toArray(); $service = serviceParser(externalVolumeService($compose))->toArray(); expect($application['services']['web']['volumes'])->toBe(['shared-data:/data']) ->and($application['volumes'])->toBe(['shared-data' => $declaration]) ->and($service['services']['web']['volumes'])->toBe(['shared-data:/data']) ->and($service['volumes'])->toBe(['shared-data' => $declaration]) ->and(persistentVolumeNames())->toBe([]); })->with('external volume names with a variable'); it('keeps the old volume of an existing application whose external volume name has a variable', function (string $name, bool $oldSyntax) { $application = externalVolumeApplication(variableExternalVolumeCompose($name, $oldSyntax)); $uuid = $application->uuid; legacyExternalVolumeRow($application, "{$uuid}_shared-data"); $parsed = applicationParser($application)->toArray(); expect($parsed['services']['web']['volumes'])->toBe(["{$uuid}_shared-data:/data"]) ->and($parsed['volumes']["{$uuid}_shared-data"])->toBe(['name' => "{$uuid}_shared-data"]) ->and($application->composeVolumeWarnings())->toBe([legacyExternalVolumeWarning("{$uuid}_shared-data", $name)]); })->with('external volume names with a variable'); it('keeps the old volume of an existing service whose external volume name has a variable', function (string $name, bool $oldSyntax) { $compose = variableExternalVolumeCompose($name, $oldSyntax); $service = externalVolumeService($compose); $uuid = $service->uuid; legacyExternalVolumeRow(legacyExternalVolumeServiceResource($service, 'web', $compose), "{$uuid}_shared-data"); $parsed = serviceParser($service)->toArray(); expect($parsed['services']['web']['volumes'])->toBe(["{$uuid}_shared-data:/data"]) ->and($parsed['volumes']["{$uuid}_shared-data"])->toBe(['name' => "{$uuid}_shared-data"]) ->and($service->composeVolumeWarnings())->toBe([legacyExternalVolumeWarning("{$uuid}_shared-data", $name)]); })->with('external volume names with a variable'); it('saves a service Compose file whose external volume name has a variable', function () { $compose = variableExternalVolumeCompose('${SHARED_VOLUME}'); $service = externalVolumeService(EXTERNAL_VOLUME_SHORT_COMPOSE); $owner = User::factory()->create(); $team = $service->environment->project->team; $team->members()->attach($owner, ['role' => 'owner']); $this->actingAs($owner); session(['currentTeam' => $team]); Livewire::test(StackForm::class, ['service' => $service]) ->set('dockerComposeRaw', $compose) ->call('submit') ->assertDispatched('success'); expect($service->fresh()->docker_compose_raw)->toBe($compose); }); it('keeps the old behavior of a legacy Compose application whose external volume name has a variable', function (string $name, bool $oldSyntax) { $application = externalVolumeApplication(variableExternalVolumeCompose($name, $oldSyntax), '2'); $parsed = parseDockerComposeFile($application)->toArray(); expect($parsed['services']['web']['volumes'])->toBe(["{$application->uuid}-shared-data:/data"]); })->with('external volume names with a variable'); it('rejects an external volume name with an unsafe or unsupported variable', function (string $name) { $compose = variableExternalVolumeCompose($name); $application = externalVolumeApplication($compose); expect(fn () => validateDockerComposeForInjection($compose)) ->toThrow(Exception::class, 'Invalid external Docker Compose volume shared-data') ->and(fn () => applicationParser($application)) ->toThrow(Exception::class, 'Invalid external Docker Compose volume shared-data'); })->with([ 'command after a variable' => ['${SHARED_VOLUME}; rm -rf /'], 'command substitution' => ['$(id)'], 'space' => ['shared ${VOLUME}'], 'dollar sign alone' => ['shared-$'], 'escaped dollar sign' => ['$$SHARED_VOLUME'], 'required variable syntax' => ['${SHARED_VOLUME:?required}'], 'default with a space' => ['${SHARED_VOLUME:-a b}'], ]); it('rejects a variable in the key of an external volume', function () { $compose = "services:\n web:\n image: nginx\nvolumes:\n \${SHARED_VOLUME}:\n external: true\n"; expect(fn () => validateDockerComposeForInjection($compose)) ->toThrow(Exception::class, 'Invalid external Docker Compose volume. Volume names must start with an alphanumeric character'); }); 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('never removes the external volume when a Compose application is deleted with its volumes', function () { $application = externalVolumeApplication(EXTERNAL_VOLUME_SHORT_COMPOSE); $uuid = $application->uuid; applicationParser($application); $application->delete(); $commands = []; fakeExternalVolumeServer($commands); (new DeleteResourceJob($application))->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('removes the volumes by name without Docker Compose when a Compose application is deleted', function () { $application = externalVolumeApplication(EXTERNAL_VOLUME_SHORT_COMPOSE); applicationParser($application); LocalFileVolume::create([ 'fs_path' => $application->dirOnServer().'/compose.yaml', 'mount_path' => '/compose.yaml', 'content' => "services:\n other:\n image: busybox\n", 'resource_id' => $application->id, 'resource_type' => $application->getMorphClass(), ]); $application->delete(); $commands = []; fakeExternalVolumeServer($commands); (new DeleteResourceJob($application, deleteConfigurations: false))->handle(); expect(implode("\n", $commands))->not->toContain('docker compose') ->toContain("docker volume rm -f '{$application->uuid}_app-data'"); }); 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('removes the own volume of the preview, but not the external volume, when a preview is deleted', function (string $compose, string $externalVolume) { $application = externalVolumeApplication($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'") ->toContain("docker volume rm -f '{$uuid}_shared-data-pr-42'") ->not->toContain("'{$externalVolume}'"); })->with([ 'short syntax' => [EXTERNAL_VOLUME_SHORT_COMPOSE, 'shared-data'], 'named external volume' => [EXTERNAL_VOLUME_LONG_COMPOSE, 'existing-shared-volume'], ]); it('does not mark the own volume of a preview as an entry that replaces the external volume', function () { $application = externalVolumeApplication(EXTERNAL_VOLUME_SHORT_COMPOSE); $uuid = $application->uuid; $preview = externalVolumePreview($application); $productionEntry = legacyExternalVolumeRow($application, "{$uuid}_shared-data"); applicationParser($application, 42, $preview->id); $previewEntry = LocalPersistentVolume::query()->where('name', "{$uuid}_shared-data-pr-42")->firstOrFail(); expect($previewEntry->replacedExternalComposeVolume())->toBeNull() ->and($productionEntry->replacedExternalComposeVolume())->toBe('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'], ]); it('writes no warning to the deployment log of a preview, which always uses its own volume', function () { $application = externalVolumeApplication(EXTERNAL_VOLUME_SHORT_COMPOSE); $uuid = $application->uuid; $preview = externalVolumePreview($application); legacyExternalVolumeRow($application, "{$uuid}_shared-data-pr-42"); [$composeFile, $logEntries] = parseExternalVolumeDeployment($application, 42, $preview); expect(data_get($composeFile, 'services.web-pr-42.volumes'))->toContain("{$uuid}_shared-data-pr-42:/data:ro") ->and($logEntries)->toBe([]); }); 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 = RemoteProcessCommand::read(StartService::run($service->fresh())); $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 = RemoteProcessCommand::read(DeployServiceApplication::run($serviceApplication->fresh())); $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 = RemoteProcessCommand::read(StartService::run($service->fresh())); expect($command)->not->toContain('declared as external'); }); }); describe('storage page', function () { it('lists the external volumes of a Compose application as read-only', function () { $application = externalVolumeApplication(EXTERNAL_VOLUME_LONG_COMPOSE); applicationParser($application); loginExternalVolumeOwner(); $html = Livewire::test(StoragePage::class, ['resource' => $application->fresh()])->html(); $section = externalVolumesSection($html, $application->uuid); expect($section)->toContain('External volumes') ->toContain('Managed outside Coolify. Coolify never removes these volumes.') ->toContain('shared-data') ->toContain('existing-shared-volume') ->toContain('/data') ->toContain('web') ->not->toContain('app-data') ->not->toContain('delete(') ->not->toContain('Delete') ->not->toContain('Backup') ->not->toContain('wire:submit') ->not->toContain('applications()->where('name', 'web')->firstOrFail(); loginExternalVolumeOwner(); $html = Livewire::test(StoragePage::class, ['resource' => $serviceApplication])->html(); $section = externalVolumesSection($html, $serviceApplication->uuid); expect($section)->toContain('External volumes') ->toContain('shared-data') ->toContain('/data') ->toContain('web') ->not->toContain('app-data') ->not->toContain('delete(') ->not->toContain('Backup'); }); it('shows each mount path of an external volume', function () { $compose = "services:\n web:\n image: nginx\n volumes:\n - 'shared-data:/data'\n - type: volume\n source: shared-data\n target: /backup\n read_only: true\nvolumes:\n shared-data:\n external: true\n"; $application = externalVolumeApplication($compose); loginExternalVolumeOwner(); $component = Livewire::test(StoragePage::class, ['resource' => $application]); expect($component->get('externalVolumes'))->toBe([[ 'key' => 'shared-data', 'dockerName' => 'shared-data', 'service' => 'web', 'mountPaths' => ['/data', '/backup'], ]]); }); it('does not list an external volume while the old storage entry is kept', function () { $application = externalVolumeApplication(EXTERNAL_VOLUME_SHORT_COMPOSE); legacyExternalVolumeRow($application, "{$application->uuid}_shared-data"); loginExternalVolumeOwner(); $html = Livewire::test(StoragePage::class, ['resource' => $application->fresh()])->html(); expect(externalVolumesSection($html, $application->uuid))->toBe(''); }); it('does not list an external volume of a service resource while its old storage entry is kept', function () { $service = externalVolumeService(EXTERNAL_VOLUME_SHORT_COMPOSE); $serviceApplication = legacyExternalVolumeServiceResource($service, 'web'); legacyExternalVolumeRow($serviceApplication, "{$service->uuid}_shared-data"); loginExternalVolumeOwner(); $html = Livewire::test(StoragePage::class, ['resource' => $serviceApplication->fresh()])->html(); expect(externalVolumesSection($html, $serviceApplication->uuid))->toBe(''); }); it('lists the external volume after the old storage entry is deleted', function () { $application = externalVolumeApplication(EXTERNAL_VOLUME_SHORT_COMPOSE); $oldVolume = legacyExternalVolumeRow($application, "{$application->uuid}_shared-data"); loginExternalVolumeOwner(); $component = Livewire::test(StoragePage::class, ['resource' => $application->fresh()]); expect($component->get('externalVolumes'))->toBe([]); $oldVolume->delete(); $component->dispatch('storageCountsChanged'); expect(collect($component->get('externalVolumes'))->pluck('key')->all())->toBe(['shared-data']); }); it('does not list external volumes of a legacy Compose application', function (string $parsingVersion) { $application = externalVolumeApplication(EXTERNAL_VOLUME_SHORT_COMPOSE, $parsingVersion); loginExternalVolumeOwner(); $html = Livewire::test(StoragePage::class, ['resource' => $application])->html(); expect(externalVolumesSection($html, $application->uuid))->toBe(''); })->with(['1', '2']); it('lets a team member see the external volumes without actions', function () { $application = externalVolumeApplication(EXTERNAL_VOLUME_SHORT_COMPOSE); $team = test()->environment->project->team; $member = User::factory()->create(); $team->members()->attach($member->id, ['role' => 'member']); test()->withoutVite()->actingAs($member); session(['currentTeam' => $team]); $section = externalVolumesSection(Livewire::test(StoragePage::class, ['resource' => $application])->html(), $application->uuid); expect($section)->toContain('shared-data') ->not->toContain('delete(') ->not->toContain('Backup'); }); it('does not show external volumes to a user of another team', function () { $application = externalVolumeApplication(EXTERNAL_VOLUME_SHORT_COMPOSE); $otherTeam = Team::factory()->create(); $otherUser = User::factory()->create(); $otherTeam->members()->attach($otherUser->id, ['role' => 'owner']); test()->withoutVite()->actingAs($otherUser); session(['currentTeam' => $otherTeam]); $component = Livewire::test(StoragePage::class, ['resource' => $application]); expect($component->get('externalVolumes'))->toBe([]) ->and(externalVolumesSection($component->html(), $application->uuid))->toBe(''); test()->get(route('project.application.persistent-storage', [ 'project_uuid' => test()->environment->project->uuid, 'environment_uuid' => test()->environment->uuid, 'application_uuid' => $application->uuid, ]))->assertNotFound(); }); });