codecortex 0.3.0__tar.gz → 0.4.0__tar.gz

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.

Potentially problematic release.


This version of codecortex might be problematic. Click here for more details.

Files changed (61) hide show
  1. {codecortex-0.3.0/src/codecortex.egg-info → codecortex-0.4.0}/PKG-INFO +23 -1
  2. {codecortex-0.3.0 → codecortex-0.4.0}/README.md +22 -0
  3. {codecortex-0.3.0 → codecortex-0.4.0/src/codecortex.egg-info}/PKG-INFO +23 -1
  4. {codecortex-0.3.0 → codecortex-0.4.0}/src/codecortex.egg-info/SOURCES.txt +3 -0
  5. codecortex-0.4.0/src/codeintel/__init__.py +1 -0
  6. {codecortex-0.3.0 → codecortex-0.4.0}/src/codeintel/http_server.py +139 -20
  7. codecortex-0.4.0/src/codeintel/logconfig.py +54 -0
  8. codecortex-0.4.0/src/codeintel/metrics.py +87 -0
  9. {codecortex-0.3.0 → codecortex-0.4.0}/src/codeintel/server.py +2 -0
  10. codecortex-0.4.0/tests/test_enterprise.py +145 -0
  11. codecortex-0.3.0/src/codeintel/__init__.py +0 -1
  12. {codecortex-0.3.0 → codecortex-0.4.0}/LICENSE +0 -0
  13. {codecortex-0.3.0 → codecortex-0.4.0}/pyproject.toml +0 -0
  14. {codecortex-0.3.0 → codecortex-0.4.0}/setup.cfg +0 -0
  15. {codecortex-0.3.0 → codecortex-0.4.0}/src/codecortex.egg-info/dependency_links.txt +0 -0
  16. {codecortex-0.3.0 → codecortex-0.4.0}/src/codecortex.egg-info/entry_points.txt +0 -0
  17. {codecortex-0.3.0 → codecortex-0.4.0}/src/codecortex.egg-info/requires.txt +0 -0
  18. {codecortex-0.3.0 → codecortex-0.4.0}/src/codecortex.egg-info/top_level.txt +0 -0
  19. {codecortex-0.3.0 → codecortex-0.4.0}/src/codeintel/__main__.py +0 -0
  20. {codecortex-0.3.0 → codecortex-0.4.0}/src/codeintel/cache.py +0 -0
  21. {codecortex-0.3.0 → codecortex-0.4.0}/src/codeintel/config.py +0 -0
  22. {codecortex-0.3.0 → codecortex-0.4.0}/src/codeintel/doctor.py +0 -0
  23. {codecortex-0.3.0 → codecortex-0.4.0}/src/codeintel/gateway.py +0 -0
  24. {codecortex-0.3.0 → codecortex-0.4.0}/src/codeintel/indexer.py +0 -0
  25. {codecortex-0.3.0 → codecortex-0.4.0}/src/codeintel/injector.py +0 -0
  26. {codecortex-0.3.0 → codecortex-0.4.0}/src/codeintel/installer.py +0 -0
  27. {codecortex-0.3.0 → codecortex-0.4.0}/src/codeintel/mapper.py +0 -0
  28. {codecortex-0.3.0 → codecortex-0.4.0}/src/codeintel/onboarding.py +0 -0
  29. {codecortex-0.3.0 → codecortex-0.4.0}/src/codeintel/policy.py +0 -0
  30. {codecortex-0.3.0 → codecortex-0.4.0}/src/codeintel/provider.py +0 -0
  31. {codecortex-0.3.0 → codecortex-0.4.0}/src/codeintel/providers/__init__.py +0 -0
  32. {codecortex-0.3.0 → codecortex-0.4.0}/src/codeintel/providers/graph.py +0 -0
  33. {codecortex-0.3.0 → codecortex-0.4.0}/src/codeintel/providers/lsp.py +0 -0
  34. {codecortex-0.3.0 → codecortex-0.4.0}/src/codeintel/providers/none.py +0 -0
  35. {codecortex-0.3.0 → codecortex-0.4.0}/src/codeintel/providers/semantic.py +0 -0
  36. {codecortex-0.3.0 → codecortex-0.4.0}/src/codeintel/reindexer.py +0 -0
  37. {codecortex-0.3.0 → codecortex-0.4.0}/src/codeintel/reset.py +0 -0
  38. {codecortex-0.3.0 → codecortex-0.4.0}/src/codeintel/searcher.py +0 -0
  39. {codecortex-0.3.0 → codecortex-0.4.0}/src/codeintel/semantic_db.py +0 -0
  40. {codecortex-0.3.0 → codecortex-0.4.0}/src/codeintel/term.py +0 -0
  41. {codecortex-0.3.0 → codecortex-0.4.0}/tests/test_cache.py +0 -0
  42. {codecortex-0.3.0 → codecortex-0.4.0}/tests/test_config.py +0 -0
  43. {codecortex-0.3.0 → codecortex-0.4.0}/tests/test_doctor.py +0 -0
  44. {codecortex-0.3.0 → codecortex-0.4.0}/tests/test_e2e.py +0 -0
  45. {codecortex-0.3.0 → codecortex-0.4.0}/tests/test_gateway.py +0 -0
  46. {codecortex-0.3.0 → codecortex-0.4.0}/tests/test_graph_provider.py +0 -0
  47. {codecortex-0.3.0 → codecortex-0.4.0}/tests/test_graph_real.py +0 -0
  48. {codecortex-0.3.0 → codecortex-0.4.0}/tests/test_graph_stdin.py +0 -0
  49. {codecortex-0.3.0 → codecortex-0.4.0}/tests/test_hardening.py +0 -0
  50. {codecortex-0.3.0 → codecortex-0.4.0}/tests/test_http_auth.py +0 -0
  51. {codecortex-0.3.0 → codecortex-0.4.0}/tests/test_http_server.py +0 -0
  52. {codecortex-0.3.0 → codecortex-0.4.0}/tests/test_integration.py +0 -0
  53. {codecortex-0.3.0 → codecortex-0.4.0}/tests/test_lsp_provider.py +0 -0
  54. {codecortex-0.3.0 → codecortex-0.4.0}/tests/test_lsp_real.py +0 -0
  55. {codecortex-0.3.0 → codecortex-0.4.0}/tests/test_mapper.py +0 -0
  56. {codecortex-0.3.0 → codecortex-0.4.0}/tests/test_never_raise.py +0 -0
  57. {codecortex-0.3.0 → codecortex-0.4.0}/tests/test_onboarding.py +0 -0
  58. {codecortex-0.3.0 → codecortex-0.4.0}/tests/test_reindexer.py +0 -0
  59. {codecortex-0.3.0 → codecortex-0.4.0}/tests/test_reset.py +0 -0
  60. {codecortex-0.3.0 → codecortex-0.4.0}/tests/test_semantic_provider.py +0 -0
  61. {codecortex-0.3.0 → codecortex-0.4.0}/tests/test_term.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codecortex
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Local-first, MCP-native code-intelligence server — graph, LSP, and semantic search behind one safe code.query tool for coding agents.
5
5
  Author: Shammai Hamilton
