feat(status): read-only CLI status command (issue #27)

Implement fenris status as the read-only CLI twin of the TUI,
composing from the observation store and allow-listed systemctl
properties per spec section 8.8.

New module src/fenris/status.py:
- Freshness grading with shared constants (section 8.9, LC-10)
- Configuration error from direct config reads (section 8.3, LC-4)
- Store fault / newer-schema exact phrases (section 9.4-9.5, FL-4/FL-5)
- Drive anomalies as ordinary facts (section 9.7, FL-7)
- Four separate service facts (section 7.3, LC-9, CI-2)
- Projection recomputed on read, never stored (section 6.10)
- Retired command rejection with migration pointers (section 8.8)
- Six disclosures via --disclosures flag (section 6.11, CI-4)

Updated fenris.py:
- Replaced old cmd_status with new status module integration
- Added retired command handlers (start/stop/run)
- Added global --device flag rejection

Tests: 43 new, 182 total passing, zero regressions.
Closes #27.
This commit is contained in:
xavierk
2026-09-01 23:49:23 +05:30
parent b99ebfe9dd
commit 7f006c7df7
3 changed files with 1187 additions and 40 deletions
+49 -40
View File
@@ -21,7 +21,11 @@ import mimetypes
from datetime import datetime, timezone, timedelta
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
# Add src/ to path for package imports
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(_SCRIPT_DIR, "src"))
SCRIPT_DIR = _SCRIPT_DIR
DATA_DIR = os.path.join(SCRIPT_DIR, "data")
DATA_FILE = os.path.join(DATA_DIR, "history.jsonl")
HOURLY_FILE = os.path.join(DATA_DIR, "hourly.jsonl")
@@ -973,38 +977,18 @@ def cmd_stop(args):
def cmd_status(args):
running = False
if os.path.exists(PID_FILE):
with open(PID_FILE) as f:
pid = int(f.read().strip())
running = pid_alive(pid)
print(f"Fenris daemon: {'RUNNING (pid ' + str(pid) + ')' if running else 'not running (stale pid file)'}")
else:
print("Fenris daemon: not running")
rows = load_history()
if rows:
summ = compute_summary(rows)
latest = rows[-1]
print(f"Samples collected: {len(rows)}")
print(f"Last sample: {latest['ts']}")
print(f"Wear (percentage_used): {latest.get('percentage_used')}%")
print(f"Total written: {latest.get('bytes_written', 0) / 1e9:.1f} GB")
print(f"Written (24h rolling): {summ['window24h']['gb']:.2f} GB over {summ['window24h']['coverage_hours']:.1f}h")
print(f"Write rate: {summ['gb_per_hour']:.2f} GB/h ({summ['gb_per_day']:.1f} GB/day)")
if summ["seconds_remaining"]:
print(f"Projected life remaining: {summ['breakdown']['human']} (≈{summ['breakdown']['days']:.0f} days / {summ['breakdown']['hours']:.0f} hours / {summ['breakdown']['years']:.2f} years)")
print(f"Endurance: {summ['endurance_tb']:.1f} TB total, {summ['remaining_tb']:.1f} TB remaining" + (" (estimated)" if summ["endurance_estimated"] else ""))
if summ["wear_model_days"]:
print(f"Wear-model cross-check: ~{summ['wear_model_days']:.0f} days at current wear rate")
if summ["preliminary"]:
print("Note: preliminary — less than 24h coverage")
else:
print("Projected life remaining: — (no writes in window or no endurance data)")
hourly = load_hourly()
if hourly:
print(f"Hourly buckets: {len(hourly)} (last {hourly[-1]['hour']}: {hourly[-1]['bytes_written']/1e9:.2f} GB)")
else:
print("No samples collected yet.")
try:
from fenris.status import render_status, check_retired_flag
from pathlib import Path
show_disclosures = getattr(args, "disclosures", False)
store_path = Path("/var/lib/fenris/observations.db")
output = render_status(store_path=store_path, show_disclosures=show_disclosures)
print(output)
except ImportError:
# Fallback if fenris package not importable
print("Error: cannot import fenris.status module. Is the package installed?")
sys.exit(1)
def cmd_run(args):
@@ -1023,8 +1007,22 @@ def cmd_sample_once(args):
sys.exit(1)
def cmd_retired(args):
"""Handle retired commands with migration pointers (§8.8)."""
from fenris.status import check_retired_command
cmd = sys.argv[1] if len(sys.argv) > 1 else ""
ptr = check_retired_command(cmd)
if ptr:
print(ptr)
else:
print("Unknown command. Use 'fenris status' or 'fenris sample'.")
sys.exit(1)
def main():
p = argparse.ArgumentParser(description="Fenris — NVMe wear monitor & dashboard (by Bongbetic)")
p.add_argument("--device", default=None,
help="(retired — device is configured in /etc/fenris/fenris.conf)")
sub = p.add_subparsers(dest="cmd", required=True)
def add_common(sp):
@@ -1032,19 +1030,30 @@ def main():
sp.add_argument("--interval", type=int, default=300, help="seconds between samples (default 300)")
sp.add_argument("--port", type=int, default=8420, help="dashboard HTTP port (default 8420)")
sp = sub.add_parser("start", help="start monitoring in background")
add_common(sp); sp.set_defaults(func=cmd_start)
sp = sub.add_parser("stop", help="stop background monitoring")
sp.set_defaults(func=cmd_stop)
sp = sub.add_parser("status", help="show daemon + latest wear stats")
sp = sub.add_parser("start", help="(retired — use 'fenris monitor resume')")
add_common(sp); sp.set_defaults(func=cmd_retired)
sp = sub.add_parser("stop", help="(retired — use 'fenris monitor pause')")
sp.set_defaults(func=cmd_retired)
sp = sub.add_parser("status", help="show read-only status (§8.8)")
sp.add_argument("-d", "--disclosures", action="store_true",
help="show the six disclosures (§6.11)")
sp.set_defaults(func=cmd_status)
sp = sub.add_parser("run", help="run in foreground (used internally by 'start')")
add_common(sp); sp.set_defaults(func=cmd_run)
sp = sub.add_parser("run", help="(retired — use 'fenris monitor resume')")
add_common(sp); sp.set_defaults(func=cmd_retired)
sp = sub.add_parser("sample", help="take one sample immediately and print it")
sp.add_argument("--device", default=detect_device())
sp.set_defaults(func=cmd_sample_once)
args = p.parse_args()
# Reject retired --device flag (§8.8) — only if explicitly passed
if getattr(args, "device", None) is not None:
from fenris.status import check_retired_flag
ptr = check_retired_flag("--device")
if ptr:
print(ptr)
sys.exit(1)
args.func(args)