Compare commits
2
Commits
37a0ed7030
...
a279c56be5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a279c56be5 | ||
|
|
e27052d09a |
@@ -18,3 +18,4 @@ json
|
|||||||
src/fenris.egg-info/
|
src/fenris.egg-info/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
.venv/
|
.venv/
|
||||||
|
MagicMock*
|
||||||
|
|||||||
@@ -93,6 +93,12 @@ install: check-python check-smartctl dist/fenris-*.whl
|
|||||||
@sudo install -m 0644 units/fenris-collect.service $(UNIT_DIR)/
|
@sudo install -m 0644 units/fenris-collect.service $(UNIT_DIR)/
|
||||||
@sudo systemctl daemon-reload
|
@sudo systemctl daemon-reload
|
||||||
|
|
||||||
|
@echo "=== Installing runit service files (dormant — not enabled) ==="
|
||||||
|
@sudo install -d -m 0755 /etc/sv/fenris-collect/log
|
||||||
|
@sudo install -m 0755 units/runit/fenris-collect/run /etc/sv/fenris-collect/run
|
||||||
|
@sudo install -m 0755 units/runit/fenris-collect/log/run /etc/sv/fenris-collect/log/run
|
||||||
|
@sudo touch /etc/sv/fenris-collect/down
|
||||||
|
|
||||||
@echo "=== Installing polkit policy ==="
|
@echo "=== Installing polkit policy ==="
|
||||||
@sudo install -m 0644 polkit/com.bongbetic.fenris.monitor.policy $(POLKIT_DIR)/
|
@sudo install -m 0644 polkit/com.bongbetic.fenris.monitor.policy $(POLKIT_DIR)/
|
||||||
|
|
||||||
@@ -143,6 +149,9 @@ upgrade: dist/fenris-*.whl
|
|||||||
@echo "=== Syncing units against manifest ==="
|
@echo "=== Syncing units against manifest ==="
|
||||||
@sudo install -m 0644 units/fenris-collect.timer $(UNIT_DIR)/
|
@sudo install -m 0644 units/fenris-collect.timer $(UNIT_DIR)/
|
||||||
@sudo install -m 0644 units/fenris-collect.service $(UNIT_DIR)/
|
@sudo install -m 0644 units/fenris-collect.service $(UNIT_DIR)/
|
||||||
|
@sudo install -d -m 0755 /etc/sv/fenris-collect/log
|
||||||
|
@sudo install -m 0755 units/runit/fenris-collect/run /etc/sv/fenris-collect/run
|
||||||
|
@sudo install -m 0755 units/runit/fenris-collect/log/run /etc/sv/fenris-collect/log/run
|
||||||
@sudo install -m 0644 polkit/com.bongbetic.fenris.monitor.policy $(POLKIT_DIR)/
|
@sudo install -m 0644 polkit/com.bongbetic.fenris.monitor.policy $(POLKIT_DIR)/
|
||||||
@sudo install -m 0755 scripts/fenris $(BIN_DIR)/fenris
|
@sudo install -m 0755 scripts/fenris $(BIN_DIR)/fenris
|
||||||
@sudo install -m 0755 src/fenris/monitor.py $(LIBEXEC_DIR)/fenris-monitor
|
@sudo install -m 0755 src/fenris/monitor.py $(LIBEXEC_DIR)/fenris-monitor
|
||||||
@@ -196,6 +205,9 @@ uninstall:
|
|||||||
@sudo systemctl stop fenris-collect.timer 2>/dev/null || true
|
@sudo systemctl stop fenris-collect.timer 2>/dev/null || true
|
||||||
@sudo systemctl disable fenris-collect.timer 2>/dev/null || true
|
@sudo systemctl disable fenris-collect.timer 2>/dev/null || true
|
||||||
@sudo systemctl daemon-reload
|
@sudo systemctl daemon-reload
|
||||||
|
@-sudo rm -f /var/service/fenris-collect 2>/dev/null || true
|
||||||
|
@-sudo rm -rf /etc/sv/fenris-collect 2>/dev/null || true
|
||||||
|
@-sudo rm -rf /var/log/fenris-collect 2>/dev/null || true
|
||||||
|
|
||||||
@echo "=== Removing installed files (preserving config and store) ==="
|
@echo "=== Removing installed files (preserving config and store) ==="
|
||||||
@-rm -f $(BIN_DIR)/fenris
|
@-rm -f $(BIN_DIR)/fenris
|
||||||
|
|||||||
+45
-23
@@ -12,10 +12,17 @@ STORE_BAK="${STORE_DIR}/observations.db.bak"
|
|||||||
RUNTIME_PYTHON="/usr/bin/python3"
|
RUNTIME_PYTHON="/usr/bin/python3"
|
||||||
VENDOR_DIR="/opt/fenris/vendor"
|
VENDOR_DIR="/opt/fenris/vendor"
|
||||||
|
|
||||||
|
# Detect init system
|
||||||
|
if [ -d /run/systemd/system ] || [ "$(cat /proc/1/comm 2>/dev/null)" = "systemd" ]; then
|
||||||
|
INIT_SYSTEM="systemd"
|
||||||
|
else
|
||||||
|
INIT_SYSTEM="runit"
|
||||||
|
fi
|
||||||
|
|
||||||
case "${1:-}" in
|
case "${1:-}" in
|
||||||
configure)
|
configure)
|
||||||
if [ -n "${2:-}" ]; then
|
if [ -n "${2:-}" ]; then
|
||||||
# Upgrade — snapshot, migration, daemon-reload, conditional timer restart
|
# Upgrade — snapshot, migration, init-system-aware reload
|
||||||
if [ -f "${STORE_DB}" ]; then
|
if [ -f "${STORE_DB}" ]; then
|
||||||
cp "${STORE_DB}" "${STORE_BAK}" 2>/dev/null || true
|
cp "${STORE_DB}" "${STORE_BAK}" 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
@@ -27,29 +34,44 @@ n = migrate_to_latest(Path('${STORE_DB}'))
|
|||||||
print(f'Fenris migration: {n} step(s) applied') if n else None
|
print(f'Fenris migration: {n} step(s) applied') if n else None
|
||||||
" 2>&1 || echo "Fenris: migration skipped (store not yet initialized)"
|
" 2>&1 || echo "Fenris: migration skipped (store not yet initialized)"
|
||||||
fi
|
fi
|
||||||
# Capture running unit content BEFORE daemon-reload (spec §7)
|
if [ "${INIT_SYSTEM}" = "systemd" ]; then
|
||||||
RUNNING_UNITS=""
|
# Capture running unit content BEFORE daemon-reload (spec §7)
|
||||||
for unit in fenris-collect.timer; do
|
RUNNING_UNITS=""
|
||||||
if systemctl is-active --quiet "${unit}" 2>/dev/null; then
|
for unit in fenris-collect.timer; do
|
||||||
RUNNING_UNITS="${RUNNING_UNITS} ${unit}"
|
if systemctl is-active --quiet "${unit}" 2>/dev/null; then
|
||||||
fi
|
RUNNING_UNITS="${RUNNING_UNITS} ${unit}"
|
||||||
done
|
fi
|
||||||
systemctl daemon-reload 2>/dev/null || true
|
done
|
||||||
# Restart timer only if unit contents changed AND active
|
systemctl daemon-reload 2>/dev/null || true
|
||||||
for unit in ${RUNNING_UNITS}; do
|
# Restart timer only if unit contents changed AND active
|
||||||
OLD_CONTENT="$(mktemp)"
|
for unit in ${RUNNING_UNITS}; do
|
||||||
NEW_PATH="/usr/lib/systemd/system/${unit}"
|
OLD_CONTENT="$(mktemp)"
|
||||||
systemctl cat "${unit}" > "${OLD_CONTENT}" 2>/dev/null || true
|
NEW_PATH="/usr/lib/systemd/system/${unit}"
|
||||||
if ! diff -q "${OLD_CONTENT}" "${NEW_PATH}" > /dev/null 2>&1; then
|
systemctl cat "${unit}" > "${OLD_CONTENT}" 2>/dev/null || true
|
||||||
systemctl restart "${unit}" 2>/dev/null || true
|
if ! diff -q "${OLD_CONTENT}" "${NEW_PATH}" > /dev/null 2>&1; then
|
||||||
fi
|
systemctl restart "${unit}" 2>/dev/null || true
|
||||||
rm -f "${OLD_CONTENT}"
|
fi
|
||||||
done
|
rm -f "${OLD_CONTENT}"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
# Fresh install: init-system-specific setup
|
||||||
|
if [ "${INIT_SYSTEM}" = "systemd" ]; then
|
||||||
|
systemd-sysusers || true
|
||||||
|
systemd-tmpfiles --create || true
|
||||||
|
systemctl daemon-reload || true
|
||||||
|
else
|
||||||
|
# runit: create group, set directory permissions
|
||||||
|
groupadd -f fenris
|
||||||
|
install -d -o root -g fenris -m 2750 "${STORE_DIR}" 2>/dev/null || true
|
||||||
|
# Mark runit service as dormant (down) for fresh install
|
||||||
|
if [ -d /etc/sv/fenris-collect ] && [ ! -e /var/service/fenris-collect ]; then
|
||||||
|
touch /etc/sv/fenris-collect/down
|
||||||
|
fi
|
||||||
|
# Ensure log directory exists
|
||||||
|
mkdir -p /var/log/fenris-collect
|
||||||
|
chown fenris:fenris /var/log/fenris-collect 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
# sysusers, tmpfiles, daemon-reload (both fresh install and upgrade)
|
|
||||||
systemd-sysusers || true
|
|
||||||
systemd-tmpfiles --create || true
|
|
||||||
systemctl daemon-reload || true
|
|
||||||
;;
|
;;
|
||||||
abort-upgrade|abort-install|disappear)
|
abort-upgrade|abort-install|disappear)
|
||||||
;;
|
;;
|
||||||
|
|||||||
+14
-1
@@ -5,6 +5,13 @@
|
|||||||
# postrm purge (after conffiles and config removed)
|
# postrm purge (after conffiles and config removed)
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
|
# Detect init system
|
||||||
|
if [ -d /run/systemd/system ] || [ "$(cat /proc/1/comm 2>/dev/null)" = "systemd" ]; then
|
||||||
|
INIT_SYSTEM="systemd"
|
||||||
|
else
|
||||||
|
INIT_SYSTEM="runit"
|
||||||
|
fi
|
||||||
|
|
||||||
case "${1:-}" in
|
case "${1:-}" in
|
||||||
purge)
|
purge)
|
||||||
rm -rf /etc/fenris
|
rm -rf /etc/fenris
|
||||||
@@ -16,4 +23,10 @@ case "${1:-}" in
|
|||||||
remove|upgrade|failed-upgrade|abort-install|abort-upgrade|disappear)
|
remove|upgrade|failed-upgrade|abort-install|abort-upgrade|disappear)
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
systemctl daemon-reload 2>/dev/null || true
|
if [ "${INIT_SYSTEM}" = "systemd" ]; then
|
||||||
|
systemctl daemon-reload 2>/dev/null || true
|
||||||
|
else
|
||||||
|
# runit: clean up service directory and log
|
||||||
|
rm -rf /etc/sv/fenris-collect 2>/dev/null || true
|
||||||
|
rm -rf /var/log/fenris-collect 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|||||||
+15
-2
@@ -5,14 +5,27 @@
|
|||||||
# prerm upgrade (old version about to be replaced)
|
# prerm upgrade (old version about to be replaced)
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
|
# Detect init system
|
||||||
|
if [ -d /run/systemd/system ] || [ "$(cat /proc/1/comm 2>/dev/null)" = "systemd" ]; then
|
||||||
|
INIT_SYSTEM="systemd"
|
||||||
|
else
|
||||||
|
INIT_SYSTEM="runit"
|
||||||
|
fi
|
||||||
|
|
||||||
case "${1:-}" in
|
case "${1:-}" in
|
||||||
remove)
|
remove)
|
||||||
# Sanctioned disable — close monitoring period (spec §7)
|
# Sanctioned disable — close monitoring period (spec §7)
|
||||||
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
|
||||||
systemctl stop fenris-collect.timer 2>/dev/null || true
|
if [ "${INIT_SYSTEM}" = "systemd" ]; then
|
||||||
systemctl disable fenris-collect.timer 2>/dev/null || true
|
systemctl stop fenris-collect.timer 2>/dev/null || true
|
||||||
|
systemctl disable fenris-collect.timer 2>/dev/null || true
|
||||||
|
else
|
||||||
|
# runit: remove the service symlink
|
||||||
|
rm -f /var/service/fenris-collect
|
||||||
|
touch /etc/sv/fenris-collect/down 2>/dev/null || true
|
||||||
|
fi
|
||||||
;;
|
;;
|
||||||
upgrade)
|
upgrade)
|
||||||
# Never interrupt monitoring on upgrade
|
# Never interrupt monitoring on upgrade
|
||||||
|
|||||||
+46
-24
@@ -8,13 +8,33 @@ STORE_BAK="${STORE_DIR}/observations.db.bak"
|
|||||||
RUNTIME_PYTHON="/usr/bin/python3"
|
RUNTIME_PYTHON="/usr/bin/python3"
|
||||||
VENDOR_DIR="/opt/fenris/vendor"
|
VENDOR_DIR="/opt/fenris/vendor"
|
||||||
|
|
||||||
|
# Detect init system
|
||||||
|
if [ -d /run/systemd/system ] || [ "$(cat /proc/1/comm 2>/dev/null)" = "systemd" ]; then
|
||||||
|
INIT_SYSTEM="systemd"
|
||||||
|
else
|
||||||
|
INIT_SYSTEM="runit"
|
||||||
|
fi
|
||||||
|
|
||||||
if [ "$1" -eq 1 ]; then
|
if [ "$1" -eq 1 ]; then
|
||||||
# Fresh install
|
# Fresh install
|
||||||
systemd-sysusers || true
|
if [ "${INIT_SYSTEM}" = "systemd" ]; then
|
||||||
systemd-tmpfiles --create || true
|
systemd-sysusers || true
|
||||||
systemctl daemon-reload || true
|
systemd-tmpfiles --create || true
|
||||||
|
systemctl daemon-reload || true
|
||||||
|
else
|
||||||
|
# runit: create group, set directory permissions
|
||||||
|
groupadd -f fenris
|
||||||
|
install -d -o root -g fenris -m 2750 "${STORE_DIR}" 2>/dev/null || true
|
||||||
|
# Mark runit service as dormant (down) for fresh install
|
||||||
|
if [ -d /etc/sv/fenris-collect ] && [ ! -e /var/service/fenris-collect ]; then
|
||||||
|
touch /etc/sv/fenris-collect/down
|
||||||
|
fi
|
||||||
|
# Ensure log directory exists
|
||||||
|
mkdir -p /var/log/fenris-collect
|
||||||
|
chown fenris:fenris /var/log/fenris-collect 2>/dev/null || true
|
||||||
|
fi
|
||||||
elif [ "$1" -ge 2 ]; then
|
elif [ "$1" -ge 2 ]; then
|
||||||
# Upgrade — snapshot, migration, daemon-reload, conditional timer restart
|
# Upgrade — snapshot, migration, init-system-aware reload
|
||||||
if [ -f "${STORE_DB}" ]; then
|
if [ -f "${STORE_DB}" ]; then
|
||||||
cp "${STORE_DB}" "${STORE_BAK}" 2>/dev/null || true
|
cp "${STORE_DB}" "${STORE_BAK}" 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
@@ -26,24 +46,26 @@ n = migrate_to_latest(Path('${STORE_DB}'))
|
|||||||
print(f'Fenris migration: {n} step(s) applied') if n else None
|
print(f'Fenris migration: {n} step(s) applied') if n else None
|
||||||
" 2>&1 || echo "Fenris: migration skipped (store not yet initialized)"
|
" 2>&1 || echo "Fenris: migration skipped (store not yet initialized)"
|
||||||
fi
|
fi
|
||||||
# Capture running unit content BEFORE daemon-reload (spec §7)
|
if [ "${INIT_SYSTEM}" = "systemd" ]; then
|
||||||
RUNNING_UNITS=""
|
# Capture running unit content BEFORE daemon-reload (spec §7)
|
||||||
for unit in fenris-collect.timer; do
|
RUNNING_UNITS=""
|
||||||
if systemctl is-active --quiet "${unit}" 2>/dev/null; then
|
for unit in fenris-collect.timer; do
|
||||||
RUNNING_UNITS="${RUNNING_UNITS} ${unit}"
|
if systemctl is-active --quiet "${unit}" 2>/dev/null; then
|
||||||
fi
|
RUNNING_UNITS="${RUNNING_UNITS} ${unit}"
|
||||||
done
|
fi
|
||||||
systemctl daemon-reload 2>/dev/null || true
|
done
|
||||||
# Restart timer only if unit contents changed AND active
|
systemctl daemon-reload 2>/dev/null || true
|
||||||
for unit in ${RUNNING_UNITS}; do
|
# Restart timer only if unit contents changed AND active
|
||||||
OLD_CONTENT="$(mktemp)"
|
for unit in ${RUNNING_UNITS}; do
|
||||||
NEW_PATH="/usr/lib/systemd/system/${unit}"
|
OLD_CONTENT="$(mktemp)"
|
||||||
systemctl cat "${unit}" > "${OLD_CONTENT}" 2>/dev/null || true
|
NEW_PATH="/usr/lib/systemd/system/${unit}"
|
||||||
if ! diff -q "${OLD_CONTENT}" "${NEW_PATH}" > /dev/null 2>&1; then
|
systemctl cat "${unit}" > "${OLD_CONTENT}" 2>/dev/null || true
|
||||||
systemctl restart "${unit}" 2>/dev/null || true
|
if ! diff -q "${OLD_CONTENT}" "${NEW_PATH}" > /dev/null 2>&1; then
|
||||||
fi
|
systemctl restart "${unit}" 2>/dev/null || true
|
||||||
rm -f "${OLD_CONTENT}"
|
fi
|
||||||
done
|
rm -f "${OLD_CONTENT}"
|
||||||
# Re-apply placement modes (store dir group access, issue #54)
|
done
|
||||||
systemd-tmpfiles --create || true
|
# Re-apply placement modes (store dir group access, issue #54)
|
||||||
|
systemd-tmpfiles --create || true
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|||||||
+14
-1
@@ -2,6 +2,13 @@
|
|||||||
# RPM %postun — post-uninstall scriptlet (spec §7).
|
# RPM %postun — post-uninstall scriptlet (spec §7).
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
|
# Detect init system
|
||||||
|
if [ -d /run/systemd/system ] || [ "$(cat /proc/1/comm 2>/dev/null)" = "systemd" ]; then
|
||||||
|
INIT_SYSTEM="systemd"
|
||||||
|
else
|
||||||
|
INIT_SYSTEM="runit"
|
||||||
|
fi
|
||||||
|
|
||||||
if [ "$1" -eq 0 ]; then
|
if [ "$1" -eq 0 ]; then
|
||||||
# Package fully erased — remove config, store, group
|
# Package fully erased — remove config, store, group
|
||||||
rm -rf /etc/fenris
|
rm -rf /etc/fenris
|
||||||
@@ -10,4 +17,10 @@ if [ "$1" -eq 0 ]; then
|
|||||||
groupdel fenris 2>/dev/null || true
|
groupdel fenris 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
systemctl daemon-reload 2>/dev/null || true
|
if [ "${INIT_SYSTEM}" = "systemd" ]; then
|
||||||
|
systemctl daemon-reload 2>/dev/null || true
|
||||||
|
else
|
||||||
|
# runit: clean up service directory and log
|
||||||
|
rm -rf /etc/sv/fenris-collect 2>/dev/null || true
|
||||||
|
rm -rf /var/log/fenris-collect 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|||||||
+15
-2
@@ -2,12 +2,25 @@
|
|||||||
# RPM %preun — pre-uninstall scriptlet (spec §7).
|
# RPM %preun — pre-uninstall scriptlet (spec §7).
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
|
# Detect init system
|
||||||
|
if [ -d /run/systemd/system ] || [ "$(cat /proc/1/comm 2>/dev/null)" = "systemd" ]; then
|
||||||
|
INIT_SYSTEM="systemd"
|
||||||
|
else
|
||||||
|
INIT_SYSTEM="runit"
|
||||||
|
fi
|
||||||
|
|
||||||
if [ "$1" -eq 0 ]; then
|
if [ "$1" -eq 0 ]; then
|
||||||
# Package is being erased — sanctioned disable (spec §7)
|
# Package is being erased — sanctioned disable (spec §7)
|
||||||
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
|
||||||
systemctl stop fenris-collect.timer 2>/dev/null || true
|
if [ "${INIT_SYSTEM}" = "systemd" ]; then
|
||||||
systemctl disable fenris-collect.timer 2>/dev/null || true
|
systemctl stop fenris-collect.timer 2>/dev/null || true
|
||||||
|
systemctl disable fenris-collect.timer 2>/dev/null || true
|
||||||
|
else
|
||||||
|
# runit: remove the service symlink
|
||||||
|
rm -f /var/service/fenris-collect
|
||||||
|
touch /etc/sv/fenris-collect/down 2>/dev/null || true
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
# On upgrade ($1 -ge 1): do nothing
|
# On upgrade ($1 -ge 1): do nothing
|
||||||
|
|||||||
@@ -74,6 +74,12 @@ mkdir -p "${STAGE_DIR}/usr/lib/systemd/system"
|
|||||||
install -m 0644 "${REPO_ROOT}/units/fenris-collect.timer" "${STAGE_DIR}/usr/lib/systemd/system/"
|
install -m 0644 "${REPO_ROOT}/units/fenris-collect.timer" "${STAGE_DIR}/usr/lib/systemd/system/"
|
||||||
install -m 0644 "${REPO_ROOT}/units/fenris-collect.service" "${STAGE_DIR}/usr/lib/systemd/system/"
|
install -m 0644 "${REPO_ROOT}/units/fenris-collect.service" "${STAGE_DIR}/usr/lib/systemd/system/"
|
||||||
|
|
||||||
|
# --- runit service files ---
|
||||||
|
echo " Installing runit service files ..."
|
||||||
|
mkdir -p "${STAGE_DIR}/etc/sv/fenris-collect/log"
|
||||||
|
install -m 0755 "${REPO_ROOT}/units/runit/fenris-collect/run" "${STAGE_DIR}/etc/sv/fenris-collect/run"
|
||||||
|
install -m 0755 "${REPO_ROOT}/units/runit/fenris-collect/log/run" "${STAGE_DIR}/etc/sv/fenris-collect/log/run"
|
||||||
|
|
||||||
# --- polkit policy ---
|
# --- polkit policy ---
|
||||||
echo " Installing polkit policy ..."
|
echo " Installing polkit policy ..."
|
||||||
mkdir -p "${STAGE_DIR}/usr/share/polkit-1/actions"
|
mkdir -p "${STAGE_DIR}/usr/share/polkit-1/actions"
|
||||||
|
|||||||
@@ -0,0 +1,479 @@
|
|||||||
|
"""Init system abstraction for Fenris monitoring (issue #84).
|
||||||
|
|
||||||
|
Detects the active init system (systemd or runit) and provides a unified
|
||||||
|
interface for timer/collection control and service-state queries. This keeps
|
||||||
|
the privileged helper and status composition init-system agnostic without
|
||||||
|
introducing a generalized plugin framework.
|
||||||
|
|
||||||
|
Design: ADR 0008 — keep service-specific operations behind a cohesive
|
||||||
|
responsibility shared by the privileged control path and read-only status
|
||||||
|
composition.
|
||||||
|
|
||||||
|
Runit service layout:
|
||||||
|
/etc/sv/fenris-collect/run — scheduler (sleep 120; loop { collect; sleep 300 })
|
||||||
|
/etc/sv/fenris-collect/log/run — logger to /var/log/fenris-collect/
|
||||||
|
/var/service/fenris-collect — symlink to enable
|
||||||
|
/etc/sv/fenris-collect/down — marker for dormant install
|
||||||
|
|
||||||
|
Runit guarantees:
|
||||||
|
- Completion-relative 5-minute cadence (sleep 300 after each collect)
|
||||||
|
- Initial 2-minute boot delay (sleep 120 before first collect)
|
||||||
|
- Bounded execution (90s timeout via timeout(1))
|
||||||
|
- No catch-up (service sleeps fixed interval, no Persistent= flag)
|
||||||
|
- Serialized scheduled runs (runsv does not restart until exit)
|
||||||
|
- Serialized on-demand (flock serializes fenris-collect execution)
|
||||||
|
|
||||||
|
Spec: §8.4, §8.5, §8.6, §8.7, §8.8, ADR 0008
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from enum import Enum
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Init system detection
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class InitSystem(Enum):
|
||||||
|
SYSTEMD = "systemd"
|
||||||
|
RUNIT = "runit"
|
||||||
|
|
||||||
|
|
||||||
|
FENRIS_SV_DIR = Path("/etc/sv/fenris-collect")
|
||||||
|
FENRIS_SERVICE_LINK = Path("/var/service/fenris-collect")
|
||||||
|
FENRIS_LOG_DIR = Path("/var/log/fenris-collect")
|
||||||
|
COLLECT_TIMEOUT_S = 90
|
||||||
|
|
||||||
|
|
||||||
|
def detect_init_system() -> InitSystem:
|
||||||
|
"""Detect the active init system.
|
||||||
|
|
||||||
|
Checks for systemd first (PID 1 is systemd or /run/systemd/system exists),
|
||||||
|
then falls back to runit (PID 1 is runsv or /etc/sv exists).
|
||||||
|
"""
|
||||||
|
# systemd detection: /run/systemd/system exists when systemd is PID 1
|
||||||
|
if Path("/run/systemd/system").exists():
|
||||||
|
return InitSystem.SYSTEMD
|
||||||
|
|
||||||
|
# Check PID 1 name
|
||||||
|
try:
|
||||||
|
pid1_comm = Path("/proc/1/comm").read_text().strip()
|
||||||
|
if pid1_comm == "systemd":
|
||||||
|
return InitSystem.SYSTEMD
|
||||||
|
if pid1_comm in ("runsv", "runsvdir"):
|
||||||
|
return InitSystem.RUNIT
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Fallback: check for /etc/sv (Void Linux default)
|
||||||
|
if Path("/etc/sv").is_dir():
|
||||||
|
return InitSystem.RUNIT
|
||||||
|
|
||||||
|
# Default to systemd (existing behavior)
|
||||||
|
return InitSystem.SYSTEMD
|
||||||
|
|
||||||
|
|
||||||
|
def get_init_system() -> InitSystem:
|
||||||
|
"""Get the detected init system (cached)."""
|
||||||
|
if not hasattr(get_init_system, "_cached"):
|
||||||
|
get_init_system._cached = detect_init_system()
|
||||||
|
return get_init_system._cached
|
||||||
|
|
||||||
|
|
||||||
|
def reset_init_system_cache() -> None:
|
||||||
|
"""Reset the cached init system detection (for testing)."""
|
||||||
|
if hasattr(get_init_system, "_cached"):
|
||||||
|
delattr(get_init_system, "_cached")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# systemd backend
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _systemd_enable(now: bool) -> None:
|
||||||
|
"""Enable and optionally start the systemd timer."""
|
||||||
|
cmd = ["systemctl", "enable"]
|
||||||
|
if now:
|
||||||
|
cmd.append("--now")
|
||||||
|
cmd.append("fenris-collect.timer")
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
if result.returncode != 0:
|
||||||
|
print("Error enabling timer:", result.stderr, file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
print("Timer enabled" + (" and started" if now else ""))
|
||||||
|
|
||||||
|
|
||||||
|
def _systemd_disable(now: bool) -> None:
|
||||||
|
"""Disable and optionally stop the systemd timer."""
|
||||||
|
cmd = ["systemctl", "disable"]
|
||||||
|
if now:
|
||||||
|
cmd.append("--now")
|
||||||
|
cmd.append("fenris-collect.timer")
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
if result.returncode != 0:
|
||||||
|
print("Error disabling timer:", result.stderr, file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
print("Timer disabled" + (" and stopped" if now else ""))
|
||||||
|
|
||||||
|
|
||||||
|
def _systemd_collect() -> None:
|
||||||
|
"""Trigger on-demand collection via systemd (blocking)."""
|
||||||
|
result = subprocess.run(
|
||||||
|
["systemctl", "start", "fenris-collect.service"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
print("Collection completed successfully")
|
||||||
|
else:
|
||||||
|
print("Collection failed:", result.stderr, file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def _systemctl_show(unit: str, *properties: str) -> Dict[str, str]:
|
||||||
|
"""Query systemctl show for specific properties."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["systemctl", "show", unit, "--property=" + ",".join(properties)],
|
||||||
|
capture_output=True, text=True, timeout=5,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return {}
|
||||||
|
out = {}
|
||||||
|
for line in result.stdout.splitlines():
|
||||||
|
if "=" in line:
|
||||||
|
key, _, value = line.partition("=")
|
||||||
|
out[key.strip()] = value.strip()
|
||||||
|
return out
|
||||||
|
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _systemd_query_state() -> Dict[str, Any]:
|
||||||
|
"""Query systemd for the four separate service facts."""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
timer_props = _systemctl_show(
|
||||||
|
"fenris-collect.timer",
|
||||||
|
"UnitFileState", "ActiveState", "LastTriggerUSec",
|
||||||
|
)
|
||||||
|
service_props = _systemctl_show(
|
||||||
|
"fenris-collect.service",
|
||||||
|
"ActiveState", "ExecMainStatus", "ExecMainExitTimestamp",
|
||||||
|
)
|
||||||
|
|
||||||
|
boot_enabled_str = timer_props.get("UnitFileState", "")
|
||||||
|
boot_enabled = boot_enabled_str == "enabled"
|
||||||
|
|
||||||
|
active_state = timer_props.get("ActiveState", "inactive")
|
||||||
|
timer_active = active_state == "active"
|
||||||
|
|
||||||
|
last_collect_ok = None
|
||||||
|
last_collect_age_s = None
|
||||||
|
last_collect_reason = None
|
||||||
|
|
||||||
|
last_trigger = timer_props.get("LastTriggerUSec", "")
|
||||||
|
if last_trigger and last_trigger != "n/a":
|
||||||
|
try:
|
||||||
|
trigger_dt = datetime.fromisoformat(last_trigger.replace("Z", "+00:00"))
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
last_collect_age_s = int((now - trigger_dt).total_seconds())
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
exec_status = service_props.get("ExecMainStatus", "")
|
||||||
|
if exec_status:
|
||||||
|
try:
|
||||||
|
exit_code = int(exec_status)
|
||||||
|
last_collect_ok = exit_code == 0
|
||||||
|
if exit_code != 0:
|
||||||
|
last_collect_reason = "exit code %d" % exit_code
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
"boot_enabled": boot_enabled,
|
||||||
|
"timer_active": timer_active,
|
||||||
|
"last_collect_ok": last_collect_ok,
|
||||||
|
"last_collect_age_s": last_collect_age_s,
|
||||||
|
"last_collect_reason": last_collect_reason,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _systemd_journal_hint(lines: int = 5) -> Optional[str]:
|
||||||
|
"""Get the last N journal lines for fenris-collect.service."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["journalctl", "-u", "fenris-collect.service",
|
||||||
|
"--no-pager", "-n", str(lines), "--output=short-iso"],
|
||||||
|
capture_output=True, text=True, timeout=5,
|
||||||
|
)
|
||||||
|
if result.returncode != 0 or not result.stdout.strip():
|
||||||
|
return None
|
||||||
|
return result.stdout.strip()
|
||||||
|
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# runit backend
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _runit_enable(_now: bool) -> None:
|
||||||
|
"""Enable the runit service by creating a symlink.
|
||||||
|
|
||||||
|
runit activates the service immediately when the symlink appears.
|
||||||
|
If the service is already enabled but stopped (e.g., via `sv stop`),
|
||||||
|
restart it when `now=True`.
|
||||||
|
"""
|
||||||
|
if FENRIS_SERVICE_LINK.exists():
|
||||||
|
# Already enabled — check if we need to restart
|
||||||
|
if _now and not _runit_is_running():
|
||||||
|
# Service is stopped but enabled — restart via sv
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
["sv", "restart", "fenris-collect"],
|
||||||
|
capture_output=True, text=True, timeout=5,
|
||||||
|
)
|
||||||
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
|
pass
|
||||||
|
print("Service already enabled (idempotent)")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Remove the 'down' file if present (dormant install marker)
|
||||||
|
down_file = FENRIS_SV_DIR / "down"
|
||||||
|
if down_file.exists():
|
||||||
|
down_file.unlink()
|
||||||
|
|
||||||
|
FENRIS_SERVICE_LINK.symlink_to(FENRIS_SV_DIR)
|
||||||
|
print("Service enabled")
|
||||||
|
|
||||||
|
|
||||||
|
def _runit_disable(_now: bool) -> None:
|
||||||
|
"""Disable the runit service by removing the symlink.
|
||||||
|
|
||||||
|
runit stops the service immediately when the symlink is removed.
|
||||||
|
"""
|
||||||
|
if not FENRIS_SERVICE_LINK.exists():
|
||||||
|
print("Service already disabled (idempotent)")
|
||||||
|
return
|
||||||
|
|
||||||
|
FENRIS_SERVICE_LINK.unlink()
|
||||||
|
# Place 'down' file to mark as intentionally disabled
|
||||||
|
(FENRIS_SV_DIR / "down").touch()
|
||||||
|
print("Service disabled")
|
||||||
|
|
||||||
|
|
||||||
|
def _runit_collect() -> None:
|
||||||
|
"""Trigger on-demand collection via direct execution with flock.
|
||||||
|
|
||||||
|
Serializes against the scheduler using the same lock file.
|
||||||
|
The timeout(1) command enforces bounded execution.
|
||||||
|
"""
|
||||||
|
lock_path = Path("/var/lib/fenris/fenris-collect.lock")
|
||||||
|
collect_script = Path("/usr/libexec/fenris/fenris-collect")
|
||||||
|
fallback_script = Path(__file__).parent.parent.parent / "src" / "fenris" / "collect.py"
|
||||||
|
|
||||||
|
if collect_script.exists():
|
||||||
|
script = str(collect_script)
|
||||||
|
elif fallback_script.exists():
|
||||||
|
script = str(fallback_script)
|
||||||
|
else:
|
||||||
|
print("Error: fenris-collect script not found", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["flock", "--nonblock", str(lock_path),
|
||||||
|
"timeout", str(COLLECT_TIMEOUT_S), "nice", "ionice", "-c3",
|
||||||
|
sys.executable, script],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
print("Collection completed successfully")
|
||||||
|
elif result.returncode == 124:
|
||||||
|
print("Collection timed out after %ds" % COLLECT_TIMEOUT_S, file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
else:
|
||||||
|
# exit code 1 from flock means lock is held (scheduled run in progress)
|
||||||
|
if result.returncode == 1 and "Resource temporarily unavailable" in result.stderr:
|
||||||
|
print("Collection already in progress (serialized)", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
print("Collection failed:", result.stderr, file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
except FileNotFoundError:
|
||||||
|
print("Error: flock/timeout not found", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def _runit_is_enabled() -> bool:
|
||||||
|
"""Check if the runit service is enabled (symlink exists)."""
|
||||||
|
return FENRIS_SERVICE_LINK.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def _runit_is_running() -> bool:
|
||||||
|
"""Check if the runit service is currently running.
|
||||||
|
|
||||||
|
Looks for a 'supervise/pid' file in the service directory.
|
||||||
|
"""
|
||||||
|
pid_file = FENRIS_SV_DIR / "supervise" / "pid"
|
||||||
|
if not pid_file.exists():
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
pid = int(pid_file.read_text().strip())
|
||||||
|
# Check if the process is alive
|
||||||
|
os.kill(pid, 0)
|
||||||
|
return True
|
||||||
|
except (ValueError, OSError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _runit_query_state() -> Dict[str, Any]:
|
||||||
|
"""Query runit for the four separate service facts.
|
||||||
|
|
||||||
|
Checks: boot_enabled (symlink), timer_active (running), last_collect
|
||||||
|
(store-based), freshness (store-based).
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
boot_enabled = _runit_is_enabled()
|
||||||
|
timer_active = _runit_is_running()
|
||||||
|
|
||||||
|
last_collect_ok = None
|
||||||
|
last_collect_age_s = None
|
||||||
|
last_collect_reason = None
|
||||||
|
|
||||||
|
# Try to get last collection info from the store
|
||||||
|
store_path = Path("/var/lib/fenris/observations.db")
|
||||||
|
if store_path.exists():
|
||||||
|
try:
|
||||||
|
import sqlite3
|
||||||
|
conn = sqlite3.connect("file:%s?mode=ro" % store_path, uri=True)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
|
||||||
|
# Get newest sample
|
||||||
|
cursor = conn.execute(
|
||||||
|
"SELECT ts FROM samples ORDER BY id DESC LIMIT 1"
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row and row[0]:
|
||||||
|
try:
|
||||||
|
ts = datetime.fromisoformat(row[0])
|
||||||
|
if ts.tzinfo is None:
|
||||||
|
ts = ts.replace(tzinfo=timezone.utc)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
last_collect_age_s = int((now - ts).total_seconds())
|
||||||
|
last_collect_ok = True
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Check for failed collections via monitoring_periods
|
||||||
|
cursor = conn.execute(
|
||||||
|
"SELECT end_cause FROM monitoring_periods "
|
||||||
|
"WHERE ended_at IS NOT NULL ORDER BY ended_at DESC LIMIT 1"
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row and row[0] and row[0] not in ("user_disabled", None):
|
||||||
|
last_collect_reason = row[0]
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Check for timeout/exit failures via supervise exit status
|
||||||
|
if timer_active:
|
||||||
|
exit_file = FENRIS_SV_DIR / "supervise" / "exit"
|
||||||
|
if exit_file.exists():
|
||||||
|
try:
|
||||||
|
exit_code = int(exit_file.read_text().strip())
|
||||||
|
if exit_code != 0:
|
||||||
|
last_collect_ok = False
|
||||||
|
last_collect_reason = "exit code %d" % exit_code
|
||||||
|
except (ValueError, OSError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
"boot_enabled": boot_enabled,
|
||||||
|
"timer_active": timer_active,
|
||||||
|
"last_collect_ok": last_collect_ok,
|
||||||
|
"last_collect_age_s": last_collect_age_s,
|
||||||
|
"last_collect_reason": last_collect_reason,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _runit_journal_hint(lines: int = 5) -> Optional[str]:
|
||||||
|
"""Get the last N log lines for fenris-collect from runit logging."""
|
||||||
|
log_current = FENRIS_LOG_DIR / "current"
|
||||||
|
if not log_current.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
# Use tail to get the last N lines
|
||||||
|
result = subprocess.run(
|
||||||
|
["tail", "-n", str(lines), str(log_current)],
|
||||||
|
capture_output=True, text=True, timeout=5,
|
||||||
|
)
|
||||||
|
if result.returncode != 0 or not result.stdout.strip():
|
||||||
|
return None
|
||||||
|
return result.stdout.strip()
|
||||||
|
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API — unified interface
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def enable_timer(now: bool) -> None:
|
||||||
|
"""Enable the collection timer/service."""
|
||||||
|
init = get_init_system()
|
||||||
|
if init == InitSystem.SYSTEMD:
|
||||||
|
_systemd_enable(now)
|
||||||
|
else:
|
||||||
|
_runit_enable(now)
|
||||||
|
|
||||||
|
|
||||||
|
def disable_timer(now: bool) -> None:
|
||||||
|
"""Disable the collection timer/service."""
|
||||||
|
init = get_init_system()
|
||||||
|
if init == InitSystem.SYSTEMD:
|
||||||
|
_systemd_disable(now)
|
||||||
|
else:
|
||||||
|
_runit_disable(now)
|
||||||
|
|
||||||
|
|
||||||
|
def collect_now() -> None:
|
||||||
|
"""Trigger on-demand collection (blocking)."""
|
||||||
|
init = get_init_system()
|
||||||
|
if init == InitSystem.SYSTEMD:
|
||||||
|
_systemd_collect()
|
||||||
|
else:
|
||||||
|
_runit_collect()
|
||||||
|
|
||||||
|
|
||||||
|
def query_service_state() -> Dict[str, Any]:
|
||||||
|
"""Query the four separate service facts."""
|
||||||
|
init = get_init_system()
|
||||||
|
if init == InitSystem.SYSTEMD:
|
||||||
|
return _systemd_query_state()
|
||||||
|
else:
|
||||||
|
return _runit_query_state()
|
||||||
|
|
||||||
|
|
||||||
|
def journal_hint(lines: int = 5, unit: str = "fenris-collect.service") -> Optional[str]:
|
||||||
|
"""Get journal/log hints for diagnostics.
|
||||||
|
|
||||||
|
The unit parameter is accepted for backward compatibility with callers
|
||||||
|
that pass 'fenris-collect.service'. For runit, the unit parameter is
|
||||||
|
ignored since the log directory is always /var/log/fenris-collect/.
|
||||||
|
"""
|
||||||
|
init = get_init_system()
|
||||||
|
if init == InitSystem.SYSTEMD:
|
||||||
|
return _systemd_journal_hint(lines)
|
||||||
|
else:
|
||||||
|
return _runit_journal_hint(lines)
|
||||||
+13
-54
@@ -15,9 +15,7 @@ When run as a script, uses the fenris package from the installed wheel.
|
|||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import subprocess
|
|
||||||
import sys
|
import sys
|
||||||
import sqlite3
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -39,6 +37,11 @@ from fenris.monitoring_periods import (
|
|||||||
close_period,
|
close_period,
|
||||||
get_open_period,
|
get_open_period,
|
||||||
)
|
)
|
||||||
|
from fenris.init_system import (
|
||||||
|
enable_timer,
|
||||||
|
disable_timer,
|
||||||
|
collect_now,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_STORE_PATH = Path("/var/lib/fenris/observations.db")
|
DEFAULT_STORE_PATH = Path("/var/lib/fenris/observations.db")
|
||||||
@@ -70,25 +73,8 @@ def cmd_enable(args: argparse.Namespace) -> None:
|
|||||||
else:
|
else:
|
||||||
print("Monitoring period already open (id=%d)" % open_period["id"])
|
print("Monitoring period already open (id=%d)" % open_period["id"])
|
||||||
|
|
||||||
# Enable and start the timer
|
# Enable the timer (init-system aware)
|
||||||
if args.now:
|
enable_timer(args.now)
|
||||||
result = subprocess.run(
|
|
||||||
["systemctl", "enable", "--now", "fenris-collect.timer"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
result = subprocess.run(
|
|
||||||
["systemctl", "enable", "fenris-collect.timer"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
if result.returncode != 0:
|
|
||||||
print("Error enabling timer:", result.stderr, file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
print("Timer enabled" + (" and started" if args.now else ""))
|
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
@@ -117,25 +103,8 @@ def cmd_disable(args: argparse.Namespace) -> None:
|
|||||||
else:
|
else:
|
||||||
print("No open monitoring period (no-op)")
|
print("No open monitoring period (no-op)")
|
||||||
|
|
||||||
# Disable and stop the timer
|
# Disable the timer (init-system aware)
|
||||||
if args.now:
|
disable_timer(args.now)
|
||||||
result = subprocess.run(
|
|
||||||
["systemctl", "disable", "--now", "fenris-collect.timer"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
result = subprocess.run(
|
|
||||||
["systemctl", "disable", "fenris-collect.timer"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
if result.returncode != 0:
|
|
||||||
print("Error disabling timer:", result.stderr, file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
print("Timer disabled" + (" and stopped" if args.now else ""))
|
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
@@ -143,22 +112,12 @@ def cmd_disable(args: argparse.Namespace) -> None:
|
|||||||
def cmd_collect(args: argparse.Namespace) -> None:
|
def cmd_collect(args: argparse.Namespace) -> None:
|
||||||
"""Trigger on-demand collection.
|
"""Trigger on-demand collection.
|
||||||
|
|
||||||
Starts fenris-collect.service, blocks until exit, reports outcome.
|
Starts fenris-collect, blocks until exit, reports outcome.
|
||||||
|
|
||||||
Spec §8.7: fenris sample routes through fenris-monitor → systemctl start,
|
Spec §8.7: fenris sample routes through fenris-monitor → collect,
|
||||||
which blocks until the oneshot exits; outcome reported synchronously.
|
which blocks until the collection exits; outcome reported synchronously.
|
||||||
"""
|
"""
|
||||||
result = subprocess.run(
|
collect_now()
|
||||||
["systemctl", "start", "fenris-collect.service"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
if result.returncode == 0:
|
|
||||||
print("Collection completed successfully")
|
|
||||||
else:
|
|
||||||
print("Collection failed:", result.stderr, file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_baseline_set(args: argparse.Namespace) -> None:
|
def cmd_baseline_set(args: argparse.Namespace) -> None:
|
||||||
|
|||||||
+12
-94
@@ -1,8 +1,8 @@
|
|||||||
"""Read-only CLI status command: the CLI twin of the TUI (spec §8.8, LC-9, CI-2).
|
"""Read-only CLI status command: the CLI twin of the TUI (spec §8.8, LC-9, CI-2).
|
||||||
|
|
||||||
Composes from the observation store (read-only) and allow-listed systemctl
|
Composes from the observation store (read-only) and allow-listed service
|
||||||
properties: projection facts, four separate service facts (boot enablement,
|
properties: projection facts, four separate service facts (boot enablement,
|
||||||
runtime activity, last collect outcome, freshness), and a journalctl hint on
|
runtime activity, last collect outcome, freshness), and a journal/log hint on
|
||||||
failure or staleness. Never auto-samples, never prompts.
|
failure or staleness. Never auto-samples, never prompts.
|
||||||
|
|
||||||
Freshness constants are defined once here and shared with the TUI (§8.9):
|
Freshness constants are defined once here and shared with the TUI (§8.9):
|
||||||
@@ -14,7 +14,6 @@ Freshness constants are defined once here and shared with the TUI (§8.9):
|
|||||||
Criteria: LC-9, CI-2, CI-4, FL-4, FL-5, FL-7.
|
Criteria: LC-9, CI-2, CI-4, FL-4, FL-5, FL-7.
|
||||||
"""
|
"""
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import subprocess
|
|
||||||
import sys
|
import sys
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -22,6 +21,10 @@ from typing import Any, Dict, List, Optional, Tuple
|
|||||||
|
|
||||||
from .projection import compute_projection, ConfidenceState, DISCLOSURES
|
from .projection import compute_projection, ConfidenceState, DISCLOSURES
|
||||||
from .store import SCHEMA_VERSION
|
from .store import SCHEMA_VERSION
|
||||||
|
from .init_system import (
|
||||||
|
query_service_state as _init_query_service_state,
|
||||||
|
journal_hint as _init_journal_hint,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -130,97 +133,12 @@ class NewerSchema(Exception):
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Service state queries (§8.8 — allow-listed systemctl properties)
|
# Service state queries (§8.8 — init-system agnostic)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def _systemctl_show(unit: str, *properties: str) -> Dict[str, str]:
|
# Re-export for backward compatibility with tests that import directly
|
||||||
"""Query systemctl show for specific properties. Returns empty dict on failure."""
|
query_service_state = _init_query_service_state
|
||||||
try:
|
_journalctl_hint = _init_journal_hint
|
||||||
result = subprocess.run(
|
|
||||||
["systemctl", "show", unit, "--property=" + ",".join(properties)],
|
|
||||||
capture_output=True, text=True, timeout=5,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
return {}
|
|
||||||
out = {}
|
|
||||||
for line in result.stdout.splitlines():
|
|
||||||
if "=" in line:
|
|
||||||
key, _, value = line.partition("=")
|
|
||||||
out[key.strip()] = value.strip()
|
|
||||||
return out
|
|
||||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def _journalctl_hint(unit: str, lines: int = 5) -> Optional[str]:
|
|
||||||
"""Get the last N journal lines for a unit. Returns None on failure."""
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
["journalctl", "-u", unit, "--no-pager", "-n", str(lines), "--output=short-iso"],
|
|
||||||
capture_output=True, text=True, timeout=5,
|
|
||||||
)
|
|
||||||
if result.returncode != 0 or not result.stdout.strip():
|
|
||||||
return None
|
|
||||||
return result.stdout.strip()
|
|
||||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def query_service_state() -> Dict[str, Any]:
|
|
||||||
"""Query systemctl for the four separate service facts (§7.3, LC-9).
|
|
||||||
|
|
||||||
Returns dict with keys:
|
|
||||||
boot_enabled: bool
|
|
||||||
timer_active: bool
|
|
||||||
last_collect_ok: Optional[bool]
|
|
||||||
last_collect_age_s: Optional[int]
|
|
||||||
last_collect_reason: Optional[str]
|
|
||||||
"""
|
|
||||||
timer_props = _systemctl_show(
|
|
||||||
"fenris-collect.timer",
|
|
||||||
"UnitFileState", "ActiveState", "LastTriggerUSec",
|
|
||||||
)
|
|
||||||
service_props = _systemctl_show(
|
|
||||||
"fenris-collect.service",
|
|
||||||
"ActiveState", "ExecMainStatus", "ExecMainExitTimestamp",
|
|
||||||
)
|
|
||||||
|
|
||||||
boot_enabled_str = timer_props.get("UnitFileState", "")
|
|
||||||
boot_enabled = boot_enabled_str == "enabled"
|
|
||||||
|
|
||||||
active_state = timer_props.get("ActiveState", "inactive")
|
|
||||||
timer_active = active_state == "active"
|
|
||||||
|
|
||||||
last_collect_ok = None
|
|
||||||
last_collect_age_s = None
|
|
||||||
last_collect_reason = None
|
|
||||||
|
|
||||||
last_trigger = timer_props.get("LastTriggerUSec", "")
|
|
||||||
if last_trigger and last_trigger != "n/a":
|
|
||||||
try:
|
|
||||||
trigger_dt = datetime.fromisoformat(last_trigger.replace("Z", "+00:00"))
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
last_collect_age_s = int((now - trigger_dt).total_seconds())
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
exec_status = service_props.get("ExecMainStatus", "")
|
|
||||||
if exec_status:
|
|
||||||
try:
|
|
||||||
exit_code = int(exec_status)
|
|
||||||
last_collect_ok = exit_code == 0
|
|
||||||
if exit_code != 0:
|
|
||||||
last_collect_reason = "exit code %d" % exit_code
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
return {
|
|
||||||
"boot_enabled": boot_enabled,
|
|
||||||
"timer_active": timer_active,
|
|
||||||
"last_collect_ok": last_collect_ok,
|
|
||||||
"last_collect_age_s": last_collect_age_s,
|
|
||||||
"last_collect_reason": last_collect_reason,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -601,7 +519,7 @@ def get_status(store_path: Optional[Path] = None, clock_now: Optional[datetime]
|
|||||||
if store_fault or newer_schema:
|
if store_fault or newer_schema:
|
||||||
journal_hint = None
|
journal_hint = None
|
||||||
if query_journal:
|
if query_journal:
|
||||||
journal_hint = _journalctl_hint("fenris-collect.service")
|
journal_hint = _journalctl_hint()
|
||||||
service["freshness"] = "unknown"
|
service["freshness"] = "unknown"
|
||||||
service["freshness_age_s"] = None
|
service["freshness_age_s"] = None
|
||||||
return _format_projection(
|
return _format_projection(
|
||||||
@@ -663,7 +581,7 @@ def get_status(store_path: Optional[Path] = None, clock_now: Optional[datetime]
|
|||||||
# --- Journal hint on failure or staleness (§8.8) ---
|
# --- Journal hint on failure or staleness (§8.8) ---
|
||||||
journal_hint = None
|
journal_hint = None
|
||||||
if query_journal and freshness in ("missed", "stale"):
|
if query_journal and freshness in ("missed", "stale"):
|
||||||
journal_hint = _journalctl_hint("fenris-collect.service")
|
journal_hint = _journalctl_hint()
|
||||||
|
|
||||||
# --- Compose output ---
|
# --- Compose output ---
|
||||||
result = _format_projection(
|
result = _format_projection(
|
||||||
|
|||||||
@@ -0,0 +1,659 @@
|
|||||||
|
"""Tests for the init system abstraction layer (issue #84).
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- Init system detection (systemd vs runit)
|
||||||
|
- systemd backend functions (enable, disable, collect, query state)
|
||||||
|
- runit backend functions (enable, disable, collect, query state)
|
||||||
|
- Public API dispatching to correct backend
|
||||||
|
- Edge cases (already enabled/disabled, missing files, timeouts)
|
||||||
|
|
||||||
|
Spec: ADR 0008, §8.4, §8.5, §8.6, §8.7, §8.8
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import tempfile
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch, MagicMock, PropertyMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||||
|
|
||||||
|
from fenris.init_system import (
|
||||||
|
InitSystem,
|
||||||
|
detect_init_system,
|
||||||
|
get_init_system,
|
||||||
|
reset_init_system_cache,
|
||||||
|
_systemd_enable,
|
||||||
|
_systemd_disable,
|
||||||
|
_systemd_collect,
|
||||||
|
_systemd_query_state,
|
||||||
|
_runit_enable,
|
||||||
|
_runit_disable,
|
||||||
|
_runit_collect,
|
||||||
|
_runit_query_state,
|
||||||
|
_runit_is_enabled,
|
||||||
|
_runit_is_running,
|
||||||
|
enable_timer,
|
||||||
|
disable_timer,
|
||||||
|
collect_now,
|
||||||
|
query_service_state,
|
||||||
|
journal_hint,
|
||||||
|
FENRIS_SV_DIR,
|
||||||
|
FENRIS_SERVICE_LINK,
|
||||||
|
COLLECT_TIMEOUT_S,
|
||||||
|
)
|
||||||
|
from fenris.store import init_store
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def reset_cache():
|
||||||
|
"""Reset the init system cache before each test."""
|
||||||
|
reset_init_system_cache()
|
||||||
|
yield
|
||||||
|
reset_init_system_cache()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Init system detection
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestInitSystemDetection:
|
||||||
|
"""Test init system detection logic."""
|
||||||
|
|
||||||
|
def test_detect_systemd_by_run_directory(self):
|
||||||
|
"""Systemd detected via /run/systemd/system directory."""
|
||||||
|
with patch("pathlib.Path.exists") as mock_exists:
|
||||||
|
mock_exists.return_value = True
|
||||||
|
reset_init_system_cache()
|
||||||
|
result = detect_init_system()
|
||||||
|
assert result == InitSystem.SYSTEMD
|
||||||
|
|
||||||
|
def test_detect_systemd_by_pid1(self):
|
||||||
|
"""Systemd detected via PID 1 name."""
|
||||||
|
original_exists = Path.exists
|
||||||
|
original_read_text = Path.read_text
|
||||||
|
|
||||||
|
def mock_exists(self_path):
|
||||||
|
if str(self_path) == "/run/systemd/system":
|
||||||
|
return False
|
||||||
|
return original_exists(self_path)
|
||||||
|
|
||||||
|
def mock_read_text(self_path):
|
||||||
|
if str(self_path) == "/proc/1/comm":
|
||||||
|
return "systemd"
|
||||||
|
return original_read_text(self_path)
|
||||||
|
|
||||||
|
with patch("pathlib.Path.exists", mock_exists), \
|
||||||
|
patch("pathlib.Path.read_text", mock_read_text):
|
||||||
|
reset_init_system_cache()
|
||||||
|
result = detect_init_system()
|
||||||
|
assert result == InitSystem.SYSTEMD
|
||||||
|
|
||||||
|
def test_detect_runit_by_pid1(self):
|
||||||
|
"""Runit detected via PID 1 name."""
|
||||||
|
original_exists = Path.exists
|
||||||
|
original_read_text = Path.read_text
|
||||||
|
|
||||||
|
def mock_exists(self_path):
|
||||||
|
if str(self_path) == "/run/systemd/system":
|
||||||
|
return False
|
||||||
|
return original_exists(self_path)
|
||||||
|
|
||||||
|
def mock_read_text(self_path):
|
||||||
|
if str(self_path) == "/proc/1/comm":
|
||||||
|
return "runsv"
|
||||||
|
return original_read_text(self_path)
|
||||||
|
|
||||||
|
with patch("pathlib.Path.exists", mock_exists), \
|
||||||
|
patch("pathlib.Path.read_text", mock_read_text):
|
||||||
|
reset_init_system_cache()
|
||||||
|
result = detect_init_system()
|
||||||
|
assert result == InitSystem.RUNIT
|
||||||
|
|
||||||
|
def test_detect_runit_by_etc_sv(self):
|
||||||
|
"""Runit detected via /etc/sv directory."""
|
||||||
|
original_exists = Path.exists
|
||||||
|
original_read_text = Path.read_text
|
||||||
|
|
||||||
|
def mock_exists(self_path):
|
||||||
|
if str(self_path) == "/run/systemd/system":
|
||||||
|
return False
|
||||||
|
if str(self_path) == "/etc/sv":
|
||||||
|
return True
|
||||||
|
return original_exists(self_path)
|
||||||
|
|
||||||
|
def mock_read_text(self_path):
|
||||||
|
if str(self_path) == "/proc/1/comm":
|
||||||
|
raise OSError("no such file")
|
||||||
|
return original_read_text(self_path)
|
||||||
|
|
||||||
|
with patch("pathlib.Path.exists", mock_exists), \
|
||||||
|
patch("pathlib.Path.read_text", mock_read_text):
|
||||||
|
reset_init_system_cache()
|
||||||
|
result = detect_init_system()
|
||||||
|
assert result == InitSystem.RUNIT
|
||||||
|
|
||||||
|
def test_default_to_systemd(self):
|
||||||
|
"""Default to systemd when no detection matches."""
|
||||||
|
original_exists = Path.exists
|
||||||
|
original_read_text = Path.read_text
|
||||||
|
original_is_dir = Path.is_dir
|
||||||
|
|
||||||
|
def mock_exists(self_path):
|
||||||
|
if str(self_path) == "/run/systemd/system":
|
||||||
|
return False
|
||||||
|
return original_exists(self_path)
|
||||||
|
|
||||||
|
def mock_is_dir(self_path):
|
||||||
|
if str(self_path) == "/etc/sv":
|
||||||
|
return False
|
||||||
|
return original_is_dir(self_path)
|
||||||
|
|
||||||
|
def mock_read_text(self_path):
|
||||||
|
if str(self_path) == "/proc/1/comm":
|
||||||
|
raise OSError("no such file")
|
||||||
|
return original_read_text(self_path)
|
||||||
|
|
||||||
|
with patch("pathlib.Path.exists", mock_exists), \
|
||||||
|
patch("pathlib.Path.is_dir", mock_is_dir), \
|
||||||
|
patch("pathlib.Path.read_text", mock_read_text):
|
||||||
|
reset_init_system_cache()
|
||||||
|
result = detect_init_system()
|
||||||
|
assert result == InitSystem.SYSTEMD
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# systemd backend
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestSystemdEnable:
|
||||||
|
"""Test systemd enable function."""
|
||||||
|
|
||||||
|
def test_enable_now(self):
|
||||||
|
"""Enable with --now flag."""
|
||||||
|
with patch("fenris.init_system.subprocess") as mock_sub:
|
||||||
|
mock_sub.run.return_value = MagicMock(returncode=0)
|
||||||
|
_systemd_enable(now=True)
|
||||||
|
mock_sub.run.assert_called_once_with(
|
||||||
|
["systemctl", "enable", "--now", "fenris-collect.timer"],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_enable_without_now(self):
|
||||||
|
"""Enable without --now flag."""
|
||||||
|
with patch("fenris.init_system.subprocess") as mock_sub:
|
||||||
|
mock_sub.run.return_value = MagicMock(returncode=0)
|
||||||
|
_systemd_enable(now=False)
|
||||||
|
mock_sub.run.assert_called_once_with(
|
||||||
|
["systemctl", "enable", "fenris-collect.timer"],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_enable_failure_exits(self):
|
||||||
|
"""Enable failure exits with error."""
|
||||||
|
with patch("fenris.init_system.subprocess") as mock_sub:
|
||||||
|
mock_sub.run.return_value = MagicMock(
|
||||||
|
returncode=1, stderr="Unit not found"
|
||||||
|
)
|
||||||
|
with pytest.raises(SystemExit) as exc_info:
|
||||||
|
_systemd_enable(now=True)
|
||||||
|
assert exc_info.value.code == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestSystemdDisable:
|
||||||
|
"""Test systemd disable function."""
|
||||||
|
|
||||||
|
def test_disable_now(self):
|
||||||
|
"""Disable with --now flag."""
|
||||||
|
with patch("fenris.init_system.subprocess") as mock_sub:
|
||||||
|
mock_sub.run.return_value = MagicMock(returncode=0)
|
||||||
|
_systemd_disable(now=True)
|
||||||
|
mock_sub.run.assert_called_once_with(
|
||||||
|
["systemctl", "disable", "--now", "fenris-collect.timer"],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_disable_without_now(self):
|
||||||
|
"""Disable without --now flag."""
|
||||||
|
with patch("fenris.init_system.subprocess") as mock_sub:
|
||||||
|
mock_sub.run.return_value = MagicMock(returncode=0)
|
||||||
|
_systemd_disable(now=False)
|
||||||
|
mock_sub.run.assert_called_once_with(
|
||||||
|
["systemctl", "disable", "fenris-collect.timer"],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSystemdCollect:
|
||||||
|
"""Test systemd collect function."""
|
||||||
|
|
||||||
|
def test_collect_success(self):
|
||||||
|
"""Successful collection."""
|
||||||
|
with patch("fenris.init_system.subprocess") as mock_sub:
|
||||||
|
mock_sub.run.return_value = MagicMock(returncode=0)
|
||||||
|
_systemd_collect()
|
||||||
|
mock_sub.run.assert_called_once_with(
|
||||||
|
["systemctl", "start", "fenris-collect.service"],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_collect_failure_exits(self):
|
||||||
|
"""Collection failure exits with error."""
|
||||||
|
with patch("fenris.init_system.subprocess") as mock_sub:
|
||||||
|
mock_sub.run.return_value = MagicMock(
|
||||||
|
returncode=1, stderr="Unit not found"
|
||||||
|
)
|
||||||
|
with pytest.raises(SystemExit) as exc_info:
|
||||||
|
_systemd_collect()
|
||||||
|
assert exc_info.value.code == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestSystemdQueryState:
|
||||||
|
"""Test systemd query state function."""
|
||||||
|
|
||||||
|
def test_query_state_enabled_active(self):
|
||||||
|
"""Query state for enabled and active timer."""
|
||||||
|
with patch("fenris.init_system._systemctl_show") as mock_show:
|
||||||
|
def mock_show_fn(unit, *props):
|
||||||
|
if unit == "fenris-collect.timer":
|
||||||
|
return {
|
||||||
|
"UnitFileState": "enabled",
|
||||||
|
"ActiveState": "active",
|
||||||
|
"LastTriggerUSec": "2026-09-01T12:00:00Z",
|
||||||
|
}
|
||||||
|
elif unit == "fenris-collect.service":
|
||||||
|
return {
|
||||||
|
"ActiveState": "inactive",
|
||||||
|
"ExecMainStatus": "0",
|
||||||
|
"ExecMainExitTimestamp": "2026-09-01T12:00:30Z",
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
mock_show.side_effect = mock_show_fn
|
||||||
|
|
||||||
|
result = _systemd_query_state()
|
||||||
|
|
||||||
|
assert result["boot_enabled"] is True
|
||||||
|
assert result["timer_active"] is True
|
||||||
|
assert result["last_collect_ok"] is True
|
||||||
|
assert result["last_collect_age_s"] is not None
|
||||||
|
|
||||||
|
def test_query_state_disabled_inactive(self):
|
||||||
|
"""Query state for disabled and inactive timer."""
|
||||||
|
with patch("fenris.init_system._systemctl_show") as mock_show:
|
||||||
|
mock_show.return_value = {}
|
||||||
|
result = _systemd_query_state()
|
||||||
|
assert result["boot_enabled"] is False
|
||||||
|
assert result["timer_active"] is False
|
||||||
|
assert result["last_collect_ok"] is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# runit backend
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestRunitEnable:
|
||||||
|
"""Test runit enable function."""
|
||||||
|
|
||||||
|
def test_enable_creates_symlink(self, tmp_path):
|
||||||
|
"""Enable creates symlink to service directory."""
|
||||||
|
sv_dir = tmp_path / "sv" / "fenris-collect"
|
||||||
|
sv_dir.mkdir(parents=True)
|
||||||
|
service_link = tmp_path / "service" / "fenris-collect"
|
||||||
|
service_link.parent.mkdir(parents=True)
|
||||||
|
|
||||||
|
with patch("fenris.init_system.FENRIS_SV_DIR", sv_dir), \
|
||||||
|
patch("fenris.init_system.FENRIS_SERVICE_LINK", service_link):
|
||||||
|
_runit_enable(_now=True)
|
||||||
|
assert service_link.exists()
|
||||||
|
assert service_link.is_symlink()
|
||||||
|
assert service_link.resolve() == sv_dir
|
||||||
|
|
||||||
|
def test_enable_removes_down_file(self, tmp_path):
|
||||||
|
"""Enable removes the 'down' file if present."""
|
||||||
|
sv_dir = tmp_path / "sv" / "fenris-collect"
|
||||||
|
sv_dir.mkdir(parents=True)
|
||||||
|
(sv_dir / "down").touch()
|
||||||
|
service_link = tmp_path / "service" / "fenris-collect"
|
||||||
|
service_link.parent.mkdir(parents=True)
|
||||||
|
|
||||||
|
with patch("fenris.init_system.FENRIS_SV_DIR", sv_dir), \
|
||||||
|
patch("fenris.init_system.FENRIS_SERVICE_LINK", service_link):
|
||||||
|
_runit_enable(_now=True)
|
||||||
|
assert not (sv_dir / "down").exists()
|
||||||
|
|
||||||
|
def test_enable_idempotent(self, tmp_path):
|
||||||
|
"""Enable is idempotent when already enabled."""
|
||||||
|
sv_dir = tmp_path / "sv" / "fenris-collect"
|
||||||
|
sv_dir.mkdir(parents=True)
|
||||||
|
service_link = tmp_path / "service" / "fenris-collect"
|
||||||
|
service_link.parent.mkdir(parents=True)
|
||||||
|
service_link.symlink_to(sv_dir)
|
||||||
|
|
||||||
|
with patch("fenris.init_system.FENRIS_SV_DIR", sv_dir), \
|
||||||
|
patch("fenris.init_system.FENRIS_SERVICE_LINK", service_link):
|
||||||
|
_runit_enable(_now=True)
|
||||||
|
assert service_link.exists()
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunitDisable:
|
||||||
|
"""Test runit disable function."""
|
||||||
|
|
||||||
|
def test_disable_removes_symlink(self, tmp_path):
|
||||||
|
"""Disable removes the service symlink."""
|
||||||
|
sv_dir = tmp_path / "sv" / "fenris-collect"
|
||||||
|
sv_dir.mkdir(parents=True)
|
||||||
|
service_link = tmp_path / "service" / "fenris-collect"
|
||||||
|
service_link.parent.mkdir(parents=True)
|
||||||
|
service_link.symlink_to(sv_dir)
|
||||||
|
|
||||||
|
with patch("fenris.init_system.FENRIS_SV_DIR", sv_dir), \
|
||||||
|
patch("fenris.init_system.FENRIS_SERVICE_LINK", service_link):
|
||||||
|
_runit_disable(_now=True)
|
||||||
|
assert not service_link.exists()
|
||||||
|
assert (sv_dir / "down").exists()
|
||||||
|
|
||||||
|
def test_disable_idempotent(self, tmp_path):
|
||||||
|
"""Disable is idempotent when already disabled."""
|
||||||
|
sv_dir = tmp_path / "sv" / "fenris-collect"
|
||||||
|
sv_dir.mkdir(parents=True)
|
||||||
|
service_link = tmp_path / "service" / "fenris-collect"
|
||||||
|
service_link.parent.mkdir(parents=True)
|
||||||
|
|
||||||
|
with patch("fenris.init_system.FENRIS_SV_DIR", sv_dir), \
|
||||||
|
patch("fenris.init_system.FENRIS_SERVICE_LINK", service_link):
|
||||||
|
_runit_disable(_now=True)
|
||||||
|
assert not service_link.exists()
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunitCollect:
|
||||||
|
"""Test runit collect function."""
|
||||||
|
|
||||||
|
def test_collect_uses_flock(self):
|
||||||
|
"""Collect uses flock for serialization."""
|
||||||
|
with patch("fenris.init_system.subprocess") as mock_sub, \
|
||||||
|
patch("fenris.init_system.Path") as mock_path:
|
||||||
|
mock_path.return_value.exists.return_value = True
|
||||||
|
mock_sub.run.return_value = MagicMock(returncode=0)
|
||||||
|
_runit_collect()
|
||||||
|
# Verify flock was used
|
||||||
|
call_args = mock_sub.run.call_args[0][0]
|
||||||
|
assert "flock" in call_args
|
||||||
|
|
||||||
|
def test_collect_timeout_exits(self):
|
||||||
|
"""Collection timeout exits with error."""
|
||||||
|
with patch("fenris.init_system.subprocess") as mock_sub, \
|
||||||
|
patch("fenris.init_system.Path") as mock_path:
|
||||||
|
mock_path.return_value.exists.return_value = True
|
||||||
|
mock_sub.run.return_value = MagicMock(returncode=124)
|
||||||
|
with pytest.raises(SystemExit) as exc_info:
|
||||||
|
_runit_collect()
|
||||||
|
assert exc_info.value.code == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunitQueryState:
|
||||||
|
"""Test runit query state function."""
|
||||||
|
|
||||||
|
def test_query_state_enabled_running(self, tmp_path):
|
||||||
|
"""Query state for enabled and running service."""
|
||||||
|
sv_dir = tmp_path / "sv" / "fenris-collect"
|
||||||
|
sv_dir.mkdir(parents=True)
|
||||||
|
supervise_dir = sv_dir / "supervise"
|
||||||
|
supervise_dir.mkdir(parents=True)
|
||||||
|
(supervise_dir / "pid").write_text("12345")
|
||||||
|
service_link = tmp_path / "service" / "fenris-collect"
|
||||||
|
service_link.parent.mkdir(parents=True)
|
||||||
|
service_link.symlink_to(sv_dir)
|
||||||
|
|
||||||
|
with patch("fenris.init_system.FENRIS_SV_DIR", sv_dir), \
|
||||||
|
patch("fenris.init_system.FENRIS_SERVICE_LINK", service_link), \
|
||||||
|
patch("os.kill") as mock_kill:
|
||||||
|
mock_kill.return_value = True # Process exists
|
||||||
|
result = _runit_query_state()
|
||||||
|
assert result["boot_enabled"] is True
|
||||||
|
assert result["timer_active"] is True
|
||||||
|
|
||||||
|
def test_query_state_disabled_not_running(self, tmp_path):
|
||||||
|
"""Query state for disabled and not running service."""
|
||||||
|
sv_dir = tmp_path / "sv" / "fenris-collect"
|
||||||
|
sv_dir.mkdir(parents=True)
|
||||||
|
service_link = tmp_path / "service" / "fenris-collect"
|
||||||
|
service_link.parent.mkdir(parents=True)
|
||||||
|
|
||||||
|
with patch("fenris.init_system.FENRIS_SV_DIR", sv_dir), \
|
||||||
|
patch("fenris.init_system.FENRIS_SERVICE_LINK", service_link):
|
||||||
|
result = _runit_query_state()
|
||||||
|
assert result["boot_enabled"] is False
|
||||||
|
assert result["timer_active"] is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunitIsEnabled:
|
||||||
|
"""Test runit is_enabled check."""
|
||||||
|
|
||||||
|
def test_is_enabled_true(self, tmp_path):
|
||||||
|
"""Service is enabled when symlink exists."""
|
||||||
|
sv_dir = tmp_path / "sv" / "fenris-collect"
|
||||||
|
sv_dir.mkdir(parents=True)
|
||||||
|
service_link = tmp_path / "service" / "fenris-collect"
|
||||||
|
service_link.parent.mkdir(parents=True)
|
||||||
|
service_link.symlink_to(sv_dir)
|
||||||
|
|
||||||
|
with patch("fenris.init_system.FENRIS_SERVICE_LINK", service_link):
|
||||||
|
assert _runit_is_enabled() is True
|
||||||
|
|
||||||
|
def test_is_enabled_false(self, tmp_path):
|
||||||
|
"""Service is disabled when symlink does not exist."""
|
||||||
|
service_link = tmp_path / "service" / "fenris-collect"
|
||||||
|
|
||||||
|
with patch("fenris.init_system.FENRIS_SERVICE_LINK", service_link):
|
||||||
|
assert _runit_is_enabled() is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunitIsRunning:
|
||||||
|
"""Test runit is_running check."""
|
||||||
|
|
||||||
|
def test_is_running_true(self, tmp_path):
|
||||||
|
"""Service is running when PID file exists and process is alive."""
|
||||||
|
sv_dir = tmp_path / "sv" / "fenris-collect"
|
||||||
|
supervise_dir = sv_dir / "supervise"
|
||||||
|
supervise_dir.mkdir(parents=True)
|
||||||
|
(supervise_dir / "pid").write_text("12345")
|
||||||
|
|
||||||
|
with patch("fenris.init_system.FENRIS_SV_DIR", sv_dir), \
|
||||||
|
patch("os.kill") as mock_kill:
|
||||||
|
mock_kill.return_value = True
|
||||||
|
assert _runit_is_running() is True
|
||||||
|
|
||||||
|
def test_is_running_false_no_pid(self, tmp_path):
|
||||||
|
"""Service is not running when PID file does not exist."""
|
||||||
|
sv_dir = tmp_path / "sv" / "fenris-collect"
|
||||||
|
sv_dir.mkdir(parents=True)
|
||||||
|
|
||||||
|
with patch("fenris.init_system.FENRIS_SV_DIR", sv_dir):
|
||||||
|
assert _runit_is_running() is False
|
||||||
|
|
||||||
|
def test_is_running_false_dead_process(self, tmp_path):
|
||||||
|
"""Service is not running when process is dead."""
|
||||||
|
sv_dir = tmp_path / "sv" / "fenris-collect"
|
||||||
|
supervise_dir = sv_dir / "supervise"
|
||||||
|
supervise_dir.mkdir(parents=True)
|
||||||
|
(supervise_dir / "pid").write_text("12345")
|
||||||
|
|
||||||
|
with patch("fenris.init_system.FENRIS_SV_DIR", sv_dir), \
|
||||||
|
patch("os.kill", side_effect=OSError("No such process")):
|
||||||
|
assert _runit_is_running() is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API dispatching
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestPublicAPI:
|
||||||
|
"""Test public API dispatches to correct backend."""
|
||||||
|
|
||||||
|
def test_enable_dispatches_to_systemd(self):
|
||||||
|
"""enable_timer dispatches to systemd on systemd system."""
|
||||||
|
with patch("fenris.init_system.get_init_system", return_value=InitSystem.SYSTEMD), \
|
||||||
|
patch("fenris.init_system._systemd_enable") as mock_enable:
|
||||||
|
enable_timer(now=True)
|
||||||
|
mock_enable.assert_called_once_with(True)
|
||||||
|
|
||||||
|
def test_enable_dispatches_to_runit(self):
|
||||||
|
"""enable_timer dispatches to runit on runit system."""
|
||||||
|
with patch("fenris.init_system.get_init_system", return_value=InitSystem.RUNIT), \
|
||||||
|
patch("fenris.init_system._runit_enable") as mock_enable:
|
||||||
|
enable_timer(now=True)
|
||||||
|
mock_enable.assert_called_once_with(True)
|
||||||
|
|
||||||
|
def test_disable_dispatches_to_systemd(self):
|
||||||
|
"""disable_timer dispatches to systemd on systemd system."""
|
||||||
|
with patch("fenris.init_system.get_init_system", return_value=InitSystem.SYSTEMD), \
|
||||||
|
patch("fenris.init_system._systemd_disable") as mock_disable:
|
||||||
|
disable_timer(now=False)
|
||||||
|
mock_disable.assert_called_once_with(False)
|
||||||
|
|
||||||
|
def test_disable_dispatches_to_runit(self):
|
||||||
|
"""disable_timer dispatches to runit on runit system."""
|
||||||
|
with patch("fenris.init_system.get_init_system", return_value=InitSystem.RUNIT), \
|
||||||
|
patch("fenris.init_system._runit_disable") as mock_disable:
|
||||||
|
disable_timer(now=False)
|
||||||
|
mock_disable.assert_called_once_with(False)
|
||||||
|
|
||||||
|
def test_collect_dispatches_to_systemd(self):
|
||||||
|
"""collect_now dispatches to systemd on systemd system."""
|
||||||
|
with patch("fenris.init_system.get_init_system", return_value=InitSystem.SYSTEMD), \
|
||||||
|
patch("fenris.init_system._systemd_collect") as mock_collect:
|
||||||
|
collect_now()
|
||||||
|
mock_collect.assert_called_once()
|
||||||
|
|
||||||
|
def test_collect_dispatches_to_runit(self):
|
||||||
|
"""collect_now dispatches to runit on runit system."""
|
||||||
|
with patch("fenris.init_system.get_init_system", return_value=InitSystem.RUNIT), \
|
||||||
|
patch("fenris.init_system._runit_collect") as mock_collect:
|
||||||
|
collect_now()
|
||||||
|
mock_collect.assert_called_once()
|
||||||
|
|
||||||
|
def test_query_state_dispatches_to_systemd(self):
|
||||||
|
"""query_service_state dispatches to systemd on systemd system."""
|
||||||
|
with patch("fenris.init_system.get_init_system", return_value=InitSystem.SYSTEMD), \
|
||||||
|
patch("fenris.init_system._systemd_query_state") as mock_query:
|
||||||
|
query_service_state()
|
||||||
|
mock_query.assert_called_once()
|
||||||
|
|
||||||
|
def test_query_state_dispatches_to_runit(self):
|
||||||
|
"""query_service_state dispatches to runit on runit system."""
|
||||||
|
with patch("fenris.init_system.get_init_system", return_value=InitSystem.RUNIT), \
|
||||||
|
patch("fenris.init_system._runit_query_state") as mock_query:
|
||||||
|
query_service_state()
|
||||||
|
mock_query.assert_called_once()
|
||||||
|
|
||||||
|
def test_journal_hint_dispatches_to_systemd(self):
|
||||||
|
"""journal_hint dispatches to systemd on systemd system."""
|
||||||
|
with patch("fenris.init_system.get_init_system", return_value=InitSystem.SYSTEMD), \
|
||||||
|
patch("fenris.init_system._systemd_journal_hint") as mock_hint:
|
||||||
|
journal_hint(lines=3, unit="fenris-collect.service")
|
||||||
|
mock_hint.assert_called_once_with(3)
|
||||||
|
|
||||||
|
def test_journal_hint_dispatches_to_runit(self):
|
||||||
|
"""journal_hint dispatches to runit on runit system."""
|
||||||
|
with patch("fenris.init_system.get_init_system", return_value=InitSystem.RUNIT), \
|
||||||
|
patch("fenris.init_system._runit_journal_hint") as mock_hint:
|
||||||
|
journal_hint(lines=3, unit="fenris-collect.service")
|
||||||
|
mock_hint.assert_called_once_with(3)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Constants and configuration
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestConstants:
|
||||||
|
"""Test constants match spec requirements."""
|
||||||
|
|
||||||
|
def test_collect_timeout(self):
|
||||||
|
"""Collection timeout is 90 seconds (bounded execution)."""
|
||||||
|
assert COLLECT_TIMEOUT_S == 90
|
||||||
|
|
||||||
|
def test_init_system_enum(self):
|
||||||
|
"""InitSystem enum has systemd and runit variants."""
|
||||||
|
assert InitSystem.SYSTEMD.value == "systemd"
|
||||||
|
assert InitSystem.RUNIT.value == "runit"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Integration with monitor.py
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestMonitorIntegration:
|
||||||
|
"""Test that monitor.py uses the abstraction layer correctly."""
|
||||||
|
|
||||||
|
def test_cmd_enable_uses_init_system(self, tmp_path):
|
||||||
|
"""cmd_enable uses init_system.enable_timer."""
|
||||||
|
from fenris.monitor import cmd_enable
|
||||||
|
from argparse import Namespace
|
||||||
|
|
||||||
|
store_path = tmp_path / "observations.db"
|
||||||
|
init_store(store_path)
|
||||||
|
args = Namespace(now=True, store_path=store_path)
|
||||||
|
|
||||||
|
with patch("fenris.monitor.enable_timer") as mock_enable:
|
||||||
|
cmd_enable(args)
|
||||||
|
mock_enable.assert_called_once_with(True)
|
||||||
|
|
||||||
|
def test_cmd_disable_uses_init_system(self, tmp_path):
|
||||||
|
"""cmd_disable uses init_system.disable_timer."""
|
||||||
|
from fenris.monitor import cmd_disable
|
||||||
|
from argparse import Namespace
|
||||||
|
|
||||||
|
store_path = tmp_path / "observations.db"
|
||||||
|
init_store(store_path)
|
||||||
|
args = Namespace(now=True, store_path=store_path)
|
||||||
|
|
||||||
|
with patch("fenris.monitor.disable_timer") as mock_disable:
|
||||||
|
cmd_disable(args)
|
||||||
|
mock_disable.assert_called_once_with(True)
|
||||||
|
|
||||||
|
def test_cmd_collect_uses_init_system(self):
|
||||||
|
"""cmd_collect uses init_system.collect_now."""
|
||||||
|
from fenris.monitor import cmd_collect
|
||||||
|
from argparse import Namespace
|
||||||
|
|
||||||
|
args = Namespace()
|
||||||
|
|
||||||
|
with patch("fenris.monitor.collect_now") as mock_collect:
|
||||||
|
cmd_collect(args)
|
||||||
|
mock_collect.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Edge cases
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestEdgeCases:
|
||||||
|
"""Test edge cases and error handling."""
|
||||||
|
|
||||||
|
def test_systemd_enable_failure_produces_stderr(self):
|
||||||
|
"""Systemd enable failure produces error message on stderr."""
|
||||||
|
with patch("fenris.init_system.subprocess") as mock_sub:
|
||||||
|
mock_sub.run.return_value = MagicMock(
|
||||||
|
returncode=1, stderr="Permission denied"
|
||||||
|
)
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
_systemd_enable(now=True)
|
||||||
|
|
||||||
|
def test_runit_collect_missing_script(self):
|
||||||
|
"""Runit collect exits when fenris-collect script not found."""
|
||||||
|
with patch("fenris.init_system.Path") as mock_path:
|
||||||
|
mock_path.return_value.exists.return_value = False
|
||||||
|
with pytest.raises(SystemExit) as exc_info:
|
||||||
|
_runit_collect()
|
||||||
|
assert exc_info.value.code == 1
|
||||||
|
|
||||||
|
def test_systemd_query_state_timeout(self):
|
||||||
|
"""Systemd query state handles subprocess timeout."""
|
||||||
|
with patch("fenris.init_system._systemctl_show") as mock_show:
|
||||||
|
mock_show.return_value = {}
|
||||||
|
result = _systemd_query_state()
|
||||||
|
assert result["boot_enabled"] is False
|
||||||
|
assert result["timer_active"] is False
|
||||||
+11
-26
@@ -56,8 +56,7 @@ class TestEnableIdempotentMatrix:
|
|||||||
"""A fresh package install has a store directory but no database yet."""
|
"""A fresh package install has a store directory but no database yet."""
|
||||||
args = MagicMock(now=False, store_path=store_path)
|
args = MagicMock(now=False, store_path=store_path)
|
||||||
|
|
||||||
with patch("fenris.monitor.subprocess") as mock_sub:
|
with patch("fenris.monitor.enable_timer") as mock_enable:
|
||||||
mock_sub.run.return_value = MagicMock(returncode=0)
|
|
||||||
cmd_enable(args)
|
cmd_enable(args)
|
||||||
|
|
||||||
conn = init_store(store_path)
|
conn = init_store(store_path)
|
||||||
@@ -74,8 +73,7 @@ class TestEnableIdempotentMatrix:
|
|||||||
|
|
||||||
args = MagicMock(now=False, store_path=store_path)
|
args = MagicMock(now=False, store_path=store_path)
|
||||||
|
|
||||||
with patch("fenris.monitor.subprocess") as mock_sub:
|
with patch("fenris.monitor.enable_timer") as mock_enable:
|
||||||
mock_sub.run.return_value = MagicMock(returncode=0)
|
|
||||||
cmd_enable(args)
|
cmd_enable(args)
|
||||||
|
|
||||||
# Period should be open
|
# Period should be open
|
||||||
@@ -100,8 +98,7 @@ class TestEnableIdempotentMatrix:
|
|||||||
|
|
||||||
args = MagicMock(now=True, store_path=store_path)
|
args = MagicMock(now=True, store_path=store_path)
|
||||||
|
|
||||||
with patch("fenris.monitor.subprocess") as mock_sub:
|
with patch("fenris.monitor.enable_timer") as mock_enable:
|
||||||
mock_sub.run.return_value = MagicMock(returncode=0)
|
|
||||||
cmd_enable(args)
|
cmd_enable(args)
|
||||||
|
|
||||||
# Should still have exactly one open period
|
# Should still have exactly one open period
|
||||||
@@ -125,8 +122,7 @@ class TestEnableIdempotentMatrix:
|
|||||||
|
|
||||||
args = MagicMock(now=True, store_path=store_path)
|
args = MagicMock(now=True, store_path=store_path)
|
||||||
|
|
||||||
with patch("fenris.monitor.subprocess") as mock_sub:
|
with patch("fenris.monitor.enable_timer") as mock_enable:
|
||||||
mock_sub.run.return_value = MagicMock(returncode=0)
|
|
||||||
cmd_enable(args)
|
cmd_enable(args)
|
||||||
|
|
||||||
# Should have a new open period
|
# Should have a new open period
|
||||||
@@ -155,8 +151,7 @@ class TestDisableIdempotentMatrix:
|
|||||||
|
|
||||||
args = MagicMock(now=True, store_path=store_path)
|
args = MagicMock(now=True, store_path=store_path)
|
||||||
|
|
||||||
with patch("fenris.monitor.subprocess") as mock_sub:
|
with patch("fenris.monitor.disable_timer") as mock_disable:
|
||||||
mock_sub.run.return_value = MagicMock(returncode=0)
|
|
||||||
cmd_disable(args)
|
cmd_disable(args)
|
||||||
|
|
||||||
# Period should be closed with user_disabled
|
# Period should be closed with user_disabled
|
||||||
@@ -174,8 +169,7 @@ class TestDisableIdempotentMatrix:
|
|||||||
|
|
||||||
args = MagicMock(now=True, store_path=store_path)
|
args = MagicMock(now=True, store_path=store_path)
|
||||||
|
|
||||||
with patch("fenris.monitor.subprocess") as mock_sub:
|
with patch("fenris.monitor.disable_timer") as mock_disable:
|
||||||
mock_sub.run.return_value = MagicMock(returncode=0)
|
|
||||||
cmd_disable(args)
|
cmd_disable(args)
|
||||||
|
|
||||||
# No periods should exist
|
# No periods should exist
|
||||||
@@ -207,28 +201,19 @@ class TestDisableIdempotentMatrix:
|
|||||||
class TestCollectTrigger:
|
class TestCollectTrigger:
|
||||||
"""§8.7: On-demand collection via helper path."""
|
"""§8.7: On-demand collection via helper path."""
|
||||||
|
|
||||||
def test_collect_triggers_systemctl_start(self):
|
def test_collect_triggers_init_system(self):
|
||||||
"""Collect starts fenris-collect.service synchronously."""
|
"""Collect triggers init_system.collect_now."""
|
||||||
args = MagicMock()
|
args = MagicMock()
|
||||||
|
|
||||||
with patch("fenris.monitor.subprocess") as mock_sub:
|
with patch("fenris.monitor.collect_now") as mock_collect:
|
||||||
mock_sub.run.return_value = MagicMock(returncode=0)
|
|
||||||
cmd_collect(args)
|
cmd_collect(args)
|
||||||
|
mock_collect.assert_called_once()
|
||||||
mock_sub.run.assert_called_once_with(
|
|
||||||
["systemctl", "start", "fenris-collect.service"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_collect_failure_exits_nonzero(self):
|
def test_collect_failure_exits_nonzero(self):
|
||||||
"""Collect failure exits with nonzero status."""
|
"""Collect failure exits with nonzero status."""
|
||||||
args = MagicMock()
|
args = MagicMock()
|
||||||
|
|
||||||
with patch("fenris.monitor.subprocess") as mock_sub:
|
with patch("fenris.monitor.collect_now", side_effect=SystemExit(1)):
|
||||||
mock_sub.run.return_value = MagicMock(
|
|
||||||
returncode=1, stderr="Unit not found"
|
|
||||||
)
|
|
||||||
with pytest.raises(SystemExit) as exc_info:
|
with pytest.raises(SystemExit) as exc_info:
|
||||||
cmd_collect(args)
|
cmd_collect(args)
|
||||||
assert exc_info.value.code == 1
|
assert exc_info.value.code == 1
|
||||||
|
|||||||
Executable
+9
@@ -0,0 +1,9 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# fenris-collect runit log script — service output to /var/log/fenris-collect/
|
||||||
|
#
|
||||||
|
# Runit automatically pipes the service's stdout/stderr to this logger's stdin.
|
||||||
|
# The logger writes to runit's log directory for diagnostics.
|
||||||
|
#
|
||||||
|
# Spec: §8.8 (actionable native diagnostics)
|
||||||
|
exec chpst -u fenris:fenris \
|
||||||
|
svlogd -tt /var/log/fenris-collect/
|
||||||
Executable
+40
@@ -0,0 +1,40 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# fenris-collect runit run script — periodic NVMe collection scheduler.
|
||||||
|
#
|
||||||
|
# Runit service layout:
|
||||||
|
# /etc/sv/fenris-collect/run — this script (scheduler)
|
||||||
|
# /etc/sv/fenris-collect/log/run — logger to /var/log/fenris-collect/
|
||||||
|
# /var/service/fenris-collect — symlink to enable
|
||||||
|
# /etc/sv/fenris-collect/down — marker for dormant install
|
||||||
|
#
|
||||||
|
# Guarantees (must match systemd timer semantics):
|
||||||
|
# - Initial 2-minute boot delay (sleep 120 before first collect)
|
||||||
|
# - Completion-relative 5-minute cadence (sleep 300 after collect)
|
||||||
|
# - Bounded execution (timeout 90s on each collect)
|
||||||
|
# - No catch-up (fixed sleep interval, no Persistent=)
|
||||||
|
# - Serialized runs (runsv does not restart until exit)
|
||||||
|
# - Serialized on-demand (flock serializes fenris-collect execution)
|
||||||
|
#
|
||||||
|
# Spec: §8.4, §8.5, §8.6, ADR 0008
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
COLLECT_TIMEOUT=90
|
||||||
|
BOOT_DELAY=120
|
||||||
|
CADENCE=300
|
||||||
|
LOCK_FILE=/var/lib/fenris/fenris-collect.lock
|
||||||
|
|
||||||
|
# Ensure lock directory exists
|
||||||
|
mkdir -p "$(dirname "$LOCK_FILE")"
|
||||||
|
|
||||||
|
# Initial boot delay: sleep before first collection
|
||||||
|
sleep "$BOOT_DELAY"
|
||||||
|
|
||||||
|
# Collection loop: collect, then sleep for cadence
|
||||||
|
while true; do
|
||||||
|
flock --nonblock "$LOCK_FILE" \
|
||||||
|
timeout "$COLLECT_TIMEOUT" nice ionice -c3 \
|
||||||
|
/usr/libexec/fenris/fenris-collect \
|
||||||
|
2>&1 || true
|
||||||
|
|
||||||
|
sleep "$CADENCE"
|
||||||
|
done
|
||||||
Reference in New Issue
Block a user