wraith-cli 1.6.0__tar.gz → 1.7.0__tar.gz

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.
Files changed (56) hide show
  1. wraith_cli-1.7.0/.claude/settings.local.json +13 -0
  2. {wraith_cli-1.6.0 → wraith_cli-1.7.0}/CHANGELOG.md +2 -0
  3. {wraith_cli-1.6.0 → wraith_cli-1.7.0}/PKG-INFO +27 -4
  4. {wraith_cli-1.6.0 → wraith_cli-1.7.0}/README.md +26 -3
  5. wraith_cli-1.7.0/docs/nas.md +88 -0
  6. {wraith_cli-1.6.0 → wraith_cli-1.7.0}/docs/usage.md +224 -0
  7. {wraith_cli-1.6.0 → wraith_cli-1.7.0}/mkdocs.yml +1 -0
  8. {wraith_cli-1.6.0 → wraith_cli-1.7.0}/pyproject.toml +1 -1
  9. {wraith_cli-1.6.0 → wraith_cli-1.7.0}/src/wraith_cli/config.py +10 -0
  10. wraith_cli-1.7.0/src/wraith_cli/doctor.py +216 -0
  11. {wraith_cli-1.6.0 → wraith_cli-1.7.0}/src/wraith_cli/main.py +311 -1
  12. wraith_cli-1.7.0/src/wraith_cli/nas.py +353 -0
  13. wraith_cli-1.7.0/src/wraith_cli/notify.py +35 -0
  14. wraith_cli-1.7.0/src/wraith_cli/report.py +85 -0
  15. wraith_cli-1.7.0/src/wraith_cli/shares.py +98 -0
  16. wraith_cli-1.7.0/src/wraith_cli/snapshots.py +133 -0
  17. {wraith_cli-1.6.0 → wraith_cli-1.7.0}/tests/test_cli.py +9 -3
  18. wraith_cli-1.7.0/tests/test_doctor.py +238 -0
  19. wraith_cli-1.7.0/tests/test_nas.py +366 -0
  20. wraith_cli-1.7.0/tests/test_nas_cli.py +280 -0
  21. wraith_cli-1.7.0/tests/test_notify.py +51 -0
  22. wraith_cli-1.7.0/tests/test_report.py +94 -0
  23. wraith_cli-1.7.0/tests/test_shares.py +112 -0
  24. wraith_cli-1.7.0/tests/test_snapshots.py +157 -0
  25. {wraith_cli-1.6.0 → wraith_cli-1.7.0}/uv.lock +1 -1
  26. {wraith_cli-1.6.0 → wraith_cli-1.7.0}/.cz.yaml +0 -0
  27. {wraith_cli-1.6.0 → wraith_cli-1.7.0}/.env.example +0 -0
  28. {wraith_cli-1.6.0 → wraith_cli-1.7.0}/.gitea/CODEOWNERS.md +0 -0
  29. {wraith_cli-1.6.0 → wraith_cli-1.7.0}/.gitea/PULL_REQUEST_TEMPLATE.md +0 -0
  30. {wraith_cli-1.6.0 → wraith_cli-1.7.0}/.gitea/workflows/pages.yml +0 -0
  31. {wraith_cli-1.6.0 → wraith_cli-1.7.0}/.gitea/workflows/release.yml +0 -0
  32. {wraith_cli-1.6.0 → wraith_cli-1.7.0}/.gitea/workflows/test.yml +0 -0
  33. {wraith_cli-1.6.0 → wraith_cli-1.7.0}/.gitignore +0 -0
  34. {wraith_cli-1.6.0 → wraith_cli-1.7.0}/.pre-commit-config.yaml +0 -0
  35. {wraith_cli-1.6.0 → wraith_cli-1.7.0}/.secrets.baseline +0 -0
  36. {wraith_cli-1.6.0 → wraith_cli-1.7.0}/CONTRIBUTING.md +0 -0
  37. {wraith_cli-1.6.0 → wraith_cli-1.7.0}/LICENSE +0 -0
  38. {wraith_cli-1.6.0 → wraith_cli-1.7.0}/bin/build.sh +0 -0
  39. {wraith_cli-1.6.0 → wraith_cli-1.7.0}/bin/run_tests.sh +0 -0
  40. {wraith_cli-1.6.0 → wraith_cli-1.7.0}/bin/setup_venv.sh +0 -0
  41. {wraith_cli-1.6.0 → wraith_cli-1.7.0}/docs/architecture.md +0 -0
  42. {wraith_cli-1.6.0 → wraith_cli-1.7.0}/docs/index.md +0 -0
  43. {wraith_cli-1.6.0 → wraith_cli-1.7.0}/docs/reference.md +0 -0
  44. {wraith_cli-1.6.0 → wraith_cli-1.7.0}/docs/security.md +0 -0
  45. {wraith_cli-1.6.0 → wraith_cli-1.7.0}/src/wraith_cli/__init__.py +0 -0
  46. {wraith_cli-1.6.0 → wraith_cli-1.7.0}/src/wraith_cli/assets.py +0 -0
  47. {wraith_cli-1.6.0 → wraith_cli-1.7.0}/src/wraith_cli/providers.py +0 -0
  48. {wraith_cli-1.6.0 → wraith_cli-1.7.0}/src/wraith_cli/qol.py +0 -0
  49. {wraith_cli-1.6.0 → wraith_cli-1.7.0}/src/wraith_cli/repo_make.py +0 -0
  50. {wraith_cli-1.6.0 → wraith_cli-1.7.0}/src/wraith_cli/shield.py +0 -0
  51. {wraith_cli-1.6.0 → wraith_cli-1.7.0}/tests/conftest.py +0 -0
  52. {wraith_cli-1.6.0 → wraith_cli-1.7.0}/tests/test_config.py +0 -0
  53. {wraith_cli-1.6.0 → wraith_cli-1.7.0}/tests/test_providers.py +0 -0
  54. {wraith_cli-1.6.0 → wraith_cli-1.7.0}/tests/test_qol.py +0 -0
  55. {wraith_cli-1.6.0 → wraith_cli-1.7.0}/tests/test_repo_make.py +0 -0
  56. {wraith_cli-1.6.0 → wraith_cli-1.7.0}/tests/test_shield.py +0 -0
