[]]], 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); } ['errors' => $errors, 's3Storage' => $s3Storage, 'saveToS3' => $saveToS3] = $this->validateUpsertRequest($request, $storage, $teamId); if ($errors->isNotEmpty()) { return response()->json([ 'message' => 'Validation failed.', 'errors' => $errors, ], 422); } return $this->persistSchedule($request, $storage, $teamId, $s3Storage, $saveToS3, $resourceType, $resource); } /** * @return array{errors: MessageBag, s3Storage: S3Storage|null, saveToS3: bool} */ private function validateUpsertRequest( Request $request, LocalPersistentVolume|LocalFileVolume $storage, int|string $teamId, ): array { $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.'); } return [ 'errors' => $errors, 's3Storage' => $s3Storage, 'saveToS3' => $saveToS3, ]; } private function persistSchedule( Request $request, LocalPersistentVolume|LocalFileVolume $storage, int|string $teamId, ?S3Storage $s3Storage, bool $saveToS3, string $resourceType, Model $resource, ): JsonResponse { $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); } #[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) { '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, ]; } }