Files
KindClustersDashboard/scripts/setup_env_interactive.py
Sergey Antropoff eb063aec20 Веб-интерфейс: страница /clusters, навигация и крошки для кластеров
- Выделена страница списка кластеров, панель упрощена; nav_active и крошки
  ведут в раздел Кластеры; theme.js синхронизирует активную пилюлю по URL.
- Доработки дашборда, аддонов, журнала, стилей и API-документации.
- Поддержка Podman: docker-compose.podman.yml, скрипты сокета; Makefile и env.
2026-04-04 13:42:21 +03:00

539 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Интерактивное создание ``.env`` в корне репозитория Kind Clusters Dashboard.
Список переменных и подсказок задаётся в этом файле (не используется env.example).
В начале мастера выбирается только **docker** или **podman** — путь ``CONTAINER_SOCKET`` подставляется автоматически.
Дефолты совпадают с ``docker-compose.yml`` / приложением; Enter — записать предложенное значение.
Запуск: ``python3 scripts/setup_env_interactive.py`` или ``make setup``.
Опции: ``--output`` / ``-f`` (перезапись без вопроса).
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
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
logger = logging.getLogger("setup_env_interactive")
# Корень репозитория Kind Clusters Dashboard (родитель каталога scripts/)
REPO_ROOT = Path(__file__).resolve().parents[1]
# Значения по умолчанию при нажатии Enter (как в docker-compose / Dockerfile / Settings).
# KUBECTL_VERSION: в Dockerfile пустой build-arg = stable.txt; здесь — закреплённый тег для воспроизводимости.
_SETUP_DEFAULTS: dict[str, str] = {
"KIND_VERSION": "0.24.0",
"KUBECTL_VERSION": "v1.32.0",
"CONTAINER_SOCKET": "/var/run/docker.sock",
"KIND_K8S_WEB_PORT": "8080",
"KIND_K8S_PATCH_KUBECONFIG": "1",
"CONTAINER_CLI": "docker",
"KIND_K8S_SKIP_VERSION_LIST": "",
"KIND_K8S_VERSION_LIST_DISPLAY": "50",
"KIND_K8S_HUB_TAGS_MAX_PAGES": "120",
"KIND_K8S_DEBUG": "",
"KIND_K8S_WAIT_NODES": "1",
"KIND_K8S_WAIT_NODES_TIMEOUT_SEC": "300",
"KIND_K8S_APP_TITLE": "Kind Clusters Dashboard",
"KIND_K8S_README_PATH": "",
"KIND_K8S_UVICORN_RELOAD": "1",
"KIND_K8S_WEB_HOST": "0.0.0.0",
"KIND_K8S_WORKDIR": "",
"KIND_K8S_JOBS_JSON": "",
"COMPOSE_BUILD_FLAGS": "",
}
# Переменные, для которых по вводу «g» генерируется секрет (на будущее / другие проекты).
_SECRET_KEYS = frozenset(
{
"SECRET_KEY",
"CONNECTORS_ENCRYPTION_KEY",
"K8S_KUBECONFIG_ENCRYPTION_KEY",
"POSTGRES_PASSWORD",
"MINIO_SECRET_KEY",
"ROOT_PASSWORD",
},
)
@dataclass(frozen=True)
class _EnvPrompt:
"""Одна переменная в мастере setup: опциональный заголовок секции в .env и текст помощи."""
section_comment: str | None
key: str
help_ru: str
# Порядок опроса и человекочитаемые пояснения (секции — комментарии в записанном .env).
_SETUP_PROMPTS: tuple[_EnvPrompt, ...] = (
_EnvPrompt(
"# --- docker-compose.yml: build-args (образ kind-k8s-tools:local) ---",
"KIND_VERSION",
"Версия бинарника kind при сборке образа (build-arg KIND_VERSION).",
),
_EnvPrompt(
None,
"KUBECTL_VERSION",
"Версия kubectl в образе (build-arg). Пустая строка в .env при ручном вводе возможна; "
"дефолт скрипта — закреплённый тег (в Dockerfile без аргумента берётся stable.txt).",
),
_EnvPrompt(
"# --- docker-compose.yml: сокет + CONTAINER_CLI + user/HOME (доступ к сокету rootless Podman) ---",
"CONTAINER_SOCKET",
"",
),
_EnvPrompt(
"# --- docker-compose.yml: публикация веб-UI (хост:контейнер → …:6000 внутри) ---",
"KIND_K8S_WEB_PORT",
"Порт на хосте для браузера (дефолт 8080 — 6000 на хосте блокирует Chrome как ERR_UNSAFE_PORT); внутри контейнера uvicorn слушает 6000.",
),
_EnvPrompt(
"# --- docker-compose.yml: environment сервиса kind-k8s-web ---",
"KIND_K8S_PATCH_KUBECONFIG",
"1/true/yes — после create писать clusters/<имя>/kubeconfig.host (server с KIND_K8S_KUBECONFIG_CLIENT_HOST, иначе localhost); "
"основной kubeconfig — как у kind. 0 или пусто — не форсировать. По умолчанию в скрипте: 1 (включено).",
),
_EnvPrompt(
None,
"KIND_K8S_KUBECONFIG_CLIENT_HOST",
"Имя хоста в server= для скачиваемого kubeconfig (kubectl на машине пользователя). Пусто — localhost (лучше 127.0.0.1 с TLS kind). Для доступа с другой машины — IP/имя хоста Docker.",
),
_EnvPrompt(
None,
"KIND_K8S_SKIP_VERSION_LIST",
"1 — не запрашивать теги kindest/node с Docker Hub (изолированная сеть); иначе пусто.",
),
_EnvPrompt(
None,
"KIND_K8S_VERSION_LIST_DISPLAY",
"Сколько строк показывать в интерактивном CLI при выборе версии (веб-UI отдаёт полный список).",
),
_EnvPrompt(
None,
"KIND_K8S_HUB_TAGS_MAX_PAGES",
"Лимит страниц Docker Hub API при сборе тегов (по умолчанию в коде 120, макс. 500); больше страниц — больше старых семверов, дольше запрос.",
),
_EnvPrompt(None, "KIND_K8S_DEBUG", "1/true/yes — уровень DEBUG в логах приложения; иначе пусто (INFO)."),
_EnvPrompt(
None,
"KIND_K8S_WAIT_NODES",
"1 — после kind create ждать Ready нод через kubectl wait; 0 — не ждать.",
),
_EnvPrompt(None, "KIND_K8S_WAIT_NODES_TIMEOUT_SEC", "Таймаут kubectl wait (секунды)."),
_EnvPrompt(None, "KIND_K8S_APP_TITLE", "Заголовок OpenAPI и веб-интерфейса."),
_EnvPrompt(
None,
"KIND_K8S_JOBS_JSON",
"Только файл глобального снимка заданий (очередь и 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,
"KIND_K8S_README_PATH",
"Путь к README.md для страницы /documentation; пусто — README рядом с app/ (в образе /opt/kind-k8s/README.md).",
),
_EnvPrompt(
None,
"KIND_K8S_UVICORN_RELOAD",
"1 — uvicorn --reload (правки в ./app без пересборки образа); 0 — один процесс без reload.",
),
_EnvPrompt(
"# --- pydantic Settings: KIND_K8S_WEB_HOST (в compose не передаётся; см. run_uvicorn.sh) ---",
"KIND_K8S_WEB_HOST",
"Хост привязки uvicorn при локальном запуске вне compose (в контейнере задаётся скриптом).",
),
_EnvPrompt(
"# --- Только локальный запуск app/*.py без docker-compose ---",
"KIND_K8S_WORKDIR",
"Корень данных на машине разработчика (в compose в контейнере задан /work литералом).",
),
_EnvPrompt(
"# --- Только Makefile (не переменные окружения compose) ---",
"COMPOSE_BUILD_FLAGS",
"Доп. флаги для «make docker build», например --platform linux/arm64.",
),
)
# Путь к сокету 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:
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
def _default_for_key(key: str) -> str:
"""Значение по умолчанию для ключа (только словарь скрипта)."""
return _SETUP_DEFAULTS.get(key, "")
def _ask_line(
key: str,
default: str,
*,
help_ru: str,
collected: dict[str, str],
) -> str | None:
"""
Спросить одну переменную.
Возвращает ``None``, если строку в ``.env`` не записывать (пропуск).
"""
extra = ""
if key in _SECRET_KEYS:
extra = " [g] — сгенерировать случайное значение"
hint = default.replace("\n", " ")[:72]
if len(default) > 72:
hint += ""
if not default:
hint = "пустая строка"
print(f"\n{key}")
print(f" {help_ru}")
print(f" По умолчанию: {hint!r}{extra}")
print(" Enter — подставить это значение в .env; - или «пропустить» — не добавлять строку")
raw = input(" Значение: ").strip()
if raw in ("-", "пропустить", "skip"):
return None
if not raw:
return default
if raw.lower() == "g" and key in _SECRET_KEYS:
generated = secrets.token_urlsafe(32)
print(f" Сгенерировано ({len(generated)} символов).")
return generated
return raw
def _default_database_url(collected: dict[str, str]) -> str | None:
"""Сборка DATABASE_URL из POSTGRES_* (если такие ключи появятся в мастере)."""
u = collected.get("POSTGRES_USER")
p = collected.get("POSTGRES_PASSWORD")
d = collected.get("POSTGRES_DB")
if u is not None and p is not None and d is not None:
return f"postgresql://{u}:{p}@postgres:5432/{d}"
return None
def run(*, output: Path, force: bool = False) -> int:
output = output.resolve()
if output.exists() and not force:
print(f"Файл уже существует: {output}")
ans = input("Перезаписать? [y/N]: ").strip().lower()
if ans not in ("y", "yes", "д", "да"):
print("Выход без изменений.")
return 0
print(
"\nИнтерактивное заполнение .env (список переменных в scripts/setup_env_interactive.py).\n"
"Enter без ввода — записать значение по умолчанию из подсказки.\n",
)
engine_cli, engine_sock = ask_container_engine()
collected: dict[str, str] = {}
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)
if built is not None:
default = built
print("\n--- DATABASE_URL: собрано из POSTGRES_* ---")
value = _ask_line(spec.key, default, help_ru=spec.help_ru, collected=collected)
if value is None:
logger.debug("Пропуск переменной %s", spec.key)
continue
collected[spec.key] = value
out_chunks.append(f"{spec.key}={value}\n")
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text("".join(out_chunks), encoding="utf-8")
print(f"\nГотово: записан файл {output}")
return 0
def main() -> None:
_configure_logging()
parser = argparse.ArgumentParser(
description="Интерактивное заполнение .env для Kind Clusters Dashboard (переменные задаются в этом скрипте)",
)
parser.add_argument(
"--output",
type=Path,
default=REPO_ROOT / ".env",
help="Куда записать .env (по умолчанию: .env в корне репозитория)",
)
parser.add_argument("-f", "--force", action="store_true", help="Не спрашивать подтверждение перезаписи")
args = parser.parse_args()
raise SystemExit(run(output=args.output, force=args.force))
if __name__ == "__main__":
main()