dorestic 0.4.4__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.
- dorestic/__init__.py +39 -0
- dorestic/__main__.py +4 -0
- dorestic/backup.py +356 -0
- dorestic/cli.py +58 -0
- dorestic/config.py +110 -0
- dorestic/config.yml.example +50 -0
- dorestic/docker.py +211 -0
- dorestic/models.py +64 -0
- dorestic/paths.py +61 -0
- dorestic/restic.py +111 -0
- dorestic-0.4.4.dist-info/METADATA +367 -0
- dorestic-0.4.4.dist-info/RECORD +16 -0
- dorestic-0.4.4.dist-info/WHEEL +5 -0
- dorestic-0.4.4.dist-info/entry_points.txt +2 -0
- dorestic-0.4.4.dist-info/licenses/LICENSE +190 -0
- dorestic-0.4.4.dist-info/top_level.txt +1 -0
dorestic/__init__.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from dorestic.backup import (
|
|
2
|
+
TeeStream as TeeStream,
|
|
3
|
+
acquire_lock as acquire_lock,
|
|
4
|
+
backup_container as backup_container,
|
|
5
|
+
backup_host_group as backup_host_group,
|
|
6
|
+
run_backup as run_backup,
|
|
7
|
+
run_hook as run_hook,
|
|
8
|
+
)
|
|
9
|
+
from dorestic.config import find_config as find_config, load_config as load_config
|
|
10
|
+
from dorestic.docker import (
|
|
11
|
+
discover_targets as discover_targets,
|
|
12
|
+
docker_cp as docker_cp,
|
|
13
|
+
resolve_container_path as resolve_container_path,
|
|
14
|
+
resolve_container_paths as resolve_container_paths,
|
|
15
|
+
run_docker_exec as run_docker_exec,
|
|
16
|
+
)
|
|
17
|
+
from dorestic.models import (
|
|
18
|
+
DEFAULT_CONTAINER_SHELL as DEFAULT_CONTAINER_SHELL,
|
|
19
|
+
DEFAULT_LABEL_PREFIX as DEFAULT_LABEL_PREFIX,
|
|
20
|
+
DEFAULT_RESTIC_IMAGE as DEFAULT_RESTIC_IMAGE,
|
|
21
|
+
EXIT_ON_START_FAILED as EXIT_ON_START_FAILED,
|
|
22
|
+
BackupConfig as BackupConfig,
|
|
23
|
+
ContainerTarget as ContainerTarget,
|
|
24
|
+
HostGroup as HostGroup,
|
|
25
|
+
RetentionPolicy as RetentionPolicy,
|
|
26
|
+
ScopeConfig as ScopeConfig,
|
|
27
|
+
ScopeResult as ScopeResult,
|
|
28
|
+
)
|
|
29
|
+
from dorestic.paths import (
|
|
30
|
+
expand_depth_limited_path as expand_depth_limited_path,
|
|
31
|
+
parse_comma_list as parse_comma_list,
|
|
32
|
+
resolve_host_path_spec as resolve_host_path_spec,
|
|
33
|
+
resolve_host_paths as resolve_host_paths,
|
|
34
|
+
)
|
|
35
|
+
from dorestic.restic import (
|
|
36
|
+
make_restic_hostname as make_restic_hostname,
|
|
37
|
+
run_restic as run_restic,
|
|
38
|
+
run_scope_backup as run_scope_backup,
|
|
39
|
+
)
|
dorestic/__main__.py
ADDED
dorestic/backup.py
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import fcntl
|
|
4
|
+
import hashlib
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
from io import TextIOBase
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import IO
|
|
14
|
+
|
|
15
|
+
import docker
|
|
16
|
+
|
|
17
|
+
from dorestic.config import load_config
|
|
18
|
+
from dorestic.docker import (
|
|
19
|
+
discover_targets,
|
|
20
|
+
resolve_container_paths,
|
|
21
|
+
run_docker_exec,
|
|
22
|
+
)
|
|
23
|
+
from dorestic.models import (
|
|
24
|
+
BackupConfig,
|
|
25
|
+
ContainerTarget,
|
|
26
|
+
EXIT_ON_START_FAILED,
|
|
27
|
+
HostGroup,
|
|
28
|
+
ScopeResult,
|
|
29
|
+
)
|
|
30
|
+
from dorestic.paths import resolve_host_paths
|
|
31
|
+
from dorestic.restic import make_restic_hostname, run_restic, run_scope_backup
|
|
32
|
+
|
|
33
|
+
log = logging.getLogger("backup")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TeeStream(TextIOBase):
|
|
37
|
+
"""Writes to both a file and the original stream."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, original: IO[str], log_file: IO[str]) -> None:
|
|
40
|
+
self.original = original
|
|
41
|
+
self.log_file = log_file
|
|
42
|
+
|
|
43
|
+
def write(self, s: str) -> int:
|
|
44
|
+
self.original.write(s)
|
|
45
|
+
self.log_file.write(s)
|
|
46
|
+
return len(s)
|
|
47
|
+
|
|
48
|
+
def flush(self) -> None:
|
|
49
|
+
self.original.flush()
|
|
50
|
+
self.log_file.flush()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _lock_path_for(config: BackupConfig) -> Path:
|
|
54
|
+
"""Derive a per-repository lock file path."""
|
|
55
|
+
repo_hash = hashlib.sha256(config.repository.encode()).hexdigest()[:16]
|
|
56
|
+
return Path(tempfile.gettempdir()) / f"dorestic-{repo_hash}.lock"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def acquire_lock(config: BackupConfig) -> IO[str]:
|
|
60
|
+
lock_path = _lock_path_for(config)
|
|
61
|
+
lock_fd: IO[str] = open(lock_path, "w")
|
|
62
|
+
try:
|
|
63
|
+
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
64
|
+
except OSError:
|
|
65
|
+
log.error("Another backup is already running (lock held on %s)", lock_path)
|
|
66
|
+
sys.exit(1)
|
|
67
|
+
return lock_fd
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def run_hook(command: str, env: dict[str, str] | None = None) -> int:
|
|
71
|
+
hook_env = os.environ.copy()
|
|
72
|
+
if env:
|
|
73
|
+
hook_env.update(env)
|
|
74
|
+
result = subprocess.run(["sh", "-c", command], env=hook_env)
|
|
75
|
+
return result.returncode
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def backup_container(
|
|
79
|
+
target: ContainerTarget,
|
|
80
|
+
config: BackupConfig,
|
|
81
|
+
staging_dir: Path | None = None,
|
|
82
|
+
) -> tuple[ScopeResult, ScopeResult]:
|
|
83
|
+
log.info("")
|
|
84
|
+
log.info("=== %s ===", target.name)
|
|
85
|
+
|
|
86
|
+
container_paths = resolve_container_paths(target, staging_dir=staging_dir)
|
|
87
|
+
host_paths = resolve_host_paths(target)
|
|
88
|
+
|
|
89
|
+
tag_env = {"DORESTIC_TAG": target.name}
|
|
90
|
+
|
|
91
|
+
container_on_start_ok = True
|
|
92
|
+
if target.container_scope and target.container_scope.on_start:
|
|
93
|
+
log.info(" container.on_start: %s", target.container_scope.on_start)
|
|
94
|
+
code, output = run_docker_exec(
|
|
95
|
+
target.container, target.container_scope.on_start,
|
|
96
|
+
env=tag_env, shell=target.container_scope.shell,
|
|
97
|
+
)
|
|
98
|
+
if output:
|
|
99
|
+
for line in output.splitlines():
|
|
100
|
+
log.info(" %s", line)
|
|
101
|
+
if code != 0:
|
|
102
|
+
log.error(
|
|
103
|
+
" container.on_start failed (exit %d), skipping container backup",
|
|
104
|
+
code,
|
|
105
|
+
)
|
|
106
|
+
container_on_start_ok = False
|
|
107
|
+
|
|
108
|
+
host_on_start_ok = True
|
|
109
|
+
if target.host_scope and target.host_scope.on_start:
|
|
110
|
+
log.info(" host.on_start: %s", target.host_scope.on_start)
|
|
111
|
+
code = run_hook(target.host_scope.on_start, env=tag_env)
|
|
112
|
+
if code != 0:
|
|
113
|
+
log.error(
|
|
114
|
+
" host.on_start failed (exit %d), skipping host backup", code
|
|
115
|
+
)
|
|
116
|
+
host_on_start_ok = False
|
|
117
|
+
|
|
118
|
+
container_result = ScopeResult(exit_code=0, skipped=True)
|
|
119
|
+
if container_paths and container_on_start_ok:
|
|
120
|
+
container_tag = f"{target.name}:container"
|
|
121
|
+
exit_code = run_scope_backup(
|
|
122
|
+
container_tag,
|
|
123
|
+
container_paths,
|
|
124
|
+
target.container_scope.exclude if target.container_scope else [],
|
|
125
|
+
config=config,
|
|
126
|
+
hostname=make_restic_hostname("container", target.name),
|
|
127
|
+
)
|
|
128
|
+
container_result = ScopeResult(exit_code=exit_code)
|
|
129
|
+
if exit_code == 0:
|
|
130
|
+
log.info(" container backup OK")
|
|
131
|
+
else:
|
|
132
|
+
log.error(" container backup FAILED (exit %d)", exit_code)
|
|
133
|
+
elif container_paths and not container_on_start_ok:
|
|
134
|
+
container_result = ScopeResult(exit_code=EXIT_ON_START_FAILED, skipped=True)
|
|
135
|
+
|
|
136
|
+
host_result = ScopeResult(exit_code=0, skipped=True)
|
|
137
|
+
if host_paths and host_on_start_ok:
|
|
138
|
+
host_tag = f"{target.name}:host"
|
|
139
|
+
exit_code = run_scope_backup(
|
|
140
|
+
host_tag,
|
|
141
|
+
host_paths,
|
|
142
|
+
target.host_scope.exclude if target.host_scope else [],
|
|
143
|
+
config=config,
|
|
144
|
+
hostname=make_restic_hostname("host", target.name),
|
|
145
|
+
)
|
|
146
|
+
host_result = ScopeResult(exit_code=exit_code)
|
|
147
|
+
if exit_code == 0:
|
|
148
|
+
log.info(" host backup OK")
|
|
149
|
+
else:
|
|
150
|
+
log.error(" host backup FAILED (exit %d)", exit_code)
|
|
151
|
+
elif host_paths and not host_on_start_ok:
|
|
152
|
+
host_result = ScopeResult(exit_code=EXIT_ON_START_FAILED, skipped=True)
|
|
153
|
+
|
|
154
|
+
if target.container_scope and target.container_scope.on_complete:
|
|
155
|
+
log.info(" container.on_complete: %s", target.container_scope.on_complete)
|
|
156
|
+
code, output = run_docker_exec(
|
|
157
|
+
target.container, target.container_scope.on_complete,
|
|
158
|
+
env={**tag_env, "DORESTIC_EXIT_CODE": str(container_result.exit_code)},
|
|
159
|
+
shell=target.container_scope.shell,
|
|
160
|
+
)
|
|
161
|
+
if output:
|
|
162
|
+
for line in output.splitlines():
|
|
163
|
+
log.info(" %s", line)
|
|
164
|
+
if code != 0:
|
|
165
|
+
log.warning(" container.on_complete failed (exit %d)", code)
|
|
166
|
+
|
|
167
|
+
if target.host_scope and target.host_scope.on_complete:
|
|
168
|
+
log.info(" host.on_complete: %s", target.host_scope.on_complete)
|
|
169
|
+
code = run_hook(
|
|
170
|
+
target.host_scope.on_complete,
|
|
171
|
+
env={**tag_env, "DORESTIC_EXIT_CODE": str(host_result.exit_code)},
|
|
172
|
+
)
|
|
173
|
+
if code != 0:
|
|
174
|
+
log.warning(" host.on_complete failed (exit %d)", code)
|
|
175
|
+
|
|
176
|
+
return container_result, host_result
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def backup_host_group(group: HostGroup, config: BackupConfig) -> ScopeResult:
|
|
180
|
+
log.info("")
|
|
181
|
+
log.info("=== host:%s ===", group.tag)
|
|
182
|
+
|
|
183
|
+
resolved_paths: list[Path] = []
|
|
184
|
+
for raw in group.paths:
|
|
185
|
+
full = Path(raw)
|
|
186
|
+
if full.exists():
|
|
187
|
+
resolved_paths.append(full)
|
|
188
|
+
else:
|
|
189
|
+
log.warning(" host path %s does not exist", full)
|
|
190
|
+
|
|
191
|
+
if not resolved_paths:
|
|
192
|
+
log.info(" no valid paths, skipping")
|
|
193
|
+
return ScopeResult(exit_code=0, skipped=True)
|
|
194
|
+
|
|
195
|
+
tag_env = {"DORESTIC_TAG": group.tag}
|
|
196
|
+
|
|
197
|
+
if group.on_start:
|
|
198
|
+
log.info(" on_start: %s", group.on_start)
|
|
199
|
+
code = run_hook(group.on_start, env=tag_env)
|
|
200
|
+
if code != 0:
|
|
201
|
+
log.error(" on_start failed (exit %d), skipping backup", code)
|
|
202
|
+
result = ScopeResult(exit_code=EXIT_ON_START_FAILED, skipped=True)
|
|
203
|
+
if group.on_complete:
|
|
204
|
+
log.info(" on_complete: %s", group.on_complete)
|
|
205
|
+
run_hook(group.on_complete, env={**tag_env, "DORESTIC_EXIT_CODE": str(result.exit_code)})
|
|
206
|
+
return result
|
|
207
|
+
|
|
208
|
+
exit_code = run_scope_backup(
|
|
209
|
+
group.tag, resolved_paths, group.exclude, config=config,
|
|
210
|
+
hostname=make_restic_hostname("host", group.tag),
|
|
211
|
+
)
|
|
212
|
+
result = ScopeResult(exit_code=exit_code)
|
|
213
|
+
if exit_code == 0:
|
|
214
|
+
log.info(" backup OK")
|
|
215
|
+
else:
|
|
216
|
+
log.error(" backup FAILED (exit %d)", exit_code)
|
|
217
|
+
|
|
218
|
+
if group.on_complete:
|
|
219
|
+
log.info(" on_complete: %s", group.on_complete)
|
|
220
|
+
code = run_hook(group.on_complete, env={**tag_env, "DORESTIC_EXIT_CODE": str(result.exit_code)})
|
|
221
|
+
if code != 0:
|
|
222
|
+
log.warning(" on_complete failed (exit %d)", code)
|
|
223
|
+
|
|
224
|
+
return result
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def run_backup(config_path: str) -> None:
|
|
228
|
+
logging.basicConfig(
|
|
229
|
+
level=logging.INFO,
|
|
230
|
+
format="%(asctime)s %(levelname)s %(message)s",
|
|
231
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
log_file = tempfile.NamedTemporaryFile(
|
|
235
|
+
mode="w", prefix="backup-", suffix=".log", delete=False
|
|
236
|
+
)
|
|
237
|
+
log_path = log_file.name
|
|
238
|
+
original_stdout = sys.stdout
|
|
239
|
+
original_stderr = sys.stderr
|
|
240
|
+
overall_exit = 1
|
|
241
|
+
lock_fd: IO[str] | None = None
|
|
242
|
+
staging_dir: Path | None = None
|
|
243
|
+
|
|
244
|
+
tee_stdout = TeeStream(original_stdout, log_file)
|
|
245
|
+
tee_stderr = TeeStream(original_stderr, log_file)
|
|
246
|
+
|
|
247
|
+
root_logger = logging.getLogger()
|
|
248
|
+
for handler in root_logger.handlers:
|
|
249
|
+
if isinstance(handler, logging.StreamHandler):
|
|
250
|
+
handler.stream = tee_stdout
|
|
251
|
+
|
|
252
|
+
sys.stdout = tee_stdout # type: ignore[assignment]
|
|
253
|
+
sys.stderr = tee_stderr # type: ignore[assignment]
|
|
254
|
+
|
|
255
|
+
try:
|
|
256
|
+
config = load_config(config_path)
|
|
257
|
+
lock_fd = acquire_lock(config)
|
|
258
|
+
|
|
259
|
+
if config.on_start:
|
|
260
|
+
log.info("Running on_start: %s", config.on_start)
|
|
261
|
+
start_code = run_hook(config.on_start)
|
|
262
|
+
if start_code != 0:
|
|
263
|
+
log.error("on_start failed (exit %d), aborting backup", start_code)
|
|
264
|
+
sys.exit(1)
|
|
265
|
+
|
|
266
|
+
log.info("=== Initializing repository if needed ===")
|
|
267
|
+
init_code, init_output = run_restic("init", config=config, capture=True)
|
|
268
|
+
if init_code == 0:
|
|
269
|
+
log.info("Initialized new repository at %s", config.repository)
|
|
270
|
+
else:
|
|
271
|
+
check_code, _ = run_restic("cat", "config", config=config, capture=True)
|
|
272
|
+
if check_code != 0:
|
|
273
|
+
log.error("Repository init failed at %s:\n%s", config.repository, init_output)
|
|
274
|
+
sys.exit(1)
|
|
275
|
+
|
|
276
|
+
client = docker.DockerClient.from_env()
|
|
277
|
+
errors = 0
|
|
278
|
+
staging_dir = Path(tempfile.mkdtemp(prefix="backup-staging-"))
|
|
279
|
+
|
|
280
|
+
log.info("")
|
|
281
|
+
log.info("=== Discovering backup-enabled containers ===")
|
|
282
|
+
targets = discover_targets(client)
|
|
283
|
+
|
|
284
|
+
if not targets:
|
|
285
|
+
log.info(" No backup-enabled containers found")
|
|
286
|
+
|
|
287
|
+
for target in targets:
|
|
288
|
+
container_result, host_result = backup_container(
|
|
289
|
+
target, config=config, staging_dir=staging_dir,
|
|
290
|
+
)
|
|
291
|
+
if container_result.exit_code != 0:
|
|
292
|
+
errors += 1
|
|
293
|
+
if host_result.exit_code != 0:
|
|
294
|
+
errors += 1
|
|
295
|
+
|
|
296
|
+
if config.host_groups:
|
|
297
|
+
log.info("")
|
|
298
|
+
log.info("=== Processing host backup groups ===")
|
|
299
|
+
for group in config.host_groups:
|
|
300
|
+
group_result = backup_host_group(group, config=config)
|
|
301
|
+
if group_result.exit_code != 0:
|
|
302
|
+
errors += 1
|
|
303
|
+
|
|
304
|
+
log.info("")
|
|
305
|
+
log.info("=== Forgetting old snapshots and pruning ===")
|
|
306
|
+
run_restic(
|
|
307
|
+
"forget",
|
|
308
|
+
"--group-by", "host,tags",
|
|
309
|
+
"--keep-daily", str(config.retention.daily),
|
|
310
|
+
"--keep-weekly", str(config.retention.weekly),
|
|
311
|
+
"--keep-monthly", str(config.retention.monthly),
|
|
312
|
+
"--prune",
|
|
313
|
+
config=config,
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
log.info("")
|
|
317
|
+
log.info("=== Checking repository integrity ===")
|
|
318
|
+
run_restic("check", config=config)
|
|
319
|
+
|
|
320
|
+
overall_exit = 1 if errors > 0 else 0
|
|
321
|
+
|
|
322
|
+
log.info("")
|
|
323
|
+
log.info("=== Backup complete ===")
|
|
324
|
+
if errors > 0:
|
|
325
|
+
log.warning("%d backup(s) had errors", errors)
|
|
326
|
+
|
|
327
|
+
if config.on_complete:
|
|
328
|
+
log_file.flush()
|
|
329
|
+
log.info("Running on_complete: %s", config.on_complete)
|
|
330
|
+
run_hook(config.on_complete, env={
|
|
331
|
+
"DORESTIC_EXIT_CODE": str(overall_exit),
|
|
332
|
+
"DORESTIC_LOGFILE": log_path,
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
except SystemExit:
|
|
336
|
+
raise
|
|
337
|
+
except Exception:
|
|
338
|
+
log.exception("Backup failed with unexpected error")
|
|
339
|
+
overall_exit = 1
|
|
340
|
+
finally:
|
|
341
|
+
sys.stdout = original_stdout
|
|
342
|
+
sys.stderr = original_stderr
|
|
343
|
+
for handler in root_logger.handlers:
|
|
344
|
+
if isinstance(handler, logging.StreamHandler):
|
|
345
|
+
handler.stream = original_stderr
|
|
346
|
+
if not log_file.closed:
|
|
347
|
+
log_file.close()
|
|
348
|
+
os.unlink(log_path)
|
|
349
|
+
if staging_dir is not None:
|
|
350
|
+
shutil.rmtree(staging_dir, ignore_errors=True)
|
|
351
|
+
if lock_fd is not None:
|
|
352
|
+
lock_fd.close()
|
|
353
|
+
|
|
354
|
+
sys.exit(overall_exit)
|
|
355
|
+
|
|
356
|
+
|
dorestic/cli.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import importlib.resources
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from dorestic.config import find_config
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def write_example_config(dest: str) -> None:
|
|
12
|
+
"""Write the bundled config.yml.example to the given path."""
|
|
13
|
+
dest_path = Path(dest)
|
|
14
|
+
|
|
15
|
+
if dest_path.suffix != ".yml":
|
|
16
|
+
dest_path = dest_path / "config.yml"
|
|
17
|
+
|
|
18
|
+
if dest_path.exists():
|
|
19
|
+
print(f"Error: {dest_path} already exists", file=sys.stderr)
|
|
20
|
+
sys.exit(1)
|
|
21
|
+
|
|
22
|
+
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
|
|
24
|
+
ref = importlib.resources.files("dorestic").joinpath("config.yml.example")
|
|
25
|
+
content = ref.read_text(encoding="utf-8")
|
|
26
|
+
dest_path.write_text(content)
|
|
27
|
+
print(f"Wrote example config to {dest_path}")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def main() -> None:
|
|
31
|
+
parser = argparse.ArgumentParser(
|
|
32
|
+
prog="dorestic",
|
|
33
|
+
description="Label-driven Docker backup using restic.",
|
|
34
|
+
)
|
|
35
|
+
parser.add_argument(
|
|
36
|
+
"config",
|
|
37
|
+
nargs="?",
|
|
38
|
+
default=None,
|
|
39
|
+
help="path to config.yml (default: auto-discover)",
|
|
40
|
+
)
|
|
41
|
+
parser.add_argument(
|
|
42
|
+
"--init",
|
|
43
|
+
metavar="PATH",
|
|
44
|
+
nargs="?",
|
|
45
|
+
const=".",
|
|
46
|
+
help="write example config.yml to PATH (default: current directory)",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
args = parser.parse_args()
|
|
50
|
+
|
|
51
|
+
if args.init is not None:
|
|
52
|
+
write_example_config(args.init)
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
config_path: str = args.config if args.config is not None else find_config()
|
|
56
|
+
|
|
57
|
+
from dorestic.backup import run_backup
|
|
58
|
+
run_backup(config_path)
|
dorestic/config.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
from dorestic.models import (
|
|
10
|
+
BackupConfig,
|
|
11
|
+
DEFAULT_RESTIC_IMAGE,
|
|
12
|
+
HostGroup,
|
|
13
|
+
RetentionPolicy,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
CONFIG_FILENAME = "config.yml"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def find_config() -> str:
|
|
20
|
+
"""Search for config.yml in standard locations.
|
|
21
|
+
|
|
22
|
+
Order: ./config.yml, then $XDG_CONFIG_HOME/dorestic/config.yml
|
|
23
|
+
(defaulting to ~/.config/dorestic/config.yml).
|
|
24
|
+
"""
|
|
25
|
+
local = Path(CONFIG_FILENAME)
|
|
26
|
+
if local.is_file():
|
|
27
|
+
return str(local)
|
|
28
|
+
|
|
29
|
+
xdg = os.environ.get("XDG_CONFIG_HOME", "")
|
|
30
|
+
config_dir = Path(xdg) if xdg else Path.home() / ".config"
|
|
31
|
+
xdg_path = config_dir / "dorestic" / CONFIG_FILENAME
|
|
32
|
+
if xdg_path.is_file():
|
|
33
|
+
return str(xdg_path)
|
|
34
|
+
|
|
35
|
+
raise FileNotFoundError(
|
|
36
|
+
f"No config.yml found. Searched:\n"
|
|
37
|
+
f" ./{CONFIG_FILENAME}\n"
|
|
38
|
+
f" {xdg_path}\n"
|
|
39
|
+
f"Run 'dorestic --init' to create one, or pass a path: dorestic /path/to/config.yml"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _as_dict(value: Any, msg: str) -> dict[str, Any]:
|
|
44
|
+
"""Validate that a YAML value is a mapping and return it typed."""
|
|
45
|
+
if not hasattr(value, "keys"):
|
|
46
|
+
raise ValueError(msg)
|
|
47
|
+
result: dict[str, Any] = value
|
|
48
|
+
return result
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def load_config(path: str) -> BackupConfig:
|
|
52
|
+
with open(path) as f:
|
|
53
|
+
raw = yaml.safe_load(f)
|
|
54
|
+
|
|
55
|
+
data: dict[str, Any] = _as_dict(raw, f"Config file {path} must be a YAML mapping")
|
|
56
|
+
|
|
57
|
+
if "repository" not in data:
|
|
58
|
+
raise ValueError(f"Config file {path}: missing required field 'repository'")
|
|
59
|
+
if "password_file" not in data:
|
|
60
|
+
raise ValueError(f"Config file {path}: missing required field 'password_file'")
|
|
61
|
+
if "excludes" in data:
|
|
62
|
+
raise ValueError("Config uses 'excludes' (plural) — use 'exclude' instead")
|
|
63
|
+
|
|
64
|
+
pw_path = Path(str(data["password_file"]))
|
|
65
|
+
if not pw_path.exists():
|
|
66
|
+
raise ValueError(f"password_file does not exist: {pw_path}")
|
|
67
|
+
|
|
68
|
+
retention = RetentionPolicy()
|
|
69
|
+
raw_retention = data.get("retention")
|
|
70
|
+
if raw_retention is not None:
|
|
71
|
+
ret = _as_dict(raw_retention, "retention must be a mapping")
|
|
72
|
+
if "daily" in ret:
|
|
73
|
+
retention.daily = int(ret["daily"])
|
|
74
|
+
if "weekly" in ret:
|
|
75
|
+
retention.weekly = int(ret["weekly"])
|
|
76
|
+
if "monthly" in ret:
|
|
77
|
+
retention.monthly = int(ret["monthly"])
|
|
78
|
+
|
|
79
|
+
host_groups: list[HostGroup] = []
|
|
80
|
+
for entry in data.get("host_groups", []):
|
|
81
|
+
group_data: dict[str, Any] = entry
|
|
82
|
+
if "excludes" in group_data:
|
|
83
|
+
raise ValueError(
|
|
84
|
+
f"host_groups entry '{group_data.get('tag', '?')}' uses 'excludes' "
|
|
85
|
+
"(plural) — use 'exclude' instead"
|
|
86
|
+
)
|
|
87
|
+
on_start_val = group_data.get("on_start")
|
|
88
|
+
on_complete_val = group_data.get("on_complete")
|
|
89
|
+
host_groups.append(
|
|
90
|
+
HostGroup(
|
|
91
|
+
tag=str(group_data["tag"]),
|
|
92
|
+
paths=[str(p) for p in group_data["paths"]],
|
|
93
|
+
exclude=[str(e) for e in group_data.get("exclude", [])],
|
|
94
|
+
on_start=str(on_start_val) if on_start_val is not None else None,
|
|
95
|
+
on_complete=str(on_complete_val) if on_complete_val is not None else None,
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
on_start_val = data.get("on_start")
|
|
100
|
+
on_complete_val = data.get("on_complete")
|
|
101
|
+
|
|
102
|
+
return BackupConfig(
|
|
103
|
+
repository=str(data["repository"]),
|
|
104
|
+
password_file=str(data["password_file"]),
|
|
105
|
+
restic_image=str(data.get("restic_image", DEFAULT_RESTIC_IMAGE)),
|
|
106
|
+
on_start=str(on_start_val) if on_start_val is not None else None,
|
|
107
|
+
on_complete=str(on_complete_val) if on_complete_val is not None else None,
|
|
108
|
+
retention=retention,
|
|
109
|
+
host_groups=host_groups,
|
|
110
|
+
)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Restic backup configuration
|
|
2
|
+
#
|
|
3
|
+
# Edit this file and fill in your values.
|
|
4
|
+
|
|
5
|
+
# Path to the restic repository (required)
|
|
6
|
+
repository: /mnt/backup/backup1
|
|
7
|
+
|
|
8
|
+
# Path to a file containing the restic repository password (required)
|
|
9
|
+
# This is mounted into the restic container via RESTIC_PASSWORD_FILE —
|
|
10
|
+
# the password never appears on the command line.
|
|
11
|
+
# A trailing newline is fine (restic strips it).
|
|
12
|
+
password_file: /etc/backup/restic-password
|
|
13
|
+
|
|
14
|
+
# Restic Docker image (optional, default: restic/restic:latest)
|
|
15
|
+
# restic_image: restic/restic:latest
|
|
16
|
+
|
|
17
|
+
# Command to run before the backup starts (optional)
|
|
18
|
+
# If it exits non-zero, the entire backup is aborted.
|
|
19
|
+
# All on_start/on_complete hooks are run via sh -c.
|
|
20
|
+
# on_start: /path/to/on_start.sh
|
|
21
|
+
|
|
22
|
+
# Command to run after the entire backup completes (optional)
|
|
23
|
+
# Environment: $DORESTIC_EXIT_CODE, $DORESTIC_LOGFILE
|
|
24
|
+
# The log file is deleted after on_complete returns — copy or upload it
|
|
25
|
+
# inside the command if you want to keep it.
|
|
26
|
+
# on_complete: /path/to/on_complete.sh
|
|
27
|
+
|
|
28
|
+
# Snapshot retention policy (optional, these are the defaults)
|
|
29
|
+
# retention:
|
|
30
|
+
# daily: 7
|
|
31
|
+
# weekly: 4
|
|
32
|
+
# monthly: 12
|
|
33
|
+
|
|
34
|
+
# Host-only backup groups — not tied to any Docker container (optional)
|
|
35
|
+
# Each group gets its own tagged restic snapshot.
|
|
36
|
+
# host_groups:
|
|
37
|
+
# - tag: documents
|
|
38
|
+
# paths:
|
|
39
|
+
# - /mnt/fileserver/share
|
|
40
|
+
# - /mnt/fileserver/share-private
|
|
41
|
+
# exclude:
|
|
42
|
+
# - "*.tmp"
|
|
43
|
+
#
|
|
44
|
+
# - tag: docker-config
|
|
45
|
+
# paths:
|
|
46
|
+
# - /mnt/fileserver/docker
|
|
47
|
+
# exclude:
|
|
48
|
+
# - "aosp-mirror/mirror"
|
|
49
|
+
# # on_start: /path/to/pre-backup.sh # env: $DORESTIC_TAG
|
|
50
|
+
# # on_complete: /path/to/post-backup.sh # env: $DORESTIC_TAG, $DORESTIC_EXIT_CODE
|