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
This commit is contained in:
Andras Bacsai
2026-09-30 14:31:33 +02:00
parent 9e7cb60b0d
commit 5921fb2958
12 changed files with 419 additions and 23 deletions
+6 -2
View File
@@ -22,7 +22,7 @@ class All extends Component
/**
* Editable form state keyed by storage id.
*
* @var array<int|string, array{name: string, mountPath: string, isPreviewSuffixEnabled: bool, isReadOnly: bool, isShared: bool, canDeleteStale: bool, replacedExternalVolume: ?string}>
* @var array<int|string, array{name: string, mountPath: string, isPreviewSuffixEnabled: bool, isReadOnly: bool, isShared: bool, canDeleteStale: bool, replacedExternalVolume: ?string, ignoresDriverOptions: bool, canDeleteToApplyDriverOptions: bool}>
*/
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;
+51
View File
@@ -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
{
@@ -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,
];
}
@@ -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,
]);
+34 -4
View File
@@ -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<string, mixed>
*/
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);
+8 -12
View File
@@ -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));
}
}
}
@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Before Coolify kept `driver`, `driver_opts` and `labels` of a renamed Compose volume, Docker
* created the volume without them. Mark every volume that exists at upgrade time, so the parsers
* keep its old name-only declaration and Docker Compose does not ask to recreate the volume.
*/
public function up(): void
{
// New volumes get their driver options, so the default for new rows is false.
Schema::table('local_persistent_volumes', function (Blueprint $table) {
$table->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');
});
}
};
@@ -76,12 +76,12 @@
@endif
@if (in_array($resourceDbType, ['standalone-postgresql', 'postgresql'], true) && ! $dumpAll)
<div class="max-w-sm">
<x-forms.checkbox id="replaceExisting" label="Replace objects that already exist"
<x-forms.checkbox id="replaceExisting" live label="Replace objects that already exist"
helper="Archive backups: drops matching tables, functions, types, and other PostgreSQL objects before restoring them. SQL backups: recreates the database before the restore."
canGate="update" :canResource="$this->resource" />
</div>
<div class="max-w-sm">
<x-forms.checkbox id="keepOwners" label="Keep owners and privileges"
<x-forms.checkbox id="keepOwners" live label="Keep owners and privileges"
helper="Archive backups: restores object owners and GRANTs from the backup. Leave this off for backups from another server (for example Amazon RDS): its roles usually do not exist here, and the whole restore would be rolled back."
canGate="update" :canResource="$this->resource" />
</div>
@@ -96,7 +96,7 @@
@endif
@if ($resourceDbType === 'standalone-mongodb')
<div class="max-w-sm">
<x-forms.checkbox id="replaceExisting" label="Replace collections that already exist"
<x-forms.checkbox id="replaceExisting" live label="Replace collections that already exist"
helper="Drops each collection from the backup before restoring it. Without this option, documents that already exist are skipped."
canGate="update" :canResource="$this->resource" />
</div>
@@ -67,6 +67,11 @@
Replaces the external volume '{{ $form['replacedExternalVolume'] }}'. Copy the data into the external volume, then delete this entry to use it.
</span>
@endif
@if ($form['ignoresDriverOptions'])
<span class="block text-xs text-amber-800 dark:text-amber-300/90">
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.
</span>
@endif
</div>
@if ($hasSourcePaths)
@@ -163,6 +168,22 @@
confirmationLabel="Please confirm by entering the Storage Name below"
shortConfirmationLabel="Storage Name" />
@endif
@if ($form['canDeleteToApplyDriverOptions'])
<x-modal-confirmation title="Delete volume entry to apply driver options?" isErrorButton
buttonTitle="Delete" submitAction="delete({{ $id }})"
:checkboxes="[[
'id' => 'deleteDockerVolume',
'label' => 'Also permanently delete the Docker volume and all its data.',
'default_warning' => 'The Docker volume and its data will not be deleted. Docker Compose applies the driver options only to a new volume.',
]]"
:actions="[
'This removes the storage entry from Coolify.',
'The next deployment creates the volume with the driver options of the Compose file.',
]" confirmationText="{{ $form['name'] }}"
confirmationLabel="Please confirm by entering the Storage Name below"
shortConfirmationLabel="Storage Name" />
@endif
</div>
@endif
</div>
@@ -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();
});
@@ -1,19 +1,23 @@
<?php
use App\Livewire\Project\Shared\Storages\All;
use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\LocalPersistentVolume;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
uses(RefreshDatabase::class);
@@ -180,6 +184,16 @@ function driverOptionsVolumeSources(array $volumes): array
->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);
});
});
@@ -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],
]);