@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
package/tests/test_api.py
CHANGED
|
@@ -132,6 +132,74 @@ def test_ingest_batch_mixed_success_and_failure_returns_per_item_accounting(tmp_
|
|
|
132
132
|
assert "failure_reason" in item
|
|
133
133
|
|
|
134
134
|
|
|
135
|
+
def test_ingest_logicmonitor_endpoint_persists_and_surfaces_via_runtime_inspection(tmp_path: Path) -> None:
|
|
136
|
+
client = TestClient(app)
|
|
137
|
+
db_path = tmp_path / "logicmonitor.sqlite3"
|
|
138
|
+
|
|
139
|
+
response = client.post(
|
|
140
|
+
"/ingest/logicmonitor",
|
|
141
|
+
json={
|
|
142
|
+
"tenant_id": "client-a",
|
|
143
|
+
"source_path": "/logicmonitor/webhook",
|
|
144
|
+
"threshold": 2,
|
|
145
|
+
"db_path": str(db_path),
|
|
146
|
+
"events": [
|
|
147
|
+
{
|
|
148
|
+
"resource_id": 123,
|
|
149
|
+
"resource_name": "edge-fw-01",
|
|
150
|
+
"severity": "warning",
|
|
151
|
+
"timestamp": "2026-03-22T00:00:00Z",
|
|
152
|
+
"message_raw": "VPN tunnel dropped and recovered",
|
|
153
|
+
"metadata": {
|
|
154
|
+
"datasource": "IPSec Tunnel",
|
|
155
|
+
"instance_name": "site-b",
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
"resource_id": 124,
|
|
160
|
+
"resource_name": "edge-fw-02",
|
|
161
|
+
"severity": "warning",
|
|
162
|
+
"timestamp": "2026-03-22T00:00:01Z",
|
|
163
|
+
"message_raw": "VPN tunnel dropped and recovered",
|
|
164
|
+
"metadata": {
|
|
165
|
+
"datasource": "IPSec Tunnel",
|
|
166
|
+
"instance_name": "site-c",
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
],
|
|
170
|
+
},
|
|
171
|
+
)
|
|
172
|
+
assert response.status_code == 200
|
|
173
|
+
payload = response.json()
|
|
174
|
+
assert payload["ok"] is True
|
|
175
|
+
assert payload["event_count"] == 2
|
|
176
|
+
assert payload["signature_count"] >= 1
|
|
177
|
+
|
|
178
|
+
raw_response = client.get(f"/raw_envelopes?db_path={db_path}&source_type=logicmonitor&limit=10")
|
|
179
|
+
assert raw_response.status_code == 200
|
|
180
|
+
raw_payload = raw_response.json()
|
|
181
|
+
assert raw_payload["count"] == 2
|
|
182
|
+
assert all(item["source_type"] == "logicmonitor" for item in raw_payload["items"])
|
|
183
|
+
assert all(item["canonicalization_status"] == "canonicalized" for item in raw_payload["items"])
|
|
184
|
+
|
|
185
|
+
canonical_response = client.get(f"/canonical_events?db_path={db_path}&source=logicmonitor&limit=10")
|
|
186
|
+
assert canonical_response.status_code == 200
|
|
187
|
+
canonical_payload = canonical_response.json()
|
|
188
|
+
assert canonical_payload["count"] == 2
|
|
189
|
+
assert all(item["source"] == "logicmonitor" for item in canonical_payload["items"])
|
|
190
|
+
|
|
191
|
+
sources_response = client.get(f"/sources?db_path={db_path}&limit=10")
|
|
192
|
+
assert sources_response.status_code == 200
|
|
193
|
+
sources_payload = sources_response.json()
|
|
194
|
+
source_types = [entry["value"] for entry in sources_payload["items"]["source_type"]]
|
|
195
|
+
assert "logicmonitor" in source_types
|
|
196
|
+
|
|
197
|
+
runtime_response = client.get("/runtime")
|
|
198
|
+
assert runtime_response.status_code == 200
|
|
199
|
+
runtime_payload = runtime_response.json()
|
|
200
|
+
assert runtime_payload["runtime"]["capability_flags"]["ingest_endpoints"]["logicmonitor_events"] is True
|
|
201
|
+
|
|
202
|
+
|
|
135
203
|
def test_candidates_endpoint_returns_candidate_inspection_payload_and_supports_filtering(tmp_path: Path) -> None:
|
|
136
204
|
client = TestClient(app)
|
|
137
205
|
db_path = tmp_path / "brainstem_candidates.sqlite3"
|
|
@@ -483,6 +551,24 @@ def test_status_and_healthz_are_coherent() -> None:
|
|
|
483
551
|
assert status_response.json() == healthz_response.json()
|
|
484
552
|
|
|
485
553
|
|
|
554
|
+
def test_runtime_endpoint_includes_source_capability_matrix() -> None:
|
|
555
|
+
client = TestClient(app)
|
|
556
|
+
response = client.get("/runtime")
|
|
557
|
+
assert response.status_code == 200
|
|
558
|
+
source_capabilities = response.json()["runtime"]["capability_flags"]["source_capabilities"]
|
|
559
|
+
source_types = source_capabilities["source_types"]
|
|
560
|
+
|
|
561
|
+
assert "syslog" in source_types
|
|
562
|
+
assert "file" in source_types
|
|
563
|
+
assert "logicmonitor" in source_types
|
|
564
|
+
|
|
565
|
+
per_source = {item["source_type"]: item["modes"] for item in source_capabilities["ingest_modes_by_source_type"]}
|
|
566
|
+
assert {"mode": "udp_listener", "endpoint": "brainstem.listener"} in per_source["syslog"]
|
|
567
|
+
assert {"mode": "single_event_api", "endpoint": "/ingest/event"} in per_source["file"]
|
|
568
|
+
assert {"mode": "batch_api", "endpoint": "/ingest/batch"} in per_source["file"]
|
|
569
|
+
assert {"mode": "logicmonitor_webhook", "endpoint": "/ingest/logicmonitor"} in per_source["logicmonitor"]
|
|
570
|
+
|
|
571
|
+
|
|
486
572
|
def test_runtime_endpoint_reports_config_object(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
487
573
|
custom_db = tmp_path / "runtime.sqlite3"
|
|
488
574
|
monkeypatch.setenv("BRAINSTEM_DB_PATH", str(custom_db))
|
package/tests/test_config.py
CHANGED
|
@@ -12,10 +12,25 @@ def test_runtime_config_exposes_shared_defaults() -> None:
|
|
|
12
12
|
assert cfg.listener.syslog_port == 5514
|
|
13
13
|
assert cfg.listener.syslog_source_path == "/dev/udp"
|
|
14
14
|
assert cfg.defaults.ingest_threshold == 2
|
|
15
|
+
assert cfg.defaults.recurrence_threshold == 2
|
|
15
16
|
assert cfg.defaults.interesting_limit == 5
|
|
17
|
+
assert cfg.candidate_attention.recurrence_count_normalizer == 10
|
|
18
|
+
assert cfg.candidate_attention.decision_band_watch == 0.25
|
|
16
19
|
assert cfg.limits.replay_raw_max_ids == 32
|
|
17
20
|
|
|
18
21
|
|
|
22
|
+
def test_runtime_config_allows_threshold_and_attention_overrides(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
23
|
+
monkeypatch.setenv("BRAINSTEM_INGEST_THRESHOLD", "7")
|
|
24
|
+
monkeypatch.setenv("BRAINSTEM_RECURRENCE_THRESHOLD", "9")
|
|
25
|
+
monkeypatch.setenv("BRAINSTEM_CANDIDATE_RECURRENCE_NORMALIZER", "5")
|
|
26
|
+
monkeypatch.setenv("BRAINSTEM_CANDIDATE_RECOVERY", "0.11")
|
|
27
|
+
cfg = get_runtime_config()
|
|
28
|
+
assert cfg.defaults.ingest_threshold == 7
|
|
29
|
+
assert cfg.defaults.recurrence_threshold == 9
|
|
30
|
+
assert cfg.candidate_attention.recurrence_count_normalizer == 5
|
|
31
|
+
assert cfg.candidate_attention.recovery_signal_weight == 0.11
|
|
32
|
+
|
|
33
|
+
|
|
19
34
|
def test_resolve_default_db_path_uses_env_override(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
20
35
|
custom_db = tmp_path / "override.sqlite3"
|
|
21
36
|
monkeypatch.setenv("BRAINSTEM_DB_PATH", str(custom_db))
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
from brainstem.
|
|
1
|
+
from brainstem.connectors.logicmonitor import map_logicmonitor_payload_to_raw_envelope
|
|
2
|
+
from brainstem.fingerprint import fingerprint_event, normalize_message
|
|
3
|
+
from brainstem.ingest import canonicalize_raw_input_envelope, parse_file_line, parse_syslog_line
|
|
2
4
|
from brainstem.models import Event
|
|
3
5
|
|
|
4
6
|
|
|
@@ -20,3 +22,51 @@ def test_fingerprint_event_builds_signature() -> None:
|
|
|
20
22
|
sig = fingerprint_event(event)
|
|
21
23
|
assert sig.signature_key.startswith("failure|sshd|") or sig.signature_key.startswith("auth|sshd|")
|
|
22
24
|
assert sig.normalized_pattern
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_event_family_for_connectivity_syslog_event() -> None:
|
|
28
|
+
event = parse_syslog_line(
|
|
29
|
+
"Mar 22 00:00:01 fw-01 charon: VPN tunnel dropped and recovered",
|
|
30
|
+
tenant_id="client-a",
|
|
31
|
+
source_path="/var/log/syslog",
|
|
32
|
+
)
|
|
33
|
+
signature = fingerprint_event(event)
|
|
34
|
+
assert signature.event_family == "connectivity"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_event_family_for_resource_file_event() -> None:
|
|
38
|
+
event = parse_file_line(
|
|
39
|
+
"disk pressure warning on /var reached high watermark",
|
|
40
|
+
tenant_id="client-a",
|
|
41
|
+
source_path="/tmp/sample.log",
|
|
42
|
+
)
|
|
43
|
+
signature = fingerprint_event(event)
|
|
44
|
+
assert signature.event_family == "resource"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_event_family_for_logicmonitor_connectivity_event() -> None:
|
|
48
|
+
raw = map_logicmonitor_payload_to_raw_envelope(
|
|
49
|
+
{
|
|
50
|
+
"resource_id": 123,
|
|
51
|
+
"resource_name": "edge-fw-01",
|
|
52
|
+
"message_raw": "IPsec tunnel dropped and recovered",
|
|
53
|
+
"severity": "warning",
|
|
54
|
+
"timestamp": "2026-03-22T00:00:00Z",
|
|
55
|
+
"metadata": {"datasource": "IPSec Tunnel"},
|
|
56
|
+
},
|
|
57
|
+
tenant_id="client-a",
|
|
58
|
+
source_path="/logicmonitor/ingest",
|
|
59
|
+
)
|
|
60
|
+
event = canonicalize_raw_input_envelope(raw)
|
|
61
|
+
signature = fingerprint_event(event)
|
|
62
|
+
assert signature.event_family == "connectivity"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_event_family_falls_back_to_generic_when_no_clear_signal() -> None:
|
|
66
|
+
event = parse_file_line(
|
|
67
|
+
"Routine status check completed without issue",
|
|
68
|
+
tenant_id="client-a",
|
|
69
|
+
source_path="/tmp/sample.log",
|
|
70
|
+
)
|
|
71
|
+
signature = fingerprint_event(event)
|
|
72
|
+
assert signature.event_family == "generic"
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
from brainstem.connectors.logicmonitor import map_logicmonitor_event
|
|
1
|
+
from brainstem.connectors.logicmonitor import map_logicmonitor_event, map_logicmonitor_payload_to_raw_envelope
|
|
2
|
+
from brainstem.ingest import canonicalize_raw_input_envelope
|
|
2
3
|
|
|
3
4
|
|
|
4
5
|
def test_map_logicmonitor_event_normalizes_payload() -> None:
|
|
@@ -20,3 +21,55 @@ def test_map_logicmonitor_event_normalizes_payload() -> None:
|
|
|
20
21
|
assert event.source_type == 'logicmonitor'
|
|
21
22
|
assert event.host == 'edge-fw-01'
|
|
22
23
|
assert event.metadata['resource_id'] == 123
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_map_logicmonitor_payload_to_raw_envelope_preserves_shape() -> None:
|
|
27
|
+
raw = map_logicmonitor_payload_to_raw_envelope(
|
|
28
|
+
{
|
|
29
|
+
'resource_id': 123,
|
|
30
|
+
'resource_name': 'edge-fw-01',
|
|
31
|
+
'severity': 'warning',
|
|
32
|
+
'timestamp': '2026-03-22T00:00:00Z',
|
|
33
|
+
'message_raw': 'VPN tunnel dropped and recovered',
|
|
34
|
+
'metadata': {
|
|
35
|
+
'datasource': 'IPSec Tunnel',
|
|
36
|
+
'instance_name': 'site-b',
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
tenant_id='client-a',
|
|
40
|
+
source_path='/logicmonitor/ingest',
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
assert raw.source_type == 'logicmonitor'
|
|
44
|
+
assert raw.source_path == '/logicmonitor/ingest'
|
|
45
|
+
assert raw.host == 'edge-fw-01'
|
|
46
|
+
assert raw.service == 'IPSec Tunnel'
|
|
47
|
+
assert raw.severity == 'warning'
|
|
48
|
+
assert raw.message_raw == 'VPN tunnel dropped and recovered'
|
|
49
|
+
assert raw.metadata['alert_id'] is None
|
|
50
|
+
assert raw.timestamp == '2026-03-22T00:00:00Z'
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_canonicalization_from_logicmonitor_raw_envelope_remains_in_ingest_flow() -> None:
|
|
54
|
+
raw = map_logicmonitor_payload_to_raw_envelope(
|
|
55
|
+
{
|
|
56
|
+
'resource_id': 123,
|
|
57
|
+
'resource_name': 'edge-fw-01',
|
|
58
|
+
'severity': 'warning',
|
|
59
|
+
'timestamp': '2026-03-22T00:00:00Z',
|
|
60
|
+
'message_raw': 'VPN tunnel dropped and recovered',
|
|
61
|
+
'metadata': {
|
|
62
|
+
'datasource': 'IPSec Tunnel',
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
tenant_id='client-a',
|
|
66
|
+
source_path='/logicmonitor/ingest',
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
event = canonicalize_raw_input_envelope(raw)
|
|
70
|
+
assert event.source_type == 'logicmonitor'
|
|
71
|
+
assert event.host == 'edge-fw-01'
|
|
72
|
+
assert event.service == 'IPSec Tunnel'
|
|
73
|
+
assert event.ingest_metadata['canonicalization_source'] == 'logicmonitor'
|
|
74
|
+
assert event.ingest_metadata['raw_input_seen'] is True
|
|
75
|
+
assert event.message_normalized == 'vpn tunnel dropped and recovered'
|
package/tests/test_recurrence.py
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
1
3
|
from brainstem.ingest import ingest_syslog_lines, signatures_for_events
|
|
2
4
|
from brainstem.recurrence import build_recurrence_candidates, digest_items
|
|
3
5
|
|
|
@@ -16,3 +18,15 @@ def test_build_recurrence_candidates_emits_candidate_for_repeated_signature() ->
|
|
|
16
18
|
assert digest[0]["attention_explanation"]["dominant_signals"]
|
|
17
19
|
assert "summary" in digest[0]["attention_explanation"]
|
|
18
20
|
assert digest[0]['decision_band'] in {'watch', 'review', 'urgent_human_review', 'promote_to_incident_memory'}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_build_recurrence_candidates_uses_default_threshold_from_config(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
24
|
+
lines = [
|
|
25
|
+
"Mar 22 00:00:01 fw-01 charon: VPN tunnel dropped and recovered",
|
|
26
|
+
"Mar 22 00:00:03 fw-01 charon: VPN tunnel dropped and recovered",
|
|
27
|
+
]
|
|
28
|
+
monkeypatch.setenv("BRAINSTEM_RECURRENCE_THRESHOLD", "3")
|
|
29
|
+
events = ingest_syslog_lines(lines, tenant_id="client-a", source_path="/var/log/syslog")
|
|
30
|
+
signatures = signatures_for_events(events)
|
|
31
|
+
candidates = build_recurrence_candidates(events, signatures)
|
|
32
|
+
assert candidates == []
|
package/tests/test_storage.py
CHANGED
|
@@ -6,7 +6,10 @@ from brainstem.models import RawInputEnvelope
|
|
|
6
6
|
from brainstem.recurrence import build_recurrence_candidates
|
|
7
7
|
from brainstem.storage import (
|
|
8
8
|
extract_source_raw_envelope_ids,
|
|
9
|
+
create_sqlite_snapshot,
|
|
10
|
+
clear_storage_tables,
|
|
9
11
|
get_raw_envelope_by_id,
|
|
12
|
+
get_storage_counts,
|
|
10
13
|
get_ingest_stats,
|
|
11
14
|
init_db,
|
|
12
15
|
list_candidates,
|
|
@@ -18,6 +21,7 @@ from brainstem.storage import (
|
|
|
18
21
|
list_recent_raw_envelopes,
|
|
19
22
|
store_raw_envelopes,
|
|
20
23
|
set_raw_envelope_status,
|
|
24
|
+
resolve_maintenance_tables,
|
|
21
25
|
store_signatures,
|
|
22
26
|
)
|
|
23
27
|
|
|
@@ -392,3 +396,76 @@ def test_get_raw_envelope_by_id(tmp_path: Path) -> None:
|
|
|
392
396
|
assert row["id"] == raw_id
|
|
393
397
|
assert row["canonicalization_status"] == "parse_failed"
|
|
394
398
|
assert row["failure_reason"] == "empty message"
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def test_storage_counts_report_expected_rows_for_ingested_payload(tmp_path: Path) -> None:
|
|
402
|
+
db_path = tmp_path / 'brainstem.sqlite3'
|
|
403
|
+
raw_events = parse_syslog_envelopes(
|
|
404
|
+
[
|
|
405
|
+
"Mar 22 00:00:01 fw-01 charon: VPN tunnel dropped and recovered",
|
|
406
|
+
"Mar 22 00:00:02 fw-01 charon: VPN tunnel dropped and recovered",
|
|
407
|
+
],
|
|
408
|
+
tenant_id="client-a",
|
|
409
|
+
source_path="/var/log/syslog",
|
|
410
|
+
)
|
|
411
|
+
result = run_ingest_pipeline(raw_events, threshold=2, db_path=str(db_path))
|
|
412
|
+
assert result.raw_envelope_ids
|
|
413
|
+
assert result.events
|
|
414
|
+
assert result.signatures
|
|
415
|
+
assert result.candidates
|
|
416
|
+
|
|
417
|
+
counts = get_storage_counts(str(db_path))
|
|
418
|
+
expected_signature_rows = len({sig.signature_key for sig in result.signatures})
|
|
419
|
+
assert counts["raw_envelopes"] == len(result.raw_envelope_ids)
|
|
420
|
+
assert counts["events"] == len(result.events)
|
|
421
|
+
assert counts["signatures"] == expected_signature_rows
|
|
422
|
+
assert counts["candidates"] == len(result.candidates)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def test_resolve_and_clear_storage_tables_are_explicit() -> None:
|
|
426
|
+
assert resolve_maintenance_tables(["all"]) == ["events", "raw_envelopes", "signatures", "candidates"]
|
|
427
|
+
assert resolve_maintenance_tables(["raw_envelopes", "events"]) == ["raw_envelopes", "events"]
|
|
428
|
+
assert resolve_maintenance_tables(None) == ["events", "raw_envelopes", "signatures", "candidates"]
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def test_clear_storage_tables_only_clears_requested_tables(tmp_path: Path) -> None:
|
|
432
|
+
db_path = tmp_path / 'brainstem.sqlite3'
|
|
433
|
+
raw_events = parse_syslog_envelopes(
|
|
434
|
+
[
|
|
435
|
+
"Mar 22 00:00:01 fw-01 charon: VPN tunnel dropped and recovered",
|
|
436
|
+
"Mar 22 00:00:02 fw-01 charon: VPN tunnel dropped and recovered",
|
|
437
|
+
],
|
|
438
|
+
tenant_id="client-a",
|
|
439
|
+
source_path="/var/log/syslog",
|
|
440
|
+
)
|
|
441
|
+
result = run_ingest_pipeline(raw_events, threshold=2, db_path=str(db_path))
|
|
442
|
+
removed = clear_storage_tables(str(db_path), tables=["raw_envelopes", "events"])
|
|
443
|
+
expected_signature_rows = len({sig.signature_key for sig in result.signatures})
|
|
444
|
+
assert removed["raw_envelopes"] == len(result.raw_envelope_ids)
|
|
445
|
+
assert removed["events"] == len(result.events)
|
|
446
|
+
|
|
447
|
+
counts_after = get_storage_counts(str(db_path))
|
|
448
|
+
assert counts_after["raw_envelopes"] == 0
|
|
449
|
+
assert counts_after["events"] == 0
|
|
450
|
+
assert counts_after["signatures"] == expected_signature_rows
|
|
451
|
+
assert counts_after["candidates"] == len(result.candidates)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def test_create_sqlite_snapshot_returns_metadata_and_copies_file(tmp_path: Path) -> None:
|
|
455
|
+
db_path = tmp_path / "snapshot.sqlite3"
|
|
456
|
+
init_db(str(db_path))
|
|
457
|
+
|
|
458
|
+
first = create_sqlite_snapshot(str(db_path))
|
|
459
|
+
first_path = Path(first["snapshot_path"])
|
|
460
|
+
assert first["source_path"] == str(db_path)
|
|
461
|
+
assert first_path.exists()
|
|
462
|
+
assert first_path.is_file()
|
|
463
|
+
assert first_path != db_path
|
|
464
|
+
assert first["size"] == first_path.stat().st_size
|
|
465
|
+
assert first["size"] > 0
|
|
466
|
+
assert first["created_at"].endswith("Z")
|
|
467
|
+
|
|
468
|
+
second = create_sqlite_snapshot(str(db_path))
|
|
469
|
+
second_path = Path(second["snapshot_path"])
|
|
470
|
+
assert second_path.exists()
|
|
471
|
+
assert second["snapshot_path"] != first["snapshot_path"]
|