mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-12 15:53:46 -04:00
refactor(database): move flush into the shared danger zone
This commit is contained in:
@@ -1,61 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Project\Database;
|
||||
|
||||
use App\Actions\Database\FlushCacheDatabase;
|
||||
use App\Models\StandaloneDragonfly;
|
||||
use App\Models\StandaloneKeydb;
|
||||
use App\Models\StandaloneRedis;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
||||
class FlushCache extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public $database;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->authorize('view', $this->database);
|
||||
}
|
||||
|
||||
public function flush(): void
|
||||
{
|
||||
try {
|
||||
$this->authorize('manage', $this->database);
|
||||
|
||||
if (! $this->isCacheDatabase()) {
|
||||
throw new \RuntimeException('This database type does not support cache flushing.');
|
||||
}
|
||||
|
||||
FlushCacheDatabase::run($this->database);
|
||||
$this->auditFlush();
|
||||
$this->dispatch('success', 'Cache flushed successfully.');
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function isCacheDatabase(): bool
|
||||
{
|
||||
return $this->database instanceof StandaloneRedis
|
||||
|| $this->database instanceof StandaloneKeydb
|
||||
|| $this->database instanceof StandaloneDragonfly;
|
||||
}
|
||||
|
||||
private function auditFlush(): void
|
||||
{
|
||||
auditLog('ui.database.flushed', [
|
||||
'team_id' => $this->database->team()?->id,
|
||||
'database_uuid' => $this->database->uuid,
|
||||
'database_name' => $this->database->name,
|
||||
]);
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
return view('livewire.project.database.flush-cache');
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Livewire\Project\Shared;
|
||||
|
||||
use App\Actions\Database\FlushCacheDatabase;
|
||||
use App\Jobs\DeleteResourceJob;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
@@ -157,6 +158,31 @@ class Danger extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function flush($password)
|
||||
{
|
||||
try {
|
||||
$this->authorize('manage', $this->resource);
|
||||
|
||||
if (! verifyPasswordConfirmation($password, $this)) {
|
||||
return 'The provided password is incorrect.';
|
||||
}
|
||||
|
||||
FlushCacheDatabase::run($this->resource);
|
||||
auditLog('ui.database.flushed', [
|
||||
'team_id' => $this->resource->team()?->id,
|
||||
'database_uuid' => $this->resource->uuid,
|
||||
'database_name' => $this->resource->name,
|
||||
]);
|
||||
$this->dispatch('success', 'Database flushed.');
|
||||
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.project.shared.danger', [
|
||||
|
||||
@@ -53,12 +53,7 @@
|
||||
@elseif ($currentRoute === 'project.database.tags')
|
||||
<livewire:project.shared.tags :resource="$database" />
|
||||
@elseif ($currentRoute === 'project.database.danger')
|
||||
<div class="flex flex-col gap-8">
|
||||
@if (in_array($database->type(), ['standalone-redis', 'standalone-keydb', 'standalone-dragonfly']))
|
||||
<livewire:project.database.flush-cache :database="$database" />
|
||||
@endif
|
||||
<livewire:project.shared.danger :resource="$database" />
|
||||
</div>
|
||||
<livewire:project.shared.danger :resource="$database" />
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
<div>
|
||||
<x-application.settings-section id="flush-cache-section" title="Flush cache"
|
||||
helper="Remove every key stored in this cache database.">
|
||||
<div
|
||||
class="rounded-lg border border-amber-300 bg-amber-50 p-4 ring-1 ring-inset ring-amber-200/60 dark:border-warning/30 dark:bg-warning/[0.08] dark:ring-warning/10">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h4 class="text-sm font-semibold text-amber-700 dark:text-warning">Flush cache</h4>
|
||||
<x-status-badge status="Irreversible" type="warning" />
|
||||
</div>
|
||||
<p class="mt-2 max-w-2xl text-[13px] leading-5 text-neutral-600 dark:text-fg-dim">
|
||||
Permanently erase every key in
|
||||
<strong class="font-semibold text-black dark:text-fg">{{ $database->name }}</strong>
|
||||
by running <code>FLUSHALL ASYNC</code> inside the running container.
|
||||
</p>
|
||||
<ul class="mt-3 space-y-1 text-xs text-neutral-500 dark:text-fg-dim">
|
||||
<li>• All cached data is removed and cannot be recovered.</li>
|
||||
<li>• The database container keeps running; only its data is cleared.</li>
|
||||
<li>• The database must be running for this action to work.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="shrink-0">
|
||||
@can('manage', $database)
|
||||
<x-modal-confirmation title="Flush cache?" buttonTitle="Flush cache" submitAction="flush"
|
||||
:confirmWithText="true" confirmationText="{{ $database->name }}"
|
||||
confirmationLabel="Enter the database name to confirm flushing the cache"
|
||||
shortConfirmationLabel="Database name" :confirmWithPassword="false"
|
||||
:actions="[
|
||||
'Every key stored in this cache will be permanently erased.',
|
||||
'This action cannot be undone and data cannot be recovered.',
|
||||
]" step1ButtonText="Continue" step2ButtonText="Flush cache" />
|
||||
@else
|
||||
<x-forms.button disabled tooltip="You do not have permission to flush this cache.">
|
||||
Flush cache
|
||||
</x-forms.button>
|
||||
@endcan
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
</div>
|
||||
@@ -17,6 +17,23 @@
|
||||
@endif
|
||||
<x-application.settings-section id="danger-zone-section" title="Danger zone"
|
||||
helper="Destructive resource actions cannot be undone.">
|
||||
@if (in_array($resource?->type(), ['standalone-redis', 'standalone-keydb', 'standalone-dragonfly']))
|
||||
<x-danger-zone title="Flush all data" class="mb-4">
|
||||
<p>
|
||||
Permanently remove every key from
|
||||
<strong class="font-semibold text-black dark:text-fg">{{ $resourceName }}</strong>.
|
||||
The database keeps running.
|
||||
</p>
|
||||
<x-slot:action>
|
||||
<x-modal-confirmation title="Flush all data?" buttonTitle="Flush all data" isErrorButton
|
||||
submitAction="flush" :actions="['Permanently remove every key from every logical database.']"
|
||||
confirmationText="{{ $resourceName }}"
|
||||
confirmationLabel="Enter the database name to confirm flushing all data"
|
||||
shortConfirmationLabel="Database name" :disabled="auth()->user()->cannot('manage', $resource)"
|
||||
disabledTooltip="You do not have permission to flush this database." />
|
||||
</x-slot:action>
|
||||
</x-danger-zone>
|
||||
@endif
|
||||
<x-danger-zone title="Delete {{ $resourceLabel }}">
|
||||
<p>
|
||||
Permanently delete
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Database\FlushCacheDatabase;
|
||||
use App\Livewire\Project\Database\FlushCache;
|
||||
use App\Models\AuditEvent;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandaloneDragonfly;
|
||||
use App\Models\StandaloneKeydb;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\StandaloneRedis;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->withoutDefer();
|
||||
|
||||
InstanceSettings::forceCreate(['id' => 0]);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id,
|
||||
'user' => 'deploy',
|
||||
]);
|
||||
$server->settings->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false]);
|
||||
$this->destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
|
||||
$project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
});
|
||||
|
||||
function makeRedis(mixed $environment, mixed $destination): StandaloneRedis
|
||||
{
|
||||
return StandaloneRedis::create([
|
||||
'name' => 'cache-redis',
|
||||
'image' => 'redis:7',
|
||||
'redis_password' => 'password',
|
||||
'redis_username' => 'default',
|
||||
'status' => 'running:healthy',
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
]);
|
||||
}
|
||||
|
||||
function makePostgres(mixed $environment, mixed $destination): StandalonePostgresql
|
||||
{
|
||||
return StandalonePostgresql::create([
|
||||
'name' => 'app-postgres',
|
||||
'image' => 'postgres:16',
|
||||
'postgres_user' => 'coolify',
|
||||
'postgres_password' => 'password',
|
||||
'postgres_db' => 'coolify',
|
||||
'status' => 'running:healthy',
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
]);
|
||||
}
|
||||
|
||||
test('flushes every key with the engine CLI and records an audit event', function (string $model, array $attributes, string $cli) {
|
||||
$database = $model::create([
|
||||
'name' => 'cache',
|
||||
'image' => 'redis:7',
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
...$attributes,
|
||||
]);
|
||||
Process::fake(['*' => Process::result(output: 'OK')]);
|
||||
|
||||
Livewire::test(FlushCache::class, ['database' => $database])
|
||||
->call('flush')
|
||||
->assertDispatched('success');
|
||||
|
||||
Process::assertRan(fn ($process) => str_contains($process->command, "sudo bash -c 'docker exec {$database->uuid} sh -c '\\''REDISCLI_AUTH=\"\$REDIS_PASSWORD\" {$cli} FLUSHALL ASYNC'\\'''"));
|
||||
expect(AuditEvent::query()
|
||||
->where('event', 'ui.database.flushed')
|
||||
->where('resource_uuid', $database->uuid)
|
||||
->exists())->toBeTrue();
|
||||
})->with([
|
||||
'redis' => [StandaloneRedis::class, [], 'redis-cli'],
|
||||
'keydb' => [StandaloneKeydb::class, ['keydb_password' => 'secret'], 'keydb-cli'],
|
||||
'dragonfly with tls' => [StandaloneDragonfly::class, ['dragonfly_password' => 'secret', 'enable_ssl' => true], 'redis-cli --tls --cacert /etc/dragonfly/certs/coolify-ca.crt --cert /etc/dragonfly/certs/server.crt --key /etc/dragonfly/certs/server.key'],
|
||||
]);
|
||||
|
||||
test('reports an error when the database does not reply OK', function () {
|
||||
$redis = makeRedis($this->environment, $this->destination);
|
||||
Process::fake(['*' => Process::result(output: 'NOAUTH Authentication required.')]);
|
||||
|
||||
Livewire::test(FlushCache::class, ['database' => $redis])
|
||||
->call('flush')
|
||||
->assertDispatched('error', 'NOAUTH Authentication required.');
|
||||
|
||||
expect(AuditEvent::query()->where('event', 'ui.database.flushed')->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('refuses to flush a non-cache database and does not run the action', function () {
|
||||
$postgres = makePostgres($this->environment, $this->destination);
|
||||
|
||||
FlushCacheDatabase::mock()->shouldReceive('handle')->never();
|
||||
|
||||
Livewire::test(FlushCache::class, ['database' => $postgres])
|
||||
->call('flush')
|
||||
->assertDispatched('error');
|
||||
|
||||
expect(AuditEvent::query()->where('event', 'ui.database.flushed')->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('denies flushing to a member without manage permission', function () {
|
||||
$redis = makeRedis($this->environment, $this->destination);
|
||||
|
||||
$member = User::factory()->create();
|
||||
$this->team->members()->attach($member->id, ['role' => 'member']);
|
||||
$this->actingAs($member);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
FlushCacheDatabase::mock()->shouldReceive('handle')->never();
|
||||
|
||||
Livewire::test(FlushCache::class, ['database' => $redis])
|
||||
->call('flush')
|
||||
->assertDispatched('error');
|
||||
|
||||
expect(AuditEvent::query()->where('event', 'ui.database.flushed')->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('renders the flush cache card for a cache database', function () {
|
||||
$redis = makeRedis($this->environment, $this->destination);
|
||||
|
||||
Livewire::test(FlushCache::class, ['database' => $redis])
|
||||
->assertSee('Flush cache')
|
||||
->assertSee('FLUSHALL ASYNC');
|
||||
});
|
||||
|
||||
test('the danger page wires the flush cache section only for cache database types', function () {
|
||||
$source = file_get_contents(resource_path('views/livewire/project/database/configuration.blade.php'));
|
||||
|
||||
// The flush-cache component is rendered on the danger route, guarded to the three cache types.
|
||||
expect($source)
|
||||
->toContain('project.database.flush-cache')
|
||||
->toMatch('/in_array\(\$database->type\(\), \[.*standalone-redis.*standalone-keydb.*standalone-dragonfly.*\]\)[\s\S]*project\.database\.flush-cache/');
|
||||
|
||||
// It must no longer live in the heading action menu.
|
||||
$heading = file_get_contents(resource_path('views/livewire/project/database/heading.blade.php'));
|
||||
expect($heading)->not->toContain('Flush cache');
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Shared\Danger;
|
||||
use App\Models\AuditEvent;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandaloneDragonfly;
|
||||
use App\Models\StandaloneKeydb;
|
||||
use App\Models\StandaloneRedis;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::forceCreate(['id' => 0]);
|
||||
$this->withoutDefer();
|
||||
});
|
||||
|
||||
function createFlushableDatabase(string $model, array $attributes = []): StandaloneRedis|StandaloneKeydb|StandaloneDragonfly
|
||||
{
|
||||
$team = Team::factory()->create();
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
'private_key_id' => PrivateKey::factory()->create(['team_id' => $team->id])->id,
|
||||
'user' => 'deploy',
|
||||
]);
|
||||
$destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
|
||||
return $model::create([
|
||||
'name' => 'cache',
|
||||
'image' => 'redis:7',
|
||||
'environment_id' => Environment::factory()->create(['project_id' => $project->id])->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
...$attributes,
|
||||
]);
|
||||
}
|
||||
|
||||
function actingAsFlushUser(Team $team, string $role): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$team->members()->attach($user->id, ['role' => $role]);
|
||||
test()->actingAs($user);
|
||||
session(['currentTeam' => $team]);
|
||||
}
|
||||
|
||||
it('flushes every key with the engine CLI', function (string $model, array $attributes, string $cli) {
|
||||
$database = createFlushableDatabase($model, $attributes);
|
||||
actingAsFlushUser($database->team(), 'owner');
|
||||
Process::fake(['*' => Process::result(output: 'OK')]);
|
||||
|
||||
Livewire::test(Danger::class, ['resource' => $database])
|
||||
->call('flush', 'password')
|
||||
->assertReturned(true)
|
||||
->assertDispatched('success');
|
||||
|
||||
Process::assertRan(fn ($process) => str_contains($process->command, "sudo bash -c 'docker exec {$database->uuid} sh -c '\\''REDISCLI_AUTH=\"\$REDIS_PASSWORD\" {$cli} FLUSHALL ASYNC'\\'''"));
|
||||
expect(AuditEvent::query()->where('event', 'ui.database.flushed')->where('resource_uuid', $database->uuid)->exists())->toBeTrue();
|
||||
})->with([
|
||||
'redis' => [StandaloneRedis::class, [], 'redis-cli'],
|
||||
'keydb' => [StandaloneKeydb::class, ['keydb_password' => 'secret'], 'keydb-cli'],
|
||||
'dragonfly with tls' => [StandaloneDragonfly::class, ['dragonfly_password' => 'secret', 'enable_ssl' => true], 'redis-cli --tls --cacert /etc/dragonfly/certs/coolify-ca.crt --cert /etc/dragonfly/certs/server.crt --key /etc/dragonfly/certs/server.key'],
|
||||
]);
|
||||
|
||||
it('reports an error when the database does not reply OK', function () {
|
||||
$database = createFlushableDatabase(StandaloneRedis::class);
|
||||
actingAsFlushUser($database->team(), 'owner');
|
||||
Process::fake(['*' => Process::result(output: 'NOAUTH Authentication required.')]);
|
||||
|
||||
Livewire::test(Danger::class, ['resource' => $database])
|
||||
->call('flush', 'password')
|
||||
->assertReturned(false)
|
||||
->assertDispatched('error', 'NOAUTH Authentication required.');
|
||||
|
||||
expect(AuditEvent::query()->where('event', 'ui.database.flushed')->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
it('rejects an incorrect password without flushing', function () {
|
||||
$database = createFlushableDatabase(StandaloneRedis::class);
|
||||
actingAsFlushUser($database->team(), 'owner');
|
||||
Process::fake();
|
||||
|
||||
Livewire::test(Danger::class, ['resource' => $database])
|
||||
->call('flush', 'wrong-password')
|
||||
->assertReturned('The provided password is incorrect.');
|
||||
|
||||
Process::assertNothingRan();
|
||||
});
|
||||
|
||||
it('forbids flushing without permission to manage the database', function (string $role, bool $sameTeam) {
|
||||
$database = createFlushableDatabase(StandaloneRedis::class);
|
||||
actingAsFlushUser($sameTeam ? $database->team() : Team::factory()->create(), $role);
|
||||
Process::fake();
|
||||
|
||||
Livewire::test(Danger::class, ['resource' => $database])
|
||||
->call('flush', 'password')
|
||||
->assertDispatched('error');
|
||||
|
||||
Process::assertNothingRan();
|
||||
})->with([
|
||||
'team member' => ['member', true],
|
||||
'admin of another team' => ['admin', false],
|
||||
]);
|
||||
Reference in New Issue
Block a user