From 35c38b1ddeff9f30f68a42802d54e3a28b8d7459 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:59:59 +0200 Subject: [PATCH 1/2] fix(database): reject blocked starts before queueing operations Check start prerequisites in database and deploy APIs and MCP controls. Return HTTP 422 for blocked API starts and restarts. Show restart notices and open UI logs only after an operation is queued. Add API regression tests for blocked ClickHouse starts and restarts. --- .../Controllers/Api/DatabasesController.php | 16 ++++++++ app/Http/Controllers/Api/DeployController.php | 5 +++ app/Livewire/Project/Database/Heading.php | 1 + app/Mcp/Tools/Control.php | 5 +++ .../project/database/heading.blade.php | 5 +-- tests/Feature/StartClickhouseVolumesTest.php | 41 +++++++++++++++++++ 6 files changed, 70 insertions(+), 3 deletions(-) diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 84486f6a9f..aa646abe32 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -3364,6 +3364,10 @@ class DatabasesController extends Controller response: 409, description: 'Another start, restart or import of this database is already in progress.', ), + new OA\Response( + response: 422, + description: 'The database cannot start yet, for example because a CA certificate is missing.', + ), ] )] public function action_deploy(Request $request) @@ -3386,6 +3390,10 @@ class DatabasesController extends Controller if (str($database->status)->contains('running')) { return response()->json(['message' => 'Database is already running.'], 400); } + $prerequisiteError = StartDatabase::prerequisiteError($database); + if ($prerequisiteError !== null) { + return response()->json(['message' => $prerequisiteError], 422); + } $reservation = StartDatabase::reserveOperation($database); if ($reservation === null) { return response()->json(['message' => ResourceStartActivity::DATABASE_OPERATION_IN_PROGRESS_MESSAGE], 409); @@ -3558,6 +3566,10 @@ class DatabasesController extends Controller response: 409, description: 'Another start, restart or import of this database is already in progress.', ), + new OA\Response( + response: 422, + description: 'The database cannot start yet, for example because a CA certificate is missing.', + ), ] )] public function action_restart(Request $request) @@ -3577,6 +3589,10 @@ class DatabasesController extends Controller $this->authorize('manage', $database); + $prerequisiteError = StartDatabase::prerequisiteError($database); + if ($prerequisiteError !== null) { + return response()->json(['message' => $prerequisiteError], 422); + } $reservation = StartDatabase::reserveOperation($database); if ($reservation === null) { return response()->json(['message' => ResourceStartActivity::DATABASE_OPERATION_IN_PROGRESS_MESSAGE], 409); diff --git a/app/Http/Controllers/Api/DeployController.php b/app/Http/Controllers/Api/DeployController.php index b58a72d6ff..e5aac43b85 100644 --- a/app/Http/Controllers/Api/DeployController.php +++ b/app/Http/Controllers/Api/DeployController.php @@ -559,6 +559,11 @@ class DeployController extends Controller } catch (AuthorizationException $e) { return ['message' => 'Unauthorized to start this database.', 'deployment_uuid' => null]; } + $prerequisiteError = StartDatabase::prerequisiteError($resource); + if ($prerequisiteError !== null) { + $message = $prerequisiteError; + break; + } $reservation = StartDatabase::reserveOperation($resource); if ($reservation === null) { $message = ResourceStartActivity::DATABASE_OPERATION_IN_PROGRESS_MESSAGE; diff --git a/app/Livewire/Project/Database/Heading.php b/app/Livewire/Project/Database/Heading.php index a7e8cfedd8..af6f7405e0 100644 --- a/app/Livewire/Project/Database/Heading.php +++ b/app/Livewire/Project/Database/Heading.php @@ -158,6 +158,7 @@ class Heading extends Component return; } $this->auditDatabaseAction('ui.database.restarted'); + $this->dispatch('info', 'Restarting database.'); $this->markDeploymentRunning($activity); $this->js("window.dispatchEvent(new CustomEvent('startdatabase'))"); $this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class); diff --git a/app/Mcp/Tools/Control.php b/app/Mcp/Tools/Control.php index aca6b46564..65ec7ca4c1 100644 --- a/app/Mcp/Tools/Control.php +++ b/app/Mcp/Tools/Control.php @@ -141,6 +141,11 @@ class Control extends Tool return ['message' => 'Database is already running.']; } + $prerequisiteError = StartDatabase::prerequisiteError($database); + if ($prerequisiteError !== null) { + throw new \RuntimeException($prerequisiteError); + } + $reservation = StartDatabase::reserveOperation($database); if ($reservation === null) { throw new \RuntimeException(ResourceStartActivity::DATABASE_OPERATION_IN_PROGRESS_MESSAGE); diff --git a/resources/views/livewire/project/database/heading.blade.php b/resources/views/livewire/project/database/heading.blade.php index abf58177c7..eec4add287 100644 --- a/resources/views/livewire/project/database/heading.blade.php +++ b/resources/views/livewire/project/database/heading.blade.php @@ -183,7 +183,7 @@ window.dispatchEvent(new CustomEvent('database-action-finished')); return; } - window.dispatchEvent(new CustomEvent('startdatabase')); + // start() opens the log dialog only when the start was queued. try { await $wire.$call('start'); } finally { @@ -197,8 +197,7 @@ return; } window.dispatchEvent(new CustomEvent('database-busy')); - $wire.$dispatch('info', 'Restarting database.'); - window.dispatchEvent(new CustomEvent('startdatabase')); + // restart() opens the log dialog only when the restart was queued. try { await $wire.$call('restart'); } finally { diff --git a/tests/Feature/StartClickhouseVolumesTest.php b/tests/Feature/StartClickhouseVolumesTest.php index faa13a0944..8193cdb95e 100644 --- a/tests/Feature/StartClickhouseVolumesTest.php +++ b/tests/Feature/StartClickhouseVolumesTest.php @@ -19,6 +19,7 @@ use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Process; use Illuminate\Support\Facades\Queue; +use Illuminate\Support\Str; use Livewire\Livewire; use Spatie\Activitylog\Models\Activity; use Symfony\Component\Yaml\Yaml; @@ -174,3 +175,43 @@ it('reads the data mount with sudo on a non-root server', function () { expect($database->fresh()->anonymousDataVolume())->toBe('3f1c0ffee') ->and(ranRemoteCommandContaining("sudo docker inspect --format '{{range .Mounts}}{{if eq .Destination \"/var/lib/clickhouse\"}}{{.Type}} {{.Name}}{{end}}{{end}}' '{$database->uuid}' 2>/dev/null || sudo true"))->toBeTrue(); }); + +it('rejects an API start or restart of a blocked database at once', function (string $method, string $path) { + $database = create_standalone_clickhouse($this->environment->id, $this->destination); + fakeClickhouseDataMount('3f1c0ffee'); + $plainToken = Str::random(40); + $token = $this->owner->tokens()->create([ + 'name' => 'clickhouse-test', + 'token' => hash('sha256', $plainToken), + 'abilities' => ['*'], + 'team_id' => $this->team->id, + ]); + + $response = $this->withHeader('Authorization', 'Bearer '.$token->getKey().'|'.$plainToken) + ->json($method, str_replace('{uuid}', $database->uuid, $path)); + + $response->assertStatus(422); + expect($response->json('message'))->toContain('unnamed Docker volume 3f1c0ffee'); + Queue::assertNothingPushed(); +})->with([ + 'start' => ['POST', '/api/v1/databases/{uuid}/start'], + 'restart' => ['POST', '/api/v1/databases/{uuid}/restart'], +]); + +it('reports a blocked database in the deploy API', function () { + $database = create_standalone_clickhouse($this->environment->id, $this->destination); + fakeClickhouseDataMount('3f1c0ffee'); + $plainToken = Str::random(40); + $token = $this->owner->tokens()->create([ + 'name' => 'clickhouse-test', + 'token' => hash('sha256', $plainToken), + 'abilities' => ['*'], + 'team_id' => $this->team->id, + ]); + + $response = $this->withHeader('Authorization', 'Bearer '.$token->getKey().'|'.$plainToken) + ->postJson('/api/v1/deploy', ['uuid' => $database->uuid]); + + expect($response->json('deployments.0.message'))->toContain('unnamed Docker volume 3f1c0ffee'); + Queue::assertNothingPushed(); +}); From 01ea05959ea457e5ddb14d313a59de91bf0ec1e5 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:09:25 +0200 Subject: [PATCH 2/2] fix(api): skip domain generation for Docker Compose applications --- .../Api/ApplicationsController.php | 13 ++++++------ ...licationComposeInjectionValidationTest.php | 20 +++++++++++++++++++ .../ApplicationPublicApiGitRepositoryTest.php | 6 ++++++ 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 238a318ce3..a611a60f4f 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -428,7 +428,7 @@ class ApplicationsController extends Controller 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], 'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'], 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], - 'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'], + 'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Ignored for the dockercompose build pack, which uses docker_compose_domains. Default: true.'], 'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'], 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the application.'], 'is_preserve_repository_enabled' => ['type' => 'boolean', 'default' => false, 'description' => 'Preserve repository during deployment.'], @@ -623,7 +623,7 @@ class ApplicationsController extends Controller 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], 'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'], 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], - 'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'], + 'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Ignored for the dockercompose build pack, which uses docker_compose_domains. Default: true.'], 'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'], 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the application.'], 'is_preserve_repository_enabled' => ['type' => 'boolean', 'default' => false, 'description' => 'Preserve repository during deployment.'], @@ -818,7 +818,7 @@ class ApplicationsController extends Controller 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], 'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'], 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], - 'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'], + 'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Ignored for the dockercompose build pack, which uses docker_compose_domains. Default: true.'], 'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'], 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the application.'], 'is_preserve_repository_enabled' => ['type' => 'boolean', 'default' => false, 'description' => 'Preserve repository during deployment.'], @@ -984,7 +984,7 @@ class ApplicationsController extends Controller 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], 'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'], 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], - 'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'], + 'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Ignored for the dockercompose build pack, which uses docker_compose_domains. Default: true.'], 'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'], 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the application.'], ], @@ -1146,7 +1146,7 @@ class ApplicationsController extends Controller 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], 'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'], 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], - 'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'], + 'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Ignored for the dockercompose build pack, which uses docker_compose_domains. Default: true.'], 'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'], 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the application.'], ], @@ -1296,7 +1296,8 @@ class ApplicationsController extends Controller $fqdn = ValidationPatterns::normalizeApplicationDomains($request->domains); $request->offsetSet('domains', $fqdn); } - $autogenerateDomain = $request->boolean('autogenerate_domain', true); + // A Docker Compose application uses per-service domains; a top-level domain is never routed. + $autogenerateDomain = $request->boolean('autogenerate_domain', true) && $request->build_pack !== 'dockercompose'; $instantDeploy = $request->instant_deploy; $githubAppUuid = $request->github_app_uuid; $useBuildServer = $request->use_build_server; diff --git a/tests/Feature/ApplicationComposeInjectionValidationTest.php b/tests/Feature/ApplicationComposeInjectionValidationTest.php index 4db2d25dad..3af9f50506 100644 --- a/tests/Feature/ApplicationComposeInjectionValidationTest.php +++ b/tests/Feature/ApplicationComposeInjectionValidationTest.php @@ -266,6 +266,26 @@ test('API create accepts a safe docker_compose_raw', function () { ->assertCreated(); }); +test('API create does not generate a domain for a docker compose application', function () { + Queue::fake(); + + $response = $this->withHeaders(['Authorization' => 'Bearer '.$this->token]) + ->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/compose-app', + 'git_branch' => 'main', + 'build_pack' => 'dockercompose', + 'ports_exposes' => '80', + 'autogenerate_domain' => true, + 'docker_compose_raw' => SAFE_APPLICATION_COMPOSE, + ]) + ->assertCreated(); + + expect(Application::query()->where('uuid', $response->json('uuid'))->value('fqdn'))->toBeNull(); +}); + test('API update does not accept docker_compose_raw', function () { $this->withHeaders(['Authorization' => 'Bearer '.$this->token]) ->patchJson("/api/v1/applications/{$this->application->uuid}", [ diff --git a/tests/Feature/ApplicationPublicApiGitRepositoryTest.php b/tests/Feature/ApplicationPublicApiGitRepositoryTest.php index eb105e6764..00f41b2a6e 100644 --- a/tests/Feature/ApplicationPublicApiGitRepositoryTest.php +++ b/tests/Feature/ApplicationPublicApiGitRepositoryTest.php @@ -86,3 +86,9 @@ test('public github repositories are stored as owner and repository with the pub ->and($application->source_type)->toBe(GithubApp::class) ->and($application->source_id)->toBe(0); }); + +test('a public application gets a generated domain by default', function () { + $application = createPublicGitApplication(['autogenerate_domain' => true]); + + expect($application->fqdn)->not->toBeEmpty(); +});