wtftools 0.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.
- wtftools/__init__.py +55 -0
- wtftools/__main__.py +10 -0
- wtftools/audit.py +809 -0
- wtftools/colors.py +111 -0
- wtftools/config.py +249 -0
- wtftools/cron.py +388 -0
- wtftools/events.py +220 -0
- wtftools/explain.py +290 -0
- wtftools/info.py +90 -0
- wtftools/llm.py +129 -0
- wtftools/main.py +1328 -0
- wtftools/snapshot.py +203 -0
- wtftools/sysinfo.py +1608 -0
- wtftools-0.0.0.data/data/share/bash-completion/completions/wtf.bash-completion +134 -0
- wtftools-0.0.0.dist-info/METADATA +246 -0
- wtftools-0.0.0.dist-info/RECORD +20 -0
- wtftools-0.0.0.dist-info/WHEEL +5 -0
- wtftools-0.0.0.dist-info/entry_points.txt +3 -0
- wtftools-0.0.0.dist-info/licenses/LICENSE +21 -0
- wtftools-0.0.0.dist-info/top_level.txt +1 -0
wtftools/audit.py
ADDED
|
@@ -0,0 +1,809 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""Audit checks for `wtf audit`. Each returns a structured CheckResult."""
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
10
|
+
|
|
11
|
+
from wtftools import config as config_mod
|
|
12
|
+
from wtftools import cron, sysinfo
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class CheckResult:
|
|
19
|
+
"""Outcome of a single audit check."""
|
|
20
|
+
|
|
21
|
+
name: str
|
|
22
|
+
status: str # ok | warn | fail | skip
|
|
23
|
+
message: str
|
|
24
|
+
detail: List[str] = field(default_factory=list)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# Time-windowed checks (OOM, auth, kernel errors) read this value at run time.
|
|
28
|
+
# Override via set_since_hours() — used by `wtf audit --since N`.
|
|
29
|
+
_SINCE_HOURS: int = 24
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def set_since_hours(hours: int) -> None:
|
|
33
|
+
"""Override the look-back window for time-bounded checks."""
|
|
34
|
+
global _SINCE_HOURS
|
|
35
|
+
_SINCE_HOURS = max(1, int(hours))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _check_cron_daemon() -> CheckResult:
|
|
39
|
+
errors = cron.check_daemon()
|
|
40
|
+
if not errors:
|
|
41
|
+
return CheckResult("cron daemon", "ok", "active")
|
|
42
|
+
return CheckResult("cron daemon", "warn", "; ".join(errors))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _check_load() -> CheckResult:
|
|
46
|
+
cfg = config_mod.get_config()
|
|
47
|
+
load1, load5, load15 = sysinfo.get_loadavg()
|
|
48
|
+
cpus = sysinfo.get_cpu_count()
|
|
49
|
+
ratio = load1 / max(cpus, 1)
|
|
50
|
+
msg = f"load avg {load1:.2f} {load5:.2f} {load15:.2f} / {cpus} CPU"
|
|
51
|
+
if ratio >= cfg.load_fail_ratio:
|
|
52
|
+
return CheckResult("load average", "fail", msg + f" (1m load is {ratio:.1f}x CPUs)")
|
|
53
|
+
if ratio >= cfg.load_warn_ratio:
|
|
54
|
+
return CheckResult("load average", "warn", msg + f" (1m load is {ratio:.1f}x CPUs)")
|
|
55
|
+
return CheckResult("load average", "ok", msg)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _check_memory() -> CheckResult:
|
|
59
|
+
cfg = config_mod.get_config()
|
|
60
|
+
mem = sysinfo.get_memory_summary()
|
|
61
|
+
pct = mem["percent"]
|
|
62
|
+
used = sysinfo.format_bytes(mem["used"])
|
|
63
|
+
total = sysinfo.format_bytes(mem["total"])
|
|
64
|
+
msg = f"{used} / {total} used ({pct}%)"
|
|
65
|
+
if pct >= cfg.mem_fail_pct:
|
|
66
|
+
return CheckResult("memory", "fail", msg)
|
|
67
|
+
if pct >= cfg.mem_warn_pct:
|
|
68
|
+
return CheckResult("memory", "warn", msg)
|
|
69
|
+
return CheckResult("memory", "ok", msg)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _check_swap() -> CheckResult:
|
|
73
|
+
cfg = config_mod.get_config()
|
|
74
|
+
mem = sysinfo.get_memory_summary()
|
|
75
|
+
if mem["swap_total"] == 0:
|
|
76
|
+
return CheckResult("swap", "skip", "no swap configured")
|
|
77
|
+
pct = mem["swap_percent"]
|
|
78
|
+
used = sysinfo.format_bytes(mem["swap_used"])
|
|
79
|
+
total = sysinfo.format_bytes(mem["swap_total"])
|
|
80
|
+
msg = f"{used} / {total} used ({pct}%)"
|
|
81
|
+
if pct >= cfg.swap_fail_pct:
|
|
82
|
+
return CheckResult("swap", "fail", msg)
|
|
83
|
+
if pct >= cfg.swap_warn_pct:
|
|
84
|
+
return CheckResult("swap", "warn", msg)
|
|
85
|
+
return CheckResult("swap", "ok", msg)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _check_disks() -> List[CheckResult]:
|
|
89
|
+
cfg = config_mod.get_config()
|
|
90
|
+
results: List[CheckResult] = []
|
|
91
|
+
for disk in sysinfo.get_disks():
|
|
92
|
+
target = disk["target"]
|
|
93
|
+
pct = disk["percent"]
|
|
94
|
+
used = sysinfo.format_bytes(disk["used"])
|
|
95
|
+
total = sysinfo.format_bytes(disk["total"])
|
|
96
|
+
msg = f"{used} / {total} used ({pct}%)"
|
|
97
|
+
name = f"disk {target}"
|
|
98
|
+
if pct >= cfg.disk_fail_pct:
|
|
99
|
+
results.append(CheckResult(name, "fail", msg))
|
|
100
|
+
elif pct >= cfg.disk_warn_pct:
|
|
101
|
+
results.append(CheckResult(name, "warn", msg))
|
|
102
|
+
else:
|
|
103
|
+
results.append(CheckResult(name, "ok", msg))
|
|
104
|
+
return results
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _check_inodes() -> List[CheckResult]:
|
|
108
|
+
results: List[CheckResult] = []
|
|
109
|
+
for disk in sysinfo.get_disks():
|
|
110
|
+
try:
|
|
111
|
+
stat = os.statvfs(disk["target"])
|
|
112
|
+
except OSError:
|
|
113
|
+
continue
|
|
114
|
+
if stat.f_files == 0:
|
|
115
|
+
continue
|
|
116
|
+
used = stat.f_files - stat.f_ffree
|
|
117
|
+
pct = int(round(100 * used / stat.f_files)) if stat.f_files else 0
|
|
118
|
+
msg = f"{pct}% inodes used"
|
|
119
|
+
name = f"inodes {disk['target']}"
|
|
120
|
+
if pct >= 95:
|
|
121
|
+
results.append(CheckResult(name, "fail", msg))
|
|
122
|
+
elif pct >= 85:
|
|
123
|
+
results.append(CheckResult(name, "warn", msg))
|
|
124
|
+
# skip ok inodes from default output for brevity — only surface problems
|
|
125
|
+
return results
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _check_failed_units() -> CheckResult:
|
|
129
|
+
if not shutil.which("systemctl"):
|
|
130
|
+
return CheckResult("failed systemd units", "skip", "systemctl not available")
|
|
131
|
+
units = sysinfo.get_failed_units()
|
|
132
|
+
if not units:
|
|
133
|
+
return CheckResult("failed systemd units", "ok", "no failed units")
|
|
134
|
+
return CheckResult("failed systemd units", "fail", f"{len(units)} failed unit(s)", detail=units)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _check_zombies() -> CheckResult:
|
|
138
|
+
count = sysinfo.count_zombie_processes()
|
|
139
|
+
if count == 0:
|
|
140
|
+
return CheckResult("zombie processes", "ok", "0 zombies")
|
|
141
|
+
if count >= 5:
|
|
142
|
+
return CheckResult("zombie processes", "fail", f"{count} zombies")
|
|
143
|
+
return CheckResult("zombie processes", "warn", f"{count} zombie(s)")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _check_oom_kills() -> CheckResult:
|
|
147
|
+
hrs = _SINCE_HOURS
|
|
148
|
+
events = sysinfo.get_oom_events(hours=hrs)
|
|
149
|
+
label = f"OOM kills ({hrs}h)"
|
|
150
|
+
if not events:
|
|
151
|
+
return CheckResult(label, "ok", "none")
|
|
152
|
+
return CheckResult(label, "fail", f"{len(events)} OOM event(s)", detail=events[:3])
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _check_kernel_errors() -> CheckResult:
|
|
156
|
+
hrs = _SINCE_HOURS
|
|
157
|
+
label = f"kernel errors ({hrs}h)"
|
|
158
|
+
if not shutil.which("journalctl"):
|
|
159
|
+
return CheckResult(label, "skip", "journalctl not available")
|
|
160
|
+
errors = sysinfo.get_recent_kernel_errors(hours=hrs, limit=5)
|
|
161
|
+
if not errors:
|
|
162
|
+
return CheckResult(label, "ok", "none")
|
|
163
|
+
return CheckResult(label, "warn", f"{len(errors)} recent kernel error line(s)", detail=errors)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _check_pending_updates() -> CheckResult:
|
|
167
|
+
count = sysinfo.get_pending_updates()
|
|
168
|
+
if count < 0:
|
|
169
|
+
return CheckResult("pending updates", "skip", "apt not available")
|
|
170
|
+
if count == 0:
|
|
171
|
+
return CheckResult("pending updates", "ok", "all packages up to date")
|
|
172
|
+
if count >= 50:
|
|
173
|
+
return CheckResult("pending updates", "warn", f"{count} package(s) upgradable")
|
|
174
|
+
return CheckResult("pending updates", "ok", f"{count} package(s) upgradable")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _check_fds() -> CheckResult:
|
|
178
|
+
cfg = config_mod.get_config()
|
|
179
|
+
fds = sysinfo.get_open_fds()
|
|
180
|
+
if fds is None:
|
|
181
|
+
return CheckResult("open file descriptors", "skip", "cannot read /proc/sys/fs/file-nr")
|
|
182
|
+
used, max_fd = fds
|
|
183
|
+
pct = int(round(100 * used / max_fd)) if max_fd else 0
|
|
184
|
+
msg = f"{used} / {max_fd} ({pct}%)"
|
|
185
|
+
if pct >= cfg.fd_fail_pct:
|
|
186
|
+
return CheckResult("open file descriptors", "fail", msg)
|
|
187
|
+
if pct >= cfg.fd_warn_pct:
|
|
188
|
+
return CheckResult("open file descriptors", "warn", msg)
|
|
189
|
+
return CheckResult("open file descriptors", "ok", msg)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _check_failed_auth() -> CheckResult:
|
|
193
|
+
cfg = config_mod.get_config()
|
|
194
|
+
hrs = _SINCE_HOURS
|
|
195
|
+
label = f"failed auth ({hrs}h)"
|
|
196
|
+
count = sysinfo.get_failed_auth_count(hours=hrs)
|
|
197
|
+
if count == 0:
|
|
198
|
+
return CheckResult(label, "ok", "none")
|
|
199
|
+
if count >= cfg.auth_warn_count:
|
|
200
|
+
return CheckResult(label, "warn", f"{count} failed login attempts")
|
|
201
|
+
return CheckResult(label, "ok", f"{count} failed login attempt(s)")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _check_user_crontabs() -> CheckResult:
|
|
205
|
+
errors: List[str] = []
|
|
206
|
+
rows = 0
|
|
207
|
+
for path, is_system in cron.discover_default_targets():
|
|
208
|
+
r, e, _ = cron.check_file(path, is_system_crontab=is_system)
|
|
209
|
+
rows += r
|
|
210
|
+
errors.extend(e)
|
|
211
|
+
if rows == 0:
|
|
212
|
+
return CheckResult("crontab syntax", "skip", "no crontab files visible")
|
|
213
|
+
if not errors:
|
|
214
|
+
return CheckResult("crontab syntax", "ok", f"{rows} cron line(s), no errors")
|
|
215
|
+
return CheckResult("crontab syntax", "fail", f"{len(errors)} error(s) in {rows} cron line(s)", detail=errors[:5])
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _check_uptime() -> CheckResult:
|
|
219
|
+
uptime = sysinfo.get_uptime_seconds()
|
|
220
|
+
msg = sysinfo.format_duration(uptime)
|
|
221
|
+
if uptime < 300:
|
|
222
|
+
return CheckResult("uptime", "warn", f"recently rebooted ({msg} ago)")
|
|
223
|
+
return CheckResult("uptime", "ok", msg)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _check_system_running() -> CheckResult:
|
|
227
|
+
state = sysinfo.get_system_running_state()
|
|
228
|
+
if state is None:
|
|
229
|
+
return CheckResult("system state", "skip", "systemctl unavailable")
|
|
230
|
+
if state == "running":
|
|
231
|
+
return CheckResult("system state", "ok", "running")
|
|
232
|
+
if state == "degraded":
|
|
233
|
+
return CheckResult("system state", "fail", "system is in 'degraded' state")
|
|
234
|
+
if state in ("initializing", "starting"):
|
|
235
|
+
return CheckResult("system state", "warn", state)
|
|
236
|
+
if state == "maintenance":
|
|
237
|
+
return CheckResult("system state", "fail", "system is in maintenance mode")
|
|
238
|
+
return CheckResult("system state", "warn", state)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _check_enabled_inactive() -> CheckResult:
|
|
242
|
+
if not shutil.which("systemctl"):
|
|
243
|
+
return CheckResult("enabled but down", "skip", "systemctl unavailable")
|
|
244
|
+
units = sysinfo.get_enabled_inactive_units()
|
|
245
|
+
if not units:
|
|
246
|
+
return CheckResult("enabled but down", "ok", "all enabled services are running")
|
|
247
|
+
rendered = [f"{u['name']} (ActiveState={u['state']}, Result={u['result']})" for u in units]
|
|
248
|
+
if any(u["state"] == "failed" for u in units):
|
|
249
|
+
return CheckResult("enabled but down", "fail", f"{len(units)} enabled service(s) not running", detail=rendered)
|
|
250
|
+
return CheckResult("enabled but down", "warn", f"{len(units)} enabled service(s) not running", detail=rendered)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _check_reboot_required() -> CheckResult:
|
|
254
|
+
msg = sysinfo.get_reboot_required()
|
|
255
|
+
if msg is None:
|
|
256
|
+
return CheckResult("reboot required", "ok", "no")
|
|
257
|
+
return CheckResult("reboot required", "warn", msg)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _check_time_sync() -> CheckResult:
|
|
261
|
+
status = sysinfo.get_time_sync_status()
|
|
262
|
+
if status["synchronized"] is None:
|
|
263
|
+
return CheckResult("time sync", "skip", status["source"])
|
|
264
|
+
# When chrony is available, the magnitude of drift is far more useful than
|
|
265
|
+
# the binary sync/no-sync flag from timedatectl.
|
|
266
|
+
offset_s = sysinfo.get_chrony_offset()
|
|
267
|
+
suffix = ""
|
|
268
|
+
drift_severity: Optional[str] = None
|
|
269
|
+
if offset_s is not None:
|
|
270
|
+
ms = offset_s * 1000.0
|
|
271
|
+
suffix = f" · offset={ms:.1f}ms"
|
|
272
|
+
if ms >= 1000:
|
|
273
|
+
drift_severity = "fail"
|
|
274
|
+
elif ms >= 100:
|
|
275
|
+
drift_severity = "warn"
|
|
276
|
+
if status["synchronized"]:
|
|
277
|
+
msg = "NTP synchronized" + suffix
|
|
278
|
+
if drift_severity == "fail":
|
|
279
|
+
return CheckResult("time sync", "fail", msg + " (drift >1s)")
|
|
280
|
+
if drift_severity == "warn":
|
|
281
|
+
return CheckResult("time sync", "warn", msg + " (drift >100ms)")
|
|
282
|
+
return CheckResult("time sync", "ok", msg)
|
|
283
|
+
if status["ntp_active"]:
|
|
284
|
+
return CheckResult("time sync", "warn", "NTP active but not synchronized" + suffix)
|
|
285
|
+
return CheckResult("time sync", "fail", "NTP disabled and clock not synchronized" + suffix)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _check_fail2ban() -> CheckResult:
|
|
289
|
+
jails = sysinfo.get_fail2ban_jails()
|
|
290
|
+
if jails is None:
|
|
291
|
+
return CheckResult("fail2ban", "skip", "fail2ban-client not installed / daemon down")
|
|
292
|
+
if not jails:
|
|
293
|
+
return CheckResult("fail2ban", "ok", "active, no jails configured")
|
|
294
|
+
banned_now = sum(j["banned"] for j in jails)
|
|
295
|
+
banned_total = sum(j["total"] for j in jails)
|
|
296
|
+
detail = [f"{j['name']}: {j['banned']} banned now / {j['total']} total" for j in jails]
|
|
297
|
+
# Active bans are informational, not a problem. We surface them so the
|
|
298
|
+
# SRE knows fail2ban is actually doing something.
|
|
299
|
+
msg = f"{banned_now} IP(s) currently banned across " f"{len(jails)} jail(s) · {banned_total} total since boot"
|
|
300
|
+
return CheckResult("fail2ban", "ok", msg, detail=detail)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _check_http_probes() -> List[CheckResult]:
|
|
304
|
+
"""One result row per configured HTTP probe (HEAD)."""
|
|
305
|
+
cfg = config_mod.get_config()
|
|
306
|
+
urls = [u.strip() for u in cfg.http_probes.split(",") if u.strip()]
|
|
307
|
+
if not urls:
|
|
308
|
+
return []
|
|
309
|
+
results: List[CheckResult] = []
|
|
310
|
+
for url in urls:
|
|
311
|
+
outcome = sysinfo.probe_http(url, timeout=cfg.probe_timeout)
|
|
312
|
+
name = f"http probe {url}"
|
|
313
|
+
if outcome["error"]:
|
|
314
|
+
results.append(CheckResult(name, "fail", outcome["error"]))
|
|
315
|
+
continue
|
|
316
|
+
status = outcome["status_code"] or 0
|
|
317
|
+
latency = outcome["latency_ms"] or 0.0
|
|
318
|
+
msg = f"HTTP {status} · {latency:.0f}ms"
|
|
319
|
+
if status < 200 or status >= 400:
|
|
320
|
+
results.append(CheckResult(name, "fail", msg))
|
|
321
|
+
elif latency >= cfg.probe_slow_ms:
|
|
322
|
+
results.append(CheckResult(name, "warn", msg + " (slow)"))
|
|
323
|
+
else:
|
|
324
|
+
results.append(CheckResult(name, "ok", msg))
|
|
325
|
+
return results
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _check_tcp_probes() -> List[CheckResult]:
|
|
329
|
+
"""One result row per configured TCP probe (connect)."""
|
|
330
|
+
cfg = config_mod.get_config()
|
|
331
|
+
targets = [t.strip() for t in cfg.tcp_probes.split(",") if t.strip()]
|
|
332
|
+
if not targets:
|
|
333
|
+
return []
|
|
334
|
+
results: List[CheckResult] = []
|
|
335
|
+
for target in targets:
|
|
336
|
+
outcome = sysinfo.probe_tcp(target, timeout=cfg.probe_timeout)
|
|
337
|
+
name = f"tcp probe {target}"
|
|
338
|
+
if outcome["error"]:
|
|
339
|
+
results.append(CheckResult(name, "fail", outcome["error"]))
|
|
340
|
+
continue
|
|
341
|
+
latency = outcome["latency_ms"] or 0.0
|
|
342
|
+
msg = f"connected · {latency:.0f}ms"
|
|
343
|
+
if latency >= cfg.probe_slow_ms:
|
|
344
|
+
results.append(CheckResult(name, "warn", msg + " (slow)"))
|
|
345
|
+
else:
|
|
346
|
+
results.append(CheckResult(name, "ok", msg))
|
|
347
|
+
return results
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _check_smart() -> CheckResult:
|
|
351
|
+
"""Per-disk SMART health via smartctl."""
|
|
352
|
+
result = sysinfo.get_smart_status()
|
|
353
|
+
if result is None:
|
|
354
|
+
return CheckResult("disk SMART", "skip", "smartctl not installed (apt install smartmontools)")
|
|
355
|
+
if not result:
|
|
356
|
+
return CheckResult("disk SMART", "skip", "no SMART-capable disks found")
|
|
357
|
+
failed = [r for r in result if r["passed"] is False]
|
|
358
|
+
detail = [
|
|
359
|
+
f"{r['device']}: " + ("PASSED" if r["passed"] else ("FAILED — replace soon" if r["passed"] is False else "unknown")) + (f" ({r['message']})" if r["message"] else "")
|
|
360
|
+
for r in result
|
|
361
|
+
]
|
|
362
|
+
if failed:
|
|
363
|
+
return CheckResult("disk SMART", "fail", f"{len(failed)} disk(s) reporting SMART failure", detail=detail)
|
|
364
|
+
return CheckResult("disk SMART", "ok", f"all {len(result)} disk(s) passing SMART", detail=detail)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _check_temperatures() -> CheckResult:
|
|
368
|
+
cfg = config_mod.get_config()
|
|
369
|
+
temps = sysinfo.get_temperatures()
|
|
370
|
+
if not temps:
|
|
371
|
+
return CheckResult("hw temperatures", "skip", "no /sys/class/hwmon sensors found")
|
|
372
|
+
hottest = max(temps, key=lambda t: t["celsius"])
|
|
373
|
+
summary = f"max {hottest['celsius']}°C " f"({hottest['sensor']}/{hottest['label']}) " f"· {len(temps)} sensor(s)"
|
|
374
|
+
detail = [f"{t['celsius']:5.1f}°C {t['sensor']}/{t['label']}" for t in temps]
|
|
375
|
+
if hottest["celsius"] >= cfg.temp_fail_c:
|
|
376
|
+
return CheckResult("hw temperatures", "fail", summary, detail=detail)
|
|
377
|
+
if hottest["celsius"] >= cfg.temp_warn_c:
|
|
378
|
+
return CheckResult("hw temperatures", "warn", summary, detail=detail)
|
|
379
|
+
return CheckResult("hw temperatures", "ok", summary)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _check_dns() -> CheckResult:
|
|
383
|
+
cfg = config_mod.get_config()
|
|
384
|
+
hosts = [h.strip() for h in cfg.dns_probe_hosts.split(",") if h.strip()]
|
|
385
|
+
if not hosts:
|
|
386
|
+
return CheckResult("dns resolution", "skip", "no probe hosts configured")
|
|
387
|
+
results: List[Tuple[str, Optional[float]]] = []
|
|
388
|
+
for host in hosts:
|
|
389
|
+
results.append((host, sysinfo.resolve_hostname(host, timeout=cfg.dns_probe_timeout)))
|
|
390
|
+
succeeded = [(h, ms) for h, ms in results if ms is not None]
|
|
391
|
+
failed = [h for h, ms in results if ms is None]
|
|
392
|
+
detail = [f"{h}: {ms:.0f}ms" for h, ms in succeeded] + [f"{h}: FAILED (timeout/error)" for h in failed]
|
|
393
|
+
if not succeeded:
|
|
394
|
+
return CheckResult("dns resolution", "fail", f"{len(failed)} probe(s) failed (no DNS reachable)", detail=detail)
|
|
395
|
+
if failed:
|
|
396
|
+
return CheckResult("dns resolution", "warn", f"{len(failed)}/{len(hosts)} probe(s) failed", detail=detail)
|
|
397
|
+
slowest = max(ms for _, ms in succeeded)
|
|
398
|
+
msg = f"all {len(hosts)} resolved (slowest: {slowest:.0f}ms)"
|
|
399
|
+
return CheckResult("dns resolution", "ok", msg)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _check_docker() -> CheckResult:
|
|
403
|
+
problems = sysinfo.get_docker_problem_containers()
|
|
404
|
+
if problems is None:
|
|
405
|
+
return CheckResult("docker", "skip", "docker not installed or daemon unreachable")
|
|
406
|
+
if not problems:
|
|
407
|
+
return CheckResult("docker", "ok", "no unhealthy/restarting containers")
|
|
408
|
+
detail = [f"{c['name']}: {c['problem']} ({c['status']})" for c in problems]
|
|
409
|
+
has_unhealthy = any(c["problem"] == "unhealthy" for c in problems)
|
|
410
|
+
status = "fail" if has_unhealthy else "warn"
|
|
411
|
+
return CheckResult("docker", status, f"{len(problems)} container(s) unhealthy/restarting", detail=detail)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _check_readonly_mounts() -> CheckResult:
|
|
415
|
+
mounts = sysinfo.get_readonly_mounts()
|
|
416
|
+
if not mounts:
|
|
417
|
+
return CheckResult("read-only mounts", "ok", "none unexpected")
|
|
418
|
+
return CheckResult("read-only mounts", "fail", f"{len(mounts)} unexpectedly read-only mount(s)", detail=mounts)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _check_stuck_processes() -> CheckResult:
|
|
422
|
+
stuck = sysinfo.get_stuck_processes()
|
|
423
|
+
if not stuck:
|
|
424
|
+
return CheckResult("D-state processes", "ok", "none")
|
|
425
|
+
if len(stuck) >= 5:
|
|
426
|
+
return CheckResult("D-state processes", "fail", f"{len(stuck)} stuck process(es)", detail=[f"pid={p['pid']} {p['name']}" for p in stuck[:5]])
|
|
427
|
+
return CheckResult("D-state processes", "warn", f"{len(stuck)} stuck process(es)", detail=[f"pid={p['pid']} {p['name']}" for p in stuck])
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _check_iowait() -> CheckResult:
|
|
431
|
+
cfg = config_mod.get_config()
|
|
432
|
+
iowait = sysinfo.get_iowait_percent()
|
|
433
|
+
if iowait is None:
|
|
434
|
+
return CheckResult("CPU iowait", "skip", "cannot read /proc/stat")
|
|
435
|
+
msg = f"{iowait:.1f}%"
|
|
436
|
+
if iowait >= cfg.iowait_fail_pct:
|
|
437
|
+
return CheckResult("CPU iowait", "fail", msg)
|
|
438
|
+
if iowait >= cfg.iowait_warn_pct:
|
|
439
|
+
return CheckResult("CPU iowait", "warn", msg)
|
|
440
|
+
return CheckResult("CPU iowait", "ok", msg)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _check_tcp_retransmits() -> CheckResult:
|
|
444
|
+
cfg = config_mod.get_config()
|
|
445
|
+
rate = sysinfo.get_tcp_retransmit_rate()
|
|
446
|
+
if rate is None:
|
|
447
|
+
return CheckResult("TCP retransmits", "skip", "cannot read /proc/net/snmp")
|
|
448
|
+
msg = f"{rate:.2f}% retransmitted (1s sample)"
|
|
449
|
+
if rate >= cfg.tcp_retrans_fail_pct:
|
|
450
|
+
return CheckResult("TCP retransmits", "fail", msg)
|
|
451
|
+
if rate >= cfg.tcp_retrans_warn_pct:
|
|
452
|
+
return CheckResult("TCP retransmits", "warn", msg)
|
|
453
|
+
return CheckResult("TCP retransmits", "ok", msg)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _check_restart_loops() -> CheckResult:
|
|
457
|
+
cfg = config_mod.get_config()
|
|
458
|
+
if not shutil.which("systemctl"):
|
|
459
|
+
return CheckResult("restart loops", "skip", "systemctl unavailable")
|
|
460
|
+
restarts = sysinfo.get_service_restart_counts(threshold=cfg.restart_warn_threshold)
|
|
461
|
+
if not restarts:
|
|
462
|
+
return CheckResult("restart loops", "ok", "no services restarting frequently")
|
|
463
|
+
detail = [f"{r['name']} (NRestarts={r['restarts']})" for r in restarts]
|
|
464
|
+
if any(r["restarts"] >= cfg.restart_fail_threshold for r in restarts):
|
|
465
|
+
return CheckResult("restart loops", "fail", f"{len(restarts)} service(s) restart-looping", detail=detail)
|
|
466
|
+
return CheckResult("restart loops", "warn", f"{len(restarts)} service(s) with multiple restarts", detail=detail)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _check_network_errors() -> CheckResult:
|
|
470
|
+
errors = sysinfo.get_network_errors()
|
|
471
|
+
if not errors:
|
|
472
|
+
return CheckResult("network errors", "ok", "no rx/tx errors or drops")
|
|
473
|
+
detail = [f"{e['iface']}: rx_err={e['rx_errors']} tx_err={e['tx_errors']} " f"rx_drop={e['rx_dropped']} tx_drop={e['tx_dropped']}" for e in errors]
|
|
474
|
+
severe = [e for e in errors if e["total"] >= 1000]
|
|
475
|
+
if severe:
|
|
476
|
+
return CheckResult("network errors", "warn", f"{len(severe)} interface(s) with high error counts (≥1000)", detail=detail)
|
|
477
|
+
return CheckResult("network errors", "ok", "minor counters present (below threshold)", detail=detail)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _check_psi() -> List[CheckResult]:
|
|
481
|
+
"""Pressure Stall Information — kernel 4.20+'s honest contention signal."""
|
|
482
|
+
cfg = config_mod.get_config()
|
|
483
|
+
results: List[CheckResult] = []
|
|
484
|
+
for resource in ("cpu", "memory", "io"):
|
|
485
|
+
data = sysinfo.get_pressure(resource)
|
|
486
|
+
name = f"PSI {resource}"
|
|
487
|
+
if data is None:
|
|
488
|
+
results.append(CheckResult(name, "skip", "no /proc/pressure (kernel <4.20 or psi=0)"))
|
|
489
|
+
continue
|
|
490
|
+
avg10 = data.get("some", {}).get("avg10", 0.0)
|
|
491
|
+
msg = f"some avg10={avg10:.1f}%"
|
|
492
|
+
if avg10 >= cfg.psi_fail_pct:
|
|
493
|
+
results.append(CheckResult(name, "fail", msg))
|
|
494
|
+
elif avg10 >= cfg.psi_warn_pct:
|
|
495
|
+
results.append(CheckResult(name, "warn", msg))
|
|
496
|
+
else:
|
|
497
|
+
results.append(CheckResult(name, "ok", msg))
|
|
498
|
+
return results
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _check_kernel_taint() -> CheckResult:
|
|
502
|
+
val = sysinfo.get_kernel_taint()
|
|
503
|
+
if val is None:
|
|
504
|
+
return CheckResult("kernel taint", "skip", "cannot read /proc/sys/kernel/tainted")
|
|
505
|
+
if val == 0:
|
|
506
|
+
return CheckResult("kernel taint", "ok", "clean")
|
|
507
|
+
flags = sysinfo.decode_kernel_taint(val)
|
|
508
|
+
bits = ", ".join(flags) if flags else f"raw=0x{val:x}"
|
|
509
|
+
msg = f"tainted: 0x{val:x} ({bits})"
|
|
510
|
+
# WARN (not FAIL) because non-zero taint is informative but rarely
|
|
511
|
+
# actionable in the same way as a failed unit. A SOFTLOCKUP or MACHINE_CHECK
|
|
512
|
+
# bit is more serious — escalate those.
|
|
513
|
+
severe_bits = {"MACHINE_CHECK", "SOFTLOCKUP", "DIE", "BAD_PAGE"}
|
|
514
|
+
if any(f in severe_bits for f in flags):
|
|
515
|
+
return CheckResult("kernel taint", "fail", msg)
|
|
516
|
+
return CheckResult("kernel taint", "warn", msg)
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _check_cert_expiry() -> CheckResult:
|
|
520
|
+
cfg = config_mod.get_config()
|
|
521
|
+
certs = sysinfo.get_certificate_expirations()
|
|
522
|
+
if not certs:
|
|
523
|
+
return CheckResult("cert expiry", "skip", "no certs found or openssl unavailable")
|
|
524
|
+
soonest = certs[0]
|
|
525
|
+
detail = [f"{c['days_left']:>4}d {c['path']}" for c in certs[:10]]
|
|
526
|
+
msg = f"soonest: {soonest['path']} in {soonest['days_left']}d ({len(certs)} cert(s) scanned)"
|
|
527
|
+
if soonest["days_left"] < cfg.cert_fail_days:
|
|
528
|
+
return CheckResult("cert expiry", "fail", msg, detail=detail)
|
|
529
|
+
if soonest["days_left"] < cfg.cert_warn_days:
|
|
530
|
+
return CheckResult("cert expiry", "warn", msg, detail=detail)
|
|
531
|
+
return CheckResult("cert expiry", "ok", msg, detail=detail)
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _check_conntrack() -> CheckResult:
|
|
535
|
+
cfg = config_mod.get_config()
|
|
536
|
+
usage = sysinfo.get_conntrack_usage()
|
|
537
|
+
if usage is None:
|
|
538
|
+
return CheckResult("conntrack", "skip", "nf_conntrack not loaded / not readable")
|
|
539
|
+
count, max_v = usage
|
|
540
|
+
if max_v <= 0:
|
|
541
|
+
return CheckResult("conntrack", "skip", "conntrack max is zero")
|
|
542
|
+
pct = int(round(100 * count / max_v))
|
|
543
|
+
msg = f"{count} / {max_v} entries ({pct}%)"
|
|
544
|
+
if pct >= cfg.conntrack_fail_pct:
|
|
545
|
+
return CheckResult("conntrack", "fail", msg)
|
|
546
|
+
if pct >= cfg.conntrack_warn_pct:
|
|
547
|
+
return CheckResult("conntrack", "warn", msg)
|
|
548
|
+
return CheckResult("conntrack", "ok", msg)
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def _check_journal_disk() -> CheckResult:
|
|
552
|
+
cfg = config_mod.get_config()
|
|
553
|
+
bytes_used = sysinfo.get_journal_disk_usage()
|
|
554
|
+
if bytes_used is None:
|
|
555
|
+
return CheckResult("journal disk", "skip", "journalctl unavailable")
|
|
556
|
+
gb = bytes_used / (1024**3)
|
|
557
|
+
msg = f"{sysinfo.format_bytes(bytes_used)} used by journald"
|
|
558
|
+
if gb >= cfg.journal_fail_gb:
|
|
559
|
+
return CheckResult("journal disk", "fail", msg + f" (tip: `journalctl --vacuum-size={int(cfg.journal_warn_gb)}G`)")
|
|
560
|
+
if gb >= cfg.journal_warn_gb:
|
|
561
|
+
return CheckResult("journal disk", "warn", msg)
|
|
562
|
+
return CheckResult("journal disk", "ok", msg)
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _check_pid_count() -> CheckResult:
|
|
566
|
+
cfg = config_mod.get_config()
|
|
567
|
+
count, pid_max = sysinfo.get_pid_count()
|
|
568
|
+
if pid_max <= 0:
|
|
569
|
+
return CheckResult("process count", "skip", "cannot read pid_max")
|
|
570
|
+
pct = int(round(100 * count / pid_max))
|
|
571
|
+
msg = f"{count} / {pid_max} ({pct}%)"
|
|
572
|
+
if pct >= cfg.pid_fail_pct:
|
|
573
|
+
return CheckResult("process count", "fail", msg)
|
|
574
|
+
if pct >= cfg.pid_warn_pct:
|
|
575
|
+
return CheckResult("process count", "warn", msg)
|
|
576
|
+
return CheckResult("process count", "ok", msg)
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
CheckFn = Callable[[], Any]
|
|
580
|
+
|
|
581
|
+
# Short, stable names for `wtf audit --check <name>` and `wtf audit --list`.
|
|
582
|
+
# These are intentionally kebab-friendly and easier to type than CheckResult.name.
|
|
583
|
+
CHECK_REGISTRY: Dict[str, CheckFn] = {
|
|
584
|
+
"uptime": _check_uptime,
|
|
585
|
+
"system": _check_system_running,
|
|
586
|
+
"load": _check_load,
|
|
587
|
+
"iowait": _check_iowait,
|
|
588
|
+
"psi": _check_psi,
|
|
589
|
+
"tcp-retrans": _check_tcp_retransmits,
|
|
590
|
+
"memory": _check_memory,
|
|
591
|
+
"swap": _check_swap,
|
|
592
|
+
"disks": _check_disks,
|
|
593
|
+
"inodes": _check_inodes,
|
|
594
|
+
"readonly-mounts": _check_readonly_mounts,
|
|
595
|
+
"failed-units": _check_failed_units,
|
|
596
|
+
"enabled-inactive": _check_enabled_inactive,
|
|
597
|
+
"restart-loops": _check_restart_loops,
|
|
598
|
+
"network-errors": _check_network_errors,
|
|
599
|
+
"conntrack": _check_conntrack,
|
|
600
|
+
"journal-disk": _check_journal_disk,
|
|
601
|
+
"zombies": _check_zombies,
|
|
602
|
+
"d-state": _check_stuck_processes,
|
|
603
|
+
"oom": _check_oom_kills,
|
|
604
|
+
"kernel-errors": _check_kernel_errors,
|
|
605
|
+
"fds": _check_fds,
|
|
606
|
+
"pids": _check_pid_count,
|
|
607
|
+
"auth": _check_failed_auth,
|
|
608
|
+
"time-sync": _check_time_sync,
|
|
609
|
+
"updates": _check_pending_updates,
|
|
610
|
+
"reboot": _check_reboot_required,
|
|
611
|
+
"kernel-taint": _check_kernel_taint,
|
|
612
|
+
"cert-expiry": _check_cert_expiry,
|
|
613
|
+
"cron-daemon": _check_cron_daemon,
|
|
614
|
+
"crontab": _check_user_crontabs,
|
|
615
|
+
"docker": _check_docker,
|
|
616
|
+
"hw-temp": _check_temperatures,
|
|
617
|
+
"smart": _check_smart,
|
|
618
|
+
"dns": _check_dns,
|
|
619
|
+
"http-probes": _check_http_probes,
|
|
620
|
+
"tcp-probes": _check_tcp_probes,
|
|
621
|
+
"fail2ban": _check_fail2ban,
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def render_html(results: List[CheckResult], host: Optional[str] = None, timestamp: Optional[str] = None) -> str:
|
|
626
|
+
"""Self-contained HTML for the audit. Inline CSS so it survives email/ticket paste."""
|
|
627
|
+
from datetime import datetime, timezone
|
|
628
|
+
from html import escape as _esc
|
|
629
|
+
|
|
630
|
+
ts = timestamp or datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
631
|
+
host = host or "?"
|
|
632
|
+
totals = summarize(results)
|
|
633
|
+
|
|
634
|
+
color = {"ok": "#5cb85c", "warn": "#f0ad4e", "fail": "#d9534f", "skip": "#999"}
|
|
635
|
+
rows = []
|
|
636
|
+
for r in results:
|
|
637
|
+
c = color.get(r.status, "#999")
|
|
638
|
+
detail_html = ""
|
|
639
|
+
if r.detail:
|
|
640
|
+
items = "".join(f"<li>{_esc(d)}</li>" for d in r.detail)
|
|
641
|
+
detail_html = f"<details><summary style='cursor:pointer;color:#888'>" f"{len(r.detail)} detail line(s)</summary>" f"<ul style='margin:4px 0'>{items}</ul></details>"
|
|
642
|
+
rows.append(
|
|
643
|
+
"<tr style='border-bottom:1px solid #eee'>"
|
|
644
|
+
f"<td style='background:{c};color:#fff;font-weight:600;"
|
|
645
|
+
f"padding:4px 8px;text-align:center;width:60px'>"
|
|
646
|
+
f"{_esc(r.status.upper())}</td>"
|
|
647
|
+
f"<td style='padding:4px 8px;font-weight:600;white-space:nowrap'>"
|
|
648
|
+
f"{_esc(r.name)}</td>"
|
|
649
|
+
f"<td style='padding:4px 8px;color:#444'>"
|
|
650
|
+
f"{_esc(r.message)}{detail_html}</td>"
|
|
651
|
+
"</tr>"
|
|
652
|
+
)
|
|
653
|
+
|
|
654
|
+
ok_c = color["ok"]
|
|
655
|
+
warn_c = color["warn"]
|
|
656
|
+
fail_c = color["fail"]
|
|
657
|
+
skip_c = color["skip"]
|
|
658
|
+
summary_html = (
|
|
659
|
+
f"<span style='color:{ok_c}'>{totals['ok']} ok</span> · "
|
|
660
|
+
f"<span style='color:{warn_c}'>{totals['warn']} warn</span> · "
|
|
661
|
+
f"<span style='color:{fail_c}'>{totals['fail']} fail</span> · "
|
|
662
|
+
f"<span style='color:{skip_c}'>{totals['skip']} skip</span>"
|
|
663
|
+
)
|
|
664
|
+
|
|
665
|
+
return (
|
|
666
|
+
"<!doctype html><html><head><meta charset='utf-8'>"
|
|
667
|
+
f"<title>wtf audit · {_esc(host)}</title></head>"
|
|
668
|
+
"<body style='font-family:system-ui,sans-serif;color:#222;"
|
|
669
|
+
"max-width:900px;margin:24px auto;padding:0 16px'>"
|
|
670
|
+
f"<h2 style='margin:0 0 4px 0'>wtf audit · {_esc(host)}</h2>"
|
|
671
|
+
f"<div style='color:#888;font-size:13px;margin-bottom:16px'>"
|
|
672
|
+
f"{_esc(ts)} · {summary_html}</div>"
|
|
673
|
+
"<table style='width:100%;border-collapse:collapse;font-size:14px'>" + "".join(rows) + "</table></body></html>\n"
|
|
674
|
+
)
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def render_prometheus(results: List[CheckResult]) -> str:
|
|
678
|
+
"""Render audit results in Prometheus textfile-collector format.
|
|
679
|
+
|
|
680
|
+
Useful with node_exporter's --collector.textfile.directory: drop the output
|
|
681
|
+
into /var/lib/node_exporter/wtf.prom and you get wtf_check_status and
|
|
682
|
+
wtf_summary_total metrics scraped automatically.
|
|
683
|
+
"""
|
|
684
|
+
lines = [
|
|
685
|
+
"# HELP wtf_check_status Status of wtf check (0=ok, 1=warn, 2=fail, 3=skip)",
|
|
686
|
+
"# TYPE wtf_check_status gauge",
|
|
687
|
+
]
|
|
688
|
+
status_to_int = {"ok": 0, "warn": 1, "fail": 2, "skip": 3}
|
|
689
|
+
for r in results:
|
|
690
|
+
# Escape backslashes and double-quotes for the label value.
|
|
691
|
+
safe = r.name.replace("\\", "\\\\").replace('"', '\\"')
|
|
692
|
+
value = status_to_int.get(r.status, 3)
|
|
693
|
+
lines.append(f'wtf_check_status{{name="{safe}"}} {value}')
|
|
694
|
+
|
|
695
|
+
lines.append("# HELP wtf_summary_total Number of results by status")
|
|
696
|
+
lines.append("# TYPE wtf_summary_total gauge")
|
|
697
|
+
totals = summarize(results)
|
|
698
|
+
for status_name in ("ok", "warn", "fail", "skip"):
|
|
699
|
+
lines.append(f'wtf_summary_total{{status="{status_name}"}} {totals.get(status_name, 0)}')
|
|
700
|
+
return "\n".join(lines) + "\n"
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
DEFAULT_CHECKS: List[CheckFn] = list(CHECK_REGISTRY.values())
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def list_check_names() -> List[str]:
|
|
707
|
+
"""Return short names of all registered checks."""
|
|
708
|
+
return list(CHECK_REGISTRY.keys())
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def run_audit(names: Optional[List[str]] = None, ignore: Optional[List[str]] = None) -> List[CheckResult]:
|
|
712
|
+
"""Run all audit checks or a filtered subset by name.
|
|
713
|
+
|
|
714
|
+
`names` filters by short keys from CHECK_REGISTRY.
|
|
715
|
+
`ignore` excludes short-names AND skips results whose `name` matches
|
|
716
|
+
(lets users say `--ignore "disk /mnt/Backup"` to skip a single mount).
|
|
717
|
+
The config file's `[ignore]` section is merged into both filters.
|
|
718
|
+
"""
|
|
719
|
+
cfg = config_mod.get_config()
|
|
720
|
+
ignore_keys = set(ignore or []) | set(cfg.ignored_checks)
|
|
721
|
+
ignore_results = set(cfg.ignored_result_names) | (set(ignore or []) - set(CHECK_REGISTRY.keys()))
|
|
722
|
+
|
|
723
|
+
if names:
|
|
724
|
+
funcs: List[CheckFn] = []
|
|
725
|
+
skips: List[CheckResult] = []
|
|
726
|
+
for n in names:
|
|
727
|
+
if n in ignore_keys:
|
|
728
|
+
continue
|
|
729
|
+
fn = CHECK_REGISTRY.get(n)
|
|
730
|
+
if fn is None:
|
|
731
|
+
skips.append(CheckResult(n, "skip", f"unknown check '{n}'"))
|
|
732
|
+
else:
|
|
733
|
+
funcs.append(fn)
|
|
734
|
+
results = _run_funcs(funcs)
|
|
735
|
+
return [r for r in skips + results if r.name not in ignore_results]
|
|
736
|
+
|
|
737
|
+
funcs = [fn for k, fn in CHECK_REGISTRY.items() if k not in ignore_keys]
|
|
738
|
+
results = _run_funcs(funcs)
|
|
739
|
+
return [r for r in results if r.name not in ignore_results]
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def _run_funcs(funcs: List[CheckFn]) -> List[CheckResult]:
|
|
743
|
+
"""Execute checks in parallel (config.parallel_workers) with per-check timeout.
|
|
744
|
+
|
|
745
|
+
Output preserves submission order. A check exceeding the timeout becomes a
|
|
746
|
+
skip-result; the underlying thread is left to finish (Python cannot kill
|
|
747
|
+
threads safely) but the audit run is not blocked.
|
|
748
|
+
"""
|
|
749
|
+
cfg = config_mod.get_config()
|
|
750
|
+
if cfg.parallel_workers <= 1 or len(funcs) <= 1:
|
|
751
|
+
return _run_funcs_serial(funcs, cfg.check_timeout_seconds)
|
|
752
|
+
return _run_funcs_parallel(funcs, cfg.parallel_workers, cfg.check_timeout_seconds)
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def _run_funcs_serial(funcs: List[CheckFn], timeout: float) -> List[CheckResult]:
|
|
756
|
+
"""Serial fallback (used when workers<=1 or only one check). No real timeout
|
|
757
|
+
enforcement here — relies on subprocess timeouts inside each check."""
|
|
758
|
+
results: List[CheckResult] = []
|
|
759
|
+
for fn in funcs:
|
|
760
|
+
try:
|
|
761
|
+
outcome = fn()
|
|
762
|
+
except Exception as exc:
|
|
763
|
+
logger.warning(f"audit check {fn.__name__} failed: {type(exc).__name__}: {exc}")
|
|
764
|
+
results.append(CheckResult(fn.__name__.lstrip("_"), "skip", f"check error: {exc}"))
|
|
765
|
+
continue
|
|
766
|
+
if isinstance(outcome, list):
|
|
767
|
+
results.extend(outcome)
|
|
768
|
+
else:
|
|
769
|
+
results.append(outcome)
|
|
770
|
+
return results
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def _run_funcs_parallel(funcs: List[CheckFn], workers: int, timeout: float) -> List[CheckResult]:
|
|
774
|
+
"""Parallel execution preserving submission order, with per-check timeout."""
|
|
775
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
776
|
+
from concurrent.futures import TimeoutError as FuturesTimeoutError
|
|
777
|
+
|
|
778
|
+
results: List[CheckResult] = []
|
|
779
|
+
with ThreadPoolExecutor(max_workers=max(1, int(workers))) as pool:
|
|
780
|
+
futures = [(fn, pool.submit(fn)) for fn in funcs]
|
|
781
|
+
for fn, fut in futures:
|
|
782
|
+
try:
|
|
783
|
+
outcome = fut.result(timeout=timeout)
|
|
784
|
+
except FuturesTimeoutError:
|
|
785
|
+
logger.warning(f"audit check {fn.__name__} timed out after {timeout}s")
|
|
786
|
+
results.append(CheckResult(fn.__name__.lstrip("_"), "skip", f"timeout (>{timeout:.0f}s)"))
|
|
787
|
+
continue
|
|
788
|
+
except Exception as exc:
|
|
789
|
+
logger.warning(f"audit check {fn.__name__} failed: {type(exc).__name__}: {exc}")
|
|
790
|
+
results.append(CheckResult(fn.__name__.lstrip("_"), "skip", f"check error: {exc}"))
|
|
791
|
+
continue
|
|
792
|
+
if isinstance(outcome, list):
|
|
793
|
+
results.extend(outcome)
|
|
794
|
+
else:
|
|
795
|
+
results.append(outcome)
|
|
796
|
+
return results
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def filter_by_status(results: List[CheckResult], statuses: List[str]) -> List[CheckResult]:
|
|
800
|
+
"""Keep only results whose status is in `statuses`."""
|
|
801
|
+
want = set(statuses)
|
|
802
|
+
return [r for r in results if r.status in want]
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
def summarize(results: List[CheckResult]) -> Dict[str, int]:
|
|
806
|
+
totals = {"ok": 0, "warn": 0, "fail": 0, "skip": 0}
|
|
807
|
+
for r in results:
|
|
808
|
+
totals[r.status] = totals.get(r.status, 0) + 1
|
|
809
|
+
return totals
|