Edit & Download (local) Backups (#436)

* Allow editing of backups

* pint updates

* setup of backup download

* allow download for local backup files

* delete uploaded files on delete of BackupFile

* pint updates

* S3 upload & download fixes

* Deletion of backup files

* support $ARCH selector for s3 installation

* delete files when deleting backup

* fixed ui issue

* adjustment

* Use system temp path for downloads

---------

Co-authored-by: Saeed Vaziry <mr.saeedvaziry@gmail.com>
This commit is contained in:
Richard Anderson 2025-01-25 20:59:35 +00:00 committed by GitHub
parent 465951fd1e
commit a73476c1dd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 382 additions and 184 deletions

View File

@ -2,6 +2,7 @@
namespace App\Actions\Database; namespace App\Actions\Database;
use App\Enums\BackupFileStatus;
use App\Enums\BackupStatus; use App\Enums\BackupStatus;
use App\Enums\DatabaseStatus; use App\Enums\DatabaseStatus;
use App\Models\Backup; use App\Models\Backup;
@ -10,7 +11,7 @@
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
class CreateBackup class ManageBackup
{ {
/** /**
* @throws AuthorizationException * @throws AuthorizationException
@ -34,6 +35,31 @@ public function create(Server $server, array $input): Backup
return $backup; return $backup;
} }
public function update(Backup $backup, array $input): void
{
$backup->interval = $input['interval'] == 'custom' ? $input['custom_interval'] : $input['interval'];
$backup->keep_backups = $input['keep'];
$backup->save();
}
public function delete(Backup $backup): void
{
$backup->status = BackupStatus::DELETING;
$backup->save();
dispatch(function () use ($backup) {
$files = $backup->files;
foreach ($files as $file) {
$file->status = BackupFileStatus::DELETING;
$file->save();
$file->deleteFile();
}
$backup->delete();
});
}
public static function rules(Server $server, array $input): array public static function rules(Server $server, array $input): array
{ {
$rules = [ $rules = [

View File

@ -0,0 +1,39 @@
<?php
namespace App\Actions\Database;
use App\Enums\BackupFileStatus;
use App\Models\BackupFile;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Throwable;
class ManageBackupFile
{
/**
* @throws Throwable
*/
public function download(BackupFile $file): StreamedResponse
{
$localFilename = "backup_{$file->id}_{$file->name}.zip";
if (! Storage::disk('backups')->exists($localFilename)) {
$file->backup->server->ssh()->download(
Storage::disk('backups')->path($localFilename),
$file->path()
);
}
return Storage::disk('backups')->download($localFilename, $file->name.'.zip');
}
public function delete(BackupFile $file): void
{
$file->status = BackupFileStatus::DELETING;
$file->save();
dispatch(function () use ($file) {
$file->deleteFile();
});
}
}

View File

@ -7,4 +7,6 @@ final class BackupStatus
const RUNNING = 'running'; const RUNNING = 'running';
const FAILED = 'failed'; const FAILED = 'failed';
const DELETING = 'deleting';
} }

View File

