From c658fc9a03704fe62f0cfd7789606ba422dcd6ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlio=20Campos?= Date: Mon, 16 Feb 2026 22:23:30 -0300 Subject: [PATCH] feat(api): add persistent storage endpoints for applications Add CRUD API endpoints for managing persistent storage (volumes) on applications. This closes the gap where the UI (Livewire) could manage storage but the REST API had no surface for it. New endpoints: - GET /applications/{uuid}/storages - POST /applications/{uuid}/storages - GET /applications/{uuid}/storages/{storage_id} - PATCH /applications/{uuid}/storages/{storage_id} - DELETE /applications/{uuid}/storages/{storage_id} Follows the same patterns as the existing env variable endpoints, including OpenAPI annotations, team-scoped authorization, and field validation with extra-field rejection. --- .../Api/ApplicationsController.php | 597 ++++++++++++++++++ routes/api.php | 7 + 2 files changed, 604 insertions(+) diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 57bcc13f6..7a7be1b0d 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -11,6 +11,7 @@ use App\Jobs\DeleteResourceJob; use App\Models\Application; use App\Models\EnvironmentVariable; use App\Models\GithubApp; +use App\Models\LocalPersistentVolume; use App\Models\PrivateKey; use App\Models\Project; use App\Models\Server; @@ -3519,6 +3520,602 @@ class ApplicationsController extends Controller ]); } + #[OA\Get( + summary: 'List Storages', + description: 'List all persistent 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 persistent storages by application UUID.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items( + type: 'object', + properties: [ + 'id' => ['type' => 'integer'], + 'name' => ['type' => 'string'], + 'mount_path' => ['type' => 'string'], + 'host_path' => ['type' => 'string', 'nullable' => true], + 'created_at' => ['type' => 'string'], + 'updated_at' => ['type' => 'string'], + ], + ) + ) + ), + ] + ), + 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) + { + $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); + + $storages = $application->persistentStorages->sortBy('id')->values(); + + return response()->json($storages); + } + + #[OA\Post( + summary: 'Create Storage', + description: 'Create persistent storage for an application by UUID.', + path: '/applications/{uuid}/storages', + operationId: 'create-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( + required: true, + description: 'Storage created.', + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + required: ['name', 'mount_path'], + properties: [ + 'name' => ['type' => 'string', 'description' => 'The name of the storage volume (will be prefixed with app UUID).'], + 'mount_path' => ['type' => 'string', 'description' => 'The mount path inside the container.'], + 'host_path' => ['type' => 'string', 'nullable' => true, 'description' => 'The host path on the server. If not set, a Docker named volume is used.'], + ], + ), + ), + ), + responses: [ + new OA\Response( + response: 201, + description: 'Persistent storage created.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'id' => ['type' => 'integer'], + 'name' => ['type' => 'string'], + 'mount_path' => ['type' => 'string'], + 'host_path' => ['type' => 'string', 'nullable' => true], + ] + ) + ), + ] + ), + 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: 409, + description: 'Storage with this name already exists.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'Storage with this name already exists.'], + ] + ) + ), + ] + ), + ] + )] + public function create_storage(Request $request) + { + $allowedFields = ['name', 'mount_path', 'host_path']; + $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('update', $application); + + $validator = customApiValidator($request->all(), [ + 'name' => 'string|required', + 'mount_path' => 'string|required', + 'host_path' => 'string|nullable', + ]); + + $extraFields = array_diff(array_keys($request->all()), $allowedFields); + 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); + } + + $name = $application->uuid.'-'.str($request->name)->trim()->value; + + $existing = LocalPersistentVolume::where('name', $name) + ->where('resource_id', $application->id) + ->where('resource_type', $application->getMorphClass()) + ->first(); + + if ($existing) { + return response()->json([ + 'message' => 'Storage with this name already exists.', + ], 409); + } + + $storage = LocalPersistentVolume::create([ + 'name' => $name, + 'mount_path' => $request->mount_path, + 'host_path' => $request->host_path, + 'resource_id' => $application->id, + 'resource_type' => $application->getMorphClass(), + ]); + + return response()->json([ + 'id' => $storage->id, + 'name' => $storage->name, + 'mount_path' => $storage->mount_path, + 'host_path' => $storage->host_path, + ])->setStatusCode(201); + } + + #[OA\Get( + summary: 'Get Storage', + description: 'Get persistent storage by ID for an application.', + path: '/applications/{uuid}/storages/{storage_id}', + operationId: 'get-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', + ) + ), + new OA\Parameter( + name: 'storage_id', + in: 'path', + description: 'ID of the persistent storage.', + required: true, + schema: new OA\Schema( + type: 'integer', + ) + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Persistent storage details.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'id' => ['type' => 'integer'], + 'name' => ['type' => 'string'], + 'mount_path' => ['type' => 'string'], + 'host_path' => ['type' => 'string', 'nullable' => true], + 'created_at' => ['type' => 'string'], + 'updated_at' => ['type' => 'string'], + ] + ) + ), + ] + ), + 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 storage_by_id(Request $request) + { + $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); + + $storage = LocalPersistentVolume::where('id', $request->storage_id) + ->where('resource_id', $application->id) + ->where('resource_type', $application->getMorphClass()) + ->first(); + + if (! $storage) { + return response()->json([ + 'message' => 'Storage not found.', + ], 404); + } + + return response()->json($storage); + } + + #[OA\Patch( + summary: 'Update Storage', + description: 'Update persistent storage by ID for an application.', + path: '/applications/{uuid}/storages/{storage_id}', + 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', + ) + ), + new OA\Parameter( + name: 'storage_id', + in: 'path', + description: 'ID of the persistent storage.', + required: true, + schema: new OA\Schema( + type: 'integer', + ) + ), + ], + requestBody: new OA\RequestBody( + required: true, + description: 'Storage updated.', + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'name' => ['type' => 'string', 'description' => 'The name of the storage volume (will be prefixed with app UUID).'], + 'mount_path' => ['type' => 'string', 'description' => 'The mount path inside the container.'], + 'host_path' => ['type' => 'string', 'nullable' => true, 'description' => 'The host path on the server. If not set, a Docker named volume is used.'], + ], + ), + ), + ), + responses: [ + new OA\Response( + response: 200, + description: 'Persistent storage updated.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'id' => ['type' => 'integer'], + 'name' => ['type' => 'string'], + 'mount_path' => ['type' => 'string'], + 'host_path' => ['type' => 'string', 'nullable' => true], + ] + ) + ), + ] + ), + 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: 409, + description: 'Storage with this name already exists.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'Storage with this name already exists.'], + ] + ) + ), + ] + ), + ] + )] + public function update_storage(Request $request) + { + $allowedFields = ['name', 'mount_path', 'host_path']; + $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('update', $application); + + $storage = LocalPersistentVolume::where('id', $request->storage_id) + ->where('resource_id', $application->id) + ->where('resource_type', $application->getMorphClass()) + ->first(); + + if (! $storage) { + return response()->json([ + 'message' => 'Storage not found.', + ], 404); + } + + $validator = customApiValidator($request->all(), [ + 'name' => 'string', + 'mount_path' => 'string', + 'host_path' => 'string|nullable', + ]); + + $extraFields = array_diff(array_keys($request->all()), $allowedFields); + 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->has('name')) { + $newName = $application->uuid.'-'.str($request->name)->trim()->value; + + $existing = LocalPersistentVolume::where('name', $newName) + ->where('resource_id', $application->id) + ->where('resource_type', $application->getMorphClass()) + ->where('id', '!=', $storage->id) + ->first(); + + if ($existing) { + return response()->json([ + 'message' => 'Storage with this name already exists.', + ], 409); + } + + $storage->name = $newName; + } + + if ($request->has('mount_path')) { + $storage->mount_path = $request->mount_path; + } + + if ($request->has('host_path')) { + $storage->host_path = $request->host_path; + } + + $storage->save(); + + return response()->json([ + 'id' => $storage->id, + 'name' => $storage->name, + 'mount_path' => $storage->mount_path, + 'host_path' => $storage->host_path, + ]); + } + + #[OA\Delete( + summary: 'Delete Storage', + description: 'Delete persistent storage by ID for an application.', + path: '/applications/{uuid}/storages/{storage_id}', + operationId: 'delete-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', + ) + ), + new OA\Parameter( + name: 'storage_id', + in: 'path', + description: 'ID of the persistent storage.', + required: true, + schema: new OA\Schema( + type: 'integer', + ) + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Persistent storage deleted.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'Storage deleted.'], + ] + ) + ), + ] + ), + 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 delete_storage(Request $request) + { + $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('update', $application); + + $storage = LocalPersistentVolume::where('id', $request->storage_id) + ->where('resource_id', $application->id) + ->where('resource_type', $application->getMorphClass()) + ->first(); + + if (! $storage) { + return response()->json([ + 'message' => 'Storage not found.', + ], 404); + } + + $storage->delete(); + + return response()->json([ + 'message' => 'Storage deleted.', + ]); + } + #[OA\Get( summary: 'Start', description: 'Start application. `Post` request is also accepted.', diff --git a/routes/api.php b/routes/api.php index 8ff1fd1cc..7a563f3cc 100644 --- a/routes/api.php +++ b/routes/api.php @@ -118,6 +118,13 @@ Route::group([ Route::patch('/applications/{uuid}/envs/bulk', [ApplicationsController::class, 'create_bulk_envs'])->middleware(['api.ability:write']); 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}/storages', [ApplicationsController::class, 'storages'])->middleware(['api.ability:read']); + Route::post('/applications/{uuid}/storages', [ApplicationsController::class, 'create_storage'])->middleware(['api.ability:write']); + Route::get('/applications/{uuid}/storages/{storage_id}', [ApplicationsController::class, 'storage_by_id'])->middleware(['api.ability:read']); + Route::patch('/applications/{uuid}/storages/{storage_id}', [ApplicationsController::class, 'update_storage'])->middleware(['api.ability:write']); + Route::delete('/applications/{uuid}/storages/{storage_id}', [ApplicationsController::class, 'delete_storage'])->middleware(['api.ability:write']); + Route::get('/applications/{uuid}/logs', [ApplicationsController::class, 'logs_by_uuid'])->middleware(['api.ability:read']); Route::match(['get', 'post'], '/applications/{uuid}/start', [ApplicationsController::class, 'action_deploy'])->middleware(['api.ability:write']);