diff --git a/scripts/da-integration/alt_mysql_native_backup_restore.sh b/scripts/da-integration/alt_mysql_native_backup_restore.sh index c3f126d..5dfde57 100755 --- a/scripts/da-integration/alt_mysql_native_backup_restore.sh +++ b/scripts/da-integration/alt_mysql_native_backup_restore.sh @@ -127,10 +127,188 @@ parse_native_db_conf() { return 0 } +mysql_import_sql_file() { + local database="$1" file="$2" + if [[ "$file" == *.gz ]]; then + gunzip -c "$file" | mysql_exec "$database" + return "${PIPESTATUS[1]}" + fi + mysql_exec "$database" < "$file" +} + +verify_database_exists() { + local db="$1" + [ -n "$(mysql_exec mysql -Nse "SELECT 1 FROM information_schema.SCHEMATA WHERE SCHEMA_NAME='$(mysql_escape_literal "$db")' LIMIT 1")" ] \ + || die "database was not restored: $db" +} + +rebuild_metadata_for_native_import() { + local db="$1" da_user="$2" + local i owner="" role host + for i in "${!NATIVE_DB_USER_NAMES[@]}"; do + role="${NATIVE_DB_USER_NAMES[$i]}" + host="${NATIVE_DB_USER_HOST[$i]:-localhost}" + [ -n "$owner" ] || owner="$role" + + mysql_exec mysql -e " + INSERT INTO da_plugin_grant_profiles (db_name, role_name, privileges) + VALUES ('$(mysql_escape_literal "$db")', '$(mysql_escape_literal "$role")', '$(mysql_escape_literal "${NATIVE_DB_USER_PRIVS[$i]}")') + ON DUPLICATE KEY UPDATE privileges=VALUES(privileges), updated_at=CURRENT_TIMESTAMP; + " >> "$LOG_FILE" 2>&1 || true + + mysql_exec mysql -e " + INSERT INTO da_plugin_access_hosts (da_user, role_name, host_pattern, note) + VALUES ('$(mysql_escape_literal "$da_user")', '$(mysql_escape_literal "$role")', '$(mysql_escape_literal "$host")', 'restored from native backup/.conf') + ON DUPLICATE KEY UPDATE da_user=VALUES(da_user), note=VALUES(note), updated_at=CURRENT_TIMESTAMP; + " >> "$LOG_FILE" 2>&1 || true + done + + if [ -n "$owner" ]; then + mysql_exec mysql -e " + INSERT INTO da_plugin_database_owners (db_name, da_user, role_name) + VALUES ('$(mysql_escape_literal "$db")', '$(mysql_escape_literal "$da_user")', '$(mysql_escape_literal "$owner")') + ON DUPLICATE KEY UPDATE da_user=VALUES(da_user), role_name=VALUES(role_name), updated_at=CURRENT_TIMESTAMP; + " >> "$LOG_FILE" 2>&1 || true + fi +} + +import_native_database_from_conf() { + local db="$1" conf_file="$2" sql_file="$3" da_user="$4" overwrite="$5" + + parse_native_db_conf "$conf_file" || { log "SKIP $db: conf did not parse"; return 1; } + + local collation="${NATIVE_DB_COLLATION:-}" + if [ -z "$collation" ]; then + log "SKIP $db: no collation parsed" + return 1 + fi + + local i + for i in "${!NATIVE_DB_USER_PLUGIN[@]}"; do + if [ "${NATIVE_DB_USER_PLUGIN[$i]}" != "mysql_native_password" ]; then + log "SKIP $db: unsupported auth plugin '${NATIVE_DB_USER_PLUGIN[$i]}' for user ${NATIVE_DB_USER_NAMES[$i]}" + return 1 + fi + done + + if [ "$overwrite" = "deny" ] && [ -n "$(mysql_exec mysql -Nse "SELECT 1 FROM information_schema.SCHEMATA WHERE SCHEMA_NAME='$(mysql_escape_literal "$db")' LIMIT 1")" ]; then + log "SKIP $db: already exists and overwrite is denied" + return 1 + fi + + backup_alt_database_for_rollback "$db" || { log "SKIP $db: could not create rollback dump"; return 1; } + + if ! mysql_exec mysql -e "DROP DATABASE IF EXISTS $(mysql_quote_identifier "$db");" >> "$LOG_FILE" 2>&1; then + restore_alt_database_from_rollback "$db" || true + log "SKIP $db: could not drop database before import" + return 1 + fi + if ! mysql_exec mysql -e "CREATE DATABASE $(mysql_quote_identifier "$db") CHARACTER SET $(mysql_safe_identifier "$NATIVE_DB_CHARSET") COLLATE $(mysql_safe_identifier "$collation");" >> "$LOG_FILE" 2>&1; then + restore_alt_database_from_rollback "$db" || true + log "SKIP $db: could not create database before import" + return 1 + fi + + local user host hash + for i in "${!NATIVE_DB_USER_NAMES[@]}"; do + user="${NATIVE_DB_USER_NAMES[$i]}" + host="${NATIVE_DB_USER_HOST[$i]:-localhost}" + hash="${NATIVE_DB_USER_HASH[$i]}" + + if ! { + printf "DROP USER IF EXISTS '%s'@'%s';\n" "$(mysql_escape_literal "$user")" "$(mysql_escape_literal "$host")" + printf "CREATE USER '%s'@'%s' IDENTIFIED WITH mysql_native_password AS '%s';\n" \ + "$(mysql_escape_literal "$user")" "$(mysql_escape_literal "$host")" "$(mysql_escape_literal "$hash")" + printf "GRANT %s ON %s.* TO '%s'@'%s';\n" \ + "${NATIVE_DB_USER_PRIVS[$i]}" "$(mysql_quote_identifier "$db")" "$(mysql_escape_literal "$user")" "$(mysql_escape_literal "$host")" + } | mysql_exec mysql >> "$LOG_FILE" 2>&1; then + restore_alt_database_from_rollback "$db" || true + log "SKIP $db: could not recreate user ${user}@${host}" + return 1 + fi + done + + if ! mysql_import_sql_file "$db" "$sql_file" >> "$LOG_FILE" 2>&1; then + restore_alt_database_from_rollback "$db" || true + log "SKIP $db: could not import dump" + return 1 + fi + + cleanup_restore_rollback + verify_database_exists "$db" + rebuild_metadata_for_native_import "$db" "$da_user" + mysql_exec mysql -e "FLUSH PRIVILEGES;" >> "$LOG_FILE" 2>&1 || true + return 0 +} + +find_native_pairs_in_dir() { + local root="$1" prefix="$2" + local conf base sql + while IFS= read -r conf; do + [ -n "$conf" ] || continue + base="$(basename "$conf" .conf)" + sql="$(dirname "$conf")/${base}.sql" + [ -r "$sql" ] || continue + printf '%s\t%s\t%s\n' "$base" "$conf" "$sql" + done < <(find "$root" -maxdepth 4 -type f -name "${prefix}_*.conf" 2>/dev/null | sort) +} + +main() { + local da_user="" root="" overwrite="allow" + while [ "$#" -gt 0 ]; do + case "$1" in + --user) da_user="${2:-}"; shift 2 ;; + --root) root="${2:-}"; shift 2 ;; + --overwrite) overwrite="${2:-}"; shift 2 ;; + *) die "unknown argument: $1" ;; + esac + done + + [ -n "$da_user" ] || die "--user is required" + [ -n "$root" ] || die "--root is required" + [[ "$da_user" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]*$ ]] || die "unsafe DirectAdmin username: $da_user" + [ -d "$root" ] || die "backup root directory does not exist: $root" + + mysql_load_alt_credentials + mysql_ensure_metadata_schema + + local -a handled=() + local base conf sql + while IFS=$'\t' read -r base conf sql; do + [ -n "$base" ] || continue + handled+=("$base") + if [[ "$base" != "${da_user}_"* ]]; then + printf 'SKIPPED %s wrong-user-prefix\n' "$base" + continue + fi + if import_native_database_from_conf "$base" "$conf" "$sql" "$da_user" "$overwrite"; then + printf 'IMPORTED %s\n' "$base" + else + printf 'SKIPPED %s import-failed\n' "$base" + fi + done < <(find_native_pairs_in_dir "$root" "$da_user") + + # Any *.sql belonging to this user's prefix with no matching .conf was not + # covered by the loop above (find_native_pairs_in_dir only yields pairs + # that have both files) - report those explicitly so the caller knows to + # fall back to native-staging-migrate for them too. + local sql_base + while IFS= read -r sql; do + [ -n "$sql" ] || continue + sql_base="$(basename "$sql" .sql)" + [[ "$sql_base" == "${da_user}_"* ]] || continue + local already=0 h + for h in "${handled[@]}"; do + [ "$h" = "$sql_base" ] && { already=1; break; } + done + [ "$already" -eq 1 ] || printf 'SKIPPED %s no-matching-conf\n' "$sql_base" + done < <(find "$root" -maxdepth 4 -type f -name "${da_user}_*.sql" 2>/dev/null | sort) +} + if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then [ -r "$COMMON_FILE" ] || die "missing helper: $COMMON_FILE" # shellcheck source=../setup/mysql_common.sh source "$COMMON_FILE" mysql_load_plugin_settings_file "$SETTINGS_FILE" - # main() is added in Task 3. + main "$@" fi diff --git a/tests/alt_mysql_native_backup_restore_import_test.sh b/tests/alt_mysql_native_backup_restore_import_test.sh new file mode 100755 index 0000000..0c71142 --- /dev/null +++ b/tests/alt_mysql_native_backup_restore_import_test.sh @@ -0,0 +1,141 @@ +#!/bin/bash +set -euo pipefail + +PLUGIN_DIR="$(cd "$(dirname "$0")/.." && pwd)" +SCRIPT="$PLUGIN_DIR/scripts/da-integration/alt_mysql_native_backup_restore.sh" +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +# --- Fake alt-mariadb client, tracks statements + a simple existing-db state file --- +mkdir -p "$TMP_DIR/mariadb/bin" +cat > "$TMP_DIR/mariadb/bin/mariadb" <<'STUB' +#!/bin/bash +STATE_FILE="${FAKE_DB_STATE_FILE:?FAKE_DB_STATE_FILE must be set}" +CALL_LOG="${FAKE_CALL_LOG:?FAKE_CALL_LOG must be set}" + +sql="" +mode="" +for arg in "$@"; do + case "$arg" in + -e) mode="sql" ;; + -Nse) mode="sql" ;; + --database=*|--user=*|--socket=*|--host=*|--port=*|--protocol=*) ;; + *) + if [ "$mode" = "sql" ]; then + sql="$arg" + mode="" + fi + ;; + esac +done + +if [ -n "$sql" ]; then + printf '%s\n' "$sql" >> "$CALL_LOG" + case "$sql" in + *"SELECT 1 FROM information_schema.SCHEMATA"*) + name="$(printf '%s' "$sql" | sed -n "s/.*SCHEMA_NAME='\([^']*\)'.*/\1/p")" + grep -qx "$name" "$STATE_FILE" 2>/dev/null && echo 1 + ;; + *"DROP DATABASE"*) + name="$(printf '%s' "$sql" | sed -n 's/.*DATABASE[^`]*`\([A-Za-z0-9_]*\)`.*/\1/p')" + [ -n "$name" ] && { grep -vx "$name" "$STATE_FILE" > "$STATE_FILE.tmp" 2>/dev/null || true; mv -f "$STATE_FILE.tmp" "$STATE_FILE"; } + ;; + *"CREATE DATABASE"*) + name="$(printf '%s' "$sql" | sed -n 's/.*DATABASE[^`]*`\([A-Za-z0-9_]*\)`.*/\1/p')" + [ -n "$name" ] && echo "$name" >> "$STATE_FILE" + ;; + esac + exit 0 +fi + +cat >> "$CALL_LOG" +printf 'IMPORT\n' >> "$CALL_LOG" +if [ "${FAKE_IMPORT_FAIL:-0}" = "1" ]; then + exit 1 +fi +exit 0 +STUB +chmod 755 "$TMP_DIR/mariadb/bin/mariadb" + +cat > "$TMP_DIR/mariadb/bin/mariadb-dump" <<'STUB' +#!/bin/bash +echo "-- fake rollback dump" +exit 0 +STUB +chmod 755 "$TMP_DIR/mariadb/bin/mariadb-dump" + +cat > "$TMP_DIR/alt-mysql.conf" < "$TMP_DIR/settings.conf" <.stdout" and +# writes every mariadb-stub SQL statement to "$TMP_DIR/.calls", +# then prints nothing (the two files are read directly by the caller). +run_import() { + local prefix="$1" + shift + local db_state="$TMP_DIR/${prefix}.dbstate" + local call_log="$TMP_DIR/${prefix}.calls" + local stdout_file="$TMP_DIR/${prefix}.stdout" + local restore_log="$TMP_DIR/${prefix}.restore.log" + : > "$db_state" + : > "$call_log" + + DA_ALT_MYSQL_SETTINGS_FILE="$TMP_DIR/settings.conf" \ + DA_ALT_MYSQL_RESTORE_LOG="$restore_log" \ + FAKE_DB_STATE_FILE="$db_state" \ + FAKE_CALL_LOG="$call_log" \ + bash "$SCRIPT" "$@" > "$stdout_file" +} + +# --- Build a fake backup directory with one valid database pair --- +BACKUP_DIR="$TMP_DIR/backup" +mkdir -p "$BACKUP_DIR" +cat > "$BACKUP_DIR/demo_app.conf" <<'EOF' +accesshosts=0=localhost +db_collation=DEFAULT_CHARACTER_SET_NAME=utf8mb4&DEFAULT_COLLATION_NAME=utf8mb4_unicode_ci&SCHEMA_NAME=demo_app +demo_app=accesshosts=localhost&select_priv=Y&insert_priv=Y&passwd=%2Aabc123&plugin=mysql_native_password +EOF +cat > "$BACKUP_DIR/demo_app.sql" <<'EOF' +CREATE TABLE t (id INT); +EOF + +# --- Scenario 1: happy path, single valid database --- +run_import scenario1 --user demo --root "$BACKUP_DIR" --overwrite allow + +grep -q "IMPORTED demo_app" "$TMP_DIR/scenario1.stdout" \ + || fail "expected 'IMPORTED demo_app' in output, got: $(cat "$TMP_DIR/scenario1.stdout")" +grep -q "CREATE USER 'demo_app'@'localhost'" "$TMP_DIR/scenario1.calls" \ + || fail "expected CREATE USER statement for demo_app@localhost" +grep -q "GRANT SELECT,INSERT ON \`demo_app\`.\* TO 'demo_app'@'localhost'" "$TMP_DIR/scenario1.calls" \ + || fail "expected GRANT SELECT,INSERT for demo_app@localhost" +grep -q "IMPORT" "$TMP_DIR/scenario1.calls" || fail "expected the .sql dump to be piped in for import" + +# --- Scenario 2: a database with no matching .conf must be skipped, not abort the run --- +mkdir -p "$TMP_DIR/backup_missing_conf" +cat > "$TMP_DIR/backup_missing_conf/orphan_db.sql" <<'EOF' +CREATE TABLE t (id INT); +EOF + +run_import scenario2 --user orphan --root "$TMP_DIR/backup_missing_conf" --overwrite allow + +grep -q "SKIPPED orphan_db" "$TMP_DIR/scenario2.stdout" \ + || fail "expected orphan_db (no .conf) to be reported as SKIPPED, got: $(cat "$TMP_DIR/scenario2.stdout")" + +echo "alt_mysql_native_backup_restore_import_test: OK"