Primo Committ

This commit is contained in:
paoloar77
2024-05-07 12:17:25 +02:00
commit e73d0e5113
7204 changed files with 884387 additions and 0 deletions

View File

@@ -0,0 +1,102 @@
<?php
namespace Spatie\Backup\BackupDestination;
use Carbon\Carbon;
use Illuminate\Contracts\Filesystem\Filesystem;
use InvalidArgumentException;
use Spatie\Backup\Tasks\Backup\BackupJob;
class Backup
{
/** @var \Illuminate\Contracts\Filesystem\Filesystem */
protected $disk;
/** @var string */
protected $path;
/** @var bool */
protected $exists;
/** @var Carbon */
protected $date;
/** @var int */
protected $size;
public function __construct(Filesystem $disk, string $path)
{
$this->disk = $disk;
$this->path = $path;
$this->exists = true;
}
public function disk(): Filesystem
{
return $this->disk;
}
public function path(): string
{
return $this->path;
}
public function exists(): bool
{
if ($this->exists === null) {
$this->exists = $this->disk->exists($this->path);
}
return $this->exists;
}
public function date(): Carbon
{
if ($this->date === null) {
try {
// try to parse the date from the filename
$basename = basename($this->path);
$this->date = Carbon::createFromFormat(BackupJob::FILENAME_FORMAT, $basename);
} catch (InvalidArgumentException $e) {
// if that fails, ask the (remote) filesystem
$this->date = Carbon::createFromTimestamp($this->disk->lastModified($this->path));
}
}
return $this->date;
}
/**
* Get the size in bytes.
*/
public function size(): float
{
if ($this->size === null) {
if (! $this->exists()) {
return 0;
}
$this->size = $this->disk->size($this->path);
}
return $this->size;
}
public function stream()
{
return $this->disk->readStream($this->path);
}
public function delete()
{
if (! $this->disk->delete($this->path)) {
consoleOutput()->error("Failed to delete backup `{$this->path}`.");
return;
}
$this->exists = false;
consoleOutput()->info("Deleted backup `{$this->path}`.");
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace Spatie\Backup\BackupDestination;
use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Support\Collection;
use Spatie\Backup\Helpers\File;
class BackupCollection extends Collection
{
/** @var null|float */
protected $sizeCache = null;
public static function createFromFiles(?FileSystem $disk, array $files): self
{
return (new static($files))
->filter(function ($path) use ($disk) {
return (new File)->isZipFile($disk, $path);
})
->map(function ($path) use ($disk) {
return new Backup($disk, $path);
})
->sortByDesc(function (Backup $backup) {
return $backup->date()->timestamp;
})
->values();
}
public function newest(): ?Backup
{
return $this->first();
}
public function oldest(): ?Backup
{
return $this
->filter->exists()
->last();
}
public function size(): float
{
if ($this->sizeCache !== null) {
return $this->sizeCache;
}
return $this->sizeCache = $this->sum->size();
}
}

View File

@@ -0,0 +1,191 @@
<?php
namespace Spatie\Backup\BackupDestination;
use Carbon\Carbon;
use Exception;
use Illuminate\Contracts\Filesystem\Factory;
use Illuminate\Contracts\Filesystem\Filesystem;
use Spatie\Backup\Exceptions\InvalidBackupDestination;
class BackupDestination
{
/** @var \Illuminate\Contracts\Filesystem\Filesystem */
protected $disk;
/** @var string */
protected $diskName;
/** @var string */
protected $backupName;
/** @var Exception */
public $connectionError;
/** @var null|\Spatie\Backup\BackupDestination\BackupCollection */
protected $backupCollectionCache = null;
public function __construct(Filesystem $disk = null, string $backupName, string $diskName)
{
$this->disk = $disk;
$this->diskName = $diskName;
$this->backupName = preg_replace('/[^a-zA-Z0-9.]/', '-', $backupName);
}
public function disk(): Filesystem
{
return $this->disk;
}
public function diskName(): string
{
return $this->diskName;
}
public function filesystemType(): string
{
if (is_null($this->disk)) {
return 'unknown';
}
$adapterClass = get_class($this->disk->getDriver()->getAdapter());
$filesystemType = last(explode('\\', $adapterClass));
return strtolower($filesystemType);
}
public static function create(string $diskName, string $backupName): self
{
try {
$disk = app(Factory::class)->disk($diskName);
return new static($disk, $backupName, $diskName);
} catch (Exception $exception) {
$backupDestination = new static(null, $backupName, $diskName);
$backupDestination->connectionError = $exception;
return $backupDestination;
}
}
public function write(string $file)
{
if (! is_null($this->connectionError)) {
throw InvalidBackupDestination::connectionError($this->diskName);
}
if (is_null($this->disk)) {
throw InvalidBackupDestination::diskNotSet($this->backupName);
}
$destination = $this->backupName.'/'.pathinfo($file, PATHINFO_BASENAME);
$handle = fopen($file, 'r+');
$result = $this->disk->getDriver()->writeStream(
$destination,
$handle,
$this->getDiskOptions()
);
if (is_resource($handle)) {
fclose($handle);
}
if ($result === false) {
throw InvalidBackupDestination::writeError($this->diskName);
}
}
public function backupName(): string
{
return $this->backupName;
}
public function backups(): BackupCollection
{
if ($this->backupCollectionCache) {
return $this->backupCollectionCache;
}
$files = [];
if (! is_null($this->disk)) {
// $this->disk->allFiles() may fail when $this->disk is not reachable
// in that case we still want to send the notification
try {
$files = $this->disk->allFiles($this->backupName);
} catch (Exception $ex) {
}
}
return $this->backupCollectionCache = BackupCollection::createFromFiles(
$this->disk,
$files
);
}
public function connectionError(): Exception
{
return $this->connectionError;
}
public function getDiskOptions(): array
{
return config("filesystems.disks.{$this->diskName()}.backup_options") ?? [];
}
public function isReachable(): bool
{
if (is_null($this->disk)) {
return false;
}
try {
$this->disk->allFiles($this->backupName);
return true;
} catch (Exception $exception) {
$this->connectionError = $exception;
return false;
}
}
public function usedStorage(): float
{
return $this->backups()->size();
}
public function newestBackup(): ?Backup
{
return $this->backups()->newest();
}
public function oldestBackup(): ?Backup
{
return $this->backups()->oldest();
}
public function newestBackupIsOlderThan(Carbon $date): bool
{
$newestBackup = $this->newestBackup();
if (is_null($newestBackup)) {
return true;
}
return $newestBackup->date()->gt($date);
}
public function fresh(): self
{
$this->backupCollectionCache = null;
return $this;
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace Spatie\Backup\BackupDestination;
use Illuminate\Support\Collection;
class BackupDestinationFactory
{
public static function createFromArray(array $config): Collection
{
return collect($config['destination']['disks'])
->map(function ($filesystemName) use ($config) {
return BackupDestination::create($filesystemName, $config['name']);
});
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace Spatie\Backup;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
use Spatie\Backup\Commands\BackupCommand;
use Spatie\Backup\Commands\CleanupCommand;
use Spatie\Backup\Commands\ListCommand;
use Spatie\Backup\Commands\MonitorCommand;
use Spatie\Backup\Events\BackupZipWasCreated;
use Spatie\Backup\Helpers\ConsoleOutput;
use Spatie\Backup\Listeners\EncryptBackupArchive;
use Spatie\Backup\Notifications\EventHandler;
use Spatie\Backup\Tasks\Cleanup\CleanupStrategy;
class BackupServiceProvider extends ServiceProvider
{
public function boot()
{
$this->publishes([
__DIR__.'/../config/backup.php' => config_path('backup.php'),
], 'config');
$this->publishes([
__DIR__.'/../resources/lang' => "{$this->app['path.lang']}/vendor/backup",
]);
$this->loadTranslationsFrom(__DIR__.'/../resources/lang/', 'backup');
if (EncryptBackupArchive::shouldEncrypt()) {
Event::listen(BackupZipWasCreated::class, EncryptBackupArchive::class);
}
}
public function register()
{
$this->mergeConfigFrom(__DIR__.'/../config/backup.php', 'backup');
$this->app['events']->subscribe(EventHandler::class);
$this->app->bind('command.backup:run', BackupCommand::class);
$this->app->bind('command.backup:clean', CleanupCommand::class);
$this->app->bind('command.backup:list', ListCommand::class);
$this->app->bind('command.backup:monitor', MonitorCommand::class);
$this->app->bind(CleanupStrategy::class, config('backup.cleanup.strategy'));
$this->commands([
'command.backup:run',
'command.backup:clean',
'command.backup:list',
'command.backup:monitor',
]);
$this->app->singleton(ConsoleOutput::class);
}
}

View File

@@ -0,0 +1,76 @@
<?php
namespace Spatie\Backup\Commands;
use Exception;
use Spatie\Backup\Events\BackupHasFailed;
use Spatie\Backup\Exceptions\InvalidCommand;
use Spatie\Backup\Tasks\Backup\BackupJobFactory;
class BackupCommand extends BaseCommand
{
/** @var string */
protected $signature = 'backup:run {--filename=} {--only-db} {--db-name=*} {--only-files} {--only-to-disk=} {--disable-notifications} {--timeout=}';
/** @var string */
protected $description = 'Run the backup.';
public function handle()
{
consoleOutput()->comment('Starting backup...');
$disableNotifications = $this->option('disable-notifications');
if ($this->option('timeout') && is_numeric($this->option('timeout'))) {
set_time_limit((int) $this->option('timeout'));
}
try {
$this->guardAgainstInvalidOptions();
$backupJob = BackupJobFactory::createFromArray(config('backup'));
if ($this->option('only-db')) {
$backupJob->dontBackupFilesystem();
}
if ($this->option('db-name')) {
$backupJob->onlyDbName($this->option('db-name'));
}
if ($this->option('only-files')) {
$backupJob->dontBackupDatabases();
}
if ($this->option('only-to-disk')) {
$backupJob->onlyBackupTo($this->option('only-to-disk'));
}
if ($this->option('filename')) {
$backupJob->setFilename($this->option('filename'));
}
if ($disableNotifications) {
$backupJob->disableNotifications();
}
$backupJob->run();
consoleOutput()->comment('Backup completed!');
} catch (Exception $exception) {
consoleOutput()->error("Backup failed because: {$exception->getMessage()}.");
if (! $disableNotifications) {
event(new BackupHasFailed($exception));
}
return 1;
}
}
protected function guardAgainstInvalidOptions()
{
if ($this->option('only-db') && $this->option('only-files')) {
throw InvalidCommand::create('Cannot use `only-db` and `only-files` together');
}
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace Spatie\Backup\Commands;
use Illuminate\Console\Command;
use Spatie\Backup\Helpers\ConsoleOutput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
abstract class BaseCommand extends Command
{
public function run(InputInterface $input, OutputInterface $output): int
{
app(ConsoleOutput::class)->setOutput($this);
return parent::run($input, $output);
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace Spatie\Backup\Commands;
use Exception;
use Spatie\Backup\BackupDestination\BackupDestinationFactory;
use Spatie\Backup\Events\CleanupHasFailed;
use Spatie\Backup\Tasks\Cleanup\CleanupJob;
use Spatie\Backup\Tasks\Cleanup\CleanupStrategy;
class CleanupCommand extends BaseCommand
{
/** @var string */
protected $signature = 'backup:clean {--disable-notifications}';
/** @var string */
protected $description = 'Remove all backups older than specified number of days in config.';
/** @var \Spatie\Backup\Tasks\Cleanup\CleanupStrategy */
protected $strategy;
public function __construct(CleanupStrategy $strategy)
{
parent::__construct();
$this->strategy = $strategy;
}
public function handle()
{
consoleOutput()->comment('Starting cleanup...');
$disableNotifications = $this->option('disable-notifications');
try {
$config = config('backup');
$backupDestinations = BackupDestinationFactory::createFromArray($config['backup']);
$cleanupJob = new CleanupJob($backupDestinations, $this->strategy, $disableNotifications);
$cleanupJob->run();
consoleOutput()->comment('Cleanup completed!');
} catch (Exception $exception) {
if (! $disableNotifications) {
event(new CleanupHasFailed($exception));
}
return 1;
}
}
}

View File

@@ -0,0 +1,101 @@
<?php
namespace Spatie\Backup\Commands;
use Illuminate\Support\Collection;
use Spatie\Backup\BackupDestination\Backup;
use Spatie\Backup\Helpers\Format;
use Spatie\Backup\Helpers\RightAlignedTableStyle;
use Spatie\Backup\Tasks\Monitor\BackupDestinationStatus;
use Spatie\Backup\Tasks\Monitor\BackupDestinationStatusFactory;
class ListCommand extends BaseCommand
{
/** @var string */
protected $signature = 'backup:list';
/** @var string */
protected $description = 'Display a list of all backups.';
public function handle()
{
$statuses = BackupDestinationStatusFactory::createForMonitorConfig(config('backup.monitor_backups'));
$this->displayOverview($statuses)->displayFailures($statuses);
}
protected function displayOverview(Collection $backupDestinationStatuses)
{
$headers = ['Name', 'Disk', 'Reachable', 'Healthy', '# of backups', 'Newest backup', 'Used storage'];
$rows = $backupDestinationStatuses->map(function (BackupDestinationStatus $backupDestinationStatus) {
return $this->convertToRow($backupDestinationStatus);
});
$this->table($headers, $rows, 'default', [
4 => new RightAlignedTableStyle(),
6 => new RightAlignedTableStyle(),
]);
return $this;
}
public function convertToRow(BackupDestinationStatus $backupDestinationStatus): array
{
$destination = $backupDestinationStatus->backupDestination();
$row = [
$destination->backupName(),
'disk' => $destination->diskName(),
Format::emoji($destination->isReachable()),
Format::emoji($backupDestinationStatus->isHealthy()),
'amount' => $destination->backups()->count(),
'newest' => $this->getFormattedBackupDate($destination->newestBackup()),
'usedStorage' => Format::humanReadableSize($destination->usedStorage()),
];
if (! $destination->isReachable()) {
foreach (['amount', 'newest', 'usedStorage'] as $propertyName) {
$row[$propertyName] = '/';
}
}
if ($backupDestinationStatus->getHealthCheckFailure() !== null) {
$row['disk'] = '<error>'.$row['disk'].'</error>';
}
return $row;
}
protected function displayFailures(Collection $backupDestinationStatuses)
{
$failed = $backupDestinationStatuses
->filter(function (BackupDestinationStatus $backupDestinationStatus) {
return $backupDestinationStatus->getHealthCheckFailure() !== null;
})
->map(function (BackupDestinationStatus $backupDestinationStatus) {
return [
$backupDestinationStatus->backupDestination()->backupName(),
$backupDestinationStatus->backupDestination()->diskName(),
$backupDestinationStatus->getHealthCheckFailure()->healthCheck()->name(),
$backupDestinationStatus->getHealthCheckFailure()->exception()->getMessage(),
];
});
if ($failed->isNotEmpty()) {
$this->warn('');
$this->warn('Unhealthy backup destinations');
$this->warn('-----------------------------');
$this->table(['Name', 'Disk', 'Failed check', 'Description'], $failed->all());
}
return $this;
}
protected function getFormattedBackupDate(Backup $backup = null)
{
return is_null($backup)
? 'No backups present'
: Format::ageInDays($backup->date());
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace Spatie\Backup\Commands;
use Spatie\Backup\Events\HealthyBackupWasFound;
use Spatie\Backup\Events\UnhealthyBackupWasFound;
use Spatie\Backup\Tasks\Monitor\BackupDestinationStatusFactory;
class MonitorCommand extends BaseCommand
{
/** @var string */
protected $signature = 'backup:monitor';
/** @var string */
protected $description = 'Monitor the health of all backups.';
public function handle()
{
$hasError = false;
$statuses = BackupDestinationStatusFactory::createForMonitorConfig(config('backup.monitor_backups'));
foreach ($statuses as $backupDestinationStatus) {
$diskName = $backupDestinationStatus->backupDestination()->diskName();
if ($backupDestinationStatus->isHealthy()) {
$this->info("The backups on {$diskName} are considered healthy.");
event(new HealthyBackupWasFound($backupDestinationStatus));
} else {
$hasError = true;
$this->error("The backups on {$diskName} are considered unhealthy!");
event(new UnHealthyBackupWasFound($backupDestinationStatus));
}
}
if ($hasError) {
return 1;
}
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace Spatie\Backup\Events;
use Exception;
use Spatie\Backup\BackupDestination\BackupDestination;
class BackupHasFailed
{
/** @var \Exception */
public $exception;
/** @var \Spatie\Backup\BackupDestination\BackupDestination|null */
public $backupDestination;
public function __construct(Exception $exception, BackupDestination $backupDestination = null)
{
$this->exception = $exception;
$this->backupDestination = $backupDestination;
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace Spatie\Backup\Events;
use Spatie\Backup\Tasks\Backup\Manifest;
class BackupManifestWasCreated
{
/** @var \Spatie\Backup\Tasks\Backup\Manifest */
public $manifest;
public function __construct(Manifest $manifest)
{
$this->manifest = $manifest;
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace Spatie\Backup\Events;
use Spatie\Backup\BackupDestination\BackupDestination;
class BackupWasSuccessful
{
/** @var \Spatie\Backup\BackupDestination\BackupDestination */
public $backupDestination;
public function __construct(BackupDestination $backupDestination)
{
$this->backupDestination = $backupDestination;
}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace Spatie\Backup\Events;
class BackupZipWasCreated
{
/** @var string */
public $pathToZip;
public function __construct(string $pathToZip)
{
$this->pathToZip = $pathToZip;
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace Spatie\Backup\Events;
use Exception;
use Spatie\Backup\BackupDestination\BackupDestination;
class CleanupHasFailed
{
/** @var \Exception */
public $exception;
/** @var \Spatie\Backup\BackupDestination\BackupDestination|null */
public $backupDestination;
public function __construct(Exception $exception, BackupDestination $backupDestination = null)
{
$this->exception = $exception;
$this->backupDestination = $backupDestination;
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace Spatie\Backup\Events;
use Spatie\Backup\BackupDestination\BackupDestination;
class CleanupWasSuccessful
{
/** @var \Spatie\Backup\BackupDestination\BackupDestination */
public $backupDestination;
public function __construct(BackupDestination $backupDestination)
{
$this->backupDestination = $backupDestination;
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace Spatie\Backup\Events;
use Spatie\DbDumper\DbDumper;
class DumpingDatabase
{
/** @var \Spatie\DbDumper\DbDumper */
public $dbDumper;
public function __construct(DbDumper $dbDumper)
{
$this->dbDumper = $dbDumper;
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace Spatie\Backup\Events;
use Spatie\Backup\Tasks\Monitor\BackupDestinationStatus;
class HealthyBackupWasFound
{
/** @var \Spatie\Backup\Tasks\Monitor\BackupDestinationStatus */
public $backupDestinationStatus;
public function __construct(BackupDestinationStatus $backupDestinationStatus)
{
$this->backupDestinationStatus = $backupDestinationStatus;
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace Spatie\Backup\Events;
use Spatie\Backup\Tasks\Monitor\BackupDestinationStatus;
class UnhealthyBackupWasFound
{
/** @var \Spatie\Backup\Tasks\Monitor\BackupDestinationStatus */
public $backupDestinationStatus;
public function __construct(BackupDestinationStatus $backupDestinationStatus)
{
$this->backupDestinationStatus = $backupDestinationStatus;
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace Spatie\Backup\Exceptions;
use Exception;
class CannotCreateDbDumper extends Exception
{
public static function unsupportedDriver(string $driver): self
{
return new static("Cannot create a dumper for db driver `{$driver}`. Use `mysql`, `pgsql`, `mongodb` or `sqlite`.");
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace Spatie\Backup\Exceptions;
use Exception;
class InvalidBackupDestination extends Exception
{
public static function diskNotSet(string $backupName): self
{
return new static("There is no disk set for the backup named `{$backupName}`.");
}
public static function connectionError(string $diskName): self
{
return new static ("There is a connection error when trying to connect to disk named `{$diskName}`");
}
public static function writeError(string $diskName): self
{
return new static ("There was an error trying to write to disk named `{$diskName}`");
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace Spatie\Backup\Exceptions;
use Exception;
class InvalidBackupJob extends Exception
{
public static function noDestinationsSpecified(): self
{
return new static('A backup job cannot run without a destination to backup to!');
}
public static function destinationDoesNotExist(string $diskName): self
{
return new static("There is no backup destination with a disk named `{$diskName}`.");
}
public static function noFilesToBeBackedUp(): self
{
return new static('There are no files to be backed up.');
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace Spatie\Backup\Exceptions;
use Exception;
class InvalidCommand extends Exception
{
public static function create(string $reason): self
{
return new static($reason);
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace Spatie\Backup\Exceptions;
use Exception;
class InvalidConfiguration extends Exception
{
public static function cannotUseUnsupportedDriver(string $connectionName, string $driverName): self
{
return new static("Db connection `{$connectionName}` uses an unsupported driver `{$driverName}`. Only `mysql` and `pgsql` are supported.");
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace Spatie\Backup\Exceptions;
use Exception;
class InvalidHealthCheck extends Exception
{
public static function because(string $message): self
{
return new static($message);
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace Spatie\Backup\Exceptions;
use Exception;
class NotificationCouldNotBeSent extends Exception
{
public static function noNotificationClassForEvent($event): self
{
$eventClass = get_class($event);
return new static("There is no notification class that can handle event `{$eventClass}`.");
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace Spatie\Backup\Helpers;
class ConsoleOutput
{
/** @var \Illuminate\Console\OutputStyle */
protected $output;
/**
* @param \Illuminate\Console\OutputStyle $output
*/
public function setOutput($output)
{
$this->output = $output;
}
public function __call(string $method, array $arguments)
{
$consoleOutput = app(static::class);
if (! $consoleOutput->output) {
return;
}
$consoleOutput->output->$method($arguments[0]);
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace Spatie\Backup\Helpers;
use Exception;
use Illuminate\Contracts\Filesystem\Filesystem;
class File
{
/** @var array */
protected static $allowedMimeTypes = [
'application/zip',
'application/x-zip',
'application/x-gzip',
];
public function isZipFile(?Filesystem $disk, string $path): bool
{
if ($this->hasZipExtension($path)) {
return true;
}
return $this->hasAllowedMimeType($disk, $path);
}
protected function hasZipExtension(string $path): bool
{
return pathinfo($path, PATHINFO_EXTENSION) === 'zip';
}
protected function hasAllowedMimeType(?Filesystem $disk, string $path)
{
return in_array($this->mimeType($disk, $path), self::$allowedMimeTypes);
}
protected function mimeType(?Filesystem $disk, string $path)
{
try {
if ($disk && method_exists($disk, 'mimeType')) {
return $disk->mimeType($path) ?: false;
}
} catch (Exception $exception) {
// Some drivers throw exceptions when checking mime types, we'll
// just fallback to `false`.
}
return false;
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace Spatie\Backup\Helpers;
use Carbon\Carbon;
class Format
{
public static function humanReadableSize(float $sizeInBytes): string
{
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
if ($sizeInBytes === 0) {
return '0 '.$units[1];
}
for ($i = 0; $sizeInBytes > 1024; $i++) {
$sizeInBytes /= 1024;
}
return round($sizeInBytes, 2).' '.$units[$i];
}
public static function emoji(bool $bool): string
{
if ($bool) {
return '✅';
}
return '❌';
}
public static function ageInDays(Carbon $date): string
{
return number_format(round($date->diffInMinutes() / (24 * 60), 2), 2).' ('.$date->diffForHumans().')';
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace Spatie\Backup\Helpers;
use Symfony\Component\Console\Helper\TableStyle;
class RightAlignedTableStyle extends TableStyle
{
public function __construct()
{
$this->setPadType(STR_PAD_LEFT);
}
}

View File

@@ -0,0 +1,8 @@
<?php
use Spatie\Backup\Helpers\ConsoleOutput;
function consoleOutput(): ConsoleOutput
{
return app(ConsoleOutput::class);
}

View File

@@ -0,0 +1,71 @@
<?php
namespace Spatie\Backup\Listeners;
use Spatie\Backup\Events\BackupZipWasCreated;
use ZipArchive;
class EncryptBackupArchive
{
public function handle(BackupZipWasCreated $event): void
{
if (! $this->shouldEncrypt()) {
return;
}
$zip = new ZipArchive;
$zip->open($event->pathToZip);
$this->encrypt($zip);
$zip->close();
}
protected function encrypt(ZipArchive $zip): void
{
$zip->setPassword(static::getPassword());
foreach (range(0, $zip->numFiles - 1) as $i) {
$zip->setEncryptionIndex($i, static::getAlgorithm());
}
}
public static function shouldEncrypt(): bool
{
$password = static::getPassword();
$algorithm = static::getAlgorithm();
if ($password === null) {
return false;
}
if ($algorithm === null) {
return false;
}
if ($algorithm === false) {
return false;
}
return true;
}
protected static function getPassword(): ?string
{
return config('backup.backup.password');
}
protected static function getAlgorithm(): ?int
{
$encryption = config('backup.backup.encryption');
if ($encryption === 'default') {
$encryption = defined("\ZipArchive::EM_AES_256")
? ZipArchive::EM_AES_256
: null;
}
return $encryption;
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace Spatie\Backup\Notifications;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Collection;
use Spatie\Backup\BackupDestination\BackupDestination;
use Spatie\Backup\Helpers\Format;
abstract class BaseNotification extends Notification
{
public function via(): array
{
$notificationChannels = config('backup.notifications.notifications.'.static::class);
return array_filter($notificationChannels);
}
public function applicationName(): string
{
return config('app.name') ?? config('app.url') ?? 'Laravel application';
}
public function backupName(): string
{
return $this->backupDestination()->backupName();
}
public function diskName(): string
{
return $this->backupDestination()->diskName();
}
protected function backupDestinationProperties(): Collection
{
$backupDestination = $this->backupDestination();
if (! $backupDestination) {
return collect();
}
$backupDestination->fresh();
$newestBackup = $backupDestination->newestBackup();
$oldestBackup = $backupDestination->oldestBackup();
return collect([
'Application name' => $this->applicationName(),
'Backup name' => $this->backupName(),
'Disk' => $backupDestination->diskName(),
'Newest backup size' => $newestBackup ? Format::humanReadableSize($newestBackup->size()) : 'No backups were made yet',
'Number of backups' => (string) $backupDestination->backups()->count(),
'Total storage used' => Format::humanReadableSize($backupDestination->backups()->size()),
'Newest backup date' => $newestBackup ? $newestBackup->date()->format('Y/m/d H:i:s') : 'No backups were made yet',
'Oldest backup date' => $oldestBackup ? $oldestBackup->date()->format('Y/m/d H:i:s') : 'No backups were made yet',
])->filter();
}
public function backupDestination(): ?BackupDestination
{
if (isset($this->event->backupDestination)) {
return $this->event->backupDestination;
}
if (isset($this->event->backupDestinationStatus)) {
return $this->event->backupDestinationStatus->backupDestination();
}
return null;
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace Spatie\Backup\Notifications;
use Illuminate\Contracts\Config\Repository;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Notifications\Notification;
use Spatie\Backup\Events\BackupHasFailed;
use Spatie\Backup\Events\BackupWasSuccessful;
use Spatie\Backup\Events\CleanupHasFailed;
use Spatie\Backup\Events\CleanupWasSuccessful;
use Spatie\Backup\Events\HealthyBackupWasFound;
use Spatie\Backup\Events\UnhealthyBackupWasFound;
use Spatie\Backup\Exceptions\NotificationCouldNotBeSent;
class EventHandler
{
/** @var \Illuminate\Contracts\Config\Repository */
protected $config;
public function __construct(Repository $config)
{
$this->config = $config;
}
public function subscribe(Dispatcher $events)
{
$events->listen($this->allBackupEventClasses(), function ($event) {
$notifiable = $this->determineNotifiable();
$notification = $this->determineNotification($event);
$notifiable->notify($notification);
});
}
protected function determineNotifiable()
{
$notifiableClass = $this->config->get('backup.notifications.notifiable');
return app($notifiableClass);
}
protected function determineNotification($event): Notification
{
$eventName = class_basename($event);
$notificationClass = collect($this->config->get('backup.notifications.notifications'))
->keys()
->first(function ($notificationClass) use ($eventName) {
$notificationName = class_basename($notificationClass);
return $notificationName === $eventName;
});
if (! $notificationClass) {
throw NotificationCouldNotBeSent::noNotificationClassForEvent($event);
}
return new $notificationClass($event);
}
protected function allBackupEventClasses(): array
{
return [
BackupHasFailed::class,
BackupWasSuccessful::class,
CleanupHasFailed::class,
CleanupWasSuccessful::class,
HealthyBackupWasFound::class,
UnhealthyBackupWasFound::class,
];
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace Spatie\Backup\Notifications;
use Illuminate\Notifications\Notifiable as NotifiableTrait;
class Notifiable
{
use NotifiableTrait;
public function routeNotificationForMail()
{
return config('backup.notifications.mail.to');
}
public function routeNotificationForSlack()
{
return config('backup.notifications.slack.webhook_url');
}
public function getKey()
{
return 1;
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace Spatie\Backup\Notifications\Notifications;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Messages\SlackAttachment;
use Illuminate\Notifications\Messages\SlackMessage;
use Spatie\Backup\Events\BackupHasFailed as BackupHasFailedEvent;
use Spatie\Backup\Notifications\BaseNotification;
class BackupHasFailed extends BaseNotification
{
/** @var \Spatie\Backup\Events\BackupHasFailed */
protected $event;
public function __construct(BackupHasFailedEvent $event)
{
$this->event = $event;
}
public function toMail(): MailMessage
{
$mailMessage = (new MailMessage)
->error()
->from(config('backup.notifications.mail.from.address', config('mail.from.address')), config('backup.notifications.mail.from.name', config('mail.from.name')))
->subject(trans('backup::notifications.backup_failed_subject', ['application_name' => $this->applicationName()]))
->line(trans('backup::notifications.backup_failed_body', ['application_name' => $this->applicationName()]))
->line(trans('backup::notifications.exception_message', ['message' => $this->event->exception->getMessage()]))
->line(trans('backup::notifications.exception_trace', ['trace' => $this->event->exception->getTraceAsString()]));
$this->backupDestinationProperties()->each(function ($value, $name) use ($mailMessage) {
$mailMessage->line("{$name}: $value");
});
return $mailMessage;
}
public function toSlack(): SlackMessage
{
return (new SlackMessage)
->error()
->from(config('backup.notifications.slack.username'), config('backup.notifications.slack.icon'))
->to(config('backup.notifications.slack.channel'))
->content(trans('backup::notifications.backup_failed_subject', ['application_name' => $this->applicationName()]))
->attachment(function (SlackAttachment $attachment) {
$attachment
->title(trans('backup::notifications.exception_message_title'))
->content($this->event->exception->getMessage());
})
->attachment(function (SlackAttachment $attachment) {
$attachment
->title(trans('backup::notifications.exception_trace_title'))
->content($this->event->exception->getTraceAsString());
})
->attachment(function (SlackAttachment $attachment) {
$attachment->fields($this->backupDestinationProperties()->toArray());
});
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace Spatie\Backup\Notifications\Notifications;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Messages\SlackAttachment;
use Illuminate\Notifications\Messages\SlackMessage;
use Spatie\Backup\Events\BackupWasSuccessful as BackupWasSuccessfulEvent;
use Spatie\Backup\Notifications\BaseNotification;
class BackupWasSuccessful extends BaseNotification
{
/** @var \Spatie\Backup\Events\BackupWasSuccessful */
protected $event;
public function __construct(BackupWasSuccessfulEvent $event)
{
$this->event = $event;
}
public function toMail(): MailMessage
{
$mailMessage = (new MailMessage)
->from(config('backup.notifications.mail.from.address', config('mail.from.address')), config('backup.notifications.mail.from.name', config('mail.from.name')))
->subject(trans('backup::notifications.backup_successful_subject', ['application_name' => $this->applicationName()]))
->line(trans('backup::notifications.backup_successful_body', ['application_name' => $this->applicationName(), 'disk_name' => $this->diskName()]));
$this->backupDestinationProperties()->each(function ($value, $name) use ($mailMessage) {
$mailMessage->line("{$name}: $value");
});
return $mailMessage;
}
public function toSlack(): SlackMessage
{
return (new SlackMessage)
->success()
->from(config('backup.notifications.slack.username'), config('backup.notifications.slack.icon'))
->to(config('backup.notifications.slack.channel'))
->content(trans('backup::notifications.backup_successful_subject_title'))
->attachment(function (SlackAttachment $attachment) {
$attachment->fields($this->backupDestinationProperties()->toArray());
});
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace Spatie\Backup\Notifications\Notifications;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Messages\SlackAttachment;
use Illuminate\Notifications\Messages\SlackMessage;
use Spatie\Backup\Events\CleanupHasFailed as CleanupHasFailedEvent;
use Spatie\Backup\Notifications\BaseNotification;
class CleanupHasFailed extends BaseNotification
{
/** @var \Spatie\Backup\Events\CleanupHasFailed */
protected $event;
public function __construct(CleanupHasFailedEvent $event)
{
$this->event = $event;
}
public function toMail(): MailMessage
{
$mailMessage = (new MailMessage)
->error()
->from(config('backup.notifications.mail.from.address', config('mail.from.address')), config('backup.notifications.mail.from.name', config('mail.from.name')))
->subject(trans('backup::notifications.cleanup_failed_subject', ['application_name' => $this->applicationName()]))
->line(trans('backup::notifications.cleanup_failed_body', ['application_name' => $this->applicationName()]))
->line(trans('backup::notifications.exception_message', ['message' => $this->event->exception->getMessage()]))
->line(trans('backup::notifications.exception_trace', ['trace' => $this->event->exception->getTraceAsString()]));
$this->backupDestinationProperties()->each(function ($value, $name) use ($mailMessage) {
$mailMessage->line("{$name}: $value");
});
return $mailMessage;
}
public function toSlack(): SlackMessage
{
return (new SlackMessage)
->error()
->from(config('backup.notifications.slack.username'), config('backup.notifications.slack.icon'))
->to(config('backup.notifications.slack.channel'))
->content(trans('backup::notifications.cleanup_failed_subject', ['application_name' => $this->applicationName()]))
->attachment(function (SlackAttachment $attachment) {
$attachment
->title(trans('backup::notifications.exception_message_title'))
->content($this->event->exception->getMessage());
})
->attachment(function (SlackAttachment $attachment) {
$attachment
->title(trans('backup::notifications.exception_message_trace'))
->content($this->event->exception->getTraceAsString());
})
->attachment(function (SlackAttachment $attachment) {
$attachment->fields($this->backupDestinationProperties()->toArray());
});
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace Spatie\Backup\Notifications\Notifications;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Messages\SlackAttachment;
use Illuminate\Notifications\Messages\SlackMessage;
use Spatie\Backup\Events\CleanupWasSuccessful as CleanupWasSuccessfulEvent;
use Spatie\Backup\Notifications\BaseNotification;
class CleanupWasSuccessful extends BaseNotification
{
/** @var \Spatie\Backup\Events\CleanupWasSuccessful */
protected $event;
public function __construct(CleanupWasSuccessfulEvent $event)
{
$this->event = $event;
}
public function toMail(): MailMessage
{
$mailMessage = (new MailMessage)
->from(config('backup.notifications.mail.from.address', config('mail.from.address')), config('backup.notifications.mail.from.name', config('mail.from.name')))
->subject(trans('backup::notifications.cleanup_successful_subject', ['application_name' => $this->applicationName()]))
->line(trans('backup::notifications.cleanup_successful_body', ['application_name' => $this->applicationName(), 'disk_name' => $this->diskName()]));
$this->backupDestinationProperties()->each(function ($value, $name) use ($mailMessage) {
$mailMessage->line("{$name}: $value");
});
return $mailMessage;
}
public function toSlack(): SlackMessage
{
return (new SlackMessage)
->success()
->from(config('backup.notifications.slack.username'), config('backup.notifications.slack.icon'))
->to(config('backup.notifications.slack.channel'))
->content(trans('backup::notifications.cleanup_successful_subject_title'))
->attachment(function (SlackAttachment $attachment) {
$attachment->fields($this->backupDestinationProperties()->toArray());
});
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace Spatie\Backup\Notifications\Notifications;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Messages\SlackAttachment;
use Illuminate\Notifications\Messages\SlackMessage;
use Spatie\Backup\Events\HealthyBackupWasFound as HealthyBackupWasFoundEvent;
use Spatie\Backup\Notifications\BaseNotification;
class HealthyBackupWasFound extends BaseNotification
{
/** @var \Spatie\Backup\Events\HealthyBackupWasFound */
protected $event;
public function __construct(HealthyBackupWasFoundEvent $event)
{
$this->event = $event;
}
public function toMail(): MailMessage
{
$mailMessage = (new MailMessage)
->from(config('backup.notifications.mail.from.address', config('mail.from.address')), config('backup.notifications.mail.from.name', config('mail.from.name')))
->subject(trans('backup::notifications.healthy_backup_found_subject', ['application_name' => $this->applicationName(), 'disk_name' => $this->diskName()]))
->line(trans('backup::notifications.healthy_backup_found_body', ['application_name' => $this->applicationName()]));
$this->backupDestinationProperties()->each(function ($value, $name) use ($mailMessage) {
$mailMessage->line("{$name}: $value");
});
return $mailMessage;
}
public function toSlack(): SlackMessage
{
return (new SlackMessage)
->success()
->from(config('backup.notifications.slack.username'), config('backup.notifications.slack.icon'))
->to(config('backup.notifications.slack.channel'))
->content(trans('backup::notifications.healthy_backup_found_subject_title', ['application_name' => $this->applicationName()]))
->attachment(function (SlackAttachment $attachment) {
$attachment->fields($this->backupDestinationProperties()->toArray());
});
}
}

View File

@@ -0,0 +1,91 @@
<?php
namespace Spatie\Backup\Notifications\Notifications;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Messages\SlackAttachment;
use Illuminate\Notifications\Messages\SlackMessage;
use Spatie\Backup\Events\UnhealthyBackupWasFound as UnhealthyBackupWasFoundEvent;
use Spatie\Backup\Notifications\BaseNotification;
use Spatie\Backup\Tasks\Monitor\HealthCheckFailure;
class UnhealthyBackupWasFound extends BaseNotification
{
/** @var \Spatie\Backup\Events\UnhealthyBackupWasFound */
protected $event;
public function __construct(UnhealthyBackupWasFoundEvent $event)
{
$this->event = $event;
}
public function toMail(): MailMessage
{
$mailMessage = (new MailMessage)
->error()
->from(config('backup.notifications.mail.from.address', config('mail.from.address')), config('backup.notifications.mail.from.name', config('mail.from.name')))
->subject(trans('backup::notifications.unhealthy_backup_found_subject', ['application_name' => $this->applicationName()]))
->line(trans('backup::notifications.unhealthy_backup_found_body', ['application_name' => $this->applicationName(), 'disk_name' => $this->diskName()]))
->line($this->problemDescription());
$this->backupDestinationProperties()->each(function ($value, $name) use ($mailMessage) {
$mailMessage->line("{$name}: $value");
});
if ($this->failure()->wasUnexpected()) {
$mailMessage
->line('Health check: '.$this->failure()->healthCheck()->name())
->line(trans('backup::notifications.exception_message', ['message' => $this->failure()->exception()->getMessage()]))
->line(trans('backup::notifications.exception_trace', ['trace' => $this->failure()->exception()->getTraceAsString()]));
}
return $mailMessage;
}
public function toSlack(): SlackMessage
{
$slackMessage = (new SlackMessage)
->error()
->from(config('backup.notifications.slack.username'), config('backup.notifications.slack.icon'))
->to(config('backup.notifications.slack.channel'))
->content(trans('backup::notifications.unhealthy_backup_found_subject_title', ['application_name' => $this->applicationName(), 'problem' => $this->problemDescription()]))
->attachment(function (SlackAttachment $attachment) {
$attachment->fields($this->backupDestinationProperties()->toArray());
});
if ($this->failure()->wasUnexpected()) {
$slackMessage
->attachment(function (SlackAttachment $attachment) {
$attachment
->title('Health check')
->content($this->failure()->healthCheck()->name());
})
->attachment(function (SlackAttachment $attachment) {
$attachment
->title(trans('backup::notifications.exception_message_title'))
->content($this->failure()->exception()->getMessage());
})
->attachment(function (SlackAttachment $attachment) {
$attachment
->title(trans('backup::notifications.exception_trace_title'))
->content($this->failure()->exception()->getTraceAsString());
});
}
return $slackMessage;
}
protected function problemDescription(): string
{
if ($this->failure()->wasUnexpected()) {
return trans('backup::notifications.unhealthy_backup_found_unknown');
}
return $this->failure()->exception()->getMessage();
}
protected function failure(): HealthCheckFailure
{
return $this->event->backupDestinationStatus->getHealthCheckFailure();
}
}

View File

@@ -0,0 +1,306 @@
<?php
namespace Spatie\Backup\Tasks\Backup;
use Carbon\Carbon;
use Exception;
use Illuminate\Support\Collection;
use Spatie\Backup\BackupDestination\BackupDestination;
use Spatie\Backup\Events\BackupHasFailed;
use Spatie\Backup\Events\BackupManifestWasCreated;
use Spatie\Backup\Events\BackupWasSuccessful;
use Spatie\Backup\Events\BackupZipWasCreated;
use Spatie\Backup\Events\DumpingDatabase;
use Spatie\Backup\Exceptions\InvalidBackupJob;
use Spatie\DbDumper\Compressors\GzipCompressor;
use Spatie\DbDumper\Databases\MongoDb;
use Spatie\DbDumper\Databases\Sqlite;
use Spatie\DbDumper\DbDumper;
use Spatie\TemporaryDirectory\TemporaryDirectory;
class BackupJob
{
public const FILENAME_FORMAT = 'Y-m-d-H-i-s.\z\i\p';
/** @var \Spatie\Backup\Tasks\Backup\FileSelection */
protected $fileSelection;
/** @var \Illuminate\Support\Collection */
protected $dbDumpers;
/** @var \Illuminate\Support\Collection */
protected $backupDestinations;
/** @var string */
protected $filename;
/** @var \Spatie\TemporaryDirectory\TemporaryDirectory */
protected $temporaryDirectory;
/** @var bool */
protected $sendNotifications = true;
public function __construct()
{
$this->dontBackupFilesystem();
$this->dontBackupDatabases();
$this->setDefaultFilename();
$this->backupDestinations = new Collection();
}
public function dontBackupFilesystem(): self
{
$this->fileSelection = FileSelection::create();
return $this;
}
public function onlyDbName(array $allowedDbNames): self
{
$this->dbDumpers = $this->dbDumpers->filter(
function (DbDumper $dbDumper, string $connectionName) use ($allowedDbNames) {
return in_array($connectionName, $allowedDbNames);
}
);
return $this;
}
public function dontBackupDatabases(): self
{
$this->dbDumpers = new Collection();
return $this;
}
public function disableNotifications(): self
{
$this->sendNotifications = false;
return $this;
}
public function setDefaultFilename(): self
{
$this->filename = Carbon::now()->format(static::FILENAME_FORMAT);
return $this;
}
public function setFileSelection(FileSelection $fileSelection): self
{
$this->fileSelection = $fileSelection;
return $this;
}
public function setDbDumpers(Collection $dbDumpers): self
{
$this->dbDumpers = $dbDumpers;
return $this;
}
public function setFilename(string $filename): self
{
$this->filename = $filename;
return $this;
}
public function onlyBackupTo(string $diskName): self
{
$this->backupDestinations = $this->backupDestinations->filter(function (BackupDestination $backupDestination) use ($diskName) {
return $backupDestination->diskName() === $diskName;
});
if (! count($this->backupDestinations)) {
throw InvalidBackupJob::destinationDoesNotExist($diskName);
}
return $this;
}
public function setBackupDestinations(Collection $backupDestinations): self
{
$this->backupDestinations = $backupDestinations;
return $this;
}
public function run()
{
$temporaryDirectoryPath = config('backup.backup.temporary_directory') ?? storage_path('app/backup-temp');
$this->temporaryDirectory = (new TemporaryDirectory($temporaryDirectoryPath))
->name('temp')
->force()
->create()
->empty();
try {
if (! count($this->backupDestinations)) {
throw InvalidBackupJob::noDestinationsSpecified();
}
$manifest = $this->createBackupManifest();
if (! $manifest->count()) {
throw InvalidBackupJob::noFilesToBeBackedUp();
}
$zipFile = $this->createZipContainingEveryFileInManifest($manifest);
$this->copyToBackupDestinations($zipFile);
} catch (Exception $exception) {
consoleOutput()->error("Backup failed because {$exception->getMessage()}.".PHP_EOL.$exception->getTraceAsString());
$this->sendNotification(new BackupHasFailed($exception));
$this->temporaryDirectory->delete();
throw $exception;
}
$this->temporaryDirectory->delete();
}
protected function createBackupManifest(): Manifest
{
$databaseDumps = $this->dumpDatabases();
consoleOutput()->info('Determining files to backup...');
$manifest = Manifest::create($this->temporaryDirectory->path('manifest.txt'))
->addFiles($databaseDumps)
->addFiles($this->filesToBeBackedUp());
$this->sendNotification(new BackupManifestWasCreated($manifest));
return $manifest;
}
public function filesToBeBackedUp()
{
$this->fileSelection->excludeFilesFrom($this->directoriesUsedByBackupJob());
return $this->fileSelection->selectedFiles();
}
protected function directoriesUsedByBackupJob(): array
{
return $this->backupDestinations
->filter(function (BackupDestination $backupDestination) {
return $backupDestination->filesystemType() === 'local';
})
->map(function (BackupDestination $backupDestination) {
return $backupDestination->disk()->getDriver()->getAdapter()->applyPathPrefix('').$backupDestination->backupName();
})
->each(function (string $backupDestinationDirectory) {
$this->fileSelection->excludeFilesFrom($backupDestinationDirectory);
})
->push($this->temporaryDirectory->path())
->toArray();
}
protected function createZipContainingEveryFileInManifest(Manifest $manifest)
{
consoleOutput()->info("Zipping {$manifest->count()} files and directories...");
$pathToZip = $this->temporaryDirectory->path(config('backup.backup.destination.filename_prefix').$this->filename);
$zip = Zip::createForManifest($manifest, $pathToZip);
consoleOutput()->info("Created zip containing {$zip->count()} files and directories. Size is {$zip->humanReadableSize()}");
if ($this->sendNotifications) {
$this->sendNotification(new BackupZipWasCreated($pathToZip));
} else {
app()->call('\Spatie\Backup\Listeners\EncryptBackupArchive@handle', ['event' => new BackupZipWasCreated($pathToZip)]);
}
return $pathToZip;
}
/**
* Dumps the databases to the given directory.
* Returns an array with paths to the dump files.
*
* @return array
*/
protected function dumpDatabases(): array
{
return $this->dbDumpers->map(function (DbDumper $dbDumper, $key) {
consoleOutput()->info("Dumping database {$dbDumper->getDbName()}...");
$dbType = mb_strtolower(basename(str_replace('\\', '/', get_class($dbDumper))));
$dbName = $dbDumper->getDbName();
if ($dbDumper instanceof Sqlite) {
$dbName = $key.'-database';
}
$fileName = "{$dbType}-{$dbName}.{$this->getExtension($dbDumper)}";
if (config('backup.backup.gzip_database_dump')) {
$dbDumper->useCompressor(new GzipCompressor());
$fileName .= '.'.$dbDumper->getCompressorExtension();
}
if ($compressor = config('backup.backup.database_dump_compressor')) {
$dbDumper->useCompressor(new $compressor());
$fileName .= '.'.$dbDumper->getCompressorExtension();
}
$temporaryFilePath = $this->temporaryDirectory->path('db-dumps'.DIRECTORY_SEPARATOR.$fileName);
event(new DumpingDatabase($dbDumper));
$dbDumper->dumpToFile($temporaryFilePath);
return $temporaryFilePath;
})->toArray();
}
protected function copyToBackupDestinations(string $path)
{
$this->backupDestinations->each(function (BackupDestination $backupDestination) use ($path) {
try {
consoleOutput()->info("Copying zip to disk named {$backupDestination->diskName()}...");
$backupDestination->write($path);
consoleOutput()->info("Successfully copied zip to disk named {$backupDestination->diskName()}.");
$this->sendNotification(new BackupWasSuccessful($backupDestination));
} catch (Exception $exception) {
consoleOutput()->error("Copying zip failed because: {$exception->getMessage()}.");
$this->sendNotification(new BackupHasFailed($exception, $backupDestination ?? null));
}
});
}
protected function sendNotification($notification)
{
if ($this->sendNotifications) {
rescue(function () use ($notification) {
event($notification);
}, function () {
consoleOutput()->error('Sending notification failed');
});
}
}
protected function getExtension(DbDumper $dbDumper): string
{
if ($extension = config('backup.backup.database_dump_file_extension')) {
return $extension;
}
return $dbDumper instanceof MongoDb
? 'archive'
: 'sql';
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace Spatie\Backup\Tasks\Backup;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Spatie\Backup\BackupDestination\BackupDestinationFactory;
class BackupJobFactory
{
public static function createFromArray(array $config): BackupJob
{
return (new BackupJob())
->setFileSelection(static::createFileSelection($config['backup']['source']['files']))
->setDbDumpers(static::createDbDumpers($config['backup']['source']['databases']))
->setBackupDestinations(BackupDestinationFactory::createFromArray($config['backup']));
}
protected static function createFileSelection(array $sourceFiles): FileSelection
{
return FileSelection::create($sourceFiles['include'])
->excludeFilesFrom($sourceFiles['exclude'])
->shouldFollowLinks(isset($sourceFiles['follow_links']) && $sourceFiles['follow_links'])
->shouldIgnoreUnreadableDirs(Arr::get($sourceFiles, 'ignore_unreadable_directories', false));
}
protected static function createDbDumpers(array $dbConnectionNames): Collection
{
return collect($dbConnectionNames)->mapWithKeys(function (string $dbConnectionName) {
return [$dbConnectionName => DbDumperFactory::createFromConnection($dbConnectionName)];
});
}
}

View File

@@ -0,0 +1,138 @@
<?php
namespace Spatie\Backup\Tasks\Backup;
use Exception;
use Illuminate\Database\ConfigurationUrlParser;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Spatie\Backup\Exceptions\CannotCreateDbDumper;
use Spatie\DbDumper\Databases\MongoDb;
use Spatie\DbDumper\Databases\MySql;
use Spatie\DbDumper\Databases\PostgreSql;
use Spatie\DbDumper\Databases\Sqlite;
use Spatie\DbDumper\DbDumper;
class DbDumperFactory
{
protected static $custom = [];
public static function createFromConnection(string $dbConnectionName): DbDumper
{
$parser = new ConfigurationUrlParser();
if (config("database.connections.{$dbConnectionName}") === null) {
throw CannotCreateDbDumper::unsupportedDriver($dbConnectionName);
}
try {
$dbConfig = $parser->parseConfiguration(config("database.connections.{$dbConnectionName}"));
} catch (Exception $e) {
throw CannotCreateDbDumper::unsupportedDriver($dbConnectionName);
}
if (isset($dbConfig['read'])) {
$dbConfig = Arr::except(
array_merge($dbConfig, $dbConfig['read']),
['read', 'write']
);
}
$dbDumper = static::forDriver($dbConfig['driver'] ?? '')
->setHost(Arr::first(Arr::wrap($dbConfig['host'] ?? '')))
->setDbName($dbConfig['database'])
->setUserName($dbConfig['username'] ?? '')
->setPassword($dbConfig['password'] ?? '');
if ($dbDumper instanceof MySql) {
$dbDumper->setDefaultCharacterSet($dbConfig['charset'] ?? '');
}
if ($dbDumper instanceof MongoDb) {
$dbDumper->setAuthenticationDatabase($dbConfig['dump']['mongodb_user_auth'] ?? '');
}
if (isset($dbConfig['port'])) {
$dbDumper = $dbDumper->setPort($dbConfig['port']);
}
if (isset($dbConfig['dump'])) {
$dbDumper = static::processExtraDumpParameters($dbConfig['dump'], $dbDumper);
}
if (isset($dbConfig['unix_socket'])) {
$dbDumper = $dbDumper->setSocket($dbConfig['unix_socket']);
}
return $dbDumper;
}
public static function extend(string $driver, callable $callback)
{
static::$custom[$driver] = $callback;
}
protected static function forDriver($dbDriver): DbDumper
{
$driver = strtolower($dbDriver);
if (isset(static::$custom[$driver])) {
return (static::$custom[$driver])();
}
if ($driver === 'mysql' || $driver === 'mariadb') {
return new MySql();
}
if ($driver === 'pgsql') {
return new PostgreSql();
}
if ($driver === 'sqlite') {
return new Sqlite();
}
if ($driver === 'mongodb') {
return new MongoDb();
}
throw CannotCreateDbDumper::unsupportedDriver($driver);
}
protected static function processExtraDumpParameters(array $dumpConfiguration, DbDumper $dbDumper): DbDumper
{
collect($dumpConfiguration)->each(function ($configValue, $configName) use ($dbDumper) {
$methodName = lcfirst(Str::studly(is_numeric($configName) ? $configValue : $configName));
$methodValue = is_numeric($configName) ? null : $configValue;
$methodName = static::determineValidMethodName($dbDumper, $methodName);
if (method_exists($dbDumper, $methodName)) {
static::callMethodOnDumper($dbDumper, $methodName, $methodValue);
}
});
return $dbDumper;
}
protected static function callMethodOnDumper(DbDumper $dbDumper, string $methodName, $methodValue): DbDumper
{
if (! $methodValue) {
$dbDumper->$methodName();
return $dbDumper;
}
$dbDumper->$methodName($methodValue);
return $dbDumper;
}
protected static function determineValidMethodName(DbDumper $dbDumper, string $methodName): string
{
return collect([$methodName, 'set'.ucfirst($methodName)])
->first(function (string $methodName) use ($dbDumper) {
return method_exists($dbDumper, $methodName);
}, '');
}
}

View File

@@ -0,0 +1,186 @@
<?php
namespace Spatie\Backup\Tasks\Backup;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Symfony\Component\Finder\Finder;
class FileSelection
{
/** @var \Illuminate\Support\Collection */
protected $includeFilesAndDirectories;
/** @var \Illuminate\Support\Collection */
protected $excludeFilesAndDirectories;
/** @var bool */
protected $shouldFollowLinks = false;
/** @var bool */
protected $shouldIgnoreUnreadableDirs = false;
/**
* @param array|string $includeFilesAndDirectories
*
* @return \Spatie\Backup\Tasks\Backup\FileSelection
*/
public static function create($includeFilesAndDirectories = []): self
{
return new static($includeFilesAndDirectories);
}
/**
* @param array|string $includeFilesAndDirectories
*/
public function __construct($includeFilesAndDirectories = [])
{
$this->includeFilesAndDirectories = collect($includeFilesAndDirectories);
$this->excludeFilesAndDirectories = collect();
}
/**
* Do not included the given files and directories.
*
* @param array|string $excludeFilesAndDirectories
*
* @return \Spatie\Backup\Tasks\Backup\FileSelection
*/
public function excludeFilesFrom($excludeFilesAndDirectories): self
{
$this->excludeFilesAndDirectories = $this->excludeFilesAndDirectories->merge($this->sanitize($excludeFilesAndDirectories));
return $this;
}
public function shouldFollowLinks(bool $shouldFollowLinks): self
{
$this->shouldFollowLinks = $shouldFollowLinks;
return $this;
}
/**
* Set if it should ignore the unreadable directories.
*
* @param bool $ignoreUnreadableDirs
*
* @return \Spatie\Backup\Tasks\Backup\FileSelection
*/
public function shouldIgnoreUnreadableDirs(bool $ignoreUnreadableDirs): self
{
$this->shouldIgnoreUnreadableDirs = $ignoreUnreadableDirs;
return $this;
}
/**
* @return \Generator|string[]
*/
public function selectedFiles()
{
if ($this->includeFilesAndDirectories->isEmpty()) {
return [];
}
$finder = (new Finder())
->ignoreDotFiles(false)
->ignoreVCS(false);
if ($this->shouldFollowLinks) {
$finder->followLinks();
}
if ($this->shouldIgnoreUnreadableDirs) {
$finder->ignoreUnreadableDirs();
}
foreach ($this->includedFiles() as $includedFile) {
yield $includedFile;
}
if (! count($this->includedDirectories())) {
return;
}
$finder->in($this->includedDirectories());
foreach ($finder->getIterator() as $file) {
if ($this->shouldExclude($file)) {
continue;
}
yield $file->getPathname();
}
}
protected function includedFiles(): array
{
return $this->includeFilesAndDirectories->filter(function ($path) {
return is_file($path);
})->toArray();
}
protected function includedDirectories(): array
{
return $this->includeFilesAndDirectories->reject(function ($path) {
return is_file($path);
})->toArray();
}
protected function shouldExclude(string $path): bool
{
$path = realpath($path);
if (is_dir($path)) {
$path .= DIRECTORY_SEPARATOR ;
}
foreach ($this->excludeFilesAndDirectories as $excludedPath) {
if (Str::startsWith($path, $excludedPath.(is_dir($excludedPath) ? DIRECTORY_SEPARATOR : ''))) {
if ($path != $excludedPath && is_file($excludedPath)) {
continue;
}
return true;
}
}
return false;
}
/**
* @param string|array $paths
*
* @return \Illuminate\Support\Collection
*/
protected function sanitize($paths): Collection
{
return collect($paths)
->reject(function ($path) {
return $path === '';
})
->flatMap(function ($path) {
return $this->getMatchingPaths($path);
})
->map(function ($path) {
return realpath($path);
})
->reject(function ($path) {
return $path === false;
});
}
protected function getMatchingPaths(string $path): array
{
if ($this->canUseGlobBrace($path)) {
return glob(str_replace('*', '{.[!.],}*', $path), GLOB_BRACE);
}
return glob($path);
}
protected function canUseGlobBrace(string $path): bool
{
return strpos($path, '*') !== false && defined('GLOB_BRACE');
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace Spatie\Backup\Tasks\Backup;
use Countable;
use SplFileObject;
class Manifest implements Countable
{
/** @var string */
protected $manifestPath;
public static function create(string $manifestPath): self
{
return new static($manifestPath);
}
public function __construct(string $manifestPath)
{
$this->manifestPath = $manifestPath;
touch($manifestPath);
}
public function path(): string
{
return $this->manifestPath;
}
/**
* @param array|string $filePaths
*
* @return $this
*/
public function addFiles($filePaths): self
{
if (is_string($filePaths)) {
$filePaths = [$filePaths];
}
foreach ($filePaths as $filePath) {
if (! empty($filePath)) {
file_put_contents($this->manifestPath, $filePath.PHP_EOL, FILE_APPEND);
}
}
return $this;
}
/**
* @return \Generator|string[]
*/
public function files()
{
$file = new SplFileObject($this->path());
while (! $file->eof()) {
$filePath = $file->fgets();
if (! empty($filePath)) {
yield trim($filePath);
}
}
}
public function count(): int
{
$file = new SplFileObject($this->manifestPath, 'r');
$file->seek(PHP_INT_MAX);
return $file->key();
}
}

View File

@@ -0,0 +1,127 @@
<?php
namespace Spatie\Backup\Tasks\Backup;
use Illuminate\Support\Str;
use Spatie\Backup\Helpers\Format;
use ZipArchive;
class Zip
{
/** @var \ZipArchive */
protected $zipFile;
/** @var int */
protected $fileCount = 0;
/** @var string */
protected $pathToZip;
public static function createForManifest(Manifest $manifest, string $pathToZip): self
{
$relativePath = config('backup.backup.source.files.relative_path') ?
rtrim(config('backup.backup.source.files.relative_path'), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR : false;
$zip = new static($pathToZip);
$zip->open();
foreach ($manifest->files() as $file) {
$zip->add($file, self::determineNameOfFileInZip($file, $pathToZip, $relativePath));
}
$zip->close();
return $zip;
}
protected static function determineNameOfFileInZip(string $pathToFile, string $pathToZip, string $relativePath)
{
$fileDirectory = pathinfo($pathToFile, PATHINFO_DIRNAME) . DIRECTORY_SEPARATOR;
$zipDirectory = pathinfo($pathToZip, PATHINFO_DIRNAME) . DIRECTORY_SEPARATOR;
if (Str::startsWith($fileDirectory, $zipDirectory)) {
return str_replace($zipDirectory, '', $pathToFile);
}
if ($relativePath && $relativePath != DIRECTORY_SEPARATOR && Str::startsWith($fileDirectory, $relativePath)) {
return str_replace($relativePath, '', $pathToFile);
}
return $pathToFile;
}
public function __construct(string $pathToZip)
{
$this->zipFile = new ZipArchive();
$this->pathToZip = $pathToZip;
$this->open();
}
public function path(): string
{
return $this->pathToZip;
}
public function size(): float
{
if ($this->fileCount === 0) {
return 0;
}
return filesize($this->pathToZip);
}
public function humanReadableSize(): string
{
return Format::humanReadableSize($this->size());
}
public function open()
{
$this->zipFile->open($this->pathToZip, ZipArchive::CREATE);
}
public function close()
{
$this->zipFile->close();
}
/**
* @param string|array $files
* @param string $nameInZip
*
* @return \Spatie\Backup\Tasks\Backup\Zip
*/
public function add($files, string $nameInZip = null): self
{
if (is_array($files)) {
$nameInZip = null;
}
if (is_string($files)) {
$files = [$files];
}
foreach ($files as $file) {
if (is_dir($file)) {
$this->zipFile->addEmptyDir(ltrim($nameInZip ?: $file, DIRECTORY_SEPARATOR));
}
if (is_file($file)) {
$this->zipFile->addFile($file, ltrim($nameInZip, DIRECTORY_SEPARATOR)).PHP_EOL;
}
$this->fileCount++;
}
return $this;
}
public function count(): int
{
return $this->fileCount;
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace Spatie\Backup\Tasks\Cleanup;
use Exception;
use Illuminate\Support\Collection;
use Spatie\Backup\BackupDestination\BackupDestination;
use Spatie\Backup\Events\CleanupHasFailed;
use Spatie\Backup\Events\CleanupWasSuccessful;
use Spatie\Backup\Helpers\Format;
class CleanupJob
{
/** @var \Illuminate\Support\Collection */
protected $backupDestinations;
/** @var \Spatie\Backup\Tasks\Cleanup\CleanupStrategy */
protected $strategy;
/** @var bool */
protected $sendNotifications = true;
public function __construct(Collection $backupDestinations, CleanupStrategy $strategy, bool $disableNotifications = false)
{
$this->backupDestinations = $backupDestinations;
$this->strategy = $strategy;
$this->sendNotifications = ! $disableNotifications;
}
public function run()
{
$this->backupDestinations->each(function (BackupDestination $backupDestination) {
try {
if (! $backupDestination->isReachable()) {
throw new Exception("Could not connect to disk {$backupDestination->diskName()} because: {$backupDestination->connectionError()}");
}
consoleOutput()->info("Cleaning backups of {$backupDestination->backupName()} on disk {$backupDestination->diskName()}...");
$this->strategy
->setBackupDestination($backupDestination)
->deleteOldBackups($backupDestination->backups());
$this->sendNotification(new CleanupWasSuccessful($backupDestination));
$usedStorage = Format::humanReadableSize($backupDestination->fresh()->usedStorage());
consoleOutput()->info("Used storage after cleanup: {$usedStorage}.");
} catch (Exception $exception) {
consoleOutput()->error("Cleanup failed because: {$exception->getMessage()}.");
$this->sendNotification(new CleanupHasFailed($exception));
throw $exception;
}
});
}
protected function sendNotification($notification)
{
if ($this->sendNotifications) {
event($notification);
}
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace Spatie\Backup\Tasks\Cleanup;
use Illuminate\Contracts\Config\Repository;
use Spatie\Backup\BackupDestination\BackupCollection;
use Spatie\Backup\BackupDestination\BackupDestination;
abstract class CleanupStrategy
{
/** @var \Illuminate\Contracts\Config\Repository */
protected $config;
/** @var \Spatie\Backup\BackupDestination\BackupDestination */
protected $backupDestination;
public function __construct(Repository $config)
{
$this->config = $config;
}
abstract public function deleteOldBackups(BackupCollection $backups);
/**
* @param \Spatie\Backup\BackupDestination\BackupDestination $backupDestination
*
* @return $this
*/
public function setBackupDestination(BackupDestination $backupDestination)
{
$this->backupDestination = $backupDestination;
return $this;
}
/**
* @return \Spatie\Backup\BackupDestination\BackupDestination
*/
public function backupDestination(): BackupDestination
{
return $this->backupDestination;
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace Spatie\Backup\Tasks\Cleanup;
use Carbon\Carbon;
class Period
{
/** @var \Carbon\Carbon */
protected $startDate;
/** @var \Carbon\Carbon */
protected $endDate;
public function __construct(Carbon $startDate, Carbon $endDate)
{
$this->startDate = $startDate;
$this->endDate = $endDate;
}
public function startDate(): Carbon
{
return $this->startDate->copy();
}
public function endDate(): Carbon
{
return $this->endDate->copy();
}
}

View File

@@ -0,0 +1,117 @@
<?php
namespace Spatie\Backup\Tasks\Cleanup\Strategies;
use Carbon\Carbon;
use Illuminate\Support\Collection;
use Spatie\Backup\BackupDestination\Backup;
use Spatie\Backup\BackupDestination\BackupCollection;
use Spatie\Backup\Tasks\Cleanup\CleanupStrategy;
use Spatie\Backup\Tasks\Cleanup\Period;
class DefaultStrategy extends CleanupStrategy
{
/** @var \Spatie\Backup\BackupDestination\Backup */
protected $newestBackup;
public function deleteOldBackups(BackupCollection $backups)
{
// Don't ever delete the newest backup.
$this->newestBackup = $backups->shift();
$dateRanges = $this->calculateDateRanges();
$backupsPerPeriod = $dateRanges->map(function (Period $period) use ($backups) {
return $backups->filter(function (Backup $backup) use ($period) {
return $backup->date()->between($period->startDate(), $period->endDate());
});
});
$backupsPerPeriod['daily'] = $this->groupByDateFormat($backupsPerPeriod['daily'], 'Ymd');
$backupsPerPeriod['weekly'] = $this->groupByDateFormat($backupsPerPeriod['weekly'], 'YW');
$backupsPerPeriod['monthly'] = $this->groupByDateFormat($backupsPerPeriod['monthly'], 'Ym');
$backupsPerPeriod['yearly'] = $this->groupByDateFormat($backupsPerPeriod['yearly'], 'Y');
$this->removeBackupsForAllPeriodsExceptOne($backupsPerPeriod);
$this->removeBackupsOlderThan($dateRanges['yearly']->endDate(), $backups);
$this->removeOldBackupsUntilUsingLessThanMaximumStorage($backups);
}
protected function calculateDateRanges(): Collection
{
$config = $this->config->get('backup.cleanup.default_strategy');
$daily = new Period(
Carbon::now()->subDays($config['keep_all_backups_for_days']),
Carbon::now()
->subDays($config['keep_all_backups_for_days'])
->subDays($config['keep_daily_backups_for_days'])
);
$weekly = new Period(
$daily->endDate(),
$daily->endDate()
->subWeeks($config['keep_weekly_backups_for_weeks'])
);
$monthly = new Period(
$weekly->endDate(),
$weekly->endDate()
->subMonths($config['keep_monthly_backups_for_months'])
);
$yearly = new Period(
$monthly->endDate(),
$monthly->endDate()
->subYears($config['keep_yearly_backups_for_years'])
);
return collect(compact('daily', 'weekly', 'monthly', 'yearly'));
}
protected function groupByDateFormat(Collection $backups, string $dateFormat): Collection
{
return $backups->groupBy(function (Backup $backup) use ($dateFormat) {
return $backup->date()->format($dateFormat);
});
}
protected function removeBackupsForAllPeriodsExceptOne(Collection $backupsPerPeriod)
{
$backupsPerPeriod->each(function (Collection $groupedBackupsByDateProperty, string $periodName) {
$groupedBackupsByDateProperty->each(function (Collection $group) {
$group->shift();
$group->each->delete();
});
});
}
protected function removeBackupsOlderThan(Carbon $endDate, BackupCollection $backups)
{
$backups->filter(function (Backup $backup) use ($endDate) {
return $backup->exists() && $backup->date()->lt($endDate);
})->each->delete();
}
protected function removeOldBackupsUntilUsingLessThanMaximumStorage(BackupCollection $backups)
{
if (! $oldest = $backups->oldest()) {
return;
}
$maximumSize = $this->config->get('backup.cleanup.default_strategy.delete_oldest_backups_when_using_more_megabytes_than')
* 1024 * 1024;
if (($backups->size() + $this->newestBackup->size()) <= $maximumSize) {
return;
}
$oldest->delete();
$backups = $backups->filter->exists();
$this->removeOldBackupsUntilUsingLessThanMaximumStorage($backups);
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace Spatie\Backup\Tasks\Monitor;
use Exception;
use Illuminate\Support\Collection;
use Spatie\Backup\BackupDestination\BackupDestination;
use Spatie\Backup\Tasks\Monitor\HealthChecks\IsReachable;
class BackupDestinationStatus
{
/** @var \Spatie\Backup\BackupDestination\BackupDestination */
protected $backupDestination;
/** @var array */
protected $healthChecks;
/** @var HealthCheckFailure|null */
protected $healthCheckFailure;
public function __construct(BackupDestination $backupDestination, array $healthChecks = [])
{
$this->backupDestination = $backupDestination;
$this->healthChecks = $healthChecks;
}
public function backupDestination(): BackupDestination
{
return $this->backupDestination;
}
public function check(HealthCheck $check)
{
try {
$check->checkHealth($this->backupDestination());
} catch (Exception $exception) {
return new HealthCheckFailure($check, $exception);
}
return true;
}
public function getHealthChecks(): Collection
{
return collect($this->healthChecks)->prepend(new IsReachable());
}
public function getHealthCheckFailure(): ?HealthCheckFailure
{
return $this->healthCheckFailure;
}
public function isHealthy(): bool
{
$healthChecks = $this->getHealthChecks();
foreach ($healthChecks as $healthCheck) {
$checkResult = $this->check($healthCheck);
if ($checkResult instanceof HealthCheckFailure) {
$this->healthCheckFailure = $checkResult;
return false;
}
}
return true;
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace Spatie\Backup\Tasks\Monitor;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Spatie\Backup\BackupDestination\BackupDestination;
class BackupDestinationStatusFactory
{
public static function createForMonitorConfig(array $monitorConfiguration): Collection
{
return collect($monitorConfiguration)->flatMap(function (array $monitorProperties) {
return self::createForSingleMonitor($monitorProperties);
})->sortBy(function (BackupDestinationStatus $backupDestinationStatus) {
return $backupDestinationStatus->backupDestination()->backupName().'-'.
$backupDestinationStatus->backupDestination()->diskName();
});
}
public static function createForSingleMonitor(array $monitorConfig): Collection
{
return collect($monitorConfig['disks'])->map(function ($diskName) use ($monitorConfig) {
$backupDestination = BackupDestination::create($diskName, $monitorConfig['name']);
return new BackupDestinationStatus($backupDestination, static::buildHealthChecks($monitorConfig));
});
}
protected static function buildHealthChecks($monitorConfig)
{
return collect(Arr::get($monitorConfig, 'health_checks'))->map(function ($options, $class) {
if (is_int($class)) {
$class = $options;
$options = [];
}
return static::buildHealthCheck($class, $options);
})->toArray();
}
protected static function buildHealthCheck($class, $options)
{
// A single value was passed - we'll instantiate it manually assuming it's the first argument
if (! is_array($options)) {
return new $class($options);
}
// A config array was given. Use reflection to match arguments
return app()->makeWith($class, $options);
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace Spatie\Backup\Tasks\Monitor;
use Illuminate\Support\Str;
use Spatie\Backup\BackupDestination\BackupDestination;
use Spatie\Backup\Exceptions\InvalidHealthCheck;
abstract class HealthCheck
{
abstract public function checkHealth(BackupDestination $backupDestination);
public function name()
{
return Str::title(class_basename($this));
}
protected function fail(string $message)
{
throw InvalidHealthCheck::because($message);
}
protected function failIf(bool $condition, string $message)
{
if ($condition) {
$this->fail($message);
}
}
protected function failUnless(bool $condition, string $message)
{
if (! $condition) {
$this->fail($message);
}
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace Spatie\Backup\Tasks\Monitor;
use Exception;
use Spatie\Backup\Exceptions\InvalidHealthCheck;
class HealthCheckFailure
{
/** @var \Spatie\Backup\Tasks\Monitor */
protected $healthCheck;
/** @var \Exception */
protected $exception;
public function __construct(HealthCheck $healthCheck, Exception $exception)
{
$this->healthCheck = $healthCheck;
$this->exception = $exception;
}
public function healthCheck(): HealthCheck
{
return $this->healthCheck;
}
public function exception(): Exception
{
return $this->exception;
}
public function wasUnexpected(): bool
{
return ! $this->exception instanceof InvalidHealthCheck;
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace Spatie\Backup\Tasks\Monitor\HealthChecks;
use Spatie\Backup\BackupDestination\BackupDestination;
use Spatie\Backup\Tasks\Monitor\HealthCheck;
class IsReachable extends HealthCheck
{
public function checkHealth(BackupDestination $backupDestination)
{
$this->failUnless(
$backupDestination->isReachable(),
trans('backup::notification.unhealthy_backup_found_not_reachable', [
'error' => $backupDestination->connectionError,
])
);
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace Spatie\Backup\Tasks\Monitor\HealthChecks;
use Spatie\Backup\BackupDestination\Backup;
use Spatie\Backup\BackupDestination\BackupDestination;
use Spatie\Backup\Tasks\Monitor\HealthCheck;
class MaximumAgeInDays extends HealthCheck
{
/** @var int */
protected $days;
public function __construct($days = 1)
{
$this->days = $days;
}
public function checkHealth(BackupDestination $backupDestination)
{
$this->failIf(
$this->hasNoBackups($backupDestination),
trans('backup::notifications.unhealthy_backup_found_empty')
);
$newestBackup = $backupDestination->backups()->newest();
$this->failIf(
$this->isTooOld($newestBackup),
trans('backup::notifications.unhealthy_backup_found_old', ['date' => $newestBackup->date()->format('Y/m/d h:i:s')])
);
}
protected function hasNoBackups(BackupDestination $backupDestination)
{
return $backupDestination->backups()->isEmpty();
}
protected function isTooOld(Backup $backup)
{
if (is_null($this->days)) {
return false;
}
if ($backup->date()->gt(now()->subDays($this->days))) {
return false;
}
return true;
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace Spatie\Backup\Tasks\Monitor\HealthChecks;
use Spatie\Backup\BackupDestination\BackupDestination;
use Spatie\Backup\Helpers\Format;
use Spatie\Backup\Tasks\Monitor\HealthCheck;
class MaximumStorageInMegabytes extends HealthCheck
{
/** @var int */
protected $maximumSizeInMegaBytes;
public function __construct(int $maximumSizeInMegaBytes = 5000)
{
$this->maximumSizeInMegaBytes = $maximumSizeInMegaBytes;
}
public function checkHealth(BackupDestination $backupDestination)
{
$usageInBytes = $backupDestination->usedStorage();
$this->failIf(
$this->exceedsAllowance($usageInBytes),
trans('backup::notifications.unhealthy_backup_found_full', [
'disk_usage' => $this->humanReadableSize($usageInBytes),
'disk_limit' => $this->humanReadableSize($this->bytes($this->maximumSizeInMegaBytes)),
])
);
}
protected function exceedsAllowance(float $usageInBytes): bool
{
return $usageInBytes > $this->bytes($this->maximumSizeInMegaBytes);
}
protected function bytes(int $megaBytes): int
{
return $megaBytes * 1024 * 1024;
}
protected function humanReadableSize(float $sizeInBytes): string
{
return Format::humanReadableSize($sizeInBytes);
}
}