-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathbackup-restore
More file actions
executable file
·1363 lines (1208 loc) · 56.8 KB
/
Copy pathbackup-restore
File metadata and controls
executable file
·1363 lines (1208 loc) · 56.8 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
# Backup and restore tool
# Usage: ./backup-restore [OPTIONS]
set -euo pipefail
# Main function wrapper
main() {
# Check if common-functions exists
if [[ ! -f "$(dirname "$0")/common-functions" ]]; then
echo "Downloading common-functions from GitHub..."
if ! curl -fsSL https://raw.githubusercontent.com/Flower7C3/bash-tools/master/common-functions -o "$(dirname "$0")/common-functions"; then
echo "Failed to download common-functions"
exit 1
fi
fi
# Source common functions
source "$(dirname "$0")/common-functions"
# Call the actual main function
backup_restore_main "$@"
}
backup_restore_main() {
#-------------------------- Settings --------------------------------
# Change to script directory and initialize configuration paths
cd "$(dirname "${BASH_SOURCE[0]}")" || exit 1
config_file="backup-restore.yaml"
# shellcheck disable=SC2155
declare -r LOG_FILE_PATH="logs/$(date +%Y/%m/)short.log"
#-------------------------- Helper Functions --------------------------------
# Utility functions for configuration reading, logging, and string manipulation
function display_help() {
# Display usage information and available command-line options
log_usage_title '[OPTIONS]'
echo
echo 'Backup and restore tool for files and databases'
echo
log_header 'Options'
log_usage_options_line '-s;--server <SERVER>' 'Select server from configuration'
log_usage_options_line '-i;--install' 'Install required tools (yq)'
log_usage_options_line '-c;--cleanup' 'Clean up old backups'
log_usage_options_line '-te;--test-email' 'Test email notification configuration'
log_usage_options_line '-ls;--list-scopes' 'List available scopes'
log_usage_options_line '-bfs;--backup-files-scope <SCOPE>' 'Backup files for the given scope'
log_usage_options_line '-rfs;--restore-files-scope <SCOPE>' 'Restore files for the given scope'
log_usage_options_line '-bds;--backup-database-scope <SCOPE>' 'Backup database for the given scope'
log_usage_options_line '-rds;--restore-database-scope <SCOPE>' 'Restore database for the given scope'
log_usage_options_line '-cs;--configure-scope <SCOPE>' 'Update configuration for the given scope'
log_usage_options_line '-n;--dry-run' 'Simulate operations without making changes'
log_usage_options_line '-f;--force;--yolo' 'Force backup even when no changes are detected'
log_usage_options_line '-d;--debug' 'Enable debug output'
log_usage_options_line '-a;--skip-archive' 'Skip file archiving after backup'
log_usage_options_line '-u;--update' 'Update app'
log_usage_options_line '-h;--help' 'Show this help message'
echo
log_header 'Examples'
echo ' File backup'
log_usage_example_line '--server production --backup-files-scope application'
echo ' Database restore'
log_usage_example_line '--server dev --restore-database-scope database'
echo ' Cleanup dry-run'
log_usage_example_line '--cleanup --dry-run'
echo ' Test email'
log_usage_example_line '--test-email'
exit 0
}
function read_config() {
# Read configuration values from YAML file using yq tool
# Tries system yq first, falls back to local yq_linux_386 binary
local _path="$1"
shift
if hash yq 2>/dev/null; then
# shellcheck disable=SC2068
yq "$_path" "$config_file" $@
else
# shellcheck disable=SC2068
./yq_linux_386 "$_path" "$config_file" $@
fi
}
function join_by() {
# Join array elements with a delimiter into a single string
local _delimiter=$1
shift
printf "%s" "$1"
shift
printf "%s" "${@/#/$_delimiter}"
}
function save_log_start() {
# Write log entry start (timestamp and initial parameters) to log file
# Skips logging in dry-run mode
if [[ "$dry_run" == "yes" ]]; then
return 0
fi
{
# Ensure we start on a new line if file exists and is not empty
if [[ -f "$LOG_FILE_PATH" ]] && [[ -s "$LOG_FILE_PATH" ]]; then
local _last_char
_last_char=$(tail -c 1 "$LOG_FILE_PATH" 2>/dev/null || echo "")
# If last character is not a newline, add one
if [[ "$_last_char" != $'\n' ]]; then
printf "\n"
fi
fi
printf "%s" "$(date +"%Y-%m-%d %H:%M:%S")"
save_log_data "$@"
} >>"$LOG_FILE_PATH"
}
function save_log_data() {
# Write log entry to log file
# Skips logging in dry-run mode
if [[ "$dry_run" == "yes" ]]; then
return 0
fi
{
if [[ "$#" -gt "0" ]]; then
printf "\t%s" "$@"
fi
} >>"$LOG_FILE_PATH"
}
function save_log_end() {
# Write log entry end (additional parameters and newline) to log file
# Skips logging in dry-run mode
if [[ "$dry_run" == "yes" ]]; then
return 0
fi
{
save_log_data "$@"
printf "\n"
} >>"$LOG_FILE_PATH"
}
#-------------------------- Email Functions --------------------------------
# Functions for testing and sending email notifications via SMTP
function test_email_configuration() {
# Test email configuration by sending a test message
# Validates SMTP settings and available email tools
log_header 'Testing email configuration...'
# Load email configuration from YAML
eval "$(read_config ".notifications" -o=shell 2>/dev/null)"
# Validate basic configuration
if [[ "$enabled" != "yes" ]]; then
log_error 'Email notifications are disabled in configuration (enabled: <b>%s</b>)' "$enabled"
return 1
fi
if [[ -z "$smtp_host" ]]; then
log_error 'SMTP host is not configured'
return 1
fi
if [[ -z "$smtp_to" ]]; then
log_error 'Email recipient (smtp_to) is not configured'
return 1
fi
log_info ---icon '📧' 'SMTP Host: <b>%s</b>' "$smtp_host"
log_info ---icon '🔌' 'SMTP Port: <b>%s</b>' "${smtp_port:-587}"
log_info ---icon '👤' 'SMTP User: <b>%s</b>' "${smtp_user:-not set}"
log_info ---icon '📤' 'From: <b>%s</b>' "${smtp_from:-backup@localhost}"
log_info ---icon '📥' 'To: <b>%s</b>' "$smtp_to"
log_info ---icon '🔒' 'Use TLS: <b>%s</b>' "${smtp_use_tls:-no}"
# Check for available email tools (sendmail, mail, or curl)
local _email_tool=""
if command -v sendmail &>/dev/null; then
_email_tool="sendmail"
elif command -v mail &>/dev/null; then
_email_tool="mail"
elif command -v curl &>/dev/null; then
_email_tool="curl"
else
log_error 'No email client available (sendmail/mail/curl)'
return 1
fi
log_info 'Email tool: <b>%s</b>' "$_email_tool"
# Send test email with configuration details
local _test_subject="[BACKUP TEST] Email configuration test"
local _test_message="This is a test email from the backup system.
If you received this email, the email configuration is working correctly.
Test details:
- Time: $(date +"%Y-%m-%d %H:%M:%S")
- Host: $(hostname 2>/dev/null || echo "unknown")
- SMTP Host: $smtp_host
- SMTP Port: ${smtp_port:-587}
- Email Tool: $_email_tool
This is an automated test message."
log_info ---icon '✉️' 'Sending test email to <b>%s</b>...' "$smtp_to"
if send_email_notification "$_test_subject" "$_test_message" "TEST"; then
log_success ---icon '✉️' 'Test email sent successfully!'
log_info ---icon '📥' 'Please check your inbox at <b>%s</b>' "$smtp_to"
return 0
else
log_error 'Failed to send test email'
return 1
fi
}
function send_email_notification() {
# Send email notification using available email tool (sendmail, mail, or curl)
# Supports both local sendmail/mail and SMTP via curl
local _subject="$1"
local _message="$2"
local _error_level="${3:-ERROR}"
# Load email configuration from YAML
eval "$(read_config ".notifications" -o=shell 2>/dev/null)"
# Check if notifications are enabled (skip check for test emails)
if [[ "$_error_level" != "TEST" ]] && [[ "${enabled}" != "yes" ]]; then
return 0
fi
if [[ -z "$smtp_host" || -z "$smtp_to" ]]; then
if [[ "$debug" == "yes" ]]; then
log_debug 'Email notifications not configured, skipping...'
fi
return 1
fi
# Prepare email headers with proper date format (RFC 2822)
local _date_header
if date -R &>/dev/null 2>&1; then
_date_header=$(date -R)
else
_date_header=$(date +"%a, %d %b %Y %H:%M:%S %z" 2>/dev/null || date +"%Y-%m-%d %H:%M:%S")
fi
local _email_body
_email_body=$(
cat <<EOF
Subject: $_subject
From: ${smtp_from:-backup@localhost}
To: $smtp_to
Date: $_date_header
Content-Type: text/plain; charset=UTF-8
$_message
EOF
)
# Send email using available tool: sendmail (preferred), mail, or curl SMTP
if command -v sendmail &>/dev/null; then
if echo "$_email_body" | sendmail -t 2>&1; then
return 0
else
if [[ "$debug" == "yes" || "$_error_level" == "TEST" ]]; then
log_error 'Failed to send email via sendmail'
fi
return 1
fi
elif command -v mail &>/dev/null; then
if echo "$_message" | mail -s "$_subject" "$smtp_to" 2>&1; then
return 0
else
if [[ "$debug" == "yes" || "$_error_level" == "TEST" ]]; then
log_error 'Failed to send email via mail'
fi
return 1
fi
elif command -v curl &>/dev/null && [[ -n "$smtp_host" ]]; then
_send_email_via_curl "$_subject" "$_message" "$_error_level"
else
if [[ "$debug" == "yes" ]]; then
log_debug 'No email client available (sendmail/mail/curl), skipping email notification'
fi
return 0
fi
if [[ "$debug" == "yes" || "$_error_level" == "TEST" ]]; then
log_info 'Email notification sent to <b>%s</b>' "$smtp_to"
fi
return 0
}
function _send_email_via_curl() {
# Send email via SMTP using curl
# Supports SMTPS (port 465) and STARTTLS (port 587)
local _subject="$1"
local _message="$2"
local _error_level="$3"
local _smtp_port="${smtp_port:-587}"
local _smtp_url=""
local _curl_opts=()
local _curl_output=""
# Port 465 requires SMTPS (SSL/TLS), port 587 uses STARTTLS
if [[ "$_smtp_port" == "465" ]]; then
# SMTPS (SSL/TLS) for port 465
_smtp_url="smtps://${smtp_host}:${_smtp_port}"
_curl_opts+=("--ssl-reqd" "--insecure")
else
# STARTTLS for port 587
_smtp_url="smtp://${smtp_host}:${_smtp_port}"
if [[ "$smtp_use_tls" == "yes" ]]; then
_curl_opts+=("--ssl-reqd")
fi
fi
# Add authentication if credentials are provided
if [[ -n "$smtp_user" && -n "$smtp_pass" ]]; then
_curl_opts+=("--user" "${smtp_user}:${smtp_pass}")
fi
# Prepare email body for curl (simpler format than sendmail)
local _email_for_curl
_email_for_curl=$(
cat <<EOF
From: ${smtp_from:-backup@localhost}
To: $smtp_to
Subject: $_subject
$_message
EOF
)
# Send email via curl SMTP
_curl_output=$(echo "$_email_for_curl" | curl -s -w "\n%{http_code}" "${_curl_opts[@]}" \
--url "$_smtp_url" \
--mail-from "${smtp_from:-backup@localhost}" \
--mail-rcpt "$smtp_to" \
--upload-file - 2>&1)
local _curl_exit_code=$?
if [[ $_curl_exit_code -eq 0 ]]; then
return 0
else
if [[ "$debug" == "yes" || "$_error_level" == "TEST" ]]; then
log_error 'Failed to send email via curl to <b>%s:%s</b>' "$smtp_host" "$_smtp_port"
log_error 'Curl output: %s' "$_curl_output"
fi
return 1
fi
}
#-------------------------- Error Handling Functions --------------------------------
# Functions for handling errors and success messages with logging and email notifications
function handle_error() {
# Handle errors: log error, save to log file, send email notification, and exit
# Accepts multiple arguments for error message
local _error_message="$1"
shift
local _exit_code=1
log_error "$_error_message" "$@"
local text
# shellcheck disable=SC2059
text="$(log_error "$_error_message" "$@" ---style mail ---no-icon)"
save_log_end "ERROR: $text"
# Send error notification email
local _email_subject="[BACKUP ERROR] Backup failed: ${action:-unknown}"
local _email_message
_email_message="Backup operation failed!
$text
Action: ${action:-unknown}
Scope: ${app_id:-N/A}
Host: ${server_id:-N/A}
Time: $(date +"%Y-%m-%d %H:%M:%S")
Log file: $LOG_FILE_PATH
Please check the logs for more details."
send_email_notification "$_email_subject" "$_email_message" "ERROR"
exit "$_exit_code"
}
function handle_success() {
# Handle success: log success message and save to log file
# Accepts multiple arguments for success message
local _success_message="$1"
shift
# Build full message from all arguments
local _full_message
if [[ $# -gt 0 ]]; then
# shellcheck disable=SC2059
_full_message=$(printf "$_success_message" "$@")
else
_full_message="$_success_message"
fi
log_success '%s' "$_full_message"
local text
# shellcheck disable=SC2059
text="$(log_success "$_full_message" ---style mail ---no-icon)"
save_log_end "SUCCESS: $text"
}
#-------------------------- Backup Date Functions --------------------------------
# Functions for extracting and checking backup file dates for GFS retention strategy
function get_file_date() {
# Extract date from backup filename (format: YYYYMMDD)
# Returns 8-digit date string or empty if not found
local _file="$1"
basename "$_file" | grep -oE '[0-9]{8}' | head -1
}
function is_weekly_backup() {
# Check if backup date is the first day of week (Monday)
# Handles macOS (date -j) and Linux (date -d) date command differences
local _file_date="$1"
local _date_obj
if date -j -f "%Y%m%d" "$_file_date" +%u &>/dev/null 2>&1; then
# macOS date command
_date_obj=$(date -j -f "%Y%m%d" "$_file_date" +%u 2>/dev/null || echo "0")
elif date -d "$_file_date" +%u &>/dev/null 2>&1; then
# Linux date command
_date_obj=$(date -d "$_file_date" +%u 2>/dev/null || echo "0")
else
# Fallback: treat first day of month as weekly backup
[[ "${_file_date:6:2}" == "01" ]] && return 0 || return 1
fi
# Return 0 if Monday (day 1), 1 otherwise
[[ "$_date_obj" == "1" ]] && return 0 || return 1
}
function is_monthly_backup() {
# Check if backup date is the first day of month (day 01)
local _file_date="$1"
[[ "${_file_date:6:2}" == "01" ]] && return 0 || return 1
}
function is_yearly_backup() {
# Check if backup date is the first day of year (January 1st)
local _file_date="$1"
[[ "${_file_date:4:4}" == "0101" ]] && return 0 || return 1
}
#-------------------------- Cleanup Functions --------------------------------
# Functions for executing cleanup operations using GFS (Grandfather-Father-Son) retention strategy
function execute_cleanup() {
# Execute cleanup for all scopes: iterate through scopes and clean up database and file backups
# Uses GFS retention strategy to keep daily, weekly, monthly, and yearly backups
if [[ "$dry_run" == "yes" ]]; then
log_header 'Starting cleanup with GFS retention strategy (dry-run)'
else
log_header 'Starting cleanup with GFS retention strategy'
fi
# Get all scope names from configuration
local _scopes
_scopes=$(read_config ".scope | keys" -o=tsv 2>/dev/null | tr '\n' ' ')
if [[ -n "$_scopes" ]]; then
for _scope in $_scopes; do
eval "$(read_config ".scope.$_scope" -o=shell 2>/dev/null)"
if [[ -n "$sql_file_pattern" && "$sql_file_pattern" != "null" ]]; then
# Clean up database backups matching the SQL file pattern
local _pattern
# shellcheck disable=SC2059
_pattern=$(printf "$sql_file_pattern" "*")
cleanup_with_gfs_retention "$_scope" "$local_databases_path" "$_pattern" "databases"
fi
if [[ -n "$ssh_sync_path" && "$ssh_sync_path" != "null" ]]; then
# Clean up file backups: pattern is YYYYMMDD.scope.tar.gz
local _pattern
_pattern="*.${_scope}.tar.gz"
cleanup_with_gfs_retention "$_scope" "$local_archive_path" "$_pattern" "files"
fi
sql_file_pattern=""
ssh_sync_path=""
done
fi
printf "\n"
if [[ "$dry_run" == "yes" ]]; then
log_success ---icon '🗑' 'Cleanup simulation completed (<b>DRY-RUN MODE</b> - no files were deleted)'
else
log_success ---icon '🗑' 'Cleanup completed successfully'
fi
}
#-------------------------- Cleanup Helper Functions --------------------------------
# Helper functions for GFS retention cleanup: collecting file info and selecting best backups
function _collect_backup_file_info() {
# Collect information about all backup files: dates, ages in days, and timestamps
# Uses namerefs to populate associative arrays with file metadata
local -n _all_files_ref="$1"
local -n _file_info_ref="$2"
local -n _file_ages_ref="$3"
local -n _file_timestamps_ref="$4"
for _file in "${_all_files_ref[@]}"; do
local _file_date
_file_date=$(get_file_date "$_file")
if [[ -z "$_file_date" ]]; then
continue
fi
# Calculate file age in days from date in filename
local _file_timestamp
local _current_timestamp
if date -j -f "%Y%m%d" "$_file_date" +%s &>/dev/null 2>&1; then
# macOS date command
_file_timestamp=$(date -j -f "%Y%m%d" "$_file_date" +%s 2>/dev/null || echo "0")
_current_timestamp=$(date +%s)
elif date -d "$_file_date" +%s &>/dev/null 2>&1; then
# Linux date command
_file_timestamp=$(date -d "$_file_date" +%s 2>/dev/null || echo "0")
_current_timestamp=$(date +%s)
else
# Fallback: use file modification time
_file_timestamp=$(stat -f %m "$_file" 2>/dev/null || stat -c %Y "$_file" 2>/dev/null || echo "0")
_current_timestamp=$(date +%s)
fi
local _age_days=$(((_current_timestamp - _file_timestamp) / 86400))
_file_info_ref["$_file"]="$_file_date"
_file_ages_ref["$_file"]="$_age_days"
_file_timestamps_ref["$_file"]="$_file_timestamp"
done
}
function _keep_best_backup_from_period() {
# Select the best backup from a time period (week/month/year)
# First tries to find an ideal backup (e.g., Monday for weekly), falls back to newest available
local -n _file_info_ref="$1"
local -n _file_ages_ref="$2"
local -n _file_timestamps_ref="$3"
local -n _files_to_keep_ref="$4"
local _start_age="$5"
local _end_age="$6"
local _check_function="$7" # Function name: is_weekly_backup, is_monthly_backup, or is_yearly_backup
local _keep_reason="$8" # Reason string: "weekly", "monthly", or "yearly"
local _fallback_reason="$9" # Fallback reason: "weekly-fallback", "monthly-fallback", or "yearly-fallback"
local _best_file=""
local _best_timestamp=0
local _has_ideal_backup=false
# First, search for ideal backup (e.g., Monday for weekly, 1st of month for monthly)
for _file in "${!_file_info_ref[@]}"; do
local _age_days="${_file_ages_ref[$_file]}"
local _file_date="${_file_info_ref[$_file]}"
if [[ $_age_days -ge $_start_age && $_age_days -le $_end_age ]]; then
# Call check function via eval (bash doesn't support direct function call via variable)
if eval "$_check_function \"\$_file_date\""; then
_has_ideal_backup=true
_files_to_keep_ref["$_file"]="$_keep_reason"
return 0
fi
# Remember newest backup from this period as fallback
local _file_ts="${_file_timestamps_ref[$_file]}"
if [[ $_file_ts -gt $_best_timestamp ]]; then
_best_timestamp=$_file_ts
_best_file="$_file"
fi
fi
done
# If no ideal backup found, keep the best available backup from this period
if [[ "$_has_ideal_backup" == "false" && -n "$_best_file" ]]; then
_files_to_keep_ref["$_best_file"]="$_fallback_reason"
fi
}
function cleanup_with_gfs_retention() {
# Clean up backup files using GFS (Grandfather-Father-Son) retention strategy
# Keeps: daily backups (last N days), weekly backups (last N weeks), monthly backups (last N months), yearly backups (last N years)
# Always keeps the newest backup as a safety measure
local _scope="$1"
local _backup_path="$2"
local _file_pattern="$3"
local _type="$4" # Backup type: "databases" or "files"
if [[ ! -d "$_backup_path" ]]; then
return 0
fi
# Load retention configuration from YAML
eval "$(read_config ".retention.$_type" -o=shell 2>/dev/null)"
# Default retention values if not configured
local _daily="${daily:-14}"
local _weekly="${weekly:-8}"
local _monthly="${monthly:-12}"
local _yearly="${yearly:-3}"
if [[ "$dry_run" == "yes" ]]; then
log_header 'Cleaning up <b>%s</b> backups at <b>%s</b> scope: <code>%s</code> (dry-run)' "$_type" "$_scope" "$_file_pattern"
else
log_header 'Cleaning up <b>%s</b> backups at <b>%s</b> scope: <code>%s</code>' "$_type" "$_scope" "$_file_pattern"
fi
log_info 'Retention: daily=<b>%d</b>d, weekly=<b>%d</b>w, monthly=<b>%d</b>m, yearly=<b>%d</b>y' "$_daily" "$_weekly" "$_monthly" "$_yearly"
save_log_start "cleanup" "$_type" "$_file_pattern"
# Find all backup files matching the pattern
local _all_files=()
local _temp_file
_temp_file=$(mktemp 2>/dev/null || echo "/tmp/backup_cleanup_$$")
find "$_backup_path" -name "$_file_pattern" -type f 2>/dev/null | sort >"$_temp_file"
while IFS= read -r _file; do
[[ -n "$_file" ]] && _all_files+=("$_file")
done <"$_temp_file"
rm -f "$_temp_file" 2>/dev/null
if [[ ${#_all_files[@]} -eq 0 ]]; then
log_info 'No backup files found to clean up'
return 0
fi
local _deleted_count=0
local _kept_count=0
# Associative arrays to store file metadata: dates, ages, and timestamps
declare -A _file_info
declare -A _file_ages
declare -A _file_timestamps
# Collect information about all backup files
_collect_backup_file_info _all_files _file_info _file_ages _file_timestamps
# Associative array to track which files should be kept
declare -A _files_to_keep
# Step 1: Keep all daily backups (from last N days)
for _file in "${!_file_info[@]}"; do
local _age_days="${_file_ages[$_file]}"
if [[ $_age_days -le $_daily ]]; then
_files_to_keep["$_file"]="daily"
fi
done
# Step 2: For weekly backups, check each week and keep the best available backup
for ((_week = 1; _week <= _weekly; _week++)); do
local _week_start_age=$((_daily + (_week - 1) * 7 + 1))
local _week_end_age=$((_daily + _week * 7))
_keep_best_backup_from_period _file_info _file_ages _file_timestamps _files_to_keep \
"$_week_start_age" "$_week_end_age" "is_weekly_backup" "weekly" "weekly-fallback"
done
# Step 3: For monthly backups, similar logic
for ((_month = 1; _month <= _monthly; _month++)); do
local _month_start_age=$((_weekly * 7 + (_month - 1) * 30 + 1))
local _month_end_age=$((_weekly * 7 + _month * 30))
_keep_best_backup_from_period _file_info _file_ages _file_timestamps _files_to_keep \
"$_month_start_age" "$_month_end_age" "is_monthly_backup" "monthly" "monthly-fallback"
done
# Step 4: For yearly backups, similar logic
for ((_year = 1; _year <= _yearly; _year++)); do
local _year_start_age=$((_monthly * 30 + (_year - 1) * 365 + 1))
local _year_end_age=$((_monthly * 30 + _year * 365))
_keep_best_backup_from_period _file_info _file_ages _file_timestamps _files_to_keep \
"$_year_start_age" "$_year_end_age" "is_yearly_backup" "yearly" "yearly-fallback"
done
# Step 5: Ensure at least one backup is always kept (the newest one)
# Even if all backups are older than retention period
local _newest_file=""
local _newest_timestamp=0
for _file in "${!_file_timestamps[@]}"; do
local _file_ts="${_file_timestamps[$_file]}"
if [[ $_file_ts -gt $_newest_timestamp ]]; then
_newest_timestamp=$_file_ts
_newest_file="$_file"
fi
done
# If no backups are marked to keep, keep at least the newest one
if [[ -z "${_files_to_keep[*]}" && -n "$_newest_file" ]]; then
_files_to_keep["$_newest_file"]="last-available"
fi
# Always keep the newest backup as a safety measure
if [[ -n "$_newest_file" ]]; then
_files_to_keep["$_newest_file"]="newest"
fi
# Process files and delete those not marked to keep
for _file in "${_all_files[@]}"; do
if [[ -n "${_files_to_keep[$_file]:-}" ]]; then
_kept_count=$((_kept_count + 1))
# Show details only in debug mode
if [[ "$debug" == "yes" ]]; then
local _age_days="${_file_ages[$_file]}"
local _reason="${_files_to_keep[$_file]:-}"
log_info 'Keeping: <b>%s</b> (age: <b>%d</b>d, reason: <b>%s</b>)' "$(basename "$_file")" "$_age_days" "$_reason"
fi
else
_deleted_count=$((_deleted_count + 1))
# Show details only in debug mode or if there are few files to delete
if [[ "$debug" == "yes" || $_deleted_count -le 10 ]]; then
local _age_days="${_file_ages[$_file]}"
if [[ "$dry_run" == "yes" ]]; then
log_info 'Would delete: <b>%s</b> (age: <b>%d</b>d)' "$(basename "$_file")" "$_age_days"
else
rm -f "$_file"
log_info 'Deleted: <b>%s</b> (age: <b>%d</b>d)' "$(basename "$_file")" "$_age_days"
fi
else
# For larger number of files, delete without showing details
if [[ "$dry_run" != "yes" ]]; then
rm -f "$_file"
fi
fi
fi
done
save_log_data "kept:$_kept_count" "deleted:$_deleted_count"
if [[ "$dry_run" == "yes" ]]; then
handle_success 'Cleanup simulation: <b>%d</b> would be kept, <b>%d</b> would be deleted' "$_kept_count" "$_deleted_count"
if [[ $_deleted_count -gt 10 && "$debug" != "yes" ]]; then
log_info 'Use <code>--debug</code> to see details of all files'
fi
else
handle_success 'Cleanup completed: <b>%d</b> kept, <b>%d</b> deleted' "$_kept_count" "$_deleted_count"
if [[ $_deleted_count -gt 10 && "$debug" != "yes" ]]; then
log_info 'Use <code>--debug</code> to see details of all deleted files'
fi
fi
}
#-------------------------- Install Functions --------------------------------
# Functions for installing required tools
function install_yq() {
# Download and install yq tool from GitHub releases
# yq is required for reading YAML configuration files
log_info ---icon '📦' 'Installing yq tool...'
# shellcheck disable=SC2207
local urls
read -r -a urls <<<"$(curl -s https://api.github.com/repos/mikefarah/yq/releases/latest | sed 's/[()",{}]/ /g; s/ /\n/g' | grep "https.*releases/.*yq_linux_386")"
if [[ ${#urls[@]} -eq 0 ]]; then
log_error 'Failed to find yq download URL'
return 1
fi
if ! curl -L "${urls[0]}" >yq_linux_386; then
log_error 'Failed to download yq'
return 1
fi
if ! chmod +x yq_linux_386; then
log_error 'Failed to make yq executable'
return 1
fi
log_success ---icon '✨' 'yq installed successfully'
}
#-------------------------- List Functions --------------------------------
# Functions for listing available configuration options
function list_scopes() {
# Display list of available server names and scope names from configuration
local server_names_list
server_names_list="$(read_config '(.server[]|key)' | grep -v local | tr '\n' ' ')"
log_info '<b>Server names list</b>\n%s\n' "$server_names_list"
local scope_names_list
scope_names_list="$(read_config "(.scope[]|key)" | tr '\n' ' ')"
log_info '<b>Scopes list</b>\n%s\n' "$scope_names_list"
}
#-------------------------- Configuration Functions --------------------------------
# Functions for validating and merging configuration from YAML file
function validate_configuration() {
# Validate that required configuration exists: server_id, app_id, and their settings
# Loads server and scope configuration, merges variables with priority: server > scope
log_header 'Validate configuration'
if [[ -z "$server_id" || "$server_id" == "null" ]]; then
local server_names
read -r -a server_names <<<"$(read_config '(.server[]|key)' | grep -v local | tr '\n' ' ')"
handle_error "Server name not specified! Available options: %s" "$(array_to_string '<b>%s</b>' ', ' "${server_names[@]}")"
fi
eval "$(read_config ".server.$server_id" -o=shell)"
if [[ "$debug" == "yes" ]]; then
log_debug 'Server <b>%s</b> variables' "$server_id"
read_config ".server.$server_id" -o=shell
fi
if [[ -z "$ssh_host_name" || "$ssh_host_name" == "null" ]]; then
handle_error "Configuration for <b>%s</b> server does not exists!" "$server_id"
fi
if [[ -z "$app_id" ]]; then
handle_error "Scope not specified!"
fi
eval "$(read_config ".scope.$app_id" -o=shell 2>/dev/null)"
if [[ "$debug" == "yes" ]]; then
log_debug 'Scope <b>%s</b> variables' "$app_id"
read_config ".scope.$app_id" -o=shell
fi
if [[ -z "${ssh_sync_path:-}" || "$ssh_sync_path" == "null" ]] && [[ -z "${sql_file_pattern:-}" || "$sql_file_pattern" == "null" ]]; then
handle_error "Configuration for <b>%s</b> scope does not exists!" "$app_id"
fi
_merge_server_and_scope_variables
log_success ---icon '✓' "All ok"
}
function _merge_server_and_scope_variables() {
# Merge configuration variables from server and scope with priority: server settings override scope settings
# Creates remote_* variables that are used throughout the script
local variables=(ssh_sync_path ssh_sync_excludes gzip_content db_host db_port db_user db_pass db_name site_url wp_config_file)
if [[ "$debug" == "yes" ]]; then
log_debug 'Variables rewrite'
fi
for variable in "${variables[@]}"; do
# First try to get value from server configuration
local _var_name="server_${server_id}_${variable}"
eval "server_value=\${${_var_name}:-}"
if [[ -n "$server_value" && "$server_value" != "null" ]]; then
eval "remote_${variable}=\"\$server_value\""
else
# If not in server config, use value from scope
eval "scope_value=\${${variable}:-}"
if [[ -n "$scope_value" && "$scope_value" != "null" ]]; then
eval "remote_${variable}=\"\$scope_value\""
fi
fi
if [[ "$debug" == "yes" ]]; then
eval "remote_var_value=\${remote_${variable}:-}"
echo "${variable}: server=[${server_value:-}] scope=[${scope_value:-}] remote=[${remote_var_value:-}]"
fi
done
}
#-------------------------- File Operations Functions --------------------------------
# Functions for backing up, restoring, and archiving files using rsync and tar
function _prepare_rsync_excludes() {
# Prepare rsync exclude options from configuration
# Converts ssh_sync_excludes array into --exclude options for rsync
rsync_exclude=""
tar_exclude=""
# shellcheck disable=SC2206
ssh_sync_excludes_array=(${remote_ssh_sync_excludes:-})
# shellcheck disable=SC2154
if [[ "${#ssh_sync_excludes_array[@]}" -gt "0" ]]; then
for exclude_path in "${ssh_sync_excludes_array[@]}"; do
rsync_exclude+="--exclude $exclude_path "
done
fi
}
function _prepare_tar_excludes() {
# Prepare tar exclude options from configuration
# Only includes paths that actually exist in the filesystem
tar_exclude=""
if [[ "${#ssh_sync_excludes_array[@]}" -gt "0" ]]; then
for exclude_path in "${ssh_sync_excludes_array[@]}"; do
if [[ -d "${local_home_path}${ssh_sync_path}${exclude_path}" ]] || [[ -f "${local_home_path}${ssh_sync_path}${exclude_path}" ]]; then
tar_exclude+="--exclude='./$exclude_path' "
fi
done
fi
}
function backup_files() {
# Backup files from remote server to local directory using rsync
# Checks for changes first (unless --force), then syncs and optionally creates archive
if [[ "$dry_run" == "yes" ]]; then
# shellcheck disable=SC2154
log_header 'Would backup files: <b>%s:%s</b> → <b>%s</b> (dry-run)' "${ssh_host_name}" "${ssh_home_path}${ssh_sync_path}" "${local_home_path}${ssh_sync_path}"
# shellcheck disable=SC2048
# shellcheck disable=SC2086
rsync \
--dry-run \
--verbose \
--archive \
--compress \
--partial --progress \
--update \
--delete-after \
$rsync_exclude \
"${ssh_host_name}":"${ssh_home_path}${ssh_sync_path}" "${local_home_path}${ssh_sync_path}"
else
# shellcheck disable=SC2154
log_header 'Backup files: <b>%s:%s</b> → <b>%s</b>' "${ssh_host_name}" "${ssh_home_path}${ssh_sync_path}" "${local_home_path}${ssh_sync_path}"
save_log_start "files" "backup" "${ssh_host_name}:${ssh_home_path}${ssh_sync_path}" "${local_home_path}${ssh_sync_path}"
mkdir -p "${local_home_path}${ssh_sync_path}" || handle_error "Failed to create backup directory: <u>%s</u>" "${local_home_path}${ssh_sync_path}"
# Check if there are changes to sync (unless --force is used)
local _has_changes="x"
if [[ "$force" == "no" ]]; then
# shellcheck disable=SC2086
# Use || true to prevent subshell from exiting on grep failure (no matches)
_has_changes=$(rsync \
--dry-run \
--archive \
--compress \
--partial --progress \
--update \
--delete-after \
--out-format='changed file: %i %n%L' \
$rsync_exclude \
"${ssh_host_name}":"${ssh_home_path}${ssh_sync_path}" "${local_home_path}${ssh_sync_path}" 2>&1 | grep -F 'changed file:' || true)
else
_has_changes="force"
fi
if [[ "$_has_changes" == "" ]]; then
# No changes detected, just rename existing archive
log_success ---icon '💾' 'No new changes in <b>%s</b>' "${ssh_host_name}:${ssh_home_path}${ssh_sync_path}"
mode="rename"
else
# Changes detected, sync files and create new archive
log_success ---icon '📁' 'Found new files in <b>%s</b>' "${ssh_host_name}:${ssh_home_path}${ssh_sync_path}"
# shellcheck disable=SC2048
# shellcheck disable=SC2086
if ! rsync \
--verbose \
--archive \
--compress \
--partial --progress \
--update \
--delete-after \
$rsync_exclude \
"${ssh_host_name}":"${ssh_home_path}${ssh_sync_path}" "${local_home_path}${ssh_sync_path}" 2>&1; then
handle_error "rsync failed during files backup from <u>%s</u>" "${ssh_host_name}:${ssh_home_path}${ssh_sync_path}"
fi
mode="create"
fi
handle_success "Files backed up successfully."
# Create archive if gzip_content is enabled and skip_archive is not set
# shellcheck disable=SC2154
if [[ "${gzip_content:-no}" == "yes" && "${skip_archive:-no}" == "no" ]]; then
pack_files
fi
fi
}
function pack_files() {
# Create compressed tar archive from backed up files
# Mode "rename": renames existing archive to current date
# Mode "create": creates new archive with current date
_prepare_tar_excludes
current_export_file_name="$(date +%Y%m%d).${app_id}.tar.gz"
log_header 'Pack files: <b>%s</b> (mode: <b>%s</b>)' "${local_archive_path}${current_export_file_name}" "$mode"
save_log_start "files" "pack" "${local_archive_path}${current_export_file_name}" "$mode"
case "$mode" in
rename)
# Rename existing archive to current date (no changes detected, just update timestamp)
# shellcheck disable=SC2207
all_export_file_names=($(cd "$local_archive_path" && ls -r "*.${app_id}.tar.gz" 2>/dev/null || true))
if [[ "${#all_export_file_names[@]}" -gt 0 ]]; then
latest_export_file_name=${all_export_file_names[0]}
if [[ "$latest_export_file_name" != "$current_export_file_name" ]]; then
(cd "$local_archive_path" && mv "$latest_export_file_name" "$current_export_file_name" || true)
fi
fi
handle_success 'Files moved successfully'
;;
create)
# Create new archive with current date (changes were detected)
rm -rf "${local_archive_path}${current_export_file_name}"
# shellcheck disable=SC2086
# shellcheck disable=SC2048
if ! eval "(cd "${local_home_path}${ssh_sync_path}" && tar -zcvf "${local_archive_path}${current_export_file_name}" $tar_exclude .)"; then
handle_error "Failed to create archive: <u>%s</u>" "${current_export_file_name}"
fi
handle_success 'Files packed successfully'
;;