diff --git a/bin/gniza b/bin/gniza index 7c6eb07..e10055f 100755 --- a/bin/gniza +++ b/bin/gniza @@ -15,6 +15,7 @@ source "$GNIZA_DIR/lib/locking.sh" source "$GNIZA_DIR/lib/targets.sh" source "$GNIZA_DIR/lib/remotes.sh" source "$GNIZA_DIR/lib/backup.sh" +source "$GNIZA_DIR/lib/mysql.sh" source "$GNIZA_DIR/lib/restore.sh" source "$GNIZA_DIR/lib/retention.sh" source "$GNIZA_DIR/lib/schedule.sh" @@ -206,6 +207,15 @@ run_cli() { echo "TARGET_PRE_HOOK=$TARGET_PRE_HOOK" echo "TARGET_POST_HOOK=$TARGET_POST_HOOK" echo "TARGET_ENABLED=$TARGET_ENABLED" + echo "TARGET_MYSQL_ENABLED=$TARGET_MYSQL_ENABLED" + echo "TARGET_MYSQL_MODE=$TARGET_MYSQL_MODE" + echo "TARGET_MYSQL_DATABASES=$TARGET_MYSQL_DATABASES" + echo "TARGET_MYSQL_EXCLUDE=$TARGET_MYSQL_EXCLUDE" + echo "TARGET_MYSQL_USER=$TARGET_MYSQL_USER" + echo "TARGET_MYSQL_PASSWORD=****" + echo "TARGET_MYSQL_HOST=$TARGET_MYSQL_HOST" + echo "TARGET_MYSQL_PORT=$TARGET_MYSQL_PORT" + echo "TARGET_MYSQL_EXTRA_OPTS=$TARGET_MYSQL_EXTRA_OPTS" ;; *) die "Unknown targets action: $action (expected list|add|delete|show)" diff --git a/etc/target.conf.example b/etc/target.conf.example index 599c508..e711099 100644 --- a/etc/target.conf.example +++ b/etc/target.conf.example @@ -9,3 +9,13 @@ TARGET_RETENTION="" TARGET_PRE_HOOK="" TARGET_POST_HOOK="" TARGET_ENABLED="yes" +# MySQL Backup +#TARGET_MYSQL_ENABLED="no" +#TARGET_MYSQL_MODE="all" +#TARGET_MYSQL_DATABASES="" +#TARGET_MYSQL_EXCLUDE="" +#TARGET_MYSQL_USER="" +#TARGET_MYSQL_PASSWORD="" +#TARGET_MYSQL_HOST="localhost" +#TARGET_MYSQL_PORT="3306" +#TARGET_MYSQL_EXTRA_OPTS="--single-transaction --routines --triggers" diff --git a/lib/backup.sh b/lib/backup.sh index 7e0f5bc..2df10af 100644 --- a/lib/backup.sh +++ b/lib/backup.sh @@ -101,6 +101,19 @@ _backup_target_impl() { fi fi + # 8.5. Dump MySQL databases (if enabled) + local mysql_dump_dir="" + if [[ "${TARGET_MYSQL_ENABLED:-no}" == "yes" ]]; then + log_info "Dumping MySQL databases for $target_name..." + if mysql_dump_databases; then + mysql_dump_dir="${MYSQL_DUMP_DIR:-}" + else + log_error "MySQL dump failed for $target_name" + mysql_cleanup_dump + return 1 + fi + fi + # 9. Transfer each folder local folder local transfer_failed=false @@ -112,6 +125,18 @@ _backup_target_impl() { fi done < <(get_target_folders) + # 9.5. Transfer MySQL dumps + if [[ -n "$mysql_dump_dir" && -d "$mysql_dump_dir/_mysql" ]]; then + log_info "Transferring MySQL dumps for $target_name..." + if ! transfer_folder "$target_name" "$mysql_dump_dir/_mysql" "$ts" "$prev"; then + log_error "Transfer failed for MySQL dumps" + transfer_failed=true + fi + fi + + # Cleanup MySQL temp dir + mysql_cleanup_dump + if [[ "$transfer_failed" == "true" ]]; then log_error "One or more folder transfers failed for $target_name" return 1 @@ -132,6 +157,7 @@ _backup_target_impl() { "timestamp": "$ts", "duration": $duration, "folders": "$(echo "$TARGET_FOLDERS" | sed 's/"/\\"/g')", + "mysql_dumps": $([ "${TARGET_MYSQL_ENABLED:-no}" = "yes" ] && echo "true" || echo "false"), "total_size": $total_size, "mode": "${BACKUP_MODE:-$DEFAULT_BACKUP_MODE}", "pinned": false diff --git a/lib/mysql.sh b/lib/mysql.sh new file mode 100644 index 0000000..87a5a21 --- /dev/null +++ b/lib/mysql.sh @@ -0,0 +1,201 @@ +#!/usr/bin/env bash +# gniza4linux/lib/mysql.sh — MySQL database dump support + +[[ -n "${_GNIZA4LINUX_MYSQL_LOADED:-}" ]] && return 0 +_GNIZA4LINUX_MYSQL_LOADED=1 + +# System databases always excluded from dumps +_MYSQL_SYSTEM_DBS="information_schema performance_schema sys" + +# Detect the mysqldump binary (MySQL or MariaDB). +_mysql_find_dump_cmd() { + if command -v mysqldump &>/dev/null; then + echo "mysqldump" + elif command -v mariadb-dump &>/dev/null; then + echo "mariadb-dump" + else + return 1 + fi +} + +# Detect the mysql client binary. +_mysql_find_client_cmd() { + if command -v mysql &>/dev/null; then + echo "mysql" + elif command -v mariadb &>/dev/null; then + echo "mariadb" + else + return 1 + fi +} + +# Build connection arguments from TARGET_MYSQL_* globals into MYSQL_CONN_ARGS array. +# Sets MYSQL_PWD env var if password is configured. +mysql_build_conn_args() { + MYSQL_CONN_ARGS=() + if [[ -n "${TARGET_MYSQL_USER:-}" ]]; then + MYSQL_CONN_ARGS+=(-u "$TARGET_MYSQL_USER") + fi + if [[ -n "${TARGET_MYSQL_HOST:-}" && "${TARGET_MYSQL_HOST}" != "localhost" ]]; then + MYSQL_CONN_ARGS+=(-h "$TARGET_MYSQL_HOST") + fi + if [[ -n "${TARGET_MYSQL_PORT:-}" && "${TARGET_MYSQL_PORT}" != "3306" ]]; then + MYSQL_CONN_ARGS+=(-P "$TARGET_MYSQL_PORT") + fi + if [[ -n "${TARGET_MYSQL_PASSWORD:-}" ]]; then + export MYSQL_PWD="${TARGET_MYSQL_PASSWORD}" + fi +} + +# Get list of databases to dump. +# Outputs one database name per line. +mysql_get_databases() { + local client_cmd + client_cmd=$(_mysql_find_client_cmd) || { + log_error "MySQL/MariaDB client not found" + return 1 + } + + mysql_build_conn_args + + local all_dbs + all_dbs=$("$client_cmd" "${MYSQL_CONN_ARGS[@]}" -N -e "SHOW DATABASES" 2>&1) || { + log_error "Failed to list databases: $all_dbs" + return 1 + } + + # Build exclude list: system dbs + user-specified excludes + local -a exclude_list=() + local db + for db in $_MYSQL_SYSTEM_DBS; do + exclude_list+=("$db") + done + if [[ -n "${TARGET_MYSQL_EXCLUDE:-}" ]]; then + local -a user_excludes + IFS=',' read -ra user_excludes <<< "$TARGET_MYSQL_EXCLUDE" + local ex + for ex in "${user_excludes[@]}"; do + ex="${ex#"${ex%%[![:space:]]*}"}" + ex="${ex%"${ex##*[![:space:]]}"}" + [[ -n "$ex" ]] && exclude_list+=("$ex") + done + fi + + while IFS= read -r db; do + db="${db#"${db%%[![:space:]]*}"}" + db="${db%"${db##*[![:space:]]}"}" + [[ -z "$db" ]] && continue + + # Skip system/excluded databases + local skip=false + local ex + for ex in "${exclude_list[@]}"; do + if [[ "$db" == "$ex" ]]; then + skip=true + break + fi + done + [[ "$skip" == "true" ]] && continue + + echo "$db" + done <<< "$all_dbs" +} + +# Dump all configured databases to a temp directory. +# Sets MYSQL_DUMP_DIR global to the temp directory path containing _mysql/ subdir. +# Returns 0 on success, 1 on failure. +mysql_dump_databases() { + local dump_cmd + dump_cmd=$(_mysql_find_dump_cmd) || { + log_error "mysqldump/mariadb-dump not found — cannot dump MySQL databases" + return 1 + } + + mysql_build_conn_args + + # Determine databases to dump + local -a databases=() + if [[ "${TARGET_MYSQL_MODE:-all}" == "select" ]]; then + # Use explicitly listed databases + if [[ -z "${TARGET_MYSQL_DATABASES:-}" ]]; then + log_error "MySQL mode=select but TARGET_MYSQL_DATABASES is empty" + return 1 + fi + local -a db_list + IFS=',' read -ra db_list <<< "$TARGET_MYSQL_DATABASES" + local db + for db in "${db_list[@]}"; do + db="${db#"${db%%[![:space:]]*}"}" + db="${db%"${db##*[![:space:]]}"}" + [[ -n "$db" ]] && databases+=("$db") + done + else + # mode=all: discover databases, apply excludes + local db_output + db_output=$(mysql_get_databases) || { + log_error "Failed to discover MySQL databases" + return 1 + } + while IFS= read -r db; do + [[ -n "$db" ]] && databases+=("$db") + done <<< "$db_output" + fi + + if [[ ${#databases[@]} -eq 0 ]]; then + log_warn "No databases to dump" + return 0 + fi + + # Create temp directory + MYSQL_DUMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/gniza-mysql-XXXXXX") + mkdir -p "$MYSQL_DUMP_DIR/_mysql" + + # Parse extra opts into array + local -a extra_opts_arr=() + if [[ -n "${TARGET_MYSQL_EXTRA_OPTS:-}" ]]; then + read -ra extra_opts_arr <<< "${TARGET_MYSQL_EXTRA_OPTS}" + else + extra_opts_arr=(--single-transaction --routines --triggers) + fi + local failed=false + + for db in "${databases[@]}"; do + # Validate database name to prevent path traversal + if [[ ! "$db" =~ ^[a-zA-Z0-9_-]+$ ]]; then + log_error "Invalid database name, skipping: $db" + failed=true + continue + fi + log_info "Dumping MySQL database: $db" + local outfile="$MYSQL_DUMP_DIR/_mysql/${db}.sql.gz" + local errfile="$MYSQL_DUMP_DIR/_mysql/${db}.err" + if "$dump_cmd" "${MYSQL_CONN_ARGS[@]}" "${extra_opts_arr[@]}" "$db" 2>"$errfile" | gzip > "$outfile"; then + rm -f "$errfile" + local size; size=$(stat -c%s "$outfile" 2>/dev/null || echo "?") + log_debug "Dumped $db -> ${db}.sql.gz ($size bytes)" + else + log_error "Failed to dump database: $db" + [[ -s "$errfile" ]] && log_error "mysqldump: $(cat "$errfile")" + rm -f "$errfile" + failed=true + fi + done + + if [[ "$failed" == "true" ]]; then + log_error "One or more MySQL dumps failed" + return 1 + fi + + log_info "MySQL dumps completed: ${#databases[@]} database(s) in $MYSQL_DUMP_DIR/_mysql/" + return 0 +} + +# Clean up the temporary MySQL dump directory and env vars. +mysql_cleanup_dump() { + if [[ -n "${MYSQL_DUMP_DIR:-}" && -d "$MYSQL_DUMP_DIR" ]]; then + rm -rf "$MYSQL_DUMP_DIR" + log_debug "Cleaned up MySQL dump dir: $MYSQL_DUMP_DIR" + MYSQL_DUMP_DIR="" + fi + unset MYSQL_PWD 2>/dev/null || true +} diff --git a/lib/targets.sh b/lib/targets.sh index 7550bac..615c170 100644 --- a/lib/targets.sh +++ b/lib/targets.sh @@ -53,6 +53,15 @@ load_target() { TARGET_PRE_HOOK="${TARGET_PRE_HOOK:-}" TARGET_POST_HOOK="${TARGET_POST_HOOK:-}" TARGET_ENABLED="${TARGET_ENABLED:-yes}" + TARGET_MYSQL_ENABLED="${TARGET_MYSQL_ENABLED:-no}" + TARGET_MYSQL_MODE="${TARGET_MYSQL_MODE:-all}" + TARGET_MYSQL_DATABASES="${TARGET_MYSQL_DATABASES:-}" + TARGET_MYSQL_EXCLUDE="${TARGET_MYSQL_EXCLUDE:-}" + TARGET_MYSQL_USER="${TARGET_MYSQL_USER:-}" + TARGET_MYSQL_PASSWORD="${TARGET_MYSQL_PASSWORD:-}" + TARGET_MYSQL_HOST="${TARGET_MYSQL_HOST:-localhost}" + TARGET_MYSQL_PORT="${TARGET_MYSQL_PORT:-3306}" + TARGET_MYSQL_EXTRA_OPTS="${TARGET_MYSQL_EXTRA_OPTS:---single-transaction --routines --triggers}" log_debug "Loaded target '$name': folders=${TARGET_FOLDERS} enabled=${TARGET_ENABLED}" } @@ -72,10 +81,10 @@ validate_target() { ((errors++)) || true fi - if [[ -z "$TARGET_FOLDERS" ]]; then - log_error "Target '$name': TARGET_FOLDERS is required" + if [[ -z "$TARGET_FOLDERS" && "${TARGET_MYSQL_ENABLED:-no}" != "yes" ]]; then + log_error "Target '$name': TARGET_FOLDERS is required (or enable MySQL backup)" ((errors++)) || true - else + elif [[ -n "$TARGET_FOLDERS" ]]; then # Validate each folder exists local -a folders IFS=',' read -ra folders <<< "$TARGET_FOLDERS" diff --git a/tui/gniza.tcss b/tui/gniza.tcss index 7bd23e0..f1a76d0 100644 --- a/tui/gniza.tcss +++ b/tui/gniza.tcss @@ -235,3 +235,9 @@ Select { Switch { margin: 0 1; } + +.section-label { + text-style: bold; + color: #00cc00; + margin: 1 0 0 0; +} diff --git a/tui/models.py b/tui/models.py index 8d37baf..952511e 100644 --- a/tui/models.py +++ b/tui/models.py @@ -11,6 +11,15 @@ class Target: pre_hook: str = "" post_hook: str = "" enabled: str = "yes" + mysql_enabled: str = "no" + mysql_mode: str = "all" + mysql_databases: str = "" + mysql_exclude: str = "" + mysql_user: str = "" + mysql_password: str = "" + mysql_host: str = "localhost" + mysql_port: str = "3306" + mysql_extra_opts: str = "--single-transaction --routines --triggers" def to_conf(self) -> dict[str, str]: return { @@ -22,6 +31,15 @@ class Target: "TARGET_PRE_HOOK": self.pre_hook, "TARGET_POST_HOOK": self.post_hook, "TARGET_ENABLED": self.enabled, + "TARGET_MYSQL_ENABLED": self.mysql_enabled, + "TARGET_MYSQL_MODE": self.mysql_mode, + "TARGET_MYSQL_DATABASES": self.mysql_databases, + "TARGET_MYSQL_EXCLUDE": self.mysql_exclude, + "TARGET_MYSQL_USER": self.mysql_user, + "TARGET_MYSQL_PASSWORD": self.mysql_password, + "TARGET_MYSQL_HOST": self.mysql_host, + "TARGET_MYSQL_PORT": self.mysql_port, + "TARGET_MYSQL_EXTRA_OPTS": self.mysql_extra_opts, } @classmethod @@ -35,6 +53,15 @@ class Target: pre_hook=data.get("TARGET_PRE_HOOK", ""), post_hook=data.get("TARGET_POST_HOOK", ""), enabled=data.get("TARGET_ENABLED", "yes"), + mysql_enabled=data.get("TARGET_MYSQL_ENABLED", "no"), + mysql_mode=data.get("TARGET_MYSQL_MODE", "all"), + mysql_databases=data.get("TARGET_MYSQL_DATABASES", ""), + mysql_exclude=data.get("TARGET_MYSQL_EXCLUDE", ""), + mysql_user=data.get("TARGET_MYSQL_USER", ""), + mysql_password=data.get("TARGET_MYSQL_PASSWORD", ""), + mysql_host=data.get("TARGET_MYSQL_HOST", "localhost"), + mysql_port=data.get("TARGET_MYSQL_PORT", "3306"), + mysql_extra_opts=data.get("TARGET_MYSQL_EXTRA_OPTS", "--single-transaction --routines --triggers"), ) diff --git a/tui/screens/target_edit.py b/tui/screens/target_edit.py index 430680f..236fb0c 100644 --- a/tui/screens/target_edit.py +++ b/tui/screens/target_edit.py @@ -52,6 +52,33 @@ class TargetEditScreen(Screen): value="yes" if target.enabled == "yes" else "no", id="te-enabled", ) + yield Static("--- MySQL Backup ---", classes="section-label") + yield Static("MySQL Enabled:") + yield Select( + [("No", "no"), ("Yes", "yes")], + value=target.mysql_enabled, + id="te-mysql-enabled", + ) + yield Static("MySQL Mode:") + yield Select( + [("All databases", "all"), ("Select databases", "select")], + value=target.mysql_mode, + id="te-mysql-mode", + ) + yield Static("Databases (comma-separated, when mode=select):") + yield Input(value=target.mysql_databases, placeholder="db1,db2", id="te-mysql-databases") + yield Static("Exclude databases (comma-separated, when mode=all):") + yield Input(value=target.mysql_exclude, placeholder="test_db,dev_db", id="te-mysql-exclude") + yield Static("MySQL User:") + yield Input(value=target.mysql_user, placeholder="Leave empty for socket/~/.my.cnf auth", id="te-mysql-user") + yield Static("MySQL Password:") + yield Input(value=target.mysql_password, placeholder="Leave empty for socket/~/.my.cnf auth", password=True, id="te-mysql-password") + yield Static("MySQL Host:") + yield Input(value=target.mysql_host, placeholder="localhost", id="te-mysql-host") + yield Static("MySQL Port:") + yield Input(value=target.mysql_port, placeholder="3306", id="te-mysql-port") + yield Static("MySQL Extra Options:") + yield Input(value=target.mysql_extra_opts, placeholder="--single-transaction --routines --triggers", id="te-mysql-extra-opts") with Horizontal(id="te-buttons"): yield Button("Save", variant="primary", id="btn-save") yield Button("Cancel", id="btn-cancel") @@ -96,8 +123,9 @@ class TargetEditScreen(Screen): name = self._edit_name folders = self.query_one("#te-folders", Input).value.strip() - if not folders: - self.notify("At least one folder is required", severity="error") + mysql_enabled = str(self.query_one("#te-mysql-enabled", Select).value) + if not folders and mysql_enabled != "yes": + self.notify("At least one folder or MySQL backup is required", severity="error") return target = Target( @@ -109,6 +137,15 @@ class TargetEditScreen(Screen): pre_hook=self.query_one("#te-prehook", Input).value.strip(), post_hook=self.query_one("#te-posthook", Input).value.strip(), enabled=str(self.query_one("#te-enabled", Select).value), + mysql_enabled=mysql_enabled, + mysql_mode=str(self.query_one("#te-mysql-mode", Select).value), + mysql_databases=self.query_one("#te-mysql-databases", Input).value.strip(), + mysql_exclude=self.query_one("#te-mysql-exclude", Input).value.strip(), + mysql_user=self.query_one("#te-mysql-user", Input).value.strip(), + mysql_password=self.query_one("#te-mysql-password", Input).value.strip(), + mysql_host=self.query_one("#te-mysql-host", Input).value.strip(), + mysql_port=self.query_one("#te-mysql-port", Input).value.strip(), + mysql_extra_opts=self.query_one("#te-mysql-extra-opts", Input).value.strip(), ) conf = CONFIG_DIR / "targets.d" / f"{name}.conf" write_conf(conf, target.to_conf())