Веб-UI FastAPI, REST API v1, интерактивный setup без env.example

- Дашборд (Jinja2 + static), управление кластерами kind, задания и kubeconfig.
- API: health, stats, clusters CRUD, versions, jobs; документация app/docs/api_routes.md.
- Docker Compose: том app, uvicorn reload, KIND_K8S_PATCH_KUBECONFIG по умолчанию 1.
- setup_env_interactive.py: список переменных в скрипте, удалён env.example.
- Makefile: явный префикс docker/podman; прочие правки CLI и ядра кластеров.
This commit is contained in:
Sergey Antropoff
2026-04-04 05:39:53 +03:00
parent ae961ef5fe
commit e46a62cfdb
31 changed files with 2507 additions and 393 deletions

25
scripts/run_uvicorn.sh Executable file
View File

@@ -0,0 +1,25 @@
#!/bin/sh
# Запуск uvicorn для kind-k8s-web.
# При KIND_K8S_UVICORN_RELOAD=1 (по умолчанию) включается --reload: правки в смонтированном ./app
# подхватываются без пересборки образа.
#
# Автор: Сергей Антропов
# Сайт: https://devops.org.ru
set -e
cd /opt/kind-k8s/app
REL="${KIND_K8S_UVICORN_RELOAD:-1}"
if [ "$REL" = "0" ] || [ "$REL" = "false" ] || [ "$REL" = "no" ]; then
exec python3 -m uvicorn main:app --host 0.0.0.0 --port 6000
fi
exec python3 -m uvicorn main:app \
--host 0.0.0.0 \
--port 6000 \
--reload \
--reload-dir /opt/kind-k8s/app \
--reload-include "*.py" \
--reload-include "*.html" \
--reload-include "*.css" \
--reload-include "*.js"

View File

