Initial commit

This commit is contained in:
2026-07-18 12:00:33 +05:30
commit caac8413e9
83 changed files with 10262 additions and 0 deletions

514
scripts/app.py Normal file
View File

@@ -0,0 +1,514 @@
#!/usr/bin/env python3
"""
FastAPI application with multiple endpoints that publish to NATS JetStream
Each endpoint corresponds to an external API that workers will forward to
"""
import os
import json
import asyncio
from fastapi import FastAPI, HTTPException, Request, Body
from fastapi.responses import JSONResponse
from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request as StarletteRequest
from starlette.responses import Response as StarletteResponse
from pydantic import BaseModel
import nats
from prometheus_client import Counter, Histogram, generate_latest, REGISTRY
from starlette.responses import Response
import uvicorn
from typing import Optional, Dict, Any, List, Union
# Prometheus metrics
request_count = Counter('http_requests_total', 'Total HTTP requests', ['method', 'endpoint', 'status'])
request_duration = Histogram('http_request_duration_seconds', 'HTTP request duration', ['method', 'endpoint'])
app = FastAPI(title="NATS Backend API - Multi-Endpoint", version="1.0.0")
# Custom CORS middleware to ensure headers are always added
class CORSHeaderMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: StarletteRequest, call_next):
origin = request.headers.get("origin", "*")
response = await call_next(request)
response.headers["Access-Control-Allow-Origin"] = origin
response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, PATCH, DELETE, OPTIONS"
response.headers["Access-Control-Allow-Headers"] = "*"
response.headers["Access-Control-Allow-Credentials"] = "false"
response.headers["Access-Control-Max-Age"] = "600"
return response
# Add custom CORS middleware first
app.add_middleware(CORSHeaderMiddleware)
# Also add FastAPI's CORS middleware as backup
app.add_middleware(
CORSMiddleware,
allow_origin_regex=r".*", # Match all origins
allow_credentials=False,
allow_methods=["*"], # Allow all HTTP methods
allow_headers=["*"], # Allow all headers
expose_headers=["*"], # Expose all headers
max_age=600,
)
# NATS connection (will be initialized on startup)
nc = None
js = None
# Configuration
NATS_URL = os.getenv("NATS_URL", "nats://nats-server:4222")
NATS_USER = os.getenv("NATS_USER", "admin")
NATS_PASSWORD = os.getenv("NATS_PASSWORD", "package@321#")
# Endpoint to NATS subject mapping
ENDPOINT_ROUTES = {
"/live/api/v1/deliveries/createdeliveries": "api.v1.deliveries.createdeliveries",
"/live/api/v1/deliveries/updatedelivery": "api.v1.deliveries.updatedelivery",
"/live/api/v2/partners/createriderlog": "api.v2.partners.createriderlog",
"/live/api/v2/deliveries/createdeliverylog": "api.v2.deliveries.createdeliverylog",
"/live/api/v2/partners/createbreaklog": "api.v2.partners.createbreaklog",
"/live/api/v2/partners/updatebreaklog": "api.v2.partners.updatebreaklog",
"/live/api/v1/mob/orders/createorder": "api.v1.mob.orders.createorder",
"/live/api/v1/web/products/create": "api.v1.web.products.create",
"/live/api/v1/mob/customers/create": "api.v1.mob.customers.create",
"/live/api/v1/mob/customers/login": "api.v1.mob.customers.login",
}
@app.on_event("startup")
async def startup():
"""Initialize NATS connection on startup"""
global nc, js
try:
print(f"Connecting to NATS at {NATS_URL}...")
nc = await nats.connect(
servers=[NATS_URL],
user=NATS_USER,
password=NATS_PASSWORD,
reconnect_time_wait=2,
max_reconnect_attempts=10
)
js = nc.jetstream()
print("✅ Connected to NATS JetStream")
print(f"✅ Configured {len(ENDPOINT_ROUTES)} endpoint routes")
except Exception as e:
print(f"❌ Failed to connect to NATS: {e}")
raise
@app.on_event("shutdown")
async def shutdown():
"""Close NATS connection on shutdown"""
global nc
if nc:
await nc.close()
print("NATS connection closed")
@app.options("/{full_path:path}")
async def options_handler(full_path: str, request: Request):
"""Handle OPTIONS requests for CORS preflight"""
origin = request.headers.get("origin")
return JSONResponse(
status_code=200,
content={},
headers={
"Access-Control-Allow-Origin": origin if origin else "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "*",
"Access-Control-Allow-Credentials": "false",
"Access-Control-Max-Age": "600",
}
)
@app.get("/health")
async def health():
"""Health check endpoint"""
return {"status": "healthy", "nats_connected": nc.is_connected if nc else False}
@app.get("/ready")
async def ready():
"""Readiness check endpoint"""
if nc and nc.is_connected:
return {"status": "ready"}
raise HTTPException(status_code=503, detail="Not ready")
async def publish_to_nats(endpoint: str, data: Union[Dict[str, Any], List[Dict[str, Any]]], request_method: str = "POST"):
"""Publish message to NATS with endpoint metadata"""
payload = {
"endpoint": endpoint,
"method": request_method,
"data": data,
"received_at": int(asyncio.get_event_loop().time() * 1000),
"original_path": endpoint
}
# Get NATS subject for this endpoint
subject = ENDPOINT_ROUTES.get(endpoint, "api.unknown")
if js:
try:
ack = await js.publish(subject, json.dumps(payload).encode())
return ack.seq
except Exception as e:
print(f"❌ Failed to publish to NATS: {e}")
raise HTTPException(status_code=500, detail=f"Failed to publish message: {str(e)}")
else:
raise HTTPException(status_code=503, detail="NATS not connected")
async def publish_to_nats_sync(endpoint: str, data: Union[Dict[str, Any], List[Dict[str, Any]]], request_method: str = "POST", timeout: int = 15):
"""
Publish message to NATS and wait for a dedicated reply from the worker (Synchronous)
"""
if not nc or not js:
raise HTTPException(status_code=503, detail="NATS not connected")
# Create a unique inbox for the reply
reply_subject = nc.new_inbox()
# Subscribe to the inbox matching the unique reply subject
sub = await nc.subscribe(reply_subject)
payload = {
"endpoint": endpoint,
"method": request_method,
"data": data,
"received_at": int(asyncio.get_event_loop().time() * 1000),
"original_path": endpoint,
"reply_subject": reply_subject # Tell worker where to reply
}
# Get NATS subject for this endpoint
subject = ENDPOINT_ROUTES.get(endpoint, "api.unknown")
try:
# Publish to the Stream
ack = await js.publish(subject, json.dumps(payload).encode())
print(f"Sync message published to {subject}, waiting for reply on {reply_subject}...")
# Wait for the worker to process and reply
# We assume the worker will send a single JSON message: {"status": <int>, "body": <str|json>}
msg = await sub.next_msg(timeout=timeout)
# Parse the reply
response_data = json.loads(msg.data.decode())
status_code = response_data.get("status", 500)
body_content = response_data.get("body", "")
# Attempt to parse body as JSON if it's a string, to avoid double-serialization
# If the body is already a dict, use it directly.
final_content = body_content
if isinstance(body_content, str):
try:
final_content = json.loads(body_content)
except:
pass # Keep as string if not valid JSON
await sub.unsubscribe()
return status_code, final_content
except asyncio.TimeoutError:
await sub.unsubscribe()
print(f"❌ Timeout waiting for sync response on {endpoint}")
raise HTTPException(status_code=504, detail="Gateway Timeout: Worker took too long to respond")
except Exception as e:
await sub.unsubscribe()
print(f"❌ Failed during sync processing: {e}")
raise HTTPException(status_code=500, detail=f"Failed to process synchronous request: {str(e)}")
# Endpoint 1: Update Delivery (v1) - External API requires PUT
@app.put("/live/api/v1/deliveries/updatedelivery")
async def update_delivery_v1(data: Dict[str, Any], request: Request):
"""Update Delivery endpoint - forwards to NATS (PUT only, as external API requires PUT)"""
start_time = asyncio.get_event_loop().time()
endpoint = "/live/api/v1/deliveries/updatedelivery"
method = "PUT" # Always use PUT for this endpoint
try:
message_id = await publish_to_nats(endpoint, data, method)
duration = asyncio.get_event_loop().time() - start_time
request_duration.labels(method="PUT", endpoint=endpoint).observe(duration)
request_count.labels(method="PUT", endpoint=endpoint, status="200").inc()
return JSONResponse(
status_code=200,
content={"status": "accepted", "message_id": message_id, "endpoint": endpoint}
)
except HTTPException:
raise
except Exception as e:
request_count.labels(method="PUT", endpoint=endpoint, status="500").inc()
print(f"❌ Error processing {endpoint}: {e}")
raise HTTPException(status_code=500, detail=str(e))
# Endpoint: Create Deliveries (v1)
@app.post("/live/api/v1/deliveries/createdeliveries")
async def create_deliveries_v1(data: Union[Dict[str, Any], List[Dict[str, Any]]], request: Request):
"""Create Deliveries endpoint - forwards payload to NATS"""
start_time = asyncio.get_event_loop().time()
endpoint = "/live/api/v1/deliveries/createdeliveries"
method = request.method
try:
message_id = await publish_to_nats(endpoint, data, method)
duration = asyncio.get_event_loop().time() - start_time
request_duration.labels(method=method, endpoint=endpoint).observe(duration)
request_count.labels(method=method, endpoint=endpoint, status="200").inc()
return JSONResponse(
status_code=200,
content={"status": "accepted", "message_id": message_id, "endpoint": endpoint}
)
except HTTPException:
raise
except Exception as e:
request_count.labels(method=method, endpoint=endpoint, status="500").inc()
print(f"❌ Error processing {endpoint}: {e}")
raise HTTPException(status_code=500, detail=str(e))
# Endpoint 2: Create Rider Log (v2)
@app.post("/live/api/v2/partners/createriderlog")
async def create_rider_log_v2(data: Dict[str, Any], request: Request):
"""Create Rider Log endpoint - forwards to NATS"""
start_time = asyncio.get_event_loop().time()
endpoint = "/live/api/v2/partners/createriderlog"
method = request.method
try:
message_id = await publish_to_nats(endpoint, data, method)
duration = asyncio.get_event_loop().time() - start_time
request_duration.labels(method=method, endpoint=endpoint).observe(duration)
request_count.labels(method=method, endpoint=endpoint, status="200").inc()
return JSONResponse(
status_code=200,
content={"status": "accepted", "message_id": message_id, "endpoint": endpoint}
)
except HTTPException:
raise
except Exception as e:
request_count.labels(method=method, endpoint=endpoint, status="500").inc()
print(f"❌ Error processing {endpoint}: {e}")
raise HTTPException(status_code=500, detail=str(e))
# Endpoint 3: Create Delivery Log (v2)
@app.post("/live/api/v2/deliveries/createdeliverylog")
async def create_delivery_log_v2(
request: Request,
data: List[Dict[str, Any]] = Body(
...,
example=[{
"logid": 0,
"tenantid": 1,
"partnerid": 44,
"locationid": 1,
"orderheaderid": 123456,
"deliveryid": 654321,
"userid": 1111,
"orderid": "1-20231624",
"orderstatus": "active",
"starttime": "2025-12-10 17:51:03",
"logdate": "2025-12-10 18:14:04",
"latitude": "11.0050664",
"longitude": "76.9508776"
}]
)
):
"""
Create Delivery Log endpoint - forwards to NATS.
Accepts either a single dict or a list of dicts to align with external API expectations.
"""
start_time = asyncio.get_event_loop().time()
endpoint = "/live/api/v2/deliveries/createdeliverylog"
method = request.method
try:
# Expect only a list of dicts
if not isinstance(data, list) or not all(isinstance(item, dict) for item in data):
raise HTTPException(status_code=422, detail="Body must be a list of objects")
normalized_data = data
message_id = await publish_to_nats(endpoint, normalized_data, method)
duration = asyncio.get_event_loop().time() - start_time
request_duration.labels(method=method, endpoint=endpoint).observe(duration)
request_count.labels(method=method, endpoint=endpoint, status="200").inc()
return JSONResponse(
status_code=200,
content={"status": "accepted", "message_id": message_id, "endpoint": endpoint}
)
except HTTPException:
raise
except Exception as e:
request_count.labels(method=method, endpoint=endpoint, status="500").inc()
print(f"❌ Error processing {endpoint}: {e}")
raise HTTPException(status_code=500, detail=str(e))
# Endpoint 4: Create Break Rider Log (v2)
@app.post("/live/api/v2/partners/createbreaklog")
async def create_break_log_v2(data: Dict[str, Any], request: Request):
"""Create Break Rider Log endpoint - forwards to NATS"""
start_time = asyncio.get_event_loop().time()
endpoint = "/live/api/v2/partners/createbreaklog"
method = request.method
try:
message_id = await publish_to_nats(endpoint, data, method)
duration = asyncio.get_event_loop().time() - start_time
request_duration.labels(method=method, endpoint=endpoint).observe(duration)
request_count.labels(method=method, endpoint=endpoint, status="200").inc()
return JSONResponse(
status_code=200,
content={"status": "accepted", "message_id": message_id, "endpoint": endpoint}
)
except HTTPException:
raise
except Exception as e:
request_count.labels(method=method, endpoint=endpoint, status="500").inc()
print(f"❌ Error processing {endpoint}: {e}")
raise HTTPException(status_code=500, detail=str(e))
# Endpoint 5: Update Break Rider Log (v2) - Supports both POST and PUT
@app.post("/live/api/v2/partners/updatebreaklog")
@app.put("/live/api/v2/partners/updatebreaklog")
async def update_break_log_v2(data: Dict[str, Any], request: Request):
"""Update Break Rider Log endpoint - forwards to NATS"""
start_time = asyncio.get_event_loop().time()
endpoint = "/live/api/v2/partners/updatebreaklog"
method = request.method # Will be POST or PUT
try:
message_id = await publish_to_nats(endpoint, data, method)
duration = asyncio.get_event_loop().time() - start_time
request_duration.labels(method=method, endpoint=endpoint).observe(duration)
request_count.labels(method=method, endpoint=endpoint, status="200").inc()
return JSONResponse(
status_code=200,
content={"status": "accepted", "message_id": message_id, "endpoint": endpoint}
)
except HTTPException:
raise
except Exception as e:
request_count.labels(method=method, endpoint=endpoint, status="500").inc()
print(f"❌ Error processing {endpoint}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.get("/metrics")
async def metrics():
"""Prometheus metrics endpoint"""
return Response(content=generate_latest(REGISTRY), media_type="text/plain")
@app.get("/routes")
async def list_routes():
"""List all configured routes"""
return {
"routes": ENDPOINT_ROUTES,
"total": len(ENDPOINT_ROUTES)
}
# Endpoint 6: Create Order (Mob V1)
@app.post("/live/api/v1/mob/orders/createorder")
async def create_order_mob_v1(data: Dict[str, Any], request: Request):
"""Create Order endpoint (Mobile) - forwards to NATS"""
start_time = asyncio.get_event_loop().time()
endpoint = "/live/api/v1/mob/orders/createorder"
method = request.method
try:
message_id = await publish_to_nats(endpoint, data, method)
duration = asyncio.get_event_loop().time() - start_time
request_duration.labels(method=method, endpoint=endpoint).observe(duration)
request_count.labels(method=method, endpoint=endpoint, status="200").inc()
return JSONResponse(
status_code=200,
content={"status": "accepted", "message_id": message_id, "endpoint": endpoint}
)
except HTTPException:
raise
except Exception as e:
request_count.labels(method=method, endpoint=endpoint, status="500").inc()
print(f"❌ Error processing {endpoint}: {e}")
raise HTTPException(status_code=500, detail=str(e))
# Endpoint 7: Create Product (Web V1)
@app.post("/live/api/v1/web/products/create")
async def create_product_web_v1(data: Dict[str, Any], request: Request):
"""Create Product endpoint (Web) - forwards to NATS"""
start_time = asyncio.get_event_loop().time()
endpoint = "/live/api/v1/web/products/create"
method = request.method
try:
message_id = await publish_to_nats(endpoint, data, method)
duration = asyncio.get_event_loop().time() - start_time
request_duration.labels(method=method, endpoint=endpoint).observe(duration)
request_count.labels(method=method, endpoint=endpoint, status="200").inc()
return JSONResponse(
status_code=200,
content={"status": "accepted", "message_id": message_id, "endpoint": endpoint}
)
except HTTPException:
raise
except Exception as e:
request_count.labels(method=method, endpoint=endpoint, status="500").inc()
print(f"❌ Error processing {endpoint}: {e}")
raise HTTPException(status_code=500, detail=str(e))
# Endpoint 8: Create Customer (Mob V1)
@app.post("/live/api/v1/mob/customers/create")
async def create_customer_v1(data: Dict[str, Any], request: Request):
"""Create Customer endpoint (Mobile) - synchronous wait for NATS worker"""
start_time = asyncio.get_event_loop().time()
endpoint = "/live/api/v1/mob/customers/create"
method = request.method
try:
status_code, content = await publish_to_nats_sync(endpoint, data, method)
duration = asyncio.get_event_loop().time() - start_time
request_duration.labels(method=method, endpoint=endpoint).observe(duration)
request_count.labels(method=method, endpoint=endpoint, status=str(status_code)).inc()
return JSONResponse(
status_code=status_code,
content=content
)
except HTTPException:
raise
except Exception as e:
request_count.labels(method=method, endpoint=endpoint, status="500").inc()
print(f"❌ Error processing {endpoint}: {e}")
raise HTTPException(status_code=500, detail=str(e))
# Endpoint 9: Login Customer (Mob V1)
@app.post("/live/api/v1/mob/customers/login")
async def login_customer_v1(data: Dict[str, Any], request: Request):
"""Login Customer endpoint (Mobile) - synchronous wait for NATS worker"""
start_time = asyncio.get_event_loop().time()
endpoint = "/live/api/v1/mob/customers/login"
method = request.method
try:
status_code, content = await publish_to_nats_sync(endpoint, data, method)
duration = asyncio.get_event_loop().time() - start_time
request_duration.labels(method=method, endpoint=endpoint).observe(duration)
request_count.labels(method=method, endpoint=endpoint, status=str(status_code)).inc()
return JSONResponse(
status_code=status_code,
content=content
)
except HTTPException:
raise
except Exception as e:
request_count.labels(method=method, endpoint=endpoint, status="500").inc()
print(f"❌ Error processing {endpoint}: {e}")
raise HTTPException(status_code=500, detail=str(e))
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000)

