Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add options config for backup/restore commands #14586

Closed
wants to merge 6 commits into from
Closed
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
16 changes: 8 additions & 8 deletions src/config/GeneralConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -419,7 +419,7 @@ class GeneralConfig extends BaseConfig
public bool $backupOnUpdate = true;

/**
* @var string|null|false The shell command that Craft should execute to create a database backup.
* @var string|null|false|array The shell command that Craft should execute to create a database backup.
*
* When set to `null` (default), Craft will run `mysqldump` or `pg_dump`, provided that those libraries are in the `$PATH` variable
* for the system user running the web server.
Expand Down Expand Up @@ -447,7 +447,7 @@ class GeneralConfig extends BaseConfig
*
* @group Environment
*/
public string|null|false $backupCommand = null;
public string|null|false|array $backupCommand = null;

/**
* @var string|null The base URL Craft should use when generating control panel URLs.
Expand Down Expand Up @@ -2412,7 +2412,7 @@ class GeneralConfig extends BaseConfig
public string $resourceBaseUrl = '@web/cpresources';

/**
* @var string|null|false The shell command Craft should execute to restore a database backup.
* @var string|null|false|array The shell command Craft should execute to restore a database backup.
*
* By default Craft will run `mysql` or `psql`, provided those libraries are in the `$PATH` variable for the user the web server is running as.
*
Expand All @@ -2438,7 +2438,7 @@ class GeneralConfig extends BaseConfig
*
* @group Environment
*/
public string|null|false $restoreCommand = null;
public string|null|false|array $restoreCommand = null;

