├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── SECURITY.md ├── composer.json ├── config └── backup-server.php ├── database └── migrations │ └── create_backup_server_tables.php.stub ├── lint-staged.config.js ├── package.json ├── rector.php ├── resources └── lang │ └── en │ └── notifications.php ├── src ├── BackupServerServiceProvider.php ├── Commands │ ├── CreateBackupCommand.php │ ├── DispatchPerformBackupJobsCommand.php │ ├── DispatchPerformCleanupJobsCommand.php │ ├── FindContentCommand.php │ ├── FindFilesCommand.php │ ├── ListDestinationsCommand.php │ ├── ListSourcesCommand.php │ └── MonitorBackupsCommand.php ├── Enums │ ├── BackupStatus.php │ ├── DestinationStatus.php │ └── SourceStatus.php ├── Exceptions │ ├── BackupFailed.php │ ├── InvalidCommandInput.php │ └── NotificationCouldNotBeSent.php ├── Models │ ├── Backup.php │ ├── BackupLogItem.php │ ├── Concerns │ │ ├── HasAsyncDelete.php │ │ ├── HasBackupRelation.php │ │ └── LogsActivity.php │ ├── Destination.php │ ├── Source.php │ └── User.php ├── Notifications │ ├── EventHandler.php │ ├── Notifiable.php │ └── Notifications │ │ ├── BackupCompletedNotification.php │ │ ├── BackupFailedNotification.php │ │ ├── CleanupForDestinationCompletedNotification.php │ │ ├── CleanupForDestinationFailedNotification.php │ │ ├── CleanupForSourceCompletedNotification.php │ │ ├── CleanupForSourceFailedNotification.php │ │ ├── Concerns │ │ └── HandlesNotifications.php │ │ ├── HealthyDestinationFoundNotification.php │ │ ├── HealthySourceFoundNotification.php │ │ ├── ServerSummaryNotification.php │ │ ├── UnhealthyDestinationFoundNotification.php │ │ └── UnhealthySourceFoundNotification.php ├── Support │ ├── AlignCenterTableStyle.php │ ├── AlignRightTableStyle.php │ ├── ExceptionRenderer.php │ └── Helpers │ │ ├── Config.php │ │ ├── DestinationLocation.php │ │ ├── Enums │ │ ├── LogLevel.php │ │ └── Task.php │ │ ├── Format.php │ │ └── SourceLocation.php └── Tasks │ ├── Backup │ ├── Actions │ │ └── CreateBackupAction.php │ ├── Events │ │ ├── BackupCompletedEvent.php │ │ └── BackupFailedEvent.php │ ├── Jobs │ │ ├── BackupTasks │ │ │ ├── BackupTask.php │ │ │ ├── CalculateBackupSize.php │ │ │ ├── Concerns │ │ │ │ └── ExecutesBackupCommands.php │ │ │ ├── DetermineDestinationPath.php │ │ │ ├── EnsureDestinationIsReachable.php │ │ │ ├── EnsureSourceIsReachable.php │ │ │ ├── PerformPostBackupCommands.php │ │ │ ├── PerformPreBackupCommands.php │ │ │ └── RunBackup.php │ │ └── PerformBackupJob.php │ └── Support │ │ ├── BackupCollection.php │ │ ├── BackupScheduler │ │ ├── BackupScheduler.php │ │ └── DefaultBackupScheduler.php │ │ ├── FileList │ │ ├── FileList.php │ │ └── FileListEntry.php │ │ ├── PendingBackup.php │ │ └── Rsync │ │ ├── RsyncProgressOutput.php │ │ └── RsyncSummaryOutput.php │ ├── Cleanup │ ├── Events │ │ ├── CleanupForDestinationCompletedEvent.php │ │ ├── CleanupForDestinationFailedEvent.php │ │ ├── CleanupForSourceCompletedEvent.php │ │ └── CleanupForSourceFailedEvent.php │ ├── Jobs │ │ ├── DeleteBackupJob.php │ │ ├── DeleteDestinationJob.php │ │ ├── DeleteSourceJob.php │ │ ├── PerformCleanupDestinationJob.php │ │ ├── PerformCleanupSourceJob.php │ │ └── Tasks │ │ │ ├── CleanupTask.php │ │ │ ├── DeleteBackupsWithoutDirectoriesFromDb.php │ │ │ ├── DeleteFailedBackups.php │ │ │ ├── DeleteOldBackups.php │ │ │ └── RecalculateRealBackupSizes.php │ ├── Strategies │ │ ├── CleanupStrategy.php │ │ └── DefaultCleanupStrategy.php │ └── Support │ │ ├── DefaultStrategyConfig.php │ │ └── Period.php │ ├── Monitor │ ├── Events │ │ ├── HealthyDestinationFoundEvent.php │ │ ├── HealthySourceFoundEvent.php │ │ ├── UnhealthyDestinationFoundEvent.php │ │ └── UnhealthySourceFoundEvent.php │ ├── HealthCheckCollection.php │ ├── HealthCheckResult.php │ └── HealthChecks │ │ ├── Destination │ │ ├── DestinationHealthCheck.php │ │ ├── DestinationReachable.php │ │ ├── MaximumDiskCapacityUsageInPercentage.php │ │ ├── MaximumInodeUsageInPercentage.php │ │ └── MaximumStorageInMB.php │ │ ├── HealthCheck.php │ │ └── Source │ │ ├── MaximumAgeInDays.php │ │ ├── MaximumStorageInMB.php │ │ └── SourceHealthCheck.php │ ├── Search │ ├── ContentSearchResult.php │ ├── ContentSearchResultFactory.php │ ├── FileSearchResult.php │ └── FileSearchResultFactory.php │ └── Summary │ ├── Actions │ └── CreateServerSummaryAction.php │ ├── Jobs │ └── SendServerSummaryNotificationJob.php │ └── ServerSummary.php └── yarn.lock /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to `laravel-backup-server` will be documented in this file 4 | 5 | ## 4.1.1 - 2025-04-25 6 | 7 | ### What's Changed 8 | 9 | * Refactor status() method for clarity and better inheritance support by @Ayoub-Mabrouk in https://github.com/spatie/laravel-backup-server/pull/88 10 | 11 | ### New Contributors 12 | 13 | * @Ayoub-Mabrouk made their first contribution in https://github.com/spatie/laravel-backup-server/pull/88 14 | 15 | **Full Changelog**: https://github.com/spatie/laravel-backup-server/compare/4.1.0...4.1.1 16 | 17 | ## 4.1.0 - 2025-04-08 18 | 19 | ### What's Changed 20 | 21 | * Bump aglipanci/laravel-pint-action from 2.4 to 2.5 by @dependabot in https://github.com/spatie/laravel-backup-server/pull/85 22 | * Bump elliptic from 6.6.0 to 6.6.1 by @dependabot in https://github.com/spatie/laravel-backup-server/pull/86 23 | * Laravel 12 by @adrum in https://github.com/spatie/laravel-backup-server/pull/87 24 | 25 | ### New Contributors 26 | 27 | * @adrum made their first contribution in https://github.com/spatie/laravel-backup-server/pull/87 28 | 29 | **Full Changelog**: https://github.com/spatie/laravel-backup-server/compare/4.0.2...4.1.0 30 | 31 | ## 4.0.2 - 2025-02-05 32 | 33 | ### What's Changed 34 | 35 | * Bump dependabot/fetch-metadata from 2.2.0 to 2.3.0 by @dependabot in https://github.com/spatie/laravel-backup-server/pull/83 36 | * #82 issue fixed - type error by @SignetPlanet in https://github.com/spatie/laravel-backup-server/pull/84 37 | 38 | ### New Contributors 39 | 40 | * @SignetPlanet made their first contribution in https://github.com/spatie/laravel-backup-server/pull/84 41 | 42 | **Full Changelog**: https://github.com/spatie/laravel-backup-server/compare/4.0.1...4.0.2 43 | 44 | ## 4.0.1 - 2025-01-30 45 | 46 | - add license 47 | 48 | **Full Changelog**: https://github.com/spatie/laravel-backup-server/compare/4.0.0...4.0.1 49 | 50 | ## 4.0.0 - 2025-01-27 51 | 52 | ### What's Changed 53 | 54 | * v4: Pause notifications by @Nielsvanpach in https://github.com/spatie/laravel-backup-server/pull/78 55 | * V4 release by @Nielsvanpach in https://github.com/spatie/laravel-backup-server/pull/77 56 | * Change internal links from /v1/ to /v3/ by @juukie in https://github.com/spatie/laravel-backup-server/pull/73 57 | 58 | ### New Contributors 59 | 60 | * @juukie made their first contribution in https://github.com/spatie/laravel-backup-server/pull/73 61 | 62 | **Full Changelog**: https://github.com/spatie/laravel-backup-server/compare/3.3.0...4.0.0 63 | 64 | ## 3.3.0 - 2024-06-12 65 | 66 | ### What's Changed 67 | 68 | * introduce Dependabot by @Nielsvanpach in https://github.com/spatie/laravel-backup-server/pull/63 69 | * Update github actions + run Pint by @Nielsvanpach in https://github.com/spatie/laravel-backup-server/pull/67 70 | * Skeleton changes by @Nielsvanpach in https://github.com/spatie/laravel-backup-server/pull/68 71 | * Migrate to PestPHP by @Nielsvanpach in https://github.com/spatie/laravel-backup-server/pull/69 72 | 73 | **Full Changelog**: https://github.com/spatie/laravel-backup-server/compare/3.2.0...3.3.0 74 | 75 | ## 3.2.0 - 2024-03-15 76 | 77 | ### What's Changed 78 | 79 | * Support Laravel 11 80 | * Bump follow-redirects from 1.14.8 to 1.15.4 by @dependabot in https://github.com/spatie/laravel-backup-server/pull/55 81 | * Bump browserify-sign from 4.0.4 to 4.2.2 by @dependabot in https://github.com/spatie/laravel-backup-server/pull/54 82 | * Bump @babel/traverse from 7.8.3 to 7.23.2 by @dependabot in https://github.com/spatie/laravel-backup-server/pull/53 83 | * Fix some problems by @mozex in https://github.com/spatie/laravel-backup-server/pull/58 84 | * Bump follow-redirects from 1.15.4 to 1.15.6 by @dependabot in https://github.com/spatie/laravel-backup-server/pull/60 85 | * Bump ip from 1.1.5 to 1.1.9 by @dependabot in https://github.com/spatie/laravel-backup-server/pull/59 86 | 87 | ### New Contributors 88 | 89 | * @mozex made their first contribution in https://github.com/spatie/laravel-backup-server/pull/58 90 | 91 | **Full Changelog**: https://github.com/spatie/laravel-backup-server/compare/3.1.3...3.2.0 92 | 93 | ## 3.1.3 - 2024-03-15 94 | 95 | ### What's Changed 96 | 97 | * Bump semver from 5.7.1 to 5.7.2 by @dependabot in https://github.com/spatie/laravel-backup-server/pull/52 98 | * Fix empty path error for Backup model by @kostamilorava in https://github.com/spatie/laravel-backup-server/pull/57 99 | 100 | ### New Contributors 101 | 102 | * @kostamilorava made their first contribution in https://github.com/spatie/laravel-backup-server/pull/57 103 | 104 | **Full Changelog**: https://github.com/spatie/laravel-backup-server/compare/3.1.2...3.1.3 105 | 106 | ## 3.1.2 - 2023-04-07 107 | 108 | - support Laravel 10 109 | 110 | ## 3.1.1 - 2023-02-24 111 | 112 | ### What's Changed 113 | 114 | - Bump json5 from 1.0.1 to 1.0.2 by @dependabot in https://github.com/spatie/laravel-backup-server/pull/44 115 | - Bump express from 4.17.1 to 4.18.2 by @dependabot in https://github.com/spatie/laravel-backup-server/pull/43 116 | - Bump decode-uri-component from 0.2.0 to 0.2.2 by @dependabot in https://github.com/spatie/laravel-backup-server/pull/42 117 | - Bump minimatch from 3.0.4 to 3.1.2 by @dependabot in https://github.com/spatie/laravel-backup-server/pull/47 118 | - Bump async from 2.6.3 to 2.6.4 by @dependabot in https://github.com/spatie/laravel-backup-server/pull/40 119 | - Bump eventsource from 1.0.7 to 1.1.1 by @dependabot in https://github.com/spatie/laravel-backup-server/pull/39 120 | - Allow timeout for backup collection size calculation to be configurable by @Harrisonbro in https://github.com/spatie/laravel-backup-server/pull/48 121 | 122 | **Full Changelog**: https://github.com/spatie/laravel-backup-server/compare/3.1.0...3.1.1 123 | 124 | ## 3.1.0 - 2023-02-19 125 | 126 | ### What's Changed 127 | 128 | - Allow timeout for backup size calculation to be configurable by @Harrisonbro in https://github.com/spatie/laravel-backup-server/pull/45 129 | 130 | ### New Contributors 131 | 132 | - @Harrisonbro made their first contribution in https://github.com/spatie/laravel-backup-server/pull/45 133 | 134 | **Full Changelog**: https://github.com/spatie/laravel-backup-server/compare/3.0.0...3.1.0 135 | 136 | ## 3.0.0 - 2022-04-01 137 | 138 | - add support for Laravel 9 139 | 140 | ## 2.0.1 - 2021-07-26 141 | 142 | - increase disk usage command timeout (#21) 143 | 144 | ## 2.0.0 - 2020-12-02 145 | 146 | - use a cron expression to determine backup time 147 | - drop support for PHP 7 148 | 149 | ## 1.0.5 - 2020-10-24 150 | 151 | - remove dead code 152 | 153 | ## 1.0.4 - 2020-10-23 154 | 155 | - remove `viewMailcoach` authorization gate 156 | - rename `used_storage` in the `backup-server:list` command 157 | 158 | ## 1.0.3 - 2020-10-22 159 | 160 | - fix sorting on `youngest_backup_size `, `backup_size ` and `used_storage` in the `backup-server:list` command (#10) 161 | 162 | ## 1.0.2 - 2020-10-22 163 | 164 | - allow more fields to be sorted in `backup-server:list` command (#9) 165 | 166 | ## 1.0.1 - 2020-10-22 167 | 168 | - add possibility to sort sources in `backup-server:list` command (#7) 169 | 170 | ## 1.0.0 - 2020-10-21 171 | 172 | - initial release 173 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # 🌟 Welcome, Contributor! 🌟 2 | 3 | We’re thrilled you’re here! Whether you’re fixing a typo, squashing a bug, or adding a shiny new feature, your help makes this package better for everyone. Let’s make collaboration fun and human! 4 | 5 | --- 6 | 7 | ### 🧡 How We Work Together 8 | 9 | **Start a conversation** 10 | 11 | - **Have an idea?** We’d love to hear it! Open a discussion to chat about your proposal. Explain why it matters, and we’ll brainstorm together. 12 | 13 | **Keep it simple** 14 | - **Small fixes?** Jump right in! Fork the repo, make your changes, and send a pull request. 15 | - **Bigger changes?** Let’s discuss it first in a discussion to align our ideas. 16 | 17 | **We’re here to help!** 18 | Stuck? Confused? Ask questions in the issue tracker—no judgment, just good vibes. 19 | 20 | --- 21 | 22 | ### ✨ Our Shared Values 23 | 24 | **Code with kindness** 25 | - Write code that’s easy for humans to read (not just computers!). 26 | - Keep methods short and focused—like a good coffee break. 27 | - Add comments where things get tricky. 28 | 29 | **Tests are friends!** 30 | - If you’re adding something new, include a test to keep things safe and sound. 31 | - We use [PEST](https://pestphp.com/) because it’s delightful. 🐝 32 | 33 | **Documentation matters** 34 | - Update the README if your change affects how folks use the package. 35 | - Pretend you’re explaining it to a friend over tea. ☕ 36 | 37 | --- 38 | 39 | ### 🎁 Submitting Your Work 40 | 41 | 1. **Pull requests are gifts**—wrap them nicely! 42 | - Describe what you’ve changed and why. 43 | - Link to related issues. 44 | 2. **We’ll review it with care** and might suggest tweaks (it’s a team effort!). 45 | 3. **Celebrate when it’s merged!** 🎉 46 | 47 | --- 48 | 49 | ### 🤝 Be Excellent to Each Other 50 | 51 | This is a space for kindness, curiosity, and respect. Assume positive intent, give constructive feedback, and celebrate each other’s wins. We’re all here to learn and grow together! 52 | 53 | --- 54 | 55 | ### 💌 You’re Awesome 56 | 57 | Seriously—thank you for giving your time to this project. Open source thrives because of humans like you. Let’s make something great together! 58 | 59 | *P.S. These guidelines were inspired by Spatie’s open-source spirit. Need help? Just ask!* 60 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) spatie 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | Logo for laravel-backup-server 6 | 7 | 8 | 9 |

Store and manage backups securely on a dedicated server

