fix(database): skip MySQL system databases when restoring all databases

An all-databases MySQL/MariaDB restore also restored the mysql system
database, so after the next restart the root and app passwords were the
source ones and the MariaDB health check failed. The restore now skips mysql,
sys, performance_schema and information_schema by default. The new option
"Restore users and privileges" (restore_mysql_users in the API) keeps the
old behaviour.
This commit is contained in:
Andras Bacsai
2026-09-30 18:59:49 +02:00
parent 93507f7699
commit 8b007edceb
13 changed files with 325 additions and 29 deletions
@@ -11,7 +11,7 @@ use Illuminate\Filesystem\Filesystem;
* These tests execute the generated scripts with `sh` against stub database clients.
* Every stub appends "name|arguments|first 5 bytes of stdin" to a log, so the tests
* prove which clients ran, in which order, and that nothing runs before a backup
* format is rejected.
* format is rejected. The full stdin of every call is kept as well.
*/
function restoreScriptTempDir(): string
{
@@ -142,6 +142,8 @@ function restoreScriptFixture(string $name): string
'mysql-tar' => restoreScriptTar(['backup.sql' => $mysqlSql]),
'mysql-tar-two' => restoreScriptTar(['app.sql' => $mysqlSql, 'other.sql' => $mysqlSql]),
'mysql-cluster' => "-- MySQL dump 10.13\n\nCREATE DATABASE `app`;\nUSE `app`;\nCREATE TABLE `items` (`id` int);\nUSE `mysql`;\nINSERT INTO `user` VALUES ();\n",
'mysql-all-databases' => mysqlAllDatabasesDump(),
'mysql-all-databases-gz' => gzencode(mysqlAllDatabasesDump()),
'mysql-two-databases' => "-- MySQL dump 10.13\n\nCREATE DATABASE `app`;\nUSE `app`;\nCREATE TABLE `items` (`id` int);\nCREATE DATABASE `other`;\nUSE `other`;\nCREATE TABLE `notes` (`t` text);\n",
'mysql-one-database' => "-- MySQL dump 10.13\n\nCREATE DATABASE `app`;\nUSE `app`;\nCREATE TABLE `items` (`id` int);\n",
'mongo-archive' => $mongoArchive,
@@ -154,6 +156,67 @@ function restoreScriptFixture(string $name): string
};
}
/**
* An all-databases dump in the layout of mysqldump and mariadb-dump: application databases
* around the mysql and sys system databases, which hold the users and their passwords.
*/
function mysqlAllDatabasesDump(): string
{
return <<<'SQL'
-- MariaDB dump 10.19
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
--
-- Current Database: `app`
--
CREATE DATABASE /*!32312 IF NOT EXISTS*/ `app` /*!40100 DEFAULT CHARACTER SET utf8mb4 */;
USE `app`;
CREATE TABLE `items` (`id` int);
INSERT INTO `items` VALUES (1),(2);
--
-- Current Database: `mysql`
--
CREATE DATABASE /*!32312 IF NOT EXISTS*/ `mysql` /*!40100 DEFAULT CHARACTER SET utf8mb4 */;
USE `mysql`;
DROP TABLE IF EXISTS `global_priv`;
INSERT INTO `global_priv` VALUES ('localhost','root','{"authentication_string":"*SOURCE"}');
INSERT INTO `user` VALUES ('%','app_user','*SOURCE');
DELIMITER ;;
CREATE PROCEDURE `system_proc`() BEGIN SELECT 1; END ;;
DELIMITER ;
--
-- Current Database: `shop`
--
CREATE DATABASE /*!32312 IF NOT EXISTS*/ `shop` /*!40100 DEFAULT CHARACTER SET utf8mb4 */;
USE `shop`;
INSERT INTO `orders` VALUES (7);
--
-- Current Database: `sys`
--
CREATE DATABASE /*!32312 IF NOT EXISTS*/ `sys`;
USE `sys`;
INSERT INTO `sys_config` VALUES ('source');
USE performance_schema;
INSERT INTO `setup_actors` VALUES ('source');
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
-- Dump completed
SQL;
}
function restoreScriptResource(string $engine): object
{
$class = match ($engine) {
@@ -170,9 +233,9 @@ function restoreScriptResource(string $engine): object
}
/**
* @return array{exit: int, stdout: string, stderr: string, calls: list<array{name: string, args: string, header: string}>, dir: string, backup: string, leftovers: list<string>}
* @return array{exit: int, stdout: string, stderr: string, calls: list<array{name: string, args: string, header: string, stdin: string}>, dir: string, backup: string, leftovers: list<string>}
*/
function restoreScriptRun(string $engine, string $contents, bool $dumpAll = false, bool $replaceExisting = false, bool $keepOwners = false): array
function restoreScriptRun(string $engine, string $contents, bool $dumpAll = false, bool $replaceExisting = false, bool $keepOwners = false, bool $restoreMysqlUsers = false): array
{
$dir = restoreScriptTempDir();
@@ -180,15 +243,18 @@ function restoreScriptRun(string $engine, string $contents, bool $dumpAll = fals
$log = $dir.'/calls.log';
file_put_contents($log, '');
mkdir($dir.'/bin');
mkdir($dir.'/stdin');
foreach (['pg_restore', 'psql', 'dropdb', 'createdb', 'mysql', 'mariadb', 'mongorestore'] as $client) {
$escapedLog = escapeshellarg($log);
$escapedStdin = escapeshellarg($dir.'/stdin');
file_put_contents($dir.'/bin/'.$client, <<<SH
#!/bin/sh
header=''
call=\$(wc -l < {$escapedLog} | tr -d ' ')
if [ ! -t 0 ]; then
header=\$(head -c 5)
cat >/dev/null
cat > {$escapedStdin}/\$call
header=\$(head -c 5 {$escapedStdin}/\$call)
fi
printf '%s|%s|%s\\n' '{$client}' "\$*" "\$header" >> {$escapedLog}
exit 0
@@ -199,7 +265,7 @@ SH);
$backup = $dir.'/backup file';
file_put_contents($backup, $contents);
$script = (new DatabaseImportCommandBuilder)->buildRestoreCommand(restoreScriptResource($engine), $backup, $dumpAll, $replaceExisting, $keepOwners);
$script = (new DatabaseImportCommandBuilder)->buildRestoreCommand(restoreScriptResource($engine), $backup, $dumpAll, $replaceExisting, $keepOwners, restoreMysqlUsers: $restoreMysqlUsers);
$result = restoreScriptProcess(['sh', '-c', $script], $dir, [
'PATH' => $dir.'/bin'.PATH_SEPARATOR.(getenv('PATH') ?: '/usr/bin:/bin'),
@@ -219,11 +285,12 @@ SH);
]);
$lines = array_values(array_filter(explode("\n", (string) file_get_contents($log)), fn (string $line): bool => $line !== ''));
$calls = array_map(function (string $line): array {
$calls = array_map(function (string $line, int $index) use ($dir): array {
[$name, $args, $header] = array_pad(explode('|', $line, 3), 3, '');
$stdin = $dir.'/stdin/'.$index;
return ['name' => $name, 'args' => $args, 'header' => $header];
}, $lines);
return ['name' => $name, 'args' => $args, 'header' => $header, 'stdin' => is_file($stdin) ? (string) file_get_contents($stdin) : ''];
}, $lines, array_keys($lines));
return $result + [
'calls' => $calls,
@@ -392,6 +459,65 @@ test('restores MySQL and MariaDB backups containing all databases after checking
'SQL that uses the mysql schema' => 'mysql-cluster',
]);
test('skips the MySQL and MariaDB system databases of an all-databases backup by default', function (string $engine, string $fixture) {
$run = restoreScriptRun($engine, restoreScriptFixture($fixture), dumpAll: true);
expect($run['exit'])->toBe(0, $run['stderr']);
$restore = end($run['calls']);
expect($restore['args'])->toBe('-u root -proot_pass app')
->and($restore['stdin'])
->toContain('INSERT INTO `items` VALUES (1),(2);')
->toContain('CREATE DATABASE /*!32312 IF NOT EXISTS*/ `app`')
->toContain('USE `shop`;')
->toContain('INSERT INTO `orders` VALUES (7);')
->toContain('/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;')
->not->toContain('global_priv')
->not->toContain('app_user')
->not->toContain('*SOURCE')
->not->toContain('system_proc')
->not->toContain('`mysql`')
->not->toContain('`sys`')
->not->toContain('sys_config')
->not->toContain('setup_actors');
})->with(['mysql', 'mariadb'])->with([
'SQL' => 'mysql-all-databases',
'gzip SQL' => 'mysql-all-databases-gz',
]);
test('restores the MySQL and MariaDB system databases of an all-databases backup when requested', function (string $engine) {
$run = restoreScriptRun($engine, restoreScriptFixture('mysql-all-databases-gz'), dumpAll: true, restoreMysqlUsers: true);
expect($run['exit'])->toBe(0, $run['stderr'])
->and(end($run['calls'])['stdin'])->toBe(mysqlAllDatabasesDump());
})->with(['mysql', 'mariadb']);
test('fails an all-databases MySQL restore before dropping anything when awk is missing', function () {
$dir = restoreScriptTempDir();
try {
mkdir($dir.'/bin');
foreach (['sh', 'head', 'od', 'tr', 'cat', 'gunzip', 'tail', 'wc', 'grep', 'mktemp', 'find'] as $tool) {
exec('command -v '.escapeshellarg($tool), $path);
if ($path !== []) {
symlink(end($path), $dir.'/bin/'.$tool);
}
$path = [];
}
file_put_contents($dir.'/bin/mysql', "#!/bin/sh\necho called >> ".escapeshellarg($dir.'/calls')."\n");
chmod($dir.'/bin/mysql', 0755);
file_put_contents($dir.'/backup', mysqlAllDatabasesDump());
$script = (new DatabaseImportCommandBuilder)->buildRestoreCommand(restoreScriptResource('mysql'), $dir.'/backup', true);
$result = restoreScriptProcess([$dir.'/bin/sh', '-c', $script], $dir, ['PATH' => $dir.'/bin', 'MYSQL_ROOT_PASSWORD' => 'root_pass']);
expect($result['exit'])->toBe(1)
->and($result['stderr'])->toContain('Nothing was changed.')
->and(file_exists($dir.'/calls'))->toBeFalse();
} finally {
restoreScriptRemoveDir($dir);
}
});
test('rejects unsupported MySQL and MariaDB backups before calling any client', function (string $engine, string $fixture, bool $dumpAll, string $message) {
$run = restoreScriptRun($engine, restoreScriptFixture($fixture), $dumpAll);