Files
KindClustersDashboard/scripts/probe_container_sockets.py
Sergey Antropoff eb063aec20 Веб-интерфейс: страница /clusters, навигация и крошки для кластеров
- Выделена страница списка кластеров, панель упрощена; nav_active и крошки
  ведут в раздел Кластеры; theme.js синхронизирует активную пилюлю по URL.
- Доработки дашборда, аддонов, журнала, стилей и API-документации.
- Поддержка Podman: docker-compose.podman.yml, скрипты сокета; Makefile и env.
2026-04-04 13:42:21 +03:00

315 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
Проверка Unix-сокетов Docker и Podman: есть ли доступ без EACCES и отвечает ли API (GET /_ping).
Печатает таблицу и рекомендуемый ``DOCKER_HOST`` для рабочих вариантов.
Запуск на хосте (не внутри контейнера веб-UI).
Важно: вывод ``podman info`` (RemoteSocket.Path) на macOS часто указывает путь **внутри VM**
Podman Machine (например ``/run/podman/...``) — на диске macOS такого файла нет. «Настоящий»
сокет для монтирования в compose — первый **существующий** ``*.sock`` из той же логики, что
``scripts/detect_podman_socket.py`` (как у ``make podman``).
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
from __future__ import annotations
import argparse
import importlib.util
import logging
import os
import shutil
import socket
import stat
import subprocess
import sys
from pathlib import Path
from typing import Iterable
logger = logging.getLogger(__name__)
# Минимальный запрос к Docker-compatible API (Podman отвечает так же).
_PING_REQUEST = b"GET /_ping HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"
def _docker_host_unix_from_env() -> str | None:
raw = (os.environ.get("DOCKER_HOST") or "").strip()
if raw.startswith("unix://"):
return raw[len("unix://") :].strip() or None
return None
def _normalize_socket_path(raw: str) -> str:
"""
Путь к сокету на диске: убираем префикс ``unix://`` / ``unix:`` (как в выводе ``podman info``).
"""
p = (raw or "").strip()
if p.startswith("unix://"):
p = p[7:]
elif p.startswith("unix:"):
rest = p[5:].lstrip("/")
p = "/" + rest if rest else p
return p.strip() or raw.strip()
def _detect_podman_socket_path_via_project_script() -> str | None:
"""
Тот же поиск «живого» сокета на **этой** ФС, что и ``make podman`` / compose.
Возвращает ``None``, если модуль не загрузился.
"""
script_path = Path(__file__).resolve().parent / "detect_podman_socket.py"
try:
spec = importlib.util.spec_from_file_location("_kind_k8s_detect_podman", script_path)
if spec is None or spec.loader is None:
return None
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
fn = getattr(mod, "detect_podman_socket_path", None)
if callable(fn):
return str(fn())
except Exception as e:
logger.debug("detect_podman_socket.py: %s", e)
return None
def _podman_default_path() -> str:
xdg = (os.environ.get("XDG_RUNTIME_DIR") or "").strip()
if xdg:
return f"{xdg.rstrip('/')}/podman/podman.sock"
try:
return f"/run/user/{os.getuid()}/podman/podman.sock"
except AttributeError:
return "/run/user/1000/podman/podman.sock"
def _collect_candidates() -> list[tuple[str, str]]:
"""
Список (метка, путь). Порядок: переменная окружения, CLI, стандартные пути.
Дубликаты путей убираем, метки склеиваем через « + ».
"""
# Ключ — нормализованный путь; для несуществующих файлов не вызываем resolve().
buckets: dict[str, tuple[list[str], str]] = {}
order_keys: list[str] = []
def add(label: str, path: str | None) -> None:
if not path:
return
expanded = str(Path(_normalize_socket_path(path)).expanduser())
key = os.path.normpath(expanded)
if key not in buckets:
order_keys.append(key)
buckets[key] = ([label], expanded)
else:
buckets[key][0].append(label)
env_u = _docker_host_unix_from_env()
if env_u:
add("DOCKER_HOST", env_u)
# Реальный путь к файлу сокета на хосте (не «логический» путь из VM в podman info).
det = _detect_podman_socket_path_via_project_script()
if det:
add("хост: detect_podman_socket.py (как make podman)", det)
podman_bin = shutil.which("podman")
if podman_bin:
try:
proc = subprocess.run(
[podman_bin, "info", "-f", "{{.Host.RemoteSocket.Path}}"],
capture_output=True,
text=True,
timeout=15,
)
if proc.returncode == 0:
line = (proc.stdout or "").strip().splitlines()
cand = (line[0] if line else "").strip()
if cand and "<no value>" not in cand.lower():
add("podman info", cand)
except (OSError, subprocess.TimeoutExpired) as e:
logger.debug("podman info: %s", e)
docker_bin = shutil.which("docker")
if docker_bin:
try:
proc = subprocess.run(
[docker_bin, "context", "inspect", "--format", "{{.Endpoints.docker.Host}}"],
capture_output=True,
text=True,
timeout=15,
)
if proc.returncode == 0:
host = (proc.stdout or "").strip().splitlines()
h = (host[0] if host else "").strip()
if h.startswith("unix://"):
add("docker context", h[len("unix://") :])
except (OSError, subprocess.TimeoutExpired) as e:
logger.debug("docker context: %s", e)
add("стандарт Docker", "/var/run/docker.sock")
home = Path.home()
add("Docker Desktop / Linux", str(home / ".docker/run/docker.sock"))
add("Colima", str(home / ".colima/default/docker.sock"))
add("Podman типичный", _podman_default_path())
return [(" + ".join(buckets[k][0]), buckets[k][1]) for k in order_keys]
def _stat_line(path: Path) -> str:
try:
if not path.exists():
return "нет файла"
if not path.is_socket():
return "не сокет"
st = path.stat()
mode = stat.filemode(st.st_mode)
return f"{mode} uid={st.st_uid} gid={st.st_gid}"
except OSError as e:
return f"stat: {e}"
def _probe_http_ping(sock_path: Path, timeout_sec: float) -> tuple[str, str]:
"""Статус: ok | denied | conn_err | bad_reply | missing."""
try:
if not sock_path.exists():
return "missing", "файл отсутствует"
if not sock_path.is_socket():
return "missing", "не Unix-сокет"
except OSError as e:
return "missing", str(e)
try:
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.settimeout(timeout_sec)
sock.connect(os.fspath(sock_path))
sock.sendall(_PING_REQUEST)
chunks: list[bytes] = []
while True:
part = sock.recv(4096)
if not part:
break
chunks.append(part)
if len(chunks) > 8: # достаточно для заголовков + OK
break
sock.close()
except PermissionError:
return "denied", "EACCES при connect (как у docker info)"
except OSError as e:
return "conn_err", str(e)
raw = b"".join(chunks)
if b" 200 " in raw and b"OK" in raw:
return "ok", "HTTP 200, тело OK"
head = raw[: min(120, len(raw))].decode("utf-8", errors="replace")
return "bad_reply", head.replace("\r", " ").replace("\n", " ")
def _probe_docker_cli(unix_path: str, timeout_sec: int) -> tuple[str, str]:
docker = shutil.which("docker")
if not docker:
return "skip", "docker CLI нет в PATH"
uri = f"unix://{unix_path}"
try:
proc = subprocess.run(
[docker, "-H", uri, "info"],
capture_output=True,
text=True,
timeout=timeout_sec,
)
if proc.returncode == 0:
return "ok", "docker -H … info → 0"
err = (proc.stderr or proc.stdout or "").strip()[:200]
if "permission denied" in err.lower() or "eacces" in err.lower():
return "denied", err
return "cli_err", err or f"код {proc.returncode}"
except subprocess.TimeoutExpired:
return "cli_err", "timeout"
except OSError as e:
return "cli_err", str(e)
def _run(candidates: Iterable[tuple[str, str]], timeout: float, with_docker_cli: bool) -> int:
rows: list[tuple[str, str, str, str, str]] = []
working: list[str] = []
for label, path_str in candidates:
p = Path(path_str).expanduser()
st = _stat_line(p)
http_st, http_msg = _probe_http_ping(p, timeout_sec=timeout)
cli_st, cli_msg = ("", "")
if with_docker_cli:
cli_st, cli_msg = _probe_docker_cli(path_str, timeout_sec=int(timeout) + 5)
rows.append((label, path_str, st, f"{http_st}: {http_msg}", f"{cli_st}: {cli_msg}"))
if http_st == "ok":
working.append(f"unix://{path_str}")
w = 22
print(
f"{'Источник':<{w}} {'Путь':<40} {'Файл':<28} {'HTTP /_ping':<35} {'docker CLI':<30}",
)
print("-" * 145)
for label, path_str, st, http_col, cli_col in rows:
p_short = path_str if len(path_str) <= 40 else path_str[:37] + "..."
print(f"{label:<{w}} {p_short:<40} {st:<28} {http_col:<35} {cli_col:<30}")
me = ""
try:
me = f"{os.getuid()}:{os.getgid()}"
except AttributeError:
me = "?"
print()
print(f"Текущий процесс: uid:gid = {me}")
if working:
print("\nРабочие URI (HTTP /_ping OK), можно задать:")
for u in working:
print(f" export DOCKER_HOST={u!r}")
print("\nДля compose этого проекта см. CONTAINER_SOCKET / KIND_K8S_REMOTE_SOCKET_URI в .env.")
return 0
print(
"\nНи один сокет не ответил на /_ping без отказа. "
"Проверьте, что Docker Desktop / Podman machine запущены; для Podman на macOS часто помогает "
"запуск веб-UI от 0:0 (см. Makefile, make podman) или нативный «podman compose».",
)
return 1
def main() -> int:
parser = argparse.ArgumentParser(
description="Проверка сокетов Docker/Podman: доступ к API без permission denied.",
)
parser.add_argument(
"-t",
"--timeout",
type=float,
default=5.0,
help="Таймаут connect/read для сокета (сек)",
)
parser.add_argument(
"--no-docker-cli",
action="store_true",
help="Не вызывать «docker -H … info»",
)
parser.add_argument(
"-v",
"--verbose",
action="store_true",
help="Подробный журнал",
)
args = parser.parse_args()
logging.basicConfig(level=logging.DEBUG if args.verbose else logging.WARNING)
cands = _collect_candidates()
if not cands:
print("Нет кандидатов путей.", file=sys.stderr)
return 2
return _run(cands, timeout=args.timeout, with_docker_cli=not args.no_docker_cli)
if __name__ == "__main__":
raise SystemExit(main())