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

493 lines
19 KiB
Python

from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
from typing import Any
from urllib.parse import quote, urlsplit, urlunsplit
import httpx
from ..core.config import settings
from ..core.redaction import redact_message
from .base import BaseConnector
_PORTAINER_DEFAULT_TIMEOUT_SECONDS = 8.0
@dataclass
class _ConnectorConfig:
url: str
api_key: str
endpoint_id: str
verify_tls: bool
class PortainerConnector(BaseConnector):
name = 'Portainer'
default_headers = {
'Content-Type': 'application/json',
}
def __init__(self):
super().__init__()
def is_configured(self):
return bool(settings.apis.get('PORTAINER_URL') and settings.apis.get('PORTAINER_API_KEY') and settings.apis.get('PORTAINER_ENDPOINT_ID'))
def _config(self) -> _ConnectorConfig | None:
if not self.is_configured():
return None
return _ConnectorConfig(
url=str(settings.apis['PORTAINER_URL']).strip().rstrip('/'),
api_key=str(settings.apis['PORTAINER_API_KEY']).strip(),
endpoint_id=str(settings.apis['PORTAINER_ENDPOINT_ID']).strip(),
verify_tls=bool(settings.apis.get('PORTAINER_VERIFY_TLS', settings.verify_tls)),
)
def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
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}{path}'
try:
self.clear_error()
with httpx.Client(timeout=_PORTAINER_DEFAULT_TIMEOUT_SECONDS, verify=config.verify_tls) as client:
response = client.request(method=method, url=url, headers=headers, **kwargs)
except (httpx.RequestError, ValueError) as exc:
message = str(exc)
redacted = redact_message(message)
self.set_error(redacted)
raise RuntimeError(redacted)
if response.status_code == 401:
message = 'unauthorized'
self.set_error(message)
raise RuntimeError(message)
if response.status_code >= 500:
message = f'Portainer server error: HTTP {response.status_code}'
self.set_error(message)
raise RuntimeError(message)
if response.status_code >= 400:
response_text = response.text or ''
message = f'Portainer API error: HTTP {response.status_code} {response_text[:256]}'
redacted = redact_message(message)
self.set_error(redacted)
raise RuntimeError(redacted)
return response
def test_connection(self):
if not self.is_configured():
return {'status': 'missing_config', 'reason': 'missing_config'}
try:
self._request('GET', '/api/status')
self._request('GET', f'/api/endpoints/{self._config().endpoint_id}/docker/version')
except RuntimeError as exc:
reason = str(exc)
if reason == 'missing_config':
return {'status': 'missing_config', 'reason': reason}
if 'endpoints/' in reason or 'HTTP 404' in reason or 'HTTP 403' in reason:
return {'status': 'endpoint_error', 'reason': reason}
return {'status': 'error', 'reason': reason}
return {'status': 'connected', 'endpointReachable': True}
def list_containers(self, include_stats: bool = False, stats_limit: int = 200):
config = self._config()
if not config:
return {'status': 'missing_config', 'reason': 'missing_config'}
try:
path = f'/api/endpoints/{config.endpoint_id}/docker/containers/json?all=1'
response = self._request('GET', path)
payload = response.json()
except (RuntimeError, ValueError) as exc:
return {'status': 'error', 'reason': str(exc), 'containers': []}
if not isinstance(payload, list):
return {'status': 'error', 'reason': 'unexpected_payload', 'containers': []}
containers = [self._map_container(container, config) for container in payload]
if include_stats:
self._attach_container_stats(containers, max(1, min(int(stats_limit or 200), 500)))
return {'status': 'ok', 'containers': containers}
def _attach_container_stats(self, containers: list[dict[str, Any]], stats_limit: int) -> None:
candidates = [
container
for container in containers
if container.get('state') == 'running' and container.get('id') and container.get('id') != 'unknown'
]
selected = candidates[:stats_limit]
selected_ids = {str(container.get('id')) for container in selected}
for container in containers:
if container.get('state') != 'running':
container['metricsStatus'] = 'not_running'
elif str(container.get('id')) not in selected_ids:
container['metricsStatus'] = 'not_requested'
if not selected:
return
max_workers = min(12, len(selected))
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = {
executor.submit(self.get_container_stats, str(container.get('id'))): container
for container in selected
}
for future in as_completed(futures):
container = futures[future]
try:
stats = future.result()
except Exception as exc: # pragma: no cover - get_container_stats normalizes expected failures.
stats = {'metricsStatus': 'error', 'metricsError': redact_message(str(exc))}
container.update(stats)
def get_container_stats(self, container_id: str) -> dict[str, Any]:
if not self.is_configured():
return {'metricsStatus': 'missing_config', 'metricsError': 'missing_config'}
try:
safe_container_id = quote(str(container_id).strip(), safe='')
response = self._request(
'GET',
f'/api/endpoints/{self._config().endpoint_id}/docker/containers/{safe_container_id}/stats',
params={'stream': 'false'},
)
payload = response.json()
except (RuntimeError, ValueError) as exc:
return {
'metricsStatus': 'error',
'metricsError': redact_message(str(exc)),
}
metrics = _map_container_stats(payload)
metrics['metricsSource'] = 'portainer.docker.stats'
return metrics
def get_container_status(self, container_name: str):
containers = self.list_containers()
if containers.get('status') != 'ok':
return containers
entry = next((c for c in containers.get('containers', []) if c['name'] == container_name), None)
if entry:
return {'status': 'ok', 'container': entry}
return {'status': 'not_found', 'reason': f'Container {container_name} not found'}
def restart_container(self, target: str):
if not self.is_configured():
return {'status': 'missing_config', 'reason': 'missing_config', 'container': target}
containers = self.list_containers()
if containers.get('status') != 'ok':
return containers
target_normalized = str(target).strip().lower()
selected = None
for candidate in containers.get('containers', []):
name = str(candidate.get('name', '')).lower()
container_id = str(candidate.get('id', '')).lower()
app_id = str(candidate.get('appId', '')).lower()
if (
name == target_normalized
or container_id == target_normalized
or app_id == target_normalized
or (target_normalized and (target_normalized in name or target_normalized in container_id))
):
selected = candidate
break
if not selected:
return {'status': 'not_found', 'reason': f'Container {target} not found'}
container_name = selected.get('id') or selected.get('name')
if not container_name:
return {'status': 'error', 'reason': 'Unable to resolve container identifier'}
endpoint_id = self._config().endpoint_id
if not endpoint_id:
return {'status': 'error', 'reason': 'invalid_connector_config'}
try:
self._request(
'POST',
f'/api/endpoints/{endpoint_id}/docker/containers/{container_name}/restart',
timeout=30.0,
)
except (RuntimeError, ValueError) as exc:
return {'status': 'error', 'reason': str(exc)}
return {
'status': 'ok',
'container': str(container_name),
'target': target,
'action': 'container.restart',
}
def get_container_logs(self, container_name: str, tail: int = 100):
if not self.is_configured():
return {'status': 'missing_config', 'reason': 'missing_config', 'container': container_name, 'logs': []}
try:
response = self._request(
'GET',
f'/api/endpoints/{self._config().endpoint_id}/docker/containers/{container_name}/logs',
params={'stdout': 1, 'stderr': 1, 'tail': max(1, int(tail))},
)
except (RuntimeError, ValueError) as exc:
return {'status': 'error', 'reason': str(exc), 'container': container_name, 'logs': []}
output = (response.text or '').strip()
logs = [line for line in output.splitlines() if line.strip()]
return {'status': 'ok', 'container': container_name, 'logs': logs}
def list_stacks(self):
if not self.is_configured():
return {'status': 'missing_config', 'reason': 'missing_config', 'stacks': []}
try:
payload = self._request('GET', '/api/stacks').json()
except (RuntimeError, ValueError) as exc:
return {'status': 'error', 'reason': str(exc), 'stacks': []}
if isinstance(payload, dict) and isinstance(payload.get('stacks'), list):
payload = payload.get('stacks', [])
if not isinstance(payload, list):
return {'status': 'error', 'reason': 'unexpected_payload', 'stacks': []}
return {'status': 'ok', 'stacks': [self._map_stack(stack) for stack in payload]}
def get_stack_status(self, stack_id: str):
stacks = self.list_stacks()
if stacks.get('status') != 'ok':
return stacks
entry = next((stack for stack in stacks.get('stacks', []) if stack['id'] == stack_id), None)
if entry:
return {'status': 'ok', 'stack': entry}
return {'status': 'not_found', 'reason': f'Stack {stack_id} not found'}
@staticmethod
def _map_container(raw: dict[str, Any], config: _ConnectorConfig | None = None) -> dict[str, Any]:
names = raw.get('Names') or []
raw_name = names[0] if names else raw.get('Name', '')
name = str(raw_name).strip('/').strip()
image = str(raw.get('Image', '')).strip()
state = str(raw.get('State', 'unknown')).lower()
state_map = {
'running': 'running',
'exited': 'exited',
'restarting': 'restarting',
'paused': 'paused',
}
mapped_state = state_map.get(state, 'exited')
health_raw = raw.get('State')
health = 'healthy'
if health_raw in {'exited', 'stopped', 'dead'}:
health = 'unhealthy'
ports_raw = raw.get('Ports', []) if isinstance(raw.get('Ports'), list) else []
ports = []
for port in ports_raw:
if not isinstance(port, dict):
continue
public_port = port.get('PublicPort')
private_port = port.get('PrivatePort')
if public_port is None or private_port is None:
continue
ports.append(f"{public_port}:{private_port}")
if not ports:
ports = []
container_id = str(raw.get('Id', name or 'unknown'))
mounts = []
for mount in raw.get('Mounts', []) if isinstance(raw.get('Mounts'), list) else []:
if not isinstance(mount, dict):
continue
source = str(mount.get('Source') or mount.get('Name') or '').strip()
destination = str(mount.get('Destination') or '').strip()
if not source and not destination:
continue
mounts.append({
'source': source,
'destination': destination,
'type': str(mount.get('Type') or '').strip(),
})
return {
'id': container_id,
'name': name or str(raw.get('Id', 'unknown')),
'appId': _infer_app_id(name),
'state': mapped_state,
'health': health,
'image': image or 'unknown',
'ports': ports,
'mounts': mounts,
'cpu': 'n/a',
'ram': 'n/a',
'metricsStatus': 'not_requested',
'restartCount': int(raw.get('RestartCount', 0) or 0),
'criticality': 'medium',
'voiceEnabled': name in {'widefrog', 'sonarr', 'jellyfin'},
'protected': _is_protected_app(name),
'portainerUrl': _container_deep_link(config, container_id),
}
@staticmethod
def _map_stack(raw: dict[str, Any]) -> dict[str, Any]:
stack_id = raw.get('Id') or raw.get('id') or raw.get('name')
status = str(raw.get('Status', 'unknown')).lower()
status_map = {
'stopped': 'critical',
'active': 'healthy',
'running': 'healthy',
'error': 'error',
'restarting': 'warning',
}
return {
'id': str(stack_id),
'name': str(raw.get('Name', stack_id or 'stack')),
'apps': [raw.get('EntryPoint')] if raw.get('EntryPoint') else [],
'containers': int(raw.get('ContainersCount', 1) or 0),
'status': status_map.get(status, 'healthy'),
'lastRedeploy': str(raw.get('UpdatedAt') or raw.get('created', 'unknown')),
'updatePolicy': str(raw.get('UpdateStrategy', 'Manual')),
'risk': 'medium',
}
def _infer_app_id(name: str) -> str:
normalized = str(name).lower()
if not normalized:
return ''
known = {
'widefrog': 'widefrog',
'sonarr': 'sonarr',
'radarr': 'radarr',
'jellyfin': 'jellyfin',
'authentik-server': 'authentik',
'authentik': 'authentik',
'postgres': 'postgres',
'redis': 'redis',
'portainer': 'portainer',
'home-assistant': 'home_assistant',
}
return known.get(normalized.split('/')[-1], normalized.split('/')[0].strip() if normalized else '')
def _is_protected_app(name: str) -> bool:
return _infer_app_id(name) in {'authentik', 'portainer', 'postgres', 'redis', 'home_assistant'}
def _nested_number(payload: dict[str, Any], path: tuple[str, ...]) -> float | None:
current: Any = payload
for key in path:
if not isinstance(current, dict) or key not in current:
return None
current = current[key]
try:
return float(current)
except (TypeError, ValueError):
return None
def _format_percent(value: float | None) -> str:
if value is None:
return 'n/a'
return f'{max(value, 0.0):.1f}%'
def _format_bytes(value: float | None) -> str:
if value is None or value < 0:
return 'n/a'
units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']
amount = float(value)
unit = units[0]
for unit in units:
if amount < 1024 or unit == units[-1]:
break
amount /= 1024
if unit == 'B' or amount >= 10 or amount.is_integer():
return f'{amount:.0f} {unit}'
return f'{amount:.1f} {unit}'
def _map_container_stats(payload: dict[str, Any]) -> dict[str, Any]:
if not isinstance(payload, dict):
return {
'cpu': 'n/a',
'ram': 'n/a',
'metricsStatus': 'partial',
}
cpu_total = _nested_number(payload, ('cpu_stats', 'cpu_usage', 'total_usage'))
cpu_previous = _nested_number(payload, ('precpu_stats', 'cpu_usage', 'total_usage'))
system_total = _nested_number(payload, ('cpu_stats', 'system_cpu_usage'))
system_previous = _nested_number(payload, ('precpu_stats', 'system_cpu_usage'))
online_cpus = _nested_number(payload, ('cpu_stats', 'online_cpus'))
if not online_cpus:
cpu_stats = payload.get('cpu_stats') if isinstance(payload.get('cpu_stats'), dict) else {}
cpu_usage = cpu_stats.get('cpu_usage') if isinstance(cpu_stats.get('cpu_usage'), dict) else {}
per_cpu = cpu_usage.get('percpu_usage')
online_cpus = float(len(per_cpu)) if isinstance(per_cpu, list) and per_cpu else 1.0
cpu_percent: float | None = None
if cpu_total is not None and cpu_previous is not None and system_total is not None and system_previous is not None:
cpu_delta = cpu_total - cpu_previous
system_delta = system_total - system_previous
if cpu_delta >= 0 and system_delta > 0:
cpu_percent = (cpu_delta / system_delta) * float(online_cpus or 1.0) * 100.0
memory_usage = _nested_number(payload, ('memory_stats', 'usage'))
memory_limit = _nested_number(payload, ('memory_stats', 'limit'))
ram = _format_bytes(memory_usage)
if memory_usage is not None and memory_limit:
ram = f'{_format_bytes(memory_usage)} / {_format_bytes(memory_limit)}'
return {
'cpu': _format_percent(cpu_percent),
'ram': ram,
'metricsStatus': 'measured' if cpu_percent is not None and memory_usage is not None else 'partial',
}
def _safe_portainer_base_url(raw_url: str) -> str | None:
parsed = urlsplit(str(raw_url or '').strip())
if parsed.scheme not in {'http', 'https'} or not parsed.netloc:
return None
path = parsed.path.rstrip('/')
return urlunsplit((parsed.scheme, parsed.netloc, path, '', '')).rstrip('/')
def _container_deep_link(config: _ConnectorConfig | None, container_id: str) -> str | None:
if not config or not config.endpoint_id or not container_id or container_id == 'unknown':
return None
base_url = _safe_portainer_base_url(config.url)
if not base_url:
return None
endpoint_id = quote(str(config.endpoint_id).strip(), safe='')
safe_container_id = quote(str(container_id).strip(), safe='')
return f'{base_url}/#!/{endpoint_id}/docker/containers/{safe_container_id}'