mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-13 00:14:24 +00:00
Merge remote-tracking branch 'origin/next' into feat/noindex-domains
This commit is contained in:
commit
003e44d123
1274 files changed
+156179
-25249
No files matched your search
@@ -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
|
||||
@@ -350,6 +363,7 @@ class ApplicationsController extends Controller
|
||||
properties: [
|
||||
'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'],
|
||||
'domain' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io")'],
|
||||
'redirect' => ['type' => 'string', 'nullable' => true, 'description' => 'Per-service www/non-www redirect for this compose service.', 'enum' => ['www', 'non-www', 'both']],
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -369,6 +383,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'],
|
||||
@@ -533,6 +557,7 @@ class ApplicationsController extends Controller
|
||||
properties: [
|
||||
'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'],
|
||||
'domain' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io")'],
|
||||
'redirect' => ['type' => 'string', 'nullable' => true, 'description' => 'Per-service www/non-www redirect for this compose service.', 'enum' => ['www', 'non-www', 'both']],
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -552,6 +577,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'],
|
||||
@@ -716,6 +751,7 @@ class ApplicationsController extends Controller
|
||||
properties: [
|
||||
'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'],
|
||||
'domain' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io")'],
|
||||
'redirect' => ['type' => 'string', 'nullable' => true, 'description' => 'Per-service www/non-www redirect for this compose service.', 'enum' => ['www', 'non-www', 'both']],
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -735,6 +771,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'],
|
||||
@@ -890,6 +936,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'],
|
||||
@@ -1041,6 +1097,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'],
|
||||
@@ -1125,7 +1191,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', 'noindex_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', 'noindex_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',
|
||||
@@ -1242,6 +1308,12 @@ class ApplicationsController extends Controller
|
||||
if (! $server) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
}
|
||||
if (! $server->canHostResources()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['server_uuid' => ['The specified server is configured as a build server and cannot host resources.']],
|
||||
], 422);
|
||||
}
|
||||
$destinations = $server->destinations();
|
||||
if ($destinations->count() == 0) {
|
||||
return response()->json(['message' => 'Server has no destinations.'], 400);
|
||||
@@ -1268,9 +1340,10 @@ class ApplicationsController extends Controller
|
||||
'build_pack' => ['required', Rule::enum(BuildPackTypes::class)],
|
||||
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|nullable',
|
||||
'docker_compose_domains' => 'array|nullable',
|
||||
'docker_compose_domains.*' => 'array:name,domain',
|
||||
'docker_compose_domains.*' => 'array:name,domain,redirect',
|
||||
'docker_compose_domains.*.name' => 'string|required',
|
||||
'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(),
|
||||
'docker_compose_domains.*.redirect' => 'nullable|string|in:www,non-www,both',
|
||||
];
|
||||
// ports_exposes is not required for dockercompose
|
||||
if ($request->build_pack === 'dockercompose') {
|
||||
@@ -1279,7 +1352,7 @@ class ApplicationsController extends Controller
|
||||
}
|
||||
$validationRules = array_merge(sharedDataApplications(), $validationRules);
|
||||
$validationMessages = [
|
||||
'docker_compose_domains.*.array' => 'An item in the docker_compose_domains array has invalid fields. Only a name and domain field are supported.',
|
||||
'docker_compose_domains.*.array' => 'An item in the docker_compose_domains array has invalid fields. Only name, domain, and redirect fields are supported.',
|
||||
];
|
||||
$validator = Validator::make($request->all(), $validationRules, $validationMessages);
|
||||
if ($validator->fails()) {
|
||||
@@ -1371,7 +1444,12 @@ class ApplicationsController extends Controller
|
||||
}
|
||||
|
||||
$dockerComposeDomains->each(function ($domain) use ($dockerComposeDomainsJson) {
|
||||
$dockerComposeDomainsJson->put(data_get($domain, 'name'), ['domain' => data_get($domain, 'domain')]);
|
||||
$entry = ['domain' => data_get($domain, 'domain')];
|
||||
$redirect = data_get($domain, 'redirect');
|
||||
if (in_array($redirect, ['www', 'non-www', 'both'], true)) {
|
||||
$entry['redirect'] = $redirect;
|
||||
}
|
||||
$dockerComposeDomainsJson->put(data_get($domain, 'name'), $entry);
|
||||
});
|
||||
$request->offsetUnset('docker_compose_domains');
|
||||
}
|
||||
@@ -1488,13 +1566,14 @@ class ApplicationsController extends Controller
|
||||
'github_app_uuid' => 'string|required',
|
||||
'watch_paths' => 'string|nullable',
|
||||
'docker_compose_domains' => 'array|nullable',
|
||||
'docker_compose_domains.*' => 'array:name,domain',
|
||||
'docker_compose_domains.*' => 'array:name,domain,redirect',
|
||||
'docker_compose_domains.*.name' => 'string|required',
|
||||
'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(),
|
||||
'docker_compose_domains.*.redirect' => 'nullable|string|in:www,non-www,both',
|
||||
];
|
||||
$validationRules = array_merge(sharedDataApplications(), $validationRules);
|
||||
$validationMessages = [
|
||||
'docker_compose_domains.*.array' => 'An item in the docker_compose_domains array has invalid fields. Only a name and domain field are supported.',
|
||||
'docker_compose_domains.*.array' => 'An item in the docker_compose_domains array has invalid fields. Only name, domain, and redirect fields are supported.',
|
||||
];
|
||||
$validator = Validator::make($request->all(), $validationRules, $validationMessages);
|
||||
if ($validator->fails()) {
|
||||
@@ -1624,7 +1703,12 @@ class ApplicationsController extends Controller
|
||||
}
|
||||
|
||||
$dockerComposeDomains->each(function ($domain) use ($dockerComposeDomainsJson) {
|
||||
$dockerComposeDomainsJson->put(data_get($domain, 'name'), ['domain' => data_get($domain, 'domain')]);
|
||||
$entry = ['domain' => data_get($domain, 'domain')];
|
||||
$redirect = data_get($domain, 'redirect');
|
||||
if (in_array($redirect, ['www', 'non-www', 'both'], true)) {
|
||||
$entry['redirect'] = $redirect;
|
||||
}
|
||||
$dockerComposeDomainsJson->put(data_get($domain, 'name'), $entry);
|
||||
});
|
||||
$request->offsetUnset('docker_compose_domains');
|
||||
}
|
||||
@@ -1740,14 +1824,15 @@ class ApplicationsController extends Controller
|
||||
'private_key_uuid' => 'string|required',
|
||||
'watch_paths' => 'string|nullable',
|
||||
'docker_compose_domains' => 'array|nullable',
|
||||
'docker_compose_domains.*' => 'array:name,domain',
|
||||
'docker_compose_domains.*' => 'array:name,domain,redirect',
|
||||
'docker_compose_domains.*.name' => 'string|required',
|
||||
'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(),
|
||||
'docker_compose_domains.*.redirect' => 'nullable|string|in:www,non-www,both',
|
||||
];
|
||||
|
||||
$validationRules = array_merge(sharedDataApplications(), $validationRules);
|
||||
$validationMessages = [
|
||||
'docker_compose_domains.*.array' => 'An item in the docker_compose_domains array has invalid fields. Only a name and domain field are supported.',
|
||||
'docker_compose_domains.*.array' => 'An item in the docker_compose_domains array has invalid fields. Only name, domain, and redirect fields are supported.',
|
||||
];
|
||||
$validator = Validator::make($request->all(), $validationRules, $validationMessages);
|
||||
|
||||
@@ -1849,7 +1934,12 @@ class ApplicationsController extends Controller
|
||||
}
|
||||
|
||||
$dockerComposeDomains->each(function ($domain) use ($dockerComposeDomainsJson) {
|
||||
$dockerComposeDomainsJson->put(data_get($domain, 'name'), ['domain' => data_get($domain, 'domain')]);
|
||||
$entry = ['domain' => data_get($domain, 'domain')];
|
||||
$redirect = data_get($domain, 'redirect');
|
||||
if (in_array($redirect, ['www', 'non-www', 'both'], true)) {
|
||||
$entry['redirect'] = $redirect;
|
||||
}
|
||||
$dockerComposeDomainsJson->put(data_get($domain, 'name'), $entry);
|
||||
});
|
||||
$request->offsetUnset('docker_compose_domains');
|
||||
}
|
||||
@@ -2584,6 +2674,7 @@ class ApplicationsController extends Controller
|
||||
properties: [
|
||||
'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'],
|
||||
'domain' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io")'],
|
||||
'redirect' => ['type' => 'string', 'nullable' => true, 'description' => 'Per-service www/non-www redirect for this compose service.', 'enum' => ['www', 'non-www', 'both']],
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -2603,6 +2694,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 +2793,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', 'noindex_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', 'noindex_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',
|
||||
@@ -2700,9 +2801,10 @@ class ApplicationsController extends Controller
|
||||
'static_image' => 'string',
|
||||
'watch_paths' => 'string|nullable',
|
||||
'docker_compose_domains' => 'array|nullable',
|
||||
'docker_compose_domains.*' => 'array:name,domain',
|
||||
'docker_compose_domains.*' => 'array:name,domain,redirect',
|
||||
'docker_compose_domains.*.name' => 'string|required',
|
||||
'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(),
|
||||
'docker_compose_domains.*.redirect' => 'nullable|string|in:www,non-www,both',
|
||||
'custom_nginx_configuration' => 'string|nullable',
|
||||
'is_http_basic_auth_enabled' => 'boolean|nullable',
|
||||
'is_preview_deployments_enabled' => 'boolean|nullable',
|
||||
@@ -2712,7 +2814,7 @@ class ApplicationsController extends Controller
|
||||
];
|
||||
$validationRules = array_merge(sharedDataApplications(), $validationRules);
|
||||
$validationMessages = [
|
||||
'docker_compose_domains.*.array' => 'An item in the docker_compose_domains array has invalid fields. Only a name and domain field are supported.',
|
||||
'docker_compose_domains.*.array' => 'An item in the docker_compose_domains array has invalid fields. Only name, domain, and redirect fields are supported.',
|
||||
];
|
||||
$validator = Validator::make($request->all(), $validationRules, $validationMessages);
|
||||
|
||||
@@ -2920,10 +3022,18 @@ class ApplicationsController extends Controller
|
||||
|
||||
$yaml = Yaml::parse($application->docker_compose_raw);
|
||||
$services = data_get($yaml, 'services', []);
|
||||
$dockerComposeDomains->each(function ($domain) use ($services, $dockerComposeDomainsJson) {
|
||||
$existingDockerComposeDomains = json_decode($application->docker_compose_domains ?? '[]', true) ?? [];
|
||||
$dockerComposeDomains->each(function ($domain) use ($services, $dockerComposeDomainsJson, $existingDockerComposeDomains) {
|
||||
$name = data_get($domain, 'name');
|
||||
if ($name && is_array($services) && isset($services[$name])) {
|
||||
$dockerComposeDomainsJson->put($name, ['domain' => data_get($domain, 'domain')]);
|
||||
$entry = ['domain' => data_get($domain, 'domain')];
|
||||
$redirect = array_key_exists('redirect', $domain)
|
||||
? data_get($domain, 'redirect')
|
||||
: data_get($existingDockerComposeDomains[$name] ?? [], 'redirect');
|
||||
if (in_array($redirect, ['www', 'non-www', 'both'], true)) {
|
||||
$entry['redirect'] = $redirect;
|
||||
}
|
||||
$dockerComposeDomainsJson->put($name, $entry);
|
||||
}
|
||||
});
|
||||
$request->offsetUnset('docker_compose_domains');
|
||||
@@ -3855,9 +3965,9 @@ class ApplicationsController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
#[OA\Post(
|
||||
summary: 'Start',
|
||||
description: 'Start application. `Post` request is also accepted.',
|
||||
description: 'Start application.',
|
||||
path: '/applications/{uuid}/start',
|
||||
operationId: 'start-application-by-uuid',
|
||||
security: [
|
||||
@@ -3979,9 +4089,9 @@ class ApplicationsController extends Controller
|
||||
);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
#[OA\Post(
|
||||
summary: 'Stop',
|
||||
description: 'Stop application. `Post` request is also accepted.',
|
||||
description: 'Stop application.',
|
||||
path: '/applications/{uuid}/stop',
|
||||
operationId: 'stop-application-by-uuid',
|
||||
security: [
|
||||
@@ -4072,9 +4182,9 @@ class ApplicationsController extends Controller
|
||||
);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
#[OA\Post(
|
||||
summary: 'Restart',
|
||||
description: 'Restart application. `Post` request is also accepted.',
|
||||
description: 'Restart application.',
|
||||
path: '/applications/{uuid}/restart',
|
||||
operationId: 'restart-application-by-uuid',
|
||||
security: [
|
||||
@@ -4263,6 +4373,54 @@ class ApplicationsController extends Controller
|
||||
return moveResourceToEnvironment($request, $application, 'Application', $teamId);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Migrate to Server',
|
||||
description: 'Migrate an application to another destination/server owned by the authenticated team. Stops the application, optionally transfers persistent volume data when both servers are managed by Coolify, and updates database records. Redeploy after migration completes.',
|
||||
path: '/applications/{uuid}/migrate',
|
||||
operationId: 'migrate-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 target destination.'),
|
||||
new OA\Property(property: 'migrate_volumes', type: 'boolean', default: true, description: 'Whether to transfer persistent volume data when migrating across servers.'),
|
||||
]
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Application migration started or completed.'),
|
||||
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 migrate_by_uuid(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
$uuid = $request->route('uuid');
|
||||
if (! $uuid) {
|
||||
return response()->json(['message' => 'UUID is required.'], 400);
|
||||
}
|
||||
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
|
||||
if (! $application) {
|
||||
return response()->json(['message' => 'Application not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('update', $application);
|
||||
|
||||
return migrateResourceToDestination($request, $application, 'Application', $teamId);
|
||||
}
|
||||
|
||||
private function validateDataApplications(Request $request, Server $server)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
@@ -4916,6 +5074,8 @@ class ApplicationsController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
$storage->abortIfScheduledBackupsExist();
|
||||
|
||||
if ($storage instanceof LocalFileVolume) {
|
||||
$storage->deleteStorageOnServer();
|
||||
}
|
||||
@@ -5153,4 +5313,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;
|
||||
|
||||
@@ -850,6 +854,12 @@ class DatabasesController extends Controller
|
||||
|
||||
$this->authorize('manageBackups', $database);
|
||||
|
||||
if (! $database->isBackupSolutionAvailable()) {
|
||||
return response()->json([
|
||||
'message' => 'Scheduled backups are not supported for this database type.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
// Validate frequency is a valid cron expression
|
||||
$isValid = validate_cron_expression($request->frequency);
|
||||
if (! $isValid) {
|
||||
@@ -915,6 +925,8 @@ class DatabasesController extends Controller
|
||||
$backupData['databases_to_backup'] = $database->mysql_database;
|
||||
} elseif ($database->type() === 'standalone-mariadb') {
|
||||
$backupData['databases_to_backup'] = $database->mariadb_database;
|
||||
} elseif ($database->type() === 'standalone-clickhouse') {
|
||||
$backupData['databases_to_backup'] = $database->clickhouse_db;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1805,6 +1817,12 @@ class DatabasesController extends Controller
|
||||
if (! $server) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
}
|
||||
if (! $server->canHostResources()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['server_uuid' => ['The specified server is configured as a build server and cannot host resources.']],
|
||||
], 422);
|
||||
}
|
||||
$destinations = $server->destinations();
|
||||
if ($destinations->count() == 0) {
|
||||
return response()->json(['message' => 'Server has no destinations.'], 400);
|
||||
@@ -3016,7 +3034,7 @@ class DatabasesController extends Controller
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function move_by_uuid(Request $request): \Illuminate\Http\JsonResponse
|
||||
public function move_by_uuid(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
@@ -3036,9 +3054,57 @@ class DatabasesController extends Controller
|
||||
return moveResourceToEnvironment($request, $database, 'Database', $teamId);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
#[OA\Post(
|
||||
summary: 'Migrate to Server',
|
||||
description: 'Migrate a database to another destination/server owned by the authenticated team. Stops the database, optionally transfers persistent volume data when both servers are managed by Coolify, and updates database records. Redeploy after migration completes.',
|
||||
path: '/databases/{uuid}/migrate',
|
||||
operationId: 'migrate-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', description: 'UUID of the target destination.'),
|
||||
new OA\Property(property: 'migrate_volumes', type: 'boolean', default: true, description: 'Whether to transfer persistent volume data when migrating across servers.'),
|
||||
]
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Database migration started or completed.'),
|
||||
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 migrate_by_uuid(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
$uuid = $request->route('uuid');
|
||||
if (! $uuid) {
|
||||
return response()->json(['message' => 'UUID is required.'], 400);
|
||||
}
|
||||
$database = queryDatabaseByUuidWithinTeam($request->uuid, $teamId);
|
||||
if (! $database) {
|
||||
return response()->json(['message' => 'Database not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('update', $database);
|
||||
|
||||
return migrateResourceToDestination($request, $database, 'Database', $teamId);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Start',
|
||||
description: 'Start database. `Post` request is also accepted.',
|
||||
description: 'Start database.',
|
||||
path: '/databases/{uuid}/start',
|
||||
operationId: 'start-database-by-uuid',
|
||||
security: [
|
||||
@@ -3123,9 +3189,9 @@ class DatabasesController extends Controller
|
||||
);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
#[OA\Post(
|
||||
summary: 'Stop',
|
||||
description: 'Stop database. `Post` request is also accepted.',
|
||||
description: 'Stop database.',
|
||||
path: '/databases/{uuid}/stop',
|
||||
operationId: 'stop-database-by-uuid',
|
||||
security: [
|
||||
@@ -3222,9 +3288,9 @@ class DatabasesController extends Controller
|
||||
);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
#[OA\Post(
|
||||
summary: 'Restart',
|
||||
description: 'Restart database. `Post` request is also accepted.',
|
||||
description: 'Restart database.',
|
||||
path: '/databases/{uuid}/restart',
|
||||
operationId: 'restart-database-by-uuid',
|
||||
security: [
|
||||
@@ -4470,6 +4536,8 @@ class DatabasesController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
$storage->abortIfScheduledBackupsExist();
|
||||
|
||||
if ($storage instanceof LocalFileVolume) {
|
||||
$storage->deleteStorageOnServer();
|
||||
}
|
||||
@@ -4632,4 +4700,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);
|
||||
}
|
||||
}
|
||||
@@ -304,9 +304,9 @@ class DeployController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
#[OA\Post(
|
||||
summary: 'Deploy',
|
||||
description: 'Deploy by tag or uuid. `Post` request also accepted with `uuid` and `tag` json body.',
|
||||
description: 'Deploy by tag or UUID using query parameters or a JSON body.',
|
||||
path: '/deploy',
|
||||
operationId: 'deploy-by-tag-or-uuid',
|
||||
security: [
|
||||
|
||||
@@ -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,540 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\GitlabApp;
|
||||
use App\Rules\SafeExternalUrl;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class GitlabController extends Controller
|
||||
{
|
||||
private function removeSensitiveData(GitlabApp $gitlabApp)
|
||||
{
|
||||
if (request()->attributes->get('can_read_sensitive', false) === true) {
|
||||
$gitlabApp->makeVisible([
|
||||
'client_secret',
|
||||
'webhook_token',
|
||||
'access_token',
|
||||
'refresh_token',
|
||||
]);
|
||||
} else {
|
||||
$gitlabApp->makeHidden([
|
||||
'client_secret',
|
||||
'webhook_token',
|
||||
'access_token',
|
||||
'refresh_token',
|
||||
]);
|
||||
}
|
||||
|
||||
return serializeApiResponse($gitlabApp);
|
||||
}
|
||||
|
||||
private function findTeamGitlabApp(int|string $gitlabAppId, int $teamId): GitlabApp
|
||||
{
|
||||
return GitlabApp::where('id', $gitlabAppId)
|
||||
->where('team_id', $teamId)
|
||||
->firstOrFail();
|
||||
}
|
||||
|
||||
private function gitlabApiUrlFromHtmlUrl(string $htmlUrl): string
|
||||
{
|
||||
return rtrim($htmlUrl, '/').'/api/v4';
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'List',
|
||||
description: 'List all GitLab apps for the current team (and system-wide sources).',
|
||||
path: '/gitlab-apps',
|
||||
operationId: 'list-gitlab-apps',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['GitLab Apps'],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'List of GitLab apps.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'array',
|
||||
items: new OA\Items(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'id' => ['type' => 'integer'],
|
||||
'uuid' => ['type' => 'string'],
|
||||
'name' => ['type' => 'string'],
|
||||
'api_url' => ['type' => 'string'],
|
||||
'html_url' => ['type' => 'string'],
|
||||
'custom_user' => ['type' => 'string'],
|
||||
'custom_port' => ['type' => 'integer'],
|
||||
'client_id' => ['type' => 'string', 'nullable' => true],
|
||||
'group_name' => ['type' => 'string', 'nullable' => true],
|
||||
'redirect_uri' => ['type' => 'string', 'nullable' => true],
|
||||
'is_system_wide' => ['type' => 'boolean'],
|
||||
'is_public' => ['type' => 'boolean'],
|
||||
'team_id' => ['type' => 'integer'],
|
||||
]
|
||||
)
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function list_gitlab_apps(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$gitlabApps = GitlabApp::where(function ($query) use ($teamId) {
|
||||
$query->where('team_id', $teamId)
|
||||
->orWhere('is_system_wide', true);
|
||||
})->get();
|
||||
|
||||
$gitlabApps = $gitlabApps->map(function ($app) {
|
||||
return $this->removeSensitiveData($app);
|
||||
});
|
||||
|
||||
return response()->json($gitlabApps);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Create GitLab App',
|
||||
description: 'Create a new GitLab app (OAuth source). Credentials may be supplied later via the UI or update endpoint.',
|
||||
path: '/gitlab-apps',
|
||||
operationId: 'create-gitlab-app',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['GitLab Apps'],
|
||||
requestBody: new OA\RequestBody(
|
||||
description: 'GitLab app creation payload.',
|
||||
required: true,
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'name' => ['type' => 'string', 'description' => 'Name of the GitLab app.'],
|
||||
'html_url' => ['type' => 'string', 'description' => 'GitLab instance URL (e.g., https://gitlab.com).'],
|
||||
'api_url' => ['type' => 'string', 'description' => 'GitLab API URL (defaults to {html_url}/api/v4).'],
|
||||
'custom_user' => ['type' => 'string', 'description' => 'Custom user for SSH access (default: git).'],
|
||||
'custom_port' => ['type' => 'integer', 'description' => 'Custom port for SSH access (default: 22).'],
|
||||
'group_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Optional comma-separated group names to filter repositories.'],
|
||||
'client_id' => ['type' => 'string', 'nullable' => true, 'description' => 'GitLab OAuth Application ID.'],
|
||||
'client_secret' => ['type' => 'string', 'nullable' => true, 'description' => 'GitLab OAuth Application Secret.'],
|
||||
'webhook_token' => ['type' => 'string', 'nullable' => true, 'description' => 'Webhook secret token (auto-generated when omitted).'],
|
||||
'redirect_uri' => ['type' => 'string', 'nullable' => true, 'description' => 'OAuth redirect URI registered in GitLab.'],
|
||||
'is_system_wide' => ['type' => 'boolean', 'description' => 'Is this app system-wide (non-cloud instances only).'],
|
||||
],
|
||||
required: ['name', 'html_url'],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 201,
|
||||
description: 'GitLab app created successfully.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'id' => ['type' => 'integer'],
|
||||
'uuid' => ['type' => 'string'],
|
||||
'name' => ['type' => 'string'],
|
||||
'api_url' => ['type' => 'string'],
|
||||
'html_url' => ['type' => 'string'],
|
||||
'custom_user' => ['type' => 'string'],
|
||||
'custom_port' => ['type' => 'integer'],
|
||||
'client_id' => ['type' => 'string', 'nullable' => true],
|
||||
'group_name' => ['type' => 'string', 'nullable' => true],
|
||||
'redirect_uri' => ['type' => 'string', 'nullable' => true],
|
||||
'is_system_wide' => ['type' => 'boolean'],
|
||||
'team_id' => ['type' => 'integer'],
|
||||
]
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function create_gitlab_app(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
$this->authorize('create', [GitlabApp::class]);
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
$allowedFields = [
|
||||
'name',
|
||||
'html_url',
|
||||
'api_url',
|
||||
'custom_user',
|
||||
'custom_port',
|
||||
'group_name',
|
||||
'client_id',
|
||||
'client_secret',
|
||||
'webhook_token',
|
||||
'redirect_uri',
|
||||
'is_system_wide',
|
||||
];
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'name' => 'required|string|max:255',
|
||||
'html_url' => ['required', 'string', 'url', new SafeExternalUrl],
|
||||
'api_url' => ['nullable', 'string', 'url', new SafeExternalUrl],
|
||||
'custom_user' => 'nullable|string|max:255',
|
||||
'custom_port' => 'nullable|integer|min:1|max:65535',
|
||||
'group_name' => 'nullable|string|max:255',
|
||||
'client_id' => 'nullable|string|max:255',
|
||||
'client_secret' => 'nullable|string',
|
||||
'webhook_token' => 'nullable|string',
|
||||
// Callback to this Coolify instance — may be a private/LAN URL; do not use SafeExternalUrl.
|
||||
'redirect_uri' => ['nullable', 'string', 'url'],
|
||||
'is_system_wide' => 'boolean',
|
||||
]);
|
||||
|
||||
$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);
|
||||
}
|
||||
|
||||
try {
|
||||
$htmlUrl = rtrim((string) $request->input('html_url'), '/');
|
||||
$apiUrl = filled($request->input('api_url'))
|
||||
? rtrim((string) $request->input('api_url'), '/')
|
||||
: $this->gitlabApiUrlFromHtmlUrl($htmlUrl);
|
||||
|
||||
$payload = [
|
||||
'name' => $request->input('name'),
|
||||
'html_url' => $htmlUrl,
|
||||
'api_url' => $apiUrl,
|
||||
'custom_user' => $request->input('custom_user', 'git'),
|
||||
'custom_port' => $request->input('custom_port', 22),
|
||||
'group_name' => $request->input('group_name'),
|
||||
'client_id' => $request->input('client_id'),
|
||||
'client_secret' => $request->input('client_secret'),
|
||||
'webhook_token' => $request->input('webhook_token') ?: Str::random(32),
|
||||
'redirect_uri' => $request->input('redirect_uri'),
|
||||
'is_public' => false,
|
||||
'team_id' => $teamId,
|
||||
];
|
||||
|
||||
if (! isCloud()) {
|
||||
$payload['is_system_wide'] = $request->boolean('is_system_wide', false);
|
||||
}
|
||||
|
||||
$gitlabApp = GitlabApp::create($payload);
|
||||
|
||||
auditLog('api.gitlab_app.created', [
|
||||
'team_id' => $teamId,
|
||||
'gitlab_app_uuid' => $gitlabApp->uuid,
|
||||
'gitlab_app_name' => $gitlabApp->name,
|
||||
]);
|
||||
|
||||
return response()->json($this->removeSensitiveData($gitlabApp->fresh()), 201);
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e);
|
||||
}
|
||||
}
|
||||
|
||||
#[OA\Patch(
|
||||
path: '/gitlab-apps/{gitlab_app_id}',
|
||||
operationId: 'updateGitlabApp',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['GitLab Apps'],
|
||||
summary: 'Update GitLab App',
|
||||
description: 'Update an existing GitLab app.',
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'gitlab_app_id',
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'integer'),
|
||||
description: 'GitLab App ID'
|
||||
),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'name' => ['type' => 'string', 'description' => 'GitLab App name'],
|
||||
'html_url' => ['type' => 'string', 'description' => 'GitLab HTML URL'],
|
||||
'api_url' => ['type' => 'string', 'description' => 'GitLab API URL'],
|
||||
'custom_user' => ['type' => 'string', 'description' => 'Custom user for SSH'],
|
||||
'custom_port' => ['type' => 'integer', 'description' => 'Custom port for SSH'],
|
||||
'group_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Optional group filter'],
|
||||
'client_id' => ['type' => 'string', 'nullable' => true, 'description' => 'OAuth Application ID'],
|
||||
'client_secret' => ['type' => 'string', 'nullable' => true, 'description' => 'OAuth Application Secret'],
|
||||
'webhook_token' => ['type' => 'string', 'nullable' => true, 'description' => 'Webhook secret token'],
|
||||
'redirect_uri' => ['type' => 'string', 'nullable' => true, 'description' => 'OAuth redirect URI'],
|
||||
'is_system_wide' => ['type' => 'boolean', 'description' => 'Is system wide (non-cloud instances only)'],
|
||||
]
|
||||
)
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'GitLab app updated successfully',
|
||||
content: new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => ['type' => 'string', 'example' => 'GitLab app updated successfully'],
|
||||
'data' => ['type' => 'object', 'description' => 'Updated GitLab app data'],
|
||||
]
|
||||
)
|
||||
)
|
||||
),
|
||||
new OA\Response(response: 401, description: 'Unauthorized'),
|
||||
new OA\Response(response: 404, description: 'GitLab app not found'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
]
|
||||
)]
|
||||
public function update_gitlab_app(Request $request, $gitlab_app_id)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
try {
|
||||
$gitlabApp = $this->findTeamGitlabApp($gitlab_app_id, $teamId);
|
||||
$this->authorize('update', $gitlabApp);
|
||||
|
||||
$allowedFields = [
|
||||
'name',
|
||||
'html_url',
|
||||
'api_url',
|
||||
'custom_user',
|
||||
'custom_port',
|
||||
'group_name',
|
||||
'client_id',
|
||||
'client_secret',
|
||||
'webhook_token',
|
||||
'redirect_uri',
|
||||
];
|
||||
|
||||
if (! isCloud()) {
|
||||
$allowedFields[] = 'is_system_wide';
|
||||
}
|
||||
|
||||
$payload = $request->only($allowedFields);
|
||||
|
||||
$rules = [];
|
||||
if (isset($payload['name'])) {
|
||||
$rules['name'] = 'string|max:255';
|
||||
}
|
||||
if (isset($payload['html_url'])) {
|
||||
$rules['html_url'] = ['url', new SafeExternalUrl];
|
||||
}
|
||||
if (isset($payload['api_url'])) {
|
||||
$rules['api_url'] = ['url', new SafeExternalUrl];
|
||||
}
|
||||
if (isset($payload['custom_user'])) {
|
||||
$rules['custom_user'] = 'string|max:255';
|
||||
}
|
||||
if (isset($payload['custom_port'])) {
|
||||
$rules['custom_port'] = 'integer|min:1|max:65535';
|
||||
}
|
||||
if (array_key_exists('group_name', $payload)) {
|
||||
$rules['group_name'] = 'nullable|string|max:255';
|
||||
}
|
||||
if (array_key_exists('client_id', $payload)) {
|
||||
$rules['client_id'] = 'nullable|string|max:255';
|
||||
}
|
||||
if (array_key_exists('client_secret', $payload)) {
|
||||
$rules['client_secret'] = 'nullable|string';
|
||||
}
|
||||
if (array_key_exists('webhook_token', $payload)) {
|
||||
$rules['webhook_token'] = 'nullable|string';
|
||||
}
|
||||
if (array_key_exists('redirect_uri', $payload)) {
|
||||
// Callback to this Coolify instance — may be a private/LAN URL.
|
||||
$rules['redirect_uri'] = 'nullable|url';
|
||||
}
|
||||
if (! isCloud() && isset($payload['is_system_wide'])) {
|
||||
$rules['is_system_wide'] = 'boolean';
|
||||
}
|
||||
|
||||
$validator = customApiValidator($payload, $rules);
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation error',
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
if (isset($payload['html_url'])) {
|
||||
$payload['html_url'] = rtrim((string) $payload['html_url'], '/');
|
||||
if (! filled($payload['api_url'] ?? null)) {
|
||||
$payload['api_url'] = $this->gitlabApiUrlFromHtmlUrl($payload['html_url']);
|
||||
}
|
||||
}
|
||||
if (isset($payload['api_url'])) {
|
||||
$payload['api_url'] = rtrim((string) $payload['api_url'], '/');
|
||||
}
|
||||
|
||||
$gitlabApp->update($payload);
|
||||
|
||||
auditLog('api.gitlab_app.updated', [
|
||||
'team_id' => $teamId,
|
||||
'gitlab_app_uuid' => $gitlabApp->uuid,
|
||||
'gitlab_app_name' => $gitlabApp->name,
|
||||
'changed_fields' => array_values(array_diff(array_keys($payload), ['client_secret', 'webhook_token'])),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'GitLab app updated successfully',
|
||||
'data' => $this->removeSensitiveData($gitlabApp->fresh()),
|
||||
]);
|
||||
} catch (ModelNotFoundException $e) {
|
||||
return response()->json([
|
||||
'message' => 'GitLab app not found',
|
||||
], 404);
|
||||
}
|
||||
}
|
||||
|
||||
#[OA\Delete(
|
||||
path: '/gitlab-apps/{gitlab_app_id}',
|
||||
operationId: 'deleteGitlabApp',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['GitLab Apps'],
|
||||
summary: 'Delete GitLab App',
|
||||
description: 'Delete a GitLab app if it is not being used by any applications.',
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'gitlab_app_id',
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'integer'),
|
||||
description: 'GitLab App ID'
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'GitLab app deleted successfully',
|
||||
content: new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => ['type' => 'string', 'example' => 'GitLab app deleted successfully'],
|
||||
]
|
||||
)
|
||||
)
|
||||
),
|
||||
new OA\Response(response: 401, description: 'Unauthorized'),
|
||||
new OA\Response(response: 404, description: 'GitLab app not found'),
|
||||
new OA\Response(
|
||||
response: 409,
|
||||
description: 'Conflict - GitLab app is in use',
|
||||
content: new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => ['type' => 'string', 'example' => 'This GitLab app is being used by 5 application(s). Please delete all applications first.'],
|
||||
]
|
||||
)
|
||||
)
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function delete_gitlab_app($gitlab_app_id)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
try {
|
||||
$gitlabApp = $this->findTeamGitlabApp($gitlab_app_id, $teamId);
|
||||
$this->authorize('delete', $gitlabApp);
|
||||
|
||||
if ($gitlabApp->applications->isNotEmpty()) {
|
||||
$count = $gitlabApp->applications->count();
|
||||
|
||||
return response()->json([
|
||||
'message' => "This GitLab app is being used by {$count} application(s). Please delete all applications first.",
|
||||
], 409);
|
||||
}
|
||||
|
||||
$deletedUuid = $gitlabApp->uuid;
|
||||
$deletedName = $gitlabApp->name;
|
||||
$gitlabApp->delete();
|
||||
|
||||
auditLog('api.gitlab_app.deleted', [
|
||||
'team_id' => $teamId,
|
||||
'gitlab_app_uuid' => $deletedUuid,
|
||||
'gitlab_app_name' => $deletedName,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'GitLab app deleted successfully',
|
||||
]);
|
||||
} catch (ModelNotFoundException $e) {
|
||||
return response()->json([
|
||||
'message' => 'GitLab app not found',
|
||||
], 404);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Internal;
|
||||
|
||||
use App\Actions\V5\Flux\ApplyFluxResourceStatusUpdate;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
class FluxResourceStatusController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
if (! $this->authorizedBearer($request)) {
|
||||
abort(401);
|
||||
}
|
||||
|
||||
$validated = Validator::make($request->all(), [
|
||||
'resource_type' => ['required', 'string', 'max:64'],
|
||||
'team_id' => ['prohibited'],
|
||||
'application_id' => ['prohibited'],
|
||||
'resource_id' => ['prohibited'],
|
||||
'server_id' => ['prohibited'],
|
||||
'host_server_id' => ['prohibited'],
|
||||
'application_uuid' => ['nullable', 'string', 'max:255'],
|
||||
'resource_uuid' => ['nullable', 'string', 'max:255'],
|
||||
'server_uuid' => ['nullable', 'string', 'max:255'],
|
||||
'host_server_uuid' => ['nullable', 'string', 'max:255'],
|
||||
'host_id' => ['nullable', 'string', 'max:255'],
|
||||
'node_id' => ['nullable', 'string', 'max:255'],
|
||||
'server_host' => ['nullable', 'string', 'max:255'],
|
||||
'container_id' => ['nullable', 'string', 'max:255'],
|
||||
'runtime_container_id' => ['nullable', 'string', 'max:255'],
|
||||
'container_name' => ['nullable', 'string', 'max:255'],
|
||||
'name' => ['nullable', 'string', 'max:255'],
|
||||
'status' => ['required_without:state', 'string', 'max:64'],
|
||||
'state' => ['required_without:status', 'string', 'max:64'],
|
||||
'status_message' => ['nullable', 'string', 'max:1000'],
|
||||
'message' => ['nullable', 'string', 'max:1000'],
|
||||
'observed_at' => ['nullable', 'string', 'date'],
|
||||
])->validate();
|
||||
|
||||
$resource = ApplyFluxResourceStatusUpdate::run($validated);
|
||||
|
||||
if ($resource === null) {
|
||||
if (($validated['resource_type'] ?? null) === 'container') {
|
||||
return response()->json([
|
||||
'message' => 'Container status accepted.',
|
||||
], 202);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'No matching v5 resource was found.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Resource status updated.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constant-time match the presented bearer token against every accepted
|
||||
* inbound token. Accepting an array (config('flux.laravel_api_tokens'),
|
||||
* falling back to the single config('flux.laravel_api_token')) lets an
|
||||
* operator rotate by serving old+new tokens simultaneously.
|
||||
*
|
||||
* SECURITY: this is still a shared global secret — every flux instance
|
||||
* presents the same token, so it cannot be scoped or revoked per-flux, and
|
||||
* a leak forces a fleet-wide rotation. The target design is per-flux,
|
||||
* individually rotatable tokens; until then the array support above is the
|
||||
* mitigation that makes rotation possible without downtime.
|
||||
*/
|
||||
private function authorizedBearer(Request $request): bool
|
||||
{
|
||||
$presented = (string) $request->bearerToken();
|
||||
|
||||
if ($presented === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($this->acceptedTokens() as $token) {
|
||||
if (hash_equals($token, $presented)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function acceptedTokens(): array
|
||||
{
|
||||
$tokens = config('flux.laravel_api_tokens', []);
|
||||
$tokens = is_array($tokens) ? $tokens : [];
|
||||
|
||||
$single = config('flux.laravel_api_token');
|
||||
|
||||
if (is_string($single) && $single !== '') {
|
||||
$tokens[] = $single;
|
||||
}
|
||||
|
||||
return array_values(array_filter(
|
||||
array_map(fn ($token): string => is_string($token) ? $token : '', $tokens),
|
||||
fn (string $token): bool => $token !== ''
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,20 @@
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class OtherController extends Controller
|
||||
{
|
||||
public function post_required(): JsonResponse
|
||||
{
|
||||
return response()
|
||||
->json(['message' => 'This endpoint has changed to a POST request.'], 405)
|
||||
->header('Allow', 'POST');
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Version',
|
||||
description: 'Get Coolify version.',
|
||||
@@ -41,7 +49,7 @@ class OtherController extends Controller
|
||||
return response(config('constants.coolify.version'));
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
#[OA\Post(
|
||||
summary: 'Enable API',
|
||||
description: 'Enable API (only with root permissions).',
|
||||
path: '/enable',
|
||||
@@ -97,7 +105,7 @@ class OtherController extends Controller
|
||||
return response()->json(['message' => 'API enabled.'], 200);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
#[OA\Post(
|
||||
summary: 'Disable API',
|
||||
description: 'Disable API (only with root permissions).',
|
||||
path: '/disable',
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Server;
|
||||
use App\Services\ServerTransfer\ServerTransferBundle;
|
||||
use App\Services\ServerTransfer\ServerTransferClaimer;
|
||||
use App\Services\ServerTransfer\ServerTransferExporter;
|
||||
use App\Services\ServerTransfer\ServerTransferImporter;
|
||||
use App\Services\ServerTransfer\ServerTransferMigrator;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Throwable;
|
||||
|
||||
class ServerTransferController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private ServerTransferExporter $exporter,
|
||||
private ServerTransferImporter $importer,
|
||||
private ServerTransferClaimer $claimer,
|
||||
private ServerTransferMigrator $migrator,
|
||||
) {
|
||||
abort_unless(isDev(), 404);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Migrate server to another Coolify instance',
|
||||
description: 'One-shot handoff: export this server, import+claim on the target instance (using the provided token), then disable automations here. Requires read:sensitive and write.',
|
||||
path: '/servers/{uuid}/migrate',
|
||||
operationId: 'migrate-server-between-instances',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Servers'],
|
||||
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(
|
||||
required: ['target_url', 'target_token'],
|
||||
properties: [
|
||||
new OA\Property(property: 'target_url', type: 'string', example: 'https://coolify-b.example.com'),
|
||||
new OA\Property(property: 'target_token', type: 'string', description: 'API token on the target instance (root or write)'),
|
||||
new OA\Property(property: 'write_remote', type: 'boolean', default: false),
|
||||
new OA\Property(property: 'rebind_sentinel', type: 'boolean', default: true),
|
||||
new OA\Property(property: 'preserve_uuids', type: 'boolean', default: true),
|
||||
new OA\Property(property: 'adopt_mode', type: 'boolean', default: true),
|
||||
]
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Migrated'),
|
||||
new OA\Response(response: 403, description: 'Missing sensitive permission'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 422, description: 'Validation or remote import failed'),
|
||||
]
|
||||
)]
|
||||
public function migrate(Request $request, string $uuid): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
if (! $this->canReadSensitive($request)) {
|
||||
return response()->json([
|
||||
'message' => 'Migrating a server requires a token with read:sensitive (or root) ability and an admin/owner team role.',
|
||||
], 403);
|
||||
}
|
||||
|
||||
$server = Server::whereTeamId($teamId)->whereUuid($uuid)->first();
|
||||
if (! $server) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('update', $server);
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'target_url' => 'required|string|url',
|
||||
'target_token' => 'required|string',
|
||||
'write_remote' => 'boolean|nullable',
|
||||
'rebind_sentinel' => 'boolean|nullable',
|
||||
'preserve_uuids' => 'boolean|nullable',
|
||||
'adopt_mode' => 'boolean|nullable',
|
||||
]);
|
||||
$allowedFields = ['target_url', 'target_token', 'write_remote', 'rebind_sentinel', 'preserve_uuids', 'adopt_mode'];
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
if ($validator->fails() || $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);
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->migrator->migrate(
|
||||
server: $server,
|
||||
targetUrl: $request->string('target_url')->toString(),
|
||||
targetToken: $request->string('target_token')->toString(),
|
||||
writeRemote: $request->boolean('write_remote', false),
|
||||
rebindSentinel: $request->boolean('rebind_sentinel', true),
|
||||
preserveUuids: $request->boolean('preserve_uuids', true),
|
||||
adoptMode: $request->boolean('adopt_mode', true),
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
return response()->json(['message' => $e->getMessage()], 422);
|
||||
}
|
||||
|
||||
auditLog('api.server.migrate', [
|
||||
'team_id' => $teamId,
|
||||
'server_uuid' => $server->uuid,
|
||||
'export_id' => $result['export_id'],
|
||||
'target_url' => $result['target_url'],
|
||||
]);
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Export server transfer bundle',
|
||||
description: 'Export a server and all resources hosted on it as a versioned transfer bundle for moving between Coolify instances. Requires read:sensitive.',
|
||||
path: '/servers/{uuid}/export',
|
||||
operationId: 'export-server-transfer-bundle',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Servers'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'encrypt', in: 'query', required: false, description: 'If true and passphrase is provided, return an encrypted envelope.', schema: new OA\Schema(type: 'boolean')),
|
||||
new OA\Parameter(name: 'passphrase', in: 'query', required: false, description: 'Passphrase used when encrypt=true.', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Transfer bundle'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 403, description: 'Missing sensitive permission'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
]
|
||||
)]
|
||||
public function export(Request $request, string $uuid): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
if (! $this->canReadSensitive($request)) {
|
||||
return response()->json([
|
||||
'message' => 'Exporting a server requires a token with read:sensitive (or root) ability and an admin/owner team role.',
|
||||
], 403);
|
||||
}
|
||||
|
||||
$server = Server::whereTeamId($teamId)->whereUuid($uuid)->first();
|
||||
if (! $server) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('view', $server);
|
||||
|
||||
try {
|
||||
$bundle = $this->exporter->export($server, includeSensitive: true);
|
||||
} catch (Throwable $e) {
|
||||
return response()->json(['message' => $e->getMessage()], 422);
|
||||
}
|
||||
|
||||
auditLog('api.server.export', [
|
||||
'team_id' => $teamId,
|
||||
'server_uuid' => $server->uuid,
|
||||
'export_id' => data_get($bundle, 'export_id'),
|
||||
]);
|
||||
|
||||
if ($request->boolean('encrypt') && $request->filled('passphrase')) {
|
||||
return response()->json(
|
||||
ServerTransferBundle::encryptWithPassphrase($bundle, $request->string('passphrase')->toString())
|
||||
);
|
||||
}
|
||||
|
||||
return response()->json($bundle);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Import server transfer bundle',
|
||||
description: 'Import a server transfer bundle into this Coolify instance (adopt mode by default).',
|
||||
path: '/servers/import',
|
||||
operationId: 'import-server-transfer-bundle',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Servers'],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\JsonContent(
|
||||
properties: [
|
||||
new OA\Property(property: 'bundle', type: 'object', description: 'Plain or encrypted transfer bundle'),
|
||||
new OA\Property(property: 'passphrase', type: 'string', nullable: true),
|
||||
new OA\Property(property: 'dry_run', type: 'boolean', default: false),
|
||||
new OA\Property(property: 'preserve_uuids', type: 'boolean', default: true),
|
||||
new OA\Property(property: 'adopt_mode', type: 'boolean', default: true, description: 'Import without forcing redeploy; keep statuses for adoption'),
|
||||
new OA\Property(property: 'claim', type: 'boolean', default: true, description: 'Automatically claim the host for this instance after import'),
|
||||
new OA\Property(property: 'write_remote', type: 'boolean', default: false, description: 'When claiming, write ownership file on the host via SSH'),
|
||||
new OA\Property(property: 'rebind_sentinel', type: 'boolean', default: true, description: 'When claiming, rebind Sentinel to this instance'),
|
||||
]
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Dry-run result'),
|
||||
new OA\Response(response: 201, description: 'Imported'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 422, description: 'Validation failed'),
|
||||
]
|
||||
)]
|
||||
public function import(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$this->authorize('create', Server::class);
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'bundle' => 'required|array',
|
||||
'passphrase' => 'string|nullable',
|
||||
'dry_run' => 'boolean|nullable',
|
||||
'preserve_uuids' => 'boolean|nullable',
|
||||
'adopt_mode' => 'boolean|nullable',
|
||||
'claim' => 'boolean|nullable',
|
||||
'write_remote' => 'boolean|nullable',
|
||||
'rebind_sentinel' => 'boolean|nullable',
|
||||
]);
|
||||
$allowedFields = ['bundle', 'passphrase', 'dry_run', 'preserve_uuids', 'adopt_mode', 'claim', 'write_remote', 'rebind_sentinel'];
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
if ($validator->fails() || $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);
|
||||
}
|
||||
|
||||
$bundle = $request->input('bundle', []);
|
||||
if (data_get($bundle, 'encrypted')) {
|
||||
if (! $request->filled('passphrase')) {
|
||||
return response()->json(['message' => 'Passphrase is required for encrypted bundles.'], 422);
|
||||
}
|
||||
try {
|
||||
$bundle = ServerTransferBundle::decryptWithPassphrase($bundle, $request->string('passphrase')->toString());
|
||||
} catch (Throwable $e) {
|
||||
return response()->json(['message' => $e->getMessage()], 422);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->importer->import(
|
||||
bundle: $bundle,
|
||||
teamId: $teamId,
|
||||
dryRun: $request->boolean('dry_run', false),
|
||||
preserveUuids: $request->boolean('preserve_uuids', true),
|
||||
adoptMode: $request->boolean('adopt_mode', true),
|
||||
claim: $request->boolean('claim', true),
|
||||
writeRemote: $request->boolean('write_remote', false),
|
||||
rebindSentinel: $request->boolean('rebind_sentinel', true),
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
$status = $e instanceof ValidationException ? 422 : 422;
|
||||
$payload = ['message' => $e->getMessage()];
|
||||
if ($e instanceof ValidationException) {
|
||||
$payload['errors'] = $e->errors();
|
||||
}
|
||||
|
||||
return response()->json($payload, $status);
|
||||
}
|
||||
|
||||
auditLog('api.server.import', [
|
||||
'team_id' => $teamId,
|
||||
'server_uuid' => $result['server_uuid'],
|
||||
'export_id' => $result['export_id'],
|
||||
'dry_run' => $result['dry_run'],
|
||||
]);
|
||||
|
||||
return response()->json($result, $result['dry_run'] ? 200 : 201);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Claim imported server',
|
||||
description: 'Claim a managed host for this instance: write ownership file and rebind Sentinel.',
|
||||
path: '/servers/{uuid}/claim',
|
||||
operationId: 'claim-server',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Servers'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
content: new OA\JsonContent(
|
||||
properties: [
|
||||
new OA\Property(property: 'write_remote', type: 'boolean', default: true),
|
||||
new OA\Property(property: 'rebind_sentinel', type: 'boolean', default: true),
|
||||
]
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Claim result'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
]
|
||||
)]
|
||||
public function claim(Request $request, string $uuid): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$server = Server::whereTeamId($teamId)->whereUuid($uuid)->first();
|
||||
if (! $server) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('update', $server);
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'write_remote' => 'boolean|nullable',
|
||||
'rebind_sentinel' => 'boolean|nullable',
|
||||
]);
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->claimer->claim(
|
||||
$server,
|
||||
writeRemote: $request->boolean('write_remote', true),
|
||||
rebindSentinel: $request->boolean('rebind_sentinel', true),
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
return response()->json(['message' => $e->getMessage()], 422);
|
||||
}
|
||||
|
||||
auditLog('api.server.claim', [
|
||||
'team_id' => $teamId,
|
||||
'server_uuid' => $server->uuid,
|
||||
'claim_written' => $result['claim_written'],
|
||||
]);
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Mark server transferred',
|
||||
description: 'Source-instance step: disable automations after a successful export/import handoff.',
|
||||
path: '/servers/{uuid}/transfer/complete',
|
||||
operationId: 'complete-server-transfer',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Servers'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
content: new OA\JsonContent(
|
||||
properties: [
|
||||
new OA\Property(property: 'export_id', type: 'string', nullable: true),
|
||||
new OA\Property(property: 'target_instance_url', type: 'string', nullable: true),
|
||||
]
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Marked transferred'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
]
|
||||
)]
|
||||
public function complete(Request $request, string $uuid): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$server = Server::whereTeamId($teamId)->whereUuid($uuid)->first();
|
||||
if (! $server) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('update', $server);
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'export_id' => 'string|nullable',
|
||||
'target_instance_url' => 'string|nullable',
|
||||
]);
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->claimer->markTransferred(
|
||||
$server,
|
||||
exportId: $request->input('export_id'),
|
||||
targetInstanceUrl: $request->input('target_instance_url'),
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
return response()->json(['message' => $e->getMessage()], 422);
|
||||
}
|
||||
|
||||
auditLog('api.server.transfer_complete', [
|
||||
'team_id' => $teamId,
|
||||
'server_uuid' => $server->uuid,
|
||||
'export_id' => $request->input('export_id'),
|
||||
]);
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Write transfer bundle to server mailbox',
|
||||
description: 'Write an export bundle to /data/coolify/exports on the managed host for air-gapped import.',
|
||||
path: '/servers/{uuid}/export/mailbox',
|
||||
operationId: 'export-server-transfer-mailbox',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Servers'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
content: new OA\JsonContent(
|
||||
properties: [
|
||||
new OA\Property(property: 'passphrase', type: 'string', nullable: true),
|
||||
]
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Mailbox write result'),
|
||||
new OA\Response(response: 403, description: 'Missing sensitive permission'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
]
|
||||
)]
|
||||
public function writeMailbox(Request $request, string $uuid): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
if (! $this->canReadSensitive($request)) {
|
||||
return response()->json([
|
||||
'message' => 'Writing a transfer mailbox requires read:sensitive (or root) ability and an admin/owner team role.',
|
||||
], 403);
|
||||
}
|
||||
|
||||
$server = Server::whereTeamId($teamId)->whereUuid($uuid)->first();
|
||||
if (! $server) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('view', $server);
|
||||
|
||||
try {
|
||||
$bundle = $this->exporter->export($server, includeSensitive: true);
|
||||
$result = $this->claimer->writeMailbox(
|
||||
$server,
|
||||
$bundle,
|
||||
$request->filled('passphrase') ? $request->string('passphrase')->toString() : null,
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
return response()->json(['message' => $e->getMessage()], 422);
|
||||
}
|
||||
|
||||
auditLog('api.server.export_mailbox', [
|
||||
'team_id' => $teamId,
|
||||
'server_uuid' => $server->uuid,
|
||||
'export_id' => data_get($bundle, 'export_id'),
|
||||
'path' => $result['path'],
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'export_id' => data_get($bundle, 'export_id'),
|
||||
'path' => $result['path'],
|
||||
'written' => $result['written'],
|
||||
'message' => $result['written']
|
||||
? 'Transfer bundle written to server mailbox.'
|
||||
: 'Failed to write mailbox on remote host.',
|
||||
], $result['written'] ? 200 : 422);
|
||||
}
|
||||
|
||||
private function canReadSensitive(Request $request): bool
|
||||
{
|
||||
return (bool) $request->attributes->get('can_read_sensitive', false);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use App\Enums\ProxyStatus;
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\DeleteResourceJob;
|
||||
use App\Jobs\ValidateAndInstallServerJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
@@ -662,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)) {
|
||||
@@ -689,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(),
|
||||
]);
|
||||
@@ -736,6 +738,13 @@ class ServersController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
if ($request->boolean('is_build_server') && ! $server->isBuildServer() && ! $server->isEmpty()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['is_build_server' => ['A server with existing resources cannot be configured as a build server.']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$server->update($updateFields);
|
||||
if ($request->has('is_build_server')) {
|
||||
$server->settings()->update([
|
||||
@@ -743,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)));
|
||||
@@ -843,7 +858,7 @@ class ServersController extends Controller
|
||||
if ($server->definedResources()->count() > 0 && ! $force) {
|
||||
return response()->json(['message' => 'Server has resources. Use ?force=true to delete all resources and the server, or delete resources manually first.'], 400);
|
||||
}
|
||||
if ($server->isLocalhost()) {
|
||||
if ($server->is_coolify_host) {
|
||||
return response()->json(['message' => 'Local server cannot be deleted.'], 400);
|
||||
}
|
||||
|
||||
@@ -880,7 +895,7 @@ class ServersController extends Controller
|
||||
return response()->json(['message' => 'Server deleted.']);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
#[OA\Post(
|
||||
summary: 'Validate',
|
||||
description: 'Validate server by UUID.',
|
||||
path: '/servers/{uuid}/validate',
|
||||
@@ -892,6 +907,19 @@ class ServersController extends Controller
|
||||
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: 'install',
|
||||
description: 'Install missing prerequisites and Docker. This can restart the Docker daemon.',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 201,
|
||||
@@ -941,14 +969,39 @@ class ServersController extends Controller
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
}
|
||||
$this->authorize('update', $server);
|
||||
ValidateServer::dispatch($server);
|
||||
|
||||
if (! $server->canBeValidated()) {
|
||||
return response()->json([
|
||||
'message' => 'This server was transferred to another Coolify instance and cannot be revalidated here.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'install' => 'boolean',
|
||||
]);
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$install = $request->boolean('install', false);
|
||||
if ($install) {
|
||||
ValidateAndInstallServerJob::dispatch($server);
|
||||
} else {
|
||||
ValidateServer::dispatch($server);
|
||||
}
|
||||
|
||||
auditLog('api.server.validated', [
|
||||
'team_id' => $teamId,
|
||||
'server_uuid' => $server->uuid,
|
||||
'server_name' => $server->name,
|
||||
'install' => $install,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Validation started.'], 201);
|
||||
$message = $install ? 'Validation and installation started.' : 'Validation started.';
|
||||
|
||||
return response()->json(['message' => $message], 201);
|
||||
}
|
||||
}
|
||||
@@ -434,6 +434,33 @@ class ServiceApplicationsController extends Controller
|
||||
),
|
||||
]
|
||||
)]
|
||||
#[OA\Post(
|
||||
summary: 'Get service application logs',
|
||||
description: 'Get Docker logs for a single compose service container.',
|
||||
path: '/services/{uuid}/applications/{app_uuid}/logs',
|
||||
operationId: 'post-service-application-logs-by-service-and-app-uuid',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Service applications'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'app_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'lines', in: 'query', required: false, schema: new OA\Schema(type: 'integer', format: 'int32', default: 100)),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Logs.',
|
||||
content: new OA\JsonContent(
|
||||
type: 'object',
|
||||
properties: [new OA\Property(property: 'logs', 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: 501, description: 'Swarm not supported.'),
|
||||
]
|
||||
)]
|
||||
public function logs_by_uuid(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
@@ -473,7 +500,7 @@ class ServiceApplicationsController extends Controller
|
||||
], 400);
|
||||
}
|
||||
|
||||
$lines = (int) ($request->query('lines', 100) ?: 100);
|
||||
$lines = normalizeLogLines($request->query('lines'));
|
||||
$logs = getContainerLogs($server, $containerName, $lines);
|
||||
|
||||
return response()->json([
|
||||
@@ -481,73 +508,32 @@ class ServiceApplicationsController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
#[OA\Post(
|
||||
summary: 'Start or redeploy service application container',
|
||||
description: 'Runs docker compose up for a single compose service (no-deps), optionally pulling the image and rebuilding.',
|
||||
path: '/services/{uuid}/applications/{app_uuid}/start',
|
||||
operationId: 'start-service-application-by-service-and-app-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
operationId: 'post-start-service-application-by-service-and-app-uuid',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Service applications'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'Service UUID.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'app_uuid',
|
||||
in: 'path',
|
||||
description: 'Service application UUID.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'force',
|
||||
in: 'query',
|
||||
description: 'When true, passes --build to docker compose up.',
|
||||
required: false,
|
||||
schema: new OA\Schema(type: 'boolean', default: false)
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'latest',
|
||||
in: 'query',
|
||||
description: 'When true, pulls the image for this compose service before up.',
|
||||
required: false,
|
||||
schema: new OA\Schema(type: 'boolean', default: false)
|
||||
),
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'app_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'force', in: 'query', required: false, schema: new OA\Schema(type: 'boolean', default: false)),
|
||||
new OA\Parameter(name: 'latest', in: 'query', required: false, schema: new OA\Schema(type: 'boolean', default: false)),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Deploy request queued.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => new OA\Property(property: 'message', type: 'string'),
|
||||
]
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 501,
|
||||
description: 'Swarm not supported.',
|
||||
content: new OA\JsonContent(
|
||||
type: 'object',
|
||||
properties: [new OA\Property(property: 'message', 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: 501, description: 'Swarm not supported.'),
|
||||
]
|
||||
)]
|
||||
public function action_start(Request $request): JsonResponse
|
||||
@@ -590,59 +576,30 @@ class ServiceApplicationsController extends Controller
|
||||
], 200);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
#[OA\Post(
|
||||
summary: 'Restart service application container',
|
||||
description: 'Restarts a single compose service container (docker restart).',
|
||||
description: 'Restarts a single compose service container.',
|
||||
path: '/services/{uuid}/applications/{app_uuid}/restart',
|
||||
operationId: 'restart-service-application-by-service-and-app-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
operationId: 'post-restart-service-application-by-service-and-app-uuid',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Service applications'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'Service UUID.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'app_uuid',
|
||||
in: 'path',
|
||||
description: 'Service application UUID.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'app_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Restart queued.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => new OA\Property(property: 'message', type: 'string'),
|
||||
]
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 501,
|
||||
description: 'Swarm not supported.',
|
||||
content: new OA\JsonContent(
|
||||
type: 'object',
|
||||
properties: [new OA\Property(property: 'message', 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: 501, description: 'Swarm not supported.'),
|
||||
]
|
||||
)]
|
||||
public function action_restart(Request $request): JsonResponse
|
||||
@@ -682,59 +639,30 @@ class ServiceApplicationsController extends Controller
|
||||
], 200);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
#[OA\Post(
|
||||
summary: 'Stop service application container',
|
||||
description: 'Stops a single compose service container (docker stop).',
|
||||
description: 'Stops a single compose service container.',
|
||||
path: '/services/{uuid}/applications/{app_uuid}/stop',
|
||||
operationId: 'stop-service-application-by-service-and-app-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
operationId: 'post-stop-service-application-by-service-and-app-uuid',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Service applications'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'Service UUID.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'app_uuid',
|
||||
in: 'path',
|
||||
description: 'Service application UUID.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'app_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Stop queued.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => new OA\Property(property: 'message', type: 'string'),
|
||||
]
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 501,
|
||||
description: 'Swarm not supported.',
|
||||
content: new OA\JsonContent(
|
||||
type: 'object',
|
||||
properties: [new OA\Property(property: 'message', 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: 501, description: 'Swarm not supported.'),
|
||||
]
|
||||
)]
|
||||
public function action_stop(Request $request): JsonResponse
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Actions\Database\StartDatabaseProxy;
|
||||
use App\Actions\Database\StopDatabaseProxy;
|
||||
use App\Actions\Service\DeployServiceApplication;
|
||||
use App\Actions\Service\RestartServiceApplication;
|
||||
use App\Actions\Service\StopServiceApplication;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceDatabase;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class ServiceDatabasesController extends Controller
|
||||
{
|
||||
private function removeSensitiveData(ServiceDatabase $serviceDatabase): array
|
||||
{
|
||||
$serviceDatabase->makeHidden([
|
||||
'id',
|
||||
'service',
|
||||
'service_id',
|
||||
'resourceable',
|
||||
'resourceable_id',
|
||||
'resourceable_type',
|
||||
]);
|
||||
|
||||
$serialized = serializeApiResponse($serviceDatabase);
|
||||
|
||||
if ($serialized instanceof Collection) {
|
||||
return $serialized->all();
|
||||
}
|
||||
|
||||
return (array) $serialized;
|
||||
}
|
||||
|
||||
private function resolveService(Request $request, int $teamId): ?Service
|
||||
{
|
||||
return Service::whereRelation('environment.project.team', 'id', $teamId)
|
||||
->whereUuid($request->route('uuid'))
|
||||
->first();
|
||||
}
|
||||
|
||||
private function resolveServiceDatabase(Request $request, Service $service): ?ServiceDatabase
|
||||
{
|
||||
return $service->databases()
|
||||
->where('uuid', $request->route('database_uuid'))
|
||||
->with(['service.destination.server'])
|
||||
->first();
|
||||
}
|
||||
|
||||
private function swarmNotSupportedResponse(): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'message' => 'This operation is not supported for Swarm servers yet.',
|
||||
], 501);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'List service databases',
|
||||
description: 'List compose databases for a single service.',
|
||||
path: '/services/{uuid}/databases',
|
||||
operationId: 'list-service-databases-by-service-uuid',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Service databases'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', description: 'Service UUID.', required: true, schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Service databases.', content: new OA\JsonContent(type: 'array', items: new OA\Items(type: 'object'))),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
]
|
||||
)]
|
||||
public function index(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);
|
||||
}
|
||||
|
||||
$this->authorize('view', $service);
|
||||
|
||||
$databases = $service->databases()
|
||||
->get()
|
||||
->map(fn (ServiceDatabase $database) => $this->removeSensitiveData($database));
|
||||
|
||||
return response()->json($databases);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Get service database',
|
||||
description: 'Get a compose database by service UUID and database UUID.',
|
||||
path: '/services/{uuid}/databases/{database_uuid}',
|
||||
operationId: 'get-service-database-by-service-and-database-uuid',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Service databases'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', description: 'Service UUID.', required: true, schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'database_uuid', in: 'path', description: 'Service database UUID.', required: true, schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Service database.', content: new OA\JsonContent(type: 'object')),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
]
|
||||
)]
|
||||
public function show(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);
|
||||
}
|
||||
|
||||
$serviceDatabase = $this->resolveServiceDatabase($request, $service);
|
||||
if (! $serviceDatabase) {
|
||||
return response()->json(['message' => 'Service database not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('view', $serviceDatabase);
|
||||
|
||||
return response()->json($this->removeSensitiveData($serviceDatabase));
|
||||
}
|
||||
|
||||
#[OA\Patch(
|
||||
summary: 'Update service database',
|
||||
description: 'Update mutable fields for a compose service database.',
|
||||
path: '/services/{uuid}/databases/{database_uuid}',
|
||||
operationId: 'patch-service-database-by-service-and-database-uuid',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Service databases'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', description: 'Service UUID.', required: true, schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'database_uuid', in: 'path', description: 'Service database UUID.', required: true, schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
content: new OA\JsonContent(
|
||||
type: 'object',
|
||||
properties: [
|
||||
new OA\Property(property: 'human_name', type: 'string', nullable: true),
|
||||
new OA\Property(property: 'description', type: 'string', nullable: true),
|
||||
new OA\Property(property: 'image', type: 'string'),
|
||||
new OA\Property(property: 'exclude_from_status', type: 'boolean'),
|
||||
new OA\Property(property: 'is_log_drain_enabled', type: 'boolean'),
|
||||
new OA\Property(property: 'is_public', type: 'boolean'),
|
||||
new OA\Property(property: 'public_port', type: 'integer', nullable: true, minimum: 1, maximum: 65535),
|
||||
new OA\Property(property: 'public_port_timeout', type: 'integer', nullable: true, minimum: 1),
|
||||
],
|
||||
additionalProperties: false,
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Updated service database.', content: new OA\JsonContent(type: 'object')),
|
||||
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 update(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$invalidRequest = validateIncomingRequest($request);
|
||||
if ($invalidRequest instanceof JsonResponse) {
|
||||
return $invalidRequest;
|
||||
}
|
||||
|
||||
$service = $this->resolveService($request, $teamId);
|
||||
if (! $service) {
|
||||
return response()->json(['message' => 'Service not found.'], 404);
|
||||
}
|
||||
|
||||
$serviceDatabase = $this->resolveServiceDatabase($request, $service);
|
||||
if (! $serviceDatabase) {
|
||||
return response()->json(['message' => 'Service database not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('update', $serviceDatabase);
|
||||
|
||||
$payload = $request->json()->all();
|
||||
if (empty($payload)) {
|
||||
$payload = $request->request->all();
|
||||
}
|
||||
|
||||
$allowedFields = [
|
||||
'human_name',
|
||||
'description',
|
||||
'image',
|
||||
'exclude_from_status',
|
||||
'is_log_drain_enabled',
|
||||
'is_public',
|
||||
'public_port',
|
||||
'public_port_timeout',
|
||||
];
|
||||
$validator = Validator::make($payload, [
|
||||
'human_name' => 'nullable|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'image' => 'sometimes|string',
|
||||
'exclude_from_status' => 'sometimes|boolean',
|
||||
'is_log_drain_enabled' => 'sometimes|boolean',
|
||||
'is_public' => 'sometimes|boolean',
|
||||
'public_port' => 'nullable|integer|min:1|max:65535',
|
||||
'public_port_timeout' => 'nullable|integer|min:1',
|
||||
]);
|
||||
|
||||
$extraFields = array_diff(array_keys($payload), $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 = $serviceDatabase->service->destination->server;
|
||||
if (($payload['is_log_drain_enabled'] ?? false) && ! $server->isLogDrainEnabled()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['is_log_drain_enabled' => ['Log drain is not enabled on the server for this service.']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$isPublic = $payload['is_public'] ?? $serviceDatabase->is_public;
|
||||
$publicPort = $payload['public_port'] ?? $serviceDatabase->public_port;
|
||||
if ($isPublic && ! $publicPort) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['public_port' => ['A public port is required when the database is public.']],
|
||||
], 422);
|
||||
}
|
||||
if ($isPublic && isPublicPortAlreadyUsed($server, $publicPort, $serviceDatabase->id)) {
|
||||
return response()->json(['message' => 'Public port already used by another database.'], 400);
|
||||
}
|
||||
|
||||
$shouldStartProxy = ($payload['is_public'] ?? null) === true && ! $serviceDatabase->is_public;
|
||||
$shouldStopProxy = ($payload['is_public'] ?? null) === false && $serviceDatabase->is_public;
|
||||
|
||||
$serviceDatabase->fill($payload);
|
||||
$serviceDatabase->save();
|
||||
$serviceDatabase->refresh();
|
||||
updateCompose($serviceDatabase);
|
||||
|
||||
if ($shouldStartProxy) {
|
||||
StartDatabaseProxy::dispatch($serviceDatabase);
|
||||
} elseif ($shouldStopProxy) {
|
||||
StopDatabaseProxy::dispatch($serviceDatabase);
|
||||
}
|
||||
|
||||
auditLog('api.service_database.updated', [
|
||||
'team_id' => $teamId,
|
||||
'service_uuid' => $service->uuid,
|
||||
'service_database_uuid' => $serviceDatabase->uuid,
|
||||
'changed_fields' => array_keys($payload),
|
||||
]);
|
||||
|
||||
return response()->json($this->removeSensitiveData($serviceDatabase));
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Get service database logs',
|
||||
description: 'Get Docker logs for a compose database container.',
|
||||
path: '/services/{uuid}/databases/{database_uuid}/logs',
|
||||
operationId: 'get-service-database-logs-by-service-and-database-uuid',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Service databases'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'database_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'lines', in: 'query', required: false, schema: new OA\Schema(type: 'integer', format: 'int32', default: 100)),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Logs.', content: new OA\JsonContent(type: 'object', properties: [new OA\Property(property: 'logs', 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: 501, description: 'Swarm not supported.'),
|
||||
]
|
||||
)]
|
||||
public function logs(Request $request): JsonResponse
|
||||
{
|
||||
$resolved = $this->resolveDatabaseRequest($request, 'view');
|
||||
if ($resolved instanceof JsonResponse) {
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
[$serviceDatabase, $server] = $resolved;
|
||||
$containerName = $serviceDatabase->name.'-'.$serviceDatabase->service->uuid;
|
||||
if (getContainerStatus($server, $containerName) !== 'running') {
|
||||
return response()->json(['message' => 'Service database container is not running.'], 400);
|
||||
}
|
||||
|
||||
$lines = normalizeLogLines($request->query('lines'));
|
||||
|
||||
return response()->json([
|
||||
'logs' => getContainerLogs($server, $containerName, $lines),
|
||||
]);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Start or redeploy service database container',
|
||||
description: 'Run docker compose up for a single compose database.',
|
||||
path: '/services/{uuid}/databases/{database_uuid}/start',
|
||||
operationId: 'start-service-database-by-service-and-database-uuid',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Service databases'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'database_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'force', in: 'query', required: false, schema: new OA\Schema(type: 'boolean', default: false)),
|
||||
new OA\Parameter(name: 'latest', in: 'query', required: false, schema: new OA\Schema(type: 'boolean', default: false)),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Deploy request queued.', content: new OA\JsonContent(type: 'object', properties: [new OA\Property(property: 'message', 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: 501, description: 'Swarm not supported.'),
|
||||
]
|
||||
)]
|
||||
public function start(Request $request): JsonResponse
|
||||
{
|
||||
$resolved = $this->resolveDatabaseRequest($request, 'deploy');
|
||||
if ($resolved instanceof JsonResponse) {
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
[$serviceDatabase] = $resolved;
|
||||
DeployServiceApplication::dispatch(
|
||||
$serviceDatabase,
|
||||
$request->boolean('latest'),
|
||||
$request->boolean('force'),
|
||||
);
|
||||
|
||||
return response()->json(['message' => 'Service database deploy request queued.']);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Restart service database container',
|
||||
description: 'Restart a compose database container.',
|
||||
path: '/services/{uuid}/databases/{database_uuid}/restart',
|
||||
operationId: 'restart-service-database-by-service-and-database-uuid',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Service databases'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'database_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Restart queued.', content: new OA\JsonContent(type: 'object', properties: [new OA\Property(property: 'message', 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: 501, description: 'Swarm not supported.'),
|
||||
]
|
||||
)]
|
||||
public function restart(Request $request): JsonResponse
|
||||
{
|
||||
$resolved = $this->resolveDatabaseRequest($request, 'deploy');
|
||||
if ($resolved instanceof JsonResponse) {
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
[$serviceDatabase] = $resolved;
|
||||
RestartServiceApplication::dispatch($serviceDatabase);
|
||||
|
||||
return response()->json(['message' => 'Service database restart request queued.']);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Stop service database container',
|
||||
description: 'Stop a compose database container.',
|
||||
path: '/services/{uuid}/databases/{database_uuid}/stop',
|
||||
operationId: 'stop-service-database-by-service-and-database-uuid',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Service databases'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'database_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Stop queued.', content: new OA\JsonContent(type: 'object', properties: [new OA\Property(property: 'message', 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: 501, description: 'Swarm not supported.'),
|
||||
]
|
||||
)]
|
||||
public function stop(Request $request): JsonResponse
|
||||
{
|
||||
$resolved = $this->resolveDatabaseRequest($request, 'deploy');
|
||||
if ($resolved instanceof JsonResponse) {
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
[$serviceDatabase] = $resolved;
|
||||
StopServiceApplication::dispatch($serviceDatabase);
|
||||
|
||||
return response()->json(['message' => 'Service database stop request queued.']);
|
||||
}
|
||||
|
||||
private function resolveDatabaseRequest(Request $request, string $ability): array|JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$service = $this->resolveService($request, $teamId);
|
||||
if (! $service) {
|
||||
return response()->json(['message' => 'Service not found.'], 404);
|
||||
}
|
||||
|
||||
$serviceDatabase = $this->resolveServiceDatabase($request, $service);
|
||||
if (! $serviceDatabase) {
|
||||
return response()->json(['message' => 'Service database not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize($ability, $serviceDatabase);
|
||||
|
||||
$server = $serviceDatabase->service->destination->server;
|
||||
if ($server->isSwarm()) {
|
||||
return $this->swarmNotSupportedResponse();
|
||||
}
|
||||
if (! $server->isFunctional()) {
|
||||
return response()->json(['message' => 'Server is not functional.'], 400);
|
||||
}
|
||||
|
||||
return [$serviceDatabase, $server];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -444,6 +448,12 @@ class ServicesController extends Controller
|
||||
if (! $server) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
}
|
||||
if (! $server->canHostResources()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['server_uuid' => ['The specified server is configured as a build server and cannot host resources.']],
|
||||
], 422);
|
||||
}
|
||||
$destinations = $server->destinations();
|
||||
if ($destinations->count() == 0) {
|
||||
return response()->json(['message' => 'Server has no destinations.'], 400);
|
||||
@@ -501,7 +511,8 @@ class ServicesController extends Controller
|
||||
if (in_array($oneClickServiceName, NEEDS_TO_CONNECT_TO_PREDEFINED_NETWORK)) {
|
||||
data_set($servicePayload, 'connect_to_docker_network', true);
|
||||
}
|
||||
$service = Service::create($servicePayload);
|
||||
$service = new Service($servicePayload);
|
||||
$service->save();
|
||||
$service->name = $request->name ?? "$oneClickServiceName-".$service->uuid;
|
||||
$service->description = $request->description;
|
||||
if ($request->has('is_container_label_escape_enabled')) {
|
||||
@@ -639,6 +650,12 @@ class ServicesController extends Controller
|
||||
if (! $server) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
}
|
||||
if (! $server->canHostResources()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['server_uuid' => ['The specified server is configured as a build server and cannot host resources.']],
|
||||
], 422);
|
||||
}
|
||||
$destinations = $server->destinations();
|
||||
if ($destinations->count() == 0) {
|
||||
return response()->json(['message' => 'Server has no destinations.'], 400);
|
||||
@@ -1053,11 +1070,6 @@ class ServicesController extends Controller
|
||||
properties: [
|
||||
'name' => ['type' => 'string', 'description' => 'The service name.'],
|
||||
'description' => ['type' => 'string', 'description' => 'The service description.'],
|
||||
'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'],
|
||||
'environment_name' => ['type' => 'string', 'description' => 'The environment name.'],
|
||||
'environment_uuid' => ['type' => 'string', 'description' => 'The environment UUID.'],
|
||||
'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'],
|
||||
'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'],
|
||||
'instant_deploy' => ['type' => 'boolean', 'description' => 'The flag to indicate if the service should be deployed instantly.'],
|
||||
'connect_to_docker_network' => ['type' => 'boolean', 'default' => false, 'description' => 'Connect the service to the predefined docker network.'],
|
||||
'docker_compose_raw' => ['type' => 'string', 'description' => 'The base64 encoded Docker Compose content.'],
|
||||
@@ -1942,7 +1954,7 @@ class ServicesController extends Controller
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function move_by_uuid(Request $request): \Illuminate\Http\JsonResponse
|
||||
public function move_by_uuid(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
@@ -1962,9 +1974,57 @@ class ServicesController extends Controller
|
||||
return moveResourceToEnvironment($request, $service, 'Service', $teamId);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
#[OA\Post(
|
||||
summary: 'Migrate to Server',
|
||||
description: 'Migrate a service to another destination/server owned by the authenticated team. Stops the service, optionally transfers persistent volume data when both servers are managed by Coolify, and updates database records. Redeploy after migration completes.',
|
||||
path: '/services/{uuid}/migrate',
|
||||
operationId: 'migrate-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', description: 'UUID of the target destination.'),
|
||||
new OA\Property(property: 'migrate_volumes', type: 'boolean', default: true, description: 'Whether to transfer persistent volume data when migrating across servers.'),
|
||||
]
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Service migration started or completed.'),
|
||||
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 migrate_by_uuid(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
$uuid = $request->route('uuid');
|
||||
if (! $uuid) {
|
||||
return response()->json(['message' => 'UUID is required.'], 400);
|
||||
}
|
||||
$service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first();
|
||||
if (! $service) {
|
||||
return response()->json(['message' => 'Service not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('update', $service);
|
||||
|
||||
return migrateResourceToDestination($request, $service, 'Service', $teamId);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Start',
|
||||
description: 'Start service. `Post` request is also accepted.',
|
||||
description: 'Start service.',
|
||||
path: '/services/{uuid}/start',
|
||||
operationId: 'start-service-by-uuid',
|
||||
security: [
|
||||
@@ -2048,9 +2108,9 @@ class ServicesController extends Controller
|
||||
);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
#[OA\Post(
|
||||
summary: 'Stop',
|
||||
description: 'Stop service. `Post` request is also accepted.',
|
||||
description: 'Stop service.',
|
||||
path: '/services/{uuid}/stop',
|
||||
operationId: 'stop-service-by-uuid',
|
||||
security: [
|
||||
@@ -2146,9 +2206,9 @@ class ServicesController extends Controller
|
||||
);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
#[OA\Post(
|
||||
summary: 'Restart',
|
||||
description: 'Restart service. `Post` request is also accepted.',
|
||||
description: 'Restart service.',
|
||||
path: '/services/{uuid}/restart',
|
||||
operationId: 'restart-service-by-uuid',
|
||||
security: [
|
||||
@@ -2903,6 +2963,8 @@ class ServicesController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
$storage->abortIfScheduledBackupsExist();
|
||||
|
||||
if ($storage instanceof LocalFileVolume) {
|
||||
$storage->deleteStorageOnServer();
|
||||
}
|
||||
@@ -3065,4 +3127,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',
|
||||
|
||||
@@ -0,0 +1,545 @@
|
||||
<?php
|
||||
|
||||
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;
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\ScheduledVolumeBackup;
|
||||
use App\Models\Service;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\MessageBag;
|
||||
use OpenApi\Attributes as OA;
|
||||
use RuntimeException;
|
||||
|
||||
#[OA\Schema(
|
||||
schema: 'VolumeBackupScheduleRequest',
|
||||
required: ['frequency'],
|
||||
properties: [
|
||||
new OA\Property(property: 'frequency', type: 'string', maxLength: 255, example: '0 2 * * *'),
|
||||
new OA\Property(property: 'enabled', type: 'boolean', default: true),
|
||||
new OA\Property(property: 'save_s3', type: 'boolean', default: false),
|
||||
new OA\Property(property: 'disable_local_backup', type: 'boolean', default: false),
|
||||
new OA\Property(property: 'stop_during_backup', type: 'boolean', default: false),
|
||||
new OA\Property(property: 's3_storage_uuid', type: 'string', nullable: true),
|
||||
new OA\Property(property: 'retention_amount_locally', type: 'integer', default: 7, minimum: 0, maximum: 10000),
|
||||
new OA\Property(property: 'retention_days_locally', type: 'integer', default: 0, maximum: 2147483647, minimum: 0),
|
||||
new OA\Property(property: 'retention_max_storage_locally', type: 'number', format: 'float', default: 0, maximum: 9999999999, minimum: 0),
|
||||
new OA\Property(property: 'retention_amount_s3', type: 'integer', default: 7, minimum: 0, maximum: 10000),
|
||||
new OA\Property(property: 'retention_days_s3', type: 'integer', default: 0, maximum: 2147483647, minimum: 0),
|
||||
new OA\Property(property: 'retention_max_storage_s3', type: 'number', format: 'float', default: 0, maximum: 9999999999, minimum: 0),
|
||||
new OA\Property(property: 'timeout', type: 'integer', default: 3600, minimum: 60, maximum: 36000),
|
||||
],
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
)]
|
||||
#[OA\Schema(
|
||||
schema: 'VolumeBackupScheduleResponse',
|
||||
required: ['uuid', 'message', 'storage_uuid', 'storage_type', 'frequency', 'enabled', 'save_s3', 'disable_local_backup', 'stop_during_backup', 'retention_amount_locally', 'retention_days_locally', 'retention_max_storage_locally', 'retention_amount_s3', 'retention_days_s3', 'retention_max_storage_s3', 'timeout'],
|
||||
properties: [
|
||||
new OA\Property(property: 'uuid', type: 'string'),
|
||||
new OA\Property(property: 'message', type: 'string'),
|
||||
new OA\Property(property: 'storage_uuid', type: 'string'),
|
||||
new OA\Property(property: 'storage_type', type: 'string', enum: ['persistent', 'directory']),
|
||||
new OA\Property(property: 'frequency', type: 'string'),
|
||||
new OA\Property(property: 'enabled', type: 'boolean'),
|
||||
new OA\Property(property: 'save_s3', type: 'boolean'),
|
||||
new OA\Property(property: 'disable_local_backup', type: 'boolean'),
|
||||
new OA\Property(property: 'stop_during_backup', type: 'boolean'),
|
||||
new OA\Property(property: 's3_storage_uuid', type: 'string', nullable: true),
|
||||
new OA\Property(property: 'retention_amount_locally', type: 'integer'),
|
||||
new OA\Property(property: 'retention_days_locally', type: 'integer'),
|
||||
new OA\Property(property: 'retention_max_storage_locally', type: 'number', format: 'float'),
|
||||
new OA\Property(property: 'retention_amount_s3', type: 'integer'),
|
||||
new OA\Property(property: 'retention_days_s3', type: 'integer'),
|
||||
new OA\Property(property: 'retention_max_storage_s3', type: 'number', format: 'float'),
|
||||
new OA\Property(property: 'timeout', type: 'integer'),
|
||||
],
|
||||
type: 'object',
|
||||
)]
|
||||
class VolumeBackupsController extends Controller
|
||||
{
|
||||
#[OA\Put(
|
||||
summary: 'Set application storage backup schedule',
|
||||
description: 'Create or replace the backup schedule for an application persistent volume or directory storage.',
|
||||
path: '/applications/{uuid}/storages/{storage_uuid}/backups',
|
||||
operationId: 'set-application-storage-backup-schedule',
|
||||
security: [['bearerAuth' => []]],
|
||||
requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/VolumeBackupScheduleRequest')),
|
||||
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: 'storage_uuid', in: 'path', required: true, description: 'UUID of the persistent volume or directory storage.', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Backup schedule replaced.', content: new OA\JsonContent(ref: '#/components/schemas/VolumeBackupScheduleResponse')),
|
||||
new OA\Response(response: 201, description: 'Backup schedule created.', content: new OA\JsonContent(ref: '#/components/schemas/VolumeBackupScheduleResponse')),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
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'),
|
||||
],
|
||||
)]
|
||||
#[OA\Put(
|
||||
summary: 'Set database storage backup schedule',
|
||||
description: 'Create or replace the backup schedule for a database persistent volume or directory storage.',
|
||||
path: '/databases/{uuid}/storages/{storage_uuid}/backups',
|
||||
operationId: 'set-database-storage-backup-schedule',
|
||||
security: [['bearerAuth' => []]],
|
||||
requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/VolumeBackupScheduleRequest')),
|
||||
tags: ['Databases'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'UUID of the database.', schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'storage_uuid', in: 'path', required: true, description: 'UUID of the persistent volume or directory storage.', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Backup schedule replaced.', content: new OA\JsonContent(ref: '#/components/schemas/VolumeBackupScheduleResponse')),
|
||||
new OA\Response(response: 201, description: 'Backup schedule created.', content: new OA\JsonContent(ref: '#/components/schemas/VolumeBackupScheduleResponse')),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
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'),
|
||||
],
|
||||
)]
|
||||
#[OA\Put(
|
||||
summary: 'Set service storage backup schedule',
|
||||
description: 'Create or replace the backup schedule for a service persistent volume or directory storage.',
|
||||
path: '/services/{uuid}/storages/{storage_uuid}/backups',
|
||||
operationId: 'set-service-storage-backup-schedule',
|
||||
security: [['bearerAuth' => []]],
|
||||
requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/VolumeBackupScheduleRequest')),
|
||||
tags: ['Services'],
|
||||
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: 'storage_uuid', in: 'path', required: true, description: 'UUID of the persistent volume or directory storage.', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Backup schedule replaced.', content: new OA\JsonContent(ref: '#/components/schemas/VolumeBackupScheduleResponse')),
|
||||
new OA\Response(response: 201, description: 'Backup schedule created.', content: new OA\JsonContent(ref: '#/components/schemas/VolumeBackupScheduleResponse')),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
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 upsert(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$invalidRequest = validateIncomingRequest($request);
|
||||
if ($invalidRequest instanceof JsonResponse) {
|
||||
return $invalidRequest;
|
||||
}
|
||||
|
||||
$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);
|
||||
}
|
||||
|
||||
['errors' => $errors, 's3Storage' => $s3Storage, 'saveToS3' => $saveToS3] = $this->validateUpsertRequest($request, $storage, $teamId);
|
||||
|
||||
if ($errors->isNotEmpty()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $errors,
|
||||
], 422);
|
||||
}
|
||||
|
||||
return $this->persistSchedule($request, $storage, $teamId, $s3Storage, $saveToS3, $resourceType, $resource);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{errors: MessageBag, s3Storage: S3Storage|null, saveToS3: bool}
|
||||
*/
|
||||
private function validateUpsertRequest(
|
||||
Request $request,
|
||||
LocalPersistentVolume|LocalFileVolume $storage,
|
||||
int|string $teamId,
|
||||
): array {
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'frequency' => 'required|string|max:255',
|
||||
'enabled' => 'boolean',
|
||||
'save_s3' => 'boolean',
|
||||
'disable_local_backup' => 'boolean',
|
||||
'stop_during_backup' => 'boolean',
|
||||
's3_storage_uuid' => 'nullable|string',
|
||||
'retention_amount_locally' => 'integer|min:0|max:10000',
|
||||
'retention_days_locally' => 'integer|min:0|max:2147483647',
|
||||
'retention_max_storage_locally' => 'numeric|min:0|max:9999999999',
|
||||
'retention_amount_s3' => 'integer|min:0|max:10000',
|
||||
'retention_days_s3' => 'integer|min:0|max:2147483647',
|
||||
'retention_max_storage_s3' => 'numeric|min:0|max:9999999999',
|
||||
'timeout' => 'integer|min:60|max:36000',
|
||||
]);
|
||||
$errors = $validator->errors();
|
||||
$allowedFields = [
|
||||
'frequency',
|
||||
'enabled',
|
||||
'save_s3',
|
||||
'disable_local_backup',
|
||||
'stop_during_backup',
|
||||
's3_storage_uuid',
|
||||
'retention_amount_locally',
|
||||
'retention_days_locally',
|
||||
'retention_max_storage_locally',
|
||||
'retention_amount_s3',
|
||||
'retention_days_s3',
|
||||
'retention_max_storage_s3',
|
||||
'timeout',
|
||||
];
|
||||
|
||||
foreach (array_diff(array_keys($request->all()), $allowedFields) as $field) {
|
||||
$errors->add($field, 'This field is not allowed.');
|
||||
}
|
||||
|
||||
if (! $errors->has('frequency') && ! validate_cron_expression($request->string('frequency')->toString())) {
|
||||
$errors->add('frequency', 'The frequency must be a valid cron or human expression.');
|
||||
}
|
||||
|
||||
$saveToS3 = $request->boolean('save_s3');
|
||||
if ($request->boolean('disable_local_backup') && ! $saveToS3) {
|
||||
$errors->add('disable_local_backup', 'Local backups can only be disabled when S3 backups are enabled.');
|
||||
}
|
||||
|
||||
$s3Storage = null;
|
||||
if ($saveToS3) {
|
||||
$s3Storage = S3Storage::query()
|
||||
->where('team_id', $teamId)
|
||||
->where('is_usable', true)
|
||||
->where('uuid', $request->input('s3_storage_uuid'))
|
||||
->first();
|
||||
|
||||
if (! $s3Storage) {
|
||||
$errors->add('s3_storage_uuid', 'Select a usable S3 storage owned by your team.');
|
||||
}
|
||||
}
|
||||
|
||||
if ($storage instanceof LocalFileVolume && (! $storage->is_directory || $storage->is_host_file)) {
|
||||
$errors->add('storage_uuid', 'Only directory file storages can be backed up.');
|
||||
}
|
||||
|
||||
return [
|
||||
'errors' => $errors,
|
||||
's3Storage' => $s3Storage,
|
||||
'saveToS3' => $saveToS3,
|
||||
];
|
||||
}
|
||||
|
||||
private function persistSchedule(
|
||||
Request $request,
|
||||
LocalPersistentVolume|LocalFileVolume $storage,
|
||||
int|string $teamId,
|
||||
?S3Storage $s3Storage,
|
||||
bool $saveToS3,
|
||||
string $resourceType,
|
||||
Model $resource,
|
||||
): JsonResponse {
|
||||
$backup = $storage->scheduledBackups()->updateOrCreate([], [
|
||||
'team_id' => $teamId,
|
||||
'frequency' => $request->string('frequency')->toString(),
|
||||
'enabled' => $request->boolean('enabled', true),
|
||||
'save_s3' => $saveToS3,
|
||||
'disable_local_backup' => $saveToS3 && $request->boolean('disable_local_backup'),
|
||||
'stop_during_backup' => $request->boolean('stop_during_backup'),
|
||||
's3_storage_id' => $s3Storage?->id,
|
||||
'retention_amount_locally' => $request->integer('retention_amount_locally', 7),
|
||||
'retention_days_locally' => $request->integer('retention_days_locally'),
|
||||
'retention_max_storage_locally' => $request->float('retention_max_storage_locally'),
|
||||
'retention_amount_s3' => $request->integer('retention_amount_s3', 7),
|
||||
'retention_days_s3' => $request->integer('retention_days_s3'),
|
||||
'retention_max_storage_s3' => $request->float('retention_max_storage_s3'),
|
||||
'timeout' => $request->integer('timeout', 3600),
|
||||
]);
|
||||
$created = $backup->wasRecentlyCreated;
|
||||
|
||||
auditLog('api.volume_backup.schedule_set', [
|
||||
'team_id' => $teamId,
|
||||
'resource_type' => $resourceType,
|
||||
'resource_uuid' => $resource->uuid,
|
||||
'storage_uuid' => $storage->uuid,
|
||||
'backup_uuid' => $backup->uuid,
|
||||
]);
|
||||
|
||||
return response()->json($this->responseData($backup, $storage, $s3Storage, $created), $created ? 201 : 200);
|
||||
}
|
||||
|
||||
#[OA\Delete(
|
||||
summary: 'Delete application storage backup schedule',
|
||||
description: 'Delete the backup schedule and its local and S3 archives for an application storage.',
|
||||
path: '/applications/{uuid}/storages/{storage_uuid}/backups',
|
||||
operationId: 'delete-application-storage-backup-schedule',
|
||||
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: 'Backup schedule and archives 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'),
|
||||
new OA\Response(response: 409, description: 'Backup or recovery operation is still running.'),
|
||||
],
|
||||
)]
|
||||
#[OA\Delete(
|
||||
summary: 'Delete database storage backup schedule',
|
||||
description: 'Delete the backup schedule and its local and S3 archives for a database storage.',
|
||||
path: '/databases/{uuid}/storages/{storage_uuid}/backups',
|
||||
operationId: 'delete-database-storage-backup-schedule',
|
||||
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: 'Backup schedule and archives 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'),
|
||||
new OA\Response(response: 409, description: 'Backup or recovery operation is still running.'),
|
||||
],
|
||||
)]
|
||||
#[OA\Delete(
|
||||
summary: 'Delete service storage backup schedule',
|
||||
description: 'Delete the backup schedule and its local and S3 archives for a service storage.',
|
||||
path: '/services/{uuid}/storages/{storage_uuid}/backups',
|
||||
operationId: 'delete-service-storage-backup-schedule',
|
||||
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: 'Backup schedule and archives 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'),
|
||||
new OA\Response(response: 409, description: 'Backup or recovery operation is still running.'),
|
||||
],
|
||||
)]
|
||||
public function destroy(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' => '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);
|
||||
}
|
||||
|
||||
try {
|
||||
DeleteScheduledVolumeBackup::run($backup);
|
||||
} catch (RuntimeException $exception) {
|
||||
return response()->json(['message' => $exception->getMessage()], 409);
|
||||
}
|
||||
|
||||
auditLog('api.volume_backup.schedule_deleted', [
|
||||
'team_id' => $teamId,
|
||||
'resource_type' => $resourceType,
|
||||
'resource_uuid' => $resource->uuid,
|
||||
'storage_uuid' => $storage->uuid,
|
||||
'backup_uuid' => $backup->uuid,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Storage backup schedule and archives deleted.']);
|
||||
}
|
||||
|
||||
private function findResource(string $resourceType, string $uuid, int|string $teamId): ?Model
|
||||
{
|
||||
return match ($resourceType) {
|
||||
'application' => Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $uuid)->first(),
|
||||
'database' => queryDatabaseByUuidWithinTeam($uuid, $teamId),
|
||||
'service' => Service::query()->whereRelation('environment.project.team', 'id', $teamId)->where('uuid', $uuid)->first(),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function findStorage(Model $resource, string $storageUuid): LocalPersistentVolume|LocalFileVolume|null
|
||||
{
|
||||
if ($resource instanceof Service) {
|
||||
foreach ($resource->applications->concat($resource->databases) as $serviceResource) {
|
||||
$storage = $this->findStorage($serviceResource, $storageUuid);
|
||||
if ($storage) {
|
||||
return $storage;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$storage = $resource->persistentStorages()->where('uuid', $storageUuid)->first();
|
||||
|
||||
return $storage ?? $resource->fileStorages()->where('uuid', $storageUuid)->first();
|
||||
}
|
||||
|
||||
private function responseData(
|
||||
ScheduledVolumeBackup $backup,
|
||||
LocalPersistentVolume|LocalFileVolume $storage,
|
||||
?S3Storage $s3Storage,
|
||||
bool $created,
|
||||
): array {
|
||||
return [
|
||||
'uuid' => $backup->uuid,
|
||||
'message' => $created ? 'Storage backup schedule created.' : 'Storage backup schedule updated.',
|
||||
'storage_uuid' => $storage->uuid,
|
||||
'storage_type' => $storage instanceof LocalFileVolume ? 'directory' : 'persistent',
|
||||
'frequency' => $backup->frequency,
|
||||
'enabled' => $backup->enabled,
|
||||
'save_s3' => $backup->save_s3,
|
||||
'disable_local_backup' => $backup->disable_local_backup,
|
||||
'stop_during_backup' => $backup->stop_during_backup,
|
||||
's3_storage_uuid' => $s3Storage?->uuid,
|
||||
'retention_amount_locally' => $backup->retention_amount_locally,
|
||||
'retention_days_locally' => $backup->retention_days_locally,
|
||||
'retention_max_storage_locally' => $backup->retention_max_storage_locally,
|
||||
'retention_amount_s3' => $backup->retention_amount_s3,
|
||||
'retention_days_s3' => $backup->retention_days_s3,
|
||||
'retention_max_storage_s3' => $backup->retention_max_storage_s3,
|
||||
'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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ use App\Rules\ValidHostname;
|
||||
use App\Services\VultrService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class VultrController extends Controller
|
||||
@@ -286,6 +287,10 @@ class VultrController extends Controller
|
||||
return response()->json(['message' => 'Private key not found.'], 404);
|
||||
}
|
||||
|
||||
$vultrService = null;
|
||||
$vultrInstanceId = null;
|
||||
$server = null;
|
||||
|
||||
try {
|
||||
$vultrService = new VultrService($token->token);
|
||||
$publicKey = $privateKey->getPublicKey();
|
||||
@@ -317,33 +322,41 @@ class VultrController extends Controller
|
||||
}
|
||||
|
||||
$vultrInstance = $vultrService->createInstance($params);
|
||||
$vultrInstanceId = (string) $vultrInstance['id'];
|
||||
$ipAddress = $vultrService->getPublicIp($vultrInstance, $request->disable_public_ipv4, $request->enable_ipv6) ?? Server::PLACEHOLDER_IP;
|
||||
|
||||
$server = Server::create([
|
||||
'name' => $normalizedServerName,
|
||||
'ip' => $ipAddress,
|
||||
'user' => 'root',
|
||||
'port' => 22,
|
||||
'team_id' => $teamId,
|
||||
'private_key_id' => $privateKey->id,
|
||||
'cloud_provider_token_id' => $token->id,
|
||||
'vultr_instance_id' => $vultrInstance['id'],
|
||||
'vultr_instance_status' => $vultrInstance['status'] ?? null,
|
||||
]);
|
||||
|
||||
$vultrInstance = $vultrService->waitForPublicIp($vultrInstance, $request->disable_public_ipv4, $request->enable_ipv6);
|
||||
$assignedIpAddress = $vultrService->getPublicIp($vultrInstance, $request->disable_public_ipv4, $request->enable_ipv6);
|
||||
if ($assignedIpAddress && $assignedIpAddress !== $server->ip) {
|
||||
$ipAddress = $assignedIpAddress;
|
||||
$server->update([
|
||||
'ip' => $assignedIpAddress,
|
||||
'vultr_instance_status' => $vultrInstance['status'] ?? $server->vultr_instance_status,
|
||||
$server = DB::transaction(function () use ($normalizedServerName, $ipAddress, $teamId, $privateKey, $token, $vultrInstanceId, $vultrInstance): Server {
|
||||
$server = Server::create([
|
||||
'name' => $normalizedServerName,
|
||||
'ip' => $ipAddress,
|
||||
'user' => 'root',
|
||||
'port' => 22,
|
||||
'team_id' => $teamId,
|
||||
'private_key_id' => $privateKey->id,
|
||||
'cloud_provider_token_id' => $token->id,
|
||||
'vultr_instance_id' => $vultrInstanceId,
|
||||
'vultr_instance_status' => $vultrInstance['status'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
$server->proxy->set('status', 'exited');
|
||||
$server->proxy->set('type', ProxyTypes::TRAEFIK->value);
|
||||
$server->save();
|
||||
$server->proxy->set('status', 'exited');
|
||||
$server->proxy->set('type', ProxyTypes::TRAEFIK->value);
|
||||
$server->save();
|
||||
|
||||
return $server;
|
||||
});
|
||||
|
||||
try {
|
||||
$vultrInstance = $vultrService->waitForPublicIp($vultrInstance, $request->disable_public_ipv4, $request->enable_ipv6);
|
||||
$assignedIpAddress = $vultrService->getPublicIp($vultrInstance, $request->disable_public_ipv4, $request->enable_ipv6);
|
||||
if ($assignedIpAddress && $assignedIpAddress !== $server->ip) {
|
||||
$server->update([
|
||||
'ip' => $assignedIpAddress,
|
||||
'vultr_instance_status' => $vultrInstance['status'] ?? $server->vultr_instance_status,
|
||||
]);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
}
|
||||
|
||||
if ($request->instant_validate) {
|
||||
ValidateServer::dispatch($server);
|
||||
@@ -353,27 +366,48 @@ class VultrController extends Controller
|
||||
'team_id' => $teamId,
|
||||
'server_uuid' => $server->uuid,
|
||||
'server_name' => $server->name,
|
||||
'vultr_instance_id' => $vultrInstance['id'],
|
||||
'ip' => $ipAddress,
|
||||
'vultr_instance_id' => $vultrInstanceId,
|
||||
'ip' => $server->ip,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'uuid' => $server->uuid,
|
||||
'vultr_instance_id' => $vultrInstance['id'],
|
||||
'ip' => $ipAddress,
|
||||
'vultr_instance_id' => $vultrInstanceId,
|
||||
'ip' => $server->ip,
|
||||
])->setStatusCode(201);
|
||||
} catch (RateLimitException $e) {
|
||||
$this->deleteUntrackedInstance($vultrService, $vultrInstanceId, $server);
|
||||
|
||||
$response = response()->json(['message' => $e->getMessage()], 429);
|
||||
if ($e->retryAfter !== null) {
|
||||
$response->header('Retry-After', $e->retryAfter);
|
||||
}
|
||||
|
||||
return $response;
|
||||
} catch (\Throwable) {
|
||||
} catch (\Throwable $e) {
|
||||
$this->deleteUntrackedInstance($vultrService, $vultrInstanceId, $server);
|
||||
|
||||
logger()->error('Failed to create Vultr server', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Failed to create Vultr server.'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
private function deleteUntrackedInstance(?VultrService $vultrService, ?string $vultrInstanceId, ?Server $server): void
|
||||
{
|
||||
if (! $vultrService || ! $vultrInstanceId || $server) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$vultrService->deleteInstance($vultrInstanceId);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
}
|
||||
}
|
||||
|
||||
private function findMatchingSshKey(array $sshKeys, string $publicKey): ?array
|
||||
{
|
||||
$normalizedPublicKey = $this->normalizePublicKey($publicKey);
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\AvatarStorageService;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class ProfileAvatarController extends Controller
|
||||
{
|
||||
public function __invoke(AvatarStorageService $avatarStorage): Response
|
||||
{
|
||||
$contents = $avatarStorage->contents(auth()->user());
|
||||
abort_if($contents === null, 404);
|
||||
|
||||
return response($contents, 200, [
|
||||
'Content-Type' => 'image/jpeg',
|
||||
'Cache-Control' => 'private, max-age=300',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,636 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V5;
|
||||
|
||||
use App\Actions\V5\Application\DestroyNginxApplication;
|
||||
use App\Actions\V5\Proxy\StartCaddyIngress;
|
||||
use App\Enums\V5\ApplicationStatus;
|
||||
use App\Enums\V5\IngressStatus;
|
||||
use App\Enums\V5\ServerStatus;
|
||||
use App\Exceptions\V5\UnsupportedCooldVerb;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\V5\Concerns\HandlesIngressSyncErrors;
|
||||
use App\Http\Controllers\V5\Concerns\ResolvesCurrentTeam;
|
||||
use App\Http\Controllers\V5\Concerns\ResolvesProjectSelection;
|
||||
use App\Http\Controllers\V5\Concerns\SerializesCanvasResources;
|
||||
use App\Jobs\V5DeployApplicationJob;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Team;
|
||||
use App\Models\V5\Application as V5Application;
|
||||
use App\Models\V5\ApplicationDomain as V5ApplicationDomain;
|
||||
use App\Models\V5\ResourceConnection;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use App\Rules\ValidHostname;
|
||||
use App\Services\Flux\FluxClient;
|
||||
use App\Support\V5\CanvasResourceSerializer;
|
||||
use App\Support\V5\ConnectionFirewallSync;
|
||||
use App\Support\V5\StatusObservation;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class ApplicationController extends Controller
|
||||
{
|
||||
use HandlesIngressSyncErrors;
|
||||
use ResolvesCurrentTeam;
|
||||
use ResolvesProjectSelection;
|
||||
use SerializesCanvasResources;
|
||||
|
||||
private const DEFAULT_NGINX_IMAGE = 'docker.io/library/nginx:alpine';
|
||||
|
||||
public function __construct(private readonly ConnectionFirewallSync $firewallSync) {}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$currentTeam = $this->currentTeamOrFail($request);
|
||||
$this->authorize('create', [V5Application::class, $currentTeam]);
|
||||
$projects = $this->projects($currentTeam);
|
||||
[$selectedProject, $selectedEnvironment] = $this->selectedProjectAndEnvironment($request, $projects);
|
||||
|
||||
if ($selectedProject === null || $selectedEnvironment === null) {
|
||||
return response()->json([
|
||||
'message' => 'Select a project and environment before deploying nginx.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$project = $this->projectQuery($currentTeam)
|
||||
->where('uuid', $selectedProject['uuid'])
|
||||
->first();
|
||||
|
||||
if (! $project instanceof Project) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$environment = $this->selectedEnvironment($project, $selectedEnvironment['uuid']);
|
||||
|
||||
if (! $environment instanceof Environment) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'server_uuid' => ['nullable', 'string', 'max:255'],
|
||||
'image' => ['nullable', 'string', 'max:255', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\/:@-]*$/'],
|
||||
]);
|
||||
$image = trim($validated['image'] ?? '') ?: self::DEFAULT_NGINX_IMAGE;
|
||||
|
||||
$server = V5Server::query()
|
||||
->where('team_id', $currentTeam->id)
|
||||
->when(
|
||||
isset($validated['server_uuid']),
|
||||
fn (Builder $query) => $query->where('uuid', $validated['server_uuid']),
|
||||
fn (Builder $query) => $query
|
||||
->orderByRaw('last_bootstrapped_at is null')
|
||||
->orderBy('name')
|
||||
)
|
||||
->first();
|
||||
|
||||
if (! $server instanceof V5Server) {
|
||||
return response()->json([
|
||||
'message' => 'Add a v5 server before deploying nginx.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
if ($server->status !== ServerStatus::Installed->value || $server->last_bootstrapped_at === null) {
|
||||
return response()->json([
|
||||
'message' => "Bootstrap server {$server->name} before deploying to it.",
|
||||
], 422);
|
||||
}
|
||||
|
||||
$canvasPosition = $this->nextApplicationCanvasPosition($currentTeam, $project, $environment);
|
||||
|
||||
$application = V5Application::query()->create([
|
||||
'team_id' => $currentTeam->id,
|
||||
'project_id' => $project->id,
|
||||
'environment_id' => $environment->id,
|
||||
'server_id' => $server->id,
|
||||
'created_by_user_id' => $request->user()->id,
|
||||
'name' => 'nginx-test',
|
||||
'image' => $image,
|
||||
'container_name' => 'coolify-v5-nginx-'.strtolower((string) Str::ulid()),
|
||||
'status' => ApplicationStatus::Creating->value,
|
||||
'status_message' => 'Starting nginx container.',
|
||||
'mesh_namespace' => 'default',
|
||||
'canvas_x' => $canvasPosition['canvas_x'],
|
||||
'canvas_y' => $canvasPosition['canvas_y'],
|
||||
]);
|
||||
|
||||
V5DeployApplicationJob::dispatch($application->id);
|
||||
|
||||
return response()->json([
|
||||
'application' => $this->serializeApplication($application),
|
||||
], 202);
|
||||
}
|
||||
|
||||
public function refresh(Request $request, FluxClient $fluxClient): JsonResponse
|
||||
{
|
||||
$currentTeam = $this->currentTeamOrFail($request);
|
||||
$projects = $this->projects($currentTeam);
|
||||
[$selectedProject, $selectedEnvironment] = $this->selectedProjectAndEnvironment($request, $projects);
|
||||
|
||||
if ($selectedProject === null || $selectedEnvironment === null) {
|
||||
return response()->json([
|
||||
'message' => 'Select a project and environment before refreshing applications.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$applications = $this->applicationQuery($currentTeam, $selectedProject, $selectedEnvironment)
|
||||
->with('server')
|
||||
->get();
|
||||
$errors = [];
|
||||
|
||||
$applications
|
||||
->groupBy('server_id')
|
||||
->each(function (Collection $serverApplications) use ($fluxClient, &$errors): void {
|
||||
/** @var V5Application|null $firstApplication */
|
||||
$firstApplication = $serverApplications->first();
|
||||
$server = $firstApplication?->server;
|
||||
$hostId = $server?->fluxHostId();
|
||||
|
||||
if (! $server instanceof V5Server || ! is_string($hostId) || $hostId === '') {
|
||||
$errors[] = 'A server is missing its Flux host id.';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// The moment we query coold is the observation time for the rows
|
||||
// this refresh writes, so a fresher webhook always wins the
|
||||
// status_observed_at watermark and is never clobbered.
|
||||
$observedAt = CarbonImmutable::now();
|
||||
|
||||
try {
|
||||
$containers = collect($fluxClient->listContainers($hostId));
|
||||
} catch (\Throwable $e) {
|
||||
$errors[] = $e->getMessage();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$serverApplications->each(function (V5Application $application) use ($containers, $observedAt): void {
|
||||
$container = $containers->first(function (array $container) use ($application): bool {
|
||||
return ($application->runtime_container_id !== null && ($container['id'] ?? null) === $application->runtime_container_id)
|
||||
|| ($container['name'] ?? null) === $application->container_name;
|
||||
});
|
||||
|
||||
if (! is_array($container)) {
|
||||
// A creating application without a container id simply has
|
||||
// not materialized yet; the deploy job will settle it.
|
||||
if ($application->status === ApplicationStatus::Creating->value && $application->runtime_container_id === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (StatusObservation::isStale($observedAt, $application->status_observed_at, 'application status', ['application_id' => $application->id])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$application->update([
|
||||
'status' => ApplicationStatus::Exited->value,
|
||||
'status_message' => 'Container not found on server.',
|
||||
'status_observed_at' => $observedAt,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (StatusObservation::isStale($observedAt, $application->status_observed_at, 'application status', ['application_id' => $application->id])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$rawState = is_string($container['state'] ?? null) && $container['state'] !== '' ? $container['state'] : null;
|
||||
|
||||
$application->update([
|
||||
'status' => StatusObservation::normalize($rawState, ApplicationStatus::class) ?? ApplicationStatus::Unknown->value,
|
||||
'status_message' => 'Container state refreshed from coold.',
|
||||
'status_observed_at' => $observedAt,
|
||||
'runtime_container_id' => is_string($container['id'] ?? null) ? $container['id'] : $application->runtime_container_id,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
V5Server::query()
|
||||
->where('team_id', $currentTeam->id)
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->filter(fn (V5Server $server) => $server->isIngress())
|
||||
->each(function (V5Server $server) use ($fluxClient, &$errors): void {
|
||||
$hostId = $server->fluxHostId();
|
||||
|
||||
if (! is_string($hostId) || $hostId === '') {
|
||||
$errors[] = "Caddy ingress server {$server->name} is missing its Flux host id.";
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$containers = collect($fluxClient->listContainers($hostId));
|
||||
} catch (\Throwable $e) {
|
||||
$errors[] = $e->getMessage();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$container = $containers->first(fn (array $container) => ($container['name'] ?? null) === 'coolify-v5-caddy');
|
||||
$rawState = is_array($container) && is_string($container['state'] ?? null) && $container['state'] !== '' ? $container['state'] : null;
|
||||
$state = $rawState !== null
|
||||
? (StatusObservation::normalize($rawState, IngressStatus::class) ?? IngressStatus::Unknown->value)
|
||||
: IngressStatus::Exited->value;
|
||||
|
||||
$server->update([
|
||||
'ingress_type' => 'caddy',
|
||||
'ingress_status' => $state,
|
||||
'last_status_check' => 'flux',
|
||||
'last_status_output' => 'Caddy ingress state refreshed from coold.',
|
||||
'last_status_checked_at' => now(),
|
||||
]);
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'applications' => $this->applicationQuery($currentTeam, $selectedProject, $selectedEnvironment)
|
||||
->with('server')
|
||||
->orderBy('created_at')
|
||||
->get()
|
||||
->map(fn (V5Application $application) => $this->serializeApplication($application))
|
||||
->all(),
|
||||
'caddyIngresses' => $this->caddyIngresses($currentTeam),
|
||||
'errors' => $errors,
|
||||
]);
|
||||
}
|
||||
|
||||
public function logs(Request $request, V5Application $application): JsonResponse
|
||||
{
|
||||
$currentTeam = $this->currentTeamOrFail($request);
|
||||
$this->authorize('view', [$application, $currentTeam]);
|
||||
|
||||
$application->loadMissing('server');
|
||||
$server = $application->server;
|
||||
$hostId = $server?->fluxHostId();
|
||||
$containerId = $application->runtime_container_id;
|
||||
|
||||
$logs = null;
|
||||
$logsError = null;
|
||||
|
||||
// A container id only appears once the deploy actually created one; a
|
||||
// deploy that failed before that (e.g. host not connected) has none, so
|
||||
// there is nothing to fetch and the frontend just shows the status.
|
||||
if (is_string($containerId) && $containerId !== '' && $server instanceof V5Server && $server->status !== ServerStatus::Unreachable->value && is_string($hostId) && $hostId !== '') {
|
||||
try {
|
||||
$logs = app(FluxClient::class)->containerLogs($hostId, $containerId);
|
||||
} catch (UnsupportedCooldVerb $exception) {
|
||||
$logsError = "This node's coold does not support container logs.";
|
||||
} catch (\RuntimeException $exception) {
|
||||
Log::warning('V5 application container logs request failed', [
|
||||
'application_id' => $application->id,
|
||||
'message' => $exception->getMessage(),
|
||||
]);
|
||||
$logsError = 'Could not fetch container logs through Flux. Check the Flux and coold status, then try again.';
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'status' => $application->status,
|
||||
'statusMessage' => $application->status_message,
|
||||
'containerId' => $containerId,
|
||||
'logs' => $logs,
|
||||
'logsError' => $logsError,
|
||||
]);
|
||||
}
|
||||
|
||||
public function updatePosition(Request $request, V5Application $application): JsonResponse
|
||||
{
|
||||
$currentTeam = $this->currentTeamOrFail($request);
|
||||
$this->authorize('update', [$application, $currentTeam]);
|
||||
|
||||
$validated = $request->validate([
|
||||
'canvas_x' => ['required', 'integer', 'min:-100000', 'max:100000'],
|
||||
'canvas_y' => ['required', 'integer', 'min:-100000', 'max:100000'],
|
||||
]);
|
||||
|
||||
$application->update([
|
||||
'canvas_x' => $validated['canvas_x'],
|
||||
'canvas_y' => $validated['canvas_y'],
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'application' => $this->serializeApplication($application->refresh()->load('server')),
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateIngress(Request $request, V5Application $application): JsonResponse
|
||||
{
|
||||
$currentTeam = $this->currentTeamOrFail($request);
|
||||
$this->authorize('updateIngress', [$application, $currentTeam]);
|
||||
|
||||
$validated = $request->validate([
|
||||
'ingress_enabled' => ['required', 'boolean'],
|
||||
'internal_port' => ['nullable', 'integer', 'min:1', 'max:65535'],
|
||||
'domains' => [Rule::requiredIf(fn () => $request->boolean('ingress_enabled')), 'array', 'min:1'],
|
||||
'domains.*' => ['required', 'string', 'max:255', 'distinct:ignore_case', new ValidHostname],
|
||||
]);
|
||||
|
||||
$application->loadMissing('server');
|
||||
|
||||
if ($validated['ingress_enabled'] && ! $application->server?->isIngress()) {
|
||||
return response()->json([
|
||||
'message' => 'Enable ingress on the server before enabling app ingress.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
if ($validated['ingress_enabled'] && array_key_exists('domains', $validated)) {
|
||||
$conflict = $this->conflictingApplicationDomain($application, $validated['domains']);
|
||||
|
||||
if ($conflict instanceof V5ApplicationDomain) {
|
||||
return response()->json([
|
||||
'message' => "The domain {$conflict->domain} is already used by application \"{$conflict->application?->name}\" on this server.",
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
|
||||
$originalAttributes = $application->only(['ingress_enabled', 'internal_port']);
|
||||
$originalDomains = $application->domains()->pluck('domain')->all();
|
||||
|
||||
DB::transaction(function () use ($application, $validated): void {
|
||||
$application->update([
|
||||
'ingress_enabled' => $validated['ingress_enabled'],
|
||||
'internal_port' => $validated['internal_port'] ?? null,
|
||||
]);
|
||||
|
||||
if (array_key_exists('domains', $validated)) {
|
||||
$application->domains()->delete();
|
||||
|
||||
collect($validated['domains'])
|
||||
->map(fn (string $domain) => trim($domain))
|
||||
->filter()
|
||||
->unique()
|
||||
->each(fn (string $domain) => V5ApplicationDomain::query()->create([
|
||||
'application_id' => $application->id,
|
||||
'domain' => $domain,
|
||||
]));
|
||||
}
|
||||
});
|
||||
|
||||
$application->refresh()->load(['server', 'domains']);
|
||||
|
||||
if ($application->server?->isIngress() && $application->server->status === ServerStatus::Installed->value) {
|
||||
try {
|
||||
StartCaddyIngress::run($application->server);
|
||||
} catch (\RuntimeException $exception) {
|
||||
$this->restoreApplicationIngress($application, $originalAttributes, $originalDomains);
|
||||
|
||||
return $this->ingressSyncErrorResponse($exception);
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'application' => $this->serializeApplication($application),
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateCaddyIngressPosition(Request $request, V5Server $server): JsonResponse
|
||||
{
|
||||
$currentTeam = $this->currentTeamOrFail($request);
|
||||
$this->authorize('updateCanvasPosition', [$server, $currentTeam]);
|
||||
|
||||
$validated = $request->validate([
|
||||
'canvas_x' => ['required', 'integer', 'min:-100000', 'max:100000'],
|
||||
'canvas_y' => ['required', 'integer', 'min:-100000', 'max:100000'],
|
||||
]);
|
||||
|
||||
$server->update([
|
||||
'canvas_x' => $validated['canvas_x'],
|
||||
'canvas_y' => $validated['canvas_y'],
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'caddyIngress' => $this->serializeCaddyIngress($server->refresh()),
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, V5Application $application, FluxClient $fluxClient): Response|JsonResponse
|
||||
{
|
||||
$currentTeam = $this->currentTeamOrFail($request);
|
||||
$this->authorize('delete', [$application, $currentTeam]);
|
||||
|
||||
$application->loadMissing(['server', 'domains']);
|
||||
$server = $application->server;
|
||||
$connections = $this->applicationResourceConnections($application);
|
||||
|
||||
if ($request->boolean('delete_locally')) {
|
||||
$this->deleteApplicationLocally($application, $connections);
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
$oldFirewallRules = $connections
|
||||
->flatMap(function (ResourceConnection $connection): Collection {
|
||||
// Deletion must never be blocked by an endpoint that already lost
|
||||
// its server; those rules can no longer be revoked anyway.
|
||||
try {
|
||||
return $this->firewallSync->rulesFor($connection->load('rules'));
|
||||
} catch (\RuntimeException $exception) {
|
||||
report($exception);
|
||||
|
||||
return collect();
|
||||
}
|
||||
});
|
||||
$originalIngressAttributes = null;
|
||||
$originalIngressDomains = [];
|
||||
$ingressConfigurationChanged = false;
|
||||
|
||||
try {
|
||||
$this->firewallSync->sync($fluxClient, $oldFirewallRules, collect());
|
||||
} catch (\RuntimeException $exception) {
|
||||
report($exception);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Could not sync firewall rules through Flux.',
|
||||
'detail' => $exception->getMessage(),
|
||||
], 502);
|
||||
}
|
||||
|
||||
if ($server instanceof V5Server && $server->isIngress() && $server->status === ServerStatus::Installed->value && $application->ingress_enabled) {
|
||||
$originalIngressAttributes = $application->only(['ingress_enabled', 'internal_port']);
|
||||
$originalIngressDomains = $application->domains()->pluck('domain')->all();
|
||||
|
||||
DB::transaction(function () use ($application): void {
|
||||
$application->update([
|
||||
'ingress_enabled' => false,
|
||||
'internal_port' => null,
|
||||
]);
|
||||
$application->domains()->delete();
|
||||
});
|
||||
|
||||
try {
|
||||
StartCaddyIngress::run($server);
|
||||
$ingressConfigurationChanged = true;
|
||||
} catch (\RuntimeException $exception) {
|
||||
$this->restoreApplicationIngress($application, $originalIngressAttributes, $originalIngressDomains);
|
||||
|
||||
return $this->ingressSyncErrorResponse($exception);
|
||||
}
|
||||
}
|
||||
|
||||
$error = DestroyNginxApplication::run($application);
|
||||
|
||||
if ($error !== null) {
|
||||
if ($originalIngressAttributes !== null) {
|
||||
$this->restoreApplicationIngress($application, $originalIngressAttributes, $originalIngressDomains);
|
||||
|
||||
if ($ingressConfigurationChanged && $server instanceof V5Server) {
|
||||
try {
|
||||
StartCaddyIngress::run($server);
|
||||
} catch (\RuntimeException $exception) {
|
||||
report($exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$this->firewallSync->sync($fluxClient, collect(), $oldFirewallRules);
|
||||
} catch (\RuntimeException $exception) {
|
||||
report($exception);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => $error,
|
||||
'can_delete_locally' => true,
|
||||
], 422);
|
||||
}
|
||||
|
||||
$this->deleteApplicationLocally($application, $connections);
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, ResourceConnection> $connections
|
||||
*/
|
||||
private function deleteApplicationLocally(V5Application $application, Collection $connections): void
|
||||
{
|
||||
DB::transaction(function () use ($application, $connections): void {
|
||||
$connections->each(function (ResourceConnection $connection): void {
|
||||
$connection->rules()->delete();
|
||||
$connection->delete();
|
||||
});
|
||||
|
||||
$application->delete();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, ResourceConnection>
|
||||
*/
|
||||
private function applicationResourceConnections(V5Application $application): Collection
|
||||
{
|
||||
return ResourceConnection::query()
|
||||
->where('team_id', $application->team_id)
|
||||
->where(function (Builder $query) use ($application): void {
|
||||
$query
|
||||
->where(function (Builder $query) use ($application): void {
|
||||
$query
|
||||
->where('resource_one_type', $application->getMorphClass())
|
||||
->where('resource_one_id', $application->id);
|
||||
})
|
||||
->orWhere(function (Builder $query) use ($application): void {
|
||||
$query
|
||||
->where('resource_two_type', $application->getMorphClass())
|
||||
->where('resource_two_id', $application->id);
|
||||
});
|
||||
})
|
||||
->with('rules')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{canvas_x: int, canvas_y: int}
|
||||
*/
|
||||
private function nextApplicationCanvasPosition(Team $currentTeam, Project $project, Environment $environment): array
|
||||
{
|
||||
$existingApplications = V5Application::query()
|
||||
->where('team_id', $currentTeam->id)
|
||||
->where('project_id', $project->id)
|
||||
->where('environment_id', $environment->id)
|
||||
->get(['canvas_x', 'canvas_y']);
|
||||
|
||||
$horizontalStep = CanvasResourceSerializer::CARD_WIDTH + CanvasResourceSerializer::CARD_GAP;
|
||||
$verticalStep = CanvasResourceSerializer::CARD_HEIGHT + CanvasResourceSerializer::CARD_GAP;
|
||||
|
||||
for ($row = 0; $row < 100; $row++) {
|
||||
for ($column = 0; $column < 100; $column++) {
|
||||
$candidate = [
|
||||
'canvas_x' => $column * $horizontalStep,
|
||||
'canvas_y' => $row * $verticalStep,
|
||||
];
|
||||
|
||||
if (! $this->canvasPositionCollides($candidate, $existingApplications)) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'canvas_x' => $existingApplications->max('canvas_x') + $horizontalStep,
|
||||
'canvas_y' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{canvas_x: int, canvas_y: int} $candidate
|
||||
* @param Collection<int, V5Application> $existingApplications
|
||||
*/
|
||||
private function canvasPositionCollides(array $candidate, Collection $existingApplications): bool
|
||||
{
|
||||
return $existingApplications->contains(function (V5Application $application) use ($candidate) {
|
||||
return abs($candidate['canvas_x'] - $application->canvas_x) < CanvasResourceSerializer::CARD_WIDTH + CanvasResourceSerializer::CARD_GAP
|
||||
&& abs($candidate['canvas_y'] - $application->canvas_y) < CanvasResourceSerializer::CARD_HEIGHT + CanvasResourceSerializer::CARD_GAP;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $domains
|
||||
*/
|
||||
private function conflictingApplicationDomain(V5Application $application, array $domains): ?V5ApplicationDomain
|
||||
{
|
||||
$normalizedDomains = collect($domains)
|
||||
->map(fn (string $domain) => Str::lower(trim($domain)))
|
||||
->filter()
|
||||
->values();
|
||||
|
||||
if ($normalizedDomains->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return V5ApplicationDomain::query()
|
||||
->whereIn(DB::raw('LOWER(domain)'), $normalizedDomains->all())
|
||||
->whereHas('application', fn (Builder $query) => $query
|
||||
->where('server_id', $application->server_id)
|
||||
->whereKeyNot($application->id)
|
||||
->where('ingress_enabled', true))
|
||||
->with('application:id,name')
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
* @param array<int, string> $domains
|
||||
*/
|
||||
private function restoreApplicationIngress(V5Application $application, array $attributes, array $domains): void
|
||||
{
|
||||
DB::transaction(function () use ($application, $attributes, $domains): void {
|
||||
$application->update($attributes);
|
||||
$application->domains()->delete();
|
||||
|
||||
foreach ($domains as $domain) {
|
||||
V5ApplicationDomain::query()->create([
|
||||
'application_id' => $application->id,
|
||||
'domain' => $domain,
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V5;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\V5\Concerns\ResolvesCurrentTeam;
|
||||
use App\Http\Controllers\V5\Concerns\ResolvesProjectSelection;
|
||||
use App\Http\Controllers\V5\Concerns\ValidatesBuilderConfiguration;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Team;
|
||||
use App\Models\V5\Cluster as V5Cluster;
|
||||
use App\Services\Flux\FluxHealth;
|
||||
use App\Support\V5\ClusterSerializer;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class ClusterController extends Controller
|
||||
{
|
||||
use ResolvesCurrentTeam;
|
||||
use ResolvesProjectSelection;
|
||||
use ValidatesBuilderConfiguration;
|
||||
|
||||
public function index(Request $request, FluxHealth $fluxHealth): Response
|
||||
{
|
||||
$currentTeam = $request->attributes->get('v5.currentTeam');
|
||||
$projects = $this->projects($currentTeam);
|
||||
[$selectedProject, $selectedEnvironment] = $this->selectedProjectAndEnvironment($request, $projects);
|
||||
|
||||
return Inertia::render('Clusters', [
|
||||
'currentTeam' => $this->serializeCurrentTeam($currentTeam),
|
||||
'flux' => $fluxHealth->check(),
|
||||
'clusters' => $this->clusters($currentTeam),
|
||||
'privateKeys' => $this->privateKeys($currentTeam),
|
||||
'projects' => $projects,
|
||||
'selectedProjectUuid' => $selectedProject['uuid'] ?? null,
|
||||
'selectedEnvironmentUuid' => $selectedEnvironment['uuid'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(Request $request, V5Cluster $cluster): JsonResponse
|
||||
{
|
||||
$currentTeam = $this->currentTeamOrFail($request);
|
||||
$this->authorize('view', [$cluster, $currentTeam]);
|
||||
|
||||
return response()->json([
|
||||
'cluster' => app(ClusterSerializer::class)->serializeFresh($cluster),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$currentTeam = $this->currentTeamOrFail($request);
|
||||
$this->authorize('create', [V5Cluster::class, $currentTeam]);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => [
|
||||
'required',
|
||||
'string',
|
||||
'max:255',
|
||||
Rule::unique('v5_clusters', 'name')->where('team_id', $currentTeam->id),
|
||||
],
|
||||
'description' => ['nullable', 'string', 'max:1000'],
|
||||
'wireguard_interface' => ['sometimes', 'string', 'max:32', 'regex:/^[a-zA-Z0-9_.-]+$/'],
|
||||
'wireguard_management_pool' => ['sometimes', 'string', 'max:64', $this->ipv4CidrRule()],
|
||||
'wireguard_listen_port' => ['sometimes', 'integer', 'min:1', 'max:65535'],
|
||||
'container_network_pool' => ['sometimes', 'string', 'max:64', $this->ipv4CidrRule()],
|
||||
'container_network_prefix' => ['sometimes', 'integer', 'min:1', 'max:32'],
|
||||
'namespaces' => ['sometimes', 'array', 'min:1'],
|
||||
'namespaces.*' => ['string', 'distinct', 'regex:/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/'],
|
||||
'default_deny_containers' => ['sometimes', 'boolean'],
|
||||
'coold_version' => ['sometimes', 'string', 'max:64'],
|
||||
'corrosion_version' => ['sometimes', 'string', 'max:64'],
|
||||
'corrosion_gossip_port' => ['sometimes', 'integer', 'min:1', 'max:65535'],
|
||||
'corrosion_api_port' => ['sometimes', 'integer', 'min:1', 'max:65535'],
|
||||
'builder_enabled' => ['sometimes', 'boolean'],
|
||||
'builder_capacity' => $this->builderCapacityRules(
|
||||
$this->requestedBuilderEnabled($request, true)
|
||||
),
|
||||
'builder_cpu_quota' => ['sometimes', 'string', 'max:32'],
|
||||
'builder_memory_max' => ['sometimes', 'string', 'max:32'],
|
||||
'builder_timeout_secs' => ['sometimes', 'integer', 'min:1', 'max:86400'],
|
||||
]);
|
||||
|
||||
$cluster = V5Cluster::query()->create([
|
||||
...$this->defaultClusterConfiguration(),
|
||||
...collect($validated)->except(['name', 'description'])->all(),
|
||||
'team_id' => $currentTeam->id,
|
||||
'created_by_user_id' => $request->user()->id,
|
||||
'name' => $validated['name'],
|
||||
'description' => $validated['description'] ?? null,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'cluster' => app(ClusterSerializer::class)->serializeFresh($cluster),
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, V5Cluster $cluster): \Illuminate\Http\Response|JsonResponse
|
||||
{
|
||||
$currentTeam = $this->currentTeamOrFail($request);
|
||||
$this->authorize('delete', [$cluster, $currentTeam]);
|
||||
|
||||
if ($cluster->servers()->exists()) {
|
||||
return response()->json([
|
||||
'message' => 'Only empty clusters can be deleted.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$cluster->delete();
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function clusters(mixed $currentTeam): array
|
||||
{
|
||||
if (! $currentTeam instanceof Team) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$serializer = app(ClusterSerializer::class);
|
||||
|
||||
return V5Cluster::query()
|
||||
->where('team_id', $currentTeam->id)
|
||||
->with(['servers' => fn ($query) => $query
|
||||
->with('privateKey')
|
||||
->orderBy('name')])
|
||||
->withCount('servers')
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->map(fn (V5Cluster $cluster) => $serializer->serialize($cluster))
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{id: string, name: string}>
|
||||
*/
|
||||
private function privateKeys(mixed $currentTeam): array
|
||||
{
|
||||
if (! $currentTeam instanceof Team) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return PrivateKey::query()
|
||||
->where('team_id', $currentTeam->id)
|
||||
->where('is_git_related', false)
|
||||
->orderBy('name')
|
||||
->get(['id', 'uuid', 'name'])
|
||||
->map(fn (PrivateKey $privateKey) => [
|
||||
'id' => $privateKey->uuid,
|
||||
'name' => $privateKey->name,
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function defaultClusterConfiguration(): array
|
||||
{
|
||||
return [
|
||||
'wireguard_interface' => V5Cluster::DEFAULT_WIREGUARD_INTERFACE,
|
||||
'wireguard_management_pool' => V5Cluster::DEFAULT_WIREGUARD_MANAGEMENT_POOL,
|
||||
'wireguard_listen_port' => V5Cluster::DEFAULT_WIREGUARD_LISTEN_PORT,
|
||||
'container_network_pool' => V5Cluster::DEFAULT_CONTAINER_NETWORK_POOL,
|
||||
'container_network_prefix' => V5Cluster::DEFAULT_CONTAINER_NETWORK_PREFIX,
|
||||
'namespaces' => V5Cluster::DEFAULT_NAMESPACES,
|
||||
'default_deny_containers' => true,
|
||||
'coold_version' => V5Cluster::DEFAULT_COOLD_VERSION,
|
||||
'corrosion_version' => V5Cluster::DEFAULT_CORROSION_VERSION,
|
||||
'corrosion_gossip_port' => V5Cluster::DEFAULT_CORROSION_GOSSIP_PORT,
|
||||
'corrosion_api_port' => V5Cluster::DEFAULT_CORROSION_API_PORT,
|
||||
'builder_enabled' => true,
|
||||
'builder_capacity' => V5Cluster::DEFAULT_BUILDER_CAPACITY,
|
||||
'builder_cpu_quota' => V5Cluster::DEFAULT_BUILDER_CPU_QUOTA,
|
||||
'builder_memory_max' => V5Cluster::DEFAULT_BUILDER_MEMORY_MAX,
|
||||
'builder_timeout_secs' => V5Cluster::DEFAULT_BUILDER_TIMEOUT_SECS,
|
||||
];
|
||||
}
|
||||
|
||||
private function ipv4CidrRule(): \Closure
|
||||
{
|
||||
return function (string $attribute, mixed $value, \Closure $fail): void {
|
||||
if (! is_string($value) || ! str_contains($value, '/')) {
|
||||
$fail('The :attribute must be a valid IPv4 CIDR range.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
[$ip, $prefix] = explode('/', $value, 2);
|
||||
|
||||
if (
|
||||
filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) === false
|
||||
|| ! ctype_digit($prefix)
|
||||
|| (int) $prefix < 0
|
||||
|| (int) $prefix > 32
|
||||
) {
|
||||
$fail('The :attribute must be a valid IPv4 CIDR range.');
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V5\Concerns;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
trait HandlesIngressSyncErrors
|
||||
{
|
||||
protected function ingressSyncErrorResponse(\RuntimeException $exception): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'message' => $this->friendlyIngressSyncError($exception->getMessage()),
|
||||
'detail' => $exception->getMessage(),
|
||||
], 502);
|
||||
}
|
||||
|
||||
protected function friendlyIngressSyncError(string $message): string
|
||||
{
|
||||
$normalized = Str::lower($message);
|
||||
|
||||
if (str_contains($normalized, 'invalid http response') || str_contains($normalized, 'could not talk to flux')) {
|
||||
return 'Could not reach Flux. Check that Flux is running in the Coolify container and try again.';
|
||||
}
|
||||
|
||||
if (str_contains($normalized, 'dispatch timeout') || str_contains($normalized, 'timed out')) {
|
||||
return 'coold did not respond in time. Check that the server agent is running and connected to Flux.';
|
||||
}
|
||||
|
||||
if (str_contains($normalized, 'validate caddyfile')) {
|
||||
return 'Caddy rejected the generated ingress configuration. Check the domains and internal port, then try again.';
|
||||
}
|
||||
|
||||
if (str_contains($normalized, 'start caddy ingress') || str_contains($normalized, 'reload caddy ingress')) {
|
||||
return 'Could not start Caddy ingress on the server. Check that Podman is running and port 80 is available.';
|
||||
}
|
||||
|
||||
return 'Could not update ingress. Check Flux and coold logs, then try again.';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V5\Concerns;
|
||||
|
||||
use App\Models\Team;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
trait ResolvesCurrentTeam
|
||||
{
|
||||
/**
|
||||
* Resolve the current team set by the EnsureCurrentTeam middleware, or
|
||||
* abort with a 404 so resources outside the team stay invisible.
|
||||
*/
|
||||
protected function currentTeamOrFail(Request $request): Team
|
||||
{
|
||||
$currentTeam = $request->attributes->get('v5.currentTeam');
|
||||
|
||||
abort_unless($currentTeam instanceof Team, 404);
|
||||
|
||||
return $currentTeam;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V5\Concerns;
|
||||
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
trait ResolvesProjectSelection
|
||||
{
|
||||
protected const SELECTED_PROJECT_SESSION_KEY = 'v5.selectedProjectUuid';
|
||||
|
||||
protected const SELECTED_ENVIRONMENT_SESSION_KEY = 'v5.selectedEnvironmentUuid';
|
||||
|
||||
/**
|
||||
* @return array{id: int}|null
|
||||
*/
|
||||
protected function serializeCurrentTeam(mixed $currentTeam): ?array
|
||||
{
|
||||
if (! $currentTeam instanceof Team) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $currentTeam->id,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{uuid: string, name: string, environments: array<int, array{uuid: string, name: string}>}> $projects
|
||||
* @return array{0: array{uuid: string, name: string, environments: array<int, array{uuid: string, name: string}>}|null, 1: array{uuid: string, name: string}|null}
|
||||
*/
|
||||
protected function selectedProjectAndEnvironment(Request $request, array $projects): array
|
||||
{
|
||||
$selectedProjectUuid = $request->query('project', $request->session()->get(self::SELECTED_PROJECT_SESSION_KEY));
|
||||
$selectedEnvironmentUuid = $request->query('environment', $request->session()->get(self::SELECTED_ENVIRONMENT_SESSION_KEY));
|
||||
$selectedProject = null;
|
||||
|
||||
foreach ($projects as $project) {
|
||||
if ($project['uuid'] === $selectedProjectUuid) {
|
||||
$selectedProject = $project;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$selectedProject ??= $projects[0] ?? null;
|
||||
$selectedEnvironment = null;
|
||||
|
||||
foreach ($selectedProject['environments'] ?? [] as $environment) {
|
||||
if ($environment['uuid'] === $selectedEnvironmentUuid) {
|
||||
$selectedEnvironment = $environment;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$selectedEnvironment ??= $selectedProject['environments'][0] ?? null;
|
||||
|
||||
if ($request->query->has('project') || $request->query->has('environment')) {
|
||||
$request->session()->put([
|
||||
self::SELECTED_PROJECT_SESSION_KEY => $selectedProject['uuid'] ?? null,
|
||||
self::SELECTED_ENVIRONMENT_SESSION_KEY => $selectedEnvironment['uuid'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
return [$selectedProject, $selectedEnvironment];
|
||||
}
|
||||
|
||||
protected function selectedEnvironment(Project $project, ?string $environmentUuid): ?Environment
|
||||
{
|
||||
if ($environmentUuid === null) {
|
||||
return $project->environments->first();
|
||||
}
|
||||
|
||||
$environment = $project->environments->firstWhere('uuid', $environmentUuid);
|
||||
|
||||
if (! $environment instanceof Environment) {
|
||||
abort(422, 'The selected environment is not available for the selected project.');
|
||||
}
|
||||
|
||||
return $environment;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{uuid: string, name: string, environments: array<int, array{uuid: string, name: string}>}>
|
||||
*/
|
||||
protected function projects(mixed $currentTeam): array
|
||||
{
|
||||
if (! $currentTeam instanceof Team) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->projectQuery($currentTeam)
|
||||
->get()
|
||||
->map(fn (Project $project) => [
|
||||
'uuid' => $project->uuid,
|
||||
'name' => $project->name,
|
||||
'environments' => $project->environments
|
||||
->map(fn ($environment) => [
|
||||
'uuid' => $environment->uuid,
|
||||
'name' => $environment->name,
|
||||
])
|
||||
->all(),
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
protected function projectQuery(Team $currentTeam): Builder
|
||||
{
|
||||
return Project::query()
|
||||
->select(['id', 'uuid', 'name', 'team_id'])
|
||||
->where('team_id', $currentTeam->id)
|
||||
->with(['environments' => fn ($query) => $query
|
||||
->select(['id', 'uuid', 'name', 'project_id'])
|
||||
->orderByRaw("CASE WHEN LOWER(name) = 'production' THEN 0 ELSE 1 END")
|
||||
->orderByRaw('LOWER(name)')])
|
||||
->orderByRaw('LOWER(name)');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V5\Concerns;
|
||||
|
||||
use App\Models\Team;
|
||||
use App\Models\V5\Application as V5Application;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use App\Support\V5\CanvasResourceSerializer;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
trait SerializesCanvasResources
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function serializeApplication(V5Application $application): array
|
||||
{
|
||||
return app(CanvasResourceSerializer::class)->serializeApplication($application);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function serializeCaddyIngress(V5Server $server, int $index = 0): array
|
||||
{
|
||||
return app(CanvasResourceSerializer::class)->serializeCaddyIngress($server, $index);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
protected function caddyIngresses(mixed $currentTeam): array
|
||||
{
|
||||
if (! $currentTeam instanceof Team) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return V5Server::query()
|
||||
->where('team_id', $currentTeam->id)
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->filter(fn (V5Server $server) => $server->isIngress())
|
||||
->values()
|
||||
->map(fn (V5Server $server, int $index) => $this->serializeCaddyIngress($server, $index))
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{uuid: string} $selectedProject
|
||||
* @param array{uuid: string} $selectedEnvironment
|
||||
* @return Builder<V5Application>
|
||||
*/
|
||||
protected function applicationQuery(Team $currentTeam, array $selectedProject, array $selectedEnvironment): Builder
|
||||
{
|
||||
return V5Application::query()
|
||||
->where('team_id', $currentTeam->id)
|
||||
->whereHas('project', fn (Builder $query) => $query
|
||||
->where('team_id', $currentTeam->id)
|
||||
->where('uuid', $selectedProject['uuid']))
|
||||
->whereHas('environment', fn (Builder $query) => $query
|
||||
->where('uuid', $selectedEnvironment['uuid']));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V5\Concerns;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
trait ValidatesBuilderConfiguration
|
||||
{
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
protected function builderCapacityRules(bool $builderEnabled, bool $required = false): array
|
||||
{
|
||||
return [
|
||||
$required ? 'required' : 'sometimes',
|
||||
'integer',
|
||||
$builderEnabled ? 'min:1' : 'min:0',
|
||||
'max:1000',
|
||||
];
|
||||
}
|
||||
|
||||
protected function requestedBuilderEnabled(Request $request, bool $default): bool
|
||||
{
|
||||
if (! $request->has('builder_enabled')) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
return $request->boolean('builder_enabled');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V5;
|
||||
|
||||
use App\Events\V5RealtimeTestEvent;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\V5\Concerns\ResolvesCurrentTeam;
|
||||
use App\Http\Controllers\V5\Concerns\ResolvesProjectSelection;
|
||||
use App\Http\Controllers\V5\Concerns\SerializesCanvasResources;
|
||||
use App\Models\Project;
|
||||
use App\Models\Team;
|
||||
use App\Models\V5\Application as V5Application;
|
||||
use App\Models\V5\ResourceConnection;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use App\Services\Flux\FluxHealth;
|
||||
use App\Support\V5\ResourceConnectionSerializer;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
use ResolvesCurrentTeam;
|
||||
use ResolvesProjectSelection;
|
||||
use SerializesCanvasResources;
|
||||
|
||||
public function __construct(private readonly ResourceConnectionSerializer $connectionSerializer) {}
|
||||
|
||||
public function __invoke(Request $request, FluxHealth $fluxHealth): Response
|
||||
{
|
||||
$currentTeam = $request->attributes->get('v5.currentTeam');
|
||||
$projects = $this->projects($currentTeam);
|
||||
[$selectedProject, $selectedEnvironment] = $this->selectedProjectAndEnvironment($request, $projects);
|
||||
$applications = $this->applications($currentTeam, $selectedProject, $selectedEnvironment);
|
||||
$requestedApplicationUuid = $request->query('application');
|
||||
$selectedApplicationUuid = collect($applications)->contains(
|
||||
fn (array $application): bool => $application['id'] === $requestedApplicationUuid
|
||||
) ? $requestedApplicationUuid : null;
|
||||
|
||||
return Inertia::render('Dashboard', [
|
||||
'currentTeam' => $this->serializeCurrentTeam($currentTeam),
|
||||
'flux' => $fluxHealth->check(),
|
||||
'applications' => $applications,
|
||||
'caddyIngresses' => $this->caddyIngresses($currentTeam),
|
||||
'resourceConnections' => $this->resourceConnections($currentTeam, $selectedProject, $selectedEnvironment),
|
||||
'nginxServers' => $this->nginxServers($currentTeam),
|
||||
'projects' => $projects,
|
||||
'selectedProjectUuid' => $selectedProject['uuid'] ?? null,
|
||||
'selectedEnvironmentUuid' => $selectedEnvironment['uuid'] ?? null,
|
||||
'selectedApplicationUuid' => $selectedApplicationUuid,
|
||||
]);
|
||||
}
|
||||
|
||||
public function realtimeTest(Request $request): Response
|
||||
{
|
||||
$currentTeam = $this->currentTeamOrFail($request);
|
||||
|
||||
return Inertia::render('RealtimeTest', [
|
||||
'currentTeam' => [
|
||||
'id' => $currentTeam->id,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function broadcastRealtimeTest(Request $request): JsonResponse
|
||||
{
|
||||
$currentTeam = $this->currentTeamOrFail($request);
|
||||
|
||||
$validated = $request->validate([
|
||||
'message' => ['nullable', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
V5RealtimeTestEvent::dispatch(
|
||||
$currentTeam->id,
|
||||
$validated['message'] ?? 'Manual v5 realtime test'
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Realtime test event broadcasted.',
|
||||
], 202);
|
||||
}
|
||||
|
||||
public function updateSelection(Request $request): \Illuminate\Http\Response
|
||||
{
|
||||
$currentTeam = $this->currentTeamOrFail($request);
|
||||
|
||||
$validated = $request->validate([
|
||||
'project_uuid' => ['required', 'string'],
|
||||
'environment_uuid' => ['nullable', 'string'],
|
||||
]);
|
||||
|
||||
$project = $this->projectQuery($currentTeam)
|
||||
->where('uuid', $validated['project_uuid'])
|
||||
->first();
|
||||
|
||||
if (! $project instanceof Project) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$environment = $this->selectedEnvironment($project, $validated['environment_uuid'] ?? null);
|
||||
|
||||
$request->session()->put([
|
||||
self::SELECTED_PROJECT_SESSION_KEY => $project->uuid,
|
||||
self::SELECTED_ENVIRONMENT_SESSION_KEY => $environment?->uuid,
|
||||
]);
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function applications(mixed $currentTeam, ?array $selectedProject, ?array $selectedEnvironment): array
|
||||
{
|
||||
if (! $currentTeam instanceof Team || $selectedProject === null || $selectedEnvironment === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->applicationQuery($currentTeam, $selectedProject, $selectedEnvironment)
|
||||
->with('server')
|
||||
->orderBy('created_at')
|
||||
->get()
|
||||
->map(fn (V5Application $application) => $this->serializeApplication($application))
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function resourceConnections(mixed $currentTeam, ?array $selectedProject, ?array $selectedEnvironment): array
|
||||
{
|
||||
if (! $currentTeam instanceof Team || $selectedProject === null || $selectedEnvironment === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return ResourceConnection::query()
|
||||
->where('team_id', $currentTeam->id)
|
||||
->whereHas('project', fn (Builder $query) => $query
|
||||
->where('team_id', $currentTeam->id)
|
||||
->where('uuid', $selectedProject['uuid']))
|
||||
->whereHas('environment', fn (Builder $query) => $query
|
||||
->where('uuid', $selectedEnvironment['uuid']))
|
||||
->with('rules')
|
||||
->orderBy('id')
|
||||
->get()
|
||||
->map(fn (ResourceConnection $connection) => $this->connectionSerializer->serialize($connection))
|
||||
->all();
|
||||
}
|
||||
|
||||
private function nginxServers(mixed $currentTeam): array
|
||||
{
|
||||
if (! $currentTeam instanceof Team) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return V5Server::query()
|
||||
->where('team_id', $currentTeam->id)
|
||||
->orderByRaw('last_bootstrapped_at is null')
|
||||
->orderBy('name')
|
||||
->get(['id', 'uuid', 'name', 'host', 'status'])
|
||||
->map(fn (V5Server $server) => [
|
||||
'id' => $server->uuid,
|
||||
'name' => $server->name,
|
||||
'host' => $server->host,
|
||||
'status' => $server->status,
|
||||
])
|
||||
->all();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V5;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\V5\Concerns\ResolvesCurrentTeam;
|
||||
use App\Http\Controllers\V5\Concerns\ResolvesProjectSelection;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Team;
|
||||
use App\Models\V5\Application as V5Application;
|
||||
use App\Models\V5\ResourceConnection;
|
||||
use App\Services\Flux\FluxClient;
|
||||
use App\Support\V5\ConnectionFirewallSync;
|
||||
use App\Support\V5\ResourceConnectionSerializer;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class ResourceConnectionController extends Controller
|
||||
{
|
||||
use ResolvesCurrentTeam;
|
||||
use ResolvesProjectSelection;
|
||||
|
||||
public function __construct(
|
||||
private readonly ConnectionFirewallSync $firewallSync,
|
||||
private readonly ResourceConnectionSerializer $connectionSerializer,
|
||||
) {}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$currentTeam = $this->currentTeamOrFail($request);
|
||||
$this->authorize('create', [ResourceConnection::class, $currentTeam]);
|
||||
$projects = $this->projects($currentTeam);
|
||||
[$selectedProject, $selectedEnvironment] = $this->selectedProjectAndEnvironment($request, $projects);
|
||||
|
||||
if ($selectedProject === null || $selectedEnvironment === null) {
|
||||
return response()->json([
|
||||
'message' => 'Select a project and environment before connecting resources.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$project = $this->projectQuery($currentTeam)
|
||||
->where('uuid', $selectedProject['uuid'])
|
||||
->first();
|
||||
|
||||
if (! $project instanceof Project) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$environment = $this->selectedEnvironment($project, $selectedEnvironment['uuid']);
|
||||
|
||||
if (! $environment instanceof Environment) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'resource_one' => ['required', 'array'],
|
||||
'resource_one.type' => ['required', 'string', Rule::in(['application'])],
|
||||
'resource_one.uuid' => ['required', 'string', 'max:255'],
|
||||
'resource_two' => ['required', 'array'],
|
||||
'resource_two.type' => ['required', 'string', Rule::in(['application'])],
|
||||
'resource_two.uuid' => ['required', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
$resourceOne = $this->resolveConnectableResource($currentTeam, $project, $environment, $validated['resource_one']);
|
||||
$resourceTwo = $this->resolveConnectableResource($currentTeam, $project, $environment, $validated['resource_two']);
|
||||
|
||||
if ($this->resourceIdentity($resourceOne) === $this->resourceIdentity($resourceTwo)) {
|
||||
return response()->json([
|
||||
'message' => 'A resource cannot connect to itself.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$connection = ResourceConnection::query()->firstOrCreate(
|
||||
[
|
||||
'team_id' => $currentTeam->id,
|
||||
'resource_pair_key' => $this->resourcePairKey($resourceOne, $resourceTwo),
|
||||
],
|
||||
[
|
||||
'project_id' => $project->id,
|
||||
'environment_id' => $environment->id,
|
||||
'resource_one_type' => $resourceOne->getMorphClass(),
|
||||
'resource_one_id' => $resourceOne->getKey(),
|
||||
'resource_two_type' => $resourceTwo->getMorphClass(),
|
||||
'resource_two_id' => $resourceTwo->getKey(),
|
||||
'created_by_user_id' => $request->user()->id,
|
||||
],
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'connection' => $this->connectionSerializer->serialize($connection->load('rules')),
|
||||
], $connection->wasRecentlyCreated ? 201 : 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the connection's rules, then converge the node firewalls.
|
||||
*
|
||||
* Ordering & failure semantics:
|
||||
* 1. Snapshot the current DB rules and their firewall representation; abort
|
||||
* with 502 before mutating anything when the snapshot cannot be built.
|
||||
* 2. Commit the requested rules in a DB transaction — the DB always holds
|
||||
* the desired state.
|
||||
* 3. Converge the node firewalls through Flux. Nodes whose coold lacks the
|
||||
* firewall verbs (UnsupportedCooldVerb) are tolerated: the committed
|
||||
* rules are kept and the request succeeds.
|
||||
* 4. On a real Flux failure the previous rules are restored in a second DB
|
||||
* transaction, the node firewalls are rolled back to the restored rules
|
||||
* best-effort (warning-logged when that also fails — deterministic rule
|
||||
* ids keep a later re-sync idempotent), and the original error surfaces
|
||||
* to the caller as a 502 {message, detail} response.
|
||||
*/
|
||||
public function update(Request $request, ResourceConnection $connection, FluxClient $fluxClient): JsonResponse
|
||||
{
|
||||
$currentTeam = $this->currentTeamOrFail($request);
|
||||
$this->authorize('update', [$connection, $currentTeam]);
|
||||
|
||||
$validated = $request->validate([
|
||||
'ports_by_direction' => ['present', 'array'],
|
||||
'ports_by_direction.*' => ['array'],
|
||||
'ports_by_direction.*.*' => ['integer', 'min:1', 'max:65535', 'distinct'],
|
||||
]);
|
||||
|
||||
$connection->load('rules');
|
||||
$oldRulePayloads = $connection->rules
|
||||
->map(fn ($rule): array => [
|
||||
'source_resource_type' => $rule->source_resource_type,
|
||||
'source_resource_id' => $rule->source_resource_id,
|
||||
'target_resource_type' => $rule->target_resource_type,
|
||||
'target_resource_id' => $rule->target_resource_id,
|
||||
'protocol' => $rule->protocol,
|
||||
'port' => $rule->port,
|
||||
])
|
||||
->all();
|
||||
|
||||
try {
|
||||
$oldFirewallRules = $this->firewallSync->rulesFor($connection);
|
||||
} catch (\RuntimeException $exception) {
|
||||
report($exception);
|
||||
Log::warning('V5 resource connection firewall snapshot failed', [
|
||||
'connection_id' => $connection->id,
|
||||
'message' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Could not sync firewall rules through Flux.',
|
||||
'detail' => 'The connection was left unchanged. Check the server diagnostics and try again.',
|
||||
], 502);
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($connection, $validated): void {
|
||||
$connection->rules()->delete();
|
||||
$resourcesByUuid = $this->connectionSerializer->applicationsByUuid($connection);
|
||||
|
||||
foreach ($validated['ports_by_direction'] as $direction => $ports) {
|
||||
[$sourceResourceUuid, $targetResourceUuid] = array_pad(explode('->', (string) $direction, 2), 2, null);
|
||||
$sourceResource = is_string($sourceResourceUuid) ? $resourcesByUuid->get($sourceResourceUuid) : null;
|
||||
$targetResource = is_string($targetResourceUuid) ? $resourcesByUuid->get($targetResourceUuid) : null;
|
||||
|
||||
if (! $sourceResource instanceof V5Application || ! $targetResource instanceof V5Application) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (array_unique($ports) as $port) {
|
||||
$connection->rules()->create([
|
||||
'source_resource_type' => $this->resourceTypeForConnectionUuid($connection, $sourceResource->uuid),
|
||||
'source_resource_id' => $sourceResource->id,
|
||||
'target_resource_type' => $this->resourceTypeForConnectionUuid($connection, $targetResource->uuid),
|
||||
'target_resource_id' => $targetResource->id,
|
||||
'protocol' => 'tcp',
|
||||
'port' => (int) $port,
|
||||
]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$connection->refresh()->load('rules');
|
||||
|
||||
$newFirewallRules = null;
|
||||
|
||||
try {
|
||||
$newFirewallRules = $this->firewallSync->rulesFor($connection);
|
||||
$this->firewallSync->sync($fluxClient, $oldFirewallRules, $newFirewallRules);
|
||||
} catch (\RuntimeException $exception) {
|
||||
$this->restoreConnectionRules($connection, $oldRulePayloads);
|
||||
$this->rollBackFirewallRules($fluxClient, $connection, $newFirewallRules, $oldFirewallRules);
|
||||
report($exception);
|
||||
Log::warning('V5 resource connection firewall sync failed', [
|
||||
'connection_id' => $connection->id,
|
||||
'message' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Could not sync firewall rules through Flux.',
|
||||
'detail' => 'The previous rules were restored. Check the server diagnostics and try again.',
|
||||
], 502);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'connection' => $this->connectionSerializer->serialize($connection),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the connection using revoke-first ordering.
|
||||
*
|
||||
* The node firewall rules are revoked before any DB rows are removed. When
|
||||
* a revoke fails with a real error the delete is aborted with a 502
|
||||
* {message, detail} response so the DB never loses track of rules that may
|
||||
* still be open on a reachable node; UnsupportedCooldVerb and
|
||||
* already-missing rules are tolerated. When the firewall snapshot cannot
|
||||
* be built (an endpoint lost its server host id) the node cannot be
|
||||
* addressed at all, so the failure is reported and the delete proceeds.
|
||||
* Deterministic rule ids make a retried delete revoke the same node-side
|
||||
* rules idempotently.
|
||||
*/
|
||||
public function destroy(Request $request, ResourceConnection $connection, FluxClient $fluxClient): Response|JsonResponse
|
||||
{
|
||||
$currentTeam = $this->currentTeamOrFail($request);
|
||||
$this->authorize('delete', [$connection, $currentTeam]);
|
||||
|
||||
try {
|
||||
$oldFirewallRules = $this->firewallSync->rulesFor($connection->load('rules'));
|
||||
} catch (\RuntimeException $exception) {
|
||||
report($exception);
|
||||
$oldFirewallRules = collect();
|
||||
}
|
||||
|
||||
try {
|
||||
$this->firewallSync->sync($fluxClient, $oldFirewallRules, collect());
|
||||
} catch (\RuntimeException $exception) {
|
||||
report($exception);
|
||||
Log::warning('V5 resource connection firewall revoke failed', [
|
||||
'connection_id' => $connection->id,
|
||||
'message' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Could not sync firewall rules through Flux.',
|
||||
'detail' => 'The connection was not deleted. Check the server diagnostics and try again.',
|
||||
], 502);
|
||||
}
|
||||
|
||||
$connection->delete();
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{type: string, uuid: string} $resource
|
||||
*/
|
||||
private function resolveConnectableResource(Team $team, Project $project, Environment $environment, array $resource): Model
|
||||
{
|
||||
return match ($resource['type']) {
|
||||
'application' => V5Application::query()
|
||||
->where('team_id', $team->id)
|
||||
->where('project_id', $project->id)
|
||||
->where('environment_id', $environment->id)
|
||||
->where('uuid', $resource['uuid'])
|
||||
->firstOrFail(),
|
||||
};
|
||||
}
|
||||
|
||||
private function resourcePairKey(Model $resourceOne, Model $resourceTwo): string
|
||||
{
|
||||
return collect([
|
||||
$this->resourceIdentity($resourceOne),
|
||||
$this->resourceIdentity($resourceTwo),
|
||||
])->sort()->implode('|');
|
||||
}
|
||||
|
||||
private function resourceIdentity(Model $resource): string
|
||||
{
|
||||
return $resource->getMorphClass().':'.$resource->getKey();
|
||||
}
|
||||
|
||||
private function resourceTypeForConnectionUuid(ResourceConnection $connection, string $resourceUuid): string
|
||||
{
|
||||
$resourcesByUuid = $this->connectionSerializer->applicationsByUuid($connection);
|
||||
$resource = $resourcesByUuid->get($resourceUuid);
|
||||
|
||||
return $resource instanceof V5Application && (int) $connection->resource_one_id === $resource->id
|
||||
? $connection->resource_one_type
|
||||
: $connection->resource_two_type;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $rulePayloads
|
||||
*/
|
||||
private function restoreConnectionRules(ResourceConnection $connection, array $rulePayloads): void
|
||||
{
|
||||
DB::transaction(function () use ($connection, $rulePayloads): void {
|
||||
$connection->rules()->delete();
|
||||
|
||||
foreach ($rulePayloads as $rulePayload) {
|
||||
$connection->rules()->create($rulePayload);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort roll back of a partially converged node firewall to the
|
||||
* restored rules after a failed forward sync. Skipped when the forward
|
||||
* sync never started (the node was not touched). Failures are only logged
|
||||
* because the DB already holds the restored, authoritative rules and the
|
||||
* deterministic rule ids keep a later re-sync idempotent.
|
||||
*
|
||||
* @param Collection<int, array{id: string, hostId: string, rule: array{id: string, namespace: string, src: string, dst: string, proto: string, port: int}}>|null $attemptedFirewallRules
|
||||
* @param Collection<int, array{id: string, hostId: string, rule: array{id: string, namespace: string, src: string, dst: string, proto: string, port: int}}> $restoredFirewallRules
|
||||
*/
|
||||
private function rollBackFirewallRules(FluxClient $fluxClient, ResourceConnection $connection, ?Collection $attemptedFirewallRules, Collection $restoredFirewallRules): void
|
||||
{
|
||||
if (! $attemptedFirewallRules instanceof Collection) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->firewallSync->sync($fluxClient, $attemptedFirewallRules, $restoredFirewallRules);
|
||||
} catch (\RuntimeException $exception) {
|
||||
Log::warning('V5 resource connection firewall rollback failed; node firewall may diverge from the restored rules until the next sync', [
|
||||
'connection_id' => $connection->id,
|
||||
'message' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,901 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V5;
|
||||
|
||||
use App\Actions\V5\Proxy\StartCaddyIngress;
|
||||
use App\Actions\V5\Proxy\StopCaddyIngress;
|
||||
use App\Actions\V5\Server\RemoveBootstrapMarker;
|
||||
use App\Enums\V5\ServerStatus;
|
||||
use App\Events\V5ClusterUpdated;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\V5\Concerns\HandlesIngressSyncErrors;
|
||||
use App\Http\Controllers\V5\Concerns\ResolvesCurrentTeam;
|
||||
use App\Http\Controllers\V5\Concerns\ValidatesBuilderConfiguration;
|
||||
use App\Jobs\V5BootstrapServerJob;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\V5\Application as V5Application;
|
||||
use App\Models\V5\Cluster as V5Cluster;
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use App\Rules\ValidServerIp;
|
||||
use App\Services\Flux\AgentTokenIssuer;
|
||||
use App\Services\Flux\FluxClient;
|
||||
use App\Support\V5\ClusterSerializer;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class ServerController extends Controller
|
||||
{
|
||||
use HandlesIngressSyncErrors;
|
||||
use ResolvesCurrentTeam;
|
||||
use ValidatesBuilderConfiguration;
|
||||
|
||||
public function store(Request $request, V5Cluster $cluster): JsonResponse
|
||||
{
|
||||
$currentTeam = $this->currentTeamOrFail($request);
|
||||
$this->authorize('create', [V5Server::class, $currentTeam, $cluster]);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'host' => [
|
||||
'required',
|
||||
'string',
|
||||
'max:255',
|
||||
$this->noControlCharactersRule(),
|
||||
new ValidServerIp,
|
||||
Rule::unique('v5_servers', 'host')
|
||||
->where('team_id', $currentTeam->id)
|
||||
->where('ssh_port', (int) $request->input('ssh_port', 22)),
|
||||
],
|
||||
'ssh_user' => ['required', 'string', 'max:255', 'regex:/^[A-Za-z0-9._-]+$/', $this->noControlCharactersRule()],
|
||||
'ssh_port' => ['required', 'integer', 'min:1', 'max:65535'],
|
||||
'private_key_uuid' => [
|
||||
'required',
|
||||
'string',
|
||||
Rule::exists('private_keys', 'uuid')->where('team_id', $currentTeam->id),
|
||||
],
|
||||
'node_address' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'max:255',
|
||||
$this->noControlCharactersRule(),
|
||||
new ValidServerIp,
|
||||
Rule::unique('v5_servers', 'node_address')->where('team_id', $currentTeam->id),
|
||||
],
|
||||
'builder_enabled' => ['sometimes', 'boolean'],
|
||||
'builder_capacity' => $this->builderCapacityRules(
|
||||
$this->requestedBuilderEnabled($request, $cluster->builder_enabled)
|
||||
),
|
||||
'builder_cpu_quota' => ['sometimes', 'string', 'max:32'],
|
||||
'wireguard_listen_port_override' => ['nullable', 'integer', 'min:1', 'max:65535'],
|
||||
'wireguard_endpoint_override' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'max:255',
|
||||
$this->noControlCharactersRule(),
|
||||
$this->hostPortRule(),
|
||||
Rule::unique('v5_servers', 'wireguard_endpoint_override')->where('cluster_id', $cluster->id),
|
||||
],
|
||||
'ingress_enabled' => ['sometimes', 'boolean'],
|
||||
'ingress_type' => [
|
||||
Rule::requiredIf(fn () => $request->boolean('ingress_enabled')),
|
||||
'nullable',
|
||||
'string',
|
||||
Rule::in(['caddy']),
|
||||
],
|
||||
]);
|
||||
|
||||
$capacity = $this->clusterServerCapacity($cluster);
|
||||
|
||||
if ($capacity !== null && $cluster->servers()->count() >= $capacity) {
|
||||
return response()->json([
|
||||
'message' => "This cluster's network pools are full ({$capacity} server(s) max). Grow the pools or remove a server first.",
|
||||
], 422);
|
||||
}
|
||||
|
||||
$builderEnabled = (bool) ($validated['builder_enabled'] ?? $cluster->builder_enabled);
|
||||
$ingressEnabled = (bool) ($validated['ingress_enabled'] ?? false);
|
||||
$ingressType = $ingressEnabled ? $validated['ingress_type'] : null;
|
||||
$builderCapacity = (int) ($validated['builder_capacity'] ?? $cluster->builder_capacity);
|
||||
$builderCpuQuota = $validated['builder_cpu_quota'] ?? $cluster->builder_cpu_quota;
|
||||
$devWireguardOverrides = $this->devLimaWireguardOverrides($validated['host'], (int) $validated['ssh_port']);
|
||||
$privateKey = PrivateKey::query()
|
||||
->where('team_id', $currentTeam->id)
|
||||
->where('uuid', $validated['private_key_uuid'])
|
||||
->firstOrFail();
|
||||
|
||||
V5Server::query()->create([
|
||||
'team_id' => $currentTeam->id,
|
||||
'cluster_id' => $cluster->id,
|
||||
'created_by_user_id' => $request->user()->id,
|
||||
'name' => $validated['name'],
|
||||
'host' => $validated['host'],
|
||||
'ssh_user' => $validated['ssh_user'],
|
||||
'ssh_port' => $validated['ssh_port'],
|
||||
'private_key_id' => $privateKey->id,
|
||||
'status' => ServerStatus::Added->value,
|
||||
'ingress_type' => $ingressType,
|
||||
'is_ingress' => $ingressEnabled,
|
||||
'builder_enabled' => $builderEnabled,
|
||||
'builder_capacity' => $builderCapacity,
|
||||
'builder_cpu_quota' => $builderCpuQuota,
|
||||
'node_address' => $validated['node_address'] ?? $validated['host'],
|
||||
'wireguard_listen_port_override' => $validated['wireguard_listen_port_override'] ?? $devWireguardOverrides['listen_port'],
|
||||
'wireguard_endpoint_override' => $validated['wireguard_endpoint_override'] ?? $devWireguardOverrides['endpoint'],
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'cluster' => app(ClusterSerializer::class)->serializeFresh($cluster),
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function update(Request $request, V5Cluster $cluster, V5Server $server): JsonResponse
|
||||
{
|
||||
$currentTeam = $this->currentTeamOrFail($request);
|
||||
$this->authorize('update', [$server, $currentTeam, $cluster]);
|
||||
|
||||
$validated = $request->validate([
|
||||
'builder_enabled' => ['required', 'boolean'],
|
||||
'builder_capacity' => $this->builderCapacityRules(
|
||||
$request->boolean('builder_enabled'),
|
||||
required: true
|
||||
),
|
||||
'builder_cpu_quota' => ['required', 'string', 'max:32'],
|
||||
'ingress_enabled' => ['sometimes', 'boolean'],
|
||||
'ingress_type' => [
|
||||
Rule::requiredIf(fn () => $request->boolean('ingress_enabled')),
|
||||
'nullable',
|
||||
'string',
|
||||
Rule::in(['caddy']),
|
||||
],
|
||||
]);
|
||||
|
||||
$wasIngress = $server->isIngress();
|
||||
$builderEnabled = (bool) $validated['builder_enabled'];
|
||||
$ingressEnabled = (bool) ($validated['ingress_enabled'] ?? $wasIngress);
|
||||
$ingressType = $ingressEnabled ? ($validated['ingress_type'] ?? $server->ingress_type ?? 'caddy') : null;
|
||||
$originalServerAttributes = $server->only([
|
||||
'is_ingress',
|
||||
'ingress_type',
|
||||
'ingress_status',
|
||||
'builder_enabled',
|
||||
'builder_capacity',
|
||||
'builder_cpu_quota',
|
||||
]);
|
||||
|
||||
// Stop the ingress before persisting the change: StopCaddyIngress needs
|
||||
// the server's current ingress state, and a failed stop must leave the
|
||||
// capability untouched.
|
||||
if ($wasIngress && ! $ingressEnabled && $server->status === ServerStatus::Installed->value) {
|
||||
try {
|
||||
StopCaddyIngress::run($server);
|
||||
} catch (\RuntimeException $exception) {
|
||||
return $this->ingressSyncErrorResponse($exception);
|
||||
}
|
||||
}
|
||||
|
||||
$server->update([
|
||||
'is_ingress' => $ingressEnabled,
|
||||
'ingress_type' => $ingressType,
|
||||
'builder_enabled' => $builderEnabled,
|
||||
'builder_capacity' => (int) $validated['builder_capacity'],
|
||||
'builder_cpu_quota' => $validated['builder_cpu_quota'],
|
||||
]);
|
||||
|
||||
$server->refresh();
|
||||
|
||||
if (! $wasIngress && $ingressEnabled && $server->status === ServerStatus::Installed->value) {
|
||||
try {
|
||||
StartCaddyIngress::run($server);
|
||||
} catch (\RuntimeException $exception) {
|
||||
$server->update($originalServerAttributes);
|
||||
|
||||
return $this->ingressSyncErrorResponse($exception);
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'cluster' => app(ClusterSerializer::class)->serializeFresh($cluster),
|
||||
]);
|
||||
}
|
||||
|
||||
public function check(Request $request, V5Cluster $cluster, V5Server $server): JsonResponse
|
||||
{
|
||||
$currentTeam = $this->currentTeamOrFail($request);
|
||||
$this->authorize('check', [$server, $currentTeam, $cluster]);
|
||||
|
||||
if (! $server->privateKey instanceof PrivateKey) {
|
||||
return response()->json([
|
||||
'status' => 'failed',
|
||||
'output' => 'No private key is attached to this server.',
|
||||
'checkedAt' => now()->toJSON(),
|
||||
]);
|
||||
}
|
||||
|
||||
$keyDirectory = storage_path('app/ssh/keys');
|
||||
if (! is_dir($keyDirectory)) {
|
||||
mkdir($keyDirectory, 0700, true);
|
||||
}
|
||||
|
||||
$keyLocation = tempnam($keyDirectory, 'v5_ssh_key_');
|
||||
if ($keyLocation === false) {
|
||||
return response()->json([
|
||||
'status' => 'failed',
|
||||
'output' => 'Could not create a temporary SSH key file.',
|
||||
'checkedAt' => now()->toJSON(),
|
||||
]);
|
||||
}
|
||||
|
||||
file_put_contents($keyLocation, $server->privateKey->private_key);
|
||||
chmod($keyLocation, 0600);
|
||||
|
||||
$target = "{$server->ssh_user}@{$server->host}";
|
||||
$command = [
|
||||
'ssh',
|
||||
'-o',
|
||||
'BatchMode=yes',
|
||||
'-o',
|
||||
'LogLevel=ERROR',
|
||||
'-o',
|
||||
'StrictHostKeyChecking=no',
|
||||
'-o',
|
||||
'UserKnownHostsFile=/dev/null',
|
||||
'-o',
|
||||
'ConnectTimeout=10',
|
||||
'-o',
|
||||
'IdentitiesOnly=yes',
|
||||
'-i',
|
||||
$keyLocation,
|
||||
'-p',
|
||||
(string) $server->ssh_port,
|
||||
$target,
|
||||
"printf 'SSH connection OK\n'; hostname; uname -srm; command -v docker || true; command -v podman || true",
|
||||
];
|
||||
|
||||
try {
|
||||
$result = Process::timeout(15)->run($command);
|
||||
$output = trim($result->output()."\n".$result->errorOutput());
|
||||
$status = $result->successful() ? 'reachable' : 'failed';
|
||||
} catch (\Throwable $e) {
|
||||
$output = $e->getMessage();
|
||||
$status = 'failed';
|
||||
} finally {
|
||||
@unlink($keyLocation);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'status' => $status,
|
||||
'output' => str($output !== '' ? $output : 'No output returned.')->limit(10000)->toString(),
|
||||
'checkedAt' => now()->toJSON(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function restartCoold(Request $request, V5Cluster $cluster, V5Server $server, AgentTokenIssuer $agentTokenIssuer, FluxClient $fluxClient): JsonResponse
|
||||
{
|
||||
$currentTeam = $this->currentTeamOrFail($request);
|
||||
$this->authorize('restartCoold', [$server, $currentTeam, $cluster]);
|
||||
|
||||
$server->loadMissing('privateKey');
|
||||
|
||||
if (! $server->privateKey instanceof PrivateKey) {
|
||||
return response()->json([
|
||||
'message' => 'No private key is attached to this server.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$token = $agentTokenIssuer->issueForServer($server);
|
||||
$output = $this->restartCooldOverSsh($server, $token);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('V5 coold restart over SSH failed', [
|
||||
'server_id' => $server->id,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => str($e->getMessage() !== '' ? $e->getMessage() : 'Could not restart coold over SSH.')->limit(10000)->toString(),
|
||||
], 502);
|
||||
}
|
||||
|
||||
$connected = false;
|
||||
|
||||
try {
|
||||
usleep(500_000);
|
||||
$fluxClient->cooldLogs($server->fluxHostId(), 1);
|
||||
$connected = true;
|
||||
$server->forceFill([
|
||||
'status' => ServerStatus::Installed->value,
|
||||
'last_status_check' => 'flux',
|
||||
'last_status_output' => 'coold restarted over SSH and reconnected to Flux.',
|
||||
])->save();
|
||||
} catch (\Throwable $e) {
|
||||
Log::info('V5 coold restart succeeded but Flux reconnect is not confirmed yet', [
|
||||
'server_id' => $server->id,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'cluster' => app(ClusterSerializer::class)->serializeFresh($cluster),
|
||||
'output' => $output,
|
||||
'connected' => $connected,
|
||||
'restartedAt' => now()->toJSON(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function restartCooldOverSsh(V5Server $server, string $token): string
|
||||
{
|
||||
$encodedToken = base64_encode($token);
|
||||
$script = implode(PHP_EOL, [
|
||||
'set -e',
|
||||
"SUDO=''",
|
||||
'if [ "$(id -u)" != "0" ]; then SUDO="sudo -n"; fi',
|
||||
'$SUDO mkdir -p /etc/coolify',
|
||||
'printf %s '.escapeshellarg($encodedToken).' | base64 -d | $SUDO tee /etc/coolify/host-jwt >/dev/null',
|
||||
'$SUDO chmod 600 /etc/coolify/host-jwt',
|
||||
'$SUDO systemctl reset-failed coold.service || true',
|
||||
'$SUDO systemctl restart coold.service',
|
||||
'$SUDO systemctl is-active coold.service',
|
||||
'$SUDO systemctl status coold.service --no-pager -l | sed -n "1,18p"',
|
||||
]);
|
||||
|
||||
return $this->runServerSshCommand($server, $script, 'SSH coold restart command failed.', 45);
|
||||
}
|
||||
|
||||
public function cooldLogs(Request $request, V5Cluster $cluster, V5Server $server, FluxClient $fluxClient): JsonResponse
|
||||
{
|
||||
$currentTeam = $this->currentTeamOrFail($request);
|
||||
$this->authorize('viewDiagnostics', [$server, $currentTeam, $cluster]);
|
||||
|
||||
$validated = $request->validate([
|
||||
'tail' => ['sometimes', 'integer', 'min:1', 'max:1000'],
|
||||
]);
|
||||
|
||||
$hostId = $server->fluxHostId();
|
||||
|
||||
if (! is_string($hostId) || $hostId === '') {
|
||||
return response()->json([
|
||||
'message' => 'This server is missing its Flux host id.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$output = $fluxClient->cooldLogs($hostId, (int) ($validated['tail'] ?? 200));
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('V5 coold logs request failed', [
|
||||
'server_id' => $server->id,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
if ($server->privateKey instanceof PrivateKey) {
|
||||
try {
|
||||
return response()->json([
|
||||
'output' => $this->cooldLogsOverSsh($server, (int) ($validated['tail'] ?? 200)),
|
||||
'source' => 'ssh',
|
||||
'fetchedAt' => now()->toJSON(),
|
||||
]);
|
||||
} catch (\Throwable $sshException) {
|
||||
Log::warning('V5 coold logs SSH fallback failed', [
|
||||
'server_id' => $server->id,
|
||||
'message' => $sshException->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Could not fetch coold logs through Flux. Check the Flux and coold status, then try again.',
|
||||
], 502);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'output' => $output,
|
||||
'source' => 'flux',
|
||||
'fetchedAt' => now()->toJSON(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function cooldLogsOverSsh(V5Server $server, int $tail): string
|
||||
{
|
||||
return $this->runServerSshCommand(
|
||||
$server,
|
||||
'sudo -n journalctl -u coold -n '.max(1, min($tail, 1000)).' --no-pager -q || journalctl -u coold -n '.max(1, min($tail, 1000)).' --no-pager -q',
|
||||
'SSH coold log command failed.',
|
||||
);
|
||||
}
|
||||
|
||||
private function runServerSshCommand(V5Server $server, string $remoteCommand, string $failureMessage, int $timeout = 15): string
|
||||
{
|
||||
$keyDirectory = storage_path('app/ssh/keys');
|
||||
if (! is_dir($keyDirectory)) {
|
||||
mkdir($keyDirectory, 0700, true);
|
||||
}
|
||||
|
||||
$keyLocation = tempnam($keyDirectory, 'v5_ssh_key_');
|
||||
if ($keyLocation === false) {
|
||||
throw new \RuntimeException('Could not create a temporary SSH key file.');
|
||||
}
|
||||
|
||||
file_put_contents($keyLocation, $server->privateKey->private_key);
|
||||
chmod($keyLocation, 0600);
|
||||
|
||||
try {
|
||||
$result = Process::timeout($timeout)->run([
|
||||
'ssh',
|
||||
'-o',
|
||||
'BatchMode=yes',
|
||||
'-o',
|
||||
'LogLevel=ERROR',
|
||||
'-o',
|
||||
'StrictHostKeyChecking=no',
|
||||
'-o',
|
||||
'UserKnownHostsFile=/dev/null',
|
||||
'-o',
|
||||
'ConnectTimeout=10',
|
||||
'-o',
|
||||
'IdentitiesOnly=yes',
|
||||
'-i',
|
||||
$keyLocation,
|
||||
'-p',
|
||||
(string) $server->ssh_port,
|
||||
"{$server->ssh_user}@{$server->host}",
|
||||
$remoteCommand,
|
||||
]);
|
||||
$output = trim($result->output()."\n".$result->errorOutput());
|
||||
|
||||
if (! $result->successful()) {
|
||||
throw new \RuntimeException($output !== '' ? $output : $failureMessage);
|
||||
}
|
||||
|
||||
return str($output)->limit(10000)->toString();
|
||||
} finally {
|
||||
@unlink($keyLocation);
|
||||
}
|
||||
}
|
||||
|
||||
public function corrosionTables(Request $request, V5Cluster $cluster, V5Server $server, FluxClient $fluxClient): JsonResponse
|
||||
{
|
||||
$currentTeam = $this->currentTeamOrFail($request);
|
||||
$this->authorize('viewDiagnostics', [$server, $currentTeam, $cluster]);
|
||||
|
||||
$validated = $request->validate([
|
||||
'limit' => ['sometimes', 'integer', 'min:1', 'max:1000'],
|
||||
]);
|
||||
|
||||
$hostId = $server->fluxHostId();
|
||||
|
||||
if (! is_string($hostId) || $hostId === '') {
|
||||
return response()->json([
|
||||
'message' => 'This server is missing its Flux host id.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$output = $fluxClient->corrosionTables($hostId, (int) ($validated['limit'] ?? 200));
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('V5 corrosion tables request failed', [
|
||||
'server_id' => $server->id,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
if ($server->privateKey instanceof PrivateKey) {
|
||||
try {
|
||||
return response()->json([
|
||||
'output' => $this->corrosionTablesOverSsh($server, $cluster, (int) ($validated['limit'] ?? 200)),
|
||||
'source' => 'ssh',
|
||||
'fetchedAt' => now()->toJSON(),
|
||||
]);
|
||||
} catch (\Throwable $sshException) {
|
||||
Log::warning('V5 corrosion tables SSH fallback failed', [
|
||||
'server_id' => $server->id,
|
||||
'message' => $sshException->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Could not fetch corrosion tables through Flux. Check the Flux and coold status, then try again.',
|
||||
], 502);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'output' => $output,
|
||||
'source' => 'flux',
|
||||
'fetchedAt' => now()->toJSON(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function corrosionTablesOverSsh(V5Server $server, V5Cluster $cluster, int $limit): string
|
||||
{
|
||||
$limit = max(1, min($limit, 1000));
|
||||
$script = <<<'PYTHON'
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import urllib.request
|
||||
|
||||
limit = __LIMIT__
|
||||
url = "http://127.0.0.1:__PORT__/v1/queries"
|
||||
|
||||
def query(sql):
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
data=json.dumps([sql, []]).encode(),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=10) as response:
|
||||
return json.loads(response.read().decode())
|
||||
|
||||
def quote_identifier(value):
|
||||
return '"' + value.replace('"', '""') + '"'
|
||||
|
||||
tables = []
|
||||
for row in query("SELECT name FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name"):
|
||||
name = row[0] if row else None
|
||||
if not isinstance(name, str):
|
||||
continue
|
||||
|
||||
identifier = quote_identifier(name)
|
||||
columns = [column[1] for column in query(f"PRAGMA table_info({identifier})") if len(column) > 1]
|
||||
rows = query(f"SELECT * FROM {identifier} LIMIT {limit}")
|
||||
tables.append({"name": name, "columns": columns, "rows": rows})
|
||||
|
||||
print(json.dumps({"limit": limit, "tables": tables}, separators=(",", ":")))
|
||||
PY
|
||||
PYTHON;
|
||||
|
||||
return $this->runServerSshCommand($server, str_replace(
|
||||
['__LIMIT__', '__PORT__'],
|
||||
[(string) $limit, (string) $cluster->corrosion_api_port],
|
||||
$script,
|
||||
), 'SSH corrosion table command failed.');
|
||||
}
|
||||
|
||||
public function firewallRules(Request $request, V5Cluster $cluster, V5Server $server, FluxClient $fluxClient): JsonResponse
|
||||
{
|
||||
$currentTeam = $this->currentTeamOrFail($request);
|
||||
$this->authorize('viewDiagnostics', [$server, $currentTeam, $cluster]);
|
||||
|
||||
$validated = $request->validate([
|
||||
'namespace' => ['sometimes', 'string', 'max:63'],
|
||||
]);
|
||||
|
||||
$hostId = $server->fluxHostId();
|
||||
|
||||
if (! is_string($hostId) || $hostId === '') {
|
||||
return response()->json([
|
||||
'message' => 'This server is missing its Flux host id.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$rules = $fluxClient->listFirewallRules($hostId, (string) ($validated['namespace'] ?? ''));
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('V5 firewall rules request failed', [
|
||||
'server_id' => $server->id,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
if ($server->privateKey instanceof PrivateKey) {
|
||||
try {
|
||||
return response()->json([
|
||||
'rules' => $this->firewallRulesOverSsh($server, (string) ($validated['namespace'] ?? '')),
|
||||
'source' => 'ssh',
|
||||
'fetchedAt' => now()->toJSON(),
|
||||
]);
|
||||
} catch (\Throwable $sshException) {
|
||||
Log::warning('V5 firewall rules SSH fallback failed', [
|
||||
'server_id' => $server->id,
|
||||
'message' => $sshException->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Could not fetch firewall rules through Flux. Check the Flux and coold status, then try again.',
|
||||
], 502);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'rules' => $rules,
|
||||
'source' => 'flux',
|
||||
'fetchedAt' => now()->toJSON(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{id: string, namespace: string, src: string, dst: string, proto: string, port: int}>
|
||||
*/
|
||||
private function firewallRulesOverSsh(V5Server $server, string $namespace): array
|
||||
{
|
||||
$script = str_replace('__NAMESPACE__', json_encode($namespace, JSON_THROW_ON_ERROR), <<<'PYTHON'
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
namespace = __NAMESPACE__
|
||||
path = Path("/etc/coolify/firewall-rules.tsv")
|
||||
rules = []
|
||||
|
||||
if path.exists():
|
||||
for line in path.read_text().splitlines():
|
||||
parts = line.split("\t")
|
||||
if len(parts) == 6:
|
||||
rule_id, rule_namespace, src, dst, proto, port = parts
|
||||
elif len(parts) == 5:
|
||||
rule_id = ""
|
||||
rule_namespace, src, dst, proto, port = parts
|
||||
else:
|
||||
continue
|
||||
|
||||
if namespace and rule_namespace != namespace:
|
||||
continue
|
||||
|
||||
try:
|
||||
port = int(port)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
rules.append({
|
||||
"id": rule_id,
|
||||
"namespace": rule_namespace,
|
||||
"src": src,
|
||||
"dst": dst,
|
||||
"proto": proto,
|
||||
"port": port,
|
||||
})
|
||||
|
||||
print(json.dumps(rules, separators=(",", ":")))
|
||||
PY
|
||||
PYTHON);
|
||||
|
||||
$output = $this->runServerSshCommand($server, $script, 'SSH firewall rules command failed.');
|
||||
$rules = json_decode($output, true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
return is_array($rules) ? $rules : [];
|
||||
}
|
||||
|
||||
public function bootstrap(Request $request, V5Cluster $cluster, V5Server $server): JsonResponse
|
||||
{
|
||||
$currentTeam = $this->currentTeamOrFail($request);
|
||||
$this->authorize('bootstrap', [$server, $currentTeam, $cluster]);
|
||||
|
||||
// Fail fast: the bootstrap job hard-fails on Flux enrollment (after
|
||||
// the WireGuard mesh is already built) when no Flux URL is configured.
|
||||
if (trim((string) config('coold.flux_url', '')) === '') {
|
||||
return response()->json([
|
||||
'message' => 'COOLIFY_COOLD_FLUX_URL is not configured, so bootstrapped servers cannot be enrolled into Flux. Set it and retry the bootstrap.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
if ($server->last_bootstrapped_at !== null) {
|
||||
return response()->json([
|
||||
'message' => 'This server is already bootstrapped.',
|
||||
], 409);
|
||||
}
|
||||
|
||||
$installedServers = $cluster->servers()
|
||||
->with('privateKey')
|
||||
->whereNotNull('last_bootstrapped_at')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
$server->load('privateKey');
|
||||
$servers = $installedServers->toBase()
|
||||
->push($server)
|
||||
->unique('id')
|
||||
->values();
|
||||
|
||||
if ($servers->contains(fn (V5Server $server) => ! $server->privateKey instanceof PrivateKey)) {
|
||||
return response()->json([
|
||||
'message' => 'The new server and every already-bootstrapped server in this cluster must have a private key before extending the cluster.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$claim = DB::transaction(function () use ($cluster, $server, $installedServers): array {
|
||||
$clusterServers = $cluster->servers()->lockForUpdate()->get();
|
||||
$activeServer = $clusterServers->first(fn (V5Server $candidate): bool => $this->hasActiveBootstrapClaim($candidate));
|
||||
|
||||
if ($activeServer instanceof V5Server) {
|
||||
return ['claimed' => false, 'active_server_id' => $activeServer->id];
|
||||
}
|
||||
|
||||
// Sweep provably dead claims (lost job or killed worker) so retries
|
||||
// are possible and the UI reflects reality.
|
||||
$clusterServers
|
||||
->filter(fn (V5Server $candidate): bool => in_array($candidate->last_bootstrap_status, ['queued', 'running'], true))
|
||||
->each(fn (V5Server $candidate) => $candidate->update([
|
||||
'last_bootstrap_status' => 'failed',
|
||||
'last_bootstrap_output' => 'The previous bootstrap attempt timed out or its worker died. Retry the bootstrap.',
|
||||
]));
|
||||
|
||||
$server->update([
|
||||
'last_bootstrap_action' => $installedServers->isEmpty() ? 'bootstrap' : 'extend',
|
||||
'last_bootstrap_status' => 'queued',
|
||||
'last_bootstrap_output' => "Queued Coolify bootstrap for {$server->name}.",
|
||||
'last_bootstrap_ran_at' => now(),
|
||||
]);
|
||||
|
||||
return ['claimed' => true, 'active_server_id' => null];
|
||||
});
|
||||
|
||||
if (! $claim['claimed']) {
|
||||
return response()->json([
|
||||
'cluster' => app(ClusterSerializer::class)->serializeFresh($cluster),
|
||||
'message' => $claim['active_server_id'] === $server->id
|
||||
? 'Bootstrap is already queued or running for this server.'
|
||||
: 'Another server bootstrap is already queued or running for this cluster.',
|
||||
], 409);
|
||||
}
|
||||
|
||||
V5ClusterUpdated::dispatch($currentTeam->id, $cluster->id);
|
||||
V5BootstrapServerJob::dispatch($cluster->id, $server->id);
|
||||
|
||||
return response()->json([
|
||||
'cluster' => app(ClusterSerializer::class)->serializeFresh($cluster),
|
||||
'message' => 'Bootstrap queued.',
|
||||
], 202);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, V5Cluster $cluster, V5Server $server): Response|JsonResponse
|
||||
{
|
||||
$currentTeam = $this->currentTeamOrFail($request);
|
||||
$this->authorize('delete', [$server, $currentTeam, $cluster]);
|
||||
|
||||
if (V5Application::query()->where('server_id', $server->id)->exists()) {
|
||||
return response()->json([
|
||||
'message' => 'Delete or move applications from this server before deleting it.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$warning = null;
|
||||
|
||||
if ($server->last_bootstrapped_at !== null) {
|
||||
if ($server->isIngress() && $server->status === ServerStatus::Installed->value) {
|
||||
try {
|
||||
StopCaddyIngress::run($server);
|
||||
} catch (\Throwable $exception) {
|
||||
report($exception);
|
||||
$warning = 'Could not stop the Caddy ingress on the server before deleting it.';
|
||||
}
|
||||
}
|
||||
|
||||
if (! RemoveBootstrapMarker::run($server)) {
|
||||
$warning = 'Could not clean up the server over SSH. Remove /etc/coolify/v5-node.json manually before re-adding this server.';
|
||||
}
|
||||
}
|
||||
|
||||
$server->delete();
|
||||
|
||||
return response()->json(array_filter([
|
||||
'cluster' => app(ClusterSerializer::class)->serializeFresh($cluster),
|
||||
'warning' => $warning,
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* A queued claim is active while the job could still pick it up; a running
|
||||
* claim is active until the job timeout (plus margin) has passed. Anything
|
||||
* older is provably dead because the job runs with $tries = 1.
|
||||
*/
|
||||
private function hasActiveBootstrapClaim(V5Server $server): bool
|
||||
{
|
||||
$ranAt = $server->last_bootstrap_ran_at;
|
||||
|
||||
return match ($server->last_bootstrap_status) {
|
||||
'queued' => $ranAt !== null && $ranAt->gt(now()->subMinutes(15)),
|
||||
'running' => $ranAt !== null && $ranAt->gt(now()->subSeconds(V5BootstrapServerJob::TIMEOUT_SECONDS + 300)),
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{listen_port: int|null, endpoint: string|null}
|
||||
*/
|
||||
private function devLimaWireguardOverrides(string $host, int $sshPort): array
|
||||
{
|
||||
if (! app()->environment(['local', 'development', 'testing']) || $host !== 'host.docker.internal') {
|
||||
return ['listen_port' => null, 'endpoint' => null];
|
||||
}
|
||||
|
||||
if ($sshPort < 60001 || $sshPort > 60009) {
|
||||
return ['listen_port' => null, 'endpoint' => null];
|
||||
}
|
||||
|
||||
$wireguardPort = $sshPort - 8180;
|
||||
|
||||
return [
|
||||
'listen_port' => $wireguardPort,
|
||||
'endpoint' => "host.lima.internal:{$wireguardPort}",
|
||||
];
|
||||
}
|
||||
|
||||
private function clusterServerCapacity(V5Cluster $cluster): ?int
|
||||
{
|
||||
$namespaceCount = max(1, count($cluster->namespaces ?? V5Cluster::DEFAULT_NAMESPACES));
|
||||
|
||||
[, $poolPrefix] = array_pad(explode('/', (string) $cluster->container_network_pool, 2), 2, null);
|
||||
$containerPrefix = (int) $cluster->container_network_prefix;
|
||||
|
||||
if (! is_string($poolPrefix) || ! ctype_digit($poolPrefix) || $containerPrefix < (int) $poolPrefix || $containerPrefix > 32) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$containerCapacity = intdiv(2 ** ($containerPrefix - (int) $poolPrefix), $namespaceCount);
|
||||
|
||||
[, $managementPrefix] = array_pad(explode('/', (string) $cluster->wireguard_management_pool, 2), 2, null);
|
||||
$managementCapacity = is_string($managementPrefix) && ctype_digit($managementPrefix) && (int) $managementPrefix <= 30
|
||||
? (2 ** (32 - (int) $managementPrefix)) - 2
|
||||
: null;
|
||||
|
||||
return $managementCapacity === null ? $containerCapacity : min($containerCapacity, $managementCapacity);
|
||||
}
|
||||
|
||||
private function noControlCharactersRule(): \Closure
|
||||
{
|
||||
return function (string $attribute, mixed $value, \Closure $fail): void {
|
||||
if (! is_string($value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (preg_match('/[\x00-\x1F\x7F]/', $value) === 1) {
|
||||
$fail('The :attribute contains invalid control characters.');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private function hostPortRule(): \Closure
|
||||
{
|
||||
return function (string $attribute, mixed $value, \Closure $fail): void {
|
||||
if ($value === null || $value === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! is_string($value)) {
|
||||
$fail('The :attribute must be in host:port format.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
|
||||
if (preg_match('/^\[(?<host>.+)]:(?<port>\d+)$/', $value, $matches) === 1) {
|
||||
$host = trim((string) $matches['host']);
|
||||
$port = trim((string) $matches['port']);
|
||||
} else {
|
||||
$separatorPosition = strrpos($value, ':');
|
||||
|
||||
if ($separatorPosition === false) {
|
||||
$fail('The :attribute must be in host:port format.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$host = trim(substr($value, 0, $separatorPosition));
|
||||
$port = trim(substr($value, $separatorPosition + 1));
|
||||
|
||||
if (str_contains($host, ':')) {
|
||||
$fail('The :attribute must use [ipv6]:port format for IPv6 addresses.');
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if ($host === '' || $port === '' || ! ctype_digit($port) || (int) $port < 1 || (int) $port > 65535) {
|
||||
$fail('The :attribute must be in host:port format.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$failed = false;
|
||||
(new ValidServerIp)->validate($attribute, $host, function () use (&$failed): void {
|
||||
$failed = true;
|
||||
});
|
||||
|
||||
if ($failed) {
|
||||
$fail('The :attribute must be in host:port format.');
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,14 @@ use App\Actions\Application\CleanupPreviewDeployment;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
|
||||
use App\Http\Controllers\Webhook\Concerns\MatchesManualWebhookApplications;
|
||||
use App\Livewire\Source\Gitlab\Change as GitlabSource;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationPreview;
|
||||
use App\Models\GitlabApp;
|
||||
use Exception;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class Gitlab extends Controller
|
||||
@@ -17,6 +21,307 @@ class Gitlab extends Controller
|
||||
use DetectsSkipDeployCommits;
|
||||
use MatchesManualWebhookApplications;
|
||||
|
||||
public function redirect(Request $request)
|
||||
{
|
||||
try {
|
||||
$code = $request->query('code');
|
||||
$state = $request->query('state');
|
||||
|
||||
if (! $code || ! $state) {
|
||||
return redirect()->route('source.all')->with('error', 'Invalid GitLab OAuth callback. Missing code or state.');
|
||||
}
|
||||
|
||||
// Validate the one-time, team-bound state (not a guessable source UUID) to stop forged callbacks from overwriting a source's tokens.
|
||||
$payload = Cache::pull(GitlabSource::oauthStateCacheKey($state));
|
||||
$team_id = $request->user()?->currentTeam()?->id;
|
||||
if (! is_array($payload) || is_null($team_id) || (int) data_get($payload, 'team_id') !== (int) $team_id) {
|
||||
return redirect()->route('source.all')->with('error', 'Invalid or expired GitLab OAuth state. Please start the authorization again.');
|
||||
}
|
||||
|
||||
$gitlabApp = GitlabApp::whereKey(data_get($payload, 'gitlab_app_id'))->firstOrFail();
|
||||
|
||||
// Only users who may administer the source can complete OAuth and store tokens.
|
||||
if (! $request->user()->can('update', $gitlabApp)) {
|
||||
return redirect()->route('source.all')->with('error', 'You are not authorized to connect this GitLab App.');
|
||||
}
|
||||
|
||||
$baseUrl = rtrim($gitlabApp->html_url, '/');
|
||||
|
||||
$response = Http::asForm()->post("{$baseUrl}/oauth/token", [
|
||||
'client_id' => $gitlabApp->client_id,
|
||||
'client_secret' => $gitlabApp->client_secret,
|
||||
'code' => $code,
|
||||
'grant_type' => 'authorization_code',
|
||||
'redirect_uri' => $gitlabApp->redirect_uri,
|
||||
]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
$error = data_get($response->json(), 'error_description', 'Token exchange failed');
|
||||
|
||||
return redirect()->route('source.gitlab.show', ['gitlab_app_uuid' => $gitlabApp->uuid])
|
||||
->with('error', "GitLab OAuth failed: {$error}");
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
$gitlabApp->update([
|
||||
'access_token' => $data['access_token'],
|
||||
'refresh_token' => $data['refresh_token'],
|
||||
'expires_at' => time() + ($data['expires_in'] ?? 7200),
|
||||
]);
|
||||
|
||||
return redirect()->route('source.gitlab.show', ['gitlab_app_uuid' => $gitlabApp->uuid]);
|
||||
} catch (Exception $e) {
|
||||
return redirect()->route('source.all')->with('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function normal(Request $request)
|
||||
{
|
||||
try {
|
||||
$return_payloads = collect([]);
|
||||
$payload = $request->collect();
|
||||
$x_gitlab_token = $request->header('X-Gitlab-Token');
|
||||
$object_kind = data_get($payload, 'object_kind');
|
||||
$project_id = data_get($payload, 'project.id');
|
||||
|
||||
$allowed_events = ['push', 'merge_request'];
|
||||
if (! in_array($object_kind, $allowed_events)) {
|
||||
return response([
|
||||
'status' => 'failed',
|
||||
'message' => 'Event not allowed. Only push and merge_request events are allowed.',
|
||||
]);
|
||||
}
|
||||
|
||||
if (empty($x_gitlab_token)) {
|
||||
auditLogWebhookFailure('gitlab', 'webhook_token_missing', [
|
||||
'event' => $object_kind,
|
||||
]);
|
||||
|
||||
return response([
|
||||
'status' => 'failed',
|
||||
'message' => 'Missing X-Gitlab-Token header.',
|
||||
], 401);
|
||||
}
|
||||
|
||||
$gitlab_app = GitlabApp::findByWebhookToken($x_gitlab_token);
|
||||
if (! $gitlab_app) {
|
||||
auditLogWebhookFailure('gitlab', 'invalid_token', [
|
||||
'event' => $object_kind,
|
||||
]);
|
||||
|
||||
return response([
|
||||
'status' => 'failed',
|
||||
'message' => 'Invalid webhook token.',
|
||||
], 401);
|
||||
}
|
||||
|
||||
$applications = Application::where('source_id', $gitlab_app->id)
|
||||
->where('source_type', GitlabApp::class)
|
||||
->where('repository_project_id', $project_id);
|
||||
|
||||
if ($object_kind === 'push') {
|
||||
$branch = data_get($payload, 'ref');
|
||||
if (Str::isMatch('/refs\/heads\/*/', $branch)) {
|
||||
$branch = Str::after($branch, 'refs/heads/');
|
||||
}
|
||||
if (! $branch) {
|
||||
return response([
|
||||
'status' => 'failed',
|
||||
'message' => 'No branch found in the request.',
|
||||
]);
|
||||
}
|
||||
|
||||
$applications = $applications->where('git_branch', $branch)->get();
|
||||
$added_files = data_get($payload, 'commits.*.added');
|
||||
$removed_files = data_get($payload, 'commits.*.removed');
|
||||
$modified_files = data_get($payload, 'commits.*.modified');
|
||||
$changed_files = collect($added_files)->concat($removed_files)->concat($modified_files)->unique()->flatten();
|
||||
$skip_deploy_commits = self::shouldSkipDeploy(data_get($payload, 'commits.*.message', []));
|
||||
|
||||
foreach ($applications as $application) {
|
||||
if (! $application->destination->server->isFunctional()) {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'failed',
|
||||
'message' => 'Server is not functional',
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $application->isDeployable()) {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'failed',
|
||||
'message' => 'Deployments disabled',
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files);
|
||||
if (! $is_watch_path_triggered && ! blank($application->watch_paths)) {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'failed',
|
||||
'message' => 'Changed files do not match watch paths.',
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($skip_deploy_commits) {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'skipped',
|
||||
'message' => 'All commits contain [skip cd] or [skip ci].',
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$deployment_uuid = new Cuid2;
|
||||
$result = queue_application_deployment(
|
||||
application: $application,
|
||||
deployment_uuid: $deployment_uuid,
|
||||
commit: data_get($payload, 'after', 'HEAD'),
|
||||
force_rebuild: false,
|
||||
is_webhook: true,
|
||||
);
|
||||
|
||||
if ($result['status'] === 'queue_full') {
|
||||
return response($result['message'], 429)->header('Retry-After', 60);
|
||||
}
|
||||
|
||||
auditLog('webhook.deployment.queued', [
|
||||
'provider' => 'gitlab',
|
||||
'mode' => 'app',
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
'deployment_uuid' => $deployment_uuid->toString(),
|
||||
'commit' => data_get($payload, 'after'),
|
||||
]);
|
||||
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => $result['status'] ?? 'success',
|
||||
'message' => $result['message'] ?? 'Deployment queued.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($object_kind === 'merge_request') {
|
||||
$action = data_get($payload, 'object_attributes.action');
|
||||
$branch = data_get($payload, 'object_attributes.source_branch');
|
||||
$base_branch = data_get($payload, 'object_attributes.target_branch');
|
||||
$pull_request_id = data_get($payload, 'object_attributes.iid');
|
||||
$pull_request_html_url = data_get($payload, 'object_attributes.url');
|
||||
$pull_request_title = data_get($payload, 'object_attributes.title');
|
||||
$latest_commit_message = data_get($payload, 'object_attributes.last_commit.message');
|
||||
$skip_deploy_pr = self::shouldSkipDeployAny([$pull_request_title, $latest_commit_message]);
|
||||
|
||||
$applications = $applications->where('git_branch', $base_branch)->get();
|
||||
|
||||
foreach ($applications as $application) {
|
||||
if (! $application->destination->server->isFunctional()) {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'failed',
|
||||
'message' => 'Server is not functional',
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (in_array($action, ['open', 'opened', 'synchronize', 'reopened', 'reopen', 'update'])) {
|
||||
if (! $application->isPRDeployable()) {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'failed',
|
||||
'message' => 'Preview deployments disabled',
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($skip_deploy_pr) {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'skipped',
|
||||
'message' => 'PR title or latest commit contains [skip cd] or [skip ci].',
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$deployment_uuid = new Cuid2;
|
||||
$found = ApplicationPreview::where('application_id', $application->id)
|
||||
->where('pull_request_id', $pull_request_id)
|
||||
->first();
|
||||
|
||||
if (! $found) {
|
||||
if ($application->build_pack === 'dockercompose') {
|
||||
$pr_app = ApplicationPreview::create([
|
||||
'git_type' => 'gitlab',
|
||||
'application_id' => $application->id,
|
||||
'pull_request_id' => $pull_request_id,
|
||||
'pull_request_html_url' => $pull_request_html_url,
|
||||
'docker_compose_domains' => $application->docker_compose_domains,
|
||||
]);
|
||||
$pr_app->generate_preview_fqdn_compose();
|
||||
} else {
|
||||
$pr_app = ApplicationPreview::create([
|
||||
'git_type' => 'gitlab',
|
||||
'application_id' => $application->id,
|
||||
'pull_request_id' => $pull_request_id,
|
||||
'pull_request_html_url' => $pull_request_html_url,
|
||||
]);
|
||||
$pr_app->generate_preview_fqdn();
|
||||
}
|
||||
}
|
||||
|
||||
$result = queue_application_deployment(
|
||||
application: $application,
|
||||
pull_request_id: $pull_request_id,
|
||||
deployment_uuid: $deployment_uuid,
|
||||
commit: data_get($payload, 'object_attributes.last_commit.id', 'HEAD'),
|
||||
force_rebuild: false,
|
||||
is_webhook: true,
|
||||
git_type: 'gitlab',
|
||||
);
|
||||
|
||||
if ($result['status'] === 'queue_full') {
|
||||
return response($result['message'], 429)->header('Retry-After', 60);
|
||||
}
|
||||
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => $result['status'] ?? 'success',
|
||||
'message' => $result['message'] ?? 'Preview Deployment queued',
|
||||
]);
|
||||
} elseif (in_array($action, ['closed', 'close', 'merge'])) {
|
||||
$found = ApplicationPreview::where('application_id', $application->id)
|
||||
->where('pull_request_id', $pull_request_id)
|
||||
->first();
|
||||
|
||||
if ($found) {
|
||||
CleanupPreviewDeployment::run($application, $pull_request_id, $found);
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'success',
|
||||
'message' => 'Preview deployment closed.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return response($return_payloads);
|
||||
} catch (Exception $e) {
|
||||
return handleError($e);
|
||||
}
|
||||
}
|
||||
|
||||
public function manual(Request $request)
|
||||
{
|
||||
try {
|
||||
@@ -291,8 +596,6 @@ class Gitlab extends Controller
|
||||
} elseif ($action === 'closed' || $action === 'close' || $action === 'merge') {
|
||||
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
|
||||
if ($found) {
|
||||
// Use comprehensive cleanup that cancels active deployments,
|
||||
// kills helper containers, and removes all PR containers
|
||||
CleanupPreviewDeployment::run($application, $pull_request_id, $found);
|
||||
|
||||
$return_payloads->push([
|
||||
|
||||
Reference in new issue
Block a user