devlogs 2.3.0__tar.gz → 2.3.1__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.
- {devlogs-2.3.0/src/devlogs.egg-info → devlogs-2.3.1}/PKG-INFO +3 -1
- {devlogs-2.3.0 → devlogs-2.3.1}/pyproject.toml +4 -1
- {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/_version_static.py +1 -1
- devlogs-2.3.1/src/devlogs/proxy/__init__.py +0 -0
- devlogs-2.3.1/src/devlogs/proxy/server.py +163 -0
- {devlogs-2.3.0 → devlogs-2.3.1/src/devlogs.egg-info}/PKG-INFO +3 -1
- {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs.egg-info/SOURCES.txt +3 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs.egg-info/requires.txt +3 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/tests/test_context.py +3 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/tests/test_handler.py +1 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/tests/test_indexing.py +4 -0
- devlogs-2.3.1/tests/test_proxy_server.py +265 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/LICENSE +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/MANIFEST.in +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/README.md +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/setup.cfg +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/__init__.py +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/__main__.py +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/build_info.py +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/cli.py +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/collector/__init__.py +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/collector/auth.py +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/collector/cli.py +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/collector/errors.py +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/collector/forwarder.py +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/collector/ingestor.py +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/collector/loki_plugin.py +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/collector/plugins.py +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/collector/schema.py +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/collector/server.py +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/config.py +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/context.py +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/demo.py +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/devlogs_client.py +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/formatting.py +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/handler.py +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/jenkins/__init__.py +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/jenkins/cli.py +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/jenkins/core.py +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/levels.py +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/loki/__init__.py +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/loki/queries.py +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/mcp/__init__.py +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/mcp/server.py +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/opensearch/__init__.py +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/opensearch/client.py +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/opensearch/indexing.py +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/opensearch/mappings.py +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/opensearch/queries.py +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/retention.py +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/scrub.py +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/time_utils.py +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/version.py +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/web/__init__.py +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/web/server.py +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/web/static/devlogs.css +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/web/static/devlogs.js +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/web/static/index.html +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/wrapper.py +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs.egg-info/dependency_links.txt +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs.egg-info/entry_points.txt +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs.egg-info/top_level.txt +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/tests/test_build_info.py +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/tests/test_cli.py +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/tests/test_collector_auth.py +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/tests/test_collector_config.py +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/tests/test_collector_plugins.py +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/tests/test_collector_schema.py +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/tests/test_collector_server.py +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/tests/test_config.py +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/tests/test_devlogs_client.py +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/tests/test_formatting.py +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/tests/test_levels.py +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/tests/test_mappings.py +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/tests/test_mcp_server.py +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/tests/test_opensearch_client.py +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/tests/test_opensearch_queries.py +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/tests/test_retention.py +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/tests/test_scrub.py +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/tests/test_time_utils.py +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/tests/test_url_parsing.py +0 -0
- {devlogs-2.3.0 → devlogs-2.3.1}/tests/test_web.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devlogs
|
|
3
|
-
Version: 2.3.
|
|
3
|
+
Version: 2.3.1
|
|
4
4
|
Summary: Developer-focused logging library for Python with OpenSearch integration.
|
|
5
5
|
Author-email: Dan Driscoll <dan@thedandriscoll.org>
|
|
6
6
|
License: MIT License
|
|
@@ -39,6 +39,8 @@ Provides-Extra: dev
|
|
|
39
39
|
Requires-Dist: httpx>=0.26.0; extra == "dev"
|
|
40
40
|
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
41
41
|
Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
|
|
42
|
+
Provides-Extra: proxy
|
|
43
|
+
Requires-Dist: aiohttp>=3.9.0; extra == "proxy"
|
|
42
44
|
Dynamic: license-file
|
|
43
45
|
|
|
44
46
|
# devlogs
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "devlogs"
|
|
7
|
-
version = "2.3.
|
|
7
|
+
version = "2.3.1"
|
|
8
8
|
description = "Developer-focused logging library for Python with OpenSearch integration."
|
|
9
9
|
requires-python = ">=3.11"
|
|
10
10
|
readme = "README.md"
|
|
@@ -26,6 +26,9 @@ dev = [
|
|
|
26
26
|
"pytest>=8.0.0",
|
|
27
27
|
"pytest-asyncio>=0.23.0",
|
|
28
28
|
]
|
|
29
|
+
proxy = [
|
|
30
|
+
"aiohttp>=3.9.0",
|
|
31
|
+
]
|
|
29
32
|
|
|
30
33
|
[project.scripts]
|
|
31
34
|
devlogs = "devlogs.wrapper:main"
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
# AUTO-GENERATED at build time — do not edit or commit
|
|
2
|
-
__version__ = "2.3.
|
|
2
|
+
__version__ = "2.3.1"
|
|
File without changes
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# Devlogs proxy server
|
|
2
|
+
#
|
|
3
|
+
# Routes external traffic to internal services:
|
|
4
|
+
# POST /ingest/* → Collector (token-in-URL auth, unchanged)
|
|
5
|
+
# GET /query/* → Loki :3100 (Bearer token auth)
|
|
6
|
+
# GET /grafana/* → Grafana :3000 (Bearer token auth)
|
|
7
|
+
#
|
|
8
|
+
# Environment variables:
|
|
9
|
+
# COLLECTOR_URL — Collector base URL (default: http://localhost:8081)
|
|
10
|
+
# LOKI_URL — Loki base URL (default: http://localhost:3100)
|
|
11
|
+
# GRAFANA_URL — Grafana base URL (default: http://localhost:3000)
|
|
12
|
+
# LOKI_ADMIN_TOKEN — Bearer token for /query and /grafana routes
|
|
13
|
+
# PORT — Port to listen on (default: 8080)
|
|
14
|
+
#
|
|
15
|
+
# Run:
|
|
16
|
+
# python -m devlogs.proxy.server
|
|
17
|
+
|
|
18
|
+
import hmac
|
|
19
|
+
import logging
|
|
20
|
+
import os
|
|
21
|
+
import posixpath
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
from aiohttp import web, ClientSession, ClientTimeout
|
|
25
|
+
except ImportError as e:
|
|
26
|
+
raise ImportError("aiohttp is required: pip install devlogs[proxy]") from e
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger("devlogs.proxy")
|
|
29
|
+
|
|
30
|
+
COLLECTOR_URL = os.environ.get("COLLECTOR_URL", "http://localhost:8081").rstrip("/")
|
|
31
|
+
LOKI_URL = os.environ.get("LOKI_URL", "http://localhost:3100").rstrip("/")
|
|
32
|
+
GRAFANA_URL = os.environ.get("GRAFANA_URL", "http://localhost:3000").rstrip("/")
|
|
33
|
+
LOKI_ADMIN_TOKEN = os.environ.get("LOKI_ADMIN_TOKEN", "")
|
|
34
|
+
PORT = int(os.environ.get("PORT", "8080"))
|
|
35
|
+
|
|
36
|
+
_SKIP_HEADERS = frozenset({
|
|
37
|
+
"host", "content-length", "transfer-encoding",
|
|
38
|
+
"x-forwarded-for", "x-forwarded-host", "x-forwarded-proto",
|
|
39
|
+
"forwarded", "x-real-ip", "x-original-url", "x-rewrite-url",
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _proxy_headers(request: web.Request) -> dict:
|
|
44
|
+
return {k: v for k, v in request.headers.items() if k.lower() not in _SKIP_HEADERS}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _build_target(base: str, strip_prefix: str, request: web.Request) -> str:
|
|
48
|
+
path = request.path.removeprefix(strip_prefix) or "/"
|
|
49
|
+
path = posixpath.normpath(path)
|
|
50
|
+
if not path.startswith("/"):
|
|
51
|
+
path = "/" + path
|
|
52
|
+
url = f"{base}{path}"
|
|
53
|
+
if request.query_string:
|
|
54
|
+
url += f"?{request.query_string}"
|
|
55
|
+
return url
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _check_admin_token(request: web.Request) -> bool:
|
|
59
|
+
if not LOKI_ADMIN_TOKEN:
|
|
60
|
+
return False
|
|
61
|
+
return hmac.compare_digest(
|
|
62
|
+
request.headers.get("Authorization", ""),
|
|
63
|
+
f"Bearer {LOKI_ADMIN_TOKEN}",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
async def handle_ingest(request: web.Request) -> web.Response:
|
|
68
|
+
"""Forward /ingest/* to Collector. Token in URL is passed through; Collector validates it."""
|
|
69
|
+
target = _build_target(COLLECTOR_URL, "/ingest", request)
|
|
70
|
+
body = await request.read()
|
|
71
|
+
|
|
72
|
+
async with request.app["session"].request(
|
|
73
|
+
method=request.method,
|
|
74
|
+
url=target,
|
|
75
|
+
headers=_proxy_headers(request),
|
|
76
|
+
data=body,
|
|
77
|
+
allow_redirects=False,
|
|
78
|
+
) as resp:
|
|
79
|
+
resp_body = await resp.read()
|
|
80
|
+
log_url = target.split("?")[0]
|
|
81
|
+
logger.info("%s /ingest → %s %d", request.method, log_url, resp.status)
|
|
82
|
+
content_type = resp.content_type or "application/octet-stream"
|
|
83
|
+
return web.Response(status=resp.status, body=resp_body, content_type=content_type)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
async def handle_query(request: web.Request) -> web.Response:
|
|
87
|
+
"""Validate Bearer token, strip /query prefix, forward to Loki."""
|
|
88
|
+
if not _check_admin_token(request):
|
|
89
|
+
return web.Response(status=401, text="Unauthorized")
|
|
90
|
+
|
|
91
|
+
target = _build_target(LOKI_URL, "/query", request)
|
|
92
|
+
body = await request.read()
|
|
93
|
+
|
|
94
|
+
async with request.app["session"].request(
|
|
95
|
+
method=request.method,
|
|
96
|
+
url=target,
|
|
97
|
+
headers=_proxy_headers(request),
|
|
98
|
+
data=body,
|
|
99
|
+
allow_redirects=False,
|
|
100
|
+
) as resp:
|
|
101
|
+
resp_body = await resp.read()
|
|
102
|
+
logger.info("%s /query → %s %d", request.method, target, resp.status)
|
|
103
|
+
content_type = resp.content_type or "application/octet-stream"
|
|
104
|
+
return web.Response(status=resp.status, body=resp_body, content_type=content_type)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
async def handle_grafana(request: web.Request) -> web.Response:
|
|
108
|
+
"""Validate Bearer token, strip /grafana prefix, forward to Grafana."""
|
|
109
|
+
if not _check_admin_token(request):
|
|
110
|
+
return web.Response(status=401, text="Unauthorized")
|
|
111
|
+
|
|
112
|
+
target = _build_target(GRAFANA_URL, "/grafana", request)
|
|
113
|
+
# Strip Authorization so Grafana uses its own session mechanism
|
|
114
|
+
headers = {k: v for k, v in _proxy_headers(request).items() if k.lower() != "authorization"}
|
|
115
|
+
body = await request.read()
|
|
116
|
+
|
|
117
|
+
async with request.app["session"].request(
|
|
118
|
+
method=request.method,
|
|
119
|
+
url=target,
|
|
120
|
+
headers=headers,
|
|
121
|
+
data=body,
|
|
122
|
+
allow_redirects=False,
|
|
123
|
+
) as resp:
|
|
124
|
+
resp_body = await resp.read()
|
|
125
|
+
logger.info("%s /grafana → %s %d", request.method, target, resp.status)
|
|
126
|
+
content_type = resp.content_type or "application/octet-stream"
|
|
127
|
+
return web.Response(status=resp.status, body=resp_body, content_type=content_type)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
async def on_startup(app: web.Application) -> None:
|
|
131
|
+
timeout = ClientTimeout(total=30)
|
|
132
|
+
app["session"] = ClientSession(timeout=timeout)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
async def on_cleanup(app: web.Application) -> None:
|
|
136
|
+
await app["session"].close()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def create_app() -> web.Application:
|
|
140
|
+
app = web.Application(client_max_size=1024 * 1024) # 1 MB
|
|
141
|
+
app.on_startup.append(on_startup)
|
|
142
|
+
app.on_cleanup.append(on_cleanup)
|
|
143
|
+
|
|
144
|
+
app.router.add_route("*", "/ingest", handle_ingest)
|
|
145
|
+
app.router.add_route("*", "/ingest/{path_info:.*}", handle_ingest)
|
|
146
|
+
app.router.add_route("*", "/query", handle_query)
|
|
147
|
+
app.router.add_route("*", "/query/{path_info:.*}", handle_query)
|
|
148
|
+
app.router.add_route("*", "/grafana", handle_grafana)
|
|
149
|
+
app.router.add_route("*", "/grafana/{path_info:.*}", handle_grafana)
|
|
150
|
+
|
|
151
|
+
return app
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
if __name__ == "__main__":
|
|
155
|
+
logging.basicConfig(
|
|
156
|
+
level=logging.INFO,
|
|
157
|
+
format="%(asctime)s %(name)s %(levelname)s %(message)s",
|
|
158
|
+
)
|
|
159
|
+
if not LOKI_ADMIN_TOKEN:
|
|
160
|
+
logger.warning("LOKI_ADMIN_TOKEN is not set — /query and /grafana routes will reject all requests")
|
|
161
|
+
|
|
162
|
+
app = create_app()
|
|
163
|
+
web.run_app(app, host="0.0.0.0", port=PORT)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devlogs
|
|
3
|
-
Version: 2.3.
|
|
3
|
+
Version: 2.3.1
|
|
4
4
|
Summary: Developer-focused logging library for Python with OpenSearch integration.
|
|
5
5
|
Author-email: Dan Driscoll <dan@thedandriscoll.org>
|
|
6
6
|
License: MIT License
|
|
@@ -39,6 +39,8 @@ Provides-Extra: dev
|
|
|
39
39
|
Requires-Dist: httpx>=0.26.0; extra == "dev"
|
|
40
40
|
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
41
41
|
Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
|
|
42
|
+
Provides-Extra: proxy
|
|
43
|
+
Requires-Dist: aiohttp>=3.9.0; extra == "proxy"
|
|
42
44
|
Dynamic: license-file
|
|
43
45
|
|
|
44
46
|
# devlogs
|
|
@@ -47,6 +47,8 @@ src/devlogs/opensearch/client.py
|
|
|
47
47
|
src/devlogs/opensearch/indexing.py
|
|
48
48
|
src/devlogs/opensearch/mappings.py
|
|
49
49
|
src/devlogs/opensearch/queries.py
|
|
50
|
+
src/devlogs/proxy/__init__.py
|
|
51
|
+
src/devlogs/proxy/server.py
|
|
50
52
|
src/devlogs/web/__init__.py
|
|
51
53
|
src/devlogs/web/server.py
|
|
52
54
|
src/devlogs/web/static/devlogs.css
|
|
@@ -70,6 +72,7 @@ tests/test_mappings.py
|
|
|
70
72
|
tests/test_mcp_server.py
|
|
71
73
|
tests/test_opensearch_client.py
|
|
72
74
|
tests/test_opensearch_queries.py
|
|
75
|
+
tests/test_proxy_server.py
|
|
73
76
|
tests/test_retention.py
|
|
74
77
|
tests/test_scrub.py
|
|
75
78
|
tests/test_time_utils.py
|
|
@@ -24,6 +24,7 @@ def _get_logger(name, handler):
|
|
|
24
24
|
return logger
|
|
25
25
|
|
|
26
26
|
|
|
27
|
+
@pytest.mark.integration
|
|
27
28
|
def test_diagnostics_handler_uses_context(opensearch_client, test_index):
|
|
28
29
|
handler = DiagnosticsHandler(opensearch_client=opensearch_client, index_name=test_index)
|
|
29
30
|
logger = _get_logger("ctx-context", handler)
|
|
@@ -40,6 +41,7 @@ def test_diagnostics_handler_uses_context(opensearch_client, test_index):
|
|
|
40
41
|
assert "hello" in (doc.get("message") or "")
|
|
41
42
|
|
|
42
43
|
|
|
44
|
+
@pytest.mark.integration
|
|
43
45
|
def test_diagnostics_handler_nested_contexts(opensearch_client, test_index):
|
|
44
46
|
handler = DiagnosticsHandler(opensearch_client=opensearch_client, index_name=test_index)
|
|
45
47
|
logger = _get_logger("ctx-nested", handler)
|
|
@@ -67,6 +69,7 @@ def test_diagnostics_handler_nested_contexts(opensearch_client, test_index):
|
|
|
67
69
|
assert "inner" in (inner_entry.get("message") or "")
|
|
68
70
|
|
|
69
71
|
|
|
72
|
+
@pytest.mark.integration
|
|
70
73
|
def test_diagnostics_handler_extra_overrides_context(opensearch_client, test_index):
|
|
71
74
|
handler = DiagnosticsHandler(opensearch_client=opensearch_client, index_name=test_index)
|
|
72
75
|
logger = _get_logger("ctx-extra", handler)
|
|
@@ -15,6 +15,7 @@ from devlogs.handler import (
|
|
|
15
15
|
)
|
|
16
16
|
|
|
17
17
|
|
|
18
|
+
@pytest.mark.integration
|
|
18
19
|
def test_handler_emits_and_indexes(opensearch_client, test_index):
|
|
19
20
|
handler = DiagnosticsHandler(opensearch_client=opensearch_client, index_name=test_index)
|
|
20
21
|
logger = logging.getLogger("devlogs-test")
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
import time
|
|
3
3
|
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
4
6
|
from devlogs.context import operation
|
|
5
7
|
from devlogs.handler import DiagnosticsHandler, OpenSearchHandler
|
|
6
8
|
from devlogs.opensearch.queries import normalize_log_entries, search_logs, tail_logs
|
|
7
9
|
|
|
10
|
+
pytestmark = pytest.mark.integration
|
|
11
|
+
|
|
8
12
|
|
|
9
13
|
def _get_logger(name, handler):
|
|
10
14
|
logger = logging.getLogger(name)
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
# Tests for the devlogs proxy server
|
|
2
|
+
#
|
|
3
|
+
# Run: pytest tests/test_proxy_server.py -v
|
|
4
|
+
# Requires: pip install devlogs[proxy] pytest-aiohttp
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
aiohttp = pytest.importorskip("aiohttp", reason="requires devlogs[proxy]")
|
|
9
|
+
pytest.importorskip("pytest_asyncio", reason="requires pytest-asyncio")
|
|
10
|
+
|
|
11
|
+
import pytest_asyncio
|
|
12
|
+
from unittest.mock import AsyncMock, MagicMock
|
|
13
|
+
from aiohttp import web
|
|
14
|
+
|
|
15
|
+
import devlogs.proxy.server as proxy_mod
|
|
16
|
+
|
|
17
|
+
pytestmark = pytest.mark.asyncio
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
# Helpers
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
def make_mock_response(status: int, body: bytes = b"ok", content_type: str = "application/json"):
|
|
25
|
+
resp = AsyncMock()
|
|
26
|
+
resp.status = status
|
|
27
|
+
resp.content_type = content_type
|
|
28
|
+
resp.read = AsyncMock(return_value=body)
|
|
29
|
+
return resp
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def make_mock_session(response):
|
|
33
|
+
"""Return a mock ClientSession whose .request() is an async context manager."""
|
|
34
|
+
cm = MagicMock()
|
|
35
|
+
cm.__aenter__ = AsyncMock(return_value=response)
|
|
36
|
+
cm.__aexit__ = AsyncMock(return_value=False)
|
|
37
|
+
session = MagicMock()
|
|
38
|
+
session.request = MagicMock(return_value=cm)
|
|
39
|
+
session.close = AsyncMock()
|
|
40
|
+
return session
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@pytest.fixture
|
|
44
|
+
def admin_token(monkeypatch):
|
|
45
|
+
monkeypatch.setattr(proxy_mod, "LOKI_ADMIN_TOKEN", "test-secret")
|
|
46
|
+
return "test-secret"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@pytest.fixture
|
|
50
|
+
def collector_url(monkeypatch):
|
|
51
|
+
monkeypatch.setattr(proxy_mod, "COLLECTOR_URL", "http://collector:8081")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@pytest.fixture
|
|
55
|
+
def loki_url(monkeypatch):
|
|
56
|
+
monkeypatch.setattr(proxy_mod, "LOKI_URL", "http://loki:3100")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@pytest.fixture
|
|
60
|
+
def grafana_url(monkeypatch):
|
|
61
|
+
monkeypatch.setattr(proxy_mod, "GRAFANA_URL", "http://grafana:3000")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@pytest_asyncio.fixture
|
|
65
|
+
async def client(aiohttp_client, admin_token, collector_url, loki_url, grafana_url):
|
|
66
|
+
app = proxy_mod.create_app()
|
|
67
|
+
return await aiohttp_client(app)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
# /ingest routing
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
class TestIngestRoute:
|
|
75
|
+
async def test_forwards_post_to_collector(self, client):
|
|
76
|
+
mock_resp = make_mock_response(202, b'{"status":"accepted"}')
|
|
77
|
+
client.app["session"] = make_mock_session(mock_resp)
|
|
78
|
+
|
|
79
|
+
resp = await client.post(
|
|
80
|
+
"/ingest",
|
|
81
|
+
data=b'{"application":"test","component":"c","message":"hi","level":"info"}',
|
|
82
|
+
headers={"Content-Type": "application/json"},
|
|
83
|
+
)
|
|
84
|
+
assert resp.status == 202
|
|
85
|
+
|
|
86
|
+
async def test_strips_ingest_prefix_when_forwarding(self, client):
|
|
87
|
+
mock_resp = make_mock_response(202)
|
|
88
|
+
session = make_mock_session(mock_resp)
|
|
89
|
+
client.app["session"] = session
|
|
90
|
+
|
|
91
|
+
await client.post("/ingest", data=b"{}")
|
|
92
|
+
call_kwargs = session.request.call_args
|
|
93
|
+
forwarded_url = call_kwargs[1]["url"] if call_kwargs[1] else call_kwargs[0][1]
|
|
94
|
+
assert forwarded_url.startswith("http://collector:8081")
|
|
95
|
+
assert "/ingest" not in forwarded_url
|
|
96
|
+
|
|
97
|
+
async def test_preserves_query_string(self, client):
|
|
98
|
+
mock_resp = make_mock_response(202)
|
|
99
|
+
session = make_mock_session(mock_resp)
|
|
100
|
+
client.app["session"] = session
|
|
101
|
+
|
|
102
|
+
await client.post("/ingest?token=abc123", data=b"{}")
|
|
103
|
+
call_kwargs = session.request.call_args
|
|
104
|
+
forwarded_url = call_kwargs[1]["url"] if call_kwargs[1] else call_kwargs[0][1]
|
|
105
|
+
assert "token=abc123" in forwarded_url
|
|
106
|
+
|
|
107
|
+
async def test_no_auth_required(self, client):
|
|
108
|
+
"""Write side has no proxy-level auth — Collector handles it."""
|
|
109
|
+
mock_resp = make_mock_response(202)
|
|
110
|
+
client.app["session"] = make_mock_session(mock_resp)
|
|
111
|
+
|
|
112
|
+
resp = await client.post("/ingest", data=b"{}")
|
|
113
|
+
# Should not be rejected by the proxy (202 or whatever the collector returns)
|
|
114
|
+
assert resp.status != 401
|
|
115
|
+
|
|
116
|
+
async def test_forwards_collector_error_status(self, client):
|
|
117
|
+
mock_resp = make_mock_response(401, b'{"code":"UNAUTHORIZED"}')
|
|
118
|
+
client.app["session"] = make_mock_session(mock_resp)
|
|
119
|
+
|
|
120
|
+
resp = await client.post("/ingest", data=b"{}")
|
|
121
|
+
assert resp.status == 401
|
|
122
|
+
|
|
123
|
+
async def test_nested_path_forwarded(self, client):
|
|
124
|
+
mock_resp = make_mock_response(202)
|
|
125
|
+
session = make_mock_session(mock_resp)
|
|
126
|
+
client.app["session"] = session
|
|
127
|
+
|
|
128
|
+
await client.post("/ingest/some/nested/path?token=abc", data=b"{}")
|
|
129
|
+
call_kwargs = session.request.call_args
|
|
130
|
+
forwarded_url = call_kwargs[1]["url"] if call_kwargs[1] else call_kwargs[0][1]
|
|
131
|
+
assert "some/nested/path" in forwarded_url
|
|
132
|
+
assert "token=abc" in forwarded_url
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ---------------------------------------------------------------------------
|
|
136
|
+
# /query routing
|
|
137
|
+
# ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
class TestQueryRoute:
|
|
140
|
+
async def test_rejects_missing_token(self, client):
|
|
141
|
+
resp = await client.get("/query/loki/api/v1/labels")
|
|
142
|
+
assert resp.status == 401
|
|
143
|
+
|
|
144
|
+
async def test_rejects_wrong_token(self, client):
|
|
145
|
+
resp = await client.get(
|
|
146
|
+
"/query/loki/api/v1/labels",
|
|
147
|
+
headers={"Authorization": "Bearer wrong-token"},
|
|
148
|
+
)
|
|
149
|
+
assert resp.status == 401
|
|
150
|
+
|
|
151
|
+
async def test_accepts_correct_token(self, client, admin_token):
|
|
152
|
+
mock_resp = make_mock_response(200, b'{"data":[]}')
|
|
153
|
+
client.app["session"] = make_mock_session(mock_resp)
|
|
154
|
+
|
|
155
|
+
resp = await client.get(
|
|
156
|
+
"/query/loki/api/v1/labels",
|
|
157
|
+
headers={"Authorization": f"Bearer {admin_token}"},
|
|
158
|
+
)
|
|
159
|
+
assert resp.status == 200
|
|
160
|
+
|
|
161
|
+
async def test_strips_query_prefix_when_forwarding(self, client, admin_token):
|
|
162
|
+
mock_resp = make_mock_response(200, b"{}")
|
|
163
|
+
session = make_mock_session(mock_resp)
|
|
164
|
+
client.app["session"] = session
|
|
165
|
+
|
|
166
|
+
await client.get(
|
|
167
|
+
"/query/loki/api/v1/labels",
|
|
168
|
+
headers={"Authorization": f"Bearer {admin_token}"},
|
|
169
|
+
)
|
|
170
|
+
call_kwargs = session.request.call_args
|
|
171
|
+
forwarded_url = call_kwargs[1]["url"] if call_kwargs[1] else call_kwargs[0][1]
|
|
172
|
+
assert forwarded_url == "http://loki:3100/loki/api/v1/labels"
|
|
173
|
+
|
|
174
|
+
async def test_forwards_query_string_to_loki(self, client, admin_token):
|
|
175
|
+
mock_resp = make_mock_response(200, b"{}")
|
|
176
|
+
session = make_mock_session(mock_resp)
|
|
177
|
+
client.app["session"] = session
|
|
178
|
+
|
|
179
|
+
await client.get(
|
|
180
|
+
'/query/loki/api/v1/query_range?query={app="x"}&limit=50',
|
|
181
|
+
headers={"Authorization": f"Bearer {admin_token}"},
|
|
182
|
+
)
|
|
183
|
+
call_kwargs = session.request.call_args
|
|
184
|
+
forwarded_url = call_kwargs[1]["url"] if call_kwargs[1] else call_kwargs[0][1]
|
|
185
|
+
assert "query=" in forwarded_url
|
|
186
|
+
assert "limit=50" in forwarded_url
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# ---------------------------------------------------------------------------
|
|
190
|
+
# /grafana routing
|
|
191
|
+
# ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
class TestGrafanaRoute:
|
|
194
|
+
async def test_rejects_missing_token(self, client):
|
|
195
|
+
resp = await client.get("/grafana/api/dashboards")
|
|
196
|
+
assert resp.status == 401
|
|
197
|
+
|
|
198
|
+
async def test_rejects_wrong_token(self, client):
|
|
199
|
+
resp = await client.get(
|
|
200
|
+
"/grafana/api/dashboards",
|
|
201
|
+
headers={"Authorization": "Bearer wrong"},
|
|
202
|
+
)
|
|
203
|
+
assert resp.status == 401
|
|
204
|
+
|
|
205
|
+
async def test_accepts_correct_token(self, client, admin_token):
|
|
206
|
+
mock_resp = make_mock_response(200, b"[]")
|
|
207
|
+
client.app["session"] = make_mock_session(mock_resp)
|
|
208
|
+
|
|
209
|
+
resp = await client.get(
|
|
210
|
+
"/grafana/api/dashboards",
|
|
211
|
+
headers={"Authorization": f"Bearer {admin_token}"},
|
|
212
|
+
)
|
|
213
|
+
assert resp.status == 200
|
|
214
|
+
|
|
215
|
+
async def test_strips_authorization_before_forwarding_to_grafana(self, client, admin_token):
|
|
216
|
+
"""Authorization header should not be forwarded — Grafana manages its own sessions."""
|
|
217
|
+
mock_resp = make_mock_response(200, b"[]")
|
|
218
|
+
session = make_mock_session(mock_resp)
|
|
219
|
+
client.app["session"] = session
|
|
220
|
+
|
|
221
|
+
await client.get(
|
|
222
|
+
"/grafana/api/dashboards",
|
|
223
|
+
headers={"Authorization": f"Bearer {admin_token}"},
|
|
224
|
+
)
|
|
225
|
+
call_kwargs = session.request.call_args
|
|
226
|
+
forwarded_headers = call_kwargs[1].get("headers", {})
|
|
227
|
+
assert "authorization" not in {k.lower() for k in forwarded_headers}
|
|
228
|
+
|
|
229
|
+
async def test_strips_grafana_prefix_when_forwarding(self, client, admin_token):
|
|
230
|
+
mock_resp = make_mock_response(200, b"[]")
|
|
231
|
+
session = make_mock_session(mock_resp)
|
|
232
|
+
client.app["session"] = session
|
|
233
|
+
|
|
234
|
+
await client.get(
|
|
235
|
+
"/grafana/api/dashboards",
|
|
236
|
+
headers={"Authorization": f"Bearer {admin_token}"},
|
|
237
|
+
)
|
|
238
|
+
call_kwargs = session.request.call_args
|
|
239
|
+
forwarded_url = call_kwargs[1]["url"] if call_kwargs[1] else call_kwargs[0][1]
|
|
240
|
+
assert forwarded_url == "http://grafana:3000/api/dashboards"
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# ---------------------------------------------------------------------------
|
|
244
|
+
# Empty LOKI_ADMIN_TOKEN edge case
|
|
245
|
+
# ---------------------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
class TestNoAdminToken:
|
|
248
|
+
async def test_query_rejects_all_when_token_unset(self, aiohttp_client, monkeypatch,
|
|
249
|
+
collector_url, loki_url, grafana_url):
|
|
250
|
+
monkeypatch.setattr(proxy_mod, "LOKI_ADMIN_TOKEN", "")
|
|
251
|
+
app = proxy_mod.create_app()
|
|
252
|
+
client = await aiohttp_client(app)
|
|
253
|
+
|
|
254
|
+
resp = await client.get("/query/loki/api/v1/labels",
|
|
255
|
+
headers={"Authorization": "Bearer anything"})
|
|
256
|
+
assert resp.status == 401
|
|
257
|
+
|
|
258
|
+
async def test_grafana_rejects_all_when_token_unset(self, aiohttp_client, monkeypatch,
|
|
259
|
+
collector_url, loki_url, grafana_url):
|
|
260
|
+
monkeypatch.setattr(proxy_mod, "LOKI_ADMIN_TOKEN", "")
|
|
261
|
+
app = proxy_mod.create_app()
|
|
262
|
+
client = await aiohttp_client(app)
|
|
263
|
+
|
|
264
|
+
resp = await client.get("/grafana/", headers={"Authorization": "Bearer anything"})
|
|
265
|
+
assert resp.status == 401
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|