mirror of
https://github.com/coollabsio/coolify.git
synced 2026-09-26 01:10:30 -04:00
Merge remote-tracking branch 'origin/main' into maintenance/control-plane-updates
This commit is contained in:
@@ -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 = '';
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 () {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user