#!/bin/bash ################################################################################ # Script Name: borg-backup-exporter.sh # Version: 1.0 # Description: Prometheus exporter for Borg backups — last archive time, # backup age, repo size, archive counts, and deduplication metrics # # Author: Phil Connor # Contact: contact@mylinux.work # Website: https://mylinux.work # License: MIT # # Prerequisites: # - borg installed # - BORG_PASSPHRASE or BORG_PASSCOMMAND env set # - netcat (nc) for HTTP mode # # Usage: # ./borg-backup-exporter.sh --repo /mnt/backup --textfile # ./borg-backup-exporter.sh --repo /mnt/backup --http -p 9201 # BORG_REPO=/mnt/backup ./borg-backup-exporter.sh # # Configuration: # Default HTTP port: 9201 # Textfile directory: /var/lib/node_exporter # ################################################################################ EXPORTER_VERSION="1.0" TEXTFILE_DIR="/var/lib/node_exporter" OUTPUT_FILE="" HTTP_MODE=false HTTP_PORT=9201 REPOS=() show_usage() { cat <&2; exit 1 ;; esac done if [ ${#REPOS[@]} -eq 0 ] && [ -n "$BORG_REPO" ]; then REPOS+=("$BORG_REPO") fi } check_borg() { if ! command -v borg >/dev/null 2>&1; then echo "# ERROR: borg not found" >&2 return 1 fi return 0 } generate_repo_metrics() { local repo="$1" local repo_label repo_label=$(echo "$repo" | sed 's/[^a-zA-Z0-9_\/-]/_/g') # Get repo info local info_json if ! info_json=$(borg info --json "$repo" 2>/dev/null) || [ -z "$info_json" ]; then echo "# WARNING: could not read repo $repo" >&2 echo "borg_backup_up 0" return fi local location location=$(echo "$info_json" | jq -r '.repository.location // empty') echo "borg_backup_repo_info{repo=\"$repo_label\",location=\"$location\"} 1" # Repo size metrics from cache/stats local total_size total_csize unique_csize total_size=$(echo "$info_json" | jq '.cache.stats.total_size // 0') total_csize=$(echo "$info_json" | jq '.cache.stats.total_csize // 0') unique_csize=$(echo "$info_json" | jq '.cache.stats.unique_csize // 0') echo "borg_backup_repo_total_size_bytes{repo=\"$repo_label\"} ${total_size:-0}" echo "borg_backup_repo_total_csize_bytes{repo=\"$repo_label\"} ${total_csize:-0}" echo "borg_backup_repo_unique_csize_bytes{repo=\"$repo_label\"} ${unique_csize:-0}" # List archives local list_json list_json=$(borg list --json "$repo" 2>/dev/null) local archive_count archive_count=$(echo "$list_json" | jq '.archives | length') echo "borg_backup_archive_count{repo=\"$repo_label\"} ${archive_count:-0}" # Last archive metrics local last_archive last_archive=$(echo "$list_json" | jq -r '.archives | sort_by(.start) | last | .start // empty' 2>/dev/null) if [ -n "$last_archive" ]; then local last_unix last_unix=$(date -d "$last_archive" +%s 2>/dev/null || echo 0) local now now=$(date +%s) local age=$((now - last_unix)) echo "borg_backup_last_archive_timestamp{repo=\"$repo_label\"} $last_unix" echo "borg_backup_last_archive_age_seconds{repo=\"$repo_label\"} $age" else echo "borg_backup_last_archive_timestamp{repo=\"$repo_label\"} 0" echo "borg_backup_last_archive_age_seconds{repo=\"$repo_label\"} 0" fi # Last archive detailed info local last_archive_name last_archive_name=$(echo "$list_json" | jq -r '.archives | sort_by(.start) | last | .archive // empty' 2>/dev/null) if [ -n "$last_archive_name" ]; then local archive_info_json archive_info_json=$(borg info --json "$repo::$last_archive_name" 2>/dev/null) if [ -n "$archive_info_json" ]; then local original_size dedup_size duration original_size=$(echo "$archive_info_json" | jq '.archives[0].stats.original_size // 0') dedup_size=$(echo "$archive_info_json" | jq '.archives[0].stats.deduplicated_size // 0') duration=$(echo "$archive_info_json" | jq '.archives[0].duration // empty') echo "borg_backup_last_archive_original_size_bytes{repo=\"$repo_label\"} ${original_size:-0}" echo "borg_backup_last_archive_deduplicated_size_bytes{repo=\"$repo_label\"} ${dedup_size:-0}" if [ -n "$duration" ] && [ "$duration" != "null" ]; then echo "borg_backup_last_archive_duration_seconds{repo=\"$repo_label\"} $duration" fi fi fi # Borg check age (from log file if available) local check_log="/var/log/borg-check.log" if [ -f "$check_log" ]; then local check_mtime check_mtime=$(stat -c %Y "$check_log" 2>/dev/null) if [ -n "$check_mtime" ]; then local now now=$(date +%s) local check_age=$((now - check_mtime)) echo "borg_backup_repo_check_age_seconds{repo=\"$repo_label\"} $check_age" fi fi } generate_metrics() { local script_start script_start=$(date +%s) if ! check_borg; then echo "# HELP borg_backup_up Exporter status (1=up, 0=down)" echo "# TYPE borg_backup_up gauge" echo "borg_backup_up 0" return fi echo "# HELP borg_backup_up Exporter status (1=up, 0=down)" echo "# TYPE borg_backup_up gauge" echo "borg_backup_up 1" echo "# HELP borg_backup_exporter_info Exporter version info" echo "# TYPE borg_backup_exporter_info gauge" echo "borg_backup_exporter_info{version=\"$EXPORTER_VERSION\"} 1" # Collect all per-repo metric lines, then output grouped by metric name local all_output all_output="" for repo in "${REPOS[@]}"; do all_output+="$(generate_repo_metrics "$repo")"$'\n' done # Output each metric type with HELP/TYPE immediately before its values local -a metric_names=( "borg_backup_repo_info|Repository info" "borg_backup_repo_total_size_bytes|Total deduplicated size in bytes" "borg_backup_repo_total_csize_bytes|Total compressed size in bytes" "borg_backup_repo_unique_csize_bytes|Unique compressed size (actual disk usage) in bytes" "borg_backup_archive_count|Total number of archives" "borg_backup_last_archive_timestamp|Unix timestamp of most recent archive" "borg_backup_last_archive_age_seconds|Seconds since last archive" "borg_backup_last_archive_original_size_bytes|Original size of last archive" "borg_backup_last_archive_deduplicated_size_bytes|Deduplicated size of last archive" "borg_backup_last_archive_duration_seconds|Duration of last archive" "borg_backup_repo_check_age_seconds|Seconds since last borg check" ) for entry in "${metric_names[@]}"; do local mname="${entry%%|*}" local mdesc="${entry#*|}" local lines lines=$(echo "$all_output" | grep "^${mname}[{[:space:]]" || true) if [ -n "$lines" ]; then echo "# HELP ${mname} ${mdesc}" echo "# TYPE ${mname} gauge" echo "$lines" fi done local script_end script_duration script_end=$(date +%s) script_duration=$((script_end - script_start)) echo "# HELP borg_backup_exporter_duration_seconds Script execution time" echo "# TYPE borg_backup_exporter_duration_seconds gauge" echo "borg_backup_exporter_duration_seconds $script_duration" echo "# HELP borg_backup_exporter_last_run_timestamp Last successful run" echo "# TYPE borg_backup_exporter_last_run_timestamp gauge" echo "borg_backup_exporter_last_run_timestamp $script_end" } run_http_server() { echo "# Starting borg backup exporter on port $HTTP_PORT..." >&2 if ! command -v nc >/dev/null 2>&1; then echo "# ERROR: netcat (nc) required for HTTP mode" >&2 exit 1 fi while true; do { read -r request if [[ "$request" =~ ^GET\ /metrics ]]; then echo -e "HTTP/1.1 200 OK\r\nContent-Type: text/plain; version=0.0.4\r\n\r" generate_metrics else echo -e "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r" echo "Borg Backup Exporter

Borg Backup Prometheus Exporter

Metrics

" fi } | nc -l -p "$HTTP_PORT" -q 1 2>/dev/null done } main() { parse_args "$@" if [ "$HTTP_MODE" = true ]; then run_http_server elif [ -n "$OUTPUT_FILE" ]; then local output_dir output_dir="$(dirname "$OUTPUT_FILE")" mkdir -p "$output_dir" local temp_file temp_file=$(mktemp "${output_dir}/.borg_metrics.XXXXXX") if ! generate_metrics > "$temp_file" 2>/dev/null; then rm -f "$temp_file" echo "# ERROR: Failed to generate metrics" >&2 exit 1 fi chmod 644 "$temp_file" mv -f "$temp_file" "$OUTPUT_FILE" echo "# Metrics written to $OUTPUT_FILE" >&2 else generate_metrics fi } main "$@"