dump-safe-full.sh 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454
  1. #!/usr/bin/env bash
  2. set -euo pipefail
  3. SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
  4. FORGE_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
  5. HOST="127.0.0.1"
  6. PORT="3306"
  7. DATABASE="forge_admin_new"
  8. USER="root"
  9. PASSWORD="${MYSQL_PWD:-}"
  10. SSL_MODE=""
  11. OUTPUT=""
  12. OUTPUT_DIR="$FORGE_DIR/db/backup"
  13. GZIP_OUTPUT="false"
  14. INCLUDE_LOG_DATA="false"
  15. INCLUDE_SENSITIVE_DATA="false"
  16. EXCLUDE_DEPENDENT_DATA="true"
  17. DRY_RUN="false"
  18. EXTRA_IGNORE_TABLES=()
  19. EXTRA_IGNORE_REGEXES=()
  20. usage() {
  21. cat <<'USAGE'
  22. Usage: dump-safe-full.sh [options]
  23. Exports one SQL file containing DDL and safe INSERT data.
  24. DDL and data are skipped for runtime/history tables that are rebuilt or not
  25. needed during normal restore, including act_* Flowable tables and *_version
  26. tables. Data is skipped for log/runtime tables and sensitive configuration
  27. tables by default.
  28. Options:
  29. --host HOST MySQL host, default 127.0.0.1
  30. --port PORT MySQL port, default 3306
  31. --database DATABASE Database name, default forge_admin_new
  32. --user USER MySQL user, default root
  33. --password PASSWORD MySQL password. Prefer MYSQL_PWD env in CI.
  34. --ssl-mode MODE MySQL SSL mode, e.g. REQUIRED for secure connection
  35. --output FILE Output .sql file path
  36. --output-dir DIR Output directory when --output is not set
  37. --gzip Compress output to .gz
  38. --include-log-data Also dump data from log/runtime tables
  39. --include-sensitive-data Also dump data from sensitive config tables
  40. --no-dependent-exclude Do not exclude child tables that reference ignored tables
  41. --ignore-data-table TABLE Skip INSERT data for a table. Can be repeated.
  42. --ignore-data-regex REGEX Skip INSERT data for tables matching regex. Can be repeated.
  43. --dry-run Print planned excluded data tables and commands only
  44. -h, --help Show help
  45. Examples:
  46. MYSQL_PWD='secret' ./forge-server/scripts/db/dump-safe-full.sh \
  47. --host 127.0.0.1 --port 3306 --database forge_admin_new --user root --ssl-mode REQUIRED
  48. ./forge-server/scripts/db/dump-safe-full.sh \
  49. --database forge_admin_new --ignore-data-table sys_user --gzip
  50. USAGE
  51. }
  52. die() {
  53. echo "ERROR: $*" >&2
  54. exit 1
  55. }
  56. require_value() {
  57. local option="$1"
  58. local value="${2:-}"
  59. [[ -n "$value" && "$value" != --* ]] || die "$option requires a value."
  60. }
  61. validate_identifier() {
  62. local value="$1"
  63. local label="$2"
  64. [[ "$value" =~ ^[A-Za-z0-9_]+$ ]] || die "$label must contain only letters, numbers and underscore: $value"
  65. }
  66. escape_sql_literal() {
  67. printf "%s" "$1" | sed "s/'/''/g"
  68. }
  69. while [[ $# -gt 0 ]]; do
  70. case "$1" in
  71. --host)
  72. require_value "$1" "${2:-}"
  73. HOST="$2"
  74. shift 2
  75. ;;
  76. --port)
  77. require_value "$1" "${2:-}"
  78. PORT="$2"
  79. shift 2
  80. ;;
  81. --database)
  82. require_value "$1" "${2:-}"
  83. DATABASE="$2"
  84. shift 2
  85. ;;
  86. --user)
  87. require_value "$1" "${2:-}"
  88. USER="$2"
  89. shift 2
  90. ;;
  91. --password)
  92. require_value "$1" "${2:-}"
  93. PASSWORD="$2"
  94. shift 2
  95. ;;
  96. --ssl-mode)
  97. require_value "$1" "${2:-}"
  98. SSL_MODE="$2"
  99. shift 2
  100. ;;
  101. --output)
  102. require_value "$1" "${2:-}"
  103. OUTPUT="$2"
  104. shift 2
  105. ;;
  106. --output-dir)
  107. require_value "$1" "${2:-}"
  108. OUTPUT_DIR="$2"
  109. shift 2
  110. ;;
  111. --gzip)
  112. GZIP_OUTPUT="true"
  113. shift
  114. ;;
  115. --include-log-data)
  116. INCLUDE_LOG_DATA="true"
  117. shift
  118. ;;
  119. --include-sensitive-data)
  120. INCLUDE_SENSITIVE_DATA="true"
  121. shift
  122. ;;
  123. --no-dependent-exclude)
  124. EXCLUDE_DEPENDENT_DATA="false"
  125. shift
  126. ;;
  127. --ignore-data-table)
  128. require_value "$1" "${2:-}"
  129. validate_identifier "$2" "--ignore-data-table"
  130. EXTRA_IGNORE_TABLES+=("$2")
  131. shift 2
  132. ;;
  133. --ignore-data-regex)
  134. require_value "$1" "${2:-}"
  135. EXTRA_IGNORE_REGEXES+=("$2")
  136. shift 2
  137. ;;
  138. --dry-run)
  139. DRY_RUN="true"
  140. shift
  141. ;;
  142. -h|--help)
  143. usage
  144. exit 0
  145. ;;
  146. *)
  147. die "Unknown argument: $1"
  148. ;;
  149. esac
  150. done
  151. validate_identifier "$DATABASE" "--database"
  152. command -v mysql >/dev/null 2>&1 || die "mysql client is required."
  153. command -v mysqldump >/dev/null 2>&1 || die "mysqldump is required."
  154. if [[ "$GZIP_OUTPUT" == "true" ]]; then
  155. command -v gzip >/dev/null 2>&1 || die "gzip is required when --gzip is set."
  156. fi
  157. MYSQL_BASE=(mysql --protocol=tcp --host="$HOST" --port="$PORT" --user="$USER" --batch --raw --skip-column-names)
  158. MYSQLDUMP_BASE=(mysqldump --protocol=tcp --host="$HOST" --port="$PORT" --user="$USER" --default-character-set=utf8mb4)
  159. if [[ -n "$SSL_MODE" ]]; then
  160. MYSQL_BASE+=(--ssl-mode="$SSL_MODE")
  161. MYSQLDUMP_BASE+=(--ssl-mode="$SSL_MODE")
  162. fi
  163. run_mysql() {
  164. if [[ -n "$PASSWORD" ]]; then
  165. MYSQL_PWD="$PASSWORD" "${MYSQL_BASE[@]}" "$@"
  166. else
  167. "${MYSQL_BASE[@]}" "$@"
  168. fi
  169. }
  170. run_mysqldump() {
  171. if [[ -n "$PASSWORD" ]]; then
  172. MYSQL_PWD="$PASSWORD" "${MYSQLDUMP_BASE[@]}" "$@"
  173. else
  174. "${MYSQLDUMP_BASE[@]}" "$@"
  175. fi
  176. }
  177. print_command() {
  178. printf ' '
  179. printf '%q ' "$@"
  180. echo
  181. }
  182. COMMON_DUMP_OPTS=(
  183. --single-transaction
  184. --skip-lock-tables
  185. --quick
  186. --hex-blob
  187. --complete-insert
  188. )
  189. if mysqldump --help 2>/dev/null | grep -q -- "--set-gtid-purged"; then
  190. COMMON_DUMP_OPTS+=(--set-gtid-purged=OFF)
  191. fi
  192. if mysqldump --help 2>/dev/null | grep -q -- "--column-statistics"; then
  193. COMMON_DUMP_OPTS+=(--column-statistics=0)
  194. fi
  195. timestamp="$(date +%Y%m%d_%H%M%S)"
  196. if [[ -z "$OUTPUT" ]]; then
  197. mkdir -p "$OUTPUT_DIR"
  198. OUTPUT="$OUTPUT_DIR/${DATABASE}_safe_full_${timestamp}.sql"
  199. fi
  200. if [[ "$GZIP_OUTPUT" == "true" && "$OUTPUT" != *.gz ]]; then
  201. OUTPUT="${OUTPUT}.gz"
  202. fi
  203. full_ignore_regex="^act_|(^|_)version$"
  204. log_regex="(^|_)log($|_)|(^|_)logs($|_)|_log$|^log_|_history$|^qrtz_(fired_triggers|scheduler_state|locks)$|^worker_node$|^sys_auth_online_user$|^ai_crud_export_task$|^ai_chat_(record|session)$|^ai_dashboard_generate_record$"
  205. sensitive_tables=(
  206. ai_business_message_channel
  207. ai_model
  208. ai_report_data_connection
  209. ai_provider
  210. config_properties
  211. gen_datasource
  212. sys_api_config
  213. sys_email_config
  214. sys_file_metadata
  215. sys_file_storage_config
  216. sys_sms_config
  217. sys_social_config
  218. sys_user_social
  219. )
  220. where_parts=()
  221. if [[ "$INCLUDE_LOG_DATA" != "true" ]]; then
  222. where_parts+=("LOWER(table_name) REGEXP '$(escape_sql_literal "$log_regex")'")
  223. fi
  224. if [[ "$INCLUDE_SENSITIVE_DATA" != "true" ]]; then
  225. sensitive_in=""
  226. for table in "${sensitive_tables[@]}"; do
  227. if [[ -n "$sensitive_in" ]]; then
  228. sensitive_in+=","
  229. fi
  230. sensitive_in+="'$(escape_sql_literal "$table")'"
  231. done
  232. where_parts+=("LOWER(table_name) IN ($sensitive_in)")
  233. fi
  234. if [[ ${#EXTRA_IGNORE_TABLES[@]} -gt 0 ]]; then
  235. for table in "${EXTRA_IGNORE_TABLES[@]}"; do
  236. where_parts+=("LOWER(table_name) = LOWER('$(escape_sql_literal "$table")')")
  237. done
  238. fi
  239. if [[ ${#EXTRA_IGNORE_REGEXES[@]} -gt 0 ]]; then
  240. for regex in "${EXTRA_IGNORE_REGEXES[@]}"; do
  241. where_parts+=("LOWER(table_name) REGEXP '$(escape_sql_literal "$regex")'")
  242. done
  243. fi
  244. base_ignore_condition="FALSE"
  245. if [[ ${#where_parts[@]} -gt 0 ]]; then
  246. base_ignore_condition=""
  247. for part in "${where_parts[@]}"; do
  248. if [[ -z "$base_ignore_condition" ]]; then
  249. base_ignore_condition="($part)"
  250. else
  251. base_ignore_condition="$base_ignore_condition OR ($part)"
  252. fi
  253. done
  254. fi
  255. if [[ "$EXCLUDE_DEPENDENT_DATA" == "true" ]]; then
  256. ignore_query="
  257. WITH RECURSIVE ignored(table_name) AS (
  258. SELECT table_name
  259. FROM information_schema.tables
  260. WHERE table_schema = DATABASE()
  261. AND table_type = 'BASE TABLE'
  262. AND ($base_ignore_condition)
  263. UNION
  264. SELECT kcu.table_name
  265. FROM information_schema.key_column_usage kcu
  266. JOIN ignored i ON i.table_name = kcu.referenced_table_name
  267. WHERE kcu.table_schema = DATABASE()
  268. AND kcu.referenced_table_schema = DATABASE()
  269. AND kcu.referenced_table_name IS NOT NULL
  270. )
  271. SELECT DISTINCT table_name FROM ignored ORDER BY table_name;"
  272. else
  273. ignore_query="
  274. SELECT table_name
  275. FROM information_schema.tables
  276. WHERE table_schema = DATABASE()
  277. AND table_type = 'BASE TABLE'
  278. AND ($base_ignore_condition)
  279. ORDER BY table_name;"
  280. fi
  281. echo "Checking MySQL connection for database $DATABASE ..."
  282. run_mysql "$DATABASE" --execute="SELECT 1" >/dev/null
  283. full_ignore_query="
  284. SELECT table_name
  285. FROM information_schema.tables
  286. WHERE table_schema = DATABASE()
  287. AND table_type = 'BASE TABLE'
  288. AND LOWER(table_name) REGEXP '$(escape_sql_literal "$full_ignore_regex")'
  289. ORDER BY table_name;"
  290. full_ignore_tables=()
  291. while IFS= read -r table; do
  292. [[ -n "$table" ]] && full_ignore_tables+=("$table")
  293. done < <(run_mysql "$DATABASE" --execute="$full_ignore_query")
  294. full_ignore_opts=()
  295. if [[ ${#full_ignore_tables[@]} -gt 0 ]]; then
  296. for table in "${full_ignore_tables[@]}"; do
  297. full_ignore_opts+=(--ignore-table="$DATABASE.$table")
  298. done
  299. fi
  300. ignored_tables=()
  301. while IFS= read -r table; do
  302. [[ -n "$table" ]] && ignored_tables+=("$table")
  303. done < <(run_mysql "$DATABASE" --execute="$ignore_query")
  304. ignore_opts=()
  305. if [[ ${#ignored_tables[@]} -gt 0 ]]; then
  306. for table in "${ignored_tables[@]}"; do
  307. ignore_opts+=(--ignore-table="$DATABASE.$table")
  308. done
  309. fi
  310. echo "Database: $DATABASE"
  311. echo "Output: $OUTPUT"
  312. echo "Fully skipped tables: ${#full_ignore_tables[@]}"
  313. if [[ ${#full_ignore_tables[@]} -gt 0 ]]; then
  314. for table in "${full_ignore_tables[@]}"; do
  315. echo " - $table"
  316. done
  317. fi
  318. echo "Data skipped tables: ${#ignored_tables[@]}"
  319. if [[ ${#ignored_tables[@]} -gt 0 ]]; then
  320. for table in "${ignored_tables[@]}"; do
  321. echo " - $table"
  322. done
  323. fi
  324. if [[ "$DRY_RUN" == "true" ]]; then
  325. schema_command=("${MYSQLDUMP_BASE[@]}" "${COMMON_DUMP_OPTS[@]}" --routines --events --triggers --no-data)
  326. data_command=("${MYSQLDUMP_BASE[@]}" "${COMMON_DUMP_OPTS[@]}" --no-create-info --skip-triggers --order-by-primary)
  327. if [[ ${#full_ignore_opts[@]} -gt 0 ]]; then
  328. schema_command+=("${full_ignore_opts[@]}")
  329. data_command+=("${full_ignore_opts[@]}")
  330. fi
  331. schema_command+=("$DATABASE")
  332. if [[ ${#ignore_opts[@]} -gt 0 ]]; then
  333. data_command+=("${ignore_opts[@]}")
  334. fi
  335. data_command+=("$DATABASE")
  336. echo
  337. echo "Schema command:"
  338. print_command "${schema_command[@]}"
  339. echo "Data command:"
  340. print_command "${data_command[@]}"
  341. exit 0
  342. fi
  343. mkdir -p "$(dirname "$OUTPUT")"
  344. tmp_output="${OUTPUT%.gz}.tmp"
  345. cleanup() {
  346. rm -f "$tmp_output"
  347. }
  348. trap cleanup EXIT
  349. {
  350. echo "-- Forge safe full dump"
  351. echo "-- Database: $DATABASE"
  352. echo "-- Generated at: $(date '+%Y-%m-%d %H:%M:%S %z')"
  353. echo "-- DDL: all tables, routines, events and triggers"
  354. echo "-- Data: INSERT statements, excluding full-skip, log/runtime and sensitive tables listed below"
  355. echo "--"
  356. echo "-- Fully skipped tables:"
  357. if [[ ${#full_ignore_tables[@]} -eq 0 ]]; then
  358. echo "-- none"
  359. else
  360. for table in "${full_ignore_tables[@]}"; do
  361. echo "-- $table"
  362. done
  363. fi
  364. echo "--"
  365. echo "-- Data skipped tables:"
  366. if [[ ${#ignored_tables[@]} -eq 0 ]]; then
  367. echo "-- none"
  368. else
  369. for table in "${ignored_tables[@]}"; do
  370. echo "-- $table"
  371. done
  372. fi
  373. echo
  374. echo "-- Restore with: mysql <options> $DATABASE < this_file.sql"
  375. echo "SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0;"
  376. echo "SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0;"
  377. echo
  378. echo "-- Schema"
  379. schema_dump_args=("${COMMON_DUMP_OPTS[@]}" --routines --events --triggers --no-data)
  380. if [[ ${#full_ignore_opts[@]} -gt 0 ]]; then
  381. schema_dump_args+=("${full_ignore_opts[@]}")
  382. fi
  383. schema_dump_args+=("$DATABASE")
  384. run_mysqldump "${schema_dump_args[@]}"
  385. echo
  386. echo "-- Data"
  387. data_dump_args=("${COMMON_DUMP_OPTS[@]}" --no-create-info --skip-triggers --order-by-primary)
  388. if [[ ${#full_ignore_opts[@]} -gt 0 ]]; then
  389. data_dump_args+=("${full_ignore_opts[@]}")
  390. fi
  391. if [[ ${#ignore_opts[@]} -gt 0 ]]; then
  392. data_dump_args+=("${ignore_opts[@]}")
  393. fi
  394. data_dump_args+=("$DATABASE")
  395. run_mysqldump "${data_dump_args[@]}"
  396. echo
  397. echo "SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS;"
  398. echo "SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS;"
  399. } > "$tmp_output"
  400. if [[ "$GZIP_OUTPUT" == "true" ]]; then
  401. gzip -c "$tmp_output" > "$OUTPUT"
  402. else
  403. mv "$tmp_output" "$OUTPUT"
  404. fi
  405. trap - EXIT
  406. rm -f "$tmp_output"
  407. echo "Dump completed: $OUTPUT"