From f152ec00ada70757da38e0b789f049b14d813e33 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 11 Dec 2025 14:18:58 +0100 Subject: [PATCH 1/3] fix: Detect read-only Docker volumes with long-form syntax and enable refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fixed isReadOnlyVolume() to detect both short-form (:ro) and long-form (read_only: true) Docker Compose volume syntax - Fixed path matching to use mount_path only (fs_path is transformed during parsing from ./file to absolute path) - Added "Load from server" button for read-only volumes to allow users to refresh content - Changed loadStorageOnServer() authorization from 'update' to 'view' since loading is a read operation - Added helper text to Content field warning users that content may be outdated - Applied fixes to both LocalFileVolume and LocalPersistentVolume models 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- app/Livewire/Project/Service/FileStorage.php | 3 +- app/Models/LocalFileVolume.php | 18 +- app/Models/LocalPersistentVolume.php | 14 ++ .../project/service/file-storage.blade.php | 9 + tests/Unit/LocalFileVolumeReadOnlyTest.php | 219 ++++++++++++++++++ 5 files changed, 258 insertions(+), 5 deletions(-) create mode 100644 tests/Unit/LocalFileVolumeReadOnlyTest.php diff --git a/app/Livewire/Project/Service/FileStorage.php b/app/Livewire/Project/Service/FileStorage.php index 2ce4374a0..8a61e3c38 100644 --- a/app/Livewire/Project/Service/FileStorage.php +++ b/app/Livewire/Project/Service/FileStorage.php @@ -104,7 +104,8 @@ class FileStorage extends Component public function loadStorageOnServer() { try { - $this->authorize('update', $this->resource); + // Loading content is a read operation, so we use 'view' permission + $this->authorize('view', $this->resource); $this->fileStorage->loadStorageOnServer(); $this->syncData(); diff --git a/app/Models/LocalFileVolume.php b/app/Models/LocalFileVolume.php index 96170dbd6..dda5de194 100644 --- a/app/Models/LocalFileVolume.php +++ b/app/Models/LocalFileVolume.php @@ -239,22 +239,32 @@ class LocalFileVolume extends BaseModel $volumes = $compose['services'][$serviceName]['volumes']; // Check each volume to find a match + // Note: We match on mount_path (container path) only, since fs_path gets transformed + // from relative (./file) to absolute (/data/coolify/services/uuid/file) during parsing foreach ($volumes as $volume) { // Volume can be string like "host:container:ro" or "host:container" if (is_string($volume)) { $parts = explode(':', $volume); - // Check if this volume matches our fs_path and mount_path + // Check if this volume matches our mount_path if (count($parts) >= 2) { - $hostPath = $parts[0]; $containerPath = $parts[1]; $options = $parts[2] ?? null; - // Match based on fs_path and mount_path - if ($hostPath === $this->fs_path && $containerPath === $this->mount_path) { + // Match based on mount_path (container path) + if ($containerPath === $this->mount_path) { return $options === 'ro'; } } + } elseif (is_array($volume)) { + // Long-form syntax: { type: bind, source: ..., target: ..., read_only: true } + $containerPath = data_get($volume, 'target'); + $readOnly = data_get($volume, 'read_only', false); + + // Match based on mount_path (container path) + if ($containerPath === $this->mount_path) { + return $readOnly === true; + } } } diff --git a/app/Models/LocalPersistentVolume.php b/app/Models/LocalPersistentVolume.php index e7862478b..26e9b3e85 100644 --- a/app/Models/LocalPersistentVolume.php +++ b/app/Models/LocalPersistentVolume.php @@ -85,6 +85,7 @@ class LocalPersistentVolume extends Model $volumes = $compose['services'][$serviceName]['volumes']; // Check each volume to find a match + // Note: We match on mount_path (container path) only, since host paths get transformed foreach ($volumes as $volume) { // Volume can be string like "host:container:ro" or "host:container" if (is_string($volume)) { @@ -104,6 +105,19 @@ class LocalPersistentVolume extends Model return $options === 'ro'; } } + } elseif (is_array($volume)) { + // Long-form syntax: { type: bind/volume, source: ..., target: ..., read_only: true } + $containerPath = data_get($volume, 'target'); + $readOnly = data_get($volume, 'read_only', false); + + // Match based on mount_path + // Remove leading slash from mount_path if present for comparison + $mountPath = str($this->mount_path)->ltrim('/')->toString(); + $containerPathClean = str($containerPath)->ltrim('/')->toString(); + + if ($mountPath === $containerPathClean || $this->mount_path === $containerPath) { + return $readOnly === true; + } } } diff --git a/resources/views/livewire/project/service/file-storage.blade.php b/resources/views/livewire/project/service/file-storage.blade.php index 4ab966ec3..1dd58fe17 100644 --- a/resources/views/livewire/project/service/file-storage.blade.php +++ b/resources/views/livewire/project/service/file-storage.blade.php @@ -65,6 +65,7 @@ @endif @if (!$fileStorage->is_based_on_git && !$fileStorage->is_binary) @@ -79,12 +80,19 @@ @endif @endcan @endif @else {{-- Read-only view --}} @if (!$fileStorage->is_directory) + @can('view', $resource) +
+ Load from + server +
+ @endcan @if (data_get($resource, 'settings.is_preserve_repository_enabled'))
@endif @endif diff --git a/tests/Unit/LocalFileVolumeReadOnlyTest.php b/tests/Unit/LocalFileVolumeReadOnlyTest.php new file mode 100644 index 000000000..9c2237e62 --- /dev/null +++ b/tests/Unit/LocalFileVolumeReadOnlyTest.php @@ -0,0 +1,219 @@ += 2) { + $containerPath = $parts[1]; + $options = $parts[2] ?? null; + + if ($containerPath === $mountPath) { + return $options === 'ro'; + } + } + } elseif (is_array($volume)) { + // Long-form syntax: { type: bind, source: ..., target: ..., read_only: true } + $containerPath = data_get($volume, 'target'); + $readOnly = data_get($volume, 'read_only', false); + + if ($containerPath === $mountPath) { + return $readOnly === true; + } + } + } + + return false; +} + +test('detects read-only with short-form syntax using :ro', function () { + $compose = <<<'YAML' +services: + garage: + image: example/image + volumes: + - ./config.toml:/etc/config.toml:ro +YAML; + + expect(isVolumeReadOnly($compose, 'garage', '/etc/config.toml'))->toBeTrue(); +}); + +test('detects writable with short-form syntax without :ro', function () { + $compose = <<<'YAML' +services: + garage: + image: example/image + volumes: + - ./config.toml:/etc/config.toml +YAML; + + expect(isVolumeReadOnly($compose, 'garage', '/etc/config.toml'))->toBeFalse(); +}); + +test('detects read-only with long-form syntax and read_only: true', function () { + $compose = <<<'YAML' +services: + garage: + image: example/image + volumes: + - type: bind + source: ./garage.toml + target: /etc/garage.toml + read_only: true +YAML; + + expect(isVolumeReadOnly($compose, 'garage', '/etc/garage.toml'))->toBeTrue(); +}); + +test('detects writable with long-form syntax and read_only: false', function () { + $compose = <<<'YAML' +services: + garage: + image: example/image + volumes: + - type: bind + source: ./garage.toml + target: /etc/garage.toml + read_only: false +YAML; + + expect(isVolumeReadOnly($compose, 'garage', '/etc/garage.toml'))->toBeFalse(); +}); + +test('detects writable with long-form syntax without read_only key', function () { + $compose = <<<'YAML' +services: + garage: + image: example/image + volumes: + - type: bind + source: ./garage.toml + target: /etc/garage.toml +YAML; + + expect(isVolumeReadOnly($compose, 'garage', '/etc/garage.toml'))->toBeFalse(); +}); + +test('handles mixed short-form and long-form volumes in same service', function () { + $compose = <<<'YAML' +services: + garage: + image: example/image + volumes: + - ./data:/var/data + - type: bind + source: ./config.toml + target: /etc/config.toml + read_only: true +YAML; + + expect(isVolumeReadOnly($compose, 'garage', '/var/data'))->toBeFalse(); + expect(isVolumeReadOnly($compose, 'garage', '/etc/config.toml'))->toBeTrue(); +}); + +test('handles same file mounted in multiple services with different read_only settings', function () { + $compose = <<<'YAML' +services: + garage: + image: example/garage + volumes: + - type: bind + source: ./garage.toml + target: /etc/garage.toml + garage-webui: + image: example/webui + volumes: + - type: bind + source: ./garage.toml + target: /etc/garage.toml + read_only: true +YAML; + + // Same file, different services, different read_only status + expect(isVolumeReadOnly($compose, 'garage', '/etc/garage.toml'))->toBeFalse(); + expect(isVolumeReadOnly($compose, 'garage-webui', '/etc/garage.toml'))->toBeTrue(); +}); + +test('handles volume mount type', function () { + $compose = <<<'YAML' +services: + app: + image: example/app + volumes: + - type: volume + source: mydata + target: /data + read_only: true +YAML; + + expect(isVolumeReadOnly($compose, 'app', '/data'))->toBeTrue(); +}); + +test('returns false when service has no volumes', function () { + $compose = <<<'YAML' +services: + garage: + image: example/image +YAML; + + expect(isVolumeReadOnly($compose, 'garage', '/etc/config.toml'))->toBeFalse(); +}); + +test('returns false when service does not exist', function () { + $compose = <<<'YAML' +services: + garage: + image: example/image + volumes: + - ./config.toml:/etc/config.toml:ro +YAML; + + expect(isVolumeReadOnly($compose, 'nonexistent', '/etc/config.toml'))->toBeFalse(); +}); + +test('returns false when mount path does not match', function () { + $compose = <<<'YAML' +services: + garage: + image: example/image + volumes: + - type: bind + source: ./other.toml + target: /etc/other.toml + read_only: true +YAML; + + expect(isVolumeReadOnly($compose, 'garage', '/etc/config.toml'))->toBeFalse(); +}); From 475cfd78cd19d1beecb33bcd80e16c76f544087e Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 11 Dec 2025 21:23:46 +0100 Subject: [PATCH 2/3] fix: Prevent N+1 query in LocalPersistentVolume.isDockerComposeResource() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use relationLoaded() check before accessing the application relationship to avoid triggering individual queries for each volume when rendering storage lists. Update Storage.php to eager load the relationship. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- app/Livewire/Project/Service/Storage.php | 2 +- app/Models/LocalPersistentVolume.php | 53 ++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/app/Livewire/Project/Service/Storage.php b/app/Livewire/Project/Service/Storage.php index 644b100b8..12d8bcbc3 100644 --- a/app/Livewire/Project/Service/Storage.php +++ b/app/Livewire/Project/Service/Storage.php @@ -67,7 +67,7 @@ class Storage extends Component public function refreshStorages() { $this->fileStorage = $this->resource->fileStorages()->get(); - $this->resource->refresh(); + $this->resource->load('persistentStorages.resource'); } public function getFilesProperty() diff --git a/app/Models/LocalPersistentVolume.php b/app/Models/LocalPersistentVolume.php index 26e9b3e85..7126253ea 100644 --- a/app/Models/LocalPersistentVolume.php +++ b/app/Models/LocalPersistentVolume.php @@ -10,6 +10,11 @@ class LocalPersistentVolume extends Model { protected $guarded = []; + public function resource() + { + return $this->morphTo('resource'); + } + public function application() { return $this->morphTo('resource'); @@ -50,6 +55,54 @@ class LocalPersistentVolume extends Model ); } + // Check if this volume belongs to a service resource + public function isServiceResource(): bool + { + return in_array($this->resource_type, [ + 'App\Models\ServiceApplication', + 'App\Models\ServiceDatabase', + ]); + } + + // Check if this volume belongs to a dockercompose application + public function isDockerComposeResource(): bool + { + if ($this->resource_type !== 'App\Models\Application') { + return false; + } + + // Only access relationship if already eager loaded to avoid N+1 + if (! $this->relationLoaded('resource')) { + return false; + } + + $application = $this->resource; + if (! $application) { + return false; + } + + return data_get($application, 'build_pack') === 'dockercompose'; + } + + // Determine if this volume should be read-only in the UI + // Service volumes and dockercompose application volumes are read-only + // (users should edit compose file directly) + public function shouldBeReadOnlyInUI(): bool + { + // All service volumes should be read-only in UI + if ($this->isServiceResource()) { + return true; + } + + // All dockercompose application volumes should be read-only in UI + if ($this->isDockerComposeResource()) { + return true; + } + + // Check for explicit :ro flag in compose (existing logic) + return $this->isReadOnlyVolume(); + } + // Check if this volume is read-only by parsing the docker-compose content public function isReadOnlyVolume(): bool { From 9bc33d65abd022884ddc6d0e3c463ad4032bb144 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 11 Dec 2025 21:25:33 +0100 Subject: [PATCH 3/3] fix: Improve read-only volume detection and UI messaging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add isServiceResource() and shouldBeReadOnlyInUI() to LocalFileVolume - Update path matching to handle leading slashes in volume comparisons - Update FileStorage and Show components to use shouldBeReadOnlyInUI() - Show consolidated warning message for service/compose resources in all.blade.php - Remove redundant per-volume warnings for service resources - Clean up configuration.blade.php formatting 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- app/Livewire/Project/Service/FileStorage.php | 2 +- app/Livewire/Project/Shared/Storages/Show.php | 2 +- app/Models/LocalFileVolume.php | 33 ++++++++++++++++--- .../project/service/configuration.blade.php | 13 +++----- .../project/service/storage.blade.php | 11 ++----- .../project/shared/storages/all.blade.php | 6 ++++ .../project/shared/storages/show.blade.php | 8 +++-- 7 files changed, 49 insertions(+), 26 deletions(-) diff --git a/app/Livewire/Project/Service/FileStorage.php b/app/Livewire/Project/Service/FileStorage.php index 8a61e3c38..54ef82872 100644 --- a/app/Livewire/Project/Service/FileStorage.php +++ b/app/Livewire/Project/Service/FileStorage.php @@ -62,7 +62,7 @@ class FileStorage extends Component $this->fs_path = $this->fileStorage->fs_path; } - $this->isReadOnly = $this->fileStorage->isReadOnlyVolume(); + $this->isReadOnly = $this->fileStorage->shouldBeReadOnlyInUI(); $this->syncData(); } diff --git a/app/Livewire/Project/Shared/Storages/Show.php b/app/Livewire/Project/Shared/Storages/Show.php index 5970ec904..c8dc68d66 100644 --- a/app/Livewire/Project/Shared/Storages/Show.php +++ b/app/Livewire/Project/Shared/Storages/Show.php @@ -67,7 +67,7 @@ class Show extends Component public function mount() { $this->syncData(false); - $this->isReadOnly = $this->storage->isReadOnlyVolume(); + $this->isReadOnly = $this->storage->shouldBeReadOnlyInUI(); } public function submit() diff --git a/app/Models/LocalFileVolume.php b/app/Models/LocalFileVolume.php index dda5de194..9d7095cb5 100644 --- a/app/Models/LocalFileVolume.php +++ b/app/Models/LocalFileVolume.php @@ -209,6 +209,23 @@ class LocalFileVolume extends BaseModel return $query->get()->where('plain_mount_path', $path); } + // Check if this volume belongs to a service resource + public function isServiceResource(): bool + { + return in_array($this->resource_type, [ + 'App\Models\ServiceApplication', + 'App\Models\ServiceDatabase', + ]); + } + + // Determine if this volume should be read-only in the UI + // File/directory mounts can be edited even for services + public function shouldBeReadOnlyInUI(): bool + { + // Check for explicit :ro flag in compose (existing logic) + return $this->isReadOnlyVolume(); + } + // Check if this volume is read-only by parsing the docker-compose content public function isReadOnlyVolume(): bool { @@ -251,8 +268,12 @@ class LocalFileVolume extends BaseModel $containerPath = $parts[1]; $options = $parts[2] ?? null; - // Match based on mount_path (container path) - if ($containerPath === $this->mount_path) { + // Match based on mount_path + // Remove leading slash from mount_path if present for comparison + $mountPath = str($this->mount_path)->ltrim('/')->toString(); + $containerPathClean = str($containerPath)->ltrim('/')->toString(); + + if ($mountPath === $containerPathClean || $this->mount_path === $containerPath) { return $options === 'ro'; } } @@ -261,8 +282,12 @@ class LocalFileVolume extends BaseModel $containerPath = data_get($volume, 'target'); $readOnly = data_get($volume, 'read_only', false); - // Match based on mount_path (container path) - if ($containerPath === $this->mount_path) { + // Match based on mount_path + // Remove leading slash from mount_path if present for comparison + $mountPath = str($this->mount_path)->ltrim('/')->toString(); + $containerPathClean = str($containerPath)->ltrim('/')->toString(); + + if ($mountPath === $containerPathClean || $this->mount_path === $containerPath) { return $readOnly === true; } } diff --git a/resources/views/livewire/project/service/configuration.blade.php b/resources/views/livewire/project/service/configuration.blade.php index 7379ca706..f1ad3e06a 100644 --- a/resources/views/livewire/project/service/configuration.blade.php +++ b/resources/views/livewire/project/service/configuration.blade.php @@ -37,7 +37,7 @@

Services

- @if($applications->isEmpty() && $databases->isEmpty()) + @if ($applications->isEmpty() && $databases->isEmpty())
No services defined in this Docker Compose file.
@@ -76,7 +76,8 @@ @if ($application->fqdn) {{ Str::limit($application->fqdn, 60) }} @can('update', $service) - + @endif -
{{ formatContainerStatus($application->status) }}
+
{{ formatContainerStatus($application->status) }}
@if ($database->isBackupSolutionAvailable() || $database->is_migrated) @@ -185,10 +186,6 @@

Storages

Persistent storage to preserve data between deployments.
-
@foreach ($applications as $application) diff --git a/resources/views/livewire/project/service/storage.blade.php b/resources/views/livewire/project/service/storage.blade.php index d55bd801a..9e32cd22d 100644 --- a/resources/views/livewire/project/service/storage.blade.php +++ b/resources/views/livewire/project/service/storage.blade.php @@ -275,15 +275,9 @@
Persistent storage to preserve data between deployments.
- @if ($resource?->build_pack === 'dockercompose') -
Please modify storage layout in your Docker Compose - file or reload the compose file to reread the storage layout.
- @else - @if ($resource->persistentStorages()->get()->count() === 0 && $fileStorage->count() == 0) -
No storage found.
- @endif + @if ($resource->persistentStorages()->get()->count() === 0 && $fileStorage->count() == 0) +
No storage found.
@endif - @php $hasVolumes = $this->volumeCount > 0; $hasFiles = $this->fileCount > 0; @@ -370,7 +364,6 @@

{{ Str::headline($resource->name) }}

- @if ($resource->persistentStorages()->get()->count() === 0 && $fileStorage->count() == 0)
No storage found.
@endif diff --git a/resources/views/livewire/project/shared/storages/all.blade.php b/resources/views/livewire/project/shared/storages/all.blade.php index d62362562..ea8e55e41 100644 --- a/resources/views/livewire/project/shared/storages/all.blade.php +++ b/resources/views/livewire/project/shared/storages/all.blade.php @@ -1,5 +1,11 @@
+ @if ($resource->type() === 'service' || data_get($resource, 'build_pack') === 'dockercompose') +
+ Volume mounts are read-only. If you would like to add or modify a volume, you must edit your Docker + Compose file and reload the compose file. +
+ @endif @foreach ($resource->persistentStorages as $storage) @if ($resource->type() === 'service')
@if ($isReadOnly) -
- This volume is mounted as read-only and cannot be modified from the UI. -
+ @if (!$storage->isServiceResource() && !$storage->isDockerComposeResource()) +
+ This volume is mounted as read-only and cannot be modified from the UI. +
+ @endif @if ($isFirst)
@if (