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
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
"""Use-case facades. They orchestrate adapters and never spawn processes themselves.
|
|
2
|
+
|
|
3
|
+
Every service gets a Runner already scoped to the server (see runner_for): restic and
|
|
4
|
+
mariadb-dump run on the web server, which reaches the SFTP storage on its own."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import contextlib
|
|
9
|
+
import fcntl
|
|
10
|
+
import secrets
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
from collections.abc import Iterator
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import IO, Any, Callable
|
|
17
|
+
|
|
18
|
+
from . import apache, schedule, sshconfig
|
|
19
|
+
from . import config as c
|
|
20
|
+
from . import validation as v
|
|
21
|
+
from .mariadb import NATIVE_PLUGIN, DbUser, MariaDBClient, probe_binaries
|
|
22
|
+
from .restic import GIT_EXCLUDE, ResticClient, Snapshot
|
|
23
|
+
from .runner import CommandError, Runner, SshRunner, pipeline
|
|
24
|
+
|
|
25
|
+
Echo = Callable[[str], None]
|
|
26
|
+
|
|
27
|
+
# Constant scripts: user data only ever travels as positional args ("$1") or on stdin.
|
|
28
|
+
_WRITE_0600 = (
|
|
29
|
+
'umask 077 && cat > "$1.ltsbu-tmp" && chmod 600 "$1.ltsbu-tmp" && mv "$1.ltsbu-tmp" "$1"'
|
|
30
|
+
)
|
|
31
|
+
_READ_SSH_CONFIG = "cat ~/.ssh/config 2>/dev/null; true"
|
|
32
|
+
_WRITE_SSH_CONFIG = (
|
|
33
|
+
"umask 077 && mkdir -p ~/.ssh && "
|
|
34
|
+
"{ [ ! -f ~/.ssh/config ] || cp -p ~/.ssh/config ~/.ssh/config.ltsbu-$(date +%Y%m%dT%H%M%S).bak; } && "
|
|
35
|
+
"cat > ~/.ssh/config.ltsbu-tmp && mv ~/.ssh/config.ltsbu-tmp ~/.ssh/config"
|
|
36
|
+
)
|
|
37
|
+
_READ_APACHE = f"cat {' '.join(apache.CONF_GLOBS)} 2>/dev/null; true"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ServiceError(RuntimeError):
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def runner_for(base: Runner, server: c.ServerConfig) -> Runner:
|
|
45
|
+
return base if server.is_local else SshRunner(base, server.ssh_target)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def restic_for(runner: Runner, server: c.ServerConfig, repo: c.RepoConfig) -> ResticClient:
|
|
49
|
+
return ResticClient(runner, server.repository(repo), server.password_path(repo))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def mariadb_for(runner: Runner, server: c.ServerConfig) -> MariaDBClient:
|
|
53
|
+
if not server.db_login:
|
|
54
|
+
raise ServiceError(
|
|
55
|
+
f"server {server.label} has no MariaDB login; run 'ltsbu discover' to set one"
|
|
56
|
+
)
|
|
57
|
+
return MariaDBClient(runner, server.mycnf_path, probe_binaries(runner))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def repo_tags(server: c.ServerConfig, repo: c.RepoConfig) -> list[str]:
|
|
61
|
+
return ["ltsbu", f"server:{server.label}", f"repo:{repo.name}"]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _stdin(text: str) -> IO[bytes]:
|
|
65
|
+
fh = tempfile.TemporaryFile() # noqa: SIM115 - caller closes; real fd, unlinked on creation
|
|
66
|
+
fh.write(text.encode())
|
|
67
|
+
fh.seek(0)
|
|
68
|
+
return fh
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def put_secret(runner: Runner, path: str, content: str) -> None:
|
|
72
|
+
"""Write a 0600 file on the runner's host; the content goes over stdin, never argv."""
|
|
73
|
+
with _stdin(content) as fh:
|
|
74
|
+
runner.run(["sh", "-c", _WRITE_0600, "sh", path], stdin=fh)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@contextlib.contextmanager
|
|
78
|
+
def backup_lock() -> Iterator[None]:
|
|
79
|
+
"""One backup at a time from this admin box: overlapping timers queue instead of
|
|
80
|
+
loading the servers in parallel."""
|
|
81
|
+
path = c.cache_dir() / "backup.lock"
|
|
82
|
+
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
83
|
+
with open(path, "w") as fh:
|
|
84
|
+
fcntl.flock(fh, fcntl.LOCK_EX)
|
|
85
|
+
try:
|
|
86
|
+
yield
|
|
87
|
+
finally:
|
|
88
|
+
fcntl.flock(fh, fcntl.LOCK_UN)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# --- backup -----------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass
|
|
95
|
+
class BackupService:
|
|
96
|
+
runner: Runner
|
|
97
|
+
server: c.ServerConfig
|
|
98
|
+
echo: Echo = print
|
|
99
|
+
|
|
100
|
+
def run(
|
|
101
|
+
self,
|
|
102
|
+
repo: c.RepoConfig,
|
|
103
|
+
weekly: bool = False,
|
|
104
|
+
include_git: bool = False,
|
|
105
|
+
verbose: bool = False,
|
|
106
|
+
) -> list[str]:
|
|
107
|
+
"""Back up then rotate. Returns the databases that failed (empty for file repos)."""
|
|
108
|
+
restic = restic_for(self.runner, self.server, repo)
|
|
109
|
+
tags = repo_tags(self.server, repo) + (["weekly"] if weekly else [])
|
|
110
|
+
failed: list[str] = []
|
|
111
|
+
if repo.kind == "db":
|
|
112
|
+
db = mariadb_for(self.runner, self.server)
|
|
113
|
+
for name in repo.databases:
|
|
114
|
+
self.echo(f"[{self.server.label}/{repo.name}] dumping {name}")
|
|
115
|
+
argv = pipeline(
|
|
116
|
+
db.dump_argv(name),
|
|
117
|
+
restic.stdin_backup_argv(
|
|
118
|
+
f"{name}.sql", [*tags, f"db:{name}"], host=self.server.label
|
|
119
|
+
),
|
|
120
|
+
)
|
|
121
|
+
try:
|
|
122
|
+
self.runner.run(argv)
|
|
123
|
+
except CommandError as e: # one broken database must not skip the others
|
|
124
|
+
self.echo(f"[{self.server.label}/{repo.name}] {name} FAILED: {e}")
|
|
125
|
+
failed.append(name)
|
|
126
|
+
else:
|
|
127
|
+
self.echo(f"[{self.server.label}/{repo.name}] backing up {', '.join(repo.paths)}")
|
|
128
|
+
excludes = [GIT_EXCLUDE] if repo.exclude_git and not include_git else []
|
|
129
|
+
out = restic.backup(repo.paths, tags, excludes, verbose=verbose, host=self.server.label)
|
|
130
|
+
if out:
|
|
131
|
+
self.echo(out.rstrip())
|
|
132
|
+
ForgetService(self.runner, self.server).run(repo, prune=weekly)
|
|
133
|
+
return failed
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# --- restore ----------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def resolve_snapshot(restic: ResticClient, tags: list[str], snapshot: str) -> Snapshot:
|
|
140
|
+
sid = v.snapshot_id(snapshot)
|
|
141
|
+
snaps = restic.snapshots(tags=tags)
|
|
142
|
+
what = " ".join(t for t in tags if t != "ltsbu")
|
|
143
|
+
if not snaps:
|
|
144
|
+
raise ServiceError(f"no snapshots found for {what}")
|
|
145
|
+
if sid == "latest":
|
|
146
|
+
return max(snaps, key=lambda s: s.time)
|
|
147
|
+
matches = [s for s in snaps if s.id.startswith(sid)]
|
|
148
|
+
if len(matches) != 1:
|
|
149
|
+
raise ServiceError(f"snapshot {snapshot!r} matched {len(matches)} snapshots of {what}")
|
|
150
|
+
return matches[0]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def snapshot_db(snap: Snapshot) -> str | None:
|
|
154
|
+
return next((t.split(":", 1)[1] for t in snap.tags if t.startswith("db:")), None)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass
|
|
158
|
+
class RestorePlan:
|
|
159
|
+
snapshot: Snapshot
|
|
160
|
+
target: str | None = None # file repos: directory on the server
|
|
161
|
+
database: str | None = None # db repos: database the dump came from
|
|
162
|
+
into: str | None = None # db repos: database to load it into
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@dataclass
|
|
166
|
+
class RestoreService:
|
|
167
|
+
runner: Runner
|
|
168
|
+
server: c.ServerConfig
|
|
169
|
+
echo: Echo = print
|
|
170
|
+
|
|
171
|
+
def plan(
|
|
172
|
+
self,
|
|
173
|
+
repo: c.RepoConfig,
|
|
174
|
+
snapshot: str = "latest",
|
|
175
|
+
target: str | None = None,
|
|
176
|
+
database: str | None = None,
|
|
177
|
+
into: str | None = None,
|
|
178
|
+
) -> RestorePlan:
|
|
179
|
+
restic = restic_for(self.runner, self.server, repo)
|
|
180
|
+
tags = repo_tags(self.server, repo)
|
|
181
|
+
if repo.kind != "db":
|
|
182
|
+
if database or into:
|
|
183
|
+
raise ServiceError("--database/--into only apply to a database repo")
|
|
184
|
+
if not target:
|
|
185
|
+
raise ServiceError("--target is required (use --target / to restore in place)")
|
|
186
|
+
return RestorePlan(resolve_snapshot(restic, tags, snapshot), v.remote_abs_path(target))
|
|
187
|
+
if target:
|
|
188
|
+
raise ServiceError("--target does not apply to a database repo (use --into)")
|
|
189
|
+
if not database:
|
|
190
|
+
raise ServiceError(
|
|
191
|
+
f"choose --database: {', '.join(sorted(repo.databases))} (and optionally --into)"
|
|
192
|
+
)
|
|
193
|
+
db = v.db_name(database)
|
|
194
|
+
if db not in repo.databases:
|
|
195
|
+
raise ServiceError(f"database {db} is not configured in repo {repo.name}")
|
|
196
|
+
snap = resolve_snapshot(restic, [*tags, f"db:{db}"], snapshot)
|
|
197
|
+
return RestorePlan(snap, database=db, into=v.db_name(into or db))
|
|
198
|
+
|
|
199
|
+
def execute(self, repo: c.RepoConfig, plan: RestorePlan) -> None:
|
|
200
|
+
restic = restic_for(self.runner, self.server, repo)
|
|
201
|
+
snap = plan.snapshot
|
|
202
|
+
if plan.target:
|
|
203
|
+
self.echo(f"restoring {', '.join(snap.paths)} from {snap.short_id} -> {plan.target}")
|
|
204
|
+
restic.restore(snap.id, plan.target)
|
|
205
|
+
return
|
|
206
|
+
if not plan.into:
|
|
207
|
+
raise ServiceError("restore plan has neither a target nor a database")
|
|
208
|
+
db = mariadb_for(self.runner, self.server)
|
|
209
|
+
self.echo(f"loading {plan.database} from {snap.short_id} into database {plan.into}")
|
|
210
|
+
db.create_database(plan.into)
|
|
211
|
+
self.runner.run(pipeline(restic.dump_argv(snap.id, snap.paths[0]), db.load_argv(plan.into)))
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# --- forget -----------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@dataclass
|
|
218
|
+
class ForgetService:
|
|
219
|
+
runner: Runner
|
|
220
|
+
server: c.ServerConfig
|
|
221
|
+
|
|
222
|
+
def run(
|
|
223
|
+
self,
|
|
224
|
+
repo: c.RepoConfig,
|
|
225
|
+
hourly: int | None = None,
|
|
226
|
+
daily: int | None = None,
|
|
227
|
+
weekly: int | None = None,
|
|
228
|
+
monthly: int | None = None,
|
|
229
|
+
prune: bool = False,
|
|
230
|
+
) -> str:
|
|
231
|
+
given = (hourly, daily, weekly, monthly)
|
|
232
|
+
k = repo.keep
|
|
233
|
+
keeps = (
|
|
234
|
+
given if any(x is not None for x in given) else (k.hourly, k.daily, k.weekly, k.monthly)
|
|
235
|
+
)
|
|
236
|
+
for x in keeps:
|
|
237
|
+
v.keep(x)
|
|
238
|
+
if not any(keeps):
|
|
239
|
+
raise ServiceError(
|
|
240
|
+
"refusing to forget with an empty keep policy (would remove everything)"
|
|
241
|
+
)
|
|
242
|
+
h, d, w, m = keeps
|
|
243
|
+
return restic_for(self.runner, self.server, repo).forget(
|
|
244
|
+
repo_tags(self.server, repo), hourly=h, daily=d, weekly=w, monthly=m, prune=prune
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# --- discover -------------------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@dataclass
|
|
252
|
+
class Discovery:
|
|
253
|
+
home: str
|
|
254
|
+
vhosts: list[apache.VHost]
|
|
255
|
+
databases: list[str] | None # None: no working MariaDB login yet
|
|
256
|
+
skipped_databases: list[str] = field(default_factory=list) # names ltsbu cannot handle
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
@dataclass
|
|
260
|
+
class DiscoverRequest:
|
|
261
|
+
server: c.ServerConfig
|
|
262
|
+
removed: list[str] = field(default_factory=list)
|
|
263
|
+
init_repositories: bool = True
|
|
264
|
+
install_timers: bool = True
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@dataclass
|
|
268
|
+
class DiscoverService:
|
|
269
|
+
base: Runner
|
|
270
|
+
cfg: c.Config
|
|
271
|
+
config_path: Path
|
|
272
|
+
ssh_config_path: Path
|
|
273
|
+
units_dir: Path
|
|
274
|
+
user_units: bool
|
|
275
|
+
echo: Echo = print
|
|
276
|
+
python: str = sys.executable
|
|
277
|
+
|
|
278
|
+
def connect(self, server: c.ServerConfig) -> Runner:
|
|
279
|
+
"""Write the admin box -> server ssh block (if ltsbu manages it) and check the login."""
|
|
280
|
+
if not server.is_local and not server.use_ssh_config:
|
|
281
|
+
text = sshconfig.read(self.ssh_config_path)
|
|
282
|
+
host = sshconfig.SshHost(
|
|
283
|
+
server.label,
|
|
284
|
+
server.ssh_host,
|
|
285
|
+
str(server.ssh_user),
|
|
286
|
+
server.ssh_port,
|
|
287
|
+
server.ssh_identity,
|
|
288
|
+
)
|
|
289
|
+
new = sshconfig.upsert(text, host)
|
|
290
|
+
if new != text:
|
|
291
|
+
if self.base.dry_run:
|
|
292
|
+
self.echo(
|
|
293
|
+
f"[dry-run] would update {self.ssh_config_path} (Host {server.ssh_target})"
|
|
294
|
+
)
|
|
295
|
+
else:
|
|
296
|
+
sshconfig.write(self.ssh_config_path, new)
|
|
297
|
+
self.echo(f"updated {self.ssh_config_path} (Host {server.ssh_target})")
|
|
298
|
+
runner = runner_for(self.base, server)
|
|
299
|
+
res = runner.run(["true"], check=False, mutating=False, timeout=60)
|
|
300
|
+
if res.returncode != 0:
|
|
301
|
+
raise ServiceError(f"cannot log in to {server.ssh_target}: {res.stderr.strip()[-300:]}")
|
|
302
|
+
return runner
|
|
303
|
+
|
|
304
|
+
def probe(self, runner: Runner, server: c.ServerConfig) -> Discovery:
|
|
305
|
+
home = (
|
|
306
|
+
str(Path.home())
|
|
307
|
+
if server.is_local
|
|
308
|
+
else runner.run(["pwd"], mutating=False).stdout.strip() # ssh starts in $HOME
|
|
309
|
+
)
|
|
310
|
+
conf = runner.run(["sh", "-c", _READ_APACHE], mutating=False).stdout
|
|
311
|
+
dbs, skipped = self.databases(runner, server)
|
|
312
|
+
return Discovery(home, apache.parse_vhosts(conf), dbs, skipped)
|
|
313
|
+
|
|
314
|
+
def databases(
|
|
315
|
+
self, runner: Runner, server: c.ServerConfig
|
|
316
|
+
) -> tuple[list[str] | None, list[str]]:
|
|
317
|
+
if not server.db_login:
|
|
318
|
+
return None, []
|
|
319
|
+
db = mariadb_for(runner, server)
|
|
320
|
+
if not db.ping():
|
|
321
|
+
return None, []
|
|
322
|
+
ok, skipped = [], []
|
|
323
|
+
for name in db.list_databases():
|
|
324
|
+
try:
|
|
325
|
+
ok.append(v.db_name(name))
|
|
326
|
+
except v.ValidationError:
|
|
327
|
+
skipped.append(name)
|
|
328
|
+
return ok, skipped
|
|
329
|
+
|
|
330
|
+
def set_db_login(
|
|
331
|
+
self, runner: Runner, server: c.ServerConfig, user: str, password: str
|
|
332
|
+
) -> bool:
|
|
333
|
+
"""Persist the MariaDB admin login on the server (0600 my.cnf) and verify it."""
|
|
334
|
+
runner.run(["mkdir", "-p", "-m", "700", server.secrets_dir])
|
|
335
|
+
put_secret(runner, server.mycnf_path, c.mycnf_text(user, password))
|
|
336
|
+
server.db_login = True
|
|
337
|
+
server.db_login = mariadb_for(runner, server).ping()
|
|
338
|
+
return server.db_login
|
|
339
|
+
|
|
340
|
+
def apply(self, req: DiscoverRequest) -> None:
|
|
341
|
+
server = req.server
|
|
342
|
+
server.validate()
|
|
343
|
+
for repo in server.repos.values():
|
|
344
|
+
repo.validate()
|
|
345
|
+
if problems := repo.kind_problems():
|
|
346
|
+
raise ServiceError(f"repo {repo.name}: {repo.kind} repo {'; '.join(problems)}")
|
|
347
|
+
if repo.kind == "db" and not server.db_login:
|
|
348
|
+
raise ServiceError(f"repo {repo.name}: no working MariaDB login on {server.label}")
|
|
349
|
+
if self.base.dry_run:
|
|
350
|
+
self.echo(f"[dry-run] would save server {server.label} to {self.config_path}")
|
|
351
|
+
for name in sorted(server.repos):
|
|
352
|
+
self.echo(f"[dry-run] would set up {server.repository(server.repos[name])}")
|
|
353
|
+
for name in req.removed:
|
|
354
|
+
self.echo(f"[dry-run] would remove repo {name} (restic data is kept)")
|
|
355
|
+
return
|
|
356
|
+
runner = runner_for(self.base, server)
|
|
357
|
+
runner.run(["mkdir", "-p", "-m", "700", server.secrets_dir])
|
|
358
|
+
self._storage_route(runner, server)
|
|
359
|
+
for repo in sorted(server.repos.values(), key=lambda r: r.name):
|
|
360
|
+
local = Path(repo.password_file)
|
|
361
|
+
if not local.is_file():
|
|
362
|
+
c.write_secret(local, secrets.token_urlsafe(32) + "\n")
|
|
363
|
+
put_secret(runner, server.password_path(repo), local.read_text())
|
|
364
|
+
if req.init_repositories:
|
|
365
|
+
restic = restic_for(runner, server, repo)
|
|
366
|
+
if restic.is_initialized():
|
|
367
|
+
self.echo(f"repository {server.repository(repo)} already initialised")
|
|
368
|
+
else:
|
|
369
|
+
restic.init()
|
|
370
|
+
self.echo(f"initialised {server.repository(repo)}")
|
|
371
|
+
for name in req.removed:
|
|
372
|
+
server.repos.pop(name, None)
|
|
373
|
+
schedule.uninstall(self.base, server.label, name, self.units_dir, self.user_units)
|
|
374
|
+
self.echo(f"removed repo {name} and its timers (restic data is kept)")
|
|
375
|
+
self.cfg.servers[server.label] = server
|
|
376
|
+
c.save(self.cfg, self.config_path)
|
|
377
|
+
self.echo(f"saved server {server.label} to {self.config_path}")
|
|
378
|
+
if req.install_timers:
|
|
379
|
+
for repo in server.repos.values():
|
|
380
|
+
units = schedule.render_units(
|
|
381
|
+
server.label, repo.name, repo.schedule, self.python, str(self.config_path)
|
|
382
|
+
)
|
|
383
|
+
schedule.install(self.base, units, self.units_dir, self.user_units)
|
|
384
|
+
self.echo(f"timers enabled for {server.label}/{repo.name}")
|
|
385
|
+
|
|
386
|
+
def _storage_route(self, runner: Runner, server: c.ServerConfig) -> None:
|
|
387
|
+
"""Managed block in the SERVER's ~/.ssh/config: how its restic reaches the storage."""
|
|
388
|
+
st = server.storage
|
|
389
|
+
text = runner.run(["sh", "-c", _READ_SSH_CONFIG], mutating=False).stdout
|
|
390
|
+
host = sshconfig.SshHost(
|
|
391
|
+
sshconfig.storage_label(server.label), st.host, st.user, st.port, st.identity
|
|
392
|
+
)
|
|
393
|
+
new = sshconfig.upsert(text, host)
|
|
394
|
+
if new != text:
|
|
395
|
+
with _stdin(new) as fh:
|
|
396
|
+
runner.run(["sh", "-c", _WRITE_SSH_CONFIG], stdin=fh)
|
|
397
|
+
self.echo(
|
|
398
|
+
f"updated ~/.ssh/config on {server.label} (Host {sshconfig.storage_alias(server.label)})"
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
# --- credentials (move servers, their secrets and site DB users between admin boxes) ------
|
|
403
|
+
|
|
404
|
+
BUNDLE_FORMAT = "ltsbu-credentials/2"
|
|
405
|
+
_CONNECTION_FIELDS = (
|
|
406
|
+
"ssh_host", "ssh_user", "use_ssh_config", "ssh_port", "ssh_identity",
|
|
407
|
+
"secrets_dir", "storage", "db_login",
|
|
408
|
+
) # fmt: skip
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def credentials_bundle(
|
|
412
|
+
base: Runner, cfg: c.Config, labels: list[str], db_users: bool = True
|
|
413
|
+
) -> tuple[str, list[str]]:
|
|
414
|
+
"""TOML bundle with config, restic passwords, admin SSH keys and the site DB users.
|
|
415
|
+
Returns (bundle, warnings)."""
|
|
416
|
+
import tomli_w
|
|
417
|
+
|
|
418
|
+
subset = c.Config(servers={lbl: cfg.server(lbl) for lbl in labels})
|
|
419
|
+
data: dict[str, Any] = {"format": BUNDLE_FORMAT, **c.to_dict(subset)}
|
|
420
|
+
warnings: list[str] = []
|
|
421
|
+
for lbl in labels:
|
|
422
|
+
server = cfg.servers[lbl]
|
|
423
|
+
out = data["servers"][lbl]
|
|
424
|
+
if server.ssh_identity and Path(server.ssh_identity).is_file():
|
|
425
|
+
out["ssh_private_key"] = Path(server.ssh_identity).read_text()
|
|
426
|
+
for name, repo in server.repos.items():
|
|
427
|
+
pw = Path(repo.password_file)
|
|
428
|
+
if not pw.is_file():
|
|
429
|
+
raise ServiceError(f"server {lbl} repo {name}: password file missing: {pw}")
|
|
430
|
+
out["repos"][name]["restic_password"] = pw.read_text().strip()
|
|
431
|
+
if not (db_users and repo.kind == "db"):
|
|
432
|
+
continue
|
|
433
|
+
if not server.db_login:
|
|
434
|
+
raise ServiceError(
|
|
435
|
+
f"server {lbl}: no MariaDB login; cannot read database users (use --no-db-users)"
|
|
436
|
+
)
|
|
437
|
+
mdb = mariadb_for(runner_for(base, server), server)
|
|
438
|
+
per_db: dict[str, list[dict[str, Any]]] = {}
|
|
439
|
+
for db in repo.databases:
|
|
440
|
+
users = []
|
|
441
|
+
for u in mdb.db_users(db):
|
|
442
|
+
try:
|
|
443
|
+
if u.plugin != NATIVE_PLUGIN:
|
|
444
|
+
raise v.ValidationError(f"plugin {u.plugin}")
|
|
445
|
+
_validate_user(u)
|
|
446
|
+
except v.ValidationError as e:
|
|
447
|
+
warnings.append(
|
|
448
|
+
f"server {lbl} db {db}: skipping user {u.user}@{u.host} ({e})"
|
|
449
|
+
)
|
|
450
|
+
continue
|
|
451
|
+
users.append(
|
|
452
|
+
{"user": u.user, "host": u.host, "password_hash": u.password_hash,
|
|
453
|
+
"privileges": u.privileges}
|
|
454
|
+
) # fmt: skip
|
|
455
|
+
if users:
|
|
456
|
+
per_db[db] = users
|
|
457
|
+
if per_db:
|
|
458
|
+
out["repos"][name]["db_users"] = per_db
|
|
459
|
+
return tomli_w.dumps(data), warnings
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _validate_user(u: DbUser) -> None:
|
|
463
|
+
v.db_user(u.user)
|
|
464
|
+
v.db_host(u.host)
|
|
465
|
+
v.password_hash(u.password_hash)
|
|
466
|
+
v.privileges(u.privileges)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _parse_users(where: str, raw: Any) -> dict[str, list[DbUser]]:
|
|
470
|
+
if not isinstance(raw, dict):
|
|
471
|
+
raise ServiceError(f"{where}: db_users must be a table of databases")
|
|
472
|
+
out: dict[str, list[DbUser]] = {}
|
|
473
|
+
for db, items in raw.items():
|
|
474
|
+
v.db_name(db)
|
|
475
|
+
if not isinstance(items, list):
|
|
476
|
+
raise ServiceError(f"{where}: db_users.{db} must be a list")
|
|
477
|
+
users = []
|
|
478
|
+
for item in items:
|
|
479
|
+
if not isinstance(item, dict) or set(item) != {
|
|
480
|
+
"user",
|
|
481
|
+
"host",
|
|
482
|
+
"password_hash",
|
|
483
|
+
"privileges",
|
|
484
|
+
}:
|
|
485
|
+
raise ServiceError(f"{where}: malformed db_users entry")
|
|
486
|
+
u = DbUser(
|
|
487
|
+
item["user"], item["host"], NATIVE_PLUGIN, item["password_hash"], item["privileges"]
|
|
488
|
+
)
|
|
489
|
+
_validate_user(u)
|
|
490
|
+
users.append(u)
|
|
491
|
+
out[db] = users
|
|
492
|
+
return out
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
@dataclass
|
|
496
|
+
class CredentialsImportService:
|
|
497
|
+
base: Runner
|
|
498
|
+
cfg: c.Config
|
|
499
|
+
config_path: Path
|
|
500
|
+
ssh_config_path: Path
|
|
501
|
+
echo: Echo = print
|
|
502
|
+
|
|
503
|
+
def apply(
|
|
504
|
+
self,
|
|
505
|
+
text: str,
|
|
506
|
+
source: str,
|
|
507
|
+
force: bool = False,
|
|
508
|
+
write_ssh: bool = True,
|
|
509
|
+
db_users: bool = True,
|
|
510
|
+
) -> list[str]:
|
|
511
|
+
"""Merge a bundle into this admin box. Everything is validated before anything is
|
|
512
|
+
written. A server that already exists here keeps its local connection settings
|
|
513
|
+
(they are per admin box), so a DR flow can re-point a server with 'discover' first.
|
|
514
|
+
Returns warnings."""
|
|
515
|
+
data = c.parse_toml(text, source)
|
|
516
|
+
if data.pop("format", None) != BUNDLE_FORMAT:
|
|
517
|
+
raise ServiceError(
|
|
518
|
+
f"{source}: not an ltsbu credentials bundle (expected format {BUNDLE_FORMAT})"
|
|
519
|
+
)
|
|
520
|
+
keys: dict[str, str] = {}
|
|
521
|
+
passwords: dict[tuple[str, str], str] = {}
|
|
522
|
+
users: dict[tuple[str, str], dict[str, list[DbUser]]] = {}
|
|
523
|
+
for lbl, raw in data.get("servers", {}).items():
|
|
524
|
+
if "ssh_private_key" in raw:
|
|
525
|
+
keys[lbl] = raw.pop("ssh_private_key")
|
|
526
|
+
for name, r in raw.get("repos", {}).items():
|
|
527
|
+
if "restic_password" in r:
|
|
528
|
+
passwords[(lbl, name)] = r.pop("restic_password")
|
|
529
|
+
if "db_users" in r:
|
|
530
|
+
users[(lbl, name)] = _parse_users(
|
|
531
|
+
f"server {lbl} repo {name}", r.pop("db_users")
|
|
532
|
+
)
|
|
533
|
+
incoming = c.from_dict(data, source)
|
|
534
|
+
if not incoming.servers:
|
|
535
|
+
raise ServiceError(f"{source}: bundle contains no servers")
|
|
536
|
+
clash = sorted(set(incoming.servers) & set(self.cfg.servers))
|
|
537
|
+
if clash and not force:
|
|
538
|
+
raise ServiceError(
|
|
539
|
+
f"servers already configured: {', '.join(clash)} (use --force to replace)"
|
|
540
|
+
)
|
|
541
|
+
for lbl, server in incoming.servers.items():
|
|
542
|
+
v.label(lbl)
|
|
543
|
+
local = self.cfg.servers.get(lbl)
|
|
544
|
+
if local:
|
|
545
|
+
for f in _CONNECTION_FIELDS:
|
|
546
|
+
setattr(server, f, getattr(local, f))
|
|
547
|
+
keys.pop(lbl, None)
|
|
548
|
+
elif lbl in keys:
|
|
549
|
+
server.ssh_identity = str(Path.home() / ".ssh" / f"ltsbu_{lbl}")
|
|
550
|
+
for name, repo in server.repos.items():
|
|
551
|
+
if (lbl, name) in passwords: # re-home: source machine paths mean nothing here
|
|
552
|
+
repo.password_file = str(c.config_dir() / "servers" / lbl / f"{name}.pass")
|
|
553
|
+
repo.validate()
|
|
554
|
+
server.validate()
|
|
555
|
+
for (lbl, name), per_db in users.items():
|
|
556
|
+
repo = incoming.servers[lbl].repos[name]
|
|
557
|
+
if repo.kind != "db" or not set(per_db) <= set(repo.databases):
|
|
558
|
+
raise ServiceError(
|
|
559
|
+
f"server {lbl} repo {name}: db_users for databases not in the repo"
|
|
560
|
+
)
|
|
561
|
+
labels = sorted(incoming.servers)
|
|
562
|
+
replay = users if db_users else {}
|
|
563
|
+
if self.base.dry_run:
|
|
564
|
+
self.echo(f"[dry-run] would import {', '.join(labels)} into {self.config_path}")
|
|
565
|
+
for (lbl, _), per_db in sorted(replay.items()):
|
|
566
|
+
for db, us in per_db.items():
|
|
567
|
+
for u in us:
|
|
568
|
+
self.echo(
|
|
569
|
+
f"[dry-run] would create/update database user {u.user}@{u.host} on {lbl}/{db}"
|
|
570
|
+
)
|
|
571
|
+
return []
|
|
572
|
+
for (lbl, name), pw in passwords.items():
|
|
573
|
+
c.write_secret(Path(incoming.servers[lbl].repos[name].password_file), pw + "\n")
|
|
574
|
+
for lbl, key in keys.items():
|
|
575
|
+
c.write_secret(Path(str(incoming.servers[lbl].ssh_identity)), key)
|
|
576
|
+
self.cfg.servers.update(incoming.servers)
|
|
577
|
+
c.save(self.cfg, self.config_path)
|
|
578
|
+
self.echo(f"imported {', '.join(labels)} into {self.config_path}")
|
|
579
|
+
if write_ssh:
|
|
580
|
+
text_ssh = sshconfig.read(self.ssh_config_path)
|
|
581
|
+
for s in incoming.servers.values():
|
|
582
|
+
if not s.is_local and not s.use_ssh_config:
|
|
583
|
+
host = sshconfig.SshHost(
|
|
584
|
+
s.label, s.ssh_host, str(s.ssh_user), s.ssh_port, s.ssh_identity
|
|
585
|
+
)
|
|
586
|
+
text_ssh = sshconfig.upsert(text_ssh, host)
|
|
587
|
+
sshconfig.write(self.ssh_config_path, text_ssh)
|
|
588
|
+
self.echo(f"updated {self.ssh_config_path}")
|
|
589
|
+
warnings: list[str] = []
|
|
590
|
+
for (lbl, _), per_db in sorted(replay.items()):
|
|
591
|
+
server = self.cfg.servers[lbl]
|
|
592
|
+
try:
|
|
593
|
+
mdb = mariadb_for(runner_for(self.base, server), server)
|
|
594
|
+
for db, us in per_db.items():
|
|
595
|
+
mdb.apply_users(db, us)
|
|
596
|
+
for u in us:
|
|
597
|
+
self.echo(f"database user {u.user}@{u.host} ready on {lbl}/{db}")
|
|
598
|
+
except (ServiceError, CommandError) as e:
|
|
599
|
+
warnings.append(
|
|
600
|
+
f"server {lbl}: database users not created ({e}); "
|
|
601
|
+
f"run 'ltsbu discover' for {lbl}, then import again with --force"
|
|
602
|
+
)
|
|
603
|
+
prefixes = tuple(f"server {lbl}" for lbl in labels)
|
|
604
|
+
return warnings + [p for p in c.lint(self.cfg) if p.startswith(prefixes)]
|