@@ -0,0 +1,13 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(uv run *)",
5
+ "Bash(./bin/run_tests.sh)",
6
+ "Bash(git add *)",
7
+ "Bash(git commit -m '👻 feature/TJP-12062: add enterprise NAS management suite *)",
8
+ "Bash(python3 -)",
9
+ "Bash(git -C /home/thomaspeoples/pjt-nas/nas/SovereignDocs/gitea-repos status --short)",
10
+ "Bash(git -C /home/thomaspeoples/pjt-nas/nas/SovereignDocs/gitea-repos branch --show-current)"
11
+ ]
12
+ }
13
+ }
@@ -1,3 +1,5 @@
1
+ ## v1.7.0 (2026-06-12)
2
+
1
3
  ## v1.6.0 (2026-04-07)
2
4
 
3
5
  ## v1.5.0 (2026-04-06)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wraith-cli
3
- Version: 1.6.0
3
+ Version: 1.7.0
4
4
  Summary: Sovereign Command Centre for a Ghost Stack
5
5
  Project-URL: Homepage, https://git.thomaspeoples.com/thomaspeoples/wraith-cli
6
6
  Project-URL: Documentation, https://www.thomaspeoples.com/gitea-repos/wraith-cli/
@@ -60,12 +60,35 @@ Description-Content-Type: text/markdown
60
60
 
61
61
 
62
62
  # 👻 Wraith-CLI
63
- ### *Sovereign Orchestration for the Ghost Stack*
63
+ ### *Sovereign Orchestration & NAS Management for the Ghost Stack*
64
64
 
65
65
  **Wraith-CLI** is the nervous system of the Ghost Stack. It bridges the gap between
66
- high-level container orchestration and bare-metal reality, providing the "Ghost Factory"
67
- for instant project scaffolding.
66
+ high-level container orchestration and bare-metal reality instant project scaffolding,
67
+ container operations, and a full NAS management suite in one binary.
68
68
 
