From dd4e7231d090616c87fc74841b6743c7ae6bbcd8 Mon Sep 17 00:00:00 2001 From: Boy132 Date: Fri, 16 Jan 2026 21:52:24 +0100 Subject: [PATCH] start backup hosts --- app/Enums/RolePermissionModels.php | 1 + .../BackupAdapterSchemaInterface.php | 29 +++ .../BackupAdapter/BackupAdapterService.php | 35 +++ .../Schemas/BackupAdapterSchema.php | 36 +++ .../BackupAdapter/Schemas/S3BackupSchema.php | 226 ++++++++++++++++++ .../Schemas/WingsBackupSchema.php | 56 +++++ app/Extensions/Backups/BackupManager.php | 177 -------------- app/Extensions/Filesystem/S3Filesystem.php | 38 --- .../Tasks/Schemas/CreateBackupSchema.php | 4 +- app/Filament/Admin/Pages/Settings.php | 46 ---- .../BackupHosts/BackupHostResource.php | 145 +++++++++++ .../BackupHosts/Pages/CreateBackupHost.php | 18 ++ .../BackupHosts/Pages/EditBackupHost.php | 42 ++++ .../BackupHosts/Pages/ListBackupHosts.php | 31 +++ .../BackupHosts/Pages/ViewBackupHost.php | 30 +++ .../Resources/Backups/BackupResource.php | 8 +- .../Api/Client/Servers/BackupController.php | 13 +- .../Backups/BackupRemoteUploadController.php | 106 ++------ .../Remote/Backups/BackupStatusController.php | 71 +----- app/Models/BackupHost.php | 62 +++++ app/Models/Node.php | 10 +- app/Policies/BackupHostPolicy.php | 29 +++ app/Providers/BackupsServiceProvider.php | 28 --- .../BackupAdapterServiceProvider.php | 26 ++ .../Daemon/DaemonBackupRepository.php | 12 +- app/Services/Backups/DeleteBackupService.php | 54 +---- app/Services/Backups/DownloadLinkService.php | 48 +--- .../Backups/InitiateBackupService.php | 20 +- bootstrap/providers.php | 2 +- ...01_16_081858_create_backup_hosts_table.php | 44 ++++ lang/en/admin/backuphost.php | 13 + .../Backups/DeleteBackupServiceTest.php | 26 -- 32 files changed, 892 insertions(+), 594 deletions(-) create mode 100644 app/Extensions/BackupAdapter/BackupAdapterSchemaInterface.php create mode 100644 app/Extensions/BackupAdapter/BackupAdapterService.php create mode 100644 app/Extensions/BackupAdapter/Schemas/BackupAdapterSchema.php create mode 100644 app/Extensions/BackupAdapter/Schemas/S3BackupSchema.php create mode 100644 app/Extensions/BackupAdapter/Schemas/WingsBackupSchema.php delete mode 100644 app/Extensions/Backups/BackupManager.php delete mode 100644 app/Extensions/Filesystem/S3Filesystem.php create mode 100644 app/Filament/Admin/Resources/BackupHosts/BackupHostResource.php create mode 100644 app/Filament/Admin/Resources/BackupHosts/Pages/CreateBackupHost.php create mode 100644 app/Filament/Admin/Resources/BackupHosts/Pages/EditBackupHost.php create mode 100644 app/Filament/Admin/Resources/BackupHosts/Pages/ListBackupHosts.php create mode 100644 app/Filament/Admin/Resources/BackupHosts/Pages/ViewBackupHost.php create mode 100644 app/Models/BackupHost.php create mode 100644 app/Policies/BackupHostPolicy.php delete mode 100644 app/Providers/BackupsServiceProvider.php create mode 100644 app/Providers/Extensions/BackupAdapterServiceProvider.php create mode 100644 database/migrations/2026_01_16_081858_create_backup_hosts_table.php create mode 100644 lang/en/admin/backuphost.php diff --git a/app/Enums/RolePermissionModels.php b/app/Enums/RolePermissionModels.php index 013d5b857..ce4980646 100644 --- a/app/Enums/RolePermissionModels.php +++ b/app/Enums/RolePermissionModels.php @@ -5,6 +5,7 @@ namespace App\Enums; enum RolePermissionModels: string { case ApiKey = 'apiKey'; + case BackupHost = 'backupHost'; case DatabaseHost = 'databaseHost'; case Database = 'database'; case Egg = 'egg'; diff --git a/app/Extensions/BackupAdapter/BackupAdapterSchemaInterface.php b/app/Extensions/BackupAdapter/BackupAdapterSchemaInterface.php new file mode 100644 index 000000000..1724aff3c --- /dev/null +++ b/app/Extensions/BackupAdapter/BackupAdapterSchemaInterface.php @@ -0,0 +1,29 @@ + */ + public function getConfiguration(): array; + + /** @param array $configuration */ + public function saveConfiguration(array $configuration): void; + + /** @return Component[] */ + public function getConfigurationForm(): array; +} diff --git a/app/Extensions/BackupAdapter/BackupAdapterService.php b/app/Extensions/BackupAdapter/BackupAdapterService.php new file mode 100644 index 000000000..6e5999826 --- /dev/null +++ b/app/Extensions/BackupAdapter/BackupAdapterService.php @@ -0,0 +1,35 @@ + */ + private array $schemas = []; + + /** @return BackupAdapterSchemaInterface[] */ + public function getAll(): array + { + return $this->schemas; + } + + public function get(string $id): ?BackupAdapterSchemaInterface + { + return array_get($this->schemas, $id); + } + + public function register(BackupAdapterSchemaInterface $schema): void + { + if (array_key_exists($schema->getId(), $this->schemas)) { + return; + } + + $this->schemas[$schema->getId()] = $schema; + } + + /** @return array */ + public function getMappings(): array + { + return collect($this->schemas)->mapWithKeys(fn ($schema) => [$schema->getId() => $schema->getName()])->all(); + } +} diff --git a/app/Extensions/BackupAdapter/Schemas/BackupAdapterSchema.php b/app/Extensions/BackupAdapter/Schemas/BackupAdapterSchema.php new file mode 100644 index 000000000..01d1d83be --- /dev/null +++ b/app/Extensions/BackupAdapter/Schemas/BackupAdapterSchema.php @@ -0,0 +1,36 @@ +getId()); + } + + /** @return array */ + public function getConfiguration(): array + { + return config('backups.disks.' . $this->getId(), []); + } + + /** @param array $configuration */ + public function saveConfiguration(array $configuration): void + { + $this->writeToEnvironment($configuration); + } + + /** @return Component[] */ + public function getConfigurationForm(): array + { + return []; + } +} diff --git a/app/Extensions/BackupAdapter/Schemas/S3BackupSchema.php b/app/Extensions/BackupAdapter/Schemas/S3BackupSchema.php new file mode 100644 index 000000000..1c6d883df --- /dev/null +++ b/app/Extensions/BackupAdapter/Schemas/S3BackupSchema.php @@ -0,0 +1,226 @@ +repository->setBackupSchema($this->getId()); + } + + private function createClient(): void + { + if (!$this->client) { + $config = $this->getConfiguration(); + $config['version'] = 'latest'; + + if (!empty($config['key']) && !empty($config['secret'])) { + $config['credentials'] = Arr::only($config, ['key', 'secret', 'token']); + } + + $this->client = new S3Client($config); + } + } + + public function getId(): string + { + return 's3'; + } + + public function createBackup(Backup $backup): void + { + $this->repository->setServer($backup->server)->create($backup); + } + + public function deleteBackup(Backup $backup): void + { + $this->createClient(); + + $this->client->deleteObject([ + 'Bucket' => config('backups.disks.s3.bucket'), + 'Key' => "{$backup->server->uuid}/{$backup->uuid}.tar.gz", + ]); + } + + public function getDownloadLink(Backup $backup, User $user): string + { + $this->createClient(); + + $request = $this->client->createPresignedRequest( + $this->client->getCommand('GetObject', [ + 'Bucket' => config('backups.disks.s3.bucket'), + 'Key' => "{$backup->server->uuid}/{$backup->uuid}.tar.gz", + 'ContentType' => 'application/x-gzip', + ]), + CarbonImmutable::now()->addMinutes(5) + ); + + return $request->getUri()->__toString(); + } + + /** @param array $configuration */ + public function saveConfiguration(array $configuration): void + { + parent::saveConfiguration($configuration); + + $this->client = null; + } + + /** @return Component[] */ + public function getConfigurationForm(): array + { + return [ + TextInput::make('AWS_DEFAULT_REGION') + ->label(trans('admin/setting.backup.s3.default_region')) + ->required() + ->default(config('backups.disks.s3.region')), + TextInput::make('AWS_ACCESS_KEY_ID') + ->label(trans('admin/setting.backup.s3.access_key')) + ->required() + ->default(config('backups.disks.s3.key')), + TextInput::make('AWS_SECRET_ACCESS_KEY') + ->label(trans('admin/setting.backup.s3.secret_key')) + ->required() + ->default(config('backups.disks.s3.secret')), + TextInput::make('AWS_BACKUPS_BUCKET') + ->label(trans('admin/setting.backup.s3.bucket')) + ->required() + ->default(config('backups.disks.s3.bucket')), + TextInput::make('AWS_ENDPOINT') + ->label(trans('admin/setting.backup.s3.endpoint')) + ->required() + ->default(config('backups.disks.s3.endpoint')), + Toggle::make('AWS_USE_PATH_STYLE_ENDPOINT') + ->label(trans('admin/setting.backup.s3.use_path_style_endpoint')) + ->inline(false) + ->onIcon('tabler-check') + ->offIcon('tabler-x') + ->onColor('success') + ->offColor('danger') + ->live() + ->stateCast(new BooleanStateCast(false)) + ->default(config('backups.disks.s3.use_path_style_endpoint')), + ]; + } + + /** @return array{parts: string[], part_size: int} */ + public function getUploadParts(Backup $backup, int $size): array + { + $expires = CarbonImmutable::now()->addMinutes(config('backups.presigned_url_lifespan', 60)); + + // Params for generating the presigned urls + $params = [ + 'Bucket' => config('backups.disks.s3.bucket'), + 'Key' => "{$backup->server->uuid}/{$backup->uuid}.tar.gz", + 'ContentType' => 'application/x-gzip', + ]; + + $storageClass = config('backups.disks.s3.storage_class'); + if (!is_null($storageClass)) { + $params['StorageClass'] = $storageClass; + } + + $this->createClient(); + + // Execute the CreateMultipartUpload request + $result = $this->client->execute($this->client->getCommand('CreateMultipartUpload', $params)); + + // Get the UploadId from the CreateMultipartUpload request, this is needed to create + // the other presigned urls. + $params['UploadId'] = $result->get('UploadId'); + + // Retrieve configured part size + $maxPartSize = config('backups.max_part_size', BackupRemoteUploadController::DEFAULT_MAX_PART_SIZE); + if ($maxPartSize <= 0) { + $maxPartSize = BackupRemoteUploadController::DEFAULT_MAX_PART_SIZE; + } + + // Create as many UploadPart presigned urls as needed + $parts = []; + for ($i = 0; $i < ($size / $maxPartSize); $i++) { + $parts[] = $this->client->createPresignedRequest( + $this->client->getCommand('UploadPart', array_merge($params, ['PartNumber' => $i + 1])), + $expires + )->getUri()->__toString(); + } + + // Set the upload_id on the backup in the database. + $backup->update(['upload_id' => $params['UploadId']]); + + return [ + 'parts' => $parts, + 'part_size' => $maxPartSize, + ]; + } + + /** + * Marks a multipart upload in a given S3-compatible instance as failed or successful for the given backup. + * + * @param ?array $parts + * + * @throws Exception + */ + public function completeMultipartUpload(Backup $backup, bool $successful, ?array $parts): void + { + // This should never really happen, but if it does don't let us fall victim to Amazon's + // wildly fun error messaging. Just stop the process right here. + if (empty($backup->upload_id)) { + // A failed backup doesn't need to error here, this can happen if the backup encounters + // an error before we even start the upload. AWS gives you tooling to clear these failed + // multipart uploads as needed too. + if (!$successful) { + return; + } + + throw new Exception('Cannot complete backup request: no upload_id present on model.'); + } + + $params = [ + 'Bucket' => config('backups.disks.s3.bucket'), + 'Key' => "{$backup->server->uuid}/{$backup->uuid}.tar.gz", + 'UploadId' => $backup->upload_id, + ]; + + $this->createClient(); + + if (!$successful) { + $this->client->execute($this->client->getCommand('AbortMultipartUpload', $params)); + + return; + } + + // Otherwise send a CompleteMultipartUpload request. + $params['MultipartUpload'] = [ + 'Parts' => [], + ]; + + if (is_null($parts)) { + $params['MultipartUpload']['Parts'] = $this->client->execute($this->client->getCommand('ListParts', $params))['Parts']; + } else { + foreach ($parts as $part) { + $params['MultipartUpload']['Parts'][] = [ + 'ETag' => $part['etag'], + 'PartNumber' => $part['part_number'], + ]; + } + } + + $this->client->execute($this->client->getCommand('CompleteMultipartUpload', $params)); + } +} diff --git a/app/Extensions/BackupAdapter/Schemas/WingsBackupSchema.php b/app/Extensions/BackupAdapter/Schemas/WingsBackupSchema.php new file mode 100644 index 000000000..d6b50bf88 --- /dev/null +++ b/app/Extensions/BackupAdapter/Schemas/WingsBackupSchema.php @@ -0,0 +1,56 @@ +repository->setBackupSchema($this->getId()); + } + + public function getId(): string + { + return 'wings'; + } + + public function createBackup(Backup $backup): void + { + $this->repository->setServer($backup->server)->create($backup); + } + + public function deleteBackup(Backup $backup): void + { + try { + $this->repository->setServer($backup->server)->delete($backup); + } catch (Exception $exception) { + // Don't fail the request if the Daemon responds with a 404, just assume the backup + // doesn't actually exist and remove its reference from the Panel as well. + if ($exception->getCode() !== Response::HTTP_NOT_FOUND) { + throw $exception; + } + } + } + + public function getDownloadLink(Backup $backup, User $user): string + { + $token = $this->jwtService + ->setExpiresAt(CarbonImmutable::now()->addMinutes(15)) + ->setUser($user) + ->setClaims([ + 'backup_uuid' => $backup->uuid, + 'server_uuid' => $backup->server->uuid, + ]) + ->handle($backup->server->node, $user->id . $backup->server->uuid); + + return $backup->server->node->getConnectionAddress() . '/download/backup?token=' . $token->toString(); + } +} diff --git a/app/Extensions/Backups/BackupManager.php b/app/Extensions/Backups/BackupManager.php deleted file mode 100644 index 1f7950093..000000000 --- a/app/Extensions/Backups/BackupManager.php +++ /dev/null @@ -1,177 +0,0 @@ - - */ - protected array $adapters = []; - - /** - * The registered custom driver creators. - * - * @var array - */ - protected array $customCreators; - - public function __construct(protected Application $app) {} - - /** - * Returns a backup adapter instance. - */ - public function adapter(?string $name = null): FilesystemAdapter - { - return $this->get($name ?: $this->getDefaultAdapter()); - } - - /** - * Set the given backup adapter instance. - */ - public function set(string $name, FilesystemAdapter $disk): self - { - $this->adapters[$name] = $disk; - - return $this; - } - - /** - * Gets a backup adapter. - */ - protected function get(string $name): FilesystemAdapter - { - return $this->adapters[$name] = $this->resolve($name); - } - - /** - * Resolve the given backup disk. - */ - protected function resolve(string $name): FilesystemAdapter - { - $config = $this->getConfig($name); - - if (empty($config['adapter'])) { - throw new InvalidArgumentException("Backup disk [$name] does not have a configured adapter."); - } - - $adapter = $config['adapter']; - - if (isset($this->customCreators[$name])) { - return $this->callCustomCreator($config); - } - - $adapterMethod = 'create' . Str::studly($adapter) . 'Adapter'; - if (method_exists($this, $adapterMethod)) { - $instance = $this->{$adapterMethod}($config); - - Assert::isInstanceOf($instance, FilesystemAdapter::class); - - return $instance; - } - - throw new InvalidArgumentException("Adapter [$adapter] is not supported."); - } - - /** - * Calls a custom creator for a given adapter type. - * - * @param array{adapter: string} $config - */ - protected function callCustomCreator(array $config): mixed - { - return $this->customCreators[$config['adapter']]($this->app, $config); - } - - /** - * Creates a new daemon adapter. - * - * @param array $config - */ - public function createWingsAdapter(array $config): FilesystemAdapter - { - return new InMemoryFilesystemAdapter(); - } - - /** - * Creates a new S3 adapter. - * - * @param array $config - */ - public function createS3Adapter(array $config): FilesystemAdapter - { - $config['version'] = 'latest'; - - if (!empty($config['key']) && !empty($config['secret'])) { - $config['credentials'] = Arr::only($config, ['key', 'secret', 'token']); - } - - $client = new S3Client($config); - - return new S3Filesystem($client, $config['bucket'], $config['prefix'] ?? '', $config['options'] ?? []); - } - - /** - * Returns the configuration associated with a given backup type. - * - * @return array - */ - protected function getConfig(string $name): array - { - return config("backups.disks.$name") ?: []; - } - - /** - * Get the default backup driver name. - */ - public function getDefaultAdapter(): string - { - return config('backups.default'); - } - - /** - * Set the default session driver name. - */ - public function setDefaultAdapter(string $name): void - { - config()->set('backups.default', $name); - } - - /** - * Unset the given adapter instances. - * - * @param string|string[] $adapter - */ - public function forget(array|string $adapter): self - { - $adapters = &$this->adapters; - foreach ((array) $adapter as $adapterName) { - unset($adapters[$adapterName]); - } - - return $this; - } - - /** - * Register a custom adapter creator closure. - */ - public function extend(string $adapter, Closure $callback): self - { - $this->customCreators[$adapter] = $callback; - - return $this; - } -} diff --git a/app/Extensions/Filesystem/S3Filesystem.php b/app/Extensions/Filesystem/S3Filesystem.php deleted file mode 100644 index 64aa95bb4..000000000 --- a/app/Extensions/Filesystem/S3Filesystem.php +++ /dev/null @@ -1,38 +0,0 @@ - $options - */ - public function __construct( - private S3ClientInterface $client, - private string $bucket, - string $prefix = '', - array $options = [], - ) { - parent::__construct( - $client, - $bucket, - $prefix, - null, - null, - $options, - ); - } - - public function getClient(): S3ClientInterface - { - return $this->client; - } - - public function getBucket(): string - { - return $this->bucket; - } -} diff --git a/app/Extensions/Tasks/Schemas/CreateBackupSchema.php b/app/Extensions/Tasks/Schemas/CreateBackupSchema.php index 98927ab08..411c5ade3 100644 --- a/app/Extensions/Tasks/Schemas/CreateBackupSchema.php +++ b/app/Extensions/Tasks/Schemas/CreateBackupSchema.php @@ -8,7 +8,7 @@ use App\Services\Backups\InitiateBackupService; final class CreateBackupSchema extends TaskSchema { - public function __construct(private InitiateBackupService $backupService) {} + public function __construct(private InitiateBackupService $initiateService) {} public function getId(): string { @@ -17,7 +17,7 @@ final class CreateBackupSchema extends TaskSchema public function runTask(Task $task): void { - $this->backupService->setIgnoredFiles(explode(PHP_EOL, $task->payload))->handle($task->server, null, true); + $this->initiateService->setIgnoredFiles(explode(PHP_EOL, $task->payload))->handle($task->server, null, true); } public function canCreate(Schedule $schedule): bool diff --git a/app/Filament/Admin/Pages/Settings.php b/app/Filament/Admin/Pages/Settings.php index 5d432f937..aeef3404f 100644 --- a/app/Filament/Admin/Pages/Settings.php +++ b/app/Filament/Admin/Pages/Settings.php @@ -5,7 +5,6 @@ namespace App\Filament\Admin\Pages; use App\Extensions\Avatar\AvatarService; use App\Extensions\Captcha\CaptchaService; use App\Extensions\OAuth\OAuthService; -use App\Models\Backup; use App\Notifications\MailTested; use App\Traits\EnvironmentWriterTrait; use App\Traits\Filament\CanCustomizeHeaderActions; @@ -477,16 +476,6 @@ class Settings extends Page implements HasSchemas private function backupSettings(): array { return [ - ToggleButtons::make('APP_BACKUP_DRIVER') - ->label(trans('admin/setting.backup.backup_driver')) - ->columnSpanFull() - ->inline() - ->options([ - Backup::ADAPTER_DAEMON => 'Wings', - Backup::ADAPTER_AWS_S3 => 'S3', - ]) - ->live() - ->default(env('APP_BACKUP_DRIVER', config('backups.default'))), Section::make(trans('admin/setting.backup.throttle')) ->description(trans('admin/setting.backup.throttle_help')) ->columns() @@ -506,41 +495,6 @@ class Settings extends Page implements HasSchemas ->suffix('Seconds') ->default(config('backups.throttles.period')), ]), - Section::make(trans('admin/setting.backup.s3.s3_title')) - ->columns() - ->visible(fn (Get $get) => $get('APP_BACKUP_DRIVER') === Backup::ADAPTER_AWS_S3) - ->schema([ - TextInput::make('AWS_DEFAULT_REGION') - ->label(trans('admin/setting.backup.s3.default_region')) - ->required() - ->default(config('backups.disks.s3.region')), - TextInput::make('AWS_ACCESS_KEY_ID') - ->label(trans('admin/setting.backup.s3.access_key')) - ->required() - ->default(config('backups.disks.s3.key')), - TextInput::make('AWS_SECRET_ACCESS_KEY') - ->label(trans('admin/setting.backup.s3.secret_key')) - ->required() - ->default(config('backups.disks.s3.secret')), - TextInput::make('AWS_BACKUPS_BUCKET') - ->label(trans('admin/setting.backup.s3.bucket')) - ->required() - ->default(config('backups.disks.s3.bucket')), - TextInput::make('AWS_ENDPOINT') - ->label(trans('admin/setting.backup.s3.endpoint')) - ->required() - ->default(config('backups.disks.s3.endpoint')), - Toggle::make('AWS_USE_PATH_STYLE_ENDPOINT') - ->label(trans('admin/setting.backup.s3.use_path_style_endpoint')) - ->inline(false) - ->onIcon('tabler-check') - ->offIcon('tabler-x') - ->onColor('success') - ->offColor('danger') - ->live() - ->stateCast(new BooleanStateCast(false)) - ->default(env('AWS_USE_PATH_STYLE_ENDPOINT', config('backups.disks.s3.use_path_style_endpoint'))), - ]), ]; } diff --git a/app/Filament/Admin/Resources/BackupHosts/BackupHostResource.php b/app/Filament/Admin/Resources/BackupHosts/BackupHostResource.php new file mode 100644 index 000000000..45ae8ebe2 --- /dev/null +++ b/app/Filament/Admin/Resources/BackupHosts/BackupHostResource.php @@ -0,0 +1,145 @@ +count() ?: null; + } + + public static function getNavigationLabel(): string + { + return static::getPluralModelLabel(); + } + + public static function getModelLabel(): string + { + return trans_choice('admin/backuphost.model_label', 1); + } + + public static function getPluralModelLabel(): string + { + return trans_choice('admin/backuphost.model_label', 2); + } + + public static function getNavigationGroup(): ?string + { + return trans('admin/dashboard.advanced'); + } + + /** @throws Exception */ + public static function defaultTable(Table $table): Table + { + return $table + ->columns([ + TextColumn::make('name') + ->label(trans('admin/backuphost.name')), + TextColumn::make('schema') + ->label(trans('admin/backuphost.schema')) + ->badge(), + TextColumn::make('backups_count') + ->counts('backups') + ->label(trans('admin/backuphost.backups')), + TextColumn::make('nodes.name') + ->badge() + ->placeholder(trans('admin/backuphost.no_nodes')), + Section::make(trans('admin/backuphost.schema')), + ]) + ->checkIfRecordIsSelectableUsing(fn (BackupHost $backupHost) => $backupHost->backups_count <= 0) + ->recordActions([ + ViewAction::make() + ->hidden(fn ($record) => static::getEditAuthorizationResponse($record)->allowed()), + EditAction::make(), + DeleteAction::make() + ->hidden(fn (BackupHost $backupHost) => $backupHost->backups_count > 0), + ]) + ->groupedBulkActions([ + DeleteBulkAction::make(), + ]) + ->emptyStateIcon('tabler-file-zip') + ->emptyStateDescription(trans('admin/backuphost.local_backups_only')) + ->emptyStateHeading(trans('admin/backuphost.no_backup_hosts')); + } + + /** @throws Exception */ + public static function defaultForm(Schema $schema): Schema + { + return $schema + ->components([ + TextInput::make('name') + ->label(trans('admin/backuphost.name')) + ->required(), + Select::make('schema') + ->label(trans('admin/backuphost.schema')) + ->required() + ->selectablePlaceholder(false) + ->searchable() + ->options(fn (BackupAdapterService $service) => $service->getMappings()), + Select::make('node_ids') + ->label(trans('admin/backuphost.linked_nodes')) + ->multiple() + ->searchable() + ->preload() + ->relationship('nodes', 'name', fn (Builder $query) => $query->whereIn('nodes.id', user()?->accessibleNodes()->pluck('id'))), + ]); + } + + /** @return class-string[] */ + public static function getDefaultRelations(): array + { + return [ + // BackupsRelationManager::class, // TODO + ]; + } + + /** @return array */ + public static function getDefaultPages(): array + { + return [ + 'index' => ListBackupHosts::route('/'), + 'create' => CreateBackupHost::route('/create'), + 'view' => ViewBackupHost::route('/{record}'), + 'edit' => EditBackupHost::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Admin/Resources/BackupHosts/Pages/CreateBackupHost.php b/app/Filament/Admin/Resources/BackupHosts/Pages/CreateBackupHost.php new file mode 100644 index 000000000..995f535d9 --- /dev/null +++ b/app/Filament/Admin/Resources/BackupHosts/Pages/CreateBackupHost.php @@ -0,0 +1,18 @@ + */ + protected function getDefaultHeaderActions(): array + { + return [ + DeleteAction::make() + ->label(fn (BackupHost $backupHost) => $backupHost->backups()->count() > 0 ? trans('admin/backuphost.delete_help') : trans('filament-actions::delete.single.modal.actions.delete.label')) + ->disabled(fn (BackupHost $backupHost) => $backupHost->backups()->count() > 0) + ->iconButton() + ->iconSize(IconSize::ExtraLarge), + $this->getSaveFormAction()->formId('form') + ->iconButton() + ->iconSize(IconSize::ExtraLarge) + ->icon('tabler-device-floppy'), + ]; + } + + protected function getFormActions(): array + { + return []; + } +} diff --git a/app/Filament/Admin/Resources/BackupHosts/Pages/ListBackupHosts.php b/app/Filament/Admin/Resources/BackupHosts/Pages/ListBackupHosts.php new file mode 100644 index 000000000..2a2c799fc --- /dev/null +++ b/app/Filament/Admin/Resources/BackupHosts/Pages/ListBackupHosts.php @@ -0,0 +1,31 @@ + */ + protected function getDefaultHeaderActions(): array + { + return [ + CreateAction::make() + ->iconButton() + ->iconSize(IconSize::ExtraLarge) + ->icon('tabler-file-plus'), + ]; + } +} diff --git a/app/Filament/Admin/Resources/BackupHosts/Pages/ViewBackupHost.php b/app/Filament/Admin/Resources/BackupHosts/Pages/ViewBackupHost.php new file mode 100644 index 000000000..ae21435c8 --- /dev/null +++ b/app/Filament/Admin/Resources/BackupHosts/Pages/ViewBackupHost.php @@ -0,0 +1,30 @@ + */ + protected function getDefaultHeaderActions(): array + { + return [ + EditAction::make() + ->iconButton() + ->iconSize(IconSize::ExtraLarge), + ]; + } +} diff --git a/app/Filament/Server/Resources/Backups/BackupResource.php b/app/Filament/Server/Resources/Backups/BackupResource.php index 320aafb32..cbbfb8652 100644 --- a/app/Filament/Server/Resources/Backups/BackupResource.php +++ b/app/Filament/Server/Resources/Backups/BackupResource.php @@ -206,17 +206,13 @@ class BackupResource extends Resource ->property(['name' => $backup->name, 'truncate' => $data['truncate']]); $log->transaction(function () use ($downloadLinkService, $daemonRepository, $backup, $server, $data) { - // If the backup is for an S3 file we need to generate a unique Download link for - // it that will allow daemon to actually access the file. - if ($backup->disk === Backup::ADAPTER_AWS_S3) { - $url = $downloadLinkService->handle($backup, user()); - } + $url = $downloadLinkService->handle($backup, user()); // Update the status right away for the server so that we know not to allow certain // actions against it via the Panel API. $server->update(['status' => ServerState::RestoringBackup]); - $daemonRepository->setServer($server)->restore($backup, $url ?? null, $data['truncate']); + $daemonRepository->setServer($server)->restore($backup, $url, $data['truncate']); }); return Notification::make() diff --git a/app/Http/Controllers/Api/Client/Servers/BackupController.php b/app/Http/Controllers/Api/Client/Servers/BackupController.php index d5492cbcd..fb5b93366 100644 --- a/app/Http/Controllers/Api/Client/Servers/BackupController.php +++ b/app/Http/Controllers/Api/Client/Servers/BackupController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\Client\Servers; use App\Enums\ServerState; use App\Enums\SubuserPermission; +use App\Extensions\BackupAdapter\BackupAdapterService; use App\Facades\Activity; use App\Http\Controllers\Api\Client\ClientApiController; use App\Http\Requests\Api\Client\Servers\Backups\RenameBackupRequest; @@ -33,6 +34,7 @@ class BackupController extends ClientApiController private readonly DeleteBackupService $deleteBackupService, private readonly InitiateBackupService $initiateBackupService, private readonly DownloadLinkService $downloadLinkService, + private readonly BackupAdapterService $backupService ) { parent::__construct(); } @@ -191,7 +193,8 @@ class BackupController extends ClientApiController throw new AuthorizationException(); } - if ($backup->disk !== Backup::ADAPTER_AWS_S3 && $backup->disk !== Backup::ADAPTER_DAEMON) { + $schema = $this->backupService->get($backup->disk); + if (!$schema) { throw new BadRequestHttpException('The backup requested references an unknown disk driver type and cannot be downloaded.'); } @@ -264,17 +267,13 @@ class BackupController extends ClientApiController ->property(['name' => $backup->name, 'truncate' => $request->input('truncate')]); $log->transaction(function () use ($backup, $server, $request) { - // If the backup is for an S3 file we need to generate a unique Download link for - // it that will allow daemon to actually access the file. - if ($backup->disk === Backup::ADAPTER_AWS_S3) { - $url = $this->downloadLinkService->handle($backup, $request->user()); - } + $url = $this->downloadLinkService->handle($backup, $request->user()); // Update the status right away for the server so that we know not to allow certain // actions against it via the Panel API. $server->update(['status' => ServerState::RestoringBackup]); - $this->daemonRepository->setServer($server)->restore($backup, $url ?? null, $request->input('truncate')); + $this->daemonRepository->setServer($server)->restore($backup, $url, $request->input('truncate')); }); return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT); diff --git a/app/Http/Controllers/Api/Remote/Backups/BackupRemoteUploadController.php b/app/Http/Controllers/Api/Remote/Backups/BackupRemoteUploadController.php index fa149c31e..ff04537c5 100644 --- a/app/Http/Controllers/Api/Remote/Backups/BackupRemoteUploadController.php +++ b/app/Http/Controllers/Api/Remote/Backups/BackupRemoteUploadController.php @@ -3,20 +3,16 @@ namespace App\Http\Controllers\Api\Remote\Backups; use App\Exceptions\Http\HttpForbiddenException; -use App\Extensions\Backups\BackupManager; -use App\Extensions\Filesystem\S3Filesystem; +use App\Extensions\BackupAdapter\BackupAdapterService; +use App\Extensions\BackupAdapter\Schemas\S3BackupSchema; use App\Http\Controllers\Controller; use App\Models\Backup; use App\Models\Node; -use App\Models\Server; -use Carbon\CarbonImmutable; -use Exception; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\ConflictHttpException; -use Throwable; class BackupRemoteUploadController extends Controller { @@ -25,117 +21,45 @@ class BackupRemoteUploadController extends Controller /** * BackupRemoteUploadController constructor. */ - public function __construct(private BackupManager $backupManager) {} + public function __construct(private BackupAdapterService $backupService) {} /** * Returns the required presigned urls to upload a backup to S3 cloud storage. * - * @throws Exception - * @throws Throwable + * @throws BadRequestHttpException * @throws ModelNotFoundException + * @throws HttpForbiddenException + * @throws ConflictHttpException */ public function __invoke(Request $request, string $backup): JsonResponse { - // Get the node associated with the request. /** @var Node $node */ $node = $request->attributes->get('node'); - // Get the size query parameter. $size = (int) $request->query('size'); if (empty($size)) { throw new BadRequestHttpException('A non-empty "size" query parameter must be provided.'); } - /** @var Backup $model */ - $model = Backup::query() - ->where('uuid', $backup) - ->firstOrFail(); + $backup = Backup::where('uuid', $backup)->firstOrFail(); // Check that the backup is "owned" by the node making the request. This avoids other nodes // from messing with backups that they don't own. - /** @var Server $server */ - $server = $model->server; - if ($server->node_id !== $node->id) { + if ($backup->server->node_id !== $node->id) { throw new HttpForbiddenException('You do not have permission to access that backup.'); } - // Prevent backups that have already been completed from trying to - // be uploaded again. - if (!is_null($model->completed_at)) { + // Prevent backups that have already been completed from trying to be uploaded again. + if (!is_null($backup->completed_at)) { throw new ConflictHttpException('This backup is already in a completed state.'); } - // Ensure we are using the S3 adapter. - $adapter = $this->backupManager->adapter(); - if (!$adapter instanceof S3Filesystem) { - throw new BadRequestHttpException('The configured backup adapter is not an S3 compatible adapter.'); + // Ensure we are using the S3 schema. + $schema = $this->backupService->get(collect($node->backupHosts)->first()->schema); + if (!$schema || !$schema instanceof S3BackupSchema) { + throw new BadRequestHttpException('The configured backup schema is not an S3 compatible.'); } - // The path where backup will be uploaded to - $path = sprintf('%s/%s.tar.gz', $model->server->uuid, $model->uuid); - - // Get the S3 client - $client = $adapter->getClient(); - $expires = CarbonImmutable::now()->addMinutes(config('backups.presigned_url_lifespan', 60)); - - // Params for generating the presigned urls - $params = [ - 'Bucket' => $adapter->getBucket(), - 'Key' => $path, - 'ContentType' => 'application/x-gzip', - ]; - - $storageClass = config('backups.disks.s3.storage_class'); - if (!is_null($storageClass)) { - $params['StorageClass'] = $storageClass; - } - - // Execute the CreateMultipartUpload request - $result = $client->execute($client->getCommand('CreateMultipartUpload', $params)); - - // Get the UploadId from the CreateMultipartUpload request, this is needed to create - // the other presigned urls. - $params['UploadId'] = $result->get('UploadId'); - - // Retrieve configured part size - $maxPartSize = $this->getConfiguredMaxPartSize(); - - // Create as many UploadPart presigned urls as needed - $parts = []; - for ($i = 0; $i < ($size / $maxPartSize); $i++) { - $parts[] = $client->createPresignedRequest( - $client->getCommand('UploadPart', array_merge($params, ['PartNumber' => $i + 1])), - $expires - )->getUri()->__toString(); - } - - // Set the upload_id on the backup in the database. - $model->update(['upload_id' => $params['UploadId']]); - - return new JsonResponse([ - 'parts' => $parts, - 'part_size' => $maxPartSize, - ]); - } - - /** - * Get the configured maximum size of a single part in the multipart upload. - * - * The function tries to retrieve a configured value from the configuration. - * If no value is specified, a fallback value will be used. - * - * Note if the received config cannot be converted to int (0), is zero or is negative, - * the fallback value will be used too. - * - * The fallback value is {@see BackupRemoteUploadController::DEFAULT_MAX_PART_SIZE}. - */ - private function getConfiguredMaxPartSize(): int - { - $maxPartSize = config('backups.max_part_size', self::DEFAULT_MAX_PART_SIZE); - if ($maxPartSize <= 0) { - $maxPartSize = self::DEFAULT_MAX_PART_SIZE; - } - - return $maxPartSize; + return new JsonResponse($schema->getUploadParts($backup, $size)); } } diff --git a/app/Http/Controllers/Api/Remote/Backups/BackupStatusController.php b/app/Http/Controllers/Api/Remote/Backups/BackupStatusController.php index ba69fd004..336089d98 100644 --- a/app/Http/Controllers/Api/Remote/Backups/BackupStatusController.php +++ b/app/Http/Controllers/Api/Remote/Backups/BackupStatusController.php @@ -2,10 +2,9 @@ namespace App\Http\Controllers\Api\Remote\Backups; -use App\Exceptions\DisplayException; use App\Exceptions\Http\HttpForbiddenException; -use App\Extensions\Backups\BackupManager; -use App\Extensions\Filesystem\S3Filesystem; +use App\Extensions\BackupAdapter\BackupAdapterService; +use App\Extensions\BackupAdapter\Schemas\S3BackupSchema; use App\Facades\Activity; use App\Http\Controllers\Controller; use App\Http\Requests\Api\Remote\ReportBackupCompleteRequest; @@ -13,7 +12,6 @@ use App\Models\Backup; use App\Models\Node; use App\Models\Server; use Carbon\CarbonImmutable; -use Exception; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; @@ -24,7 +22,7 @@ class BackupStatusController extends Controller /** * BackupStatusController constructor. */ - public function __construct(private BackupManager $backupManager) {} + public function __construct(private BackupAdapterService $backupService) {} /** * Handles updating the state of a backup. @@ -57,7 +55,7 @@ class BackupStatusController extends Controller $action = $request->boolean('successful') ? 'server:backup.complete' : 'server:backup.fail'; $log = Activity::event($action)->subject($model, $model->server)->property('name', $model->name); - $log->transaction(function () use ($model, $request) { + $log->transaction(function () use ($node, $model, $request) { $successful = $request->boolean('successful'); $model->fill([ @@ -73,9 +71,9 @@ class BackupStatusController extends Controller // Check if we are using the s3 backup adapter. If so, make sure we mark the backup as // being completed in S3 correctly. - $adapter = $this->backupManager->adapter(); - if ($adapter instanceof S3Filesystem) { - $this->completeMultipartUpload($model, $adapter, $successful, $request->input('parts')); + $schema = $this->backupService->get(collect($node->backupHosts)->first()->schema); + if ($schema instanceof S3BackupSchema) { + $schema->completeMultipartUpload($model, $successful, $request->input('parts')); } }); @@ -106,59 +104,4 @@ class BackupStatusController extends Controller return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT); } - - /** - * Marks a multipart upload in a given S3-compatible instance as failed or successful for the given backup. - * - * @param ?array $parts - * - * @throws Exception - * @throws DisplayException - */ - protected function completeMultipartUpload(Backup $backup, S3Filesystem $adapter, bool $successful, ?array $parts): void - { - // This should never really happen, but if it does don't let us fall victim to Amazon's - // wildly fun error messaging. Just stop the process right here. - if (empty($backup->upload_id)) { - // A failed backup doesn't need to error here, this can happen if the backup encounters - // an error before we even start the upload. AWS gives you tooling to clear these failed - // multipart uploads as needed too. - if (!$successful) { - return; - } - - throw new DisplayException('Cannot complete backup request: no upload_id present on model.'); - } - - $params = [ - 'Bucket' => $adapter->getBucket(), - 'Key' => sprintf('%s/%s.tar.gz', $backup->server->uuid, $backup->uuid), - 'UploadId' => $backup->upload_id, - ]; - - $client = $adapter->getClient(); - if (!$successful) { - $client->execute($client->getCommand('AbortMultipartUpload', $params)); - - return; - } - - // Otherwise send a CompleteMultipartUpload request. - $params['MultipartUpload'] = [ - 'Parts' => [], - ]; - - if (is_null($parts)) { - $params['MultipartUpload']['Parts'] = $client->execute($client->getCommand('ListParts', $params))['Parts']; - } else { - foreach ($parts as $part) { - $params['MultipartUpload']['Parts'][] = [ - 'ETag' => $part['etag'], - 'PartNumber' => $part['part_number'], - ]; - } - } - - $client->execute($client->getCommand('CompleteMultipartUpload', $params)); - } } diff --git a/app/Models/BackupHost.php b/app/Models/BackupHost.php new file mode 100644 index 000000000..68f7ec3b5 --- /dev/null +++ b/app/Models/BackupHost.php @@ -0,0 +1,62 @@ + $configuration + * @property CarbonImmutable $created_at + * @property CarbonImmutable $updated_at + * @property Collection|Node[] $nodes + * @property int|null $nodes_count + * @property Collection|Backup[] $backups + * @property int|null $backups_count + */ +class BackupHost extends Model implements Validatable +{ + use HasFactory; + use HasValidation; + + public const RESOURCE_NAME = 'backup_host'; + + protected $fillable = [ + 'name', + 'schema', + 'configuration', + ]; + + /** @var array */ + public static array $validationRules = [ + 'name' => ['required', 'string', 'max:255'], + 'schema' => ['required', 'string', 'max:255'], + 'configuration' => ['nullable', 'array'], + ]; + + protected function casts(): array + { + return [ + 'configuration' => 'array', + ]; + } + + public function nodes(): BelongsToMany + { + return $this->belongsToMany(Node::class); + } + + public function backups(): HasMany + { + return $this->hasMany(Backup::class, 'disk', 'schema'); + } +} diff --git a/app/Models/Node.php b/app/Models/Node.php index 4c5479dc3..c8f015d48 100644 --- a/app/Models/Node.php +++ b/app/Models/Node.php @@ -55,6 +55,7 @@ use Symfony\Component\Yaml\Yaml; * @property int|null $allocations_count * @property Role[]|Collection $roles * @property int|null $roles_count + * @property BackupHost[]|Collection $backupHosts */ class Node extends Model implements Validatable { @@ -269,14 +270,17 @@ class Node extends Model implements Validatable return $this->hasMany(Allocation::class); } - /** - * @return BelongsToMany - */ + /** @return BelongsToMany */ public function databaseHosts(): BelongsToMany { return $this->belongsToMany(DatabaseHost::class); } + public function backupHosts(): BelongsToMany + { + return $this->BelongsToMany(BackupHost::class); + } + public function roles(): HasManyThrough { return $this->hasManyThrough(Role::class, NodeRole::class, 'node_id', 'id', 'id', 'role_id'); diff --git a/app/Policies/BackupHostPolicy.php b/app/Policies/BackupHostPolicy.php new file mode 100644 index 000000000..dde248a68 --- /dev/null +++ b/app/Policies/BackupHostPolicy.php @@ -0,0 +1,29 @@ +nodes as $node) { + if (!$user->canTarget($node)) { + return false; + } + } + + return null; + } +} diff --git a/app/Providers/BackupsServiceProvider.php b/app/Providers/BackupsServiceProvider.php deleted file mode 100644 index 0d970afe3..000000000 --- a/app/Providers/BackupsServiceProvider.php +++ /dev/null @@ -1,28 +0,0 @@ -app->singleton(BackupManager::class, function ($app) { - return new BackupManager($app); - }); - } - - /** - * @return class-string[] - */ - public function provides(): array - { - return [BackupManager::class]; - } -} diff --git a/app/Providers/Extensions/BackupAdapterServiceProvider.php b/app/Providers/Extensions/BackupAdapterServiceProvider.php new file mode 100644 index 000000000..795668beb --- /dev/null +++ b/app/Providers/Extensions/BackupAdapterServiceProvider.php @@ -0,0 +1,26 @@ +app->singleton(BackupAdapterService::class, function ($app) { + $service = new BackupAdapterService(); + + // Default Backup adapter providers + $service->register(new WingsBackupSchema($app->make(DaemonBackupRepository::class), $app->make(NodeJWTService::class))); + $service->register(new S3BackupSchema($app->make(DaemonBackupRepository::class))); + + return $service; + }); + } +} diff --git a/app/Repositories/Daemon/DaemonBackupRepository.php b/app/Repositories/Daemon/DaemonBackupRepository.php index 4ec41d9b0..61515f29b 100644 --- a/app/Repositories/Daemon/DaemonBackupRepository.php +++ b/app/Repositories/Daemon/DaemonBackupRepository.php @@ -8,14 +8,14 @@ use Illuminate\Http\Client\Response; class DaemonBackupRepository extends DaemonRepository { - protected ?string $adapter; + protected ?string $schema; /** - * Sets the backup adapter for this execution instance. + * Sets the backup schema for this execution instance. */ - public function setBackupAdapter(string $adapter): self + public function setBackupSchema(string $schema): self { - $this->adapter = $adapter; + $this->schema = $schema; return $this; } @@ -25,11 +25,11 @@ class DaemonBackupRepository extends DaemonRepository * * @throws ConnectionException */ - public function backup(Backup $backup): Response + public function create(Backup $backup): Response { return $this->getHttpClient()->post("/api/servers/{$this->server->uuid}/backup", [ - 'adapter' => $this->adapter ?? config('backups.default'), + 'adapter' => $this->schema ?? config('backups.default'), 'uuid' => $backup->uuid, 'ignore' => implode("\n", $backup->ignored_files), ] diff --git a/app/Services/Backups/DeleteBackupService.php b/app/Services/Backups/DeleteBackupService.php index c5064a2d0..92ad24837 100644 --- a/app/Services/Backups/DeleteBackupService.php +++ b/app/Services/Backups/DeleteBackupService.php @@ -3,23 +3,15 @@ namespace App\Services\Backups; use App\Exceptions\Service\Backup\BackupLockedException; -use App\Extensions\Backups\BackupManager; -use App\Extensions\Filesystem\S3Filesystem; +use App\Extensions\BackupAdapter\BackupAdapterService; use App\Models\Backup; -use App\Repositories\Daemon\DaemonBackupRepository; -use Aws\S3\S3Client; use Exception; use Illuminate\Database\ConnectionInterface; -use Illuminate\Http\Response; use Throwable; class DeleteBackupService { - public function __construct( - private ConnectionInterface $connection, - private BackupManager $manager, - private DaemonBackupRepository $daemonBackupRepository - ) {} + public function __construct(private ConnectionInterface $connection, private BackupAdapterService $backupService) {} /** * Deletes a backup from the system. If the backup is stored in S3 a request @@ -39,47 +31,15 @@ class DeleteBackupService throw new BackupLockedException(); } - if ($backup->disk === Backup::ADAPTER_AWS_S3) { - $this->deleteFromS3($backup); - - return; + $schema = $this->backupService->get($backup->disk); + if (!$schema) { + throw new Exception("Backup uses unknown disk $backup->disk."); } - $this->connection->transaction(function () use ($backup) { - try { - $this->daemonBackupRepository->setServer($backup->server)->delete($backup); - } catch (Exception $exception) { - // Don't fail the request if the Daemon responds with a 404, just assume the backup - // doesn't actually exist and remove its reference from the Panel as well. - if ($exception->getCode() !== Response::HTTP_NOT_FOUND) { - throw $exception; - } - } + $this->connection->transaction(function () use ($schema, $backup) { + $schema->deleteBackup($backup); $backup->delete(); }); } - - /** - * Deletes a backup from an S3 disk. - * - * @throws Throwable - */ - protected function deleteFromS3(Backup $backup): void - { - $this->connection->transaction(function () use ($backup) { - $backup->delete(); - - /** @var S3Filesystem $adapter */ - $adapter = $this->manager->adapter(Backup::ADAPTER_AWS_S3); - - /** @var S3Client $client */ - $client = $adapter->getClient(); - - $client->deleteObject([ - 'Bucket' => $adapter->getBucket(), - 'Key' => sprintf('%s/%s.tar.gz', $backup->server->uuid, $backup->uuid), - ]); - }); - } } diff --git a/app/Services/Backups/DownloadLinkService.php b/app/Services/Backups/DownloadLinkService.php index 0b3eaba15..3aa1d3645 100644 --- a/app/Services/Backups/DownloadLinkService.php +++ b/app/Services/Backups/DownloadLinkService.php @@ -2,19 +2,14 @@ namespace App\Services\Backups; -use App\Extensions\Backups\BackupManager; -use App\Extensions\Filesystem\S3Filesystem; +use App\Extensions\BackupAdapter\BackupAdapterService; use App\Models\Backup; use App\Models\User; -use App\Services\Nodes\NodeJWTService; -use Carbon\CarbonImmutable; +use Exception; class DownloadLinkService { - /** - * DownloadLinkService constructor. - */ - public function __construct(private BackupManager $backupManager, private NodeJWTService $jwtService) {} + public function __construct(private BackupAdapterService $backupService) {} /** * Returns the URL that allows for a backup to be downloaded by an individual @@ -22,40 +17,11 @@ class DownloadLinkService */ public function handle(Backup $backup, User $user): string { - if ($backup->disk === Backup::ADAPTER_AWS_S3) { - return $this->getS3BackupUrl($backup); + $schema = $this->backupService->get($backup->disk); + if (!$schema) { + throw new Exception("Backup uses unknown disk $backup->disk."); } - $token = $this->jwtService - ->setExpiresAt(CarbonImmutable::now()->addMinutes(15)) - ->setUser($user) - ->setClaims([ - 'backup_uuid' => $backup->uuid, - 'server_uuid' => $backup->server->uuid, - ]) - ->handle($backup->server->node, $user->id . $backup->server->uuid); - - return sprintf('%s/download/backup?token=%s', $backup->server->node->getConnectionAddress(), $token->toString()); - } - - /** - * Returns a signed URL that allows us to download a file directly out of a non-public - * S3 bucket by using a signed URL. - */ - protected function getS3BackupUrl(Backup $backup): string - { - /** @var S3Filesystem $adapter */ - $adapter = $this->backupManager->adapter(Backup::ADAPTER_AWS_S3); - - $request = $adapter->getClient()->createPresignedRequest( - $adapter->getClient()->getCommand('GetObject', [ - 'Bucket' => $adapter->getBucket(), - 'Key' => sprintf('%s/%s.tar.gz', $backup->server->uuid, $backup->uuid), - 'ContentType' => 'application/x-gzip', - ]), - CarbonImmutable::now()->addMinutes(5) - ); - - return $request->getUri()->__toString(); + return $schema->getDownloadLink($backup, $user); } } diff --git a/app/Services/Backups/InitiateBackupService.php b/app/Services/Backups/InitiateBackupService.php index 67aff8646..45637a137 100644 --- a/app/Services/Backups/InitiateBackupService.php +++ b/app/Services/Backups/InitiateBackupService.php @@ -3,10 +3,10 @@ namespace App\Services\Backups; use App\Exceptions\Service\Backup\TooManyBackupsException; -use App\Extensions\Backups\BackupManager; +use App\Extensions\BackupAdapter\BackupAdapterService; use App\Models\Backup; use App\Models\Server; -use App\Repositories\Daemon\DaemonBackupRepository; +use Exception; use Illuminate\Database\ConnectionInterface; use Ramsey\Uuid\Uuid; use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException; @@ -25,9 +25,8 @@ class InitiateBackupService */ public function __construct( private readonly ConnectionInterface $connection, - private readonly DaemonBackupRepository $daemonBackupRepository, private readonly DeleteBackupService $deleteBackupService, - private readonly BackupManager $backupManager + private readonly BackupAdapterService $backupService ) {} /** @@ -110,20 +109,19 @@ class InitiateBackupService $this->deleteBackupService->handle($oldest); } - return $this->connection->transaction(function () use ($server, $name) { - /** @var Backup $backup */ - $backup = Backup::query()->create([ + $schema = $this->backupService->get(collect($server->node->backupHosts)->first()->schema ?? config('backups.default')); + + return $this->connection->transaction(function () use ($schema, $server, $name) { + $backup = Backup::create([ 'server_id' => $server->id, 'uuid' => Uuid::uuid4()->toString(), 'name' => trim($name) ?: sprintf('Backup at %s', now()->toDateTimeString()), 'ignored_files' => array_values($this->ignoredFiles ?? []), - 'disk' => $this->backupManager->getDefaultAdapter(), + 'disk' => $schema->getId(), 'is_locked' => $this->isLocked, ]); - $this->daemonBackupRepository->setServer($server) - ->setBackupAdapter($this->backupManager->getDefaultAdapter()) - ->backup($backup); + $schema->createBackup($backup); return $backup; }); diff --git a/bootstrap/providers.php b/bootstrap/providers.php index c1f98d09d..2443be0c4 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -3,9 +3,9 @@ return [ App\Providers\ActivityLogServiceProvider::class, App\Providers\AppServiceProvider::class, - App\Providers\BackupsServiceProvider::class, App\Providers\EventServiceProvider::class, App\Providers\Extensions\AvatarServiceProvider::class, + App\Providers\Extensions\BackupAdapterServiceProvider::class, App\Providers\Extensions\CaptchaServiceProvider::class, App\Providers\Extensions\FeatureServiceProvider::class, App\Providers\Extensions\OAuthServiceProvider::class, diff --git a/database/migrations/2026_01_16_081858_create_backup_hosts_table.php b/database/migrations/2026_01_16_081858_create_backup_hosts_table.php new file mode 100644 index 000000000..73ea027e1 --- /dev/null +++ b/database/migrations/2026_01_16_081858_create_backup_hosts_table.php @@ -0,0 +1,44 @@ +id(); + $table->string('name'); + $table->string('schema'); + $table->json('configuration')->nullable(); + $table->timestamps(); + }); + + Schema::create('backup_host_node', function (Blueprint $table) { + $table->unsignedInteger('node_id'); + $table->foreign('node_id')->references('id')->on('nodes')->cascadeOnDelete(); + + $table->unsignedInteger('backup_host_id'); + $table->foreign('backup_host_id')->references('id')->on('backup_hosts')->cascadeOnDelete(); + + $table->timestamps(); + + $table->unique(['node_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('backup_hosts'); + + Schema::dropIfExists('backup_host_node'); + } +}; diff --git a/lang/en/admin/backuphost.php b/lang/en/admin/backuphost.php new file mode 100644 index 000000000..224fe3f6d --- /dev/null +++ b/lang/en/admin/backuphost.php @@ -0,0 +1,13 @@ + 'Backup Host|Backup Hosts', + 'model_label_plural' => 'Database Hosts', + 'name' => 'Name', + 'schema' => 'Schema', + 'backups' => 'Backups', + 'linked_nodes' => 'Linked Nodes', + 'no_nodes' => 'No Nodes', + 'no_backup_hosts' => 'No Backup Hosts', + 'local_backups_only' => 'All backups will be created locally on the respective node', +]; diff --git a/tests/Integration/Services/Backups/DeleteBackupServiceTest.php b/tests/Integration/Services/Backups/DeleteBackupServiceTest.php index b8a796c2c..3f1f63aaf 100644 --- a/tests/Integration/Services/Backups/DeleteBackupServiceTest.php +++ b/tests/Integration/Services/Backups/DeleteBackupServiceTest.php @@ -3,8 +3,6 @@ namespace App\Tests\Integration\Services\Backups; use App\Exceptions\Service\Backup\BackupLockedException; -use App\Extensions\Backups\BackupManager; -use App\Extensions\Filesystem\S3Filesystem; use App\Models\Backup; use App\Repositories\Daemon\DaemonBackupRepository; use App\Services\Backups\DeleteBackupService; @@ -77,28 +75,4 @@ class DeleteBackupServiceTest extends IntegrationTestCase $this->assertNull($backup->deleted_at); } - - public function test_s3_object_can_be_deleted(): void - { - $server = $this->createServerModel(); - $backup = Backup::factory()->create([ - 'disk' => Backup::ADAPTER_AWS_S3, - 'server_id' => $server->id, - ]); - - $manager = $this->mock(BackupManager::class); - $adapter = $this->mock(S3Filesystem::class); - - $manager->expects('adapter')->with(Backup::ADAPTER_AWS_S3)->andReturn($adapter); - - $adapter->expects('getBucket')->andReturn('foobar'); - $adapter->expects('getClient->deleteObject')->with([ - 'Bucket' => 'foobar', - 'Key' => sprintf('%s/%s.tar.gz', $server->uuid, $backup->uuid), - ]); - - $this->app->make(DeleteBackupService::class)->handle($backup); - - $this->assertSoftDeleted($backup); - } }