#!/usr/bin/env python3 """Генерация HTML-отчёта по Ansible-фактам хостов для GitLab Pages. Использование: python3 scripts/generate_report.py Ожидает JSON-файлы в host_facts/.json. Результат записывается в public/ (GitLab Pages). """ import ipaddress import json import os import re import sys from datetime import datetime, timezone from pathlib import Path import yaml FACTS_DIR = Path("host_facts") VMWARE_FACTS_DIR = Path("vmware_facts") OUTPUT_DIR = Path("public") SERVICE_MAP_FILE = Path("service_map.yml") SERVICES_DIR = Path("services") HISTORY_FILE = Path("connection_history.json") # Локальные сети — внешние подключения из этих диапазонов группируются по подсети _LOCAL_NETWORKS: list = [ ipaddress.ip_network("10.0.0.0/8"), ipaddress.ip_network("172.16.0.0/16"), ipaddress.ip_network("192.168.0.0/16"), ipaddress.ip_network("100.64.0.0/10"), ipaddress.ip_network("91.218.84.0/22"), ipaddress.ip_network("91.236.140.0/22"), ] BOOTSTRAP_CSS = "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" BOOTSTRAP_JS = "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" GENERATED_AT = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") # ── Загрузка данных ──────────────────────────────────────────────────────────── def load_vmware_facts() -> dict: """Загружает VMware факты из директории vmware_facts/.""" data: dict = {"esxi_hosts": {}, "vms": [], "datastores": []} if not VMWARE_FACTS_DIR.exists(): return data for f in sorted(VMWARE_FACTS_DIR.glob("esxi_*.json")): try: hostname = f.stem[5:] # убираем префикс "esxi_" data["esxi_hosts"][hostname] = json.loads(f.read_text(encoding="utf-8")) except Exception as exc: print(f"Предупреждение: не удалось прочитать {f}: {exc}", file=sys.stderr) vms_file = VMWARE_FACTS_DIR / "vms.json" if vms_file.exists(): try: data["vms"] = json.loads(vms_file.read_text(encoding="utf-8")) except Exception as exc: print(f"Предупреждение: не удалось прочитать {vms_file}: {exc}", file=sys.stderr) ds_file = VMWARE_FACTS_DIR / "datastores.json" if ds_file.exists(): try: data["datastores"] = json.loads(ds_file.read_text(encoding="utf-8")) except Exception as exc: print(f"Предупреждение: не удалось прочитать {ds_file}: {exc}", file=sys.stderr) return data def load_facts(facts_dir: Path) -> dict: hosts = {} for f in sorted(facts_dir.glob("*.json")): try: hosts[f.stem] = json.loads(f.read_text(encoding="utf-8")) except Exception as exc: print(f"Предупреждение: не удалось прочитать {f}: {exc}", file=sys.stderr) return hosts def load_service_map() -> list[dict]: """Загружает статическую карту зависимостей из service_map.yml.""" if not SERVICE_MAP_FILE.exists(): return [] try: data = yaml.safe_load(SERVICE_MAP_FILE.read_text(encoding="utf-8")) or {} result = [] for item in data.get("dependencies", []) or []: frm = item.get("from", {}) to = item.get("to", {}) if not frm.get("host") or not to.get("host"): continue result.append({ "from_host": frm.get("host", ""), "from_service": frm.get("service", ""), "to_host": to.get("host", ""), "to_service": to.get("service", ""), "port": item.get("port", ""), "protocol": item.get("protocol", "tcp"), "description": item.get("description", ""), "source": "static", }) return result except Exception as exc: print(f"Предупреждение: не удалось прочитать {SERVICE_MAP_FILE}: {exc}", file=sys.stderr) return [] def load_global_services() -> list[dict]: """Загружает описания глобальных сервисов из директории services/.""" if not SERVICES_DIR.exists(): return [] result = [] for f in sorted(SERVICES_DIR.glob("*.yml")) : try: data = yaml.safe_load(f.read_text(encoding="utf-8")) or {} if not data.get("name") or not data.get("components"): continue result.append({ "slug": f.stem, "name": data["name"], "description": data.get("description", ""), "components": data["components"], "dep_refs": data.get("dep_refs"), }) except Exception as exc: print(f"Предупреждение: не удалось прочитать {f}: {exc}", file=sys.stderr) for f in sorted(SERVICES_DIR.glob("*.yaml")): try: data = yaml.safe_load(f.read_text(encoding="utf-8")) or {} if not data.get("name") or not data.get("components"): continue result.append({ "slug": f.stem, "name": data["name"], "description": data.get("description", ""), "components": data["components"], "dep_refs": data.get("dep_refs"), }) except Exception as exc: print(f"Предупреждение: не удалось прочитать {f}: {exc}", file=sys.stderr) return result # ── Вспомогательные функции ──────────────────────────────────────────────────── def fmt_bytes(b: int) -> str: for unit in ("Б", "КБ", "МБ", "ГБ", "ТБ"): if b < 1024: return f"{b:.1f} {unit}" b /= 1024 return f"{b:.1f} ПБ" def get_os(f: dict) -> str: dist = f.get("distribution", "") ver = f.get("distribution_version", "") return f"{dist} {ver}".strip() or "—" def get_ipv4(f: dict) -> str: return f.get("default_ipv4", {}).get("address", "—") def get_ram(f: dict) -> str: mb = f.get("memtotal_mb", 0) return f"{mb / 1024:.1f} ГБ" if mb >= 1024 else f"{mb} МБ" def get_uptime(f: dict) -> str: secs = int(f.get("uptime_seconds", 0)) days, secs = divmod(secs, 86400) hours = secs // 3600 return f"{days}д {hours}ч" def get_disk_root(f: dict) -> tuple: """Возвращает (строка_использования, процент_заполнения).""" for m in f.get("mounts", []): if m.get("mount") == "/": total = int(m.get("size_total", 0)) avail = int(m.get("size_available", 0)) if total == 0: return "—", 0 pct = int((total - avail) / total * 100) return f"{(total - avail) // 1024**3}Г / {total // 1024**3}Г", pct return "—", 0 def os_badge(os_str: str) -> str: if "CentOS" in os_str: color = "danger" elif "Debian" in os_str: color = "primary" else: color = "secondary" return f'{os_str}' def disk_badge(pct: int) -> str: color = "danger" if pct > 85 else "warning" if pct > 70 else "success" return f'{pct}%' def parse_docker_containers(f: dict) -> list[dict]: """Извлекает данные о запущенных Docker-контейнерах из фактов хоста.""" result = [] for c in f.get("containers", []): name = c.get("Name", "").lstrip("/") image = c.get("Config", {}).get("Image", "—") # Порты: NetworkSettings.Ports → {"80/tcp": [{"HostIp": "...", "HostPort": "..."}]} ports_dict = c.get("NetworkSettings", {}).get("Ports", {}) port_parts = [] for container_port, bindings in sorted(ports_dict.items()): if bindings: for b in bindings: host_ip = b.get("HostIp", "") host_port = b.get("HostPort", "") prefix = f"{host_ip}:" if host_ip and host_ip not in ("0.0.0.0", "::") else "" port_parts.append(f"{prefix}{host_port}→{container_port}") else: port_parts.append(container_port) ports_str = ", ".join(port_parts) or "—" # Volumes: Mounts → [{"Type": "...", "Source": "...", "Destination": "..."}] volume_parts = [] for m in c.get("Mounts", []): src = m.get("Source", "") dst = m.get("Destination", "") volume_parts.append(f"{src}:{dst}" if src else dst) volumes_str = ", ".join(volume_parts) or "—" result.append({"name": name, "image": image, "ports": ports_str, "volumes": volumes_str}) return result def parse_ports(raw: str) -> list[dict]: """Разбирает вывод ss -tlnup в список словарей.""" ports = [] for line in raw.splitlines(): parts = line.split() # пропускаем заголовок и короткие строки if len(parts) < 5 or parts[0] in ("Netid", "State"): continue proto = parts[0] state = parts[1] local = parts[4] proc_info = " ".join(parts[6:]) if len(parts) > 6 else "" m = re.search(r'users:\(\("([^"]+)"', proc_info) process = m.group(1) if m else "—" m_pid = re.search(r'pid=(\d+)', proc_info) pid = int(m_pid.group(1)) if m_pid else None # разбираем адрес:порт с учётом IPv6 [::]:port m6 = re.match(r"\[([^\]]+)\]:(\d+|\*)", local) if m6: addr, port = m6.group(1), m6.group(2) elif ":" in local: addr, port = local.rsplit(":", 1) else: addr, port = local, "—" ports.append({"proto": proto, "state": state, "addr": addr, "port": port, "process": process, "pid": pid}) return sorted(ports, key=lambda x: (x["proto"], int(x["port"]) if x["port"].isdigit() else 0)) # ── Зависимости между службами ───────────────────────────────────────────────── def build_ip_to_host(hosts: dict) -> dict[str, str]: """Строит маппинг IP-адрес → имя хоста из собранных фактов.""" mapping: dict[str, str] = {} for hostname, f in hosts.items(): ip = f.get("default_ipv4", {}).get("address", "") if ip: mapping[ip] = hostname for iface in f.get("interfaces", []): iface_ip = f.get(iface, {}).get("ipv4", {}).get("address", "") if iface_ip and iface_ip not in mapping: mapping[iface_ip] = hostname return mapping def _parse_addr_port(s: str) -> tuple[str, str]: """Разбирает строку вида addr:port или [ipv6]:port.""" m6 = re.match(r"\[([^\]]+)\]:(\d+)$", s) if m6: return m6.group(1), m6.group(2) if ":" in s: addr, port = s.rsplit(":", 1) return addr, port return s, "—" def parse_connections(raw: str) -> list[dict]: """Разбирает вывод ss -tnp state established в список соединений. Поддерживает оба формата iproute2: - со столбцом State: ESTAB 0 0 local:port peer:port [Process] - без столбца State: 0 0 local:port peer:port [Process] """ conns = [] for line in raw.splitlines(): parts = line.split() if len(parts) < 4: continue # Определяем смещение: первая колонка — State-слово или число (Recv-Q) if parts[0] in ("ESTAB", "CLOSE-WAIT", "TIME-WAIT", "SYN-SENT"): offset = 1 # State Recv-Q Send-Q Local Peer [Process] elif parts[0].isdigit(): offset = 0 # Recv-Q Send-Q Local Peer [Process] else: continue # заголовок или неизвестный формат if len(parts) < offset + 4: continue local = parts[offset + 2] peer = parts[offset + 3] proc_info = " ".join(parts[offset + 4:]) if len(parts) > offset + 4 else "" m = re.search(r'users:\(\("([^"]+)"', proc_info) process = m.group(1) if m else "—" m_pid = re.search(r'pid=(\d+)', proc_info) pid = int(m_pid.group(1)) if m_pid else None peer_addr, peer_port = _parse_addr_port(peer) local_addr, local_port = _parse_addr_port(local) conns.append({ "local_addr": local_addr, "local_port": local_port, "peer_addr": peer_addr, "peer_port": peer_port, "process": process, "pid": pid, }) return conns def parse_ps_map(raw: str) -> dict: """Строит маппинг PID → понятное имя процесса из вывода ps -eo pid,args. Для Java-процессов извлекает -Dapp.name=XXX (BGBillingServer, BGInetAccess и т.д.). Для ActiveMQ определяет по классу запуска. Для остальных процессов возвращает базовое имя исполняемого файла. """ result: dict[int, str] = {} for line in raw.splitlines(): parts = line.split(None, 1) if len(parts) < 2 or not parts[0].isdigit(): continue pid = int(parts[0]) args = parts[1] m = re.search(r'-Dapp\.name=(\S+)', args) if m: result[pid] = m.group(1) continue if 'org.apache.activemq' in args: result[pid] = 'activemq' continue exe = args.split()[0] result[pid] = os.path.basename(exe) return result def _resolve_proc(process: str, pid, ps_map: dict) -> str: """Возвращает понятное имя процесса, используя ps_map если доступен PID.""" if pid is not None and pid in ps_map: return ps_map[pid] return process _CT_INCLUDE_STATES = {"ESTABLISHED", "TIME_WAIT", "CLOSE_WAIT"} # Слова-состояния TCP в conntrack (отличают TCP-строку от UDP-строки) _CT_TCP_STATES = { "ESTABLISHED", "TIME_WAIT", "CLOSE_WAIT", "SYN_SENT", "SYN_RECV", "FIN_WAIT", "LAST_ACK", "NONE", } def parse_conntrack(raw: str) -> list[dict]: """Разбирает вывод conntrack -L (TCP и UDP). Формат TCP: tcp 6 58 TIME_WAIT src=A dst=B sport=P dport=Q src=... ... Формат UDP: udp 17 28 src=A dst=B sport=P dport=Q src=... ... Возвращает список dict с ключами: local_addr, local_port, peer_addr, peer_port, process, ct_state, proto. Поле process всегда '—'; обогащается позже через корреляцию с ports_raw. Для TCP принимаются только состояния из _CT_INCLUDE_STATES. Для UDP состояние отсутствует, принимаются все записи. """ result = [] for line in raw.splitlines(): parts = line.split() if len(parts) < 4: continue proto = parts[0] if proto not in ("tcp", "udp"): continue if proto == "tcp": # parts[3] — слово состояния (ESTABLISHED, TIME_WAIT, …) if len(parts) < 5: continue state = parts[3] if state not in _CT_INCLUDE_STATES: continue field_start = 4 else: # UDP: нет слова состояния; parts[3] начинается с src= или другого key=val state = "" field_start = 3 fields: dict[str, str] = {} for part in parts[field_start:]: if "=" in part and "[" not in part: k, v = part.split("=", 1) if k not in fields: # только первое вхождение — original direction fields[k] = v src = fields.get("src", "—") dst = fields.get("dst", "—") sport = fields.get("sport", "—") dport = fields.get("dport", "—") if src == "—" or dst == "—": continue result.append({ "local_addr": dst, "local_port": dport, "peer_addr": src, "peer_port": sport, "process": "—", "ct_state": state, "proto": proto, }) return result def detect_auto_deps(hosts: dict, ip_to_host: dict) -> list[dict]: """Находит зависимости между хостами по активным TCP-соединениям. Логика: 1. Только соединения, где peer_port является СЛУШАЮЩИМ портом на хосте-назначении. Это отсекает "зеркальные" строки (db-2 видит billing-main:38518 — эфемерный порт) и оставляет только инициирующую сторону (billing-main → db-2:3306). 2. Сопоставляет процессы: хост A видит java→B:3306, хост B видит mysqld←A:ephemeral. 3. Группирует по (from_host, from_service, to_host, to_service, port) — без эфемерных портов. """ # Шаг 1: индекс слушающих портов и серверных соединений для каждого хоста host_listening: dict[str, frozenset] = {} host_srv_index: dict[str, dict] = {} host_to_ips: dict[str, set] = {} host_ps_map: dict[str, dict] = {} for hostname, f in hosts.items(): ips: set = set() ip = f.get("default_ipv4", {}).get("address", "") if ip: ips.add(ip) for iface in f.get("interfaces", []): iface_ip = f.get(iface, {}).get("ipv4", {}).get("address", "") if iface_ip: ips.add(iface_ip) host_to_ips[hostname] = ips host_listening[hostname] = _get_listening_ports(f) host_ps_map[hostname] = parse_ps_map(f.get("ps_raw", "")) # (local_port, peer_addr, peer_port) → (process, pid) — для поиска серверного процесса index: dict = {} for conn in parse_connections(f.get("connections_raw", "")): lp = conn["local_port"] if lp.isdigit(): key = (lp, conn["peer_addr"], conn["peer_port"]) index[key] = (conn["process"], conn.get("pid")) host_srv_index[hostname] = index # Шаг 2: обходим соединения, оставляем только те, где peer_port слушает на to_host counts: dict = {} # (from_host, from_proc, to_host, to_proc, port) → кол-во соед. for hostname, f in hosts.items(): ps_map = host_ps_map[hostname] for conn in parse_connections(f.get("connections_raw", "")): pp = conn["peer_port"] lp = conn["local_port"] if not pp.isdigit(): continue port = int(pp) to_host = ip_to_host.get(conn["peer_addr"]) if not to_host or to_host == hostname: continue # Фильтр: peer_port должен быть слушающим портом на to_host. # Если нет — это эфемерный порт (ответный трафик), пропускаем. if port not in host_listening.get(to_host, frozenset()): continue client_proc = _resolve_proc(conn["process"], conn.get("pid"), ps_map) # Ищем серверный процесс по зеркальной записи на to_host server_proc = "—" srv_index = host_srv_index.get(to_host, {}) to_ps_map = host_ps_map.get(to_host, {}) for my_ip in host_to_ips.get(hostname, set()): candidate = srv_index.get((pp, my_ip, lp)) if candidate: srv_proc_name, srv_pid = candidate server_proc = _resolve_proc(srv_proc_name, srv_pid, to_ps_map) break key = (hostname, client_proc, to_host, server_proc, port) counts[key] = counts.get(key, 0) + 1 return [ { "from_host": fh, "from_service": fp, "to_host": th, "to_service": tp, "port": p, "count": c, "source": "auto", } for (fh, fp, th, tp, p), c in sorted(counts.items()) ] def _classify_external_ip(addr: str) -> tuple[str, str]: """Классифицирует внешний IP-адрес. Возвращает (тип, подсеть): ('local', '10.0.0.0/8') — адрес входит в одну из локальных сетей ('remote', '') — публичный интернет-адрес """ try: ip = ipaddress.ip_address(addr) except ValueError: return "remote", "" for net in _LOCAL_NETWORKS: if ip in net: return "local", str(net) return "remote", "" def _get_listening_ports(f: dict) -> frozenset: """Возвращает множество портов, на которых хост слушает (из ports_raw).""" result = set() for p in parse_ports(f.get("ports_raw", "")): if p["port"].isdigit(): result.add(int(p["port"])) return frozenset(result) def _ip_sort_key(ip: str): """Ключ сортировки IP-адресов (IPv4 числовой, IPv6 лексикографический).""" try: return (0, int(ipaddress.ip_address(ip))) except ValueError: return (1, ip) def detect_external_deps(hosts: dict, ip_to_host: dict) -> list[dict]: """Находит входящие соединения от хостов не из проекта. Для каждого сервера из инвентаря смотрит его соединения, где: - local_port является слушающим портом этого хоста (хост — сервер) - peer_addr НЕ входит в ip_to_host (источник — внешний) Источники данных: ss (TCP ESTABLISHED) + conntrack -L (TCP и UDP, если доступен). Conntrack расширяет охват: TCP TIME_WAIT/CLOSE_WAIT и все UDP-соединения. Каждая запись соответствует одному уникальному IP-адресу источника. Результат: - ext_type='local' → источник в локальных подсетях (_LOCAL_NETWORKS) - ext_type='remote' → публичный интернет - historical=True → пир из connection_history.json, не активен сейчас """ counts: dict = {} # key → int ct_states: dict = {} # key → лучший ct_state ('ESTABLISHED' предпочтительнее 'TIME_WAIT') for hostname, f in hosts.items(): listening = _get_listening_ports(f) # Маппинг порт→процесс для обогащения conntrack-записей port_to_proc: dict = {} for p in parse_ports(f.get("ports_raw", "")): if p["process"] != "—" and p["port"].isdigit(): port_to_proc[p["port"]] = p["process"] # Источник 1: ss (имена процессов есть) conns_ss = parse_connections(f.get("connections_raw", "")) # Источник 2: conntrack (обогащаем именами процессов через порты) conns_ct = parse_conntrack(f.get("conntrack_raw", "")) for c in conns_ct: if c["process"] == "—" and c["local_port"] in port_to_proc: c["process"] = port_to_proc[c["local_port"]] # Дедупликация: убираем из conntrack то, что ss уже показывает ss_seen = {(c["local_port"], c["peer_addr"], c["peer_port"]) for c in conns_ss} conns_ct_new = [ c for c in conns_ct if (c["local_port"], c["peer_addr"], c["peer_port"]) not in ss_seen ] all_conns = conns_ss + conns_ct_new for conn in all_conns: lp = conn["local_port"] if not lp.isdigit(): continue local_port_int = int(lp) # Хост является сервером только если local_port слушает if local_port_int not in listening: continue peer_addr = conn["peer_addr"] # Пропускаем известные хосты из проекта if peer_addr in ip_to_host: continue ext_type, subnet = _classify_external_ip(peer_addr) proto = conn.get("proto", "tcp") key = (hostname, local_port_int, conn["process"], proto, ext_type, subnet, peer_addr) counts[key] = counts.get(key, 0) + 1 # Предпочитаем '' (ss/ESTABLISHED) над TIME_WAIT ct = conn.get("ct_state", "") if key not in ct_states or ct in ("", "ESTABLISHED"): ct_states[key] = ct current_keys: set = set() result = [] for (th, port, proc, proto, etype, subnet, addr), c in sorted(counts.items()): key = (th, port, proc, proto, etype, subnet, addr) current_keys.add(key) entry: dict = { "to_host": th, "to_port": port, "to_service": proc, "proto": proto, "ext_type": etype, "subnet": subnet, "ip": addr, "count": c, } ct = ct_states.get(key, "") if ct: entry["ct_state"] = ct result.append(entry) # Добавляем исторические пиры из connection_history.json if HISTORY_FILE.exists(): try: history = json.loads(HISTORY_FILE.read_text(encoding="utf-8")) for peer in history.get("peers", []): if peer.get("ext_type") != "local": continue peer_proto = peer.get("proto", "tcp") hkey = ( peer["to_host"], peer["to_port"], peer.get("to_service", "—"), peer_proto, "local", peer["subnet"], peer["peer_addr"], ) if hkey in current_keys: continue result.append({ "to_host": peer["to_host"], "to_port": peer["to_port"], "to_service": peer.get("to_service", "—"), "proto": peer_proto, "ext_type": "local", "subnet": peer["subnet"], "ip": peer["peer_addr"], "count": 0, "historical": True, "last_seen": peer.get("last_seen", ""), }) except Exception as exc: print(f"Предупреждение: не удалось прочитать {HISTORY_FILE}: {exc}", file=sys.stderr) return result def detect_intra_deps(hosts: dict) -> list[dict]: """Находит зависимости между службами внутри одного хоста по loopback TCP-соединениям. Для каждого хоста ищет соединения, где peer_addr — это loopback-адрес (127.0.0.1, ::1) или собственный IP интерфейса, а peer_port является слушающим портом того же хоста. Серверный процесс определяется по зеркальной строке из того же connections_raw. Результат группируется по (host, client_service, server_service, port). """ result = [] for hostname, f in hosts.items(): # Собираем все "свои" IP-адреса хоста (loopback + все интерфейсы) own_ips: set = {"127.0.0.1", "::1"} ip = f.get("default_ipv4", {}).get("address", "") if ip: own_ips.add(ip) for iface in f.get("interfaces", []): iface_ip = f.get(iface, {}).get("ipv4", {}).get("address", "") if iface_ip: own_ips.add(iface_ip) listening = _get_listening_ports(f) connections = parse_connections(f.get("connections_raw", "")) ps_map = parse_ps_map(f.get("ps_raw", "")) # Индекс серверной стороны: (local_port, peer_addr, peer_port) → (process, pid) # Используется для поиска серверного процесса по зеркальной строке соединения srv_index: dict = {} for conn in connections: lp = conn["local_port"] if lp.isdigit(): key = (lp, conn["peer_addr"], conn["peer_port"]) srv_index[key] = (conn["process"], conn.get("pid")) # (client_proc, server_proc, port) → количество соединений counts: dict = {} for conn in connections: pp = conn["peer_port"] lp = conn["local_port"] if not pp.isdigit(): continue # Только соединения к собственным адресам хоста if conn["peer_addr"] not in own_ips: continue port = int(pp) # Peer_port должен быть слушающим портом — отсекаем эфемерные порты if port not in listening: continue client_proc = _resolve_proc(conn["process"], conn.get("pid"), ps_map) # Ищем серверный процесс по зеркальной записи srv_raw, srv_pid = srv_index.get((pp, conn["local_addr"], lp), ("—", None)) server_proc = _resolve_proc(srv_raw, srv_pid, ps_map) key = (client_proc, server_proc, port) counts[key] = counts.get(key, 0) + 1 for (cp, sp, port), cnt in sorted(counts.items()): result.append({ "host": hostname, "client_service": cp, "server_service": sp, "port": port, "count": cnt, }) return result def _node_id(hostname: str) -> str: """Возвращает безопасный идентификатор Mermaid-узла для имени хоста.""" return "h_" + re.sub(r"[^a-zA-Z0-9]", "_", hostname) def host_deps_mermaid(hostname: str, deps: list, ext_deps: list | None = None) -> str: """Строит Mermaid-диаграмму зависимостей для одного хоста. Текущий хост выделен стилем; рёбра подписаны входящим портом. Внешние локальные подключения — зелёный прямоугольник (subgraph). Внешние удалённые подключения — оранжевый прямоугольник (subgraph). """ ext_deps = ext_deps or [] if not deps and not ext_deps: return "" # рёбра: (from_node_id, to_node_id) → labels[] edges: dict = {} for d in deps: fid = _node_id(d["from_host"]) tid = _node_id(d["to_host"]) port = str(d.get("port", "")) if d.get("port") else "" proto = d.get("protocol", "tcp") label = f"{port}/{proto}" if port and d.get("source") == "static" else port key = (fid, tid, d["from_host"], d["to_host"]) edges.setdefault(key, []) if label and label not in edges[key]: edges[key].append(label) all_hosts = sorted( {d["from_host"] for d in deps} | {d["to_host"] for d in deps} | {hostname} ) lines = ["graph LR"] for h in all_hosts: nid = _node_id(h) if h == hostname: lines.append(f' {nid}["{h}"]') lines.append(f' style {nid} fill:#0d6efd,color:#fff,stroke:#0a58ca') else: lines.append(f' {nid}["{h}"]') for (fid, tid, _fh, _th), labels in edges.items(): label = ", ".join(labels) if label: lines.append(f' {fid} -->|"{label}"| {tid}') else: lines.append(f' {fid} --> {tid}') host_nid = _node_id(hostname) # Внешние локальные подключения — зелёный прямоугольник local_ext = [d for d in ext_deps if d["ext_type"] == "local"] if local_ext: subnet_ports: dict = {} for d in local_ext: subnet_ports.setdefault(d["subnet"], set()).add(str(d["to_port"])) lines.append(' subgraph ext_local_group ["Внешние локальные"]') lines.append(" style ext_local_group fill:#d4edda,stroke:#28a745,color:#155724") for sn in sorted(subnet_ports): sn_id = "ext_loc_" + re.sub(r"[^a-zA-Z0-9]", "_", sn) lines.append(f' {sn_id}["{sn}"]') lines.append(" end") for sn, ports in sorted(subnet_ports.items()): sn_id = "ext_loc_" + re.sub(r"[^a-zA-Z0-9]", "_", sn) port_label = ", ".join(sorted(ports, key=lambda x: int(x) if x.isdigit() else 0)) lines.append(f' {sn_id} -->|"{port_label}"| {host_nid}') # Внешние удалённые подключения — оранжевый прямоугольник remote_ext = [d for d in ext_deps if d["ext_type"] == "remote"] if remote_ext: remote_ports: set = set() for d in remote_ext: remote_ports.add(str(d["to_port"])) lines.append(' subgraph ext_remote_group ["Внешние удалённые"]') lines.append(" style ext_remote_group fill:#fff3cd,stroke:#fd7e14,color:#856404") lines.append(' ext_internet["Интернет"]') lines.append(" end") port_label = ", ".join(sorted(remote_ports, key=lambda x: int(x) if x.isdigit() else 0)) lines.append(f' ext_internet -->|"{port_label}"| {host_nid}') return "\n".join(lines) def deps_mermaid(static_deps: list, auto_deps: list, ext_deps: list | None = None) -> str: """Строит текст Mermaid-диаграммы зависимостей (graph LR). Внешние локальные подключения — зелёный subgraph по подсетям. Внешние удалённые подключения — оранжевый subgraph с узлом «Интернет». """ ext_deps = ext_deps or [] # рёбра: (from_node_id, to_node_id) → {fh, th, labels[]} edges: dict = {} # На диаграмме отображаем только входящий порт (порт сервера-получателя) for d in static_deps: fid, tid = _node_id(d["from_host"]), _node_id(d["to_host"]) port, proto = d.get("port", ""), d.get("protocol", "tcp") label = f"{port}/{proto}" if port else "—" key = (fid, tid) edges.setdefault(key, {"fh": d["from_host"], "th": d["to_host"], "labels": []}) if label not in edges[key]["labels"]: edges[key]["labels"].append(str(label)) for d in auto_deps: fid, tid = _node_id(d["from_host"]), _node_id(d["to_host"]) label = str(d["port"]) key = (fid, tid) edges.setdefault(key, {"fh": d["from_host"], "th": d["to_host"], "labels": []}) if label not in edges[key]["labels"]: edges[key]["labels"].append(label) if not edges and not ext_deps: return "" all_hosts = sorted( {v["fh"] for v in edges.values()} | {v["th"] for v in edges.values()} ) lines = ["graph LR"] for h in all_hosts: lines.append(f' {_node_id(h)}["{h}"]') for (fid, tid), v in edges.items(): label = ", ".join(v["labels"]) lines.append(f' {fid} -->|"{label}"| {tid}') # Внешние локальные подключения — зелёный прямоугольник # Группировка: subnet → {to_host → set(ports)} local_ext = [d for d in ext_deps if d["ext_type"] == "local"] if local_ext: subnet_host_ports: dict = {} for d in local_ext: sn = d["subnet"] subnet_host_ports.setdefault(sn, {}) subnet_host_ports[sn].setdefault(d["to_host"], set()).add(str(d["to_port"])) lines.append(' subgraph ext_local_group ["Внешние локальные"]') lines.append(" style ext_local_group fill:#d4edda,stroke:#28a745,color:#155724") for sn in sorted(subnet_host_ports): sn_id = "ext_loc_" + re.sub(r"[^a-zA-Z0-9]", "_", sn) lines.append(f' {sn_id}["{sn}"]') lines.append(" end") for sn, host_ports in sorted(subnet_host_ports.items()): sn_id = "ext_loc_" + re.sub(r"[^a-zA-Z0-9]", "_", sn) for to_host, ports in sorted(host_ports.items()): port_label = ", ".join(sorted(ports, key=lambda x: int(x) if x.isdigit() else 0)) lines.append(f' {sn_id} -->|"{port_label}"| {_node_id(to_host)}') # Внешние удалённые подключения — оранжевый прямоугольник # Группировка: to_host → set(ports) remote_ext = [d for d in ext_deps if d["ext_type"] == "remote"] if remote_ext: host_ports: dict = {} for d in remote_ext: host_ports.setdefault(d["to_host"], set()).add(str(d["to_port"])) lines.append(' subgraph ext_remote_group ["Внешние удалённые"]') lines.append(" style ext_remote_group fill:#fff3cd,stroke:#fd7e14,color:#856404") lines.append(' ext_internet["Интернет"]') lines.append(" end") for to_host, ports in sorted(host_ports.items()): port_label = ", ".join(sorted(ports, key=lambda x: int(x) if x.isdigit() else 0)) lines.append(f' ext_internet -->|"{port_label}"| {_node_id(to_host)}') return "\n".join(lines) def service_mermaid(svc_def: dict, all_deps: list) -> str: """Строит Mermaid-диаграмму для глобального сервиса. Если dep_refs задан — показывает только указанные рёбра. Иначе — все рёбра из all_deps, где оба конца входят в components. """ _ROLE_COLORS = { "app": "#0d6efd", "db-primary": "#198754", "db-replica": "#d1e7dd", } _DEFAULT_COLOR = "#6c757d" comp_set = {(c["host"], c["service"]) for c in svc_def["components"]} dep_refs = svc_def.get("dep_refs") if dep_refs: ref_pairs = { (r["from"]["host"], r["from"]["service"], r["to"]["host"], r["to"]["service"]) for r in dep_refs } edges = [ d for d in all_deps if (d["from_host"], d["from_service"], d["to_host"], d["to_service"]) in ref_pairs ] else: edges = [ d for d in all_deps if (d["from_host"], d["from_service"]) in comp_set and (d["to_host"], d["to_service"]) in comp_set ] if not edges and len(svc_def["components"]) < 2: return "" def _svc_node_id(host: str, service: str) -> str: return "s_" + re.sub(r"[^a-zA-Z0-9]", "_", host + "_" + service) # Строим карту компонентов для получения label и role comp_map = {(c["host"], c["service"]): c for c in svc_def["components"]} lines = ["graph LR"] # Узлы всех компонентов for c in svc_def["components"]: nid = _svc_node_id(c["host"], c["service"]) label = c.get("label") or f"{c['host']}\\n{c['service']}" color = _ROLE_COLORS.get(c.get("role", ""), _DEFAULT_COLOR) lines.append(f' {nid}["{label}"]') lines.append(f" style {nid} fill:{color},color:#fff,stroke:#333") # Рёбра seen = set() for d in edges: fid = _svc_node_id(d["from_host"], d["from_service"]) tid = _svc_node_id(d["to_host"], d["to_service"]) port = str(d.get("port", "")) label = port if port else "—" key = (fid, tid, label) if key in seen: continue seen.add(key) lines.append(f' {fid} -->|"{label}"| {tid}') return "\n".join(lines) # ── Шаблон страницы ──────────────────────────────────────────────────────────── def page(title: str, body: str, depth: int = 0, extra_scripts: str = "") -> str: root = "../" * depth return f""" {title}
{body}
{extra_scripts} """ # ── Docker (индекс + страница) ──────────────────────────────────────────────── def docker_summary_section(hosts: dict) -> str: """Рендерит краткую сводку Docker-контейнеров на главной странице.""" all_containers = [] hosts_with_containers = 0 for hostname, f in hosts.items(): containers = parse_docker_containers(f) if containers: hosts_with_containers += 1 all_containers.extend(containers) if not all_containers: return "" return f"""

