diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index c2592d7f9..5ad3aa09d 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -17,6 +17,8 @@ use App\Models\LocalPersistentVolume; use App\Models\PrivateKey; use App\Models\Project; use App\Models\Server; +use App\Models\StandaloneDocker; +use App\Models\SwarmDocker; use App\Rules\DockerImageFormat; use App\Rules\ValidGitBranch; use App\Rules\ValidGitRepositoryUrl; @@ -49,6 +51,14 @@ class ApplicationsController extends Controller 'is_gzip_enabled', 'is_stripprefix_enabled', 'is_raw_compose_deployment_enabled', + 'is_log_drain_enabled', + 'is_gpu_enabled', + 'gpu_driver', + 'gpu_count', + 'gpu_device_ids', + 'gpu_options', + 'is_consistent_container_name_enabled', + 'custom_internal_name', ]; private const BOOLEAN_APPLICATION_SETTING_FIELDS = [ @@ -63,6 +73,9 @@ class ApplicationsController extends Controller 'is_gzip_enabled', 'is_stripprefix_enabled', 'is_raw_compose_deployment_enabled', + 'is_log_drain_enabled', + 'is_gpu_enabled', + 'is_consistent_container_name_enabled', ]; protected function findTaggableResource(string $uuid, int|string $teamId): mixed @@ -368,6 +381,16 @@ class ApplicationsController extends Controller 'is_gzip_enabled' => ['type' => 'boolean', 'description' => 'Enable gzip compression.'], 'is_stripprefix_enabled' => ['type' => 'boolean', 'description' => 'Enable path prefix stripping.'], 'is_raw_compose_deployment_enabled' => ['type' => 'boolean', 'description' => 'Deploy the raw Docker Compose definition.'], + 'is_log_drain_enabled' => ['type' => 'boolean', 'description' => 'Enable log drain for the application.'], + 'is_gpu_enabled' => ['type' => 'boolean', 'description' => 'Enable GPU support.'], + 'gpu_driver' => ['type' => 'string', 'nullable' => true, 'description' => 'GPU driver name.'], + 'gpu_count' => ['type' => 'string', 'nullable' => true, 'description' => 'Number of GPUs to allocate.'], + 'gpu_device_ids' => ['type' => 'string', 'nullable' => true, 'description' => 'Comma-separated GPU device IDs.'], + 'gpu_options' => ['type' => 'string', 'nullable' => true, 'description' => 'Additional GPU options.'], + 'is_consistent_container_name_enabled' => ['type' => 'boolean', 'description' => 'Use a consistent container name across deployments.'], + 'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name.'], + 'preview_url_template' => ['type' => 'string', 'description' => 'Preview URL template.'], + 'max_restart_count' => ['type' => 'integer', 'minimum' => 0, 'description' => 'Maximum container restart count before stopping.'], 'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'], 'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'], 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], @@ -550,6 +573,16 @@ class ApplicationsController extends Controller 'is_gzip_enabled' => ['type' => 'boolean', 'description' => 'Enable gzip compression.'], 'is_stripprefix_enabled' => ['type' => 'boolean', 'description' => 'Enable path prefix stripping.'], 'is_raw_compose_deployment_enabled' => ['type' => 'boolean', 'description' => 'Deploy the raw Docker Compose definition.'], + 'is_log_drain_enabled' => ['type' => 'boolean', 'description' => 'Enable log drain for the application.'], + 'is_gpu_enabled' => ['type' => 'boolean', 'description' => 'Enable GPU support.'], + 'gpu_driver' => ['type' => 'string', 'nullable' => true, 'description' => 'GPU driver name.'], + 'gpu_count' => ['type' => 'string', 'nullable' => true, 'description' => 'Number of GPUs to allocate.'], + 'gpu_device_ids' => ['type' => 'string', 'nullable' => true, 'description' => 'Comma-separated GPU device IDs.'], + 'gpu_options' => ['type' => 'string', 'nullable' => true, 'description' => 'Additional GPU options.'], + 'is_consistent_container_name_enabled' => ['type' => 'boolean', 'description' => 'Use a consistent container name across deployments.'], + 'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name.'], + 'preview_url_template' => ['type' => 'string', 'description' => 'Preview URL template.'], + 'max_restart_count' => ['type' => 'integer', 'minimum' => 0, 'description' => 'Maximum container restart count before stopping.'], 'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'], 'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'], 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], @@ -732,6 +765,16 @@ class ApplicationsController extends Controller 'is_gzip_enabled' => ['type' => 'boolean', 'description' => 'Enable gzip compression.'], 'is_stripprefix_enabled' => ['type' => 'boolean', 'description' => 'Enable path prefix stripping.'], 'is_raw_compose_deployment_enabled' => ['type' => 'boolean', 'description' => 'Deploy the raw Docker Compose definition.'], + 'is_log_drain_enabled' => ['type' => 'boolean', 'description' => 'Enable log drain for the application.'], + 'is_gpu_enabled' => ['type' => 'boolean', 'description' => 'Enable GPU support.'], + 'gpu_driver' => ['type' => 'string', 'nullable' => true, 'description' => 'GPU driver name.'], + 'gpu_count' => ['type' => 'string', 'nullable' => true, 'description' => 'Number of GPUs to allocate.'], + 'gpu_device_ids' => ['type' => 'string', 'nullable' => true, 'description' => 'Comma-separated GPU device IDs.'], + 'gpu_options' => ['type' => 'string', 'nullable' => true, 'description' => 'Additional GPU options.'], + 'is_consistent_container_name_enabled' => ['type' => 'boolean', 'description' => 'Use a consistent container name across deployments.'], + 'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name.'], + 'preview_url_template' => ['type' => 'string', 'description' => 'Preview URL template.'], + 'max_restart_count' => ['type' => 'integer', 'minimum' => 0, 'description' => 'Maximum container restart count before stopping.'], 'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'], 'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'], 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], @@ -886,6 +929,16 @@ class ApplicationsController extends Controller 'is_gzip_enabled' => ['type' => 'boolean', 'description' => 'Enable gzip compression.'], 'is_stripprefix_enabled' => ['type' => 'boolean', 'description' => 'Enable path prefix stripping.'], 'is_raw_compose_deployment_enabled' => ['type' => 'boolean', 'description' => 'Deploy the raw Docker Compose definition.'], + 'is_log_drain_enabled' => ['type' => 'boolean', 'description' => 'Enable log drain for the application.'], + 'is_gpu_enabled' => ['type' => 'boolean', 'description' => 'Enable GPU support.'], + 'gpu_driver' => ['type' => 'string', 'nullable' => true, 'description' => 'GPU driver name.'], + 'gpu_count' => ['type' => 'string', 'nullable' => true, 'description' => 'Number of GPUs to allocate.'], + 'gpu_device_ids' => ['type' => 'string', 'nullable' => true, 'description' => 'Comma-separated GPU device IDs.'], + 'gpu_options' => ['type' => 'string', 'nullable' => true, 'description' => 'Additional GPU options.'], + 'is_consistent_container_name_enabled' => ['type' => 'boolean', 'description' => 'Use a consistent container name across deployments.'], + 'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name.'], + 'preview_url_template' => ['type' => 'string', 'description' => 'Preview URL template.'], + 'max_restart_count' => ['type' => 'integer', 'minimum' => 0, 'description' => 'Maximum container restart count before stopping.'], 'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'], 'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'], 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], @@ -1036,6 +1089,16 @@ class ApplicationsController extends Controller 'is_gzip_enabled' => ['type' => 'boolean', 'description' => 'Enable gzip compression.'], 'is_stripprefix_enabled' => ['type' => 'boolean', 'description' => 'Enable path prefix stripping.'], 'is_raw_compose_deployment_enabled' => ['type' => 'boolean', 'description' => 'Deploy the raw Docker Compose definition.'], + 'is_log_drain_enabled' => ['type' => 'boolean', 'description' => 'Enable log drain for the application.'], + 'is_gpu_enabled' => ['type' => 'boolean', 'description' => 'Enable GPU support.'], + 'gpu_driver' => ['type' => 'string', 'nullable' => true, 'description' => 'GPU driver name.'], + 'gpu_count' => ['type' => 'string', 'nullable' => true, 'description' => 'Number of GPUs to allocate.'], + 'gpu_device_ids' => ['type' => 'string', 'nullable' => true, 'description' => 'Comma-separated GPU device IDs.'], + 'gpu_options' => ['type' => 'string', 'nullable' => true, 'description' => 'Additional GPU options.'], + 'is_consistent_container_name_enabled' => ['type' => 'boolean', 'description' => 'Use a consistent container name across deployments.'], + 'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name.'], + 'preview_url_template' => ['type' => 'string', 'description' => 'Preview URL template.'], + 'max_restart_count' => ['type' => 'integer', 'minimum' => 0, 'description' => 'Maximum container restart count before stopping.'], 'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'], 'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'], 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], @@ -1120,7 +1183,7 @@ class ApplicationsController extends Controller if ($return instanceof JsonResponse) { return $return; } - $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'use_build_secrets', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled', 'tags', 'is_preserve_repository_enabled', ...self::APPLICATION_SETTING_FIELDS]; + $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'use_build_secrets', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled', 'tags', 'is_preserve_repository_enabled', 'preview_url_template', 'max_restart_count', ...self::APPLICATION_SETTING_FIELDS]; $validator = customApiValidator($request->all(), [ 'name' => 'string|max:255', @@ -2603,6 +2666,16 @@ class ApplicationsController extends Controller 'is_gzip_enabled' => ['type' => 'boolean', 'description' => 'Enable gzip compression.'], 'is_stripprefix_enabled' => ['type' => 'boolean', 'description' => 'Enable path prefix stripping.'], 'is_raw_compose_deployment_enabled' => ['type' => 'boolean', 'description' => 'Deploy the raw Docker Compose definition.'], + 'is_log_drain_enabled' => ['type' => 'boolean', 'description' => 'Enable log drain for the application.'], + 'is_gpu_enabled' => ['type' => 'boolean', 'description' => 'Enable GPU support.'], + 'gpu_driver' => ['type' => 'string', 'nullable' => true, 'description' => 'GPU driver name.'], + 'gpu_count' => ['type' => 'string', 'nullable' => true, 'description' => 'Number of GPUs to allocate.'], + 'gpu_device_ids' => ['type' => 'string', 'nullable' => true, 'description' => 'Comma-separated GPU device IDs.'], + 'gpu_options' => ['type' => 'string', 'nullable' => true, 'description' => 'Additional GPU options.'], + 'is_consistent_container_name_enabled' => ['type' => 'boolean', 'description' => 'Use a consistent container name across deployments.'], + 'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name.'], + 'preview_url_template' => ['type' => 'string', 'description' => 'Preview URL template.'], + 'max_restart_count' => ['type' => 'integer', 'minimum' => 0, 'description' => 'Maximum container restart count before stopping.'], 'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'], 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], 'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'], @@ -2692,7 +2765,7 @@ class ApplicationsController extends Controller $this->authorize('update', $application); $server = $application->destination->server; - $allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'dockerfile_target_build', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'use_build_secrets', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled', 'is_preserve_repository_enabled', ...self::APPLICATION_SETTING_FIELDS]; + $allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'dockerfile_target_build', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'use_build_secrets', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled', 'is_preserve_repository_enabled', 'preview_url_template', 'max_restart_count', ...self::APPLICATION_SETTING_FIELDS]; $validationRules = [ 'name' => 'string|max:255', @@ -5149,4 +5222,548 @@ class ApplicationsController extends Controller { return $this->deleteTag($request); } + + #[OA\Post( + summary: 'Clone', + description: 'Clone an application to a destination owned by the authenticated team.', + path: '/applications/{uuid}/clone', + operationId: 'clone-application-by-uuid', + security: [['bearerAuth' => []]], + tags: ['Applications'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'UUID of the application.', schema: new OA\Schema(type: 'string')), + ], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + required: ['destination_uuid'], + properties: [ + new OA\Property(property: 'destination_uuid', type: 'string', description: 'UUID of the destination to clone into.'), + new OA\Property(property: 'name', type: 'string', nullable: true, description: 'Optional name for the cloned application.'), + new OA\Property(property: 'clone_volumes', type: 'boolean', default: false, description: 'Whether to clone volume data.'), + ] + ) + ), + responses: [ + new OA\Response( + response: 201, + description: 'Application cloned.', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'uuid', type: 'string'), + new OA\Property(property: 'message', type: 'string', example: 'Application cloned.'), + ] + ) + ), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ] + )] + public function clone_by_uuid(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + + $validator = customApiValidator($request->all(), [ + 'destination_uuid' => 'required|string', + 'name' => 'string|max:255|nullable', + 'clone_volumes' => 'boolean', + ]); + $allowedFields = ['destination_uuid', 'name', 'clone_volumes']; + $extraFields = array_diff(array_keys($request->all()), $allowedFields); + if ($validator->fails() || ! empty($extraFields)) { + $errors = $validator->errors(); + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $errors, + ], 422); + } + + $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first(); + if (! $application) { + return response()->json(['message' => 'Application not found.'], 404); + } + + $this->authorize('update', $application); + + $destination = StandaloneDocker::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->destination_uuid)->first() + ?? SwarmDocker::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->destination_uuid)->first(); + + if (! $destination || ! $destination->server?->canHostResources()) { + return response()->json(['message' => 'Destination not found.'], 404); + } + + $overrides = ['uuid' => new_public_id()]; + if ($request->filled('name')) { + $overrides['name'] = $request->string('name')->toString(); + } + + $newApplication = clone_application( + $application, + $destination, + $overrides, + $request->boolean('clone_volumes', false), + ); + + auditLog('api.application.cloned', [ + 'team_id' => $teamId, + 'source_uuid' => $application->uuid, + 'application_uuid' => $newApplication->uuid, + 'application_name' => $newApplication->name, + 'destination_uuid' => $destination->uuid, + 'clone_volumes' => $request->boolean('clone_volumes', false), + ]); + + return response()->json([ + 'uuid' => $newApplication->uuid, + 'message' => 'Application cloned.', + ], 201); + } + + #[OA\Get( + summary: 'List Rollback Images', + description: 'List available Docker images for rolling back an application. Returns an empty list when the server is unavailable or remote inspection is not possible.', + path: '/applications/{uuid}/rollback-images', + operationId: 'list-application-rollback-images', + security: [['bearerAuth' => []]], + tags: ['Applications'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'UUID of the application.', schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Rollback images.', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'current', type: 'string', nullable: true), + new OA\Property( + property: 'images', + type: 'array', + items: new OA\Items( + type: 'object', + properties: [ + new OA\Property(property: 'tag', type: 'string'), + new OA\Property(property: 'created_at', type: 'string'), + new OA\Property(property: 'is_current', type: 'boolean'), + ] + ) + ), + ] + ) + ), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ] + )] + public function rollback_images(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first(); + if (! $application) { + return response()->json(['message' => 'Application not found.'], 404); + } + + $this->authorize('view', $application); + + $current = null; + $images = []; + + try { + $server = $application->destination?->server; + if ($server && $server->isFunctional()) { + $image = $application->docker_registry_image_name ?? $application->uuid; + $output = instant_remote_process([ + "docker inspect --format='{{.Config.Image}}' {$application->uuid}", + ], $server, throwError: false); + $current = self::currentRollbackImageTag(str($output)->trim()->toString()); + + $output = instant_remote_process([ + "docker images --format '{{.Repository}}#{{.Tag}}#{{.CreatedAt}}'", + ], $server); + $images = str($output)->trim()->explode("\n")->filter(function ($item) use ($image) { + $repository = str($item)->before('#')->toString(); + + // Exact repository match only — avoid substring collisions across images. + return $repository === $image; + })->map(function ($item) use ($current) { + $parts = str($item)->explode('#'); + + return [ + 'tag' => $parts[1] ?? null, + 'created_at' => $parts[2] ?? null, + 'is_current' => ($parts[1] ?? null) === $current, + ]; + })->values()->all(); + } + } catch (\Throwable) { + $current = null; + $images = []; + } + + return response()->json([ + 'current' => $current, + 'images' => $images, + ]); + } + + private static function currentRollbackImageTag(string $imageReference): ?string + { + if (str_contains($imageReference, '@')) { + return null; + } + + $lastColon = strrpos($imageReference, ':'); + $lastSlash = strrpos($imageReference, '/'); + + if ($lastColon === false || ($lastSlash !== false && $lastColon < $lastSlash)) { + return null; + } + + return substr($imageReference, $lastColon + 1) ?: null; + } + + #[OA\Post( + summary: 'Rollback', + description: 'Queue a rollback deployment for an application to a previous image commit/tag.', + path: '/applications/{uuid}/rollback', + operationId: 'rollback-application-by-uuid', + security: [['bearerAuth' => []]], + tags: ['Applications'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'UUID of the application.', schema: new OA\Schema(type: 'string')), + ], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + required: ['commit'], + properties: [ + new OA\Property(property: 'commit', type: 'string', description: 'Image tag / commit to roll back to.'), + ] + ) + ), + responses: [ + new OA\Response( + response: 200, + description: 'Rollback deployment queued.', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'message', type: 'string'), + new OA\Property(property: 'deployment_uuid', type: 'string'), + ] + ) + ), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ] + )] + public function rollback_by_uuid(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + + $validator = customApiValidator($request->all(), [ + 'commit' => 'required|string', + ]); + $allowedFields = ['commit']; + $extraFields = array_diff(array_keys($request->all()), $allowedFields); + if ($validator->fails() || ! empty($extraFields)) { + $errors = $validator->errors(); + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $errors, + ], 422); + } + + $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first(); + if (! $application) { + return response()->json(['message' => 'Application not found.'], 404); + } + + $this->authorize('deploy', $application); + + try { + $commit = validateGitRef($request->string('commit')->toString(), 'rollback commit'); + } catch (\Throwable $e) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['commit' => [$e->getMessage()]], + ], 422); + } + + $deployment_uuid = new_public_id(); + $result = queue_application_deployment( + application: $application, + deployment_uuid: $deployment_uuid, + commit: $commit, + rollback: true, + force_rebuild: false, + is_api: true, + ); + + if ($result['status'] === 'queue_full') { + return response()->json(['message' => $result['message'] ?? 'Deployment queue full.'], 400); + } + + if ($result['status'] === 'skipped') { + return response()->json(['message' => $result['message']], 200); + } + + auditLog('api.application.rollback', [ + 'team_id' => $teamId, + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'deployment_uuid' => $deployment_uuid, + 'commit' => $commit, + ]); + + return response()->json([ + 'message' => 'Rollback deployment queued.', + 'deployment_uuid' => $deployment_uuid, + ]); + } + + #[OA\Get( + summary: 'List Destinations', + description: 'List primary and additional destinations for a standalone application.', + path: '/applications/{uuid}/destinations', + operationId: 'list-application-destinations', + security: [['bearerAuth' => []]], + tags: ['Applications'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'UUID of the application.', schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response(response: 200, description: 'Application destinations.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ] + )] + public function destinations(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first(); + if (! $application) { + return response()->json(['message' => 'Application not found.'], 404); + } + + $this->authorize('view', $application); + + $destinations = collect(); + $primary = $application->destination; + if ($primary) { + $destinations->push([ + 'uuid' => $primary->uuid, + 'name' => $primary->name, + 'network' => $primary->network ?? null, + 'server_uuid' => $primary->server?->uuid, + 'server_id' => $primary->server_id, + 'is_primary' => true, + ]); + } + + foreach ($application->additional_networks as $network) { + $destinations->push([ + 'uuid' => $network->uuid, + 'name' => $network->name, + 'network' => $network->network ?? null, + 'server_uuid' => $network->server?->uuid, + 'server_id' => $network->pivot->server_id ?? $network->server_id, + 'is_primary' => false, + ]); + } + + return response()->json($destinations->values()); + } + + #[OA\Post( + summary: 'Add Destination', + description: 'Attach an additional standalone Docker destination to an application.', + path: '/applications/{uuid}/destinations', + operationId: 'add-application-destination', + security: [['bearerAuth' => []]], + tags: ['Applications'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'UUID of the application.', schema: new OA\Schema(type: 'string')), + ], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + required: ['destination_uuid'], + properties: [ + new OA\Property(property: 'destination_uuid', type: 'string'), + ] + ) + ), + responses: [ + new OA\Response(response: 201, description: 'Destination attached.'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ] + )] + public function add_destination(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + + $validator = customApiValidator($request->all(), [ + 'destination_uuid' => 'required|string', + ]); + $extraFields = array_diff(array_keys($request->all()), ['destination_uuid']); + if ($validator->fails() || ! empty($extraFields)) { + $errors = $validator->errors(); + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $errors, + ], 422); + } + + $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first(); + if (! $application) { + return response()->json(['message' => 'Application not found.'], 404); + } + + $this->authorize('update', $application); + + $destination = StandaloneDocker::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->destination_uuid)->first(); + if (! $destination || ! $destination->server?->canHostResources()) { + return response()->json(['message' => 'Destination not found.'], 404); + } + + if ($application->destination_id === $destination->id && $application->destination_type === $destination->getMorphClass()) { + return response()->json(['message' => 'Destination is already the primary destination.'], 422); + } + + if ($application->additional_networks()->where('standalone_dockers.id', $destination->id)->exists()) { + return response()->json(['message' => 'Destination is already attached.'], 422); + } + + if ($application->destination?->server_id === $destination->server_id) { + return response()->json(['message' => 'Cannot attach a destination on the same server as the primary destination.'], 422); + } + + if ($application->additional_servers?->pluck('id')->contains($destination->server_id)) { + return response()->json(['message' => 'A destination on this server is already attached.'], 422); + } + + $application->additional_networks()->attach($destination->id, ['server_id' => $destination->server_id]); + + auditLog('api.application.destination_added', [ + 'team_id' => $teamId, + 'application_uuid' => $application->uuid, + 'destination_uuid' => $destination->uuid, + ]); + + return response()->json([ + 'message' => 'Destination attached.', + 'uuid' => $destination->uuid, + ], 201); + } + + #[OA\Delete( + summary: 'Remove Destination', + description: 'Detach an additional destination from an application.', + path: '/applications/{uuid}/destinations/{destination_uuid}', + operationId: 'remove-application-destination', + security: [['bearerAuth' => []]], + tags: ['Applications'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'UUID of the application.', schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'destination_uuid', in: 'path', required: true, description: 'UUID of the destination.', schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response(response: 200, description: 'Destination detached.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ] + )] + public function remove_destination(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first(); + if (! $application) { + return response()->json(['message' => 'Application not found.'], 404); + } + + $this->authorize('update', $application); + + $destinationUuid = $request->route('destination_uuid'); + $destination = StandaloneDocker::ownedByCurrentTeamAPI($teamId)->where('uuid', $destinationUuid)->first(); + if (! $destination) { + return response()->json(['message' => 'Destination not found.'], 404); + } + + if ($application->destination_id === $destination->id && $application->destination_type === $destination->getMorphClass()) { + return response()->json(['message' => 'Cannot remove the primary destination.'], 422); + } + + $attached = $application->additional_networks()->where('standalone_dockers.id', $destination->id)->first(); + if (! $attached) { + return response()->json(['message' => 'Destination not found.'], 404); + } + + $application->additional_networks() + ->wherePivot('server_id', $attached->pivot->server_id) + ->detach($destination->id); + + auditLog('api.application.destination_removed', [ + 'team_id' => $teamId, + 'application_uuid' => $application->uuid, + 'destination_uuid' => $destination->uuid, + ]); + + return response()->json(['message' => 'Destination detached.']); + } } diff --git a/app/Http/Controllers/Api/CloudInitScriptsController.php b/app/Http/Controllers/Api/CloudInitScriptsController.php new file mode 100644 index 000000000..4b53636e2 --- /dev/null +++ b/app/Http/Controllers/Api/CloudInitScriptsController.php @@ -0,0 +1,281 @@ +makeHidden(['id', 'team_id']); + + if (request()->attributes->get('can_read_sensitive', false) === true) { + $script->makeVisible(['script']); + } + + return serializeApiResponse($script)->all(); + } + + #[OA\Get( + summary: 'List Cloud-init Scripts', + description: 'List all cloud-init scripts for the authenticated team.', + path: '/cloud-init-scripts', + operationId: 'list-cloud-init-scripts', + security: [['bearerAuth' => []]], + tags: ['Cloud-init Scripts'], + responses: [ + new OA\Response(response: 200, description: 'Cloud-init scripts for the team.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 403, description: 'Forbidden.'), + ] + )] + public function index(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $this->authorize('viewAny', CloudInitScript::class); + + $scripts = CloudInitScript::where('team_id', $teamId) + ->orderByDesc('created_at') + ->get() + ->map(fn (CloudInitScript $script) => $this->removeSensitiveData($script)); + + return response()->json($scripts); + } + + #[OA\Post( + summary: 'Create Cloud-init Script', + description: 'Create a new cloud-init script for the authenticated team.', + path: '/cloud-init-scripts', + operationId: 'create-cloud-init-script', + security: [['bearerAuth' => []]], + tags: ['Cloud-init Scripts'], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + required: ['name', 'script'], + properties: [ + new OA\Property(property: 'name', type: 'string'), + new OA\Property(property: 'script', type: 'string', description: 'Bash script (#!) or cloud-config YAML.'), + ] + ) + ), + responses: [ + new OA\Response(response: 201, description: 'Cloud-init script created.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 403, description: 'Forbidden.'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ] + )] + public function store(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $this->authorize('create', CloudInitScript::class); + + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + + $validator = customApiValidator($request->all(), [ + 'name' => 'required|string|max:255', + 'script' => ['required', 'string', new ValidCloudInitYaml], + ]); + $extraFields = array_diff(array_keys($request->all()), ['name', 'script']); + if ($validator->fails() || ! empty($extraFields)) { + $errors = $validator->errors(); + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $errors, + ], 422); + } + + $script = CloudInitScript::create([ + 'team_id' => $teamId, + 'name' => $request->string('name')->toString(), + 'script' => $request->string('script')->toString(), + ]); + + auditLog('api.cloud_init_script.created', [ + 'team_id' => $teamId, + 'cloud_init_script_uuid' => $script->uuid, + 'cloud_init_script_name' => $script->name, + ]); + + return response()->json($this->removeSensitiveData($script), 201); + } + + #[OA\Get( + summary: 'Get Cloud-init Script', + description: 'Get a cloud-init script by UUID.', + path: '/cloud-init-scripts/{uuid}', + operationId: 'get-cloud-init-script-by-uuid', + security: [['bearerAuth' => []]], + tags: ['Cloud-init Scripts'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response(response: 200, description: 'Cloud-init script.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 403, description: 'Forbidden.'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ] + )] + public function show(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $script = CloudInitScript::where('team_id', $teamId)->where('uuid', $request->route('uuid'))->first(); + if (! $script) { + return response()->json(['message' => 'Cloud-init script not found.'], 404); + } + + $this->authorize('view', $script); + + return response()->json($this->removeSensitiveData($script)); + } + + #[OA\Patch( + summary: 'Update Cloud-init Script', + description: 'Update a cloud-init script by UUID.', + path: '/cloud-init-scripts/{uuid}', + operationId: 'update-cloud-init-script-by-uuid', + security: [['bearerAuth' => []]], + tags: ['Cloud-init Scripts'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + ], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'name', type: 'string'), + new OA\Property(property: 'script', type: 'string'), + ] + ) + ), + responses: [ + new OA\Response(response: 200, description: 'Cloud-init script updated.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 403, description: 'Forbidden.'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ] + )] + public function update(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + + if ($request->all() === []) { + return response()->json(['message' => 'At least one field must be provided.'], 422); + } + + $script = CloudInitScript::where('team_id', $teamId)->where('uuid', $request->route('uuid'))->first(); + if (! $script) { + return response()->json(['message' => 'Cloud-init script not found.'], 404); + } + + $this->authorize('update', $script); + + $validator = customApiValidator($request->all(), [ + 'name' => 'string|max:255', + 'script' => ['string', new ValidCloudInitYaml], + ]); + $extraFields = array_diff(array_keys($request->all()), ['name', 'script']); + if ($validator->fails() || ! empty($extraFields)) { + $errors = $validator->errors(); + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $errors, + ], 422); + } + + $script->update($request->only(['name', 'script'])); + + auditLog('api.cloud_init_script.updated', [ + 'team_id' => $teamId, + 'cloud_init_script_uuid' => $script->uuid, + 'cloud_init_script_name' => $script->name, + 'changed_fields' => array_values(array_intersect(['name', 'script'], array_keys($request->all()))), + ]); + + return response()->json($this->removeSensitiveData($script->fresh())); + } + + #[OA\Delete( + summary: 'Delete Cloud-init Script', + description: 'Delete a cloud-init script by UUID.', + path: '/cloud-init-scripts/{uuid}', + operationId: 'delete-cloud-init-script-by-uuid', + security: [['bearerAuth' => []]], + tags: ['Cloud-init Scripts'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response(response: 200, description: 'Cloud-init script deleted.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 403, description: 'Forbidden.'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ] + )] + public function destroy(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $script = CloudInitScript::where('team_id', $teamId)->where('uuid', $request->route('uuid'))->first(); + if (! $script) { + return response()->json(['message' => 'Cloud-init script not found.'], 404); + } + + $this->authorize('delete', $script); + + $uuid = $script->uuid; + $name = $script->name; + $script->delete(); + + auditLog('api.cloud_init_script.deleted', [ + 'team_id' => $teamId, + 'cloud_init_script_uuid' => $uuid, + 'cloud_init_script_name' => $name, + ]); + + return response()->json(['message' => 'Cloud-init script deleted.']); + } +} diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 41af38a72..d9953fa9c 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -11,6 +11,7 @@ use App\Enums\NewDatabaseTypes; use App\Http\Controllers\Controller; use App\Jobs\DatabaseBackupJob; use App\Jobs\DeleteResourceJob; +use App\Jobs\VolumeCloneJob; use App\Models\EnvironmentVariable; use App\Models\LocalFileVolume; use App\Models\LocalPersistentVolume; @@ -18,11 +19,14 @@ use App\Models\Project; use App\Models\S3Storage; use App\Models\ScheduledDatabaseBackup; use App\Models\Server; +use App\Models\StandaloneDocker; use App\Models\StandalonePostgresql; +use App\Models\SwarmDocker; use App\Support\ValidationPatterns; use Illuminate\Database\Eloquent\Model; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\DB; use OpenApi\Attributes as OA; @@ -4648,4 +4652,220 @@ class DatabasesController extends Controller { return $this->deleteTag($request); } + + #[OA\Post( + summary: 'Clone', + description: 'Clone a database to a destination owned by the authenticated team.', + path: '/databases/{uuid}/clone', + operationId: 'clone-database-by-uuid', + security: [['bearerAuth' => []]], + tags: ['Databases'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'UUID of the database.', schema: new OA\Schema(type: 'string')), + ], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + required: ['destination_uuid'], + properties: [ + new OA\Property(property: 'destination_uuid', type: 'string'), + new OA\Property(property: 'name', type: 'string', nullable: true), + new OA\Property(property: 'clone_volumes', type: 'boolean', default: false), + ] + ) + ), + responses: [ + new OA\Response(response: 201, description: 'Database cloned.'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ] + )] + public function clone_by_uuid(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + + $validator = customApiValidator($request->all(), [ + 'destination_uuid' => 'required|string', + 'name' => 'string|max:255|nullable', + 'clone_volumes' => 'boolean', + ]); + $allowedFields = ['destination_uuid', 'name', 'clone_volumes']; + $extraFields = array_diff(array_keys($request->all()), $allowedFields); + if ($validator->fails() || ! empty($extraFields)) { + $errors = $validator->errors(); + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $errors, + ], 422); + } + + $database = queryDatabaseByUuidWithinTeam($request->route('uuid'), $teamId); + if (! $database) { + return response()->json(['message' => 'Database not found.'], 404); + } + + $this->authorize('update', $database); + + $destination = StandaloneDocker::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->destination_uuid)->first() + ?? SwarmDocker::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->destination_uuid)->first(); + + if (! $destination || ! $destination->server?->canHostResources()) { + return response()->json(['message' => 'Destination not found.'], 404); + } + + $uuid = new_public_id(); + $name = $request->filled('name') + ? $request->string('name')->toString() + : $database->name.'-clone-'.$uuid; + $cloneVolumeData = $request->boolean('clone_volumes', false); + + $newDatabase = $database->replicate([ + 'id', + 'created_at', + 'updated_at', + ])->fill([ + 'uuid' => $uuid, + 'name' => $name, + 'status' => 'exited', + 'started_at' => null, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + $newDatabase->save(); + + foreach ($database->tags as $tag) { + $newDatabase->tags()->attach($tag->id); + } + + $newDatabase->persistentStorages()->delete(); + $pendingVolumeClones = []; + $sourceServer = $database->destination?->server; + $targetServer = $newDatabase->destination?->server; + + foreach ($database->persistentStorages()->get() as $volume) { + $originalName = $volume->name; + $newName = match (true) { + str_starts_with($originalName, 'postgres-data-') => 'postgres-data-'.$newDatabase->uuid, + str_starts_with($originalName, 'mysql-data-') => 'mysql-data-'.$newDatabase->uuid, + str_starts_with($originalName, 'redis-data-') => 'redis-data-'.$newDatabase->uuid, + str_starts_with($originalName, 'clickhouse-data-') => 'clickhouse-data-'.$newDatabase->uuid, + str_starts_with($originalName, 'mariadb-data-') => 'mariadb-data-'.$newDatabase->uuid, + str_starts_with($originalName, 'mongodb-data-') => 'mongodb-data-'.$newDatabase->uuid, + str_starts_with($originalName, 'keydb-data-') => 'keydb-data-'.$newDatabase->uuid, + str_starts_with($originalName, 'dragonfly-data-') => 'dragonfly-data-'.$newDatabase->uuid, + str_starts_with($volume->name, $database->uuid) => str($volume->name)->replace($database->uuid, $newDatabase->uuid)->toString(), + default => $newDatabase->uuid.'-'.$volume->name, + }; + + $newPersistentVolume = $volume->replicate([ + 'id', + 'created_at', + 'updated_at', + 'uuid', + ])->fill([ + 'name' => $newName, + 'resource_id' => $newDatabase->id, + ]); + $newPersistentVolume->save(); + + if ($cloneVolumeData) { + $pendingVolumeClones[] = [ + 'source' => $volume->name, + 'target' => $newPersistentVolume->name, + 'model' => $newPersistentVolume, + ]; + } + } + + // Stop once, clone all volumes, then start once — avoids per-volume stop/start races. + if ($pendingVolumeClones !== [] && $sourceServer && $targetServer) { + try { + $chain = [ + function () use ($database) { + StopDatabase::run($database); + }, + ]; + + foreach ($pendingVolumeClones as $clone) { + $chain[] = new VolumeCloneJob( + $clone['source'], + $clone['target'], + $sourceServer, + $targetServer, + $clone['model'], + ); + } + + $chain[] = function () use ($database) { + StartDatabase::run($database); + }; + + Bus::chain($chain)->onQueue('high')->dispatch(); + } catch (\Exception $e) { + \Log::error('Failed to queue database volume clone for '.$database->uuid.': '.$e->getMessage()); + } + } + + foreach ($database->fileStorages()->get() as $storage) { + $storage->replicate([ + 'id', + 'created_at', + 'updated_at', + ])->fill([ + 'resource_id' => $newDatabase->id, + ])->save(); + } + + foreach ($database->scheduledBackups()->get() as $backup) { + $backup->replicate([ + 'id', + 'created_at', + 'updated_at', + ])->fill([ + 'uuid' => new_public_id(), + 'database_id' => $newDatabase->id, + 'database_type' => $newDatabase->getMorphClass(), + 'team_id' => $teamId, + ])->save(); + } + + foreach ($database->environment_variables()->get() as $environmentVariable) { + $environmentVariable->replicate([ + 'id', + 'created_at', + 'updated_at', + ])->fill([ + 'resourceable_id' => $newDatabase->id, + 'resourceable_type' => $newDatabase->getMorphClass(), + ])->save(); + } + + auditLog('api.database.cloned', [ + 'team_id' => $teamId, + 'source_uuid' => $database->uuid, + 'database_uuid' => $newDatabase->uuid, + 'database_name' => $newDatabase->name, + 'destination_uuid' => $destination->uuid, + 'clone_volumes' => $cloneVolumeData, + ]); + + return response()->json([ + 'uuid' => $newDatabase->uuid, + 'message' => 'Database cloned.', + ], 201); + } } diff --git a/app/Http/Controllers/Api/DestinationsController.php b/app/Http/Controllers/Api/DestinationsController.php index a745ea5d2..53b4ef8b1 100644 --- a/app/Http/Controllers/Api/DestinationsController.php +++ b/app/Http/Controllers/Api/DestinationsController.php @@ -274,6 +274,84 @@ class DestinationsController extends Controller || in_array($driverCode, ['19', '1062', '2067'], true); } + #[OA\Patch( + summary: 'Update destination', + description: 'Update a Docker network destination name. Network cannot be changed via the API.', + path: '/destinations/{uuid}', + operationId: 'update-destination-by-uuid', + security: [['bearerAuth' => []]], + tags: ['Destinations'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Destination UUID', schema: new OA\Schema(type: 'string')), + ], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'name', type: 'string', maxLength: 255), + ], + type: 'object', + ), + ), + responses: [ + new OA\Response( + response: 200, + description: 'Destination updated.', + content: new OA\JsonContent(ref: '#/components/schemas/Destination'), + ), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ], + )] + public function update(Request $request, string $uuid): JsonResponse + { + $teamId = $this->teamIdOrAbort(); + if (! is_int($teamId)) { + return $teamId; + } + + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + + $allowed = ['name']; + + $validator = customApiValidator($request->all(), [ + 'name' => 'required|string|max:255', + ]); + $extra = array_diff(array_keys($request->all()), $allowed); + if ($validator->fails() || ! empty($extra)) { + $errors = $validator->errors(); + if (! empty($extra)) { + foreach ($extra as $field) { + $errors->add($field, 'This field is not allowed.'); + } + } + + return response()->json(['message' => 'Validation failed.', 'errors' => $errors], 422); + } + + $destination = $this->findDestinationForTeam($teamId, $uuid); + + $this->authorize('update', $destination); + + $destination->update(['name' => $request->input('name')]); + $destination->load('server:id,uuid'); + + auditLog('api.destination.updated', [ + 'team_id' => $teamId, + 'destination_uuid' => $destination->uuid, + 'destination_name' => $destination->name, + 'destination_type' => $destination instanceof SwarmDocker ? 'swarm' : 'standalone', + 'server_uuid' => $destination->server?->uuid, + 'changed_fields' => ['name'], + ]); + + return response()->json($this->transform($destination)); + } + #[OA\Delete( summary: 'Delete destination', description: 'Delete an unused Docker network destination.', diff --git a/app/Http/Controllers/Api/NotificationsController.php b/app/Http/Controllers/Api/NotificationsController.php new file mode 100644 index 000000000..99d0cb897 --- /dev/null +++ b/app/Http/Controllers/Api/NotificationsController.php @@ -0,0 +1,511 @@ +, rules: array} + */ + private function channelConfig(string $channel): array + { + return match ($channel) { + 'email' => [ + 'model' => EmailNotificationSettings::class, + 'rules' => [ + 'smtp_enabled' => 'sometimes|boolean', + 'smtp_from_address' => 'sometimes|nullable|email', + 'smtp_from_name' => 'sometimes|nullable|string|max:255', + 'smtp_recipients' => 'sometimes|nullable|string|max:1000', + 'smtp_host' => 'sometimes|nullable|string|max:255', + 'smtp_port' => 'sometimes|nullable|integer|min:1|max:65535', + 'smtp_encryption' => 'sometimes|nullable|string|in:starttls,tls,none', + 'smtp_username' => 'sometimes|nullable|string|max:255', + 'smtp_password' => 'sometimes|nullable|string|max:255', + 'smtp_timeout' => 'sometimes|nullable|integer|min:0', + 'resend_enabled' => 'sometimes|boolean', + 'resend_api_key' => 'sometimes|nullable|string|max:255', + 'use_instance_email_settings' => 'sometimes|boolean', + 'deployment_success_email_notifications' => 'sometimes|boolean', + 'deployment_failure_email_notifications' => 'sometimes|boolean', + 'status_change_email_notifications' => 'sometimes|boolean', + 'backup_success_email_notifications' => 'sometimes|boolean', + 'backup_failure_email_notifications' => 'sometimes|boolean', + 'scheduled_task_success_email_notifications' => 'sometimes|boolean', + 'scheduled_task_failure_email_notifications' => 'sometimes|boolean', + 'docker_cleanup_success_email_notifications' => 'sometimes|boolean', + 'docker_cleanup_failure_email_notifications' => 'sometimes|boolean', + 'server_disk_usage_email_notifications' => 'sometimes|boolean', + 'server_reachable_email_notifications' => 'sometimes|boolean', + 'server_unreachable_email_notifications' => 'sometimes|boolean', + 'server_patch_email_notifications' => 'sometimes|boolean', + 'traefik_outdated_email_notifications' => 'sometimes|boolean', + ], + ], + 'discord' => [ + 'model' => DiscordNotificationSettings::class, + 'rules' => [ + 'discord_enabled' => 'sometimes|boolean', + 'discord_webhook_url' => ['sometimes', 'nullable', 'string', new SafeWebhookUrl], + 'deployment_success_discord_notifications' => 'sometimes|boolean', + 'deployment_failure_discord_notifications' => 'sometimes|boolean', + 'status_change_discord_notifications' => 'sometimes|boolean', + 'backup_success_discord_notifications' => 'sometimes|boolean', + 'backup_failure_discord_notifications' => 'sometimes|boolean', + 'scheduled_task_success_discord_notifications' => 'sometimes|boolean', + 'scheduled_task_failure_discord_notifications' => 'sometimes|boolean', + 'docker_cleanup_success_discord_notifications' => 'sometimes|boolean', + 'docker_cleanup_failure_discord_notifications' => 'sometimes|boolean', + 'server_disk_usage_discord_notifications' => 'sometimes|boolean', + 'server_reachable_discord_notifications' => 'sometimes|boolean', + 'server_unreachable_discord_notifications' => 'sometimes|boolean', + 'server_patch_discord_notifications' => 'sometimes|boolean', + 'traefik_outdated_discord_notifications' => 'sometimes|boolean', + 'discord_ping_enabled' => 'sometimes|boolean', + ], + ], + 'slack' => [ + 'model' => SlackNotificationSettings::class, + 'rules' => [ + 'slack_enabled' => 'sometimes|boolean', + 'slack_webhook_url' => ['sometimes', 'nullable', 'string', new SafeWebhookUrl], + 'deployment_success_slack_notifications' => 'sometimes|boolean', + 'deployment_failure_slack_notifications' => 'sometimes|boolean', + 'status_change_slack_notifications' => 'sometimes|boolean', + 'backup_success_slack_notifications' => 'sometimes|boolean', + 'backup_failure_slack_notifications' => 'sometimes|boolean', + 'scheduled_task_success_slack_notifications' => 'sometimes|boolean', + 'scheduled_task_failure_slack_notifications' => 'sometimes|boolean', + 'docker_cleanup_success_slack_notifications' => 'sometimes|boolean', + 'docker_cleanup_failure_slack_notifications' => 'sometimes|boolean', + 'server_disk_usage_slack_notifications' => 'sometimes|boolean', + 'server_reachable_slack_notifications' => 'sometimes|boolean', + 'server_unreachable_slack_notifications' => 'sometimes|boolean', + 'server_patch_slack_notifications' => 'sometimes|boolean', + 'traefik_outdated_slack_notifications' => 'sometimes|boolean', + ], + ], + 'telegram' => [ + 'model' => TelegramNotificationSettings::class, + 'rules' => [ + 'telegram_enabled' => 'sometimes|boolean', + 'telegram_token' => 'sometimes|nullable|string|max:255', + 'telegram_chat_id' => 'sometimes|nullable|string|max:255', + 'deployment_success_telegram_notifications' => 'sometimes|boolean', + 'deployment_failure_telegram_notifications' => 'sometimes|boolean', + 'status_change_telegram_notifications' => 'sometimes|boolean', + 'backup_success_telegram_notifications' => 'sometimes|boolean', + 'backup_failure_telegram_notifications' => 'sometimes|boolean', + 'scheduled_task_success_telegram_notifications' => 'sometimes|boolean', + 'scheduled_task_failure_telegram_notifications' => 'sometimes|boolean', + 'docker_cleanup_success_telegram_notifications' => 'sometimes|boolean', + 'docker_cleanup_failure_telegram_notifications' => 'sometimes|boolean', + 'server_disk_usage_telegram_notifications' => 'sometimes|boolean', + 'server_reachable_telegram_notifications' => 'sometimes|boolean', + 'server_unreachable_telegram_notifications' => 'sometimes|boolean', + 'server_patch_telegram_notifications' => 'sometimes|boolean', + 'traefik_outdated_telegram_notifications' => 'sometimes|boolean', + 'telegram_notifications_deployment_success_thread_id' => 'sometimes|nullable|string|max:255', + 'telegram_notifications_deployment_failure_thread_id' => 'sometimes|nullable|string|max:255', + 'telegram_notifications_status_change_thread_id' => 'sometimes|nullable|string|max:255', + 'telegram_notifications_backup_success_thread_id' => 'sometimes|nullable|string|max:255', + 'telegram_notifications_backup_failure_thread_id' => 'sometimes|nullable|string|max:255', + 'telegram_notifications_scheduled_task_success_thread_id' => 'sometimes|nullable|string|max:255', + 'telegram_notifications_scheduled_task_failure_thread_id' => 'sometimes|nullable|string|max:255', + 'telegram_notifications_docker_cleanup_success_thread_id' => 'sometimes|nullable|string|max:255', + 'telegram_notifications_docker_cleanup_failure_thread_id' => 'sometimes|nullable|string|max:255', + 'telegram_notifications_server_disk_usage_thread_id' => 'sometimes|nullable|string|max:255', + 'telegram_notifications_server_reachable_thread_id' => 'sometimes|nullable|string|max:255', + 'telegram_notifications_server_unreachable_thread_id' => 'sometimes|nullable|string|max:255', + 'telegram_notifications_server_patch_thread_id' => 'sometimes|nullable|string|max:255', + 'telegram_notifications_traefik_outdated_thread_id' => 'sometimes|nullable|string|max:255', + ], + ], + 'pushover' => [ + 'model' => PushoverNotificationSettings::class, + 'rules' => [ + 'pushover_enabled' => 'sometimes|boolean', + 'pushover_user_key' => 'sometimes|nullable|string|max:255', + 'pushover_api_token' => 'sometimes|nullable|string|max:255', + 'deployment_success_pushover_notifications' => 'sometimes|boolean', + 'deployment_failure_pushover_notifications' => 'sometimes|boolean', + 'status_change_pushover_notifications' => 'sometimes|boolean', + 'backup_success_pushover_notifications' => 'sometimes|boolean', + 'backup_failure_pushover_notifications' => 'sometimes|boolean', + 'scheduled_task_success_pushover_notifications' => 'sometimes|boolean', + 'scheduled_task_failure_pushover_notifications' => 'sometimes|boolean', + 'docker_cleanup_success_pushover_notifications' => 'sometimes|boolean', + 'docker_cleanup_failure_pushover_notifications' => 'sometimes|boolean', + 'server_disk_usage_pushover_notifications' => 'sometimes|boolean', + 'server_reachable_pushover_notifications' => 'sometimes|boolean', + 'server_unreachable_pushover_notifications' => 'sometimes|boolean', + 'server_patch_pushover_notifications' => 'sometimes|boolean', + 'traefik_outdated_pushover_notifications' => 'sometimes|boolean', + ], + ], + 'webhook' => [ + 'model' => WebhookNotificationSettings::class, + 'rules' => [ + 'webhook_enabled' => 'sometimes|boolean', + 'webhook_url' => ['sometimes', 'nullable', 'string', new SafeWebhookUrl], + 'deployment_success_webhook_notifications' => 'sometimes|boolean', + 'deployment_failure_webhook_notifications' => 'sometimes|boolean', + 'status_change_webhook_notifications' => 'sometimes|boolean', + 'backup_success_webhook_notifications' => 'sometimes|boolean', + 'backup_failure_webhook_notifications' => 'sometimes|boolean', + 'scheduled_task_success_webhook_notifications' => 'sometimes|boolean', + 'scheduled_task_failure_webhook_notifications' => 'sometimes|boolean', + 'docker_cleanup_success_webhook_notifications' => 'sometimes|boolean', + 'docker_cleanup_failure_webhook_notifications' => 'sometimes|boolean', + 'server_disk_usage_webhook_notifications' => 'sometimes|boolean', + 'server_reachable_webhook_notifications' => 'sometimes|boolean', + 'server_unreachable_webhook_notifications' => 'sometimes|boolean', + 'server_patch_webhook_notifications' => 'sometimes|boolean', + 'traefik_outdated_webhook_notifications' => 'sometimes|boolean', + ], + ], + default => throw new \InvalidArgumentException("Unknown notification channel [{$channel}]."), + }; + } + + /** + * @return list + */ + private function allowedFields(string $channel): array + { + $config = $this->channelConfig($channel); + /** @var Model $model */ + $model = new $config['model']; + + return array_values(array_filter( + $model->getFillable(), + fn (string $field): bool => $field !== 'team_id' + )); + } + + private function serializeSettings(Model $settings): array + { + exposeSensitiveFields($settings); + + $settings->makeHidden(['team']); + + return serializeApiResponse($settings)->toArray(); + } + + private function resolveSettings(string $channel, int $teamId): Model + { + $config = $this->channelConfig($channel); + $modelClass = $config['model']; + + /** @var Model $settings */ + $settings = $modelClass::query()->firstOrCreate(['team_id' => $teamId]); + $settings->setRelation('team', Team::query()->findOrFail($teamId)); + + return $settings; + } + + private function showChannel(string $channel): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $settings = $this->resolveSettings($channel, $teamId); + $this->authorize('view', $settings); + + return response()->json($this->serializeSettings($settings)); + } + + private function updateChannel(Request $request, string $channel): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + + $allowedFields = $this->allowedFields($channel); + $body = $request->json()->all(); + $config = $this->channelConfig($channel); + + $validator = customApiValidator($body, $config['rules']); + + $extraFields = array_diff(array_keys($body), $allowedFields); + if ($validator->fails() || ! empty($extraFields)) { + $errors = $validator->errors(); + if (! empty($extraFields)) { + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + } + + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $errors, + ], 422); + } + + $settings = $this->resolveSettings($channel, $teamId); + $this->authorize('update', $settings); + + $settings->fill(array_intersect_key($body, array_flip($allowedFields))); + $settings->save(); + + auditLog("api.notifications.{$channel}.updated", [ + 'team_id' => $teamId, + 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($body))), + ]); + + $settings->refresh(); + $settings->setRelation('team', Team::query()->findOrFail($teamId)); + + return response()->json($this->serializeSettings($settings)); + } + + #[OA\Get( + summary: 'Get email notification settings', + description: 'Get the current team email notification settings. Encrypted secrets are only returned when the token has `read:sensitive` (or `root`) and the user is a team admin/owner.', + path: '/notifications/email', + operationId: 'get-current-team-email-notifications', + security: [['bearerAuth' => []]], + tags: ['Notifications'], + responses: [ + new OA\Response(response: 200, description: 'Email notification settings.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + ] + )] + public function email(Request $request): JsonResponse + { + return $this->showChannel('email'); + } + + #[OA\Patch( + summary: 'Update email notification settings', + description: 'Update the current team email notification settings.', + path: '/notifications/email', + operationId: 'update-current-team-email-notifications', + security: [['bearerAuth' => []]], + tags: ['Notifications'], + responses: [ + new OA\Response(response: 200, description: 'Updated email notification settings.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 403, description: 'Forbidden.'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ] + )] + public function update_email(Request $request): JsonResponse + { + return $this->updateChannel($request, 'email'); + } + + #[OA\Get( + summary: 'Get Discord notification settings', + description: 'Get the current team Discord notification settings. Encrypted secrets are only returned when the token has `read:sensitive` (or `root`) and the user is a team admin/owner.', + path: '/notifications/discord', + operationId: 'get-current-team-discord-notifications', + security: [['bearerAuth' => []]], + tags: ['Notifications'], + responses: [ + new OA\Response(response: 200, description: 'Discord notification settings.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + ] + )] + public function discord(Request $request): JsonResponse + { + return $this->showChannel('discord'); + } + + #[OA\Patch( + summary: 'Update Discord notification settings', + description: 'Update the current team Discord notification settings.', + path: '/notifications/discord', + operationId: 'update-current-team-discord-notifications', + security: [['bearerAuth' => []]], + tags: ['Notifications'], + responses: [ + new OA\Response(response: 200, description: 'Updated Discord notification settings.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 403, description: 'Forbidden.'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ] + )] + public function update_discord(Request $request): JsonResponse + { + return $this->updateChannel($request, 'discord'); + } + + #[OA\Get( + summary: 'Get Slack notification settings', + description: 'Get the current team Slack notification settings. Encrypted secrets are only returned when the token has `read:sensitive` (or `root`) and the user is a team admin/owner.', + path: '/notifications/slack', + operationId: 'get-current-team-slack-notifications', + security: [['bearerAuth' => []]], + tags: ['Notifications'], + responses: [ + new OA\Response(response: 200, description: 'Slack notification settings.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + ] + )] + public function slack(Request $request): JsonResponse + { + return $this->showChannel('slack'); + } + + #[OA\Patch( + summary: 'Update Slack notification settings', + description: 'Update the current team Slack notification settings.', + path: '/notifications/slack', + operationId: 'update-current-team-slack-notifications', + security: [['bearerAuth' => []]], + tags: ['Notifications'], + responses: [ + new OA\Response(response: 200, description: 'Updated Slack notification settings.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 403, description: 'Forbidden.'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ] + )] + public function update_slack(Request $request): JsonResponse + { + return $this->updateChannel($request, 'slack'); + } + + #[OA\Get( + summary: 'Get Telegram notification settings', + description: 'Get the current team Telegram notification settings. Encrypted secrets are only returned when the token has `read:sensitive` (or `root`) and the user is a team admin/owner.', + path: '/notifications/telegram', + operationId: 'get-current-team-telegram-notifications', + security: [['bearerAuth' => []]], + tags: ['Notifications'], + responses: [ + new OA\Response(response: 200, description: 'Telegram notification settings.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + ] + )] + public function telegram(Request $request): JsonResponse + { + return $this->showChannel('telegram'); + } + + #[OA\Patch( + summary: 'Update Telegram notification settings', + description: 'Update the current team Telegram notification settings.', + path: '/notifications/telegram', + operationId: 'update-current-team-telegram-notifications', + security: [['bearerAuth' => []]], + tags: ['Notifications'], + responses: [ + new OA\Response(response: 200, description: 'Updated Telegram notification settings.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 403, description: 'Forbidden.'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ] + )] + public function update_telegram(Request $request): JsonResponse + { + return $this->updateChannel($request, 'telegram'); + } + + #[OA\Get( + summary: 'Get Pushover notification settings', + description: 'Get the current team Pushover notification settings. Encrypted secrets are only returned when the token has `read:sensitive` (or `root`) and the user is a team admin/owner.', + path: '/notifications/pushover', + operationId: 'get-current-team-pushover-notifications', + security: [['bearerAuth' => []]], + tags: ['Notifications'], + responses: [ + new OA\Response(response: 200, description: 'Pushover notification settings.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + ] + )] + public function pushover(Request $request): JsonResponse + { + return $this->showChannel('pushover'); + } + + #[OA\Patch( + summary: 'Update Pushover notification settings', + description: 'Update the current team Pushover notification settings.', + path: '/notifications/pushover', + operationId: 'update-current-team-pushover-notifications', + security: [['bearerAuth' => []]], + tags: ['Notifications'], + responses: [ + new OA\Response(response: 200, description: 'Updated Pushover notification settings.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 403, description: 'Forbidden.'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ] + )] + public function update_pushover(Request $request): JsonResponse + { + return $this->updateChannel($request, 'pushover'); + } + + #[OA\Get( + summary: 'Get webhook notification settings', + description: 'Get the current team webhook notification settings. Encrypted secrets are only returned when the token has `read:sensitive` (or `root`) and the user is a team admin/owner.', + path: '/notifications/webhook', + operationId: 'get-current-team-webhook-notifications', + security: [['bearerAuth' => []]], + tags: ['Notifications'], + responses: [ + new OA\Response(response: 200, description: 'Webhook notification settings.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + ] + )] + public function webhook(Request $request): JsonResponse + { + return $this->showChannel('webhook'); + } + + #[OA\Patch( + summary: 'Update webhook notification settings', + description: 'Update the current team webhook notification settings.', + path: '/notifications/webhook', + operationId: 'update-current-team-webhook-notifications', + security: [['bearerAuth' => []]], + tags: ['Notifications'], + responses: [ + new OA\Response(response: 200, description: 'Updated webhook notification settings.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 403, description: 'Forbidden.'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ] + )] + public function update_webhook(Request $request): JsonResponse + { + return $this->updateChannel($request, 'webhook'); + } +} diff --git a/app/Http/Controllers/Api/ProjectController.php b/app/Http/Controllers/Api/ProjectController.php index ea3b54e80..64bf26c1b 100644 --- a/app/Http/Controllers/Api/ProjectController.php +++ b/app/Http/Controllers/Api/ProjectController.php @@ -682,6 +682,155 @@ class ProjectController extends Controller ])->setStatusCode(201); } + #[OA\Patch( + summary: 'Update Environment', + description: 'Update environment by name or UUID within a project.', + path: '/projects/{uuid}/environments/{environment_name_or_uuid}', + operationId: 'update-environment', + security: [ + ['bearerAuth' => []], + ], + tags: ['Projects'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Project UUID', schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'environment_name_or_uuid', in: 'path', required: true, description: 'Environment name or UUID', schema: new OA\Schema(type: 'string')), + ], + requestBody: new OA\RequestBody( + required: true, + description: 'Environment fields to update.', + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'name' => ['type' => 'string', 'description' => 'The name of the environment.'], + 'description' => ['type' => 'string', 'description' => 'The description of the environment.'], + ], + ), + ), + ), + responses: [ + new OA\Response( + response: 200, + description: 'Environment updated.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'uuid' => ['type' => 'string', 'example' => 'env123'], + 'name' => ['type' => 'string', 'example' => 'staging'], + 'description' => ['type' => 'string', 'example' => 'Staging environment'], + ] + ) + ), + ]), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 404, + description: 'Project or environment not found.', + ), + new OA\Response( + response: 409, + description: 'Environment with this name already exists.', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function update_environment(Request $request) + { + $allowedFields = ['name', 'description']; + + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + + $validator = Validator::make($request->all(), [ + 'name' => ValidationPatterns::nameRules(required: false), + 'description' => ValidationPatterns::descriptionRules(), + ], ValidationPatterns::combinedMessages()); + + $extraFields = array_diff(array_keys($request->all()), $allowedFields); + if ($validator->fails() || ! empty($extraFields)) { + $errors = $validator->errors(); + if (! empty($extraFields)) { + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + } + + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $errors, + ], 422); + } + + if (! $request->uuid) { + return response()->json(['message' => 'Project UUID is required.'], 422); + } + if (! $request->environment_name_or_uuid) { + return response()->json(['message' => 'Environment name or UUID is required.'], 422); + } + + $project = Project::whereTeamId($teamId)->whereUuid($request->uuid)->first(); + if (! $project) { + return response()->json(['message' => 'Project not found.'], 404); + } + + $environment = $project->environments()->whereName($request->environment_name_or_uuid)->first(); + if (! $environment) { + $environment = $project->environments()->whereUuid($request->environment_name_or_uuid)->first(); + } + if (! $environment) { + return response()->json(['message' => 'Environment not found.'], 404); + } + + $this->authorize('update', $environment); + + if ($request->filled('name') && $request->name !== $environment->name) { + $existingEnvironment = $project->environments() + ->where('name', $request->name) + ->where('id', '!=', $environment->id) + ->first(); + if ($existingEnvironment) { + return response()->json(['message' => 'Environment with this name already exists.'], 409); + } + } + + $environment->update($request->only($allowedFields)); + + auditLog('api.project.environment_updated', [ + 'team_id' => $teamId, + 'project_uuid' => $project->uuid, + 'environment_uuid' => $environment->uuid, + 'environment_name' => $environment->name, + 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))), + ]); + + return response()->json([ + 'uuid' => $environment->uuid, + 'name' => $environment->name, + 'description' => $environment->description, + ]); + } + #[OA\Delete( summary: 'Delete Environment', description: 'Delete environment by name or UUID. Environment must be empty.', diff --git a/app/Http/Controllers/Api/S3StoragesController.php b/app/Http/Controllers/Api/S3StoragesController.php new file mode 100644 index 000000000..c2ad48c51 --- /dev/null +++ b/app/Http/Controllers/Api/S3StoragesController.php @@ -0,0 +1,566 @@ +makeHidden([ + 'id', + ]); + + if (request()->attributes->get('can_read_sensitive', false) === true) { + $storage->makeVisible([ + 'key', + 'secret', + ]); + } + + return serializeApiResponse($storage); + } + + /** + * @return array{valid: bool, error: string|null} + */ + private function validateStorageConnection(S3Storage $storage): array + { + try { + $storage->testConnection(shouldSave: true); + + return ['valid' => true, 'error' => null]; + } catch (\Throwable $e) { + return ['valid' => false, 'error' => $e->getMessage()]; + } + } + + /** + * @param array $body + * @param array $allowedFields + * @param array $rules + */ + private function validateBody(array $body, array $allowedFields, array $rules): ?JsonResponse + { + $validator = customApiValidator($body, $rules); + + $extraFields = array_diff(array_keys($body), $allowedFields); + if ($validator->fails() || ! empty($extraFields)) { + $errors = $validator->errors(); + if (! empty($extraFields)) { + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + } + + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $errors, + ], 422); + } + + return null; + } + + #[OA\Get( + summary: 'List S3 Storages', + description: 'List all S3 storages for the authenticated team.', + path: '/s3-storages', + operationId: 'list-s3-storages', + security: [ + ['bearerAuth' => []], + ], + tags: ['S3 Storages'], + responses: [ + new OA\Response( + response: 200, + description: 'Get all S3 storages.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items( + type: 'object', + properties: [ + 'uuid' => ['type' => 'string'], + 'name' => ['type' => 'string'], + 'description' => ['type' => 'string', 'nullable' => true], + 'endpoint' => ['type' => 'string'], + 'bucket' => ['type' => 'string'], + 'region' => ['type' => 'string'], + 'is_usable' => ['type' => 'boolean'], + 'team_id' => ['type' => 'integer'], + 'created_at' => ['type' => 'string'], + 'updated_at' => ['type' => 'string'], + ] + ) + ) + ), + ]), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + ] + )] + public function index(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $storages = S3Storage::ownedByCurrentTeamAPI($teamId) + ->get() + ->map(function ($storage) { + return $this->removeSensitiveData($storage); + }); + + return response()->json($storages); + } + + #[OA\Get( + summary: 'Get S3 Storage', + description: 'Get S3 storage by UUID.', + path: '/s3-storages/{uuid}', + operationId: 'get-s3-storage-by-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['S3 Storages'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'S3 Storage UUID', schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Get S3 storage by UUID', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'uuid' => ['type' => 'string'], + 'name' => ['type' => 'string'], + 'description' => ['type' => 'string', 'nullable' => true], + 'endpoint' => ['type' => 'string'], + 'bucket' => ['type' => 'string'], + 'region' => ['type' => 'string'], + 'is_usable' => ['type' => 'boolean'], + 'team_id' => ['type' => 'integer'], + 'created_at' => ['type' => 'string'], + 'updated_at' => ['type' => 'string'], + ] + ) + ), + ]), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function show(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $storage = S3Storage::ownedByCurrentTeamAPI($teamId) + ->whereUuid($request->uuid) + ->first(); + + if (is_null($storage)) { + return response()->json(['message' => 'S3 storage not found.'], 404); + } + $this->authorize('view', $storage); + + return response()->json($this->removeSensitiveData($storage)); + } + + #[OA\Post( + summary: 'Create S3 Storage', + description: 'Create a new S3 storage configuration for the authenticated team.', + path: '/s3-storages', + operationId: 'create-s3-storage', + security: [ + ['bearerAuth' => []], + ], + tags: ['S3 Storages'], + requestBody: new OA\RequestBody( + required: true, + description: 'S3 storage details', + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + required: ['name', 'endpoint', 'bucket', 'region', 'key', 'secret'], + properties: [ + 'name' => ['type' => 'string', 'example' => 'My S3 Storage', 'description' => 'A friendly name for the storage.'], + 'description' => ['type' => 'string', 'nullable' => true, 'description' => 'Optional description.'], + 'endpoint' => ['type' => 'string', 'example' => 'https://s3.us-east-1.amazonaws.com', 'description' => 'S3-compatible endpoint URL.'], + 'bucket' => ['type' => 'string', 'example' => 'my-bucket', 'description' => 'S3 bucket name.'], + 'region' => ['type' => 'string', 'example' => 'us-east-1', 'description' => 'S3 region.'], + 'key' => ['type' => 'string', 'description' => 'Access key.'], + 'secret' => ['type' => 'string', 'description' => 'Secret key.'], + 'is_usable' => ['type' => 'boolean', 'description' => 'Whether the storage is marked usable.'], + ], + ), + ), + ), + responses: [ + new OA\Response( + response: 201, + description: 'S3 storage created.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'uuid' => ['type' => 'string', 'example' => 'og888os', 'description' => 'The UUID of the S3 storage.'], + ] + ) + ), + ]), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function store(Request $request) + { + $allowedFields = ['name', 'description', 'endpoint', 'bucket', 'region', 'key', 'secret', 'is_usable']; + + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + $this->authorize('create', [S3Storage::class]); + + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + + $body = $request->json()->all(); + + $validationError = $this->validateBody($body, $allowedFields, [ + 'name' => ValidationPatterns::nameRules(), + 'description' => ValidationPatterns::descriptionRules(), + 'endpoint' => ['required', 'string', 'max:255', new SafeWebhookUrl], + 'bucket' => ['required', new ValidS3BucketName], + 'region' => 'required|string|max:255', + 'key' => 'required|string|max:255', + 'secret' => 'required|string|max:255', + 'is_usable' => 'sometimes|boolean', + ]); + if ($validationError instanceof JsonResponse) { + return $validationError; + } + + $storage = S3Storage::create([ + 'team_id' => $teamId, + 'name' => $body['name'], + 'description' => $body['description'] ?? null, + 'endpoint' => $body['endpoint'], + 'bucket' => $body['bucket'], + 'region' => $body['region'], + 'key' => $body['key'], + 'secret' => $body['secret'], + 'is_usable' => $body['is_usable'] ?? false, + ]); + + auditLog('api.s3_storage.created', [ + 'team_id' => $teamId, + 's3_storage_uuid' => $storage->uuid, + 's3_storage_name' => $storage->name, + ]); + + return response()->json([ + 'uuid' => $storage->uuid, + ])->setStatusCode(201); + } + + #[OA\Patch( + summary: 'Update S3 Storage', + description: 'Update S3 storage by UUID.', + path: '/s3-storages/{uuid}', + operationId: 'update-s3-storage-by-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['S3 Storages'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'S3 Storage UUID', schema: new OA\Schema(type: 'string')), + ], + requestBody: new OA\RequestBody( + required: true, + description: 'S3 storage fields to update.', + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'name' => ['type' => 'string', 'description' => 'A friendly name for the storage.'], + 'description' => ['type' => 'string', 'nullable' => true, 'description' => 'Optional description.'], + 'endpoint' => ['type' => 'string', 'description' => 'S3-compatible endpoint URL.'], + 'bucket' => ['type' => 'string', 'description' => 'S3 bucket name.'], + 'region' => ['type' => 'string', 'description' => 'S3 region.'], + 'key' => ['type' => 'string', 'description' => 'Access key.'], + 'secret' => ['type' => 'string', 'description' => 'Secret key.'], + 'is_usable' => ['type' => 'boolean', 'description' => 'Whether the storage is marked usable.'], + ], + ), + ), + ), + responses: [ + new OA\Response( + response: 200, + description: 'S3 storage updated.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'uuid' => ['type' => 'string'], + ] + ) + ), + ]), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function update(Request $request) + { + $allowedFields = ['name', 'description', 'endpoint', 'bucket', 'region', 'key', 'secret', 'is_usable']; + + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + + $body = $request->json()->all(); + + $validationError = $this->validateBody($body, $allowedFields, [ + 'name' => ValidationPatterns::nameRules(required: false), + 'description' => ValidationPatterns::descriptionRules(), + 'endpoint' => ['sometimes', 'string', 'max:255', new SafeWebhookUrl], + 'bucket' => ['sometimes', new ValidS3BucketName], + 'region' => 'sometimes|string|max:255', + 'key' => 'sometimes|string|max:255', + 'secret' => 'sometimes|string|max:255', + 'is_usable' => 'sometimes|boolean', + ]); + if ($validationError instanceof JsonResponse) { + return $validationError; + } + + $storage = S3Storage::ownedByCurrentTeamAPI($teamId)->whereUuid($request->route('uuid'))->first(); + if (! $storage) { + return response()->json(['message' => 'S3 storage not found.'], 404); + } + $this->authorize('update', $storage); + + $storage->update(array_intersect_key($body, array_flip($allowedFields))); + + auditLog('api.s3_storage.updated', [ + 'team_id' => $teamId, + 's3_storage_uuid' => $storage->uuid, + 's3_storage_name' => $storage->name, + 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($body))), + ]); + + return response()->json([ + 'uuid' => $storage->uuid, + ]); + } + + #[OA\Delete( + summary: 'Delete S3 Storage', + description: 'Delete S3 storage by UUID.', + path: '/s3-storages/{uuid}', + operationId: 'delete-s3-storage-by-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['S3 Storages'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the S3 storage.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'S3 storage deleted.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'S3 storage deleted.'], + ] + ) + ), + ]), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function destroy(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + if (! $request->uuid) { + return response()->json(['message' => 'UUID is required.'], 422); + } + + $storage = S3Storage::ownedByCurrentTeamAPI($teamId)->whereUuid($request->uuid)->first(); + + if (! $storage) { + return response()->json(['message' => 'S3 storage not found.'], 404); + } + $this->authorize('delete', $storage); + + $storageUuid = $storage->uuid; + $storageName = $storage->name; + $storage->delete(); + + auditLog('api.s3_storage.deleted', [ + 'team_id' => $teamId, + 's3_storage_uuid' => $storageUuid, + 's3_storage_name' => $storageName, + ]); + + return response()->json(['message' => 'S3 storage deleted.']); + } + + #[OA\Post( + summary: 'Validate S3 Storage', + description: 'Validate an S3 storage connection using ListObjectsV2.', + path: '/s3-storages/{uuid}/validate', + operationId: 'validate-s3-storage-by-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['S3 Storages'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'S3 Storage UUID', schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'S3 storage validation result.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'valid' => ['type' => 'boolean', 'example' => true], + 'message' => ['type' => 'string', 'example' => 'S3 storage connection is valid.'], + ] + ) + ), + ]), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function validateStorage(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $storage = S3Storage::ownedByCurrentTeamAPI($teamId)->whereUuid($request->uuid)->first(); + + if (! $storage) { + return response()->json(['message' => 'S3 storage not found.'], 404); + } + $this->authorize('validateConnection', $storage); + + $validation = $this->validateStorageConnection($storage); + + auditLog('api.s3_storage.validated', [ + 'team_id' => $teamId, + 's3_storage_uuid' => $storage->uuid, + 's3_storage_name' => $storage->name, + 'valid' => $validation['valid'], + ]); + + return response()->json([ + 'valid' => $validation['valid'], + 'message' => $validation['valid'] ? 'S3 storage connection is valid.' : $validation['error'], + ]); + } +} diff --git a/app/Http/Controllers/Api/ScheduledTasksController.php b/app/Http/Controllers/Api/ScheduledTasksController.php index d7b109918..c0eff2052 100644 --- a/app/Http/Controllers/Api/ScheduledTasksController.php +++ b/app/Http/Controllers/Api/ScheduledTasksController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; +use App\Jobs\ScheduledTaskJob; use App\Models\Application; use App\Models\ScheduledTask; use App\Models\Service; @@ -224,6 +225,28 @@ class ScheduledTasksController extends Controller return response()->json($executions); } + private function executeTask(Request $request, Application|Service $resource): JsonResponse + { + $this->authorize('update', $resource); + + $task = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->first(); + if (! $task) { + return response()->json(['message' => 'Scheduled task not found.'], 404); + } + + ScheduledTaskJob::dispatch($task); + + auditLog('api.scheduled_task.executed', [ + 'team_id' => getTeamIdFromToken(), + 'task_uuid' => $task->uuid, + 'task_name' => $task->name, + 'resource_type' => $resource instanceof Application ? 'application' : 'service', + 'resource_uuid' => $resource->uuid, + ]); + + return response()->json(['message' => 'Scheduled task execution queued.']); + } + #[OA\Get( summary: 'List Tasks', description: 'List all scheduled tasks for an application.', @@ -949,4 +972,68 @@ class ScheduledTasksController extends Controller return $this->getExecutions($request, $service); } + + #[OA\Post( + summary: 'Execute Task', + description: 'Queue immediate execution of a scheduled task for an application.', + path: '/applications/{uuid}/scheduled-tasks/{task_uuid}/execute', + operationId: 'execute-scheduled-task-by-application-uuid', + security: [['bearerAuth' => []]], + tags: ['Scheduled Tasks'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'UUID of the application.', schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'task_uuid', in: 'path', required: true, description: 'UUID of the scheduled task.', schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response(response: 200, description: 'Scheduled task execution queued.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ] + )] + public function execute_scheduled_task_by_application_uuid(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $application = $this->resolveApplication($request, $teamId); + if (! $application) { + return response()->json(['message' => 'Application not found.'], 404); + } + + return $this->executeTask($request, $application); + } + + #[OA\Post( + summary: 'Execute Task', + description: 'Queue immediate execution of a scheduled task for a service.', + path: '/services/{uuid}/scheduled-tasks/{task_uuid}/execute', + operationId: 'execute-scheduled-task-by-service-uuid', + security: [['bearerAuth' => []]], + tags: ['Scheduled Tasks'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'UUID of the service.', schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'task_uuid', in: 'path', required: true, description: 'UUID of the scheduled task.', schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response(response: 200, description: 'Scheduled task execution queued.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ] + )] + public function execute_scheduled_task_by_service_uuid(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $service = $this->resolveService($request, $teamId); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + return $this->executeTask($request, $service); + } } diff --git a/app/Http/Controllers/Api/ServerCloudflareTunnelController.php b/app/Http/Controllers/Api/ServerCloudflareTunnelController.php new file mode 100644 index 000000000..76be72171 --- /dev/null +++ b/app/Http/Controllers/Api/ServerCloudflareTunnelController.php @@ -0,0 +1,269 @@ +whereUuid($uuid)->first(); + } + + private function transform(Server $server): array + { + return [ + 'is_cloudflare_tunnel' => (bool) $server->settings->is_cloudflare_tunnel, + 'ip' => $server->ip, + 'ip_previous' => $server->ip_previous, + ]; + } + + #[OA\Get( + summary: 'Get Cloudflare Tunnel settings', + description: 'Get Cloudflare Tunnel settings for a server owned by the authenticated team.', + path: '/servers/{uuid}/cloudflare-tunnel', + operationId: 'get-server-cloudflare-tunnel', + security: [['bearerAuth' => []]], + tags: ['Servers'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Cloudflare Tunnel settings.', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'is_cloudflare_tunnel', type: 'boolean'), + new OA\Property(property: 'ip', type: 'string'), + new OA\Property(property: 'ip_previous', type: 'string', nullable: true), + ], + 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 show(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $server = $this->findServerForTeam($teamId, $request->uuid); + if (! $server) { + return response()->json(['message' => 'Server not found.'], 404); + } + + $this->authorize('view', $server); + + return response()->json($this->transform($server)); + } + + #[OA\Patch( + summary: 'Update Cloudflare Tunnel settings', + description: 'Update stored Cloudflare Tunnel settings for a server. Does not run remote cloudflared configuration; use enable/disable for the manual UI actions.', + path: '/servers/{uuid}/cloudflare-tunnel', + operationId: 'update-server-cloudflare-tunnel', + security: [['bearerAuth' => []]], + tags: ['Servers'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')), + ], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'is_cloudflare_tunnel', type: 'boolean'), + ], + type: 'object', + ), + ), + responses: [ + new OA\Response(response: 200, description: 'Updated Cloudflare Tunnel settings.'), + 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(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + + $server = $this->findServerForTeam($teamId, $request->uuid); + if (! $server) { + return response()->json(['message' => 'Server not found.'], 404); + } + + $this->authorize('update', $server); + + if ($server->isLocalhost()) { + return response()->json(['message' => 'Cloudflare Tunnel cannot be configured on the localhost server.'], 422); + } + + $validator = customApiValidator($request->all(), [ + 'is_cloudflare_tunnel' => 'required|boolean', + ]); + $extraFields = array_diff(array_keys($request->all()), self::ALLOWED_FIELDS); + if ($validator->fails() || ! empty($extraFields)) { + $errors = $validator->errors(); + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $errors, + ], 422); + } + + $enabled = $request->boolean('is_cloudflare_tunnel'); + $server->settings->is_cloudflare_tunnel = $enabled; + $server->settings->save(); + + if (! $enabled && $server->ip_previous) { + $server->update(['ip' => $server->ip_previous]); + } + + auditLog('api.server.cloudflare_tunnel.updated', [ + 'team_id' => $teamId, + 'server_uuid' => $server->uuid, + 'server_name' => $server->name, + 'is_cloudflare_tunnel' => $enabled, + ]); + + return response()->json($this->transform($server->refresh())); + } + + #[OA\Post( + summary: 'Enable Cloudflare Tunnel (manual)', + description: 'Manually mark Cloudflare Tunnel as enabled for a server (matches UI manual enable). Does not deploy cloudflared remotely.', + path: '/servers/{uuid}/cloudflare-tunnel/enable', + operationId: 'enable-server-cloudflare-tunnel', + security: [['bearerAuth' => []]], + tags: ['Servers'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response(response: 200, description: 'Cloudflare Tunnel enabled.'), + 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 enable(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $server = $this->findServerForTeam($teamId, $request->uuid); + if (! $server) { + return response()->json(['message' => 'Server not found.'], 404); + } + + $this->authorize('update', $server); + + if ($server->isLocalhost()) { + return response()->json(['message' => 'Cloudflare Tunnel cannot be configured on the localhost server.'], 422); + } + + $server->settings->is_cloudflare_tunnel = true; + $server->settings->save(); + + auditLog('api.server.cloudflare_tunnel.enabled', [ + 'team_id' => $teamId, + 'server_uuid' => $server->uuid, + 'server_name' => $server->name, + ]); + + return response()->json([ + 'message' => 'Cloudflare Tunnel enabled.', + ...$this->transform($server->refresh()), + ]); + } + + #[OA\Post( + summary: 'Disable Cloudflare Tunnel', + description: 'Mark Cloudflare Tunnel as disabled and restore ip_previous when available. Does not remove the remote cloudflared container.', + path: '/servers/{uuid}/cloudflare-tunnel/disable', + operationId: 'disable-server-cloudflare-tunnel', + security: [['bearerAuth' => []]], + tags: ['Servers'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response(response: 200, description: 'Cloudflare Tunnel disabled.'), + 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 disable(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $server = $this->findServerForTeam($teamId, $request->uuid); + if (! $server) { + return response()->json(['message' => 'Server not found.'], 404); + } + + $this->authorize('update', $server); + + if ($server->isLocalhost()) { + return response()->json(['message' => 'Cloudflare Tunnel cannot be configured on the localhost server.'], 422); + } + + $server->settings->is_cloudflare_tunnel = false; + $server->settings->save(); + + $message = 'Cloudflare Tunnel disabled.'; + if ($server->ip_previous) { + $server->update(['ip' => $server->ip_previous]); + $message .= ' Server IP restored to its previous IP address.'; + } else { + $message .= ' Action required: Update the server IP address to its real IP address if needed.'; + } + + auditLog('api.server.cloudflare_tunnel.disabled', [ + 'team_id' => $teamId, + 'server_uuid' => $server->uuid, + 'server_name' => $server->name, + ]); + + return response()->json([ + 'message' => $message, + ...$this->transform($server->refresh()), + ]); + } +} diff --git a/app/Http/Controllers/Api/ServerDockerCleanupController.php b/app/Http/Controllers/Api/ServerDockerCleanupController.php new file mode 100644 index 000000000..2c0a15da2 --- /dev/null +++ b/app/Http/Controllers/Api/ServerDockerCleanupController.php @@ -0,0 +1,356 @@ +whereUuid($uuid)->first(); + } + + private function transform(Server $server): array + { + $settings = $server->settings; + + return [ + 'docker_cleanup_frequency' => $settings->docker_cleanup_frequency, + 'docker_cleanup_threshold' => (int) $settings->docker_cleanup_threshold, + 'force_docker_cleanup' => (bool) $settings->force_docker_cleanup, + 'delete_unused_volumes' => (bool) $settings->delete_unused_volumes, + 'delete_unused_networks' => (bool) $settings->delete_unused_networks, + 'disable_application_image_retention' => (bool) $settings->disable_application_image_retention, + ]; + } + + #[OA\Get( + summary: 'Get Docker cleanup settings', + description: 'Get Docker cleanup settings for a server owned by the authenticated team.', + path: '/servers/{uuid}/docker-cleanup', + operationId: 'get-server-docker-cleanup', + security: [['bearerAuth' => []]], + tags: ['Servers'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Docker cleanup settings.', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'docker_cleanup_frequency', type: 'string'), + new OA\Property(property: 'docker_cleanup_threshold', type: 'integer'), + new OA\Property(property: 'force_docker_cleanup', type: 'boolean'), + new OA\Property(property: 'delete_unused_volumes', type: 'boolean'), + new OA\Property(property: 'delete_unused_networks', type: 'boolean'), + new OA\Property(property: 'disable_application_image_retention', type: 'boolean'), + ], + 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 show(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $server = $this->findServerForTeam($teamId, $request->uuid); + if (! $server) { + return response()->json(['message' => 'Server not found.'], 404); + } + + $this->authorize('view', $server); + + return response()->json($this->transform($server)); + } + + #[OA\Patch( + summary: 'Update Docker cleanup settings', + description: 'Update Docker cleanup settings for a server owned by the authenticated team.', + path: '/servers/{uuid}/docker-cleanup', + operationId: 'update-server-docker-cleanup', + security: [['bearerAuth' => []]], + tags: ['Servers'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')), + ], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'docker_cleanup_frequency', type: 'string', description: 'Cron / human frequency expression.'), + new OA\Property(property: 'docker_cleanup_threshold', type: 'integer', minimum: 1, maximum: 99), + new OA\Property(property: 'force_docker_cleanup', type: 'boolean'), + new OA\Property(property: 'delete_unused_volumes', type: 'boolean'), + new OA\Property(property: 'delete_unused_networks', type: 'boolean'), + new OA\Property(property: 'disable_application_image_retention', type: 'boolean'), + ], + type: 'object', + ), + ), + responses: [ + new OA\Response( + response: 200, + description: 'Updated Docker cleanup settings.', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'docker_cleanup_frequency', type: 'string'), + new OA\Property(property: 'docker_cleanup_threshold', type: 'integer'), + new OA\Property(property: 'force_docker_cleanup', type: 'boolean'), + new OA\Property(property: 'delete_unused_volumes', type: 'boolean'), + new OA\Property(property: 'delete_unused_networks', type: 'boolean'), + new OA\Property(property: 'disable_application_image_retention', type: 'boolean'), + ], + 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(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + + $server = $this->findServerForTeam($teamId, $request->uuid); + if (! $server) { + return response()->json(['message' => 'Server not found.'], 404); + } + + $this->authorize('update', $server); + + $validator = customApiValidator($request->all(), [ + 'docker_cleanup_frequency' => 'string', + 'docker_cleanup_threshold' => 'integer|min:1|max:99', + 'force_docker_cleanup' => 'boolean', + 'delete_unused_volumes' => 'boolean', + 'delete_unused_networks' => 'boolean', + 'disable_application_image_retention' => 'boolean', + ]); + + $extraFields = array_diff(array_keys($request->all()), self::ALLOWED_FIELDS); + if ($validator->fails() || ! empty($extraFields)) { + $errors = $validator->errors(); + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $errors, + ], 422); + } + + if ($request->has('docker_cleanup_frequency') && ! validate_cron_expression($request->docker_cleanup_frequency)) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['docker_cleanup_frequency' => ['Invalid Cron / Human expression for Docker Cleanup Frequency.']], + ], 422); + } + + $settings = $server->settings; + foreach (self::ALLOWED_FIELDS as $field) { + if ($request->has($field)) { + $settings->{$field} = $request->input($field); + } + } + $settings->save(); + + auditLog('api.server.docker_cleanup.updated', [ + 'team_id' => $teamId, + 'server_uuid' => $server->uuid, + 'server_name' => $server->name, + 'changed_fields' => array_values(array_intersect(self::ALLOWED_FIELDS, array_keys($request->all()))), + ]); + + return response()->json($this->transform($server->refresh())); + } + + #[OA\Post( + summary: 'Run Docker cleanup', + description: 'Dispatch a manual Docker cleanup job for a server owned by the authenticated team.', + path: '/servers/{uuid}/docker-cleanup/run', + operationId: 'run-server-docker-cleanup', + security: [['bearerAuth' => []]], + tags: ['Servers'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')), + ], + requestBody: new OA\RequestBody( + required: false, + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'delete_unused_volumes', type: 'boolean'), + new OA\Property(property: 'delete_unused_networks', type: 'boolean'), + ], + type: 'object', + ), + ), + responses: [ + new OA\Response( + response: 200, + description: 'Docker cleanup job dispatched.', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'message', type: 'string', example: 'Manual cleanup job started.'), + ], + 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 run(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $server = $this->findServerForTeam($teamId, $request->uuid); + if (! $server) { + return response()->json(['message' => 'Server not found.'], 404); + } + + $this->authorize('update', $server); + + $validator = customApiValidator($request->all(), [ + 'delete_unused_volumes' => 'boolean', + 'delete_unused_networks' => 'boolean', + ]); + $extraFields = array_diff(array_keys($request->all()), ['delete_unused_volumes', 'delete_unused_networks']); + if ($validator->fails() || ! empty($extraFields)) { + $errors = $validator->errors(); + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $errors, + ], 422); + } + + $deleteUnusedVolumes = $request->has('delete_unused_volumes') + ? $request->boolean('delete_unused_volumes') + : (bool) $server->settings->delete_unused_volumes; + $deleteUnusedNetworks = $request->has('delete_unused_networks') + ? $request->boolean('delete_unused_networks') + : (bool) $server->settings->delete_unused_networks; + + DockerCleanupJob::dispatch($server, true, $deleteUnusedVolumes, $deleteUnusedNetworks); + + auditLog('api.server.docker_cleanup.run', [ + 'team_id' => $teamId, + 'server_uuid' => $server->uuid, + 'server_name' => $server->name, + 'delete_unused_volumes' => $deleteUnusedVolumes, + 'delete_unused_networks' => $deleteUnusedNetworks, + ]); + + return response()->json([ + 'message' => 'Manual cleanup job started. Depending on the amount of data, this might take a while.', + ]); + } + + #[OA\Get( + summary: 'List Docker cleanup executions', + description: 'List recent Docker cleanup execution logs for a server owned by the authenticated team.', + path: '/servers/{uuid}/docker-cleanup/executions', + operationId: 'list-server-docker-cleanup-executions', + security: [['bearerAuth' => []]], + tags: ['Servers'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Recent Docker cleanup executions.', + content: new OA\JsonContent( + type: 'array', + items: new OA\Items( + properties: [ + new OA\Property(property: 'uuid', type: 'string'), + new OA\Property(property: 'status', type: 'string'), + new OA\Property(property: 'message', type: 'string', nullable: true), + new OA\Property(property: 'finished_at', type: 'string', nullable: true), + new OA\Property(property: 'created_at', type: 'string'), + new OA\Property(property: 'updated_at', type: 'string'), + ], + 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 executions(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $server = $this->findServerForTeam($teamId, $request->uuid); + if (! $server) { + return response()->json(['message' => 'Server not found.'], 404); + } + + $this->authorize('view', $server); + + $executions = $server->dockerCleanupExecutions() + ->orderBy('created_at', 'desc') + ->take(20) + ->get() + ->map(fn ($execution) => [ + 'uuid' => $execution->uuid, + 'status' => $execution->status, + 'message' => $execution->message, + 'finished_at' => $execution->finished_at, + 'created_at' => $execution->created_at, + 'updated_at' => $execution->updated_at, + ]) + ->values(); + + return response()->json($executions); + } +} diff --git a/app/Http/Controllers/Api/ServerLogDrainsController.php b/app/Http/Controllers/Api/ServerLogDrainsController.php new file mode 100644 index 000000000..e56d6dfb2 --- /dev/null +++ b/app/Http/Controllers/Api/ServerLogDrainsController.php @@ -0,0 +1,248 @@ +whereUuid($uuid)->first(); + } + + private function canReadSensitive(): bool + { + return request()->attributes->get('can_read_sensitive', false) === true; + } + + private function transform(Server $server): array + { + $settings = $server->settings; + $payload = [ + 'is_logdrain_newrelic_enabled' => (bool) $settings->is_logdrain_newrelic_enabled, + 'logdrain_newrelic_base_uri' => $settings->logdrain_newrelic_base_uri, + 'is_logdrain_axiom_enabled' => (bool) $settings->is_logdrain_axiom_enabled, + 'logdrain_axiom_dataset_name' => $settings->logdrain_axiom_dataset_name, + 'is_logdrain_custom_enabled' => (bool) $settings->is_logdrain_custom_enabled, + ]; + + if ($this->canReadSensitive()) { + $payload['logdrain_newrelic_license_key'] = $settings->logdrain_newrelic_license_key; + $payload['logdrain_axiom_api_key'] = $settings->logdrain_axiom_api_key; + $payload['logdrain_custom_config'] = $settings->logdrain_custom_config; + $payload['logdrain_custom_config_parser'] = $settings->logdrain_custom_config_parser; + } + + return $payload; + } + + #[OA\Get( + summary: 'Get log drain settings', + description: 'Get log drain settings for a server owned by the authenticated team. Sensitive fields require the read:sensitive or root token ability.', + path: '/servers/{uuid}/log-drains', + operationId: 'get-server-log-drains', + security: [['bearerAuth' => []]], + tags: ['Servers'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Log drain settings.', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'is_logdrain_newrelic_enabled', type: 'boolean'), + new OA\Property(property: 'logdrain_newrelic_license_key', type: 'string', description: 'Only present with read:sensitive.'), + new OA\Property(property: 'logdrain_newrelic_base_uri', type: 'string', nullable: true), + new OA\Property(property: 'is_logdrain_axiom_enabled', type: 'boolean'), + new OA\Property(property: 'logdrain_axiom_dataset_name', type: 'string', nullable: true), + new OA\Property(property: 'logdrain_axiom_api_key', type: 'string', description: 'Only present with read:sensitive.'), + new OA\Property(property: 'is_logdrain_custom_enabled', type: 'boolean'), + new OA\Property(property: 'logdrain_custom_config', type: 'string', description: 'Only present with read:sensitive.'), + new OA\Property(property: 'logdrain_custom_config_parser', type: 'string', description: 'Only present with read:sensitive.'), + ], + 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 show(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $server = $this->findServerForTeam($teamId, $request->uuid); + if (! $server) { + return response()->json(['message' => 'Server not found.'], 404); + } + + $this->authorize('view', $server); + + return response()->json($this->transform($server)); + } + + #[OA\Patch( + summary: 'Update log drain settings', + description: 'Update New Relic, Axiom, or custom log drain settings for a server owned by the authenticated team.', + path: '/servers/{uuid}/log-drains', + operationId: 'update-server-log-drains', + security: [['bearerAuth' => []]], + tags: ['Servers'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')), + ], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'is_logdrain_newrelic_enabled', type: 'boolean'), + new OA\Property(property: 'logdrain_newrelic_license_key', type: 'string'), + new OA\Property(property: 'logdrain_newrelic_base_uri', type: 'string'), + new OA\Property(property: 'is_logdrain_axiom_enabled', type: 'boolean'), + new OA\Property(property: 'logdrain_axiom_dataset_name', type: 'string'), + new OA\Property(property: 'logdrain_axiom_api_key', type: 'string'), + new OA\Property(property: 'is_logdrain_custom_enabled', type: 'boolean'), + new OA\Property(property: 'logdrain_custom_config', type: 'string'), + new OA\Property(property: 'logdrain_custom_config_parser', type: 'string'), + ], + type: 'object', + ), + ), + responses: [ + new OA\Response(response: 200, description: 'Updated log drain settings.'), + 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(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + + $server = $this->findServerForTeam($teamId, $request->uuid); + if (! $server) { + return response()->json(['message' => 'Server not found.'], 404); + } + + $this->authorize('update', $server); + + $validator = customApiValidator($request->all(), [ + 'is_logdrain_newrelic_enabled' => 'boolean', + 'logdrain_newrelic_license_key' => ['nullable', 'string', 'regex:/^[a-zA-Z0-9_\-\.]+$/'], + 'logdrain_newrelic_base_uri' => 'nullable|url', + 'is_logdrain_axiom_enabled' => 'boolean', + 'logdrain_axiom_dataset_name' => ['nullable', 'string', 'regex:/^[a-zA-Z0-9_\-\.]+$/'], + 'logdrain_axiom_api_key' => ['nullable', 'string', 'regex:/^[a-zA-Z0-9_\-\.]+$/'], + 'is_logdrain_custom_enabled' => 'boolean', + 'logdrain_custom_config' => 'nullable|string', + 'logdrain_custom_config_parser' => 'nullable|string', + ]); + + $extraFields = array_diff(array_keys($request->all()), self::ALLOWED_FIELDS); + if ($validator->fails() || ! empty($extraFields)) { + $errors = $validator->errors(); + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $errors, + ], 422); + } + + $settings = $server->settings; + foreach (self::ALLOWED_FIELDS as $field) { + if ($request->has($field)) { + $settings->{$field} = $request->input($field); + } + } + + // Conditional required fields when enabling a drain type (matches Livewire). + if ($settings->is_logdrain_newrelic_enabled) { + $errors = []; + if (blank($settings->logdrain_newrelic_license_key)) { + $errors['logdrain_newrelic_license_key'] = ['The New Relic license key is required when New Relic log drain is enabled.']; + } + if (blank($settings->logdrain_newrelic_base_uri)) { + $errors['logdrain_newrelic_base_uri'] = ['The New Relic base URI is required when New Relic log drain is enabled.']; + } + if ($errors !== []) { + return response()->json(['message' => 'Validation failed.', 'errors' => $errors], 422); + } + } + if ($settings->is_logdrain_axiom_enabled) { + $errors = []; + if (blank($settings->logdrain_axiom_dataset_name)) { + $errors['logdrain_axiom_dataset_name'] = ['The Axiom dataset name is required when Axiom log drain is enabled.']; + } + if (blank($settings->logdrain_axiom_api_key)) { + $errors['logdrain_axiom_api_key'] = ['The Axiom API key is required when Axiom log drain is enabled.']; + } + if ($errors !== []) { + return response()->json(['message' => 'Validation failed.', 'errors' => $errors], 422); + } + } + if ($settings->is_logdrain_custom_enabled && blank($settings->logdrain_custom_config)) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [ + 'logdrain_custom_config' => ['The custom log drain config is required when custom log drain is enabled.'], + ], + ], 422); + } + + $settings->save(); + $server->refresh(); + + // Match Livewire instantSave: start or stop the drain service after settings change. + if ($server->isLogDrainEnabled()) { + StartLogDrain::dispatch($server); + } else { + StopLogDrain::dispatch($server); + } + + auditLog('api.server.log_drains.updated', [ + 'team_id' => $teamId, + 'server_uuid' => $server->uuid, + 'server_name' => $server->name, + 'changed_fields' => array_values(array_intersect(self::ALLOWED_FIELDS, array_keys($request->all()))), + ]); + + return response()->json($this->transform($server)); + } +} diff --git a/app/Http/Controllers/Api/ServerProxyController.php b/app/Http/Controllers/Api/ServerProxyController.php new file mode 100644 index 000000000..6b1e86b78 --- /dev/null +++ b/app/Http/Controllers/Api/ServerProxyController.php @@ -0,0 +1,422 @@ +whereUuid($uuid)->first(); + } + + private function canReadSensitive(): bool + { + return request()->attributes->get('can_read_sensitive', false) === true; + } + + /** + * @return array{ + * proxy_type: string|null, + * status: string|null, + * redirect_enabled: bool, + * redirect_url: string|null, + * generate_exact_labels: bool, + * configuration?: string|null + * } + */ + private function payload(Server $server, bool $includeConfiguration = true): array + { + $payload = [ + 'proxy_type' => $server->proxyType(), + 'status' => data_get($server->proxy, 'status'), + 'redirect_enabled' => (bool) data_get($server->proxy, 'redirect_enabled', true), + 'redirect_url' => data_get($server->proxy, 'redirect_url'), + 'generate_exact_labels' => (bool) ($server->settings->generate_exact_labels ?? false), + ]; + + // Proxy compose can contain secrets; only expose with read:sensitive (and admin) like other APIs. + if ($includeConfiguration && $this->canReadSensitive()) { + // Prefer DB-stored config only — never SSH or regenerate for GET. + $configuration = $server->proxy->get('last_saved_proxy_configuration'); + $payload['configuration'] = filled($configuration) ? $configuration : null; + } + + return $payload; + } + + #[OA\Get( + summary: 'Get server proxy', + description: 'Get proxy settings for a server owned by the authenticated team. The raw proxy configuration is only returned when the token has `read:sensitive` (or `root`) and the user is a team admin/owner, and only when already stored in the database (no remote fetch).', + path: '/servers/{uuid}/proxy', + operationId: 'get-server-proxy', + security: [['bearerAuth' => []]], + tags: ['Servers'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Server proxy settings.', + content: new OA\JsonContent( + type: 'object', + properties: [ + new OA\Property(property: 'proxy_type', type: 'string', nullable: true, example: 'TRAEFIK'), + new OA\Property(property: 'status', type: 'string', nullable: true, example: 'running'), + new OA\Property(property: 'redirect_enabled', type: 'boolean', example: true), + new OA\Property(property: 'redirect_url', type: 'string', nullable: true, example: 'https://example.com'), + new OA\Property(property: 'generate_exact_labels', type: 'boolean', example: false), + new OA\Property(property: 'configuration', type: 'string', nullable: true, description: 'Docker Compose proxy configuration when stored in the database. Only present with read:sensitive.'), + ] + ) + ), + 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 show(Request $request, string $uuid): JsonResponse + { + $teamId = $this->teamIdOrAbort(); + if (! is_int($teamId)) { + return $teamId; + } + + $server = $this->findServerForTeam($teamId, $uuid); + if (! $server) { + return response()->json(['message' => 'Server not found.'], 404); + } + + $this->authorize('view', $server); + + return response()->json($this->payload($server)); + } + + #[OA\Patch( + summary: 'Update server proxy', + description: 'Update proxy redirect settings, exact labels generation, and optionally the proxy type for a team-owned server.', + path: '/servers/{uuid}/proxy', + operationId: 'update-server-proxy', + security: [['bearerAuth' => []]], + tags: ['Servers'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')), + ], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + type: 'object', + properties: [ + new OA\Property(property: 'redirect_enabled', type: 'boolean'), + new OA\Property(property: 'redirect_url', type: 'string', nullable: true, description: 'Public http(s) redirect URL, or null to clear.'), + new OA\Property(property: 'generate_exact_labels', type: 'boolean'), + new OA\Property(property: 'proxy_type', type: 'string', enum: ['traefik', 'caddy', 'nginx', 'none'], description: 'Proxy type (case-insensitive).'), + ] + ) + ), + responses: [ + new OA\Response( + response: 200, + description: 'Proxy settings updated.', + content: new OA\JsonContent( + type: 'object', + properties: [ + new OA\Property(property: 'proxy_type', type: 'string', nullable: true), + new OA\Property(property: 'status', type: 'string', nullable: true), + new OA\Property(property: 'redirect_enabled', type: 'boolean'), + new OA\Property(property: 'redirect_url', type: 'string', nullable: true), + new OA\Property(property: 'generate_exact_labels', type: 'boolean'), + new OA\Property(property: 'configuration', type: 'string', nullable: true), + ] + ) + ), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ], + )] + public function update(Request $request, string $uuid): JsonResponse + { + $teamId = $this->teamIdOrAbort(); + if (! is_int($teamId)) { + return $teamId; + } + + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + + $allowedFields = ['redirect_enabled', 'redirect_url', 'generate_exact_labels', 'proxy_type']; + $validator = customApiValidator($request->all(), [ + 'redirect_enabled' => 'boolean', + 'redirect_url' => ['nullable', 'string', new SafeExternalUrl], + 'generate_exact_labels' => 'boolean', + 'proxy_type' => 'string|nullable', + ]); + + $extraFields = array_diff(array_keys($request->all()), $allowedFields); + if ($validator->fails() || ! empty($extraFields)) { + $errors = $validator->errors(); + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $errors, + ], 422); + } + + $server = $this->findServerForTeam($teamId, $uuid); + if (! $server) { + return response()->json(['message' => 'Server not found.'], 404); + } + + $this->authorize('update', $server); + + if ($request->has('proxy_type') && filled($request->proxy_type)) { + $validProxyTypes = collect(ProxyTypes::cases())->map(fn (ProxyTypes $type) => str($type->value)->lower()); + if (! $validProxyTypes->contains(str($request->proxy_type)->lower())) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['proxy_type' => ['Invalid proxy type.']], + ], 422); + } + } + + $changedFields = array_values(array_intersect($allowedFields, array_keys($request->all()))); + $redirectChanged = false; + + if ($request->has('redirect_enabled')) { + $server->proxy->redirect_enabled = $request->boolean('redirect_enabled'); + $redirectChanged = true; + } + + if ($request->exists('redirect_url')) { + $server->proxy->redirect_url = $request->input('redirect_url') ?: null; + $redirectChanged = true; + } + + if ($redirectChanged) { + $server->save(); + } + + if ($request->has('generate_exact_labels')) { + $server->settings->generate_exact_labels = $request->boolean('generate_exact_labels'); + $server->settings->save(); + } + + if ($request->has('proxy_type') && filled($request->proxy_type)) { + $server->changeProxy($request->proxy_type, async: true); + $server->refresh(); + } + + // Apply redirect file on the server only when reachable (DB settings always saved above). + if ($redirectChanged && $server->isFunctional()) { + $server->setupDefaultRedirect(); + } + + auditLog('api.server.proxy.updated', [ + 'team_id' => $teamId, + 'server_uuid' => $server->uuid, + 'server_name' => $server->name, + 'changed_fields' => $changedFields, + ]); + + return response()->json($this->payload($server->fresh())); + } + + #[OA\Put( + summary: 'Save server proxy configuration', + description: 'Save the raw proxy Docker Compose configuration for a team-owned server. Multi-line configuration must be base64 encoded (same pattern as other compose payloads).', + path: '/servers/{uuid}/proxy/configuration', + operationId: 'save-server-proxy-configuration', + security: [['bearerAuth' => []]], + tags: ['Servers'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')), + ], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + required: ['configuration'], + type: 'object', + properties: [ + new OA\Property( + property: 'configuration', + type: 'string', + description: 'Proxy docker-compose YAML. Prefer base64 encoding for multi-line content.' + ), + ] + ) + ), + responses: [ + new OA\Response( + response: 200, + description: 'Proxy configuration saved.', + content: new OA\JsonContent( + type: 'object', + properties: [ + new OA\Property(property: 'message', type: 'string', example: 'Proxy configuration saved.'), + new OA\Property(property: 'proxy_type', type: 'string', nullable: true), + new OA\Property(property: 'status', type: 'string', nullable: true), + new OA\Property(property: 'redirect_enabled', type: 'boolean'), + new OA\Property(property: 'redirect_url', type: 'string', nullable: true), + new OA\Property(property: 'generate_exact_labels', type: 'boolean'), + new OA\Property(property: 'configuration', type: 'string', nullable: true), + ] + ) + ), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ], + )] + public function saveConfiguration(Request $request, string $uuid): JsonResponse + { + $teamId = $this->teamIdOrAbort(); + if (! is_int($teamId)) { + return $teamId; + } + + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + + $allowedFields = ['configuration']; + $validator = customApiValidator($request->all(), [ + 'configuration' => 'required|string', + ]); + + $extraFields = array_diff(array_keys($request->all()), $allowedFields); + if ($validator->fails() || ! empty($extraFields)) { + $errors = $validator->errors(); + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $errors, + ], 422); + } + + $server = $this->findServerForTeam($teamId, $uuid); + if (! $server) { + return response()->json(['message' => 'Server not found.'], 404); + } + + $this->authorize('update', $server); + + $configuration = $request->input('configuration'); + if (isBase64Encoded($configuration)) { + $decoded = base64_decode($configuration, true); + if ($decoded === false || mb_detect_encoding($decoded, 'UTF-8', true) === false) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [ + 'configuration' => ['The configuration should be valid base64-encoded UTF-8 text.'], + ], + ], 422); + } + $configuration = $decoded; + } + + if (! filled(trim($configuration))) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [ + 'configuration' => ['The configuration field is required.'], + ], + ], 422); + } + + SaveProxyConfiguration::run($server, $configuration); + + auditLog('api.server.proxy.configuration_saved', [ + 'team_id' => $teamId, + 'server_uuid' => $server->uuid, + 'server_name' => $server->name, + ]); + + $payload = $this->payload($server->fresh()); + $payload['message'] = 'Proxy configuration saved.'; + + return response()->json($payload); + } + + #[OA\Post( + summary: 'Restart server proxy', + description: 'Queue a proxy restart for a team-owned server.', + path: '/servers/{uuid}/proxy/restart', + operationId: 'restart-server-proxy', + security: [['bearerAuth' => []]], + tags: ['Servers'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Proxy restart queued.', + content: new OA\JsonContent( + type: 'object', + properties: [ + new OA\Property(property: 'message', type: 'string', example: 'Proxy restart queued.'), + ] + ) + ), + 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 restart(Request $request, string $uuid): JsonResponse + { + $teamId = $this->teamIdOrAbort(); + if (! is_int($teamId)) { + return $teamId; + } + + $server = $this->findServerForTeam($teamId, $uuid); + if (! $server) { + return response()->json(['message' => 'Server not found.'], 404); + } + + $this->authorize('manageProxy', $server); + + RestartProxyJob::dispatch($server); + + auditLog('api.server.proxy.restarted', [ + 'team_id' => $teamId, + 'server_uuid' => $server->uuid, + 'server_name' => $server->name, + ]); + + return response()->json(['message' => 'Proxy restart queued.']); + } +} diff --git a/app/Http/Controllers/Api/ServerSentinelController.php b/app/Http/Controllers/Api/ServerSentinelController.php new file mode 100644 index 000000000..f52bdb4c3 --- /dev/null +++ b/app/Http/Controllers/Api/ServerSentinelController.php @@ -0,0 +1,226 @@ +whereUuid($uuid)->first(); + } + + private function canReadSensitive(): bool + { + return request()->attributes->get('can_read_sensitive', false) === true; + } + + private function transform(Server $server): array + { + $settings = $server->settings; + $payload = [ + 'is_sentinel_enabled' => (bool) $settings->is_sentinel_enabled, + 'is_metrics_enabled' => (bool) $settings->is_metrics_enabled, + 'is_sentinel_debug_enabled' => (bool) $settings->is_sentinel_debug_enabled, + 'sentinel_metrics_refresh_rate_seconds' => (int) $settings->sentinel_metrics_refresh_rate_seconds, + 'sentinel_metrics_history_days' => (int) $settings->sentinel_metrics_history_days, + 'sentinel_push_interval_seconds' => (int) $settings->sentinel_push_interval_seconds, + 'sentinel_updated_at' => $server->sentinel_updated_at, + ]; + + if ($this->canReadSensitive()) { + $payload['sentinel_token'] = $settings->sentinel_token; + $payload['sentinel_custom_url'] = $settings->sentinel_custom_url; + } + + return $payload; + } + + #[OA\Get( + summary: 'Get Sentinel settings', + description: 'Get Sentinel settings for a server owned by the authenticated team. sentinel_token and sentinel_custom_url require the read:sensitive or root token ability.', + path: '/servers/{uuid}/sentinel', + operationId: 'get-server-sentinel', + security: [['bearerAuth' => []]], + tags: ['Servers'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Sentinel settings.', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'is_sentinel_enabled', type: 'boolean'), + new OA\Property(property: 'is_metrics_enabled', type: 'boolean'), + new OA\Property(property: 'is_sentinel_debug_enabled', type: 'boolean'), + new OA\Property(property: 'sentinel_token', type: 'string', description: 'Only present with read:sensitive.'), + new OA\Property(property: 'sentinel_metrics_refresh_rate_seconds', type: 'integer'), + new OA\Property(property: 'sentinel_metrics_history_days', type: 'integer'), + new OA\Property(property: 'sentinel_push_interval_seconds', type: 'integer'), + new OA\Property(property: 'sentinel_custom_url', type: 'string', description: 'Only present with read:sensitive.'), + new OA\Property(property: 'sentinel_updated_at', type: 'string', nullable: true), + ], + 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 show(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $server = $this->findServerForTeam($teamId, $request->uuid); + if (! $server) { + return response()->json(['message' => 'Server not found.'], 404); + } + + $this->authorize('view', $server); + + return response()->json($this->transform($server)); + } + + #[OA\Patch( + summary: 'Update Sentinel settings', + description: 'Update Sentinel settings for a server owned by the authenticated team. Changing token/metrics timing fields may restart Sentinel.', + path: '/servers/{uuid}/sentinel', + operationId: 'update-server-sentinel', + security: [['bearerAuth' => []]], + tags: ['Servers'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')), + ], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'is_sentinel_enabled', type: 'boolean'), + new OA\Property(property: 'is_metrics_enabled', type: 'boolean'), + new OA\Property(property: 'is_sentinel_debug_enabled', type: 'boolean'), + new OA\Property(property: 'sentinel_token', type: 'string'), + new OA\Property(property: 'sentinel_metrics_refresh_rate_seconds', type: 'integer', minimum: 1), + new OA\Property(property: 'sentinel_metrics_history_days', type: 'integer', minimum: 1), + new OA\Property(property: 'sentinel_push_interval_seconds', type: 'integer', minimum: 10), + new OA\Property(property: 'sentinel_custom_url', type: 'string', nullable: true), + ], + type: 'object', + ), + ), + responses: [ + new OA\Response(response: 200, description: 'Updated Sentinel settings.'), + 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(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + + $server = $this->findServerForTeam($teamId, $request->uuid); + if (! $server) { + return response()->json(['message' => 'Server not found.'], 404); + } + + $this->authorize('update', $server); + + $validator = customApiValidator($request->all(), [ + 'is_sentinel_enabled' => 'boolean', + 'is_metrics_enabled' => 'boolean', + 'is_sentinel_debug_enabled' => 'boolean', + 'sentinel_token' => ['string', 'max:500', 'regex:/\A[a-zA-Z0-9._\-+=\/]+\z/'], + 'sentinel_metrics_refresh_rate_seconds' => 'integer|min:1', + 'sentinel_metrics_history_days' => 'integer|min:1', + 'sentinel_push_interval_seconds' => 'integer|min:10', + 'sentinel_custom_url' => 'nullable|url', + ]); + + $extraFields = array_diff(array_keys($request->all()), self::ALLOWED_FIELDS); + if ($validator->fails() || ! empty($extraFields)) { + $errors = $validator->errors(); + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $errors, + ], 422); + } + + if ($request->has('sentinel_token') && ! ServerSetting::isValidSentinelToken($request->input('sentinel_token'))) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['sentinel_token' => ['Invalid sentinel token characters.']], + ], 422); + } + + $settings = $server->settings; + $enablingSentinel = $request->has('is_sentinel_enabled') + && $request->boolean('is_sentinel_enabled') + && ! $settings->is_sentinel_enabled; + + if ($enablingSentinel && $server->isBuildServer()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['is_sentinel_enabled' => ['Sentinel cannot be enabled on build servers.']], + ], 422); + } + + foreach (self::ALLOWED_FIELDS as $field) { + if ($request->has($field)) { + $settings->{$field} = $request->input($field); + } + } + + // Disabling Sentinel also clears related toggles (matches Livewire toggleSentinel). + if ($request->has('is_sentinel_enabled') && ! $request->boolean('is_sentinel_enabled')) { + $settings->is_metrics_enabled = false; + $settings->is_sentinel_debug_enabled = false; + } + + $settings->save(); + + auditLog('api.server.sentinel.updated', [ + 'team_id' => $teamId, + 'server_uuid' => $server->uuid, + 'server_name' => $server->name, + 'changed_fields' => array_values(array_intersect(self::ALLOWED_FIELDS, array_keys($request->all()))), + ]); + + return response()->json($this->transform($server->refresh())); + } +} diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php index 278c7714d..4ba90afe2 100644 --- a/app/Http/Controllers/Api/ServersController.php +++ b/app/Http/Controllers/Api/ServersController.php @@ -663,7 +663,7 @@ class ServersController extends Controller )] public function update_server(Request $request) { - $allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'is_build_server', 'instant_validate', 'proxy_type', 'concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency', 'connection_timeout']; + $allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'is_build_server', 'instant_validate', 'proxy_type', 'concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency', 'connection_timeout', 'is_terminal_enabled']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -690,6 +690,7 @@ class ServersController extends Controller 'server_disk_usage_notification_threshold' => 'integer|min:1|max:100', 'server_disk_usage_check_frequency' => 'string', 'connection_timeout' => 'integer|min:1|max:300', + 'is_terminal_enabled' => 'boolean|nullable', ], [ ...ValidationPatterns::serverUsernameMessages(), ]); @@ -751,6 +752,12 @@ class ServersController extends Controller ]); } + if ($request->has('is_terminal_enabled')) { + $server->settings()->update([ + 'is_terminal_enabled' => $request->boolean('is_terminal_enabled'), + ]); + } + $advancedSettings = $request->only(['concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency', 'connection_timeout']); if (! empty($advancedSettings)) { $server->settings()->update(array_filter($advancedSettings, fn ($value) => ! is_null($value))); diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 9945dfcf9..11a6294f4 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -7,17 +7,21 @@ use App\Actions\Service\StartService; use App\Actions\Service\StopService; use App\Http\Controllers\Controller; use App\Jobs\DeleteResourceJob; +use App\Jobs\VolumeCloneJob; 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 App\Models\StandaloneDocker; +use App\Models\SwarmDocker; use App\Support\ValidationPatterns; use Illuminate\Database\Eloquent\Model; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Validator; use OpenApi\Attributes as OA; use Symfony\Component\Yaml\Yaml; @@ -3075,4 +3079,241 @@ class ServicesController extends Controller { return $this->deleteTag($request); } + + #[OA\Post( + summary: 'Clone', + description: 'Clone a service to a destination owned by the authenticated team.', + path: '/services/{uuid}/clone', + operationId: 'clone-service-by-uuid', + security: [['bearerAuth' => []]], + tags: ['Services'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'UUID of the service.', schema: new OA\Schema(type: 'string')), + ], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + required: ['destination_uuid'], + properties: [ + new OA\Property(property: 'destination_uuid', type: 'string'), + new OA\Property(property: 'name', type: 'string', nullable: true), + new OA\Property(property: 'clone_volumes', type: 'boolean', default: false), + ] + ) + ), + responses: [ + new OA\Response(response: 201, description: 'Service cloned.'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ] + )] + public function clone_by_uuid(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + + $validator = customApiValidator($request->all(), [ + 'destination_uuid' => 'required|string', + 'name' => 'string|max:255|nullable', + 'clone_volumes' => 'boolean', + ]); + $allowedFields = ['destination_uuid', 'name', 'clone_volumes']; + $extraFields = array_diff(array_keys($request->all()), $allowedFields); + if ($validator->fails() || ! empty($extraFields)) { + $errors = $validator->errors(); + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $errors, + ], 422); + } + + $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); + + $destination = StandaloneDocker::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->destination_uuid)->first() + ?? SwarmDocker::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->destination_uuid)->first(); + + if (! $destination || ! $destination->server?->canHostResources()) { + return response()->json(['message' => 'Destination not found.'], 404); + } + + $uuid = new_public_id(); + $name = $request->filled('name') + ? $request->string('name')->toString() + : $service->name.'-clone-'.$uuid; + $cloneVolumeData = $request->boolean('clone_volumes', false); + + $newService = $service->replicate([ + 'id', + 'created_at', + 'updated_at', + ])->fill([ + 'uuid' => $uuid, + 'name' => $name, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'server_id' => $destination->server_id, + ]); + $newService->save(); + + foreach ($service->tags as $tag) { + $newService->tags()->attach($tag->id); + } + + foreach ($service->scheduled_tasks()->get() as $task) { + $task->replicate([ + 'id', + 'created_at', + 'updated_at', + ])->fill([ + 'uuid' => new_public_id(), + 'service_id' => $newService->id, + 'team_id' => $teamId, + ])->save(); + } + + foreach ($service->environment_variables()->get() as $environmentVariable) { + $environmentVariable->replicate([ + 'id', + 'created_at', + 'updated_at', + ])->fill([ + 'resourceable_id' => $newService->id, + 'resourceable_type' => $newService->getMorphClass(), + ])->save(); + } + + // Create applications/databases (and their volumes) for the clone first. + // Child rows are not copied by Service::replicate(). + $newService->parse(); + $newService->refresh(); + + $sourceApplicationsByName = $service->applications()->get()->keyBy('name'); + $sourceDatabasesByName = $service->databases()->get()->keyBy('name'); + $pendingVolumeClones = []; + $sourceServer = $service->destination?->server; + $targetServer = $newService->destination?->server; + + foreach ($newService->applications()->get() as $application) { + $application->fill(['status' => 'exited'])->save(); + + $sourceApplication = $sourceApplicationsByName->get($application->name); + if (! $sourceApplication) { + continue; + } + + if ($cloneVolumeData) { + $targetVolumesByMount = $application->persistentStorages()->get()->keyBy('mount_path'); + foreach ($sourceApplication->persistentStorages()->get() as $sourceVolume) { + $targetVolume = $targetVolumesByMount->get($sourceVolume->mount_path); + if (! $targetVolume) { + continue; + } + + $pendingVolumeClones[] = [ + 'source' => $sourceVolume->name, + 'target' => $targetVolume->name, + 'model' => $targetVolume, + ]; + } + } + } + + foreach ($newService->databases()->get() as $database) { + $database->fill(['status' => 'exited'])->save(); + + $sourceDatabase = $sourceDatabasesByName->get($database->name); + if (! $sourceDatabase) { + continue; + } + + if ($cloneVolumeData) { + $targetVolumesByMount = $database->persistentStorages()->get()->keyBy('mount_path'); + foreach ($sourceDatabase->persistentStorages()->get() as $sourceVolume) { + $targetVolume = $targetVolumesByMount->get($sourceVolume->mount_path); + if (! $targetVolume) { + continue; + } + + $pendingVolumeClones[] = [ + 'source' => $sourceVolume->name, + 'target' => $targetVolume->name, + 'model' => $targetVolume, + ]; + } + } + + foreach ($sourceDatabase->scheduledBackups()->get() as $backup) { + $backup->replicate([ + 'id', + 'created_at', + 'updated_at', + ])->fill([ + 'uuid' => new_public_id(), + 'database_id' => $database->id, + 'database_type' => $database->getMorphClass(), + 'team_id' => $teamId, + ])->save(); + } + } + + if ($cloneVolumeData && $pendingVolumeClones !== [] && $sourceServer && $targetServer) { + try { + $chain = [ + function () use ($service) { + StopService::run($service); + }, + ]; + + foreach ($pendingVolumeClones as $clone) { + $chain[] = new VolumeCloneJob( + $clone['source'], + $clone['target'], + $sourceServer, + $targetServer, + $clone['model'], + ); + } + + $chain[] = function () use ($service) { + StartService::run($service); + }; + + Bus::chain($chain)->onQueue('high')->dispatch(); + } catch (\Exception $e) { + \Log::error('Failed to queue service volume clone for '.$service->uuid.': '.$e->getMessage()); + } + } + + auditLog('api.service.cloned', [ + 'team_id' => $teamId, + 'source_uuid' => $service->uuid, + 'service_uuid' => $newService->uuid, + 'service_name' => $newService->name, + 'destination_uuid' => $destination->uuid, + 'clone_volumes' => $cloneVolumeData, + ]); + + return response()->json([ + 'uuid' => $newService->uuid, + 'message' => 'Service cloned.', + ], 201); + } } diff --git a/app/Http/Controllers/Api/SharedEnvironmentVariablesController.php b/app/Http/Controllers/Api/SharedEnvironmentVariablesController.php new file mode 100644 index 000000000..3149c80b1 --- /dev/null +++ b/app/Http/Controllers/Api/SharedEnvironmentVariablesController.php @@ -0,0 +1,907 @@ +makeHidden([ + 'team_id', + 'project_id', + 'environment_id', + 'server_id', + 'version', + ]); + + if (request()->attributes->get('can_read_sensitive', false) === true) { + $env->makeVisible(['value']); + } + + if ($env->is_shown_once ?? false) { + $env->makeHidden(['value']); + } + + return serializeApiResponse($env); + } + + private function teamIdOrAbort(): int|JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + return $teamId; + } + + private function validateEnvPayload(Request $request, bool $requireKey = true): JsonResponse|true + { + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + + if ($request->has('key')) { + $request->merge(['key' => ValidationPatterns::normalizeEnvironmentVariableKey((string) $request->key)]); + } + + $validator = customApiValidator($request->all(), [ + 'key' => ValidationPatterns::environmentVariableKeyRules(required: $requireKey), + 'value' => 'string|nullable', + 'is_literal' => 'boolean', + 'is_multiline' => 'boolean', + 'is_shown_once' => 'boolean', + 'comment' => 'string|nullable|max:256', + ]); + + $extraFields = array_diff(array_keys($request->all()), self::ALLOWED_FIELDS); + 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 (! $requireKey && $request->all() === []) { + return response()->json(['message' => 'At least one field must be provided.'], 422); + } + + return true; + } + + private function findEnvInScope(int $teamId, int|string $envId, string $type, array $scope = []): ?SharedEnvironmentVariable + { + $query = SharedEnvironmentVariable::ownedByCurrentTeamAPI($teamId) + ->where('type', $type) + ->where('id', $envId); + + if (array_key_exists('project_id', $scope)) { + $query->where('project_id', $scope['project_id']); + } + if (array_key_exists('environment_id', $scope)) { + $query->where('environment_id', $scope['environment_id']); + } + if (array_key_exists('server_id', $scope)) { + $query->where('server_id', $scope['server_id']); + } + + return $query->first(); + } + + private function keyExistsInScope(int $teamId, string $key, string $type, array $scope = [], ?int $exceptId = null): bool + { + $query = SharedEnvironmentVariable::ownedByCurrentTeamAPI($teamId) + ->where('type', $type) + ->where('key', $key); + + if (array_key_exists('project_id', $scope)) { + $query->where('project_id', $scope['project_id']); + } else { + $query->whereNull('project_id'); + } + if (array_key_exists('environment_id', $scope)) { + $query->where('environment_id', $scope['environment_id']); + } else { + $query->whereNull('environment_id'); + } + if (array_key_exists('server_id', $scope)) { + $query->where('server_id', $scope['server_id']); + } else { + $query->whereNull('server_id'); + } + + if ($exceptId !== null) { + $query->where('id', '!=', $exceptId); + } + + return $query->exists(); + } + + private function listEnvs(int $teamId, string $type, array $scope = []): JsonResponse + { + $query = SharedEnvironmentVariable::ownedByCurrentTeamAPI($teamId) + ->where('type', $type) + ->orderBy('id'); + + if (array_key_exists('project_id', $scope)) { + $query->where('project_id', $scope['project_id']); + } + if (array_key_exists('environment_id', $scope)) { + $query->where('environment_id', $scope['environment_id']); + } + if (array_key_exists('server_id', $scope)) { + $query->where('server_id', $scope['server_id']); + } + + $envs = $query->get()->map(fn (SharedEnvironmentVariable $env) => $this->removeSensitiveData($env)); + + return response()->json($envs); + } + + private function createEnv(Request $request, int $teamId, string $type, array $attributes = []): JsonResponse + { + $validated = $this->validateEnvPayload($request, requireKey: true); + if ($validated instanceof JsonResponse) { + return $validated; + } + + $this->authorize('create', SharedEnvironmentVariable::class); + + $scope = array_filter([ + 'project_id' => $attributes['project_id'] ?? null, + 'environment_id' => $attributes['environment_id'] ?? null, + 'server_id' => $attributes['server_id'] ?? null, + ], fn ($value) => ! is_null($value)); + + if ($this->keyExistsInScope($teamId, $request->key, $type, $scope)) { + return response()->json([ + 'message' => 'Environment variable already exists. Use PATCH request to update it.', + ], 409); + } + + $env = SharedEnvironmentVariable::create([ + 'key' => $request->key, + 'value' => $request->value, + 'is_literal' => $request->boolean('is_literal'), + 'is_multiline' => $request->boolean('is_multiline'), + 'is_shown_once' => $request->boolean('is_shown_once'), + 'comment' => $request->comment, + 'type' => $type, + 'team_id' => $teamId, + 'project_id' => $attributes['project_id'] ?? null, + 'environment_id' => $attributes['environment_id'] ?? null, + 'server_id' => $attributes['server_id'] ?? null, + ]); + + auditLog('api.shared_env.created', [ + 'team_id' => $teamId, + 'env_id' => $env->id, + 'env_key' => $env->key, + 'type' => $type, + ]); + + return response()->json([ + 'id' => $env->id, + ], 201); + } + + private function updateEnv(Request $request, int $teamId, int|string $envId, string $type, array $scope = []): JsonResponse + { + $env = $this->findEnvInScope($teamId, $envId, $type, $scope); + if (! $env) { + return response()->json(['message' => 'Environment variable not found.'], 404); + } + + $this->authorize('update', $env); + + $validated = $this->validateEnvPayload($request, requireKey: false); + if ($validated instanceof JsonResponse) { + return $validated; + } + + if ($request->has('key') && $request->key !== $env->key) { + if ($this->keyExistsInScope($teamId, $request->key, $type, $scope, exceptId: $env->id)) { + return response()->json([ + 'message' => 'Environment variable already exists with this key.', + ], 409); + } + $env->key = $request->key; + } + + if ($request->has('value')) { + $env->value = $request->value; + } + if ($request->has('is_literal')) { + $env->is_literal = $request->boolean('is_literal'); + } + if ($request->has('is_multiline')) { + $env->is_multiline = $request->boolean('is_multiline'); + } + if ($request->has('is_shown_once')) { + $env->is_shown_once = $request->boolean('is_shown_once'); + } + if ($request->has('comment')) { + $env->comment = $request->comment; + } + + $env->save(); + + auditLog('api.shared_env.updated', [ + 'team_id' => $teamId, + 'env_id' => $env->id, + 'env_key' => $env->key, + 'type' => $type, + ]); + + return response()->json($this->removeSensitiveData($env->fresh())); + } + + private function deleteEnv(int $teamId, int|string $envId, string $type, array $scope = []): JsonResponse + { + $env = $this->findEnvInScope($teamId, $envId, $type, $scope); + if (! $env) { + return response()->json(['message' => 'Environment variable not found.'], 404); + } + + $this->authorize('delete', $env); + + $envKey = $env->key; + $envIdValue = $env->id; + $env->delete(); + + auditLog('api.shared_env.deleted', [ + 'team_id' => $teamId, + 'env_id' => $envIdValue, + 'env_key' => $envKey, + 'type' => $type, + ]); + + return response()->json([ + 'message' => 'Environment variable deleted.', + ]); + } + + private function resolveProject(int $teamId, string $uuid): Project|JsonResponse + { + $project = Project::whereTeamId($teamId)->whereUuid($uuid)->first(); + if (! $project) { + return response()->json(['message' => 'Project not found.'], 404); + } + + return $project; + } + + private function resolveServer(int $teamId, string $uuid): Server|JsonResponse + { + $server = Server::whereTeamId($teamId)->whereUuid($uuid)->first(); + if (! $server) { + return response()->json(['message' => 'Server not found.'], 404); + } + + return $server; + } + + private function resolveEnvironment(Project $project, string $environmentNameOrUuid): Environment|JsonResponse + { + $environment = $project->environments()->whereName($environmentNameOrUuid)->first(); + if (! $environment) { + $environment = $project->environments()->whereUuid($environmentNameOrUuid)->first(); + } + if (! $environment) { + return response()->json(['message' => 'Environment not found.'], 404); + } + + return $environment; + } + + // ── Team ────────────────────────────────────────────────────────── + + #[OA\Get( + summary: 'List Team Shared Envs', + description: 'List shared environment variables for the current team (type=team).', + path: '/team/envs', + operationId: 'list-team-shared-envs', + security: [['bearerAuth' => []]], + tags: ['Shared Environment Variables'], + responses: [ + new OA\Response(response: 200, description: 'Team shared environment variables.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + ], + )] + public function team_envs(Request $request): JsonResponse + { + $teamId = $this->teamIdOrAbort(); + if (! is_int($teamId)) { + return $teamId; + } + + $this->authorize('viewAny', SharedEnvironmentVariable::class); + + return $this->listEnvs($teamId, 'team'); + } + + #[OA\Post( + summary: 'Create Team Shared Env', + description: 'Create a shared environment variable for the current team (type=team).', + path: '/team/envs', + operationId: 'create-team-shared-env', + security: [['bearerAuth' => []]], + tags: ['Shared Environment Variables'], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + required: ['key'], + properties: [ + new OA\Property(property: 'key', type: 'string'), + new OA\Property(property: 'value', type: 'string', nullable: true), + new OA\Property(property: 'is_literal', type: 'boolean'), + new OA\Property(property: 'is_multiline', type: 'boolean'), + new OA\Property(property: 'is_shown_once', type: 'boolean'), + new OA\Property(property: 'comment', type: 'string', nullable: true), + ], + ), + ), + responses: [ + new OA\Response(response: 201, description: 'Environment variable created.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 409, description: 'Environment variable already exists.'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ], + )] + public function team_create_env(Request $request): JsonResponse + { + $teamId = $this->teamIdOrAbort(); + if (! is_int($teamId)) { + return $teamId; + } + + return $this->createEnv($request, $teamId, 'team'); + } + + #[OA\Patch( + summary: 'Update Team Shared Env', + description: 'Update a team shared environment variable by id.', + path: '/team/envs/{env_id}', + operationId: 'update-team-shared-env', + security: [['bearerAuth' => []]], + tags: ['Shared Environment Variables'], + parameters: [ + new OA\Parameter(name: 'env_id', in: 'path', required: true, description: 'Shared env id (integer).', schema: new OA\Schema(type: 'integer')), + ], + responses: [ + new OA\Response(response: 200, description: 'Environment variable updated.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ], + )] + public function team_update_env(Request $request): JsonResponse + { + $teamId = $this->teamIdOrAbort(); + if (! is_int($teamId)) { + return $teamId; + } + + return $this->updateEnv($request, $teamId, $request->route('env_id'), 'team'); + } + + #[OA\Delete( + summary: 'Delete Team Shared Env', + description: 'Delete a team shared environment variable by id.', + path: '/team/envs/{env_id}', + operationId: 'delete-team-shared-env', + security: [['bearerAuth' => []]], + tags: ['Shared Environment Variables'], + parameters: [ + new OA\Parameter(name: 'env_id', in: 'path', required: true, description: 'Shared env id (integer).', schema: new OA\Schema(type: 'integer')), + ], + responses: [ + new OA\Response(response: 200, description: 'Environment variable deleted.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ], + )] + public function team_delete_env(Request $request): JsonResponse + { + $teamId = $this->teamIdOrAbort(); + if (! is_int($teamId)) { + return $teamId; + } + + return $this->deleteEnv($teamId, $request->route('env_id'), 'team'); + } + + // ── Project ─────────────────────────────────────────────────────── + + #[OA\Get( + summary: 'List Project Shared Envs', + description: 'List shared environment variables for a project (type=project).', + path: '/projects/{uuid}/envs', + operationId: 'list-project-shared-envs', + security: [['bearerAuth' => []]], + tags: ['Shared Environment Variables'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Project UUID', schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response(response: 200, description: 'Project shared environment variables.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ], + )] + public function project_envs(Request $request): JsonResponse + { + $teamId = $this->teamIdOrAbort(); + if (! is_int($teamId)) { + return $teamId; + } + + $project = $this->resolveProject($teamId, $request->route('uuid')); + if ($project instanceof JsonResponse) { + return $project; + } + + $this->authorize('view', $project); + + return $this->listEnvs($teamId, 'project', ['project_id' => $project->id]); + } + + #[OA\Post( + summary: 'Create Project Shared Env', + description: 'Create a shared environment variable for a project (type=project).', + path: '/projects/{uuid}/envs', + operationId: 'create-project-shared-env', + security: [['bearerAuth' => []]], + tags: ['Shared Environment Variables'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Project UUID', schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response(response: 201, description: 'Environment variable created.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 409, description: 'Environment variable already exists.'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ], + )] + public function project_create_env(Request $request): JsonResponse + { + $teamId = $this->teamIdOrAbort(); + if (! is_int($teamId)) { + return $teamId; + } + + $project = $this->resolveProject($teamId, $request->route('uuid')); + if ($project instanceof JsonResponse) { + return $project; + } + + $this->authorize('view', $project); + + return $this->createEnv($request, $teamId, 'project', ['project_id' => $project->id]); + } + + #[OA\Patch( + summary: 'Update Project Shared Env', + description: 'Update a project shared environment variable by id.', + path: '/projects/{uuid}/envs/{env_id}', + operationId: 'update-project-shared-env', + security: [['bearerAuth' => []]], + tags: ['Shared Environment Variables'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Project UUID', schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'env_id', in: 'path', required: true, description: 'Shared env id (integer).', schema: new OA\Schema(type: 'integer')), + ], + responses: [ + new OA\Response(response: 200, description: 'Environment variable updated.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ], + )] + public function project_update_env(Request $request): JsonResponse + { + $teamId = $this->teamIdOrAbort(); + if (! is_int($teamId)) { + return $teamId; + } + + $project = $this->resolveProject($teamId, $request->route('uuid')); + if ($project instanceof JsonResponse) { + return $project; + } + + $this->authorize('view', $project); + + return $this->updateEnv( + $request, + $teamId, + $request->route('env_id'), + 'project', + ['project_id' => $project->id], + ); + } + + #[OA\Delete( + summary: 'Delete Project Shared Env', + description: 'Delete a project shared environment variable by id.', + path: '/projects/{uuid}/envs/{env_id}', + operationId: 'delete-project-shared-env', + security: [['bearerAuth' => []]], + tags: ['Shared Environment Variables'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Project UUID', schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'env_id', in: 'path', required: true, description: 'Shared env id (integer).', schema: new OA\Schema(type: 'integer')), + ], + responses: [ + new OA\Response(response: 200, description: 'Environment variable deleted.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ], + )] + public function project_delete_env(Request $request): JsonResponse + { + $teamId = $this->teamIdOrAbort(); + if (! is_int($teamId)) { + return $teamId; + } + + $project = $this->resolveProject($teamId, $request->route('uuid')); + if ($project instanceof JsonResponse) { + return $project; + } + + $this->authorize('view', $project); + + return $this->deleteEnv( + $teamId, + $request->route('env_id'), + 'project', + ['project_id' => $project->id], + ); + } + + // ── Environment ─────────────────────────────────────────────────── + + #[OA\Get( + summary: 'List Environment Shared Envs', + description: 'List shared environment variables for a project environment (type=environment).', + path: '/projects/{uuid}/environments/{environment_name_or_uuid}/envs', + operationId: 'list-environment-shared-envs', + security: [['bearerAuth' => []]], + tags: ['Shared Environment Variables'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Project UUID', schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'environment_name_or_uuid', in: 'path', required: true, description: 'Environment name or UUID', schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response(response: 200, description: 'Environment shared environment variables.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ], + )] + public function environment_envs(Request $request): JsonResponse + { + $teamId = $this->teamIdOrAbort(); + if (! is_int($teamId)) { + return $teamId; + } + + $project = $this->resolveProject($teamId, $request->route('uuid')); + if ($project instanceof JsonResponse) { + return $project; + } + + $environment = $this->resolveEnvironment($project, $request->route('environment_name_or_uuid')); + if ($environment instanceof JsonResponse) { + return $environment; + } + + $this->authorize('view', $project); + + return $this->listEnvs($teamId, 'environment', ['environment_id' => $environment->id]); + } + + #[OA\Post( + summary: 'Create Environment Shared Env', + description: 'Create a shared environment variable for a project environment (type=environment).', + path: '/projects/{uuid}/environments/{environment_name_or_uuid}/envs', + operationId: 'create-environment-shared-env', + security: [['bearerAuth' => []]], + tags: ['Shared Environment Variables'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Project UUID', schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'environment_name_or_uuid', in: 'path', required: true, description: 'Environment name or UUID', schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response(response: 201, description: 'Environment variable created.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 409, description: 'Environment variable already exists.'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ], + )] + public function environment_create_env(Request $request): JsonResponse + { + $teamId = $this->teamIdOrAbort(); + if (! is_int($teamId)) { + return $teamId; + } + + $project = $this->resolveProject($teamId, $request->route('uuid')); + if ($project instanceof JsonResponse) { + return $project; + } + + $environment = $this->resolveEnvironment($project, $request->route('environment_name_or_uuid')); + if ($environment instanceof JsonResponse) { + return $environment; + } + + $this->authorize('view', $project); + + return $this->createEnv($request, $teamId, 'environment', ['environment_id' => $environment->id]); + } + + #[OA\Patch( + summary: 'Update Environment Shared Env', + description: 'Update an environment shared environment variable by id.', + path: '/projects/{uuid}/environments/{environment_name_or_uuid}/envs/{env_id}', + operationId: 'update-environment-shared-env', + security: [['bearerAuth' => []]], + tags: ['Shared Environment Variables'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Project UUID', schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'environment_name_or_uuid', in: 'path', required: true, description: 'Environment name or UUID', schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'env_id', in: 'path', required: true, description: 'Shared env id (integer).', schema: new OA\Schema(type: 'integer')), + ], + responses: [ + new OA\Response(response: 200, description: 'Environment variable updated.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ], + )] + public function environment_update_env(Request $request): JsonResponse + { + $teamId = $this->teamIdOrAbort(); + if (! is_int($teamId)) { + return $teamId; + } + + $project = $this->resolveProject($teamId, $request->route('uuid')); + if ($project instanceof JsonResponse) { + return $project; + } + + $environment = $this->resolveEnvironment($project, $request->route('environment_name_or_uuid')); + if ($environment instanceof JsonResponse) { + return $environment; + } + + $this->authorize('view', $project); + + return $this->updateEnv( + $request, + $teamId, + $request->route('env_id'), + 'environment', + ['environment_id' => $environment->id], + ); + } + + #[OA\Delete( + summary: 'Delete Environment Shared Env', + description: 'Delete an environment shared environment variable by id.', + path: '/projects/{uuid}/environments/{environment_name_or_uuid}/envs/{env_id}', + operationId: 'delete-environment-shared-env', + security: [['bearerAuth' => []]], + tags: ['Shared Environment Variables'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Project UUID', schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'environment_name_or_uuid', in: 'path', required: true, description: 'Environment name or UUID', schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'env_id', in: 'path', required: true, description: 'Shared env id (integer).', schema: new OA\Schema(type: 'integer')), + ], + responses: [ + new OA\Response(response: 200, description: 'Environment variable deleted.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ], + )] + public function environment_delete_env(Request $request): JsonResponse + { + $teamId = $this->teamIdOrAbort(); + if (! is_int($teamId)) { + return $teamId; + } + + $project = $this->resolveProject($teamId, $request->route('uuid')); + if ($project instanceof JsonResponse) { + return $project; + } + + $environment = $this->resolveEnvironment($project, $request->route('environment_name_or_uuid')); + if ($environment instanceof JsonResponse) { + return $environment; + } + + $this->authorize('view', $project); + + return $this->deleteEnv( + $teamId, + $request->route('env_id'), + 'environment', + ['environment_id' => $environment->id], + ); + } + + // ── Server ──────────────────────────────────────────────────────── + + #[OA\Get( + summary: 'List Server Shared Envs', + description: 'List shared environment variables for a server (type=server).', + path: '/servers/{uuid}/envs', + operationId: 'list-server-shared-envs', + security: [['bearerAuth' => []]], + tags: ['Shared Environment Variables'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response(response: 200, description: 'Server shared environment variables.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ], + )] + public function server_envs(Request $request): JsonResponse + { + $teamId = $this->teamIdOrAbort(); + if (! is_int($teamId)) { + return $teamId; + } + + $server = $this->resolveServer($teamId, $request->route('uuid')); + if ($server instanceof JsonResponse) { + return $server; + } + + $this->authorize('view', $server); + + return $this->listEnvs($teamId, 'server', ['server_id' => $server->id]); + } + + #[OA\Post( + summary: 'Create Server Shared Env', + description: 'Create a shared environment variable for a server (type=server).', + path: '/servers/{uuid}/envs', + operationId: 'create-server-shared-env', + security: [['bearerAuth' => []]], + tags: ['Shared Environment Variables'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response(response: 201, description: 'Environment variable created.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 409, description: 'Environment variable already exists.'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ], + )] + public function server_create_env(Request $request): JsonResponse + { + $teamId = $this->teamIdOrAbort(); + if (! is_int($teamId)) { + return $teamId; + } + + $server = $this->resolveServer($teamId, $request->route('uuid')); + if ($server instanceof JsonResponse) { + return $server; + } + + $this->authorize('view', $server); + + return $this->createEnv($request, $teamId, 'server', ['server_id' => $server->id]); + } + + #[OA\Patch( + summary: 'Update Server Shared Env', + description: 'Update a server shared environment variable by id.', + path: '/servers/{uuid}/envs/{env_id}', + operationId: 'update-server-shared-env', + security: [['bearerAuth' => []]], + tags: ['Shared Environment Variables'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'env_id', in: 'path', required: true, description: 'Shared env id (integer).', schema: new OA\Schema(type: 'integer')), + ], + responses: [ + new OA\Response(response: 200, description: 'Environment variable updated.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ], + )] + public function server_update_env(Request $request): JsonResponse + { + $teamId = $this->teamIdOrAbort(); + if (! is_int($teamId)) { + return $teamId; + } + + $server = $this->resolveServer($teamId, $request->route('uuid')); + if ($server instanceof JsonResponse) { + return $server; + } + + $this->authorize('view', $server); + + return $this->updateEnv( + $request, + $teamId, + $request->route('env_id'), + 'server', + ['server_id' => $server->id], + ); + } + + #[OA\Delete( + summary: 'Delete Server Shared Env', + description: 'Delete a server shared environment variable by id.', + path: '/servers/{uuid}/envs/{env_id}', + operationId: 'delete-server-shared-env', + security: [['bearerAuth' => []]], + tags: ['Shared Environment Variables'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'env_id', in: 'path', required: true, description: 'Shared env id (integer).', schema: new OA\Schema(type: 'integer')), + ], + responses: [ + new OA\Response(response: 200, description: 'Environment variable deleted.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ], + )] + public function server_delete_env(Request $request): JsonResponse + { + $teamId = $this->teamIdOrAbort(); + if (! is_int($teamId)) { + return $teamId; + } + + $server = $this->resolveServer($teamId, $request->route('uuid')); + if ($server instanceof JsonResponse) { + return $server; + } + + $this->authorize('view', $server); + + return $this->deleteEnv( + $teamId, + $request->route('env_id'), + 'server', + ['server_id' => $server->id], + ); + } +} diff --git a/app/Http/Controllers/Api/TagsController.php b/app/Http/Controllers/Api/TagsController.php index 173a8ab7b..af1e51728 100644 --- a/app/Http/Controllers/Api/TagsController.php +++ b/app/Http/Controllers/Api/TagsController.php @@ -4,8 +4,10 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Models\Tag; +use Illuminate\Database\QueryException; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Validator; use OpenApi\Attributes as OA; class TagsController extends Controller @@ -20,6 +22,57 @@ class TagsController extends Controller ]; } + private function normalizeTagName(string $name): string + { + return strtolower(trim(strip_tags($name))); + } + + private function validateTagWriteRequest(Request $request, array $allowedFields = ['name']): array|JsonResponse + { + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + + $validator = Validator::make($request->all(), [ + 'name' => 'required|string|min:2|max:255', + ]); + + $extraFields = array_diff(array_keys($request->all()), $allowedFields); + if ($validator->fails() || ! empty($extraFields)) { + $errors = $validator->errors(); + if (! empty($extraFields)) { + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + } + + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $errors, + ], 422); + } + + $name = $this->normalizeTagName((string) $request->input('name')); + if (mb_strlen($name) < 2) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['name' => ['The tag name must be at least 2 characters after sanitization.']], + ], 422); + } + + return ['name' => $name]; + } + + private function isUniqueConstraintViolation(QueryException $exception): bool + { + $sqlState = $exception->errorInfo[0] ?? null; + $driverCode = (string) ($exception->errorInfo[1] ?? $exception->getCode()); + + return in_array($sqlState, ['23000', '23505'], true) + || in_array($driverCode, ['19', '1062', '2067'], true); + } + #[OA\Get( summary: 'List', description: 'List all tags for the current team.', @@ -58,4 +111,209 @@ class TagsController extends Controller return response()->json($tags->map(self::serializeTag(...))); } + + #[OA\Post( + summary: 'Create', + description: 'Create a tag for the current team.', + path: '/tags', + operationId: 'create-tag', + security: [ + ['bearerAuth' => []], + ], + tags: ['Tags'], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + required: ['name'], + properties: [ + new OA\Property(property: 'name', type: 'string', minLength: 2, maxLength: 255), + ], + type: 'object', + ), + ), + responses: [ + new OA\Response( + response: 201, + description: 'Tag created.', + content: new OA\JsonContent(ref: '#/components/schemas/Tag'), + ), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 409, description: 'Tag with this name already exists.'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ] + )] + public function create(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $this->authorize('create', Tag::class); + + $validated = $this->validateTagWriteRequest($request); + if ($validated instanceof JsonResponse) { + return $validated; + } + + if (Tag::where('team_id', $teamId)->where('name', $validated['name'])->exists()) { + return response()->json(['message' => 'Tag with this name already exists.'], 409); + } + + try { + $tag = Tag::create([ + 'name' => $validated['name'], + 'team_id' => $teamId, + ]); + } catch (QueryException $exception) { + if ($this->isUniqueConstraintViolation($exception)) { + return response()->json(['message' => 'Tag with this name already exists.'], 409); + } + + throw $exception; + } + + auditLog('api.tag.created', [ + 'team_id' => $teamId, + 'tag_uuid' => $tag->uuid, + 'tag_name' => $tag->name, + ]); + + return response()->json(self::serializeTag($tag), 201); + } + + #[OA\Patch( + summary: 'Update', + description: 'Update a tag name for the current team.', + path: '/tags/{uuid}', + operationId: 'update-tag-by-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Tags'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Tag UUID', schema: new OA\Schema(type: 'string')), + ], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + required: ['name'], + properties: [ + new OA\Property(property: 'name', type: 'string', minLength: 2, maxLength: 255), + ], + type: 'object', + ), + ), + responses: [ + new OA\Response( + response: 200, + description: 'Tag updated.', + content: new OA\JsonContent(ref: '#/components/schemas/Tag'), + ), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 409, description: 'Tag with this name already exists.'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ] + )] + public function update(Request $request, string $uuid): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $validated = $this->validateTagWriteRequest($request); + if ($validated instanceof JsonResponse) { + return $validated; + } + + $tag = Tag::where('team_id', $teamId)->where('uuid', $uuid)->first(); + if (! $tag) { + return response()->json(['message' => 'Tag not found.'], 404); + } + + $this->authorize('update', $tag); + + if ($validated['name'] !== $tag->name + && Tag::where('team_id', $teamId)->where('name', $validated['name'])->where('id', '!=', $tag->id)->exists()) { + return response()->json(['message' => 'Tag with this name already exists.'], 409); + } + + try { + $tag->update(['name' => $validated['name']]); + } catch (QueryException $exception) { + if ($this->isUniqueConstraintViolation($exception)) { + return response()->json(['message' => 'Tag with this name already exists.'], 409); + } + + throw $exception; + } + + auditLog('api.tag.updated', [ + 'team_id' => $teamId, + 'tag_uuid' => $tag->uuid, + 'tag_name' => $tag->name, + 'changed_fields' => ['name'], + ]); + + return response()->json(self::serializeTag($tag->refresh())); + } + + #[OA\Delete( + summary: 'Delete', + description: 'Delete a tag for the current team. Detaches the tag from all resources via cascade.', + path: '/tags/{uuid}', + operationId: 'delete-tag-by-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Tags'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Tag UUID', schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Tag deleted.', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'message', type: 'string', example: 'Tag deleted.'), + ], + type: 'object', + ), + ), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ] + )] + public function delete(Request $request, string $uuid): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $tag = Tag::where('team_id', $teamId)->where('uuid', $uuid)->first(); + if (! $tag) { + return response()->json(['message' => 'Tag not found.'], 404); + } + + $this->authorize('delete', $tag); + + $tagUuid = $tag->uuid; + $tagName = $tag->name; + // taggables rows cascade-delete via FK on tag_id + $tag->delete(); + + auditLog('api.tag.deleted', [ + 'team_id' => $teamId, + 'tag_uuid' => $tagUuid, + 'tag_name' => $tagName, + ]); + + return response()->json(['message' => 'Tag deleted.']); + } } diff --git a/app/Http/Controllers/Api/TeamController.php b/app/Http/Controllers/Api/TeamController.php index d47399533..35e01c831 100644 --- a/app/Http/Controllers/Api/TeamController.php +++ b/app/Http/Controllers/Api/TeamController.php @@ -184,9 +184,9 @@ class TeamController extends Controller #[OA\Get( summary: 'Authenticated Team', - description: 'Get currently authenticated team.', - path: '/teams/current', - operationId: 'get-current-team', + description: 'Get the team bound to the API token.', + path: '/team', + operationId: 'get-token-team', security: [ ['bearerAuth' => []], ], @@ -194,7 +194,7 @@ class TeamController extends Controller responses: [ new OA\Response( response: 200, - description: 'Current Team.', + description: 'Team bound to the API token.', content: new OA\JsonContent(ref: '#/components/schemas/Team')), new OA\Response( response: 401, @@ -224,9 +224,9 @@ class TeamController extends Controller #[OA\Get( summary: 'Authenticated Team Members', - description: 'Get currently authenticated team members.', - path: '/teams/current/members', - operationId: 'get-current-team-members', + description: 'Get members of the team bound to the API token.', + path: '/team/members', + operationId: 'get-token-team-members', security: [ ['bearerAuth' => []], ], @@ -234,7 +234,7 @@ class TeamController extends Controller responses: [ new OA\Response( response: 200, - description: 'Currently authenticated team members.', + description: 'Members of the team bound to the API token.', content: [ new OA\MediaType( mediaType: 'application/json', diff --git a/app/Http/Controllers/Api/VolumeBackupsController.php b/app/Http/Controllers/Api/VolumeBackupsController.php index e30cdad38..e51bf31f8 100644 --- a/app/Http/Controllers/Api/VolumeBackupsController.php +++ b/app/Http/Controllers/Api/VolumeBackupsController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api; use App\Actions\Shared\DeleteScheduledVolumeBackup; use App\Http\Controllers\Controller; +use App\Jobs\VolumeBackupJob; use App\Models\Application; use App\Models\LocalFileVolume; use App\Models\LocalPersistentVolume; @@ -442,4 +443,103 @@ class VolumeBackupsController extends Controller 'timeout' => $backup->timeout, ]; } + + #[OA\Post( + summary: 'Run application storage backup', + description: 'Queue an immediate volume backup for an application storage that has a schedule.', + path: '/applications/{uuid}/storages/{storage_uuid}/backups/run', + operationId: 'run-application-storage-backup', + security: [['bearerAuth' => []]], + tags: ['Applications'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'storage_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response(response: 200, description: 'Storage backup queued.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ] + )] + #[OA\Post( + summary: 'Run database storage backup', + description: 'Queue an immediate volume backup for a database storage that has a schedule.', + path: '/databases/{uuid}/storages/{storage_uuid}/backups/run', + operationId: 'run-database-storage-backup', + security: [['bearerAuth' => []]], + tags: ['Databases'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'storage_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response(response: 200, description: 'Storage backup queued.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ] + )] + #[OA\Post( + summary: 'Run service storage backup', + description: 'Queue an immediate volume backup for a service storage that has a schedule.', + path: '/services/{uuid}/storages/{storage_uuid}/backups/run', + operationId: 'run-service-storage-backup', + security: [['bearerAuth' => []]], + tags: ['Services'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'storage_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response(response: 200, description: 'Storage backup queued.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ] + )] + public function run(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $resourceType = $request->route('resource_type'); + $resource = $this->findResource($resourceType, $request->route('uuid'), $teamId); + if (! $resource) { + return response()->json([ + 'message' => match ($resourceType) { + 'application' => 'Application not found.', + 'database' => 'Database not found.', + 'service' => 'Service not found.', + default => 'Resource not found.', + }, + ], 404); + } + + $this->authorize('update', $resource); + + $storage = $this->findStorage($resource, $request->route('storage_uuid')); + if (! $storage) { + return response()->json(['message' => 'Storage not found.'], 404); + } + + $backup = $storage->scheduledBackups()->first(); + if (! $backup) { + return response()->json(['message' => 'Storage backup schedule not found.'], 404); + } + + VolumeBackupJob::dispatch($backup); + + auditLog('api.volume_backup.run', [ + 'team_id' => $teamId, + 'resource_type' => $resourceType, + 'resource_uuid' => $resource->uuid, + 'storage_uuid' => $storage->uuid, + 'backup_uuid' => $backup->uuid, + ]); + + return response()->json([ + 'message' => 'Storage backup queued.', + 'uuid' => $backup->uuid, + ]); + } } diff --git a/app/Models/Application.php b/app/Models/Application.php index eff03b175..ce86ba13b 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -74,6 +74,7 @@ use Symfony\Component\Yaml\Yaml; 'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares.'], 'status' => ['type' => 'string', 'description' => 'Application status.'], 'preview_url_template' => ['type' => 'string', 'description' => 'Preview URL template.'], + 'max_restart_count' => ['type' => 'integer', 'description' => 'Maximum container restart count before stopping.'], 'destination_type' => ['type' => 'string', 'description' => 'Destination type.'], 'destination_id' => ['type' => 'integer', 'description' => 'Destination identifier.'], 'source_id' => ['type' => 'integer', 'nullable' => true, 'description' => 'Source identifier.'], diff --git a/app/Models/SharedEnvironmentVariable.php b/app/Models/SharedEnvironmentVariable.php index 8bb241240..c70bf9f08 100644 --- a/app/Models/SharedEnvironmentVariable.php +++ b/app/Models/SharedEnvironmentVariable.php @@ -39,6 +39,14 @@ class SharedEnvironmentVariable extends Model 'value' => 'encrypted', ]; + /** + * Scope shared environment variables to a team (API token team_id). + */ + public static function ownedByCurrentTeamAPI(int $teamId) + { + return static::where('team_id', $teamId); + } + protected function key(): Attribute { return Attribute::make( diff --git a/app/Policies/TagPolicy.php b/app/Policies/TagPolicy.php new file mode 100644 index 000000000..7c19d5f1b --- /dev/null +++ b/app/Policies/TagPolicy.php @@ -0,0 +1,65 @@ +teams->contains('id', $tag->team_id); + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + return $user->isAdmin(); + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, Tag $tag): bool + { + return $user->isAdminOfTeam((int) $tag->team_id); + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, Tag $tag): bool + { + return $user->isAdminOfTeam((int) $tag->team_id); + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(User $user, Tag $tag): bool + { + return false; + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(User $user, Tag $tag): bool + { + return false; + } +} diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index 7bc8f162d..f68e46516 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -35,6 +35,7 @@ use App\Models\StandaloneMysql; use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; use App\Models\SwarmDocker; +use App\Models\Tag; use App\Models\Team; use App\Models\TelegramNotificationSettings; use App\Models\V5\Application as V5Application; @@ -66,6 +67,7 @@ use App\Policies\ServicePolicy; use App\Policies\SharedEnvironmentVariablePolicy; use App\Policies\StandaloneDockerPolicy; use App\Policies\SwarmDockerPolicy; +use App\Policies\TagPolicy; use App\Policies\TeamPolicy; use App\Policies\V5\ApplicationPolicy as V5ApplicationPolicy; use App\Policies\V5\ClusterPolicy as V5ClusterPolicy; @@ -134,6 +136,7 @@ class AuthServiceProvider extends ServiceProvider // Cloud provider policies CloudProviderToken::class => CloudProviderTokenPolicy::class, CloudInitScript::class => CloudInitScriptPolicy::class, + Tag::class => TagPolicy::class, // V5 policies - scoped to the current team resolved from the request V5Application::class => V5ApplicationPolicy::class, diff --git a/app/Rules/SafeWebhookUrl.php b/app/Rules/SafeWebhookUrl.php index 478b05197..0316da0bc 100644 --- a/app/Rules/SafeWebhookUrl.php +++ b/app/Rules/SafeWebhookUrl.php @@ -216,9 +216,23 @@ class SafeWebhookUrl implements ValidationRule $customDnsServers = $this->customDnsServers(); if ($customDnsServers !== []) { - return $this->resolveHostWithCustomDnsServers($host, $customDnsServers); + $customResolvedIps = $this->resolveHostWithCustomDnsServers($host, $customDnsServers); + // Fall back to the system resolver when custom DNS has no answer so + // docker/internal hostnames (e.g. coolify-minio) still work with an + // instance-level public DNS server configured. + if ($customResolvedIps !== []) { + return $customResolvedIps; + } } + return $this->resolveHostWithSystemDns($host); + } + + /** + * @return array + */ + protected function resolveHostWithSystemDns(string $host): array + { $records = @dns_get_record($host, DNS_A | DNS_AAAA); if ($records === false) { $records = []; @@ -249,7 +263,7 @@ class SafeWebhookUrl implements ValidationRule * @param array $dnsServers * @return array */ - private function resolveHostWithCustomDnsServers(string $host, array $dnsServers): array + protected function resolveHostWithCustomDnsServers(string $host, array $dnsServers): array { $ips = []; diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php index e314ead82..60ec07aa8 100644 --- a/bootstrap/helpers/api.php +++ b/bootstrap/helpers/api.php @@ -129,6 +129,16 @@ function sharedDataApplications() 'is_gzip_enabled' => 'boolean', 'is_stripprefix_enabled' => 'boolean', 'is_raw_compose_deployment_enabled' => 'boolean', + 'is_log_drain_enabled' => 'boolean', + 'is_gpu_enabled' => 'boolean', + 'gpu_driver' => 'string|nullable', + 'gpu_count' => 'string|nullable', + 'gpu_device_ids' => 'string|nullable', + 'gpu_options' => 'string|nullable', + 'is_consistent_container_name_enabled' => 'boolean', + 'custom_internal_name' => 'string|nullable', + 'preview_url_template' => 'string', + 'max_restart_count' => 'integer|min:0', 'stop_grace_period' => 'nullable|integer|min:'.MIN_STOP_GRACE_PERIOD_SECONDS.'|max:'.MAX_STOP_GRACE_PERIOD_SECONDS, 'docker_images_to_keep' => 'integer|min:0|max:100', 'static_image' => Rule::enum(StaticImageTypes::class), @@ -310,6 +320,14 @@ function removeUnnecessaryFieldsFromRequest(Request $request) $request->offsetUnset('is_gzip_enabled'); $request->offsetUnset('is_stripprefix_enabled'); $request->offsetUnset('is_raw_compose_deployment_enabled'); + $request->offsetUnset('is_log_drain_enabled'); + $request->offsetUnset('is_gpu_enabled'); + $request->offsetUnset('gpu_driver'); + $request->offsetUnset('gpu_count'); + $request->offsetUnset('gpu_device_ids'); + $request->offsetUnset('gpu_options'); + $request->offsetUnset('is_consistent_container_name_enabled'); + $request->offsetUnset('custom_internal_name'); $request->offsetUnset('docker_compose_raw'); $request->offsetUnset('tags'); } diff --git a/openapi.json b/openapi.json index 47060a4b0..4e9a12d1f 100644 --- a/openapi.json +++ b/openapi.json @@ -442,6 +442,52 @@ "type": "boolean", "description": "Deploy the raw Docker Compose definition." }, + "is_log_drain_enabled": { + "type": "boolean", + "description": "Enable log drain for the application." + }, + "is_gpu_enabled": { + "type": "boolean", + "description": "Enable GPU support." + }, + "gpu_driver": { + "type": "string", + "nullable": true, + "description": "GPU driver name." + }, + "gpu_count": { + "type": "string", + "nullable": true, + "description": "Number of GPUs to allocate." + }, + "gpu_device_ids": { + "type": "string", + "nullable": true, + "description": "Comma-separated GPU device IDs." + }, + "gpu_options": { + "type": "string", + "nullable": true, + "description": "Additional GPU options." + }, + "is_consistent_container_name_enabled": { + "type": "boolean", + "description": "Use a consistent container name across deployments." + }, + "custom_internal_name": { + "type": "string", + "nullable": true, + "description": "Custom internal container name." + }, + "preview_url_template": { + "type": "string", + "description": "Preview URL template." + }, + "max_restart_count": { + "type": "integer", + "minimum": 0, + "description": "Maximum container restart count before stopping." + }, "is_http_basic_auth_enabled": { "type": "boolean", "description": "HTTP Basic Authentication enabled." @@ -965,6 +1011,52 @@ "type": "boolean", "description": "Deploy the raw Docker Compose definition." }, + "is_log_drain_enabled": { + "type": "boolean", + "description": "Enable log drain for the application." + }, + "is_gpu_enabled": { + "type": "boolean", + "description": "Enable GPU support." + }, + "gpu_driver": { + "type": "string", + "nullable": true, + "description": "GPU driver name." + }, + "gpu_count": { + "type": "string", + "nullable": true, + "description": "Number of GPUs to allocate." + }, + "gpu_device_ids": { + "type": "string", + "nullable": true, + "description": "Comma-separated GPU device IDs." + }, + "gpu_options": { + "type": "string", + "nullable": true, + "description": "Additional GPU options." + }, + "is_consistent_container_name_enabled": { + "type": "boolean", + "description": "Use a consistent container name across deployments." + }, + "custom_internal_name": { + "type": "string", + "nullable": true, + "description": "Custom internal container name." + }, + "preview_url_template": { + "type": "string", + "description": "Preview URL template." + }, + "max_restart_count": { + "type": "integer", + "minimum": 0, + "description": "Maximum container restart count before stopping." + }, "is_http_basic_auth_enabled": { "type": "boolean", "description": "HTTP Basic Authentication enabled." @@ -1488,6 +1580,52 @@ "type": "boolean", "description": "Deploy the raw Docker Compose definition." }, + "is_log_drain_enabled": { + "type": "boolean", + "description": "Enable log drain for the application." + }, + "is_gpu_enabled": { + "type": "boolean", + "description": "Enable GPU support." + }, + "gpu_driver": { + "type": "string", + "nullable": true, + "description": "GPU driver name." + }, + "gpu_count": { + "type": "string", + "nullable": true, + "description": "Number of GPUs to allocate." + }, + "gpu_device_ids": { + "type": "string", + "nullable": true, + "description": "Comma-separated GPU device IDs." + }, + "gpu_options": { + "type": "string", + "nullable": true, + "description": "Additional GPU options." + }, + "is_consistent_container_name_enabled": { + "type": "boolean", + "description": "Use a consistent container name across deployments." + }, + "custom_internal_name": { + "type": "string", + "nullable": true, + "description": "Custom internal container name." + }, + "preview_url_template": { + "type": "string", + "description": "Preview URL template." + }, + "max_restart_count": { + "type": "integer", + "minimum": 0, + "description": "Maximum container restart count before stopping." + }, "is_http_basic_auth_enabled": { "type": "boolean", "description": "HTTP Basic Authentication enabled." @@ -1916,6 +2054,52 @@ "type": "boolean", "description": "Deploy the raw Docker Compose definition." }, + "is_log_drain_enabled": { + "type": "boolean", + "description": "Enable log drain for the application." + }, + "is_gpu_enabled": { + "type": "boolean", + "description": "Enable GPU support." + }, + "gpu_driver": { + "type": "string", + "nullable": true, + "description": "GPU driver name." + }, + "gpu_count": { + "type": "string", + "nullable": true, + "description": "Number of GPUs to allocate." + }, + "gpu_device_ids": { + "type": "string", + "nullable": true, + "description": "Comma-separated GPU device IDs." + }, + "gpu_options": { + "type": "string", + "nullable": true, + "description": "Additional GPU options." + }, + "is_consistent_container_name_enabled": { + "type": "boolean", + "description": "Use a consistent container name across deployments." + }, + "custom_internal_name": { + "type": "string", + "nullable": true, + "description": "Custom internal container name." + }, + "preview_url_template": { + "type": "string", + "description": "Preview URL template." + }, + "max_restart_count": { + "type": "integer", + "minimum": 0, + "description": "Maximum container restart count before stopping." + }, "is_http_basic_auth_enabled": { "type": "boolean", "description": "HTTP Basic Authentication enabled." @@ -2324,6 +2508,52 @@ "type": "boolean", "description": "Deploy the raw Docker Compose definition." }, + "is_log_drain_enabled": { + "type": "boolean", + "description": "Enable log drain for the application." + }, + "is_gpu_enabled": { + "type": "boolean", + "description": "Enable GPU support." + }, + "gpu_driver": { + "type": "string", + "nullable": true, + "description": "GPU driver name." + }, + "gpu_count": { + "type": "string", + "nullable": true, + "description": "Number of GPUs to allocate." + }, + "gpu_device_ids": { + "type": "string", + "nullable": true, + "description": "Comma-separated GPU device IDs." + }, + "gpu_options": { + "type": "string", + "nullable": true, + "description": "Additional GPU options." + }, + "is_consistent_container_name_enabled": { + "type": "boolean", + "description": "Use a consistent container name across deployments." + }, + "custom_internal_name": { + "type": "string", + "nullable": true, + "description": "Custom internal container name." + }, + "preview_url_template": { + "type": "string", + "description": "Preview URL template." + }, + "max_restart_count": { + "type": "integer", + "minimum": 0, + "description": "Maximum container restart count before stopping." + }, "is_http_basic_auth_enabled": { "type": "boolean", "description": "HTTP Basic Authentication enabled." @@ -2967,6 +3197,52 @@ "type": "boolean", "description": "Deploy the raw Docker Compose definition." }, + "is_log_drain_enabled": { + "type": "boolean", + "description": "Enable log drain for the application." + }, + "is_gpu_enabled": { + "type": "boolean", + "description": "Enable GPU support." + }, + "gpu_driver": { + "type": "string", + "nullable": true, + "description": "GPU driver name." + }, + "gpu_count": { + "type": "string", + "nullable": true, + "description": "Number of GPUs to allocate." + }, + "gpu_device_ids": { + "type": "string", + "nullable": true, + "description": "Comma-separated GPU device IDs." + }, + "gpu_options": { + "type": "string", + "nullable": true, + "description": "Additional GPU options." + }, + "is_consistent_container_name_enabled": { + "type": "boolean", + "description": "Use a consistent container name across deployments." + }, + "custom_internal_name": { + "type": "string", + "nullable": true, + "description": "Custom internal container name." + }, + "preview_url_template": { + "type": "string", + "description": "Preview URL template." + }, + "max_restart_count": { + "type": "integer", + "minimum": 0, + "description": "Maximum container restart count before stopping." + }, "connect_to_docker_network": { "type": "boolean", "description": "The flag to connect the service to the predefined Docker network." @@ -4380,6 +4656,597 @@ ] } }, + "\/applications\/{uuid}\/clone": { + "post": { + "tags": [ + "Applications" + ], + "summary": "Clone", + "description": "Clone an application to a destination owned by the authenticated team.", + "operationId": "clone-application-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "destination_uuid" + ], + "properties": { + "destination_uuid": { + "description": "UUID of the destination to clone into.", + "type": "string" + }, + "name": { + "description": "Optional name for the cloned application.", + "type": [ + "string", + "null" + ] + }, + "clone_volumes": { + "description": "Whether to clone volume data.", + "type": "boolean", + "default": false + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Application cloned.", + "content": { + "application\/json": { + "schema": { + "properties": { + "uuid": { + "type": "string" + }, + "message": { + "type": "string", + "example": "Application cloned." + } + }, + "type": "object" + } + } + } + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/applications\/{uuid}\/rollback-images": { + "get": { + "tags": [ + "Applications" + ], + "summary": "List Rollback Images", + "description": "List available Docker images for rolling back an application. Returns an empty list when the server is unavailable or remote inspection is not possible.", + "operationId": "list-application-rollback-images", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Rollback images.", + "content": { + "application\/json": { + "schema": { + "properties": { + "current": { + "type": [ + "string", + "null" + ] + }, + "images": { + "type": "array", + "items": { + "properties": { + "tag": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "is_current": { + "type": "boolean" + } + }, + "type": "object" + } + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/applications\/{uuid}\/rollback": { + "post": { + "tags": [ + "Applications" + ], + "summary": "Rollback", + "description": "Queue a rollback deployment for an application to a previous image commit\/tag.", + "operationId": "rollback-application-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "commit" + ], + "properties": { + "commit": { + "description": "Image tag \/ commit to roll back to.", + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Rollback deployment queued.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string" + }, + "deployment_uuid": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/applications\/{uuid}\/destinations": { + "get": { + "tags": [ + "Applications" + ], + "summary": "List Destinations", + "description": "List primary and additional destinations for a standalone application.", + "operationId": "list-application-destinations", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Application destinations." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Applications" + ], + "summary": "Add Destination", + "description": "Attach an additional standalone Docker destination to an application.", + "operationId": "add-application-destination", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "destination_uuid" + ], + "properties": { + "destination_uuid": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Destination attached." + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/applications\/{uuid}\/destinations\/{destination_uuid}": { + "delete": { + "tags": [ + "Applications" + ], + "summary": "Remove Destination", + "description": "Detach an additional destination from an application.", + "operationId": "remove-application-destination", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "destination_uuid", + "in": "path", + "description": "UUID of the destination.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Destination detached." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/cloud-init-scripts": { + "get": { + "tags": [ + "Cloud-init Scripts" + ], + "summary": "List Cloud-init Scripts", + "description": "List all cloud-init scripts for the authenticated team.", + "operationId": "list-cloud-init-scripts", + "responses": { + "200": { + "description": "Cloud-init scripts for the team." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "403": { + "description": "Forbidden." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Cloud-init Scripts" + ], + "summary": "Create Cloud-init Script", + "description": "Create a new cloud-init script for the authenticated team.", + "operationId": "create-cloud-init-script", + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "name", + "script" + ], + "properties": { + "name": { + "type": "string" + }, + "script": { + "description": "Bash script (#!) or cloud-config YAML.", + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Cloud-init script created." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "403": { + "description": "Forbidden." + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/cloud-init-scripts\/{uuid}": { + "get": { + "tags": [ + "Cloud-init Scripts" + ], + "summary": "Get Cloud-init Script", + "description": "Get a cloud-init script by UUID.", + "operationId": "get-cloud-init-script-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Cloud-init script." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "403": { + "description": "Forbidden." + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "delete": { + "tags": [ + "Cloud-init Scripts" + ], + "summary": "Delete Cloud-init Script", + "description": "Delete a cloud-init script by UUID.", + "operationId": "delete-cloud-init-script-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Cloud-init script deleted." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "403": { + "description": "Forbidden." + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Cloud-init Scripts" + ], + "summary": "Update Cloud-init Script", + "description": "Update a cloud-init script by UUID.", + "operationId": "update-cloud-init-script-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "properties": { + "name": { + "type": "string" + }, + "script": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Cloud-init script updated." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "403": { + "description": "Forbidden." + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/cloud-tokens": { "get": { "tags": [ @@ -8082,6 +8949,77 @@ ] } }, + "\/databases\/{uuid}\/clone": { + "post": { + "tags": [ + "Databases" + ], + "summary": "Clone", + "description": "Clone a database to a destination owned by the authenticated team.", + "operationId": "clone-database-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "destination_uuid" + ], + "properties": { + "destination_uuid": { + "type": "string" + }, + "name": { + "type": [ + "string", + "null" + ] + }, + "clone_volumes": { + "type": "boolean", + "default": false + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Database cloned." + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/deployments": { "get": { "tags": [ @@ -8678,6 +9616,67 @@ "bearerAuth": [] } ] + }, + "patch": { + "tags": [ + "Destinations" + ], + "summary": "Update destination", + "description": "Update a Docker network destination name. Network cannot be changed via the API.", + "operationId": "update-destination-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Destination UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "properties": { + "name": { + "type": "string", + "maxLength": 255 + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Destination updated.", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/Destination" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] } }, "\/digitalocean\/regions": { @@ -9439,6 +10438,409 @@ ] } }, + "\/gitlab-apps": { + "get": { + "tags": [ + "GitLab Apps" + ], + "summary": "List", + "description": "List all GitLab apps for the current team (and system-wide sources).", + "operationId": "list-gitlab-apps", + "responses": { + "200": { + "description": "List of GitLab apps.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "properties": { + "id": { + "type": "integer" + }, + "uuid": { + "type": "string" + }, + "name": { + "type": "string" + }, + "api_url": { + "type": "string" + }, + "html_url": { + "type": "string" + }, + "custom_user": { + "type": "string" + }, + "custom_port": { + "type": "integer" + }, + "client_id": { + "type": "string", + "nullable": true + }, + "group_name": { + "type": "string", + "nullable": true + }, + "redirect_uri": { + "type": "string", + "nullable": true + }, + "is_system_wide": { + "type": "boolean" + }, + "is_public": { + "type": "boolean" + }, + "team_id": { + "type": "integer" + } + }, + "type": "object" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "GitLab Apps" + ], + "summary": "Create GitLab App", + "description": "Create a new GitLab app (OAuth source). Credentials may be supplied later via the UI or update endpoint.", + "operationId": "create-gitlab-app", + "requestBody": { + "description": "GitLab app creation payload.", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "name", + "html_url" + ], + "properties": { + "name": { + "type": "string", + "description": "Name of the GitLab app." + }, + "html_url": { + "type": "string", + "description": "GitLab instance URL (e.g., https:\/\/gitlab.com)." + }, + "api_url": { + "type": "string", + "description": "GitLab API URL (defaults to {html_url}\/api\/v4)." + }, + "custom_user": { + "type": "string", + "description": "Custom user for SSH access (default: git)." + }, + "custom_port": { + "type": "integer", + "description": "Custom port for SSH access (default: 22)." + }, + "group_name": { + "type": "string", + "nullable": true, + "description": "Optional comma-separated group names to filter repositories." + }, + "client_id": { + "type": "string", + "nullable": true, + "description": "GitLab OAuth Application ID." + }, + "client_secret": { + "type": "string", + "nullable": true, + "description": "GitLab OAuth Application Secret." + }, + "webhook_token": { + "type": "string", + "nullable": true, + "description": "Webhook secret token (auto-generated when omitted)." + }, + "redirect_uri": { + "type": "string", + "nullable": true, + "description": "OAuth redirect URI registered in GitLab." + }, + "is_system_wide": { + "type": "boolean", + "description": "Is this app system-wide (non-cloud instances only)." + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "GitLab app created successfully.", + "content": { + "application\/json": { + "schema": { + "properties": { + "id": { + "type": "integer" + }, + "uuid": { + "type": "string" + }, + "name": { + "type": "string" + }, + "api_url": { + "type": "string" + }, + "html_url": { + "type": "string" + }, + "custom_user": { + "type": "string" + }, + "custom_port": { + "type": "integer" + }, + "client_id": { + "type": "string", + "nullable": true + }, + "group_name": { + "type": "string", + "nullable": true + }, + "redirect_uri": { + "type": "string", + "nullable": true + }, + "is_system_wide": { + "type": "boolean" + }, + "team_id": { + "type": "integer" + } + }, + "type": "object" + } + } + } + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/gitlab-apps\/{gitlab_app_id}": { + "delete": { + "tags": [ + "GitLab Apps" + ], + "summary": "Delete GitLab App", + "description": "Delete a GitLab app if it is not being used by any applications.", + "operationId": "deleteGitlabApp", + "parameters": [ + { + "name": "gitlab_app_id", + "in": "path", + "description": "GitLab App ID", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "GitLab app deleted successfully", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "GitLab app deleted successfully" + } + }, + "type": "object" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "GitLab app not found" + }, + "409": { + "description": "Conflict - GitLab app is in use", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "This GitLab app is being used by 5 application(s). Please delete all applications first." + } + }, + "type": "object" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "GitLab Apps" + ], + "summary": "Update GitLab App", + "description": "Update an existing GitLab app.", + "operationId": "updateGitlabApp", + "parameters": [ + { + "name": "gitlab_app_id", + "in": "path", + "description": "GitLab App ID", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "properties": { + "name": { + "type": "string", + "description": "GitLab App name" + }, + "html_url": { + "type": "string", + "description": "GitLab HTML URL" + }, + "api_url": { + "type": "string", + "description": "GitLab API URL" + }, + "custom_user": { + "type": "string", + "description": "Custom user for SSH" + }, + "custom_port": { + "type": "integer", + "description": "Custom port for SSH" + }, + "group_name": { + "type": "string", + "nullable": true, + "description": "Optional group filter" + }, + "client_id": { + "type": "string", + "nullable": true, + "description": "OAuth Application ID" + }, + "client_secret": { + "type": "string", + "nullable": true, + "description": "OAuth Application Secret" + }, + "webhook_token": { + "type": "string", + "nullable": true, + "description": "Webhook secret token" + }, + "redirect_uri": { + "type": "string", + "nullable": true, + "description": "OAuth redirect URI" + }, + "is_system_wide": { + "type": "boolean", + "description": "Is system wide (non-cloud instances only)" + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "GitLab app updated successfully", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "GitLab app updated successfully" + }, + "data": { + "type": "object", + "description": "Updated GitLab app data" + } + }, + "type": "object" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "GitLab app not found" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/hetzner\/locations": { "get": { "tags": [ @@ -10071,6 +11473,342 @@ ] } }, + "\/notifications\/email": { + "get": { + "tags": [ + "Notifications" + ], + "summary": "Get email notification settings", + "description": "Get the current team email notification settings. Encrypted secrets are only returned when the token has `read:sensitive` (or `root`) and the user is a team admin\/owner.", + "operationId": "get-current-team-email-notifications", + "responses": { + "200": { + "description": "Email notification settings." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Notifications" + ], + "summary": "Update email notification settings", + "description": "Update the current team email notification settings.", + "operationId": "update-current-team-email-notifications", + "responses": { + "200": { + "description": "Updated email notification settings." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "403": { + "description": "Forbidden." + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/notifications\/discord": { + "get": { + "tags": [ + "Notifications" + ], + "summary": "Get Discord notification settings", + "description": "Get the current team Discord notification settings. Encrypted secrets are only returned when the token has `read:sensitive` (or `root`) and the user is a team admin\/owner.", + "operationId": "get-current-team-discord-notifications", + "responses": { + "200": { + "description": "Discord notification settings." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Notifications" + ], + "summary": "Update Discord notification settings", + "description": "Update the current team Discord notification settings.", + "operationId": "update-current-team-discord-notifications", + "responses": { + "200": { + "description": "Updated Discord notification settings." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "403": { + "description": "Forbidden." + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/notifications\/slack": { + "get": { + "tags": [ + "Notifications" + ], + "summary": "Get Slack notification settings", + "description": "Get the current team Slack notification settings. Encrypted secrets are only returned when the token has `read:sensitive` (or `root`) and the user is a team admin\/owner.", + "operationId": "get-current-team-slack-notifications", + "responses": { + "200": { + "description": "Slack notification settings." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Notifications" + ], + "summary": "Update Slack notification settings", + "description": "Update the current team Slack notification settings.", + "operationId": "update-current-team-slack-notifications", + "responses": { + "200": { + "description": "Updated Slack notification settings." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "403": { + "description": "Forbidden." + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/notifications\/telegram": { + "get": { + "tags": [ + "Notifications" + ], + "summary": "Get Telegram notification settings", + "description": "Get the current team Telegram notification settings. Encrypted secrets are only returned when the token has `read:sensitive` (or `root`) and the user is a team admin\/owner.", + "operationId": "get-current-team-telegram-notifications", + "responses": { + "200": { + "description": "Telegram notification settings." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Notifications" + ], + "summary": "Update Telegram notification settings", + "description": "Update the current team Telegram notification settings.", + "operationId": "update-current-team-telegram-notifications", + "responses": { + "200": { + "description": "Updated Telegram notification settings." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "403": { + "description": "Forbidden." + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/notifications\/pushover": { + "get": { + "tags": [ + "Notifications" + ], + "summary": "Get Pushover notification settings", + "description": "Get the current team Pushover notification settings. Encrypted secrets are only returned when the token has `read:sensitive` (or `root`) and the user is a team admin\/owner.", + "operationId": "get-current-team-pushover-notifications", + "responses": { + "200": { + "description": "Pushover notification settings." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Notifications" + ], + "summary": "Update Pushover notification settings", + "description": "Update the current team Pushover notification settings.", + "operationId": "update-current-team-pushover-notifications", + "responses": { + "200": { + "description": "Updated Pushover notification settings." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "403": { + "description": "Forbidden." + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/notifications\/webhook": { + "get": { + "tags": [ + "Notifications" + ], + "summary": "Get webhook notification settings", + "description": "Get the current team webhook notification settings. Encrypted secrets are only returned when the token has `read:sensitive` (or `root`) and the user is a team admin\/owner.", + "operationId": "get-current-team-webhook-notifications", + "responses": { + "200": { + "description": "Webhook notification settings." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Notifications" + ], + "summary": "Update webhook notification settings", + "description": "Update the current team webhook notification settings.", + "operationId": "update-current-team-webhook-notifications", + "responses": { + "200": { + "description": "Updated webhook notification settings." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "403": { + "description": "Forbidden." + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/version": { "get": { "summary": "Version", @@ -10871,6 +12609,101 @@ "bearerAuth": [] } ] + }, + "patch": { + "tags": [ + "Projects" + ], + "summary": "Update Environment", + "description": "Update environment by name or UUID within a project.", + "operationId": "update-environment", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Project UUID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "environment_name_or_uuid", + "in": "path", + "description": "Environment name or UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Environment fields to update.", + "required": true, + "content": { + "application\/json": { + "schema": { + "properties": { + "name": { + "type": "string", + "description": "The name of the environment." + }, + "description": { + "type": "string", + "description": "The description of the environment." + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Environment updated.", + "content": { + "application\/json": { + "schema": { + "properties": { + "uuid": { + "type": "string", + "example": "env123" + }, + "name": { + "type": "string", + "example": "staging" + }, + "description": { + "type": "string", + "example": "Staging environment" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "description": "Project or environment not found." + }, + "409": { + "description": "Environment with this name already exists." + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] } }, "\/resources": { @@ -10907,6 +12740,449 @@ ] } }, + "\/s3-storages": { + "get": { + "tags": [ + "S3 Storages" + ], + "summary": "List S3 Storages", + "description": "List all S3 storages for the authenticated team.", + "operationId": "list-s3-storages", + "responses": { + "200": { + "description": "Get all S3 storages.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "properties": { + "uuid": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "endpoint": { + "type": "string" + }, + "bucket": { + "type": "string" + }, + "region": { + "type": "string" + }, + "is_usable": { + "type": "boolean" + }, + "team_id": { + "type": "integer" + }, + "created_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + }, + "type": "object" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "S3 Storages" + ], + "summary": "Create S3 Storage", + "description": "Create a new S3 storage configuration for the authenticated team.", + "operationId": "create-s3-storage", + "requestBody": { + "description": "S3 storage details", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "name", + "endpoint", + "bucket", + "region", + "key", + "secret" + ], + "properties": { + "name": { + "type": "string", + "example": "My S3 Storage", + "description": "A friendly name for the storage." + }, + "description": { + "type": "string", + "nullable": true, + "description": "Optional description." + }, + "endpoint": { + "type": "string", + "example": "https:\/\/s3.us-east-1.amazonaws.com", + "description": "S3-compatible endpoint URL." + }, + "bucket": { + "type": "string", + "example": "my-bucket", + "description": "S3 bucket name." + }, + "region": { + "type": "string", + "example": "us-east-1", + "description": "S3 region." + }, + "key": { + "type": "string", + "description": "Access key." + }, + "secret": { + "type": "string", + "description": "Secret key." + }, + "is_usable": { + "type": "boolean", + "description": "Whether the storage is marked usable." + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "S3 storage created.", + "content": { + "application\/json": { + "schema": { + "properties": { + "uuid": { + "type": "string", + "example": "og888os", + "description": "The UUID of the S3 storage." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/s3-storages\/{uuid}": { + "get": { + "tags": [ + "S3 Storages" + ], + "summary": "Get S3 Storage", + "description": "Get S3 storage by UUID.", + "operationId": "get-s3-storage-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "S3 Storage UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Get S3 storage by UUID", + "content": { + "application\/json": { + "schema": { + "properties": { + "uuid": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "endpoint": { + "type": "string" + }, + "bucket": { + "type": "string" + }, + "region": { + "type": "string" + }, + "is_usable": { + "type": "boolean" + }, + "team_id": { + "type": "integer" + }, + "created_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "delete": { + "tags": [ + "S3 Storages" + ], + "summary": "Delete S3 Storage", + "description": "Delete S3 storage by UUID.", + "operationId": "delete-s3-storage-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the S3 storage.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "S3 storage deleted.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "S3 storage deleted." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "S3 Storages" + ], + "summary": "Update S3 Storage", + "description": "Update S3 storage by UUID.", + "operationId": "update-s3-storage-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "S3 Storage UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "S3 storage fields to update.", + "required": true, + "content": { + "application\/json": { + "schema": { + "properties": { + "name": { + "type": "string", + "description": "A friendly name for the storage." + }, + "description": { + "type": "string", + "nullable": true, + "description": "Optional description." + }, + "endpoint": { + "type": "string", + "description": "S3-compatible endpoint URL." + }, + "bucket": { + "type": "string", + "description": "S3 bucket name." + }, + "region": { + "type": "string", + "description": "S3 region." + }, + "key": { + "type": "string", + "description": "Access key." + }, + "secret": { + "type": "string", + "description": "Secret key." + }, + "is_usable": { + "type": "boolean", + "description": "Whether the storage is marked usable." + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "S3 storage updated.", + "content": { + "application\/json": { + "schema": { + "properties": { + "uuid": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/s3-storages\/{uuid}\/validate": { + "post": { + "tags": [ + "S3 Storages" + ], + "summary": "Validate S3 Storage", + "description": "Validate an S3 storage connection using ListObjectsV2.", + "operationId": "validate-s3-storage-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "S3 Storage UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "S3 storage validation result.", + "content": { + "application\/json": { + "schema": { + "properties": { + "valid": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "S3 storage connection is valid." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/applications\/{uuid}\/scheduled-tasks": { "get": { "tags": [ @@ -11599,6 +13875,98 @@ ] } }, + "\/applications\/{uuid}\/scheduled-tasks\/{task_uuid}\/execute": { + "post": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "Execute Task", + "description": "Queue immediate execution of a scheduled task for an application.", + "operationId": "execute-scheduled-task-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "task_uuid", + "in": "path", + "description": "UUID of the scheduled task.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Scheduled task execution queued." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/services\/{uuid}\/scheduled-tasks\/{task_uuid}\/execute": { + "post": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "Execute Task", + "description": "Queue immediate execution of a scheduled task for a service.", + "operationId": "execute-scheduled-task-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "task_uuid", + "in": "path", + "description": "UUID of the scheduled task.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Scheduled task execution queued." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/security\/keys": { "get": { "tags": [ @@ -11877,6 +14245,1215 @@ ] } }, + "\/servers\/{uuid}\/cloudflare-tunnel": { + "get": { + "tags": [ + "Servers" + ], + "summary": "Get Cloudflare Tunnel settings", + "description": "Get Cloudflare Tunnel settings for a server owned by the authenticated team.", + "operationId": "get-server-cloudflare-tunnel", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Server UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Cloudflare Tunnel settings.", + "content": { + "application\/json": { + "schema": { + "properties": { + "is_cloudflare_tunnel": { + "type": "boolean" + }, + "ip": { + "type": "string" + }, + "ip_previous": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Servers" + ], + "summary": "Update Cloudflare Tunnel settings", + "description": "Update stored Cloudflare Tunnel settings for a server. Does not run remote cloudflared configuration; use enable\/disable for the manual UI actions.", + "operationId": "update-server-cloudflare-tunnel", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Server UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "properties": { + "is_cloudflare_tunnel": { + "type": "boolean" + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Updated Cloudflare Tunnel settings." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/servers\/{uuid}\/cloudflare-tunnel\/enable": { + "post": { + "tags": [ + "Servers" + ], + "summary": "Enable Cloudflare Tunnel (manual)", + "description": "Manually mark Cloudflare Tunnel as enabled for a server (matches UI manual enable). Does not deploy cloudflared remotely.", + "operationId": "enable-server-cloudflare-tunnel", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Server UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Cloudflare Tunnel enabled." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/servers\/{uuid}\/cloudflare-tunnel\/disable": { + "post": { + "tags": [ + "Servers" + ], + "summary": "Disable Cloudflare Tunnel", + "description": "Mark Cloudflare Tunnel as disabled and restore ip_previous when available. Does not remove the remote cloudflared container.", + "operationId": "disable-server-cloudflare-tunnel", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Server UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Cloudflare Tunnel disabled." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/servers\/{uuid}\/docker-cleanup": { + "get": { + "tags": [ + "Servers" + ], + "summary": "Get Docker cleanup settings", + "description": "Get Docker cleanup settings for a server owned by the authenticated team.", + "operationId": "get-server-docker-cleanup", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Server UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Docker cleanup settings.", + "content": { + "application\/json": { + "schema": { + "properties": { + "docker_cleanup_frequency": { + "type": "string" + }, + "docker_cleanup_threshold": { + "type": "integer" + }, + "force_docker_cleanup": { + "type": "boolean" + }, + "delete_unused_volumes": { + "type": "boolean" + }, + "delete_unused_networks": { + "type": "boolean" + }, + "disable_application_image_retention": { + "type": "boolean" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Servers" + ], + "summary": "Update Docker cleanup settings", + "description": "Update Docker cleanup settings for a server owned by the authenticated team.", + "operationId": "update-server-docker-cleanup", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Server UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "properties": { + "docker_cleanup_frequency": { + "description": "Cron \/ human frequency expression.", + "type": "string" + }, + "docker_cleanup_threshold": { + "type": "integer", + "maximum": 99, + "minimum": 1 + }, + "force_docker_cleanup": { + "type": "boolean" + }, + "delete_unused_volumes": { + "type": "boolean" + }, + "delete_unused_networks": { + "type": "boolean" + }, + "disable_application_image_retention": { + "type": "boolean" + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Updated Docker cleanup settings.", + "content": { + "application\/json": { + "schema": { + "properties": { + "docker_cleanup_frequency": { + "type": "string" + }, + "docker_cleanup_threshold": { + "type": "integer" + }, + "force_docker_cleanup": { + "type": "boolean" + }, + "delete_unused_volumes": { + "type": "boolean" + }, + "delete_unused_networks": { + "type": "boolean" + }, + "disable_application_image_retention": { + "type": "boolean" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/servers\/{uuid}\/docker-cleanup\/run": { + "post": { + "tags": [ + "Servers" + ], + "summary": "Run Docker cleanup", + "description": "Dispatch a manual Docker cleanup job for a server owned by the authenticated team.", + "operationId": "run-server-docker-cleanup", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Server UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": false, + "content": { + "application\/json": { + "schema": { + "properties": { + "delete_unused_volumes": { + "type": "boolean" + }, + "delete_unused_networks": { + "type": "boolean" + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Docker cleanup job dispatched.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Manual cleanup job started." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/servers\/{uuid}\/docker-cleanup\/executions": { + "get": { + "tags": [ + "Servers" + ], + "summary": "List Docker cleanup executions", + "description": "List recent Docker cleanup execution logs for a server owned by the authenticated team.", + "operationId": "list-server-docker-cleanup-executions", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Server UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Recent Docker cleanup executions.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "properties": { + "uuid": { + "type": "string" + }, + "status": { + "type": "string" + }, + "message": { + "type": [ + "string", + "null" + ] + }, + "finished_at": { + "type": [ + "string", + "null" + ] + }, + "created_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + }, + "type": "object" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/servers\/{uuid}\/log-drains": { + "get": { + "tags": [ + "Servers" + ], + "summary": "Get log drain settings", + "description": "Get log drain settings for a server owned by the authenticated team. Sensitive fields require the read:sensitive or root token ability.", + "operationId": "get-server-log-drains", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Server UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Log drain settings.", + "content": { + "application\/json": { + "schema": { + "properties": { + "is_logdrain_newrelic_enabled": { + "type": "boolean" + }, + "logdrain_newrelic_license_key": { + "description": "Only present with read:sensitive.", + "type": "string" + }, + "logdrain_newrelic_base_uri": { + "type": [ + "string", + "null" + ] + }, + "is_logdrain_axiom_enabled": { + "type": "boolean" + }, + "logdrain_axiom_dataset_name": { + "type": [ + "string", + "null" + ] + }, + "logdrain_axiom_api_key": { + "description": "Only present with read:sensitive.", + "type": "string" + }, + "is_logdrain_custom_enabled": { + "type": "boolean" + }, + "logdrain_custom_config": { + "description": "Only present with read:sensitive.", + "type": "string" + }, + "logdrain_custom_config_parser": { + "description": "Only present with read:sensitive.", + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Servers" + ], + "summary": "Update log drain settings", + "description": "Update New Relic, Axiom, or custom log drain settings for a server owned by the authenticated team.", + "operationId": "update-server-log-drains", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Server UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "properties": { + "is_logdrain_newrelic_enabled": { + "type": "boolean" + }, + "logdrain_newrelic_license_key": { + "type": "string" + }, + "logdrain_newrelic_base_uri": { + "type": "string" + }, + "is_logdrain_axiom_enabled": { + "type": "boolean" + }, + "logdrain_axiom_dataset_name": { + "type": "string" + }, + "logdrain_axiom_api_key": { + "type": "string" + }, + "is_logdrain_custom_enabled": { + "type": "boolean" + }, + "logdrain_custom_config": { + "type": "string" + }, + "logdrain_custom_config_parser": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Updated log drain settings." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/servers\/{uuid}\/proxy": { + "get": { + "tags": [ + "Servers" + ], + "summary": "Get server proxy", + "description": "Get proxy settings for a server owned by the authenticated team. The raw proxy configuration is only returned when the token has `read:sensitive` (or `root`) and the user is a team admin\/owner, and only when already stored in the database (no remote fetch).", + "operationId": "get-server-proxy", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Server UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Server proxy settings.", + "content": { + "application\/json": { + "schema": { + "properties": { + "proxy_type": { + "type": [ + "string", + "null" + ], + "example": "TRAEFIK" + }, + "status": { + "type": [ + "string", + "null" + ], + "example": "running" + }, + "redirect_enabled": { + "type": "boolean", + "example": true + }, + "redirect_url": { + "type": [ + "string", + "null" + ], + "example": "https:\/\/example.com" + }, + "generate_exact_labels": { + "type": "boolean", + "example": false + }, + "configuration": { + "description": "Docker Compose proxy configuration when stored in the database. Only present with read:sensitive.", + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Servers" + ], + "summary": "Update server proxy", + "description": "Update proxy redirect settings, exact labels generation, and optionally the proxy type for a team-owned server.", + "operationId": "update-server-proxy", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Server UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "properties": { + "redirect_enabled": { + "type": "boolean" + }, + "redirect_url": { + "description": "Public http(s) redirect URL, or null to clear.", + "type": [ + "string", + "null" + ] + }, + "generate_exact_labels": { + "type": "boolean" + }, + "proxy_type": { + "description": "Proxy type (case-insensitive).", + "type": "string", + "enum": [ + "traefik", + "caddy", + "nginx", + "none" + ] + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Proxy settings updated.", + "content": { + "application\/json": { + "schema": { + "properties": { + "proxy_type": { + "type": [ + "string", + "null" + ] + }, + "status": { + "type": [ + "string", + "null" + ] + }, + "redirect_enabled": { + "type": "boolean" + }, + "redirect_url": { + "type": [ + "string", + "null" + ] + }, + "generate_exact_labels": { + "type": "boolean" + }, + "configuration": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/servers\/{uuid}\/proxy\/configuration": { + "put": { + "tags": [ + "Servers" + ], + "summary": "Save server proxy configuration", + "description": "Save the raw proxy Docker Compose configuration for a team-owned server. Multi-line configuration must be base64 encoded (same pattern as other compose payloads).", + "operationId": "save-server-proxy-configuration", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Server UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "configuration" + ], + "properties": { + "configuration": { + "description": "Proxy docker-compose YAML. Prefer base64 encoding for multi-line content.", + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Proxy configuration saved.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Proxy configuration saved." + }, + "proxy_type": { + "type": [ + "string", + "null" + ] + }, + "status": { + "type": [ + "string", + "null" + ] + }, + "redirect_enabled": { + "type": "boolean" + }, + "redirect_url": { + "type": [ + "string", + "null" + ] + }, + "generate_exact_labels": { + "type": "boolean" + }, + "configuration": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/servers\/{uuid}\/proxy\/restart": { + "post": { + "tags": [ + "Servers" + ], + "summary": "Restart server proxy", + "description": "Queue a proxy restart for a team-owned server.", + "operationId": "restart-server-proxy", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Server UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Proxy restart queued.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Proxy restart queued." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/servers\/{uuid}\/sentinel": { + "get": { + "tags": [ + "Servers" + ], + "summary": "Get Sentinel settings", + "description": "Get Sentinel settings for a server owned by the authenticated team. sentinel_token and sentinel_custom_url require the read:sensitive or root token ability.", + "operationId": "get-server-sentinel", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Server UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Sentinel settings.", + "content": { + "application\/json": { + "schema": { + "properties": { + "is_sentinel_enabled": { + "type": "boolean" + }, + "is_metrics_enabled": { + "type": "boolean" + }, + "is_sentinel_debug_enabled": { + "type": "boolean" + }, + "sentinel_token": { + "description": "Only present with read:sensitive.", + "type": "string" + }, + "sentinel_metrics_refresh_rate_seconds": { + "type": "integer" + }, + "sentinel_metrics_history_days": { + "type": "integer" + }, + "sentinel_push_interval_seconds": { + "type": "integer" + }, + "sentinel_custom_url": { + "description": "Only present with read:sensitive.", + "type": "string" + }, + "sentinel_updated_at": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Servers" + ], + "summary": "Update Sentinel settings", + "description": "Update Sentinel settings for a server owned by the authenticated team. Changing token\/metrics timing fields may restart Sentinel.", + "operationId": "update-server-sentinel", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Server UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "properties": { + "is_sentinel_enabled": { + "type": "boolean" + }, + "is_metrics_enabled": { + "type": "boolean" + }, + "is_sentinel_debug_enabled": { + "type": "boolean" + }, + "sentinel_token": { + "type": "string" + }, + "sentinel_metrics_refresh_rate_seconds": { + "type": "integer", + "minimum": 1 + }, + "sentinel_metrics_history_days": { + "type": "integer", + "minimum": 1 + }, + "sentinel_push_interval_seconds": { + "type": "integer", + "minimum": 10 + }, + "sentinel_custom_url": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Updated Sentinel settings." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/servers": { "get": { "tags": [ @@ -15298,6 +18875,790 @@ ] } }, + "\/services\/{uuid}\/clone": { + "post": { + "tags": [ + "Services" + ], + "summary": "Clone", + "description": "Clone a service to a destination owned by the authenticated team.", + "operationId": "clone-service-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "destination_uuid" + ], + "properties": { + "destination_uuid": { + "type": "string" + }, + "name": { + "type": [ + "string", + "null" + ] + }, + "clone_volumes": { + "type": "boolean", + "default": false + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Service cloned." + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/team\/envs": { + "get": { + "tags": [ + "Shared Environment Variables" + ], + "summary": "List Team Shared Envs", + "description": "List shared environment variables for the current team (type=team).", + "operationId": "list-team-shared-envs", + "responses": { + "200": { + "description": "Team shared environment variables." + }, + "401": { + "$ref": "#\/components\/responses\/401" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Shared Environment Variables" + ], + "summary": "Create Team Shared Env", + "description": "Create a shared environment variable for the current team (type=team).", + "operationId": "create-team-shared-env", + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "key" + ], + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": [ + "string", + "null" + ] + }, + "is_literal": { + "type": "boolean" + }, + "is_multiline": { + "type": "boolean" + }, + "is_shown_once": { + "type": "boolean" + }, + "comment": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Environment variable created." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "409": { + "description": "Environment variable already exists." + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/team\/envs\/{env_id}": { + "delete": { + "tags": [ + "Shared Environment Variables" + ], + "summary": "Delete Team Shared Env", + "description": "Delete a team shared environment variable by id.", + "operationId": "delete-team-shared-env", + "parameters": [ + { + "name": "env_id", + "in": "path", + "description": "Shared env id (integer).", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Environment variable deleted." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Shared Environment Variables" + ], + "summary": "Update Team Shared Env", + "description": "Update a team shared environment variable by id.", + "operationId": "update-team-shared-env", + "parameters": [ + { + "name": "env_id", + "in": "path", + "description": "Shared env id (integer).", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Environment variable updated." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/projects\/{uuid}\/envs": { + "get": { + "tags": [ + "Shared Environment Variables" + ], + "summary": "List Project Shared Envs", + "description": "List shared environment variables for a project (type=project).", + "operationId": "list-project-shared-envs", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Project UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Project shared environment variables." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Shared Environment Variables" + ], + "summary": "Create Project Shared Env", + "description": "Create a shared environment variable for a project (type=project).", + "operationId": "create-project-shared-env", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Project UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "201": { + "description": "Environment variable created." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "409": { + "description": "Environment variable already exists." + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/projects\/{uuid}\/envs\/{env_id}": { + "delete": { + "tags": [ + "Shared Environment Variables" + ], + "summary": "Delete Project Shared Env", + "description": "Delete a project shared environment variable by id.", + "operationId": "delete-project-shared-env", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Project UUID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "env_id", + "in": "path", + "description": "Shared env id (integer).", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Environment variable deleted." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Shared Environment Variables" + ], + "summary": "Update Project Shared Env", + "description": "Update a project shared environment variable by id.", + "operationId": "update-project-shared-env", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Project UUID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "env_id", + "in": "path", + "description": "Shared env id (integer).", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Environment variable updated." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/projects\/{uuid}\/environments\/{environment_name_or_uuid}\/envs": { + "get": { + "tags": [ + "Shared Environment Variables" + ], + "summary": "List Environment Shared Envs", + "description": "List shared environment variables for a project environment (type=environment).", + "operationId": "list-environment-shared-envs", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Project UUID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "environment_name_or_uuid", + "in": "path", + "description": "Environment name or UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Environment shared environment variables." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Shared Environment Variables" + ], + "summary": "Create Environment Shared Env", + "description": "Create a shared environment variable for a project environment (type=environment).", + "operationId": "create-environment-shared-env", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Project UUID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "environment_name_or_uuid", + "in": "path", + "description": "Environment name or UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "201": { + "description": "Environment variable created." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "409": { + "description": "Environment variable already exists." + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/projects\/{uuid}\/environments\/{environment_name_or_uuid}\/envs\/{env_id}": { + "delete": { + "tags": [ + "Shared Environment Variables" + ], + "summary": "Delete Environment Shared Env", + "description": "Delete an environment shared environment variable by id.", + "operationId": "delete-environment-shared-env", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Project UUID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "environment_name_or_uuid", + "in": "path", + "description": "Environment name or UUID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "env_id", + "in": "path", + "description": "Shared env id (integer).", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Environment variable deleted." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Shared Environment Variables" + ], + "summary": "Update Environment Shared Env", + "description": "Update an environment shared environment variable by id.", + "operationId": "update-environment-shared-env", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Project UUID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "environment_name_or_uuid", + "in": "path", + "description": "Environment name or UUID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "env_id", + "in": "path", + "description": "Shared env id (integer).", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Environment variable updated." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/servers\/{uuid}\/envs": { + "get": { + "tags": [ + "Shared Environment Variables" + ], + "summary": "List Server Shared Envs", + "description": "List shared environment variables for a server (type=server).", + "operationId": "list-server-shared-envs", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Server UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Server shared environment variables." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Shared Environment Variables" + ], + "summary": "Create Server Shared Env", + "description": "Create a shared environment variable for a server (type=server).", + "operationId": "create-server-shared-env", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Server UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "201": { + "description": "Environment variable created." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "409": { + "description": "Environment variable already exists." + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/servers\/{uuid}\/envs\/{env_id}": { + "delete": { + "tags": [ + "Shared Environment Variables" + ], + "summary": "Delete Server Shared Env", + "description": "Delete a server shared environment variable by id.", + "operationId": "delete-server-shared-env", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Server UUID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "env_id", + "in": "path", + "description": "Shared env id (integer).", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Environment variable deleted." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Shared Environment Variables" + ], + "summary": "Update Server Shared Env", + "description": "Update a server shared environment variable by id.", + "operationId": "update-server-shared-env", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Server UUID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "env_id", + "in": "path", + "description": "Shared env id (integer).", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Environment variable updated." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/tags": { "get": { "tags": [ @@ -15332,6 +19693,184 @@ "bearerAuth": [] } ] + }, + "post": { + "tags": [ + "Tags" + ], + "summary": "Create", + "description": "Create a tag for the current team.", + "operationId": "create-tag", + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "maxLength": 255, + "minLength": 2 + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Tag created.", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/Tag" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "409": { + "description": "Tag with this name already exists." + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/tags\/{uuid}": { + "delete": { + "tags": [ + "Tags" + ], + "summary": "Delete", + "description": "Delete a tag for the current team. Detaches the tag from all resources via cascade.", + "operationId": "delete-tag-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Tag UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Tag deleted.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Tag deleted." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Tags" + ], + "summary": "Update", + "description": "Update a tag name for the current team.", + "operationId": "update-tag-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Tag UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "maxLength": 255, + "minLength": 2 + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Tag updated.", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/Tag" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "409": { + "description": "Tag with this name already exists." + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] } }, "\/teams": { @@ -15467,17 +20006,17 @@ ] } }, - "\/teams\/current": { + "\/team": { "get": { "tags": [ "Teams" ], "summary": "Authenticated Team", - "description": "Get currently authenticated team.", - "operationId": "get-current-team", + "description": "Get the team bound to the API token.", + "operationId": "get-token-team", "responses": { "200": { - "description": "Current Team.", + "description": "Team bound to the API token.", "content": { "application\/json": { "schema": { @@ -15500,17 +20039,17 @@ ] } }, - "\/teams\/current\/members": { + "\/team\/members": { "get": { "tags": [ "Teams" ], "summary": "Authenticated Team Members", - "description": "Get currently authenticated team members.", - "operationId": "get-current-team-members", + "description": "Get members of the team bound to the API token.", + "operationId": "get-token-team-members", "responses": { "200": { - "description": "Currently authenticated team members.", + "description": "Members of the team bound to the API token.", "content": { "application\/json": { "schema": { @@ -15926,6 +20465,138 @@ ] } }, + "\/applications\/{uuid}\/storages\/{storage_uuid}\/backups\/run": { + "post": { + "tags": [ + "Applications" + ], + "summary": "Run application storage backup", + "description": "Queue an immediate volume backup for an application storage that has a schedule.", + "operationId": "run-application-storage-backup", + "parameters": [ + { + "name": "uuid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "storage_uuid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Storage backup queued." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/databases\/{uuid}\/storages\/{storage_uuid}\/backups\/run": { + "post": { + "tags": [ + "Databases" + ], + "summary": "Run database storage backup", + "description": "Queue an immediate volume backup for a database storage that has a schedule.", + "operationId": "run-database-storage-backup", + "parameters": [ + { + "name": "uuid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "storage_uuid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Storage backup queued." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/services\/{uuid}\/storages\/{storage_uuid}\/backups\/run": { + "post": { + "tags": [ + "Services" + ], + "summary": "Run service storage backup", + "description": "Queue an immediate volume backup for a service storage that has a schedule.", + "operationId": "run-service-storage-backup", + "parameters": [ + { + "name": "uuid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "storage_uuid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Storage backup queued." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/vultr\/regions": { "get": { "tags": [ @@ -16436,6 +21107,10 @@ "type": "string", "description": "Preview URL template." }, + "max_restart_count": { + "type": "integer", + "description": "Maximum container restart count before stopping." + }, "destination_type": { "type": "string", "description": "Destination type." @@ -17621,6 +22296,10 @@ "name": "Applications", "description": "Applications" }, + { + "name": "Cloud-init Scripts", + "description": "Cloud-init Scripts" + }, { "name": "Cloud Tokens", "description": "Cloud Tokens" @@ -17645,10 +22324,18 @@ "name": "GitHub Apps", "description": "GitHub Apps" }, + { + "name": "GitLab Apps", + "description": "GitLab Apps" + }, { "name": "Hetzner", "description": "Hetzner" }, + { + "name": "Notifications", + "description": "Notifications" + }, { "name": "Projects", "description": "Projects" @@ -17657,6 +22344,10 @@ "name": "Resources", "description": "Resources" }, + { + "name": "S3 Storages", + "description": "S3 Storages" + }, { "name": "Scheduled Tasks", "description": "Scheduled Tasks" @@ -17681,6 +22372,10 @@ "name": "Services", "description": "Services" }, + { + "name": "Shared Environment Variables", + "description": "Shared Environment Variables" + }, { "name": "Tags", "description": "Tags" diff --git a/openapi.yaml b/openapi.yaml index fe3b85ad4..dd98f718d 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -316,6 +316,42 @@ paths: is_raw_compose_deployment_enabled: type: boolean description: 'Deploy the raw Docker Compose definition.' + is_log_drain_enabled: + type: boolean + description: 'Enable log drain for the application.' + is_gpu_enabled: + type: boolean + description: 'Enable GPU support.' + gpu_driver: + type: string + nullable: true + description: 'GPU driver name.' + gpu_count: + type: string + nullable: true + description: 'Number of GPUs to allocate.' + gpu_device_ids: + type: string + nullable: true + description: 'Comma-separated GPU device IDs.' + gpu_options: + type: string + nullable: true + description: 'Additional GPU options.' + is_consistent_container_name_enabled: + type: boolean + description: 'Use a consistent container name across deployments.' + custom_internal_name: + type: string + nullable: true + description: 'Custom internal container name.' + preview_url_template: + type: string + description: 'Preview URL template.' + max_restart_count: + type: integer + minimum: 0 + description: 'Maximum container restart count before stopping.' is_http_basic_auth_enabled: type: boolean description: 'HTTP Basic Authentication enabled.' @@ -658,6 +694,42 @@ paths: is_raw_compose_deployment_enabled: type: boolean description: 'Deploy the raw Docker Compose definition.' + is_log_drain_enabled: + type: boolean + description: 'Enable log drain for the application.' + is_gpu_enabled: + type: boolean + description: 'Enable GPU support.' + gpu_driver: + type: string + nullable: true + description: 'GPU driver name.' + gpu_count: + type: string + nullable: true + description: 'Number of GPUs to allocate.' + gpu_device_ids: + type: string + nullable: true + description: 'Comma-separated GPU device IDs.' + gpu_options: + type: string + nullable: true + description: 'Additional GPU options.' + is_consistent_container_name_enabled: + type: boolean + description: 'Use a consistent container name across deployments.' + custom_internal_name: + type: string + nullable: true + description: 'Custom internal container name.' + preview_url_template: + type: string + description: 'Preview URL template.' + max_restart_count: + type: integer + minimum: 0 + description: 'Maximum container restart count before stopping.' is_http_basic_auth_enabled: type: boolean description: 'HTTP Basic Authentication enabled.' @@ -1000,6 +1072,42 @@ paths: is_raw_compose_deployment_enabled: type: boolean description: 'Deploy the raw Docker Compose definition.' + is_log_drain_enabled: + type: boolean + description: 'Enable log drain for the application.' + is_gpu_enabled: + type: boolean + description: 'Enable GPU support.' + gpu_driver: + type: string + nullable: true + description: 'GPU driver name.' + gpu_count: + type: string + nullable: true + description: 'Number of GPUs to allocate.' + gpu_device_ids: + type: string + nullable: true + description: 'Comma-separated GPU device IDs.' + gpu_options: + type: string + nullable: true + description: 'Additional GPU options.' + is_consistent_container_name_enabled: + type: boolean + description: 'Use a consistent container name across deployments.' + custom_internal_name: + type: string + nullable: true + description: 'Custom internal container name.' + preview_url_template: + type: string + description: 'Preview URL template.' + max_restart_count: + type: integer + minimum: 0 + description: 'Maximum container restart count before stopping.' is_http_basic_auth_enabled: type: boolean description: 'HTTP Basic Authentication enabled.' @@ -1283,6 +1391,42 @@ paths: is_raw_compose_deployment_enabled: type: boolean description: 'Deploy the raw Docker Compose definition.' + is_log_drain_enabled: + type: boolean + description: 'Enable log drain for the application.' + is_gpu_enabled: + type: boolean + description: 'Enable GPU support.' + gpu_driver: + type: string + nullable: true + description: 'GPU driver name.' + gpu_count: + type: string + nullable: true + description: 'Number of GPUs to allocate.' + gpu_device_ids: + type: string + nullable: true + description: 'Comma-separated GPU device IDs.' + gpu_options: + type: string + nullable: true + description: 'Additional GPU options.' + is_consistent_container_name_enabled: + type: boolean + description: 'Use a consistent container name across deployments.' + custom_internal_name: + type: string + nullable: true + description: 'Custom internal container name.' + preview_url_template: + type: string + description: 'Preview URL template.' + max_restart_count: + type: integer + minimum: 0 + description: 'Maximum container restart count before stopping.' is_http_basic_auth_enabled: type: boolean description: 'HTTP Basic Authentication enabled.' @@ -1552,6 +1696,42 @@ paths: is_raw_compose_deployment_enabled: type: boolean description: 'Deploy the raw Docker Compose definition.' + is_log_drain_enabled: + type: boolean + description: 'Enable log drain for the application.' + is_gpu_enabled: + type: boolean + description: 'Enable GPU support.' + gpu_driver: + type: string + nullable: true + description: 'GPU driver name.' + gpu_count: + type: string + nullable: true + description: 'Number of GPUs to allocate.' + gpu_device_ids: + type: string + nullable: true + description: 'Comma-separated GPU device IDs.' + gpu_options: + type: string + nullable: true + description: 'Additional GPU options.' + is_consistent_container_name_enabled: + type: boolean + description: 'Use a consistent container name across deployments.' + custom_internal_name: + type: string + nullable: true + description: 'Custom internal container name.' + preview_url_template: + type: string + description: 'Preview URL template.' + max_restart_count: + type: integer + minimum: 0 + description: 'Maximum container restart count before stopping.' is_http_basic_auth_enabled: type: boolean description: 'HTTP Basic Authentication enabled.' @@ -1975,6 +2155,42 @@ paths: is_raw_compose_deployment_enabled: type: boolean description: 'Deploy the raw Docker Compose definition.' + is_log_drain_enabled: + type: boolean + description: 'Enable log drain for the application.' + is_gpu_enabled: + type: boolean + description: 'Enable GPU support.' + gpu_driver: + type: string + nullable: true + description: 'GPU driver name.' + gpu_count: + type: string + nullable: true + description: 'Number of GPUs to allocate.' + gpu_device_ids: + type: string + nullable: true + description: 'Comma-separated GPU device IDs.' + gpu_options: + type: string + nullable: true + description: 'Additional GPU options.' + is_consistent_container_name_enabled: + type: boolean + description: 'Use a consistent container name across deployments.' + custom_internal_name: + type: string + nullable: true + description: 'Custom internal container name.' + preview_url_template: + type: string + description: 'Preview URL template.' + max_restart_count: + type: integer + minimum: 0 + description: 'Maximum container restart count before stopping.' connect_to_docker_network: type: boolean description: 'The flag to connect the service to the predefined Docker network.' @@ -2846,6 +3062,378 @@ paths: security: - bearerAuth: [] + '/applications/{uuid}/clone': + post: + tags: + - Applications + summary: Clone + description: 'Clone an application to a destination owned by the authenticated team.' + operationId: clone-application-by-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + required: + - destination_uuid + properties: + destination_uuid: + description: 'UUID of the destination to clone into.' + type: string + name: + description: 'Optional name for the cloned application.' + type: [string, 'null'] + clone_volumes: + description: 'Whether to clone volume data.' + type: boolean + default: false + type: object + responses: + '201': + description: 'Application cloned.' + content: + application/json: + schema: + properties: + uuid: { type: string } + message: { type: string, example: 'Application cloned.' } + type: object + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/applications/{uuid}/rollback-images': + get: + tags: + - Applications + summary: 'List Rollback Images' + description: 'List available Docker images for rolling back an application. Returns an empty list when the server is unavailable or remote inspection is not possible.' + operationId: list-application-rollback-images + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + responses: + '200': + description: 'Rollback images.' + content: + application/json: + schema: + properties: + current: { type: [string, 'null'] } + images: { type: array, items: { properties: { tag: { type: string }, created_at: { type: string }, is_current: { type: boolean } }, type: object } } + type: object + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + '/applications/{uuid}/rollback': + post: + tags: + - Applications + summary: Rollback + description: 'Queue a rollback deployment for an application to a previous image commit/tag.' + operationId: rollback-application-by-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + required: + - commit + properties: + commit: + description: 'Image tag / commit to roll back to.' + type: string + type: object + responses: + '200': + description: 'Rollback deployment queued.' + content: + application/json: + schema: + properties: + message: { type: string } + deployment_uuid: { type: string } + type: object + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/applications/{uuid}/destinations': + get: + tags: + - Applications + summary: 'List Destinations' + description: 'List primary and additional destinations for a standalone application.' + operationId: list-application-destinations + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + responses: + '200': + description: 'Application destinations.' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + post: + tags: + - Applications + summary: 'Add Destination' + description: 'Attach an additional standalone Docker destination to an application.' + operationId: add-application-destination + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + required: + - destination_uuid + properties: + destination_uuid: + type: string + type: object + responses: + '201': + description: 'Destination attached.' + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/applications/{uuid}/destinations/{destination_uuid}': + delete: + tags: + - Applications + summary: 'Remove Destination' + description: 'Detach an additional destination from an application.' + operationId: remove-application-destination + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + - + name: destination_uuid + in: path + description: 'UUID of the destination.' + required: true + schema: + type: string + responses: + '200': + description: 'Destination detached.' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + /cloud-init-scripts: + get: + tags: + - 'Cloud-init Scripts' + summary: 'List Cloud-init Scripts' + description: 'List all cloud-init scripts for the authenticated team.' + operationId: list-cloud-init-scripts + responses: + '200': + description: 'Cloud-init scripts for the team.' + '401': + $ref: '#/components/responses/401' + '403': + description: Forbidden. + security: + - + bearerAuth: [] + post: + tags: + - 'Cloud-init Scripts' + summary: 'Create Cloud-init Script' + description: 'Create a new cloud-init script for the authenticated team.' + operationId: create-cloud-init-script + requestBody: + required: true + content: + application/json: + schema: + required: + - name + - script + properties: + name: + type: string + script: + description: 'Bash script (#!) or cloud-config YAML.' + type: string + type: object + responses: + '201': + description: 'Cloud-init script created.' + '401': + $ref: '#/components/responses/401' + '403': + description: Forbidden. + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/cloud-init-scripts/{uuid}': + get: + tags: + - 'Cloud-init Scripts' + summary: 'Get Cloud-init Script' + description: 'Get a cloud-init script by UUID.' + operationId: get-cloud-init-script-by-uuid + parameters: + - + name: uuid + in: path + required: true + schema: + type: string + responses: + '200': + description: 'Cloud-init script.' + '401': + $ref: '#/components/responses/401' + '403': + description: Forbidden. + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + delete: + tags: + - 'Cloud-init Scripts' + summary: 'Delete Cloud-init Script' + description: 'Delete a cloud-init script by UUID.' + operationId: delete-cloud-init-script-by-uuid + parameters: + - + name: uuid + in: path + required: true + schema: + type: string + responses: + '200': + description: 'Cloud-init script deleted.' + '401': + $ref: '#/components/responses/401' + '403': + description: Forbidden. + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + patch: + tags: + - 'Cloud-init Scripts' + summary: 'Update Cloud-init Script' + description: 'Update a cloud-init script by UUID.' + operationId: update-cloud-init-script-by-uuid + parameters: + - + name: uuid + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + properties: + name: + type: string + script: + type: string + type: object + responses: + '200': + description: 'Cloud-init script updated.' + '401': + $ref: '#/components/responses/401' + '403': + description: Forbidden. + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] /cloud-tokens: get: tags: @@ -5304,6 +5892,51 @@ paths: security: - bearerAuth: [] + '/databases/{uuid}/clone': + post: + tags: + - Databases + summary: Clone + description: 'Clone a database to a destination owned by the authenticated team.' + operationId: clone-database-by-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the database.' + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + required: + - destination_uuid + properties: + destination_uuid: + type: string + name: + type: [string, 'null'] + clone_volumes: + type: boolean + default: false + type: object + responses: + '201': + description: 'Database cloned.' + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] /deployments: get: tags: @@ -5680,6 +6313,46 @@ paths: security: - bearerAuth: [] + patch: + tags: + - Destinations + summary: 'Update destination' + description: 'Update a Docker network destination name. Network cannot be changed via the API.' + operationId: update-destination-by-uuid + parameters: + - + name: uuid + in: path + description: 'Destination UUID' + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + properties: + name: + type: string + maxLength: 255 + type: object + responses: + '200': + description: 'Destination updated.' + content: + application/json: + schema: + $ref: '#/components/schemas/Destination' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] /digitalocean/regions: get: tags: @@ -6134,6 +6807,231 @@ paths: security: - bearerAuth: [] + /gitlab-apps: + get: + tags: + - 'GitLab Apps' + summary: List + description: 'List all GitLab apps for the current team (and system-wide sources).' + operationId: list-gitlab-apps + responses: + '200': + description: 'List of GitLab apps.' + content: + application/json: + schema: + type: array + items: + properties: { id: { type: integer }, uuid: { type: string }, name: { type: string }, api_url: { type: string }, html_url: { type: string }, custom_user: { type: string }, custom_port: { type: integer }, client_id: { type: string, nullable: true }, group_name: { type: string, nullable: true }, redirect_uri: { type: string, nullable: true }, is_system_wide: { type: boolean }, is_public: { type: boolean }, team_id: { type: integer } } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + security: + - + bearerAuth: [] + post: + tags: + - 'GitLab Apps' + summary: 'Create GitLab App' + description: 'Create a new GitLab app (OAuth source). Credentials may be supplied later via the UI or update endpoint.' + operationId: create-gitlab-app + requestBody: + description: 'GitLab app creation payload.' + required: true + content: + application/json: + schema: + required: + - name + - html_url + properties: + name: + type: string + description: 'Name of the GitLab app.' + html_url: + type: string + description: 'GitLab instance URL (e.g., https://gitlab.com).' + api_url: + type: string + description: 'GitLab API URL (defaults to {html_url}/api/v4).' + custom_user: + type: string + description: 'Custom user for SSH access (default: git).' + custom_port: + type: integer + description: 'Custom port for SSH access (default: 22).' + group_name: + type: string + nullable: true + description: 'Optional comma-separated group names to filter repositories.' + client_id: + type: string + nullable: true + description: 'GitLab OAuth Application ID.' + client_secret: + type: string + nullable: true + description: 'GitLab OAuth Application Secret.' + webhook_token: + type: string + nullable: true + description: 'Webhook secret token (auto-generated when omitted).' + redirect_uri: + type: string + nullable: true + description: 'OAuth redirect URI registered in GitLab.' + is_system_wide: + type: boolean + description: 'Is this app system-wide (non-cloud instances only).' + type: object + responses: + '201': + description: 'GitLab app created successfully.' + content: + application/json: + schema: + properties: + id: { type: integer } + uuid: { type: string } + name: { type: string } + api_url: { type: string } + html_url: { type: string } + custom_user: { type: string } + custom_port: { type: integer } + client_id: { type: string, nullable: true } + group_name: { type: string, nullable: true } + redirect_uri: { type: string, nullable: true } + is_system_wide: { type: boolean } + team_id: { type: integer } + type: object + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/gitlab-apps/{gitlab_app_id}': + delete: + tags: + - 'GitLab Apps' + summary: 'Delete GitLab App' + description: 'Delete a GitLab app if it is not being used by any applications.' + operationId: deleteGitlabApp + parameters: + - + name: gitlab_app_id + in: path + description: 'GitLab App ID' + required: true + schema: + type: integer + responses: + '200': + description: 'GitLab app deleted successfully' + content: + application/json: + schema: + properties: + message: { type: string, example: 'GitLab app deleted successfully' } + type: object + '401': + description: Unauthorized + '404': + description: 'GitLab app not found' + '409': + description: 'Conflict - GitLab app is in use' + content: + application/json: + schema: + properties: + message: { type: string, example: 'This GitLab app is being used by 5 application(s). Please delete all applications first.' } + type: object + security: + - + bearerAuth: [] + patch: + tags: + - 'GitLab Apps' + summary: 'Update GitLab App' + description: 'Update an existing GitLab app.' + operationId: updateGitlabApp + parameters: + - + name: gitlab_app_id + in: path + description: 'GitLab App ID' + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + properties: + name: + type: string + description: 'GitLab App name' + html_url: + type: string + description: 'GitLab HTML URL' + api_url: + type: string + description: 'GitLab API URL' + custom_user: + type: string + description: 'Custom user for SSH' + custom_port: + type: integer + description: 'Custom port for SSH' + group_name: + type: string + nullable: true + description: 'Optional group filter' + client_id: + type: string + nullable: true + description: 'OAuth Application ID' + client_secret: + type: string + nullable: true + description: 'OAuth Application Secret' + webhook_token: + type: string + nullable: true + description: 'Webhook secret token' + redirect_uri: + type: string + nullable: true + description: 'OAuth redirect URI' + is_system_wide: + type: boolean + description: 'Is system wide (non-cloud instances only)' + type: object + responses: + '200': + description: 'GitLab app updated successfully' + content: + application/json: + schema: + properties: + message: { type: string, example: 'GitLab app updated successfully' } + data: { type: object, description: 'Updated GitLab app data' } + type: object + '401': + description: Unauthorized + '404': + description: 'GitLab app not found' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] /hetzner/locations: get: tags: @@ -6478,6 +7376,228 @@ paths: security: - bearerAuth: [] + /notifications/email: + get: + tags: + - Notifications + summary: 'Get email notification settings' + description: 'Get the current team email notification settings. Encrypted secrets are only returned when the token has `read:sensitive` (or `root`) and the user is a team admin/owner.' + operationId: get-current-team-email-notifications + responses: + '200': + description: 'Email notification settings.' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + security: + - + bearerAuth: [] + patch: + tags: + - Notifications + summary: 'Update email notification settings' + description: 'Update the current team email notification settings.' + operationId: update-current-team-email-notifications + responses: + '200': + description: 'Updated email notification settings.' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '403': + description: Forbidden. + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + /notifications/discord: + get: + tags: + - Notifications + summary: 'Get Discord notification settings' + description: 'Get the current team Discord notification settings. Encrypted secrets are only returned when the token has `read:sensitive` (or `root`) and the user is a team admin/owner.' + operationId: get-current-team-discord-notifications + responses: + '200': + description: 'Discord notification settings.' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + security: + - + bearerAuth: [] + patch: + tags: + - Notifications + summary: 'Update Discord notification settings' + description: 'Update the current team Discord notification settings.' + operationId: update-current-team-discord-notifications + responses: + '200': + description: 'Updated Discord notification settings.' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '403': + description: Forbidden. + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + /notifications/slack: + get: + tags: + - Notifications + summary: 'Get Slack notification settings' + description: 'Get the current team Slack notification settings. Encrypted secrets are only returned when the token has `read:sensitive` (or `root`) and the user is a team admin/owner.' + operationId: get-current-team-slack-notifications + responses: + '200': + description: 'Slack notification settings.' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + security: + - + bearerAuth: [] + patch: + tags: + - Notifications + summary: 'Update Slack notification settings' + description: 'Update the current team Slack notification settings.' + operationId: update-current-team-slack-notifications + responses: + '200': + description: 'Updated Slack notification settings.' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '403': + description: Forbidden. + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + /notifications/telegram: + get: + tags: + - Notifications + summary: 'Get Telegram notification settings' + description: 'Get the current team Telegram notification settings. Encrypted secrets are only returned when the token has `read:sensitive` (or `root`) and the user is a team admin/owner.' + operationId: get-current-team-telegram-notifications + responses: + '200': + description: 'Telegram notification settings.' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + security: + - + bearerAuth: [] + patch: + tags: + - Notifications + summary: 'Update Telegram notification settings' + description: 'Update the current team Telegram notification settings.' + operationId: update-current-team-telegram-notifications + responses: + '200': + description: 'Updated Telegram notification settings.' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '403': + description: Forbidden. + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + /notifications/pushover: + get: + tags: + - Notifications + summary: 'Get Pushover notification settings' + description: 'Get the current team Pushover notification settings. Encrypted secrets are only returned when the token has `read:sensitive` (or `root`) and the user is a team admin/owner.' + operationId: get-current-team-pushover-notifications + responses: + '200': + description: 'Pushover notification settings.' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + security: + - + bearerAuth: [] + patch: + tags: + - Notifications + summary: 'Update Pushover notification settings' + description: 'Update the current team Pushover notification settings.' + operationId: update-current-team-pushover-notifications + responses: + '200': + description: 'Updated Pushover notification settings.' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '403': + description: Forbidden. + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + /notifications/webhook: + get: + tags: + - Notifications + summary: 'Get webhook notification settings' + description: 'Get the current team webhook notification settings. Encrypted secrets are only returned when the token has `read:sensitive` (or `root`) and the user is a team admin/owner.' + operationId: get-current-team-webhook-notifications + responses: + '200': + description: 'Webhook notification settings.' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + security: + - + bearerAuth: [] + patch: + tags: + - Notifications + summary: 'Update webhook notification settings' + description: 'Update the current team webhook notification settings.' + operationId: update-current-team-webhook-notifications + responses: + '200': + description: 'Updated webhook notification settings.' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '403': + description: Forbidden. + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] /version: get: summary: Version @@ -6973,6 +8093,65 @@ paths: security: - bearerAuth: [] + patch: + tags: + - Projects + summary: 'Update Environment' + description: 'Update environment by name or UUID within a project.' + operationId: update-environment + parameters: + - + name: uuid + in: path + description: 'Project UUID' + required: true + schema: + type: string + - + name: environment_name_or_uuid + in: path + description: 'Environment name or UUID' + required: true + schema: + type: string + requestBody: + description: 'Environment fields to update.' + required: true + content: + application/json: + schema: + properties: + name: + type: string + description: 'The name of the environment.' + description: + type: string + description: 'The description of the environment.' + type: object + responses: + '200': + description: 'Environment updated.' + content: + application/json: + schema: + properties: + uuid: { type: string, example: env123 } + name: { type: string, example: staging } + description: { type: string, example: 'Staging environment' } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + description: 'Project or environment not found.' + '409': + description: 'Environment with this name already exists.' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] /resources: get: tags: @@ -6995,6 +8174,265 @@ paths: security: - bearerAuth: [] + /s3-storages: + get: + tags: + - 'S3 Storages' + summary: 'List S3 Storages' + description: 'List all S3 storages for the authenticated team.' + operationId: list-s3-storages + responses: + '200': + description: 'Get all S3 storages.' + content: + application/json: + schema: + type: array + items: + properties: { uuid: { type: string }, name: { type: string }, description: { type: string, nullable: true }, endpoint: { type: string }, bucket: { type: string }, region: { type: string }, is_usable: { type: boolean }, team_id: { type: integer }, created_at: { type: string }, updated_at: { type: string } } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + security: + - + bearerAuth: [] + post: + tags: + - 'S3 Storages' + summary: 'Create S3 Storage' + description: 'Create a new S3 storage configuration for the authenticated team.' + operationId: create-s3-storage + requestBody: + description: 'S3 storage details' + required: true + content: + application/json: + schema: + required: + - name + - endpoint + - bucket + - region + - key + - secret + properties: + name: + type: string + example: 'My S3 Storage' + description: 'A friendly name for the storage.' + description: + type: string + nullable: true + description: 'Optional description.' + endpoint: + type: string + example: 'https://s3.us-east-1.amazonaws.com' + description: 'S3-compatible endpoint URL.' + bucket: + type: string + example: my-bucket + description: 'S3 bucket name.' + region: + type: string + example: us-east-1 + description: 'S3 region.' + key: + type: string + description: 'Access key.' + secret: + type: string + description: 'Secret key.' + is_usable: + type: boolean + description: 'Whether the storage is marked usable.' + type: object + responses: + '201': + description: 'S3 storage created.' + content: + application/json: + schema: + properties: + uuid: { type: string, example: og888os, description: 'The UUID of the S3 storage.' } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/s3-storages/{uuid}': + get: + tags: + - 'S3 Storages' + summary: 'Get S3 Storage' + description: 'Get S3 storage by UUID.' + operationId: get-s3-storage-by-uuid + parameters: + - + name: uuid + in: path + description: 'S3 Storage UUID' + required: true + schema: + type: string + responses: + '200': + description: 'Get S3 storage by UUID' + content: + application/json: + schema: + properties: + uuid: { type: string } + name: { type: string } + description: { type: string, nullable: true } + endpoint: { type: string } + bucket: { type: string } + region: { type: string } + is_usable: { type: boolean } + team_id: { type: integer } + created_at: { type: string } + updated_at: { type: string } + type: object + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + delete: + tags: + - 'S3 Storages' + summary: 'Delete S3 Storage' + description: 'Delete S3 storage by UUID.' + operationId: delete-s3-storage-by-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the S3 storage.' + required: true + schema: + type: string + responses: + '200': + description: 'S3 storage deleted.' + content: + application/json: + schema: + properties: + message: { type: string, example: 'S3 storage deleted.' } + type: object + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + patch: + tags: + - 'S3 Storages' + summary: 'Update S3 Storage' + description: 'Update S3 storage by UUID.' + operationId: update-s3-storage-by-uuid + parameters: + - + name: uuid + in: path + description: 'S3 Storage UUID' + required: true + schema: + type: string + requestBody: + description: 'S3 storage fields to update.' + required: true + content: + application/json: + schema: + properties: + name: + type: string + description: 'A friendly name for the storage.' + description: + type: string + nullable: true + description: 'Optional description.' + endpoint: + type: string + description: 'S3-compatible endpoint URL.' + bucket: + type: string + description: 'S3 bucket name.' + region: + type: string + description: 'S3 region.' + key: + type: string + description: 'Access key.' + secret: + type: string + description: 'Secret key.' + is_usable: + type: boolean + description: 'Whether the storage is marked usable.' + type: object + responses: + '200': + description: 'S3 storage updated.' + content: + application/json: + schema: + properties: + uuid: { type: string } + type: object + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/s3-storages/{uuid}/validate': + post: + tags: + - 'S3 Storages' + summary: 'Validate S3 Storage' + description: 'Validate an S3 storage connection using ListObjectsV2.' + operationId: validate-s3-storage-by-uuid + parameters: + - + name: uuid + in: path + description: 'S3 Storage UUID' + required: true + schema: + type: string + responses: + '200': + description: 'S3 storage validation result.' + content: + application/json: + schema: + properties: + valid: { type: boolean, example: true } + message: { type: string, example: 'S3 storage connection is valid.' } + type: object + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] '/applications/{uuid}/scheduled-tasks': get: tags: @@ -7467,6 +8905,70 @@ paths: security: - bearerAuth: [] + '/applications/{uuid}/scheduled-tasks/{task_uuid}/execute': + post: + tags: + - 'Scheduled Tasks' + summary: 'Execute Task' + description: 'Queue immediate execution of a scheduled task for an application.' + operationId: execute-scheduled-task-by-application-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + - + name: task_uuid + in: path + description: 'UUID of the scheduled task.' + required: true + schema: + type: string + responses: + '200': + description: 'Scheduled task execution queued.' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + '/services/{uuid}/scheduled-tasks/{task_uuid}/execute': + post: + tags: + - 'Scheduled Tasks' + summary: 'Execute Task' + description: 'Queue immediate execution of a scheduled task for a service.' + operationId: execute-scheduled-task-by-service-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the service.' + required: true + schema: + type: string + - + name: task_uuid + in: path + description: 'UUID of the scheduled task.' + required: true + schema: + type: string + responses: + '200': + description: 'Scheduled task execution queued.' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] /security/keys: get: tags: @@ -7641,6 +9143,685 @@ paths: security: - bearerAuth: [] + '/servers/{uuid}/cloudflare-tunnel': + get: + tags: + - Servers + summary: 'Get Cloudflare Tunnel settings' + description: 'Get Cloudflare Tunnel settings for a server owned by the authenticated team.' + operationId: get-server-cloudflare-tunnel + parameters: + - + name: uuid + in: path + description: 'Server UUID' + required: true + schema: + type: string + responses: + '200': + description: 'Cloudflare Tunnel settings.' + content: + application/json: + schema: + properties: + is_cloudflare_tunnel: { type: boolean } + ip: { type: string } + ip_previous: { type: [string, 'null'] } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + patch: + tags: + - Servers + summary: 'Update Cloudflare Tunnel settings' + description: 'Update stored Cloudflare Tunnel settings for a server. Does not run remote cloudflared configuration; use enable/disable for the manual UI actions.' + operationId: update-server-cloudflare-tunnel + parameters: + - + name: uuid + in: path + description: 'Server UUID' + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + properties: + is_cloudflare_tunnel: + type: boolean + type: object + responses: + '200': + description: 'Updated Cloudflare Tunnel settings.' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/servers/{uuid}/cloudflare-tunnel/enable': + post: + tags: + - Servers + summary: 'Enable Cloudflare Tunnel (manual)' + description: 'Manually mark Cloudflare Tunnel as enabled for a server (matches UI manual enable). Does not deploy cloudflared remotely.' + operationId: enable-server-cloudflare-tunnel + parameters: + - + name: uuid + in: path + description: 'Server UUID' + required: true + schema: + type: string + responses: + '200': + description: 'Cloudflare Tunnel enabled.' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/servers/{uuid}/cloudflare-tunnel/disable': + post: + tags: + - Servers + summary: 'Disable Cloudflare Tunnel' + description: 'Mark Cloudflare Tunnel as disabled and restore ip_previous when available. Does not remove the remote cloudflared container.' + operationId: disable-server-cloudflare-tunnel + parameters: + - + name: uuid + in: path + description: 'Server UUID' + required: true + schema: + type: string + responses: + '200': + description: 'Cloudflare Tunnel disabled.' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/servers/{uuid}/docker-cleanup': + get: + tags: + - Servers + summary: 'Get Docker cleanup settings' + description: 'Get Docker cleanup settings for a server owned by the authenticated team.' + operationId: get-server-docker-cleanup + parameters: + - + name: uuid + in: path + description: 'Server UUID' + required: true + schema: + type: string + responses: + '200': + description: 'Docker cleanup settings.' + content: + application/json: + schema: + properties: + docker_cleanup_frequency: { type: string } + docker_cleanup_threshold: { type: integer } + force_docker_cleanup: { type: boolean } + delete_unused_volumes: { type: boolean } + delete_unused_networks: { type: boolean } + disable_application_image_retention: { type: boolean } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + patch: + tags: + - Servers + summary: 'Update Docker cleanup settings' + description: 'Update Docker cleanup settings for a server owned by the authenticated team.' + operationId: update-server-docker-cleanup + parameters: + - + name: uuid + in: path + description: 'Server UUID' + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + properties: + docker_cleanup_frequency: + description: 'Cron / human frequency expression.' + type: string + docker_cleanup_threshold: + type: integer + maximum: 99 + minimum: 1 + force_docker_cleanup: + type: boolean + delete_unused_volumes: + type: boolean + delete_unused_networks: + type: boolean + disable_application_image_retention: + type: boolean + type: object + responses: + '200': + description: 'Updated Docker cleanup settings.' + content: + application/json: + schema: + properties: + docker_cleanup_frequency: { type: string } + docker_cleanup_threshold: { type: integer } + force_docker_cleanup: { type: boolean } + delete_unused_volumes: { type: boolean } + delete_unused_networks: { type: boolean } + disable_application_image_retention: { type: boolean } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/servers/{uuid}/docker-cleanup/run': + post: + tags: + - Servers + summary: 'Run Docker cleanup' + description: 'Dispatch a manual Docker cleanup job for a server owned by the authenticated team.' + operationId: run-server-docker-cleanup + parameters: + - + name: uuid + in: path + description: 'Server UUID' + required: true + schema: + type: string + requestBody: + required: false + content: + application/json: + schema: + properties: + delete_unused_volumes: + type: boolean + delete_unused_networks: + type: boolean + type: object + responses: + '200': + description: 'Docker cleanup job dispatched.' + content: + application/json: + schema: + properties: + message: { type: string, example: 'Manual cleanup job started.' } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/servers/{uuid}/docker-cleanup/executions': + get: + tags: + - Servers + summary: 'List Docker cleanup executions' + description: 'List recent Docker cleanup execution logs for a server owned by the authenticated team.' + operationId: list-server-docker-cleanup-executions + parameters: + - + name: uuid + in: path + description: 'Server UUID' + required: true + schema: + type: string + responses: + '200': + description: 'Recent Docker cleanup executions.' + content: + application/json: + schema: + type: array + items: + properties: { uuid: { type: string }, status: { type: string }, message: { type: [string, 'null'] }, finished_at: { type: [string, 'null'] }, created_at: { type: string }, updated_at: { type: string } } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + '/servers/{uuid}/log-drains': + get: + tags: + - Servers + summary: 'Get log drain settings' + description: 'Get log drain settings for a server owned by the authenticated team. Sensitive fields require the read:sensitive or root token ability.' + operationId: get-server-log-drains + parameters: + - + name: uuid + in: path + description: 'Server UUID' + required: true + schema: + type: string + responses: + '200': + description: 'Log drain settings.' + content: + application/json: + schema: + properties: + is_logdrain_newrelic_enabled: { type: boolean } + logdrain_newrelic_license_key: { description: 'Only present with read:sensitive.', type: string } + logdrain_newrelic_base_uri: { type: [string, 'null'] } + is_logdrain_axiom_enabled: { type: boolean } + logdrain_axiom_dataset_name: { type: [string, 'null'] } + logdrain_axiom_api_key: { description: 'Only present with read:sensitive.', type: string } + is_logdrain_custom_enabled: { type: boolean } + logdrain_custom_config: { description: 'Only present with read:sensitive.', type: string } + logdrain_custom_config_parser: { description: 'Only present with read:sensitive.', type: string } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + patch: + tags: + - Servers + summary: 'Update log drain settings' + description: 'Update New Relic, Axiom, or custom log drain settings for a server owned by the authenticated team.' + operationId: update-server-log-drains + parameters: + - + name: uuid + in: path + description: 'Server UUID' + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + properties: + is_logdrain_newrelic_enabled: + type: boolean + logdrain_newrelic_license_key: + type: string + logdrain_newrelic_base_uri: + type: string + is_logdrain_axiom_enabled: + type: boolean + logdrain_axiom_dataset_name: + type: string + logdrain_axiom_api_key: + type: string + is_logdrain_custom_enabled: + type: boolean + logdrain_custom_config: + type: string + logdrain_custom_config_parser: + type: string + type: object + responses: + '200': + description: 'Updated log drain settings.' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/servers/{uuid}/proxy': + get: + tags: + - Servers + summary: 'Get server proxy' + description: 'Get proxy settings for a server owned by the authenticated team. The raw proxy configuration is only returned when the token has `read:sensitive` (or `root`) and the user is a team admin/owner, and only when already stored in the database (no remote fetch).' + operationId: get-server-proxy + parameters: + - + name: uuid + in: path + description: 'Server UUID' + required: true + schema: + type: string + responses: + '200': + description: 'Server proxy settings.' + content: + application/json: + schema: + properties: + proxy_type: { type: [string, 'null'], example: TRAEFIK } + status: { type: [string, 'null'], example: running } + redirect_enabled: { type: boolean, example: true } + redirect_url: { type: [string, 'null'], example: 'https://example.com' } + generate_exact_labels: { type: boolean, example: false } + configuration: { description: 'Docker Compose proxy configuration when stored in the database. Only present with read:sensitive.', type: [string, 'null'] } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + patch: + tags: + - Servers + summary: 'Update server proxy' + description: 'Update proxy redirect settings, exact labels generation, and optionally the proxy type for a team-owned server.' + operationId: update-server-proxy + parameters: + - + name: uuid + in: path + description: 'Server UUID' + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + properties: + redirect_enabled: + type: boolean + redirect_url: + description: 'Public http(s) redirect URL, or null to clear.' + type: [string, 'null'] + generate_exact_labels: + type: boolean + proxy_type: + description: 'Proxy type (case-insensitive).' + type: string + enum: [traefik, caddy, nginx, none] + type: object + responses: + '200': + description: 'Proxy settings updated.' + content: + application/json: + schema: + properties: + proxy_type: { type: [string, 'null'] } + status: { type: [string, 'null'] } + redirect_enabled: { type: boolean } + redirect_url: { type: [string, 'null'] } + generate_exact_labels: { type: boolean } + configuration: { type: [string, 'null'] } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/servers/{uuid}/proxy/configuration': + put: + tags: + - Servers + summary: 'Save server proxy configuration' + description: 'Save the raw proxy Docker Compose configuration for a team-owned server. Multi-line configuration must be base64 encoded (same pattern as other compose payloads).' + operationId: save-server-proxy-configuration + parameters: + - + name: uuid + in: path + description: 'Server UUID' + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + required: + - configuration + properties: + configuration: + description: 'Proxy docker-compose YAML. Prefer base64 encoding for multi-line content.' + type: string + type: object + responses: + '200': + description: 'Proxy configuration saved.' + content: + application/json: + schema: + properties: + message: { type: string, example: 'Proxy configuration saved.' } + proxy_type: { type: [string, 'null'] } + status: { type: [string, 'null'] } + redirect_enabled: { type: boolean } + redirect_url: { type: [string, 'null'] } + generate_exact_labels: { type: boolean } + configuration: { type: [string, 'null'] } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/servers/{uuid}/proxy/restart': + post: + tags: + - Servers + summary: 'Restart server proxy' + description: 'Queue a proxy restart for a team-owned server.' + operationId: restart-server-proxy + parameters: + - + name: uuid + in: path + description: 'Server UUID' + required: true + schema: + type: string + responses: + '200': + description: 'Proxy restart queued.' + content: + application/json: + schema: + properties: + message: { type: string, example: 'Proxy restart queued.' } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + '/servers/{uuid}/sentinel': + get: + tags: + - Servers + summary: 'Get Sentinel settings' + description: 'Get Sentinel settings for a server owned by the authenticated team. sentinel_token and sentinel_custom_url require the read:sensitive or root token ability.' + operationId: get-server-sentinel + parameters: + - + name: uuid + in: path + description: 'Server UUID' + required: true + schema: + type: string + responses: + '200': + description: 'Sentinel settings.' + content: + application/json: + schema: + properties: + is_sentinel_enabled: { type: boolean } + is_metrics_enabled: { type: boolean } + is_sentinel_debug_enabled: { type: boolean } + sentinel_token: { description: 'Only present with read:sensitive.', type: string } + sentinel_metrics_refresh_rate_seconds: { type: integer } + sentinel_metrics_history_days: { type: integer } + sentinel_push_interval_seconds: { type: integer } + sentinel_custom_url: { description: 'Only present with read:sensitive.', type: string } + sentinel_updated_at: { type: [string, 'null'] } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + patch: + tags: + - Servers + summary: 'Update Sentinel settings' + description: 'Update Sentinel settings for a server owned by the authenticated team. Changing token/metrics timing fields may restart Sentinel.' + operationId: update-server-sentinel + parameters: + - + name: uuid + in: path + description: 'Server UUID' + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + properties: + is_sentinel_enabled: + type: boolean + is_metrics_enabled: + type: boolean + is_sentinel_debug_enabled: + type: boolean + sentinel_token: + type: string + sentinel_metrics_refresh_rate_seconds: + type: integer + minimum: 1 + sentinel_metrics_history_days: + type: integer + minimum: 1 + sentinel_push_interval_seconds: + type: integer + minimum: 10 + sentinel_custom_url: + type: [string, 'null'] + type: object + responses: + '200': + description: 'Updated Sentinel settings.' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] /servers: get: tags: @@ -9771,6 +11952,538 @@ paths: security: - bearerAuth: [] + '/services/{uuid}/clone': + post: + tags: + - Services + summary: Clone + description: 'Clone a service to a destination owned by the authenticated team.' + operationId: clone-service-by-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the service.' + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + required: + - destination_uuid + properties: + destination_uuid: + type: string + name: + type: [string, 'null'] + clone_volumes: + type: boolean + default: false + type: object + responses: + '201': + description: 'Service cloned.' + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + /team/envs: + get: + tags: + - 'Shared Environment Variables' + summary: 'List Team Shared Envs' + description: 'List shared environment variables for the current team (type=team).' + operationId: list-team-shared-envs + responses: + '200': + description: 'Team shared environment variables.' + '401': + $ref: '#/components/responses/401' + security: + - + bearerAuth: [] + post: + tags: + - 'Shared Environment Variables' + summary: 'Create Team Shared Env' + description: 'Create a shared environment variable for the current team (type=team).' + operationId: create-team-shared-env + requestBody: + required: true + content: + application/json: + schema: + required: + - key + properties: + key: + type: string + value: + type: [string, 'null'] + is_literal: + type: boolean + is_multiline: + type: boolean + is_shown_once: + type: boolean + comment: + type: [string, 'null'] + type: object + responses: + '201': + description: 'Environment variable created.' + '401': + $ref: '#/components/responses/401' + '409': + description: 'Environment variable already exists.' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/team/envs/{env_id}': + delete: + tags: + - 'Shared Environment Variables' + summary: 'Delete Team Shared Env' + description: 'Delete a team shared environment variable by id.' + operationId: delete-team-shared-env + parameters: + - + name: env_id + in: path + description: 'Shared env id (integer).' + required: true + schema: + type: integer + responses: + '200': + description: 'Environment variable deleted.' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + patch: + tags: + - 'Shared Environment Variables' + summary: 'Update Team Shared Env' + description: 'Update a team shared environment variable by id.' + operationId: update-team-shared-env + parameters: + - + name: env_id + in: path + description: 'Shared env id (integer).' + required: true + schema: + type: integer + responses: + '200': + description: 'Environment variable updated.' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/projects/{uuid}/envs': + get: + tags: + - 'Shared Environment Variables' + summary: 'List Project Shared Envs' + description: 'List shared environment variables for a project (type=project).' + operationId: list-project-shared-envs + parameters: + - + name: uuid + in: path + description: 'Project UUID' + required: true + schema: + type: string + responses: + '200': + description: 'Project shared environment variables.' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + post: + tags: + - 'Shared Environment Variables' + summary: 'Create Project Shared Env' + description: 'Create a shared environment variable for a project (type=project).' + operationId: create-project-shared-env + parameters: + - + name: uuid + in: path + description: 'Project UUID' + required: true + schema: + type: string + responses: + '201': + description: 'Environment variable created.' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '409': + description: 'Environment variable already exists.' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/projects/{uuid}/envs/{env_id}': + delete: + tags: + - 'Shared Environment Variables' + summary: 'Delete Project Shared Env' + description: 'Delete a project shared environment variable by id.' + operationId: delete-project-shared-env + parameters: + - + name: uuid + in: path + description: 'Project UUID' + required: true + schema: + type: string + - + name: env_id + in: path + description: 'Shared env id (integer).' + required: true + schema: + type: integer + responses: + '200': + description: 'Environment variable deleted.' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + patch: + tags: + - 'Shared Environment Variables' + summary: 'Update Project Shared Env' + description: 'Update a project shared environment variable by id.' + operationId: update-project-shared-env + parameters: + - + name: uuid + in: path + description: 'Project UUID' + required: true + schema: + type: string + - + name: env_id + in: path + description: 'Shared env id (integer).' + required: true + schema: + type: integer + responses: + '200': + description: 'Environment variable updated.' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/projects/{uuid}/environments/{environment_name_or_uuid}/envs': + get: + tags: + - 'Shared Environment Variables' + summary: 'List Environment Shared Envs' + description: 'List shared environment variables for a project environment (type=environment).' + operationId: list-environment-shared-envs + parameters: + - + name: uuid + in: path + description: 'Project UUID' + required: true + schema: + type: string + - + name: environment_name_or_uuid + in: path + description: 'Environment name or UUID' + required: true + schema: + type: string + responses: + '200': + description: 'Environment shared environment variables.' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + post: + tags: + - 'Shared Environment Variables' + summary: 'Create Environment Shared Env' + description: 'Create a shared environment variable for a project environment (type=environment).' + operationId: create-environment-shared-env + parameters: + - + name: uuid + in: path + description: 'Project UUID' + required: true + schema: + type: string + - + name: environment_name_or_uuid + in: path + description: 'Environment name or UUID' + required: true + schema: + type: string + responses: + '201': + description: 'Environment variable created.' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '409': + description: 'Environment variable already exists.' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/projects/{uuid}/environments/{environment_name_or_uuid}/envs/{env_id}': + delete: + tags: + - 'Shared Environment Variables' + summary: 'Delete Environment Shared Env' + description: 'Delete an environment shared environment variable by id.' + operationId: delete-environment-shared-env + parameters: + - + name: uuid + in: path + description: 'Project UUID' + required: true + schema: + type: string + - + name: environment_name_or_uuid + in: path + description: 'Environment name or UUID' + required: true + schema: + type: string + - + name: env_id + in: path + description: 'Shared env id (integer).' + required: true + schema: + type: integer + responses: + '200': + description: 'Environment variable deleted.' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + patch: + tags: + - 'Shared Environment Variables' + summary: 'Update Environment Shared Env' + description: 'Update an environment shared environment variable by id.' + operationId: update-environment-shared-env + parameters: + - + name: uuid + in: path + description: 'Project UUID' + required: true + schema: + type: string + - + name: environment_name_or_uuid + in: path + description: 'Environment name or UUID' + required: true + schema: + type: string + - + name: env_id + in: path + description: 'Shared env id (integer).' + required: true + schema: + type: integer + responses: + '200': + description: 'Environment variable updated.' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/servers/{uuid}/envs': + get: + tags: + - 'Shared Environment Variables' + summary: 'List Server Shared Envs' + description: 'List shared environment variables for a server (type=server).' + operationId: list-server-shared-envs + parameters: + - + name: uuid + in: path + description: 'Server UUID' + required: true + schema: + type: string + responses: + '200': + description: 'Server shared environment variables.' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + post: + tags: + - 'Shared Environment Variables' + summary: 'Create Server Shared Env' + description: 'Create a shared environment variable for a server (type=server).' + operationId: create-server-shared-env + parameters: + - + name: uuid + in: path + description: 'Server UUID' + required: true + schema: + type: string + responses: + '201': + description: 'Environment variable created.' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '409': + description: 'Environment variable already exists.' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/servers/{uuid}/envs/{env_id}': + delete: + tags: + - 'Shared Environment Variables' + summary: 'Delete Server Shared Env' + description: 'Delete a server shared environment variable by id.' + operationId: delete-server-shared-env + parameters: + - + name: uuid + in: path + description: 'Server UUID' + required: true + schema: + type: string + - + name: env_id + in: path + description: 'Shared env id (integer).' + required: true + schema: + type: integer + responses: + '200': + description: 'Environment variable deleted.' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + patch: + tags: + - 'Shared Environment Variables' + summary: 'Update Server Shared Env' + description: 'Update a server shared environment variable by id.' + operationId: update-server-shared-env + parameters: + - + name: uuid + in: path + description: 'Server UUID' + required: true + schema: + type: string + - + name: env_id + in: path + description: 'Shared env id (integer).' + required: true + schema: + type: integer + responses: + '200': + description: 'Environment variable updated.' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] /tags: get: tags: @@ -9794,6 +12507,121 @@ paths: security: - bearerAuth: [] + post: + tags: + - Tags + summary: Create + description: 'Create a tag for the current team.' + operationId: create-tag + requestBody: + required: true + content: + application/json: + schema: + required: + - name + properties: + name: + type: string + maxLength: 255 + minLength: 2 + type: object + responses: + '201': + description: 'Tag created.' + content: + application/json: + schema: + $ref: '#/components/schemas/Tag' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '409': + description: 'Tag with this name already exists.' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/tags/{uuid}': + delete: + tags: + - Tags + summary: Delete + description: 'Delete a tag for the current team. Detaches the tag from all resources via cascade.' + operationId: delete-tag-by-uuid + parameters: + - + name: uuid + in: path + description: 'Tag UUID' + required: true + schema: + type: string + responses: + '200': + description: 'Tag deleted.' + content: + application/json: + schema: + properties: + message: { type: string, example: 'Tag deleted.' } + type: object + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + patch: + tags: + - Tags + summary: Update + description: 'Update a tag name for the current team.' + operationId: update-tag-by-uuid + parameters: + - + name: uuid + in: path + description: 'Tag UUID' + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + required: + - name + properties: + name: + type: string + maxLength: 255 + minLength: 2 + type: object + responses: + '200': + description: 'Tag updated.' + content: + application/json: + schema: + $ref: '#/components/schemas/Tag' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '409': + description: 'Tag with this name already exists.' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] /teams: get: tags: @@ -9881,16 +12709,16 @@ paths: security: - bearerAuth: [] - /teams/current: + /team: get: tags: - Teams summary: 'Authenticated Team' - description: 'Get currently authenticated team.' - operationId: get-current-team + description: 'Get the team bound to the API token.' + operationId: get-token-team responses: '200': - description: 'Current Team.' + description: 'Team bound to the API token.' content: application/json: schema: @@ -9902,16 +12730,16 @@ paths: security: - bearerAuth: [] - /teams/current/members: + /team/members: get: tags: - Teams summary: 'Authenticated Team Members' - description: 'Get currently authenticated team members.' - operationId: get-current-team-members + description: 'Get members of the team bound to the API token.' + operationId: get-token-team-members responses: '200': - description: 'Currently authenticated team members.' + description: 'Members of the team bound to the API token.' content: application/json: schema: @@ -10186,6 +13014,96 @@ paths: security: - bearerAuth: [] + '/applications/{uuid}/storages/{storage_uuid}/backups/run': + post: + tags: + - Applications + summary: 'Run application storage backup' + description: 'Queue an immediate volume backup for an application storage that has a schedule.' + operationId: run-application-storage-backup + parameters: + - + name: uuid + in: path + required: true + schema: + type: string + - + name: storage_uuid + in: path + required: true + schema: + type: string + responses: + '200': + description: 'Storage backup queued.' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + '/databases/{uuid}/storages/{storage_uuid}/backups/run': + post: + tags: + - Databases + summary: 'Run database storage backup' + description: 'Queue an immediate volume backup for a database storage that has a schedule.' + operationId: run-database-storage-backup + parameters: + - + name: uuid + in: path + required: true + schema: + type: string + - + name: storage_uuid + in: path + required: true + schema: + type: string + responses: + '200': + description: 'Storage backup queued.' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + '/services/{uuid}/storages/{storage_uuid}/backups/run': + post: + tags: + - Services + summary: 'Run service storage backup' + description: 'Queue an immediate volume backup for a service storage that has a schedule.' + operationId: run-service-storage-backup + parameters: + - + name: uuid + in: path + required: true + schema: + type: string + - + name: storage_uuid + in: path + required: true + schema: + type: string + responses: + '200': + description: 'Storage backup queued.' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] /vultr/regions: get: tags: @@ -10562,6 +13480,9 @@ components: preview_url_template: type: string description: 'Preview URL template.' + max_restart_count: + type: integer + description: 'Maximum container restart count before stopping.' destination_type: type: string description: 'Destination type.' @@ -11401,6 +14322,9 @@ tags: - name: Applications description: Applications + - + name: 'Cloud-init Scripts' + description: 'Cloud-init Scripts' - name: 'Cloud Tokens' description: 'Cloud Tokens' @@ -11419,15 +14343,24 @@ tags: - name: 'GitHub Apps' description: 'GitHub Apps' + - + name: 'GitLab Apps' + description: 'GitLab Apps' - name: Hetzner description: Hetzner + - + name: Notifications + description: Notifications - name: Projects description: Projects - name: Resources description: Resources + - + name: 'S3 Storages' + description: 'S3 Storages' - name: 'Scheduled Tasks' description: 'Scheduled Tasks' @@ -11446,6 +14379,9 @@ tags: - name: Services description: Services + - + name: 'Shared Environment Variables' + description: 'Shared Environment Variables' - name: Tags description: Tags diff --git a/routes/api.php b/routes/api.php index b9d2f6c6f..78f24404b 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,6 +1,7 @@ middleware(['api.ability:read']); Route::get('/teams', [TeamController::class, 'teams'])->middleware(['api.ability:read']); + // Token's team + Route::get('/team', [TeamController::class, 'current_team'])->middleware(['api.ability:read']); + Route::get('/team/members', [TeamController::class, 'current_team_members'])->middleware(['api.ability:read']); + // Deprecated aliases — same handlers as /team and /team/members (remove in a later release) Route::get('/teams/current', [TeamController::class, 'current_team'])->middleware(['api.ability:read']); Route::get('/teams/current/members', [TeamController::class, 'current_team_members'])->middleware(['api.ability:read']); + Route::get('/notifications/email', [NotificationsController::class, 'email'])->middleware(['api.ability:read']); + Route::patch('/notifications/email', [NotificationsController::class, 'update_email'])->middleware(['api.ability:write']); + Route::get('/notifications/discord', [NotificationsController::class, 'discord'])->middleware(['api.ability:read']); + Route::patch('/notifications/discord', [NotificationsController::class, 'update_discord'])->middleware(['api.ability:write']); + Route::get('/notifications/slack', [NotificationsController::class, 'slack'])->middleware(['api.ability:read']); + Route::patch('/notifications/slack', [NotificationsController::class, 'update_slack'])->middleware(['api.ability:write']); + Route::get('/notifications/telegram', [NotificationsController::class, 'telegram'])->middleware(['api.ability:read']); + Route::patch('/notifications/telegram', [NotificationsController::class, 'update_telegram'])->middleware(['api.ability:write']); + Route::get('/notifications/pushover', [NotificationsController::class, 'pushover'])->middleware(['api.ability:read']); + Route::patch('/notifications/pushover', [NotificationsController::class, 'update_pushover'])->middleware(['api.ability:write']); + Route::get('/notifications/webhook', [NotificationsController::class, 'webhook'])->middleware(['api.ability:read']); + Route::patch('/notifications/webhook', [NotificationsController::class, 'update_webhook'])->middleware(['api.ability:write']); + Route::get('/team/envs', [SharedEnvironmentVariablesController::class, 'team_envs'])->middleware(['api.ability:read']); + Route::post('/team/envs', [SharedEnvironmentVariablesController::class, 'team_create_env'])->middleware(['api.ability:write']); + Route::patch('/team/envs/{env_id}', [SharedEnvironmentVariablesController::class, 'team_update_env'])->middleware(['api.ability:write']); + Route::delete('/team/envs/{env_id}', [SharedEnvironmentVariablesController::class, 'team_delete_env'])->middleware(['api.ability:write']); Route::get('/teams/{id}', [TeamController::class, 'team_by_id'])->middleware(['api.ability:read']); Route::get('/teams/{id}/members', [TeamController::class, 'members_by_id'])->middleware(['api.ability:read']); Route::get('/projects', [ProjectController::class, 'projects'])->middleware(['api.ability:read']); Route::get('/projects/{uuid}', [ProjectController::class, 'project_by_uuid'])->middleware(['api.ability:read']); Route::get('/projects/{uuid}/environments', [ProjectController::class, 'get_environments'])->middleware(['api.ability:read']); + // Shared project/environment envs must be registered before the catch-all environment route. + Route::get('/projects/{uuid}/envs', [SharedEnvironmentVariablesController::class, 'project_envs'])->middleware(['api.ability:read']); + Route::post('/projects/{uuid}/envs', [SharedEnvironmentVariablesController::class, 'project_create_env'])->middleware(['api.ability:write']); + Route::patch('/projects/{uuid}/envs/{env_id}', [SharedEnvironmentVariablesController::class, 'project_update_env'])->middleware(['api.ability:write']); + Route::delete('/projects/{uuid}/envs/{env_id}', [SharedEnvironmentVariablesController::class, 'project_delete_env'])->middleware(['api.ability:write']); + Route::get('/projects/{uuid}/environments/{environment_name_or_uuid}/envs', [SharedEnvironmentVariablesController::class, 'environment_envs'])->middleware(['api.ability:read']); + Route::post('/projects/{uuid}/environments/{environment_name_or_uuid}/envs', [SharedEnvironmentVariablesController::class, 'environment_create_env'])->middleware(['api.ability:write']); + Route::patch('/projects/{uuid}/environments/{environment_name_or_uuid}/envs/{env_id}', [SharedEnvironmentVariablesController::class, 'environment_update_env'])->middleware(['api.ability:write']); + Route::delete('/projects/{uuid}/environments/{environment_name_or_uuid}/envs/{env_id}', [SharedEnvironmentVariablesController::class, 'environment_delete_env'])->middleware(['api.ability:write']); Route::get('/projects/{uuid}/{environment_name_or_uuid}', [ProjectController::class, 'environment_details'])->middleware(['api.ability:read']); Route::post('/projects/{uuid}/environments', [ProjectController::class, 'create_environment'])->middleware(['api.ability:write']); + Route::patch('/projects/{uuid}/environments/{environment_name_or_uuid}', [ProjectController::class, 'update_environment'])->middleware(['api.ability:write']); Route::delete('/projects/{uuid}/environments/{environment_name_or_uuid}', [ProjectController::class, 'delete_environment'])->middleware(['api.ability:write']); Route::post('/projects', [ProjectController::class, 'create_project'])->middleware(['api.ability:write']); @@ -87,6 +126,19 @@ Route::group([ Route::delete('/cloud-tokens/{uuid}', [CloudProviderTokensController::class, 'destroy'])->middleware(['api.ability:write']); Route::post('/cloud-tokens/{uuid}/validate', [CloudProviderTokensController::class, 'validateToken'])->middleware(['api.ability:write']); + Route::get('/cloud-init-scripts', [CloudInitScriptsController::class, 'index'])->middleware(['api.ability:read']); + Route::post('/cloud-init-scripts', [CloudInitScriptsController::class, 'store'])->middleware(['api.ability:write']); + Route::get('/cloud-init-scripts/{uuid}', [CloudInitScriptsController::class, 'show'])->middleware(['api.ability:read']); + Route::patch('/cloud-init-scripts/{uuid}', [CloudInitScriptsController::class, 'update'])->middleware(['api.ability:write']); + Route::delete('/cloud-init-scripts/{uuid}', [CloudInitScriptsController::class, 'destroy'])->middleware(['api.ability:write']); + + Route::get('/s3-storages', [S3StoragesController::class, 'index'])->middleware(['api.ability:read']); + Route::post('/s3-storages', [S3StoragesController::class, 'store'])->middleware(['api.ability:write']); + Route::get('/s3-storages/{uuid}', [S3StoragesController::class, 'show'])->middleware(['api.ability:read']); + Route::patch('/s3-storages/{uuid}', [S3StoragesController::class, 'update'])->middleware(['api.ability:write']); + Route::delete('/s3-storages/{uuid}', [S3StoragesController::class, 'destroy'])->middleware(['api.ability:write']); + Route::post('/s3-storages/{uuid}/validate', [S3StoragesController::class, 'validateStorage'])->middleware(['api.ability:write']); + Route::get('/deploy', [OtherController::class, 'post_required'])->middleware(['api.ability:deploy']); Route::post('/deploy', [DeployController::class, 'deploy'])->middleware(['api.ability:deploy']); Route::get('/deployments', [DeployController::class, 'deployments'])->middleware(['api.ability:read']); @@ -98,10 +150,32 @@ Route::group([ Route::get('/servers/{uuid}', [ServersController::class, 'server_by_uuid'])->middleware(['api.ability:read']); Route::get('/servers/{uuid}/domains', [ServersController::class, 'domains_by_server'])->middleware(['api.ability:read']); Route::get('/servers/{uuid}/resources', [ServersController::class, 'resources_by_server'])->middleware(['api.ability:read']); + Route::get('/servers/{uuid}/envs', [SharedEnvironmentVariablesController::class, 'server_envs'])->middleware(['api.ability:read']); + Route::post('/servers/{uuid}/envs', [SharedEnvironmentVariablesController::class, 'server_create_env'])->middleware(['api.ability:write']); + Route::patch('/servers/{uuid}/envs/{env_id}', [SharedEnvironmentVariablesController::class, 'server_update_env'])->middleware(['api.ability:write']); + Route::delete('/servers/{uuid}/envs/{env_id}', [SharedEnvironmentVariablesController::class, 'server_delete_env'])->middleware(['api.ability:write']); + + // Server subsystem APIs (Docker cleanup, log drains, Sentinel, Cloudflare Tunnel). + Route::get('/servers/{uuid}/docker-cleanup', [ServerDockerCleanupController::class, 'show'])->middleware(['api.ability:read']); + Route::patch('/servers/{uuid}/docker-cleanup', [ServerDockerCleanupController::class, 'update'])->middleware(['api.ability:write']); + Route::post('/servers/{uuid}/docker-cleanup/run', [ServerDockerCleanupController::class, 'run'])->middleware(['api.ability:write']); + Route::get('/servers/{uuid}/docker-cleanup/executions', [ServerDockerCleanupController::class, 'executions'])->middleware(['api.ability:read']); + + Route::get('/servers/{uuid}/log-drains', [ServerLogDrainsController::class, 'show'])->middleware(['api.ability:read']); + Route::patch('/servers/{uuid}/log-drains', [ServerLogDrainsController::class, 'update'])->middleware(['api.ability:write']); + + Route::get('/servers/{uuid}/sentinel', [ServerSentinelController::class, 'show'])->middleware(['api.ability:read']); + Route::patch('/servers/{uuid}/sentinel', [ServerSentinelController::class, 'update'])->middleware(['api.ability:write']); + + Route::get('/servers/{uuid}/cloudflare-tunnel', [ServerCloudflareTunnelController::class, 'show'])->middleware(['api.ability:read']); + Route::patch('/servers/{uuid}/cloudflare-tunnel', [ServerCloudflareTunnelController::class, 'update'])->middleware(['api.ability:write']); + Route::post('/servers/{uuid}/cloudflare-tunnel/enable', [ServerCloudflareTunnelController::class, 'enable'])->middleware(['api.ability:write']); + Route::post('/servers/{uuid}/cloudflare-tunnel/disable', [ServerCloudflareTunnelController::class, 'disable'])->middleware(['api.ability:write']); // Destinations — REST surface for the Coolify "Destinations" UI section (added). Route::get('/destinations', [DestinationsController::class, 'index'])->middleware(['api.ability:read']); Route::get('/destinations/{uuid}', [DestinationsController::class, 'show'])->middleware(['api.ability:read']); + Route::patch('/destinations/{uuid}', [DestinationsController::class, 'update'])->middleware(['api.ability:write']); Route::delete('/destinations/{uuid}', [DestinationsController::class, 'delete'])->middleware(['api.ability:write']); Route::get('/servers/{server_uuid}/destinations', [DestinationsController::class, 'index_by_server'])->middleware(['api.ability:read']); Route::post('/servers/{server_uuid}/destinations', [DestinationsController::class, 'create'])->middleware(['api.ability:write']); @@ -109,6 +183,11 @@ Route::group([ Route::get('/servers/{uuid}/validate', [OtherController::class, 'post_required'])->middleware(['api.ability:write']); Route::post('/servers/{uuid}/validate', [ServersController::class, 'validate_server'])->middleware(['api.ability:write']); + Route::get('/servers/{uuid}/proxy', [ServerProxyController::class, 'show'])->middleware(['api.ability:read']); + Route::patch('/servers/{uuid}/proxy', [ServerProxyController::class, 'update'])->middleware(['api.ability:write']); + Route::put('/servers/{uuid}/proxy/configuration', [ServerProxyController::class, 'saveConfiguration'])->middleware(['api.ability:write']); + Route::post('/servers/{uuid}/proxy/restart', [ServerProxyController::class, 'restart'])->middleware(['api.ability:write']); + Route::post('/servers', [ServersController::class, 'create_server'])->middleware(['api.ability:write']); Route::patch('/servers/{uuid}', [ServersController::class, 'update_server'])->middleware(['api.ability:write']); Route::delete('/servers/{uuid}', [ServersController::class, 'delete_server'])->middleware(['api.ability:write']); @@ -136,6 +215,9 @@ Route::group([ Route::get('/resources', [ResourcesController::class, 'resources'])->middleware(['api.ability:read']); Route::get('/tags', [TagsController::class, 'tags'])->middleware(['api.ability:read']); + Route::post('/tags', [TagsController::class, 'create'])->middleware(['api.ability:write']); + Route::patch('/tags/{uuid}', [TagsController::class, 'update'])->middleware(['api.ability:write']); + Route::delete('/tags/{uuid}', [TagsController::class, 'delete'])->middleware(['api.ability:write']); Route::get('/applications', [ApplicationsController::class, 'applications'])->middleware(['api.ability:read']); Route::post('/applications/public', [ApplicationsController::class, 'create_public_application'])->middleware(['api.ability:write']); @@ -161,6 +243,9 @@ Route::group([ Route::put('/applications/{uuid}/storages/{storage_uuid}/backups', [VolumeBackupsController::class, 'upsert']) ->defaults('resource_type', 'application') ->middleware(['api.ability:write']); + Route::post('/applications/{uuid}/storages/{storage_uuid}/backups/run', [VolumeBackupsController::class, 'run']) + ->defaults('resource_type', 'application') + ->middleware(['api.ability:write']); Route::delete('/applications/{uuid}/storages/{storage_uuid}/backups', [VolumeBackupsController::class, 'destroy']) ->defaults('resource_type', 'application') ->middleware(['api.ability:write']); @@ -170,6 +255,12 @@ Route::group([ Route::delete('/applications/{uuid}/tags/{tag_uuid}', [ApplicationsController::class, 'delete_tag'])->middleware(['api.ability:write']); Route::post('/applications/{uuid}/move', [ApplicationsController::class, 'move_by_uuid'])->middleware(['api.ability:write']); + Route::post('/applications/{uuid}/clone', [ApplicationsController::class, 'clone_by_uuid'])->middleware(['api.ability:write']); + Route::get('/applications/{uuid}/rollback-images', [ApplicationsController::class, 'rollback_images'])->middleware(['api.ability:read']); + Route::post('/applications/{uuid}/rollback', [ApplicationsController::class, 'rollback_by_uuid'])->middleware(['api.ability:deploy']); + Route::get('/applications/{uuid}/destinations', [ApplicationsController::class, 'destinations'])->middleware(['api.ability:read']); + Route::post('/applications/{uuid}/destinations', [ApplicationsController::class, 'add_destination'])->middleware(['api.ability:write']); + Route::delete('/applications/{uuid}/destinations/{destination_uuid}', [ApplicationsController::class, 'remove_destination'])->middleware(['api.ability:write']); Route::get('/applications/{uuid}/start', [OtherController::class, 'post_required'])->middleware(['api.ability:deploy']); Route::get('/applications/{uuid}/restart', [OtherController::class, 'post_required'])->middleware(['api.ability:deploy']); Route::get('/applications/{uuid}/stop', [OtherController::class, 'post_required'])->middleware(['api.ability:deploy']); @@ -219,6 +310,9 @@ Route::group([ Route::put('/databases/{uuid}/storages/{storage_uuid}/backups', [VolumeBackupsController::class, 'upsert']) ->defaults('resource_type', 'database') ->middleware(['api.ability:write']); + Route::post('/databases/{uuid}/storages/{storage_uuid}/backups/run', [VolumeBackupsController::class, 'run']) + ->defaults('resource_type', 'database') + ->middleware(['api.ability:write']); Route::delete('/databases/{uuid}/storages/{storage_uuid}/backups', [VolumeBackupsController::class, 'destroy']) ->defaults('resource_type', 'database') ->middleware(['api.ability:write']); @@ -234,6 +328,7 @@ Route::group([ Route::delete('/databases/{uuid}/tags/{tag_uuid}', [DatabasesController::class, 'delete_tag'])->middleware(['api.ability:write']); Route::post('/databases/{uuid}/move', [DatabasesController::class, 'move_by_uuid'])->middleware(['api.ability:write']); + Route::post('/databases/{uuid}/clone', [DatabasesController::class, 'clone_by_uuid'])->middleware(['api.ability:write']); Route::get('/databases/{uuid}/start', [OtherController::class, 'post_required'])->middleware(['api.ability:deploy']); Route::get('/databases/{uuid}/restart', [OtherController::class, 'post_required'])->middleware(['api.ability:deploy']); Route::get('/databases/{uuid}/stop', [OtherController::class, 'post_required'])->middleware(['api.ability:deploy']); @@ -255,6 +350,9 @@ Route::group([ Route::put('/services/{uuid}/storages/{storage_uuid}/backups', [VolumeBackupsController::class, 'upsert']) ->defaults('resource_type', 'service') ->middleware(['api.ability:write']); + Route::post('/services/{uuid}/storages/{storage_uuid}/backups/run', [VolumeBackupsController::class, 'run']) + ->defaults('resource_type', 'service') + ->middleware(['api.ability:write']); Route::delete('/services/{uuid}/storages/{storage_uuid}/backups', [VolumeBackupsController::class, 'destroy']) ->defaults('resource_type', 'service') ->middleware(['api.ability:write']); @@ -271,6 +369,7 @@ Route::group([ Route::delete('/services/{uuid}/tags/{tag_uuid}', [ServicesController::class, 'delete_tag'])->middleware(['api.ability:write']); Route::post('/services/{uuid}/move', [ServicesController::class, 'move_by_uuid'])->middleware(['api.ability:write']); + Route::post('/services/{uuid}/clone', [ServicesController::class, 'clone_by_uuid'])->middleware(['api.ability:write']); Route::get('/services/{uuid}/start', [OtherController::class, 'post_required'])->middleware(['api.ability:deploy']); Route::get('/services/{uuid}/restart', [OtherController::class, 'post_required'])->middleware(['api.ability:deploy']); Route::get('/services/{uuid}/stop', [OtherController::class, 'post_required'])->middleware(['api.ability:deploy']); @@ -302,12 +401,14 @@ Route::group([ Route::patch('/applications/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'update_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']); Route::delete('/applications/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'delete_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']); Route::get('/applications/{uuid}/scheduled-tasks/{task_uuid}/executions', [ScheduledTasksController::class, 'executions_by_application_uuid'])->middleware(['api.ability:read']); + Route::post('/applications/{uuid}/scheduled-tasks/{task_uuid}/execute', [ScheduledTasksController::class, 'execute_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']); Route::get('/services/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'scheduled_tasks_by_service_uuid'])->middleware(['api.ability:read']); Route::post('/services/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'create_scheduled_task_by_service_uuid'])->middleware(['api.ability:write']); Route::patch('/services/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'update_scheduled_task_by_service_uuid'])->middleware(['api.ability:write']); Route::delete('/services/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'delete_scheduled_task_by_service_uuid'])->middleware(['api.ability:write']); Route::get('/services/{uuid}/scheduled-tasks/{task_uuid}/executions', [ScheduledTasksController::class, 'executions_by_service_uuid'])->middleware(['api.ability:read']); + Route::post('/services/{uuid}/scheduled-tasks/{task_uuid}/execute', [ScheduledTasksController::class, 'execute_scheduled_task_by_service_uuid'])->middleware(['api.ability:write']); }); Route::group([ diff --git a/tests/Feature/Api/ApplicationRollbackImagesApiTest.php b/tests/Feature/Api/ApplicationRollbackImagesApiTest.php new file mode 100644 index 000000000..4e61000ba --- /dev/null +++ b/tests/Feature/Api/ApplicationRollbackImagesApiTest.php @@ -0,0 +1,21 @@ +setAccessible(true); + + return $method->invoke(null, $imageReference); +} + +it('extracts the current rollback image tag when the registry includes a port', function () { + expect(currentRollbackImageTag('registry.example.com:5000/team/application:commit-sha')) + ->toBe('commit-sha'); +}); + +it('does not treat a digest as the current rollback image tag', function () { + expect(currentRollbackImageTag('registry.example.com:5000/team/application@sha256:'.str_repeat('a', 64))) + ->toBeNull(); +}); diff --git a/tests/Feature/Api/ApplicationSettingsApiTest.php b/tests/Feature/Api/ApplicationSettingsApiTest.php index 51ec776b2..6f54e57e8 100644 --- a/tests/Feature/Api/ApplicationSettingsApiTest.php +++ b/tests/Feature/Api/ApplicationSettingsApiTest.php @@ -57,6 +57,14 @@ function recommendedApplicationSettingsPayload(): array 'is_gzip_enabled' => false, 'is_stripprefix_enabled' => false, 'is_raw_compose_deployment_enabled' => true, + 'is_log_drain_enabled' => true, + 'is_gpu_enabled' => true, + 'gpu_driver' => 'nvidia', + 'gpu_count' => '1', + 'gpu_device_ids' => '0', + 'gpu_options' => null, + 'is_consistent_container_name_enabled' => true, + 'custom_internal_name' => 'my-app-internal', ]; } @@ -181,3 +189,122 @@ test('raw compose deployment can only be enabled for Docker Compose applications ->assertUnprocessable() ->assertJsonValidationErrors('is_raw_compose_deployment_enabled'); }); + +function advancedApplicationSettingsPayload(): array +{ + return [ + 'is_log_drain_enabled' => true, + 'is_gpu_enabled' => true, + 'gpu_driver' => 'nvidia', + 'gpu_count' => '1', + 'gpu_device_ids' => '0', + 'gpu_options' => 'capabilities=compute,utility', + 'is_consistent_container_name_enabled' => true, + 'custom_internal_name' => 'my-app-container', + ]; +} + +test('PATCH /api/v1/applications/{uuid} updates advanced application settings', function () { + $this->withHeaders(applicationSettingsApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$this->application->uuid}", advancedApplicationSettingsPayload()) + ->assertOk(); + + $settings = $this->application->fresh()->settings; + + foreach (advancedApplicationSettingsPayload() as $field => $value) { + expect($settings->{$field})->toBe($value); + } +}); + +test('PATCH /api/v1/applications/{uuid} updates preview_url_template and max_restart_count', function () { + $this->withHeaders(applicationSettingsApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$this->application->uuid}", [ + 'preview_url_template' => '{{pr_id}}.preview.example.com', + 'max_restart_count' => 5, + ]) + ->assertOk(); + + $application = $this->application->fresh(); + + expect($application->preview_url_template)->toBe('{{pr_id}}.preview.example.com') + ->and($application->max_restart_count)->toBe(5); +}); + +test('GET /api/v1/applications/{uuid} includes advanced settings', function () { + $this->application->settings->update(advancedApplicationSettingsPayload()); + $this->application->update([ + 'preview_url_template' => '{{pr_id}}.preview.example.com', + 'max_restart_count' => 3, + ]); + + $this->withHeaders(applicationSettingsApiHeaders($this->bearerToken)) + ->getJson("/api/v1/applications/{$this->application->uuid}") + ->assertOk() + ->assertJsonPath('settings.is_log_drain_enabled', true) + ->assertJsonPath('settings.is_gpu_enabled', true) + ->assertJsonPath('settings.gpu_driver', 'nvidia') + ->assertJsonPath('settings.custom_internal_name', 'my-app-container') + ->assertJsonPath('settings.is_consistent_container_name_enabled', true) + ->assertJsonPath('preview_url_template', '{{pr_id}}.preview.example.com') + ->assertJsonPath('max_restart_count', 3); +}); + +test('application creation accepts advanced application settings', function () { + Queue::fake(); + + $response = $this->withHeaders(applicationSettingsApiHeaders($this->bearerToken)) + ->postJson('/api/v1/applications/public', array_merge([ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'server_uuid' => $this->server->uuid, + 'git_repository' => 'https://gitlab.com/coolify/advanced-settings-test', + 'git_branch' => 'main', + 'build_pack' => 'nixpacks', + 'ports_exposes' => '3000', + 'autogenerate_domain' => false, + 'preview_url_template' => '{{pr_id}}.create.example.com', + 'max_restart_count' => 7, + ], advancedApplicationSettingsPayload())) + ->assertCreated(); + + $application = Application::where('uuid', $response->json('uuid'))->firstOrFail(); + $settings = $application->settings; + + foreach (advancedApplicationSettingsPayload() as $field => $value) { + expect($settings->{$field})->toBe($value); + } + + expect($application->preview_url_template)->toBe('{{pr_id}}.create.example.com') + ->and($application->max_restart_count)->toBe(7); +}); + +test('rejects invalid max_restart_count', function () { + $this->withHeaders(applicationSettingsApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$this->application->uuid}", [ + 'max_restart_count' => -1, + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('max_restart_count'); +}); + +test('rejects invalid gpu boolean settings', function () { + $this->withHeaders(applicationSettingsApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$this->application->uuid}", [ + 'is_gpu_enabled' => 'not-a-boolean', + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('is_gpu_enabled'); +}); + +test('rejects swarm fields on application update', function (string $field, mixed $value) { + $this->withHeaders(applicationSettingsApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$this->application->uuid}", [ + $field => $value, + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors($field); +})->with([ + 'swarm_replicas' => ['swarm_replicas', 3], + 'swarm_placement_constraints' => ['swarm_placement_constraints', 'node.role==worker'], + 'is_swarm_only_worker_nodes' => ['is_swarm_only_worker_nodes', true], +]); diff --git a/tests/Feature/Api/DestinationsApiTest.php b/tests/Feature/Api/DestinationsApiTest.php index 2de639b5c..90875a0a2 100644 --- a/tests/Feature/Api/DestinationsApiTest.php +++ b/tests/Feature/Api/DestinationsApiTest.php @@ -245,6 +245,79 @@ describe('POST /api/v1/servers/{server_uuid}/destinations', function () { }); }); +describe('PATCH /api/v1/destinations/{uuid}', function () { + test('updates destination name only', function () { + $originalNetwork = $this->destination->network; + + $response = $this->withHeaders(destinationsApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/destinations/{$this->destination->uuid}", [ + 'name' => 'Renamed Destination', + ]); + + $response->assertOk() + ->assertJson([ + 'uuid' => $this->destination->uuid, + 'name' => 'Renamed Destination', + 'network' => $originalNetwork, + 'type' => 'standalone', + 'server_uuid' => $this->server->uuid, + ]); + + $this->destination->refresh(); + expect($this->destination->name)->toBe('Renamed Destination') + ->and($this->destination->network)->toBe($originalNetwork); + }); + + test('requires a write token', function () { + $readOnlyToken = destinationsApiToken($this->user, $this->team, ['read']); + + $response = $this->withHeaders(destinationsApiHeaders($readOnlyToken)) + ->patchJson("/api/v1/destinations/{$this->destination->uuid}", [ + 'name' => 'Should Fail', + ]); + + $response->assertForbidden(); + }); + + test('rejects update requests from non-admin team members', function () { + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + $memberToken = destinationsApiToken($member, $this->team, ['*']); + + $response = $this->withHeaders(destinationsApiHeaders($memberToken)) + ->patchJson("/api/v1/destinations/{$this->destination->uuid}", [ + 'name' => 'Member Rename', + ]); + + $response->assertForbidden(); + }); + + test('rejects network changes and unknown fields', function () { + $response = $this->withHeaders(destinationsApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/destinations/{$this->destination->uuid}", [ + 'name' => 'Valid Name', + 'network' => 'new-network', + ]); + + $response->assertUnprocessable(); + $response->assertJsonValidationErrors(['network']); + }); + + test('does not update another team destination', function () { + $otherTeam = Team::factory()->create(); + $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]); + $otherDestination = StandaloneDocker::where('server_id', $otherServer->id)->first(); + + $response = $this->withHeaders(destinationsApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/destinations/{$otherDestination->uuid}", [ + 'name' => 'Stolen Name', + ]); + + $response->assertNotFound(); + expect($otherDestination->fresh()->name)->not->toBe('Stolen Name'); + }); +}); + describe('DELETE /api/v1/destinations/{uuid}', function () { test('requires a write token', function () { $readOnlyToken = destinationsApiToken($this->user, $this->team, ['read']); diff --git a/tests/Feature/Api/EnvironmentUpdateApiTest.php b/tests/Feature/Api/EnvironmentUpdateApiTest.php new file mode 100644 index 000000000..7a10adc5c --- /dev/null +++ b/tests/Feature/Api/EnvironmentUpdateApiTest.php @@ -0,0 +1,166 @@ + 'array', + 'session.driver' => 'array', + 'queue.default' => 'sync', + 'app.maintenance.driver' => 'file', + ]); + + InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate( + ['id' => 0], + ['is_api_enabled' => true], + )); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + session(['currentTeam' => $this->team]); + + $this->bearerToken = environmentUpdateApiToken($this->user, $this->team, ['*']); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = $this->project->environments()->first() + ?? Environment::factory()->create(['project_id' => $this->project->id, 'name' => 'production']); +}); + +function environmentUpdateApiHeaders(string $bearerToken): array +{ + return [ + 'Authorization' => 'Bearer '.$bearerToken, + 'Content-Type' => 'application/json', + ]; +} + +function environmentUpdateApiToken(User $user, Team $team, array $abilities): string +{ + $plainTextToken = Str::random(40); + $token = $user->tokens()->create([ + 'name' => 'environment-update-api-test-'.Str::random(6), + 'token' => hash('sha256', $plainTextToken), + 'abilities' => $abilities, + 'team_id' => $team->id, + ]); + + return $token->getKey().'|'.$plainTextToken; +} + +describe('PATCH /api/v1/projects/{uuid}/environments/{environment_name_or_uuid}', function () { + test('updates environment name and description by uuid', function () { + $response = $this->withHeaders(environmentUpdateApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/projects/{$this->project->uuid}/environments/{$this->environment->uuid}", [ + 'name' => 'staging', + 'description' => 'Staging environment', + ]); + + $response->assertOk() + ->assertJson([ + 'uuid' => $this->environment->uuid, + 'name' => 'staging', + 'description' => 'Staging environment', + ]); + + $this->environment->refresh(); + expect($this->environment->name)->toBe('staging') + ->and($this->environment->description)->toBe('Staging environment'); + }); + + test('updates environment by name path segment', function () { + $response = $this->withHeaders(environmentUpdateApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/projects/{$this->project->uuid}/environments/{$this->environment->name}", [ + 'description' => 'Updated description only', + ]); + + $response->assertOk() + ->assertJson([ + 'uuid' => $this->environment->uuid, + 'name' => $this->environment->name, + 'description' => 'Updated description only', + ]); + }); + + test('requires a write token', function () { + $readOnlyToken = environmentUpdateApiToken($this->user, $this->team, ['read']); + + $response = $this->withHeaders(environmentUpdateApiHeaders($readOnlyToken)) + ->patchJson("/api/v1/projects/{$this->project->uuid}/environments/{$this->environment->uuid}", [ + 'name' => 'should-fail', + ]); + + $response->assertForbidden(); + expect($this->environment->fresh()->name)->toBe($this->environment->name); + }); + + test('rejects update requests from non-admin team members', function () { + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + $memberToken = environmentUpdateApiToken($member, $this->team, ['*']); + + $response = $this->withHeaders(environmentUpdateApiHeaders($memberToken)) + ->patchJson("/api/v1/projects/{$this->project->uuid}/environments/{$this->environment->uuid}", [ + 'name' => 'member-rename', + ]); + + $response->assertForbidden(); + expect($this->environment->fresh()->name)->toBe($this->environment->name); + }); + + test('rejects unknown fields', function () { + $response = $this->withHeaders(environmentUpdateApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/projects/{$this->project->uuid}/environments/{$this->environment->uuid}", [ + 'name' => 'valid-name', + 'unexpected' => 'value', + ]); + + $response->assertUnprocessable(); + $response->assertJsonValidationErrors(['unexpected']); + }); + + test('returns 404 for another team project', function () { + $otherTeam = Team::factory()->create(); + $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]); + $otherEnvironment = $otherProject->environments()->first() + ?? Environment::factory()->create(['project_id' => $otherProject->id]); + + $response = $this->withHeaders(environmentUpdateApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/projects/{$otherProject->uuid}/environments/{$otherEnvironment->uuid}", [ + 'name' => 'stolen', + ]); + + $response->assertNotFound(); + }); + + test('returns 409 when renaming to an existing environment name', function () { + $other = Environment::factory()->create([ + 'project_id' => $this->project->id, + 'name' => 'already-taken', + ]); + + $response = $this->withHeaders(environmentUpdateApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/projects/{$this->project->uuid}/environments/{$this->environment->uuid}", [ + 'name' => $other->name, + ]); + + $response->assertStatus(409) + ->assertJson(['message' => 'Environment with this name already exists.']); + }); + + test('returns 404 for missing environment', function () { + $response = $this->withHeaders(environmentUpdateApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/projects/{$this->project->uuid}/environments/missing-env", [ + 'name' => 'new-name', + ]); + + $response->assertNotFound(); + }); +}); diff --git a/tests/Feature/Api/LifecycleApisTest.php b/tests/Feature/Api/LifecycleApisTest.php new file mode 100644 index 000000000..913eac59a --- /dev/null +++ b/tests/Feature/Api/LifecycleApisTest.php @@ -0,0 +1,419 @@ + 'array', + 'session.driver' => 'array', + 'queue.default' => 'sync', + 'app.maintenance.driver' => 'file', + ]); + + InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate( + ['id' => 0], + ['is_api_enabled' => true], + )); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + session(['currentTeam' => $this->team]); + + $plainTextToken = Str::random(40); + $token = $this->user->tokens()->create([ + 'name' => 'lifecycle-api-test', + 'token' => hash('sha256', $plainTextToken), + 'abilities' => ['*'], + 'team_id' => $this->team->id, + ]); + $this->headers = [ + 'Authorization' => 'Bearer '.$token->getKey().'|'.$plainTextToken, + 'Content-Type' => 'application/json', + ]; + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail(); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = $this->project->environments()->first() + ?? Environment::factory()->create(['project_id' => $this->project->id]); + + $this->application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'name' => 'source-app', + ]); + $this->application->settings->fill([ + 'is_container_label_readonly_enabled' => false, + ])->save(); +}); + +describe('POST /api/v1/applications/{uuid}/clone', function () { + test('clones an application and returns the new uuid', function () { + $response = $this->withHeaders($this->headers) + ->postJson("/api/v1/applications/{$this->application->uuid}/clone", [ + 'destination_uuid' => $this->destination->uuid, + 'name' => 'cloned-app', + ]); + + $response->assertCreated() + ->assertJsonPath('message', 'Application cloned.') + ->assertJsonStructure(['uuid', 'message']); + + $newUuid = $response->json('uuid'); + expect($newUuid)->not->toBe($this->application->uuid); + + $cloned = Application::where('uuid', $newUuid)->first(); + expect($cloned)->not->toBeNull() + ->and($cloned->name)->toBe('cloned-app') + ->and($cloned->environment_id)->toBe($this->application->environment_id) + ->and($cloned->destination_id)->toBe($this->destination->id); + }); + + test('returns 404 for another team destination', function () { + $otherTeam = Team::factory()->create(); + $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]); + $otherDestination = StandaloneDocker::where('server_id', $otherServer->id)->firstOrFail(); + + $response = $this->withHeaders($this->headers) + ->postJson("/api/v1/applications/{$this->application->uuid}/clone", [ + 'destination_uuid' => $otherDestination->uuid, + ]); + + $response->assertNotFound(); + }); + + test('returns 404 for another team application', function () { + $otherTeam = Team::factory()->create(); + $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]); + $otherDestination = StandaloneDocker::where('server_id', $otherServer->id)->firstOrFail(); + $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]); + $otherEnvironment = $otherProject->environments()->first() + ?? Environment::factory()->create(['project_id' => $otherProject->id]); + $otherApp = Application::factory()->create([ + 'environment_id' => $otherEnvironment->id, + 'destination_id' => $otherDestination->id, + 'destination_type' => $otherDestination->getMorphClass(), + ]); + + $response = $this->withHeaders($this->headers) + ->postJson("/api/v1/applications/{$otherApp->uuid}/clone", [ + 'destination_uuid' => $this->destination->uuid, + ]); + + $response->assertNotFound(); + }); +}); + +describe('POST /api/v1/databases/{uuid}/clone', function () { + test('clones a database and returns the new uuid', function () { + $database = StandalonePostgresql::create([ + 'name' => 'source-db', + 'image' => 'postgres:17-alpine', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'postgres', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders($this->headers) + ->postJson("/api/v1/databases/{$database->uuid}/clone", [ + 'destination_uuid' => $this->destination->uuid, + 'name' => 'cloned-db', + ]); + + $response->assertCreated() + ->assertJsonPath('message', 'Database cloned.'); + + $cloned = StandalonePostgresql::where('uuid', $response->json('uuid'))->first(); + expect($cloned)->not->toBeNull() + ->and($cloned->name)->toBe('cloned-db') + ->and($cloned->environment_id)->toBe($database->environment_id) + ->and($cloned->destination_id)->toBe($this->destination->id) + ->and(str($cloned->status)->startsWith('exited'))->toBeTrue(); + }); + + test('creates renamed volumes when cloning a database with clone_volumes', function () { + // Prevent remote stop/clone/start from running in unit tests. + Bus::fake(); + + $database = StandalonePostgresql::create([ + 'name' => 'source-db-volumes', + 'image' => 'postgres:17-alpine', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'postgres', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + // Factory/create hooks may already create a data volume. + if ($database->persistentStorages()->count() === 0) { + LocalPersistentVolume::create([ + 'name' => 'postgres-data-'.$database->uuid, + 'mount_path' => '/var/lib/postgresql/data', + 'resource_id' => $database->id, + 'resource_type' => $database->getMorphClass(), + ]); + } + + $sourceVolumeCount = $database->persistentStorages()->count(); + expect($sourceVolumeCount)->toBeGreaterThan(0); + + $response = $this->withHeaders($this->headers) + ->postJson("/api/v1/databases/{$database->uuid}/clone", [ + 'destination_uuid' => $this->destination->uuid, + 'name' => 'cloned-db-volumes', + 'clone_volumes' => true, + ]) + ->assertCreated(); + + $cloned = StandalonePostgresql::where('uuid', $response->json('uuid'))->firstOrFail(); + expect($cloned->persistentStorages()->count())->toBe($sourceVolumeCount) + ->and($cloned->persistentStorages()->first()->name)->not->toBe($database->persistentStorages()->first()->name) + ->and($cloned->persistentStorages()->first()->name)->toContain($cloned->uuid); + }); + + test('returns 404 for another team database', function () { + $otherTeam = Team::factory()->create(); + $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]); + $otherDestination = StandaloneDocker::where('server_id', $otherServer->id)->firstOrFail(); + $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]); + $otherEnvironment = $otherProject->environments()->first() + ?? Environment::factory()->create(['project_id' => $otherProject->id]); + $otherDatabase = StandalonePostgresql::create([ + 'name' => 'other-db', + 'image' => 'postgres:17-alpine', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'postgres', + 'environment_id' => $otherEnvironment->id, + 'destination_id' => $otherDestination->id, + 'destination_type' => $otherDestination->getMorphClass(), + ]); + + $this->withHeaders($this->headers) + ->postJson("/api/v1/databases/{$otherDatabase->uuid}/clone", [ + 'destination_uuid' => $this->destination->uuid, + ]) + ->assertNotFound(); + }); +}); + +describe('POST /api/v1/services/{uuid}/clone', function () { + test('clones a service and parses applications from compose', function () { + $service = Service::factory()->create([ + 'name' => 'source-service', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'server_id' => $this->server->id, + 'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n", + 'compose_parsing_version' => '5', + ]); + $service->parse(); + expect($service->applications()->count())->toBeGreaterThan(0); + + $response = $this->withHeaders($this->headers) + ->postJson("/api/v1/services/{$service->uuid}/clone", [ + 'destination_uuid' => $this->destination->uuid, + 'name' => 'cloned-service', + ]); + + $response->assertCreated() + ->assertJsonPath('message', 'Service cloned.'); + + $cloned = Service::where('uuid', $response->json('uuid'))->first(); + expect($cloned)->not->toBeNull() + ->and($cloned->name)->toBe('cloned-service') + ->and($cloned->environment_id)->toBe($service->environment_id) + ->and($cloned->applications()->count())->toBe($service->applications()->count()); + }); + + test('returns 404 for another team service', function () { + $otherTeam = Team::factory()->create(); + $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]); + $otherDestination = StandaloneDocker::where('server_id', $otherServer->id)->firstOrFail(); + $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]); + $otherEnvironment = $otherProject->environments()->first() + ?? Environment::factory()->create(['project_id' => $otherProject->id]); + $otherService = Service::factory()->create([ + 'environment_id' => $otherEnvironment->id, + 'destination_id' => $otherDestination->id, + 'destination_type' => $otherDestination->getMorphClass(), + 'server_id' => $otherServer->id, + 'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n", + ]); + + $this->withHeaders($this->headers) + ->postJson("/api/v1/services/{$otherService->uuid}/clone", [ + 'destination_uuid' => $this->destination->uuid, + ]) + ->assertNotFound(); + }); +}); + +describe('POST /api/v1/applications/{uuid}/scheduled-tasks/{task_uuid}/execute', function () { + test('dispatches ScheduledTaskJob for an application task', function () { + Queue::fake(); + + $task = ScheduledTask::factory()->create([ + 'application_id' => $this->application->id, + 'team_id' => $this->team->id, + 'name' => 'nightly', + ]); + + $response = $this->withHeaders($this->headers) + ->postJson("/api/v1/applications/{$this->application->uuid}/scheduled-tasks/{$task->uuid}/execute"); + + $response->assertOk() + ->assertJsonPath('message', 'Scheduled task execution queued.'); + + Queue::assertPushed(ScheduledTaskJob::class, fn (ScheduledTaskJob $job) => $job->task->is($task)); + }); + + test('returns 404 for unknown task', function () { + $response = $this->withHeaders($this->headers) + ->postJson("/api/v1/applications/{$this->application->uuid}/scheduled-tasks/missing-task/execute"); + + $response->assertNotFound(); + }); +}); + +describe('PATCH /api/v1/databases/{uuid} health check fields', function () { + test('can update database health_check fields', function () { + $database = StandalonePostgresql::create([ + 'name' => 'pg-health', + 'uuid' => (string) Str::uuid(), + 'postgres_password' => 'secret', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders($this->headers) + ->patchJson("/api/v1/databases/{$database->uuid}", [ + 'health_check_enabled' => false, + 'health_check_interval' => 30, + 'health_check_timeout' => 10, + 'health_check_retries' => 3, + 'health_check_start_period' => 20, + ]); + + $response->assertOk(); + + $database->refresh(); + expect($database->health_check_enabled)->toBeFalse() + ->and($database->health_check_interval)->toBe(30) + ->and($database->health_check_timeout)->toBe(10) + ->and($database->health_check_retries)->toBe(3) + ->and($database->health_check_start_period)->toBe(20); + }); +}); + +describe('Cloud-init scripts CRUD', function () { + test('creates lists updates and deletes cloud-init scripts for the team', function () { + $create = $this->withHeaders($this->headers) + ->postJson('/api/v1/cloud-init-scripts', [ + 'name' => 'bootstrap', + 'script' => "#!/bin/bash\necho hello", + ]); + + $create->assertCreated() + ->assertJsonPath('name', 'bootstrap') + ->assertJsonStructure(['uuid', 'name']); + + $uuid = $create->json('uuid'); + + $list = $this->withHeaders($this->headers) + ->getJson('/api/v1/cloud-init-scripts'); + $list->assertOk(); + expect(collect($list->json())->pluck('uuid'))->toContain($uuid); + + $show = $this->withHeaders($this->headers) + ->getJson("/api/v1/cloud-init-scripts/{$uuid}"); + $show->assertOk()->assertJsonPath('name', 'bootstrap'); + + $update = $this->withHeaders($this->headers) + ->patchJson("/api/v1/cloud-init-scripts/{$uuid}", [ + 'name' => 'bootstrap-v2', + ]); + $update->assertOk()->assertJsonPath('name', 'bootstrap-v2'); + + $delete = $this->withHeaders($this->headers) + ->deleteJson("/api/v1/cloud-init-scripts/{$uuid}"); + $delete->assertOk(); + + expect(CloudInitScript::where('uuid', $uuid)->exists())->toBeFalse(); + }); + + test('returns 404 for another team cloud-init script', function () { + $otherTeam = Team::factory()->create(); + $script = CloudInitScript::create([ + 'team_id' => $otherTeam->id, + 'name' => 'other', + 'script' => "#!/bin/bash\necho other", + ]); + + $this->withHeaders($this->headers) + ->getJson("/api/v1/cloud-init-scripts/{$script->uuid}") + ->assertNotFound(); + + $this->withHeaders($this->headers) + ->patchJson("/api/v1/cloud-init-scripts/{$script->uuid}", ['name' => 'nope']) + ->assertNotFound(); + + $this->withHeaders($this->headers) + ->deleteJson("/api/v1/cloud-init-scripts/{$script->uuid}") + ->assertNotFound(); + }); +}); + +describe('Application multi-destination cross-team', function () { + test('returns 404 when attaching another team destination', function () { + $otherTeam = Team::factory()->create(); + $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]); + $otherDestination = StandaloneDocker::where('server_id', $otherServer->id)->firstOrFail(); + + $response = $this->withHeaders($this->headers) + ->postJson("/api/v1/applications/{$this->application->uuid}/destinations", [ + 'destination_uuid' => $otherDestination->uuid, + ]); + + $response->assertNotFound(); + expect($this->application->fresh()->additional_networks)->toHaveCount(0); + }); + + test('lists primary destination', function () { + $response = $this->withHeaders($this->headers) + ->getJson("/api/v1/applications/{$this->application->uuid}/destinations"); + + $response->assertOk(); + expect($response->json())->toHaveCount(1) + ->and($response->json('0.uuid'))->toBe($this->destination->uuid) + ->and($response->json('0.is_primary'))->toBeTrue(); + }); +}); diff --git a/tests/Feature/Api/NotificationsApiTest.php b/tests/Feature/Api/NotificationsApiTest.php new file mode 100644 index 000000000..647c327de --- /dev/null +++ b/tests/Feature/Api/NotificationsApiTest.php @@ -0,0 +1,342 @@ + 'file', + 'cache.default' => 'array', + 'session.driver' => 'array', + ]); + + InstanceSettings::query()->whereKey(0)->delete(); + $settings = new InstanceSettings(['is_api_enabled' => true]); + $settings->id = 0; + $settings->save(); + Once::flush(); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + session(['currentTeam' => $this->team]); + + $this->token = $this->user->createToken('test-token', ['*']); + $this->bearerToken = $this->token->plainTextToken; +}); + +function authHeaders(string $token): array +{ + return [ + 'Authorization' => 'Bearer '.$token, + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + ]; +} + +describe('GET /api/v1/notifications/*', function () { + test('returns email notification settings for the current team', function () { + $response = $this->withHeaders(authHeaders($this->bearerToken)) + ->getJson('/api/v1/notifications/email'); + + $response->assertSuccessful(); + $response->assertJsonFragment([ + 'team_id' => $this->team->id, + 'smtp_enabled' => false, + ]); + $response->assertJsonStructure([ + 'team_id', + 'smtp_enabled', + 'deployment_failure_email_notifications', + 'use_instance_email_settings', + ]); + }); + + test('returns settings for every notification channel', function (string $channel) { + $response = $this->withHeaders(authHeaders($this->bearerToken)) + ->getJson("/api/v1/notifications/{$channel}"); + + $response->assertSuccessful(); + $response->assertJsonPath('team_id', $this->team->id); + })->with([ + 'email', + 'discord', + 'slack', + 'telegram', + 'pushover', + 'webhook', + ]); + + test('hides encrypted secrets without read:sensitive ability', function () { + $this->team->discordNotificationSettings->update([ + 'discord_webhook_url' => 'https://discord.com/api/webhooks/hidden/secret', + 'discord_enabled' => true, + ]); + + $readToken = $this->user->createToken('read-token', ['read'])->plainTextToken; + + $response = $this->withHeaders(authHeaders($readToken)) + ->getJson('/api/v1/notifications/discord'); + + $response->assertSuccessful(); + $response->assertJsonMissingPath('discord_webhook_url'); + expect($response->getContent())->not->toContain('hidden/secret'); + }); + + test('includes encrypted secrets with read:sensitive ability for admins', function () { + $this->team->discordNotificationSettings->update([ + 'discord_webhook_url' => 'https://discord.com/api/webhooks/visible/secret-token', + 'discord_enabled' => true, + ]); + + $sensitiveToken = $this->user->createToken('read-sensitive-token', ['read', 'read:sensitive'])->plainTextToken; + + $response = $this->withHeaders(authHeaders($sensitiveToken)) + ->getJson('/api/v1/notifications/discord'); + + $response->assertSuccessful(); + $response->assertJsonFragment([ + 'discord_webhook_url' => 'https://discord.com/api/webhooks/visible/secret-token', + ]); + }); + + test('member with read token can view settings but not secrets', function () { + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + session(['currentTeam' => $this->team]); + + $this->team->emailNotificationSettings->update([ + 'smtp_password' => 'super-secret-password', + 'smtp_enabled' => true, + ]); + + $memberToken = $member->createToken('member-read', ['read'])->plainTextToken; + + $response = $this->withHeaders(authHeaders($memberToken)) + ->getJson('/api/v1/notifications/email'); + + $response->assertSuccessful(); + $response->assertJsonPath('smtp_enabled', true); + $response->assertJsonMissingPath('smtp_password'); + expect($response->getContent())->not->toContain('super-secret-password'); + }); + + test('member cannot use read:sensitive token ability', function () { + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + session(['currentTeam' => $this->team]); + + $memberToken = $member->createToken('member-sensitive', ['read', 'read:sensitive'])->plainTextToken; + + $this->withHeaders(authHeaders($memberToken)) + ->getJson('/api/v1/notifications/email') + ->assertForbidden(); + }); + + test('rejects unauthenticated requests', function () { + $this->getJson('/api/v1/notifications/email') + ->assertStatus(401); + }); + + test('firstOrCreate restores missing channel settings', function () { + DiscordNotificationSettings::query()->where('team_id', $this->team->id)->delete(); + + expect(DiscordNotificationSettings::query()->where('team_id', $this->team->id)->exists())->toBeFalse(); + + $response = $this->withHeaders(authHeaders($this->bearerToken)) + ->getJson('/api/v1/notifications/discord'); + + $response->assertSuccessful(); + $response->assertJsonPath('team_id', $this->team->id); + expect(DiscordNotificationSettings::query()->where('team_id', $this->team->id)->exists())->toBeTrue(); + }); +}); + +describe('PATCH /api/v1/notifications/*', function () { + test('updates email notification settings', function () { + $response = $this->withHeaders(authHeaders($this->bearerToken)) + ->patchJson('/api/v1/notifications/email', [ + 'smtp_enabled' => true, + 'smtp_from_address' => 'alerts@example.com', + 'smtp_host' => 'smtp.example.com', + 'smtp_port' => 587, + 'smtp_encryption' => 'starttls', + 'deployment_failure_email_notifications' => false, + ]); + + $response->assertSuccessful(); + $response->assertJsonPath('smtp_enabled', true); + $response->assertJsonPath('deployment_failure_email_notifications', false); + + $settings = EmailNotificationSettings::query()->where('team_id', $this->team->id)->first(); + expect($settings->smtp_enabled)->toBeTrue() + ->and($settings->smtp_from_address)->toBe('alerts@example.com') + ->and($settings->smtp_host)->toBe('smtp.example.com') + ->and($settings->smtp_port)->toBe(587) + ->and($settings->deployment_failure_email_notifications)->toBeFalse(); + }); + + test('updates discord notification settings', function () { + $response = $this->withHeaders(authHeaders($this->bearerToken)) + ->patchJson('/api/v1/notifications/discord', [ + 'discord_enabled' => true, + 'discord_webhook_url' => 'https://discord.com/api/webhooks/123/abc', + 'discord_ping_enabled' => false, + 'deployment_success_discord_notifications' => true, + ]); + + $response->assertSuccessful(); + $response->assertJsonPath('discord_enabled', true); + $response->assertJsonPath('discord_ping_enabled', false); + + $settings = DiscordNotificationSettings::query()->where('team_id', $this->team->id)->first(); + expect($settings->discord_enabled)->toBeTrue() + ->and($settings->discord_webhook_url)->toBe('https://discord.com/api/webhooks/123/abc') + ->and($settings->discord_ping_enabled)->toBeFalse() + ->and($settings->deployment_success_discord_notifications)->toBeTrue(); + }); + + test('updates slack, telegram, pushover, and webhook channels', function (string $channel, array $payload, string $modelClass, string $enabledField) { + $response = $this->withHeaders(authHeaders($this->bearerToken)) + ->patchJson("/api/v1/notifications/{$channel}", $payload); + + $response->assertSuccessful(); + $response->assertJsonPath($enabledField, true); + + $settings = $modelClass::query()->where('team_id', $this->team->id)->first(); + expect($settings->{$enabledField})->toBeTrue(); + })->with([ + 'slack' => [ + 'slack', + [ + 'slack_enabled' => true, + 'slack_webhook_url' => 'https://hooks.slack.com/services/T00/B00/xxx', + 'deployment_failure_slack_notifications' => true, + ], + SlackNotificationSettings::class, + 'slack_enabled', + ], + 'telegram' => [ + 'telegram', + [ + 'telegram_enabled' => true, + 'telegram_token' => '123456:ABC-DEF', + 'telegram_chat_id' => '-100123', + 'deployment_failure_telegram_notifications' => true, + ], + TelegramNotificationSettings::class, + 'telegram_enabled', + ], + 'pushover' => [ + 'pushover', + [ + 'pushover_enabled' => true, + 'pushover_user_key' => 'user-key', + 'pushover_api_token' => 'api-token', + 'deployment_failure_pushover_notifications' => true, + ], + PushoverNotificationSettings::class, + 'pushover_enabled', + ], + 'webhook' => [ + 'webhook', + [ + 'webhook_enabled' => true, + 'webhook_url' => 'https://example.com/hooks/coolify', + 'deployment_failure_webhook_notifications' => true, + ], + WebhookNotificationSettings::class, + 'webhook_enabled', + ], + ]); + + test('rejects unknown fields with 422', function () { + $response = $this->withHeaders(authHeaders($this->bearerToken)) + ->patchJson('/api/v1/notifications/email', [ + 'smtp_enabled' => true, + 'not_a_real_field' => 'nope', + ]); + + $response->assertStatus(422); + $response->assertJsonPath('errors.not_a_real_field.0', 'This field is not allowed.'); + }); + + test('rejects team_id mass assignment attempts', function () { + $otherTeam = Team::factory()->create(); + + $response = $this->withHeaders(authHeaders($this->bearerToken)) + ->patchJson('/api/v1/notifications/discord', [ + 'discord_enabled' => true, + 'team_id' => $otherTeam->id, + ]); + + $response->assertStatus(422); + $response->assertJsonPath('errors.team_id.0', 'This field is not allowed.'); + }); + + test('requires write ability', function () { + $readToken = $this->user->createToken('read-only', ['read'])->plainTextToken; + + $response = $this->withHeaders(authHeaders($readToken)) + ->patchJson('/api/v1/notifications/email', [ + 'smtp_enabled' => true, + ]); + + $response->assertForbidden(); + }); + + test('forbids members from updating notification settings', function () { + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + session(['currentTeam' => $this->team]); + $memberToken = $member->createToken('member-write', ['read', 'write'])->plainTextToken; + + $response = $this->withHeaders(authHeaders($memberToken)) + ->patchJson('/api/v1/notifications/email', [ + 'smtp_enabled' => true, + ]); + + $response->assertForbidden(); + }); + + test('rejects empty json body', function () { + $response = $this->withHeaders(authHeaders($this->bearerToken)) + ->patchJson('/api/v1/notifications/email', []); + + $response->assertStatus(400); + }); + + test('does not leak updates across teams', function () { + $otherTeam = Team::factory()->create(); + $otherUser = User::factory()->create(); + $otherTeam->members()->attach($otherUser->id, ['role' => 'owner']); + session(['currentTeam' => $otherTeam]); + $otherToken = $otherUser->createToken('other-token', ['*'])->plainTextToken; + + $this->withHeaders(authHeaders($otherToken)) + ->patchJson('/api/v1/notifications/email', [ + 'smtp_enabled' => true, + 'smtp_from_address' => 'other@example.com', + ]) + ->assertSuccessful(); + + $thisTeamSettings = EmailNotificationSettings::query()->where('team_id', $this->team->id)->first(); + $otherSettings = EmailNotificationSettings::query()->where('team_id', $otherTeam->id)->first(); + + expect($thisTeamSettings->smtp_enabled)->toBeFalse() + ->and($otherSettings->smtp_enabled)->toBeTrue() + ->and($otherSettings->smtp_from_address)->toBe('other@example.com'); + }); +}); diff --git a/tests/Feature/Api/S3StoragesApiTest.php b/tests/Feature/Api/S3StoragesApiTest.php new file mode 100644 index 000000000..f2bd3d5b5 --- /dev/null +++ b/tests/Feature/Api/S3StoragesApiTest.php @@ -0,0 +1,543 @@ + 'file', + 'cache.default' => 'array', + 'session.driver' => 'array', + ]); + + InstanceSettings::query()->whereKey(0)->delete(); + $settings = new InstanceSettings(['is_api_enabled' => true]); + $settings->id = 0; + $settings->save(); + Once::flush(); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + session(['currentTeam' => $this->team]); + + $this->token = $this->user->createToken('test-token', ['*']); + $this->bearerToken = $this->token->plainTextToken; +}); + +function createS3StorageForTeam(Team $team, array $overrides = []): S3Storage +{ + return S3Storage::create(array_merge([ + 'team_id' => $team->id, + 'name' => 'Team S3 Storage', + 'description' => 'Test storage', + 'region' => 'us-east-1', + 'key' => 'test-access-key', + 'secret' => 'test-secret-key', + 'bucket' => 'test-bucket', + 'endpoint' => 'https://s3.amazonaws.com', + 'is_usable' => false, + ], $overrides)); +} + +function validS3StoragePayload(array $overrides = []): array +{ + return array_merge([ + 'name' => 'My S3 Storage', + 'description' => 'Backup storage', + 'endpoint' => 'https://s3.amazonaws.com', + 'bucket' => 'coolify-backups', + 'region' => 'us-east-1', + 'key' => 'AKIAEXAMPLEKEY', + 'secret' => 'example-secret-value', + ], $overrides); +} + +describe('GET /api/v1/s3-storages', function () { + test('lists all s3 storages for the team', function () { + createS3StorageForTeam($this->team, ['name' => 'Storage One', 'bucket' => 'bucket-one']); + createS3StorageForTeam($this->team, ['name' => 'Storage Two', 'bucket' => 'bucket-two']); + createS3StorageForTeam($this->team, ['name' => 'Storage Three', 'bucket' => 'bucket-three']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->getJson('/api/v1/s3-storages'); + + $response->assertStatus(200); + $response->assertJsonCount(3); + $response->assertJsonStructure([ + '*' => ['uuid', 'name', 'description', 'endpoint', 'bucket', 'region', 'is_usable', 'team_id', 'created_at', 'updated_at'], + ]); + }); + + test('does not include storages from other teams', function () { + createS3StorageForTeam($this->team); + + $otherTeam = Team::factory()->create(); + createS3StorageForTeam($otherTeam, ['name' => 'Other Team Storage', 'bucket' => 'other-bucket']); + createS3StorageForTeam($otherTeam, ['name' => 'Other Team Storage 2', 'bucket' => 'other-bucket-2']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->getJson('/api/v1/s3-storages'); + + $response->assertStatus(200); + $response->assertJsonCount(1); + }); + + test('rejects request without authentication', function () { + $response = $this->getJson('/api/v1/s3-storages'); + $response->assertStatus(401); + }); + + test('read token does not include key and secret values', function () { + createS3StorageForTeam($this->team, [ + 'key' => 'hidden-access-key', + 'secret' => 'hidden-secret-key', + ]); + + $readToken = $this->user->createToken('read-token', ['read'])->plainTextToken; + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$readToken, + 'Content-Type' => 'application/json', + ])->getJson('/api/v1/s3-storages'); + + $response->assertSuccessful(); + expect($response->getContent())->not->toContain('hidden-access-key'); + expect($response->getContent())->not->toContain('hidden-secret-key'); + expect($response->getContent())->not->toContain('"key":'); + expect($response->getContent())->not->toContain('"secret":'); + }); + + test('read sensitive token includes key and secret values', function () { + createS3StorageForTeam($this->team, [ + 'key' => 'visible-access-key', + 'secret' => 'visible-secret-key', + ]); + + $readSensitiveToken = $this->user->createToken('read-sensitive-token', ['read', 'read:sensitive'])->plainTextToken; + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$readSensitiveToken, + 'Content-Type' => 'application/json', + ])->getJson('/api/v1/s3-storages'); + + $response->assertSuccessful(); + $response->assertJsonFragment([ + 'key' => 'visible-access-key', + 'secret' => 'visible-secret-key', + ]); + }); + + test('root token includes key and secret values', function () { + createS3StorageForTeam($this->team, [ + 'key' => 'root-access-key', + 'secret' => 'root-secret-key', + ]); + + $rootToken = $this->user->createToken('root-token', ['root'])->plainTextToken; + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$rootToken, + 'Content-Type' => 'application/json', + ])->getJson('/api/v1/s3-storages'); + + $response->assertSuccessful(); + $response->assertJsonFragment([ + 'key' => 'root-access-key', + 'secret' => 'root-secret-key', + ]); + }); +}); + +describe('GET /api/v1/s3-storages/{uuid}', function () { + test('gets s3 storage by UUID', function () { + $storage = createS3StorageForTeam($this->team, [ + 'name' => 'Primary Backup', + 'bucket' => 'primary-backup', + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->getJson("/api/v1/s3-storages/{$storage->uuid}"); + + $response->assertStatus(200); + $response->assertJsonFragment([ + 'name' => 'Primary Backup', + 'bucket' => 'primary-backup', + 'region' => 'us-east-1', + ]); + }); + + test('returns 404 for non-existent storage', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->getJson('/api/v1/s3-storages/non-existent-uuid'); + + $response->assertStatus(404); + }); + + test('cannot access storage from another team', function () { + $otherTeam = Team::factory()->create(); + $storage = createS3StorageForTeam($otherTeam); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->getJson("/api/v1/s3-storages/{$storage->uuid}"); + + $response->assertStatus(404); + }); + + test('read token does not include key and secret by UUID', function () { + $storage = createS3StorageForTeam($this->team, [ + 'key' => 'hidden-detail-key', + 'secret' => 'hidden-detail-secret', + ]); + + $readToken = $this->user->createToken('read-token', ['read'])->plainTextToken; + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$readToken, + 'Content-Type' => 'application/json', + ])->getJson("/api/v1/s3-storages/{$storage->uuid}"); + + $response->assertSuccessful(); + expect($response->getContent())->not->toContain('hidden-detail-key'); + expect($response->getContent())->not->toContain('hidden-detail-secret'); + }); + + test('read sensitive token includes key and secret by UUID', function () { + $storage = createS3StorageForTeam($this->team, [ + 'key' => 'visible-detail-key', + 'secret' => 'visible-detail-secret', + ]); + + $readSensitiveToken = $this->user->createToken('read-sensitive-token', ['read', 'read:sensitive'])->plainTextToken; + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$readSensitiveToken, + 'Content-Type' => 'application/json', + ])->getJson("/api/v1/s3-storages/{$storage->uuid}"); + + $response->assertSuccessful(); + $response->assertJsonFragment([ + 'key' => 'visible-detail-key', + 'secret' => 'visible-detail-secret', + ]); + }); +}); + +describe('POST /api/v1/s3-storages', function () { + test('creates an s3 storage', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/s3-storages', validS3StoragePayload()); + + $response->assertStatus(201); + $response->assertJsonStructure(['uuid']); + + $this->assertDatabaseHas('s3_storages', [ + 'team_id' => $this->team->id, + 'name' => 'My S3 Storage', + 'bucket' => 'coolify-backups', + 'region' => 'us-east-1', + ]); + }); + + test('validates name is required', function () { + $payload = validS3StoragePayload(); + unset($payload['name']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/s3-storages', $payload); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['name']); + }); + + test('validates key is required', function () { + $payload = validS3StoragePayload(); + unset($payload['key']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/s3-storages', $payload); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['key']); + }); + + test('validates secret is required', function () { + $payload = validS3StoragePayload(); + unset($payload['secret']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/s3-storages', $payload); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['secret']); + }); + + test('validates bucket format', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/s3-storages', validS3StoragePayload([ + 'bucket' => 'Invalid_Bucket', + ])); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['bucket']); + }); + + test('rejects unsafe endpoints', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/s3-storages', validS3StoragePayload([ + 'endpoint' => 'http://127.0.0.1:9000', + ])); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['endpoint']); + }); + + test('rejects extra fields not in allowed list', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/s3-storages', validS3StoragePayload([ + 'invalid_field' => 'invalid_value', + ])); + + $response->assertStatus(422); + }); +}); + +describe('PATCH /api/v1/s3-storages/{uuid}', function () { + test('updates s3 storage name', function () { + $storage = createS3StorageForTeam($this->team, ['name' => 'Old Name']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/s3-storages/{$storage->uuid}", [ + 'name' => 'New Name', + ]); + + $response->assertStatus(200); + + $this->assertDatabaseHas('s3_storages', [ + 'uuid' => $storage->uuid, + 'name' => 'New Name', + ]); + }); + + test('updates multiple fields', function () { + $storage = createS3StorageForTeam($this->team); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/s3-storages/{$storage->uuid}", [ + 'name' => 'Updated Storage', + 'region' => 'eu-west-1', + 'bucket' => 'updated-bucket', + ]); + + $response->assertStatus(200); + + $this->assertDatabaseHas('s3_storages', [ + 'uuid' => $storage->uuid, + 'name' => 'Updated Storage', + 'region' => 'eu-west-1', + 'bucket' => 'updated-bucket', + ]); + }); + + test('rejects empty body', function () { + $storage = createS3StorageForTeam($this->team); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/s3-storages/{$storage->uuid}", []); + + $response->assertStatus(400); + $response->assertJson([ + 'message' => 'Invalid request.', + 'error' => 'Invalid JSON.', + ]); + }); + + test('cannot update storage from another team', function () { + $otherTeam = Team::factory()->create(); + $storage = createS3StorageForTeam($otherTeam); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/s3-storages/{$storage->uuid}", [ + 'name' => 'New Name', + ]); + + $response->assertStatus(404); + }); + + test('rejects extra fields on update', function () { + $storage = createS3StorageForTeam($this->team); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->patchJson("/api/v1/s3-storages/{$storage->uuid}", [ + 'name' => 'New Name', + 'team_id' => 999, + ]); + + $response->assertStatus(422); + }); +}); + +describe('DELETE /api/v1/s3-storages/{uuid}', function () { + test('deletes s3 storage', function () { + $storage = createS3StorageForTeam($this->team); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->deleteJson("/api/v1/s3-storages/{$storage->uuid}"); + + $response->assertStatus(200); + $response->assertJson(['message' => 'S3 storage deleted.']); + + $this->assertDatabaseMissing('s3_storages', [ + 'uuid' => $storage->uuid, + ]); + }); + + test('cannot delete storage from another team', function () { + $otherTeam = Team::factory()->create(); + $storage = createS3StorageForTeam($otherTeam); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->deleteJson("/api/v1/s3-storages/{$storage->uuid}"); + + $response->assertStatus(404); + }); + + test('returns 404 for non-existent storage', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->deleteJson('/api/v1/s3-storages/non-existent-uuid'); + + $response->assertStatus(404); + }); +}); + +describe('POST /api/v1/s3-storages/{uuid}/validate', function () { + test('validates a working s3 storage connection', function () { + $storage = createS3StorageForTeam($this->team); + + $disk = Mockery::mock(); + $disk->expects('files')->once()->andReturn([]); + Storage::expects('build')->once()->andReturn($disk); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson("/api/v1/s3-storages/{$storage->uuid}/validate"); + + $response->assertStatus(200); + $response->assertJson([ + 'valid' => true, + 'message' => 'S3 storage connection is valid.', + ]); + + expect($storage->fresh()->is_usable)->toBeTrue(); + }); + + test('detects an invalid s3 storage connection', function () { + $storage = createS3StorageForTeam($this->team, ['is_usable' => true]); + + $disk = Mockery::mock(); + $disk->expects('files') + ->once() + ->andThrow(new RuntimeException('Access Denied')); + Storage::expects('build')->once()->andReturn($disk); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson("/api/v1/s3-storages/{$storage->uuid}/validate"); + + $response->assertStatus(200); + $response->assertJson([ + 'valid' => false, + 'message' => 'Access Denied', + ]); + + expect($storage->fresh()->is_usable)->toBeFalse(); + }); + + test('cannot validate storage from another team', function () { + $otherTeam = Team::factory()->create(); + $storage = createS3StorageForTeam($otherTeam); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson("/api/v1/s3-storages/{$storage->uuid}/validate"); + + $response->assertStatus(404); + }); + + test('writes an audit log entry when validating storage', function () { + $storage = createS3StorageForTeam($this->team, ['name' => 'Audit Storage']); + + $disk = Mockery::mock(); + $disk->expects('files')->once()->andReturn([]); + Storage::expects('build')->once()->andReturn($disk); + + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('info') + ->once() + ->with('api.s3_storage.validated', Mockery::on(function (array $context) use ($storage) { + return $context['s3_storage_uuid'] === $storage->uuid + && $context['s3_storage_name'] === 'Audit Storage' + && $context['valid'] === true; + })); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + + $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson("/api/v1/s3-storages/{$storage->uuid}/validate") + ->assertOk(); + }); +}); diff --git a/tests/Feature/Api/ServerProxyApiTest.php b/tests/Feature/Api/ServerProxyApiTest.php new file mode 100644 index 000000000..03c88be2a --- /dev/null +++ b/tests/Feature/Api/ServerProxyApiTest.php @@ -0,0 +1,231 @@ + 'array', + 'session.driver' => 'array', + 'queue.default' => 'sync', + 'app.maintenance.driver' => 'file', + ]); + + InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate( + ['id' => 0], + ['is_api_enabled' => true], + )); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + session(['currentTeam' => $this->team]); + + $this->bearerToken = serverProxyApiToken($this->user, $this->team, ['*']); + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->server->proxy->set('type', ProxyTypes::TRAEFIK->value); + $this->server->proxy->set('status', 'exited'); + $this->server->proxy->redirect_enabled = true; + $this->server->save(); +}); + +function serverProxyApiHeaders(string $bearerToken): array +{ + return [ + 'Authorization' => 'Bearer '.$bearerToken, + 'Content-Type' => 'application/json', + ]; +} + +function serverProxyApiToken(User $user, Team $team, array $abilities): string +{ + $plainTextToken = Str::random(40); + $token = $user->tokens()->create([ + 'name' => 'server-proxy-api-test-'.Str::random(6), + 'token' => hash('sha256', $plainTextToken), + 'abilities' => $abilities, + 'team_id' => $team->id, + ]); + + return $token->getKey().'|'.$plainTextToken; +} + +test('GET /api/v1/servers/{uuid}/proxy returns proxy settings without configuration when none is stored', function () { + $sensitiveToken = serverProxyApiToken($this->user, $this->team, ['read', 'read:sensitive']); + + $this->withHeaders(serverProxyApiHeaders($sensitiveToken)) + ->getJson("/api/v1/servers/{$this->server->uuid}/proxy") + ->assertOk() + ->assertJsonPath('proxy_type', ProxyTypes::TRAEFIK->value) + ->assertJsonPath('redirect_enabled', true) + ->assertJsonPath('redirect_url', null) + ->assertJsonPath('generate_exact_labels', false) + ->assertJsonPath('configuration', null); +}); + +test('GET /api/v1/servers/{uuid}/proxy omits stored configuration without read:sensitive', function () { + $compose = "services:\n traefik:\n image: traefik:v3.5\n"; + $this->server->proxy->set('last_saved_proxy_configuration', $compose); + $this->server->save(); + + // '*' tokens grant all abilities including read:sensitive; use a read-only token. + $readToken = serverProxyApiToken($this->user, $this->team, ['read']); + + $this->withHeaders(serverProxyApiHeaders($readToken)) + ->getJson("/api/v1/servers/{$this->server->uuid}/proxy") + ->assertOk() + ->assertJsonMissingPath('configuration'); +}); + +test('GET /api/v1/servers/{uuid}/proxy returns stored configuration with read:sensitive for admins', function () { + $compose = "services:\n traefik:\n image: traefik:v3.5\n"; + $this->server->proxy->set('last_saved_proxy_configuration', $compose); + $this->server->save(); + + $sensitiveToken = serverProxyApiToken($this->user, $this->team, ['read', 'read:sensitive']); + + $this->withHeaders(serverProxyApiHeaders($sensitiveToken)) + ->getJson("/api/v1/servers/{$this->server->uuid}/proxy") + ->assertOk() + ->assertJsonPath('configuration', $compose); +}); + +test('GET /api/v1/servers/{uuid}/proxy hides configuration from non-admin users with read:sensitive', function () { + $compose = "services:\n traefik:\n image: traefik:v3.5\n"; + $this->server->proxy->set('last_saved_proxy_configuration', $compose); + $this->server->save(); + + // ApiSensitiveData requires admin/owner of the token team even with read:sensitive. + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + session(['currentTeam' => $this->team]); + $memberToken = serverProxyApiToken($member, $this->team, ['read', 'read:sensitive']); + + // Members may be forbidden from viewing servers via policy; when allowed, config must still be hidden. + $response = $this->withHeaders(serverProxyApiHeaders($memberToken)) + ->getJson("/api/v1/servers/{$this->server->uuid}/proxy"); + + if ($response->status() === 200) { + $response->assertJsonMissingPath('configuration'); + } else { + $response->assertForbidden(); + } +}); + +test('GET /api/v1/servers/{uuid}/proxy does not expose another team server', function () { + $otherTeam = Team::factory()->create(); + $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]); + + $this->withHeaders(serverProxyApiHeaders($this->bearerToken)) + ->getJson("/api/v1/servers/{$otherServer->uuid}/proxy") + ->assertNotFound(); +}); + +test('PATCH /api/v1/servers/{uuid}/proxy updates redirect and label settings without SSH', function () { + // Factory servers are not reachable, so setupDefaultRedirect is skipped. + $this->withHeaders(serverProxyApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/servers/{$this->server->uuid}/proxy", [ + 'redirect_enabled' => false, + 'redirect_url' => null, + 'generate_exact_labels' => true, + ]) + ->assertOk() + ->assertJsonPath('redirect_enabled', false) + ->assertJsonPath('redirect_url', null) + ->assertJsonPath('generate_exact_labels', true); + + $server = $this->server->fresh(); + + expect((bool) data_get($server->proxy, 'redirect_enabled'))->toBeFalse() + ->and(data_get($server->proxy, 'redirect_url'))->toBeNull() + ->and((bool) $server->settings->generate_exact_labels)->toBeTrue(); +}); + +test('PATCH /api/v1/servers/{uuid}/proxy rejects unknown fields', function () { + $this->withHeaders(serverProxyApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/servers/{$this->server->uuid}/proxy", [ + 'redirect_enabled' => true, + 'unknown_field' => 'nope', + ]) + ->assertUnprocessable() + ->assertJsonPath('errors.unknown_field.0', 'This field is not allowed.'); +}); + +test('PATCH /api/v1/servers/{uuid}/proxy rejects invalid proxy type', function () { + $this->withHeaders(serverProxyApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/servers/{$this->server->uuid}/proxy", [ + 'proxy_type' => 'haproxy', + ]) + ->assertUnprocessable() + ->assertJsonPath('errors.proxy_type.0', 'Invalid proxy type.'); +}); + +test('PATCH /api/v1/servers/{uuid}/proxy can change proxy type asynchronously', function () { + StartProxy::shouldRun()->andReturn('OK'); + + $this->withHeaders(serverProxyApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/servers/{$this->server->uuid}/proxy", [ + 'proxy_type' => 'caddy', + ]) + ->assertOk() + ->assertJsonPath('proxy_type', ProxyTypes::CADDY->value); + + expect($this->server->fresh()->proxyType())->toBe(ProxyTypes::CADDY->value); +}); + +test('PUT /api/v1/servers/{uuid}/proxy/configuration saves base64 configuration via action', function () { + $compose = "services:\n traefik:\n image: traefik:v3.5\n"; + + SaveProxyConfiguration::shouldRun() + ->once() + ->withArgs(function (Server $server, string $configuration) use ($compose) { + return $server->is($this->server) && $configuration === $compose; + }); + + $this->withHeaders(serverProxyApiHeaders($this->bearerToken)) + ->putJson("/api/v1/servers/{$this->server->uuid}/proxy/configuration", [ + 'configuration' => base64_encode($compose), + ]) + ->assertOk() + ->assertJsonPath('message', 'Proxy configuration saved.'); +}); + +test('PUT /api/v1/servers/{uuid}/proxy/configuration rejects missing configuration', function () { + $this->withHeaders(serverProxyApiHeaders($this->bearerToken)) + ->putJson("/api/v1/servers/{$this->server->uuid}/proxy/configuration", [ + 'foo' => 'bar', + ]) + ->assertUnprocessable(); +}); + +test('POST /api/v1/servers/{uuid}/proxy/restart queues RestartProxyJob', function () { + Queue::fake(); + + $this->withHeaders(serverProxyApiHeaders($this->bearerToken)) + ->postJson("/api/v1/servers/{$this->server->uuid}/proxy/restart") + ->assertOk() + ->assertJsonPath('message', 'Proxy restart queued.'); + + Queue::assertPushed( + RestartProxyJob::class, + fn (RestartProxyJob $job): bool => $job->server->is($this->server) + ); +}); + +test('proxy endpoints require authentication', function () { + $this->getJson("/api/v1/servers/{$this->server->uuid}/proxy")->assertUnauthorized(); + $this->patchJson("/api/v1/servers/{$this->server->uuid}/proxy", ['redirect_enabled' => false])->assertUnauthorized(); + $this->putJson("/api/v1/servers/{$this->server->uuid}/proxy/configuration", ['configuration' => 'x'])->assertUnauthorized(); + $this->postJson("/api/v1/servers/{$this->server->uuid}/proxy/restart")->assertUnauthorized(); +}); diff --git a/tests/Feature/Api/ServerSubsystemsApiTest.php b/tests/Feature/Api/ServerSubsystemsApiTest.php new file mode 100644 index 000000000..e31c8a051 --- /dev/null +++ b/tests/Feature/Api/ServerSubsystemsApiTest.php @@ -0,0 +1,337 @@ + 0, 'is_api_enabled' => true]); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + session(['currentTeam' => $this->team]); + + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'ip' => '10.0.0.10', + ]); + + $otherTeam = Team::factory()->create(); + $this->otherServer = Server::factory()->create([ + 'team_id' => $otherTeam->id, + 'ip' => '10.0.0.20', + ]); + + $this->token = $this->user->createToken('server-subsystems', ['*'])->plainTextToken; +}); + +function serverSubsystemsHeaders(): array +{ + return [ + 'Authorization' => 'Bearer '.test()->token, + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + ]; +} + +describe('Docker cleanup API', function () { + test('GET returns docker cleanup settings for own team server', function () { + $this->server->settings->update([ + 'docker_cleanup_frequency' => '0 0 * * *', + 'docker_cleanup_threshold' => 25, + 'force_docker_cleanup' => true, + 'delete_unused_volumes' => true, + 'delete_unused_networks' => false, + 'disable_application_image_retention' => true, + ]); + + $this->withHeaders(serverSubsystemsHeaders()) + ->getJson("/api/v1/servers/{$this->server->uuid}/docker-cleanup") + ->assertOk() + ->assertJsonPath('docker_cleanup_threshold', 25) + ->assertJsonPath('force_docker_cleanup', true) + ->assertJsonPath('delete_unused_volumes', true) + ->assertJsonPath('disable_application_image_retention', true); + }); + + test('PATCH updates docker cleanup settings for own team server', function () { + $this->withHeaders(serverSubsystemsHeaders()) + ->patchJson("/api/v1/servers/{$this->server->uuid}/docker-cleanup", [ + 'docker_cleanup_frequency' => '0 */6 * * *', + 'docker_cleanup_threshold' => 42, + 'force_docker_cleanup' => true, + 'delete_unused_volumes' => true, + 'delete_unused_networks' => true, + 'disable_application_image_retention' => true, + ]) + ->assertOk() + ->assertJsonPath('docker_cleanup_threshold', 42) + ->assertJsonPath('force_docker_cleanup', true); + + $settings = $this->server->settings->fresh(); + expect($settings->docker_cleanup_threshold)->toBe(42) + ->and((bool) $settings->force_docker_cleanup)->toBeTrue() + ->and((bool) $settings->delete_unused_volumes)->toBeTrue() + ->and((bool) $settings->delete_unused_networks)->toBeTrue() + ->and((bool) $settings->disable_application_image_retention)->toBeTrue(); + }); + + test('POST run dispatches DockerCleanupJob for own team server', function () { + Queue::fake(); + + $this->server->settings->update([ + 'delete_unused_volumes' => true, + 'delete_unused_networks' => false, + ]); + + $this->withHeaders(serverSubsystemsHeaders()) + ->postJson("/api/v1/servers/{$this->server->uuid}/docker-cleanup/run") + ->assertOk() + ->assertJsonPath('message', fn ($message) => str_contains($message, 'Manual cleanup job started')); + + Queue::assertPushed(DockerCleanupJob::class, function (DockerCleanupJob $job) { + return $job->server->is($this->server) + && $job->manualCleanup === true + && $job->deleteUnusedVolumes === true + && $job->deleteUnusedNetworks === false; + }); + }); + + test('GET executions lists recent cleanup runs for own team server', function () { + $execution = DockerCleanupExecution::create([ + 'server_id' => $this->server->id, + 'status' => 'success', + 'message' => 'Cleanup completed', + ]); + + $this->withHeaders(serverSubsystemsHeaders()) + ->getJson("/api/v1/servers/{$this->server->uuid}/docker-cleanup/executions") + ->assertOk() + ->assertJsonCount(1) + ->assertJsonPath('0.uuid', $execution->uuid) + ->assertJsonPath('0.status', 'success'); + }); + + test('other-team docker cleanup endpoints return 404', function () { + $this->withHeaders(serverSubsystemsHeaders()) + ->getJson("/api/v1/servers/{$this->otherServer->uuid}/docker-cleanup") + ->assertNotFound(); + + $this->withHeaders(serverSubsystemsHeaders()) + ->patchJson("/api/v1/servers/{$this->otherServer->uuid}/docker-cleanup", [ + 'docker_cleanup_threshold' => 50, + ]) + ->assertNotFound(); + + $this->withHeaders(serverSubsystemsHeaders()) + ->postJson("/api/v1/servers/{$this->otherServer->uuid}/docker-cleanup/run") + ->assertNotFound(); + + $this->withHeaders(serverSubsystemsHeaders()) + ->getJson("/api/v1/servers/{$this->otherServer->uuid}/docker-cleanup/executions") + ->assertNotFound(); + }); +}); + +describe('Log drains API', function () { + test('GET returns log drain settings and hides secrets without read:sensitive', function () { + $this->server->settings->update([ + 'is_logdrain_axiom_enabled' => false, + 'logdrain_axiom_dataset_name' => 'coolify-logs', + 'logdrain_axiom_api_key' => 'secret-axiom-key', + 'logdrain_newrelic_license_key' => 'secret-nr-key', + ]); + + $readToken = $this->user->createToken('server-subsystems-read', ['read'])->plainTextToken; + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$readToken, + 'Accept' => 'application/json', + ]) + ->getJson("/api/v1/servers/{$this->server->uuid}/log-drains") + ->assertOk() + ->assertJsonPath('logdrain_axiom_dataset_name', 'coolify-logs') + ->assertJsonPath('is_logdrain_axiom_enabled', false); + + expect($response->json())->not->toHaveKey('logdrain_axiom_api_key') + ->and($response->json())->not->toHaveKey('logdrain_newrelic_license_key'); + }); + + test('PATCH updates log drain settings for own team server', function () { + Queue::fake(); + + $this->withHeaders(serverSubsystemsHeaders()) + ->patchJson("/api/v1/servers/{$this->server->uuid}/log-drains", [ + 'logdrain_axiom_dataset_name' => 'api-dataset', + 'logdrain_axiom_api_key' => 'axiom-key-123', + ]) + ->assertOk() + ->assertJsonPath('logdrain_axiom_dataset_name', 'api-dataset'); + + $settings = $this->server->settings->fresh(); + expect($settings->logdrain_axiom_dataset_name)->toBe('api-dataset') + ->and($settings->logdrain_axiom_api_key)->toBe('axiom-key-123'); + }); + + test('other-team log drains endpoints return 404', function () { + $this->withHeaders(serverSubsystemsHeaders()) + ->getJson("/api/v1/servers/{$this->otherServer->uuid}/log-drains") + ->assertNotFound(); + + $this->withHeaders(serverSubsystemsHeaders()) + ->patchJson("/api/v1/servers/{$this->otherServer->uuid}/log-drains", [ + 'logdrain_axiom_dataset_name' => 'nope', + ]) + ->assertNotFound(); + }); +}); + +describe('Sentinel API', function () { + test('GET returns sentinel settings without token without read:sensitive', function () { + // Avoid fields that trigger restartSentinel() on save (token/url/metrics timing). + $this->server->settings->update([ + 'is_sentinel_enabled' => true, + 'is_metrics_enabled' => true, + 'is_sentinel_debug_enabled' => false, + ]); + + $readToken = $this->user->createToken('server-subsystems-read', ['read'])->plainTextToken; + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$readToken, + 'Accept' => 'application/json', + ]) + ->getJson("/api/v1/servers/{$this->server->uuid}/sentinel") + ->assertOk() + ->assertJsonPath('is_sentinel_enabled', true) + ->assertJsonPath('is_metrics_enabled', true); + + expect($response->json())->not->toHaveKey('sentinel_token') + ->and($response->json())->not->toHaveKey('sentinel_custom_url'); + }); + + test('PATCH updates sentinel settings for own team server', function () { + // Only toggle fields that do not restart Sentinel (avoids remote StartSentinel on sync queue). + $this->withHeaders(serverSubsystemsHeaders()) + ->patchJson("/api/v1/servers/{$this->server->uuid}/sentinel", [ + 'is_metrics_enabled' => true, + 'is_sentinel_debug_enabled' => true, + ]) + ->assertOk() + ->assertJsonPath('is_metrics_enabled', true) + ->assertJsonPath('is_sentinel_debug_enabled', true); + + $settings = $this->server->settings->fresh(); + expect((bool) $settings->is_metrics_enabled)->toBeTrue() + ->and((bool) $settings->is_sentinel_debug_enabled)->toBeTrue(); + }); + + test('other-team sentinel endpoints return 404', function () { + $this->withHeaders(serverSubsystemsHeaders()) + ->getJson("/api/v1/servers/{$this->otherServer->uuid}/sentinel") + ->assertNotFound(); + + $this->withHeaders(serverSubsystemsHeaders()) + ->patchJson("/api/v1/servers/{$this->otherServer->uuid}/sentinel", [ + 'is_metrics_enabled' => true, + ]) + ->assertNotFound(); + }); +}); + +describe('Cloudflare Tunnel API', function () { + test('GET returns cloudflare tunnel settings for own team server', function () { + $this->server->settings->update(['is_cloudflare_tunnel' => true]); + + $this->withHeaders(serverSubsystemsHeaders()) + ->getJson("/api/v1/servers/{$this->server->uuid}/cloudflare-tunnel") + ->assertOk() + ->assertJsonPath('is_cloudflare_tunnel', true) + ->assertJsonPath('ip', '10.0.0.10'); + }); + + test('PATCH enables cloudflare tunnel setting for own team server', function () { + $this->withHeaders(serverSubsystemsHeaders()) + ->patchJson("/api/v1/servers/{$this->server->uuid}/cloudflare-tunnel", [ + 'is_cloudflare_tunnel' => true, + ]) + ->assertOk() + ->assertJsonPath('is_cloudflare_tunnel', true); + + expect((bool) $this->server->settings->fresh()->is_cloudflare_tunnel)->toBeTrue(); + }); + + test('POST enable and disable match manual UI actions', function () { + // Changing ip auto-sets ip_previous to the previous IP via Server model boot. + $originalIp = (string) $this->server->ip; + $this->server->update(['ip' => '100.64.0.5']); + expect((string) $this->server->fresh()->ip_previous)->toBe($originalIp); + + $this->server->settings->update(['is_cloudflare_tunnel' => false]); + + $this->withHeaders(serverSubsystemsHeaders()) + ->postJson("/api/v1/servers/{$this->server->uuid}/cloudflare-tunnel/enable") + ->assertOk() + ->assertJsonPath('is_cloudflare_tunnel', true); + + expect((bool) $this->server->settings->fresh()->is_cloudflare_tunnel)->toBeTrue(); + + $this->withHeaders(serverSubsystemsHeaders()) + ->postJson("/api/v1/servers/{$this->server->uuid}/cloudflare-tunnel/disable") + ->assertOk() + ->assertJsonPath('is_cloudflare_tunnel', false) + ->assertJsonPath('ip', $originalIp); + + expect((bool) $this->server->settings->fresh()->is_cloudflare_tunnel)->toBeFalse() + ->and((string) $this->server->fresh()->ip)->toBe($originalIp); + }); + + test('other-team cloudflare tunnel endpoints return 404', function () { + $this->withHeaders(serverSubsystemsHeaders()) + ->getJson("/api/v1/servers/{$this->otherServer->uuid}/cloudflare-tunnel") + ->assertNotFound(); + + $this->withHeaders(serverSubsystemsHeaders()) + ->patchJson("/api/v1/servers/{$this->otherServer->uuid}/cloudflare-tunnel", [ + 'is_cloudflare_tunnel' => true, + ]) + ->assertNotFound(); + + $this->withHeaders(serverSubsystemsHeaders()) + ->postJson("/api/v1/servers/{$this->otherServer->uuid}/cloudflare-tunnel/enable") + ->assertNotFound(); + + $this->withHeaders(serverSubsystemsHeaders()) + ->postJson("/api/v1/servers/{$this->otherServer->uuid}/cloudflare-tunnel/disable") + ->assertNotFound(); + }); +}); + +describe('Server update is_terminal_enabled', function () { + test('PATCH /servers/{uuid} updates is_terminal_enabled on settings', function () { + $this->withHeaders(serverSubsystemsHeaders()) + ->patchJson("/api/v1/servers/{$this->server->uuid}", [ + 'is_terminal_enabled' => true, + ]) + ->assertCreated() + ->assertJsonPath('uuid', $this->server->uuid); + + expect((bool) $this->server->settings->fresh()->is_terminal_enabled)->toBeTrue(); + }); + + test('PATCH /servers/{uuid} other-team returns 404', function () { + $this->withHeaders(serverSubsystemsHeaders()) + ->patchJson("/api/v1/servers/{$this->otherServer->uuid}", [ + 'is_terminal_enabled' => true, + ]) + ->assertNotFound(); + }); +}); diff --git a/tests/Feature/Api/SharedEnvironmentVariablesApiTest.php b/tests/Feature/Api/SharedEnvironmentVariablesApiTest.php new file mode 100644 index 000000000..009738981 --- /dev/null +++ b/tests/Feature/Api/SharedEnvironmentVariablesApiTest.php @@ -0,0 +1,190 @@ + 'array', + 'session.driver' => 'array', + 'queue.default' => 'sync', + 'app.maintenance.driver' => 'file', + ]); + + InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0], ['is_api_enabled' => true])); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + session(['currentTeam' => $this->team]); + + $this->token = $this->user->createToken('test-token', ['*']); + $this->bearerToken = $this->token->plainTextToken; + + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); +}); + +function sharedEnvHeaders(string $bearerToken): array +{ + return [ + 'Authorization' => 'Bearer '.$bearerToken, + 'Content-Type' => 'application/json', + ]; +} + +describe('Team shared environment variables API', function () { + test('creates lists updates and deletes team shared envs', function () { + $create = $this->withHeaders(sharedEnvHeaders($this->bearerToken)) + ->postJson('/api/v1/team/envs', [ + 'key' => 'TEAM_API_KEY', + 'value' => 'secret-value', + 'is_literal' => true, + 'comment' => 'from api', + ]); + + $create->assertStatus(201); + $create->assertJsonStructure(['id']); + $envId = $create->json('id'); + + expect(SharedEnvironmentVariable::query()->whereKey($envId)->first()) + ->type->toBe('team') + ->team_id->toBe($this->team->id) + ->project_id->toBeNull() + ->key->toBe('TEAM_API_KEY'); + + $list = $this->withHeaders(sharedEnvHeaders($this->bearerToken)) + ->getJson('/api/v1/team/envs'); + + $list->assertOk(); + $list->assertJsonFragment(['key' => 'TEAM_API_KEY', 'id' => $envId]); + + $update = $this->withHeaders(sharedEnvHeaders($this->bearerToken)) + ->patchJson("/api/v1/team/envs/{$envId}", [ + 'value' => 'updated-value', + 'is_multiline' => true, + ]); + + $update->assertOk(); + $update->assertJsonFragment(['key' => 'TEAM_API_KEY']); + expect($update->json('is_multiline'))->toBeTruthy(); + + $delete = $this->withHeaders(sharedEnvHeaders($this->bearerToken)) + ->deleteJson("/api/v1/team/envs/{$envId}"); + + $delete->assertOk(); + $delete->assertJson(['message' => 'Environment variable deleted.']); + expect(SharedEnvironmentVariable::query()->whereKey($envId)->exists())->toBeFalse(); + }); + + test('hides value without can_read_sensitive', function () { + SharedEnvironmentVariable::create([ + 'key' => 'HIDDEN_SECRET', + 'value' => 'should-not-appear', + 'type' => 'team', + 'team_id' => $this->team->id, + ]); + + $readToken = $this->user->createToken('read-token', ['read'])->plainTextToken; + + $response = $this->withHeaders(sharedEnvHeaders($readToken)) + ->getJson('/api/v1/team/envs'); + + $response->assertOk(); + $response->assertJsonFragment(['key' => 'HIDDEN_SECRET']); + expect($response->json('0'))->not->toHaveKey('value'); + }); + + test('returns 409 when creating duplicate team key', function () { + SharedEnvironmentVariable::create([ + 'key' => 'DUP_KEY', + 'value' => 'one', + 'type' => 'team', + 'team_id' => $this->team->id, + ]); + + $response = $this->withHeaders(sharedEnvHeaders($this->bearerToken)) + ->postJson('/api/v1/team/envs', [ + 'key' => 'DUP_KEY', + 'value' => 'two', + ]); + + $response->assertStatus(409); + }); +}); + +describe('Project shared environment variables API', function () { + test('creates and lists project shared envs', function () { + $create = $this->withHeaders(sharedEnvHeaders($this->bearerToken)) + ->postJson("/api/v1/projects/{$this->project->uuid}/envs", [ + 'key' => 'PROJECT_VAR', + 'value' => 'project-secret', + ]); + + $create->assertStatus(201); + $envId = $create->json('id'); + + $env = SharedEnvironmentVariable::query()->whereKey($envId)->first(); + expect($env) + ->type->toBe('project') + ->project_id->toBe($this->project->id) + ->team_id->toBe($this->team->id); + + $list = $this->withHeaders(sharedEnvHeaders($this->bearerToken)) + ->getJson("/api/v1/projects/{$this->project->uuid}/envs"); + + $list->assertOk(); + $list->assertJsonFragment(['key' => 'PROJECT_VAR', 'id' => $envId]); + }); + + test('returns 404 for project from another team', function () { + $otherTeam = Team::factory()->create(); + $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]); + + SharedEnvironmentVariable::create([ + 'key' => 'OTHER_TEAM_VAR', + 'value' => 'nope', + 'type' => 'project', + 'team_id' => $otherTeam->id, + 'project_id' => $otherProject->id, + ]); + + $list = $this->withHeaders(sharedEnvHeaders($this->bearerToken)) + ->getJson("/api/v1/projects/{$otherProject->uuid}/envs"); + $list->assertStatus(404); + + $create = $this->withHeaders(sharedEnvHeaders($this->bearerToken)) + ->postJson("/api/v1/projects/{$otherProject->uuid}/envs", [ + 'key' => 'SHOULD_FAIL', + 'value' => 'x', + ]); + $create->assertStatus(404); + }); + + test('returns 404 when updating env from another team scope', function () { + $otherTeam = Team::factory()->create(); + $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]); + $otherEnv = SharedEnvironmentVariable::create([ + 'key' => 'FOREIGN', + 'value' => 'secret', + 'type' => 'project', + 'team_id' => $otherTeam->id, + 'project_id' => $otherProject->id, + ]); + + $response = $this->withHeaders(sharedEnvHeaders($this->bearerToken)) + ->patchJson("/api/v1/projects/{$this->project->uuid}/envs/{$otherEnv->id}", [ + 'value' => 'hacked', + ]); + + $response->assertStatus(404); + }); +}); diff --git a/tests/Feature/Api/TeamTokenTeamApiTest.php b/tests/Feature/Api/TeamTokenTeamApiTest.php new file mode 100644 index 000000000..4f45562ea --- /dev/null +++ b/tests/Feature/Api/TeamTokenTeamApiTest.php @@ -0,0 +1,72 @@ + InstanceSettings::updateOrCreate(['id' => 0], ['is_api_enabled' => true])); + + $this->team = Team::factory()->create(['name' => 'Token Team']); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + session(['currentTeam' => $this->team]); + + $this->bearerToken = $this->user->createToken('team-token-api-test', ['*'])->plainTextToken; +}); + +function teamTokenApiHeaders(string $bearerToken): array +{ + return [ + 'Authorization' => 'Bearer '.$bearerToken, + 'Accept' => 'application/json', + ]; +} + +describe('token team endpoints', function () { + test('GET /team returns the token team', function () { + $this->withHeaders(teamTokenApiHeaders($this->bearerToken)) + ->getJson('/api/v1/team') + ->assertOk() + ->assertJsonPath('id', $this->team->id) + ->assertJsonPath('name', 'Token Team'); + }); + + test('GET /team/members returns members of the token team', function () { + $this->withHeaders(teamTokenApiHeaders($this->bearerToken)) + ->getJson('/api/v1/team/members') + ->assertOk() + ->assertJsonFragment(['id' => $this->user->id]); + }); + + test('deprecated GET /teams/current aliases GET /team', function () { + $preferred = $this->withHeaders(teamTokenApiHeaders($this->bearerToken)) + ->getJson('/api/v1/team') + ->assertOk() + ->json(); + + $alias = $this->withHeaders(teamTokenApiHeaders($this->bearerToken)) + ->getJson('/api/v1/teams/current') + ->assertOk() + ->json(); + + expect($alias)->toBe($preferred); + }); + + test('deprecated GET /teams/current/members aliases GET /team/members', function () { + $preferred = $this->withHeaders(teamTokenApiHeaders($this->bearerToken)) + ->getJson('/api/v1/team/members') + ->assertOk() + ->json(); + + $alias = $this->withHeaders(teamTokenApiHeaders($this->bearerToken)) + ->getJson('/api/v1/teams/current/members') + ->assertOk() + ->json(); + + expect($alias)->toBe($preferred); + }); +}); diff --git a/tests/Feature/TagApiTest.php b/tests/Feature/TagApiTest.php index 448d7917f..ad46995a3 100644 --- a/tests/Feature/TagApiTest.php +++ b/tests/Feature/TagApiTest.php @@ -19,6 +19,13 @@ use Livewire\Livewire; uses(RefreshDatabase::class); beforeEach(function () { + config([ + 'cache.default' => 'array', + 'session.driver' => 'array', + 'queue.default' => 'sync', + 'app.maintenance.driver' => 'file', + ]); + InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['is_api_enabled' => true])); $this->team = Team::factory()->create(); @@ -87,6 +94,140 @@ describe('GET /api/v1/tags', function () { }); }); +describe('POST /api/v1/tags', function () { + test('creates a tag for the current team', function () { + $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) + ->postJson('/api/v1/tags', [ + 'name' => 'Production', + ]); + + $response->assertCreated() + ->assertJsonFragment(['name' => 'production']) + ->assertJsonStructure(['uuid', 'name', 'created_at', 'updated_at']); + + expect(Tag::where('team_id', $this->team->id)->where('name', 'production')->exists())->toBeTrue(); + }); + + test('rejects duplicate tag names for the team', function () { + Tag::create(['name' => 'production', 'team_id' => $this->team->id]); + + $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) + ->postJson('/api/v1/tags', [ + 'name' => 'production', + ]); + + $response->assertStatus(409); + }); + + test('rejects unknown fields', function () { + $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) + ->postJson('/api/v1/tags', [ + 'name' => 'valid-tag', + 'unexpected' => 'value', + ]); + + $response->assertUnprocessable(); + $response->assertJsonValidationErrors(['unexpected']); + }); + + test('rejects create requests from non-admin team members', function () { + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + session(['currentTeam' => $this->team]); + $memberToken = $member->createToken('member-token', ['*'])->plainTextToken; + + $response = $this->withHeaders(tagApiAuthHeaders($memberToken)) + ->postJson('/api/v1/tags', [ + 'name' => 'member-tag', + ]); + + $response->assertForbidden(); + expect(Tag::where('team_id', $this->team->id)->where('name', 'member-tag')->exists())->toBeFalse(); + }); +}); + +describe('PATCH /api/v1/tags/{uuid}', function () { + test('renames a team tag', function () { + $tag = Tag::create(['name' => 'old-name', 'team_id' => $this->team->id]); + + $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) + ->patchJson("/api/v1/tags/{$tag->uuid}", [ + 'name' => 'New Name', + ]); + + $response->assertOk() + ->assertJsonFragment(['uuid' => $tag->uuid, 'name' => 'new name']); + + expect($tag->fresh()->name)->toBe('new name'); + }); + + test('does not rename tags from another team', function () { + $otherTeam = Team::factory()->create(); + $tag = Tag::create(['name' => 'other-tag', 'team_id' => $otherTeam->id]); + + $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) + ->patchJson("/api/v1/tags/{$tag->uuid}", [ + 'name' => 'stolen', + ]); + + $response->assertNotFound(); + expect($tag->fresh()->name)->toBe('other-tag'); + }); + + test('rejects renaming to an existing team tag name', function () { + Tag::create(['name' => 'taken', 'team_id' => $this->team->id]); + $tag = Tag::create(['name' => 'rename-me', 'team_id' => $this->team->id]); + + $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) + ->patchJson("/api/v1/tags/{$tag->uuid}", [ + 'name' => 'taken', + ]); + + $response->assertStatus(409); + }); +}); + +describe('DELETE /api/v1/tags/{uuid}', function () { + test('deletes a tag and detaches it from resources', function () { + $tag = Tag::create(['name' => 'to-delete', 'team_id' => $this->team->id]); + $this->application->tags()->attach($tag->id); + + $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) + ->deleteJson("/api/v1/tags/{$tag->uuid}"); + + $response->assertOk() + ->assertJson(['message' => 'Tag deleted.']); + + expect(Tag::find($tag->id))->toBeNull() + ->and($this->application->tags()->count())->toBe(0); + }); + + test('does not delete tags from another team', function () { + $otherTeam = Team::factory()->create(); + $tag = Tag::create(['name' => 'other-team-tag', 'team_id' => $otherTeam->id]); + + $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) + ->deleteJson("/api/v1/tags/{$tag->uuid}"); + + $response->assertNotFound(); + expect(Tag::find($tag->id))->not->toBeNull(); + }); + + test('rejects delete requests from non-admin team members', function () { + $tag = Tag::create(['name' => 'protected', 'team_id' => $this->team->id]); + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + session(['currentTeam' => $this->team]); + $memberToken = $member->createToken('member-token', ['*'])->plainTextToken; + + $response = $this->withHeaders(tagApiAuthHeaders($memberToken)) + ->deleteJson("/api/v1/tags/{$tag->uuid}"); + + $response->assertForbidden(); + expect(Tag::find($tag->id))->not->toBeNull(); + }); +}); + describe('GET /api/v1/applications/{uuid}/tags', function () { test('returns tags for an application', function () { $tag = Tag::create(['name' => 'production', 'team_id' => $this->team->id]); diff --git a/tests/Unit/DestinationsOpenApiTest.php b/tests/Unit/DestinationsOpenApiTest.php index a8686ace2..a75f0b1d3 100644 --- a/tests/Unit/DestinationsOpenApiTest.php +++ b/tests/Unit/DestinationsOpenApiTest.php @@ -7,6 +7,7 @@ it('defines OpenAPI documentation for every destinations endpoint', function () expect($model)->toContain("schema: 'Destination'") ->and($controller)->toContain("operationId: 'list-destinations'") ->and($controller)->toContain("operationId: 'get-destination-by-uuid'") + ->and($controller)->toContain("operationId: 'update-destination-by-uuid'") ->and($controller)->toContain("operationId: 'delete-destination-by-uuid'") ->and($controller)->toContain("operationId: 'list-server-destinations'") ->and($controller)->toContain("operationId: 'create-server-destination'"); diff --git a/tests/Unit/S3StorageEndpointValidationTest.php b/tests/Unit/S3StorageEndpointValidationTest.php index d01769b39..6d4a0474a 100644 --- a/tests/Unit/S3StorageEndpointValidationTest.php +++ b/tests/Unit/S3StorageEndpointValidationTest.php @@ -104,3 +104,56 @@ it('accepts explicitly allowlisted intranet S3 endpoints', function (string $end 'Self-hosted MinIO on 172.16.x CIDR' => ['http://172.16.0.10:9000', ['172.16.0.0/12']], 'Self-hosted MinIO on 192.168.x exact IP' => ['http://192.168.1.50:9000', ['192.168.1.50']], ]); + +it('accepts allowlisted docker-style MinIO hostnames that resolve to private IPs', function () { + InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], [ + 'webhook_allowed_internal_hosts' => ['coolify-minio'], + ])); + + $validator = Validator::make( + ['endpoint' => 'http://coolify-minio:9000'], + ['endpoint' => ['required', 'max:255', new SafeWebhookUrl(fn (string $host): array => ['172.16.0.5'])]], + ); + + expect($validator->passes())->toBeTrue('Expected coolify-minio with private IP to pass when allowlisted'); +}); + +it('rejects docker-style MinIO hostnames resolving to private IPs without allowlist', function () { + InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], [ + 'webhook_allowed_internal_hosts' => [], + ])); + + $validator = Validator::make( + ['endpoint' => 'http://coolify-minio:9000'], + ['endpoint' => ['required', 'max:255', new SafeWebhookUrl(fn (string $host): array => ['172.16.0.5'])]], + ); + + expect($validator->fails())->toBeTrue('Expected coolify-minio private IP rejection without allowlist'); +}); + +it('accepts allowlisted docker MinIO hostname after custom DNS miss and system DNS hit', function () { + InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], [ + 'custom_dns_servers' => '1.1.1.1', + 'webhook_allowed_internal_hosts' => ['coolify-minio'], + ])); + + $rule = new class extends SafeWebhookUrl + { + protected function resolveHostWithCustomDnsServers(string $host, array $dnsServers): array + { + return []; + } + + protected function resolveHostWithSystemDns(string $host): array + { + return ['172.16.0.5']; + } + }; + + $validator = Validator::make( + ['endpoint' => 'http://coolify-minio:9000'], + ['endpoint' => ['required', 'max:255', $rule]], + ); + + expect($validator->passes())->toBeTrue('Expected S3 endpoint to pass after DNS fallback + allowlist'); +}); diff --git a/tests/Unit/SafeWebhookUrlTest.php b/tests/Unit/SafeWebhookUrlTest.php index de0c06260..84d1b33ff 100644 --- a/tests/Unit/SafeWebhookUrlTest.php +++ b/tests/Unit/SafeWebhookUrlTest.php @@ -278,3 +278,103 @@ it('redacts webhook URLs for logs', function () { expect(SafeWebhookUrl::redactedUrlForLog('https://hooks.slack.com/services/T000/B000/secret-token?foo=bar')) ->toBe('https://hooks.slack.com'); }); + +it('falls back to system DNS when custom DNS returns no answers', function () { + InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], [ + 'custom_dns_servers' => '1.1.1.1', + 'webhook_allowed_internal_hosts' => ['coolify-minio'], + ])); + + $rule = new class extends SafeWebhookUrl + { + /** @var array}> */ + public array $customCalls = []; + + /** @var array */ + public array $systemCalls = []; + + protected function resolveHostWithCustomDnsServers(string $host, array $dnsServers): array + { + $this->customCalls[] = [$host, $dnsServers]; + + // Simulate public DNS (1.1.1.1) having no record for a docker hostname. + return []; + } + + protected function resolveHostWithSystemDns(string $host): array + { + $this->systemCalls[] = $host; + + return ['172.16.0.5']; + } + }; + + $validator = Validator::make( + ['url' => 'http://coolify-minio:9000'], + ['url' => $rule], + ); + + expect($validator->passes())->toBeTrue('Expected system-DNS fallback for allowlisted docker hostname') + ->and($rule->customCalls)->toHaveCount(1) + ->and($rule->customCalls[0][0])->toBe('coolify-minio') + ->and($rule->customCalls[0][1])->toBe(['1.1.1.1']) + ->and($rule->systemCalls)->toBe(['coolify-minio']); +}); + +it('does not fall back to system DNS when custom DNS returns answers', function () { + InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], [ + 'custom_dns_servers' => '1.1.1.1', + ])); + + $rule = new class extends SafeWebhookUrl + { + public int $systemCalls = 0; + + protected function resolveHostWithCustomDnsServers(string $host, array $dnsServers): array + { + return ['93.184.216.34']; + } + + protected function resolveHostWithSystemDns(string $host): array + { + $this->systemCalls++; + + return ['10.0.0.1']; + } + }; + + $validator = Validator::make( + ['url' => 'https://example.com/webhook'], + ['url' => $rule], + ); + + expect($validator->passes())->toBeTrue() + ->and($rule->systemCalls)->toBe(0); +}); + +it('still rejects private targets after system DNS fallback when host is not allowlisted', function () { + InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], [ + 'custom_dns_servers' => '1.1.1.1', + 'webhook_allowed_internal_hosts' => [], + ])); + + $rule = new class extends SafeWebhookUrl + { + protected function resolveHostWithCustomDnsServers(string $host, array $dnsServers): array + { + return []; + } + + protected function resolveHostWithSystemDns(string $host): array + { + return ['172.16.0.5']; + } + }; + + $validator = Validator::make( + ['url' => 'http://coolify-minio:9000'], + ['url' => $rule], + ); + + expect($validator->fails())->toBeTrue('Expected private IP rejection without allowlist after fallback'); +});