restic-profile-cli 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,513 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ import os
6
+ import socket
7
+ import subprocess
8
+ from contextlib import contextmanager
9
+ from datetime import datetime
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING, TypedDict
12
+
13
+ import jinja2
14
+ from chaos_utils.notify.base import BaseNotifier
15
+
16
+ from .config import NotifierConfig
17
+
18
+ if TYPE_CHECKING:
19
+ from .config import Profile
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ class ResticSnapshotSummary(TypedDict, total=False):
25
+ data_added: int
26
+ backup_start: str
27
+ backup_end: str
28
+
29
+
30
+ class ResticSnapshot(TypedDict, total=False):
31
+ id: str
32
+ short_id: str
33
+ time: str
34
+ tags: list[str]
35
+ paths: list[str]
36
+ parent: str
37
+ summary: ResticSnapshotSummary
38
+
39
+
40
+ class ResticFileNode(TypedDict, total=False):
41
+ type: str
42
+ name: str
43
+ size: int
44
+
45
+
46
+ class ResticDiffAddedRemoved(TypedDict, total=False):
47
+ files: int
48
+ dirs: int
49
+ bytes: int
50
+
51
+
52
+ class ResticDiffStatistics(TypedDict, total=False):
53
+ changed_files: int
54
+ added: ResticDiffAddedRemoved
55
+ removed: ResticDiffAddedRemoved
56
+
57
+
58
+ class ResticDiffChange(TypedDict, total=False):
59
+ message_type: str
60
+ modifier: str
61
+ path: str
62
+
63
+
64
+ class ResticDiffResult(TypedDict, total=False):
65
+ changes: list[ResticDiffChange]
66
+ statistics: ResticDiffStatistics
67
+
68
+
69
+ class ResticRepoStats(TypedDict, total=False):
70
+ snapshots_count: int
71
+ total_size: int
72
+
73
+
74
+ def _human_bytes(n: float | None) -> str:
75
+ if n is None or n <= 0:
76
+ return "0 B"
77
+ for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
78
+ if abs(n) < 1024:
79
+ return f"{n:.1f} {unit}" if unit != "B" else f"{n:.0f} {unit}"
80
+ n /= 1024
81
+ return f"{n:.1f} PiB"
82
+
83
+
84
+ def _format_ts(iso_ts: str) -> str:
85
+ try:
86
+ dt = datetime.fromisoformat(iso_ts)
87
+ return dt.strftime("%Y-%m-%d %H:%M:%S %z")
88
+ except (ValueError, TypeError):
89
+ return iso_ts
90
+
91
+
92
+ def _format_duration(start: str, end: str) -> str:
93
+ try:
94
+ s = datetime.fromisoformat(start)
95
+ e = datetime.fromisoformat(end)
96
+ sec = int((e - s).total_seconds())
97
+ if sec < 60:
98
+ return f"{sec}s"
99
+ m, s = divmod(sec, 60)
100
+ if m < 60:
101
+ return f"{m}m{s}s"
102
+ h, m = divmod(m, 60)
103
+ return f"{h}h{m}m{s}s"
104
+ except (ValueError, TypeError):
105
+ return ""
106
+
107
+
108
+ @contextmanager
109
+ def _inject_notify_env(env_vars: dict[str, str]):
110
+ """Temporarily set environment variables, restoring originals on exit."""
111
+ originals: dict[str, str | None] = {}
112
+ for key, val in env_vars.items():
113
+ originals[key] = os.environ.get(key)
114
+ os.environ[key] = val
115
+ try:
116
+ yield
117
+ finally:
118
+ for key, orig in originals.items():
119
+ if orig is None:
120
+ os.environ.pop(key, None)
121
+ else:
122
+ os.environ[key] = orig
123
+
124
+
125
+ def _query_snapshot(
126
+ *,
127
+ env: dict[str, str],
128
+ restic_executable: str,
129
+ global_args: list[str],
130
+ snapshot_id: str | None = None,
131
+ tag: str | None = None,
132
+ host: str | None = None,
133
+ ) -> ResticSnapshot | None:
134
+ cmd = [restic_executable, *global_args, "--json", "snapshots"]
135
+ if snapshot_id:
136
+ cmd.append(snapshot_id)
137
+ else:
138
+ if tag:
139
+ cmd.extend(["--tag", tag])
140
+ if host:
141
+ cmd.extend(["--host", host])
142
+ cmd.extend(["--latest", "1"])
143
+
144
+ result = subprocess.run(
145
+ cmd,
146
+ env=env,
147
+ capture_output=True,
148
+ text=True,
149
+ check=False,
150
+ )
151
+ if result.returncode != 0:
152
+ detail = snapshot_id or f"tag={tag} host={host}"
153
+ logger.warning(
154
+ "restic snapshots (%s) failed (exit %s): %s",
155
+ detail,
156
+ result.returncode,
157
+ (result.stderr or "").rstrip()[:200],
158
+ )
159
+ return None
160
+ try:
161
+ snapshots = json.loads(result.stdout)
162
+ except json.JSONDecodeError:
163
+ logger.warning("Failed to parse restic snapshots JSON")
164
+ return None
165
+ return snapshots[-1] if snapshots else None
166
+
167
+
168
+ def _query_largest_files(
169
+ snapshot_id: str,
170
+ *,
171
+ env: dict[str, str],
172
+ restic_executable: str,
173
+ global_args: list[str],
174
+ limit: int,
175
+ ) -> list[ResticFileNode]:
176
+ if limit <= 0:
177
+ return []
178
+ result = subprocess.run(
179
+ [
180
+ restic_executable,
181
+ *global_args,
182
+ "--json",
183
+ "ls",
184
+ "--sort",
185
+ "size",
186
+ snapshot_id,
187
+ ],
188
+ env=env,
189
+ capture_output=True,
190
+ text=True,
191
+ check=False,
192
+ )
193
+ if result.returncode != 0:
194
+ logger.warning(
195
+ "restic ls (snapshot %s) failed (exit %s): %s",
196
+ snapshot_id,
197
+ result.returncode,
198
+ (result.stderr or "").rstrip()[:200],
199
+ )
200
+ return []
201
+ files: list[ResticFileNode] = []
202
+ for line in result.stdout.strip().splitlines():
203
+ if not line:
204
+ continue
205
+ try:
206
+ entry = json.loads(line)
207
+ except json.JSONDecodeError:
208
+ continue
209
+ if entry.get("type") == "file":
210
+ files.append(entry)
211
+ return files[-limit:][::-1]
212
+
213
+
214
+ def _query_snapshot_diff(
215
+ parent_id: str,
216
+ snapshot_id: str,
217
+ *,
218
+ env: dict[str, str],
219
+ restic_executable: str,
220
+ global_args: list[str],
221
+ ) -> ResticDiffResult | None:
222
+ result = subprocess.run(
223
+ [
224
+ restic_executable,
225
+ *global_args,
226
+ "--json",
227
+ "diff",
228
+ parent_id,
229
+ snapshot_id,
230
+ ],
231
+ env=env,
232
+ capture_output=True,
233
+ text=True,
234
+ check=False,
235
+ )
236
+ if result.returncode != 0:
237
+ logger.warning(
238
+ "restic diff (%s .. %s) failed (exit %s): %s",
239
+ parent_id,
240
+ snapshot_id,
241
+ result.returncode,
242
+ (result.stderr or "").rstrip()[:200],
243
+ )
244
+ return None
245
+ changes: list[ResticDiffChange] = []
246
+ statistics: ResticDiffStatistics = {}
247
+ for line in result.stdout.strip().splitlines():
248
+ if not line:
249
+ continue
250
+ try:
251
+ entry = json.loads(line)
252
+ except json.JSONDecodeError:
253
+ continue
254
+ if entry.get("message_type") == "change":
255
+ changes.append(entry)
256
+ elif entry.get("message_type") == "statistics":
257
+ statistics = entry
258
+ return {"changes": changes, "statistics": statistics}
259
+
260
+
261
+ def _query_repo_stats(
262
+ *,
263
+ env: dict[str, str],
264
+ restic_executable: str,
265
+ global_args: list[str],
266
+ ) -> ResticRepoStats | None:
267
+ result = subprocess.run(
268
+ [restic_executable, *global_args, "--json", "stats"],
269
+ env=env,
270
+ capture_output=True,
271
+ text=True,
272
+ check=False,
273
+ )
274
+ if result.returncode != 0:
275
+ logger.warning(
276
+ "restic stats (repo) failed (exit %s): %s",
277
+ result.returncode,
278
+ (result.stderr or "").rstrip()[:200],
279
+ )
280
+ return None
281
+ try:
282
+ return json.loads(result.stdout)
283
+ except json.JSONDecodeError:
284
+ logger.warning("Failed to parse restic stats JSON for repo")
285
+ return None
286
+
287
+
288
+ def _build_success_facts(
289
+ snapshot: ResticSnapshot,
290
+ largest_files: list[ResticFileNode],
291
+ host: str,
292
+ profile_name: str,
293
+ *,
294
+ diff_data: ResticDiffResult | None = None,
295
+ repo_stats: ResticRepoStats | None = None,
296
+ ) -> dict[str, object]:
297
+ summary = snapshot.get("summary", {})
298
+ facts: dict[str, object] = {
299
+ "host": host,
300
+ "profile": profile_name,
301
+ "short_id": snapshot.get("short_id", "N/A"),
302
+ "time": _format_ts(str(snapshot.get("time", ""))),
303
+ "tags": snapshot.get("tags", []),
304
+ "paths": snapshot.get("paths", []),
305
+ "data_added": _human_bytes(summary.get("data_added")),
306
+ "duration": _format_duration(
307
+ str(summary.get("backup_start", "")),
308
+ str(summary.get("backup_end", "")),
309
+ ),
310
+ "largest_files": [
311
+ (
312
+ f.get("name", "?"),
313
+ _human_bytes(f.get("size", 0)),
314
+ )
315
+ for f in largest_files
316
+ ],
317
+ }
318
+
319
+ if diff_data and diff_data.get("statistics"):
320
+ diff_stats = diff_data["statistics"]
321
+ diff_changes = diff_data.get("changes", [])
322
+ added = diff_stats.get("added", {})
323
+ removed = diff_stats.get("removed", {})
324
+ facts["diff_has_parent"] = True
325
+ facts["diff_changed_files"] = diff_stats.get("changed_files", 0)
326
+ facts["diff_added_files"] = added.get("files", 0)
327
+ facts["diff_added_dirs"] = added.get("dirs", 0)
328
+ facts["diff_added_bytes"] = _human_bytes(added.get("bytes"))
329
+ facts["diff_removed_files"] = removed.get("files", 0)
330
+ facts["diff_removed_dirs"] = removed.get("dirs", 0)
331
+ facts["diff_removed_bytes"] = _human_bytes(removed.get("bytes"))
332
+ facts["diff_changes"] = [
333
+ (c.get("modifier", "?"), c.get("path", "?")) for c in diff_changes
334
+ ]
335
+ else:
336
+ facts["diff_has_parent"] = False
337
+
338
+ if repo_stats:
339
+ facts["repo_snapshots_count"] = repo_stats.get("snapshots_count", 0)
340
+ facts["repo_total_size"] = _human_bytes(repo_stats.get("total_size"))
341
+ else:
342
+ facts["repo_snapshots_count"] = 0
343
+ facts["repo_total_size"] = "—"
344
+
345
+ return facts
346
+
347
+
348
+ def _load_template_content(
349
+ template_name: str,
350
+ *,
351
+ user_template_dir: str | None = None,
352
+ ) -> str:
353
+ if user_template_dir:
354
+ user_path = Path(user_template_dir) / template_name
355
+ if user_path.is_file():
356
+ logger.debug("Loading notify template: %s", user_path)
357
+ return user_path.read_text(encoding="utf-8")
358
+ pkg_path = Path(__file__).resolve().parent / "templates" / template_name
359
+ logger.debug("Loading built-in notify template: %s", pkg_path)
360
+ return pkg_path.read_text(encoding="utf-8")
361
+
362
+
363
+ def _render_notify_template(
364
+ template_name: str,
365
+ facts: dict[str, object],
366
+ *,
367
+ template_dir: str | None = None,
368
+ ) -> str:
369
+ content = _load_template_content(template_name, user_template_dir=template_dir)
370
+ env = jinja2.Environment(autoescape=False, trim_blocks=True, lstrip_blocks=True)
371
+ template = env.from_string(content)
372
+ return template.render(**facts)
373
+
374
+
375
+ def _build_failure_facts(
376
+ error: str,
377
+ host: str,
378
+ profile_name: str,
379
+ ) -> dict[str, object]:
380
+ return {
381
+ "host": host,
382
+ "profile": profile_name,
383
+ "error": error,
384
+ "time": _format_ts(datetime.now().astimezone().isoformat()),
385
+ }
386
+
387
+
388
+ def _dispatch_formatted(
389
+ notifier_config: NotifierConfig,
390
+ *,
391
+ title: str,
392
+ markdown: str,
393
+ ) -> None:
394
+ logger.info("Notify report:\n%s", markdown)
395
+ bot: BaseNotifier = notifier_config.build()
396
+ nt = notifier_config.type
397
+ extra_kwargs: dict[str, object] = getattr(notifier_config, "send_kwargs", {})
398
+
399
+ if nt == "dingtalk":
400
+ bot.send_markdown(title, markdown) # type: ignore
401
+ elif nt == "telegram":
402
+ bot.send_rich_message(markdown=markdown, **extra_kwargs) # type: ignore
403
+ elif nt == "wechat":
404
+ bot.send_markdown_v2(markdown) # type: ignore
405
+ else:
406
+ bot.send(markdown)
407
+
408
+
409
+ def try_notify_success(
410
+ profile: Profile,
411
+ *,
412
+ snapshot_id: str | None,
413
+ env: dict[str, str],
414
+ restic_executable: str,
415
+ global_args: list[str],
416
+ ) -> None:
417
+ """Send a success notification for *profile*."""
418
+ if profile.resolved_notifier is None:
419
+ return
420
+
421
+ host = socket.gethostname()
422
+ tag = profile.tag
423
+
424
+ snap = _query_snapshot(
425
+ snapshot_id=snapshot_id,
426
+ tag=tag,
427
+ host=host,
428
+ env=env,
429
+ restic_executable=restic_executable,
430
+ global_args=global_args,
431
+ )
432
+
433
+ if not snap:
434
+ logger.warning(
435
+ "No snapshot data available for success notification (profile %s)",
436
+ profile.name,
437
+ )
438
+ return
439
+
440
+ snap_id = snap.get("id", "latest")
441
+ notifier = profile.resolved_notifier
442
+ limit = max(notifier.top_files_limit, 0)
443
+ largest_files = _query_largest_files(
444
+ snap_id,
445
+ env=env,
446
+ restic_executable=restic_executable,
447
+ global_args=global_args,
448
+ limit=limit,
449
+ )
450
+
451
+ parent_id = snap.get("parent")
452
+ diff_data: ResticDiffResult | None = None
453
+ if parent_id:
454
+ diff_data = _query_snapshot_diff(
455
+ parent_id,
456
+ snap_id,
457
+ env=env,
458
+ restic_executable=restic_executable,
459
+ global_args=global_args,
460
+ )
461
+ if not diff_data:
462
+ diff_data = None
463
+
464
+ repo_stats = _query_repo_stats(
465
+ env=env,
466
+ restic_executable=restic_executable,
467
+ global_args=global_args,
468
+ )
469
+
470
+ facts = _build_success_facts(
471
+ snap,
472
+ largest_files,
473
+ host,
474
+ profile.name,
475
+ diff_data=diff_data,
476
+ repo_stats=repo_stats,
477
+ )
478
+
479
+ markdown = _render_notify_template(
480
+ "notify_success.md.j2",
481
+ facts,
482
+ template_dir=profile.resolved_template_dir or None,
483
+ )
484
+
485
+ with _inject_notify_env(notifier.env):
486
+ _dispatch_formatted(
487
+ notifier,
488
+ title=f"restic Backup Succeeded — {profile.name}",
489
+ markdown=markdown,
490
+ )
491
+
492
+
493
+ def try_notify_failure(profile: Profile, error_msg: str) -> None:
494
+ """Send a failure notification for *profile*."""
495
+ if profile.resolved_notifier is None:
496
+ return
497
+
498
+ host = socket.gethostname()
499
+ facts = _build_failure_facts(error_msg, host, profile.name)
500
+ notifier = profile.resolved_notifier
501
+
502
+ markdown = _render_notify_template(
503
+ "notify_failure.md.j2",
504
+ facts,
505
+ template_dir=profile.resolved_template_dir or None,
506
+ )
507
+
508
+ with _inject_notify_env(notifier.env):
509
+ _dispatch_formatted(
510
+ notifier,
511
+ title=f"restic Backup Failed — {profile.name}",
512
+ markdown=markdown,
513
+ )