Compare commits
No commits in common. "0999abd10d48d74d53db5ca43f0bddd2f43bb351" and "0c1fe5326e90016498ca88476a87b5382bb2fd5b" have entirely different histories.
0999abd10d
...
0c1fe5326e
5 changed files with 17 additions and 152 deletions
|
|
@ -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())
|
|
||||||
|
|
@ -7,14 +7,6 @@
|
||||||
state: present
|
state: present
|
||||||
update_cache: yes
|
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
|
- name: SSH Private Key deployen
|
||||||
copy:
|
copy:
|
||||||
src: id_rsa
|
src: id_rsa
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,23 @@ before_backup:
|
||||||
|
|
||||||
after_backup:
|
after_backup:
|
||||||
- curl -fsS -m 10 "https://status.guck.tv/api/push/borg-{{ inventory_hostname }}?status=up&msg=OK&ping=" || true
|
- 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:
|
on_error:
|
||||||
- curl -fsS -m 10 "https://status.guck.tv/api/push/borg-{{ inventory_hostname }}?status=down&msg=FEHLER&ping=" || true
|
- curl -fsS -m 10 "https://status.guck.tv/api/push/borg-{{ inventory_hostname }}?status=down&msg=FEHLER&ping=" || true
|
||||||
|
|
|
||||||
16
tests/fixtures/borgmatic-info-latest.json
vendored
16
tests/fixtures/borgmatic-info-latest.json
vendored
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
@ -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()
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue