Publish curated Unraid-AppOps-Gateway source from 034c9f844d2455850ba37b31379b7edaa786583e
This commit is contained in:
1 parent
c61fbfd87f
commit
1a54836efc
101 files changed
+4973
-1298
No files matched your search
@@ -0,0 +1,193 @@
|
||||
"""Read-only GPU-Hot snapshot collection with an allowlisted public result."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import httpx
|
||||
|
||||
from ..core.config import settings
|
||||
|
||||
_MAX_RESPONSE_BYTES = 1_048_576
|
||||
_MAX_FUTURE_SECONDS = 5.0
|
||||
|
||||
|
||||
def _result(status: str, reason: str | None, *, devices=None, observed_at=None) -> dict[str, Any]:
|
||||
return {
|
||||
'status': status,
|
||||
'observedAt': observed_at,
|
||||
'devices': devices or [],
|
||||
'reason': reason,
|
||||
'source': 'gpu-hot',
|
||||
}
|
||||
|
||||
|
||||
def _number(value: Any, *, minimum: float = 0.0, maximum: float | None = None) -> float | None:
|
||||
if value is None or isinstance(value, bool):
|
||||
return None
|
||||
try:
|
||||
numeric = float(value)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return None
|
||||
if not math.isfinite(numeric) or numeric < minimum or (maximum is not None and numeric > maximum):
|
||||
return None
|
||||
return numeric
|
||||
|
||||
|
||||
def _source_time(value: Any) -> datetime | None:
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.replace('Z', '+00:00'))
|
||||
if parsed.tzinfo is None or parsed.utcoffset() is None:
|
||||
return None
|
||||
return parsed.astimezone(timezone.utc)
|
||||
except (ValueError, OverflowError):
|
||||
return None
|
||||
|
||||
|
||||
def _index(value: Any) -> str | None:
|
||||
if isinstance(value, bool) or not isinstance(value, (int, str)):
|
||||
return None
|
||||
text = str(value)
|
||||
if not re.fullmatch(r'[0-9]{1,6}', text):
|
||||
return None
|
||||
return str(int(text))
|
||||
|
||||
|
||||
def normalize_gpu_snapshot(payload, *, now=None, max_age_seconds=30.0):
|
||||
"""Keep measured values; never infer zero usage or a source timezone."""
|
||||
if not isinstance(payload, dict):
|
||||
return _result('error', 'gpu_metrics_invalid_response')
|
||||
raw_devices = payload.get('gpus')
|
||||
if isinstance(raw_devices, dict):
|
||||
entries = list(raw_devices.items())
|
||||
elif isinstance(raw_devices, list):
|
||||
entries = list(enumerate(raw_devices))
|
||||
else:
|
||||
return _result('error', 'gpu_metrics_invalid_response')
|
||||
if not entries:
|
||||
return _result('unavailable', 'gpu_metrics_no_devices')
|
||||
|
||||
current_time = now if isinstance(now, datetime) else datetime.now(timezone.utc)
|
||||
if current_time.tzinfo is None:
|
||||
current_time = current_time.replace(tzinfo=timezone.utc)
|
||||
current_time = current_time.astimezone(timezone.utc)
|
||||
age_limit = _number(max_age_seconds) or 30.0
|
||||
devices = []
|
||||
observed_times = []
|
||||
seen_ids = set()
|
||||
incomplete = False
|
||||
missing_time = False
|
||||
incomplete_values = False
|
||||
|
||||
for key, raw_device in entries:
|
||||
if not isinstance(raw_device, dict):
|
||||
incomplete = True
|
||||
continue
|
||||
index = _index(raw_device.get('index')) or _index(key)
|
||||
if index is None or index in seen_ids:
|
||||
incomplete = True
|
||||
continue
|
||||
observed_time = _source_time(raw_device.get('timestamp'))
|
||||
if observed_time is not None:
|
||||
age_seconds = (current_time - observed_time).total_seconds()
|
||||
if age_seconds > age_limit or age_seconds < -_MAX_FUTURE_SECONDS:
|
||||
incomplete = True
|
||||
continue
|
||||
observed_times.append(observed_time)
|
||||
else:
|
||||
missing_time = True
|
||||
|
||||
raw_name = raw_device.get('name')
|
||||
name = re.sub(r'[\x00-\x1f\x7f]', '', raw_name).strip()[:160] if isinstance(raw_name, str) else ''
|
||||
if not name:
|
||||
name = f'GPU {index}'
|
||||
incomplete_values = True
|
||||
utilization = _number(raw_device.get('utilization'), maximum=100.0)
|
||||
memory_used = _number(raw_device.get('memory_used'))
|
||||
memory_total = _number(raw_device.get('memory_total'))
|
||||
if memory_total == 0:
|
||||
memory_total = None
|
||||
if memory_used is not None and memory_total is not None and memory_used > memory_total:
|
||||
memory_used = None
|
||||
temperature = _number(raw_device.get('temperature'), maximum=150.0)
|
||||
values = (utilization, memory_used, memory_total, temperature)
|
||||
if any(value is None for value in values):
|
||||
incomplete_values = True
|
||||
devices.append({
|
||||
'id': f'gpu-{index}',
|
||||
'name': name,
|
||||
'utilizationPercent': utilization,
|
||||
'memoryUsedMiB': memory_used,
|
||||
'memoryTotalMiB': memory_total,
|
||||
'temperatureC': temperature,
|
||||
})
|
||||
seen_ids.add(index)
|
||||
|
||||
if not devices:
|
||||
return _result('unavailable', 'gpu_metrics_no_devices')
|
||||
observed_at = None
|
||||
if observed_times and not missing_time:
|
||||
observed_at = min(observed_times).isoformat().replace('+00:00', 'Z')
|
||||
partial = incomplete or incomplete_values or missing_time
|
||||
reason = None
|
||||
if partial:
|
||||
reason = 'gpu_metrics_timestamp_unavailable' if missing_time and not incomplete and not incomplete_values else 'gpu_metrics_partial'
|
||||
return _result('partial' if partial else 'ok', reason, devices=devices, observed_at=observed_at)
|
||||
|
||||
|
||||
class GpuMetricsConnector:
|
||||
def __init__(self, snapshot_url=None, verify_tls=True, timeout_seconds=3.0):
|
||||
self.snapshot_url = settings.gpu_metrics_url if snapshot_url is None else snapshot_url
|
||||
self.verify_tls = verify_tls
|
||||
duration = _number(timeout_seconds)
|
||||
self.timeout_seconds = min(max(duration if duration is not None else 3.0, 0.05), 3.0)
|
||||
|
||||
def get_metrics(self):
|
||||
if not self.snapshot_url:
|
||||
return _result('missing_config', 'gpu_metrics_missing_config')
|
||||
if not self._valid_url():
|
||||
return _result('error', 'gpu_metrics_invalid_url')
|
||||
|
||||
try:
|
||||
with (
|
||||
httpx.Client(verify=self.verify_tls, timeout=self.timeout_seconds, follow_redirects=False, trust_env=False) as client,
|
||||
client.stream('GET', self.snapshot_url, headers={'Accept': 'application/json'}) as response,
|
||||
):
|
||||
if response.status_code != 200:
|
||||
return _result('unavailable', 'gpu_metrics_http_error')
|
||||
body = bytearray()
|
||||
for chunk in response.iter_bytes(chunk_size=65_536):
|
||||
if len(body) + len(chunk) > _MAX_RESPONSE_BYTES:
|
||||
return _result('error', 'gpu_metrics_response_too_large')
|
||||
body.extend(chunk)
|
||||
payload = json.loads(body)
|
||||
return normalize_gpu_snapshot(payload)
|
||||
except httpx.HTTPError:
|
||||
return _result('unavailable', 'gpu_metrics_unreachable')
|
||||
except httpx.InvalidURL:
|
||||
return _result('error', 'gpu_metrics_invalid_url')
|
||||
except (ValueError, UnicodeError):
|
||||
return _result('error', 'gpu_metrics_invalid_response')
|
||||
|
||||
def _valid_url(self) -> bool:
|
||||
if not isinstance(self.snapshot_url, str) or re.search(r'[\s\x00-\x1f\x7f]', self.snapshot_url):
|
||||
return False
|
||||
try:
|
||||
parsed = urlsplit(self.snapshot_url)
|
||||
return (
|
||||
parsed.scheme in {'http', 'https'}
|
||||
and bool(parsed.hostname)
|
||||
and parsed.username is None
|
||||
and parsed.password is None
|
||||
and not parsed.fragment
|
||||
and (parsed.port is None or 0 < parsed.port <= 65535)
|
||||
)
|
||||
except ValueError:
|
||||
return False
|
||||
@@ -10,6 +10,7 @@ import httpx
|
||||
|
||||
from ..core.config import settings
|
||||
from ..core.redaction import redact_message
|
||||
from ..services.exposure_preferences import runtime_nginx_access_list_checks_enabled
|
||||
from .base import BaseConnector
|
||||
|
||||
|
||||
@@ -307,15 +308,16 @@ class NginxProxyManagerConnector(BaseConnector):
|
||||
}
|
||||
|
||||
def exposure_summary(self) -> dict[str, Any]:
|
||||
access_list_checks_enabled = runtime_nginx_access_list_checks_enabled()
|
||||
hosts = self.proxy_hosts()
|
||||
certs = self.certificates()
|
||||
access_lists = self.access_lists()
|
||||
access_lists = self.access_lists() if access_list_checks_enabled else {}
|
||||
if not hosts.get('success'):
|
||||
return {**hosts, 'data': {'warnings': []}}
|
||||
host_items = hosts.get('items', [])
|
||||
cert_items = certs.get('items', []) if certs.get('success') else []
|
||||
cert_by_id = {item.get('id'): item for item in cert_items}
|
||||
warnings = self._build_warnings(host_items, cert_by_id)
|
||||
warnings = self._build_warnings(host_items, cert_by_id, nginx_access_list_checks_enabled=access_list_checks_enabled)
|
||||
summary = {
|
||||
'totalProxyHosts': len(host_items),
|
||||
'enabledProxyHosts': sum(1 for item in host_items if item.get('enabled')),
|
||||
@@ -327,6 +329,7 @@ class NginxProxyManagerConnector(BaseConnector):
|
||||
'hostsWithoutSslForce': sum(1 for item in host_items if item.get('enabled') and not item.get('sslForced')),
|
||||
'hostsWithWebsocket': sum(1 for item in host_items if item.get('websocketSupport')),
|
||||
'accessLists': access_lists.get('total', 0),
|
||||
'nginxAccessListChecksEnabled': access_list_checks_enabled,
|
||||
'warnings': warnings,
|
||||
}
|
||||
return {'success': True, 'status': 'ok', 'message': 'exposure_summary_ok', **summary, 'data': summary}
|
||||
@@ -353,7 +356,7 @@ class NginxProxyManagerConnector(BaseConnector):
|
||||
if not hosts.get('success'):
|
||||
return {**hosts, 'items': [], 'total': 0, 'data': {'items': []}}
|
||||
cert_by_id = {item.get('id'): item for item in certs.get('items', [])} if certs.get('success') else {}
|
||||
items = self._build_warnings(hosts.get('items', []), cert_by_id)
|
||||
items = self._build_warnings(hosts.get('items', []), cert_by_id, nginx_access_list_checks_enabled=runtime_nginx_access_list_checks_enabled())
|
||||
severity_order = {'critical': 3, 'high': 2, 'warning': 1, 'info': 0}
|
||||
status = 'warning' if items else 'ok'
|
||||
return {
|
||||
@@ -609,7 +612,7 @@ class NginxProxyManagerConnector(BaseConnector):
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _build_warnings(hosts: list[dict[str, Any]], cert_by_id: dict[Any, dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
def _build_warnings(hosts: list[dict[str, Any]], cert_by_id: dict[Any, dict[str, Any]], *, nginx_access_list_checks_enabled: bool = True) -> list[dict[str, Any]]:
|
||||
warnings: list[dict[str, Any]] = []
|
||||
for host in hosts:
|
||||
if not host.get('enabled'):
|
||||
@@ -624,7 +627,7 @@ class NginxProxyManagerConnector(BaseConnector):
|
||||
warnings.append({'type': 'certificate_expired', 'severity': 'critical', 'title': f'{label} certificate is expired', 'evidence': [label, str(cert.get('expiresOn'))], 'suggestedAction': 'review_certificate_in_npm'})
|
||||
elif cert and cert.get('isExpiringSoon'):
|
||||
warnings.append({'type': 'certificate_expiring_soon', 'severity': 'warning', 'title': f'{label} certificate expires soon', 'evidence': [label, f"{cert.get('expiresInDays')} days", str(cert.get('expiresOn'))], 'suggestedAction': 'review_certificate_in_npm'})
|
||||
if not host.get('accessListId'):
|
||||
if nginx_access_list_checks_enabled and not host.get('accessListId'):
|
||||
warnings.append({'type': 'public_host_without_access_list', 'severity': 'high', 'title': f'{label} has no access list', 'evidence': [label, target], 'suggestedAction': 'review_access_protection'})
|
||||
if not host.get('sslForced'):
|
||||
warnings.append({'type': 'ssl_not_forced', 'severity': 'warning', 'title': f'{label} does not force SSL', 'evidence': [label], 'suggestedAction': 'review_ssl_policy'})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
@@ -11,7 +12,6 @@ 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}
|
||||
|
||||
@@ -70,7 +70,8 @@ def _as_float(value: Any) -> float | None:
|
||||
return None
|
||||
|
||||
if isinstance(value, int | float):
|
||||
return float(value)
|
||||
number = float(value)
|
||||
return number if math.isfinite(number) else None
|
||||
|
||||
if isinstance(value, str):
|
||||
cleaned = value.strip().strip('%')
|
||||
@@ -80,7 +81,8 @@ def _as_float(value: Any) -> float | None:
|
||||
if not numeric:
|
||||
return None
|
||||
try:
|
||||
return float(numeric)
|
||||
number = float(numeric)
|
||||
return number if math.isfinite(number) else None
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
@@ -91,15 +93,16 @@ def _clamp_percent(value: float) -> int:
|
||||
return int(round(max(0.0, min(100.0, value))))
|
||||
|
||||
|
||||
def _percent_from_values(used: Any, total: Any) -> int | None:
|
||||
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
|
||||
return _clamp_percent((used_value / total_value) * 100)
|
||||
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) -> int | None:
|
||||
def _average_cpu_usage(cpus: Any) -> float | None:
|
||||
if not isinstance(cpus, list) or not cpus:
|
||||
return None
|
||||
|
||||
@@ -127,9 +130,9 @@ def _average_cpu_usage(cpus: Any) -> int | None:
|
||||
active_values.append(active_total)
|
||||
|
||||
if idle_values:
|
||||
return _clamp_percent(100 - (sum(idle_values) / len(idle_values)))
|
||||
return round(max(0.0, min(100.0, 100 - (sum(idle_values) / len(idle_values)))), 2)
|
||||
if active_values:
|
||||
return _clamp_percent(sum(active_values) / len(active_values))
|
||||
return round(max(0.0, min(100.0, sum(active_values) / len(active_values))), 2)
|
||||
return None
|
||||
|
||||
|
||||
@@ -398,7 +401,7 @@ class UnraidConnector(BaseConnector):
|
||||
)
|
||||
if ram_uses_available:
|
||||
memory_used = memory_total - memory_available
|
||||
ram_usage = _percent_from_values(memory_used, memory_total)
|
||||
ram_usage = _percent_from_values(memory_used, memory_total, precise=True)
|
||||
missing = []
|
||||
if cpu_usage is None:
|
||||
missing.append('cpu_unavailable')
|
||||
|
||||
Reference in new issue
Block a user