From 261763d916c2a4ece511b2d39287d58d6bc48461 Mon Sep 17 00:00:00 2001 From: viktorbeck98 Date: Tue, 30 Jun 2026 12:41:14 +0200 Subject: [PATCH] feat(persist): default persistency path to systemd $STATE_DIRECTORY MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PersistConfig.path defaulted to the CWD-relative "./state". Under systemd (CWD typically /), this resolves to /state — wrong location and needs root, and the save failure is swallowed as a warning, so state is silently lost. Default now reads systemd's $STATE_DIRECTORY (set by StateDirectory= in the unit file, which also creates the dir with correct ownership), taking the first entry of a colon-separated list, and falls back to ./state outside systemd. Explicit path= still overrides. Docs: systemd setup note + updated field table in docs/detectors.md. Co-Authored-By: Claude Opus 4.8 --- .../test_common/test_persist_config.py | 22 ++++++++++++++++++- docs/detectors.md | 18 ++++++++++++++- src/detectmatelibrary/common/detector.py | 11 ++++++++-- 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/detectmatelibrary_tests/test_common/test_persist_config.py b/detectmatelibrary_tests/test_common/test_persist_config.py index 9b38b3bd..49d2e1b8 100644 --- a/detectmatelibrary_tests/test_common/test_persist_config.py +++ b/detectmatelibrary_tests/test_common/test_persist_config.py @@ -12,7 +12,8 @@ class TestPersistConfig: - def test_defaults(self): + def test_defaults(self, monkeypatch): + monkeypatch.delenv("STATE_DIRECTORY", raising=False) cfg = PersistConfig() assert cfg.path == "./state" assert cfg.interval_seconds == 300 @@ -35,6 +36,25 @@ def test_extra_fields_rejected(self): PersistConfig(unknown_field="value") # type: ignore +class TestPersistConfigStateDirectoryDefault: + def test_uses_state_directory_env_when_set(self, monkeypatch): + monkeypatch.setenv("STATE_DIRECTORY", "/var/lib/detectmate") + assert PersistConfig().path == "/var/lib/detectmate" + + def test_takes_first_path_when_state_directory_is_colon_list(self, monkeypatch): + # systemd sets STATE_DIRECTORY colon-separated for multiple StateDirectory= entries + monkeypatch.setenv("STATE_DIRECTORY", "/var/lib/a:/var/lib/b") + assert PersistConfig().path == "/var/lib/a" + + def test_falls_back_to_state_when_env_unset(self, monkeypatch): + monkeypatch.delenv("STATE_DIRECTORY", raising=False) + assert PersistConfig().path == "./state" + + def test_explicit_path_overrides_state_directory_env(self, monkeypatch): + monkeypatch.setenv("STATE_DIRECTORY", "/var/lib/detectmate") + assert PersistConfig(path="s3://bucket/state").path == "s3://bucket/state" + + class TestCoreDetectorConfigPersistField: def test_persist_is_none_by_default(self): cfg = CoreDetectorConfig() diff --git a/docs/detectors.md b/docs/detectors.md index d737373f..e3e744a3 100644 --- a/docs/detectors.md +++ b/docs/detectors.md @@ -248,11 +248,27 @@ disables saving (backward compatible). The detector name is automatically appended to `path`, so `path: ./state` for a detector named `NewValueDetector` writes to `./state/NewValueDetector/`. +#### Running under systemd + +The default `path` is CWD-relative. systemd services usually run with CWD `/`, +so `./state` would resolve to `/state` (wrong location, needs root). To avoid +this, set `StateDirectory=` in your unit file — systemd creates `/var/lib/` +with the right ownership and exports `$STATE_DIRECTORY`, which the default `path` +reads automatically. No explicit `path:` needed: + +```ini +[Service] +User=detectmate +StateDirectory=detectmate # → state at /var/lib/detectmate// +``` + +Setting `path:` explicitly (e.g. an `s3://` URL) always overrides `$STATE_DIRECTORY`. + #### Fields | Field | Type | Default | Description | |---|---|---|---| -| `path` | `str` | `"./state"` | Base directory or cloud URL. Detector name is appended. | +| `path` | `str` | `$STATE_DIRECTORY` or `"./state"` | Base directory or cloud URL. Detector name is appended. Defaults to systemd's `$STATE_DIRECTORY` if set, else `./state` (see note above). | | `interval_seconds` | `int` | `300` | Background save interval in seconds. | | `events_until_save` | `int \| null` | `null` | Save after this many ingested events. `null` disables event-count triggering. | | `auto_load` | `bool` | `false` | Load saved state on construction. Raises `PersistencyLoadError` if no state exists. | diff --git a/src/detectmatelibrary/common/detector.py b/src/detectmatelibrary/common/detector.py index ba2067b8..18a78f96 100644 --- a/src/detectmatelibrary/common/detector.py +++ b/src/detectmatelibrary/common/detector.py @@ -6,7 +6,9 @@ from detectmatelibrary.utils import persistency from detectmatelibrary.common.persist import init_persistency -from pydantic import BaseModel, ConfigDict +import os + +from pydantic import BaseModel, ConfigDict, Field from detectmatelibrary.schemas import ParserSchema, DetectorSchema @@ -23,7 +25,12 @@ class PersistConfig(BaseModel): model_config = ConfigDict(extra="forbid") - path: str = "./state" + # Default honors systemd's $STATE_DIRECTORY (set by StateDirectory= in the + # unit file) so services persist to /var/lib/ with no explicit path=. + # Falls back to CWD-relative ./state outside systemd. Explicit path= wins. + path: str = Field( + default_factory=lambda: os.environ.get("STATE_DIRECTORY", "./state").split(":")[0] + ) interval_seconds: int = 300 events_until_save: int | None = None auto_load: bool = False