2210 lines
84 KiB
PHP
2210 lines
84 KiB
PHP
<?php
|
|
|
|
use App\Jobs\DeleteResourceJob;
|
|
use App\Jobs\ScheduledJobManager;
|
|
use App\Jobs\VolumeBackupJob;
|
|
use App\Jobs\VolumeBackupRecoveryJob;
|
|
use App\Livewire\Project\Application\Backup\Create as CreateScheduledVolumeBackup;
|
|
use App\Livewire\Project\Service\FileStorage;
|
|
use App\Livewire\Project\Shared\Storages\Show;
|
|
use App\Livewire\Project\Shared\Storages\VolumeBackups;
|
|
use App\Models\Application;
|
|
use App\Models\Environment;
|
|
use App\Models\InstanceSettings;
|
|
use App\Models\LocalFileVolume;
|
|
use App\Models\LocalPersistentVolume;
|
|
use App\Models\PrivateKey;
|
|
use App\Models\Project;
|
|
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;
|
|
use Illuminate\Support\Facades\Process;
|
|
use Illuminate\Support\Facades\Queue;
|
|
use Illuminate\Support\Facades\Route;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Livewire\Livewire;
|
|
use Symfony\Component\HttpKernel\Exception\HttpException;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
it('provides the volume backup domain classes and relationship', function () {
|
|
expect(class_exists(ScheduledVolumeBackup::class))->toBeTrue()
|
|
->and(class_exists(ScheduledVolumeBackupExecution::class))->toBeTrue()
|
|
->and(class_exists(VolumeBackupJob::class))->toBeTrue()
|
|
->and(class_exists(VolumeBackups::class))->toBeTrue()
|
|
->and(method_exists(LocalPersistentVolume::class, 'scheduledBackups'))->toBeTrue()
|
|
->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);
|
|
$directory = LocalFileVolume::unguarded(fn () => LocalFileVolume::withoutEvents(fn () => LocalFileVolume::create([
|
|
'uuid' => new_public_id(),
|
|
'fs_path' => './uploads',
|
|
'mount_path' => '/app/uploads',
|
|
'is_directory' => true,
|
|
'is_based_on_git' => false,
|
|
'is_preview_suffix_enabled' => true,
|
|
'resource_id' => $application->id,
|
|
'resource_type' => $application->getMorphClass(),
|
|
])));
|
|
|
|
$volumeBackup = $volume->scheduledBackups()->create([
|
|
'team_id' => $team->id,
|
|
'frequency' => 'daily',
|
|
]);
|
|
$directoryBackup = $directory->scheduledBackups()->create([
|
|
'team_id' => $team->id,
|
|
'frequency' => 'weekly',
|
|
]);
|
|
|
|
expect($volumeBackup->backupable->is($volume))->toBeTrue()
|
|
->and($volumeBackup->targetType())->toBe('Volume')
|
|
->and($volumeBackup->targetName())->toBe('app-data')
|
|
->and($volumeBackup->sourcePath())->toBe('app-data')
|
|
->and($directoryBackup->backupable->is($directory))->toBeTrue()
|
|
->and($directoryBackup->targetType())->toBe('Directory')
|
|
->and($directoryBackup->targetName())->toBe('./uploads')
|
|
->and($directoryBackup->sourcePath())->toBe($application->workdir().'/uploads')
|
|
->and($directoryBackup->server()?->id)->toBe($application->destination->server->id);
|
|
});
|
|
|
|
it('provides application backup index and detail routes', function () {
|
|
expect(Route::has('project.application.backup.index'))->toBeTrue()
|
|
->and(Route::has('project.application.backup.show'))->toBeTrue()
|
|
->and(Route::has('download.volume-backup'))->toBeTrue();
|
|
});
|
|
|
|
it('creates a scheduled backup with a preselected volume from the shared modal', function () {
|
|
$team = Team::factory()->create();
|
|
signInForVolumeBackups($this, $team);
|
|
[$application, $volume] = createVolumeBackupApplication($team);
|
|
|
|
$component = Livewire::test(CreateScheduledVolumeBackup::class, [
|
|
'application' => $application,
|
|
'selectedTargetKey' => 'volume:'.$volume->id,
|
|
])
|
|
->assertSet('targetKey', 'volume:'.$volume->id)
|
|
->assertSee($volume->name)
|
|
->assertDontSee('Save to S3')
|
|
->set('frequency', 'daily')
|
|
->call('submit')
|
|
->assertDispatched('success');
|
|
|
|
$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($backup->backupable->is($volume))->toBeTrue()
|
|
->and($backup->frequency)->toBe('daily')
|
|
->and($backup->enabled)->toBeTrue()
|
|
->and($backup->save_s3)->toBeFalse()
|
|
->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);
|
|
[$application] = createVolumeBackupApplication($team);
|
|
$directory = createApplicationBackupDirectory($application);
|
|
|
|
Livewire::test(CreateScheduledVolumeBackup::class, [
|
|
'application' => $application,
|
|
'selectedTargetKey' => 'directory:'.$directory->id,
|
|
])
|
|
->assertSet('targetKey', 'directory:'.$directory->id)
|
|
->assertSee('Directory: '.$directory->fs_path)
|
|
->set('frequency', 'daily')
|
|
->call('submit')
|
|
->assertDispatched('success');
|
|
|
|
expect(ScheduledVolumeBackup::query()->sole()->backupable->is($directory))->toBeTrue();
|
|
});
|
|
|
|
it('rejects files and directory mounts owned by another application as backup targets', function () {
|
|
$team = Team::factory()->create();
|
|
signInForVolumeBackups($this, $team);
|
|
[$application] = createVolumeBackupApplication($team);
|
|
$otherApplication = Application::factory()->create([
|
|
'environment_id' => $application->environment_id,
|
|
'destination_id' => $application->destination_id,
|
|
'destination_type' => $application->destination_type,
|
|
]);
|
|
$foreignDirectory = createApplicationBackupDirectory($otherApplication);
|
|
$file = createApplicationBackupDirectory($application, './config.json');
|
|
$file->update(['is_directory' => false]);
|
|
|
|
Livewire::test(CreateScheduledVolumeBackup::class, ['application' => $application])
|
|
->set('targetKey', 'directory:'.$file->id)
|
|
->call('submit')
|
|
->assertHasErrors('targetKey')
|
|
->set('targetKey', 'directory:'.$foreignDirectory->id)
|
|
->call('submit')
|
|
->assertHasErrors('targetKey');
|
|
|
|
expect(ScheduledVolumeBackup::query()->count())->toBe(0);
|
|
});
|
|
|
|
it('shows volume backups on the application backups pages', function () {
|
|
config(['cache.default' => 'array', 'app.maintenance.driver' => 'file']);
|
|
InstanceSettings::unguarded(fn () => InstanceSettings::create(['id' => 0]));
|
|
$team = Team::factory()->create();
|
|
signInForVolumeBackups($this, $team);
|
|
[$application, $volume] = createVolumeBackupApplication($team);
|
|
$backup = $volume->scheduledBackups()->create([
|
|
'team_id' => $team->id,
|
|
'frequency' => 'daily',
|
|
]);
|
|
$parameters = [
|
|
'project_uuid' => $application->project()->uuid,
|
|
'environment_uuid' => $application->environment->uuid,
|
|
'application_uuid' => $application->uuid,
|
|
];
|
|
|
|
$this->get(route('project.application.backup.index', $parameters))
|
|
->assertOk()
|
|
->assertSee('Scheduled Backups')
|
|
->assertSee($volume->name);
|
|
|
|
$this->get(route('project.application.backup.show', [...$parameters, 'backup_uuid' => $backup->uuid]))
|
|
->assertOk()
|
|
->assertSee('<h1>Backups</h1>', false)
|
|
->assertSee($volume->name);
|
|
});
|
|
|
|
it('shows directory backups on the application backup index and detail pages', function () {
|
|
config(['cache.default' => 'array', 'app.maintenance.driver' => 'file']);
|
|
InstanceSettings::unguarded(fn () => InstanceSettings::create(['id' => 0]));
|
|
$team = Team::factory()->create();
|
|
signInForVolumeBackups($this, $team);
|
|
[$application] = createVolumeBackupApplication($team);
|
|
$directory = createApplicationBackupDirectory($application);
|
|
$backup = $directory->scheduledBackups()->create([
|
|
'team_id' => $team->id,
|
|
'frequency' => 'daily',
|
|
]);
|
|
$parameters = [
|
|
'project_uuid' => $application->project()->uuid,
|
|
'environment_uuid' => $application->environment->uuid,
|
|
'application_uuid' => $application->uuid,
|
|
];
|
|
|
|
$this->get(route('project.application.backup.index', $parameters))
|
|
->assertOk()
|
|
->assertSee('Directory: '.$directory->fs_path);
|
|
|
|
$this->get(route('project.application.backup.show', [...$parameters, 'backup_uuid' => $backup->uuid]))
|
|
->assertOk()
|
|
->assertSee('<h1>Backups</h1>', false)
|
|
->assertSee($directory->fs_path);
|
|
});
|
|
|
|
it('splits scheduled backup settings and executions across dedicated urls', function () {
|
|
config(['cache.default' => 'array', 'app.maintenance.driver' => 'file']);
|
|
InstanceSettings::unguarded(fn () => InstanceSettings::create(['id' => 0]));
|
|
$team = Team::factory()->create();
|
|
signInForVolumeBackups($this, $team);
|
|
[$application, $volume] = createVolumeBackupApplication($team);
|
|
$backup = $volume->scheduledBackups()->create([
|
|
'team_id' => $team->id,
|
|
'frequency' => 'daily',
|
|
'save_s3' => true,
|
|
]);
|
|
ScheduledVolumeBackupExecution::create([
|
|
'scheduled_volume_backup_id' => $backup->id,
|
|
'status' => 'success',
|
|
'filename' => '/data/coolify/backups/volumes/test/archive.tar.gz',
|
|
'finished_at' => now(),
|
|
]);
|
|
$parameters = [
|
|
'project_uuid' => $application->project()->uuid,
|
|
'environment_uuid' => $application->environment->uuid,
|
|
'application_uuid' => $application->uuid,
|
|
'backup_uuid' => $backup->uuid,
|
|
];
|
|
$generalUrl = route('project.application.backup.show', $parameters);
|
|
|
|
$this->get($generalUrl)
|
|
->assertOk()
|
|
->assertSee('General')
|
|
->assertSee('S3')
|
|
->assertSee('Retention')
|
|
->assertSee('Executions')
|
|
->assertSee('Danger Zone')
|
|
->assertSee('Stop containers while creating the archive')
|
|
->assertDontSee('S3 Enabled')
|
|
->assertDontSee('Number of backups to keep')
|
|
->assertDontSee('Backup Availability:')
|
|
->assertDontSee('Delete Backups and Schedule');
|
|
|
|
$this->get($generalUrl.'/s3')
|
|
->assertOk()
|
|
->assertSeeText('No validated S3 available. Configure one here.')
|
|
->assertDontSee('Disable Local Backup')
|
|
->assertDontSee('Enable S3')
|
|
->assertDontSee('Disable S3')
|
|
->assertDontSee('S3 Storage Retention')
|
|
->assertDontSee('Local Backup Retention')
|
|
->assertDontSee('Frequency')
|
|
->assertDontSee('Backup Availability:');
|
|
|
|
$s3View = file_get_contents(resource_path('views/livewire/project/shared/storages/volume-backups/s3.blade.php'));
|
|
expect(strpos($s3View, '<span>S3 Storage</span>'))
|
|
->toBeLessThan(strpos($s3View, 'label="Disable Local Backup"'));
|
|
|
|
$this->get($generalUrl.'/retention')
|
|
->assertOk()
|
|
->assertSee('Local Backup Retention')
|
|
->assertSee('S3 Storage Retention')
|
|
->assertSee('Number of backups to keep')
|
|
->assertDontSee('Stop containers while creating the archive')
|
|
->assertDontSee('Backup Availability:');
|
|
|
|
$this->get($generalUrl.'/executions')
|
|
->assertOk()
|
|
->assertSee('Backup Availability:')
|
|
->assertDontSee('Stop containers while creating the archive')
|
|
->assertDontSee('Number of backups to keep');
|
|
|
|
$this->get($generalUrl.'/danger')
|
|
->assertOk()
|
|
->assertSee('Danger Zone')
|
|
->assertSee('Delete Scheduled Backup')
|
|
->assertSee('Delete Backups and Schedule')
|
|
->assertDontSee('Stop containers while creating the archive')
|
|
->assertDontSee('Number of backups to keep')
|
|
->assertDontSee('Backup Availability:');
|
|
});
|
|
|
|
it('shows the configure backup modal trigger inside the volume card instead of inline backup settings', function () {
|
|
InstanceSettings::unguarded(fn () => InstanceSettings::create(['id' => 0]));
|
|
$team = Team::factory()->create();
|
|
signInForVolumeBackups($this, $team);
|
|
[$application, $volume] = createVolumeBackupApplication($team);
|
|
|
|
$component = Livewire::test(Show::class, [
|
|
'storage' => $volume,
|
|
'resource' => $application,
|
|
])
|
|
->set('isReadOnly', true)
|
|
->assertSee('Configure Backup')
|
|
->assertDontSee('Backups made while the application is writing');
|
|
|
|
$html = $component->html();
|
|
|
|
expect(strpos($html, 'Configure Backup'))
|
|
->toBeGreaterThan(strpos($html, '<form'))
|
|
->toBeLessThan(strpos($html, '</form>'));
|
|
});
|
|
|
|
it('only shows the backup enabled badge for an enabled volume backup', function () {
|
|
InstanceSettings::unguarded(fn () => InstanceSettings::create(['id' => 0]));
|
|
$team = Team::factory()->create();
|
|
signInForVolumeBackups($this, $team);
|
|
[$application, $volume] = createVolumeBackupApplication($team);
|
|
|
|
$backup = $volume->scheduledBackups()->create([
|
|
'team_id' => $team->id,
|
|
'frequency' => 'daily',
|
|
'enabled' => false,
|
|
]);
|
|
|
|
$component = Livewire::test(Show::class, [
|
|
'storage' => $volume,
|
|
'resource' => $application,
|
|
])->assertDontSee('Backup enabled');
|
|
|
|
$backup->update(['enabled' => true]);
|
|
|
|
$backupUrl = route('project.application.backup.show', [
|
|
'project_uuid' => $application->project()->uuid,
|
|
'environment_uuid' => $application->environment->uuid,
|
|
'application_uuid' => $application->uuid,
|
|
'backup_uuid' => $backup->uuid,
|
|
]);
|
|
|
|
$component
|
|
->dispatch('refreshVolumeBackups')
|
|
->assertSeeInOrder(['Volume Name', 'Backup enabled'])
|
|
->assertSee('href="'.$backupUrl.'"', false);
|
|
|
|
Livewire::test(Show::class, [
|
|
'storage' => $volume,
|
|
'resource' => $application,
|
|
'isFirst' => false,
|
|
])->assertSeeInOrder(['Volume Name', 'Backup enabled']);
|
|
});
|
|
|
|
it('links the backup enabled badge to a filtered backup list when the application has multiple schedules', function () {
|
|
$team = Team::factory()->create();
|
|
signInForVolumeBackups($this, $team);
|
|
[$application, $volume] = createVolumeBackupApplication($team);
|
|
|
|
$volume->scheduledBackups()->create([
|
|
'team_id' => $team->id,
|
|
'frequency' => 'daily',
|
|
'enabled' => true,
|
|
]);
|
|
createApplicationBackupDirectory($application)->scheduledBackups()->create([
|
|
'team_id' => $team->id,
|
|
'frequency' => 'weekly',
|
|
'enabled' => true,
|
|
]);
|
|
|
|
$backupUrl = route('project.application.backup.index', [
|
|
'project_uuid' => $application->project()->uuid,
|
|
'environment_uuid' => $application->environment->uuid,
|
|
'application_uuid' => $application->uuid,
|
|
'search' => $volume->name,
|
|
]);
|
|
|
|
Livewire::test(Show::class, [
|
|
'storage' => $volume,
|
|
'resource' => $application,
|
|
])
|
|
->assertSee('Backup enabled')
|
|
->assertSee('href="'.$backupUrl.'"', false);
|
|
});
|
|
|
|
it('offers backup configuration and status on application directory mounts', function () {
|
|
$team = Team::factory()->create();
|
|
signInForVolumeBackups($this, $team);
|
|
[$application] = createVolumeBackupApplication($team);
|
|
$directory = createApplicationBackupDirectory($application);
|
|
|
|
$component = Livewire::test(FileStorage::class, ['fileStorage' => $directory])
|
|
->assertSee('Configure Backup')
|
|
->assertSeeInOrder(['Convert to file', 'Configure Backup', 'Delete'])
|
|
->assertDontSee('Backup enabled');
|
|
|
|
$backup = $directory->scheduledBackups()->create([
|
|
'team_id' => $team->id,
|
|
'frequency' => 'daily',
|
|
'enabled' => true,
|
|
]);
|
|
|
|
$backupUrl = route('project.application.backup.show', [
|
|
'project_uuid' => $application->project()->uuid,
|
|
'environment_uuid' => $application->environment->uuid,
|
|
'application_uuid' => $application->uuid,
|
|
'backup_uuid' => $backup->uuid,
|
|
]);
|
|
|
|
$component
|
|
->dispatch('refreshVolumeBackups')
|
|
->assertSee('Backup enabled')
|
|
->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();
|
|
signInForVolumeBackups($this, $team);
|
|
[$application] = createVolumeBackupApplication($team);
|
|
$directory = createApplicationBackupDirectory($application);
|
|
$directory->scheduledBackups()->create([
|
|
'team_id' => $team->id,
|
|
'frequency' => 'daily',
|
|
]);
|
|
|
|
Livewire::test(FileStorage::class, ['fileStorage' => $directory])
|
|
->call('convertToFile')
|
|
->assertDispatched('error')
|
|
->call('delete', 'password')
|
|
->assertDispatched('error');
|
|
|
|
expect($directory->fresh())->not->toBeNull()
|
|
->and($directory->fresh()->is_directory)->toBeTrue();
|
|
});
|
|
|
|
it('stores volume backup schedules and executions', function () {
|
|
expect(Schema::hasColumns('scheduled_volume_backups', [
|
|
'uuid',
|
|
'backupable_type',
|
|
'backupable_id',
|
|
'team_id',
|
|
's3_storage_id',
|
|
'frequency',
|
|
'enabled',
|
|
'save_s3',
|
|
'disable_local_backup',
|
|
'stop_during_backup',
|
|
'retention_amount_locally',
|
|
'retention_days_locally',
|
|
'retention_max_storage_locally',
|
|
'retention_amount_s3',
|
|
'retention_days_s3',
|
|
'retention_max_storage_s3',
|
|
'timeout',
|
|
]))->toBeTrue()
|
|
->and(Schema::hasColumns('scheduled_volume_backup_executions', [
|
|
'uuid',
|
|
'scheduled_volume_backup_id',
|
|
'status',
|
|
'message',
|
|
'size',
|
|
'filename',
|
|
'stop_container_ids',
|
|
'stop_recovery_pending',
|
|
's3_cleanup_pending',
|
|
'finished_at',
|
|
'local_storage_deleted',
|
|
's3_storage_deleted',
|
|
's3_uploaded',
|
|
]))->toBeTrue();
|
|
});
|
|
|
|
it('records the S3 storage used by each volume backup execution', function () {
|
|
expect(Schema::hasColumn('scheduled_volume_backup_executions', 's3_storage_id'))->toBeTrue()
|
|
->and((new ScheduledVolumeBackupExecution)->s3())->not->toBeNull();
|
|
});
|
|
|
|
it('exposes actions to manage and run volume backups', function () {
|
|
expect(method_exists(VolumeBackups::class, 'save'))->toBeTrue()
|
|
->and(method_exists(VolumeBackups::class, 'backupNow'))->toBeTrue()
|
|
->and(method_exists(VolumeBackups::class, 'toggleEnabled'))->toBeTrue()
|
|
->and(method_exists(VolumeBackups::class, 'delete'))->toBeTrue()
|
|
->and(method_exists(VolumeBackups::class, 'cleanupFailed'))->toBeTrue()
|
|
->and(method_exists(VolumeBackups::class, 'cleanupDeleted'))->toBeTrue()
|
|
->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();
|
|
signInForVolumeBackups($this, $team);
|
|
[$application, $volume, $server] = createVolumeBackupApplication($team);
|
|
$server->settings->update(['server_timezone' => 'Europe/Budapest']);
|
|
$backup = $volume->scheduledBackups()->create([
|
|
'team_id' => $team->id,
|
|
'frequency' => 'daily',
|
|
]);
|
|
ScheduledVolumeBackupExecution::create([
|
|
'scheduled_volume_backup_id' => $backup->id,
|
|
'status' => 'success',
|
|
'filename' => '/data/coolify/backups/volumes/test/archive.tar.gz',
|
|
'size' => 1024,
|
|
'finished_at' => now(),
|
|
]);
|
|
|
|
Livewire::test(VolumeBackups::class, ['storage' => $volume, 'resource' => $application, 'section' => 'executions'])
|
|
->assertSet('timezone', 'Europe/Budapest')
|
|
->assertSeeInOrder([
|
|
'Executions',
|
|
'Success',
|
|
'Volume: app-data',
|
|
'Backup Availability:',
|
|
])
|
|
->assertSee('Executions')
|
|
->assertSee('Page 1 of 1')
|
|
->assertSee('Cleanup Failed Backups')
|
|
->assertSee('Cleanup Deleted')
|
|
->assertSee('Backup Availability:')
|
|
->assertSee('Local Storage')
|
|
->assertSee('Location: /data/coolify/backups/volumes/test/archive.tar.gz')
|
|
->assertSee('Download')
|
|
->assertSee('Delete')
|
|
->assertDontSee('border border-neutral-200', false);
|
|
});
|
|
|
|
it('cleans up failed and fully deleted volume backup execution records', function () {
|
|
InstanceSettings::unguarded(fn () => InstanceSettings::create(['id' => 0]));
|
|
$team = Team::factory()->create();
|
|
signInForVolumeBackups($this, $team);
|
|
[$application, $volume] = createVolumeBackupApplication($team);
|
|
$backup = $volume->scheduledBackups()->create([
|
|
'team_id' => $team->id,
|
|
'frequency' => 'daily',
|
|
]);
|
|
$failed = ScheduledVolumeBackupExecution::create([
|
|
'scheduled_volume_backup_id' => $backup->id,
|
|
'status' => 'failed',
|
|
]);
|
|
$deleted = ScheduledVolumeBackupExecution::create([
|
|
'scheduled_volume_backup_id' => $backup->id,
|
|
'status' => 'success',
|
|
'local_storage_deleted' => true,
|
|
's3_storage_deleted' => true,
|
|
's3_uploaded' => true,
|
|
]);
|
|
|
|
Livewire::test(VolumeBackups::class, ['storage' => $volume, 'resource' => $application])
|
|
->call('cleanupFailed')
|
|
->assertDispatched('success')
|
|
->call('cleanupDeleted')
|
|
->assertDispatched('success');
|
|
|
|
expect($failed->fresh())->toBeNull()
|
|
->and($deleted->fresh())->toBeNull();
|
|
});
|
|
|
|
it('deletes an individual volume backup archive and execution', function () {
|
|
InstanceSettings::unguarded(fn () => InstanceSettings::create(['id' => 0]));
|
|
Process::fake();
|
|
$team = Team::factory()->create();
|
|
signInForVolumeBackups($this, $team);
|
|
[$application, $volume] = createVolumeBackupApplication($team);
|
|
$backup = $volume->scheduledBackups()->create([
|
|
'team_id' => $team->id,
|
|
'frequency' => 'daily',
|
|
]);
|
|
$execution = ScheduledVolumeBackupExecution::create([
|
|
'scheduled_volume_backup_id' => $backup->id,
|
|
'status' => 'success',
|
|
'filename' => '/data/coolify/backups/volumes/test/archive.tar.gz',
|
|
'finished_at' => now(),
|
|
]);
|
|
|
|
Livewire::test(VolumeBackups::class, ['storage' => $volume, 'resource' => $application])
|
|
->call('deleteBackup', $execution->id, 'password')
|
|
->assertDispatched('success');
|
|
|
|
expect($execution->fresh())->toBeNull();
|
|
Process::assertRan(fn ($process) => str_contains($process->command, 'rm -f')
|
|
&& str_contains($process->command, 'archive.tar.gz'));
|
|
});
|
|
|
|
it('deletes an individual S3 archive from the storage recorded on its execution', function () {
|
|
$team = Team::factory()->create();
|
|
signInForVolumeBackups($this, $team);
|
|
[$application, $volume] = createVolumeBackupApplication($team);
|
|
$originalStorage = S3Storage::create([
|
|
'name' => 'Original storage',
|
|
'region' => 'us-east-1',
|
|
'key' => 'original-key',
|
|
'secret' => 'secret',
|
|
'bucket' => 'original-bucket',
|
|
'endpoint' => 'https://s3.example.com',
|
|
'team_id' => $team->id,
|
|
'is_usable' => true,
|
|
]);
|
|
$newStorage = S3Storage::create([
|
|
'name' => 'New storage',
|
|
'region' => 'us-east-1',
|
|
'key' => 'new-key',
|
|
'secret' => 'secret',
|
|
'bucket' => 'new-bucket',
|
|
'endpoint' => 'https://s3.example.com',
|
|
'team_id' => $team->id,
|
|
'is_usable' => true,
|
|
]);
|
|
$backup = $volume->scheduledBackups()->create([
|
|
'team_id' => $team->id,
|
|
's3_storage_id' => $newStorage->id,
|
|
'frequency' => 'daily',
|
|
'save_s3' => true,
|
|
]);
|
|
$execution = ScheduledVolumeBackupExecution::create([
|
|
'scheduled_volume_backup_id' => $backup->id,
|
|
's3_storage_id' => $originalStorage->id,
|
|
'status' => 'success',
|
|
'filename' => '/data/coolify/backups/volumes/test/historical.tar.gz',
|
|
'local_storage_deleted' => true,
|
|
's3_uploaded' => true,
|
|
]);
|
|
$disk = Mockery::mock();
|
|
$disk->shouldReceive('delete')
|
|
->once()
|
|
->with(['/data/coolify/backups/volumes/test/historical.tar.gz'])
|
|
->andReturnTrue();
|
|
Storage::shouldReceive('build')
|
|
->once()
|
|
->with(Mockery::on(fn (array $config): bool => $config['key'] === 'original-key'))
|
|
->andReturn($disk);
|
|
|
|
Livewire::test(VolumeBackups::class, ['storage' => $volume, 'resource' => $application])
|
|
->set('delete_backup_s3', true)
|
|
->call('deleteBackup', $execution->id, 'password')
|
|
->assertDispatched('success');
|
|
|
|
expect($execution->fresh())->toBeNull();
|
|
});
|
|
|
|
it('prevents another team from downloading a volume backup', function () {
|
|
config(['app.maintenance.driver' => 'file']);
|
|
$backupTeam = Team::factory()->create();
|
|
[$application, $volume] = createVolumeBackupApplication($backupTeam);
|
|
$backup = $volume->scheduledBackups()->create([
|
|
'team_id' => $backupTeam->id,
|
|
'frequency' => 'daily',
|
|
]);
|
|
$execution = ScheduledVolumeBackupExecution::create([
|
|
'scheduled_volume_backup_id' => $backup->id,
|
|
'status' => 'success',
|
|
'filename' => '/data/coolify/backups/volumes/test/archive.tar.gz',
|
|
]);
|
|
$otherTeam = Team::factory()->create();
|
|
signInForVolumeBackups($this, $otherTeam);
|
|
|
|
$this->get(route('download.volume-backup', $execution->id))->assertForbidden();
|
|
});
|
|
|
|
it('enables and disables volume backups from the title action', function () {
|
|
$team = Team::factory()->create();
|
|
signInForVolumeBackups($this, $team);
|
|
[$application, $volume] = createVolumeBackupApplication($team);
|
|
|
|
$component = Livewire::test(VolumeBackups::class, ['storage' => $volume, 'resource' => $application])
|
|
->assertSet('enabled', false)
|
|
->assertSee('Enable Backup')
|
|
->call('toggleEnabled')
|
|
->assertSet('enabled', true)
|
|
->assertSee('Disable Backup');
|
|
|
|
expect(ScheduledVolumeBackup::query()->sole()->enabled)->toBeTrue();
|
|
|
|
$component->call('toggleEnabled')->assertSet('enabled', false);
|
|
|
|
expect(ScheduledVolumeBackup::query()->sole()->enabled)->toBeFalse();
|
|
});
|
|
|
|
it('enables and disables volume S3 backups from the S3 title action', function () {
|
|
$team = Team::factory()->create();
|
|
signInForVolumeBackups($this, $team);
|
|
[$application, $volume] = createVolumeBackupApplication($team);
|
|
$s3Storage = S3Storage::create([
|
|
'name' => 'Volume backups',
|
|
'region' => 'us-east-1',
|
|
'key' => 'key',
|
|
'secret' => 'secret',
|
|
'bucket' => 'bucket',
|
|
'endpoint' => 'https://s3.example.com',
|
|
'team_id' => $team->id,
|
|
'is_usable' => true,
|
|
]);
|
|
$backup = $volume->scheduledBackups()->create([
|
|
'team_id' => $team->id,
|
|
'frequency' => 'daily',
|
|
's3_storage_id' => $s3Storage->id,
|
|
]);
|
|
|
|
$component = Livewire::test(VolumeBackups::class, [
|
|
'storage' => $volume,
|
|
'resource' => $application,
|
|
'section' => 's3',
|
|
])
|
|
->assertSee('Enable S3')
|
|
->call('toggleS3')
|
|
->assertSet('saveToS3', true)
|
|
->assertSee('Disable S3');
|
|
|
|
expect($backup->refresh()->save_s3)->toBeTrue();
|
|
|
|
$component->call('toggleS3')->assertSet('saveToS3', false);
|
|
|
|
expect($backup->refresh()->save_s3)->toBeFalse();
|
|
});
|
|
|
|
it('shows and saves volume S3 retention while S3 backups are disabled', function () {
|
|
$team = Team::factory()->create();
|
|
signInForVolumeBackups($this, $team);
|
|
[$application, $volume] = createVolumeBackupApplication($team);
|
|
$backup = $volume->scheduledBackups()->create([
|
|
'team_id' => $team->id,
|
|
'frequency' => 'daily',
|
|
'save_s3' => false,
|
|
]);
|
|
|
|
Livewire::test(VolumeBackups::class, [
|
|
'storage' => $volume,
|
|
'resource' => $application,
|
|
'section' => 'retention',
|
|
])
|
|
->assertSee('S3 Storage Retention')
|
|
->set('retentionAmountS3', 12)
|
|
->set('retentionDaysS3', 30)
|
|
->set('retentionMaxStorageS3', 4.5)
|
|
->call('save')
|
|
->assertHasNoErrors();
|
|
|
|
expect($backup->refresh()->save_s3)->toBeFalse()
|
|
->and($backup->retention_amount_s3)->toBe(12)
|
|
->and($backup->retention_days_s3)->toBe(30)
|
|
->and($backup->retention_max_storage_s3)->toBe(4.5);
|
|
});
|
|
|
|
it('only updates S3 fields when toggling volume S3 backups', function () {
|
|
$team = Team::factory()->create();
|
|
signInForVolumeBackups($this, $team);
|
|
[$application, $volume] = createVolumeBackupApplication($team);
|
|
$s3Storage = S3Storage::create([
|
|
'name' => 'Volume backups',
|
|
'region' => 'us-east-1',
|
|
'key' => 'key',
|
|
'secret' => 'secret',
|
|
'bucket' => 'bucket',
|
|
'endpoint' => 'https://s3.example.com',
|
|
'team_id' => $team->id,
|
|
'is_usable' => true,
|
|
]);
|
|
$backup = $volume->scheduledBackups()->create([
|
|
'team_id' => $team->id,
|
|
'frequency' => 'daily',
|
|
's3_storage_id' => $s3Storage->id,
|
|
]);
|
|
|
|
Livewire::test(VolumeBackups::class, [
|
|
'storage' => $volume,
|
|
'resource' => $application,
|
|
'section' => 's3',
|
|
])
|
|
->set('frequency', 'not a valid schedule')
|
|
->call('toggleS3')
|
|
->assertDispatched('success');
|
|
|
|
expect($backup->refresh()->save_s3)->toBeTrue()
|
|
->and($backup->frequency)->toBe('daily');
|
|
});
|
|
|
|
function createVolumeBackupApplication(Team $team): array
|
|
{
|
|
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0]));
|
|
$privateKey = PrivateKey::factory()->create(['team_id' => $team->id]);
|
|
$server = Server::factory()->create([
|
|
'team_id' => $team->id,
|
|
'private_key_id' => $privateKey->id,
|
|
'ip' => '203.0.113.10',
|
|
]);
|
|
$destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
|
|
$project = Project::factory()->create(['team_id' => $team->id]);
|
|
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
|
$application = Application::factory()->create([
|
|
'environment_id' => $environment->id,
|
|
'destination_id' => $destination->id,
|
|
'destination_type' => $destination->getMorphClass(),
|
|
]);
|
|
$volume = LocalPersistentVolume::create([
|
|
'name' => 'app-data',
|
|
'mount_path' => '/data',
|
|
'resource_id' => $application->id,
|
|
'resource_type' => $application->getMorphClass(),
|
|
]);
|
|
|
|
return [$application, $volume, $server];
|
|
}
|
|
|
|
function createApplicationBackupDirectory(Application $application, string $path = './uploads'): LocalFileVolume
|
|
{
|
|
return LocalFileVolume::unguarded(fn () => LocalFileVolume::withoutEvents(fn () => LocalFileVolume::create([
|
|
'uuid' => new_public_id(),
|
|
'fs_path' => $path,
|
|
'mount_path' => '/app/uploads',
|
|
'is_directory' => true,
|
|
'is_based_on_git' => false,
|
|
'is_preview_suffix_enabled' => true,
|
|
'resource_id' => $application->id,
|
|
'resource_type' => $application->getMorphClass(),
|
|
])));
|
|
}
|
|
|
|
function signInForVolumeBackups($testCase, Team $team): User
|
|
{
|
|
$user = User::factory()->create();
|
|
$user->teams()->attach($team, ['role' => 'owner']);
|
|
$testCase->actingAs($user);
|
|
session(['currentTeam' => $team]);
|
|
|
|
return $user;
|
|
}
|
|
|
|
it('creates a local scheduled backup for a persistent volume', function () {
|
|
$team = Team::factory()->create();
|
|
signInForVolumeBackups($this, $team);
|
|
[$application, $volume] = createVolumeBackupApplication($team);
|
|
|
|
Livewire::test(VolumeBackups::class, ['storage' => $volume, 'resource' => $application])
|
|
->assertSee('General')
|
|
->assertSee('Volume:')
|
|
->assertSee('inconsistent or corrupted')
|
|
->assertSee('gracefully stop containers')
|
|
->set('frequency', 'daily')
|
|
->set('retentionAmountLocally', 5)
|
|
->set('retentionDaysLocally', 14)
|
|
->set('retentionMaxStorageLocally', 1.5)
|
|
->set('stopDuringBackup', true)
|
|
->call('save')
|
|
->assertDispatched('success');
|
|
|
|
$backup = ScheduledVolumeBackup::query()->sole();
|
|
|
|
expect($backup->backupable->is($volume))->toBeTrue()
|
|
->and($backup->team_id)->toBe($team->id)
|
|
->and($backup->frequency)->toBe('daily')
|
|
->and($backup->retention_amount_locally)->toBe(5)
|
|
->and($backup->retention_days_locally)->toBe(14)
|
|
->and($backup->retention_max_storage_locally)->toBe(1.5)
|
|
->and($backup->stop_during_backup)->toBeTrue()
|
|
->and($backup->save_s3)->toBeFalse();
|
|
});
|
|
|
|
it('only accepts a usable S3 storage owned by the current team', function () {
|
|
$team = Team::factory()->create();
|
|
signInForVolumeBackups($this, $team);
|
|
[$application, $volume] = createVolumeBackupApplication($team);
|
|
$foreignStorage = S3Storage::create([
|
|
'name' => 'Foreign S3',
|
|
'region' => 'us-east-1',
|
|
'key' => 'key',
|
|
'secret' => 'secret',
|
|
'bucket' => 'bucket',
|
|
'endpoint' => 'https://s3.example.com',
|
|
'team_id' => Team::factory()->create()->id,
|
|
'is_usable' => true,
|
|
]);
|
|
|
|
Livewire::test(VolumeBackups::class, ['storage' => $volume, 'resource' => $application])
|
|
->set('frequency', 'daily')
|
|
->set('saveToS3', true)
|
|
->set('s3StorageId', $foreignStorage->id)
|
|
->call('save')
|
|
->assertHasErrors('s3StorageId');
|
|
|
|
expect(ScheduledVolumeBackup::query()->count())->toBe(0);
|
|
});
|
|
|
|
it('saves the database-style S3 backup controls immediately', function () {
|
|
$team = Team::factory()->create();
|
|
signInForVolumeBackups($this, $team);
|
|
[$application, $volume] = createVolumeBackupApplication($team);
|
|
$s3Storage = S3Storage::create([
|
|
'name' => 'Volume backups',
|
|
'region' => 'us-east-1',
|
|
'key' => 'key',
|
|
'secret' => 'secret',
|
|
'bucket' => 'bucket',
|
|
'endpoint' => 'https://s3.example.com',
|
|
'team_id' => $team->id,
|
|
'is_usable' => true,
|
|
]);
|
|
$volume->scheduledBackups()->create([
|
|
'team_id' => $team->id,
|
|
'frequency' => 'daily',
|
|
]);
|
|
|
|
$component = Livewire::test(VolumeBackups::class, ['storage' => $volume, 'resource' => $application])
|
|
->assertSet('s3StorageId', $s3Storage->id)
|
|
->set('saveToS3', true)
|
|
->call('instantSave')
|
|
->set('disableLocalBackup', true)
|
|
->set('retentionAmountS3', 10)
|
|
->set('retentionDaysS3', 30)
|
|
->set('retentionMaxStorageS3', 5.5)
|
|
->call('instantSave');
|
|
|
|
$backup = ScheduledVolumeBackup::query()->sole();
|
|
expect($backup->save_s3)->toBeTrue()
|
|
->and($backup->s3_storage_id)->toBe($s3Storage->id)
|
|
->and($backup->disable_local_backup)->toBeTrue()
|
|
->and($backup->retention_amount_s3)->toBe(10)
|
|
->and($backup->retention_days_s3)->toBe(30)
|
|
->and($backup->retention_max_storage_s3)->toBe(5.5);
|
|
|
|
$component->set('saveToS3', false)->call('instantSave');
|
|
|
|
expect($backup->fresh()->save_s3)->toBeFalse()
|
|
->and($backup->fresh()->s3_storage_id)->toBe($s3Storage->id)
|
|
->and($backup->fresh()->disable_local_backup)->toBeFalse();
|
|
});
|
|
|
|
it('saves the selected volume backup S3 storage immediately while S3 is disabled', function () {
|
|
$team = Team::factory()->create();
|
|
signInForVolumeBackups($this, $team);
|
|
[$application, $volume] = createVolumeBackupApplication($team);
|
|
$firstS3Storage = S3Storage::create([
|
|
'name' => 'First storage',
|
|
'region' => 'us-east-1',
|
|
'key' => 'key',
|
|
'secret' => 'secret',
|
|
'bucket' => 'bucket',
|
|
'endpoint' => 'https://s3.example.com',
|
|
'team_id' => $team->id,
|
|
'is_usable' => true,
|
|
]);
|
|
$secondS3Storage = S3Storage::create([
|
|
'name' => 'Second storage',
|
|
'region' => 'us-east-1',
|
|
'key' => 'key',
|
|
'secret' => 'secret',
|
|
'bucket' => 'bucket',
|
|
'endpoint' => 'https://s3.example.com',
|
|
'team_id' => $team->id,
|
|
'is_usable' => true,
|
|
]);
|
|
$volume->scheduledBackups()->create([
|
|
'team_id' => $team->id,
|
|
'frequency' => 'daily',
|
|
'save_s3' => false,
|
|
's3_storage_id' => $firstS3Storage->id,
|
|
]);
|
|
|
|
Livewire::test(VolumeBackups::class, ['storage' => $volume, 'resource' => $application, 'section' => 's3'])
|
|
->set('s3StorageId', $secondS3Storage->id)
|
|
->assertDispatched('success');
|
|
|
|
$backup = ScheduledVolumeBackup::query()->sole();
|
|
expect($backup->save_s3)->toBeFalse()
|
|
->and($backup->s3_storage_id)->toBe($secondS3Storage->id);
|
|
});
|
|
|
|
it('saves each editable volume backup checkbox immediately', function () {
|
|
$team = Team::factory()->create();
|
|
signInForVolumeBackups($this, $team);
|
|
[$application, $volume] = createVolumeBackupApplication($team);
|
|
$s3Storage = S3Storage::create([
|
|
'name' => 'Volume backups',
|
|
'region' => 'us-east-1',
|
|
'key' => 'key',
|
|
'secret' => 'secret',
|
|
'bucket' => 'bucket',
|
|
'endpoint' => 'https://s3.example.com',
|
|
'team_id' => $team->id,
|
|
'is_usable' => true,
|
|
]);
|
|
$backup = $volume->scheduledBackups()->create([
|
|
'team_id' => $team->id,
|
|
's3_storage_id' => $s3Storage->id,
|
|
'frequency' => 'daily',
|
|
'save_s3' => true,
|
|
]);
|
|
|
|
$generalComponent = Livewire::test(VolumeBackups::class, ['storage' => $volume, 'resource' => $application]);
|
|
preg_match('/<input\b(?=[^>]*wire:model=(?:"stopDuringBackup"|stopDuringBackup))[^>]*>/', $generalComponent->html(), $matches);
|
|
expect($matches[0] ?? null)->not->toBeNull()
|
|
->and($matches[0])->toContain("wire:click='instantSave'");
|
|
|
|
$s3Component = Livewire::test(VolumeBackups::class, [
|
|
'storage' => $volume,
|
|
'resource' => $application,
|
|
'section' => 's3',
|
|
]);
|
|
foreach (['disableLocalBackup'] as $property) {
|
|
preg_match('/<input\b(?=[^>]*wire:model=(?:"'.$property.'"|'.$property.'))[^>]*>/', $s3Component->html(), $matches);
|
|
expect($matches[0] ?? null)->not->toBeNull()
|
|
->and($matches[0])->toContain("wire:click='instantSave'");
|
|
}
|
|
|
|
$generalComponent->set('stopDuringBackup', true)->call('instantSave')->assertDispatched('success');
|
|
expect($backup->refresh()->stop_during_backup)->toBeTrue();
|
|
|
|
$generalComponent->set('stopDuringBackup', false)->call('instantSave')->assertDispatched('success');
|
|
expect($backup->refresh()->stop_during_backup)->toBeFalse();
|
|
});
|
|
|
|
it('allows volume S3 backups to be disabled when no usable storage remains', function () {
|
|
$team = Team::factory()->create();
|
|
signInForVolumeBackups($this, $team);
|
|
[$application, $volume] = createVolumeBackupApplication($team);
|
|
$backup = $volume->scheduledBackups()->create([
|
|
'team_id' => $team->id,
|
|
'frequency' => 'daily',
|
|
'save_s3' => true,
|
|
's3_storage_id' => null,
|
|
]);
|
|
|
|
$component = Livewire::test(VolumeBackups::class, [
|
|
'storage' => $volume,
|
|
'resource' => $application,
|
|
'section' => 's3',
|
|
])
|
|
->assertSet('saveToS3', true)
|
|
->assertSeeHtml('<h2>S3</h2>')
|
|
->assertSeeText('No validated S3 available. Configure one here.')
|
|
->assertSeeHtml('href="'.route('storage.index').'"')
|
|
->assertSeeHtml('>here</a>')
|
|
->assertDontSee('Save')
|
|
->assertDontSee('Disable S3')
|
|
->assertDontSee('S3 Storage')
|
|
->assertDontSee('Disable Local Backup')
|
|
->call('toggleS3')
|
|
->assertDispatched('success')
|
|
->assertSet('saveToS3', false)
|
|
->assertDontSee('Enable S3');
|
|
|
|
expect($backup->refresh()->save_s3)->toBeFalse()
|
|
->and($backup->s3_storage_id)->toBeNull();
|
|
|
|
});
|
|
|
|
it('disables S3 volume backups when the storage is deleted', function () {
|
|
$team = Team::factory()->create();
|
|
[$application, $volume] = createVolumeBackupApplication($team);
|
|
$s3Storage = S3Storage::create([
|
|
'name' => 'Volume backups',
|
|
'region' => 'us-east-1',
|
|
'key' => 'key',
|
|
'secret' => 'secret',
|
|
'bucket' => 'bucket',
|
|
'endpoint' => 'https://s3.example.com',
|
|
'team_id' => $team->id,
|
|
'is_usable' => true,
|
|
]);
|
|
$backup = $volume->scheduledBackups()->create([
|
|
'team_id' => $team->id,
|
|
's3_storage_id' => $s3Storage->id,
|
|
'frequency' => 'daily',
|
|
'save_s3' => true,
|
|
]);
|
|
$execution = ScheduledVolumeBackupExecution::create([
|
|
'scheduled_volume_backup_id' => $backup->id,
|
|
's3_storage_id' => $s3Storage->id,
|
|
'status' => 'success',
|
|
'filename' => '/data/coolify/backups/volumes/test/s3.tar.gz',
|
|
's3_uploaded' => true,
|
|
's3_cleanup_pending' => true,
|
|
]);
|
|
|
|
$s3Storage->delete();
|
|
|
|
expect($backup->fresh()->save_s3)->toBeFalse()
|
|
->and($backup->fresh()->s3_storage_id)->toBeNull()
|
|
->and($execution->fresh()->s3_storage_deleted)->toBeTrue()
|
|
->and($execution->fresh()->s3_cleanup_pending)->toBeFalse();
|
|
});
|
|
|
|
it('marks historical executions deleted when their recorded S3 storage is removed', function () {
|
|
$team = Team::factory()->create();
|
|
[$application, $volume] = createVolumeBackupApplication($team);
|
|
$originalStorage = S3Storage::create([
|
|
'name' => 'Original storage',
|
|
'region' => 'us-east-1',
|
|
'key' => 'original-key',
|
|
'secret' => 'secret',
|
|
'bucket' => 'original-bucket',
|
|
'endpoint' => 'https://s3.example.com',
|
|
'team_id' => $team->id,
|
|
]);
|
|
$newStorage = S3Storage::create([
|
|
'name' => 'New storage',
|
|
'region' => 'us-east-1',
|
|
'key' => 'new-key',
|
|
'secret' => 'secret',
|
|
'bucket' => 'new-bucket',
|
|
'endpoint' => 'https://s3.example.com',
|
|
'team_id' => $team->id,
|
|
]);
|
|
$backup = $volume->scheduledBackups()->create([
|
|
'team_id' => $team->id,
|
|
's3_storage_id' => $newStorage->id,
|
|
'frequency' => 'daily',
|
|
'save_s3' => true,
|
|
]);
|
|
$execution = ScheduledVolumeBackupExecution::create([
|
|
'scheduled_volume_backup_id' => $backup->id,
|
|
's3_storage_id' => $originalStorage->id,
|
|
'status' => 'success',
|
|
'filename' => '/data/coolify/backups/volumes/test/historical.tar.gz',
|
|
's3_uploaded' => true,
|
|
's3_cleanup_pending' => true,
|
|
]);
|
|
|
|
$originalStorage->delete();
|
|
|
|
expect($backup->fresh()->s3_storage_id)->toBe($newStorage->id)
|
|
->and($execution->fresh()->s3_storage_id)->toBeNull()
|
|
->and($execution->fresh()->s3_storage_deleted)->toBeTrue()
|
|
->and($execution->fresh()->s3_cleanup_pending)->toBeFalse();
|
|
});
|
|
|
|
it('queues a manual backup before a schedule has been saved', function () {
|
|
Queue::fake();
|
|
$team = Team::factory()->create();
|
|
signInForVolumeBackups($this, $team);
|
|
[$application, $volume] = createVolumeBackupApplication($team);
|
|
|
|
$component = Livewire::test(VolumeBackups::class, ['storage' => $volume, 'resource' => $application])
|
|
->call('backupNow')
|
|
->assertDispatched('success');
|
|
|
|
$backup = ScheduledVolumeBackup::query()->sole();
|
|
|
|
$component->assertRedirectToRoute('project.application.backup.executions', [
|
|
'project_uuid' => $application->project()->uuid,
|
|
'environment_uuid' => $application->environment->uuid,
|
|
'application_uuid' => $application->uuid,
|
|
'backup_uuid' => $backup->uuid,
|
|
]);
|
|
|
|
expect($backup->enabled)->toBeFalse();
|
|
Queue::assertPushed(VolumeBackupJob::class, fn (VolumeBackupJob $job) => $job->backup->is($backup));
|
|
});
|
|
|
|
it('queues a manual backup when its S3 storage has become unusable', function () {
|
|
Queue::fake();
|
|
$team = Team::factory()->create();
|
|
signInForVolumeBackups($this, $team);
|
|
[$application, $volume] = createVolumeBackupApplication($team);
|
|
$s3Storage = S3Storage::create([
|
|
'name' => 'Unavailable storage',
|
|
'region' => 'us-east-1',
|
|
'key' => 'key',
|
|
'secret' => 'secret',
|
|
'bucket' => 'bucket',
|
|
'endpoint' => 'https://s3.example.com',
|
|
'team_id' => $team->id,
|
|
'is_usable' => false,
|
|
]);
|
|
$backup = $volume->scheduledBackups()->create([
|
|
'team_id' => $team->id,
|
|
's3_storage_id' => $s3Storage->id,
|
|
'frequency' => 'daily',
|
|
'save_s3' => true,
|
|
]);
|
|
$parameters = [
|
|
'project_uuid' => $application->project()->uuid,
|
|
'environment_uuid' => $application->environment->uuid,
|
|
'application_uuid' => $application->uuid,
|
|
'backup_uuid' => $backup->uuid,
|
|
];
|
|
|
|
Livewire::test(VolumeBackups::class, ['storage' => $volume, 'resource' => $application])
|
|
->call('backupNow')
|
|
->assertRedirectToRoute('project.application.backup.executions', $parameters);
|
|
|
|
Queue::assertPushed(VolumeBackupJob::class, fn (VolumeBackupJob $job) => $job->backup->is($backup));
|
|
});
|
|
|
|
it('deletes local archives before deleting a volume backup schedule', function () {
|
|
Process::fake();
|
|
$team = Team::factory()->create();
|
|
signInForVolumeBackups($this, $team);
|
|
[$application, $volume] = createVolumeBackupApplication($team);
|
|
$backup = $volume->scheduledBackups()->create([
|
|
'team_id' => $team->id,
|
|
'frequency' => 'daily',
|
|
]);
|
|
ScheduledVolumeBackupExecution::create([
|
|
'scheduled_volume_backup_id' => $backup->id,
|
|
'status' => 'success',
|
|
'filename' => '/data/coolify/backups/volumes/test/archive.tar.gz',
|
|
'size' => 128,
|
|
]);
|
|
|
|
Livewire::test(VolumeBackups::class, ['storage' => $volume, 'resource' => $application])
|
|
->call('delete', 'password')
|
|
->assertDispatched('success')
|
|
->assertRedirectToRoute('project.application.backup.index', [
|
|
'project_uuid' => $application->project()->uuid,
|
|
'environment_uuid' => $application->environment->uuid,
|
|
'application_uuid' => $application->uuid,
|
|
]);
|
|
|
|
expect(ScheduledVolumeBackup::query()->count())->toBe(0);
|
|
Process::assertRan(fn ($process) => str_contains($process->command, 'rm -f')
|
|
&& str_contains($process->command, 'archive.tar.gz'));
|
|
});
|
|
|
|
it('deletes a volume backup schedule without a password when two-step confirmation is disabled', function () {
|
|
$team = Team::factory()->create();
|
|
signInForVolumeBackups($this, $team);
|
|
[$application, $volume] = createVolumeBackupApplication($team);
|
|
InstanceSettings::get()->update(['disable_two_step_confirmation' => true]);
|
|
$backup = $volume->scheduledBackups()->create([
|
|
'team_id' => $team->id,
|
|
'frequency' => 'daily',
|
|
]);
|
|
|
|
Livewire::test(VolumeBackups::class, ['storage' => $volume, 'resource' => $application])
|
|
->call('delete', '')
|
|
->assertDispatched('success');
|
|
|
|
expect($backup->fresh())->toBeNull();
|
|
});
|
|
|
|
it('deletes S3 archives from the storage recorded on each execution', function () {
|
|
$team = Team::factory()->create();
|
|
signInForVolumeBackups($this, $team);
|
|
[$application, $volume] = createVolumeBackupApplication($team);
|
|
$originalStorage = S3Storage::create([
|
|
'name' => 'Original storage',
|
|
'region' => 'us-east-1',
|
|
'key' => 'original-key',
|
|
'secret' => 'secret',
|
|
'bucket' => 'original-bucket',
|
|
'endpoint' => 'https://s3.example.com',
|
|
'team_id' => $team->id,
|
|
'is_usable' => true,
|
|
]);
|
|
$newStorage = S3Storage::create([
|
|
'name' => 'New storage',
|
|
'region' => 'us-east-1',
|
|
'key' => 'new-key',
|
|
'secret' => 'secret',
|
|
'bucket' => 'new-bucket',
|
|
'endpoint' => 'https://s3.example.com',
|
|
'team_id' => $team->id,
|
|
'is_usable' => true,
|
|
]);
|
|
$backup = $volume->scheduledBackups()->create([
|
|
'team_id' => $team->id,
|
|
's3_storage_id' => $newStorage->id,
|
|
'frequency' => 'daily',
|
|
'save_s3' => true,
|
|
]);
|
|
ScheduledVolumeBackupExecution::create([
|
|
'scheduled_volume_backup_id' => $backup->id,
|
|
's3_storage_id' => $originalStorage->id,
|
|
'status' => 'success',
|
|
'filename' => '/data/coolify/backups/volumes/test/historical.tar.gz',
|
|
'local_storage_deleted' => true,
|
|
's3_uploaded' => true,
|
|
]);
|
|
$disk = Mockery::mock();
|
|
$disk->shouldReceive('delete')
|
|
->once()
|
|
->with(['/data/coolify/backups/volumes/test/historical.tar.gz'])
|
|
->andReturnTrue();
|
|
Storage::shouldReceive('build')
|
|
->once()
|
|
->with(Mockery::on(fn (array $config): bool => $config['key'] === 'original-key'))
|
|
->andReturn($disk);
|
|
|
|
Livewire::test(VolumeBackups::class, ['storage' => $volume, 'resource' => $application])
|
|
->call('delete', 'password')
|
|
->assertDispatched('success');
|
|
|
|
expect($backup->fresh())->toBeNull();
|
|
});
|
|
|
|
it('refuses to delete a schedule while its backup is running', function () {
|
|
Process::fake();
|
|
$team = Team::factory()->create();
|
|
signInForVolumeBackups($this, $team);
|
|
[$application, $volume] = createVolumeBackupApplication($team);
|
|
$backup = $volume->scheduledBackups()->create([
|
|
'team_id' => $team->id,
|
|
'frequency' => 'daily',
|
|
]);
|
|
ScheduledVolumeBackupExecution::create([
|
|
'scheduled_volume_backup_id' => $backup->id,
|
|
'status' => 'running',
|
|
]);
|
|
|
|
Livewire::test(VolumeBackups::class, ['storage' => $volume, 'resource' => $application])
|
|
->call('delete', 'password')
|
|
->assertDispatched('error');
|
|
|
|
expect($backup->fresh())->not->toBeNull();
|
|
Process::assertNothingRan();
|
|
});
|
|
|
|
it('uses the backup operation lock while deleting a schedule', function () {
|
|
$team = Team::factory()->create();
|
|
signInForVolumeBackups($this, $team);
|
|
[$application, $volume] = createVolumeBackupApplication($team);
|
|
$backup = $volume->scheduledBackups()->create([
|
|
'team_id' => $team->id,
|
|
'frequency' => 'daily',
|
|
]);
|
|
$lock = Cache::lock(VolumeBackupJob::lockKey($backup->id), 60);
|
|
$lock->get();
|
|
|
|
try {
|
|
Livewire::test(VolumeBackups::class, ['storage' => $volume, 'resource' => $application])
|
|
->call('delete', 'password')
|
|
->assertDispatched('error');
|
|
|
|
expect($backup->fresh())->not->toBeNull();
|
|
} finally {
|
|
$lock->release();
|
|
}
|
|
});
|
|
|
|
it('prevents a volume with tracked backup archives from being deleted', function () {
|
|
$team = Team::factory()->create();
|
|
[$application, $volume] = createVolumeBackupApplication($team);
|
|
$volume->scheduledBackups()->create([
|
|
'team_id' => $team->id,
|
|
'frequency' => 'daily',
|
|
]);
|
|
|
|
expect(fn () => $volume->delete())->toThrow(RuntimeException::class, 'Delete this volume backup schedule');
|
|
expect($volume->fresh())->not->toBeNull();
|
|
});
|
|
|
|
it('aborts storage deletion when scheduled backups exist', function () {
|
|
$team = Team::factory()->create();
|
|
[$application, $volume] = createVolumeBackupApplication($team);
|
|
$directory = createApplicationBackupDirectory($application);
|
|
|
|
$volume->scheduledBackups()->create([
|
|
'team_id' => $team->id,
|
|
'frequency' => 'daily',
|
|
]);
|
|
$directory->scheduledBackups()->create([
|
|
'team_id' => $team->id,
|
|
'frequency' => 'daily',
|
|
]);
|
|
|
|
expect(fn () => $volume->abortIfScheduledBackupsExist())
|
|
->toThrow(HttpException::class, 'Delete this volume backup schedule and its archives before deleting the volume.')
|
|
->and(fn () => $directory->abortIfScheduledBackupsExist())
|
|
->toThrow(HttpException::class, 'Delete this directory backup schedule and its archives before deleting the directory.');
|
|
});
|
|
|
|
it('deletes disabled volume backup archives before deleting their application', function () {
|
|
Process::fake();
|
|
Queue::fake();
|
|
$team = Team::factory()->create();
|
|
[$application, $volume] = createVolumeBackupApplication($team);
|
|
$backup = $volume->scheduledBackups()->create([
|
|
'team_id' => $team->id,
|
|
'frequency' => 'daily',
|
|
'enabled' => false,
|
|
]);
|
|
$execution = ScheduledVolumeBackupExecution::create([
|
|
'scheduled_volume_backup_id' => $backup->id,
|
|
'status' => 'success',
|
|
'filename' => '/data/coolify/backups/volumes/test/application-delete.tar.gz',
|
|
]);
|
|
$application->delete();
|
|
|
|
(new DeleteResourceJob($application))->handle();
|
|
|
|
expect($backup->fresh())->toBeNull()
|
|
->and($execution->fresh())->toBeNull();
|
|
Process::assertRan(fn ($process) => str_contains($process->command, 'rm -f')
|
|
&& str_contains($process->command, 'application-delete.tar.gz'));
|
|
});
|
|
|
|
it('marks a running execution failed even when the job instance lost its execution state', function () {
|
|
Process::fake();
|
|
$team = Team::factory()->create();
|
|
[$application, $volume] = createVolumeBackupApplication($team);
|
|
$backup = $volume->scheduledBackups()->create([
|
|
'team_id' => $team->id,
|
|
'frequency' => 'daily',
|
|
]);
|
|
$execution = ScheduledVolumeBackupExecution::create([
|
|
'scheduled_volume_backup_id' => $backup->id,
|
|
'status' => 'running',
|
|
'filename' => '/data/coolify/backups/volumes/test/timed-out.tar.gz',
|
|
]);
|
|
|
|
(new VolumeBackupJob($backup))->failed(new RuntimeException('Worker timed out'));
|
|
|
|
expect($execution->fresh()->status)->toBe('failed')
|
|
->and($execution->fresh()->message)->toBe('Worker timed out')
|
|
->and($execution->fresh()->finished_at)->not->toBeNull()
|
|
->and($execution->fresh()->filename)->toBeNull();
|
|
Process::assertRan(fn ($process) => str_contains($process->command, 'rm -f')
|
|
&& str_contains($process->command, 'timed-out.tar.gz'));
|
|
});
|
|
|
|
it('archives a named volume on its server', function () {
|
|
config(['broadcasting.default' => 'null']);
|
|
InstanceSettings::unguarded(fn () => InstanceSettings::create(['id' => 0]));
|
|
$team = Team::factory()->create();
|
|
[$application, $volume] = createVolumeBackupApplication($team);
|
|
$backup = $volume->scheduledBackups()->create([
|
|
'team_id' => $team->id,
|
|
'frequency' => 'daily',
|
|
'enabled' => true,
|
|
'retention_amount_locally' => 7,
|
|
'retention_amount_s3' => 7,
|
|
'timeout' => 3600,
|
|
]);
|
|
|
|
Process::fake([
|
|
'*docker ps -q*' => 'abc123',
|
|
'*du -b*' => '128',
|
|
'*' => '',
|
|
]);
|
|
|
|
$job = new VolumeBackupJob($backup);
|
|
$middleware = $job->middleware();
|
|
$job->handle();
|
|
|
|
$execution = ScheduledVolumeBackupExecution::query()->sole();
|
|
|
|
expect($middleware)->toHaveCount(1)
|
|
->and($middleware[0])->toBeInstanceOf(WithoutOverlapping::class)
|
|
->and($job->queue)->toBe(crons_queue())
|
|
->and($execution->status)->toBe('success')
|
|
->and($execution->size)->toBe(128)
|
|
->and($execution->filename)->toEndWith('.tar.gz');
|
|
|
|
Process::assertRan(fn ($process) => str_contains($process->command, 'docker volume inspect')
|
|
&& str_contains($process->command, 'docker run --rm --name ')
|
|
&& str_contains($process->command, 'app-data:/volume:ro')
|
|
&& str_contains($process->command, 'tar -czf -')
|
|
&& str_contains($process->command, '> ')
|
|
&& str_contains($process->command, '.tar.gz')
|
|
&& ! str_contains($process->command, ':/backup'));
|
|
});
|
|
|
|
it('keeps the upload destination on the volume backup execution', function () {
|
|
config(['broadcasting.default' => 'null']);
|
|
InstanceSettings::unguarded(fn () => InstanceSettings::create(['id' => 0]));
|
|
$team = Team::factory()->create();
|
|
[$application, $volume] = createVolumeBackupApplication($team);
|
|
$s3Storage = S3Storage::create([
|
|
'name' => 'Execution destination',
|
|
'region' => 'us-east-1',
|
|
'key' => 'key',
|
|
'secret' => 'secret',
|
|
'bucket' => 'bucket',
|
|
'endpoint' => 'https://s3.example.com',
|
|
'team_id' => $team->id,
|
|
'is_usable' => true,
|
|
]);
|
|
$backup = $volume->scheduledBackups()->create([
|
|
'team_id' => $team->id,
|
|
'frequency' => 'daily',
|
|
'save_s3' => true,
|
|
's3_storage_id' => $s3Storage->id,
|
|
]);
|
|
$sshDisk = Storage::fake('ssh-keys');
|
|
$disk = Mockery::mock();
|
|
$disk->shouldReceive('files')->zeroOrMoreTimes()->andReturn([]);
|
|
$disk->shouldReceive('delete')->zeroOrMoreTimes()->andReturnTrue();
|
|
Storage::shouldReceive('disk')->with('ssh-keys')->andReturn($sshDisk);
|
|
Storage::shouldReceive('build')->zeroOrMoreTimes()->andReturn($disk);
|
|
Process::fake([
|
|
'*du -b*' => '128',
|
|
'*' => '',
|
|
]);
|
|
|
|
(new VolumeBackupJob($backup))->handle();
|
|
|
|
$execution = ScheduledVolumeBackupExecution::query()->sole();
|
|
|
|
expect($execution->s3_storage_id)->toBe($s3Storage->id)
|
|
->and($execution->s3->is($s3Storage))->toBeTrue();
|
|
});
|
|
|
|
it('removes local volume backups older than the configured retention days', function () {
|
|
config(['broadcasting.default' => 'null']);
|
|
InstanceSettings::unguarded(fn () => InstanceSettings::create(['id' => 0]));
|
|
$team = Team::factory()->create();
|
|
[$application, $volume] = createVolumeBackupApplication($team);
|
|
$backup = $volume->scheduledBackups()->create([
|
|
'team_id' => $team->id,
|
|
'frequency' => 'daily',
|
|
'retention_amount_locally' => 0,
|
|
'retention_days_locally' => 7,
|
|
'retention_max_storage_locally' => 0,
|
|
]);
|
|
$expiredExecution = ScheduledVolumeBackupExecution::create([
|
|
'scheduled_volume_backup_id' => $backup->id,
|
|
'status' => 'success',
|
|
'filename' => '/data/coolify/backups/volumes/test/expired.tar.gz',
|
|
'size' => 64,
|
|
]);
|
|
ScheduledVolumeBackupExecution::query()->whereKey($expiredExecution)->update(['created_at' => now()->subDays(8)]);
|
|
$recentExecution = ScheduledVolumeBackupExecution::create([
|
|
'scheduled_volume_backup_id' => $backup->id,
|
|
'status' => 'success',
|
|
'filename' => '/data/coolify/backups/volumes/test/recent.tar.gz',
|
|
'size' => 64,
|
|
]);
|
|
ScheduledVolumeBackupExecution::query()->whereKey($recentExecution)->update(['created_at' => now()->subDay()]);
|
|
|
|
Process::fake([
|
|
'*du -b*' => '128',
|
|
'*' => '',
|
|
]);
|
|
|
|
(new VolumeBackupJob($backup))->handle();
|
|
|
|
expect($expiredExecution->fresh())->toBeNull()
|
|
->and($recentExecution->fresh())->not->toBeNull();
|
|
Process::assertRan(fn ($process) => str_contains($process->command, 'rm -f')
|
|
&& str_contains($process->command, 'expired.tar.gz'));
|
|
});
|
|
|
|
it('removes oldest local volume backups over the configured storage limit', function () {
|
|
config(['broadcasting.default' => 'null']);
|
|
InstanceSettings::unguarded(fn () => InstanceSettings::create(['id' => 0]));
|
|
$team = Team::factory()->create();
|
|
[$application, $volume] = createVolumeBackupApplication($team);
|
|
$backup = $volume->scheduledBackups()->create([
|
|
'team_id' => $team->id,
|
|
'frequency' => 'daily',
|
|
'retention_amount_locally' => 0,
|
|
'retention_days_locally' => 0,
|
|
'retention_max_storage_locally' => 0.00000015,
|
|
]);
|
|
$oldExecution = ScheduledVolumeBackupExecution::create([
|
|
'scheduled_volume_backup_id' => $backup->id,
|
|
'status' => 'success',
|
|
'filename' => '/data/coolify/backups/volumes/test/over-limit.tar.gz',
|
|
'size' => 64,
|
|
]);
|
|
ScheduledVolumeBackupExecution::query()->whereKey($oldExecution)->update(['created_at' => now()->subDay()]);
|
|
|
|
Process::fake([
|
|
'*du -b*' => '128',
|
|
'*' => '',
|
|
]);
|
|
|
|
(new VolumeBackupJob($backup))->handle();
|
|
|
|
expect($oldExecution->fresh())->toBeNull()
|
|
->and($backup->executions()->where('status', 'success')->count())->toBe(1);
|
|
Process::assertRan(fn ($process) => str_contains($process->command, 'rm -f')
|
|
&& str_contains($process->command, 'over-limit.tar.gz'));
|
|
});
|
|
|
|
it('removes retained S3 archives from the storage recorded on each execution', function () {
|
|
$team = Team::factory()->create();
|
|
[$application, $volume, $server] = createVolumeBackupApplication($team);
|
|
$originalStorage = S3Storage::create([
|
|
'name' => 'Original storage',
|
|
'region' => 'us-east-1',
|
|
'key' => 'original-key',
|
|
'secret' => 'secret',
|
|
'bucket' => 'original-bucket',
|
|
'endpoint' => 'https://s3.example.com',
|
|
'team_id' => $team->id,
|
|
'is_usable' => true,
|
|
]);
|
|
$newStorage = S3Storage::create([
|
|
'name' => 'New storage',
|
|
'region' => 'us-east-1',
|
|
'key' => 'new-key',
|
|
'secret' => 'secret',
|
|
'bucket' => 'new-bucket',
|
|
'endpoint' => 'https://s3.example.com',
|
|
'team_id' => $team->id,
|
|
'is_usable' => true,
|
|
]);
|
|
$backup = $volume->scheduledBackups()->create([
|
|
'team_id' => $team->id,
|
|
's3_storage_id' => $newStorage->id,
|
|
'frequency' => 'daily',
|
|
'save_s3' => true,
|
|
'retention_amount_locally' => 0,
|
|
'retention_days_locally' => 0,
|
|
'retention_max_storage_locally' => 0,
|
|
'retention_amount_s3' => 1,
|
|
'retention_days_s3' => 0,
|
|
'retention_max_storage_s3' => 0,
|
|
]);
|
|
$oldExecution = ScheduledVolumeBackupExecution::create([
|
|
'scheduled_volume_backup_id' => $backup->id,
|
|
's3_storage_id' => $originalStorage->id,
|
|
'status' => 'success',
|
|
'filename' => '/data/coolify/backups/volumes/test/old.tar.gz',
|
|
's3_uploaded' => true,
|
|
'created_at' => now()->subDay(),
|
|
]);
|
|
ScheduledVolumeBackupExecution::create([
|
|
'scheduled_volume_backup_id' => $backup->id,
|
|
's3_storage_id' => $newStorage->id,
|
|
'status' => 'success',
|
|
'filename' => '/data/coolify/backups/volumes/test/new.tar.gz',
|
|
's3_uploaded' => true,
|
|
]);
|
|
$disk = Mockery::mock();
|
|
$disk->shouldReceive('delete')->once()->with(['/data/coolify/backups/volumes/test/old.tar.gz'])->andReturnTrue();
|
|
Storage::shouldReceive('build')
|
|
->once()
|
|
->with(Mockery::on(fn (array $config): bool => $config['key'] === 'original-key'))
|
|
->andReturn($disk);
|
|
$job = new VolumeBackupJob($backup);
|
|
$method = (new ReflectionClass($job))->getMethod('removeExpiredBackups');
|
|
|
|
$method->invoke($job, $server);
|
|
|
|
expect($oldExecution->fresh()->s3_storage_deleted)->toBeTrue();
|
|
});
|
|
|
|
it('does not query execution history when local retention is unlimited', function () {
|
|
$team = Team::factory()->create();
|
|
[$application, $volume, $server] = createVolumeBackupApplication($team);
|
|
$backup = $volume->scheduledBackups()->create([
|
|
'team_id' => $team->id,
|
|
'frequency' => 'daily',
|
|
'save_s3' => false,
|
|
'retention_amount_locally' => 0,
|
|
'retention_days_locally' => 0,
|
|
'retention_max_storage_locally' => 0,
|
|
]);
|
|
ScheduledVolumeBackupExecution::create([
|
|
'scheduled_volume_backup_id' => $backup->id,
|
|
'status' => 'success',
|
|
'filename' => '/data/coolify/backups/volumes/test/unlimited.tar.gz',
|
|
]);
|
|
$job = new VolumeBackupJob($backup);
|
|
$method = (new ReflectionClass($job))->getMethod('removeExpiredBackups');
|
|
DB::enableQueryLog();
|
|
DB::flushQueryLog();
|
|
|
|
$method->invoke($job, $server);
|
|
|
|
$executionQueries = collect(DB::getQueryLog())
|
|
->filter(fn (array $query): bool => str_contains($query['query'], 'scheduled_volume_backup_executions'))
|
|
->filter(fn (array $query): bool => str_starts_with(strtolower(ltrim($query['query'])), 'select'));
|
|
|
|
expect($executionQueries)->toBeEmpty();
|
|
});
|
|
|
|
it('keeps a successful backup successful when retention cleanup fails', function () {
|
|
config(['broadcasting.default' => 'null']);
|
|
InstanceSettings::unguarded(fn () => InstanceSettings::create(['id' => 0]));
|
|
$team = Team::factory()->create();
|
|
[$application, $volume] = createVolumeBackupApplication($team);
|
|
$backup = $volume->scheduledBackups()->create([
|
|
'team_id' => $team->id,
|
|
'frequency' => 'daily',
|
|
'disable_local_backup' => true,
|
|
'retention_amount_locally' => 1,
|
|
]);
|
|
$oldExecution = ScheduledVolumeBackupExecution::create([
|
|
'scheduled_volume_backup_id' => $backup->id,
|
|
'status' => 'success',
|
|
'filename' => '/data/coolify/backups/volumes/test/old.tar.gz',
|
|
'size' => 64,
|
|
'created_at' => now()->subDay(),
|
|
]);
|
|
|
|
Process::fake([
|
|
'*rm -f*' => Process::result(errorOutput: 'permission denied', exitCode: 1),
|
|
'*du -b*' => '128',
|
|
'*' => '',
|
|
]);
|
|
|
|
(new VolumeBackupJob($backup))->handle();
|
|
|
|
$latestExecution = $backup->executions()->first();
|
|
|
|
expect($latestExecution->status)->toBe('success')
|
|
->and($latestExecution->filename)->not->toBeNull()
|
|
->and($oldExecution->fresh()->local_storage_deleted)->toBeFalse();
|
|
});
|
|
|
|
it('stops and restarts containers that use the volume when requested', function () {
|
|
config(['broadcasting.default' => 'null']);
|
|
InstanceSettings::unguarded(fn () => InstanceSettings::create(['id' => 0]));
|
|
$team = Team::factory()->create();
|
|
[$application, $volume] = createVolumeBackupApplication($team);
|
|
$backup = $volume->scheduledBackups()->create([
|
|
'team_id' => $team->id,
|
|
'frequency' => 'daily',
|
|
'stop_during_backup' => true,
|
|
]);
|
|
|
|
Process::fake([
|
|
'*docker ps -q*' => "abc123\ndef456",
|
|
'*du -b*' => '128',
|
|
'*' => '',
|
|
]);
|
|
|
|
(new VolumeBackupJob($backup))->handle();
|
|
|
|
Process::assertRan(fn ($process) => str_contains($process->command, 'trap cleanup EXIT')
|
|
&& str_contains($process->command, 'exit 143')
|
|
&& str_contains($process->command, 'TERM')
|
|
&& str_contains($process->command, 'docker stop')
|
|
&& str_contains($process->command, 'docker start')
|
|
&& str_contains($process->command, 'state_file='));
|
|
Process::assertRan(fn ($process) => str_contains($process->command, '{{println .Source}}{{println .Name}}')
|
|
&& str_contains($process->command, "grep -Fqx -- 'app-data'")
|
|
&& str_contains($process->command, '{{.State.Paused}}')
|
|
&& str_contains($process->command, 'continue'));
|
|
});
|
|
|
|
it('finds containers using a bind mounted host path before stopping', function () {
|
|
config(['broadcasting.default' => 'null']);
|
|
InstanceSettings::unguarded(fn () => InstanceSettings::create(['id' => 0]));
|
|
$team = Team::factory()->create();
|
|
[$application, $volume] = createVolumeBackupApplication($team);
|
|
$volume->update(['host_path' => '/srv/app-data']);
|
|
$backup = $volume->scheduledBackups()->create([
|
|
'team_id' => $team->id,
|
|
'frequency' => 'daily',
|
|
'stop_during_backup' => true,
|
|
]);
|
|
|
|
Process::fake([
|
|
'*docker ps -q*' => 'abc123',
|
|
'*du -b*' => '128',
|
|
'*' => '',
|
|
]);
|
|
|
|
(new VolumeBackupJob($backup))->handle();
|
|
|
|
Process::assertRan(fn ($process) => str_contains($process->command, '{{println .Source}}{{println .Name}}')
|
|
&& str_contains($process->command, "grep -Fqx -- '/srv/app-data'"));
|
|
});
|
|
|
|
it('archives an application directory mount from its resolved host path', function () {
|
|
config(['broadcasting.default' => 'null']);
|
|
InstanceSettings::unguarded(fn () => InstanceSettings::create(['id' => 0]));
|
|
$team = Team::factory()->create();
|
|
[$application] = createVolumeBackupApplication($team);
|
|
$directory = LocalFileVolume::unguarded(fn () => LocalFileVolume::withoutEvents(fn () => LocalFileVolume::create([
|
|
'uuid' => new_public_id(),
|
|
'fs_path' => './uploads',
|
|
'mount_path' => '/app/uploads',
|
|
'is_directory' => true,
|
|
'resource_id' => $application->id,
|
|
'resource_type' => $application->getMorphClass(),
|
|
])));
|
|
$backup = $directory->scheduledBackups()->create([
|
|
'team_id' => $team->id,
|
|
'frequency' => 'daily',
|
|
'stop_during_backup' => true,
|
|
]);
|
|
$source = $application->workdir().'/uploads';
|
|
|
|
Process::fake([
|
|
'*docker ps -q*' => 'abc123',
|
|
'*du -b*' => '128',
|
|
'*' => '',
|
|
]);
|
|
|
|
(new VolumeBackupJob($backup))->handle();
|
|
|
|
expect(ScheduledVolumeBackupExecution::query()->sole()->status)->toBe('success');
|
|
Process::assertRan(fn ($process) => str_contains($process->command, 'test -d '.escapeshellarg($source)));
|
|
Process::assertRan(fn ($process) => str_contains($process->command, $source)
|
|
&& str_contains($process->command, ':/volume:ro')
|
|
&& str_contains($process->command, 'directory-uploads-'));
|
|
Process::assertRan(fn ($process) => str_contains($process->command, "grep -Fqx -- '".$source."'"));
|
|
Process::assertRan(fn ($process) => str_contains($process->command, 'docker stop')
|
|
&& str_contains($process->command, 'docker start'));
|
|
});
|
|
|
|
it('retries container recovery when a timed out backup left a container stopped', function () {
|
|
Queue::fake();
|
|
$team = Team::factory()->create();
|
|
[$application, $volume] = createVolumeBackupApplication($team);
|
|
$backup = $volume->scheduledBackups()->create([
|
|
'team_id' => $team->id,
|
|
'frequency' => 'daily',
|
|
]);
|
|
$execution = ScheduledVolumeBackupExecution::create([
|
|
'scheduled_volume_backup_id' => $backup->id,
|
|
'status' => 'running',
|
|
'stop_recovery_pending' => true,
|
|
]);
|
|
Process::fake([
|
|
'*cat *coolify-volume-backup-*' => 'abc123',
|
|
'*docker start*' => Process::result(errorOutput: 'daemon unavailable', exitCode: 1),
|
|
]);
|
|
|
|
(new VolumeBackupJob($backup))->failed(new RuntimeException('Worker timed out'));
|
|
|
|
expect($execution->fresh()->status)->toBe('failed')
|
|
->and($execution->fresh()->message)->toContain('Container recovery failed')
|
|
->and($execution->fresh()->stop_container_ids)->toBe(['abc123'])
|
|
->and($execution->fresh()->stop_recovery_pending)->toBeTrue();
|
|
Queue::assertPushed(VolumeBackupRecoveryJob::class);
|
|
});
|
|
|
|
it('restarts containers and removes a partial archive when archiving fails', function () {
|
|
config(['broadcasting.default' => 'null']);
|
|
InstanceSettings::unguarded(fn () => InstanceSettings::create(['id' => 0]));
|
|
$team = Team::factory()->create();
|
|
[$application, $volume] = createVolumeBackupApplication($team);
|
|
$backup = $volume->scheduledBackups()->create([
|
|
'team_id' => $team->id,
|
|
'frequency' => 'daily',
|
|
'stop_during_backup' => true,
|
|
]);
|
|
Process::fake([
|
|
'*docker ps -q*' => 'abc123',
|
|
'*trap cleanup EXIT*' => Process::result(errorOutput: 'archive failed', exitCode: 1),
|
|
'*' => '',
|
|
]);
|
|
|
|
expect(fn () => (new VolumeBackupJob($backup))->handle())->toThrow(RuntimeException::class);
|
|
|
|
$execution = ScheduledVolumeBackupExecution::query()->sole();
|
|
expect($execution->status)->toBe('failed')
|
|
->and($execution->stop_container_ids)->toBeNull();
|
|
Process::assertRan(fn ($process) => str_contains($process->command, 'rm -f')
|
|
&& str_contains($process->command, '.tar.gz'));
|
|
});
|
|
|
|
it('throws when the S3 filesystem reports that deletion failed', function () {
|
|
$disk = Mockery::mock();
|
|
$disk->shouldReceive('delete')->once()->andReturnFalse();
|
|
Storage::shouldReceive('build')->once()->andReturn($disk);
|
|
$s3 = new S3Storage([
|
|
'key' => 'key',
|
|
'secret' => 'secret',
|
|
'region' => 'us-east-1',
|
|
'bucket' => 'bucket',
|
|
'endpoint' => 'https://s3.example.com',
|
|
]);
|
|
|
|
expect(fn () => deleteBackupsS3('archive.tar.gz', $s3))
|
|
->toThrow(RuntimeException::class, 'could not be deleted');
|
|
});
|
|
|
|
it('cleans an interrupted S3 upload and coordinates recovery with the backup lock', function () {
|
|
$team = Team::factory()->create();
|
|
[$application, $volume] = createVolumeBackupApplication($team);
|
|
$s3Storage = S3Storage::create([
|
|
'name' => 'Volume backups',
|
|
'region' => 'us-east-1',
|
|
'key' => 'key',
|
|
'secret' => 'secret',
|
|
'bucket' => 'bucket',
|
|
'endpoint' => 'https://s3.example.com',
|
|
'team_id' => $team->id,
|
|
'is_usable' => true,
|
|
]);
|
|
$backup = $volume->scheduledBackups()->create([
|
|
'team_id' => $team->id,
|
|
's3_storage_id' => $s3Storage->id,
|
|
'frequency' => 'daily',
|
|
'save_s3' => true,
|
|
]);
|
|
$execution = ScheduledVolumeBackupExecution::create([
|
|
'scheduled_volume_backup_id' => $backup->id,
|
|
's3_storage_id' => $s3Storage->id,
|
|
'status' => 'failed',
|
|
'filename' => '/data/coolify/backups/volumes/test/interrupted.tar.gz',
|
|
's3_cleanup_pending' => true,
|
|
]);
|
|
$disk = Mockery::mock();
|
|
$disk->shouldReceive('delete')->once()->andReturnTrue();
|
|
Storage::shouldReceive('build')->once()->andReturn($disk);
|
|
$job = new VolumeBackupRecoveryJob($execution);
|
|
|
|
expect($job->middleware()[0]->getLockKey($job))->toBe(VolumeBackupJob::lockKey($backup->id));
|
|
$job->handle();
|
|
|
|
expect($execution->fresh()->s3_cleanup_pending)->toBeFalse()
|
|
->and($execution->fresh()->s3_storage_deleted)->toBeTrue();
|
|
});
|
|
|
|
it('cleans an interrupted upload from the execution S3 storage after a schedule switch', function () {
|
|
$team = Team::factory()->create();
|
|
[$application, $volume] = createVolumeBackupApplication($team);
|
|
$originalStorage = S3Storage::create([
|
|
'name' => 'Original storage',
|
|
'region' => 'us-east-1',
|
|
'key' => 'original-key',
|
|
'secret' => 'secret',
|
|
'bucket' => 'original-bucket',
|
|
'endpoint' => 'https://s3.example.com',
|
|
'team_id' => $team->id,
|
|
'is_usable' => true,
|
|
]);
|
|
$newStorage = S3Storage::create([
|
|
'name' => 'New storage',
|
|
'region' => 'us-east-1',
|
|
'key' => 'new-key',
|
|
'secret' => 'secret',
|
|
'bucket' => 'new-bucket',
|
|
'endpoint' => 'https://s3.example.com',
|
|
'team_id' => $team->id,
|
|
'is_usable' => true,
|
|
]);
|
|
$backup = $volume->scheduledBackups()->create([
|
|
'team_id' => $team->id,
|
|
's3_storage_id' => $newStorage->id,
|
|
'frequency' => 'daily',
|
|
'save_s3' => true,
|
|
]);
|
|
$execution = ScheduledVolumeBackupExecution::create([
|
|
'scheduled_volume_backup_id' => $backup->id,
|
|
's3_storage_id' => $originalStorage->id,
|
|
'status' => 'failed',
|
|
'filename' => '/data/coolify/backups/volumes/test/interrupted.tar.gz',
|
|
's3_cleanup_pending' => true,
|
|
]);
|
|
$disk = Mockery::mock();
|
|
$disk->shouldReceive('delete')->once()->andReturnTrue();
|
|
Storage::shouldReceive('build')
|
|
->once()
|
|
->with(Mockery::on(fn (array $config): bool => $config['key'] === 'original-key'))
|
|
->andReturn($disk);
|
|
|
|
VolumeBackupRecoveryJob::cleanupS3Upload($execution);
|
|
|
|
expect($execution->fresh()->s3_cleanup_pending)->toBeFalse()
|
|
->and($execution->fresh()->s3_storage_deleted)->toBeTrue();
|
|
});
|
|
|
|
it('keeps the S3 key tracked when interrupted upload cleanup must be retried', function () {
|
|
Queue::fake();
|
|
Process::fake();
|
|
$team = Team::factory()->create();
|
|
[$application, $volume] = createVolumeBackupApplication($team);
|
|
$s3Storage = S3Storage::create([
|
|
'name' => 'Volume backups',
|
|
'region' => 'us-east-1',
|
|
'key' => 'key',
|
|
'secret' => 'secret',
|
|
'bucket' => 'bucket',
|
|
'endpoint' => 'https://s3.example.com',
|
|
'team_id' => $team->id,
|
|
'is_usable' => true,
|
|
]);
|
|
$backup = $volume->scheduledBackups()->create([
|
|
'team_id' => $team->id,
|
|
's3_storage_id' => $s3Storage->id,
|
|
'frequency' => 'daily',
|
|
'save_s3' => true,
|
|
]);
|
|
$execution = ScheduledVolumeBackupExecution::create([
|
|
'scheduled_volume_backup_id' => $backup->id,
|
|
's3_storage_id' => $s3Storage->id,
|
|
'status' => 'running',
|
|
'filename' => '/data/coolify/backups/volumes/test/interrupted.tar.gz',
|
|
's3_cleanup_pending' => true,
|
|
]);
|
|
$disk = Mockery::mock();
|
|
$disk->shouldReceive('delete')->once()->andReturnFalse();
|
|
Storage::shouldReceive('build')->once()->andReturn($disk);
|
|
|
|
(new VolumeBackupJob($backup))->failed(new RuntimeException('Worker timed out'));
|
|
|
|
expect($execution->fresh()->filename)->toBe('/data/coolify/backups/volumes/test/interrupted.tar.gz')
|
|
->and($execution->fresh()->s3_cleanup_pending)->toBeTrue();
|
|
Queue::assertPushed(VolumeBackupRecoveryJob::class);
|
|
});
|
|
|
|
it('dispatches due scheduled volume backups', function () {
|
|
config(['constants.coolify.self_hosted' => true]);
|
|
Carbon::setTestNow('2026-07-15 12:00:00');
|
|
Queue::fake();
|
|
$team = Team::factory()->create();
|
|
[$application, $volume, $server] = createVolumeBackupApplication($team);
|
|
$server->settings()->update([
|
|
'is_reachable' => true,
|
|
'is_usable' => true,
|
|
'force_disabled' => false,
|
|
]);
|
|
expect($server->fresh()->isFunctional())->toBeTrue();
|
|
$backup = $volume->scheduledBackups()->create([
|
|
'team_id' => $team->id,
|
|
'frequency' => '* * * * *',
|
|
'enabled' => true,
|
|
]);
|
|
Cache::forget("scheduled-volume-backup:{$backup->id}");
|
|
expect($backup->server()?->isFunctional())->toBeTrue();
|
|
|
|
(new ScheduledJobManager)->handle();
|
|
|
|
Queue::assertPushed(
|
|
VolumeBackupJob::class,
|
|
fn (VolumeBackupJob $job) => $job->backup->is($backup),
|
|
);
|
|
});
|
|
|
|
it('dispatches due scheduled directory backups', function () {
|
|
config(['constants.coolify.self_hosted' => true]);
|
|
Carbon::setTestNow('2026-07-15 12:00:00');
|
|
Queue::fake();
|
|
$team = Team::factory()->create();
|
|
[$application, $volume, $server] = createVolumeBackupApplication($team);
|
|
$directory = createApplicationBackupDirectory($application);
|
|
$server->settings()->update([
|
|
'is_reachable' => true,
|
|
'is_usable' => true,
|
|
'force_disabled' => false,
|
|
]);
|
|
$backup = $directory->scheduledBackups()->create([
|
|
'team_id' => $team->id,
|
|
'frequency' => '* * * * *',
|
|
'enabled' => true,
|
|
]);
|
|
Cache::forget("scheduled-volume-backup:{$backup->id}");
|
|
|
|
(new ScheduledJobManager)->handle();
|
|
|
|
Queue::assertPushed(
|
|
VolumeBackupJob::class,
|
|
fn (VolumeBackupJob $job) => $job->backup->is($backup),
|
|
);
|
|
});
|
|
|
|
it('retains scheduled volume backups and archive metadata when the server is missing', function () {
|
|
config(['constants.coolify.self_hosted' => true]);
|
|
Queue::fake();
|
|
$team = Team::factory()->create();
|
|
[$application, $volume] = createVolumeBackupApplication($team);
|
|
$backup = $volume->scheduledBackups()->create([
|
|
'team_id' => $team->id,
|
|
'frequency' => '* * * * *',
|
|
'enabled' => true,
|
|
]);
|
|
$execution = ScheduledVolumeBackupExecution::create([
|
|
'scheduled_volume_backup_id' => $backup->id,
|
|
'status' => 'success',
|
|
'filename' => '/data/coolify/backups/volumes/test/archive.tar.gz',
|
|
]);
|
|
$application->update([
|
|
'destination_id' => null,
|
|
'destination_type' => null,
|
|
]);
|
|
|
|
(new ScheduledJobManager)->handle();
|
|
|
|
expect($backup->fresh())->not->toBeNull()
|
|
->and($execution->fresh())->not->toBeNull()
|
|
->and($execution->fresh()->filename)->toBe('/data/coolify/backups/volumes/test/archive.tar.gz');
|
|
Queue::assertNotPushed(VolumeBackupJob::class);
|
|
});
|
|
|
|
it('dispatches pending recovery without starting another volume backup', function () {
|
|
config(['constants.coolify.self_hosted' => true]);
|
|
Queue::fake();
|
|
$team = Team::factory()->create();
|
|
[$application, $volume, $server] = createVolumeBackupApplication($team);
|
|
$server->settings()->update([
|
|
'is_reachable' => true,
|
|
'is_usable' => true,
|
|
'force_disabled' => false,
|
|
]);
|
|
$backup = $volume->scheduledBackups()->create([
|
|
'team_id' => $team->id,
|
|
'frequency' => '* * * * *',
|
|
'enabled' => true,
|
|
]);
|
|
$execution = ScheduledVolumeBackupExecution::create([
|
|
'scheduled_volume_backup_id' => $backup->id,
|
|
'status' => 'failed',
|
|
'stop_recovery_pending' => true,
|
|
]);
|
|
|
|
(new ScheduledJobManager)->handle();
|
|
|
|
Queue::assertPushed(
|
|
VolumeBackupRecoveryJob::class,
|
|
fn (VolumeBackupRecoveryJob $job) => $job->execution->is($execution),
|
|
);
|
|
Queue::assertNotPushed(VolumeBackupJob::class);
|
|
});
|