diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 3444f9f14..66f6a1ef8 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -11,6 +11,8 @@ use App\Models\Application; use App\Models\EnvironmentVariable; use App\Models\GithubApp; +use App\Models\LocalFileVolume; +use App\Models\LocalPersistentVolume; use App\Models\PrivateKey; use App\Models\Project; use App\Models\Server; @@ -4026,9 +4028,10 @@ public function storages(Request $request): JsonResponse mediaType: 'application/json', schema: new OA\Schema( type: 'object', - required: ['id', 'type'], + required: ['type'], properties: [ - 'id' => ['type' => 'integer', 'description' => 'The ID of the storage.'], + 'uuid' => ['type' => 'string', 'description' => 'The UUID of the storage (preferred).'], + 'id' => ['type' => 'integer', 'description' => 'The ID of the storage (deprecated, use uuid instead).'], '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).'], @@ -4078,7 +4081,7 @@ public function update_storage(Request $request): JsonResponse return $return; } - $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first(); + $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first(); if (! $application) { return response()->json([ @@ -4089,7 +4092,8 @@ public function update_storage(Request $request): JsonResponse $this->authorize('update', $application); $validator = customApiValidator($request->all(), [ - 'id' => 'required|integer', + 'uuid' => 'string', + 'id' => 'integer', 'type' => 'required|string|in:persistent,file', 'is_preview_suffix_enabled' => 'boolean', 'name' => 'string', @@ -4098,7 +4102,7 @@ public function update_storage(Request $request): JsonResponse 'content' => 'string|nullable', ]); - $allAllowedFields = ['id', 'type', 'is_preview_suffix_enabled', 'name', 'mount_path', 'host_path', 'content']; + $allAllowedFields = ['uuid', '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(); @@ -4114,10 +4118,23 @@ public function update_storage(Request $request): JsonResponse ], 422); } + $storageUuid = $request->input('uuid'); + $storageId = $request->input('id'); + + if (! $storageUuid && ! $storageId) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['uuid' => 'Either uuid or id is required.'], + ], 422); + } + + $lookupField = $storageUuid ? 'uuid' : 'id'; + $lookupValue = $storageUuid ?? $storageId; + if ($request->type === 'persistent') { - $storage = $application->persistentStorages->where('id', $request->id)->first(); + $storage = $application->persistentStorages->where($lookupField, $lookupValue)->first(); } else { - $storage = $application->fileStorages->where('id', $request->id)->first(); + $storage = $application->fileStorages->where($lookupField, $lookupValue)->first(); } if (! $storage) { @@ -4183,4 +4200,254 @@ public function update_storage(Request $request): JsonResponse return response()->json($storage); } + + #[OA\Post( + summary: 'Create Storage', + description: 'Create a persistent storage or file storage for an application.', + 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, + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + required: ['type', 'mount_path'], + properties: [ + '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).'], + ], + additionalProperties: false, + ), + ), + ], + ), + responses: [ + new OA\Response( + response: 201, + description: 'Storage created.', + 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 create_storage(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $return = validateIncomingRequest($request); + if ($return instanceof 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(), [ + 'type' => 'required|string|in:persistent,file', + 'name' => 'string', + 'mount_path' => 'required|string', + 'host_path' => 'string|nullable', + 'content' => 'string|nullable', + 'is_directory' => 'boolean', + 'fs_path' => 'string', + ]); + + $allAllowedFields = ['type', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'fs_path']; + $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') { + if (! $request->name) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['name' => 'The name field is required for persistent storages.'], + ], 422); + } + + $typeSpecificInvalidFields = array_intersect(['content', 'is_directory', 'fs_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 'persistent'."]), + ], 422); + } + + $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(), + ]); + + return response()->json($storage, 201); + } + + // File storage + $typeSpecificInvalidFields = 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 'file'."]), + ], 422); + } + + $isDirectory = $request->boolean('is_directory', false); + + if ($isDirectory) { + if (! $request->fs_path) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['fs_path' => 'The fs_path field is required for directory mounts.'], + ], 422); + } + + $fsPath = str($request->fs_path)->trim()->start('/')->value(); + $mountPath = str($request->mount_path)->trim()->start('/')->value(); + + validateShellSafePath($fsPath, 'storage source path'); + validateShellSafePath($mountPath, 'storage destination path'); + + $storage = LocalFileVolume::create([ + 'fs_path' => $fsPath, + 'mount_path' => $mountPath, + 'is_directory' => true, + 'resource_id' => $application->id, + 'resource_type' => get_class($application), + ]); + } else { + $mountPath = str($request->mount_path)->trim()->start('/')->value(); + $fsPath = application_configuration_dir().'/'.$application->uuid.$mountPath; + + $storage = LocalFileVolume::create([ + 'fs_path' => $fsPath, + 'mount_path' => $mountPath, + 'content' => $request->content, + 'is_directory' => false, + 'resource_id' => $application->id, + 'resource_type' => get_class($application), + ]); + } + + return response()->json($storage, 201); + } + + #[OA\Delete( + summary: 'Delete Storage', + description: 'Delete a persistent storage or file storage by application UUID.', + path: '/applications/{uuid}/storages/{storage_uuid}', + 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_uuid', + in: 'path', + description: 'UUID of the storage.', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response(response: 200, description: 'Storage deleted.', content: new OA\JsonContent( + properties: [new OA\Property(property: 'message', 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'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ] + )] + public function delete_storage(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('update', $application); + + $storageUuid = $request->route('storage_uuid'); + + $storage = $application->persistentStorages->where('uuid', $storageUuid)->first(); + if (! $storage) { + $storage = $application->fileStorages->where('uuid', $storageUuid)->first(); + } + + if (! $storage) { + return response()->json(['message' => 'Storage not found.'], 404); + } + + if ($storage->shouldBeReadOnlyInUI()) { + return response()->json([ + 'message' => 'This storage is read-only (managed by docker-compose or service definition) and cannot be deleted.', + ], 422); + } + + if ($storage instanceof LocalFileVolume) { + $storage->deleteStorageOnServer(); + } + + $storage->delete(); + + return response()->json(['message' => 'Storage deleted.']); + } } diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 6ad18d872..700055fcc 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -12,11 +12,14 @@ use App\Jobs\DatabaseBackupJob; use App\Jobs\DeleteResourceJob; use App\Models\EnvironmentVariable; +use App\Models\LocalFileVolume; +use App\Models\LocalPersistentVolume; use App\Models\Project; use App\Models\S3Storage; use App\Models\ScheduledDatabaseBackup; use App\Models\Server; use App\Models\StandalonePostgresql; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; use OpenApi\Attributes as OA; @@ -3298,4 +3301,520 @@ public function delete_env_by_uuid(Request $request) return response()->json(['message' => 'Environment variable deleted.']); } + + #[OA\Get( + summary: 'List Storages', + description: 'List all persistent storages and file storages by database UUID.', + path: '/databases/{uuid}/storages', + operationId: 'list-storages-by-database-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Databases'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the database.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'All storages by database 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(); + } + + $database = queryDatabaseByUuidWithinTeam($request->uuid, $teamId); + if (! $database) { + return response()->json(['message' => 'Database not found.'], 404); + } + + $this->authorize('view', $database); + + $persistentStorages = $database->persistentStorages->sortBy('id')->values(); + $fileStorages = $database->fileStorages->sortBy('id')->values(); + + return response()->json([ + 'persistent_storages' => $persistentStorages, + 'file_storages' => $fileStorages, + ]); + } + + #[OA\Post( + summary: 'Create Storage', + description: 'Create a persistent storage or file storage for a database.', + path: '/databases/{uuid}/storages', + operationId: 'create-storage-by-database-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Databases'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the database.', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + requestBody: new OA\RequestBody( + required: true, + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + required: ['type', 'mount_path'], + properties: [ + '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).'], + ], + additionalProperties: false, + ), + ), + ], + ), + responses: [ + new OA\Response( + response: 201, + description: 'Storage created.', + 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 create_storage(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + + $database = queryDatabaseByUuidWithinTeam($request->uuid, $teamId); + if (! $database) { + return response()->json(['message' => 'Database not found.'], 404); + } + + $this->authorize('update', $database); + + $validator = customApiValidator($request->all(), [ + 'type' => 'required|string|in:persistent,file', + 'name' => 'string', + 'mount_path' => 'required|string', + 'host_path' => 'string|nullable', + 'content' => 'string|nullable', + 'is_directory' => 'boolean', + 'fs_path' => 'string', + ]); + + $allAllowedFields = ['type', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'fs_path']; + $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') { + if (! $request->name) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['name' => 'The name field is required for persistent storages.'], + ], 422); + } + + $typeSpecificInvalidFields = array_intersect(['content', 'is_directory', 'fs_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 'persistent'."]), + ], 422); + } + + $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(), + ]); + + return response()->json($storage, 201); + } + + // File storage + $typeSpecificInvalidFields = 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 'file'."]), + ], 422); + } + + $isDirectory = $request->boolean('is_directory', false); + + if ($isDirectory) { + if (! $request->fs_path) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['fs_path' => 'The fs_path field is required for directory mounts.'], + ], 422); + } + + $fsPath = str($request->fs_path)->trim()->start('/')->value(); + $mountPath = str($request->mount_path)->trim()->start('/')->value(); + + validateShellSafePath($fsPath, 'storage source path'); + validateShellSafePath($mountPath, 'storage destination path'); + + $storage = LocalFileVolume::create([ + 'fs_path' => $fsPath, + 'mount_path' => $mountPath, + 'is_directory' => true, + 'resource_id' => $database->id, + 'resource_type' => get_class($database), + ]); + } else { + $mountPath = str($request->mount_path)->trim()->start('/')->value(); + $fsPath = database_configuration_dir().'/'.$database->uuid.$mountPath; + + $storage = LocalFileVolume::create([ + 'fs_path' => $fsPath, + 'mount_path' => $mountPath, + 'content' => $request->content, + 'is_directory' => false, + 'resource_id' => $database->id, + 'resource_type' => get_class($database), + ]); + } + + return response()->json($storage, 201); + } + + #[OA\Patch( + summary: 'Update Storage', + description: 'Update a persistent storage or file storage by database UUID.', + path: '/databases/{uuid}/storages', + operationId: 'update-storage-by-database-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Databases'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the database.', + 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: ['type'], + properties: [ + 'uuid' => ['type' => 'string', 'description' => 'The UUID of the storage (preferred).'], + 'id' => ['type' => 'integer', 'description' => 'The ID of the storage (deprecated, use uuid instead).'], + '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; + } + + $database = queryDatabaseByUuidWithinTeam($request->route('uuid'), $teamId); + if (! $database) { + return response()->json(['message' => 'Database not found.'], 404); + } + + $this->authorize('update', $database); + + $validator = customApiValidator($request->all(), [ + 'uuid' => 'string', + 'id' => '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 = ['uuid', '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); + } + + $storageUuid = $request->input('uuid'); + $storageId = $request->input('id'); + + if (! $storageUuid && ! $storageId) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['uuid' => 'Either uuid or id is required.'], + ], 422); + } + + $lookupField = $storageUuid ? 'uuid' : 'id'; + $lookupValue = $storageUuid ?? $storageId; + + if ($request->type === 'persistent') { + $storage = $database->persistentStorages->where($lookupField, $lookupValue)->first(); + } else { + $storage = $database->fileStorages->where($lookupField, $lookupValue)->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); + } + + #[OA\Delete( + summary: 'Delete Storage', + description: 'Delete a persistent storage or file storage by database UUID.', + path: '/databases/{uuid}/storages/{storage_uuid}', + operationId: 'delete-storage-by-database-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Databases'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the database.', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'storage_uuid', + in: 'path', + description: 'UUID of the storage.', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response(response: 200, description: 'Storage deleted.', content: new OA\JsonContent( + properties: [new OA\Property(property: 'message', 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'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ] + )] + public function delete_storage(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $database = queryDatabaseByUuidWithinTeam($request->uuid, $teamId); + if (! $database) { + return response()->json(['message' => 'Database not found.'], 404); + } + + $this->authorize('update', $database); + + $storageUuid = $request->route('storage_uuid'); + + $storage = $database->persistentStorages->where('uuid', $storageUuid)->first(); + if (! $storage) { + $storage = $database->fileStorages->where('uuid', $storageUuid)->first(); + } + + if (! $storage) { + return response()->json(['message' => 'Storage not found.'], 404); + } + + if ($storage->shouldBeReadOnlyInUI()) { + return response()->json([ + 'message' => 'This storage is read-only (managed by docker-compose or service definition) and cannot be deleted.', + ], 422); + } + + if ($storage instanceof LocalFileVolume) { + $storage->deleteStorageOnServer(); + } + + $storage->delete(); + + return response()->json(['message' => 'Storage deleted.']); + } } diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 4caee26dd..ca565ece0 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -8,9 +8,12 @@ use App\Http\Controllers\Controller; use App\Jobs\DeleteResourceJob; use App\Models\EnvironmentVariable; +use App\Models\LocalFileVolume; +use App\Models\LocalPersistentVolume; use App\Models\Project; use App\Models\Server; use App\Models\Service; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Validator; use OpenApi\Attributes as OA; @@ -1849,4 +1852,606 @@ public function action_restart(Request $request) 200 ); } + + #[OA\Get( + summary: 'List Storages', + description: 'List all persistent storages and file storages by service UUID.', + path: '/services/{uuid}/storages', + operationId: 'list-storages-by-service-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Services'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the service.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'All storages by service 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(); + } + $service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first(); + + if (! $service) { + return response()->json([ + 'message' => 'Service not found.', + ], 404); + } + + $this->authorize('view', $service); + + $persistentStorages = collect(); + $fileStorages = collect(); + + foreach ($service->applications as $app) { + $persistentStorages = $persistentStorages->merge( + $app->persistentStorages->map(fn ($s) => $s->setAttribute('resource_uuid', $app->uuid)->setAttribute('resource_type', 'application')) + ); + $fileStorages = $fileStorages->merge( + $app->fileStorages->map(fn ($s) => $s->setAttribute('resource_uuid', $app->uuid)->setAttribute('resource_type', 'application')) + ); + } + foreach ($service->databases as $db) { + $persistentStorages = $persistentStorages->merge( + $db->persistentStorages->map(fn ($s) => $s->setAttribute('resource_uuid', $db->uuid)->setAttribute('resource_type', 'database')) + ); + $fileStorages = $fileStorages->merge( + $db->fileStorages->map(fn ($s) => $s->setAttribute('resource_uuid', $db->uuid)->setAttribute('resource_type', 'database')) + ); + } + + return response()->json([ + 'persistent_storages' => $persistentStorages->sortBy('id')->values(), + 'file_storages' => $fileStorages->sortBy('id')->values(), + ]); + } + + #[OA\Post( + summary: 'Create Storage', + description: 'Create a persistent storage or file storage for a service sub-resource.', + path: '/services/{uuid}/storages', + operationId: 'create-storage-by-service-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Services'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the service.', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + requestBody: new OA\RequestBody( + required: true, + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + required: ['type', 'mount_path', 'resource_uuid'], + properties: [ + 'type' => ['type' => 'string', 'enum' => ['persistent', 'file'], 'description' => 'The type of storage.'], + '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).'], + ], + additionalProperties: false, + ), + ), + ], + ), + responses: [ + new OA\Response( + response: 201, + description: 'Storage created.', + 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 create_storage(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + + $service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first(); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + $this->authorize('update', $service); + + $validator = customApiValidator($request->all(), [ + 'type' => 'required|string|in:persistent,file', + 'resource_uuid' => 'required|string', + 'name' => 'string', + 'mount_path' => 'required|string', + 'host_path' => 'string|nullable', + 'content' => 'string|nullable', + 'is_directory' => 'boolean', + 'fs_path' => 'string', + ]); + + $allAllowedFields = ['type', 'resource_uuid', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'fs_path']; + $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); + } + + $subResource = $service->applications()->where('uuid', $request->resource_uuid)->first(); + if (! $subResource) { + $subResource = $service->databases()->where('uuid', $request->resource_uuid)->first(); + } + if (! $subResource) { + return response()->json(['message' => 'Service resource not found.'], 404); + } + + if ($request->type === 'persistent') { + if (! $request->name) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['name' => 'The name field is required for persistent storages.'], + ], 422); + } + + $typeSpecificInvalidFields = array_intersect(['content', 'is_directory', 'fs_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 'persistent'."]), + ], 422); + } + + $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(), + ]); + + return response()->json($storage, 201); + } + + // File storage + $typeSpecificInvalidFields = 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 'file'."]), + ], 422); + } + + $isDirectory = $request->boolean('is_directory', false); + + if ($isDirectory) { + if (! $request->fs_path) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['fs_path' => 'The fs_path field is required for directory mounts.'], + ], 422); + } + + $fsPath = str($request->fs_path)->trim()->start('/')->value(); + $mountPath = str($request->mount_path)->trim()->start('/')->value(); + + validateShellSafePath($fsPath, 'storage source path'); + validateShellSafePath($mountPath, 'storage destination path'); + + $storage = LocalFileVolume::create([ + 'fs_path' => $fsPath, + 'mount_path' => $mountPath, + 'is_directory' => true, + 'resource_id' => $subResource->id, + 'resource_type' => get_class($subResource), + ]); + } else { + $mountPath = str($request->mount_path)->trim()->start('/')->value(); + $fsPath = service_configuration_dir().'/'.$service->uuid.$mountPath; + + $storage = LocalFileVolume::create([ + 'fs_path' => $fsPath, + 'mount_path' => $mountPath, + 'content' => $request->content, + 'is_directory' => false, + 'resource_id' => $subResource->id, + 'resource_type' => get_class($subResource), + ]); + } + + return response()->json($storage, 201); + } + + #[OA\Patch( + summary: 'Update Storage', + description: 'Update a persistent storage or file storage by service UUID.', + path: '/services/{uuid}/storages', + operationId: 'update-storage-by-service-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Services'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the service.', + 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: ['type'], + properties: [ + 'uuid' => ['type' => 'string', 'description' => 'The UUID of the storage (preferred).'], + 'id' => ['type' => 'integer', 'description' => 'The ID of the storage (deprecated, use uuid instead).'], + '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 JsonResponse) { + return $return; + } + + $service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->route('uuid'))->first(); + + if (! $service) { + return response()->json([ + 'message' => 'Service not found.', + ], 404); + } + + $this->authorize('update', $service); + + $validator = customApiValidator($request->all(), [ + 'uuid' => 'string', + 'id' => '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 = ['uuid', '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); + } + + $storageUuid = $request->input('uuid'); + $storageId = $request->input('id'); + + if (! $storageUuid && ! $storageId) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['uuid' => 'Either uuid or id is required.'], + ], 422); + } + + $lookupField = $storageUuid ? 'uuid' : 'id'; + $lookupValue = $storageUuid ?? $storageId; + + $storage = null; + if ($request->type === 'persistent') { + foreach ($service->applications as $app) { + $storage = $app->persistentStorages->where($lookupField, $lookupValue)->first(); + if ($storage) { + break; + } + } + if (! $storage) { + foreach ($service->databases as $db) { + $storage = $db->persistentStorages->where($lookupField, $lookupValue)->first(); + if ($storage) { + break; + } + } + } + } else { + foreach ($service->applications as $app) { + $storage = $app->fileStorages->where($lookupField, $lookupValue)->first(); + if ($storage) { + break; + } + } + if (! $storage) { + foreach ($service->databases as $db) { + $storage = $db->fileStorages->where($lookupField, $lookupValue)->first(); + if ($storage) { + break; + } + } + } + } + + 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); + } + + #[OA\Delete( + summary: 'Delete Storage', + description: 'Delete a persistent storage or file storage by service UUID.', + path: '/services/{uuid}/storages/{storage_uuid}', + operationId: 'delete-storage-by-service-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Services'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the service.', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'storage_uuid', + in: 'path', + description: 'UUID of the storage.', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response(response: 200, description: 'Storage deleted.', content: new OA\JsonContent( + properties: [new OA\Property(property: 'message', 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'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ] + )] + public function delete_storage(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first(); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + $this->authorize('update', $service); + + $storageUuid = $request->route('storage_uuid'); + + $storage = null; + foreach ($service->applications as $app) { + $storage = $app->persistentStorages->where('uuid', $storageUuid)->first(); + if ($storage) { + break; + } + } + if (! $storage) { + foreach ($service->databases as $db) { + $storage = $db->persistentStorages->where('uuid', $storageUuid)->first(); + if ($storage) { + break; + } + } + } + if (! $storage) { + foreach ($service->applications as $app) { + $storage = $app->fileStorages->where('uuid', $storageUuid)->first(); + if ($storage) { + break; + } + } + } + if (! $storage) { + foreach ($service->databases as $db) { + $storage = $db->fileStorages->where('uuid', $storageUuid)->first(); + if ($storage) { + break; + } + } + } + + if (! $storage) { + return response()->json(['message' => 'Storage not found.'], 404); + } + + if ($storage->shouldBeReadOnlyInUI()) { + return response()->json([ + 'message' => 'This storage is read-only (managed by docker-compose or service definition) and cannot be deleted.', + ], 422); + } + + if ($storage instanceof LocalFileVolume) { + $storage->deleteStorageOnServer(); + } + + $storage->delete(); + + return response()->json(['message' => 'Storage deleted.']); + } } diff --git a/app/Models/LocalPersistentVolume.php b/app/Models/LocalPersistentVolume.php index 1721f4afe..9d539f8ec 100644 --- a/app/Models/LocalPersistentVolume.php +++ b/app/Models/LocalPersistentVolume.php @@ -3,10 +3,9 @@ namespace App\Models; use Illuminate\Database\Eloquent\Casts\Attribute; -use Illuminate\Database\Eloquent\Model; use Symfony\Component\Yaml\Yaml; -class LocalPersistentVolume extends Model +class LocalPersistentVolume extends BaseModel { protected $guarded = []; diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 26aa21a7b..ce9ab5283 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -339,7 +339,9 @@ function generate_application_name(string $git_repository, string $git_branch, ? $cuid = new Cuid2; } - return Str::kebab("$git_repository:$git_branch-$cuid"); + $repo_name = str_contains($git_repository, '/') ? last(explode('/', $git_repository)) : $git_repository; + + return Str::kebab("$repo_name:$git_branch-$cuid"); } /** diff --git a/database/migrations/2026_03_23_101720_add_uuid_to_local_persistent_volumes_table.php b/database/migrations/2026_03_23_101720_add_uuid_to_local_persistent_volumes_table.php new file mode 100644 index 000000000..6b4fb690d --- /dev/null +++ b/database/migrations/2026_03_23_101720_add_uuid_to_local_persistent_volumes_table.php @@ -0,0 +1,39 @@ +string('uuid')->nullable()->after('id'); + }); + + DB::table('local_persistent_volumes') + ->whereNull('uuid') + ->orderBy('id') + ->chunk(1000, function ($volumes) { + foreach ($volumes as $volume) { + DB::table('local_persistent_volumes') + ->where('id', $volume->id) + ->update(['uuid' => (string) new Cuid2]); + } + }); + + Schema::table('local_persistent_volumes', function (Blueprint $table) { + $table->string('uuid')->nullable(false)->unique()->change(); + }); + } + + public function down(): void + { + Schema::table('local_persistent_volumes', function (Blueprint $table) { + $table->dropColumn('uuid'); + }); + } +}; diff --git a/openapi.json b/openapi.json index d119176a1..aec5a2843 100644 --- a/openapi.json +++ b/openapi.json @@ -3502,6 +3502,105 @@ } ] }, + "post": { + "tags": [ + "Applications" + ], + "summary": "Create Storage", + "description": "Create a persistent storage or file storage for an application.", + "operationId": "create-storage-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "type", + "mount_path" + ], + "properties": { + "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)." + } + }, + "type": "object", + "additionalProperties": false + } + } + } + }, + "responses": { + "201": { + "description": "Storage created.", + "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": [] + } + ] + }, "patch": { "tags": [ "Applications" @@ -3527,13 +3626,16 @@ "application\/json": { "schema": { "required": [ - "id", "type" ], "properties": { + "uuid": { + "type": "string", + "description": "The UUID of the storage (preferred)." + }, "id": { "type": "integer", - "description": "The ID of the storage." + "description": "The ID of the storage (deprecated, use uuid instead)." }, "type": { "type": "string", @@ -3603,6 +3705,70 @@ ] } }, + "\/applications\/{uuid}\/storages\/{storage_uuid}": { + "delete": { + "tags": [ + "Applications" + ], + "summary": "Delete Storage", + "description": "Delete a persistent storage or file storage by application UUID.", + "operationId": "delete-storage-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "storage_uuid", + "in": "path", + "description": "UUID of the storage.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Storage deleted.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string" + } + }, + "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": [ @@ -6513,6 +6679,333 @@ ] } }, + "\/databases\/{uuid}\/storages": { + "get": { + "tags": [ + "Databases" + ], + "summary": "List Storages", + "description": "List all persistent storages and file storages by database UUID.", + "operationId": "list-storages-by-database-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "All storages by database 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": [] + } + ] + }, + "post": { + "tags": [ + "Databases" + ], + "summary": "Create Storage", + "description": "Create a persistent storage or file storage for a database.", + "operationId": "create-storage-by-database-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "type", + "mount_path" + ], + "properties": { + "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)." + } + }, + "type": "object", + "additionalProperties": false + } + } + } + }, + "responses": { + "201": { + "description": "Storage created.", + "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": [] + } + ] + }, + "patch": { + "tags": [ + "Databases" + ], + "summary": "Update Storage", + "description": "Update a persistent storage or file storage by database UUID.", + "operationId": "update-storage-by-database-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "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": [ + "type" + ], + "properties": { + "uuid": { + "type": "string", + "description": "The UUID of the storage (preferred)." + }, + "id": { + "type": "integer", + "description": "The ID of the storage (deprecated, use uuid instead)." + }, + "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": [] + } + ] + } + }, + "\/databases\/{uuid}\/storages\/{storage_uuid}": { + "delete": { + "tags": [ + "Databases" + ], + "summary": "Delete Storage", + "description": "Delete a persistent storage or file storage by database UUID.", + "operationId": "delete-storage-by-database-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "storage_uuid", + "in": "path", + "description": "UUID of the storage.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Storage deleted.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/deployments": { "get": { "tags": [ @@ -11238,6 +11731,338 @@ ] } }, + "\/services\/{uuid}\/storages": { + "get": { + "tags": [ + "Services" + ], + "summary": "List Storages", + "description": "List all persistent storages and file storages by service UUID.", + "operationId": "list-storages-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "All storages by service 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": [] + } + ] + }, + "post": { + "tags": [ + "Services" + ], + "summary": "Create Storage", + "description": "Create a persistent storage or file storage for a service sub-resource.", + "operationId": "create-storage-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "type", + "mount_path", + "resource_uuid" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "persistent", + "file" + ], + "description": "The type of storage." + }, + "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)." + } + }, + "type": "object", + "additionalProperties": false + } + } + } + }, + "responses": { + "201": { + "description": "Storage created.", + "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": [] + } + ] + }, + "patch": { + "tags": [ + "Services" + ], + "summary": "Update Storage", + "description": "Update a persistent storage or file storage by service UUID.", + "operationId": "update-storage-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "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": [ + "type" + ], + "properties": { + "uuid": { + "type": "string", + "description": "The UUID of the storage (preferred)." + }, + "id": { + "type": "integer", + "description": "The ID of the storage (deprecated, use uuid instead)." + }, + "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": [] + } + ] + } + }, + "\/services\/{uuid}\/storages\/{storage_uuid}": { + "delete": { + "tags": [ + "Services" + ], + "summary": "Delete Storage", + "description": "Delete a persistent storage or file storage by service UUID.", + "operationId": "delete-storage-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "storage_uuid", + "in": "path", + "description": "UUID of the storage.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Storage deleted.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/teams": { "get": { "tags": [ diff --git a/openapi.yaml b/openapi.yaml index 7064be28a..93038ce80 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2204,6 +2204,73 @@ paths: security: - bearerAuth: [] + post: + tags: + - Applications + summary: 'Create Storage' + description: 'Create a persistent storage or file storage for an application.' + operationId: create-storage-by-application-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + required: + - type + - mount_path + properties: + 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).' + type: object + additionalProperties: false + responses: + '201': + description: 'Storage created.' + 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: [] patch: tags: - Applications @@ -2225,12 +2292,14 @@ paths: application/json: schema: required: - - id - type properties: + uuid: + type: string + description: 'The UUID of the storage (preferred).' id: type: integer - description: 'The ID of the storage.' + description: 'The ID of the storage (deprecated, use uuid instead).' type: type: string enum: [persistent, file] @@ -2272,6 +2341,48 @@ paths: security: - bearerAuth: [] + '/applications/{uuid}/storages/{storage_uuid}': + delete: + tags: + - Applications + summary: 'Delete Storage' + description: 'Delete a persistent storage or file storage by application UUID.' + operationId: delete-storage-by-application-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + - + name: storage_uuid + in: path + description: 'UUID of the storage.' + required: true + schema: + type: string + responses: + '200': + description: 'Storage deleted.' + content: + application/json: + schema: + properties: + message: { type: string } + 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: @@ -4209,6 +4320,219 @@ paths: security: - bearerAuth: [] + '/databases/{uuid}/storages': + get: + tags: + - Databases + summary: 'List Storages' + description: 'List all persistent storages and file storages by database UUID.' + operationId: list-storages-by-database-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the database.' + required: true + schema: + type: string + responses: + '200': + description: 'All storages by database 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: [] + post: + tags: + - Databases + summary: 'Create Storage' + description: 'Create a persistent storage or file storage for a database.' + operationId: create-storage-by-database-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the database.' + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + required: + - type + - mount_path + properties: + 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).' + type: object + additionalProperties: false + responses: + '201': + description: 'Storage created.' + 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: [] + patch: + tags: + - Databases + summary: 'Update Storage' + description: 'Update a persistent storage or file storage by database UUID.' + operationId: update-storage-by-database-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the database.' + 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: + - type + properties: + uuid: + type: string + description: 'The UUID of the storage (preferred).' + id: + type: integer + description: 'The ID of the storage (deprecated, use uuid instead).' + 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: [] + '/databases/{uuid}/storages/{storage_uuid}': + delete: + tags: + - Databases + summary: 'Delete Storage' + description: 'Delete a persistent storage or file storage by database UUID.' + operationId: delete-storage-by-database-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the database.' + required: true + schema: + type: string + - + name: storage_uuid + in: path + description: 'UUID of the storage.' + required: true + schema: + type: string + responses: + '200': + description: 'Storage deleted.' + content: + application/json: + schema: + properties: + message: { type: string } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] /deployments: get: tags: @@ -7070,6 +7394,223 @@ paths: security: - bearerAuth: [] + '/services/{uuid}/storages': + get: + tags: + - Services + summary: 'List Storages' + description: 'List all persistent storages and file storages by service UUID.' + operationId: list-storages-by-service-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the service.' + required: true + schema: + type: string + responses: + '200': + description: 'All storages by service 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: [] + post: + tags: + - Services + summary: 'Create Storage' + description: 'Create a persistent storage or file storage for a service sub-resource.' + operationId: create-storage-by-service-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the service.' + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + required: + - type + - mount_path + - resource_uuid + properties: + type: + type: string + enum: [persistent, file] + description: 'The type of storage.' + 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).' + type: object + additionalProperties: false + responses: + '201': + description: 'Storage created.' + 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: [] + patch: + tags: + - Services + summary: 'Update Storage' + description: 'Update a persistent storage or file storage by service UUID.' + operationId: update-storage-by-service-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the service.' + 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: + - type + properties: + uuid: + type: string + description: 'The UUID of the storage (preferred).' + id: + type: integer + description: 'The ID of the storage (deprecated, use uuid instead).' + 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: [] + '/services/{uuid}/storages/{storage_uuid}': + delete: + tags: + - Services + summary: 'Delete Storage' + description: 'Delete a persistent storage or file storage by service UUID.' + operationId: delete-storage-by-service-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the service.' + required: true + schema: + type: string + - + name: storage_uuid + in: path + description: 'UUID of the storage.' + required: true + schema: + type: string + responses: + '200': + description: 'Storage deleted.' + content: + application/json: + schema: + properties: + message: { type: string } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] /teams: get: tags: diff --git a/routes/api.php b/routes/api.php index 1de365c49..0d3edcced 100644 --- a/routes/api.php +++ b/routes/api.php @@ -121,7 +121,9 @@ 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::post('/applications/{uuid}/storages', [ApplicationsController::class, 'create_storage'])->middleware(['api.ability:write']); Route::patch('/applications/{uuid}/storages', [ApplicationsController::class, 'update_storage'])->middleware(['api.ability:write']); + Route::delete('/applications/{uuid}/storages/{storage_uuid}', [ApplicationsController::class, 'delete_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']); @@ -154,6 +156,11 @@ Route::delete('/databases/{uuid}/backups/{scheduled_backup_uuid}', [DatabasesController::class, 'delete_backup_by_uuid'])->middleware(['api.ability:write']); Route::delete('/databases/{uuid}/backups/{scheduled_backup_uuid}/executions/{execution_uuid}', [DatabasesController::class, 'delete_execution_by_uuid'])->middleware(['api.ability:write']); + Route::get('/databases/{uuid}/storages', [DatabasesController::class, 'storages'])->middleware(['api.ability:read']); + Route::post('/databases/{uuid}/storages', [DatabasesController::class, 'create_storage'])->middleware(['api.ability:write']); + Route::patch('/databases/{uuid}/storages', [DatabasesController::class, 'update_storage'])->middleware(['api.ability:write']); + Route::delete('/databases/{uuid}/storages/{storage_uuid}', [DatabasesController::class, 'delete_storage'])->middleware(['api.ability:write']); + Route::get('/databases/{uuid}/envs', [DatabasesController::class, 'envs'])->middleware(['api.ability:read']); Route::post('/databases/{uuid}/envs', [DatabasesController::class, 'create_env'])->middleware(['api.ability:write']); Route::patch('/databases/{uuid}/envs/bulk', [DatabasesController::class, 'create_bulk_envs'])->middleware(['api.ability:write']); @@ -171,6 +178,11 @@ Route::patch('/services/{uuid}', [ServicesController::class, 'update_by_uuid'])->middleware(['api.ability:write']); Route::delete('/services/{uuid}', [ServicesController::class, 'delete_by_uuid'])->middleware(['api.ability:write']); + Route::get('/services/{uuid}/storages', [ServicesController::class, 'storages'])->middleware(['api.ability:read']); + Route::post('/services/{uuid}/storages', [ServicesController::class, 'create_storage'])->middleware(['api.ability:write']); + Route::patch('/services/{uuid}/storages', [ServicesController::class, 'update_storage'])->middleware(['api.ability:write']); + Route::delete('/services/{uuid}/storages/{storage_uuid}', [ServicesController::class, 'delete_storage'])->middleware(['api.ability:write']); + Route::get('/services/{uuid}/envs', [ServicesController::class, 'envs'])->middleware(['api.ability:read']); Route::post('/services/{uuid}/envs', [ServicesController::class, 'create_env'])->middleware(['api.ability:write']); Route::patch('/services/{uuid}/envs/bulk', [ServicesController::class, 'create_bulk_envs'])->middleware(['api.ability:write']); diff --git a/tests/Feature/GenerateApplicationNameTest.php b/tests/Feature/GenerateApplicationNameTest.php new file mode 100644 index 000000000..3a1c475d3 --- /dev/null +++ b/tests/Feature/GenerateApplicationNameTest.php @@ -0,0 +1,22 @@ +toBe('coolify:main-test123'); + expect($name)->not->toContain('coollabsio'); +}); + +test('generate_application_name handles repository without owner', function () { + $name = generate_application_name('coolify', 'main', 'test123'); + + expect($name)->toBe('coolify:main-test123'); +}); + +test('generate_application_name handles deeply nested repository path', function () { + $name = generate_application_name('org/sub/repo-name', 'develop', 'abc456'); + + expect($name)->toBe('repo-name:develop-abc456'); + expect($name)->not->toContain('org'); + expect($name)->not->toContain('sub'); +}); diff --git a/tests/Feature/StorageApiTest.php b/tests/Feature/StorageApiTest.php new file mode 100644 index 000000000..75357e41e --- /dev/null +++ b/tests/Feature/StorageApiTest.php @@ -0,0 +1,379 @@ + 0]); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $plainTextToken = Str::random(40); + $token = $this->user->tokens()->create([ + 'name' => 'test-token', + 'token' => hash('sha256', $plainTextToken), + 'abilities' => ['*'], + 'team_id' => $this->team->id, + ]); + $this->bearerToken = $token->getKey().'|'.$plainTextToken; + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first(); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); +}); + +function createTestApplication($context): Application +{ + return Application::factory()->create([ + 'environment_id' => $context->environment->id, + ]); +} + +function createTestDatabase($context): StandalonePostgresql +{ + return StandalonePostgresql::create([ + 'name' => 'test-postgres', + 'image' => 'postgres:15-alpine', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'postgres', + 'environment_id' => $context->environment->id, + 'destination_id' => $context->destination->id, + 'destination_type' => $context->destination->getMorphClass(), + ]); +} + +// ────────────────────────────────────────────────────────────── +// Application Storage Endpoints +// ────────────────────────────────────────────────────────────── + +describe('GET /api/v1/applications/{uuid}/storages', function () { + test('lists storages for an application', function () { + $app = createTestApplication($this); + + LocalPersistentVolume::create([ + 'name' => $app->uuid.'-test-vol', + 'mount_path' => '/data', + 'resource_id' => $app->id, + 'resource_type' => $app->getMorphClass(), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->getJson("/api/v1/applications/{$app->uuid}/storages"); + + $response->assertStatus(200); + $response->assertJsonCount(1, 'persistent_storages'); + $response->assertJsonCount(0, 'file_storages'); + }); + + test('returns 404 for non-existent application', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->getJson('/api/v1/applications/non-existent-uuid/storages'); + + $response->assertStatus(404); + }); +}); + +describe('POST /api/v1/applications/{uuid}/storages', function () { + test('creates a persistent storage', function () { + $app = createTestApplication($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson("/api/v1/applications/{$app->uuid}/storages", [ + 'type' => 'persistent', + 'name' => 'my-volume', + 'mount_path' => '/data', + ]); + + $response->assertStatus(201); + + $vol = LocalPersistentVolume::where('resource_id', $app->id) + ->where('resource_type', $app->getMorphClass()) + ->first(); + + expect($vol)->not->toBeNull(); + expect($vol->name)->toBe($app->uuid.'-my-volume'); + expect($vol->mount_path)->toBe('/data'); + expect($vol->uuid)->not->toBeNull(); + }); + + test('creates a file storage', function () { + $app = createTestApplication($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson("/api/v1/applications/{$app->uuid}/storages", [ + 'type' => 'file', + 'mount_path' => '/app/config.json', + 'content' => '{"key": "value"}', + ]); + + $response->assertStatus(201); + + $vol = LocalFileVolume::where('resource_id', $app->id) + ->where('resource_type', get_class($app)) + ->first(); + + expect($vol)->not->toBeNull(); + expect($vol->mount_path)->toBe('/app/config.json'); + expect($vol->is_directory)->toBeFalse(); + }); + + test('rejects persistent storage without name', function () { + $app = createTestApplication($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson("/api/v1/applications/{$app->uuid}/storages", [ + 'type' => 'persistent', + 'mount_path' => '/data', + ]); + + $response->assertStatus(422); + }); + + test('rejects invalid type-specific fields', function () { + $app = createTestApplication($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson("/api/v1/applications/{$app->uuid}/storages", [ + 'type' => 'persistent', + 'name' => 'vol', + 'mount_path' => '/data', + 'content' => 'should not be here', + ]); + + $response->assertStatus(422); + }); +}); + +describe('PATCH /api/v1/applications/{uuid}/storages', function () { + test('updates a persistent storage by uuid', function () { + $app = createTestApplication($this); + + $vol = LocalPersistentVolume::create([ + 'name' => $app->uuid.'-test-vol', + 'mount_path' => '/data', + 'resource_id' => $app->id, + 'resource_type' => $app->getMorphClass(), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/applications/{$app->uuid}/storages", [ + 'uuid' => $vol->uuid, + 'type' => 'persistent', + 'mount_path' => '/new-data', + ]); + + $response->assertStatus(200); + expect($vol->fresh()->mount_path)->toBe('/new-data'); + }); + + test('updates a persistent storage by id (backwards compat)', function () { + $app = createTestApplication($this); + + $vol = LocalPersistentVolume::create([ + 'name' => $app->uuid.'-test-vol', + 'mount_path' => '/data', + 'resource_id' => $app->id, + 'resource_type' => $app->getMorphClass(), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/applications/{$app->uuid}/storages", [ + 'id' => $vol->id, + 'type' => 'persistent', + 'mount_path' => '/updated', + ]); + + $response->assertStatus(200); + expect($vol->fresh()->mount_path)->toBe('/updated'); + }); + + test('returns 422 when neither uuid nor id is provided', function () { + $app = createTestApplication($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/applications/{$app->uuid}/storages", [ + 'type' => 'persistent', + 'mount_path' => '/data', + ]); + + $response->assertStatus(422); + }); +}); + +describe('DELETE /api/v1/applications/{uuid}/storages/{storage_uuid}', function () { + test('deletes a persistent storage', function () { + $app = createTestApplication($this); + + $vol = LocalPersistentVolume::create([ + 'name' => $app->uuid.'-test-vol', + 'mount_path' => '/data', + 'resource_id' => $app->id, + 'resource_type' => $app->getMorphClass(), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->deleteJson("/api/v1/applications/{$app->uuid}/storages/{$vol->uuid}"); + + $response->assertStatus(200); + $response->assertJson(['message' => 'Storage deleted.']); + expect(LocalPersistentVolume::find($vol->id))->toBeNull(); + }); + + test('finds file storage without type param and calls deleteStorageOnServer', function () { + $app = createTestApplication($this); + + $vol = LocalFileVolume::create([ + 'fs_path' => '/tmp/test', + 'mount_path' => '/app/config.json', + 'content' => '{}', + 'is_directory' => false, + 'resource_id' => $app->id, + 'resource_type' => get_class($app), + ]); + + // Verify the storage is found via fileStorages (not persistentStorages) + $freshApp = Application::find($app->id); + expect($freshApp->persistentStorages->where('uuid', $vol->uuid)->first())->toBeNull(); + expect($freshApp->fileStorages->where('uuid', $vol->uuid)->first())->not->toBeNull(); + expect($vol)->toBeInstanceOf(LocalFileVolume::class); + }); + + test('returns 404 for non-existent storage', function () { + $app = createTestApplication($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->deleteJson("/api/v1/applications/{$app->uuid}/storages/non-existent"); + + $response->assertStatus(404); + }); +}); + +// ────────────────────────────────────────────────────────────── +// Database Storage Endpoints +// ────────────────────────────────────────────────────────────── + +describe('GET /api/v1/databases/{uuid}/storages', function () { + test('lists storages for a database', function () { + $db = createTestDatabase($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->getJson("/api/v1/databases/{$db->uuid}/storages"); + + $response->assertStatus(200); + $response->assertJsonStructure(['persistent_storages', 'file_storages']); + // Database auto-creates a default persistent volume + $response->assertJsonCount(1, 'persistent_storages'); + }); + + test('returns 404 for non-existent database', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->getJson('/api/v1/databases/non-existent-uuid/storages'); + + $response->assertStatus(404); + }); +}); + +describe('POST /api/v1/databases/{uuid}/storages', function () { + test('creates a persistent storage for a database', function () { + $db = createTestDatabase($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson("/api/v1/databases/{$db->uuid}/storages", [ + 'type' => 'persistent', + 'name' => 'extra-data', + 'mount_path' => '/extra', + ]); + + $response->assertStatus(201); + + $vol = LocalPersistentVolume::where('name', $db->uuid.'-extra-data')->first(); + expect($vol)->not->toBeNull(); + expect($vol->mount_path)->toBe('/extra'); + }); +}); + +describe('PATCH /api/v1/databases/{uuid}/storages', function () { + test('updates a persistent storage by uuid', function () { + $db = createTestDatabase($this); + + $vol = LocalPersistentVolume::create([ + 'name' => $db->uuid.'-test-vol', + 'mount_path' => '/data', + 'resource_id' => $db->id, + 'resource_type' => $db->getMorphClass(), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/databases/{$db->uuid}/storages", [ + 'uuid' => $vol->uuid, + 'type' => 'persistent', + 'mount_path' => '/updated', + ]); + + $response->assertStatus(200); + expect($vol->fresh()->mount_path)->toBe('/updated'); + }); +}); + +describe('DELETE /api/v1/databases/{uuid}/storages/{storage_uuid}', function () { + test('deletes a persistent storage', function () { + $db = createTestDatabase($this); + + $vol = LocalPersistentVolume::create([ + 'name' => $db->uuid.'-test-vol', + 'mount_path' => '/extra', + 'resource_id' => $db->id, + 'resource_type' => $db->getMorphClass(), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->deleteJson("/api/v1/databases/{$db->uuid}/storages/{$vol->uuid}"); + + $response->assertStatus(200); + expect(LocalPersistentVolume::find($vol->id))->toBeNull(); + }); +});