From d291d85311df4480c2b5fe8e4b0600c93ca59c9c Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 3 Nov 2025 13:02:14 +0100 Subject: [PATCH] feat: add RestoreDatabase command for PostgreSQL dump restoration --- .../Commands/Cloud/RestoreDatabase.php | 219 ++++++++++++++++++ 1 file changed, 219 insertions(+) create mode 100644 app/Console/Commands/Cloud/RestoreDatabase.php diff --git a/app/Console/Commands/Cloud/RestoreDatabase.php b/app/Console/Commands/Cloud/RestoreDatabase.php new file mode 100644 index 000000000..6c60d1c6c --- /dev/null +++ b/app/Console/Commands/Cloud/RestoreDatabase.php @@ -0,0 +1,219 @@ +debug = $this->option('debug'); + + if (! $this->isDevelopment()) { + $this->error('This command can only be run in development mode.'); + + return 1; + } + + $filePath = $this->argument('file'); + + if (! file_exists($filePath)) { + $this->error("File not found: {$filePath}"); + + return 1; + } + + if (! is_readable($filePath)) { + $this->error("File is not readable: {$filePath}"); + + return 1; + } + + try { + $this->info('Starting database restoration...'); + + $database = config('database.connections.pgsql.database'); + $host = config('database.connections.pgsql.host'); + $port = config('database.connections.pgsql.port'); + $username = config('database.connections.pgsql.username'); + $password = config('database.connections.pgsql.password'); + + if (! $database || ! $username) { + $this->error('Database configuration is incomplete.'); + + return 1; + } + + $this->info("Restoring to database: {$database}"); + + // Drop all tables + if (! $this->dropAllTables($database, $host, $port, $username, $password)) { + return 1; + } + + // Restore the database dump + if (! $this->restoreDatabaseDump($filePath, $database, $host, $port, $username, $password)) { + return 1; + } + + $this->info('Database restoration completed successfully!'); + + return 0; + } catch (\Exception $e) { + $this->error("An error occurred: {$e->getMessage()}"); + + return 1; + } + } + + private function dropAllTables(string $database, string $host, string $port, string $username, string $password): bool + { + $this->info('Dropping all tables...'); + + // SQL to drop all tables + $dropTablesSQL = <<<'SQL' + DO $$ DECLARE + r RECORD; + BEGIN + FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = 'public') LOOP + EXECUTE 'DROP TABLE IF EXISTS ' || quote_ident(r.tablename) || ' CASCADE'; + END LOOP; + END $$; + SQL; + + // Build the psql command to drop all tables + $command = sprintf( + 'PGPASSWORD=%s psql -h %s -p %s -U %s -d %s -c %s', + escapeshellarg($password), + escapeshellarg($host), + escapeshellarg($port), + escapeshellarg($username), + escapeshellarg($database), + escapeshellarg($dropTablesSQL) + ); + + if ($this->debug) { + $this->line('Executing drop command:'); + $this->line($command); + } + + $output = shell_exec($command.' 2>&1'); + + if ($this->debug) { + $this->line("Output: {$output}"); + } + + $this->info('All tables dropped successfully.'); + + return true; + } + + private function restoreDatabaseDump(string $filePath, string $database, string $host, string $port, string $username, string $password): bool + { + $this->info('Restoring database from dump file...'); + + // Handle gzipped files by decompressing first + $actualFile = $filePath; + if (str_ends_with($filePath, '.gz')) { + $actualFile = rtrim($filePath, '.gz'); + $this->info('Decompressing gzipped dump file...'); + + $decompressCommand = sprintf( + 'gunzip -c %s > %s', + escapeshellarg($filePath), + escapeshellarg($actualFile) + ); + + if ($this->debug) { + $this->line('Executing decompress command:'); + $this->line($decompressCommand); + } + + $decompressOutput = shell_exec($decompressCommand.' 2>&1'); + if ($this->debug && $decompressOutput) { + $this->line("Decompress output: {$decompressOutput}"); + } + } + + // Use pg_restore for custom format dumps + $command = sprintf( + 'PGPASSWORD=%s pg_restore -h %s -p %s -U %s -d %s -v %s', + escapeshellarg($password), + escapeshellarg($host), + escapeshellarg($port), + escapeshellarg($username), + escapeshellarg($database), + escapeshellarg($actualFile) + ); + + if ($this->debug) { + $this->line('Executing restore command:'); + $this->line($command); + } + + // Execute the restore command + $process = proc_open( + $command, + [ + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ], + $pipes + ); + + if (! is_resource($process)) { + $this->error('Failed to start restoration process.'); + + return false; + } + + $output = stream_get_contents($pipes[1]); + $error = stream_get_contents($pipes[2]); + $exitCode = proc_close($process); + + // Clean up decompressed file if we created one + if ($actualFile !== $filePath && file_exists($actualFile)) { + unlink($actualFile); + } + + if ($this->debug) { + if ($output) { + $this->line('Output:'); + $this->line($output); + } + if ($error) { + $this->line('Error output:'); + $this->line($error); + } + $this->line("Exit code: {$exitCode}"); + } + + if ($exitCode !== 0) { + $this->error("Restoration failed with exit code: {$exitCode}"); + if ($error) { + $this->error('Error details:'); + $this->error($error); + } + + return false; + } + + if ($output && ! $this->debug) { + $this->line($output); + } + + return true; + } + + private function isDevelopment(): bool + { + return app()->environment(['local', 'development', 'dev']); + } +}