mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-18 00:15:00 +00:00
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:
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 () {
|
||||
|
||||
@@ -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]));
|
||||
|
||||
Reference in new issue
Block a user