@ -181,7 +181,7 @@ public function download(string $local, string $remote): void
$this->connect(true); $this->connect(true);
} }
$this->connection->get($remote, $local, SFTP::SOURCE_LOCAL_FILE); $this->connection->get($remote, $local);
} }
/** /**

View File

@ -56,8 +56,17 @@ public static function boot(): void
public static array $statusColors = [ public static array $statusColors = [
BackupStatus::RUNNING => 'success', BackupStatus::RUNNING => 'success',
BackupStatus::FAILED => 'danger', BackupStatus::FAILED => 'danger',
BackupStatus::DELETING => 'warning',
]; ];
public function isCustomInterval(): bool
{
$intervals = array_keys(config('core.cronjob_intervals'));
$intervals = array_filter($intervals, fn ($interval) => $interval !== 'custom');
return ! in_array($this->interval, $intervals);
}
public function server(): BelongsTo public function server(): BelongsTo
{ {
return $this->belongsTo(Server::class); return $this->belongsTo(Server::class);

View File

@ -2,7 +2,9 @@
namespace App\Models; namespace App\Models;
use App\Actions\Database\ManageBackupFile;
use App\Enums\BackupFileStatus; use App\Enums\BackupFileStatus;
use App\Enums\StorageProvider as StorageProviderAlias;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -46,19 +48,11 @@ protected static function booted(): void
->where('id', '<=', $lastFileToKeep->id) ->where('id', '<=', $lastFileToKeep->id)
->get(); ->get();
foreach ($files as $file) { foreach ($files as $file) {
$file->delete(); app(ManageBackupFile::class)->delete($file);
} }
} }
} }
}); });
static::deleting(function (BackupFile $backupFile) {
$provider = $backupFile->backup->storage->provider();
$path = $backupFile->storagePath();
dispatch(function () use ($provider, $path) {
$provider->delete([$path]);
});
});
} }
public static array $statusColors = [ public static array $statusColors = [
@ -71,18 +65,52 @@ protected static function booted(): void
BackupFileStatus::RESTORE_FAILED => 'danger', BackupFileStatus::RESTORE_FAILED => 'danger',
]; ];
public function isAvailable(): bool
{
return ! in_array(
$this->status,
[BackupFileStatus::CREATING, BackupFileStatus::FAILED, BackupFileStatus::DELETING]
);
}
public function isLocal(): bool
{
return $this->backup->storage->provider === StorageProviderAlias::LOCAL;
}
public function backup(): BelongsTo public function backup(): BelongsTo
{ {
return $this->belongsTo(Backup::class); return $this->belongsTo(Backup::class);
} }
public function path(): string public function tempPath(): string
{ {
return '/home/'.$this->backup->server->getSshUser().'/'.$this->name.'.zip'; return '/home/'.$this->backup->server->getSshUser().'/'.$this->name.'.zip';
} }
public function storagePath(): string public function path(): string
{ {
return '/'.$this->backup->database->name.'/'.$this->name.'.zip'; $storage = $this->backup->storage;
$databaseName = $this->backup->database->name;
return match ($storage->provider) {
StorageProviderAlias::DROPBOX => '/'.$databaseName.'/'.$this->name.'.zip',
StorageProviderAlias::S3, StorageProviderAlias::FTP, StorageProviderAlias::LOCAL => implode('/', [
rtrim($storage->credentials['path'], '/'),
$databaseName,
$this->name.'.zip',
]),
default => '',
};
}
public function deleteFile(): void
{
try {
$storage = $this->backup->storage->provider()->ssh($this->backup->server);
$storage->delete($this->path());
} finally {
$this->delete();
}
} }
} }

View File

@ -243,6 +243,16 @@ public function resourceInfo(): array
]; ];
} }
public function deleteFile(string $path): void
{
$this->server->ssh()->exec(
$this->getScript('delete-file.sh', [
'path' => $path,
]),
'delete-file'
);
}
private function deleteTempFile(string $name): void private function deleteTempFile(string $name): void
{ {
if (Storage::disk('local')->exists($name)) { if (Storage::disk('local')->exists($name)) {

View File

@ -0,0 +1 @@
rm -f __path__

View File

@ -159,12 +159,12 @@ public function runBackup(BackupFile $backupFile): void
// upload to storage // upload to storage
$upload = $backupFile->backup->storage->provider()->ssh($this->service->server)->upload( $upload = $backupFile->backup->storage->provider()->ssh($this->service->server)->upload(
$backupFile->tempPath(),
$backupFile->path(), $backupFile->path(),
$backupFile->storagePath(),
); );
// cleanup // cleanup
$this->service->server->ssh()->exec('rm '.$backupFile->path()); $this->service->server->ssh()->exec('rm '.$backupFile->tempPath());
$backupFile->size = $upload['size']; $backupFile->size = $upload['size'];
$backupFile->save(); $backupFile->save();
@ -174,14 +174,14 @@ public function restoreBackup(BackupFile $backupFile, string $database): void
{ {
// download // download
$backupFile->backup->storage->provider()->ssh($this->service->server)->download( $backupFile->backup->storage->provider()->ssh($this->service->server)->download(
$backupFile->storagePath(), $backupFile->path(),
$backupFile->name.'.zip', $backupFile->tempPath(),
); );
$this->service->server->ssh()->exec( $this->service->server->ssh()->exec(
$this->getScript($this->getScriptsDir().'/restore.sh', [ $this->getScript($this->getScriptsDir().'/restore.sh', [
'database' => $database, 'database' => $database,
'file' => $backupFile->name, 'file' => rtrim($backupFile->tempPath(), '.zip'),
]), ]),
'restore-database' 'restore-database'
); );

View File

@ -45,11 +45,14 @@ public function download(string $src, string $dest): void
); );
} }
/** public function delete(string $src): void
* @TODO Implement delete method
*/
public function delete(string $path): void
{ {
// $this->server->ssh()->exec(
$this->getScript('dropbox/delete-file.sh', [
'src' => $src,
'token' => $this->storageProvider->credentials['token'],
]),
'delete-from-dropbox'
);
} }
} }

