Initial commit of Docker files
This commit is contained in:
34
racknerd-converter/Dockerfile
Executable file
34
racknerd-converter/Dockerfile
Executable file
@@ -0,0 +1,34 @@
|
||||
# Use Python 3.11 slim image
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements file
|
||||
COPY requirements.txt .
|
||||
|
||||
# Install Python dependencies
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy the application
|
||||
COPY kvmapiconv.py .
|
||||
|
||||
# Create a non-root user
|
||||
RUN useradd --create-home --shell /bin/bash appuser && \
|
||||
chown -R appuser:appuser /app
|
||||
USER appuser
|
||||
|
||||
# Expose port
|
||||
EXPOSE 5000
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:5000/health || exit 1
|
||||
|
||||
# Run the application
|
||||
CMD ["python", "kvmapiconv.py"]
|
||||
152
racknerd-converter/README.md
Executable file
152
racknerd-converter/README.md
Executable file
@@ -0,0 +1,152 @@
|
||||
# RackNerd KVM API Converter
|
||||
|
||||
A Python Flask service that converts RackNerd KVM XML API responses to JSON format for Homepage widgets.
|
||||
|
||||
## Features
|
||||
|
||||
- Converts RackNerd XML API responses to clean JSON format
|
||||
- Caches data to reduce API calls (configurable interval, default 30 minutes)
|
||||
- RESTful API endpoints for Homepage integration
|
||||
- Docker containerized for easy deployment
|
||||
- Health checks and error handling
|
||||
- Automatic background data updates
|
||||
|
||||
## API Endpoints
|
||||
|
||||
- `GET /api/kvm` - Get cached KVM data in JSON format
|
||||
- `GET /api/kvm/raw` - Get fresh data (bypass cache)
|
||||
- `GET /api/kvm/status` - Service status and metadata
|
||||
- `GET /health` - Health check endpoint
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Using Docker Compose (Recommended)
|
||||
|
||||
1. Clone/download the files
|
||||
2. Update the environment variables in `docker-compose.yml` with your RackNerd API credentials
|
||||
3. Run:
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### Using Docker
|
||||
|
||||
```bash
|
||||
# Build the image
|
||||
docker build -t racknerd-api-converter .
|
||||
|
||||
# Run the container
|
||||
docker run -d \
|
||||
-p 5000:5000 \
|
||||
-e RACKNERD_API_KEY=your_api_key \
|
||||
-e RACKNERD_API_HASH=your_api_hash \
|
||||
-e RACKNERD_VSERVER_ID=your_vserver_id \
|
||||
-e UPDATE_INTERVAL=1800 \
|
||||
--name racknerd-converter \
|
||||
racknerd-api-converter
|
||||
```
|
||||
|
||||
### Manual Python Setup
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
export RACKNERD_API_KEY=your_api_key
|
||||
export RACKNERD_API_HASH=your_api_hash
|
||||
export RACKNERD_VSERVER_ID=your_vserver_id
|
||||
python app.py
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Configure via environment variables:
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `RACKNERD_API_KEY` | Your RackNerd API key | A0ZJA-FSJXW-QXV7R |
|
||||
| `RACKNERD_API_HASH` | Your RackNerd API hash | fce545debdab0edf2565788277d3670e1afd8823 |
|
||||
| `RACKNERD_VSERVER_ID` | Your VServer ID | 476515 |
|
||||
| `RACKNERD_BASE_URL` | RackNerd API base URL | https://nerdvm.racknerd.com/api/client/command.php |
|
||||
| `UPDATE_INTERVAL` | Cache update interval (seconds) | 1800 (30 minutes) |
|
||||
| `HOST` | Server host | 0.0.0.0 |
|
||||
| `PORT` | Server port | 5000 |
|
||||
|
||||
## Homepage Widget Configuration
|
||||
|
||||
Add this to your Homepage `services.yaml`:
|
||||
|
||||
```yaml
|
||||
- KVM Server:
|
||||
icon: server
|
||||
href: http://your-server:5000
|
||||
ping: http://your-server:5000
|
||||
widget:
|
||||
type: customapi
|
||||
url: http://your-server:5000/api/kvm
|
||||
refreshInterval: 30000
|
||||
mappings:
|
||||
- field: hostname
|
||||
label: Hostname
|
||||
- field: status
|
||||
label: Status
|
||||
- field: uptime
|
||||
label: Uptime
|
||||
- field: cpu_usage
|
||||
label: CPU Usage
|
||||
suffix: "%"
|
||||
- field: memory_usage
|
||||
label: Memory
|
||||
suffix: "%"
|
||||
```
|
||||
|
||||
## API Response Format
|
||||
|
||||
The service converts XML responses to JSON. Example response:
|
||||
|
||||
```json
|
||||
{
|
||||
"vserver": {
|
||||
"hostname": "your-server",
|
||||
"status": "online",
|
||||
"uptime": "15 days",
|
||||
"cpu_usage": 25.5,
|
||||
"memory": {
|
||||
"total": "4096MB",
|
||||
"used": "2048MB",
|
||||
"free": "2048MB"
|
||||
},
|
||||
"bandwidth": {
|
||||
"used": "150GB",
|
||||
"total": "1000GB"
|
||||
}
|
||||
},
|
||||
"_metadata": {
|
||||
"last_updated": "2024-01-15T10:30:00Z",
|
||||
"source": "racknerd_api",
|
||||
"vserver_id": "476515"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
- Check service health: `GET /health`
|
||||
- Monitor logs: `docker logs racknerd-api-converter`
|
||||
- Service status: `GET /api/kvm/status`
|
||||
|
||||
## Security Notes
|
||||
|
||||
- Keep your API credentials secure
|
||||
- Consider using Docker secrets for production
|
||||
- The service runs as a non-root user
|
||||
- Network access is limited to necessary ports
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
1. **No data returned**: Check API credentials and network connectivity
|
||||
2. **XML parsing errors**: Verify API response format hasn't changed
|
||||
3. **Container won't start**: Check environment variables and port conflicts
|
||||
4. **Homepage not updating**: Verify URL and check service logs
|
||||
|
||||
## Contributing
|
||||
|
||||
Feel free to submit issues and enhancement requests!
|
||||
315
racknerd-converter/kvmapiconv.py
Executable file
315
racknerd-converter/kvmapiconv.py
Executable file
@@ -0,0 +1,315 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import requests
|
||||
import xml.etree.ElementTree as ET
|
||||
import json
|
||||
import time
|
||||
import threading
|
||||
from flask import Flask, jsonify, request
|
||||
from datetime import datetime
|
||||
import logging
|
||||
import os
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
class RackNerdAPIConverter:
|
||||
def __init__(self):
|
||||
# Load configuration from environment variables
|
||||
self.api_key = os.getenv('RACKNERD_API_KEY', 'A0ZJA-FSJXW-QXV7R')
|
||||
self.api_hash = os.getenv('RACKNERD_API_HASH', 'fce545debdab0edf2565788277d3670e1afd8823')
|
||||
self.vserver_id = os.getenv('RACKNERD_VSERVER_ID', '476515')
|
||||
self.base_url = os.getenv('RACKNERD_BASE_URL', 'https://nerdvm.racknerd.com/api/client/command.php')
|
||||
self.update_interval = int(os.getenv('UPDATE_INTERVAL', '1800')) # 30 minutes default
|
||||
|
||||
self.cached_data = {}
|
||||
self.last_update = None
|
||||
self.lock = threading.Lock()
|
||||
|
||||
def fetch_kvm_data(self) -> Optional[Dict[str, Any]]:
|
||||
"""Fetch data from RackNerd API and convert XML to JSON"""
|
||||
try:
|
||||
# Construct the API URL
|
||||
params = {
|
||||
'key': self.api_key,
|
||||
'hash': self.api_hash,
|
||||
'action': 'info',
|
||||
'vserverid': self.vserver_id,
|
||||
'bw': 'true'
|
||||
}
|
||||
|
||||
logger.info(f"Fetching data from RackNerd API for vserver {self.vserver_id}")
|
||||
|
||||
# Make the API request
|
||||
response = requests.get(self.base_url, params=params, timeout=30)
|
||||
response.raise_for_status()
|
||||
|
||||
# Clean up the response text
|
||||
xml_text = response.text.strip()
|
||||
|
||||
# Handle potential HTML/error responses
|
||||
if xml_text.lower().startswith('<!doctype') or xml_text.lower().startswith('<html'):
|
||||
logger.error("Received HTML response instead of XML - check API credentials")
|
||||
return {
|
||||
'error': 'Invalid API Response',
|
||||
'message': 'Received HTML instead of XML. Check your API credentials.',
|
||||
'raw_response': xml_text[:500]
|
||||
}
|
||||
|
||||
# RackNerd returns XML fragments without root element, so wrap them
|
||||
if not xml_text.startswith('<?xml') and not xml_text.startswith('<root>'):
|
||||
xml_text = f"<root>{xml_text}</root>"
|
||||
logger.info("Wrapped XML fragments in root element")
|
||||
|
||||
# Parse XML response
|
||||
root = ET.fromstring(xml_text)
|
||||
|
||||
# Convert XML to JSON
|
||||
json_data = self.xml_to_dict(root)
|
||||
|
||||
# Parse bandwidth data if present
|
||||
if 'bw' in json_data and isinstance(json_data['bw'], str):
|
||||
bw_parts = json_data['bw'].split(',')
|
||||
if len(bw_parts) >= 4:
|
||||
json_data['bandwidth'] = {
|
||||
'total_bytes': int(bw_parts[0]) if bw_parts[0].isdigit() else bw_parts[0],
|
||||
'used_bytes': int(bw_parts[1]) if bw_parts[1].isdigit() else bw_parts[1],
|
||||
'total_formatted': self.format_bytes(int(bw_parts[0])) if bw_parts[0].isdigit() else bw_parts[0],
|
||||
'used_formatted': self.format_bytes(int(bw_parts[1])) if bw_parts[1].isdigit() else bw_parts[1],
|
||||
'remaining_bytes': int(bw_parts[0]) - int(bw_parts[1]) if bw_parts[0].isdigit() and bw_parts[1].isdigit() else 0,
|
||||
'usage_percent': round((int(bw_parts[1]) / int(bw_parts[0])) * 100, 2) if bw_parts[0].isdigit() and bw_parts[1].isdigit() and int(bw_parts[0]) > 0 else 0
|
||||
}
|
||||
if json_data['bandwidth']['remaining_bytes'] > 0:
|
||||
json_data['bandwidth']['remaining_formatted'] = self.format_bytes(json_data['bandwidth']['remaining_bytes'])
|
||||
|
||||
# Add metadata
|
||||
json_data['_metadata'] = {
|
||||
'last_updated': datetime.utcnow().isoformat() + 'Z',
|
||||
'source': 'racknerd_api',
|
||||
'vserver_id': self.vserver_id,
|
||||
'raw_response_length': len(response.text)
|
||||
}
|
||||
|
||||
logger.info("Successfully converted XML to JSON")
|
||||
return json_data
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"API request failed: {e}")
|
||||
return {
|
||||
'error': 'API Request Failed',
|
||||
'message': str(e),
|
||||
'timestamp': datetime.utcnow().isoformat() + 'Z'
|
||||
}
|
||||
except ET.ParseError as e:
|
||||
logger.error(f"XML parsing failed: {e}")
|
||||
logger.error(f"Raw response that failed to parse: {response.text}")
|
||||
return {
|
||||
'error': 'XML Parse Error',
|
||||
'message': str(e),
|
||||
'raw_response': response.text[:1000],
|
||||
'timestamp': datetime.utcnow().isoformat() + 'Z'
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error: {e}")
|
||||
return {
|
||||
'error': 'Unexpected Error',
|
||||
'message': str(e),
|
||||
'timestamp': datetime.utcnow().isoformat() + 'Z'
|
||||
}
|
||||
|
||||
def xml_to_dict(self, element) -> Dict[str, Any]:
|
||||
"""Recursively convert XML element to dictionary"""
|
||||
result = {}
|
||||
|
||||
# Handle element text
|
||||
if element.text and element.text.strip():
|
||||
# Try to convert to appropriate type
|
||||
text = element.text.strip()
|
||||
if text.lower() in ['true', 'false']:
|
||||
result['_text'] = text.lower() == 'true'
|
||||
elif text.isdigit():
|
||||
result['_text'] = int(text)
|
||||
elif self.is_float(text):
|
||||
result['_text'] = float(text)
|
||||
else:
|
||||
result['_text'] = text
|
||||
|
||||
# Handle attributes
|
||||
if element.attrib:
|
||||
result['_attributes'] = element.attrib
|
||||
|
||||
# Handle child elements
|
||||
children = {}
|
||||
for child in element:
|
||||
child_data = self.xml_to_dict(child)
|
||||
|
||||
if child.tag in children:
|
||||
# Handle multiple children with same tag
|
||||
if not isinstance(children[child.tag], list):
|
||||
children[child.tag] = [children[child.tag]]
|
||||
children[child.tag].append(child_data)
|
||||
else:
|
||||
children[child.tag] = child_data
|
||||
|
||||
if children:
|
||||
result.update(children)
|
||||
|
||||
# If only text content, return it directly
|
||||
if len(result) == 1 and '_text' in result:
|
||||
return result['_text']
|
||||
|
||||
return result
|
||||
|
||||
def format_bytes(self, bytes_value: int) -> str:
|
||||
"""Convert bytes to human readable format"""
|
||||
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
|
||||
if bytes_value < 1024.0:
|
||||
return f"{bytes_value:.1f} {unit}"
|
||||
bytes_value /= 1024.0
|
||||
return f"{bytes_value:.1f} PB"
|
||||
|
||||
def is_float(self, value: str) -> bool:
|
||||
"""Check if string can be converted to float"""
|
||||
try:
|
||||
float(value)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
def update_cache(self):
|
||||
"""Update cached data"""
|
||||
with self.lock:
|
||||
data = self.fetch_kvm_data()
|
||||
if data:
|
||||
self.cached_data = data
|
||||
self.last_update = datetime.utcnow()
|
||||
logger.info("Cache updated successfully")
|
||||
else:
|
||||
logger.error("Failed to update cache")
|
||||
|
||||
def get_cached_data(self) -> Dict[str, Any]:
|
||||
"""Get cached data with thread safety"""
|
||||
with self.lock:
|
||||
if not self.cached_data:
|
||||
return {
|
||||
'error': 'No data available',
|
||||
'message': 'Initial data fetch in progress'
|
||||
}
|
||||
return self.cached_data.copy()
|
||||
|
||||
def start_background_updates(self):
|
||||
"""Start background thread for periodic updates"""
|
||||
def update_loop():
|
||||
while True:
|
||||
self.update_cache()
|
||||
time.sleep(self.update_interval)
|
||||
|
||||
thread = threading.Thread(target=update_loop, daemon=True)
|
||||
thread.start()
|
||||
logger.info(f"Background updates started (interval: {self.update_interval} seconds)")
|
||||
|
||||
# Initialize converter
|
||||
converter = RackNerdAPIConverter()
|
||||
|
||||
@app.route('/health', methods=['GET'])
|
||||
def health_check():
|
||||
"""Health check endpoint"""
|
||||
return jsonify({
|
||||
'status': 'healthy',
|
||||
'timestamp': datetime.utcnow().isoformat() + 'Z',
|
||||
'last_update': converter.last_update.isoformat() + 'Z' if converter.last_update else None
|
||||
})
|
||||
|
||||
@app.route('/api/kvm', methods=['GET'])
|
||||
def get_kvm_data():
|
||||
"""Main endpoint to get KVM data in JSON format"""
|
||||
return jsonify(converter.get_cached_data())
|
||||
|
||||
@app.route('/api/kvm/raw', methods=['GET'])
|
||||
def get_raw_kvm_data():
|
||||
"""Endpoint to get fresh data (bypass cache)"""
|
||||
data = converter.fetch_kvm_data()
|
||||
if data:
|
||||
return jsonify(data)
|
||||
else:
|
||||
return jsonify({
|
||||
'error': 'Failed to fetch data',
|
||||
'message': 'Unable to retrieve data from RackNerd API'
|
||||
}), 500
|
||||
|
||||
@app.route('/api/kvm/debug', methods=['GET'])
|
||||
def debug_api():
|
||||
"""Debug endpoint to see raw API response"""
|
||||
try:
|
||||
params = {
|
||||
'key': converter.api_key,
|
||||
'hash': converter.api_hash,
|
||||
'action': 'info',
|
||||
'vserverid': converter.vserver_id,
|
||||
'bw': 'true'
|
||||
}
|
||||
|
||||
response = requests.get(converter.base_url, params=params, timeout=30)
|
||||
|
||||
return jsonify({
|
||||
'status_code': response.status_code,
|
||||
'headers': dict(response.headers),
|
||||
'raw_content': response.text,
|
||||
'content_length': len(response.text),
|
||||
'url': response.url
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'error': 'Debug request failed',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
|
||||
@app.route('/api/kvm/status', methods=['GET'])
|
||||
def get_status():
|
||||
"""Get service status and metadata"""
|
||||
with converter.lock:
|
||||
return jsonify({
|
||||
'service': 'racknerd-api-converter',
|
||||
'status': 'running',
|
||||
'vserver_id': converter.vserver_id,
|
||||
'update_interval': converter.update_interval,
|
||||
'last_update': converter.last_update.isoformat() + 'Z' if converter.last_update else None,
|
||||
'has_cached_data': bool(converter.cached_data),
|
||||
'timestamp': datetime.utcnow().isoformat() + 'Z'
|
||||
})
|
||||
|
||||
@app.errorhandler(404)
|
||||
def not_found(error):
|
||||
return jsonify({
|
||||
'error': 'Not found',
|
||||
'message': 'The requested endpoint does not exist'
|
||||
}), 404
|
||||
|
||||
@app.errorhandler(500)
|
||||
def internal_error(error):
|
||||
return jsonify({
|
||||
'error': 'Internal server error',
|
||||
'message': 'An unexpected error occurred'
|
||||
}), 500
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Initial data fetch
|
||||
logger.info("Starting RackNerd API Converter...")
|
||||
converter.update_cache()
|
||||
|
||||
# Start background updates
|
||||
converter.start_background_updates()
|
||||
|
||||
# Start Flask app
|
||||
port = int(os.getenv('PORT', '5000'))
|
||||
host = os.getenv('HOST', '0.0.0.0')
|
||||
|
||||
logger.info(f"Starting server on {host}:{port}")
|
||||
app.run(host=host, port=port, debug=False)
|
||||
3
racknerd-converter/requirements.txt
Executable file
3
racknerd-converter/requirements.txt
Executable file
@@ -0,0 +1,3 @@
|
||||
Flask==2.3.3
|
||||
requests==2.31.0
|
||||
Werkzeug==2.3.7
|
||||
Reference in New Issue
Block a user