493 lines
19 KiB
Python
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}'
|