diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 8c70d1bdc..31bf2807e 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -82,14 +82,15 @@ class DatabasesController extends Controller $databases = $databases->merge($project->databases()); } - $databases = $databases->map(function ($database) { - $backupConfig = ScheduledDatabaseBackup::with('latest_log')->where('database_id', $database->id)->get(); + $databaseIds = $databases->pluck('id')->toArray(); - if ($backupConfig) { - $database->backup_configs = $backupConfig; - } else { - $database->backup_configs = null; - } + $backupConfigs = ScheduledDatabaseBackup::with('latest_log') + ->whereIn('database_id', $databaseIds) + ->get() + ->groupBy('database_id'); + + $databases = $databases->map(function ($database) use ($backupConfigs) { + $database->backup_configs = $backupConfigs->get($database->id, collect())->values(); return $this->removeSensitiveData($database); }); @@ -155,6 +156,8 @@ class DatabasesController extends Controller return response()->json(['message' => 'Database not found.'], 404); } + $this->authorize('view', $database); + $backupConfig = ScheduledDatabaseBackup::with('executions')->where('database_id', $database->id)->get(); return response()->json($backupConfig); @@ -291,19 +294,19 @@ class DatabasesController extends Controller 'mysql_user' => ['type' => 'string', 'description' => 'MySQL user'], 'mysql_database' => ['type' => 'string', 'description' => 'MySQL database'], 'mysql_conf' => ['type' => 'string', 'description' => 'MySQL conf'], - 'save_s3' => ['type' => 'boolean', 'description' => 'Weather data is saved in s3 or not'], - 's3_storage_id' => ['type' => 'integer', 'description' => 'S3 storage id'], - 'backup_now' => ['type' => 'boolean', 'description' => 'Weather to take a backup now or not'], - 'enabled' => ['type' => 'boolean', 'description' => 'Weather the backup is enabled or not'], + 'save_s3' => ['type' => 'boolean', 'description' => 'Whether data is saved in s3 or not'], + 's3_storage_uuid' => ['type' => 'string', 'description' => 'S3 storage UUID'], + 'backup_now' => ['type' => 'boolean', 'description' => 'Whether to take a backup now or not'], + 'enabled' => ['type' => 'boolean', 'description' => 'Whether the backup is enabled or not'], 'databases_to_backup' => ['type' => 'string', 'description' => 'Comma separated list of databases to backup'], - 'dump_all' => ['type' => 'boolean', 'description' => 'Weather all databases are dumped or not'], + 'dump_all' => ['type' => 'boolean', 'description' => 'Whether all databases are dumped or not'], 'frequency' => ['type' => 'string', 'description' => 'Frequency of the backup'], 'database_backup_retention_amount_locally' => ['type' => 'integer', 'description' => 'Retention amount of the backup locally'], 'database_backup_retention_days_locally' => ['type' => 'integer', 'description' => 'Retention days of the backup locally'], 'database_backup_retention_max_storage_locally' => ['type' => 'integer', 'description' => 'Max storage of the backup locally'], 'database_backup_retention_amount_s3' => ['type' => 'integer', 'description' => 'Retention amount of the backup in s3'], 'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Retention days of the backup in s3'], - 'database_backup_retention_max_storage_s3' => ['type' => 'integer', 'description' => 'Max storage of the backup locally'], + 'database_backup_retention_max_storage_s3' => ['type' => 'integer', 'description' => 'Max storage of the backup in S3'], ], ), ) @@ -587,6 +590,7 @@ class DatabasesController extends Controller $whatToDoWithDatabaseProxy = 'start'; } + // Only update database fields, not backup configuration $database->update($request->all()); if ($whatToDoWithDatabaseProxy === 'start') { @@ -603,8 +607,8 @@ class DatabasesController extends Controller #[OA\Patch( summary: 'Update', description: 'Update a specific backup configuration for a given database, identified by its UUID and the backup ID', - path: '/databases/{uuid}/backups/{backup_id}', - operationId: 'update-database-backup-config-by-uuid-and-backup-id', + path: '/databases/{uuid}/backups/{scheduled_backup_uuid}', + operationId: 'update-database-backup', security: [ ['bearerAuth' => []], ], @@ -621,12 +625,13 @@ class DatabasesController extends Controller ) ), new OA\Parameter( - name: 'backup_id', + name: 'scheduled_backup_uuid', in: 'path', - description: 'ID of the backup configuration.', + description: 'UUID of the backup configuration.', required: true, schema: new OA\Schema( - type: 'integer', + type: 'string', + format: 'uuid', ) ), ], @@ -638,19 +643,19 @@ class DatabasesController extends Controller schema: new OA\Schema( type: 'object', properties: [ - 'save_s3' => ['type' => 'boolean', 'description' => 'Weather data is saved in s3 or not'], - 's3_storage_id' => ['type' => 'integer', 'description' => 'S3 storage id'], - 'backup_now' => ['type' => 'boolean', 'description' => 'Weather to take a backup now or not'], - 'enabled' => ['type' => 'boolean', 'description' => 'Weather the backup is enabled or not'], + 'save_s3' => ['type' => 'boolean', 'description' => 'Whether data is saved in s3 or not'], + 's3_storage_uuid' => ['type' => 'string', 'description' => 'S3 storage UUID'], + 'backup_now' => ['type' => 'boolean', 'description' => 'Whether to take a backup now or not'], + 'enabled' => ['type' => 'boolean', 'description' => 'Whether the backup is enabled or not'], 'databases_to_backup' => ['type' => 'string', 'description' => 'Comma separated list of databases to backup'], - 'dump_all' => ['type' => 'boolean', 'description' => 'Weather all databases are dumped or not'], + 'dump_all' => ['type' => 'boolean', 'description' => 'Whether all databases are dumped or not'], 'frequency' => ['type' => 'string', 'description' => 'Frequency of the backup'], 'database_backup_retention_amount_locally' => ['type' => 'integer', 'description' => 'Retention amount of the backup locally'], 'database_backup_retention_days_locally' => ['type' => 'integer', 'description' => 'Retention days of the backup locally'], 'database_backup_retention_max_storage_locally' => ['type' => 'integer', 'description' => 'Max storage of the backup locally'], 'database_backup_retention_amount_s3' => ['type' => 'integer', 'description' => 'Retention amount of the backup in s3'], 'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Retention days of the backup in s3'], - 'database_backup_retention_max_storage_s3' => ['type' => 'integer', 'description' => 'Max storage of the backup locally'], + 'database_backup_retention_max_storage_s3' => ['type' => 'integer', 'description' => 'Max storage of the backup in S3'], ], ), ) @@ -674,9 +679,9 @@ class DatabasesController extends Controller ), ] )] - public function update_backup_config_by_uuid_and_backup_id(Request $request) + public function update_backup(Request $request) { - $backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_id']; + $backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -692,8 +697,8 @@ class DatabasesController extends Controller 'backup_now' => 'boolean|nullable', 'enabled' => 'boolean', 'dump_all' => 'boolean', - 's3_storage_id' => 'integer|min:1|exists:s3_storages,id|nullable', - 'databases_to_backup' => 'string', + 's3_storage_uuid' => 'string|exists:s3_storages,uuid|nullable', + 'databases_to_backup' => 'string|nullable', 'frequency' => 'string|in:every_minute,hourly,daily,weekly,monthly,yearly', 'database_backup_retention_amount_locally' => 'integer|min:0', 'database_backup_retention_days_locally' => 'integer|min:0', @@ -712,6 +717,12 @@ class DatabasesController extends Controller if (! $request->uuid) { return response()->json(['message' => 'UUID is required.'], 404); } + + // Validate scheduled_backup_uuid is provided + if (! $request->scheduled_backup_uuid) { + return response()->json(['message' => 'Scheduled backup UUID is required.'], 400); + } + $uuid = $request->uuid; removeUnnecessaryFieldsFromRequest($request); $database = queryDatabaseByUuidWithinTeam($uuid, $teamId); @@ -720,7 +731,7 @@ class DatabasesController extends Controller } $backupConfig = ScheduledDatabaseBackup::where('database_id', $database->id) - ->where('id', $request->backup_id) + ->where('uuid', $request->scheduled_backup_uuid) ->first(); if (! $backupConfig) { return response()->json(['message' => 'Backup config not found.'], 404); @@ -739,7 +750,18 @@ class DatabasesController extends Controller ], 422); } - $backupConfig->update($request->only($backupConfigFields)); + $backupData = $request->only($backupConfigFields); + + // Convert s3_storage_uuid to s3_storage_id + if (isset($backupData['s3_storage_uuid'])) { + $s3Storage = \App\Models\S3Storage::where('uuid', $backupData['s3_storage_uuid'])->first(); + if ($s3Storage) { + $backupData['s3_storage_id'] = $s3Storage->id; + } + unset($backupData['s3_storage_uuid']); + } + + $backupConfig->update($backupData); if ($request->backup_now) { DatabaseBackupJob::dispatch($backupConfig); @@ -1869,29 +1891,147 @@ class DatabasesController extends Controller } #[OA\Delete( - summary: 'Delete backup', - description: 'Deletes a backup by its database UUID and backup ID.', - path: '/databases/{uuid}/backups/{backup_id}', - operationId: 'delete-backup-by-uuid', + summary: 'Delete backup configuration', + description: 'Deletes a backup configuration and all its executions.', + path: '/databases/{uuid}/backups/{scheduled_backup_uuid}', + operationId: 'delete-backup-configuration-by-uuid', security: [ ['bearerAuth' => []], ], - tags: ['backups'], + tags: ['Databases'], parameters: [ new OA\Parameter( name: 'uuid', in: 'path', required: true, - description: 'UUID of the database to delete', + description: 'UUID of the database', schema: new OA\Schema(type: 'string') ), new OA\Parameter( - name: 'backup_id', + name: 'scheduled_backup_uuid', in: 'path', required: true, - description: 'ID of the backup to delete', + description: 'UUID of the backup configuration to delete', + schema: new OA\Schema(type: 'string', format: 'uuid') + ), + new OA\Parameter( + name: 'delete_s3', + in: 'query', + required: false, + description: 'Whether to delete all backup files from S3', + schema: new OA\Schema(type: 'boolean', default: false) + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Backup configuration deleted.', + content: new OA\JsonContent( + type: 'object', + properties: [ + 'message' => new OA\Schema(type: 'string', example: 'Backup configuration and all executions deleted.'), + ] + ) + ), + new OA\Response( + response: 404, + description: 'Backup configuration not found.', + content: new OA\JsonContent( + type: 'object', + properties: [ + 'message' => new OA\Schema(type: 'string', example: 'Backup configuration not found.'), + ] + ) + ), + ] + )] + public function delete_backup_by_uuid(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + // Validate scheduled_backup_uuid is provided + if (! $request->scheduled_backup_uuid) { + return response()->json(['message' => 'Scheduled backup UUID is required.'], 400); + } + + $database = queryDatabaseByUuidWithinTeam($request->uuid, $teamId); + if (! $database) { + return response()->json(['message' => 'Database not found.'], 404); + } + + // Find the backup configuration by its UUID + $backup = ScheduledDatabaseBackup::where('database_id', $database->id) + ->where('uuid', $request->scheduled_backup_uuid) + ->first(); + + if (! $backup) { + return response()->json(['message' => 'Backup configuration not found.'], 404); + } + + $deleteS3 = filter_var($request->query->get('delete_s3', false), FILTER_VALIDATE_BOOLEAN); + + try { + // Get all executions for this backup configuration + $executions = $backup->executions()->get(); + + // Delete all execution files (locally and optionally from S3) + foreach ($executions as $execution) { + if ($execution->filename) { + deleteBackupsLocally($execution->filename, $database->destination->server); + + if ($deleteS3 && $backup->s3) { + deleteBackupsS3($execution->filename, $backup->s3); + } + } + + $execution->delete(); + } + + // Delete the backup configuration itself + $backup->delete(); + + return response()->json([ + 'message' => 'Backup configuration and all executions deleted.', + ]); + } catch (\Exception $e) { + return response()->json(['message' => 'Failed to delete backup: '.$e->getMessage()], 500); + } + } + + #[OA\Delete( + summary: 'Delete backup execution', + description: 'Deletes a specific backup execution.', + path: '/databases/{uuid}/backups/{scheduled_backup_uuid}/executions/{execution_uuid}', + operationId: 'delete-backup-execution-by-uuid', + security: [ + ['bearerAuth' => []], + ], + 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: 'scheduled_backup_uuid', + in: 'path', + required: true, + description: 'UUID of the backup configuration', + schema: new OA\Schema(type: 'string', format: 'uuid') + ), + new OA\Parameter( + name: 'execution_uuid', + in: 'path', + required: true, + description: 'UUID of the backup execution to delete', + schema: new OA\Schema(type: 'string', format: 'uuid') + ), new OA\Parameter( name: 'delete_s3', in: 'query', @@ -1903,43 +2043,59 @@ class DatabasesController extends Controller responses: [ new OA\Response( response: 200, - description: 'Backup deleted.', + description: 'Backup execution deleted.', content: new OA\JsonContent( type: 'object', properties: [ - 'message' => new OA\Schema(type: 'string', example: 'Backup deleted.'), + 'message' => new OA\Schema(type: 'string', example: 'Backup execution deleted.'), ] ) ), new OA\Response( response: 404, - description: 'Backup not found.', + description: 'Backup execution not found.', content: new OA\JsonContent( type: 'object', properties: [ - 'message' => new OA\Schema(type: 'string', example: 'Backup not found.'), + 'message' => new OA\Schema(type: 'string', example: 'Backup execution not found.'), ] ) ), ] )] - public function delete_backup_by_uuid(Request $request) + public function delete_execution_by_uuid(Request $request) { $teamId = getTeamIdFromToken(); if (is_null($teamId)) { return invalidTokenResponse(); } + + // Validate parameters + if (! $request->scheduled_backup_uuid) { + return response()->json(['message' => 'Scheduled backup UUID is required.'], 400); + } + if (! $request->execution_uuid) { + return response()->json(['message' => 'Execution UUID is required.'], 400); + } + $database = queryDatabaseByUuidWithinTeam($request->uuid, $teamId); if (! $database) { return response()->json(['message' => 'Database not found.'], 404); } - $backup = ScheduledDatabaseBackup::where('database_id', $database->id)->first(); + + // Find the backup configuration by its UUID + $backup = ScheduledDatabaseBackup::where('database_id', $database->id) + ->where('uuid', $request->scheduled_backup_uuid) + ->first(); + if (! $backup) { - return response()->json(['message' => 'Backup not found.'], 404); + return response()->json(['message' => 'Backup configuration not found.'], 404); } - $execution = $backup->executions()->where('id', $request->backup_id)->first(); + + // Find the specific execution + $execution = $backup->executions()->where('uuid', $request->execution_uuid)->first(); if (! $execution) { - return response()->json(['message' => 'Execution not found.'], 404); + return response()->json(['message' => 'Backup execution not found.'], 404); } $deleteS3 = filter_var($request->query->get('delete_s3', false), FILTER_VALIDATE_BOOLEAN); @@ -1956,13 +2112,114 @@ class DatabasesController extends Controller $execution->delete(); return response()->json([ - 'message' => 'Backup deleted.', + 'message' => 'Backup execution deleted.', ]); } catch (\Exception $e) { - return response()->json(['message' => 'Failed to delete backup: '.$e->getMessage()], 500); + return response()->json(['message' => 'Failed to delete backup execution: '.$e->getMessage()], 500); } } + #[OA\Get( + summary: 'List backup executions', + description: 'Get all executions for a specific backup configuration.', + path: '/databases/{uuid}/backups/{scheduled_backup_uuid}/executions', + operationId: 'list-backup-executions', + security: [ + ['bearerAuth' => []], + ], + 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: 'scheduled_backup_uuid', + in: 'path', + required: true, + description: 'UUID of the backup configuration', + schema: new OA\Schema(type: 'string', format: 'uuid') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'List of backup executions', + content: new OA\JsonContent( + type: 'object', + properties: [ + 'executions' => new OA\Schema( + type: 'array', + items: new OA\Schema( + type: 'object', + properties: [ + 'uuid' => ['type' => 'string'], + 'filename' => ['type' => 'string'], + 'size' => ['type' => 'integer'], + 'created_at' => ['type' => 'string'], + 'message' => ['type' => 'string'], + 'status' => ['type' => 'string'], + ] + ) + ), + ] + ) + ), + new OA\Response( + response: 404, + description: 'Backup configuration not found.', + ), + ] + )] + public function list_backup_executions(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + // Validate scheduled_backup_uuid is provided + if (! $request->scheduled_backup_uuid) { + return response()->json(['message' => 'Scheduled backup UUID is required.'], 400); + } + + $database = queryDatabaseByUuidWithinTeam($request->uuid, $teamId); + if (! $database) { + return response()->json(['message' => 'Database not found.'], 404); + } + + // Find the backup configuration by its UUID + $backup = ScheduledDatabaseBackup::where('database_id', $database->id) + ->where('uuid', $request->scheduled_backup_uuid) + ->first(); + + if (! $backup) { + return response()->json(['message' => 'Backup configuration not found.'], 404); + } + + // Get all executions for this backup configuration + $executions = $backup->executions() + ->orderBy('created_at', 'desc') + ->get() + ->map(function ($execution) { + return [ + 'uuid' => $execution->uuid, + 'filename' => $execution->filename, + 'size' => $execution->size, + 'created_at' => $execution->created_at->toIso8601String(), + 'message' => $execution->message, + 'status' => $execution->status, + ]; + }); + + return response()->json([ + 'executions' => $executions, + ]); + } + #[OA\Get( summary: 'Start', description: 'Start database. `Post` request is also accepted.', diff --git a/routes/api.php b/routes/api.php index 82b29ea4d..ea6fd5249 100644 --- a/routes/api.php +++ b/routes/api.php @@ -115,10 +115,12 @@ Route::group([ Route::get('/databases/{uuid}', [DatabasesController::class, 'database_by_uuid'])->middleware(['api.ability:read']); Route::get('/databases/{uuid}/backups', [DatabasesController::class, 'database_backup_details_uuid'])->middleware(['api.ability:read']); + Route::get('/databases/{uuid}/backups/{scheduled_backup_uuid}/executions', [DatabasesController::class, 'list_backup_executions'])->middleware(['api.ability:read']); Route::patch('/databases/{uuid}', [DatabasesController::class, 'update_by_uuid'])->middleware(['api.ability:write']); - Route::patch('/databases/{uuid}/backups/{backup_id}', [DatabasesController::class, 'update_backup_config_by_uuid_and_backup_id'])->middleware(['api.ability:write']); + Route::patch('/databases/{uuid}/backups/{scheduled_backup_uuid}', [DatabasesController::class, 'update_backup'])->middleware(['api.ability:write']); Route::delete('/databases/{uuid}', [DatabasesController::class, 'delete_by_uuid'])->middleware(['api.ability:write']); - Route::delete('/databases/{uuid}/backups/{backup_id}', [DatabasesController::class, 'delete_backup_by_uuid'])->middleware(['api.ability:write']); + Route::delete('/databases/{uuid}/backups/{scheduled_backup_uuid}', [DatabasesController::class, 'delete_backup_by_uuid'])->middleware(['api.ability:write']); + Route::delete('/databases/{uuid}/backups/{scheduled_backup_uuid}/executions/{execution_uuid}', [DatabasesController::class, 'delete_execution_by_uuid'])->middleware(['api.ability:write']); Route::match(['get', 'post'], '/databases/{uuid}/start', [DatabasesController::class, 'action_deploy'])->middleware(['api.ability:write']); Route::match(['get', 'post'], '/databases/{uuid}/restart', [DatabasesController::class, 'action_restart'])->middleware(['api.ability:write']);