@@ -1,10 +1,12 @@
#!/usr/bin/env python3
"""Интерактивное создание ``.env`` по шаблону ``env.example`` в корне kind-k8s-develop.
"""Интерактивное создание ``.env`` в корне kind-k8s-develop.
Запуск из корня репозитория: ``python3 scripts/setup_env_interactive.py``
или ``make setup``.
Список переменных и подсказок задаётся в этом файле (не используется env.example).
Дефолты совпадают с ``docker-compose.yml`` / приложением; Enter — записать предложенное значение.
Опционально: ``--template`` / ``--output`` для других путей.
Запуск: ``python3 scripts/setup_env_interactive.py`` или ``make setup``.
Опции: ``--output`` / ``-f`` (перезапись без вопроса).
Автор: Сергей Антропов
Сайт: https://devops.org.ru
@@ -14,8 +16,8 @@ from __future__ import annotations
import argparse
import logging
import re
import secrets
from dataclasses import dataclass
from pathlib import Path
logger = logging.getLogger("setup_env_interactive")
@@ -23,7 +25,29 @@ logger = logging.getLogger("setup_env_interactive")
# Корень репозитория kind-k8s-develop (родитель каталога scripts/)
REPO_ROOT = Path(__file__).resolve().parents[1]
# Переменные, для которых предлагается сгенерировать секрет по вводу «g»
# Значения по умолчанию при нажатии 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": "6000",
"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": "60",
"KIND_K8S_DEBUG": "",
"KIND_K8S_WAIT_NODES": "1",
"KIND_K8S_WAIT_NODES_TIMEOUT_SEC": "300",
"KIND_K8S_APP_TITLE": "kind-k8s-develop",
"KIND_K8S_UVICORN_RELOAD": "1",
"KIND_K8S_WEB_HOST": "0.0.0.0",
"KIND_K8S_WORKDIR": "",
"COMPOSE_BUILD_FLAGS": "",
}
# Переменные, для которых по вводу «g» генерируется секрет (на будущее / другие проекты).
_SECRET_KEYS = frozenset(
{
"SECRET_KEY",
@@ -36,36 +60,114 @@ _SECRET_KEYS = frozenset(
)
@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: том сокета (Docker / Podman rootless) ---",
"CONTAINER_SOCKET",
"Путь к сокету на хосте для volume (Docker: /var/run/docker.sock; Podman rootless — см. README).",
),
_EnvPrompt(
"# --- docker-compose.yml: публикация веб-UI (хост:контейнер → …:6000) ---",
"KIND_K8S_WEB_PORT",
"Порт на хосте для веб-интерфейса; внутри контейнера всегда 6000.",
),
_EnvPrompt(
"# --- docker-compose.yml: environment сервиса kind-k8s-web ---",
"KIND_K8S_PATCH_KUBECONFIG",
"1/true/yes — после create всегда патчить server в kubeconfig на 127.0.0.1:<порт> для доступа с хоста; "
"0 или пусто — не форсировать (в контейнере compose всё равно может сработать KIND_K8S_IN_CONTAINER). "
"По умолчанию в скрипте: 1 (включено).",
),
_EnvPrompt(None, "CONTAINER_CLI", "Имя CLI для вызовов к движку контейнеров: docker или podman."),
_EnvPrompt(
None,
"KIND_K8S_SKIP_VERSION_LIST",
"1 — не запрашивать теги kindest/node с Docker Hub (изолированная сеть); иначе пусто.",
),
_EnvPrompt(None, "KIND_K8S_VERSION_LIST_DISPLAY", "Сколько тегов отдавать в API/списке версий в UI."),
_EnvPrompt(None, "KIND_K8S_HUB_TAGS_MAX_PAGES", "Лимит страниц при обходе Docker Hub API."),
_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_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 compose-build», например --platform linux/arm64.",
),
)
_ENV_FILE_HEADER = """# Файл .env для kind-k8s-develop
# Создан интерактивно: scripts/setup_env_interactive.py
#
# В docker-compose.yml заданы литералами (не из этого файла):
# DOCKER_HOST, KIND_K8S_IN_CONTAINER, KIND_K8S_WORKDIR=/work в контейнере.
#
"""
def _configure_logging() -> None:
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
def _is_comment_or_blank(line: str) -> bool:
s = line.strip()
return not s or s.startswith("#")
def _parse_assignment(line: str) -> tuple[str, str] | None:
"""Строка ``KEY=значение`` без ведущего ``#``; иначе ``None``."""
raw = line.rstrip("\n\r")
if raw.lstrip().startswith("#"):
return None
if "=" not in raw:
return None
key, _, value = raw.partition("=")
k = key.strip()
if not k or not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", k):
return None
return k, value
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:
"""Спросить одну переменную; ``g`` — сгенерировать секрет (для известных ключей)."""
) -> str | None:
"""
Спросить одну переменную.
Возвращает ``None``, если строку в ``.env`` не записывать (пропуск).
"""
extra = ""
if key in _SECRET_KEYS:
extra = " [g] — сгенерировать случайное значение"
@@ -73,9 +175,17 @@ def _ask_line(
hint = default.replace("\n", " ")[:72]
if len(default) > 72:
hint += ""
if not default:
hint = "пустая строка"
print(f"\n{key}")
print(f" По умолчанию из env.example: {hint!r}{extra}")
raw = input(" Значение (Enter — по умолчанию): ").strip()
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
@@ -89,6 +199,7 @@ def _ask_line(
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")
@@ -97,19 +208,9 @@ def _default_database_url(collected: dict[str, str]) -> str | None:
return None
def run(
*,
template: Path,
output: Path,
force: bool = False,
) -> int:
template = template.resolve()
def run(*, output: Path, force: bool = False) -> int:
output = output.resolve()
if not template.is_file():
logger.error("Не найден шаблон: %s", template)
return 1
if output.exists() and not force:
print(f"Файл уже существует: {output}")
ans = input("Перезаписать? [y/N]: ").strip().lower()
@@ -117,32 +218,31 @@ def run(
print("Выход без изменений.")
return 0
lines_in = template.read_text(encoding="utf-8").splitlines(keepends=True)
print(
"\nИнтерактивное заполнение .env (список переменных в scripts/setup_env_interactive.py).\n"
"Enter без ввода — записать значение по умолчанию из подсказки.\n",
)
collected: dict[str, str] = {}
out_chunks: list[str] = []
out_chunks: list[str] = [_ENV_FILE_HEADER]
for line in lines_in:
if _is_comment_or_blank(line.rstrip("\n\r")):
out_chunks.append(line if line.endswith("\n") else line + "\n")
continue
for spec in _SETUP_PROMPTS:
if spec.section_comment:
out_chunks.append(f"{spec.section_comment}\n")
parsed = _parse_assignment(line)
if parsed is None:
out_chunks.append(line if line.endswith("\n") else line + "\n")
continue
key, template_default = parsed
default = template_default
if key == "DATABASE_URL":
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: можно собрать из учётки PostgreSQL выше ---")
print("\n--- DATABASE_URL: собрано из POSTGRES_* ---")
value = _ask_line(key, default, collected=collected)
collected[key] = value
out_chunks.append(f"{key}={value}\n")
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")
@@ -152,12 +252,8 @@ def run(
def main() -> None:
_configure_logging()
parser = argparse.ArgumentParser(description="Интерактивное заполнение .env по env.example")
parser.add_argument(
"--template",
type=Path,
default=REPO_ROOT / "env.example",
help="Путь к шаблону (по умолчанию: env.example в корне репозитория)",
parser = argparse.ArgumentParser(
description="Интерактивное заполнение .env для kind-k8s-develop (переменные задаются в этом скрипте)",
)
parser.add_argument(
"--output",
@@ -167,7 +263,7 @@ def main() -> None:
)
parser.add_argument("-f", "--force", action="store_true", help="Не спрашивать подтверждение перезаписи")
args = parser.parse_args()
raise SystemExit(run(template=args.template, output=args.output, force=args.force))
raise SystemExit(run(output=args.output, force=args.force))
if __name__ == "__main__":