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/runner.py
ADDED
|
@@ -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
|
+
)
|