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:
parent
fab012b5c8
commit
28f8867567
11 changed files with 548 additions and 16 deletions
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
144
openapi.json
144
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": {
|
||||
|
|
|
|||
99
openapi.yaml
99
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:
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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]));
|
||||
|
|
|
|||
Loading…
Reference in a new issue