- Выделена страница списка кластеров, панель упрощена; nav_active и крошки ведут в раздел Кластеры; theme.js синхронизирует активную пилюлю по URL. - Доработки дашборда, аддонов, журнала, стилей и API-документации. - Поддержка Podman: docker-compose.podman.yml, скрипты сокета; Makefile и env.
315 lines
11 KiB
Python
315 lines
11 KiB
Python
#!/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())
|