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.
- restic_profile/__init__.py +6 -0
- restic_profile/__main__.py +4 -0
- restic_profile/cli.py +204 -0
- restic_profile/config.py +334 -0
- restic_profile/notify.py +513 -0
- restic_profile/runner.py +711 -0
- restic_profile/templates/notify_failure.md.j2 +6 -0
- restic_profile/templates/notify_success.md.j2 +45 -0
- restic_profile_cli-0.2.0.dist-info/METADATA +90 -0
- restic_profile_cli-0.2.0.dist-info/RECORD +13 -0
- restic_profile_cli-0.2.0.dist-info/WHEEL +4 -0
- restic_profile_cli-0.2.0.dist-info/entry_points.txt +2 -0
- restic_profile_cli-0.2.0.dist-info/licenses/LICENSE +18 -0
restic_profile/notify.py
ADDED
|
@@ -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
|
+
)
|