feat: streamline S3 restore with single-step flow and improved UI consistency

Major architectural improvements:
- Merged download and restore into single atomic operation
- Eliminated separate S3DownloadFinished event (redundant)
- Files now transfer directly: S3 → helper container → server → database container
- Removed download progress tracking in favor of unified restore progress

UI/UX improvements:
- Unified restore method selection with visual cards
- Consistent "File Information" display between local and S3 restore
- Single slide-over for all restore operations (removed separate S3 download monitor)
- Better visual feedback with loading states

Security enhancements:
- Added isSafeTmpPath() helper for path traversal protection
- URL decode validation to catch encoded attacks
- Canonical path resolution to prevent symlink attacks
- Comprehensive path validation in all cleanup events

Cleanup improvements:
- S3RestoreJobFinished now handles all cleanup (helper container + all temp files)
- RestoreJobFinished uses new isSafeTmpPath() validation
- CoolifyTask dispatches cleanup events even on job failure
- All cleanup uses non-throwing commands (2>/dev/null || true)

Other improvements:
- S3 storage policy authorization on Show component
- Storage Form properly syncs is_usable state after test
- Removed debug code and improved error handling
- Better command organization and documentation

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2025-11-17 10:05:18 +01:00
parent a660dd8c83
commit 94560ea6c7
19 changed files with 1298 additions and 421 deletions

View File