Docker-контейнеры

{len(all_containers)} {hosts_with_containers} хостов Подробнее →
""" def docker_page(hosts: dict) -> str: """Генерирует HTML-страницу со всеми Docker-контейнерами.""" rows = [] for hostname, f in hosts.items(): for c in parse_docker_containers(f): rows.append( f"" f"{c['name']}" f"{hostname}" f"{c['image']}" f"{c['ports']}" f"{c['volumes']}" f"" ) containers_section = "" if rows: containers_section = f"""
Контейнеры {len(rows)}
{''.join(rows)}
Имя контейнера Хост Образ Порты Volumes
""" body = f"""

Docker-контейнеры

{containers_section}""" return page("Docker-контейнеры", body) # ── Зависимости между службами (страница) ───────────────────────────────────── def deps_index_section(static_deps: list, auto_deps: list, ext_deps: list, intra_deps: list) -> str: """Рендерит краткую сводку зависимостей на главной странице.""" total = len(static_deps) + len(auto_deps) if total == 0 and not ext_deps and not intra_deps: return "" ext_local = sum(1 for d in ext_deps if d["ext_type"] == "local") ext_remote = sum(1 for d in ext_deps if d["ext_type"] == "remote") return f"""

Зависимости между службами

{total} {len(static_deps)} статических {len(auto_deps)} авто {len(intra_deps)} внутрихостовых {ext_local} внешних локальных {ext_remote} внешних удалённых Зависимости → Внешние подключения →
""" def global_services_index_section(global_services: list) -> str: """Рендерит краткую сводку глобальных сервисов на главной странице.""" if not global_services: return "" links = "".join( f'{s["name"]}' for s in global_services ) return f"""

