@kernelminds/create-enclave 0.0.1

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.
@@ -0,0 +1,446 @@
1
+ import asyncio
2
+ # Import aiohttp library for async requests
3
+ import aiohttp
4
+ from aiohttp_session import setup, get_session
5
+ from aiohttp_session.cookie_storage import EncryptedCookieStorage
6
+ import os
7
+ import sys
8
+ import logging
9
+ import random
10
+ from datetime import datetime
11
+ from aiohttp import web
12
+ from dotenv import load_dotenv
13
+ import redis.asyncio as redis
14
+ import hashlib
15
+
16
+ # Import login module
17
+ from scailo_sdk.login_api import AsyncLoginServiceClient, login
18
+ from scailo_sdk.vault_api import AsyncVaultServiceClient
19
+ from scailo_sdk.vault_commons import VerifyEnclaveIngressRequest
20
+ from scailo_sdk.vendors_api import AsyncVendorsServiceClient
21
+ from scailo_sdk.vendors import VendorsServiceFilterReq
22
+ from scailo_sdk.base import BOOL_FILTER_TRUE
23
+ import utils
24
+
25
+ # --- Configuration and Globals ---
26
+
27
+ # Set up basic logging configuration
28
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
29
+ log = logging.getLogger(__name__)
30
+
31
+ class Config:
32
+ """Holds all necessary environment variables, matching the Go structure."""
33
+ ENCLAVE_NAME: str = ""
34
+ SCAILO_API: str = ""
35
+ PORT: int = 8080 # Default port
36
+ USERNAME: str = ""
37
+ PASSWORD: str = ""
38
+
39
+ REDIS_USERNAME: str = ""
40
+ REDIS_PASSWORD: str = ""
41
+ REDIS_URL: str = ""
42
+ WORKFLOW_EVENTS_CHANNEL: str = ""
43
+ COOKIE_SIGNATURE_SECRET: str = ""
44
+
45
+ # Global state variables
46
+ global_config = Config()
47
+ production: bool = False
48
+ index_page_cache: str = ""
49
+ enclave_prefix: str = ""
50
+ auth_token: str = ""
51
+
52
+ encoded_cookie_signature_secret: bytes = b""
53
+
54
+ # Constants
55
+ LOGIN_INTERVAL_SECONDS = 3600 * 12 # 12 hour
56
+ INDEX_HTML_FILE = "index.html"
57
+
58
+ # --- Initialization and Config Loading ---
59
+
60
+ def load_config():
61
+ """Reads and validates environment variables."""
62
+ load_dotenv(override=True)
63
+
64
+ global production
65
+ # Determine production status
66
+ production_flag = os.getenv("PRODUCTION", "false").lower() == "true"
67
+
68
+ # Matching Go's logic: if GIN_MODE is 'release' OR PRODUCTION is 'true'
69
+ if production_flag:
70
+ production = True
71
+
72
+ log.info(f"Server operating in Production mode: {production}")
73
+
74
+ # 2. Read environment variables
75
+ global_config.ENCLAVE_NAME = os.getenv("ENCLAVE_NAME") or ""
76
+ global_config.SCAILO_API = os.getenv("SCAILO_API") or ""
77
+ global_config.USERNAME = os.getenv("USERNAME") or ""
78
+ global_config.PASSWORD = os.getenv("PASSWORD") or ""
79
+
80
+ global_config.REDIS_USERNAME = os.getenv("REDIS_USERNAME") or ""
81
+ global_config.REDIS_PASSWORD = os.getenv("REDIS_PASSWORD") or ""
82
+ global_config.REDIS_URL = os.getenv("REDIS_URL") or ""
83
+ global_config.WORKFLOW_EVENTS_CHANNEL = os.getenv("WORKFLOW_EVENTS_CHANNEL") or ""
84
+ global_config.COOKIE_SIGNATURE_SECRET = os.getenv("COOKIE_SIGNATURE_SECRET") or ""
85
+
86
+ port_str = os.getenv("PORT", "8080")
87
+ try:
88
+ global_config.PORT = int(port_str)
89
+ except ValueError:
90
+ log.error(f"Invalid PORT value: {port_str}. Using default 8080.")
91
+ global_config.PORT = 8080
92
+
93
+ # 3. Validate environment variables (matches Go's exit logic)
94
+ exit_code = 0
95
+ if not global_config.ENCLAVE_NAME:
96
+ log.error("ENCLAVE_NAME not set")
97
+ exit_code = 1
98
+ if not global_config.SCAILO_API:
99
+ log.error("SCAILO_API not set")
100
+ exit_code = 1
101
+ if global_config.PORT == 0:
102
+ log.error("PORT not set or is 0")
103
+ exit_code = 1
104
+ if not global_config.USERNAME:
105
+ log.error("USERNAME not set (required for API login stub)")
106
+ exit_code = 1
107
+ if not global_config.PASSWORD:
108
+ log.error("PASSWORD not set (required for API login stub)")
109
+ exit_code = 1
110
+ if not global_config.REDIS_URL:
111
+ log.error("REDIS_URL not set")
112
+ exit_code = 1
113
+ if not global_config.WORKFLOW_EVENTS_CHANNEL:
114
+ log.error("WORKFLOW_EVENTS_CHANNEL not set")
115
+ exit_code = 1
116
+ if not global_config.COOKIE_SIGNATURE_SECRET:
117
+ log.error("COOKIE_SIGNATURE_SECRET not set")
118
+ exit_code = 1
119
+
120
+ global enclave_prefix
121
+ enclave_prefix = utils.get_enclave_prefix(global_config.ENCLAVE_NAME)
122
+
123
+ global encoded_cookie_signature_secret
124
+ encoded_cookie_signature_secret = hashlib.sha256(global_config.COOKIE_SIGNATURE_SECRET.encode('utf-8')).digest()
125
+
126
+ if exit_code != 0:
127
+ log.error("Configuration errors found. Exiting.")
128
+ sys.exit(exit_code)
129
+
130
+ async def perform_login():
131
+ global auth_token
132
+
133
+ log.info(f"Attempting login to API at: {global_config.SCAILO_API} with user: {global_config.USERNAME}")
134
+
135
+ async with aiohttp.ClientSession() as http_client:
136
+ # Create the login client
137
+ login_client = AsyncLoginServiceClient(global_config.SCAILO_API, http_client)
138
+ # Call the login method to retrieve the auth token
139
+ login_resp = await login_client.login_as_employee_primary(login.UserLoginRequest(username=global_config.USERNAME, plain_text_password=global_config.PASSWORD))
140
+ if login_resp.auth_token:
141
+ auth_token = login_resp.auth_token
142
+
143
+ # print("Auth token is: ", auth_token)
144
+ log.info(f"Successfully logged in...")
145
+
146
+
147
+ async def setup_redis(app: web.Application):
148
+ # Connect to the Redis server and create a PubSub instance
149
+ try:
150
+ [redis_host, redis_port] = global_config.REDIS_URL.split(":")
151
+ redis_client = redis.Redis(host=redis_host, port=int(redis_port), decode_responses=True)
152
+ pubsub = redis_client.pubsub()
153
+ print("Connected to Redis server.")
154
+
155
+ # Subscribe and listen for messages
156
+ await pubsub.subscribe(global_config.WORKFLOW_EVENTS_CHANNEL)
157
+ print(f"Subscribed to {global_config.WORKFLOW_EVENTS_CHANNEL}. Waiting for messages...")
158
+
159
+ async for message in pubsub.listen():
160
+ print(f"Received: {message}")
161
+ except redis.exceptions.ConnectionError as e:
162
+ print(f"Error connecting to Redis: {e}")
163
+ exit()
164
+ except Exception as e:
165
+ print(f"An error occurred: {e}")
166
+ finally:
167
+ await pubsub.unsubscribe(global_config.WORKFLOW_EVENTS_CHANNEL)
168
+ print(f"Unsubscribed from {global_config.WORKFLOW_EVENTS_CHANNEL}.")
169
+ await redis_client.aclose()
170
+
171
+
172
+ def append_default_header(auth_token_to_add: str | None = None):
173
+ if not auth_token_to_add:
174
+ auth_token_to_add = auth_token
175
+ d = dict[str, str]()
176
+ d["auth_token"] = auth_token_to_add
177
+ return d
178
+
179
+
180
+ async def login_to_api_task(app: web.Application):
181
+ """
182
+ Background task to perform the login periodically, matching the Go code.
183
+ This runs indefinitely after server startup.
184
+ """
185
+ log.info("Starting recurring API login task...")
186
+
187
+ # Perform initial login immediately
188
+ await perform_login()
189
+
190
+ while True:
191
+ try:
192
+ log.info(f"Waiting {LOGIN_INTERVAL_SECONDS} seconds until next login.")
193
+ await asyncio.sleep(LOGIN_INTERVAL_SECONDS)
194
+ await perform_login()
195
+ except asyncio.CancelledError:
196
+ log.info("API login task cancelled.")
197
+ break
198
+ except Exception as e:
199
+ log.error(f"Error in API login task: {e}")
200
+ await asyncio.sleep(60) # Wait a minute before retrying
201
+
202
+ # --- HTML & Cache Busting Logic ---
203
+
204
+ def replace_bundle_caches(page: str) -> str:
205
+ """Implements the cache-busting logic by appending a version number."""
206
+ # Use current timestamp for cache busting version (YYYYMMDDhhmmss format)
207
+ version = datetime.now().strftime("%Y%m%d%H%M%S")
208
+
209
+ log.debug(f"Applying cache-busting version: {version}")
210
+
211
+ # Asset paths to replace
212
+ script_preload_old = f'<link rel="preload" as="script" href="{enclave_prefix}/resources/dist/js/bundle.src.min.js">'
213
+ script_preload_new = f'<link rel="preload" as="script" href="{enclave_prefix}/resources/dist/js/bundle.src.min.js?v={version}">'
214
+
215
+ script_src_old = f'<script src="{enclave_prefix}/resources/dist/js/bundle.src.min.js"></script>'
216
+ script_src_new = f'<script src="{enclave_prefix}/resources/dist/js/bundle.src.min.js?v={version}"></script>'
217
+
218
+ style_link_old = f'<link rel="stylesheet" href="{enclave_prefix}/resources/dist/css/bundle.css">'
219
+ style_link_new = f'<link rel="stylesheet" href="{enclave_prefix}/resources/dist/css/bundle.css?v={version}">'
220
+
221
+ # Replacement execution
222
+ page = page.replace(script_preload_old, script_preload_new)
223
+ page = page.replace(script_src_old, script_src_new)
224
+ page = page.replace(style_link_old, style_link_new)
225
+
226
+ return page
227
+
228
+
229
+ async def get_index_page() -> str:
230
+ """Reads index.html, using the cache if in production."""
231
+ global index_page_cache
232
+
233
+ if production and index_page_cache:
234
+ return index_page_cache
235
+
236
+ try:
237
+ # For simplicity, we use a direct read as it is only done once in production.
238
+ with open(INDEX_HTML_FILE, 'r') as f:
239
+ content = f.read()
240
+
241
+ if production:
242
+ index_page_cache = content
243
+
244
+ return content
245
+
246
+ except FileNotFoundError:
247
+ log.error(f"Error: {INDEX_HTML_FILE} not found.")
248
+ return "Index page not found."
249
+ except Exception as e:
250
+ log.error(f"Error reading {INDEX_HTML_FILE}: {e}")
251
+ return "Error reading index page."
252
+
253
+ # --- Request Handlers ---
254
+
255
+ async def index_handler(request: web.Request):
256
+ """
257
+ The single handler for all root/SPA UI routes (e.g., /enclave/name/ui, /enclave/name/ui/path).
258
+ Serves the cache-busted index.html.
259
+ """
260
+
261
+ index_content = await get_index_page()
262
+
263
+ if index_content.startswith("Error") or index_content.startswith("Index page not found"):
264
+ return web.Response(text=index_content, status=500, content_type='text/plain')
265
+
266
+ page_with_cache = replace_bundle_caches(index_content)
267
+
268
+ return web.Response(text=page_with_cache, status=200, content_type='text/html')
269
+
270
+
271
+ async def health_check_handler(request: web.Request):
272
+ return web.json_response({"status": "OK"}, status=200)
273
+
274
+
275
+ async def random_api_handler(request: web.Request):
276
+ """Handles the /api/random endpoint."""
277
+ # Generate a random float between 0.0 and 1.0 (like Math.random())
278
+ random_number = random.random()
279
+ return web.json_response({"random": random_number}, status=200)
280
+
281
+ async def no_route_handler(request: web.Request):
282
+ """Handles all unmatched routes and redirects them to the UI path."""
283
+ ui_path = f"{enclave_prefix}/ui"
284
+ log.info(f"No route found for {request.path}. Redirecting to {ui_path}")
285
+ raise web.HTTPTemporaryRedirect(location=ui_path)
286
+
287
+
288
+ async def ingress_handler(request: web.Request):
289
+ """Handles the ingress -> sets the auth token and redirects to the entry point"""
290
+ try:
291
+ if not production:
292
+ # In dev, use the default auth token
293
+ session = await get_session(request)
294
+ session[f'{global_config.ENCLAVE_NAME}_auth_token'] = auth_token
295
+ session.max_age = 3600
296
+ # Redirect to the UI path
297
+ ui_path = f"{enclave_prefix}/ui"
298
+ return web.HTTPTemporaryRedirect(location=ui_path)
299
+ else:
300
+ # `${enclavePrefix}/ingress/:token`
301
+ # Get the token
302
+ token = request.match_info.get('token')
303
+ if not token:
304
+ return web.Response(status=400, text="Missing token")
305
+ async with aiohttp.ClientSession() as http_client:
306
+ vault_client = AsyncVaultServiceClient(global_config.SCAILO_API, http_client)
307
+ ingress = await vault_client.verify_enclave_ingress(VerifyEnclaveIngressRequest(token=token), extra_headers=append_default_header(auth_token))
308
+ session = await get_session(request)
309
+ session[f'{global_config.ENCLAVE_NAME}_auth_token'] = ingress.auth_token
310
+ session.max_age = ingress.expires_at
311
+ # Redirect to the UI path
312
+ ui_path = f"{enclave_prefix}/ui"
313
+ return web.HTTPTemporaryRedirect(location=ui_path)
314
+
315
+ except Exception as e:
316
+ return web.Response(status=500, text=str(e))
317
+
318
+ async def protected_api_random_handler(request: web.Request):
319
+ """Handles the /protected/api/random endpoint."""
320
+ try:
321
+ session = await get_session(request)
322
+ user_auth_token = session.get(f'{global_config.ENCLAVE_NAME}_auth_token')
323
+
324
+ if not user_auth_token:
325
+ return web.Response(text="Session expired or invalid. Please login again.", status=401)
326
+ if len(user_auth_token) == 0:
327
+ return web.Response(text="Session expired or invalid. Please login again.", status=401)
328
+
329
+ random_number = random.random()
330
+ async with aiohttp.ClientSession() as http_client:
331
+ vendors_client = AsyncVendorsServiceClient(global_config.SCAILO_API, http_client)
332
+ vendors_list = (await vendors_client.filter(VendorsServiceFilterReq(is_active=BOOL_FILTER_TRUE, count=-1), extra_headers=append_default_header(auth_token))).list
333
+ resp_vendors = []
334
+ for vendor in vendors_list:
335
+ d = {}
336
+ d["code"] = vendor.code
337
+ resp_vendors.append(d)
338
+ return web.json_response({"random": random_number, "vendors": resp_vendors}, status=200)
339
+ except Exception as e:
340
+ return web.Response(status=500, text=str(e))
341
+
342
+ # Redirects to "{enclavePrefix}/ui"
343
+ async def direct_url_entry_point_handler(request: web.Request):
344
+ return web.HTTPTemporaryRedirect(location=f"{enclave_prefix}/ui")
345
+
346
+ # --- Background Task Management ---
347
+
348
+ async def start_background_tasks(app: web.Application):
349
+ """Starts the recurring login task and stores the reference."""
350
+ # Create the task and immediately schedule it, storing the task object
351
+ _login_to_api_task = asyncio.create_task(login_to_api_task(app))
352
+ app['login_to_api_task'] = _login_to_api_task
353
+
354
+ _setup_redis_task = asyncio.create_task(setup_redis(app))
355
+ app['_setup_redis_task'] = _setup_redis_task
356
+
357
+
358
+ async def cleanup_background_tasks(app: web.Application):
359
+ """Cancels the recurring login task on application shutdown."""
360
+ _login_to_api_task = app.get('login_to_api_task')
361
+ if _login_to_api_task:
362
+ log.info("Cancelling API login task...")
363
+ _login_to_api_task.cancel()
364
+ # Wait for the task to finish, ignoring the expected CancelledError
365
+ await asyncio.gather(_login_to_api_task, return_exceptions=True)
366
+ log.info("API login task cancelled successfully.")
367
+
368
+ _setup_redis_task = app.get('_setup_redis_task')
369
+ if _setup_redis_task:
370
+ log.info("Cancelling API login task...")
371
+ _setup_redis_task.cancel()
372
+ # Wait for the task to finish, ignoring the expected CancelledError
373
+ await asyncio.gather(_setup_redis_task, return_exceptions=True)
374
+ log.info("API login task cancelled successfully.")
375
+
376
+
377
+ # --- Main Application Setup ---
378
+
379
+ def create_app() -> web.Application:
380
+ """Sets up the aiohttp application with all routes."""
381
+
382
+ load_config()
383
+
384
+ # Initialize aiohttp application
385
+ app = web.Application()
386
+
387
+ # 4. Global Middleware Setup
388
+ # max_age here sets the default for all sessions (e.g., 24 hours)
389
+ setup(app, EncryptedCookieStorage(
390
+ encoded_cookie_signature_secret,
391
+ cookie_name=f"{global_config.ENCLAVE_NAME}_auth_token",
392
+ max_age=86400, # 24 hours in seconds
393
+ httponly=True, # Prevents JavaScript access (XSS protection)
394
+ samesite="Lax" # CSRF protection
395
+ ))
396
+
397
+ # --- 1. Register Static Routes ---
398
+ static_route_path = f"{enclave_prefix}/resources/dist"
399
+ app.router.add_static(static_route_path, "resources/dist", name="static_resources")
400
+ # log.info(f"Static route registered: {static_route_path} -> resources/dist")
401
+
402
+ # --- 2. Health Checks ---
403
+ app.router.add_get(f"{enclave_prefix}/health/startup", health_check_handler)
404
+ app.router.add_get(f"{enclave_prefix}/health/liveliness", health_check_handler)
405
+ app.router.add_get(f"{enclave_prefix}/health/readiness", health_check_handler)
406
+
407
+ # --- 3. API Endpoint ---
408
+ app.router.add_get(f"{enclave_prefix}/api/random", random_api_handler)
409
+
410
+ app.router.add_get(f"{enclave_prefix}/ingress/{{token}}", ingress_handler)
411
+ app.router.add_get(f"{enclave_prefix}/protected/api/random", protected_api_random_handler)
412
+
413
+ # Implicit redirect for entry_point_management = direct_url
414
+ app.router.add_get(f"/", direct_url_entry_point_handler)
415
+
416
+ # --- 4. Index Page / SPA Routes ---
417
+ ui_path_root = f"{enclave_prefix}/ui"
418
+ ui_path_spa = f"{enclave_prefix}/ui/{{tail:.*}}"
419
+
420
+ app.router.add_get(ui_path_root, index_handler)
421
+ app.router.add_get(ui_path_spa, index_handler)
422
+ # log.info(f"UI/SPA routes registered: {ui_path_root} and {ui_path_spa}")
423
+
424
+ # --- 5. Not Found Handler (NoRoute replacement) ---
425
+ app.router.add_route('*', '/{tail:.*}', no_route_handler)
426
+
427
+ # --- 6. Start/Stop the recurring login task ---
428
+ # Use explicit handlers to manage the task's full lifecycle
429
+ app.on_startup.append(start_background_tasks)
430
+ app.on_cleanup.append(cleanup_background_tasks)
431
+
432
+ return app
433
+
434
+ # --- Main Entry Point ---
435
+
436
+ if __name__ == '__main__':
437
+ # Start the application
438
+ app = create_app()
439
+ address = f"0.0.0.0:{global_config.PORT}"
440
+ log.info(f"Server listening on address {address} with Production: {production}")
441
+
442
+ try:
443
+ web.run_app(app, host='0.0.0.0', port=global_config.PORT)
444
+ except Exception as e:
445
+ log.critical(f"Server failed to start: {e}")
446
+ sys.exit(1)
@@ -0,0 +1,2 @@
1
+ def get_enclave_prefix(enclave_name: str) -> str:
2
+ return f"/enclave/{enclave_name}"