View File

@ -13,7 +13,7 @@ public function upload(string $src, string $dest): array
$this->server->ssh()->exec( $this->server->ssh()->exec(
$this->getScript('ftp/upload.sh', [ $this->getScript('ftp/upload.sh', [
'src' => $src, 'src' => $src,
'dest' => $this->storageProvider->credentials['path'].'/'.$dest, 'dest' => $dest,
'host' => $this->storageProvider->credentials['host'], 'host' => $this->storageProvider->credentials['host'],
'port' => $this->storageProvider->credentials['port'], 'port' => $this->storageProvider->credentials['port'],
'username' => $this->storageProvider->credentials['username'], 'username' => $this->storageProvider->credentials['username'],
@ -33,7 +33,7 @@ public function download(string $src, string $dest): void
{ {
$this->server->ssh()->exec( $this->server->ssh()->exec(
$this->getScript('ftp/download.sh', [ $this->getScript('ftp/download.sh', [
'src' => $this->storageProvider->credentials['path'].'/'.$src, 'src' => $src,
'dest' => $dest, 'dest' => $dest,
'host' => $this->storageProvider->credentials['host'], 'host' => $this->storageProvider->credentials['host'],
'port' => $this->storageProvider->credentials['port'], 'port' => $this->storageProvider->credentials['port'],
@ -46,11 +46,19 @@ public function download(string $src, string $dest): void
); );
} }
/** public function delete(string $src): void
* @TODO Implement delete method
*/
public function delete(string $path): void
{ {
// $this->server->ssh()->exec(
$this->getScript('ftp/delete-file.sh', [
'src' => $src,
'host' => $this->storageProvider->credentials['host'],
'port' => $this->storageProvider->credentials['port'],
'username' => $this->storageProvider->credentials['username'],
'password' => $this->storageProvider->credentials['password'],
'ssl' => $this->storageProvider->credentials['ssl'],
'passive' => $this->storageProvider->credentials['passive'],
]),
'delete-from-ftp'
);
} }
} }

View File

@ -10,13 +10,12 @@ class Local extends AbstractStorage
public function upload(string $src, string $dest): array public function upload(string $src, string $dest): array
{ {
$destDir = dirname($this->storageProvider->credentials['path'].$dest); $destDir = dirname($dest);
$destFile = basename($this->storageProvider->credentials['path'].$dest);
$this->server->ssh()->exec( $this->server->ssh()->exec(
$this->getScript('local/upload.sh', [ $this->getScript('local/upload.sh', [
'src' => $src, 'src' => $src,
'dest_dir' => $destDir, 'dest_dir' => $destDir,
'dest_file' => $destFile, 'dest_file' => $dest,
]), ]),
'upload-to-local' 'upload-to-local'
); );
@ -30,20 +29,15 @@ public function download(string $src, string $dest): void
{ {
$this->server->ssh()->exec( $this->server->ssh()->exec(
$this->getScript('local/download.sh', [ $this->getScript('local/download.sh', [
'src' => $this->storageProvider->credentials['path'].$src, 'src' => $src,
'dest' => $dest, 'dest' => $dest,
]), ]),
'download-from-local' 'download-from-local'
); );
} }
public function delete(string $path): void public function delete(string $src): void
{ {
$this->server->ssh()->exec( $this->server->os()->deleteFile($src);
$this->getScript('local/delete.sh', [
'path' => $this->storageProvider->credentials['path'].$path,
]),
'delete-from-local'
);
} }
} }

View File

@ -23,7 +23,7 @@ public function upload(string $src, string $dest): array
$uploadCommand = $this->getScript('s3/upload.sh', [ $uploadCommand = $this->getScript('s3/upload.sh', [
'src' => $src, 'src' => $src,
'bucket' => $this->storageProvider->credentials['bucket'], 'bucket' => $this->storageProvider->credentials['bucket'],
'dest' => $this->prepareS3Path($this->storageProvider->credentials['path'].'/'.$dest), 'dest' => $this->prepareS3Path($dest),
'key' => $this->storageProvider->credentials['key'], 'key' => $this->storageProvider->credentials['key'],
'secret' => $this->storageProvider->credentials['secret'], 'secret' => $this->storageProvider->credentials['secret'],
'region' => $this->storageProvider->credentials['region'], 'region' => $this->storageProvider->credentials['region'],
@ -52,7 +52,7 @@ public function download(string $src, string $dest): void
$provider = $this->storageProvider->provider(); $provider = $this->storageProvider->provider();
$downloadCommand = $this->getScript('s3/download.sh', [ $downloadCommand = $this->getScript('s3/download.sh', [
'src' => $this->prepareS3Path($this->storageProvider->credentials['path'].'/'.$src), 'src' => $this->prepareS3Path($src),
'dest' => $dest, 'dest' => $dest,
'bucket' => $this->storageProvider->credentials['bucket'], 'bucket' => $this->storageProvider->credentials['bucket'],
'key' => $this->storageProvider->credentials['key'], 'key' => $this->storageProvider->credentials['key'],
@ -71,8 +71,21 @@ public function download(string $src, string $dest): void
} }
} }
/** public function delete(string $src): void
* @TODO Implement delete method {
*/ /** @var \App\StorageProviders\S3 $provider */
public function delete(string $path): void {} $provider = $this->storageProvider->provider();
$this->server->ssh()->exec(
$this->getScript('s3/delete-file.sh', [
'src' => $this->prepareS3Path($src),
'bucket' => $this->storageProvider->credentials['bucket'],
'key' => $this->storageProvider->credentials['key'],
'secret' => $this->storageProvider->credentials['secret'],
'region' => $this->storageProvider->credentials['region'],
'endpoint' => $provider->getApiUrl(),
]),
'delete-from-s3'
);
}
} }

View File

@ -8,5 +8,5 @@ public function upload(string $src, string $dest): array;
public function download(string $src, string $dest): void; public function download(string $src, string $dest): void;
public function delete(string $path): void; public function delete(string $src): void;
} }

View File

@ -0,0 +1,6 @@
curl --location --request POST 'https://api.dropboxapi.com/2/files/delete_v2' \
--header 'Authorization: Bearer __token__' \
--header 'Content-Type: application/json' \
--data-raw '{
"path": "__src__"
}'

View File

@ -0,0 +1 @@
curl __passive__ -u "__username__:__password__" ftp__ssl__://__host__:__port__/__src__ -Q "DELE /__src__"

View File

@ -1 +0,0 @@
rm __path__

View File

@ -1,2 +1,2 @@
mkdir -p __dest_dir__ mkdir -p __dest_dir__
cp __src__ __dest_dir__/__dest_file__ cp __src__ __dest_file__

View File

@ -0,0 +1,30 @@
#!/bin/bash
command_exists() {
command -v "$1" >/dev/null 2>&1
}
install_aws_cli() {
echo "Installing AWS CLI"
ARCH=$(uname -m)
curl "https://awscli.amazonaws.com/awscli-exe-linux-$ARCH.zip" -o "aws.zip"
unzip -q aws.zip
sudo ./aws/install --bin-dir /usr/local/bin --install-dir /usr/local/aws-cli --update
rm -rf aws.zip aws
}
if ! command_exists aws; then
install_aws_cli
fi
if ! command_exists aws; then
echo "Error: AWS CLI installation failed"
exit 1
fi
export AWS_ACCESS_KEY_ID=__key__
export AWS_SECRET_ACCESS_KEY=__secret__
export AWS_DEFAULT_REGION=__region__
export AWS_ENDPOINT_URL=__endpoint__
aws s3 rm s3://__bucket__/__src__

View File

@ -1,32 +1,32 @@
#!/bin/bash #!/bin/bash
# Configure AWS CLI with provided credentials command_exists() {
/usr/local/bin/aws configure set aws_access_key_id "__key__" command -v "$1" >/dev/null 2>&1
/usr/local/bin/aws configure set aws_secret_access_key "__secret__" }
/usr/local/bin/aws configure set default.region "__region__"
# Use the provided endpoint in the correct format install_aws_cli() {
ENDPOINT="__endpoint__" echo "Installing AWS CLI"
BUCKET="__bucket__" ARCH=$(uname -m)
REGION="__region__" curl "https://awscli.amazonaws.com/awscli-exe-linux-$ARCH.zip" -o "aws.zip"
unzip -q aws.zip
sudo ./aws/install --bin-dir /usr/local/bin --install-dir /usr/local/aws-cli --update
rm -rf aws.zip aws
}
# Ensure that DEST does not have a trailing slash if ! command_exists aws; then
SRC="__src__" install_aws_cli
DEST="__dest__" fi
# Download the file from S3 if ! command_exists aws; then
echo "Downloading s3://__bucket__/__src__ to __dest__" echo "Error: AWS CLI installation failed"
download_output=$(/usr/local/bin/aws s3 cp "s3://$BUCKET/$SRC" "$DEST" --endpoint-url="$ENDPOINT" --region "$REGION" 2>&1)
download_exit_code=$?
# Log output and exit code
echo "Download command output: $download_output"
echo "Download command exit code: $download_exit_code"
# Check if the download was successful
if [ $download_exit_code -eq 0 ]; then
echo "Download successful"
else
echo "Download failed"
exit 1 exit 1
fi fi
export AWS_ACCESS_KEY_ID=__key__
export AWS_SECRET_ACCESS_KEY=__secret__
export AWS_DEFAULT_REGION=__region__
export AWS_ENDPOINT_URL=__endpoint__
if aws s3 cp s3://__bucket__/__src__ __dest__; then
echo "Download successful"
fi

View File

@ -1,59 +1,32 @@
#!/bin/bash #!/bin/bash
# Check if AWS CLI is installed command_exists() {
if ! command -v aws &> /dev/null command -v "$1" >/dev/null 2>&1
then }
echo "AWS CLI is not installed. Installing..."
# Detect system architecture install_aws_cli() {
echo "Installing AWS CLI"
ARCH=$(uname -m) ARCH=$(uname -m)
if [ "$ARCH" == "x86_64" ]; then curl "https://awscli.amazonaws.com/awscli-exe-linux-$ARCH.zip" -o "aws.zip"
CLI_URL="https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" unzip -q aws.zip
elif [ "$ARCH" == "aarch64" ]; then sudo ./aws/install --bin-dir /usr/local/bin --install-dir /usr/local/aws-cli --update
CLI_URL="https://awscli.amazonaws.com/awscli-exe-linux-aarch64.zip" rm -rf aws.zip aws
else }
echo "Unsupported architecture: $ARCH"
if ! command_exists aws; then
install_aws_cli
fi
if ! command_exists aws; then
echo "Error: AWS CLI installation failed"
exit 1 exit 1
fi fi
# Download and install AWS CLI export AWS_ACCESS_KEY_ID=__key__
sudo curl "$CLI_URL" -o "awscliv2.zip" export AWS_SECRET_ACCESS_KEY=__secret__
sudo unzip awscliv2.zip export AWS_DEFAULT_REGION=__region__
sudo ./aws/install --update export AWS_ENDPOINT_URL=__endpoint__
sudo rm -rf awscliv2.zip aws
echo "AWS CLI installation completed." if aws s3 cp __src__ s3://__bucket__/__dest__; then
else
echo "AWS CLI is already installed."
/usr/local/bin/aws --version
fi
# Configure AWS CLI with provided credentials
/usr/local/bin/aws configure set aws_access_key_id "__key__"
/usr/local/bin/aws configure set aws_secret_access_key "__secret__"
# Use the provided endpoint in the correct format
ENDPOINT="__endpoint__"
BUCKET="__bucket__"
REGION="__region__"
# Ensure that DEST does not have a trailing slash
SRC="__src__"
DEST="__dest__"
# Upload the file
echo "Uploading __src__ to s3://$BUCKET/$DEST"
upload_output=$(/usr/local/bin/aws s3 cp "$SRC" "s3://$BUCKET/$DEST" --endpoint-url="$ENDPOINT" --region "$REGION" 2>&1)
upload_exit_code=$?
# Log output and exit code
echo "Upload command output: $upload_output"
echo "Upload command exit code: $upload_exit_code"
# Check if the upload was successful
if [ $upload_exit_code -eq 0 ]; then
echo "Upload successful" echo "Upload successful"
else
echo "Upload failed"
exit 1
fi fi

View File

@ -38,19 +38,4 @@ public function ssh(Server $server): Storage
{ {
return new \App\SSH\Storage\Dropbox($server, $this->storageProvider); return new \App\SSH\Storage\Dropbox($server, $this->storageProvider);
} }
public function delete(array $paths): void
{
$data = [];
foreach ($paths as $path) {
$data[] = ['path' => $path];
}
Http::withToken($this->storageProvider->credentials['token'])
->withHeaders([
'Content-Type:application/json',
])
->post($this->apiUrl.'/files/delete_batch', [
'entries' => $data,
]);
}
} }

