diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 4b0cfc6ab..6188651a1 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -18,6 +18,7 @@ use App\Rules\ValidGitBranch; use App\Rules\ValidGitRepositoryUrl; use App\Services\DockerImageParser; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Validator; @@ -3919,4 +3920,260 @@ private function validateDataApplications(Request $request, Server $server) } } } + + #[OA\Get( + summary: 'List Storages', + description: 'List all persistent storages and file storages by application UUID.', + path: '/applications/{uuid}/storages', + operationId: 'list-storages-by-application-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Applications'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the application.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'All storages by application UUID.', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'persistent_storages', type: 'array', items: new OA\Items(type: 'object')), + new OA\Property(property: 'file_storages', type: 'array', items: new OA\Items(type: 'object')), + ], + ), + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function storages(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first(); + + if (! $application) { + return response()->json([ + 'message' => 'Application not found', + ], 404); + } + + $this->authorize('view', $application); + + $persistentStorages = $application->persistentStorages->sortBy('id')->values(); + $fileStorages = $application->fileStorages->sortBy('id')->values(); + + return response()->json([ + 'persistent_storages' => $persistentStorages, + 'file_storages' => $fileStorages, + ]); + } + + #[OA\Patch( + summary: 'Update Storage', + description: 'Update a persistent storage or file storage by application UUID.', + path: '/applications/{uuid}/storages', + operationId: 'update-storage-by-application-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Applications'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the application.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + requestBody: new OA\RequestBody( + description: 'Storage updated. For read-only storages (from docker-compose or services), only is_preview_suffix_enabled can be updated.', + required: true, + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + required: ['id', 'type'], + properties: [ + 'id' => ['type' => 'integer', 'description' => 'The ID of the storage.'], + 'type' => ['type' => 'string', 'enum' => ['persistent', 'file'], 'description' => 'The type of storage: persistent or file.'], + '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, + ), + ), + ], + ), + responses: [ + new OA\Response( + response: 200, + description: 'Storage updated.', + content: new OA\JsonContent(type: 'object'), + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function update_storage(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $return = validateIncomingRequest($request); + if ($return instanceof \Illuminate\Http\JsonResponse) { + return $return; + } + + $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first(); + + if (! $application) { + return response()->json([ + 'message' => 'Application not found', + ], 404); + } + + $this->authorize('update', $application); + + $validator = customApiValidator($request->all(), [ + 'id' => 'required|integer', + 'type' => 'required|string|in:persistent,file', + 'is_preview_suffix_enabled' => 'boolean', + 'name' => 'string', + 'mount_path' => 'string', + 'host_path' => 'string|nullable', + 'content' => 'string|nullable', + ]); + + $allAllowedFields = ['id', 'type', 'is_preview_suffix_enabled', 'name', 'mount_path', 'host_path', 'content']; + $extraFields = array_diff(array_keys($request->all()), $allAllowedFields); + if ($validator->fails() || ! empty($extraFields)) { + $errors = $validator->errors(); + if (! empty($extraFields)) { + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + } + + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $errors, + ], 422); + } + + if ($request->type === 'persistent') { + $storage = $application->persistentStorages->where('id', $request->id)->first(); + } else { + $storage = $application->fileStorages->where('id', $request->id)->first(); + } + + if (! $storage) { + return response()->json([ + 'message' => 'Storage not found.', + ], 404); + } + + $isReadOnly = $storage->shouldBeReadOnlyInUI(); + $editableOnlyFields = ['name', 'mount_path', 'host_path', 'content']; + $requestedEditableFields = array_intersect($editableOnlyFields, array_keys($request->all())); + + if ($isReadOnly && ! empty($requestedEditableFields)) { + return response()->json([ + 'message' => 'This storage is read-only (managed by docker-compose or service definition). Only is_preview_suffix_enabled can be updated.', + 'read_only_fields' => array_values($requestedEditableFields), + ], 422); + } + + // Reject fields that don't apply to the given storage type + if (! $isReadOnly) { + $typeSpecificInvalidFields = $request->type === 'persistent' + ? array_intersect(['content'], array_keys($request->all())) + : array_intersect(['name', 'host_path'], array_keys($request->all())); + + if (! empty($typeSpecificInvalidFields)) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => collect($typeSpecificInvalidFields) + ->mapWithKeys(fn ($field) => [$field => "Field '{$field}' is not valid for type '{$request->type}'."]), + ], 422); + } + } + + // Always allowed + if ($request->has('is_preview_suffix_enabled')) { + $storage->is_preview_suffix_enabled = $request->is_preview_suffix_enabled; + } + + // Only for editable storages + if (! $isReadOnly) { + if ($request->type === 'persistent') { + if ($request->has('name')) { + $storage->name = $request->name; + } + 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; + } + if ($request->has('content')) { + $storage->content = $request->content; + } + } + } + + $storage->save(); + + return response()->json($storage); + } } diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index cbec016e9..7adb938c5 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -2745,7 +2745,8 @@ private function generate_local_persistent_volumes() } else { $volume_name = $persistentStorage->name; } - if ($this->pull_request_id !== 0) { + $isPreviewSuffixEnabled = (bool) data_get($persistentStorage, 'is_preview_suffix_enabled', true); + if ($this->pull_request_id !== 0 && $isPreviewSuffixEnabled) { $volume_name = addPreviewDeploymentSuffix($volume_name, $this->pull_request_id); } $local_persistent_volumes[] = $volume_name.':'.$persistentStorage->mount_path; @@ -2763,7 +2764,8 @@ private function generate_local_persistent_volumes_only_volume_names() } $name = $persistentStorage->name; - if ($this->pull_request_id !== 0) { + $isPreviewSuffixEnabled = (bool) data_get($persistentStorage, 'is_preview_suffix_enabled', true); + if ($this->pull_request_id !== 0 && $isPreviewSuffixEnabled) { $name = addPreviewDeploymentSuffix($name, $this->pull_request_id); } diff --git a/app/Livewire/Project/Service/FileStorage.php b/app/Livewire/Project/Service/FileStorage.php index 5d948bffd..844e37854 100644 --- a/app/Livewire/Project/Service/FileStorage.php +++ b/app/Livewire/Project/Service/FileStorage.php @@ -40,12 +40,16 @@ class FileStorage extends Component #[Validate(['required', 'boolean'])] public bool $isBasedOnGit = false; + #[Validate(['required', 'boolean'])] + public bool $isPreviewSuffixEnabled = true; + protected $rules = [ 'fileStorage.is_directory' => 'required', 'fileStorage.fs_path' => 'required', 'fileStorage.mount_path' => 'required', 'content' => 'nullable', 'isBasedOnGit' => 'required|boolean', + 'isPreviewSuffixEnabled' => 'required|boolean', ]; public function mount() @@ -71,12 +75,14 @@ public function syncData(bool $toModel = false): void // Sync to model $this->fileStorage->content = $this->content; $this->fileStorage->is_based_on_git = $this->isBasedOnGit; + $this->fileStorage->is_preview_suffix_enabled = $this->isPreviewSuffixEnabled; $this->fileStorage->save(); } else { // Sync from model $this->content = $this->fileStorage->content; $this->isBasedOnGit = $this->fileStorage->is_based_on_git; + $this->isPreviewSuffixEnabled = $this->fileStorage->is_preview_suffix_enabled ?? true; } } @@ -175,6 +181,7 @@ public function submit() // Sync component properties to model $this->fileStorage->content = $this->content; $this->fileStorage->is_based_on_git = $this->isBasedOnGit; + $this->fileStorage->is_preview_suffix_enabled = $this->isPreviewSuffixEnabled; $this->fileStorage->save(); $this->fileStorage->saveStorageOnServer(); $this->dispatch('success', 'File updated.'); @@ -187,9 +194,11 @@ public function submit() } } - public function instantSave() + public function instantSave(): void { - $this->submit(); + $this->authorize('update', $this->resource); + $this->syncData(true); + $this->dispatch('success', 'File updated.'); } public function render() diff --git a/app/Livewire/Project/Shared/Storages/Show.php b/app/Livewire/Project/Shared/Storages/Show.php index 69395a591..eee5a0776 100644 --- a/app/Livewire/Project/Shared/Storages/Show.php +++ b/app/Livewire/Project/Shared/Storages/Show.php @@ -29,10 +29,13 @@ class Show extends Component public ?string $hostPath = null; + public bool $isPreviewSuffixEnabled = true; + protected $rules = [ 'name' => 'required|string', 'mountPath' => 'required|string', 'hostPath' => 'string|nullable', + 'isPreviewSuffixEnabled' => 'required|boolean', ]; protected $validationAttributes = [ @@ -53,11 +56,13 @@ private function syncData(bool $toModel = false): void $this->storage->name = $this->name; $this->storage->mount_path = $this->mountPath; $this->storage->host_path = $this->hostPath; + $this->storage->is_preview_suffix_enabled = $this->isPreviewSuffixEnabled; } else { // Sync FROM model (on load/refresh) $this->name = $this->storage->name; $this->mountPath = $this->storage->mount_path; $this->hostPath = $this->storage->host_path; + $this->isPreviewSuffixEnabled = $this->storage->is_preview_suffix_enabled ?? true; } } @@ -67,6 +72,16 @@ public function mount() $this->isReadOnly = $this->storage->shouldBeReadOnlyInUI(); } + public function instantSave(): void + { + $this->authorize('update', $this->resource); + $this->validate(); + + $this->syncData(true); + $this->storage->save(); + $this->dispatch('success', 'Storage updated successfully'); + } + public function submit() { $this->authorize('update', $this->resource); diff --git a/app/Models/LocalFileVolume.php b/app/Models/LocalFileVolume.php index 9d7095cb5..da58ed2f9 100644 --- a/app/Models/LocalFileVolume.php +++ b/app/Models/LocalFileVolume.php @@ -14,6 +14,7 @@ class LocalFileVolume extends BaseModel // 'mount_path' => 'encrypted', 'content' => 'encrypted', 'is_directory' => 'boolean', + 'is_preview_suffix_enabled' => 'boolean', ]; use HasFactory; diff --git a/app/Models/LocalPersistentVolume.php b/app/Models/LocalPersistentVolume.php index 7126253ea..1721f4afe 100644 --- a/app/Models/LocalPersistentVolume.php +++ b/app/Models/LocalPersistentVolume.php @@ -10,6 +10,10 @@ class LocalPersistentVolume extends Model { protected $guarded = []; + protected $casts = [ + 'is_preview_suffix_enabled' => 'boolean', + ]; + public function resource() { return $this->morphTo('resource'); diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index e84df55f9..cd4928d63 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -789,7 +789,10 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $mainDirectory = str(base_configuration_dir().'/applications/'.$uuid); } $source = replaceLocalSource($source, $mainDirectory); - if ($isPullRequest) { + $isPreviewSuffixEnabled = $foundConfig + ? (bool) data_get($foundConfig, 'is_preview_suffix_enabled', true) + : true; + if ($isPullRequest && $isPreviewSuffixEnabled) { $source = addPreviewDeploymentSuffix($source, $pull_request_id); } LocalFileVolume::updateOrCreate( @@ -1315,19 +1318,19 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int } if (! $isDatabase && $fqdns instanceof Collection && $fqdns->count() > 0) { $shouldGenerateLabelsExactly = $resource->destination->server->settings->generate_exact_labels; - $uuid = $resource->uuid; - $network = data_get($resource, 'destination.network'); + $labelUuid = $resource->uuid; + $labelNetwork = data_get($resource, 'destination.network'); if ($isPullRequest) { - $uuid = "{$resource->uuid}-{$pullRequestId}"; + $labelUuid = "{$resource->uuid}-{$pullRequestId}"; } if ($isPullRequest) { - $network = "{$resource->destination->network}-{$pullRequestId}"; + $labelNetwork = "{$resource->destination->network}-{$pullRequestId}"; } if ($shouldGenerateLabelsExactly) { switch ($server->proxyType()) { case ProxyTypes::TRAEFIK->value: $serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik( - uuid: $uuid, + uuid: $labelUuid, domains: $fqdns, is_force_https_enabled: $originalResource->isForceHttpsEnabled(), serviceLabels: $serviceLabels, @@ -1339,8 +1342,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int break; case ProxyTypes::CADDY->value: $serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy( - network: $network, - uuid: $uuid, + network: $labelNetwork, + uuid: $labelUuid, domains: $fqdns, is_force_https_enabled: $originalResource->isForceHttpsEnabled(), serviceLabels: $serviceLabels, @@ -1354,7 +1357,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int } } else { $serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik( - uuid: $uuid, + uuid: $labelUuid, domains: $fqdns, is_force_https_enabled: $originalResource->isForceHttpsEnabled(), serviceLabels: $serviceLabels, @@ -1364,8 +1367,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int image: $image )); $serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy( - network: $network, - uuid: $uuid, + network: $labelNetwork, + uuid: $labelUuid, domains: $fqdns, is_force_https_enabled: $originalResource->isForceHttpsEnabled(), serviceLabels: $serviceLabels, diff --git a/database/migrations/2026_03_16_000000_add_is_preview_suffix_enabled_to_volume_tables.php b/database/migrations/2026_03_16_000000_add_is_preview_suffix_enabled_to_volume_tables.php new file mode 100644 index 000000000..a1f1d9ea1 --- /dev/null +++ b/database/migrations/2026_03_16_000000_add_is_preview_suffix_enabled_to_volume_tables.php @@ -0,0 +1,30 @@ +boolean('is_preview_suffix_enabled')->default(true)->after('is_based_on_git'); + }); + + Schema::table('local_persistent_volumes', function (Blueprint $table) { + $table->boolean('is_preview_suffix_enabled')->default(true)->after('host_path'); + }); + } + + public function down(): void + { + Schema::table('local_file_volumes', function (Blueprint $table) { + $table->dropColumn('is_preview_suffix_enabled'); + }); + + Schema::table('local_persistent_volumes', function (Blueprint $table) { + $table->dropColumn('is_preview_suffix_enabled'); + }); + } +}; diff --git a/openapi.json b/openapi.json index f5d9813b3..5477420ab 100644 --- a/openapi.json +++ b/openapi.json @@ -3442,6 +3442,167 @@ ] } }, + "\/applications\/{uuid}\/storages": { + "get": { + "tags": [ + "Applications" + ], + "summary": "List Storages", + "description": "List all persistent storages and file storages by application UUID.", + "operationId": "list-storages-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "All storages by application UUID.", + "content": { + "application\/json": { + "schema": { + "properties": { + "persistent_storages": { + "type": "array", + "items": { + "type": "object" + } + }, + "file_storages": { + "type": "array", + "items": { + "type": "object" + } + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Applications" + ], + "summary": "Update Storage", + "description": "Update a persistent storage or file storage by application UUID.", + "operationId": "update-storage-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Storage updated. For read-only storages (from docker-compose or services), only is_preview_suffix_enabled can be updated.", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "id", + "type" + ], + "properties": { + "id": { + "type": "integer", + "description": "The ID of the storage." + }, + "type": { + "type": "string", + "enum": [ + "persistent", + "file" + ], + "description": "The type of storage: persistent or file." + }, + "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)." + } + }, + "type": "object", + "additionalProperties": false + } + } + } + }, + "responses": { + "200": { + "description": "Storage updated.", + "content": { + "application\/json": { + "schema": { + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/cloud-tokens": { "get": { "tags": [ diff --git a/openapi.yaml b/openapi.yaml index 81753544f..dd03f9c42 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2170,6 +2170,108 @@ paths: security: - bearerAuth: [] + '/applications/{uuid}/storages': + get: + tags: + - Applications + summary: 'List Storages' + description: 'List all persistent storages and file storages by application UUID.' + operationId: list-storages-by-application-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + responses: + '200': + description: 'All storages by application UUID.' + content: + application/json: + schema: + properties: + persistent_storages: { type: array, items: { type: object } } + file_storages: { type: array, items: { type: object } } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + patch: + tags: + - Applications + summary: 'Update Storage' + description: 'Update a persistent storage or file storage by application UUID.' + operationId: update-storage-by-application-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + requestBody: + description: 'Storage updated. For read-only storages (from docker-compose or services), only is_preview_suffix_enabled can be updated.' + required: true + content: + application/json: + schema: + required: + - id + - type + properties: + id: + type: integer + description: 'The ID of the storage.' + type: + type: string + enum: [persistent, file] + description: 'The type of storage: persistent or file.' + 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).' + type: object + additionalProperties: false + responses: + '200': + description: 'Storage updated.' + content: + application/json: + schema: + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] /cloud-tokens: get: tags: diff --git a/resources/views/livewire/project/service/file-storage.blade.php b/resources/views/livewire/project/service/file-storage.blade.php index 1dd58fe17..4bd88d761 100644 --- a/resources/views/livewire/project/service/file-storage.blade.php +++ b/resources/views/livewire/project/service/file-storage.blade.php @@ -15,6 +15,15 @@ + @if ($resource instanceof \App\Models\Application) + @can('update', $resource) +
+ +
+ @endcan + @endif
@if (!$isReadOnly) @can('update', $resource) diff --git a/resources/views/livewire/project/shared/storages/show.blade.php b/resources/views/livewire/project/shared/storages/show.blade.php index 694f7d4f2..7fc58000c 100644 --- a/resources/views/livewire/project/shared/storages/show.blade.php +++ b/resources/views/livewire/project/shared/storages/show.blade.php @@ -38,6 +38,15 @@ @endif + @if (!$isService) + @can('update', $resource) +
+ +
+ @endcan + @endif @else @can('update', $resource) @if ($isFirst) @@ -54,6 +63,13 @@ @endif + @if (!$isService) +
+ +
+ @endif
Update diff --git a/routes/api.php b/routes/api.php index 8b28177f3..b02682a5b 100644 --- a/routes/api.php +++ b/routes/api.php @@ -120,6 +120,8 @@ Route::patch('/applications/{uuid}/envs', [ApplicationsController::class, 'update_env_by_uuid'])->middleware(['api.ability:write']); Route::delete('/applications/{uuid}/envs/{env_uuid}', [ApplicationsController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']); Route::get('/applications/{uuid}/logs', [ApplicationsController::class, 'logs_by_uuid'])->middleware(['api.ability:read']); + Route::get('/applications/{uuid}/storages', [ApplicationsController::class, 'storages'])->middleware(['api.ability:read']); + Route::patch('/applications/{uuid}/storages', [ApplicationsController::class, 'update_storage'])->middleware(['api.ability:write']); Route::match(['get', 'post'], '/applications/{uuid}/start', [ApplicationsController::class, 'action_deploy'])->middleware(['api.ability:deploy']); Route::match(['get', 'post'], '/applications/{uuid}/restart', [ApplicationsController::class, 'action_restart'])->middleware(['api.ability:deploy']); diff --git a/tests/Unit/PreviewDeploymentBindMountTest.php b/tests/Unit/PreviewDeploymentBindMountTest.php new file mode 100644 index 000000000..0bf23e4e3 --- /dev/null +++ b/tests/Unit/PreviewDeploymentBindMountTest.php @@ -0,0 +1,176 @@ +destination->server, etc.), making them + * unsuitable for unit tests. Integration tests for those paths belong + * in tests/Feature/. + */ +describe('addPreviewDeploymentSuffix', function () { + it('appends -pr-N suffix for non-zero pull request id', function () { + expect(addPreviewDeploymentSuffix('myvolume', 3))->toBe('myvolume-pr-3'); + }); + + it('returns name unchanged when pull request id is zero', function () { + expect(addPreviewDeploymentSuffix('myvolume', 0))->toBe('myvolume'); + }); + + it('handles pull request id of 1', function () { + expect(addPreviewDeploymentSuffix('scripts', 1))->toBe('scripts-pr-1'); + }); + + it('handles large pull request ids', function () { + expect(addPreviewDeploymentSuffix('data', 9999))->toBe('data-pr-9999'); + }); + + it('handles names with dots and slashes', function () { + expect(addPreviewDeploymentSuffix('./scripts', 2))->toBe('./scripts-pr-2'); + }); + + it('handles names with existing hyphens', function () { + expect(addPreviewDeploymentSuffix('my-volume-name', 5))->toBe('my-volume-name-pr-5'); + }); + + it('handles empty name with non-zero pr id', function () { + expect(addPreviewDeploymentSuffix('', 1))->toBe('-pr-1'); + }); + + it('handles uuid-prefixed volume names', function () { + $uuid = 'abc123_my-volume'; + expect(addPreviewDeploymentSuffix($uuid, 7))->toBe('abc123_my-volume-pr-7'); + }); + + it('defaults pull_request_id to 0', function () { + expect(addPreviewDeploymentSuffix('myvolume'))->toBe('myvolume'); + }); +}); + +describe('sourceIsLocal', function () { + it('detects relative paths starting with dot-slash', function () { + expect(sourceIsLocal(str('./scripts')))->toBeTrue(); + }); + + it('detects absolute paths starting with slash', function () { + expect(sourceIsLocal(str('/var/data')))->toBeTrue(); + }); + + it('detects tilde paths', function () { + expect(sourceIsLocal(str('~/data')))->toBeTrue(); + }); + + it('detects parent directory paths', function () { + expect(sourceIsLocal(str('../config')))->toBeTrue(); + }); + + it('returns false for named volumes', function () { + expect(sourceIsLocal(str('myvolume')))->toBeFalse(); + }); +}); + +describe('replaceLocalSource', function () { + it('replaces dot-slash prefix with target path', function () { + $result = replaceLocalSource(str('./scripts'), str('/app')); + expect((string) $result)->toBe('/app/scripts'); + }); + + it('replaces dot-dot-slash prefix with target path', function () { + $result = replaceLocalSource(str('../config'), str('/app')); + expect((string) $result)->toBe('/app./config'); + }); + + it('replaces tilde prefix with target path', function () { + $result = replaceLocalSource(str('~/data'), str('/app')); + expect((string) $result)->toBe('/app/data'); + }); +}); + +/** + * Source-code structure tests for parser and deployment job. + * + * These verify that key code patterns exist in the parser and deployment job. + * They are intentionally text-based because the parser/deployment functions + * require database-persisted models with deep relationships, making behavioral + * unit tests impractical. Full behavioral coverage should be done via Feature tests. + */ +describe('parser structure: bind mount handling', function () { + it('checks is_preview_suffix_enabled before applying suffix', function () { + $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php'); + + $bindBlockStart = strpos($parsersFile, "if (\$type->value() === 'bind')"); + $volumeBlockStart = strpos($parsersFile, "} elseif (\$type->value() === 'volume')"); + $bindBlock = substr($parsersFile, $bindBlockStart, $volumeBlockStart - $bindBlockStart); + + expect($bindBlock) + ->toContain('$isPreviewSuffixEnabled') + ->toContain('is_preview_suffix_enabled') + ->toContain('addPreviewDeploymentSuffix'); + }); + + it('applies preview suffix to named volumes', function () { + $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php'); + + $volumeBlockStart = strpos($parsersFile, "} elseif (\$type->value() === 'volume')"); + $volumeBlock = substr($parsersFile, $volumeBlockStart, 1000); + + expect($volumeBlock)->toContain('addPreviewDeploymentSuffix'); + }); +}); + +describe('parser structure: label generation uuid isolation', function () { + it('uses labelUuid instead of mutating shared uuid', function () { + $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php'); + + $labelBlockStart = strpos($parsersFile, '$shouldGenerateLabelsExactly = $resource->destination->server->settings->generate_exact_labels;'); + $labelBlock = substr($parsersFile, $labelBlockStart, 300); + + expect($labelBlock) + ->toContain('$labelUuid = $resource->uuid') + ->not->toContain('$uuid = $resource->uuid') + ->not->toContain('$uuid = "{$resource->uuid}'); + }); + + it('uses labelUuid in all proxy label generation calls', function () { + $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php'); + + $labelBlockStart = strpos($parsersFile, '$shouldGenerateLabelsExactly'); + $labelBlockEnd = strpos($parsersFile, "data_forget(\$service, 'volumes.*.content')"); + $labelBlock = substr($parsersFile, $labelBlockStart, $labelBlockEnd - $labelBlockStart); + + expect($labelBlock) + ->toContain('uuid: $labelUuid') + ->not->toContain('uuid: $uuid'); + }); +}); + +describe('deployment job structure: is_preview_suffix_enabled', function () { + it('checks setting in generate_local_persistent_volumes', function () { + $deploymentJobFile = file_get_contents(__DIR__.'/../../app/Jobs/ApplicationDeploymentJob.php'); + + $methodStart = strpos($deploymentJobFile, 'function generate_local_persistent_volumes()'); + $methodEnd = strpos($deploymentJobFile, 'function generate_local_persistent_volumes_only_volume_names()'); + $methodBlock = substr($deploymentJobFile, $methodStart, $methodEnd - $methodStart); + + expect($methodBlock) + ->toContain('is_preview_suffix_enabled') + ->toContain('$isPreviewSuffixEnabled') + ->toContain('addPreviewDeploymentSuffix'); + }); + + it('checks setting in generate_local_persistent_volumes_only_volume_names', function () { + $deploymentJobFile = file_get_contents(__DIR__.'/../../app/Jobs/ApplicationDeploymentJob.php'); + + $methodStart = strpos($deploymentJobFile, 'function generate_local_persistent_volumes_only_volume_names()'); + $methodEnd = strpos($deploymentJobFile, 'function generate_healthcheck_commands()'); + $methodBlock = substr($deploymentJobFile, $methodStart, $methodEnd - $methodStart); + + expect($methodBlock) + ->toContain('is_preview_suffix_enabled') + ->toContain('$isPreviewSuffixEnabled') + ->toContain('addPreviewDeploymentSuffix'); + }); +});