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 2026-07-16 21:44:48 +02:00
parent fab012b5c8
commit 28f8867567
11 changed files with 548 additions and 16 deletions

View file

@ -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();
}

View file

@ -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();
}

View file

@ -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();
}

View file

@ -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) {

View file

@ -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) {

View file

@ -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();

View file

@ -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": {

View file

@ -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:

View file

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

View file

@ -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 () {

View file

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