10 | 11 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/spatie/laravel-backup-server.svg?style=flat-square)](https://packagist.org/packages/spatie/laravel-backup-server) 12 | [![GitHub Tests Action Status](https://img.shields.io/github/actions/workflow/status/spatie/laravel-backup-server/run-tests.yml?branch=main&label=tests)](https://github.com/spatie/laravel-backup-server/actions?query=workflow%3Arun-tests+branch%3Amain) 13 | [![Total Downloads](https://img.shields.io/packagist/dt/spatie/laravel-backup-server.svg?style=flat-square)](https://packagist.org/packages/spatie/laravel-backup-server) 14 | 15 |
16 | 17 | Laravel Backup Server builds on top of [laravel-backup](https://github.com/spatie/laravel-backup), allowing backups from multiple Laravel projects to be automatically sent to a server. 18 | 19 | ## Installation 20 | 21 | You can install the package via composer: 22 | 23 | ```bash 24 | composer require spatie/laravel-backup-server 25 | ``` 26 | 27 | ## Usage 28 | 29 | Extensive documention is available [on our documentation site](https://docs.spatie.be/laravel-backup-server/) 30 | 31 | 32 | ## Changelog 33 | 34 | Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. 35 | 36 | ## Contributing 37 | 38 | Please see [CONTRIBUTING](CONTRIBUTING.md) for details. 39 | 40 | ## Security Vulnerabilities 41 | 42 | Please review [our security policy](../../security/policy) on how to report security vulnerabilities. 43 | 44 | ## Credits 45 | 46 | - [Freek Van der Herten](https://github.com/freekmurze) 47 | - [Niels Vanpachtenbeke](https://github.com/nielsvanpach) 48 | - [All Contributors](../../contributors) 49 | 50 | ## License 51 | 52 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 53 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | Report security vulnerabilities to security@spatie.be 4 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spatie/laravel-backup-server", 3 | "description": "Backup multiple applications", 4 | "keywords": [ 5 | "spatie", 6 | "laravel-backup-server" 7 | ], 8 | "homepage": "https://github.com/spatie/laravel-backup-server", 9 | "license": "MIT", 10 | "authors": [ 11 | { 12 | "name": "Freek Van der Herten", 13 | "email": "freek@spatie.be", 14 | "homepage": "https://spatie.be", 15 | "role": "Developer" 16 | } 17 | ], 18 | "require": { 19 | "php": "^8.2", 20 | "dragonmantank/cron-expression": "^3.3.3", 21 | "illuminate/console": "^10.0|^11.0|^12.0", 22 | "illuminate/contracts": "^10.0|^11.0|^12.0", 23 | "illuminate/events": "^10.0|^11.0|^12.0", 24 | "illuminate/notifications": "^10.0|^11.0|^12.0", 25 | "illuminate/queue": "^10.0|^11.0|^12.0", 26 | "illuminate/support": "^10.0|^11.0|^12.0", 27 | "laravel/slack-notification-channel": "^3.2", 28 | "spatie/regex": "^3.1.1", 29 | "spatie/ssh": "^1.10" 30 | }, 31 | "require-dev": { 32 | "mockery/mockery": "^1.6.9", 33 | "orchestra/testbench": "^8.0|^9.0|^10.0", 34 | "pestphp/pest": "^2.34|^3.0", 35 | "pestphp/pest-plugin-type-coverage": "^2.8|^3.0", 36 | "phpunit/phpunit": "^10.5.13|^11.0", 37 | "rector/rector": "^1.1", 38 | "spatie/docker": "^1.12", 39 | "spatie/test-time": "^1.3.3", 40 | "symfony/var-dumper": "^6.0|^7.0.4" 41 | }, 42 | "autoload": { 43 | "psr-4": { 44 | "Spatie\\BackupServer\\": "src" 45 | } 46 | }, 47 | "autoload-dev": { 48 | "psr-4": { 49 | "Spatie\\BackupServer\\Tests\\Database\\Factories\\": "tests/database/Factories", 50 | "Spatie\\BackupServer\\Tests\\": "tests" 51 | 52 | } 53 | }, 54 | "scripts": { 55 | "test": "vendor/bin/pest --compact", 56 | "test-coverage": "vendor/bin/pest --coverage", 57 | "format": "vendor/bin/pint", 58 | "rector": "vendor/bin/rector --dry-run", 59 | "build-docker": "docker build -t spatie/laravel-backup-server-tests ./tests/docker" 60 | }, 61 | "config": { 62 | "sort-packages": true, 63 | "allow-plugins": { 64 | "pestphp/pest-plugin": true 65 | } 66 | }, 67 | "extra": { 68 | "laravel": { 69 | "providers": [ 70 | "Spatie\\BackupServer\\BackupServerServiceProvider" 71 | ], 72 | "aliases": { 73 | "BackupServer": "Spatie\\BackupServer\\BackupServerFacade" 74 | } 75 | } 76 | }, 77 | "minimum-stability": "dev", 78 | "prefer-stable": true 79 | } 80 | -------------------------------------------------------------------------------- /config/backup-server.php: -------------------------------------------------------------------------------- 1 | 'Y-m-d H:i', 10 | 11 | 'backup' => [ 12 | /* 13 | * This class is responsible for deciding when sources should be backed up. An valid backup scheduler 14 | * is any class that implements `Spatie\BackupServer\Tasks\Backup\Support\BackupScheduler\BackupScheduler`. 15 | */ 16 | 'scheduler' => \Spatie\BackupServer\Tasks\Backup\Support\BackupScheduler\DefaultBackupScheduler::class, 17 | ], 18 | 19 | 'notifications' => [ 20 | 21 | /* 22 | * Backup Server sends out notifications on several events. Out of the box, mails and Slack messages 23 | * can be sent. 24 | */ 25 | 'notifications' => [ 26 | \Spatie\BackupServer\Notifications\Notifications\BackupCompletedNotification::class => ['mail'], 27 | \Spatie\BackupServer\Notifications\Notifications\BackupFailedNotification::class => ['mail'], 28 | 29 | \Spatie\BackupServer\Notifications\Notifications\CleanupForSourceCompletedNotification::class => ['mail'], 30 | \Spatie\BackupServer\Notifications\Notifications\CleanupForSourceFailedNotification::class => ['mail'], 31 | \Spatie\BackupServer\Notifications\Notifications\CleanupForDestinationCompletedNotification::class => ['mail'], 32 | \Spatie\BackupServer\Notifications\Notifications\CleanupForDestinationFailedNotification::class => ['mail'], 33 | 34 | \Spatie\BackupServer\Notifications\Notifications\HealthySourceFoundNotification::class => ['mail'], 35 | \Spatie\BackupServer\Notifications\Notifications\UnhealthySourceFoundNotification::class => ['mail'], 36 | \Spatie\BackupServer\Notifications\Notifications\HealthyDestinationFoundNotification::class => ['mail'], 37 | \Spatie\BackupServer\Notifications\Notifications\UnhealthyDestinationFoundNotification::class => ['mail'], 38 | 39 | \Spatie\BackupServer\Notifications\Notifications\ServerSummaryNotification::class => ['mail'], 40 | ], 41 | 42 | /* 43 | * Here you can specify the notifiable to which the notifications should be sent. The default 44 | * notifiable will use the variables specified in this config file. 45 | */ 46 | 'notifiable' => \Spatie\BackupServer\Notifications\Notifiable::class, 47 | 48 | 'mail' => [ 49 | 'to' => 'your@example.com', 50 | 51 | 'from' => [ 52 | 'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'), 53 | 'name' => env('MAIL_FROM_NAME', 'Example'), 54 | ], 55 | ], 56 | 57 | 'slack' => [ 58 | 'webhook_url' => '', 59 | 60 | /* 61 | * If this is set to null the default channel of the web hook will be used. 62 | */ 63 | 'channel' => null, 64 | 65 | 'username' => 'Backup Server', 66 | 67 | 'icon' => null, 68 | 69 | ], 70 | ], 71 | 72 | 'monitor' => [ 73 | /* 74 | * These checks will be used to determine whether a source is health. The given value will be used 75 | * when there is no value for the check specified on either the destination or the source. 76 | */ 77 | 'source_health_checks' => [ 78 | \Spatie\BackupServer\Tasks\Monitor\HealthChecks\Source\MaximumStorageInMB::class => 5000, 79 | \Spatie\BackupServer\Tasks\Monitor\HealthChecks\Source\MaximumAgeInDays::class => 2, 80 | ], 81 | 82 | /* 83 | * These checks will be used to determine whether a destination is healthy. The given value will be used 84 | * when there is no value for the check specified on either the destination or the source. 85 | */ 86 | 'destination_health_checks' => [ 87 | \Spatie\BackupServer\Tasks\Monitor\HealthChecks\Destination\DestinationReachable::class, 88 | \Spatie\BackupServer\Tasks\Monitor\HealthChecks\Destination\MaximumDiskCapacityUsageInPercentage::class => 90, 89 | \Spatie\BackupServer\Tasks\Monitor\HealthChecks\Destination\MaximumStorageInMB::class => 0, 90 | \Spatie\BackupServer\Tasks\Monitor\HealthChecks\Destination\MaximumInodeUsageInPercentage::class => 90, 91 | ], 92 | ], 93 | 94 | 'cleanup' => [ 95 | /* 96 | * The strategy that will be used to cleanup old backups. The default strategy 97 | * will keep all backups for a certain amount of days. After that period only 98 | * a daily backup will be kept. After that period only weekly backups will 99 | * be kept and so on. 100 | * 101 | * No matter how you configure it the default strategy will never 102 | * delete the newest backup. 103 | */ 104 | 'strategy' => \Spatie\BackupServer\Tasks\Cleanup\Strategies\DefaultCleanupStrategy::class, 105 | 106 | 'default_strategy' => [ 107 | 108 | /* 109 | * The number of days for which backups must be kept. 110 | */ 111 | 'keep_all_backups_for_days' => 7, 112 | 113 | /* 114 | * The number of days for which daily backups must be kept. 115 | */ 116 | 'keep_daily_backups_for_days' => 31, 117 | 118 | /* 119 | * The number of weeks for which one weekly backup must be kept. 120 | */ 121 | 'keep_weekly_backups_for_weeks' => 8, 122 | 123 | /* 124 | * The number of months for which one monthly backup must be kept. 125 | */ 126 | 'keep_monthly_backups_for_months' => 4, 127 | 128 | /* 129 | * The number of years for which one yearly backup must be kept. 130 | */ 131 | 'keep_yearly_backups_for_years' => 2, 132 | 133 | /* 134 | * After cleaning up the backups remove the oldest backup until 135 | * this amount of megabytes has been reached. 136 | */ 137 | 'delete_oldest_backups_when_using_more_megabytes_than' => 5000, 138 | ], 139 | ], 140 | 141 | /* 142 | * Here you can specify on which connection the backup server jobs will be dispatched. 143 | * Leave empty to use the app default's env('QUEUE_CONNECTION') 144 | */ 145 | 'queue_connection' => '', 146 | 147 | 'jobs' => [ 148 | 'perform_backup_job' => [ 149 | 'queue' => 'backup-server-backup', 150 | 'timeout' => CarbonInterval::hour(1)->totalSeconds, 151 | ], 152 | 'delete_backup_job' => [ 153 | 'queue' => 'backup-server', 154 | 'timeout' => CarbonInterval::minutes(1)->totalSeconds, 155 | ], 156 | 'delete_destination_job' => [ 157 | 'queue' => 'backup-server', 158 | 'timeout' => CarbonInterval::hour(1)->totalSeconds, 159 | ], 160 | 'delete_source_job' => [ 161 | 'queue' => 'backup-server', 162 | 'timeout' => CarbonInterval::hour(1)->totalSeconds, 163 | ], 164 | 'perform_cleanup_for_source_job' => [ 165 | 'queue' => 'backup-server-cleanup', 166 | 'timeout' => CarbonInterval::hour(1)->totalSeconds, 167 | ], 168 | 'perform_cleanup_for_destination_job' => [ 169 | 'queue' => 'backup-server-cleanup', 170 | 'timeout' => CarbonInterval::hour(1)->totalSeconds, 171 | ], 172 | ], 173 | 174 | /** 175 | * It can take a long time to calculate the size of very large backups. If your 176 | * backups sometimes timeout when calculating their size you can increase this value. 177 | */ 178 | 'backup_size_calculation_timeout_in_seconds' => 60, 179 | 180 | /** 181 | * Calculating the size of multiple backups at once can be a very slow 182 | * process particularly on cloud volumes, so we allow plenty of time. 183 | */ 184 | 'backup_collection_size_calculation_timeout_in_seconds' => 60 * 15, 185 | ]; 186 | -------------------------------------------------------------------------------- /database/migrations/create_backup_server_tables.php.stub: -------------------------------------------------------------------------------- 1 | bigIncrements('id'); 13 | 14 | $table->string('status')->default('active'); 15 | 16 | $table->string('name'); 17 | $table->string('disk_name'); 18 | 19 | $table->integer('keep_all_backups_for_days')->nullable(); 20 | $table->integer('keep_daily_backups_for_days')->nullable(); 21 | $table->integer('keep_weekly_backups_for_weeks')->nullable(); 22 | $table->integer('keep_monthly_backups_for_months')->nullable(); 23 | $table->integer('keep_yearly_backups_for_years')->nullable(); 24 | $table->integer('delete_oldest_backups_when_using_more_megabytes_than')->nullable(); 25 | 26 | $table->integer('healthy_maximum_backup_age_in_days_per_source')->nullable(); 27 | $table->integer('healthy_maximum_storage_in_mb_per_source')->nullable(); 28 | $table->integer('healthy_maximum_storage_in_mb')->nullable(); 29 | $table->integer('healthy_maximum_inode_usage_percentage')->nullable(); 30 | 31 | $table->timestamps(); 32 | }); 33 | 34 | Schema::create('backup_server_sources', function (Blueprint $table) { 35 | $table->bigIncrements('id'); 36 | 37 | $table->string('status')->default('active'); 38 | $table->boolean('healthy')->default(false); 39 | 40 | $table->string('name'); 41 | $table->string('host'); 42 | $table->string('ssh_user'); 43 | $table->integer('ssh_port')->default(22); 44 | $table->string('ssh_private_key_file', 512)->nullable(); 45 | 46 | $table->string('cron_expression'); 47 | 48 | $table->json('pre_backup_commands')->nullable(); 49 | $table->json('post_backup_commands')->nullable(); 50 | 51 | $table->json('includes')->nullable(); 52 | $table->json('excludes')->nullable(); 53 | 54 | $table->unsignedBigInteger('destination_id')->nullable(); 55 | 56 | $table->string('cleanup_strategy_class')->nullable(); 57 | 58 | $table->integer('keep_all_backups_for_days')->nullable(); 59 | $table->integer('keep_daily_backups_for_days')->nullable(); 60 | $table->integer('keep_weekly_backups_for_weeks')->nullable(); 61 | $table->integer('keep_monthly_backups_for_months')->nullable(); 62 | $table->integer('keep_yearly_backups_for_years')->nullable(); 63 | $table->integer('delete_oldest_backups_when_using_more_megabytes_than')->nullable(); 64 | 65 | $table->integer('healthy_maximum_backup_age_in_days')->nullable(); 66 | $table->integer('healthy_maximum_storage_in_mb')->nullable(); 67 | 68 | $table->timestamp('pause_notifications_until')->nullable(); 69 | 70 | $table->timestamps(); 71 | 72 | $table 73 | ->foreign('destination_id') 74 | ->references('id') 75 | ->on('backup_server_destinations') 76 | ->onDelete('set null'); 77 | }); 78 | 79 | Schema::create('backup_server_backups', function (Blueprint $table) { 80 | $table->bigIncrements('id'); 81 | $table->string('status'); 82 | $table->unsignedBigInteger('source_id'); 83 | $table->unsignedBigInteger('destination_id'); 84 | $table->string('disk_name'); 85 | $table->string('path')->nullable(); 86 | $table->unsignedBigInteger('size_in_kb')->nullable(); 87 | $table->unsignedBigInteger('real_size_in_kb')->nullable(); 88 | 89 | $table->timestamps(); 90 | $table->timestamp('completed_at')->nullable(); 91 | 92 | $table->text('rsync_summary')->nullable(); 93 | $table->bigInteger('rsync_time_in_seconds')->nullable(); 94 | $table->string('rsync_current_transfer_speed')->nullable(); 95 | $table->string('rsync_average_transfer_speed_in_MB_per_second')->nullable(); 96 | 97 | $table->foreign('source_id')->references('id')->on('backup_server_sources')->onDelete('cascade'); 98 | $table->foreign('destination_id')->references('id')->on('backup_server_destinations')->onDelete('cascade'); 99 | }); 100 | 101 | Schema::create('backup_server_backup_log', function (Blueprint $table) { 102 | $table->bigIncrements('id'); 103 | $table->unsignedBigInteger('source_id')->nullable(); 104 | $table->unsignedBigInteger('backup_id')->nullable(); 105 | $table->unsignedBigInteger('destination_id')->nullable(); 106 | $table->string('task'); 107 | $table->string('level'); 108 | $table->longText('message'); 109 | $table->timestamps(); 110 | }); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /lint-staged.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'resources/{css,js}/**/*.{css,js}': ['prettier --write', 'git add'], 3 | 'resources/**/*.{css,js}?(x)': () => ['yarn production', 'git add resources/dist'], 4 | }; 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "dev": "yarn run development", 5 | "development": "cross-env NODE_ENV=development node_modules/webpack/bin/webpack.js --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js", 6 | "watch": "yarn run development -- --watch", 7 | "watch-poll": "yarn run watch -- --watch-poll", 8 | "hot": "cross-env NODE_ENV=development node_modules/webpack-dev-server/bin/webpack-dev-server.js --inline --hot --config=node_modules/laravel-mix/setup/webpack.config.js", 9 | "prod": "yarn run production", 10 | "production": "cross-env NODE_ENV=production node_modules/webpack/bin/webpack.js --no-progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js", 11 | "format": "prettier 'resources/**/*.{css,js,json,css,ts,tsx}' '*.{js,css}' --write", 12 | "type-check": "tsc --noEmit" 13 | }, 14 | "devDependencies": { 15 | "choices.js": "^9.0.1", 16 | "cross-env": "^5.1", 17 | "flatpickr": "^4.6.3", 18 | "husky": "^3.0.9", 19 | "laravel-mix": "^5.0.0", 20 | "laravel-mix-bundle-analyzer": "^1.0.2", 21 | "lint-staged": "^9.5.0", 22 | "morphdom": "^2.5.10", 23 | "postcss-easy-import": "^3.0.0", 24 | "prettier": "^1.18.2", 25 | "tailwindcss": "^1.0.0", 26 | "turbolinks": "^5.2.0", 27 | "vue-template-compiler": "^2.6.10" 28 | }, 29 | "husky": { 30 | "hooks": { 31 | "pre-commit": "lint-staged" 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /rector.php: -------------------------------------------------------------------------------- 1 | withPaths(['config', 'resources', 'src']) 16 | ->withPhpSets(php82: true) 17 | ->withPreparedSets(deadCode: true, codeQuality: true, typeDeclarations: true) 18 | ->withSkip([ 19 | ReadOnlyPropertyRector::class, 20 | ClosureToArrowFunctionRector::class, 21 | AddArrowFunctionReturnTypeRector::class, 22 | AddClosureVoidReturnTypeWhereNoReturnRector::class, 23 | CombineIfRector::class, 24 | FlipTypeControlToUseExclusiveTypeRector::class, 25 | ExplicitBoolCompareRector::class, 26 | ]); 27 | -------------------------------------------------------------------------------- /resources/lang/en/notifications.php: -------------------------------------------------------------------------------- 1 | 'Exception:', 5 | 'exception_message_title' => 'Exception message', 6 | 'exception_trace_title' => 'Exception trace', 7 | 8 | 'backup_failed_subject' => 'Failed backup of :source_name', 9 | 'backup_failed_subject_title' => 'Backup failed!', 10 | 'backup_failed_body' => 'Important: An error occurred while backing up :source_name to :destination_name.', 11 | 12 | 'backup_completed_subject' => 'Successful new backup of :source_name', 13 | 'backup_completed_subject_title' => 'Successful new backup!', 14 | 'backup_completed_body' => 'Great news, a new backup of :source_name was successfully created on :destination_name.', 15 | 16 | 'cleanup_source_successful_subject' => 'Clean up of :source_name backups successful', 17 | 'cleanup_source_successful_subject_title' => 'All clean!', 18 | 'cleanup_source_successful_body' => 'The backups of :source_name were succesfully cleaned up.', 19 | 20 | 'cleanup_source_failed_subject' => 'Clean up of :source_name backups failed', 21 | 'cleanup_source_failed_subject_title' => 'Clean up failed!', 22 | 'cleanup_source_failed_body' => 'An error occurred while cleaning up the backups of :source_name.', 23 | 24 | 'cleanup_destination_successful_subject' => 'Clean up of backups on :destination_name succesfull', 25 | 'cleanup_destination_successful_subject_title' => 'All clean!', 26 | 'cleanup_destination_successful_body' => 'The backups on :destination_name were successfully cleaned up.', 27 | 28 | 'cleanup_destination_failed_subject' => 'Clean up of :destination_name backups failed', 29 | 'cleanup_destination_failed_subject_title' => 'Clean up failed!', 30 | 'cleanup_destination_failed_body' => 'An error occurred while cleaning up the backups on :destination_name.', 31 | 32 | 'healthy_source_found_subject' => 'The backups for :source_name are healthy', 33 | 'healthy_source_found_subject_title' => 'Healthy backups', 34 | 'healthy_source_found_body' => 'A health check ran and the backups for :source_name are considered healthy.', 35 | 36 | 'unhealthy_source_found_subject' => 'Important: The backups for :source_name are unhealthy', 37 | 'unhealthy_source_found_subject_title' => 'Unhealthy backups', 38 | 'unhealthy_source_found_body' => 'Important: A health check ran and the backups for :source_name are unhealthy.', 39 | 40 | 'healthy_destination_found_subject' => 'The backup destination :destination_name is healthy', 41 | 'healthy_destination_found_subject_title' => 'Healthy backup destination', 42 | 'healthy_destination_found_body' => 'A health check ran and the backup destination :destination_name is considered healthy.', 43 | 44 | 'unhealthy_destination_found_subject' => 'Important: The backup destination :destination_name is unhealthy', 45 | 'unhealthy_destination_found_subject_title' => 'Unhealthy backup destination', 46 | 'unhealthy_destination_found_body' => 'A health check ran and the backup destination :destination_name is unhealthy.', 47 | 48 | 'server_summary_subject' => 'Backup summary for *:period*', 49 | 'server_summary_subject_title' => 'Your backup summary', 50 | 'server_summary_body' => "Below you'll find some stats for your backup server for :period.", 51 | ]; 52 | -------------------------------------------------------------------------------- /src/BackupServerServiceProvider.php: -------------------------------------------------------------------------------- 1 | bootCarbon() 28 | ->bootCommands() 29 | ->bootGate() 30 | ->bootPublishables() 31 | ->bootTranslations(); 32 | } 33 | 34 | public function register(): void 35 | { 36 | $this->mergeConfigFrom(__DIR__.'/../config/backup-server.php', 'backup-server'); 37 | 38 | $this->app['events']->subscribe(EventHandler::class); 39 | 40 | $this->app->bind(BackupScheduler::class, function () { 41 | $schedulerClass = config('backup-server.backup.scheduler'); 42 | 43 | return new $schedulerClass; 44 | }); 45 | 46 | $this->app->bind(CleanupStrategy::class, function () { 47 | $strategyClass = config('backup-server.cleanup.strategy'); 48 | 49 | return new $strategyClass; 50 | }); 51 | } 52 | 53 | protected function bootCarbon(): static 54 | { 55 | $dataFormat = config('backup-server.date_format'); 56 | 57 | Carbon::macro('toBackupServerFormat', fn () => self::this()->copy()->format($dataFormat)); 58 | 59 | return $this; 60 | } 61 | 62 | protected function bootCommands(): static 63 | { 64 | if ($this->app->runningInConsole()) { 65 | $this->commands([ 66 | CreateBackupCommand::class, 67 | DispatchPerformBackupJobsCommand::class, 68 | DispatchPerformCleanupJobsCommand::class, 69 | ListSourcesCommand::class, 70 | ListDestinationsCommand::class, 71 | MonitorBackupsCommand::class, 72 | FindFilesCommand::class, 73 | FindContentCommand::class, 74 | ]); 75 | } 76 | 77 | return $this; 78 | } 79 | 80 | protected function bootGate(): self 81 | { 82 | Gate::define('viewBackupServer', fn ($user = null) => app()->environment('local')); 83 | 84 | return $this; 85 | } 86 | 87 | protected function bootPublishables(): self 88 | { 89 | $this->publishes([ 90 | __DIR__.'/../config/backup-server.php' => config_path('backup-server.php'), 91 | ], 'backup-server-config'); 92 | 93 | $this->publishes([ 94 | __DIR__.'/../resources/views' => resource_path('views/vendor/backup-server'), 95 | ], 'backup-server-views'); 96 | 97 | if (! class_exists('CreateBackupServerTables')) { 98 | $this->publishes([ 99 | __DIR__.'/../database/migrations/create_backup_server_tables.php.stub' => database_path('migrations/'.date('Y_m_d_His', time()).'_create_backup_server_tables.php'), 100 | ], 'backup-server-migrations'); 101 | } 102 | 103 | return $this; 104 | } 105 | 106 | protected function bootTranslations(): self 107 | { 108 | $this->loadTranslationsFrom(__DIR__.'/../resources/lang/', 'backup-server'); 109 | 110 | return $this; 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/Commands/CreateBackupCommand.php: -------------------------------------------------------------------------------- 1 | argument('sourceName'); 22 | 23 | $source = Source::firstWhere('name', $sourceName); 24 | 25 | if (! $source) { 26 | $this->error("There is no source named `{$sourceName}`"); 27 | 28 | return -1; 29 | } 30 | 31 | $this->info("Creating new backup for {$sourceName}..."); 32 | 33 | $writeLogItemsToConsole = function (Backup $backup) { 34 | Event::listen('eloquent.saving: '.BackupLogItem::class, function (BackupLogItem $backupLogItem) use ($backup) { 35 | if ($backupLogItem->backup_id !== $backup->id) { 36 | return; 37 | } 38 | 39 | $outputMethod = $backupLogItem->level === LogLevel::Error 40 | ? 'error' 41 | : 'comment'; 42 | 43 | $this->$outputMethod($backupLogItem->message); 44 | }); 45 | }; 46 | 47 | (new CreateBackupAction) 48 | ->doNotUseQueue() 49 | ->afterBackupModelCreated($writeLogItemsToConsole) 50 | ->execute($source); 51 | 52 | return 0; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Commands/DispatchPerformBackupJobsCommand.php: -------------------------------------------------------------------------------- 1 | info('Dispatching backup jobs...'); 19 | 20 | $backupScheduler = app(BackupScheduler::class); 21 | 22 | Source::cursor() 23 | ->filter(fn (Source $source) => $backupScheduler->shouldBackupNow($source)) 24 | ->each(function (Source $source) { 25 | $this->comment("Dispatching backup job for source `{$source->name}` (id: {$source->id})"); 26 | 27 | (new CreateBackupAction)->execute($source); 28 | }); 29 | 30 | $this->info('All done!'); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Commands/DispatchPerformCleanupJobsCommand.php: -------------------------------------------------------------------------------- 1 | info('Dispatching cleanup jobs...'); 20 | 21 | Source::each(function (Source $source) { 22 | $this->comment("Dispatching cleanup job for source `{$source->name}` (id: {$source->id})..."); 23 | 24 | dispatch(new PerformCleanupSourceJob($source)); 25 | }); 26 | 27 | Destination::each(function (Destination $destination) { 28 | $this->comment("Dispatching cleanup job for destination `{$destination->name}` (id: {$destination->id})..."); 29 | 30 | dispatch(new PerformCleanupDestinationJob($destination)); 31 | }); 32 | 33 | $this->info('All done!'); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Commands/FindContentCommand.php: -------------------------------------------------------------------------------- 1 | argument('sourceName'); 24 | 25 | $searchFor = $this->argument('searchFor'); 26 | 27 | if (! $source = Source::named($this->argument('sourceName'))->first()) { 28 | $this->error("Did not find a source named {$sourceName}"); 29 | 30 | return -1; 31 | } 32 | 33 | $source->completedBackups 34 | ->each(function (Backup $backup) use ($searchFor) { 35 | $backup->findContent($searchFor, Closure::fromCallable($this->handleFoundContent(...))); 36 | }); 37 | 38 | $this->comment(''); 39 | $this->comment($this->resultCounter.' '.Str::plural('search result', $this->resultCounter).' found.'); 40 | 41 | return null; 42 | } 43 | 44 | protected function handleFoundContent(Collection $contentSearchResults) 45 | { 46 | $contentSearchResults->each(function (ContentSearchResult $contentSearchResult) { 47 | $this->resultCounter++; 48 | 49 | $this->info($contentSearchResult->getAbsolutePath().':'.$contentSearchResult->lineNumber()); 50 | }); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Commands/FindFilesCommand.php: -------------------------------------------------------------------------------- 1 | argument('sourceName'); 24 | 25 | $searchFor = $this->argument('searchFor'); 26 | 27 | $this->info("Searching all backups of `{$sourceName}` for files named `{$searchFor}`..."); 28 | 29 | if (! $source = Source::named($this->argument('sourceName'))->first()) { 30 | $this->info("Did not find a source named {$sourceName}"); 31 | 32 | return true; 33 | } 34 | 35 | $source->completedBackups 36 | ->each(function (Backup $backup) use ($searchFor) { 37 | $backup->findFile($searchFor, Closure::fromCallable($this->handleFoundFile(...))); 38 | }); 39 | 40 | $this->info(''); 41 | 42 | $this->info($this->resultCounter.' '.Str::plural('search result', $this->resultCounter).' found.'); 43 | 44 | return 0; 45 | } 46 | 47 | protected function handleFoundFile(Collection $fileSearchResults) 48 | { 49 | $fileSearchResults->each(function (FileSearchResult $fileSearchResult) { 50 | $this->resultCounter++; 51 | 52 | $this->comment($fileSearchResult->getAbsolutePath()); 53 | }); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Commands/ListDestinationsCommand.php: -------------------------------------------------------------------------------- 1 | map(fn (Destination $destination) => $this->convertToRow($destination)); 32 | 33 | $columnStyles = collect($headers) 34 | ->map(function (string $header): ?TableStyle { 35 | if (in_array($header, ['Total Backup Size', 'Used Storage', 'Free Space', 'Capacity Used', 'Inode Usage'])) { 36 | return new AlignRightTableStyle; 37 | } 38 | 39 | if ($header === 'Healthy') { 40 | return new AlignCenterTableStyle; 41 | } 42 | 43 | return null; 44 | }) 45 | ->filter() 46 | ->all(); 47 | 48 | $this->table($headers, $rows, 'default', $columnStyles); 49 | } 50 | 51 | protected function convertToRow(Destination $destination): array 52 | { 53 | $backups = $destination->backups; 54 | 55 | $rowValues = [ 56 | 'name' => $destination->name, 57 | 'healthy' => Format::emoji($destination->isHealthy()), 58 | 'total_backup_size' => Format::KbToHumanReadableSize($backups->sizeInKb()), 59 | 'used_storage' => Format::KbToHumanReadableSize($destination->backups->realSizeInKb()), 60 | ]; 61 | 62 | if ($destination->reachable()) { 63 | return array_merge($rowValues, [ 64 | 'free_space' => Format::KbToHumanReadableSize($destination->getFreeSpaceInKb()), 65 | 'capacity_used' => $destination->getUsedSpaceInPercentage().'%', 66 | 'inode_usage' => $destination->getInodeUsagePercentage().'%', 67 | ]); 68 | } 69 | 70 | return $rowValues; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/Commands/ListSourcesCommand.php: -------------------------------------------------------------------------------- 1 | 'Source', 24 | 'id' => 'Id', 25 | 'healthy' => 'Healthy', 26 | 'backup_count' => '# of Backups', 27 | 'newest_backup' => 'Youngest Backup Age', 28 | 'youngest_backup_size' => 'Youngest Backup Size', 29 | 'backup_size' => 'Total Backup Size', 30 | 'used_storage' => 'Used storage', 31 | ]; 32 | 33 | public function handle(): void 34 | { 35 | $sortBy = (string) ($this->option('sortBy') ?? 'name'); 36 | 37 | $this->guardAgainstInvalidOptionValues($sortBy); 38 | 39 | $rows = Source::all() 40 | ->map(fn (Source $source) => $this->convertToRow($source)) 41 | ->sortBy($sortBy, SORT_REGULAR, $this->option('desc')) 42 | ->map(fn (Collection $data) => $this->makeRowReadable($data)); 43 | 44 | $headers = array_values($this->headers); 45 | 46 | $columnStyles = collect($headers) 47 | ->map(function (string $header): \Spatie\BackupServer\Support\AlignRightTableStyle|\Spatie\BackupServer\Support\AlignCenterTableStyle|null { 48 | if (in_array($header, ['Id', 'Youngest Backup Size', '# of Backups', 'Total Backup Size', 'Used storage'])) { 49 | return new AlignRightTableStyle; 50 | } 51 | 52 | if ($header === 'Healthy') { 53 | return new AlignCenterTableStyle; 54 | } 55 | 56 | return null; 57 | }) 58 | ->filter() 59 | ->all(); 60 | 61 | $this->table($headers, $rows, 'default', $columnStyles); 62 | } 63 | 64 | protected function makeRowReadable(Collection $row): array 65 | { 66 | return [ 67 | 'name' => $row->get('name'), 68 | 'id' => $row->get('id'), 69 | 'healthy' => Format::emoji($row->get('healthy')), 70 | 'backup_count' => $row->get('backup_count'), 71 | 'newest_backup' => $row->get('newest_backup') ? Format::ageInDays($row->get('newest_backup')) : 'No backups present', 72 | 'youngest_backup_size' => $row->get('youngest_backup_size') ? Format::KbToHumanReadableSize($row->get('youngest_backup_size')) : '/', 73 | 'backup_size' => $row->get('backup_size') ? Format::KbToHumanReadableSize($row->get('backup_size')) : '/', 74 | 'used_storage' => $row->get('used_storage') ? Format::KbToHumanReadableSize($row->get('used_storage')) : '/', 75 | ]; 76 | } 77 | 78 | protected function convertToRow(Source $source): Collection 79 | { 80 | $completedBackups = $source->completedBackups; 81 | 82 | $youngestBackup = $completedBackups->youngest(); 83 | 84 | return collect([ 85 | 'name' => $source->name, 86 | 'id' => $source->id, 87 | 'healthy' => $source->isHealthy(), 88 | 'backup_count' => $completedBackups->count(), 89 | 'newest_backup' => $youngestBackup->created_at ?? null, 90 | 'youngest_backup_size' => $youngestBackup->size_in_kb ?? null, 91 | 'backup_size' => $completedBackups->sizeInKb(), 92 | 'used_storage' => $completedBackups->realSizeInKb(), 93 | ]); 94 | } 95 | 96 | protected function getFormattedBackupDate(?Backup $backup = null): string 97 | { 98 | return is_null($backup) 99 | ? 'No backups present' 100 | : Format::ageInDays($backup->created_at); 101 | } 102 | 103 | public function guardAgainstInvalidOptionValues(string $optionValue): void 104 | { 105 | if (! array_key_exists($optionValue, $this->headers)) { 106 | throw InvalidCommandInput::byOption($optionValue, array_keys($this->headers)); 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/Commands/MonitorBackupsCommand.php: -------------------------------------------------------------------------------- 1 | info('Checking health...'); 23 | 24 | $this 25 | ->checkSourcesHealth() 26 | ->checkDestinationsHealth(); 27 | 28 | $this->info('All done!'); 29 | } 30 | 31 | protected function checkSourcesHealth(): self 32 | { 33 | [$healthySources, $unhealthySources] = collect(Source::all()) 34 | ->partition(function (Source $source): bool { 35 | return $source->isHealthy(); 36 | }); 37 | 38 | $healthySources->each(function (Source $source) { 39 | $this->comment("Source `{$source->name}` is healthy"); 40 | 41 | $source->update(['healthy' => true]); 42 | 43 | event(new HealthySourceFoundEvent($source)); 44 | }); 45 | 46 | $unhealthySources->each(function (Source $source) { 47 | $failureMessages = $source->getHealthChecks()->getFailureMessages(); 48 | 49 | $this->error("Source `{$source->name}` is unhealthy"); 50 | 51 | foreach ($failureMessages as $failureMessage) { 52 | $source->logError(Task::Monitor, $failureMessage); 53 | } 54 | 55 | $source->update(['healthy' => false]); 56 | 57 | event(new UnhealthySourceFoundEvent($source, $failureMessages)); 58 | }); 59 | 60 | return $this; 61 | } 62 | 63 | protected function checkDestinationsHealth(): self 64 | { 65 | [$healthyDestinations, $unHealthyDestinations] = collect(Destination::all()) 66 | ->partition(function (Destination $destination): bool { 67 | return $destination->isHealthy(); 68 | }); 69 | 70 | $healthyDestinations->each(function (Destination $destination) { 71 | $this->comment("Destination `{$destination->name}` is healthy"); 72 | 73 | event(new HealthyDestinationFoundEvent($destination)); 74 | }); 75 | 76 | $unHealthyDestinations->each(function (Destination $destination) { 77 | $failureMessages = $destination->getHealthChecks()->getFailureMessages(); 78 | 79 | $this->error("Destination `{$destination->name}` is unhealthy"); 80 | 81 | foreach ($failureMessages as $failureMessage) { 82 | $destination->logError(Task::Monitor, $failureMessage); 83 | } 84 | 85 | event(new UnhealthyDestinationFoundEvent($destination, $failureMessages)); 86 | }); 87 | 88 | return $this; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/Enums/BackupStatus.php: -------------------------------------------------------------------------------- 1 | sourceLocation()->connectionString()} could not be reached. Response: {$response}"); 13 | } 14 | 15 | public static function destinationNotReachable(Backup $backup): self 16 | { 17 | return new static("The destination disk `{$backup->destination->disk_name}` could not be reached."); 18 | } 19 | 20 | public static function rsyncDidFail(Backup $backup, string $commandOutput): static 21 | { 22 | return new static("rsync failed. Output: {$commandOutput}"); 23 | } 24 | 25 | public static function BackupCommandsFailed(Backup $backup, string $attribute, string $commandOutput): static 26 | { 27 | return new static("Backup commands in attribute `{$attribute}` failed. Output: {$commandOutput}"); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Exceptions/InvalidCommandInput.php: -------------------------------------------------------------------------------- 1 | BackupStatus::class, 44 | 'log' => 'array', 45 | 'size_in_kb' => 'int', 46 | 'real_size_in_kb' => 'int', 47 | 'completed_at' => 'datetime', 48 | 'pre_backup_commands' => 'array', 49 | 'post_backup_commands' => 'array', 50 | 'includes' => 'array', 51 | 'excludes' => 'array', 52 | ]; 53 | 54 | public static function booted(): void 55 | { 56 | static::deleting(function (Backup $backup) { 57 | if (! empty($backup->path) && $backup->disk()->exists($backup->path)) { 58 | $backup->disk()->deleteDirectory($backup->path); 59 | } 60 | }); 61 | } 62 | 63 | protected static function newFactory(): BackupFactory 64 | { 65 | return BackupFactory::new(); 66 | } 67 | 68 | public function getDeletionJobClassName(): string 69 | { 70 | return DeleteBackupJob::class; 71 | } 72 | 73 | public function newCollection(array $models = []) 74 | { 75 | return new BackupCollection($models); 76 | } 77 | 78 | public function source(): BelongsTo 79 | { 80 | return $this->belongsTo(Source::class); 81 | } 82 | 83 | public function destination(): BelongsTo 84 | { 85 | return $this->belongsTo(Destination::class); 86 | } 87 | 88 | public function logItems(): HasMany 89 | { 90 | return $this->hasMany(BackupLogItem::class); 91 | } 92 | 93 | public function sourceLocation(): SourceLocation 94 | { 95 | return new SourceLocation( 96 | $this->source->includes ?? [], 97 | $this->source->ssh_user, 98 | $this->source->host, 99 | $this->source->ssh_port, 100 | ); 101 | } 102 | 103 | public function destinationLocation(): DestinationLocation 104 | { 105 | return new DestinationLocation($this->disk_name, $this->path); 106 | } 107 | 108 | public function markAsInProgress(): self 109 | { 110 | $this->update([ 111 | 'status' => BackupStatus::InProgress, 112 | ]); 113 | 114 | return $this; 115 | } 116 | 117 | public function markAsCompleted(): self 118 | { 119 | $this->logInfo(Task::Backup, 'Backup completed.'); 120 | 121 | $this->update([ 122 | 'status' => BackupStatus::Completed, 123 | 'completed_at' => now(), 124 | ]); 125 | 126 | return $this; 127 | } 128 | 129 | public function markAsFailed(string $errorMessage): self 130 | { 131 | $this->logError(Task::Backup, "Backup failed: {$errorMessage}"); 132 | 133 | $this->update([ 134 | 'status' => BackupStatus::Failed, 135 | ]); 136 | 137 | return $this; 138 | } 139 | 140 | public function scopeCompleted(Builder $query): void 141 | { 142 | $query->where('status', BackupStatus::Completed); 143 | } 144 | 145 | public function scopeFailed(Builder $query): void 146 | { 147 | $query->where('status', BackupStatus::Failed); 148 | } 149 | 150 | public function handleProgress(string $type, string $progressOutput): self 151 | { 152 | if ($type === Process::ERR) { 153 | $this->logError(Task::Backup, $progressOutput); 154 | 155 | return $this; 156 | } 157 | 158 | $rsyncOutput = new RsyncProgressOutput($progressOutput); 159 | 160 | if ($rsyncOutput->concernsProgress()) { 161 | $this->update([ 162 | 'rsync_current_transfer_speed' => $rsyncOutput->getTransferSpeed(), 163 | ]); 164 | } 165 | 166 | return $this; 167 | } 168 | 169 | protected function addMessageToLog(Task $task, LogLevel $level, string $message): Backup 170 | { 171 | $this->logItems()->create([ 172 | 'source_id' => $this->source_id, 173 | 'destination_id' => $this->destination_id, 174 | 'task' => $task, 175 | 'level' => $level, 176 | 'message' => trim($message), 177 | ]); 178 | 179 | return $this; 180 | } 181 | 182 | public function recalculateBackupSize(): Backup 183 | { 184 | $process = Process::fromShellCommandline( 185 | 'du -kd 0', 186 | $this->destinationLocation()->getFullPath(), 187 | null, 188 | null, 189 | config('backup-server.backup_size_calculation_timeout_in_seconds'), 190 | ); 191 | 192 | $process->run(); 193 | 194 | $output = $process->getOutput(); 195 | 196 | $sizeInKb = Str::before($output, ' '); 197 | 198 | $this->update(['size_in_kb' => (int) trim($sizeInKb)]); 199 | 200 | return $this; 201 | } 202 | 203 | public function recalculateRealBackupSize(): Backup 204 | { 205 | if (! $this->disk()->exists($this->path)) { 206 | $this->update(['real_size_in_kb' => 0]); 207 | 208 | return $this; 209 | } 210 | 211 | $command = 'du -kd 1 ..'; 212 | 213 | $process = Process::fromShellCommandline( 214 | $command, 215 | $this->destinationLocation()->getFullPath(), 216 | null, 217 | null, 218 | config('backup-server.backup_size_calculation_timeout_in_seconds'), 219 | ); 220 | $process->run(); 221 | 222 | $output = $process->getOutput(); 223 | 224 | $directoryLine = collect(explode(PHP_EOL, $output))->first(function (string $line) { 225 | return Str::contains($line, $this->destinationLocation()->getDirectory()); 226 | }); 227 | 228 | $sizeInKb = Str::before($directoryLine, "\t"); 229 | 230 | $this->update(['real_size_in_kb' => (int) trim($sizeInKb)]); 231 | 232 | return $this; 233 | } 234 | 235 | public function existsOnDisk(): bool 236 | { 237 | return $this->destination->disk()->exists($this->path); 238 | } 239 | 240 | public function disk(): Filesystem 241 | { 242 | return Storage::disk($this->disk_name); 243 | } 244 | 245 | public function pathPrefix(): string 246 | { 247 | return config("filesystems.disks.{$this->disk_name}.root", ''); 248 | } 249 | 250 | public function has(string $path): bool 251 | { 252 | return $this->disk()->exists("{$this->path}/{$path}"); 253 | } 254 | 255 | public function findFile(string $searchFor, callable $handleSearchResult): bool 256 | { 257 | $path = $this->destinationLocation()->getFullPath(); 258 | 259 | if (! file_exists($path)) { 260 | return false; 261 | } 262 | 263 | $process = Process::fromShellCommandline("find . -name \"{$searchFor}\" -print", $path); 264 | 265 | $process->run(function ($type, $buffer) use ($handleSearchResult) { 266 | if ($type === Process::ERR) { 267 | return null; 268 | } 269 | 270 | $fileSearchResults = FileSearchResultFactory::create($buffer, $this); 271 | 272 | return $handleSearchResult($fileSearchResults); 273 | }); 274 | 275 | return $process->isSuccessful(); 276 | } 277 | 278 | public function findContent(string $searchFor, callable $handleSearchResult): bool 279 | { 280 | $path = $this->destinationLocation()->getFullPath(); 281 | 282 | $process = Process::fromShellCommandline("grep -onIir '{$searchFor} ' .", $path); 283 | 284 | $process->run(function ($type, $buffer) use ($handleSearchResult) { 285 | if ($type === Process::ERR) { 286 | return null; 287 | } 288 | 289 | $fileSearchResults = ContentSearchResultFactory::create($buffer, $this); 290 | 291 | return $handleSearchResult($fileSearchResults); 292 | }); 293 | 294 | return $process->isSuccessful(); 295 | } 296 | 297 | public function isPendingOrInProgress(): bool 298 | { 299 | return in_array($this->status, [ 300 | BackupStatus::Pending, 301 | BackupStatus::InProgress, 302 | ], true); 303 | } 304 | 305 | public function isCompleted(): bool 306 | { 307 | return $this->status === BackupStatus::Completed; 308 | } 309 | 310 | public function fileList(string $relativeDirectory = '/'): FileList 311 | { 312 | return new FileList($this, $relativeDirectory); 313 | } 314 | 315 | public function name(): string 316 | { 317 | return $this->source->name.'-'.optional($this->completed_at)->format('Y-m-d-His'); 318 | } 319 | } 320 | -------------------------------------------------------------------------------- /src/Models/BackupLogItem.php: -------------------------------------------------------------------------------- 1 | Task::class, 20 | ]; 21 | 22 | public function source(): BelongsTo 23 | { 24 | return $this->belongsTo(Source::class); 25 | } 26 | 27 | public function backup(): BelongsTo 28 | { 29 | return $this->belongsTo(Backup::class); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Models/Concerns/HasAsyncDelete.php: -------------------------------------------------------------------------------- 1 | update(['status' => $this->status()]); 17 | 18 | $deletionJobClassName = $this->getDeletionJobClassName(); 19 | 20 | dispatch(new $deletionJobClassName($this)); 21 | } 22 | 23 | public function willBeDeleted(): bool 24 | { 25 | return $this->status === $this->status(); 26 | } 27 | 28 | abstract public function getDeletionJobClassName(): string; 29 | 30 | protected function status(): DestinationStatus|BackupStatus|SourceStatus 31 | { 32 | return match (static::class) { 33 | Source::class => SourceStatus::Deleting, 34 | Destination::class => DestinationStatus::Deleting, 35 | Backup::class => BackupStatus::Deleting, 36 | default => throw new \InvalidArgumentException( 37 | 'Unknown class type for deletion status: '.static::class 38 | ), 39 | }; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Models/Concerns/HasBackupRelation.php: -------------------------------------------------------------------------------- 1 | hasMany(Backup::class)->orderByDesc('created_at'); 13 | } 14 | 15 | public function completedBackups(): HasMany 16 | { 17 | return $this->backups()->completed(); 18 | } 19 | 20 | public function youngestCompletedBackup(): ?Backup 21 | { 22 | return $this->completedBackups()->first(); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Models/Concerns/LogsActivity.php: -------------------------------------------------------------------------------- 1 | hasMany(BackupLogItem::class); 15 | } 16 | 17 | public function logInfo(Task $task, string $text): void 18 | { 19 | $this->addMessageToLog($task, LogLevel::Info, $text); 20 | } 21 | 22 | public function logError(Task $task, string $text): void 23 | { 24 | $this->addMessageToLog($task, LogLevel::Error, $text); 25 | } 26 | 27 | abstract protected function addMessageToLog(Task $task, LogLevel $level, string $message); 28 | } 29 | -------------------------------------------------------------------------------- /src/Models/Destination.php: -------------------------------------------------------------------------------- 1 | status = DestinationStatus::Active; 37 | }); 38 | } 39 | 40 | protected static function newFactory(): DestinationFactory 41 | { 42 | return DestinationFactory::new(); 43 | } 44 | 45 | public function getDeletionJobClassName(): string 46 | { 47 | return DeleteDestinationJob::class; 48 | } 49 | 50 | public function disk(): Filesystem 51 | { 52 | return Storage::disk($this->disk_name); 53 | } 54 | 55 | public function reachable(): bool 56 | { 57 | try { 58 | $this->disk(); 59 | 60 | return true; 61 | } catch (Exception) { 62 | return false; 63 | } 64 | } 65 | 66 | protected function addMessageToLog(Task $task, LogLevel $level, string $message): void 67 | { 68 | $this->logItems()->create([ 69 | 'task' => $task, 70 | 'level' => $level, 71 | 'message' => trim($message), 72 | ]); 73 | } 74 | 75 | public function isHealthy(): bool 76 | { 77 | return $this->getHealthChecks()->allPass(); 78 | } 79 | 80 | public function getHealthChecks(): HealthCheckCollection 81 | { 82 | $healthCheckClassNames = config('backup-server.monitor.destination_health_checks'); 83 | 84 | return new HealthCheckCollection($healthCheckClassNames, $this); 85 | } 86 | 87 | public function getInodeUsagePercentage(): int 88 | { 89 | $rawOutput = $this->getDfOutput(8, 'ipcent'); 90 | 91 | return (int) Str::before($rawOutput, '%'); 92 | } 93 | 94 | public function getFreeSpaceInKb(): int 95 | { 96 | $rawOutput = $this->getDfOutput(4, 'avail'); 97 | 98 | return (int) $rawOutput; 99 | } 100 | 101 | public function getUsedSpaceInPercentage(): int 102 | { 103 | $rawOutput = $this->getDfOutput(5, 'pcent'); 104 | 105 | return (int) Str::before($rawOutput, '%'); 106 | } 107 | 108 | protected function getDfOutput(int $macOsColumnNumber, string $linuxOutputFormat): string 109 | { 110 | $command = PHP_OS === 'Darwin' 111 | ? 'df -k "$PWD" | awk \'{print $'.$macOsColumnNumber.'}\'' 112 | : 'df -k --output='.$linuxOutputFormat.' "$PWD"'; 113 | 114 | $diskRootPath = $this->disk()->path(''); 115 | 116 | $process = Process::fromShellCommandline("cd {$diskRootPath}; {$command}"); 117 | $process->run(); 118 | 119 | if (! $process->isSuccessful()) { 120 | throw new Exception('Could not determine inode count'); 121 | } 122 | 123 | $lines = explode(PHP_EOL, $process->getOutput()); 124 | 125 | return $lines[1]; 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/Models/Source.php: -------------------------------------------------------------------------------- 1 | SourceStatus::class, 34 | 'healthy' => 'boolean', 35 | 'includes' => 'array', 36 | 'excludes' => 'array', 37 | 'pre_backup_commands' => 'array', 38 | 'post_backup_commands' => 'array', 39 | 'pause_notifications_until' => 'immutable_datetime', 40 | ]; 41 | 42 | public static function booted(): void 43 | { 44 | static::creating(function (Source $source) { 45 | $source->status = SourceStatus::Active; 46 | }); 47 | } 48 | 49 | public function hasNotificationsPaused(): bool 50 | { 51 | if ($this->pause_notifications_until === null) { 52 | return false; 53 | } 54 | 55 | return $this->pause_notifications_until->isFuture(); 56 | } 57 | 58 | protected static function newFactory(): SourceFactory 59 | { 60 | return SourceFactory::new(); 61 | } 62 | 63 | public function getDeletionJobClassName(): string 64 | { 65 | return DeleteSourceJob::class; 66 | } 67 | 68 | public function destination(): BelongsTo 69 | { 70 | return $this->belongsTo(Destination::class); 71 | } 72 | 73 | public function scopeNamed(Builder $builder, string $name): void 74 | { 75 | $builder->where('name', $name)->first(); 76 | } 77 | 78 | public function executeSshCommands(array $commands): Process 79 | { 80 | $ssh = new Ssh($this->ssh_user, $this->host); 81 | 82 | if ($this->ssh_port) { 83 | $ssh->usePort($this->ssh_port); 84 | } 85 | 86 | if ($this->ssh_private_key_file) { 87 | $ssh->usePrivateKey($this->ssh_private_key_file); 88 | } 89 | 90 | return $ssh->execute($commands); 91 | } 92 | 93 | public function isHealthy(): bool 94 | { 95 | return $this->getHealthChecks()->allPass(); 96 | } 97 | 98 | public function getHealthChecks(): HealthCheckCollection 99 | { 100 | $healthCheckClassNames = config('backup-server.monitor.source_health_checks'); 101 | 102 | return new HealthCheckCollection($healthCheckClassNames, $this); 103 | } 104 | 105 | public function scopeHealthy(Builder $query): void 106 | { 107 | $query->where('healthy', true); 108 | } 109 | 110 | public function scopeUnhealthy(Builder $query): void 111 | { 112 | $query->where('healthy', false); 113 | } 114 | 115 | protected function addMessageToLog(Task $task, LogLevel $level, string $message): Source 116 | { 117 | $this->logItems()->create([ 118 | 'destination_id' => $this->destination_id, 119 | 'task' => $task, 120 | 'level' => $level, 121 | 'message' => trim($message), 122 | ]); 123 | 124 | return $this; 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/Models/User.php: -------------------------------------------------------------------------------- 1 | 'datetime', 27 | ]; 28 | 29 | protected static function newFactory(): UserFactory 30 | { 31 | return UserFactory::new(); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Notifications/EventHandler.php: -------------------------------------------------------------------------------- 1 | listen($this->allBackupEventClasses(), function ($event) { 30 | if (! $this->shouldSendNotification($event)) { 31 | return; 32 | } 33 | 34 | $notifiable = $this->determineNotifiable(); 35 | 36 | $notification = $this->determineNotification($event); 37 | 38 | $notifiable->notify($notification); 39 | }); 40 | } 41 | 42 | protected function determineNotifiable() 43 | { 44 | $notifiableClass = $this->config->get('backup-server.notifications.notifiable'); 45 | 46 | return app($notifiableClass); 47 | } 48 | 49 | protected function determineNotification(object $event): Notification 50 | { 51 | $eventName = class_basename($event); 52 | 53 | $notificationClassName = Str::replaceLast('Event', 'Notification', $eventName); 54 | 55 | $notificationClass = collect($this->config->get('backup-server.notifications.notifications')) 56 | ->keys() 57 | ->first(fn ($notificationClass) => class_basename($notificationClass) === $notificationClassName); 58 | 59 | if (! $notificationClass) { 60 | throw NotificationCouldNotBeSent::noNotificationClassForEvent($event); 61 | } 62 | 63 | return new $notificationClass($event); 64 | } 65 | 66 | protected function shouldSendNotification(object $event): bool 67 | { 68 | return match (true) { 69 | $event instanceof BackupCompletedEvent => ! $event->backup->source->hasNotificationsPaused(), 70 | $event instanceof BackupFailedEvent => ! $event->backup->source->hasNotificationsPaused(), 71 | $event instanceof CleanupForSourceCompletedEvent => ! $event->source->hasNotificationsPaused(), 72 | $event instanceof CleanupForSourceFailedEvent => ! $event->source->hasNotificationsPaused(), 73 | $event instanceof HealthySourceFoundEvent => ! $event->source->hasNotificationsPaused(), 74 | $event instanceof UnhealthySourceFoundEvent => ! $event->source->hasNotificationsPaused(), 75 | default => true, 76 | }; 77 | } 78 | 79 | protected function allBackupEventClasses(): array 80 | { 81 | return [ 82 | BackupCompletedEvent::class, 83 | BackupFailedEvent::class, 84 | CleanupForSourceCompletedEvent::class, 85 | CleanupForSourceFailedEvent::class, 86 | CleanupForDestinationCompletedEvent::class, 87 | CleanupForDestinationFailedEvent::class, 88 | HealthySourceFoundEvent::class, 89 | UnhealthySourceFoundEvent::class, 90 | HealthyDestinationFoundEvent::class, 91 | UnhealthyDestinationFoundEvent::class, 92 | ]; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/Notifications/Notifiable.php: -------------------------------------------------------------------------------- 1 | success() 28 | ->from($this->fromEmail(), $this->fromName()) 29 | ->subject(trans('backup-server::notifications.backup_completed_subject', $this->translationParameters())) 30 | ->greeting(trans('backup-server::notifications.backup_completed_subject_title', $this->translationParameters())) 31 | ->line(trans('backup-server::notifications.backup_completed_body', $this->translationParameters())); 32 | } 33 | 34 | public function toSlack(): SlackMessage 35 | { 36 | return $this->slackMessage() 37 | ->success() 38 | ->from(config('backup-server.notifications.slack.username')) 39 | ->attachment(function (SlackAttachment $attachment) { 40 | $attachment 41 | ->title(trans('backup-server::notifications.backup_completed_subject_title', $this->translationParameters())) 42 | ->content(trans('backup-server::notifications.backup_completed_body', $this->translationParameters())) 43 | ->fallback(trans('backup-server::notifications.backup_completed_body', $this->translationParameters())) 44 | ->fields([ 45 | 'Source' => $this->event->backup->source->name, 46 | 'Destination' => $this->event->backup->destination->name, 47 | 'Duration' => $this->event->backup->rsync_time_in_seconds.' seconds', 48 | 'Average speed' => $this->event->backup->rsync_average_transfer_speed_in_MB_per_second, 49 | 'Size' => Format::KbToHumanReadableSize($this->event->backup->size_in_kb), 50 | ]) 51 | ->timestamp($this->event->backup->completed_at); 52 | }); 53 | } 54 | 55 | protected function translationParameters(): array 56 | { 57 | return [ 58 | 'source_name' => $this->event->backup->source->name, 59 | 'destination_name' => $this->event->backup->destination->name, 60 | ]; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Notifications/Notifications/BackupFailedNotification.php: -------------------------------------------------------------------------------- 1 | error() 28 | ->from($this->fromEmail(), $this->fromName()) 29 | ->subject(trans('backup-server::notifications.backup_failed_subject', $this->translationParameters())) 30 | ->greeting(trans('backup-server::notifications.backup_failed_subject_title', $this->translationParameters())) 31 | ->line(trans('backup-server::notifications.backup_failed_body', $this->translationParameters())) 32 | ->line(trans('backup-server::notifications.exception_title')) 33 | ->line(new ExceptionRenderer($this->event->exceptionMessage, $this->event->trace)); 34 | } 35 | 36 | public function toSlack(): SlackMessage 37 | { 38 | return $this->slackMessage() 39 | ->error() 40 | ->from(config('backup-server.notifications.slack.username')) 41 | ->attachment(function (SlackAttachment $attachment) { 42 | $attachment 43 | ->title(trans('backup-server::notifications.backup_failed_subject_title', $this->translationParameters())) 44 | ->content(trans('backup-server::notifications.backup_failed_body', $this->translationParameters())) 45 | ->fallback(trans('backup-server::notifications.backup_failed_body', $this->translationParameters())) 46 | ->fields([ 47 | 'Source' => $this->event->backup->source->name, 48 | 'Destination' => $this->event->backup->destination->name, 49 | ]); 50 | }) 51 | ->attachment(function (SlackAttachment $attachment) { 52 | $attachment 53 | ->title(trans('backup-server::notifications.exception_message_title')) 54 | ->content("```{$this->event->getExceptionMessage()}```"); 55 | }) 56 | ->attachment(function (SlackAttachment $attachment) { 57 | $attachment 58 | ->title(trans('backup-server::notifications.exception_trace_title')) 59 | ->content("```{$this->event->getTrace()}```"); 60 | }); 61 | } 62 | 63 | protected function translationParameters(): array 64 | { 65 | return [ 66 | 'source_name' => $this->event->backup->source->name, 67 | 'destination_name' => $this->event->backup->destination->name, 68 | 'message' => $this->event->getExceptionMessage(), 69 | 'trace' => $this->event->getTrace(), 70 | ]; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/Notifications/Notifications/CleanupForDestinationCompletedNotification.php: -------------------------------------------------------------------------------- 1 | success() 26 | ->from($this->fromEmail(), $this->fromName()) 27 | ->subject(trans('backup-server::notifications.cleanup_destination_successful_subject', $this->translationParameters())) 28 | ->greeting(trans('backup-server::notifications.cleanup_destination_successful_subject_title', $this->translationParameters())) 29 | ->line(trans('backup-server::notifications.cleanup_destination_successful_body', $this->translationParameters())); 30 | } 31 | 32 | public function toSlack(): SlackMessage 33 | { 34 | return $this->slackMessage() 35 | ->success() 36 | ->from(config('backup-server.notifications.slack.username')) 37 | ->attachment(function (SlackAttachment $attachment) { 38 | $attachment 39 | ->title(trans('backup-server::notifications.cleanup_destination_successful_subject_title', $this->translationParameters())) 40 | ->content(trans('backup-server::notifications.cleanup_destination_successful_body', $this->translationParameters())) 41 | ->fallback(trans('backup-server::notifications.cleanup_destination_successful_body', $this->translationParameters())) 42 | ->fields([ 43 | 'Destination' => $this->event->destination->name, 44 | 'Space used' => Format::KbToHumanReadableSize($this->event->destination->getFreeSpaceInKb()), 45 | 'Space used (%)' => $this->event->destination->getUsedSpaceInPercentage().'%', 46 | 'Inodes used (%)' => $this->event->destination->getInodeUsagePercentage().'%', 47 | ]); 48 | }); 49 | } 50 | 51 | public function translationParameters(): array 52 | { 53 | return [ 54 | 'destination_name' => $this->event->destination->name, 55 | ]; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Notifications/Notifications/CleanupForDestinationFailedNotification.php: -------------------------------------------------------------------------------- 1 | error() 28 | ->from($this->fromEmail(), $this->fromName()) 29 | ->subject(trans('backup-server::notifications.cleanup_destination_failed_subject', $this->translationParameters())) 30 | ->greeting(trans('backup-server::notifications.cleanup_destination_failed_subject_title', $this->translationParameters())) 31 | ->line(trans('backup-server::notifications.cleanup_destination_failed_body', $this->translationParameters())) 32 | ->line(trans('backup-server::notifications.exception_title')) 33 | ->line(new ExceptionRenderer($this->event->exceptionMessage)); 34 | } 35 | 36 | public function toSlack(): SlackMessage 37 | { 38 | return $this->slackMessage() 39 | ->error() 40 | ->from(config('backup-server.notifications.slack.username')) 41 | ->attachment(function (SlackAttachment $attachment) { 42 | $attachment 43 | ->title(trans('backup-server::notifications.cleanup_destination_failed_subject_title', $this->translationParameters())) 44 | ->content(trans('backup-server::notifications.cleanup_destination_failed_body', $this->translationParameters())) 45 | ->fallback(trans('backup-server::notifications.cleanup_destination_failed_body', $this->translationParameters())) 46 | ->fields([ 47 | 'Destination' => $this->event->destination->name, 48 | ]); 49 | }) 50 | ->attachment(function (SlackAttachment $attachment) { 51 | $attachment 52 | ->title(trans('backup-server::notifications.exception_message_title')) 53 | ->content("```{$this->event->exceptionMessage}```"); 54 | }); 55 | } 56 | 57 | public function translationParameters(): array 58 | { 59 | return [ 60 | 'destination_name' => $this->event->destination->name, 61 | ]; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Notifications/Notifications/CleanupForSourceCompletedNotification.php: -------------------------------------------------------------------------------- 1 | success() 27 | ->from($this->fromEmail(), $this->fromName()) 28 | ->subject(trans('backup-server::notifications.cleanup_source_successful_subject', $this->translationParameters())) 29 | ->greeting(trans('backup-server::notifications.cleanup_source_successful_subject_title', $this->translationParameters())) 30 | ->line(trans('backup-server::notifications.cleanup_source_successful_body', $this->translationParameters())); 31 | } 32 | 33 | public function toSlack(): SlackMessage 34 | { 35 | return $this->slackMessage() 36 | ->success() 37 | ->from(config('backup-server.notifications.slack.username')) 38 | ->attachment(function (SlackAttachment $attachment) { 39 | $attachment 40 | ->title(trans('backup-server::notifications.cleanup_source_successful_subject_title', $this->translationParameters())) 41 | ->content(trans('backup-server::notifications.cleanup_source_successful_body', $this->translationParameters())) 42 | ->fallback(trans('backup-server::notifications.cleanup_source_successful_body', $this->translationParameters())) 43 | ->fields([ 44 | 'Source' => $this->event->source->name, 45 | ]); 46 | }); 47 | } 48 | 49 | public function translationParameters(): array 50 | { 51 | return [ 52 | 'source_name' => $this->event->source->name, 53 | ]; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Notifications/Notifications/CleanupForSourceFailedNotification.php: -------------------------------------------------------------------------------- 1 | error() 28 | ->from($this->fromEmail(), $this->fromName()) 29 | ->subject(trans('backup-server::notifications.cleanup_source_failed_subject', $this->translationParameters())) 30 | ->greeting(trans('backup-server::notifications.cleanup_source_failed_subject_title', $this->translationParameters())) 31 | ->line(trans('backup-server::notifications.cleanup_source_failed_body', $this->translationParameters())) 32 | ->line(trans('backup-server::notifications.exception_title')) 33 | ->line(new ExceptionRenderer($this->event->exceptionMessage)); 34 | } 35 | 36 | public function toSlack(): SlackMessage 37 | { 38 | return $this->slackMessage() 39 | ->error() 40 | ->from(config('backup-server.notifications.slack.username')) 41 | ->attachment(function (SlackAttachment $attachment) { 42 | $attachment 43 | ->title(trans('backup-server::notifications.cleanup_source_failed_subject_title', $this->translationParameters())) 44 | ->content(trans('backup-server::notifications.cleanup_source_failed_body', $this->translationParameters())) 45 | ->fallback(trans('backup-server::notifications.cleanup_source_failed_body', $this->translationParameters())) 46 | ->fields([ 47 | 'Destination' => $this->event->source->name, 48 | ]); 49 | }) 50 | ->attachment(function (SlackAttachment $attachment) { 51 | $attachment 52 | ->title(trans('backup-server::notifications.exception_message_title')) 53 | ->content("```{$this->event->exceptionMessage}\n```"); 54 | }); 55 | } 56 | 57 | protected function translationParameters(): array 58 | { 59 | return [ 60 | 'source_name' => $this->event->source->name, 61 | ]; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Notifications/Notifications/Concerns/HandlesNotifications.php: -------------------------------------------------------------------------------- 1 | from(config('backup-server.notifications.slack.username'), config('backup-server.notifications.slack.icon')) 30 | ->to(config('backup-server.notifications.slack.channel')); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Notifications/Notifications/HealthyDestinationFoundNotification.php: -------------------------------------------------------------------------------- 1 | from($this->fromEmail(), $this->fromName()) 28 | ->subject(trans('backup-server::notifications.healthy_destination_found_subject', $this->translationParameters())) 29 | ->greeting(trans('backup-server::notifications.healthy_destination_found_subject_title', $this->translationParameters())) 30 | ->line(trans('backup-server::notifications.healthy_destination_found_body', $this->translationParameters())); 31 | } 32 | 33 | public function toSlack(): SlackMessage 34 | { 35 | return $this->slackMessage() 36 | ->success() 37 | ->from(config('backup-server.notifications.slack.username')) 38 | ->attachment(function (SlackAttachment $attachment) { 39 | $attachment 40 | ->title(trans('backup-server::notifications.healthy_destination_found_subject_title', $this->translationParameters())) 41 | ->content(trans('backup-server::notifications.healthy_destination_found_body', $this->translationParameters())) 42 | ->fallback(trans('backup-server::notifications.healthy_destination_found_body', $this->translationParameters())) 43 | ->fields([ 44 | 'Destination' => $this->event->destination->name, 45 | 'Space used' => Format::KbToHumanReadableSize($this->event->destination->getFreeSpaceInKb()), 46 | 'Space used (%)' => $this->event->destination->getUsedSpaceInPercentage().'%', 47 | 'Inodes used (%)' => $this->event->destination->getInodeUsagePercentage().'%', 48 | ]); 49 | }); 50 | } 51 | 52 | protected function translationParameters(): array 53 | { 54 | return [ 55 | 'destination_name' => $this->event->destination->name, 56 | ]; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Notifications/Notifications/HealthySourceFoundNotification.php: -------------------------------------------------------------------------------- 1 | from($this->fromEmail(), $this->fromName()) 27 | ->subject(trans('backup-server::notifications.healthy_source_found_subject', $this->translationParameters())) 28 | ->greeting(trans('backup-server::notifications.healthy_source_found_subject_title', $this->translationParameters())) 29 | ->line(trans('backup-server::notifications.healthy_source_found_body', $this->translationParameters())); 30 | } 31 | 32 | public function toSlack(): SlackMessage 33 | { 34 | return $this->slackMessage() 35 | ->success() 36 | ->from(config('backup-server.notifications.slack.username')) 37 | ->attachment(function (SlackAttachment $attachment) { 38 | $attachment 39 | ->title(trans('backup-server::notifications.healthy_source_found_subject_title', $this->translationParameters())) 40 | ->content(trans('backup-server::notifications.healthy_source_found_body', $this->translationParameters())) 41 | ->fallback(trans('backup-server::notifications.healthy_source_found_body', $this->translationParameters())) 42 | ->fields([ 43 | 'Source' => $this->event->source->name, 44 | ]); 45 | }); 46 | } 47 | 48 | public function translationParameters(): array 49 | { 50 | return [ 51 | 'source_name' => $this->event->source->name, 52 | ]; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Notifications/Notifications/ServerSummaryNotification.php: -------------------------------------------------------------------------------- 1 | serverSummary->destinationFreeSpaceInKb + $this->serverSummary->destinationUsedSpaceInKb; 26 | $totalSpace = Format::KbToHumanReadableSize($totalSpaceInKb); 27 | $usedSpace = Format::KbToHumanReadableSize($this->serverSummary->destinationUsedSpaceInKb); 28 | 29 | return (new MailMessage) 30 | ->from($this->fromEmail(), $this->fromName()) 31 | ->subject(trans('backup-server::notifications.server_summary_subject', $this->translationParameters())) 32 | ->greeting(trans('backup-server::notifications.server_summary_subject_title', $this->translationParameters())) 33 | ->line(trans('backup-server::notifications.server_summary_body', $this->translationParameters())) 34 | ->line("- successful backups: {$this->serverSummary->successfulBackups}") 35 | ->line("- {$this->serverSummary->failedBackups} failed backups") 36 | ->line("- {$this->serverSummary->healthyDestinations} healthy destinations") 37 | ->line("- {$this->serverSummary->unhealthyDestinations} unhealthy destinations") 38 | ->line("- {$this->serverSummary->healthySources} healthy sources") 39 | ->line("- {$this->serverSummary->unhealthySources} unhealthy sources") 40 | ->line("- {$usedSpace}/{$totalSpace} used on all destinations") 41 | ->line("- {$this->serverSummary->timeSpentRunningBackupsInSeconds} seconds spent running backups") 42 | ->line("- {$this->serverSummary->errorsInLog} new errors in backup log"); 43 | } 44 | 45 | public function toSlack(): SlackMessage 46 | { 47 | $totalSpaceInKb = $this->serverSummary->destinationFreeSpaceInKb + $this->serverSummary->destinationUsedSpaceInKb; 48 | $totalSpace = Format::KbToHumanReadableSize($totalSpaceInKb); 49 | $usedSpace = Format::KbToHumanReadableSize($this->serverSummary->destinationUsedSpaceInKb); 50 | $timeSpent = gmdate('H:i:s', $this->serverSummary->timeSpentRunningBackupsInSeconds); 51 | 52 | return $this->slackMessage() 53 | ->content(trans('backup-server::notifications.server_summary_subject_title', $this->translationParameters())) 54 | ->from(config('backup-server.notifications.slack.username')) 55 | ->block(function (SlackBlock $block) { 56 | $block 57 | ->type('section') 58 | ->text([ 59 | 'type' => 'mrkdwn', 60 | 'text' => trans('backup-server::notifications.server_summary_subject_title', $this->translationParameters()), 61 | ]); 62 | }) 63 | ->block(fn (SlackBlock $block) => $block->type('divider')) 64 | ->block(function (SlackBlock $block) { 65 | $block 66 | ->type('section') 67 | ->text(['type' => 'mrkdwn', 'text' => ':package: *Backups*']); 68 | }) 69 | ->block(function (SlackBlock $block) { 70 | $block 71 | ->type('context') 72 | ->elements([ 73 | [ 74 | 'type' => 'mrkdwn', 75 | 'text' => "Completed: *{$this->serverSummary->successfulBackups}* \nFailed: *{$this->serverSummary->failedBackups}*", 76 | ], 77 | ]); 78 | }) 79 | ->block(fn (SlackBlock $block) => $block->type('divider')) 80 | ->block(function (SlackBlock $block) { 81 | $block 82 | ->type('section') 83 | ->text(['type' => 'mrkdwn', 'text' => ':staff_of_aesculapius: *Health*']); 84 | }) 85 | ->block(function (SlackBlock $block) { 86 | $block 87 | ->type('context') 88 | ->elements([ 89 | [ 90 | 'type' => 'mrkdwn', 91 | 'text' => "Healthy sources: *{$this->serverSummary->healthySources}* \nUnhealthy sources: *{$this->serverSummary->unhealthySources}* \nHealthy destinations: *{$this->serverSummary->healthyDestinations}* \nUnhealthy destinations: *{$this->serverSummary->unhealthyDestinations}* \nNew error log entries: *{$this->serverSummary->errorsInLog}*", 92 | ], 93 | ]); 94 | }) 95 | ->block(fn (SlackBlock $block) => $block->type('divider')) 96 | ->block(function (SlackBlock $block) { 97 | $block 98 | ->type('section') 99 | ->text(['type' => 'mrkdwn', 'text' => ':bar_chart: *Usage*']); 100 | }) 101 | ->block(function (SlackBlock $block) use ($timeSpent, $totalSpace, $usedSpace) { 102 | $block 103 | ->type('context') 104 | ->elements([ 105 | [ 106 | 'type' => 'mrkdwn', 107 | 'text' => "Disk space on all destinations combined: *{$usedSpace}/{$totalSpace}* \nTotal time spent: *{$timeSpent}*", 108 | ], 109 | ]); 110 | }) 111 | ->block(function (SlackBlock $block) { 112 | $block 113 | ->type('actions') 114 | ->elements([ 115 | [ 116 | 'type' => 'button', 117 | 'text' => ['type' => 'plain_text', 'text' => 'Backup Dashboard'], 118 | 'url' => 'https://backups.spatie.be/', 119 | ], 120 | ]); 121 | }); 122 | } 123 | 124 | protected function translationParameters(): array 125 | { 126 | return [ 127 | 'period' => "{$this->serverSummary->from->format('d-m-Y')} to {$this->serverSummary->to->format('d-m-Y')}", 128 | ]; 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/Notifications/Notifications/UnhealthyDestinationFoundNotification.php: -------------------------------------------------------------------------------- 1 | error() 27 | ->from($this->fromEmail(), $this->fromName()) 28 | ->subject(trans('backup-server::notifications.unhealthy_destination_found_subject', $this->translationParameters())) 29 | ->greeting(trans('backup-server::notifications.unhealthy_destination_found_subject_title', $this->translationParameters())) 30 | ->line(trans('backup-server::notifications.unhealthy_destination_found_body', $this->translationParameters())) 31 | ->line([ 32 | "Found problems:\n* ".collect($this->event->failureMessages)->join("\n* "), 33 | ]); 34 | } 35 | 36 | public function toSlack(): SlackMessage 37 | { 38 | $message = $this->slackMessage() 39 | ->error() 40 | ->from(config('backup-server.notifications.slack.username')) 41 | ->attachment(function (SlackAttachment $attachment) { 42 | $attachment 43 | ->title(trans('backup-server::notifications.unhealthy_destination_found_subject_title', $this->translationParameters())) 44 | ->content(trans('backup-server::notifications.unhealthy_destination_found_body', $this->translationParameters())) 45 | ->fallback(trans('backup-server::notifications.unhealthy_destination_found_body', $this->translationParameters())) 46 | ->fields([ 47 | 'Destination' => $this->event->destination->name, 48 | ]); 49 | }); 50 | 51 | foreach ($this->event->failureMessages as $failureMessage) { 52 | $message->attachment(function (SlackAttachment $attachment) use ($failureMessage) { 53 | $attachment->content($failureMessage); 54 | }); 55 | } 56 | 57 | return $message; 58 | } 59 | 60 | protected function translationParameters(): array 61 | { 62 | return [ 63 | 'destination_name' => $this->event->destination->name, 64 | ]; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Notifications/Notifications/UnhealthySourceFoundNotification.php: -------------------------------------------------------------------------------- 1 | error() 27 | ->from($this->fromEmail(), $this->fromName()) 28 | ->subject(trans('backup-server::notifications.unhealthy_source_found_subject', $this->translationParameters())) 29 | ->greeting(trans('backup-server::notifications.unhealthy_source_found_subject_title', $this->translationParameters())) 30 | ->line(trans('backup-server::notifications.unhealthy_source_found_body', $this->translationParameters())) 31 | ->line([ 32 | "Found problems:\n* ".collect($this->event->failureMessages)->join("\n* "), 33 | ]); 34 | } 35 | 36 | public function toSlack(): SlackMessage 37 | { 38 | $message = $this->slackMessage() 39 | ->error() 40 | ->from(config('backup-server.notifications.slack.username')) 41 | ->attachment(function (SlackAttachment $attachment) { 42 | $attachment 43 | ->title(trans('backup-server::notifications.unhealthy_source_found_subject_title', $this->translationParameters())) 44 | ->content(trans('backup-server::notifications.unhealthy_source_found_body', $this->translationParameters())) 45 | ->fallback(trans('backup-server::notifications.unhealthy_source_found_body', $this->translationParameters())) 46 | ->fields([ 47 | 'Source' => $this->event->source->name, 48 | ]); 49 | }); 50 | 51 | foreach ($this->event->failureMessages as $failureMessage) { 52 | $message->attachment(function (SlackAttachment $attachment) use ($failureMessage) { 53 | $attachment->content($failureMessage); 54 | }); 55 | } 56 | 57 | return $message; 58 | } 59 | 60 | protected function translationParameters(): array 61 | { 62 | return [ 63 | 'source_name' => $this->event->source->name, 64 | ]; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Support/AlignCenterTableStyle.php: -------------------------------------------------------------------------------- 1 | setPadType(STR_PAD_BOTH); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Support/AlignRightTableStyle.php: -------------------------------------------------------------------------------- 1 | setPadType(STR_PAD_LEFT); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Support/ExceptionRenderer.php: -------------------------------------------------------------------------------- 1 | $this->exceptionMessage 15 |
$this->trace
16 | HTML; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Support/Helpers/Config.php: -------------------------------------------------------------------------------- 1 | path; 15 | } 16 | 17 | public function getDirectory(): string 18 | { 19 | return pathinfo($this->getPath(), PATHINFO_BASENAME); 20 | } 21 | 22 | public function getFullPath(): string 23 | { 24 | $pathPrefix = config("filesystems.disks.{$this->diskName}.root"); 25 | 26 | return $pathPrefix.'/'.$this->path; 27 | } 28 | 29 | public function __toString(): string 30 | { 31 | return $this->getFullPath(); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Support/Helpers/Enums/LogLevel.php: -------------------------------------------------------------------------------- 1 | 1024; $i++) { 28 | $sizeInBytes /= 1024; 29 | } 30 | 31 | return round($sizeInBytes, 2).' '.$units[$i]; 32 | } 33 | 34 | public static function ageInDays(Carbon $date): string 35 | { 36 | return number_format(round($date->diffInMinutes() / (24 * 60), 2), 2).' ('.$date->diffForHumans().')'; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Support/Helpers/SourceLocation.php: -------------------------------------------------------------------------------- 1 | paths; 17 | } 18 | 19 | public function getPort(): int 20 | { 21 | return $this->port; 22 | } 23 | 24 | public function connectionString(): string 25 | { 26 | return "{$this->sshUser}@{$this->host}"; 27 | } 28 | 29 | public function __toString(): string 30 | { 31 | $remotePart = "{$this->connectionString()}:"; 32 | 33 | return collect($this->paths) 34 | ->map(fn (string $path) => rtrim($path, '/')) 35 | ->map(fn (string $path) => $remotePart.$path) 36 | ->implode(' '); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Tasks/Backup/Actions/CreateBackupAction.php: -------------------------------------------------------------------------------- 1 | afterBackupModelCreated = $afterBackupModelCreated; 21 | 22 | return $this; 23 | } 24 | 25 | public function doNotUseQueue(): self 26 | { 27 | $this->dispatchOnQueue = false; 28 | 29 | return $this; 30 | } 31 | 32 | public function execute(Source $source): Backup 33 | { 34 | $backup = Backup::create([ 35 | 'status' => BackupStatus::Pending, 36 | 'source_id' => $source->id, 37 | 'destination_id' => $source->destination->id, 38 | 'disk_name' => $source->destination->disk_name, 39 | ]); 40 | 41 | if ($this->afterBackupModelCreated) { 42 | ($this->afterBackupModelCreated)($backup); 43 | } 44 | 45 | $backup->logInfo(Task::Backup, 'Dispatching backup job...'); 46 | 47 | $job = (new PerformBackupJob($backup)); 48 | 49 | $this->dispatchOnQueue 50 | ? dispatch($job) 51 | : dispatch_sync($job); 52 | 53 | return $backup; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Tasks/Backup/Events/BackupCompletedEvent.php: -------------------------------------------------------------------------------- 1 | exceptionMessage = $throwable->getMessage(); 22 | $this->trace = $throwable->getTraceAsString(); 23 | } 24 | 25 | public function getExceptionMessage(): string 26 | { 27 | return $this->exceptionMessage; 28 | } 29 | 30 | public function getTrace(): string 31 | { 32 | return $this->trace; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Tasks/Backup/Jobs/BackupTasks/BackupTask.php: -------------------------------------------------------------------------------- 1 | logInfo(Task::Backup, 'Calculating backup size...'); 13 | 14 | $backup->recalculateBackupSize(); 15 | 16 | $backup->source->backups->recalculateRealSizeInKb(); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Tasks/Backup/Jobs/BackupTasks/Concerns/ExecutesBackupCommands.php: -------------------------------------------------------------------------------- 1 | source->$commandAttributeName ?? []; 14 | 15 | if (! count($commands)) { 16 | return; 17 | } 18 | 19 | $label = str_replace('_', ' ', $commandAttributeName); 20 | 21 | $backup->logInfo(Task::Backup, "Performing {$label}..."); 22 | 23 | /** @var \Symfony\Component\Process\Process $process */ 24 | $process = $backup->source->executeSshCommands($commands); 25 | 26 | if (! $process->isSuccessful()) { 27 | $backup->logError(Task::Backup, $label.' error output:'.PHP_EOL.$process->getErrorOutput()); 28 | 29 | throw BackupFailed::BackupCommandsFailed($backup, $commandAttributeName, $process->getErrorOutput()); 30 | } 31 | 32 | $backup->logInfo(Task::Backup, $label.' output:'.PHP_EOL.$process->getOutput()); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Tasks/Backup/Jobs/BackupTasks/DetermineDestinationPath.php: -------------------------------------------------------------------------------- 1 | logInfo(Task::Backup, 'Determining destination directory...'); 14 | 15 | $directory = $backup->destination->directory.$backup->source->id.'/backup-'.$backup->created_at->format('Y-m-d-His').'/'; 16 | 17 | $pathPrefix = $backup->pathPrefix(); 18 | 19 | $fullDirectory = $pathPrefix.'/'.$directory; 20 | 21 | if (! file_exists($fullDirectory)) { 22 | File::makeDirectory($fullDirectory, 0755, true); 23 | } 24 | 25 | $backup->update(['path' => $directory]); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Tasks/Backup/Jobs/BackupTasks/EnsureDestinationIsReachable.php: -------------------------------------------------------------------------------- 1 | logInfo(Task::Backup, 'Ensuring destination is reachable...'); 14 | 15 | if (! $backup->destination->reachable()) { 16 | throw BackupFailed::destinationNotReachable($backup); 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Tasks/Backup/Jobs/BackupTasks/EnsureSourceIsReachable.php: -------------------------------------------------------------------------------- 1 | logInfo(Task::Backup, 'Ensuring source is reachable...'); 14 | 15 | $process = $backup->source->executeSshCommands(['whoami']); 16 | 17 | if (! $process->isSuccessful()) { 18 | throw BackupFailed::sourceNotReachable($backup, $process->getErrorOutput()); 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Tasks/Backup/Jobs/BackupTasks/PerformPostBackupCommands.php: -------------------------------------------------------------------------------- 1 | executeBackupCommands($backup, 'post_backup_commands'); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Tasks/Backup/Jobs/BackupTasks/PerformPreBackupCommands.php: -------------------------------------------------------------------------------- 1 | executeBackupCommands($backup, 'pre_backup_commands'); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Tasks/Backup/Jobs/BackupTasks/RunBackup.php: -------------------------------------------------------------------------------- 1 | logInfo(Task::Backup, 'Running backup...'); 17 | 18 | $pendingBackup = (new PendingBackup) 19 | ->from($backup->sourceLocation()) 20 | ->exclude($backup->source->excludes ?? []) 21 | ->to($backup->destinationLocation()) 22 | ->usePrivateKeyFile($backup->source->ssh_private_key_file ?? '') 23 | ->reportProgress(function (string $type, string $progress) use ($backup) { 24 | $backup->handleProgress($type, $progress); 25 | }); 26 | 27 | /** @var \Spatie\BackupServer\Models\Backup $previousCompletedBackup */ 28 | if ($previousCompletedBackup = $backup->source->backups()->completed()->latest()->first()) { 29 | $pendingBackup->incrementalFrom($previousCompletedBackup->destinationLocation()->getFullPath()); 30 | } 31 | 32 | $this->runBackup($pendingBackup, $backup); 33 | } 34 | 35 | protected function runBackup(PendingBackup $pendingBackup, Backup $backup) 36 | { 37 | $command = $this->getBackupCommand($pendingBackup); 38 | 39 | $progressCallable = $pendingBackup->progressCallable; 40 | 41 | $process = Process::fromShellCommandline($command)->setTimeout(null); 42 | $rsyncStart = now(); 43 | $process->run(fn (string $type, string $buffer) => $progressCallable($type, $buffer)); 44 | $rsyncEnd = now(); 45 | 46 | $backup->update(['rsync_time_in_seconds' => (int) $rsyncStart->diffInSeconds($rsyncEnd)]); 47 | 48 | $didCompleteSuccessFully = $process->getExitCode() === 0; 49 | 50 | $this->saveRsyncSummary($backup, $process->getOutput()); 51 | 52 | if (! $didCompleteSuccessFully) { 53 | throw BackupFailed::rsyncDidFail($backup, $process->getErrorOutput()); 54 | } 55 | } 56 | 57 | protected function getBackupCommand(PendingBackup $pendingBackup): string 58 | { 59 | $source = $pendingBackup->source; 60 | 61 | $port = $pendingBackup->source->getPort(); 62 | 63 | $destination = $pendingBackup->destination; 64 | 65 | $linkFromDestination = ''; 66 | if ($pendingBackup->incrementalFromDirectory) { 67 | $linkFromDestination = "--link-dest {$pendingBackup->incrementalFromDirectory}"; 68 | } 69 | 70 | $privateKeyFile = ''; 71 | 72 | if ($pendingBackup->privateKeyFile !== '') { 73 | $privateKeyFile = "-i {$pendingBackup->privateKeyFile}"; 74 | } 75 | 76 | $excludes = collect($pendingBackup->excludedPaths) 77 | ->map(fn (string $excludedPath) => "--exclude={$excludedPath}") 78 | ->implode(' '); 79 | 80 | // --info=progress2 81 | return "rsync -progress -zaHLK --stats {$excludes} {$linkFromDestination} -e \"ssh {$privateKeyFile} -p {$port}\" {$source} {$destination}"; 82 | } 83 | 84 | protected function saveRsyncSummary(Backup $backup, string $output) 85 | { 86 | $startingPosition = strpos($output, 'Number of files'); 87 | 88 | $summary = substr($output, $startingPosition); 89 | 90 | $backup->logInfo(Task::Backup, trim($summary)); 91 | 92 | $backup->update([ 93 | 'rsync_summary' => trim($summary), 94 | 'rsync_average_transfer_speed_in_MB_per_second' => (new RsyncSummaryOutput($output))->averageSpeedInMB(), 95 | ]); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/Tasks/Backup/Jobs/PerformBackupJob.php: -------------------------------------------------------------------------------- 1 | timeout = config('backup-server.jobs.perform_backup_job.timeout'); 38 | 39 | $this->queue = config('backup-server.jobs.perform_backup_job.queue'); 40 | 41 | $this->connection ??= Config::getQueueConnection(); 42 | } 43 | 44 | public function handle(): void 45 | { 46 | $this->backup->markAsInProgress(); 47 | 48 | try { 49 | $tasks = [ 50 | EnsureSourceIsReachable::class, 51 | EnsureDestinationIsReachable::class, 52 | DetermineDestinationPath::class, 53 | PerformPreBackupCommands::class, 54 | RunBackup::class, 55 | PerformPostBackupCommands::class, 56 | CalculateBackupSize::class, 57 | ]; 58 | 59 | collect($tasks) 60 | ->map(fn (string $backupTaskClass) => app($backupTaskClass)) 61 | ->each->execute($this->backup); 62 | 63 | $this->backup->markAsCompleted(); 64 | 65 | event(new BackupCompletedEvent($this->backup)); 66 | } catch (Throwable $exception) { 67 | $this->backup->markAsFailed($exception->getMessage()); 68 | 69 | event(new BackupFailedEvent($this->backup, $exception)); 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/Tasks/Backup/Support/BackupCollection.php: -------------------------------------------------------------------------------- 1 | sum(function (Backup $backup) { 15 | return $backup->real_size_in_kb; 16 | }); 17 | } 18 | 19 | public function sizeInKb(): int 20 | { 21 | return $this->sum(function (Backup $backup) { 22 | return $backup->size_in_kb; 23 | }); 24 | } 25 | 26 | public function youngest(): ?Backup 27 | { 28 | return $this->first(); 29 | } 30 | 31 | public function oldest(): ?Backup 32 | { 33 | return $this->reverse()->last(); 34 | } 35 | 36 | public function recalculateRealSizeInKb(): self 37 | { 38 | $backupsToRecalculate = $this->whereNotNull('path'); 39 | 40 | if ($backupsToRecalculate->count() === 0) { 41 | return $this; 42 | } 43 | 44 | $firstBackup = $backupsToRecalculate->first(); 45 | 46 | $command = 'du -kd 1 ..'; 47 | 48 | $timeout = config('backup_collection_size_calculation_timeout_in_seconds'); 49 | 50 | $process = Process::fromShellCommandline($command, $firstBackup->destinationLocation()->getFullPath()) 51 | ->setTimeout($timeout); 52 | $process->run(); 53 | 54 | $output = $process->getOutput(); 55 | 56 | $backupsToRecalculate->each(function (Backup $backup) use ($output) { 57 | $directoryLine = collect(explode(PHP_EOL, $output))->first(function (string $line) use ($backup) { 58 | return Str::contains($line, $backup->destinationLocation()->getDirectory()); 59 | }); 60 | 61 | if (! $directoryLine) { 62 | $backup->update(['real_size_in_kb' => 0]); 63 | 64 | return; 65 | } 66 | 67 | $sizeInKb = Str::before($directoryLine, "\t"); 68 | 69 | $backup->update(['real_size_in_kb' => (int) trim($sizeInKb)]); 70 | }); 71 | 72 | return $this; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Tasks/Backup/Support/BackupScheduler/BackupScheduler.php: -------------------------------------------------------------------------------- 1 | cron_expression))->isDue(Carbon::now()); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/Tasks/Backup/Support/FileList/FileList.php: -------------------------------------------------------------------------------- 1 | backup->destinationLocation()->getFullPath(); 18 | 19 | $fileListingPath = $backupBasePath.$this->relativePath; 20 | 21 | $entries = []; 22 | 23 | $finder = (new Finder) 24 | ->directories() 25 | ->in($fileListingPath) 26 | ->depth(0) 27 | ->sortByName() 28 | ->getIterator(); 29 | foreach ($finder as $file) { 30 | $entries[] = new FileListEntry($file, $backupBasePath); 31 | } 32 | 33 | $finder = (new Finder) 34 | ->files() 35 | ->in($fileListingPath) 36 | ->depth(0) 37 | ->sortByName() 38 | ->getIterator(); 39 | foreach ($finder as $file) { 40 | $entries[] = new FileListEntry($file, $backupBasePath); 41 | } 42 | 43 | return $entries; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Tasks/Backup/Support/FileList/FileListEntry.php: -------------------------------------------------------------------------------- 1 | file->getFilename(); 18 | } 19 | 20 | public function relativePath(): string 21 | { 22 | $relativePath = Str::after($this->file->getPathname(), $this->relativeBashPath); 23 | 24 | return Str::start($relativePath, '/'); 25 | } 26 | 27 | public function isDirectory(): bool 28 | { 29 | return $this->file->isDir(); 30 | } 31 | 32 | public function size(): int 33 | { 34 | return $this->file->getSize(); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Tasks/Backup/Support/PendingBackup.php: -------------------------------------------------------------------------------- 1 | source = $location; 26 | 27 | return $this; 28 | } 29 | 30 | public function to(DestinationLocation $location): self 31 | { 32 | $this->destination = $location; 33 | 34 | return $this; 35 | } 36 | 37 | public function exclude(array $excludedPaths): self 38 | { 39 | $this->excludedPaths = $excludedPaths; 40 | 41 | return $this; 42 | } 43 | 44 | public function incrementalFrom(string $directory): self 45 | { 46 | $this->incrementalFromDirectory = $directory; 47 | 48 | return $this; 49 | } 50 | 51 | public function usePrivateKeyFile(string $privateKeyFile): self 52 | { 53 | $this->privateKeyFile = $privateKeyFile; 54 | 55 | return $this; 56 | } 57 | 58 | public function reportProgress(callable $progressCallable): static 59 | { 60 | $this->progressCallable = $progressCallable; 61 | 62 | return $this; 63 | } 64 | 65 | public function start(): void 66 | { 67 | app(CreateNewBackupAction::class)->execute($this); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Tasks/Backup/Support/Rsync/RsyncProgressOutput.php: -------------------------------------------------------------------------------- 1 | output, 'xfr#'); 17 | } 18 | 19 | public function isSummary(): bool 20 | { 21 | return Str::contains($this->output, 'Number of files'); 22 | } 23 | 24 | public function getTransferSpeed(): string 25 | { 26 | return Regex::match('/\d+\.\d+(k|M)B\/s/', $this->output)->resultOr(''); 27 | } 28 | 29 | public function getSummary(): string 30 | { 31 | return trim($this->output); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Tasks/Backup/Support/Rsync/RsyncSummaryOutput.php: -------------------------------------------------------------------------------- 1 | lines = collect(array_filter($lines)); 17 | } 18 | 19 | public function averageSpeedInMB(): string 20 | { 21 | $line = $this->getValueOfLineLineStartingWith('sent') ?? ''; 22 | 23 | $bytesPerSecondString = explode(' ', $line)[2] ?? null; 24 | 25 | if (is_null($bytesPerSecondString)) { 26 | return '0MB/s'; 27 | } 28 | 29 | $bytesPerSecondString = str_replace(' bytes/sec', '', $bytesPerSecondString); 30 | 31 | $bytesPerSecondString = str_replace(',', '', $bytesPerSecondString); 32 | 33 | $megaBytesPerSecond = ((float) $bytesPerSecondString / 1024 / 1024); 34 | 35 | return round($megaBytesPerSecond, 2).'MB/s'; 36 | } 37 | 38 | protected function getValueOfLineLineStartingWith(string $startsWith): ?string 39 | { 40 | return $this->lines->first(fn (string $line) => Str::startsWith($line, $startsWith)); 41 | } 42 | 43 | protected function removeAllNonNumbericalCharacters(string $string): string 44 | { 45 | return preg_replace('/[^0-9]/', '', $string); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Tasks/Cleanup/Events/CleanupForDestinationCompletedEvent.php: -------------------------------------------------------------------------------- 1 | timeout = config('backup-server.jobs.delete_backup_job.timeout'); 28 | 29 | $this->queue = config('backup-server.jobs.delete_backup_job.queue'); 30 | 31 | $this->connection ??= Config::getQueueConnection(); 32 | } 33 | 34 | public function handle(): void 35 | { 36 | $this->backup->delete(); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Tasks/Cleanup/Jobs/DeleteDestinationJob.php: -------------------------------------------------------------------------------- 1 | timeout = config('backup-server.jobs.delete_destination_job.timeout'); 29 | 30 | $this->queue = config('backup-server.jobs.delete_destination_job.queue'); 31 | 32 | $this->connection ??= Config::getQueueConnection(); 33 | } 34 | 35 | public function handle(): void 36 | { 37 | $this->destination->backups->each( 38 | fn (Backup $backup) => $backup->delete() 39 | ); 40 | 41 | $this->destination->delete(); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Tasks/Cleanup/Jobs/DeleteSourceJob.php: -------------------------------------------------------------------------------- 1 | timeout = config('backup-server.jobs.delete_source_job.timeout'); 29 | 30 | $this->queue = config('backup-server.jobs.delete_source_job.queue'); 31 | 32 | $this->connection ??= Config::getQueueConnection(); 33 | } 34 | 35 | public function handle(): void 36 | { 37 | $this->source->backups->each( 38 | fn (Backup $backup) => $backup->delete() 39 | ); 40 | 41 | $this->source->delete(); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Tasks/Cleanup/Jobs/PerformCleanupDestinationJob.php: -------------------------------------------------------------------------------- 1 | timeout = config('backup-server.jobs.perform_cleanup_for_destination_job.timeout'); 32 | 33 | $this->queue = config('backup-server.jobs.perform_cleanup_for_destination_job.queue'); 34 | 35 | $this->connection ??= Config::getQueueConnection(); 36 | } 37 | 38 | public function handle(): void 39 | { 40 | $this->destination->logInfo(Task::Cleanup, 'Starting cleanup of destination'); 41 | 42 | // TODO: implement 43 | 44 | $this->destination->logInfo(Task::Cleanup, 'Destination cleaned up'); 45 | 46 | event(new CleanupForDestinationCompletedEvent($this->destination)); 47 | } 48 | 49 | public function failed(Throwable $exception): void 50 | { 51 | $this->destination->logError(Task::Cleanup, "Error while cleaning up destination `{$this->destination->name}`: `{$exception->getMessage()}`"); 52 | 53 | event(new CleanupForDestinationFailedEvent($this->destination, $exception->getMessage())); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Tasks/Cleanup/Jobs/PerformCleanupSourceJob.php: -------------------------------------------------------------------------------- 1 | timeout = config('backup-server.jobs.perform_cleanup_for_source_job.timeout'); 36 | 37 | $this->queue = config('backup-server.jobs.perform_cleanup_for_source_job.queue'); 38 | } 39 | 40 | public function handle(): void 41 | { 42 | $this->source->logInfo(Task::Cleanup, 'Starting cleanup...'); 43 | 44 | $tasks = [ 45 | DeleteBackupsWithoutDirectoriesFromDb::class, 46 | DeleteOldBackups::class, 47 | DeleteFailedBackups::class, 48 | RecalculateRealBackupSizes::class, 49 | ]; 50 | 51 | collect($tasks) 52 | ->map(fn (string $className) => app($className)) 53 | ->each(fn (CleanupTask $cleanupTask) => $cleanupTask->execute($this->source)); 54 | 55 | event(new CleanupForSourceCompletedEvent($this->source)); 56 | 57 | $this->source->logInfo(Task::Cleanup, 'Cleanup done!'); 58 | } 59 | 60 | public function failed(Throwable $exception): void 61 | { 62 | $this->source->logError(Task::Cleanup, "Error while cleaning up source `{$this->source->name}`: `{$exception->getMessage()}`"); 63 | 64 | report($exception); 65 | 66 | event(new CleanupForSourceFailedEvent($this->source, $exception->getMessage())); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/Tasks/Cleanup/Jobs/Tasks/CleanupTask.php: -------------------------------------------------------------------------------- 1 | completedBackups() 15 | ->each(function (Backup $backup) { 16 | if (! $backup->existsOnDisk()) { 17 | $backup->source->logInfo(Task::Cleanup, "Removing backup id `{$backup->id}` because its directory `{$backup->destinationLocation()->getPath()}` on disk `{$backup->disk_name}` does not exist anymore "); 18 | $backup->delete(); 19 | } 20 | }); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Tasks/Cleanup/Jobs/Tasks/DeleteFailedBackups.php: -------------------------------------------------------------------------------- 1 | backups() 14 | ->failed() 15 | ->get() 16 | ->filter(fn (Backup $backup) => $backup->created_at->diffInDays() > 1) 17 | ->each(function (Backup $backup) use ($source) { 18 | $source->logInfo(Task::Cleanup, "Removing backup id {$backup->id} because it has failed"); 19 | 20 | $backup->delete(); 21 | }); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Tasks/Cleanup/Jobs/Tasks/DeleteOldBackups.php: -------------------------------------------------------------------------------- 1 | deleteOldBackups($source); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Tasks/Cleanup/Jobs/Tasks/RecalculateRealBackupSizes.php: -------------------------------------------------------------------------------- 1 | logInfo(Task::Cleanup, 'Recalculating real backup sizes...'); 13 | 14 | /** @var \Spatie\BackupServer\Tasks\Backup\Support\BackupCollection $backupCollection */ 15 | $backupCollection = $source->completedBackups()->get(); 16 | 17 | $backupCollection->recalculateRealSizeInKb(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Tasks/Cleanup/Strategies/CleanupStrategy.php: -------------------------------------------------------------------------------- 1 | config = new DefaultStrategyConfig($source); 23 | 24 | /** @var \Spatie\BackupServer\Tasks\Backup\Support\BackupCollection $backups */ 25 | $backups = $source->completedBackups()->get(); 26 | 27 | // Don't ever delete the youngest backup. 28 | $this->youngestBackup = $backups->shift(); 29 | 30 | $dateRanges = $this->calculateDateRanges(); 31 | 32 | $backupsPerPeriod = $dateRanges->map(function (Period $period) use ($backups) { 33 | return $backups->filter(function (Backup $backup) use ($period) { 34 | return $backup->created_at->between($period->startDate(), $period->endDate()); 35 | }); 36 | }); 37 | 38 | $backupsPerPeriod['daily'] = $this->groupByDateFormat($backupsPerPeriod['daily'], 'Ymd'); 39 | $backupsPerPeriod['weekly'] = $this->groupByDateFormat($backupsPerPeriod['weekly'], 'YW'); 40 | $backupsPerPeriod['monthly'] = $this->groupByDateFormat($backupsPerPeriod['monthly'], 'Ym'); 41 | $backupsPerPeriod['yearly'] = $this->groupByDateFormat($backupsPerPeriod['yearly'], 'Y'); 42 | 43 | $this->removeBackupsForAllPeriodsExceptOne($backupsPerPeriod); 44 | 45 | $this->removeBackupsOlderThan($dateRanges['yearly']->endDate(), $backups); 46 | 47 | $backups = $backups->filter->exists; 48 | 49 | if ($sizeLimitInMb = $this->config->deleteOldestBackupsWhenUsingMoreMegabytesThan) { 50 | $this->removeOldBackupsUntilUsingLessThanMaximumStorage($backups, $sizeLimitInMb); 51 | } 52 | } 53 | 54 | protected function calculateDateRanges(): Collection 55 | { 56 | $daily = new Period( 57 | Carbon::now()->subDays($this->config->keepAllBackupsForDays), 58 | Carbon::now() 59 | ->subDays($this->config->keepAllBackupsForDays) 60 | ->subDays($this->config->keepDailyBackupsForDays) 61 | ); 62 | 63 | $weekly = new Period( 64 | $daily->endDate(), 65 | $daily->endDate()->subWeeks($this->config->keepWeeklyBackupsForWeeks) 66 | ); 67 | 68 | $monthly = new Period( 69 | $weekly->endDate(), 70 | $weekly->endDate()->subMonths($this->config->keepMonthlyBackupsForMonths) 71 | ); 72 | 73 | $yearly = new Period( 74 | $monthly->endDate(), 75 | $monthly->endDate()->subYears($this->config->keepYearlyBackupsForYears) 76 | ); 77 | 78 | return collect(['daily' => $daily, 'weekly' => $weekly, 'monthly' => $monthly, 'yearly' => $yearly]); 79 | } 80 | 81 | protected function groupByDateFormat(Collection $backups, string $dateFormat): Collection 82 | { 83 | return $backups->groupBy(function (Backup $backup) use ($dateFormat) { 84 | return $backup->created_at->format($dateFormat); 85 | }); 86 | } 87 | 88 | protected function removeBackupsForAllPeriodsExceptOne(Collection $backupsPerPeriod): void 89 | { 90 | $backupsPerPeriod->each(function (Collection $groupedBackupsByDateProperty) { 91 | $groupedBackupsByDateProperty->each(function (Collection $group) { 92 | $group->shift(); 93 | 94 | $group->each->delete(); 95 | }); 96 | }); 97 | } 98 | 99 | protected function removeBackupsOlderThan(Carbon $endDate, Collection $backups): void 100 | { 101 | $backups->filter(function (Backup $backup) use ($endDate) { 102 | return $backup->created_at->lt($endDate); 103 | })->each->delete(); 104 | } 105 | 106 | protected function removeOldBackupsUntilUsingLessThanMaximumStorage(BackupCollection $backups, int $sizeLimitInMb): void 107 | { 108 | if (! $backups->count()) { 109 | return; 110 | } 111 | 112 | $actualSizeInKb = $backups 113 | ->recalculateRealSizeInKb() 114 | ->sum('real_size_in_kb'); 115 | 116 | if ($actualSizeInKb <= ($sizeLimitInMb * 1024)) { 117 | return; 118 | } 119 | 120 | /** @var \Spatie\BackupServer\Models\Backup $oldestBackup */ 121 | $oldestBackup = $backups->oldest(); 122 | 123 | $oldestBackup->logInfo(Task::Cleanup, 'Deleting backup because destination uses more space than the limit allows'); 124 | 125 | $oldestBackup->delete(); 126 | 127 | $backups = $backups->filter->exists; 128 | 129 | $this->removeOldBackupsUntilUsingLessThanMaximumStorage($backups, $sizeLimitInMb); 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/Tasks/Cleanup/Support/DefaultStrategyConfig.php: -------------------------------------------------------------------------------- 1 | keepAllBackupsForDays = $this->getConfigValue('keep_all_backups_for_days'); 24 | $this->keepDailyBackupsForDays = $this->getConfigValue('keep_daily_backups_for_days'); 25 | $this->keepWeeklyBackupsForWeeks = $this->getConfigValue('keep_weekly_backups_for_weeks'); 26 | $this->keepMonthlyBackupsForMonths = $this->getConfigValue('keep_monthly_backups_for_months'); 27 | $this->keepYearlyBackupsForYears = $this->getConfigValue('keep_yearly_backups_for_years'); 28 | $this->deleteOldestBackupsWhenUsingMoreMegabytesThan = $this->getConfigValue('delete_oldest_backups_when_using_more_megabytes_than'); 29 | } 30 | 31 | private function getConfigValue(string $attribute): int 32 | { 33 | return $this->source->$attribute 34 | ?? $this->source->destination->$attribute 35 | ?? config("backup-server.cleanup.default_strategy.{$attribute}"); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Tasks/Cleanup/Support/Period.php: -------------------------------------------------------------------------------- 1 | startDate->copy(); 17 | } 18 | 19 | public function endDate(): Carbon 20 | { 21 | return $this->endDate->copy(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Tasks/Monitor/Events/HealthyDestinationFoundEvent.php: -------------------------------------------------------------------------------- 1 | healthCheckResults = $this->performHealthChecks(); 18 | } 19 | 20 | public function allPass(): bool 21 | { 22 | $containsFailingHealthCheck = collect($this->healthCheckResults)->contains(function (HealthCheckResult $healthCheckResult): bool { 23 | return ! $healthCheckResult->isOk(); 24 | }); 25 | 26 | return ! $containsFailingHealthCheck; 27 | } 28 | 29 | public function getFailureMessages(): array 30 | { 31 | return collect($this->healthCheckResults) 32 | ->reject(function (HealthCheckResult $healthCheckResult): bool { 33 | return $healthCheckResult->isOk(); 34 | }) 35 | ->map(function (HealthCheckResult $healthCheckResult): string { 36 | return $healthCheckResult->getMessage(); 37 | }) 38 | ->toArray(); 39 | } 40 | 41 | protected function performHealthChecks(): array 42 | { 43 | if (! is_null($this->healthCheckResults)) { 44 | return $this->healthCheckResults; 45 | } 46 | 47 | $healthChecks = collect($this->healthCheckClassNames) 48 | ->map(function ($arguments, string $healthCheckClassName): \Spatie\BackupServer\Tasks\Monitor\HealthChecks\HealthCheck { 49 | if (is_numeric($healthCheckClassName)) { 50 | $healthCheckClassName = $arguments; 51 | $arguments = []; 52 | } 53 | 54 | return $this->instanciateHealthCheck($healthCheckClassName, $arguments); 55 | }); 56 | 57 | $healthCheckResults = []; 58 | $runRemainingChecks = true; 59 | 60 | /** @var HealthCheck $healthCheck */ 61 | foreach ($healthChecks as $healthCheck) { 62 | if ($runRemainingChecks) { 63 | /** @var \Spatie\BackupServer\Tasks\Monitor\HealthCheckResult $healthCheckResult */ 64 | $healthCheckResult = $healthCheck->getResult($this->model); 65 | 66 | $healthCheckResults[] = $healthCheckResult; 67 | 68 | $runRemainingChecks = $healthCheckResult->shouldContinueRunningRemainingChecks(); 69 | } 70 | } 71 | 72 | return $healthCheckResults; 73 | } 74 | 75 | protected function instanciateHealthCheck(string $healthCheckClass, $arguments): HealthCheck 76 | { 77 | // A single value was passed - we'll instantiate it manually assuming it's the first argument 78 | if (! is_array($arguments)) { 79 | return new $healthCheckClass($arguments); 80 | } 81 | 82 | // A config array was given. Use reflection to match arguments 83 | return app()->makeWith($healthCheckClass, $arguments); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/Tasks/Monitor/HealthCheckResult.php: -------------------------------------------------------------------------------- 1 | markAsFailed(); 19 | } 20 | 21 | protected function __construct(protected string $message = '') {} 22 | 23 | public function isOk(): bool 24 | { 25 | return $this->ok; 26 | } 27 | 28 | public function getMessage(): string 29 | { 30 | return $this->message; 31 | } 32 | 33 | public function markAsFailed(): self 34 | { 35 | $this->ok = false; 36 | 37 | return $this; 38 | } 39 | 40 | public function doNotRunRemainingChecks(): self 41 | { 42 | $this->runRemainingChecks = false; 43 | 44 | return $this; 45 | } 46 | 47 | public function shouldContinueRunningRemainingChecks(): bool 48 | { 49 | return $this->runRemainingChecks; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Tasks/Monitor/HealthChecks/Destination/DestinationHealthCheck.php: -------------------------------------------------------------------------------- 1 | reachable() 13 | ? HealthCheckResult::ok() 14 | : HealthCheckResult::failed('Could not reach destination')->doNotRunRemainingChecks(); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Tasks/Monitor/HealthChecks/Destination/MaximumDiskCapacityUsageInPercentage.php: -------------------------------------------------------------------------------- 1 | getUsedSpaceInPercentage(); 15 | 16 | if ($actualDiskSpaceUsage > $this->maximumDiskCapacityUsageInPercentage) { 17 | return HealthCheckResult::failed("The used disk space capacity ($actualDiskSpaceUsage %) is greater than the maximum allowed ($this->maximumDiskCapacityUsageInPercentage %)"); 18 | } 19 | 20 | return HealthCheckResult::ok(); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Tasks/Monitor/HealthChecks/Destination/MaximumInodeUsageInPercentage.php: -------------------------------------------------------------------------------- 1 | getInodeUsagePercentage(); 15 | 16 | if ($destination->getInodeUsagePercentage() > $this->maximumPercentage) { 17 | HealthCheckResult::failed("The current inode usage percentage ({$currentInodeUsagePercentage}) is higher than the allowed percentage {$this->maximumPercentage}"); 18 | } 19 | 20 | return HealthCheckResult::ok(); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Tasks/Monitor/HealthChecks/Destination/MaximumStorageInMB.php: -------------------------------------------------------------------------------- 1 | maximumSizeInMB($destination); 17 | 18 | if ($maximumSizeInMB === 0) { 19 | return HealthCheckResult::ok(); 20 | } 21 | 22 | $actualSizeInMB = round($destination->completedBackups()->sum('real_size_in_kb') / 1024, 5); 23 | 24 | if ($actualSizeInMB > $maximumSizeInMB) { 25 | return HealthCheckResult::failed("The actual destination storage used ({$actualSizeInMB} MB) is greater than the allowed storage used ({$maximumSizeInMB})."); 26 | } 27 | 28 | return HealthCheckResult::ok(); 29 | } 30 | 31 | protected function maximumSizeInMB(Destination $destination): int 32 | { 33 | return $destination->healthy_maximum_storage_in_mb ?? $this->configuredMaximumStorageInMB; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Tasks/Monitor/HealthChecks/HealthCheck.php: -------------------------------------------------------------------------------- 1 | created_at) { 18 | if ($source->created_at->diffInDays() < $this->maximumHealthyAgeInDays($source)) { 19 | return HealthCheckResult::ok(); 20 | } 21 | } 22 | 23 | $youngestCompletedBackup = $source->youngestCompletedBackup(); 24 | 25 | if (! $youngestCompletedBackup) { 26 | return HealthCheckResult::failed('No backup found'); 27 | } 28 | 29 | $maximumHealthAgeInDays = $this->maximumHealthyAgeInDays($source); 30 | 31 | if ($youngestCompletedBackup->created_at->diffInDays() >= $this->maximumHealthyAgeInDays($source)) { 32 | return HealthCheckResult::failed("Latest backup is older then {$maximumHealthAgeInDays}".Str::plural('day', $maximumHealthAgeInDays)); 33 | } 34 | 35 | return HealthCheckResult::ok(); 36 | } 37 | 38 | public function maximumHealthyAgeInDays(Source $source): int 39 | { 40 | $maximumAgeOnSource = $source->healthy_maximum_backup_age_in_days; 41 | $maximumAgeOnDestination = optional($source->destination)->healthy_maximum_backup_age_in_days_per_source; 42 | 43 | return $maximumAgeOnSource ?? $maximumAgeOnDestination ?? $this->configuredMaximumAgeInDays; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Tasks/Monitor/HealthChecks/Source/MaximumStorageInMB.php: -------------------------------------------------------------------------------- 1 | completedBackups()->sum('real_size_in_kb') / 1024, 5); 17 | 18 | $maximumSizeInMB = $this->maximumSizeInMB($source); 19 | 20 | if ($actualSizeInMB > $maximumSizeInMB) { 21 | return HealthCheckResult::failed("The actual source storage used ({$actualSizeInMB} MB) is greater than the allowed storage used ({$maximumSizeInMB})."); 22 | } 23 | 24 | return HealthCheckResult::ok(); 25 | } 26 | 27 | protected function maximumSizeInMB(Source $source): int 28 | { 29 | $maximumSizeOnSource = $source->healthy_maximum_storage_in_mb; 30 | $maximumAgeOnDestination = optional($source->destination)->healthy_maximum_storage_in_mb_per_source; 31 | 32 | return $maximumSizeOnSource ?? $maximumAgeOnDestination ?? $this->configuredMaximumStorageInMB; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Tasks/Monitor/HealthChecks/Source/SourceHealthCheck.php: -------------------------------------------------------------------------------- 1 | relativePath, $this->lineNumber] = explode(':', $grepResultLine); 17 | } 18 | 19 | public function lineNumber(): string 20 | { 21 | return $this->lineNumber; 22 | } 23 | 24 | public function getAbsolutePath(): string 25 | { 26 | $root = $this->backup->destinationLocation()->getFullPath(); 27 | 28 | return $root.Str::after($this->relativePath, './'); 29 | } 30 | 31 | public function age(): string 32 | { 33 | return $this->backup->created_at->diffForHumans(); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Tasks/Search/ContentSearchResultFactory.php: -------------------------------------------------------------------------------- 1 | filter() 15 | ->filter(function (string $outputLine): bool { 16 | if (! Str::startsWith($outputLine, '.')) { 17 | return false; 18 | } 19 | 20 | return Str::contains($outputLine, ':'); 21 | }) 22 | ->values() 23 | ->map(fn (string $relativePath): \Spatie\BackupServer\Tasks\Search\ContentSearchResult => new ContentSearchResult($relativePath, $backup)); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Tasks/Search/FileSearchResult.php: -------------------------------------------------------------------------------- 1 | backup->destinationLocation()->getFullPath(); 18 | 19 | return $root.Str::after($this->relativePath, './'); 20 | } 21 | 22 | public function age(): string 23 | { 24 | return $this->backup->created_at->diffForHumans(); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Tasks/Search/FileSearchResultFactory.php: -------------------------------------------------------------------------------- 1 | filter() 14 | ->values() 15 | ->map(fn (string $relativePath): \Spatie\BackupServer\Tasks\Search\FileSearchResult => new FileSearchResult($relativePath, $backup)); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Tasks/Summary/Actions/CreateServerSummaryAction.php: -------------------------------------------------------------------------------- 1 | where(function (Builder $query) use ($to, $from) { 18 | $query 19 | ->whereBetween('completed_at', [$from, $to]) 20 | ->orWhereBetween('created_at', [$from, $to]); 21 | }); 22 | 23 | $destinations = Destination::get(); 24 | $healthyDestinations = $destinations->filter(fn (Destination $destination) => $destination->isHealthy()); 25 | 26 | $totalUsedSpaceInKb = $destinations->sum(fn (Destination $destination) => $destination->backups->realSizeInKb()); 27 | $totalFreeSpaceInKb = $destinations->sum(fn (Destination $destination) => $destination->getFreeSpaceInKb()); 28 | 29 | return new ServerSummary( 30 | $from, 31 | $to, 32 | (clone $backupsQuery)->completed()->count(), 33 | (clone $backupsQuery)->failed()->count(), 34 | $healthyDestinations->count(), 35 | $destinations->count() - $healthyDestinations->count(), 36 | Source::healthy()->count(), 37 | Source::unhealthy()->count(), 38 | $totalUsedSpaceInKb, 39 | $totalFreeSpaceInKb, 40 | $backupsQuery->sum('rsync_time_in_seconds'), 41 | BackupLogItem::query()->where('level', 'error')->count(), 42 | ); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Tasks/Summary/Jobs/SendServerSummaryNotificationJob.php: -------------------------------------------------------------------------------- 1 | from = $from ?? now()->subWeek(); 28 | $this->to = $to ?? now(); 29 | } 30 | 31 | public function handle(CreateServerSummaryAction $createServerSummaryAction): void 32 | { 33 | $summary = $createServerSummaryAction->execute($this->from, $this->to); 34 | 35 | $notification = new ServerSummaryNotification($summary); 36 | 37 | $notifiableClass = config('backup-server.notifications.notifiable'); 38 | 39 | app($notifiableClass)->notify($notification); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Tasks/Summary/ServerSummary.php: -------------------------------------------------------------------------------- 1 |