From 128939b06247c9e088df41ff6bc5feea58885390 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 16 Sep 2026 14:24:52 +0200 Subject: [PATCH] fix(storages): remove host path from storage configuration --- .../Api/ApplicationsController.php | 14 +--- .../Controllers/Api/DatabasesController.php | 14 +--- .../Controllers/Api/ServicesController.php | 14 +--- app/Livewire/Project/Service/Storage.php | 4 +- openapi.json | 30 --------- openapi.yaml | 24 ------- resources/css/app.css | 42 ++---------- .../project/service/storage.blade.php | 16 ----- .../project/shared/storages/all.blade.php | 13 ---- .../project/shared/storages/show.blade.php | 13 ---- tests/Feature/Mcp/McpEndpointTest.php | 8 ++- .../PersistentStorageVolumesLayoutTest.php | 40 ++++++++++- tests/Feature/StorageApiTest.php | 67 +++++++++++++++++++ 13 files changed, 128 insertions(+), 171 deletions(-) diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index bf68713e94..8cf61ef69b 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -4872,7 +4872,6 @@ class ApplicationsController extends Controller 'is_preview_suffix_enabled' => ['type' => 'boolean', 'description' => 'Whether to add -pr-N suffix for preview deployments.'], 'name' => ['type' => 'string', 'description' => 'The volume name (persistent only, not allowed for read-only storages).'], 'mount_path' => ['type' => 'string', 'description' => 'The container mount path (not allowed for read-only storages).'], - 'host_path' => ['type' => 'string', 'nullable' => true, 'description' => 'The host path (persistent only, not allowed for read-only storages).'], 'content' => ['type' => 'string', 'nullable' => true, 'description' => 'The file content (file only, not allowed for read-only storages).'], ], additionalProperties: false, @@ -4934,11 +4933,10 @@ class ApplicationsController extends Controller 'is_preview_suffix_enabled' => 'boolean', 'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN], 'mount_path' => 'string', - 'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN], 'content' => 'string|nullable', ]); - $allAllowedFields = ['uuid', 'id', 'type', 'is_preview_suffix_enabled', 'name', 'mount_path', 'host_path', 'content']; + $allAllowedFields = ['uuid', 'id', 'type', 'is_preview_suffix_enabled', 'name', 'mount_path', 'content']; $extraFields = array_diff(array_keys($request->all()), $allAllowedFields); if ($validator->fails() || ! empty($extraFields)) { $errors = $validator->errors(); @@ -4980,7 +4978,7 @@ class ApplicationsController extends Controller } $isReadOnly = $storage->shouldBeReadOnlyInUI(); - $editableOnlyFields = ['name', 'mount_path', 'host_path', 'content']; + $editableOnlyFields = ['name', 'mount_path', 'content']; $requestedEditableFields = array_intersect($editableOnlyFields, array_keys($request->all())); if ($isReadOnly && ! empty($requestedEditableFields)) { @@ -5019,9 +5017,6 @@ class ApplicationsController extends Controller if ($request->has('mount_path')) { $storage->mount_path = $request->mount_path; } - if ($request->has('host_path')) { - $storage->host_path = $request->host_path; - } } else { if ($request->has('mount_path')) { $storage->mount_path = $request->mount_path; @@ -5076,7 +5071,6 @@ class ApplicationsController extends Controller 'type' => ['type' => 'string', 'enum' => ['persistent', 'file'], 'description' => 'The type of storage.'], 'name' => ['type' => 'string', 'description' => 'Volume name (persistent only, required for persistent).'], 'mount_path' => ['type' => 'string', 'description' => 'The container mount path.'], - 'host_path' => ['type' => 'string', 'nullable' => true, 'description' => 'The host path (persistent only, optional).'], 'content' => ['type' => 'string', 'nullable' => true, 'description' => 'File content (file only, optional).'], 'is_directory' => ['type' => 'boolean', 'description' => 'Whether this is a directory mount (file only, default false).'], 'fs_path' => ['type' => 'string', 'description' => 'Host directory path (required when is_directory is true).'], @@ -5121,14 +5115,13 @@ class ApplicationsController extends Controller 'type' => 'required|string|in:persistent,file', 'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN], 'mount_path' => 'required|string', - 'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN], 'content' => 'string|nullable', 'is_directory' => 'boolean', 'is_host_file' => 'boolean', 'fs_path' => 'string', ]); - $allAllowedFields = ['type', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'is_host_file', 'fs_path']; + $allAllowedFields = ['type', 'name', 'mount_path', 'content', 'is_directory', 'is_host_file', 'fs_path']; $extraFields = array_diff(array_keys($request->all()), $allAllowedFields); if ($validator->fails() || ! empty($extraFields)) { $errors = $validator->errors(); @@ -5164,7 +5157,6 @@ class ApplicationsController extends Controller $storage = LocalPersistentVolume::create([ 'name' => $application->uuid.'-'.$request->name, 'mount_path' => $request->mount_path, - 'host_path' => $request->host_path, 'resource_id' => $application->id, 'resource_type' => $application->getMorphClass(), ]); diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index c9b28e5a2f..f101e3ec8e 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -4064,7 +4064,6 @@ class DatabasesController extends Controller 'type' => ['type' => 'string', 'enum' => ['persistent', 'file'], 'description' => 'The type of storage.'], 'name' => ['type' => 'string', 'description' => 'Volume name (persistent only, required for persistent).'], 'mount_path' => ['type' => 'string', 'description' => 'The container mount path.'], - 'host_path' => ['type' => 'string', 'nullable' => true, 'description' => 'The host path (persistent only, optional).'], 'content' => ['type' => 'string', 'nullable' => true, 'description' => 'File content (file only, optional).'], 'is_directory' => ['type' => 'boolean', 'description' => 'Whether this is a directory mount (file only, default false).'], 'fs_path' => ['type' => 'string', 'description' => 'Host directory path (required when is_directory is true).'], @@ -4109,14 +4108,13 @@ class DatabasesController extends Controller 'type' => 'required|string|in:persistent,file', 'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN], 'mount_path' => 'required|string', - 'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN], 'content' => 'string|nullable', 'is_directory' => 'boolean', 'is_host_file' => 'boolean', 'fs_path' => 'string', ]); - $allAllowedFields = ['type', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'is_host_file', 'fs_path']; + $allAllowedFields = ['type', 'name', 'mount_path', 'content', 'is_directory', 'is_host_file', 'fs_path']; $extraFields = array_diff(array_keys($request->all()), $allAllowedFields); if ($validator->fails() || ! empty($extraFields)) { $errors = $validator->errors(); @@ -4152,7 +4150,6 @@ class DatabasesController extends Controller $storage = LocalPersistentVolume::create([ 'name' => $database->uuid.'-'.$request->name, 'mount_path' => $request->mount_path, - 'host_path' => $request->host_path, 'resource_id' => $database->id, 'resource_type' => $database->getMorphClass(), ]); @@ -4304,7 +4301,6 @@ class DatabasesController extends Controller 'is_preview_suffix_enabled' => ['type' => 'boolean', 'description' => 'Whether to add -pr-N suffix for preview deployments.'], 'name' => ['type' => 'string', 'description' => 'The volume name (persistent only, not allowed for read-only storages).'], 'mount_path' => ['type' => 'string', 'description' => 'The container mount path (not allowed for read-only storages).'], - 'host_path' => ['type' => 'string', 'nullable' => true, 'description' => 'The host path (persistent only, not allowed for read-only storages).'], 'content' => ['type' => 'string', 'nullable' => true, 'description' => 'The file content (file only, not allowed for read-only storages).'], ], additionalProperties: false, @@ -4363,11 +4359,10 @@ class DatabasesController extends Controller 'is_preview_suffix_enabled' => 'boolean', 'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN], 'mount_path' => 'string', - 'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN], 'content' => 'string|nullable', ]); - $allAllowedFields = ['uuid', 'id', 'type', 'is_preview_suffix_enabled', 'name', 'mount_path', 'host_path', 'content']; + $allAllowedFields = ['uuid', 'id', 'type', 'is_preview_suffix_enabled', 'name', 'mount_path', 'content']; $extraFields = array_diff(array_keys($request->all()), $allAllowedFields); if ($validator->fails() || ! empty($extraFields)) { $errors = $validator->errors(); @@ -4409,7 +4404,7 @@ class DatabasesController extends Controller } $isReadOnly = $storage->shouldBeReadOnlyInUI(); - $editableOnlyFields = ['name', 'mount_path', 'host_path', 'content']; + $editableOnlyFields = ['name', 'mount_path', 'content']; $requestedEditableFields = array_intersect($editableOnlyFields, array_keys($request->all())); if ($isReadOnly && ! empty($requestedEditableFields)) { @@ -4448,9 +4443,6 @@ class DatabasesController extends Controller if ($request->has('mount_path')) { $storage->mount_path = $request->mount_path; } - if ($request->has('host_path')) { - $storage->host_path = $request->host_path; - } } else { if ($request->has('mount_path')) { $storage->mount_path = $request->mount_path; diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 5763f3c276..099c126624 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -2421,7 +2421,6 @@ class ServicesController extends Controller 'resource_uuid' => ['type' => 'string', 'description' => 'UUID of the service application or database sub-resource.'], 'name' => ['type' => 'string', 'description' => 'Volume name (persistent only, required for persistent).'], 'mount_path' => ['type' => 'string', 'description' => 'The container mount path.'], - 'host_path' => ['type' => 'string', 'nullable' => true, 'description' => 'The host path (persistent only, optional).'], 'content' => ['type' => 'string', 'nullable' => true, 'description' => 'File content (file only, optional).'], 'is_directory' => ['type' => 'boolean', 'description' => 'Whether this is a directory mount (file only, default false).'], 'fs_path' => ['type' => 'string', 'description' => 'Host directory path (required when is_directory is true).'], @@ -2467,14 +2466,13 @@ class ServicesController extends Controller 'resource_uuid' => 'required|string', 'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN], 'mount_path' => 'required|string', - 'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN], 'content' => 'string|nullable', 'is_directory' => 'boolean', 'is_host_file' => 'boolean', 'fs_path' => 'string', ]); - $allAllowedFields = ['type', 'resource_uuid', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'is_host_file', 'fs_path']; + $allAllowedFields = ['type', 'resource_uuid', 'name', 'mount_path', 'content', 'is_directory', 'is_host_file', 'fs_path']; $extraFields = array_diff(array_keys($request->all()), $allAllowedFields); if ($validator->fails() || ! empty($extraFields)) { $errors = $validator->errors(); @@ -2518,7 +2516,6 @@ class ServicesController extends Controller $storage = LocalPersistentVolume::create([ 'name' => $subResource->uuid.'-'.$request->name, 'mount_path' => $request->mount_path, - 'host_path' => $request->host_path, 'resource_id' => $subResource->id, 'resource_type' => $subResource->getMorphClass(), ]); @@ -2670,7 +2667,6 @@ class ServicesController extends Controller 'is_preview_suffix_enabled' => ['type' => 'boolean', 'description' => 'Whether to add -pr-N suffix for preview deployments.'], 'name' => ['type' => 'string', 'description' => 'The volume name (persistent only, not allowed for read-only storages).'], 'mount_path' => ['type' => 'string', 'description' => 'The container mount path (not allowed for read-only storages).'], - 'host_path' => ['type' => 'string', 'nullable' => true, 'description' => 'The host path (persistent only, not allowed for read-only storages).'], 'content' => ['type' => 'string', 'nullable' => true, 'description' => 'The file content (file only, not allowed for read-only storages).'], ], additionalProperties: false, @@ -2732,11 +2728,10 @@ class ServicesController extends Controller 'is_preview_suffix_enabled' => 'boolean', 'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN], 'mount_path' => 'string', - 'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN], 'content' => 'string|nullable', ]); - $allAllowedFields = ['uuid', 'id', 'type', 'is_preview_suffix_enabled', 'name', 'mount_path', 'host_path', 'content']; + $allAllowedFields = ['uuid', 'id', 'type', 'is_preview_suffix_enabled', 'name', 'mount_path', 'content']; $extraFields = array_diff(array_keys($request->all()), $allAllowedFields); if ($validator->fails() || ! empty($extraFields)) { $errors = $validator->errors(); @@ -2805,7 +2800,7 @@ class ServicesController extends Controller } $isReadOnly = $storage->shouldBeReadOnlyInUI(); - $editableOnlyFields = ['name', 'mount_path', 'host_path', 'content']; + $editableOnlyFields = ['name', 'mount_path', 'content']; $requestedEditableFields = array_intersect($editableOnlyFields, array_keys($request->all())); if ($isReadOnly && ! empty($requestedEditableFields)) { @@ -2844,9 +2839,6 @@ class ServicesController extends Controller if ($request->has('mount_path')) { $storage->mount_path = $request->mount_path; } - if ($request->has('host_path')) { - $storage->host_path = $request->host_path; - } } else { if ($request->has('mount_path')) { $storage->mount_path = $request->mount_path; diff --git a/app/Livewire/Project/Service/Storage.php b/app/Livewire/Project/Service/Storage.php index adb19a3135..b004e3c16a 100644 --- a/app/Livewire/Project/Service/Storage.php +++ b/app/Livewire/Project/Service/Storage.php @@ -208,9 +208,7 @@ class Storage extends Component $this->validate([ 'name' => ValidationPatterns::volumeNameRules(), 'mount_path' => 'required|string', - 'host_path' => $this->isSwarm - ? ['required', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN] - : ['nullable', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN], + '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.', ])); diff --git a/openapi.json b/openapi.json index 718191d3cf..ce08cb3d9b 100644 --- a/openapi.json +++ b/openapi.json @@ -4345,11 +4345,6 @@ "type": "string", "description": "The container mount path." }, - "host_path": { - "type": "string", - "nullable": true, - "description": "The host path (persistent only, optional)." - }, "content": { "type": "string", "nullable": true, @@ -4456,11 +4451,6 @@ "type": "string", "description": "The container mount path (not allowed for read-only storages)." }, - "host_path": { - "type": "string", - "nullable": true, - "description": "The host path (persistent only, not allowed for read-only storages)." - }, "content": { "type": "string", "nullable": true, @@ -8769,11 +8759,6 @@ "type": "string", "description": "The container mount path." }, - "host_path": { - "type": "string", - "nullable": true, - "description": "The host path (persistent only, optional)." - }, "content": { "type": "string", "nullable": true, @@ -8880,11 +8865,6 @@ "type": "string", "description": "The container mount path (not allowed for read-only storages)." }, - "host_path": { - "type": "string", - "nullable": true, - "description": "The host path (persistent only, not allowed for read-only storages)." - }, "content": { "type": "string", "nullable": true, @@ -19156,11 +19136,6 @@ "type": "string", "description": "The container mount path." }, - "host_path": { - "type": "string", - "nullable": true, - "description": "The host path (persistent only, optional)." - }, "content": { "type": "string", "nullable": true, @@ -19267,11 +19242,6 @@ "type": "string", "description": "The container mount path (not allowed for read-only storages)." }, - "host_path": { - "type": "string", - "nullable": true, - "description": "The host path (persistent only, not allowed for read-only storages)." - }, "content": { "type": "string", "nullable": true, diff --git a/openapi.yaml b/openapi.yaml index a53ab1439a..62888a0b0f 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2828,10 +2828,6 @@ paths: mount_path: type: string description: 'The container mount path.' - host_path: - type: string - nullable: true - description: 'The host path (persistent only, optional).' content: type: string nullable: true @@ -2904,10 +2900,6 @@ paths: mount_path: type: string description: 'The container mount path (not allowed for read-only storages).' - host_path: - type: string - nullable: true - description: 'The host path (persistent only, not allowed for read-only storages).' content: type: string nullable: true @@ -5745,10 +5737,6 @@ paths: mount_path: type: string description: 'The container mount path.' - host_path: - type: string - nullable: true - description: 'The host path (persistent only, optional).' content: type: string nullable: true @@ -5821,10 +5809,6 @@ paths: mount_path: type: string description: 'The container mount path (not allowed for read-only storages).' - host_path: - type: string - nullable: true - description: 'The host path (persistent only, not allowed for read-only storages).' content: type: string nullable: true @@ -12103,10 +12087,6 @@ paths: mount_path: type: string description: 'The container mount path.' - host_path: - type: string - nullable: true - description: 'The host path (persistent only, optional).' content: type: string nullable: true @@ -12179,10 +12159,6 @@ paths: mount_path: type: string description: 'The container mount path (not allowed for read-only storages).' - host_path: - type: string - nullable: true - description: 'The host path (persistent only, not allowed for read-only storages).' content: type: string nullable: true diff --git a/resources/css/app.css b/resources/css/app.css index da912284cf..1d0cd200ae 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -2942,17 +2942,17 @@ input[type="search"]::-webkit-search-results-decoration { background: color-mix(in srgb, var(--coollabs-base) 98%, white); } -/* Persistent storage volumes: Name | Source | Destination | [PR suffix] | Backup | [Actions] */ +/* Persistent storage volumes: Name | Destination | [PR suffix] | Backup | [Actions] */ .volumes-table-grid-readonly { - grid-template-columns: minmax(12rem, 1.5fr) minmax(8rem, 1fr) minmax(8rem, 1fr) 5rem; + grid-template-columns: minmax(10rem, 1.4fr) minmax(8rem, 1fr) 5rem; } .volumes-table-grid { - grid-template-columns: minmax(10rem, 1.4fr) minmax(6rem, 1fr) minmax(6rem, 1fr) 5rem 15rem; + grid-template-columns: minmax(9rem, 1.2fr) minmax(6rem, 1fr) 5rem 15rem; } .volumes-table-grid-with-pr { - grid-template-columns: minmax(9rem, 1.2fr) minmax(5.5rem, 0.85fr) minmax(5.5rem, 0.85fr) 9.25rem 5rem 15rem; + grid-template-columns: minmax(9rem, 1.1fr) minmax(6rem, 1fr) 8.5rem 5rem 15rem; } .volumes-mobile-label { @@ -2986,35 +2986,6 @@ input[type="search"]::-webkit-search-results-decoration { padding-inline: 0.5rem; } -@media (max-width: 1100px) { - .volumes-table-grid { - grid-template-columns: minmax(9rem, 1.2fr) minmax(6rem, 1fr) 5rem 15rem; - } - - .volumes-table-grid > .volumes-col-source, - .data-table-header.volumes-table-grid > .volumes-col-source { - display: none; - } - - .volumes-table-grid-with-pr { - grid-template-columns: minmax(9rem, 1.1fr) minmax(6rem, 1fr) 8.5rem 5rem 15rem; - } - - .volumes-table-grid-with-pr > .volumes-col-source, - .data-table-header.volumes-table-grid-with-pr > .volumes-col-source { - display: none; - } - - .volumes-table-grid-readonly { - grid-template-columns: minmax(10rem, 1.4fr) minmax(8rem, 1fr) 5rem; - } - - .volumes-table-grid-readonly > .volumes-col-source, - .data-table-header.volumes-table-grid-readonly > .volumes-col-source { - display: none; - } -} - /* Phone: stacked card rows with per-field labels (table headers hidden) */ @media (max-width: 768px) { .data-table-header.volumes-table-grid, @@ -3034,10 +3005,7 @@ input[type="search"]::-webkit-search-results-decoration { min-height: 0; } - .data-table-row.volumes-table-grid > .volumes-col-source, - .data-table-row.volumes-table-grid-with-pr > .volumes-col-source, - .data-table-row.volumes-table-grid-with-pr > .volumes-col-pr, - .data-table-row.volumes-table-grid-readonly > .volumes-col-source { + .data-table-row.volumes-table-grid-with-pr > .volumes-col-pr { display: flex; flex-direction: column; gap: 0.25rem; diff --git a/resources/views/livewire/project/service/storage.blade.php b/resources/views/livewire/project/service/storage.blade.php index e2749aad06..43608188d1 100644 --- a/resources/views/livewire/project/service/storage.blade.php +++ b/resources/views/livewire/project/service/storage.blade.php @@ -116,25 +116,9 @@

Mount a Docker volume inside the container.

- @if ($isSwarm) -
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.
- @endif
- @if ($isSwarm) - - @else - - @endif diff --git a/resources/views/livewire/project/shared/storages/all.blade.php b/resources/views/livewire/project/shared/storages/all.blade.php index 17288910c9..c5e0953ac9 100644 --- a/resources/views/livewire/project/shared/storages/all.blade.php +++ b/resources/views/livewire/project/shared/storages/all.blade.php @@ -18,7 +18,6 @@
Volume Name - Source Path Destination Path @if ($supportsPreviewSuffix)
@@ -44,7 +43,6 @@ $hasS3Backup = $backupMeta['s3']; $backupUrl = $backupMeta['url']; $inputsReadonly = $form['isReadOnly']; - $displayHostPath = filled($form['hostPath']) ? $form['hostPath'] : '—'; @endphp @if ($inputsReadonly) @@ -59,12 +57,6 @@
-
- Source Path - {{ $displayHostPath }} -
-
Destination Path
-
- Source Path - -
-
Destination Path user()?->can('update', $resource) ?? false; $inputsReadonly = $isReadOnly || ! $canUpdate; - $displayHostPath = filled($hostPath) ? $hostPath : '—'; @endphp @if ($inputsReadonly) @@ -35,13 +34,6 @@
-
- Source Path - - {{ $displayHostPath }} - -
-
Destination Path
-
- Source Path - -
-
Destination Path diff --git a/tests/Feature/Mcp/McpEndpointTest.php b/tests/Feature/Mcp/McpEndpointTest.php index 01ba0ecf16..450dd5023f 100644 --- a/tests/Feature/Mcp/McpEndpointTest.php +++ b/tests/Feature/Mcp/McpEndpointTest.php @@ -12,6 +12,7 @@ use Illuminate\Support\Once; uses(RefreshDatabase::class); beforeEach(function () { + config()->set('app.maintenance.store', 'array'); InstanceSettings::query()->where('id', 0)->delete(); InstanceSettings::query()->delete(); $settings = new InstanceSettings(['is_mcp_server_enabled' => true]); @@ -123,6 +124,7 @@ test('MCP endpoint lists tools for an authenticated token', function () { $response->assertOk(); $toolNames = collect($response->json('result.tools'))->pluck('name')->all(); + expect(json_encode($response->json('result.tools')))->not->toContain('host_path'); expect($toolNames)->toContain( 'get_infrastructure_overview', 'list_servers', @@ -139,7 +141,11 @@ test('MCP endpoint lists tools for an authenticated token', function () { 'get_logs', 'list_env_keys', ); - expect($toolNames)->not->toContain('get_resource_status'); + expect($toolNames)->not->toContain( + 'get_resource_status', + 'create_storage', + 'update_storage', + ); expect($toolNames)->toContain('coolify_help', 'control', 'deploy'); }); diff --git a/tests/Feature/PersistentStorageVolumesLayoutTest.php b/tests/Feature/PersistentStorageVolumesLayoutTest.php index fb00590df2..2d89d14486 100644 --- a/tests/Feature/PersistentStorageVolumesLayoutTest.php +++ b/tests/Feature/PersistentStorageVolumesLayoutTest.php @@ -43,6 +43,7 @@ it('keeps nested storage component keys stable when mounts are added or deleted' ->not->toContain('wire:key="svc-volumes-{{ $resource->id }}-{{ $this->volumeCount }}"'); }); +use App\Livewire\Project\Service\Storage; use App\Livewire\Project\Service\VolumeBackup\Create as CreateServiceVolumeBackup; use App\Livewire\Project\Shared\Storages\All; use App\Models\Application; @@ -147,7 +148,7 @@ it('renders volumes as a data table with shared column headers', function () { ->toContain('volumes-table-grid') ->toContain('volumes-table-grid-readonly') ->toContain('Volume Name') - ->toContain('Source Path') + ->not->toContain('Source Path') ->toContain('Destination Path') ->toContain('volumes-col-backup') ->toContain('supportsPreviewSuffix') @@ -222,6 +223,43 @@ it('renders volumes as a data table with shared column headers', function () { ->toMatch('/\.application-settings-form label\s*\{[^}]*font-size:\s*13px/s'); }); +it('keeps source paths out of the Docker volume creation form', function () { + $view = file_get_contents(resource_path('views/livewire/project/service/storage.blade.php')); + + expect($view) + ->not->toContain('id="host_path"') + ->not->toContain('label="Source Path"') + ->not->toContain('Swarm Mode detected'); +}); + +it('creates named Docker volumes without a source 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()->host_path)->toBeNull(); +}); + +it('removes the source path column from Docker volume views', function () { + $allView = file_get_contents(resource_path('views/livewire/project/shared/storages/all.blade.php')); + $showView = file_get_contents(resource_path('views/livewire/project/shared/storages/show.blade.php')); + + expect($allView) + ->not->toContain('Source Path') + ->not->toContain('volumes-col-source') + ->not->toContain('forms.{{ $id }}.hostPath') + ->and($showView) + ->not->toContain('Source Path') + ->not->toContain('volumes-col-source') + ->not->toContain('id="hostPath"'); +}); + it('renders volume actions and PR suffix controls as valid markup', function () { [$application] = createApplicationWithVolume(); LocalPersistentVolume::create([ diff --git a/tests/Feature/StorageApiTest.php b/tests/Feature/StorageApiTest.php index 2f5a823749..aadcd163b5 100644 --- a/tests/Feature/StorageApiTest.php +++ b/tests/Feature/StorageApiTest.php @@ -621,3 +621,70 @@ describe('DELETE /api/v1/databases/{uuid}/storages/{storage_uuid}', function () expect(LocalPersistentVolume::find($vol->id))->toBeNull(); }); }); + +test('rejects host paths when creating persistent storage through the API', function (string $resourceType) { + if ($resourceType === 'application') { + $resource = createTestApplication($this); + $url = "/api/v1/applications/{$resource->uuid}/storages"; + $payload = []; + } elseif ($resourceType === 'database') { + $resource = createTestDatabase($this); + $url = "/api/v1/databases/{$resource->uuid}/storages"; + $payload = []; + } else { + [$service, $resource] = createTestServiceApplication($this); + $url = "/api/v1/services/{$service->uuid}/storages"; + $payload = ['resource_uuid' => $resource->uuid]; + } + + $storageCountBefore = $resource->persistentStorages()->count(); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson($url, array_merge($payload, [ + 'type' => 'persistent', + 'name' => 'blocked-bind-mount', + 'mount_path' => '/data', + 'host_path' => '/srv/data', + ])); + + $response->assertUnprocessable() + ->assertJsonPath('errors.host_path.0', 'This field is not allowed.'); + + expect($resource->persistentStorages()->count())->toBe($storageCountBefore); +})->with(['application', 'database', 'service']); + +test('rejects host paths when updating persistent storage through the API', function (string $resourceType) { + if ($resourceType === 'application') { + $resource = createTestApplication($this); + $url = "/api/v1/applications/{$resource->uuid}/storages"; + } elseif ($resourceType === 'database') { + $resource = createTestDatabase($this); + $url = "/api/v1/databases/{$resource->uuid}/storages"; + } else { + [$service, $resource] = createTestServiceApplication($this); + $url = "/api/v1/services/{$service->uuid}/storages"; + } + + $storage = LocalPersistentVolume::create([ + 'name' => $resource->uuid.'-data', + 'mount_path' => '/data', + 'resource_id' => $resource->id, + 'resource_type' => $resource->getMorphClass(), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson($url, [ + 'uuid' => $storage->uuid, + 'type' => 'persistent', + 'host_path' => '/srv/data', + ]); + + $response->assertUnprocessable() + ->assertJsonPath('errors.host_path.0', 'This field is not allowed.'); + + expect($storage->refresh()->host_path)->toBeNull(); +})->with(['application', 'database', 'service']);