#!/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//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()