Веб-интерфейс: страница /clusters, навигация и крошки для кластеров

- Выделена страница списка кластеров, панель упрощена; nav_active и крошки
  ведут в раздел Кластеры; theme.js синхронизирует активную пилюлю по URL.
- Доработки дашборда, аддонов, журнала, стилей и API-документации.
- Поддержка Podman: docker-compose.podman.yml, скрипты сокета; Makefile и env.
This commit is contained in:
2026-04-04 13:42:21 +03:00
parent d0933510b5
commit a5d75aa56b
38 changed files with 3990 additions and 734 deletions
+314
View File
@@ -0,0 +1,314 @@
#!/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())