@@ -62,39 +62,19 @@ class Import extends Component
public string $s3Path = '';
public ?string $s3DownloadedFile = null;
public ?int $s3FileSize = null;
public bool $s3DownloadInProgress = false;
public function getListeners()
{
$userId = Auth::id();
return [
"echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
"echo-private:user.{$userId},S3DownloadFinished" => 'handleS3DownloadFinished',
];
}
public function handleS3DownloadFinished($data): void
{
$this->s3DownloadInProgress = false;
// Set the downloaded file path from the event data
$downloadPath = data_get($data, 'downloadPath');
if (filled($downloadPath)) {
$this->s3DownloadedFile = $downloadPath;
$this->filename = $downloadPath;
}
}
public function mount()
{
if (isDev()) {
$this->customLocation = '/data/coolify/pg-dump-all-1736245863.gz';
}
$this->parameters = get_route_parameters();
$this->getContainers();
$this->loadAvailableS3Storages();
@@ -276,7 +256,10 @@ EOD;
'container' => $this->container,
'serverId' => $this->server->id,
]);
// Dispatch activity to the monitor and open slide-over
$this->dispatch('activityMonitor', $activity->id);
$this->dispatch('databaserestore');
}
} catch (\Throwable $e) {
return handleError($e, $this);
@@ -294,7 +277,6 @@ EOD;
->get();
} catch (\Throwable $e) {
$this->availableS3Storages = collect();
ray($e);
}
}
@@ -350,7 +332,7 @@ EOD;
}
}
public function downloadFromS3()
public function restoreFromS3()
{
$this->authorize('update', $this->resource);
@@ -367,7 +349,7 @@ EOD;
}
try {
$this->s3DownloadInProgress = true;
$this->importRunning = true;
$s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId);
@@ -376,154 +358,119 @@ EOD;
$bucket = $s3Storage->bucket;
$endpoint = $s3Storage->endpoint;
// Clean the path
// Clean the S3 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;
$latestVersion = getHelperVersion();
$fullImageName = "{$helperImage}:{$latestVersion}";
// Prepare download commands
$commands = [];
// Get the database destination network
$destinationNetwork = $this->resource->destination->network ?? 'coolify';
// Create download directory on server
$commands[] = "mkdir -p {$downloadDir}";
// Check if container exists and remove it (done in the command queue to avoid blocking)
// Generate unique names for this operation
$containerName = "s3-restore-{$this->resource->uuid}";
$commands[] = "docker rm -f {$containerName} 2>/dev/null || true";
// 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: [
'userId' => Auth::id(),
'downloadPath' => $downloadPath,
'containerName' => $containerName,
'serverId' => $this->server->id,
'resourceUuid' => $this->resource->uuid,
]);
$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
$helperTmpPath = '/tmp/'.basename($cleanPath);
$serverTmpPath = "/tmp/s3-restore-{$this->resource->uuid}-".basename($cleanPath);
$containerTmpPath = "/tmp/restore_{$this->resource->uuid}-".basename($cleanPath);
$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;
}
// Prepare all commands in sequence
$commands = [];
// 1. Clean up any existing helper container
$commands[] = "docker rm -f {$containerName} 2>/dev/null || true";
// 2. Start helper container on the database network
$commands[] = "docker run -d --network {$destinationNetwork} --name {$containerName} --rm {$fullImageName} sleep 3600";
// 3. Configure S3 access in helper container
$escapedEndpoint = escapeshellarg($endpoint);
$escapedKey = escapeshellarg($key);
$escapedSecret = escapeshellarg($secret);
$commands[] = "docker exec {$containerName} mc alias set s3temp {$escapedEndpoint} {$escapedKey} {$escapedSecret}";
// 4. Check file exists in S3
$commands[] = "docker exec {$containerName} mc stat s3temp/{$bucket}/{$cleanPath}";
// 5. Download from S3 to helper container's internal /tmp
$commands[] = "docker exec {$containerName} mc cp s3temp/{$bucket}/{$cleanPath} {$helperTmpPath}";
// 6. Copy file from helper container to server
$commands[] = "docker cp {$containerName}:{$helperTmpPath} {$serverTmpPath}";
// 7. Copy file from server to database container
$commands[] = "docker cp {$serverTmpPath} {$this->container}:{$containerTmpPath}";
// 8. Build and execute restore command inside database container
$restoreCommand = $this->buildRestoreCommand($containerTmpPath);
$restoreCommandBase64 = base64_encode($restoreCommand);
$this->importCommands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$scriptPath}";
$this->importCommands[] = "chmod +x {$scriptPath}";
$this->importCommands[] = "docker cp {$scriptPath} {$this->container}:{$scriptPath}";
$commands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$scriptPath}";
$commands[] = "chmod +x {$scriptPath}";
$commands[] = "docker cp {$scriptPath} {$this->container}:{$scriptPath}";
$commands[] = "docker exec {$this->container} sh -c '{$scriptPath}'";
$commands[] = "docker exec {$this->container} sh -c 'echo \"Import finished with exit code $?\"'";
$this->importCommands[] = "docker exec {$this->container} sh -c '{$scriptPath}'";
$this->importCommands[] = "docker exec {$this->container} sh -c 'echo \"Import finished with exit code $?\"'";
// Execute all commands with cleanup event
$activity = remote_process($commands, $this->server, ignore_errors: true, callEventOnFinish: 'S3RestoreJobFinished', callEventData: [
'containerName' => $containerName,
'serverTmpPath' => $serverTmpPath,
'scriptPath' => $scriptPath,
'containerTmpPath' => $containerTmpPath,
'container' => $this->container,
'serverId' => $this->server->id,
]);
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);
}
// Dispatch activity to the monitor and open slide-over
$this->dispatch('activityMonitor', $activity->id);
$this->dispatch('databaserestore');
$this->dispatch('info', 'Restoring database from S3. This may take a few minutes for large backups...');
} catch (\Throwable $e) {
$this->importRunning = false;
return handleError($e, $this);
} finally {
$this->importCommands = [];
}
}
public function cancelS3Download()
public function buildRestoreCommand(string $tmpPath): string
{
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);
}
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;
default:
$restoreCommand = '';
}
// Reset S3 download state
$this->s3DownloadedFile = null;
$this->s3DownloadInProgress = false;
$this->filename = null;
return $restoreCommand;
}
}