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.
This commit is contained in:
Andras Bacsai
2026-10-05 14:59:59 +02:00
parent 88efc0716e
commit 35c38b1dde
6 changed files with 70 additions and 3 deletions
@@ -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);
@@ -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;
@@ -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);
+5
View File
@@ -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);
@@ -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 {
@@ -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();
});