"""Read-only CLI status command: the CLI twin of the TUI (spec §8.8, LC-9, CI-2). Composes from the observation store (read-only) and allow-listed systemctl properties: projection facts, four separate service facts (boot enablement, runtime activity, last collect outcome, freshness), and a journalctl hint on failure or staleness. Never auto-samples, never prompts. Freshness constants are defined once here and shared with the TUI (§8.9): fresh — newest sample within 2 × cadence + AccuracySec + 60 s missed — between fresh and 48 h stale — ≥ 48 h empty — no observations yet Criteria: LC-9, CI-2, CI-4, FL-4, FL-5, FL-7. """ import sqlite3 import subprocess import sys from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any, Dict, List, Optional, Tuple from .projection import compute_projection, ConfidenceState, DISCLOSURES from .store import SCHEMA_VERSION # --------------------------------------------------------------------------- # Freshness constants (§8.9, §8.2) # --------------------------------------------------------------------------- CADENCE_DEFAULT_S = 300 # 5 min ACCURACY_SEC = 30 FRESH_THRESHOLD_S = 2 * CADENCE_DEFAULT_S + ACCURACY_SEC + 60 # 690 s STALENESS_THRESHOLD_S = 48 * 3600 # 48 h # --------------------------------------------------------------------------- # Configuration reading (§8.3) # --------------------------------------------------------------------------- CONFIG_PATH = Path("/etc/fenris/fenris.conf") def read_config() -> Dict[str, Any]: """Read the world-readable configuration file. Returns a dict with at least 'device'. Raises ConfigError with a reason string on any failure. """ if not CONFIG_PATH.exists(): raise ConfigError("configuration file not found at %s" % CONFIG_PATH) try: text = CONFIG_PATH.read_text() except OSError as e: raise ConfigError("cannot read %s: %s" % (CONFIG_PATH, e)) device = None for line in text.splitlines(): line = line.strip() if not line or line.startswith("#"): continue if "=" in line: key, _, value = line.partition("=") key = key.strip() value = value.strip().strip('"').strip("'") if key == "device": device = value break if not device: raise ConfigError("no device selector in %s" % CONFIG_PATH) return {"device": device} class ConfigError(Exception): """Configuration is invalid — surfaced in status as a fact (§8.3).""" pass # --------------------------------------------------------------------------- # Store opening (read-only, §3, §9.4, §9.5) # --------------------------------------------------------------------------- def open_store_readonly(store_path: Path) -> sqlite3.Connection: """Open the observation store read-only. Raises StoreFault if unreadable, NewerSchema if user_version > SCHEMA_VERSION. """ try: exists = store_path.exists() except OSError as e: # A non-group user stat()ing a 2750 store directory gets # PermissionError before any StoreFault can be raised (issue #54). raise StoreFault("observation store not readable: %s" % e) if not exists: raise StoreFault("observation store not found at %s" % store_path) try: conn = sqlite3.connect("file:%s?mode=ro" % store_path, uri=True) conn.row_factory = sqlite3.Row except sqlite3.Error as e: raise StoreFault("observation store unreadable: %s" % e) try: cursor = conn.execute("PRAGMA user_version") version = cursor.fetchone()[0] except sqlite3.Error as e: conn.close() raise StoreFault("observation store unreadable: %s" % e) if version > SCHEMA_VERSION: conn.close() raise NewerSchema(version) return conn class StoreFault(Exception): """Store is present but unreadable or corrupt (§9.4).""" pass class NewerSchema(Exception): """Store has a newer user_version (§9.5).""" def __init__(self, version: int): self.version = version super().__init__("schema version %d" % version) # --------------------------------------------------------------------------- # Service state queries (§8.8 — allow-listed systemctl properties) # --------------------------------------------------------------------------- def _systemctl_show(unit: str, *properties: str) -> Dict[str, str]: """Query systemctl show for specific properties. Returns empty dict on failure.""" try: result = subprocess.run( ["systemctl", "show", unit, "--property=" + ",".join(properties)], capture_output=True, text=True, timeout=5, ) if result.returncode != 0: return {} out = {} for line in result.stdout.splitlines(): if "=" in line: key, _, value = line.partition("=") out[key.strip()] = value.strip() return out except (subprocess.TimeoutExpired, FileNotFoundError, OSError): return {} def _journalctl_hint(unit: str, lines: int = 5) -> Optional[str]: """Get the last N journal lines for a unit. Returns None on failure.""" try: result = subprocess.run( ["journalctl", "-u", unit, "--no-pager", "-n", str(lines), "--output=short-iso"], capture_output=True, text=True, timeout=5, ) if result.returncode != 0 or not result.stdout.strip(): return None return result.stdout.strip() except (subprocess.TimeoutExpired, FileNotFoundError, OSError): return None def query_service_state() -> Dict[str, Any]: """Query systemctl for the four separate service facts (§7.3, LC-9). Returns dict with keys: boot_enabled: bool timer_active: bool last_collect_ok: Optional[bool] last_collect_age_s: Optional[int] last_collect_reason: Optional[str] """ timer_props = _systemctl_show( "fenris-collect.timer", "UnitFileState", "ActiveState", "LastTriggerUSec", ) service_props = _systemctl_show( "fenris-collect.service", "ActiveState", "ExecMainStatus", "ExecMainExitTimestamp", ) boot_enabled_str = timer_props.get("UnitFileState", "") boot_enabled = boot_enabled_str == "enabled" active_state = timer_props.get("ActiveState", "inactive") timer_active = active_state == "active" last_collect_ok = None last_collect_age_s = None last_collect_reason = None last_trigger = timer_props.get("LastTriggerUSec", "") if last_trigger and last_trigger != "n/a": try: trigger_dt = datetime.fromisoformat(last_trigger.replace("Z", "+00:00")) now = datetime.now(timezone.utc) last_collect_age_s = int((now - trigger_dt).total_seconds()) except (ValueError, TypeError): pass exec_status = service_props.get("ExecMainStatus", "") if exec_status: try: exit_code = int(exec_status) last_collect_ok = exit_code == 0 if exit_code != 0: last_collect_reason = "exit code %d" % exit_code except (ValueError, TypeError): pass return { "boot_enabled": boot_enabled, "timer_active": timer_active, "last_collect_ok": last_collect_ok, "last_collect_age_s": last_collect_age_s, "last_collect_reason": last_collect_reason, } # --------------------------------------------------------------------------- # Freshness grading (§8.9) # --------------------------------------------------------------------------- def grade_freshness(newest_sample_ts: Optional[str], clock_now: datetime) -> str: """Grade freshness from the newest sample timestamp (never a stored flag). Returns 'fresh', 'missed', 'stale', or 'empty'. """ if newest_sample_ts is None: return "empty" try: ts = datetime.fromisoformat(newest_sample_ts) if ts.tzinfo is None: ts = ts.replace(tzinfo=timezone.utc) else: ts = ts.astimezone(timezone.utc) except (ValueError, TypeError): return "empty" age_s = (clock_now - ts).total_seconds() if age_s <= FRESH_THRESHOLD_S: return "fresh" elif age_s < STALENESS_THRESHOLD_S: return "missed" else: return "stale" def freshness_age_human(age_s: Optional[int]) -> str: """Human-readable age string for freshness fact.""" if age_s is None: return "unknown age" if age_s < 60: return "%ds ago" % age_s if age_s < 3600: return "%dm ago" % (age_s // 60) if age_s < 86400: return "%dh %dm ago" % (age_s // 3600, (age_s % 3600) // 60) return "%dd ago" % (age_s // 86400) # --------------------------------------------------------------------------- # Drive anomalies (§9.7 — FL-7) # --------------------------------------------------------------------------- def _query_drive_facts(conn: sqlite3.Connection) -> List[str]: """Query drive-reported anomalies from the latest sample (§9.7, FL-7). critical_warning, media errors, and unsafe shutdowns render as ordinary facts and never affect the projection. """ cursor = conn.execute( "SELECT critical_warning, media_errors, unsafe_shutdowns, " "temperature_c, available_spare " "FROM samples ORDER BY id DESC LIMIT 1" ) row = cursor.fetchone() if row is None: return [] facts = [] cw = row[0] if cw and cw != 0: facts.append("critical warning: %s" % hex(cw) if isinstance(cw, int) else str(cw)) me = row[1] if me and me > 0: facts.append("media errors: %d" % me) us = row[2] if us and us > 0: facts.append("unsafe shutdowns: %d" % us) return facts # --------------------------------------------------------------------------- # Retired command rejection (§8.8) # --------------------------------------------------------------------------- RETIRED_COMMANDS = { "start": "use 'fenris monitor resume' to enable monitoring", "stop": "use 'fenris monitor pause' to disable monitoring", "run": "use 'fenris monitor resume' to enable monitoring; the timer runs in the background", } MIGRATION_POINTERS = { "--device": "device is configured in /etc/fenris/fenris.conf", } def check_retired_command(cmd: str) -> Optional[str]: """Check if a command is retired and return the migration pointer, or None.""" return RETIRED_COMMANDS.get(cmd) def check_retired_flag(flag: str) -> Optional[str]: """Check if a flag is retired and return the migration pointer, or None.""" return MIGRATION_POINTERS.get(flag) # --------------------------------------------------------------------------- # Formatting # --------------------------------------------------------------------------- def _format_projection(proj, freshness: str, service: Dict[str, Any], drive_facts: List[str], config_error: Optional[str], store_fault: Optional[str], newer_schema: Optional[str], journal_hint: Optional[str], sample_count: int = 0, day_count: int = 0) -> str: """Format the complete status output.""" lines = [] # --- Store/system fault overrides (§9.4, §9.5) --- if store_fault: lines.append("observation store unreadable") if journal_hint: lines.append("") lines.append("Recent journal entries:") lines.append(journal_hint) return "\n".join(lines) if newer_schema: lines.append("observation store written by a newer Fenris — upgrade Fenris") return "\n".join(lines) # --- Configuration error (§8.3) --- if config_error: lines.append("configuration error: %s" % config_error) lines.append("") # --- Empty store (§8.9) --- if freshness == "empty": lines.append("no observations yet") lines.append("") lines.append("Enable monitoring: fenris monitor resume") _append_service_facts(lines, service) return "\n".join(lines) # --- Single sample: awaiting another sample (issue #73 AC3) --- # Only show awaiting state when there are no day aggregates (e.g., legacy import # or hand-crafted stores can have 1 sample but sufficient day data for projection) if sample_count <= 1 and day_count == 0: lines.append("awaiting another sample") lines.append("") lines.append("Collecting usage data — the first projection requires at least two samples.") _append_service_facts(lines, service) return "\n".join(lines) # --- Projection headline --- headline = _format_headline(proj) lines.append(headline) lines.append("") # --- Confidence state + contributing facts (§6.7, §6.11) --- state_label = proj.confidence_state.value facts_list = list(proj.contributing_facts) if proj.contributing_facts else [] # Issue #77: Show qualifying day progress for honesty if proj.qualifying_days_progress: facts_list.insert(0, proj.qualifying_days_progress) if facts_list: facts_str = " · ".join(facts_list) lines.append("%s evidence · %s" % (state_label, facts_str)) else: lines.append("%s evidence" % state_label) lines.append("") # --- Scenario range (§6.5) with horizon reasons --- if proj.scenario_range: parts = [] for horizon in sorted(proj.scenario_range.rates.keys()): rate_gb_day = proj.scenario_range.rates[horizon] * 86400 / 1e9 parts.append("%dd: %.2f GB/day" % (horizon, rate_gb_day)) for horizon, reason in sorted(proj.scenario_range.horizon_reasons.items()): parts.append("%dd: %s" % (horizon, reason)) if parts: lines.append("scenario range: %s" % " · ".join(parts)) lines.append("") # --- PU context line (§6.1) --- lines.append(proj.pu_context_line) lines.append("") # --- Drive anomalies (§9.7, FL-7) --- if drive_facts: for fact in drive_facts: lines.append(fact) lines.append("") # --- Four separate service facts (§7.3, LC-9) --- _append_service_facts(lines, service) # --- Journal hint on failure or staleness (§8.8) --- if journal_hint: if freshness in ("missed", "stale"): lines.append("") lines.append("Recent journal entries:") lines.append(journal_hint) return "\n".join(lines) def _format_headline(proj) -> str: """Format the lifespan headline or its no-projection wording (§6.11).""" if proj.headline_remaining_seconds is None: if proj.zero_rate_fact: return "no finite projection from this history" if proj.warming_fact: return proj.warming_fact return "no projection available" secs = proj.headline_remaining_seconds if secs <= 0: return "endurance exhausted" # Human-readable time years = int(secs // 31557600) rem = secs % 31557600 days = int(rem // 86400) rem %= 86400 hours = int(rem // 3600) parts = [] if years: parts.append("%d yr" % years) if days or years: parts.append("%d d" % days) parts.append("%d h" % hours) remaining_human = " ".join(parts) # Regime line regime_parts = [] if proj.regime_days: regime_parts.append("sustained regime: %d days" % proj.regime_days) headline = "%s remaining" % remaining_human if regime_parts: headline += " · %s" % " · ".join(regime_parts) return headline def _append_service_facts(lines: List[str], service: Dict[str, Any]) -> None: """Append service facts and dashboard-clarity monitoring state.""" boot = "enabled" if service.get("boot_enabled") else "disabled" activity = "active" if service.get("timer_active") else "inactive" if service.get("last_collect_ok") is True: collect = "ok" elif service.get("last_collect_ok") is False: collect = "FAILED" if service.get("last_collect_reason"): collect += " (%s)" % service["last_collect_reason"] else: collect = "unknown" collect_age = "" if service.get("last_collect_age_s") is not None: collect_age = " %s" % freshness_age_human(service["last_collect_age_s"]) freshness_str = service.get("freshness", "unknown") freshness_age = "" if service.get("freshness_age_s") is not None: freshness_age = " (%s)" % freshness_age_human(service["freshness_age_s"]) lines.append("boot: %s · timer: %s · last collect: %s%s · freshness: %s%s" % (boot, activity, collect, collect_age, freshness_str, freshness_age)) lines.append("CONTINUITY: %s" % monitoring_continuity(service)) if service.get("deliberately_paused"): lines.extend(deliberate_pause_lines()) # --------------------------------------------------------------------------- # Dashboard clarity parity wording (DC-2, DC-3) # --------------------------------------------------------------------------- _CONTINUITY_ACTIVE = "monitoring: active in background · persists across reboots" _CONTINUITY_DISABLED = "monitoring: does not start on next boot" _PAUSED_TITLE = "monitoring: paused — deliberate disable" _PAUSED_CONSEQUENCE = ( "paused time is excluded from your usage habit · resume: fenris monitor resume" ) def monitoring_continuity(service: Dict[str, Any]) -> str: """Return the boot-persistence wording, independent of timer runtime.""" return _CONTINUITY_ACTIVE if service.get("boot_enabled") else _CONTINUITY_DISABLED def deliberate_pause_lines() -> List[str]: """Return the exact CLI/TUI presentation for a sanctioned pause.""" return [_PAUSED_TITLE, _PAUSED_CONSEQUENCE] def is_deliberately_paused(conn: sqlite3.Connection, service: Dict[str, Any]) -> bool: """Whether the latest closed period was ended by Fenris's own pause path. Raw systemd operations have no `user_disabled` row, so they must never be presented as a Deliberate disable. A live enabled timer also wins over a stale period marker, keeping the presentation consistent with service facts. """ if service.get("boot_enabled") or service.get("timer_active"): return False open_period = conn.execute( "SELECT 1 FROM monitoring_periods WHERE ended_at IS NULL LIMIT 1" ).fetchone() if open_period is not None: return False row = conn.execute( "SELECT end_cause FROM monitoring_periods " "WHERE ended_at IS NOT NULL " "ORDER BY ended_at DESC, id DESC LIMIT 1" ).fetchone() return row is not None and row[0] == "user_disabled" def format_disclosures() -> str: """Format the six disclosures (§6.11, CI-4).""" lines = [] lines.append("Disclosures") lines.append("") for i, disc in enumerate(DISCLOSURES, 1): lines.append("%d. %s" % (i, disc)) return "\n".join(lines) # --------------------------------------------------------------------------- # Main status entry point # --------------------------------------------------------------------------- def get_status(store_path: Optional[Path] = None, clock_now: Optional[datetime] = None, query_services: bool = True, query_journal: bool = True) -> str: """Render the complete read-only status (§8.8, LC-9). This is the single entry point for 'fenris status'. It never auto-samples, never prompts, and never writes to the store. """ if clock_now is None: clock_now = datetime.now(timezone.utc) # --- Configuration (§8.3) --- config_error = None device = None try: config = read_config() device = config["device"] except ConfigError as e: config_error = str(e) # --- Service state --- service = {} if query_services: service = query_service_state() # --- Store open --- store_fault = None newer_schema = None conn = None if store_path is None: store_path = Path("/var/lib/fenris/observations.db") try: conn = open_store_readonly(store_path) except StoreFault as e: store_fault = str(e) except NewerSchema as e: newer_schema = str(e) # --- Store fault / newer schema short-circuit --- if store_fault or newer_schema: journal_hint = None if query_journal: journal_hint = _journalctl_hint("fenris-collect.service") service["freshness"] = "unknown" service["freshness_age_s"] = None return _format_projection( None, "unknown", service, [], config_error, store_fault, newer_schema, journal_hint ) # --- Freshness grading (§8.9) --- try: cursor = conn.execute("SELECT ts FROM samples ORDER BY id DESC LIMIT 1") row = cursor.fetchone() newest_ts = row[0] if row else None except sqlite3.Error: newest_ts = None freshness = grade_freshness(newest_ts, clock_now) # --- Sample count for single-sample state (issue #73 AC3) --- sample_count = 0 day_count = 0 try: cursor = conn.execute("SELECT COUNT(*) FROM samples") sample_count = cursor.fetchone()[0] cursor = conn.execute("SELECT COUNT(*) FROM day_aggregates") day_count = cursor.fetchone()[0] except sqlite3.Error: pass # Freshness age for the service fact freshness_age_s = None if newest_ts: try: ts = datetime.fromisoformat(newest_ts) if ts.tzinfo is None: ts = ts.replace(tzinfo=timezone.utc) freshness_age_s = int((clock_now - ts).total_seconds()) except (ValueError, TypeError): pass service["freshness"] = freshness service["freshness_age_s"] = freshness_age_s try: service["deliberately_paused"] = is_deliberately_paused(conn, service) except sqlite3.Error: service["deliberately_paused"] = False # --- Drive anomalies (§9.7, FL-7) --- drive_facts = [] try: drive_facts = _query_drive_facts(conn) except sqlite3.Error: pass # --- Projection (§6 — recomputed on read, never stored) --- try: proj = compute_projection(conn, clock_now) except Exception: proj = None # --- Journal hint on failure or staleness (§8.8) --- journal_hint = None if query_journal and freshness in ("missed", "stale"): journal_hint = _journalctl_hint("fenris-collect.service") # --- Compose output --- result = _format_projection( proj, freshness, service, drive_facts, config_error, None, None, journal_hint, sample_count, day_count, ) conn.close() return result def render_status(store_path: Optional[Path] = None, clock_now: Optional[datetime] = None, query_services: bool = True, query_journal: bool = True, show_disclosures: bool = False) -> str: """High-level status renderer: status + optional disclosures. Used by the CLI entry point. """ parts = [get_status(store_path, clock_now, query_services, query_journal)] if show_disclosures: parts.append("") parts.append(format_disclosures()) return "\n\n".join(parts) # --------------------------------------------------------------------------- # Shared status composition integration (issue #78) # --------------------------------------------------------------------------- def get_status_composition( store_path: Optional[Path] = None, clock_now: Optional[datetime] = None, query_services: bool = True, collecting: bool = False, reduced_motion: bool = False, ) -> 'StatusComposition': """Get the shared status composition consumed by both TUI and CLI. This is the new entry point that centralizes the status lattice. """ # Lazy import to avoid circular dependency from .status_composition import ( StatusComposition, compose_status, ) if clock_now is None: clock_now = datetime.now(timezone.utc) # --- Configuration (§8.3) --- # Config errors are surfaced through the store fault mechanism # --- Service state --- service = {} if query_services: try: service = query_service_state() except Exception: service = None # --- Store open --- store_fault = None newer_schema = None conn = None if store_path is None: store_path = Path("/var/lib/fenris/observations.db") try: conn = open_store_readonly(store_path) except StoreFault as e: store_fault = str(e) except NewerSchema as e: newer_schema = str(e) # --- Use shared composition --- if conn is not None: try: comp = compose_status( conn, service, clock_now, store_fault=store_fault, newer_schema=newer_schema, collecting=collecting, reduced_motion=reduced_motion, ) finally: conn.close() else: # Store fault or newer schema - compose without store data comp = compose_status( None, service, clock_now, store_fault=store_fault, newer_schema=newer_schema, collecting=collecting, reduced_motion=reduced_motion, ) return comp