Веб-интерфейс: страница /clusters, навигация и крошки для кластеров
- Выделена страница списка кластеров, панель упрощена; nav_active и крошки ведут в раздел Кластеры; theme.js синхронизирует активную пилюлю по URL. - Доработки дашборда, аддонов, журнала, стилей и API-документации. - Поддержка Podman: docker-compose.podman.yml, скрипты сокета; Makefile и env.
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Печатает в stdout путь к ``podman.sock`` или ``uid:gid`` владельца сокета.
|
||||
|
||||
Режимы:
|
||||
|
||||
* без аргументов — путь (для ``$(shell ...)`` в Makefile);
|
||||
* ``--print-owner`` — ``uid:gid`` по ``stat`` найденного сокета (для ``KIND_K8S_CONTAINER_UIDGID``).
|
||||
|
||||
Логика пути: ``podman info`` (схема ``unix://`` снимается), ``podman machine inspect`` (без путей
|
||||
в ``/var/folders/…`` на macOS — там API-сокет, **compose не смонтирует**: ``operation not supported``),
|
||||
стабильные ``*.sock`` под ``~/.local/share/containers/podman/machine/``, затем типичные пути пользователя.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _compose_cannot_bind_mount_socket(path_str: str) -> bool:
|
||||
"""
|
||||
На macOS сокет в ``/var/folders/…/T/podman/`` (например ``*-api.sock`` из machine inspect)
|
||||
существует на диске, но bind-mount в контейнер через compose часто даёт
|
||||
``mkdir … operation not supported`` — такой путь в ``CONTAINER_SOCKET`` использовать нельзя.
|
||||
"""
|
||||
if sys.platform != "darwin":
|
||||
return False
|
||||
norm = os.path.normpath(path_str)
|
||||
return "/var/folders/" in norm
|
||||
|
||||
|
||||
def _strip_unix_scheme(raw: str) -> str:
|
||||
"""Путь на диске без префикса ``unix://`` / ``unix:`` (как в выводе ``podman info``)."""
|
||||
s = (raw or "").strip()
|
||||
if s.startswith("unix://"):
|
||||
return s[7:].strip() or s
|
||||
if s.startswith("unix:"):
|
||||
rest = s[5:].lstrip("/")
|
||||
return "/" + rest if rest else s
|
||||
return s
|
||||
|
||||
|
||||
def _podman_machine_socket_paths(podman_bin: str) -> list[str]:
|
||||
"""
|
||||
Пути к сокету на **хосте** из ``podman machine inspect`` (актуально для macOS/Windows).
|
||||
|
||||
На Linux без machine обычно возвращается ``[]``.
|
||||
"""
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[podman_bin, "machine", "inspect"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=20,
|
||||
)
|
||||
if proc.returncode != 0 or not (proc.stdout or "").strip():
|
||||
return []
|
||||
data = json.loads(proc.stdout)
|
||||
if not isinstance(data, list):
|
||||
return []
|
||||
out: list[str] = []
|
||||
for m in data:
|
||||
if not isinstance(m, dict):
|
||||
continue
|
||||
ci = m.get("ConnectionInfo") or {}
|
||||
if not isinstance(ci, dict):
|
||||
continue
|
||||
ps = ci.get("PodmanSocket")
|
||||
pth: str | None = None
|
||||
if isinstance(ps, dict):
|
||||
raw = ps.get("Path")
|
||||
pth = str(raw).strip() if raw else None
|
||||
elif isinstance(ps, str) and ps.strip():
|
||||
pth = ps.strip()
|
||||
if pth:
|
||||
cleaned = _strip_unix_scheme(pth)
|
||||
if not _compose_cannot_bind_mount_socket(cleaned):
|
||||
out.append(cleaned)
|
||||
return out
|
||||
except (OSError, subprocess.TimeoutExpired, json.JSONDecodeError, TypeError, ValueError):
|
||||
return []
|
||||
|
||||
|
||||
def _darwin_stable_machine_sockets() -> list[str]:
|
||||
"""Подкаталог machine на macOS: сокеты вне временного ``/var/folders``."""
|
||||
base = Path.home() / ".local/share/containers/podman/machine"
|
||||
if sys.platform != "darwin" or not base.is_dir():
|
||||
return []
|
||||
out: list[str] = []
|
||||
try:
|
||||
for p in sorted(base.rglob("*.sock"), key=lambda x: (len(x.parts), str(x))):
|
||||
s = str(p)
|
||||
if _compose_cannot_bind_mount_socket(s):
|
||||
continue
|
||||
out.append(s)
|
||||
except OSError:
|
||||
return []
|
||||
return out
|
||||
|
||||
|
||||
def _podman_socket_path_for_user() -> 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 podman_socket_owner_uidgid() -> str:
|
||||
"""
|
||||
UID:GID владельца найденного ``podman.sock`` на диске (``stat``).
|
||||
|
||||
Нужен для ``user:`` в compose: при Podman на macOS и в VM владелец сокета часто
|
||||
**не совпадает** с ``id -u`` / ``id -g`` процесса ``make`` на хосте — иначе
|
||||
``permission denied`` на смонтированный сокет внутри контейнера.
|
||||
Если сокет недоступен — fallback на UID:GID текущего процесса.
|
||||
"""
|
||||
path_str = detect_podman_socket_path()
|
||||
p = Path(path_str)
|
||||
try:
|
||||
if p.is_socket():
|
||||
st = p.stat()
|
||||
return f"{st.st_uid}:{st.st_gid}"
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
return f"{os.getuid()}:{os.getgid()}"
|
||||
except AttributeError:
|
||||
return "1000:1000"
|
||||
|
||||
|
||||
def detect_podman_socket_path() -> str:
|
||||
"""Путь к существующему сокету или типичный путь по умолчанию."""
|
||||
cands: list[str] = []
|
||||
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()
|
||||
candidate = (line[0] if line else "").strip()
|
||||
if candidate and "<no value>" not in candidate.lower():
|
||||
cands.append(_strip_unix_scheme(candidate))
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
# Реальный файл на хосте (без macOS temp api.sock — см. _compose_cannot_bind_mount_socket).
|
||||
cands.extend(_podman_machine_socket_paths(podman_bin))
|
||||
cands.extend(_darwin_stable_machine_sockets())
|
||||
cands.append(_podman_socket_path_for_user())
|
||||
for path in cands:
|
||||
cleaned = _strip_unix_scheme(path)
|
||||
if _compose_cannot_bind_mount_socket(cleaned):
|
||||
continue
|
||||
p = Path(cleaned).expanduser()
|
||||
try:
|
||||
if p.is_socket():
|
||||
return str(p.resolve())
|
||||
except OSError:
|
||||
continue
|
||||
return str(Path(_strip_unix_scheme(_podman_socket_path_for_user())).expanduser())
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if len(sys.argv) > 1 and sys.argv[1] == "--print-owner":
|
||||
sys.stdout.write(podman_socket_owner_uidgid())
|
||||
return
|
||||
sys.stdout.write(detect_podman_socket_path())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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())
|
||||
@@ -2,6 +2,7 @@
|
||||
"""Интерактивное создание ``.env`` в корне репозитория Kind Clusters Dashboard.
|
||||
|
||||
Список переменных и подсказок задаётся в этом файле (не используется env.example).
|
||||
В начале мастера выбирается только **docker** или **podman** — путь ``CONTAINER_SOCKET`` подставляется автоматически.
|
||||
Дефолты совпадают с ``docker-compose.yml`` / приложением; Enter — записать предложенное значение.
|
||||
|
||||
Запуск: ``python3 scripts/setup_env_interactive.py`` или ``make setup``.
|
||||
@@ -16,7 +17,11 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
@@ -85,9 +90,9 @@ _SETUP_PROMPTS: tuple[_EnvPrompt, ...] = (
|
||||
"дефолт скрипта — закреплённый тег (в Dockerfile без аргумента берётся stable.txt).",
|
||||
),
|
||||
_EnvPrompt(
|
||||
"# --- docker-compose.yml: том сокета (Docker / Podman rootless) ---",
|
||||
"# --- docker-compose.yml: сокет + CONTAINER_CLI + user/HOME (доступ к сокету rootless Podman) ---",
|
||||
"CONTAINER_SOCKET",
|
||||
"Путь к сокету на хосте для volume (Docker: /var/run/docker.sock; Podman rootless — см. README).",
|
||||
"",
|
||||
),
|
||||
_EnvPrompt(
|
||||
"# --- docker-compose.yml: публикация веб-UI (хост:контейнер → …:6000 внутри) ---",
|
||||
@@ -105,7 +110,6 @@ _SETUP_PROMPTS: tuple[_EnvPrompt, ...] = (
|
||||
"KIND_K8S_KUBECONFIG_CLIENT_HOST",
|
||||
"Имя хоста в server= для скачиваемого kubeconfig (kubectl на машине пользователя). Пусто — localhost (лучше 127.0.0.1 с TLS kind). Для доступа с другой машины — IP/имя хоста Docker.",
|
||||
),
|
||||
_EnvPrompt(None, "CONTAINER_CLI", "Имя CLI для вызовов к движку контейнеров: docker или podman."),
|
||||
_EnvPrompt(
|
||||
None,
|
||||
"KIND_K8S_SKIP_VERSION_LIST",
|
||||
@@ -132,7 +136,12 @@ _SETUP_PROMPTS: tuple[_EnvPrompt, ...] = (
|
||||
_EnvPrompt(
|
||||
None,
|
||||
"KIND_K8S_JOBS_JSON",
|
||||
"Путь к JSON с историей заданий (создание/старт/стоп кластера); пусто — файл kind_k8s_jobs.json в каталоге clusters/ на томе данных.",
|
||||
"Только файл глобального снимка заданий (очередь и running; после перезапуска контейнера). "
|
||||
"Пусто — <корень_данных>/clusters/kind_k8s_jobs.json (корень = KIND_K8S_WORKDIR; в compose внутри контейнера /work, "
|
||||
"на хосте тот же том — обычно ./clusters/kind_k8s_jobs.json). "
|
||||
"Архив завершённых операций и страница «Журнал» — clusters/<имя>/journal/jobs_history.json; "
|
||||
"полный лог create/start/reapply — clusters/<имя>/provision_log.json; история Helm-аддонов — "
|
||||
"clusters/<имя>/helm_addon_log.json — эти пути KIND_K8S_JOBS_JSON не переопределяет.",
|
||||
),
|
||||
_EnvPrompt(
|
||||
None,
|
||||
@@ -161,14 +170,239 @@ _SETUP_PROMPTS: tuple[_EnvPrompt, ...] = (
|
||||
),
|
||||
)
|
||||
|
||||
_ENV_FILE_HEADER = """# Файл .env для Kind Clusters Dashboard
|
||||
# Создан интерактивно: scripts/setup_env_interactive.py
|
||||
#
|
||||
# В docker-compose.yml заданы литералами (не из этого файла):
|
||||
# DOCKER_HOST, KIND_K8S_IN_CONTAINER, KIND_K8S_WORKDIR=/work в контейнере.
|
||||
#
|
||||
# Путь к сокету Docker в примерах и значение по умолчанию для CONTAINER_SOCKET (не подставляется из UID).
|
||||
_DOCKER_SOCKET_EXAMPLE = "/var/run/docker.sock"
|
||||
|
||||
"""
|
||||
|
||||
def _process_uid() -> int | None:
|
||||
"""UID процесса (то же, что ``id -u`` для пользователя, запустившего скрипт)."""
|
||||
try:
|
||||
return int(os.getuid())
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
|
||||
def _podman_socket_path_for_user() -> str:
|
||||
"""
|
||||
Путь к podman.sock для подсказок: ``$XDG_RUNTIME_DIR/podman/podman.sock`` или
|
||||
``/run/user/<UID>/podman/podman.sock`` (UID — результат :func:`_process_uid`, иначе 1000).
|
||||
"""
|
||||
xdg = (os.environ.get("XDG_RUNTIME_DIR") or "").strip()
|
||||
if xdg:
|
||||
return f"{xdg.rstrip('/')}/podman/podman.sock"
|
||||
uid = _process_uid()
|
||||
if uid is not None:
|
||||
return f"/run/user/{uid}/podman/podman.sock"
|
||||
return "/run/user/1000/podman/podman.sock"
|
||||
|
||||
|
||||
def _unix_url_for_path(absolute_path: str) -> str:
|
||||
"""Ссылка вида ``unix:///…`` для абсолютного пути к сокету."""
|
||||
p = absolute_path if absolute_path.startswith("/") else f"/{absolute_path}"
|
||||
return f"unix://{p}"
|
||||
|
||||
|
||||
def _docker_host_unix_path_from_env() -> str | None:
|
||||
"""Путь из ``DOCKER_HOST=unix://…``, если задан."""
|
||||
dh = (os.environ.get("DOCKER_HOST") or "").strip()
|
||||
if not dh.startswith("unix://"):
|
||||
return None
|
||||
path = dh[7:]
|
||||
if path and not path.startswith("/"):
|
||||
path = "/" + path.lstrip("/")
|
||||
return path or None
|
||||
|
||||
|
||||
def _first_existing_socket(candidates: list[str]) -> str | None:
|
||||
"""Первый существующий unix-socket из списка путей."""
|
||||
for c in candidates:
|
||||
if not c:
|
||||
continue
|
||||
p = Path(c).expanduser()
|
||||
try:
|
||||
if p.is_socket():
|
||||
return str(p.resolve())
|
||||
except OSError:
|
||||
logger.debug("Пропуск кандидата сокета (ошибка доступа): %s", p, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def _detect_docker_socket_path() -> str:
|
||||
"""
|
||||
Авто-поиск сокета Docker на машине, где запущен мастер: ``DOCKER_HOST``, ``/var/run/docker.sock``,
|
||||
типичные пути Docker Desktop / Colima.
|
||||
"""
|
||||
cands: list[str] = []
|
||||
envp = _docker_host_unix_path_from_env()
|
||||
if envp:
|
||||
cands.append(envp)
|
||||
cands.append(_DOCKER_SOCKET_EXAMPLE)
|
||||
home = Path.home()
|
||||
cands.append(str(home / ".docker/run/docker.sock"))
|
||||
cands.append(str(home / ".colima/default/docker.sock"))
|
||||
found = _first_existing_socket(cands)
|
||||
if found:
|
||||
logger.info("Найден сокет Docker: %s", found)
|
||||
return found
|
||||
logger.info("Сокет Docker не найден на диске — подставляю стандартный путь %s", _DOCKER_SOCKET_EXAMPLE)
|
||||
return _DOCKER_SOCKET_EXAMPLE
|
||||
|
||||
|
||||
def _podman_uidgid_for_env() -> str:
|
||||
"""
|
||||
Строка ``uid:gid`` для ``KIND_K8S_CONTAINER_UIDGID`` при Podman.
|
||||
|
||||
Берётся владелец файла сокета (как у ``make podman …``), чтобы на macOS и в VM
|
||||
не расходилось с ``id -u`` / ``id -g`` интерактивного мастера.
|
||||
"""
|
||||
script = Path(__file__).resolve().parent / "detect_podman_socket.py"
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[sys.executable, str(script), "--print-owner"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=25,
|
||||
)
|
||||
if proc.returncode == 0:
|
||||
s = (proc.stdout or "").strip()
|
||||
parts = s.split(":", 1)
|
||||
if len(parts) == 2 and parts[0].isdigit() and parts[1].isdigit():
|
||||
return s
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
logger.debug("detect_podman_socket.py --print-owner недоступен", exc_info=True)
|
||||
try:
|
||||
return f"{os.getuid()}:{os.getgid()}"
|
||||
except AttributeError:
|
||||
return "1000:1000"
|
||||
|
||||
|
||||
def _detect_podman_socket_path() -> str:
|
||||
"""
|
||||
Путь к ``podman.sock`` — тот же алгоритм, что ``scripts/detect_podman_socket.py``
|
||||
(``podman info``, ``podman machine inspect`` на macOS, типичные пути).
|
||||
"""
|
||||
script = Path(__file__).resolve().parent / "detect_podman_socket.py"
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[sys.executable, str(script)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=35,
|
||||
)
|
||||
if proc.returncode == 0:
|
||||
line = (proc.stdout or "").strip().splitlines()
|
||||
candidate = (line[0] if line else "").strip()
|
||||
if candidate:
|
||||
logger.info("Путь к сокету Podman (detect_podman_socket.py): %s", candidate)
|
||||
return candidate
|
||||
except (OSError, subprocess.TimeoutExpired) as e:
|
||||
logger.debug("detect_podman_socket.py не удалось: %s", e)
|
||||
fallback = _podman_socket_path_for_user()
|
||||
logger.info("Сокет Podman не определён скриптом — подставляю типичный путь %s", fallback)
|
||||
return fallback
|
||||
|
||||
|
||||
def ask_container_engine() -> tuple[str, str]:
|
||||
"""Спросить только docker или podman; вернуть ``(CONTAINER_CLI, CONTAINER_SOCKET)`` с автоподбором пути."""
|
||||
print(
|
||||
"\n--- Движок контейнеров для compose ---\n"
|
||||
" Укажите, чем пользуетесь на этом компьютере. Путь к сокету (CONTAINER_SOCKET) подставится сам;\n"
|
||||
" CONTAINER_CLI в .env будет docker или podman (в образе веб-UI к сокету ходит клиент docker).\n",
|
||||
)
|
||||
pod_hint = _podman_socket_path_for_user()
|
||||
print(" 1 или d / docker — Docker")
|
||||
print(f" 2 или p / podman — Podman (типичный путь при отсутствии сокета: {pod_hint})")
|
||||
print(" Enter — вариант 1 (Docker)")
|
||||
raw = input(" Ваш выбор [1]: ").strip().lower()
|
||||
|
||||
if raw in ("", "1", "d", "docker"):
|
||||
cli, sock = "docker", _detect_docker_socket_path()
|
||||
elif raw in ("2", "p", "podman"):
|
||||
cli, sock = "podman", _detect_podman_socket_path()
|
||||
else:
|
||||
print(" Не распознано — принимаю Docker.")
|
||||
cli, sock = "docker", _detect_docker_socket_path()
|
||||
|
||||
warn = ""
|
||||
try:
|
||||
if not Path(sock).is_socket():
|
||||
warn = " Внимание: по этому пути сейчас нет socket-файла — проверьте, что движок запущен, или поправьте CONTAINER_SOCKET в .env."
|
||||
except OSError:
|
||||
warn = " Внимание: нет доступа к пути сокета — при необходимости поправьте CONTAINER_SOCKET в .env."
|
||||
if cli == "podman":
|
||||
ug = _podman_uidgid_for_env()
|
||||
print(
|
||||
f" Для rootless Podman в .env также будут записаны KIND_K8S_CONTAINER_UIDGID={ug} "
|
||||
"и KIND_K8S_CONTAINER_HOME=/tmp (иначе в контейнере будет permission denied на сокет).\n",
|
||||
)
|
||||
else:
|
||||
print(
|
||||
" Для Docker в .env будут KIND_K8S_CONTAINER_UIDGID=0:0 и KIND_K8S_CONTAINER_HOME=/root.\n",
|
||||
)
|
||||
print(f" В .env: CONTAINER_CLI={cli}, CONTAINER_SOCKET={sock}.{warn}\n")
|
||||
return cli, sock
|
||||
|
||||
|
||||
def _compose_user_env_lines(engine_cli: str) -> list[str]:
|
||||
"""
|
||||
Строки для .env: путь монтирования сокета и URI для ``DOCKER_HOST``, затем ``user``/``HOME``.
|
||||
|
||||
Для Podman сокет в контейнере — ``/run/podman/podman.sock``, для Docker — ``/var/run/docker.sock``
|
||||
(должно совпадать с ``KIND_K8S_REMOTE_SOCKET_URI``).
|
||||
"""
|
||||
el = engine_cli.strip().lower()
|
||||
block: list[str] = []
|
||||
if el == "podman":
|
||||
block.extend(
|
||||
[
|
||||
"# Том сокета внутри контейнера и URI API (не docker.sock — явно путь Podman)\n",
|
||||
"CONTAINER_SOCKET_MOUNT_TARGET=/run/podman/podman.sock\n",
|
||||
"KIND_K8S_REMOTE_SOCKET_URI=unix:///run/podman/podman.sock\n",
|
||||
],
|
||||
)
|
||||
else:
|
||||
block.extend(
|
||||
[
|
||||
"CONTAINER_SOCKET_MOUNT_TARGET=/var/run/docker.sock\n",
|
||||
"KIND_K8S_REMOTE_SOCKET_URI=unix:///var/run/docker.sock\n",
|
||||
],
|
||||
)
|
||||
block.append(
|
||||
"# KIND_K8S_CONTAINER_UIDGID — user: в compose; KIND_K8S_CONTAINER_HOME — переменная HOME в контейнере\n",
|
||||
)
|
||||
if el == "podman":
|
||||
ug = _podman_uidgid_for_env()
|
||||
block.append(f"KIND_K8S_CONTAINER_UIDGID={ug}\n")
|
||||
block.append("KIND_K8S_CONTAINER_HOME=/tmp\n")
|
||||
else:
|
||||
block.append("KIND_K8S_CONTAINER_UIDGID=0:0\n")
|
||||
block.append("KIND_K8S_CONTAINER_HOME=/root\n")
|
||||
return block
|
||||
|
||||
|
||||
def _build_env_file_header() -> str:
|
||||
"""Заголовок записываемого .env."""
|
||||
pod = _podman_socket_path_for_user()
|
||||
url = _unix_url_for_path(pod)
|
||||
uid = _process_uid()
|
||||
uid_line = (
|
||||
f"# UID пользователя, запустившего мастер (os.getuid / id -u): {uid}\n"
|
||||
if uid is not None
|
||||
else "# UID недоступен на этой ОС.\n"
|
||||
)
|
||||
return (
|
||||
"# Файл .env для Kind Clusters Dashboard\n"
|
||||
"# Создан интерактивно: scripts/setup_env_interactive.py\n"
|
||||
"#\n"
|
||||
"# В docker-compose.yml заданы литералами (не из этого файла): KIND_K8S_IN_CONTAINER, KIND_K8S_WORKDIR=/work.\n"
|
||||
"# DOCKER_HOST в контейнере берётся из KIND_K8S_REMOTE_SOCKET_URI (см. ниже).\n"
|
||||
"#\n"
|
||||
"# CONTAINER_SOCKET и CONTAINER_CLI — в мастере (только docker/podman); путь к сокету подбирается автоматически.\n"
|
||||
"# KIND_K8S_CONTAINER_UIDGID / KIND_K8S_CONTAINER_HOME — для доступа к сокету (Podman rootless: ваш uid:gid).\n"
|
||||
f"# Справочно, типичные пути: Docker — {_DOCKER_SOCKET_EXAMPLE}; Podman — {pod} (URL: {url}).\n"
|
||||
f"{uid_line}"
|
||||
"#\n\n"
|
||||
)
|
||||
|
||||
|
||||
def _configure_logging() -> None:
|
||||
@@ -247,13 +481,23 @@ def run(*, output: Path, force: bool = False) -> int:
|
||||
"Enter без ввода — записать значение по умолчанию из подсказки.\n",
|
||||
)
|
||||
|
||||
engine_cli, engine_sock = ask_container_engine()
|
||||
|
||||
collected: dict[str, str] = {}
|
||||
out_chunks: list[str] = [_ENV_FILE_HEADER]
|
||||
out_chunks: list[str] = [_build_env_file_header()]
|
||||
|
||||
for spec in _SETUP_PROMPTS:
|
||||
if spec.section_comment:
|
||||
out_chunks.append(f"{spec.section_comment}\n")
|
||||
|
||||
if spec.key == "CONTAINER_SOCKET":
|
||||
collected["CONTAINER_SOCKET"] = engine_sock
|
||||
collected["CONTAINER_CLI"] = engine_cli
|
||||
out_chunks.append(f"CONTAINER_SOCKET={engine_sock}\n")
|
||||
out_chunks.append(f"CONTAINER_CLI={engine_cli}\n")
|
||||
out_chunks.extend(_compose_user_env_lines(engine_cli))
|
||||
continue
|
||||
|
||||
default = _default_for_key(spec.key)
|
||||
if spec.key == "DATABASE_URL":
|
||||
built = _default_database_url(collected)
|
||||
|
||||
Reference in New Issue
Block a user