Files
Fenris/src/fenris/status.py
T
2026-10-05 17:28:15 +05:30

554 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 service
properties: projection facts, four separate service facts (boot enablement,
runtime activity, last collect outcome, freshness), and a journal/log 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
from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, Iterator, List, Optional, Tuple, TYPE_CHECKING
if TYPE_CHECKING:
from .status_composition import StatusComposition
from .projection import compute_projection, DISCLOSURES
from .store import SCHEMA_VERSION
from .init_system import (
query_service_state as _init_query_service_state,
journal_hint as _init_journal_hint,
)
# ---------------------------------------------------------------------------
# Freshness constants (§8.9, §8.2)
# ---------------------------------------------------------------------------
CADENCE_DEFAULT_S = 180 # 3 min
ACCURACY_SEC = 30
FRESH_THRESHOLD_S = 2 * CADENCE_DEFAULT_S + ACCURACY_SEC + 60 # 450 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 MissingStore("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 MissingStore(StoreFault):
"""No observation history has been created yet."""
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 — init-system agnostic)
# ---------------------------------------------------------------------------
# Re-export for backward compatibility with tests that import directly
query_service_state = _init_query_service_state
_journalctl_hint = _init_journal_hint
# ---------------------------------------------------------------------------
# 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, drive_facts: List[str],
config_error: Optional[str],
sample_count: int = 0, day_count: int = 0) -> str:
"""Format projection details; monitoring status has its own renderer."""
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")
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.")
return "\n".join(lines)
if proj is None:
lines.append("no projection available")
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("")
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
# ---------------------------------------------------------------------------
# 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."""
if service.get("boot_enabled") is None:
return "monitoring: boot persistence unknown"
return _CONTINUITY_ACTIVE if service["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
# ---------------------------------------------------------------------------
@contextmanager
def read_status(
store_path: Optional[Path] = None,
clock_now: Optional[datetime] = None,
query_services: bool = True,
collecting: bool = False,
reduced_motion: bool = False,
) -> Iterator[Tuple[Optional[sqlite3.Connection], "StatusComposition"]]:
"""Yield a read-only store snapshot and its composed monitoring status.
Own acquisition, fault classification, and connection lifetime for both
renderers. An absent store is empty; an unreadable or newer store exposes
no connection. Unknown monitoring facts are never coerced to disabled.
"""
from .status_composition import compose_status
clock_now = clock_now or datetime.now(timezone.utc)
service = None
if query_services:
try:
service = query_service_state()
except (OSError, RuntimeError):
pass
conn = None
store_fault = newer_schema = None
try:
try:
conn = open_store_readonly(store_path or Path("/var/lib/fenris/observations.db"))
conn.execute("BEGIN")
except MissingStore:
pass
except (StoreFault, sqlite3.Error) as exc:
store_fault = str(exc)
except NewerSchema as exc:
newer_schema = str(exc)
comp = compose_status(
conn, service, clock_now, store_fault=store_fault,
newer_schema=newer_schema, collecting=collecting,
reduced_motion=reduced_motion,
)
if conn is not None and (comp.store_fault or comp.newer_schema):
conn.close()
conn = None
yield conn, comp
finally:
if conn is not None:
conn.close()
def get_status(store_path: Optional[Path] = None, clock_now: Optional[datetime] = None,
query_services: bool = True, query_journal: bool = True) -> str:
"""Render CLI status through the shared read-only acquisition path."""
from .status_composition import render_status_cli
clock_now = clock_now or datetime.now(timezone.utc)
config_error = None
try:
read_config()
except ConfigError as exc:
config_error = str(exc)
with read_status(store_path, clock_now, query_services) as (conn, comp):
parts = [render_status_cli(comp)]
if not comp.store_fault and not comp.newer_schema:
drive_facts = []
proj = None
if conn is not None:
try:
drive_facts = _query_drive_facts(conn)
proj = compute_projection(conn, clock_now)
except (sqlite3.Error, ValueError, TypeError):
pass
parts.append(_format_projection(
proj, comp.freshness, drive_facts, config_error,
comp.sample_count, comp.day_count,
))
if query_journal and (
comp.store_fault or comp.last_collect_ok is False
or comp.freshness in ("missed", "stale")
):
hint = _journalctl_hint()
if hint:
parts.append("Recent collector logs:\n" + hint)
return "\n\n".join(part for part in parts if part)
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':
"""Return monitoring status without retaining the read-only snapshot."""
with read_status(
store_path, clock_now, query_services, collecting, reduced_motion,
) as (_, comp):
return comp