6
6
  License-Expression: MIT
@@ -213,6 +213,9 @@ Config is **validated on load** — an out-of-range number, a misspelled enum, o
213
213
  | Variable | Effect |
214
214
  |---|---|
215
215
  | `CODEINTEL_HTTP_TOKEN` | Bearer token required by `serve-http` (equivalent to `--token`) |
216
+ | `CODEINTEL_LOG_LEVEL` | `DEBUG`\|`INFO`\|`WARNING`(default)\|`ERROR` for the server logger |
217
+ | `CODEINTEL_LOG_FORMAT=json` | Structured (JSON-per-line) logs for ELK / Splunk / Datadog |
218
+ | `CODEINTEL_HTTP_ACCESS_LOG=1` | One log line per HTTP request (method, path, status, latency) |
216
219
  | `CODEINTEL_DEBUG=1` | Log the full traceback of any error the never-throw contract swallows (silent by default) — the switch for diagnosing an unexpected `null` |
217
220
  | `CODEINTEL_REINDEX=off` | Disable the background reindexer; queries then index inline to stay fresh |
218
221
 
@@ -273,6 +276,25 @@ if result["result"] is not None:
273
276
 
274
277
  The response is always JSON-safe. Check `result["result"] is not None` before use. Never catch an exception from the gateway — it never raises.
275
278
 
279
+ ## Operations & deployment
280
+
281
+ Running codeintel as a shared service? It ships with what ops teams expect:
282
+
283
+ | Endpoint | Auth | Purpose |
284
+ |---|---|---|
285
+ | `GET /healthz` | none | Liveness — always `200` (for load balancers / `livenessProbe`) |
286
+ | `GET /readyz` | none | Readiness — `200` once the gateway is up (`readinessProbe`) |
287
+ | `GET /metrics` | token | Prometheus exposition — request counts, latency, in-flight, build info |
288
+
289
+ Plus **bearer-token auth**, **structured JSON logs** (`CODEINTEL_LOG_FORMAT=json`) with optional per-request access logs, **graceful `SIGTERM`** shutdown, a bounded connection pool, and a non-root **Dockerfile** with a healthcheck.
290
+
291
+ **Full guide → [docs/deploy.md](docs/deploy.md)**: systemd, Docker / Compose, Kubernetes (liveness + readiness probes, token from a Secret), reverse-proxy TLS, a Prometheus scrape config, and a security checklist.
292
+
293
+ ```bash
294
+ docker build -t codeintel . && docker run -p 127.0.0.1:8766:8766 \
295
+ -e CODEINTEL_HTTP_TOKEN="$(openssl rand -hex 32)" codeintel
296
+ ```
297
+
276
298
  ## Development
277
299
 
