fix(backup): throw explicit error when S3 storage missing or deleted (#9038)

This commit is contained in:
Andras Bacsai 2026-03-19 23:39:36 +01:00 committed by GitHub
commit 2bc8fe3dd7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 393 additions and 49 deletions

View file

@ -625,10 +625,16 @@ private function calculate_size()
private function upload_to_s3(): void
{
if (is_null($this->s3)) {
$this->backup->update([
'save_s3' => false,
's3_storage_id' => null,
]);
throw new \Exception('S3 storage configuration is missing or has been deleted (S3 storage ID: '.($this->backup->s3_storage_id ?? 'null').'). S3 backup has been disabled for this schedule.');
}
try {
if (is_null($this->s3)) {
return;
}
$key = $this->s3->key;
$secret = $this->s3->secret;
// $region = $this->s3->region;

View file

@ -6,6 +6,7 @@
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\On;
use Livewire\Component;
class Form extends Component
@ -131,19 +132,7 @@ public function testConnection()
}
}
public function delete()
{
try {
$this->authorize('delete', $this->storage);
$this->storage->delete();
return redirect()->route('storage.index');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
#[On('submitStorage')]
public function submit()
{
try {

View file

@ -0,0 +1,85 @@
<?php
namespace App\Livewire\Storage;
use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup;
use Livewire\Component;
class Resources extends Component
{
public S3Storage $storage;
public array $selectedStorages = [];
public function mount(): void
{
$backups = ScheduledDatabaseBackup::where('s3_storage_id', $this->storage->id)
->where('save_s3', true)
->get();
foreach ($backups as $backup) {
$this->selectedStorages[$backup->id] = $this->storage->id;
}
}
public function disableS3(int $backupId): void
{
$backup = ScheduledDatabaseBackup::findOrFail($backupId);
$backup->update([
'save_s3' => false,
's3_storage_id' => null,
]);
unset($this->selectedStorages[$backupId]);
$this->dispatch('success', 'S3 disabled.', 'S3 backup has been disabled for this schedule.');
}
public function moveBackup(int $backupId): void
{
$backup = ScheduledDatabaseBackup::findOrFail($backupId);
$newStorageId = $this->selectedStorages[$backupId] ?? null;
if (! $newStorageId || (int) $newStorageId === $this->storage->id) {
$this->dispatch('error', 'No change.', 'The backup is already using this storage.');
return;
}
$newStorage = S3Storage::where('id', $newStorageId)
->where('team_id', $this->storage->team_id)
->first();
if (! $newStorage) {
$this->dispatch('error', 'Storage not found.');
return;
}
$backup->update(['s3_storage_id' => $newStorage->id]);
unset($this->selectedStorages[$backupId]);
$this->dispatch('success', 'Backup moved.', "Moved to {$newStorage->name}.");
}
public function render()
{
$backups = ScheduledDatabaseBackup::where('s3_storage_id', $this->storage->id)
->where('save_s3', true)
->with('database')
->get()
->groupBy(fn ($backup) => $backup->database_type.'-'.$backup->database_id);
$allStorages = S3Storage::where('team_id', $this->storage->team_id)
->orderBy('name')
->get(['id', 'name', 'is_usable']);
return view('livewire.storage.resources', [
'groupedBackups' => $backups,
'allStorages' => $allStorages,
]);
}
}

View file

@ -3,6 +3,7 @@
namespace App\Livewire\Storage;
use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
@ -12,6 +13,10 @@ class Show extends Component
public $storage = null;
public string $currentRoute = '';
public int $backupCount = 0;
public function mount()
{
$this->storage = S3Storage::ownedByCurrentTeam()->whereUuid(request()->storage_uuid)->first();
@ -19,6 +24,21 @@ public function mount()
abort(404);
}
$this->authorize('view', $this->storage);
$this->currentRoute = request()->route()->getName();
$this->backupCount = ScheduledDatabaseBackup::where('s3_storage_id', $this->storage->id)->count();
}
public function delete()
{
try {
$this->authorize('delete', $this->storage);
$this->storage->delete();
return redirect()->route('storage.index');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function render()

View file

@ -40,6 +40,13 @@ protected static function boot(): void
$storage->secret = trim($storage->secret);
}
});
static::deleting(function (S3Storage $storage) {
ScheduledDatabaseBackup::where('s3_storage_id', $storage->id)->update([
'save_s3' => false,
's3_storage_id' => null,
]);
});
}
public static function ownedByCurrentTeam(array $select = ['*'])
@ -59,6 +66,11 @@ public function team()
return $this->belongsTo(Team::class);
}
public function scheduledBackups()
{
return $this->hasMany(ScheduledDatabaseBackup::class, 's3_storage_id');
}
public function awsUrl()
{
return "{$this->endpoint}/{$this->bucket}";

View file

@ -163,7 +163,7 @@ tbody {
}
tr {
@apply text-black dark:text-neutral-400 dark:hover:bg-black hover:bg-neutral-200;
@apply text-black dark:text-neutral-400 dark:hover:bg-coolgray-300 hover:bg-neutral-100;
}
tr th {

View file

@ -1,36 +1,5 @@
<div>
<form class="flex flex-col gap-2 pb-6" wire:submit='submit'>
<div class="flex items-start gap-2">
<div class="">
<h1>Storage Details</h1>
<div class="subtitle">{{ $storage->name }}</div>
<div class="flex items-center gap-2 pb-4">
<div>Current Status:</div>
@if ($isUsable)
<span
class="px-2 py-1 text-xs font-semibold text-green-800 bg-green-100 rounded dark:text-green-100 dark:bg-green-800">
Usable
</span>
@else
<span
class="px-2 py-1 text-xs font-semibold text-red-800 bg-red-100 rounded dark:text-red-100 dark:bg-red-800">
Not Usable
</span>
@endif
</div>
</div>
<x-forms.button canGate="update" :canResource="$storage" type="submit">Save</x-forms.button>
@can('delete', $storage)
<x-modal-confirmation title="Confirm Storage Deletion?" isErrorButton buttonTitle="Delete"
submitAction="delete({{ $storage->id }})" :actions="[
'The selected storage location will be permanently deleted from Coolify.',
'If the storage location is in use by any backup jobs those backup jobs will only store the backup locally on the server.',
]" confirmationText="{{ $storage->name }}"
confirmationLabel="Please confirm the execution of the actions by entering the Storage Name below"
shortConfirmationLabel="Storage Name" :confirmWithPassword="false" step2ButtonText="Permanently Delete" />
@endcan
</div>
<div class="flex gap-2">
<x-forms.input canGate="update" :canResource="$storage" label="Name" id="name" />
<x-forms.input canGate="update" :canResource="$storage" label="Description" id="description" />

View file

@ -0,0 +1,107 @@
<div x-data="{ search: '' }">
<x-forms.input placeholder="Search resources..." x-model="search" id="null" />
@if ($groupedBackups->count() > 0)
<div class="overflow-x-auto pt-4">
<div class="inline-block min-w-full">
<div class="overflow-hidden">
<table class="min-w-full">
<thead>
<tr>
<th class="px-5 py-3 text-xs font-medium text-left uppercase">Database</th>
<th class="px-5 py-3 text-xs font-medium text-left uppercase">Frequency</th>
<th class="px-5 py-3 text-xs font-medium text-left uppercase">Status</th>
<th class="px-5 py-3 text-xs font-medium text-left uppercase">S3 Storage</th>
</tr>
</thead>
<tbody>
@foreach ($groupedBackups as $backups)
@php
$firstBackup = $backups->first();
$database = $firstBackup->database;
$databaseName = $database?->name ?? 'Deleted database';
$resourceLink = null;
$backupParams = null;
if ($database && $database instanceof \App\Models\ServiceDatabase) {
$service = $database->service;
if ($service) {
$environment = $service->environment;
$project = $environment?->project;
if ($project && $environment) {
$resourceLink = route('project.service.configuration', [
'project_uuid' => $project->uuid,
'environment_uuid' => $environment->uuid,
'service_uuid' => $service->uuid,
]);
}
}
} elseif ($database) {
$environment = $database->environment;
$project = $environment?->project;
if ($project && $environment) {
$resourceLink = route('project.database.backup.index', [
'project_uuid' => $project->uuid,
'environment_uuid' => $environment->uuid,
'database_uuid' => $database->uuid,
]);
$backupParams = [
'project_uuid' => $project->uuid,
'environment_uuid' => $environment->uuid,
'database_uuid' => $database->uuid,
];
}
}
@endphp
@foreach ($backups as $backup)
<tr class="dark:hover:bg-coolgray-300 hover:bg-neutral-100" x-show="search === '' || '{{ strtolower(addslashes($databaseName)) }}'.includes(search.toLowerCase()) || '{{ strtolower(addslashes($backup->frequency)) }}'.includes(search.toLowerCase())">
<td class="px-5 py-4 text-sm whitespace-nowrap">
@if ($resourceLink)
<a class="hover:underline" {{ wireNavigate() }} href="{{ $resourceLink }}">{{ $databaseName }} <x-internal-link /></a>
@else
{{ $databaseName }}
@endif
</td>
<td class="px-5 py-4 text-sm whitespace-nowrap">
@php
$backupLink = null;
if ($backupParams) {
$backupLink = route('project.database.backup.execution', array_merge($backupParams, [
'backup_uuid' => $backup->uuid,
]));
}
@endphp
@if ($backupLink)
<a class="hover:underline" {{ wireNavigate() }} href="{{ $backupLink }}">{{ $backup->frequency }} <x-internal-link /></a>
@else
{{ $backup->frequency }}
@endif
</td>
<td class="px-5 py-4 text-sm font-medium whitespace-nowrap">
@if ($backup->enabled)
<span class="text-green-500">Enabled</span>
@else
<span class="text-yellow-500">Disabled</span>
@endif
</td>
<td class="px-5 py-4 text-sm whitespace-nowrap">
<div class="flex items-center gap-2">
<select wire:model="selectedStorages.{{ $backup->id }}" class="w-full input">
@foreach ($allStorages as $s3)
<option value="{{ $s3->id }}" @disabled(!$s3->is_usable)>{{ $s3->name }}@if (!$s3->is_usable) (unusable)@endif</option>
@endforeach
</select>
<x-forms.button wire:click="moveBackup({{ $backup->id }})">Save</x-forms.button>
<x-forms.button isError wire:click="disableS3({{ $backup->id }})" wire:confirm="Are you sure you want to disable S3 for this backup schedule?">Disable S3</x-forms.button>
</div>
</td>
</tr>
@endforeach
@endforeach
</tbody>
</table>
</div>
</div>
</div>
@else
<div class="pt-4">No backup schedules are using this storage.</div>
@endif
</div>

View file

@ -2,5 +2,51 @@
<x-slot:title>
{{ data_get_str($storage, 'name')->limit(10) }} >Storages | Coolify
</x-slot>
<livewire:storage.form :storage="$storage" />
<div class="flex items-center gap-2">
<h1>Storage Details</h1>
@if ($storage->is_usable)
<span class="px-2 py-1 text-xs font-semibold text-green-800 bg-green-100 rounded dark:text-green-100 dark:bg-green-800">
Usable
</span>
@else
<span class="px-2 py-1 text-xs font-semibold text-red-800 bg-red-100 rounded dark:text-red-100 dark:bg-red-800">
Not Usable
</span>
@endif
<x-forms.button canGate="update" :canResource="$storage" wire:click="$dispatch('submitStorage')" :disabled="$currentRoute !== 'storage.show'">Save</x-forms.button>
@can('delete', $storage)
<x-modal-confirmation title="Confirm Storage Deletion?" isErrorButton buttonTitle="Delete"
submitAction="delete({{ $storage->id }})" :actions="array_filter([
'The selected storage location will be permanently deleted from Coolify.',
$backupCount > 0
? $backupCount . ' backup schedule(s) will be updated to no longer save to S3 and will only store backups locally on the server.'
: null,
])" confirmationText="{{ $storage->name }}"
confirmationLabel="Please confirm the execution of the actions by entering the Storage Name below"
shortConfirmationLabel="Storage Name" :confirmWithPassword="false" step2ButtonText="Permanently Delete" />
@endcan
</div>
<div class="subtitle">{{ $storage->name }}</div>
<div class="navbar-main">
<nav class="flex shrink-0 gap-6 items-center whitespace-nowrap scrollbar min-h-10">
<a class="{{ request()->routeIs('storage.show') ? 'dark:text-white' : '' }}" {{ wireNavigate() }}
href="{{ route('storage.show', ['storage_uuid' => $storage->uuid]) }}">
General
</a>
<a class="{{ request()->routeIs('storage.resources') ? 'dark:text-white' : '' }}" {{ wireNavigate() }}
href="{{ route('storage.resources', ['storage_uuid' => $storage->uuid]) }}">
Resources
</a>
</nav>
</div>
<div class="pt-4">
@if ($currentRoute === 'storage.show')
<livewire:storage.form :storage="$storage" />
@elseif ($currentRoute === 'storage.resources')
<livewire:storage.resources :storage="$storage" :key="'resources-'.uniqid()" />
@endif
</div>
</div>

