cmdscope 0.1.0__py3-none-any.whl
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.
- cmdscope/__init__.py +12 -0
- cmdscope/__main__.py +8 -0
- cmdscope/analysis/__init__.py +1 -0
- cmdscope/analysis/comparison.py +134 -0
- cmdscope/analysis/summary.py +37 -0
- cmdscope/cli.py +555 -0
- cmdscope/collectors/__init__.py +1 -0
- cmdscope/collectors/filesystem.py +370 -0
- cmdscope/collectors/network.py +196 -0
- cmdscope/collectors/processes.py +365 -0
- cmdscope/config.py +461 -0
- cmdscope/core/__init__.py +1 -0
- cmdscope/core/collector.py +48 -0
- cmdscope/core/engine.py +514 -0
- cmdscope/core/events.py +113 -0
- cmdscope/core/ignore.py +56 -0
- cmdscope/core/registry.py +33 -0
- cmdscope/core/session.py +81 -0
- cmdscope/diagnostics.py +183 -0
- cmdscope/errors.py +36 -0
- cmdscope/platforms/__init__.py +57 -0
- cmdscope/platforms/base.py +140 -0
- cmdscope/platforms/linux.py +28 -0
- cmdscope/platforms/macos.py +26 -0
- cmdscope/platforms/windows.py +28 -0
- cmdscope/privacy.py +143 -0
- cmdscope/py.typed +0 -0
- cmdscope/reports/__init__.py +1 -0
- cmdscope/reports/html.py +279 -0
- cmdscope/reports/json_report.py +133 -0
- cmdscope/reports/terminal.py +429 -0
- cmdscope/storage/__init__.py +6 -0
- cmdscope/storage/sqlite.py +469 -0
- cmdscope/storage/writer.py +105 -0
- cmdscope-0.1.0.dist-info/METADATA +353 -0
- cmdscope-0.1.0.dist-info/RECORD +40 -0
- cmdscope-0.1.0.dist-info/WHEEL +5 -0
- cmdscope-0.1.0.dist-info/entry_points.txt +2 -0
- cmdscope-0.1.0.dist-info/licenses/LICENSE +21 -0
- cmdscope-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
"""SQLite persistence for sessions, events, and collector status."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sqlite3
|
|
9
|
+
from collections.abc import Iterable, Sequence
|
|
10
|
+
from datetime import UTC, datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from cmdscope.core.events import (
|
|
14
|
+
CollectorState,
|
|
15
|
+
CollectorStatus,
|
|
16
|
+
Event,
|
|
17
|
+
EventKind,
|
|
18
|
+
JSONValue,
|
|
19
|
+
SessionStatus,
|
|
20
|
+
StoredEvent,
|
|
21
|
+
)
|
|
22
|
+
from cmdscope.core.session import Session, parse_session_ref
|
|
23
|
+
from cmdscope.errors import SessionNotFoundError, StorageError
|
|
24
|
+
|
|
25
|
+
SCHEMA_VERSION = 1
|
|
26
|
+
|
|
27
|
+
_SCHEMA = """
|
|
28
|
+
CREATE TABLE IF NOT EXISTS meta (
|
|
29
|
+
key TEXT PRIMARY KEY,
|
|
30
|
+
value TEXT NOT NULL
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
34
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
35
|
+
status TEXT NOT NULL,
|
|
36
|
+
command_json TEXT NOT NULL,
|
|
37
|
+
command_display TEXT NOT NULL,
|
|
38
|
+
cwd TEXT NOT NULL,
|
|
39
|
+
platform_name TEXT NOT NULL,
|
|
40
|
+
created_at TEXT NOT NULL,
|
|
41
|
+
started_at TEXT,
|
|
42
|
+
ended_at TEXT,
|
|
43
|
+
duration_s REAL,
|
|
44
|
+
exit_code INTEGER,
|
|
45
|
+
interrupted INTEGER NOT NULL DEFAULT 0,
|
|
46
|
+
error TEXT,
|
|
47
|
+
env_diff_json TEXT NOT NULL DEFAULT '{}',
|
|
48
|
+
options_json TEXT NOT NULL DEFAULT '{}'
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
52
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
53
|
+
session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
|
54
|
+
kind TEXT NOT NULL,
|
|
55
|
+
occurred_at TEXT NOT NULL,
|
|
56
|
+
source TEXT NOT NULL,
|
|
57
|
+
payload_json TEXT NOT NULL
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
CREATE INDEX IF NOT EXISTS idx_events_session_kind
|
|
61
|
+
ON events(session_id, kind);
|
|
62
|
+
|
|
63
|
+
CREATE TABLE IF NOT EXISTS collectors (
|
|
64
|
+
session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
|
65
|
+
name TEXT NOT NULL,
|
|
66
|
+
state TEXT NOT NULL,
|
|
67
|
+
message TEXT,
|
|
68
|
+
details_json TEXT NOT NULL DEFAULT '{}',
|
|
69
|
+
started_at TEXT,
|
|
70
|
+
stopped_at TEXT,
|
|
71
|
+
PRIMARY KEY (session_id, name)
|
|
72
|
+
);
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _iso(value: datetime) -> str:
|
|
77
|
+
if value.tzinfo is None:
|
|
78
|
+
value = value.replace(tzinfo=UTC)
|
|
79
|
+
return value.astimezone(UTC).isoformat(timespec="microseconds")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _datetime(value: str | None) -> datetime | None:
|
|
83
|
+
return datetime.fromisoformat(value) if value else None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _json(value: object) -> str:
|
|
87
|
+
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _load_json(value: str) -> JSONValue:
|
|
91
|
+
try:
|
|
92
|
+
loaded: JSONValue = json.loads(value)
|
|
93
|
+
except json.JSONDecodeError as exc:
|
|
94
|
+
raise StorageError(f"corrupt JSON stored in the database: {exc}") from exc
|
|
95
|
+
return loaded
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class Storage:
|
|
99
|
+
"""Small SQLite repository with schema versioning and cascading deletes."""
|
|
100
|
+
|
|
101
|
+
def __init__(self, path: Path) -> None:
|
|
102
|
+
self.path = Path(path).expanduser()
|
|
103
|
+
try:
|
|
104
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
105
|
+
except OSError as exc:
|
|
106
|
+
raise StorageError(
|
|
107
|
+
f"cannot create database directory {self.path.parent}: {exc}"
|
|
108
|
+
) from exc
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
self._connection = sqlite3.connect(self.path, timeout=30)
|
|
112
|
+
self._connection.row_factory = sqlite3.Row
|
|
113
|
+
self._connection.execute("PRAGMA foreign_keys = ON")
|
|
114
|
+
self._connection.execute("PRAGMA busy_timeout = 30000")
|
|
115
|
+
with contextlib.suppress(sqlite3.DatabaseError):
|
|
116
|
+
self._connection.execute("PRAGMA journal_mode = WAL")
|
|
117
|
+
self._initialize()
|
|
118
|
+
self._restrict_permissions()
|
|
119
|
+
except sqlite3.Error as exc:
|
|
120
|
+
raise StorageError(f"cannot open database {self.path}: {exc}") from exc
|
|
121
|
+
|
|
122
|
+
def _initialize(self) -> None:
|
|
123
|
+
with self._connection:
|
|
124
|
+
self._connection.executescript(_SCHEMA)
|
|
125
|
+
row = self._connection.execute(
|
|
126
|
+
"SELECT value FROM meta WHERE key = 'schema_version'"
|
|
127
|
+
).fetchone()
|
|
128
|
+
if row is None:
|
|
129
|
+
self._connection.execute(
|
|
130
|
+
"INSERT INTO meta(key, value) VALUES ('schema_version', ?)",
|
|
131
|
+
(str(SCHEMA_VERSION),),
|
|
132
|
+
)
|
|
133
|
+
return
|
|
134
|
+
stored = int(row["value"])
|
|
135
|
+
if stored > SCHEMA_VERSION:
|
|
136
|
+
raise StorageError(
|
|
137
|
+
f"database schema {stored} is newer than this CmdScope build "
|
|
138
|
+
f"({SCHEMA_VERSION}); upgrade CmdScope"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
def _restrict_permissions(self) -> None:
|
|
142
|
+
if os.name == "nt":
|
|
143
|
+
return
|
|
144
|
+
try:
|
|
145
|
+
os.chmod(self.path, 0o600)
|
|
146
|
+
os.chmod(self.path.parent, 0o700)
|
|
147
|
+
except OSError:
|
|
148
|
+
pass
|
|
149
|
+
|
|
150
|
+
def close(self) -> None:
|
|
151
|
+
with contextlib.suppress(sqlite3.Error):
|
|
152
|
+
self._connection.close()
|
|
153
|
+
|
|
154
|
+
def __enter__(self) -> Storage:
|
|
155
|
+
return self
|
|
156
|
+
|
|
157
|
+
def __exit__(self, *_exc: object) -> None:
|
|
158
|
+
self.close()
|
|
159
|
+
|
|
160
|
+
def schema_version(self) -> int:
|
|
161
|
+
row = self._connection.execute(
|
|
162
|
+
"SELECT value FROM meta WHERE key = 'schema_version'"
|
|
163
|
+
).fetchone()
|
|
164
|
+
return int(row["value"]) if row else 0
|
|
165
|
+
|
|
166
|
+
def quick_check(self) -> str:
|
|
167
|
+
row = self._connection.execute("PRAGMA quick_check").fetchone()
|
|
168
|
+
return str(row[0]) if row else "unknown"
|
|
169
|
+
|
|
170
|
+
def create_session(
|
|
171
|
+
self,
|
|
172
|
+
*,
|
|
173
|
+
command: Sequence[str],
|
|
174
|
+
command_display: str,
|
|
175
|
+
cwd: str,
|
|
176
|
+
platform_name: str,
|
|
177
|
+
created_at: datetime,
|
|
178
|
+
env_diff: dict[str, JSONValue],
|
|
179
|
+
options: dict[str, JSONValue],
|
|
180
|
+
) -> int:
|
|
181
|
+
try:
|
|
182
|
+
with self._connection:
|
|
183
|
+
cursor = self._connection.execute(
|
|
184
|
+
"""
|
|
185
|
+
INSERT INTO sessions(
|
|
186
|
+
status, command_json, command_display, cwd, platform_name,
|
|
187
|
+
created_at, env_diff_json, options_json
|
|
188
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
189
|
+
""",
|
|
190
|
+
(
|
|
191
|
+
str(SessionStatus.RUNNING),
|
|
192
|
+
_json(list(command)),
|
|
193
|
+
command_display,
|
|
194
|
+
cwd,
|
|
195
|
+
platform_name,
|
|
196
|
+
_iso(created_at),
|
|
197
|
+
_json(env_diff),
|
|
198
|
+
_json(options),
|
|
199
|
+
),
|
|
200
|
+
)
|
|
201
|
+
rowid = cursor.lastrowid
|
|
202
|
+
if rowid is None:
|
|
203
|
+
raise StorageError("SQLite did not return a row id")
|
|
204
|
+
return int(rowid)
|
|
205
|
+
except sqlite3.Error as exc:
|
|
206
|
+
raise StorageError(f"cannot create session: {exc}") from exc
|
|
207
|
+
|
|
208
|
+
def mark_command_started(self, session_id: int, started_at: datetime) -> None:
|
|
209
|
+
self._execute(
|
|
210
|
+
"UPDATE sessions SET started_at = ? WHERE id = ?",
|
|
211
|
+
(_iso(started_at), session_id),
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
def finish_session(
|
|
215
|
+
self,
|
|
216
|
+
session_id: int,
|
|
217
|
+
*,
|
|
218
|
+
status: SessionStatus,
|
|
219
|
+
ended_at: datetime,
|
|
220
|
+
duration_s: float,
|
|
221
|
+
exit_code: int | None,
|
|
222
|
+
interrupted: bool,
|
|
223
|
+
error: str | None,
|
|
224
|
+
) -> None:
|
|
225
|
+
self._execute(
|
|
226
|
+
"""
|
|
227
|
+
UPDATE sessions
|
|
228
|
+
SET status = ?, ended_at = ?, duration_s = ?, exit_code = ?,
|
|
229
|
+
interrupted = ?, error = ?
|
|
230
|
+
WHERE id = ?
|
|
231
|
+
""",
|
|
232
|
+
(
|
|
233
|
+
str(status),
|
|
234
|
+
_iso(ended_at),
|
|
235
|
+
duration_s,
|
|
236
|
+
exit_code,
|
|
237
|
+
1 if interrupted else 0,
|
|
238
|
+
error,
|
|
239
|
+
session_id,
|
|
240
|
+
),
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
def set_collector_statuses(self, session_id: int, statuses: Iterable[CollectorStatus]) -> None:
|
|
244
|
+
try:
|
|
245
|
+
with self._connection:
|
|
246
|
+
self._connection.execute(
|
|
247
|
+
"DELETE FROM collectors WHERE session_id = ?", (session_id,)
|
|
248
|
+
)
|
|
249
|
+
self._connection.executemany(
|
|
250
|
+
"""
|
|
251
|
+
INSERT INTO collectors(
|
|
252
|
+
session_id, name, state, message, details_json,
|
|
253
|
+
started_at, stopped_at
|
|
254
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
255
|
+
""",
|
|
256
|
+
[
|
|
257
|
+
(
|
|
258
|
+
session_id,
|
|
259
|
+
status.name,
|
|
260
|
+
str(status.state),
|
|
261
|
+
status.message,
|
|
262
|
+
_json(status.details),
|
|
263
|
+
_iso(status.started_at) if status.started_at else None,
|
|
264
|
+
_iso(status.stopped_at) if status.stopped_at else None,
|
|
265
|
+
)
|
|
266
|
+
for status in statuses
|
|
267
|
+
],
|
|
268
|
+
)
|
|
269
|
+
except sqlite3.Error as exc:
|
|
270
|
+
raise StorageError(f"cannot store collector status: {exc}") from exc
|
|
271
|
+
|
|
272
|
+
def insert_event(self, session_id: int, event: Event) -> int:
|
|
273
|
+
try:
|
|
274
|
+
with self._connection:
|
|
275
|
+
cursor = self._connection.execute(
|
|
276
|
+
"""
|
|
277
|
+
INSERT INTO events(session_id, kind, occurred_at, source, payload_json)
|
|
278
|
+
VALUES (?, ?, ?, ?, ?)
|
|
279
|
+
""",
|
|
280
|
+
(
|
|
281
|
+
session_id,
|
|
282
|
+
str(event.kind),
|
|
283
|
+
_iso(event.occurred_at),
|
|
284
|
+
event.source,
|
|
285
|
+
_json(event.payload),
|
|
286
|
+
),
|
|
287
|
+
)
|
|
288
|
+
rowid = cursor.lastrowid
|
|
289
|
+
if rowid is None:
|
|
290
|
+
raise StorageError("SQLite did not return a row id")
|
|
291
|
+
return int(rowid)
|
|
292
|
+
except sqlite3.Error as exc:
|
|
293
|
+
raise StorageError(f"cannot store event: {exc}") from exc
|
|
294
|
+
|
|
295
|
+
def get_session(self, session_id: int) -> Session | None:
|
|
296
|
+
if session_id <= 0:
|
|
297
|
+
return None
|
|
298
|
+
row = self._connection.execute(
|
|
299
|
+
"SELECT * FROM sessions WHERE id = ?", (session_id,)
|
|
300
|
+
).fetchone()
|
|
301
|
+
if row is None:
|
|
302
|
+
return None
|
|
303
|
+
return self._row_to_session(row, self.get_collector_statuses(session_id))
|
|
304
|
+
|
|
305
|
+
def require_session(self, reference: str) -> Session:
|
|
306
|
+
try:
|
|
307
|
+
session_id = parse_session_ref(reference)
|
|
308
|
+
except ValueError as exc:
|
|
309
|
+
raise SessionNotFoundError(f"invalid session reference: {reference!r}") from exc
|
|
310
|
+
session = self.get_session(session_id)
|
|
311
|
+
if session is None:
|
|
312
|
+
raise SessionNotFoundError(f"session not found: {reference}")
|
|
313
|
+
return session
|
|
314
|
+
|
|
315
|
+
def get_collector_statuses(self, session_id: int) -> tuple[CollectorStatus, ...]:
|
|
316
|
+
rows = self._connection.execute(
|
|
317
|
+
"SELECT * FROM collectors WHERE session_id = ? ORDER BY name",
|
|
318
|
+
(session_id,),
|
|
319
|
+
).fetchall()
|
|
320
|
+
statuses: list[CollectorStatus] = []
|
|
321
|
+
for row in rows:
|
|
322
|
+
try:
|
|
323
|
+
state = CollectorState(row["state"])
|
|
324
|
+
except ValueError:
|
|
325
|
+
state = CollectorState.DEGRADED
|
|
326
|
+
details = _load_json(row["details_json"])
|
|
327
|
+
statuses.append(
|
|
328
|
+
CollectorStatus(
|
|
329
|
+
name=str(row["name"]),
|
|
330
|
+
state=state,
|
|
331
|
+
message=str(row["message"]) if row["message"] is not None else None,
|
|
332
|
+
details=details if isinstance(details, dict) else {},
|
|
333
|
+
started_at=_datetime(row["started_at"]),
|
|
334
|
+
stopped_at=_datetime(row["stopped_at"]),
|
|
335
|
+
)
|
|
336
|
+
)
|
|
337
|
+
return tuple(statuses)
|
|
338
|
+
|
|
339
|
+
def get_events(
|
|
340
|
+
self,
|
|
341
|
+
session_id: int,
|
|
342
|
+
kinds: Sequence[EventKind] | None = None,
|
|
343
|
+
) -> list[StoredEvent]:
|
|
344
|
+
sql = "SELECT * FROM events WHERE session_id = ?"
|
|
345
|
+
parameters: list[object] = [session_id]
|
|
346
|
+
if kinds:
|
|
347
|
+
placeholders = ", ".join("?" for _ in kinds)
|
|
348
|
+
sql += f" AND kind IN ({placeholders})"
|
|
349
|
+
parameters.extend(str(kind) for kind in kinds)
|
|
350
|
+
sql += " ORDER BY id"
|
|
351
|
+
rows = self._connection.execute(sql, parameters).fetchall()
|
|
352
|
+
events: list[StoredEvent] = []
|
|
353
|
+
for row in rows:
|
|
354
|
+
payload = _load_json(row["payload_json"])
|
|
355
|
+
if not isinstance(payload, dict):
|
|
356
|
+
raise StorageError(f"event {row['id']} has a non-object payload")
|
|
357
|
+
events.append(
|
|
358
|
+
StoredEvent(
|
|
359
|
+
id=int(row["id"]),
|
|
360
|
+
session_id=int(row["session_id"]),
|
|
361
|
+
kind=EventKind(row["kind"]),
|
|
362
|
+
occurred_at=datetime.fromisoformat(row["occurred_at"]),
|
|
363
|
+
source=str(row["source"]),
|
|
364
|
+
payload=payload,
|
|
365
|
+
)
|
|
366
|
+
)
|
|
367
|
+
return events
|
|
368
|
+
|
|
369
|
+
def count_events(self, session_id: int) -> int:
|
|
370
|
+
row = self._connection.execute(
|
|
371
|
+
"SELECT COUNT(*) AS total FROM events WHERE session_id = ?", (session_id,)
|
|
372
|
+
).fetchone()
|
|
373
|
+
return int(row["total"]) if row else 0
|
|
374
|
+
|
|
375
|
+
def list_sessions(self, limit: int = 50) -> list[Session]:
|
|
376
|
+
if limit < 1:
|
|
377
|
+
raise ValueError("limit must be positive")
|
|
378
|
+
rows = self._connection.execute(
|
|
379
|
+
"SELECT * FROM sessions ORDER BY id DESC LIMIT ?", (limit,)
|
|
380
|
+
).fetchall()
|
|
381
|
+
return [self._row_to_session(row, ()) for row in rows]
|
|
382
|
+
|
|
383
|
+
def delete_session(self, session_id: int) -> bool:
|
|
384
|
+
try:
|
|
385
|
+
with self._connection:
|
|
386
|
+
cursor = self._connection.execute(
|
|
387
|
+
"DELETE FROM sessions WHERE id = ?", (session_id,)
|
|
388
|
+
)
|
|
389
|
+
return cursor.rowcount > 0
|
|
390
|
+
except sqlite3.Error as exc:
|
|
391
|
+
raise StorageError(f"cannot delete session: {exc}") from exc
|
|
392
|
+
|
|
393
|
+
def prune(self, max_sessions: int) -> int:
|
|
394
|
+
"""Delete oldest finished sessions when the configured limit is exceeded."""
|
|
395
|
+
|
|
396
|
+
if max_sessions <= 0:
|
|
397
|
+
return 0
|
|
398
|
+
row = self._connection.execute(
|
|
399
|
+
"SELECT COUNT(*) AS total FROM sessions WHERE status != ?",
|
|
400
|
+
(str(SessionStatus.RUNNING),),
|
|
401
|
+
).fetchone()
|
|
402
|
+
total = int(row["total"]) if row else 0
|
|
403
|
+
excess = total - max_sessions
|
|
404
|
+
if excess <= 0:
|
|
405
|
+
return 0
|
|
406
|
+
try:
|
|
407
|
+
with self._connection:
|
|
408
|
+
cursor = self._connection.execute(
|
|
409
|
+
"""
|
|
410
|
+
DELETE FROM sessions
|
|
411
|
+
WHERE id IN (
|
|
412
|
+
SELECT id FROM sessions
|
|
413
|
+
WHERE status != ?
|
|
414
|
+
ORDER BY id ASC
|
|
415
|
+
LIMIT ?
|
|
416
|
+
)
|
|
417
|
+
""",
|
|
418
|
+
(str(SessionStatus.RUNNING), excess),
|
|
419
|
+
)
|
|
420
|
+
return cursor.rowcount
|
|
421
|
+
except sqlite3.Error as exc:
|
|
422
|
+
raise StorageError(f"cannot prune sessions: {exc}") from exc
|
|
423
|
+
|
|
424
|
+
def storage_size_bytes(self) -> int:
|
|
425
|
+
total = 0
|
|
426
|
+
for suffix in ("", "-wal", "-shm"):
|
|
427
|
+
candidate = Path(f"{self.path}{suffix}")
|
|
428
|
+
with contextlib.suppress(OSError):
|
|
429
|
+
total += candidate.stat().st_size
|
|
430
|
+
return total
|
|
431
|
+
|
|
432
|
+
def _execute(self, sql: str, parameters: Sequence[object]) -> None:
|
|
433
|
+
try:
|
|
434
|
+
with self._connection:
|
|
435
|
+
self._connection.execute(sql, parameters)
|
|
436
|
+
except sqlite3.Error as exc:
|
|
437
|
+
raise StorageError(f"database update failed: {exc}") from exc
|
|
438
|
+
|
|
439
|
+
@staticmethod
|
|
440
|
+
def _row_to_session(row: sqlite3.Row, collectors: tuple[CollectorStatus, ...]) -> Session:
|
|
441
|
+
try:
|
|
442
|
+
command_value = _load_json(row["command_json"])
|
|
443
|
+
env_diff = _load_json(row["env_diff_json"])
|
|
444
|
+
options = _load_json(row["options_json"])
|
|
445
|
+
status = SessionStatus(row["status"])
|
|
446
|
+
except ValueError as exc:
|
|
447
|
+
raise StorageError(f"corrupt session row {row['id']}: {exc}") from exc
|
|
448
|
+
if not isinstance(command_value, list) or not all(
|
|
449
|
+
isinstance(item, str) for item in command_value
|
|
450
|
+
):
|
|
451
|
+
raise StorageError(f"corrupt command in session {row['id']}")
|
|
452
|
+
return Session(
|
|
453
|
+
id=int(row["id"]),
|
|
454
|
+
status=status,
|
|
455
|
+
command=tuple(str(item) for item in command_value),
|
|
456
|
+
command_display=str(row["command_display"]),
|
|
457
|
+
cwd=str(row["cwd"]),
|
|
458
|
+
platform_name=str(row["platform_name"]),
|
|
459
|
+
created_at=datetime.fromisoformat(row["created_at"]),
|
|
460
|
+
started_at=_datetime(row["started_at"]),
|
|
461
|
+
ended_at=_datetime(row["ended_at"]),
|
|
462
|
+
duration_s=float(row["duration_s"]) if row["duration_s"] is not None else None,
|
|
463
|
+
exit_code=int(row["exit_code"]) if row["exit_code"] is not None else None,
|
|
464
|
+
interrupted=bool(row["interrupted"]),
|
|
465
|
+
error=str(row["error"]) if row["error"] is not None else None,
|
|
466
|
+
env_diff=env_diff if isinstance(env_diff, dict) else {},
|
|
467
|
+
options=options if isinstance(options, dict) else {},
|
|
468
|
+
collectors=collectors,
|
|
469
|
+
)
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Event writer thread with bounded session storage."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import queue
|
|
6
|
+
import sqlite3
|
|
7
|
+
import threading
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from cmdscope.core.events import Event
|
|
12
|
+
from cmdscope.errors import StorageError
|
|
13
|
+
from cmdscope.storage.sqlite import _iso, _json
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class EventWriterStats:
|
|
18
|
+
written: int
|
|
19
|
+
dropped: int
|
|
20
|
+
truncated: bool
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class EventWriter:
|
|
24
|
+
"""Serialize collector events into SQLite without blocking collectors.
|
|
25
|
+
|
|
26
|
+
A single writer thread owns its SQLite connection. Collectors only enqueue
|
|
27
|
+
typed events, so filesystem observers, process pollers, and network pollers
|
|
28
|
+
never contend for the database or for each other's locks.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, database: Path, session_id: int, max_events: int) -> None:
|
|
32
|
+
if max_events < 1:
|
|
33
|
+
raise ValueError("max_events must be at least 1")
|
|
34
|
+
self._database = Path(database)
|
|
35
|
+
self._session_id = session_id
|
|
36
|
+
self._max_events = max_events
|
|
37
|
+
self._queue: queue.Queue[Event | None] = queue.Queue()
|
|
38
|
+
self._thread = threading.Thread(target=self._run, name="cmdscope-event-writer", daemon=True)
|
|
39
|
+
self._closed = False
|
|
40
|
+
self._written = 0
|
|
41
|
+
self._dropped = 0
|
|
42
|
+
self._error: BaseException | None = None
|
|
43
|
+
|
|
44
|
+
def start(self) -> None:
|
|
45
|
+
self._thread.start()
|
|
46
|
+
|
|
47
|
+
def emit(self, event: Event) -> None:
|
|
48
|
+
if self._closed:
|
|
49
|
+
return
|
|
50
|
+
self._queue.put(event)
|
|
51
|
+
|
|
52
|
+
def close(self) -> EventWriterStats:
|
|
53
|
+
if not self._closed:
|
|
54
|
+
self._closed = True
|
|
55
|
+
self._queue.put(None)
|
|
56
|
+
self._thread.join(timeout=30)
|
|
57
|
+
if self._thread.is_alive():
|
|
58
|
+
raise StorageError("event writer did not finish within 30 seconds")
|
|
59
|
+
if self._error is not None:
|
|
60
|
+
raise StorageError(f"event writer failed: {self._error}") from self._error
|
|
61
|
+
return EventWriterStats(
|
|
62
|
+
written=self._written,
|
|
63
|
+
dropped=self._dropped,
|
|
64
|
+
truncated=self._dropped > 0,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def _run(self) -> None:
|
|
68
|
+
connection: sqlite3.Connection | None = None
|
|
69
|
+
try:
|
|
70
|
+
connection = sqlite3.connect(self._database, timeout=30)
|
|
71
|
+
connection.execute("PRAGMA busy_timeout = 30000")
|
|
72
|
+
connection.execute("PRAGMA foreign_keys = ON")
|
|
73
|
+
while True:
|
|
74
|
+
try:
|
|
75
|
+
event = self._queue.get(timeout=0.5)
|
|
76
|
+
except queue.Empty:
|
|
77
|
+
connection.commit()
|
|
78
|
+
continue
|
|
79
|
+
if event is None:
|
|
80
|
+
break
|
|
81
|
+
if self._written >= self._max_events:
|
|
82
|
+
self._dropped += 1
|
|
83
|
+
continue
|
|
84
|
+
connection.execute(
|
|
85
|
+
"""
|
|
86
|
+
INSERT INTO events(session_id, kind, occurred_at, source, payload_json)
|
|
87
|
+
VALUES (?, ?, ?, ?, ?)
|
|
88
|
+
""",
|
|
89
|
+
(
|
|
90
|
+
self._session_id,
|
|
91
|
+
str(event.kind),
|
|
92
|
+
_iso(event.occurred_at),
|
|
93
|
+
event.source,
|
|
94
|
+
_json(event.payload),
|
|
95
|
+
),
|
|
96
|
+
)
|
|
97
|
+
self._written += 1
|
|
98
|
+
if self._written % 128 == 0:
|
|
99
|
+
connection.commit()
|
|
100
|
+
connection.commit()
|
|
101
|
+
except BaseException as exc:
|
|
102
|
+
self._error = exc
|
|
103
|
+
finally:
|
|
104
|
+
if connection is not None:
|
|
105
|
+
connection.close()
|