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]));