69
+ ---
70
+
71
+ ## 🗄️ NAS Management Suite
72
+
73
+ | Command | Purpose |
74
+ |---|---|
75
+ | `wraith disks` | Physical disk inventory — model, serial, capacity, HDD/SSD, bus |
76
+ | `wraith storage` | Filesystem usage with configurable warn/crit thresholds |
77
+ | `wraith raid` | mdraid array and ZFS pool health |
78
+ | `wraith smart /dev/sda` | SMART verdict + failure-predicting attributes per drive |
79
+ | `wraith shares` | NFS exports and Samba shares, guest access flagged |
80
+ | `wraith snap` | ZFS/btrfs snapshot create / list / prune (safe by design) |
81
+ | `wraith doctor` | Full health check suite — cron-friendly exit codes, webhook/ntfy alerts |
82
+ | `wraith report` | Timestamped markdown/JSON audit reports |
83
+
84
+ Every read-only command takes `--json` — pipe Wraith straight into dashboards,
85
+ monitoring stacks, or fleet tooling. Unattended monitoring is one cron line:
86
+
87
+ ```cron
88
+ */30 * * * * wraith doctor --notify
89
+ ```
90
+
91
+ See the [NAS Management docs](docs/nas.md) for full coverage.
69
92
 
70
93
  ---
71
94
 
@@ -5,12 +5,35 @@
5
5
 
6
6
 
7
7
  # 👻 Wraith-CLI
8
- ### *Sovereign Orchestration for the Ghost Stack*
8
+ ### *Sovereign Orchestration & NAS Management for the Ghost Stack*
9
9
 
10
10
  **Wraith-CLI** is the nervous system of the Ghost Stack. It bridges the gap between
11
- high-level container orchestration and bare-metal reality, providing the "Ghost Factory"
12
- for instant project scaffolding.
11
+ high-level container orchestration and bare-metal reality instant project scaffolding,
12
+ container operations, and a full NAS management suite in one binary.
13
13
 
14
+ ---
15
+
16
+ ## 🗄️ NAS Management Suite
17
+
18
+ | Command | Purpose |
19
+ |---|---|
20
+ | `wraith disks` | Physical disk inventory — model, serial, capacity, HDD/SSD, bus |
21
+ | `wraith storage` | Filesystem usage with configurable warn/crit thresholds |
22
+ | `wraith raid` | mdraid array and ZFS pool health |
23
+ | `wraith smart /dev/sda` | SMART verdict + failure-predicting attributes per drive |
24
+ | `wraith shares` | NFS exports and Samba shares, guest access flagged |
25
+ | `wraith snap` | ZFS/btrfs snapshot create / list / prune (safe by design) |
26
+ | `wraith doctor` | Full health check suite — cron-friendly exit codes, webhook/ntfy alerts |
27
+ | `wraith report` | Timestamped markdown/JSON audit reports |
28
+
29
+ Every read-only command takes `--json` — pipe Wraith straight into dashboards,
30
+ monitoring stacks, or fleet tooling. Unattended monitoring is one cron line:
31
+
32
+ ```cron
33
+ */30 * * * * wraith doctor --notify
34
+ ```
35
+
36
+ See the [NAS Management docs](docs/nas.md) for full coverage.
14
37
 
15
38
  ---
16
39
 
