Files
Unraid-AppOps-Gateway-Public/backend/app/connectors/unraid.py
T

746 lines
30 KiB
Python

from __future__ import annotations
import json
import math
import re
from dataclasses import dataclass
from typing import Any
import httpx
from ..core.config import settings
from ..core.redaction import redact_message
from .base import BaseConnector
_UNRAID_DEFAULT_TIMEOUT_SECONDS = 8.0
_UNRAID_REDIRECT_STATUSES = {301, 302, 303, 307, 308}
@dataclass
class _ConnectorConfig:
url: str
api_key: str
verify_tls: bool
def _as_bool(value: Any) -> bool | None:
if isinstance(value, bool):
return value
if value is None:
return None
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in {'true', '1', 'yes', 'on', 'running', 'started'}:
return True
if normalized in {'false', '0', 'off', 'stopped'}:
return False
return None
def _as_int(value: Any, default: int = 0) -> int:
if value is None:
return default
if isinstance(value, bool):
return int(value)
if isinstance(value, int | float):
return int(value)
if isinstance(value, str):
cleaned = value.strip().strip('%')
if not cleaned:
return default
numeric = re.sub(r'[^0-9.+-]', '', cleaned)
if not numeric:
return default
try:
return int(float(numeric))
except ValueError:
return default
return default
def _as_float(value: Any) -> float | None:
if value is None or isinstance(value, bool):
return None
if isinstance(value, int | float):
number = float(value)
return number if math.isfinite(number) else None
if isinstance(value, str):
cleaned = value.strip().strip('%')
if not cleaned:
return None
numeric = re.sub(r'[^0-9.+-]', '', cleaned)
if not numeric:
return None
try:
number = float(numeric)
return number if math.isfinite(number) else None
except ValueError:
return None
return None
def _clamp_percent(value: float) -> int:
return int(round(max(0.0, min(100.0, value))))
def _percent_from_values(used: Any, total: Any, *, precise: bool = False) -> float | int | None:
used_value = _as_float(used)
total_value = _as_float(total)
if used_value is None or total_value is None or total_value <= 0:
return None
percent = max(0.0, min(100.0, (used_value / total_value) * 100))
return round(percent, 2) if precise else _clamp_percent(percent)
def _average_cpu_usage(cpus: Any) -> float | None:
if not isinstance(cpus, list) or not cpus:
return None
idle_values: list[float] = []
active_values: list[float] = []
active_fields = ('percentUser', 'percentSystem', 'percentNice', 'percentIrq')
for raw_cpu in cpus:
if not isinstance(raw_cpu, dict):
continue
idle = _as_float(raw_cpu.get('percentIdle'))
if idle is not None:
idle_values.append(max(0.0, min(100.0, idle)))
continue
active_total = 0.0
has_active = False
for field in active_fields:
value = _as_float(raw_cpu.get(field))
if value is None:
continue
active_total += value
has_active = True
if has_active:
active_values.append(active_total)
if idle_values:
return round(max(0.0, min(100.0, 100 - (sum(idle_values) / len(idle_values)))), 2)
if active_values:
return round(max(0.0, min(100.0, sum(active_values) / len(active_values))), 2)
return None
def _storage_value_to_gib(value: Any) -> int:
raw = _as_int(value, 0)
if raw <= 0:
return 0
if raw >= 1_048_576:
return int(raw / 1024 / 1024)
return raw
def _get_nested(data: dict[str, Any], path: str) -> Any:
cursor: Any = data
for part in path.split('.'):
if not isinstance(cursor, dict):
return None
cursor = cursor.get(part)
if cursor is None:
return None
return cursor
def _first_value(data: dict[str, Any], paths: tuple[str, ...]) -> Any:
for path in paths:
value = _get_nested(data, path)
if value is not None:
return value
return None
def _normalize_status(value: Any, allowed: tuple[str, ...], default: str = "stopped") -> str:
if value is None:
return default
candidate = str(value).strip().lower()
if not candidate:
return default
if candidate in {"1", "true", "started", "running", "online", "healthy"}:
return allowed[0]
if candidate in {"0", "false", "stopped", "offline", "unhealthy", "critical", "invalid"}:
return allowed[1] if len(allowed) > 1 else default
return candidate if candidate in allowed else default
def _safe_disk_warning_count(disks: list[dict[str, Any]]) -> int:
total = 0
for raw_disk in disks:
if not isinstance(raw_disk, dict):
continue
warning = raw_disk.get('warning')
if warning is True:
total += 1
continue
if raw_disk.get('critical') is not None:
total += 1
continue
status = str(raw_disk.get('status', raw_disk.get('state', ''))).strip().lower()
if status in {'warning', 'degraded', 'failed', 'offline', 'faulty', 'disk_dsbl', 'disk_invalid'}:
total += 1
if _as_bool(raw_disk.get('healthy')) is False:
total += 1
return total
def _trusted_https_redirect(response: httpx.Response) -> httpx.URL | None:
if response.status_code not in _UNRAID_REDIRECT_STATUSES:
return None
location = response.headers.get('location')
if not location:
return None
redirected = response.url.join(location)
if redirected.scheme != 'https' or redirected.host != response.url.host:
return None
return redirected
class UnraidConnector(BaseConnector):
name = 'Unraid'
default_headers = {
'Content-Type': 'application/json',
'x-api-key': '',
}
def __init__(self):
super().__init__()
def is_configured(self):
return bool(settings.apis.get('UNRAID_URL') and settings.apis.get('UNRAID_API_KEY'))
def _config(self) -> _ConnectorConfig | None:
if not self.is_configured():
return None
return _ConnectorConfig(
url=str(settings.apis['UNRAID_URL']).rstrip('/'),
api_key=str(settings.apis['UNRAID_API_KEY']),
verify_tls=bool(settings.apis.get('UNRAID_VERIFY_TLS', settings.verify_tls)),
)
def _request(self, query: str) -> dict[str, Any]:
config = self._config()
if not config:
raise RuntimeError('missing_config')
headers = dict(self.default_headers)
headers['x-api-key'] = config.api_key
url = f'{config.url}/graphql'
try:
self.clear_error()
with httpx.Client(timeout=_UNRAID_DEFAULT_TIMEOUT_SECONDS, verify=config.verify_tls) as client:
response = client.post(url, headers=headers, json={'query': query})
if response.status_code in _UNRAID_REDIRECT_STATUSES:
redirected = _trusted_https_redirect(response)
if redirected is None:
raise RuntimeError('Unraid API refused an untrusted redirect')
response = client.post(str(redirected), headers=headers, json={'query': query})
except (httpx.RequestError, ValueError) as exc:
message = redact_message(str(exc))
self.set_error(message)
raise RuntimeError(message)
if response.status_code in {401, 403}:
message = 'unauthorized'
self.set_error(message)
raise RuntimeError(message)
if response.status_code >= 500:
message = f'Unraid server error: HTTP {response.status_code}'
self.set_error(message)
raise RuntimeError(message)
if response.status_code >= 400:
payload = response.text or ''
message = f'Unraid API error: HTTP {response.status_code} {payload[:256]}'
redacted = redact_message(message)
self.set_error(redacted)
raise RuntimeError(redacted)
try:
payload = response.json()
except ValueError as exc:
message = redact_message(str(exc))
self.set_error(message)
raise RuntimeError(message)
if not isinstance(payload, dict):
message = 'Invalid GraphQL payload'
self.set_error(message)
raise RuntimeError(message)
if payload.get('errors'):
error_messages = [
str(err.get('message', 'unknown'))
for err in payload.get('errors') if isinstance(err, dict)
]
message = f'GraphQL error: {", ".join(error_messages[:2])}'
redacted = redact_message(message)
self.set_error(redacted)
raise RuntimeError(redacted)
data = payload.get('data')
if data is None:
message = 'No GraphQL data field'
self.set_error(message)
raise RuntimeError(message)
if not isinstance(data, dict):
message = 'Invalid GraphQL data'
self.set_error(message)
raise RuntimeError(message)
return data
def _query(self, queries: list[str]) -> tuple[dict[str, Any], str]:
last_error = ''
for query in queries:
try:
return self._request(query), 'ok'
except RuntimeError as exc:
last_error = str(exc)
if last_error == 'missing_config':
return {'error': 'missing_config'}, 'missing_config'
if 'VMs are not available' in last_error:
return {'error': last_error}, 'unavailable'
continue
return {'error': last_error or 'query_failed'}, 'error'
def test_connection(self):
data, status = self._query(['query { __typename }'])
if status == 'ok':
return {'status': 'connected'}
if status == 'missing_config':
return {'status': 'missing_config', 'reason': 'missing_config'}
reason = str(data.get('error', 'connection_failed'))
return {'status': 'error', 'reason': reason}
def get_server_info(self):
payload, status = self._query([
'query { info { os { platform distro release uptime } cpu { manufacturer brand cores threads } } }',
'query { serverInfo { hostname version uptime cpuPercent ramPercent } }',
'query { system { name version uptime cpuPercent ramPercent } }',
])
if status != 'ok':
return {'status': status, 'reason': str(payload.get('error', 'connection_failed'))}
uptime = _first_value(payload, ('info.os.uptime', 'serverInfo.uptime', 'system.uptime', 'info.uptime'))
hostname = _first_value(payload, ('serverInfo.hostname', 'system.hostname', 'info.hostname', 'serverInfo.name'))
version = _first_value(payload, ('info.os.release', 'serverInfo.version', 'system.version', 'info.version'))
cpu_brand = _first_value(payload, ('info.cpu.brand',))
cpu_cores = _first_value(payload, ('info.cpu.cores',))
cpu_threads = _first_value(payload, ('info.cpu.threads',))
return {
'status': 'ok',
'info': {
'hostname': str(hostname or 'unknown'),
'version': str(version or 'unknown'),
'uptime': str(uptime or 'unknown'),
'name': str(hostname or 'unraid'),
'cpu': {
'brand': cpu_brand,
'cores': _as_int(cpu_cores, 0) if cpu_cores is not None else None,
'threads': _as_int(cpu_threads, 0) if cpu_threads is not None else None,
},
},
}
def get_system_metrics(self):
payload, status = self._query([
(
'query { metrics { '
'cpu { cpus { percentIdle percentUser percentSystem percentNice percentIrq } } '
'memory { total used free available active } '
'} }'
),
])
if status != 'ok':
return {
'status': status,
'reason': str(payload.get('error', 'metrics_unavailable')),
'cpuUsage': None,
'ramUsage': None,
}
cpu_usage = _average_cpu_usage(_first_value(payload, ('metrics.cpu.cpus',)))
memory_total = _first_value(payload, ('metrics.memory.total',))
memory_available = _first_value(payload, ('metrics.memory.available',))
memory_used = _first_value(payload, ('metrics.memory.used',))
# Linux includes reclaimable cache (and often ZFS ARC) in ``used``.
# Prefer total - available so the dashboard reflects actual memory
# pressure rather than useful filesystem cache.
ram_uses_available = (
isinstance(memory_total, (int, float))
and isinstance(memory_available, (int, float))
and 0 <= memory_available <= memory_total
)
if ram_uses_available:
memory_used = memory_total - memory_available
ram_usage = _percent_from_values(memory_used, memory_total, precise=True)
missing = []
if cpu_usage is None:
missing.append('cpu_unavailable')
if ram_usage is None:
missing.append('ram_unavailable')
memory = _first_value(payload, ('metrics.memory',))
return {
'status': 'ok' if not missing else 'partial',
'reason': ','.join(missing) if missing else None,
'cpuUsage': cpu_usage,
'ramUsage': ram_usage,
'sources': {
'cpu': 'metrics.cpu.cpus.percentIdle' if cpu_usage is not None else 'unavailable',
'ram': 'metrics.memory.available_total' if ram_usage is not None and ram_uses_available else ('metrics.memory.used_total' if ram_usage is not None else 'unavailable'),
},
'memory': memory if isinstance(memory, dict) else {},
}
def get_array_status(self):
payload, status = self._query([
'query { array { state } }',
'query { array { status started } }',
'query { system { array { status started } } }',
'query { arrayStatus started }',
'query { system { arrayStatus started } }',
])
if status != 'ok':
return {'status': status, 'reason': str(payload.get('error', 'connection_failed'))}
raw_status = _first_value(payload, ('array.state', 'array.status', 'arrayStatus', 'system.array.status', 'system.arrayStatus', 'state', 'status'))
started = _first_value(payload, ('array.started', 'system.array.started', 'started'))
if isinstance(started, bool):
raw_status = 'started' if started else 'stopped'
elif isinstance(started, str):
if started.lower() in {'true', '1', 'yes', 'running', 'started', 'online'}:
raw_status = 'started'
elif started.lower() in {'false', '0', 'no', 'stopped', 'offline'}:
raw_status = 'stopped'
normalized = _normalize_status(raw_status, ('started', 'stopped', 'maintenance'), default='maintenance')
return {'status': 'ok', 'arrayStatus': normalized}
def get_disk_warnings(self):
payload, status = self._query([
'query { array { disks { name status warning critical fsSize fsUsed fsFree type } } }',
'query { disks { name smartStatus temperature isSpinning size } }',
'query { array { disks { name status healthy warning } } }',
'query { system { disks { name status healthy warning } } }',
'query { disks { name status healthy warning } }',
])
if status != 'ok':
return {'status': status, 'reason': str(payload.get('error', 'connection_failed')), 'disks': [], 'diskWarnings': 0}
disks_raw = _first_value(payload, ('array.disks', 'system.disks', 'disks')) or []
if not isinstance(disks_raw, list):
disks_raw = []
disks: list[dict[str, Any]] = []
for raw_disk in disks_raw:
if not isinstance(raw_disk, dict):
continue
status_value = str(raw_disk.get('status') or raw_disk.get('smartStatus') or 'unknown')
warning = raw_disk.get('warning') is not None or raw_disk.get('critical') is not None
healthy = _as_bool(raw_disk.get('healthy', None))
if healthy is None:
healthy = not warning and status_value.lower() in {'ok', 'disk_ok', 'unknown'}
used_gib = _storage_value_to_gib(raw_disk.get('fsUsed', raw_disk.get('used')))
size_gib = _storage_value_to_gib(raw_disk.get('fsSize', raw_disk.get('size')))
free_gib = _storage_value_to_gib(raw_disk.get('fsFree', raw_disk.get('free')))
if size_gib <= 0 and used_gib > 0 and free_gib > 0:
size_gib = used_gib + free_gib
disks.append({
'name': str(raw_disk.get('name', 'unknown')),
'status': status_value,
'healthy': healthy,
'type': raw_disk.get('type'),
'usedGiB': used_gib if used_gib > 0 else None,
'sizeGiB': size_gib if size_gib > 0 else None,
'freeGiB': free_gib if free_gib > 0 else None,
'temperature': raw_disk.get('temperature'),
})
return {'status': 'ok', 'disks': disks, 'diskWarnings': _safe_disk_warning_count(disks_raw)}
def get_cache_status(self):
payload, status = self._query([
'query { array { caches { name fsSize fsUsed fsFree size } } }',
'query { cache { usagePercent usedGiB sizeGiB } }',
'query { cache { used size } }',
'query { system { cache { usagePercent usedGiB sizeGiB used size } } }',
'query { cacheUsage }',
'query { cachePercent }',
])
if status != 'ok':
return {'status': status, 'reason': str(payload.get('error', 'connection_failed')), 'cacheUsage': 0, 'pools': []}
cache_percent = _first_value(payload, ('cache.usagePercent', 'system.cache.usagePercent', 'cacheUsage', 'cachePercent'))
pools: list[dict[str, Any]] = []
caches = _first_value(payload, ('array.caches',))
if isinstance(caches, list):
for cache in caches:
if not isinstance(cache, dict):
continue
used_gib = _storage_value_to_gib(cache.get('fsUsed', cache.get('used', cache.get('usedGiB'))))
size_gib = _storage_value_to_gib(cache.get('fsSize', cache.get('size', cache.get('sizeGiB'))))
free_gib = _storage_value_to_gib(cache.get('fsFree', cache.get('free', cache.get('freeGiB'))))
if size_gib <= 0 and used_gib > 0 and free_gib > 0:
size_gib = used_gib + free_gib
status_value = str(cache.get('status') or cache.get('smartStatus') or 'unknown')
warning = cache.get('warning') is not None or cache.get('critical') is not None
healthy = _as_bool(cache.get('healthy', None))
if healthy is None:
healthy = not warning and status_value.lower() in {'ok', 'disk_ok', 'healthy', 'unknown'}
usage_percent = _as_int(cache.get('usagePercent'), -1)
if usage_percent < 0:
usage_percent = _percent_from_values(used_gib, size_gib)
pools.append({
'name': str(cache.get('name', 'cache')),
'status': status_value,
'healthy': healthy,
'type': cache.get('type'),
'usedGiB': used_gib if used_gib > 0 else None,
'sizeGiB': size_gib if size_gib > 0 else None,
'freeGiB': free_gib if free_gib > 0 else None,
'usagePercent': usage_percent,
})
if cache_percent is None:
if isinstance(caches, list):
used_total = 0
size_total = 0
for cache in caches:
if not isinstance(cache, dict):
continue
used_total += _as_int(cache.get('fsUsed', cache.get('used')), 0)
size_total += _as_int(cache.get('fsSize', cache.get('size')), 0)
if size_total > 0:
cache_percent = int((used_total / size_total) * 100)
if cache_percent is None:
used = _first_value(payload, ('cache.used', 'cache.usedGiB', 'system.cache.used', 'system.cache.usedGiB'))
total = _first_value(payload, ('cache.size', 'cache.sizeGiB', 'system.cache.size', 'system.cache.sizeGiB'))
if isinstance(used, (int, float)) and isinstance(total, (int, float)) and total > 0:
cache_percent = int((float(used) / float(total)) * 100)
if isinstance(cache_percent, bool):
cache_percent = 100 if cache_percent else 0
usage = _as_int(cache_percent, 0)
return {'status': 'ok', 'cacheUsage': usage, 'pools': pools}
def get_parity_status(self):
payload, status = self._query([
(
'query { array { '
'parityCheckStatus { progress speed errors status paused running correcting } '
'parities { name status warning critical } '
'} }'
),
'query { array { parities { name status warning critical } } }',
])
if status != 'ok':
return {'status': status, 'reason': str(payload.get('error', 'connection_failed')), 'parityStatus': 'invalid'}
check = _first_value(payload, ('array.parityCheckStatus',))
check = check if isinstance(check, dict) else {}
check_status = str(check.get('status') or '').strip().lower()
paused = _as_bool(check.get('paused')) is True
running = (
not paused
and (
_as_bool(check.get('running')) is True
or check_status in {'running', 'checking', 'resyncing'}
)
)
if running or paused or check_status in {'running', 'checking', 'paused', 'resyncing'}:
parity_status = 'checking'
else:
parities = _first_value(payload, ('array.parities',))
parities = parities if isinstance(parities, list) else []
healthy_statuses = {'disk_ok', 'ok', 'valid', 'healthy', 'online'}
parity_status = 'valid' if parities and all(
isinstance(disk, dict)
and not disk.get('warning')
and not disk.get('critical')
and str(disk.get('status') or '').strip().lower() in healthy_statuses
for disk in parities
) else 'invalid'
return {
'status': 'ok',
'parityStatus': parity_status,
'checkStatus': check_status or 'idle',
'running': running,
'paused': paused,
'correcting': _as_bool(check.get('correcting')) is True,
'progress': check.get('progress'),
'speed': check.get('speed'),
'errors': _as_int(check.get('errors'), 0),
}
def get_docker_service_status(self):
payload, status = self._query([
'query { docker { containers { id names image state status } networks { id name } } }',
'query { docker { running status } }',
'query { dockerService { running status } }',
'query { system { docker { running status } } }',
'query { dockerStatus }',
])
if status != 'ok':
return {'status': status, 'reason': str(payload.get('error', 'connection_failed')), 'running': False}
docker_containers = _first_value(payload, ('docker.containers',))
raw_running = _first_value(payload, ('docker.running', 'dockerService.running', 'system.docker.running'))
if raw_running is None and isinstance(docker_containers, list):
raw_running = True
if isinstance(raw_running, str) and raw_running.lower() in {'running', 'started', 'online'}:
raw_running = True
running = bool(_as_bool(raw_running) or str(raw_running).lower() in {'running', 'started', 'online'})
return {
'status': 'ok',
'running': running,
'serviceStatus': str(_first_value(payload, ('docker.status', 'dockerService.status', 'system.docker.status', 'status')) or ('running' if running else 'stopped')),
'containers': len(docker_containers) if isinstance(docker_containers, list) else None,
}
def get_vm_summary(self):
payload, status = self._query([
'query { vms { domains { id uuid name state } } }',
'query { vms { id name state status } }',
'query { virtualMachines { id name state status } }',
'query { system { vms { id name state status } } }',
])
if status != 'ok':
reason = str(payload.get('error', 'connection_failed'))
if 'VMs are not available' in reason:
return {
'status': 'api_unavailable',
'reason': (
'De Unraid API gaf geen VM-toegang terug. VM Manager kan nog steeds actief zijn; '
'controleer of de ingestelde API-sleutel vm:read (of vm:*) heeft en of deze '
'Unraid API-versie het VM-schema beschikbaar stelt.'
),
'vms': [],
}
return {'status': status, 'reason': reason, 'vms': []}
raw_vms = _first_value(payload, ('vms.domains', 'vms', 'virtualMachines', 'system.vms'))
if not isinstance(raw_vms, list):
raw_vms = []
vms = []
for raw_vm in raw_vms:
if not isinstance(raw_vm, dict):
continue
vms.append({
'id': str(raw_vm.get('id', raw_vm.get('uuid', raw_vm.get('name', '')))),
'name': str(raw_vm.get('name', 'unknown')),
'state': str(raw_vm.get('state', raw_vm.get('status', 'unknown'))),
})
return {'status': 'ok', 'vms': vms}
def mutate_vm(self, vm_id: str, action: str) -> dict[str, Any]:
supported_actions = {'start', 'pause', 'resume'}
normalized_action = str(action).strip().lower()
normalized_vm_id = str(vm_id).strip()
if normalized_action not in supported_actions:
return {
'success': False,
'status': 'blocked',
'message': 'unsupported_or_blocked_vm_action',
'action': normalized_action,
}
if not normalized_vm_id:
return {
'success': False,
'status': 'invalid_target',
'message': 'vm_id_required',
'action': normalized_action,
}
if not self.is_configured():
return {
'success': False,
'status': 'missing_config',
'message': 'missing_config',
'action': normalized_action,
}
# json.dumps produces a valid escaped GraphQL string literal without
# interpolating an operator-controlled identifier as GraphQL syntax.
vm_id_literal = json.dumps(normalized_vm_id)
query = f'mutation AppOpsVmAction {{ vm {{ {normalized_action}(id: {vm_id_literal}) }} }}'
try:
payload = self._request(query)
except RuntimeError as exc:
reason = str(exc)
if reason == 'unauthorized':
reason = 'Unraid API key needs vm:update (or vm:*) permission.'
return {
'success': False,
'status': 'permission_denied' if 'unauthorized' in reason.lower() else 'error',
'message': reason,
'action': normalized_action,
}
changed = _first_value(payload, (f'vm.{normalized_action}',))
return {
'success': changed is True,
'status': 'ok' if changed is True else 'failed',
'message': f'vm_{normalized_action}_accepted' if changed is True else f'vm_{normalized_action}_rejected',
'action': normalized_action,
}
def get_share_summary(self):
payload, status = self._query([
'query { shares { name free used size } }',
'query { shares { name usedGiB sizeGiB critical } }',
'query { system { shares { name usedGiB sizeGiB critical } } }',
'query { shareSummary { shares { name usedGiB sizeGiB critical } } }',
])
if status != 'ok':
return {'status': status, 'reason': str(payload.get('error', 'connection_failed')), 'shares': []}
raw_shares = _first_value(payload, ('shares', 'system.shares', 'shareSummary.shares'))
if not isinstance(raw_shares, list):
raw_shares = []
shares = []
for share in raw_shares:
if not isinstance(share, dict):
continue
used = _as_int(share.get('usedGiB'), -1)
size = _as_int(share.get('sizeGiB'), -1)
if used < 0:
used = _storage_value_to_gib(share.get('used'))
if size < 0:
size = _storage_value_to_gib(share.get('size'))
if size <= 0 and share.get('free') is not None:
size = used + _storage_value_to_gib(share.get('free'))
shares.append({
'name': str(share.get('name', 'unknown')),
'usedGiB': used,
'sizeGiB': size,
'critical': bool(_as_bool(share.get('critical', False))),
})
return {'status': 'ok', 'shares': shares}