Merge remote-tracking branch 'origin/main' into maintenance/control-plane-updates

This commit is contained in:
Andras Bacsai
2026-09-24 19:05:07 +02:00
9 changed files with 287 additions and 64 deletions
+2 -8
View File
@@ -24,8 +24,6 @@ class Storage extends Component
public string $mount_path = '';
public ?string $host_path = null;
public string $file_storage_path = '';
public ?string $file_storage_content = null;
@@ -209,17 +207,14 @@ class Storage extends Component
$this->validate([
'name' => ValidationPatterns::volumeNameRules(),
'mount_path' => 'required|string',
'host_path' => ['nullable', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
], array_merge(ValidationPatterns::volumeNameMessages(), [
'host_path.regex' => 'Host path must start with / and only contain safe path characters.',
]));
], ValidationPatterns::volumeNameMessages());
$name = $this->resource->uuid.'-'.$this->name;
LocalPersistentVolume::create([
'name' => $name,
'mount_path' => $this->mount_path,
'host_path' => $this->host_path,
'host_path' => null,
'resource_id' => $this->resource->id,
'resource_type' => $this->resource->getMorphClass(),
]);
@@ -347,7 +342,6 @@ class Storage extends Component
{
$this->name = $this->generateDefaultVolumeName();
$this->mount_path = '';
$this->host_path = null;
$this->file_storage_path = '';
$this->file_storage_content = null;
$this->file_storage_directory_destination = '';
+2 -25
View File
@@ -22,7 +22,7 @@ class All extends Component
/**
* Editable form state keyed by storage id.
*
* @var array<int|string, array{name: string, mountPath: string, hostPath: ?string, isPreviewSuffixEnabled: bool, isReadOnly: bool, canDeleteStale: bool}>
* @var array<int|string, array{name: string, mountPath: string, isPreviewSuffixEnabled: bool, isReadOnly: bool, canDeleteStale: bool}>
*/
public array $forms = [];
@@ -65,6 +65,7 @@ class All extends Component
public function refreshList(): void
{
$this->authorize('view', $this->resource);
$this->resource->refresh();
$this->resource->unsetRelation('persistentStorages');
$this->resource->load(['persistentStorages' => fn ($query) => $query->orderBy('id')]);
@@ -96,7 +97,6 @@ class All extends Component
$form = $this->forms[$storageId];
$storage->name = $form['name'];
$storage->mount_path = $form['mountPath'];
$storage->host_path = $form['hostPath'] ?: null;
$storage->is_preview_suffix_enabled = (bool) $form['isPreviewSuffixEnabled'];
$storage->save();
@@ -108,25 +108,6 @@ class All extends Component
$this->submit($storageId);
}
public function clearHostPath(int $storageId): void
{
$this->authorize('update', $this->resource);
$storage = $this->findStorageOrFail($storageId);
if ($storage->shouldBeReadOnlyInUI()) {
$this->dispatch('error', 'This volume is read-only.');
return;
}
$storage->host_path = null;
$storage->save();
$this->forms[$storageId]['hostPath'] = null;
$this->dispatch('configurationChanged');
$this->dispatch('success', 'Source path removed. Use a directory mount for host directory bindings.');
}
/**
* Livewire listbox onChange cannot pass args; PR suffix fields call this via updatedForms.
*/
@@ -212,7 +193,6 @@ class All extends Component
$forms[$storage->id] = [
'name' => $storage->name,
'mountPath' => $storage->mount_path,
'hostPath' => $storage->host_path,
'isPreviewSuffixEnabled' => (bool) ($storage->is_preview_suffix_enabled ?? true),
'isReadOnly' => $storage->shouldBeReadOnlyInUI() || ! $this->canUpdate,
'canDeleteStale' => $this->canUpdate
@@ -301,18 +281,15 @@ class All extends Component
$this->validate([
"forms.{$storageId}.name" => ValidationPatterns::volumeNameRules(),
"forms.{$storageId}.mountPath" => ['required', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
"forms.{$storageId}.hostPath" => ['nullable', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
"forms.{$storageId}.isPreviewSuffixEnabled" => 'required|boolean',
], array_merge(
ValidationPatterns::volumeNameMessages(),
[
"forms.{$storageId}.mountPath.regex" => 'Mount path must start with / and only contain safe path characters.',
"forms.{$storageId}.hostPath.regex" => 'Host path must start with / and only contain safe path characters.',
]
), [
"forms.{$storageId}.name" => 'name',
"forms.{$storageId}.mountPath" => 'mount',
"forms.{$storageId}.hostPath" => 'host',
]);
}
+30 -3
View File
@@ -189,11 +189,30 @@ class Proxy extends Component
{
try {
$this->proxySettings = GetProxyConfiguration::run($this->server);
$this->clearAppliedTraefikBranchWarning();
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function getTraefikVersionForWarningProperty(): ?string
{
if ($this->server->detected_traefik_version) {
return $this->server->detected_traefik_version;
}
if ($this->server->proxy->get('status') !== 'running' || $this->server->hasPendingProxyConfiguration()) {
return null;
}
$configuration = $this->server->proxy->get('last_saved_proxy_configuration');
if (! is_string($configuration) || ! preg_match('/^\s*image:\s*[\'\"]?traefik:(v?\d+\.\d+(?:\.\d+)?|latest)[\'\"]?\s*$/mi', $configuration, $matches)) {
return null;
}
return $matches[1];
}
/**
* Get the latest Traefik version for this server's current branch.
*
@@ -211,7 +230,7 @@ class Proxy extends Component
}
// Get this server's current version
$currentVersion = $this->server->detected_traefik_version;
$currentVersion = $this->traefikVersionForWarning;
// If we have a current version, try to find matching branch
if ($currentVersion && $currentVersion !== 'latest') {
@@ -244,7 +263,7 @@ class Proxy extends Component
return false;
}
$currentVersion = $this->server->detected_traefik_version;
$currentVersion = $this->traefikVersionForWarning;
if (! $currentVersion || $currentVersion === 'latest') {
return false;
}
@@ -273,7 +292,7 @@ class Proxy extends Component
}
// Get this server's current version
$currentVersion = $this->server->detected_traefik_version;
$currentVersion = $this->traefikVersionForWarning;
if (! $currentVersion || $currentVersion === 'latest') {
return null;
}
@@ -335,6 +354,14 @@ class Proxy extends Component
}
}
public function getLatestNewerTraefikVersionProperty(): ?string
{
$branch = $this->newerTraefikBranchAvailable;
$version = $branch ? ($this->getTraefikVersions()[$branch] ?? null) : null;
return $version ? 'v'.ltrim($version, 'v') : null;
}
private function getConfiguredTraefikBranch(): ?string
{
if ($this->server->proxy->get('status') !== 'running' || $this->server->hasPendingProxyConfiguration()) {
@@ -237,7 +237,7 @@ class ApplicationConfigurationSnapshot
return $this->item(
key: 'volume_'.$volume->id,
label: 'Volume mount',
label: filled($volume->host_path) ? 'Directory mount' : 'Volume mount',
value: ['source' => $source, 'destination' => $volume->mount_path],
impact: 'redeploy',
displayValue: "{$source} → {$volume->mount_path}",
+14 -1
View File
@@ -2996,7 +2996,7 @@ input[type="search"]::-webkit-search-results-decoration {
background: color-mix(in srgb, var(--coollabs-base) 98%, white);
}
/* Persistent storage volumes: Name | Destination | [PR suffix] | Backup | [Actions] */
/* Persistent storage volumes: Name | [Source] | Destination | [PR suffix] | Backup | [Actions] */
.volumes-table-grid-readonly {
grid-template-columns: minmax(10rem, 1.4fr) minmax(8rem, 1fr) 5rem;
}
@@ -3009,6 +3009,18 @@ input[type="search"]::-webkit-search-results-decoration {
grid-template-columns: minmax(9rem, 1.1fr) minmax(6rem, 1fr) 8.5rem 5rem 15rem;
}
.volumes-table-grid-readonly.has-source {
grid-template-columns: minmax(10rem, 1.2fr) minmax(10rem, 1.2fr) minmax(8rem, 1fr) 5rem;
}
.volumes-table-grid.has-source {
grid-template-columns: minmax(9rem, 1.1fr) minmax(14rem, 1.5fr) minmax(6rem, 1fr) 5rem 15rem;
}
.volumes-table-grid-with-pr.has-source {
grid-template-columns: minmax(9rem, 1fr) minmax(14rem, 1.4fr) minmax(6rem, 1fr) 8.5rem 5rem 15rem;
}
.volumes-mobile-label {
display: none;
}
@@ -3084,6 +3096,7 @@ input[type="search"]::-webkit-search-results-decoration {
}
.volumes-cell-name,
.volumes-cell-source,
.volumes-col-backup,
.volumes-cell-dest,
.volumes-cell-actions {
@@ -1,4 +1,5 @@
@php
$hasSourcePaths = $resource->persistentStorages->contains(fn ($storage) => filled($storage->host_path));
$gridClass = match (true) {
$supportsPreviewSuffix => 'volumes-table-grid-with-pr',
$showActionsColumn => 'volumes-table-grid',
@@ -16,8 +17,11 @@
@if ($resource->persistentStorages->isNotEmpty())
<div class="data-table w-full">
<div class="data-table-header {{ $gridClass }}">
<span>Volume Name</span>
<div class="data-table-header {{ $gridClass }} {{ $hasSourcePaths ? 'has-source' : '' }}">
<span>Storage Name</span>
@if ($hasSourcePaths)
<span>Source Path</span>
@endif
<span>Destination Path</span>
@if ($supportsPreviewSuffix)
<div class="volumes-col-pr flex items-center gap-1.5">
@@ -47,16 +51,30 @@
@if ($inputsReadonly)
<div class="env-table-item" wire:key="storage-row-{{ $id }}">
<div class="data-table-row {{ $gridClass }} text-[13px] text-neutral-700 dark:text-fg-dim">
<div class="data-table-row {{ $gridClass }} {{ $hasSourcePaths ? 'has-source' : '' }} text-[13px] text-neutral-700 dark:text-fg-dim">
<div class="volumes-cell-name min-w-0">
<span class="volumes-mobile-label volumes-field-label">Volume Name</span>
<span class="volumes-mobile-label volumes-field-label">Storage Name</span>
<div class="flex min-w-0 items-center gap-2">
<span
class="min-w-0 truncate text-[13px] font-medium text-neutral-950 dark:text-fg"
title="{{ $form['name'] }}">{{ $form['name'] }}</span>
</div>
@if (blank($storage->host_path))
<span class="block text-xs text-neutral-500 dark:text-fg-dim">Volume mount</span>
@endif
</div>
@if ($hasSourcePaths)
<div class="volumes-cell-source min-w-0">
<span class="volumes-mobile-label volumes-field-label">Source Path</span>
@if (filled($storage->host_path))
<x-forms.input aria-label="Source Path" :value="$storage->host_path" readonly />
@else
<span class="data-table-cell-dash">-</span>
@endif
</div>
@endif
<div class="volumes-cell-dest min-w-0">
<span class="volumes-mobile-label volumes-field-label">Destination Path</span>
<span
@@ -143,16 +161,30 @@
</div>
@else
<form wire:submit="submit({{ $id }})" class="env-table-item" wire:key="storage-row-{{ $id }}">
<div class="data-table-row {{ $gridClass }}">
<div class="data-table-row {{ $gridClass }} {{ $hasSourcePaths ? 'has-source' : '' }}">
<div class="volumes-cell-name min-w-0">
<span class="volumes-mobile-label volumes-field-label">Volume Name</span>
<span class="volumes-mobile-label volumes-field-label">Storage Name</span>
<div class="flex min-w-0 items-center gap-2">
<div class="min-w-0 flex-1">
<x-forms.input id="forms.{{ $id }}.name" required />
</div>
</div>
@if (blank($storage->host_path))
<span class="block text-xs text-neutral-500 dark:text-fg-dim">Volume mount</span>
@endif
</div>
@if ($hasSourcePaths)
<div class="volumes-cell-source min-w-0">
<span class="volumes-mobile-label volumes-field-label">Source Path</span>
@if (filled($storage->host_path))
<x-forms.input aria-label="Source Path" :value="$storage->host_path" readonly />
@else
<span class="data-table-cell-dash">-</span>
@endif
</div>
@endif
<div class="volumes-cell-dest min-w-0">
<span class="volumes-mobile-label volumes-field-label">Destination Path</span>
<x-forms.input id="forms.{{ $id }}.mountPath" required
@@ -110,21 +110,26 @@
</x-slot:actions>
@if ($server->proxyType() === ProxyTypes::TRAEFIK->value)
@if ($server->detected_traefik_version === 'latest')
@if ($this->traefikVersionForWarning === 'latest')
<x-callout type="warning" title="Unpinned Traefik version">
The proxy uses the <span class="font-mono">latest</span> tag. Pin
<span class="font-mono">traefik:{{ $this->latestTraefikVersion }}</span>
for predictable updates.
</x-callout>
@elseif($this->isTraefikOutdated)
@endif
@if ($this->isTraefikOutdated)
<x-callout type="warning" title="Traefik patch update available">
Version {{ $this->latestTraefikVersion }} is available. Test the update before
applying it to production servers.
{{ $server->detected_traefik_version ? 'Running version' : 'Configured image' }}
v{{ ltrim($this->traefikVersionForWarning, 'v') }}. The latest patch
for this branch is {{ $this->latestTraefikVersion }}. Test the update before applying it
to production servers.
</x-callout>
@elseif($this->newerTraefikBranchAvailable)
@endif
@if ($this->newerTraefikBranchAvailable)
<x-callout type="info" title="New Traefik minor version available">
{{ $this->newerTraefikBranchAvailable }} is available. Review the Traefik
changelog for breaking changes before upgrading.
{{ $this->newerTraefikBranchAvailable }} is available (latest patch:
{{ $this->latestNewerTraefikVersion }}). Review the Traefik changelog for breaking
changes before upgrading.
</x-callout>
@endif
@endif
@@ -159,8 +159,7 @@ it('renders volumes as a data table with shared column headers', function () {
->toContain('data-table-header')
->toContain('volumes-table-grid')
->toContain('volumes-table-grid-readonly')
->toContain('Volume Name')
->not->toContain('Source Path')
->toContain('Storage Name')
->toContain('Destination Path')
->toContain('volumes-col-backup')
->toContain('supportsPreviewSuffix')
@@ -251,12 +250,10 @@ it('creates named Docker volumes without a source path in swarm mode', function
expect($application->persistentStorages()->first()->host_path)->toBeNull();
});
it('removes the source path column from Docker volume views', function () {
it('keeps source paths out of the named volume creation form', function () {
$allView = file_get_contents(resource_path('views/livewire/project/shared/storages/all.blade.php'));
expect($allView)
->not->toContain('Source Path')
->not->toContain('volumes-col-source')
->not->toContain('forms.{{ $id }}.hostPath')
->and(resource_path('views/livewire/project/shared/storages/show.blade.php'))
->not->toBeFile();
@@ -336,7 +333,7 @@ it('uses valid block wrappers around PR suffix helpers', function () {
->toBe(3);
});
it('keeps bind mount source paths out of the add volume form', function () {
it('shows legacy bind mount source paths without an unsafe removal action', function () {
$storageView = file_get_contents(resource_path('views/livewire/project/service/storage.blade.php'));
$volumesView = file_get_contents(resource_path('views/livewire/project/shared/storages/all.blade.php'));
@@ -344,9 +341,38 @@ it('keeps bind mount source paths out of the add volume form', function () {
->not->toContain('id="host_path"')
->not->toContain('Swarm Mode detected')
->and($volumesView)
->toMatch('/<x-modal-confirmation title="Remove Source Path\?"[^>]*canGate="update"[^>]*:canResource="\$resource"/')
->toContain('The next deployment will use a named Docker volume instead.')
->toContain('Data from the existing host directory will not be copied to the named volume.');
->not->toContain('submitAction="clearHostPath({{ $id }})"')
->not->toContain('The next deployment will use a named Docker volume instead.');
});
it('shows legacy source paths in read-only inputs beside the destination path', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/storages/all.blade.php'));
expect(substr_count($view, '<x-forms.input aria-label="Source Path" :value="$storage->host_path" readonly />'))
->toBe(2)
->and(substr_count($view, 'class="volumes-cell-source'))
->toBe(2)
->and($view)->not->toContain('volumes-bind-details');
});
it('aligns bind and named volumes when they share the source path column', function () {
[$application] = createApplicationWithVolume(volumeAttributes: ['host_path' => '/srv/storage']);
LocalPersistentVolume::create([
'name' => $application->uuid.'-cache',
'mount_path' => '/cache',
'resource_id' => $application->id,
'resource_type' => $application->getMorphClass(),
]);
$document = new DOMDocument;
$previousState = libxml_use_internal_errors(true);
$document->loadHTML(Livewire::test(All::class, ['resource' => $application])->html());
libxml_clear_errors();
libxml_use_internal_errors($previousState);
$xpath = new DOMXPath($document);
expect($xpath->query("//*[contains(concat(' ', normalize-space(@class), ' '), ' data-table-row ')]/div[contains(concat(' ', normalize-space(@class), ' '), ' volumes-cell-source ')]"))->toHaveCount(2)
->and($xpath->query("//*[contains(concat(' ', normalize-space(@class), ' '), ' volumes-cell-source ')]//span[contains(concat(' ', normalize-space(@class), ' '), ' data-table-cell-dash ')]"))->toHaveCount(1);
});
it('creates named volumes without a host path in swarm mode', function () {
@@ -379,17 +405,84 @@ it('uses a valid fallback default volume name when the resource name has no slug
->assertSet('name', 'volume-data');
});
it('removes existing bind mount source paths from the volume table', function () {
it('preserves existing bind mount source paths in the volume table', function () {
[$application, $volume] = createApplicationWithVolume(volumeAttributes: [
'host_path' => '/srv/storage',
]);
Livewire::test(All::class, ['resource' => $application])
->assertSet("forms.{$volume->id}.hostPath", '/srv/storage')
->call('clearHostPath', $volume->id)
->assertDontSee('Directory mount')
->assertSee('/srv/storage')
->assertDontSee('Remove Source Path');
expect($volume->refresh()->host_path)->toBe('/srv/storage')
->and(method_exists(All::class, 'clearHostPath'))->toBeFalse();
});
it('does not show a source path or removal action for a named volume', function () {
[$application] = createApplicationWithVolume();
Livewire::test(All::class, ['resource' => $application])
->assertDontSee('Directory mount')
->assertSee('Volume mount')
->assertDontSee('Remove Source Path');
});
it('keeps bind mount source paths out of editable form state', function () {
[$application, $volume] = createApplicationWithVolume(volumeAttributes: ['host_path' => '/srv/storage']);
$component = Livewire::test(All::class, ['resource' => $application]);
expect($component->get('forms')[$volume->id])->not->toHaveKey('hostPath');
$component
->call('submit', $volume->id)
->assertHasNoErrors();
expect($volume->refresh()->host_path)->toBeNull();
expect($volume->refresh()->host_path)->toBe('/srv/storage');
});
it('does not offer bind mount conversion to a team member', function () {
[$application, $volume] = createApplicationWithVolume(volumeAttributes: ['host_path' => '/srv/storage']);
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
$this->actingAs($member);
session(['currentTeam' => $this->team]);
Livewire::test(All::class, ['resource' => $application])
->assertDontSee('Directory mount')
->assertSee('/srv/storage')
->assertDontSee('Remove Source Path');
expect($volume->refresh()->host_path)->toBe('/srv/storage');
});
it('does not show a bind mount source path to another team', function () {
[$application, $volume] = createApplicationWithVolume(volumeAttributes: ['host_path' => '/srv/storage']);
$otherTeam = Team::factory()->create();
$otherUser = User::factory()->create();
$otherTeam->members()->attach($otherUser->id, ['role' => 'owner']);
$this->actingAs($otherUser);
session(['currentTeam' => $otherTeam]);
Livewire::test(All::class, ['resource' => $application])
->assertForbidden();
expect($volume->refresh()->host_path)->toBe('/srv/storage');
});
it('labels bind mounts as directories in deployment configuration', function () {
[$application, $volume] = createApplicationWithVolume(volumeAttributes: ['host_path' => '/srv/storage']);
$storage = collect(data_get($application->deploymentConfigurationSnapshot(), 'sections.storage.items'));
expect($storage->firstWhere('key', 'volume_'.$volume->id))
->toMatchArray(['label' => 'Directory mount', 'display_value' => '/srv/storage → /data']);
$volume->update(['host_path' => null]);
$storage = collect(data_get($application->deploymentConfigurationSnapshot(), 'sections.storage.items'));
expect($storage->firstWhere('key', 'volume_'.$volume->id)['label'])->toBe('Volume mount');
});
it('creates and exposes volume backups for service storage', function () {
+82
View File
@@ -7,13 +7,94 @@ use App\Jobs\CheckTraefikVersionJob;
use App\Livewire\Server\Proxy;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Event;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('shows patch and minor upgrade warnings on the first proxy render', function () {
Cache::put('coolify:versions:all', [
'traefik' => [
'v3.7' => '3.7.13',
'v3.6' => '3.6.25',
],
]);
$team = Team::factory()->create();
$user = User::factory()->create();
$team->members()->attach($user->id, ['role' => 'owner']);
session(['currentTeam' => $team]);
$this->actingAs($user);
$server = Server::factory()->create([
'team_id' => $team->id,
'proxy' => ['type' => ProxyTypes::TRAEFIK->value, 'status' => 'running'],
'detected_traefik_version' => '3.6.1',
'traefik_outdated_info' => [
'current' => '3.6.1',
'latest' => '3.7.13',
'type' => 'minor_upgrade',
'upgrade_target' => 'v3.7',
],
]);
Livewire::test(Proxy::class, ['server' => $server])
->assertSee('Traefik patch update available')
->assertSee('v3.6.25')
->assertSee('New Traefik minor version available')
->assertSee('v3.7.13');
});
it('shows a warning from the saved image before version detection finishes', function () {
Cache::put('coolify:versions:all', [
'traefik' => ['v3.7' => '3.7.13', 'v3.6' => '3.6.25'],
]);
$team = Team::factory()->create();
$user = User::factory()->create();
$team->members()->attach($user->id, ['role' => 'owner']);
session(['currentTeam' => $team]);
$this->actingAs($user);
$server = Server::factory()->create([
'team_id' => $team->id,
'proxy' => [
'type' => ProxyTypes::TRAEFIK->value,
'status' => 'running',
'last_saved_proxy_configuration' => "services:\n traefik:\n image: 'traefik:v3.6.5'",
],
'detected_traefik_version' => null,
]);
Livewire::test(Proxy::class, ['server' => $server])
->assertSee('Configured image')
->assertSee('v3.6.5')
->assertSee('v3.6.25')
->assertSee('v3.7.13');
});
it('does not treat an unapplied image as the running Traefik version', function () {
$server = Server::factory()->make([
'proxy' => [
'type' => ProxyTypes::TRAEFIK->value,
'status' => 'running',
'last_saved_settings' => 'new',
'last_applied_settings' => 'old',
'last_saved_proxy_configuration' => "services:\n traefik:\n image: traefik:v3.6.5",
],
'detected_traefik_version' => null,
]);
$component = new Proxy;
$component->server = $server;
expect($component->getTraefikVersionForWarningProperty())->toBeNull();
});
it('ignores stale minor upgrade information for the detected Traefik version', function () {
Cache::put('coolify:versions:all', [
'traefik' => [
@@ -120,6 +201,7 @@ YAML,
$component = new Proxy;
$component->server = $server;
$component->mount();
$component->loadProxyConfiguration();
expect($server->refresh()->traefik_outdated_info)->toBeNull();
});