diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 6493ff7b47..4dbc4e9a95 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -3258,11 +3258,12 @@ class ApplicationsController extends Controller $dnsCleanup = app(ManagedDnsRecordCleanup::class); $previousDnsHostnames = $dnsCleanup->hostnamesOf($application); $server = $application->destination->server; - $allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'noindex_domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'dockerfile_target_build', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'use_build_secrets', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled', 'is_preserve_repository_enabled', 'preview_url_template', 'max_restart_count', 'github_app_uuid', ...self::APPLICATION_SETTING_FIELDS]; + $allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'noindex_domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'dockerfile_target_build', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'use_build_secrets', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled', 'is_preserve_repository_enabled', 'preview_url_template', 'max_restart_count', 'github_app_uuid', 'dockerfile', ...self::APPLICATION_SETTING_FIELDS]; $validationRules = [ 'name' => 'string|max:255', 'github_app_uuid' => 'string', + 'dockerfile' => 'string', 'description' => 'string|nullable', 'watch_paths' => 'string|nullable', 'docker_compose_domains' => 'array|nullable', @@ -3320,6 +3321,28 @@ class ApplicationsController extends Controller 'custom_nginx_configuration' => $customNginxConfiguration, ]); } + if ($request->has('dockerfile')) { + if (blank($application->dockerfile)) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [ + 'dockerfile' => 'The dockerfile can only be updated for applications created from a Dockerfile.', + ], + ], 422); + } + $dockerfile = is_string($request->dockerfile) && isBase64Encoded($request->dockerfile) ? base64_decode($request->dockerfile) : false; + if ($dockerfile === false || blank($dockerfile) || mb_detect_encoding($dockerfile, 'UTF-8', true) === false) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [ + 'dockerfile' => 'The dockerfile should be base64 encoded.', + ], + ], 422); + } + $request->merge([ + 'dockerfile' => $dockerfile, + ]); + } $return = $this->validateDataApplications($request, $server); if ($return instanceof JsonResponse) { return $return; @@ -3341,7 +3364,7 @@ class ApplicationsController extends Controller $githubApp = null; if ($request->has('github_app_uuid')) { - if ($application->build_pack === 'dockerimage' || $application->deploymentType() === 'deploy_key') { + if ($application->build_pack === 'dockerimage' || filled($application->dockerfile) || $application->deploymentType() === 'deploy_key') { return response()->json([ 'message' => 'Validation failed.', 'errors' => [ @@ -3624,6 +3647,9 @@ class ApplicationsController extends Controller $application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n"); } $application->withoutAuditLogging(fn () => $application->save()); + if ($application->wasChanged('dockerfile')) { + $application->parseHealthcheckFromDockerfile($application->dockerfile); + } $dnsCleanup->queueReleaseOfRemovedHostnames($application, $previousDnsHostnames, (int) $teamId); auditLog('api.application.updated', [ diff --git a/tests/Feature/ApplicationDockerfileUpdateApiTest.php b/tests/Feature/ApplicationDockerfileUpdateApiTest.php new file mode 100644 index 0000000000..8aed601394 --- /dev/null +++ b/tests/Feature/ApplicationDockerfileUpdateApiTest.php @@ -0,0 +1,73 @@ + InstanceSettings::firstOrCreate(['id' => 0])); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + session(['currentTeam' => $this->team]); + $this->token = $this->user->createToken('dockerfile-update-test', ['*'])->plainTextToken; + + $server = Server::factory()->create(['team_id' => $this->team->id]); + $destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + $this->application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'build_pack' => 'dockerfile', + 'dockerfile' => "FROM nginx:alpine\n", + 'custom_healthcheck_found' => true, + 'health_check_interval' => 30, + ]); +}); + +function patchApplicationDockerfile(array $payload) +{ + return test()->withHeaders(['Authorization' => 'Bearer '.test()->token]) + ->patchJson('/api/v1/applications/'.test()->application->uuid, $payload); +} + +test('PATCH updates the Dockerfile of a Dockerfile application', function () { + $dockerfile = "FROM nginx:1.27-alpine\n"; + + patchApplicationDockerfile(['dockerfile' => base64_encode($dockerfile)])->assertOk(); + + $application = $this->application->refresh(); + expect($application->dockerfile)->toBe($dockerfile) + ->and($application->custom_healthcheck_found)->toBeFalsy() + ->and($application->health_check_interval)->toBe(5); +}); + +test('PATCH rejects a Dockerfile that is not base64 encoded', function () { + patchApplicationDockerfile(['dockerfile' => "FROM nginx:1.27-alpine\n"]) + ->assertUnprocessable() + ->assertJsonPath('errors.dockerfile', 'The dockerfile should be base64 encoded.'); + + expect($this->application->refresh()->dockerfile)->toBe("FROM nginx:alpine\n"); +}); + +test('PATCH rejects a Dockerfile for applications built from a git repository', function () { + $this->application->forceFill(['dockerfile' => null])->saveQuietly(); + + patchApplicationDockerfile(['dockerfile' => base64_encode("FROM nginx:1.27-alpine\n")]) + ->assertUnprocessable() + ->assertInvalid(['dockerfile']); + + expect($this->application->refresh()->dockerfile)->toBeNull(); +}); diff --git a/tests/Feature/ApplicationGithubAppSourceApiTest.php b/tests/Feature/ApplicationGithubAppSourceApiTest.php index fa55f6305e..d67b546483 100644 --- a/tests/Feature/ApplicationGithubAppSourceApiTest.php +++ b/tests/Feature/ApplicationGithubAppSourceApiTest.php @@ -101,3 +101,16 @@ test('PATCH rejects github_app_uuid for deploy key applications', function () { expect($this->application->refresh()->source_id)->toBe($this->oldGithubApp->id); }); + +test('PATCH rejects github_app_uuid for applications without a git repository', function (array $attributes) { + $this->application->forceFill($attributes)->saveQuietly(); + + patchApplicationSource(['github_app_uuid' => $this->newGithubApp->uuid]) + ->assertUnprocessable() + ->assertInvalid(['github_app_uuid']); + + expect($this->application->refresh()->source_id)->toBe($this->oldGithubApp->id); +})->with([ + 'inline dockerfile' => [['build_pack' => 'dockerfile', 'dockerfile' => "FROM nginx:alpine\n"]], + 'docker image' => [['build_pack' => 'dockerimage']], +]);