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,308 @@
1
+ """Configuration model, persistence and lint. Secrets live in separate 0600 files, never in TOML."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import stat
7
+ import sys
8
+ from dataclasses import asdict, dataclass, field
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import tomli_w
13
+
14
+ from . import validation as v
15
+
16
+ if sys.version_info >= (3, 11):
17
+ import tomllib
18
+ else: # pragma: no cover
19
+ import tomli as tomllib
20
+
21
+
22
+ class ConfigError(RuntimeError):
23
+ pass
24
+
25
+
26
+ def config_dir() -> Path:
27
+ base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
28
+ return Path(base) / "ltsbu"
29
+
30
+
31
+ def cache_dir() -> Path:
32
+ base = os.environ.get("XDG_CACHE_HOME") or str(Path.home() / ".cache")
33
+ return Path(base) / "ltsbu"
34
+
35
+
36
+ def default_config_path() -> Path:
37
+ return config_dir() / "config.toml"
38
+
39
+
40
+ VERSION = 2
41
+
42
+
43
+ @dataclass
44
+ class KeepPolicy:
45
+ hourly: int | None = 12
46
+ daily: int | None = 7
47
+ weekly: int | None = 4
48
+ monthly: int | None = 6
49
+
50
+
51
+ @dataclass
52
+ class Schedule:
53
+ every_hours: int = 2
54
+ minute: int = 0 # offset inside the hour; staggered per repo by schedule.default_schedule
55
+ weekly_day: str = "Sun"
56
+ weekly_time: str = "03:00"
57
+
58
+
59
+ @dataclass
60
+ class RepoConfig:
61
+ name: str
62
+ kind: str # vhost | db | folder
63
+ password_file: (
64
+ str # local 0600 copy; the server holds its own copy (ServerConfig.password_path)
65
+ )
66
+ paths: list[str] = field(default_factory=list) # on the server (vhost/folder)
67
+ databases: list[str] = field(default_factory=list) # db
68
+ vhost: str | None = None
69
+ exclude_git: bool = True
70
+ schedule: Schedule = field(default_factory=Schedule)
71
+ keep: KeepPolicy = field(default_factory=KeepPolicy)
72
+
73
+ def validate(self) -> None:
74
+ v.label(self.name)
75
+ v.repo_kind(self.kind)
76
+ v.abs_path(self.password_file)
77
+ for p in self.paths:
78
+ v.remote_abs_path(p)
79
+ for db in self.databases:
80
+ v.db_name(db)
81
+ if self.vhost:
82
+ v.hostname(self.vhost)
83
+ v.every_hours(self.schedule.every_hours)
84
+ if not 0 <= self.schedule.minute <= 59:
85
+ raise v.ValidationError(f"schedule minute must be 0-59, got {self.schedule.minute}")
86
+ v.weekday(self.schedule.weekly_day)
87
+ v.hhmm(self.schedule.weekly_time)
88
+ for k in (self.keep.hourly, self.keep.daily, self.keep.weekly, self.keep.monthly):
89
+ v.keep(k)
90
+
91
+ def kind_problems(self) -> list[str]:
92
+ if self.kind == "db":
93
+ return [
94
+ *([] if self.databases else ["needs at least one database"]),
95
+ *(["takes no paths"] if self.paths else []),
96
+ ]
97
+ return [
98
+ *([] if self.paths else ["needs at least one path"]),
99
+ *(["takes no databases"] if self.databases else []),
100
+ ]
101
+
102
+
103
+ @dataclass
104
+ class StorageConfig:
105
+ """SFTP storage as reached FROM the web server (restic runs there)."""
106
+
107
+ host: str
108
+ user: str
109
+ base_path: str
110
+ port: int = 22
111
+ identity: str | None = None # private key path on the web server
112
+
113
+ def validate(self) -> None:
114
+ v.hostname(self.host)
115
+ v.unix_user(self.user)
116
+ v.port(self.port)
117
+ v.remote_path(self.base_path)
118
+ if self.identity:
119
+ v.remote_abs_path(self.identity)
120
+
121
+
122
+ @dataclass
123
+ class ServerConfig:
124
+ label: str
125
+ ssh_host: str # "localhost" runs everything on this machine without ssh
126
+ secrets_dir: str # absolute dir on the server for 0600 my.cnf and restic password files
127
+ storage: StorageConfig
128
+ ssh_user: str | None = None
129
+ use_ssh_config: bool = False # ssh_host is the user's own ~/.ssh/config alias; no managed block
130
+ ssh_port: int = 22
131
+ ssh_identity: str | None = None # local private key for admin box -> server
132
+ db_login: bool = False # my.cnf with the MariaDB admin login was written to secrets_dir
133
+ repos: dict[str, RepoConfig] = field(default_factory=dict)
134
+
135
+ @property
136
+ def is_local(self) -> bool:
137
+ return self.ssh_host == "localhost"
138
+
139
+ @property
140
+ def ssh_target(self) -> str:
141
+ from .sshconfig import host_alias
142
+
143
+ return self.ssh_host if self.use_ssh_config else host_alias(self.label)
144
+
145
+ @property
146
+ def mycnf_path(self) -> str:
147
+ return f"{self.secrets_dir}/my.cnf"
148
+
149
+ def password_path(self, repo: RepoConfig) -> str:
150
+ return f"{self.secrets_dir}/{repo.name}.pass"
151
+
152
+ def repository(self, repo: RepoConfig) -> str:
153
+ from .sshconfig import storage_alias
154
+
155
+ return f"sftp:{storage_alias(self.label)}:{self.storage.base_path}/{self.label}/{repo.name}"
156
+
157
+ def repo(self, name: str) -> RepoConfig:
158
+ try:
159
+ return self.repos[name]
160
+ except KeyError:
161
+ known = ", ".join(sorted(self.repos)) or "none"
162
+ raise ConfigError(
163
+ f"unknown repo {name!r} on server {self.label} (configured: {known})"
164
+ ) from None
165
+
166
+ def validate(self) -> None:
167
+ v.label(self.label)
168
+ v.hostname(self.ssh_host)
169
+ if self.ssh_user:
170
+ v.unix_user(self.ssh_user)
171
+ elif not self.use_ssh_config and not self.is_local:
172
+ raise v.ValidationError("ssh_user is required unless use_ssh_config is set")
173
+ v.port(self.ssh_port)
174
+ v.remote_abs_path(self.secrets_dir)
175
+ if self.ssh_identity:
176
+ v.abs_path(self.ssh_identity)
177
+ self.storage.validate()
178
+
179
+
180
+ @dataclass
181
+ class Config:
182
+ servers: dict[str, ServerConfig] = field(default_factory=dict)
183
+
184
+ def server(self, label: str) -> ServerConfig:
185
+ try:
186
+ return self.servers[label]
187
+ except KeyError:
188
+ known = ", ".join(sorted(self.servers)) or "none"
189
+ raise ConfigError(f"unknown server {label!r} (configured: {known})") from None
190
+
191
+
192
+ def _drop_none(d: Any) -> Any:
193
+ if isinstance(d, dict):
194
+ return {k: _drop_none(x) for k, x in d.items() if x is not None}
195
+ return d
196
+
197
+
198
+ def from_dict(data: dict[str, Any], source: str) -> Config:
199
+ data = dict(data)
200
+ if data.pop("version", None) != VERSION:
201
+ raise ConfigError(
202
+ f"{source}: v1 config detected (expected version = {VERSION}); "
203
+ "move it aside and re-run 'ltsbu discover <host>'"
204
+ )
205
+ try:
206
+ cfg = Config()
207
+ for label, raw in data.pop("servers", {}).items():
208
+ raw = dict(raw)
209
+ storage = StorageConfig(**raw.pop("storage", {}))
210
+ repos = {}
211
+ for name, r in raw.pop("repos", {}).items():
212
+ r = dict(r)
213
+ repos[name] = RepoConfig(
214
+ name=name,
215
+ schedule=Schedule(**r.pop("schedule", {})),
216
+ keep=KeepPolicy(**r.pop("keep", {})),
217
+ **r,
218
+ )
219
+ cfg.servers[label] = ServerConfig(label=label, storage=storage, repos=repos, **raw)
220
+ except TypeError as e:
221
+ raise ConfigError(f"{source}: unexpected or missing key: {e}") from None
222
+ if data:
223
+ raise ConfigError(f"{source}: unexpected top-level key(s): {', '.join(sorted(data))}")
224
+ return cfg
225
+
226
+
227
+ def to_dict(cfg: Config) -> dict[str, Any]:
228
+ data: dict[str, Any] = {"version": VERSION, "servers": {}}
229
+ for label, server in sorted(cfg.servers.items()):
230
+ d = asdict(server)
231
+ d.pop("label")
232
+ for r in d["repos"].values():
233
+ r.pop("name")
234
+ d["repos"] = dict(sorted(d["repos"].items()))
235
+ data["servers"][label] = d
236
+ return dict(_drop_none(data))
237
+
238
+
239
+ def parse_toml(text: str, source: str) -> dict[str, Any]:
240
+ try:
241
+ return tomllib.loads(text)
242
+ except tomllib.TOMLDecodeError as e:
243
+ raise ConfigError(f"{source}: invalid TOML: {e}") from None
244
+
245
+
246
+ def load(path: Path) -> Config:
247
+ if not path.exists():
248
+ return Config()
249
+ return from_dict(parse_toml(path.read_text(), str(path)), str(path))
250
+
251
+
252
+ def save(cfg: Config, path: Path) -> None:
253
+ tmp = path.with_suffix(".tmp")
254
+ write_secret(tmp, tomli_w.dumps(to_dict(cfg)))
255
+ os.replace(tmp, path)
256
+
257
+
258
+ def write_secret(path: Path, content: str) -> None:
259
+ """Create/overwrite a secret file with 0600 from the start (no world-readable window)."""
260
+ path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
261
+ fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
262
+ with os.fdopen(fd, "w") as fh:
263
+ fh.write(content)
264
+ os.chmod(path, 0o600)
265
+
266
+
267
+ def mycnf_text(user: str, password: str) -> str:
268
+ def esc(s: str) -> str:
269
+ return s.replace("\\", "\\\\").replace('"', '\\"')
270
+
271
+ return f'[client]\nuser="{esc(user)}"\npassword="{esc(password)}"\nhost=localhost\n'
272
+
273
+
274
+ def _perm_problem(p: Path, what: str) -> str | None:
275
+ if not p.exists():
276
+ return f"{what} missing: {p}"
277
+ if stat.S_IMODE(p.stat().st_mode) & 0o077:
278
+ return f"{what} {p} is accessible by group/others (chmod 600)"
279
+ return None
280
+
281
+
282
+ def lint(cfg: Config, check_files: bool = True) -> list[str]:
283
+ """Validate all values; optionally check local files exist and secrets are 0600.
284
+ Server-side files are checked by doctor (needs ssh)."""
285
+ problems: list[str] = []
286
+ for label, server in sorted(cfg.servers.items()):
287
+ try:
288
+ server.validate()
289
+ except v.ValidationError as e:
290
+ problems.append(f"server {label}: {e}")
291
+ continue
292
+ if check_files and server.ssh_identity and not Path(server.ssh_identity).is_file():
293
+ problems.append(f"server {label}: ssh identity missing: {server.ssh_identity}")
294
+ for name, repo in sorted(server.repos.items()):
295
+ where = f"server {label} repo {name}"
296
+ try:
297
+ repo.validate()
298
+ except v.ValidationError as e:
299
+ problems.append(f"{where}: {e}")
300
+ continue
301
+ problems += [f"{where}: {repo.kind} repo {p}" for p in repo.kind_problems()]
302
+ if repo.kind == "db" and not server.db_login:
303
+ problems.append(f"{where}: no MariaDB login on the server; re-run 'ltsbu discover'")
304
+ if check_files and (
305
+ msg := _perm_problem(Path(repo.password_file), f"{where}: password file")
306
+ ):
307
+ problems.append(msg)
308
+ return problems
@@ -0,0 +1,278 @@
1
+ """Health checks (Strategy): each check is a callable returning CheckResults.
2
+
3
+ Remote checks run through the same SshRunner as backups; servers that fail the login
4
+ check are skipped by the others (see _reachable)."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import getpass
9
+ import shutil
10
+ import socket
11
+ from collections.abc import Iterable, Iterator
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from typing import Callable
15
+
16
+ from . import config as c
17
+ from . import schedule, services, sshconfig
18
+ from .mariadb import MariaDBClient, probe_binaries
19
+ from .runner import Runner
20
+
21
+ OK, WARN, FAIL = "ok", "warn", "fail"
22
+
23
+ # Constant script; the binary names travel as positional args.
24
+ _WHICH = 'for n in "$@"; do command -v "$n" && exit 0; done; exit 1'
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class CheckResult:
29
+ name: str
30
+ status: str
31
+ detail: str = ""
32
+
33
+
34
+ @dataclass
35
+ class Context:
36
+ runner: Runner # local; remote checks wrap it per server
37
+ cfg: c.Config
38
+ config_path: Path
39
+ ssh_config_path: Path
40
+ servers: list[c.ServerConfig]
41
+ units_dir: Path
42
+ user_units: bool
43
+ timeout: float = 5.0
44
+ login: dict[str, bool] = field(default_factory=dict) # server label -> ssh login ok
45
+
46
+
47
+ Check = Callable[[Context], Iterable[CheckResult]]
48
+
49
+
50
+ def _login(ctx: Context, server: c.ServerConfig) -> tuple[bool, str]:
51
+ res = services.runner_for(ctx.runner, server).run(
52
+ ["true"], check=False, mutating=False, timeout=ctx.timeout * 6
53
+ )
54
+ ctx.login[server.label] = res.returncode == 0
55
+ return res.returncode == 0, res.stderr.strip()[-300:]
56
+
57
+
58
+ def _reachable(ctx: Context) -> Iterator[tuple[c.ServerConfig, Runner]]:
59
+ """Servers we can log in to (one ssh attempt per server per doctor run)."""
60
+ for server in ctx.servers:
61
+ if server.label not in ctx.login:
62
+ _login(ctx, server)
63
+ if ctx.login[server.label]:
64
+ yield server, services.runner_for(ctx.runner, server)
65
+
66
+
67
+ def check_local_binaries(ctx: Context) -> Iterable[CheckResult]:
68
+ for name, required in (("ssh", True), ("systemctl", False)):
69
+ found = shutil.which(name)
70
+ status = OK if found else (FAIL if required else WARN)
71
+ yield CheckResult(f"local binary {name}", status, found or "not found in PATH")
72
+
73
+
74
+ def check_config(ctx: Context) -> Iterable[CheckResult]:
75
+ if not ctx.config_path.exists():
76
+ yield CheckResult(
77
+ "config", FAIL, f"{ctx.config_path} not found; run 'ltsbu discover <host>'"
78
+ )
79
+ return
80
+ problems = c.lint(ctx.cfg)
81
+ yield CheckResult(
82
+ "config lint", FAIL if problems else OK, "; ".join(problems) or str(ctx.config_path)
83
+ )
84
+
85
+
86
+ def check_ssh_config(ctx: Context) -> Iterable[CheckResult]:
87
+ text = sshconfig.read(ctx.ssh_config_path)
88
+ problems = sshconfig.lint(text)
89
+ yield CheckResult(
90
+ "ssh config lint", FAIL if problems else OK, "; ".join(problems) or str(ctx.ssh_config_path)
91
+ )
92
+ for s in ctx.servers:
93
+ if not s.is_local and not s.use_ssh_config and not sshconfig.has_block(text, s.label):
94
+ yield CheckResult(
95
+ f"[{s.label}] ssh block", FAIL, f"no ltsbu:{s.label} block; re-run 'ltsbu discover'"
96
+ )
97
+
98
+
99
+ def check_network(ctx: Context) -> Iterable[CheckResult]:
100
+ for s in ctx.servers:
101
+ if s.is_local or s.use_ssh_config: # the user's alias may not be a resolvable name
102
+ continue
103
+ name = f"[{s.label}]"
104
+ try:
105
+ addrs = socket.getaddrinfo(s.ssh_host, s.ssh_port, type=socket.SOCK_STREAM)
106
+ except socket.gaierror as e:
107
+ yield CheckResult(f"{name} dns {s.ssh_host}", FAIL, str(e))
108
+ continue
109
+ yield CheckResult(f"{name} dns {s.ssh_host}", OK, str(addrs[0][4][0]))
110
+ try:
111
+ with socket.create_connection((s.ssh_host, s.ssh_port), timeout=ctx.timeout):
112
+ pass
113
+ yield CheckResult(f"{name} tcp {s.ssh_host}:{s.ssh_port}", OK)
114
+ except OSError as e:
115
+ yield CheckResult(f"{name} tcp {s.ssh_host}:{s.ssh_port}", FAIL, str(e))
116
+
117
+
118
+ def check_server_login(ctx: Context) -> Iterable[CheckResult]:
119
+ for s in ctx.servers:
120
+ ok, err = _login(ctx, s)
121
+ yield CheckResult(f"[{s.label}] ssh login", OK if ok else FAIL, err or s.ssh_target)
122
+
123
+
124
+ def check_server_binaries(ctx: Context) -> Iterable[CheckResult]:
125
+ for s, r in _reachable(ctx):
126
+ needs_db = any(repo.kind == "db" for repo in s.repos.values())
127
+ for names, required in (
128
+ (("restic",), True),
129
+ (("mariadb", "mysql"), needs_db),
130
+ (("mariadb-dump", "mysqldump"), needs_db),
131
+ ):
132
+ res = r.run(["sh", "-c", _WHICH, "sh", *names], check=False, mutating=False)
133
+ found = res.returncode == 0
134
+ yield CheckResult(
135
+ f"[{s.label}] remote binary {'/'.join(names)}",
136
+ OK if found else (FAIL if required else WARN),
137
+ res.stdout.strip() if found else "not found in PATH on the server",
138
+ )
139
+
140
+
141
+ def check_server_paths(ctx: Context) -> Iterable[CheckResult]:
142
+ for s, r in _reachable(ctx):
143
+ for repo in s.repos.values():
144
+ for path in repo.paths:
145
+ ok = r.run(["test", "-d", path], check=False, mutating=False).returncode == 0
146
+ yield CheckResult(
147
+ f"[{s.label}/{repo.name}] path {path}",
148
+ OK if ok else FAIL,
149
+ "" if ok else "not a directory on the server",
150
+ )
151
+
152
+
153
+ def check_server_database(ctx: Context) -> Iterable[CheckResult]:
154
+ for s, r in _reachable(ctx):
155
+ dbs = sorted(
156
+ {d for repo in s.repos.values() if repo.kind == "db" for d in repo.databases},
157
+ reverse=True,
158
+ )
159
+ if not dbs:
160
+ continue
161
+ if not s.db_login:
162
+ yield CheckResult(
163
+ f"[{s.label}] mariadb login", FAIL, "no login configured; run 'ltsbu discover'"
164
+ )
165
+ continue
166
+ mode = r.run(["stat", "-c", "%a", s.mycnf_path], check=False, mutating=False).stdout.strip()
167
+ yield CheckResult(
168
+ f"[{s.label}] my.cnf permissions",
169
+ OK if mode == "600" else FAIL,
170
+ f"{s.mycnf_path} mode {mode or 'missing'}",
171
+ )
172
+ db = MariaDBClient(r, s.mycnf_path, probe_binaries(r))
173
+ if not db.ping():
174
+ yield CheckResult(
175
+ f"[{s.label}] mariadb login",
176
+ FAIL,
177
+ "cannot connect with the saved login; re-run 'ltsbu discover'",
178
+ )
179
+ continue
180
+ yield CheckResult(f"[{s.label}] mariadb login", OK)
181
+ present = set(db.list_databases())
182
+ for name in dbs:
183
+ yield CheckResult(
184
+ f"[{s.label}] database {name}",
185
+ OK if name in present else FAIL,
186
+ "" if name in present else "database not found",
187
+ )
188
+
189
+
190
+ def check_storage(ctx: Context) -> Iterable[CheckResult]:
191
+ for s, r in _reachable(ctx):
192
+ alias = sshconfig.storage_alias(s.label)
193
+ res = r.run(
194
+ [
195
+ "ssh",
196
+ "-o",
197
+ "BatchMode=yes",
198
+ "-o",
199
+ f"ConnectTimeout={int(ctx.timeout)}",
200
+ alias,
201
+ "true",
202
+ ],
203
+ check=False,
204
+ mutating=False,
205
+ timeout=ctx.timeout * 6,
206
+ )
207
+ yield CheckResult(
208
+ f"[{s.label}] storage ssh {alias}",
209
+ OK if res.returncode == 0 else FAIL,
210
+ res.stderr.strip()[-300:],
211
+ )
212
+ if res.returncode != 0:
213
+ continue
214
+ for repo in s.repos.values():
215
+ ok = services.restic_for(r, s, repo).is_initialized()
216
+ yield CheckResult(
217
+ f"[{s.label}/{repo.name}] restic repository", OK if ok else FAIL, s.repository(repo)
218
+ )
219
+
220
+
221
+ def check_timers(ctx: Context) -> Iterable[CheckResult]:
222
+ for s in ctx.servers:
223
+ for repo in s.repos.values():
224
+ base = schedule.unit_base(s.label, repo.name)
225
+ timers = [f"{base}.timer", f"{base}-weekly.timer"]
226
+ missing = [t for t in timers if not (ctx.units_dir / t).is_file()]
227
+ if missing:
228
+ yield CheckResult(
229
+ f"[{s.label}/{repo.name}] timers",
230
+ FAIL,
231
+ f"missing {', '.join(missing)}; re-run 'ltsbu discover'",
232
+ )
233
+ continue
234
+ res = ctx.runner.run(
235
+ ["systemctl", *(["--user"] if ctx.user_units else []), "is-enabled", *timers],
236
+ check=False,
237
+ mutating=False,
238
+ )
239
+ enabled = res.returncode == 0
240
+ yield CheckResult(
241
+ f"[{s.label}/{repo.name}] timers",
242
+ OK if enabled else FAIL,
243
+ res.stdout.strip().replace("\n", ", "),
244
+ )
245
+ if ctx.user_units and any(s.repos for s in ctx.servers):
246
+ res = ctx.runner.run(
247
+ ["loginctl", "show-user", getpass.getuser(), "-p", "Linger"],
248
+ check=False,
249
+ mutating=False,
250
+ )
251
+ if "Linger=yes" not in res.stdout:
252
+ yield CheckResult(
253
+ "systemd linger", WARN, "user timers stop at logout; run 'loginctl enable-linger'"
254
+ )
255
+
256
+
257
+ CHECKS: list[Check] = [
258
+ check_local_binaries,
259
+ check_config,
260
+ check_ssh_config,
261
+ check_network,
262
+ check_server_login,
263
+ check_server_binaries,
264
+ check_server_paths,
265
+ check_server_database,
266
+ check_storage,
267
+ check_timers,
268
+ ]
269
+
270
+
271
+ def run(ctx: Context, checks: list[Check] | None = None) -> list[CheckResult]:
272
+ results: list[CheckResult] = []
273
+ for check in checks or CHECKS:
274
+ try:
275
+ results.extend(check(ctx))
276
+ except Exception as e: # a broken check must not hide the others
277
+ results.append(CheckResult(check.__name__, FAIL, f"check crashed: {e}"))
278
+ return results