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,711 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import os
5
+ import pwd
6
+ import re
7
+ import shutil
8
+ import socket
9
+ import subprocess
10
+ import sys
11
+ from functools import lru_cache
12
+ from pathlib import Path
13
+ from typing import NoReturn
14
+
15
+ from .config import Profile
16
+ from .notify import try_notify_failure, try_notify_success
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ _COMMON_RESTIC_PATHS = ("/usr/local/bin/restic", "/usr/bin/restic")
21
+
22
+
23
+ class _CommandError(Exception):
24
+ """Raised when a subprocess command exits with a non-zero exit code."""
25
+
26
+ def __init__(
27
+ self,
28
+ cmd: list[str],
29
+ returncode: int,
30
+ *,
31
+ stdout: str = "",
32
+ stderr: str = "",
33
+ ) -> None:
34
+ self.cmd = list(cmd)
35
+ self.returncode = returncode
36
+ self.stdout = stdout
37
+ self.stderr = stderr
38
+ super().__init__(f"Command failed (exit {returncode}): {' '.join(cmd)}")
39
+
40
+
41
+ class WorkflowError(Exception):
42
+ """Raised when a backup/retention workflow cannot continue."""
43
+
44
+
45
+ def build_env(profile: Profile) -> dict[str, str]:
46
+ """Build the environment dict for a restic subprocess.
47
+
48
+ Starts from a copy of ``os.environ`` so that PATH and other inherited
49
+ variables are preserved. Credentials are injected via env vars only —
50
+ never written to disk.
51
+ """
52
+ env = os.environ.copy()
53
+
54
+ if not env.get("HOME"):
55
+ try:
56
+ env["HOME"] = pwd.getpwuid(os.getuid()).pw_dir
57
+ except KeyError:
58
+ pass
59
+
60
+ if not env.get("XDG_CACHE_HOME") and env.get("HOME"):
61
+ env["XDG_CACHE_HOME"] = str(Path(env["HOME"]) / ".cache")
62
+
63
+ repo = profile.resolved_repository
64
+ if not repo:
65
+ raise ValueError(f"Profile {profile.name!r} has no resolved repository")
66
+
67
+ env["RESTIC_REPOSITORY"] = repo.repository
68
+ env["RESTIC_PASSWORD"] = repo.password
69
+
70
+ if repo.rest_username:
71
+ env["RESTIC_REST_USERNAME"] = repo.rest_username
72
+ env["RESTIC_REST_PASSWORD"] = repo.rest_password
73
+
74
+ if repo.cacert:
75
+ env["RESTIC_CACERT"] = repo.cacert
76
+
77
+ if repo.aws_access_key_id:
78
+ env["AWS_ACCESS_KEY_ID"] = repo.aws_access_key_id
79
+ env["AWS_SECRET_ACCESS_KEY"] = repo.aws_secret_access_key
80
+ if repo.aws_default_region:
81
+ env["AWS_DEFAULT_REGION"] = repo.aws_default_region
82
+
83
+ if repo.google_project_id:
84
+ env["GOOGLE_PROJECT_ID"] = repo.google_project_id
85
+ # google_access_token takes precedence — when set it disables all other
86
+ # GCS auth mechanisms (service account key, ADC, etc.).
87
+ if repo.google_access_token:
88
+ env["GOOGLE_ACCESS_TOKEN"] = repo.google_access_token
89
+ elif repo.google_application_credentials:
90
+ env["GOOGLE_APPLICATION_CREDENTIALS"] = repo.google_application_credentials
91
+
92
+ if repo.env:
93
+ for k, v in repo.env.items():
94
+ if k in env:
95
+ logger.debug(
96
+ "Repository %r env %r=%r overrides existing value %r",
97
+ repo.name,
98
+ k,
99
+ v,
100
+ env[k],
101
+ )
102
+ env[k] = v
103
+
104
+ if profile.env:
105
+ for k, v in profile.env.items():
106
+ if k in env:
107
+ logger.debug(
108
+ "Profile %r env %r=%r overrides existing value %r",
109
+ profile.name,
110
+ k,
111
+ v,
112
+ env[k],
113
+ )
114
+ env[k] = v
115
+
116
+ return env
117
+
118
+
119
+ @lru_cache(maxsize=32)
120
+ def _resolve_restic_executable(configured: str) -> str:
121
+ """Resolve a configured restic command to the executable path to run."""
122
+ candidate = configured.strip() or "restic"
123
+ expanded = Path(candidate).expanduser()
124
+
125
+ if expanded.is_absolute():
126
+ return str(expanded)
127
+
128
+ if "/" in candidate:
129
+ return str(expanded.resolve(strict=False))
130
+
131
+ if resolved := shutil.which(candidate):
132
+ return resolved
133
+
134
+ if candidate == "restic":
135
+ for fallback in _COMMON_RESTIC_PATHS:
136
+ if Path(fallback).exists():
137
+ return fallback
138
+
139
+ return candidate
140
+
141
+
142
+ def _restic_executable(profile: Profile) -> str:
143
+ """Return the resolved restic executable for *profile*."""
144
+ return _resolve_restic_executable(profile.restic_binary)
145
+
146
+
147
+ def build_global_args(
148
+ profile: Profile,
149
+ *,
150
+ force_no_cache: bool = False,
151
+ ) -> list[str]:
152
+ """Return flags inserted before every restic subcommand."""
153
+ args: list[str] = []
154
+ if profile.no_cache or force_no_cache:
155
+ args.append("--no-cache")
156
+ if profile.retry_lock:
157
+ args += ["--retry-lock", profile.retry_lock]
158
+ return args
159
+
160
+
161
+ def _restic_command_parts(profile: Profile) -> tuple[str, list[str]]:
162
+ """Return the executable and global args for *profile*."""
163
+ return _restic_executable(profile), build_global_args(profile)
164
+
165
+
166
+ def _snapshot_host() -> str:
167
+ """Return the host label used for restic snapshot creation and filtering."""
168
+ return socket.gethostname()
169
+
170
+
171
+ def build_forget_args(
172
+ profile: Profile,
173
+ *,
174
+ current_host_only: bool = False,
175
+ snapshot_host: str | None = None,
176
+ ) -> list[str]:
177
+ """Return the ``forget`` subcommand scoped by tag and optional host.
178
+
179
+ Returns an empty list when no retention policy is configured (all
180
+ ``keep_*`` values are 0), which signals callers to skip the forget step.
181
+ """
182
+ ret = profile.retention
183
+ if not ret:
184
+ return []
185
+
186
+ keep_fields: list[tuple[str, int]] = [
187
+ ("--keep-last", ret.keep_last),
188
+ ("--keep-hourly", ret.keep_hourly),
189
+ ("--keep-daily", ret.keep_daily),
190
+ ("--keep-weekly", ret.keep_weekly),
191
+ ("--keep-monthly", ret.keep_monthly),
192
+ ("--keep-yearly", ret.keep_yearly),
193
+ ]
194
+
195
+ if not any(value > 0 for _, value in keep_fields):
196
+ return []
197
+
198
+ args: list[str] = ["forget"]
199
+ if current_host_only:
200
+ if snapshot_host is None:
201
+ snapshot_host = _snapshot_host()
202
+ args += ["--host", snapshot_host]
203
+ args += ["--tag", profile.tag]
204
+ for flag, value in keep_fields:
205
+ if value > 0:
206
+ args += [flag, str(value)]
207
+
208
+ return args
209
+
210
+
211
+ def _build_forget_command(
212
+ profile: Profile,
213
+ *,
214
+ current_host_only: bool = False,
215
+ snapshot_host: str | None = None,
216
+ prune: bool = False,
217
+ restic_executable: str | None = None,
218
+ global_args: list[str] | None = None,
219
+ ) -> list[str]:
220
+ """Return the full restic forget command for *profile*.
221
+
222
+ Returns an empty list when the profile has no effective retention policy.
223
+ """
224
+ forget_args = build_forget_args(
225
+ profile,
226
+ current_host_only=current_host_only,
227
+ snapshot_host=snapshot_host,
228
+ )
229
+ if not forget_args:
230
+ return []
231
+
232
+ if restic_executable is None or global_args is None:
233
+ restic_executable, global_args = _restic_command_parts(profile)
234
+
235
+ forget_cmd = [restic_executable, *global_args, *forget_args]
236
+ if prune:
237
+ forget_cmd.append("--prune")
238
+ return forget_cmd
239
+
240
+
241
+ def _build_prune_command(
242
+ profile: Profile,
243
+ *,
244
+ restic_executable: str | None = None,
245
+ global_args: list[str] | None = None,
246
+ ) -> list[str]:
247
+ """Return the full restic prune command for *profile* when enabled."""
248
+ if not profile.retention or not profile.retention.prune:
249
+ return []
250
+
251
+ if restic_executable is None or global_args is None:
252
+ restic_executable, global_args = _restic_command_parts(profile)
253
+
254
+ return [restic_executable, *global_args, "prune"]
255
+
256
+
257
+ def _build_unlock_command(
258
+ profile: Profile,
259
+ *,
260
+ restic_executable: str | None = None,
261
+ global_args: list[str] | None = None,
262
+ ) -> list[str]:
263
+ """Return the full restic unlock command for *profile*."""
264
+ if restic_executable is None or global_args is None:
265
+ restic_executable, global_args = _restic_command_parts(profile)
266
+
267
+ return [restic_executable, *global_args, "unlock"]
268
+
269
+
270
+ def _is_local_repo(repository: str) -> bool:
271
+ """Return True when *repository* looks like a local filesystem path."""
272
+ return ":" not in repository
273
+
274
+
275
+ def _existing_backup_sources(profile: Profile) -> list[str]:
276
+ """Return the subset of configured backup sources that currently exists."""
277
+ existing_sources: list[str] = []
278
+ if not profile.backup:
279
+ return []
280
+ for source in profile.backup.sources:
281
+ if Path(source).exists():
282
+ existing_sources.append(source)
283
+ continue
284
+
285
+ logger.warning(
286
+ "Profile %s: skipping missing backup source %s",
287
+ profile.name,
288
+ source,
289
+ )
290
+
291
+ return existing_sources
292
+
293
+
294
+ def _is_existing_repository_error(error: _CommandError) -> bool:
295
+ """Return True when ``restic init`` failed because the repo already exists."""
296
+ combined_output = "\n".join(filter(None, [error.stdout, error.stderr])).lower()
297
+ return "config file already exists" in combined_output
298
+
299
+
300
+ def _repo_initialized(profile: Profile, env: dict[str, str]) -> bool:
301
+ """Return True when the restic repository already exists / is initialised.
302
+
303
+ Uses ``restic cat config`` for both local and remote repositories. When the
304
+ profile does not already require ``--no-cache``, the check first retries with
305
+ that flag to bypass stale local restic metadata and then falls back to the
306
+ plain command for older restic builds that lack ``--no-cache``.
307
+ """
308
+ restic_executable, global_args = _restic_command_parts(profile)
309
+ repo_check_commands: list[list[str]] = []
310
+
311
+ if profile.no_cache:
312
+ repo_check_commands.append([restic_executable, *global_args, "cat", "config"])
313
+ else:
314
+ repo_check_commands.append(
315
+ [
316
+ restic_executable,
317
+ *build_global_args(profile, force_no_cache=True),
318
+ "cat",
319
+ "config",
320
+ ]
321
+ )
322
+ repo_check_commands.append([restic_executable, *global_args, "cat", "config"])
323
+
324
+ for command in repo_check_commands:
325
+ result = subprocess.run(
326
+ command,
327
+ env=env,
328
+ capture_output=True,
329
+ check=False,
330
+ text=True,
331
+ )
332
+ if result.returncode == 0:
333
+ return True
334
+
335
+ return False
336
+
337
+
338
+ def _run(
339
+ cmd: list[str],
340
+ env: dict[str, str],
341
+ *,
342
+ capture_output: bool = False,
343
+ ) -> subprocess.CompletedProcess[str]:
344
+ """Log and execute *cmd*; raise :exc:`_CommandError` on non-zero exit code."""
345
+ logger.info("Running: %s", " ".join(cmd))
346
+ result = subprocess.run(
347
+ cmd,
348
+ env=env,
349
+ capture_output=capture_output,
350
+ check=False,
351
+ text=capture_output,
352
+ )
353
+ if result.returncode != 0:
354
+ logger.error("Command failed (exit %s): %s", result.returncode, " ".join(cmd))
355
+ if capture_output:
356
+ if result.stdout:
357
+ logger.error("%s", result.stdout.rstrip())
358
+ if result.stderr:
359
+ logger.error("%s", result.stderr.rstrip())
360
+ raise _CommandError(
361
+ cmd,
362
+ result.returncode,
363
+ stdout=result.stdout or "",
364
+ stderr=result.stderr or "",
365
+ )
366
+ if capture_output:
367
+ stdout: str | None = result.stdout if isinstance(result.stdout, str) else None
368
+ stderr: str | None = result.stderr if isinstance(result.stderr, str) else None
369
+ if stdout:
370
+ stdout = stdout.rstrip()
371
+ if stderr:
372
+ stderr = stderr.rstrip()
373
+ if stdout:
374
+ logger.info("%s", stdout)
375
+ if stderr:
376
+ logger.info("%s", stderr)
377
+ return result
378
+
379
+
380
+ def run_hooks(
381
+ stage: str,
382
+ commands: list[str],
383
+ env: dict[str, str],
384
+ shell_path: str = "/bin/sh",
385
+ *,
386
+ dry_run: bool = False,
387
+ ) -> bool:
388
+ """Run *commands* via *shell_path*, returning ``True`` when all succeed.
389
+
390
+ Each command string is passed to the shell executable as
391
+ ``shell_path -c command``, so pipelines, redirections, and multi-line
392
+ scripts all work as expected. This is intentionally not
393
+ ``subprocess.run(..., shell=True)``: the caller chooses the exact shell
394
+ binary to exec.
395
+
396
+ Returns ``False`` and stops on the first command that exits non-zero.
397
+ In *dry_run* mode, logs each command without executing and returns ``True``.
398
+ """
399
+ for command in commands:
400
+ if dry_run:
401
+ logger.info("DRY RUN: Would run %s hook: %s", stage, command)
402
+ continue
403
+ logger.info("Running %s hook: %s", stage, command)
404
+ result = subprocess.run([shell_path, "-c", command], env=env, check=False)
405
+ if result.returncode != 0:
406
+ logger.error(
407
+ "%s hook command failed (exit %s): %s",
408
+ stage.capitalize(),
409
+ result.returncode,
410
+ command,
411
+ )
412
+ return False
413
+ return True
414
+
415
+
416
+ def run_backup(
417
+ profile: Profile, *, dry_run: bool = False, force_notify: bool = False
418
+ ) -> None:
419
+ """Run ``restic backup`` and any configured inline retention for *profile*.
420
+
421
+ Parameters
422
+ ----------
423
+ profile:
424
+ The resolved :class:`Profile` to operate on.
425
+ dry_run:
426
+ When *True* log the actions that would be taken without executing
427
+ any subprocess calls.
428
+ force_notify:
429
+ When *True* and *dry_run* is also *True*, still send the notification
430
+ at the end. Without *dry_run* this flag has no effect.
431
+ """
432
+ if not profile.is_backup or not profile.backup:
433
+ raise ValueError(
434
+ f"Profile {profile.name!r} has no backup block, cannot run backup"
435
+ )
436
+
437
+ hooks = profile.hooks
438
+ env = build_env(profile)
439
+ restic_executable, global_args = _restic_command_parts(profile)
440
+
441
+ def _fail(msg: str, exc: Exception | None = None) -> NoReturn:
442
+ run_hooks("failure", hooks.failure, env, hooks.shell, dry_run=dry_run)
443
+ if profile.resolved_notifier is not None:
444
+ if dry_run:
445
+ logger.info(
446
+ "DRY RUN: Would send failure notification via %s",
447
+ profile.resolved_notifier.type,
448
+ )
449
+ else:
450
+ error_detail = ""
451
+ if isinstance(exc, _CommandError):
452
+ error_detail = f"exit {exc.returncode}"
453
+ if exc.stderr:
454
+ error_detail += f"\n{exc.stderr}"
455
+ elif exc.stdout:
456
+ error_detail += f"\n{exc.stdout}"
457
+ elif exc is not None:
458
+ error_detail = str(exc)
459
+ try_notify_failure(profile, error_detail.strip())
460
+ raise WorkflowError(msg) from exc
461
+
462
+ # prevalidate: run before checking / mounting the backup location
463
+ if not run_hooks(
464
+ "prevalidate", hooks.prevalidate, env, hooks.shell, dry_run=dry_run
465
+ ):
466
+ _fail(f"Profile {profile.name!r}: prevalidate hook failed")
467
+
468
+ sources = _existing_backup_sources(profile)
469
+ if not sources:
470
+ _fail(
471
+ f"Profile {profile.name!r} has no existing sources, cannot run backup",
472
+ ValueError("No existing sources"),
473
+ )
474
+
475
+ repo = profile.resolved_repository
476
+ if not repo:
477
+ _fail(
478
+ f"Profile {profile.name!r} has no resolved repository",
479
+ ValueError("No resolved repository"),
480
+ )
481
+ repo_url = repo.repository
482
+ if _is_local_repo(repo_url):
483
+ repo_path = Path(repo_url)
484
+ if dry_run:
485
+ logger.info("DRY RUN: Would create directory %s", repo_path)
486
+ else:
487
+ logger.debug("Ensuring local repository directory exists: %s", repo_path)
488
+ repo_path.mkdir(parents=True, exist_ok=True)
489
+
490
+ if dry_run:
491
+ logger.info("DRY RUN: Would check / initialise repository %s", repo_url)
492
+ else:
493
+ try:
494
+ if not _repo_initialized(profile, env):
495
+ logger.info(
496
+ "Repository not initialised, running: %s init",
497
+ restic_executable,
498
+ )
499
+ _run(
500
+ [restic_executable] + global_args + ["init"],
501
+ env,
502
+ capture_output=True,
503
+ )
504
+ except _CommandError as exc:
505
+ if _is_existing_repository_error(exc):
506
+ logger.warning(
507
+ "restic init reported that %s already exists; continuing",
508
+ repo_url,
509
+ )
510
+ else:
511
+ _fail(
512
+ f"Profile {profile.name!r}: repository initialization failed", exc
513
+ )
514
+
515
+ # before: run after location checks, before the backup starts
516
+ if not run_hooks("before", hooks.before, env, hooks.shell, dry_run=dry_run):
517
+ _fail(f"Profile {profile.name!r}: before hook failed")
518
+
519
+ if profile.unlock:
520
+ try:
521
+ run_unlock(profile, dry_run=dry_run)
522
+ except _CommandError:
523
+ logger.warning(
524
+ "restic unlock failed for profile %s; continuing with backup",
525
+ profile.name,
526
+ )
527
+
528
+ snapshot_host = _snapshot_host()
529
+ backup_cmd = [
530
+ restic_executable,
531
+ *global_args,
532
+ "backup",
533
+ "--host",
534
+ snapshot_host,
535
+ "--tag",
536
+ profile.tag,
537
+ ]
538
+
539
+ if profile.backup.one_file_system:
540
+ backup_cmd.append("--one-file-system")
541
+
542
+ for pattern in profile.backup.exclude_patterns:
543
+ backup_cmd += ["--exclude", pattern]
544
+
545
+ if profile.backup.exclude_file:
546
+ backup_cmd += ["--exclude-file", profile.backup.exclude_file]
547
+
548
+ backup_cmd += sources
549
+
550
+ _BACKUP_SNAPSHOT_RE = re.compile(r"snapshot ([0-9a-f]+) saved")
551
+
552
+ backup_failed = False
553
+ snapshot_id: str | None = None
554
+ backup_error: _CommandError | None = None
555
+ try:
556
+ capture = not sys.stdout.isatty() if hasattr(sys.stdout, "isatty") else True
557
+ if dry_run:
558
+ logger.info("DRY RUN: Would run: %s", " ".join(backup_cmd))
559
+ else:
560
+ result = _run(backup_cmd, env, capture_output=capture)
561
+ if capture:
562
+ stdout_text = result.stdout
563
+ if isinstance(stdout_text, str):
564
+ if m := _BACKUP_SNAPSHOT_RE.search(stdout_text):
565
+ snapshot_id = m.group(1)
566
+
567
+ if profile.retention:
568
+ forget_cmd = _build_forget_command(
569
+ profile,
570
+ current_host_only=True,
571
+ snapshot_host=snapshot_host,
572
+ prune=profile.retention.prune,
573
+ restic_executable=restic_executable,
574
+ global_args=global_args,
575
+ )
576
+ if forget_cmd:
577
+ if dry_run:
578
+ logger.info("DRY RUN: Would run: %s", " ".join(forget_cmd))
579
+ else:
580
+ _run(forget_cmd, env, capture_output=capture)
581
+ else:
582
+ prune_cmd = _build_prune_command(
583
+ profile,
584
+ restic_executable=restic_executable,
585
+ global_args=global_args,
586
+ )
587
+ if prune_cmd:
588
+ if dry_run:
589
+ logger.info("DRY RUN: Would run: %s", " ".join(prune_cmd))
590
+ else:
591
+ _run(prune_cmd, env, capture_output=capture)
592
+ except _CommandError as exc:
593
+ backup_failed = True
594
+ backup_error = exc
595
+
596
+ # after: always runs after the backup attempt (success or failure)
597
+ run_hooks("after", hooks.after, env, hooks.shell, dry_run=dry_run)
598
+
599
+ if backup_failed:
600
+ _fail(f"Profile {profile.name!r}: backup command failed", backup_error)
601
+
602
+ run_hooks("success", hooks.success, env, hooks.shell, dry_run=dry_run)
603
+ if profile.resolved_notifier is not None:
604
+ if dry_run and not force_notify:
605
+ logger.info(
606
+ "DRY RUN: Would send success notification via %s",
607
+ profile.resolved_notifier.type,
608
+ )
609
+ else:
610
+ try_notify_success(
611
+ profile,
612
+ snapshot_id=snapshot_id,
613
+ env=env,
614
+ restic_executable=restic_executable,
615
+ global_args=global_args,
616
+ )
617
+
618
+
619
+ def run_retention(
620
+ profile: Profile,
621
+ *,
622
+ dry_run: bool = False,
623
+ ) -> None:
624
+ """Run ``restic forget`` (and optional ``prune``) for *profile*.
625
+
626
+ Parameters
627
+ ----------
628
+ profile:
629
+ The resolved :class:`Profile` to operate on.
630
+ dry_run:
631
+ When *True* log the action without executing any subprocess calls.
632
+
633
+ Raises
634
+ ------
635
+ ValueError
636
+ When the profile has no retention block or no retention action.
637
+ """
638
+ if not profile.retention:
639
+ raise ValueError(
640
+ f"Profile {profile.name!r} has no retention block, cannot run retention"
641
+ )
642
+
643
+ forget_cmd = _build_forget_command(
644
+ profile,
645
+ current_host_only=profile.retention.forget_current_host,
646
+ prune=profile.retention.prune,
647
+ )
648
+ command = forget_cmd or _build_prune_command(profile)
649
+ if not command:
650
+ raise ValueError(
651
+ f"Profile {profile.name!r} has no retention action, cannot run retention"
652
+ )
653
+
654
+ env = build_env(profile)
655
+
656
+ if profile.unlock:
657
+ try:
658
+ run_unlock(profile, dry_run=dry_run)
659
+ except _CommandError:
660
+ logger.warning(
661
+ "restic unlock failed for profile %s; continuing with retention",
662
+ profile.name,
663
+ )
664
+
665
+ capture = not sys.stdout.isatty() if hasattr(sys.stdout, "isatty") else True
666
+ if dry_run:
667
+ logger.info("DRY RUN: Would run: %s", " ".join(command))
668
+ else:
669
+ try:
670
+ _run(command, env, capture_output=capture)
671
+ except _CommandError as exc:
672
+ raise WorkflowError(
673
+ f"Profile {profile.name!r}: retention command failed"
674
+ ) from exc
675
+
676
+
677
+ def run_unlock(profile: Profile, *, dry_run: bool = False) -> None:
678
+ """Run ``restic unlock`` for *profile* to remove stale locks.
679
+
680
+ Parameters
681
+ ----------
682
+ profile:
683
+ The resolved :class:`Profile` to operate on.
684
+ dry_run:
685
+ When *True* log the action without executing any subprocess calls.
686
+ """
687
+ env = build_env(profile)
688
+ unlock_cmd = _build_unlock_command(profile)
689
+
690
+ capture = not sys.stdout.isatty() if hasattr(sys.stdout, "isatty") else True
691
+ if dry_run:
692
+ logger.info("DRY RUN: Would run: %s", " ".join(unlock_cmd))
693
+ else:
694
+ _run(unlock_cmd, env, capture_output=capture)
695
+
696
+
697
+ def run_profile(
698
+ profile: Profile, *, dry_run: bool = False, force_notify: bool = False
699
+ ) -> None:
700
+ """Run the configured workflow for *profile*."""
701
+ if profile.is_backup:
702
+ run_backup(profile, dry_run=dry_run, force_notify=force_notify)
703
+ return
704
+
705
+ if profile.runs_retention:
706
+ run_retention(profile, dry_run=dry_run)
707
+ return
708
+
709
+ raise ValueError(
710
+ f"Profile {profile.name!r} has no runnable backup or retention configuration"
711
+ )
@@ -0,0 +1,6 @@
1
+ # restic Backup Failed
2
+
3
+ - **Host:** {{ host }}
4
+ - **Profile:** {{ profile }}
5
+ - **Time:** {{ time }}
6
+ - **Error:** {{ error }}