View file

@ -140,6 +140,7 @@
Route::prefix('storages')->group(function () {
Route::get('/', StorageIndex::class)->name('storage.index');
Route::get('/{storage_uuid}', StorageShow::class)->name('storage.show');
Route::get('/{storage_uuid}/resources', StorageShow::class)->name('storage.resources');
});
Route::prefix('shared-variables')->group(function () {
Route::get('/', SharedVariablesIndex::class)->name('shared-variables.index');

View file

@ -1,6 +1,10 @@
<?php
use App\Jobs\DatabaseBackupJob;
use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ScheduledDatabaseBackupExecution;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Schema;
@ -35,3 +39,108 @@
expect($casts)->toHaveKey('s3_storage_deleted');
expect($casts['s3_storage_deleted'])->toBe('boolean');
});
test('upload_to_s3 throws exception and disables s3 when storage is null', function () {
$backup = ScheduledDatabaseBackup::create([
'frequency' => '0 0 * * *',
'save_s3' => true,
's3_storage_id' => 99999,
'database_type' => 'App\Models\StandalonePostgresql',
'database_id' => 1,
'team_id' => Team::factory()->create()->id,
]);
$job = new DatabaseBackupJob($backup);
$reflection = new ReflectionClass($job);
$s3Property = $reflection->getProperty('s3');
$s3Property->setValue($job, null);
$method = $reflection->getMethod('upload_to_s3');
expect(fn () => $method->invoke($job))
->toThrow(Exception::class, 'S3 storage configuration is missing or has been deleted');
$backup->refresh();
expect($backup->save_s3)->toBeFalsy();
expect($backup->s3_storage_id)->toBeNull();
});
test('deleting s3 storage disables s3 on linked backups', function () {
$team = Team::factory()->create();
$s3 = S3Storage::create([
'name' => 'Test S3',
'region' => 'us-east-1',
'key' => 'test-key',
'secret' => 'test-secret',
'bucket' => 'test-bucket',
'endpoint' => 'https://s3.example.com',
'team_id' => $team->id,
]);
$backup1 = ScheduledDatabaseBackup::create([
'frequency' => '0 0 * * *',
'save_s3' => true,
's3_storage_id' => $s3->id,
'database_type' => 'App\Models\StandalonePostgresql',
'database_id' => 1,
'team_id' => $team->id,
]);
$backup2 = ScheduledDatabaseBackup::create([
'frequency' => '0 0 * * *',
'save_s3' => true,
's3_storage_id' => $s3->id,
'database_type' => 'App\Models\StandaloneMysql',
'database_id' => 2,
'team_id' => $team->id,
]);
// Unrelated backup should not be affected
$unrelatedBackup = ScheduledDatabaseBackup::create([
'frequency' => '0 0 * * *',
'save_s3' => true,
's3_storage_id' => null,
'database_type' => 'App\Models\StandalonePostgresql',
'database_id' => 3,
'team_id' => $team->id,
]);
$s3->delete();
$backup1->refresh();
$backup2->refresh();
$unrelatedBackup->refresh();
expect($backup1->save_s3)->toBeFalsy();
expect($backup1->s3_storage_id)->toBeNull();
expect($backup2->save_s3)->toBeFalsy();
expect($backup2->s3_storage_id)->toBeNull();
expect($unrelatedBackup->save_s3)->toBeTruthy();
});
test('s3 storage has scheduled backups relationship', function () {
$team = Team::factory()->create();
$s3 = S3Storage::create([
'name' => 'Test S3',
'region' => 'us-east-1',
'key' => 'test-key',
'secret' => 'test-secret',
'bucket' => 'test-bucket',
'endpoint' => 'https://s3.example.com',
'team_id' => $team->id,
]);
ScheduledDatabaseBackup::create([
'frequency' => '0 0 * * *',
'save_s3' => true,
's3_storage_id' => $s3->id,
'database_type' => 'App\Models\StandalonePostgresql',
'database_id' => 1,
'team_id' => $team->id,
]);
expect($s3->scheduledBackups()->count())->toBe(1);
});