diff --git a/app/Console/Commands/Maintenance/DeleteOrphanedBackupsCommand.php b/app/Console/Commands/Maintenance/DeleteOrphanedBackupsCommand.php new file mode 100644 index 000000000..e9e4ebdd2 --- /dev/null +++ b/app/Console/Commands/Maintenance/DeleteOrphanedBackupsCommand.php @@ -0,0 +1,90 @@ +option('dry-run'); + + // Find backups that reference non-existent servers + $orphanedBackups = Backup::whereDoesntHave('server')->get(); + + if ($orphanedBackups->isEmpty()) { + $this->info('No orphaned backups found.'); + return; + } + + $count = $orphanedBackups->count(); + + if ($isDryRun) { + $this->warn("Found {$count} orphaned backup(s) that would be deleted:"); + + $this->table( + ['ID', 'UUID', 'Name', 'Server ID', 'Disk', 'Created At'], + $orphanedBackups->map(function (Backup $backup) { + return [ + $backup->id, + $backup->uuid, + $backup->name, + $backup->server_id, + $backup->disk, + $backup->created_at->format('Y-m-d H:i:s'), + ]; + })->toArray() + ); + + $this->info('Run without --dry-run to actually delete these backups.'); + return; + } + + if (!$this->confirm("Are you sure you want to delete {$count} orphaned backup(s)? This action cannot be undone.")) { + $this->info('Operation cancelled.'); + return; + } + + $this->warn("Deleting {$count} orphaned backup(s)..."); + + $deletedCount = 0; + $failedCount = 0; + + foreach ($orphanedBackups as $backup) { + try { + $this->deleteBackupService->handle($backup); + $deletedCount++; + $this->info("Deleted backup: {$backup->uuid} ({$backup->name})"); + } catch (\Exception $exception) { + $failedCount++; + $this->error("Failed to delete backup {$backup->uuid}: {$exception->getMessage()}"); + + // If we can't delete from storage, at least remove the database record + try { + $backup->delete(); + $this->warn("Removed database record for backup {$backup->uuid} (storage deletion failed)"); + } catch (\Exception $dbException) { + $this->error("Failed to remove database record for backup {$backup->uuid}: {$dbException->getMessage()}"); + } + } + } + + $this->info("Cleanup completed. Deleted: {$deletedCount}, Failed: {$failedCount}"); + } +} \ No newline at end of file diff --git a/app/Services/Servers/ServerDeletionService.php b/app/Services/Servers/ServerDeletionService.php index 714e22623..747e1d656 100644 --- a/app/Services/Servers/ServerDeletionService.php +++ b/app/Services/Servers/ServerDeletionService.php @@ -8,6 +8,7 @@ use Illuminate\Support\Facades\Log; use Illuminate\Database\ConnectionInterface; use Pterodactyl\Repositories\Wings\DaemonServerRepository; use Pterodactyl\Services\Databases\DatabaseManagementService; +use Pterodactyl\Services\Backups\DeleteBackupService; use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException; class ServerDeletionService @@ -21,6 +22,7 @@ class ServerDeletionService private ConnectionInterface $connection, private DaemonServerRepository $daemonServerRepository, private DatabaseManagementService $databaseManagementService, + private DeleteBackupService $deleteBackupService, ) { } @@ -57,6 +59,28 @@ class ServerDeletionService } $this->connection->transaction(function () use ($server) { + // Delete all backups associated with this server + foreach ($server->backups as $backup) { + try { + $this->deleteBackupService->handle($backup); + } catch (\Exception $exception) { + if (!$this->force) { + throw $exception; + } + + // If we can't delete the backup from storage, at least remove the database record + // to prevent orphaned backup entries + $backup->delete(); + + Log::warning('Failed to delete backup during server deletion', [ + 'backup_id' => $backup->id, + 'backup_uuid' => $backup->uuid, + 'server_id' => $server->id, + 'exception' => $exception->getMessage(), + ]); + } + } + foreach ($server->databases as $database) { try { $this->databaseManagementService->delete($database); diff --git a/tests/Integration/Services/Servers/ServerDeletionServiceTest.php b/tests/Integration/Services/Servers/ServerDeletionServiceTest.php index 1a1446467..8eb14a1f9 100644 --- a/tests/Integration/Services/Servers/ServerDeletionServiceTest.php +++ b/tests/Integration/Services/Servers/ServerDeletionServiceTest.php @@ -5,11 +5,13 @@ namespace Pterodactyl\Tests\Integration\Services\Servers; use Mockery\MockInterface; use GuzzleHttp\Psr7\Request; use GuzzleHttp\Psr7\Response; +use Pterodactyl\Models\Backup; use Pterodactyl\Models\Database; use Pterodactyl\Models\DatabaseHost; use GuzzleHttp\Exception\BadResponseException; use Pterodactyl\Tests\Integration\IntegrationTestCase; use Pterodactyl\Services\Servers\ServerDeletionService; +use Pterodactyl\Services\Backups\DeleteBackupService; use Pterodactyl\Repositories\Wings\DaemonServerRepository; use Pterodactyl\Services\Databases\DatabaseManagementService; use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException; @@ -20,6 +22,8 @@ class ServerDeletionServiceTest extends IntegrationTestCase private MockInterface $databaseManagementService; + private MockInterface $deleteBackupService; + private static ?string $defaultLogger; /** @@ -35,9 +39,11 @@ class ServerDeletionServiceTest extends IntegrationTestCase $this->daemonServerRepository = \Mockery::mock(DaemonServerRepository::class); $this->databaseManagementService = \Mockery::mock(DatabaseManagementService::class); + $this->deleteBackupService = \Mockery::mock(DeleteBackupService::class); $this->app->instance(DaemonServerRepository::class, $this->daemonServerRepository); $this->app->instance(DatabaseManagementService::class, $this->databaseManagementService); + $this->app->instance(DeleteBackupService::class, $this->deleteBackupService); } /** @@ -153,6 +159,86 @@ class ServerDeletionServiceTest extends IntegrationTestCase $this->assertDatabaseMissing('databases', ['id' => $db->id]); } + /** + * Test that server backups are deleted when a server is deleted. + */ + public function testServerBackupsAreDeletedDuringServerDeletion() + { + $server = $this->createServerModel(); + + /** @var Backup $backup1 */ + $backup1 = Backup::factory()->create(['server_id' => $server->id]); + /** @var Backup $backup2 */ + $backup2 = Backup::factory()->create(['server_id' => $server->id]); + + $server->refresh(); + + $this->daemonServerRepository->expects('setServer->delete')->withNoArgs()->andReturnUndefined(); + + $this->deleteBackupService->expects('handle')->with(\Mockery::on(function ($value) use ($backup1) { + return $value instanceof Backup && $value->id === $backup1->id; + }))->andReturnUndefined(); + + $this->deleteBackupService->expects('handle')->with(\Mockery::on(function ($value) use ($backup2) { + return $value instanceof Backup && $value->id === $backup2->id; + }))->andReturnUndefined(); + + $this->getService()->handle($server); + + $this->assertDatabaseMissing('servers', ['id' => $server->id]); + } + + /** + * Test that server deletion continues even if backup deletion fails when force is enabled. + */ + public function testServerDeletionContinuesWhenBackupDeletionFailsWithForce() + { + $server = $this->createServerModel(); + + /** @var Backup $backup */ + $backup = Backup::factory()->create(['server_id' => $server->id]); + + $server->refresh(); + + $this->daemonServerRepository->expects('setServer->delete')->withNoArgs()->andReturnUndefined(); + + $this->deleteBackupService->expects('handle')->with(\Mockery::on(function ($value) use ($backup) { + return $value instanceof Backup && $value->id === $backup->id; + }))->andThrows(new \Exception('Backup deletion failed')); + + $this->getService()->withForce(true)->handle($server); + + $this->assertDatabaseMissing('servers', ['id' => $server->id]); + $this->assertDatabaseMissing('backups', ['id' => $backup->id]); + } + + /** + * Test that server deletion fails if backup deletion fails and force is not enabled. + */ + public function testServerDeletionFailsWhenBackupDeletionFailsWithoutForce() + { + $server = $this->createServerModel(); + + /** @var Backup $backup */ + $backup = Backup::factory()->create(['server_id' => $server->id]); + + $server->refresh(); + + $this->daemonServerRepository->expects('setServer->delete')->withNoArgs()->andReturnUndefined(); + + $this->deleteBackupService->expects('handle')->with(\Mockery::on(function ($value) use ($backup) { + return $value instanceof Backup && $value->id === $backup->id; + }))->andThrows(new \Exception('Backup deletion failed')); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Backup deletion failed'); + + $this->getService()->handle($server); + + $this->assertDatabaseHas('servers', ['id' => $server->id]); + $this->assertDatabaseHas('backups', ['id' => $backup->id]); + } + private function getService(): ServerDeletionService { return $this->app->make(ServerDeletionService::class);