mirror of
https://github.com/coollabsio/coolify.git
synced 2026-08-24 02:24:11 -05:00
feat(api): add volume backup schedule delete endpoints
Expose DELETE for application, database, and service storage backup schedules (with OpenAPI docs), reject storage deletes while schedules exist, skip retention cleanup when no limits are set, and remove S3 archives using the execution’s S3 storage.
This commit is contained in:
@@ -4904,6 +4904,14 @@ class ApplicationsController extends Controller
|
||||
], 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 @@ class DatabasesController extends Controller
|
||||
], 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 @@ class ServicesController extends Controller
|
||||
], 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\Database\Eloquent\Model;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use OpenApi\Attributes as OA;
|
||||
use RuntimeException;
|
||||
|
||||
#[OA\Schema(
|
||||
schema: 'VolumeBackupScheduleRequest',
|
||||
@@ -257,6 +259,105 @@ class VolumeBackupsController extends Controller
|
||||
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 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue
|
||||
|
||||
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 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue
|
||||
->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 @@ class VolumeBackups extends Component
|
||||
}
|
||||
|
||||
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
@@ -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": {
|
||||
|
||||
@@ -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::group([
|
||||
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::group([
|
||||
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::group([
|
||||
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\StandalonePostgresql;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Config::set('cache.default', 'array');
|
||||
Config::set('app.maintenance.store', 'array');
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0]));
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
@@ -165,7 +169,46 @@ it('sets a directory backup schedule through the API', function () {
|
||||
expect(ScheduledVolumeBackup::query()->sole()->backupable->is($directory))->toBeTrue();
|
||||
});
|
||||
|
||||
it('sets database and service volume backup schedules through their storage APIs', function () {
|
||||
it('refuses to delete an application directory before its backup schedule is removed', function () {
|
||||
Process::fake();
|
||||
$directory = LocalFileVolume::unguarded(fn () => LocalFileVolume::withoutEvents(fn () => LocalFileVolume::create([
|
||||
'uuid' => new_public_id(),
|
||||
'fs_path' => './uploads',
|
||||
'mount_path' => '/app/uploads',
|
||||
'is_directory' => true,
|
||||
'is_host_file' => false,
|
||||
'resource_id' => $this->application->id,
|
||||
'resource_type' => $this->application->getMorphClass(),
|
||||
])));
|
||||
$directory->scheduledBackups()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'frequency' => 'daily',
|
||||
]);
|
||||
|
||||
$this->withHeaders($this->headers)
|
||||
->deleteJson("/api/v1/applications/{$this->application->uuid}/storages/{$directory->uuid}")
|
||||
->assertUnprocessable()
|
||||
->assertJsonPath('message', 'Delete this directory backup schedule and its archives before deleting the directory.');
|
||||
|
||||
expect($directory->fresh())->not->toBeNull();
|
||||
Process::assertNothingRan();
|
||||
});
|
||||
|
||||
it('deletes an application volume backup schedule through the API', function () {
|
||||
$backup = $this->volume->scheduledBackups()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'frequency' => 'daily',
|
||||
]);
|
||||
|
||||
$this->withHeaders($this->headers)
|
||||
->deleteJson("/api/v1/applications/{$this->application->uuid}/storages/{$this->volume->uuid}/backups")
|
||||
->assertOk()
|
||||
->assertJsonPath('message', 'Storage backup schedule and archives deleted.');
|
||||
|
||||
expect($backup->fresh())->toBeNull();
|
||||
});
|
||||
|
||||
it('sets and deletes database and service volume backup schedules through their storage APIs', function () {
|
||||
$database = StandalonePostgresql::create([
|
||||
'name' => 'api-postgres',
|
||||
'image' => 'postgres:17-alpine',
|
||||
@@ -205,6 +248,16 @@ it('sets database and service volume backup schedules through their storage APIs
|
||||
|
||||
expect($databaseVolume->scheduledBackups()->sole()->frequency)->toBe('daily')
|
||||
->and($serviceVolume->scheduledBackups()->sole()->frequency)->toBe('weekly');
|
||||
|
||||
$this->withHeaders($this->headers)
|
||||
->deleteJson("/api/v1/databases/{$database->uuid}/storages/{$databaseVolume->uuid}/backups")
|
||||
->assertOk();
|
||||
$this->withHeaders($this->headers)
|
||||
->deleteJson("/api/v1/services/{$service->uuid}/storages/{$serviceVolume->uuid}/backups")
|
||||
->assertOk();
|
||||
|
||||
expect($databaseVolume->scheduledBackups()->count())->toBe(0)
|
||||
->and($serviceVolume->scheduledBackups()->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('rejects ineligible file storages and invalid schedule settings', function () {
|
||||
|
||||
@@ -26,6 +26,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Queue\Middleware\WithoutOverlapping;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
@@ -578,6 +579,62 @@ it('deletes an individual volume backup archive and execution', function () {
|
||||
&& str_contains($process->command, 'archive.tar.gz'));
|
||||
});
|
||||
|
||||
it('deletes an individual S3 archive from the storage recorded on its execution', function () {
|
||||
$team = Team::factory()->create();
|
||||
signInForVolumeBackups($this, $team);
|
||||
[$application, $volume] = createVolumeBackupApplication($team);
|
||||
$originalStorage = S3Storage::create([
|
||||
'name' => 'Original storage',
|
||||
'region' => 'us-east-1',
|
||||
'key' => 'original-key',
|
||||
'secret' => 'secret',
|
||||
'bucket' => 'original-bucket',
|
||||
'endpoint' => 'https://s3.example.com',
|
||||
'team_id' => $team->id,
|
||||
'is_usable' => true,
|
||||
]);
|
||||
$newStorage = S3Storage::create([
|
||||
'name' => 'New storage',
|
||||
'region' => 'us-east-1',
|
||||
'key' => 'new-key',
|
||||
'secret' => 'secret',
|
||||
'bucket' => 'new-bucket',
|
||||
'endpoint' => 'https://s3.example.com',
|
||||
'team_id' => $team->id,
|
||||
'is_usable' => true,
|
||||
]);
|
||||
$backup = $volume->scheduledBackups()->create([
|
||||
'team_id' => $team->id,
|
||||
's3_storage_id' => $newStorage->id,
|
||||
'frequency' => 'daily',
|
||||
'save_s3' => true,
|
||||
]);
|
||||
$execution = ScheduledVolumeBackupExecution::create([
|
||||
'scheduled_volume_backup_id' => $backup->id,
|
||||
's3_storage_id' => $originalStorage->id,
|
||||
'status' => 'success',
|
||||
'filename' => '/data/coolify/backups/volumes/test/historical.tar.gz',
|
||||
'local_storage_deleted' => true,
|
||||
's3_uploaded' => true,
|
||||
]);
|
||||
$disk = Mockery::mock();
|
||||
$disk->shouldReceive('delete')
|
||||
->once()
|
||||
->with(['/data/coolify/backups/volumes/test/historical.tar.gz'])
|
||||
->andReturnTrue();
|
||||
Storage::shouldReceive('build')
|
||||
->once()
|
||||
->with(Mockery::on(fn (array $config): bool => $config['key'] === 'original-key'))
|
||||
->andReturn($disk);
|
||||
|
||||
Livewire::test(VolumeBackups::class, ['storage' => $volume, 'resource' => $application])
|
||||
->set('delete_backup_s3', true)
|
||||
->call('deleteBackup', $execution->id, 'password')
|
||||
->assertDispatched('success');
|
||||
|
||||
expect($execution->fresh())->toBeNull();
|
||||
});
|
||||
|
||||
it('prevents another team from downloading a volume backup', function () {
|
||||
config(['app.maintenance.driver' => 'file']);
|
||||
$backupTeam = Team::factory()->create();
|
||||
@@ -1547,6 +1604,36 @@ it('removes retained S3 archives from the storage recorded on each execution', f
|
||||
expect($oldExecution->fresh()->s3_storage_deleted)->toBeTrue();
|
||||
});
|
||||
|
||||
it('does not query execution history when local retention is unlimited', function () {
|
||||
$team = Team::factory()->create();
|
||||
[$application, $volume, $server] = createVolumeBackupApplication($team);
|
||||
$backup = $volume->scheduledBackups()->create([
|
||||
'team_id' => $team->id,
|
||||
'frequency' => 'daily',
|
||||
'save_s3' => false,
|
||||
'retention_amount_locally' => 0,
|
||||
'retention_days_locally' => 0,
|
||||
'retention_max_storage_locally' => 0,
|
||||
]);
|
||||
ScheduledVolumeBackupExecution::create([
|
||||
'scheduled_volume_backup_id' => $backup->id,
|
||||
'status' => 'success',
|
||||
'filename' => '/data/coolify/backups/volumes/test/unlimited.tar.gz',
|
||||
]);
|
||||
$job = new VolumeBackupJob($backup);
|
||||
$method = (new ReflectionClass($job))->getMethod('removeExpiredBackups');
|
||||
DB::enableQueryLog();
|
||||
DB::flushQueryLog();
|
||||
|
||||
$method->invoke($job, $server);
|
||||
|
||||
$executionQueries = collect(DB::getQueryLog())
|
||||
->filter(fn (array $query): bool => str_contains($query['query'], 'scheduled_volume_backup_executions'))
|
||||
->filter(fn (array $query): bool => str_starts_with(strtolower(ltrim($query['query'])), 'select'));
|
||||
|
||||
expect($executionQueries)->toBeEmpty();
|
||||
});
|
||||
|
||||
it('keeps a successful backup successful when retention cleanup fails', function () {
|
||||
config(['broadcasting.default' => 'null']);
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::create(['id' => 0]));
|
||||
|
||||
Reference in New Issue
Block a user