278
300
  ```bash
@@ -180,6 +180,9 @@ Config is **validated on load** — an out-of-range number, a misspelled enum, o
180
180
  | Variable | Effect |
181
181
  |---|---|
182
182
  | `CODEINTEL_HTTP_TOKEN` | Bearer token required by `serve-http` (equivalent to `--token`) |
183
+ | `CODEINTEL_LOG_LEVEL` | `DEBUG`\|`INFO`\|`WARNING`(default)\|`ERROR` for the server logger |
184
+ | `CODEINTEL_LOG_FORMAT=json` | Structured (JSON-per-line) logs for ELK / Splunk / Datadog |
185
+ | `CODEINTEL_HTTP_ACCESS_LOG=1` | One log line per HTTP request (method, path, status, latency) |
183
186
  | `CODEINTEL_DEBUG=1` | Log the full traceback of any error the never-throw contract swallows (silent by default) — the switch for diagnosing an unexpected `null` |
184
187
  | `CODEINTEL_REINDEX=off` | Disable the background reindexer; queries then index inline to stay fresh |
185
188
 
@@ -240,6 +243,25 @@ if result["result"] is not None:
240
243
 
241
244
  The response is always JSON-safe. Check `result["result"] is not None` before use. Never catch an exception from the gateway — it never raises.
242
245
 
246
+ ## Operations & deployment
247
+
248
+ Running codeintel as a shared service? It ships with what ops teams expect:
249
+
250
+ | Endpoint | Auth | Purpose |
251
+ |---|---|---|
252
+ | `GET /healthz` | none | Liveness — always `200` (for load balancers / `livenessProbe`) |
253
+ | `GET /readyz` | none | Readiness — `200` once the gateway is up (`readinessProbe`) |
254
+ | `GET /metrics` | token | Prometheus exposition — request counts, latency, in-flight, build info |
255
+
256
+ Plus **bearer-token auth**, **structured JSON logs** (`CODEINTEL_LOG_FORMAT=json`) with optional per-request access logs, **graceful `SIGTERM`** shutdown, a bounded connection pool, and a non-root **Dockerfile** with a healthcheck.
257
+
258
+ **Full guide → [docs/deploy.md](docs/deploy.md)**: systemd, Docker / Compose, Kubernetes (liveness + readiness probes, token from a Secret), reverse-proxy TLS, a Prometheus scrape config, and a security checklist.
259
+
260
+ ```bash
261
+ docker build -t codeintel . && docker run -p 127.0.0.1:8766:8766 \
262
+ -e CODEINTEL_HTTP_TOKEN="$(openssl rand -hex 32)" codeintel
263
+ ```
264
+
243
265
  ## Development
244
266
 
245
267
  ```bash
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codecortex
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Local-first, MCP-native code-intelligence server — graph, LSP, and semantic search behind one safe code.query tool for coding agents.
5
5
  Author: Shammai Hamilton
6
6
  License-Expression: MIT
@@ -213,6 +213,9 @@ Config is **validated on load** — an out-of-range number, a misspelled enum, o
213
213
  | Variable | Effect |
214
214
  |---|---|
215
215
  | `CODEINTEL_HTTP_TOKEN` | Bearer token required by `serve-http` (equivalent to `--token`) |
216
+ | `CODEINTEL_LOG_LEVEL` | `DEBUG`\|`INFO`\|`WARNING`(default)\|`ERROR` for the server logger |
217
+ | `CODEINTEL_LOG_FORMAT=json` | Structured (JSON-per-line) logs for ELK / Splunk / Datadog |
218
+ | `CODEINTEL_HTTP_ACCESS_LOG=1` | One log line per HTTP request (method, path, status, latency) |
216
219
  | `CODEINTEL_DEBUG=1` | Log the full traceback of any error the never-throw contract swallows (silent by default) — the switch for diagnosing an unexpected `null` |
217
220
  | `CODEINTEL_REINDEX=off` | Disable the background reindexer; queries then index inline to stay fresh |
218
221
 
@@ -273,6 +276,25 @@ if result["result"] is not None:
273
276
 
274
277
  The response is always JSON-safe. Check `result["result"] is not None` before use. Never catch an exception from the gateway — it never raises.
275
278
 
279
+ ## Operations & deployment
280
+
281
+ Running codeintel as a shared service? It ships with what ops teams expect:
282
+
283
+ | Endpoint | Auth | Purpose |
284
+ |---|---|---|
285
+ | `GET /healthz` | none | Liveness — always `200` (for load balancers / `livenessProbe`) |
286
+ | `GET /readyz` | none | Readiness — `200` once the gateway is up (`readinessProbe`) |
287
+ | `GET /metrics` | token | Prometheus exposition — request counts, latency, in-flight, build info |
288
+
289
+ Plus **bearer-token auth**, **structured JSON logs** (`CODEINTEL_LOG_FORMAT=json`) with optional per-request access logs, **graceful `SIGTERM`** shutdown, a bounded connection pool, and a non-root **Dockerfile** with a healthcheck.
290
+
291
+ **Full guide → [docs/deploy.md](docs/deploy.md)**: systemd, Docker / Compose, Kubernetes (liveness + readiness probes, token from a Secret), reverse-proxy TLS, a Prometheus scrape config, and a security checklist.
292
+
293
+ ```bash
294
+ docker build -t codeintel . && docker run -p 127.0.0.1:8766:8766 \
295
+ -e CODEINTEL_HTTP_TOKEN="$(openssl rand -hex 32)" codeintel
296
+ ```
297
+
276
298
  ## Development
