feat(database): add flush cache option for Redis, KeyDB and Dragonfly (#11513)

This commit is contained in:
🏔️ Peak
2026-10-04 23:17:10 +02:00
committed by GitHub
4 changed files with 186 additions and 0 deletions
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\Actions\Database;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
use App\Models\StandaloneRedis;
use Lorisleiva\Actions\Concerns\AsAction;
use RuntimeException;
class FlushDatabase
{
use AsAction;
public function handle(StandaloneRedis|StandaloneKeydb|StandaloneDragonfly $database): void
{
$cli = $database instanceof StandaloneKeydb ? 'keydb-cli' : 'redis-cli';
if ($database instanceof StandaloneDragonfly && $database->enable_ssl) {
$cli .= ' --tls --cacert /etc/dragonfly/certs/coolify-ca.crt --cert /etc/dragonfly/certs/server.crt --key /etc/dragonfly/certs/server.key';
}
$output = instant_remote_process(
["docker exec {$database->uuid} sh -c 'REDISCLI_AUTH=\"\$REDIS_PASSWORD\" {$cli} FLUSHALL ASYNC'"],
$database->destination->server,
);
if ($output !== 'OK') {
throw new RuntimeException($output ?: 'Failed to flush the database.');
}
}
}
+26
View File
@@ -2,6 +2,7 @@
namespace App\Livewire\Project\Shared;
use App\Actions\Database\FlushDatabase;
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.';
}
FlushDatabase::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', [
@@ -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
+112
View File
@@ -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],
]);