Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a7661d81c | ||
|
|
fb683f52ba | ||
|
|
512df2ae83 |
@@ -8,4 +8,9 @@
|
||||
#
|
||||
# device = /dev/disk/by-id/nvme-Samsung_SSD_980_PRO_500GB_S5PANS0T123456
|
||||
#
|
||||
# The observation store path is optional and defaults to
|
||||
# /var/lib/fenris/observations.db when unset:
|
||||
#
|
||||
# store_path = /var/lib/fenris/observations.db
|
||||
#
|
||||
# See https://git.bongbetic.com/xavierk/Fenris for documentation.
|
||||
|
||||
@@ -44,4 +44,6 @@ print(f'Fenris migration: {n} step(s) applied') if n else None
|
||||
fi
|
||||
rm -f "${OLD_CONTENT}"
|
||||
done
|
||||
# Re-apply placement modes (store dir group access, issue #54)
|
||||
systemd-tmpfiles --create || true
|
||||
fi
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
# Type Path Mode User Group Age Argument
|
||||
d /var/lib/fenris 2750 root fenris - -
|
||||
d /var/lib/fenris 2770 root fenris - -
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "fenris"
|
||||
version = "0.3.1"
|
||||
version = "0.3.3"
|
||||
description = "NVMe wear monitor with persistent TUI"
|
||||
requires-python = ">=3.9"
|
||||
dependencies = [
|
||||
|
||||
@@ -88,7 +88,13 @@ def open_store_readonly(store_path: Path) -> sqlite3.Connection:
|
||||
|
||||
Raises StoreFault if unreadable, NewerSchema if user_version > SCHEMA_VERSION.
|
||||
"""
|
||||
if not store_path.exists():
|
||||
try:
|
||||
exists = store_path.exists()
|
||||
except OSError as e:
|
||||
# A non-group user stat()ing a 2750 store directory gets
|
||||
# PermissionError before any StoreFault can be raised (issue #54).
|
||||
raise StoreFault("observation store not readable: %s" % e)
|
||||
if not exists:
|
||||
raise StoreFault("observation store not found at %s" % store_path)
|
||||
|
||||
try:
|
||||
|
||||
+26
-2
@@ -15,9 +15,19 @@ from typing import Optional
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
|
||||
# Packaged default placement (spec §8.3). The config may override it, but a
|
||||
# fresh install that sets only the device selector must collect cleanly.
|
||||
DEFAULT_STORE_PATH = Path("/var/lib/fenris/observations.db")
|
||||
|
||||
|
||||
def get_store_path(config: dict) -> Path:
|
||||
"""Get the store path from config."""
|
||||
return Path(config["store_path"])
|
||||
"""Get the store path from config.
|
||||
|
||||
Falls back to the packaged default when the config does not pin one,
|
||||
so a fresh install whose config holds only the device selector works
|
||||
instead of crashing with KeyError 'store_path' (issue #53).
|
||||
"""
|
||||
return Path(config.get("store_path", DEFAULT_STORE_PATH))
|
||||
|
||||
|
||||
def init_store(store_path: Path) -> sqlite3.Connection:
|
||||
@@ -31,6 +41,20 @@ def init_store(store_path: Path) -> sqlite3.Connection:
|
||||
# Enable WAL mode for concurrent reads during writes
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
|
||||
# Group members (fenris group) read the live store read-only, but SQLite
|
||||
# in WAL mode needs write access to the db and its -wal/-shm sidecars even
|
||||
# for readers. Best effort: root-created stores stay group-accessible
|
||||
# without relying on the creating process's umask (issue #54).
|
||||
import os as _os
|
||||
for sidecar in (store_path,
|
||||
store_path.with_name(store_path.name + "-wal"),
|
||||
store_path.with_name(store_path.name + "-shm")):
|
||||
try:
|
||||
mode = _os.stat(sidecar).st_mode & 0o777
|
||||
_os.chmod(sidecar, mode | 0o060)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Check if this is a new database
|
||||
cursor = conn.execute("PRAGMA user_version")
|
||||
current_version = cursor.fetchone()[0]
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Store path resolution from config — regression coverage for issue #53.
|
||||
|
||||
A fresh install ships a placeholder-commented config whose only required
|
||||
key is the device selector. The collector must not crash with
|
||||
KeyError 'store_path' when the key is absent.
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||
|
||||
from fenris.store import DEFAULT_STORE_PATH, get_store_path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
TEMPLATE = REPO_ROOT / "packaging" / "fenris.conf"
|
||||
|
||||
|
||||
def _parse_like_load_config(text: str) -> dict:
|
||||
"""Mirror collect.load_config()'s key=value parsing rules."""
|
||||
config = {}
|
||||
for line in text.splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if "=" in line:
|
||||
key, value = line.split("=", 1)
|
||||
config[key.strip()] = value.strip()
|
||||
return config
|
||||
|
||||
|
||||
def test_missing_store_path_falls_back_to_default():
|
||||
"""Config with only the device selector resolves to the packaged default."""
|
||||
assert get_store_path({"device": "/dev/nvme0n1"}) == DEFAULT_STORE_PATH
|
||||
|
||||
|
||||
def test_explicit_store_path_wins():
|
||||
"""An explicit store_path override is honored."""
|
||||
assert get_store_path({"store_path": "/tmp/other.db"}) == Path("/tmp/other.db")
|
||||
|
||||
|
||||
def test_packaged_template_yields_collectable_config():
|
||||
"""The packaged template, once a device is set, must be collector-ready.
|
||||
|
||||
Reproduces the fresh-install path: parse packaging/fenris.conf the way
|
||||
collect.load_config() does, add the device selector, then resolve the
|
||||
store. Issue #53 made this raise KeyError.
|
||||
"""
|
||||
config = _parse_like_load_config(TEMPLATE.read_text())
|
||||
config["device"] = "/dev/nvme0n1"
|
||||
assert get_store_path(config) == DEFAULT_STORE_PATH
|
||||
|
||||
|
||||
def test_template_documents_store_path():
|
||||
"""The template must mention store_path so admins know it is overridable."""
|
||||
assert "store_path" in TEMPLATE.read_text()
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Group access to the observation store — regression coverage for issue #54.
|
||||
|
||||
Two defects: (1) a non-group user's stat() on the store directory raised
|
||||
PermissionError straight through open_store_readonly(), crashing status/TUI
|
||||
instead of degrading to the Store fault view; (2) even group members could
|
||||
not open the WAL-mode store because root-created sidecars lacked group write
|
||||
and the store directory lacked group execute-then-write.
|
||||
"""
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||
|
||||
from fenris.store import DEFAULT_STORE_PATH, init_store
|
||||
from fenris.status import StoreFault, open_store_readonly
|
||||
|
||||
|
||||
def test_stat_permission_error_becomes_store_fault(monkeypatch, tmp_path):
|
||||
"""stat() denied (non-group user on a 2750 dir) → StoreFault, not crash."""
|
||||
store = tmp_path / "observations.db"
|
||||
store.write_bytes(b"")
|
||||
|
||||
import pathlib
|
||||
|
||||
def denied(self, follow_symlinks=True):
|
||||
raise PermissionError(13, "Permission denied")
|
||||
|
||||
monkeypatch.setattr(pathlib.Path, "exists", denied)
|
||||
with pytest.raises(StoreFault):
|
||||
open_store_readonly(store)
|
||||
|
||||
|
||||
def test_connect_failure_becomes_store_fault(tmp_path):
|
||||
"""sqlite failures stay wrapped as StoreFault (existing contract)."""
|
||||
garbage = tmp_path / "observations.db"
|
||||
garbage.write_bytes(b"not a database" * 100)
|
||||
with pytest.raises(StoreFault):
|
||||
open_store_readonly(garbage)
|
||||
|
||||
|
||||
def test_init_store_leaves_files_group_writable(tmp_path):
|
||||
"""Root-created stores must stay readable by WAL readers: db and sidecars
|
||||
need group write after init_store (issue #54)."""
|
||||
store = tmp_path / "observations.db"
|
||||
conn = init_store(store)
|
||||
try:
|
||||
assert (store.stat().st_mode & 0o060) == 0o060, "db not group rw"
|
||||
wal = store.with_name(store.name + "-wal")
|
||||
shm = store.with_name(store.name + "-shm")
|
||||
if wal.exists():
|
||||
assert (wal.stat().st_mode & 0o060) == 0o060, "wal not group rw"
|
||||
if shm.exists():
|
||||
assert (shm.stat().st_mode & 0o060) == 0o060, "shm not group rw"
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_readonly_open_works_after_init_store(tmp_path):
|
||||
"""The shipped read path opens a store created by init_store."""
|
||||
store = tmp_path / "observations.db"
|
||||
writer = init_store(store)
|
||||
writer.execute("INSERT INTO monitoring_periods (started_at) VALUES ('2026-01-01T00:00:00+00:00')")
|
||||
writer.commit()
|
||||
conn = open_store_readonly(store)
|
||||
assert conn is not None
|
||||
conn.close()
|
||||
writer.close()
|
||||
|
||||
|
||||
def test_packaging_ships_group_access():
|
||||
"""tmpfiles must create the store dir group-writable; collect unit must
|
||||
keep the umask loose so root-created sidecars stay group-accessible."""
|
||||
repo = Path(__file__).resolve().parent.parent
|
||||
assert "2770" in (repo / "packaging" / "tmpfiles.d" / "fenris.conf").read_text()
|
||||
assert "2750" not in (repo / "packaging" / "tmpfiles.d" / "fenris.conf").read_text()
|
||||
assert "UMask=002" in (repo / "units" / "fenris-collect.service").read_text()
|
||||
@@ -7,3 +7,4 @@ After=local-fs.target
|
||||
Type=oneshot
|
||||
ExecStart=/usr/libexec/fenris/fenris-collect
|
||||
TimeoutStartSec=90
|
||||
UMask=002
|
||||
|
||||
Reference in New Issue
Block a user