py-app-runner 0.5.49.dev0__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.
- py_app_runner/__init__.py +11 -0
- py_app_runner/audit/__init__.py +29 -0
- py_app_runner/audit/_service.py +91 -0
- py_app_runner/audit/_service_args.py +44 -0
- py_app_runner/audit/audit.py +319 -0
- py_app_runner/audit/commands.py +151 -0
- py_app_runner/audit/diff.py +202 -0
- py_app_runner/audit/errors.py +8 -0
- py_app_runner/audit/event.py +130 -0
- py_app_runner/audit/store.py +134 -0
- py_app_runner/bridge/__init__.py +0 -0
- py_app_runner/bridge/_service.py +265 -0
- py_app_runner/bridge/_service_args.py +24 -0
- py_app_runner/bridge/api.py +138 -0
- py_app_runner/bridge/encoders/__init__.py +5 -0
- py_app_runner/bridge/encoders/base.py +24 -0
- py_app_runner/bridge/encoders/json_encoder.py +26 -0
- py_app_runner/bridge/encoders/msgpack_encoder.py +58 -0
- py_app_runner/bridge/web_app.py +31 -0
- py_app_runner/bridge/websocket.py +313 -0
- py_app_runner/colors.py +73 -0
- py_app_runner/config.py +132 -0
- py_app_runner/crypto/__init__.py +14 -0
- py_app_runner/crypto/_service.py +75 -0
- py_app_runner/crypto/_service_args.py +54 -0
- py_app_runner/crypto/commands.py +164 -0
- py_app_runner/crypto/envelope.py +144 -0
- py_app_runner/crypto/errors.py +8 -0
- py_app_runner/crypto/fields.py +300 -0
- py_app_runner/crypto/passwords.py +66 -0
- py_app_runner/db_pools.py +20 -0
- py_app_runner/http_exception.py +31 -0
- py_app_runner/logger_handlers.py +167 -0
- py_app_runner/migrations/__init__.py +5 -0
- py_app_runner/migrations/_service.py +296 -0
- py_app_runner/migrations/_service_args.py +91 -0
- py_app_runner/migrations/commands.py +386 -0
- py_app_runner/migrations/discovery.py +108 -0
- py_app_runner/migrations/states.py +63 -0
- py_app_runner/migrations/tracker.py +141 -0
- py_app_runner/py.typed +0 -0
- py_app_runner/pybridge.py +64 -0
- py_app_runner/queue/__init__.py +25 -0
- py_app_runner/queue/_service.py +231 -0
- py_app_runner/queue/_service_args.py +67 -0
- py_app_runner/queue/commands.py +180 -0
- py_app_runner/queue/driver_pg.py +464 -0
- py_app_runner/queue/driver_redis.py +613 -0
- py_app_runner/queue/handler.py +90 -0
- py_app_runner/queue/interface.py +63 -0
- py_app_runner/queue/job.py +46 -0
- py_app_runner/queue/worker.py +221 -0
- py_app_runner/registry.py +54 -0
- py_app_runner/request_handler/__init__.py +0 -0
- py_app_runner/request_handler/auth_service.py +123 -0
- py_app_runner/request_handler/decorators.py +304 -0
- py_app_runner/request_handler/handlers.py +604 -0
- py_app_runner/request_handler/pagination.py +24 -0
- py_app_runner/return_model.py +78 -0
- py_app_runner/runner.py +182 -0
- py_app_runner/throttle/__init__.py +5 -0
- py_app_runner/throttle/throttle.py +217 -0
- py_app_runner/tick_service.py +308 -0
- py_app_runner/timer.py +289 -0
- py_app_runner/utils.py +346 -0
- py_app_runner/wbcm/__init__.py +0 -0
- py_app_runner/wbcm/device_connections.py +89 -0
- py_app_runner/wbcm/factory.py +113 -0
- py_app_runner/wbcm/wb_connection_manager.py +333 -0
- py_app_runner/wbcm/ws_interface.py +56 -0
- py_app_runner-0.5.49.dev0.dist-info/METADATA +134 -0
- py_app_runner-0.5.49.dev0.dist-info/RECORD +75 -0
- py_app_runner-0.5.49.dev0.dist-info/WHEEL +5 -0
- py_app_runner-0.5.49.dev0.dist-info/licenses/LICENSE +21 -0
- py_app_runner-0.5.49.dev0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import uuid
|
|
4
|
+
from asyncio import Future, get_event_loop
|
|
5
|
+
from typing import Any
|
|
6
|
+
from urllib.parse import urlparse
|
|
7
|
+
|
|
8
|
+
from tornado.websocket import WebSocketHandler as TornadoWebSocketHandler
|
|
9
|
+
|
|
10
|
+
from py_app_runner.bridge.encoders import JsonEncoder, MessageEncoder, MsgpackEncoder
|
|
11
|
+
from py_app_runner.config import is_env_prod
|
|
12
|
+
from py_app_runner.http_exception import HTTPException
|
|
13
|
+
from py_app_runner.registry import AppRegistry
|
|
14
|
+
from py_app_runner.request_handler.handlers import RequestHandlerApiKeys
|
|
15
|
+
from py_app_runner.return_model import MessageModel, ReturnModel, StatusModel
|
|
16
|
+
from py_app_runner.utils import json_encode
|
|
17
|
+
from py_app_runner.wbcm.ws_interface import WebSocketHandlerInterface
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def allowed_ws_origins() -> set[str]:
|
|
21
|
+
"""Origins accepted for WebSocket upgrades.
|
|
22
|
+
|
|
23
|
+
Read on demand rather than at import time: the value comes from the app config
|
|
24
|
+
(with an env fallback), and neither is populated when this module is imported.
|
|
25
|
+
An empty set means "accept any origin".
|
|
26
|
+
"""
|
|
27
|
+
configured = AppRegistry.config().get("ws_allowed_origins")
|
|
28
|
+
if configured is None:
|
|
29
|
+
configured = os.environ.get("WS_ALLOWED_ORIGINS", "")
|
|
30
|
+
|
|
31
|
+
if isinstance(configured, str):
|
|
32
|
+
configured = configured.split(",")
|
|
33
|
+
|
|
34
|
+
return {str(o).strip().lower().rstrip("/") for o in configured if str(o).strip()}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# * BaseWebSocketHandler - protocol-agnostic WebSocket handler
|
|
38
|
+
class BaseWebSocketHandler(RequestHandlerApiKeys, TornadoWebSocketHandler, WebSocketHandlerInterface):
|
|
39
|
+
"""Base WebSocket request handler with pluggable encoding."""
|
|
40
|
+
|
|
41
|
+
encoder: MessageEncoder
|
|
42
|
+
|
|
43
|
+
#########################
|
|
44
|
+
### Class lifecycle #####
|
|
45
|
+
#########################
|
|
46
|
+
|
|
47
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
48
|
+
logger_name = f"{__name__}.{self.__class__.__name__}"
|
|
49
|
+
self.logger = logging.getLogger(logger_name)
|
|
50
|
+
|
|
51
|
+
self.loop = get_event_loop()
|
|
52
|
+
|
|
53
|
+
self.uid = None
|
|
54
|
+
self.msg_id = None
|
|
55
|
+
self.service = None
|
|
56
|
+
self.current_session_sid = None
|
|
57
|
+
self.device_id: str | None = None
|
|
58
|
+
self._api_key_valid: bool | None = None
|
|
59
|
+
self._rejected_api_key: str | None = None
|
|
60
|
+
|
|
61
|
+
super().__init__(*args, **kwargs)
|
|
62
|
+
|
|
63
|
+
###############
|
|
64
|
+
### Helpers ###
|
|
65
|
+
###############
|
|
66
|
+
|
|
67
|
+
def error_message(self, msg: str | HTTPException, code: int = -1, **kwargs: Any):
|
|
68
|
+
error: dict[str, Any]
|
|
69
|
+
if isinstance(msg, HTTPException):
|
|
70
|
+
error = msg.to_dict()
|
|
71
|
+
else:
|
|
72
|
+
error = {"code": code, "msg": msg}
|
|
73
|
+
|
|
74
|
+
message: dict[str, Any] = {"error": error}
|
|
75
|
+
message.update(kwargs)
|
|
76
|
+
self.write_custom_message(message)
|
|
77
|
+
|
|
78
|
+
#################
|
|
79
|
+
### Overrides ###
|
|
80
|
+
#################
|
|
81
|
+
|
|
82
|
+
def write_custom_message(
|
|
83
|
+
self,
|
|
84
|
+
message: bytes | str | dict[str, Any] | list[Any],
|
|
85
|
+
wrap_in_data: bool = True,
|
|
86
|
+
**kwargs: Any,
|
|
87
|
+
) -> Future[None]:
|
|
88
|
+
if isinstance(message, (StatusModel, MessageModel, ReturnModel)):
|
|
89
|
+
message = message.to_dict()
|
|
90
|
+
if wrap_in_data:
|
|
91
|
+
message = {"data": message}
|
|
92
|
+
|
|
93
|
+
response_message: str | bytes
|
|
94
|
+
if isinstance(message, dict):
|
|
95
|
+
# Add service
|
|
96
|
+
if self.service and "service" not in message:
|
|
97
|
+
message["service"] = self.service
|
|
98
|
+
|
|
99
|
+
# Add msg_id
|
|
100
|
+
if self.msg_id and "msg_id" not in message:
|
|
101
|
+
message["msg_id"] = self.msg_id
|
|
102
|
+
|
|
103
|
+
# Add additional fields
|
|
104
|
+
message.update(kwargs)
|
|
105
|
+
|
|
106
|
+
# Encode message using the pluggable encoder
|
|
107
|
+
response_message = self.encoder.encode(message)
|
|
108
|
+
|
|
109
|
+
elif isinstance(message, (str, bytes)):
|
|
110
|
+
# Already-encoded payload, pass through untouched
|
|
111
|
+
response_message = message
|
|
112
|
+
|
|
113
|
+
else:
|
|
114
|
+
response_message = self.encoder.encode(message) # type: ignore[arg-type]
|
|
115
|
+
|
|
116
|
+
if self.ws_connection is not None and self.ws_connection.is_closing() is False:
|
|
117
|
+
return super().write_message(response_message, binary=self.encoder.is_binary) # type: ignore
|
|
118
|
+
|
|
119
|
+
# Resolved, not dangling: callers may await the result.
|
|
120
|
+
dropped: Future[None] = Future()
|
|
121
|
+
dropped.set_result(None)
|
|
122
|
+
return dropped
|
|
123
|
+
|
|
124
|
+
########################
|
|
125
|
+
### Request handling ###
|
|
126
|
+
########################
|
|
127
|
+
|
|
128
|
+
async def reload_token(self, new_auth_token: str) -> None:
|
|
129
|
+
old_auth_token = self.auth_token
|
|
130
|
+
if old_auth_token != new_auth_token:
|
|
131
|
+
self.auth_token = new_auth_token
|
|
132
|
+
await self._ensure_current_user()
|
|
133
|
+
|
|
134
|
+
def check_origin(self, origin: str) -> bool:
|
|
135
|
+
allowed = allowed_ws_origins()
|
|
136
|
+
if not allowed:
|
|
137
|
+
if is_env_prod():
|
|
138
|
+
self.logger.warning(
|
|
139
|
+
"ws_allowed_origins is not configured; accepting WebSocket upgrade from origin %s."
|
|
140
|
+
" Set it to prevent cross-site WebSocket hijacking.",
|
|
141
|
+
origin,
|
|
142
|
+
)
|
|
143
|
+
return True
|
|
144
|
+
|
|
145
|
+
normalized = origin.lower().rstrip("/")
|
|
146
|
+
if normalized in allowed:
|
|
147
|
+
return True
|
|
148
|
+
|
|
149
|
+
# Also check just the scheme + host (ignoring path)
|
|
150
|
+
parsed = urlparse(normalized)
|
|
151
|
+
origin_host = f"{parsed.scheme}://{parsed.netloc}" if parsed.scheme and parsed.netloc else normalized
|
|
152
|
+
return origin_host in allowed
|
|
153
|
+
|
|
154
|
+
async def open(self, *args: str, **kwargs: str) -> None:
|
|
155
|
+
# Cache the connection
|
|
156
|
+
self.uid = str(uuid.uuid4())
|
|
157
|
+
self.wb_connection_manager.user_connections.add_connection(self)
|
|
158
|
+
|
|
159
|
+
self.logger.debug(
|
|
160
|
+
"WS open uid=%s remote_ip=%s origin=%s",
|
|
161
|
+
self.uid,
|
|
162
|
+
self.request.remote_ip,
|
|
163
|
+
self.request.headers.get("Origin", "<none>"),
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
# Validate API key from query param or header at connection time
|
|
167
|
+
status = await self.has_valid_api_key()
|
|
168
|
+
if status is True:
|
|
169
|
+
self._api_key_valid = True
|
|
170
|
+
elif status == "API key is missing":
|
|
171
|
+
self._api_key_valid = None
|
|
172
|
+
else:
|
|
173
|
+
self._api_key_valid = False
|
|
174
|
+
self.logger.debug("WS closing uid=%s reason=api_key_failed status=%s", self.uid, status)
|
|
175
|
+
self.close(4401, f"API key validation failed: {status}")
|
|
176
|
+
|
|
177
|
+
async def on_message(self, message: str | bytes) -> None:
|
|
178
|
+
self.logger.debug(f"Received message ({self.encoder.name}): {message!r}")
|
|
179
|
+
try:
|
|
180
|
+
message_data = self.encoder.decode(message)
|
|
181
|
+
except Exception:
|
|
182
|
+
msg_str = "It was impossible to parse the given data"
|
|
183
|
+
self.logger.exception(f"Error: {msg_str}\nData: {message!r}")
|
|
184
|
+
self.error_message(msg_str, code=1000)
|
|
185
|
+
return
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
# Assign message id
|
|
189
|
+
msg_id = message_data.get("msg_id", None)
|
|
190
|
+
service = message_data.get("service", None)
|
|
191
|
+
|
|
192
|
+
if not msg_id or not service:
|
|
193
|
+
raise HTTPException(
|
|
194
|
+
f"Missing input data; Data received: {json_encode(message_data, pretty=True)}",
|
|
195
|
+
code=1001,
|
|
196
|
+
http_status=400,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
self.msg_id = int(msg_id)
|
|
200
|
+
self.service = service
|
|
201
|
+
res = self.find_service(service_name=service)
|
|
202
|
+
if not res.result:
|
|
203
|
+
raise HTTPException(
|
|
204
|
+
f"Could not find service by provided name: {self.service}",
|
|
205
|
+
code=1002,
|
|
206
|
+
http_status=400,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
bridge_request, requires_api_key = res.result
|
|
210
|
+
if not bridge_request:
|
|
211
|
+
raise HTTPException(f"Service not found: {self.service}", code=1003, http_status=400)
|
|
212
|
+
|
|
213
|
+
# Check cached API key validation from connection handshake;
|
|
214
|
+
# fall back to in-message api_key (browsers cannot send custom headers on WS)
|
|
215
|
+
if requires_api_key is not False:
|
|
216
|
+
if self._api_key_valid is None:
|
|
217
|
+
in_msg_api_key = message_data.get("api_key", None)
|
|
218
|
+
# Remember only the last rejected key, so retrying the same bad key
|
|
219
|
+
# costs nothing while a corrected one can still be accepted.
|
|
220
|
+
if in_msg_api_key and in_msg_api_key != self._rejected_api_key:
|
|
221
|
+
status = await self.has_valid_api_key(in_msg_api_key)
|
|
222
|
+
if status is True:
|
|
223
|
+
self._api_key_valid = True
|
|
224
|
+
else:
|
|
225
|
+
self._rejected_api_key = in_msg_api_key
|
|
226
|
+
|
|
227
|
+
if self._api_key_valid is not True:
|
|
228
|
+
raise HTTPException(
|
|
229
|
+
"Application is not authenticated: API key is missing or invalid",
|
|
230
|
+
code=1401,
|
|
231
|
+
http_status=401,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
# Set auth token
|
|
235
|
+
auth_token = message_data.get("auth_token", None)
|
|
236
|
+
if auth_token:
|
|
237
|
+
await self.reload_token(auth_token)
|
|
238
|
+
|
|
239
|
+
# Device session token auth
|
|
240
|
+
device_session_token = message_data.get("device_session_token", None)
|
|
241
|
+
if device_session_token and not self.device_id:
|
|
242
|
+
await self._authenticate_device(device_session_token)
|
|
243
|
+
|
|
244
|
+
# Timer
|
|
245
|
+
async with self.timer.aenter("bridge.websockets.on_message.runRequestHandler"):
|
|
246
|
+
req_data = message_data.get("data", {})
|
|
247
|
+
action = req_data.get("action", None)
|
|
248
|
+
input_data = req_data.get("data", {})
|
|
249
|
+
|
|
250
|
+
if not action:
|
|
251
|
+
raise HTTPException("Missing action", code=1005, http_status=400)
|
|
252
|
+
|
|
253
|
+
# Run request handler
|
|
254
|
+
return_data = await bridge_request(action, input_data, self)
|
|
255
|
+
if return_data is not None:
|
|
256
|
+
self.write_custom_message(return_data)
|
|
257
|
+
|
|
258
|
+
except HTTPException as e:
|
|
259
|
+
self.log_request(error=e)
|
|
260
|
+
self.error_message(str(e.message), e.code or -1)
|
|
261
|
+
return
|
|
262
|
+
|
|
263
|
+
except Exception as e:
|
|
264
|
+
self.log_request(error=e)
|
|
265
|
+
self.logger.exception(f"Error processing request with exception: {e}")
|
|
266
|
+
self.error_message(
|
|
267
|
+
"Found an error while processing your request. Please try again later."
|
|
268
|
+
" Send us a message if the problem persists."
|
|
269
|
+
)
|
|
270
|
+
return
|
|
271
|
+
|
|
272
|
+
finally:
|
|
273
|
+
self.msg_id = None
|
|
274
|
+
self.service = None
|
|
275
|
+
|
|
276
|
+
self.timer.print_timer_stats()
|
|
277
|
+
self.timer.reset_timers()
|
|
278
|
+
|
|
279
|
+
async def _authenticate_device(self, session_token: str) -> None:
|
|
280
|
+
"""Validate a device session token and register the device connection.
|
|
281
|
+
Override in subclasses to implement project-specific device authentication."""
|
|
282
|
+
pass
|
|
283
|
+
|
|
284
|
+
def on_close(self):
|
|
285
|
+
self.logger.debug(
|
|
286
|
+
"WS close uid=%s code=%s reason=%s",
|
|
287
|
+
self.uid,
|
|
288
|
+
self.close_code,
|
|
289
|
+
self.close_reason or "<none>",
|
|
290
|
+
)
|
|
291
|
+
if self.uid:
|
|
292
|
+
self.wb_connection_manager.user_connections.remove_connection(self)
|
|
293
|
+
if self.device_id:
|
|
294
|
+
self.wb_connection_manager.device_connections.remove_connection(self)
|
|
295
|
+
self.device_id = None
|
|
296
|
+
self.uid = None
|
|
297
|
+
self.auth_token = None
|
|
298
|
+
self.current_user = None
|
|
299
|
+
self.current_session_sid = None
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
# * WebSocketHandler - JSON WebSocket handler (backward compatible)
|
|
303
|
+
class WebSocketHandler(BaseWebSocketHandler):
|
|
304
|
+
"""JSON WebSocket handler at /v1/socket."""
|
|
305
|
+
|
|
306
|
+
encoder = JsonEncoder()
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
# * MsgpackWebSocketHandler - MessagePack WebSocket handler
|
|
310
|
+
class MsgpackWebSocketHandler(BaseWebSocketHandler):
|
|
311
|
+
"""MessagePack WebSocket handler at /v1/socket/msgpack."""
|
|
312
|
+
|
|
313
|
+
encoder = MsgpackEncoder()
|
py_app_runner/colors.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
class Colors:
|
|
2
|
+
# Reset
|
|
3
|
+
ColorOff = "\033[0m" # Text Reset
|
|
4
|
+
|
|
5
|
+
# Regular Colors
|
|
6
|
+
Black = "\033[0;30m" # Black
|
|
7
|
+
Red = "\033[0;31m" # Red
|
|
8
|
+
Green = "\033[0;32m" # Green
|
|
9
|
+
Yellow = "\033[0;33m" # Yellow
|
|
10
|
+
Blue = "\033[0;34m" # Blue
|
|
11
|
+
Purple = "\033[0;35m" # Purple
|
|
12
|
+
Cyan = "\033[0;36m" # Cyan
|
|
13
|
+
White = "\033[0;37m" # White
|
|
14
|
+
|
|
15
|
+
# Bold
|
|
16
|
+
BBlack = "\033[1;30m" # Black
|
|
17
|
+
BRed = "\033[1;31m" # Red
|
|
18
|
+
BGreen = "\033[1;32m" # Green
|
|
19
|
+
BYellow = "\033[1;33m" # Yellow
|
|
20
|
+
BBlue = "\033[1;34m" # Blue
|
|
21
|
+
BPurple = "\033[1;35m" # Purple
|
|
22
|
+
BCyan = "\033[1;36m" # Cyan
|
|
23
|
+
BWhite = "\033[1;37m" # White
|
|
24
|
+
|
|
25
|
+
# Underline
|
|
26
|
+
UBlack = "\033[4;30m" # Black
|
|
27
|
+
URed = "\033[4;31m" # Red
|
|
28
|
+
UGreen = "\033[4;32m" # Green
|
|
29
|
+
UYellow = "\033[4;33m" # Yellow
|
|
30
|
+
UBlue = "\033[4;34m" # Blue
|
|
31
|
+
UPurple = "\033[4;35m" # Purple
|
|
32
|
+
UCyan = "\033[4;36m" # Cyan
|
|
33
|
+
UWhite = "\033[4;37m" # White
|
|
34
|
+
|
|
35
|
+
# Background
|
|
36
|
+
BgBlack = "\033[40m" # Black
|
|
37
|
+
BgRed = "\033[41m" # Red
|
|
38
|
+
BgGreen = "\033[42m" # Green
|
|
39
|
+
BgYellow = "\033[43m" # Yellow
|
|
40
|
+
BgBlue = "\033[44m" # Blue
|
|
41
|
+
BgPurple = "\033[45m" # Purple
|
|
42
|
+
BgCyan = "\033[46m" # Cyan
|
|
43
|
+
BgWhite = "\033[47m" # White
|
|
44
|
+
|
|
45
|
+
# High Intensty
|
|
46
|
+
IBlack = "\033[0;90m" # Black
|
|
47
|
+
IRed = "\033[0;91m" # Red
|
|
48
|
+
IGreen = "\033[0;92m" # Green
|
|
49
|
+
IYellow = "\033[0;93m" # Yellow
|
|
50
|
+
IBlue = "\033[0;94m" # Blue
|
|
51
|
+
IPurple = "\033[0;95m" # Purple
|
|
52
|
+
ICyan = "\033[0;96m" # Cyan
|
|
53
|
+
IWhite = "\033[0;97m" # White
|
|
54
|
+
|
|
55
|
+
# Bold High Intensty
|
|
56
|
+
BIBlack = "\033[1;90m" # Black
|
|
57
|
+
BIRed = "\033[1;91m" # Red
|
|
58
|
+
BIGreen = "\033[1;92m" # Green
|
|
59
|
+
BIYellow = "\033[1;93m" # Yellow
|
|
60
|
+
BIBlue = "\033[1;94m" # Blue
|
|
61
|
+
BIPurple = "\033[1;95m" # Purple
|
|
62
|
+
BICyan = "\033[1;96m" # Cyan
|
|
63
|
+
BIWhite = "\033[1;97m" # White
|
|
64
|
+
|
|
65
|
+
# High Intensty backgrounds
|
|
66
|
+
BgIBlack = "\033[0;100m" # Black
|
|
67
|
+
BgIRed = "\033[0;101m" # Red
|
|
68
|
+
BgIGreen = "\033[0;102m" # Green
|
|
69
|
+
BgIYellow = "\033[0;103m" # Yellow
|
|
70
|
+
BgIBlue = "\033[0;104m" # Blue
|
|
71
|
+
BgIPurple = "\033[10;95m" # Purple
|
|
72
|
+
BgICyan = "\033[0;106m" # Cyan
|
|
73
|
+
BgIWhite = "\033[0;107m" # White
|
py_app_runner/config.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import sys
|
|
3
|
+
from copy import deepcopy
|
|
4
|
+
from os import environ, getcwd, path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from dotenv import load_dotenv
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def coerce_value(value: Any) -> Any:
|
|
11
|
+
"""Turn a plain integer string into an int, leaving everything else alone.
|
|
12
|
+
|
|
13
|
+
The round-trip check keeps values where the text form carries meaning - a zero
|
|
14
|
+
padded id or a numeric secret must not silently become a different number.
|
|
15
|
+
"""
|
|
16
|
+
if isinstance(value, list) or not isinstance(value, str):
|
|
17
|
+
return value
|
|
18
|
+
|
|
19
|
+
if value.isdigit() and str(int(value)) == value:
|
|
20
|
+
return int(value)
|
|
21
|
+
|
|
22
|
+
return value
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def replace_rec(
|
|
26
|
+
keys: list[str],
|
|
27
|
+
finalDict: dict[str, Any],
|
|
28
|
+
finalValue: Any,
|
|
29
|
+
) -> dict[str, Any]:
|
|
30
|
+
key = keys.pop(0)
|
|
31
|
+
if len(keys) == 0:
|
|
32
|
+
finalDict[key] = coerce_value(finalValue)
|
|
33
|
+
return finalDict
|
|
34
|
+
|
|
35
|
+
if key not in finalDict:
|
|
36
|
+
finalDict[key] = {}
|
|
37
|
+
|
|
38
|
+
# An env var whose key path runs through an existing scalar cannot be merged
|
|
39
|
+
# (e.g. API_KEY_PEPPER against a config that already has api.key). Overwriting
|
|
40
|
+
# the scalar would silently destroy it, so refuse instead.
|
|
41
|
+
if not isinstance(finalDict[key], dict):
|
|
42
|
+
raise ValueError(f"Cannot descend into non-dict config key '{key}': it already holds a scalar value")
|
|
43
|
+
|
|
44
|
+
finalDict[key] = replace_rec(keys, finalDict[key], finalValue)
|
|
45
|
+
return finalDict
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def parse_splitted_values(value: str, separator: str = ",") -> list[str]:
|
|
49
|
+
valueList = value.split(separator)
|
|
50
|
+
formattedList: list[str] = []
|
|
51
|
+
for item in valueList:
|
|
52
|
+
item = item.strip(" \r\n\t\\")
|
|
53
|
+
if len(item) == 0 or item[0] == "#":
|
|
54
|
+
continue
|
|
55
|
+
|
|
56
|
+
formattedList.append(item)
|
|
57
|
+
|
|
58
|
+
return formattedList
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def is_env(env: str, config: dict[str, Any] | None = None) -> bool:
|
|
62
|
+
if config is None:
|
|
63
|
+
from py_app_runner.registry import AppRegistry
|
|
64
|
+
|
|
65
|
+
config = AppRegistry.config()
|
|
66
|
+
return env == config.get("environment", "")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def is_env_dev(config: dict[str, Any] | None = None) -> bool:
|
|
70
|
+
return is_env("dev", config)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def is_env_test(config: dict[str, Any] | None = None) -> bool:
|
|
74
|
+
return is_env("test", config)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def is_env_prod(config: dict[str, Any] | None = None) -> bool:
|
|
78
|
+
return is_env("prod", config)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def load_config(
|
|
82
|
+
defaults: dict[str, Any] | None = None,
|
|
83
|
+
split_value_keys: list[str] | None = None,
|
|
84
|
+
) -> dict[str, Any]:
|
|
85
|
+
"""
|
|
86
|
+
Load configuration from environment variables.
|
|
87
|
+
|
|
88
|
+
1. Calls load_dotenv() from the current working directory
|
|
89
|
+
2. Takes a `defaults` dict (the project's base config structure)
|
|
90
|
+
3. Applies replace_rec() to fill in from env vars
|
|
91
|
+
4. Handles split_value_keys (CSV splitting)
|
|
92
|
+
5. Returns the populated dict
|
|
93
|
+
"""
|
|
94
|
+
currentDirectory = getcwd()
|
|
95
|
+
load_dotenv(path.join(currentDirectory, ".env"))
|
|
96
|
+
|
|
97
|
+
appEnv = environ.get("APP_ENV", None)
|
|
98
|
+
if not appEnv:
|
|
99
|
+
logging.getLogger().error("*\n*\n* It seems that `.env` file is not loaded\n*\n*")
|
|
100
|
+
sys.exit(-1)
|
|
101
|
+
|
|
102
|
+
if defaults is None:
|
|
103
|
+
defaults = {}
|
|
104
|
+
|
|
105
|
+
if split_value_keys is None:
|
|
106
|
+
split_value_keys = []
|
|
107
|
+
|
|
108
|
+
# Deep copy: replace_rec writes into nested dicts, which a shallow copy would
|
|
109
|
+
# share with the caller's (often module level) defaults.
|
|
110
|
+
config_dict: dict[str, Any] = deepcopy(defaults)
|
|
111
|
+
|
|
112
|
+
# Always set environment from APP_ENV
|
|
113
|
+
config_dict["environment"] = appEnv
|
|
114
|
+
|
|
115
|
+
for key in environ:
|
|
116
|
+
keys = key.split("_")
|
|
117
|
+
keys = [item.lower() for item in keys]
|
|
118
|
+
value = environ[key]
|
|
119
|
+
|
|
120
|
+
if len(keys) == 0 or config_dict.get(keys[0], None) is None:
|
|
121
|
+
continue
|
|
122
|
+
|
|
123
|
+
new_value: Any = parse_splitted_values(value) if key in split_value_keys else value
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
config_dict = replace_rec(keys, config_dict, new_value)
|
|
127
|
+
except ValueError as e:
|
|
128
|
+
# One unmappable variable must not take the whole service down, but it
|
|
129
|
+
# must be loud: the value the operator set is not in effect.
|
|
130
|
+
logging.getLogger(__name__).error("Ignoring env var %s: %s", key, e)
|
|
131
|
+
|
|
132
|
+
return config_dict
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Field encryption, the shared client envelope, and password hashing."""
|
|
2
|
+
|
|
3
|
+
from py_app_runner.crypto.errors import CryptoError
|
|
4
|
+
from py_app_runner.crypto.fields import FieldCrypto, generate_key, is_encrypted, key_id_of
|
|
5
|
+
from py_app_runner.crypto.passwords import PasswordHasher
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"CryptoError",
|
|
9
|
+
"FieldCrypto",
|
|
10
|
+
"PasswordHasher",
|
|
11
|
+
"generate_key",
|
|
12
|
+
"is_encrypted",
|
|
13
|
+
"key_id_of",
|
|
14
|
+
]
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
from argparse import Namespace
|
|
4
|
+
|
|
5
|
+
import psycopg
|
|
6
|
+
|
|
7
|
+
from py_app_runner.crypto.commands import Out, cmd_key, cmd_rotate
|
|
8
|
+
from py_app_runner.crypto.errors import CryptoError
|
|
9
|
+
from py_app_runner.crypto.fields import FieldCrypto
|
|
10
|
+
from py_app_runner.migrations._service import connect_kwargs
|
|
11
|
+
from py_app_runner.pybridge import PyBridge
|
|
12
|
+
from py_app_runner.registry import AppRegistry
|
|
13
|
+
|
|
14
|
+
_DEFAULT_DB = "main"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def init_service(args: Namespace, _pybridge: PyBridge, logger: logging.Logger) -> None:
|
|
18
|
+
out: Out = print
|
|
19
|
+
|
|
20
|
+
# `key` runs before any config is read and any connection is opened. Generating the
|
|
21
|
+
# first key must not require a working install, because until it exists there isn't
|
|
22
|
+
# one - the environment variable it goes into is what the config refers to.
|
|
23
|
+
if args.step == "key":
|
|
24
|
+
raise SystemExit(cmd_key(out))
|
|
25
|
+
|
|
26
|
+
# Same reasoning as migrations: runner.py catches Exception around init_service and
|
|
27
|
+
# returns normally, which exits 0. A rotate that could not reach the database must not
|
|
28
|
+
# report success to a deploy script, so every non-SystemExit failure becomes a non-zero
|
|
29
|
+
# exit here rather than in runner.py.
|
|
30
|
+
code = 1
|
|
31
|
+
try:
|
|
32
|
+
config = AppRegistry.config()
|
|
33
|
+
crypto = FieldCrypto.from_config(config)
|
|
34
|
+
|
|
35
|
+
db_name = getattr(args, "db", None) or _DEFAULT_DB
|
|
36
|
+
db_config = config.get("db") or {}
|
|
37
|
+
if db_name not in db_config:
|
|
38
|
+
out(
|
|
39
|
+
f'error: no database {db_name!r} in config["db"]; '
|
|
40
|
+
f"configured are: {', '.join(sorted(db_config)) or 'none'}."
|
|
41
|
+
)
|
|
42
|
+
raise SystemExit(2)
|
|
43
|
+
|
|
44
|
+
if args.step == "rotate":
|
|
45
|
+
async with await psycopg.AsyncConnection.connect(**connect_kwargs(db_config[db_name])) as conn:
|
|
46
|
+
code = await cmd_rotate(
|
|
47
|
+
conn,
|
|
48
|
+
crypto,
|
|
49
|
+
args.table,
|
|
50
|
+
args.column,
|
|
51
|
+
args.id,
|
|
52
|
+
args.batch,
|
|
53
|
+
getattr(args, "dry_run", False),
|
|
54
|
+
out,
|
|
55
|
+
)
|
|
56
|
+
else:
|
|
57
|
+
out(f"error: unknown crypto command {args.step!r}")
|
|
58
|
+
code = 1
|
|
59
|
+
|
|
60
|
+
except CryptoError as e:
|
|
61
|
+
# Configuration and key material problems are the expected failure here, and their
|
|
62
|
+
# messages already say what to do. A stack trace would bury that.
|
|
63
|
+
out(f"error: {e}")
|
|
64
|
+
raise SystemExit(1) from None
|
|
65
|
+
except (KeyboardInterrupt, asyncio.CancelledError):
|
|
66
|
+
# Above `except Exception` because both derive from BaseException. An interrupted
|
|
67
|
+
# rotate has re-encrypted some rows and not others, which is safe to resume but must
|
|
68
|
+
# not be reported as done.
|
|
69
|
+
logger.error("crypto: interrupted; the column may be partially rotated")
|
|
70
|
+
raise SystemExit(1) from None
|
|
71
|
+
except Exception:
|
|
72
|
+
logger.exception("crypto: unhandled failure")
|
|
73
|
+
raise SystemExit(1) from None
|
|
74
|
+
|
|
75
|
+
raise SystemExit(code)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""CLI subparsers for the built-in crypto service.
|
|
2
|
+
|
|
3
|
+
python3 src/app.py crypto key
|
|
4
|
+
python3 src/app.py crypto rotate --table NAME --column NAME [--id NAME]
|
|
5
|
+
[--batch N] [--dry-run] [--db NAME]
|
|
6
|
+
|
|
7
|
+
`--dry-run` shadows a real top-level flag on runner.py's parser, so it needs
|
|
8
|
+
`default=SUPPRESS` or argparse copies the subparser default back over the parent
|
|
9
|
+
namespace - the same collision migrations' `apply` has.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
from argparse import SUPPRESS, ArgumentParser, _SubParsersAction # type: ignore
|
|
14
|
+
|
|
15
|
+
from py_app_runner.pybridge import PyBridge
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def reg_subparsers(
|
|
19
|
+
subparsers: "_SubParsersAction[ArgumentParser]",
|
|
20
|
+
_pybridge: PyBridge,
|
|
21
|
+
_base_logger: logging.Logger,
|
|
22
|
+
) -> None:
|
|
23
|
+
"""Command line subparsers"""
|
|
24
|
+
|
|
25
|
+
parser = subparsers.add_parser(
|
|
26
|
+
"crypto",
|
|
27
|
+
description="Generate key material and re-encrypt stored columns",
|
|
28
|
+
help="Field encryption",
|
|
29
|
+
)
|
|
30
|
+
group = parser.add_subparsers(title="command", dest="step", required=True)
|
|
31
|
+
|
|
32
|
+
group.add_parser("key", help="Print fresh key material for an environment variable")
|
|
33
|
+
|
|
34
|
+
rotate_parser = group.add_parser("rotate", help="Re-encrypt a column under the current key")
|
|
35
|
+
rotate_parser.add_argument("--table", required=True, help="Table holding the column")
|
|
36
|
+
rotate_parser.add_argument("--column", required=True, help="Encrypted column")
|
|
37
|
+
rotate_parser.add_argument("--id", default="id", help="Primary key to page through (default: id)")
|
|
38
|
+
rotate_parser.add_argument(
|
|
39
|
+
"--batch",
|
|
40
|
+
type=int,
|
|
41
|
+
default=500,
|
|
42
|
+
help="Rows read per statement (default: 500)",
|
|
43
|
+
)
|
|
44
|
+
rotate_parser.add_argument(
|
|
45
|
+
"--dry-run",
|
|
46
|
+
action="store_true",
|
|
47
|
+
default=SUPPRESS,
|
|
48
|
+
help="Report what would change, change nothing",
|
|
49
|
+
)
|
|
50
|
+
rotate_parser.add_argument(
|
|
51
|
+
"--db",
|
|
52
|
+
default=None,
|
|
53
|
+
help='Entry of config["db"] to rotate in (default: main)',
|
|
54
|
+
)
|