coolify/tests/Feature/VolumeBackupTest.php
Andras Bacsai ac36a59088 fix(backups): pin S3 per volume execution and harden deletes
Store s3_storage_id on scheduled volume backup executions so retention
and recovery use the S3 that received the upload. Extract
DeleteScheduledVolumeBackup for UI and resource deletion, and isolate
database backup retention failures so cleanup errors do not fail the job.
2026-07-16 14:30:58 +02:00

1937 lines
73 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\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
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;
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('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('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('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('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('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 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('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('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('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);
});