keepup-admin 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. keepup/THIRD-PARTY.md +44 -0
  2. keepup/__init__.py +41 -0
  3. keepup/api_versions.py +100 -0
  4. keepup/audit.py +499 -0
  5. keepup/auth/__init__.py +7 -0
  6. keepup/auth/config.py +128 -0
  7. keepup/auth/dependencies.py +485 -0
  8. keepup/auth/dto/__init__.py +0 -0
  9. keepup/auth/dto/token.py +31 -0
  10. keepup/auth/factory.py +28 -0
  11. keepup/auth/login_throttle.py +151 -0
  12. keepup/auth/oidc.py +245 -0
  13. keepup/auth/oidc_policy.py +111 -0
  14. keepup/auth/oidc_routes.py +288 -0
  15. keepup/auth/panel_session.py +220 -0
  16. keepup/auth/permissions.py +59 -0
  17. keepup/auth/providers/__init__.py +0 -0
  18. keepup/auth/providers/base.py +168 -0
  19. keepup/auth/providers/local.py +180 -0
  20. keepup/auth/routes.py +660 -0
  21. keepup/auth/seed_accounts.py +322 -0
  22. keepup/auth/session_lifetime.py +104 -0
  23. keepup/auth/signing_key.py +138 -0
  24. keepup/auth/websocket.py +86 -0
  25. keepup/cluster.py +634 -0
  26. keepup/db.py +663 -0
  27. keepup/events.py +764 -0
  28. keepup/factory.py +484 -0
  29. keepup/instance.py +46 -0
  30. keepup/integrations.py +260 -0
  31. keepup/locks.py +412 -0
  32. keepup/logging_setup.py +690 -0
  33. keepup/metrics.py +818 -0
  34. keepup/metrics_retention.py +376 -0
  35. keepup/modules.py +572 -0
  36. keepup/notification_bus.py +355 -0
  37. keepup/plugins/__init__.py +9 -0
  38. keepup/plugins/admin.py +246 -0
  39. keepup/plugins/base.py +234 -0
  40. keepup/plugins/enablement.py +184 -0
  41. keepup/plugins/registry.py +171 -0
  42. keepup/plugins/route_mask.py +338 -0
  43. keepup/plugins/routes.py +376 -0
  44. keepup/roles.py +17 -0
  45. keepup/scheduler.py +93 -0
  46. keepup/schema.py +295 -0
  47. keepup/sections.json +104 -0
  48. keepup/settings.py +184 -0
  49. keepup/static/css/aos.css +1 -0
  50. keepup/static/css/main_nebula.css +232 -0
  51. keepup/static/css/main_new.css +852 -0
  52. keepup/static/css/tailwind.css +1 -0
  53. keepup/static/index_nebula.html +293 -0
  54. keepup/static/index_new.html +286 -0
  55. keepup/static/js/aos.js +1 -0
  56. keepup/static/js/feather-icons.js +13 -0
  57. keepup/static/js/main_new.js +1861 -0
  58. keepup/static/js/tailwind.js +83 -0
  59. keepup/static/modules/css/background_tasks.css +233 -0
  60. keepup/static/modules/css/cluster.css +16 -0
  61. keepup/static/modules/css/event_manager.css +386 -0
  62. keepup/static/modules/css/integration_logs.css +33 -0
  63. keepup/static/modules/css/metrics.css +115 -0
  64. keepup/static/modules/css/modules.css +189 -0
  65. keepup/static/modules/css/themes.css +563 -0
  66. keepup/static/modules/css/users.css +278 -0
  67. keepup/static/modules/js/background_tasks.js +657 -0
  68. keepup/static/modules/js/chart.js +14 -0
  69. keepup/static/modules/js/chartjs-adapter-date-fns.bundle.min.js +7 -0
  70. keepup/static/modules/js/cluster.js +363 -0
  71. keepup/static/modules/js/event_manager.js +979 -0
  72. keepup/static/modules/js/integration_logs.js +767 -0
  73. keepup/static/modules/js/metrics.js +908 -0
  74. keepup/static/modules/js/modules.js +1086 -0
  75. keepup/static/modules/js/themes.js +653 -0
  76. keepup/static/modules/js/users.js +784 -0
  77. keepup/tables.py +302 -0
  78. keepup/themes.py +496 -0
  79. keepup/web.py +182 -0
  80. keepup_admin-0.1.0.dist-info/METADATA +117 -0
  81. keepup_admin-0.1.0.dist-info/RECORD +86 -0
  82. keepup_admin-0.1.0.dist-info/WHEEL +5 -0
  83. keepup_admin-0.1.0.dist-info/licenses/LICENSE +202 -0
  84. keepup_admin-0.1.0.dist-info/licenses/NOTICE +22 -0
  85. keepup_admin-0.1.0.dist-info/licenses/THIRD-PARTY.md +44 -0
  86. keepup_admin-0.1.0.dist-info/top_level.txt +1 -0
