fix(api): accept dockerfile when updating an application

Closes #12016
This commit is contained in:
Andras Bacsai
2026-10-15 23:05:19 +02:00
parent 04274a3c16
commit 25b554996b
3 changed files with 114 additions and 2 deletions
@@ -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', [
@@ -0,0 +1,73 @@
<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::unguarded(fn () => 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();
});
@@ -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']],
]);