]> BookStack Code Mirror - system-cli/commitdiff
Progressed restore command to almost working state
authorDan Brown <redacted>
Tue, 7 Mar 2023 18:10:44 +0000 (18:10 +0000)
committerDan Brown <redacted>
Thu, 9 Mar 2023 15:28:14 +0000 (15:28 +0000)
scripts/Commands/BackupCommand.php
scripts/Commands/RestoreCommand.php
scripts/Services/BackupZip.php
scripts/Services/InteractiveConsole.php
scripts/Services/MySqlRunner.php [new file with mode: 0644]

index 727e0200c3ef928e830ed8f9567743497833c022..7e47d4ad1b4366b02e69960801da2d0501c3f60b 100644 (file)
@@ -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'] ?? ''),
-        ];
-
-        $port = $envOptions['DB_PORT'] ?? '';
-        if ($port) {
-            $dbOptions['host'] .= ':' . $port;
-        }
+        $mysql = MySqlRunner::fromEnvOptions($envOptions);
+        $mysql->ensureOptionsSet();
 
-        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;
     }
 }
index c181ff84eb40c259812e9751a312d282a044e6e8..6c2b7deb552774ee90df4b18946b63ec68fa974c 100644 (file)
@@ -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("<info>Checking system requirements...</info>");
         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("\n<info>Contents found in the backup ZIP:</info>");
@@ -60,7 +66,6 @@ class RestoreCommand extends Command
 
         $output->writeln("<info>The checked elements will be restored into [{$appDir}].</info>");
         $output->writeln("<info>Existing content may be overwritten.</info>");
-        $output->writeln("<info>Do you want to continue?</info>");
 
         if (!$interactions->confirm("Do you want to continue?")) {
             $output->writeln("<info>Stopping restore operation.</info>");
@@ -74,21 +79,29 @@ class RestoreCommand extends Command
         }
         $zip->extractInto($extractDir);
 
-        // TODO - Cleanup temp extract dir
-
-        // TODO - Environment handling
-        //  - Restore of old .env
-        //  - Prompt for correct DB details (Test before serving?)
-        //  - Prompt for correct URL (Allow entry of new?)
+        if ($contents['env']['exists']) {
+            $output->writeln("<info>Restoring and merging .env file...</info>");
+            $this->restoreEnv($extractDir, $appDir);
+        }
 
-        // TODO - Restore folders from backup
+        $folderLocations = ['themes', 'public/uploads', 'storage/uploads'];
+        foreach ($folderLocations as $folderSubPath) {
+            if ($contents[$folderSubPath]['exists']) {
+                $output->writeln("<info>Restoring {$folderSubPath} folder...</info>");
+                $this->restoreFolder($folderSubPath, $appDir, $extractDir);
+            }
+        }
 
-        // TODO - Restore database from backup
+        if ($contents['db']['exists']) {
+            $output->writeln("<info>Restoring database from SQL dump...</info>");
+            $this->restoreDatabase($appDir, $extractDir);
 
-        $output->writeln("<info>Running database migrations...</info>");
-        $artisan = (new ArtisanRunner($appDir));
-        $artisan->run(['migrate', '--force']);
+            $output->writeln("<info>Running database migrations...</info>");
+            $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("<info>Cleaning up extract directory...</info>");
+        $this->deleteDirectoryAndContents($extractDir);
+
+        $output->writeln("<info>\nRestore operation complete!</info>");
+
         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';
+
+        $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");
+            }
 
-        // 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.
+            // 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);
     }
 }
index ae82f650380bdd6220a1ff211360b73fed6f605a..798e2447714f010f156319f7846a8cef9e6b035a 100644 (file)
@@ -18,6 +18,9 @@ class BackupZip
         }
     }
 
+    /**
+     * @return array<string, array{desc: string, exists: bool}>
+     */
     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',
index 0cc4186fe6aef1bed4db0052a247e6715542ad78..8d8f92626035a6cf884ab550fe68a8a46c6a8aab 100644 (file)
@@ -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 (file)
index 0000000..1b1091c
--- /dev/null
@@ -0,0 +1,120 @@
+<?php
+
+namespace Cli\Services;
+
+use Exception;
+
+class MySqlRunner
+{
+    public function __construct(
+        protected string $host,
+        protected string $user,
+        protected string $password,
+        protected string $database,
+        protected int $port = 3306
+    ) {
+    }
+
+    /**
+     * @throws Exception
+     */
+    public function ensureOptionsSet(): void
+    {
+        $options = ['host', 'user', 'password', 'database'];
+        foreach ($options as $option) {
+            if (!$this->$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