From 639a07e4cb5765270a1a85be2c0751693faa96ae Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 29 Sep 2026 10:16:09 +0200 Subject: [PATCH] feat(s3): revalidate unusable S3 storages hourly Re-test S3 storages marked unusable every hour so a storage that becomes reachable again is usable without manual action. Failures keep the storage unusable and reuse the existing once-per-outage email. Co-Authored-By: Claude Opus 5.5 --- app/Console/Kernel.php | 2 + app/Jobs/RevalidateUnusableS3StoragesJob.php | 39 ++++++++++++ .../RevalidateUnusableS3StoragesJobTest.php | 63 +++++++++++++++++++ 3 files changed, 104 insertions(+) create mode 100644 app/Jobs/RevalidateUnusableS3StoragesJob.php create mode 100644 tests/Feature/RevalidateUnusableS3StoragesJobTest.php diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 150d956d5f..c827586b49 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -12,6 +12,7 @@ use App\Jobs\CleanupStaleMultiplexedConnections; use App\Jobs\PullChangelog; use App\Jobs\PullTemplatesFromCDN; use App\Jobs\RegenerateSslCertJob; +use App\Jobs\RevalidateUnusableS3StoragesJob; use App\Jobs\ServerManagerJob; use App\Jobs\UpdateCoolifyJob; use App\Models\InstanceSettings; @@ -63,6 +64,7 @@ class Kernel extends ConsoleKernel ->runInBackground(); $this->scheduleInstance->job(new ApiTokenExpirationWarningJob)->hourly()->onOneServer(); $this->scheduleInstance->job(new CheckMissingDatabaseBackupsJob)->hourly()->onOneServer(); + $this->scheduleInstance->job(new RevalidateUnusableS3StoragesJob)->hourly()->onOneServer(); if (isDev()) { // Instance Jobs diff --git a/app/Jobs/RevalidateUnusableS3StoragesJob.php b/app/Jobs/RevalidateUnusableS3StoragesJob.php new file mode 100644 index 0000000000..fe9593b6fc --- /dev/null +++ b/app/Jobs/RevalidateUnusableS3StoragesJob.php @@ -0,0 +1,39 @@ +where('is_usable', false) + ->chunkById(100, function ($storages): void { + foreach ($storages as $storage) { + try { + $storage->testConnection(shouldSave: true); + } catch (\Throwable) { + // testConnection() keeps the storage unusable and notifies the team once. + } + } + }); + } +} diff --git a/tests/Feature/RevalidateUnusableS3StoragesJobTest.php b/tests/Feature/RevalidateUnusableS3StoragesJobTest.php new file mode 100644 index 0000000000..94b3667841 --- /dev/null +++ b/tests/Feature/RevalidateUnusableS3StoragesJobTest.php @@ -0,0 +1,63 @@ + 'Storage', + 'region' => 'us-east-1', + 'key' => 'key', + 'secret' => 'secret', + 'bucket' => 'bucket', + 'endpoint' => 'https://93.184.216.34', + 'team_id' => $team->id, + 'is_usable' => false, + 'unusable_email_sent' => true, + ], $attributes)); +} + +it('marks an unusable storage as usable when the connection works again', function () { + $storage = revalidationStorage(Team::factory()->create()); + $disk = Mockery::mock(FilesystemAdapter::class); + $disk->shouldReceive('files')->once()->andReturn([]); + Storage::shouldReceive('build')->once()->andReturn($disk); + + (new RevalidateUnusableS3StoragesJob)->handle(); + + $storage->refresh(); + expect($storage->is_usable)->toBeTrue() + ->and($storage->unusable_email_sent)->toBeFalsy(); +}); + +it('keeps a storage unusable when the connection still fails and continues with the next one', function () { + $team = Team::factory()->create(); + $failing = revalidationStorage($team, ['bucket' => 'failing-bucket']); + $working = revalidationStorage($team, ['bucket' => 'working-bucket']); + $failingDisk = Mockery::mock(FilesystemAdapter::class); + $failingDisk->shouldReceive('files')->once()->andThrow(new RuntimeException('Access denied')); + $workingDisk = Mockery::mock(FilesystemAdapter::class); + $workingDisk->shouldReceive('files')->once()->andReturn([]); + Storage::shouldReceive('build')->twice()->andReturnUsing( + fn (array $config) => $config['bucket'] === 'failing-bucket' ? $failingDisk : $workingDisk + ); + + (new RevalidateUnusableS3StoragesJob)->handle(); + + expect($failing->fresh()->is_usable)->toBeFalse() + ->and($working->fresh()->is_usable)->toBeTrue(); +}); + +it('does not test storages that are already usable', function () { + revalidationStorage(Team::factory()->create(), ['is_usable' => true]); + Storage::shouldReceive('build')->never(); + + (new RevalidateUnusableS3StoragesJob)->handle(); +});