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,3 @@
1
+ """lts-backup-cli: restic + MariaDB backup utility."""
2
+
3
+ __version__ = "2.0.0"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
@@ -0,0 +1,45 @@
1
+ """Apache/httpd vhost discovery. Pure: config text in, vhosts out."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+
8
+ from . import validation as v
9
+
10
+ # Ubuntu (sites-enabled), Rocky (conf.d) and Virtualmin on Rocky (vhosts appended to httpd.conf).
11
+ CONF_GLOBS = (
12
+ "/etc/apache2/sites-enabled/*",
13
+ "/etc/httpd/conf/httpd.conf",
14
+ "/etc/httpd/conf.d/*.conf",
15
+ )
16
+
17
+ _BLOCK_RE = re.compile(r"<VirtualHost\b[^>]*>(.*?)</VirtualHost>", re.IGNORECASE | re.DOTALL)
18
+ _DIRECTIVE_RE = re.compile(r"^\s*(ServerName|DocumentRoot)\s+(\S+)", re.IGNORECASE | re.MULTILINE)
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class VHost:
23
+ server_name: str
24
+ document_root: str
25
+
26
+
27
+ def parse_vhosts(text: str) -> list[VHost]:
28
+ """Named vhosts with a valid absolute DocumentRoot, deduplicated (:80/:443 pairs), sorted.
29
+ Nameless (default) vhosts are skipped: add their docroot as a folder repo instead."""
30
+ # shortcut: no Include/Macro expansion, upgrade if a server uses mod_macro
31
+ text = "\n".join(line for line in text.splitlines() if not line.lstrip().startswith("#"))
32
+ found: set[VHost] = set()
33
+ for block in _BLOCK_RE.findall(text):
34
+ values = {k.lower(): val.strip("\"'") for k, val in _DIRECTIVE_RE.findall(block)}
35
+ name, root = values.get("servername", ""), values.get("documentroot", "")
36
+ try:
37
+ found.add(VHost(v.hostname(name.split(":")[0]), v.remote_abs_path(root)))
38
+ except v.ValidationError:
39
+ continue
40
+ return sorted(found, key=lambda h: (h.server_name, h.document_root))
41
+
42
+
43
+ def repo_name_for(server_name: str) -> str:
44
+ """vhost name -> repo label (dots are not allowed in labels)."""
45
+ return re.sub(r"[^a-z0-9_-]", "-", server_name.lower())[:63]
lts_backup_cli/cli.py ADDED
@@ -0,0 +1,559 @@
1
+ """Composition root: parses options, wires adapters/services, maps errors to exit codes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ import json
7
+ import logging
8
+ import os
9
+ import sys
10
+ from dataclasses import asdict, dataclass
11
+ from pathlib import Path
12
+ from typing import Any, Callable, TypeVar
13
+
14
+ import click
15
+
16
+ from . import __version__, doctor, schedule, services, sshconfig
17
+ from . import config as c
18
+ from . import validation as v
19
+ from . import wizard as wz
20
+ from .runner import CommandError, DryRunRunner, Runner, SubprocessRunner
21
+
22
+ F = TypeVar("F", bound=Callable[..., Any])
23
+
24
+ EXIT_DOCTOR_FAILED = 3
25
+
26
+
27
+ @dataclass
28
+ class State:
29
+ config_path: Path | None = None
30
+ server: str | None = None
31
+ repo: str | None = None
32
+ dry_run: bool = False
33
+ verbose: bool = False
34
+ # Injection points for tests:
35
+ runner: Runner | None = None
36
+ asker: wz.Asker | None = None
37
+ ssh_config_path: Path | None = None
38
+ units_dir: Path | None = None
39
+
40
+ def get_runner(self) -> Runner:
41
+ logging.basicConfig(
42
+ level=logging.DEBUG if self.verbose else logging.WARNING,
43
+ format="%(message)s",
44
+ stream=sys.stderr,
45
+ )
46
+ if self.runner is not None:
47
+ self.runner.dry_run = self.dry_run
48
+ return self.runner
49
+ return DryRunRunner() if self.dry_run else SubprocessRunner()
50
+
51
+ @property
52
+ def cfg_path(self) -> Path:
53
+ return (self.config_path or c.default_config_path()).absolute()
54
+
55
+ @property
56
+ def ssh_path(self) -> Path:
57
+ return self.ssh_config_path or sshconfig.default_path()
58
+
59
+ @property
60
+ def user_units(self) -> bool:
61
+ return os.geteuid() != 0
62
+
63
+ @property
64
+ def units_path(self) -> Path:
65
+ return self.units_dir or schedule.unit_dir(root=not self.user_units)
66
+
67
+ def load(self) -> c.Config:
68
+ return c.load(self.cfg_path)
69
+
70
+ def servers(self, cfg: c.Config) -> list[c.ServerConfig]:
71
+ if self.server:
72
+ return [cfg.server(v.label(self.server))]
73
+ return sorted(cfg.servers.values(), key=lambda s: s.label)
74
+
75
+ def pick(self, cfg: c.Config) -> tuple[c.ServerConfig, c.RepoConfig]:
76
+ """--server/--repo, or the only candidate when there is exactly one."""
77
+ if not cfg.servers:
78
+ raise c.ConfigError(NO_SERVERS)
79
+ repo = v.label(self.repo) if self.repo else None
80
+ candidates = [
81
+ (s, r)
82
+ for s in self.servers(cfg)
83
+ for r in sorted(s.repos.values(), key=lambda r: r.name)
84
+ if repo is None or r.name == repo
85
+ ]
86
+ if len(candidates) == 1:
87
+ return candidates[0]
88
+ if not candidates:
89
+ if repo and self.server:
90
+ cfg.server(self.server).repo(repo) # raises with the known names
91
+ raise c.ConfigError(f"unknown repo {repo!r}" if repo else "no repos configured")
92
+ if repo:
93
+ servers = ", ".join(s.label for s, _ in candidates)
94
+ raise c.ConfigError(
95
+ f"repo {repo!r} exists on several servers ({servers}); use --server"
96
+ )
97
+ names = ", ".join(f"{s.label}/{r.name}" for s, r in candidates)
98
+ raise c.ConfigError(f"several repos configured ({names}); use --repo (and --server)")
99
+
100
+
101
+ NO_SERVERS = "no servers configured; run 'ltsbu discover <host>'"
102
+
103
+
104
+ class Group(click.Group):
105
+ """Turns domain errors into clean 'Error: ...' messages with exit code 1."""
106
+
107
+ def invoke(self, ctx: click.Context) -> Any:
108
+ try:
109
+ return super().invoke(ctx)
110
+ except (
111
+ v.ValidationError,
112
+ c.ConfigError,
113
+ services.ServiceError,
114
+ wz.WizardError,
115
+ CommandError,
116
+ ) as e:
117
+ raise click.ClickException(str(e)) from None
118
+ except KeyboardInterrupt:
119
+ raise click.Abort() from None
120
+
121
+
122
+ def common(f: F) -> F:
123
+ """--server, --repo, --dry-run, --verbose also accepted after the subcommand name."""
124
+
125
+ @click.option("--server", "-s", metavar="LABEL", help="Server label.")
126
+ @click.option("--repo", "-r", metavar="NAME", help="Repo name on the server.")
127
+ @click.option(
128
+ "--dry-run", is_flag=True, help="Print mutating commands instead of running them."
129
+ )
130
+ @click.option("--verbose", "-v", is_flag=True, help="Show executed commands.")
131
+ @functools.wraps(f)
132
+ def wrapper(
133
+ *args: Any, server: str | None, repo: str | None, dry_run: bool, verbose: bool, **kw: Any
134
+ ) -> Any:
135
+ st = click.get_current_context().ensure_object(State)
136
+ st.server = server or st.server
137
+ st.repo = repo or st.repo
138
+ st.dry_run = dry_run or st.dry_run
139
+ st.verbose = verbose or st.verbose
140
+ return f(*args, **kw)
141
+
142
+ return wrapper # type: ignore[return-value]
143
+
144
+
145
+ def _confirm(st: State, yes: bool, message: str) -> None:
146
+ if st.dry_run or yes:
147
+ return
148
+ if not sys.stdin.isatty():
149
+ raise click.ClickException(f"{message} -- refusing without --yes (no terminal)")
150
+ click.confirm(message, abort=True)
151
+
152
+
153
+ @click.group(cls=Group, context_settings={"help_option_names": ["-h", "--help"]})
154
+ @click.option(
155
+ "--config",
156
+ "config_path",
157
+ type=click.Path(dir_okay=False, path_type=Path),
158
+ help="Config file (default ~/.config/ltsbu/config.toml).",
159
+ )
160
+ @click.option("--server", "-s", metavar="LABEL", help="Server label.")
161
+ @click.option("--repo", "-r", metavar="NAME", help="Repo name on the server.")
162
+ @click.option("--dry-run", is_flag=True, help="Print mutating commands instead of running them.")
163
+ @click.option("--verbose", "-v", is_flag=True, help="Show executed commands.")
164
+ @click.version_option(__version__, prog_name="ltsbu")
165
+ @click.pass_context
166
+ def cli(
167
+ ctx: click.Context,
168
+ config_path: Path | None,
169
+ server: str | None,
170
+ repo: str | None,
171
+ dry_run: bool,
172
+ verbose: bool,
173
+ ) -> None:
174
+ """ltsbu - per-server restic backups of Apache vhosts, MariaDB databases and folders."""
175
+ st = ctx.obj if isinstance(ctx.obj, State) else State()
176
+ st.config_path = config_path or st.config_path
177
+ st.server, st.repo, st.dry_run, st.verbose = server, repo, dry_run, verbose
178
+ ctx.obj = st
179
+
180
+
181
+ # --- doctor -----------------------------------------------------------------------------
182
+
183
+
184
+ @cli.command("doctor")
185
+ @common
186
+ @click.pass_obj
187
+ def doctor_cmd(st: State) -> None:
188
+ """Check SSH, server binaries, databases, storage, repositories and timers."""
189
+ _run_doctor(st)
190
+
191
+
192
+ _ICON = {
193
+ doctor.OK: click.style("OK ", fg="green"),
194
+ doctor.WARN: click.style("WARN", fg="yellow"),
195
+ doctor.FAIL: click.style("FAIL", fg="red"),
196
+ }
197
+
198
+
199
+ def _run_doctor(st: State) -> None:
200
+ cfg = st.load() if st.cfg_path.exists() else c.Config()
201
+ ctx = doctor.Context(
202
+ st.get_runner(),
203
+ cfg,
204
+ st.cfg_path,
205
+ st.ssh_path,
206
+ st.servers(cfg),
207
+ st.units_path,
208
+ st.user_units,
209
+ )
210
+ results = doctor.run(ctx)
211
+ for r in results:
212
+ click.echo(f"[{_ICON[r.status]}] {r.name}" + (f" - {r.detail}" if r.detail else ""))
213
+ failed = sum(r.status == doctor.FAIL for r in results)
214
+ click.echo(f"\n{len(results)} checks, {failed} failed")
215
+ if failed:
216
+ raise click.exceptions.Exit(EXIT_DOCTOR_FAILED)
217
+
218
+
219
+ # --- discover ---------------------------------------------------------------------------
220
+
221
+
222
+ @cli.command("discover")
223
+ @common
224
+ @click.argument("host")
225
+ @click.option("--ssh-user", help="SSH user on the server (skipped for a ~/.ssh/config alias).")
226
+ @click.option("--ssh-port", type=int, help="SSH port [22].")
227
+ @click.option("--identity", help="Local SSH private key for the server.")
228
+ @click.option("--storage-host", help="SFTP storage host, as reached from the server.")
229
+ @click.option("--storage-user", help="SSH user on the storage host.")
230
+ @click.option("--storage-port", type=int, help="SSH port of the storage host [22].")
231
+ @click.option("--storage-path", help="Base path on the storage host [/srv/restic].")
232
+ @click.option("--storage-identity", help="Private key path ON THE SERVER for the storage host.")
233
+ @click.option("--db-user", help="MariaDB admin user (password from $LTSBU_DB_PASSWORD or prompt).")
234
+ @click.option("--no-db-login", is_flag=True, help="Do not set up a MariaDB login now.")
235
+ @click.option("--vhosts", help="'all', 'none' or comma-separated repo names.")
236
+ @click.option("--databases", help="'all', 'none' or comma-separated database names.")
237
+ @click.option("--folders", help="Extra folders on the server, space separated.")
238
+ @click.option("--every-hours", type=int, help="Backup interval in hours [2].")
239
+ @click.option("--weekly-day", help="Day of the weekly (pruning) run [staggered].")
240
+ @click.option("--weekly-time", help="Time of the weekly run, HH:MM [staggered, at night].")
241
+ @click.option("--keep-hourly", type=int)
242
+ @click.option("--keep-daily", type=int)
243
+ @click.option("--keep-weekly", type=int)
244
+ @click.option("--keep-monthly", type=int)
245
+ @click.option("--remove", is_flag=True, help="Remove repos that are no longer selected.")
246
+ @click.option("--no-restic-init", is_flag=True, help="Do not initialise missing repositories.")
247
+ @click.option("--no-timers", is_flag=True, help="Do not install systemd timers.")
248
+ @click.option(
249
+ "--non-interactive", is_flag=True, help="Never prompt; use options, env and defaults."
250
+ )
251
+ @click.pass_obj
252
+ def discover_cmd(st: State, /, host: str, non_interactive: bool, **opts: Any) -> None:
253
+ """Find HOST's vhosts and databases and create or edit its backups (wizard)."""
254
+ cfg = st.load()
255
+ presets = _discover_presets(non_interactive, opts)
256
+ asker = st.asker or (
257
+ wz.ScriptedAsker(presets) if non_interactive else wz.QuestionaryAsker(presets)
258
+ )
259
+ runner = st.get_runner()
260
+ svc = services.DiscoverService(
261
+ runner, cfg, st.cfg_path, st.ssh_path, st.units_path, st.user_units, click.echo
262
+ )
263
+ label = v.label(st.server) if st.server else ""
264
+ req = wz.run(asker, svc, cfg, host, label, click.echo)
265
+ svc.apply(req)
266
+ if not st.dry_run:
267
+ click.echo("run 'ltsbu doctor' to verify everything end to end")
268
+
269
+
270
+ def _pick_all(value: str | None) -> Any:
271
+ if value is None:
272
+ return None
273
+ if value == "all":
274
+ return wz.ALL
275
+ return [] if value == "none" else [x.strip() for x in value.split(",") if x.strip()]
276
+
277
+
278
+ def _discover_presets(non_interactive: bool, o: dict[str, Any]) -> dict[str, Any]:
279
+ p: dict[str, Any] = {}
280
+ for key in (
281
+ "ssh_user",
282
+ "storage_host",
283
+ "storage_user",
284
+ "storage_path",
285
+ "storage_identity",
286
+ "folders",
287
+ ):
288
+ if o[key]:
289
+ p[key] = o[key]
290
+ for key in ("ssh_port", "storage_port"):
291
+ if o[key] is not None:
292
+ p[key] = str(o[key])
293
+ if o["identity"]:
294
+ p["identity"] = os.path.expanduser(o["identity"])
295
+ for key in ("vhosts", "databases"):
296
+ if (picked := _pick_all(o[key])) is not None:
297
+ p[key] = picked
298
+ tuned = {
299
+ "every_hours": o["every_hours"], "weekly_day": o["weekly_day"], "weekly_time": o["weekly_time"],
300
+ **{f"keep_{k}": o[f"keep_{k}"] for k in ("hourly", "daily", "weekly", "monthly")},
301
+ } # fmt: skip
302
+ if any(x is not None for x in tuned.values()):
303
+ p["customize"] = True
304
+ p.update({f"*.{k}": str(x) for k, x in tuned.items() if x is not None})
305
+ if o["no_db_login"]:
306
+ p["db_login"] = False
307
+ elif o["db_user"]:
308
+ p["db_login"], p["db_user"] = True, o["db_user"]
309
+ if os.environ.get("LTSBU_DB_PASSWORD"):
310
+ p["db_password"] = os.environ["LTSBU_DB_PASSWORD"]
311
+ if non_interactive:
312
+ p.setdefault("identity", wz.NONE)
313
+ p.setdefault("db_login", "db_user" in p)
314
+ p.setdefault("storage_identity", "-")
315
+ p.setdefault("folders", "-")
316
+ p.setdefault("customize", False)
317
+ p["confirm_remove"] = o["remove"]
318
+ p["apply"] = True
319
+ p["actions"] = [
320
+ *([] if o["no_restic_init"] else ["restic_init"]),
321
+ *([] if o["no_timers"] else ["timers"]),
322
+ ]
323
+ elif o["remove"]:
324
+ p["confirm_remove"] = True
325
+ return p
326
+
327
+
328
+ # --- list -------------------------------------------------------------------------------
329
+
330
+
331
+ @cli.command("list")
332
+ @common
333
+ @click.pass_obj
334
+ def list_cmd(st: State) -> None:
335
+ """List configured servers and their repos."""
336
+ cfg = st.load()
337
+ if not cfg.servers:
338
+ click.echo(NO_SERVERS)
339
+ return
340
+ rows = [("SERVER", "REPO", "KIND", "WHAT", "SCHEDULE", "REPOSITORY")] + [
341
+ (
342
+ s.label,
343
+ r.name,
344
+ r.kind,
345
+ ", ".join(r.databases or r.paths),
346
+ f"every {r.schedule.every_hours}h, weekly {r.schedule.weekly_day} {r.schedule.weekly_time}",
347
+ s.repository(r),
348
+ )
349
+ for s in st.servers(cfg)
350
+ for r in sorted(s.repos.values(), key=lambda r: r.name)
351
+ ]
352
+ widths = [max(len(row[i]) for row in rows) for i in range(len(rows[0]))]
353
+ for row in rows:
354
+ click.echo(" ".join(cell.ljust(w) for cell, w in zip(row, widths)).rstrip())
355
+
356
+
357
+ # --- backup -----------------------------------------------------------------------------
358
+
359
+
360
+ @cli.command("backup")
361
+ @common
362
+ @click.option(
363
+ "--all", "all_repos", is_flag=True, help="Every repo (of --server, or of all servers)."
364
+ )
365
+ @click.option("--weekly", is_flag=True, help="Weekly run: tag 'weekly' and prune after forget.")
366
+ @click.option("--include-git", is_flag=True, help="Do not exclude .git directories this run.")
367
+ @click.pass_obj
368
+ def backup_cmd(st: State, all_repos: bool, weekly: bool, include_git: bool) -> None:
369
+ """Back up repos on their servers, then rotate them with the keep policy."""
370
+ cfg = st.load()
371
+ if all_repos:
372
+ targets = [
373
+ (s, r) for s in st.servers(cfg) for r in sorted(s.repos.values(), key=lambda r: r.name)
374
+ ]
375
+ if not targets:
376
+ raise c.ConfigError(NO_SERVERS)
377
+ else:
378
+ targets = [st.pick(cfg)]
379
+ base = st.get_runner()
380
+ failed: list[str] = []
381
+ with services.backup_lock():
382
+ for server, repo in targets:
383
+ svc = services.BackupService(services.runner_for(base, server), server, click.echo)
384
+ where = f"{server.label}/{repo.name}"
385
+ try:
386
+ failed += [f"{where}:{db}" for db in svc.run(repo, weekly, include_git, st.verbose)]
387
+ except (v.ValidationError, services.ServiceError, CommandError) as e:
388
+ if not all_repos:
389
+ raise
390
+ click.echo(f"Error: [{where}] {e}", err=True)
391
+ failed.append(where)
392
+ if failed:
393
+ raise click.ClickException(f"backup failed for: {', '.join(failed)}")
394
+
395
+
396
+ # --- snapshot ---------------------------------------------------------------------------
397
+
398
+
399
+ @cli.command("snapshot")
400
+ @common
401
+ @click.option("--json", "as_json", is_flag=True, help="JSON output.")
402
+ @click.pass_obj
403
+ def snapshot_cmd(st: State, as_json: bool) -> None:
404
+ """List a repo's snapshots."""
405
+ server, repo = st.pick(st.load())
406
+ runner = services.runner_for(st.get_runner(), server)
407
+ snaps = services.restic_for(runner, server, repo).snapshots(services.repo_tags(server, repo))
408
+ snaps.sort(key=lambda s: s.time)
409
+ if as_json:
410
+ click.echo(json.dumps([asdict(s) for s in snaps], indent=2))
411
+ return
412
+ if not snaps:
413
+ click.echo(f"no snapshots for {server.label}/{repo.name}")
414
+ return
415
+ click.echo(f"{'ID':<10}{'TIME':<21}{'DATABASE':<20}{'WEEKLY':<8}PATHS")
416
+ for s in snaps:
417
+ db = services.snapshot_db(s) or "-"
418
+ weekly = "yes" if "weekly" in s.tags else ""
419
+ click.echo(
420
+ f"{s.short_id:<10}{s.time[:19].replace('T', ' '):<21}{db:<20}{weekly:<8}{', '.join(s.paths)}"
421
+ )
422
+
423
+
424
+ # --- restore ----------------------------------------------------------------------------
425
+
426
+
427
+ @cli.command("restore")
428
+ @common
429
+ @click.argument("snapshot", default="latest")
430
+ @click.option(
431
+ "--target", help="Files repos: restore under this directory on the server ('/' = in place)."
432
+ )
433
+ @click.option("--database", help="Database repos: which database to restore.")
434
+ @click.option("--into", help="Database repos: load into this database instead [same name].")
435
+ @click.option("--yes", "-y", is_flag=True, help="Do not ask for confirmation.")
436
+ @click.pass_obj
437
+ def restore_cmd(
438
+ st: State, snapshot: str, target: str | None, database: str | None, into: str | None, yes: bool
439
+ ) -> None:
440
+ """Restore files or a database from SNAPSHOT (default: latest), on the server."""
441
+ server, repo = st.pick(st.load())
442
+ svc = services.RestoreService(services.runner_for(st.get_runner(), server), server, click.echo)
443
+ plan = svc.plan(repo, snapshot, target, database, into)
444
+ click.echo(f"snapshot {plan.snapshot.short_id} ({plan.snapshot.time[:19]})")
445
+ if plan.target == "/":
446
+ _confirm(st, yes, f"Overwrite {', '.join(plan.snapshot.paths)} IN PLACE on {server.label}?")
447
+ if plan.into:
448
+ _confirm(
449
+ st,
450
+ yes,
451
+ f"Load {plan.database} into database '{plan.into}' on {server.label} (overwrites tables)?",
452
+ )
453
+ svc.execute(repo, plan)
454
+ click.echo("restore complete")
455
+
456
+
457
+ # --- forget -----------------------------------------------------------------------------
458
+
459
+
460
+ @cli.command("forget")
461
+ @common
462
+ @click.option("--keep-hourly", type=int, help="Hourly snapshots to keep.")
463
+ @click.option("--keep-daily", type=int, help="Daily snapshots to keep.")
464
+ @click.option("--keep-weekly", type=int, help="Weekly snapshots to keep.")
465
+ @click.option("--keep-monthly", type=int, help="Monthly snapshots to keep.")
466
+ @click.option("--prune", is_flag=True, help="Also remove unreferenced data.")
467
+ @click.option("--yes", "-y", is_flag=True, help="Do not ask for confirmation.")
468
+ @click.pass_obj
469
+ def forget_cmd(
470
+ st: State,
471
+ keep_hourly: int | None,
472
+ keep_daily: int | None,
473
+ keep_weekly: int | None,
474
+ keep_monthly: int | None,
475
+ prune: bool,
476
+ yes: bool,
477
+ ) -> None:
478
+ """Remove snapshots outside the retention policy (backups already do this)."""
479
+ server, repo = st.pick(st.load())
480
+ _confirm(
481
+ st, yes, f"Forget snapshots of '{server.label}/{repo.name}' outside the retention policy?"
482
+ )
483
+ out = services.ForgetService(services.runner_for(st.get_runner(), server), server).run(
484
+ repo, keep_hourly, keep_daily, keep_weekly, keep_monthly, prune
485
+ )
486
+ if out:
487
+ click.echo(out.rstrip())
488
+
489
+
490
+ # --- credentials ----------------------------------------------------------------------
491
+
492
+
493
+ @cli.group("credentials")
494
+ def credentials() -> None:
495
+ """Move servers, their secrets and site database users between admin boxes."""
496
+
497
+
498
+ @credentials.command("export")
499
+ @common
500
+ @click.argument("file", required=False, type=click.Path(dir_okay=False, path_type=Path))
501
+ @click.option("--no-db-users", is_flag=True, help="Do not include the sites' database users.")
502
+ @click.pass_obj
503
+ def credentials_export(st: State, file: Path | None, no_db_users: bool) -> None:
504
+ """Write config, secrets and site DB users to FILE (default: stdout)."""
505
+ cfg = st.load()
506
+ labels = [s.label for s in st.servers(cfg)]
507
+ if not labels:
508
+ raise c.ConfigError(NO_SERVERS)
509
+ text, warnings = services.credentials_bundle(
510
+ st.get_runner(), cfg, labels, db_users=not no_db_users
511
+ )
512
+ for w in warnings:
513
+ click.echo(f"warning: {w}", err=True)
514
+ if file is None or str(file) == "-":
515
+ click.echo(text, nl=False)
516
+ else:
517
+ c.write_secret(file, text)
518
+ click.echo(f"exported {', '.join(labels)} to {file} (mode 0600)", err=True)
519
+ click.echo(
520
+ "warning: this bundle can decrypt and delete your backups; "
521
+ "pipe it over ssh or shred every copy",
522
+ err=True,
523
+ )
524
+
525
+
526
+ @credentials.command("import")
527
+ @common
528
+ @click.argument("file", type=click.File("r"))
529
+ @click.option(
530
+ "--force", is_flag=True, help="Replace servers that already exist (keeps their connection)."
531
+ )
532
+ @click.option("--no-ssh-config", is_flag=True, help="Do not edit ~/.ssh/config.")
533
+ @click.option("--no-db-users", is_flag=True, help="Do not create the sites' database users.")
534
+ @click.pass_obj
535
+ def credentials_import(
536
+ st: State, file: Any, force: bool, no_ssh_config: bool, no_db_users: bool
537
+ ) -> None:
538
+ """Import a bundle from 'ltsbu credentials export' (FILE or - for stdin)."""
539
+ cfg = st.load()
540
+ svc = services.CredentialsImportService(
541
+ st.get_runner(), cfg, st.cfg_path, st.ssh_path, click.echo
542
+ )
543
+ warnings = svc.apply(
544
+ file.read(), file.name, force=force, write_ssh=not no_ssh_config, db_users=not no_db_users
545
+ )
546
+ for w in warnings:
547
+ click.echo(f"warning: {w}", err=True)
548
+ if not st.dry_run:
549
+ click.echo(
550
+ "run 'ltsbu discover <host>' to refresh server-side secrets and timers, then 'ltsbu doctor'"
551
+ )
552
+
553
+
554
+ def main() -> None:
555
+ cli(prog_name="ltsbu")
556
+
557
+
558
+ if __name__ == "__main__": # pragma: no cover
559
+ main()