From e0b6ac112127869d1cdcccffa9867e59ebd85dab Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:40:19 +0200 Subject: [PATCH] fix(api): accept settings that the UI can change but the API rejected - Applications: is_container_label_readonly_enabled (turning it back on regenerates labels), include_coolify_default_variables, and dockerfile_target_build on create. - Databases: save is_log_drain_enabled on update instead of dropping it, include_coolify_default_variables, and disable_local_backup on backups (only with save_s3). - Services: include_coolify_default_variables, and redirect on service applications (www/non-www needs both hosts). - Servers: wildcard_domain, server_timezone, backup_compression_cpu_percentage, and the proxy error page. Closes #11092 --- .../UpdateServiceApplicationFromApi.php | 20 ++ .../Api/ApplicationsController.php | 33 +- .../Controllers/Api/DatabasesController.php | 49 ++- .../Controllers/Api/ServerProxyController.php | 13 +- .../Controllers/Api/ServersController.php | 17 +- .../Api/ServiceApplicationsController.php | 5 + .../Controllers/Api/ServicesController.php | 9 +- bootstrap/helpers/api.php | 4 + openapi.json | 155 ++++++++++ openapi.yaml | 102 ++++++ .../Api/ApplicationSettingsApiTest.php | 75 +++++ tests/Feature/Api/UiSettingsApiParityTest.php | 291 ++++++++++++++++++ 12 files changed, 760 insertions(+), 13 deletions(-) create mode 100644 tests/Feature/Api/UiSettingsApiParityTest.php diff --git a/app/Actions/Service/UpdateServiceApplicationFromApi.php b/app/Actions/Service/UpdateServiceApplicationFromApi.php index 9e7657984e..dafbc80f30 100644 --- a/app/Actions/Service/UpdateServiceApplicationFromApi.php +++ b/app/Actions/Service/UpdateServiceApplicationFromApi.php @@ -67,6 +67,16 @@ class UpdateServiceApplicationFromApi $serviceApplication->setNoindexDomains($payload['noindex_domains'] ?? []); } + if (array_key_exists('redirect', $payload)) { + if (in_array($payload['redirect'], ['www', 'non-www'], true) && ! $this->hasWwwAndNonWwwDomains($serviceApplication->fqdn)) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['redirect' => ['The www and non-www redirects need both a www and a non-www domain in url.']], + ], 422); + } + $serviceApplication->redirect = $payload['redirect']; + } + if (array_key_exists('human_name', $payload)) { $serviceApplication->human_name = $payload['human_name']; } @@ -130,4 +140,14 @@ class UpdateServiceApplicationFromApi return null; } + + private function hasWwwAndNonWwwDomains(?string $fqdn): bool + { + $hosts = str($fqdn ?? '')->explode(',') + ->map(fn (string $url) => strtolower((string) parse_url(trim($url), PHP_URL_HOST))) + ->filter(); + + return $hosts->contains(fn (string $host) => str_starts_with($host, 'www.')) + && $hosts->contains(fn (string $host) => ! str_starts_with($host, 'www.')); + } } diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index de01074b5a..4baeefd913 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -53,6 +53,7 @@ class ApplicationsController extends Controller 'include_source_commit_in_build', 'is_env_sorting_enabled', 'redact_all_env_values_in_logs', + 'include_coolify_default_variables', 'is_pr_deployments_public_enabled', 'stop_grace_period', 'docker_images_to_keep', @@ -60,6 +61,7 @@ class ApplicationsController extends Controller 'is_stripprefix_enabled', 'is_permanent_redirect_enabled', 'traefik_certificate_resolver', + 'is_container_label_readonly_enabled', 'is_raw_compose_deployment_enabled', 'is_log_drain_enabled', 'is_gpu_enabled', @@ -81,10 +83,12 @@ class ApplicationsController extends Controller 'include_source_commit_in_build', 'is_env_sorting_enabled', 'redact_all_env_values_in_logs', + 'include_coolify_default_variables', 'is_pr_deployments_public_enabled', 'is_gzip_enabled', 'is_stripprefix_enabled', 'is_permanent_redirect_enabled', + 'is_container_label_readonly_enabled', 'is_raw_compose_deployment_enabled', 'is_log_drain_enabled', 'is_gpu_enabled', @@ -231,15 +235,18 @@ class ApplicationsController extends Controller return; } + $wasLabelManagementEnabled = $application->settings->is_container_label_readonly_enabled; + + $application->settings->fill($settings)->save(); + $regenerateLabels = ! $application->wasRecentlyCreated && $application->settings->is_container_label_readonly_enabled - && (array_key_exists('is_gzip_enabled', $settings) + && (! $wasLabelManagementEnabled + || array_key_exists('is_gzip_enabled', $settings) || array_key_exists('is_stripprefix_enabled', $settings) || array_key_exists('is_permanent_redirect_enabled', $settings) || array_key_exists('traefik_certificate_resolver', $settings)); - $application->settings->fill($settings)->save(); - if ($regenerateLabels) { $application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n"); $application->save(); @@ -431,6 +438,7 @@ class ApplicationsController extends Controller 'instant_deploy' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application should be deployed instantly.'], 'dockerfile' => ['type' => 'string', 'description' => 'The Dockerfile content.'], 'dockerfile_location' => ['type' => 'string', 'description' => 'The Dockerfile location in the repository.'], + 'dockerfile_target_build' => ['type' => 'string', 'nullable' => true, 'description' => 'The Docker build target (stage) in the Dockerfile.'], 'docker_compose_location' => ['type' => 'string', 'description' => 'The Docker Compose location.'], 'docker_compose_custom_start_command' => ['type' => 'string', 'description' => 'The Docker Compose custom start command.'], 'docker_compose_custom_build_command' => ['type' => 'string', 'description' => 'The Docker Compose custom build command.'], @@ -457,6 +465,7 @@ class ApplicationsController extends Controller 'include_source_commit_in_build' => ['type' => 'boolean', 'description' => 'Include the source commit in the build.'], 'is_env_sorting_enabled' => ['type' => 'boolean', 'description' => 'Sort environment variables.'], 'redact_all_env_values_in_logs' => ['type' => 'boolean', 'description' => 'Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions.'], + 'include_coolify_default_variables' => ['type' => 'boolean', 'default' => true, 'description' => 'Add the predefined COOLIFY_* variables (COOLIFY_URL, COOLIFY_FQDN, ...) to the environment. Requires team admin permissions. Redeploy to apply it.'], 'is_pr_deployments_public_enabled' => ['type' => 'boolean', 'description' => 'Make pull request deployments public.'], 'stop_grace_period' => ['type' => 'integer', 'nullable' => true, 'minimum' => 1, 'maximum' => 3600, 'description' => 'Container stop grace period in seconds.'], 'docker_images_to_keep' => ['type' => 'integer', 'minimum' => 0, 'maximum' => 100, 'description' => 'Number of Docker images to retain.'], @@ -464,6 +473,7 @@ class ApplicationsController extends Controller 'is_stripprefix_enabled' => ['type' => 'boolean', 'description' => 'Enable path prefix stripping.'], 'is_permanent_redirect_enabled' => ['type' => 'boolean', 'description' => 'Use permanent redirects (301/308) instead of temporary (302/307) for the HTTP to HTTPS and www redirects. Defaults to false.'], 'traefik_certificate_resolver' => ['type' => 'string', 'nullable' => true, 'description' => 'Traefik certificate resolver for this application. Null uses the server setting, "none" uses no resolver.'], + 'is_container_label_readonly_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Let Coolify manage the container labels. True regenerates custom_labels from domains, redirect, and proxy settings. False keeps custom_labels as you set them: domains, redirect, and basic auth changes then do not change the labels.'], '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.'], @@ -637,6 +647,7 @@ class ApplicationsController extends Controller 'instant_deploy' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application should be deployed instantly.'], 'dockerfile' => ['type' => 'string', 'description' => 'The Dockerfile content.'], 'dockerfile_location' => ['type' => 'string', 'description' => 'The Dockerfile location in the repository'], + 'dockerfile_target_build' => ['type' => 'string', 'nullable' => true, 'description' => 'The Docker build target (stage) in the Dockerfile.'], 'docker_compose_location' => ['type' => 'string', 'description' => 'The Docker Compose location.'], 'docker_compose_custom_start_command' => ['type' => 'string', 'description' => 'The Docker Compose custom start command.'], 'docker_compose_custom_build_command' => ['type' => 'string', 'description' => 'The Docker Compose custom build command.'], @@ -663,6 +674,7 @@ class ApplicationsController extends Controller 'include_source_commit_in_build' => ['type' => 'boolean', 'description' => 'Include the source commit in the build.'], 'is_env_sorting_enabled' => ['type' => 'boolean', 'description' => 'Sort environment variables.'], 'redact_all_env_values_in_logs' => ['type' => 'boolean', 'description' => 'Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions.'], + 'include_coolify_default_variables' => ['type' => 'boolean', 'default' => true, 'description' => 'Add the predefined COOLIFY_* variables (COOLIFY_URL, COOLIFY_FQDN, ...) to the environment. Requires team admin permissions. Redeploy to apply it.'], 'is_pr_deployments_public_enabled' => ['type' => 'boolean', 'description' => 'Make pull request deployments public.'], 'stop_grace_period' => ['type' => 'integer', 'nullable' => true, 'minimum' => 1, 'maximum' => 3600, 'description' => 'Container stop grace period in seconds.'], 'docker_images_to_keep' => ['type' => 'integer', 'minimum' => 0, 'maximum' => 100, 'description' => 'Number of Docker images to retain.'], @@ -670,6 +682,7 @@ class ApplicationsController extends Controller 'is_stripprefix_enabled' => ['type' => 'boolean', 'description' => 'Enable path prefix stripping.'], 'is_permanent_redirect_enabled' => ['type' => 'boolean', 'description' => 'Use permanent redirects (301/308) instead of temporary (302/307) for the HTTP to HTTPS and www redirects. Defaults to false.'], 'traefik_certificate_resolver' => ['type' => 'string', 'nullable' => true, 'description' => 'Traefik certificate resolver for this application. Null uses the server setting, "none" uses no resolver.'], + 'is_container_label_readonly_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Let Coolify manage the container labels. True regenerates custom_labels from domains, redirect, and proxy settings. False keeps custom_labels as you set them: domains, redirect, and basic auth changes then do not change the labels.'], '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.'], @@ -843,6 +856,7 @@ class ApplicationsController extends Controller 'instant_deploy' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application should be deployed instantly.'], 'dockerfile' => ['type' => 'string', 'description' => 'The Dockerfile content.'], 'dockerfile_location' => ['type' => 'string', 'description' => 'The Dockerfile location in the repository.'], + 'dockerfile_target_build' => ['type' => 'string', 'nullable' => true, 'description' => 'The Docker build target (stage) in the Dockerfile.'], 'docker_compose_location' => ['type' => 'string', 'description' => 'The Docker Compose location.'], 'docker_compose_custom_start_command' => ['type' => 'string', 'description' => 'The Docker Compose custom start command.'], 'docker_compose_custom_build_command' => ['type' => 'string', 'description' => 'The Docker Compose custom build command.'], @@ -869,6 +883,7 @@ class ApplicationsController extends Controller 'include_source_commit_in_build' => ['type' => 'boolean', 'description' => 'Include the source commit in the build.'], 'is_env_sorting_enabled' => ['type' => 'boolean', 'description' => 'Sort environment variables.'], 'redact_all_env_values_in_logs' => ['type' => 'boolean', 'description' => 'Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions.'], + 'include_coolify_default_variables' => ['type' => 'boolean', 'default' => true, 'description' => 'Add the predefined COOLIFY_* variables (COOLIFY_URL, COOLIFY_FQDN, ...) to the environment. Requires team admin permissions. Redeploy to apply it.'], 'is_pr_deployments_public_enabled' => ['type' => 'boolean', 'description' => 'Make pull request deployments public.'], 'stop_grace_period' => ['type' => 'integer', 'nullable' => true, 'minimum' => 1, 'maximum' => 3600, 'description' => 'Container stop grace period in seconds.'], 'docker_images_to_keep' => ['type' => 'integer', 'minimum' => 0, 'maximum' => 100, 'description' => 'Number of Docker images to retain.'], @@ -876,6 +891,7 @@ class ApplicationsController extends Controller 'is_stripprefix_enabled' => ['type' => 'boolean', 'description' => 'Enable path prefix stripping.'], 'is_permanent_redirect_enabled' => ['type' => 'boolean', 'description' => 'Use permanent redirects (301/308) instead of temporary (302/307) for the HTTP to HTTPS and www redirects. Defaults to false.'], 'traefik_certificate_resolver' => ['type' => 'string', 'nullable' => true, 'description' => 'Traefik certificate resolver for this application. Null uses the server setting, "none" uses no resolver.'], + 'is_container_label_readonly_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Let Coolify manage the container labels. True regenerates custom_labels from domains, redirect, and proxy settings. False keeps custom_labels as you set them: domains, redirect, and basic auth changes then do not change the labels.'], '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.'], @@ -1046,6 +1062,7 @@ class ApplicationsController extends Controller 'include_source_commit_in_build' => ['type' => 'boolean', 'description' => 'Include the source commit in the build.'], 'is_env_sorting_enabled' => ['type' => 'boolean', 'description' => 'Sort environment variables.'], 'redact_all_env_values_in_logs' => ['type' => 'boolean', 'description' => 'Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions.'], + 'include_coolify_default_variables' => ['type' => 'boolean', 'default' => true, 'description' => 'Add the predefined COOLIFY_* variables (COOLIFY_URL, COOLIFY_FQDN, ...) to the environment. Requires team admin permissions. Redeploy to apply it.'], 'is_pr_deployments_public_enabled' => ['type' => 'boolean', 'description' => 'Make pull request deployments public.'], 'stop_grace_period' => ['type' => 'integer', 'nullable' => true, 'minimum' => 1, 'maximum' => 3600, 'description' => 'Container stop grace period in seconds.'], 'docker_images_to_keep' => ['type' => 'integer', 'minimum' => 0, 'maximum' => 100, 'description' => 'Number of Docker images to retain.'], @@ -1053,6 +1070,7 @@ class ApplicationsController extends Controller 'is_stripprefix_enabled' => ['type' => 'boolean', 'description' => 'Enable path prefix stripping.'], 'is_permanent_redirect_enabled' => ['type' => 'boolean', 'description' => 'Use permanent redirects (301/308) instead of temporary (302/307) for the HTTP to HTTPS and www redirects. Defaults to false.'], 'traefik_certificate_resolver' => ['type' => 'string', 'nullable' => true, 'description' => 'Traefik certificate resolver for this application. Null uses the server setting, "none" uses no resolver.'], + 'is_container_label_readonly_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Let Coolify manage the container labels. True regenerates custom_labels from domains, redirect, and proxy settings. False keeps custom_labels as you set them: domains, redirect, and basic auth changes then do not change the labels.'], '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.'], @@ -1219,6 +1237,7 @@ class ApplicationsController extends Controller 'include_source_commit_in_build' => ['type' => 'boolean', 'description' => 'Include the source commit in the build.'], 'is_env_sorting_enabled' => ['type' => 'boolean', 'description' => 'Sort environment variables.'], 'redact_all_env_values_in_logs' => ['type' => 'boolean', 'description' => 'Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions.'], + 'include_coolify_default_variables' => ['type' => 'boolean', 'default' => true, 'description' => 'Add the predefined COOLIFY_* variables (COOLIFY_URL, COOLIFY_FQDN, ...) to the environment. Requires team admin permissions. Redeploy to apply it.'], 'is_pr_deployments_public_enabled' => ['type' => 'boolean', 'description' => 'Make pull request deployments public.'], 'stop_grace_period' => ['type' => 'integer', 'nullable' => true, 'minimum' => 1, 'maximum' => 3600, 'description' => 'Container stop grace period in seconds.'], 'docker_images_to_keep' => ['type' => 'integer', 'minimum' => 0, 'maximum' => 100, 'description' => 'Number of Docker images to retain.'], @@ -1226,6 +1245,7 @@ class ApplicationsController extends Controller 'is_stripprefix_enabled' => ['type' => 'boolean', 'description' => 'Enable path prefix stripping.'], 'is_permanent_redirect_enabled' => ['type' => 'boolean', 'description' => 'Use permanent redirects (301/308) instead of temporary (302/307) for the HTTP to HTTPS and www redirects. Defaults to false.'], 'traefik_certificate_resolver' => ['type' => 'string', 'nullable' => true, 'description' => 'Traefik certificate resolver for this application. Null uses the server setting, "none" uses no resolver.'], + 'is_container_label_readonly_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Let Coolify manage the container labels. True regenerates custom_labels from domains, redirect, and proxy settings. False keeps custom_labels as you set them: domains, redirect, and basic auth changes then do not change the labels.'], '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.'], @@ -1337,7 +1357,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', 'http_basic_auth_hash_algorithm', 'http_basic_auth_bcrypt_cost', 'http_basic_auth_argon2id_memory_cost', 'http_basic_auth_argon2id_time_cost', '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]; + $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', 'dockerfile_target_build', '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', 'http_basic_auth_hash_algorithm', 'http_basic_auth_bcrypt_cost', 'http_basic_auth_argon2id_memory_cost', 'http_basic_auth_argon2id_time_cost', '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', @@ -3207,6 +3227,7 @@ class ApplicationsController extends Controller 'instant_deploy' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application should be deployed instantly.'], 'dockerfile' => ['type' => 'string', 'description' => 'The Dockerfile content.'], 'dockerfile_location' => ['type' => 'string', 'description' => 'The Dockerfile location in the repository.'], + 'dockerfile_target_build' => ['type' => 'string', 'nullable' => true, 'description' => 'The Docker build target (stage) in the Dockerfile.'], 'docker_compose_location' => ['type' => 'string', 'description' => 'The Docker Compose location.'], 'docker_compose_custom_start_command' => ['type' => 'string', 'description' => 'The Docker Compose custom start command.'], 'docker_compose_custom_build_command' => ['type' => 'string', 'description' => 'The Docker Compose custom build command.'], @@ -3237,6 +3258,7 @@ class ApplicationsController extends Controller 'include_source_commit_in_build' => ['type' => 'boolean', 'description' => 'Include the source commit in the build.'], 'is_env_sorting_enabled' => ['type' => 'boolean', 'description' => 'Sort environment variables.'], 'redact_all_env_values_in_logs' => ['type' => 'boolean', 'description' => 'Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions.'], + 'include_coolify_default_variables' => ['type' => 'boolean', 'default' => true, 'description' => 'Add the predefined COOLIFY_* variables (COOLIFY_URL, COOLIFY_FQDN, ...) to the environment. Requires team admin permissions. Redeploy to apply it.'], 'is_pr_deployments_public_enabled' => ['type' => 'boolean', 'description' => 'Make pull request deployments public.'], 'stop_grace_period' => ['type' => 'integer', 'nullable' => true, 'minimum' => 1, 'maximum' => 3600, 'description' => 'Container stop grace period in seconds.'], 'docker_images_to_keep' => ['type' => 'integer', 'minimum' => 0, 'maximum' => 100, 'description' => 'Number of Docker images to retain.'], @@ -3244,6 +3266,7 @@ class ApplicationsController extends Controller 'is_stripprefix_enabled' => ['type' => 'boolean', 'description' => 'Enable path prefix stripping.'], 'is_permanent_redirect_enabled' => ['type' => 'boolean', 'description' => 'Use permanent redirects (301/308) instead of temporary (302/307) for the HTTP to HTTPS and www redirects. Defaults to false.'], 'traefik_certificate_resolver' => ['type' => 'string', 'nullable' => true, 'description' => 'Traefik certificate resolver for this application. Null uses the server setting, "none" uses no resolver.'], + 'is_container_label_readonly_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Let Coolify manage the container labels. True regenerates custom_labels from domains, redirect, and proxy settings. False keeps custom_labels as you set them: domains, redirect, and basic auth changes then do not change the labels.'], '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.'], @@ -3348,7 +3371,7 @@ class ApplicationsController extends Controller $this->authorize('update', $application); - if ($request->has('redact_all_env_values_in_logs')) { + if ($request->has('redact_all_env_values_in_logs') || $request->has('include_coolify_default_variables')) { $this->authorize('manageEnvironment', $application); } diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index b00d5e7dce..5a0c4c526c 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -594,6 +594,8 @@ class DatabasesController extends Controller 'health_check_retries' => ['type' => 'integer', 'description' => 'Healthcheck retries count.', 'minimum' => 1, 'default' => 5], 'health_check_start_period' => ['type' => 'integer', 'description' => 'Healthcheck start period in seconds.', 'minimum' => 0, 'default' => 5], 'redact_all_env_values_in_logs' => ['type' => 'boolean', 'description' => 'Redact all environment variable values in logs. When false, only locked (shown once) values, database passwords, and remote secrets are redacted. Requires team admin permissions.'], + 'include_coolify_default_variables' => ['type' => 'boolean', 'description' => 'Add the predefined COOLIFY_* variables (COOLIFY_URL, COOLIFY_FQDN, ...) to the environment. Requires team admin permissions. Restart the database to apply it.'], + 'is_log_drain_enabled' => ['type' => 'boolean', 'description' => 'Send the database logs to the log drain of the server. Log drain must be enabled on the server. Restart the database to apply it.'], ], ), ) @@ -654,6 +656,8 @@ class DatabasesController extends Controller 'limits_cpu_shares' => 'numeric', 'instant_deploy' => 'boolean', 'redact_all_env_values_in_logs' => 'boolean', + 'include_coolify_default_variables' => 'boolean', + 'is_log_drain_enabled' => 'boolean', ]); if ($validator->fails()) { @@ -665,6 +669,8 @@ class DatabasesController extends Controller $uuid = $request->uuid; $instantDeploy = $request->boolean('instant_deploy'); $redactAllEnvValuesInLogs = $request->has('redact_all_env_values_in_logs') ? $request->boolean('redact_all_env_values_in_logs') : null; + $isLogDrainEnabled = $request->has('is_log_drain_enabled') ? $request->boolean('is_log_drain_enabled') : null; + $includeCoolifyDefaultVariables = $request->has('include_coolify_default_variables') ? $request->boolean('include_coolify_default_variables') : null; removeUnnecessaryFieldsFromRequest($request); $database = queryDatabaseByUuidWithinTeam($uuid, $teamId); if (! $database) { @@ -678,10 +684,17 @@ class DatabasesController extends Controller $this->authorize('manage', $database); } - if ($redactAllEnvValuesInLogs !== null) { + if ($redactAllEnvValuesInLogs !== null || $includeCoolifyDefaultVariables !== null) { $this->authorize('manageEnvironment', $database); } + if ($isLogDrainEnabled && ! $database->destination->server->isLogDrainEnabled()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['is_log_drain_enabled' => ['Log drain is not enabled on the server. Enable it on the server first.']], + ], 422); + } + if ($request->has('is_public') && (bool) $request->is_public !== (bool) $database->is_public) { $forbidden = $this->deployAbilityForbiddenResponse($request, 'Making a database public or private starts or stops its proxy on the server, so it needs a token with the deploy permission.'); if ($forbidden) { @@ -935,6 +948,12 @@ class DatabasesController extends Controller if ($redactAllEnvValuesInLogs !== null) { $database->redact_all_env_values_in_logs = $redactAllEnvValuesInLogs; } + if ($isLogDrainEnabled !== null) { + $database->is_log_drain_enabled = $isLogDrainEnabled; + } + if ($includeCoolifyDefaultVariables !== null) { + $database->include_coolify_default_variables = $includeCoolifyDefaultVariables; + } $database->update($request->only($allowedFields)); // The Redis password is stored in the REDIS_PASSWORD variable, not in a column. if ($database instanceof StandaloneRedis && filled($request->redis_password)) { @@ -961,6 +980,8 @@ class DatabasesController extends Controller 'changed_fields' => [ ...array_values(array_intersect($allowedFields, array_keys($request->all()))), ...($redactAllEnvValuesInLogs === null ? [] : ['redact_all_env_values_in_logs']), + ...($isLogDrainEnabled === null ? [] : ['is_log_drain_enabled']), + ...($includeCoolifyDefaultVariables === null ? [] : ['include_coolify_default_variables']), ], ]); @@ -969,6 +990,14 @@ class DatabasesController extends Controller ]); } + private function disableLocalBackupWithoutS3Response(): JsonResponse + { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['disable_local_backup' => ['Local backups can only be disabled when save_s3 is true.']], + ], 422); + } + #[OA\Post( summary: 'Create Backup', description: 'Create a new scheduled backup configuration for a database', @@ -1001,6 +1030,7 @@ class DatabasesController extends Controller 'frequency' => ['type' => 'string', 'description' => 'Backup frequency (cron expression or: every_minute, hourly, daily, weekly, monthly, yearly)'], 'enabled' => ['type' => 'boolean', 'description' => 'Whether the backup is enabled', 'default' => true], 'save_s3' => ['type' => 'boolean', 'description' => 'Whether to save backups to S3', 'default' => false], + 'disable_local_backup' => ['type' => 'boolean', 'description' => 'Delete the local backup file after the upload to S3. Only allowed when save_s3 is true.', 'default' => false], 's3_storage_uuid' => ['type' => 'string', 'description' => 'Primary S3 storage UUID. Sent without s3_storage_uuids, it selects this storage as the only destination. Either field is required if save_s3 is true.'], 's3_storage_uuids' => ['type' => 'array', 'items' => ['type' => 'string'], 'description' => 'UUIDs of every S3 storage the backup is uploaded to. Replaces the current destinations. When s3_storage_uuid is also sent, it must be in this list and becomes the primary destination; otherwise the first entry is the primary destination.'], 'databases_to_backup' => ['type' => 'string', 'description' => 'Comma separated list of databases to backup'], @@ -1054,7 +1084,7 @@ class DatabasesController extends Controller )] public function create_backup(Request $request) { - $backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid', 's3_storage_uuids', 'timeout', 'missing_backup_notification_days']; + $backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid', 's3_storage_uuids', 'timeout', 'missing_backup_notification_days', 'disable_local_backup']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -1071,6 +1101,7 @@ class DatabasesController extends Controller 'frequency' => 'required|string', 'enabled' => 'boolean', 'save_s3' => 'boolean', + 'disable_local_backup' => 'boolean', 'dump_all' => 'boolean', 'backup_now' => 'boolean|nullable', 's3_storage_uuid' => 'string|exists:s3_storages,uuid|nullable', @@ -1138,6 +1169,9 @@ class DatabasesController extends Controller 'errors' => ['s3_storage_uuid' => ['The s3_storage_uuid or s3_storage_uuids field is required when save_s3 is true.']], ], 422); } + if ($request->boolean('disable_local_backup') && ! $request->boolean('save_s3')) { + return $this->disableLocalBackupWithoutS3Response(); + } // Check for extra fields $extraFields = array_diff(array_keys($request->all()), $backupConfigFields, ['backup_now']); @@ -1256,6 +1290,7 @@ class DatabasesController extends Controller type: 'object', properties: [ 'save_s3' => ['type' => 'boolean', 'description' => 'Whether data is saved in s3 or not'], + 'disable_local_backup' => ['type' => 'boolean', 'description' => 'Delete the local backup file after the upload to S3. Only allowed when save_s3 is true. Turning save_s3 off also turns this off.'], 's3_storage_uuid' => ['type' => 'string', 'description' => 'Primary S3 storage UUID. Sent without s3_storage_uuids, it selects this storage as the only destination; when it is already a destination, it becomes the primary destination and the other destinations stay.'], 's3_storage_uuids' => ['type' => 'array', 'items' => ['type' => 'string'], 'description' => 'UUIDs of every S3 storage the backup is uploaded to. Replaces the current destinations. When s3_storage_uuid is also sent, it must be in this list and becomes the primary destination; otherwise the first entry is the primary destination.'], 'backup_now' => ['type' => 'boolean', 'description' => 'Whether to take a backup now or not. Needs a token with the deploy permission.'], @@ -1304,7 +1339,7 @@ class DatabasesController extends Controller )] public function update_backup(Request $request) { - $backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid', 's3_storage_uuids', 'timeout', 'missing_backup_notification_days']; + $backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid', 's3_storage_uuids', 'timeout', 'missing_backup_notification_days', 'disable_local_backup']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -1317,6 +1352,7 @@ class DatabasesController extends Controller } $validator = customApiValidator($request->all(), [ 'save_s3' => 'boolean', + 'disable_local_backup' => 'boolean', 'backup_now' => 'boolean|nullable', 'enabled' => 'boolean', 'dump_all' => 'boolean', @@ -1409,11 +1445,18 @@ class DatabasesController extends Controller 'errors' => $errors, ], 422); } + if ($request->boolean('disable_local_backup') && ! $saveToS3) { + return $this->disableLocalBackupWithoutS3Response(); + } $backupData = Arr::except($request->only($backupConfigFields), ['s3_storage_uuid', 's3_storage_uuids']); if ($s3Storages !== null) { $backupData['s3_storage_id'] = $s3Storages->first()?->id; } + // Without S3 the backup must stay on the server, as in the UI. + if (! $saveToS3) { + $backupData['disable_local_backup'] = false; + } // Validate databases_to_backup input if (! empty($backupData['databases_to_backup'])) { diff --git a/app/Http/Controllers/Api/ServerProxyController.php b/app/Http/Controllers/Api/ServerProxyController.php index dc5f1c73b0..e7964f68e1 100644 --- a/app/Http/Controllers/Api/ServerProxyController.php +++ b/app/Http/Controllers/Api/ServerProxyController.php @@ -56,6 +56,7 @@ class ServerProxyController extends Controller 'status' => data_get($server->proxy, 'status'), 'redirect_enabled' => (bool) data_get($server->proxy, 'redirect_enabled', true), 'redirect_url' => data_get($server->proxy, 'redirect_url'), + 'proxy_error_page' => $server->proxy_error_page, 'generate_exact_labels' => (bool) ($server->settings->generate_exact_labels ?? false), 'traefik_certificate_resolver' => $server->traefikCertificateResolver(), ]; @@ -91,6 +92,7 @@ class ServerProxyController extends Controller 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: 'proxy_error_page', type: 'string', nullable: true), new OA\Property(property: 'generate_exact_labels', type: 'boolean', example: false), new OA\Property(property: 'traefik_certificate_resolver', type: 'string', nullable: true, example: 'letsencrypt', description: 'Traefik certificate resolver for generated HTTPS routers. Null when the proxy uses its own certificates.'), new OA\Property(property: 'configuration', type: 'string', nullable: true, description: 'Docker Compose proxy configuration when stored in the database. Only present with read:sensitive.'), @@ -136,6 +138,7 @@ class ServerProxyController extends Controller 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: 'proxy_error_page', type: 'string', nullable: true, description: 'Custom HTML error page that the proxy shows for unknown hosts (when redirect_url is empty) and for resources in maintenance mode. Null restores the default page.'), new OA\Property(property: 'generate_exact_labels', type: 'boolean'), new OA\Property(property: 'traefik_certificate_resolver', type: 'string', nullable: true, description: 'Traefik certificate resolver for generated HTTPS routers (letters, digits, _ and -). Null or empty to use your own certificates. Redeploy resources to apply.'), new OA\Property(property: 'proxy_type', type: 'string', enum: ['traefik', 'caddy', 'nginx', 'none'], description: 'Proxy type (case-insensitive). Changing it starts the new proxy on the server and needs the deploy permission.'), @@ -153,6 +156,7 @@ class ServerProxyController extends Controller 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: 'proxy_error_page', type: 'string', nullable: true), new OA\Property(property: 'generate_exact_labels', type: 'boolean'), new OA\Property(property: 'traefik_certificate_resolver', type: 'string', nullable: true), new OA\Property(property: 'configuration', type: 'string', nullable: true), @@ -178,10 +182,11 @@ class ServerProxyController extends Controller return $return; } - $allowedFields = ['redirect_enabled', 'redirect_url', 'generate_exact_labels', 'traefik_certificate_resolver', 'proxy_type']; + $allowedFields = ['redirect_enabled', 'redirect_url', 'proxy_error_page', 'generate_exact_labels', 'traefik_certificate_resolver', 'proxy_type']; $validator = customApiValidator($request->all(), [ 'redirect_enabled' => 'boolean', 'redirect_url' => ['nullable', 'string', new SafeExternalUrl], + 'proxy_error_page' => ['nullable', 'string', proxyPageSizeRule('custom error page')], 'generate_exact_labels' => 'boolean', 'traefik_certificate_resolver' => ['nullable', 'string', 'max:255', 'regex:'.ServerSetting::TRAEFIK_CERTIFICATE_RESOLVER_PATTERN], 'proxy_type' => 'string|nullable', @@ -236,6 +241,11 @@ class ServerProxyController extends Controller $redirectChanged = true; } + if ($request->exists('proxy_error_page')) { + $server->proxy_error_page = $request->input('proxy_error_page') ?: null; + $redirectChanged = true; + } + if ($redirectChanged) { $server->save(); } @@ -314,6 +324,7 @@ class ServerProxyController extends Controller 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: 'proxy_error_page', type: 'string', nullable: true), new OA\Property(property: 'generate_exact_labels', type: 'boolean'), new OA\Property(property: 'configuration', type: 'string', nullable: true), ] diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php index 557843f396..febd83a9ac 100644 --- a/app/Http/Controllers/Api/ServersController.php +++ b/app/Http/Controllers/Api/ServersController.php @@ -705,6 +705,9 @@ class ServersController extends Controller 'server_disk_usage_check_frequency' => ['type' => 'string', 'description' => 'Cron expression for disk usage check frequency.'], 'server_disk_usage_notification_interval_hours' => ['type' => 'integer', 'description' => 'Minimum hours between high disk usage notifications (1-720). Default: 24.'], 'connection_timeout' => ['type' => 'integer', 'description' => 'SSH connection timeout in seconds (1-300). Default: 10.'], + 'wildcard_domain' => ['type' => 'string', 'nullable' => true, 'description' => 'Wildcard domain (URL) used to generate domains for new resources on this server. Null removes it.'], + 'server_timezone' => ['type' => 'string', 'description' => 'Timezone of the server, for example Europe/Berlin. Scheduled tasks and backups use it.'], + 'backup_compression_cpu_percentage' => ['type' => 'integer', 'enum' => [25, 50, 75, 100], 'description' => 'Maximum CPU percentage that backup compression can use. Default: 25.'], ], ), ), @@ -742,7 +745,7 @@ class ServersController extends Controller )] public function update_server(Request $request) { - $allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'server_role', 'is_build_server', 'instant_validate', 'proxy_type', 'concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency', 'server_disk_usage_notification_interval_hours', 'connection_timeout', 'is_terminal_enabled']; + $allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'server_role', 'is_build_server', 'instant_validate', 'proxy_type', 'concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency', 'server_disk_usage_notification_interval_hours', 'connection_timeout', 'is_terminal_enabled', 'wildcard_domain', 'server_timezone', 'backup_compression_cpu_percentage']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -772,6 +775,13 @@ class ServersController extends Controller 'server_disk_usage_notification_interval_hours' => 'integer|min:1|max:720', 'connection_timeout' => 'integer|min:1|max:300', 'is_terminal_enabled' => 'boolean|nullable', + 'wildcard_domain' => 'nullable|url', + 'server_timezone' => ['string', function (string $attribute, mixed $value, \Closure $fail): void { + if (! validate_timezone($value)) { + $fail('The server_timezone must be a valid timezone.'); + } + }], + 'backup_compression_cpu_percentage' => 'integer|in:25,50,75,100', ], [ ...ValidationPatterns::serverUsernameMessages(), 'name.not_regex' => 'The name must not contain control characters such as new lines.', @@ -886,10 +896,13 @@ class ServersController extends Controller ]); } - $advancedSettings = $request->only(['concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency', 'server_disk_usage_notification_interval_hours', 'connection_timeout']); + $advancedSettings = $request->only(['concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency', 'server_disk_usage_notification_interval_hours', 'connection_timeout', 'server_timezone', 'backup_compression_cpu_percentage']); if (! empty($advancedSettings)) { $server->settings()->update(array_filter($advancedSettings, fn ($value) => ! is_null($value))); } + if ($request->has('wildcard_domain')) { + $server->settings()->update(['wildcard_domain' => $request->input('wildcard_domain')]); + } if ($proxyTypeChanged) { $server->changeProxy($request->proxy_type, async: true); diff --git a/app/Http/Controllers/Api/ServiceApplicationsController.php b/app/Http/Controllers/Api/ServiceApplicationsController.php index 14d8b97924..f6147855aa 100644 --- a/app/Http/Controllers/Api/ServiceApplicationsController.php +++ b/app/Http/Controllers/Api/ServiceApplicationsController.php @@ -6,6 +6,7 @@ use App\Actions\Service\DeployServiceApplication; use App\Actions\Service\RestartServiceApplication; use App\Actions\Service\StopServiceApplication; use App\Actions\Service\UpdateServiceApplicationFromApi; +use App\Enums\RedirectTypes; use App\Http\Controllers\Controller; use App\Models\EnvironmentVariable; use App\Models\ServerSetting; @@ -16,6 +17,7 @@ use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Validator; +use Illuminate\Validation\Rule; use OpenApi\Attributes as OA; class ServiceApplicationsController extends Controller @@ -263,6 +265,7 @@ class ServiceApplicationsController extends Controller 'is_stripprefix_enabled' => new OA\Property(property: 'is_stripprefix_enabled', type: 'boolean', nullable: true), 'is_force_https_enabled' => new OA\Property(property: 'is_force_https_enabled', type: 'boolean', nullable: true), 'is_permanent_redirect_enabled' => new OA\Property(property: 'is_permanent_redirect_enabled', type: 'boolean', nullable: true, description: 'Use permanent redirects (301/308) instead of temporary (302/307) for the HTTP to HTTPS and www redirects. Defaults to false.'), + 'redirect' => new OA\Property(property: 'redirect', type: 'string', enum: ['both', 'www', 'non-www'], description: 'How to handle www and non-www domains. "www" redirects non-www to www, "non-www" redirects www to non-www. Both need a www and a non-www domain in url.'), 'max_restart_count' => new OA\Property(property: 'max_restart_count', type: 'integer', minimum: 0, nullable: true, description: 'Maximum Docker restart count before Coolify stops the container. Set to 0 to disable the limit.'), ] ) @@ -339,6 +342,7 @@ class ServiceApplicationsController extends Controller 'is_stripprefix_enabled', 'is_force_https_enabled', 'is_permanent_redirect_enabled', + 'redirect', 'max_restart_count', ]; @@ -356,6 +360,7 @@ class ServiceApplicationsController extends Controller 'is_stripprefix_enabled' => 'sometimes|boolean', 'is_force_https_enabled' => 'sometimes|boolean', 'is_permanent_redirect_enabled' => 'sometimes|boolean', + 'redirect' => ['sometimes', Rule::enum(RedirectTypes::class)], 'max_restart_count' => 'sometimes|integer|min:0', ]; diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 2c5e1fcedb..57ea276897 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -1150,6 +1150,7 @@ class ServicesController extends Controller 'force_domain_override' => ['type' => 'boolean', 'default' => false, 'description' => 'Force domain override even if conflicts are detected.'], 'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off.'], 'redact_all_env_values_in_logs' => ['type' => 'boolean', 'default' => true, 'description' => 'Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions.'], + 'include_coolify_default_variables' => ['type' => 'boolean', 'default' => true, 'description' => 'Add the predefined COOLIFY_* variables (COOLIFY_URL, COOLIFY_FQDN, ...) to the environment. Requires team admin permissions. Redeploy to apply it.'], ], ) ), @@ -1238,7 +1239,7 @@ class ServicesController extends Controller $this->authorize('update', $service); - if ($request->has('redact_all_env_values_in_logs')) { + if ($request->has('redact_all_env_values_in_logs') || $request->has('include_coolify_default_variables')) { $this->authorize('manageEnvironment', $service); } @@ -1247,7 +1248,7 @@ class ServicesController extends Controller $this->authorize('deploy', $service); } - $allowedFields = ['name', 'description', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override', 'is_container_label_escape_enabled', 'redact_all_env_values_in_logs']; + $allowedFields = ['name', 'description', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override', 'is_container_label_escape_enabled', 'redact_all_env_values_in_logs', 'include_coolify_default_variables']; $validationRules = [ 'name' => 'string|max:255', @@ -1262,6 +1263,7 @@ class ServicesController extends Controller 'force_domain_override' => 'boolean', 'is_container_label_escape_enabled' => 'boolean', 'redact_all_env_values_in_logs' => 'boolean', + 'include_coolify_default_variables' => 'boolean', ]; $validationMessages = [ 'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.', @@ -1332,6 +1334,9 @@ class ServicesController extends Controller if ($request->has('redact_all_env_values_in_logs')) { $service->redact_all_env_values_in_logs = $request->boolean('redact_all_env_values_in_logs'); } + if ($request->has('include_coolify_default_variables')) { + $service->include_coolify_default_variables = $request->boolean('include_coolify_default_variables'); + } $previousDnsHostnames = $this->serviceApplicationDnsHostnames($service); $service->save(); diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php index 081396b44b..d6f05c8095 100644 --- a/bootstrap/helpers/api.php +++ b/bootstrap/helpers/api.php @@ -204,11 +204,13 @@ function sharedDataApplications(?Application $application = null): array 'include_source_commit_in_build' => 'boolean', 'is_env_sorting_enabled' => 'boolean', 'redact_all_env_values_in_logs' => 'boolean', + 'include_coolify_default_variables' => 'boolean', 'is_pr_deployments_public_enabled' => 'boolean', 'is_gzip_enabled' => 'boolean', 'is_stripprefix_enabled' => 'boolean', 'is_permanent_redirect_enabled' => 'boolean', 'traefik_certificate_resolver' => ['nullable', 'string', 'max:255', 'regex:'.ServerSetting::TRAEFIK_CERTIFICATE_RESOLVER_PATTERN], + 'is_container_label_readonly_enabled' => 'boolean', 'is_raw_compose_deployment_enabled' => 'boolean', 'is_log_drain_enabled' => 'boolean', 'is_gpu_enabled' => 'boolean', @@ -485,6 +487,7 @@ function removeUnnecessaryFieldsFromRequest(Request $request) $request->offsetUnset('inject_build_args_to_dockerfile'); $request->offsetUnset('is_env_sorting_enabled'); $request->offsetUnset('redact_all_env_values_in_logs'); + $request->offsetUnset('include_coolify_default_variables'); $request->offsetUnset('is_pr_deployments_public_enabled'); $request->offsetUnset('stop_grace_period'); $request->offsetUnset('docker_images_to_keep'); @@ -492,6 +495,7 @@ function removeUnnecessaryFieldsFromRequest(Request $request) $request->offsetUnset('is_stripprefix_enabled'); $request->offsetUnset('is_permanent_redirect_enabled'); $request->offsetUnset('traefik_certificate_resolver'); + $request->offsetUnset('is_container_label_readonly_enabled'); $request->offsetUnset('is_raw_compose_deployment_enabled'); $request->offsetUnset('is_log_drain_enabled'); $request->offsetUnset('is_gpu_enabled'); diff --git a/openapi.json b/openapi.json index 5f720a54ae..5978a99124 100644 --- a/openapi.json +++ b/openapi.json @@ -808,6 +808,11 @@ "type": "string", "description": "The Dockerfile location in the repository." }, + "dockerfile_target_build": { + "type": "string", + "nullable": true, + "description": "The Docker build target (stage) in the Dockerfile." + }, "docker_compose_location": { "type": "string", "description": "The Docker Compose location." @@ -893,6 +898,11 @@ "type": "boolean", "description": "Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions." }, + "include_coolify_default_variables": { + "type": "boolean", + "default": true, + "description": "Add the predefined COOLIFY_* variables (COOLIFY_URL, COOLIFY_FQDN, ...) to the environment. Requires team admin permissions. Redeploy to apply it." + }, "is_pr_deployments_public_enabled": { "type": "boolean", "description": "Make pull request deployments public." @@ -927,6 +937,11 @@ "nullable": true, "description": "Traefik certificate resolver for this application. Null uses the server setting, \"none\" uses no resolver." }, + "is_container_label_readonly_enabled": { + "type": "boolean", + "default": true, + "description": "Let Coolify manage the container labels. True regenerates custom_labels from domains, redirect, and proxy settings. False keeps custom_labels as you set them: domains, redirect, and basic auth changes then do not change the labels." + }, "is_raw_compose_deployment_enabled": { "type": "boolean", "description": "Deploy the raw Docker Compose definition." @@ -1445,6 +1460,11 @@ "type": "string", "description": "The Dockerfile location in the repository" }, + "dockerfile_target_build": { + "type": "string", + "nullable": true, + "description": "The Docker build target (stage) in the Dockerfile." + }, "docker_compose_location": { "type": "string", "description": "The Docker Compose location." @@ -1530,6 +1550,11 @@ "type": "boolean", "description": "Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions." }, + "include_coolify_default_variables": { + "type": "boolean", + "default": true, + "description": "Add the predefined COOLIFY_* variables (COOLIFY_URL, COOLIFY_FQDN, ...) to the environment. Requires team admin permissions. Redeploy to apply it." + }, "is_pr_deployments_public_enabled": { "type": "boolean", "description": "Make pull request deployments public." @@ -1564,6 +1589,11 @@ "nullable": true, "description": "Traefik certificate resolver for this application. Null uses the server setting, \"none\" uses no resolver." }, + "is_container_label_readonly_enabled": { + "type": "boolean", + "default": true, + "description": "Let Coolify manage the container labels. True regenerates custom_labels from domains, redirect, and proxy settings. False keeps custom_labels as you set them: domains, redirect, and basic auth changes then do not change the labels." + }, "is_raw_compose_deployment_enabled": { "type": "boolean", "description": "Deploy the raw Docker Compose definition." @@ -2082,6 +2112,11 @@ "type": "string", "description": "The Dockerfile location in the repository." }, + "dockerfile_target_build": { + "type": "string", + "nullable": true, + "description": "The Docker build target (stage) in the Dockerfile." + }, "docker_compose_location": { "type": "string", "description": "The Docker Compose location." @@ -2167,6 +2202,11 @@ "type": "boolean", "description": "Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions." }, + "include_coolify_default_variables": { + "type": "boolean", + "default": true, + "description": "Add the predefined COOLIFY_* variables (COOLIFY_URL, COOLIFY_FQDN, ...) to the environment. Requires team admin permissions. Redeploy to apply it." + }, "is_pr_deployments_public_enabled": { "type": "boolean", "description": "Make pull request deployments public." @@ -2201,6 +2241,11 @@ "nullable": true, "description": "Traefik certificate resolver for this application. Null uses the server setting, \"none\" uses no resolver." }, + "is_container_label_readonly_enabled": { + "type": "boolean", + "default": true, + "description": "Let Coolify manage the container labels. True regenerates custom_labels from domains, redirect, and proxy settings. False keeps custom_labels as you set them: domains, redirect, and basic auth changes then do not change the labels." + }, "is_raw_compose_deployment_enabled": { "type": "boolean", "description": "Deploy the raw Docker Compose definition." @@ -2699,6 +2744,11 @@ "type": "boolean", "description": "Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions." }, + "include_coolify_default_variables": { + "type": "boolean", + "default": true, + "description": "Add the predefined COOLIFY_* variables (COOLIFY_URL, COOLIFY_FQDN, ...) to the environment. Requires team admin permissions. Redeploy to apply it." + }, "is_pr_deployments_public_enabled": { "type": "boolean", "description": "Make pull request deployments public." @@ -2733,6 +2783,11 @@ "nullable": true, "description": "Traefik certificate resolver for this application. Null uses the server setting, \"none\" uses no resolver." }, + "is_container_label_readonly_enabled": { + "type": "boolean", + "default": true, + "description": "Let Coolify manage the container labels. True regenerates custom_labels from domains, redirect, and proxy settings. False keeps custom_labels as you set them: domains, redirect, and basic auth changes then do not change the labels." + }, "is_raw_compose_deployment_enabled": { "type": "boolean", "description": "Deploy the raw Docker Compose definition." @@ -3211,6 +3266,11 @@ "type": "boolean", "description": "Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions." }, + "include_coolify_default_variables": { + "type": "boolean", + "default": true, + "description": "Add the predefined COOLIFY_* variables (COOLIFY_URL, COOLIFY_FQDN, ...) to the environment. Requires team admin permissions. Redeploy to apply it." + }, "is_pr_deployments_public_enabled": { "type": "boolean", "description": "Make pull request deployments public." @@ -3245,6 +3305,11 @@ "nullable": true, "description": "Traefik certificate resolver for this application. Null uses the server setting, \"none\" uses no resolver." }, + "is_container_label_readonly_enabled": { + "type": "boolean", + "default": true, + "description": "Let Coolify manage the container labels. True regenerates custom_labels from domains, redirect, and proxy settings. False keeps custom_labels as you set them: domains, redirect, and basic auth changes then do not change the labels." + }, "is_raw_compose_deployment_enabled": { "type": "boolean", "description": "Deploy the raw Docker Compose definition." @@ -3871,6 +3936,11 @@ "type": "string", "description": "The Dockerfile location in the repository." }, + "dockerfile_target_build": { + "type": "string", + "nullable": true, + "description": "The Docker build target (stage) in the Dockerfile." + }, "docker_compose_location": { "type": "string", "description": "The Docker Compose location." @@ -3981,6 +4051,11 @@ "type": "boolean", "description": "Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions." }, + "include_coolify_default_variables": { + "type": "boolean", + "default": true, + "description": "Add the predefined COOLIFY_* variables (COOLIFY_URL, COOLIFY_FQDN, ...) to the environment. Requires team admin permissions. Redeploy to apply it." + }, "is_pr_deployments_public_enabled": { "type": "boolean", "description": "Make pull request deployments public." @@ -4015,6 +4090,11 @@ "nullable": true, "description": "Traefik certificate resolver for this application. Null uses the server setting, \"none\" uses no resolver." }, + "is_container_label_readonly_enabled": { + "type": "boolean", + "default": true, + "description": "Let Coolify manage the container labels. True regenerates custom_labels from domains, redirect, and proxy settings. False keeps custom_labels as you set them: domains, redirect, and basic auth changes then do not change the labels." + }, "is_raw_compose_deployment_enabled": { "type": "boolean", "description": "Deploy the raw Docker Compose definition." @@ -7025,6 +7105,11 @@ "description": "Whether to save backups to S3", "default": false }, + "disable_local_backup": { + "type": "boolean", + "description": "Delete the local backup file after the upload to S3. Only allowed when save_s3 is true.", + "default": false + }, "s3_storage_uuid": { "type": "string", "description": "Primary S3 storage UUID. Sent without s3_storage_uuids, it selects this storage as the only destination. Either field is required if save_s3 is true." @@ -7502,6 +7587,14 @@ "redact_all_env_values_in_logs": { "type": "boolean", "description": "Redact all environment variable values in logs. When false, only locked (shown once) values, database passwords, and remote secrets are redacted. Requires team admin permissions." + }, + "include_coolify_default_variables": { + "type": "boolean", + "description": "Add the predefined COOLIFY_* variables (COOLIFY_URL, COOLIFY_FQDN, ...) to the environment. Requires team admin permissions. Restart the database to apply it." + }, + "is_log_drain_enabled": { + "type": "boolean", + "description": "Send the database logs to the log drain of the server. Log drain must be enabled on the server. Restart the database to apply it." } }, "type": "object" @@ -7652,6 +7745,10 @@ "type": "boolean", "description": "Whether data is saved in s3 or not" }, + "disable_local_backup": { + "type": "boolean", + "description": "Delete the local backup file after the upload to S3. Only allowed when save_s3 is true. Turning save_s3 off also turns this off." + }, "s3_storage_uuid": { "type": "string", "description": "Primary S3 storage UUID. Sent without s3_storage_uuids, it selects this storage as the only destination; when it is already a destination, it becomes the primary destination and the other destinations stay." @@ -17199,6 +17296,12 @@ ], "example": "https:\/\/example.com" }, + "proxy_error_page": { + "type": [ + "string", + "null" + ] + }, "generate_exact_labels": { "type": "boolean", "example": false @@ -17274,6 +17377,13 @@ "null" ] }, + "proxy_error_page": { + "description": "Custom HTML error page that the proxy shows for unknown hosts (when redirect_url is empty) and for resources in maintenance mode. Null restores the default page.", + "type": [ + "string", + "null" + ] + }, "generate_exact_labels": { "type": "boolean" }, @@ -17328,6 +17438,12 @@ "null" ] }, + "proxy_error_page": { + "type": [ + "string", + "null" + ] + }, "generate_exact_labels": { "type": "boolean" }, @@ -17442,6 +17558,12 @@ "null" ] }, + "proxy_error_page": { + "type": [ + "string", + "null" + ] + }, "generate_exact_labels": { "type": "boolean" }, @@ -18448,6 +18570,25 @@ "connection_timeout": { "type": "integer", "description": "SSH connection timeout in seconds (1-300). Default: 10." + }, + "wildcard_domain": { + "type": "string", + "nullable": true, + "description": "Wildcard domain (URL) used to generate domains for new resources on this server. Null removes it." + }, + "server_timezone": { + "type": "string", + "description": "Timezone of the server, for example Europe\/Berlin. Scheduled tasks and backups use it." + }, + "backup_compression_cpu_percentage": { + "type": "integer", + "enum": [ + 25, + 50, + 75, + 100 + ], + "description": "Maximum CPU percentage that backup compression can use. Default: 25." } }, "type": "object" @@ -18913,6 +19054,15 @@ "null" ] }, + "redirect": { + "description": "How to handle www and non-www domains. \"www\" redirects non-www to www, \"non-www\" redirects www to non-www. Both need a www and a non-www domain in url.", + "type": "string", + "enum": [ + "both", + "www", + "non-www" + ] + }, "max_restart_count": { "description": "Maximum Docker restart count before Coolify stops the container. Set to 0 to disable the limit.", "type": [ @@ -20461,6 +20611,11 @@ "type": "boolean", "default": true, "description": "Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions." + }, + "include_coolify_default_variables": { + "type": "boolean", + "default": true, + "description": "Add the predefined COOLIFY_* variables (COOLIFY_URL, COOLIFY_FQDN, ...) to the environment. Requires team admin permissions. Redeploy to apply it." } }, "type": "object" diff --git a/openapi.yaml b/openapi.yaml index fff4540097..ad222762e4 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -529,6 +529,10 @@ paths: dockerfile_location: type: string description: 'The Dockerfile location in the repository.' + dockerfile_target_build: + type: string + nullable: true + description: 'The Docker build target (stage) in the Dockerfile.' docker_compose_location: type: string description: 'The Docker Compose location.' @@ -577,6 +581,10 @@ paths: redact_all_env_values_in_logs: type: boolean description: 'Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions.' + include_coolify_default_variables: + type: boolean + default: true + description: 'Add the predefined COOLIFY_* variables (COOLIFY_URL, COOLIFY_FQDN, ...) to the environment. Requires team admin permissions. Redeploy to apply it.' is_pr_deployments_public_enabled: type: boolean description: 'Make pull request deployments public.' @@ -604,6 +612,10 @@ paths: type: string nullable: true description: 'Traefik certificate resolver for this application. Null uses the server setting, "none" uses no resolver.' + is_container_label_readonly_enabled: + type: boolean + default: true + description: 'Let Coolify manage the container labels. True regenerates custom_labels from domains, redirect, and proxy settings. False keeps custom_labels as you set them: domains, redirect, and basic auth changes then do not change the labels.' is_raw_compose_deployment_enabled: type: boolean description: 'Deploy the raw Docker Compose definition.' @@ -950,6 +962,10 @@ paths: dockerfile_location: type: string description: 'The Dockerfile location in the repository' + dockerfile_target_build: + type: string + nullable: true + description: 'The Docker build target (stage) in the Dockerfile.' docker_compose_location: type: string description: 'The Docker Compose location.' @@ -998,6 +1014,10 @@ paths: redact_all_env_values_in_logs: type: boolean description: 'Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions.' + include_coolify_default_variables: + type: boolean + default: true + description: 'Add the predefined COOLIFY_* variables (COOLIFY_URL, COOLIFY_FQDN, ...) to the environment. Requires team admin permissions. Redeploy to apply it.' is_pr_deployments_public_enabled: type: boolean description: 'Make pull request deployments public.' @@ -1025,6 +1045,10 @@ paths: type: string nullable: true description: 'Traefik certificate resolver for this application. Null uses the server setting, "none" uses no resolver.' + is_container_label_readonly_enabled: + type: boolean + default: true + description: 'Let Coolify manage the container labels. True regenerates custom_labels from domains, redirect, and proxy settings. False keeps custom_labels as you set them: domains, redirect, and basic auth changes then do not change the labels.' is_raw_compose_deployment_enabled: type: boolean description: 'Deploy the raw Docker Compose definition.' @@ -1371,6 +1395,10 @@ paths: dockerfile_location: type: string description: 'The Dockerfile location in the repository.' + dockerfile_target_build: + type: string + nullable: true + description: 'The Docker build target (stage) in the Dockerfile.' docker_compose_location: type: string description: 'The Docker Compose location.' @@ -1419,6 +1447,10 @@ paths: redact_all_env_values_in_logs: type: boolean description: 'Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions.' + include_coolify_default_variables: + type: boolean + default: true + description: 'Add the predefined COOLIFY_* variables (COOLIFY_URL, COOLIFY_FQDN, ...) to the environment. Requires team admin permissions. Redeploy to apply it.' is_pr_deployments_public_enabled: type: boolean description: 'Make pull request deployments public.' @@ -1446,6 +1478,10 @@ paths: type: string nullable: true description: 'Traefik certificate resolver for this application. Null uses the server setting, "none" uses no resolver.' + is_container_label_readonly_enabled: + type: boolean + default: true + description: 'Let Coolify manage the container labels. True regenerates custom_labels from domains, redirect, and proxy settings. False keeps custom_labels as you set them: domains, redirect, and basic auth changes then do not change the labels.' is_raw_compose_deployment_enabled: type: boolean description: 'Deploy the raw Docker Compose definition.' @@ -1781,6 +1817,10 @@ paths: redact_all_env_values_in_logs: type: boolean description: 'Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions.' + include_coolify_default_variables: + type: boolean + default: true + description: 'Add the predefined COOLIFY_* variables (COOLIFY_URL, COOLIFY_FQDN, ...) to the environment. Requires team admin permissions. Redeploy to apply it.' is_pr_deployments_public_enabled: type: boolean description: 'Make pull request deployments public.' @@ -1808,6 +1848,10 @@ paths: type: string nullable: true description: 'Traefik certificate resolver for this application. Null uses the server setting, "none" uses no resolver.' + is_container_label_readonly_enabled: + type: boolean + default: true + description: 'Let Coolify manage the container labels. True regenerates custom_labels from domains, redirect, and proxy settings. False keeps custom_labels as you set them: domains, redirect, and basic auth changes then do not change the labels.' is_raw_compose_deployment_enabled: type: boolean description: 'Deploy the raw Docker Compose definition.' @@ -2129,6 +2173,10 @@ paths: redact_all_env_values_in_logs: type: boolean description: 'Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions.' + include_coolify_default_variables: + type: boolean + default: true + description: 'Add the predefined COOLIFY_* variables (COOLIFY_URL, COOLIFY_FQDN, ...) to the environment. Requires team admin permissions. Redeploy to apply it.' is_pr_deployments_public_enabled: type: boolean description: 'Make pull request deployments public.' @@ -2156,6 +2204,10 @@ paths: type: string nullable: true description: 'Traefik certificate resolver for this application. Null uses the server setting, "none" uses no resolver.' + is_container_label_readonly_enabled: + type: boolean + default: true + description: 'Let Coolify manage the container labels. True regenerates custom_labels from domains, redirect, and proxy settings. False keeps custom_labels as you set them: domains, redirect, and basic auth changes then do not change the labels.' is_raw_compose_deployment_enabled: type: boolean description: 'Deploy the raw Docker Compose definition.' @@ -2574,6 +2626,10 @@ paths: dockerfile_location: type: string description: 'The Dockerfile location in the repository.' + dockerfile_target_build: + type: string + nullable: true + description: 'The Docker build target (stage) in the Dockerfile.' docker_compose_location: type: string description: 'The Docker Compose location.' @@ -2640,6 +2696,10 @@ paths: redact_all_env_values_in_logs: type: boolean description: 'Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions.' + include_coolify_default_variables: + type: boolean + default: true + description: 'Add the predefined COOLIFY_* variables (COOLIFY_URL, COOLIFY_FQDN, ...) to the environment. Requires team admin permissions. Redeploy to apply it.' is_pr_deployments_public_enabled: type: boolean description: 'Make pull request deployments public.' @@ -2667,6 +2727,10 @@ paths: type: string nullable: true description: 'Traefik certificate resolver for this application. Null uses the server setting, "none" uses no resolver.' + is_container_label_readonly_enabled: + type: boolean + default: true + description: 'Let Coolify manage the container labels. True regenerates custom_labels from domains, redirect, and proxy settings. False keeps custom_labels as you set them: domains, redirect, and basic auth changes then do not change the labels.' is_raw_compose_deployment_enabled: type: boolean description: 'Deploy the raw Docker Compose definition.' @@ -4519,6 +4583,10 @@ paths: type: boolean description: 'Whether to save backups to S3' default: false + disable_local_backup: + type: boolean + description: 'Delete the local backup file after the upload to S3. Only allowed when save_s3 is true.' + default: false s3_storage_uuid: type: string description: 'Primary S3 storage UUID. Sent without s3_storage_uuids, it selects this storage as the only destination. Either field is required if save_s3 is true.' @@ -4860,6 +4928,12 @@ paths: redact_all_env_values_in_logs: type: boolean description: 'Redact all environment variable values in logs. When false, only locked (shown once) values, database passwords, and remote secrets are redacted. Requires team admin permissions.' + include_coolify_default_variables: + type: boolean + description: 'Add the predefined COOLIFY_* variables (COOLIFY_URL, COOLIFY_FQDN, ...) to the environment. Requires team admin permissions. Restart the database to apply it.' + is_log_drain_enabled: + type: boolean + description: 'Send the database logs to the log drain of the server. Log drain must be enabled on the server. Restart the database to apply it.' type: object responses: '200': @@ -4958,6 +5032,9 @@ paths: save_s3: type: boolean description: 'Whether data is saved in s3 or not' + disable_local_backup: + type: boolean + description: 'Delete the local backup file after the upload to S3. Only allowed when save_s3 is true. Turning save_s3 off also turns this off.' s3_storage_uuid: type: string description: 'Primary S3 storage UUID. Sent without s3_storage_uuids, it selects this storage as the only destination; when it is already a destination, it becomes the primary destination and the other destinations stay.' @@ -10926,6 +11003,7 @@ paths: status: { type: [string, 'null'], example: running } redirect_enabled: { type: boolean, example: true } redirect_url: { type: [string, 'null'], example: 'https://example.com' } + proxy_error_page: { type: [string, 'null'] } generate_exact_labels: { type: boolean, example: false } traefik_certificate_resolver: { description: 'Traefik certificate resolver for generated HTTPS routers. Null when the proxy uses its own certificates.', type: [string, 'null'], example: letsencrypt } configuration: { description: 'Docker Compose proxy configuration when stored in the database. Only present with read:sensitive.', type: [string, 'null'] } @@ -10964,6 +11042,9 @@ paths: redirect_url: description: 'Public http(s) redirect URL, or null to clear.' type: [string, 'null'] + proxy_error_page: + description: 'Custom HTML error page that the proxy shows for unknown hosts (when redirect_url is empty) and for resources in maintenance mode. Null restores the default page.' + type: [string, 'null'] generate_exact_labels: type: boolean traefik_certificate_resolver: @@ -10985,6 +11066,7 @@ paths: status: { type: [string, 'null'] } redirect_enabled: { type: boolean } redirect_url: { type: [string, 'null'] } + proxy_error_page: { type: [string, 'null'] } generate_exact_labels: { type: boolean } traefik_certificate_resolver: { type: [string, 'null'] } configuration: { type: [string, 'null'] } @@ -11041,6 +11123,7 @@ paths: status: { type: [string, 'null'] } redirect_enabled: { type: boolean } redirect_url: { type: [string, 'null'] } + proxy_error_page: { type: [string, 'null'] } generate_exact_labels: { type: boolean } configuration: { type: [string, 'null'] } type: object @@ -11674,6 +11757,17 @@ paths: connection_timeout: type: integer description: 'SSH connection timeout in seconds (1-300). Default: 10.' + wildcard_domain: + type: string + nullable: true + description: 'Wildcard domain (URL) used to generate domains for new resources on this server. Null removes it.' + server_timezone: + type: string + description: 'Timezone of the server, for example Europe/Berlin. Scheduled tasks and backups use it.' + backup_compression_cpu_percentage: + type: integer + enum: [25, 50, 75, 100] + description: 'Maximum CPU percentage that backup compression can use. Default: 25.' type: object responses: '403': @@ -11937,6 +12031,10 @@ paths: is_permanent_redirect_enabled: description: 'Use permanent redirects (301/308) instead of temporary (302/307) for the HTTP to HTTPS and www redirects. Defaults to false.' type: [boolean, 'null'] + redirect: + description: 'How to handle www and non-www domains. "www" redirects non-www to www, "non-www" redirects www to non-www. Both need a www and a non-www domain in url.' + type: string + enum: [both, www, non-www] max_restart_count: description: 'Maximum Docker restart count before Coolify stops the container. Set to 0 to disable the limit.' type: [integer, 'null'] @@ -12929,6 +13027,10 @@ paths: type: boolean default: true description: 'Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions.' + include_coolify_default_variables: + type: boolean + default: true + description: 'Add the predefined COOLIFY_* variables (COOLIFY_URL, COOLIFY_FQDN, ...) to the environment. Requires team admin permissions. Redeploy to apply it.' type: object responses: '200': diff --git a/tests/Feature/Api/ApplicationSettingsApiTest.php b/tests/Feature/Api/ApplicationSettingsApiTest.php index 6e46957222..8698c2bd94 100644 --- a/tests/Feature/Api/ApplicationSettingsApiTest.php +++ b/tests/Feature/Api/ApplicationSettingsApiTest.php @@ -704,3 +704,78 @@ test('application creation rejects an unsafe Traefik certificate resolver', func expect(Application::query()->where('git_repository', 'like', '%resolver-test%')->exists())->toBeFalse(); }); + +test('PATCH /api/v1/applications/{uuid} turns off label management and keeps custom labels with new domains', function () { + $this->application->settings->update(['is_container_label_readonly_enabled' => true]); + $this->application->update([ + 'fqdn' => 'https://app.example.com', + 'custom_labels' => base64_encode('sentinel-label=true'), + ]); + + $this->withHeaders(applicationSettingsApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$this->application->uuid}", [ + 'is_container_label_readonly_enabled' => false, + 'domains' => 'https://new.example.com', + ]) + ->assertOk(); + + $application = $this->application->fresh(); + + expect($application->settings->is_container_label_readonly_enabled)->toBeFalse() + ->and($application->fqdn)->toBe('https://new.example.com') + ->and(base64_decode($application->custom_labels))->toBe('sentinel-label=true'); +}); + +test('PATCH /api/v1/applications/{uuid} regenerates labels when label management is turned back on', function () { + $this->application->settings->update(['is_container_label_readonly_enabled' => false]); + $this->application->update([ + 'fqdn' => 'https://app.example.com', + 'custom_labels' => base64_encode('sentinel-label=true'), + ]); + + $this->withHeaders(applicationSettingsApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$this->application->uuid}", [ + 'is_container_label_readonly_enabled' => true, + ]) + ->assertOk(); + + $application = $this->application->fresh(); + $labels = $application->parseContainerLabels(); + + expect($application->settings->is_container_label_readonly_enabled)->toBeTrue() + ->and($labels)->not->toContain('sentinel-label=true') + ->and($labels)->toContain('app.example.com'); +}); + +test('application creation keeps custom labels when label management is off', function () { + Queue::fake(); + + $response = $this->withHeaders(applicationSettingsApiHeaders($this->bearerToken)) + ->postJson('/api/v1/applications/public', [ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'server_uuid' => $this->server->uuid, + 'git_repository' => 'https://gitlab.com/coolify/readonly-labels-test', + 'git_branch' => 'main', + 'build_pack' => 'nixpacks', + 'ports_exposes' => '3000', + 'domains' => 'https://app.example.com', + 'custom_labels' => base64_encode('sentinel-label=true'), + 'is_container_label_readonly_enabled' => false, + ]) + ->assertCreated(); + + $application = Application::where('uuid', $response->json('uuid'))->firstOrFail(); + + expect($application->settings->is_container_label_readonly_enabled)->toBeFalse() + ->and(base64_decode($application->custom_labels))->toBe('sentinel-label=true'); +}); + +test('rejects an invalid is_container_label_readonly_enabled value', function () { + $this->withHeaders(applicationSettingsApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$this->application->uuid}", [ + 'is_container_label_readonly_enabled' => 'not-a-boolean', + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('is_container_label_readonly_enabled'); +}); diff --git a/tests/Feature/Api/UiSettingsApiParityTest.php b/tests/Feature/Api/UiSettingsApiParityTest.php new file mode 100644 index 0000000000..dd3b0649b4 --- /dev/null +++ b/tests/Feature/Api/UiSettingsApiParityTest.php @@ -0,0 +1,291 @@ +set('app.maintenance.store', 'array'); + config()->set('app.maintenance.driver', 'file'); + InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]); + + $this->team = Team::factory()->create(); + $user = User::factory()->create(); + $this->team->members()->attach($user->id, ['role' => 'owner']); + + $plainTextToken = Str::random(40); + $token = $user->tokens()->create([ + 'name' => 'ui-settings-api-parity-test', + 'token' => hash('sha256', $plainTextToken), + 'abilities' => ['*'], + 'team_id' => $this->team->id, + ]); + $this->headers = ['Authorization' => 'Bearer '.$token->getKey().'|'.$plainTextToken]; + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail(); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + + $this->database = StandalonePostgresql::create([ + 'name' => 'db', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'app', + 'image' => 'postgres:16', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); +}); + +function parityS3Storage(Team $team): S3Storage +{ + return S3Storage::create([ + 'name' => 's3', + 'region' => 'us-east-1', + 'key' => 'key', + 'secret' => 'secret', + 'bucket' => 'bucket', + 'endpoint' => 'https://s3.example.com', + 'team_id' => $team->id, + 'is_usable' => true, + ]); +} + +function parityServiceApplication(object $ctx): ServiceApplication +{ + $service = Service::factory()->create([ + 'environment_id' => $ctx->environment->id, + 'server_id' => $ctx->server->id, + 'destination_id' => $ctx->destination->id, + 'destination_type' => $ctx->destination->getMorphClass(), + 'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n", + ]); + + return ServiceApplication::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'web', + 'service_id' => $service->id, + 'image' => 'nginx:alpine', + ]); +} + +describe('applications', function () { + test('creation accepts dockerfile_target_build', function () { + $response = $this->withHeaders($this->headers)->postJson('/api/v1/applications/public', [ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'server_uuid' => $this->server->uuid, + 'git_repository' => 'https://gitlab.com/coolify/target-build-test', + 'git_branch' => 'main', + 'build_pack' => 'dockerfile', + 'ports_exposes' => '3000', + 'dockerfile_target_build' => 'production', + ])->assertCreated(); + + expect(Application::where('uuid', $response->json('uuid'))->firstOrFail()->dockerfile_target_build)->toBe('production'); + }); + + test('PATCH updates include_coolify_default_variables', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $this->withHeaders($this->headers) + ->patchJson("/api/v1/applications/{$application->uuid}", ['include_coolify_default_variables' => false]) + ->assertOk(); + + expect($application->fresh()->settings->include_coolify_default_variables)->toBeFalse(); + }); +}); + +describe('databases', function () { + test('PATCH saves is_log_drain_enabled when the server has a log drain', function () { + $this->server->settings->update(['is_logdrain_newrelic_enabled' => true]); + + $this->withHeaders($this->headers) + ->patchJson("/api/v1/databases/{$this->database->uuid}", ['is_log_drain_enabled' => true]) + ->assertOk(); + + expect($this->database->fresh()->is_log_drain_enabled)->toBeTruthy(); + }); + + test('PATCH rejects is_log_drain_enabled when the server has no log drain', function () { + $this->withHeaders($this->headers) + ->patchJson("/api/v1/databases/{$this->database->uuid}", ['is_log_drain_enabled' => true]) + ->assertUnprocessable() + ->assertJsonValidationErrors('is_log_drain_enabled'); + + expect($this->database->fresh()->is_log_drain_enabled)->toBeFalsy(); + }); + + test('PATCH updates include_coolify_default_variables', function () { + $this->withHeaders($this->headers) + ->patchJson("/api/v1/databases/{$this->database->uuid}", ['include_coolify_default_variables' => false]) + ->assertOk(); + + expect($this->database->fresh()->include_coolify_default_variables)->toBeFalse(); + }); + + test('backup creation accepts disable_local_backup with S3', function () { + $s3 = parityS3Storage($this->team); + + $response = $this->withHeaders($this->headers)->postJson("/api/v1/databases/{$this->database->uuid}/backups", [ + 'frequency' => 'daily', + 'save_s3' => true, + 's3_storage_uuid' => $s3->uuid, + 'disable_local_backup' => true, + ])->assertCreated(); + + expect(ScheduledDatabaseBackup::where('uuid', $response->json('uuid'))->firstOrFail()->disable_local_backup)->toBeTruthy(); + }); + + test('backup creation rejects disable_local_backup without S3', function () { + $this->withHeaders($this->headers)->postJson("/api/v1/databases/{$this->database->uuid}/backups", [ + 'frequency' => 'daily', + 'disable_local_backup' => true, + ])->assertUnprocessable() + ->assertJsonValidationErrors('disable_local_backup'); + + expect(ScheduledDatabaseBackup::count())->toBe(0); + }); + + test('backup update turns local backups back on when S3 is turned off', function () { + $s3 = parityS3Storage($this->team); + $backup = ScheduledDatabaseBackup::create([ + 'frequency' => 'daily', + 'save_s3' => true, + 's3_storage_id' => $s3->id, + 'disable_local_backup' => true, + 'database_id' => $this->database->id, + 'database_type' => $this->database->getMorphClass(), + 'team_id' => $this->team->id, + ]); + + $this->withHeaders($this->headers) + ->patchJson("/api/v1/databases/{$this->database->uuid}/backups/{$backup->uuid}", ['save_s3' => false]) + ->assertOk(); + + expect($backup->fresh()->disable_local_backup)->toBeFalsy(); + + $this->withHeaders($this->headers) + ->patchJson("/api/v1/databases/{$this->database->uuid}/backups/{$backup->uuid}", ['disable_local_backup' => true]) + ->assertUnprocessable() + ->assertJsonValidationErrors('disable_local_backup'); + }); +}); + +describe('services', function () { + test('PATCH updates include_coolify_default_variables', function () { + $serviceApplication = parityServiceApplication($this); + $service = $serviceApplication->service; + + $this->withHeaders($this->headers) + ->patchJson("/api/v1/services/{$service->uuid}", ['include_coolify_default_variables' => false]) + ->assertOk(); + + expect($service->fresh()->include_coolify_default_variables)->toBeFalse(); + }); + + test('service application PATCH sets a www redirect when both hosts exist', function () { + $serviceApplication = parityServiceApplication($this); + + $this->withHeaders($this->headers) + ->patchJson("/api/v1/services/{$serviceApplication->service->uuid}/applications/{$serviceApplication->uuid}", [ + 'url' => 'https://example.com,https://www.example.com', + 'redirect' => 'www', + ])->assertOk(); + + expect($serviceApplication->fresh()->redirect)->toBe('www'); + }); + + test('service application PATCH rejects a www redirect without a www host', function () { + $serviceApplication = parityServiceApplication($this); + $serviceApplication->update(['fqdn' => 'https://example.com']); + + $this->withHeaders($this->headers) + ->patchJson("/api/v1/services/{$serviceApplication->service->uuid}/applications/{$serviceApplication->uuid}", [ + 'redirect' => 'www', + ])->assertUnprocessable() + ->assertJsonValidationErrors('redirect'); + + expect($serviceApplication->fresh()->redirect)->not->toBe('www'); + }); + + test('service application PATCH rejects an unknown redirect', function () { + $serviceApplication = parityServiceApplication($this); + + $this->withHeaders($this->headers) + ->patchJson("/api/v1/services/{$serviceApplication->service->uuid}/applications/{$serviceApplication->uuid}", [ + 'redirect' => 'sometimes', + ])->assertUnprocessable() + ->assertJsonValidationErrors('redirect'); + }); +}); + +describe('servers', function () { + test('PATCH updates wildcard domain, timezone and backup compression', function () { + $this->withHeaders($this->headers)->patchJson("/api/v1/servers/{$this->server->uuid}", [ + 'wildcard_domain' => 'https://apps.example.com', + 'server_timezone' => 'Europe/Berlin', + 'backup_compression_cpu_percentage' => 50, + ])->assertCreated(); + + expect($this->server->settings->fresh()) + ->wildcard_domain->toBe('https://apps.example.com') + ->server_timezone->toBe('Europe/Berlin') + ->backup_compression_cpu_percentage->toBe(50); + + $this->withHeaders($this->headers) + ->patchJson("/api/v1/servers/{$this->server->uuid}", ['wildcard_domain' => null]) + ->assertCreated(); + + expect($this->server->settings->fresh()->wildcard_domain)->toBeNull(); + }); + + test('PATCH rejects invalid server settings', function (string $field, mixed $value) { + $this->withHeaders($this->headers) + ->patchJson("/api/v1/servers/{$this->server->uuid}", [$field => $value]) + ->assertUnprocessable() + ->assertJsonValidationErrors($field); + })->with([ + 'wildcard domain' => ['wildcard_domain', 'not a url'], + 'timezone' => ['server_timezone', 'Mars/Olympus'], + 'compression' => ['backup_compression_cpu_percentage', 30], + ]); + + test('proxy PATCH sets and clears the custom error page', function () { + $this->withHeaders($this->headers) + ->patchJson("/api/v1/servers/{$this->server->uuid}/proxy", ['proxy_error_page' => '

Not here

']) + ->assertOk() + ->assertJsonPath('proxy_error_page', '

Not here

'); + + expect($this->server->fresh()->proxy_error_page)->toBe('

Not here

'); + + $this->withHeaders($this->headers) + ->patchJson("/api/v1/servers/{$this->server->uuid}/proxy", ['proxy_error_page' => null]) + ->assertOk(); + + expect($this->server->fresh()->proxy_error_page)->toBeNull(); + }); +});