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/cli.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# PYTHON_ARGCOMPLETE_OK
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import argcomplete
|
|
9
|
+
from chaos_utils.logging import setup_logger
|
|
10
|
+
from pydantic import ValidationError
|
|
11
|
+
|
|
12
|
+
from .config import load_config
|
|
13
|
+
from .runner import WorkflowError, run_profile, run_unlock
|
|
14
|
+
|
|
15
|
+
logger = setup_logger(__name__)
|
|
16
|
+
|
|
17
|
+
DEFAULT_CONFIG = Path("/etc/restic-profile/restic-profile.toml")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _load_config_or_exit(config_path: Path):
|
|
21
|
+
"""Return the parsed config or exit with a logged error."""
|
|
22
|
+
try:
|
|
23
|
+
return load_config(config_path)
|
|
24
|
+
except FileNotFoundError as exc:
|
|
25
|
+
logger.error("%s", exc)
|
|
26
|
+
sys.exit(1)
|
|
27
|
+
except (ValidationError, ValueError) as exc:
|
|
28
|
+
logger.error("%s", exc)
|
|
29
|
+
sys.exit(1)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _resolve_profile_or_exit(args: argparse.Namespace):
|
|
33
|
+
"""Return the named profile from *args* or exit with a logged error."""
|
|
34
|
+
cfg = _load_config_or_exit(args.config)
|
|
35
|
+
|
|
36
|
+
if not args.profile_name:
|
|
37
|
+
raise ValueError("profile_name is required")
|
|
38
|
+
|
|
39
|
+
if args.profile_name not in cfg.profiles:
|
|
40
|
+
logger.error(
|
|
41
|
+
"Profile %r not found in config %s", args.profile_name, args.config
|
|
42
|
+
)
|
|
43
|
+
sys.exit(1)
|
|
44
|
+
|
|
45
|
+
return cfg.profiles[args.profile_name]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _profile_type(profile) -> str:
|
|
49
|
+
"""Return a user-facing type label for *profile*."""
|
|
50
|
+
sub_types = []
|
|
51
|
+
if profile.backup:
|
|
52
|
+
sub_types.append("backup")
|
|
53
|
+
if profile.retention:
|
|
54
|
+
sub_types.append("retention")
|
|
55
|
+
return "+".join(sub_types)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _cmd_run(
|
|
59
|
+
args: argparse.Namespace,
|
|
60
|
+
*,
|
|
61
|
+
is_dry_run: bool = False,
|
|
62
|
+
force_notify: bool = False,
|
|
63
|
+
) -> None:
|
|
64
|
+
"""Run the configured workflow for a named profile."""
|
|
65
|
+
profile = _resolve_profile_or_exit(args)
|
|
66
|
+
logger.info("Running configured workflow for profile: %s", args.profile_name)
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
run_profile(profile, dry_run=is_dry_run, force_notify=force_notify)
|
|
70
|
+
except (ValueError, WorkflowError) as exc:
|
|
71
|
+
logger.error("%s", exc)
|
|
72
|
+
sys.exit(1)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _cmd_unlock(args: argparse.Namespace, *, is_dry_run: bool = False) -> None:
|
|
76
|
+
"""Run restic unlock on the named profile's repository."""
|
|
77
|
+
profile = _resolve_profile_or_exit(args)
|
|
78
|
+
logger.info("Unlocking repository for profile: %s", args.profile_name)
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
run_unlock(profile, dry_run=is_dry_run)
|
|
82
|
+
except (ValueError, WorkflowError) as exc:
|
|
83
|
+
logger.error("%s", exc)
|
|
84
|
+
sys.exit(1)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _cmd_check(args: argparse.Namespace) -> None:
|
|
88
|
+
"""Validate the TOML config file and print any errors."""
|
|
89
|
+
_load_config_or_exit(args.config)
|
|
90
|
+
|
|
91
|
+
print("Config is valid.")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _cmd_list(args: argparse.Namespace) -> None:
|
|
95
|
+
"""List all profiles with key settings."""
|
|
96
|
+
cfg = _load_config_or_exit(args.config)
|
|
97
|
+
|
|
98
|
+
if not cfg.profiles:
|
|
99
|
+
print("No profiles configured.")
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
for name, profile in cfg.profiles.items():
|
|
103
|
+
repo_url = (
|
|
104
|
+
profile.resolved_repository.repository
|
|
105
|
+
if profile.resolved_repository
|
|
106
|
+
else "N/A"
|
|
107
|
+
)
|
|
108
|
+
schedule = profile.on_calendar or "manual"
|
|
109
|
+
print(
|
|
110
|
+
f"{name} type={_profile_type(profile)} "
|
|
111
|
+
f"schedule={schedule} repository={repo_url}"
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
116
|
+
parser = argparse.ArgumentParser(
|
|
117
|
+
prog="restic-profile",
|
|
118
|
+
description="Run configured restic profile workflows.",
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
parser.add_argument(
|
|
122
|
+
"profile_name",
|
|
123
|
+
nargs="?",
|
|
124
|
+
help="Profile name from config to execute",
|
|
125
|
+
)
|
|
126
|
+
action_group = parser.add_mutually_exclusive_group()
|
|
127
|
+
action_group.add_argument(
|
|
128
|
+
"-l",
|
|
129
|
+
"--list",
|
|
130
|
+
action="store_true",
|
|
131
|
+
default=False,
|
|
132
|
+
help="List all configured profiles",
|
|
133
|
+
)
|
|
134
|
+
action_group.add_argument(
|
|
135
|
+
"-C",
|
|
136
|
+
"--check",
|
|
137
|
+
action="store_true",
|
|
138
|
+
default=False,
|
|
139
|
+
help="Validate the TOML config file and exit",
|
|
140
|
+
)
|
|
141
|
+
action_group.add_argument(
|
|
142
|
+
"-U",
|
|
143
|
+
"--unlock",
|
|
144
|
+
action="store_true",
|
|
145
|
+
default=False,
|
|
146
|
+
help="Remove stale restic locks for the named profile",
|
|
147
|
+
)
|
|
148
|
+
parser.add_argument(
|
|
149
|
+
"-c",
|
|
150
|
+
"--config",
|
|
151
|
+
type=Path,
|
|
152
|
+
default=DEFAULT_CONFIG,
|
|
153
|
+
metavar="PATH",
|
|
154
|
+
help="Path to TOML config file (default: %(default)s)",
|
|
155
|
+
)
|
|
156
|
+
parser.add_argument(
|
|
157
|
+
"-n",
|
|
158
|
+
"--dry-run",
|
|
159
|
+
nargs="?",
|
|
160
|
+
const="all",
|
|
161
|
+
default=None,
|
|
162
|
+
choices=["all", "notify"],
|
|
163
|
+
help="Dry-run mode: 'all' logs actions without executing (no-op), "
|
|
164
|
+
"'notify' dry-runs everything but sends the success notification for "
|
|
165
|
+
"real. When -n is used without a value, defaults to 'all'.",
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
return parser
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def main(argv: list[str] | None = None) -> None:
|
|
172
|
+
"""CLI entry point for restic-profile."""
|
|
173
|
+
parser = _build_parser()
|
|
174
|
+
argcomplete.autocomplete(parser)
|
|
175
|
+
args = parser.parse_args(argv)
|
|
176
|
+
|
|
177
|
+
if args.profile_name and (args.list or args.check or args.unlock):
|
|
178
|
+
parser.error(
|
|
179
|
+
"profile name cannot be combined with --list, --check, or --unlock"
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
if args.list:
|
|
183
|
+
_cmd_list(args)
|
|
184
|
+
return
|
|
185
|
+
|
|
186
|
+
if args.check:
|
|
187
|
+
_cmd_check(args)
|
|
188
|
+
return
|
|
189
|
+
|
|
190
|
+
if args.unlock:
|
|
191
|
+
if not args.profile_name:
|
|
192
|
+
parser.error("--unlock requires a profile name")
|
|
193
|
+
_cmd_unlock(args, is_dry_run=args.dry_run == "all")
|
|
194
|
+
return
|
|
195
|
+
|
|
196
|
+
if not args.profile_name:
|
|
197
|
+
parser.error(
|
|
198
|
+
"provide a profile name or one of --list/--check/--unlock/--dry-run"
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
if args.dry_run == "notify":
|
|
202
|
+
_cmd_run(args, is_dry_run=True, force_notify=True)
|
|
203
|
+
else:
|
|
204
|
+
_cmd_run(args, is_dry_run=args.dry_run == "all")
|
restic_profile/config.py
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import tomllib
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Annotated, Literal
|
|
6
|
+
|
|
7
|
+
from chaos_utils.notify.dingtalk import DingTalkBot
|
|
8
|
+
from chaos_utils.notify.telegram import TelegramBot
|
|
9
|
+
from chaos_utils.notify.wechat import WechatWorkBot
|
|
10
|
+
from pydantic import (
|
|
11
|
+
BaseModel,
|
|
12
|
+
ConfigDict,
|
|
13
|
+
Field,
|
|
14
|
+
TypeAdapter,
|
|
15
|
+
field_validator,
|
|
16
|
+
model_validator,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class HooksConfig(BaseModel):
|
|
21
|
+
"""Per-profile hook commands executed around the backup lifecycle.
|
|
22
|
+
|
|
23
|
+
Each hook field is a list of shell command strings (or multi-line scripts).
|
|
24
|
+
Commands are executed via *shell* so pipelines and shell builtins work as
|
|
25
|
+
expected. If any command in a hook phase exits non-zero, that phase is
|
|
26
|
+
considered failed.
|
|
27
|
+
|
|
28
|
+
Lifecycle order::
|
|
29
|
+
|
|
30
|
+
prevalidate → (check location) → before → backup → after → success|failure
|
|
31
|
+
|
|
32
|
+
If *prevalidate* or *before* fails the backup and *after* hooks are
|
|
33
|
+
skipped; only the *failure* hooks run.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
shell: str = "/bin/sh"
|
|
37
|
+
prevalidate: list[str] = Field(default_factory=list)
|
|
38
|
+
before: list[str] = Field(default_factory=list)
|
|
39
|
+
after: list[str] = Field(default_factory=list)
|
|
40
|
+
failure: list[str] = Field(default_factory=list)
|
|
41
|
+
success: list[str] = Field(default_factory=list)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class _BaseNotifyConfig(BaseModel):
|
|
45
|
+
"""Shared base for per-platform notifier configs."""
|
|
46
|
+
|
|
47
|
+
model_config = ConfigDict(extra="forbid")
|
|
48
|
+
|
|
49
|
+
env: dict[str, str] = Field(
|
|
50
|
+
default_factory=dict,
|
|
51
|
+
description="Environment variables injected before sending (e.g. HTTP_PROXY).",
|
|
52
|
+
)
|
|
53
|
+
top_files_limit: int = Field(
|
|
54
|
+
default=3,
|
|
55
|
+
ge=0,
|
|
56
|
+
description="Max largest files and diff-changed files in success notification.",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class DingTalkNotifyConfig(_BaseNotifyConfig):
|
|
61
|
+
type: Literal["dingtalk"] = "dingtalk"
|
|
62
|
+
access_token: str = Field(min_length=1)
|
|
63
|
+
secret: str = ""
|
|
64
|
+
|
|
65
|
+
def build(self) -> DingTalkBot:
|
|
66
|
+
return DingTalkBot(access_token=self.access_token, secret=self.secret)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class TelegramNotifyConfig(_BaseNotifyConfig):
|
|
70
|
+
type: Literal["telegram"] = "telegram"
|
|
71
|
+
token: str = Field(min_length=1)
|
|
72
|
+
chat_id: int | str
|
|
73
|
+
timeout: float = Field(default=5.0, ge=0.1)
|
|
74
|
+
send_kwargs: dict[str, object] = Field(
|
|
75
|
+
default_factory=dict,
|
|
76
|
+
description="Extra keyword arguments forwarded to send_rich_message / send_message.",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def build(self) -> TelegramBot:
|
|
80
|
+
return TelegramBot(token=self.token, chat_id=self.chat_id, timeout=self.timeout)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class WechatWorkNotifyConfig(_BaseNotifyConfig):
|
|
84
|
+
type: Literal["wechat"] = "wechat"
|
|
85
|
+
key: str = Field(min_length=1)
|
|
86
|
+
|
|
87
|
+
def build(self) -> WechatWorkBot:
|
|
88
|
+
return WechatWorkBot(key=self.key)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
NotifierConfig = Annotated[
|
|
92
|
+
DingTalkNotifyConfig | TelegramNotifyConfig | WechatWorkNotifyConfig,
|
|
93
|
+
Field(discriminator="type"),
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class Repository(BaseModel):
|
|
98
|
+
"""A single restic repository definition and credentials."""
|
|
99
|
+
|
|
100
|
+
name: str = ""
|
|
101
|
+
repository: str = ""
|
|
102
|
+
password: str = ""
|
|
103
|
+
|
|
104
|
+
# REST backend auth
|
|
105
|
+
rest_username: str = ""
|
|
106
|
+
rest_password: str = ""
|
|
107
|
+
cacert: str = ""
|
|
108
|
+
|
|
109
|
+
# S3-compatible backend credentials
|
|
110
|
+
aws_default_region: str = ""
|
|
111
|
+
aws_access_key_id: str = ""
|
|
112
|
+
aws_secret_access_key: str = ""
|
|
113
|
+
|
|
114
|
+
# Google Cloud Storage credentials
|
|
115
|
+
google_project_id: str = ""
|
|
116
|
+
google_application_credentials: str = ""
|
|
117
|
+
google_access_token: str = ""
|
|
118
|
+
|
|
119
|
+
env: dict[str, str] = Field(default_factory=dict)
|
|
120
|
+
|
|
121
|
+
@field_validator("repository")
|
|
122
|
+
@classmethod
|
|
123
|
+
def validate_repository(cls, v: str) -> str:
|
|
124
|
+
if not v:
|
|
125
|
+
raise ValueError("repository cannot be empty")
|
|
126
|
+
return v
|
|
127
|
+
|
|
128
|
+
@field_validator("password")
|
|
129
|
+
@classmethod
|
|
130
|
+
def validate_password(cls, v: str) -> str:
|
|
131
|
+
if not v:
|
|
132
|
+
raise ValueError("password cannot be empty")
|
|
133
|
+
return v
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class BackupConfig(BaseModel):
|
|
137
|
+
"""Configurations specific to backing up data sources."""
|
|
138
|
+
|
|
139
|
+
sources: list[str] = Field(default_factory=list)
|
|
140
|
+
exclude_patterns: list[str] = Field(default_factory=list)
|
|
141
|
+
exclude_file: str = ""
|
|
142
|
+
one_file_system: bool = False
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class RetentionConfig(BaseModel):
|
|
146
|
+
"""Configurations specific to snapshot retention (forget & prune)."""
|
|
147
|
+
|
|
148
|
+
keep_last: int = 0
|
|
149
|
+
keep_hourly: int = 0
|
|
150
|
+
keep_daily: int = 0
|
|
151
|
+
keep_weekly: int = 0
|
|
152
|
+
keep_monthly: int = 0
|
|
153
|
+
keep_yearly: int = 0
|
|
154
|
+
prune: bool = False
|
|
155
|
+
forget_current_host: bool = True
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def has_policy(self) -> bool:
|
|
159
|
+
"""True when at least one keep_* retention setting is active (nonzero)."""
|
|
160
|
+
return any(
|
|
161
|
+
v > 0
|
|
162
|
+
for v in [
|
|
163
|
+
self.keep_last,
|
|
164
|
+
self.keep_hourly,
|
|
165
|
+
self.keep_daily,
|
|
166
|
+
self.keep_weekly,
|
|
167
|
+
self.keep_monthly,
|
|
168
|
+
self.keep_yearly,
|
|
169
|
+
]
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
@property
|
|
173
|
+
def has_action(self) -> bool:
|
|
174
|
+
"""True when the retention block has any actionable setting."""
|
|
175
|
+
return self.has_policy or self.prune
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class Profile(BaseModel):
|
|
179
|
+
"""A single restic-profile configuration entry."""
|
|
180
|
+
|
|
181
|
+
name: str = ""
|
|
182
|
+
repository_ref: str = ""
|
|
183
|
+
notify_ref: str = ""
|
|
184
|
+
|
|
185
|
+
tag: str = "" # snapshot tag; defaults to profile name
|
|
186
|
+
on_calendar: str = ""
|
|
187
|
+
randomized_delay_sec: str = ""
|
|
188
|
+
|
|
189
|
+
# Runtime
|
|
190
|
+
restic_binary: str = ""
|
|
191
|
+
no_cache: bool = False
|
|
192
|
+
retry_lock: str = ""
|
|
193
|
+
unlock: bool = False
|
|
194
|
+
|
|
195
|
+
# Runtime env & hooks
|
|
196
|
+
env: dict[str, str] = Field(default_factory=dict)
|
|
197
|
+
hooks: HooksConfig = Field(default_factory=HooksConfig)
|
|
198
|
+
|
|
199
|
+
# Sub-task blocks
|
|
200
|
+
backup: BackupConfig | None = None
|
|
201
|
+
retention: RetentionConfig | None = None
|
|
202
|
+
|
|
203
|
+
# Transient runtime resolved state
|
|
204
|
+
resolved_repository: Repository | None = None
|
|
205
|
+
resolved_notifier: NotifierConfig | None = None
|
|
206
|
+
resolved_template_dir: str = ""
|
|
207
|
+
|
|
208
|
+
@model_validator(mode="after")
|
|
209
|
+
def _validate_and_set_defaults(self) -> "Profile":
|
|
210
|
+
if not self.tag:
|
|
211
|
+
self.tag = self.name
|
|
212
|
+
if not self.backup and not self.retention:
|
|
213
|
+
raise ValueError(
|
|
214
|
+
f"Profile {self.name!r}: must configure at least one of 'backup' or 'retention'"
|
|
215
|
+
)
|
|
216
|
+
if self.backup and not self.backup.sources:
|
|
217
|
+
raise ValueError(
|
|
218
|
+
f"Profile {self.name!r}: backup section configured but 'sources' list is empty"
|
|
219
|
+
)
|
|
220
|
+
if self.retention and not self.retention.has_action:
|
|
221
|
+
raise ValueError(
|
|
222
|
+
f"Profile {self.name!r}: retention section configured but has no action "
|
|
223
|
+
"(configure keep_* and/or set prune=true)"
|
|
224
|
+
)
|
|
225
|
+
return self
|
|
226
|
+
|
|
227
|
+
@property
|
|
228
|
+
def is_backup(self) -> bool:
|
|
229
|
+
"""True when the profile has a backup block configured."""
|
|
230
|
+
return self.backup is not None
|
|
231
|
+
|
|
232
|
+
@property
|
|
233
|
+
def is_retention_only(self) -> bool:
|
|
234
|
+
"""True when the profile only has a retention block configured."""
|
|
235
|
+
return self.backup is None and self.retention is not None
|
|
236
|
+
|
|
237
|
+
@property
|
|
238
|
+
def runs_retention(self) -> bool:
|
|
239
|
+
"""True when the profile executes a retention step."""
|
|
240
|
+
return self.retention is not None
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class ResticProfileConfig(BaseModel):
|
|
244
|
+
"""Top-level restic-profile configuration."""
|
|
245
|
+
|
|
246
|
+
repositories: dict[str, Repository] = Field(default_factory=dict)
|
|
247
|
+
profiles: dict[str, Profile] = Field(default_factory=dict)
|
|
248
|
+
notify: dict[str, NotifierConfig] = Field(default_factory=dict)
|
|
249
|
+
template_dir: str = ""
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def load_config(path: Path) -> ResticProfileConfig:
|
|
253
|
+
"""Load and parse the TOML config at *path*.
|
|
254
|
+
|
|
255
|
+
Raises
|
|
256
|
+
------
|
|
257
|
+
FileNotFoundError
|
|
258
|
+
When *path* does not exist.
|
|
259
|
+
pydantic.ValidationError
|
|
260
|
+
When the TOML content fails schema validation.
|
|
261
|
+
"""
|
|
262
|
+
if not path.exists():
|
|
263
|
+
raise FileNotFoundError(f"Config file not found: {path}")
|
|
264
|
+
|
|
265
|
+
with path.open("rb") as fh:
|
|
266
|
+
data = tomllib.load(fh)
|
|
267
|
+
|
|
268
|
+
global_section: dict[str, object] = data.get("global", {})
|
|
269
|
+
global_restic_binary: str = str(global_section.get("restic_binary", ""))
|
|
270
|
+
global_no_cache: bool = bool(global_section.get("no_cache", False))
|
|
271
|
+
global_retry_lock: str = str(global_section.get("retry_lock", ""))
|
|
272
|
+
global_unlock: bool = bool(global_section.get("unlock", False))
|
|
273
|
+
global_template_dir: str = str(global_section.get("template_dir", ""))
|
|
274
|
+
|
|
275
|
+
# Parse repositories
|
|
276
|
+
repositories_data: dict[str, Repository] = {}
|
|
277
|
+
for rname, rdata in data.get("repositories", {}).items():
|
|
278
|
+
entry = dict(rdata)
|
|
279
|
+
entry.setdefault("name", rname)
|
|
280
|
+
repositories_data[rname] = Repository.model_validate(entry)
|
|
281
|
+
|
|
282
|
+
# Parse notify channels
|
|
283
|
+
notify_adapter: TypeAdapter[dict[str, NotifierConfig]] = TypeAdapter(
|
|
284
|
+
dict[str, NotifierConfig]
|
|
285
|
+
)
|
|
286
|
+
notify_data: dict[str, NotifierConfig] = {}
|
|
287
|
+
raw_notify: dict[str, object] = data.get("notify", {}) # type: ignore[assignment]
|
|
288
|
+
if raw_notify:
|
|
289
|
+
notify_data = notify_adapter.validate_python(raw_notify)
|
|
290
|
+
|
|
291
|
+
# Parse profiles
|
|
292
|
+
profiles_data: dict[str, Profile] = {}
|
|
293
|
+
for pname, pdata in data.get("profiles", {}).items():
|
|
294
|
+
entry = dict(pdata)
|
|
295
|
+
entry.setdefault("name", pname)
|
|
296
|
+
if "restic_binary" not in entry:
|
|
297
|
+
entry["restic_binary"] = global_restic_binary
|
|
298
|
+
if "no_cache" not in entry:
|
|
299
|
+
entry["no_cache"] = global_no_cache
|
|
300
|
+
if not entry.get("retry_lock", ""):
|
|
301
|
+
entry["retry_lock"] = global_retry_lock
|
|
302
|
+
if "unlock" not in entry:
|
|
303
|
+
entry["unlock"] = global_unlock
|
|
304
|
+
|
|
305
|
+
profile = Profile.model_validate(entry)
|
|
306
|
+
|
|
307
|
+
# Resolve repository reference
|
|
308
|
+
if not profile.repository_ref:
|
|
309
|
+
raise ValueError(f"Profile {profile.name!r}: repository_ref is required")
|
|
310
|
+
if profile.repository_ref not in repositories_data:
|
|
311
|
+
raise ValueError(
|
|
312
|
+
f"Profile {profile.name!r}: referenced repository {profile.repository_ref!r} not found"
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
profile.resolved_repository = repositories_data[profile.repository_ref]
|
|
316
|
+
|
|
317
|
+
# Resolve notify reference
|
|
318
|
+
if profile.notify_ref:
|
|
319
|
+
if profile.notify_ref not in notify_data:
|
|
320
|
+
raise ValueError(
|
|
321
|
+
f"Profile {profile.name!r}: referenced notifier {profile.notify_ref!r} not found"
|
|
322
|
+
)
|
|
323
|
+
profile.resolved_notifier = notify_data[profile.notify_ref]
|
|
324
|
+
|
|
325
|
+
profile.resolved_template_dir = global_template_dir
|
|
326
|
+
|
|
327
|
+
profiles_data[pname] = profile
|
|
328
|
+
|
|
329
|
+
return ResticProfileConfig(
|
|
330
|
+
repositories=repositories_data,
|
|
331
|
+
profiles=profiles_data,
|
|
332
|
+
notify=notify_data,
|
|
333
|
+
template_dir=global_template_dir,
|
|
334
|
+
)
|