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,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)]