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] 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 @@ public function convertToDirectory() 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 @@ public function isReadOnlyVolume(): bool $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 @@ public function isReadOnlyVolume(): bool $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 @@ public function isReadOnlyVolume(): bool 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(); +});