554 lines
19 KiB
Python
554 lines
19 KiB
Python
"""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
|