mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-04 10:42:23 +00:00
890 lines
41 KiB
PHP
890 lines
41 KiB
PHP
<?php
|
|
|
|
use App\Models\StandaloneMariadb;
|
|
use App\Models\StandaloneMongodb;
|
|
use App\Models\StandaloneMysql;
|
|
use App\Models\StandalonePostgresql;
|
|
use App\Support\DatabaseImport\DatabaseImportCommandBuilder;
|
|
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. The full stdin of every call is kept as well.
|
|
*/
|
|
function restoreScriptTempDir(): string
|
|
{
|
|
$dir = sys_get_temp_dir().'/coolify-restore-script-'.bin2hex(random_bytes(8));
|
|
mkdir($dir);
|
|
|
|
return $dir;
|
|
}
|
|
|
|
function restoreScriptRemoveDir(string $dir): void
|
|
{
|
|
(new Filesystem)->deleteDirectory($dir);
|
|
}
|
|
|
|
function restoreScriptHasTool(string $tool): bool
|
|
{
|
|
exec('command -v '.escapeshellarg($tool).' >/dev/null 2>&1', $output, $exitCode);
|
|
|
|
return $exitCode === 0;
|
|
}
|
|
|
|
/**
|
|
* @param list<string> $command
|
|
* @param array<string, string> $environment
|
|
* @return array{exit: int, stdout: string, stderr: string}
|
|
*/
|
|
function restoreScriptProcess(array $command, string $cwd, array $environment = []): array
|
|
{
|
|
$process = proc_open(
|
|
$command,
|
|
[0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
|
|
$pipes,
|
|
$cwd,
|
|
$environment + ['PATH' => getenv('PATH') ?: '/usr/bin:/bin'],
|
|
);
|
|
|
|
expect($process)->not->toBeFalse();
|
|
|
|
fclose($pipes[0]);
|
|
$stdout = (string) stream_get_contents($pipes[1]);
|
|
$stderr = (string) stream_get_contents($pipes[2]);
|
|
fclose($pipes[1]);
|
|
fclose($pipes[2]);
|
|
|
|
return ['exit' => proc_close($process), 'stdout' => $stdout, 'stderr' => $stderr];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, string> $files relative path => contents
|
|
*/
|
|
function restoreScriptTar(array $files): string
|
|
{
|
|
$dir = restoreScriptTempDir();
|
|
|
|
try {
|
|
foreach ($files as $name => $contents) {
|
|
$path = $dir.'/src/'.$name;
|
|
if (! is_dir(dirname($path))) {
|
|
mkdir(dirname($path), 0777, true);
|
|
}
|
|
file_put_contents($path, $contents);
|
|
}
|
|
|
|
$result = restoreScriptProcess(['tar', '-cf', $dir.'/out.tar', '-C', $dir.'/src', ...array_keys($files)], $dir);
|
|
expect($result['exit'])->toBe(0, $result['stderr']);
|
|
|
|
return (string) file_get_contents($dir.'/out.tar');
|
|
} finally {
|
|
restoreScriptRemoveDir($dir);
|
|
}
|
|
}
|
|
|
|
function restoreScriptCompress(string $tool, string $contents): string
|
|
{
|
|
$dir = restoreScriptTempDir();
|
|
|
|
try {
|
|
file_put_contents($dir.'/input', $contents);
|
|
$result = restoreScriptProcess([$tool, '-c', $dir.'/input'], $dir);
|
|
expect($result['exit'])->toBe(0, $result['stderr']);
|
|
|
|
return $result['stdout'];
|
|
} finally {
|
|
restoreScriptRemoveDir($dir);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Builds an uncompressed (stored) zip archive, so the fixture does not depend on ext-zip.
|
|
*
|
|
* @param array<string, string> $files file name => contents
|
|
*/
|
|
function restoreScriptZip(array $files): string
|
|
{
|
|
$entries = '';
|
|
$directory = '';
|
|
|
|
foreach ($files as $name => $contents) {
|
|
$crc = crc32($contents);
|
|
$size = strlen($contents);
|
|
$offset = strlen($entries);
|
|
$entries .= pack('VvvvvvVVVvv', 0x04034B50, 20, 0, 0, 0, 0x21, $crc, $size, $size, strlen($name), 0).$name.$contents;
|
|
$directory .= pack('VvvvvvvVVVvvvvvVV', 0x02014B50, 20, 20, 0, 0, 0, 0x21, $crc, $size, $size, strlen($name), 0, 0, 0, 0, 0, $offset).$name;
|
|
}
|
|
|
|
return $entries.$directory.pack('VvvvvVVv', 0x06054B50, 0, 0, count($files), count($files), strlen($directory), strlen($entries), 0);
|
|
}
|
|
|
|
function restoreScriptFixture(string $name): string
|
|
{
|
|
$pgCustom = "PGDMP\x01\x0e\x00\x04\x08\x01\x01\x01".str_repeat("\x00\x01toc", 64);
|
|
$pgSql = "-- PostgreSQL database dump\n\nCREATE TABLE items (id integer);\n";
|
|
$mysqlSql = "-- MySQL dump 10.13\n\nCREATE TABLE `items` (`id` int);\nINSERT INTO `items` VALUES (1);\n";
|
|
$mongoArchive = "\x6d\xe2\x99\x81".str_repeat("\x01\x00archive", 32);
|
|
$bson = pack('V', 22)."\x02name\x00".pack('V', 5)."item\x00\x00";
|
|
|
|
return match ($name) {
|
|
'pg-custom' => $pgCustom,
|
|
'pg-custom-gz' => gzencode($pgCustom),
|
|
'pg-tar' => restoreScriptTar(['toc.dat' => $pgCustom, '3001.dat' => "1\n\\.\n"]),
|
|
'pg-tar-gz' => gzencode(restoreScriptTar(['toc.dat' => $pgCustom, '3001.dat' => "1\n\\.\n"])),
|
|
'pg-sql' => $pgSql,
|
|
'pg-sql-gz' => gzencode($pgSql),
|
|
'pg-cluster' => "--\n-- PostgreSQL database cluster dump\n--\n\nCREATE ROLE app;\n",
|
|
'garbage' => str_repeat("\x00\xff\x10\x80binary\x00", 64),
|
|
'mysql-sql' => $mysqlSql,
|
|
'mysql-sql-gz' => gzencode($mysqlSql),
|
|
'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,
|
|
'mongo-archive-gz' => gzencode($mongoArchive),
|
|
'mongo-dump-tar' => restoreScriptTar(['dump/app/items.bson' => $bson, 'dump/app/items.metadata.json' => '{"indexes":[]}']),
|
|
'mongo-dump-gz-tar' => restoreScriptTar(['dump/app/items.bson.gz' => gzencode($bson), 'dump/app/items.metadata.json.gz' => gzencode('{"indexes":[]}')]),
|
|
'mongo-archive-tar' => restoreScriptTar(['app.archive' => $mongoArchive]),
|
|
'mongo-notes-tar' => restoreScriptTar(['notes.txt' => 'hello', 'readme.txt' => 'world']),
|
|
'mongo-bson' => $bson,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
'postgresql' => StandalonePostgresql::class,
|
|
'mysql' => StandaloneMysql::class,
|
|
'mariadb' => StandaloneMariadb::class,
|
|
'mongodb' => StandaloneMongodb::class,
|
|
};
|
|
|
|
$resource = Mockery::mock($class);
|
|
$resource->shouldReceive('getMorphClass')->andReturn($class);
|
|
|
|
return $resource;
|
|
}
|
|
|
|
/**
|
|
* @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, bool $restoreMysqlUsers = false, array $env = []): array
|
|
{
|
|
$dir = restoreScriptTempDir();
|
|
|
|
try {
|
|
$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
|
|
if [ "\$*" = '--help' ]; then printf '%s\\n' "\${STUB_HELP:-}"; exit 0; fi
|
|
header=''
|
|
call=\$(wc -l < {$escapedLog} | tr -d ' ')
|
|
if [ ! -t 0 ]; then
|
|
cat > {$escapedStdin}/\$call
|
|
header=\$(head -c 5 {$escapedStdin}/\$call)
|
|
fi
|
|
printf '%s|%s|%s\\n' '{$client}' "\$*" "\$header" >> {$escapedLog}
|
|
if [ -n "\${STUB_FAIL_MATCH:-}" ]; then
|
|
case "\$*" in *"\$STUB_FAIL_MATCH"*) exit 1 ;; esac
|
|
fi
|
|
case "\$*" in *"SELECT datname FROM pg_database"*) printf '%s\\n' "\${STUB_DATABASES:-}" ;; esac
|
|
case "\$*" in *rolsuper*) printf '%s\\n' "\${STUB_ROLSUPER-t}" ;; esac
|
|
exit 0
|
|
SH);
|
|
chmod($dir.'/bin/'.$client, 0755);
|
|
}
|
|
|
|
$backup = $dir.'/backup file';
|
|
file_put_contents($backup, $contents);
|
|
|
|
$script = (new DatabaseImportCommandBuilder)->buildRestoreCommand(restoreScriptResource($engine), $backup, $dumpAll, $replaceExisting, $keepOwners, restoreMysqlUsers: $restoreMysqlUsers);
|
|
|
|
$environment = array_merge([
|
|
'PATH' => $dir.'/bin'.PATH_SEPARATOR.(getenv('PATH') ?: '/usr/bin:/bin'),
|
|
'TMPDIR' => $dir,
|
|
'POSTGRES_USER' => 'postgres',
|
|
'POSTGRES_DB' => 'app',
|
|
'MYSQL_USER' => 'app_user',
|
|
'MYSQL_PASSWORD' => 'app_pass',
|
|
'MYSQL_DATABASE' => 'app',
|
|
'MYSQL_ROOT_PASSWORD' => 'root_pass',
|
|
'MARIADB_USER' => 'app_user',
|
|
'MARIADB_PASSWORD' => 'app_pass',
|
|
'MARIADB_DATABASE' => 'app',
|
|
'MARIADB_ROOT_PASSWORD' => 'root_pass',
|
|
'MONGO_INITDB_ROOT_USERNAME' => 'root',
|
|
'MONGO_INITDB_ROOT_PASSWORD' => 'mongo_pass',
|
|
], $env);
|
|
$result = restoreScriptProcess(['sh', '-c', $script], $dir, array_filter($environment, fn (?string $value): bool => $value !== null));
|
|
|
|
$lines = array_values(array_filter(explode("\n", (string) file_get_contents($log)), fn (string $line): bool => $line !== ''));
|
|
$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, 'stdin' => is_file($stdin) ? (string) file_get_contents($stdin) : ''];
|
|
}, $lines, array_keys($lines));
|
|
|
|
return $result + [
|
|
'calls' => $calls,
|
|
'dir' => $dir,
|
|
'backup' => $backup,
|
|
'leftovers' => glob($dir.'/tmp.*') ?: [],
|
|
];
|
|
} finally {
|
|
restoreScriptRemoveDir($dir);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param list<array{name: string, args: string, header: string}> $calls
|
|
* @param list<array{0: string, 1: string, 2: list<string>}> $expected client, stdin header, argument fragments
|
|
*/
|
|
function restoreScriptExpectCalls(array $calls, array $expected): void
|
|
{
|
|
expect(array_map(fn (array $call): string => $call['name'].'|'.$call['header'], $calls))
|
|
->toBe(array_map(fn (array $call): string => $call[0].'|'.$call[1], $expected));
|
|
|
|
foreach ($expected as $index => $call) {
|
|
foreach ($call[2] as $fragment) {
|
|
expect($calls[$index]['args'])->toContain($fragment);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return array{exit: int, stdout: string, stderr: string, output: ?string, sourceExists: bool}
|
|
*/
|
|
function restoreScriptNormalize(string $contents): array
|
|
{
|
|
$dir = restoreScriptTempDir();
|
|
|
|
try {
|
|
$source = $dir.'/uploaded backup';
|
|
$target = $dir.'/normalized backup';
|
|
file_put_contents($source, $contents);
|
|
|
|
$result = restoreScriptProcess(
|
|
['sh', '-c', (new DatabaseImportCommandBuilder)->buildNormalizeScript($source, $target)],
|
|
$dir,
|
|
['TMPDIR' => $dir],
|
|
);
|
|
|
|
return $result + [
|
|
'output' => is_file($target) ? (string) file_get_contents($target) : null,
|
|
'sourceExists' => file_exists($source),
|
|
];
|
|
} finally {
|
|
restoreScriptRemoveDir($dir);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Runs a SQL replacement against stateful PostgreSQL client stubs. Database values represent
|
|
* their data, so accidental drops and a failed swap are visible in the final database state.
|
|
*
|
|
* @return array{exit: int, stdout: string, stderr: string, databases: array<string, string>}
|
|
*/
|
|
function postgresReplacementScriptRun(string $target = 'app', string $failure = '', bool $gzip = false, ?string $contents = null): array
|
|
{
|
|
$dir = restoreScriptTempDir();
|
|
|
|
try {
|
|
$backup = $dir.'/backup.sql';
|
|
file_put_contents($backup, $contents ?? ($gzip ? gzencode('SELECT 1;') : 'SELECT 1;'));
|
|
$script = (new DatabaseImportCommandBuilder)->buildRestoreCommand(restoreScriptResource('postgresql'), $backup, false, true);
|
|
preg_match('/new=(\w+)/', $script, $new);
|
|
preg_match('/old=(\w+)/', $script, $old);
|
|
$databases = ['coolify_restore_new' => 'unrelated-new', 'coolify_restore_old' => 'unrelated-old', $target => 'original'];
|
|
if (in_array($failure, ['new_collision', 'old_collision'], true)) {
|
|
$databases[$failure === 'new_collision' ? $new[1] : $old[1]] = 'collision';
|
|
}
|
|
$state = $dir.'/databases.json';
|
|
file_put_contents($state, json_encode($databases));
|
|
mkdir($dir.'/bin');
|
|
|
|
$client = <<<'PHP'
|
|
<?php
|
|
$state = getenv('PG_STATE');
|
|
$databases = json_decode(file_get_contents($state), true);
|
|
$name = basename($argv[0]);
|
|
$args = array_slice($argv, 1);
|
|
$sql = stream_get_contents(STDIN);
|
|
$failure = getenv('PG_FAILURE');
|
|
$save = static function () use ($state, &$databases): void { file_put_contents($state, json_encode($databases)); };
|
|
if ($name === 'dropdb') {
|
|
unset($databases[end($args)]);
|
|
} elseif ($name === 'createdb') {
|
|
$database = end($args);
|
|
if (isset($databases[$database])) { exit(1); }
|
|
$databases[$database] = 'empty';
|
|
} elseif (in_array('-Atc', $args, true)) {
|
|
// Connection and superuser checks run their query as an argument and change nothing.
|
|
echo str_contains(end($args), 'rolsuper') ? "t\n" : "1\n";
|
|
exit(0);
|
|
} else {
|
|
$variables = [];
|
|
foreach ($args as $index => $arg) {
|
|
if ($arg === '-v') {
|
|
[$key, $value] = explode('=', $args[$index + 1], 2);
|
|
$variables[$key] = $value;
|
|
}
|
|
}
|
|
if (str_contains($sql, 'SELECT 1 FROM pg_database')) {
|
|
if (isset($databases[$variables['new']]) || isset($databases[$variables['old']])) { echo "1\n"; }
|
|
} elseif (str_contains($sql, 'ALTER DATABASE')) {
|
|
preg_match_all('/ALTER DATABASE :"(\w+)" RENAME TO :"(\w+)";/', $sql, $renames, PREG_SET_ORDER);
|
|
foreach ($renames as $rename) {
|
|
$from = $variables[$rename[1]];
|
|
$to = $variables[$rename[2]];
|
|
if (! isset($databases[$from]) || isset($databases[$to]) || ($failure === 'swap' && $rename[1] === 'new')) {
|
|
$save();
|
|
exit(1);
|
|
}
|
|
$databases[$to] = $databases[$from];
|
|
unset($databases[$from]);
|
|
}
|
|
} else {
|
|
if ($failure === 'restore') { exit(1); }
|
|
$database = $args[array_search('-d', $args, true) + 1];
|
|
if (! isset($databases[$database])) { exit(1); }
|
|
$databases[$database] = 'restored';
|
|
}
|
|
}
|
|
$save();
|
|
PHP;
|
|
|
|
foreach (['psql', 'createdb', 'dropdb'] as $name) {
|
|
file_put_contents($dir.'/bin/'.$name, '#!'.PHP_BINARY."\n".$client);
|
|
chmod($dir.'/bin/'.$name, 0755);
|
|
}
|
|
|
|
return restoreScriptProcess(['sh', '-c', $script], $dir, [
|
|
'PATH' => $dir.'/bin'.PATH_SEPARATOR.(getenv('PATH') ?: '/usr/bin:/bin'),
|
|
'POSTGRES_USER' => 'postgres',
|
|
'POSTGRES_DB' => $target,
|
|
'PG_STATE' => $state,
|
|
'PG_FAILURE' => $failure,
|
|
]) + ['databases' => json_decode(file_get_contents($state), true)];
|
|
} finally {
|
|
restoreScriptRemoveDir($dir);
|
|
}
|
|
}
|
|
|
|
test('SQL replacement preserves unrelated PostgreSQL databases with the former scratch names', function (string $target, bool $gzip) {
|
|
$run = postgresReplacementScriptRun($target, gzip: $gzip);
|
|
$expected = ['coolify_restore_new' => 'unrelated-new', 'coolify_restore_old' => 'unrelated-old', $target => 'restored'];
|
|
|
|
expect($run['exit'])->toBe(0, $run['stderr'])
|
|
->and($run['databases'])->toEqual($expected);
|
|
})->with(['app', 'coolify_restore_new', 'coolify_restore_old'])->with([false, true]);
|
|
|
|
test('failed SQL replacement preserves every existing PostgreSQL database and removes only its own scratch database', function (string $failure) {
|
|
$run = postgresReplacementScriptRun(failure: $failure);
|
|
|
|
expect($run['exit'])->toBe(1)
|
|
->and($run['databases'])->toEqual(['coolify_restore_new' => 'unrelated-new', 'coolify_restore_old' => 'unrelated-old', 'app' => 'original']);
|
|
})->with(['restore', 'swap']);
|
|
|
|
dataset('corrupt restore gzip', [
|
|
'missing footer' => fn (string $contents): string => substr(gzencode($contents), 0, -8),
|
|
'truncated later member' => fn (string $contents): string => gzencode($contents).substr(gzencode('SELECT 2;'), 0, 10),
|
|
]);
|
|
|
|
test('corrupt gzip SQL replacement preserves every existing PostgreSQL database', function (Closure $corrupt) {
|
|
$run = postgresReplacementScriptRun(contents: $corrupt('SELECT 1;'));
|
|
|
|
expect($run['exit'])->toBe(1)
|
|
->and($run['stderr'])->toContain('The gzip backup is corrupt or incomplete. Nothing was changed.')
|
|
->and($run['databases'])->toEqual(['coolify_restore_new' => 'unrelated-new', 'coolify_restore_old' => 'unrelated-old', 'app' => 'original']);
|
|
})->with('corrupt restore gzip');
|
|
|
|
test('rejects corrupt gzip backups before any database client runs', function (string $engine, string $fixture, bool $dumpAll, bool $replaceExisting, Closure $corrupt) {
|
|
$run = restoreScriptRun($engine, $corrupt(restoreScriptFixture($fixture)), $dumpAll, $replaceExisting);
|
|
|
|
expect($run['exit'])->toBe(1)
|
|
->and($run['stderr'])->toContain('The gzip backup is corrupt or incomplete. Nothing was changed.')
|
|
->and($run['calls'])->toBe([])
|
|
->and($run['leftovers'])->toBe([]);
|
|
})->with([
|
|
'single PostgreSQL SQL' => ['postgresql', 'pg-sql', false, false],
|
|
'PostgreSQL archive replacement' => ['postgresql', 'pg-custom', false, true],
|
|
'all PostgreSQL databases' => ['postgresql', 'pg-sql', true, false],
|
|
'single MySQL database' => ['mysql', 'mysql-sql', false, false],
|
|
'all MySQL databases' => ['mysql', 'mysql-sql', true, false],
|
|
'all MariaDB databases' => ['mariadb', 'mysql-sql', true, false],
|
|
'MongoDB replacement' => ['mongodb', 'mongo-archive', false, true],
|
|
])->with('corrupt restore gzip');
|
|
|
|
test('SQL replacement refuses scratch database name collisions without changing existing data', function (string $failure) {
|
|
$run = postgresReplacementScriptRun(failure: $failure);
|
|
|
|
expect($run['exit'])->toBe(1)
|
|
->and($run['databases'])->toHaveCount(4)
|
|
->and($run['databases']['app'])->toBe('original')
|
|
->and($run['databases']['coolify_restore_new'])->toBe('unrelated-new')
|
|
->and($run['databases']['coolify_restore_old'])->toBe('unrelated-old')
|
|
->and(array_values($run['databases']))->toContain('collision');
|
|
})->with(['new_collision', 'old_collision']);
|
|
|
|
test('restores single PostgreSQL archives with pg_restore and SQL with psql', function (string $fixture, bool $replaceExisting, array $expectedCalls) {
|
|
$run = restoreScriptRun('postgresql', restoreScriptFixture($fixture), false, $replaceExisting);
|
|
|
|
expect($run['exit'])->toBe(0, $run['stderr']);
|
|
restoreScriptExpectCalls($run['calls'], $expectedCalls);
|
|
})->with([
|
|
'custom archive' => ['pg-custom', false, [['psql', '', ['-U postgres -d template1 -Atc', 'SELECT 1']], ['pg_restore', 'PGDMP', ['--exit-on-error --single-transaction --no-owner --no-acl -U postgres -d app']]]],
|
|
'custom archive replacing existing objects' => ['pg-custom', true, [['psql', '', ['-U postgres -d template1 -Atc', 'SELECT 1']], ['pg_restore', 'PGDMP', ['--exit-on-error --single-transaction --no-owner --no-acl --clean --if-exists -U postgres -d app']]]],
|
|
'gzip custom archive' => ['pg-custom-gz', false, [['psql', '', ['-U postgres -d template1 -Atc', 'SELECT 1']], ['pg_restore', 'PGDMP', ['--exit-on-error --single-transaction --no-owner --no-acl -U postgres -d app']]]],
|
|
'gzip custom archive replacing existing objects' => ['pg-custom-gz', true, [['psql', '', ['-U postgres -d template1 -Atc', 'SELECT 1']], ['pg_restore', 'PGDMP', ['--exit-on-error --single-transaction --no-owner --no-acl --clean --if-exists -U postgres -d app']]]],
|
|
'tar archive' => ['pg-tar', false, [['psql', '', ['-U postgres -d template1 -Atc', 'SELECT 1']], ['pg_restore', 'toc.d', ['--exit-on-error --single-transaction --no-owner --no-acl -U postgres -d app']]]],
|
|
'gzip tar archive' => ['pg-tar-gz', false, [['psql', '', ['-U postgres -d template1 -Atc', 'SELECT 1']], ['pg_restore', 'toc.d', ['--exit-on-error --single-transaction --no-owner --no-acl -U postgres -d app']]]],
|
|
'plain SQL' => ['pg-sql', false, [['psql', '', ['-U postgres -d template1 -Atc', 'SELECT 1']], ['psql', '-- Po', ['-v ON_ERROR_STOP=1 --single-transaction -U postgres -d app']]]],
|
|
'gzip SQL' => ['pg-sql-gz', false, [['psql', '', ['-U postgres -d template1 -Atc', 'SELECT 1']], ['psql', '-- Po', ['-v ON_ERROR_STOP=1 --single-transaction -U postgres -d app']]]],
|
|
// SQL cannot replace single objects: it restores into a new database, and only a
|
|
// successful restore replaces the current one, so a failure changes nothing.
|
|
'plain SQL replacing existing objects' => ['pg-sql', true, [
|
|
['psql', '', ['-U postgres -d template1 -Atc', 'SELECT 1']],
|
|
['psql', 'SELEC', ['-v ON_ERROR_STOP=1 -At', '-v new=coolify_restore_new_', '-v old=coolify_restore_old_']],
|
|
['createdb', '', ['-U postgres coolify_restore_new_']],
|
|
['psql', '-- Po', ['-v ON_ERROR_STOP=1 --single-transaction -U postgres -d coolify_restore_new_']],
|
|
['psql', 'SELEC', ['-v ON_ERROR_STOP=1 -v db=app -v old=coolify_restore_old_', '-v new=coolify_restore_new_', '-U postgres -d template1']],
|
|
['dropdb', '', ['--maintenance-db=template1 -U postgres --if-exists coolify_restore_old_']],
|
|
]],
|
|
'gzip SQL replacing existing objects' => ['pg-sql-gz', true, [
|
|
['psql', '', ['-U postgres -d template1 -Atc', 'SELECT 1']],
|
|
['psql', 'SELEC', ['-v ON_ERROR_STOP=1 -At', '-v new=coolify_restore_new_', '-v old=coolify_restore_old_']],
|
|
['createdb', '', ['-U postgres coolify_restore_new_']],
|
|
['psql', '-- Po', ['-v ON_ERROR_STOP=1 --single-transaction -U postgres -d coolify_restore_new_']],
|
|
['psql', 'SELEC', ['-v ON_ERROR_STOP=1 -v db=app -v old=coolify_restore_old_', '-v new=coolify_restore_new_', '-U postgres -d template1']],
|
|
['dropdb', '', ['--maintenance-db=template1 -U postgres --if-exists coolify_restore_old_']],
|
|
]],
|
|
]);
|
|
|
|
test('keeps PostgreSQL owners and privileges only when requested', function (string $fixture) {
|
|
$run = restoreScriptRun('postgresql', restoreScriptFixture($fixture), keepOwners: true);
|
|
|
|
expect($run['exit'])->toBe(0, $run['stderr']);
|
|
restoreScriptExpectCalls($run['calls'], [['psql', '', ['SELECT 1']], ['pg_restore', 'PGDMP', ['--exit-on-error --single-transaction -U postgres -d app']]]);
|
|
expect($run['calls'][1]['args'])->not->toContain('--no-owner')->not->toContain('--no-acl');
|
|
})->with(['custom archive' => ['pg-custom'], 'gzip custom archive' => ['pg-custom-gz']]);
|
|
|
|
test('rejects unsupported single PostgreSQL backups before calling any client', function (string $fixture, string $message) {
|
|
$run = restoreScriptRun('postgresql', restoreScriptFixture($fixture));
|
|
|
|
expect($run['exit'])->toBe(1)
|
|
->and($run['stderr'])->toContain($message)
|
|
->and($run['calls'])->toBe([]);
|
|
})->with([
|
|
'cluster SQL dump' => ['pg-cluster', 'This backup contains all databases.'],
|
|
'binary garbage' => ['garbage', 'Unsupported PostgreSQL backup format'],
|
|
]);
|
|
|
|
test('restores PostgreSQL backups containing all databases after checking the format', function (string $fixture, array $restoreCall, array $environment, string $user) {
|
|
$run = restoreScriptRun('postgresql', restoreScriptFixture($fixture), dumpAll: true, env: $environment + ['STUB_DATABASES' => 'app']);
|
|
|
|
$recreate = [
|
|
['psql', '', ["-U {$user} -d template1 -Atc", 'rolsuper']],
|
|
['psql', '', ["-U {$user} -d template1 -c", 'pg_terminate_backend']],
|
|
['psql', '', ["-U {$user} -d template1 -t -c", 'SELECT datname FROM pg_database']],
|
|
['dropdb', '', ["-U {$user} --maintenance-db=template1 --if-exists app"]],
|
|
['createdb', '', ["-U {$user} app"]],
|
|
['createdb', '', ["-U {$user} postgres"]],
|
|
];
|
|
$inspect = $restoreCall[0] === 'pg_restore' ? [['pg_restore', 'PGDMP', ['-l']]] : [];
|
|
|
|
expect($run['exit'])->toBe(0, $run['stderr']);
|
|
$restoreCall[2] = ["-U {$user} -d app"];
|
|
restoreScriptExpectCalls($run['calls'], [...$inspect, ...$recreate, $restoreCall]);
|
|
})->with([
|
|
'custom archive' => ['pg-custom', ['pg_restore', 'PGDMP', ['-U postgres -d app']]],
|
|
'gzip custom archive' => ['pg-custom-gz', ['pg_restore', 'PGDMP', ['-U postgres -d app']]],
|
|
'plain SQL' => ['pg-sql', ['psql', '-- Po', ['-U postgres -d app']]],
|
|
'gzip SQL' => ['pg-sql-gz', ['psql', '-- Po', ['-U postgres -d app']]],
|
|
])->with([
|
|
'POSTGRES_USER' => [[], 'postgres'],
|
|
'PGUSER' => [['POSTGRES_USER' => null, 'PGUSER' => 'custom_user'], 'custom_user'],
|
|
'POSTGRESQL_USERNAME' => [['POSTGRES_USER' => null, 'POSTGRESQL_USERNAME' => 'bitnami'], 'bitnami'],
|
|
]);
|
|
|
|
test('rejects unsupported PostgreSQL backups containing all databases before dropping anything', function () {
|
|
$run = restoreScriptRun('postgresql', restoreScriptFixture('garbage'), dumpAll: true);
|
|
|
|
expect($run['exit'])->toBe(1)
|
|
->and($run['stderr'])->toContain('Unsupported PostgreSQL backup format')->toContain('Nothing was changed.')
|
|
->and($run['calls'])->toBe([]);
|
|
});
|
|
|
|
test('finds the PostgreSQL user from POSTGRES_USER, PGUSER, or POSTGRESQL_USERNAME', function (array $env, string $user) {
|
|
$run = restoreScriptRun('postgresql', restoreScriptFixture('pg-custom'), env: ['POSTGRES_USER' => null, 'POSTGRES_DB' => null, ...$env]);
|
|
|
|
expect($run['exit'])->toBe(0, $run['stderr']);
|
|
restoreScriptExpectCalls($run['calls'], [
|
|
['psql', '', ["-U {$user} -d template1 -Atc"]],
|
|
['pg_restore', 'PGDMP', ["-U {$user} -d {$user}"]],
|
|
]);
|
|
})->with([
|
|
'POSTGRES_USER' => [['POSTGRES_USER' => 'app'], 'app'],
|
|
'PGUSER' => [['PGUSER' => 'pguser'], 'pguser'],
|
|
'Bitnami POSTGRESQL_USERNAME' => [['POSTGRESQL_USERNAME' => 'bitnami'], 'bitnami'],
|
|
'POSTGRES_USER wins over the others' => [['POSTGRES_USER' => 'app', 'PGUSER' => 'pguser', 'POSTGRESQL_USERNAME' => 'bitnami'], 'app'],
|
|
'nothing set' => [[], 'postgres'],
|
|
]);
|
|
|
|
test('stops a PostgreSQL restore before any change when it cannot connect as the user', function (string $fixture, bool $dumpAll, bool $replaceExisting, string $message) {
|
|
$run = restoreScriptRun('postgresql', restoreScriptFixture($fixture), $dumpAll, $replaceExisting, env: ['POSTGRES_USER' => null, 'STUB_FAIL_MATCH' => '-d template1 -Atc']);
|
|
|
|
$changing = array_filter($run['calls'], fn (array $call): bool => ! str_contains($call['args'], '-d template1 -Atc') && ! ($call['name'] === 'pg_restore' && $call['args'] === '-l'));
|
|
|
|
expect($run['exit'])->toBe(1)
|
|
->and($run['stderr'])->toContain($message)->toContain('postgres')->toContain('Nothing was changed.')
|
|
->and($changing)->toBe([]);
|
|
})->with([
|
|
'custom archive' => ['pg-custom', false, false, 'Cannot connect to PostgreSQL as user'],
|
|
'SQL' => ['pg-sql', false, false, 'Cannot connect to PostgreSQL as user'],
|
|
'SQL replacing existing objects' => ['pg-sql', false, true, 'Cannot connect to PostgreSQL as user'],
|
|
'all databases' => ['pg-custom', true, false, 'needs a PostgreSQL superuser'],
|
|
]);
|
|
|
|
test('refuses to restore all PostgreSQL databases as a user that is not a superuser', function () {
|
|
$run = restoreScriptRun('postgresql', restoreScriptFixture('pg-sql'), dumpAll: true, env: ['POSTGRES_USER' => null, 'POSTGRESQL_USERNAME' => 'bitnami', 'STUB_ROLSUPER' => 'f']);
|
|
|
|
expect($run['exit'])->toBe(1)
|
|
->and($run['stderr'])->toContain('needs a PostgreSQL superuser')->toContain('bitnami')->toContain('Nothing was changed.')
|
|
->and(array_column($run['calls'], 'name'))->toBe(['psql'])
|
|
->and($run['calls'][0]['args'])->toContain('rolsuper');
|
|
});
|
|
|
|
test('restores single MySQL and MariaDB SQL backups', function (string $engine, string $fixture) {
|
|
$run = restoreScriptRun($engine, restoreScriptFixture($fixture));
|
|
|
|
expect($run['exit'])->toBe(0, $run['stderr'])
|
|
->and($run['leftovers'])->toBe([]);
|
|
restoreScriptExpectCalls($run['calls'], [[$engine, '-- My', ['-u app_user -papp_pass app']]]);
|
|
})->with(['mysql', 'mariadb'])->with([
|
|
'SQL' => 'mysql-sql',
|
|
'gzip SQL' => 'mysql-sql-gz',
|
|
'tar with one SQL file' => 'mysql-tar',
|
|
'dump of one database made with --databases' => 'mysql-one-database',
|
|
]);
|
|
|
|
test('restores MySQL and MariaDB backups containing all databases after checking the format', function (string $engine, string $fixture) {
|
|
$run = restoreScriptRun($engine, restoreScriptFixture($fixture), dumpAll: true);
|
|
|
|
expect($run['exit'])->toBe(0, $run['stderr'])
|
|
->and($run['leftovers'])->toBe([]);
|
|
restoreScriptExpectCalls($run['calls'], [
|
|
[$engine, '', ['-u root -proot_pass -N -e', 'information_schema.processlist']],
|
|
[$engine, '', ['-u root -proot_pass -N -e', 'DROP DATABASE IF EXISTS']],
|
|
[$engine, '', ['-u root -proot_pass']],
|
|
[$engine, '', ['-u root -proot_pass -e CREATE DATABASE IF NOT EXISTS `app`;']],
|
|
[$engine, '-- My', ['-u root -proot_pass app']],
|
|
]);
|
|
expect(end($run['calls'])['args'])->toBe('-u root -proot_pass app');
|
|
})->with(['mysql', 'mariadb'])->with([
|
|
'SQL' => 'mysql-sql',
|
|
'gzip SQL' => 'mysql-sql-gz',
|
|
'tar with one SQL file' => 'mysql-tar',
|
|
'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);
|
|
|
|
expect($run['exit'])->toBe(1)
|
|
->and($run['stderr'])->toContain($message)
|
|
->and($run['calls'])->toBe([]);
|
|
})->with(['mysql', 'mariadb'])->with([
|
|
'tar with two files' => ['mysql-tar-two', false, 'A tar backup must contain exactly one SQL dump.'],
|
|
'tar with two files, all databases' => ['mysql-tar-two', true, 'A tar backup must contain exactly one SQL dump.'],
|
|
'binary garbage' => ['garbage', false, 'Unsupported backup format.'],
|
|
'binary garbage, all databases' => ['garbage', true, 'Unsupported backup format.'],
|
|
'all-databases dump without dump-all' => ['mysql-cluster', false, 'This backup contains more than one database.'],
|
|
'dump of two databases without dump-all' => ['mysql-two-databases', false, 'This backup contains more than one database.'],
|
|
]);
|
|
|
|
test('restores MySQL and MariaDB dumps with client commands disabled when the client supports it', function (string $engine, string $help, string $flag, bool $dumpAll) {
|
|
$run = restoreScriptRun($engine, restoreScriptFixture('mysql-sql'), $dumpAll, env: ['STUB_HELP' => $help]);
|
|
|
|
expect($run['exit'])->toBe(0, $run['stderr'])
|
|
->and(end($run['calls'])['args'])->toStartWith($flag.' -u ')
|
|
->and(end($run['calls'])['stdin'])->toBe(restoreScriptFixture('mysql-sql'));
|
|
})->with(['mysql', 'mariadb'])->with([
|
|
'MariaDB client' => [' --sandbox Disallow commands that access the file system', '--sandbox'],
|
|
'MySQL client' => [' --commands Whether to enable or disable processing of local mysql commands.', '--commands=FALSE'],
|
|
])->with(['single database' => false, 'all databases' => true]);
|
|
|
|
test('refuses MySQL and MariaDB dumps with client commands before calling any client', function (string $engine, string $command, bool $dumpAll) {
|
|
$dump = "-- MySQL dump 10.13\n\nCREATE TABLE `items` (`id` int);\n{$command}\nINSERT INTO `items` VALUES (1);\n";
|
|
|
|
foreach ([$dump, gzencode($dump)] as $contents) {
|
|
$run = restoreScriptRun($engine, $contents, $dumpAll);
|
|
|
|
expect($run['exit'])->toBe(1)
|
|
->and($run['stderr'])->toContain('client command')->toContain('Nothing was changed.')
|
|
->and($run['calls'])->toBe([]);
|
|
}
|
|
})->with(['mysql', 'mariadb'])->with([
|
|
'system' => 'system id',
|
|
'indented SYSTEM' => ' SYSTEM id;',
|
|
'\\!' => '\\! id',
|
|
'\\! after SQL' => 'SELECT 1; \\! id',
|
|
'tee' => 'tee /etc/cron.d/x',
|
|
'\\T' => '\\T /etc/cron.d/x',
|
|
'pager' => 'pager sh',
|
|
'source' => 'source /etc/shadow',
|
|
'\\.' => '\\. /etc/shadow',
|
|
])->with(['single database' => false, 'all databases' => true]);
|
|
|
|
test('restores MySQL and MariaDB dumps whose data only looks like client commands', function (string $engine) {
|
|
$dump = "-- MySQL dump 10.13\n/*M!999999\\- enable the sandbox mode */\nCREATE TABLE `items` (`t` text, `system` int);\nINSERT INTO `items` VALUES ('a\\\\! b\\\\\\\\T c\\'s \\\\. \\r\\n',1),('system id',2);\n";
|
|
$run = restoreScriptRun($engine, $dump);
|
|
|
|
expect($run['exit'])->toBe(0, $run['stderr'])
|
|
->and(end($run['calls'])['stdin'])->toBe($dump);
|
|
})->with(['mysql', 'mariadb']);
|
|
|
|
test('restores mongodump archives', function (string $fixture, bool $replaceExisting, bool $gzip) {
|
|
$run = restoreScriptRun('mongodb', restoreScriptFixture($fixture), false, $replaceExisting);
|
|
|
|
expect($run['exit'])->toBe(0, $run['stderr']);
|
|
restoreScriptExpectCalls($run['calls'], [['mongorestore', '', [
|
|
'--authenticationDatabase=admin --username root --password mongo_pass',
|
|
'--archive='.$run['backup'],
|
|
]]]);
|
|
|
|
$args = $run['calls'][0]['args'];
|
|
expect(str_contains($args, '--gzip'))->toBe($gzip)
|
|
->and(str_contains($args, '--drop'))->toBe($replaceExisting);
|
|
})->with([
|
|
'plain archive' => ['mongo-archive', false, false],
|
|
'plain archive replacing existing collections' => ['mongo-archive', true, false],
|
|
'gzip archive' => ['mongo-archive-gz', false, true],
|
|
'gzip archive replacing existing collections' => ['mongo-archive-gz', true, true],
|
|
]);
|
|
|
|
test('restores mongodump directories packed as tar', function (string $fixture, bool $gzip) {
|
|
$run = restoreScriptRun('mongodb', restoreScriptFixture($fixture));
|
|
|
|
expect($run['exit'])->toBe(0, $run['stderr'])
|
|
->and($run['calls'])->toHaveCount(1)
|
|
->and($run['calls'][0]['name'])->toBe('mongorestore')
|
|
->and($run['calls'][0]['args'])->toMatch('#--dir='.preg_quote($run['dir'], '#').'/tmp\.[^/ ]+/dump$#')
|
|
->and(str_contains($run['calls'][0]['args'], '--gzip'))->toBe($gzip)
|
|
->and($run['calls'][0]['args'])->not->toContain('--archive')
|
|
->and($run['leftovers'])->toBe([]);
|
|
})->with([
|
|
'bson files' => ['mongo-dump-tar', false],
|
|
'gzip bson files' => ['mongo-dump-gz-tar', true],
|
|
]);
|
|
|
|
test('restores a mongodump archive wrapped in tar', function () {
|
|
$run = restoreScriptRun('mongodb', restoreScriptFixture('mongo-archive-tar'));
|
|
|
|
expect($run['exit'])->toBe(0, $run['stderr'])
|
|
->and($run['calls'])->toHaveCount(1)
|
|
->and($run['calls'][0]['name'])->toBe('mongorestore')
|
|
->and($run['calls'][0]['args'])->toMatch('#--archive='.preg_quote($run['dir'], '#').'/tmp\.[^/ ]+/app\.archive$#')
|
|
->and($run['calls'][0]['args'])->not->toContain('--gzip')
|
|
->and($run['leftovers'])->toBe([]);
|
|
});
|
|
|
|
test('rejects unsupported MongoDB backups before calling mongorestore', function (string $fixture, bool $replaceExisting, string $message) {
|
|
$run = restoreScriptRun('mongodb', restoreScriptFixture($fixture), false, $replaceExisting);
|
|
|
|
expect($run['exit'])->toBe(1)
|
|
->and($run['stderr'])->toContain($message)
|
|
->and($run['calls'])->toBe([]);
|
|
})->with([
|
|
'single bson file' => ['mongo-bson', false, 'Unsupported MongoDB backup format'],
|
|
'single bson file replacing existing collections' => ['mongo-bson', true, 'Unsupported MongoDB backup format'],
|
|
'tar without a dump directory' => ['mongo-notes-tar', true, 'The tar backup does not contain a mongodump directory.'],
|
|
]);
|
|
|
|
test('normalize moves plain and gzip backups unchanged', function (string $contents) {
|
|
$result = restoreScriptNormalize($contents);
|
|
|
|
expect($result['exit'])->toBe(0, $result['stderr'])
|
|
->and($result['output'])->toBe($contents)
|
|
->and($result['sourceExists'])->toBeFalse();
|
|
})->with([
|
|
'plain SQL' => ["-- PostgreSQL database dump\nSELECT 1;\n"],
|
|
'gzip SQL' => [gzencode("-- PostgreSQL database dump\nSELECT 1;\n")],
|
|
]);
|
|
|
|
test('normalize decompresses bz2, xz and single-file zip backups', function (string $format, array $tools) {
|
|
foreach ($tools as $tool) {
|
|
if (! restoreScriptHasTool($tool)) {
|
|
$this->markTestSkipped("{$tool} is not installed.");
|
|
}
|
|
}
|
|
|
|
$original = "-- MySQL dump 10.13\nCREATE TABLE `items` (`id` int);\n";
|
|
$compressed = match ($format) {
|
|
'bz2' => restoreScriptCompress('bzip2', $original),
|
|
'xz' => restoreScriptCompress('xz', $original),
|
|
'zip' => restoreScriptZip(['backup.sql' => $original]),
|
|
};
|
|
|
|
$result = restoreScriptNormalize($compressed);
|
|
|
|
expect($result['exit'])->toBe(0, $result['stderr'])
|
|
->and($result['output'])->toBe($original);
|
|
})->with([
|
|
'bz2' => ['bz2', ['bzip2', 'bunzip2']],
|
|
'xz' => ['xz', ['xz', 'unxz']],
|
|
'zip' => ['zip', ['unzip']],
|
|
]);
|
|
|
|
test('normalize rejects backups it cannot unpack', function (string $format, string $tool, string $message) {
|
|
if (! restoreScriptHasTool($tool)) {
|
|
$this->markTestSkipped("{$tool} is not installed.");
|
|
}
|
|
|
|
$contents = match ($format) {
|
|
'zip with two files' => restoreScriptZip(['app.sql' => 'SELECT 1;', 'other.sql' => 'SELECT 2;']),
|
|
'corrupt bz2' => 'BZh91AY&SY'.str_repeat("\x00\xffcorrupt", 16),
|
|
};
|
|
|
|
$result = restoreScriptNormalize($contents);
|
|
|
|
expect($result['exit'])->toBe(1)
|
|
->and($result['stderr'])->toContain($message);
|
|
})->with([
|
|
'zip with two files' => ['zip with two files', 'unzip', 'A zip backup must contain exactly one file.'],
|
|
'corrupt bz2' => ['corrupt bz2', 'bunzip2', 'The bz2 backup cannot be decompressed.'],
|
|
]);
|