Files
jabali-panel/app/Console/Commands/RunBackupSchedules.php
2026-01-24 19:36:46 +02:00

283 lines
10 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Backup;
use App\Models\BackupSchedule;
use App\Services\AdminNotificationService;
use App\Services\Agent\AgentClient;
use Illuminate\Console\Command;
use Exception;
class RunBackupSchedules extends Command
{
protected $signature = 'backups:run-schedules';
protected $description = 'Run due backup schedules';
protected AgentClient $agent;
public function __construct()
{
parent::__construct();
$this->agent = new AgentClient();
}
public function handle(): int
{
$this->info('Checking for due backup schedules...');
$dueSchedules = BackupSchedule::due()->with('destination')->get();
if ($dueSchedules->isEmpty()) {
$this->info('No backup schedules due.');
return Command::SUCCESS;
}
$this->info("Found {$dueSchedules->count()} schedule(s) to run.");
foreach ($dueSchedules as $schedule) {
$this->runSchedule($schedule);
}
return Command::SUCCESS;
}
protected function runSchedule(BackupSchedule $schedule): void
{
$this->info("Running schedule: {$schedule->name}");
$backupType = $schedule->metadata['backup_type'] ?? 'full';
$timestamp = now()->format('Y-m-d_His');
if ($schedule->is_server_backup) {
// Server backup: folder with individual user tar.gz files
$filename = $timestamp;
$outputPath = "/var/backups/jabali/{$timestamp}";
} else {
$user = $schedule->user;
if (!$user) {
$this->error("Schedule {$schedule->id} has no user.");
return;
}
$filename = "backup_scheduled_{$timestamp}.tar.gz";
$outputPath = "/home/{$user->username}/backups/{$filename}";
}
$backup = Backup::create([
'user_id' => $schedule->user_id,
'destination_id' => $schedule->destination_id,
'schedule_id' => $schedule->id,
'name' => "{$schedule->name} - " . now()->format('M j, Y H:i'),
'filename' => $filename,
'type' => $schedule->is_server_backup ? 'server' : 'partial',
'include_files' => $schedule->include_files,
'include_databases' => $schedule->include_databases,
'include_mailboxes' => $schedule->include_mailboxes,
'include_dns' => $schedule->include_dns,
'users' => $schedule->users,
'status' => 'pending',
'local_path' => $outputPath,
'metadata' => ['backup_type' => $backupType],
]);
try {
$backup->update(['status' => 'running', 'started_at' => now()]);
// Check if this is an incremental backup with remote destination (dirvish-style)
$isIncrementalRemote = $backupType === 'incremental' && $schedule->destination;
if ($schedule->is_server_backup) {
if ($isIncrementalRemote) {
// Dirvish-style: rsync directly to remote with --link-dest
$config = array_merge(
$schedule->destination->config ?? [],
['type' => $schedule->destination->type]
);
$result = $this->agent->backupIncrementalDirect($config, [
'users' => $schedule->users,
'include_files' => $schedule->include_files,
'include_databases' => $schedule->include_databases,
'include_mailboxes' => $schedule->include_mailboxes,
'include_dns' => $schedule->include_dns,
]);
// Update backup record with remote path
if ($result['success']) {
$backup->update([
'local_path' => null, // No local file for incremental remote
'remote_path' => $result['remote_path'] ?? null,
]);
}
} else {
$result = $this->agent->backupCreateServer($outputPath, [
'backup_type' => $backupType,
'users' => $schedule->users,
'include_files' => $schedule->include_files,
'include_databases' => $schedule->include_databases,
'include_mailboxes' => $schedule->include_mailboxes,
'include_dns' => $schedule->include_dns,
]);
}
} else {
$result = $this->agent->backupCreate($schedule->user->username, $outputPath, [
'include_files' => $schedule->include_files,
'include_databases' => $schedule->include_databases,
'include_mailboxes' => $schedule->include_mailboxes,
'include_dns' => $schedule->include_dns,
'include_ssl' => $schedule->include_ssl ?? true,
]);
}
if ($result['success']) {
$backup->update([
'status' => 'completed',
'completed_at' => now(),
'size_bytes' => $result['size'] ?? 0,
'checksum' => $result['checksum'] ?? null,
'domains' => $result['domains'] ?? null,
'databases' => $result['databases'] ?? null,
'mailboxes' => $result['mailboxes'] ?? null,
'users' => $result['users'] ?? null,
]);
// Upload to remote if destination configured
if ($schedule->destination) {
$backup->load('destination'); // Load the relationship
$uploadSuccess = $this->uploadToRemote($backup);
// Delete local file after successful remote upload (unless keep_local is set)
$keepLocal = $schedule->metadata['keep_local_copy'] ?? false;
if (!$keepLocal && $uploadSuccess && $backup->local_path) {
$this->agent->backupDeleteServer($backup->local_path);
$backup->update(['local_path' => null]);
$this->info("Local backup deleted after remote upload");
}
}
$schedule->update([
'last_run_at' => now(),
'last_status' => 'success',
'last_error' => null,
]);
$this->info("Backup completed: {$backup->name}");
// Apply retention policy
$this->applyRetention($schedule);
} else {
throw new Exception($result['error'] ?? 'Backup failed');
}
} catch (Exception $e) {
$backup->update([
'status' => 'failed',
'completed_at' => now(),
'error_message' => $e->getMessage(),
]);
$schedule->update([
'last_run_at' => now(),
'last_status' => 'failed',
'last_error' => $e->getMessage(),
]);
$this->error("Backup failed: {$e->getMessage()}");
// Send admin notification
AdminNotificationService::backupFailure($schedule->name, $e->getMessage());
}
// Calculate next run
$schedule->calculateNextRun();
$schedule->save();
}
protected function uploadToRemote(Backup $backup): bool
{
if (!$backup->destination || !$backup->local_path) {
return false;
}
try {
$backup->update(['status' => 'uploading']);
$config = array_merge(
$backup->destination->config ?? [],
['type' => $backup->destination->type]
);
$backupType = $backup->metadata['backup_type'] ?? 'full';
$result = $this->agent->backupUploadRemote($backup->local_path, $config, $backupType);
if ($result['success']) {
$backup->update([
'status' => 'completed',
'remote_path' => $result['remote_path'] ?? null,
]);
$this->info("Uploaded to remote: {$backup->destination->name}");
return true;
} else {
throw new Exception($result['error'] ?? 'Upload failed');
}
} catch (Exception $e) {
$backup->update([
'status' => 'completed', // Keep as completed since local exists
'error_message' => 'Remote upload failed: ' . $e->getMessage(),
]);
$this->warn("Remote upload failed: {$e->getMessage()}");
return false;
}
}
protected function applyRetention(BackupSchedule $schedule): void
{
$retentionCount = $schedule->retention_count ?? 7;
// Get backups from this schedule, ordered by date
// schedule_id is a top-level field on the backups table
$backups = Backup::where('schedule_id', $schedule->id)
->where('status', 'completed')
->orderByDesc('created_at')
->get();
if ($backups->count() <= $retentionCount) {
return;
}
// Get backups to delete
$toDelete = $backups->slice($retentionCount);
foreach ($toDelete as $backup) {
$this->info("Deleting old backup: {$backup->name}");
// Delete local file
if ($backup->local_path && file_exists($backup->local_path)) {
if (is_file($backup->local_path)) {
unlink($backup->local_path);
} else {
exec("rm -rf " . escapeshellarg($backup->local_path));
}
}
// Delete from remote if exists
if ($backup->remote_path && $backup->destination) {
try {
$config = array_merge(
$backup->destination->config ?? [],
['type' => $backup->destination->type]
);
$this->agent->backupDeleteRemote($backup->remote_path, $config);
} catch (Exception $e) {
// Silent fail for remote deletion
}
}
$backup->delete();
}
$deletedCount = $toDelete->count();
$this->info("Deleted {$deletedCount} old backup(s) per retention policy.");
}
}