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}'