From baa3d2f90808ff72f473ae1740141f0f0a8808f7 Mon Sep 17 00:00:00 2001 From: sascha Date: Wed, 29 Jul 2026 15:01:04 +0200 Subject: [PATCH 1/5] fix(borg): update roles/borg/files/borg-sentinel-push.py --- roles/borg/files/borg-sentinel-push.py | 96 ++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 roles/borg/files/borg-sentinel-push.py diff --git a/roles/borg/files/borg-sentinel-push.py b/roles/borg/files/borg-sentinel-push.py new file mode 100644 index 0000000..7dfd660 --- /dev/null +++ b/roles/borg/files/borg-sentinel-push.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Send latest Borg archive statistics to Backup Sentinel.""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any + +DEFAULT_URL = "http://10.1.1.111:9999/api/push" + + +def load_borg_info(info_file: str | None) -> Any: + if info_file: + return json.loads(Path(info_file).read_text()) + + result = subprocess.run( + ["borgmatic", "info", "--archive", "latest", "--json"], + check=True, + text=True, + capture_output=True, + ) + return json.loads(result.stdout) + + +def latest_archive(info: Any) -> dict[str, Any]: + if not isinstance(info, list) or not info or not isinstance(info[0], dict): + raise ValueError("Unexpected borgmatic info JSON: expected a non-empty repository list") + archives = info[0].get("archives") + if not isinstance(archives, list) or not archives or not isinstance(archives[-1], dict): + raise ValueError("Unexpected borgmatic info JSON: no latest archive found") + return archives[-1] + + +def build_payload(host: str, archive: dict[str, Any]) -> dict[str, Any]: + stats = archive.get("stats") + if not isinstance(stats, dict): + raise ValueError("Unexpected borgmatic info JSON: latest archive has no stats") + + return { + "host": host, + "status": "ok", + # Borg exposes duration on the archive object, not below stats. + "duration_sec": int(round(float(archive.get("duration", 0)))), + "original_size": int(stats.get("original_size", 0)), + "deduplicated_size": int(stats.get("deduplicated_size", 0)), + "compressed_size": int(stats.get("compressed_size", 0)), + "nfiles_new": int(stats.get("nfiles", 0)), + "archive": str(archive.get("name", "")), + } + + +def post_payload(url: str, payload: dict[str, Any]) -> None: + request = urllib.request.Request( + url, + data=json.dumps(payload, separators=(",", ":")).encode(), + method="POST", + headers={"Content-Type": "application/json"}, + ) + with urllib.request.urlopen(request, timeout=10) as response: + body = response.read().decode(errors="replace") + if response.status < 200 or response.status >= 300: + raise RuntimeError(f"Sentinel returned HTTP {response.status}: {body[:500]}") + print(body) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--host", required=True) + parser.add_argument("--url", default=DEFAULT_URL) + parser.add_argument("--info-file", help="Read borgmatic info JSON from a fixture instead of running borgmatic") + parser.add_argument("--dry-run", action="store_true", help="Print payload without posting it") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + try: + payload = build_payload(args.host, latest_archive(load_borg_info(args.info_file))) + if args.dry_run: + print(json.dumps(payload, separators=(",", ":"))) + else: + post_payload(args.url, payload) + return 0 + except (ValueError, OSError, subprocess.SubprocessError, json.JSONDecodeError, urllib.error.URLError) as exc: + print(f"borg-sentinel-push: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) From 6ef859898c55fab49562fc66018827359e076e7b Mon Sep 17 00:00:00 2001 From: sascha Date: Wed, 29 Jul 2026 15:01:04 +0200 Subject: [PATCH 2/5] fix(borg): update roles/borg/templates/borgmatic.yml.j2 --- roles/borg/templates/borgmatic.yml.j2 | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/roles/borg/templates/borgmatic.yml.j2 b/roles/borg/templates/borgmatic.yml.j2 index ef61e06..4ec91d9 100644 --- a/roles/borg/templates/borgmatic.yml.j2 +++ b/roles/borg/templates/borgmatic.yml.j2 @@ -28,23 +28,7 @@ before_backup: after_backup: - curl -fsS -m 10 "https://status.guck.tv/api/push/borg-{{ inventory_hostname }}?status=up&msg=OK&ping=" || true - - >- - bash -c ' - STATS=$(borgmatic info --archive latest --json 2>/dev/null | python3 -c " - import sys,json - d=json.load(sys.stdin)[0][\"archives\"][-1] - s=d.get(\"stats\",{}) - print(json.dumps({ - \"host\":\"{{ inventory_hostname }}\", - \"status\":\"ok\", - \"duration_sec\":int(s.get(\"duration\",0)), - \"original_size\":s.get(\"original_size\",0), - \"deduplicated_size\":s.get(\"deduplicated_size\",0), - \"compressed_size\":s.get(\"compressed_size\",0), - \"nfiles_new\":s.get(\"nfiles\",0) - }))" 2>/dev/null || echo "{\"host\":\"{{ inventory_hostname }}\",\"status\":\"ok\"}"); - curl -fsS -m 10 -X POST -H "Content-Type: application/json" -d "$STATS" "http://10.1.1.111:9999/api/push" || true - ' + - /usr/local/bin/borg-sentinel-push --host {{ inventory_hostname }} || true on_error: - curl -fsS -m 10 "https://status.guck.tv/api/push/borg-{{ inventory_hostname }}?status=down&msg=FEHLER&ping=" || true From b6556cd18e6f623af666441d1634b72a266e650b Mon Sep 17 00:00:00 2001 From: sascha Date: Wed, 29 Jul 2026 15:01:05 +0200 Subject: [PATCH 3/5] fix(borg): update roles/borg/tasks/main.yml --- roles/borg/tasks/main.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/roles/borg/tasks/main.yml b/roles/borg/tasks/main.yml index a47c6f7..2d6f6f5 100644 --- a/roles/borg/tasks/main.yml +++ b/roles/borg/tasks/main.yml @@ -7,6 +7,14 @@ state: present update_cache: yes +- name: Sentinel-Statistik-Push installieren + copy: + src: borg-sentinel-push.py + dest: /usr/local/bin/borg-sentinel-push + owner: root + group: root + mode: '0755' + - name: SSH Private Key deployen copy: src: id_rsa From 1a87e5f35f08e46af6c9da708fb1deb1b61ca984 Mon Sep 17 00:00:00 2001 From: sascha Date: Wed, 29 Jul 2026 15:01:06 +0200 Subject: [PATCH 4/5] fix(borg): update tests/test_borg_sentinel_push.py --- tests/test_borg_sentinel_push.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 tests/test_borg_sentinel_push.py diff --git a/tests/test_borg_sentinel_push.py b/tests/test_borg_sentinel_push.py new file mode 100644 index 0000000..8c261db --- /dev/null +++ b/tests/test_borg_sentinel_push.py @@ -0,0 +1,31 @@ +import json +import subprocess +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parent +SCRIPT = ROOT.parent / "roles" / "borg" / "files" / "borg-sentinel-push.py" +FIXTURE = ROOT / "fixtures" / "borgmatic-info-latest.json" + + +class SentinelPayloadRegressionTest(unittest.TestCase): + def test_real_borgmatic_info_shape_produces_nonzero_recent_backup_stats(self): + proc = subprocess.run( + ["python3", str(SCRIPT), "--host", "pfannkuchen", "--info-file", str(FIXTURE), "--dry-run"], + text=True, + capture_output=True, + ) + self.assertEqual(proc.returncode, 0, proc.stderr) + payload = json.loads(proc.stdout) + self.assertEqual(payload["host"], "pfannkuchen") + self.assertEqual(payload["status"], "ok") + self.assertEqual(payload["duration_sec"], 7) + self.assertEqual(payload["original_size"], 573861799) + self.assertEqual(payload["deduplicated_size"], 189532521) + self.assertEqual(payload["compressed_size"], 189978004) + self.assertEqual(payload["nfiles_new"], 157) + self.assertEqual(payload["archive"], "pfannkuchen-2026-07-29_11-53") + + +if __name__ == "__main__": + unittest.main() From f639c88952e9b48eef53cdb960de8a948fa35be0 Mon Sep 17 00:00:00 2001 From: sascha Date: Wed, 29 Jul 2026 15:01:06 +0200 Subject: [PATCH 5/5] fix(borg): update tests/fixtures/borgmatic-info-latest.json --- tests/fixtures/borgmatic-info-latest.json | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 tests/fixtures/borgmatic-info-latest.json diff --git a/tests/fixtures/borgmatic-info-latest.json b/tests/fixtures/borgmatic-info-latest.json new file mode 100644 index 0000000..be39b2d --- /dev/null +++ b/tests/fixtures/borgmatic-info-latest.json @@ -0,0 +1,16 @@ +[ + { + "archives": [ + { + "name": "pfannkuchen-2026-07-29_11-53", + "duration": 7.111822, + "stats": { + "compressed_size": 189978004, + "deduplicated_size": 189532521, + "nfiles": 157, + "original_size": 573861799 + } + } + ] + } +]