From 5d547f04ecd29856fd3d314ae95bccf2e8e7c795 Mon Sep 17 00:00:00 2001 From: Kevin Bataille Date: Sat, 25 Jul 2026 19:16:13 +0200 Subject: [PATCH] feat: add single-database restore mode to restore.sh --- restore.sh | 55 ++++++++++++++++++++++++++++++++++++++++ tests/test_restore.sh | 58 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+) diff --git a/restore.sh b/restore.sh index c6034df..648638a 100755 --- a/restore.sh +++ b/restore.sh @@ -86,6 +86,61 @@ cmd_file() { log "Restored file available at: $final" } +detect_client() { + if docker exec "$DB_CONTAINER" sh -c 'command -v mariadb' >/dev/null 2>&1; then + CLIENT_BIN="mariadb" + else + CLIENT_BIN="mysql" + fi +} + +get_root_creds() { + if [[ -n "${MYSQL_ROOT_PASSWORD:-}" ]]; then + DB_PASS="$MYSQL_ROOT_PASSWORD" + elif [[ -r "$ROOT_PASSWORD_FILE" ]]; then + DB_PASS="$(< "$ROOT_PASSWORD_FILE")" + else + die "no root DB password: set MYSQL_ROOT_PASSWORD or create $ROOT_PASSWORD_FILE (chmod 600)" + fi +} + +confirm_or_abort() { + local dbname="$1" typed + [[ "$YES" == true ]] && return 0 + echo "This will DROP/overwrite database '$dbname'. Type the database name to confirm:" + read -r typed + [[ "$typed" == "$dbname" ]] || die "confirmation did not match '$dbname' - aborting" +} + +restore_single_db() { + local sqlfile="$1" dbname="$2" + [[ -s "$sqlfile" ]] || die "dump file missing or empty: $sqlfile" + run_cmd docker exec -e MYSQL_PWD="$DB_PASS" "$DB_CONTAINER" \ + "$CLIENT_BIN" -u root -e "CREATE DATABASE IF NOT EXISTS \`$dbname\`;" + log "[RUN] docker exec -i ... $CLIENT_BIN -u root $dbname < $sqlfile" + docker exec -i -e MYSQL_PWD="$DB_PASS" "$DB_CONTAINER" \ + "$CLIENT_BIN" -u root "$dbname" < "$sqlfile" +} + +cmd_db() { + local dbname="$1" dumpfile dest + resolve_archive + step "Restoring database '$dbname' from archive $RESOLVED_ARCHIVE" + if [[ "$DRY_RUN" == true ]]; then + log "[DRY-RUN] would extract ${ARCHIVE_TARGET_PATH}/${DUMP_SUBDIR}/${dbname}.sql from ${REPO}::${RESOLVED_ARCHIVE}" + log "[DRY-RUN] would DROP/recreate database '$dbname' and import the dump using root credentials" + return 0 + fi + confirm_or_abort "$dbname" + dest="/tmp/restore-db-$$" + dumpfile="$(extract_path "${DUMP_SUBDIR}/${dbname}.sql" "$dest" "$RESOLVED_ARCHIVE" | tail -n1)" + detect_client + get_root_creds + restore_single_db "$dumpfile" "$dbname" + log "Database '$dbname' restored from $dumpfile" + rm -rf "$dest" +} + usage() { cat <<'EOF' Usage: diff --git a/tests/test_restore.sh b/tests/test_restore.sh index ca509ac..fe0dfb2 100755 --- a/tests/test_restore.sh +++ b/tests/test_restore.sh @@ -91,12 +91,70 @@ test_file_restore_dry_run_makes_no_borg_call() { rm -rf "$mockdir" "$dest" } +test_db_restore_with_yes_runs_full_sequence() { + local mockdir out bashenv + mockdir="$(mktemp -d)" + setup_mock_bin "$mockdir" + echo "rootpass" > "$mockdir/rootpw" + # This host may have a real `docker` CLI in one of the system dirs that + # restore.sh's hardened PATH prepends (e.g. /usr/local/bin), which would + # shadow the mock and make a real (unreachable) docker daemon get called + # instead. A BASH_ENV-sourced function takes precedence over PATH lookup + # regardless of PATH ordering, so force `docker` to the mock that way. + bashenv="$mockdir/bash_env.sh" + cat > "$bashenv" </dev/null 2>&1 + rc=$? + set -e + assert_eq "1" "$rc" "db mode aborts on mismatched confirmation" + rm -rf "$mockdir" +} + test_help_exits_zero test_no_args_exits_one test_unknown_command_exits_one test_list_archives test_file_restore_extracts_to_dest test_file_restore_dry_run_makes_no_borg_call +test_db_restore_with_yes_runs_full_sequence +test_db_restore_dry_run_skips_confirmation_and_calls +test_db_restore_aborts_on_wrong_confirmation echo "-----" if [[ "$FAILURES" -gt 0 ]]; then