feat(api): add volume backup schedule delete endpoints

Expose DELETE for application, database, and service storage backup
schedules (with OpenAPI docs), reject storage deletes while schedules
exist, skip retention cleanup when no limits are set, and remove S3
archives using the execution’s S3 storage.
This commit is contained in:
Andras Bacsai committed 2026-07-16 21:44:48 +02:00
1 parent fab012b5c8
commit 28f8867567
11 files changed
+548 -16

No files matched your search

@@ -16,11 +16,15 @@ use App\Models\StandalonePostgresql;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
beforeEach(function () {
Config::set('cache.default', 'array');
Config::set('app.maintenance.store', 'array');
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0]));
$this->team = Team::factory()->create();
@@ -165,7 +169,46 @@ it('sets a directory backup schedule through the API', function () {
expect(ScheduledVolumeBackup::query()->sole()->backupable->is($directory))->toBeTrue();
});
it('sets database and service volume backup schedules through their storage APIs', function () {
it('refuses to delete an application directory before its backup schedule is removed', function () {
Process::fake();
$directory = LocalFileVolume::unguarded(fn () => LocalFileVolume::withoutEvents(fn () => LocalFileVolume::create([
'uuid' => new_public_id(),
'fs_path' => './uploads',
'mount_path' => '/app/uploads',
'is_directory' => true,
'is_host_file' => false,
'resource_id' => $this->application->id,
'resource_type' => $this->application->getMorphClass(),
])));
$directory->scheduledBackups()->create([
'team_id' => $this->team->id,
'frequency' => 'daily',
]);
$this->withHeaders($this->headers)
->deleteJson("/api/v1/applications/{$this->application->uuid}/storages/{$directory->uuid}")
->assertUnprocessable()
->assertJsonPath('message', 'Delete this directory backup schedule and its archives before deleting the directory.');
expect($directory->fresh())->not->toBeNull();
Process::assertNothingRan();
});
it('deletes an application volume backup schedule through the API', function () {
$backup = $this->volume->scheduledBackups()->create([
'team_id' => $this->team->id,
'frequency' => 'daily',
]);
$this->withHeaders($this->headers)
->deleteJson("/api/v1/applications/{$this->application->uuid}/storages/{$this->volume->uuid}/backups")
->assertOk()
->assertJsonPath('message', 'Storage backup schedule and archives deleted.');
expect($backup->fresh())->toBeNull();
});
it('sets and deletes database and service volume backup schedules through their storage APIs', function () {
$database = StandalonePostgresql::create([
'name' => 'api-postgres',
'image' => 'postgres:17-alpine',
@@ -205,6 +248,16 @@ it('sets database and service volume backup schedules through their storage APIs
expect($databaseVolume->scheduledBackups()->sole()->frequency)->toBe('daily')
->and($serviceVolume->scheduledBackups()->sole()->frequency)->toBe('weekly');
$this->withHeaders($this->headers)
->deleteJson("/api/v1/databases/{$database->uuid}/storages/{$databaseVolume->uuid}/backups")
->assertOk();
$this->withHeaders($this->headers)
->deleteJson("/api/v1/services/{$service->uuid}/storages/{$serviceVolume->uuid}/backups")
->assertOk();
expect($databaseVolume->scheduledBackups()->count())->toBe(0)
->and($serviceVolume->scheduledBackups()->count())->toBe(0);
});
it('rejects ineligible file storages and invalid schedule settings', function () {
+87
View File
@@ -26,6 +26,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Queue\Middleware\WithoutOverlapping;
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;
@@ -578,6 +579,62 @@ it('deletes an individual volume backup archive and execution', function () {
&& 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();
@@ -1547,6 +1604,36 @@ it('removes retained S3 archives from the storage recorded on each execution', f
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]));