#!/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 "" 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())