diff --git a/scripts/Commands/BackupCommand.php b/scripts/Commands/BackupCommand.php index a607f342a..d41b36d4d 100644 --- a/scripts/Commands/BackupCommand.php +++ b/scripts/Commands/BackupCommand.php @@ -2,12 +2,11 @@ namespace Cli\Commands; +use Cli\Services\ProgramRunner; use Minicli\Command\CommandCall; use RecursiveDirectoryIterator; use SplFileInfo; use Symfony\Component\Process\Exception\ProcessTimedOutException; -use Symfony\Component\Process\ExecutableFinder; -use Symfony\Component\Process\Process; use ZipArchive; final class BackupCommand @@ -36,6 +35,7 @@ final class BackupCommand // Create a new ZIP file $zipTempFile = tempnam(sys_get_temp_dir(), 'bsbackup'); + $dumpTempFile = ''; $zip = new ZipArchive(); $zip->open($zipTempFile, ZipArchive::CREATE); @@ -48,8 +48,6 @@ final class BackupCommand $dumpTempFile = $this->createDatabaseDump(); echo "Adding database dump to backup archive...\n"; $zip->addFile($dumpTempFile, 'db.sql'); - // Delete our temporary DB dump file - unlink($dumpTempFile); } if ($handleUploads) { @@ -64,6 +62,11 @@ final class BackupCommand // Close off our zip and move it to the required location $zip->close(); + // Delete our temporary DB dump file if exists. Must be done after zip close. + if ($dumpTempFile) { + unlink($dumpTempFile); + } + // Move the zip into the target location rename($zipTempFile, $zipOutFile); // Announce end @@ -161,48 +164,39 @@ final class BackupCommand } } - // Create a mysqldump for the BookStack database - $executableFinder = new ExecutableFinder(); - $mysqldumpPath = $executableFinder->find('mysqldump', '/usr/bin/mysqldump'); - - if (!is_file($mysqldumpPath)) { - throw new CommandError('Could not locate "mysqldump" program'); - } - - $process = new Process([ - $mysqldumpPath, - '-h', $dbOptions['host'], - '-u', $dbOptions['username'], - '-p' . $dbOptions['password'], - '--single-transaction', - '--no-tablespaces', - $dbOptions['database'], - ]); - $process->setTimeout(240); - $process->setIdleTimeout(5); - $process->start(); - $errors = ""; $hasOutput = false; - $dumpTempFile = tempnam(sys_get_temp_dir(), 'bsbackup'); + $dumpTempFile = tempnam(sys_get_temp_dir(), 'bsdbdump'); $dumpTempFileResource = fopen($dumpTempFile, 'w'); + try { - foreach ($process as $type => $data) { - if ($process::OUT === $type) { + (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; - } else { // $process::ERR === $type - $errors .= $data . "\n"; - } - } - } catch (ProcessTimedOutException $timedOutException) { + }, function ($error) use (&$errors) { + $errors .= $error . "\n"; + }); + } catch (\Exception $exception) { fclose($dumpTempFileResource); unlink($dumpTempFile); - 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."); + 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); diff --git a/scripts/Commands/InitCommand.php b/scripts/Commands/InitCommand.php index 1c943a21c..1041678ee 100644 --- a/scripts/Commands/InitCommand.php +++ b/scripts/Commands/InitCommand.php @@ -2,9 +2,8 @@ namespace Cli\Commands; +use Cli\Services\ProgramRunner; use Minicli\Command\CommandCall; -use Symfony\Component\Process\ExecutableFinder; -use Symfony\Component\Process\Process; class InitCommand { @@ -15,7 +14,6 @@ class InitCommand { $this->ensureRequiredExtensionInstalled(); // TODO - Ensure bookstack install deps are met? - // TODO - Dedupe the command stuff going on. // TODO - Check composer and git exists before running // TODO - Look at better way of handling env usage, on demand maybe where needed? // Env loading in main `run` script if confilicting with certain bits here (app key generate, hence APP_KEY overload) @@ -67,27 +65,14 @@ class InitCommand protected function generateAppKey(string $installDir): void { - // Find reference to php - $executableFinder = new ExecutableFinder(); - $phpPath = $executableFinder->find('php', '/usr/bin/php'); - if (!is_file($phpPath)) { - throw new CommandError('Could not locate "php" program.'); - } - - $process = new Process([ - $phpPath, - $installDir . DIRECTORY_SEPARATOR . 'artisan', - 'key:generate', '--force', '-n', '-q' - ], null, ['APP_KEY' => 'SomeRandomString']); - $process->setTimeout(240); - $process->setIdleTimeout(5); - $process->start(); - - $errors = ''; - foreach ($process as $type => $data) { - // Errors are on stdout for artisan - $errors .= $data . "\n"; - } + $errors = (new ProgramRunner('php', '/usr/bin/php')) + ->withTimeout(60) + ->withIdleTimeout(5) + ->withEnvironment(['APP_KEY' => 'SomeRandomString']) + ->runCapturingAllOutput([ + $installDir . DIRECTORY_SEPARATOR . 'artisan', + 'key:generate', '--force', '-n', '-q' + ]); if ($errors) { throw new CommandError("Failed 'php artisan key:generate' with errors:\n" . $errors); @@ -100,28 +85,14 @@ class InitCommand */ protected function installComposerDependencies(string $installDir): void { - // Find reference to composer - $executableFinder = new ExecutableFinder(); - $composerPath = $executableFinder->find('composer', '/usr/local/bin/composer'); - if (!is_file($composerPath)) { - throw new CommandError('Could not locate "composer" program.'); - } - - $process = new Process([ - $composerPath, 'install', - '--no-dev', '-n', '-q', '--no-progress', - '-d', $installDir - ]); - $process->setTimeout(240); - $process->setIdleTimeout(15); - $process->start(); - - $errors = ''; - foreach ($process as $type => $data) { - if ($process::ERR === $type) { - $errors .= $data . "\n"; - } - } + $errors = (new ProgramRunner('composer', '/usr/local/bin/composer')) + ->withTimeout(300) + ->withIdleTimeout(15) + ->runCapturingStdErr([ + 'install', + '--no-dev', '-n', '-q', '--no-progress', + '-d', $installDir + ]); if ($errors) { throw new CommandError("Failed composer install with errors:\n" . $errors); @@ -134,30 +105,16 @@ class InitCommand */ protected function cloneBookStackViaGit(string $installDir): void { - // Find reference to git - $executableFinder = new ExecutableFinder(); - $gitPath = $executableFinder->find('git', '/usr/bin/bit'); - if (!is_file($gitPath)) { - throw new CommandError('Could not locate "git" program.'); - } - - $process = new Process([ - $gitPath, 'clone', '-q', - '--branch', 'release', - '--single-branch', - 'https://github.com/BookStackApp/BookStack.git', - $installDir - ]); - $process->setTimeout(240); - $process->setIdleTimeout(15); - $process->start(); - - $errors = ''; - foreach ($process as $type => $data) { - if ($process::ERR === $type) { - $errors .= $data . "\n"; - } - } + $errors = (new ProgramRunner('git', '/usr/bin/git')) + ->withTimeout(240) + ->withIdleTimeout(15) + ->runCapturingStdErr([ + 'clone', '-q', + '--branch', 'release', + '--single-branch', + 'https://github.com/BookStackApp/BookStack.git', + $installDir + ]); if ($errors) { throw new CommandError("Failed git clone with errors:\n" . $errors); diff --git a/scripts/Services/ProgramRunner.php b/scripts/Services/ProgramRunner.php new file mode 100644 index 000000000..6f94f1e30 --- /dev/null +++ b/scripts/Services/ProgramRunner.php @@ -0,0 +1,91 @@ +timeout = $timeoutSeconds; + return $this; + } + + public function withIdleTimeout(int $idleTimeoutSeconds): static + { + $this->idleTimeout = $idleTimeoutSeconds; + return $this; + } + + public function withEnvironment(array $environment): static + { + $this->environment = $environment; + return $this; + } + + public function runCapturingAllOutput(array $args): string + { + $output = ''; + $callable = function ($data) use (&$output) { + $output .= $data . "\n"; + }; + + $this->runWithoutOutputCallbacks($args, $callable, $callable); + return $output; + } + + public function runCapturingStdErr(array $args): string + { + $err = ''; + $this->runWithoutOutputCallbacks($args, fn() => '', function ($data) use (&$err) { + $err .= $data . "\n"; + }); + return $err; + } + + public function runWithoutOutputCallbacks(array $args, callable $stdOutCallback, callable $stdErrCallback): void + { + $process = $this->startProcess($args); + foreach ($process as $type => $data) { + if ($type === $process::ERR) { + $stdErrCallback($data); + } else { + $stdOutCallback($data); + } + } + } + + protected function startProcess(array $args): Process + { + $programPath = $this->resolveProgramPath(); + $process = new Process([$programPath, ...$args], null, $this->environment); + $process->setTimeout($this->timeout); + $process->setIdleTimeout($this->idleTimeout); + $process->start(); + return $process; + } + + protected function resolveProgramPath(): string + { + $executableFinder = new ExecutableFinder(); + $path = $executableFinder->find($this->program, $this->defaultPath); + + if (is_null($path) || !is_file($path)) { + throw new \Exception("Could not locate \"{$this->program}\" program."); + } + + return $path; + } +} diff --git a/scripts/composer.json b/scripts/composer.json index 5bc28b411..b79df7763 100644 --- a/scripts/composer.json +++ b/scripts/composer.json @@ -6,7 +6,8 @@ }, "autoload": { "psr-4": { - "Cli\\Commands\\": "Commands/" + "Cli\\Commands\\": "Commands/", + "Cli\\Services\\": "Services/" } }, "config": { diff --git a/scripts/run b/scripts/run index 6f1d20567..fb7e23ce6 100644 --- a/scripts/run +++ b/scripts/run @@ -8,7 +8,6 @@ if (php_sapi_name() !== 'cli') { require __DIR__ . '/vendor/autoload.php'; use Cli\Commands\BackupCommand; -use Cli\Commands\CommandError; use Cli\Commands\InitCommand; use Minicli\App; @@ -33,7 +32,7 @@ $app->registerCommand('init', [new InitCommand(), 'handle']); try { $app->runCommand($argv); -} catch (CommandError $error) { +} catch (Exception $error) { fwrite(STDERR, "An error occurred when attempting to run a command:\n"); fwrite(STDERR, $error->getMessage() . "\n"); exit(1);