mirror of
https://github.com/tiennm99/coolify.git
synced 2026-08-19 12:25:36 +00:00
fix(backups): enforce authorization and safe retention
- Gate volume backup retention and S3 controls by update permission - Preserve backup records when S3 deletion fails - Share SFTP download streaming with consistent missing-file handling - Handle schedule creation errors and link service database backups
This commit is contained in:
@@ -13,6 +13,7 @@ use App\Models\Service;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\MessageBag;
|
||||
use OpenApi\Attributes as OA;
|
||||
use RuntimeException;
|
||||
|
||||
@@ -161,6 +162,26 @@ class VolumeBackupsController extends Controller
|
||||
return response()->json(['message' => 'Storage not found.'], 404);
|
||||
}
|
||||
|
||||
['errors' => $errors, 's3Storage' => $s3Storage, 'saveToS3' => $saveToS3] = $this->validateUpsertRequest($request, $storage, $teamId);
|
||||
|
||||
if ($errors->isNotEmpty()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $errors,
|
||||
], 422);
|
||||
}
|
||||
|
||||
return $this->persistSchedule($request, $storage, $teamId, $s3Storage, $saveToS3, $resourceType, $resource);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{errors: MessageBag, s3Storage: S3Storage|null, saveToS3: bool}
|
||||
*/
|
||||
private function validateUpsertRequest(
|
||||
Request $request,
|
||||
LocalPersistentVolume|LocalFileVolume $storage,
|
||||
int|string $teamId,
|
||||
): array {
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'frequency' => 'required|string|max:255',
|
||||
'enabled' => 'boolean',
|
||||
@@ -223,13 +244,22 @@ class VolumeBackupsController extends Controller
|
||||
$errors->add('storage_uuid', 'Only directory file storages can be backed up.');
|
||||
}
|
||||
|
||||
if ($errors->isNotEmpty()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $errors,
|
||||
], 422);
|
||||
}
|
||||
return [
|
||||
'errors' => $errors,
|
||||
's3Storage' => $s3Storage,
|
||||
'saveToS3' => $saveToS3,
|
||||
];
|
||||
}
|
||||
|
||||
private function persistSchedule(
|
||||
Request $request,
|
||||
LocalPersistentVolume|LocalFileVolume $storage,
|
||||
int|string $teamId,
|
||||
?S3Storage $s3Storage,
|
||||
bool $saveToS3,
|
||||
string $resourceType,
|
||||
Model $resource,
|
||||
): JsonResponse {
|
||||
$backup = $storage->scheduledBackups()->updateOrCreate([], [
|
||||
'team_id' => $teamId,
|
||||
'frequency' => $request->string('frequency')->toString(),
|
||||
|
||||
@@ -85,22 +85,26 @@ class Create extends Component
|
||||
return;
|
||||
}
|
||||
|
||||
$backup = $target->scheduledBackups()->updateOrCreate(
|
||||
[],
|
||||
[
|
||||
'team_id' => currentTeam()->id,
|
||||
'frequency' => $this->frequency,
|
||||
'enabled' => true,
|
||||
],
|
||||
);
|
||||
try {
|
||||
$backup = $target->scheduledBackups()->updateOrCreate(
|
||||
[],
|
||||
[
|
||||
'team_id' => currentTeam()->id,
|
||||
'frequency' => $this->frequency,
|
||||
'enabled' => true,
|
||||
],
|
||||
);
|
||||
|
||||
$this->dispatch('success', $backup->wasRecentlyCreated ? 'Scheduled storage backup created.' : 'Scheduled storage backup updated.');
|
||||
$this->redirectRoute('project.application.backup.show', [
|
||||
'project_uuid' => $this->application->project()->uuid,
|
||||
'environment_uuid' => $this->application->environment->uuid,
|
||||
'application_uuid' => $this->application->uuid,
|
||||
'backup_uuid' => $backup->uuid,
|
||||
], navigate: true);
|
||||
$this->dispatch('success', $backup->wasRecentlyCreated ? 'Scheduled storage backup created.' : 'Scheduled storage backup updated.');
|
||||
redirectRoute($this, 'project.application.backup.show', [
|
||||
'project_uuid' => $this->application->project()->uuid,
|
||||
'environment_uuid' => $this->application->environment->uuid,
|
||||
'application_uuid' => $this->application->uuid,
|
||||
'backup_uuid' => $backup->uuid,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
||||
@@ -84,7 +84,24 @@ class FileStorage extends Component
|
||||
$this->hasEnabledBackup = $backup?->enabled ?? false;
|
||||
$this->backupUrl = null;
|
||||
|
||||
if (! $this->hasEnabledBackup || ! $this->resource instanceof Application) {
|
||||
if (! $this->hasEnabledBackup) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->resource instanceof ServiceDatabase) {
|
||||
$this->backupUrl = route('project.service.database.backups', [
|
||||
'project_uuid' => $this->resource->service->project()->uuid,
|
||||
'environment_uuid' => $this->resource->service->environment->uuid,
|
||||
'service_uuid' => $this->resource->service->uuid,
|
||||
'stack_service_uuid' => $this->resource->uuid,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $this->resource instanceof Application) {
|
||||
$this->hasEnabledBackup = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ use App\Models\LocalPersistentVolume;
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\ScheduledVolumeBackup;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Routing\Redirector;
|
||||
use Illuminate\Support\Collection;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
@@ -178,13 +180,13 @@ class VolumeBackups extends Component
|
||||
$this->dispatch('success', $this->enabled ? 'Storage backups enabled.' : 'Storage backups disabled.');
|
||||
}
|
||||
|
||||
public function backupNow()
|
||||
public function backupNow(): Redirector|RedirectResponse|null
|
||||
{
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
if (! $this->backup) {
|
||||
if (! $this->validateSettings()) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->enabled = false;
|
||||
@@ -202,7 +204,7 @@ class VolumeBackups extends Component
|
||||
]);
|
||||
}
|
||||
|
||||
public function delete(?string $password = null, array $selectedActions = [])
|
||||
public function delete(?string $password = null, array $selectedActions = []): bool|string
|
||||
{
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
|
||||
@@ -17,6 +17,10 @@ use App\Models\SwarmDocker;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\HttpFoundation\File\Exception\FileNotFoundException;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
use Throwable;
|
||||
|
||||
function create_standalone_postgresql($environmentId, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null, string $databaseImage = 'postgres:16-alpine'): StandalonePostgresql
|
||||
{
|
||||
@@ -194,6 +198,41 @@ function deleteBackupsLocally(string|array|null $filenames, Server $server, bool
|
||||
$foldersToCheck->each(fn ($folder) => deleteEmptyBackupFolder($folder, $server));
|
||||
}
|
||||
|
||||
function streamBackupFromServer(Server $server, string $filename, string $contentType): StreamedResponse
|
||||
{
|
||||
$disk = Storage::build([
|
||||
'driver' => 'sftp',
|
||||
'host' => $server->ip,
|
||||
'port' => (int) $server->port,
|
||||
'username' => $server->user,
|
||||
'privateKey' => $server->privateKey->getKeyLocation(),
|
||||
'root' => '/',
|
||||
]);
|
||||
|
||||
if (! $disk->exists($filename)) {
|
||||
throw new FileNotFoundException($filename);
|
||||
}
|
||||
|
||||
return new StreamedResponse(function () use ($disk, $filename) {
|
||||
if (ob_get_level()) {
|
||||
ob_end_clean();
|
||||
}
|
||||
$stream = $disk->readStream($filename);
|
||||
if ($stream === false || is_null($stream)) {
|
||||
abort(500, 'Failed to open stream for the requested file.');
|
||||
}
|
||||
while (! feof($stream)) {
|
||||
echo fread($stream, 2048);
|
||||
flush();
|
||||
}
|
||||
|
||||
fclose($stream);
|
||||
}, 200, [
|
||||
'Content-Type' => $contentType,
|
||||
'Content-Disposition' => 'attachment; filename="'.basename($filename).'"',
|
||||
]);
|
||||
}
|
||||
|
||||
function deleteBackupsS3(string|array|null $filenames, S3Storage $s3): void
|
||||
{
|
||||
if (empty($filenames) || ! $s3) {
|
||||
@@ -430,8 +469,12 @@ function deleteOldBackupsFromS3($backup): Collection
|
||||
->all();
|
||||
|
||||
if (! empty($filesToDelete)) {
|
||||
deleteBackupsS3($filesToDelete, $backup->s3);
|
||||
$processedBackups = $backupsToDelete;
|
||||
try {
|
||||
deleteBackupsS3($filesToDelete, $backup->s3);
|
||||
$processedBackups = $backupsToDelete;
|
||||
} catch (Throwable $e) {
|
||||
report($e);
|
||||
}
|
||||
}
|
||||
|
||||
return $processedBackups;
|
||||
|
||||
@@ -10,12 +10,12 @@
|
||||
@else
|
||||
@include('livewire.project.shared.storages.volume-backups.general')
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@script
|
||||
<script>
|
||||
window.download_volume_backup_file = function(executionId) {
|
||||
window.open('/download/volume-backup/' + executionId, '_blank');
|
||||
}
|
||||
</script>
|
||||
@endscript
|
||||
@script
|
||||
<script>
|
||||
window.download_volume_backup_file = function(executionId) {
|
||||
window.open('/download/volume-backup/' + executionId, '_blank');
|
||||
}
|
||||
</script>
|
||||
@endscript
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<form wire:submit="save" class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
||||
<h2>Retention</h2>
|
||||
<x-forms.button type="submit" class="w-full sm:w-auto">Save</x-forms.button>
|
||||
<x-forms.button type="submit" class="w-full sm:w-auto" canGate="update"
|
||||
:canResource="$backup">Save</x-forms.button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -16,15 +17,15 @@
|
||||
<h3 class="mb-3">Local Backup Retention</h3>
|
||||
<div class="grid grid-cols-1 gap-2 md:grid-cols-3">
|
||||
<x-forms.input label="Number of backups to keep" id="retentionAmountLocally" type="number"
|
||||
min="0"
|
||||
min="0" canGate="update" :canResource="$backup"
|
||||
helper="Keeps only the specified number of most recent backups on the server. Set to 0 for unlimited backups."
|
||||
required />
|
||||
<x-forms.input label="Days to keep backups" id="retentionDaysLocally" type="number"
|
||||
min="0"
|
||||
min="0" canGate="update" :canResource="$backup"
|
||||
helper="Automatically removes backups older than the specified number of days. Set to 0 for no time limit."
|
||||
required />
|
||||
<x-forms.input label="Maximum storage (GB)" id="retentionMaxStorageLocally" type="number"
|
||||
min="0" step="any"
|
||||
min="0" step="any" canGate="update" :canResource="$backup"
|
||||
helper="When total size of all backups in the current backup job exceeds this limit in GB, the oldest backups will be removed. Decimal values are supported (e.g. 0.001 for 1MB). Set to 0 for unlimited storage."
|
||||
required />
|
||||
</div>
|
||||
@@ -34,13 +35,15 @@
|
||||
<h3 class="mb-3">S3 Storage Retention</h3>
|
||||
<div class="grid grid-cols-1 gap-2 md:grid-cols-3">
|
||||
<x-forms.input label="Number of backups to keep" id="retentionAmountS3" type="number" min="0"
|
||||
canGate="update" :canResource="$backup"
|
||||
helper="Keeps only the specified number of most recent backups on S3 storage. Set to 0 for unlimited backups."
|
||||
required />
|
||||
<x-forms.input label="Days to keep backups" id="retentionDaysS3" type="number" min="0"
|
||||
canGate="update" :canResource="$backup"
|
||||
helper="Automatically removes S3 backups older than the specified number of days. Set to 0 for no time limit."
|
||||
required />
|
||||
<x-forms.input label="Maximum storage (GB)" id="retentionMaxStorageS3" type="number" min="0"
|
||||
step="any"
|
||||
step="any" canGate="update" :canResource="$backup"
|
||||
helper="When total size of all backups in the current backup job exceeds this limit in GB, the oldest backups will be removed. Decimal values are supported (e.g. 0.5 for 500MB). Set to 0 for unlimited storage."
|
||||
required />
|
||||
</div>
|
||||
|
||||
@@ -10,13 +10,14 @@
|
||||
<form wire:submit="save" class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
||||
<h2>S3</h2>
|
||||
<x-forms.button type="submit" class="w-full sm:w-auto">Save</x-forms.button>
|
||||
<x-forms.button type="submit" class="w-full sm:w-auto" canGate="update"
|
||||
:canResource="$backup">Save</x-forms.button>
|
||||
@if (!$saveToS3)
|
||||
<x-forms.button type="button" wire:click="toggleS3" wire:loading.attr="disabled"
|
||||
wire:target="toggleS3" isHighlighted>Enable S3</x-forms.button>
|
||||
wire:target="toggleS3" isHighlighted canGate="update" :canResource="$backup">Enable S3</x-forms.button>
|
||||
@else
|
||||
<x-forms.button type="button" wire:click="toggleS3" wire:loading.attr="disabled"
|
||||
wire:target="toggleS3">Disable S3</x-forms.button>
|
||||
wire:target="toggleS3" canGate="update" :canResource="$backup">Disable S3</x-forms.button>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@@ -29,7 +30,8 @@
|
||||
<x-highlighted text="*" />
|
||||
@endif
|
||||
</div>
|
||||
<x-forms.select id="s3StorageId" wire:model.live="s3StorageId" :required="$saveToS3">
|
||||
<x-forms.select id="s3StorageId" wire:model.live="s3StorageId" :required="$saveToS3" canGate="update"
|
||||
:canResource="$backup">
|
||||
@foreach ($availableS3Storages as $s3Storage)
|
||||
<option value="{{ $s3Storage->id }}">{{ $s3Storage->name }}</option>
|
||||
@endforeach
|
||||
@@ -39,10 +41,12 @@
|
||||
<div class="w-full max-w-md">
|
||||
@if ($saveToS3)
|
||||
<x-forms.checkbox instantSave id="disableLocalBackup" label="Disable Local Backup"
|
||||
helper="When enabled, backup files are deleted locally after a successful S3 upload." />
|
||||
helper="When enabled, backup files are deleted locally after a successful S3 upload." canGate="update"
|
||||
:canResource="$backup" />
|
||||
@else
|
||||
<x-forms.checkbox id="disableLocalBackup" label="Disable Local Backup"
|
||||
helper="When enabled, backup files are deleted locally after a successful S3 upload." disabled />
|
||||
helper="When enabled, backup files are deleted locally after a successful S3 upload." disabled
|
||||
canGate="update" :canResource="$backup" />
|
||||
@endif
|
||||
</div>
|
||||
|
||||
|
||||
+9
-66
@@ -98,8 +98,7 @@ use App\Models\Server;
|
||||
use App\Models\ServiceDatabase;
|
||||
use App\Providers\RouteServiceProvider;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
use Symfony\Component\HttpFoundation\File\Exception\FileNotFoundException;
|
||||
|
||||
Route::post('/forgot-password', [Controller::class, 'forgot_password'])->name('password.forgot')->middleware('throttle:forgot-password');
|
||||
Route::get('/realtime', [Controller::class, 'realtime_test'])->middleware('auth');
|
||||
@@ -389,41 +388,13 @@ Route::middleware(['auth'])->group(function () {
|
||||
$server = $execution->scheduledDatabaseBackup->database->destination->server;
|
||||
}
|
||||
|
||||
$privateKeyLocation = $server->privateKey->getKeyLocation();
|
||||
$disk = Storage::build([
|
||||
'driver' => 'sftp',
|
||||
'host' => $server->ip,
|
||||
'port' => (int) $server->port,
|
||||
'username' => $server->user,
|
||||
'privateKey' => $privateKeyLocation,
|
||||
'root' => '/',
|
||||
]);
|
||||
if (! $disk->exists($filename)) {
|
||||
if ($execution->scheduledDatabaseBackup->disable_local_backup === true && $execution->scheduledDatabaseBackup->save_s3 === true) {
|
||||
return response()->json(['message' => 'Backup not available locally, but available on S3.'], 404);
|
||||
}
|
||||
|
||||
return response()->json(['message' => 'Backup not found locally on the server.'], 404);
|
||||
return streamBackupFromServer($server, $filename, 'application/octet-stream');
|
||||
} catch (FileNotFoundException) {
|
||||
if (isset($execution) && $execution->scheduledDatabaseBackup->disable_local_backup === true && $execution->scheduledDatabaseBackup->save_s3 === true) {
|
||||
return response()->json(['message' => 'Backup not available locally, but available on S3.'], 404);
|
||||
}
|
||||
|
||||
return new StreamedResponse(function () use ($disk, $filename) {
|
||||
if (ob_get_level()) {
|
||||
ob_end_clean();
|
||||
}
|
||||
$stream = $disk->readStream($filename);
|
||||
if ($stream === false || is_null($stream)) {
|
||||
abort(500, 'Failed to open stream for the requested file.');
|
||||
}
|
||||
while (! feof($stream)) {
|
||||
echo fread($stream, 2048);
|
||||
flush();
|
||||
}
|
||||
|
||||
fclose($stream);
|
||||
}, 200, [
|
||||
'Content-Type' => 'application/octet-stream',
|
||||
'Content-Disposition' => 'attachment; filename="'.basename($filename).'"',
|
||||
]);
|
||||
return response()->json(['message' => 'Backup not found locally on the server.'], 404);
|
||||
} catch (Throwable $e) {
|
||||
return response()->json(['message' => 'Failed to download backup.'], 500);
|
||||
}
|
||||
@@ -455,37 +426,9 @@ Route::middleware(['auth'])->group(function () {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
}
|
||||
|
||||
$filename = $execution->filename;
|
||||
$disk = Storage::build([
|
||||
'driver' => 'sftp',
|
||||
'host' => $server->ip,
|
||||
'port' => (int) $server->port,
|
||||
'username' => $server->user,
|
||||
'privateKey' => $server->privateKey->getKeyLocation(),
|
||||
'root' => '/',
|
||||
]);
|
||||
if (! $disk->exists($filename)) {
|
||||
return response()->json(['message' => 'Backup not found locally on the server.'], 404);
|
||||
}
|
||||
|
||||
return new StreamedResponse(function () use ($disk, $filename) {
|
||||
if (ob_get_level()) {
|
||||
ob_end_clean();
|
||||
}
|
||||
$stream = $disk->readStream($filename);
|
||||
if ($stream === false || is_null($stream)) {
|
||||
abort(500, 'Failed to open stream for the requested file.');
|
||||
}
|
||||
while (! feof($stream)) {
|
||||
echo fread($stream, 2048);
|
||||
flush();
|
||||
}
|
||||
|
||||
fclose($stream);
|
||||
}, 200, [
|
||||
'Content-Type' => 'application/gzip',
|
||||
'Content-Disposition' => 'attachment; filename="'.basename($filename).'"',
|
||||
]);
|
||||
return streamBackupFromServer($server, $execution->filename, 'application/gzip');
|
||||
} catch (FileNotFoundException) {
|
||||
return response()->json(['message' => 'Backup not found locally on the server.'], 404);
|
||||
} catch (Throwable) {
|
||||
return response()->json(['message' => 'Failed to download backup.'], 500);
|
||||
}
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
use App\Jobs\CleanupInstanceStuffsJob;
|
||||
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\Queue\Middleware\WithoutOverlapping;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
@@ -235,6 +237,55 @@ test('deleteOldBackupsLocally identifies correct backups for deletion by retenti
|
||||
expect($successfulBackups->first()->uuid)->toBe('exec-uuid-1');
|
||||
});
|
||||
|
||||
test('database S3 retention remains best effort when deletion fails', 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,
|
||||
]);
|
||||
$backup = ScheduledDatabaseBackup::create([
|
||||
'frequency' => '0 0 * * *',
|
||||
'save_s3' => true,
|
||||
'disable_local_backup' => true,
|
||||
's3_storage_id' => $s3->id,
|
||||
'database_type' => 'App\Models\StandalonePostgresql',
|
||||
'database_id' => 1,
|
||||
'team_id' => $team->id,
|
||||
'database_backup_retention_amount_s3' => 1,
|
||||
]);
|
||||
$newestExecution = ScheduledDatabaseBackupExecution::create([
|
||||
'uuid' => 'newest-s3-backup',
|
||||
'database_name' => 'test_db',
|
||||
'filename' => '/backup/newest.dmp',
|
||||
'scheduled_database_backup_id' => $backup->id,
|
||||
'status' => 'success',
|
||||
's3_uploaded' => true,
|
||||
]);
|
||||
$oldExecution = ScheduledDatabaseBackupExecution::create([
|
||||
'uuid' => 'old-s3-backup',
|
||||
'database_name' => 'test_db',
|
||||
'filename' => '/backup/old.dmp',
|
||||
'scheduled_database_backup_id' => $backup->id,
|
||||
'status' => 'success',
|
||||
's3_uploaded' => true,
|
||||
]);
|
||||
ScheduledDatabaseBackupExecution::whereKey($newestExecution->id)->update(['created_at' => now()]);
|
||||
ScheduledDatabaseBackupExecution::whereKey($oldExecution->id)->update(['created_at' => now()->subDay()]);
|
||||
$disk = Mockery::mock();
|
||||
$disk->shouldReceive('delete')->once()->with(['/backup/old.dmp'])->andReturnFalse();
|
||||
Storage::shouldReceive('build')->once()->andReturn($disk);
|
||||
|
||||
removeOldBackups($backup);
|
||||
|
||||
expect($oldExecution->fresh()->s3_storage_deleted)->toBeFalse()
|
||||
->and($backup->executions()->count())->toBe(2);
|
||||
});
|
||||
|
||||
test('cleanup instance stuffs job throttles retention enforcement via cache', function () {
|
||||
Cache::forget('backup-retention-enforcement');
|
||||
|
||||
|
||||
@@ -28,3 +28,35 @@ it('keeps mutable Livewire components behind authorization checks', function (st
|
||||
['AuthorizesRequests', "authorize('view'", "authorize('canAccessTerminal'"],
|
||||
],
|
||||
]);
|
||||
|
||||
it('authorizes every volume backup form control', function (string $path, array $controlPatterns) {
|
||||
$source = file_get_contents(base_path($path));
|
||||
|
||||
foreach ($controlPatterns as $controlPattern) {
|
||||
expect($source)->toMatch($controlPattern);
|
||||
}
|
||||
})->with([
|
||||
'retention controls' => [
|
||||
'resources/views/livewire/project/shared/storages/volume-backups/retention.blade.php',
|
||||
[
|
||||
'/<x-forms\.button(?=[^>]*type="submit")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$backup")[^>]*>Save<\/x-forms\.button>/s',
|
||||
'/<x-forms\.input(?=[^>]*id="retentionAmountLocally")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$backup")[^>]*\/\>/s',
|
||||
'/<x-forms\.input(?=[^>]*id="retentionDaysLocally")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$backup")[^>]*\/\>/s',
|
||||
'/<x-forms\.input(?=[^>]*id="retentionMaxStorageLocally")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$backup")[^>]*\/\>/s',
|
||||
'/<x-forms\.input(?=[^>]*id="retentionAmountS3")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$backup")[^>]*\/\>/s',
|
||||
'/<x-forms\.input(?=[^>]*id="retentionDaysS3")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$backup")[^>]*\/\>/s',
|
||||
'/<x-forms\.input(?=[^>]*id="retentionMaxStorageS3")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$backup")[^>]*\/\>/s',
|
||||
],
|
||||
],
|
||||
'S3 controls' => [
|
||||
'resources/views/livewire/project/shared/storages/volume-backups/s3.blade.php',
|
||||
[
|
||||
'/<x-forms\.button(?=[^>]*type="submit")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$backup")[^>]*>Save<\/x-forms\.button>/s',
|
||||
'/<x-forms\.button(?=[^>]*wire:click="toggleS3")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$backup")[^>]*>Enable S3<\/x-forms\.button>/s',
|
||||
'/<x-forms\.button(?=[^>]*wire:click="toggleS3")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$backup")[^>]*>Disable S3<\/x-forms\.button>/s',
|
||||
'/<x-forms\.select(?=[^>]*id="s3StorageId")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$backup")[^>]*>/s',
|
||||
'/<x-forms\.checkbox(?=[^>]*id="disableLocalBackup")(?=[^>]*instantSave)(?=[^>]*canGate="update")(?=[^>]*:canResource="\$backup")[^>]*\/\>/s',
|
||||
'/<x-forms\.checkbox(?=[^>]*id="disableLocalBackup")(?=[^>]*disabled)(?=[^>]*canGate="update")(?=[^>]*:canResource="\$backup")[^>]*\/\>/s',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
@@ -19,11 +19,15 @@ use App\Models\S3Storage;
|
||||
use App\Models\ScheduledVolumeBackup;
|
||||
use App\Models\ScheduledVolumeBackupExecution;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceDatabase;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Queue\Middleware\WithoutOverlapping;
|
||||
use Illuminate\Routing\Redirector;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -45,6 +49,12 @@ it('provides the volume backup domain classes and relationship', function () {
|
||||
->and(method_exists(LocalFileVolume::class, 'scheduledBackups'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('keeps the volume backup script inside the Livewire root element', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/shared/storages/volume-backups.blade.php'));
|
||||
|
||||
expect(strrpos($view, '@endscript'))->toBeLessThan(strrpos($view, '</div>'));
|
||||
});
|
||||
|
||||
it('targets named volumes and application directory mounts through one backup relation', function () {
|
||||
$team = Team::factory()->create();
|
||||
[$application, $volume] = createVolumeBackupApplication($team);
|
||||
@@ -117,6 +127,59 @@ it('creates a scheduled backup with a preselected volume from the shared modal',
|
||||
->and($backup->s3_storage_id)->toBeNull();
|
||||
});
|
||||
|
||||
it('handles scheduled backup persistence failures', function () {
|
||||
$team = Team::factory()->create();
|
||||
signInForVolumeBackups($this, $team);
|
||||
[$application, $volume] = createVolumeBackupApplication($team);
|
||||
$shouldFail = true;
|
||||
|
||||
ScheduledVolumeBackup::creating(function () use (&$shouldFail): void {
|
||||
if ($shouldFail) {
|
||||
$shouldFail = false;
|
||||
|
||||
throw new RuntimeException('Scheduled backup persistence failed.');
|
||||
}
|
||||
});
|
||||
|
||||
Livewire::test(CreateScheduledVolumeBackup::class, [
|
||||
'application' => $application,
|
||||
'selectedTargetKey' => 'volume:'.$volume->id,
|
||||
])
|
||||
->set('frequency', 'daily')
|
||||
->call('submit')
|
||||
->assertDispatched('error', 'Scheduled backup persistence failed.');
|
||||
|
||||
expect(ScheduledVolumeBackup::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('respects the instance wire navigate setting after creating a scheduled backup', function () {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::create([
|
||||
'id' => 0,
|
||||
'is_wire_navigate_enabled' => false,
|
||||
]));
|
||||
$team = Team::factory()->create();
|
||||
signInForVolumeBackups($this, $team);
|
||||
[$application, $volume] = createVolumeBackupApplication($team);
|
||||
|
||||
$component = Livewire::test(CreateScheduledVolumeBackup::class, [
|
||||
'application' => $application,
|
||||
'selectedTargetKey' => 'volume:'.$volume->id,
|
||||
])
|
||||
->set('frequency', 'daily')
|
||||
->call('submit');
|
||||
|
||||
$backup = ScheduledVolumeBackup::query()->sole();
|
||||
|
||||
$component->assertRedirectToRoute('project.application.backup.show', [
|
||||
'project_uuid' => $application->project()->uuid,
|
||||
'environment_uuid' => $application->environment->uuid,
|
||||
'application_uuid' => $application->uuid,
|
||||
'backup_uuid' => $backup->uuid,
|
||||
]);
|
||||
|
||||
expect($component->effects)->not->toHaveKey('redirectUsingNavigate');
|
||||
});
|
||||
|
||||
it('creates a scheduled backup for a preselected application directory', function () {
|
||||
$team = Team::factory()->create();
|
||||
signInForVolumeBackups($this, $team);
|
||||
@@ -411,6 +474,49 @@ it('offers backup configuration and status on application directory mounts', fun
|
||||
->assertSee('href="'.$backupUrl.'"', false);
|
||||
});
|
||||
|
||||
it('links the backup enabled badge to service database backups for database directory mounts', function () {
|
||||
$team = Team::factory()->create();
|
||||
signInForVolumeBackups($this, $team);
|
||||
[$application] = createVolumeBackupApplication($team);
|
||||
$service = Service::factory()->create([
|
||||
'environment_id' => $application->environment_id,
|
||||
'destination_id' => $application->destination_id,
|
||||
'destination_type' => $application->destination_type,
|
||||
]);
|
||||
$database = ServiceDatabase::create([
|
||||
'uuid' => new_public_id(),
|
||||
'name' => 'postgres',
|
||||
'image' => 'postgres:17-alpine',
|
||||
'service_id' => $service->id,
|
||||
]);
|
||||
$directory = LocalFileVolume::unguarded(fn () => LocalFileVolume::withoutEvents(fn () => LocalFileVolume::create([
|
||||
'uuid' => new_public_id(),
|
||||
'fs_path' => './postgres-data',
|
||||
'mount_path' => '/var/lib/postgresql/data',
|
||||
'is_directory' => true,
|
||||
'is_based_on_git' => false,
|
||||
'is_preview_suffix_enabled' => true,
|
||||
'resource_id' => $database->id,
|
||||
'resource_type' => $database->getMorphClass(),
|
||||
])));
|
||||
$directory->scheduledBackups()->create([
|
||||
'team_id' => $team->id,
|
||||
'frequency' => 'daily',
|
||||
'enabled' => true,
|
||||
]);
|
||||
|
||||
$backupUrl = route('project.service.database.backups', [
|
||||
'project_uuid' => $application->project()->uuid,
|
||||
'environment_uuid' => $application->environment->uuid,
|
||||
'service_uuid' => $service->uuid,
|
||||
'stack_service_uuid' => $database->uuid,
|
||||
]);
|
||||
|
||||
Livewire::test(FileStorage::class, ['fileStorage' => $directory])
|
||||
->assertSee('Backup enabled')
|
||||
->assertSee('href="'.$backupUrl.'"', false);
|
||||
});
|
||||
|
||||
it('prevents a backed up directory from being converted or deleted', function () {
|
||||
Process::fake();
|
||||
$team = Team::factory()->create();
|
||||
@@ -484,6 +590,20 @@ it('exposes actions to manage and run volume backups', function () {
|
||||
->and(method_exists(VolumeBackups::class, 'deleteBackup'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('declares the volume backup action return types', function () {
|
||||
$backupNowReturnType = (new ReflectionMethod(VolumeBackups::class, 'backupNow'))->getReturnType();
|
||||
$deleteReturnType = (new ReflectionMethod(VolumeBackups::class, 'delete'))->getReturnType();
|
||||
|
||||
expect($backupNowReturnType)->toBeInstanceOf(ReflectionUnionType::class)
|
||||
->and(collect($backupNowReturnType->getTypes())->map->getName()->all())->toEqualCanonicalizing([
|
||||
RedirectResponse::class,
|
||||
Redirector::class,
|
||||
'null',
|
||||
])
|
||||
->and($deleteReturnType)->toBeInstanceOf(ReflectionUnionType::class)
|
||||
->and(collect($deleteReturnType->getTypes())->map->getName()->all())->toEqualCanonicalizing(['bool', 'string']);
|
||||
});
|
||||
|
||||
it('renders volume backup executions like database backup executions', function () {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::create(['id' => 0]));
|
||||
$team = Team::factory()->create();
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\File\Exception\FileNotFoundException;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
function backupDownloadServer(): Server
|
||||
{
|
||||
$server = new Server;
|
||||
$server->forceFill([
|
||||
'ip' => '192.0.2.10',
|
||||
'port' => 2222,
|
||||
'user' => 'root',
|
||||
]);
|
||||
|
||||
$privateKey = Mockery::mock(PrivateKey::class);
|
||||
$privateKey->shouldReceive('getKeyLocation')->andReturn('/tmp/private-key');
|
||||
$server->setRelation('privateKey', $privateKey);
|
||||
|
||||
return $server;
|
||||
}
|
||||
|
||||
it('throws when the backup file does not exist on the server', function () {
|
||||
$disk = Mockery::mock();
|
||||
$disk->shouldReceive('exists')
|
||||
->once()
|
||||
->with('/backups/archive.tar.gz')
|
||||
->andReturnFalse();
|
||||
|
||||
Storage::shouldReceive('build')
|
||||
->once()
|
||||
->with([
|
||||
'driver' => 'sftp',
|
||||
'host' => '192.0.2.10',
|
||||
'port' => 2222,
|
||||
'username' => 'root',
|
||||
'privateKey' => '/tmp/private-key',
|
||||
'root' => '/',
|
||||
])
|
||||
->andReturn($disk);
|
||||
|
||||
streamBackupFromServer(backupDownloadServer(), '/backups/archive.tar.gz', 'application/gzip');
|
||||
})->throws(FileNotFoundException::class);
|
||||
|
||||
it('streams a backup file with the requested content type', function () {
|
||||
$stream = fopen('php://memory', 'r+');
|
||||
fwrite($stream, 'backup contents');
|
||||
rewind($stream);
|
||||
|
||||
$disk = Mockery::mock();
|
||||
$disk->shouldReceive('exists')->once()->andReturnTrue();
|
||||
$disk->shouldReceive('readStream')
|
||||
->once()
|
||||
->with('/backups/archive.tar.gz')
|
||||
->andReturn($stream);
|
||||
Storage::shouldReceive('build')->once()->andReturn($disk);
|
||||
|
||||
$response = streamBackupFromServer(backupDownloadServer(), '/backups/archive.tar.gz', 'application/gzip');
|
||||
|
||||
expect($response)->toBeInstanceOf(StreamedResponse::class)
|
||||
->and($response->headers->get('content-type'))->toBe('application/gzip')
|
||||
->and($response->headers->get('content-disposition'))->toBe('attachment; filename="archive.tar.gz"');
|
||||
|
||||
ob_start();
|
||||
ob_start();
|
||||
$response->sendContent();
|
||||
$contents = ob_get_clean();
|
||||
|
||||
expect($contents)->toBe('backup contents');
|
||||
});
|
||||
Reference in New Issue
Block a user