276 lines
8.9 KiB
Python
276 lines
8.9 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
DexOrder User Container Main Entry Point
|
|
|
|
Brings together:
|
|
- Config and secrets loading from k8s mounted YAML files
|
|
- ZeroMQ event publisher for user events
|
|
- MCP server with minimal "hello world" resource
|
|
- Lifecycle management integration
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
import os
|
|
import signal
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
import yaml
|
|
from mcp.server import Server
|
|
from mcp.server.stdio import stdio_server
|
|
|
|
from dexorder import EventPublisher, start_lifecycle_manager, get_lifecycle_manager
|
|
from dexorder.events import EventType, UserEvent, DeliverySpec
|
|
|
|
|
|
# =============================================================================
|
|
# Configuration
|
|
# =============================================================================
|
|
|
|
class Config:
|
|
"""Application configuration loaded from config.yaml and secrets.yaml"""
|
|
|
|
def __init__(self):
|
|
# User ID (required)
|
|
self.user_id: str = os.getenv("USER_ID", "")
|
|
if not self.user_id:
|
|
raise ValueError("USER_ID environment variable required")
|
|
|
|
# Config and secrets paths (k8s mounted)
|
|
self.config_path = Path(os.getenv("CONFIG_PATH", "/app/config/config.yaml"))
|
|
self.secrets_path = Path(os.getenv("SECRETS_PATH", "/app/config/secrets.yaml"))
|
|
|
|
# ZMQ ports for event system
|
|
self.zmq_xpub_port: int = int(os.getenv("ZMQ_XPUB_PORT", "5570"))
|
|
self.zmq_gateway_endpoint: str = os.getenv(
|
|
"ZMQ_GATEWAY_ENDPOINT",
|
|
"tcp://gateway:5571"
|
|
)
|
|
|
|
# MCP server settings
|
|
self.mcp_server_name: str = os.getenv("MCP_SERVER_NAME", "dexorder-user")
|
|
|
|
# Lifecycle settings
|
|
self.idle_timeout_minutes: int = int(os.getenv("IDLE_TIMEOUT_MINUTES", "15"))
|
|
self.enable_idle_shutdown: bool = os.getenv("ENABLE_IDLE_SHUTDOWN", "true").lower() == "true"
|
|
|
|
# Loaded from files
|
|
self.config_data: dict = {}
|
|
self.secrets_data: dict = {}
|
|
|
|
def load(self) -> None:
|
|
"""Load configuration and secrets from YAML files"""
|
|
# Load config.yaml if exists
|
|
if self.config_path.exists():
|
|
with open(self.config_path) as f:
|
|
self.config_data = yaml.safe_load(f) or {}
|
|
logging.info(f"Loaded config from {self.config_path}")
|
|
else:
|
|
logging.warning(f"Config file not found: {self.config_path}")
|
|
|
|
# Load secrets.yaml if exists
|
|
if self.secrets_path.exists():
|
|
with open(self.secrets_path) as f:
|
|
self.secrets_data = yaml.safe_load(f) or {}
|
|
logging.info(f"Loaded secrets from {self.secrets_path}")
|
|
else:
|
|
logging.warning(f"Secrets file not found: {self.secrets_path}")
|
|
|
|
|
|
# =============================================================================
|
|
# MCP Server Setup
|
|
# =============================================================================
|
|
|
|
def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server:
|
|
"""Create MCP server with minimal hello world resource"""
|
|
|
|
server = Server(config.mcp_server_name)
|
|
|
|
@server.list_resources()
|
|
async def list_resources():
|
|
"""List available resources"""
|
|
return [
|
|
{
|
|
"uri": f"dexorder://user/{config.user_id}/hello",
|
|
"name": "Hello World",
|
|
"description": "A simple hello world resource",
|
|
"mimeType": "text/plain",
|
|
}
|
|
]
|
|
|
|
@server.read_resource()
|
|
async def read_resource(uri: str):
|
|
"""Read a resource by URI"""
|
|
if uri == f"dexorder://user/{config.user_id}/hello":
|
|
# Publish an event when resource is accessed
|
|
await event_publisher.publish(UserEvent(
|
|
event_type=EventType.STRATEGY_LOG,
|
|
payload={
|
|
"message": "Hello world resource accessed",
|
|
"uri": uri,
|
|
},
|
|
delivery=DeliverySpec.informational(),
|
|
))
|
|
|
|
return {
|
|
"uri": uri,
|
|
"mimeType": "text/plain",
|
|
"text": f"Hello from DexOrder user container!\nUser ID: {config.user_id}\n",
|
|
}
|
|
else:
|
|
raise ValueError(f"Unknown resource: {uri}")
|
|
|
|
logging.info(f"MCP server '{config.mcp_server_name}' created")
|
|
return server
|
|
|
|
|
|
# =============================================================================
|
|
# Main Application
|
|
# =============================================================================
|
|
|
|
class UserContainer:
|
|
"""Main user container application"""
|
|
|
|
def __init__(self):
|
|
self.config = Config()
|
|
self.event_publisher: Optional[EventPublisher] = None
|
|
self.mcp_server: Optional[Server] = None
|
|
self.running = False
|
|
|
|
async def start(self) -> None:
|
|
"""Start all subsystems"""
|
|
logging.info("Starting user container")
|
|
|
|
# Load configuration
|
|
self.config.load()
|
|
|
|
# Start lifecycle manager
|
|
await start_lifecycle_manager(
|
|
user_id=self.config.user_id,
|
|
idle_timeout_minutes=self.config.idle_timeout_minutes,
|
|
enable_idle_shutdown=self.config.enable_idle_shutdown,
|
|
)
|
|
logging.info("Lifecycle manager started")
|
|
|
|
# Start event publisher
|
|
self.event_publisher = EventPublisher(
|
|
user_id=self.config.user_id,
|
|
xpub_port=self.config.zmq_xpub_port,
|
|
gateway_router_endpoint=self.config.zmq_gateway_endpoint,
|
|
)
|
|
await self.event_publisher.start()
|
|
logging.info("Event publisher started")
|
|
|
|
# Publish CONTAINER_STARTING event
|
|
await self.event_publisher.publish(UserEvent(
|
|
event_type=EventType.CONTAINER_STARTING,
|
|
payload={
|
|
"user_id": self.config.user_id,
|
|
"timestamp": None, # Will be auto-filled
|
|
},
|
|
delivery=DeliverySpec.active_or_telegram(),
|
|
))
|
|
|
|
# Create MCP server
|
|
self.mcp_server = create_mcp_server(self.config, self.event_publisher)
|
|
|
|
# Publish CONTAINER_READY event
|
|
await self.event_publisher.publish(UserEvent(
|
|
event_type=EventType.CONTAINER_READY,
|
|
payload={
|
|
"user_id": self.config.user_id,
|
|
},
|
|
delivery=DeliverySpec.active_or_telegram(),
|
|
))
|
|
|
|
self.running = True
|
|
logging.info("User container ready")
|
|
|
|
async def stop(self) -> None:
|
|
"""Stop all subsystems"""
|
|
if not self.running:
|
|
return
|
|
|
|
logging.info("Stopping user container")
|
|
self.running = False
|
|
|
|
# Publish CONTAINER_SHUTTING_DOWN event
|
|
if self.event_publisher:
|
|
await self.event_publisher.publish(UserEvent(
|
|
event_type=EventType.CONTAINER_SHUTTING_DOWN,
|
|
payload={
|
|
"user_id": self.config.user_id,
|
|
},
|
|
delivery=DeliverySpec.active_or_telegram(),
|
|
))
|
|
|
|
# Stop subsystems
|
|
if self.event_publisher:
|
|
await self.event_publisher.stop()
|
|
logging.info("Event publisher stopped")
|
|
|
|
lifecycle = get_lifecycle_manager()
|
|
if lifecycle:
|
|
await lifecycle.stop()
|
|
logging.info("Lifecycle manager stopped")
|
|
|
|
logging.info("User container stopped")
|
|
|
|
async def run(self) -> None:
|
|
"""Run the MCP server via stdio"""
|
|
await self.start()
|
|
|
|
try:
|
|
# Run MCP server on stdio
|
|
async with stdio_server() as (read_stream, write_stream):
|
|
await self.mcp_server.run(
|
|
read_stream,
|
|
write_stream,
|
|
self.mcp_server.create_initialization_options()
|
|
)
|
|
finally:
|
|
await self.stop()
|
|
|
|
|
|
# =============================================================================
|
|
# Entry Point
|
|
# =============================================================================
|
|
|
|
async def main():
|
|
"""Main entry point"""
|
|
# Setup logging
|
|
log_level = os.getenv("LOG_LEVEL", "INFO").upper()
|
|
logging.basicConfig(
|
|
level=getattr(logging, log_level),
|
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
stream=sys.stderr, # MCP uses stdout for protocol
|
|
)
|
|
|
|
# Create and run container
|
|
container = UserContainer()
|
|
|
|
# Handle shutdown signals
|
|
loop = asyncio.get_event_loop()
|
|
|
|
def handle_signal(sig):
|
|
logging.info(f"Received signal {sig}, shutting down...")
|
|
asyncio.create_task(container.stop())
|
|
loop.stop()
|
|
|
|
for sig in (signal.SIGTERM, signal.SIGINT):
|
|
loop.add_signal_handler(sig, lambda s=sig: handle_signal(s))
|
|
|
|
try:
|
|
await container.run()
|
|
except KeyboardInterrupt:
|
|
logging.info("Keyboard interrupt received")
|
|
except Exception as e:
|
|
logging.error(f"Fatal error: {e}", exc_info=True)
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
asyncio.run(main())
|