database backups

This commit is contained in:
Saeed Vaziry
2023-08-25 21:05:18 +02:00
parent f9ac454a7c
commit ea3f011f34
55 changed files with 1111 additions and 172 deletions

View File

@ -1,14 +1,12 @@
<?php
namespace App\Actions\Backup;
namespace App\Actions\Database;
use App\Enums\BackupStatus;
use App\Enums\DatabaseStatus;
use App\Models\Backup;
use App\Models\Database;
use App\Models\Server;
use App\Models\User;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
@ -19,23 +17,18 @@ class CreateBackup
* @throws AuthorizationException
* @throws ValidationException
*/
public function create($type, Server $server, User $user, array $input): Backup
public function create($type, Server $server, array $input): Backup
{
$this->validate($type, $server, $user, $input);
if ($type == 'database') {
Gate::forUser($user)->authorize('viewAny', [Database::class, $server]);
}
$this->validate($type, $server, $input);
$backup = new Backup([
'name' => $input['name'],
'type' => $type,
'server_id' => $server->id,
'database_id' => $input['database'] ?? null,
'storage_id' => $input['storage'],
'interval' => $input['interval'],
'keep_backups' => $input['keep_backups'],
'status' => 'running',
'interval' => $input['interval'] == 'custom' ? $input['custom'] : $input['interval'],
'keep_backups' => $input['keep'],
'status' => BackupStatus::RUNNING,
]);
$backup->save();
@ -47,17 +40,14 @@ public function create($type, Server $server, User $user, array $input): Backup
/**
* @throws ValidationException
*/
private function validate($type, Server $server, User $user, array $input): void
private function validate($type, Server $server, array $input): void
{
$rules = [
'name' => 'required',
'storage' => [
'required',
Rule::exists('storage_providers', 'id')
->where('user_id', $user->id)
->where('connected', 1),
Rule::exists('storage_providers', 'id'),
],
'keep_backups' => [
'keep' => [
'required',
'numeric',
'min:1',
@ -69,9 +59,15 @@ private function validate($type, Server $server, User $user, array $input): void
'0 0 * * *',
'0 0 * * 0',
'0 0 1 * *',
'custom'
]),
],
];
if ($input['interval'] == 'custom') {
$rules['custom'] = [
'required',
];
}
if ($type === 'database') {
$rules['database'] = [
'required',
@ -80,6 +76,6 @@ private function validate($type, Server $server, User $user, array $input): void
->where('status', DatabaseStatus::READY),
];
}
Validator::make($input, $rules)->validateWithBag('createBackup');
Validator::make($input, $rules)->validate();
}
}

View File

@ -1,30 +0,0 @@
<?php
namespace App\Actions\StorageProvider;
use App\Models\StorageProvider;
use App\Models\User;
use Illuminate\Validation\ValidationException;
class AddStorageProvider
{
use ValidateProvider;
/**
* @throws ValidationException
*/
public function add(User $user, array $input): mixed
{
$this->validate($user, $input);
$storageProvider = new StorageProvider([
'user_id' => $user->id,
'provider' => $input['provider'],
'label' => $input['label'],
'connected' => false,
]);
$storageProvider->save();
return $storageProvider->provider()->connect();
}
}

View File

@ -0,0 +1,52 @@
<?php
namespace App\Actions\StorageProvider;
use App\Models\StorageProvider;
use App\Models\User;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
class CreateStorageProvider
{
/**
* @throws ValidationException
*/
public function create(User $user, array $input): void
{
$this->validate($user, $input);
$storageProvider = new StorageProvider([
'user_id' => $user->id,
'provider' => $input['provider'],
'profile' => $input['name'],
'credentials' => [
'token' => $input['token']
]
]);
if (! $storageProvider->provider()->connect()) {
throw ValidationException::withMessages([
'token' => __("Couldn't connect to the provider")
]);
}
$storageProvider->save();;
}
private function validate(User $user, array $input): void
{
Validator::make($input, [
'provider' => [
'required',
Rule::in(config('core.storage_providers')),
],
'name' => [
'required',
Rule::unique('storage_providers', 'profile')->where('user_id', $user->id),
],
'token' => [
'required'
]
])->validate();
}
}

View File

@ -1,34 +0,0 @@
<?php
namespace App\Actions\StorageProvider;
use App\Models\StorageProvider;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Laravel\Socialite\Facades\Socialite;
use Laravel\Socialite\Two\User;
use Throwable;
class HandleProviderCallback
{
public function callback(Request $request, string $provider): string|RedirectResponse
{
try {
$providerId = $request->session()->get('storage_provider_id');
/** @var StorageProvider $storageProvider */
$storageProvider = StorageProvider::query()->findOrFail($providerId);
/** @var User $oauthUser */
$oauthUser = Socialite::driver($provider)->user();
$storageProvider->token = $oauthUser->token;
$storageProvider->refresh_token = $oauthUser->refreshToken;
$storageProvider->token_expires_at = now()->addSeconds($oauthUser->expiresIn);
$storageProvider->connected = true;
$storageProvider->save();
/** @TODO toast success message */
} catch (Throwable) {
/** @TODO toast failed message */
}
return redirect()->route('storage-providers');
}
}

