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:
@@ -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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user