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.
This commit is contained in:
Andras Bacsai
2026-10-01 07:16:35 +02:00
parent 658ac6bca5
commit 8144b1cbc1
5 changed files with 101 additions and 0 deletions
+3
View File
@@ -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);
}
}
@@ -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,
];
}
@@ -88,6 +88,13 @@ class ServerTransferImporter
/** @var array<string, Model> */
private array $databaseMap = [];
/**
* Imported volumes that mount a SQLite database volume, keyed by volume id. The database may be imported later.
*
* @var array<int, array{volume: LocalPersistentVolume, sqlite_uuid: string}>
*/
private array $pendingSqliteLinks = [];
/**
* @param array<string, mixed> $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<string> 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<array<string, mixed>> $storages
*/
@@ -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([
@@ -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();
});