PyMkDB 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.
Files changed (54) hide show
  1. pymkdb/__init__.py +6 -0
  2. pymkdb/cli.py +57 -0
  3. pymkdb-0.1.0.dist-info/METADATA +86 -0
  4. pymkdb-0.1.0.dist-info/RECORD +54 -0
  5. pymkdb-0.1.0.dist-info/WHEEL +5 -0
  6. pymkdb-0.1.0.dist-info/entry_points.txt +2 -0
  7. pymkdb-0.1.0.dist-info/top_level.txt +3 -0
  8. sdk/__init__.py +1 -0
  9. sdk/connection.py +225 -0
  10. sdk/delta.py +19 -0
  11. sdk/http_connection.py +180 -0
  12. sdk/mkdb_client.py +226 -0
  13. sdk/responses.py +154 -0
  14. src/__init__.py +1 -0
  15. src/config/db.py +227 -0
  16. src/config/server.py +52 -0
  17. src/db/__init__.py +207 -0
  18. src/db/cache/__init__.py +1 -0
  19. src/db/cache/ram_cache.py +144 -0
  20. src/db/cache/write_queue.py +156 -0
  21. src/db/maintenance/__init__.py +0 -0
  22. src/db/maintenance/compactor.py +118 -0
  23. src/db/maintenance/task_scheduler.py +73 -0
  24. src/db/objects/store.py +283 -0
  25. src/db/parity/__init__.py +0 -0
  26. src/db/parity/parity_manager.py +196 -0
  27. src/db/query/__init__.py +1 -0
  28. src/db/query/full_text_index.py +168 -0
  29. src/db/query/numeric_index.py +196 -0
  30. src/db/query/query_engine.py +308 -0
  31. src/db/query/tokenizer.py +48 -0
  32. src/db/query_workers/__init__.py +16 -0
  33. src/db/query_workers/dispatcher.py +339 -0
  34. src/db/query_workers/task.py +78 -0
  35. src/db/query_workers/worker.py +292 -0
  36. src/db/requesting/main.py +0 -0
  37. src/db/storage/__init__.py +1 -0
  38. src/db/storage/blob_store.py +47 -0
  39. src/db/storage/index_manager.py +92 -0
  40. src/db/storage/log_manager.py +119 -0
  41. src/db/storage/serializer.py +38 -0
  42. src/filing/__init__.py +31 -0
  43. src/objects/__init__.py +190 -0
  44. src/runtime/__init__.py +15 -0
  45. src/server/__init__.py +0 -0
  46. src/server/coms/actions.py +209 -0
  47. src/server/coms/http.py +46 -0
  48. src/server/coms/http_handlers.py +445 -0
  49. src/server/coms/metrics.py +231 -0
  50. src/server/coms/socket.py +461 -0
  51. src/server/coms/socket_protocol.py +54 -0
  52. src/server/control/api/actions.py +1001 -0
  53. src/server/control/server.py +404 -0
  54. src/server/event_log.py +58 -0
