diff --git a/docs/detectors.md b/docs/detectors.md index d737373..e3e744a 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 ba2067b..69cc0a4 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,15 @@ 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: next( + (p for p in os.environ.get("STATE_DIRECTORY", "").split(":") if p.strip()), + "./state", + ) + ) interval_seconds: int = 300 events_until_save: int | None = None auto_load: bool = False diff --git a/tests/detectmatelibrary/test_common/test_persist_config.py b/tests/detectmatelibrary/test_common/test_persist_config.py index 9b38b3b..49d2e1b 100644 --- a/tests/detectmatelibrary/test_common/test_persist_config.py +++ b/tests/detectmatelibrary/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()