mirror of
https://github.com/coollabsio/coolify.git
synced 2026-10-09 07:26:09 -04:00
- A Coolify restart now fails queued or running database imports, so they no longer block all later imports with 409. - An import without progress for the SSH command timeout plus 30 minutes (at least 2 hours) is stale and no longer blocks. - The import sets its operation property when the activity is created, so a worker cannot save the activity without it. - Start and restart refuse a second operation while a start, restart, or import is in progress, in the UI, the API (409), and actions. - DatabaseStartJob runs only while its activity is still queued and the newest one, and holds a lock per database during the commands. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
277 lines
11 KiB
PHP
277 lines
11 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\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\Str;
|
|
use Livewire\Livewire;
|
|
use Spatie\Activitylog\Models\Activity;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
beforeEach(function () {
|
|
InstanceSettings::forceCreate(['id' => 0]);
|
|
config()->set('cache.default', 'array');
|
|
|
|
$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);
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|