Веб-интерфейс: страница /clusters, навигация и крошки для кластеров
- Выделена страница списка кластеров, панель упрощена; nav_active и крошки ведут в раздел Кластеры; theme.js синхронизирует активную пилюлю по URL. - Доработки дашборда, аддонов, журнала, стилей и API-документации. - Поддержка Podman: docker-compose.podman.yml, скрипты сокета; Makefile и env.
This commit is contained in:
@@ -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