mirror of
https://github.com/pyrohost/pyrodactyl.git
synced 2026-04-05 19:51:59 +02:00
feat(server): add command to delete orphaned backups and delete server backups on termination
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace Pterodactyl\Console\Commands\Maintenance;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Pterodactyl\Models\Backup;
|
||||
use Pterodactyl\Services\Backups\DeleteBackupService;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class DeleteOrphanedBackupsCommand extends Command
|
||||
{
|
||||
protected $signature = 'p:maintenance:delete-orphaned-backups {--dry-run : Show what would be deleted without actually deleting}';
|
||||
|
||||
protected $description = 'Delete backups that reference non-existent servers (orphaned backups).';
|
||||
|
||||
/**
|
||||
* DeleteOrphanedBackupsCommand constructor.
|
||||
*/
|
||||
public function __construct(private DeleteBackupService $deleteBackupService)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle()
|
||||
{
|
||||
$isDryRun = $this->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}");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user