283 lines
10 KiB
PHP
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.");
|
|
}
|
|
}
|