From f5b221e3f3ef113360c14530758b71587e7e5e45 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:07:04 +0200 Subject: [PATCH] fix(database): flush KeyDB and TLS Dragonfly and detect failed flushes --- app/Actions/Database/FlushCacheDatabase.php | 20 ++++++--- tests/Feature/DatabaseFlushCacheTest.php | 45 +++++++++++++++++---- 2 files changed, 52 insertions(+), 13 deletions(-) diff --git a/app/Actions/Database/FlushCacheDatabase.php b/app/Actions/Database/FlushCacheDatabase.php index f5c8254978..0737b8a397 100644 --- a/app/Actions/Database/FlushCacheDatabase.php +++ b/app/Actions/Database/FlushCacheDatabase.php @@ -23,9 +23,18 @@ class FlushCacheDatabase throw new \RuntimeException('Database is not running.'); } - $command = $this->buildFlushCommand($database->uuid, $this->resolvePassword($database)); + $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'; + } - instant_remote_process(command: [$command], server: $server, throwError: true); + $command = $this->buildFlushCommand($database->uuid, $this->resolvePassword($database), $cli); + + $output = instant_remote_process(command: [$command], server: $server, throwError: true); + + if ($output !== 'OK') { + throw new \RuntimeException($output ?: 'Failed to flush the database.'); + } } /** @@ -41,14 +50,13 @@ class FlushCacheDatabase * This is a limitation shared with every other password-carrying remote command in the * codebase (e.g. DatabaseBackupJob); the escaping still holds, so no command is injected. */ - public function buildFlushCommand(string $containerName, ?string $password): string + public function buildFlushCommand(string $containerName, ?string $password, string $cli = 'redis-cli'): string { - $redisCli = 'redis-cli'; if (filled($password)) { - $redisCli .= ' -a '.escapeshellarg($password); + $cli .= ' -a '.escapeshellarg($password); } - return "docker exec {$containerName} {$redisCli} FLUSHALL ASYNC"; + return "docker exec {$containerName} {$cli} FLUSHALL ASYNC"; } private function resolvePassword(StandaloneRedis|StandaloneKeydb|StandaloneDragonfly $database): ?string diff --git a/tests/Feature/DatabaseFlushCacheTest.php b/tests/Feature/DatabaseFlushCacheTest.php index 8c3e68d943..40862a4183 100644 --- a/tests/Feature/DatabaseFlushCacheTest.php +++ b/tests/Feature/DatabaseFlushCacheTest.php @@ -5,14 +5,18 @@ 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); @@ -28,7 +32,11 @@ beforeEach(function () { $this->actingAs($this->user); session(['currentTeam' => $this->team]); - $server = Server::factory()->create(['team_id' => $this->team->id]); + $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]); @@ -64,19 +72,42 @@ function makePostgres(mixed $environment, mixed $destination): StandalonePostgre ]); } -test('flushes the cache of a running redis database and records an audit event', function () { - $redis = makeRedis($this->environment, $this->destination); +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', + 'status' => 'running:healthy', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ...$attributes, + ]); + Process::fake(['*' => Process::result(output: 'OK')]); - FlushCacheDatabase::mock()->shouldReceive('handle')->once(); - - Livewire::test(FlushCache::class, ['database' => $redis]) + Livewire::test(FlushCache::class, ['database' => $database]) ->call('flush') ->assertDispatched('success'); + Process::assertRan(fn ($process) => str_contains($process->command, "sudo docker exec {$database->uuid} {$cli} FLUSHALL ASYNC")); expect(AuditEvent::query() ->where('event', 'ui.database.flushed') - ->where('resource_uuid', $redis->uuid) + ->where('resource_uuid', $database->uuid) ->exists())->toBeTrue(); +})->with([ + 'redis' => [StandaloneRedis::class, [], 'redis-cli'], + 'keydb' => [StandaloneKeydb::class, ['keydb_password' => 'secret'], "keydb-cli -a 'secret'"], + '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 -a 'secret'"], +]); + +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 () {