@@ -0,0 +1,404 @@
1
+ import hashlib
2
+ import json
3
+ import os
4
+ import secrets
5
+ import time
6
+ from http.server import BaseHTTPRequestHandler
7
+ from urllib.parse import urlparse
8
+
9
+ from src.filing import read_file
10
+ from src.server.coms.http import HTTPServer
11
+
12
+ # ── Session store (in-memory, cleared on restart) ────────────────────────────
13
+ _SESSIONS: dict[str, float] = {} # admin cookie token → expiry
14
+ _SDK_SESSIONS: dict[str, str] = {} # sdk bearer token → control_username
15
+
16
+
17
+ # ── Password helpers ──────────────────────────────────────────────────────────
18
+
19
+ def hash_password(password: str) -> str:
20
+ """Hash a password using PBKDF2-HMAC-SHA256 with a random salt."""
21
+ salt = secrets.token_hex(16)
22
+ key = hashlib.pbkdf2_hmac("sha256", password.encode(), salt.encode(), 260_000)
23
+ return f"pbkdf2:sha256:260000:{salt}:{key.hex()}"
24
+
25
+
26
+ def verify_password(password: str, stored: str) -> bool:
27
+ """Verify a plaintext password against a stored PBKDF2 hash."""
28
+ if not stored:
29
+ return False
30
+ try:
31
+ _, algo, iters, salt, key_hex = stored.split(":")
32
+ key = hashlib.pbkdf2_hmac(algo, password.encode(), salt.encode(), int(iters))
33
+ return secrets.compare_digest(key.hex(), key_hex)
34
+ except Exception:
35
+ return False
36
+
37
+
38
+ # ── Session helpers ───────────────────────────────────────────────────────────
39
+
40
+ def _new_session(ttl: int) -> str:
41
+ token = secrets.token_hex(32)
42
+ _SESSIONS[token] = time.time() + ttl
43
+ _purge_expired()
44
+ return token
45
+
46
+
47
+ def _purge_expired() -> None:
48
+ now = time.time()
49
+ for t in [t for t, exp in _SESSIONS.items() if now > exp]:
50
+ del _SESSIONS[t]
51
+
52
+
53
+ def _validate_session(token: str) -> bool:
54
+ if not token:
55
+ return False
56
+ expiry = _SESSIONS.get(token)
57
+ if expiry is None:
58
+ return False
59
+ if time.time() > expiry:
60
+ del _SESSIONS[token]
61
+ return False
62
+ return True
63
+
64
+
65
+ def invalidate_all_sessions() -> int:
66
+ count = len(_SESSIONS)
67
+ _SESSIONS.clear()
68
+ return count
69
+
70
+
71
+ def active_session_count() -> int:
72
+ _purge_expired()
73
+ return len(_SESSIONS)
74
+
75
+
76
+ # ── SDK / control-user session helpers ───────────────────────────────────────
77
+
78
+ def _new_sdk_token(username: str) -> str:
79
+ token = "sdk_" + secrets.token_hex(32)
80
+ _SDK_SESSIONS[token] = username
81
+ return token
82
+
83
+
84
+ def _validate_sdk_token(token: str):
85
+ """Return username or None."""
86
+ return _SDK_SESSIONS.get(token)
87
+
88
+
89
+ # ── Role-based permission map ─────────────────────────────────────────────────
90
+ # Maps action name → minimum role required ("viewer" < "operator" < "admin").
91
+ # Any action NOT listed requires "admin" (safe default).
92
+
93
+ _ROLE_ORDER = {"viewer": 0, "operator": 1, "admin": 2}
94
+
95
+ _ACTION_ROLES: dict[str, str] = {
96
+ # viewer
97
+ "api_list_stores": "viewer",
98
+ "api_get_store_config": "viewer",
99
+ "api_export_store_json": "viewer",
100
+ "api_get_db_info": "viewer",
101
+ "api_get_dashboard": "viewer",
102
+ "api_get_event_log": "viewer",
103
+ "api_get_auth_status": "viewer",
104
+ "api_get_db_settings": "viewer",
105
+ "api_get_server_status": "viewer",
106
+ "api_list_users": "viewer",
107
+ "api_get_rate_limit_log": "viewer",
108
+ "api_list_control_users": "viewer",
109
+ "api_query_store": "viewer",
110
+ "api_get_data_security": "viewer",
111
+ "api_get_store_metrics": "viewer",
112
+ "api_get_all_store_metrics": "viewer",
113
+ # operator
114
+ "api_create_store": "operator",
115
+ "api_delete_store": "operator",
116
+ "api_update_store": "operator",
117
+ "api_update_store_config": "operator",
118
+ "api_reset_store_metrics": "operator",
119
+ # admin
120
+ "api_update_server_config": "admin",
121
+ "api_server_control": "admin",
122
+ "api_set_auth_password": "admin",
123
+ "api_set_session_ttl": "admin",
124
+ "api_invalidate_sessions": "admin",
125
+ "api_set_data_security": "admin",
126
+ "api_update_db_settings": "admin",
127
+ # admin — everything else defaults to admin
128
+ }
129
+
130
+
131
+ # ── Request handler ───────────────────────────────────────────────────────────
132
+
133
+ class HTTPRequestHandler(BaseHTTPRequestHandler):
134
+ database = None # set by start_control_server
135
+
136
+ # helpers ─────────────────────────────────────────────────────────────────
137
+
138
+ def _json(self, code: int, payload: dict) -> None:
139
+ body = json.dumps(payload).encode()
140
+ self.send_response(code)
141
+ self.send_header("Content-Type", "application/json")
142
+ self.send_header("Content-Length", str(len(body)))
143
+ self.end_headers()
144
+ self.wfile.write(body)
145
+
146
+ def _html(self, code: int, body: bytes) -> None:
147
+ self.send_response(code)
148
+ self.send_header("Content-Type", "text/html; charset=utf-8")
149
+ self.send_header("Content-Length", str(len(body)))
150
+ self.send_header("Cache-Control", "no-store")
151
+ self.end_headers()
152
+ self.wfile.write(body)
153
+
154
+ def _redirect(self, location: str) -> None:
155
+ self.send_response(302)
156
+ self.send_header("Location", location)
157
+ self.end_headers()
158
+
159
+ def _read_body(self, max_bytes: int = 1_048_576) -> dict:
160
+ length = int(self.headers.get("Content-Length", 0))
161
+ if length > max_bytes:
162
+ raise ValueError(f"Payload too large ({length} bytes, max {max_bytes})")
163
+ raw = self.rfile.read(length)
164
+ return json.loads(raw.decode())
165
+
166
+ def _serve_file(self, name: str) -> None:
167
+ # Sanitise: strip path separators to prevent traversal
168
+ safe_name = os.path.basename(name)
169
+ file_path = os.path.join(
170
+ os.path.dirname(os.path.abspath(__file__)), "files", f"{safe_name}.html"
171
+ )
172
+ if os.path.exists(file_path):
173
+ self._html(200, read_file(file_path).encode())
174
+ else:
175
+ self._html(404, b"<h1>404 Not Found</h1>")
176
+
177
+ def _get_cookie(self, name: str) -> str:
178
+ raw = self.headers.get("Cookie", "")
179
+ for part in raw.split(";"):
180
+ k, _, v = part.strip().partition("=")
181
+ if k.strip() == name:
182
+ return v.strip()
183
+ return ""
184
+
185
+ def _auth_enabled(self) -> bool:
186
+ db = self.database
187
+ return bool(db and db.config.servers.control_server.auth.enabled)
188
+
189
+ def _is_authenticated(self) -> bool:
190
+ if not self._auth_enabled():
191
+ return True
192
+ return _validate_session(self._get_cookie("mkdb_token"))
193
+
194
+ def _sdk_username(self) -> str | None:
195
+ """Return the control_user username if a valid SDK Bearer token is present."""
196
+ auth = self.headers.get("Authorization", "")
197
+ if auth.startswith("Bearer "):
198
+ return _validate_sdk_token(auth[7:].strip())
199
+ return None
200
+
201
+ def _caller_role(self) -> str:
202
+ """Return the effective role of the caller (admin session → 'admin'; SDK token → user role)."""
203
+ if self._is_authenticated(): # admin session or no auth required
204
+ return "admin"
205
+ sdk_user = self._sdk_username()
206
+ if sdk_user and self.database:
207
+ cu = self.database.config.control_users.get(sdk_user)
208
+ if cu:
209
+ return cu.role
210
+ return "" # unauthenticated
211
+
212
+ # GET ─────────────────────────────────────────────────────────────────────
213
+
214
+ def do_GET(self) -> None:
215
+ path = urlparse(self.path).path.rstrip("/") or "/"
216
+
217
+ if path == "/":
218
+ self._redirect("/home")
219
+ return
220
+
221
+ # Always accessible — no auth check
222
+ if path == "/auth/status":
223
+ self._handle_auth_status()
224
+ return
225
+ if path == "/login":
226
+ self._serve_file("login")
227
+ return
228
+
229
+ # All other pages require valid session when auth is enabled
230
+ if not self._is_authenticated():
231
+ self._redirect("/login")
232
+ return
233
+
234
+ self._serve_file(path.lstrip("/"))
235
+
236
+ # POST ────────────────────────────────────────────────────────────────────
237
+
238
+ def do_POST(self) -> None:
239
+ path = urlparse(self.path).path
240
+
241
+ if path == "/auth/login":
242
+ self._handle_auth_login()
243
+ return
244
+ if path == "/auth/logout":
245
+ self._handle_auth_logout()
246
+ return
247
+ if path == "/control/auth":
248
+ self._handle_control_auth()
249
+ return
250
+ if path == "/control/auth/logout":
251
+ self._handle_control_auth_logout()
252
+ return
253
+
254
+ # Admin session OR valid SDK Bearer token may call /control
255
+ caller_role = self._caller_role()
256
+ if not caller_role:
257
+ self._json(401, {"status": "error", "message": "Authentication required"})
258
+ return
259
+
260
+ if path == "/control":
261
+ self._handle_control(caller_role)
262
+ return
263
+
264
+ self._json(404, {"status": "error", "message": "Not found"})
265
+
266
+ # Auth endpoints ──────────────────────────────────────────────────────────
267
+
268
+ def _handle_auth_status(self) -> None:
269
+ enabled = self._auth_enabled()
270
+ authenticated = self._is_authenticated()
271
+ has_password = False
272
+ if self.database and enabled:
273
+ has_password = bool(
274
+ self.database.config.servers.control_server.auth.password_hash
275
+ )
276
+ self._json(200, {
277
+ "enabled": enabled,
278
+ "authenticated": authenticated,
279
+ "has_password": has_password,
280
+ "sessions": active_session_count(),
281
+ })
282
+
283
+ def _handle_auth_login(self) -> None:
284
+ try:
285
+ data = self._read_body()
286
+ except Exception as e:
287
+ self._json(400, {"status": "error", "message": str(e)})
288
+ return
289
+
290
+ auth_cfg = (
291
+ self.database.config.servers.control_server.auth
292
+ if self.database else None
293
+ )
294
+
295
+ if not self._auth_enabled() or auth_cfg is None:
296
+ # Auth disabled — hand out a short-lived session so the UI cookie
297
+ # pattern works uniformly without requiring a real password check.
298
+ token = _new_session(3600)
299
+ self._json(200, {"status": "ok", "token": token})
300
+ return
301
+
302
+ password = data.get("password", "")
303
+
304
+ if not auth_cfg.password_hash:
305
+ # First-time setup: accept this password and store it.
306
+ if not password:
307
+ self._json(400, {"status": "error", "message": "Password cannot be empty"})
308
+ return
309
+ auth_cfg.password_hash = hash_password(password)
310
+ self.database.config.save() # type: ignore
311
+ elif not verify_password(password, auth_cfg.password_hash):
312
+ self._json(401, {"status": "error", "message": "Incorrect password"})
313
+ return
314
+
315
+ token = _new_session(auth_cfg.session_ttl)
316
+ self._json(200, {"status": "ok", "token": token})
317
+
318
+ def _handle_auth_logout(self) -> None:
319
+ token = self._get_cookie("mkdb_token")
320
+ if token and token in _SESSIONS:
321
+ del _SESSIONS[token]
322
+ self._json(200, {"status": "ok"})
323
+
324
+ # Control endpoint ────────────────────────────────────────────────────────
325
+
326
+ def _handle_control_auth(self) -> None:
327
+ """Authenticate a control/SDK user and return a Bearer token."""
328
+ try:
329
+ data = self._read_body()
330
+ except Exception as e:
331
+ self._json(400, {"status": "error", "message": str(e)})
332
+ return
333
+ username = str(data.get("username", "")).strip()
334
+ password = str(data.get("password", ""))
335
+ if not username or not password:
336
+ self._json(400, {"status": "error", "message": "username and password required"})
337
+ return
338
+ db = self.database
339
+ if db is None:
340
+ self._json(503, {"status": "error", "message": "Database not available"})
341
+ return
342
+ cu = db.config.control_users.get(username)
343
+ if cu is None or not verify_password(password, cu.password_hash):
344
+ self._json(403, {"status": "error", "message": "Invalid credentials"})
345
+ return
346
+ token = _new_sdk_token(username)
347
+ self._json(200, {"status": "ok", "token": token, "role": cu.role})
348
+
349
+ def _handle_control_auth_logout(self) -> None:
350
+ auth = self.headers.get("Authorization", "")
351
+ if auth.startswith("Bearer "):
352
+ _SDK_SESSIONS.pop(auth[7:].strip(), None)
353
+ self._json(200, {"status": "ok"})
354
+
355
+ def _handle_control(self, caller_role: str = "admin") -> None:
356
+ try:
357
+ data = self._read_body(max_bytes=10_485_760)
358
+ except Exception as e:
359
+ self._json(400, {"status": "error", "message": str(e)})
360
+ return
361
+
362
+ action = data.get("action")
363
+ if action not in CONTROL_FUNCTIONS:
364
+ self._json(400, {"status": "error", "message": f"Unknown action: {action!r}"})
365
+ return
366
+
367
+ # RBAC check
368
+ required_role = _ACTION_ROLES.get(action, "admin")
369
+ if _ROLE_ORDER.get(caller_role, -1) < _ROLE_ORDER.get(required_role, 2):
370
+ self._json(403, {"status": "error",
371
+ "message": f"Action '{action}' requires role '{required_role}' (you have '{caller_role}')"})
372
+ return
373
+
374
+ try:
375
+ result = CONTROL_FUNCTIONS[action](self.database, data)
376
+ self._json(200, {"status": "success", "result": result})
377
+ except Exception as e:
378
+ from traceback import print_exc
379
+ print_exc()
380
+ self._json(500, {"status": "error", "message": str(e)})
381
+
382
+ def log_message(self, format: str, *args) -> None:
383
+ pass # suppress default stdout logging
384
+
385
+
386
+ CONTROL_FUNCTIONS: dict = {}
387
+
388
+
389
+ def start_control_server(host: str, port: int, database=None):
390
+ HTTPRequestHandler.database = database
391
+
392
+ import inspect
393
+ from src.server.control.api import actions
394
+ for name, obj in inspect.getmembers(actions):
395
+ if inspect.isfunction(obj) and "api" in obj.__name__:
396
+ CONTROL_FUNCTIONS[name] = obj
397
+
398
+ srv = HTTPServer(
399
+ name="Control-HTTP",
400
+ host=host,
401
+ port=port,
402
+ responder=HTTPRequestHandler, # type: ignore
403
+ )
404
+ return srv
@@ -0,0 +1,58 @@
1
+ """
2
+ event_log.py — In-memory circular event log for the MkDB dashboard.
3
+
4
+ Usage:
5
+ from src.server.event_log import emit, get_events
6
+
7
+ emit("info", "store:products", "Store loaded — 1024 entries recovered from disk")
8
+ emit("warning", "store:orders", "RAM cache full — eviction started")
9
+ emit("error", "db", "File recovery attempted on segment orders_003.log")
10
+ """
11
+
12
+ import threading
13
+ import time
14
+ from typing import Literal
15
+
16
+ _MAX_EVENTS = 200 # circular buffer depth
17
+ _lock = threading.Lock()
18
+ _events: list[dict] = [] # newest at the end
19
+
20
+ Level = Literal["info", "warning", "error", "recovery"]
21
+
22
+
23
+ def emit(level: Level, source: str, message: str) -> None:
24
+ """Append one event to the ring buffer, dropping the oldest if full."""
25
+ entry = {
26
+ "ts": round(time.time(), 3),
27
+ "level": level,
28
+ "source": source,
29
+ "message": message,
30
+ }
31
+ with _lock:
32
+ _events.append(entry)
33
+ if len(_events) > _MAX_EVENTS:
34
+ _events.pop(0)
35
+
36
+
37
+ def get_events(limit: int = 50, level: str = "") -> list[dict]:
38
+ """Return up to `limit` most-recent events (newest first).
39
+
40
+ Parameters
41
+ ----------
42
+ limit : int
43
+ Maximum number of events to return (capped at _MAX_EVENTS).
44
+ level : str
45
+ If given, filter to only events with this level.
46
+ """
47
+ with _lock:
48
+ snapshot = list(_events) # copy under lock
49
+ snapshot.reverse() # newest first
50
+ if level:
51
+ snapshot = [e for e in snapshot if e["level"] == level]
52
+ return snapshot[:max(1, min(limit, _MAX_EVENTS))]
53
+
54
+
55
+ def clear() -> None:
56
+ """Wipe all events (used by tests / admin reset)."""
57
+ with _lock:
58
+ _events.clear()