Глобальные сервисы

{len(global_services)} Все сервисы → {links}
""" def external_page(ext_deps: list) -> str: """Генерирует HTML-страницу external_connections.html. Структура: хост → служба → IP-адреса источников без группировки по подсетям. Локальные подключения — зелёная таблица, удалённые — оранжевая. """ if not ext_deps: body = """

Внешние подключения

Данные об внешних подключениях отсутствуют.

""" return page("Внешние подключения", body) # Структура: {to_host: {to_service: {"local": [(port, proto, ip, count, historical, last_seen, ct_state)], "remote": [(port, ip, count)]}}} structure: dict = {} for d in ext_deps: host = d["to_host"] svc = d["to_service"] structure.setdefault(host, {}) structure[host].setdefault(svc, {"local": [], "remote": []}) if d["ext_type"] == "local": entry = ( d["to_port"], d.get("proto", "tcp"), d.get("ip", ""), d["count"], d.get("historical", False), d.get("last_seen", ""), d.get("ct_state", ""), ) structure[host][svc]["local"].append(entry) else: structure[host][svc]["remote"].append((d["to_port"], d.get("ip", ""), d["count"])) host_cards = [] for host in sorted(structure): svc_blocks = [] for idx, svc in enumerate(sorted(structure[host])): data = structure[host][svc] # Таблица локальных подключений — индивидуальные IP, сортировка по порту → IP local_block = "" if data["local"]: rows = [] for port, proto, ip, count, historical, last_seen, ct_state in sorted( data["local"], key=lambda x: (x[0], x[1], _ip_sort_key(x[2])) ): port_badge = f"{port}/{proto}" if historical: ls_short = (last_seen or "")[:10] rows.append( f"" f"{port_badge}" f"{ip}" f"last seen: {ls_short}" f"" ) elif ct_state == "TIME_WAIT": rows.append( f"" f"{port_badge}" f"{ip}" f"(TIME_WAIT)" f"" ) else: rows.append( f"" f"{port_badge}" f"{ip}" f"{count}" f"" ) active_local = sum(1 for _, _, _, _, h, _, _ in data["local"] if not h) hist_local = sum(1 for _, _, _, _, h, _, _ in data["local"] if h) hist_badge_l = f' {hist_local} истор.' if hist_local else "" local_block = f"""
■ Локальные сети {active_local}{hist_badge_l}
{''.join(rows)}
ПортIP-адрес источникаСоед.
""" # Таблица удалённых подключений — индивидуальные IP, сортировка по порту → IP remote_block = "" if data["remote"]: rows = [] for port, ip, count in sorted( data["remote"], key=lambda x: (x[0], _ip_sort_key(x[1])) ): rows.append( f"" f"{port}/tcp" f"{ip}" f"{count}" f"" ) remote_block = f"""
■ Публичный интернет {len(rows)}
{''.join(rows)}
ПортIP-адрес источникаСоед.
""" border_top = ' style="border-top:1px solid #dee2e6"' if idx > 0 else "" svc_blocks.append(f"""
{svc}
{local_block} {remote_block}
""") svc_count = len(structure[host]) local_ips = len({(svc, e[2]) for svc, d in structure[host].items() for e in d["local"] if not e[4]}) remote_ips = len({(svc, e[1]) for svc, d in structure[host].items() for e in d["remote"]}) badges = "" if local_ips: badges += f' {local_ips} лок. IP' if remote_ips: badges += f' {remote_ips} удал. IP' host_cards.append(f"""
{host} {svc_count} служб{badges}
{''.join(svc_blocks)}
""") total_hosts = len(structure) total_svcs = sum(len(v) for v in structure.values()) local_ips = len({d.get("ip", "") for d in ext_deps if d["ext_type"] == "local" and not d.get("historical")}) remote_ips = len({d.get("ip", "") for d in ext_deps if d["ext_type"] == "remote"}) body = f"""

