404 lines
14 KiB
Python
404 lines
14 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
|
|
from dexorder.api.workspace_tools import get_workspace_store
|
|
|
|
|
|
# =============================================================================
|
|
# Global Data Directory
|
|
# =============================================================================
|
|
|
|
# Default data directory (relative to working directory for local dev)
|
|
DEFAULT_DATA_DIR = Path("data")
|
|
|
|
# Global data directory - set after config is loaded
|
|
DATA_DIR: Path = DEFAULT_DATA_DIR
|
|
|
|
|
|
def get_data_dir() -> Path:
|
|
"""Get the global data directory."""
|
|
return DATA_DIR
|
|
|
|
|
|
# =============================================================================
|
|
# 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 = {}
|
|
|
|
# Data directory (set after config load)
|
|
self.data_dir: Path = DEFAULT_DATA_DIR
|
|
|
|
def load(self) -> None:
|
|
"""Load configuration and secrets from YAML files"""
|
|
global DATA_DIR
|
|
|
|
# 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}")
|
|
|
|
# Set data directory from config or environment
|
|
# Priority: env var > config file > default
|
|
data_dir_str = os.getenv("DATA_DIR") or self.config_data.get("data_dir")
|
|
if data_dir_str:
|
|
self.data_dir = Path(data_dir_str)
|
|
else:
|
|
self.data_dir = DEFAULT_DATA_DIR
|
|
|
|
# Update global DATA_DIR
|
|
DATA_DIR = self.data_dir
|
|
|
|
# Ensure data directory exists
|
|
self.data_dir.mkdir(parents=True, exist_ok=True)
|
|
logging.info(f"Data directory: {self.data_dir}")
|
|
|
|
@property
|
|
def workspace_dir(self) -> Path:
|
|
"""Workspace directory under DATA_DIR."""
|
|
return self.data_dir / "workspace"
|
|
|
|
|
|
# =============================================================================
|
|
# MCP Server Setup
|
|
# =============================================================================
|
|
|
|
def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server:
|
|
"""Create MCP server with resources and workspace tools"""
|
|
|
|
server = Server(config.mcp_server_name)
|
|
|
|
# Initialize workspace store
|
|
workspace_store = get_workspace_store(config.workspace_dir)
|
|
logging.info(f"Workspace store initialized at {config.workspace_dir}")
|
|
|
|
@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}")
|
|
|
|
@server.list_tools()
|
|
async def list_tools():
|
|
"""List available tools including workspace tools"""
|
|
return [
|
|
{
|
|
"name": "workspace_read",
|
|
"description": "Read a workspace store from persistent storage",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"store_name": {
|
|
"type": "string",
|
|
"description": "Name of the store (e.g., 'chartStore', 'userPreferences')"
|
|
}
|
|
},
|
|
"required": ["store_name"]
|
|
}
|
|
},
|
|
{
|
|
"name": "workspace_write",
|
|
"description": "Write a workspace store to persistent storage",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"store_name": {
|
|
"type": "string",
|
|
"description": "Name of the store"
|
|
},
|
|
"data": {
|
|
"description": "Data to write"
|
|
}
|
|
},
|
|
"required": ["store_name", "data"]
|
|
}
|
|
},
|
|
{
|
|
"name": "workspace_patch",
|
|
"description": "Apply JSON patch operations to a workspace store",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"store_name": {
|
|
"type": "string",
|
|
"description": "Name of the store"
|
|
},
|
|
"patch": {
|
|
"type": "array",
|
|
"description": "JSON Patch operations (RFC 6902)",
|
|
"items": {
|
|
"type": "object",
|
|
"properties": {
|
|
"op": {"type": "string", "enum": ["add", "remove", "replace", "move", "copy", "test"]},
|
|
"path": {"type": "string"},
|
|
"value": {}
|
|
},
|
|
"required": ["op", "path"]
|
|
}
|
|
}
|
|
},
|
|
"required": ["store_name", "patch"]
|
|
}
|
|
}
|
|
]
|
|
|
|
@server.call_tool()
|
|
async def handle_tool_call(name: str, arguments: dict):
|
|
"""Handle tool calls including workspace tools"""
|
|
if name == "workspace_read":
|
|
return workspace_store.read(arguments.get("store_name", ""))
|
|
elif name == "workspace_write":
|
|
return workspace_store.write(
|
|
arguments.get("store_name", ""),
|
|
arguments.get("data")
|
|
)
|
|
elif name == "workspace_patch":
|
|
return workspace_store.patch(
|
|
arguments.get("store_name", ""),
|
|
arguments.get("patch", [])
|
|
)
|
|
else:
|
|
raise ValueError(f"Unknown tool: {name}")
|
|
|
|
logging.info(f"MCP server '{config.mcp_server_name}' created with workspace tools")
|
|
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())
|