From b1826542037bdde2006a349120fbce2385b8263d Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Fri, 25 Sep 2026 22:47:29 +0200 Subject: [PATCH] feat(database): add option to connect a sqlite database to an application in 1 click --- .../Database/Sqlite/ConnectApplication.php | 167 ++++++++++++++++++ .../Project/Database/Sqlite/General.php | 5 +- app/Livewire/Project/Shared/Storages/All.php | 6 + app/Models/Application.php | 3 + app/Models/LocalPersistentVolume.php | 18 ++ .../sqlite/connect-application.blade.php | 29 +++ .../project/database/sqlite/general.blade.php | 9 +- .../Feature/SqliteConnectApplicationTest.php | 167 ++++++++++++++++++ tests/Feature/StandaloneSqliteTest.php | 12 ++ 9 files changed, 408 insertions(+), 8 deletions(-) create mode 100644 app/Livewire/Project/Database/Sqlite/ConnectApplication.php create mode 100644 resources/views/livewire/project/database/sqlite/connect-application.blade.php create mode 100644 tests/Feature/SqliteConnectApplicationTest.php diff --git a/app/Livewire/Project/Database/Sqlite/ConnectApplication.php b/app/Livewire/Project/Database/Sqlite/ConnectApplication.php new file mode 100644 index 0000000000..88c49b3bec --- /dev/null +++ b/app/Livewire/Project/Database/Sqlite/ConnectApplication.php @@ -0,0 +1,167 @@ + + */ + public array $applicationOptions = []; + + public function mount(): void + { + $this->authorize('view', $this->database); + + $this->volumeName = $this->database->persistentStorages() + ->whereNull('host_path') + ->orderBy('id') + ->value('name') ?? 'sqlite-data-'.$this->database->uuid; + + $this->applicationOptions = $this->connectableApplications() + ->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. + */ + public function connect() + { + $this->authorize('view', $this->database); + + $this->validate([ + 'applicationUuid' => 'required|string', + 'mountPath' => ['required', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN], + ], [ + 'applicationUuid.required' => 'Select an application first.', + 'mountPath.regex' => 'Mount path must start with / and only contain safe path characters.', + ]); + + $application = $this->selectedApplication(); + if ($application) { + $this->authorize('update', $application); + } + + try { + if (! $application) { + throw new \Exception('The selected application is not available on this server.'); + } + + if ($this->isComposeApplication($application)) { + throw new \Exception('Docker Compose applications are not supported: Coolify renames named volumes in compose files.'); + } + + if ($application->persistentStorages()->where('name', $this->volumeName)->exists()) { + throw new \Exception("{$application->name} already mounts this database volume."); + } + LocalPersistentVolume::create([ + 'name' => $this->volumeName, + 'mount_path' => $this->mountPath, + 'host_path' => null, + 'resource_id' => $application->id, + 'resource_type' => $application->getMorphClass(), + 'is_preview_suffix_enabled' => false, + ]); + + return redirect()->route('project.application.persistent-storage', $this->applicationRouteParameters($application)); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function render() + { + return view('livewire.project.database.sqlite.connect-application'); + } + + /** + * Applications owned by the current team that run on the same server as the database. + * + * @return Collection + */ + private function connectableApplications(): Collection + { + $serverId = $this->database->destination?->server_id; + if ($serverId === null) { + return collect(); + } + + return Application::ownedByCurrentTeam() + ->with(['destination', 'environment.project']) + ->get() + ->filter(fn (Application $application) => $application->destination?->server_id === $serverId) + ->values(); + } + + private function selectedApplication(): ?Application + { + if (blank($this->applicationUuid)) { + return null; + } + + return $this->connectableApplications()->firstWhere('uuid', $this->applicationUuid); + } + + private function applicationLabel(Application $application): string + { + $label = $application->name; + + $project = $application->environment?->project?->name; + $environment = $application->environment?->name; + if ($project && $environment) { + $label .= " ({$project} / {$environment})"; + } + + if ($this->isComposeApplication($application)) { + $label .= ' · Docker Compose (not supported)'; + } + + return $label; + } + + private function isComposeApplication(Application $application): bool + { + return $application->build_pack === 'dockercompose'; + } + + /** + * @return array{project_uuid: string, environment_uuid: string, application_uuid: string} + */ + private function applicationRouteParameters(Application $application): array + { + return [ + 'project_uuid' => $application->environment->project->uuid, + 'environment_uuid' => $application->environment->uuid, + 'application_uuid' => $application->uuid, + ]; + } +} diff --git a/app/Livewire/Project/Database/Sqlite/General.php b/app/Livewire/Project/Database/Sqlite/General.php index 38aa16e614..59e05363d3 100644 --- a/app/Livewire/Project/Database/Sqlite/General.php +++ b/app/Livewire/Project/Database/Sqlite/General.php @@ -116,7 +116,10 @@ class General extends Component try { $this->authorize('update', $this->database); - $this->sqliteDatabases = str($this->sqliteDatabases)->replace(' ', '')->trim(',')->toString(); + $this->sqliteDatabases = collect(explode(',', $this->sqliteDatabases)) + ->map(fn (string $name) => trim($name)) + ->filter(fn (string $name) => $name !== '') + ->implode(','); $this->syncData(true); $this->dispatch('success', 'Database updated.'); $this->dispatch('databaseUpdated'); diff --git a/app/Livewire/Project/Shared/Storages/All.php b/app/Livewire/Project/Shared/Storages/All.php index ce564829fb..a7886484cf 100644 --- a/app/Livewire/Project/Shared/Storages/All.php +++ b/app/Livewire/Project/Shared/Storages/All.php @@ -147,6 +147,12 @@ class All extends Component $this->deleteDockerVolume = in_array('deleteDockerVolume', $selectedActions, true); if ($this->deleteDockerVolume) { + if ($storage->isSharedWithAnotherResource()) { + $this->dispatch('error', 'This Docker volume is also mounted by another resource. Remove the mount without deleting the Docker volume.'); + + return false; + } + $server = $this->resource instanceof Application ? $this->resource->destination->server : $this->resource->service->server; diff --git a/app/Models/Application.php b/app/Models/Application.php index bbc37290b4..bb047e910d 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -555,6 +555,9 @@ class Application extends BaseModel } $server = data_get($this, 'destination.server'); foreach ($persistentStorages as $storage) { + if ($storage->isSharedWithAnotherResource()) { + continue; + } instant_remote_process(['docker volume rm -f '.escapeshellarg($storage->name)], $server, false); } } diff --git a/app/Models/LocalPersistentVolume.php b/app/Models/LocalPersistentVolume.php index add857fe2a..0b5cc908f4 100644 --- a/app/Models/LocalPersistentVolume.php +++ b/app/Models/LocalPersistentVolume.php @@ -64,6 +64,24 @@ class LocalPersistentVolume extends BaseModel } } + /** + * Whether another resource mounts the same Docker volume, e.g. an application that uses a SQLite database volume. + */ + public function isSharedWithAnotherResource(): bool + { + if (filled($this->host_path)) { + return false; + } + + return static::query() + ->where('name', $this->name) + ->where(function ($query): void { + $query->where('resource_type', '!=', $this->resource_type) + ->orWhere('resource_id', '!=', $this->resource_id); + }) + ->exists(); + } + protected function customizeName($value) { return str($value)->trim()->value; diff --git a/resources/views/livewire/project/database/sqlite/connect-application.blade.php b/resources/views/livewire/project/database/sqlite/connect-application.blade.php new file mode 100644 index 0000000000..b9ff075554 --- /dev/null +++ b/resources/views/livewire/project/database/sqlite/connect-application.blade.php @@ -0,0 +1,29 @@ +{{-- Rendered inside the General form, so no
here: the button is type=button and Enter is handled explicitly. --}} +
+ + @if (count($applicationOptions) === 0) +

+ No applications are deployed on this server yet. +

+ @else +
+
+ + +
+ +
+ + Mount volume + +
+
+ @endif +
+
diff --git a/resources/views/livewire/project/database/sqlite/general.blade.php b/resources/views/livewire/project/database/sqlite/general.blade.php index aab6e8cb55..bf22507420 100644 --- a/resources/views/livewire/project/database/sqlite/general.blade.php +++ b/resources/views/livewire/project/database/sqlite/general.blade.php @@ -27,15 +27,10 @@ canGate="update" :canResource="$database" helper="Comma-separated file names. Missing files are created in the data volume on the next start. Removing a name does not delete its file. Backups restore into the first file." /> -
- - Mount the data volume from Persistent Storage in the application and open - <mount path>/<file name>. - The application must run as user 65532 or as root to access the files. - -
+ + withoutVite(); + + InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate( + ['id' => 0], + ['id' => 0, 'is_dns_validation_enabled' => false] + )); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + $keyId = DB::table('private_keys')->insertGetId([ + 'uuid' => (string) Str::uuid(), + 'name' => 'Test Key', + 'private_key' => encrypt('test-key'), + 'team_id' => $this->team->id, + 'created_at' => now(), + 'updated_at' => now(), + ]); + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $keyId, + 'ip' => '203.0.113.10', + ]); + $this->server->settings()->update(['is_reachable' => true, 'is_usable' => true]); + + $this->destination = StandaloneDocker::withoutEvents(fn () => StandaloneDocker::firstOrCreate( + ['server_id' => $this->server->id, 'network' => 'coolify'], + ['uuid' => (string) Str::uuid(), 'name' => 'test-docker'] + )); + + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + + $this->sqlite = StandaloneSqlite::create([ + 'name' => 'app-sqlite', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $this->application = createSqliteConnectApplication($this->environment, $this->destination, 'Nginx App'); +}); + +function createSqliteConnectApplication(Environment $environment, StandaloneDocker $destination, string $name, string $buildPack = 'dockerimage'): Application +{ + return Application::factory()->create([ + 'uuid' => (string) Str::uuid(), + 'name' => $name, + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'build_pack' => $buildPack, + 'docker_registry_image_name' => 'nginx', + ]); +} + +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) + ->set('mountPath', '/app/database') + ->call('connect') + ->assertHasNoErrors() + ->assertRedirect(route('project.application.persistent-storage', [ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'application_uuid' => $this->application->uuid, + ])); + + $volume = $this->application->persistentStorages()->sole(); + + expect($volume->name)->toBe('sqlite-data-'.$this->sqlite->uuid) + ->and($volume->mount_path)->toBe('/app/database') + ->and($volume->is_preview_suffix_enabled)->toBeFalse(); +}); + +it('refuses to mount the volume into compose applications', function () { + $compose = createSqliteConnectApplication($this->environment, $this->destination, 'Compose App', 'dockercompose'); + + 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(); + + expect($compose->persistentStorages()->count())->toBe(0); +}); + +it('ignores applications that belong to another team', function () { + $otherProject = Project::factory()->create(['team_id' => Team::factory()->create()->id]); + $otherEnvironment = Environment::factory()->create(['project_id' => $otherProject->id]); + $otherApplication = createSqliteConnectApplication($otherEnvironment, $this->destination, 'Other Team App'); + + Livewire::test(ConnectApplication::class, ['database' => $this->sqlite]) + ->assertSet('applicationOptions', fn (array $options) => collect($options)->pluck('value')->all() === [$this->application->uuid]) + ->set('applicationUuid', $otherApplication->uuid) + ->call('connect') + ->assertDispatched('error') + ->assertNoRedirect(); + + expect($otherApplication->persistentStorages()->count())->toBe(0); +}); + +it('forbids team members from mounting the volume into an application', function () { + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + $this->actingAs($member); + + Livewire::actingAs($member) + ->test(ConnectApplication::class, ['database' => $this->sqlite]) + ->set('applicationUuid', $this->application->uuid) + ->call('connect') + ->assertForbidden(); + + expect($this->application->persistentStorages()->count())->toBe(0); +}); + +it('keeps a docker volume shared with another resource when the application volumes are deleted', function () { + Process::fake(); + + LocalPersistentVolume::create([ + 'name' => 'sqlite-data-'.$this->sqlite->uuid, + 'mount_path' => StandaloneSqlite::DATA_DIRECTORY, + 'host_path' => null, + 'resource_id' => $this->application->id, + 'resource_type' => $this->application->getMorphClass(), + 'is_preview_suffix_enabled' => false, + ]); + LocalPersistentVolume::create([ + 'name' => $this->application->uuid.'-data', + 'mount_path' => '/data', + 'host_path' => null, + 'resource_id' => $this->application->id, + 'resource_type' => $this->application->getMorphClass(), + ]); + + $this->application->deleteVolumes(); + + Process::assertRan(fn ($process) => str_contains($process->command, 'docker volume rm -f '.escapeshellarg($this->application->uuid.'-data'))); + Process::assertNotRan(fn ($process) => str_contains($process->command, 'sqlite-data-'.$this->sqlite->uuid)); +}); diff --git a/tests/Feature/StandaloneSqliteTest.php b/tests/Feature/StandaloneSqliteTest.php index 5e7b95f475..c441a7067b 100644 --- a/tests/Feature/StandaloneSqliteTest.php +++ b/tests/Feature/StandaloneSqliteTest.php @@ -103,6 +103,18 @@ it('normalises the file list when saved from the general page', function () { expect($this->database->refresh()->sqlite_databases)->toBe('app.db,cache.db'); }); +it('rejects space separated file names instead of merging them', function () { + $original = $this->database->sqlite_databases; + + Livewire::actingAs($this->user) + ->test(General::class, ['database' => $this->database]) + ->set('sqliteDatabases', 'app.db cache.db') + ->call('submit') + ->assertNotDispatched('success'); + + expect($this->database->refresh()->sqlite_databases)->toBe($original); +}); + it('creates a sqlite database through the API without public access fields', function () { $headers = sqliteApiHeaders(); $payload = [