277
299
 
278
300
  ```bash
@@ -17,7 +17,9 @@ src/codeintel/http_server.py
17
17
  src/codeintel/indexer.py
18
18
  src/codeintel/injector.py
19
19
  src/codeintel/installer.py
20
+ src/codeintel/logconfig.py
20
21
  src/codeintel/mapper.py
22
+ src/codeintel/metrics.py
21
23
  src/codeintel/onboarding.py
22
24
  src/codeintel/policy.py
23
25
  src/codeintel/provider.py
@@ -36,6 +38,7 @@ tests/test_cache.py
36
38
  tests/test_config.py
37
39
  tests/test_doctor.py
38
40
  tests/test_e2e.py
41
+ tests/test_enterprise.py
39
42
  tests/test_gateway.py
40
43
  tests/test_graph_provider.py
41
44
  tests/test_graph_real.py
@@ -0,0 +1 @@
1
+ __version__ = "0.4.0"
@@ -2,17 +2,29 @@ from __future__ import annotations
2
2
 
3
3
  import hmac
4
4
  import json
5
+ import logging
6
+ import os
7
+ import signal
5
8
  import sys
6
9
  import threading
10
+ import time
7
11
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
8
12
  from urllib.parse import parse_qs, urlparse
9
13
 
14
+ from codeintel import __version__
15
+ from codeintel.logconfig import configure_logging
16
+ from codeintel.metrics import Metrics
10
17
  from codeintel.provider import log_swallowed
11
18
  from codeintel.server import code_doctor_handler, code_query_handler, code_status_handler
12
19
 
13
20
  _MAX_BODY_BYTES = 1_048_576 # 1 MiB
14
21
  _REQUEST_TIMEOUT_S = 60 # per-request socket read timeout — drops an idle/half-open client
15
22
  _MAX_CONCURRENT_REQUESTS = 64 # cap live worker threads so a burst can't exhaust threads/FDs
23
+ _DRAIN_TIMEOUT_S = 15 # on shutdown, wait up to this long for in-flight requests to finish
24
+ _ACCESS_LOG = os.environ.get("CODEINTEL_HTTP_ACCESS_LOG", "").strip().lower() in ("1", "true", "on", "yes")
25
+ _PROM_CONTENT_TYPE = "text/plain; version=0.0.4; charset=utf-8"
26
+
27
+ _logger = logging.getLogger("codeintel")
16
28
 
17
29
 
18
30
  class _Handler(BaseHTTPRequestHandler):
@@ -21,7 +33,35 @@ class _Handler(BaseHTTPRequestHandler):
21
33
  timeout = _REQUEST_TIMEOUT_S
22
34
 
23
35
  def log_message(self, format: str, *args: object) -> None:
24
- pass # suppress default stderr noise
36
+ pass # suppress default stderr noise; opt-in access logging is handled in _observe
37
+
38
+ def handle_one_request(self) -> None:
39
+ self._t0 = time.monotonic()
40
+ m = getattr(self.server, "metrics", None)
41
+ if m is not None:
42
+ m.inc_in_flight()
43
+ try:
44
+ super().handle_one_request()
45
+ finally:
46
+ if m is not None:
47
+ m.dec_in_flight()
48
+
49
+ def _observe(self, status: int) -> None:
50
+ try:
51
+ dur = time.monotonic() - getattr(self, "_t0", time.monotonic())
52
+ except Exception:
53
+ dur = 0.0
54
+ m = getattr(self.server, "metrics", None)
55
+ if m is not None:
56
+ try:
57
+ m.record(self.command or "?", urlparse(self.path).path, status, dur)
58
+ except Exception:
59
+ pass
60
+ if _ACCESS_LOG:
61
+ try:
62
+ _logger.info("%s %s -> %s (%.1fms)", self.command, self.path, status, dur * 1000.0)
63
+ except Exception:
64
+ pass
25
65
 
26
66
  def _send_json(self, status: int, data: dict) -> None:
27
67
  body = json.dumps(data).encode()
@@ -30,6 +70,16 @@ class _Handler(BaseHTTPRequestHandler):
30
70
  self.send_header("Content-Length", str(len(body)))
31
71
  self.end_headers()
32
72
  self.wfile.write(body)
73
+ self._observe(status)
74
+
75
+ def _send_text(self, status: int, text: str, content_type: str) -> None:
76
+ body = text.encode()
77
+ self.send_response(status)
78
+ self.send_header("Content-Type", content_type)
79
+ self.send_header("Content-Length", str(len(body)))
80
+ self.end_headers()
81
+ self.wfile.write(body)
82
+ self._observe(status)
33
83
 
34
84
  def _authorized(self) -> bool:
35
85
  """When the server was started with a token, require ``Authorization: Bearer <token>``
@@ -79,17 +129,44 @@ class _Handler(BaseHTTPRequestHandler):
79
129
  self._send_json(200, result)
80
130
 
81
131
  def do_GET(self) -> None:
132
+ parsed = urlparse(self.path)
133
+ path = parsed.path
134
+
135
+ # Liveness / readiness are UNAUTHENTICATED by convention (kubelet/load balancers don't send
136
+ # tokens) and reveal nothing sensitive — only up/ready.
137
+ if path == "/healthz":
138
+ self._send_json(200, {"status": "ok"})
139
+ return
140
+ if path == "/readyz":
141
+ try:
142
+ from codeintel.server import _get_gateway
143
+ _get_gateway() # builds once (cached); readiness = the gateway is constructible
144
+ self._send_json(200, {"status": "ready"})
145
+ except Exception as exc:
146
+ log_swallowed("readyz", exc)
147
+ self._send_json(503, {"status": "not-ready"})
148
+ return
149
+
150
+ # Everything else is auth-gated when a token is configured (metrics can reveal usage
151
+ # patterns; status reveals engine/index state).
82
152
  if not self._authorized():
83
153
  self._send_json(401, {"error": "unauthorized"})
84
154
  return
85
- parsed = urlparse(self.path)
86
- if parsed.path != "/code/status":
87
- self._send_json(404, {"error": "not-found"})
155
+
156
+ if path == "/metrics":
157
+ m = getattr(self.server, "metrics", None)
158
+ try:
159
+ text = m.render() if m is not None else ""
160
+ except Exception as exc:
161
+ log_swallowed("metrics.render", exc) # never leave the client with no response
162
+ text = ""
163
+ self._send_text(200, text, _PROM_CONTENT_TYPE)
88
164
  return
89
- # Optional ?project_root=... scopes the `indexed` flag to that repo.
90
- project_root = (parse_qs(parsed.query).get("project_root") or [""])[0]
91
- result = code_status_handler({"project_root": project_root})
92
- self._send_json(200, result)
165
+ if path == "/code/status":
166
+ project_root = (parse_qs(parsed.query).get("project_root") or [""])[0]
167
+ self._send_json(200, code_status_handler({"project_root": project_root}))
168
+ return
169
+ self._send_json(404, {"error": "not-found"})
93
170
 
94
171
 
95
172
  class CodeIntelHTTPServer(ThreadingHTTPServer):
@@ -102,6 +179,7 @@ class CodeIntelHTTPServer(ThreadingHTTPServer):
102
179
 
103
180
  def __init__(self, *args, **kwargs) -> None:
104
181
  super().__init__(*args, **kwargs)
182
+ self.metrics = Metrics(version=__version__)
105
183
  # Stdlib ThreadingHTTPServer spawns one thread per connection with no ceiling, so many
106
184
  # slow/half-open clients could exhaust threads/FDs. Bound live workers; past the cap we
107
185
  # refuse fast with 503 rather than spawn an unbounded thread. (For a genuinely hostile
@@ -110,6 +188,10 @@ class CodeIntelHTTPServer(ThreadingHTTPServer):
110
188
 
111
189
  def process_request(self, request, client_address) -> None:
112
190
  if not self._slots.acquire(blocking=False):
191
+ try:
192
+ self.metrics.inc_overload() # surface overload in /metrics (rejected_total)
193
+ except Exception:
194
+ pass
113
195
  try:
114
196
  request.sendall(
115
197
  b"HTTP/1.1 503 Service Unavailable\r\n"
@@ -152,6 +234,10 @@ def _is_loopback(host: str) -> bool:
152
234
  return False # a non-IP hostname is never treated as loopback
153
235
 
154
236
 
237
+ def _no_auth_override() -> bool:
238
+ return os.environ.get("CODEINTEL_ALLOW_NO_AUTH", "").strip().lower() in ("1", "true", "on", "yes")
239
+
240
+
155
241
  def run(
156
242
  host: str = "127.0.0.1",
157
243
  port: int = 8766,
@@ -159,17 +245,50 @@ def run(
159
245
  allow_remote: bool = False,
160
246
  token: str | None = None,
161
247
  ) -> None:
162
- if not _is_loopback(host) and not allow_remote:
163
- print(f"refusing to bind non-loopback host {host!r} without --allow-remote — this would "
164
- f"expose an UNAUTHENTICATED code-intel endpoint (your indexed repo) to the network",
165
- file=sys.stderr)
166
- raise SystemExit(2)
248
+ configure_logging()
249
+ server_token = (token or "").strip() or None
250
+ if not _is_loopback(host):
251
+ if not allow_remote:
252
+ print(f"refusing to bind non-loopback host {host!r} without --allow-remote — this would "
253
+ f"expose a code-intel endpoint (your indexed repo) to the network", file=sys.stderr)
254
+ raise SystemExit(2)
255
+ if not server_token and not _no_auth_override():
256
+ # Fail closed: never serve an UNAUTHENTICATED endpoint on the network by accident (a
257
+ # containerized `serve-http --host 0.0.0.0` with no token stops here unless overridden).
258
+ print(f"refusing to serve on {host}:{port} with NO authentication — set --token or "
259
+ f"CODEINTEL_HTTP_TOKEN (recommended), or set CODEINTEL_ALLOW_NO_AUTH=1 to override "
260
+ f"for a trusted network", file=sys.stderr)
261
+ raise SystemExit(2)
262
+
167
263
  server = CodeIntelHTTPServer((host, port), _Handler)
168
- server.auth_token = (token or "").strip() or None
169
- if not _is_loopback(host) and not server.auth_token:
170
- print(f"WARNING: serving codeintel on {host}:{port} with NO authentication — anyone who can "
171
- f"reach this port can read your indexed repo (set --token to require a bearer token)",
172
- file=sys.stderr)
173
- auth_note = " (bearer-token auth required)" if server.auth_token else ""
264
+ server.auth_token = server_token
265
+ if not _is_loopback(host) and not server_token:
266
+ print(f"WARNING: serving codeintel on {host}:{port} with NO authentication "
267
+ f"(CODEINTEL_ALLOW_NO_AUTH set) — anyone who can reach this port can read your "
268
+ f"indexed repo", file=sys.stderr)
269
+ auth_note = " (bearer-token auth required)" if server_token else ""
174
270
  print(f"Listening on http://{host}:{port}{auth_note}")
175
- server.serve_forever()
271
+
272
+ def _graceful(signum, frame) -> None:
273
+ # shutdown() blocks until serve_forever() returns, so it must run OFF the serving thread —
274
+ # spawn a helper thread to avoid deadlocking the signal handler on the main thread.
275
+ threading.Thread(target=server.shutdown, daemon=True).start()
276
+
277
+ try:
278
+ signal.signal(signal.SIGTERM, _graceful)
279
+ signal.signal(signal.SIGINT, _graceful)
280
+ except (ValueError, OSError):
281
+ pass # not the main thread — signal handlers unavailable; rely on the caller to stop us
282
+
283
+ try:
284
+ server.serve_forever()
285
+ finally:
286
+ # Drain: serve_forever() has stopped accepting new connections; wait (bounded) for in-flight
287
+ # requests to finish before closing. Worker threads are daemons, so server_close() will NOT
288
+ # join them — this loop is what makes the shutdown actually graceful (a rolling restart
289
+ # doesn't cut a live response mid-flight).
290
+ deadline = time.monotonic() + _DRAIN_TIMEOUT_S
291
+ while server.metrics.in_flight() > 0 and time.monotonic() < deadline:
292
+ time.sleep(0.05)
293
+ server.server_close()
294
+ print("codeintel http server stopped", file=sys.stderr)
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ import os
6
+ import sys
7
+
8
+
9
+ class _JsonFormatter(logging.Formatter):
10
+ """Minimal structured formatter for log aggregators (ELK / Splunk / Datadog). One JSON object
11
+ per line; no external dependency."""
12
+
13
+ def format(self, record: logging.LogRecord) -> str:
14
+ payload = {
15
+ "ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"),
16
+ "level": record.levelname,
17
+ "logger": record.name,
18
+ "msg": record.getMessage(),
19
+ }
20
+ if record.exc_info:
21
+ payload["exc"] = self.formatException(record.exc_info)
22
+ return json.dumps(payload, ensure_ascii=False)
23
+
24
+
25
+ _configured = False
26
+
27
+
28
+ def configure_logging() -> None:
29
+ """Configure the ``codeintel`` logger for a long-running server process. Idempotent, and
30
+ scoped to the ``codeintel`` logger only — importing codeintel as a library never hijacks the
31
+ root logger. Honors:
32
+
33
+ * ``CODEINTEL_LOG_LEVEL`` (default ``WARNING``) — DEBUG | INFO | WARNING | ERROR
34
+ * ``CODEINTEL_LOG_FORMAT`` (default plain) — set to ``json`` for structured output
35
+ """
36
+ global _configured
37
+ if _configured:
38
+ return
39
+ level_name = os.environ.get("CODEINTEL_LOG_LEVEL", "WARNING").strip().upper()
40
+ level = getattr(logging, level_name, logging.WARNING)
41
+ if not isinstance(level, int):
42
+ level = logging.WARNING
43
+
44
+ handler = logging.StreamHandler(sys.stderr)
45
+ if os.environ.get("CODEINTEL_LOG_FORMAT", "").strip().lower() == "json":
46
+ handler.setFormatter(_JsonFormatter())
47
+ else:
48
+ handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))
49
+
50
+ logger = logging.getLogger("codeintel")
51
+ logger.setLevel(level)
52
+ logger.handlers[:] = [handler]
53
+ logger.propagate = False
54
+ _configured = True
@@ -0,0 +1,87 @@
1
+ from __future__ import annotations
2
+
3
+ import threading
4
+ from typing import Dict, Tuple
5
+
6
+ # Path labels are restricted to known routes so a caller cannot explode Prometheus label
7
+ # cardinality (a memory-exhaustion vector) by hammering random URLs — everything else folds
8
+ # into path="other". Method and status are already low-cardinality; version is our own string.
9
+ _KNOWN_PATHS = frozenset(
10
+ {"/code/query", "/code/doctor", "/code/status", "/healthz", "/readyz", "/metrics"}
11
+ )
12
+
13
+
14
+ class Metrics:
15
+ """Thread-safe, dependency-free request metrics rendered in Prometheus text exposition
16
+ format. Shared across the threaded HTTP server's worker threads via one instance on the
17
+ server object; every mutation is lock-guarded."""
18
+
19
+ def __init__(self, version: str = "") -> None:
20
+ self._lock = threading.Lock()
21
+ self._version = version
22
+ self._count: Dict[Tuple[str, str, int], int] = {} # (method, path, status) -> n
23
+ self._lat_sum: Dict[str, float] = {} # path -> total seconds
24
+ self._lat_count: Dict[str, int] = {} # path -> n
25
+ self._in_flight = 0
26
+ self._overload = 0 # requests refused at the concurrency cap
27
+
28
+ @staticmethod
29
+ def _norm(path: str) -> str:
30
+ return path if path in _KNOWN_PATHS else "other"
31
+
32
+ def inc_in_flight(self) -> None:
33
+ with self._lock:
34
+ self._in_flight += 1
35
+
36
+ def dec_in_flight(self) -> None:
37
+ with self._lock:
38
+ self._in_flight = max(0, self._in_flight - 1)
39
+
40
+ def in_flight(self) -> int:
41
+ with self._lock:
42
+ return self._in_flight
43
+
44
+ def inc_overload(self) -> None:
45
+ with self._lock:
46
+ self._overload += 1
47
+
48
+ def record(self, method: str, path: str, status: int, duration_s: float) -> None:
49
+ p = self._norm(str(path))
50
+ key = (str(method or "?"), p, int(status))
51
+ with self._lock:
52
+ self._count[key] = self._count.get(key, 0) + 1
53
+ self._lat_sum[p] = self._lat_sum.get(p, 0.0) + max(0.0, float(duration_s))
54
+ self._lat_count[p] = self._lat_count.get(p, 0) + 1
55
+
56
+ def render(self) -> str:
57
+ with self._lock:
58
+ count = dict(self._count)
59
+ lat_sum = dict(self._lat_sum)
60
+ lat_count = dict(self._lat_count)
61
+ in_flight = self._in_flight
62
+ overload = self._overload
63
+ version = self._version
64
+
65
+ out: list[str] = []
66
+ out.append("# HELP codeintel_requests_total Total HTTP requests handled.")
67
+ out.append("# TYPE codeintel_requests_total counter")
68
+ for (method, path, status), n in sorted(count.items()):
69
+ out.append(
70
+ f'codeintel_requests_total{{method="{method}",path="{path}",status="{status}"}} {n}'
71
+ )
72
+ out.append("# HELP codeintel_request_duration_seconds Request handling duration.")
73
+ out.append("# TYPE codeintel_request_duration_seconds summary")
74
+ for path in sorted(lat_count):
75
+ out.append(f'codeintel_request_duration_seconds_sum{{path="{path}"}} {lat_sum[path]:.6f}')
76
+ out.append(f'codeintel_request_duration_seconds_count{{path="{path}"}} {lat_count[path]}')
77
+ out.append("# HELP codeintel_requests_in_flight Requests currently being handled.")
78
+ out.append("# TYPE codeintel_requests_in_flight gauge")
79
+ out.append(f"codeintel_requests_in_flight {in_flight}")
80
+ out.append("# HELP codeintel_requests_rejected_total Requests refused at the concurrency cap.")
81
+ out.append("# TYPE codeintel_requests_rejected_total counter")
82
+ out.append(f"codeintel_requests_rejected_total {overload}")
83
+ if version:
84
+ out.append("# HELP codeintel_build_info Build metadata (constant 1).")
85
+ out.append("# TYPE codeintel_build_info gauge")
86
+ out.append(f'codeintel_build_info{{version="{version}"}} 1')
87
+ return "\n".join(out) + "\n"
@@ -192,6 +192,8 @@ def code_map_handler(args: dict) -> dict:
192
192
 
193
193
 
194
194
  def run() -> None:
195
+ from codeintel.logconfig import configure_logging
196
+ configure_logging() # logs to stderr; stdout is the MCP protocol channel
195
197
  mcp = MCPServer(name="codeintel")
196
198
 
197
199
  async def _code_query(
@@ -0,0 +1,145 @@
1
+ """Enterprise surface (0.4.0): health/readiness probes, Prometheus /metrics, and the metrics
2
+ registry. Real live server on an ephemeral port; no mocks."""
3
+ from __future__ import annotations
4
+
5
+ import http.client
6
+ import json
7
+ import threading
8
+
9
+ from codeintel.http_server import CodeIntelHTTPServer, _Handler
10
+ from codeintel.metrics import Metrics
11
+
12
+
13
+ def _serve(token=None):
14
+ s = CodeIntelHTTPServer(("127.0.0.1", 0), _Handler)
15
+ s.auth_token = token
16
+ threading.Thread(target=s.serve_forever, daemon=True).start()
17
+ return s, s.server_address[1]
18
+
19
+
20
+ def _get(port, path, headers=None):
21
+ c = http.client.HTTPConnection("127.0.0.1", port, timeout=5)
22
+ c.request("GET", path, headers=headers or {})
23
+ r = c.getresponse()
24
+ body = r.read().decode()
25
+ c.close()
26
+ return r.status, body
27
+
28
+
29
+ # --------------------------------------------------------------------------- probes
30
+
31
+ def test_healthz_ok_and_unauthenticated_even_with_token():
32
+ s, port = _serve(token="secret") # health must not require the token (kubelet won't send one)
33
+ try:
34
+ status, body = _get(port, "/healthz")
35
+ assert status == 200 and json.loads(body)["status"] == "ok"
36
+ finally:
37
+ s.shutdown()
38
+
39
+
40
+ def test_readyz_reports_ready():
41
+ s, port = _serve()
42
+ try:
43
+ status, body = _get(port, "/readyz")
44
+ assert status == 200 and json.loads(body)["status"] == "ready"
45
+ finally:
46
+ s.shutdown()
47
+
48
+
49
+ def test_readyz_unauthenticated_even_with_token():
50
+ s, port = _serve(token="secret")
51
+ try:
52
+ assert _get(port, "/readyz")[0] == 200
53
+ finally:
54
+ s.shutdown()
55
+
56
+
57
+ # --------------------------------------------------------------------------- /metrics endpoint
58
+
59
+ def test_metrics_exposition_format():
60
+ s, port = _serve()
61
+ try:
62
+ _get(port, "/healthz") # generate a recorded request first
63
+ status, body = _get(port, "/metrics")
64
+ assert status == 200
65
+ assert "# TYPE codeintel_requests_total counter" in body
66
+ assert "codeintel_build_info" in body
67
+ assert 'path="/healthz"' in body # the earlier request was counted
68
+ finally:
69
+ s.shutdown()
70
+
71
+
72
+ def test_metrics_is_auth_gated_when_token_set():
73
+ s, port = _serve(token="secret")
74
+ try:
75
+ assert _get(port, "/metrics")[0] == 401
76
+ assert _get(port, "/metrics", {"Authorization": "Bearer secret"})[0] == 200
77
+ finally:
78
+ s.shutdown()
79
+
80
+
81
+ # --------------------------------------------------------------------------- metrics registry
82
+
83
+ def test_metrics_registry_counts_and_caps_cardinality():
84
+ m = Metrics(version="9.9.9")
85
+ m.record("POST", "/code/query", 200, 0.05)
86
+ m.record("POST", "/code/query", 200, 0.05)
87
+ m.record("GET", "/some/random/unknown/path", 404, 0.01) # unknown → folded to path="other"
88
+ out = m.render()
89
+ assert 'codeintel_requests_total{method="POST",path="/code/query",status="200"} 2' in out
90
+ assert 'path="other"' in out
91
+ assert "/some/random/unknown/path" not in out # no unbounded label cardinality
92
+ assert 'codeintel_build_info{version="9.9.9"} 1' in out
93
+ assert "codeintel_request_duration_seconds_count" in out
94
+
95
+
96
+ def test_metrics_in_flight_gauge_never_negative():
97
+ m = Metrics()
98
+ m.dec_in_flight() # underflow guard
99
+ assert "codeintel_requests_in_flight 0" in m.render()
100
+
101
+
102
+ def test_metrics_exposes_rejected_and_in_flight():
103
+ m = Metrics()
104
+ m.inc_overload()
105
+ m.inc_overload()
106
+ m.inc_in_flight()
107
+ out = m.render()
108
+ assert "codeintel_requests_rejected_total 2" in out # capacity refusals are now visible
109
+ assert "codeintel_requests_in_flight 1" in out
110
+ assert m.in_flight() == 1 # accessor used by the shutdown drain
111
+
112
+
113
+ # --------------------------------------------------------------------------- secure-by-default bind
114
+
115
+ def test_run_refuses_non_loopback_without_auth(monkeypatch):
116
+ import pytest
117
+
118
+ from codeintel.http_server import run
119
+ monkeypatch.delenv("CODEINTEL_ALLOW_NO_AUTH", raising=False)
120
+ # non-loopback + --allow-remote but NO token and no override → must fail closed, not bind.
121
+ with pytest.raises(SystemExit):
122
+ run(host="0.0.0.0", port=0, allow_remote=True, token=None)
123
+
124
+
125
+ def test_run_refuses_non_loopback_without_allow_remote():
126
+ import pytest
127
+
128
+ from codeintel.http_server import run
129
+ with pytest.raises(SystemExit):
130
+ run(host="0.0.0.0", port=0, allow_remote=False, token="tok")
131
+
132
+
133
+ # --------------------------------------------------------------------------- structured logging
134
+
135
+ def test_json_log_formatter_emits_valid_json():
136
+ import json
137
+ import logging
138
+
139
+ from codeintel.logconfig import _JsonFormatter
140
+
141
+ rec = logging.LogRecord("codeintel", logging.WARNING, __file__, 1, "hello %s", ("world",), None)
142
+ parsed = json.loads(_JsonFormatter().format(rec))
143
+ assert parsed["level"] == "WARNING"
144
+ assert parsed["msg"] == "hello world"
145
+ assert parsed["logger"] == "codeintel"
@@ -1 +0,0 @@
1
- __version__ = "0.3.0"
File without changes
File without changes
File without changes
File without changes