From 8144b1cbc1eca29023af0cb72be9475487e13aa6 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 1 Oct 2026 07:16:35 +0200 Subject: [PATCH] fix(transfer): keep SQLite volume links during server transfer The server transfer bundle did not include the standalone_sqlite_id of persistent volumes, so after an import an application's mount of a SQLite data volume was no longer linked to the database. The shared volume checks then failed: deleting the SQLite database, or the application with its volumes, could remove the SQLite data. The exporter now sends the SQLite database uuid with each volume, and the importer links the volume again after all databases are imported (with a warning when the database is not in the bundle). As a backstop, StandaloneSqlite::deleteVolumes() skips volumes that another resource uses, like Application already does. --- app/Models/StandaloneSqlite.php | 3 ++ .../ServerTransfer/ServerTransferExporter.php | 1 + .../ServerTransfer/ServerTransferImporter.php | 38 ++++++++++++++++++ .../Feature/SqliteConnectApplicationTest.php | 19 +++++++++ .../ServerTransferExporterImporterTest.php | 40 +++++++++++++++++++ 5 files changed, 101 insertions(+) diff --git a/app/Models/StandaloneSqlite.php b/app/Models/StandaloneSqlite.php index 81726497e9..4f5c1cfc74 100644 --- a/app/Models/StandaloneSqlite.php +++ b/app/Models/StandaloneSqlite.php @@ -180,6 +180,9 @@ class StandaloneSqlite 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/Services/ServerTransfer/ServerTransferExporter.php b/app/Services/ServerTransfer/ServerTransferExporter.php index 5becc34c34..d169a1bc06 100644 --- a/app/Services/ServerTransfer/ServerTransferExporter.php +++ b/app/Services/ServerTransfer/ServerTransferExporter.php @@ -861,6 +861,7 @@ class ServerTransferExporter 'host_path' => $volume->host_path, 'is_preview_suffix_enabled' => (bool) ($volume->is_preview_suffix_enabled ?? false), 'ignores_compose_driver_options' => (bool) $volume->ignores_compose_driver_options, + 'standalone_sqlite_uuid' => $volume->standaloneSqlite?->uuid, ]; } diff --git a/app/Services/ServerTransfer/ServerTransferImporter.php b/app/Services/ServerTransfer/ServerTransferImporter.php index 6584cc2ab2..92945b8dc4 100644 --- a/app/Services/ServerTransfer/ServerTransferImporter.php +++ b/app/Services/ServerTransfer/ServerTransferImporter.php @@ -88,6 +88,13 @@ class ServerTransferImporter /** @var array */ private array $databaseMap = []; + /** + * Imported volumes that mount a SQLite database volume, keyed by volume id. The database may be imported later. + * + * @var array + */ + private array $pendingSqliteLinks = []; + /** * @param array $bundle * @return array{ @@ -219,6 +226,7 @@ class ServerTransferImporter $this->applicationMap = []; $this->serviceMap = []; $this->databaseMap = []; + $this->pendingSqliteLinks = []; // Import shared dependencies first $keyPayloads = data_get($bundle, 'private_keys', []); @@ -306,6 +314,8 @@ class ServerTransferImporter } } + $warnings = array_merge($warnings, $this->linkSqliteVolumes()); + $created['ssl_certificates'] = $this->importSslCertificates(data_get($bundle, 'ssl_certificates', []), $server); $created['volume_backups'] = $this->importVolumeBackups(data_get($bundle, 'volume_backups', []), $teamId); @@ -1060,9 +1070,37 @@ class ServerTransferImporter if (filled(data_get($volume, 'uuid'))) { $this->volumeMap[(string) data_get($volume, 'uuid')] = $volumeModel; } + if (filled(data_get($volume, 'standalone_sqlite_uuid'))) { + $this->pendingSqliteLinks[$volumeModel->id] = [ + 'volume' => $volumeModel, + 'sqlite_uuid' => (string) data_get($volume, 'standalone_sqlite_uuid'), + ]; + } } } + /** + * Connect imported volumes to the imported SQLite database whose data volume they mount. + * + * @return list warnings for volumes whose database is not in the bundle + */ + private function linkSqliteVolumes(): array + { + $warnings = []; + foreach ($this->pendingSqliteLinks as $link) { + $sqlite = $this->databaseMap[$link['sqlite_uuid']] ?? null; + if (! $sqlite instanceof StandaloneSqlite) { + $warnings[] = "Volume {$link['volume']->name} mounts a SQLite database that is not part of this transfer. Coolify does not protect it when that database or its applications are deleted."; + + continue; + } + + $link['volume']->forceFill(['standalone_sqlite_id' => $sqlite->id])->save(); + } + + return $warnings; + } + /** * @param list> $storages */ diff --git a/tests/Feature/SqliteConnectApplicationTest.php b/tests/Feature/SqliteConnectApplicationTest.php index 2fc7ff3a34..258ac78b94 100644 --- a/tests/Feature/SqliteConnectApplicationTest.php +++ b/tests/Feature/SqliteConnectApplicationTest.php @@ -245,6 +245,25 @@ it('keeps a docker volume shared with another resource when the application volu Process::assertNotRan(fn ($process) => str_contains($process->command, 'sqlite-data-'.$this->sqlite->uuid)); }); +it('keeps the sqlite data volume when the database volumes are deleted while an application mounts it', function () { + Process::fake(); + connectSqliteVolume($this->application, $this->sqlite); + $databaseVolume = $this->sqlite->persistentStorages()->sole(); + + $this->sqlite->deleteVolumes(); + + Process::assertNotRan(fn ($process) => str_contains($process->command, $databaseVolume->name)); +}); + +it('removes the sqlite data volume when no application mounts it', function () { + Process::fake(); + $databaseVolume = $this->sqlite->persistentStorages()->sole(); + + $this->sqlite->deleteVolumes(); + + Process::assertRan(fn ($process) => str_contains($process->command, 'docker volume rm -f '.escapeshellarg($databaseVolume->name))); +}); + function connectSqliteVolume(Application $application, StandaloneSqlite $sqlite): LocalPersistentVolume { return LocalPersistentVolume::create([ diff --git a/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php b/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php index 89f500f307..6f9426eef5 100644 --- a/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php +++ b/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php @@ -21,6 +21,7 @@ use App\Models\ServiceDatabase; use App\Models\SharedEnvironmentVariable; use App\Models\StandaloneDocker; use App\Models\StandalonePostgresql; +use App\Models\StandaloneSqlite; use App\Models\Tag; use App\Models\Team; use App\Services\ServerTransfer\ServerTransferBundle; @@ -940,3 +941,42 @@ test('transfer keeps whether a volume ignores the Compose driver options', funct 'new volume' => [false, false], 'bundle without the flag' => [null, true], ]); + +test('transfer keeps the link between an application volume and its sqlite database', function () { + $sqlite = StandaloneSqlite::create([ + 'name' => 'app-sqlite', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + LocalPersistentVolume::create([ + 'name' => 'sqlite-data-'.$sqlite->uuid, + 'mount_path' => StandaloneSqlite::DATA_DIRECTORY, + 'standalone_sqlite_id' => $sqlite->id, + 'resource_type' => $this->application->getMorphClass(), + 'resource_id' => $this->application->id, + ]); + $bundle = $this->exporter->export($this->server); + $originalAppName = $this->application->name; + + $this->service->forceDelete(); + $this->application->forceDelete(); + $this->database->forceDelete(); + $sqlite->forceDelete(); + $this->server->forceDelete(); + Tag::query()->delete(); + ScheduledDatabaseBackup::query()->delete(); + ScheduledTask::query()->delete(); + $this->privateKey->delete(); + + $this->importer->import($bundle, teamId: $this->team->id, dryRun: false, preserveUuids: false, adoptMode: true); + + $importedSqlite = StandaloneSqlite::where('name', 'app-sqlite')->sole(); + $connectedVolume = Application::where('name', $originalAppName)->sole() + ->persistentStorages()->where('mount_path', StandaloneSqlite::DATA_DIRECTORY)->sole(); + + expect($connectedVolume->standalone_sqlite_id)->toBe($importedSqlite->id) + ->and($connectedVolume->isSharedWithAnotherResource())->toBeTrue() + ->and($importedSqlite->hasConnectedApplications())->toBeTrue() + ->and($importedSqlite->persistentStorages()->sole()->isSharedWithAnotherResource())->toBeTrue(); +});