Compare commits
13 Commits
5e1cb64a81
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a89f46e3d | |||
| 9683cf280b | |||
| fb2c1f0c60 | |||
| 217d41d680 | |||
| 991080ae2b | |||
| 3feedd5698 | |||
| eb8553ce0b | |||
| 7dae8faf62 | |||
| 2cb5263d9e | |||
| d6f2ea90a8 | |||
| 69d18cc494 | |||
| 05aa70c4af | |||
| 9281c0e02a |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1 +1,6 @@
|
|||||||
__pycache__
|
__pycache__
|
||||||
|
.env
|
||||||
|
node_modules
|
||||||
|
cypress/screenshots
|
||||||
|
cypress/videos
|
||||||
|
cypress/downloads
|
||||||
|
|||||||
10
Dockerfile
10
Dockerfile
@@ -11,7 +11,7 @@ RUN apt-get update && apt-get install -y \
|
|||||||
RUN curl -fsSL https://opencode.ai/install | bash
|
RUN curl -fsSL https://opencode.ai/install | bash
|
||||||
|
|
||||||
# Copy OpenCode configuration
|
# Copy OpenCode configuration
|
||||||
COPY ../config_opencode /root/.config/opencode
|
COPY config_opencode /root/.config/opencode
|
||||||
|
|
||||||
# Copy session manager
|
# Copy session manager
|
||||||
COPY . /app
|
COPY . /app
|
||||||
@@ -19,8 +19,8 @@ COPY . /app
|
|||||||
# Install Python dependencies
|
# Install Python dependencies
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
# Create working directory
|
# Create working directory and auth directory
|
||||||
RUN mkdir -p /app/somedir
|
RUN mkdir -p /app/somedir /root/.local/share/opencode
|
||||||
|
|
||||||
# Expose port
|
# Expose port
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
@@ -28,5 +28,5 @@ EXPOSE 8080
|
|||||||
# Set environment variables
|
# Set environment variables
|
||||||
ENV PYTHONPATH=/app
|
ENV PYTHONPATH=/app
|
||||||
|
|
||||||
# Start OpenCode server
|
# Start OpenCode server (mDNS disabled to prevent conflicts between containers)
|
||||||
CMD ["/bin/bash", "-c", "source /root/.bashrc && opencode serve --hostname 0.0.0.0 --port 8080 --mdns"]
|
CMD ["/bin/bash", "-c", "source /root/.bashrc && opencode serve --hostname 0.0.0.0 --port 8080"]
|
||||||
36
Makefile
36
Makefile
@@ -29,14 +29,30 @@ logs:
|
|||||||
# Create a new session and display its info
|
# Create a new session and display its info
|
||||||
session:
|
session:
|
||||||
@echo "Creating new session..."
|
@echo "Creating new session..."
|
||||||
@curl -s -X POST http://localhost:8080/sessions | jq .
|
@curl -s -X POST http://localhost:8080/api/sessions | jq .
|
||||||
|
|
||||||
# Try the web interface - creates a session and opens it
|
# Try the web interface - starts stack, creates a session and opens it
|
||||||
try:
|
try: up
|
||||||
@echo "Creating session and opening web interface..."
|
@echo "Waiting for services to be ready (Docker daemon can take ~30s)..."
|
||||||
@SESSION_ID=$$(curl -s -X POST http://localhost:8080/sessions | jq -r '.session_id') && \
|
@for i in $$(seq 1 60); do \
|
||||||
echo "Session created: $$SESSION_ID" && \
|
STATUS=$$(curl -sf http://localhost:8080/api/health 2>/dev/null | jq -r '.docker // false') && \
|
||||||
echo "Opening http://localhost:8080/session/$$SESSION_ID" && \
|
[ "$$STATUS" = "true" ] && break; \
|
||||||
xdg-open "http://localhost:8080/session/$$SESSION_ID" 2>/dev/null || \
|
printf '.'; \
|
||||||
open "http://localhost:8080/session/$$SESSION_ID" 2>/dev/null || \
|
sleep 1; \
|
||||||
echo "Visit: http://localhost:8080/session/$$SESSION_ID"
|
done
|
||||||
|
@echo ""
|
||||||
|
@echo "Creating session..."
|
||||||
|
@SESSION_ID=$$(curl -s -X POST http://localhost:8080/api/sessions | jq -r '.session_id') && \
|
||||||
|
echo "Session $$SESSION_ID created, waiting for container to start..." && \
|
||||||
|
for i in $$(seq 1 30); do \
|
||||||
|
S=$$(curl -sf http://localhost:8080/api/sessions/$$SESSION_ID 2>/dev/null | jq -r '.status // "unknown"') && \
|
||||||
|
[ "$$S" = "running" ] && break; \
|
||||||
|
[ "$$S" = "error" ] && echo "Container failed to start" && exit 1; \
|
||||||
|
printf '.'; \
|
||||||
|
sleep 1; \
|
||||||
|
done && \
|
||||||
|
echo "" && \
|
||||||
|
echo "Opening http://localhost:8080/c/$$SESSION_ID" && \
|
||||||
|
(xdg-open "http://localhost:8080/c/$$SESSION_ID" 2>/dev/null || \
|
||||||
|
open "http://localhost:8080/c/$$SESSION_ID" 2>/dev/null || \
|
||||||
|
echo "Visit: http://localhost:8080/c/$$SESSION_ID")
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
# Lovdata Chat Development Environment
|
|
||||||
|
|
||||||
This setup creates a container-per-visitor architecture for the Norwegian legal research chat interface with socket-based Docker communication.
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
1. **Set up environment variables:**
|
|
||||||
```bash
|
|
||||||
cp .env.example .env
|
|
||||||
# Edit .env with your API keys and MCP server URL
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Start the services:**
|
|
||||||
```bash
|
|
||||||
docker-compose up --build
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Create a session:**
|
|
||||||
```bash
|
|
||||||
curl http://localhost/api/sessions -X POST
|
|
||||||
```
|
|
||||||
|
|
||||||
5. **Access the chat interface:**
|
|
||||||
Open the returned URL in your browser
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
- **session-manager**: FastAPI service managing container lifecycles with socket-based Docker communication
|
|
||||||
- **lovdata-mcp**: External Norwegian legal research MCP server (configured via MCP_SERVER env var)
|
|
||||||
- **caddy**: Reverse proxy with dynamic session-based routing
|
|
||||||
|
|
||||||
## Security Features
|
|
||||||
|
|
||||||
- **Socket-based Docker communication**: Direct Unix socket access for container management
|
|
||||||
- **Container isolation**: Each visitor gets dedicated container with resource limits
|
|
||||||
- **Automatic cleanup**: Sessions expire after 60 minutes of inactivity
|
|
||||||
- **Resource quotas**: 4GB RAM, 1 CPU core per container, max 3 concurrent sessions
|
|
||||||
|
|
||||||
## Development Notes
|
|
||||||
|
|
||||||
- Session data persists in ./sessions/ directory
|
|
||||||
- Docker socket mounted from host for development
|
|
||||||
- External MCP server configured via environment variables
|
|
||||||
- Health checks ensure service reliability
|
|
||||||
|
|
||||||
## API Endpoints
|
|
||||||
|
|
||||||
- `POST /api/sessions` - Create new session
|
|
||||||
- `GET /api/sessions` - List all sessions
|
|
||||||
- `GET /api/sessions/{id}` - Get session info
|
|
||||||
- `DELETE /api/sessions/{id}` - Delete session
|
|
||||||
- `POST /api/cleanup` - Manual cleanup
|
|
||||||
- `GET /api/health` - Health check
|
|
||||||
- `/{path}` - Dynamic proxy routing (with X-Session-ID header)
|
|
||||||
|
|
||||||
## Environment Variables
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Required
|
|
||||||
MCP_SERVER=http://your-lovdata-mcp-server:8001
|
|
||||||
|
|
||||||
# Optional LLM API keys
|
|
||||||
OPENAI_API_KEY=your_key
|
|
||||||
ANTHROPIC_API_KEY=your_key
|
|
||||||
GOOGLE_API_KEY=your_key
|
|
||||||
```
|
|
||||||
329
README.md
329
README.md
@@ -1,239 +1,162 @@
|
|||||||
# Lovdata Chat Interface
|
# Lovdata Chat Interface
|
||||||
|
|
||||||
A web-based chat interface that allows users to interact with Large Language Models (LLMs) equipped with Norwegian legal research tools from the Lovdata MCP server.
|
A container-per-session architecture for Norwegian legal research. Each user session gets an isolated [OpenCode](https://opencode.ai/) container connected to the external [Lovdata MCP server](https://modelcontextprotocol.io/), which provides 15+ tools for searching Norwegian laws, provisions, and cross-references.
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
This project creates a chat interface where users can:
|
|
||||||
- Choose from multiple LLM providers (OpenAI, Anthropic, Google Gemini)
|
|
||||||
- Have conversations enhanced with Norwegian legal document search capabilities
|
|
||||||
- Access laws, regulations, and legal provisions through AI-powered semantic search
|
|
||||||
- Receive properly cited legal information with cross-references
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
### Backend (FastAPI)
|
```
|
||||||
- **LLM Provider Layer**: Abstract interface supporting multiple LLM providers with tool calling
|
Users → Caddy (reverse proxy) → Session Manager (FastAPI)
|
||||||
- **MCP Integration**: Client connection to lovdata-ai MCP server
|
↓
|
||||||
- **Skill System**: Norwegian legal research guidance and best practices
|
Docker-in-Docker daemon
|
||||||
- **Chat Management**: Conversation history, streaming responses, session management
|
↓ ↓ ↓
|
||||||
|
[OC 1] [OC 2] [OC 3] ← OpenCode containers
|
||||||
|
↓ ↓ ↓
|
||||||
|
Lovdata MCP Server (external)
|
||||||
|
LLM APIs (OpenAI/Anthropic/Google)
|
||||||
|
```
|
||||||
|
|
||||||
### Frontend (Next.js)
|
| Component | Purpose |
|
||||||
- **Chat Interface**: Real-time messaging with streaming responses
|
|-----------|---------|
|
||||||
- **Model Selector**: Dropdown to choose LLM provider and model
|
| **Session Manager** | FastAPI service managing OpenCode container lifecycles |
|
||||||
- **Tool Visualization**: Display when legal tools are being used
|
| **OpenCode Containers** | Isolated chat environments with MCP integration |
|
||||||
- **Citation Rendering**: Properly formatted legal references and cross-references
|
| **Lovdata MCP Server** | External Norwegian legal research (laws, provisions, cross-references) |
|
||||||
|
| **Caddy** | Reverse proxy with dynamic session-based routing |
|
||||||
|
| **PostgreSQL** | Session persistence across restarts |
|
||||||
|
| **Docker-in-Docker** | TLS-secured Docker daemon for container management |
|
||||||
|
|
||||||
### External Dependencies
|
### Session Manager Components
|
||||||
- **Lovdata MCP Server**: Provides 15+ tools for Norwegian legal research
|
|
||||||
- **PostgreSQL Database**: Vector embeddings for semantic search
|
|
||||||
- **LLM APIs**: OpenAI, Anthropic, Google Gemini (with API keys)
|
|
||||||
|
|
||||||
## Supported LLM Providers
|
```
|
||||||
|
main.py → FastAPI endpoints, session lifecycle orchestration
|
||||||
|
docker_service.py → Docker abstraction layer (testable, mockable)
|
||||||
|
async_docker_client.py → Async Docker operations
|
||||||
|
database.py → PostgreSQL session persistence with asyncpg
|
||||||
|
session_auth.py → Token-based session authentication
|
||||||
|
container_health.py → Health monitoring and auto-recovery
|
||||||
|
resource_manager.py → CPU/memory limits, throttling
|
||||||
|
http_pool.py → Connection pooling for container HTTP requests
|
||||||
|
host_ip_detector.py → Docker host IP detection
|
||||||
|
logging_config.py → Structured JSON logging with context
|
||||||
|
```
|
||||||
|
|
||||||
| Provider | Models | Tool Support | Notes |
|
## Quick Start
|
||||||
|----------|--------|--------------|-------|
|
|
||||||
| OpenAI | GPT-4, GPT-4o | ✅ Native | Requires API key |
|
|
||||||
| Anthropic | Claude-3.5-Sonnet | ✅ Native | Requires API key |
|
|
||||||
| Google | Gemini-1.5-Pro | ✅ Function calling | Requires API key |
|
|
||||||
| Local | Ollama models | ⚠️ Limited | Self-hosted option |
|
|
||||||
|
|
||||||
## MCP Tools Available
|
1. **Set up environment variables:**
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env with your API keys and MCP server URL
|
||||||
|
```
|
||||||
|
|
||||||
The interface integrates all tools from the lovdata-ai MCP server:
|
2. **Start the services:**
|
||||||
|
```bash
|
||||||
|
docker-compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
### Law Document Tools
|
3. **Create a session:**
|
||||||
- `get_law`: Retrieve specific laws by ID or title
|
```bash
|
||||||
- `list_laws`: Browse laws with filtering and pagination
|
curl http://localhost/api/sessions -X POST
|
||||||
- `get_law_content`: Get HTML content of laws
|
```
|
||||||
- `get_law_text`: Get plain text content
|
|
||||||
|
|
||||||
### Search Tools
|
4. **Access the chat interface** at the URL returned in step 3.
|
||||||
- `search_laws_fulltext`: Full-text search in laws
|
|
||||||
- `search_laws_semantic`: Semantic search using vector embeddings
|
|
||||||
- `search_provisions_fulltext`: Full-text search in provisions
|
|
||||||
- `search_provisions_semantic`: Semantic search in provisions
|
|
||||||
|
|
||||||
### Provision Tools
|
## Development
|
||||||
- `get_provision`: Get individual legal provisions
|
|
||||||
- `list_provisions`: List all provisions in a law
|
|
||||||
- `get_provisions_batch`: Bulk retrieval for RAG applications
|
|
||||||
|
|
||||||
### Reference Tools
|
### Running the Stack
|
||||||
- `get_cross_references`: Find references from/to provisions
|
|
||||||
- `resolve_reference`: Parse legal reference strings (e.g., "lov/2014-06-20-42/§8")
|
|
||||||
|
|
||||||
## Skills Integration
|
|
||||||
|
|
||||||
The system loads Norwegian legal research skills that ensure:
|
|
||||||
- Proper citation standards (Lovdata URL formatting)
|
|
||||||
- Appropriate legal terminology usage
|
|
||||||
- Clear distinction between information and legal advice
|
|
||||||
- Systematic amendment tracking
|
|
||||||
- Cross-reference analysis
|
|
||||||
|
|
||||||
## Implementation Plan
|
|
||||||
|
|
||||||
### Phase 1: Core Infrastructure
|
|
||||||
1. **Project Structure Setup**
|
|
||||||
- Create backend (FastAPI) and frontend (Next.js) directories
|
|
||||||
- Set up Python virtual environment and Node.js dependencies
|
|
||||||
- Configure development tooling (linting, testing, formatting)
|
|
||||||
|
|
||||||
2. **LLM Provider Abstraction**
|
|
||||||
- Create abstract base class for LLM providers
|
|
||||||
- Implement OpenAI, Anthropic, and Google Gemini clients
|
|
||||||
- Add tool calling support and response streaming
|
|
||||||
- Implement provider switching logic
|
|
||||||
|
|
||||||
3. **MCP Server Integration**
|
|
||||||
- Build MCP client to connect to lovdata-ai server
|
|
||||||
- Create tool registry and execution pipeline
|
|
||||||
- Add error handling and retry logic
|
|
||||||
- Implement tool result formatting for LLM consumption
|
|
||||||
|
|
||||||
### Phase 2: Chat Functionality
|
|
||||||
4. **Backend API Development**
|
|
||||||
- Create chat session management endpoints
|
|
||||||
- Implement conversation history storage
|
|
||||||
- Add streaming response support
|
|
||||||
- Build health check and monitoring endpoints
|
|
||||||
|
|
||||||
5. **Skill System Implementation**
|
|
||||||
- Create skill loading and parsing system
|
|
||||||
- Implement skill application to LLM prompts
|
|
||||||
- Add skill validation and error handling
|
|
||||||
- Create skill management API endpoints
|
|
||||||
|
|
||||||
### Phase 3: Frontend Development
|
|
||||||
6. **Chat Interface**
|
|
||||||
- Build responsive chat UI with message history
|
|
||||||
- Implement real-time message streaming
|
|
||||||
- Add message formatting for legal citations
|
|
||||||
- Create conversation management (new chat, clear history)
|
|
||||||
|
|
||||||
7. **Model Selection UI**
|
|
||||||
- Create LLM provider and model selector
|
|
||||||
- Add API key management (secure storage)
|
|
||||||
- Implement model switching during conversations
|
|
||||||
- Add model capability indicators
|
|
||||||
|
|
||||||
8. **Tool Usage Visualization**
|
|
||||||
- Display when MCP tools are being used
|
|
||||||
- Show tool execution results in chat
|
|
||||||
- Add legal citation formatting
|
|
||||||
- Create expandable tool result views
|
|
||||||
|
|
||||||
### Phase 4: Deployment & Production
|
|
||||||
9. **Containerization**
|
|
||||||
- Create Dockerfiles for backend and frontend
|
|
||||||
- Set up Docker Compose for development
|
|
||||||
- Configure production Docker Compose
|
|
||||||
- Add environment variable management
|
|
||||||
|
|
||||||
10. **Deployment Configuration**
|
|
||||||
- Set up CI/CD pipeline (GitHub Actions)
|
|
||||||
- Configure cloud deployment (Railway/Render)
|
|
||||||
- Add reverse proxy configuration
|
|
||||||
- Implement SSL certificate management
|
|
||||||
|
|
||||||
11. **Monitoring & Error Handling**
|
|
||||||
- Add comprehensive logging
|
|
||||||
- Implement error tracking and reporting
|
|
||||||
- Create health check endpoints
|
|
||||||
- Add rate limiting and abuse protection
|
|
||||||
|
|
||||||
12. **Documentation**
|
|
||||||
- Create setup and deployment guides
|
|
||||||
- Document API endpoints
|
|
||||||
- Add user documentation
|
|
||||||
- Create troubleshooting guides
|
|
||||||
|
|
||||||
## Development Setup
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
- Python 3.12+
|
|
||||||
- Node.js 18+
|
|
||||||
- Docker and Docker Compose
|
|
||||||
- API keys for desired LLM providers
|
|
||||||
|
|
||||||
### Local Development
|
|
||||||
```bash
|
```bash
|
||||||
# Clone and setup
|
# Start all services (session-manager, docker-daemon, caddy)
|
||||||
git clone <repository>
|
docker-compose up --build
|
||||||
cd lovdata-chat
|
|
||||||
|
|
||||||
# Backend setup
|
# Start in background
|
||||||
cd backend
|
docker-compose up -d --build
|
||||||
python -m venv venv
|
|
||||||
source venv/bin/activate # On Windows: venv\Scripts\activate
|
# View logs
|
||||||
|
docker-compose logs -f session-manager
|
||||||
|
|
||||||
|
# Stop services
|
||||||
|
docker-compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
### Session Management API
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POST /api/sessions # Create new session
|
||||||
|
GET /api/sessions # List all sessions
|
||||||
|
GET /api/sessions/{id} # Get session info
|
||||||
|
DELETE /api/sessions/{id} # Delete session
|
||||||
|
POST /api/cleanup # Manual cleanup
|
||||||
|
GET /api/health # Health check
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running Locally (without Docker)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd session-manager
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
|
uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
||||||
# Frontend setup
|
|
||||||
cd ../frontend
|
|
||||||
npm install
|
|
||||||
|
|
||||||
# Start development servers
|
|
||||||
docker-compose -f docker-compose.dev.yml up
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Environment Variables
|
### Testing
|
||||||
|
|
||||||
|
Test scripts live in `docker/scripts/` and are self-contained:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Backend
|
python docker/scripts/test-docker-service.py
|
||||||
LOVDATA_MCP_URL=http://localhost:8001
|
python docker/scripts/test-async-docker.py
|
||||||
OPENAI_API_KEY=your_key_here
|
python docker/scripts/test-resource-limits.py
|
||||||
ANTHROPIC_API_KEY=your_key_here
|
python docker/scripts/test-session-auth.py
|
||||||
GOOGLE_API_KEY=your_key_here
|
python docker/scripts/test-database-persistence.py
|
||||||
|
python docker/scripts/test-container-health.py
|
||||||
# Frontend
|
python docker/scripts/test-http-connection-pool.py
|
||||||
NEXT_PUBLIC_API_URL=http://localhost:8000
|
python docker/scripts/test-host-ip-detection.py
|
||||||
|
python docker/scripts/test-structured-logging.py
|
||||||
```
|
```
|
||||||
|
|
||||||
## Deployment Options
|
### Building the OpenCode Image
|
||||||
|
|
||||||
### Cloud Deployment (Recommended)
|
```bash
|
||||||
- **Frontend**: Vercel or Netlify
|
make build MCP_SERVER=http://your-lovdata-server:8001
|
||||||
- **Backend**: Railway, Render, or Fly.io
|
make run # Run interactively
|
||||||
- **Database**: Use existing lovdata-ai PostgreSQL instance
|
make clean # Clean up
|
||||||
|
```
|
||||||
|
|
||||||
### Self-Hosted Deployment
|
## Environment Configuration
|
||||||
- **Docker Compose**: Full stack containerization
|
|
||||||
- **Reverse Proxy**: Nginx or Caddy
|
|
||||||
- **SSL**: Let's Encrypt automatic certificates
|
|
||||||
|
|
||||||
## Security Considerations
|
Required variables (see `.env.example`):
|
||||||
|
|
||||||
- API keys stored securely (environment variables, secret management)
|
```bash
|
||||||
- Rate limiting on chat endpoints
|
MCP_SERVER=http://localhost:8001 # External Lovdata MCP server URL
|
||||||
- Input validation and sanitization
|
|
||||||
- CORS configuration for frontend-backend communication
|
|
||||||
- Audit logging for legal tool usage
|
|
||||||
|
|
||||||
## Performance Optimization
|
# Docker TLS (if using TLS instead of socket)
|
||||||
|
DOCKER_TLS_VERIFY=1
|
||||||
|
DOCKER_CERT_PATH=/etc/docker/certs
|
||||||
|
DOCKER_HOST=tcp://host.docker.internal:2376
|
||||||
|
|
||||||
- Response streaming for real-time chat experience
|
# Optional LLM keys (at least one required for chat)
|
||||||
- MCP tool result caching
|
OPENAI_API_KEY=...
|
||||||
- Conversation history pagination
|
ANTHROPIC_API_KEY=...
|
||||||
- Lazy loading of legal document content
|
GOOGLE_API_KEY=...
|
||||||
- CDN for static frontend assets
|
```
|
||||||
|
|
||||||
## Future Enhancements
|
## Security
|
||||||
|
|
||||||
- User authentication and conversation persistence
|
**Docker socket**: Default setup uses socket mounting (`/var/run/docker.sock`). For production, enable TLS:
|
||||||
- Advanced citation management and export
|
|
||||||
- Integration with legal research workflows
|
|
||||||
- Multi-language support beyond Norwegian
|
|
||||||
- Advanced analytics and usage tracking
|
|
||||||
|
|
||||||
## Contributing
|
```bash
|
||||||
|
cd docker && DOCKER_ENV=production ./scripts/generate-certs.sh
|
||||||
|
./scripts/setup-docker-tls.sh
|
||||||
|
```
|
||||||
|
|
||||||
1. Follow the implementation plan phases
|
**Session isolation:**
|
||||||
2. Ensure comprehensive testing for LLM integrations
|
- Each session gets a dedicated container
|
||||||
3. Document API changes and new features
|
- Resource limits: 4GB RAM, 1 CPU core per container
|
||||||
4. Maintain security best practices for API key handling
|
- Max 3 concurrent sessions (configurable via `resource_manager.py`)
|
||||||
|
- Auto-cleanup after 60 minutes inactivity
|
||||||
|
- Token-based session authentication
|
||||||
|
|
||||||
---
|
## Further Documentation
|
||||||
|
|
||||||
**Status**: Planning phase complete. Ready for implementation.
|
- [`CLAUDE.md`](CLAUDE.md) — AI assistant guidance for working with this codebase
|
||||||
|
- [`LOW_PRIORITY_IMPROVEMENTS.md`](LOW_PRIORITY_IMPROVEMENTS.md) — Backlog of non-critical improvements
|
||||||
**Next Steps**: Begin with Phase 1 - Project Structure Setup
|
- [`docs/project-analysis.md`](docs/project-analysis.md) — Detailed architectural analysis
|
||||||
|
- `docker/*.md` — Implementation docs for individual components
|
||||||
@@ -2,7 +2,85 @@
|
|||||||
"$schema": "https://opencode.ai/config.json",
|
"$schema": "https://opencode.ai/config.json",
|
||||||
"theme": "opencode",
|
"theme": "opencode",
|
||||||
"autoupdate": false,
|
"autoupdate": false,
|
||||||
|
"model": "opencode/kimi-k2.5-free",
|
||||||
"plugin": [],
|
"plugin": [],
|
||||||
|
|
||||||
|
// Global permissions — defense-in-depth safety net across ALL agents
|
||||||
|
"permission": {
|
||||||
|
"bash": "deny",
|
||||||
|
"edit": "deny",
|
||||||
|
"webfetch": "deny",
|
||||||
|
"read": "allow",
|
||||||
|
"grep": "allow",
|
||||||
|
"glob": "allow",
|
||||||
|
"list": "allow",
|
||||||
|
"todoread": "allow",
|
||||||
|
"todowrite": "allow",
|
||||||
|
"lsp": "deny",
|
||||||
|
"task": "allow",
|
||||||
|
"skill": "allow"
|
||||||
|
},
|
||||||
|
|
||||||
|
"agent": {
|
||||||
|
// Primary agent — locked to read-only + Lovdata MCP tools
|
||||||
|
"build": {
|
||||||
|
"mode": "primary",
|
||||||
|
"prompt": "{file:./prompts/legal-research.md}",
|
||||||
|
"temperature": 0.2,
|
||||||
|
"tools": {
|
||||||
|
"bash": false,
|
||||||
|
"write": false,
|
||||||
|
"edit": false,
|
||||||
|
"patch": false,
|
||||||
|
"webfetch": false,
|
||||||
|
"read": true,
|
||||||
|
"grep": true,
|
||||||
|
"glob": true,
|
||||||
|
"list": true,
|
||||||
|
"todowrite": true,
|
||||||
|
"todoread": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Disable plan agent — users shouldn't switch modes
|
||||||
|
"plan": {
|
||||||
|
"mode": "primary",
|
||||||
|
"disable": true
|
||||||
|
},
|
||||||
|
|
||||||
|
// Lock down general subagent — it normally has full tool access
|
||||||
|
"general": {
|
||||||
|
"mode": "subagent",
|
||||||
|
"tools": {
|
||||||
|
"bash": false,
|
||||||
|
"write": false,
|
||||||
|
"edit": false,
|
||||||
|
"patch": false,
|
||||||
|
"webfetch": false,
|
||||||
|
"read": true,
|
||||||
|
"grep": true,
|
||||||
|
"glob": true,
|
||||||
|
"list": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Explore subagent is already read-only, but be explicit
|
||||||
|
"explore": {
|
||||||
|
"mode": "subagent",
|
||||||
|
"tools": {
|
||||||
|
"bash": false,
|
||||||
|
"write": false,
|
||||||
|
"edit": false,
|
||||||
|
"patch": false,
|
||||||
|
"webfetch": false,
|
||||||
|
"read": true,
|
||||||
|
"grep": true,
|
||||||
|
"glob": true,
|
||||||
|
"list": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
"mcp": {
|
"mcp": {
|
||||||
"sequential-thinking": {
|
"sequential-thinking": {
|
||||||
"type": "local",
|
"type": "local",
|
||||||
|
|||||||
25
config_opencode/prompts/legal-research.md
Normal file
25
config_opencode/prompts/legal-research.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
You are a Norwegian legal research assistant powered by Lovdata.
|
||||||
|
|
||||||
|
Your role is to help users research Norwegian laws (lover), regulations (forskrifter), and legal concepts using the Lovdata MCP tools available to you.
|
||||||
|
|
||||||
|
## What you can do
|
||||||
|
|
||||||
|
- Search and retrieve Norwegian laws and regulations via Lovdata
|
||||||
|
- Explain legal concepts in clear Norwegian (or English when asked)
|
||||||
|
- Provide proper citations with Lovdata URLs
|
||||||
|
- Trace cross-references between legal provisions
|
||||||
|
- Track amendment history
|
||||||
|
|
||||||
|
## What you cannot do
|
||||||
|
|
||||||
|
- You cannot execute shell commands, create files, or modify files
|
||||||
|
- You are a research tool, not a lawyer. Always recommend professional legal consultation for specific legal situations
|
||||||
|
- Clearly distinguish between legal information and legal advice
|
||||||
|
|
||||||
|
## Guidelines
|
||||||
|
|
||||||
|
- Always cite specific Lovdata URLs with amendment dates
|
||||||
|
- Distinguish between laws (lover) and regulations (forskrifter)
|
||||||
|
- Use the correct document ID prefixes: `NL/lov/` for laws, `SF/forskrift/` for regulations
|
||||||
|
- Consider the hierarchical legal structure and cross-references
|
||||||
|
- Respond in the same language the user writes in (Norwegian or English)
|
||||||
12
cypress.config.js
Normal file
12
cypress.config.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
const { defineConfig } = require('cypress');
|
||||||
|
|
||||||
|
module.exports = defineConfig({
|
||||||
|
e2e: {
|
||||||
|
supportFile: 'cypress/support/e2e.js',
|
||||||
|
specPattern: 'cypress/e2e/**/*.cy.{js,ts}',
|
||||||
|
defaultCommandTimeout: 10000,
|
||||||
|
requestTimeout: 10000,
|
||||||
|
video: false,
|
||||||
|
screenshotOnRunFailure: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
21
cypress/e2e/api/health.cy.js
Normal file
21
cypress/e2e/api/health.cy.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
/// <reference types="cypress" />
|
||||||
|
|
||||||
|
const api = () => Cypress.env('API_URL');
|
||||||
|
|
||||||
|
describe('Health API', () => {
|
||||||
|
it('GET /api/health returns status and required fields', () => {
|
||||||
|
cy.request(`${api()}/api/health`).then((response) => {
|
||||||
|
expect(response.status).to.eq(200);
|
||||||
|
expect(response.body).to.have.property('status');
|
||||||
|
expect(response.body.status).to.be.oneOf([
|
||||||
|
'healthy',
|
||||||
|
'degraded',
|
||||||
|
'unhealthy',
|
||||||
|
]);
|
||||||
|
expect(response.body).to.have.property('docker');
|
||||||
|
expect(response.body).to.have.property('active_sessions');
|
||||||
|
expect(response.body).to.have.property('timestamp');
|
||||||
|
expect(response.body).to.have.property('resource_limits');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
97
cypress/e2e/api/sessions.cy.js
Normal file
97
cypress/e2e/api/sessions.cy.js
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
/// <reference types="cypress" />
|
||||||
|
|
||||||
|
const api = () => Cypress.env('API_URL');
|
||||||
|
|
||||||
|
describe('Sessions API', () => {
|
||||||
|
const createdSessions = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
createdSessions.splice(0).forEach((id) => {
|
||||||
|
cy.request({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: `${api()}/api/sessions/${id}`,
|
||||||
|
failOnStatusCode: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/sessions', () => {
|
||||||
|
it('returns 200 with an array', () => {
|
||||||
|
cy.request(`${api()}/api/sessions`).then((response) => {
|
||||||
|
expect(response.status).to.eq(200);
|
||||||
|
expect(response.body).to.be.an('array');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/sessions', () => {
|
||||||
|
it('creates a session with expected fields', () => {
|
||||||
|
cy.request('POST', `${api()}/api/sessions`).then((response) => {
|
||||||
|
expect(response.status).to.be.oneOf([200, 201]);
|
||||||
|
expect(response.body).to.have.property('session_id');
|
||||||
|
expect(response.body).to.have.property('auth_token');
|
||||||
|
expect(response.body).to.have.property('status');
|
||||||
|
createdSessions.push(response.body.session_id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/sessions/:id', () => {
|
||||||
|
it('returns the created session', () => {
|
||||||
|
cy.request('POST', `${api()}/api/sessions`).then((createRes) => {
|
||||||
|
createdSessions.push(createRes.body.session_id);
|
||||||
|
const id = createRes.body.session_id;
|
||||||
|
|
||||||
|
cy.request(`${api()}/api/sessions/${id}`).then((response) => {
|
||||||
|
expect(response.status).to.eq(200);
|
||||||
|
expect(response.body).to.have.property('session_id', id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 for nonexistent session', () => {
|
||||||
|
cy.request({
|
||||||
|
url: `${api()}/api/sessions/nonexistent-id-000`,
|
||||||
|
failOnStatusCode: false,
|
||||||
|
}).then((response) => {
|
||||||
|
expect(response.status).to.eq(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DELETE /api/sessions/:id', () => {
|
||||||
|
it('deletes a session', () => {
|
||||||
|
cy.request('POST', `${api()}/api/sessions`).then((createRes) => {
|
||||||
|
const id = createRes.body.session_id;
|
||||||
|
|
||||||
|
cy.request('DELETE', `${api()}/api/sessions/${id}`).then(
|
||||||
|
(response) => {
|
||||||
|
expect(response.status).to.eq(200);
|
||||||
|
expect(response.body).to.have.property('message');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 for nonexistent session', () => {
|
||||||
|
cy.request({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: `${api()}/api/sessions/nonexistent-id-000`,
|
||||||
|
failOnStatusCode: false,
|
||||||
|
}).then((response) => {
|
||||||
|
expect(response.status).to.eq(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/cleanup', () => {
|
||||||
|
it('returns 200 with cleanup message', () => {
|
||||||
|
cy.request('POST', `${api()}/api/cleanup`).then((response) => {
|
||||||
|
expect(response.status).to.eq(200);
|
||||||
|
expect(response.body)
|
||||||
|
.to.have.property('message')
|
||||||
|
.that.includes('Cleanup completed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
7
cypress/e2e/smoke.cy.js
Normal file
7
cypress/e2e/smoke.cy.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
/// <reference types="cypress" />
|
||||||
|
|
||||||
|
describe('Cypress Infrastructure Smoke Test', () => {
|
||||||
|
it('should execute a basic assertion', () => {
|
||||||
|
expect(true).to.be.true;
|
||||||
|
});
|
||||||
|
});
|
||||||
6
cypress/support/e2e.js
Normal file
6
cypress/support/e2e.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// Cypress E2E support file
|
||||||
|
|
||||||
|
// Base URL for API tests. Override with CYPRESS_API_URL env var.
|
||||||
|
// Not set as Cypress baseUrl to avoid server-reachability checks
|
||||||
|
// that would block offline tests (smoke).
|
||||||
|
Cypress.env('API_URL', Cypress.env('API_URL') || 'http://localhost');
|
||||||
@@ -22,6 +22,7 @@ services:
|
|||||||
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
|
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
|
||||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
||||||
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
||||||
|
- ZEN_API_KEY=${ZEN_API_KEY:-}
|
||||||
# Certificate paths (configurable via environment)
|
# Certificate paths (configurable via environment)
|
||||||
- DOCKER_CA_CERT=${DOCKER_CA_CERT:-/etc/docker/certs/ca.pem}
|
- DOCKER_CA_CERT=${DOCKER_CA_CERT:-/etc/docker/certs/ca.pem}
|
||||||
- DOCKER_CLIENT_CERT=${DOCKER_CLIENT_CERT:-/etc/docker/certs/client-cert.pem}
|
- DOCKER_CLIENT_CERT=${DOCKER_CLIENT_CERT:-/etc/docker/certs/client-cert.pem}
|
||||||
@@ -29,6 +30,8 @@ services:
|
|||||||
# Host configuration
|
# Host configuration
|
||||||
- DOCKER_HOST_IP=${DOCKER_HOST_IP:-host.docker.internal}
|
- DOCKER_HOST_IP=${DOCKER_HOST_IP:-host.docker.internal}
|
||||||
- DOCKER_TLS_PORT=${DOCKER_TLS_PORT:-2376}
|
- DOCKER_TLS_PORT=${DOCKER_TLS_PORT:-2376}
|
||||||
|
# Disable database storage (use in-memory)
|
||||||
|
- USE_DATABASE_STORAGE=false
|
||||||
networks:
|
networks:
|
||||||
- lovdata-network
|
- lovdata-network
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -2,63 +2,16 @@
|
|||||||
# Using HTTP for local development (no SSL warnings)
|
# Using HTTP for local development (no SSL warnings)
|
||||||
|
|
||||||
# Main web interface - HTTP only for development
|
# Main web interface - HTTP only for development
|
||||||
http://localhost {
|
:80 {
|
||||||
# API endpoints for session management
|
# API endpoints for session management (strip /api prefix)
|
||||||
handle /api/* {
|
handle /api/* {
|
||||||
uri strip_prefix /api
|
uri strip_prefix /api
|
||||||
reverse_proxy session-manager:8000
|
reverse_proxy session-manager:8000
|
||||||
}
|
}
|
||||||
|
|
||||||
# Session-specific routing - proxy to session manager for dynamic routing
|
# Everything else goes to session-manager (handles /c/{id} entry
|
||||||
handle /session/{session_id}* {
|
# point and cookie-based proxy to OpenCode containers)
|
||||||
|
handle {
|
||||||
reverse_proxy session-manager:8000
|
reverse_proxy session-manager:8000
|
||||||
}
|
}
|
||||||
|
|
||||||
# OpenCode SPA runtime requests - route based on session cookie
|
|
||||||
handle /global/* {
|
|
||||||
reverse_proxy session-manager:8000
|
|
||||||
}
|
|
||||||
|
|
||||||
handle /assets/* {
|
|
||||||
reverse_proxy session-manager:8000
|
|
||||||
}
|
|
||||||
|
|
||||||
handle /provider/* {
|
|
||||||
reverse_proxy session-manager:8000
|
|
||||||
}
|
|
||||||
|
|
||||||
handle /provider {
|
|
||||||
reverse_proxy session-manager:8000
|
|
||||||
}
|
|
||||||
|
|
||||||
handle /project {
|
|
||||||
reverse_proxy session-manager:8000
|
|
||||||
}
|
|
||||||
|
|
||||||
handle /path {
|
|
||||||
reverse_proxy session-manager:8000
|
|
||||||
}
|
|
||||||
|
|
||||||
handle /find/* {
|
|
||||||
reverse_proxy session-manager:8000
|
|
||||||
}
|
|
||||||
|
|
||||||
handle /file {
|
|
||||||
reverse_proxy session-manager:8000
|
|
||||||
}
|
|
||||||
|
|
||||||
handle /file/* {
|
|
||||||
reverse_proxy session-manager:8000
|
|
||||||
}
|
|
||||||
|
|
||||||
# Health check
|
|
||||||
handle /health {
|
|
||||||
reverse_proxy session-manager:8000
|
|
||||||
}
|
|
||||||
|
|
||||||
# Static files and main interface (fallback)
|
|
||||||
handle /* {
|
|
||||||
try_files {path} {path}/ /index.html
|
|
||||||
file_server
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
2203
package-lock.json
generated
Normal file
2203
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
package.json
Normal file
23
package.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "lovdata-chat",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "A web-based chat interface that allows users to interact with Large Language Models (LLMs) equipped with Norwegian legal research tools from the Lovdata MCP server.",
|
||||||
|
"directories": {
|
||||||
|
"doc": "docs"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test": "cypress run",
|
||||||
|
"cypress:open": "cypress open",
|
||||||
|
"cypress:run": "cypress run"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "ssh://git@gitea.torbjorn.org:2222/torbjorn/lovdata-chat.git"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"devDependencies": {
|
||||||
|
"cypress": "^15.10.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
92
session-manager/app.py
Normal file
92
session-manager/app.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import asyncio
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
from config import USE_ASYNC_DOCKER, USE_DATABASE_STORAGE
|
||||||
|
from session_manager import session_manager
|
||||||
|
from http_pool import init_http_pool, shutdown_http_pool
|
||||||
|
from database import init_database, shutdown_database, run_migrations
|
||||||
|
from container_health import (
|
||||||
|
start_container_health_monitoring,
|
||||||
|
stop_container_health_monitoring,
|
||||||
|
)
|
||||||
|
from logging_config import get_logger, init_logging
|
||||||
|
from routes import sessions_router, auth_router, health_router, proxy_router
|
||||||
|
|
||||||
|
init_logging()
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
_use_database_storage = USE_DATABASE_STORAGE
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
global _use_database_storage
|
||||||
|
|
||||||
|
logger.info("Starting Session Management Service")
|
||||||
|
|
||||||
|
await init_http_pool()
|
||||||
|
|
||||||
|
if _use_database_storage:
|
||||||
|
try:
|
||||||
|
await init_database()
|
||||||
|
await run_migrations()
|
||||||
|
await session_manager._load_sessions_from_database()
|
||||||
|
logger.info("Database initialized and sessions loaded")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Database initialization failed", extra={"error": str(e)})
|
||||||
|
if _use_database_storage:
|
||||||
|
logger.warning("Falling back to JSON file storage")
|
||||||
|
_use_database_storage = False
|
||||||
|
session_manager._load_sessions_from_file()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Use the session manager's docker_service for health monitoring
|
||||||
|
# This ensures the docker client stays alive for the lifetime of the application
|
||||||
|
docker_client = session_manager.docker_service
|
||||||
|
|
||||||
|
await start_container_health_monitoring(session_manager, docker_client)
|
||||||
|
logger.info("Container health monitoring started")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"Failed to start container health monitoring", extra={"error": str(e)}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def cleanup_task():
|
||||||
|
while True:
|
||||||
|
await session_manager.cleanup_expired_sessions()
|
||||||
|
await asyncio.sleep(300)
|
||||||
|
|
||||||
|
cleanup_coro = asyncio.create_task(cleanup_task())
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
logger.info("Shutting down Session Management Service")
|
||||||
|
cleanup_coro.cancel()
|
||||||
|
|
||||||
|
await shutdown_http_pool()
|
||||||
|
|
||||||
|
try:
|
||||||
|
await stop_container_health_monitoring()
|
||||||
|
logger.info("Container health monitoring stopped")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"Error stopping container health monitoring", extra={"error": str(e)}
|
||||||
|
)
|
||||||
|
|
||||||
|
if _use_database_storage:
|
||||||
|
await shutdown_database()
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="Lovdata Chat Session Manager",
|
||||||
|
description="Manages isolated OpenCode containers for Norwegian legal research sessions",
|
||||||
|
version="1.0.0",
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.include_router(sessions_router)
|
||||||
|
app.include_router(auth_router)
|
||||||
|
app.include_router(health_router)
|
||||||
|
app.include_router(proxy_router)
|
||||||
@@ -199,6 +199,23 @@ class AsyncDockerClient:
|
|||||||
except DockerError:
|
except DockerError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
async def _get_container_info(self, container_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Get detailed container information (equivalent to docker inspect).
|
||||||
|
|
||||||
|
Returns the full container info dict including State, Config, etc.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
container = await self._docker.containers.get(container_id)
|
||||||
|
if container:
|
||||||
|
# show() returns the full container inspect data
|
||||||
|
return await container.show()
|
||||||
|
except DockerError as e:
|
||||||
|
logger.debug(f"Failed to get container info for {container_id}: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Unexpected error getting container info: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
async def list_containers(
|
async def list_containers(
|
||||||
self, all: bool = False, filters: Optional[Dict[str, Any]] = None
|
self, all: bool = False, filters: Optional[Dict[str, Any]] = None
|
||||||
) -> List[DockerContainer]:
|
) -> List[DockerContainer]:
|
||||||
|
|||||||
47
session-manager/config.py
Normal file
47
session-manager/config.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
"""
|
||||||
|
Configuration module for Session Management Service
|
||||||
|
|
||||||
|
Centralized configuration loading from environment variables with defaults.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
# Session storage configuration
|
||||||
|
SESSIONS_DIR = Path("/app/sessions")
|
||||||
|
SESSIONS_FILE = Path("/app/sessions/sessions.json")
|
||||||
|
|
||||||
|
# Container configuration
|
||||||
|
CONTAINER_IMAGE = os.getenv("CONTAINER_IMAGE", "lovdata-opencode:latest")
|
||||||
|
|
||||||
|
# Resource limits - configurable via environment variables with defaults
|
||||||
|
CONTAINER_MEMORY_LIMIT = os.getenv(
|
||||||
|
"CONTAINER_MEMORY_LIMIT", "4g"
|
||||||
|
) # Memory limit per container
|
||||||
|
CONTAINER_CPU_QUOTA = int(
|
||||||
|
os.getenv("CONTAINER_CPU_QUOTA", "100000")
|
||||||
|
) # CPU quota (100000 = 1 core)
|
||||||
|
CONTAINER_CPU_PERIOD = int(
|
||||||
|
os.getenv("CONTAINER_CPU_PERIOD", "100000")
|
||||||
|
) # CPU period (microseconds)
|
||||||
|
|
||||||
|
# Session management
|
||||||
|
MAX_CONCURRENT_SESSIONS = int(
|
||||||
|
os.getenv("MAX_CONCURRENT_SESSIONS", "3")
|
||||||
|
) # Max concurrent sessions
|
||||||
|
SESSION_TIMEOUT_MINUTES = int(
|
||||||
|
os.getenv("SESSION_TIMEOUT_MINUTES", "60")
|
||||||
|
) # Auto-cleanup timeout
|
||||||
|
|
||||||
|
# Resource monitoring thresholds
|
||||||
|
MEMORY_WARNING_THRESHOLD = float(
|
||||||
|
os.getenv("MEMORY_WARNING_THRESHOLD", "0.8")
|
||||||
|
) # 80% memory usage
|
||||||
|
CPU_WARNING_THRESHOLD = float(
|
||||||
|
os.getenv("CPU_WARNING_THRESHOLD", "0.9")
|
||||||
|
) # 90% CPU usage
|
||||||
|
|
||||||
|
# Feature flags
|
||||||
|
USE_ASYNC_DOCKER = os.getenv("USE_ASYNC_DOCKER", "true").lower() == "true"
|
||||||
|
USE_DATABASE_STORAGE = os.getenv("USE_DATABASE_STORAGE", "true").lower() == "true"
|
||||||
@@ -129,23 +129,30 @@ class ContainerHealthMonitor:
|
|||||||
"""Main monitoring loop."""
|
"""Main monitoring loop."""
|
||||||
while self._monitoring:
|
while self._monitoring:
|
||||||
try:
|
try:
|
||||||
await self._perform_health_checks()
|
await self._check_all_containers()
|
||||||
await self._cleanup_old_history()
|
await self._cleanup_old_history()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error in health monitoring loop", extra={"error": str(e)})
|
logger.error("Error in health monitoring loop", extra={"error": str(e)})
|
||||||
|
|
||||||
await asyncio.sleep(self.check_interval)
|
await asyncio.sleep(self.check_interval)
|
||||||
|
|
||||||
async def _perform_health_checks(self):
|
async def _check_all_containers(self):
|
||||||
"""Perform health checks on all running containers."""
|
"""Perform health checks on all running containers."""
|
||||||
if not self.session_manager:
|
if not self.session_manager:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Get all running sessions
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
# Startup grace period - don't check containers that started recently
|
||||||
|
startup_grace_period = timedelta(seconds=60)
|
||||||
|
now = datetime.now()
|
||||||
|
|
||||||
|
# Get all running sessions that are past the startup grace period
|
||||||
running_sessions = [
|
running_sessions = [
|
||||||
session
|
session
|
||||||
for session in self.session_manager.sessions.values()
|
for session in self.session_manager.sessions.values()
|
||||||
if session.status == "running"
|
if session.status == "running"
|
||||||
|
and (now - session.created_at) > startup_grace_period
|
||||||
]
|
]
|
||||||
|
|
||||||
if not running_sessions:
|
if not running_sessions:
|
||||||
@@ -263,23 +270,30 @@ class ContainerHealthMonitor:
|
|||||||
async def _get_container_info(self, container_id: str) -> Optional[Dict[str, Any]]:
|
async def _get_container_info(self, container_id: str) -> Optional[Dict[str, Any]]:
|
||||||
"""Get container information from Docker."""
|
"""Get container information from Docker."""
|
||||||
try:
|
try:
|
||||||
if self.docker_client:
|
# Use session_manager.docker_service for consistent container access
|
||||||
# Try async Docker client first
|
if (
|
||||||
container = await self.docker_client.get_container(container_id)
|
self.session_manager
|
||||||
if hasattr(container, "_container"):
|
and hasattr(self.session_manager, "docker_service")
|
||||||
return await container._container.show()
|
and self.session_manager.docker_service
|
||||||
elif hasattr(container, "show"):
|
):
|
||||||
return await container.show()
|
container_info = await self.session_manager.docker_service.get_container_info(container_id)
|
||||||
else:
|
if container_info:
|
||||||
# Fallback to sync client if available
|
# Convert ContainerInfo to dict format expected by health check
|
||||||
if (
|
return {
|
||||||
hasattr(self.session_manager, "docker_client")
|
"State": {
|
||||||
and self.session_manager.docker_client
|
"Status": container_info.status,
|
||||||
):
|
"Health": {"Status": container_info.health_status} if container_info.health_status else {}
|
||||||
container = self.session_manager.docker_client.containers.get(
|
}
|
||||||
container_id
|
}
|
||||||
)
|
elif self.docker_client and hasattr(self.docker_client, "get_container_info"):
|
||||||
return container.attrs
|
container_info = await self.docker_client.get_container_info(container_id)
|
||||||
|
if container_info:
|
||||||
|
return {
|
||||||
|
"State": {
|
||||||
|
"Status": container_info.status,
|
||||||
|
"Health": {"Status": container_info.health_status} if container_info.health_status else {}
|
||||||
|
}
|
||||||
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Failed to get container info for {container_id}",
|
f"Failed to get container info for {container_id}",
|
||||||
@@ -384,8 +398,8 @@ class ContainerHealthMonitor:
|
|||||||
|
|
||||||
# Trigger container restart through session manager
|
# Trigger container restart through session manager
|
||||||
if self.session_manager:
|
if self.session_manager:
|
||||||
# Create new container for the session
|
# Restart container for the SAME session (preserves session_id)
|
||||||
await self.session_manager.create_session()
|
await self.session_manager.restart_session(session_id)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Container restart initiated",
|
"Container restart initiated",
|
||||||
extra={
|
extra={
|
||||||
@@ -418,17 +432,22 @@ class ContainerHealthMonitor:
|
|||||||
async def _stop_container(self, container_id: str):
|
async def _stop_container(self, container_id: str):
|
||||||
"""Stop a container."""
|
"""Stop a container."""
|
||||||
try:
|
try:
|
||||||
if self.docker_client:
|
# Use session_manager.docker_service for container operations
|
||||||
container = await self.docker_client.get_container(container_id)
|
# docker_service.stop_container takes container_id as a string
|
||||||
await self.docker_client.stop_container(container, timeout=10)
|
if (
|
||||||
elif (
|
self.session_manager
|
||||||
hasattr(self.session_manager, "docker_client")
|
and hasattr(self.session_manager, "docker_service")
|
||||||
and self.session_manager.docker_client
|
and self.session_manager.docker_service
|
||||||
):
|
):
|
||||||
container = self.session_manager.docker_client.containers.get(
|
await self.session_manager.docker_service.stop_container(container_id, timeout=10)
|
||||||
container_id
|
elif self.docker_client and hasattr(self.docker_client, "stop_container"):
|
||||||
|
# If docker_client is docker_service, use it directly
|
||||||
|
await self.docker_client.stop_container(container_id, timeout=10)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"No docker client available to stop container",
|
||||||
|
extra={"container_id": container_id},
|
||||||
)
|
)
|
||||||
container.stop(timeout=10)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Failed to stop container during restart",
|
"Failed to stop container during restart",
|
||||||
|
|||||||
@@ -159,6 +159,24 @@ async def make_http_request(method: str, url: str, **kwargs) -> httpx.Response:
|
|||||||
return await client.request(method, url, **kwargs)
|
return await client.request(method, url, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def stream_http_request(method: str, url: str, **kwargs):
|
||||||
|
"""Stream an HTTP response using a dedicated client with no read timeout.
|
||||||
|
|
||||||
|
Yields an httpx.Response whose body has NOT been read -- caller must
|
||||||
|
iterate over ``response.aiter_bytes()`` / ``aiter_lines()`` etc.
|
||||||
|
|
||||||
|
A separate AsyncClient is used (not the pool) because httpx's
|
||||||
|
``stream()`` keeps the connection checked-out for the lifetime of the
|
||||||
|
context manager, and SSE streams are effectively infinite. Using a
|
||||||
|
short-lived client avoids starving the pool.
|
||||||
|
"""
|
||||||
|
timeout = httpx.Timeout(connect=10.0, read=None, write=10.0, pool=5.0)
|
||||||
|
async with httpx.AsyncClient(timeout=timeout, follow_redirects=False) as client:
|
||||||
|
async with client.stream(method, url, **kwargs) as response:
|
||||||
|
yield response
|
||||||
|
|
||||||
|
|
||||||
async def get_connection_pool_stats() -> Dict[str, Any]:
|
async def get_connection_pool_stats() -> Dict[str, Any]:
|
||||||
"""Get connection pool statistics."""
|
"""Get connection pool statistics."""
|
||||||
return await _http_pool.get_pool_stats()
|
return await _http_pool.get_pool_stats()
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
24
session-manager/models.py
Normal file
24
session-manager/models.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"""
|
||||||
|
Data models for Session Management Service
|
||||||
|
|
||||||
|
Pydantic models for session data and API request/response schemas.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class SessionData(BaseModel):
|
||||||
|
"""Represents a user session with its associated container"""
|
||||||
|
|
||||||
|
session_id: str
|
||||||
|
container_name: str
|
||||||
|
container_id: Optional[str] = None
|
||||||
|
host_dir: str
|
||||||
|
port: Optional[int] = None
|
||||||
|
auth_token: Optional[str] = None # Authentication token for the session
|
||||||
|
created_at: datetime
|
||||||
|
last_accessed: datetime
|
||||||
|
status: str = "creating" # creating, running, stopped, error
|
||||||
6
session-manager/routes/__init__.py
Normal file
6
session-manager/routes/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from .sessions import router as sessions_router
|
||||||
|
from .auth import router as auth_router
|
||||||
|
from .health import router as health_router
|
||||||
|
from .proxy import router as proxy_router
|
||||||
|
|
||||||
|
__all__ = ["sessions_router", "auth_router", "health_router", "proxy_router"]
|
||||||
56
session-manager/routes/auth.py
Normal file
56
session-manager/routes/auth.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
|
||||||
|
from session_manager import session_manager
|
||||||
|
from session_auth import (
|
||||||
|
get_session_auth_info as get_auth_info,
|
||||||
|
list_active_auth_sessions,
|
||||||
|
_session_token_manager,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sessions/{session_id}/auth")
|
||||||
|
async def get_session_auth_info(session_id: str):
|
||||||
|
session = await session_manager.get_session(session_id)
|
||||||
|
if not session:
|
||||||
|
raise HTTPException(status_code=404, detail="Session not found")
|
||||||
|
|
||||||
|
auth_info = get_auth_info(session_id)
|
||||||
|
if not auth_info:
|
||||||
|
raise HTTPException(status_code=404, detail="Authentication info not found")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"session_id": session_id,
|
||||||
|
"auth_info": auth_info,
|
||||||
|
"has_token": session.auth_token is not None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sessions/{session_id}/auth/rotate")
|
||||||
|
async def rotate_session_token(session_id: str):
|
||||||
|
session = await session_manager.get_session(session_id)
|
||||||
|
if not session:
|
||||||
|
raise HTTPException(status_code=404, detail="Session not found")
|
||||||
|
|
||||||
|
new_token = _session_token_manager.rotate_session_token(session_id)
|
||||||
|
if not new_token:
|
||||||
|
raise HTTPException(status_code=404, detail="Failed to rotate token")
|
||||||
|
|
||||||
|
session.auth_token = new_token
|
||||||
|
session_manager._save_sessions()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"session_id": session_id,
|
||||||
|
"new_token": new_token,
|
||||||
|
"message": "Token rotated successfully",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/auth/sessions")
|
||||||
|
async def list_authenticated_sessions():
|
||||||
|
sessions = list_active_auth_sessions()
|
||||||
|
return {
|
||||||
|
"active_auth_sessions": len(sessions),
|
||||||
|
"sessions": sessions,
|
||||||
|
}
|
||||||
149
session-manager/routes/health.py
Normal file
149
session-manager/routes/health.py
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
|
||||||
|
from config import (
|
||||||
|
CONTAINER_MEMORY_LIMIT,
|
||||||
|
CONTAINER_CPU_QUOTA,
|
||||||
|
CONTAINER_CPU_PERIOD,
|
||||||
|
MAX_CONCURRENT_SESSIONS,
|
||||||
|
USE_ASYNC_DOCKER,
|
||||||
|
USE_DATABASE_STORAGE,
|
||||||
|
)
|
||||||
|
from session_manager import session_manager
|
||||||
|
from host_ip_detector import async_get_host_ip
|
||||||
|
from resource_manager import check_system_resources
|
||||||
|
from http_pool import get_connection_pool_stats
|
||||||
|
from database import get_database_stats
|
||||||
|
from container_health import get_container_health_stats, get_container_health_history
|
||||||
|
from logging_config import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(tags=["health"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health/container")
|
||||||
|
async def get_container_health():
|
||||||
|
stats = get_container_health_stats()
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health/container/{session_id}")
|
||||||
|
async def get_session_container_health(session_id: str):
|
||||||
|
session = await session_manager.get_session(session_id)
|
||||||
|
if not session:
|
||||||
|
raise HTTPException(status_code=404, detail="Session not found")
|
||||||
|
|
||||||
|
stats = get_container_health_stats(session_id)
|
||||||
|
history = get_container_health_history(session_id, limit=20)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"session_id": session_id,
|
||||||
|
"container_id": session.container_id,
|
||||||
|
"stats": stats.get(f"session_{session_id}", {}),
|
||||||
|
"recent_history": history,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
async def health_check():
|
||||||
|
docker_ok = False
|
||||||
|
host_ip_ok = False
|
||||||
|
detected_host_ip = None
|
||||||
|
resource_status = {}
|
||||||
|
http_pool_stats = {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
docker_ok = await session_manager.docker_service.ping()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Docker health check failed: {e}")
|
||||||
|
docker_ok = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
detected_host_ip = await async_get_host_ip()
|
||||||
|
host_ip_ok = True
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Host IP detection failed: {e}")
|
||||||
|
host_ip_ok = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
resource_status = check_system_resources()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Resource monitoring failed", extra={"error": str(e)})
|
||||||
|
resource_status = {"error": str(e)}
|
||||||
|
|
||||||
|
try:
|
||||||
|
http_pool_stats = await get_connection_pool_stats()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("HTTP pool stats failed", extra={"error": str(e)})
|
||||||
|
http_pool_stats = {"error": str(e)}
|
||||||
|
|
||||||
|
database_status = {}
|
||||||
|
if USE_DATABASE_STORAGE:
|
||||||
|
try:
|
||||||
|
database_status = await get_database_stats()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Database stats failed", extra={"error": str(e)})
|
||||||
|
database_status = {"status": "error", "error": str(e)}
|
||||||
|
|
||||||
|
container_health_stats = {}
|
||||||
|
try:
|
||||||
|
container_health_stats = get_container_health_stats()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Container health stats failed", extra={"error": str(e)})
|
||||||
|
container_health_stats = {"error": str(e)}
|
||||||
|
|
||||||
|
resource_alerts = (
|
||||||
|
resource_status.get("alerts", []) if isinstance(resource_status, dict) else []
|
||||||
|
)
|
||||||
|
critical_alerts = [
|
||||||
|
a
|
||||||
|
for a in resource_alerts
|
||||||
|
if isinstance(a, dict) and a.get("level") == "critical"
|
||||||
|
]
|
||||||
|
|
||||||
|
http_healthy = (
|
||||||
|
http_pool_stats.get("status") == "healthy"
|
||||||
|
if isinstance(http_pool_stats, dict)
|
||||||
|
else False
|
||||||
|
)
|
||||||
|
|
||||||
|
if critical_alerts or not (docker_ok and host_ip_ok and http_healthy):
|
||||||
|
status = "unhealthy"
|
||||||
|
elif resource_alerts:
|
||||||
|
status = "degraded"
|
||||||
|
else:
|
||||||
|
status = "healthy"
|
||||||
|
|
||||||
|
health_data = {
|
||||||
|
"status": status,
|
||||||
|
"docker": docker_ok,
|
||||||
|
"docker_mode": "async" if USE_ASYNC_DOCKER else "sync",
|
||||||
|
"host_ip_detection": host_ip_ok,
|
||||||
|
"detected_host_ip": detected_host_ip,
|
||||||
|
"http_connection_pool": http_pool_stats,
|
||||||
|
"storage_backend": "database" if USE_DATABASE_STORAGE else "json_file",
|
||||||
|
"active_sessions": len(
|
||||||
|
[s for s in session_manager.sessions.values() if s.status == "running"]
|
||||||
|
),
|
||||||
|
"resource_limits": {
|
||||||
|
"memory_limit": CONTAINER_MEMORY_LIMIT,
|
||||||
|
"cpu_quota": CONTAINER_CPU_QUOTA,
|
||||||
|
"cpu_period": CONTAINER_CPU_PERIOD,
|
||||||
|
"max_concurrent_sessions": MAX_CONCURRENT_SESSIONS,
|
||||||
|
},
|
||||||
|
"system_resources": resource_status.get("system_resources", {})
|
||||||
|
if isinstance(resource_status, dict)
|
||||||
|
else {},
|
||||||
|
"resource_alerts": resource_alerts,
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if USE_DATABASE_STORAGE and database_status:
|
||||||
|
health_data["database"] = database_status
|
||||||
|
|
||||||
|
if container_health_stats:
|
||||||
|
health_data["container_health"] = container_health_stats
|
||||||
|
|
||||||
|
return health_data
|
||||||
216
session-manager/routes/proxy.py
Normal file
216
session-manager/routes/proxy.py
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
import os
|
||||||
|
import time
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Request, Response
|
||||||
|
from starlette.responses import RedirectResponse, StreamingResponse
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from session_manager import session_manager
|
||||||
|
from http_pool import make_http_request, stream_http_request
|
||||||
|
from logging_config import (
|
||||||
|
RequestContext,
|
||||||
|
log_request,
|
||||||
|
log_session_operation,
|
||||||
|
log_security_event,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(tags=["proxy"])
|
||||||
|
|
||||||
|
ALL_METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"]
|
||||||
|
|
||||||
|
|
||||||
|
def get_session_from_cookie(request: Request) -> str:
|
||||||
|
session_id = request.cookies.get("lovdata_session")
|
||||||
|
if not session_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="No active session - please access via /c/{id} first",
|
||||||
|
)
|
||||||
|
return session_id
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/c/{session_id}")
|
||||||
|
@router.get("/c/{session_id}/{path:path}")
|
||||||
|
async def enter_session(request: Request, session_id: str, path: str = ""):
|
||||||
|
"""Entry point: set session cookie and redirect to root."""
|
||||||
|
session = await session_manager.get_session(session_id)
|
||||||
|
if not session or session.status != "running":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404, detail="Session not found or not running"
|
||||||
|
)
|
||||||
|
resp = RedirectResponse(url="/", status_code=302)
|
||||||
|
resp.set_cookie(
|
||||||
|
key="lovdata_session",
|
||||||
|
value=session_id,
|
||||||
|
httponly=True,
|
||||||
|
samesite="lax",
|
||||||
|
max_age=86400,
|
||||||
|
)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
def _is_sse_request(request: Request, path: str) -> bool:
|
||||||
|
"""Detect SSE requests by Accept header or path convention."""
|
||||||
|
accept = request.headers.get("accept", "")
|
||||||
|
if "text/event-stream" in accept:
|
||||||
|
return True
|
||||||
|
# OpenCode uses /global/event and /event paths for SSE
|
||||||
|
if path == "event" or path.endswith("/event"):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@router.api_route("/{path:path}", methods=ALL_METHODS)
|
||||||
|
async def proxy_root_to_container(request: Request, path: str):
|
||||||
|
"""Catch-all: proxy everything to the container identified by cookie."""
|
||||||
|
session_id = get_session_from_cookie(request)
|
||||||
|
return await _proxy_to_container(request, session_id, path)
|
||||||
|
|
||||||
|
|
||||||
|
async def _proxy_to_container(request: Request, session_id: str, path: str):
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
with RequestContext():
|
||||||
|
session = await session_manager.get_session(session_id)
|
||||||
|
if not session or session.status != "running":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404, detail="Session not found or not running"
|
||||||
|
)
|
||||||
|
|
||||||
|
docker_host = os.getenv("DOCKER_HOST", "http://docker-daemon:2375")
|
||||||
|
parsed = urlparse(docker_host)
|
||||||
|
container_host = parsed.hostname or "docker-daemon"
|
||||||
|
|
||||||
|
container_url = f"http://{container_host}:{session.port}"
|
||||||
|
|
||||||
|
url = f"{container_url}/{path}"
|
||||||
|
if request.url.query:
|
||||||
|
url += f"?{request.url.query}"
|
||||||
|
|
||||||
|
body = await request.body()
|
||||||
|
|
||||||
|
headers = dict(request.headers)
|
||||||
|
headers.pop("host", None)
|
||||||
|
|
||||||
|
if session.auth_token:
|
||||||
|
headers["Authorization"] = f"Bearer {session.auth_token}"
|
||||||
|
headers["X-Session-Token"] = session.auth_token
|
||||||
|
headers["X-Session-ID"] = session.session_id
|
||||||
|
|
||||||
|
# --- SSE streaming path ---
|
||||||
|
if _is_sse_request(request, path):
|
||||||
|
return await _proxy_sse(request, session_id, path, url, headers, body, start_time)
|
||||||
|
|
||||||
|
# --- Buffered path ---
|
||||||
|
try:
|
||||||
|
log_session_operation(
|
||||||
|
session_id, "proxy_request", method=request.method, path=path
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await make_http_request(
|
||||||
|
method=request.method,
|
||||||
|
url=url,
|
||||||
|
headers=headers,
|
||||||
|
content=body,
|
||||||
|
)
|
||||||
|
|
||||||
|
duration_ms = (time.time() - start_time) * 1000
|
||||||
|
log_request(
|
||||||
|
request.method,
|
||||||
|
f"/{path}",
|
||||||
|
response.status_code,
|
||||||
|
duration_ms,
|
||||||
|
session_id=session_id,
|
||||||
|
operation="proxy_complete",
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = Response(
|
||||||
|
content=response.content,
|
||||||
|
status_code=response.status_code,
|
||||||
|
headers=dict(response.headers),
|
||||||
|
)
|
||||||
|
resp.set_cookie(
|
||||||
|
key="lovdata_session",
|
||||||
|
value=session_id,
|
||||||
|
httponly=True,
|
||||||
|
samesite="lax",
|
||||||
|
max_age=86400,
|
||||||
|
)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
except httpx.TimeoutException as e:
|
||||||
|
duration_ms = (time.time() - start_time) * 1000
|
||||||
|
log_request(
|
||||||
|
request.method, f"/{path}", 504, duration_ms,
|
||||||
|
session_id=session_id, error="timeout",
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=504, detail="Request to session container timed out"
|
||||||
|
)
|
||||||
|
except httpx.RequestError as e:
|
||||||
|
duration_ms = (time.time() - start_time) * 1000
|
||||||
|
log_request(
|
||||||
|
request.method, f"/{path}", 502, duration_ms,
|
||||||
|
session_id=session_id, error=str(e),
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=502,
|
||||||
|
detail=f"Failed to connect to session container: {str(e)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _proxy_sse(
|
||||||
|
request: Request,
|
||||||
|
session_id: str,
|
||||||
|
path: str,
|
||||||
|
url: str,
|
||||||
|
headers: dict,
|
||||||
|
body: bytes,
|
||||||
|
start_time: float,
|
||||||
|
):
|
||||||
|
"""Proxy an SSE event stream without buffering."""
|
||||||
|
log_session_operation(
|
||||||
|
session_id, "proxy_sse_stream", method=request.method, path=path
|
||||||
|
)
|
||||||
|
|
||||||
|
async def event_generator():
|
||||||
|
try:
|
||||||
|
async with stream_http_request(
|
||||||
|
method=request.method,
|
||||||
|
url=url,
|
||||||
|
headers=headers,
|
||||||
|
content=body,
|
||||||
|
) as upstream:
|
||||||
|
async for chunk in upstream.aiter_bytes():
|
||||||
|
yield chunk
|
||||||
|
except httpx.RequestError as e:
|
||||||
|
log_security_event(
|
||||||
|
"proxy_sse_error", "error",
|
||||||
|
session_id=session_id, method=request.method,
|
||||||
|
path=path, error=str(e),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
duration_ms = (time.time() - start_time) * 1000
|
||||||
|
log_request(
|
||||||
|
request.method, f"/{path}", 200, duration_ms,
|
||||||
|
session_id=session_id, operation="proxy_sse_complete",
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = StreamingResponse(
|
||||||
|
event_generator(),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
resp.set_cookie(
|
||||||
|
key="lovdata_session",
|
||||||
|
value=session_id,
|
||||||
|
httponly=True,
|
||||||
|
samesite="lax",
|
||||||
|
max_age=86400,
|
||||||
|
)
|
||||||
|
return resp
|
||||||
121
session-manager/routes/sessions.py
Normal file
121
session-manager/routes/sessions.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import time
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, BackgroundTasks, Request
|
||||||
|
|
||||||
|
from models import SessionData
|
||||||
|
from session_manager import session_manager
|
||||||
|
from session_auth import revoke_session_auth_token
|
||||||
|
from logging_config import (
|
||||||
|
RequestContext,
|
||||||
|
log_performance,
|
||||||
|
log_request,
|
||||||
|
log_session_operation,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(tags=["sessions"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sessions", response_model=SessionData)
|
||||||
|
async def create_session(request: Request):
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
with RequestContext():
|
||||||
|
try:
|
||||||
|
log_request("POST", "/sessions", 200, 0, operation="create_session_start")
|
||||||
|
|
||||||
|
session = await session_manager.create_session()
|
||||||
|
|
||||||
|
duration_ms = (time.time() - start_time) * 1000
|
||||||
|
log_session_operation(
|
||||||
|
session.session_id, "created", duration_ms=duration_ms
|
||||||
|
)
|
||||||
|
log_performance(
|
||||||
|
"create_session", duration_ms, session_id=session.session_id
|
||||||
|
)
|
||||||
|
|
||||||
|
return session
|
||||||
|
except HTTPException as e:
|
||||||
|
duration_ms = (time.time() - start_time) * 1000
|
||||||
|
log_request(
|
||||||
|
"POST", "/sessions", e.status_code, duration_ms, error=str(e.detail)
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
duration_ms = (time.time() - start_time) * 1000
|
||||||
|
log_request("POST", "/sessions", 500, duration_ms, error=str(e))
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Failed to create session: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sessions/{session_id}", response_model=SessionData)
|
||||||
|
async def get_session(session_id: str, request: Request):
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
with RequestContext():
|
||||||
|
try:
|
||||||
|
log_request(
|
||||||
|
"GET", f"/sessions/{session_id}", 200, 0, operation="get_session_start"
|
||||||
|
)
|
||||||
|
|
||||||
|
session = await session_manager.get_session(session_id)
|
||||||
|
if not session:
|
||||||
|
duration_ms = (time.time() - start_time) * 1000
|
||||||
|
log_request(
|
||||||
|
"GET",
|
||||||
|
f"/sessions/{session_id}",
|
||||||
|
404,
|
||||||
|
duration_ms,
|
||||||
|
session_id=session_id,
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=404, detail="Session not found")
|
||||||
|
|
||||||
|
duration_ms = (time.time() - start_time) * 1000
|
||||||
|
log_request(
|
||||||
|
"GET",
|
||||||
|
f"/sessions/{session_id}",
|
||||||
|
200,
|
||||||
|
duration_ms,
|
||||||
|
session_id=session_id,
|
||||||
|
)
|
||||||
|
log_session_operation(session_id, "accessed", duration_ms=duration_ms)
|
||||||
|
|
||||||
|
return session
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
duration_ms = (time.time() - start_time) * 1000
|
||||||
|
log_request(
|
||||||
|
"GET", f"/sessions/{session_id}", 500, duration_ms, error=str(e)
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Failed to get session: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sessions", response_model=List[SessionData])
|
||||||
|
async def list_sessions():
|
||||||
|
return await session_manager.list_sessions()
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/sessions/{session_id}")
|
||||||
|
async def delete_session(session_id: str, background_tasks: BackgroundTasks):
|
||||||
|
session = await session_manager.get_session(session_id)
|
||||||
|
if not session:
|
||||||
|
raise HTTPException(status_code=404, detail="Session not found")
|
||||||
|
|
||||||
|
revoke_session_auth_token(session_id)
|
||||||
|
|
||||||
|
background_tasks.add_task(session_manager.cleanup_expired_sessions)
|
||||||
|
|
||||||
|
del session_manager.sessions[session_id]
|
||||||
|
session_manager._save_sessions()
|
||||||
|
|
||||||
|
return {"message": f"Session {session_id} scheduled for deletion"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/cleanup")
|
||||||
|
async def trigger_cleanup():
|
||||||
|
await session_manager.cleanup_expired_sessions()
|
||||||
|
return {"message": "Cleanup completed"}
|
||||||
@@ -83,8 +83,8 @@ class SessionTokenManager:
|
|||||||
|
|
||||||
session_data = self._session_tokens[session_id]
|
session_data = self._session_tokens[session_id]
|
||||||
|
|
||||||
# Check if token matches
|
# Check if token matches using constant-time comparison to prevent timing attacks
|
||||||
if session_data["token"] != token:
|
if not secrets.compare_digest(session_data["token"], token):
|
||||||
return False, "Invalid token"
|
return False, "Invalid token"
|
||||||
|
|
||||||
# Check if token has expired
|
# Check if token has expired
|
||||||
@@ -212,7 +212,7 @@ def revoke_session_auth_token(session_id: str) -> bool:
|
|||||||
|
|
||||||
def rotate_session_auth_token(session_id: str) -> Optional[str]:
|
def rotate_session_auth_token(session_id: str) -> Optional[str]:
|
||||||
"""Rotate a session authentication token."""
|
"""Rotate a session authentication token."""
|
||||||
return _session_token_manager.rotate_session_auth_token(session_id)
|
return _session_token_manager.rotate_session_token(session_id)
|
||||||
|
|
||||||
|
|
||||||
def cleanup_expired_auth_tokens() -> int:
|
def cleanup_expired_auth_tokens() -> int:
|
||||||
|
|||||||
492
session-manager/session_manager.py
Normal file
492
session-manager/session_manager.py
Normal file
@@ -0,0 +1,492 @@
|
|||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
import json
|
||||||
|
import asyncio
|
||||||
|
import shutil
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, Optional, List
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from config import (
|
||||||
|
SESSIONS_DIR,
|
||||||
|
SESSIONS_FILE,
|
||||||
|
CONTAINER_IMAGE,
|
||||||
|
MAX_CONCURRENT_SESSIONS,
|
||||||
|
SESSION_TIMEOUT_MINUTES,
|
||||||
|
USE_ASYNC_DOCKER,
|
||||||
|
USE_DATABASE_STORAGE,
|
||||||
|
)
|
||||||
|
from models import SessionData
|
||||||
|
from docker_service import DockerService
|
||||||
|
from database import SessionModel
|
||||||
|
from resource_manager import get_resource_limits, should_throttle_sessions
|
||||||
|
from session_auth import generate_session_auth_token, cleanup_expired_auth_tokens
|
||||||
|
from logging_config import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SessionManager:
|
||||||
|
def __init__(self, docker_service: Optional[DockerService] = None):
|
||||||
|
if docker_service:
|
||||||
|
self.docker_service = docker_service
|
||||||
|
else:
|
||||||
|
self.docker_service = DockerService(use_async=USE_ASYNC_DOCKER)
|
||||||
|
|
||||||
|
if USE_DATABASE_STORAGE:
|
||||||
|
self.sessions: Dict[str, SessionData] = {}
|
||||||
|
logger.info("Session storage initialized", extra={"backend": "database"})
|
||||||
|
else:
|
||||||
|
self.sessions: Dict[str, SessionData] = {}
|
||||||
|
self._load_sessions_from_file()
|
||||||
|
logger.info("Session storage initialized", extra={"backend": "json_file"})
|
||||||
|
|
||||||
|
from container_health import get_container_health_monitor
|
||||||
|
|
||||||
|
self.health_monitor = get_container_health_monitor()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"SessionManager initialized",
|
||||||
|
extra={
|
||||||
|
"docker_service_type": type(self.docker_service).__name__,
|
||||||
|
"storage_backend": "database" if USE_DATABASE_STORAGE else "json_file",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _load_sessions_from_file(self):
|
||||||
|
if SESSIONS_FILE.exists():
|
||||||
|
try:
|
||||||
|
with open(SESSIONS_FILE, "r") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
for session_id, session_dict in data.items():
|
||||||
|
session_dict["created_at"] = datetime.fromisoformat(
|
||||||
|
session_dict["created_at"]
|
||||||
|
)
|
||||||
|
session_dict["last_accessed"] = datetime.fromisoformat(
|
||||||
|
session_dict["last_accessed"]
|
||||||
|
)
|
||||||
|
self.sessions[session_id] = SessionData(**session_dict)
|
||||||
|
logger.info(
|
||||||
|
"Sessions loaded from JSON file",
|
||||||
|
extra={"count": len(self.sessions)},
|
||||||
|
)
|
||||||
|
except (json.JSONDecodeError, KeyError) as e:
|
||||||
|
logger.warning("Could not load sessions file", extra={"error": str(e)})
|
||||||
|
self.sessions = {}
|
||||||
|
|
||||||
|
async def _load_sessions_from_database(self):
|
||||||
|
try:
|
||||||
|
db_sessions = await SessionModel.get_sessions_by_status("running")
|
||||||
|
db_sessions.extend(await SessionModel.get_sessions_by_status("creating"))
|
||||||
|
|
||||||
|
self.sessions = {}
|
||||||
|
for session_dict in db_sessions:
|
||||||
|
session_data = SessionData(**session_dict)
|
||||||
|
self.sessions[session_dict["session_id"]] = session_data
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Sessions loaded from database", extra={"count": len(self.sessions)}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"Failed to load sessions from database", extra={"error": str(e)}
|
||||||
|
)
|
||||||
|
self.sessions = {}
|
||||||
|
|
||||||
|
def _save_sessions(self):
|
||||||
|
SESSIONS_DIR.mkdir(exist_ok=True)
|
||||||
|
data = {}
|
||||||
|
for session_id, session in self.sessions.items():
|
||||||
|
data[session_id] = session.dict()
|
||||||
|
|
||||||
|
with open(SESSIONS_FILE, "w") as f:
|
||||||
|
json.dump(data, f, indent=2, default=str)
|
||||||
|
|
||||||
|
def _generate_session_id(self) -> str:
|
||||||
|
return str(uuid.uuid4()).replace("-", "")[:16]
|
||||||
|
|
||||||
|
def _get_available_port(self) -> int:
|
||||||
|
used_ports = {s.port for s in self.sessions.values() if s.port}
|
||||||
|
port = 8081
|
||||||
|
while port in used_ports:
|
||||||
|
port += 1
|
||||||
|
return port
|
||||||
|
|
||||||
|
def _check_container_limits(self) -> bool:
|
||||||
|
active_sessions = sum(
|
||||||
|
1 for s in self.sessions.values() if s.status in ["creating", "running"]
|
||||||
|
)
|
||||||
|
return active_sessions < MAX_CONCURRENT_SESSIONS
|
||||||
|
|
||||||
|
async def _async_check_container_limits(self) -> bool:
|
||||||
|
return self._check_container_limits()
|
||||||
|
|
||||||
|
async def create_session(self) -> SessionData:
|
||||||
|
if USE_ASYNC_DOCKER:
|
||||||
|
limits_ok = await self._async_check_container_limits()
|
||||||
|
else:
|
||||||
|
limits_ok = self._check_container_limits()
|
||||||
|
|
||||||
|
if not limits_ok:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=429,
|
||||||
|
detail=f"Maximum concurrent sessions ({MAX_CONCURRENT_SESSIONS}) reached",
|
||||||
|
)
|
||||||
|
|
||||||
|
should_throttle, reason = should_throttle_sessions()
|
||||||
|
if should_throttle:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail=f"System resource constraints prevent new sessions: {reason}",
|
||||||
|
)
|
||||||
|
|
||||||
|
session_id = self._generate_session_id()
|
||||||
|
container_name = f"opencode-{session_id}"
|
||||||
|
host_dir = str(SESSIONS_DIR / session_id)
|
||||||
|
port = self._get_available_port()
|
||||||
|
|
||||||
|
Path(host_dir).mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
auth_token = generate_session_auth_token(session_id)
|
||||||
|
|
||||||
|
session = SessionData(
|
||||||
|
session_id=session_id,
|
||||||
|
container_name=container_name,
|
||||||
|
host_dir=host_dir,
|
||||||
|
port=port,
|
||||||
|
auth_token=auth_token,
|
||||||
|
created_at=datetime.now(),
|
||||||
|
last_accessed=datetime.now(),
|
||||||
|
status="creating",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.sessions[session_id] = session
|
||||||
|
|
||||||
|
if USE_DATABASE_STORAGE:
|
||||||
|
try:
|
||||||
|
await SessionModel.create_session(
|
||||||
|
{
|
||||||
|
"session_id": session_id,
|
||||||
|
"container_name": container_name,
|
||||||
|
"host_dir": host_dir,
|
||||||
|
"port": port,
|
||||||
|
"auth_token": auth_token,
|
||||||
|
"status": "creating",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Session created in database", extra={"session_id": session_id}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"Failed to create session in database",
|
||||||
|
extra={"session_id": session_id, "error": str(e)},
|
||||||
|
)
|
||||||
|
|
||||||
|
if USE_ASYNC_DOCKER:
|
||||||
|
asyncio.create_task(self._start_container_async(session))
|
||||||
|
else:
|
||||||
|
asyncio.create_task(self._start_container_sync(session))
|
||||||
|
|
||||||
|
return session
|
||||||
|
|
||||||
|
async def _start_container_async(self, session: SessionData):
|
||||||
|
try:
|
||||||
|
resource_limits = get_resource_limits()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Starting container {session.container_name} with resource limits: "
|
||||||
|
f"memory={resource_limits.memory_limit}, cpu_quota={resource_limits.cpu_quota}"
|
||||||
|
)
|
||||||
|
|
||||||
|
container_info = await self.docker_service.create_container(
|
||||||
|
name=session.container_name,
|
||||||
|
image=CONTAINER_IMAGE,
|
||||||
|
volumes={session.host_dir: {"bind": "/app/somedir", "mode": "rw"}},
|
||||||
|
ports={"8080": session.port},
|
||||||
|
environment={
|
||||||
|
"MCP_SERVER": os.getenv("MCP_SERVER", ""),
|
||||||
|
"OPENAI_API_KEY": os.getenv("OPENAI_API_KEY", ""),
|
||||||
|
"ANTHROPIC_API_KEY": os.getenv("ANTHROPIC_API_KEY", ""),
|
||||||
|
"GOOGLE_API_KEY": os.getenv("GOOGLE_API_KEY", ""),
|
||||||
|
"OPENCODE_API_KEY": os.getenv("ZEN_API_KEY", ""),
|
||||||
|
"SESSION_AUTH_TOKEN": session.auth_token or "",
|
||||||
|
"SESSION_ID": session.session_id,
|
||||||
|
},
|
||||||
|
network_mode="bridge",
|
||||||
|
mem_limit=resource_limits.memory_limit,
|
||||||
|
cpu_quota=resource_limits.cpu_quota,
|
||||||
|
cpu_period=resource_limits.cpu_period,
|
||||||
|
tmpfs={
|
||||||
|
"/tmp": "rw,noexec,nosuid,size=100m",
|
||||||
|
"/var/tmp": "rw,noexec,nosuid,size=50m",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
await self.docker_service.start_container(container_info.container_id)
|
||||||
|
|
||||||
|
session.container_id = container_info.container_id
|
||||||
|
session.status = "running"
|
||||||
|
|
||||||
|
self.sessions[session.session_id] = session
|
||||||
|
|
||||||
|
if USE_DATABASE_STORAGE:
|
||||||
|
try:
|
||||||
|
await SessionModel.update_session(
|
||||||
|
session.session_id,
|
||||||
|
{
|
||||||
|
"container_id": container_info.container_id,
|
||||||
|
"status": "running",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"Failed to update session in database",
|
||||||
|
extra={"session_id": session.session_id, "error": str(e)},
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Container started successfully",
|
||||||
|
extra={
|
||||||
|
"session_id": session.session_id,
|
||||||
|
"container_name": session.container_name,
|
||||||
|
"container_id": container_info.container_id,
|
||||||
|
"port": session.port,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
session.status = "error"
|
||||||
|
self._save_sessions()
|
||||||
|
logger.error(f"Failed to start container {session.container_name}: {e}")
|
||||||
|
|
||||||
|
async def _start_container_sync(self, session: SessionData):
|
||||||
|
try:
|
||||||
|
resource_limits = get_resource_limits()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Starting container {session.container_name} with resource limits: "
|
||||||
|
f"memory={resource_limits.memory_limit}, cpu_quota={resource_limits.cpu_quota}"
|
||||||
|
)
|
||||||
|
|
||||||
|
container_info = await self.docker_service.create_container(
|
||||||
|
name=session.container_name,
|
||||||
|
image=CONTAINER_IMAGE,
|
||||||
|
volumes={session.host_dir: {"bind": "/app/somedir", "mode": "rw"}},
|
||||||
|
ports={"8080": session.port},
|
||||||
|
environment={
|
||||||
|
"MCP_SERVER": os.getenv("MCP_SERVER", ""),
|
||||||
|
"OPENAI_API_KEY": os.getenv("OPENAI_API_KEY", ""),
|
||||||
|
"ANTHROPIC_API_KEY": os.getenv("ANTHROPIC_API_KEY", ""),
|
||||||
|
"GOOGLE_API_KEY": os.getenv("GOOGLE_API_KEY", ""),
|
||||||
|
"OPENCODE_API_KEY": os.getenv("ZEN_API_KEY", ""),
|
||||||
|
"SESSION_AUTH_TOKEN": session.auth_token or "",
|
||||||
|
"SESSION_ID": session.session_id,
|
||||||
|
},
|
||||||
|
network_mode="bridge",
|
||||||
|
mem_limit=resource_limits.memory_limit,
|
||||||
|
cpu_quota=resource_limits.cpu_quota,
|
||||||
|
cpu_period=resource_limits.cpu_period,
|
||||||
|
tmpfs={
|
||||||
|
"/tmp": "rw,noexec,nosuid,size=100m",
|
||||||
|
"/var/tmp": "rw,noexec,nosuid,size=50m",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
session.container_id = container_info.container_id
|
||||||
|
session.status = "running"
|
||||||
|
|
||||||
|
self.sessions[session.session_id] = session
|
||||||
|
|
||||||
|
if USE_DATABASE_STORAGE:
|
||||||
|
try:
|
||||||
|
await SessionModel.update_session(
|
||||||
|
session.session_id,
|
||||||
|
{
|
||||||
|
"container_id": container_info.container_id,
|
||||||
|
"status": "running",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"Failed to update session in database",
|
||||||
|
extra={"session_id": session.session_id, "error": str(e)},
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Container started successfully",
|
||||||
|
extra={
|
||||||
|
"session_id": session.session_id,
|
||||||
|
"container_name": session.container_name,
|
||||||
|
"container_id": container_info.container_id,
|
||||||
|
"port": session.port,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
session.status = "error"
|
||||||
|
self._save_sessions()
|
||||||
|
logger.error(f"Failed to start container {session.container_name}: {e}")
|
||||||
|
|
||||||
|
async def get_session(self, session_id: str) -> Optional[SessionData]:
|
||||||
|
session = self.sessions.get(session_id)
|
||||||
|
if session:
|
||||||
|
session.last_accessed = datetime.now()
|
||||||
|
if USE_DATABASE_STORAGE:
|
||||||
|
try:
|
||||||
|
await SessionModel.update_session(
|
||||||
|
session_id, {"last_accessed": datetime.now()}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to update session access time in database",
|
||||||
|
extra={"session_id": session_id, "error": str(e)},
|
||||||
|
)
|
||||||
|
return session
|
||||||
|
|
||||||
|
if USE_DATABASE_STORAGE:
|
||||||
|
try:
|
||||||
|
db_session = await SessionModel.get_session(session_id)
|
||||||
|
if db_session:
|
||||||
|
session_data = SessionData(**db_session)
|
||||||
|
self.sessions[session_id] = session_data
|
||||||
|
logger.debug(
|
||||||
|
"Session loaded from database", extra={"session_id": session_id}
|
||||||
|
)
|
||||||
|
return session_data
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"Failed to load session from database",
|
||||||
|
extra={"session_id": session_id, "error": str(e)},
|
||||||
|
)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def list_sessions(self) -> List[SessionData]:
|
||||||
|
return list(self.sessions.values())
|
||||||
|
|
||||||
|
async def restart_session(self, session_id: str) -> Optional[SessionData]:
|
||||||
|
"""Restart a session's container while preserving the session ID.
|
||||||
|
|
||||||
|
Unlike create_session(), this reuses the existing session data
|
||||||
|
and only creates a new container, maintaining session ID continuity.
|
||||||
|
This method removes the old container to free up the port.
|
||||||
|
"""
|
||||||
|
session = await self.get_session(session_id)
|
||||||
|
if not session:
|
||||||
|
logger.error(
|
||||||
|
"Cannot restart session: not found",
|
||||||
|
extra={"session_id": session_id},
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
old_container_id = session.container_id
|
||||||
|
logger.info(
|
||||||
|
"Restarting session container",
|
||||||
|
extra={"session_id": session_id, "old_container_id": old_container_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Stop and remove old container to free up the port
|
||||||
|
if old_container_id and self.docker_service:
|
||||||
|
try:
|
||||||
|
logger.info(
|
||||||
|
"Stopping old container for restart",
|
||||||
|
extra={"session_id": session_id, "container_id": old_container_id},
|
||||||
|
)
|
||||||
|
await self.docker_service.stop_container(old_container_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to stop old container (may already be stopped)",
|
||||||
|
extra={"session_id": session_id, "container_id": old_container_id, "error": str(e)},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info(
|
||||||
|
"Removing old container for restart",
|
||||||
|
extra={"session_id": session_id, "container_id": old_container_id},
|
||||||
|
)
|
||||||
|
await self.docker_service.remove_container(old_container_id, force=True)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to remove old container",
|
||||||
|
extra={"session_id": session_id, "container_id": old_container_id, "error": str(e)},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate new container name for the restart
|
||||||
|
new_container_name = f"opencode-{session_id}-{uuid.uuid4().hex[:8]}"
|
||||||
|
session.container_name = new_container_name
|
||||||
|
session.container_id = None # Clear old container_id
|
||||||
|
session.status = "starting"
|
||||||
|
|
||||||
|
# Update session in store before starting container
|
||||||
|
self.sessions[session_id] = session
|
||||||
|
|
||||||
|
if USE_DATABASE_STORAGE:
|
||||||
|
try:
|
||||||
|
await SessionModel.update_session(
|
||||||
|
session_id,
|
||||||
|
{
|
||||||
|
"container_name": new_container_name,
|
||||||
|
"container_id": None,
|
||||||
|
"status": "starting",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"Failed to update session in database during restart",
|
||||||
|
extra={"session_id": session_id, "error": str(e)},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Start new container for this session
|
||||||
|
if USE_ASYNC_DOCKER:
|
||||||
|
asyncio.create_task(self._start_container_async(session))
|
||||||
|
else:
|
||||||
|
asyncio.create_task(self._start_container_sync(session))
|
||||||
|
|
||||||
|
return session
|
||||||
|
|
||||||
|
async def list_containers_async(self, all: bool = False) -> List:
|
||||||
|
return await self.docker_service.list_containers(all=all)
|
||||||
|
|
||||||
|
async def cleanup_expired_sessions(self):
|
||||||
|
now = datetime.now()
|
||||||
|
expired_sessions = []
|
||||||
|
|
||||||
|
for session_id, session in self.sessions.items():
|
||||||
|
if now - session.last_accessed > timedelta(minutes=SESSION_TIMEOUT_MINUTES):
|
||||||
|
expired_sessions.append(session_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.docker_service.stop_container(
|
||||||
|
session.container_name, timeout=10
|
||||||
|
)
|
||||||
|
await self.docker_service.remove_container(session.container_name)
|
||||||
|
logger.info(f"Cleaned up container {session.container_name}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Error cleaning up container {session.container_name}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
shutil.rmtree(session.host_dir)
|
||||||
|
logger.info(f"Removed session directory {session.host_dir}")
|
||||||
|
except OSError as e:
|
||||||
|
logger.error(
|
||||||
|
f"Error removing session directory {session.host_dir}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
for session_id in expired_sessions:
|
||||||
|
del self.sessions[session_id]
|
||||||
|
|
||||||
|
if expired_sessions:
|
||||||
|
self._save_sessions()
|
||||||
|
logger.info(f"Cleaned up {len(expired_sessions)} expired sessions")
|
||||||
|
|
||||||
|
expired_tokens = cleanup_expired_auth_tokens()
|
||||||
|
if expired_tokens > 0:
|
||||||
|
logger.info(f"Cleaned up {expired_tokens} expired authentication tokens")
|
||||||
|
|
||||||
|
|
||||||
|
session_manager = SessionManager()
|
||||||
Reference in New Issue
Block a user