mirror of
https://github.com/coollabsio/coolify.git
synced 2025-12-28 05:34:50 +00:00
Fix read-only volume detection and add refresh capability (#7588)
This commit is contained in:
commit
98b99cbb09
@ -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();
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
{
|
||||
@ -239,22 +256,40 @@ 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
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
{
|
||||
@ -85,6 +138,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 +158,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -37,7 +37,7 @@
|
||||
<livewire:project.service.stack-form :service="$service" />
|
||||
<h3>Services</h3>
|
||||
<div class="grid grid-cols-1 gap-2 pt-4 xl:grid-cols-1">
|
||||
@if($applications->isEmpty() && $databases->isEmpty())
|
||||
@if ($applications->isEmpty() && $databases->isEmpty())
|
||||
<div class="p-4 text-sm text-neutral-500">
|
||||
No services defined in this Docker Compose file.
|
||||
</div>
|
||||
@ -76,7 +76,8 @@
|
||||
@if ($application->fqdn)
|
||||
<span class="flex gap-1 text-xs">{{ Str::limit($application->fqdn, 60) }}
|
||||
@can('update', $service)
|
||||
<x-modal-input title="Edit Domains" :closeOutside="false" minWidth="32rem" maxWidth="40rem">
|
||||
<x-modal-input title="Edit Domains" :closeOutside="false" minWidth="32rem"
|
||||
maxWidth="40rem">
|
||||
<x-slot:content>
|
||||
<span class="cursor-pointer">
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
@ -100,7 +101,7 @@
|
||||
@endcan
|
||||
</span>
|
||||
@endif
|
||||
<div class="pt-2 text-xs">{{ formatContainerStatus($application->status) }}</div>
|
||||
<div class="pt-2 text-xs">{{ formatContainerStatus($application->status) }}</div>
|
||||
</div>
|
||||
<div class="flex items-center px-4">
|
||||
<a class="mx-4 text-xs font-bold hover:underline"
|
||||
@ -149,7 +150,7 @@
|
||||
@if ($database->description)
|
||||
<span class="text-xs">{{ Str::limit($database->description, 60) }}</span>
|
||||
@endif
|
||||
<div class="text-xs">{{ formatContainerStatus($database->status) }}</div>
|
||||
<div class="text-xs">{{ formatContainerStatus($database->status) }}</div>
|
||||
</div>
|
||||
<div class="flex items-center px-4">
|
||||
@if ($database->isBackupSolutionAvailable() || $database->is_migrated)
|
||||
@ -185,10 +186,6 @@
|
||||
<h2>Storages</h2>
|
||||
</div>
|
||||
<div class="pb-4">Persistent storage to preserve data between deployments.</div>
|
||||
<div class="pb-4 dark:text-warning text-coollabs">If you would like to add a volume, you must add it to
|
||||
your compose file (<a class="underline"
|
||||
href="{{ route('project.service.configuration', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'service_uuid' => $service->uuid]) }}">General
|
||||
tab</a>).</div>
|
||||
@foreach ($applications as $application)
|
||||
<livewire:project.service.storage wire:key="application-{{ $application->id }}"
|
||||
:resource="$application" />
|
||||
|
||||
@ -65,6 +65,7 @@
|
||||
@endif
|
||||
<x-forms.textarea
|
||||
label="{{ $fileStorage->is_based_on_git ? 'Content (refreshed after a successful deployment)' : 'Content' }}"
|
||||
helper="The content shown may be outdated. Click 'Load from server' to fetch the latest version."
|
||||
rows="20" id="content"
|
||||
readonly="{{ $fileStorage->is_based_on_git || $fileStorage->is_binary }}"></x-forms.textarea>
|
||||
@if (!$fileStorage->is_based_on_git && !$fileStorage->is_binary)
|
||||
@ -79,12 +80,19 @@
|
||||
@endif
|
||||
<x-forms.textarea
|
||||
label="{{ $fileStorage->is_based_on_git ? 'Content (refreshed after a successful deployment)' : 'Content' }}"
|
||||
helper="The content shown may be outdated. Click 'Load from server' to fetch the latest version."
|
||||
rows="20" id="content" disabled></x-forms.textarea>
|
||||
@endcan
|
||||
@endif
|
||||
@else
|
||||
{{-- Read-only view --}}
|
||||
@if (!$fileStorage->is_directory)
|
||||
@can('view', $resource)
|
||||
<div class="flex gap-2">
|
||||
<x-forms.button type="button" wire:click="loadStorageOnServer">Load from
|
||||
server</x-forms.button>
|
||||
</div>
|
||||
@endcan
|
||||
@if (data_get($resource, 'settings.is_preserve_repository_enabled'))
|
||||
<div class="w-96">
|
||||
<x-forms.checkbox disabled label="Is this based on the Git repository?"
|
||||
@ -93,6 +101,7 @@
|
||||
@endif
|
||||
<x-forms.textarea
|
||||
label="{{ $fileStorage->is_based_on_git ? 'Content (refreshed after a successful deployment)' : 'Content' }}"
|
||||
helper="The content shown may be outdated. Click 'Load from server' to fetch the latest version."
|
||||
rows="20" id="content" disabled></x-forms.textarea>
|
||||
@endif
|
||||
@endif
|
||||
|
||||
@ -275,15 +275,9 @@
|
||||
</div>
|
||||
<div>Persistent storage to preserve data between deployments.</div>
|
||||
</div>
|
||||
@if ($resource?->build_pack === 'dockercompose')
|
||||
<div class="dark:text-warning text-coollabs">Please modify storage layout in your Docker Compose
|
||||
file or reload the compose file to reread the storage layout.</div>
|
||||
@else
|
||||
@if ($resource->persistentStorages()->get()->count() === 0 && $fileStorage->count() == 0)
|
||||
<div>No storage found.</div>
|
||||
@endif
|
||||
@if ($resource->persistentStorages()->get()->count() === 0 && $fileStorage->count() == 0)
|
||||
<div>No storage found.</div>
|
||||
@endif
|
||||
|
||||
@php
|
||||
$hasVolumes = $this->volumeCount > 0;
|
||||
$hasFiles = $this->fileCount > 0;
|
||||
@ -370,7 +364,6 @@
|
||||
<h2>{{ Str::headline($resource->name) }}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($resource->persistentStorages()->get()->count() === 0 && $fileStorage->count() == 0)
|
||||
<div>No storage found.</div>
|
||||
@endif
|
||||
|
||||
@ -1,5 +1,11 @@
|
||||
<div>
|
||||
<div class="flex flex-col gap-4">
|
||||
@if ($resource->type() === 'service' || data_get($resource, 'build_pack') === 'dockercompose')
|
||||
<div class="w-full p-2 text-sm rounded bg-warning/10 text-warning">
|
||||
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.
|
||||
</div>
|
||||
@endif
|
||||
@foreach ($resource->persistentStorages as $storage)
|
||||
@if ($resource->type() === 'service')
|
||||
<livewire:project.shared.storages.show wire:key="storage-{{ $storage->id }}" :storage="$storage"
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
<div>
|
||||
<form wire:submit='submit' class="flex flex-col items-center gap-4 p-4 bg-white border lg:items-start dark:bg-base dark:border-coolgray-300 border-neutral-200">
|
||||
@if ($isReadOnly)
|
||||
<div class="w-full p-2 text-sm rounded bg-warning/10 text-warning">
|
||||
This volume is mounted as read-only and cannot be modified from the UI.
|
||||
</div>
|
||||
@if (!$storage->isServiceResource() && !$storage->isDockerComposeResource())
|
||||
<div class="w-full p-2 text-sm rounded bg-warning/10 text-warning">
|
||||
This volume is mounted as read-only and cannot be modified from the UI.
|
||||
</div>
|
||||
@endif
|
||||
@if ($isFirst)
|
||||
<div class="flex gap-2 items-end w-full md:flex-row flex-col">
|
||||
@if (
|
||||
|
||||
219
tests/Unit/LocalFileVolumeReadOnlyTest.php
Normal file
219
tests/Unit/LocalFileVolumeReadOnlyTest.php
Normal file
@ -0,0 +1,219 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Unit tests to verify LocalFileVolume::isReadOnlyVolume() correctly detects
|
||||
* read-only volumes in both short-form and long-form Docker Compose syntax.
|
||||
*
|
||||
* Related Issue: Volumes with read_only: true in long-form syntax were not
|
||||
* being detected as read-only, allowing UI edits on files that should be protected.
|
||||
*
|
||||
* Related Files:
|
||||
* - app/Models/LocalFileVolume.php
|
||||
* - app/Livewire/Project/Service/FileStorage.php
|
||||
*/
|
||||
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
/**
|
||||
* Helper function to parse volumes and detect read-only status.
|
||||
* This mirrors the logic in LocalFileVolume::isReadOnlyVolume()
|
||||
*
|
||||
* 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
|
||||
*/
|
||||
function isVolumeReadOnly(string $dockerComposeRaw, string $serviceName, string $mountPath): bool
|
||||
{
|
||||
$compose = Yaml::parse($dockerComposeRaw);
|
||||
|
||||
if (! isset($compose['services'][$serviceName]['volumes'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$volumes = $compose['services'][$serviceName]['volumes'];
|
||||
|
||||
foreach ($volumes as $volume) {
|
||||
// Volume can be string like "host:container:ro" or "host:container"
|
||||
if (is_string($volume)) {
|
||||
$parts = explode(':', $volume);
|
||||
|
||||
if (count($parts) >= 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();
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user