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.
Files changed (82) hide show
  1. {devlogs-2.3.0/src/devlogs.egg-info → devlogs-2.3.1}/PKG-INFO +3 -1
  2. {devlogs-2.3.0 → devlogs-2.3.1}/pyproject.toml +4 -1
  3. {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/_version_static.py +1 -1
  4. devlogs-2.3.1/src/devlogs/proxy/__init__.py +0 -0
  5. devlogs-2.3.1/src/devlogs/proxy/server.py +163 -0
  6. {devlogs-2.3.0 → devlogs-2.3.1/src/devlogs.egg-info}/PKG-INFO +3 -1
  7. {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs.egg-info/SOURCES.txt +3 -0
  8. {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs.egg-info/requires.txt +3 -0
  9. {devlogs-2.3.0 → devlogs-2.3.1}/tests/test_context.py +3 -0
  10. {devlogs-2.3.0 → devlogs-2.3.1}/tests/test_handler.py +1 -0
  11. {devlogs-2.3.0 → devlogs-2.3.1}/tests/test_indexing.py +4 -0
  12. devlogs-2.3.1/tests/test_proxy_server.py +265 -0
  13. {devlogs-2.3.0 → devlogs-2.3.1}/LICENSE +0 -0
  14. {devlogs-2.3.0 → devlogs-2.3.1}/MANIFEST.in +0 -0
  15. {devlogs-2.3.0 → devlogs-2.3.1}/README.md +0 -0
  16. {devlogs-2.3.0 → devlogs-2.3.1}/setup.cfg +0 -0
  17. {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/__init__.py +0 -0
  18. {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/__main__.py +0 -0
  19. {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/build_info.py +0 -0
  20. {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/cli.py +0 -0
  21. {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/collector/__init__.py +0 -0
  22. {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/collector/auth.py +0 -0
  23. {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/collector/cli.py +0 -0
  24. {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/collector/errors.py +0 -0
  25. {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/collector/forwarder.py +0 -0
  26. {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/collector/ingestor.py +0 -0
  27. {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/collector/loki_plugin.py +0 -0
  28. {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/collector/plugins.py +0 -0
  29. {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/collector/schema.py +0 -0
  30. {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/collector/server.py +0 -0
  31. {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/config.py +0 -0
  32. {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/context.py +0 -0
  33. {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/demo.py +0 -0
  34. {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/devlogs_client.py +0 -0
  35. {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/formatting.py +0 -0
  36. {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/handler.py +0 -0
  37. {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/jenkins/__init__.py +0 -0
  38. {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/jenkins/cli.py +0 -0
  39. {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/jenkins/core.py +0 -0
  40. {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/levels.py +0 -0
  41. {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/loki/__init__.py +0 -0
  42. {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/loki/queries.py +0 -0
  43. {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/mcp/__init__.py +0 -0
  44. {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/mcp/server.py +0 -0
  45. {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/opensearch/__init__.py +0 -0
  46. {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/opensearch/client.py +0 -0
  47. {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/opensearch/indexing.py +0 -0
  48. {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/opensearch/mappings.py +0 -0
  49. {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/opensearch/queries.py +0 -0
  50. {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/retention.py +0 -0
  51. {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/scrub.py +0 -0
  52. {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/time_utils.py +0 -0
  53. {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/version.py +0 -0
  54. {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/web/__init__.py +0 -0
  55. {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/web/server.py +0 -0
  56. {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/web/static/devlogs.css +0 -0
  57. {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/web/static/devlogs.js +0 -0
  58. {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/web/static/index.html +0 -0
  59. {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs/wrapper.py +0 -0
  60. {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs.egg-info/dependency_links.txt +0 -0
  61. {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs.egg-info/entry_points.txt +0 -0
  62. {devlogs-2.3.0 → devlogs-2.3.1}/src/devlogs.egg-info/top_level.txt +0 -0
  63. {devlogs-2.3.0 → devlogs-2.3.1}/tests/test_build_info.py +0 -0
  64. {devlogs-2.3.0 → devlogs-2.3.1}/tests/test_cli.py +0 -0
  65. {devlogs-2.3.0 → devlogs-2.3.1}/tests/test_collector_auth.py +0 -0
  66. {devlogs-2.3.0 → devlogs-2.3.1}/tests/test_collector_config.py +0 -0
  67. {devlogs-2.3.0 → devlogs-2.3.1}/tests/test_collector_plugins.py +0 -0
  68. {devlogs-2.3.0 → devlogs-2.3.1}/tests/test_collector_schema.py +0 -0
  69. {devlogs-2.3.0 → devlogs-2.3.1}/tests/test_collector_server.py +0 -0
  70. {devlogs-2.3.0 → devlogs-2.3.1}/tests/test_config.py +0 -0
  71. {devlogs-2.3.0 → devlogs-2.3.1}/tests/test_devlogs_client.py +0 -0
  72. {devlogs-2.3.0 → devlogs-2.3.1}/tests/test_formatting.py +0 -0
  73. {devlogs-2.3.0 → devlogs-2.3.1}/tests/test_levels.py +0 -0
  74. {devlogs-2.3.0 → devlogs-2.3.1}/tests/test_mappings.py +0 -0
  75. {devlogs-2.3.0 → devlogs-2.3.1}/tests/test_mcp_server.py +0 -0
  76. {devlogs-2.3.0 → devlogs-2.3.1}/tests/test_opensearch_client.py +0 -0
  77. {devlogs-2.3.0 → devlogs-2.3.1}/tests/test_opensearch_queries.py +0 -0
  78. {devlogs-2.3.0 → devlogs-2.3.1}/tests/test_retention.py +0 -0
  79. {devlogs-2.3.0 → devlogs-2.3.1}/tests/test_scrub.py +0 -0
  80. {devlogs-2.3.0 → devlogs-2.3.1}/tests/test_time_utils.py +0 -0
  81. {devlogs-2.3.0 → devlogs-2.3.1}/tests/test_url_parsing.py +0 -0
  82. {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.0
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.0"
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.0"
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.0
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
@@ -10,3 +10,6 @@ mcp>=1.0.0
10
10
  httpx>=0.26.0
11
11
  pytest>=8.0.0
12
12
  pytest-asyncio>=0.23.0
13
+
14
+ [proxy]
15
+ aiohttp>=3.9.0
@@ -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