Веб-интерфейс: страница /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
+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)