restic-backups 0.1.1__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 @@
1
+ """Restic backups CLI."""
restic_backups/cli.py ADDED
@@ -0,0 +1,103 @@
1
+ """Command-line interface for configured restic backups."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Annotated, Any, NoReturn
8
+
9
+ import typer
10
+
11
+ from . import config as config_module
12
+ from .errors import BackupError
13
+ from .generic import cli as generic_cli
14
+ from .generic import repository
15
+ from .generic import sops as sops_module
16
+
17
+ app = typer.Typer(
18
+ help="Configured backup commands.",
19
+ no_args_is_help=True,
20
+ )
21
+ app.add_typer(generic_cli.app, name="generic")
22
+
23
+
24
+ @app.callback()
25
+ def configure(
26
+ config_file: Annotated[
27
+ Path | None,
28
+ typer.Option(
29
+ "--config",
30
+ envvar=config_module.CONFIG_ENV,
31
+ dir_okay=False,
32
+ help=f"YAML configuration file. Env: {config_module.CONFIG_ENV}.",
33
+ ),
34
+ ] = None,
35
+ use_sops: Annotated[
36
+ bool,
37
+ typer.Option(
38
+ "--sops",
39
+ envvar=sops_module.SOPS_ENV,
40
+ help=f"Decrypt the configuration with SOPS. Env: {sops_module.SOPS_ENV}.",
41
+ ),
42
+ ] = False,
43
+ ) -> None:
44
+ """Configure storage before running a command."""
45
+ if config_file is not None:
46
+ os.environ[config_module.CONFIG_ENV] = str(config_file)
47
+ os.environ[sops_module.SOPS_ENV] = "1" if use_sops else "0"
48
+
49
+
50
+ def fail(message: str) -> NoReturn:
51
+ typer.echo(f"restic-backups: {message}", err=True)
52
+ raise typer.Exit(1)
53
+
54
+
55
+ def validated() -> tuple[
56
+ dict[str, Any],
57
+ dict[str, dict[str, Any]],
58
+ dict[str, dict[str, Any]],
59
+ dict[str, dict[str, Any]],
60
+ ]:
61
+ try:
62
+ return config_module.load_validated()
63
+ except BackupError as exc:
64
+ fail(str(exc))
65
+
66
+
67
+ @app.command("check-config")
68
+ def check_config_command() -> None:
69
+ """Validate configuration without contacting remote storage."""
70
+ validated()
71
+ typer.echo("config ok")
72
+
73
+
74
+ @app.command(
75
+ "voice-memos",
76
+ context_settings={
77
+ "allow_extra_args": True,
78
+ "ignore_unknown_options": True,
79
+ "help_option_names": [],
80
+ },
81
+ )
82
+ def voice_memos_command(context: typer.Context) -> None:
83
+ """Back up, transcribe, summarise, and diarize macOS Voice Memos."""
84
+ if "SUMMARIES_DIR" not in os.environ and "--help" not in context.args:
85
+ _, credentials, stores, backups = validated()
86
+ try:
87
+ store, _ = repository.resolve("voice-memos", credentials, stores, backups)
88
+ path = repository.data_dir("voice-memos", store) / "summaries"
89
+ except BackupError as exc:
90
+ fail(str(exc))
91
+ os.environ["SUMMARIES_DIR"] = str(path)
92
+
93
+ from .voice_memos.cli import cli
94
+
95
+ cli.main(
96
+ args=list(context.args),
97
+ prog_name="restic-backups voice-memos",
98
+ standalone_mode=True,
99
+ )
100
+
101
+
102
+ if __name__ == "__main__":
103
+ app()
@@ -0,0 +1,152 @@
1
+ """Load and validate backup configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Any, NoReturn
8
+
9
+ import yaml
10
+
11
+ from .errors import BackupError
12
+ from .generic import sops
13
+
14
+ CONFIG_ENV = "RESTIC_BACKUPS_CONFIG"
15
+ PLACEHOLDER = "CHANGE_ME"
16
+
17
+
18
+ class ConfigError(Exception):
19
+ pass
20
+
21
+
22
+ def fail(message: str) -> NoReturn:
23
+ raise BackupError(message)
24
+
25
+
26
+ def config_path() -> Path:
27
+ value = os.environ.get(CONFIG_ENV)
28
+ if not value:
29
+ fail(f"set --config or {CONFIG_ENV}")
30
+ return Path(value).expanduser()
31
+
32
+
33
+ def load_config(path: Path, use_sops: bool) -> dict[str, Any]:
34
+ if use_sops:
35
+ return sops.decrypt(path)
36
+ try:
37
+ loaded = yaml.safe_load(path.read_text())
38
+ except FileNotFoundError:
39
+ fail(f"config file not found: {path}")
40
+ except OSError as exc:
41
+ fail(f"could not read {path}: {exc}")
42
+ except yaml.YAMLError as exc:
43
+ fail(f"config is not valid YAML: {exc}")
44
+ if not isinstance(loaded, dict):
45
+ fail(f"config in {path} must be a mapping")
46
+ return loaded
47
+
48
+
49
+ def required_text(item: dict[str, Any], field: str, owner: str) -> str:
50
+ value = item.get(field)
51
+ if not isinstance(value, str) or not value:
52
+ raise ConfigError(f"{owner}.{field} must be a non-empty string")
53
+ return value
54
+
55
+
56
+ def indexed(config: dict[str, Any], section: str) -> dict[str, dict[str, Any]]:
57
+ items = config.get(section)
58
+ if not isinstance(items, list) or not items:
59
+ raise ConfigError(f"{section} must be a non-empty list")
60
+ result: dict[str, dict[str, Any]] = {}
61
+ for item in items:
62
+ if not isinstance(item, dict):
63
+ raise ConfigError(f"{section} entries must be mappings")
64
+ item_id = required_text(item, "id", section)
65
+ if item_id in result:
66
+ raise ConfigError(f"duplicate {section} id '{item_id}'")
67
+ result[item_id] = item
68
+ return result
69
+
70
+
71
+ def validate(
72
+ config: dict[str, Any],
73
+ ) -> tuple[
74
+ dict[str, dict[str, Any]],
75
+ dict[str, dict[str, Any]],
76
+ dict[str, dict[str, Any]],
77
+ ]:
78
+ credentials = indexed(config, "credentials")
79
+ stores = indexed(config, "restic-stores")
80
+ backups = indexed(config, "backups")
81
+
82
+ for credential_id, credential in credentials.items():
83
+ for field in ("access-key-id", "secret-access-key"):
84
+ required_text(credential, field, credential_id)
85
+
86
+ for store_id, store in stores.items():
87
+ credential_id = required_text(store, "credentials-id", store_id)
88
+ store_credential = credentials.get(credential_id)
89
+ if store_credential is None:
90
+ raise ConfigError(
91
+ f"{store_id} references unknown credentials '{credential_id}'"
92
+ )
93
+ if not isinstance(store.get("enabled"), bool):
94
+ raise ConfigError(f"{store_id}.enabled must be true or false")
95
+ required = [
96
+ required_text(store, field, store_id)
97
+ for field in ("endpoint", "region", "bucket", "key_prefix", "password")
98
+ ]
99
+ if store["enabled"] and PLACEHOLDER in required:
100
+ raise ConfigError(f"{store_id} is enabled but contains placeholders")
101
+ if store["enabled"] and any(
102
+ store_credential[field] == PLACEHOLDER
103
+ for field in ("access-key-id", "secret-access-key")
104
+ ):
105
+ raise ConfigError(f"{store_id} uses placeholder credentials")
106
+
107
+ archive = store.get("archive")
108
+ if archive is None:
109
+ continue
110
+ if not isinstance(archive, dict):
111
+ raise ConfigError(f"{store_id}.archive must be a mapping")
112
+ storage_class = required_text(archive, "storage-class", f"{store_id}.archive")
113
+ restore = archive.get("restore")
114
+ if storage_class == "GLACIER_IR":
115
+ if restore is not None:
116
+ raise ConfigError(f"{store_id}: GLACIER_IR forbids a restore policy")
117
+ continue
118
+ tiers = {
119
+ "GLACIER": {"Standard", "Bulk", "Expedited"},
120
+ "DEEP_ARCHIVE": {"Standard", "Bulk"},
121
+ }.get(storage_class)
122
+ if tiers is None or not isinstance(restore, dict):
123
+ raise ConfigError(f"{store_id} has an invalid cold-storage policy")
124
+ if restore.get("tier") not in tiers:
125
+ raise ConfigError(f"{store_id} has an invalid retrieval tier")
126
+ if not isinstance(restore.get("days"), int) or restore["days"] <= 0:
127
+ raise ConfigError(f"{store_id}.archive.restore.days must be positive")
128
+ required_text(restore, "timeout", f"{store_id}.archive.restore")
129
+
130
+ for backup_id, backup in backups.items():
131
+ store_id = required_text(backup, "restic-store-id", backup_id)
132
+ if store_id not in stores:
133
+ raise ConfigError(
134
+ f"{backup_id} references unknown restic store '{store_id}'"
135
+ )
136
+
137
+ return credentials, stores, backups
138
+
139
+
140
+ def load_validated() -> tuple[
141
+ dict[str, Any],
142
+ dict[str, dict[str, Any]],
143
+ dict[str, dict[str, Any]],
144
+ dict[str, dict[str, Any]],
145
+ ]:
146
+ path = config_path()
147
+ loaded = load_config(path, os.environ.get(sops.SOPS_ENV) == "1")
148
+ try:
149
+ credentials, stores, backups = validate(loaded)
150
+ except ConfigError as exc:
151
+ fail(f"invalid config in {path}: {exc}")
152
+ return loaded, credentials, stores, backups
@@ -0,0 +1,5 @@
1
+ """Shared application errors."""
2
+
3
+
4
+ class BackupError(Exception):
5
+ pass
@@ -0,0 +1 @@
1
+ """Generic restic repository commands."""
@@ -0,0 +1,126 @@
1
+ """CLI for generic configured restic repositories."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from typing import Annotated, Any, NoReturn
7
+
8
+ import questionary
9
+ import typer
10
+
11
+ from .. import config
12
+ from ..errors import BackupError
13
+ from . import repository, restic
14
+
15
+ app = typer.Typer(
16
+ help="Generic configured restic repository commands.", no_args_is_help=True
17
+ )
18
+
19
+
20
+ def fail(message: str) -> NoReturn:
21
+ typer.echo(f"restic-backups: {message}", err=True)
22
+ raise typer.Exit(1)
23
+
24
+
25
+ def validated() -> tuple[
26
+ dict[str, Any],
27
+ dict[str, dict[str, Any]],
28
+ dict[str, dict[str, Any]],
29
+ dict[str, dict[str, Any]],
30
+ ]:
31
+ try:
32
+ return config.load_validated()
33
+ except BackupError as exc:
34
+ fail(str(exc))
35
+
36
+
37
+ def choose_backup(
38
+ backup_id: str | None,
39
+ stores: dict[str, dict[str, Any]],
40
+ backups: dict[str, dict[str, Any]],
41
+ ) -> str:
42
+ if backup_id is not None:
43
+ if backup_id not in backups:
44
+ fail(f"backup '{backup_id}' not found in {config.config_path()}")
45
+ return backup_id
46
+ if not sys.stdin.isatty():
47
+ fail("--backup is required when stdin is not interactive")
48
+ choices = [
49
+ questionary.Choice(
50
+ f"{item_id} ({stores[item['restic-store-id']]['endpoint']})",
51
+ value=item_id,
52
+ )
53
+ for item_id, item in backups.items()
54
+ if stores[item["restic-store-id"]]["enabled"]
55
+ ]
56
+ if not choices:
57
+ fail("no enabled backups are available")
58
+ selected = questionary.select("Backup:", choices=choices).ask()
59
+ if selected is None:
60
+ raise typer.Abort()
61
+ return str(selected)
62
+
63
+
64
+ @app.command("list")
65
+ def list_command() -> None:
66
+ """List backup IDs and their configured restic stores."""
67
+ _, _, stores, backups = validated()
68
+ for backup_id, backup in backups.items():
69
+ store = stores[backup["restic-store-id"]]
70
+ state = "enabled" if store["enabled"] else "disabled"
71
+ typer.echo(f"{backup_id}\t{store['id']}\t{store['endpoint']}\t{state}")
72
+
73
+
74
+ @app.command("data-dir")
75
+ def data_dir_command(
76
+ backup: str | None = typer.Argument(None, help="Backup ID; prompts when omitted."),
77
+ ) -> None:
78
+ """Print the managed local data directory for a backup."""
79
+ _, credentials, stores, backups = validated()
80
+ backup_id = choose_backup(backup, stores, backups)
81
+ try:
82
+ store, _ = repository.resolve(backup_id, credentials, stores, backups)
83
+ typer.echo(repository.data_dir(backup_id, store))
84
+ except BackupError as exc:
85
+ fail(str(exc))
86
+
87
+
88
+ @app.command("init")
89
+ def init_command() -> None:
90
+ """Initialize every enabled restic store referenced by a backup, once."""
91
+ _, credentials, stores, backups = validated()
92
+ seen: set[str] = set()
93
+ for backup_id, backup in backups.items():
94
+ store_id = backup["restic-store-id"]
95
+ if store_id not in seen and stores[store_id]["enabled"]:
96
+ seen.add(store_id)
97
+ try:
98
+ code = restic.command(backup_id, ["init"], credentials, stores, backups)
99
+ except BackupError as exc:
100
+ fail(str(exc))
101
+ if code:
102
+ raise typer.Exit(code)
103
+
104
+
105
+ @app.command(
106
+ "run",
107
+ context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
108
+ )
109
+ def run_command(
110
+ context: typer.Context,
111
+ backup: Annotated[
112
+ str | None,
113
+ typer.Option("--backup", "-b", help="Backup ID; prompts when omitted."),
114
+ ] = None,
115
+ ) -> None:
116
+ """Run restic with all trailing arguments passed through unchanged."""
117
+ _, credentials, stores, backups = validated()
118
+ backup_id = choose_backup(backup, stores, backups)
119
+ if not context.args:
120
+ fail("a restic command is required after 'run'")
121
+ try:
122
+ raise typer.Exit(
123
+ restic.command(backup_id, list(context.args), credentials, stores, backups)
124
+ )
125
+ except BackupError as exc:
126
+ fail(str(exc))
@@ -0,0 +1,41 @@
1
+ """Resolve configured repositories and managed paths."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any, NoReturn
7
+
8
+ from ..config import config_path
9
+ from ..errors import BackupError
10
+
11
+ ROOT = Path(__file__).resolve().parents[3]
12
+
13
+
14
+ def fail(message: str) -> NoReturn:
15
+ raise BackupError(message)
16
+
17
+
18
+ def resolve(
19
+ backup_id: str,
20
+ credentials: dict[str, dict[str, Any]],
21
+ stores: dict[str, dict[str, Any]],
22
+ backups: dict[str, dict[str, Any]],
23
+ ) -> tuple[dict[str, Any], dict[str, Any]]:
24
+ backup = backups.get(backup_id)
25
+ if backup is None:
26
+ fail(f"backup '{backup_id}' not found in {config_path()}")
27
+ store = stores[backup["restic-store-id"]]
28
+ if not store["enabled"]:
29
+ fail(f"restic store '{store['id']}' is disabled")
30
+ return store, credentials[store["credentials-id"]]
31
+
32
+
33
+ def data_dir(backup_id: str, store: dict[str, Any]) -> Path:
34
+ key_prefix = store["key_prefix"].strip("/")
35
+ components = [store["id"], store["bucket"], backup_id]
36
+ if any(not value or value in {".", ".."} or "/" in value for value in components):
37
+ fail("unsafe managed data path component")
38
+ parts = key_prefix.split("/")
39
+ if any(not part or part in {".", ".."} for part in parts):
40
+ fail("unsafe key prefix for managed data")
41
+ return ROOT / "data" / components[0] / components[1] / Path(*parts) / backup_id
@@ -0,0 +1,92 @@
1
+ """Execute restic for a configured repository."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import subprocess
7
+ import sys
8
+ import tempfile
9
+ from typing import Any, NoReturn
10
+
11
+ from ..errors import BackupError
12
+ from . import repository
13
+
14
+
15
+ def fail(message: str) -> NoReturn:
16
+ raise BackupError(message)
17
+
18
+
19
+ def command(
20
+ backup_id: str,
21
+ args: list[str],
22
+ credentials: dict[str, dict[str, Any]],
23
+ stores: dict[str, dict[str, Any]],
24
+ backups: dict[str, dict[str, Any]],
25
+ ) -> int:
26
+ if not args:
27
+ fail("restic command required")
28
+ store, credential = repository.resolve(backup_id, credentials, stores, backups)
29
+
30
+ env = os.environ.copy()
31
+ endpoint = store["endpoint"].rstrip("/")
32
+ key_prefix = store["key_prefix"].strip("/")
33
+ env.update(
34
+ AWS_ACCESS_KEY_ID=credential["access-key-id"],
35
+ AWS_SECRET_ACCESS_KEY=credential["secret-access-key"],
36
+ AWS_DEFAULT_REGION=store["region"],
37
+ RESTIC_PASSWORD=store["password"],
38
+ RESTIC_REPOSITORY=(f"s3:{endpoint}/{store['bucket']}/{key_prefix}"),
39
+ )
40
+ options: list[str] = ["-o", f"s3.region={store['region']}"]
41
+ archive = store.get("archive")
42
+ if archive is not None:
43
+ storage_class = archive["storage-class"]
44
+ options.extend(("-o", f"s3.storage-class={storage_class}"))
45
+ if storage_class != "GLACIER_IR":
46
+ if args[0] in {"init", "backup"}:
47
+ pass
48
+ elif args[0] in {"check", "copy", "prune", "restore"}:
49
+ if os.environ.get("ALLOW_ARCHIVE_RETRIEVAL") != "1":
50
+ fail(
51
+ f"set ALLOW_ARCHIVE_RETRIEVAL=1 to permit {storage_class} retrieval"
52
+ )
53
+ features = {
54
+ value
55
+ for value in env.get("RESTIC_FEATURES", "").split(",")
56
+ if value
57
+ }
58
+ features.add("s3-restore")
59
+ env["RESTIC_FEATURES"] = ",".join(sorted(features))
60
+ restore = archive["restore"]
61
+ options.extend(
62
+ (
63
+ "-o",
64
+ "s3.enable-restore=true",
65
+ "-o",
66
+ f"s3.restore-tier={restore['tier']}",
67
+ "-o",
68
+ f"s3.restore-days={restore['days']}",
69
+ "-o",
70
+ f"s3.restore-timeout={restore['timeout']}",
71
+ )
72
+ )
73
+ else:
74
+ fail(f"restic command '{args[0]}' is not supported for cold S3 storage")
75
+
76
+ try:
77
+ with tempfile.TemporaryFile(mode="w+") as errors:
78
+ result = subprocess.run(
79
+ ["restic", *options, *args], env=env, stderr=errors, check=False
80
+ )
81
+ errors.seek(0)
82
+ error_text = errors.read()
83
+ except FileNotFoundError:
84
+ fail("restic is not installed")
85
+ print(error_text, end="", file=sys.stderr)
86
+ if "operation not permitted" in error_text.lower():
87
+ print(
88
+ "\nrestic was blocked by macOS. Grant the terminal Full Disk Access, "
89
+ "quit it fully, reopen it, and retry.",
90
+ file=sys.stderr,
91
+ )
92
+ return result.returncode
@@ -0,0 +1,30 @@
1
+ """Decrypt SOPS configuration files."""
2
+
3
+ import json
4
+ import subprocess
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from ..errors import BackupError
9
+
10
+ SOPS_ENV = "RESTIC_BACKUPS_SOPS"
11
+
12
+
13
+ def decrypt(path: Path) -> dict[str, Any]:
14
+ try:
15
+ result = subprocess.run(
16
+ ["sops", "--decrypt", "--output-type", "json", path],
17
+ check=True,
18
+ capture_output=True,
19
+ text=True,
20
+ )
21
+ config = json.loads(result.stdout)
22
+ except FileNotFoundError as exc:
23
+ raise BackupError("sops is not installed") from exc
24
+ except subprocess.CalledProcessError as exc:
25
+ raise BackupError(exc.stderr.strip() or f"could not decrypt {path}") from exc
26
+ except json.JSONDecodeError as exc:
27
+ raise BackupError(f"decrypted config is not valid JSON: {exc}") from exc
28
+ if not isinstance(config, dict):
29
+ raise BackupError(f"config in {path} must be a mapping")
30
+ return config
@@ -0,0 +1 @@
1
+ """Voice Memos transcription and diarization tools."""