@simbimbo/brainstem 0.0.4 → 0.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/README.md +4 -3
- package/brainstem/__init__.py +1 -1
- package/brainstem/api.py +80 -3
- package/brainstem/config.py +66 -0
- package/brainstem/connectors/logicmonitor.py +57 -0
- package/brainstem/demo.py +16 -2
- package/brainstem/fingerprint.py +54 -0
- package/brainstem/ingest.py +34 -5
- package/brainstem/listener.py +8 -2
- package/brainstem/recurrence.py +25 -8
- package/brainstem/scoring.py +6 -4
- package/brainstem/source_drivers.py +29 -0
- package/brainstem/storage.py +84 -0
- package/docs/README.md +12 -3
- package/docs/adapters.md +401 -97
- package/docs/api.md +36 -1
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/tests/test_adapters.py +1 -0
- package/tests/test_api.py +86 -0
- package/tests/test_config.py +15 -0
- package/tests/test_fingerprint.py +51 -1
- package/tests/test_logicmonitor.py +54 -1
- package/tests/test_recurrence.py +14 -0
- package/tests/test_storage.py +77 -0
|
@@ -6,6 +6,9 @@ from typing import Any, Callable, Iterable, List, Protocol, runtime_checkable
|
|
|
6
6
|
from .adapters import get_raw_input_adapter
|
|
7
7
|
from .models import RawInputEnvelope
|
|
8
8
|
|
|
9
|
+
# Register LogicMonitor adapter definitions for structured ingest support.
|
|
10
|
+
from .connectors import logicmonitor # noqa: F401
|
|
11
|
+
|
|
9
12
|
ErrorHandler = Callable[[Exception, str], None]
|
|
10
13
|
|
|
11
14
|
|
|
@@ -146,5 +149,31 @@ class SyslogSourceDriver:
|
|
|
146
149
|
return raw_envelopes
|
|
147
150
|
|
|
148
151
|
|
|
152
|
+
@dataclass(frozen=True)
|
|
153
|
+
class LogicMonitorSourceDriver:
|
|
154
|
+
"""Driver for narrow LogicMonitor-shaped payloads."""
|
|
155
|
+
|
|
156
|
+
source_type: str = "logicmonitor"
|
|
157
|
+
|
|
158
|
+
def parse_payload(
|
|
159
|
+
self,
|
|
160
|
+
payload: Any,
|
|
161
|
+
*,
|
|
162
|
+
tenant_id: str,
|
|
163
|
+
source_path: str = "",
|
|
164
|
+
on_parse_error: ErrorHandler | None = None,
|
|
165
|
+
) -> List[RawInputEnvelope]:
|
|
166
|
+
adapter = get_raw_input_adapter(self.source_type)
|
|
167
|
+
payload_text = "" if payload is None else str(payload)
|
|
168
|
+
try:
|
|
169
|
+
return [adapter.parse_raw_input(payload, tenant_id=tenant_id, source_path=source_path)]
|
|
170
|
+
except Exception as exc:
|
|
171
|
+
if on_parse_error is None:
|
|
172
|
+
raise
|
|
173
|
+
on_parse_error(exc, payload_text)
|
|
174
|
+
return []
|
|
175
|
+
|
|
176
|
+
|
|
149
177
|
register_source_driver(FileSourceDriver())
|
|
150
178
|
register_source_driver(SyslogSourceDriver())
|
|
179
|
+
register_source_driver(LogicMonitorSourceDriver())
|
package/brainstem/storage.py
CHANGED
|
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
import sqlite3
|
|
5
|
+
from datetime import datetime, timezone
|
|
5
6
|
from pathlib import Path
|
|
6
7
|
from typing import Any, Iterable, List
|
|
7
8
|
|
|
@@ -13,8 +14,43 @@ def default_db_path() -> Path:
|
|
|
13
14
|
return Path(resolve_default_db_path())
|
|
14
15
|
|
|
15
16
|
|
|
17
|
+
def _snapshot_timestamp() -> tuple[str, str]:
|
|
18
|
+
now = datetime.now(timezone.utc)
|
|
19
|
+
created_at = now.isoformat(timespec="seconds").replace("+00:00", "Z")
|
|
20
|
+
compact_timestamp = now.strftime("%Y%m%dT%H%M%S%fZ")
|
|
21
|
+
return created_at, compact_timestamp
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def create_sqlite_snapshot(db_path: str | None = None) -> dict[str, str | int]:
|
|
25
|
+
source_path = Path(db_path) if db_path else default_db_path()
|
|
26
|
+
if not source_path.exists():
|
|
27
|
+
raise FileNotFoundError(f"source database does not exist: {source_path}")
|
|
28
|
+
if not source_path.is_file():
|
|
29
|
+
raise ValueError(f"source path is not a file: {source_path}")
|
|
30
|
+
|
|
31
|
+
created_at, compact_timestamp = _snapshot_timestamp()
|
|
32
|
+
snapshot_name = f"{source_path.stem}.snapshot.{compact_timestamp}{source_path.suffix}"
|
|
33
|
+
snapshot_path = source_path.with_name(snapshot_name)
|
|
34
|
+
|
|
35
|
+
source_connection = sqlite3.connect(str(source_path))
|
|
36
|
+
snapshot_connection = sqlite3.connect(str(snapshot_path))
|
|
37
|
+
try:
|
|
38
|
+
source_connection.backup(snapshot_connection)
|
|
39
|
+
finally:
|
|
40
|
+
snapshot_connection.close()
|
|
41
|
+
source_connection.close()
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
"source_path": str(source_path),
|
|
45
|
+
"snapshot_path": str(snapshot_path),
|
|
46
|
+
"size": snapshot_path.stat().st_size,
|
|
47
|
+
"created_at": created_at,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
16
51
|
RAW_ENVELOPE_STATUSES = ("received", "canonicalized", "parse_failed", "unsupported")
|
|
17
52
|
RAW_ENVELOPE_FAILURE_STATUSES = ("parse_failed", "unsupported")
|
|
53
|
+
MAINTENANCE_TABLES = ("events", "raw_envelopes", "signatures", "candidates")
|
|
18
54
|
|
|
19
55
|
|
|
20
56
|
def _coerce_raw_envelope_id(value: Any) -> int | None:
|
|
@@ -54,6 +90,25 @@ def extract_source_raw_envelope_ids(metadata_json: str | None) -> List[int]:
|
|
|
54
90
|
return _coerce_raw_envelope_id_list(metadata.get("source_raw_envelope_ids"))
|
|
55
91
|
|
|
56
92
|
|
|
93
|
+
def resolve_maintenance_tables(table_names: Iterable[str] | None = None) -> list[str]:
|
|
94
|
+
if table_names is None:
|
|
95
|
+
return list(MAINTENANCE_TABLES)
|
|
96
|
+
|
|
97
|
+
requested: list[str] = []
|
|
98
|
+
for table_name in table_names:
|
|
99
|
+
table_name = str(table_name).strip().lower()
|
|
100
|
+
if not table_name:
|
|
101
|
+
continue
|
|
102
|
+
if table_name == "all":
|
|
103
|
+
return list(MAINTENANCE_TABLES)
|
|
104
|
+
if table_name not in MAINTENANCE_TABLES:
|
|
105
|
+
raise ValueError(f"unsupported table for maintenance clear: {table_name}")
|
|
106
|
+
if table_name not in requested:
|
|
107
|
+
requested.append(table_name)
|
|
108
|
+
|
|
109
|
+
return requested or list(MAINTENANCE_TABLES)
|
|
110
|
+
|
|
111
|
+
|
|
57
112
|
def _validate_canonicalization_status(status: str) -> None:
|
|
58
113
|
if status not in RAW_ENVELOPE_STATUSES:
|
|
59
114
|
raise ValueError(f"unsupported canonicalization_status: {status}")
|
|
@@ -139,6 +194,35 @@ def init_db(db_path: str | None = None) -> None:
|
|
|
139
194
|
conn.close()
|
|
140
195
|
|
|
141
196
|
|
|
197
|
+
def get_storage_counts(db_path: str | None = None) -> dict[str, int]:
|
|
198
|
+
init_db(db_path)
|
|
199
|
+
conn = connect(db_path)
|
|
200
|
+
try:
|
|
201
|
+
return {
|
|
202
|
+
table: _query_count(conn, f"SELECT COUNT(*) FROM {table}")
|
|
203
|
+
for table in MAINTENANCE_TABLES
|
|
204
|
+
}
|
|
205
|
+
finally:
|
|
206
|
+
conn.close()
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def clear_storage_tables(db_path: str | None = None, *, tables: Iterable[str] | None = None) -> dict[str, int]:
|
|
210
|
+
resolved_tables = resolve_maintenance_tables(tables)
|
|
211
|
+
init_db(db_path)
|
|
212
|
+
conn = connect(db_path)
|
|
213
|
+
try:
|
|
214
|
+
counts: dict[str, int] = {}
|
|
215
|
+
for table in resolved_tables:
|
|
216
|
+
count = _query_count(conn, f"SELECT COUNT(*) FROM {table}")
|
|
217
|
+
conn.execute(f"DELETE FROM {table}")
|
|
218
|
+
conn.execute("DELETE FROM sqlite_sequence WHERE name = ?", (table,))
|
|
219
|
+
counts[table] = count
|
|
220
|
+
conn.commit()
|
|
221
|
+
return counts
|
|
222
|
+
finally:
|
|
223
|
+
conn.close()
|
|
224
|
+
|
|
225
|
+
|
|
142
226
|
def store_raw_envelopes(raw_envelopes: Iterable[RawInputEnvelope], db_path: str | None = None) -> List[int]:
|
|
143
227
|
conn = connect(db_path)
|
|
144
228
|
raw_ids: List[int] = []
|
package/docs/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Runtime Examples
|
|
2
2
|
|
|
3
|
-
Use this compact surface for the implemented runtime API, listener,
|
|
3
|
+
Use this compact surface for the implemented runtime API, listener, file-ingest, and LogicMonitor-shaped webhook path.
|
|
4
4
|
|
|
5
5
|
## 0) Shared runtime settings
|
|
6
6
|
|
|
@@ -51,7 +51,16 @@ curl -s -X POST http://127.0.0.1:8000/ingest/batch \
|
|
|
51
51
|
-d '{"threshold":2,"db_path":"/tmp/brainstem.sqlite3","events":[{"tenant_id":"demo-tenant","source_type":"file","source_path":"/tmp/manual.log","message_raw":"vpn tunnel dropped and recovered"}]}'
|
|
52
52
|
```
|
|
53
53
|
|
|
54
|
-
## 5)
|
|
54
|
+
## 5) LogicMonitor-shaped ingest (connector payloads)
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
curl -s -X POST http://127.0.0.1:8000/ingest/logicmonitor \
|
|
58
|
+
-H "Content-Type: application/json" \
|
|
59
|
+
-H "X-API-Token: $BRAINSTEM_API_TOKEN" \
|
|
60
|
+
-d '{"tenant_id":"demo-tenant","threshold":2,"source_path":"/logicmonitor/ingest","db_path":"/tmp/brainstem.sqlite3","events":[{"resource_id":123,"resource_name":"edge-fw-01","message_raw":"VPN tunnel dropped and recovered","severity":"warning","metadata":{"datasource":"IPSec Tunnel"}}]}'
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## 6) Runtime inspection endpoints (same db path)
|
|
55
64
|
|
|
56
65
|
```bash
|
|
57
66
|
curl -s "http://127.0.0.1:8000/ingest/recent?db_path=/tmp/brainstem.sqlite3&limit=5" \
|
|
@@ -72,7 +81,7 @@ curl -s "http://127.0.0.1:8000/sources/status?db_path=/tmp/brainstem.sqlite3&lim
|
|
|
72
81
|
-H "X-API-Token: $BRAINSTEM_API_TOKEN"
|
|
73
82
|
```
|
|
74
83
|
|
|
75
|
-
##
|
|
84
|
+
## 7) Direct file ingest helper path
|
|
76
85
|
|
|
77
86
|
```bash
|
|
78
87
|
python - <<'PY'
|