fix(storage): confirm volume renames because the data stays in the old volume

Closes #8816

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-11-03 20:34:55 +01:00
co-authored by Claude Opus 5.5
parent e29dcf70d9
commit a05fe524f8
4 changed files with 128 additions and 2 deletions
+1
View File
@@ -20,6 +20,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
### Fixed
- Ask for confirmation in a dialog before you change the name of a persistent volume of an application or a database. A new name does not rename the Docker volume: after the next restart, the resource uses a different volume, which Docker creates empty, and the data stays in the old volume. Before, Coolify saved the new name without a warning, so the data looked lost after the restart. The dialog tells you to copy the data into the new volume ([#8816](https://github.com/coollabsio/coolify/issues/8816))
- Show "Not in Compose file" on the applications and databases of a service that you removed from its Docker Compose file, and do not count them in the service status. Before, each removed part showed as "Exited" and the service stayed degraded. Coolify keeps the settings of a removed part: if you add the service back under the same name, Coolify uses the part again. To remove the part, delete it in its settings. A part that a Compose error created, for example a `ports:` block at the wrong indentation, is also marked. Existing parts are marked at the next deployment or save of the service ([#9591](https://github.com/coollabsio/coolify/issues/9591))
- Fix deployments that failed with "The container name ... is already in use" when **Use build secrets** is on. To set the real `SOURCE_COMMIT`, Coolify starts the helper container a second time with the same name. Before, Coolify stopped the old helper and started the new one immediately, but Docker removes a stopped `--rm` container in the background, so the name was sometimes still in use. Coolify now removes the old helper and waits until Docker frees the name ([#9363](https://github.com/coollabsio/coolify/issues/9363))
- Fix Docker builds that failed with "BuildKit is enabled but the buildx component is missing or broken" when the application has a build-time variable `HOME` or `DOCKER_CONFIG`. Coolify loads the build-time variables before it runs `docker build` in the helper container, so the variable moved the Docker CLI away from its plugins in `/root/.docker/cli-plugins`. The Docker CLI in the helper now always uses `/root/.docker`. A `HOME` build argument keeps its value ([#9726](https://github.com/coollabsio/coolify/issues/9726))
+27 -2
View File
@@ -47,6 +47,13 @@ class All extends Component
public bool $deleteDockerVolume = false;
/**
* Volume rename that waits for confirmation. A new name mounts another Docker volume, and the data stays in the old one.
*
* @var array{storageId: int, from: string, to: string}|null
*/
public ?array $pendingRename = null;
protected $listeners = ['refreshVolumeList' => 'refreshList', 'refreshVolumeBackups' => 'refreshList'];
public function mount(): void
@@ -80,11 +87,12 @@ class All extends Component
$this->resource->loadMissing('environment.project');
}
$this->pendingRename = null;
$this->rebuildForms();
$this->rebuildVolumeBackupMeta();
}
public function submit(int $storageId): void
public function submit(int $storageId, bool $renameConfirmed = false): void
{
$this->authorize('update', $this->resource);
$this->validateStorage($storageId);
@@ -97,7 +105,16 @@ class All extends Component
}
$form = $this->forms[$storageId];
if (! $storage->isSharedWithAnotherResource()) {
$isRename = ! $storage->isSharedWithAnotherResource() && $form['name'] !== $storage->name;
$rename = ['storageId' => $storageId, 'from' => $storage->name, 'to' => $form['name']];
if ($isRename && ! ($renameConfirmed && $this->pendingRename === $rename)) {
$this->pendingRename = $rename;
return;
}
$this->pendingRename = null;
if ($isRename) {
$storage->name = $form['name'];
}
$storage->mount_path = $form['mountPath'];
@@ -112,6 +129,14 @@ class All extends Component
$this->dispatch('success', 'Storage updated successfully');
}
public function cancelRename(int $storageId): void
{
$this->authorize('view', $this->resource);
$storage = $this->findStorageOrFail($storageId);
$this->forms[$storageId]['name'] = $storage->name;
$this->pendingRename = null;
}
public function instantSave(int $storageId): void
{
$this->submit($storageId);
@@ -352,4 +352,53 @@
</div>
@endif
@if ($pendingRename)
<div x-data="{ modalOpen: true }" wire:key="rename-volume-modal-{{ md5(json_encode($pendingRename)) }}"
@keydown.escape.window="if (modalOpen) { modalOpen = false; $wire.cancelRename({{ $pendingRename['storageId'] }}) }"
:class="{ 'z-40': modalOpen }" class="relative h-auto w-auto">
<template x-teleport="body">
<div x-show="modalOpen"
class="fixed inset-0 z-99 flex min-h-full items-center justify-center overflow-y-auto p-4" x-cloak>
<div x-show="modalOpen" class="absolute inset-0 bg-black/50 backdrop-blur-[2px]"></div>
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen" role="dialog" aria-modal="true"
x-transition:enter="ease-out duration-100"
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
class="application-settings-form application-settings-section relative w-full lg:min-w-[36rem] lg:max-w-2xl"
style="box-shadow: 0 0 0 1px var(--coollabs-hairline), var(--shadow-modal)">
<header>
<h3>Change the volume name?</h3>
<button type="button" aria-label="Close"
@click="modalOpen = false; $wire.cancelRename({{ $pendingRename['storageId'] }})"
class="flex size-7 items-center justify-center rounded-md text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg">
<x-reicon name="x" class="size-4" />
</button>
</header>
<div class="application-settings-section-body">
<x-callout type="warning" title="The data does not move to the new volume" class="mb-4">
<ul class="mt-2 ml-4 list-disc">
<li>Changing the name does not rename or move the Docker volume.</li>
<li>After the next restart or deployment, the resource uses the Docker volume <strong>{{ $pendingRename['to'] }}</strong>. If that volume does not exist, Docker creates it empty.</li>
<li>Your data stays in <strong>{{ $pendingRename['from'] }}</strong>. Coolify does not copy it, and does not delete it when you delete this resource.</li>
<li>To keep your data, stop the resource and copy the data into the new volume before you start it again.</li>
</ul>
</x-callout>
<div class="mt-4 flex flex-wrap justify-end gap-2 border-t border-neutral-200 pt-4 dark:border-white/[0.08]">
<x-forms.button
@click="modalOpen = false; $wire.cancelRename({{ $pendingRename['storageId'] }})">
Cancel
</x-forms.button>
<x-forms.button wire:click="submit({{ $pendingRename['storageId'] }}, true)"
@click="modalOpen = false" isError>
Change Volume Name
</x-forms.button>
</div>
</div>
</div>
</div>
</template>
</div>
@endif
</div>
@@ -408,3 +408,54 @@ it('hides PR deployment suffix for databases', function () {
->assertDontSee('Add suffix')
->assertDontSee('PR deployment suffix');
});
it('asks for confirmation before it changes the volume name', function () {
[$application, $volume] = createApplicationWithVolume();
$oldName = $volume->name;
$component = Livewire::test(All::class, ['resource' => $application])
->set("forms.{$volume->id}.name", 'renamed-data')
->call('submit', $volume->id)
->assertNotDispatched('success')
->assertSet('pendingRename', ['storageId' => $volume->id, 'from' => $oldName, 'to' => 'renamed-data'])
->assertSee('Change the volume name?')
->assertSee("Your data stays in <strong>{$oldName}</strong>", false);
expect($volume->refresh()->name)->toBe($oldName);
$component->call('submit', $volume->id, true)
->assertDispatched('success')
->assertSet('pendingRename', null);
expect($volume->refresh()->name)->toBe('renamed-data');
});
it('asks again when the volume name changes after the warning', function () {
[$application, $volume] = createApplicationWithVolume();
$oldName = $volume->name;
Livewire::test(All::class, ['resource' => $application])
->set("forms.{$volume->id}.name", 'first-name')
->call('submit', $volume->id)
->set("forms.{$volume->id}.name", 'second-name')
->call('submit', $volume->id, true)
->assertNotDispatched('success')
->assertSet('pendingRename', ['storageId' => $volume->id, 'from' => $oldName, 'to' => 'second-name']);
expect($volume->refresh()->name)->toBe($oldName);
});
it('restores the volume name when the rename is cancelled', function () {
[$application, $volume] = createApplicationWithVolume();
$oldName = $volume->name;
Livewire::test(All::class, ['resource' => $application])
->set("forms.{$volume->id}.name", 'renamed-data')
->call('submit', $volume->id)
->call('cancelRename', $volume->id)
->assertSet("forms.{$volume->id}.name", $oldName)
->assertSet('pendingRename', null)
->assertDontSee('Your data stays in');
expect($volume->refresh()->name)->toBe($oldName);
});