Внешние подключения

{total_hosts} хостов {total_svcs} служб {local_ips} локальных IP {remote_ips} удалённых IP
{''.join(host_cards)}""" return page("Внешние подключения", body) def deps_page(static_deps: list, auto_deps: list, ext_deps: list, intra_deps: list) -> str: """Генерирует HTML-страницу dependencies.html.""" # ── Mermaid-диаграмма ───────────────────────────────────────────────────── mermaid_text = deps_mermaid(static_deps, auto_deps, ext_deps) diagram_section = "" if mermaid_text: escaped = mermaid_text.replace("`", "\\`") diagram_section = f"""
Граф зависимостей
{mermaid_text}
""" # ── Статические зависимости ─────────────────────────────────────────────── static_rows = [] for d in static_deps: from_cell = ( f"{d['from_host']}" + (f"
{d['from_service']}" if d["from_service"] else "") ) to_cell = ( f"{d['to_host']}" + (f"
{d['to_service']}" if d["to_service"] else "") ) port_cell = ( f"{d['port']}/{d['protocol']}" if d["port"] else "—" ) static_rows.append( f"" f"{from_cell}" f"{to_cell}" f"{port_cell}" f"{d['description'] or '—'}" f"" ) static_section = "" if static_rows: static_section = f"""
Статические зависимости {len(static_rows)} из service_map.yml
{''.join(static_rows)}
От (хост / служба)До (хост / служба)ПортОписание
""" # ── Автоопределённые зависимости ───────────────────────────────────────── auto_rows = [] for d in sorted(auto_deps, key=lambda x: (x["port"], x["to_host"], x["from_host"])): from_link = f"{d['from_host']}" to_link = f"{d['to_host']}" from_svc = d.get("from_service") or "—" to_svc = d.get("to_service") or "—" port_badge = f"{d['port']}/tcp" svc_matched = from_svc != "—" and to_svc != "—" match_badge = ( "" if svc_matched else "" ) auto_rows.append( f"" f"{from_link}
{from_svc}" f"{to_link}
{to_svc}" f"{port_badge}{match_badge}" f"{d['count']}" f"" ) auto_section = "" if auto_rows: matched = sum(1 for d in auto_deps if d.get("from_service", "—") != "—" and d.get("to_service", "—") != "—") auto_section = f"""
Автоопределённые зависимости {len(auto_rows)} {matched} сопоставлено по активным TCP-соединениям из ss
{''.join(auto_rows)}
Источник (хост / служба)Назначение (хост / служба)ПортСоед.
""" if not static_section and not auto_section: auto_section = '

Зависимости не обнаружены. Соберите факты хостов и/или заполните service_map.yml.

' # ── Внутрихостовые зависимости ──────────────────────────────────────────── intra_rows = [] for d in sorted(intra_deps, key=lambda x: (x["host"], x["port"], x["client_service"])): host_link = f"{d['host']}" client_svc = d.get("client_service") or "—" server_svc = d.get("server_service") or "—" port_badge = f"{d['port']}/tcp" svc_matched = client_svc != "—" and server_svc != "—" match_badge = ( "" if svc_matched else "" ) intra_rows.append( f"" f"{host_link}" f"{client_svc}" f"{server_svc}" f"{port_badge}{match_badge}" f"{d['count']}" f"" ) intra_section = "" if intra_rows: matched_intra = sum( 1 for d in intra_deps if d.get("client_service", "—") != "—" and d.get("server_service", "—") != "—" ) intra_section = f"""
Внутрихостовые зависимости {len(intra_rows)} {matched_intra} сопоставлено loopback TCP-соединения внутри хоста
{''.join(intra_rows)}
ХостКлиент (служба)Сервер (служба)ПортСоед.
""" # ── Внешние локальные подключения ───────────────────────────────────────── # Показываем индивидуальные IP: текущие (ss/conntrack) и исторические ext_local_entries = [d for d in ext_deps if d["ext_type"] == "local"] ext_local_rows = [] for d in sorted( ext_local_entries, key=lambda x: (x["to_host"], x["to_port"], x["to_service"], _ip_sort_key(x["ip"])), ): historical = d.get("historical", False) ct_state = d.get("ct_state", "") proto = d.get("proto", "tcp") to_link = f"{d['to_host']}" svc_cell = f"{d['to_service']}" if d["to_service"] != "—" else "—" port_badge = f"{d['to_port']}/{proto}" subnet_badge = f"{d['subnet']}" if historical: last_seen = (d.get("last_seen") or "")[:10] ip_cell = f"{d['ip']}" status = f"last seen: {last_seen}" row_class = " class='text-muted'" elif ct_state == "TIME_WAIT": ip_cell = f"{d['ip']}" status = f"(TIME_WAIT)" row_class = "" else: ip_cell = f"{d['ip']}" status = f"×{d['count']}" if d["count"] > 1 else "" row_class = "" ext_local_rows.append( f"" f"{to_link}" f"{svc_cell}" f"{port_badge}" f"{ip_cell} {subnet_badge}" f"{status}" f"" ) ext_local_section = "" if ext_local_rows: active_count = sum(1 for d in ext_local_entries if not d.get("historical")) hist_count = sum(1 for d in ext_local_entries if d.get("historical")) hist_badge = f' {hist_count} истор.' if hist_count else "" ext_local_section = f"""
Внешние локальные подключения {active_count} активных{hist_badge} источник — локальные сети, индивидуальные IP из ss/conntrack и истории
{''.join(ext_local_rows)}
СерверСлужбаПортIP / ПодсетьСтатус / Соед.
""" # ── Внешние удалённые подключения ───────────────────────────────────────── # Группировка: (to_host, to_port, proto, to_service) → total count remote_groups: dict = {} for d in ext_deps: if d["ext_type"] != "remote": continue key = (d["to_host"], d["to_port"], d.get("proto", "tcp"), d["to_service"]) remote_groups[key] = remote_groups.get(key, 0) + d["count"] ext_remote_rows = [] for (to_host, to_port, proto, to_svc), total in sorted(remote_groups.items()): to_link = f"{to_host}" svc_cell = f"{to_svc}" if to_svc != "—" else "—" port_badge = f"{to_port}/{proto}" ext_remote_rows.append( f"" f"{to_link}" f"{svc_cell}" f"{port_badge}" f"{total}" f"" ) ext_remote_section = "" if ext_remote_rows: ext_remote_section = f"""
Внешние удалённые подключения {len(ext_remote_rows)} источник — публичный интернет, суммарное количество
{''.join(ext_remote_rows)}
СерверСлужбаПортВсего соединений
""" body = f"""