keepup/THIRD-PARTY.md ADDED
@@ -0,0 +1,44 @@
1
+ # Third-party code shipped with this package
2
+
3
+ The panel's front end is served from the package itself, and five of its files
4
+ are third-party bundles rather than our code. They are vendored — kept in the
5
+ tree and served from it — rather than fetched from a CDN, because the panel of
6
+ an administrative tool should not depend on somebody else's server being up,
7
+ and because a page that loads nothing from outside is one fewer place a
8
+ deployment has to reason about.
9
+
10
+ All five are minified, and minification stripped their licence headers. That
11
+ is what this file is for: every one of them is MIT, and MIT asks that its
12
+ notice travel with the copy.
13
+
14
+ | File | Project | Licence |
15
+ |---|---|---|
16
+ | `static/js/tailwind.js` | Tailwind CSS (browser build) — https://tailwindcss.com | MIT, Copyright (c) Tailwind Labs, Inc. |
17
+ | `static/js/feather-icons.js` | Feather — https://feathericons.com | MIT, Copyright (c) 2013-2023 Cole Bemis |
18
+ | `static/js/aos.js` | AOS — https://michalsnik.github.io/aos/ | MIT, Copyright (c) 2015 Michał Sajnóg |
19
+ | `static/modules/js/chart.js` | Chart.js — https://www.chartjs.org | MIT, Copyright (c) Chart.js Contributors |
20
+ | `static/modules/js/chartjs-adapter-date-fns.bundle.min.js` | chartjs-adapter-date-fns — https://github.com/chartjs/chartjs-adapter-date-fns | MIT, Copyright (c) Chart.js Contributors |
21
+
22
+ ## The MIT licence
23
+
24
+ > Permission is hereby granted, free of charge, to any person obtaining a copy
25
+ > of this software and associated documentation files (the "Software"), to deal
26
+ > in the Software without restriction, including without limitation the rights
27
+ > to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
28
+ > copies of the Software, and to permit persons to whom the Software is
29
+ > furnished to do so, subject to the following conditions:
30
+ >
31
+ > The above copyright notice and this permission notice shall be included in all
32
+ > copies or substantial portions of the Software.
33
+ >
34
+ > THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
35
+ > IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
36
+ > FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
37
+ > AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
38
+ > LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
39
+ > OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALING IN THE
40
+ > SOFTWARE.
41
+
42
+ The licence of this package, whatever it is, does not reach these five files:
43
+ they stay under the terms above, and replacing one of them means checking that
44
+ its replacement's terms still allow this.
keepup/__init__.py ADDED
@@ -0,0 +1,41 @@
1
+ """KeepUP — a framework over FastAPI for applications built like this one.
2
+
3
+ An application supplies a :class:`~keepup.settings.KeepupSettings` and gets an
4
+ application back from :func:`~keepup.factory.create_app`. Everything the
5
+ framework cannot know -- the product's name, the collector its logs go to,
6
+ which fields of a request are secret, what a password must look like, which
7
+ tables belong to the product -- travels in those settings, never as a default
8
+ in here. See ``doc/keepup.md``.
9
+
10
+ Imported lazily: ``keepup.factory`` pulls in the database layer and the
11
+ authentication provider, and importing the package to reach one constant
12
+ should not do that.
13
+ """
14
+
15
+ #: The framework's own version, which is the version of the distribution it
16
+ #: was installed from -- not the version of the application it serves. The
17
+ #: application's version is its own metadata and reaches the framework through
18
+ #: settings; reading one for the other is how a panel ends up reporting the
19
+ #: framework's number as the product's (keepup-5).
20
+ try:
21
+ from importlib.metadata import version as _distribution_version
22
+
23
+ __version__ = _distribution_version("keepup-admin")
24
+ except Exception:
25
+ # Running from a source tree that was never installed -- the repository
26
+ # itself, until the applications switch to installing by version.
27
+ __version__ = "0.0.0+source"
28
+
29
+ __all__ = ["KeepupSettings", "StaticMount", "create_app", "__version__"]
30
+
31
+
32
+ def __getattr__(name):
33
+ if name in ("KeepupSettings", "StaticMount"):
34
+ from keepup import settings
35
+
36
+ return getattr(settings, name)
37
+ if name == "create_app":
38
+ from keepup.factory import create_app
39
+
40
+ return create_app
41
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
keepup/api_versions.py ADDED
@@ -0,0 +1,100 @@
1
+ """API versions: the version in the path, and the paths without one.
2
+
3
+ Clients cannot be updated together with the server: an agent is updated by the
4
+ host owner copying an archive, the client inside a VM by rebuilding the image.
5
+ So the model on the wire is pinned to a version named in the path -- `/api/v1/…`
6
+ for HTTP, `/ws/v1/…` for websockets -- and a later incompatible model gets a new
7
+ prefix while old clients keep talking to theirs.
8
+
9
+ v1 is the model the routes have today, and their handlers do not know about it:
10
+ this middleware maps `/api/v1/x` onto `/api/x` before routing, for the routes of
11
+ the application and of every plugin at once, so no route dictionary changes. A
12
+ path without a version stays an alias of v1 for as long as v1 exists (decision of
13
+ 17.09.2026): the panel is served with the server and has no reason to move, and
14
+ agents already installed must not break. Its HTTP answers say so with a
15
+ `Deprecation` header and a link to the versioned path.
16
+
17
+ A future v2 route is registered under its own `/api/v2/…` path and is never
18
+ rewritten. `/api/v2/websocket/token` and `/ws/v2/websocket` predate this scheme:
19
+ they are the second version of the guest channel, whose first version is gone,
20
+ and are listed as such in `/api/versions`.
21
+ """
22
+
23
+ from typing import Any, Dict, List
24
+
25
+ CURRENT = "v1"
26
+
27
+ #: Every version the server speaks. `sunset` is when a deprecated version stops
28
+ #: being served; None means no date is set.
29
+ VERSIONS: List[Dict[str, Any]] = [
30
+ {"version": "v1", "status": "current", "http_prefix": "/api/v1", "websocket_prefix": "/ws/v1",
31
+ "sunset": None},
32
+ ]
33
+
34
+ #: Paths that carry their own version outside the scheme above.
35
+ CHANNEL_VERSIONS: List[Dict[str, str]] = [
36
+ {"channel": "guest", "version": "v2", "paths": "/api/v2/websocket/token, /ws/v2/websocket"},
37
+ ]
38
+
39
+ #: Versioned prefix -> the unversioned prefix the routes are registered under.
40
+ ALIASES = {"/api/v1": "/api", "/ws/v1": "/ws"}
41
+
42
+ #: Unversioned paths that are not an alias of v1 and get no deprecation notice.
43
+ NOT_DEPRECATED = ("/api/versions", "/api/v2/", "/api/v3/")
44
+
45
+
46
+ def unversioned(path: str):
47
+ """The registered path for a versioned one, or None when the path has no v1 prefix."""
48
+ for versioned, plain in ALIASES.items():
49
+ if path == versioned or path.startswith(versioned + "/"):
50
+ return plain + path[len(versioned):]
51
+ return None
52
+
53
+
54
+ def successor(path: str):
55
+ """The versioned path a deprecated unversioned one should become, or None."""
56
+ if path.startswith(NOT_DEPRECATED) or path == "/api":
57
+ return None
58
+ for versioned, plain in ALIASES.items():
59
+ if path.startswith(plain + "/") and not path.startswith(versioned + "/"):
60
+ return versioned + path[len(plain):]
61
+ return None
62
+
63
+
64
+ def describe() -> Dict[str, Any]:
65
+ return {"current": CURRENT, "versions": VERSIONS, "channels": CHANNEL_VERSIONS,
66
+ "unversioned_paths": "alias of " + CURRENT}
67
+
68
+
69
+ class ApiVersionMiddleware:
70
+ """Route `/api/v1/…` and `/ws/v1/…` to the handlers, mark unversioned answers."""
71
+
72
+ def __init__(self, app):
73
+ self.app = app
74
+
75
+ async def __call__(self, scope, receive, send):
76
+ if scope["type"] not in ("http", "websocket"):
77
+ return await self.app(scope, receive, send)
78
+
79
+ path = scope.get("path", "")
80
+ plain = unversioned(path)
81
+ if plain is not None:
82
+ scope = dict(scope)
83
+ scope["path"] = plain
84
+ scope["raw_path"] = plain.encode("utf-8")
85
+ scope["api_version"] = CURRENT
86
+ return await self.app(scope, receive, send)
87
+
88
+ target = successor(path) if scope["type"] == "http" else None
89
+ if target is None:
90
+ return await self.app(scope, receive, send)
91
+
92
+ async def send_with_notice(message):
93
+ if message["type"] == "http.response.start":
94
+ headers = list(message.get("headers", []))
95
+ headers.append((b"deprecation", b"true"))
96
+ headers.append((b"link", f'<{target}>; rel="successor-version"'.encode("utf-8")))
97
+ message = {**message, "headers": headers}
98
+ await send(message)
99
+
100
+ return await self.app(scope, receive, send_with_notice)
keepup/audit.py ADDED
@@ -0,0 +1,499 @@
1
+ """The audit of incoming requests.
2
+
3
+ Every call that reaches a plugin route is written to ``incoming_requests``:
4
+ who called, what was asked, what came back and how long it took. Writing a row
5
+ per request would put the audit on the request path, so rows are buffered in
6
+ memory and flushed by size or by age -- which is also why the process flushes
7
+ what is left before it exits.
8
+
9
+ What counts as a secret is the application's to say: ``configure()`` takes the
10
+ redaction function, because the framework does not know which field of which
11
+ plugin carries a key. What it does know is that it should not guess in the
12
+ permissive direction: without a function, values are replaced by a marker and
13
+ only the field names are kept. An application that genuinely wants the contents
14
+ recorded says so by name -- ``configure(redaction=keep_as_is)``.
15
+ """
16
+
17
+ import asyncio
18
+ import json
19
+ import logging
20
+ import os
21
+ import time
22
+ from contextlib import asynccontextmanager
23
+ from datetime import datetime
24
+ from typing import Any, Dict, Optional
25
+
26
+ from sqlalchemy import CheckConstraint, Column, DateTime, Index, Integer, Text, UniqueConstraint
27
+ from sqlalchemy.dialects import postgresql
28
+
29
+ from keepup import tables
30
+ from keepup.db import DatabaseManager, DatabaseManagerV2, db_config
31
+ from keepup.instance import get_instance_id
32
+
33
+ #: What an application may import from this module. Everything else is
34
+ #: internal and may change without notice -- see doc/keepup.md.
35
+ __all__ = [
36
+ "BUFFER_FLUSH_INTERVAL",
37
+ "BUFFER_MAX_SIZE",
38
+ "HIDDEN",
39
+ "IncomingRequestLogger",
40
+ "audit_retention_background",
41
+ "background_buffer_flusher",
42
+ "configure",
43
+ "incoming_requests_buffer",
44
+ "init_incoming_requests_table",
45
+ "keep_as_is",
46
+ "log_api_request",
47
+ "names_only",
48
+ "purge_old_requests",
49
+ "retention_days",
50
+ ]
51
+
52
+ logger = logging.getLogger(__name__)
53
+
54
+ incoming_requests_buffer: Dict[str, Dict[str, Any]] = {}
55
+ incoming_requests_lock = asyncio.Lock()
56
+
57
+ BUFFER_FLUSH_INTERVAL = 125
58
+ BUFFER_MAX_SIZE = 100
59
+
60
+
61
+ #: What a value is replaced by when the application named no redaction.
62
+ HIDDEN = "<hidden>"
63
+
64
+
65
+ def keep_as_is(value):
66
+ """Record everything, values included.
67
+
68
+ Named and public because it is a decision an application has to be able to
69
+ state: passing it says "nothing here is a secret", which is different from
70
+ saying nothing at all.
71
+
72
+ Args:
73
+ value: the request or response body.
74
+
75
+ Returns:
76
+ The same value.
77
+ """
78
+ return value
79
+
80
+
81
+ def names_only(value):
82
+ """Keep the shape and the field names, replace every value.
83
+
84
+ The default. A framework that does not know which field carries a key must
85
+ not guess that none of them does: a one-time link, an invitation or reset
86
+ token, an API key in a query string -- all of them used to be written to the
87
+ table in full and kept there. The names are what makes a row useful for
88
+ reading an incident afterwards; the values are what makes it a second place
89
+ the secret lives.
90
+
91
+ Args:
92
+ value: the request or response body.
93
+
94
+ Returns:
95
+ The same structure with every scalar replaced by HIDDEN.
96
+ """
97
+ if isinstance(value, dict):
98
+ return {key: names_only(item) for key, item in value.items()}
99
+ if isinstance(value, (list, tuple)):
100
+ return [names_only(item) for item in value]
101
+ if value is None or isinstance(value, bool):
102
+ # Absence and a flag are kept. Nothing can hide in two values, and a
103
+ # row that says only "a boolean was here" stops being any use for
104
+ # reading an incident -- which is the whole reason the table exists.
105
+ return value
106
+ return HIDDEN
107
+
108
+
109
+ redact = names_only
110
+
111
+
112
+ #: Told apart from None so that the settings of the last application built in
113
+ #: the process do not go on applying to the next one. "This application hides
114
+ #: nothing" is expressible too, but by name -- configure(redaction=keep_as_is) --
115
+ #: rather than by an absence that looks like every other absence.
116
+ _UNSET = object()
117
+
118
+
119
+ def configure(redaction=_UNSET, flush_interval=_UNSET, max_size=_UNSET):
120
+ """Supply the application's audit values."""
121
+ global redact, BUFFER_FLUSH_INTERVAL, BUFFER_MAX_SIZE
122
+ if redaction is not _UNSET:
123
+ redact = redaction or names_only
124
+ if flush_interval is not _UNSET and flush_interval is not None:
125
+ BUFFER_FLUSH_INTERVAL = flush_interval
126
+ if max_size is not _UNSET and max_size is not None:
127
+ BUFFER_MAX_SIZE = max_size
128
+
129
+
130
+ incoming_requests_buffer: Dict[str, Dict[str, Any]] = {}
131
+ incoming_requests_lock = asyncio.Lock()
132
+
133
+ BUFFER_FLUSH_INTERVAL = 125
134
+ BUFFER_MAX_SIZE = 100
135
+
136
+
137
+ class IncomingRequestLogger:
138
+ """Records incoming API requests."""
139
+
140
+ @staticmethod
141
+ async def start_request(
142
+ instance_id: str,
143
+ method: str,
144
+ endpoint: str,
145
+ host: str,
146
+ request_data: Optional[Dict[str, Any]] = None,
147
+ request_start_at: Optional[datetime] = None
148
+ ) -> str:
149
+ """Begin recording a request and return its id."""
150
+ from uuid import uuid4
151
+
152
+ request_id = str(uuid4())
153
+ request_start = request_start_at or datetime.utcnow()
154
+
155
+ async with incoming_requests_lock:
156
+ incoming_requests_buffer[request_id] = {
157
+ 'instance_id': instance_id,
158
+ 'method': method,
159
+ 'endpoint': endpoint,
160
+ 'host': host,
161
+ 'request_data': redact(request_data),
162
+ 'request_start_at': request_start,
163
+ 'created_at': datetime.utcnow()
164
+ }
165
+ if len(incoming_requests_buffer) >= BUFFER_MAX_SIZE:
166
+ asyncio.create_task(IncomingRequestLogger.flush_buffer())
167
+
168
+ return request_id
169
+
170
+ @staticmethod
171
+ async def end_request(
172
+ request_id: str,
173
+ duration_ms: Optional[int] = None,
174
+ http_status: Optional[int] = None,
175
+ response_data: Optional[Dict[str, Any]] = None,
176
+ error_message: Optional[str] = None
177
+ ) -> bool:
178
+ """Finish recording a request."""
179
+ async with incoming_requests_lock:
180
+ if request_id not in incoming_requests_buffer:
181
+ logger.warning(f"Request {request_id} not found in buffer")
182
+ return False
183
+
184
+ request = incoming_requests_buffer[request_id]
185
+ request['request_end_at'] = datetime.utcnow()
186
+
187
+ if duration_ms is None:
188
+ start_time = request['request_start_at']
189
+ if isinstance(start_time, str):
190
+ start_time = datetime.fromisoformat(start_time.replace('Z', ''))
191
+ duration_ms = int((datetime.utcnow() - start_time).total_seconds() * 1000)
192
+
193
+ # Only what this call was told, and never over an outcome already
194
+ # recorded. log_api_request() calls this twice -- once with the
195
+ # outcome, then again from its finally -- and an unconditional
196
+ # update meant the second call wrote http_status, response_data and
197
+ # error_message back to None. Every row of the table carried an
198
+ # empty outcome, so a wave of 401s and 500s was indistinguishable
199
+ # from a wave of successful calls (task keepup-11).
200
+ request['duration_ms'] = duration_ms
201
+ if http_status is not None:
202
+ request['http_status'] = http_status
203
+ if response_data is not None:
204
+ # The values people prove rights with never reach the table:
205
+ # node tokens, one-time tokens, volume and image keys all passed
206
+ # through here, and one row was enough to hold the lot.
207
+ request['response_data'] = redact(response_data)
208
+ if error_message is not None:
209
+ request['error_message'] = error_message
210
+
211
+ return True
212
+
213
+ @staticmethod
214
+ async def flush_buffer() -> int:
215
+ """Write the buffered requests to the database."""
216
+ from keepup.db import DatabaseManager
217
+
218
+ async with incoming_requests_lock:
219
+ if not incoming_requests_buffer:
220
+ return 0
221
+
222
+ requests_to_insert = list(incoming_requests_buffer.values())
223
+ inserted_count = 0
224
+
225
+ try:
226
+ values = []
227
+ for req in requests_to_insert:
228
+ values.append((
229
+ req['instance_id'],
230
+ req['method'],
231
+ req['endpoint'],
232
+ req['host'],
233
+ json.dumps(req['request_data']) if req['request_data'] else None,
234
+ req['request_start_at'],
235
+ req.get('request_end_at'),
236
+ req.get('duration_ms'),
237
+ req.get('http_status'),
238
+ json.dumps(req.get('response_data')) if req.get('response_data') else None,
239
+ req.get('error_message'),
240
+ req['created_at']
241
+ ))
242
+
243
+ if values:
244
+ if db_config.is_postgres():
245
+ query = '''
246
+ INSERT INTO incoming_requests
247
+ (instance_id, method, endpoint, host, request_data, request_start_at,
248
+ request_end_at, duration_ms, http_status, response_data, error_message, created_at)
249
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
250
+ ON CONFLICT (instance_id, method, endpoint, request_start_at)
251
+ DO NOTHING
252
+ '''
253
+ else:
254
+ query = '''
255
+ INSERT OR IGNORE INTO incoming_requests
256
+ (instance_id, method, endpoint, host, request_data, request_start_at,
257
+ request_end_at, duration_ms, http_status, response_data, error_message, created_at)
258
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
259
+ '''
260
+
261
+ if db_config.is_postgres():
262
+ conn = DatabaseManager.get_connection()
263
+ cursor = conn.cursor()
264
+ try:
265
+ cursor.executemany(query, values)
266
+ inserted_count = cursor.rowcount
267
+ conn.commit()
268
+ finally:
269
+ cursor.close()
270
+ conn.close()
271
+ else:
272
+ conn = DatabaseManager.get_connection()
273
+ cursor = conn.cursor()
274
+ try:
275
+ cursor.executemany(query, values)
276
+ inserted_count = cursor.rowcount
277
+ conn.commit()
278
+ finally:
279
+ cursor.close()
280
+ conn.close()
281
+
282
+ if inserted_count > 0:
283
+ inserted_ids = []
284
+ for req_id, req_data in incoming_requests_buffer.items():
285
+ for v in values:
286
+ if (req_data['instance_id'] == v[0] and
287
+ req_data['method'] == v[1] and
288
+ req_data['endpoint'] == v[2] and
289
+ req_data['request_start_at'] == v[5]):
290
+ inserted_ids.append(req_id)
291
+ break
292
+
293
+ for req_id in inserted_ids:
294
+ incoming_requests_buffer.pop(req_id, None)
295
+
296
+ logger.info(f"Flushed {inserted_count} incoming requests to database")
297
+
298
+ except Exception as e:
299
+ logger.error(f"Error flushing incoming requests buffer: {str(e)}")
300
+
301
+ return inserted_count
302
+
303
+
304
+ #: What PostgreSQL enforces with named constraints, SQLite held as a unique
305
+ #: index of the same name and no checks at all; each dialect keeps what it had.
306
+ # --- how long a row lives ----------------------------------------------------
307
+
308
+ #: How many days an audit row is kept. The table used to have no sweep at all,
309
+ #: while metric snapshots and application events both had one: it grew for as
310
+ #: long as the deployment ran, and every row in it was a second place a value
311
+ #: from a query string lived.
312
+ DEFAULT_AUDIT_RETENTION_DAYS = 30
313
+ #: How often the sweep runs.
314
+ AUDIT_SWEEP_INTERVAL_SECONDS = 3600
315
+ #: How many rows one statement removes. Deleting a month of traffic in a single
316
+ #: statement would hold up the flush of the buffer behind it.
317
+ AUDIT_DELETE_CHUNK_ROWS = 5000
318
+ #: How many statements one pass makes. This is what turns clearing a backlog
319
+ #: into several short passes instead of one long one.
320
+ AUDIT_MAX_CHUNKS_PER_PASS = 20
321
+ #: How long to wait after a failed pass.
322
+ AUDIT_ERROR_BACKOFF_SECONDS = 60
323
+
324
+ AUDIT_RETENTION_LOCK = "incoming_requests_retention"
325
+
326
+
327
+ def retention_days():
328
+ """How many days rows are kept, from the environment or the default.
329
+
330
+ Returns:
331
+ A positive number of days; rubbish in the variable means the default
332
+ and a line in the log, because a sweep that switched itself off
333
+ silently would be worse than one that swept too much.
334
+ """
335
+ raw = os.getenv("AUDIT_RETENTION_DAYS")
336
+ if raw in (None, ""):
337
+ return DEFAULT_AUDIT_RETENTION_DAYS
338
+ try:
339
+ value = int(raw)
340
+ except (TypeError, ValueError):
341
+ logger.warning("AUDIT_RETENTION_DAYS=%r is not a number, using %s",
342
+ raw, DEFAULT_AUDIT_RETENTION_DAYS)
343
+ return DEFAULT_AUDIT_RETENTION_DAYS
344
+ if value <= 0:
345
+ logger.warning("AUDIT_RETENTION_DAYS=%s must be positive, using %s",
346
+ value, DEFAULT_AUDIT_RETENTION_DAYS)
347
+ return DEFAULT_AUDIT_RETENTION_DAYS
348
+ return value
349
+
350
+
351
+ def purge_old_requests(days=None, now=None):
352
+ """Delete audit rows older than the retention period.
353
+
354
+ The boundary is computed here rather than in the statement: the interval
355
+ syntax differs between the engines, and a query written for one does not
356
+ run on the other.
357
+
358
+ Args:
359
+ days: how many days to keep; read from the environment when omitted.
360
+ now: the moment to measure from; defaults to the current time.
361
+
362
+ Returns:
363
+ How many rows were deleted.
364
+ """
365
+ from datetime import timedelta
366
+
367
+ keep_days = days if days is not None else retention_days()
368
+ cutoff = (now or datetime.utcnow()) - timedelta(days=keep_days)
369
+
370
+ removed = 0
371
+ for _ in range(AUDIT_MAX_CHUNKS_PER_PASS):
372
+ rows = DatabaseManagerV2.execute(
373
+ "SELECT id FROM incoming_requests WHERE created_at < :cutoff "
374
+ "ORDER BY created_at ASC LIMIT :limit",
375
+ {"cutoff": cutoff, "limit": AUDIT_DELETE_CHUNK_ROWS})
376
+ if not rows:
377
+ break
378
+ ids = {f"i{index}": row["id"] for index, row in enumerate(rows)}
379
+ placeholders = ", ".join(f":{key}" for key in ids)
380
+ DatabaseManagerV2.execute_commit(
381
+ f"DELETE FROM incoming_requests WHERE id IN ({placeholders})", ids)
382
+ removed += len(rows)
383
+ if len(rows) < AUDIT_DELETE_CHUNK_ROWS:
384
+ break
385
+ return removed
386
+
387
+
388
+ async def audit_retention_background():
389
+ """Sweep the audit table once per interval, under a distributed lock.
390
+
391
+ Locked because the work is shared across replicas: three of them deleting
392
+ the same rows at once make three times the statements and not one row
393
+ fewer. A failed pass costs nothing -- recording requests runs on its own
394
+ path and knows nothing of the sweep.
395
+ """
396
+ from keepup.locks import distributed_lock
397
+
398
+ while True:
399
+ try:
400
+ async with distributed_lock(AUDIT_RETENTION_LOCK, timeout=5,
401
+ max_lock_time=AUDIT_SWEEP_INTERVAL_SECONDS):
402
+ removed = await asyncio.to_thread(purge_old_requests)
403
+ if removed:
404
+ logger.info("Audit rows expired: %s", removed)
405
+ await asyncio.sleep(AUDIT_SWEEP_INTERVAL_SECONDS)
406
+ except asyncio.CancelledError:
407
+ raise
408
+ except Exception as error:
409
+ logger.error("Error in the audit retention sweep: %s", error)
410
+ await asyncio.sleep(AUDIT_ERROR_BACKOFF_SECONDS)
411
+
412
+
413
+ INCOMING_REQUESTS = tables.table(
414
+ "incoming_requests",
415
+ tables.auto_id(),
416
+ Column("instance_id", Text, nullable=False),
417
+ Column("method", Text, nullable=False),
418
+ Column("endpoint", Text, nullable=False),
419
+ Column("host", Text, nullable=False),
420
+ Column("request_data", postgresql.JSONB().with_variant(Text(), "sqlite")),
421
+ Column("request_start_at", DateTime, nullable=False),
422
+ Column("request_end_at", DateTime),
423
+ Column("duration_ms", Integer),
424
+ Column("http_status", Integer),
425
+ Column("response_data", postgresql.JSONB().with_variant(Text(), "sqlite")),
426
+ Column("error_message", Text),
427
+ Column("created_at", DateTime, server_default=tables.NOW),
428
+ UniqueConstraint("instance_id", "method", "endpoint", "request_start_at",
429
+ name="incoming_requests_instance_method_endpoint_unique").ddl_if(dialect="postgresql"),
430
+ CheckConstraint("duration_ms >= 0",
431
+ name="incoming_requests_check_duration").ddl_if(dialect="postgresql"),
432
+ CheckConstraint("(http_status IS NULL) OR (http_status >= 100 AND http_status <= 599)",
433
+ name="incoming_requests_check_http_status").ddl_if(dialect="postgresql"),
434
+ Index("incoming_requests_instance_method_endpoint_unique",
435
+ "instance_id", "method", "endpoint", "request_start_at", unique=True).ddl_if(dialect="sqlite"),
436
+ Index("idx_incoming_requests_instance_id", "instance_id"),
437
+ Index("idx_incoming_requests_endpoint", "endpoint"),
438
+ Index("idx_incoming_requests_created_at", "created_at"),
439
+ )
440
+
441
+
442
+ def init_incoming_requests_table():
443
+ """Create the table holding incoming request logs."""
444
+ tables.ensure_tables(INCOMING_REQUESTS)
445
+ logger.info("Incoming requests table initialized successfully")
446
+
447
+
448
+ async def background_buffer_flusher():
449
+ """Background task flushing the request buffer to the database."""
450
+ while True:
451
+ try:
452
+ await asyncio.sleep(BUFFER_FLUSH_INTERVAL)
453
+ await IncomingRequestLogger.flush_buffer()
454
+ except asyncio.CancelledError:
455
+ break
456
+ except Exception as e:
457
+ logger.error(f"Error in background buffer flusher: {str(e)}")
458
+ await asyncio.sleep(10)
459
+
460
+
461
+ @asynccontextmanager
462
+ async def log_api_request(
463
+ method: str,
464
+ endpoint: str,
465
+ host: str = "plugin_api",
466
+ request_data: Optional[Dict[str, Any]] = None
467
+ ):
468
+ """Async context manager recording one API request."""
469
+ request_id = None
470
+ instance_id = get_instance_id()
471
+ start_time = time.time()
472
+
473
+ try:
474
+ request_id = await IncomingRequestLogger.start_request(
475
+ instance_id=instance_id,
476
+ method=method,
477
+ endpoint=endpoint,
478
+ host=host,
479
+ request_data=request_data,
480
+ request_start_at=datetime.utcnow()
481
+ )
482
+
483
+ yield request_id
484
+
485
+ except Exception as e:
486
+ if request_id:
487
+ await IncomingRequestLogger.end_request(
488
+ request_id=request_id,
489
+ duration_ms=int((time.time() - start_time) * 1000),
490
+ error_message=str(e)
491
+ )
492
+ raise
493
+
494
+ finally:
495
+ if request_id:
496
+ await IncomingRequestLogger.end_request(
497
+ request_id=request_id,
498
+ duration_ms=int((time.time() - start_time) * 1000)
499
+ )