deleteDirectory($dir); } function restoreScriptHasTool(string $tool): bool { exec('command -v '.escapeshellarg($tool).' >/dev/null 2>&1', $output, $exitCode); return $exitCode === 0; } /** * @param list $command * @param array $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 $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 $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, dir: string, backup: string, leftovers: list} */ 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, << {$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 $calls * @param list}> $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} */ 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' $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.'], ]);