Compare commits
3
Commits
2d4cb16a00
...
967ba6964f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
967ba6964f | ||
|
|
efcfd266b7 | ||
|
|
1113532c9a |
@@ -52,6 +52,11 @@ VENDOR_DIR="${STAGE_DIR}/opt/fenris/vendor"
|
|||||||
mkdir -p "${VENDOR_DIR}"
|
mkdir -p "${VENDOR_DIR}"
|
||||||
python3 -m pip install --disable-pip-version-check --no-compile \
|
python3 -m pip install --disable-pip-version-check --no-compile \
|
||||||
--target "${VENDOR_DIR}" -r "${REPO_ROOT}/requirements.txt" "${WHEEL}"
|
--target "${VENDOR_DIR}" -r "${REPO_ROOT}/requirements.txt" "${WHEEL}"
|
||||||
|
# Package files must be importable by unprivileged Fenris users regardless of
|
||||||
|
# the builder's umask. pip otherwise preserves a restrictive umask in the
|
||||||
|
# vendored runtime, which makes the installed CLI fail before it can read the
|
||||||
|
# observation store.
|
||||||
|
chmod -R a+rX "${VENDOR_DIR}"
|
||||||
|
|
||||||
# --- Inject version into wrapper from pyproject.toml ---
|
# --- Inject version into wrapper from pyproject.toml ---
|
||||||
# The wrapper has a hardcoded version string; patch it for packaging.
|
# The wrapper has a hardcoded version string; patch it for packaging.
|
||||||
|
|||||||
+10
-14
@@ -1,33 +1,29 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# XBPS REMOVE script — pre-remove and purge paths.
|
# XBPS REMOVE script — pre-remove path.
|
||||||
#
|
#
|
||||||
# Arguments: $1=ACTION $2=PKGNAME $3=VERSION $4=UPDATE $5=CONF_FILE $6=ARCH
|
# Arguments: $1=ACTION $2=PKGNAME $3=VERSION $4=UPDATE $5=CONF_FILE $6=ARCH
|
||||||
#
|
#
|
||||||
# Actions: pre (before files removed), post (after files removed),
|
# Actions: pre (before files removed)
|
||||||
# purge (after metadata removed, for config cleanup)
|
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ACTION="$1"
|
ACTION="$1"
|
||||||
|
UPDATE="$4"
|
||||||
case "${ACTION}" in
|
case "${ACTION}" in
|
||||||
pre)
|
pre)
|
||||||
# Sanctioned disable — close monitoring period
|
# Only a real erase is a sanctioned disable. An upgrade must preserve
|
||||||
|
# both monitoring intent and the active runit service.
|
||||||
|
if [ "${UPDATE}" = "no" ]; then
|
||||||
|
# Close the monitoring period while the store is still available.
|
||||||
if [ -x /usr/libexec/fenris/fenris-monitor ]; then
|
if [ -x /usr/libexec/fenris/fenris-monitor ]; then
|
||||||
/usr/libexec/fenris/fenris-monitor disable --now 2>/dev/null || true
|
/usr/libexec/fenris/fenris-monitor disable --now 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
|
# Configuration and the observation store are deliberately not
|
||||||
|
# package-owned. Leave them in place: XBPS does not guarantee a
|
||||||
|
# post-remove callback before its cleanup action.
|
||||||
# runit: remove the service symlink and mark dormant
|
# runit: remove the service symlink and mark dormant
|
||||||
rm -f /var/service/fenris-collect
|
rm -f /var/service/fenris-collect
|
||||||
touch /etc/sv/fenris-collect/down 2>/dev/null || true
|
touch /etc/sv/fenris-collect/down 2>/dev/null || true
|
||||||
;;
|
|
||||||
purge)
|
|
||||||
# Full cleanup — remove config, store, and runit service directories
|
|
||||||
rm -rf /etc/fenris
|
|
||||||
rm -rf /var/lib/fenris
|
|
||||||
if getent group fenris > /dev/null 2>&1; then
|
|
||||||
groupdel fenris 2>/dev/null || true
|
|
||||||
fi
|
fi
|
||||||
rm -rf /etc/sv/fenris-collect 2>/dev/null || true
|
|
||||||
rm -rf /var/log/fenris-collect 2>/dev/null || true
|
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
|||||||
@@ -319,16 +319,37 @@ def _runit_is_enabled() -> bool:
|
|||||||
def _runit_is_running() -> bool:
|
def _runit_is_running() -> bool:
|
||||||
"""Check if the runit service is currently running.
|
"""Check if the runit service is currently running.
|
||||||
|
|
||||||
Looks for a 'supervise/pid' file in the service directory.
|
Looks for a 'supervise/pid' file in the service directory. Void creates
|
||||||
|
that directory root-only, so unprivileged dashboard reads fall back to
|
||||||
|
the public process table when they cannot traverse it.
|
||||||
"""
|
"""
|
||||||
pid_file = FENRIS_SV_DIR / "supervise" / "pid"
|
def runsv_process_exists() -> bool:
|
||||||
if not pid_file.exists():
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["pgrep", "-f", "^runsv fenris-collect$"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
return result.returncode == 0
|
||||||
|
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
pid_file = FENRIS_SV_DIR / "supervise" / "pid"
|
||||||
|
# On Void, Path.exists() is false for an unprivileged process when it
|
||||||
|
# cannot traverse runit's root-only supervise directory.
|
||||||
|
if not pid_file.exists():
|
||||||
|
return FENRIS_SERVICE_LINK.exists() and runsv_process_exists()
|
||||||
try:
|
try:
|
||||||
pid = int(pid_file.read_text().strip())
|
pid = int(pid_file.read_text().strip())
|
||||||
# Check if the process is alive
|
# Check if the process is alive
|
||||||
os.kill(pid, 0)
|
os.kill(pid, 0)
|
||||||
return True
|
return True
|
||||||
|
except PermissionError:
|
||||||
|
# runsv's supervisor state is root-only on Void. Its process command
|
||||||
|
# is still observable, which gives the read-only UI the same runtime
|
||||||
|
# fact without granting it service-control permissions.
|
||||||
|
return FENRIS_SERVICE_LINK.exists() and runsv_process_exists()
|
||||||
except (ValueError, OSError):
|
except (ValueError, OSError):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
+15
-9
@@ -14,6 +14,7 @@ Criteria: TUI-1, TUI-2, TUI-4, CI-1, CI-2, CI-4, IN-3, LC-6, LC-8.
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
@@ -67,6 +68,11 @@ from .themes import THEMES, THEME_NAMES, get_theme, get_graph_colors
|
|||||||
# Helpers
|
# Helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_RESUME_HINT = "r resume — enable monitoring and future boots"
|
||||||
|
_ACTION_LEGEND = (
|
||||||
|
"p pause · " + _RESUME_HINT + " · c collect · t theme · m motion · d disclosures"
|
||||||
|
)
|
||||||
|
|
||||||
def _format_remaining(seconds: float) -> str:
|
def _format_remaining(seconds: float) -> str:
|
||||||
"""Format remaining lifespan as human-readable string."""
|
"""Format remaining lifespan as human-readable string."""
|
||||||
if seconds <= 0:
|
if seconds <= 0:
|
||||||
@@ -1164,7 +1170,7 @@ class FenrisTuiApp(App):
|
|||||||
# Empty store — greeting with enable hint (IN-3)
|
# Empty store — greeting with enable hint (IN-3)
|
||||||
self._render_headline(
|
self._render_headline(
|
||||||
"[bold]No observations yet[/bold]\n\n"
|
"[bold]No observations yet[/bold]\n\n"
|
||||||
"Enable monitoring: fenris monitor resume"
|
"[bold]%s[/bold]" % _RESUME_HINT
|
||||||
)
|
)
|
||||||
self.query_one("#usage-history").set_data([])
|
self.query_one("#usage-history").set_data([])
|
||||||
if self._is_constrained_mode:
|
if self._is_constrained_mode:
|
||||||
@@ -1173,10 +1179,10 @@ class FenrisTuiApp(App):
|
|||||||
)
|
)
|
||||||
self.query_one("#drive-health").update("")
|
self.query_one("#drive-health").update("")
|
||||||
self.query_one("#service-strip").update(
|
self.query_one("#service-strip").update(
|
||||||
|
(
|
||||||
"boot: disabled · timer: inactive · last collect: unknown · freshness: empty\n"
|
"boot: disabled · timer: inactive · last collect: unknown · freshness: empty\n"
|
||||||
"[bold]CONTINUITY[/bold] %s\n"
|
"[bold]CONTINUITY[/bold] %s\n" + _ACTION_LEGEND
|
||||||
"p pause · r resume · c collect · t theme · m motion · d disclosures"
|
) % monitoring_continuity({"boot_enabled": False})
|
||||||
% monitoring_continuity({"boot_enabled": False})
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Store fault (FL-4)
|
# Store fault (FL-4)
|
||||||
@@ -1191,7 +1197,7 @@ class FenrisTuiApp(App):
|
|||||||
)
|
)
|
||||||
self.query_one("#drive-health").update("")
|
self.query_one("#drive-health").update("")
|
||||||
self.query_one("#service-strip").update(
|
self.query_one("#service-strip").update(
|
||||||
"p pause · r resume · c collect · t theme · m motion · d disclosures"
|
_ACTION_LEGEND
|
||||||
)
|
)
|
||||||
|
|
||||||
def _render_all_regions(self, conn: sqlite3.Connection) -> None:
|
def _render_all_regions(self, conn: sqlite3.Connection) -> None:
|
||||||
@@ -1279,16 +1285,14 @@ class FenrisTuiApp(App):
|
|||||||
status_text = render_status_tui(comp)
|
status_text = render_status_tui(comp)
|
||||||
# Add TUI-only actions
|
# Add TUI-only actions
|
||||||
self.query_one("#service-strip").update(
|
self.query_one("#service-strip").update(
|
||||||
"%s\n"
|
"%s\n%s" % (status_text, _ACTION_LEGEND)
|
||||||
"p pause · r resume · c collect · t theme · m motion · d disclosures"
|
|
||||||
% status_text
|
|
||||||
)
|
)
|
||||||
self._render_paused_banner(comp)
|
self._render_paused_banner(comp)
|
||||||
except Exception:
|
except Exception:
|
||||||
self._hide_paused_banner()
|
self._hide_paused_banner()
|
||||||
self.query_one("#service-strip").update(
|
self.query_one("#service-strip").update(
|
||||||
"boot: unknown · timer: unknown · last collect: unknown · freshness: unknown\n"
|
"boot: unknown · timer: unknown · last collect: unknown · freshness: unknown\n"
|
||||||
"p pause · r resume · c collect · t theme · m motion · d disclosures"
|
+ _ACTION_LEGEND
|
||||||
)
|
)
|
||||||
|
|
||||||
def _render_paused_banner(self, comp) -> None:
|
def _render_paused_banner(self, comp) -> None:
|
||||||
@@ -1451,6 +1455,8 @@ class FenrisTuiApp(App):
|
|||||||
cmd = [self.helper_path, operation]
|
cmd = [self.helper_path, operation]
|
||||||
if extra_args:
|
if extra_args:
|
||||||
cmd.extend(extra_args)
|
cmd.extend(extra_args)
|
||||||
|
if os.geteuid() != 0:
|
||||||
|
cmd.insert(0, "pkexec")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with self.suspend():
|
with self.suspend():
|
||||||
|
|||||||
@@ -471,7 +471,9 @@ class TestRunitIsRunning:
|
|||||||
sv_dir = tmp_path / "sv" / "fenris-collect"
|
sv_dir = tmp_path / "sv" / "fenris-collect"
|
||||||
sv_dir.mkdir(parents=True)
|
sv_dir.mkdir(parents=True)
|
||||||
|
|
||||||
with patch("fenris.init_system.FENRIS_SV_DIR", sv_dir):
|
with patch("fenris.init_system.FENRIS_SV_DIR", sv_dir), \
|
||||||
|
patch("subprocess.run") as mock_run:
|
||||||
|
mock_run.return_value.returncode = 1
|
||||||
assert _runit_is_running() is False
|
assert _runit_is_running() is False
|
||||||
|
|
||||||
def test_is_running_false_dead_process(self, tmp_path):
|
def test_is_running_false_dead_process(self, tmp_path):
|
||||||
@@ -485,6 +487,37 @@ class TestRunitIsRunning:
|
|||||||
patch("os.kill", side_effect=OSError("No such process")):
|
patch("os.kill", side_effect=OSError("No such process")):
|
||||||
assert _runit_is_running() is False
|
assert _runit_is_running() is False
|
||||||
|
|
||||||
|
def test_is_running_uses_process_table_when_supervise_is_unreadable(self, tmp_path):
|
||||||
|
"""Void keeps runit's supervise directory root-only for normal users."""
|
||||||
|
sv_dir = tmp_path / "sv" / "fenris-collect"
|
||||||
|
supervise_dir = sv_dir / "supervise"
|
||||||
|
supervise_dir.mkdir(parents=True)
|
||||||
|
pid_file = supervise_dir / "pid"
|
||||||
|
pid_file.write_text("12345")
|
||||||
|
service_link = tmp_path / "service" / "fenris-collect"
|
||||||
|
service_link.parent.mkdir(parents=True)
|
||||||
|
service_link.symlink_to(sv_dir)
|
||||||
|
|
||||||
|
original_read_text = Path.read_text
|
||||||
|
|
||||||
|
def deny_pid(path, *args, **kwargs):
|
||||||
|
if path == pid_file:
|
||||||
|
raise PermissionError("supervise is root-only")
|
||||||
|
return original_read_text(path, *args, **kwargs)
|
||||||
|
|
||||||
|
with patch("fenris.init_system.FENRIS_SV_DIR", sv_dir), \
|
||||||
|
patch("fenris.init_system.FENRIS_SERVICE_LINK", service_link), \
|
||||||
|
patch.object(Path, "read_text", autospec=True, side_effect=deny_pid), \
|
||||||
|
patch("subprocess.run") as mock_run:
|
||||||
|
mock_run.return_value.returncode = 0
|
||||||
|
assert _runit_is_running() is True
|
||||||
|
mock_run.assert_called_once_with(
|
||||||
|
["pgrep", "-f", "^runsv fenris-collect$"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Public API dispatching
|
# Public API dispatching
|
||||||
|
|||||||
+21
-4
@@ -425,7 +425,7 @@ class TestDenseScreen:
|
|||||||
quit_rail = app.query_one("#quit-rail")
|
quit_rail = app.query_one("#quit-rail")
|
||||||
assert "continuity" in strip
|
assert "continuity" in strip
|
||||||
assert "monitoring: active in background · persists across reboots" in strip
|
assert "monitoring: active in background · persists across reboots" in strip
|
||||||
assert "p pause · r resume · c collect · t theme · m motion · d disclosures" in strip
|
assert "r resume — enable monitoring and future boots" in strip
|
||||||
assert "q quit" not in strip
|
assert "q quit" not in strip
|
||||||
assert rail == "q QUIT TUI"
|
assert rail == "q QUIT TUI"
|
||||||
assert usage.region.y < service.region.y < quit_rail.region.y
|
assert usage.region.y < service.region.y < quit_rail.region.y
|
||||||
@@ -595,15 +595,32 @@ class TestDisclosuresAndGreeting:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TestFirstRun:
|
class TestFirstRun:
|
||||||
|
def test_unprivileged_resume_uses_polkit(self, tmp_path):
|
||||||
|
"""TUI controls use the same authenticated path as the CLI."""
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||||
|
|
||||||
|
with patch("fenris.tui.os.geteuid", return_value=1000), \
|
||||||
|
patch("fenris.tui.subprocess.run") as run, \
|
||||||
|
patch.object(app, "suspend"), \
|
||||||
|
patch.object(app, "_refresh"):
|
||||||
|
run.return_value.returncode = 0
|
||||||
|
app._run_helper("enable", ["--now"])
|
||||||
|
|
||||||
|
run.assert_called_once_with(
|
||||||
|
["pkexec", "/usr/libexec/fenris/fenris-monitor", "enable", "--now"],
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_first_run_prompt(self, tmp_path):
|
async def test_first_run_prompt(self, tmp_path):
|
||||||
"""First-run prompt enables timer and opens first period."""
|
"""First-run prompt makes the keyboard action and boot effect explicit."""
|
||||||
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||||
async with app.run_test() as pilot:
|
async with app.run_test() as pilot:
|
||||||
headline = str(app.query_one("#headline-band").render())
|
headline = str(app.query_one("#headline-band").render())
|
||||||
|
strip = str(app.query_one("#service-strip").render())
|
||||||
assert "no observations yet" in headline.lower()
|
assert "no observations yet" in headline.lower()
|
||||||
# The enable hint should mention resume
|
assert "r resume — enable monitoring and future boots" in headline.lower()
|
||||||
assert "resume" in headline.lower()
|
assert "r resume — enable monitoring and future boots" in strip.lower()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user