mirror of
https://github.com/BookStackApp/BookStack.git
synced 2024-10-01 01:36:00 -04:00
Progressed restore command to almost working state
This commit is contained in:
parent
74b5fadf60
commit
0c14f22831
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
if ($contents['env']['exists']) {
|
||||
$output->writeln("<info>Restoring and merging .env file...</info>");
|
||||
$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("<info>Restoring {$folderSubPath} folder...</info>");
|
||||
$this->restoreFolder($folderSubPath, $appDir, $extractDir);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO - Restore folders from backup
|
||||
if ($contents['db']['exists']) {
|
||||
$output->writeln("<info>Restoring database from SQL dump...</info>");
|
||||
$this->restoreDatabase($appDir, $extractDir);
|
||||
|
||||
// TODO - Restore database from backup
|
||||
|
||||
$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';
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
@ -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',
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
120
scripts/Services/MySqlRunner.php
Normal file
120
scripts/Services/MySqlRunner.php
Normal file
@ -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);
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user