diff --git a/roles/borg/files/borg-sentinel-push.py b/roles/borg/files/borg-sentinel-push.py deleted file mode 100644 index 7dfd660..0000000 --- a/roles/borg/files/borg-sentinel-push.py +++ /dev/null @@ -1,96 +0,0 @@ -#!/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()) diff --git a/roles/borg/tasks/main.yml b/roles/borg/tasks/main.yml index 2d6f6f5..a47c6f7 100644 --- a/roles/borg/tasks/main.yml +++ b/roles/borg/tasks/main.yml @@ -7,14 +7,6 @@ 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 diff --git a/roles/borg/templates/borgmatic.yml.j2 b/roles/borg/templates/borgmatic.yml.j2 index 4ec91d9..ef61e06 100644 --- a/roles/borg/templates/borgmatic.yml.j2 +++ b/roles/borg/templates/borgmatic.yml.j2 @@ -28,7 +28,23 @@ before_backup: after_backup: - curl -fsS -m 10 "https://status.guck.tv/api/push/borg-{{ inventory_hostname }}?status=up&msg=OK&ping=" || true - - /usr/local/bin/borg-sentinel-push --host {{ inventory_hostname }} || 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 + ' on_error: - curl -fsS -m 10 "https://status.guck.tv/api/push/borg-{{ inventory_hostname }}?status=down&msg=FEHLER&ping=" || true diff --git a/tests/fixtures/borgmatic-info-latest.json b/tests/fixtures/borgmatic-info-latest.json deleted file mode 100644 index be39b2d..0000000 --- a/tests/fixtures/borgmatic-info-latest.json +++ /dev/null @@ -1,16 +0,0 @@ -[ - { - "archives": [ - { - "name": "pfannkuchen-2026-07-29_11-53", - "duration": 7.111822, - "stats": { - "compressed_size": 189978004, - "deduplicated_size": 189532521, - "nfiles": 157, - "original_size": 573861799 - } - } - ] - } -] diff --git a/tests/test_borg_sentinel_push.py b/tests/test_borg_sentinel_push.py deleted file mode 100644 index 8c261db..0000000 --- a/tests/test_borg_sentinel_push.py +++ /dev/null @@ -1,31 +0,0 @@ -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()