Зависимости между службами

Внешние подключения по службам →

Статические — из service_map.yml. Авто — из активных TCP-соединений (ss -tnp state established), собранных при последнем запуске gather_facts.

{diagram_section} {static_section} {auto_section} {intra_section} {ext_local_section} {ext_remote_section}""" mermaid_script = """ """ return page("Зависимости между службами", body, extra_scripts=mermaid_script) # ── VMware vSphere ───────────────────────────────────────────────────────────── def vmware_index_section(vmw: dict) -> str: """Рендерит краткую сводку VMware vSphere на главной странице.""" esxi_count = len(vmw["esxi_hosts"]) vm_count = len(vmw["vms"]) powered_on = sum(1 for v in vmw["vms"] if v.get("power_state") == "poweredOn") ds_count = len(vmw["datastores"]) if esxi_count == 0 and vm_count == 0: return "" return f"""

VMware vSphere

{esxi_count} ESXi {vm_count} VM {powered_on} вкл. {ds_count} датасторов Подробнее →
""" def vmware_page(vmw: dict) -> str: """Генерирует HTML-страницу с обзором VMware vSphere инфраструктуры.""" # ── ESXi гипервизоры ────────────────────────────────────────────────────── esxi_rows = [] for hostname, facts in vmw["esxi_hosts"].items(): vcpus = facts.get("ansible_processor_vcpus", facts.get("ansible_processor_count", "—")) ram_mb = facts.get("ansible_memtotal_mb", 0) ram_str = f"{ram_mb / 1024:.1f} ГБ" if ram_mb >= 1024 else f"{ram_mb} МБ" product = facts.get("ansible_product_name", "—") kernel = facts.get("ansible_kernel", "—") esxi_rows.append( f"" f"{hostname}" f"{vcpus}" f"{ram_str}" f"{product}" f"{kernel}" f"" ) esxi_section = "" if esxi_rows: esxi_section = f"""
ESXi-гипервизоры {len(esxi_rows)}
{''.join(esxi_rows)}
ХостCPURAMМодельВерсия ESXi
""" # ── Виртуальные машины ──────────────────────────────────────────────────── vm_rows = [] for vm in vmw["vms"]: name = vm.get("guest_name", "—") power = vm.get("power_state", "—") power_badge = ( 'poweredOn' if power == "poweredOn" else 'poweredOff' if power == "poweredOff" else f'{power}' ) esxi_host = vm.get("esxi_hostname", "—") ip = vm.get("ip_address") or "—" cpu = vm.get("num_cpu", "—") mem_mb = vm.get("memory_mb", 0) mem_str = f"{mem_mb / 1024:.1f} ГБ" if mem_mb >= 1024 else f"{mem_mb} МБ" os_guest = vm.get("guest_fullname") or "—" vm_rows.append( f"" f"{name}" f"{power_badge}" f"{esxi_host}" f"{ip}" f"{cpu}" f"{mem_str}" f"{os_guest}" f"" ) vms_section = "" if vm_rows: powered_on = sum(1 for v in vmw["vms"] if v.get("power_state") == "poweredOn") vms_section = f"""
Виртуальные машины {len(vm_rows)} {powered_on} вкл.
{''.join(vm_rows)}
Имя VMСостояниеГипервизорIP CPURAMОС
""" # ── Датасторы ───────────────────────────────────────────────────────────── ds_rows = [] for ds in vmw["datastores"]: ds_name = ds.get("name", "—") ds_type = ds.get("type", "—") capacity = int(ds.get("capacity", 0)) free = int(ds.get("freeSpace", 0)) accessible = ds.get("accessible", False) if capacity > 0: pct = int((capacity - free) / capacity * 100) color = "danger" if pct > 85 else "warning" if pct > 70 else "success" bar = ( f'
' f'
{pct}%
' f'
' ) usage_str = f"{fmt_bytes(capacity - free)} / {fmt_bytes(capacity)}" else: bar = "—" usage_str = "—" access_badge = ( 'ОК' if accessible else 'Недоступен' ) ds_rows.append( f"" f"{ds_name}" f"{ds_type}" f"{access_badge}" f"{usage_str}" f'{bar}' f"" ) ds_section = "" if ds_rows: ds_section = f"""
Датасторы {len(ds_rows)}
{''.join(ds_rows)}
ДатасторТипДоступностьИспользование Заполнение
""" body = f"""