View File

@ -57,23 +57,6 @@ public function ssh(Server $server): Storage
return new \App\SSH\Storage\FTP($server, $this->storageProvider); return new \App\SSH\Storage\FTP($server, $this->storageProvider);
} }
public function delete(array $paths): void
{
$connection = $this->connection();
if ($connection && $this->login($connection)) {
if ($this->storageProvider->credentials['passive']) {
\App\Facades\FTP::passive($connection, true);
}
foreach ($paths as $path) {
\App\Facades\FTP::delete($connection, $this->storageProvider->credentials['path'].'/'.$path);
}
}
\App\Facades\FTP::close($connection);
}
private function connection(): bool|Connection private function connection(): bool|Connection
{ {
$credentials = $this->storageProvider->credentials; $credentials = $this->storageProvider->credentials;

View File

@ -30,9 +30,4 @@ public function ssh(Server $server): Storage
{ {
return new \App\SSH\Storage\Local($server, $this->storageProvider); return new \App\SSH\Storage\Local($server, $this->storageProvider);
} }
public function delete(array $paths): void
{
//
}
} }

View File

@ -95,6 +95,4 @@ public function ssh(Server $server): Storage
{ {
return new S3Storage($server, $this->storageProvider); return new S3Storage($server, $this->storageProvider);
} }
public function delete(array $paths): void {}
} }

