2026-07-15 15:34:22 +00:00
< ? php
namespace App\Http\Controllers\Api ;
2026-07-16 19:44:48 +00:00
use App\Actions\Shared\DeleteScheduledVolumeBackup ;
2026-07-15 15:34:22 +00:00
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 ;
2026-07-19 21:15:55 +00:00
use Illuminate\Support\MessageBag ;
2026-07-15 15:34:22 +00:00
use OpenApi\Attributes as OA ;
2026-07-16 19:44:48 +00:00
use RuntimeException ;
2026-07-15 15:34:22 +00:00
#[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 );
}
2026-07-19 21:15:55 +00:00
[ '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 {
2026-07-15 15:34:22 +00:00
$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.' );
}
2026-07-19 21:15:55 +00:00
return [
'errors' => $errors ,
's3Storage' => $s3Storage ,
'saveToS3' => $saveToS3 ,
];
}
2026-07-15 15:34:22 +00:00
2026-07-19 21:15:55 +00:00
private function persistSchedule (
Request $request ,
LocalPersistentVolume | LocalFileVolume $storage ,
int | string $teamId ,
? S3Storage $s3Storage ,
bool $saveToS3 ,
string $resourceType ,
Model $resource ,
) : JsonResponse {
2026-07-15 15:34:22 +00:00
$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 );
}
2026-07-16 19:44:48 +00:00
#[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.' ]);
}
2026-07-15 15:34:22 +00:00
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 ,
];
}
}