-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdb-tools.sh
More file actions
4046 lines (3451 loc) · 136 KB
/
db-tools.sh
File metadata and controls
4046 lines (3451 loc) · 136 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env bash
#===============================================================================
#
# db-tools.sh — MySQL/MariaDB Administration Toolkit
# Version: 3.2.2
#
# A comprehensive backup, restore, and maintenance solution for MySQL/MariaDB
# with support for XtraBackup, point-in-time recovery, encryption, and more.
#
#===============================================================================
#
# FEATURES
# --------
# • Physical backups via XtraBackup/MariaBackup (fast, hot backups)
# • Logical backups via mysqldump (portable, cross-version compatible)
# • Incremental backups with automatic base detection
# • Point-in-Time Recovery (PITR) via binary log replay
# • AES-256-GCM encryption for backup files
# • Compression (pigz, zstd, xz, gzip)
# • SHA256 checksums for integrity verification
# • Email and webhook notifications
# • Smart space management:
# - Auto-cleanup old backups when space is low
# - Size-based retention (max total backup size)
# - Disk usage warnings at configurable thresholds
# - Automatic cleanup of orphaned partial files
# - Minimum free space enforcement
# • Database health checks and tuning advisors
# • Safe mode: auto-detects risky conditions (low disk, peak hours)
#
#===============================================================================
#
# INSTALLATION
# ------------
# # One-line install:
# sudo curl -fsSL "https://raw.githubusercontent.com/deforay/utility-scripts/master/db-tools.sh" -o /usr/local/bin/db-tools && sudo chmod +x /usr/local/bin/db-tools
#
# # Or clone the repository:
# git clone https://github.com/deforay/utility-scripts.git
# sudo cp utility-scripts/db-tools.sh /usr/local/bin/db-tools
# sudo chmod +x /usr/local/bin/db-tools
#
#===============================================================================
#
# QUICK START
# -----------
# 1. Initialize (first-time setup - stores credentials securely):
# $ sudo db-tools init
#
# 2. Run a backup:
# $ sudo db-tools backup
#
# 3. Check backup health:
# $ sudo db-tools health
#
# 4. Set up automated backups (add to root's crontab):
# $ sudo crontab -e
#
# # Daily full backup at 2 AM
# 0 2 * * * /usr/local/bin/db-tools backup full >> /var/log/db-tools/backup.log 2>&1
#
# # Incremental every 6 hours
# 0 */6 * * * /usr/local/bin/db-tools backup incremental >> /var/log/db-tools/backup.log 2>&1
#
# # Weekly maintenance on Sunday at 3 AM
# 0 3 * * 0 /usr/local/bin/db-tools maintain full >> /var/log/db-tools/maintain.log 2>&1
#
# # Daily cleanup at 4 AM
# 0 4 * * * /usr/local/bin/db-tools cleanup >> /var/log/db-tools/cleanup.log 2>&1
#
#===============================================================================
#
# COMMANDS
# --------
# init Initialize credentials and install dependencies
# backup [full|incremental] Create backup (XtraBackup by default)
# backup logical Create logical backup (mysqldump)
# restore <DB|ALL|file> Restore from backup (auto-detects type)
# verify Verify backup integrity
# list List available backups
# health Run system health check
# sizes Show database and table sizes
# tune Run tuning advisors
# maintain [quick|full] ANALYZE (quick) or OPTIMIZE (full) tables
# cleanup [days] Remove old backups
# config [path] Generate sample config file
# genkey [path] Generate encryption key
# status Show current operation status
# help Show help message
#
#===============================================================================
#
# CONFIGURATION
# -------------
# Configuration can be set via:
# 1. Environment variables (highest priority)
# 2. Config file: /etc/db-tools.conf
# 3. Local .env file in current directory
#
# Generate a sample config:
# $ sudo db-tools config /etc/db-tools.conf
#
# Key Settings:
# -------------
# BACKUP_DIR Backup storage location (default: /var/backups/mysql)
# RETENTION_DAYS Days to keep backups (default: 7)
# BACKUP_METHOD "xtrabackup" or "mysqldump" (default: xtrabackup)
# COMPRESS_ALGO zstd, pigz, gzip, xz (default: zstd)
# LOGIN_PATH MySQL login path name (default: dbtools)
#
# Encryption:
# -----------
# ENCRYPT_BACKUPS 0=off, 1=on (default: 0)
# ENCRYPTION_KEY_FILE Path to encryption key (default: /etc/db-tools-encryption.key)
#
# # Generate encryption key:
# $ sudo db-tools genkey /etc/db-tools-encryption.key
#
# Notifications:
# --------------
# NOTIFY_EMAIL Email address for notifications
# NOTIFY_WEBHOOK Webhook URL (receives JSON POST)
# NOTIFY_ON "always", "error", "never" (default: error)
#
# Logging:
# --------
# LOG_LEVEL DEBUG, INFO, WARN, ERROR (default: INFO)
# USE_SYSLOG 0=off, 1=on (default: 0)
#
#===============================================================================
#
# RESTORE EXAMPLES
# ----------------
# # Restore latest backup of a specific database:
# $ sudo db-tools restore mydb
#
# # Restore all databases:
# $ sudo db-tools restore ALL
#
# # Restore from a specific backup file:
# $ sudo db-tools restore /var/backups/mysql/mydb-2025-01-15-10-00-00.sql.gz
#
# # Restore with database rename:
# $ sudo db-tools restore /var/backups/mysql/mydb-backup.sql.gz new_database_name
#
# # Restore XtraBackup (auto-detected):
# $ sudo db-tools restore /var/backups/mysql/xtra-full-2025-01-15-10-00-00.tar
#
# Point-in-Time Recovery (PITR):
# ------------------------------
# # Restore to a specific point in time:
# $ UNTIL_TIME="2025-01-15 10:30:00" sudo db-tools restore mydb
#
# # Restore to a specific binlog position:
# $ END_POS=12345 sudo db-tools restore mydb
#
# Safety Options:
# ---------------
# $ DROP_FIRST=1 sudo db-tools restore mydb # Drop existing DB first
# $ FORCE_RESTORE=1 sudo db-tools restore mydb # Skip confirmation prompt
#
#===============================================================================
#
# XTRABACKUP VS MYSQLDUMP
# -----------------------
# XtraBackup (default):
# • Hot backup - no table locking for InnoDB
# • Faster for large databases
# • Supports incremental backups
# • Requires same MySQL version for restore
#
# mysqldump:
# • Portable across MySQL versions
# • Human-readable SQL output
# • Better for smaller databases
# • Can restore individual tables
#
# To use mysqldump instead:
# $ BACKUP_METHOD=mysqldump sudo db-tools backup
# # Or set in /etc/db-tools.conf:
# BACKUP_METHOD="mysqldump"
#
#===============================================================================
#
# MAINTENANCE
# -----------
# Quick maintenance (ANALYZE only - safe, fast):
# $ sudo db-tools maintain quick
#
# Full maintenance (ANALYZE + OPTIMIZE - reclaims disk space):
# $ sudo db-tools maintain full
#
# Safe Mode:
# ----------
# The script auto-detects risky conditions and enables safe mode:
# • Low disk space (< 10GB or < 2x largest table)
# • Peak hours (8 AM - 8 PM by default)
# • High server load (Threads_running > 25)
# • Replication lag (> 120 seconds)
#
# In safe mode, OPTIMIZE is skipped to prevent issues.
#
# Override safe mode:
# $ sudo db-tools maintain full --force
#
# Force safe mode:
# $ sudo db-tools maintain full --safe
#
#===============================================================================
#
# SPACE MANAGEMENT
# ----------------
# The script includes smart disk space management:
#
# View space usage:
# $ sudo db-tools space
#
# Auto-Cleanup Before Backup:
# ---------------------------
# When SPACE_AUTO_CLEANUP=1 (default), the script automatically removes
# old backups if there isn't enough space for a new backup. It respects
# CLEAN_KEEP_MIN to always keep a minimum number of backups per database.
#
# Size-Based Retention:
# ---------------------
# Set SPACE_MAX_USAGE_GB to limit total backup storage:
# $ export SPACE_MAX_USAGE_GB=100 # Max 100GB for backups
#
# Disk Usage Alerts:
# ------------------
# - Warning at SPACE_WARNING_PERCENT (default: 70%)
# - Critical at SPACE_CRITICAL_PERCENT (default: 90%)
# - Alerts sent via email/webhook when thresholds exceeded
#
# Partial File Cleanup:
# ---------------------
# Orphaned .partial files from interrupted backups are automatically
# cleaned up on startup (files older than 1 hour).
#
# Configuration:
# --------------
# SPACE_AUTO_CLEANUP=1 # Enable auto-cleanup (default: 1)
# SPACE_MAX_USAGE_GB=0 # Max backup size, 0=unlimited (default: 0)
# SPACE_WARNING_PERCENT=70 # Warning threshold (default: 70)
# SPACE_CRITICAL_PERCENT=90 # Critical threshold (default: 90)
# SPACE_MIN_FREE_GB=5 # Minimum free space to keep (default: 5)
# CLEAN_KEEP_MIN=2 # Minimum backups per DB (default: 2)
#
#===============================================================================
#
# TROUBLESHOOTING
# ---------------
# Check health status:
# $ sudo db-tools health
#
# View current operation:
# $ sudo db-tools status
#
# Test MySQL connection:
# $ mysql --login-path=dbtools -e "SELECT 1"
#
# Re-initialize credentials:
# $ sudo db-tools init
#
# Check logs:
# $ tail -100 /var/log/db-tools/backup.log
#
# Verify backups:
# $ sudo db-tools verify
#
# Common Issues:
# --------------
# "Login test failed"
# → Re-run: sudo db-tools init
#
# "XtraBackup not found"
# → Set AUTO_INSTALL=1 or install manually:
# apt install mariadb-backup # MariaDB
# apt install percona-xtrabackup-80 # MySQL 8.0
#
# "Insufficient disk space"
# → Free up space or reduce RETENTION_DAYS
# → Run: sudo db-tools cleanup 3
#
# "Lock timeout"
# → Another db-tools instance is running
# → Check: sudo db-tools status
# → Remove stale lock: sudo rm /var/run/db-tools.lock
#
#===============================================================================
#
# SECURITY NOTES
# --------------
# • Credentials are stored securely via mysql_config_editor (encrypted)
# • Backup encryption uses AES-256-GCM (authenticated encryption)
# • Key files must have 600 permissions and be owned by root
# • Config files are validated for safe ownership before loading
# • Database/table names are validated to prevent SQL injection
#
#===============================================================================
#
# LICENSE
# -------
# This script is provided as-is under the MIT License.
# https://github.com/deforay/utility-scripts
#
#===============================================================================
set -euo pipefail
# Version
DB_TOOLS_VERSION="3.5.0"
# ========================== Configuration ==========================
CONFIG_FILE="${CONFIG_FILE:-/etc/db-tools.conf}"
ENV_FILE="${ENV_FILE:-.env}"
BACKUP_DIR="${BACKUP_DIR:-/var/backups/mysql}"
LOG_DIR="${LOG_DIR:-/var/log/db-tools}"
RETENTION_DAYS="${RETENTION_DAYS:-7}"
LOGIN_PATH="${LOGIN_PATH:-dbtools}"
TOP_N_TABLES="${TOP_N_TABLES:-30}"
MARK_DIR="/var/lib/dbtools"
MARK_INIT="$MARK_DIR/init.stamp"
LOCK_FILE="/var/run/db-tools.lock"
if [[ ! -w "$(dirname "$LOCK_FILE")" ]]; then
LOCK_FILE="/tmp/db-tools.lock"
fi
MYSQL_DEFAULTS_FILE="${MYSQL_DEFAULTS_FILE:-/etc/db-tools.my.cnf}" # for XtraBackup auth
# Compression settings
COMPRESS_ALGO="${COMPRESS_ALGO:-zstd}" # zstd, pigz, gzip, xz
COMPRESS_LEVEL="${COMPRESS_LEVEL:-6}"
# Backup types and retention
BACKUP_TYPE="${BACKUP_TYPE:-full}" # full, incremental
KEEP_DAILY="${KEEP_DAILY:-7}"
KEEP_WEEKLY="${KEEP_WEEKLY:-4}"
KEEP_MONTHLY="${KEEP_MONTHLY:-6}"
CLEAN_KEEP_MIN="${CLEAN_KEEP_MIN:-2}"
# Smart space management
SPACE_AUTO_CLEANUP="${SPACE_AUTO_CLEANUP:-1}" # Auto-remove old backups if space low before backup
SPACE_MAX_USAGE_GB="${SPACE_MAX_USAGE_GB:-0}" # Max total backup size in GB (0=unlimited)
SPACE_MAX_USAGE_PERCENT="${SPACE_MAX_USAGE_PERCENT:-80}" # Max % of disk to use for backups
SPACE_WARNING_PERCENT="${SPACE_WARNING_PERCENT:-70}" # Warn when disk usage exceeds this %
SPACE_CRITICAL_PERCENT="${SPACE_CRITICAL_PERCENT:-90}" # Critical alert threshold
SPACE_MIN_FREE_GB="${SPACE_MIN_FREE_GB:-5}" # Minimum free GB to keep on disk
SPACE_CLEANUP_PARTIAL="${SPACE_CLEANUP_PARTIAL:-1}" # Clean orphaned .partial files on startup
# Encryption
ENCRYPT_BACKUPS="${ENCRYPT_BACKUPS:-0}"
ENCRYPTION_KEY_FILE="${ENCRYPTION_KEY_FILE:-}"
# Notifications
NOTIFY_EMAIL="${NOTIFY_EMAIL:-}"
NOTIFY_WEBHOOK="${NOTIFY_WEBHOOK:-}"
NOTIFY_ON="${NOTIFY_ON:-error}" # always, error, never
# Auto-install behavior
AUTO_INSTALL="${AUTO_INSTALL:-1}" # Set to 1 to enable auto-installation of tools
# Logging
LOG_LEVEL="${LOG_LEVEL:-INFO}" # DEBUG, INFO, WARN, ERROR
USE_SYSLOG="${USE_SYSLOG:-0}"
# Other options
DRY_RUN="${DRY_RUN:-0}"
DROP_FIRST="${DROP_FIRST:-0}"
CHECKSUM_ENABLED="${CHECKSUM_ENABLED:-1}"
# Tool paths
MYSQL="${MYSQL:-$(command -v mysql || true)}"
MYSQLDUMP="${MYSQLDUMP:-$(command -v mysqldump || true)}"
MYSQLBINLOG="${MYSQLBINLOG:-$(command -v mysqlbinlog || true)}"
# XtraBackup settings
XTRABACKUP_ENABLED="${XTRABACKUP_ENABLED:-1}"
XTRABACKUP_COMPRESS="${XTRABACKUP_COMPRESS:-1}"
XTRABACKUP_MEMORY="${XTRABACKUP_MEMORY:-1G}" # Memory for prepare phase
BACKUP_METHOD="${BACKUP_METHOD:-xtrabackup}" # xtrabackup or mysqldump
XTRABACKUP="${XTRABACKUP:-$(command -v xtrabackup || command -v mariabackup || true)}"
# Global state
declare -g BACKUP_SUMMARY=()
declare -g OPERATION_START=$(date +%s)
# ===== Auto-safe heuristics (configurable via env) =====
# Minimum free space (GB) on /var/lib/mysql to allow OPTIMIZE
SAFE_MIN_FREE_GB="${SAFE_MIN_FREE_GB:-10}"
# Free space must also be >= RATIO * largest table size
SAFE_MIN_FREE_RATIO="${SAFE_MIN_FREE_RATIO:-2.0}"
# Consider server "busy" if Threads_running exceeds this
SAFE_MAX_THREADS_RUNNING="${SAFE_MAX_THREADS_RUNNING:-25}"
# Treat these hours (local time) as "peak"; OPTIMIZE avoided unless --force
# Comma-separated 24h hour numbers, e.g. "8-20" = 08:00..20:59
SAFE_PEAK_HOURS="${SAFE_PEAK_HOURS:-8-20}"
# Auto-safe on (1) / off (0)
MAINT_SAFE_AUTO="${MAINT_SAFE_AUTO:-1}"
# --- helpers ---
# Stack new commands onto an existing trap on a signal (default EXIT)
stack_trap() {
local new_cmd="$1" sig="${2:-EXIT}"
local old_cmd
old_cmd="$(trap -p "$sig" | sed -E "s/.*'(.+)'/\1/")"
if [[ -n "$old_cmd" && "$old_cmd" != "trap -- '' $sig" ]]; then
trap "$old_cmd; $new_cmd" "$sig"
else
trap "$new_cmd" "$sig"
fi
}
get_free_mb() {
# Arg: path; prints integer MB free (0 on error)
local p="${1:-/var/lib/mysql}"
df -BM "$p" 2>/dev/null | awk 'NR==2{gsub(/M/,"",$4); print int($4)}'
}
get_largest_table_mb() {
# Uses information_schema; returns MB (integer, 0 if none)
"$MYSQL" --login-path="$LOGIN_PATH" -N -e "
SELECT COALESCE(ROUND(MAX((data_length+index_length)/1024/1024)),0)
FROM information_schema.TABLES
WHERE table_schema NOT IN ('mysql','information_schema','performance_schema','sys');" 2>/dev/null \
| awk '{print int($1)}'
}
get_threads_running() {
"$MYSQL" --login-path="$LOGIN_PATH" -N -e "SHOW GLOBAL STATUS LIKE 'Threads_running';" 2>/dev/null \
| awk '{print ($2+0)}'
}
replication_lag_seconds() {
# Works on MySQL/MariaDB; returns seconds or 0 if not a replica / unknown
local lag=0
# Try standard SHOW SLAVE/REPLICA STATUS
local out
out=$("$MYSQL" --login-path="$LOGIN_PATH" -e "SHOW SLAVE STATUS\G" 2>/dev/null || true)
if [[ -n "$out" ]]; then
lag=$(printf "%s\n" "$out" | awk -F': ' '/Seconds_Behind_Master/{print $2+0; exit}')
else
out=$("$MYSQL" --login-path="$LOGIN_PATH" -e "SHOW REPLICA STATUS\G" 2>/dev/null || true)
if [[ -n "$out" ]]; then
lag=$(printf "%s\n" "$out" | awk -F': ' '/Seconds_Behind_Source/{print $2+0; exit}')
(( lag == 0 )) && lag=$(printf "%s\n" "$out" | awk -F': ' '/Seconds_Behind_Master/{print $2+0; exit}')
fi
fi
echo $((lag+0))
}
_in_peak_hours() {
# Parses SAFE_PEAK_HOURS like "8-20" or "9-12,14-18"
local spec="$SAFE_PEAK_HOURS"
[[ -z "$spec" ]] && return 1
local h now
now=$(date +%H) || now=0
IFS=',' read -r -a parts <<< "$spec"
for part in "${parts[@]}"; do
if [[ "$part" =~ ^([0-9]{1,2})-([0-9]{1,2})$ ]]; then
local a=${BASH_REMATCH[1]} b=${BASH_REMATCH[2]}
(( a <= now && now <= b )) && return 0
elif [[ "$part" =~ ^[0-9]{1,2}$ ]]; then
(( part == now )) && return 0
fi
done
return 1
}
should_safe_mode() {
# Returns 0 (true) if we should force safe mode; 1 otherwise
[[ "$MAINT_SAFE_AUTO" != "1" ]] && return 1
local free_mb largest_mb threads lag
free_mb=$(get_free_mb "/var/lib/mysql")
largest_mb=$(get_largest_table_mb)
threads=$(get_threads_running || echo 0)
lag=$(replication_lag_seconds || echo 0)
# Free space checks
local min_free_mb=$(( SAFE_MIN_FREE_GB * 1024 ))
local ratio_need_mb
# ratio * largest_table (rounded up) - use awk for portable float math
ratio_need_mb=$(awk -v r="${SAFE_MIN_FREE_RATIO:-2.0}" -v l="${largest_mb:-0}" 'BEGIN { printf "%d", r * l + 0.5 }')
(( ratio_need_mb == 0 )) && ratio_need_mb=$(( (largest_mb * 2) )) # fallback
# Trigger conditions
if (( free_mb < min_free_mb )); then
debug "Auto-safe: free_mb($free_mb) < min_free_mb($min_free_mb)"
return 0
fi
if (( free_mb < ratio_need_mb )); then
debug "Auto-safe: free_mb($free_mb) < ratio_need_mb($ratio_need_mb)"
return 0
fi
if (( threads > SAFE_MAX_THREADS_RUNNING )); then
debug "Auto-safe: Threads_running($threads) > threshold($SAFE_MAX_THREADS_RUNNING)"
return 0
fi
if _in_peak_hours; then
debug "Auto-safe: peak hours active ($SAFE_PEAK_HOURS)"
return 0
fi
if (( lag > 120 )); then
debug "Auto-safe: replication lag ($lag s) > 120 s"
return 0
fi
return 1
}
# ========================== Utility Functions ==========================
# ---------- TTY helpers (visual cues) ----------
is_tty() { [[ -t 2 ]] && [[ "${NO_TTY:-0}" != "1" ]]; }
SPINNER_PID=""
start_spinner() {
is_tty || return 0
local msg="${*:-Working}"
# minimal spinner on STDERR so normal output stays clean
(
local frames='|/-\'
local i=0
# hide cursor
printf '\033[?25l' >&2
while :; do
printf '\r%s %s' "$msg" "${frames:i++%4:1}" >&2
sleep 0.15
done
) &
SPINNER_PID=$!
# make sure it stops even on errors
stack_trap 'stop_spinner' EXIT
}
stop_spinner() {
[[ -n "$SPINNER_PID" ]] || return 0
kill "$SPINNER_PID" >/dev/null 2>&1 || true
wait "$SPINNER_PID" 2>/dev/null || true
SPINNER_PID=""
# clear spinner line & show cursor
printf '\r\033[K\033[?25h' >&2
}
show_progress() {
local current="$1"
local total="$2"
local desc="${3:-Progress}"
local width=30
[[ "$total" -eq 0 ]] && return
# Cap current at total to prevent overflow
[[ "$current" -gt "$total" ]] && current="$total"
local pct=$(( current * 100 / total ))
local filled=$(( width * current / total ))
local empty=$(( width - filled ))
# Build progress bar string
local bar=""
for ((i=0; i<filled; i++)); do bar+="█"; done
for ((i=0; i<empty; i++)); do bar+="░"; done
if is_tty; then
# Simple carriage return - no complex cursor movement
printf '\r%s: [%s] %3d%% (%d/%d) ' "$desc" "$bar" "$pct" "$current" "$total" >&2
else
printf '%s: %d/%d (%d%%)\n' "$desc" "$current" "$total" "$pct" >&2
fi
if [[ "$current" -eq "$total" ]] && is_tty; then
echo >&2
fi
}
# Create a secure MySQL client defaults file for XtraBackup (non-interactive auth)
write_mysql_defaults_file() {
local host="$1" port="$2" user="$3" pass="$4"
local file="${MYSQL_DEFAULTS_FILE:-/etc/db-tools.my.cnf}"
[[ -z "$host" ]] && host="localhost"
[[ -z "$port" ]] && port="3306"
[[ -z "$user" ]] && user="root"
mkdir -p "$(dirname "$file")" || err "Cannot create $(dirname "$file")"
# secure file creation
local old_umask; old_umask=$(umask); umask 077
cat >"$file" <<EOF
[client]
user = $user
password = $pass
host = $host
port = $port
EOF
umask "$old_umask"
chmod 600 "$file" || warn "Could not chmod 600 $file"
chown root:root "$file" 2>/dev/null || true
if [[ -s "$file" ]]; then
log INFO "✅ Wrote XtraBackup credentials to $file (600)"
else
err "Failed writing $file"
fi
}
# Return auth args for XtraBackup, preferring a secure defaults file.
# Echoes a list of CLI tokens, ready to splat into command arrays.
mysql_auth_args() {
local args=()
local defaults="${MYSQL_DEFAULTS_FILE:-/etc/db-tools.my.cnf}"
if [[ -r "$defaults" ]]; then
args+=("--defaults-file=$defaults")
else
local host port user
host=$(mysql_config_editor print --login-path="$LOGIN_PATH" 2>/dev/null | awk -F= '/host/{gsub(/[ "]/,"",$2);print $2}')
port=$(mysql_config_editor print --login-path="$LOGIN_PATH" 2>/dev/null | awk -F= '/port/{gsub(/[ "]/,"",$2);print $2}')
user=$(mysql_config_editor print --login-path="$LOGIN_PATH" 2>/dev/null | awk -F= '/user/{gsub(/[ "]/,"",$2);print $2}')
[[ -n "$host" ]] && args+=(--host="$host")
[[ -n "$port" ]] && args+=(--port="$port")
[[ -n "$user" ]] && args+=(--user="$user")
# password intentionally not echoed unless using the secure defaults file
fi
echo "${args[*]}"
}
log() {
local level="${1:-INFO}"
shift
local msg="$*"
local ts="$(date +'%F %T')"
case "$level" in
DEBUG) [[ "$LOG_LEVEL" == "DEBUG" ]] || return 0 ;;
INFO|WARN|ERROR) ;;
*) level="INFO" ;;
esac
local color=""
case "$level" in
ERROR) color="\033[0;31m" ;; # Red
WARN) color="\033[0;33m" ;; # Yellow
INFO) color="\033[0;32m" ;; # Green
DEBUG) color="\033[0;36m" ;; # Cyan
esac
printf "${color}[%s] %s: %s\033[0m\n" "$ts" "$level" "$msg" >&2
[[ "$USE_SYSLOG" == "1" ]] && logger -t db-tools -p "user.${level,,}" "$msg" || true
}
warn() { log WARN "$@"; }
err() { log ERROR "$@"; exit 1; }
debug() { log DEBUG "$@"; }
have() { command -v "$1" >/dev/null 2>&1; }
add_summary() { BACKUP_SUMMARY+=("$*"); }
# Validate database/table name to prevent SQL injection
# MySQL identifiers: alphanumeric, underscore, dollar sign; max 64 chars
# Reject backticks, quotes, semicolons, and other dangerous chars
validate_identifier() {
local name="$1"
local type="${2:-identifier}" # "database", "table", or "identifier"
[[ -z "$name" ]] && { warn "Empty $type name"; return 1; }
# Check length (MySQL max is 64)
if (( ${#name} > 64 )); then
warn "Invalid $type name (too long): $name"
return 1
fi
# Allow only safe characters: alphanumeric, underscore, dollar
# Also allow dot for table.name format
if [[ ! "$name" =~ ^[a-zA-Z0-9_\$]+(\.[a-zA-Z0-9_\$]+)?$ ]]; then
warn "Invalid $type name (unsafe characters): $name"
return 1
fi
# Reject reserved/dangerous patterns
if [[ "$name" =~ [\`\'\"\;\\] ]]; then
err "SECURITY: Dangerous characters in $type name: $name"
fi
return 0
}
print_summary() {
[[ ${#BACKUP_SUMMARY[@]} -eq 0 ]] && return
local duration=$(( $(date +%s) - OPERATION_START ))
log INFO "=== Operation Summary (${duration}s) ==="
printf '%s\n' "${BACKUP_SUMMARY[@]}" >&2
}
generate_encryption_key() {
local key_file="${1:-$ENCRYPTION_KEY_FILE}"
if [[ -z "$key_file" ]]; then
err "No encryption key file specified. Set ENCRYPTION_KEY_FILE or provide path as argument."
fi
if [[ -f "$key_file" ]]; then
read -r -p "Key file exists at $key_file. Overwrite? [y/N]: " confirm
[[ "$confirm" =~ ^[Yy]$ ]] || { log INFO "Cancelled"; return 0; }
fi
local key_dir
key_dir="$(dirname "$key_file")"
mkdir -p "$key_dir" || err "Cannot create directory: $key_dir"
# Set secure umask before creating key file
local old_umask=$(umask)
umask 077
log INFO "Generating 256-bit encryption key..."
# Generate a strong random key
if have openssl; then
openssl rand -base64 32 > "$key_file"
elif [[ -r /dev/urandom ]]; then
head -c 32 /dev/urandom | base64 > "$key_file"
else
err "Cannot generate random key: neither openssl nor /dev/urandom available"
fi
# Set secure permissions
chmod 600 "$key_file"
# Try to set immutable flag if possible (Linux only)
if have chattr; then
chattr +i "$key_file" 2>/dev/null && log INFO "Set immutable flag on key file" || true
fi
log INFO "✅ Encryption key created: $key_file"
log INFO "⚠️ IMPORTANT: Back up this key file securely!"
log INFO "⚠️ Without this key, encrypted backups cannot be restored."
echo
log INFO "To enable encryption, set in your config:"
echo " ENCRYPT_BACKUPS=1"
echo " ENCRYPTION_KEY_FILE=\"$key_file\""
# Restore original umask
umask "$old_umask"
}
stack_trap 'print_summary' EXIT
# ========================== Signal Handling ==========================
# Global state tracking
declare -g OPERATION_IN_PROGRESS=""
declare -g MYSQL_WAS_STOPPED=0
declare -g DATADIR_BACKUP_PATH=""
declare -g EXPECTED_ERROR_EXIT=0 # Set to 1 when returning error intentionally (e.g., partial backup success)
# Critical cleanup on exit/interrupt
emergency_cleanup() {
local exit_code=$?
local signal="${1:-EXIT}"
# Only log error if this is an actual unexpected error (not normal exit or expected error)
if [[ $exit_code -ne 0 ]] && [[ "$EXPECTED_ERROR_EXIT" != "1" ]]; then
log ERROR "Emergency cleanup triggered (signal: $signal, exit code: $exit_code)"
fi
# If MySQL was stopped during restore, try to restart it
if [[ "$MYSQL_WAS_STOPPED" == "1" ]]; then
log ERROR "MySQL was stopped during operation! Attempting restart..."
# If we have a backup of the original datadir, consider restoring it
if [[ -n "$DATADIR_BACKUP_PATH" ]] && [[ -d "$DATADIR_BACKUP_PATH" ]]; then
log ERROR "Original datadir backup exists at: $DATADIR_BACKUP_PATH"
log ERROR "Current /var/lib/mysql may be incomplete!"
log ERROR "MANUAL INTERVENTION REQUIRED!"
log ERROR "To rollback: systemctl stop mysql && rm -rf /var/lib/mysql && mv $DATADIR_BACKUP_PATH /var/lib/mysql && systemctl start mysql"
fi
# Try to start MySQL anyway
if systemctl start mysql 2>/dev/null || systemctl start mysqld 2>/dev/null || systemctl start mariadb 2>/dev/null; then
log WARN "MySQL restarted, but database state is UNKNOWN - verify data integrity!"
else
log ERROR "Failed to restart MySQL! Database is DOWN!"
log ERROR "Check logs: journalctl -u mysql -n 100"
fi
fi
# Clean up temp directories (only if there was an operation in progress that failed)
if [[ -n "$OPERATION_IN_PROGRESS" ]] && [[ $exit_code -ne 0 ]]; then
log WARN "Operation '$OPERATION_IN_PROGRESS' was interrupted"
# Clean up any .partial files
find "$BACKUP_DIR" -name "*.partial" -type f -delete 2>/dev/null || true
# Clean up temp restore directories
find "$BACKUP_DIR" -name ".xtra_restore_*" -type d -exec rm -rf {} + 2>/dev/null || true
find "$BACKUP_DIR" -name ".xtra_base_*" -type d -exec rm -rf {} + 2>/dev/null || true
fi
# Release lock
release_lock
# Send notification if this was an error
if [[ $exit_code -ne 0 ]] && [[ -n "$OPERATION_IN_PROGRESS" ]]; then
notify "db-tools FAILED: $OPERATION_IN_PROGRESS" \
"Operation interrupted with exit code $exit_code. Check logs immediately!" \
"error"
fi
}
# Set up signal handlers
trap 'emergency_cleanup SIGINT' SIGINT # Ctrl+C
trap 'emergency_cleanup SIGTERM' SIGTERM # kill
trap 'emergency_cleanup SIGHUP' SIGHUP # Terminal closed
trap 'emergency_cleanup EXIT' EXIT # Normal/abnormal exit
# ========================== Lock Management ==========================
acquire_lock() {
local timeout="${1:-300}"
local operation="${2:-operation}" # What operation is locking
if have flock; then
# Use FD 9 for the lock
exec 9>"$LOCK_FILE"
if ! flock -n 9; then
# Read what process holds the lock
local lock_info
if [[ -f "$LOCK_FILE" ]]; then
lock_info=$(cat "$LOCK_FILE" 2>/dev/null || echo "unknown")
err "Another db-tools instance is already running: $lock_info (lock: $LOCK_FILE)"
else
err "Another db-tools instance holds the lock ($LOCK_FILE)."
fi
fi
# Write lock info
echo "PID:$$ OPERATION:$operation USER:$(whoami) STARTED:$(date '+%Y-%m-%d %H:%M:%S')" > "$LOCK_FILE"
stack_trap 'release_lock' EXIT
debug "Lock acquired for '$operation' (PID: $$)"
return
fi
# Fallback to manual lock file with timeout and PID checking
# Use atomic file creation with noclobber to prevent race conditions
local elapsed=0
local lock_content="PID:$$ OPERATION:$operation USER:$(whoami) STARTED:$(date '+%Y-%m-%d %H:%M:%S')"
while true; do
# Try atomic lock creation (noclobber prevents overwriting existing file)
if (set -o noclobber; echo "$lock_content" > "$LOCK_FILE") 2>/dev/null; then
# Successfully created lock file
stack_trap 'release_lock' EXIT
debug "Lock acquired for '$operation' (PID: $$)"
return 0
fi
# Lock file exists, check if it's stale
if [[ -f "$LOCK_FILE" ]]; then
local lock_info=$(cat "$LOCK_FILE" 2>/dev/null || echo "unknown")
if [[ "$lock_info" =~ PID:([0-9]+) ]]; then
local lock_pid="${BASH_REMATCH[1]}"
if ! kill -0 "$lock_pid" 2>/dev/null; then
warn "Stale lock file found (PID $lock_pid not running), removing..."
rm -f "$LOCK_FILE"
continue # Retry lock acquisition
fi
fi
if (( elapsed >= timeout )); then
err "Lock timeout: Another instance running for ${elapsed}s. Lock info: $lock_info"
fi
debug "Waiting for lock... (${elapsed}s) - Held by: $lock_info"
fi
sleep 5
((elapsed+=5))
done
}
release_lock() {
# Close FD if flock path used
{ exec 9>&-; } 2>/dev/null || true
rm -f "$LOCK_FILE"
debug "Lock released"
}
# Helper to check if operation is locked
is_locked() {
if [[ -f "$LOCK_FILE" ]]; then
local lock_info=$(cat "$LOCK_FILE" 2>/dev/null)
echo "yes: $lock_info"
return 0
else
echo "no"
return 1
fi
}
# ========================== Configuration Loading ==========================
load_config() {
# Safe config loader - parses key=value instead of sourcing
_safe_load_config() {
local file="$1"
[[ -f "$file" ]] || return 0
# Only allow files owned by root or current user with safe permissions
local file_owner file_perms
if file_owner=$(stat -c '%U' "$file" 2>/dev/null); then
file_perms=$(stat -c '%a' "$file" 2>/dev/null)
else
file_owner=$(stat -f '%Su' "$file" 2>/dev/null || echo "")
file_perms=$(stat -f '%Lp' "$file" 2>/dev/null || echo "777")
fi
if [[ -n "$file_owner" && "$file_owner" != "root" && "$file_owner" != "$USER" ]]; then
warn "Skipping config file owned by '$file_owner': $file"
return 1
fi
# Parse key=value pairs safely (only uppercase alphanumeric + underscore keys)
while IFS= read -r line || [[ -n "$line" ]]; do
# Skip comments and empty lines
[[ "$line" =~ ^[[:space:]]*# ]] && continue
[[ "$line" =~ ^[[:space:]]*$ ]] && continue
# Extract key=value, allowing quoted values
if [[ "$line" =~ ^[[:space:]]*([A-Z_][A-Z0-9_]*)[[:space:]]*=[[:space:]]*(.*)[[:space:]]*$ ]]; then
local key="${BASH_REMATCH[1]}"
local value="${BASH_REMATCH[2]}"
# Strip surrounding quotes if present
value="${value#\"}" ; value="${value%\"}"
value="${value#\'}" ; value="${value%\'}"
# Export the variable
export "$key=$value"
fi
done < "$file"
debug "Configuration loaded from $file"
}
# Load from .env if present (local override)
_safe_load_config "$ENV_FILE"
# Load from system config
_safe_load_config "$CONFIG_FILE"
}
# ========================== Tool Checking ==========================
check_core_dependencies() {
local missing=()
for tool in curl awk sed grep cut date tr head tail sort uniq; do
if ! have "$tool"; then
missing+=("$tool")
fi
done
if [[ ${#missing[@]} -gt 0 ]]; then
err "Missing core dependencies: ${missing[*]}. Please install them."
fi
}
need_tooling() {
[[ -n "$MYSQL" ]] || err "mysql client not found"
[[ -n "$MYSQLDUMP" ]] || warn "mysqldump not found (logical backups will be unavailable)"
[[ -n "$MYSQLBINLOG" ]] || warn "mysqlbinlog not found (PITR will be limited)"
# Auto-install XtraBackup if backup method is xtrabackup and it's not found
if [[ "$BACKUP_METHOD" == "xtrabackup" ]]; then
if [[ -z "$XTRABACKUP" ]]; then
# Check if we already tried and failed
if [[ -f "$MARK_DIR/xtrabackup-install-failed" ]]; then
debug "XtraBackup install previously failed, using mysqldump"
BACKUP_METHOD="mysqldump"
return
fi
# Check if auto-install is enabled
if [[ "$AUTO_INSTALL" != "1" ]]; then
warn "XtraBackup not found. Set AUTO_INSTALL=1 to enable auto-install, or install manually."