View File

@ -14,6 +14,4 @@ public function credentialData(array $input): array;
public function connect(): bool; public function connect(): bool;
public function ssh(Server $server): Storage; public function ssh(Server $server): Storage;
public function delete(array $paths): void;
} }

View File

@ -2,7 +2,7 @@
namespace App\Web\Pages\Servers\Databases; namespace App\Web\Pages\Servers\Databases;
use App\Actions\Database\CreateBackup; use App\Actions\Database\ManageBackup;
use App\Models\Backup; use App\Models\Backup;
use App\Models\StorageProvider; use App\Models\StorageProvider;
use App\Web\Contracts\HasSecondSubNav; use App\Web\Contracts\HasSecondSubNav;
@ -38,12 +38,12 @@ protected function getHeaderActions(): array
Select::make('database') Select::make('database')
->label('Database') ->label('Database')
->options($this->server->databases()->pluck('name', 'id')->toArray()) ->options($this->server->databases()->pluck('name', 'id')->toArray())
->rules(fn (callable $get) => CreateBackup::rules($this->server, $get())['database']) ->rules(fn (callable $get) => ManageBackup::rules($this->server, $get())['database'])
->searchable(), ->searchable(),
Select::make('storage') Select::make('storage')
->label('Storage') ->label('Storage')
->options(StorageProvider::getByProjectId($this->server->project_id)->pluck('profile', 'id')->toArray()) ->options(StorageProvider::getByProjectId($this->server->project_id)->pluck('profile', 'id')->toArray())
->rules(fn (callable $get) => CreateBackup::rules($this->server, $get())['storage']) ->rules(fn (callable $get) => ManageBackup::rules($this->server, $get())['storage'])
->suffixAction( ->suffixAction(
\Filament\Forms\Components\Actions\Action::make('connect') \Filament\Forms\Components\Actions\Action::make('connect')
->form(Create::form()) ->form(Create::form())
@ -59,21 +59,21 @@ protected function getHeaderActions(): array
->label('Interval') ->label('Interval')
->options(config('core.cronjob_intervals')) ->options(config('core.cronjob_intervals'))
->reactive() ->reactive()
->rules(fn (callable $get) => CreateBackup::rules($this->server, $get())['interval']), ->rules(fn (callable $get) => ManageBackup::rules($this->server, $get())['interval']),
TextInput::make('custom_interval') TextInput::make('custom_interval')
->label('Custom Interval (Cron)') ->label('Custom Interval (Cron)')
->rules(fn (callable $get) => CreateBackup::rules($this->server, $get())['custom_interval']) ->rules(fn (callable $get) => ManageBackup::rules($this->server, $get())['custom_interval'])
->visible(fn (callable $get) => $get('interval') === 'custom') ->visible(fn (callable $get) => $get('interval') === 'custom')
->placeholder('0 * * * *'), ->placeholder('0 * * * *'),
TextInput::make('keep') TextInput::make('keep')
->label('Backups to Keep') ->label('Backups to Keep')
->rules(fn (callable $get) => CreateBackup::rules($this->server, $get())['keep']) ->rules(fn (callable $get) => ManageBackup::rules($this->server, $get())['keep'])
->helperText('How many backups to keep before deleting the oldest one'), ->helperText('How many backups to keep before deleting the oldest one'),
]) ])
->modalSubmitActionLabel('Create') ->modalSubmitActionLabel('Create')
->action(function (array $data) { ->action(function (array $data) {
run_action($this, function () use ($data) { run_action($this, function () use ($data) {
app(CreateBackup::class)->create($this->server, $data); app(ManageBackup::class)->create($this->server, $data);
$this->dispatch('$refresh'); $this->dispatch('$refresh');

View File

@ -2,6 +2,7 @@
namespace App\Web\Pages\Servers\Databases\Widgets; namespace App\Web\Pages\Servers\Databases\Widgets;
use App\Actions\Database\ManageBackupFile;
use App\Actions\Database\RestoreBackup; use App\Actions\Database\RestoreBackup;
use App\Models\Backup; use App\Models\Backup;
use App\Models\BackupFile; use App\Models\BackupFile;
@ -59,11 +60,21 @@ public function table(Table $table): Table
->query($this->getTableQuery()) ->query($this->getTableQuery())
->columns($this->getTableColumns()) ->columns($this->getTableColumns())
->actions([ ->actions([
Action::make('download')
->hiddenLabel()
->icon('heroicon-o-arrow-down-tray')
->visible(fn (BackupFile $record) => $record->isAvailable() && $record->isLocal())
->tooltip('Download')
->action(function (BackupFile $record) {
return app(ManageBackupFile::class)->download($record);
})
->authorize(fn (BackupFile $record) => auth()->user()->can('view', $record)),
Action::make('restore') Action::make('restore')
->hiddenLabel() ->hiddenLabel()
->icon('heroicon-o-arrow-path') ->icon('heroicon-o-arrow-path')
->modalHeading('Restore Backup') ->modalHeading('Restore Backup')
->tooltip('Restore Backup') ->tooltip('Restore Backup')
->disabled(fn (BackupFile $record) => ! $record->isAvailable())
->authorize(fn (BackupFile $record) => auth()->user()->can('update', $record->backup)) ->authorize(fn (BackupFile $record) => auth()->user()->can('update', $record->backup))
->form([ ->form([
Select::make('database') Select::make('database')
@ -95,16 +106,15 @@ public function table(Table $table): Table
Action::make('delete') Action::make('delete')
->hiddenLabel() ->hiddenLabel()
->icon('heroicon-o-trash') ->icon('heroicon-o-trash')
->modalHeading('Delete Database') ->modalHeading('Delete Backup File')
->color('danger') ->color('danger')
->disabled(fn (BackupFile $record) => ! $record->isAvailable())
->tooltip('Delete') ->tooltip('Delete')
->authorize(fn (BackupFile $record) => auth()->user()->can('delete', $record)) ->authorize(fn (BackupFile $record) => auth()->user()->can('delete', $record))
->requiresConfirmation() ->requiresConfirmation()
->action(function (BackupFile $record) { ->action(function (BackupFile $record) {
run_action($this, function () use ($record) { app(ManageBackupFile::class)->delete($record);
$record->delete();
$this->dispatch('$refresh'); $this->dispatch('$refresh');
});
}), }),
]); ]);
} }

View File

@ -2,10 +2,14 @@
namespace App\Web\Pages\Servers\Databases\Widgets; namespace App\Web\Pages\Servers\Databases\Widgets;
use App\Actions\Database\ManageBackup;
use App\Actions\Database\RunBackup; use App\Actions\Database\RunBackup;
use App\Models\Backup; use App\Models\Backup;
use App\Models\BackupFile; use App\Models\BackupFile;
use App\Models\Server; use App\Models\Server;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Support\Enums\MaxWidth; use Filament\Support\Enums\MaxWidth;
use Filament\Tables\Actions\Action; use Filament\Tables\Actions\Action;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
@ -57,12 +61,53 @@ public function table(Table $table): Table
->query($this->getTableQuery()) ->query($this->getTableQuery())
->columns($this->getTableColumns()) ->columns($this->getTableColumns())
->actions([ ->actions([
Action::make('edit')
->hiddenLabel()
->icon('heroicon-o-pencil')
->tooltip('Edit Configuration')
->disabled(fn (Backup $record) => ! in_array($record->status, ['running', 'failed']))
->authorize(fn (Backup $record) => auth()->user()->can('update', $record))
->modelLabel('Edit Backup')
->modalWidth(MaxWidth::Large)
->modalSubmitActionLabel('Update')
->form([
Select::make('interval')
->label('Interval')
->options(config('core.cronjob_intervals'))
->reactive()
->default(fn (Backup $record) => $record->isCustomInterval() ? 'custom' : $record->interval)
->rules(fn (callable $get) => ManageBackup::rules($this->server, $get())['interval']),
TextInput::make('custom_interval')
->label('Custom Interval (Cron)')
->rules(fn (callable $get) => ManageBackup::rules($this->server, $get())['custom_interval'])
->visible(fn (callable $get) => $get('interval') === 'custom')
->default(fn (Backup $record) => $record->isCustomInterval() ? $record->interval : '')
->placeholder('0 * * * *'),
TextInput::make('keep')
->label('Backups to Keep')
->default(fn (Backup $record) => $record->keep_backups)
->rules(fn (callable $get) => ManageBackup::rules($this->server, $get())['keep'])
->helperText('How many backups to keep before deleting the oldest one'),
])
->action(function (Backup $backup, array $data) {
run_action($this, function () use ($data, $backup) {
app(ManageBackup::class)->update($backup, $data);
$this->dispatch('$refresh');
Notification::make()
->success()
->title('Backup updated!')
->send();
});
}),
Action::make('files') Action::make('files')
->hiddenLabel() ->hiddenLabel()
->icon('heroicon-o-rectangle-stack') ->icon('heroicon-o-rectangle-stack')
->modalHeading('Backup Files') ->modalHeading('Backup Files')
->color('gray') ->color('gray')
->tooltip('Show backup files') ->tooltip('Show backup files')
->disabled(fn (Backup $record) => ! in_array($record->status, ['running', 'failed']))
->authorize(fn (Backup $record) => auth()->user()->can('viewAny', [BackupFile::class, $record])) ->authorize(fn (Backup $record) => auth()->user()->can('viewAny', [BackupFile::class, $record]))
->modalContent(fn (Backup $record) => view('components.dynamic-widget', [ ->modalContent(fn (Backup $record) => view('components.dynamic-widget', [
'widget' => BackupFilesList::class, 'widget' => BackupFilesList::class,
@ -88,16 +133,16 @@ public function table(Table $table): Table
Action::make('delete') Action::make('delete')
->hiddenLabel() ->hiddenLabel()
->icon('heroicon-o-trash') ->icon('heroicon-o-trash')
->modalHeading('Delete Database') ->modalHeading('Delete Backup & Files')
->disabled(fn (Backup $record) => ! in_array($record->status, ['running', 'failed']))
->color('danger') ->color('danger')
->tooltip('Delete') ->tooltip('Delete')
->authorize(fn (Backup $record) => auth()->user()->can('delete', $record)) ->authorize(fn (Backup $record) => auth()->user()->can('delete', $record))
->requiresConfirmation() ->requiresConfirmation()
->action(function (Backup $record) { ->action(function (Backup $record) {
run_action($this, function () use ($record) { app(ManageBackup::class)->delete($record);
$record->delete();
$this->dispatch('$refresh'); $this->dispatch('$refresh');
});
}), }),
]); ]);
} }

View File

@ -62,6 +62,11 @@
'root' => storage_path('app/key-pairs'), 'root' => storage_path('app/key-pairs'),
], ],
'backups' => [
'driver' => 'local',
'root' => sys_get_temp_dir(),
],
// @deprecated // @deprecated
'key-pairs-local' => [ 'key-pairs-local' => [
'driver' => 'local', 'driver' => 'local',

View File

@ -115,6 +115,43 @@ public function test_see_backups_list(): void
->assertSee($backup->database->name); ->assertSee($backup->database->name);
} }
public function test_update_backup(): void
{
$this->actingAs($this->user);
$database = Database::factory()->create([
'server_id' => $this->server,
]);
$storage = StorageProvider::factory()->create([
'user_id' => $this->user->id,
'provider' => \App\Enums\StorageProvider::DROPBOX,
]);
$backup = Backup::factory()->create([
'server_id' => $this->server->id,
'database_id' => $database->id,
'storage_id' => $storage->id,
'interval' => '0 * * * *',
'keep_backups' => 5,
]);
Livewire::test(BackupsList::class, [
'server' => $this->server,
])
->callTableAction('edit', $backup->id, [
'interval' => '0 0 * * *',
'keep' => '10',
])
->assertSuccessful();
$this->assertDatabaseHas('backups', [
'id' => $backup->id,
'interval' => '0 0 * * *',
'keep_backups' => 10,
]);
}
public function test_delete_backup(): void public function test_delete_backup(): void
{ {
$this->actingAs($this->user); $this->actingAs($this->user);