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.
- lts_backup_cli/__init__.py +3 -0
- lts_backup_cli/__main__.py +3 -0
- lts_backup_cli/apache.py +45 -0
- lts_backup_cli/cli.py +559 -0
- lts_backup_cli/config.py +308 -0
- lts_backup_cli/doctor.py +278 -0
- lts_backup_cli/mariadb.py +157 -0
- lts_backup_cli/restic.py +122 -0
- lts_backup_cli/runner.py +172 -0
- lts_backup_cli/schedule.py +111 -0
- lts_backup_cli/services.py +604 -0
- lts_backup_cli/sshconfig.py +142 -0
- lts_backup_cli/validation.py +189 -0
- lts_backup_cli/wizard.py +419 -0
- lts_backup_cli-2.0.0.dist-info/METADATA +522 -0
- lts_backup_cli-2.0.0.dist-info/RECORD +19 -0
- lts_backup_cli-2.0.0.dist-info/WHEEL +4 -0
- lts_backup_cli-2.0.0.dist-info/entry_points.txt +2 -0
- lts_backup_cli-2.0.0.dist-info/licenses/LICENSE +29 -0
lts_backup_cli/apache.py
ADDED
|
@@ -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()
|