"""Метрики CPU, памяти и I/O узлов kind через ``docker stats`` / ``podman stats``. Имена контейнеров совпадают с префиксом кластера (см. ``list_kind_cluster_container_names``). Автор: Сергей Антропов Сайт: https://devops.org.ru """ from __future__ import annotations import logging import os import re import shutil import subprocess from core.cluster_lifecycle import _sort_kind_node_containers, list_registered_kind_clusters from models.schemas import AggregateResourcesSummary, KindClusterResources, KindNodeResourceStat logger = logging.getLogger("kind_k8s.container_resource_stats") # Одна строка на контейнер (совместимо с Docker и Podman). _STAT_FORMAT = "{{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}\t{{.NetIO}}\t{{.BlockIO}}\t{{.PIDs}}" def _container_cli_bin() -> str: return (os.environ.get("CONTAINER_CLI") or "docker").strip() or "docker" def _list_running_container_names(cli: str) -> set[str]: """Имена **работающих** контейнеров (``ps`` без ``-a``).""" if not shutil.which(cli): return set() p = subprocess.run( [cli, "ps", "--format", "{{.Names}}"], capture_output=True, text=True, timeout=30, ) if p.returncode != 0: logger.debug("%s ps: код %s", cli, p.returncode) return set() return {n.strip() for n in (p.stdout or "").splitlines() if n.strip()} def running_kind_clusters_mask(cluster_names: list[str]) -> dict[str, bool]: """ Один вызов `` ps``: для каждого имени кластера — есть ли запущенный контейнер-узел с префиксом ``{имя}-`` (как в ``collect_kind_clusters_resource_stats``). """ out: dict[str, bool] = {n: False for n in cluster_names} if not cluster_names: return out cli = _container_cli_bin() if not shutil.which(cli): logger.debug("running_kind_clusters_mask: CLI «%s» не в PATH", cli) return out running = _list_running_container_names(cli) for name in cluster_names: pfx = f"{name}-" out[name] = any(c.startswith(pfx) for c in running) return out def _all_container_names_ps_a(cli: str) -> tuple[list[str], str | None]: """Все имена контейнеров (``ps -a``). При ошибке — ([], сообщение).""" p = subprocess.run( [cli, "ps", "-a", "--format", "{{.Names}}"], capture_output=True, text=True, timeout=30, ) if p.returncode != 0: err = (p.stderr or p.stdout or "").strip() or str(p.returncode) return [], f"{cli} ps -a: {err}" lines = [n.strip() for n in (p.stdout or "").splitlines() if n.strip()] return lines, None def _parse_pids(raw: str) -> int | None: s = raw.strip() if not s: return None try: return int(re.sub(r"\D", "", s) or "0") except ValueError: return None # Множители для суффиксов Docker/Podman (kB/MB — десятичные, KiB/MiB — двоичные). _SUFFIX_TO_BYTES: dict[str, float] = { "b": 1.0, "kb": 1e3, "kib": 1024.0, "mb": 1e6, "mib": 1024.0**2, "gb": 1e9, "gib": 1024.0**3, "tb": 1e12, "tib": 1024.0**4, "pb": 1e15, "pib": 1024.0**5, } def _parse_docker_size_bytes(chunk: str) -> float | None: """Разбор одного значения вроде ``512MiB``, ``7.7GiB``, ``800kB`` в байты.""" t = chunk.strip().lower().replace(" ", "") if not t: return None m = re.match(r"^([\d.]+)([a-z]+)$", t) if not m: return None val = float(m.group(1)) suf = m.group(2) mult = _SUFFIX_TO_BYTES.get(suf) if mult is None: return None return val * mult def _parse_percent_value(raw: str | None) -> float | None: """Число из строки вида ``12.34%``.""" if not raw: return None m = re.match(r"^\s*([\d.]+)\s*%", raw.strip()) if not m: return None return float(m.group(1)) def _memory_used_ratio_percent(memory_usage: str | None) -> float | None: """Процент занятой памяти: левая часть MemUsage / правая (лимит контейнера).""" if not memory_usage or "/" not in memory_usage: return None left, right = memory_usage.split("/", 1) used = _parse_docker_size_bytes(left.strip()) limit = _parse_docker_size_bytes(right.strip()) if used is None or limit is None or limit <= 0: return None return min(100.0, 100.0 * used / limit) def _parse_io_pair_sum_bytes(raw: str | None) -> float | None: """Сумма байт по обеим частям NetIO/BlockIO до и после ``/`` (чтение + запись).""" if not raw or "/" not in raw: return None a, b = raw.split("/", 1) ba = _parse_docker_size_bytes(a.strip()) bb = _parse_docker_size_bytes(b.strip()) if ba is None and bb is None: return None return (ba or 0.0) + (bb or 0.0) def _fmt_bytes_ru(n: float) -> str: """Краткая подпись объёма для UI.""" if n <= 0: return "0 B" if n < 1024: return f"{n:.0f} B" for thresh, suffix in ( (1024.0**4, "TiB"), (1024.0**3, "GiB"), (1024.0**2, "MiB"), (1024.0, "KiB"), ): if n >= thresh: v = n / thresh s = f"{v:.2f}".rstrip("0").rstrip(".") return f"{s} {suffix}" return f"{n:.0f} B" def _io_ring_percent(total_bytes: float, ref_bytes: float) -> float: """Нормализация суммарного I/O в 0–100 для кольца (ref ≈ «полное кольцо»).""" if total_bytes <= 0 or ref_bytes <= 0: return 0.0 return min(100.0, 100.0 * total_bytes / ref_bytes) def aggregate_kind_cluster_resources(blocks: list[KindClusterResources]) -> AggregateResourcesSummary: """ Средние и суммы по всем узлам из ``cluster_resources`` для донатов на главной. Кольца сети/диска — условная шкала относительно порога (8 GiB суммарного I/O ≈ 100%). """ nodes: list[KindNodeResourceStat] = [] for b in blocks: nodes.extend(b.nodes) n = len(nodes) if n == 0: logger.debug("aggregate_kind_cluster_resources: узлов нет") return AggregateResourcesSummary() cpus = [_parse_percent_value(x.cpu_percent) for x in nodes] mem_pcts = [_parse_percent_value(x.memory_percent) for x in nodes] mem_ratios = [_memory_used_ratio_percent(x.memory_usage) for x in nodes] nets = [_parse_io_pair_sum_bytes(x.net_io) for x in nodes] blks = [_parse_io_pair_sum_bytes(x.block_io) for x in nodes] def avg(vals: list[float | None]) -> float | None: xs = [v for v in vals if v is not None] if not xs: return None return sum(xs) / len(xs) av_cpu = avg(cpus) av_mem_pct = avg(mem_pcts) av_mem_ratio = avg(mem_ratios) sum_net = sum(v for v in nets if v is not None) sum_blk = sum(v for v in blks if v is not None) ref_io = 8.0 * 1024.0**3 cpu_ring = min(100.0, av_cpu) if av_cpu is not None else 0.0 mem_ring = min(100.0, av_mem_pct) if av_mem_pct is not None else 0.0 ratio_ring = min(100.0, av_mem_ratio) if av_mem_ratio is not None else 0.0 net_ring = _io_ring_percent(sum_net, ref_io) disk_ring = _io_ring_percent(sum_blk, ref_io) cpu_l = f"ср. {av_cpu:.1f}%" if av_cpu is not None else "—" mem_l = f"ср. {av_mem_pct:.1f}%" if av_mem_pct is not None else "—" ratio_l = f"ср. {av_mem_ratio:.1f}%" if av_mem_ratio is not None else "—" net_l = f"Σ {_fmt_bytes_ru(sum_net)}" if sum_net > 0 else "—" disk_l = f"Σ {_fmt_bytes_ru(sum_blk)}" if sum_blk > 0 else "—" logger.debug( "aggregate_kind_cluster_resources: узлов=%s cpu_ring=%.1f net_ring=%.1f", n, cpu_ring, net_ring, ) return AggregateResourcesSummary( nodes_count=n, cpu_ring=cpu_ring, cpu_label=cpu_l, memory_percent_ring=mem_ring, memory_percent_label=mem_l, memory_used_ratio_ring=ratio_ring, memory_used_ratio_label=ratio_l, network_ring=net_ring, network_label=net_l, disk_ring=disk_ring, disk_label=disk_l, ) def _stats_for_container_names(cli: str, names: list[str]) -> list[KindNodeResourceStat]: """Вызов `` stats --no-stream`` для списка имён (только запущенные).""" if not names: return [] cmd = [cli, "stats", "--no-stream", "--format", _STAT_FORMAT, *names] p = subprocess.run(cmd, capture_output=True, text=True, timeout=60) if p.returncode != 0: err = (p.stderr or p.stdout or "").strip() or str(p.returncode) logger.warning("%s stats: %s", cli, err[:500]) return [] out: list[KindNodeResourceStat] = [] for line in (p.stdout or "").splitlines(): line = line.strip() if not line: continue parts = line.split("\t") while len(parts) < 7: parts.append("") name, cpu, mem_use, mem_pct, net_io, blk_io, pids_raw = parts[:7] out.append( KindNodeResourceStat( container_name=name.strip(), cpu_percent=cpu.strip() or None, memory_usage=mem_use.strip() or None, memory_percent=mem_pct.strip() or None, net_io=net_io.strip() or None, block_io=blk_io.strip() or None, pids=_parse_pids(pids_raw), ) ) return out def collect_kind_clusters_resource_stats() -> tuple[list[KindClusterResources], str | None]: """ Для каждого кластера из ``kind get clusters`` — метрики **запущенных** узлов. Один вызов ``ps -a`` на все имена; префикс узлов совпадает с ``list_kind_cluster_container_names``. Возвращает (список блоков, сообщение об ошибке уровня движка или ``None``). """ cli = _container_cli_bin() if not shutil.which(cli): return [], f"CLI «{cli}» не найден в PATH — метрики узлов недоступны" all_lines, ps_err = _all_container_names_ps_a(cli) if ps_err: return [], ps_err running = _list_running_container_names(cli) if not running: logger.debug("Нет запущенных контейнеров по %s ps", cli) kind_names = list_registered_kind_clusters() blocks: list[KindClusterResources] = [] for cluster_name in kind_names: prefix = f"{cluster_name}-" node_names = _sort_kind_node_containers([n for n in all_lines if n.startswith(prefix)]) active = [n for n in node_names if n in running] if not active: blocks.append( KindClusterResources( cluster_name=cluster_name, nodes=[], note="узлы остановлены или контейнеры отсутствуют", ) ) continue stats = _stats_for_container_names(cli, active) # Сохраняем порядок control-plane первым, как в kind order = {n: i for i, n in enumerate(node_names)} stats.sort(key=lambda s: order.get(s.container_name, 99)) blocks.append(KindClusterResources(cluster_name=cluster_name, nodes=stats, note=None)) return blocks, None