/**
* @var bool Whether asset URLs should be revved so browsers don’t load cached versions when they’re modified.
Expand Down Expand Up @@ -3531,12 +3531,12 @@ public function backupOnUpdate(bool $value = true): self
* ```
*
* @group Environment
* @param string|null|false $value
* @param string|null|false|array $value
* @return self
* @see $backupCommand
* @since 4.2.0
*/
public function backupCommand(string|null|false $value): self
public function backupCommand(string|null|false|array $value): self
{
$this->backupCommand = $value;
return $this;
Expand Down Expand Up @@ -5820,12 +5820,12 @@ public function resourceBaseUrl(string $value): self
* ```
*
* @group Environment
* @param string|null|false $value
* @param string|null|false|array $value
* @return self
* @see $restoreCommand
* @since 4.2.0
*/
public function restoreCommand(string|null|false $value): self
public function restoreCommand(string|null|false|array $value): self
{
$this->restoreCommand = $value;
return $this;
Expand Down
22 changes: 20 additions & 2 deletions src/db/Connection.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,13 @@

use Composer\Util\Platform;
use Craft;
use craft\db\mysql\BackupCommand as MysqlBackupCommand;
use craft\db\mysql\QueryBuilder as MysqlQueryBuilder;
use craft\db\mysql\RestoreCommand as MysqlRestoreCommand;
use craft\db\mysql\Schema as MysqlSchema;
use craft\db\pgsql\BackupCommand as PgsqlBackupCommand;
use craft\db\pgsql\QueryBuilder as PgsqlQueryBuilder;
use craft\db\pgsql\RestoreCommand as PgsqlRestoreCommand;
use craft\db\pgsql\Schema as PgsqlSchema;
use craft\errors\DbConnectException;
use craft\errors\ShellCommandException;
Expand Down Expand Up @@ -271,7 +275,14 @@ public function backupTo(string $filePath): void
// Determine the command that should be executed
$backupCommand = Craft::$app->getConfig()->getGeneral()->backupCommand;

if ($backupCommand === null) {
if ($backupCommand === null || is_array($backupCommand)) {
/** @var PgsqlSchema|MysqlSchema $schema */
$schema = $this->getSchema();
$schema->backupCommand = $this->getIsPgsql()
? new PgsqlBackupCommand($backupCommand ?? [])
: new MysqlBackupCommand($backupCommand ?? []);
;

$backupCommand = $this->getSchema()->getDefaultBackupCommand($event->ignoreTables);
}

Expand Down Expand Up @@ -337,7 +348,14 @@ public function restore(string $filePath): void
// Determine the command that should be executed
$restoreCommand = Craft::$app->getConfig()->getGeneral()->restoreCommand;

if ($restoreCommand === null) {
if ($restoreCommand === null || is_array($restoreCommand)) {
/** @var PgsqlSchema|MysqlSchema $schema */
$schema = $this->getSchema();
$schema->restoreCommand = $this->getIsPgsql()
? new PgsqlRestoreCommand($restoreCommand ?? [])
: new MysqlRestoreCommand($restoreCommand ?? []);
;

$restoreCommand = $this->getSchema()->getDefaultRestoreCommand();
}

Expand Down
103 changes: 103 additions & 0 deletions src/db/DbShellCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<?php

namespace craft\db;

use Closure;
use Composer\Util\Platform;
use Craft;
use craft\base\Component;
use craft\helpers\Db;
use craft\helpers\FileHelper;
use craft\helpers\StringHelper;
use mikehaertl\shellcommand\Command as ShellCommand;
use PDO;
use yii\base\ErrorException;
use yii\base\Exception;

/**
* @property-read string $execCommand
* @property-read ShellCommand $command
*/
abstract class DbShellCommand extends Component
{
public ?Closure $callback = null;

protected function getCommand(): ShellCommand
{
$shellCommand = new ShellCommand();
$shellCommand->escapeArgs = false;

return $shellCommand;
}

public function getExecCommand(): string
{
return $this->getCommand()->getExecCommand();
}

/**
* Creates a temporary my.cnf file based on the DB config settings.
*
* @return string The path to the my.cnf file
* @throws ErrorException
*/
protected function createDumpConfigFile(): string
timkelty marked this conversation as resolved.
Show resolved Hide resolved
{
if (!Craft::$app->getDb()->getIsMysql()) {
throw new Exception('This method is only applicable to MySQL.');
}

$db = Craft::$app->getDb();

// Set on the schema for later cleanup
$tempMyCnfPath
= $db->getSchema()->tempMyCnfPath
= FileHelper::normalizePath(sys_get_temp_dir()) . DIRECTORY_SEPARATOR . StringHelper::randomString(12) . '.cnf';

$parsed = Db::parseDsn($db->dsn);
$username = $db->getIsPgsql() && !empty($parsed['user']) ? $parsed['user'] : $db->username;
$password = $db->getIsPgsql() && !empty($parsed['password']) ? $parsed['password'] : $db->password;
$contents = '[client]' . PHP_EOL .
'user=' . $username . PHP_EOL .
'password="' . addslashes($password) . '"';

if (isset($parsed['unix_socket'])) {
$contents .= PHP_EOL . 'socket=' . $parsed['unix_socket'];
} else {
$contents .= PHP_EOL . 'host=' . ($parsed['host'] ?? '') .
PHP_EOL . 'port=' . ($parsed['port'] ?? '');
}

// Certificates
if (isset($db->attributes[PDO::MYSQL_ATTR_SSL_CA])) {
$contents .= PHP_EOL . 'ssl_ca=' . $db->attributes[PDO::MYSQL_ATTR_SSL_CA];
}
if (isset($db->attributes[PDO::MYSQL_ATTR_SSL_CERT])) {
$contents .= PHP_EOL . 'ssl_cert=' . $db->attributes[PDO::MYSQL_ATTR_SSL_CERT];
}
if (isset($db->attributes[PDO::MYSQL_ATTR_SSL_KEY])) {
$contents .= PHP_EOL . 'ssl_key=' . $db->attributes[PDO::MYSQL_ATTR_SSL_KEY];
}

FileHelper::writeToFile($tempMyCnfPath, '');
// Avoid a “world-writable config file 'my.cnf' is ignored” warning
chmod($tempMyCnfPath, 0600);
FileHelper::writeToFile($tempMyCnfPath, $contents, ['append']);

return $tempMyCnfPath;
}

/**
* Returns the PGPASSWORD command for backup/restore actions.
*
* @return string
*/
protected function pgPasswordCommand(): string
{
if (!Craft::$app->getDb()->getIsPgsql()) {
throw new Exception('This method is only applicable to PostgreSQL.');
}

return Platform::isWindows() ? 'set PGPASSWORD="{password}" && ' : 'PGPASSWORD="{password}" ';
}
}
98 changes: 98 additions & 0 deletions src/db/mysql/BackupCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<?php

namespace craft\db\mysql;

use Composer\Util\Platform;
use Craft;
use craft\db\DbShellCommand;
use craft\helpers\App;
use mikehaertl\shellcommand\Command as ShellCommand;

class BackupCommand extends DbShellCommand
{
public ?array $ignoreTables = null;

protected function getCommand(): ShellCommand
{
$serverVersion = App::normalizeVersion(Craft::$app->getDb()->getServerVersion());
$isMySQL5 = version_compare($serverVersion, '8', '<');
$isMySQL8 = version_compare($serverVersion, '8', '>=');

// https://bugs.mysql.com/bug.php?id=109685
$useSingleTransaction =
($isMySQL5 && version_compare($serverVersion, '5.7.41', '>=')) ||
($isMySQL8 && version_compare($serverVersion, '8.0.32', '>='));

$command = parent::getCommand();
$command->setCommand('mysqldump');
$command->addArg('--defaults-file=', $this->createDumpConfigFile());
$command->addArg('--add-drop-table');
$command->addArg('--comments');
$command->addArg('--create-options');
$command->addArg('--dump-date');
$command->addArg('--no-autocommit');
$command->addArg('--routines');
$command->addArg('--default-character-set=', Craft::$app->getConfig()->getDb()->charset);
$command->addArg('--set-charset');
$command->addArg('--triggers');
$command->addArg('--no-tablespaces');

if ($useSingleTransaction) {
$command->addArg('--single-transaction');
}

// if there was output, then column-statistics is supported and we should disable it
if ($this->supportsColumnStatistics()) {
$command->addArg('--column-statistics=', '0');
}

return $this->callback
? ($this->callback)($command)
: $command;
}

public function getExecCommand(): string
{
$schemaDump = (clone $this->getCommand())
->addArg('--no-data')
->addArg('--result-file=', '{file}')
->addArg('{database}')
->getExecCommand();

$dataDump = (clone $this->getCommand())
->addArg('--no-create-info');

foreach ($this->ignoreTables as $table) {
$table = Craft::$app->getDb()->getSchema()->getRawTableName($table);
$dataDump->addArg('--ignore-table=', "{database}.$table");
}

$dataDump = $dataDump
->addArg('{database}')
->getExecCommand();

return "$schemaDump && $dataDump >> {file}";
}

protected function supportsColumnStatistics(): bool
{
// Find out if the db/dump client supports column-statistics
$shellCommand = new ShellCommand();

if (Platform::isWindows()) {
$shellCommand->setCommand('mysqldump --help | findstr "column-statistics"');
} else {
$shellCommand->setCommand('mysqldump --help | grep "column-statistics"');
}

// If we don't have proc_open, maybe we've got exec
if (!function_exists('proc_open') && function_exists('exec')) {
$shellCommand->useExec = true;
}

$success = $shellCommand->execute();

// if there was output, then column-statistics is supported
return $success && $shellCommand->getOutput();
}
}
26 changes: 26 additions & 0 deletions src/db/mysql/RestoreCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

namespace craft\db\mysql;

use craft\db\DbShellCommand;
use mikehaertl\shellcommand\Command as ShellCommand;

class RestoreCommand extends DbShellCommand
{
protected function getCommand(): ShellCommand
{
$command = parent::getCommand();
$command->setCommand('mysql');
$command->addArg('--defaults-file=', $this->createDumpConfigFile());
$command->addArg('{database}');

return $this->callback
? ($this->callback)($command)
: $command;
}

public function getExecCommand(): string
{
return $this->getCommand()->getExecCommand() . ' < "{file}"';
}
}