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] 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(); +});