Веб-интерфейс: страница /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
+185
View File
@@ -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()
+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())
+256 -12
View File
@@ -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)