fix(backup): throw explicit error when S3 storage missing or deleted (#9038)
This commit is contained in:
commit
2bc8fe3dd7
11 changed files with 393 additions and 49 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
85
app/Livewire/Storage/Resources.php
Normal file
85
app/Livewire/Storage/Resources.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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}";
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
107
resources/views/livewire/storage/resources.blade.php
Normal file
107
resources/views/livewire/storage/resources.blade.php
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue