diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 8c44a54740..6815cde88b 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -433,7 +433,7 @@ class ApplicationsController extends Controller 'http_basic_auth_argon2id_time_cost' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 12, 'default' => 4, 'description' => 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.'], '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.'], @@ -632,7 +632,7 @@ class ApplicationsController extends Controller 'http_basic_auth_argon2id_time_cost' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 12, 'default' => 4, 'description' => 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.'], '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.'], @@ -831,7 +831,7 @@ class ApplicationsController extends Controller 'http_basic_auth_argon2id_time_cost' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 12, 'default' => 4, 'description' => 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.'], '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.'], @@ -1001,7 +1001,7 @@ class ApplicationsController extends Controller 'http_basic_auth_argon2id_time_cost' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 12, 'default' => 4, 'description' => 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.'], '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.'], ], @@ -1167,7 +1167,7 @@ class ApplicationsController extends Controller 'http_basic_auth_argon2id_time_cost' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 12, 'default' => 4, 'description' => 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.'], '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.'], ], @@ -1319,7 +1319,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/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/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(); +}); 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(); +});