diff --git a/app/Livewire/Project/Service/FileStorage.php b/app/Livewire/Project/Service/FileStorage.php index 2ce4374a0..54ef82872 100644 --- a/app/Livewire/Project/Service/FileStorage.php +++ b/app/Livewire/Project/Service/FileStorage.php @@ -62,7 +62,7 @@ public function mount() $this->fs_path = $this->fileStorage->fs_path; } - $this->isReadOnly = $this->fileStorage->isReadOnlyVolume(); + $this->isReadOnly = $this->fileStorage->shouldBeReadOnlyInUI(); $this->syncData(); } @@ -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/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 @@ public function refreshStoragesFromEvent() public function refreshStorages() { $this->fileStorage = $this->resource->fileStorages()->get(); - $this->resource->refresh(); + $this->resource->load('persistentStorages.resource'); } public function getFilesProperty() 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 @@ private function syncData(bool $toModel = false): void 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 96170dbd6..9d7095cb5 100644 --- a/app/Models/LocalFileVolume.php +++ b/app/Models/LocalFileVolume.php @@ -209,6 +209,23 @@ public function scopeWherePlainMountPath($query, $path) 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 { @@ -239,22 +256,40 @@ 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 + // 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'; } } + } 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 + // 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/app/Models/LocalPersistentVolume.php b/app/Models/LocalPersistentVolume.php index e7862478b..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 @@ protected function hostPath(): Attribute ); } + // 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 { @@ -85,6 +138,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 +158,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/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) }}
description) {{ Str::limit($database->description, 60) }} @endif -
{{ formatContainerStatus($database->status) }}
+
{{ formatContainerStatus($database->status) }}
@if ($database->isBackupSolutionAvailable() || $database->is_migrated) @@ -185,10 +186,6 @@ class="w-4 h-4 dark:text-warning text-coollabs"

Storages

Persistent storage to preserve data between deployments.
-
If you would like to add a volume, you must add it to - your compose file (General - tab).
@foreach ($applications as $application) 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/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 @@ class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5
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 @@ class="px-4 py-2 -mb-px font-medium transition-colors {{ $hasDirectories ? 'dark

{{ 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 ( 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(); +});