From 0c14f22831a7f5590533a7bde186431d765e168f Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 7 Mar 2023 18:10:44 +0000 Subject: [PATCH] Progressed restore command to almost working state --- scripts/Commands/BackupCommand.php | 60 ++---------- scripts/Commands/RestoreCommand.php | 108 +++++++++++++++++---- scripts/Services/BackupZip.php | 13 ++- scripts/Services/InteractiveConsole.php | 4 +- scripts/Services/MySqlRunner.php | 120 ++++++++++++++++++++++++ 5 files changed, 226 insertions(+), 79 deletions(-) create mode 100644 scripts/Services/MySqlRunner.php diff --git a/scripts/Commands/BackupCommand.php b/scripts/Commands/BackupCommand.php index 727e0200c..7e47d4ad1 100644 --- a/scripts/Commands/BackupCommand.php +++ b/scripts/Commands/BackupCommand.php @@ -4,14 +4,14 @@ namespace Cli\Commands; use Cli\Services\AppLocator; use Cli\Services\EnvironmentLoader; -use Cli\Services\ProgramRunner; +use Cli\Services\MySqlRunner; use RecursiveDirectoryIterator; use SplFileInfo; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Process\Exception\ProcessTimedOutException; use ZipArchive; final class BackupCommand extends Command @@ -24,6 +24,7 @@ final class BackupCommand extends Command $this->addOption('no-database', null, null, "Skip adding a database dump to the backup"); $this->addOption('no-uploads', null, null, "Skip adding uploaded files to the backup"); $this->addOption('no-themes', null, null, "Skip adding the themes folder to the backup"); + $this->addOption('app-directory', null, InputOption::VALUE_OPTIONAL, 'BookStack install directory to backup', ''); } /** @@ -159,66 +160,17 @@ final class BackupCommand extends Command protected function createDatabaseDump(string $appDir): string { $envOptions = EnvironmentLoader::loadMergedWithCurrentEnv($appDir); - $dbOptions = [ - 'host' => ($envOptions['DB_HOST'] ?? ''), - 'username' => ($envOptions['DB_USERNAME'] ?? ''), - 'password' => ($envOptions['DB_PASSWORD'] ?? ''), - 'database' => ($envOptions['DB_DATABASE'] ?? ''), - ]; + $mysql = MySqlRunner::fromEnvOptions($envOptions); + $mysql->ensureOptionsSet(); - $port = $envOptions['DB_PORT'] ?? ''; - if ($port) { - $dbOptions['host'] .= ':' . $port; - } - - foreach ($dbOptions as $name => $option) { - if (!$option) { - throw new CommandError("Could not find a value for the database {$name}"); - } - } - - $errors = ""; - $hasOutput = false; $dumpTempFile = tempnam(sys_get_temp_dir(), 'bsdbdump'); - $dumpTempFileResource = fopen($dumpTempFile, 'w'); - try { - (new ProgramRunner('mysqldump', '/usr/bin/mysqldump')) - ->withTimeout(240) - ->withIdleTimeout(15) - ->runWithoutOutputCallbacks([ - '-h', $dbOptions['host'], - '-u', $dbOptions['username'], - '-p' . $dbOptions['password'], - '--single-transaction', - '--no-tablespaces', - $dbOptions['database'], - ], function ($data) use (&$dumpTempFileResource, &$hasOutput) { - fwrite($dumpTempFileResource, $data); - $hasOutput = true; - }, function ($error) use (&$errors) { - $errors .= $error . "\n"; - }); + $mysql->runDumpToFile($dumpTempFile); } catch (\Exception $exception) { - fclose($dumpTempFileResource); unlink($dumpTempFile); - if ($exception instanceof ProcessTimedOutException) { - if (!$hasOutput) { - throw new CommandError("mysqldump operation timed-out.\nNo data has been received so the connection to your database may have failed."); - } else { - throw new CommandError("mysqldump operation timed-out after data was received."); - } - } throw new CommandError($exception->getMessage()); } - fclose($dumpTempFileResource); - - if ($errors) { - unlink($dumpTempFile); - throw new CommandError("Failed mysqldump with errors:\n" . $errors); - } - return $dumpTempFile; } } diff --git a/scripts/Commands/RestoreCommand.php b/scripts/Commands/RestoreCommand.php index c181ff84e..6c2b7deb5 100644 --- a/scripts/Commands/RestoreCommand.php +++ b/scripts/Commands/RestoreCommand.php @@ -7,7 +7,11 @@ use Cli\Services\ArtisanRunner; use Cli\Services\BackupZip; use Cli\Services\EnvironmentLoader; use Cli\Services\InteractiveConsole; +use Cli\Services\MySqlRunner; +use Cli\Services\ProgramRunner; use Cli\Services\RequirementsValidator; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; @@ -40,9 +44,11 @@ class RestoreCommand extends Command $appDir = AppLocator::require($input->getOption('app-directory')); $output->writeln("Checking system requirements..."); RequirementsValidator::validate(); + (new ProgramRunner('mysql', '/usr/bin/mysql'))->ensureFound(); $zipPath = realpath($input->getArgument('backup-zip')); $zip = new BackupZip($zipPath); + // TODO - Fix folders not being picked up here: $contents = $zip->getContentsOverview(); $output->writeln("\nContents found in the backup ZIP:"); @@ -60,7 +66,6 @@ class RestoreCommand extends Command $output->writeln("The checked elements will be restored into [{$appDir}]."); $output->writeln("Existing content may be overwritten."); - $output->writeln("Do you want to continue?"); if (!$interactions->confirm("Do you want to continue?")) { $output->writeln("Stopping restore operation."); @@ -74,21 +79,29 @@ class RestoreCommand extends Command } $zip->extractInto($extractDir); - // TODO - Cleanup temp extract dir + if ($contents['env']['exists']) { + $output->writeln("Restoring and merging .env file..."); + $this->restoreEnv($extractDir, $appDir); + } - // TODO - Environment handling - // - Restore of old .env - // - Prompt for correct DB details (Test before serving?) - // - Prompt for correct URL (Allow entry of new?) + $folderLocations = ['themes', 'public/uploads', 'storage/uploads']; + foreach ($folderLocations as $folderSubPath) { + if ($contents[$folderSubPath]['exists']) { + $output->writeln("Restoring {$folderSubPath} folder..."); + $this->restoreFolder($folderSubPath, $appDir, $extractDir); + } + } - // TODO - Restore folders from backup + if ($contents['db']['exists']) { + $output->writeln("Restoring database from SQL dump..."); + $this->restoreDatabase($appDir, $extractDir); - // TODO - Restore database from backup - - $output->writeln("Running database migrations..."); - $artisan = (new ArtisanRunner($appDir)); - $artisan->run(['migrate', '--force']); + $output->writeln("Running database migrations..."); + $artisan = (new ArtisanRunner($appDir)); + $artisan->run(['migrate', '--force']); + } + // TODO - Handle change of URL? // TODO - Update system URL (via BookStack artisan command) if // there's been a change from old backup env @@ -97,16 +110,75 @@ class RestoreCommand extends Command $artisan->run(['config:clear']); $artisan->run(['view:clear']); + $output->writeln("Cleaning up extract directory..."); + $this->deleteDirectoryAndContents($extractDir); + + $output->writeln("\nRestore operation complete!"); + return Command::SUCCESS; } - protected function restoreEnv(string $extractDir, string $appDir, InteractiveConsole $interactions) + protected function restoreEnv(string $extractDir, string $appDir) { - $extractEnv = EnvironmentLoader::load($extractDir); - $appEnv = EnvironmentLoader::load($appDir); // TODO - Probably pass in since we'll need the APP_URL later on. + $oldEnv = EnvironmentLoader::load($extractDir); + $currentEnv = EnvironmentLoader::load($appDir); + $envContents = file_get_contents($extractDir . DIRECTORY_SEPARATOR . '.env'); + $appEnvPath = $appDir . DIRECTORY_SEPARATOR . '.env'; - // TODO - Create mysql runner to take variables to a programrunner instance. - // Then test each, backup existing env, then replace env with old then overwrite - // db options if the new app env options are the valid ones. + $mysqlCurrent = MySqlRunner::fromEnvOptions($currentEnv); + $mysqlOld = MySqlRunner::fromEnvOptions($oldEnv); + if (!$mysqlOld->testConnection()) { + $currentWorking = $mysqlCurrent->testConnection(); + if (!$currentWorking) { + throw new CommandError("Could not find a working database configuration"); + } + + // Copy across new env details to old env + $currentEnvContents = file_get_contents($appEnvPath); + $currentEnvDbLines = array_values(array_filter(explode("\n", $currentEnvContents), function (string $line) { + return str_starts_with($line, 'DB_'); + })); + $oldEnvLines = array_values(array_filter(explode("\n", $currentEnvContents), function (string $line) { + return !str_starts_with($line, 'DB_'); + })); + $envContents = implode("\n", [ + '# Database credentials merged from existing .env file', + ...$currentEnvDbLines, + ...$oldEnvLines + ]); + copy($appEnvPath, $appEnvPath . '.backup'); + } + + file_put_contents($appDir . DIRECTORY_SEPARATOR . '.env', $envContents); + } + + protected function restoreFolder(string $folderSubPath, string $appDir, string $extractDir): void + { + $fullAppFolderPath = $appDir . DIRECTORY_SEPARATOR . $folderSubPath; + $this->deleteDirectoryAndContents($fullAppFolderPath); + rename($extractDir . DIRECTORY_SEPARATOR . $folderSubPath, $fullAppFolderPath); + } + + protected function deleteDirectoryAndContents(string $dir) + { + $files = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($files as $fileinfo) { + $path = $fileinfo->getRealPath(); + $fileinfo->isDir() ? rmdir($path) : unlink($path); + } + + rmdir($dir); + } + + protected function restoreDatabase(string $appDir, string $extractDir): void + { + $dbDump = $extractDir . DIRECTORY_SEPARATOR . 'db.sql'; + $currentEnv = EnvironmentLoader::load($appDir); + $mysql = MySqlRunner::fromEnvOptions($currentEnv); + $mysql->importSqlFile($dbDump); } } diff --git a/scripts/Services/BackupZip.php b/scripts/Services/BackupZip.php index ae82f6503..798e24477 100644 --- a/scripts/Services/BackupZip.php +++ b/scripts/Services/BackupZip.php @@ -18,6 +18,9 @@ class BackupZip } } + /** + * @return array + */ public function getContentsOverview(): array { return [ @@ -27,15 +30,15 @@ class BackupZip ], 'themes' => [ 'desc' => 'Themes Folder', - 'exists' => boolval($this->zip->statName('themes')), + 'exists' => $this->zip->locateName('/themes/') !== false, ], - 'public-uploads' => [ + 'public/uploads' => [ 'desc' => 'Public File Uploads', - 'exists' => boolval($this->zip->statName('public/uploads')), + 'exists' => $this->zip->locateName('/public/uploads/') !== false, ], - 'storage-uploads' => [ + 'storage/uploads' => [ 'desc' => 'Private File Uploads', - 'exists' => boolval($this->zip->statName('storage/uploads')), + 'exists' => $this->zip->locateName('/storage/uploads/') !== false, ], 'db' => [ 'desc' => 'Database Dump', diff --git a/scripts/Services/InteractiveConsole.php b/scripts/Services/InteractiveConsole.php index 0cc4186fe..8d8f92626 100644 --- a/scripts/Services/InteractiveConsole.php +++ b/scripts/Services/InteractiveConsole.php @@ -2,7 +2,7 @@ namespace Cli\Services; -use Illuminate\Console\QuestionHelper; +use Symfony\Component\Console\Helper\QuestionHelper; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Question\ConfirmationQuestion; @@ -21,7 +21,7 @@ class InteractiveConsole public function confirm(string $text): bool { - $question = new ConfirmationQuestion($text, false); + $question = new ConfirmationQuestion($text . " (y/n)\n", false); return $this->helper->ask($this->input, $this->output, $question); } } \ No newline at end of file diff --git a/scripts/Services/MySqlRunner.php b/scripts/Services/MySqlRunner.php new file mode 100644 index 000000000..1b1091c77 --- /dev/null +++ b/scripts/Services/MySqlRunner.php @@ -0,0 +1,120 @@ +$option) { + throw new Exception("Could not find a valid value for the \"{$option}\" database option."); + } + } + } + + public function testConnection(): bool + { + $output = (new ProgramRunner('mysql', '/usr/bin/mysql')) + ->withTimeout(240) + ->withIdleTimeout(5) + ->runCapturingStdErr([ + '-h', $this->host, + '-P', $this->port, + '-u', $this->user, + '-p' . $this->password, + $this->database, + '-e' . "'show tables;'" + ]); + + return !$output; + } + + public function importSqlFile(string $sqlFilePath): void + { + $output = (new ProgramRunner('mysql', '/usr/bin/mysql')) + ->withTimeout(240) + ->withIdleTimeout(5) + ->runCapturingStdErr([ + '-h', $this->host, + '-P', $this->port, + '-u', $this->user, + '-p' . $this->password, + $this->database, + '<', $sqlFilePath + ]); + + if ($output) { + throw new Exception("Failed mysql file import with errors:\n" . $output); + } + } + + public function runDumpToFile(string $filePath): void + { + $file = fopen($filePath, 'w'); + $errors = ""; + $hasOutput = false; + + try { + (new ProgramRunner('mysqldump', '/usr/bin/mysqldump')) + ->withTimeout(240) + ->withIdleTimeout(15) + ->runWithoutOutputCallbacks([ + '-h', $this->host, + '-P', $this->port, + '-u', $this->user, + '-p' . $this->password, + '--single-transaction', + '--no-tablespaces', + $this->database, + ], function ($data) use (&$file, &$hasOutput) { + fwrite($file, $data); + $hasOutput = true; + }, function ($error) use (&$errors) { + $errors .= $error . "\n"; + }); + } catch (\Exception $exception) { + fclose($file); + if ($exception instanceof ProcessTimedOutException) { + if (!$hasOutput) { + throw new Exception("mysqldump operation timed-out.\nNo data has been received so the connection to your database may have failed."); + } else { + throw new Exception("mysqldump operation timed-out after data was received."); + } + } + throw new Exception($exception->getMessage()); + } + + fclose($file); + + if ($errors) { + throw new Exception("Failed mysqldump with errors:\n" . $errors); + } + } + + public static function fromEnvOptions(array $env): static + { + $host = ($env['DB_HOST'] ?? ''); + $username = ($env['DB_USERNAME'] ?? ''); + $password = ($env['DB_PASSWORD'] ?? ''); + $database = ($env['DB_DATABASE'] ?? ''); + $port = intval($env['DB_PORT'] ?? 3306); + + return new static($host, $username, $password, $database, $port); + } +} \ No newline at end of file