coolify/tests/Feature/VolumeBackupTest.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);
});