View File

@ -1,28 +0,0 @@
<?php
namespace App\Actions\StorageProvider;
use App\Models\User;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
trait ValidateProvider
{
/**
* @throws ValidationException
*/
private function validate(User $user, array $input): void
{
Validator::make($input, [
'label' => [
'required',
Rule::unique('storage_providers', 'label')->where('user_id', $user->id),
],
'provider' => [
'required',
Rule::in(config('core.storage_providers')),
],
])->validateWithBag('addStorageProvider');
}
}

View File

@ -0,0 +1,49 @@
<?php
namespace App\Console\Commands;
use App\Models\Backup;
use Illuminate\Console\Command;
class RunBackup extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'backups:run {interval}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Run backup';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*/
public function handle(): void
{
Backup::query()
->where('interval', $this->argument('interval'))
->where('status', 'running')
->chunk(100, function ($backups) {
/** @var Backup $backup */
foreach ($backups as $backup) {
$backup->run();
}
});
}
}

View File

@ -12,7 +12,10 @@ class Kernel extends ConsoleKernel
*/
protected function schedule(Schedule $schedule): void
{
// $schedule->command('inspire')->hourly();
$schedule->command('backups:run "0 * * * *"')->hourly();
$schedule->command('backups:run "0 0 * * *"')->daily();
$schedule->command('backups:run "0 0 * * 0"')->weekly();
$schedule->command('backups:run "0 0 1 * *"')->monthly();
}
/**

View File

@ -7,7 +7,7 @@
interface StorageProvider
{
public function connect(): RedirectResponse;
public function connect(): bool;
public function upload(Server $server, string $src, string $dest): array;

View File

@ -0,0 +1,22 @@
<?php
namespace App\Enums;
use BenSampo\Enum\Enum;
final class BackupFileStatus extends Enum
{
const CREATED = 'created';
const CREATING = 'creating';
const FAILED = 'failed';
const DELETING = 'deleting';
const RESTORING = 'restoring';
const RESTORED = 'restored';
const RESTORE_FAILED = 'restore_failed';
}

View File

@ -0,0 +1,16 @@
<?php
namespace App\Enums;
use BenSampo\Enum\Enum;
final class BackupStatus extends Enum
{
const READY = 'ready';
const RUNNING = 'running';
const FAILED = 'failed';
const DELETING = 'deleting';
}

View File

@ -0,0 +1,9 @@
<?php
namespace App\Exceptions;
use Exception;
class BackupFileException extends Exception
{
}

View File

@ -2,14 +2,24 @@
namespace App\Http\Controllers;
use App\Models\Backup;
use App\Models\Server;
use Illuminate\Contracts\View\View;
class DatabaseController extends Controller
{
public function index(Server $server)
public function index(Server $server): View
{
return view('databases.index', [
'server' => $server,
]);
}
public function backups(Server $server, Backup $backup): View
{
return view('databases.backups', [
'server' => $server,
'backup' => $backup,
]);
}
}

View File

@ -0,0 +1,67 @@
<?php
namespace App\Http\Livewire\Databases;
use App\Models\Backup;
use App\Models\BackupFile;
use App\Models\Database;
use App\Models\Server;
use App\Traits\HasCustomPaginationView;
use App\Traits\RefreshComponentOnBroadcast;
use Illuminate\Contracts\View\View;
use Livewire\Component;
class DatabaseBackupFiles extends Component
{
use HasCustomPaginationView;
use RefreshComponentOnBroadcast;
public Server $server;
public Backup $backup;
public string $restoreId = "";
public string $restoreDatabaseId = "";
public int $deleteId;
public function backup(): void
{
$this->backup->run();
$this->refreshComponent([]);
}
public function restore(): void
{
/** @var BackupFile $file */
$file = BackupFile::query()->findOrFail($this->restoreId);
/** @var Database $database */
$database = Database::query()->findOrFail($this->restoreDatabaseId);
$file->restore($database);
$this->refreshComponent([]);
$this->dispatchBrowserEvent('restored', true);
}
public function delete(): void
{
/** @var BackupFile $file */
$file = BackupFile::query()->findOrFail($this->deleteId);
$file->delete();
$this->dispatchBrowserEvent('confirmed', true);
}
public function render(): View
{
return view('livewire.databases.database-backup-files', [
'files' => $this->backup->files()->orderByDesc('id')->simplePaginate(10)
]);
}
}

