feat: add S3 storage integration for file import
This commit introduces functionality for integrating S3 storage into the import process. It allows users to select S3 storage, check for file existence, and download files directly from S3. This enhancement improves the flexibility of the import feature by enabling users to work with files stored in S3, addressing a common use case for teams that utilize cloud storage solutions.
This commit is contained in:
parent
0e163dfa11
commit
800396b443
7 changed files with 583 additions and 58 deletions
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Livewire\Project\Database;
|
||||
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
|
@ -54,6 +55,19 @@ class Import extends Component
|
|||
|
||||
public string $mongodbRestoreCommand = 'mongorestore --authenticationDatabase=admin --username $MONGO_INITDB_ROOT_USERNAME --password $MONGO_INITDB_ROOT_PASSWORD --uri mongodb://localhost:27017 --gzip --archive=';
|
||||
|
||||
// S3 Restore properties
|
||||
public $availableS3Storages = [];
|
||||
|
||||
public ?int $s3StorageId = null;
|
||||
|
||||
public string $s3Path = '';
|
||||
|
||||
public ?string $s3DownloadedFile = null;
|
||||
|
||||
public ?int $s3FileSize = null;
|
||||
|
||||
public bool $s3DownloadInProgress = false;
|
||||
|
||||
public function getListeners()
|
||||
{
|
||||
$userId = Auth::id();
|
||||
|
|
@ -70,6 +84,7 @@ public function mount()
|
|||
}
|
||||
$this->parameters = get_route_parameters();
|
||||
$this->getContainers();
|
||||
$this->loadAvailableS3Storages();
|
||||
}
|
||||
|
||||
public function updatedDumpAll($value)
|
||||
|
|
@ -257,4 +272,264 @@ public function runImport()
|
|||
$this->importCommands = [];
|
||||
}
|
||||
}
|
||||
|
||||
public function loadAvailableS3Storages()
|
||||
{
|
||||
try {
|
||||
$this->availableS3Storages = S3Storage::ownedByCurrentTeam(['id', 'name', 'description'])
|
||||
->where('is_usable', true)
|
||||
->get();
|
||||
} catch (\Throwable $e) {
|
||||
$this->availableS3Storages = collect();
|
||||
ray($e);
|
||||
}
|
||||
}
|
||||
|
||||
public function checkS3File()
|
||||
{
|
||||
if (! $this->s3StorageId) {
|
||||
$this->dispatch('error', 'Please select an S3 storage.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (blank($this->s3Path)) {
|
||||
$this->dispatch('error', 'Please provide an S3 path.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$s3Storage = S3Storage::findOrFail($this->s3StorageId);
|
||||
|
||||
// Verify S3 belongs to current team
|
||||
if ($s3Storage->team_id !== currentTeam()->id) {
|
||||
$this->dispatch('error', 'You do not have permission to access this S3 storage.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Test connection
|
||||
$s3Storage->testConnection();
|
||||
|
||||
// Build S3 disk configuration
|
||||
$disk = Storage::build([
|
||||
'driver' => 's3',
|
||||
'region' => $s3Storage->region,
|
||||
'key' => $s3Storage->key,
|
||||
'secret' => $s3Storage->secret,
|
||||
'bucket' => $s3Storage->bucket,
|
||||
'endpoint' => $s3Storage->endpoint,
|
||||
'use_path_style_endpoint' => true,
|
||||
]);
|
||||
|
||||
// Clean the path (remove leading slash if present)
|
||||
$cleanPath = ltrim($this->s3Path, '/');
|
||||
|
||||
// Check if file exists
|
||||
if (! $disk->exists($cleanPath)) {
|
||||
$this->dispatch('error', 'File not found in S3. Please check the path.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Get file size
|
||||
$this->s3FileSize = $disk->size($cleanPath);
|
||||
|
||||
$this->dispatch('success', 'File found in S3. Size: '.formatBytes($this->s3FileSize));
|
||||
} catch (\Throwable $e) {
|
||||
$this->s3FileSize = null;
|
||||
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function downloadFromS3()
|
||||
{
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
if (! $this->s3StorageId || blank($this->s3Path)) {
|
||||
$this->dispatch('error', 'Please select S3 storage and provide a path first.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (is_null($this->s3FileSize)) {
|
||||
$this->dispatch('error', 'Please check the file first by clicking "Check File".');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->s3DownloadInProgress = true;
|
||||
|
||||
$s3Storage = S3Storage::findOrFail($this->s3StorageId);
|
||||
|
||||
// Verify S3 belongs to current team
|
||||
if ($s3Storage->team_id !== currentTeam()->id) {
|
||||
$this->dispatch('error', 'You do not have permission to access this S3 storage.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$key = $s3Storage->key;
|
||||
$secret = $s3Storage->secret;
|
||||
$bucket = $s3Storage->bucket;
|
||||
$endpoint = $s3Storage->endpoint;
|
||||
|
||||
// Clean the path
|
||||
$cleanPath = ltrim($this->s3Path, '/');
|
||||
|
||||
// Create temporary download directory
|
||||
$downloadDir = "/tmp/s3-restore-{$this->resource->uuid}";
|
||||
$downloadPath = "{$downloadDir}/".basename($cleanPath);
|
||||
|
||||
// Get helper image
|
||||
$helperImage = config('constants.coolify.helper_image');
|
||||
$latestVersion = instanceSettings()->helper_version;
|
||||
$fullImageName = "{$helperImage}:{$latestVersion}";
|
||||
|
||||
// Prepare download commands
|
||||
$commands = [];
|
||||
|
||||
// Create download directory on server
|
||||
$commands[] = "mkdir -p {$downloadDir}";
|
||||
|
||||
// Check if container exists and remove it
|
||||
$containerName = "s3-restore-{$this->resource->uuid}";
|
||||
$containerExists = instant_remote_process(["docker ps -a -q -f name={$containerName}"], $this->server, false);
|
||||
if (filled($containerExists)) {
|
||||
instant_remote_process(["docker rm -f {$containerName}"], $this->server, false);
|
||||
}
|
||||
|
||||
// Run MinIO client container to download file
|
||||
$commands[] = "docker run -d --name {$containerName} --rm -v {$downloadDir}:{$downloadDir} {$fullImageName} sleep 30";
|
||||
$commands[] = "docker exec {$containerName} mc alias set temporary {$endpoint} {$key} \"{$secret}\"";
|
||||
$commands[] = "docker exec {$containerName} mc cp temporary/{$bucket}/{$cleanPath} {$downloadPath}";
|
||||
|
||||
// Execute download commands
|
||||
$activity = remote_process($commands, $this->server, ignore_errors: false, callEventOnFinish: 'S3DownloadFinished', callEventData: [
|
||||
'downloadPath' => $downloadPath,
|
||||
'containerName' => $containerName,
|
||||
'serverId' => $this->server->id,
|
||||
'resourceUuid' => $this->resource->uuid,
|
||||
]);
|
||||
|
||||
$this->s3DownloadedFile = $downloadPath;
|
||||
$this->filename = $downloadPath;
|
||||
|
||||
$this->dispatch('activityMonitor', $activity->id);
|
||||
$this->dispatch('info', 'Downloading file from S3. This may take a few minutes for large backups...');
|
||||
} catch (\Throwable $e) {
|
||||
$this->s3DownloadInProgress = false;
|
||||
$this->s3DownloadedFile = null;
|
||||
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function restoreFromS3()
|
||||
{
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
if (! $this->s3DownloadedFile) {
|
||||
$this->dispatch('error', 'Please download the file from S3 first.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->importRunning = true;
|
||||
$this->importCommands = [];
|
||||
|
||||
// Use the downloaded file path
|
||||
$backupFileName = '/tmp/restore_'.$this->resource->uuid;
|
||||
$this->importCommands[] = "docker cp {$this->s3DownloadedFile} {$this->container}:{$backupFileName}";
|
||||
$tmpPath = $backupFileName;
|
||||
|
||||
// Copy the restore command to a script file
|
||||
$scriptPath = "/tmp/restore_{$this->resource->uuid}.sh";
|
||||
|
||||
switch ($this->resource->getMorphClass()) {
|
||||
case \App\Models\StandaloneMariadb::class:
|
||||
$restoreCommand = $this->mariadbRestoreCommand;
|
||||
if ($this->dumpAll) {
|
||||
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mariadb -u root -p\$MARIADB_ROOT_PASSWORD";
|
||||
} else {
|
||||
$restoreCommand .= " < {$tmpPath}";
|
||||
}
|
||||
break;
|
||||
case \App\Models\StandaloneMysql::class:
|
||||
$restoreCommand = $this->mysqlRestoreCommand;
|
||||
if ($this->dumpAll) {
|
||||
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mysql -u root -p\$MYSQL_ROOT_PASSWORD";
|
||||
} else {
|
||||
$restoreCommand .= " < {$tmpPath}";
|
||||
}
|
||||
break;
|
||||
case \App\Models\StandalonePostgresql::class:
|
||||
$restoreCommand = $this->postgresqlRestoreCommand;
|
||||
if ($this->dumpAll) {
|
||||
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | psql -U \$POSTGRES_USER postgres";
|
||||
} else {
|
||||
$restoreCommand .= " {$tmpPath}";
|
||||
}
|
||||
break;
|
||||
case \App\Models\StandaloneMongodb::class:
|
||||
$restoreCommand = $this->mongodbRestoreCommand;
|
||||
if ($this->dumpAll === false) {
|
||||
$restoreCommand .= "{$tmpPath}";
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
$restoreCommandBase64 = base64_encode($restoreCommand);
|
||||
$this->importCommands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$scriptPath}";
|
||||
$this->importCommands[] = "chmod +x {$scriptPath}";
|
||||
$this->importCommands[] = "docker cp {$scriptPath} {$this->container}:{$scriptPath}";
|
||||
|
||||
$this->importCommands[] = "docker exec {$this->container} sh -c '{$scriptPath}'";
|
||||
$this->importCommands[] = "docker exec {$this->container} sh -c 'echo \"Import finished with exit code $?\"'";
|
||||
|
||||
if (! empty($this->importCommands)) {
|
||||
$activity = remote_process($this->importCommands, $this->server, ignore_errors: true, callEventOnFinish: 'S3RestoreJobFinished', callEventData: [
|
||||
'scriptPath' => $scriptPath,
|
||||
'tmpPath' => $tmpPath,
|
||||
'container' => $this->container,
|
||||
'serverId' => $this->server->id,
|
||||
's3DownloadedFile' => $this->s3DownloadedFile,
|
||||
'resourceUuid' => $this->resource->uuid,
|
||||
]);
|
||||
$this->dispatch('activityMonitor', $activity->id);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
} finally {
|
||||
$this->importCommands = [];
|
||||
}
|
||||
}
|
||||
|
||||
public function cancelS3Download()
|
||||
{
|
||||
if ($this->s3DownloadedFile) {
|
||||
try {
|
||||
// Cleanup downloaded file and directory
|
||||
$downloadDir = "/tmp/s3-restore-{$this->resource->uuid}";
|
||||
instant_remote_process(["rm -rf {$downloadDir}"], $this->server, false);
|
||||
|
||||
// Cleanup container if exists
|
||||
$containerName = "s3-restore-{$this->resource->uuid}";
|
||||
instant_remote_process(["docker rm -f {$containerName}"], $this->server, false);
|
||||
|
||||
$this->dispatch('success', 'S3 download cancelled and temporary files cleaned up.');
|
||||
} catch (\Throwable $e) {
|
||||
ray($e);
|
||||
}
|
||||
}
|
||||
|
||||
// Reset S3 download state
|
||||
$this->s3DownloadedFile = null;
|
||||
$this->s3DownloadInProgress = false;
|
||||
$this->filename = null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3154,3 +3154,18 @@ function generateDockerComposeServiceName(mixed $services, int $pullRequestId =
|
|||
|
||||
return $collection;
|
||||
}
|
||||
|
||||
function formatBytes(?int $bytes = 0, int $precision = 2): string
|
||||
{
|
||||
if (is_null($bytes) || $bytes <= 0) {
|
||||
return '0 B';
|
||||
}
|
||||
|
||||
$units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
|
||||
$pow = min($pow, count($units) - 1);
|
||||
|
||||
$bytes /= (1024 ** $pow);
|
||||
|
||||
return round($bytes, $precision).' '.$units[$pow];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,22 +68,21 @@ class="flex flex-col gap-4">
|
|||
</span>
|
||||
</div>
|
||||
<div class="text-gray-600 dark:text-gray-400 text-sm">
|
||||
Started: {{ formatDateInServerTimezone(data_get($execution, 'created_at'), $this->server()) }}
|
||||
@if (data_get($execution, 'status') !== 'running')
|
||||
<br>Ended:
|
||||
{{ formatDateInServerTimezone(data_get($execution, 'finished_at'), $this->server()) }}
|
||||
<br>Duration:
|
||||
{{ calculateDuration(data_get($execution, 'created_at'), data_get($execution, 'finished_at')) }}
|
||||
<br>Finished {{ \Carbon\Carbon::parse(data_get($execution, 'finished_at'))->diffForHumans() }}
|
||||
@if (data_get($execution, 'status') === 'running')
|
||||
<span title="Started: {{ formatDateInServerTimezone(data_get($execution, 'created_at'), $this->server()) }}">
|
||||
Running for {{ calculateDuration(data_get($execution, 'created_at'), now()) }}
|
||||
</span>
|
||||
@else
|
||||
<span title="Started: {{ formatDateInServerTimezone(data_get($execution, 'created_at'), $this->server()) }} Ended: {{ formatDateInServerTimezone(data_get($execution, 'finished_at'), $this->server()) }}">
|
||||
{{ \Carbon\Carbon::parse(data_get($execution, 'finished_at'))->diffForHumans() }}
|
||||
({{ calculateDuration(data_get($execution, 'created_at'), data_get($execution, 'finished_at')) }})
|
||||
• {{ \Carbon\Carbon::parse(data_get($execution, 'finished_at'))->format('M j, H:i') }}
|
||||
</span>
|
||||
@endif
|
||||
• Database: {{ data_get($execution, 'database_name', 'N/A') }}
|
||||
@if(data_get($execution, 'size'))
|
||||
• Size: {{ formatBytes(data_get($execution, 'size')) }}
|
||||
@endif
|
||||
</div>
|
||||
<div class="text-gray-600 dark:text-gray-400 text-sm">
|
||||
Database: {{ data_get($execution, 'database_name', 'N/A') }}
|
||||
</div>
|
||||
<div class="text-gray-600 dark:text-gray-400 text-sm">
|
||||
Size: {{ data_get($execution, 'size') }} B /
|
||||
{{ round((int) data_get($execution, 'size') / 1024, 2) }} kB /
|
||||
{{ round((int) data_get($execution, 'size') / 1024 / 1024, 3) }} MB
|
||||
</div>
|
||||
<div class="text-gray-600 dark:text-gray-400 text-sm">
|
||||
Location: {{ data_get($execution, 'filename', 'N/A') }}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,13 @@
|
|||
<div x-data="{ error: $wire.entangle('error'), filesize: $wire.entangle('filesize'), filename: $wire.entangle('filename'), isUploading: $wire.entangle('isUploading'), progress: $wire.entangle('progress') }">
|
||||
<div x-data="{
|
||||
error: $wire.entangle('error'),
|
||||
filesize: $wire.entangle('filesize'),
|
||||
filename: $wire.entangle('filename'),
|
||||
isUploading: $wire.entangle('isUploading'),
|
||||
progress: $wire.entangle('progress'),
|
||||
s3DownloadInProgress: $wire.entangle('s3DownloadInProgress'),
|
||||
s3DownloadedFile: $wire.entangle('s3DownloadedFile'),
|
||||
s3FileSize: $wire.entangle('s3FileSize')
|
||||
}">
|
||||
<script type="text/javascript" src="{{ URL::asset('js/dropzone.js') }}"></script>
|
||||
@script
|
||||
<script data-navigate-once>
|
||||
|
|
@ -103,7 +112,65 @@
|
|||
<div x-show="isUploading">
|
||||
<progress max="100" x-bind:value="progress" class="progress progress-warning"></progress>
|
||||
</div>
|
||||
<h3 class="pt-6" x-show="filename && !error">File Information</h3>
|
||||
|
||||
@if ($availableS3Storages->count() > 0)
|
||||
<div class="pt-2 text-center text-xl font-bold">
|
||||
Or
|
||||
</div>
|
||||
<h3 class="pt-4">Restore from S3</h3>
|
||||
<div class="flex flex-col gap-2">
|
||||
<x-forms.select label="S3 Storage" wire:model="s3StorageId">
|
||||
<option value="">Select S3 Storage</option>
|
||||
@foreach ($availableS3Storages as $storage)
|
||||
<option value="{{ $storage->id }}">{{ $storage->name }}
|
||||
@if ($storage->description)
|
||||
- {{ $storage->description }}
|
||||
@endif
|
||||
</option>
|
||||
@endforeach
|
||||
</x-forms.select>
|
||||
|
||||
<x-forms.input label="S3 File Path (within bucket)"
|
||||
helper="Path to the backup file in your S3 bucket, e.g., /backups/database-2025-01-15.gz"
|
||||
placeholder="/backups/database-backup.gz" wire:model='s3Path'></x-forms.input>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<x-forms.button class="w-full" wire:click='checkS3File'
|
||||
:disabled="!$s3StorageId || !$s3Path">
|
||||
Check File
|
||||
</x-forms.button>
|
||||
</div>
|
||||
|
||||
<div x-show="s3FileSize && !s3DownloadedFile" class="pt-2">
|
||||
<div class="text-sm">File found in S3 ({{ formatBytes($s3FileSize ?? 0) }})</div>
|
||||
<div class="flex gap-2 pt-2">
|
||||
<x-forms.button class="w-full" wire:click='downloadFromS3'>
|
||||
Download & Prepare for Restore
|
||||
</x-forms.button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="s3DownloadInProgress" class="pt-2">
|
||||
<div class="text-sm text-warning">Downloading from S3... This may take a few minutes for large
|
||||
backups.</div>
|
||||
<livewire:activity-monitor header="S3 Download Progress" :showWaiting="false" />
|
||||
</div>
|
||||
|
||||
<div x-show="s3DownloadedFile && !s3DownloadInProgress" class="pt-2">
|
||||
<div class="text-sm text-success">File downloaded successfully and ready for restore.</div>
|
||||
<div class="flex gap-2 pt-2">
|
||||
<x-forms.button class="w-full" wire:click='restoreFromS3'>
|
||||
Restore Database from S3
|
||||
</x-forms.button>
|
||||
<x-forms.button class="w-full" wire:click='cancelS3Download'>
|
||||
Cancel
|
||||
</x-forms.button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<h3 class="pt-6" x-show="filename && !error && !s3DownloadedFile">File Information</h3>
|
||||
<div x-show="filename && !error">
|
||||
<div>Location: <span x-text="filename ?? 'N/A'"></span> <span x-text="filesize">/ </span></div>
|
||||
<x-forms.button class="w-full my-4" wire:click='runImport'>Restore Backup</x-forms.button>
|
||||
|
|
|
|||
|
|
@ -70,32 +70,32 @@ class="px-3 py-1 rounded-md text-xs font-medium tracking-wide shadow-xs bg-gray-
|
|||
</div>
|
||||
<div class="text-gray-600 dark:text-gray-400 text-sm">
|
||||
@if ($backup->latest_log)
|
||||
Started:
|
||||
{{ formatDateInServerTimezone(data_get($backup->latest_log, 'created_at'), $backup->server()) }}
|
||||
@if (data_get($backup->latest_log, 'status') !== 'running')
|
||||
<br>Ended:
|
||||
{{ formatDateInServerTimezone(data_get($backup->latest_log, 'finished_at'), $backup->server()) }}
|
||||
<br>Duration:
|
||||
{{ calculateDuration(data_get($backup->latest_log, 'created_at'), data_get($backup->latest_log, 'finished_at')) }}
|
||||
<br>Finished
|
||||
{{ \Carbon\Carbon::parse(data_get($backup->latest_log, 'finished_at'))->diffForHumans() }}
|
||||
@endif
|
||||
@if ($backup->save_s3)
|
||||
<br>S3 Storage: Enabled
|
||||
@if (data_get($backup->latest_log, 'status') === 'running')
|
||||
<span title="Started: {{ formatDateInServerTimezone(data_get($backup->latest_log, 'created_at'), $backup->server()) }}">
|
||||
Running for {{ calculateDuration(data_get($backup->latest_log, 'created_at'), now()) }}
|
||||
</span>
|
||||
@else
|
||||
<span title="Started: {{ formatDateInServerTimezone(data_get($backup->latest_log, 'created_at'), $backup->server()) }} Ended: {{ formatDateInServerTimezone(data_get($backup->latest_log, 'finished_at'), $backup->server()) }}">
|
||||
{{ \Carbon\Carbon::parse(data_get($backup->latest_log, 'finished_at'))->diffForHumans() }}
|
||||
({{ calculateDuration(data_get($backup->latest_log, 'created_at'), data_get($backup->latest_log, 'finished_at')) }})
|
||||
• {{ \Carbon\Carbon::parse(data_get($backup->latest_log, 'finished_at'))->format('M j, H:i') }}
|
||||
</span>
|
||||
@endif
|
||||
@if (data_get($backup->latest_log, 'status') === 'success')
|
||||
@php
|
||||
$size = data_get($backup->latest_log, 'size', 0);
|
||||
$sizeFormatted =
|
||||
$size > 0 ? number_format($size / 1024 / 1024, 2) . ' MB' : 'Unknown';
|
||||
@endphp
|
||||
<br>Last Backup Size: {{ $sizeFormatted }}
|
||||
@if ($size > 0)
|
||||
• Size: {{ formatBytes($size) }}
|
||||
@endif
|
||||
@endif
|
||||
@if ($backup->save_s3)
|
||||
• S3: Enabled
|
||||
@endif
|
||||
@else
|
||||
Last Run: Never
|
||||
<br>Total Executions: 0
|
||||
Last Run: Never • Total Executions: 0
|
||||
@if ($backup->save_s3)
|
||||
<br>S3 Storage: Enabled
|
||||
• S3: Enabled
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
|
|
@ -154,27 +154,36 @@ class="px-3 py-1 rounded-md text-xs font-medium tracking-wide shadow-xs bg-gray-
|
|||
</div>
|
||||
<div class="text-gray-600 dark:text-gray-400 text-sm">
|
||||
@if ($backup->latest_log)
|
||||
Started:
|
||||
{{ formatDateInServerTimezone(data_get($backup->latest_log, 'created_at'), $backup->server()) }}
|
||||
@if (data_get($backup->latest_log, 'status') !== 'running')
|
||||
<br>Ended:
|
||||
{{ formatDateInServerTimezone(data_get($backup->latest_log, 'finished_at'), $backup->server()) }}
|
||||
<br>Duration:
|
||||
{{ calculateDuration(data_get($backup->latest_log, 'created_at'), data_get($backup->latest_log, 'finished_at')) }}
|
||||
<br>Finished
|
||||
{{ \Carbon\Carbon::parse(data_get($backup->latest_log, 'finished_at'))->diffForHumans() }}
|
||||
@if (data_get($backup->latest_log, 'status') === 'running')
|
||||
<span title="Started: {{ formatDateInServerTimezone(data_get($backup->latest_log, 'created_at'), $backup->server()) }}">
|
||||
Running for {{ calculateDuration(data_get($backup->latest_log, 'created_at'), now()) }}
|
||||
</span>
|
||||
@else
|
||||
<span title="Started: {{ formatDateInServerTimezone(data_get($backup->latest_log, 'created_at'), $backup->server()) }} Ended: {{ formatDateInServerTimezone(data_get($backup->latest_log, 'finished_at'), $backup->server()) }}">
|
||||
{{ \Carbon\Carbon::parse(data_get($backup->latest_log, 'finished_at'))->diffForHumans() }}
|
||||
({{ calculateDuration(data_get($backup->latest_log, 'created_at'), data_get($backup->latest_log, 'finished_at')) }})
|
||||
• {{ \Carbon\Carbon::parse(data_get($backup->latest_log, 'finished_at'))->format('M j, H:i') }}
|
||||
</span>
|
||||
@endif
|
||||
@if (data_get($backup->latest_log, 'status') === 'success')
|
||||
@php
|
||||
$size = data_get($backup->latest_log, 'size', 0);
|
||||
@endphp
|
||||
@if ($size > 0)
|
||||
• Size: {{ formatBytes($size) }}
|
||||
@endif
|
||||
@endif
|
||||
<br><br>Total Executions: {{ $backup->executions()->count() }}
|
||||
@if ($backup->save_s3)
|
||||
<br>S3 Storage: Enabled
|
||||
• S3: Enabled
|
||||
@endif
|
||||
<br>Total Executions: {{ $backup->executions()->count() }}
|
||||
@php
|
||||
$successCount = $backup->executions()->where('status', 'success')->count();
|
||||
$totalCount = $backup->executions()->count();
|
||||
$successRate = $totalCount > 0 ? round(($successCount / $totalCount) * 100) : 0;
|
||||
@endphp
|
||||
@if ($totalCount > 0)
|
||||
<br>Success Rate: <span @class([
|
||||
• Success Rate: <span @class([
|
||||
'font-medium',
|
||||
'text-green-600' => $successRate >= 80,
|
||||
'text-yellow-600' => $successRate >= 50 && $successRate < 80,
|
||||
|
|
@ -182,19 +191,10 @@ class="px-3 py-1 rounded-md text-xs font-medium tracking-wide shadow-xs bg-gray-
|
|||
])>{{ $successRate }}%</span>
|
||||
({{ $successCount }}/{{ $totalCount }})
|
||||
@endif
|
||||
@if (data_get($backup->latest_log, 'status') === 'success')
|
||||
@php
|
||||
$size = data_get($backup->latest_log, 'size', 0);
|
||||
$sizeFormatted =
|
||||
$size > 0 ? number_format($size / 1024 / 1024, 2) . ' MB' : 'Unknown';
|
||||
@endphp
|
||||
<br>Last Backup Size: {{ $sizeFormatted }}
|
||||
@endif
|
||||
@else
|
||||
Last Run: Never
|
||||
<br>Total Executions: 0
|
||||
Last Run: Never • Total Executions: 0
|
||||
@if ($backup->save_s3)
|
||||
<br>S3 Storage: Enabled
|
||||
• S3: Enabled
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
|
|
|
|||
94
tests/Feature/DatabaseS3RestoreTest.php
Normal file
94
tests/Feature/DatabaseS3RestoreTest.php
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
<?php
|
||||
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
// Create a user and team
|
||||
$this->user = User::factory()->create();
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user->teams()->attach($this->team, ['role' => 'owner']);
|
||||
|
||||
// Create S3 storage
|
||||
$this->s3Storage = S3Storage::create([
|
||||
'uuid' => 'test-s3-uuid-'.uniqid(),
|
||||
'team_id' => $this->team->id,
|
||||
'name' => 'Test S3',
|
||||
'key' => 'test-key',
|
||||
'secret' => 'test-secret',
|
||||
'region' => 'us-east-1',
|
||||
'bucket' => 'test-bucket',
|
||||
'endpoint' => 'https://s3.amazonaws.com',
|
||||
'is_usable' => true,
|
||||
]);
|
||||
|
||||
// Authenticate as the user
|
||||
$this->actingAs($this->user);
|
||||
$this->user->currentTeam()->associate($this->team);
|
||||
$this->user->save();
|
||||
});
|
||||
|
||||
test('S3Storage can be created with team association', function () {
|
||||
expect($this->s3Storage->team_id)->toBe($this->team->id);
|
||||
expect($this->s3Storage->name)->toBe('Test S3');
|
||||
expect($this->s3Storage->is_usable)->toBeTrue();
|
||||
});
|
||||
|
||||
test('Only usable S3 storages are loaded', function () {
|
||||
// Create an unusable S3 storage
|
||||
S3Storage::create([
|
||||
'uuid' => 'test-s3-uuid-unusable-'.uniqid(),
|
||||
'team_id' => $this->team->id,
|
||||
'name' => 'Unusable S3',
|
||||
'key' => 'key',
|
||||
'secret' => 'secret',
|
||||
'region' => 'us-east-1',
|
||||
'bucket' => 'bucket',
|
||||
'endpoint' => 'https://s3.amazonaws.com',
|
||||
'is_usable' => false,
|
||||
]);
|
||||
|
||||
// Query only usable S3 storages
|
||||
$usableS3Storages = S3Storage::where('team_id', $this->team->id)
|
||||
->where('is_usable', true)
|
||||
->get();
|
||||
|
||||
expect($usableS3Storages)->toHaveCount(1);
|
||||
expect($usableS3Storages->first()->name)->toBe('Test S3');
|
||||
});
|
||||
|
||||
test('S3 storages are isolated by team', function () {
|
||||
// Create another team with its own S3 storage
|
||||
$otherTeam = Team::factory()->create();
|
||||
S3Storage::create([
|
||||
'uuid' => 'test-s3-uuid-other-'.uniqid(),
|
||||
'team_id' => $otherTeam->id,
|
||||
'name' => 'Other Team S3',
|
||||
'key' => 'key',
|
||||
'secret' => 'secret',
|
||||
'region' => 'us-east-1',
|
||||
'bucket' => 'bucket',
|
||||
'endpoint' => 'https://s3.amazonaws.com',
|
||||
'is_usable' => true,
|
||||
]);
|
||||
|
||||
// Current user's team should only see their S3
|
||||
$teamS3Storages = S3Storage::where('team_id', $this->team->id)
|
||||
->where('is_usable', true)
|
||||
->get();
|
||||
|
||||
expect($teamS3Storages)->toHaveCount(1);
|
||||
expect($teamS3Storages->first()->name)->toBe('Test S3');
|
||||
});
|
||||
|
||||
test('S3Storage model has required fields', function () {
|
||||
expect($this->s3Storage)->toHaveProperty('key');
|
||||
expect($this->s3Storage)->toHaveProperty('secret');
|
||||
expect($this->s3Storage)->toHaveProperty('bucket');
|
||||
expect($this->s3Storage)->toHaveProperty('endpoint');
|
||||
expect($this->s3Storage)->toHaveProperty('region');
|
||||
});
|
||||
75
tests/Unit/S3RestoreTest.php
Normal file
75
tests/Unit/S3RestoreTest.php
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
<?php
|
||||
|
||||
test('S3 path is cleaned correctly', function () {
|
||||
// Test that leading slashes are removed
|
||||
$path = '/backups/database.gz';
|
||||
$cleanPath = ltrim($path, '/');
|
||||
|
||||
expect($cleanPath)->toBe('backups/database.gz');
|
||||
|
||||
// Test path without leading slash remains unchanged
|
||||
$path2 = 'backups/database.gz';
|
||||
$cleanPath2 = ltrim($path2, '/');
|
||||
|
||||
expect($cleanPath2)->toBe('backups/database.gz');
|
||||
});
|
||||
|
||||
test('S3 container name is generated correctly', function () {
|
||||
$resourceUuid = 'test-database-uuid';
|
||||
$containerName = "s3-restore-{$resourceUuid}";
|
||||
|
||||
expect($containerName)->toBe('s3-restore-test-database-uuid');
|
||||
expect($containerName)->toStartWith('s3-restore-');
|
||||
});
|
||||
|
||||
test('S3 download directory is created correctly', function () {
|
||||
$resourceUuid = 'test-database-uuid';
|
||||
$downloadDir = "/tmp/s3-restore-{$resourceUuid}";
|
||||
|
||||
expect($downloadDir)->toBe('/tmp/s3-restore-test-database-uuid');
|
||||
expect($downloadDir)->toStartWith('/tmp/s3-restore-');
|
||||
});
|
||||
|
||||
test('cancelS3Download cleans up correctly', function () {
|
||||
// Test that cleanup directory path is correct
|
||||
$resourceUuid = 'test-database-uuid';
|
||||
$downloadDir = "/tmp/s3-restore-{$resourceUuid}";
|
||||
$containerName = "s3-restore-{$resourceUuid}";
|
||||
|
||||
expect($downloadDir)->toContain($resourceUuid);
|
||||
expect($containerName)->toContain($resourceUuid);
|
||||
});
|
||||
|
||||
test('S3 file path formats are handled correctly', function () {
|
||||
$paths = [
|
||||
'/backups/db.gz',
|
||||
'backups/db.gz',
|
||||
'/nested/path/to/backup.sql.gz',
|
||||
'backup-2025-01-15.gz',
|
||||
];
|
||||
|
||||
foreach ($paths as $path) {
|
||||
$cleanPath = ltrim($path, '/');
|
||||
expect($cleanPath)->not->toStartWith('/');
|
||||
}
|
||||
});
|
||||
|
||||
test('formatBytes helper formats file sizes correctly', function () {
|
||||
// Test various file sizes
|
||||
expect(formatBytes(0))->toBe('0 B');
|
||||
expect(formatBytes(null))->toBe('0 B');
|
||||
expect(formatBytes(1024))->toBe('1 KB');
|
||||
expect(formatBytes(1048576))->toBe('1 MB');
|
||||
expect(formatBytes(1073741824))->toBe('1 GB');
|
||||
expect(formatBytes(1099511627776))->toBe('1 TB');
|
||||
|
||||
// Test with different sizes
|
||||
expect(formatBytes(512))->toBe('512 B');
|
||||
expect(formatBytes(2048))->toBe('2 KB');
|
||||
expect(formatBytes(5242880))->toBe('5 MB');
|
||||
expect(formatBytes(10737418240))->toBe('10 GB');
|
||||
|
||||
// Test precision
|
||||
expect(formatBytes(1536, 2))->toBe('1.5 KB');
|
||||
expect(formatBytes(1572864, 1))->toBe('1.5 MB');
|
||||
});
|
||||
Loading…
Reference in a new issue