Initial commit of Docker files

This commit is contained in:
2025-08-18 00:26:57 +12:00
commit 05297cf246
29 changed files with 2517 additions and 0 deletions

315
racknerd-converter/kvmapiconv.py Executable file
View 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)