mirror of
https://github.com/tiennm99/coolify.git
synced 2026-08-20 06:23:23 +00:00
fix(api): secure proxy config, clone volumes, and rollback tags (#11003)
This commit is contained in:
@@ -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.']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\CloudInitScript;
|
||||
use App\Rules\ValidCloudInitYaml;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class CloudInitScriptsController extends Controller
|
||||
{
|
||||
private function removeSensitiveData(CloudInitScript $script): array
|
||||
{
|
||||
$script->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.']);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -0,0 +1,511 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\DiscordNotificationSettings;
|
||||
use App\Models\EmailNotificationSettings;
|
||||
use App\Models\PushoverNotificationSettings;
|
||||
use App\Models\SlackNotificationSettings;
|
||||
use App\Models\Team;
|
||||
use App\Models\TelegramNotificationSettings;
|
||||
use App\Models\WebhookNotificationSettings;
|
||||
use App\Rules\SafeWebhookUrl;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class NotificationsController extends Controller
|
||||
{
|
||||
/**
|
||||
* @return array{model: class-string<Model>, rules: array<string, mixed>}
|
||||
*/
|
||||
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<string>
|
||||
*/
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -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.',
|
||||
|
||||
@@ -0,0 +1,566 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\S3Storage;
|
||||
use App\Rules\SafeWebhookUrl;
|
||||
use App\Rules\ValidS3BucketName;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class S3StoragesController extends Controller
|
||||
{
|
||||
private function removeSensitiveData(S3Storage $storage)
|
||||
{
|
||||
$storage->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<string, mixed> $body
|
||||
* @param array<int, string> $allowedFields
|
||||
* @param array<string, mixed> $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'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class ServerCloudflareTunnelController extends Controller
|
||||
{
|
||||
private const ALLOWED_FIELDS = [
|
||||
'is_cloudflare_tunnel',
|
||||
];
|
||||
|
||||
private function findServerForTeam(int $teamId, string $uuid): ?Server
|
||||
{
|
||||
return Server::whereTeamId($teamId)->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()),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\DockerCleanupJob;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class ServerDockerCleanupController extends Controller
|
||||
{
|
||||
private const ALLOWED_FIELDS = [
|
||||
'docker_cleanup_frequency',
|
||||
'docker_cleanup_threshold',
|
||||
'force_docker_cleanup',
|
||||
'delete_unused_volumes',
|
||||
'delete_unused_networks',
|
||||
'disable_application_image_retention',
|
||||
];
|
||||
|
||||
private function findServerForTeam(int $teamId, string $uuid): ?Server
|
||||
{
|
||||
return Server::whereTeamId($teamId)->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Actions\Server\StartLogDrain;
|
||||
use App\Actions\Server\StopLogDrain;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class ServerLogDrainsController extends Controller
|
||||
{
|
||||
private const ALLOWED_FIELDS = [
|
||||
'is_logdrain_newrelic_enabled',
|
||||
'logdrain_newrelic_license_key',
|
||||
'logdrain_newrelic_base_uri',
|
||||
'is_logdrain_axiom_enabled',
|
||||
'logdrain_axiom_dataset_name',
|
||||
'logdrain_axiom_api_key',
|
||||
'is_logdrain_custom_enabled',
|
||||
'logdrain_custom_config',
|
||||
'logdrain_custom_config_parser',
|
||||
];
|
||||
|
||||
private function findServerForTeam(int $teamId, string $uuid): ?Server
|
||||
{
|
||||
return Server::whereTeamId($teamId)->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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Actions\Proxy\SaveProxyConfiguration;
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\RestartProxyJob;
|
||||
use App\Models\Server;
|
||||
use App\Rules\SafeExternalUrl;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class ServerProxyController extends Controller
|
||||
{
|
||||
private function teamIdOrAbort(): int|JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
return $teamId;
|
||||
}
|
||||
|
||||
private function findServerForTeam(int $teamId, string $uuid): ?Server
|
||||
{
|
||||
return Server::whereTeamId($teamId)->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.']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Server;
|
||||
use App\Models\ServerSetting;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class ServerSentinelController extends Controller
|
||||
{
|
||||
private const ALLOWED_FIELDS = [
|
||||
'is_sentinel_enabled',
|
||||
'is_metrics_enabled',
|
||||
'is_sentinel_debug_enabled',
|
||||
'sentinel_token',
|
||||
'sentinel_metrics_refresh_rate_seconds',
|
||||
'sentinel_metrics_history_days',
|
||||
'sentinel_push_interval_seconds',
|
||||
'sentinel_custom_url',
|
||||
];
|
||||
|
||||
private function findServerForTeam(int $teamId, string $uuid): ?Server
|
||||
{
|
||||
return Server::whereTeamId($teamId)->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()));
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,907 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\SharedEnvironmentVariable;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class SharedEnvironmentVariablesController extends Controller
|
||||
{
|
||||
private const ALLOWED_FIELDS = ['key', 'value', 'is_literal', 'is_multiline', 'is_shown_once', 'comment'];
|
||||
|
||||
private function removeSensitiveData(SharedEnvironmentVariable $env): mixed
|
||||
{
|
||||
$env->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],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.'],
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Tag;
|
||||
use App\Models\User;
|
||||
|
||||
class TagPolicy
|
||||
{
|
||||
/**
|
||||
* Determine whether the user can view any models.
|
||||
*/
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can view the model.
|
||||
*/
|
||||
public function view(User $user, Tag $tag): bool
|
||||
{
|
||||
return $user->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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<int, string>
|
||||
*/
|
||||
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<int, string> $dnsServers
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function resolveHostWithCustomDnsServers(string $host, array $dnsServers): array
|
||||
protected function resolveHostWithCustomDnsServers(string $host, array $dnsServers): array
|
||||
{
|
||||
$ips = [];
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
+4703
-8
File diff suppressed because it is too large
Load Diff
+2944
-8
File diff suppressed because it is too large
Load Diff
+101
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\Api\ApplicationsController;
|
||||
use App\Http\Controllers\Api\CloudInitScriptsController;
|
||||
use App\Http\Controllers\Api\CloudProviderTokensController;
|
||||
use App\Http\Controllers\Api\DatabasesController;
|
||||
use App\Http\Controllers\Api\DeployController;
|
||||
@@ -10,16 +11,24 @@ use App\Http\Controllers\Api\GithubController;
|
||||
use App\Http\Controllers\Api\GitlabController;
|
||||
use App\Http\Controllers\Api\HetznerController;
|
||||
use App\Http\Controllers\Api\Internal\FluxResourceStatusController;
|
||||
use App\Http\Controllers\Api\NotificationsController;
|
||||
use App\Http\Controllers\Api\OtherController;
|
||||
use App\Http\Controllers\Api\ProjectController;
|
||||
use App\Http\Controllers\Api\ResourcesController;
|
||||
use App\Http\Controllers\Api\S3StoragesController;
|
||||
use App\Http\Controllers\Api\ScheduledTasksController;
|
||||
use App\Http\Controllers\Api\SecurityController;
|
||||
use App\Http\Controllers\Api\SentinelController;
|
||||
use App\Http\Controllers\Api\ServerCloudflareTunnelController;
|
||||
use App\Http\Controllers\Api\ServerDockerCleanupController;
|
||||
use App\Http\Controllers\Api\ServerLogDrainsController;
|
||||
use App\Http\Controllers\Api\ServerProxyController;
|
||||
use App\Http\Controllers\Api\ServersController;
|
||||
use App\Http\Controllers\Api\ServerSentinelController;
|
||||
use App\Http\Controllers\Api\ServiceApplicationsController;
|
||||
use App\Http\Controllers\Api\ServiceDatabasesController;
|
||||
use App\Http\Controllers\Api\ServicesController;
|
||||
use App\Http\Controllers\Api\SharedEnvironmentVariablesController;
|
||||
use App\Http\Controllers\Api\TagsController;
|
||||
use App\Http\Controllers\Api\TeamController;
|
||||
use App\Http\Controllers\Api\VolumeBackupsController;
|
||||
@@ -57,16 +66,46 @@ Route::group([
|
||||
Route::get('/version', [OtherController::class, 'version'])->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([
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\Api\ApplicationsController;
|
||||
|
||||
function currentRollbackImageTag(string $imageReference): ?string
|
||||
{
|
||||
$method = new ReflectionMethod(ApplicationsController::class, 'currentRollbackImageTag');
|
||||
$method->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();
|
||||
});
|
||||
@@ -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],
|
||||
]);
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config([
|
||||
'cache.default' => '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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,419 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\ScheduledTaskJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\CloudInitScript;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\LocalPersistentVolume;
|
||||
use App\Models\Project;
|
||||
use App\Models\ScheduledTask;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config([
|
||||
'cache.default' => '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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,342 @@
|
||||
<?php
|
||||
|
||||
use App\Models\DiscordNotificationSettings;
|
||||
use App\Models\EmailNotificationSettings;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PushoverNotificationSettings;
|
||||
use App\Models\SlackNotificationSettings;
|
||||
use App\Models\Team;
|
||||
use App\Models\TelegramNotificationSettings;
|
||||
use App\Models\User;
|
||||
use App\Models\WebhookNotificationSettings;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Once;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config([
|
||||
'app.maintenance.driver' => '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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,543 @@
|
||||
<?php
|
||||
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Once;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config([
|
||||
'app.maintenance.driver' => '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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Proxy\SaveProxyConfiguration;
|
||||
use App\Actions\Proxy\StartProxy;
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Jobs\RestartProxyJob;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config([
|
||||
'cache.default' => '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();
|
||||
});
|
||||
@@ -0,0 +1,337 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\DockerCleanupJob;
|
||||
use App\Models\DockerCleanupExecution;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::forceCreate(['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->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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\SharedEnvironmentVariable;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config([
|
||||
'cache.default' => '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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::unguarded(fn () => 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);
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
|
||||
@@ -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'");
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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<int, array{0: string, 1: array<int, string>}> */
|
||||
public array $customCalls = [];
|
||||
|
||||
/** @var array<int, string> */
|
||||
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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user