&2; exit 1; } is_gzip() { [ "$(head -c 2 "$backup" | od -An -tx1 | tr -d ' \n')" = 1f8b ]; } stream() { if is_gzip; then gunzip -c "$backup"; else cat "$backup"; fi; } header() { stream | head -c "$1" | od -An -tx1 | tr -d ' \n'; } is_tar() { [ "$(stream | head -c 262 | tail -c 5)" = ustar ]; } is_text() { [ "$(stream | head -c 65536 | tr -d '\000' | wc -c | tr -d ' ')" = "$(stream | head -c 65536 | wc -c | tr -d ' ')" ]; } extract_tar() { work=$(mktemp -d) && trap 'rm -rf "$work"' EXIT && stream | tar -xf - -C "$work" || fail 'The tar backup cannot be extracted.'; } use_single_tar_member() { [ "$(find "$work" -type f | wc -l | tr -d ' ')" = 1 ] && backup=$(find "$work" -type f); } SH; public function buildRestoreCommand(object $resource, string $path, bool $dumpAll, bool $replaceExisting = false): string { $script = match ($this->databaseType($resource)) { 'postgresql' => $dumpAll ? $this->postgresqlDumpAll() : $this->postgresqlSingle($replaceExisting), 'mysql' => $this->mysql('mysql', 'MYSQL', $dumpAll), 'mariadb' => $this->mysql('mariadb', 'MARIADB', $dumpAll), 'mongodb' => $this->mongodb($replaceExisting), 'sqlite' => $this->sqlite($resource->databaseFilePath()), default => throw new InvalidArgumentException('Database import is not supported for this database type.'), }; return 'backup='.escapeshellarg($path)."\n".self::PRELUDE.$script; } /** * Prepares a backup inside the Coolify helper image. bz2, xz and single-file zip * backups are decompressed because database images do not ship those tools; * plain and gzip backups are moved unchanged. Exits non-zero on any failure. */ public function buildNormalizeScript(string $source, string $target): string { $source = escapeshellarg($source); $target = escapeshellarg($target); return << "\$out" || { echo 'The bz2 backup cannot be decompressed.' >&2; exit 1; } ;; fd377a585a00) unxz -c "\$f" > "\$out" || { echo 'The xz backup cannot be decompressed.' >&2; exit 1; } ;; 504b0304*) d=\$(mktemp -d) && unzip -q "\$f" -d "\$d" || { echo 'The zip backup cannot be extracted.' >&2; exit 1; } [ "\$(find "\$d" -type f | wc -l | tr -d ' ')" = 1 ] || { echo 'A zip backup must contain exactly one file.' >&2; exit 1; } mv "\$(find "\$d" -type f)" "\$out" ;; *) mv "\$f" "\$out" ;; esac SH; } public function buildPostgresRestoreScanScript(object $resource, string $path): ?string { if ($this->databaseType($resource) !== 'postgresql') { return null; } $escapedPath = escapeshellarg($path); // Token separator PostgreSQL treats as whitespace: real whitespace or a // /* ... */ block comment (used to split keywords like FROM/**/PROGRAM). $sep = '([[:space:]]|/\\*[^*]*\\*/)'; $sqlPattern = "(^|;){$sep}*copy{$sep}+[^;]*(from|to){$sep}+program"; $psqlPattern = "^{$sep}*\\\\(!|copy{$sep}+[^[:space:]]+.*{$sep}+program|(o|g){$sep}*\\|)"; $escapedSqlPattern = escapeshellarg($sqlPattern); $escapedPsqlPattern = escapeshellarg($psqlPattern); $contents = "{ gunzip -cf {$escapedPath} 2>/dev/null || cat {$escapedPath}; }"; $scan = static fn (string $source): string => "{$source} | sed 's/--.*//' | grep -Eiq {$escapedPsqlPattern} || {$source} | sed 's/--.*//' | tr '\\n\\r\\t' ' ' | grep -Eiq {$escapedSqlPattern}"; $customScan = $scan('pg_restore -f - "$inspect" 2>/dev/null'); $sqlScan = $scan($contents); $blockedProgram = 'echo \'Blocked PostgreSQL restore: COPY ... PROGRAM and psql shell commands are not allowed.\'; exit 1'; $blockedInspect = 'echo \'Blocked PostgreSQL restore: unable to inspect custom archive.\'; exit 1'; return << "\$inspect"; then {$blockedInspect} fi if ! pg_restore -l "\$inspect" >/dev/null 2>&1; then {$blockedInspect} fi if {$customScan}; then {$blockedProgram} fi elif {$sqlScan}; then {$blockedProgram} fi SH; } public function buildPostgresSafetyCommand(object $resource, string $container, string $path): ?string { $script = $this->buildPostgresRestoreScanScript($resource, $path); if ($script === null) { return null; } return 'docker exec '.$container.' sh -c '.escapeshellarg($script); } public function supports(object $resource): bool { return in_array($this->databaseType($resource), ['postgresql', 'mysql', 'mariadb', 'mongodb', 'sqlite'], true); } public function databaseType(object $resource): string { $class = $resource->getMorphClass(); $type = ($resource instanceof ServiceDatabase || str_contains(strtolower($class), 'service')) ? strtolower($resource->databaseType()) : strtolower($class); return match (true) { str_contains($type, 'postgres') => 'postgresql', str_contains($type, 'mariadb') => 'mariadb', str_contains($type, 'mysql') => 'mysql', str_contains($type, 'mongo') => 'mongodb', str_contains($type, 'sqlite') => 'sqlite', default => 'unsupported', }; } /** * pg_dump custom and tar archives are restored with pg_restore, SQL dumps with psql. * pg_restore cannot read gzip files, so both clients receive the backup on stdin. * SQL cannot replace single objects, so replacing recreates the target database. */ private function postgresqlSingle(bool $replaceExisting): string { // Every restore runs in one transaction, so a failure part-way rolls back and leaves the // current data as it was, also when --clean dropped objects first. $clean = $replaceExisting ? ' --clean --if-exists' : ''; $sqlRestore = $replaceExisting ? <<<'SH' echo 'SQL backups cannot replace single objects. The backup is restored into a new database first; the current database is replaced only when that restore succeeds.' new=coolify_restore_new old=coolify_restore_old PGOPTIONS='-c client_min_messages=warning' dropdb --maintenance-db=template1 -U $POSTGRES_USER --if-exists "$new" || exit 1 createdb -U $POSTGRES_USER "$new" || exit 1 if ! stream | psql -v ON_ERROR_STOP=1 --single-transaction -U $POSTGRES_USER -d "$new"; then PGOPTIONS='-c client_min_messages=warning' dropdb --maintenance-db=template1 -U $POSTGRES_USER --if-exists "$new" fail 'The SQL restore failed. The current database was not changed.' fi PGOPTIONS='-c client_min_messages=warning' dropdb --maintenance-db=template1 -U $POSTGRES_USER --if-exists "$old" || exit 1 if ! printf '%s\n' "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = :'db' AND pid <> pg_backend_pid();" 'ALTER DATABASE :"db" RENAME TO :"old";' 'ALTER DATABASE :"new" RENAME TO :"db";' | psql -v ON_ERROR_STOP=1 -v db="$db" -v old="$old" -v new="$new" -U $POSTGRES_USER -d template1 >/dev/null; then # Undo a half-done swap: the current database keeps its name and data. printf '%s\n' 'ALTER DATABASE :"old" RENAME TO :"db";' | psql -v db="$db" -v old="$old" -U $POSTGRES_USER -d template1 >/dev/null 2>&1 PGOPTIONS='-c client_min_messages=warning' dropdb --maintenance-db=template1 -U $POSTGRES_USER --if-exists "$new" fail 'The current database is in use and could not be replaced. Nothing was changed.' fi PGOPTIONS='-c client_min_messages=warning' dropdb --maintenance-db=template1 -U $POSTGRES_USER --if-exists "$old" || exit 1 SH : <<<'SH' stream | psql -v ON_ERROR_STOP=1 --single-transaction -U $POSTGRES_USER -d "$db" SH; return <</dev/null 2>&1 || fail 'pg_restore cannot read this backup archive. Nothing was changed.' elif is_text; then kind=sql else fail 'Unsupported PostgreSQL backup format. Use a pg_dump archive (custom or tar format) or an SQL file. Nothing was changed.' fi psql -U ${POSTGRES_USER} -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname IS NOT NULL AND pid <> pg_backend_pid()" || exit 1 psql -U ${POSTGRES_USER} -t -c "SELECT datname FROM pg_database WHERE NOT datistemplate" | xargs -I {} dropdb -U ${POSTGRES_USER} --if-exists {} || exit 1 createdb -U ${POSTGRES_USER} "$db" || exit 1 if [ "$kind" = archive ]; then stream | pg_restore -U ${POSTGRES_USER} -d "$db" else stream | psql -U ${POSTGRES_USER} -d "$db" fi SH; } /** * MySQL and MariaDB restore SQL dumps. A tar backup must wrap exactly one dump. * The all-databases mode checks the backup before it drops any database. */ private function mysql(string $binary, string $prefix, bool $dumpAll): string { $preflight = <<<'SH' if is_tar; then extract_tar use_single_tar_member || fail 'A tar backup must contain exactly one SQL dump. Nothing was changed.' fi is_text || fail 'Unsupported backup format. Use an SQL dump. Nothing was changed.' SH; if (! $dumpAll) { return $preflight.<</dev/null || true; done {$root} -N -e "SELECT CONCAT('DROP DATABASE IF EXISTS \\`',schema_name,'\\`;') FROM information_schema.schemata WHERE schema_name NOT IN ('information_schema','mysql','performance_schema','sys');" | {$root} || exit 1 {$root} -e "CREATE DATABASE IF NOT EXISTS \\`{$database}\\`;" || exit 1 stream | {$root} {$database} SH; } /** * MongoDB restores mongodump archives (plain or gzip) and dump directories packed * as tar. Replacing existing data drops each restored collection first. */ private function mongodb(bool $replaceExisting): string { $drop = $replaceExisting ? ' --drop' : ''; return << "\$backup.db" || fail 'The backup cannot be read. Nothing was changed.' sqlite3 -bail {$file} '.timeout 10000' ".restore \$backup.db"; status=\$?; rm -f "\$backup.db"; exit \$status SH; } }