feat(mcp): add REST-backed resource management tools (#12163)

This commit is contained in:
Andras Bacsai
2026-10-14 13:26:12 +02:00
committed by GitHub
parent b31a458a51
commit 521b3cb90f
125 changed files with 5749 additions and 418 deletions
@@ -235,7 +235,7 @@ function restoreScriptResource(string $engine): object
/**
* @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
function restoreScriptRun(string $engine, string $contents, bool $dumpAll = false, bool $replaceExisting = false, bool $keepOwners = false, bool $restoreMysqlUsers = false, array $env = []): array
{
$dir = restoreScriptTempDir();
@@ -257,6 +257,11 @@ if [ ! -t 0 ]; then
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);
@@ -267,7 +272,7 @@ SH);
$script = (new DatabaseImportCommandBuilder)->buildRestoreCommand(restoreScriptResource($engine), $backup, $dumpAll, $replaceExisting, $keepOwners, restoreMysqlUsers: $restoreMysqlUsers);
$result = restoreScriptProcess(['sh', '-c', $script], $dir, [
$environment = array_merge([
'PATH' => $dir.'/bin'.PATH_SEPARATOR.(getenv('PATH') ?: '/usr/bin:/bin'),
'TMPDIR' => $dir,
'POSTGRES_USER' => 'postgres',
@@ -282,7 +287,8 @@ SH);
'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 {
@@ -385,6 +391,10 @@ if ($name === 'dropdb') {
$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) {
@@ -496,17 +506,18 @@ test('restores single PostgreSQL archives with pg_restore and SQL with psql', fu
expect($run['exit'])->toBe(0, $run['stderr']);
restoreScriptExpectCalls($run['calls'], $expectedCalls);
})->with([
'custom archive' => ['pg-custom', false, [['pg_restore', 'PGDMP', ['--exit-on-error --single-transaction --no-owner --no-acl -U postgres -d app']]]],
'custom archive replacing existing objects' => ['pg-custom', true, [['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, [['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, [['pg_restore', 'PGDMP', ['--exit-on-error --single-transaction --no-owner --no-acl --clean --if-exists -U postgres -d app']]]],
'tar archive' => ['pg-tar', false, [['pg_restore', 'toc.d', ['--exit-on-error --single-transaction --no-owner --no-acl -U postgres -d app']]]],
'gzip tar archive' => ['pg-tar-gz', false, [['pg_restore', 'toc.d', ['--exit-on-error --single-transaction --no-owner --no-acl -U postgres -d app']]]],
'plain SQL' => ['pg-sql', false, [['psql', '-- Po', ['-v ON_ERROR_STOP=1 --single-transaction -U postgres -d app']]]],
'gzip SQL' => ['pg-sql-gz', false, [['psql', '-- Po', ['-v ON_ERROR_STOP=1 --single-transaction -U postgres -d app']]]],
'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_']],
@@ -514,6 +525,7 @@ test('restores single PostgreSQL archives with pg_restore and SQL with psql', fu
['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_']],
@@ -526,8 +538,8 @@ test('keeps PostgreSQL owners and privileges only when requested', function (str
$run = restoreScriptRun('postgresql', restoreScriptFixture($fixture), keepOwners: true);
expect($run['exit'])->toBe(0, $run['stderr']);
restoreScriptExpectCalls($run['calls'], [['pg_restore', 'PGDMP', ['--exit-on-error --single-transaction -U postgres -d app']]]);
expect($run['calls'][0]['args'])->not->toContain('--no-owner')->not->toContain('--no-acl');
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) {
@@ -541,23 +553,30 @@ test('rejects unsupported single PostgreSQL backups before calling any client',
'binary garbage' => ['garbage', 'Unsupported PostgreSQL backup format'],
]);
test('restores PostgreSQL backups containing all databases after checking the format', function (string $fixture, array $restoreCall) {
$run = restoreScriptRun('postgresql', restoreScriptFixture($fixture), dumpAll: true);
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 postgres -c', 'pg_terminate_backend']],
['psql', '', ['-U postgres -t -c', 'SELECT datname FROM pg_database']],
['createdb', '', ['-U postgres app']],
['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"]],
];
$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 () {
@@ -568,6 +587,46 @@ test('rejects unsupported PostgreSQL backups containing all databases before dro
->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));