From d73e00db1c05ba22a017432c71e3278d2ad2d7ad Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 21 Sep 2026 09:26:55 +0800 Subject: [PATCH] Add MQTT backup reporting and trigger listener --- .env.example | 9 +++++ README.md | 42 +++++++++++++++++++++ backup.sh | 31 ++++++++++++++++ bin/mqtt-trigger-listener.sh | 24 ++++++++++++ lib/common.sh | 14 ++++++- lib/mqtt.sh | 19 ++++++++++ stages/10-containers.sh | 1 + stages/99-mqtt-report.sh | 43 ++++++++++++++++++++++ systemd/zorruno-docker-backup-mqtt.service | 15 ++++++++ 9 files changed, 197 insertions(+), 1 deletion(-) create mode 100755 bin/mqtt-trigger-listener.sh create mode 100644 lib/mqtt.sh create mode 100755 stages/99-mqtt-report.sh create mode 100644 systemd/zorruno-docker-backup-mqtt.service diff --git a/.env.example b/.env.example index cfb42fc..feea13f 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,15 @@ START_RETRIES=1 LOCK_FILE="/run/lock/zorruno-docker-backup.lock" ZSTD_EXTRA_OPTS="-T0 -12" +# Optional retained MQTT status and command listener settings. +MQTT_REPORT_ENABLED=0 +MQTT_HOST="beaver" +MQTT_PORT=1883 +MQTT_USERNAME="dockerbackup" +MQTT_PASSWORD_FILE="/etc/zorruno-docker-backup/mqtt-password" +MQTT_STATUS_TOPIC="viewroad-status/dockerbackup/beaver" +MQTT_COMMAND_TOPIC="viewroad-commands/dockerbackup/beaver/run" + # Rclone destination becomes /beaver/. RCLONE_ENABLED=0 RCLONE_REMOTE="gdrive:dockerbackups" diff --git a/README.md b/README.md index c41a63c..6a9203f 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,48 @@ sudo stages/90-prune.sh --dry-run `--force` runs a transfer method even when its `*_ENABLED` value is false. It still requires the method's destination to be configured and available. +## MQTT reporting and Home Assistant trigger + +Enable retained run results in the host's `.env`: + +```bash +MQTT_REPORT_ENABLED=1 +MQTT_HOST="beaver" +MQTT_PORT=1883 +MQTT_USERNAME="dockerbackup" +MQTT_PASSWORD_FILE="/etc/zorruno-docker-backup/mqtt-password" +MQTT_STATUS_TOPIC="viewroad-status/dockerbackup/panda" +MQTT_COMMAND_TOPIC="viewroad-commands/dockerbackup/panda/run" +``` + +The retained status JSON includes overall status, timestamps, duration, exit +code, container totals, successful backups, dry-run counts, and error messages. +Final reporting runs from an exit trap, so failures in any earlier stage are +reported when MQTT remains available. + +Install the optional root-run command listener with: + +```bash +cp systemd/zorruno-docker-backup-mqtt.service /etc/systemd/system/ +systemctl daemon-reload +systemctl enable --now zorruno-docker-backup-mqtt.service +``` + +It accepts only JSON containing the exact action `run` on the configured +command topic: + +```json +{"action":"run"} +``` + +Publish commands without MQTT retain. The listener also ignores retained +messages, preventing an old command from starting another backup after a +listener or broker restart. + +Restrict publish access to the command topic with broker ACLs. The listener +runs as root because the backup stage controls Docker containers. The existing +pipeline lock prevents concurrent runs. + ## Adding another backup method Add an executable script named `methods/NN-name.sh`, for example diff --git a/backup.sh b/backup.sh index 0efe826..0f47676 100755 --- a/backup.sh +++ b/backup.sh @@ -16,6 +16,32 @@ while [[ $# -gt 0 ]]; do esac shift done + +BACKUP_RUN_ID="$(run_id)" +BACKUP_STARTED_EPOCH="$(date +%s)" +BACKUP_RUN_LOG="$(mktemp "${TMPDIR:-/tmp}/zorruno-docker-backup.XXXXXX.log")" +export BACKUP_RUN_ID BACKUP_STARTED_EPOCH BACKUP_RUN_LOG + +finish_run() { + local exit_code=$? finished status + trap - EXIT + set +e + finished="$(date +%s)" + if (( exit_code == 0 )); then + if (( DRY_RUN )); then status="dry_run_success"; else status="success"; fi + else + if (( DRY_RUN )); then status="dry_run_failed"; else status="failed"; fi + fi + if is_true "$MQTT_REPORT_ENABLED"; then + "$SCRIPT_DIR/stages/99-mqtt-report.sh" "$status" "$exit_code" \ + "$BACKUP_STARTED_EPOCH" "$finished" "$BACKUP_RUN_ID" "$BACKUP_RUN_LOG" \ + || log ERROR "Unable to publish final MQTT result" + fi + rm -f -- "$BACKUP_RUN_LOG" + exit "$exit_code" +} +trap finish_run EXIT + require_command flock mkdir -p "$(dirname "$LOCK_FILE")" exec 9>"$LOCK_FILE" @@ -24,6 +50,11 @@ stage_args=() (( DRY_RUN )) && stage_args+=(--dry-run) [[ -n "$CONTAINERS_OVERRIDE" ]] && stage_args+=(--containers "$CONTAINERS_OVERRIDE") log START "Backup pipeline for $BACKUP_HOSTNAME" +if is_true "$MQTT_REPORT_ENABLED"; then + "$SCRIPT_DIR/stages/99-mqtt-report.sh" running 0 "$BACKUP_STARTED_EPOCH" \ + "$BACKUP_STARTED_EPOCH" "$BACKUP_RUN_ID" "$BACKUP_RUN_LOG" \ + || log WARN "Unable to publish MQTT running state; backup will continue" +fi (( SKIP_CONTAINERS )) || "$SCRIPT_DIR/stages/10-containers.sh" "${stage_args[@]}" method_args=(); (( DRY_RUN )) && method_args+=(--dry-run) for method in $BACKUP_METHODS; do diff --git a/bin/mqtt-trigger-listener.sh b/bin/mqtt-trigger-listener.sh new file mode 100755 index 0000000..94e8f05 --- /dev/null +++ b/bin/mqtt-trigger-listener.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Listen for an authenticated JSON command and run the backup pipeline. +set -Eeuo pipefail +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)" +. "$SCRIPT_DIR/lib/common.sh" +. "$SCRIPT_DIR/lib/mqtt.sh" + +require_command jq +require_command mosquitto_sub +[[ -n "$MQTT_HOST" ]] || die "MQTT_HOST is not configured" +mqtt_common_args +log MQTT "Listening on $MQTT_COMMAND_TOPIC" + +# Never replay a retained command after this listener or the MQTT broker restarts. +# Home Assistant should also publish run commands without the retain flag. +mosquitto_sub "${MQTT_ARGS[@]}" -R -t "$MQTT_COMMAND_TOPIC" | while IFS= read -r payload; do + action="$(jq -er '.action // empty' <<<"$payload" 2>/dev/null || true)" + if [[ "$action" != "run" ]]; then + log WARN "Ignored invalid MQTT command" + continue + fi + log MQTT "Accepted backup run command" + "$SCRIPT_DIR/backup.sh" || log ERROR "MQTT-triggered backup failed" +done diff --git a/lib/common.sh b/lib/common.sh index 39ca80d..8074dcc 100644 --- a/lib/common.sh +++ b/lib/common.sh @@ -16,6 +16,13 @@ WAIT_INTERVAL=3 START_RETRIES=1 LOCK_FILE="/run/lock/zorruno-docker-backup.lock" ZSTD_EXTRA_OPTS="-T0 -12" +MQTT_REPORT_ENABLED=0 +MQTT_HOST="" +MQTT_PORT=1883 +MQTT_USERNAME="" +MQTT_PASSWORD_FILE="" +MQTT_STATUS_TOPIC="viewroad-status/dockerbackup/$BACKUP_HOSTNAME" +MQTT_COMMAND_TOPIC="viewroad-commands/dockerbackup/$BACKUP_HOSTNAME/run" RCLONE_ENABLED=0 RCLONE_REMOTE="" @@ -39,7 +46,12 @@ if [[ -n "${SOURCE_DIR:-}" && "$BACKUP_ROOT" == "/dockerbackups" ]]; then fi timestamp() { date '+%F %T'; } -log() { printf '%s [%s] %s\n' "$(timestamp)" "$1" "$2" >&2; } +log() { + local line + line="$(timestamp) [$1] $2" + printf '%s\n' "$line" >&2 + [[ -n "${BACKUP_RUN_LOG:-}" ]] && printf '%s\n' "$line" >>"$BACKUP_RUN_LOG" +} die() { log ERROR "$1"; exit "${2:-1}"; } is_true() { diff --git a/lib/mqtt.sh b/lib/mqtt.sh new file mode 100644 index 0000000..bd1076c --- /dev/null +++ b/lib/mqtt.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash + +mqtt_common_args() { + MQTT_ARGS=(-h "$MQTT_HOST" -p "$MQTT_PORT") + [[ -n "$MQTT_USERNAME" ]] && MQTT_ARGS+=(-u "$MQTT_USERNAME") + if [[ -n "$MQTT_PASSWORD_FILE" ]]; then + [[ -r "$MQTT_PASSWORD_FILE" ]] || die "MQTT password file is not readable: $MQTT_PASSWORD_FILE" + MQTT_ARGS+=(-P "$(<"$MQTT_PASSWORD_FILE")") + fi +} + +mqtt_publish() { + local topic="$1" payload="$2" retain="${3:-0}" + [[ -n "$MQTT_HOST" ]] || die "MQTT_HOST is not configured" + require_command mosquitto_pub + mqtt_common_args + (( retain )) && MQTT_ARGS+=(-r) + mosquitto_pub "${MQTT_ARGS[@]}" -t "$topic" -m "$payload" +} diff --git a/stages/10-containers.sh b/stages/10-containers.sh index 59d10df..d348d5a 100755 --- a/stages/10-containers.sh +++ b/stages/10-containers.sh @@ -100,6 +100,7 @@ backup_project() { compose up -d || true return 1 fi + log ARCHIVE "Created $output" if is_true "$PULL_IMAGES"; then compose pull || log WARN "Image pull failed for $project; starting with available images" fi diff --git a/stages/99-mqtt-report.sh b/stages/99-mqtt-report.sh new file mode 100755 index 0000000..02738d4 --- /dev/null +++ b/stages/99-mqtt-report.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Publish retained backup run state for Home Assistant. +set -Eeuo pipefail +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" +. "$SCRIPT_DIR/../lib/common.sh" +. "$SCRIPT_DIR/../lib/mqtt.sh" + +STATUS="${1:?Usage: ${0##*/} STATUS EXIT_CODE STARTED_EPOCH FINISHED_EPOCH RUN_ID LOG_FILE}" +EXIT_CODE="${2:-0}" +STARTED_EPOCH="${3:-$(date +%s)}" +FINISHED_EPOCH="${4:-$(date +%s)}" +RUN_ID="${5:-unknown}" +LOG_FILE="${6:-/dev/null}" + +require_command jq +total=0; archived=0; dry_run=0; error_count=0 +if [[ -r "$LOG_FILE" ]]; then + total="$(grep -c '\[INFO\] Project:' "$LOG_FILE" || true)" + archived="$(grep -c '\[ARCHIVE\] Created ' "$LOG_FILE" || true)" + dry_run="$(grep -c '\[DRY\] Would stop, archive' "$LOG_FILE" || true)" + error_count="$(grep -c '\[ERROR\]' "$LOG_FILE" || true)" + errors_json="$(sed -n 's/^.*\[ERROR\] //p' "$LOG_FILE" | jq -Rsc 'split("\n") | map(select(length > 0))')" +else + errors_json='[]' +fi +if (( EXIT_CODE != 0 && error_count == 0 )); then + errors_json="$(jq -cn --arg message "Pipeline exited with status $EXIT_CODE" '[ $message ]')" + error_count=1 +fi + +payload="$(jq -cn \ + --arg host "$BACKUP_HOSTNAME" --arg status "$STATUS" --arg run_id "$RUN_ID" \ + --argjson exit_code "$EXIT_CODE" --argjson started "$STARTED_EPOCH" \ + --argjson finished "$FINISHED_EPOCH" --argjson duration "$((FINISHED_EPOCH - STARTED_EPOCH))" \ + --argjson total "$total" --argjson archived "$archived" --argjson dry_run "$dry_run" \ + --argjson error_count "$error_count" --argjson errors "$errors_json" \ + '{host:$host,status:$status,run_id:$run_id,exit_code:$exit_code, + started_at:($started|todateiso8601),finished_at:($finished|todateiso8601), + duration_seconds:$duration,containers_total:$total,containers_backed_up:$archived, + containers_dry_run:$dry_run,error_count:$error_count,errors:$errors}')" + +mqtt_publish "$MQTT_STATUS_TOPIC" "$payload" 1 +log MQTT "Published $STATUS result to $MQTT_STATUS_TOPIC" diff --git a/systemd/zorruno-docker-backup-mqtt.service b/systemd/zorruno-docker-backup-mqtt.service new file mode 100644 index 0000000..bbbb448 --- /dev/null +++ b/systemd/zorruno-docker-backup-mqtt.service @@ -0,0 +1,15 @@ +[Unit] +Description=Zorruno Docker backup MQTT command listener +After=network-online.target docker.service +Wants=network-online.target + +[Service] +Type=simple +User=root +WorkingDirectory=/backupdockerscripts +ExecStart=/backupdockerscripts/bin/mqtt-trigger-listener.sh +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target