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.
- restic_backups/__init__.py +1 -0
- restic_backups/cli.py +103 -0
- restic_backups/config.py +152 -0
- restic_backups/errors.py +5 -0
- restic_backups/generic/__init__.py +1 -0
- restic_backups/generic/cli.py +126 -0
- restic_backups/generic/repository.py +41 -0
- restic_backups/generic/restic.py +92 -0
- restic_backups/generic/sops.py +30 -0
- restic_backups/voice_memos/__init__.py +1 -0
- restic_backups/voice_memos/cli.py +240 -0
- restic_backups/voice_memos/dashboard.py +156 -0
- restic_backups/voice_memos/parallel.py +72 -0
- restic_backups/voice_memos/pipeline.py +1685 -0
- restic_backups/voice_memos/prompts/__init__.py +1 -0
- restic_backups/voice_memos/prompts/summary.md +18 -0
- restic_backups/voice_memos/workflow.py +90 -0
- restic_backups-0.1.1.dist-info/METADATA +112 -0
- restic_backups-0.1.1.dist-info/RECORD +21 -0
- restic_backups-0.1.1.dist-info/WHEEL +4 -0
- restic_backups-0.1.1.dist-info/entry_points.txt +3 -0
|
@@ -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()
|
restic_backups/config.py
ADDED
|
@@ -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
|
restic_backups/errors.py
ADDED
|
@@ -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."""
|