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,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);
}
}