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/config.py
ADDED
|
@@ -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
|
lts_backup_cli/doctor.py
ADDED
|
@@ -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
|