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 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
@@ -0,0 +1,4 @@
1
+ from dorestic.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
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