Skip to content
Open
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
dd4e723
start backup hosts
Boy132 Jan 16, 2026
ad2333e
more work on backup hosts
Boy132 Jan 16, 2026
12d8b23
Merge remote-tracking branch 'origin/main' into boy132/backup-hosts
Boy132 Jan 20, 2026
a181978
handle old backups and cleanup
Boy132 Jan 20, 2026
53761f8
dont allow to delete last backup host
Boy132 Jan 20, 2026
efebb99
fix backup hosts for "all nodes"
Boy132 Jan 20, 2026
150f803
fix tests
Boy132 Jan 20, 2026
f1dbbbb
fix migration for mysql
Boy132 Jan 20, 2026
7da9d8c
rabbit fixes
Boy132 Jan 20, 2026
6c8c2a0
Merge branch 'main' into boy132/backup-hosts
Boy132 Jan 23, 2026
9252b21
Merge branch 'main' into boy132/backup-hosts
Boy132 Jan 28, 2026
3a9f09c
run pint
Boy132 Jan 28, 2026
e3893ff
Merge branch 'main' into boy132/backup-hosts
Boy132 Feb 4, 2026
4f2a472
Merge branch 'main' into boy132/backup-hosts
Boy132 Feb 12, 2026
c215e95
Merge branch 'main' into boy132/backup-hosts
Boy132 Feb 19, 2026
bc727b7
use TablerIcon enum and update some actions
Boy132 Feb 24, 2026
a949a56
add info comment
Boy132 Feb 24, 2026
c3b597d
fix function name
Boy132 Feb 24, 2026
a6ba81e
small cleanup
Boy132 Feb 24, 2026
a9f6bcb
add simple BackupsRelationManager
Boy132 Feb 24, 2026
1a9cc5f
re-implement backup transfers
Boy132 Feb 24, 2026
f300259
Merge remote-tracking branch 'origin/main' into boy132/backup-hosts
Boy132 Feb 24, 2026
5057d72
rabbit fixes
Boy132 Feb 24, 2026
cdd69c2
rabbit fixes 2
Boy132 Feb 24, 2026
b7aea4c
avoid n+1
Boy132 Mar 17, 2026
7a8aaad
Merge remote-tracking branch 'origin/main' into boy132/backup-hosts
Boy132 Mar 17, 2026
1a2e155
Merge remote-tracking branch 'origin/main' into boy132/backup-hosts
Boy132 Apr 28, 2026
0a04c76
delete all (remote) backups when server is deleted
Boy132 Apr 28, 2026
f75a1ac
Merge remote-tracking branch 'origin/main' into boy132/backup-hosts
Boy132 May 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/Enums/RolePermissionModels.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ enum RolePermissionModels: string
{
case ApiKey = 'apiKey';
case Allocation = 'allocation';
case BackupHost = 'backupHost';
case DatabaseHost = 'databaseHost';
case Database = 'database';
case Egg = 'egg';
Expand Down
23 changes: 23 additions & 0 deletions app/Extensions/BackupAdapter/BackupAdapterSchemaInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?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;
}
35 changes: 35 additions & 0 deletions app/Extensions/BackupAdapter/BackupAdapterService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?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();
}
}
14 changes: 14 additions & 0 deletions app/Extensions/BackupAdapter/Schemas/BackupAdapterSchema.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?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());
}
}
207 changes: 207 additions & 0 deletions app/Extensions/BackupAdapter/Schemas/S3BackupSchema.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
<?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']);
}
Comment thread
Boy132 marked this conversation as resolved.

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();
}
Comment thread
Boy132 marked this conversation as resolved.

/** @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,
];
}
Comment thread
Boy132 marked this conversation as resolved.

/**
* 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));
}
}
64 changes: 64 additions & 0 deletions app/Extensions/BackupAdapter/Schemas/WingsBackupSchema.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?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')),
];
}
}
Loading