View File

@@ -0,0 +1,40 @@
#!/usr/bin/env python3
"""
Purge old failed messages from NATS JetStream
"""
import asyncio
import nats
import os
async def purge_messages():
nats_url = "nats://nats.workolik.com:4222"
nats_user = "admin"
nats_password = "package@321#"
try:
print(f"Connecting to NATS at {nats_url}...")
nc = await nats.connect(
servers=[nats_url],
user=nats_user,
password=nats_password
)
print("✅ Connected to NATS")
js = nc.jetstream()
# Purge the stream to remove all old messages
print("Purging old messages from stream 'EVENTS'...")
await js.purge_stream("EVENTS")
print("✅ Stream purged - all old messages removed")
await nc.close()
print("✅ Done!")
except Exception as e:
print(f"❌ Error: {e}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
asyncio.run(purge_messages())

166
scripts/setup_jetstream.py Normal file
View File

@@ -0,0 +1,166 @@
#!/usr/bin/env python3
"""
Setup JetStream stream and consumer for NATS
Run this after NATS is deployed and running
"""
import asyncio
import nats
import os
import sys
async def setup_jetstream():
"""Configure JetStream with two streams (DELIVERIES, RIDER) and per-subject consumers."""
nats_url = os.getenv("NATS_URL", "nats://nats.workolik.com:4222")
nats_user = os.getenv("NATS_USER", "admin")
nats_password = os.getenv("NATS_PASSWORD", "package@321#")
# Stream definitions
streams = {
"DELIVERIES": {
"subjects": [
"api.v1.deliveries.createdeliveries",
"api.v1.deliveries.updatedelivery",
"api.v2.deliveries.createdeliverylog",
],
},
"RIDER": {
"subjects": [
"api.v2.partners.createriderlog",
"api.v2.partners.createbreaklog",
"api.v2.partners.updatebreaklog",
],
},
"ORDERS": {
"subjects": [
"api.v1.mob.orders.createorder",
],
},
"PRODUCTS": {
"subjects": [
"api.v1.web.products.create",
],
},
"CUSTOMERS": {
"subjects": [
"api.v1.mob.customers.login",
"api.v1.mob.customers.create",
],
"retention": "work" # Special handling for Login queue: delete immediately after ack
},
}
# Per-subject durable consumers
consumers = {
"DELIVERIES": {
"api.v1.deliveries.createdeliveries": "deliveries_createdeliveries",
"api.v1.deliveries.updatedelivery": "deliveries_updatedelivery",
"api.v2.deliveries.createdeliverylog": "deliveries_createdeliverylog",
},
"RIDER": {
"api.v2.partners.createriderlog": "rider_createriderlog",
"api.v2.partners.createbreaklog": "rider_createbreaklog",
"api.v2.partners.updatebreaklog": "rider_updatebreaklog",
},
"ORDERS": {
"api.v1.mob.orders.createorder": "orders_createorder",
},
"PRODUCTS": {
"api.v1.web.products.create": "products_create",
},
"CUSTOMERS": {
"api.v1.mob.customers.login": "customers_login",
"api.v1.mob.customers.create": "customers_create",
},
}
try:
print(f"Connecting to NATS at {nats_url}...")
nc = await nats.connect(
servers=[nats_url],
user=nats_user,
password=nats_password,
)
print("✅ Connected to NATS")
js = nc.jetstream()
# Create / recreate streams
for stream_name, cfg in streams.items():
try:
info = await js.stream_info(stream_name)
print(f"⚠️ Stream '{stream_name}' already exists with subjects={info.config.subjects}, updating...")
# Determine retention policy
retention_policy = cfg.get("retention", "limits")
await js.update_stream(
name=stream_name,
subjects=cfg["subjects"],
storage="memory",
retention=retention_policy,
max_age=24 * 60 * 60,
max_msgs=50000,
max_bytes=512 * 1024 * 1024,
)
print(f"✅ Stream '{stream_name}' updated")
except Exception as e:
if "not found" in str(e).lower() or "404" in str(e).lower():
print(f"Creating stream '{stream_name}'...")
# Determine retention policy (use 'limits' by default, 'work' for queues)
retention_policy = cfg.get("retention", "limits")
await js.add_stream(
name=stream_name,
subjects=cfg["subjects"],
storage="memory",
retention=retention_policy,
max_age=24 * 60 * 60,
max_msgs=50000,
max_bytes=512 * 1024 * 1024,
)
print(f"✅ Stream '{stream_name}' created")
else:
print(f"⚠️ Could not inspect stream '{stream_name}': {e}")
# Create durable consumers per subject
print("\nConfiguring consumers...")
for stream_name, subject_map in consumers.items():
for subject, durable in subject_map.items():
try:
print(f"Creating consumer '{durable}' on stream '{stream_name}' for subject '{subject}'...")
await js.add_consumer(
stream_name,
durable_name=durable,
filter_subject=subject,
ack_policy="explicit",
deliver_policy="all",
max_deliver=5,
ack_wait=30,
)
print(f"✅ Consumer '{durable}' created")
except Exception as e:
if "already in use" in str(e).lower() or "already exists" in str(e).lower():
print(f"⚠️ Consumer '{durable}' already exists, skipping...")
else:
raise
print("\n✅ JetStream setup complete!")
print(" Streams:")
for name, cfg in streams.items():
print(f" - {name}: {', '.join(cfg['subjects'])}")
print(" Consumers:")
for stream_name, subject_map in consumers.items():
for subject, durable in subject_map.items():
print(f" - {durable}: stream={stream_name}, subject={subject}")
await nc.close()
sys.exit(0)
except Exception as e:
print(f"❌ Error: {e}")
sys.exit(1)
if __name__ == "__main__":
asyncio.run(setup_jetstream())

66
scripts/sync_manifests.py Normal file
View File

@@ -0,0 +1,66 @@
import os
def update_yaml_with_script(yaml_path, script_path, key_line_start):
with open(script_path, 'r', encoding='utf-8') as f:
script_content = f.read()
# Indent script content by 4 spaces
indented_script = '\n'.join(' ' + line if line.strip() else line for line in script_content.splitlines())
with open(yaml_path, 'r', encoding='utf-8') as f:
yaml_lines = f.readlines()
# Find the key line (e.g., " app.py: |")
start_index = -1
for i, line in enumerate(yaml_lines):
if key_line_start in line:
start_index = i + 1
break
if start_index == -1:
print(f"Error: Could not find '{key_line_start}' in {yaml_path}")
return
# Find where the script block ends (next line that is NOT indented by at least 4 spaces, or EOF)
# Actually, the Data block might be the last thing.
# We assume the script goes until the end of the file or next unindented key.
# In these files, the script is usually the main data.
# Let's just truncate and append if it looks like the script is the last/main thing.
# But usually, it's safer to just replace the lines that look like script.
# Simple heuristic: The script block ends when indentation drops to 2 spaces or 0?
# In worker-script.yaml:
# 6: data:
# 7: worker.py: |
# 8: ...script...
# The script is indented by 4 spaces.
pre_script = yaml_lines[:start_index]
# We will just write the pre_script + indented_script
# WARNING: If there are other keys after worker.py, this deletes them.
# Let's check the files.
# worker-script.yaml: 378 lines. Script ends at 378. Nothing follows.
# fiesta-gateway.yaml: 407 lines. Script ends at 407. Nothing follows.
# So appending is SAFE.
with open(yaml_path, 'w', encoding='utf-8') as f:
f.writelines(pre_script)
f.write(indented_script)
f.write('\n') # Ensure newline at EOF
print(f"Successfully updated {yaml_path}")
# Update Fiesta Gateway
update_yaml_with_script(
r'e:\nats\kubernetes\manifests\nearle\fiesta-gateway.yaml',
r'e:\nats\kubernetes\conf\app.py',
' app.py: |'
)
# Update Worker Script
update_yaml_with_script(
r'e:\nats\kubernetes\manifests\core\worker-script.yaml',
r'e:\nats\kubernetes\conf\worker.py',
' worker.py: |'
)

285
scripts/worker.py Normal file
View File

@@ -0,0 +1,285 @@
#!/usr/bin/env python3
"""
NATS Worker that consumes messages and routes them to different external endpoints
Based on the endpoint in the message, forwards to the corresponding external API
"""
import os
import json
import asyncio
import aiohttp
import nats
from prometheus_client import Counter, Histogram, Gauge, start_http_server
import signal
import sys
# Prometheus metrics
messages_processed = Counter('worker_messages_processed_total', 'Total messages processed', ['status', 'endpoint'])
message_duration = Histogram('worker_message_duration_seconds', 'Message processing duration', ['endpoint'])
messages_in_flight = Gauge('worker_messages_in_flight', 'Messages currently being processed')
queue_depth = Gauge('worker_queue_depth', 'Approximate queue depth')
# Configuration
NATS_URL = os.getenv("NATS_URL", "nats://nats-server:4222")
NATS_USER = os.getenv("NATS_USER", "admin")
NATS_PASSWORD = os.getenv("NATS_PASSWORD", "package@321#")
NATS_STREAM = os.getenv("NATS_STREAM", "EVENTS")
NATS_SUBJECT = os.getenv("NATS_SUBJECT", "api.>") # Subscribe to all API subjects
NATS_CONSUMER = os.getenv("NATS_CONSUMER", "worker_consumer")
WORKER_CONCURRENCY = int(os.getenv("WORKER_CONCURRENCY", "10"))
RETRY_ATTEMPTS = int(os.getenv("RETRY_ATTEMPTS", "5"))
RETRY_DELAY = int(os.getenv("RETRY_DELAY_SECONDS", "5"))
# Base URL for external APIs
BASE_URL = os.getenv("EXTERNAL_BASE_URL", "https://jupiter.nearle.app")
# Endpoint mapping: FastAPI endpoint -> External API endpoint
ENDPOINT_MAPPING = {
"/live/api/v1/deliveries/createdeliveries": f"{BASE_URL}/live/api/v1/deliveries/createdeliveries",
"/live/api/v1/deliveries/updatedelivery": f"{BASE_URL}/live/api/v1/deliveries/updatedelivery",
"/live/api/v2/partners/createriderlog": f"{BASE_URL}/live/api/v2/partners/createriderlog",
"/live/api/v2/deliveries/createdeliverylog": f"{BASE_URL}/live/api/v2/deliveries/createdeliverylog",
"/live/api/v2/partners/createbreaklog": f"{BASE_URL}/live/api/v2/partners/createbreaklog",
"/live/api/v2/partners/updatebreaklog": f"{BASE_URL}/live/api/v2/partners/updatebreaklog",
}
# Global state
nc = None
js = None
running = True
semaphore = asyncio.Semaphore(WORKER_CONCURRENCY)
def signal_handler(sig, frame):
"""Handle shutdown signals"""
global running
print("\n🛑 Shutdown signal received, stopping worker...")
running = False
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
async def forward_to_external(endpoint: str, payload: dict, api_key: str = None, method: str = "POST") -> bool:
"""Forward message to external endpoint with retries"""
external_url = ENDPOINT_MAPPING.get(endpoint)
if not external_url:
print(f"⚠️ Unknown endpoint: {endpoint}, skipping...")
return False
headers = {
"Content-Type": "application/json"
}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
# Get the actual data to forward
data_to_forward = payload.get("data", payload)
# Normalize payload shape for endpoints that expect an array
if endpoint == "/live/api/v2/deliveries/createdeliverylog":
if isinstance(data_to_forward, dict):
data_to_forward = [data_to_forward]
elif not isinstance(data_to_forward, list):
# Fallback to wrapping anything that's not already list-like
data_to_forward = [data_to_forward]
# Debug logging for delivery log payload shape
print(f"➡️ Forwarding createdeliverylog payload: {data_to_forward}")
# Normalize method to uppercase
http_method = method.upper() if method else "POST"
for attempt in range(RETRY_ATTEMPTS):
try:
async with aiohttp.ClientSession() as session:
# Use the correct HTTP method
if http_method == "GET":
async with session.get(
external_url,
params=data_to_forward if isinstance(data_to_forward, dict) else {},
headers=headers,
timeout=aiohttp.ClientTimeout(total=30)
) as response:
return await _handle_response(response, endpoint, attempt)
elif http_method == "PUT":
async with session.put(
external_url,
json=data_to_forward,
headers=headers,
timeout=aiohttp.ClientTimeout(total=30)
) as response:
return await _handle_response(response, endpoint, attempt)
elif http_method == "PATCH":
async with session.patch(
external_url,
json=data_to_forward,
headers=headers,
timeout=aiohttp.ClientTimeout(total=30)
) as response:
return await _handle_response(response, endpoint, attempt)
elif http_method == "DELETE":
async with session.delete(
external_url,
headers=headers,
timeout=aiohttp.ClientTimeout(total=30)
) as response:
return await _handle_response(response, endpoint, attempt)
else: # Default to POST
async with session.post(
external_url,
json=data_to_forward,
headers=headers,
timeout=aiohttp.ClientTimeout(total=30)
) as response:
return await _handle_response(response, endpoint, attempt)
except asyncio.TimeoutError:
print(f"⚠️ Timeout forwarding {endpoint} to external API (attempt {attempt + 1}/{RETRY_ATTEMPTS})")
if attempt < RETRY_ATTEMPTS - 1:
await asyncio.sleep(RETRY_DELAY * (attempt + 1))
except Exception as e:
print(f"⚠️ Error forwarding {endpoint} to external API: {e} (attempt {attempt + 1}/{RETRY_ATTEMPTS})")
if attempt < RETRY_ATTEMPTS - 1:
await asyncio.sleep(RETRY_DELAY * (attempt + 1))
return False
async def _handle_response(response, endpoint: str, attempt: int) -> bool:
"""Handle HTTP response and determine if we should retry"""
body_text = None
try:
body_text = await response.text()
except Exception:
body_text = "<no body>"
if response.status == 200 or response.status == 201:
return True
elif response.status >= 500:
# Server error - retry
print(f"⚠️ External API returned {response.status} for {endpoint}, retrying... (attempt {attempt + 1}/{RETRY_ATTEMPTS}) body={body_text}")
if attempt < RETRY_ATTEMPTS - 1:
await asyncio.sleep(RETRY_DELAY * (attempt + 1)) # Exponential backoff
return False
else:
# Client error - don't retry
print(f"❌ External API returned {response.status} for {endpoint}, not retrying; body={body_text}")
return False
async def process_message(msg):
"""Process a single message"""
async with semaphore:
messages_in_flight.inc()
start_time = asyncio.get_event_loop().time()
endpoint = "unknown"
try:
# Decode message
payload = json.loads(msg.data.decode())
endpoint = payload.get("endpoint", payload.get("original_path", "unknown"))
http_method = payload.get("method", "POST") # Get HTTP method from payload
print(f"📨 Processing message for endpoint: {endpoint} (method: {http_method})")
# Forward to external endpoint with correct HTTP method
api_key = os.getenv("EXTERNAL_ENDPOINT_API_KEY", "")
success = await forward_to_external(endpoint, payload, api_key if api_key else None, http_method)
duration = asyncio.get_event_loop().time() - start_time
message_duration.labels(endpoint=endpoint).observe(duration)
if success:
# Acknowledge message (remove from queue)
await msg.ack()
messages_processed.labels(status="success", endpoint=endpoint).inc()
print(f"✅ Message processed and forwarded successfully for {endpoint}")
else:
# Negative acknowledge (requeue for retry)
await msg.nak()
messages_processed.labels(status="failed", endpoint=endpoint).inc()
print(f"❌ Failed to forward message for {endpoint}, requeued for retry")
except json.JSONDecodeError as e:
print(f"❌ Invalid JSON in message: {e}")
await msg.term() # Terminate (don't retry)
messages_processed.labels(status="invalid", endpoint=endpoint).inc()
except Exception as e:
print(f"❌ Error processing message for {endpoint}: {e}")
await msg.nak() # Requeue for retry
messages_processed.labels(status="error", endpoint=endpoint).inc()
finally:
messages_in_flight.dec()
async def consume_messages():
"""Consume messages from NATS JetStream"""
global nc, js
try:
print(f"Connecting to NATS at {NATS_URL}...")
nc = await nats.connect(
servers=[NATS_URL],
user=NATS_USER,
password=NATS_PASSWORD,
reconnect_time_wait=2,
max_reconnect_attempts=-1 # Retry forever
)
js = nc.jetstream()
print("✅ Connected to NATS JetStream")
# Subscribe to messages (using wildcard to catch all API subjects)
print(f"Subscribing to stream '{NATS_STREAM}', consumer '{NATS_CONSUMER}'...")
print(f" Subject pattern: {NATS_SUBJECT}")
print(f" Endpoints configured: {len(ENDPOINT_MAPPING)}")
psub = await js.pull_subscribe(
NATS_SUBJECT,
NATS_CONSUMER,
stream=NATS_STREAM
)
print(f"✅ Subscribed, waiting for messages...")
# Start metrics server
start_http_server(9090)
print("✅ Metrics server started on port 9090")
# Process messages in a loop
while running:
try:
# Fetch messages (batch of up to 10)
msgs = await psub.fetch(10, timeout=5)
for msg in msgs:
# Process asynchronously
asyncio.create_task(process_message(msg))
except asyncio.TimeoutError:
# No messages available, continue
continue
except Exception as e:
print(f"⚠️ Error fetching messages: {e}")
await asyncio.sleep(1)
except Exception as e:
print(f"❌ Fatal error: {e}")
raise
finally:
if nc:
await nc.close()
print("NATS connection closed")
async def main():
"""Main entry point"""
print("🚀 Starting NATS Worker (Multi-Endpoint)...")
print(f" External Base URL: {BASE_URL}")
print(f" Concurrency: {WORKER_CONCURRENCY}")
print(f" Retry Attempts: {RETRY_ATTEMPTS}")
print(f" Configured Endpoints:")
for fastapi_endpoint, external_url in ENDPOINT_MAPPING.items():
print(f" {fastapi_endpoint}{external_url}")
try:
await consume_messages()
except KeyboardInterrupt:
print("\n🛑 Worker stopped by user")
except Exception as e:
print(f"❌ Worker crashed: {e}")
sys.exit(1)
if __name__ == "__main__":
asyncio.run(main())