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.
@@ -0,0 +1,6 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ try:
4
+ __version__ = version("restic-profile-cli")
5
+ except PackageNotFoundError:
6
+ __version__ = "0.0.0"
@@ -0,0 +1,4 @@
1
+ from restic_profile.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
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")
@@ -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
+ )