VMware vSphere

Датацентр: dc1 / Кластер: cl1

{esxi_section} {vms_section} {ds_section}""" return page("VMware vSphere", body) # ── Глобальные сервисы ───────────────────────────────────────────────────────── def services_index_page(global_services: list, hosts: dict, all_deps: list) -> str: """Генерирует public/services/index.html — каталог всех глобальных сервисов.""" if not global_services: body = """

Глобальные сервисы

Файлы сервисов не найдены. Создайте services/*.yml в корне репозитория.

""" return page("Глобальные сервисы", body, depth=1) cards = [] for svc in global_services: comp_rows = [] for c in svc["components"]: hostname = c["host"] service = c["service"] role = c.get("role", "—") label = c.get("label", "") state = hosts.get(hostname, {}).get("services", {}).get(service, {}).get("state", "") if state == "running": state_badge = 'running' elif state: state_badge = f'{state}' else: state_badge = '' comp_rows.append( f"" f"{hostname}" f"{service}" f"{role}" f"{state_badge}" f"" ) comp_table = f""" {''.join(comp_rows)}
ХостСлужбаРольСостояние
""" desc_html = f'

{svc["description"]}

' if svc["description"] else "" cards.append(f"""
{svc['name']}
{desc_html} {comp_table}
""") body = f"""

Глобальные сервисы

{len(global_services)}
{''.join(cards)}
""" return page("Глобальные сервисы", body, depth=1) def service_detail_page(svc_def: dict, hosts: dict, all_deps: list) -> str: """Генерирует public/services/{slug}.html — страница конкретного сервиса.""" name = svc_def["name"] slug = svc_def["slug"] mermaid_text = service_mermaid(svc_def, all_deps) if mermaid_text: graph_html = f"""
{mermaid_text}
""" mermaid_script = """ """ else: graph_html = '

Зависимости между компонентами не обнаружены.

' mermaid_script = "" comp_rows = [] for c in svc_def["components"]: hostname = c["host"] service = c["service"] role = c.get("role", "—") label = c.get("label", "—") svc_data = hosts.get(hostname, {}).get("services", {}).get(service, {}) state = svc_data.get("state", "") enabled = svc_data.get("status", "") if state == "running": state_badge = 'running' elif state: state_badge = f'{state}' else: state_badge = '' if enabled == "enabled": enabled_badge = 'enabled' elif enabled: enabled_badge = f'{enabled}' else: enabled_badge = '' comp_rows.append( f"" f"{hostname}" f"{service}" f"{label}" f"{role}" f"{state_badge}" f"{enabled_badge}" f"" ) desc_html = f'

{svc_def["description"]}

' if svc_def["description"] else "" body = f"""

{name}

{desc_html}
Граф зависимостей
{graph_html}
Компоненты
{''.join(comp_rows)}
ХостСлужбаМеткаРольСостояниеАвтозапуск
""" return page(f"{name} — сервис", body, depth=1, extra_scripts=mermaid_script) # ── Страница-индекс ──────────────────────────────────────────────────────────── def index_page(hosts: dict, static_deps: list, auto_deps: list, ext_deps: list, intra_deps: list, global_services: list | None = None) -> str: rows = [] for hostname, f in hosts.items(): disk_str, pct = get_disk_root(f) disk_cell = f"{disk_str}{disk_badge(pct)}" if pct > 0 else disk_str vcpus = f.get("processor_vcpus", f.get("processor_count", "—")) collected = f.get("date_time", {}).get("iso8601", "—") rows.append(f""" {hostname} {get_ipv4(f)} {os_badge(get_os(f))} {f.get("kernel", "—")} {vcpus} {get_ram(f)} {disk_cell} {get_uptime(f)} {collected} """) vmw = load_vmware_facts() body = f"""

Управляемые хосты

{len(hosts)}
{''.join(rows)}
Хост IP ОС Ядро CPU RAM Диск / Аптайм Факты собраны
{vmware_index_section(vmw)} {docker_summary_section(hosts)} {global_services_index_section(global_services or [])} {deps_index_section(static_deps, auto_deps, ext_deps, intra_deps)}""" return page("Ansible Hosts — Обзор", body) # ── Страница хоста ───────────────────────────────────────────────────────────── def host_page(hostname: str, f: dict, deps: list | None = None, intra_deps: list | None = None, ext_deps: list | None = None) -> str: # Сетевые интерфейсы iface_rows = [] for iface in sorted(f.get("interfaces", [])): data = f.get(iface, {}) ipv4 = data.get("ipv4", {}).get("address", "—") mask = data.get("ipv4", {}).get("netmask", "") mac = data.get("macaddress", "—") mtu = data.get("mtu", "—") addr_cell = f"{ipv4} / {mask}" if mask else ipv4 iface_rows.append( f"{iface}{addr_cell}" f"{mac}{mtu}" ) # Файловые системы mount_rows = [] for m in sorted(f.get("mounts", []), key=lambda x: x.get("mount", "")): total = int(m.get("size_total", 0)) avail = int(m.get("size_available", 0)) if total > 0: pct = int((total - avail) / total * 100) color = "danger" if pct > 85 else "warning" if pct > 70 else "success" bar = ( f'
' f'
{pct}%
' f'
' ) used_str = f"{fmt_bytes(total - avail)} / {fmt_bytes(total)}" else: bar = "—" used_str = "—" mount_rows.append( f"{m.get('mount', '?')}" f"{m.get('device', '—')}" f"{m.get('fstype', '—')}" f"{used_str}" f'{bar}' ) # CPU-модель (берём первую непустую нецифровую строку из списка процессоров) procs = f.get("processor", []) cpu_model = next((p for p in procs if p and not p.strip().isdigit()), "—") vcpus = f.get("processor_vcpus", f.get("processor_count", "—")) pkg_count = len(f.get("packages", {})) dns = ", ".join(f.get("dns_nameservers", [])) or "—" gateway = f.get("default_ipv4", {}).get("gateway", "—") iface_name = f.get("default_ipv4", {}).get("interface", "—") collected = f.get("date_time", {}).get("iso8601", "—") cards = f"""
Система
ОС{os_badge(get_os(f))}
Ядро{f.get("kernel", "—")}
Архитектура{f.get("architecture", "—")}
FQDN{f.get("fqdn", "—")}
Аптайм{get_uptime(f)}
Время{collected}
Железо
CPU (vCPU){vcpus}
Модель CPU{cpu_model}
RAM{get_ram(f)}
RAM своб.{f.get("memfree_mb", "—")} МБ
SWAP{f.get("swaptotal_mb", 0)} МБ
Пакеты{"%d уст." % pkg_count if pkg_count else "—"}
Сеть
IPv4{get_ipv4(f)}
Шлюз{gateway}
Интерфейс{iface_name}
DNS{dns}
""" ifaces_section = "" if iface_rows: ifaces_section = f"""
Сетевые интерфейсы
{''.join(iface_rows)}
ИнтерфейсIPv4 / маскаMACMTU
""" mounts_section = "" if mount_rows: mounts_section = f"""
Файловые системы
{''.join(mount_rows)}
Точка монтирования Устройство Тип ФС Использование Заполнение
""" # Открытые порты ports_section = "" ports = parse_ports(f.get("ports_raw", "")) if ports: proto_badge = { "tcp": 'TCP', "tcp6": 'TCP6', "udp": 'UDP', "udp6": 'UDP6', } port_rows = "".join( f"" f"{proto_badge.get(p['proto'], p['proto'])}" f"{p['port']}" f"{p['addr']}" f"{p['state']}" f"{p['process']}" f"" for p in ports ) ports_section = f"""
Открытые порты
{port_rows}
ПротоколПортАдресСостояниеПроцесс
""" # Службы services_section = "" services = f.get("services", {}) if services: state_order = {"running": 0, "stopped": 1, "failed": 2} sorted_svcs = sorted( services.values(), key=lambda s: (state_order.get(s.get("state", ""), 3), s.get("name", "")), ) state_badge = { "running": 'running', "stopped": 'stopped', "failed": 'failed', } status_badge = { "enabled": 'enabled', "disabled": 'disabled', "static": 'static', "masked": 'masked', } svc_rows = "".join( f"" f"{s.get('name', '—')}" f"{state_badge.get(s.get('state', ''), s.get('state', '—'))}" f"{status_badge.get(s.get('status', ''), s.get('status', '—'))}" f"{s.get('source', '—')}" f"" for s in sorted_svcs ) running_count = sum(1 for s in services.values() if s.get("state") == "running") services_section = f"""
Службы {len(services)} {running_count} running
{svc_rows}
СлужбаСостояниеАвтозапускИсточник
""" # Фаервол firewall_section = "" firewall_raw = f.get("firewall_raw", "").strip() if firewall_raw: firewall_section = f"""
Фаервол
{firewall_raw}
""" # Зависимости хоста host_deps_section = "" if deps: dep_rows = [] for d in deps: from_link = f"{d['from_host']}" to_link = f"{d['to_host']}" from_svc = d.get("from_service") or "—" proto = d.get("protocol", "tcp") dst_port = f"{d['port']}/{proto}" if d.get("port") else "—" src_badge = "статич." if d["source"] == "static" else "авто" desc = d.get("description", "") dep_rows.append( f"" f"{from_link}" f"{from_svc}" f"{to_link}" f"{dst_port}" f"{src_badge}" f"{desc}" f"" ) col_heads = "ИсточникСлужба источникаНазначениеПорт назначенияТипОписание" dep_table = "" if dep_rows: dep_table = f"""
{col_heads}{''.join(dep_rows)}
""" host_mermaid = host_deps_mermaid(hostname, deps, ext_deps) diagram = "" if host_mermaid: diagram = f"""
{host_mermaid}
""" if diagram or dep_table: host_deps_section = f"""
Зависимости Все →
{diagram} {dep_table}""" elif ext_deps: host_mermaid = host_deps_mermaid(hostname, [], ext_deps) if host_mermaid: host_deps_section = f"""
Зависимости Все →
{host_mermaid}
""" # Внутрихостовые зависимости host_intra_deps_section = "" if intra_deps: intra_rows = [] for d in sorted(intra_deps, key=lambda x: (x["port"], x["client_service"])): client_svc = d.get("client_service") or "—" server_svc = d.get("server_service") or "—" port_badge = f"{d['port']}/tcp" intra_rows.append( f"" f"{client_svc}" f"{server_svc}" f"{port_badge}" f"{d['count']}" f"" ) if intra_rows: host_intra_deps_section = f"""
Внутрихостовые зависимости {len(intra_rows)} Все →
{''.join(intra_rows)}
Клиент (служба)Сервер (служба)ПортСоед.
""" # Внешние локальные подключения на странице хоста host_ext_local_section = "" host_ext_remote_section = "" if ext_deps: local_host_deps = [d for d in ext_deps if d["ext_type"] == "local"] if local_host_deps: local_groups: dict = {} for d in local_host_deps: key = (d["to_port"], d.get("proto", "tcp"), d["to_service"]) local_groups.setdefault(key, {}) local_groups[key][d["subnet"]] = local_groups[key].get(d["subnet"], 0) + d["count"] local_rows = [] for (to_port, proto, to_svc), subnets in sorted(local_groups.items()): svc_cell = f"{to_svc}" if to_svc != "—" else "—" port_badge = f"{to_port}/{proto}" total = sum(subnets.values()) subnet_badges = " ".join( f"{sn}" f"×{cnt}" for sn, cnt in sorted(subnets.items()) ) local_rows.append( f"" f"{svc_cell}" f"{port_badge}" f"{subnet_badges}" f"{total}" f"" ) if local_rows: host_ext_local_section = f"""
Внешние локальные подключения {len(local_rows)} Все →
{''.join(local_rows)}
СлужбаПортИсточники (подсеть × кол-во)Всего
""" remote_host_deps = [d for d in ext_deps if d["ext_type"] == "remote"] if remote_host_deps: remote_groups: dict = {} for d in remote_host_deps: key = (d["to_port"], d.get("proto", "tcp"), d["to_service"]) remote_groups[key] = remote_groups.get(key, 0) + d["count"] remote_rows = [] for (to_port, proto, to_svc), total in sorted(remote_groups.items()): svc_cell = f"{to_svc}" if to_svc != "—" else "—" port_badge = f"{to_port}/{proto}" remote_rows.append( f"" f"{svc_cell}" f"{port_badge}" f"{total}" f"" ) if remote_rows: host_ext_remote_section = f"""
Внешние удалённые подключения {len(remote_rows)} Все →
{''.join(remote_rows)}
СлужбаПортВсего соединений
""" mermaid_script = "" if host_deps_section: mermaid_script = """ """ body = f"""

{hostname}

{cards} {ifaces_section} {mounts_section} {ports_section} {services_section} {firewall_section} {host_deps_section} {host_intra_deps_section} {host_ext_local_section} {host_ext_remote_section}""" return page(f"{hostname} — факты", body, depth=1, extra_scripts=mermaid_script) # ── Точка входа ──────────────────────────────────────────────────────────────── def main() -> None: if not FACTS_DIR.exists(): sys.exit(f"Ошибка: директория '{FACTS_DIR}' не найдена") hosts = load_facts(FACTS_DIR) if not hosts: sys.exit(f"Ошибка: в '{FACTS_DIR}' нет JSON-файлов") OUTPUT_DIR.mkdir(exist_ok=True) (OUTPUT_DIR / "hosts").mkdir(exist_ok=True) # Зависимости: статические + автоопределённые + внутрихостовые + внешние static_deps = load_service_map() ip_to_host = build_ip_to_host(hosts) auto_deps = detect_auto_deps(hosts, ip_to_host) intra_deps = detect_intra_deps(hosts) ext_deps = detect_external_deps(hosts, ip_to_host) all_deps = static_deps + auto_deps global_services = load_global_services() (OUTPUT_DIR / "index.html").write_text(index_page(hosts, static_deps, auto_deps, ext_deps, intra_deps, global_services), encoding="utf-8") print(f"index.html — {len(hosts)} хостов") for hostname, facts in hosts.items(): host_deps = [d for d in all_deps if d["from_host"] == hostname or d["to_host"] == hostname] host_intra = [d for d in intra_deps if d["host"] == hostname] host_ext = [d for d in ext_deps if d["to_host"] == hostname] out = OUTPUT_DIR / "hosts" / f"{hostname}.html" out.write_text(host_page(hostname, facts, host_deps, host_intra, host_ext), encoding="utf-8") print(f" hosts/{hostname}.html") vmw = load_vmware_facts() if vmw["esxi_hosts"] or vmw["vms"] or vmw["datastores"]: (OUTPUT_DIR / "vmware.html").write_text(vmware_page(vmw), encoding="utf-8") print(f"vmware.html — {len(vmw['esxi_hosts'])} ESXi, {len(vmw['vms'])} VM, {len(vmw['datastores'])} датасторов") else: print("vmware.html — пропущено (нет данных в vmware_facts/)") containers_exist = any(parse_docker_containers(f) for f in hosts.values()) if containers_exist: (OUTPUT_DIR / "containers.html").write_text(docker_page(hosts), encoding="utf-8") total = sum(len(parse_docker_containers(f)) for f in hosts.values()) print(f"containers.html — {total} контейнеров") else: print("containers.html — пропущено (нет данных о контейнерах)") ext_local = sum(1 for d in ext_deps if d["ext_type"] == "local") ext_remote = sum(1 for d in ext_deps if d["ext_type"] == "remote") (OUTPUT_DIR / "dependencies.html").write_text(deps_page(static_deps, auto_deps, ext_deps, intra_deps), encoding="utf-8") print(f"dependencies.html — {len(static_deps)} статических, {len(auto_deps)} авто, {len(intra_deps)} внутрихостовых, {ext_local} внешних локальных, {ext_remote} внешних удалённых") (OUTPUT_DIR / "external_connections.html").write_text(external_page(ext_deps), encoding="utf-8") ext_hosts = len({d["to_host"] for d in ext_deps}) ext_svcs = len({(d["to_host"], d["to_service"]) for d in ext_deps}) print(f"external_connections.html — {ext_hosts} хостов, {ext_svcs} служб, {ext_local} локальных, {ext_remote} удалённых") (OUTPUT_DIR / "services").mkdir(exist_ok=True) (OUTPUT_DIR / "services" / "index.html").write_text( services_index_page(global_services, hosts, all_deps), encoding="utf-8" ) for svc in global_services: out = OUTPUT_DIR / "services" / f"{svc['slug']}.html" out.write_text(service_detail_page(svc, hosts, all_deps), encoding="utf-8") print(f" services/{svc['slug']}.html") print(f"services/index.html — {len(global_services)} сервисов") print(f"\nГотово → {OUTPUT_DIR}/") if __name__ == "__main__": main()