From 28f886756798135874d8261ae9ba82e2fe29fdb7 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 16 Jul 2026 21:44:48 +0200 Subject: [PATCH] feat(api): add volume backup schedule delete endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../Api/ApplicationsController.php | 8 + .../Controllers/Api/DatabasesController.php | 8 + .../Controllers/Api/ServicesController.php | 8 + .../Api/VolumeBackupsController.php | 101 ++++++++++++ app/Jobs/VolumeBackupJob.php | 41 +++-- .../Project/Shared/Storages/VolumeBackups.php | 4 +- openapi.json | 144 ++++++++++++++++++ openapi.yaml | 99 ++++++++++++ routes/api.php | 9 ++ .../Api/VolumeBackupScheduleApiTest.php | 55 ++++++- tests/Feature/VolumeBackupTest.php | 87 +++++++++++ 11 files changed, 548 insertions(+), 16 deletions(-) diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 593207735..aaf2ba76b 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -4904,6 +4904,14 @@ public function delete_storage(Request $request): JsonResponse ], 422); } + if ($storage->scheduledBackups()->exists()) { + return response()->json([ + 'message' => $storage instanceof LocalFileVolume + ? 'Delete this directory backup schedule and its archives before deleting the directory.' + : 'Delete this volume backup schedule and its archives before deleting the volume.', + ], 422); + } + if ($storage instanceof LocalFileVolume) { $storage->deleteStorageOnServer(); } diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index d06e2eac5..7216a30bf 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -4478,6 +4478,14 @@ public function delete_storage(Request $request): JsonResponse ], 422); } + if ($storage->scheduledBackups()->exists()) { + return response()->json([ + 'message' => $storage instanceof LocalFileVolume + ? 'Delete this directory backup schedule and its archives before deleting the directory.' + : 'Delete this volume backup schedule and its archives before deleting the volume.', + ], 422); + } + if ($storage instanceof LocalFileVolume) { $storage->deleteStorageOnServer(); } diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 9fa512c6b..3e22bb6a0 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -2898,6 +2898,14 @@ public function delete_storage(Request $request): JsonResponse ], 422); } + if ($storage->scheduledBackups()->exists()) { + return response()->json([ + 'message' => $storage instanceof LocalFileVolume + ? 'Delete this directory backup schedule and its archives before deleting the directory.' + : 'Delete this volume backup schedule and its archives before deleting the volume.', + ], 422); + } + if ($storage instanceof LocalFileVolume) { $storage->deleteStorageOnServer(); } diff --git a/app/Http/Controllers/Api/VolumeBackupsController.php b/app/Http/Controllers/Api/VolumeBackupsController.php index d6dc63f45..fdd8caf13 100644 --- a/app/Http/Controllers/Api/VolumeBackupsController.php +++ b/app/Http/Controllers/Api/VolumeBackupsController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Api; +use App\Actions\Shared\DeleteScheduledVolumeBackup; use App\Http\Controllers\Controller; use App\Models\Application; use App\Models\LocalFileVolume; @@ -13,6 +14,7 @@ use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use OpenApi\Attributes as OA; +use RuntimeException; #[OA\Schema( schema: 'VolumeBackupScheduleRequest', @@ -257,6 +259,105 @@ public function upsert(Request $request): JsonResponse return response()->json($this->responseData($backup, $storage, $s3Storage, $created), $created ? 201 : 200); } + #[OA\Delete( + summary: 'Delete application storage backup schedule', + description: 'Delete the backup schedule and its local and S3 archives for an application storage.', + path: '/applications/{uuid}/storages/{storage_uuid}/backups', + operationId: 'delete-application-storage-backup-schedule', + security: [['bearerAuth' => []]], + tags: ['Applications'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'storage_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response(response: 200, description: 'Backup schedule and archives deleted.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 403, description: 'Forbidden.'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 409, description: 'Backup or recovery operation is still running.'), + ], + )] + #[OA\Delete( + summary: 'Delete database storage backup schedule', + description: 'Delete the backup schedule and its local and S3 archives for a database storage.', + path: '/databases/{uuid}/storages/{storage_uuid}/backups', + operationId: 'delete-database-storage-backup-schedule', + security: [['bearerAuth' => []]], + tags: ['Databases'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'storage_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response(response: 200, description: 'Backup schedule and archives deleted.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 403, description: 'Forbidden.'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 409, description: 'Backup or recovery operation is still running.'), + ], + )] + #[OA\Delete( + summary: 'Delete service storage backup schedule', + description: 'Delete the backup schedule and its local and S3 archives for a service storage.', + path: '/services/{uuid}/storages/{storage_uuid}/backups', + operationId: 'delete-service-storage-backup-schedule', + security: [['bearerAuth' => []]], + tags: ['Services'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'storage_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response(response: 200, description: 'Backup schedule and archives deleted.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 403, description: 'Forbidden.'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 409, description: 'Backup or recovery operation is still running.'), + ], + )] + public function destroy(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $resourceType = $request->route('resource_type'); + $resource = $this->findResource($resourceType, $request->route('uuid'), $teamId); + if (! $resource) { + return response()->json(['message' => 'Resource not found.'], 404); + } + + $this->authorize('update', $resource); + + $storage = $this->findStorage($resource, $request->route('storage_uuid')); + if (! $storage) { + return response()->json(['message' => 'Storage not found.'], 404); + } + + $backup = $storage->scheduledBackups()->first(); + if (! $backup) { + return response()->json(['message' => 'Storage backup schedule not found.'], 404); + } + + try { + DeleteScheduledVolumeBackup::run($backup); + } catch (RuntimeException $exception) { + return response()->json(['message' => $exception->getMessage()], 409); + } + + auditLog('api.volume_backup.schedule_deleted', [ + 'team_id' => $teamId, + 'resource_type' => $resourceType, + 'resource_uuid' => $resource->uuid, + 'storage_uuid' => $storage->uuid, + 'backup_uuid' => $backup->uuid, + ]); + + return response()->json(['message' => 'Storage backup schedule and archives deleted.']); + } + private function findResource(string $resourceType, string $uuid, int|string $teamId): ?Model { return match ($resourceType) { diff --git a/app/Jobs/VolumeBackupJob.php b/app/Jobs/VolumeBackupJob.php index bb9be3a8f..a2c2360d0 100644 --- a/app/Jobs/VolumeBackupJob.php +++ b/app/Jobs/VolumeBackupJob.php @@ -334,25 +334,35 @@ private function uploadToS3(string $backupLocation, string $backupDirectory, Ser private function removeExpiredBackups(Server $server): void { - $localExecutions = $this->backup->executions() - ->where('status', 'success') - ->where('local_storage_deleted', false) - ->get(); - $localExecutions = $this->executionsOutsideRetention( - $localExecutions, + if ($this->hasRetentionLimits( $this->backup->retention_amount_locally, $this->backup->retention_days_locally, $this->backup->retention_max_storage_locally, - ); + )) { + $localExecutions = $this->backup->executions() + ->where('status', 'success') + ->where('local_storage_deleted', false) + ->get(); + $localExecutions = $this->executionsOutsideRetention( + $localExecutions, + $this->backup->retention_amount_locally, + $this->backup->retention_days_locally, + $this->backup->retention_max_storage_locally, + ); - $filenames = $localExecutions->pluck('filename')->filter()->all(); - if ($filenames !== []) { - deleteBackupsLocally($filenames, $server, throwError: true); - $this->backup->executions()->whereKey($localExecutions->pluck('id')->all()) - ->update(['local_storage_deleted' => true]); + $filenames = $localExecutions->pluck('filename')->filter()->all(); + if ($filenames !== []) { + deleteBackupsLocally($filenames, $server, throwError: true); + $this->backup->executions()->whereKey($localExecutions->pluck('id')->all()) + ->update(['local_storage_deleted' => true]); + } } - if ($this->backup->save_s3 && $this->backup->s3) { + if ($this->backup->save_s3 && $this->backup->s3 && $this->hasRetentionLimits( + $this->backup->retention_amount_s3, + $this->backup->retention_days_s3, + $this->backup->retention_max_storage_s3, + )) { $s3Executions = $this->backup->executions() ->with('s3') ->where('status', 'success') @@ -389,6 +399,11 @@ private function removeExpiredBackups(Server $server): void ->delete(); } + private function hasRetentionLimits(int $amount, int $days, float $maxStorageGb): bool + { + return $amount > 0 || $days > 0 || $maxStorageGb > 0; + } + private function executionsOutsideRetention(Collection $executions, int $amount, int $days, float $maxStorageGb): Collection { if ($amount === 0 && $days === 0 && $maxStorageGb == 0) { diff --git a/app/Livewire/Project/Shared/Storages/VolumeBackups.php b/app/Livewire/Project/Shared/Storages/VolumeBackups.php index a378101b8..56301cf4e 100644 --- a/app/Livewire/Project/Shared/Storages/VolumeBackups.php +++ b/app/Livewire/Project/Shared/Storages/VolumeBackups.php @@ -301,11 +301,11 @@ public function deleteBackup(int $executionId, string $password, array $selected } if ($this->delete_backup_s3 && $execution->s3_uploaded && ! $execution->s3_storage_deleted) { - if (! $this->backup->s3) { + if (! $execution->s3) { throw new \RuntimeException('The S3 storage is unavailable.'); } - deleteBackupsS3($execution->filename, $this->backup->s3); + deleteBackupsS3($execution->filename, $execution->s3); } $execution->delete(); diff --git a/openapi.json b/openapi.json index 99c3d666c..128aa85ee 100644 --- a/openapi.json +++ b/openapi.json @@ -15796,6 +15796,54 @@ "bearerAuth": [] } ] + }, + "delete": { + "tags": [ + "Applications" + ], + "summary": "Delete application storage backup schedule", + "description": "Delete the backup schedule and its local and S3 archives for an application storage.", + "operationId": "delete-application-storage-backup-schedule", + "parameters": [ + { + "name": "uuid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "storage_uuid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Backup schedule and archives deleted." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "403": { + "description": "Forbidden." + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "409": { + "description": "Backup or recovery operation is still running." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] } }, "\/databases\/{uuid}\/storages\/{storage_uuid}\/backups": { @@ -15878,6 +15926,54 @@ "bearerAuth": [] } ] + }, + "delete": { + "tags": [ + "Databases" + ], + "summary": "Delete database storage backup schedule", + "description": "Delete the backup schedule and its local and S3 archives for a database storage.", + "operationId": "delete-database-storage-backup-schedule", + "parameters": [ + { + "name": "uuid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "storage_uuid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Backup schedule and archives deleted." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "403": { + "description": "Forbidden." + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "409": { + "description": "Backup or recovery operation is still running." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] } }, "\/services\/{uuid}\/storages\/{storage_uuid}\/backups": { @@ -15960,6 +16056,54 @@ "bearerAuth": [] } ] + }, + "delete": { + "tags": [ + "Services" + ], + "summary": "Delete service storage backup schedule", + "description": "Delete the backup schedule and its local and S3 archives for a service storage.", + "operationId": "delete-service-storage-backup-schedule", + "parameters": [ + { + "name": "uuid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "storage_uuid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Backup schedule and archives deleted." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "403": { + "description": "Forbidden." + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "409": { + "description": "Backup or recovery operation is still running." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] } }, "\/vultr\/regions": { diff --git a/openapi.yaml b/openapi.yaml index 00cb59b7d..1fae055cc 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -10101,6 +10101,39 @@ paths: security: - bearerAuth: [] + delete: + tags: + - Applications + summary: 'Delete application storage backup schedule' + description: 'Delete the backup schedule and its local and S3 archives for an application storage.' + operationId: delete-application-storage-backup-schedule + parameters: + - + name: uuid + in: path + required: true + schema: + type: string + - + name: storage_uuid + in: path + required: true + schema: + type: string + responses: + '200': + description: 'Backup schedule and archives deleted.' + '401': + $ref: '#/components/responses/401' + '403': + description: Forbidden. + '404': + $ref: '#/components/responses/404' + '409': + description: 'Backup or recovery operation is still running.' + security: + - + bearerAuth: [] '/databases/{uuid}/storages/{storage_uuid}/backups': put: tags: @@ -10155,6 +10188,39 @@ paths: security: - bearerAuth: [] + delete: + tags: + - Databases + summary: 'Delete database storage backup schedule' + description: 'Delete the backup schedule and its local and S3 archives for a database storage.' + operationId: delete-database-storage-backup-schedule + parameters: + - + name: uuid + in: path + required: true + schema: + type: string + - + name: storage_uuid + in: path + required: true + schema: + type: string + responses: + '200': + description: 'Backup schedule and archives deleted.' + '401': + $ref: '#/components/responses/401' + '403': + description: Forbidden. + '404': + $ref: '#/components/responses/404' + '409': + description: 'Backup or recovery operation is still running.' + security: + - + bearerAuth: [] '/services/{uuid}/storages/{storage_uuid}/backups': put: tags: @@ -10209,6 +10275,39 @@ paths: security: - bearerAuth: [] + delete: + tags: + - Services + summary: 'Delete service storage backup schedule' + description: 'Delete the backup schedule and its local and S3 archives for a service storage.' + operationId: delete-service-storage-backup-schedule + parameters: + - + name: uuid + in: path + required: true + schema: + type: string + - + name: storage_uuid + in: path + required: true + schema: + type: string + responses: + '200': + description: 'Backup schedule and archives deleted.' + '401': + $ref: '#/components/responses/401' + '403': + description: Forbidden. + '404': + $ref: '#/components/responses/404' + '409': + description: 'Backup or recovery operation is still running.' + security: + - + bearerAuth: [] /vultr/regions: get: tags: diff --git a/routes/api.php b/routes/api.php index 0042540be..a625f7f2d 100644 --- a/routes/api.php +++ b/routes/api.php @@ -154,6 +154,9 @@ Route::put('/applications/{uuid}/storages/{storage_uuid}/backups', [VolumeBackupsController::class, 'upsert']) ->defaults('resource_type', 'application') ->middleware(['api.ability:write']); + Route::delete('/applications/{uuid}/storages/{storage_uuid}/backups', [VolumeBackupsController::class, 'destroy']) + ->defaults('resource_type', 'application') + ->middleware(['api.ability:write']); Route::get('/applications/{uuid}/tags', [ApplicationsController::class, 'tags'])->middleware(['api.ability:read']); Route::post('/applications/{uuid}/tags', [ApplicationsController::class, 'create_tag'])->middleware(['api.ability:write']); @@ -201,6 +204,9 @@ Route::put('/databases/{uuid}/storages/{storage_uuid}/backups', [VolumeBackupsController::class, 'upsert']) ->defaults('resource_type', 'database') ->middleware(['api.ability:write']); + Route::delete('/databases/{uuid}/storages/{storage_uuid}/backups', [VolumeBackupsController::class, 'destroy']) + ->defaults('resource_type', 'database') + ->middleware(['api.ability:write']); Route::get('/databases/{uuid}/envs', [DatabasesController::class, 'envs'])->middleware(['api.ability:read']); Route::post('/databases/{uuid}/envs', [DatabasesController::class, 'create_env'])->middleware(['api.ability:write']); @@ -231,6 +237,9 @@ Route::put('/services/{uuid}/storages/{storage_uuid}/backups', [VolumeBackupsController::class, 'upsert']) ->defaults('resource_type', 'service') ->middleware(['api.ability:write']); + Route::delete('/services/{uuid}/storages/{storage_uuid}/backups', [VolumeBackupsController::class, 'destroy']) + ->defaults('resource_type', 'service') + ->middleware(['api.ability:write']); Route::get('/services/{uuid}/envs', [ServicesController::class, 'envs'])->middleware(['api.ability:read']); Route::post('/services/{uuid}/envs', [ServicesController::class, 'create_env'])->middleware(['api.ability:write']); diff --git a/tests/Feature/Api/VolumeBackupScheduleApiTest.php b/tests/Feature/Api/VolumeBackupScheduleApiTest.php index d9386972e..4f4bc395b 100644 --- a/tests/Feature/Api/VolumeBackupScheduleApiTest.php +++ b/tests/Feature/Api/VolumeBackupScheduleApiTest.php @@ -16,11 +16,15 @@ 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 @@ function createVolumeBackupApiToken($context, User $user, array $abilities): str 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 @@ function createVolumeBackupApiToken($context, User $user, array $abilities): str 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 () { diff --git a/tests/Feature/VolumeBackupTest.php b/tests/Feature/VolumeBackupTest.php index 7e8ee0c21..e00841110 100644 --- a/tests/Feature/VolumeBackupTest.php +++ b/tests/Feature/VolumeBackupTest.php @@ -26,6 +26,7 @@ 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 @@ && 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 @@ function signInForVolumeBackups($testCase, Team $team): User 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]));