diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 178d1f12e7..57af00469c 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -2789,7 +2789,7 @@ class DatabasesController extends Controller $this->authorize('delete', $database); - if ($database instanceof StandaloneSqlite && $database->connectedVolumes()->exists()) { + if ($database instanceof StandaloneSqlite && $database->hasConnectedApplications()) { return response()->json([ 'message' => 'This database volume is mounted by '.$database->connectedApplicationNames()->implode(', ').'. Remove those mounts before deleting the database.', ], 422); diff --git a/app/Livewire/Project/Database/Sqlite/ConnectApplication.php b/app/Livewire/Project/Database/Sqlite/ConnectApplication.php index b741159212..0c5620189b 100644 --- a/app/Livewire/Project/Database/Sqlite/ConnectApplication.php +++ b/app/Livewire/Project/Database/Sqlite/ConnectApplication.php @@ -8,7 +8,9 @@ use App\Models\StandaloneSqlite; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; +use Livewire\Attributes\Computed; use Livewire\Component; +use Symfony\Component\Yaml\Yaml; class ConnectApplication extends Component { @@ -26,13 +28,19 @@ class ConnectApplication extends Component public string $volumeName = ''; /** - * Applications on the same server. Compose applications are listed but disabled, - * because Coolify renames named volumes in compose files. + * Applications on the same server. * - * @var array + * @var array */ public array $applicationOptions = []; + /** + * Compose file lines that mount the data volume into the selected Docker Compose application. + */ + public ?string $composeSnippet = null; + + public ?string $composeApplicationName = null; + public function mount(): void { $this->authorize('view', $this->database); @@ -46,17 +54,19 @@ class ConnectApplication extends Component ->map(fn (Application $application) => [ 'value' => $application->uuid, 'label' => $this->applicationLabel($application), - 'disabled' => $this->isComposeApplication($application), ]) ->all(); } /** - * Mount the data volume into the selected application and open its storage page. + * Mount the data volume into the selected application and open its storage page. A Docker Compose + * application mounts volumes only from its compose file, so it gets the lines to add instead. */ public function connect() { $this->authorize('view', $this->database); + $this->composeSnippet = null; + $this->composeApplicationName = null; $this->validate([ 'applicationUuid' => 'required|string', @@ -77,7 +87,14 @@ class ConnectApplication extends Component } if ($this->isComposeApplication($application)) { - throw new \Exception('Docker Compose applications are not supported: Coolify renames named volumes in compose files.'); + if (in_array($this->volumeName, composeExternalVolumeDockerNames($application->docker_compose_raw), true)) { + throw new \Exception("{$application->name} already mounts this database volume in its compose file."); + } + + $this->composeApplicationName = $application->name; + $this->composeSnippet = $this->composeVolumeSnippet($application); + + return null; } if ($application->persistentStorages()->where('standalone_sqlite_id', $this->database->id)->exists()) { @@ -110,6 +127,17 @@ class ConnectApplication extends Component return $this->database->connectedVolumes()->with('resource')->orderBy('id')->get(); } + /** + * Docker Compose applications that declare the data volume as external in their compose file. + * + * @return Collection + */ + #[Computed] + public function composeConnections(): Collection + { + return $this->database->composeApplicationsUsingDataVolume($this->volumeName); + } + /** * Remove the mount from the application. The Docker volume and its data stay untouched. */ @@ -169,7 +197,7 @@ class ConnectApplication extends Component } if ($this->isComposeApplication($application)) { - $label .= ' · Docker Compose (not supported)'; + $label .= ' · Docker Compose'; } return $label; @@ -180,6 +208,24 @@ class ConnectApplication extends Component return $application->build_pack === 'dockercompose'; } + /** + * Coolify uses an external volume as written, so the application mounts the existing data volume. + */ + private function composeVolumeSnippet(Application $application): string + { + try { + $services = data_get(Yaml::parse((string) $application->docker_compose_raw), 'services'); + } catch (\Throwable) { + $services = null; + } + $serviceName = is_array($services) && $services !== [] ? (string) array_key_first($services) : 'app'; + + return Yaml::dump([ + 'services' => [$serviceName => ['volumes' => ["{$this->volumeName}:{$this->mountPath}"]]], + 'volumes' => [$this->volumeName => ['external' => true]], + ], 4, 2); + } + /** * @return array{project_uuid: string, environment_uuid: string, application_uuid: string} */ diff --git a/app/Livewire/Project/Shared/Danger.php b/app/Livewire/Project/Shared/Danger.php index 6d63f27db3..4de58e145e 100644 --- a/app/Livewire/Project/Shared/Danger.php +++ b/app/Livewire/Project/Shared/Danger.php @@ -99,7 +99,7 @@ class Danger extends Component return 'Resource not found.'; } - if ($this->resource instanceof StandaloneSqlite && $this->resource->connectedVolumes()->exists()) { + if ($this->resource instanceof StandaloneSqlite && $this->resource->hasConnectedApplications()) { $this->dispatch('error', 'This database volume is mounted by '.$this->resource->connectedApplicationNames()->implode(', ').'. Remove those mounts before deleting the database.'); return; diff --git a/app/Models/LocalPersistentVolume.php b/app/Models/LocalPersistentVolume.php index 70a91be232..3b3b5fd5e5 100644 --- a/app/Models/LocalPersistentVolume.php +++ b/app/Models/LocalPersistentVolume.php @@ -87,10 +87,13 @@ class LocalPersistentVolume extends BaseModel return false; } - return static::query() + $isConnected = static::query() ->where('standalone_sqlite_id', $this->resource_id) ->where('name', $this->name) ->exists(); + + return $isConnected + || ($this->resource instanceof StandaloneSqlite && $this->resource->composeApplicationsUsingDataVolume($this->name)->isNotEmpty()); } protected function customizeName($value) diff --git a/app/Models/StandaloneSqlite.php b/app/Models/StandaloneSqlite.php index 6012ac4e0b..746bc97905 100644 --- a/app/Models/StandaloneSqlite.php +++ b/app/Models/StandaloneSqlite.php @@ -315,6 +315,43 @@ class StandaloneSqlite extends BaseModel return $this->hasMany(LocalPersistentVolume::class, 'standalone_sqlite_id'); } + /** + * Docker Compose applications of the same team and server that declare this database's data volume + * (or the given volume) as external. Coolify uses an external volume as written, so they mount it + * without a storage entry. + * + * @return Collection + */ + public function composeApplicationsUsingDataVolume(?string $volumeName = null): Collection + { + $serverId = $this->destination?->server_id; + $teamId = $this->environment?->project?->team_id; + if ($serverId === null || $teamId === null) { + return collect(); + } + + $volumeNames = $volumeName !== null + ? collect([$volumeName]) + : $this->persistentStorages()->whereNull('host_path')->pluck('name'); + + return Application::query() + ->where('build_pack', 'dockercompose') + ->whereRelation('environment.project', 'team_id', $teamId) + ->with('destination') + ->get() + ->filter(fn (Application $application) => $application->destination?->server_id === $serverId + && $volumeNames->intersect(composeExternalVolumeDockerNames($application->docker_compose_raw))->isNotEmpty()) + ->values(); + } + + /** + * Whether an application mounts this database's data volume. + */ + public function hasConnectedApplications(): bool + { + return $this->connectedVolumes()->exists() || $this->composeApplicationsUsingDataVolume()->isNotEmpty(); + } + /** * Names of the resources that mount this database's data volume. * @@ -324,6 +361,8 @@ class StandaloneSqlite extends BaseModel { return $this->connectedVolumes()->with('resource')->get() ->map(fn (LocalPersistentVolume $volume) => $volume->resource?->name) + ->toBase() + ->merge($this->composeApplicationsUsingDataVolume()->pluck('name')) ->filter() ->unique() ->values(); diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index bfd9af40df..907d97bc89 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -410,12 +410,50 @@ function legacyApplicationComposeVolumeName(Application $resource, string $sourc */ function composeLegacyExternalVolumeWarning(string $source, array $declaration, string $legacyName): string { - $dockerVolume = data_get($declaration, 'name') ?? data_get($declaration, 'external.name') ?? $source; + $dockerVolume = composeExternalVolumeDockerName($source, $declaration); $external = $dockerVolume === $source ? 'external' : "external (Docker volume '{$dockerVolume}')"; return "Volume '{$source}' is declared as {$external}, but Coolify still uses '{$legacyName}' because this resource used it before. To use the external volume, copy your data into it and delete the storage entry '{$legacyName}', then redeploy."; } +/** + * The Docker volume that an external Compose volume declaration refers to: `name:`, the old + * `external: {name: x}` syntax, or else the key. + * + * @param array $declaration + */ +function composeExternalVolumeDockerName(string $key, array $declaration): string +{ + return (string) (data_get($declaration, 'name') ?? data_get($declaration, 'external.name') ?? $key); +} + +/** + * The Docker volumes that a Compose file declares as external. Returns an empty list for a + * Compose file that is empty or not valid YAML. + * + * @return list + */ +function composeExternalVolumeDockerNames(?string $compose): array +{ + if (blank($compose)) { + return []; + } + try { + $volumes = data_get(Yaml::parse($compose), 'volumes'); + } catch (Throwable) { + return []; + } + if (! is_array($volumes)) { + return []; + } + + return collect($volumes) + ->filter(fn (mixed $declaration): bool => isComposeExternalVolume($declaration)) + ->map(fn (array $declaration, int|string $key): string => composeExternalVolumeDockerName((string) $key, $declaration)) + ->values() + ->all(); +} + /** * The key and the name of an external volume must be literal Docker volume names. Docker Compose * can resolve variables in `name:`, but then only the deployment `.env` sets which existing volume diff --git a/resources/views/livewire/project/database/sqlite/connect-application.blade.php b/resources/views/livewire/project/database/sqlite/connect-application.blade.php index 8bef8cb400..aa7fd959f1 100644 --- a/resources/views/livewire/project/database/sqlite/connect-application.blade.php +++ b/resources/views/livewire/project/database/sqlite/connect-application.blade.php @@ -2,7 +2,7 @@
- @if ($this->connections->isNotEmpty()) + @if ($this->connections->isNotEmpty() || $this->composeConnections->isNotEmpty())
@foreach ($this->connections as $connection)
@endforeach + @foreach ($this->composeConnections as $composeApplication) +
+ + {{ $composeApplication->name }} + in its compose file + + Remove it from the compose file to unlink +
+ @endforeach
@endif @if (count($applicationOptions) === 0) @@ -30,7 +41,7 @@ + helper="Docker Compose applications mount the volume from their compose file. Coolify shows the lines to add." /> @@ -41,6 +52,19 @@ Mount volume
+ + @if ($composeSnippet) + +

Add these lines to the service that uses the database, then redeploy. Start this database + first: Docker Compose does not create external volumes.

+
+
{{ $composeSnippet }}
+ +
+
+ @endif @endif diff --git a/tests/Feature/ComposeExternalVolumeTest.php b/tests/Feature/ComposeExternalVolumeTest.php index 7cf697853f..3a50b4cfcf 100644 --- a/tests/Feature/ComposeExternalVolumeTest.php +++ b/tests/Feature/ComposeExternalVolumeTest.php @@ -574,6 +574,17 @@ describe('validation', function () { ]); }); +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); diff --git a/tests/Feature/SqliteConnectApplicationTest.php b/tests/Feature/SqliteConnectApplicationTest.php index 51d79f3c38..9936af6280 100644 --- a/tests/Feature/SqliteConnectApplicationTest.php +++ b/tests/Feature/SqliteConnectApplicationTest.php @@ -69,7 +69,7 @@ beforeEach(function () { $this->application = createSqliteConnectApplication($this->environment, $this->destination, 'Nginx App'); }); -function createSqliteConnectApplication(Environment $environment, StandaloneDocker $destination, string $name, string $buildPack = 'dockerimage'): Application +function createSqliteConnectApplication(Environment $environment, StandaloneDocker $destination, string $name, string $buildPack = 'dockerimage', ?string $compose = null): Application { return Application::factory()->create([ 'uuid' => (string) Str::uuid(), @@ -79,9 +79,17 @@ function createSqliteConnectApplication(Environment $environment, StandaloneDock 'destination_type' => $destination->getMorphClass(), 'build_pack' => $buildPack, 'docker_registry_image_name' => 'nginx', + 'docker_compose_raw' => $compose, ]); } +function sqliteVolumeCompose(StandaloneSqlite $sqlite, string $declaration = 'external: true'): string +{ + $volume = 'sqlite-data-'.$sqlite->uuid; + + return "services:\n web:\n image: nginx:alpine\n volumes:\n - '{$volume}:/app/database'\nvolumes:\n {$volume}:\n {$declaration}\n"; +} + it('mounts the data volume into the selected application and redirects to its storage page', function () { Livewire::test(ConnectApplication::class, ['database' => $this->sqlite]) ->set('applicationUuid', $this->application->uuid) @@ -103,17 +111,82 @@ it('mounts the data volume into the selected application and redirects to its st ->and($this->sqlite->connectedVolumes()->sole()->resource->is($this->application))->toBeTrue(); }); -it('refuses to mount the volume into compose applications', function () { - $compose = createSqliteConnectApplication($this->environment, $this->destination, 'Compose App', 'dockercompose'); +it('shows the compose lines for compose applications instead of adding a storage entry', function () { + $compose = createSqliteConnectApplication($this->environment, $this->destination, 'Compose App', 'dockercompose', "services:\n api:\n image: nginx:alpine\n"); + $volume = 'sqlite-data-'.$this->sqlite->uuid; + + $component = Livewire::test(ConnectApplication::class, ['database' => $this->sqlite]) + ->assertSet('applicationOptions', fn (array $options) => str_ends_with(collect($options)->firstWhere('value', $compose->uuid)['label'], '· Docker Compose')) + ->set('applicationUuid', $compose->uuid) + ->set('mountPath', '/app/database') + ->call('connect') + ->assertHasNoErrors() + ->assertNotDispatched('error') + ->assertNoRedirect() + ->assertSet('composeApplicationName', 'Compose App') + ->assertSet('composeSnippet', "services:\n api:\n volumes:\n - '{$volume}:/app/database'\nvolumes:\n {$volume}:\n external: true\n") + ->assertSee('Add the volume to the compose file of Compose App'); + + validateDockerComposeForInjection($component->get('composeSnippet')); + expect(composeExternalVolumeDockerNames($component->get('composeSnippet')))->toBe([$volume]) + ->and($compose->persistentStorages()->count())->toBe(0); +}); + +it('lists compose applications that declare the volume and blocks deleting it or the database', function () { + createSqliteConnectApplication($this->environment, $this->destination, 'Compose App', 'dockercompose', sqliteVolumeCompose($this->sqlite)); + $databaseVolume = $this->sqlite->persistentStorages()->sole(); + + expect($this->sqlite->hasConnectedApplications())->toBeTrue() + ->and($this->sqlite->connectedApplicationNames()->all())->toBe(['Compose App']) + ->and($databaseVolume->isSharedWithAnotherResource())->toBeTrue(); + + Livewire::test(ConnectApplication::class, ['database' => $this->sqlite]) + ->assertSee('Remove it from the compose file to unlink'); + + Livewire::test(All::class, ['resource' => $this->sqlite]) + ->call('delete', $databaseVolume->id, 'password') + ->assertDispatched('error'); + + Livewire::test(Danger::class, ['resource' => $this->sqlite]) + ->call('delete', 'password') + ->assertDispatched('error'); + + expect($databaseVolume->fresh())->not->toBeNull() + ->and(StandaloneSqlite::find($this->sqlite->id))->not->toBeNull(); +}); + +it('does not show the compose lines to a compose application that already declares the volume', function () { + $compose = createSqliteConnectApplication($this->environment, $this->destination, 'Compose App', 'dockercompose', sqliteVolumeCompose($this->sqlite)); Livewire::test(ConnectApplication::class, ['database' => $this->sqlite]) - ->assertSet('applicationOptions', fn (array $options) => collect($options)->firstWhere('value', $compose->uuid)['disabled'] === true) ->set('applicationUuid', $compose->uuid) ->call('connect') ->assertDispatched('error') - ->assertNoRedirect(); + ->assertSet('composeSnippet', null); +}); - expect($compose->persistentStorages()->count())->toBe(0); +it('counts only compose applications of the same team and server that declare the volume as external', function () { + createSqliteConnectApplication($this->environment, $this->destination, 'Not External', 'dockercompose', sqliteVolumeCompose($this->sqlite, 'driver: local')); + createSqliteConnectApplication($this->environment, $this->destination, 'Other Volume', 'dockercompose', "services:\n web:\n image: nginx\n volumes:\n - 'other:/data'\nvolumes:\n other:\n external: true\n"); + + $otherServer = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $this->server->private_key_id, + 'ip' => '203.0.113.11', + ]); + $otherDestination = StandaloneDocker::withoutEvents(fn () => StandaloneDocker::firstOrCreate( + ['server_id' => $otherServer->id, 'network' => 'coolify'], + ['uuid' => (string) Str::uuid(), 'name' => 'other-docker'] + )); + createSqliteConnectApplication($this->environment, $otherDestination, 'Other Server', 'dockercompose', sqliteVolumeCompose($this->sqlite)); + + $otherProject = Project::factory()->create(['team_id' => Team::factory()->create()->id]); + $otherEnvironment = Environment::factory()->create(['project_id' => $otherProject->id]); + createSqliteConnectApplication($otherEnvironment, $this->destination, 'Other Team', 'dockercompose', sqliteVolumeCompose($this->sqlite)); + + expect($this->sqlite->hasConnectedApplications())->toBeFalse() + ->and($this->sqlite->connectedApplicationNames()->all())->toBe([]) + ->and($this->sqlite->persistentStorages()->sole()->isSharedWithAnotherResource())->toBeFalse(); }); it('ignores applications that belong to another team', function () {