Compare commits

..
Author SHA1 Message Date
xavierk 2eb4c48fb6 research: evaluate Python TUI frameworks 2026-08-31 13:45:19 +05:30
2 changed files with 69 additions and 259 deletions
+69
View File
@@ -0,0 +1,69 @@
# Python TUI frameworks for Fenris
_Research snapshot: 2026-08-31. Planning only; no product implementation._
## Decision
Use **Textual** for Fenris's later TUI prototype and, if the prototype checks below pass, for the persistent TUI.
Textual is the best fit for Fenris's keyboard-first Overview, Usage History, Drive Health, and Settings views because one framework supplies flexible grid/horizontal/vertical layouts, dashboard and form widgets, background workers, and a headless interaction driver ([layout](https://textual.textualize.io/guide/layout/), [widget gallery](https://textual.textualize.io/widget_gallery/), [workers](https://textual.textualize.io/guide/workers/), [testing](https://textual.textualize.io/guide/testing/)). Its principal costs are the largest direct dependency set in this shortlist and only a compact `Sparkline` as built-in charting; detailed historical plots would need a custom widget or the first-party `textual-plotext` integration ([PyPI metadata](https://pypi.org/pypi/textual/json), [Sparkline](https://textual.textualize.io/widgets/sparkline/), [`textual-plotext`](https://github.com/Textualize/textual-plotext)).
This recommendation is **conditional on raising Fenris's Python floor from its currently documented Python 3.7+ to Python 3.9+**: Textual 8.2.8 and Urwid 4.0.13 require Python 3.9+, while prompt_toolkit 3.0.53 requires Python 3.10+ ([Fenris README](../../README.md#you-need), [Textual metadata](https://pypi.org/pypi/textual/json), [Urwid metadata](https://pypi.org/pypi/urwid/json), [prompt_toolkit metadata](https://pypi.org/pypi/prompt-toolkit/json)). If retaining Python 3.7 is mandatory, none of the current versions evaluated here qualifies.
**Fallback:** choose **Urwid** if prototype evidence shows that explicit low-level terminal/display control or a smaller direct dependency set matters more than Textual's higher-level layouts, forms, workers, and test driver ([display modules](https://urwid.org/manual/displaymodules.html), [package metadata](https://pypi.org/pypi/urwid/json)).
## Scope and method
The shortlist covers maintained, full-screen-capable Python projects with first-party application/widget documentation: Textual, Urwid, and prompt_toolkit. The evaluation uses official documentation, repository release APIs, and PyPI package metadata. Versions, Python floors, dependencies, and release activity are a point-in-time snapshot and should be rechecked when dependencies are locked.
## Comparison
| Criterion | Textual 8.2.8 | Urwid 4.0.13 | prompt_toolkit 3.0.53 |
|---|---|---|---|
| **Terminal compatibility** | PyPI classifies Linux, macOS, and Windows 10/11 support; this exceeds Fenris's Linux-only scope ([metadata](https://pypi.org/pypi/textual/json)). | Offers pure-Python raw and OS curses displays. The manual compares UTF-8, color, mouse, and external-event-loop capabilities; curses is described as broadly terminal-compatible, while raw supports 88/256/24-bit color and external loops ([display modules](https://urwid.org/manual/displaymodules.html)). | Provides full-screen applications and platform-specific POSIX/Windows test input; official input docs describe cross-platform one-key-at-a-time input and Windows behavior ([full-screen apps](https://python-prompt-toolkit.readthedocs.io/en/stable/pages/full_screen_apps.html), [input](https://python-prompt-toolkit.readthedocs.io/en/stable/pages/asking_for_input.html), [testing](https://python-prompt-toolkit.readthedocs.io/en/stable/pages/advanced_topics/unit_testing.html)). |
| **Responsive layout** | Vertical, horizontal, and grid layouts support fractional, percentage, and automatic sizing, nesting, spans, overflow, docking, and runtime layout changes ([layout](https://textual.textualize.io/guide/layout/)). | `Pile`, `Columns`, `GridFlow`, `Overlay`, `ListBox`, padding, and flow/box/fixed sizing provide procedural composition ([widgets](https://urwid.org/manual/widgets.html)). | `VSplit`, `HSplit`, `FloatContainer`, `ConditionalContainer`, and scrollable panes compose full-screen regions, but the reviewed guide does not document breakpoint-style responsiveness ([full-screen apps](https://python-prompt-toolkit.readthedocs.io/en/stable/pages/full_screen_apps.html)). |
| **Dashboard, charts, history** | Built-ins include `DataTable`, `ProgressBar`, `Digits`, and `Sparkline`; Sparkline summarizes reactive numerical data into bars according to available widget width ([gallery](https://textual.textualize.io/widget_gallery/), [Sparkline](https://textual.textualize.io/widgets/sparkline/)). `textual-plotext` adds a `PlotextPlot` wrapper for richer plotting ([repository](https://github.com/Textualize/textual-plotext)). | Includes `BarGraph`, `GraphVScale`, and `ProgressBar`; richer history views still require composition or custom rendering ([graph widgets](https://urwid.org/reference/widget.html#graph-widgets)). | The documented reusable widget set supports text areas, buttons, frames, dialogs, and menus, but does not list a chart widget; Fenris charts would therefore be custom controls/rendering ([widget reference](https://python-prompt-toolkit.readthedocs.io/en/stable/pages/reference.html#module-prompt_toolkit.widgets)). |
| **Forms and keyboard use** | Built-ins include `Input`, `MaskedInput`, `Select`, checkbox/radio/selection controls, switches, buttons, and text areas ([gallery](https://textual.textualize.io/widget_gallery/)). `Input` supports validation on change, blur, or submit and emits results with its messages ([Input](https://textual.textualize.io/widgets/input/)). | Supplies `Edit`, `Button`, `CheckBox`, `RadioButton`, focus handling, and keyboard/mouse event support; application code owns more form orchestration ([widgets](https://urwid.org/manual/widgets.html)). | Strong key-binding, editing, completion, history, suggestion, and validation facilities; full-screen reusable components include `TextArea` and `Button` ([input](https://python-prompt-toolkit.readthedocs.io/en/stable/pages/asking_for_input.html), [full-screen apps](https://python-prompt-toolkit.readthedocs.io/en/stable/pages/full_screen_apps.html)). |
| **Async/background updates** | Async workers run coroutines concurrently; thread workers cover blocking APIs. Workers support cancellation and exclusivity, while thread-originated UI updates use `call_from_thread()` or thread-safe messages ([workers](https://textual.textualize.io/guide/workers/)). | Supports Select, asyncio, Twisted, GLib, Tornado, and ZMQ event loops plus alarms and watched files; executor use varies by loop ([main loop](https://urwid.org/manual/mainloop.html)). | Uses asyncio natively and supports `Application.run_async()` inside an existing event loop ([asyncio](https://python-prompt-toolkit.readthedocs.io/en/stable/pages/advanced_topics/asyncio.html)). |
| **Testability** | `App.run_test()` runs headlessly and returns a `Pilot` that can press keys, click, wait for queued messages, and run at specified terminal sizes ([testing](https://textual.textualize.io/guide/testing/)). | The public manuals reviewed do not expose a comparable application pilot; tests would be built around widgets, rendering, callbacks, and event-loop seams ([widgets](https://urwid.org/manual/widgets.html), [main loop](https://urwid.org/manual/mainloop.html)). | Official testing guidance supplies platform-specific pipe input, `DummyOutput`, and app sessions for asserting results or data changes, while warning against brittle stdout-byte assertions ([unit testing](https://python-prompt-toolkit.readthedocs.io/en/stable/pages/advanced_topics/unit_testing.html)). |
| **Direct dependency cost** | Five required distributions: `markdown-it-py[linkify]`, `mdit-py-plugins`, `platformdirs`, `pygments`, and `rich`; syntax-tree packages are optional-extra dependencies ([metadata](https://pypi.org/pypi/textual/json)). | Two required distributions: `wcwidth` and `typing-extensions`; curses, GLib, Tornado, Trio, Twisted, ZMQ, serial, and LCD integrations are optional extras ([metadata](https://pypi.org/pypi/urwid/json)). | One required distribution: `wcwidth` ([metadata](https://pypi.org/pypi/prompt-toolkit/json)). |
| **Python floor** | `>=3.9,<4.0` ([metadata](https://pypi.org/pypi/textual/json)). | `>=3.9.0` ([metadata](https://pypi.org/pypi/urwid/json)). | `>=3.10` ([metadata](https://pypi.org/pypi/prompt-toolkit/json)). |
| **Project health signal** | Five non-draft releases from 8.2.4 through 8.2.8 were published between 2026-04-19 and 2026-06-30 ([release API](https://api.github.com/repos/Textualize/textual/releases?per_page=5)). | Five releases from 4.0.9 through 4.0.13 were published between 2026-08-14 and 2026-08-25 ([release API](https://api.github.com/repos/urwid/urwid/releases?per_page=5)). | Release 3.0.53 was published 2026-07-26, following 3.0.52 on 2025-08-27 ([release API](https://api.github.com/repos/prompt-toolkit/python-prompt-toolkit/releases?per_page=5)). |
All three show recent release activity, so maintenance does not eliminate a candidate. Textual wins on integrated application-level capabilities rather than on release recency alone.
## Fit to the four views
- **Overview:** use Textual layout containers with `Digits`, labels, progress indicators, and compact sparklines for monitoring status, usage-adjusted theoretical lifespan, projection confidence, and recent activity ([layout](https://textual.textualize.io/guide/layout/), [gallery](https://textual.textualize.io/widget_gallery/)).
- **Usage History:** prototype a `DataTable` plus `Sparkline` first. Add `textual-plotext` only if user testing establishes a need for axes, multiple series, or richer plots, keeping plotting out of the core dependency decision until then ([DataTable](https://textual.textualize.io/widgets/data_table/), [Sparkline](https://textual.textualize.io/widgets/sparkline/), [`textual-plotext`](https://github.com/Textualize/textual-plotext)).
- **Drive Health:** use a table and status/progress widgets for NVMe attributes; wording must continue to distinguish endurance projection from a hardware-failure prediction ([gallery](https://textual.textualize.io/widget_gallery/), [parent-map destination and terminology](https://git.bongbetic.com/xavierk/Fenris/issues/1)).
- **Settings:** Textual's selection, boolean, and validated text-entry controls cover a keyboard-only form without inventing controls ([gallery](https://textual.textualize.io/widget_gallery/), [Input validation](https://textual.textualize.io/widgets/input/)).
The TUI should consume snapshots from the independent collector rather than run NVMe collection in its UI loop; the ticket's parent map makes that independent-collector architecture part of the destination ([parent map](https://git.bongbetic.com/xavierk/Fenris/issues/1)). Textual workers remain useful for non-blocking database/IPC reads and cancellable refreshes ([workers](https://textual.textualize.io/guide/workers/)).
## Prototype acceptance checks
The later prototype should verify:
1. Complete keyboard-only operation and predictable focus order across all four views.
2. Reflow or deliberate scrolling at 80×24 and a representative wide terminal; Textual's test runner accepts explicit terminal sizes ([testing](https://textual.textualize.io/guide/testing/)).
3. Smooth refresh while database/IPC reads are delayed, plus clean cancellation and shutdown ([workers](https://textual.textualize.io/guide/workers/)).
4. Headless tests for view switching, settings validation, stale/error states, and collector disappearance ([testing](https://textual.textualize.io/guide/testing/), [Input](https://textual.textualize.io/widgets/input/)).
5. Readable behavior in the actual Linux terminal matrix, including SSH and tmux/screen if those are required; framework platform labels alone do not establish every terminal's behavior.
6. Usage History performance with realistic observation volume and gaps.
7. A table/text equivalent for every chart so observation gaps and projection confidence are not encoded by color or graphics alone.
8. Installation with Fenris's chosen packaging mechanism and the agreed Python floor.
## Newly surfaced questions
Do not open follow-up tickets during this research resolution; carry these into the next Wayfinder session:
- May the redesign raise Fenris's minimum Python version from 3.7 to 3.9?
- What is the minimum supported terminal contract: modern color terminals only, or also Linux virtual console, monochrome/16-color, SSH, tmux, and screen?
- Is a table plus sparkline sufficient for Usage History, or are axes, multiple series, zoom, and interval selection required?
- Should richer plotting be an optional install extra?
- What is the small-terminal policy: reflow, hide secondary content, scroll, or reject below a documented size?
- Which accessibility targets are required, including screen readers, reduced color, high contrast, and reduced animation?
- Does the TUI read observation storage directly or use an IPC snapshot API from the collector?
- Which settings are user-owned versus system/collector-owned, and which changes require privilege escalation?
- Are semantic interaction assertions sufficient, or are deterministic visual snapshots also required?
@@ -1,259 +0,0 @@
# Fenris: systemd lifecycle and privilege constraints
**Ticket:** “Verify systemd lifecycle and privilege constraints”
**Status:** Research and planning only; no product implementation is included
**Research date:** 2026-08-31
## Executive recommendation
Run collection as a **system timer plus a short-lived system service**, not as a user service and not as the TUI's child process. Enable `fenris-collect.timer` at installation so PID 1 schedules a collection shortly after every boot and thereafter at the configured interval. Keep the interactive TUI an ordinary, on-demand, unprivileged process.
The collection service should invoke an absolute, administrator-owned `smartctl` binary directly—never `sudo`—and should have no listener or TUI code. It should read root-owned configuration from `/etc/fenris/`, use `/var/lib/fenris/` for durable history, use `/run/fenris/` only for ephemeral status/locking, and log to the journal. `StateDirectory=` and `RuntimeDirectory=` create and lifecycle-manage those standard locations and add the mount dependencies needed to reach them; state directories persist after service stop, while runtime directories normally do not. [systemd.exec(5), directory options](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html#RuntimeDirectory=)
The TUI should:
1. inspect a deliberately small, non-secret status surface without elevation;
2. display **runtime state**, **boot enablement**, **last sample outcome**, and **freshness** separately;
3. offer only explicit “Enable collection at boot” and “Disable collection at boot” actions, with confirmation; and
4. ask systemd to make that change, allowing the platform's normal polkit authentication to occur.
Do **not** install a permissive polkit rule granting `org.freedesktop.systemd1.manage-unit-files` to a Fenris group. systemd uses that action for enable/disable/mask/preset and related unit-file operations generally, and its current unit-file authorization check supplies no unit detail with which a rule could safely limit authorization to Fenris. [systemd D-Bus API, Security](https://www.freedesktop.org/software/systemd/man/latest/org.freedesktop.systemd1.html#Security); [systemd `dbus-util.c`, pinned source: `manage-unit-files` has `details = NULL`](https://github.com/systemd/systemd/blob/a6a831d0d9ce304619b8937b27b1286109b5e625/src/core/dbus-util.c#L209-L223). If passwordless delegated startup control becomes a requirement, add a purpose-built, root-owned helper exposing only the two fixed Fenris operations and authorize that helper with a Fenris-specific polkit action; do not grant the generic systemd action.
## Product context observed in this repository
The current prototype combines sampling, persistence, HTTP serving, process daemonization, PID-file management, status, and control in [`fenris.py`](../../fenris.py). It starts a detached Python process itself, stores history/PID/log files under the checkout's `data/`, binds the dashboard to `0.0.0.0`, and runs `sudo -n smartctl -a -j DEVICE`. [`fenris.sh`](../../fenris.sh) starts/stops that process and currently recommends a passwordless sudoers entry for `/usr/sbin/smartctl` without argument constraints. The README describes the intended five-minute continuous collection and on-demand menu/dashboard.
That prototype shape is unsuitable for a boot-persistent privileged installation: a privileged process would also contain the HTTP server and large dashboard surface, checkout-relative state has no system ownership boundary, PID files duplicate service-manager state, and the broad sudoers example permits more than Fenris's read-only query. The recommendation below separates those concerns rather than wrapping the existing `start` command in a unit.
## Verified constraints
| Area | Verified constraint | Design consequence |
|---|---|---|
| System vs. user manager | A non-root user service cannot switch to another identity with `User=`; system services default to root and may select another user. [systemd.exec(5), `User=`](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html#User=) | A normal user service is not a reliable privilege boundary for SMART access. Collection belongs in the system manager. |
| User-service persistence | User lingering causes that user's manager to be spawned at boot and kept after logout; without this extra policy, a user manager is session-oriented. [loginctl(1), `enable-linger`](https://www.freedesktop.org/software/systemd/man/latest/loginctl.html#enable-linger%20USER%E2%80%A6) | A user unit either fails the reboot/no-login requirement or requires lingering while still not solving device privilege. Reject it for the collector. |
| Boot enablement | `[Install]` directives do not execute at runtime; `enable` materializes them as symlinks. `WantedBy=` creates a `.wants/` link, and `timers.target` is the recommended boot target for application timers. [systemd.unit(5), `[Install]`](https://www.freedesktop.org/software/systemd/man/latest/systemd.unit.html#%5BInstall%5D%20Section%20Options); [systemd.special(7), `timers.target`](https://www.freedesktop.org/software/systemd/man/latest/systemd.special.html#timers.target) | Install with `WantedBy=timers.target`, then explicitly enable the timer. Merely shipping the files does not enable startup. |
| Enabled vs. running | `systemctl enable` does not start a unit, and `disable` does not stop it; `--now` couples those otherwise separate changes. [systemctl(1), `enable`](https://www.freedesktop.org/software/systemd/man/latest/systemctl.html#enable%20UNIT%E2%80%A6); [systemctl(1), `disable`](https://www.freedesktop.org/software/systemd/man/latest/systemctl.html#disable%20UNIT%E2%80%A6); [systemctl(1), `--now`](https://www.freedesktop.org/software/systemd/man/latest/systemctl.html#--now) | The TUI must label startup and immediate runtime effects separately. Default startup actions should not silently use `--now`. |
| Timer recurrence | Combining `OnBootSec=` with a relative trigger provides post-boot and recurring activation. Timer expiry is coalesced within `AccuracySec=` (one minute by default). [systemd.timer(5), monotonic timers](https://www.freedesktop.org/software/systemd/man/latest/systemd.timer.html#OnActiveSec=); [systemd.timer(5), `AccuracySec=`](https://www.freedesktop.org/software/systemd/man/latest/systemd.timer.html#AccuracySec=) | Use `OnBootSec=` plus `OnUnitInactiveSec=` (or `OnUnitActiveSec=` after overlap behavior is tested), and choose accuracy deliberately. Do not promise exact-second polling. |
| Missed runs | `Persistent=` records the last trigger and can fire when an inactive timer is reactivated; its documented clock behavior must be considered with monotonic timers. [systemd.timer(5), `Persistent=`](https://www.freedesktop.org/software/systemd/man/latest/systemd.timer.html#Persistent=) | Fenris reads lifetime counters, so replaying every missed five-minute sample is neither possible nor useful. One prompt post-boot sample is sufficient; decide whether suspend catch-up warrants `Persistent=yes`. |
| Boot ordering | `timers.target` exists to activate timers after boot. `StateDirectory=`/`RuntimeDirectory=` automatically add `Requires=` and `After=` dependencies for mounts needed by their paths. `network-online.target` is for consumers that strictly require configured networking. [systemd.special(7), `timers.target`](https://www.freedesktop.org/software/systemd/man/latest/systemd.special.html#timers.target); [systemd.exec(5), implicit dependencies](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html#Implicit%20Dependencies); [systemd.special(7), `network-online.target`](https://www.freedesktop.org/software/systemd/man/latest/systemd.special.html#network-online.target) | Do not order local SMART collection after the network. Let managed data directories establish filesystem ordering. Treat a not-yet-present NVMe device as a failed sample retried at the next interval unless a fixed device-unit dependency is proven necessary. |
| `smartctl` Linux path | smartmontools opens a Linux NVMe device read-only and sends `NVME_IOCTL_ADMIN_CMD`; failure is returned from the ioctl. [smartmontools `os_linux.cpp`, pinned source](https://github.com/smartmontools/smartmontools/blob/618fcaede4478bc7d17fa2a8db5fd18af3744e20/lib/os_linux.cpp#L2817-L2861) | Ordinary file read permission alone does not prove the admin ioctl will be authorized. Validate the shipped unit on every supported kernel/device transport. Root execution is the robust initial compatibility choice. |
| Capability substitution | `CAP_SYS_ADMIN` is intentionally overloaded and is described as plausibly “the new root”; Linux man-pages explicitly advise avoiding it where possible. [capabilities(7), `CAP_SYS_ADMIN`](https://man7.org/linux/man-pages/man7/capabilities.7.html#CAP_SYS_ADMIN); [capabilities(7), developer notes](https://man7.org/linux/man-pages/man7/capabilities.7.html#NOTES) | Do not move `CAP_SYS_ADMIN` into the long-lived TUI or combined web process merely to avoid UID 0. A short-lived, sandboxed root collector has a smaller practical exposure. A minimal capability set remains a test item, not an assumption. |
| Device sandboxing | `PrivateDevices=yes` supplies only pseudo-devices and excludes physical devices. [systemd.exec(5), `PrivateDevices=`](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html#PrivateDevices=) | The collector must not set `PrivateDevices=yes`. Prefer a device cgroup allow-list for the configured node where supported and tested. The TUI/dashboard can use `PrivateDevices=yes`. |
| sudo non-interactivity | `sudo -n` never prompts and fails when authentication is required. [sudo(8), pinned upstream source](https://github.com/sudo-project/sudo/blob/194042b55c54055c7337fbbd93a518e8da69866f/docs/sudo.man.in#L629-L635) | `sudo -n` prevents a daemon hang but does not create authorization. It is unnecessary inside a root system service and gives poor interactive UX in the TUI. |
| sudoers command scope | If a sudoers command omits arguments, the user may supply any arguments; argument wildcards require care. `NOPASSWD` removes authentication for matching entries. [sudoers(5), pinned upstream source](https://github.com/sudo-project/sudo/blob/194042b55c54055c7337fbbd93a518e8da69866f/docs/sudoers.man.in#L1107-L1129); [sudoers(5), `PASSWD`/`NOPASSWD`](https://github.com/sudo-project/sudo/blob/194042b55c54055c7337fbbd93a518e8da69866f/docs/sudoers.man.in#L2063-L2089) | The README's path-only `NOPASSWD: /usr/sbin/smartctl` rule is too broad. Do not retain it as the installed architecture. If sudo is retained as a fallback, use a root-owned fixed-argument helper, not user-controlled smartctl arguments. |
| systemd authorization | Read access to systemd's D-Bus objects is generally available; state-changing unit operations require `manage-units`, while enablement operations require `manage-unit-files`. [systemd D-Bus API, Security](https://www.freedesktop.org/software/systemd/man/latest/org.freedesktop.systemd1.html#Security) | Status needs no blanket elevation. Starting/stopping and enabling/disabling are separate privileged action classes. |
| polkit rules | polkit loads JavaScript rules from `/etc/polkit-1/rules.d` and `/usr/share/polkit-1/rules.d`; a rule may return `AUTH_ADMIN`, while `AUTH_ADMIN_KEEP` caches authorization briefly for the same action/subject. [polkit(8), authorization rules](https://polkit.pages.freedesktop.org/polkit/polkit.8.html#AUTHORIZATION-RULES) | Prefer normal admin authentication and avoid cached authorization for a sensitive toggle. A custom delegated helper needs its own narrow action and rule. |
| Status parsing | `systemctl status` is human-readable and may include recent journal lines; `systemctl show` is the computer-parsable interface and supports selecting properties. [systemctl(1), `status`](https://www.freedesktop.org/software/systemd/man/latest/systemctl.html#status%20PATTERN%E2%80%A6); [systemctl(1), `show`](https://www.freedesktop.org/software/systemd/man/latest/systemctl.html#show%20PATTERN%E2%80%A6) | Never scrape `status` in the TUI. Query an explicit property allow-list and do not echo arbitrary logs in the default status screen. |
| Durable and ephemeral paths | systemd maps `StateDirectory=` to `/var/lib/` and `RuntimeDirectory=` to `/run/`; state/config/log directories remain after stop, while runtime directories are normally removed. [systemd.exec(5), directory table and lifecycle](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html#RuntimeDirectory=) | Durable samples belong in `/var/lib/fenris`; locks/sockets belong in `/run/fenris`. A checkout-relative `data/` directory and PID file should not survive migration. |
## Proposed lifecycle and control boundary
```text
boot
└─ system systemd
└─ enabled fenris-collect.timer (unprivileged users may inspect)
└─ periodically activates fenris-collect.service
├─ short-lived privileged collector only
├─ reads /etc/fenris/fenris.conf
├─ runs /usr/sbin/smartctl with fixed read-only query arguments
├─ validates and sanitizes JSON
└─ atomically updates /var/lib/fenris/{history,latest,status}
interactive login (independent of collection)
└─ fenris TUI, ordinary invoking user
├─ reads safe status and permitted data
├─ queries selected systemd read-only properties
└─ on explicit confirmed request only:
└─ systemctl enable|disable fenris-collect.timer
└─ system bus → polkit admin authentication → PID 1
```
### Boundary rules
- **Collector:** may access the configured block/controller device and write only Fenris state. It must not bind a network socket, render a UI, edit its configuration, alter unit enablement, or run caller-supplied commands.
- **TUI/dashboard:** may read sanitized state. It must not inherit collector privilege. If a dashboard remains, run it as a separate unprivileged process bound to loopback by default; the current `0.0.0.0` binding must not become part of the privileged unit.
- **systemd/PID 1:** owns process lifecycle and boot enablement. Remove application daemonization, kill-by-PID, and checkout PID files. A service process should remain in the foreground; systemd's service model assumes the started process remains until termination unless a forking type is deliberately used. [systemd.service(5), examples](https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html#Examples)
- **Administrator:** owns installation, `/etc/fenris`, unit files, authorization policy, membership in any read group, and startup-state changes.
A timer/oneshot split is preferable to a continuously privileged daemon because Fenris samples cumulative device counters and needs privilege only during a sample. If later requirements demand a live HTTP server, keep it in a distinct unprivileged service rather than extending collector lifetime.
## Unit sketch (planning only)
The following is intentionally illustrative. Paths, device cgroup syntax, hardening compatibility, and interval behavior must be validated on supported distributions before shipping.
```ini
# /usr/lib/systemd/system/fenris-collect.service
[Unit]
Description=Fenris NVMe SMART sample collector
Documentation=man:smartctl(8)
[Service]
Type=oneshot
ExecStart=/usr/libexec/fenris/fenris-collect --config /etc/fenris/fenris.conf
# Packaging creates the non-privileged read group; the process remains UID 0.
Group=fenris-readers
StateDirectory=fenris
StateDirectoryMode=0750
RuntimeDirectory=fenris
RuntimeDirectoryMode=0750
UMask=0027
# Hardening candidates; validate with smartctl on every supported transport.
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=no
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
LockPersonality=yes
RestrictAddressFamilies=AF_UNIX
# DevicePolicy=closed
# DeviceAllow=/dev/nvme0 r
# CapabilityBoundingSet=... # unresolved; do not guess CAP_SYS_ADMIN-only portability
```
`ProtectSystem=strict` makes the hierarchy read-only except API filesystems, while managed state/log directories are excluded so they remain writable; systemd recommends the protection for long-running services, and it is still useful defense-in-depth for this short-lived one. [systemd.exec(5), `ProtectSystem=`](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html#ProtectSystem=) `NoNewPrivileges=yes` prevents this process and its descendants from gaining new privilege through `execve` mechanisms such as set-user-ID bits or file capabilities. [systemd.exec(5), `NoNewPrivileges=`](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html#NoNewPrivileges=)
```ini
# /usr/lib/systemd/system/fenris-collect.timer
[Unit]
Description=Periodically collect Fenris NVMe SMART samples
[Timer]
OnBootSec=2min
OnUnitInactiveSec=5min
AccuracySec=30s
Unit=fenris-collect.service
[Install]
WantedBy=timers.target
```
`OnUnitInactiveSec=` measures from deactivation, which avoids overlapping a slow oneshot at the cost of interval drift; `OnUnitActiveSec=` measures from activation. Those distinct bases are defined by `systemd.timer(5)`. [systemd.timer(5), monotonic timer table](https://www.freedesktop.org/software/systemd/man/latest/systemd.timer.html#OnActiveSec=) Choose between them after measuring collection duration and confirming the desired interval semantics.
Do not add `After=network-online.target`; collection is local. Do not add `Requires=/dev/...` as pseudo-syntax. If strict device binding is needed, use the escaped `.device` unit generated for the configured node only after testing replacement/hotplug behavior; otherwise record a bounded failure and let the next timer activation retry.
## Authorization and TUI control sketch (planning only)
### Default: systemctl plus normal polkit authentication
Read path:
```text
systemctl show fenris-collect.service \
--property=LoadState,ActiveState,SubState,Result,ExecMainStatus
systemctl show fenris-collect.timer \
--property=LoadState,ActiveState,SubState,UnitFileState,NextElapseUSecRealtime,NextElapseUSecMonotonic
```
Mutation path, only after a confirmation screen naming the exact effect:
```text
Enable at future boots: systemctl enable fenris-collect.timer
Disable at future boots: systemctl disable fenris-collect.timer
```
Do not silently append `--now`. “Disable at boot” does not mean “cancel a currently executing sample,” and systemctl explicitly keeps enablement separate from start/stop unless `--now` is requested. [systemctl(1), `--now`](https://www.freedesktop.org/software/systemd/man/latest/systemctl.html#--now)
The TUI must pass a fixed unit name and fixed verb without a shell. On cancellation, authentication failure, timeout, or non-zero exit, it should report no successful change and re-read `UnitFileState`; it must not infer success from the requested action.
### Why not a broad polkit group rule
`manage-units` calls can include `unit` and `verb` details in current systemd source, but the `manage-unit-files` check currently has no details. [systemd `dbus-util.c`, pinned source](https://github.com/systemd/systemd/blob/a6a831d0d9ce304619b8937b27b1286109b5e625/src/core/dbus-util.c#L160-L223) Therefore a rule such as “members of `fenris` may perform `org.freedesktop.systemd1.manage-unit-files`” would delegate generic unit enablement/masking operations, not only Fenris startup state. That is outside the required boundary.
### If delegated passwordless control is later required
Use a small root-owned D-Bus/helper mechanism with a Fenris-specific action, for example `com.bongbetic.fenris.manage-startup`. Its API should accept only an enum `{enable, disable}`, internally target the constant `fenris-collect.timer`, reject options/paths/extra units, perform the unit-file operation, and return the observed resulting state. A polkit rule may then grant that custom action to a designated local group, optionally requiring `subject.local && subject.active`; polkit exposes subject locality/activity and action details to JavaScript rules. [polkit(8), authorization rules and `Subject`](https://polkit.pages.freedesktop.org/polkit/polkit.8.html#AUTHORIZATION-RULES)
A sudo fallback should follow the same fixed helper design. Do not authorize `/usr/bin/systemctl` or `/usr/sbin/smartctl` without exact argument control: sudoers explicitly permits arbitrary arguments when none are specified. [sudoers(5), command arguments](https://github.com/sudo-project/sudo/blob/194042b55c54055c7337fbbd93a518e8da69866f/docs/sudoers.man.in#L1107-L1129)
## Ownership and path choices
| Path | Proposed owner/mode | Purpose and rationale |
|---|---|---|
| `/usr/libexec/fenris/fenris-collect` (or distribution-equivalent) | `root:root`, `0755`, package-managed | Privileged entry point must not be writable by TUI users or the service's read group. Use an absolute `ExecStart`; systemd does not provide shell syntax by default. [systemd.service(5), command lines](https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html#Command%20lines) |
| `/usr/lib/systemd/system/fenris-collect.{service,timer}` | `root:root`, `0644`, package-managed | Vendor unit definitions. Local administrator overrides belong under `/etc/systemd/system/`, which has higher load-path precedence. [systemd.unit(5), unit load path](https://www.freedesktop.org/software/systemd/man/latest/systemd.unit.html#Unit%20File%20Load%20Path) |
| `/etc/fenris/fenris.conf` | `root:root`, `0644` if strictly non-secret; otherwise `0640` | Persistent host configuration. The collector reads but cannot write it under `ProtectSystem=strict`. The TUI should receive only safe fields through status rather than requiring config write access. |
| `/var/lib/fenris/` | created by `StateDirectory=fenris`, `0750`; `root:fenris-readers` if direct group reads are retained | Durable history, aggregates, latest sample, and machine-readable sample status. systemd maps state directories here and leaves them after stop. [systemd.exec(5), directory table/lifecycle](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html#RuntimeDirectory=) |
| `/run/fenris/` | created by `RuntimeDirectory=fenris`, `0750` | Ephemeral lock/socket only. Do not use a PID file as authority; systemd already tracks the process/unit. Runtime directories are removed on stop by default. [systemd.exec(5), `RuntimeDirectoryPreserve=`](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html#RuntimeDirectoryPreserve=) |
| Journal | journal ACL/policy | Operational diagnostics. Avoid a separate root-owned `/var/log/fenris.log` unless retention/export requirements demand it. Do not expose arbitrary journal messages through safe status. |
These locations also match FHS semantics: `/etc` holds host-specific configuration, `/run` is run-time variable data cleared at boot, and `/var/lib` holds application state that persists across restarts. [FHS 3.0, `/etc`](https://refspecs.linuxfoundation.org/FHS_3.0/fhs/ch03s07.html); [FHS 3.0, `/run`](https://refspecs.linuxfoundation.org/FHS_3.0/fhs/ch03s15.html); [FHS 3.0, `/var/lib`](https://refspecs.linuxfoundation.org/FHS_3.0/fhs/ch05s08.html)
Prefer a narrow read-only IPC/status endpoint over group-readable raw files if multi-user confidentiality matters. The sketch instead uses a dedicated `fenris-readers` primary group for the UID-0 collector so managed directories become `root:fenris-readers`; packaging must create that group, and only approved users should join it. Do not reuse the collector's privileged identity as a user-facing authorization group. Use atomic replace for `latest.json`/`status.json`, append safely for history, set a restrictive umask, and omit serial numbers, command lines, environment, and raw stderr from the shared surface.
## Safe status design
The default TUI status should be an allow-listed composition, not a dump of `systemctl status`, journal output, raw smartctl JSON, configuration, or process command lines.
Recommended fields:
```text
Installation: loaded | not-installed | error
Startup: enabled | disabled | static | masked | unknown
Scheduler runtime: active | inactive | failed
Collection runtime: active | inactive | failed
Last attempt: RFC3339 timestamp
Last success: RFC3339 timestamp
Freshness: fresh | stale | never (show threshold and age)
Last result: success | device-unavailable | permission-denied |
timeout | invalid-output | storage-error | internal-error
Next scheduled: timestamp if systemd reports one
Samples/history: count and range, if readable
Device: configured stable identifier or node; no serial by default
```
Design requirements:
- Treat `UnitFileState` (startup policy), `ActiveState`/`SubState` (runtime), and last sample result as independent axes. `is-enabled` documents multiple states—including enabled, disabled, static, indirect, generated, transient, and masked—so a Boolean loses actionable information. [systemctl(1), `is-enabled` state table](https://www.freedesktop.org/software/systemd/man/latest/systemctl.html#is-enabled%20UNIT%E2%80%A6)
- Parse only `systemctl show --property=...` or the equivalent D-Bus properties. `status` is explicitly human-oriented and includes journal data. [systemctl(1), `show`](https://www.freedesktop.org/software/systemd/man/latest/systemctl.html#show%20PATTERN%E2%80%A6)
- Compute freshness from `last_success`, not merely timer activity. An active timer can coexist with repeated collection failures.
- Publish bounded error categories and a short administrator hint; keep raw smartctl stderr and tracebacks in the journal. This prevents device identifiers, paths, malformed device output, or command details from crossing the read boundary.
- Never report “enabled” immediately after a requested mutation without re-querying the authoritative state. Never equate “disabled” with “stopped.”
- If status data is unreadable, say `permission-denied`/`unknown`; do not elevate merely to render the screen.
## Alternatives and trade-offs
### Long-running system service
A foreground `fenris-collector.service` with `Restart=on-failure` and `WantedBy=multi-user.target` also satisfies reboot persistence; `Restart=` controls automatic restart after process failure, while `WantedBy=multi-user.target` is the standard installation relationship for a multi-user service. [systemd.service(5), `Restart=`](https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html#Restart=); [systemd.special(7), `multi-user.target`](https://www.freedesktop.org/software/systemd/man/latest/systemd.special.html#multi-user.target)
Trade-off: it preserves today's loop model and precise in-process scheduling, but leaves a privileged Python process resident continuously and requires restart/backoff handling. Use it only if future collection needs persistent in-memory state that cannot be reconstructed from durable samples.
### Unprivileged daemon plus privileged smartctl helper
This can reduce privileged code if the helper accepts no uncontrolled path or options, validates a configured device allow-list, emits bounded sanitized output, and exits. It adds an IPC/protocol and another authorization surface. Giving the whole Python process `CAP_SYS_ADMIN` is not an equivalent reduction because that capability is exceptionally broad. [capabilities(7), notes](https://man7.org/linux/man-pages/man7/capabilities.7.html#NOTES)
### User service with lingering
Lingering can run a user manager from boot through logout, but the user manager cannot switch an ordinary user's unit to root and some system-service sandboxing features are unavailable in user services. [loginctl(1), lingering](https://www.freedesktop.org/software/systemd/man/latest/loginctl.html#enable-linger%20USER%E2%80%A6); [systemd.exec(5), user-service sandboxing limitations](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html#Sandboxing) This adds account coupling and still needs a separate privilege mechanism. Reject for collection; it remains acceptable for an optional per-user dashboard client.
### Root daemon calling `sudo -n smartctl`
This is redundant: root already crosses the privilege boundary, while sudo introduces policy/path/configuration failure modes. For an unprivileged daemon, `sudo -n` avoids blocking but only works with pre-authorized sudoers policy and produces no authentication opportunity. [sudo(8), `--non-interactive`](https://github.com/sudo-project/sudo/blob/194042b55c54055c7337fbbd93a518e8da69866f/docs/sudo.man.in#L629-L635) Reject as the installed systemd architecture.
### Direct broad polkit delegation
Convenient but unsafe for startup-state delegation: `manage-unit-files` covers generic enable/disable/mask/preset operations and currently lacks unit-scoping details in systemd's authorization call. Use normal administrator authentication or a custom narrow action instead. [systemd D-Bus API, Security](https://www.freedesktop.org/software/systemd/man/latest/org.freedesktop.systemd1.html#Security); [pinned systemd authorization source](https://github.com/systemd/systemd/blob/a6a831d0d9ce304619b8937b27b1286109b5e625/src/core/dbus-util.c#L209-L223)
## Unresolved questions and required validation
1. **Supported distributions/systemd floor:** What is the minimum systemd version? Confirm every selected hardening and directory directive exists there; unknown directives can weaken the intended sandbox.
2. **Device identity:** Is configuration a mutable `/dev/nvmeN` node, namespace node, controller, `/dev/disk/by-id` link, or discovered set? smartctl documents Linux NVMe controller and namespace forms, but the stable product identity and hotplug behavior remain a product decision. [smartctl(8), pinned source](https://github.com/smartmontools/smartmontools/blob/618fcaede4478bc7d17fa2a8db5fd18af3744e20/src/smartctl.8.in#L72-L86)
3. **Privilege matrix:** On each supported kernel, packaging of smartmontools, NVMe/SATA/USB bridge, and device permission setup, record which open/ioctl fails as an unprivileged service and which exact capability/device allow-list is sufficient. Do not generalize an NVMe result to every smartmontools transport.
4. **Root vs. reduced-capability collector:** After that matrix exists, decide whether a non-root static service user plus a minimal capability/device set works portably. Reject any result that requires putting broad capability into the TUI/dashboard.
5. **Timer semantics:** Should five minutes be measured from sample start or completion? What maximum runtime and timeout are acceptable? Should resume from suspend trigger immediately? Decide `OnUnitActiveSec` vs. `OnUnitInactiveSec`, `Persistent=`, and `AccuracySec` from those answers.
6. **Startup toggle semantics:** Does “disable startup” leave the timer active until reboot, or should the TUI offer a separate, clearly labeled “disable and stop now”? The systemctl semantics intentionally separate these effects. [systemctl(1), enable/disable](https://www.freedesktop.org/software/systemd/man/latest/systemctl.html#enable%20UNIT%E2%80%A6)
7. **Who may read health history:** All local users, a `fenris-readers` group, or only an authenticated local client? This determines state modes and whether a read-only Unix socket is preferable to files.
8. **Dashboard scope:** Is the HTTP dashboard retained, and if so must it be local-only or remotely accessible? Remote access needs a separate threat model, authentication, transport security, and unprivileged service; it must not enlarge the collector boundary.
9. **Packaging paths:** Confirm `/usr/libexec` and `/usr/lib/systemd/system` equivalents per target distribution, the absolute smartctl path, and whether administrator overrides use environment files or a dedicated validated config format.
10. **Data durability:** Define atomicity, fsync policy, retention, corruption recovery, migration from checkout-relative `data/`, and behavior on read-only/full filesystems.
11. **Authorization UX:** Is ordinary admin authentication acceptable? If not, specify exactly which local principals may toggle startup and commission the custom helper/action rather than broad `manage-unit-files` delegation.
12. **Status confidentiality:** Decide whether model name, device path, capacity, wear, temperatures, and error counters are safe for every local reader; serial numbers and raw smartctl output should remain excluded by default.
## Decision summary
The verified boundary is: **system systemd owns scheduling and boot state; a short-lived privileged collector owns only device interrogation and state writes; an unprivileged on-demand TUI owns presentation; polkit/admin authentication mediates explicit startup changes.** This satisfies collection across reboot without tying it to login, avoids embedding sudo in unattended code, keeps generic service control out of the TUI's ambient privilege, and makes startup state observable and alterable without conflating it with current execution.