keepup-http 0.5.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.
- keepup_http/__init__.py +38 -0
- keepup_http/api_docs.py +76 -0
- keepup_http/body_limit.py +156 -0
- keepup_http/events_api.py +237 -0
- keepup_http/factory.py +562 -0
- keepup_http/lock_routes.py +155 -0
- keepup_http/plugin.py +53 -0
- keepup_http/routes.py +549 -0
- keepup_http/static/css/aos.css +1 -0
- keepup_http/static/css/main_nebula.css +232 -0
- keepup_http/static/css/main_new.css +869 -0
- keepup_http/static/css/tailwind.css +1 -0
- keepup_http/static/index_nebula.html +282 -0
- keepup_http/static/index_new.html +275 -0
- keepup_http/static/js/aos.js +1 -0
- keepup_http/static/js/feather-icons.js +13 -0
- keepup_http/static/js/layout_bootstrap.js +29 -0
- keepup_http/static/js/main_new.js +2076 -0
- keepup_http/static/js/tailwind.js +83 -0
- keepup_http/static/modules/css/background_tasks.css +233 -0
- keepup_http/static/modules/css/cluster.css +16 -0
- keepup_http/static/modules/css/event_manager.css +386 -0
- keepup_http/static/modules/css/integration_logs.css +33 -0
- keepup_http/static/modules/css/metrics.css +115 -0
- keepup_http/static/modules/css/modules.css +189 -0
- keepup_http/static/modules/css/themes.css +563 -0
- keepup_http/static/modules/css/users.css +278 -0
- keepup_http/static/modules/js/background_tasks.js +695 -0
- keepup_http/static/modules/js/chart.js +14 -0
- keepup_http/static/modules/js/chartjs-adapter-date-fns.bundle.min.js +7 -0
- keepup_http/static/modules/js/cluster.js +374 -0
- keepup_http/static/modules/js/event_manager.js +1007 -0
- keepup_http/static/modules/js/integration_logs.js +795 -0
- keepup_http/static/modules/js/metrics.js +929 -0
- keepup_http/static/modules/js/modules.js +1128 -0
- keepup_http/static/modules/js/themes.js +684 -0
- keepup_http/static/modules/js/users.js +898 -0
- keepup_http/web.py +214 -0
- keepup_http-0.5.0.dist-info/METADATA +29 -0
- keepup_http-0.5.0.dist-info/RECORD +43 -0
- keepup_http-0.5.0.dist-info/WHEEL +5 -0
- keepup_http-0.5.0.dist-info/entry_points.txt +2 -0
- keepup_http-0.5.0.dist-info/top_level.txt +1 -0
keepup_http/factory.py
ADDED
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
"""Building the application.
|
|
2
|
+
|
|
3
|
+
Everything that used to happen while ``app/main.py`` was being imported happens
|
|
4
|
+
inside ``create_app`` instead: the application is created, its static
|
|
5
|
+
directories mounted, its middleware added and its own endpoints registered.
|
|
6
|
+
That is what makes a second application on this framework possible, and it is
|
|
7
|
+
also what makes the first one testable -- a test can build one with its own
|
|
8
|
+
settings instead of importing a module that builds it as a side effect.
|
|
9
|
+
|
|
10
|
+
The order of the start-up is not free, and two steps in it are the reason this
|
|
11
|
+
file exists rather than a list of calls in each application:
|
|
12
|
+
|
|
13
|
+
* the signing key is demanded before anything else, so a deployment that would
|
|
14
|
+
accept tokens anyone could sign never starts;
|
|
15
|
+
* ``on_startup`` runs before the plugins, because a plugin subscribes to the
|
|
16
|
+
application's bus while initialising and a bus started afterwards would have
|
|
17
|
+
no subscribers.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import logging
|
|
22
|
+
import os
|
|
23
|
+
from contextlib import asynccontextmanager
|
|
24
|
+
|
|
25
|
+
from fastapi import FastAPI
|
|
26
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
27
|
+
from fastapi.staticfiles import StaticFiles
|
|
28
|
+
|
|
29
|
+
from keepup_audit import audit
|
|
30
|
+
from keepup import notification_bus
|
|
31
|
+
from keepup_cluster import cluster
|
|
32
|
+
from keepup_http import web
|
|
33
|
+
from keepup_modules import catalogue as modules
|
|
34
|
+
from keepup_http.api_docs import register_api_documentation
|
|
35
|
+
from keepup.api_versions import ApiVersionMiddleware
|
|
36
|
+
from keepup_audit.audit import init_incoming_requests_table
|
|
37
|
+
from keepup_auth import dependencies as auth_dependencies
|
|
38
|
+
from keepup_auth import routes as auth_routes
|
|
39
|
+
from keepup_auth import panel_session, socket_sessions
|
|
40
|
+
from keepup_auth.oidc_routes import register_oidc_routes
|
|
41
|
+
from keepup_auth.identity import runtime as identity_runtime
|
|
42
|
+
from keepup_audit.events import init_event_manager
|
|
43
|
+
from keepup_http.events_api import register_event_api_routes
|
|
44
|
+
from keepup.instance import get_instance_id
|
|
45
|
+
from keepup_http.lock_routes import register_lock_routes
|
|
46
|
+
from keepup_metrics.api import register_metrics_routes
|
|
47
|
+
from keepup_modules.catalogue import register_module_routes
|
|
48
|
+
from keepup.plugins.registry import (
|
|
49
|
+
initialize_plugins,
|
|
50
|
+
register_plugin_admin_routes,
|
|
51
|
+
run_post_construct_processors,
|
|
52
|
+
)
|
|
53
|
+
from keepup_tasks.scheduler import register_scheduler_routes
|
|
54
|
+
from keepup.settings import KeepupSettings
|
|
55
|
+
from keepup_ui.themes import config_service, register_theme_routes
|
|
56
|
+
from keepup_http.web import register_web_routes
|
|
57
|
+
|
|
58
|
+
#: What an application may import from this module. Everything else is
|
|
59
|
+
#: internal and may change without notice -- see doc/keepup.md.
|
|
60
|
+
__all__ = [
|
|
61
|
+
"create_app",
|
|
62
|
+
"require_signing_key",
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
logger = logging.getLogger(__name__)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def require_signing_key() -> None:
|
|
69
|
+
"""Stop the start-up when the token signing key is not configured.
|
|
70
|
+
|
|
71
|
+
Here rather than at import time: importing a module to generate the API
|
|
72
|
+
documentation, or to reach one of its utilities, is not a deployment and
|
|
73
|
+
has no business demanding a production secret. Starting the server is, and
|
|
74
|
+
a server that came up with the placeholder key from the setup examples
|
|
75
|
+
would accept a token anybody could sign for any account.
|
|
76
|
+
"""
|
|
77
|
+
from keepup_auth.signing_key import SigningKeyUnavailable, signing_key_problem
|
|
78
|
+
|
|
79
|
+
problem = signing_key_problem()
|
|
80
|
+
if problem:
|
|
81
|
+
logger.critical(problem)
|
|
82
|
+
raise SigningKeyUnavailable(problem)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class SecurityHeadersMiddleware:
|
|
86
|
+
"""The response headers a browser needs in order to refuse things.
|
|
87
|
+
|
|
88
|
+
The panel is an ordinary page, so without X-Frame-Options anybody can frame
|
|
89
|
+
it and read a click as the administrator's. The framework carried none of
|
|
90
|
+
these headers at all, which for a package installed on a public address is
|
|
91
|
+
a default rather than an omission.
|
|
92
|
+
|
|
93
|
+
The Content-Security-Policy arrives here already written -- the framework's
|
|
94
|
+
own or the application's (``KeepupSettings.content_security_policy``,
|
|
95
|
+
keepup-93) -- and is sent as a policy or, with ``csp_report_only``, as a
|
|
96
|
+
report the browser does not act on. A response that carries a policy of its
|
|
97
|
+
own keeps it, like every other header here.
|
|
98
|
+
|
|
99
|
+
Plain ASGI: it only adds headers to the start of a response. Written on
|
|
100
|
+
BaseHTTPMiddleware it ran every request through a task and a body stream
|
|
101
|
+
of its own, the most expensive kind of middleware Starlette has (keepup-88).
|
|
102
|
+
A header the response already carries is left as it is.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
def __init__(self, app, https=False, csp=None, csp_report_only=False):
|
|
106
|
+
self.app = app
|
|
107
|
+
headers = [(b"x-frame-options", b"SAMEORIGIN"),
|
|
108
|
+
(b"x-content-type-options", b"nosniff"),
|
|
109
|
+
(b"referrer-policy", b"same-origin")]
|
|
110
|
+
if https:
|
|
111
|
+
# Only over TLS: sent over plain HTTP it would be ignored, and a
|
|
112
|
+
# deployment on a local network without TLS must stay reachable.
|
|
113
|
+
headers.append((b"strict-transport-security", b"max-age=31536000"))
|
|
114
|
+
if csp:
|
|
115
|
+
name = (b"content-security-policy-report-only" if csp_report_only
|
|
116
|
+
else b"content-security-policy")
|
|
117
|
+
# Headers travel as latin-1 in ASGI; a policy is written in ASCII.
|
|
118
|
+
headers.append((name, csp.encode("latin-1", "replace")))
|
|
119
|
+
self.headers = headers
|
|
120
|
+
|
|
121
|
+
async def __call__(self, scope, receive, send):
|
|
122
|
+
if scope["type"] != "http":
|
|
123
|
+
await self.app(scope, receive, send)
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
async def send_with_headers(message):
|
|
127
|
+
if message["type"] == "http.response.start":
|
|
128
|
+
present = {name.lower() for name, _ in message.get("headers", [])}
|
|
129
|
+
missing = [(name, value) for name, value in self.headers if name not in present]
|
|
130
|
+
if missing:
|
|
131
|
+
message = {**message, "headers": list(message.get("headers", [])) + missing}
|
|
132
|
+
await send(message)
|
|
133
|
+
|
|
134
|
+
await self.app(scope, receive, send_with_headers)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def apply_performance(performance) -> None:
|
|
138
|
+
"""The pool from ``KeepupSettings.performance`` (keepup-38).
|
|
139
|
+
|
|
140
|
+
The pool may already exist: applications touch the database before
|
|
141
|
+
create_app (their init_db). It is closed then, and the next query opens one
|
|
142
|
+
with the application's values -- safe at start-up, before any request.
|
|
143
|
+
"""
|
|
144
|
+
from keepup_db import DatabaseManagerV2, db_config
|
|
145
|
+
if db_config.apply_pool(performance.db_pool_size, performance.db_pool_max_overflow,
|
|
146
|
+
performance.db_pool_timeout, performance.db_pool_recycle,
|
|
147
|
+
performance.db_pool_ping_after_idle):
|
|
148
|
+
DatabaseManagerV2.dispose()
|
|
149
|
+
from keepup import cache
|
|
150
|
+
cache.set_ttl(performance.catalogue_cache_seconds)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def apply_settings(settings: KeepupSettings) -> None:
|
|
154
|
+
"""Hand each part of the framework the application's values.
|
|
155
|
+
|
|
156
|
+
Done in one place so that what the application supplies is visible as a
|
|
157
|
+
list, rather than as a configure() call hidden in each module's import.
|
|
158
|
+
"""
|
|
159
|
+
performance = settings.performance
|
|
160
|
+
audit.configure(redaction=settings.audit_redaction,
|
|
161
|
+
flush_interval=performance.audit_flush_interval,
|
|
162
|
+
max_size=performance.audit_buffer_size)
|
|
163
|
+
apply_performance(performance)
|
|
164
|
+
web.configure(
|
|
165
|
+
static_dir=settings.static_dir,
|
|
166
|
+
client_page=settings.client_page,
|
|
167
|
+
version_file=settings.version_file,
|
|
168
|
+
favicon_file=settings.favicon_file,
|
|
169
|
+
)
|
|
170
|
+
auth_dependencies.configure_panel_gate(pending=settings.pending_documents)
|
|
171
|
+
if not settings.database_dialect:
|
|
172
|
+
# The framework's configuration is the default; naming the dialect lets
|
|
173
|
+
# the kernel choose a driver without importing a database (keepup-123).
|
|
174
|
+
from keepup_db import db_config as _db_config
|
|
175
|
+
settings.database_dialect = "postgresql" if _db_config.is_postgres() else "sqlite"
|
|
176
|
+
# Who is calling and what they may do, behind the two names the kernel owns
|
|
177
|
+
# (keepup-119). Until keepup-auth is a plugin of its own this is the
|
|
178
|
+
# framework's own sign-in; afterwards it is that plugin's register().
|
|
179
|
+
from keepup_auth.identity import access as identity_access
|
|
180
|
+
from keepup.kernel import observability, security
|
|
181
|
+
# What is written down about a call, behind the two names the kernel owns
|
|
182
|
+
# (keepup-111). Until keepup-audit is a plugin this is the framework's own,
|
|
183
|
+
# and it is built here rather than imported from the plugin file: the
|
|
184
|
+
# framework's own plugins are data of the package, not a module of it.
|
|
185
|
+
from keepup_audit.audit import IncomingRequestLogger, log_api_request
|
|
186
|
+
observability.set_recording(observability.Recording(
|
|
187
|
+
start=log_api_request, end=IncomingRequestLogger.end_request, name="keepup audit"))
|
|
188
|
+
security.set_identity(security.Identity(
|
|
189
|
+
subject_dependency=auth_dependencies.get_panel_user,
|
|
190
|
+
checker=identity_access.check,
|
|
191
|
+
name="keepup sign-in",
|
|
192
|
+
))
|
|
193
|
+
auth_routes.configure(
|
|
194
|
+
password=settings.password_rule,
|
|
195
|
+
login_record=settings.record_login,
|
|
196
|
+
)
|
|
197
|
+
auth_routes.configure_public_config(settings.public_config)
|
|
198
|
+
# The panel's plugin report reads the deployment's own catalogue, wherever
|
|
199
|
+
# the application keeps it -- not the default path (keepup-93).
|
|
200
|
+
try:
|
|
201
|
+
from keepup_modules import decisions as plugins_admin
|
|
202
|
+
|
|
203
|
+
plugins_admin.configure(settings.plugins_config_path)
|
|
204
|
+
except ImportError:
|
|
205
|
+
# The decision about a plugin is the modules capability's (keepup-113);
|
|
206
|
+
# without it the declared file and the environment decide.
|
|
207
|
+
logger.info("keepup-modules is not installed: no plugin overrides to read")
|
|
208
|
+
# The catalogue of panel sections and their grants is seeded from the same
|
|
209
|
+
# file, which until keepup-98 was only the default one.
|
|
210
|
+
modules.configure(settings.plugins_config_path)
|
|
211
|
+
# Themes are declared here and not when the themes module is imported:
|
|
212
|
+
# there is no application at that point and nothing for it to declare --
|
|
213
|
+
# which is exactly why a declared theme never reached the database. The
|
|
214
|
+
# service brings itself up on this call.
|
|
215
|
+
config_service.register_built_in_themes(settings.built_in_themes)
|
|
216
|
+
config_service.initialize()
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class GatedStaticFiles(StaticFiles):
|
|
220
|
+
"""Static files the browser rechecks on every load, some of them gated.
|
|
221
|
+
|
|
222
|
+
Starlette sends ETag and Last-Modified but no Cache-Control. Without one a
|
|
223
|
+
browser falls back to heuristic freshness -- roughly a tenth of the file's
|
|
224
|
+
age -- and stops asking the server at all, so an edited module keeps
|
|
225
|
+
serving its old copy until a hard reload. `no-cache` still permits caching;
|
|
226
|
+
it only requires revalidation, which the ETag answers with an empty 304.
|
|
227
|
+
|
|
228
|
+
A gated page is served only while the plugin behind it runs: a page that
|
|
229
|
+
says a payment succeeded, reachable by its address on a deployment with no
|
|
230
|
+
payments, says something untrue.
|
|
231
|
+
"""
|
|
232
|
+
|
|
233
|
+
def __init__(self, *args, gated_pages=None, plugin_manager=None, **kwargs):
|
|
234
|
+
super().__init__(*args, **kwargs)
|
|
235
|
+
self.gated_pages = gated_pages or {}
|
|
236
|
+
self.plugin_manager = plugin_manager
|
|
237
|
+
|
|
238
|
+
def file_response(self, *args, **kwargs):
|
|
239
|
+
response = super().file_response(*args, **kwargs)
|
|
240
|
+
response.headers.setdefault("Cache-Control", "no-cache")
|
|
241
|
+
return response
|
|
242
|
+
|
|
243
|
+
async def get_response(self, path, scope):
|
|
244
|
+
plugin_id = self.gated_pages.get(str(path).lstrip("/"))
|
|
245
|
+
if plugin_id and not self._plugin_running(plugin_id):
|
|
246
|
+
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
247
|
+
raise StarletteHTTPException(status_code=404)
|
|
248
|
+
return await super().get_response(path, scope)
|
|
249
|
+
|
|
250
|
+
def _plugin_running(self, plugin_id: str) -> bool:
|
|
251
|
+
plugin = self.plugin_manager.get_plugin(plugin_id) if self.plugin_manager else None
|
|
252
|
+
return bool(plugin and getattr(plugin, "initialized", False))
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _build_lifespan(settings: KeepupSettings):
|
|
256
|
+
"""The start-up and shutdown of one application built from these settings."""
|
|
257
|
+
|
|
258
|
+
manager = settings.plugin_manager
|
|
259
|
+
|
|
260
|
+
@asynccontextmanager
|
|
261
|
+
async def lifespan(app: FastAPI):
|
|
262
|
+
require_signing_key()
|
|
263
|
+
|
|
264
|
+
# The plugin catalogue, raised beside the path this application already
|
|
265
|
+
# has (keepup-123). Everything the runtime does is additive: it resolves
|
|
266
|
+
# the catalogue and the profile, publishes the services a capability
|
|
267
|
+
# offers and creates the tables a capability declares. A deployment whose
|
|
268
|
+
# catalogue cannot start -- a required plugin missing, two providers of
|
|
269
|
+
# one service -- is reported and the application boots anyway, because
|
|
270
|
+
# 0.3.0 deployments reach this code with a catalogue that never existed.
|
|
271
|
+
from keepup.kernel import create_runtime
|
|
272
|
+
|
|
273
|
+
kernel = create_runtime(settings)
|
|
274
|
+
try:
|
|
275
|
+
await kernel.start()
|
|
276
|
+
except Exception as error: # noqa: BLE001 - a catalogue is not the application
|
|
277
|
+
logger.warning("The plugin catalogue did not start: %s", error)
|
|
278
|
+
kernel = None
|
|
279
|
+
app.state.kernel = kernel
|
|
280
|
+
|
|
281
|
+
# Shipping is loaded only for an application that names a collector
|
|
282
|
+
# (keepup/log_shipping.py, keepup-24).
|
|
283
|
+
# Table creation is a round of statements against the database; off the
|
|
284
|
+
# loop, where the tasks started above already run (keepup-54).
|
|
285
|
+
await asyncio.to_thread(init_incoming_requests_table)
|
|
286
|
+
|
|
287
|
+
# The catalogue the application named reaches the panel here as well as
|
|
288
|
+
# The catalogue is imported by the capability that owns it, on the
|
|
289
|
+
# freeze (keepup-113): it is the union of the framework's own sections,
|
|
290
|
+
# the application's file and what the running plugins declare, and the
|
|
291
|
+
# last of those is only known once every plugin has been loaded.
|
|
292
|
+
# The four loops the framework used to start here -- the audit buffer,
|
|
293
|
+
# the audit sweep, the event log's sweep and the socket sweep -- belong
|
|
294
|
+
# to the capabilities that own them and start with their plugins
|
|
295
|
+
# (keepup-109). The kernel starts none of them.
|
|
296
|
+
event_manager = await asyncio.to_thread(init_event_manager)
|
|
297
|
+
if event_manager:
|
|
298
|
+
logger.info("Event manager started successfully")
|
|
299
|
+
else:
|
|
300
|
+
logger.warning("Event manager failed to start")
|
|
301
|
+
|
|
302
|
+
register_event_api_routes(app, declared_event_types=settings.declared_event_types)
|
|
303
|
+
|
|
304
|
+
# Before on_startup and the plugins: both subscribe to the bus while
|
|
305
|
+
# initialising, and a bus created after them would have no subscribers.
|
|
306
|
+
bus = await _create_notification_bus(settings.notification_channel)
|
|
307
|
+
|
|
308
|
+
# Before the plugins: a plugin subscribes to the application's bus
|
|
309
|
+
# while initialising, and a bus created after it would have no
|
|
310
|
+
# subscribers.
|
|
311
|
+
if settings.on_startup is not None:
|
|
312
|
+
await settings.on_startup(app)
|
|
313
|
+
|
|
314
|
+
if manager is not None:
|
|
315
|
+
await initialize_plugins(app, manager, config_path=settings.plugins_config_path)
|
|
316
|
+
|
|
317
|
+
# The log handlers the running capabilities contribute -- the shipping
|
|
318
|
+
# one, when a collector is named (keepup-118). A deployment that
|
|
319
|
+
# installs none attaches nothing.
|
|
320
|
+
attached_handlers = _attach_log_handlers(kernel)
|
|
321
|
+
|
|
322
|
+
# The delivery between replicas is the database driver's (keepup-117):
|
|
323
|
+
# the bus existed before the plugins, and takes its transport once they
|
|
324
|
+
# have all registered the services they publish.
|
|
325
|
+
await _start_notification_bus(bus, kernel)
|
|
326
|
+
|
|
327
|
+
# The scheduler belongs to the tasks capability (keepup-109) and is
|
|
328
|
+
# running by the time this line is reached: it starts with its plugin
|
|
329
|
+
# (priority 80), before the cluster (85), which is the order a replica
|
|
330
|
+
# an administrator stopped needs -- it comes back stopped, and stopping
|
|
331
|
+
# pauses the scheduler.
|
|
332
|
+
# This replica's controller is the cluster capability's now (keepup-117):
|
|
333
|
+
# it is built and launched with that plugin, which starts after this
|
|
334
|
+
# line because it only wants the scheduler (80 against 85).
|
|
335
|
+
if manager is not None:
|
|
336
|
+
asyncio.create_task(run_post_construct_processors(manager))
|
|
337
|
+
|
|
338
|
+
# Whatever start-up read into the theme and section caches, it may have
|
|
339
|
+
# changed since: an application's one-off changesets run after the
|
|
340
|
+
# framework's schema and can switch the active theme or take a section
|
|
341
|
+
# from a role. The first request reads afresh.
|
|
342
|
+
from keepup import cache
|
|
343
|
+
cache.invalidate_all()
|
|
344
|
+
|
|
345
|
+
yield
|
|
346
|
+
|
|
347
|
+
_detach_log_handlers(attached_handlers)
|
|
348
|
+
|
|
349
|
+
if kernel is not None:
|
|
350
|
+
# The services a capability published are closed with the process
|
|
351
|
+
# that published them (keepup-106).
|
|
352
|
+
await kernel.stop()
|
|
353
|
+
|
|
354
|
+
if manager is not None:
|
|
355
|
+
await manager.cleanup_all()
|
|
356
|
+
|
|
357
|
+
if settings.on_shutdown is not None:
|
|
358
|
+
await settings.on_shutdown(app)
|
|
359
|
+
|
|
360
|
+
await identity_runtime.shutdown()
|
|
361
|
+
|
|
362
|
+
await _stop_notification_bus(bus)
|
|
363
|
+
|
|
364
|
+
return lifespan
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _attach_log_handlers(kernel):
|
|
368
|
+
"""Attach what the running capabilities contribute to the application's log.
|
|
369
|
+
|
|
370
|
+
The contribution is the contract (keepup-118): the capability builds the
|
|
371
|
+
handler and says so, and the application -- which owns the root logger --
|
|
372
|
+
attaches it. Until `keepup-http` mounts contributions itself this is the
|
|
373
|
+
bridge, and it has the same shape as the routes one (keepup-121).
|
|
374
|
+
"""
|
|
375
|
+
contributions = getattr(kernel, "contributions", None)
|
|
376
|
+
handlers = list(contributions.of("logging")) if contributions is not None else []
|
|
377
|
+
root = logging.getLogger()
|
|
378
|
+
for handler in handlers:
|
|
379
|
+
root.addHandler(handler)
|
|
380
|
+
if handlers:
|
|
381
|
+
logging.getLogger(__name__).info("Attached %s contributed log handler(s)",
|
|
382
|
+
len(handlers))
|
|
383
|
+
return handlers
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _detach_log_handlers(handlers):
|
|
387
|
+
"""Take them off again: a second application in one process must not double-log."""
|
|
388
|
+
root = logging.getLogger()
|
|
389
|
+
for handler in handlers or []:
|
|
390
|
+
root.removeHandler(handler)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
async def _create_notification_bus(channel):
|
|
394
|
+
"""Install the replicas' bus on the application's channel, if it named one.
|
|
395
|
+
|
|
396
|
+
Created before the plugins and started after them: a plugin subscribes to the
|
|
397
|
+
bus while it initialises, and the transport that carries an envelope to the
|
|
398
|
+
other replicas is a service only the drivers publish (keepup-117).
|
|
399
|
+
"""
|
|
400
|
+
if not channel:
|
|
401
|
+
return None
|
|
402
|
+
bus = notification_bus.NotificationBus(get_instance_id(), channel)
|
|
403
|
+
notification_bus.set_notification_bus(bus)
|
|
404
|
+
# The other replicas' changes to the theme and the section catalogue.
|
|
405
|
+
from keepup import cache
|
|
406
|
+
cache.attach_to_bus(bus)
|
|
407
|
+
socket_sessions.attach_to_bus(bus)
|
|
408
|
+
return bus
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
async def _start_notification_bus(bus, kernel=None):
|
|
412
|
+
"""Give the bus the delivery its driver published, and start listening."""
|
|
413
|
+
if bus is None:
|
|
414
|
+
return None
|
|
415
|
+
services = getattr(kernel, "services", None)
|
|
416
|
+
if services is not None and services.has(notification_bus.SERVICE_NOTIFY_TRANSPORT):
|
|
417
|
+
bus.set_transport(services.require(notification_bus.SERVICE_NOTIFY_TRANSPORT))
|
|
418
|
+
await bus.start()
|
|
419
|
+
return bus
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
async def _stop_notification_bus(bus):
|
|
423
|
+
if bus is None:
|
|
424
|
+
return
|
|
425
|
+
await bus.stop()
|
|
426
|
+
socket_sessions.detach_from_bus()
|
|
427
|
+
if notification_bus.get_notification_bus() is bus:
|
|
428
|
+
notification_bus.set_notification_bus(None)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def create_app(settings: KeepupSettings = None) -> FastAPI:
|
|
438
|
+
"""Build an application from these settings.
|
|
439
|
+
|
|
440
|
+
The plugin manager, when the settings carry one, is left on
|
|
441
|
+
``app.state.plugin_manager``: the entry point re-exports it, because that
|
|
442
|
+
is the object plugins reach for.
|
|
443
|
+
"""
|
|
444
|
+
settings = settings or KeepupSettings()
|
|
445
|
+
apply_settings(settings)
|
|
446
|
+
# Before any route exists: a provider that cannot be built stops the start
|
|
447
|
+
# here, and one that can is what the routes below reach (keepup-91).
|
|
448
|
+
identity_runtime.configure(settings.identity_provider)
|
|
449
|
+
|
|
450
|
+
# /docs belongs to the public gateway reference, so the interactive API docs
|
|
451
|
+
# live under /api. FastAPI's own schema and pages are switched off: the
|
|
452
|
+
# schema describes every route and FastAPI would hand it to anybody. The
|
|
453
|
+
# framework registers its own right here, ahead of any other route, which is
|
|
454
|
+
# the place FastAPI's constructor gave them (keepup/api_docs.py, keepup-96).
|
|
455
|
+
app = FastAPI(
|
|
456
|
+
title=settings.title,
|
|
457
|
+
lifespan=_build_lifespan(settings),
|
|
458
|
+
docs_url=None,
|
|
459
|
+
redoc_url=None,
|
|
460
|
+
openapi_url=None,
|
|
461
|
+
)
|
|
462
|
+
register_api_documentation(app, settings)
|
|
463
|
+
app.state.settings = settings
|
|
464
|
+
app.state.plugin_manager = settings.plugin_manager
|
|
465
|
+
|
|
466
|
+
for mount in settings.static_mounts:
|
|
467
|
+
app.mount(
|
|
468
|
+
mount.path,
|
|
469
|
+
GatedStaticFiles(
|
|
470
|
+
directory=mount.directory,
|
|
471
|
+
gated_pages=settings.gated_pages,
|
|
472
|
+
plugin_manager=settings.plugin_manager,
|
|
473
|
+
),
|
|
474
|
+
name=mount.name,
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
# The package's own shell, always mounted and never from the working
|
|
478
|
+
# directory: an installed keepup renders its panel out of its own files,
|
|
479
|
+
# and an application that ships none still gets one (keepup-3).
|
|
480
|
+
app.mount(
|
|
481
|
+
settings.shell_mount,
|
|
482
|
+
GatedStaticFiles(
|
|
483
|
+
directory=web.SHELL_DIR,
|
|
484
|
+
gated_pages={},
|
|
485
|
+
plugin_manager=None,
|
|
486
|
+
),
|
|
487
|
+
name="keepup-shell",
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
# Empty unless the application named origins: see KeepupSettings.
|
|
491
|
+
app.add_middleware(
|
|
492
|
+
CORSMiddleware,
|
|
493
|
+
allow_origins=list(settings.cors_origins),
|
|
494
|
+
# The panel session is a cookie now. With credentials allowed, Starlette
|
|
495
|
+
# answers a request carrying cookies with that request's own origin, and
|
|
496
|
+
# any site could read the panel's answers in the user's name.
|
|
497
|
+
allow_credentials=False,
|
|
498
|
+
allow_methods=["*"],
|
|
499
|
+
allow_headers=["*"],
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
if settings.security_headers:
|
|
503
|
+
app.add_middleware(SecurityHeadersMiddleware,
|
|
504
|
+
https=os.getenv("SSL_ENABLED", "false").lower() == "true",
|
|
505
|
+
csp=settings.content_security_policy,
|
|
506
|
+
csp_report_only=settings.csp_report_only)
|
|
507
|
+
|
|
508
|
+
# A panel signed in before the CSRF value was bound to the session gets the
|
|
509
|
+
# bound one on its next read (keepup/auth/panel_session.py, keepup-72).
|
|
510
|
+
app.add_middleware(panel_session.CsrfCookieRefresh)
|
|
511
|
+
|
|
512
|
+
# A replica an administrator stopped refuses the API (keepup/cluster.py).
|
|
513
|
+
# Added before the version middleware, so it runs inside it and sees the
|
|
514
|
+
# registered path.
|
|
515
|
+
app.add_middleware(cluster.StoppedReplicaGate)
|
|
516
|
+
|
|
517
|
+
# The largest body a route accepts, checked before the route reads it
|
|
518
|
+
# (keepup/body_limit.py). Inside the version middleware, so it matches the
|
|
519
|
+
# registered path; it finds the route itself, before routing, since a body
|
|
520
|
+
# read by the route is a body already received.
|
|
521
|
+
from keepup_http.body_limit import BodyLimitMiddleware
|
|
522
|
+
app.add_middleware(BodyLimitMiddleware, router_of=app,
|
|
523
|
+
default_limit=(settings.max_upload_bytes
|
|
524
|
+
if settings.max_upload_bytes is not None
|
|
525
|
+
else settings.max_json_bytes))
|
|
526
|
+
|
|
527
|
+
# Versioned paths (/api/v1, /ws/v1) reach the same handlers; added last, so it
|
|
528
|
+
# runs first and every middleware and route below sees the registered path.
|
|
529
|
+
app.add_middleware(ApiVersionMiddleware)
|
|
530
|
+
|
|
531
|
+
if os.getenv('SSL_ENABLED', 'false').lower() == 'true' \
|
|
532
|
+
and os.getenv('FORCE_HTTPS', 'false').lower() == 'true':
|
|
533
|
+
from fastapi.middleware.httpsredirect import HTTPSRedirectMiddleware
|
|
534
|
+
|
|
535
|
+
app.add_middleware(HTTPSRedirectMiddleware)
|
|
536
|
+
logger.info("HTTPS redirect middleware enabled - HTTP requests will be redirected to HTTPS")
|
|
537
|
+
|
|
538
|
+
register_web_routes(app)
|
|
539
|
+
register_module_routes(app)
|
|
540
|
+
# Registered only when the application configured a provider; without one
|
|
541
|
+
# the paths do not exist at all.
|
|
542
|
+
register_oidc_routes(app, settings)
|
|
543
|
+
# The transport that mounts what a capability declares is keepup-http, and
|
|
544
|
+
# until it exists the application registers this capability's routes itself
|
|
545
|
+
# -- the same bridge the other capabilities still travel (keepup-121).
|
|
546
|
+
register_metrics_routes(app, public=settings.metrics_public)
|
|
547
|
+
register_lock_routes(app)
|
|
548
|
+
register_scheduler_routes(app)
|
|
549
|
+
cluster.register_cluster_routes(app)
|
|
550
|
+
register_theme_routes(app, config_service)
|
|
551
|
+
# Sign-in is registered whatever the application: one without plugins used
|
|
552
|
+
# to get a panel nobody could sign into (keepup-89). What needs plugins --
|
|
553
|
+
# telling them an account was blocked -- has none to tell then.
|
|
554
|
+
auth_routes.register_auth_routes(app, settings.plugin_manager)
|
|
555
|
+
if settings.plugin_manager is not None:
|
|
556
|
+
try:
|
|
557
|
+
register_plugin_admin_routes(app, settings.plugin_manager)
|
|
558
|
+
except ImportError:
|
|
559
|
+
# The plugin report belongs to the modules capability (keepup-113).
|
|
560
|
+
logger.info("keepup-modules is not installed: no plugin report in the panel")
|
|
561
|
+
|
|
562
|
+
return app
|