View File

@ -0,0 +1,81 @@
<?php
namespace App\Http\Livewire\Databases;
use App\Actions\Database\CreateBackup;
use App\Models\Backup;
use App\Models\Server;
use App\Traits\RefreshComponentOnBroadcast;
use Illuminate\Contracts\Pagination\Paginator;
use Illuminate\Contracts\View\View;
use Livewire\Component;
use Livewire\WithPagination;
class DatabaseBackups extends Component
{
use RefreshComponentOnBroadcast;
use WithPagination;
public Server $server;
public int $deleteId;
public string $database = '';
public string $storage = '';
public string $interval = '';
public string $custom = '';
public int $keep = 10;
public ?Backup $backup = null;
protected ?Paginator $files = null;
public function create(): void
{
app(CreateBackup::class)->create('database', $this->server, $this->all());
$this->refreshComponent([]);
$this->dispatchBrowserEvent('backup-created', true);
}
public function files(int $id): void
{
$backup = Backup::query()->findOrFail($id);
$this->backup = $backup;
$this->files = $backup->files()->orderByDesc('id')->simplePaginate(1);
$this->dispatchBrowserEvent('show-files', true);
}
public function backup(): void
{
$this->backup?->run();
$this->files = $this->backup?->files()->orderByDesc('id')->simplePaginate();
$this->dispatchBrowserEvent('backup-running', true);
}
public function delete(): void
{
/** @var Backup $backup */
$backup = Backup::query()->findOrFail($this->deleteId);
$backup->delete();
$this->dispatchBrowserEvent('confirmed', true);
}
public function render(): View
{
return view('livewire.databases.database-backups', [
'backups' => $this->server->backups,
'databases' => $this->server->databases,
'files' => $this->files
]);
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Http\Livewire\StorageProviders;
use App\Actions\StorageProvider\CreateStorageProvider;
use Illuminate\Contracts\View\View;
use Livewire\Component;
class ConnectProvider extends Component
{
public string $provider = '';
public string $name;
public string $token;
public function connect(): void
{
app(CreateStorageProvider::class)->create(auth()->user(), $this->all());
$this->emitTo(ProvidersList::class, '$refresh');
$this->dispatchBrowserEvent('connected', true);
}
public function render(): View
{
return view('livewire.storage-providers.connect-provider');
}
}

View File

@ -0,0 +1,37 @@
<?php
namespace App\Http\Livewire\StorageProviders;
use App\Models\StorageProvider;
use App\Traits\RefreshComponentOnBroadcast;
use Illuminate\Contracts\View\View;
use Livewire\Component;
class ProvidersList extends Component
{
use RefreshComponentOnBroadcast;
public int $deleteId;
protected $listeners = [
'$refresh',
];
public function delete(): void
{
$provider = StorageProvider::query()->findOrFail($this->deleteId);
$provider->delete();
$this->refreshComponent([]);
$this->dispatchBrowserEvent('confirmed', true);
}
public function render(): View
{
return view('livewire.storage-providers.providers-list', [
'providers' => StorageProvider::query()->latest()->get(),
]);
}
}

View File

@ -2,6 +2,7 @@
namespace App\Jobs\Backup;
use App\Enums\BackupFileStatus;
use App\Events\Broadcast;
use App\Jobs\Job;
use App\Models\BackupFile;
@ -23,7 +24,7 @@ public function handle(): void
{
$this->database->server->database()->handler()->restoreBackup($this->backupFile, $this->database->name);
$this->backupFile->status = 'restored';
$this->backupFile->status = BackupFileStatus::RESTORED;
$this->backupFile->restored_at = now();
$this->backupFile->save();
@ -36,7 +37,7 @@ public function handle(): void
public function failed(): void
{
$this->backupFile->status = 'restore_failed';
$this->backupFile->status = BackupFileStatus::RESTORE_FAILED;
$this->backupFile->save();
event(
new Broadcast('backup-restore-failed', [

View File

@ -2,6 +2,7 @@
namespace App\Jobs\Backup;
use App\Enums\BackupFileStatus;
use App\Events\Broadcast;
use App\Jobs\Job;
use App\Models\BackupFile;
@ -21,7 +22,7 @@ public function handle(): void
$this->backupFile->backup->server->database()->handler()->runBackup($this->backupFile);
}
$this->backupFile->status = 'finished';
$this->backupFile->status = BackupFileStatus::CREATED;
$this->backupFile->save();
event(
@ -33,7 +34,7 @@ public function handle(): void
public function failed(): void
{
$this->backupFile->status = 'failed';
$this->backupFile->status = BackupFileStatus::FAILED;
$this->backupFile->save();
event(
new Broadcast('run-backup-failed', [

View File

@ -2,6 +2,7 @@
namespace App\Models;
use App\Enums\BackupFileStatus;
use App\Jobs\Backup\RunBackup;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -10,7 +11,6 @@
/**
* @property string $type
* @property string $name
* @property int $server_id
* @property int $storage_id
* @property int $database_id
@ -28,7 +28,6 @@ class Backup extends AbstractModel
protected $fillable = [
'type',
'name',
'server_id',
'storage_id',
'database_id',
@ -77,8 +76,8 @@ public function run(): void
{
$file = new BackupFile([
'backup_id' => $this->id,
'name' => Str::of($this->name)->slug().'-'.now()->format('YmdHis'),
'status' => 'creating',
'name' => Str::of($this->database->name)->slug().'-'.now()->format('YmdHis'),
'status' => BackupFileStatus::CREATING,
]);
$file->save();

View File

@ -2,6 +2,7 @@
namespace App\Models;
use App\Enums\BackupFileStatus;
use App\Jobs\Backup\RestoreDatabase;
use App\Jobs\StorageProvider\DeleteFile;
use Carbon\Carbon;
@ -75,12 +76,12 @@ public function getPathAttribute(): string
public function getStoragePathAttribute(): string
{
return '/'.$this->backup->name.'/'.$this->name.'.zip';
return '/'.$this->backup->database->name.'/'.$this->name.'.zip';
}
public function restore(Database $database): void
{
$this->status = 'restoring';
$this->status = BackupFileStatus::RESTORING;
$this->restored_to = $database->name;
$this->save();
dispatch(new RestoreDatabase($this, $database))->onConnection('ssh');

View File

@ -2,18 +2,14 @@
namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @property int $user_id
* @property string $profile
* @property string $provider
* @property string $label
* @property string $token
* @property string $refresh_token
* @property bool $connected
* @property Carbon $token_expires_at
* @property array $credentials
* @property User $user
*/
class StorageProvider extends AbstractModel
@ -22,18 +18,14 @@ class StorageProvider extends AbstractModel
protected $fillable = [
'user_id',
'profile',
'provider',
'label',
'token',
'refresh_token',
'connected',
'token_expires_at',
'credentials',
];
protected $casts = [
'user_id' => 'integer',
'connected' => 'boolean',
'token_expires_at' => 'datetime',
'credentials' => 'encrypted:array',
];
public function user(): BelongsTo

View File

@ -99,7 +99,7 @@ public function runBackup(BackupFile $backupFile): void
'backup-database'
);
// upload to cloud
// upload to storage
$upload = $backupFile->backup->storage->provider()->upload(
$backupFile->backup->server,
$backupFile->path,

View File

@ -2,21 +2,25 @@
namespace App\StorageProviders;
use App\Exceptions\BackupFileException;
use App\Models\Server;
use App\SSHCommands\Storage\DownloadFromDropboxCommand;
use App\SSHCommands\Storage\UploadToDropboxCommand;
use Illuminate\Support\Facades\Http;
use Laravel\Socialite\Facades\Socialite;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Throwable;
class Dropbox extends AbstractStorageProvider
{
public function connect(): RedirectResponse
{
session()->put('storage_provider_id', $this->storageProvider->id);
protected string $apiUrl = 'https://api.dropboxapi.com/2';
return Socialite::driver('dropbox')->redirect();
public function connect(): bool
{
$res = Http::withToken($this->storageProvider->credentials['token'])
->post($this->apiUrl.'/check/user', [
'query' => ''
]);
return $res->successful();
}
/**
@ -28,15 +32,19 @@ public function upload(Server $server, string $src, string $dest): array
new UploadToDropboxCommand(
$src,
$dest,
$this->storageProvider->token
$this->storageProvider->credentials['token']
),
'upload-to-dropbox'
);
$data = json_decode($upload);
$data = json_decode($upload, true);
if (isset($data['error'])) {
throw new BackupFileException("Failed to upload to Dropbox ".$data['error_summary'] ?? '');
}
return [
'size' => $data?->size,
'size' => $data['size'] ?? null,
];
}
@ -49,7 +57,7 @@ public function download(Server $server, string $src, string $dest): void
new DownloadFromDropboxCommand(
$src,
$dest,
$this->storageProvider->token
$this->storageProvider->credentials['token']
),
'download-from-dropbox'
);
@ -61,11 +69,11 @@ public function delete(array $paths): void
foreach ($paths as $path) {
$data[] = ['path' => $path];
}
Http::withToken($this->storageProvider->token)
Http::withToken($this->storageProvider->credentials['token'])
->withHeaders([
'Content-Type:application/json',
])
->post('https://api.dropboxapi.com/2/files/delete_batch', [
->post($this->apiUrl.'/files/delete_batch', [
'entries' => $data,
]);
}