Hide source path from volume creation UI (#7591)

This commit is contained in:
Andras Bacsai
2026-08-19 13:51:44 +02:00
committed by GitHub
17 changed files with 108 additions and 364 deletions
+1
View File
@@ -1,6 +1,7 @@
APP_ENV=testing APP_ENV=testing
APP_KEY=base64:8VEfVNVkXQ9mH2L33WBWNMF4eQ0BWD5CTzB8mIxcl+k= APP_KEY=base64:8VEfVNVkXQ9mH2L33WBWNMF4eQ0BWD5CTzB8mIxcl+k=
APP_DEBUG=true APP_DEBUG=true
APP_MAINTENANCE_DRIVER=file
DB_CONNECTION=testing DB_CONNECTION=testing
+10 -4
View File
@@ -77,6 +77,7 @@ class Storage extends Component
$this->activeTab = $this->resolveDefaultTab(); $this->activeTab = $this->resolveDefaultTab();
$this->fileStorage = collect(); $this->fileStorage = collect();
$this->loadFileStorageForActiveTab(); $this->loadFileStorageForActiveTab();
$this->name = $this->generateDefaultVolumeName();
} }
public function refreshStoragesFromEvent() public function refreshStoragesFromEvent()
@@ -201,9 +202,7 @@ class Storage extends Component
$this->validate([ $this->validate([
'name' => ValidationPatterns::volumeNameRules(), 'name' => ValidationPatterns::volumeNameRules(),
'mount_path' => 'required|string', 'mount_path' => 'required|string',
'host_path' => $this->isSwarm 'host_path' => ['nullable', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
? ['required', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN]
: ['nullable', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
], array_merge(ValidationPatterns::volumeNameMessages(), [ ], array_merge(ValidationPatterns::volumeNameMessages(), [
'host_path.regex' => 'Host path must start with / and only contain safe path characters.', 'host_path.regex' => 'Host path must start with / and only contain safe path characters.',
])); ]));
@@ -340,7 +339,7 @@ class Storage extends Component
public function clearForm() public function clearForm()
{ {
$this->name = ''; $this->name = $this->generateDefaultVolumeName();
$this->mount_path = ''; $this->mount_path = '';
$this->host_path = null; $this->host_path = null;
$this->file_storage_path = ''; $this->file_storage_path = '';
@@ -373,6 +372,13 @@ class Storage extends Component
throw new \Exception('No valid resource type for file mount storage type!'); throw new \Exception('No valid resource type for file mount storage type!');
} }
private function generateDefaultVolumeName(): string
{
$name = str($this->resource->name)->slug()->value();
return ($name ?: 'volume').'-data';
}
public function fileStoragePreviewPath(): string public function fileStoragePreviewPath(): string
{ {
$path = str($this->file_storage_path)->trim(); $path = str($this->file_storage_path)->trim();
@@ -107,6 +107,25 @@ class All extends Component
$this->submit($storageId); $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. * Livewire listbox onChange cannot pass args; PR suffix fields call this via updatedForms.
*/ */
-21
View File
@@ -1,21 +0,0 @@
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
class DuskServiceProvider extends ServiceProvider
{
/**
* Register Dusk's browser macros.
*/
public function boot(): void
{
\Laravel\Dusk\Browser::macro('loginWithRootUser', function () {
return $this->visit('/login')
->type('email', 'test@example.com')
->type('password', 'password')
->press('Login');
});
}
}
-1
View File
@@ -64,7 +64,6 @@
"driftingly/rector-laravel": "^2.5.0", "driftingly/rector-laravel": "^2.5.0",
"fakerphp/faker": "^1.24.1", "fakerphp/faker": "^1.24.1",
"laravel/boost": "^2.4.8", "laravel/boost": "^2.4.8",
"laravel/dusk": "^8.6.0",
"laravel/pint": "^1.30.4", "laravel/pint": "^1.30.4",
"mockery/mockery": "^1.6.12", "mockery/mockery": "^1.6.12",
"nunomaduro/collision": "^8.9.5", "nunomaduro/collision": "^8.9.5",
Generated
+1 -141
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "2d511da9e5e82eade5aa7e5094c888ae", "content-hash": "13e5d201c34a64cdf53e80a21304c9d5",
"packages": [ "packages": [
{ {
"name": "aws/aws-crt-php", "name": "aws/aws-crt-php",
@@ -13698,80 +13698,6 @@
}, },
"time": "2026-05-19T20:09:50+00:00" "time": "2026-05-19T20:09:50+00:00"
}, },
{
"name": "laravel/dusk",
"version": "v8.6.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/dusk.git",
"reference": "e7fd48762c6a82ad2cd311db07587aa2a97ce143"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/dusk/zipball/e7fd48762c6a82ad2cd311db07587aa2a97ce143",
"reference": "e7fd48762c6a82ad2cd311db07587aa2a97ce143",
"shasum": ""
},
"require": {
"ext-json": "*",
"ext-zip": "*",
"guzzlehttp/guzzle": "^7.5",
"illuminate/console": "^10.0|^11.0|^12.0|^13.0",
"illuminate/support": "^10.0|^11.0|^12.0|^13.0",
"php": "^8.1",
"php-webdriver/webdriver": "^1.15.2",
"symfony/console": "^6.2|^7.0|^8.0",
"symfony/finder": "^6.2|^7.0|^8.0",
"symfony/process": "^6.2|^7.0|^8.0",
"vlucas/phpdotenv": "^5.2"
},
"require-dev": {
"laravel/framework": "^10.0|^11.0|^12.0|^13.0",
"mockery/mockery": "^1.6",
"orchestra/testbench-core": "^8.19|^9.17|^10.8|^11.0",
"phpstan/phpstan": "^1.10",
"phpunit/phpunit": "^10.1|^11.0|^12.0.1",
"psy/psysh": "^0.11.12|^0.12",
"symfony/yaml": "^6.2|^7.0|^8.0"
},
"suggest": {
"ext-pcntl": "Used to gracefully terminate Dusk when tests are running."
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Laravel\\Dusk\\DuskServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Laravel\\Dusk\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
}
],
"description": "Laravel Dusk provides simple end-to-end testing and browser automation.",
"keywords": [
"laravel",
"testing",
"webdriver"
],
"support": {
"issues": "https://github.com/laravel/dusk/issues",
"source": "https://github.com/laravel/dusk/tree/v8.6.0"
},
"time": "2026-04-15T14:50:40+00:00"
},
{ {
"name": "laravel/pint", "name": "laravel/pint",
"version": "v1.30.4", "version": "v1.30.4",
@@ -14817,72 +14743,6 @@
}, },
"time": "2022-02-21T01:04:05+00:00" "time": "2022-02-21T01:04:05+00:00"
}, },
{
"name": "php-webdriver/webdriver",
"version": "1.16.0",
"source": {
"type": "git",
"url": "https://github.com/php-webdriver/php-webdriver.git",
"reference": "ac0662863aa120b4f645869f584013e4c4dba46a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-webdriver/php-webdriver/zipball/ac0662863aa120b4f645869f584013e4c4dba46a",
"reference": "ac0662863aa120b4f645869f584013e4c4dba46a",
"shasum": ""
},
"require": {
"ext-curl": "*",
"ext-json": "*",
"ext-zip": "*",
"php": "^7.3 || ^8.0",
"symfony/polyfill-mbstring": "^1.12",
"symfony/process": "^5.0 || ^6.0 || ^7.0 || ^8.0"
},
"replace": {
"facebook/webdriver": "*"
},
"require-dev": {
"ergebnis/composer-normalize": "^2.20.0",
"ondram/ci-detector": "^4.0",
"php-coveralls/php-coveralls": "^2.4",
"php-mock/php-mock-phpunit": "^2.0",
"php-parallel-lint/php-parallel-lint": "^1.2",
"phpunit/phpunit": "^9.3",
"squizlabs/php_codesniffer": "^3.5",
"symfony/var-dumper": "^5.0 || ^6.0 || ^7.0 || ^8.0"
},
"suggest": {
"ext-simplexml": "For Firefox profile creation"
},
"type": "library",
"autoload": {
"files": [
"lib/Exception/TimeoutException.php"
],
"psr-4": {
"Facebook\\WebDriver\\": "lib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "A PHP client for Selenium WebDriver. Previously facebook/webdriver.",
"homepage": "https://github.com/php-webdriver/php-webdriver",
"keywords": [
"Chromedriver",
"geckodriver",
"php",
"selenium",
"webdriver"
],
"support": {
"issues": "https://github.com/php-webdriver/php-webdriver/issues",
"source": "https://github.com/php-webdriver/php-webdriver/tree/1.16.0"
},
"time": "2025-12-28T23:57:40+00:00"
},
{ {
"name": "phpstan/phpstan", "name": "phpstan/phpstan",
"version": "2.2.8", "version": "2.2.8",
+2 -2
View File
@@ -193,8 +193,8 @@ return [
*/ */
'maintenance' => [ 'maintenance' => [
'driver' => 'cache', 'driver' => env('APP_MAINTENANCE_DRIVER', 'cache'),
'store' => 'redis', 'store' => env('APP_MAINTENANCE_STORE', 'redis'),
], ],
/* /*
@@ -116,25 +116,9 @@
<p class="text-[13px] leading-5 text-neutral-500 dark:text-fg-dim"> <p class="text-[13px] leading-5 text-neutral-500 dark:text-fg-dim">
Mount a Docker volume inside the container. Mount a Docker volume inside the container.
</p> </p>
@if ($isSwarm)
<div class="text-warning">Swarm Mode detected: You need to set a shared
volume
(EFS/NFS/etc) on all the worker nodes if you would like to use a
persistent
volumes.</div>
@endif
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<x-forms.input canGate="update" :canResource="$resource" placeholder="pv-name" <x-forms.input canGate="update" :canResource="$resource" placeholder="pv-name"
id="name" label="Name" required helper="Volume name." /> id="name" label="Name" required helper="Volume name." />
@if ($isSwarm)
<x-forms.input canGate="update" :canResource="$resource"
placeholder="/root" id="host_path" label="Source Path" required
helper="Directory on the host system." />
@else
<x-forms.input canGate="update" :canResource="$resource"
placeholder="/root" id="host_path" label="Source Path"
helper="Directory on the host system." />
@endif
<x-forms.input canGate="update" :canResource="$resource" <x-forms.input canGate="update" :canResource="$resource"
placeholder="/tmp/root" id="mount_path" label="Destination Path" placeholder="/tmp/root" id="mount_path" label="Destination Path"
required helper="Directory inside the container." /> required helper="Directory inside the container." />
@@ -154,7 +154,24 @@
<div class="volumes-col-source min-w-0"> <div class="volumes-col-source min-w-0">
<span class="volumes-mobile-label volumes-field-label">Source Path</span> <span class="volumes-mobile-label volumes-field-label">Source Path</span>
<x-forms.input id="forms.{{ $id }}.hostPath" placeholder="Host path (optional)" /> @if (filled($form['hostPath']))
<div class="flex items-center gap-1.5">
<div class="min-w-0 flex-1">
<x-forms.input id="forms.{{ $id }}.hostPath" />
</div>
<x-modal-confirmation title="Remove Source Path?" isErrorButton
canGate="update" :canResource="$resource"
buttonTitle="Remove" submitAction="clearHostPath({{ $id }})"
:actions="[
'Are you sure you want to remove the source path?',
'The next deployment will use a named Docker volume instead.',
'Data from the existing host directory will not be copied to the named volume.',
'Use a Directory Mount when you need to mount a host directory.',
]" />
</div>
@else
<span class="data-table-cell-dash">-</span>
@endif
</div> </div>
<div class="volumes-cell-dest min-w-0"> <div class="volumes-cell-dest min-w-0">
-27
View File
@@ -1,27 +0,0 @@
<?php
namespace Tests\Browser;
use Laravel\Dusk\Browser;
use Tests\DuskTestCase;
use Throwable;
class LoginTest extends DuskTestCase
{
/**
* A basic test for the login page.
* Login with the test user and assert that the user is redirected to the dashboard.
*
* @return void
*
* @throws Throwable
*/
public function test_login()
{
$this->browse(callback: function (Browser $browser) {
$browser->loginWithRootUser()
->assertPathIs('/')
->assertSee('Dashboard');
});
}
}
@@ -1,34 +0,0 @@
<?php
namespace Tests\Browser;
use Laravel\Dusk\Browser;
use Tests\DuskTestCase;
use Throwable;
class ProjectAddNewTest extends DuskTestCase
{
/**
* A basic test for the projects page.
* Login with the test user and assert that the user is redirected to the projects page.
*
* @return void
*
* @throws Throwable
*/
public function test_login()
{
$this->browse(function (Browser $browser) {
$browser->loginWithRootUser()
->visit('/projects')
->pressAndWaitFor('+ Add', 1)
->assertSee('New Project')
->screenshot('project-add-new-1')
->type('name', 'Test Project')
->screenshot('project-add-new-2')
->press('Continue')
->assertSee('Test Project.')
->screenshot('project-add-new-3');
});
}
}
@@ -1,29 +0,0 @@
<?php
namespace Tests\Browser;
use Laravel\Dusk\Browser;
use Tests\DuskTestCase;
use Throwable;
class ProjectSearchTest extends DuskTestCase
{
/**
* A basic test for the projects page.
* Login with the test user and assert that the user is redirected to the projects page.
*
* @return void
*
* @throws Throwable
*/
public function test_login()
{
$this->browse(function (Browser $browser) {
$browser->loginWithRootUser()
->visit('/projects')
->type('[x-model="search"]', 'joi43j4oi32j4o2')
->assertSee('No project found with the search term "joi43j4oi32j4o2".')
->screenshot('project-search-not-found');
});
}
}
-27
View File
@@ -1,27 +0,0 @@
<?php
namespace Tests\Browser;
use Laravel\Dusk\Browser;
use Tests\DuskTestCase;
use Throwable;
class ProjectTest extends DuskTestCase
{
/**
* A basic test for the projects page.
* Login with the test user and assert that the user is redirected to the projects page.
*
* @return void
*
* @throws Throwable
*/
public function test_login()
{
$this->browse(function (Browser $browser) {
$browser->loginWithRootUser()
->visit('/projects')
->assertSee('Projects');
});
}
}
-2
View File
@@ -1,2 +0,0 @@
*
!.gitignore
-2
View File
@@ -1,2 +0,0 @@
*
!.gitignore
-57
View File
@@ -1,57 +0,0 @@
<?php
namespace Tests;
use Facebook\WebDriver\Chrome\ChromeOptions;
use Facebook\WebDriver\Remote\DesiredCapabilities;
use Facebook\WebDriver\Remote\RemoteWebDriver;
use Illuminate\Support\Collection;
use Laravel\Dusk\TestCase as BaseTestCase;
abstract class DuskTestCase extends BaseTestCase
{
use CreatesApplication;
/**
* Prepare for Dusk test execution.
*
* @beforeClass
*/
public static function prepare(): void
{
if (! static::runningInSail()) {
static::startChromeDriver();
}
}
/**
* Create the RemoteWebDriver instance.
*/
protected function driver(): RemoteWebDriver
{
$options = (new ChromeOptions)->addArguments(collect([
$this->shouldStartMaximized() ? '--start-maximized' : '--window-size=1920,1080',
])->unless($this->hasHeadlessDisabled(), function (Collection $items) {
return $items->merge([
'--disable-gpu',
'--headless=new',
]);
})->all());
return RemoteWebDriver::create(
'http://localhost:4444',
DesiredCapabilities::chrome()->setCapability(
ChromeOptions::CAPABILITY,
$options
)
);
}
/**
* Determine if the browser window should start maximized.
*/
protected function baseUrl()
{
return 'http://localhost:8000';
}
}
@@ -33,6 +33,7 @@ it('keeps storage backup schedule tables horizontally scrollable on mobile', fun
->and($css)->toMatch('/\.backup-table-grid\s*\{[^}]*min-width:\s*50rem;/'); ->and($css)->toMatch('/\.backup-table-grid\s*\{[^}]*min-width:\s*50rem;/');
}); });
use App\Livewire\Project\Service\Storage;
use App\Livewire\Project\Service\VolumeBackup\Create as CreateServiceVolumeBackup; use App\Livewire\Project\Service\VolumeBackup\Create as CreateServiceVolumeBackup;
use App\Livewire\Project\Shared\Storages\All; use App\Livewire\Project\Shared\Storages\All;
use App\Models\Application; use App\Models\Application;
@@ -206,6 +207,62 @@ it('renders volumes as a data table with shared column headers', function () {
->toMatch('/\.application-settings-form label\s*\{[^}]*font-size:\s*13px/s'); ->toMatch('/\.application-settings-form label\s*\{[^}]*font-size:\s*13px/s');
}); });
it('keeps bind mount source paths out of the add volume form', 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'));
expect($storageView)
->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.');
});
it('creates named volumes without a host path in swarm mode', function () {
[$application] = createApplicationWithVolume();
$application->persistentStorages()->delete();
Livewire::test(Storage::class, ['resource' => $application])
->set('isSwarm', true)
->set('name', 'storage-app-data')
->set('mount_path', '/data')
->call('submitPersistentVolume')
->assertHasNoErrors();
expect($application->persistentStorages()->first())
->name->toBe($application->uuid.'-storage-app-data')
->host_path->toBeNull();
});
it('uses a resource based default name for new volumes', function () {
[$application] = createApplicationWithVolume(['name' => 'Storage App']);
Livewire::test(Storage::class, ['resource' => $application])
->assertSet('name', 'storage-app-data');
});
it('uses a valid fallback default volume name when the resource name has no slug characters', function () {
[$application] = createApplicationWithVolume(['name' => '---']);
Livewire::test(Storage::class, ['resource' => $application])
->assertSet('name', 'volume-data');
});
it('removes existing bind mount source paths from 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)
->assertHasNoErrors();
expect($volume->refresh()->host_path)->toBeNull();
});
it('creates and exposes volume backups for service storage', function () { it('creates and exposes volume backups for service storage', function () {
$service = Service::factory()->create([ $service = Service::factory()->create([
'environment_id' => $this->environment->id, 'environment_id' => $this->environment->id,