mirror of
https://github.com/coollabsio/coolify.git
synced 2026-10-29 01:54:37 +08:00
A queued start or restart whose reservation expired no longer runs. Failed queued starts are recorded as failed activities and release the reservation.
701 lines
30 KiB
PHP
701 lines
30 KiB
PHP
<?php
|
|
|
|
use App\Actions\Database\RestartDatabase;
|
|
use App\Actions\Database\StartDatabase;
|
|
use App\Actions\Database\StartPostgresql;
|
|
use App\Actions\Database\StopDatabase;
|
|
use App\Enums\ActivityTypes;
|
|
use App\Enums\ProcessStatus;
|
|
use App\Events\DatabaseStatusChanged;
|
|
use App\Jobs\DatabaseStartJob;
|
|
use App\Livewire\Project\Database\Heading;
|
|
use App\Models\Environment;
|
|
use App\Models\InstanceSettings;
|
|
use App\Models\Project;
|
|
use App\Models\Server;
|
|
use App\Models\StandaloneDocker;
|
|
use App\Models\StandalonePostgresql;
|
|
use App\Models\Team;
|
|
use App\Models\User;
|
|
use App\Support\DatabaseOperationReservation;
|
|
use App\Support\ResourceStartActivity;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Bus;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Event;
|
|
use Illuminate\Support\Facades\Queue;
|
|
use Illuminate\Support\Once;
|
|
use Illuminate\Support\Str;
|
|
use Livewire\Livewire;
|
|
use Lorisleiva\Actions\Decorators\JobDecorator;
|
|
use Spatie\Activitylog\Models\Activity;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
beforeEach(function () {
|
|
InstanceSettings::forceCreate(['id' => 0]);
|
|
config()->set('cache.default', 'array');
|
|
Server::flushIdentityMap();
|
|
|
|
$this->team = Team::factory()->create();
|
|
$this->user = User::factory()->create();
|
|
$this->team->members()->attach($this->user, ['role' => 'owner']);
|
|
session(['currentTeam' => $this->team]);
|
|
$this->bearerToken = $this->user->createToken('test-token', ['*'])->plainTextToken;
|
|
|
|
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
|
$this->server->settings()->update([
|
|
'is_reachable' => true,
|
|
'is_usable' => true,
|
|
'force_disabled' => false,
|
|
]);
|
|
$this->destination = StandaloneDocker::firstOrCreate(
|
|
['server_id' => $this->server->id, 'network' => 'coolify'],
|
|
['uuid' => (string) Str::uuid(), 'name' => 'docker']
|
|
);
|
|
$project = Project::factory()->create(['team_id' => $this->team->id]);
|
|
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
|
$this->database = StandalonePostgresql::create([
|
|
'uuid' => (string) Str::uuid(),
|
|
'name' => 'db',
|
|
'postgres_user' => 'postgres',
|
|
'postgres_password' => 'password',
|
|
'postgres_db' => 'db',
|
|
'image' => 'postgres:17',
|
|
'status' => 'exited',
|
|
'environment_id' => $environment->id,
|
|
'destination_id' => $this->destination->id,
|
|
'destination_type' => $this->destination->getMorphClass(),
|
|
]);
|
|
});
|
|
|
|
function concurrentStartActivity(string $databaseUuid, int $teamId, ProcessStatus $status = ProcessStatus::QUEUED, int $minutesSinceLastUpdate = 0): Activity
|
|
{
|
|
$activity = activity()
|
|
->withProperties([
|
|
'type' => ActivityTypes::INLINE->value,
|
|
'type_uuid' => $databaseUuid,
|
|
'status' => $status->value,
|
|
'team_id' => $teamId,
|
|
'operation' => ResourceStartActivity::DATABASE_START_OPERATION,
|
|
])
|
|
->event(ActivityTypes::INLINE->value)
|
|
->log('[]');
|
|
|
|
Activity::query()->whereKey($activity->id)->update([
|
|
'created_at' => now()->subMinutes($minutesSinceLastUpdate),
|
|
'updated_at' => now()->subMinutes($minutesSinceLastUpdate),
|
|
]);
|
|
|
|
return $activity->refresh();
|
|
}
|
|
|
|
function databaseStartActivityCount(string $databaseUuid): int
|
|
{
|
|
return Activity::query()
|
|
->where('properties->type_uuid', $databaseUuid)
|
|
->where('properties->operation', ResourceStartActivity::DATABASE_START_OPERATION)
|
|
->count();
|
|
}
|
|
|
|
function databaseStartApiHeaders(string $token): array
|
|
{
|
|
return ['Authorization' => 'Bearer '.$token, 'Content-Type' => 'application/json'];
|
|
}
|
|
|
|
describe('start and restart guards', function () {
|
|
it('does not queue a second start from the UI while a start is queued', function () {
|
|
Bus::fake();
|
|
concurrentStartActivity($this->database->uuid, $this->team->id);
|
|
|
|
Livewire::actingAs($this->user)->test(Heading::class, ['database' => $this->database])
|
|
->call('start')
|
|
->assertDispatched('error', ResourceStartActivity::DATABASE_OPERATION_IN_PROGRESS_MESSAGE);
|
|
|
|
Bus::assertNotDispatched(DatabaseStartJob::class);
|
|
expect(databaseStartActivityCount($this->database->uuid))->toBe(1);
|
|
});
|
|
|
|
it('does not stop the database on a UI restart while a start is in progress', function () {
|
|
Bus::fake();
|
|
StopDatabase::shouldRun()->never();
|
|
concurrentStartActivity($this->database->uuid, $this->team->id, ProcessStatus::IN_PROGRESS);
|
|
|
|
Livewire::actingAs($this->user)->test(Heading::class, ['database' => $this->database])
|
|
->call('restart')
|
|
->assertDispatched('error', ResourceStartActivity::DATABASE_OPERATION_IN_PROGRESS_MESSAGE);
|
|
|
|
Bus::assertNotDispatched(DatabaseStartJob::class);
|
|
expect(databaseStartActivityCount($this->database->uuid))->toBe(1);
|
|
});
|
|
|
|
it('rejects an API start while a start is queued', function () {
|
|
Queue::fake();
|
|
concurrentStartActivity($this->database->uuid, $this->team->id);
|
|
|
|
$this->withHeaders(databaseStartApiHeaders($this->bearerToken))
|
|
->postJson("/api/v1/databases/{$this->database->uuid}/start")
|
|
->assertStatus(409)
|
|
->assertJson(['message' => ResourceStartActivity::DATABASE_OPERATION_IN_PROGRESS_MESSAGE]);
|
|
|
|
StartDatabase::assertNotPushed();
|
|
});
|
|
|
|
it('rejects an API restart while a start is queued', function () {
|
|
Queue::fake();
|
|
concurrentStartActivity($this->database->uuid, $this->team->id);
|
|
|
|
$this->withHeaders(databaseStartApiHeaders($this->bearerToken))
|
|
->postJson("/api/v1/databases/{$this->database->uuid}/restart")
|
|
->assertStatus(409)
|
|
->assertJson(['message' => ResourceStartActivity::DATABASE_OPERATION_IN_PROGRESS_MESSAGE]);
|
|
|
|
RestartDatabase::assertNotPushed();
|
|
});
|
|
|
|
it('still queues an API start when no start is running', function () {
|
|
Queue::fake();
|
|
concurrentStartActivity($this->database->uuid, $this->team->id, ProcessStatus::FINISHED);
|
|
|
|
$this->withHeaders(databaseStartApiHeaders($this->bearerToken))
|
|
->postJson("/api/v1/databases/{$this->database->uuid}/start")
|
|
->assertOk();
|
|
|
|
StartDatabase::assertPushed();
|
|
});
|
|
|
|
it('does not reveal a running start of another team database through the API', function () {
|
|
Queue::fake();
|
|
concurrentStartActivity($this->database->uuid, $this->team->id);
|
|
$otherTeam = Team::factory()->create();
|
|
$otherUser = User::factory()->create();
|
|
$otherTeam->members()->attach($otherUser, ['role' => 'owner']);
|
|
session(['currentTeam' => $otherTeam]);
|
|
$otherToken = $otherUser->createToken('other-token', ['*'])->plainTextToken;
|
|
|
|
$this->withHeaders(databaseStartApiHeaders($otherToken))
|
|
->postJson("/api/v1/databases/{$this->database->uuid}/start")
|
|
->assertNotFound();
|
|
|
|
StartDatabase::assertNotPushed();
|
|
});
|
|
|
|
it('refuses to queue a second start in the action itself', function () {
|
|
Bus::fake();
|
|
concurrentStartActivity($this->database->uuid, $this->team->id);
|
|
|
|
expect(StartDatabase::run($this->database))->toBe(ResourceStartActivity::DATABASE_OPERATION_IN_PROGRESS_MESSAGE);
|
|
|
|
Bus::assertNotDispatched(DatabaseStartJob::class);
|
|
expect(databaseStartActivityCount($this->database->uuid))->toBe(1);
|
|
});
|
|
|
|
it('does not stop the database when a queued restart action finds a running start', function () {
|
|
Bus::fake();
|
|
StopDatabase::shouldRun()->never();
|
|
concurrentStartActivity($this->database->uuid, $this->team->id, ProcessStatus::IN_PROGRESS);
|
|
|
|
expect(RestartDatabase::run($this->database))->toBe(ResourceStartActivity::DATABASE_OPERATION_IN_PROGRESS_MESSAGE);
|
|
});
|
|
|
|
it('lets a new start replace a stale queued start and marks the stale one as failed', function () {
|
|
Bus::fake();
|
|
$stale = concurrentStartActivity($this->database->uuid, $this->team->id, minutesSinceLastUpdate: 11);
|
|
|
|
expect(StartDatabase::run($this->database))->toBeInstanceOf(Activity::class);
|
|
|
|
Bus::assertDispatched(DatabaseStartJob::class);
|
|
$stale->refresh();
|
|
expect(data_get($stale, 'properties.status'))->toBe(ProcessStatus::ERROR->value)
|
|
->and(data_get($stale, 'properties.error'))->toBe(ResourceStartActivity::STALE_MESSAGE);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Run the queued StartDatabase/RestartDatabase action that the API pushed to the fake queue,
|
|
* with the same parameters (including the reservation token) that the worker would use.
|
|
*/
|
|
function runPushedDatabaseAction(string $actionClass): mixed
|
|
{
|
|
$job = Queue::pushed(JobDecorator::class, fn (JobDecorator $job) => $job->getAction() instanceof $actionClass)->last();
|
|
expect($job)->not->toBeNull();
|
|
|
|
return $job->handle();
|
|
}
|
|
|
|
function databaseOperationReserved(string $databaseUuid): bool
|
|
{
|
|
return Cache::has(DatabaseOperationReservation::key($databaseUuid));
|
|
}
|
|
|
|
describe('pending operation reservation', function () {
|
|
it('rejects a second API start while the first start is still in the queue', function () {
|
|
Queue::fake();
|
|
|
|
$this->withHeaders(databaseStartApiHeaders($this->bearerToken))
|
|
->postJson("/api/v1/databases/{$this->database->uuid}/start")
|
|
->assertOk()
|
|
->assertJson(['message' => 'Database starting request queued.']);
|
|
|
|
// The queued action did not run yet, so no activity exists. The reservation must block.
|
|
expect(databaseStartActivityCount($this->database->uuid))->toBe(0);
|
|
|
|
$this->withHeaders(databaseStartApiHeaders($this->bearerToken))
|
|
->postJson("/api/v1/databases/{$this->database->uuid}/start")
|
|
->assertStatus(409)
|
|
->assertJson(['message' => ResourceStartActivity::DATABASE_OPERATION_IN_PROGRESS_MESSAGE]);
|
|
|
|
StartDatabase::assertPushed(1);
|
|
});
|
|
|
|
it('rejects an API restart while an API start is still in the queue', function () {
|
|
Queue::fake();
|
|
|
|
$this->withHeaders(databaseStartApiHeaders($this->bearerToken))
|
|
->postJson("/api/v1/databases/{$this->database->uuid}/start")
|
|
->assertOk();
|
|
|
|
$this->withHeaders(databaseStartApiHeaders($this->bearerToken))
|
|
->postJson("/api/v1/databases/{$this->database->uuid}/restart")
|
|
->assertStatus(409)
|
|
->assertJson(['message' => ResourceStartActivity::DATABASE_OPERATION_IN_PROGRESS_MESSAGE]);
|
|
|
|
RestartDatabase::assertNotPushed();
|
|
});
|
|
|
|
it('rejects a second API restart and an API start while the first restart is still in the queue', function () {
|
|
Queue::fake();
|
|
|
|
$this->withHeaders(databaseStartApiHeaders($this->bearerToken))
|
|
->postJson("/api/v1/databases/{$this->database->uuid}/restart")
|
|
->assertOk()
|
|
->assertJson(['message' => 'Database restarting request queued.']);
|
|
|
|
$this->withHeaders(databaseStartApiHeaders($this->bearerToken))
|
|
->postJson("/api/v1/databases/{$this->database->uuid}/restart")
|
|
->assertStatus(409)
|
|
->assertJson(['message' => ResourceStartActivity::DATABASE_OPERATION_IN_PROGRESS_MESSAGE]);
|
|
|
|
$this->withHeaders(databaseStartApiHeaders($this->bearerToken))
|
|
->postJson("/api/v1/databases/{$this->database->uuid}/start")
|
|
->assertStatus(409);
|
|
|
|
RestartDatabase::assertPushed(1);
|
|
StartDatabase::assertNotPushed();
|
|
});
|
|
|
|
it('hands the reservation over to the start activity and allows a new start after it finished', function () {
|
|
Queue::fake();
|
|
|
|
$this->withHeaders(databaseStartApiHeaders($this->bearerToken))
|
|
->postJson("/api/v1/databases/{$this->database->uuid}/start")
|
|
->assertOk();
|
|
expect(databaseOperationReserved($this->database->uuid))->toBeTrue();
|
|
|
|
$activity = runPushedDatabaseAction(StartDatabase::class);
|
|
|
|
expect($activity)->toBeInstanceOf(Activity::class)
|
|
->and(databaseOperationReserved($this->database->uuid))->toBeFalse();
|
|
Queue::assertPushed(DatabaseStartJob::class, 1);
|
|
|
|
// The queued start activity now blocks.
|
|
$this->withHeaders(databaseStartApiHeaders($this->bearerToken))
|
|
->postJson("/api/v1/databases/{$this->database->uuid}/start")
|
|
->assertStatus(409);
|
|
|
|
$activity->properties = $activity->properties->merge(['status' => ProcessStatus::FINISHED->value]);
|
|
$activity->save();
|
|
|
|
$this->withHeaders(databaseStartApiHeaders($this->bearerToken))
|
|
->postJson("/api/v1/databases/{$this->database->uuid}/start")
|
|
->assertOk();
|
|
});
|
|
|
|
it('stops and starts in the queued restart action and then releases the reservation', function () {
|
|
Queue::fake();
|
|
StopDatabase::shouldRun()->once();
|
|
|
|
$this->withHeaders(databaseStartApiHeaders($this->bearerToken))
|
|
->postJson("/api/v1/databases/{$this->database->uuid}/restart")
|
|
->assertOk();
|
|
|
|
$activity = runPushedDatabaseAction(RestartDatabase::class);
|
|
|
|
expect($activity)->toBeInstanceOf(Activity::class)
|
|
->and(databaseOperationReserved($this->database->uuid))->toBeFalse()
|
|
->and(databaseStartActivityCount($this->database->uuid))->toBe(1);
|
|
});
|
|
|
|
it('releases the reservation when the queued action does not start the database', function () {
|
|
Queue::fake();
|
|
|
|
$this->withHeaders(databaseStartApiHeaders($this->bearerToken))
|
|
->postJson("/api/v1/databases/{$this->database->uuid}/start")
|
|
->assertOk();
|
|
|
|
// For example a start that another path created without a reservation.
|
|
concurrentStartActivity($this->database->uuid, $this->team->id);
|
|
|
|
expect(runPushedDatabaseAction(StartDatabase::class))->toBe(ResourceStartActivity::DATABASE_OPERATION_IN_PROGRESS_MESSAGE)
|
|
->and(databaseOperationReserved($this->database->uuid))->toBeFalse();
|
|
Queue::assertNotPushed(DatabaseStartJob::class);
|
|
});
|
|
|
|
it('lets a new start through after a lost reservation expires', function () {
|
|
Queue::fake();
|
|
|
|
$this->withHeaders(databaseStartApiHeaders($this->bearerToken))
|
|
->postJson("/api/v1/databases/{$this->database->uuid}/start")
|
|
->assertOk();
|
|
|
|
$this->travel(DatabaseOperationReservation::TTL_SECONDS - 1)->seconds();
|
|
$this->withHeaders(databaseStartApiHeaders($this->bearerToken))
|
|
->postJson("/api/v1/databases/{$this->database->uuid}/start")
|
|
->assertStatus(409);
|
|
|
|
$this->travel(2)->seconds();
|
|
$this->withHeaders(databaseStartApiHeaders($this->bearerToken))
|
|
->postJson("/api/v1/databases/{$this->database->uuid}/start")
|
|
->assertOk();
|
|
|
|
StartDatabase::assertPushed(2);
|
|
});
|
|
|
|
it('does not let an expired reservation start after a newer request took the reservation', function () {
|
|
Queue::fake();
|
|
|
|
$this->withHeaders(databaseStartApiHeaders($this->bearerToken))
|
|
->postJson("/api/v1/databases/{$this->database->uuid}/start")
|
|
->assertOk();
|
|
$firstJob = Queue::pushed(JobDecorator::class)->first();
|
|
|
|
$this->travel(DatabaseOperationReservation::TTL_SECONDS + 1)->seconds();
|
|
$this->withHeaders(databaseStartApiHeaders($this->bearerToken))
|
|
->postJson("/api/v1/databases/{$this->database->uuid}/start")
|
|
->assertOk();
|
|
|
|
expect($firstJob->handle())->toBe(ResourceStartActivity::DATABASE_OPERATION_IN_PROGRESS_MESSAGE)
|
|
->and(databaseOperationReserved($this->database->uuid))->toBeTrue()
|
|
->and(databaseStartActivityCount($this->database->uuid))->toBe(0);
|
|
});
|
|
|
|
it('fails an expired queued start instead of running it late', function () {
|
|
Queue::fake();
|
|
Event::fake([DatabaseStatusChanged::class]);
|
|
|
|
$this->withHeaders(databaseStartApiHeaders($this->bearerToken))
|
|
->postJson("/api/v1/databases/{$this->database->uuid}/start")
|
|
->assertOk();
|
|
|
|
$this->travel(DatabaseOperationReservation::TTL_SECONDS + 1)->seconds();
|
|
|
|
expect(runPushedDatabaseAction(StartDatabase::class))->toBe(DatabaseOperationReservation::EXPIRED_MESSAGE)
|
|
->and(databaseOperationReserved($this->database->uuid))->toBeFalse();
|
|
Queue::assertNotPushed(DatabaseStartJob::class);
|
|
Event::assertDispatched(DatabaseStatusChanged::class);
|
|
|
|
$failed = Activity::query()->where('properties->type_uuid', $this->database->uuid)->sole();
|
|
expect(data_get($failed, 'properties.status'))->toBe(ProcessStatus::ERROR->value)
|
|
->and(data_get($failed, 'properties.operation'))->toBe(ResourceStartActivity::DATABASE_START_OPERATION)
|
|
->and(data_get($failed, 'properties.error'))->toBe(DatabaseOperationReservation::EXPIRED_MESSAGE)
|
|
->and(ResourceStartActivity::latestRunning($this->database->uuid))->toBeNull();
|
|
|
|
$this->withHeaders(databaseStartApiHeaders($this->bearerToken))
|
|
->postJson("/api/v1/databases/{$this->database->uuid}/start")
|
|
->assertOk();
|
|
});
|
|
|
|
it('does not start the database a second time when an expired queued start runs after a newer start finished', function () {
|
|
Queue::fake();
|
|
|
|
$this->withHeaders(databaseStartApiHeaders($this->bearerToken))
|
|
->postJson("/api/v1/databases/{$this->database->uuid}/start")
|
|
->assertOk();
|
|
|
|
$this->travel(DatabaseOperationReservation::TTL_SECONDS + 1)->seconds();
|
|
$uiActivity = StartDatabase::run($this->database);
|
|
expect($uiActivity)->toBeInstanceOf(Activity::class);
|
|
$uiActivity->properties = $uiActivity->properties->merge(['status' => ProcessStatus::FINISHED->value]);
|
|
$uiActivity->save();
|
|
|
|
expect(runPushedDatabaseAction(StartDatabase::class))->toBe(DatabaseOperationReservation::EXPIRED_MESSAGE);
|
|
Queue::assertPushed(DatabaseStartJob::class, 1);
|
|
});
|
|
|
|
it('fails an expired queued restart without stopping the database', function () {
|
|
Queue::fake();
|
|
StopDatabase::shouldRun()->never();
|
|
|
|
$this->withHeaders(databaseStartApiHeaders($this->bearerToken))
|
|
->postJson("/api/v1/databases/{$this->database->uuid}/restart")
|
|
->assertOk();
|
|
|
|
$this->travel(DatabaseOperationReservation::TTL_SECONDS + 1)->seconds();
|
|
|
|
expect(runPushedDatabaseAction(RestartDatabase::class))->toBe(DatabaseOperationReservation::EXPIRED_MESSAGE);
|
|
Queue::assertNotPushed(DatabaseStartJob::class);
|
|
$failed = Activity::query()->where('properties->type_uuid', $this->database->uuid)->sole();
|
|
expect(data_get($failed, 'properties.error'))->toBe(DatabaseOperationReservation::EXPIRED_MESSAGE);
|
|
});
|
|
|
|
it('keeps the reservation of a queued restart while the database stops', function () {
|
|
Queue::fake();
|
|
|
|
$this->withHeaders(databaseStartApiHeaders($this->bearerToken))
|
|
->postJson("/api/v1/databases/{$this->database->uuid}/restart")
|
|
->assertOk();
|
|
|
|
$this->travel(DatabaseOperationReservation::TTL_SECONDS - 10)->seconds();
|
|
StopDatabase::shouldRun()->once()->andReturnUsing(function () {
|
|
$this->travel(20)->seconds();
|
|
|
|
return 'Database stopped successfully';
|
|
});
|
|
|
|
expect(runPushedDatabaseAction(RestartDatabase::class))->toBeInstanceOf(Activity::class);
|
|
Queue::assertPushed(DatabaseStartJob::class, 1);
|
|
});
|
|
|
|
it('records a failed start when the queued action cannot start the database', function () {
|
|
Queue::fake();
|
|
|
|
$this->withHeaders(databaseStartApiHeaders($this->bearerToken))
|
|
->postJson("/api/v1/databases/{$this->database->uuid}/start")
|
|
->assertOk();
|
|
|
|
$job = Queue::pushed(JobDecorator::class, fn (JobDecorator $job) => $job->getAction() instanceof StartDatabase)->last();
|
|
$job->getParameters()[0]->destination->server->settings->is_reachable = false;
|
|
|
|
expect($job->handle())->toBe('Server is not functional')
|
|
->and(databaseOperationReserved($this->database->uuid))->toBeFalse();
|
|
$failed = Activity::query()->where('properties->type_uuid', $this->database->uuid)->sole();
|
|
expect(data_get($failed, 'properties.status'))->toBe(ProcessStatus::ERROR->value)
|
|
->and(data_get($failed, 'properties.error'))->toBe('Server is not functional');
|
|
});
|
|
|
|
it('records a failed start when the queued restart action throws', function () {
|
|
Queue::fake();
|
|
StopDatabase::shouldRun()->once()->andThrow(new RuntimeException('ssh: connection refused'));
|
|
|
|
$this->withHeaders(databaseStartApiHeaders($this->bearerToken))
|
|
->postJson("/api/v1/databases/{$this->database->uuid}/restart")
|
|
->assertOk();
|
|
|
|
expect(fn () => runPushedDatabaseAction(RestartDatabase::class))->toThrow(RuntimeException::class);
|
|
|
|
expect(databaseOperationReserved($this->database->uuid))->toBeFalse();
|
|
$failed = Activity::query()->where('properties->type_uuid', $this->database->uuid)->sole();
|
|
expect(data_get($failed, 'properties.status'))->toBe(ProcessStatus::ERROR->value)
|
|
->and(data_get($failed, 'properties.error'))->toBe('Database restart failed.');
|
|
|
|
$this->withHeaders(databaseStartApiHeaders($this->bearerToken))
|
|
->postJson("/api/v1/databases/{$this->database->uuid}/start")
|
|
->assertOk();
|
|
});
|
|
|
|
it('does not record a second failed start when the queued start already failed its own activity', function () {
|
|
Queue::fake();
|
|
|
|
$this->withHeaders(databaseStartApiHeaders($this->bearerToken))
|
|
->postJson("/api/v1/databases/{$this->database->uuid}/start")
|
|
->assertOk();
|
|
|
|
$job = Queue::pushed(JobDecorator::class, fn (JobDecorator $job) => $job->getAction() instanceof StartDatabase)->last();
|
|
$queue = Queue::partialMock();
|
|
$queue->shouldReceive('connection')->andReturnSelf();
|
|
$queue->shouldReceive('push', 'pushOn')->andThrow(new RuntimeException('redis down'));
|
|
|
|
expect(fn () => $job->handle())->toThrow(RuntimeException::class, 'redis down');
|
|
|
|
$activities = Activity::query()->where('properties->type_uuid', $this->database->uuid)->get();
|
|
expect($activities)->toHaveCount(1)
|
|
->and(data_get($activities->first(), 'properties.error'))->toBe('Database start could not be queued.');
|
|
});
|
|
|
|
it('does not start from the UI while an API start is still in the queue', function () {
|
|
Queue::fake();
|
|
|
|
$this->withHeaders(databaseStartApiHeaders($this->bearerToken))
|
|
->postJson("/api/v1/databases/{$this->database->uuid}/start")
|
|
->assertOk();
|
|
|
|
Livewire::actingAs($this->user)->test(Heading::class, ['database' => $this->database])
|
|
->call('start')
|
|
->assertDispatched('error', ResourceStartActivity::DATABASE_OPERATION_IN_PROGRESS_MESSAGE);
|
|
|
|
Queue::assertNotPushed(DatabaseStartJob::class);
|
|
expect(databaseStartActivityCount($this->database->uuid))->toBe(0);
|
|
});
|
|
|
|
it('does not stop the database on a UI restart while an API restart is still in the queue', function () {
|
|
Queue::fake();
|
|
StopDatabase::shouldRun()->never();
|
|
|
|
$this->withHeaders(databaseStartApiHeaders($this->bearerToken))
|
|
->postJson("/api/v1/databases/{$this->database->uuid}/restart")
|
|
->assertOk();
|
|
|
|
Livewire::actingAs($this->user)->test(Heading::class, ['database' => $this->database])
|
|
->call('restart')
|
|
->assertDispatched('error', ResourceStartActivity::DATABASE_OPERATION_IN_PROGRESS_MESSAGE);
|
|
});
|
|
|
|
it('releases its own reservation after a UI start created the activity', function () {
|
|
Bus::fake();
|
|
|
|
Livewire::actingAs($this->user)->test(Heading::class, ['database' => $this->database])
|
|
->call('start')
|
|
->assertNotDispatched('error');
|
|
|
|
Bus::assertDispatched(DatabaseStartJob::class);
|
|
expect(databaseOperationReserved($this->database->uuid))->toBeFalse()
|
|
->and(databaseStartActivityCount($this->database->uuid))->toBe(1);
|
|
});
|
|
|
|
it('rejects an API start while a UI start holds the reservation', function () {
|
|
Queue::fake();
|
|
$token = DatabaseOperationReservation::acquire($this->database->uuid);
|
|
expect($token)->toBeString();
|
|
|
|
$this->withHeaders(databaseStartApiHeaders($this->bearerToken))
|
|
->postJson("/api/v1/databases/{$this->database->uuid}/start")
|
|
->assertStatus(409);
|
|
|
|
DatabaseOperationReservation::release($this->database->uuid, $token);
|
|
|
|
$this->withHeaders(databaseStartApiHeaders($this->bearerToken))
|
|
->postJson("/api/v1/databases/{$this->database->uuid}/start")
|
|
->assertOk();
|
|
});
|
|
|
|
it('does not release a reservation that another request holds', function () {
|
|
$token = DatabaseOperationReservation::acquire($this->database->uuid);
|
|
|
|
DatabaseOperationReservation::release($this->database->uuid, 'another-token');
|
|
|
|
expect(databaseOperationReserved($this->database->uuid))->toBeTrue()
|
|
->and(DatabaseOperationReservation::acquire($this->database->uuid))->toBeNull();
|
|
DatabaseOperationReservation::release($this->database->uuid, $token);
|
|
expect(databaseOperationReserved($this->database->uuid))->toBeFalse();
|
|
});
|
|
|
|
it('does not queue a database start through the deploy API while a start is still in the queue', function () {
|
|
Queue::fake();
|
|
|
|
$this->withHeaders(databaseStartApiHeaders($this->bearerToken))
|
|
->postJson("/api/v1/databases/{$this->database->uuid}/start")
|
|
->assertOk();
|
|
|
|
$this->withHeaders(databaseStartApiHeaders($this->bearerToken))
|
|
->postJson('/api/v1/deploy', ['uuid' => $this->database->uuid])
|
|
->assertOk()
|
|
->assertJsonPath('deployments.0.message', ResourceStartActivity::DATABASE_OPERATION_IN_PROGRESS_MESSAGE);
|
|
|
|
StartDatabase::assertPushed(1);
|
|
});
|
|
|
|
it('does not queue a database start through the MCP control tool while a start is still in the queue', function (string $action) {
|
|
Queue::fake();
|
|
InstanceSettings::query()->whereKey(0)->update(['is_mcp_server_enabled' => true]);
|
|
$this->team->update(['is_mcp_server_enabled' => true]);
|
|
Once::flush();
|
|
$mcpToken = $this->user->createToken('mcp-deploy', ['read', 'deploy'])->plainTextToken;
|
|
|
|
$this->withHeaders(databaseStartApiHeaders($this->bearerToken))
|
|
->postJson("/api/v1/databases/{$this->database->uuid}/start")
|
|
->assertOk();
|
|
|
|
$response = $this->withHeaders([
|
|
'Content-Type' => 'application/json',
|
|
'Accept' => 'application/json, text/event-stream',
|
|
'Authorization' => 'Bearer '.$mcpToken,
|
|
])->postJson('/mcp', [
|
|
'jsonrpc' => '2.0',
|
|
'id' => 1,
|
|
'method' => 'tools/call',
|
|
'params' => [
|
|
'name' => 'control',
|
|
'arguments' => (object) [
|
|
'resource' => 'database',
|
|
'action' => $action,
|
|
'uuid' => $this->database->uuid,
|
|
],
|
|
],
|
|
]);
|
|
|
|
$response->assertOk();
|
|
expect($response->json('result.isError'))->toBeTrue()
|
|
->and($response->json('result.content.0.text'))->toContain(ResourceStartActivity::DATABASE_OPERATION_IN_PROGRESS_MESSAGE);
|
|
StartDatabase::assertPushed(1);
|
|
RestartDatabase::assertNotPushed();
|
|
})->with(['start', 'restart']);
|
|
});
|
|
|
|
describe('start job', function () {
|
|
beforeEach(function () {
|
|
Event::fake([DatabaseStatusChanged::class]);
|
|
});
|
|
|
|
it('does not run the start commands while another start of the same database holds the lock', function () {
|
|
StartPostgresql::shouldRun()->never();
|
|
$activity = concurrentStartActivity($this->database->uuid, $this->team->id);
|
|
$lock = Cache::lock(ResourceStartActivity::databaseStartLockKey($this->database->uuid), 60);
|
|
expect($lock->get())->toBeTrue();
|
|
|
|
try {
|
|
DatabaseStartJob::dispatchSync(StandalonePostgresql::class, $this->database->id, $this->team->id, $activity->id, null);
|
|
} finally {
|
|
$lock->release();
|
|
}
|
|
|
|
$activity->refresh();
|
|
expect(data_get($activity, 'properties.status'))->toBe(ProcessStatus::ERROR->value)
|
|
->and(data_get($activity, 'properties.error'))->toBe(ResourceStartActivity::ALREADY_STARTING_MESSAGE);
|
|
});
|
|
|
|
it('holds the start lock while the start commands run and releases it afterwards', function () {
|
|
$activity = concurrentStartActivity($this->database->uuid, $this->team->id);
|
|
$lockKey = ResourceStartActivity::databaseStartLockKey($this->database->uuid);
|
|
$lockWasHeld = null;
|
|
StartPostgresql::shouldRun()->once()->andReturnUsing(function ($database, Activity $activity) use ($lockKey, &$lockWasHeld) {
|
|
$probe = Cache::lock($lockKey, 60);
|
|
$lockWasHeld = ! $probe->get();
|
|
if (! $lockWasHeld) {
|
|
$probe->release();
|
|
}
|
|
$activity->properties = $activity->properties->merge(['status' => ProcessStatus::FINISHED->value]);
|
|
$activity->save();
|
|
|
|
return $activity;
|
|
});
|
|
|
|
DatabaseStartJob::dispatchSync(StandalonePostgresql::class, $this->database->id, $this->team->id, $activity->id, null);
|
|
|
|
expect($lockWasHeld)->toBeTrue();
|
|
$lock = Cache::lock($lockKey, 60);
|
|
expect($lock->get())->toBeTrue();
|
|
$lock->release();
|
|
});
|
|
|
|
it('skips a start that a newer start of the same database replaced', function () {
|
|
StartPostgresql::shouldRun()->never();
|
|
$older = concurrentStartActivity($this->database->uuid, $this->team->id, minutesSinceLastUpdate: 20);
|
|
concurrentStartActivity($this->database->uuid, $this->team->id);
|
|
|
|
DatabaseStartJob::dispatchSync(StandalonePostgresql::class, $this->database->id, $this->team->id, $older->id, null);
|
|
|
|
$older->refresh();
|
|
expect(data_get($older, 'properties.status'))->toBe(ProcessStatus::ERROR->value)
|
|
->and(data_get($older, 'properties.error'))->toBe(ResourceStartActivity::SUPERSEDED_MESSAGE);
|
|
});
|
|
|
|
it('skips a start whose activity is no longer queued', function () {
|
|
StartPostgresql::shouldRun()->never();
|
|
$activity = concurrentStartActivity($this->database->uuid, $this->team->id, ProcessStatus::ERROR);
|
|
|
|
DatabaseStartJob::dispatchSync(StandalonePostgresql::class, $this->database->id, $this->team->id, $activity->id, null);
|
|
|
|
expect(data_get($activity->refresh(), 'properties.status'))->toBe(ProcessStatus::ERROR->value);
|
|
});
|
|
});
|