Add polymorphic volume backup scheduling for persistent volumes and directories, expose schedule management via API, and reorganize backup configuration and execution views.
314 lines
16 KiB
PHP
314 lines
16 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Api;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\Application;
|
|
use App\Models\LocalFileVolume;
|
|
use App\Models\LocalPersistentVolume;
|
|
use App\Models\S3Storage;
|
|
use App\Models\ScheduledVolumeBackup;
|
|
use App\Models\Service;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use OpenApi\Attributes as OA;
|
|
|
|
#[OA\Schema(
|
|
schema: 'VolumeBackupScheduleRequest',
|
|
required: ['frequency'],
|
|
properties: [
|
|
new OA\Property(property: 'frequency', type: 'string', maxLength: 255, example: '0 2 * * *'),
|
|
new OA\Property(property: 'enabled', type: 'boolean', default: true),
|
|
new OA\Property(property: 'save_s3', type: 'boolean', default: false),
|
|
new OA\Property(property: 'disable_local_backup', type: 'boolean', default: false),
|
|
new OA\Property(property: 'stop_during_backup', type: 'boolean', default: false),
|
|
new OA\Property(property: 's3_storage_uuid', type: 'string', nullable: true),
|
|
new OA\Property(property: 'retention_amount_locally', type: 'integer', default: 7, minimum: 0, maximum: 10000),
|
|
new OA\Property(property: 'retention_days_locally', type: 'integer', default: 0, maximum: 2147483647, minimum: 0),
|
|
new OA\Property(property: 'retention_max_storage_locally', type: 'number', format: 'float', default: 0, maximum: 9999999999, minimum: 0),
|
|
new OA\Property(property: 'retention_amount_s3', type: 'integer', default: 7, minimum: 0, maximum: 10000),
|
|
new OA\Property(property: 'retention_days_s3', type: 'integer', default: 0, maximum: 2147483647, minimum: 0),
|
|
new OA\Property(property: 'retention_max_storage_s3', type: 'number', format: 'float', default: 0, maximum: 9999999999, minimum: 0),
|
|
new OA\Property(property: 'timeout', type: 'integer', default: 3600, minimum: 60, maximum: 36000),
|
|
],
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
)]
|
|
#[OA\Schema(
|
|
schema: 'VolumeBackupScheduleResponse',
|
|
required: ['uuid', 'message', 'storage_uuid', 'storage_type', 'frequency', 'enabled', 'save_s3', 'disable_local_backup', 'stop_during_backup', 'retention_amount_locally', 'retention_days_locally', 'retention_max_storage_locally', 'retention_amount_s3', 'retention_days_s3', 'retention_max_storage_s3', 'timeout'],
|
|
properties: [
|
|
new OA\Property(property: 'uuid', type: 'string'),
|
|
new OA\Property(property: 'message', type: 'string'),
|
|
new OA\Property(property: 'storage_uuid', type: 'string'),
|
|
new OA\Property(property: 'storage_type', type: 'string', enum: ['persistent', 'directory']),
|
|
new OA\Property(property: 'frequency', type: 'string'),
|
|
new OA\Property(property: 'enabled', type: 'boolean'),
|
|
new OA\Property(property: 'save_s3', type: 'boolean'),
|
|
new OA\Property(property: 'disable_local_backup', type: 'boolean'),
|
|
new OA\Property(property: 'stop_during_backup', type: 'boolean'),
|
|
new OA\Property(property: 's3_storage_uuid', type: 'string', nullable: true),
|
|
new OA\Property(property: 'retention_amount_locally', type: 'integer'),
|
|
new OA\Property(property: 'retention_days_locally', type: 'integer'),
|
|
new OA\Property(property: 'retention_max_storage_locally', type: 'number', format: 'float'),
|
|
new OA\Property(property: 'retention_amount_s3', type: 'integer'),
|
|
new OA\Property(property: 'retention_days_s3', type: 'integer'),
|
|
new OA\Property(property: 'retention_max_storage_s3', type: 'number', format: 'float'),
|
|
new OA\Property(property: 'timeout', type: 'integer'),
|
|
],
|
|
type: 'object',
|
|
)]
|
|
class VolumeBackupsController extends Controller
|
|
{
|
|
#[OA\Put(
|
|
summary: 'Set application storage backup schedule',
|
|
description: 'Create or replace the backup schedule for an application persistent volume or directory storage.',
|
|
path: '/applications/{uuid}/storages/{storage_uuid}/backups',
|
|
operationId: 'set-application-storage-backup-schedule',
|
|
security: [['bearerAuth' => []]],
|
|
requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/VolumeBackupScheduleRequest')),
|
|
tags: ['Applications'],
|
|
parameters: [
|
|
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'UUID of the application.', schema: new OA\Schema(type: 'string')),
|
|
new OA\Parameter(name: 'storage_uuid', in: 'path', required: true, description: 'UUID of the persistent volume or directory storage.', schema: new OA\Schema(type: 'string')),
|
|
],
|
|
responses: [
|
|
new OA\Response(response: 200, description: 'Backup schedule replaced.', content: new OA\JsonContent(ref: '#/components/schemas/VolumeBackupScheduleResponse')),
|
|
new OA\Response(response: 201, description: 'Backup schedule created.', content: new OA\JsonContent(ref: '#/components/schemas/VolumeBackupScheduleResponse')),
|
|
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
|
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: 422, ref: '#/components/responses/422'),
|
|
],
|
|
)]
|
|
#[OA\Put(
|
|
summary: 'Set database storage backup schedule',
|
|
description: 'Create or replace the backup schedule for a database persistent volume or directory storage.',
|
|
path: '/databases/{uuid}/storages/{storage_uuid}/backups',
|
|
operationId: 'set-database-storage-backup-schedule',
|
|
security: [['bearerAuth' => []]],
|
|
requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/VolumeBackupScheduleRequest')),
|
|
tags: ['Databases'],
|
|
parameters: [
|
|
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'UUID of the database.', schema: new OA\Schema(type: 'string')),
|
|
new OA\Parameter(name: 'storage_uuid', in: 'path', required: true, description: 'UUID of the persistent volume or directory storage.', schema: new OA\Schema(type: 'string')),
|
|
],
|
|
responses: [
|
|
new OA\Response(response: 200, description: 'Backup schedule replaced.', content: new OA\JsonContent(ref: '#/components/schemas/VolumeBackupScheduleResponse')),
|
|
new OA\Response(response: 201, description: 'Backup schedule created.', content: new OA\JsonContent(ref: '#/components/schemas/VolumeBackupScheduleResponse')),
|
|
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
|
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: 422, ref: '#/components/responses/422'),
|
|
],
|
|
)]
|
|
#[OA\Put(
|
|
summary: 'Set service storage backup schedule',
|
|
description: 'Create or replace the backup schedule for a service persistent volume or directory storage.',
|
|
path: '/services/{uuid}/storages/{storage_uuid}/backups',
|
|
operationId: 'set-service-storage-backup-schedule',
|
|
security: [['bearerAuth' => []]],
|
|
requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/VolumeBackupScheduleRequest')),
|
|
tags: ['Services'],
|
|
parameters: [
|
|
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'UUID of the service.', schema: new OA\Schema(type: 'string')),
|
|
new OA\Parameter(name: 'storage_uuid', in: 'path', required: true, description: 'UUID of the persistent volume or directory storage.', schema: new OA\Schema(type: 'string')),
|
|
],
|
|
responses: [
|
|
new OA\Response(response: 200, description: 'Backup schedule replaced.', content: new OA\JsonContent(ref: '#/components/schemas/VolumeBackupScheduleResponse')),
|
|
new OA\Response(response: 201, description: 'Backup schedule created.', content: new OA\JsonContent(ref: '#/components/schemas/VolumeBackupScheduleResponse')),
|
|
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
|
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: 422, ref: '#/components/responses/422'),
|
|
],
|
|
)]
|
|
public function upsert(Request $request): JsonResponse
|
|
{
|
|
$teamId = getTeamIdFromToken();
|
|
if (is_null($teamId)) {
|
|
return invalidTokenResponse();
|
|
}
|
|
|
|
$invalidRequest = validateIncomingRequest($request);
|
|
if ($invalidRequest instanceof JsonResponse) {
|
|
return $invalidRequest;
|
|
}
|
|
|
|
$resourceType = $request->route('resource_type');
|
|
$resource = $this->findResource($resourceType, $request->route('uuid'), $teamId);
|
|
if (! $resource) {
|
|
return response()->json([
|
|
'message' => match ($resourceType) {
|
|
'application' => 'Application not found.',
|
|
'database' => 'Database not found.',
|
|
'service' => 'Service not found.',
|
|
default => '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);
|
|
}
|
|
|
|
$validator = customApiValidator($request->all(), [
|
|
'frequency' => 'required|string|max:255',
|
|
'enabled' => 'boolean',
|
|
'save_s3' => 'boolean',
|
|
'disable_local_backup' => 'boolean',
|
|
'stop_during_backup' => 'boolean',
|
|
's3_storage_uuid' => 'nullable|string',
|
|
'retention_amount_locally' => 'integer|min:0|max:10000',
|
|
'retention_days_locally' => 'integer|min:0|max:2147483647',
|
|
'retention_max_storage_locally' => 'numeric|min:0|max:9999999999',
|
|
'retention_amount_s3' => 'integer|min:0|max:10000',
|
|
'retention_days_s3' => 'integer|min:0|max:2147483647',
|
|
'retention_max_storage_s3' => 'numeric|min:0|max:9999999999',
|
|
'timeout' => 'integer|min:60|max:36000',
|
|
]);
|
|
$errors = $validator->errors();
|
|
$allowedFields = [
|
|
'frequency',
|
|
'enabled',
|
|
'save_s3',
|
|
'disable_local_backup',
|
|
'stop_during_backup',
|
|
's3_storage_uuid',
|
|
'retention_amount_locally',
|
|
'retention_days_locally',
|
|
'retention_max_storage_locally',
|
|
'retention_amount_s3',
|
|
'retention_days_s3',
|
|
'retention_max_storage_s3',
|
|
'timeout',
|
|
];
|
|
|
|
foreach (array_diff(array_keys($request->all()), $allowedFields) as $field) {
|
|
$errors->add($field, 'This field is not allowed.');
|
|
}
|
|
|
|
if (! $errors->has('frequency') && ! validate_cron_expression($request->string('frequency')->toString())) {
|
|
$errors->add('frequency', 'The frequency must be a valid cron or human expression.');
|
|
}
|
|
|
|
$saveToS3 = $request->boolean('save_s3');
|
|
if ($request->boolean('disable_local_backup') && ! $saveToS3) {
|
|
$errors->add('disable_local_backup', 'Local backups can only be disabled when S3 backups are enabled.');
|
|
}
|
|
|
|
$s3Storage = null;
|
|
if ($saveToS3) {
|
|
$s3Storage = S3Storage::query()
|
|
->where('team_id', $teamId)
|
|
->where('is_usable', true)
|
|
->where('uuid', $request->input('s3_storage_uuid'))
|
|
->first();
|
|
|
|
if (! $s3Storage) {
|
|
$errors->add('s3_storage_uuid', 'Select a usable S3 storage owned by your team.');
|
|
}
|
|
}
|
|
|
|
if ($storage instanceof LocalFileVolume && (! $storage->is_directory || $storage->is_host_file)) {
|
|
$errors->add('storage_uuid', 'Only directory file storages can be backed up.');
|
|
}
|
|
|
|
if ($errors->isNotEmpty()) {
|
|
return response()->json([
|
|
'message' => 'Validation failed.',
|
|
'errors' => $errors,
|
|
], 422);
|
|
}
|
|
|
|
$backup = $storage->scheduledBackups()->updateOrCreate([], [
|
|
'team_id' => $teamId,
|
|
'frequency' => $request->string('frequency')->toString(),
|
|
'enabled' => $request->boolean('enabled', true),
|
|
'save_s3' => $saveToS3,
|
|
'disable_local_backup' => $saveToS3 && $request->boolean('disable_local_backup'),
|
|
'stop_during_backup' => $request->boolean('stop_during_backup'),
|
|
's3_storage_id' => $s3Storage?->id,
|
|
'retention_amount_locally' => $request->integer('retention_amount_locally', 7),
|
|
'retention_days_locally' => $request->integer('retention_days_locally'),
|
|
'retention_max_storage_locally' => $request->float('retention_max_storage_locally'),
|
|
'retention_amount_s3' => $request->integer('retention_amount_s3', 7),
|
|
'retention_days_s3' => $request->integer('retention_days_s3'),
|
|
'retention_max_storage_s3' => $request->float('retention_max_storage_s3'),
|
|
'timeout' => $request->integer('timeout', 3600),
|
|
]);
|
|
$created = $backup->wasRecentlyCreated;
|
|
|
|
auditLog('api.volume_backup.schedule_set', [
|
|
'team_id' => $teamId,
|
|
'resource_type' => $resourceType,
|
|
'resource_uuid' => $resource->uuid,
|
|
'storage_uuid' => $storage->uuid,
|
|
'backup_uuid' => $backup->uuid,
|
|
]);
|
|
|
|
return response()->json($this->responseData($backup, $storage, $s3Storage, $created), $created ? 201 : 200);
|
|
}
|
|
|
|
private function findResource(string $resourceType, string $uuid, int|string $teamId): ?Model
|
|
{
|
|
return match ($resourceType) {
|
|
'application' => Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $uuid)->first(),
|
|
'database' => queryDatabaseByUuidWithinTeam($uuid, $teamId),
|
|
'service' => Service::query()->whereRelation('environment.project.team', 'id', $teamId)->where('uuid', $uuid)->first(),
|
|
default => null,
|
|
};
|
|
}
|
|
|
|
private function findStorage(Model $resource, string $storageUuid): LocalPersistentVolume|LocalFileVolume|null
|
|
{
|
|
if ($resource instanceof Service) {
|
|
foreach ($resource->applications->concat($resource->databases) as $serviceResource) {
|
|
$storage = $this->findStorage($serviceResource, $storageUuid);
|
|
if ($storage) {
|
|
return $storage;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
$storage = $resource->persistentStorages()->where('uuid', $storageUuid)->first();
|
|
|
|
return $storage ?? $resource->fileStorages()->where('uuid', $storageUuid)->first();
|
|
}
|
|
|
|
private function responseData(
|
|
ScheduledVolumeBackup $backup,
|
|
LocalPersistentVolume|LocalFileVolume $storage,
|
|
?S3Storage $s3Storage,
|
|
bool $created,
|
|
): array {
|
|
return [
|
|
'uuid' => $backup->uuid,
|
|
'message' => $created ? 'Storage backup schedule created.' : 'Storage backup schedule updated.',
|
|
'storage_uuid' => $storage->uuid,
|
|
'storage_type' => $storage instanceof LocalFileVolume ? 'directory' : 'persistent',
|
|
'frequency' => $backup->frequency,
|
|
'enabled' => $backup->enabled,
|
|
'save_s3' => $backup->save_s3,
|
|
'disable_local_backup' => $backup->disable_local_backup,
|
|
'stop_during_backup' => $backup->stop_during_backup,
|
|
's3_storage_uuid' => $s3Storage?->uuid,
|
|
'retention_amount_locally' => $backup->retention_amount_locally,
|
|
'retention_days_locally' => $backup->retention_days_locally,
|
|
'retention_max_storage_locally' => $backup->retention_max_storage_locally,
|
|
'retention_amount_s3' => $backup->retention_amount_s3,
|
|
'retention_days_s3' => $backup->retention_days_s3,
|
|
'retention_max_storage_s3' => $backup->retention_max_storage_s3,
|
|
'timeout' => $backup->timeout,
|
|
];
|
|
}
|
|
}
|