Files
coolify/tests/Feature/CleanupUnusedSshKeysTest.php
033f0c84da v4.7 (#12478)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com>
Co-authored-by: Aditya Tripathi <aditya@climactic.co>
2026-11-06 14:25:02 +01:00

201 lines
7.8 KiB
PHP

<?php
use App\Jobs\CleanupUnusedSshKeys;
use App\Jobs\RotatePrivateKeyJob;
use App\Models\Application;
use App\Models\GiteaApp;
use App\Models\GithubApp;
use App\Models\GitlabApp;
use App\Models\PrivateKey;
use App\Models\Server;
use Illuminate\Cache\Events\CacheMissed;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
uses(RefreshDatabase::class);
beforeEach(function () {
Storage::fake('ssh-keys');
Cache::flush();
$this->freezeTime();
});
function unusedSshCleanupKey(string $uuid): PrivateKey
{
return PrivateKey::withoutEvents(fn () => PrivateKey::forceCreate([
'uuid' => $uuid, 'name' => 'Cleanup test', 'private_key' => 'encrypted test key', 'team_id' => 1,
]));
}
function unusedSshCleanupFile(string $uuid): string
{
$filename = 'ssh_key@'.$uuid;
Storage::disk('ssh-keys')->put($filename, 'test key');
touch(Storage::disk('ssh-keys')->path($filename), now()->subDays(2)->timestamp);
return $filename;
}
it('removes unused and orphaned files but keeps encrypted database keys', function () {
$key = unusedSshCleanupKey('unused');
$unused = unusedSshCleanupFile($key->uuid);
$orphan = unusedSshCleanupFile('orphan');
Storage::disk('ssh-keys')->put('unrelated', 'keep');
app(CleanupUnusedSshKeys::class)->handle();
Storage::disk('ssh-keys')->assertMissing([$unused, $orphan]);
Storage::disk('ssh-keys')->assertExists('unrelated');
$this->assertModelExists($key);
expect($key->fresh()->private_key)->toBe('encrypted test key');
});
it('keeps recent files and both keys of a running rotation', function () {
$old = unusedSshCleanupKey('old');
$new = unusedSshCleanupKey('new');
$files = [unusedSshCleanupFile('old'), unusedSshCleanupFile('new')];
Storage::disk('ssh-keys')->put('ssh_key@recent', 'keep');
Cache::put(RotatePrivateKeyJob::cacheKey($old), ['status' => 'running', 'new_private_key_id' => $new->id]);
app(CleanupUnusedSshKeys::class)->handle();
Storage::disk('ssh-keys')->assertExists([...$files, 'ssh_key@recent']);
});
it('does not delete a key while its writer holds the lock', function () {
$file = unusedSshCleanupFile('locked');
$lock = fopen(Storage::disk('ssh-keys')->path($file).'.lock', 'c');
flock($lock, LOCK_EX);
try {
app(CleanupUnusedSshKeys::class)->handle();
Storage::disk('ssh-keys')->assertExists($file);
} finally {
fclose($lock);
}
});
it('keeps a key referenced by a server', function () {
$key = unusedSshCleanupKey('server-key');
$file = unusedSshCleanupFile($key->uuid);
Server::withoutEvents(fn () => Server::factory()->create([
'uuid' => 'test-server', 'private_key_id' => $key->id, 'team_id' => 1,
]));
app(CleanupUnusedSshKeys::class)->handle();
Storage::disk('ssh-keys')->assertExists($file);
});
it('keeps a key referenced by an application or a git source', function (Closure $reference) {
$key = unusedSshCleanupKey('referenced-key');
$file = unusedSshCleanupFile($key->uuid);
$reference($key);
app(CleanupUnusedSshKeys::class)->handle();
Storage::disk('ssh-keys')->assertExists($file);
})->with([
'application' => [fn (PrivateKey $key) => Application::withoutEvents(fn () => Application::factory()->create(['uuid' => 'cleanup-app', 'private_key_id' => $key->id]))],
'GitHub app' => [fn (PrivateKey $key) => GithubApp::withoutEvents(fn () => GithubApp::forceCreate([
'uuid' => 'cleanup-github', 'name' => 'github', 'api_url' => 'https://api.github.com', 'html_url' => 'https://github.com', 'team_id' => 1, 'private_key_id' => $key->id,
]))],
'GitLab app' => [fn (PrivateKey $key) => GitlabApp::withoutEvents(fn () => GitlabApp::forceCreate([
'uuid' => 'cleanup-gitlab', 'name' => 'gitlab', 'api_url' => 'https://gitlab.example.com/api/v4', 'html_url' => 'https://gitlab.example.com', 'team_id' => 1, 'private_key_id' => $key->id,
]))],
'Gitea app' => [fn (PrivateKey $key) => GiteaApp::withoutEvents(fn () => GiteaApp::forceCreate([
'uuid' => 'cleanup-gitea', 'name' => 'gitea', 'api_url' => 'https://gitea.example.com/api/v1', 'html_url' => 'https://gitea.example.com', 'team_id' => 1, 'private_key_id' => $key->id,
]))],
]);
it('removes the file of a key that only a deleted server references', function () {
$key = unusedSshCleanupKey('deleted-server-key');
$file = unusedSshCleanupFile($key->uuid);
$server = Server::withoutEvents(fn () => Server::factory()->create([
'uuid' => 'deleted-server', 'private_key_id' => $key->id, 'team_id' => 1,
]));
Server::withoutEvents(fn () => $server->delete());
app(CleanupUnusedSshKeys::class)->handle();
Storage::disk('ssh-keys')->assertMissing($file);
});
it('uses a fixed number of queries for any number of key files', function () {
foreach (range(1, 40) as $id) {
$key = unusedSshCleanupKey('bounded-'.$id);
unusedSshCleanupFile($key->uuid);
if ($id % 2 === 0) {
Server::withoutEvents(fn () => Server::factory()->create([
'uuid' => 'bounded-server-'.$id, 'private_key_id' => $key->id, 'team_id' => 1,
]));
}
}
$queries = 0;
(new PrivateKey)->getConnection()->listen(function () use (&$queries) {
$queries++;
});
app(CleanupUnusedSshKeys::class)->handle();
expect($queries)->toBeLessThanOrEqual(6);
$remaining = array_values(array_filter(Storage::disk('ssh-keys')->files(), fn ($file) => ! str_ends_with($file, '.lock')));
expect($remaining)->toHaveCount(20);
});
it('cleans up 10000 local key files with mixed usage', function () {
$encryptedKey = encrypt(str_repeat('test-key-material', 240));
for ($batch = 0; $batch < 20; $batch++) {
$keys = [];
$servers = [];
for ($offset = 1; $offset <= 500; $offset++) {
$id = $batch * 500 + $offset;
$keys[] = [
'id' => $id, 'uuid' => 'load-'.$id, 'name' => 'Load test '.$id,
'private_key' => $encryptedKey, 'team_id' => 1,
];
unusedSshCleanupFile('load-'.$id);
if ($id <= 5000) {
$servers[] = [
'uuid' => 'load-server-'.$id, 'name' => 'Load server '.$id,
'ip' => '127.0.0.1', 'private_key_id' => $id, 'team_id' => 1,
];
}
}
PrivateKey::query()->insert($keys);
if ($servers !== []) {
Server::query()->insert($servers);
}
}
$queries = 0;
$cacheReads = 0;
$connection = (new PrivateKey)->getConnection();
$connection->listen(function () use (&$queries) {
$queries++;
});
app('events')->listen(CacheMissed::class, function () use (&$cacheReads) {
$cacheReads++;
});
$memoryBefore = memory_get_usage(true);
memory_reset_peak_usage();
$started = hrtime(true);
app(CleanupUnusedSshKeys::class)->handle();
$seconds = (hrtime(true) - $started) / 1e9;
$peakExtraMiB = (memory_get_peak_usage(true) - $memoryBefore) / 1048576;
$jobQueries = $queries;
$files = Storage::disk('ssh-keys')->files();
$remaining = array_values(array_filter($files, fn ($file) => ! str_ends_with($file, '.lock')));
expect($remaining)->toHaveCount(5000);
foreach (range(1, 10000) as $id) {
expect(in_array('ssh_key@load-'.$id, $remaining, true))->toBe($id <= 5000);
}
expect(PrivateKey::query()->count())->toBe(10000);
fwrite(STDERR, sprintf(
"\nSSH cleanup load test: %.3f s, %.1f MiB extra peak, %d SQL queries, %d cache misses, 5000 deleted, 5000 retained, %d lock files\n",
$seconds, $peakExtraMiB, $jobQueries, $cacheReads, count($files) - count($remaining),
));
})->skip(fn () => getenv('SSH_KEY_CLEANUP_LOAD_TEST') !== '1', 'Opt-in load test');