@@ -0,0 +1,88 @@
1
+ # NAS Management
2
+
3
+ Wraith turns your Ghost Stack node into a fully managed NAS. The
4
+ suite covers hardware introspection, capacity monitoring, share
5
+ discovery, snapshot lifecycle, and automated health enforcement —
6
+ all from one binary, all scriptable via `--json`.
7
+
8
+ ## Command Overview
9
+
10
+ | Command | What it does |
11
+ |---|---|
12
+ | `wraith disks` | Physical disk inventory: model, serial, capacity, HDD/SSD, bus |
13
+ | `wraith storage` | Filesystem usage with warn/crit thresholds |
14
+ | `wraith raid` | mdraid array and ZFS pool health |
15
+ | `wraith smart <device>` | Per-drive SMART verdict and failure-predicting attributes |
16
+ | `wraith shares` | NFS exports and Samba shares, guest-access flagged |
17
+ | `wraith snap` | ZFS/btrfs snapshot list / create / prune |
18
+ | `wraith doctor` | Full health check suite with exit codes and alerting |
19
+ | `wraith report` | Point-in-time audit report (markdown or JSON) |
20
+
21
+ Every read-only command accepts `--json` for machine-readable
22
+ output, making Wraith a drop-in data source for dashboards,
23
+ monitoring pipelines, and fleet tooling.
24
+
25
+ ## The Doctor
26
+
27
+ `wraith doctor` is the heartbeat of the system. It checks:
28
+
29
+ - Docker daemon responsiveness
30
+ - Tailscale mesh connectivity
31
+ - Storage utilisation against configured thresholds
32
+ - RAID array and ZFS pool degradation
33
+ - SMART self-assessment on every physical disk
34
+ - HTTP health of every configured stack service
35
+
36
+ It exits non-zero when anything is critical, so it slots straight
37
+ into cron:
38
+
39
+ ```cron
40
+ */30 * * * * wraith doctor --notify || logger "NAS degraded"
41
+ ```
42
+
43
+ With `--notify`, warn/crit findings are pushed to your configured
44
+ webhook or [ntfy](https://ntfy.sh) topic.
45
+
46
+ ## Configuration
47
+
48
+ Add to `.wraith.yaml`:
49
+
50
+ ```yaml
51
+ storage_warn_pct: 80
52
+ storage_crit_pct: 90
53
+ alerts:
54
+ webhook_url: https://ntfy.sh/my-nas-alerts
55
+ kind: ntfy # or "webhook" for a JSON POST
56
+ ```
57
+
58
+ ## Snapshots
59
+
60
+ Snapshot lifecycle management with a safety guarantee: `snap prune`
61
+ only ever considers snapshots Wraith itself created (the `wraith-`
62
+ prefix). Manual snapshots are never touched.
63
+
64
+ ```bash
65
+ wraith snap create tank/data # ZFS, auto-named
66
+ wraith snap create /srv/data --btrfs-dest /srv/.snapshots/nightly
67
+ wraith snap list tank/data
68
+ wraith snap prune tank/data --keep 7 # confirm before destroy
69
+ ```
70
+
71
+ A nightly rotation is two cron lines:
72
+
73
+ ```cron
74
+ 0 2 * * * wraith snap create tank/data
75
+ 30 2 * * * wraith snap prune tank/data --keep 14 --yes
76
+ ```
77
+
78
+ ## Audit Reports
79
+
80
+ `wraith report` captures disks, storage, RAID state, and the full
81
+ doctor suite into a timestamped document — an audit trail for
82
+ compliance, capacity planning, or a paper trail before and after
83
+ hardware changes.
84
+
85
+ ```bash
86
+ wraith report # markdown, timestamped name
87
+ wraith report -f json -o nightly.json # feed it to anything
88
+ ```
@@ -28,6 +28,14 @@ $ [OPTIONS] COMMAND [ARGS]...
28
28
  * `logs`: Unified hardware health and logging portal.
29
29
  * `mesh`: Map the Tailscale Ghost Mesh network.
30
30
  * `audit`: Execute a Sovereign security and...
31
+ * `disks`: Inventory the physical disks in this NAS.
32
+ * `storage`: Show filesystem usage across all mounted...
33
+ * `raid`: Report RAID array and ZFS pool health.
34
+ * `smart`: Deep-dive SMART health for a single drive.
35
+ * `shares`: List network shares exposed by this NAS.
36
+ * `doctor`: Run the full NAS health check suite.
37
+ * `report`: Generate a point-in-time NAS health report.
38
+ * `snap`: Manage ZFS/btrfs snapshots.
31
39
 
32
40
  ## `init`
33
41
 
@@ -243,3 +251,219 @@ $ audit [OPTIONS]
243
251
  **Options**:
244
252
 
245
253
  * `--help`: Show this message and exit.
254
+
255
+ ## `disks`
256
+
257
+ Inventory the physical disks in this NAS.
258
+
259
+ Enumerates block devices with model, serial, capacity,
260
+ media type (HDD/SSD), bus, and active mountpoints.
261
+
262
+ **Usage**:
263
+
264
+ ```console
265
+ $ disks [OPTIONS]
266
+ ```
267
+
268
+ **Options**:
269
+
270
+ * `--json`: Emit machine-readable JSON.
271
+ * `--help`: Show this message and exit.
272
+
273
+ ## `storage`
274
+
275
+ Show filesystem usage across all mounted volumes.
276
+
277
+ Colour-codes utilisation against the configured warn/crit
278
+ thresholds (storage_warn_pct / storage_crit_pct).
279
+
280
+ **Usage**:
281
+
282
+ ```console
283
+ $ storage [OPTIONS]
284
+ ```
285
+
286
+ **Options**:
287
+
288
+ * `--json`: Emit machine-readable JSON.
289
+ * `--help`: Show this message and exit.
290
+
291
+ ## `raid`
292
+
293
+ Report RAID array and ZFS pool health.
294
+
295
+ Parses /proc/mdstat for Linux software RAID and queries
296
+ zpool for ZFS pool state. Degraded arrays surface in red.
297
+
298
+ **Usage**:
299
+
300
+ ```console
301
+ $ raid [OPTIONS]
302
+ ```
303
+
304
+ **Options**:
305
+
306
+ * `--json`: Emit machine-readable JSON.
307
+ * `--help`: Show this message and exit.
308
+
309
+ ## `smart`
310
+
311
+ Deep-dive SMART health for a single drive.
312
+
313
+ Pulls the self-assessment verdict, temperature, power-on
314
+ hours, and failure-predicting attributes via smartctl.
315
+
316
+ **Usage**:
317
+
318
+ ```console
319
+ $ smart [OPTIONS] DEVICE
320
+ ```
321
+
322
+ **Arguments**:
323
+
324
+ * `DEVICE`: Block device path (e.g. /dev/sda) [required]
325
+
326
+ **Options**:
327
+
328
+ * `--json`: Emit machine-readable JSON.
329
+ * `--help`: Show this message and exit.
330
+
331
+ ## `shares`
332
+
333
+ List network shares exposed by this NAS.
334
+
335
+ Discovers NFS exports (/etc/exports) and Samba shares
336
+ (smb.conf), flagging guest-accessible SMB shares.
337
+
338
+ **Usage**:
339
+
340
+ ```console
341
+ $ shares [OPTIONS]
342
+ ```
343
+
344
+ **Options**:
345
+
346
+ * `--json`: Emit machine-readable JSON.
347
+ * `--help`: Show this message and exit.
348
+
349
+ ## `doctor`
350
+
351
+ Run the full NAS health check suite.
352
+
353
+ Checks Docker, Tailscale, storage thresholds, RAID/pool
354
+ health, per-disk SMART status, and service endpoints.
355
+ Exits non-zero on any critical finding — cron-friendly.
356
+
357
+ **Usage**:
358
+
359
+ ```console
360
+ $ doctor [OPTIONS]
361
+ ```
362
+
363
+ **Options**:
364
+
365
+ * `--json`: Emit machine-readable JSON.
366
+ * `--notify`: Send warn/crit findings to the configured webhook.
367
+ * `--help`: Show this message and exit.
368
+
369
+ ## `report`
370
+
371
+ Generate a point-in-time NAS health report.
372
+
373
+ Captures disks, storage, RAID state, and the full doctor
374
+ check suite into a timestamped markdown or JSON document —
375
+ an audit trail for compliance and capacity planning.
376
+
377
+ **Usage**:
378
+
379
+ ```console
380
+ $ report [OPTIONS]
381
+ ```
382
+
383
+ **Options**:
384
+
385
+ * `-o, --output PATH`: Report file destination.
386
+ * `-f, --format TEXT`: Report format: md or json. [default: md]
387
+ * `--help`: Show this message and exit.
388
+
389
+ ## `snap`
390
+
391
+ Manage ZFS/btrfs snapshots.
392
+
393
+ **Usage**:
394
+
395
+ ```console
396
+ $ snap [OPTIONS] COMMAND [ARGS]...
397
+ ```
398
+
399
+ **Options**:
400
+
401
+ * `--help`: Show this message and exit.
402
+
403
+ **Commands**:
404
+
405
+ * `list`: List ZFS snapshots, newest last.
406
+ * `create`: Create a snapshot of a ZFS dataset or...
407
+ * `prune`: Prune old wraith-created snapshots from a...
408
+
409
+ ### `snap list`
410
+
411
+ List ZFS snapshots, newest last.
412
+
413
+ **Usage**:
414
+
415
+ ```console
416
+ $ snap list [OPTIONS] [DATASET]
417
+ ```
418
+
419
+ **Arguments**:
420
+
421
+ * `[DATASET]`: Limit to one ZFS dataset.
422
+
423
+ **Options**:
424
+
425
+ * `--json`: Emit machine-readable JSON.
426
+ * `--help`: Show this message and exit.
427
+
428
+ ### `snap create`
429
+
430
+ Create a snapshot of a ZFS dataset or btrfs subvolume.
431
+
432
+ **Usage**:
433
+
434
+ ```console
435
+ $ snap create [OPTIONS] TARGET
436
+ ```
437
+
438
+ **Arguments**:
439
+
440
+ * `TARGET`: ZFS dataset (pool/data) or btrfs subvolume path. [required]
441
+
442
+ **Options**:
443
+
444
+ * `-n, --name TEXT`: Snapshot name override.
445
+ * `--btrfs-dest TEXT`: Treat target as btrfs subvolume; snapshot here.
446
+ * `--help`: Show this message and exit.
447
+
448
+ ### `snap prune`
449
+
450
+ Prune old wraith-created snapshots from a dataset.
451
+
452
+ Only snapshots named with the wraith- prefix are candidates;
453
+ manually created snapshots are never touched.
454
+
455
+ **Usage**:
456
+
457
+ ```console
458
+ $ snap prune [OPTIONS] DATASET
459
+ ```
460
+
461
+ **Arguments**:
462
+
463
+ * `DATASET`: ZFS dataset to prune. [required]
464
+
465
+ **Options**:
466
+
467
+ * `-k, --keep INTEGER`: Snapshots to retain. [default: 7]
468
+ * `-y, --yes`: Skip confirmation prompt.
469
+ * `--help`: Show this message and exit.
@@ -19,6 +19,7 @@ theme:
19
19
  nav:
20
20
  - Home: index.md
21
21
  - Usage: usage.md
22
+ - NAS Management: nas.md
22
23
  - Technical Reference: reference.md
23
24
  - Architecture Reference: architecture.md
24
25
  - Security Statement: security.md
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "wraith-cli"
7
- version = "1.6.0"
7
+ version = "1.7.0"
8
8
  description = "Sovereign Command Centre for a Ghost Stack"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -17,6 +17,11 @@ class TemplateModel(BaseModel):
17
17
  url: str
18
18
 
19
19
 
20
+ class AlertsModel(BaseModel):
21
+ webhook_url: str | None = None
22
+ kind: str = "webhook" # "webhook" (JSON POST) or "ntfy" (text POST)
23
+
24
+
20
25
  class WraithSettings(BaseSettings):
21
26
  stack_name: str = "Ghost Stack"
22
27
  viking_api: HttpUrl | None = None
@@ -25,6 +30,11 @@ class WraithSettings(BaseSettings):
25
30
  docker_enabled: bool = False
26
31
  tailscale_enabled: bool = False
27
32
 
33
+ # NAS management
34
+ alerts: AlertsModel = Field(default_factory=AlertsModel)
35
+ storage_warn_pct: int = 80
36
+ storage_crit_pct: int = 90
37
+
28
38
  # Provider Settings
29
39
  forge_api_url: str | None = None
30
40
  forge_token: str | None = None
@@ -0,0 +1,216 @@
1
+ """Full-stack NAS health check engine.
2
+
3
+ Each check returns {"name", "status", "detail"} where status is one
4
+ of: ok, warn, crit, skip. The CLI maps any crit to a non-zero exit
5
+ code so `wraith doctor` slots straight into cron and CI.
6
+ """
7
+
8
+ import shutil
9
+ import subprocess
10
+ from typing import Any
11
+
12
+ import requests
13
+ from rich.table import Table
14
+
15
+ from wraith_cli import nas
16
+ from wraith_cli.config import WraithSettings
17
+
18
+ OK = "ok"
19
+ WARN = "warn"
20
+ CRIT = "crit"
21
+ SKIP = "skip"
22
+
23
+
24
+ def _check(name: str, status: str, detail: str) -> dict[str, str]:
25
+ return {"name": name, "status": status, "detail": detail}
26
+
27
+
28
+ def check_docker(settings: WraithSettings) -> dict[str, str]:
29
+ if not settings.docker_enabled:
30
+ return _check("docker", SKIP, "Disabled in config")
31
+ result = subprocess.run(
32
+ ["docker", "info"], capture_output=True, check=False
33
+ )
34
+ if result.returncode == 0:
35
+ return _check("docker", OK, "Daemon responsive")
36
+ return _check("docker", CRIT, "Docker daemon unreachable")
37
+
38
+
39
+ def check_tailscale(settings: WraithSettings) -> dict[str, str]:
40
+ if not settings.tailscale_enabled:
41
+ return _check("tailscale", SKIP, "Disabled in config")
42
+ result = subprocess.run(
43
+ ["tailscale", "status"], capture_output=True, check=False
44
+ )
45
+ if result.returncode == 0:
46
+ return _check("tailscale", OK, "Mesh connected")
47
+ return _check("tailscale", CRIT, "Tailscale down — mesh unreachable")
48
+
49
+
50
+ def check_storage(settings: WraithSettings) -> list[dict[str, str]]:
51
+ try:
52
+ usage = nas.get_storage(
53
+ settings.storage_warn_pct, settings.storage_crit_pct
54
+ )
55
+ except Exception as e:
56
+ return [_check("storage", WARN, f"Could not read usage: {e}")]
57
+
58
+ checks = []
59
+ for u in usage:
60
+ status = {"ok": OK, "warn": WARN, "crit": CRIT}[u["level"]]
61
+ checks.append(
62
+ _check(
63
+ f"storage {u['mount']}",
64
+ status,
65
+ f"{u['used_pct']}% used, "
66
+ f"{nas.human_bytes(u['free_bytes'])} free",
67
+ )
68
+ )
69
+ return checks or [_check("storage", WARN, "No filesystems found")]
70
+
71
+
72
+ def check_raid() -> list[dict[str, str]]:
73
+ try:
74
+ raid = nas.get_raid_status()
75
+ except Exception as e:
76
+ return [_check("raid", WARN, f"Could not read RAID state: {e}")]
77
+
78
+ checks = []
79
+ for arr in raid["mdraid"]:
80
+ if arr["healthy"]:
81
+ checks.append(
82
+ _check(f"mdraid {arr['name']}", OK, arr["detail"] or "active")
83
+ )
84
+ else:
85
+ checks.append(
86
+ _check(
87
+ f"mdraid {arr['name']}",
88
+ CRIT,
89
+ f"DEGRADED: {arr['detail']}",
90
+ )
91
+ )
92
+ for pool in raid["zpools"]:
93
+ status = OK if pool["healthy"] else CRIT
94
+ checks.append(_check(f"zpool {pool['name']}", status, pool["health"]))
95
+ return checks or [_check("raid", SKIP, "No arrays or pools")]
96
+
97
+
98
+ def check_smart() -> list[dict[str, str]]:
99
+ if shutil.which("smartctl") is None:
100
+ return [_check("smart", SKIP, "smartmontools not installed")]
101
+ try:
102
+ disks = nas.get_disks()
103
+ except Exception as e:
104
+ return [_check("smart", WARN, f"Disk enumeration failed: {e}")]
105
+
106
+ checks = []
107
+ for disk in disks:
108
+ try:
109
+ smart = nas.get_smart(disk["path"])
110
+ except Exception as e:
111
+ checks.append(_check(f"smart {disk['path']}", WARN, str(e)))
112
+ continue
113
+ bad_attrs = {
114
+ k: v
115
+ for k, v in smart["attributes"].items()
116
+ if k != "Available Spare %" and v
117
+ }
118
+ if not smart["passed"]:
119
+ checks.append(
120
+ _check(
121
+ f"smart {disk['path']}",
122
+ CRIT,
123
+ "SMART self-assessment FAILED",
124
+ )
125
+ )
126
+ elif bad_attrs:
127
+ detail = ", ".join(f"{k}={v}" for k, v in bad_attrs.items())
128
+ checks.append(_check(f"smart {disk['path']}", WARN, detail))
129
+ else:
130
+ checks.append(_check(f"smart {disk['path']}", OK, "Healthy"))
131
+ return checks or [_check("smart", SKIP, "No disks found")]
132
+
133
+
134
+ def check_services(settings: WraithSettings) -> list[dict[str, str]]:
135
+ if not settings.services:
136
+ return [_check("services", SKIP, "No services configured")]
137
+ checks = []
138
+ for name, svc in settings.services.items():
139
+ if not svc.health_url:
140
+ checks.append(_check(f"service {name}", SKIP, "No health_url"))
141
+ continue
142
+ try:
143
+ resp = requests.get(str(svc.health_url), timeout=5)
144
+ if resp.status_code == 200:
145
+ checks.append(_check(f"service {name}", OK, "HTTP 200"))
146
+ else:
147
+ checks.append(
148
+ _check(
149
+ f"service {name}",
150
+ WARN,
151
+ f"HTTP {resp.status_code}",
152
+ )
153
+ )
154
+ except requests.exceptions.RequestException:
155
+ checks.append(_check(f"service {name}", CRIT, "Unreachable"))
156
+ return checks
157
+
158
+
159
+ def run_doctor(settings: WraithSettings) -> dict[str, Any]:
160
+ """Run the full check suite and summarise."""
161
+ checks: list[dict[str, str]] = []
162
+ checks.append(check_docker(settings))
163
+ checks.append(check_tailscale(settings))
164
+ checks.extend(check_storage(settings))
165
+ checks.extend(check_raid())
166
+ checks.extend(check_smart())
167
+ checks.extend(check_services(settings))
168
+
169
+ counts = {OK: 0, WARN: 0, CRIT: 0, SKIP: 0}
170
+ for c in checks:
171
+ counts[c["status"]] += 1
172
+
173
+ if counts[CRIT]:
174
+ verdict = CRIT
175
+ elif counts[WARN]:
176
+ verdict = WARN
177
+ else:
178
+ verdict = OK
179
+
180
+ return {"verdict": verdict, "counts": counts, "checks": checks}
181
+
182
+
183
+ def render_doctor(result: dict[str, Any]) -> Table:
184
+ styles = {
185
+ OK: "[green]OK[/green]",
186
+ WARN: "[yellow]WARN[/yellow]",
187
+ CRIT: "[red]CRIT[/red]",
188
+ SKIP: "[bright_black]SKIP[/bright_black]",
189
+ }
190
+ counts = result["counts"]
191
+ title = (
192
+ f"Wraith Doctor — {counts[OK]} ok, {counts[WARN]} warn, "
193
+ f"{counts[CRIT]} crit, {counts[SKIP]} skipped"
194
+ )
195
+ table = Table(
196
+ title=title,
197
+ border_style="bright_black",
198
+ header_style="bold green",
199
+ )
200
+ table.add_column("Check", style="cyan")
201
+ table.add_column("Status")
202
+ table.add_column("Detail")
203
+
204
+ for c in result["checks"]:
205
+ table.add_row(c["name"], styles[c["status"]], c["detail"])
206
+ return table
207
+
208
+
209
+ def summarise_failures(result: dict[str, Any]) -> str:
210
+ """One-line-per-failure summary for alert payloads."""
211
+ lines = [
212
+ f"[{c['status'].upper()}] {c['name']}: {c['detail']}"
213
+ for c in result["checks"]
214
+ if c["status"] in (WARN, CRIT)
215
+ ]
216
+ return "\n".join(lines)