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(); +});