From 5921fb29584ceaaf72bd865b80f46284dc5d67e4 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:31:33 +0200 Subject: [PATCH] fix(compose): keep name-only declaration for pre-existing volumes Before Coolify kept `driver`, `driver_opts` and `labels` of renamed Compose volumes, Docker created those volumes without them. Applying the options now would make Docker Compose ask to recreate the volumes on every deployment. - Add `ignores_compose_driver_options` column to local_persistent_volumes and mark all volumes that exist at upgrade time - Parsers now use composeRenamedVolumeDeclarationFor() so flagged volumes keep the old name-only top-level declaration - Legacy application parsers (v1/v2) always emit name-only declarations - Reset the flag when a volume is replicated, since a clone is a new Docker volume - Include the flag in server transfer export/import; bundles without it default to true - Storages UI shows which volumes ignore driver options and allows deleting them so the options apply on recreation - Add tests for parsers, cloning and server transfer --- app/Livewire/Project/Shared/Storages/All.php | 8 +- app/Models/LocalPersistentVolume.php | 51 +++++ .../ServerTransfer/ServerTransferExporter.php | 1 + .../ServerTransfer/ServerTransferImporter.php | 2 + bootstrap/helpers/parsers.php | 38 ++- bootstrap/helpers/shared.php | 20 +- ...ions_to_local_persistent_volumes_table.php | 32 +++ .../project/database/import-form.blade.php | 6 +- .../project/shared/storages/all.blade.php | 21 ++ .../Feature/ClonePersistentVolumeUuidTest.php | 17 ++ .../ComposeVolumeDriverOptionsTest.php | 216 +++++++++++++++++- .../ServerTransferExporterImporterTest.php | 30 +++ 12 files changed, 419 insertions(+), 23 deletions(-) create mode 100644 database/migrations/2026_09_30_115702_add_ignores_compose_driver_options_to_local_persistent_volumes_table.php diff --git a/app/Livewire/Project/Shared/Storages/All.php b/app/Livewire/Project/Shared/Storages/All.php index 75d8a0dc2b..64d8bb49c4 100644 --- a/app/Livewire/Project/Shared/Storages/All.php +++ b/app/Livewire/Project/Shared/Storages/All.php @@ -22,7 +22,7 @@ class All extends Component /** * Editable form state keyed by storage id. * - * @var array + * @var array */ public array $forms = []; @@ -141,7 +141,7 @@ class All extends Component return false; } - if ($this->isComposeOrService && $storage->isDeclaredInCompose()) { + if ($this->isComposeOrService && $storage->isDeclaredInCompose() && ! $storage->ignoresComposeDriverOptionsOfDeclaration()) { $this->dispatch('error', 'This volume is managed by the current Docker Compose file.'); return false; @@ -198,6 +198,7 @@ class All extends Component { $forms = []; foreach ($this->resource->persistentStorages->sortBy('id') as $storage) { + $ignoresDriverOptions = $this->isComposeOrService && $storage->ignoresComposeDriverOptionsOfDeclaration(); $forms[$storage->id] = [ 'name' => $storage->name, 'mountPath' => $storage->mount_path, @@ -206,8 +207,11 @@ class All extends Component 'isShared' => $storage->isSharedWithAnotherResource(), 'canDeleteStale' => $this->canUpdate && ($storage->isServiceResource() || $storage->isDockerComposeResource()) + && ! $ignoresDriverOptions && ! $storage->isDeclaredInCompose(), + 'canDeleteToApplyDriverOptions' => $this->canUpdate && $ignoresDriverOptions, 'replacedExternalVolume' => $this->isComposeOrService ? $storage->replacedExternalComposeVolume() : null, + 'ignoresDriverOptions' => $ignoresDriverOptions, ]; } $this->forms = $forms; diff --git a/app/Models/LocalPersistentVolume.php b/app/Models/LocalPersistentVolume.php index 3b3b5fd5e5..0ff3a1903f 100644 --- a/app/Models/LocalPersistentVolume.php +++ b/app/Models/LocalPersistentVolume.php @@ -17,6 +17,11 @@ class LocalPersistentVolume extends BaseModel throw new \RuntimeException('Delete this volume backup schedule and its archives before deleting the volume.'); } }); + + // A copy is a new Docker volume, so Docker Compose creates it with the driver options. + static::replicating(function (LocalPersistentVolume $volume): void { + $volume->ignores_compose_driver_options = false; + }); } protected $fillable = [ @@ -32,6 +37,7 @@ class LocalPersistentVolume extends BaseModel protected $casts = [ 'is_preview_suffix_enabled' => 'boolean', + 'ignores_compose_driver_options' => 'boolean', ]; public function resource() @@ -262,6 +268,51 @@ class LocalPersistentVolume extends BaseModel } } + /** + * Whether the Compose file gives this volume `driver`, `driver_opts` or `labels` that the parsers + * do not apply, because the volume was created before Coolify kept them. Docker Compose would ask + * to recreate such a volume, so the parsers keep its old name-only declaration. + */ + public function ignoresComposeDriverOptionsOfDeclaration(): bool + { + if (! $this->ignores_compose_driver_options) { + return false; + } + + try { + $resource = $this->resource; + if (! $resource) { + return false; + } + + $composeContent = $resource instanceof Application + ? $resource->docker_compose_raw + : data_get($resource, 'service.docker_compose_raw'); + if (blank($composeContent)) { + return false; + } + + $resourceUuid = $resource instanceof Application ? $resource->uuid : data_get($resource, 'service.uuid'); + foreach (data_get(Yaml::parse($composeContent), 'volumes') ?? [] as $key => $declaration) { + if (! is_array($declaration) || isComposeExternalVolume($declaration)) { + continue; + } + $generatedName = $resourceUuid.'_'.Str::slug((string) $key, '-'); + if ($this->name !== $generatedName && preg_match('/^'.preg_quote($generatedName, '/').'-pr-\d+$/', $this->name) !== 1) { + continue; + } + + return filled(data_get($declaration, 'driver')) + || filled(data_get($declaration, 'driver_opts')) + || filled(data_get($declaration, 'labels')); + } + + return false; + } catch (\Throwable) { + return false; + } + } + // Check if this volume is read-only by parsing the docker-compose content public function isReadOnlyVolume(): bool { diff --git a/app/Services/ServerTransfer/ServerTransferExporter.php b/app/Services/ServerTransfer/ServerTransferExporter.php index 4a07e813ea..5becc34c34 100644 --- a/app/Services/ServerTransfer/ServerTransferExporter.php +++ b/app/Services/ServerTransfer/ServerTransferExporter.php @@ -860,6 +860,7 @@ class ServerTransferExporter 'mount_path' => $volume->mount_path, '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, ]; } diff --git a/app/Services/ServerTransfer/ServerTransferImporter.php b/app/Services/ServerTransfer/ServerTransferImporter.php index c3c59443d0..6584cc2ab2 100644 --- a/app/Services/ServerTransfer/ServerTransferImporter.php +++ b/app/Services/ServerTransfer/ServerTransferImporter.php @@ -1049,6 +1049,8 @@ class ServerTransferImporter 'mount_path' => data_get($volume, 'mount_path'), 'host_path' => data_get($volume, 'host_path'), 'is_preview_suffix_enabled' => (bool) data_get($volume, 'is_preview_suffix_enabled', false), + // The server keeps its Docker volumes. A bundle without the flag is from a Coolify version that created them without driver options. + 'ignores_compose_driver_options' => (bool) data_get($volume, 'ignores_compose_driver_options', true), 'resource_type' => $resource->getMorphClass(), 'resource_id' => $resource->id, ]); diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index cd1f29257e..59c45cf27a 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -402,6 +402,19 @@ function legacyApplicationComposeVolumeName(Application $resource, string $sourc return $resource->compose_parsing_version === '2' ? "{$resource->uuid}-{$name}" : $name; } +/** + * The top-level declaration of a volume that a legacy application parser (compose_parsing_version 1 + * and 2) renamed. Only applications from before Coolify kept driver options use these parsers, and + * they have no storage entries, so their volumes keep the old name-only declaration. Docker created + * them without the options, and Docker Compose would otherwise ask to recreate them. + * + * @return array{name: string} + */ +function legacyApplicationRenamedVolumeDeclaration(string $name): array +{ + return ['name' => $name]; +} + /** * Records a warning when a legacy Compose application (parser version 1 or 2) does not use an * external volume as written. These parsers keep the old volume name (see @@ -475,6 +488,23 @@ function composeRenamedVolumeDeclaration(mixed $declaration, string $name): arra return $renamed; } +/** + * The top-level declaration of a renamed volume with its storage entry. A volume that existed before + * Coolify kept the driver options (see the `ignores_compose_driver_options` flag) keeps its old + * name-only declaration: Docker created it without the options, and Docker Compose would otherwise + * ask to recreate it on every deployment. + * + * @return array + */ +function composeRenamedVolumeDeclarationFor(mixed $declaration, string $name, ?LocalPersistentVolume $volume): array +{ + if ($volume?->ignores_compose_driver_options) { + return ['name' => $name]; + } + + return composeRenamedVolumeDeclaration($declaration, $name); +} + /** * 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. @@ -1466,8 +1496,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int } elseif (is_array($volume)) { data_set($volume, 'source', $name); } - $topLevel->get('volumes')->put($name, composeRenamedVolumeDeclaration($declaration, $name)); - LocalPersistentVolume::updateOrCreate( + $persistentVolume = LocalPersistentVolume::updateOrCreate( [ 'name' => $name, 'resource_id' => $originalResource->id, @@ -1480,6 +1509,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int 'resource_type' => get_class($originalResource), ] ); + $topLevel->get('volumes')->put($name, composeRenamedVolumeDeclarationFor($declaration, $name, $persistentVolume)); } dispatch(new ServerFilesFromServerJob($originalResource)); $volumesParsed->put($index, $volume); @@ -2815,8 +2845,7 @@ function serviceParser(Service $resource): Collection } elseif (is_array($volume)) { data_set($volume, 'source', $name); } - $topLevel->get('volumes')->put($name, composeRenamedVolumeDeclaration($declaration, $name)); - LocalPersistentVolume::updateOrCreate( + $persistentVolume = LocalPersistentVolume::updateOrCreate( [ 'name' => $name, 'resource_id' => $originalResource->id, @@ -2829,6 +2858,7 @@ function serviceParser(Service $resource): Collection 'resource_type' => get_class($originalResource), ] ); + $topLevel->get('volumes')->put($name, composeRenamedVolumeDeclarationFor($declaration, $name, $persistentVolume)); } dispatch(new ServerFilesFromServerJob($originalResource)); $volumesParsed->put($index, $volume); diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 5f4a3642fe..7d16b816d5 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -2983,8 +2983,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal } elseif (is_array($volume)) { data_set($volume, 'source', $name); } - $topLevelVolumes->put($name, composeRenamedVolumeDeclaration($declaration, $name)); - LocalPersistentVolume::updateOrCreate( + $persistentVolume = LocalPersistentVolume::updateOrCreate( [ 'mount_path' => $target, 'resource_id' => $savedService->id, @@ -2997,6 +2996,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal 'resource_type' => get_class($savedService), ] ); + $topLevelVolumes->put($name, composeRenamedVolumeDeclarationFor($declaration, $name, $persistentVolume)); } dispatch(new ServerFilesFromServerJob($savedService)); @@ -3564,7 +3564,6 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal $volume = str($volume); if ($volume->contains(':') && ! $volume->startsWith('/')) { $name = $volume->before(':'); - $volumeKey = $name->value(); $mount = $volume->after(':'); if ($name->startsWith('.') || $name->startsWith('~')) { $dir = base_configuration_dir().'/applications/'.$resource->uuid; @@ -3594,7 +3593,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal } } } else { - $topLevelVolumes->put($name, composeRenamedVolumeDeclaration($declaredTopLevelVolumes->get($volumeKey), $name)); + $topLevelVolumes->put($name, legacyApplicationRenamedVolumeDeclaration($name)); } } else { if ($topLevelVolumes->has($name->value())) { @@ -3607,7 +3606,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal } } } else { - $topLevelVolumes->put($name->value(), composeRenamedVolumeDeclaration($declaredTopLevelVolumes->get($volumeKey), $name->value())); + $topLevelVolumes->put($name->value(), legacyApplicationRenamedVolumeDeclaration($name->value())); } } } @@ -3623,7 +3622,6 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal } } elseif (is_array($volume)) { $source = data_get($volume, 'source'); - $volumeKey = (string) $source; $target = data_get($volume, 'target'); $read_only = data_get($volume, 'read_only'); if ($source && $target) { @@ -3665,7 +3663,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal } } } else { - $topLevelVolumes->put($source, composeRenamedVolumeDeclaration($declaredTopLevelVolumes->get($volumeKey), $source)); + $topLevelVolumes->put($source, legacyApplicationRenamedVolumeDeclaration($source)); } } } @@ -3686,7 +3684,6 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal $volume = str($volume); if ($volume->contains(':') && ! $volume->startsWith('/')) { $name = $volume->before(':'); - $volumeKey = $name->value(); $mount = $volume->after(':'); if ($name->startsWith('.') || $name->startsWith('~')) { $dir = base_configuration_dir().'/applications/'.$resource->uuid; @@ -3717,7 +3714,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal } } } else { - $topLevelVolumes->put($name, composeRenamedVolumeDeclaration($declaredTopLevelVolumes->get($volumeKey), $name)); + $topLevelVolumes->put($name, legacyApplicationRenamedVolumeDeclaration($name)); } } else { $uuid = $resource->uuid; @@ -3733,7 +3730,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal } } } else { - $topLevelVolumes->put($name->value(), composeRenamedVolumeDeclaration($declaredTopLevelVolumes->get($volumeKey), $name->value())); + $topLevelVolumes->put($name->value(), legacyApplicationRenamedVolumeDeclaration($name->value())); } } } @@ -3749,7 +3746,6 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal } } elseif (is_array($volume)) { $source = data_get($volume, 'source'); - $volumeKey = (string) $source; $target = data_get($volume, 'target'); $read_only = data_get($volume, 'read_only'); if ($source && $target) { @@ -3791,7 +3787,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal } } } else { - $topLevelVolumes->put($source, composeRenamedVolumeDeclaration($declaredTopLevelVolumes->get($volumeKey), $source)); + $topLevelVolumes->put($source, legacyApplicationRenamedVolumeDeclaration($source)); } } } diff --git a/database/migrations/2026_09_30_115702_add_ignores_compose_driver_options_to_local_persistent_volumes_table.php b/database/migrations/2026_09_30_115702_add_ignores_compose_driver_options_to_local_persistent_volumes_table.php new file mode 100644 index 0000000000..a257726edc --- /dev/null +++ b/database/migrations/2026_09_30_115702_add_ignores_compose_driver_options_to_local_persistent_volumes_table.php @@ -0,0 +1,32 @@ +boolean('ignores_compose_driver_options')->default(false); + }); + + // Volumes that exist now were created without driver options, so they keep the old declaration. + DB::table('local_persistent_volumes')->update(['ignores_compose_driver_options' => true]); + } + + public function down(): void + { + Schema::table('local_persistent_volumes', function (Blueprint $table) { + $table->dropColumn('ignores_compose_driver_options'); + }); + } +}; diff --git a/resources/views/livewire/project/database/import-form.blade.php b/resources/views/livewire/project/database/import-form.blade.php index 556f01a1ad..399b5f9cb6 100644 --- a/resources/views/livewire/project/database/import-form.blade.php +++ b/resources/views/livewire/project/database/import-form.blade.php @@ -76,12 +76,12 @@ @endif @if (in_array($resourceDbType, ['standalone-postgresql', 'postgresql'], true) && ! $dumpAll)
-
-
@@ -96,7 +96,7 @@ @endif @if ($resourceDbType === 'standalone-mongodb')
-
diff --git a/resources/views/livewire/project/shared/storages/all.blade.php b/resources/views/livewire/project/shared/storages/all.blade.php index 26c9a6cf1f..feaff85c2e 100644 --- a/resources/views/livewire/project/shared/storages/all.blade.php +++ b/resources/views/livewire/project/shared/storages/all.blade.php @@ -67,6 +67,11 @@ Replaces the external volume '{{ $form['replacedExternalVolume'] }}'. Copy the data into the external volume, then delete this entry to use it. @endif + @if ($form['ignoresDriverOptions']) + + Coolify does not apply the driver options of this volume because it was created before they were supported. To apply them, check the volume for data, delete this storage entry and the Docker volume, then redeploy. + + @endif @if ($hasSourcePaths) @@ -163,6 +168,22 @@ confirmationLabel="Please confirm by entering the Storage Name below" shortConfirmationLabel="Storage Name" /> @endif + + @if ($form['canDeleteToApplyDriverOptions']) + + @endif @endif diff --git a/tests/Feature/ClonePersistentVolumeUuidTest.php b/tests/Feature/ClonePersistentVolumeUuidTest.php index 13f7a13962..7b1ebfe9ec 100644 --- a/tests/Feature/ClonePersistentVolumeUuidTest.php +++ b/tests/Feature/ClonePersistentVolumeUuidTest.php @@ -158,3 +158,20 @@ test('cloning application reassigns scheduled tasks and previews to the cloned a ->and($clonedPreview?->application_id)->toBe($newApp->id) ->and($clonedPreview?->status)->toBe('exited'); }); + +test('cloning application gives the cloned volume the Compose driver options', function () { + $volume = LocalPersistentVolume::create([ + 'name' => $this->application->uuid.'_data', + 'mount_path' => '/data', + 'resource_id' => $this->application->id, + 'resource_type' => $this->application->getMorphClass(), + ]); + LocalPersistentVolume::query()->update(['ignores_compose_driver_options' => true]); + + $newApp = clone_application($this->application, $this->destination, [ + 'environment_id' => $this->environment->id, + ]); + + expect($newApp->persistentStorages()->sole()->ignores_compose_driver_options)->toBeFalse() + ->and($volume->refresh()->ignores_compose_driver_options)->toBeTrue(); +}); diff --git a/tests/Feature/ComposeVolumeDriverOptionsTest.php b/tests/Feature/ComposeVolumeDriverOptionsTest.php index 32a3904b9b..b81759431d 100644 --- a/tests/Feature/ComposeVolumeDriverOptionsTest.php +++ b/tests/Feature/ComposeVolumeDriverOptionsTest.php @@ -1,19 +1,23 @@ all(); } +/** + * Marks every storage entry as created before Coolify kept the driver options, as the migration does. + */ +function markVolumesAsCreatedBeforeDriverOptions(): void +{ + LocalPersistentVolume::query()->update(['ignores_compose_driver_options' => true]); +} + +const DRIVER_OPTIONS_NOTE = 'Coolify does not apply the driver options of this volume because it was created before they were supported.'; + describe('applicationParser', function () { it('keeps driver, driver_opts and labels on the renamed volume', function (string $compose) { $application = driverOptionsApplication($compose); @@ -268,7 +282,8 @@ describe('legacy parsers', function () { 'long syntax' => [DRIVER_OPTIONS_LONG_COMPOSE], ]); - it('keeps driver options on the renamed volume of a legacy application', function (string $parsingVersion, int $pullRequestId, string $compose, string $expectedName) { + it('keeps the name-only declaration of the renamed volume of a legacy application', function (string $parsingVersion, int $pullRequestId, string $compose, string $expectedName) { + // Legacy parsers run only for applications that exist since before driver options were kept. $application = driverOptionsApplication($compose, $parsingVersion); $uuid = $application->uuid; $previewId = $pullRequestId === 0 ? null : driverOptionsPreview($application, $pullRequestId)->id; @@ -276,7 +291,7 @@ describe('legacy parsers', function () { $parsed = parseDockerComposeFile($application, pull_request_id: $pullRequestId, preview_id: $previewId)->toArray(); - expect($parsed['volumes'][$expectedName])->toBe(expectedDriverOptionsVolume($expectedName)); + expect($parsed['volumes'][$expectedName])->toBe(['name' => $expectedName]); })->with([ 'v1 short preview' => ['1', 42, DRIVER_OPTIONS_SHORT_COMPOSE, 'opts-pr-42'], 'v1 long preview' => ['1', 42, DRIVER_OPTIONS_LONG_COMPOSE, 'opts-pr-42'], @@ -295,3 +310,200 @@ describe('legacy parsers', function () { ->and($parsed['volumes']['opts']['driver_opts']['device'])->toBe('${OPTS_DEVICE}'); }); }); + +describe('volumes created before driver options were kept', function () { + it('marks only the storage entries that exist when the migration runs', function () { + $application = driverOptionsApplication(DRIVER_OPTIONS_SHORT_COMPOSE); + $existing = $application->persistentStorages()->create(['name' => 'existing', 'mount_path' => '/existing']); + $migration = require database_path('migrations/2026_09_30_115702_add_ignores_compose_driver_options_to_local_persistent_volumes_table.php'); + $migration->down(); + $migration->up(); + + $new = $application->persistentStorages()->create(['name' => 'new', 'mount_path' => '/new']); + + expect($existing->refresh()->ignores_compose_driver_options)->toBeTrue() + ->and($new->refresh()->ignores_compose_driver_options)->toBeFalse(); + }); + + it('keeps the name-only declaration of an existing application volume', function () { + $application = driverOptionsApplication(DRIVER_OPTIONS_SHORT_COMPOSE); + $name = "{$application->uuid}_opts"; + applicationParser($application); + markVolumesAsCreatedBeforeDriverOptions(); + + $parsed = applicationParser($application->fresh())->toArray(); + + expect($parsed['volumes'][$name])->toBe(['name' => $name]) + ->and(LocalPersistentVolume::where('name', $name)->sole()->ignores_compose_driver_options)->toBeTrue(); + }); + + it('keeps the name-only declaration of an existing preview volume', function () { + $application = driverOptionsApplication(DRIVER_OPTIONS_SHORT_COMPOSE); + $preview = driverOptionsPreview($application); + $name = addPreviewDeploymentSuffix("{$application->uuid}_opts", 42); + applicationParser($application, 42, $preview->id); + markVolumesAsCreatedBeforeDriverOptions(); + + $parsed = applicationParser($application->fresh(), 42, $preview->id)->toArray(); + + expect($parsed['volumes'][$name])->toBe(['name' => $name]); + }); + + it('keeps driver options on a new preview volume of an existing application', function () { + $application = driverOptionsApplication(DRIVER_OPTIONS_SHORT_COMPOSE); + applicationParser($application); + markVolumesAsCreatedBeforeDriverOptions(); + $preview = driverOptionsPreview($application); + $name = addPreviewDeploymentSuffix("{$application->uuid}_opts", 42); + + $parsed = applicationParser($application->fresh(), 42, $preview->id)->toArray(); + + expect($parsed['volumes'][$name])->toBe(expectedDriverOptionsVolume($name)); + }); + + it('keeps the name-only declaration of an existing service volume', function () { + $service = driverOptionsService(DRIVER_OPTIONS_SHORT_COMPOSE); + $name = "{$service->uuid}_opts"; + serviceParser($service); + markVolumesAsCreatedBeforeDriverOptions(); + + $parsed = serviceParser($service->fresh())->toArray(); + + expect($parsed['volumes'][$name])->toBe(['name' => $name]); + }); + + it('keeps the name-only declaration of an existing legacy service volume', function () { + $service = driverOptionsService(DRIVER_OPTIONS_SHORT_COMPOSE, '2'); + $name = "{$service->uuid}_opts"; + parseDockerComposeFile($service); + markVolumesAsCreatedBeforeDriverOptions(); + + $parsed = parseDockerComposeFile($service->fresh())->toArray(); + + expect($parsed['volumes'][$name])->toBe(['name' => $name]); + }); + + it('keeps driver options on a volume that a new resource adds', function () { + $application = driverOptionsApplication(DRIVER_OPTIONS_SHORT_COMPOSE); + $name = "{$application->uuid}_opts"; + + applicationParser($application); + $parsed = applicationParser($application->fresh())->toArray(); + + expect($parsed['volumes'][$name])->toBe(expectedDriverOptionsVolume($name)) + ->and(LocalPersistentVolume::where('name', $name)->sole()->ignores_compose_driver_options)->toBeFalse(); + }); + + it('applies the driver options after the storage entry of an existing volume is deleted', function (string $resourceType) { + $resource = $resourceType === 'application' + ? driverOptionsApplication(DRIVER_OPTIONS_SHORT_COMPOSE) + : driverOptionsService(DRIVER_OPTIONS_SHORT_COMPOSE); + $parse = fn () => $resourceType === 'application' ? applicationParser($resource->fresh()) : serviceParser($resource->fresh()); + $name = "{$resource->uuid}_opts"; + $parse(); + markVolumesAsCreatedBeforeDriverOptions(); + + LocalPersistentVolume::where('name', $name)->sole()->delete(); + $parsed = $parse()->toArray(); + + expect($parsed['volumes'][$name])->toBe(expectedDriverOptionsVolume($name)) + ->and(LocalPersistentVolume::where('name', $name)->sole()->ignores_compose_driver_options)->toBeFalse(); + })->with(['application', 'service']); + + it('gives a copied storage entry the driver options', function () { + $application = driverOptionsApplication(DRIVER_OPTIONS_SHORT_COMPOSE); + $volume = $application->persistentStorages()->create(['name' => "{$application->uuid}_opts", 'mount_path' => '/data']); + markVolumesAsCreatedBeforeDriverOptions(); + + $copy = $volume->fresh()->replicate(['id', 'uuid'])->fill(['name' => 'copy_opts']); + $copy->save(); + + expect($copy->refresh()->ignores_compose_driver_options)->toBeFalse() + ->and($volume->refresh()->ignores_compose_driver_options)->toBeTrue(); + }); +}); + +describe('storage page', function () { + beforeEach(function () { + $this->withoutVite(); + $team = $this->environment->project->team; + $user = User::factory()->create(); + $team->members()->attach($user->id, ['role' => 'owner']); + $this->actingAs($user); + session(['currentTeam' => $team]); + }); + + it('shows a note on an existing volume whose driver options Coolify does not apply', function () { + $application = driverOptionsApplication(DRIVER_OPTIONS_SHORT_COMPOSE); + applicationParser($application); + markVolumesAsCreatedBeforeDriverOptions(); + + Livewire::test(All::class, ['resource' => $application->fresh()]) + ->assertSee(DRIVER_OPTIONS_NOTE); + }); + + it('shows the note on an existing service volume', function () { + $service = driverOptionsService(DRIVER_OPTIONS_SHORT_COMPOSE); + serviceParser($service); + markVolumesAsCreatedBeforeDriverOptions(); + + Livewire::test(All::class, ['resource' => $service->fresh()->applications()->sole()]) + ->assertSee(DRIVER_OPTIONS_NOTE); + }); + + it('lets the storage page delete the entry of an existing volume so the next parse applies the driver options', function (bool $deleteDockerVolume) { + $application = driverOptionsApplication(DRIVER_OPTIONS_SHORT_COMPOSE); + $name = "{$application->uuid}_opts"; + applicationParser($application); + markVolumesAsCreatedBeforeDriverOptions(); + $volume = LocalPersistentVolume::where('name', $name)->sole(); + $commands = []; + Process::fake(function ($process) use (&$commands) { + $commands[] = is_array($process->command) ? implode(' ', $process->command) : $process->command; + + return Process::result(output: ''); + }); + + Livewire::test(All::class, ['resource' => $application->fresh()]) + ->call('delete', $volume->id, 'password', $deleteDockerVolume ? ['deleteDockerVolume'] : []) + ->assertHasNoErrors() + ->assertReturned(true); + + expect(LocalPersistentVolume::find($volume->id))->toBeNull() + ->and(implode("\n", $commands))->{$deleteDockerVolume ? 'toContain' : 'not'}("docker volume rm -f '{$name}'"); + $parsed = applicationParser($application->fresh())->toArray(); + expect($parsed['volumes'][$name])->toBe(expectedDriverOptionsVolume($name)); + })->with([ + 'keep the Docker volume' => [false], + 'delete the Docker volume' => [true], + ]); + + it('does not let the storage page delete the entry of a new volume', function () { + $application = driverOptionsApplication(DRIVER_OPTIONS_SHORT_COMPOSE); + applicationParser($application); + $volume = LocalPersistentVolume::where('name', "{$application->uuid}_opts")->sole(); + + Livewire::test(All::class, ['resource' => $application->fresh()]) + ->call('delete', $volume->id, 'password') + ->assertReturned(false); + + expect(LocalPersistentVolume::find($volume->id))->not->toBeNull(); + }); + + it('does not show the note on a new volume', function () { + $application = driverOptionsApplication(DRIVER_OPTIONS_SHORT_COMPOSE); + applicationParser($application); + + Livewire::test(All::class, ['resource' => $application->fresh()]) + ->assertDontSee(DRIVER_OPTIONS_NOTE); + }); + + it('does not show the note on an existing volume without driver options', function () { + $application = driverOptionsApplication(DRIVER_OPTIONS_NFS_MIXED_COMPOSE); + applicationParser($application); + markVolumesAsCreatedBeforeDriverOptions(); + + Livewire::test(All::class, ['resource' => $application->fresh()]) + ->assertDontSee(DRIVER_OPTIONS_NOTE); + }); +}); diff --git a/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php b/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php index 41d5cc7813..89f500f307 100644 --- a/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php +++ b/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php @@ -910,3 +910,33 @@ test('import rejects shell-like file ownership and mode metadata', function (str 'mode command' => ['chmod', '600; id'], 'option mode' => ['chmod', '--reference=/etc/passwd'], ]); + +test('transfer keeps whether a volume ignores the Compose driver options', function (?bool $exported, bool $expected) { + LocalPersistentVolume::query()->update(['ignores_compose_driver_options' => (bool) $exported]); + $bundle = $this->exporter->export($this->server); + $storage = &$bundle['projects'][0]['environments'][0]['applications'][0]['persistent_storages'][0]; + expect($storage['ignores_compose_driver_options'])->toBe((bool) $exported); + if ($exported === null) { + // A bundle from a Coolify version without the flag: its volumes were created without driver options. + unset($storage['ignores_compose_driver_options']); + } + unset($storage); + $originalAppUuid = $this->application->uuid; + + $this->service->forceDelete(); + $this->application->forceDelete(); + $this->database->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: true, adoptMode: true); + + expect(Application::where('uuid', $originalAppUuid)->sole()->persistentStorages()->sole()->ignores_compose_driver_options)->toBe($expected); +})->with([ + 'existing volume' => [true, true], + 'new volume' => [false, false], + 'bundle without the flag' => [null, true], +]);