mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-14 16:17:10 +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:
1 parent
99a8a96e7f
commit
72a0a57f0e
13 files changed
+433
-112
No files matched your search
@@ -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