mirror of
https://github.com/pelican-dev/panel.git
synced 2026-05-04 18:00:48 +03:00
Compare commits
3 Commits
boy132/bac
...
boy132/rep
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
670a9c1a97 | ||
|
|
2bbfb0eef9 | ||
|
|
2a64ea8536 |
@@ -41,17 +41,8 @@ enum ContainerStatus: string implements HasColor, HasIcon, HasLabel
|
||||
};
|
||||
}
|
||||
|
||||
public function getColor(bool $hex = false): string
|
||||
public function getColor(): string
|
||||
{
|
||||
if ($hex) {
|
||||
return match ($this) {
|
||||
self::Created, self::Restarting => '#2563EB',
|
||||
self::Starting, self::Paused, self::Removing, self::Stopping => '#D97706',
|
||||
self::Running => '#22C55E',
|
||||
self::Exited, self::Missing, self::Dead, self::Offline => '#EF4444',
|
||||
};
|
||||
}
|
||||
|
||||
return match ($this) {
|
||||
self::Created => 'primary',
|
||||
self::Starting => 'warning',
|
||||
|
||||
@@ -6,7 +6,6 @@ enum RolePermissionModels: string
|
||||
{
|
||||
case ApiKey = 'apiKey';
|
||||
case Allocation = 'allocation';
|
||||
case BackupHost = 'backupHost';
|
||||
case DatabaseHost = 'databaseHost';
|
||||
case Database = 'database';
|
||||
case Egg = 'egg';
|
||||
|
||||
@@ -25,16 +25,8 @@ enum ServerState: string implements HasColor, HasIcon, HasLabel
|
||||
};
|
||||
}
|
||||
|
||||
public function getColor(bool $hex = false): string
|
||||
public function getColor(): string
|
||||
{
|
||||
if ($hex) {
|
||||
return match ($this) {
|
||||
self::Installing, self::RestoringBackup => '#2563EB',
|
||||
self::Suspended => '#D97706',
|
||||
self::InstallFailed, self::ReinstallFailed => '#EF4444',
|
||||
};
|
||||
}
|
||||
|
||||
return match ($this) {
|
||||
self::Installing => 'primary',
|
||||
self::InstallFailed => 'danger',
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Extensions\BackupAdapter;
|
||||
|
||||
use App\Models\Backup;
|
||||
use App\Models\User;
|
||||
use Filament\Schemas\Components\Component;
|
||||
|
||||
interface BackupAdapterSchemaInterface
|
||||
{
|
||||
public function getId(): string;
|
||||
|
||||
public function getName(): string;
|
||||
|
||||
public function createBackup(Backup $backup): void;
|
||||
|
||||
public function deleteBackup(Backup $backup): void;
|
||||
|
||||
public function getDownloadLink(Backup $backup, User $user): string;
|
||||
|
||||
/** @return Component[] */
|
||||
public function getConfigurationForm(): array;
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Extensions\BackupAdapter;
|
||||
|
||||
class BackupAdapterService
|
||||
{
|
||||
/** @var array<string, BackupAdapterSchemaInterface> */
|
||||
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<string, string> */
|
||||
public function getMappings(): array
|
||||
{
|
||||
return collect($this->schemas)->mapWithKeys(fn ($schema) => [$schema->getId() => $schema->getName()])->all();
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Extensions\BackupAdapter\Schemas;
|
||||
|
||||
use App\Extensions\BackupAdapter\BackupAdapterSchemaInterface;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
abstract class BackupAdapterSchema implements BackupAdapterSchemaInterface
|
||||
{
|
||||
public function getName(): string
|
||||
{
|
||||
return Str::title($this->getId());
|
||||
}
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Extensions\BackupAdapter\Schemas;
|
||||
|
||||
use App\Enums\TablerIcon;
|
||||
use App\Http\Controllers\Api\Remote\Backups\BackupRemoteUploadController;
|
||||
use App\Models\Backup;
|
||||
use App\Models\BackupHost;
|
||||
use App\Models\User;
|
||||
use App\Repositories\Daemon\DaemonBackupRepository;
|
||||
use Aws\S3\S3Client;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Exception;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Schemas\Components\Component;
|
||||
use Filament\Schemas\Components\StateCasts\BooleanStateCast;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
final class S3BackupSchema extends BackupAdapterSchema
|
||||
{
|
||||
public function __construct(private readonly DaemonBackupRepository $repository) {}
|
||||
|
||||
private function createClient(BackupHost $backupHost): S3Client
|
||||
{
|
||||
$config = $backupHost->configuration;
|
||||
$config['version'] = 'latest';
|
||||
|
||||
if (!empty($config['key']) && !empty($config['secret'])) {
|
||||
$config['credentials'] = Arr::only($config, ['key', 'secret', 'token']);
|
||||
}
|
||||
|
||||
return 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
|
||||
{
|
||||
$client = $this->createClient($backup->backupHost);
|
||||
|
||||
$client->deleteObject([
|
||||
'Bucket' => $backup->backupHost->configuration['bucket'],
|
||||
'Key' => "{$backup->server->uuid}/$backup->uuid.tar.gz",
|
||||
]);
|
||||
}
|
||||
|
||||
public function getDownloadLink(Backup $backup, User $user): string
|
||||
{
|
||||
$client = $this->createClient($backup->backupHost);
|
||||
|
||||
$request = $client->createPresignedRequest(
|
||||
$client->getCommand('GetObject', [
|
||||
'Bucket' => $backup->backupHost->configuration['bucket'],
|
||||
'Key' => "{$backup->server->uuid}/$backup->uuid.tar.gz",
|
||||
'ContentType' => 'application/x-gzip',
|
||||
]),
|
||||
CarbonImmutable::now()->addMinutes(5)
|
||||
);
|
||||
|
||||
return $request->getUri()->__toString();
|
||||
}
|
||||
|
||||
/** @return Component[] */
|
||||
public function getConfigurationForm(): array
|
||||
{
|
||||
return [
|
||||
TextInput::make('configuration.region')
|
||||
->label(trans('admin/setting.backup.s3.default_region'))
|
||||
->required(),
|
||||
TextInput::make('configuration.key')
|
||||
->label(trans('admin/setting.backup.s3.access_key'))
|
||||
->required(),
|
||||
TextInput::make('configuration.secret')
|
||||
->label(trans('admin/setting.backup.s3.secret_key'))
|
||||
->required(),
|
||||
TextInput::make('configuration.bucket')
|
||||
->label(trans('admin/setting.backup.s3.bucket'))
|
||||
->required(),
|
||||
TextInput::make('configuration.endpoint')
|
||||
->label(trans('admin/setting.backup.s3.endpoint'))
|
||||
->required(),
|
||||
Toggle::make('configuration.use_path_style_endpoint')
|
||||
->label(trans('admin/setting.backup.s3.use_path_style_endpoint'))
|
||||
->inline(false)
|
||||
->onIcon(TablerIcon::Check)
|
||||
->offIcon(TablerIcon::X)
|
||||
->onColor('success')
|
||||
->offColor('danger')
|
||||
->live()
|
||||
->stateCast(new BooleanStateCast(false)),
|
||||
];
|
||||
}
|
||||
|
||||
/** @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' => $backup->backupHost->configuration['bucket'],
|
||||
'Key' => "{$backup->server->uuid}/$backup->uuid.tar.gz",
|
||||
'ContentType' => 'application/x-gzip',
|
||||
];
|
||||
|
||||
$storageClass = $backup->backupHost->configuration['storage_class'];
|
||||
if (!is_null($storageClass)) {
|
||||
$params['StorageClass'] = $storageClass;
|
||||
}
|
||||
|
||||
$client = $this->createClient($backup->backupHost);
|
||||
|
||||
// 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 = 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[] = $client->createPresignedRequest(
|
||||
$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<array{int, etag: string, part_number: string}> $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' => $backup->backupHost->configuration['bucket'],
|
||||
'Key' => "{$backup->server->uuid}/$backup->uuid.tar.gz",
|
||||
'UploadId' => $backup->upload_id,
|
||||
];
|
||||
|
||||
$client = $this->createClient($backup->backupHost);
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Extensions\BackupAdapter\Schemas;
|
||||
|
||||
use App\Models\Backup;
|
||||
use App\Models\User;
|
||||
use App\Repositories\Daemon\DaemonBackupRepository;
|
||||
use App\Services\Nodes\NodeJWTService;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Exception;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Schemas\Components\Component;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
final class WingsBackupSchema extends BackupAdapterSchema
|
||||
{
|
||||
public function __construct(private readonly DaemonBackupRepository $repository, private readonly NodeJWTService $jwtService) {}
|
||||
|
||||
public function getId(): string
|
||||
{
|
||||
return 'wings';
|
||||
}
|
||||
|
||||
public function createBackup(Backup $backup): void
|
||||
{
|
||||
$this->repository->setServer($backup->server)->create($backup);
|
||||
}
|
||||
|
||||
/** @throws Exception */
|
||||
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();
|
||||
}
|
||||
|
||||
/** @return Component[] */
|
||||
public function getConfigurationForm(): array
|
||||
{
|
||||
return [
|
||||
TextEntry::make(trans('admin/backuphost.no_configuration')),
|
||||
];
|
||||
}
|
||||
}
|
||||
177
app/Extensions/Backups/BackupManager.php
Normal file
177
app/Extensions/Backups/BackupManager.php
Normal file
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
|
||||
namespace App\Extensions\Backups;
|
||||
|
||||
use App\Extensions\Filesystem\S3Filesystem;
|
||||
use Aws\S3\S3Client;
|
||||
use Closure;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Str;
|
||||
use InvalidArgumentException;
|
||||
use League\Flysystem\FilesystemAdapter;
|
||||
use League\Flysystem\InMemory\InMemoryFilesystemAdapter;
|
||||
use Webmozart\Assert\Assert;
|
||||
|
||||
class BackupManager
|
||||
{
|
||||
/**
|
||||
* The array of resolved backup drivers.
|
||||
*
|
||||
* @var array<string, FilesystemAdapter>
|
||||
*/
|
||||
protected array $adapters = [];
|
||||
|
||||
/**
|
||||
* The registered custom driver creators.
|
||||
*
|
||||
* @var array<string, callable>
|
||||
*/
|
||||
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<string, string> $config
|
||||
*/
|
||||
public function createWingsAdapter(array $config): FilesystemAdapter
|
||||
{
|
||||
return new InMemoryFilesystemAdapter();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new S3 adapter.
|
||||
*
|
||||
* @param array<string, string> $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<mixed>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
38
app/Extensions/Filesystem/S3Filesystem.php
Normal file
38
app/Extensions/Filesystem/S3Filesystem.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Extensions\Filesystem;
|
||||
|
||||
use Aws\S3\S3ClientInterface;
|
||||
use League\Flysystem\AwsS3V3\AwsS3V3Adapter;
|
||||
|
||||
class S3Filesystem extends AwsS3V3Adapter
|
||||
{
|
||||
/**
|
||||
* @param array<mixed> $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;
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ use App\Services\Backups\InitiateBackupService;
|
||||
|
||||
final class CreateBackupSchema extends TaskSchema
|
||||
{
|
||||
public function __construct(private InitiateBackupService $initiateService) {}
|
||||
public function __construct(private InitiateBackupService $backupService) {}
|
||||
|
||||
public function getId(): string
|
||||
{
|
||||
@@ -17,7 +17,7 @@ final class CreateBackupSchema extends TaskSchema
|
||||
|
||||
public function runTask(Task $task): void
|
||||
{
|
||||
$this->initiateService->setIgnoredFiles(explode(PHP_EOL, $task->payload))->handle($task->server, null, true);
|
||||
$this->backupService->setIgnoredFiles(explode(PHP_EOL, $task->payload))->handle($task->server, null, true);
|
||||
}
|
||||
|
||||
public function canCreate(Schedule $schedule): bool
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Enums\TablerIcon;
|
||||
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;
|
||||
@@ -489,6 +490,16 @@ 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()
|
||||
@@ -508,6 +519,41 @@ 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(TablerIcon::Check)
|
||||
->offIcon(TablerIcon::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'))),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Admin\Resources\BackupHosts;
|
||||
|
||||
use App\Enums\TablerIcon;
|
||||
use App\Extensions\BackupAdapter\BackupAdapterService;
|
||||
use App\Filament\Admin\Resources\BackupHosts\Pages\CreateBackupHost;
|
||||
use App\Filament\Admin\Resources\BackupHosts\Pages\EditBackupHost;
|
||||
use App\Filament\Admin\Resources\BackupHosts\Pages\ListBackupHosts;
|
||||
use App\Filament\Admin\Resources\BackupHosts\Pages\ViewBackupHost;
|
||||
use App\Filament\Admin\Resources\BackupHosts\RelationManagers\BackupsRelationManager;
|
||||
use App\Models\BackupHost;
|
||||
use App\Traits\Filament\CanCustomizePages;
|
||||
use App\Traits\Filament\CanCustomizeRelations;
|
||||
use App\Traits\Filament\CanModifyForm;
|
||||
use App\Traits\Filament\CanModifyTable;
|
||||
use BackedEnum;
|
||||
use Exception;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Actions\ViewAction;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Resources\Pages\PageRegistration;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class BackupHostResource extends Resource
|
||||
{
|
||||
use CanCustomizePages;
|
||||
use CanCustomizeRelations;
|
||||
use CanModifyForm;
|
||||
use CanModifyTable;
|
||||
|
||||
protected static ?string $model = BackupHost::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = TablerIcon::FileZip;
|
||||
|
||||
protected static ?string $recordTitleAttribute = 'name';
|
||||
|
||||
public static function getNavigationBadge(): ?string
|
||||
{
|
||||
return (string) static::getEloquentQuery()->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.all_nodes')),
|
||||
])
|
||||
->recordActions([
|
||||
ViewAction::make()
|
||||
->hidden(fn ($record) => static::getEditAuthorizationResponse($record)->allowed()),
|
||||
EditAction::make(),
|
||||
])
|
||||
->toolbarActions([
|
||||
CreateAction::make(),
|
||||
])
|
||||
->emptyStateIcon(TablerIcon::FileZip)
|
||||
->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())
|
||||
->live(onBlur: true),
|
||||
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'))),
|
||||
Section::make(trans('admin/backuphost.configuration'))
|
||||
->columnSpanFull()
|
||||
->columns()
|
||||
->schema(function (?BackupHost $backupHost, Get $get, BackupAdapterService $service) {
|
||||
$schema = $get('schema') ?? $backupHost?->schema;
|
||||
|
||||
if (!$schema) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$schema = $service->get($schema);
|
||||
|
||||
if ($schema) {
|
||||
return $schema->getConfigurationForm();
|
||||
}
|
||||
|
||||
return [];
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return class-string<RelationManager>[] */
|
||||
public static function getDefaultRelations(): array
|
||||
{
|
||||
return [
|
||||
BackupsRelationManager::class,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string, PageRegistration> */
|
||||
public static function getDefaultPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListBackupHosts::route('/'),
|
||||
'create' => CreateBackupHost::route('/create'),
|
||||
'view' => ViewBackupHost::route('/{record}'),
|
||||
'edit' => EditBackupHost::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Admin\Resources\BackupHosts\Pages;
|
||||
|
||||
use App\Filament\Admin\Resources\BackupHosts\BackupHostResource;
|
||||
use App\Traits\Filament\CanCustomizeHeaderActions;
|
||||
use App\Traits\Filament\CanCustomizeHeaderWidgets;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateBackupHost extends CreateRecord
|
||||
{
|
||||
use CanCustomizeHeaderActions;
|
||||
use CanCustomizeHeaderWidgets;
|
||||
|
||||
protected static string $resource = BackupHostResource::class;
|
||||
|
||||
protected static bool $canCreateAnother = false;
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Admin\Resources\BackupHosts\Pages;
|
||||
|
||||
use App\Enums\TablerIcon;
|
||||
use App\Filament\Admin\Resources\BackupHosts\BackupHostResource;
|
||||
use App\Models\BackupHost;
|
||||
use App\Traits\Filament\CanCustomizeHeaderActions;
|
||||
use App\Traits\Filament\CanCustomizeHeaderWidgets;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditBackupHost extends EditRecord
|
||||
{
|
||||
use CanCustomizeHeaderActions;
|
||||
use CanCustomizeHeaderWidgets;
|
||||
|
||||
protected static string $resource = BackupHostResource::class;
|
||||
|
||||
/** @return array<Action|ActionGroup> */
|
||||
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)
|
||||
->hidden(fn () => BackupHost::count() === 1),
|
||||
Action::make('save')
|
||||
->hiddenLabel()
|
||||
->action('save')
|
||||
->keyBindings(['mod+s'])
|
||||
->tooltip(trans('filament-panels::resources/pages/edit-record.form.actions.save.label'))
|
||||
->icon(TablerIcon::DeviceFloppy),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getFormActions(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Admin\Resources\BackupHosts\Pages;
|
||||
|
||||
use App\Filament\Admin\Resources\BackupHosts\BackupHostResource;
|
||||
use App\Traits\Filament\CanCustomizeHeaderActions;
|
||||
use App\Traits\Filament\CanCustomizeHeaderWidgets;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListBackupHosts extends ListRecords
|
||||
{
|
||||
use CanCustomizeHeaderActions;
|
||||
use CanCustomizeHeaderWidgets;
|
||||
|
||||
protected static string $resource = BackupHostResource::class;
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Admin\Resources\BackupHosts\Pages;
|
||||
|
||||
use App\Filament\Admin\Resources\BackupHosts\BackupHostResource;
|
||||
use App\Traits\Filament\CanCustomizeHeaderActions;
|
||||
use App\Traits\Filament\CanCustomizeHeaderWidgets;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
class ViewBackupHost extends ViewRecord
|
||||
{
|
||||
use CanCustomizeHeaderActions;
|
||||
use CanCustomizeHeaderWidgets;
|
||||
|
||||
protected static string $resource = BackupHostResource::class;
|
||||
|
||||
/** @return array<Action|ActionGroup> */
|
||||
protected function getDefaultHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
EditAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Admin\Resources\BackupHosts\RelationManagers;
|
||||
|
||||
use App\Enums\TablerIcon;
|
||||
use App\Filament\Components\Tables\Columns\BytesColumn;
|
||||
use App\Filament\Components\Tables\Columns\DateTimeColumn;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class BackupsRelationManager extends RelationManager
|
||||
{
|
||||
protected static string $relationship = 'backups';
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->recordTitleAttribute('name')
|
||||
->heading(null)
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->label(trans('server/backup.actions.create.name'))
|
||||
->searchable(),
|
||||
BytesColumn::make('bytes')
|
||||
->label(trans('server/backup.size')),
|
||||
DateTimeColumn::make('created_at')
|
||||
->label(trans('server/backup.created_at'))
|
||||
->since()
|
||||
->sortable(),
|
||||
TextColumn::make('status')
|
||||
->label(trans('server/backup.status'))
|
||||
->badge(),
|
||||
IconColumn::make('is_locked')
|
||||
->label(trans('server/backup.is_locked'))
|
||||
->visibleFrom('md')
|
||||
->trueIcon(TablerIcon::Lock)
|
||||
->falseIcon(TablerIcon::LockOpen),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -64,9 +64,8 @@ class CreateNode extends CreateRecord
|
||||
->icon(TablerIcon::Server)
|
||||
->columnSpanFull()
|
||||
->columns([
|
||||
'default' => 2,
|
||||
'sm' => 3,
|
||||
'md' => 3,
|
||||
'default' => 1,
|
||||
'md' => 2,
|
||||
'lg' => 4,
|
||||
])
|
||||
->schema([
|
||||
@@ -76,81 +75,83 @@ class CreateNode extends CreateRecord
|
||||
->autofocus()
|
||||
->live(debounce: 1500)
|
||||
->rules(Node::getRulesForField('fqdn'))
|
||||
->prohibited(fn ($state) => is_ip($state) && request()->isSecure())
|
||||
->label(fn ($state) => is_ip($state) ? trans('admin/node.ip_address') : trans('admin/node.domain'))
|
||||
->placeholder(fn ($state) => is_ip($state) ? '192.168.1.1' : 'node.example.com')
|
||||
->helperText(function ($state) {
|
||||
->helperText(fn () => request()->isSecure() ? trans('admin/node.fqdn_ssl') : null)
|
||||
->validationMessages([
|
||||
'prohibited' => trans('admin/node.dns_error'),
|
||||
])
|
||||
->prohibited(function ($state, Get $get) {
|
||||
if (!$state) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (is_ip($state)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$ip = $get('ip');
|
||||
|
||||
return !is_ip($ip);
|
||||
})
|
||||
->hintColor(function ($state, Get $get) {
|
||||
if (!$state) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_ip($state)) {
|
||||
if (request()->isSecure()) {
|
||||
return trans('admin/node.fqdn_help');
|
||||
return 'warning';
|
||||
}
|
||||
} else {
|
||||
$ip = $get('ip');
|
||||
|
||||
return '';
|
||||
return is_ip($ip) ? 'success' : 'danger';
|
||||
}
|
||||
|
||||
return trans('admin/node.error');
|
||||
return null;
|
||||
})
|
||||
->hintColor('danger')
|
||||
->hint(function ($state) {
|
||||
if (is_ip($state) && request()->isSecure()) {
|
||||
return trans('admin/node.ssl_ip');
|
||||
->hint(function ($state, Get $get) {
|
||||
if (!$state) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return '';
|
||||
if (is_ip($state)) {
|
||||
if (request()->isSecure()) {
|
||||
return trans('admin/node.ssl_ip');
|
||||
}
|
||||
} else {
|
||||
$ip = $get('ip');
|
||||
|
||||
return is_ip($ip) ? trans('admin/node.valid') . ': ' . $ip : trans('admin/node.invalid');
|
||||
}
|
||||
|
||||
return null;
|
||||
})
|
||||
->afterStateUpdated(function (Set $set, ?string $state) {
|
||||
$set('dns', null);
|
||||
$set('ip', null);
|
||||
|
||||
if (!$state) {
|
||||
return;
|
||||
}
|
||||
|
||||
[$subdomain] = str($state)->explode('.', 2);
|
||||
if (!is_numeric($subdomain)) {
|
||||
$set('name', $subdomain);
|
||||
}
|
||||
|
||||
if (!$state || is_ip($state)) {
|
||||
$set('dns', null);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$ip = get_ip_from_hostname($state);
|
||||
if ($ip) {
|
||||
$set('dns', true);
|
||||
|
||||
$set('ip', $ip);
|
||||
} else {
|
||||
$set('dns', false);
|
||||
if (!is_ip($state)) {
|
||||
$ip = get_ip_from_hostname($state);
|
||||
if (is_ip($ip)) {
|
||||
$set('ip', $ip);
|
||||
} else {
|
||||
$set('ip', null);
|
||||
}
|
||||
}
|
||||
})
|
||||
->maxLength(255),
|
||||
|
||||
TextInput::make('ip')
|
||||
->disabled()
|
||||
->hidden(),
|
||||
|
||||
ToggleButtons::make('dns')
|
||||
->label(trans('admin/node.dns'))
|
||||
->helperText(trans('admin/node.dns_help'))
|
||||
->disabled()
|
||||
->inline()
|
||||
->default(null)
|
||||
->hint(fn (Get $get) => $get('ip'))
|
||||
->hintColor('success')
|
||||
->options([
|
||||
true => trans('admin/node.valid'),
|
||||
false => trans('admin/node.invalid'),
|
||||
])
|
||||
->colors([
|
||||
true => 'success',
|
||||
false => 'danger',
|
||||
])
|
||||
->columnSpan([
|
||||
'default' => 1,
|
||||
'sm' => 1,
|
||||
'md' => 1,
|
||||
'lg' => 1,
|
||||
]),
|
||||
|
||||
Hidden::make('ip')
|
||||
->saved(false),
|
||||
TextInput::make('daemon_connect')
|
||||
->columnSpan(1)
|
||||
->label(fn (Get $get) => $get('connection') === 'https_proxy' ? trans('admin/node.connect_port') : trans('admin/node.port'))
|
||||
@@ -160,7 +161,16 @@ class CreateNode extends CreateRecord
|
||||
->default(8080)
|
||||
->required()
|
||||
->integer(),
|
||||
|
||||
TextInput::make('daemon_listen')
|
||||
->columnSpan(1)
|
||||
->label(trans('admin/node.listen_port'))
|
||||
->helperText(trans('admin/node.listen_port_help'))
|
||||
->minValue(1)
|
||||
->maxValue(65535)
|
||||
->default(8080)
|
||||
->required()
|
||||
->integer()
|
||||
->visible(fn (Get $get) => $get('connection') === 'https_proxy'),
|
||||
TextInput::make('name')
|
||||
->label(trans('admin/node.display_name'))
|
||||
->columnSpan([
|
||||
@@ -171,27 +181,16 @@ class CreateNode extends CreateRecord
|
||||
])
|
||||
->required()
|
||||
->maxLength(100),
|
||||
|
||||
Hidden::make('scheme')
|
||||
->default(fn () => request()->isSecure() ? 'https' : 'http'),
|
||||
|
||||
Hidden::make('behind_proxy')
|
||||
->default(false),
|
||||
|
||||
ToggleButtons::make('connection')
|
||||
->label(trans('admin/node.ssl'))
|
||||
->columnSpan(1)
|
||||
->columnSpan(2)
|
||||
->inline()
|
||||
->helperText(function (Get $get) {
|
||||
->helperText(function () {
|
||||
if (request()->isSecure()) {
|
||||
return new HtmlString(trans('admin/node.panel_on_ssl'));
|
||||
return trans('admin/node.panel_on_ssl');
|
||||
}
|
||||
|
||||
if (is_ip($get('fqdn'))) {
|
||||
return trans('admin/node.ssl_help');
|
||||
}
|
||||
|
||||
return '';
|
||||
return null;
|
||||
})
|
||||
->disableOptionWhen(fn (string $value) => $value === 'http' && request()->isSecure())
|
||||
->options([
|
||||
@@ -219,17 +218,10 @@ class CreateNode extends CreateRecord
|
||||
$set('daemon_connect', $state === 'https_proxy' ? 443 : 8080);
|
||||
$set('daemon_listen', 8080);
|
||||
}),
|
||||
|
||||
TextInput::make('daemon_listen')
|
||||
->columnSpan(1)
|
||||
->label(trans('admin/node.listen_port'))
|
||||
->helperText(trans('admin/node.listen_port_help'))
|
||||
->minValue(1)
|
||||
->maxValue(65535)
|
||||
->default(8080)
|
||||
->required()
|
||||
->integer()
|
||||
->visible(fn (Get $get) => $get('connection') === 'https_proxy'),
|
||||
Hidden::make('scheme')
|
||||
->default(fn () => request()->isSecure() ? 'https' : 'http'),
|
||||
Hidden::make('behind_proxy')
|
||||
->default(false),
|
||||
]),
|
||||
Step::make('advanced')
|
||||
->label(trans('admin/node.tabs.advanced_settings'))
|
||||
|
||||
@@ -137,74 +137,83 @@ class EditNode extends EditRecord
|
||||
->autofocus()
|
||||
->live(debounce: 1500)
|
||||
->rules(Node::getRulesForField('fqdn'))
|
||||
->prohibited(fn ($state) => is_ip($state) && request()->isSecure())
|
||||
->label(fn ($state) => is_ip($state) ? trans('admin/node.ip_address') : trans('admin/node.domain'))
|
||||
->placeholder(fn ($state) => is_ip($state) ? '192.168.1.1' : 'node.example.com')
|
||||
->helperText(function ($state) {
|
||||
->helperText(fn () => request()->isSecure() ? trans('admin/node.fqdn_ssl') : null)
|
||||
->validationMessages([
|
||||
'prohibited' => trans('admin/node.dns_error'),
|
||||
])
|
||||
->prohibited(function ($state, Get $get) {
|
||||
if (!$state) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (is_ip($state)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$ip = $get('ip');
|
||||
|
||||
return !is_ip($ip);
|
||||
})
|
||||
->hintColor(function ($state, Get $get) {
|
||||
if (!$state) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_ip($state)) {
|
||||
if (request()->isSecure()) {
|
||||
return trans('admin/node.fqdn_help');
|
||||
return 'warning';
|
||||
}
|
||||
} else {
|
||||
$ip = $get('ip');
|
||||
|
||||
return '';
|
||||
return is_ip($ip) ? 'success' : 'danger';
|
||||
}
|
||||
|
||||
return trans('admin/node.error');
|
||||
return null;
|
||||
})
|
||||
->hintColor('danger')
|
||||
->hint(function ($state) {
|
||||
if (is_ip($state) && request()->isSecure()) {
|
||||
return trans('admin/node.ssl_ip');
|
||||
->hint(function ($state, Get $get) {
|
||||
if (!$state) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return '';
|
||||
if (is_ip($state)) {
|
||||
if (request()->isSecure()) {
|
||||
return trans('admin/node.ssl_ip');
|
||||
}
|
||||
} else {
|
||||
$ip = $get('ip');
|
||||
|
||||
return is_ip($ip) ? trans('admin/node.valid') . ': ' . $ip : trans('admin/node.invalid');
|
||||
}
|
||||
|
||||
return null;
|
||||
})
|
||||
->afterStateUpdated(function (Set $set, ?string $state) {
|
||||
$set('dns', null);
|
||||
$set('ip', null);
|
||||
|
||||
if (!$state) {
|
||||
return;
|
||||
}
|
||||
|
||||
[$subdomain] = str($state)->explode('.', 2);
|
||||
if (!is_numeric($subdomain)) {
|
||||
$set('name', $subdomain);
|
||||
}
|
||||
|
||||
if (!$state || is_ip($state)) {
|
||||
$set('dns', null);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$ip = get_ip_from_hostname($state);
|
||||
if ($ip) {
|
||||
$set('dns', true);
|
||||
|
||||
$set('ip', $ip);
|
||||
} else {
|
||||
$set('dns', false);
|
||||
if (!is_ip($state)) {
|
||||
$ip = get_ip_from_hostname($state);
|
||||
if (is_ip($ip)) {
|
||||
$set('ip', $ip);
|
||||
} else {
|
||||
$set('ip', null);
|
||||
}
|
||||
}
|
||||
})
|
||||
->maxLength(255),
|
||||
TextInput::make('ip')
|
||||
->disabled()
|
||||
->hidden(),
|
||||
ToggleButtons::make('dns')
|
||||
->label(trans('admin/node.dns'))
|
||||
->helperText(trans('admin/node.dns_help'))
|
||||
->disabled()
|
||||
->inline()
|
||||
->default(null)
|
||||
->hint(fn (Get $get) => $get('ip'))
|
||||
->hintColor('success')
|
||||
->stateCast(new BooleanStateCast(false, true))
|
||||
->options([
|
||||
1 => trans('admin/node.valid'),
|
||||
0 => trans('admin/node.invalid'),
|
||||
])
|
||||
->colors([
|
||||
1 => 'success',
|
||||
0 => 'danger',
|
||||
])
|
||||
->columnSpan(1),
|
||||
Hidden::make('ip')
|
||||
->saved(false),
|
||||
TextInput::make('daemon_connect')
|
||||
->columnSpan(1)
|
||||
->label(fn (Get $get) => $get('connection') === 'https_proxy' ? trans('admin/node.connect_port') : trans('admin/node.port'))
|
||||
@@ -214,6 +223,16 @@ class EditNode extends EditRecord
|
||||
->default(8080)
|
||||
->required()
|
||||
->integer(),
|
||||
TextInput::make('daemon_listen')
|
||||
->columnSpan(1)
|
||||
->label(trans('admin/node.listen_port'))
|
||||
->helperText(trans('admin/node.listen_port_help'))
|
||||
->minValue(1)
|
||||
->maxValue(65535)
|
||||
->default(8080)
|
||||
->required()
|
||||
->integer()
|
||||
->visible(fn (Get $get) => $get('connection') === 'https_proxy'),
|
||||
TextInput::make('name')
|
||||
->label(trans('admin/node.display_name'))
|
||||
->columnSpan([
|
||||
@@ -224,22 +243,16 @@ class EditNode extends EditRecord
|
||||
])
|
||||
->required()
|
||||
->maxLength(100),
|
||||
Hidden::make('scheme'),
|
||||
Hidden::make('behind_proxy'),
|
||||
ToggleButtons::make('connection')
|
||||
->label(trans('admin/node.ssl'))
|
||||
->columnSpan(1)
|
||||
->columnSpan(2)
|
||||
->inline()
|
||||
->helperText(function (Get $get) {
|
||||
->helperText(function () {
|
||||
if (request()->isSecure()) {
|
||||
return new HtmlString(trans('admin/node.panel_on_ssl'));
|
||||
return trans('admin/node.panel_on_ssl');
|
||||
}
|
||||
|
||||
if (is_ip($get('fqdn'))) {
|
||||
return trans('admin/node.ssl_help');
|
||||
}
|
||||
|
||||
return '';
|
||||
return null;
|
||||
})
|
||||
->disableOptionWhen(fn (string $value) => $value === 'http' && request()->isSecure())
|
||||
->options([
|
||||
@@ -267,16 +280,10 @@ class EditNode extends EditRecord
|
||||
$set('daemon_connect', $state === 'https_proxy' ? 443 : 8080);
|
||||
$set('daemon_listen', 8080);
|
||||
}),
|
||||
TextInput::make('daemon_listen')
|
||||
->columnSpan(1)
|
||||
->label(trans('admin/node.listen_port'))
|
||||
->helperText(trans('admin/node.listen_port_help'))
|
||||
->minValue(1)
|
||||
->maxValue(65535)
|
||||
->default(8080)
|
||||
->required()
|
||||
->integer()
|
||||
->visible(fn (Get $get) => $get('connection') === 'https_proxy'),
|
||||
Hidden::make('scheme')
|
||||
->default(fn () => request()->isSecure() ? 'https' : 'http'),
|
||||
Hidden::make('behind_proxy')
|
||||
->default(false),
|
||||
]),
|
||||
Tab::make('advanced_settings')
|
||||
->label(trans('admin/node.tabs.advanced_settings'))
|
||||
|
||||
@@ -4,8 +4,6 @@ namespace App\Filament\Admin\Resources\Servers\Pages;
|
||||
|
||||
use App\Enums\SuspendAction;
|
||||
use App\Enums\TablerIcon;
|
||||
use App\Extensions\BackupAdapter\BackupAdapterService;
|
||||
use App\Extensions\BackupAdapter\Schemas\WingsBackupSchema;
|
||||
use App\Filament\Admin\Resources\Servers\ServerResource;
|
||||
use App\Filament\Components\Actions\DeleteIcon;
|
||||
use App\Filament\Components\Actions\PreviewStartupAction;
|
||||
@@ -15,6 +13,7 @@ use App\Filament\Components\Forms\Fields\StartupVariable;
|
||||
use App\Filament\Components\StateCasts\ServerConditionStateCast;
|
||||
use App\Filament\Server\Pages\Console;
|
||||
use App\Models\Allocation;
|
||||
use App\Models\Backup;
|
||||
use App\Models\Egg;
|
||||
use App\Models\Server;
|
||||
use App\Models\User;
|
||||
@@ -851,20 +850,16 @@ class EditServer extends EditRecord
|
||||
->disabled(fn (Server $server) => user()?->accessibleNodes()->count() <= 1 || $server->isInConflictState())
|
||||
->modalHeading(trans('admin/server.transfer'))
|
||||
->schema($this->transferServer())
|
||||
->action(function (TransferServerService $transfer, BackupAdapterService $backupService, Server $server, $data) {
|
||||
->action(function (TransferServerService $transfer, Server $server, $data) {
|
||||
try {
|
||||
$selectedBackupUuids = Arr::get($data, 'backups', []);
|
||||
$transfer->handle($server, Arr::get($data, 'node_id'), Arr::get($data, 'allocation_id'), Arr::get($data, 'allocation_additional', []), $selectedBackupUuids);
|
||||
|
||||
$server->backups
|
||||
->whereNotIn('uuid', $selectedBackupUuids)
|
||||
->each(function ($backup) use ($backupService) {
|
||||
$schema = $backupService->get($backup->backupHost->schema);
|
||||
|
||||
// Wings backups that aren't transferred only need to be delete on the panel, wings will cleanup the backup files automatically
|
||||
if ($schema instanceof WingsBackupSchema) {
|
||||
$backup->delete();
|
||||
}
|
||||
->where('disk', Backup::ADAPTER_DAEMON)
|
||||
->each(function ($backup) {
|
||||
$backup->delete();
|
||||
});
|
||||
|
||||
Notification::make()
|
||||
@@ -956,17 +951,17 @@ class EditServer extends EditRecord
|
||||
->placeholder(trans('admin/server.select_additional')),
|
||||
Grid::make()
|
||||
->columnSpanFull()
|
||||
->schema(fn (BackupAdapterService $backupService) => [
|
||||
->schema([
|
||||
CheckboxList::make('backups')
|
||||
->label(trans('admin/server.backups'))
|
||||
->bulkToggleable()
|
||||
->options(fn (Server $server) => $server->backups->filter(fn ($backup) => $backupService->get($backup->backupHost->schema) instanceof WingsBackupSchema)->mapWithKeys(fn ($backup) => [$backup->uuid => $backup->name]))
|
||||
->columns(fn (Server $record) => (int) ceil($record->backups->filter(fn ($backup) => $backupService->get($backup->backupHost->schema) instanceof WingsBackupSchema)->count() / 4)),
|
||||
->options(fn (Server $server) => $server->backups->where('disk', Backup::ADAPTER_DAEMON)->mapWithKeys(fn ($backup) => [$backup->uuid => $backup->name]))
|
||||
->columns(fn (Server $record) => (int) ceil($record->backups->where('disk', Backup::ADAPTER_DAEMON)->count() / 4)),
|
||||
Text::make('backup_helper')
|
||||
->columnSpanFull()
|
||||
->content(trans('admin/server.warning_backups')),
|
||||
])
|
||||
->hidden(fn (Server $server, BackupAdapterService $backupService) => $server->backups->filter(fn ($backup) => $backupService->get($backup->backupHost->schema) instanceof WingsBackupSchema)->count() === 0),
|
||||
->hidden(fn (Server $server) => $server->backups->where('disk', Backup::ADAPTER_DAEMON)->count() === 0),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -21,9 +21,9 @@ class TagsFilter extends BaseFilter
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->query(fn (Builder $query, array $data) => $query->when($data['tag'], fn (Builder $query, $tag) => $query->whereJsonContains('tags', $tag)));
|
||||
$this->query(fn (Builder $query, array $data) => $query->when($data['tag'] ?? null, fn (Builder $query, $tag) => $query->whereJsonContains('tags', $tag)));
|
||||
|
||||
$this->indicateUsing(fn (array $data) => $data['tag'] ? 'Tag: ' . $data['tag'] : null);
|
||||
$this->indicateUsing(fn (array $data) => ($data['tag'] ?? null) ? 'Tag: ' . $data['tag'] : null);
|
||||
|
||||
$this->resetState(['tag' => null]);
|
||||
|
||||
|
||||
@@ -170,7 +170,7 @@ class BackupResource extends Resource
|
||||
->color('primary')
|
||||
->icon(TablerIcon::Download)
|
||||
->authorize(fn () => user()?->can(SubuserPermission::BackupDownload, $server))
|
||||
->url(fn (DownloadLinkService $downloadLinkService, Backup $backup) => $downloadLinkService->handle($backup, user()), true)
|
||||
->url(fn (DownloadLinkService $downloadLinkService, Backup $backup, Request $request) => $downloadLinkService->handle($backup, $request->user()), true)
|
||||
->visible(fn (Backup $backup) => $backup->status === BackupStatus::Successful),
|
||||
Action::make('exclude_restore')
|
||||
->label(trans('server/backup.actions.restore.title'))
|
||||
@@ -207,13 +207,17 @@ class BackupResource extends Resource
|
||||
->property(['name' => $backup->name, 'truncate' => $data['truncate']]);
|
||||
|
||||
$log->transaction(function () use ($downloadLinkService, $daemonRepository, $backup, $server, $data) {
|
||||
$url = $downloadLinkService->handle($backup, user());
|
||||
// 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());
|
||||
}
|
||||
|
||||
// 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, $data['truncate']);
|
||||
$daemonRepository->setServer($server)->restore($backup, $url ?? null, $data['truncate']);
|
||||
});
|
||||
|
||||
return Notification::make()
|
||||
|
||||
@@ -4,7 +4,6 @@ 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;
|
||||
@@ -34,7 +33,6 @@ class BackupController extends ClientApiController
|
||||
private readonly DeleteBackupService $deleteBackupService,
|
||||
private readonly InitiateBackupService $initiateBackupService,
|
||||
private readonly DownloadLinkService $downloadLinkService,
|
||||
private readonly BackupAdapterService $backupService
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
@@ -193,8 +191,7 @@ class BackupController extends ClientApiController
|
||||
throw new AuthorizationException();
|
||||
}
|
||||
|
||||
$schema = $this->backupService->get($backup->backupHost->schema);
|
||||
if (!$schema) {
|
||||
if ($backup->disk !== Backup::ADAPTER_AWS_S3 && $backup->disk !== Backup::ADAPTER_DAEMON) {
|
||||
throw new BadRequestHttpException('The backup requested references an unknown disk driver type and cannot be downloaded.');
|
||||
}
|
||||
|
||||
@@ -267,13 +264,17 @@ class BackupController extends ClientApiController
|
||||
->property(['name' => $backup->name, 'truncate' => $request->input('truncate')]);
|
||||
|
||||
$log->transaction(function () use ($backup, $server, $request) {
|
||||
$url = $this->downloadLinkService->handle($backup, $request->user());
|
||||
// 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());
|
||||
}
|
||||
|
||||
// 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, $request->input('truncate'));
|
||||
$this->daemonRepository->setServer($server)->restore($backup, $url ?? null, $request->input('truncate'));
|
||||
});
|
||||
|
||||
return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT);
|
||||
|
||||
@@ -3,16 +3,20 @@
|
||||
namespace App\Http\Controllers\Api\Remote\Backups;
|
||||
|
||||
use App\Exceptions\Http\HttpForbiddenException;
|
||||
use App\Extensions\BackupAdapter\BackupAdapterService;
|
||||
use App\Extensions\BackupAdapter\Schemas\S3BackupSchema;
|
||||
use App\Extensions\Backups\BackupManager;
|
||||
use App\Extensions\Filesystem\S3Filesystem;
|
||||
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
|
||||
{
|
||||
@@ -21,45 +25,117 @@ class BackupRemoteUploadController extends Controller
|
||||
/**
|
||||
* BackupRemoteUploadController constructor.
|
||||
*/
|
||||
public function __construct(private BackupAdapterService $backupService) {}
|
||||
public function __construct(private BackupManager $backupManager) {}
|
||||
|
||||
/**
|
||||
* Returns the required presigned urls to upload a backup to S3 cloud storage.
|
||||
*
|
||||
* @throws BadRequestHttpException
|
||||
* @throws Exception
|
||||
* @throws Throwable
|
||||
* @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.');
|
||||
}
|
||||
|
||||
$backup = Backup::where('uuid', $backup)->firstOrFail();
|
||||
/** @var Backup $model */
|
||||
$model = Backup::query()
|
||||
->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.
|
||||
if ($backup->server->node_id !== $node->id) {
|
||||
/** @var Server $server */
|
||||
$server = $model->server;
|
||||
if ($server->node_id !== $node->id) {
|
||||
throw new HttpForbiddenException('Requesting node does not have permission to access this server.');
|
||||
}
|
||||
|
||||
// Prevent backups that have already been completed from trying to be uploaded again.
|
||||
if (!is_null($backup->completed_at)) {
|
||||
// Prevent backups that have already been completed from trying to
|
||||
// be uploaded again.
|
||||
if (!is_null($model->completed_at)) {
|
||||
throw new ConflictHttpException('This backup is already in a completed state.');
|
||||
}
|
||||
|
||||
// Ensure we are using the S3 schema.
|
||||
$schema = $this->backupService->get($backup->backupHost->schema);
|
||||
if (!$schema instanceof S3BackupSchema) {
|
||||
throw new BadRequestHttpException('The configured backup schema is not an S3 compatible.');
|
||||
// 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.');
|
||||
}
|
||||
|
||||
return new JsonResponse($schema->getUploadParts($backup, $size));
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
namespace App\Http\Controllers\Api\Remote\Backups;
|
||||
|
||||
use App\Exceptions\DisplayException;
|
||||
use App\Exceptions\Http\HttpForbiddenException;
|
||||
use App\Extensions\BackupAdapter\BackupAdapterService;
|
||||
use App\Extensions\BackupAdapter\Schemas\S3BackupSchema;
|
||||
use App\Extensions\Backups\BackupManager;
|
||||
use App\Extensions\Filesystem\S3Filesystem;
|
||||
use App\Facades\Activity;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Api\Remote\ReportBackupCompleteRequest;
|
||||
@@ -12,6 +13,7 @@ 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;
|
||||
@@ -22,7 +24,7 @@ class BackupStatusController extends Controller
|
||||
/**
|
||||
* BackupStatusController constructor.
|
||||
*/
|
||||
public function __construct(private BackupAdapterService $backupService) {}
|
||||
public function __construct(private BackupManager $backupManager) {}
|
||||
|
||||
/**
|
||||
* Handles updating the state of a backup.
|
||||
@@ -71,9 +73,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.
|
||||
$schema = $this->backupService->get($model->backupHost->schema);
|
||||
if ($schema instanceof S3BackupSchema) {
|
||||
$schema->completeMultipartUpload($model, $successful, $request->input('parts'));
|
||||
$adapter = $this->backupManager->adapter();
|
||||
if ($adapter instanceof S3Filesystem) {
|
||||
$this->completeMultipartUpload($model, $adapter, $successful, $request->input('parts'));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -109,4 +111,59 @@ 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<array{int, etag: string, part_number: string}> $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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,8 +20,7 @@ use Illuminate\Database\Query\Builder;
|
||||
* @property string $uuid
|
||||
* @property string $name
|
||||
* @property string[] $ignored_files
|
||||
* @property int $backup_host_id
|
||||
* @property BackupHost $backupHost
|
||||
* @property string $disk
|
||||
* @property string|null $checksum
|
||||
* @property int $bytes
|
||||
* @property CarbonImmutable|null $completed_at
|
||||
@@ -66,6 +65,10 @@ class Backup extends Model implements Validatable
|
||||
|
||||
public const RESOURCE_NAME = 'backup';
|
||||
|
||||
public const ADAPTER_DAEMON = 'wings';
|
||||
|
||||
public const ADAPTER_AWS_S3 = 's3';
|
||||
|
||||
protected $attributes = [
|
||||
'is_successful' => false,
|
||||
'is_locked' => false,
|
||||
@@ -84,7 +87,7 @@ class Backup extends Model implements Validatable
|
||||
'is_locked' => ['boolean'],
|
||||
'name' => ['required', 'string'],
|
||||
'ignored_files' => ['array'],
|
||||
'backup_host_id' => ['required', 'numeric', 'exists:backup_hosts,id'],
|
||||
'disk' => ['required', 'string'],
|
||||
'checksum' => ['nullable', 'string'],
|
||||
'bytes' => ['numeric'],
|
||||
'upload_id' => ['nullable', 'string'],
|
||||
@@ -117,11 +120,6 @@ class Backup extends Model implements Validatable
|
||||
return $this->belongsTo(Server::class);
|
||||
}
|
||||
|
||||
public function backupHost(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(BackupHost::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder $query
|
||||
* @return BackupQueryBuilder<Model>
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Contracts\Validatable;
|
||||
use App\Traits\HasValidation;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property string $name
|
||||
* @property string $schema
|
||||
* @property array<string, mixed> $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<array-key, string[]> */
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -61,8 +61,6 @@ use Symfony\Component\Yaml\Yaml;
|
||||
* @property-read int|null $roles_count
|
||||
* @property-read Collection<int, Server> $servers
|
||||
* @property-read int|null $servers_count
|
||||
* @property-read Collection<int, BackupHost> $backupHosts
|
||||
* @property-read int|null $backup_hosts_count
|
||||
*
|
||||
* @method static \Database\Factories\NodeFactory factory($count = null, $state = [])
|
||||
* @method static \Illuminate\Database\Eloquent\Builder<static>|Node newModelQuery()
|
||||
@@ -310,17 +308,14 @@ class Node extends Model implements Validatable
|
||||
return $this->hasMany(Allocation::class);
|
||||
}
|
||||
|
||||
/** @return BelongsToMany<DatabaseHost, $this> */
|
||||
/**
|
||||
* @return BelongsToMany<DatabaseHost, $this>
|
||||
*/
|
||||
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');
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\BackupHost;
|
||||
use App\Models\User;
|
||||
|
||||
class BackupHostPolicy
|
||||
{
|
||||
use DefaultAdminPolicies;
|
||||
|
||||
protected string $modelName = 'backupHost';
|
||||
|
||||
public function before(User $user, string $ability, string|BackupHost $backupHost): ?bool
|
||||
{
|
||||
// For "viewAny" the $backupHost param is the class name
|
||||
if (is_string($backupHost)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($backupHost->nodes as $node) {
|
||||
if (!$user->canTarget($node)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -117,6 +117,8 @@ class AppServiceProvider extends ServiceProvider
|
||||
'Up-to-Date' => $versionService->isLatestPanel() ? '<fg=green;options=bold>Yes</>' : '<fg=red;options=bold>No</>',
|
||||
]);
|
||||
|
||||
AboutCommand::add('Drivers', 'Backups', config('backups.default'));
|
||||
|
||||
AboutCommand::add('Environment', 'Installation Directory', base_path());
|
||||
}
|
||||
|
||||
|
||||
28
app/Providers/BackupsServiceProvider.php
Normal file
28
app/Providers/BackupsServiceProvider.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Extensions\Backups\BackupManager;
|
||||
use Illuminate\Contracts\Support\DeferrableProvider;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class BackupsServiceProvider extends ServiceProvider implements DeferrableProvider
|
||||
{
|
||||
/**
|
||||
* Register the S3 backup disk.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->singleton(BackupManager::class, function ($app) {
|
||||
return new BackupManager($app);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return class-string[]
|
||||
*/
|
||||
public function provides(): array
|
||||
{
|
||||
return [BackupManager::class];
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers\Extensions;
|
||||
|
||||
use App\Extensions\BackupAdapter\BackupAdapterService;
|
||||
use App\Extensions\BackupAdapter\Schemas\S3BackupSchema;
|
||||
use App\Extensions\BackupAdapter\Schemas\WingsBackupSchema;
|
||||
use App\Repositories\Daemon\DaemonBackupRepository;
|
||||
use App\Services\Nodes\NodeJWTService;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class BackupAdapterServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
$this->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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -8,16 +8,28 @@ use Illuminate\Http\Client\Response;
|
||||
|
||||
class DaemonBackupRepository extends DaemonRepository
|
||||
{
|
||||
protected ?string $adapter;
|
||||
|
||||
/**
|
||||
* Sets the backup adapter for this execution instance.
|
||||
*/
|
||||
public function setBackupAdapter(string $adapter): self
|
||||
{
|
||||
$this->adapter = $adapter;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells the remote Daemon to begin generating a backup for the server.
|
||||
*
|
||||
* @throws ConnectionException
|
||||
*/
|
||||
public function create(Backup $backup): Response
|
||||
public function backup(Backup $backup): Response
|
||||
{
|
||||
return $this->getHttpClient()->post("/api/servers/{$this->server->uuid}/backup",
|
||||
[
|
||||
'adapter' => $backup->backupHost->schema,
|
||||
'adapter' => $this->adapter ?? config('backups.default'),
|
||||
'uuid' => $backup->uuid,
|
||||
'ignore' => implode("\n", $backup->ignored_files),
|
||||
]
|
||||
@@ -33,7 +45,7 @@ class DaemonBackupRepository extends DaemonRepository
|
||||
{
|
||||
return $this->getHttpClient()->post("/api/servers/{$this->server->uuid}/backup/$backup->uuid/restore",
|
||||
[
|
||||
'adapter' => $backup->backupHost->schema,
|
||||
'adapter' => $backup->disk,
|
||||
'truncate_directory' => $truncate,
|
||||
'download_url' => $url ?? '',
|
||||
]
|
||||
|
||||
@@ -3,15 +3,23 @@
|
||||
namespace App\Services\Backups;
|
||||
|
||||
use App\Exceptions\Service\Backup\BackupLockedException;
|
||||
use App\Extensions\BackupAdapter\BackupAdapterService;
|
||||
use App\Extensions\Backups\BackupManager;
|
||||
use App\Extensions\Filesystem\S3Filesystem;
|
||||
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 readonly ConnectionInterface $connection, private readonly BackupAdapterService $backupService) {}
|
||||
public function __construct(
|
||||
private ConnectionInterface $connection,
|
||||
private BackupManager $manager,
|
||||
private DaemonBackupRepository $daemonBackupRepository
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Deletes a backup from the system. If the backup is stored in S3 a request
|
||||
@@ -31,15 +39,47 @@ class DeleteBackupService
|
||||
throw new BackupLockedException();
|
||||
}
|
||||
|
||||
$schema = $this->backupService->get($backup->backupHost->schema);
|
||||
if (!$schema) {
|
||||
throw new Exception('Backup has unknown backup adapter.');
|
||||
if ($backup->disk === Backup::ADAPTER_AWS_S3) {
|
||||
$this->deleteFromS3($backup);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->connection->transaction(function () use ($schema, $backup) {
|
||||
$schema->deleteBackup($backup);
|
||||
$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;
|
||||
}
|
||||
}
|
||||
|
||||
$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),
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,28 +2,60 @@
|
||||
|
||||
namespace App\Services\Backups;
|
||||
|
||||
use App\Extensions\BackupAdapter\BackupAdapterService;
|
||||
use App\Extensions\Backups\BackupManager;
|
||||
use App\Extensions\Filesystem\S3Filesystem;
|
||||
use App\Models\Backup;
|
||||
use App\Models\User;
|
||||
use Exception;
|
||||
use App\Services\Nodes\NodeJWTService;
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
class DownloadLinkService
|
||||
{
|
||||
public function __construct(private readonly BackupAdapterService $backupService) {}
|
||||
/**
|
||||
* DownloadLinkService constructor.
|
||||
*/
|
||||
public function __construct(private BackupManager $backupManager, private NodeJWTService $jwtService) {}
|
||||
|
||||
/**
|
||||
* Returns the URL that allows for a backup to be downloaded by an individual
|
||||
* user, or by the daemon control software.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function handle(Backup $backup, User $user): string
|
||||
{
|
||||
$schema = $this->backupService->get($backup->backupHost->schema);
|
||||
if (!$schema) {
|
||||
throw new Exception('Backup has unknown backup adapter.');
|
||||
if ($backup->disk === Backup::ADAPTER_AWS_S3) {
|
||||
return $this->getS3BackupUrl($backup);
|
||||
}
|
||||
|
||||
return $schema->getDownloadLink($backup, $user);
|
||||
$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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,10 @@
|
||||
namespace App\Services\Backups;
|
||||
|
||||
use App\Exceptions\Service\Backup\TooManyBackupsException;
|
||||
use App\Extensions\BackupAdapter\BackupAdapterService;
|
||||
use App\Extensions\Backups\BackupManager;
|
||||
use App\Models\Backup;
|
||||
use App\Models\BackupHost;
|
||||
use App\Models\Server;
|
||||
use Exception;
|
||||
use App\Repositories\Daemon\DaemonBackupRepository;
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
|
||||
@@ -26,8 +25,9 @@ class InitiateBackupService
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly ConnectionInterface $connection,
|
||||
private readonly DaemonBackupRepository $daemonBackupRepository,
|
||||
private readonly DeleteBackupService $deleteBackupService,
|
||||
private readonly BackupAdapterService $backupService
|
||||
private readonly BackupManager $backupManager
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -110,28 +110,20 @@ class InitiateBackupService
|
||||
$this->deleteBackupService->handle($oldest);
|
||||
}
|
||||
|
||||
// TODO: select backup host via frontend
|
||||
$backupHost = $server->node->backupHosts()->first();
|
||||
if (!$backupHost) {
|
||||
$backupHost = BackupHost::doesntHave('nodes')->firstOrFail();
|
||||
}
|
||||
|
||||
$schema = $this->backupService->get($backupHost->schema);
|
||||
if (!$schema) {
|
||||
throw new Exception('Backup host has unknown backup adapter.');
|
||||
}
|
||||
|
||||
return $this->connection->transaction(function () use ($backupHost, $schema, $server, $name) {
|
||||
$backup = Backup::create([
|
||||
return $this->connection->transaction(function () use ($server, $name) {
|
||||
/** @var Backup $backup */
|
||||
$backup = Backup::query()->create([
|
||||
'server_id' => $server->id,
|
||||
'uuid' => Uuid::uuid4()->toString(),
|
||||
'name' => trim($name) ?: sprintf('Backup at %s', now()->toDateTimeString()),
|
||||
'ignored_files' => array_values($this->ignoredFiles ?? []),
|
||||
'backup_host_id' => $backupHost->id,
|
||||
'disk' => $this->backupManager->getDefaultAdapter(),
|
||||
'is_locked' => $this->isLocked,
|
||||
]);
|
||||
|
||||
$schema->createBackup($backup);
|
||||
$this->daemonBackupRepository->setServer($server)
|
||||
->setBackupAdapter($this->backupManager->getDefaultAdapter())
|
||||
->backup($backup);
|
||||
|
||||
return $backup;
|
||||
});
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
namespace App\Services\Servers;
|
||||
|
||||
use App\Exceptions\DisplayException;
|
||||
use App\Extensions\BackupAdapter\BackupAdapterService;
|
||||
use App\Extensions\BackupAdapter\Schemas\WingsBackupSchema;
|
||||
use App\Models\Server;
|
||||
use App\Repositories\Daemon\DaemonServerRepository;
|
||||
use App\Services\Databases\DatabaseManagementService;
|
||||
@@ -24,8 +22,7 @@ class ServerDeletionService
|
||||
public function __construct(
|
||||
private ConnectionInterface $connection,
|
||||
private DaemonServerRepository $daemonServerRepository,
|
||||
private DatabaseManagementService $databaseManagementService,
|
||||
private BackupAdapterService $backupService
|
||||
private DatabaseManagementService $databaseManagementService
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -81,26 +78,6 @@ class ServerDeletionService
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($server->backups as $backup) {
|
||||
try {
|
||||
$schema = $this->backupService->get($backup->backupHost->schema);
|
||||
|
||||
if ($schema) {
|
||||
if ($schema instanceof WingsBackupSchema) {
|
||||
// Local wings backups are already deleted by the daemon
|
||||
} else {
|
||||
$schema->deleteBackup($backup);
|
||||
}
|
||||
}
|
||||
} catch (Exception $exception) {
|
||||
if (!$this->force) {
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
logger()->warning($exception);
|
||||
}
|
||||
}
|
||||
|
||||
$server->allocations()->update([
|
||||
'server_id' => null,
|
||||
'notes' => null,
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
namespace App\Services\Servers;
|
||||
|
||||
use App\Extensions\BackupAdapter\BackupAdapterService;
|
||||
use App\Extensions\BackupAdapter\Schemas\WingsBackupSchema;
|
||||
use App\Models\Allocation;
|
||||
use App\Models\Backup;
|
||||
use App\Models\Node;
|
||||
@@ -22,23 +20,19 @@ class TransferServerService
|
||||
* TransferService constructor.
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly ConnectionInterface $connection,
|
||||
private readonly NodeJWTService $nodeJWTService,
|
||||
private readonly BackupAdapterService $backupService
|
||||
private ConnectionInterface $connection,
|
||||
private NodeJWTService $nodeJWTService,
|
||||
) {}
|
||||
|
||||
/** @param string[] $backup_uuids */ // TODO: add backup uuids to ServerTransfer model
|
||||
/**
|
||||
* @param string[] $backup_uuids
|
||||
*/
|
||||
private function notify(ServerTransfer $transfer, UnencryptedToken $token, array $backup_uuids = []): void
|
||||
{
|
||||
// Make sure only wings backups of the current server are forwarded in the wings request
|
||||
$backups = Backup::where('server_id', $transfer->server_id)
|
||||
->whereIn('uuid', $backup_uuids)
|
||||
->with('backupHost')
|
||||
->get()
|
||||
->filter(fn (Backup $backup) => $this->backupService->get($backup->backupHost->schema) instanceof WingsBackupSchema)
|
||||
->pluck('uuid')
|
||||
->all();
|
||||
|
||||
$backups = [];
|
||||
if (config('backups.default') === Backup::ADAPTER_DAEMON) {
|
||||
$backups = $backup_uuids;
|
||||
}
|
||||
Http::daemon($transfer->oldNode)->post("/api/servers/{$transfer->server->uuid}/transfer", [
|
||||
'url' => $transfer->newNode->getConnectionAddress() . '/api/transfers',
|
||||
'token' => 'Bearer ' . $token->toString(),
|
||||
@@ -54,11 +48,11 @@ class TransferServerService
|
||||
* Starts a transfer of a server to a new node.
|
||||
*
|
||||
* @param int[] $additional_allocations
|
||||
* @param string[] $backup_uuids
|
||||
* @param string[] $backup_uuid
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function handle(Server $server, int $node_id, ?int $allocation_id = null, ?array $additional_allocations = [], array $backup_uuids = []): bool
|
||||
public function handle(Server $server, int $node_id, ?int $allocation_id = null, ?array $additional_allocations = [], ?array $backup_uuid = []): bool
|
||||
{
|
||||
$additional_allocations = array_map(intval(...), $additional_allocations);
|
||||
|
||||
@@ -109,7 +103,7 @@ class TransferServerService
|
||||
->handle($transfer->newNode, $server->uuid, 'sha256');
|
||||
|
||||
// Notify the source node of the pending outgoing transfer.
|
||||
$this->notify($transfer, $token, $backup_uuids);
|
||||
$this->notify($transfer, $token, $backup_uuid);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
use App\Providers\ActivityLogServiceProvider;
|
||||
use App\Providers\AppServiceProvider;
|
||||
use App\Providers\BackupsServiceProvider;
|
||||
use App\Providers\EventServiceProvider;
|
||||
use App\Providers\Extensions\AvatarServiceProvider;
|
||||
use App\Providers\Extensions\BackupAdapterServiceProvider;
|
||||
use App\Providers\Extensions\CaptchaServiceProvider;
|
||||
use App\Providers\Extensions\FeatureServiceProvider;
|
||||
use App\Providers\Extensions\OAuthServiceProvider;
|
||||
@@ -19,9 +19,9 @@ use SocialiteProviders\Manager\ServiceProvider;
|
||||
return [
|
||||
ActivityLogServiceProvider::class,
|
||||
AppServiceProvider::class,
|
||||
BackupsServiceProvider::class,
|
||||
EventServiceProvider::class,
|
||||
AvatarServiceProvider::class,
|
||||
BackupAdapterServiceProvider::class,
|
||||
CaptchaServiceProvider::class,
|
||||
FeatureServiceProvider::class,
|
||||
OAuthServiceProvider::class,
|
||||
|
||||
@@ -4,6 +4,11 @@ use App\Http\Controllers\Api\Remote\Backups\BackupRemoteUploadController;
|
||||
use App\Models\Backup;
|
||||
|
||||
return [
|
||||
// The backup driver to use for this Panel instance. All client generated server backups
|
||||
// will be stored in this location by default. It is possible to change this once backups
|
||||
// have been made, without losing data.
|
||||
'default' => env('APP_BACKUP_DRIVER', Backup::ADAPTER_DAEMON),
|
||||
|
||||
// This value is used to determine the lifespan of UploadPart presigned urls that daemon
|
||||
// uses to upload backups to S3 storage. Value is in minutes, so this would default to an hour.
|
||||
'presigned_url_lifespan' => (int) env('BACKUP_PRESIGNED_URL_LIFESPAN', 60),
|
||||
@@ -26,4 +31,37 @@ return [
|
||||
'limit' => (int) env('BACKUP_THROTTLE_LIMIT', 2),
|
||||
'period' => (int) env('BACKUP_THROTTLE_PERIOD', 600),
|
||||
],
|
||||
|
||||
'disks' => [
|
||||
// There is no configuration for the local disk for Daemon. That configuration
|
||||
// is determined by the Daemon configuration, and not the Panel.
|
||||
'wings' => [
|
||||
'adapter' => Backup::ADAPTER_DAEMON,
|
||||
],
|
||||
|
||||
// Configuration for storing backups in Amazon S3. This uses the same credentials
|
||||
// specified in filesystems.php but does include some more specific settings for
|
||||
// backups, notably bucket, location, and use_accelerate_endpoint.
|
||||
's3' => [
|
||||
'adapter' => Backup::ADAPTER_AWS_S3,
|
||||
|
||||
'region' => env('AWS_DEFAULT_REGION'),
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
|
||||
// The S3 bucket to use for backups.
|
||||
'bucket' => env('AWS_BACKUPS_BUCKET'),
|
||||
|
||||
// The location within the S3 bucket where backups will be stored. Backups
|
||||
// are stored within a folder using the server's UUID as the name. Each
|
||||
// backup for that server lives within that folder.
|
||||
'prefix' => env('AWS_BACKUPS_BUCKET') ?? '',
|
||||
|
||||
'endpoint' => env('AWS_ENDPOINT'),
|
||||
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
|
||||
'use_accelerate_endpoint' => env('AWS_BACKUPS_USE_ACCELERATE', false),
|
||||
|
||||
'storage_class' => env('AWS_BACKUPS_STORAGE_CLASS'),
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
@@ -24,6 +24,7 @@ class BackupFactory extends Factory
|
||||
return [
|
||||
'uuid' => Uuid::uuid4()->toString(),
|
||||
'name' => $this->faker->sentence(),
|
||||
'disk' => Backup::ADAPTER_DAEMON,
|
||||
'is_successful' => true,
|
||||
'created_at' => CarbonImmutable::now(),
|
||||
'completed_at' => CarbonImmutable::now(),
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\BackupHost;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
class BackupHostFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* The name of the factory's corresponding model.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $model = BackupHost::class;
|
||||
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'name' => $this->faker->colorName(),
|
||||
'schema' => 'wings',
|
||||
'configuration' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Models\BackupHost;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('backup_hosts', function (Blueprint $table) {
|
||||
$table->increments('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']);
|
||||
});
|
||||
|
||||
Schema::table('backups', function (Blueprint $table) {
|
||||
$table->unsignedInteger('backup_host_id')->after('disk');
|
||||
$table->foreign('backup_host_id')->references('id')->on('backup_hosts');
|
||||
|
||||
$table->dropColumn('disk');
|
||||
});
|
||||
|
||||
$oldDriver = env('APP_BACKUP_DRIVER', 'wings');
|
||||
|
||||
$oldConfiguration = null;
|
||||
if ($oldDriver === 's3') {
|
||||
$oldConfiguration = [
|
||||
'region' => env('AWS_DEFAULT_REGION'),
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
'bucket' => env('AWS_BACKUPS_BUCKET'),
|
||||
'prefix' => env('AWS_BACKUPS_BUCKET', ''),
|
||||
'endpoint' => env('AWS_ENDPOINT'),
|
||||
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
|
||||
'use_accelerate_endpoint' => env('AWS_BACKUPS_USE_ACCELERATE', false),
|
||||
'storage_class' => env('AWS_BACKUPS_STORAGE_CLASS'),
|
||||
];
|
||||
}
|
||||
|
||||
$backupHost = BackupHost::create([
|
||||
'name' => $oldDriver === 's3' ? 'Remote' : 'Local',
|
||||
'schema' => $oldDriver,
|
||||
'configuration' => $oldConfiguration,
|
||||
]);
|
||||
|
||||
DB::table('backups')->update(['backup_host_id' => $backupHost->id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('backups', function (Blueprint $table) {
|
||||
$table->string('disk')->after('backup_host_id');
|
||||
|
||||
$table->dropForeign(['backup_host_id']);
|
||||
$table->dropColumn('backup_host_id');
|
||||
});
|
||||
|
||||
Schema::dropIfExists('backup_hosts');
|
||||
|
||||
Schema::dropIfExists('backup_host_node');
|
||||
}
|
||||
};
|
||||
@@ -1,14 +0,0 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'model_label' => 'Backup Host|Backup Hosts',
|
||||
'name' => 'Name',
|
||||
'schema' => 'Schema',
|
||||
'backups' => 'Backups',
|
||||
'linked_nodes' => 'Linked Nodes',
|
||||
'all_nodes' => 'All Nodes',
|
||||
'configuration' => 'Configuration',
|
||||
'no_configuration' => 'No additional configuration required',
|
||||
'no_backup_hosts' => 'No Backup Hosts',
|
||||
'local_backups_only' => 'All backups will be created locally on the respective node',
|
||||
];
|
||||
@@ -42,13 +42,11 @@ return [
|
||||
'refresh' => 'Refresh',
|
||||
'custom_ip' => 'Enter Custom IP',
|
||||
'domain' => 'Domain Name',
|
||||
'ssl_ip' => 'You cannot connect to an IP Address over SSL',
|
||||
'error' => 'This is the domain name that points to your node\'s IP Address. If you\'ve already set up this, you can verify it by checking the next field!',
|
||||
'fqdn_help' => 'Your panel is currently secured via an SSL certificate and that means your nodes require one too. You must use a domain name, because you cannot get SSL certificates for IP Addresses.',
|
||||
'dns' => 'DNS Record Check',
|
||||
'dns_help' => 'This lets you know if your DNS record is pointing to the correct IP address.',
|
||||
'valid' => 'Valid',
|
||||
'invalid' => 'Invalid',
|
||||
'ssl_ip' => 'Consider using a domain name instead of an ip address',
|
||||
'fqdn_ssl' => 'Your panel is currently secured via an SSL certificate and that means your nodes require one too.',
|
||||
'dns_error' => 'No valid DNS records were found for the provided domain name.',
|
||||
'valid' => 'Valid DNS',
|
||||
'invalid' => 'Invalid DNS',
|
||||
'port' => 'Port',
|
||||
'ports' => 'Ports',
|
||||
'port_help' => 'If you are running the daemon behind Cloudflare you should set the daemon port to 8443 to allow websocket proxying over SSL.',
|
||||
@@ -58,7 +56,7 @@ return [
|
||||
'listen_port_help' => 'Wings will listen on this port.',
|
||||
'display_name' => 'Display Name',
|
||||
'ssl' => 'Communicate over SSL',
|
||||
'panel_on_ssl' => 'Your Panel is using a secure SSL connection,<br>so your Daemon must too.',
|
||||
'panel_on_ssl' => 'Your Panel is using a secure SSL connection, so your Daemon must too.',
|
||||
'ssl_help' => 'An IP address cannot use SSL.',
|
||||
|
||||
'tags' => 'Tags',
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<div class="relative cursor-pointer"
|
||||
x-on:click="{{ $component->redirectUrl() }}"
|
||||
x-on:auxclick.prevent="if ($event.button === 1) {{ $component->redirectUrl(true) }}">
|
||||
<div class="absolute left-0 top-1 bottom-0 w-1 rounded-lg" style="background-color: #D97706;"></div>
|
||||
<div class="absolute left-0 top-1 bottom-0 w-1 rounded-lg fi-color fi-color-warning fi-bg-color-600" style="background-color: var(--bg);"></div>
|
||||
|
||||
<div class="flex-1 dark:bg-gray-800 dark:text-white rounded-lg overflow-hidden p-3">
|
||||
@if($backgroundImage)
|
||||
|
||||
@@ -14,9 +14,7 @@
|
||||
x-on:click="{{ $component->redirectUrl() }}"
|
||||
x-on:auxclick.prevent="if ($event.button === 1) {{ $component->redirectUrl(true) }}">
|
||||
|
||||
<div class="absolute left-0 top-1 bottom-0 w-1 rounded-lg"
|
||||
style="background-color: {{ $server->condition->getColor(true) }};">
|
||||
</div>
|
||||
<div class="absolute left-0 top-1 bottom-0 w-1 rounded-lg fi-color fi-color-{{ $server->condition->getColor() }} fi-bg-color-600" style="background-color: var(--bg);"> </div>
|
||||
|
||||
<div class="flex-1 dark:bg-gray-800 dark:text-white rounded-lg overflow-hidden p-3">
|
||||
@if($backgroundImage)
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace App\Tests\Integration\Api\Client\Server\Backup;
|
||||
|
||||
use App\Models\Backup;
|
||||
use App\Models\BackupHost;
|
||||
use App\Models\Subuser;
|
||||
use App\Services\Backups\DeleteBackupService;
|
||||
use App\Tests\Integration\Api\Client\ClientApiIntegrationTestCase;
|
||||
@@ -26,11 +25,9 @@ class BackupAuthorizationTest extends ClientApiIntegrationTestCase
|
||||
// to do anything with the backups for that server.
|
||||
Subuser::factory()->create(['server_id' => $server2->id, 'user_id' => $user->id]);
|
||||
|
||||
$backupHost = BackupHost::factory()->create();
|
||||
|
||||
$backup1 = Backup::factory()->create(['server_id' => $server1->id, 'backup_host_id' => $backupHost->id, 'completed_at' => CarbonImmutable::now()]);
|
||||
$backup2 = Backup::factory()->create(['server_id' => $server2->id, 'backup_host_id' => $backupHost->id, 'completed_at' => CarbonImmutable::now()]);
|
||||
$backup3 = Backup::factory()->create(['server_id' => $server3->id, 'backup_host_id' => $backupHost->id, 'completed_at' => CarbonImmutable::now()]);
|
||||
$backup1 = Backup::factory()->create(['server_id' => $server1->id, 'completed_at' => CarbonImmutable::now()]);
|
||||
$backup2 = Backup::factory()->create(['server_id' => $server2->id, 'completed_at' => CarbonImmutable::now()]);
|
||||
$backup3 = Backup::factory()->create(['server_id' => $server3->id, 'completed_at' => CarbonImmutable::now()]);
|
||||
|
||||
$this->instance(DeleteBackupService::class, $mock = \Mockery::mock(DeleteBackupService::class));
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ namespace App\Tests\Integration\Api\Client\Server\Backup;
|
||||
use App\Enums\SubuserPermission;
|
||||
use App\Events\ActivityLogged;
|
||||
use App\Models\Backup;
|
||||
use App\Models\BackupHost;
|
||||
use App\Repositories\Daemon\DaemonBackupRepository;
|
||||
use App\Tests\Integration\Api\Client\ClientApiIntegrationTestCase;
|
||||
use Illuminate\Http\Response;
|
||||
@@ -27,8 +26,7 @@ class DeleteBackupTest extends ClientApiIntegrationTestCase
|
||||
{
|
||||
[$user, $server] = $this->generateTestAccount([SubuserPermission::BackupCreate]);
|
||||
|
||||
$backupHost = BackupHost::factory()->create();
|
||||
$backup = Backup::factory()->create(['server_id' => $server->id, 'backup_host_id' => $backupHost->id]);
|
||||
$backup = Backup::factory()->create(['server_id' => $server->id]);
|
||||
|
||||
$this->actingAs($user)->deleteJson($this->link($backup))
|
||||
->assertStatus(Response::HTTP_FORBIDDEN);
|
||||
@@ -45,9 +43,8 @@ class DeleteBackupTest extends ClientApiIntegrationTestCase
|
||||
|
||||
[$user, $server] = $this->generateTestAccount([SubuserPermission::BackupDelete]);
|
||||
|
||||
$backupHost = BackupHost::factory()->create();
|
||||
/** @var Backup $backup */
|
||||
$backup = Backup::factory()->create(['server_id' => $server->id, 'backup_host_id' => $backupHost->id]);
|
||||
$backup = Backup::factory()->create(['server_id' => $server->id]);
|
||||
|
||||
$this->repository->expects('setServer->delete')->with(
|
||||
\Mockery::on(function ($value) use ($backup) {
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
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\Models\BackupHost;
|
||||
use App\Repositories\Daemon\DaemonBackupRepository;
|
||||
use App\Services\Backups\DeleteBackupService;
|
||||
use App\Tests\Integration\IntegrationTestCase;
|
||||
@@ -16,11 +17,8 @@ class DeleteBackupServiceTest extends IntegrationTestCase
|
||||
public function test_locked_backup_cannot_be_deleted(): void
|
||||
{
|
||||
$server = $this->createServerModel();
|
||||
|
||||
$backupHost = BackupHost::factory()->create();
|
||||
$backup = Backup::factory()->create([
|
||||
'server_id' => $server->id,
|
||||
'backup_host_id' => $backupHost->id,
|
||||
'is_locked' => true,
|
||||
]);
|
||||
|
||||
@@ -32,11 +30,8 @@ class DeleteBackupServiceTest extends IntegrationTestCase
|
||||
public function test_failed_backup_that_is_locked_can_be_deleted(): void
|
||||
{
|
||||
$server = $this->createServerModel();
|
||||
|
||||
$backupHost = BackupHost::factory()->create();
|
||||
$backup = Backup::factory()->create([
|
||||
'server_id' => $server->id,
|
||||
'backup_host_id' => $backupHost->id,
|
||||
'is_locked' => true,
|
||||
'is_successful' => false,
|
||||
]);
|
||||
@@ -54,9 +49,7 @@ class DeleteBackupServiceTest extends IntegrationTestCase
|
||||
public function test_exception_thrown_due_to_missing_backup_is_ignored(): void
|
||||
{
|
||||
$server = $this->createServerModel();
|
||||
|
||||
$backupHost = BackupHost::factory()->create();
|
||||
$backup = Backup::factory()->create(['server_id' => $server->id, 'backup_host_id' => $backupHost->id]);
|
||||
$backup = Backup::factory()->create(['server_id' => $server->id]);
|
||||
|
||||
$mock = $this->mock(DaemonBackupRepository::class);
|
||||
$mock->expects('setServer->delete')->with($backup)->andThrow(new ConnectionException(code: 404));
|
||||
@@ -71,9 +64,7 @@ class DeleteBackupServiceTest extends IntegrationTestCase
|
||||
public function test_exception_is_thrown_if_not404(): void
|
||||
{
|
||||
$server = $this->createServerModel();
|
||||
|
||||
$backupHost = BackupHost::factory()->create();
|
||||
$backup = Backup::factory()->create(['server_id' => $server->id, 'backup_host_id' => $backupHost->id]);
|
||||
$backup = Backup::factory()->create(['server_id' => $server->id]);
|
||||
|
||||
$mock = $this->mock(DaemonBackupRepository::class);
|
||||
$mock->expects('setServer->delete')->with($backup)->andThrow(new ConnectionException(code: 500));
|
||||
@@ -86,4 +77,28 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user