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."); } }