lts-backup-cli 2.0.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,419 @@
1
+ """Interactive `discover` flow. Prompts go through an Asker so answers can be preset or scripted."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping, Sequence
6
+ from pathlib import Path
7
+ from typing import Any, Callable, Protocol
8
+
9
+ from . import apache, sshconfig
10
+ from . import config as c
11
+ from . import schedule as sch
12
+ from . import validation as v
13
+ from .services import DiscoverRequest, DiscoverService, Echo
14
+
15
+ Validator = Callable[[str], Any]
16
+
17
+ NONE = "(none - use ssh defaults)"
18
+ ALL = "(all)" # checkbox preset: every choice offered
19
+ OTHER = "(other path...)"
20
+
21
+
22
+ class WizardError(RuntimeError):
23
+ pass
24
+
25
+
26
+ class Asker(Protocol):
27
+ def text(
28
+ self, key: str, message: str, default: str = "", validate: Validator | None = None
29
+ ) -> str: ...
30
+ def password(self, key: str, message: str) -> str: ...
31
+ def confirm(self, key: str, message: str, default: bool = True) -> bool: ...
32
+ def select(
33
+ self, key: str, message: str, choices: Sequence[str], default: str | None = None
34
+ ) -> str: ...
35
+ def checkbox(
36
+ self, key: str, message: str, choices: Mapping[str, str], defaults: Sequence[str] = ()
37
+ ) -> list[str]: ...
38
+
39
+
40
+ def _checked(validate: Validator | None, value: str) -> str:
41
+ return str(validate(value)) if validate else value
42
+
43
+
44
+ class ScriptedAsker:
45
+ """Answers from a dict; falls back to defaults. Used by --non-interactive and tests."""
46
+
47
+ def __init__(self, answers: Mapping[str, Any]) -> None:
48
+ self.answers = dict(answers)
49
+
50
+ def _get(self, key: str, default: Any) -> Any:
51
+ if key in self.answers:
52
+ return self.answers[key]
53
+ wildcard = "*." + key.split(".", 1)[1] if "." in key else None
54
+ if wildcard in self.answers: # "*.every_hours" answers it for every repo
55
+ return self.answers[wildcard]
56
+ if default is None or default == "":
57
+ raise WizardError(f"missing value for '{key}' (required in non-interactive mode)")
58
+ return default
59
+
60
+ def text(
61
+ self, key: str, message: str, default: str = "", validate: Validator | None = None
62
+ ) -> str:
63
+ return _checked(validate, str(self._get(key, default)))
64
+
65
+ def password(self, key: str, message: str) -> str:
66
+ return str(self._get(key, None))
67
+
68
+ def confirm(self, key: str, message: str, default: bool = True) -> bool:
69
+ return bool(self._get(key, default))
70
+
71
+ def select(
72
+ self, key: str, message: str, choices: Sequence[str], default: str | None = None
73
+ ) -> str:
74
+ return str(self._get(key, default))
75
+
76
+ def checkbox(
77
+ self, key: str, message: str, choices: Mapping[str, str], defaults: Sequence[str] = ()
78
+ ) -> list[str]:
79
+ raw = self.answers.get(key, defaults)
80
+ picked = list(choices) if raw == ALL else list(raw)
81
+ unknown = [x for x in picked if x not in choices]
82
+ if unknown:
83
+ raise WizardError(
84
+ f"{key}: unknown choice(s) {', '.join(unknown)} (have: {', '.join(choices)})"
85
+ )
86
+ return picked
87
+
88
+
89
+ class QuestionaryAsker:
90
+ """Interactive prompts; any preset answer (from CLI options) skips its prompt."""
91
+
92
+ def __init__(self, presets: Mapping[str, Any] | None = None) -> None:
93
+ self.presets = ScriptedAsker(presets or {})
94
+
95
+ def _has(self, key: str) -> bool:
96
+ wildcard = "*." + key.split(".", 1)[1] if "." in key else None
97
+ return key in self.presets.answers or wildcard in self.presets.answers
98
+
99
+ @staticmethod
100
+ def _ask(question: Any) -> Any:
101
+ ans = question.ask()
102
+ if ans is None: # Ctrl-C / Ctrl-D
103
+ raise KeyboardInterrupt
104
+ return ans
105
+
106
+ def text(
107
+ self, key: str, message: str, default: str = "", validate: Validator | None = None
108
+ ) -> str:
109
+ if self._has(key):
110
+ return self.presets.text(key, message, default, validate)
111
+ import questionary
112
+
113
+ def qv(s: str) -> Any:
114
+ if not validate:
115
+ return True
116
+ try:
117
+ validate(s)
118
+ return True
119
+ except v.ValidationError as e:
120
+ return str(e)
121
+
122
+ return _checked(
123
+ validate, self._ask(questionary.text(message, default=default, validate=qv))
124
+ )
125
+
126
+ def password(self, key: str, message: str) -> str:
127
+ if self._has(key):
128
+ return self.presets.password(key, message)
129
+ import questionary
130
+
131
+ while True:
132
+ first = self._ask(questionary.password(message))
133
+ if not first:
134
+ print(" password must not be empty")
135
+ continue
136
+ if self._ask(questionary.password("Repeat password:")) == first:
137
+ return str(first)
138
+ print(" passwords do not match, try again")
139
+
140
+ def confirm(self, key: str, message: str, default: bool = True) -> bool:
141
+ if self._has(key):
142
+ return self.presets.confirm(key, message, default)
143
+ import questionary
144
+
145
+ return bool(self._ask(questionary.confirm(message, default=default)))
146
+
147
+ def select(
148
+ self, key: str, message: str, choices: Sequence[str], default: str | None = None
149
+ ) -> str:
150
+ if self._has(key):
151
+ return self.presets.select(key, message, choices, default)
152
+ import questionary
153
+
154
+ return str(self._ask(questionary.select(message, choices=list(choices), default=default)))
155
+
156
+ def checkbox(
157
+ self, key: str, message: str, choices: Mapping[str, str], defaults: Sequence[str] = ()
158
+ ) -> list[str]:
159
+ if self._has(key):
160
+ return self.presets.checkbox(key, message, choices, defaults)
161
+ import questionary
162
+
163
+ qchoices = [
164
+ questionary.Choice(title, value=val, checked=val in defaults)
165
+ for val, title in choices.items()
166
+ ]
167
+ return list(self._ask(questionary.checkbox(message, choices=qchoices)))
168
+
169
+
170
+ def _int_ge0(s: str) -> int:
171
+ try:
172
+ n = int(s)
173
+ except ValueError:
174
+ raise v.ValidationError(f"not a number: {s!r}") from None
175
+ v.keep(n)
176
+ return n
177
+
178
+
179
+ def _identity(ask: Asker, key: str, default: str | None) -> str | None:
180
+ keys = sorted(
181
+ str(p) for p in (Path.home() / ".ssh").glob("id_*") if p.suffix != ".pub" and p.is_file()
182
+ )
183
+ choice = ask.select(
184
+ key,
185
+ "SSH private key (admin box -> server):",
186
+ [*keys, NONE, OTHER],
187
+ default=default if default in keys else (keys[0] if keys else NONE),
188
+ )
189
+ if choice == OTHER:
190
+ choice = ask.text(f"{key}_path", "Path to private key:")
191
+ return None if choice == NONE else v.abs_path(choice, must_exist=True, kind="file")
192
+
193
+
194
+ def _connection(
195
+ ask: Asker, host: str, label: str, old: c.ServerConfig | None, ssh_text: str
196
+ ) -> c.ServerConfig:
197
+ storage_old = old.storage if old else None
198
+ alias = host in sshconfig.defined_hosts(ssh_text) or (old is not None and old.use_ssh_config)
199
+ use_alias = host == "localhost" or (
200
+ alias
201
+ and ask.confirm("use_ssh_config", f"Use your ~/.ssh/config entry '{host}' as is?", True)
202
+ )
203
+ user, port, identity = None, 22, None
204
+ if not use_alias:
205
+ user = ask.text(
206
+ "ssh_user", "SSH user on the server:", (old and old.ssh_user) or "", v.unix_user
207
+ )
208
+ port = int(ask.text("ssh_port", "SSH port:", str(old.ssh_port if old else 22), v.port))
209
+ identity = _identity(ask, "identity", old.ssh_identity if old else None)
210
+ st_identity = ask.text(
211
+ "storage_identity",
212
+ "Private key ON THE SERVER for the storage host (empty = server's default keys):",
213
+ (storage_old and storage_old.identity) or "-",
214
+ ).strip()
215
+ storage = c.StorageConfig(
216
+ host=ask.text("storage_host", "Storage (SFTP) host, as seen from the server:",
217
+ storage_old.host if storage_old else "", v.hostname),
218
+ user=ask.text("storage_user", "Storage SSH user:",
219
+ storage_old.user if storage_old else "", v.unix_user),
220
+ port=int(ask.text("storage_port", "Storage SSH port:",
221
+ str(storage_old.port if storage_old else 22), v.port)),
222
+ base_path=ask.text("storage_path", "Base path on the storage host:",
223
+ storage_old.base_path if storage_old else "/srv/restic", v.remote_path),
224
+ identity=None if st_identity in ("", "-") else v.remote_abs_path(st_identity),
225
+ ) # fmt: skip
226
+ return c.ServerConfig(
227
+ label=label,
228
+ ssh_host=host,
229
+ ssh_user=user,
230
+ use_ssh_config=use_alias and host != "localhost",
231
+ ssh_port=port,
232
+ ssh_identity=identity,
233
+ secrets_dir=old.secrets_dir if old else "/", # set from the probed home below
234
+ storage=storage,
235
+ db_login=old.db_login if old else False,
236
+ repos=dict(old.repos) if old else {},
237
+ )
238
+
239
+
240
+ def _db_login(
241
+ ask: Asker, svc: DiscoverService, runner: Any, server: c.ServerConfig, echo: Echo
242
+ ) -> list[str] | None:
243
+ if not ask.confirm(
244
+ "db_login",
245
+ f"No working MariaDB login on {server.label}. Enter the admin (sa) login now?",
246
+ True,
247
+ ):
248
+ return None
249
+ for _ in range(3):
250
+ user = ask.text("db_user", "MariaDB admin user:", "root", v.db_user)
251
+ if svc.set_db_login(runner, server, user, ask.password("db_password", "MariaDB password:")):
252
+ echo(f"MariaDB login verified and saved to {server.label}:{server.mycnf_path} (0600)")
253
+ return svc.databases(runner, server)[0]
254
+ echo(" MariaDB login failed, try again")
255
+ echo(f"warning: no working MariaDB login; database backups skipped for {server.label}")
256
+ return None
257
+
258
+
259
+ def _folder_name(path: str) -> str:
260
+ return apache.repo_name_for(path.strip("/").replace("/", "-") or "root")
261
+
262
+
263
+ def _tune(ask: Asker, repo: c.RepoConfig) -> None:
264
+ n, s, k = repo.name, repo.schedule, repo.keep
265
+ s.every_hours = int(
266
+ ask.text(
267
+ f"{n}.every_hours", f"[{n}] backup every N hours:", str(s.every_hours), v.every_hours
268
+ )
269
+ )
270
+ s.weekly_day = ask.text(f"{n}.weekly_day", f"[{n}] weekly run day:", s.weekly_day, v.weekday)
271
+ s.weekly_time = ask.text(
272
+ f"{n}.weekly_time", f"[{n}] weekly run time (HH:MM):", s.weekly_time, v.hhmm
273
+ )
274
+ for attr in ("hourly", "daily", "weekly", "monthly"):
275
+ cur = getattr(k, attr)
276
+ val = ask.text(
277
+ f"{n}.keep_{attr}",
278
+ f"[{n}] keep {attr} snapshots:",
279
+ "" if cur is None else str(cur),
280
+ _int_ge0,
281
+ )
282
+ setattr(k, attr, int(val))
283
+
284
+
285
+ def run(
286
+ ask: Asker, svc: DiscoverService, cfg: c.Config, host: str, label: str, echo: Echo
287
+ ) -> DiscoverRequest:
288
+ host = v.hostname(host)
289
+ known = next((s.label for s in cfg.servers.values() if s.ssh_host == host), None)
290
+ label = label or ask.text(
291
+ "server", "Server label:", known or v.label(host.split(".")[0]), v.label
292
+ )
293
+ old = cfg.servers.get(label)
294
+ echo(f"{'editing' if old else 'new'} server {label} ({host})")
295
+ server = _connection(ask, host, label, old, sshconfig.read(svc.ssh_config_path))
296
+
297
+ runner = svc.connect(server)
298
+ found = svc.probe(runner, server)
299
+ if not old:
300
+ server.secrets_dir = f"{found.home}/.config/ltsbu"
301
+ databases = (
302
+ found.databases
303
+ if found.databases is not None
304
+ else _db_login(ask, svc, runner, server, echo)
305
+ )
306
+ for name in found.skipped_databases:
307
+ echo(f"warning: database {name!r} has characters ltsbu does not handle; skipped")
308
+
309
+ existing = {r.name: r for r in server.repos.values()}
310
+ wanted: dict[str, c.RepoConfig] = {}
311
+ pw_dir = c.config_dir() / "servers" / label
312
+
313
+ def new_repo(name: str, kind: str, **kw: Any) -> c.RepoConfig:
314
+ return existing.get(name) or c.RepoConfig(
315
+ name=name, kind=kind, password_file=str(pw_dir / f"{name}.pass"), **kw
316
+ )
317
+
318
+ # --- vhosts ---
319
+ vhosts = {apache.repo_name_for(h.server_name): h for h in found.vhosts}
320
+ choices = {n: f"{h.server_name} ({h.document_root})" for n, h in vhosts.items()}
321
+ choices |= {
322
+ n: f"{r.vhost} ({', '.join(r.paths)}) [configured]"
323
+ for n, r in existing.items()
324
+ if r.kind == "vhost" and n not in choices
325
+ }
326
+ defaults = [n for n in choices if n in existing] if old else list(choices)
327
+ for n in ask.checkbox(
328
+ "vhosts", "Apache/httpd vhosts to back up (one repo each):", choices, defaults
329
+ ):
330
+ h = vhosts.get(n)
331
+ wanted[n] = new_repo(
332
+ n, "vhost", vhost=h.server_name if h else None, paths=[h.document_root] if h else []
333
+ )
334
+ if h: # docroot may have moved since last discover
335
+ wanted[n].paths = [h.document_root]
336
+
337
+ # --- databases (one repo per server) ---
338
+ if databases is not None:
339
+ current = existing["db"].databases if "db" in existing else []
340
+ db_choices = {d: d for d in sorted({*databases, *current})}
341
+ picked = ask.checkbox(
342
+ "databases",
343
+ "MariaDB databases to back up (repo 'db'):",
344
+ db_choices,
345
+ current if old else list(db_choices),
346
+ )
347
+ if picked:
348
+ wanted["db"] = new_repo("db", "db")
349
+ wanted["db"].databases = sorted(picked)
350
+ elif "db" in existing:
351
+ echo("warning: cannot list databases; keeping repo 'db' unchanged")
352
+ wanted["db"] = existing["db"]
353
+
354
+ # --- extra folders (one repo each) ---
355
+ folder_paths = [p for r in existing.values() if r.kind == "folder" for p in r.paths]
356
+ raw = ask.text(
357
+ "folders",
358
+ "Extra folders on the server (space separated, empty for none):",
359
+ " ".join(folder_paths) or "-",
360
+ )
361
+ for path in (p for p in raw.split() if p != "-"):
362
+ path = v.remote_abs_path(path)
363
+ n = _folder_name(path)
364
+ wanted[n] = new_repo(n, "folder", paths=[path])
365
+
366
+ # --- schedule & retention ---
367
+ taken = {
368
+ (r.schedule.weekly_day, r.schedule.weekly_time)
369
+ for s in cfg.servers.values()
370
+ if s.storage.host == server.storage.host and s.label != label
371
+ for r in s.repos.values()
372
+ } | {
373
+ (r.schedule.weekly_day, r.schedule.weekly_time)
374
+ for r in wanted.values()
375
+ if r.name in existing
376
+ }
377
+ for repo in sorted(wanted.values(), key=lambda r: r.name):
378
+ if repo.name not in existing:
379
+ repo.schedule = sch.default_schedule(label, repo.name, taken)
380
+ taken.add((repo.schedule.weekly_day, repo.schedule.weekly_time))
381
+ if wanted and ask.confirm(
382
+ "customize",
383
+ "Customise schedule/retention per repo? (default: every 2h + weekly, rotated)",
384
+ False,
385
+ ):
386
+ for repo in sorted(wanted.values(), key=lambda r: r.name):
387
+ _tune(ask, repo)
388
+
389
+ # --- removals need an explicit yes; restic data is never deleted ---
390
+ removed = sorted(set(existing) - set(wanted))
391
+ if removed and not ask.confirm(
392
+ "confirm_remove", f"Stop backing up {', '.join(removed)}? (restic data is kept)", False
393
+ ):
394
+ wanted |= {n: existing[n] for n in removed}
395
+ removed = []
396
+ server.repos = dict(sorted(wanted.items()))
397
+
398
+ echo(
399
+ f"\nserver {label}: {len(server.repos)} repo(s) -> {server.storage.host}:{server.storage.base_path}/{label}/"
400
+ )
401
+ for r in server.repos.values():
402
+ what = ", ".join(r.databases or r.paths)
403
+ echo(
404
+ f" {r.name:<24} {r.kind:<6} {what} [every {r.schedule.every_hours}h, weekly {r.schedule.weekly_day} {r.schedule.weekly_time}]"
405
+ )
406
+ for n in removed:
407
+ echo(f" {n:<24} REMOVE (timers off, restic data kept)")
408
+ actions = ask.checkbox(
409
+ "actions",
410
+ "Actions:",
411
+ {
412
+ "restic_init": "Initialise missing restic repositories",
413
+ "timers": "Install/refresh systemd timers",
414
+ },
415
+ defaults=["restic_init", "timers"],
416
+ )
417
+ if not ask.confirm("apply", "Apply?", True):
418
+ raise WizardError("aborted")
419
+ return DiscoverRequest(server, removed, "restic_init" in actions, "timers" in actions)