Add MQTT backup reporting and trigger listener

This commit is contained in:
Codex authored and zorruno committed 2026-09-21 13:58:14 +12:00
1 parent a1ce3b19bc
commit d73e00db1c
9 files changed
+197 -1

No files matched your search

+9
View File
@@ -14,6 +14,15 @@ START_RETRIES=1
LOCK_FILE="/run/lock/zorruno-docker-backup.lock" LOCK_FILE="/run/lock/zorruno-docker-backup.lock"
ZSTD_EXTRA_OPTS="-T0 -12" 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 <remote>/beaver/. # Rclone destination becomes <remote>/beaver/.
RCLONE_ENABLED=0 RCLONE_ENABLED=0
RCLONE_REMOTE="gdrive:dockerbackups" RCLONE_REMOTE="gdrive:dockerbackups"
+42
View File
@@ -135,6 +135,48 @@ sudo stages/90-prune.sh --dry-run
`--force` runs a transfer method even when its `*_ENABLED` value is false. It `--force` runs a transfer method even when its `*_ENABLED` value is false. It
still requires the method's destination to be configured and available. 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 ## Adding another backup method
Add an executable script named `methods/NN-name.sh`, for example Add an executable script named `methods/NN-name.sh`, for example
+31
View File
@@ -16,6 +16,32 @@ while [[ $# -gt 0 ]]; do
esac esac
shift shift
done 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 require_command flock
mkdir -p "$(dirname "$LOCK_FILE")" mkdir -p "$(dirname "$LOCK_FILE")"
exec 9>"$LOCK_FILE" exec 9>"$LOCK_FILE"
@@ -24,6 +50,11 @@ stage_args=()
(( DRY_RUN )) && stage_args+=(--dry-run) (( DRY_RUN )) && stage_args+=(--dry-run)
[[ -n "$CONTAINERS_OVERRIDE" ]] && stage_args+=(--containers "$CONTAINERS_OVERRIDE") [[ -n "$CONTAINERS_OVERRIDE" ]] && stage_args+=(--containers "$CONTAINERS_OVERRIDE")
log START "Backup pipeline for $BACKUP_HOSTNAME" 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[@]}" (( SKIP_CONTAINERS )) || "$SCRIPT_DIR/stages/10-containers.sh" "${stage_args[@]}"
method_args=(); (( DRY_RUN )) && method_args+=(--dry-run) method_args=(); (( DRY_RUN )) && method_args+=(--dry-run)
for method in $BACKUP_METHODS; do for method in $BACKUP_METHODS; do
+24
View File
@@ -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
+13 -1
View File
@@ -16,6 +16,13 @@ WAIT_INTERVAL=3
START_RETRIES=1 START_RETRIES=1
LOCK_FILE="/run/lock/zorruno-docker-backup.lock" LOCK_FILE="/run/lock/zorruno-docker-backup.lock"
ZSTD_EXTRA_OPTS="-T0 -12" 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_ENABLED=0
RCLONE_REMOTE="" RCLONE_REMOTE=""
@@ -39,7 +46,12 @@ if [[ -n "${SOURCE_DIR:-}" && "$BACKUP_ROOT" == "/dockerbackups" ]]; then
fi fi
timestamp() { date '+%F %T'; } 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}"; } die() { log ERROR "$1"; exit "${2:-1}"; }
is_true() { is_true() {
+19
View File
@@ -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"
}
+1
View File
@@ -100,6 +100,7 @@ backup_project() {
compose up -d || true compose up -d || true
return 1 return 1
fi fi
log ARCHIVE "Created $output"
if is_true "$PULL_IMAGES"; then if is_true "$PULL_IMAGES"; then
compose pull || log WARN "Image pull failed for $project; starting with available images" compose pull || log WARN "Image pull failed for $project; starting with available images"
fi fi
+43
View File
@@ -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"
@@ -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