From 73ae5d7032fa12dc7c7e67fca74a42d46827b9b1 Mon Sep 17 00:00:00 2001 From: Sergey Antropoff Date: Sat, 4 Apr 2026 05:39:53 +0300 Subject: [PATCH] =?UTF-8?q?=D0=92=D0=B5=D0=B1-UI=20FastAPI,=20REST=20API?= =?UTF-8?q?=20v1,=20=D0=B8=D0=BD=D1=82=D0=B5=D1=80=D0=B0=D0=BA=D1=82=D0=B8?= =?UTF-8?q?=D0=B2=D0=BD=D1=8B=D0=B9=20setup=20=D0=B1=D0=B5=D0=B7=20env.exa?= =?UTF-8?q?mple?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Дашборд (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 и ядра кластеров. --- .gitignore | 1 + Dockerfile | 19 +- Makefile | 122 ++++---- README.md | 161 +++++----- app/api/__init__.py | 5 + app/api/v1/__init__.py | 5 + app/api/v1/endpoints/__init__.py | 5 + app/api/v1/endpoints/clusters.py | 280 +++++++++++++++++ app/api/v1/endpoints/health.py | 50 +++ app/api/v1/endpoints/versions.py | 36 +++ app/api/v1/router.py | 16 + app/cluster_status.py | 4 +- app/core/cluster_lifecycle.py | 52 ++++ app/core/config.py | 31 +- app/core/job_store.py | 15 +- app/core/kind_guard.py | 13 + app/create_cluster.py | 7 +- app/delete_cluster.py | 11 +- app/docs/api_routes.md | 290 ++++++++++++++++++ app/main.py | 80 +++++ app/models/schemas.py | 73 +++++ app/static/js/dashboard.js | 484 +++++++++++++++++++++++++++++ app/static/style.css | 504 +++++++++++++++++++++++++++++++ app/templates/base.html | 49 +++ app/templates/dashboard.html | 158 ++++++++++ clusters/.gitkeep | 2 + docker-compose.yml | 32 +- docs/k8s_runbook.md | 119 -------- env.example | 27 -- scripts/run_uvicorn.sh | 25 ++ scripts/setup_env_interactive.py | 224 ++++++++++---- 31 files changed, 2507 insertions(+), 393 deletions(-) create mode 100644 app/api/__init__.py create mode 100644 app/api/v1/__init__.py create mode 100644 app/api/v1/endpoints/__init__.py create mode 100644 app/api/v1/endpoints/clusters.py create mode 100644 app/api/v1/endpoints/health.py create mode 100644 app/api/v1/endpoints/versions.py create mode 100644 app/api/v1/router.py create mode 100644 app/core/kind_guard.py create mode 100644 app/docs/api_routes.md create mode 100644 app/main.py create mode 100644 app/models/schemas.py create mode 100644 app/static/js/dashboard.js create mode 100644 app/static/style.css create mode 100644 app/templates/base.html create mode 100644 app/templates/dashboard.html delete mode 100644 docs/k8s_runbook.md delete mode 100644 env.example create mode 100755 scripts/run_uvicorn.sh diff --git a/.gitignore b/.gitignore index bb28386..c8ae2b5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ # Сгенерированные конфиги и kubeconfig локальных кластеров kind +.DS_Store .env clusters/*/ !clusters/.gitkeep diff --git a/Dockerfile b/Dockerfile index 1ec8a99..b60a1c1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,15 +1,21 @@ -# Образ kind-k8s-tools: kind, kubectl, docker CLI, Python-скрипты (без установки на хост). +# Образ kind-k8s-tools: kind, kubectl, docker CLI, FastAPI (веб-UI), Python-скрипты. # Данные кластеров монтируются в /work/clusters (см. docker-compose.yml). # +# Воспроизводимая версия kubectl: build-arg KUBECTL_VERSION=v1.32.0 (или пусто — stable.txt). +# # Автор: Сергей Антропов — https://devops.org.ru +FROM alpine:3.20 + ARG KIND_VERSION=0.24.0 +# Пусто — взять актуальный stable.txt; иначе явная версия, например v1.32.0 +ARG KUBECTL_VERSION= # Платформа целевого образа (BuildKit подставляет amd64/arm64; иначе — uname внутри слоя) ARG TARGETARCH -FROM alpine:3.20 +COPY requirements.txt /opt/kind-k8s/requirements.txt -RUN apk add --no-cache python3 docker-cli curl bash ca-certificates \ +RUN apk add --no-cache python3 py3-pip docker-cli curl bash ca-certificates \ && ARCH="${TARGETARCH:-}" \ && if [ -z "$ARCH" ]; then ARCH="$(uname -m)"; fi \ && case "$ARCH" in \ @@ -19,11 +25,14 @@ RUN apk add --no-cache python3 docker-cli curl bash ca-certificates \ esac \ && curl -sSLo /usr/local/bin/kind "https://kind.sigs.k8s.io/dl/v${KIND_VERSION}/kind-linux-${KARCH}" \ && chmod +x /usr/local/bin/kind \ - && KVER=$(curl -Ls https://dl.k8s.io/release/stable.txt) \ + && if [ -n "${KUBECTL_VERSION}" ]; then KVER="${KUBECTL_VERSION}"; else KVER=$(curl -Ls https://dl.k8s.io/release/stable.txt); fi \ && curl -sSLo /usr/local/bin/kubectl "https://dl.k8s.io/release/${KVER}/bin/linux/${KARCH}/kubectl" \ - && chmod +x /usr/local/bin/kubectl + && chmod +x /usr/local/bin/kubectl \ + && pip3 install --no-cache-dir --break-system-packages -r /opt/kind-k8s/requirements.txt COPY app/ /opt/kind-k8s/app/ +COPY scripts/run_uvicorn.sh /opt/kind-k8s/run_uvicorn.sh +RUN chmod +x /opt/kind-k8s/run_uvicorn.sh ENV KIND_K8S_WORKDIR=/work \ PYTHONPATH=/opt/kind-k8s/app \ diff --git a/Makefile b/Makefile index ae84310..af67a13 100644 --- a/Makefile +++ b/Makefile @@ -1,81 +1,75 @@ -# Локальные кластеры Kubernetes через kind. -# Основной сценарий: только Docker (+ make) на хосте — скрипты и kind внутри образа kind-k8s-tools. +# kind-k8s-develop — веб-интерфейс (FastAPI) для kind. +# Создание кластеров — в браузере: http://127.0.0.1:6000 (порт: KIND_K8S_WEB_PORT). +# +# Все операции с Compose только с явным выбором среды: +# make docker up | make docker down | make docker logs | … +# make podman up | make podman down | … +# Без префикса docker/podman цели up/down/logs/compose-build/check-docker завершатся с подсказкой. # # Автор: Сергей Антропов — https://devops.org.ru -# https://kind.sigs.k8s.io/docs/user/quick-start/ -.PHONY: help setup clusters-dir check-docker compose-build create delete list status \ - create-host delete-host status-host check-host kubeconfig create-compose delete-compose +ifneq (,$(filter podman,$(MAKECMDGOALS))) + COMPOSE := podman compose +else ifneq (,$(filter docker,$(MAKECMDGOALS))) + COMPOSE := docker compose +endif + +.PHONY: help docker podman _require_runtime up down logs setup clusters-dir check-docker compose-build KIND_K8S_DIR := $(abspath $(dir $(lastword $(MAKEFILE_LIST)))) SETUP_ENV_SCRIPT := $(KIND_K8S_DIR)/scripts/setup_env_interactive.py PYTHON ?= python3 -COMPOSE ?= docker compose -# При ошибке «exec format error» у kind в контейнере: make compose-build COMPOSE_BUILD_FLAGS=--platform linux/arm64 +# При «exec format error» у kind: make docker compose-build COMPOSE_BUILD_FLAGS=--platform linux/arm64 COMPOSE_BUILD_FLAGS ?= -K8S_APP := /opt/kind-k8s/app -help: ## Показать справку - @echo "Команды (из каталога kind-k8s-develop или: make -C kind-k8s-develop <цель>):" - @echo " Основной путь — Docker: make create / delete / list / status (kind и Python не нужны на хосте)." - @grep -E '^[a-zA-Z0-9_-]+:.*?##' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?##"} {printf " \033[36m%-22s\033[0m %s\n", $$1, $$2}' +help: ## Справка по целям + @echo "Веб-UI kind — только с выбором Docker или Podman в одной команде с целью:" + @echo " make docker up или make podman up → http://127.0.0.1:\$${KIND_K8S_WEB_PORT:-6000}" + @echo " make docker down / make podman down" + @echo " make docker logs / make podman logs" + @echo " make docker compose-build / make podman compose-build" + @echo " make docker check-docker / make podman check-docker" + @echo "Без установки Compose: make setup, make clusters-dir (python3 для setup)." + @grep -E '^[a-zA-Z0-9_-]+:.*?##' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?##"} {printf " \033[36m%-28s\033[0m %s\n", $$1, $$2}' -setup: ## Интерактивно создать .env по env.example (scripts/setup_env_interactive.py) +docker: ## Маркер среды: задайте вторую цель (например: make docker up) + @: + +podman: ## Маркер среды: задайте вторую цель (например: make podman up) + @: + +# Общая проверка: цели up/down/logs/compose-build/check-docker вызывать только как make docker … / make podman … +_require_runtime: + @if [ -z "$(COMPOSE)" ]; then \ + echo >&2 "Укажите среду в той же команде, что и цель:"; \ + echo >&2 " make docker up | make podman up"; \ + echo >&2 " make docker down | make docker logs | make docker compose-build | make docker check-docker"; \ + echo >&2 " (или то же с префиксом podman)"; \ + exit 1; \ + fi + +up: _require_runtime clusters-dir compose-build ## (с docker/podman) Поднять веб-UI в фоне + cd "$(KIND_K8S_DIR)" && $(COMPOSE) up -d kind-k8s-web + +down: _require_runtime ## (с docker/podman) Остановить compose в этом каталоге + cd "$(KIND_K8S_DIR)" && $(COMPOSE) down + +logs: _require_runtime ## (с docker/podman) Логи kind-k8s-web + cd "$(KIND_K8S_DIR)" && $(COMPOSE) logs -f kind-k8s-web + +setup: ## Интерактивно создать .env (scripts/setup_env_interactive.py; нужен python3 на хосте) @$(PYTHON) "$(SETUP_ENV_SCRIPT)" -clusters-dir: ## Создать каталог clusters/ для тома (если ещё нет) +clusters-dir: ## Каталог clusters/ для тома (если ещё нет) @mkdir -p "$(KIND_K8S_DIR)/clusters" -check-docker: ## Проверить docker/podman в PATH и работу команды COMPOSE (по умолчанию docker compose) - @command -v docker >/dev/null 2>&1 || command -v podman >/dev/null 2>&1 || { echo "Нужен docker или podman в PATH."; exit 1; } - @$(COMPOSE) version >/dev/null 2>&1 || { echo "Нужна рабочая команда Compose: «$(COMPOSE)». Для Podman: COMPOSE='podman compose' make check-docker"; exit 1; } +check-docker: _require_runtime ## (с docker/podman) Проверить CLI и compose + @case "$(COMPOSE)" in \ + docker*) command -v docker >/dev/null 2>&1 || { echo >&2 "docker не найден в PATH."; exit 1; } ;; \ + podman*) command -v podman >/dev/null 2>&1 || { echo >&2 "podman не найден в PATH."; exit 1; } ;; \ + esac + @$(COMPOSE) version >/dev/null 2>&1 || { echo >&2 "Команда «$(COMPOSE) version» недоступна."; exit 1; } @echo "$(COMPOSE): OK" -compose-build: clusters-dir ## Собрать образ kind-k8s-tools +compose-build: _require_runtime clusters-dir ## (с docker/podman) Собрать образ kind-k8s-tools:local cd "$(KIND_K8S_DIR)" && $(COMPOSE) build $(COMPOSE_BUILD_FLAGS) - -# --- Сценарий без установки kind/kubectl/python на хост (только Docker) --- - -create: compose-build ## Интерактивно создать кластер (всё в контейнере) - cd "$(KIND_K8S_DIR)" && $(COMPOSE) run --rm -it kind-k8s-tools python3 $(K8S_APP)/create_cluster.py - -delete: compose-build ## Интерактивно удалить кластер и папку clusters/<имя>/ - cd "$(KIND_K8S_DIR)" && $(COMPOSE) run --rm -it kind-k8s-tools python3 $(K8S_APP)/delete_cluster.py - -list: compose-build ## Список кластеров kind (kind внутри контейнера) - cd "$(KIND_K8S_DIR)" && $(COMPOSE) run --rm kind-k8s-tools kind get clusters - -status: compose-build ## Статус узлов; make status CLUSTER=имя — один кластер - cd "$(KIND_K8S_DIR)" && $(COMPOSE) run --rm -it kind-k8s-tools python3 $(K8S_APP)/cluster_status.py $(CLUSTER) - -# Совместимость со старыми именами целей -create-compose: ## то же, что create (совместимость) - @$(MAKE) -C "$(KIND_K8S_DIR)" create - -delete-compose: ## то же, что delete (совместимость) - @$(MAKE) -C "$(KIND_K8S_DIR)" delete - -# --- Локальный запуск скриптов на хосте (нужны kind, kubectl, python3) --- - -check-host: ## Проверить docker, kind, kubectl, python3 на хосте - @echo "--- check-host (локальные бинарники) ---" - @command -v docker >/dev/null 2>&1 && echo " docker: $$(command -v docker)" || { echo " docker: НЕ НАЙДЕН"; exit 1; } - @command -v kind >/dev/null 2>&1 && echo " kind: $$(command -v kind)" || { echo " kind: НЕ НАЙДЕН (см. https://kind.sigs.k8s.io/docs/user/quick-start/#installation)"; exit 1; } - @command -v kubectl >/dev/null 2>&1 && echo " kubectl: $$(command -v kubectl)" || { echo " kubectl: НЕ НАЙДЕН"; exit 1; } - @command -v $(PYTHON) >/dev/null 2>&1 && echo " $(PYTHON): $$(command -v $(PYTHON))" || { echo " $(PYTHON): НЕ НАЙДЕН"; exit 1; } - -create-host: check-host ## Создать кластер скриптом на хосте (не через образ) - cd "$(KIND_K8S_DIR)" && $(PYTHON) app/create_cluster.py - -delete-host: ## Удалить кластер скриптом на хосте - @command -v $(PYTHON) >/dev/null 2>&1 || { echo "Нужен $(PYTHON)"; exit 1; } - cd "$(KIND_K8S_DIR)" && $(PYTHON) app/delete_cluster.py - -status-host: check-host ## Статус узлов скриптом на хосте - cd "$(KIND_K8S_DIR)" && \ - if [ -n "$(CLUSTER)" ]; then $(PYTHON) app/cluster_status.py "$(CLUSTER)"; else $(PYTHON) app/cluster_status.py; fi - -kubeconfig: ## Путь к kubeconfig на хосте: make kubeconfig CLUSTER=имя - @if [ -z "$(CLUSTER)" ]; then echo "Использование: make kubeconfig CLUSTER=<имя_кластера>"; exit 1; fi - @test -f "$(KIND_K8S_DIR)/clusters/$(CLUSTER)/kubeconfig" || { echo "Файл не найден: clusters/$(CLUSTER)/kubeconfig"; exit 1; } - @echo "$(KIND_K8S_DIR)/clusters/$(CLUSTER)/kubeconfig" diff --git a/README.md b/README.md index fd32708..b3e1f1c 100644 --- a/README.md +++ b/README.md @@ -1,125 +1,144 @@ # kind-k8s-develop — локальные кластеры Kubernetes (kind) -Образ **kind-k8s-tools** и **Makefile**: поднять kind на машине с **Docker** (или Podman + compose), сохранить **kubeconfig** в `clusters/<имя>/` на хосте. Python-скрипты и бинарники **kind/kubectl** лежат **внутри образа** — на хосте достаточно **Docker**, **make** и при необходимости **kubectl** для проверки API. +Образ **kind-k8s-tools:local** и **Makefile** поднимают **веб-интерфейс** (FastAPI) на порту **6000** на хосте: через браузер создаёте и удаляете кластеры, смотрите статистику. **kubeconfig** сохраняется в `clusters/<имя>/`. На хосте достаточно **Docker** (или Podman) и **make**; **kind** и **kubectl** — внутри контейнера. **Автор:** Сергей Антропов — [devops.org.ru](https://devops.org.ru) ## Зачем это нужно -- Быстро получить Kubernetes без облака (интеграционные тесты, проверка манифестов, обучение). -- Версия кластера и число worker-нод задаются **интерактивно** при создании. -- Артефакты на хосте: `clusters/<имя>/` в этом каталоге — удобно указать путь к `kubeconfig` в приложении или в `kubectl`. +- Быстро получить Kubernetes без облака (интеграционные тесты, манифесты, обучение). +- Версия кластера и число worker-нод задаются **в веб-UI** (или при необходимости скриптами/API). +- Количество кластеров **не ограничено** кодом (ограничения — ресурсы хоста и Docker). +- Артефакты на хосте: `clusters/<имя>/` — удобно указать путь к `kubeconfig` в приложении или в `kubectl`. -## Требования на хосте (основной сценарий) +## Требования на хосте | Компонент | Назначение | |-----------|------------| -| **Docker** + **Compose v2** | Сборка образа и запуск (`docker compose`) | -| **make** | Цели `create`, `delete`, `list`, … | -| **kubectl** (опционально) | Проверка кластера с хоста после создания | +| **Docker** + **Compose v2** (или **Podman** + compose) | Сборка образа и запуск веб-сервиса | +| **make** | `make docker up` / `make podman up` и вспомогательные цели | +| **kubectl** (опционально) | Проверка API с хоста: `kubectl --kubeconfig=clusters/<имя>/kubeconfig get nodes` | -**На хост не ставятся:** Python, kind, curl для kind — всё уже в образе `kind-k8s-tools`. +**На хост не ставятся:** Python, kind — всё в образе. -Смонтированы только **сокет** Docker/Podman и каталог **`./clusters`** → в контейнере `/work/clusters`. +Смонтированы **сокет** Docker/Podman и каталог **`./clusters`** → в контейнере `/work/clusters`. -После `make create` kubeconfig **патчится** на `https://127.0.0.1:<порт>` (apiserver с хоста), см. `kubeconfig_patch.py`. +После создания кластера из UI kubeconfig **патчится** на `https://127.0.0.1:<порт>` для доступа с хоста, см. `kubeconfig_patch.py`. ## Быстрый старт ```bash cd kind-k8s-develop -make setup # опционально: интерактивно заполнить .env -make check-docker # опционально: проверить docker compose -make create # интерактивно: имя, версия образа нод, workers -kubectl --kubeconfig="$(pwd)/clusters/<имя>/kubeconfig" get nodes # kubectl с хоста, если установлен -make delete +make setup # опционально: интерактивно .env (скрипт в scripts/; Enter — дефолты как в compose; нужен python3) +make docker check-docker # или: make podman check-docker +make docker up # или: make podman up +# Браузер: http://127.0.0.1:6000 (порт: KIND_K8S_WEB_PORT в .env) ``` -Из родительского каталога: `make -C kind-k8s-develop create`. +Из родительского каталога: `make -C kind-k8s-develop docker up`. + +**Логи и остановка:** `make docker logs` / `make podman logs`, `make docker down` / `make podman down`. + +Описание REST API и примеры JSON: **`app/docs/api_routes.md`**, интерактивно: **`/docs`** на том же порту. + +### Разработка UI и API без пересборки образа + +В **`docker-compose.yml`** каталог **`./app`** смонтирован в контейнер как **`/opt/kind-k8s/app`** — исправления в Python, шаблонах и `static/` на хосте сразу видны внутри сервиса. + +По умолчанию (**`KIND_K8S_UVICORN_RELOAD=1`**) uvicorn запускается с **`--reload`** и перезапускает процесс при изменении `*.py`, `*.html`, `*.css`, `*.js` в `app/`. Пересобирать образ нужно только после изменений **Dockerfile**, **`requirements.txt`** или скрипта **`scripts/run_uvicorn.sh`**. + +Отключить reload: в **`.env`** задать **`KIND_K8S_UVICORN_RELOAD=0`**. + +### Дополнительно: CLI в одноразовом контейнере + +Если нужен сценарий без UI (CI, скрипты): + +```bash +docker compose run --rm --entrypoint python3 kind-k8s-web \ + /opt/kind-k8s/app/create_cluster.py --non-interactive --name dev --kubernetes-version 1.29.4 --workers 2 + +docker compose run --rm --entrypoint python3 kind-k8s-web \ + /opt/kind-k8s/app/delete_cluster.py --non-interactive --name dev --yes +``` + +Рабочий каталог сервиса в образе — `/opt/kind-k8s/app`; том `clusters/` и сокет те же, что у `docker compose up`. + +После успешного `kind create` по умолчанию выполняется **`kubectl wait`** готовности нод (`KIND_K8S_WAIT_NODES`, `KIND_K8S_WAIT_NODES_TIMEOUT_SEC` в `.env`). ## Команды Makefile | Цель | Описание | |------|----------| -| `make help` | Справка | -| `make setup` | Интерактивно заполнить `.env` по `env.example` (`scripts/setup_env_interactive.py`, нужен `python3`) | -| `make check-docker` | Проверить `docker` и `docker compose` | -| `make compose-build` | Собрать образ `kind-k8s-tools` | -| `make create` | Интерактивно создать кластер (**в контейнере**) | -| `make delete` | Интерактивно удалить кластер и `clusters/<имя>/` | -| `make list` | `kind get clusters` в контейнере | -| `make status` | Статус узлов (`kubectl` в контейнере) | -| `make status CLUSTER=имя` | Один кластер | -| `make kubeconfig CLUSTER=имя` | Путь к `clusters/<имя>/kubeconfig` на хосте | -| `make create-compose` / `make delete-compose` | То же, что `create` / `delete` (совместимость) | +| `make help` | Краткая справка | +| `make docker up` / `make podman up` | Поднять веб-UI (`kind-k8s-web`) | +| `make docker down` / `make podman down` | Остановить compose в каталоге репозитория | +| `make docker logs` / `make podman logs` | Логи `kind-k8s-web` | +| `make docker compose-build` / `make podman compose-build` | Собрать образ `kind-k8s-tools:local` | +| `make docker check-docker` / `make podman check-docker` | Проверить выбранный CLI и `compose version` | +| `make setup` | Интерактивно заполнить `.env`: переменные и подсказки в `scripts/setup_env_interactive.py`, дефолты как в `docker-compose` | +| `make clusters-dir` | Создать каталог `clusters/` | +| `make docker …` / `make podman …` | Префикс **обязателен** для целей `up`, `down`, `logs`, `compose-build`, `check-docker` | -### Локальный запуск скриптов на хосте (без образа) - -Если **kind**, **kubectl** и **Python** уже в PATH (kind: [установка](https://kind.sigs.k8s.io/docs/user/quick-start/#installation)): - -| Цель | Описание | -|------|----------| -| `make check-host` | Проверить docker, kind, kubectl, python3 | -| `make create-host` | `app/create_cluster.py` на хосте | -| `make delete-host` | `app/delete_cluster.py` на хосте | -| `make status-host` | `app/cluster_status.py` на хосте | +Цели `up`, `down`, `logs`, `compose-build` и `check-docker` **без** `docker`/`podman` в той же команде завершатся с подсказкой. ## Переменные окружения -Шаблон: **`env.example`**. Удобно выполнить **`make setup`** или скопировать в **`.env`** в корне этого каталога — Compose подхватывает `.env` при запуске отсюда. +Файл **`.env`** в корне репозитория подхватывает Compose. Создать его можно командой **`make setup`** (опрос по списку в **`scripts/setup_env_interactive.py`**) или вручную. | Переменная | Где используется | Назначение | |------------|------------------|------------| -| **`KIND_VERSION`** | `docker-compose` (build-arg) | Версия бинарника kind в образе при `compose build` / `make compose-build`. | -| **`CONTAINER_SOCKET`** | `docker-compose` (volume) | Сокет Docker/Podman на хосте (по умолчанию `/var/run/docker.sock`). | -| **`KIND_K8S_PATCH_KUBECONFIG`** | контейнер (environment) | `1` / `true` — всегда патчить `server` в kubeconfig на `127.0.0.1:<порт>`. | -| **`CONTAINER_CLI`** | контейнер (environment) | CLI к API контейнеров для `docker port` (по умолчанию `docker`). | -| **`KIND_K8S_SKIP_VERSION_LIST`** | контейнер | `1` — не ходить в Docker Hub, версия только с клавиатуры. | -| **`KIND_K8S_VERSION_LIST_DISPLAY`** | контейнер | Сколько строк списка версий показать (по умолчанию `50`, макс. `500`). | -| **`KIND_K8S_HUB_TAGS_MAX_PAGES`** | контейнер | Лимит страниц API Hub при сборе тегов (по умолчанию `60`, макс. `200`). | -| **`KIND_K8S_DEBUG`** | контейнер | `1` — уровень логов DEBUG для модулей kind-k8s. | -| **`COMPOSE`** | только **Makefile** | Команда Compose, по умолчанию `docker compose`; Podman: `COMPOSE='podman compose'`. | -| **`COMPOSE_BUILD_FLAGS`** | только **Makefile** | Аргументы к `compose build`, например `--platform linux/arm64` при `exec format error`. | +| **`KIND_VERSION`** | build-arg | Версия бинарника kind при сборке образа | +| **`KUBECTL_VERSION`** | build-arg | Версия kubectl в образе; в Dockerfile без build-arg — `stable.txt`; `make setup` предлагает закреплённый тег | +| **`KIND_K8S_WEB_PORT`** | ports | Порт **на хосте** для веб-UI (в контейнере 6000) | +| **`KIND_K8S_UVICORN_RELOAD`** | контейнер | `1` (по умолчанию) — hot-reload при правках в `./app`; `0` — без reload | +| **`KIND_K8S_APP_TITLE`** | контейнер | Заголовок в OpenAPI и HTML | +| **`KIND_K8S_WAIT_NODES`** | контейнер | `0` — не ждать Ready нод после create | +| **`KIND_K8S_WAIT_NODES_TIMEOUT_SEC`** | контейнер | Таймаут `kubectl wait` (секунды) | +| **`CONTAINER_SOCKET`** | volume | Сокет Docker/Podman | +| **`KIND_K8S_PATCH_KUBECONFIG`** | контейнер | Патч `server` в kubeconfig на хост; по умолчанию **включено** (`1` в compose и в `make setup`) | +| **`CONTAINER_CLI`** | контейнер | CLI для `docker port` | +| **`KIND_K8S_SKIP_VERSION_LIST`** | контейнер | Не ходить в Docker Hub за тегами | +| **`KIND_K8S_VERSION_LIST_DISPLAY`** | контейнер | Сколько тегов показывать в списке | +| **`KIND_K8S_HUB_TAGS_MAX_PAGES`** | контейнер | Лимит страниц API Hub | +| **`KIND_K8S_DEBUG`** | контейнер | Уровень DEBUG в логах | +| **`COMPOSE_BUILD_FLAGS`** | Makefile | Например `make docker compose-build COMPOSE_BUILD_FLAGS=--platform linux/arm64` | ## Podman (пример rootless) ```bash export CONTAINER_SOCKET="$XDG_RUNTIME_DIR/podman/podman.sock" -COMPOSE='podman compose' make create +make podman up ``` ## Файлы | Путь | Назначение | |------|------------| -| `Dockerfile` | Alpine, kind, kubectl, docker-cli; каталог `app/` копируется в `/opt/kind-k8s/app` | -| `docker-compose.yml` | Том `./clusters`, сокет Docker/Podman | -| `scripts/setup_env_interactive.py` | Интерактивное заполнение `.env` (цель `make setup`) | -| `app/` | Все Python-модули и скрипты (`PYTHONPATH=/opt/kind-k8s/app` в образе) | -| `app/kind_k8s_paths.py` | Корень данных: `KIND_K8S_WORKDIR` (в образе `/work`) или корень этого репозитория | -| `app/create_cluster.py` | Диалог создания кластера | -| `app/delete_cluster.py` | Удаление | -| `app/cluster_status.py` | Узлы и meta | -| `app/kubeconfig_patch.py` | Патч `server` в kubeconfig для доступа с хоста | -| `app/kindest_node_tags.py` | Теги `kindest/node` (1.19+) с Docker Hub для выбора версии | +| `Makefile` | Запуск веб-UI и вспомогательные цели | +| `scripts/setup_env_interactive.py` | Интерактивное создание `.env` (список переменных и дефолты внутри скрипта) | +| `scripts/run_uvicorn.sh` | Точка входа веб-сервиса: uvicorn с опциональным `--reload` | +| `Dockerfile` | Образ: kind, kubectl, docker-cli, FastAPI | +| `requirements.txt` | pip-зависимости веб-интерфейса | +| `docker-compose.yml` | Сервис `kind-k8s-web`, том `./clusters`, сокет | +| `app/main.py` | FastAPI, дашборд | +| `app/api/v1/` | REST API | +| `app/core/` | Логика кластеров, задания, блокировки | +| `app/docs/api_routes.md` | Описание маршрутов с примерами JSON | +| `app/create_cluster.py`, `delete_cluster.py`, … | Используются веб-API и при ручном `compose run` | -При **`make create`** скрипт запрашивает версию Kubernetes: по умолчанию подгружается список тегов с Docker Hub (нужен интернет). Без сети или в air-gapped: **`KIND_K8S_SKIP_VERSION_LIST=1`** в **`.env`** — ввод версии только вручную. +В UI и API список версий **kindest/node** по умолчанию тянется с Docker Hub (нужна сеть). В изолированной среде: **`KIND_K8S_SKIP_VERSION_LIST=1`** — версию вводят вручную. ## Где лежат данные на хосте - `clusters/<имя>/kind-config.yaml` - `clusters/<имя>/kubeconfig` -- `clusters/<имя>/meta.json` (в т.ч. `kubeconfig_patched_for_host`, `created_via_container`) +- `clusters/<имя>/meta.json` -Содержимое `clusters/*/` не коммитится (см. `.gitignore`), каталог `clusters/` держит `.gitkeep`. - -## Документация AppsTemplate (модуль Kubernetes) - -Файл **`docs/k8s_runbook.md`** в этом репозитории — runbook эксплуатации модуля Kubernetes **веб-приложения** (кластеры в БД, health, RBAC, observability и т.д.). Раздел **§6.1** связывает локальный kind из этого каталога с импортом kubeconfig в приложение. +Содержимое `clusters/*/` не коммитится; в репозитории есть `clusters/.gitkeep`. ## Ограничения -- Образ `kindest/node:v…` должен быть в реестре; опечатка версии → ошибка pull/kind. -- На **Windows** без WSL удобнее WSL2 + Docker Desktop; пути ориентированы на Unix. -- Для проверки с хоста нужен отдельно установленный **kubectl** (образ ставит kubectl только **внутри** контейнера). -- Если при `make list` / `make create` в логе **`exec format error`** у `kind`, архитектура бинарника в образе не совпала с платформой контейнера. Пересоберите явно, например: `make compose-build COMPOSE_BUILD_FLAGS=--platform linux/arm64` или `linux/amd64` (как у вашего Docker). +- Образ `kindest/node:v…` должен быть доступен для pull. +- На **Windows** без WSL удобнее WSL2 + Docker Desktop. +- Для проверки с хоста нужен отдельный **kubectl** (в образе kubectl только внутри контейнера). +- При **`exec format error`** у kind пересоберите образ: `make docker compose-build COMPOSE_BUILD_FLAGS=--platform linux/arm64` (или `make podman …`, или `linux/amd64`). diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..de15f15 --- /dev/null +++ b/app/api/__init__.py @@ -0,0 +1,5 @@ +"""HTTP-слой (FastAPI). + +Автор: Сергей Антропов +Сайт: https://devops.org.ru +""" diff --git a/app/api/v1/__init__.py b/app/api/v1/__init__.py new file mode 100644 index 0000000..dafd8d9 --- /dev/null +++ b/app/api/v1/__init__.py @@ -0,0 +1,5 @@ +"""Версия API v1. + +Автор: Сергей Антропов +Сайт: https://devops.org.ru +""" diff --git a/app/api/v1/endpoints/__init__.py b/app/api/v1/endpoints/__init__.py new file mode 100644 index 0000000..915da79 --- /dev/null +++ b/app/api/v1/endpoints/__init__.py @@ -0,0 +1,5 @@ +"""Маршруты API v1. + +Автор: Сергей Антропов +Сайт: https://devops.org.ru +""" diff --git a/app/api/v1/endpoints/clusters.py b/app/api/v1/endpoints/clusters.py new file mode 100644 index 0000000..2d988f6 --- /dev/null +++ b/app/api/v1/endpoints/clusters.py @@ -0,0 +1,280 @@ +"""CRUD-операции над кластерами kind и сводная статистика. + +Автор: Сергей Антропов +Сайт: https://devops.org.ru +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +from fastapi import APIRouter, BackgroundTasks, HTTPException, Query +from fastapi.responses import FileResponse + +from core.cluster_lifecycle import ( + KindClusterError, + cluster_summary_for_api, + create_cluster_non_interactive, + delete_kind_cluster_and_data, + kubectl_nodes_wide, + kubectl_pods_all_namespaces, + list_registered_kind_clusters, + read_meta_json, + validate_cluster_name, +) +from core.job_store import job_store +from core.kind_guard import kind_cluster_lock +from kind_k8s_paths import clusters_dir +from models.schemas import ( + ClusterCreateAccepted, + ClusterCreateRequest, + ClusterSummary, + ClusterWorkloadsResponse, + JobView, + StatsResponse, +) + +logger = logging.getLogger("kind_k8s.api.clusters") + +router = APIRouter(tags=["clusters"]) + + +def _stats_sync() -> StatsResponse: + """Собрать статистику (синхронно; вызывать из thread при необходимости).""" + kind_names = list_registered_kind_clusters() + cdir = clusters_dir() + subdirs: list[str] = [] + if cdir.is_dir(): + subdirs = sorted(p.name for p in cdir.iterdir() if p.is_dir() and not p.name.startswith(".")) + + total_workers = 0 + counted = False + for name in subdirs: + meta = read_meta_json(name) + if not meta: + continue + w = meta.get("worker_nodes") + if w is None: + continue + try: + total_workers += int(w) + counted = True + except (TypeError, ValueError): + continue + + jobs = job_store.snapshot_all() + failed = sum(1 for j in jobs if j.status == "failed") + + return StatsResponse( + kind_clusters_count=len(kind_names), + local_cluster_dirs_count=len(subdirs), + total_workers_from_meta=total_workers if counted else None, + jobs_total=len(jobs), + jobs_recent_failed=failed, + ) + + +@router.get("/stats", response_model=StatsResponse, summary="Статистика") +async def get_stats() -> StatsResponse: + """Число кластеров kind, локальных каталогов, сумма workers из meta (если есть), счётчики заданий.""" + return await asyncio.to_thread(_stats_sync) + + +@router.get("/jobs", response_model=list[JobView], summary="Список заданий") +async def list_jobs(limit: int = Query(30, ge=1, le=200, description="Сколько последних заданий")) -> list[JobView]: + """История создания кластеров (в памяти процесса; после перезапуска контейнера пусто).""" + items = job_store.snapshot_recent_sorted(limit=limit) + return [ + JobView( + job_id=r.job_id, + kind=r.kind, + status=r.status, + cluster_name=r.cluster_name, + created_at_utc=r.created_at_utc, + message=r.message, + result=r.result, + ) + for r in items + ] + + +@router.get("/clusters", response_model=list[ClusterSummary], summary="Список кластеров") +async def list_clusters() -> list[ClusterSummary]: + """Объединение: зарегистрированные в kind + каталоги в ``clusters/`` (без дубликатов в выдаче).""" + kind_names = set(list_registered_kind_clusters()) + cdir = clusters_dir() + dir_names: set[str] = set() + if cdir.is_dir(): + dir_names = {p.name for p in cdir.iterdir() if p.is_dir() and not p.name.startswith(".")} + all_names = sorted(kind_names | dir_names) + out: list[ClusterSummary] = [] + for name in all_names: + summary = cluster_summary_for_api(name) + out.append( + ClusterSummary( + name=str(summary["name"]), + registered_in_kind=bool(summary["registered_in_kind"]), + has_local_kubeconfig=bool(summary["has_local_kubeconfig"]), + meta=dict(summary["meta"]) if isinstance(summary.get("meta"), dict) else {}, + ) + ) + logger.debug("list_clusters: %s записей", len(out)) + return out + + +@router.get( + "/clusters/{name}/kubeconfig", + summary="Скачать kubeconfig", + responses={404: {"description": "Файл не найден"}}, +) +async def download_kubeconfig(name: str) -> FileResponse: + """Файл ``clusters/<имя>/kubeconfig`` для использования с хоста (локальная dev-среда).""" + if not validate_cluster_name(name): + raise HTTPException(status_code=400, detail="Некорректное имя кластера") + path = clusters_dir() / name / "kubeconfig" + if not path.is_file(): + raise HTTPException(status_code=404, detail="kubeconfig не найден") + logger.info("Отдача kubeconfig для кластера %s", name) + return FileResponse( + path=path, + filename=f"kubeconfig-{name}.yaml", + media_type="application/x-yaml", + ) + + +@router.get( + "/clusters/{name}/workloads", + response_model=ClusterWorkloadsResponse, + summary="Узлы и поды (kubectl)", +) +async def cluster_workloads(name: str) -> ClusterWorkloadsResponse: + """``kubectl get nodes`` и ``kubectl get pods -A`` по сохранённому kubeconfig.""" + if not validate_cluster_name(name): + raise HTTPException(status_code=400, detail="Некорректное имя кластера") + kc = clusters_dir() / name / "kubeconfig" + if not kc.is_file(): + return ClusterWorkloadsResponse(cluster_name=name, error="Нет сохранённого kubeconfig в clusters/<имя>/") + + nodes_rc, nodes_out = await asyncio.to_thread(kubectl_nodes_wide, kubeconfig=kc) + pods_rc, pods_out = await asyncio.to_thread(kubectl_pods_all_namespaces, kubeconfig=kc) + return ClusterWorkloadsResponse( + cluster_name=name, + nodes_rc=nodes_rc, + nodes_output=nodes_out, + pods_rc=pods_rc, + pods_output=pods_out, + ) + + +@router.get("/clusters/{name}", summary="Детали кластера") +async def get_cluster(name: str) -> dict[str, object]: + """Сводка и попытка ``kubectl get nodes`` (предпочтительно сохранённый kubeconfig).""" + summary = cluster_summary_for_api(name) + saved = clusters_dir() / name / "kubeconfig" + kubectl_rc: int | None = None + kubectl_msg: str | None = None + if saved.is_file(): + rc, msg = await asyncio.to_thread(kubectl_nodes_wide, kubeconfig=saved) + kubectl_rc = rc + kubectl_msg = msg + return { + **summary, + "kubectl_get_nodes_rc": kubectl_rc, + "kubectl_get_nodes": kubectl_msg, + } + + +async def _run_create_job(job_id: str, body: ClusterCreateRequest) -> None: + async with kind_cluster_lock: + await job_store.set_running(job_id) + try: + result = await asyncio.to_thread( + create_cluster_non_interactive, + name=body.name.strip(), + kubernetes_version_tag=body.kubernetes_version.strip(), + workers=body.workers, + ) + except KindClusterError as e: + await job_store.set_failed(job_id, str(e)) + logger.warning("create job %s: %s", job_id, e) + return + except Exception as e: + await job_store.set_failed(job_id, f"{type(e).__name__}: {e}") + logger.exception("create job %s: непредвиденная ошибка", job_id) + return + + payload: dict[str, Any] = { + "cluster_name": result.cluster_name, + "kubernetes_version_tag": result.ver_tag, + "node_image": result.node_image, + "workers": result.workers, + "kubeconfig_path": str(result.kubeconfig_path), + "kubeconfig_patched_for_host": result.kubeconfig_patched_for_host, + "nodes_ready": result.nodes_ready, + "nodes_ready_message": result.nodes_ready_message, + } + await job_store.set_success(job_id, result=payload, message="Кластер создан") + logger.info("create job %s: успех, кластер %s", job_id, result.cluster_name) + + +@router.post( + "/clusters", + response_model=ClusterCreateAccepted, + status_code=202, + summary="Создать кластер (фон)", +) +async def post_create_cluster( + body: ClusterCreateRequest, + background_tasks: BackgroundTasks, +) -> ClusterCreateAccepted: + """Поставить создание кластера в фон; идентификатор задания — в ответе.""" + if not validate_cluster_name(body.name.strip()): + raise HTTPException(status_code=400, detail="Некорректное имя кластера") + + existing = await asyncio.to_thread(list_registered_kind_clusters) + if body.name.strip() in existing: + raise HTTPException(status_code=409, detail="Кластер с таким именем уже есть в kind") + + rec = await job_store.create_job("create_cluster", cluster_name=body.name.strip()) + background_tasks.add_task(_run_create_job, rec.job_id, body) + logger.info("Принят запрос на создание кластера %s, job_id=%s", body.name, rec.job_id) + return ClusterCreateAccepted(job_id=rec.job_id) + + +@router.delete("/clusters/{name}", summary="Удалить кластер") +async def delete_cluster(name: str) -> dict[str, object]: + """``kind delete`` и удаление локальной папки ``clusters/<имя>/``.""" + if not validate_cluster_name(name): + raise HTTPException(status_code=400, detail="Некорректное имя кластера") + + async with kind_cluster_lock: + + def _do() -> tuple[bool, str]: + return delete_kind_cluster_and_data(name=name, log_to_stdout=False) + + try: + kind_ok, summary = await asyncio.to_thread(_do) + except KindClusterError as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + logger.info("Удаление кластера %s: kind_ok=%s", name, kind_ok) + return {"name": name, "kind_delete_ok": kind_ok, "summary": summary} + + +@router.get("/jobs/{job_id}", response_model=JobView, summary="Статус одного задания") +async def get_job(job_id: str) -> JobView: + """Узнать состояние фонового создания кластера.""" + rec = await job_store.get(job_id) + if not rec: + raise HTTPException(status_code=404, detail="Задание не найдено") + return JobView( + job_id=rec.job_id, + kind=rec.kind, + status=rec.status, + cluster_name=rec.cluster_name, + created_at_utc=rec.created_at_utc, + message=rec.message, + result=rec.result, + ) diff --git a/app/api/v1/endpoints/health.py b/app/api/v1/endpoints/health.py new file mode 100644 index 0000000..2942b79 --- /dev/null +++ b/app/api/v1/endpoints/health.py @@ -0,0 +1,50 @@ +"""Проверка живости сервиса и доступности движка контейнеров. + +Автор: Сергей Антропов +Сайт: https://devops.org.ru +""" + +from __future__ import annotations + +import asyncio +import logging +import shutil + +from fastapi import APIRouter + +from core.cluster_lifecycle import container_engine_ping + +logger = logging.getLogger("kind_k8s.api.health") + +router = APIRouter(tags=["health"]) + + +@router.get("/health", summary="Состояние сервиса и среды") +async def health() -> dict[str, object]: + """ + Проверка: процесс отвечает, в PATH есть kind/kubectl, доступен Docker/Podman API (``info``). + + Без рабочего сокета создание kind-кластеров из UI невозможно. + """ + kind_ok = shutil.which("kind") is not None + kubectl_ok = shutil.which("kubectl") is not None + + engine_ok, engine_msg, cli = await asyncio.to_thread(container_engine_ping) + + logger.debug( + "health: kind=%s kubectl=%s engine=%s cli=%s", + kind_ok, + kubectl_ok, + engine_ok, + cli, + ) + + overall = "ok" if (kind_ok and kubectl_ok and engine_ok) else "degraded" + return { + "status": overall, + "kind_in_path": kind_ok, + "kubectl_in_path": kubectl_ok, + "container_cli": cli, + "container_engine_ok": engine_ok, + "container_engine_detail": engine_msg if not engine_ok else None, + } diff --git a/app/api/v1/endpoints/versions.py b/app/api/v1/endpoints/versions.py new file mode 100644 index 0000000..a12e561 --- /dev/null +++ b/app/api/v1/endpoints/versions.py @@ -0,0 +1,36 @@ +"""Список доступных тегов kindest/node (Docker Hub) для выпадающего списка в UI. + +Автор: Сергей Антропов +Сайт: https://devops.org.ru +""" + +from __future__ import annotations + +import asyncio +import logging +import os + +from fastapi import APIRouter + +from kindest_node_tags import fetch_kindest_node_tags + +logger = logging.getLogger("kind_k8s.api.versions") + +router = APIRouter(tags=["versions"]) + + +@router.get("/versions", summary="Теги kindest/node") +async def list_kindest_versions() -> dict[str, object]: + """ + Вернуть отсортированный список стабильных тегов (как при интерактивном выборе версии в UI/CLI). + + При ``KIND_K8S_SKIP_VERSION_LIST=1`` возвращает пустой список — UI может предложить ввод вручную. + """ + skip = os.environ.get("KIND_K8S_SKIP_VERSION_LIST", "").strip().lower() in ("1", "true", "yes", "да") + if skip: + logger.info("Список версий отключён (KIND_K8S_SKIP_VERSION_LIST)") + return {"tags": [], "skipped": True, "reason": "KIND_K8S_SKIP_VERSION_LIST"} + + tags = await asyncio.to_thread(fetch_kindest_node_tags) + logger.info("Отдано тегов kindest/node: %s", len(tags)) + return {"tags": tags, "skipped": False} diff --git a/app/api/v1/router.py b/app/api/v1/router.py new file mode 100644 index 0000000..f3f0078 --- /dev/null +++ b/app/api/v1/router.py @@ -0,0 +1,16 @@ +"""Сборка маршрутов API v1. + +Автор: Сергей Антропов +Сайт: https://devops.org.ru +""" + +from __future__ import annotations + +from fastapi import APIRouter + +from api.v1.endpoints import clusters, health, versions + +api_router = APIRouter() +api_router.include_router(health.router, prefix="") +api_router.include_router(versions.router, prefix="") +api_router.include_router(clusters.router, prefix="") diff --git a/app/cluster_status.py b/app/cluster_status.py index 421ca94..fd71d15 100755 --- a/app/cluster_status.py +++ b/app/cluster_status.py @@ -104,7 +104,7 @@ def _print_cluster(name: str, *, kube_path_saved: Path | None) -> None: use_path: str | None = None if kube_path_saved and kube_path_saved.is_file(): use_path = str(kube_path_saved) - print(" Проверка API: kubectl с сохранённым kubeconfig (как на хосте после make create).") + print(" Проверка API: kubectl с сохранённым kubeconfig (как на хосте после создания кластера).") tmp_kc: str | None = None if not use_path: @@ -141,7 +141,7 @@ def main() -> None: if not shutil.which("kind"): print("Не найден kind.", file=sys.stderr) - print(" Обычно запускают: make -C kind-k8s-develop status (kind внутри образа).", file=sys.stderr) + print(" Статус узлов — в веб-интерфейсе (make docker up) или внутри контейнера kind-k8s-web.", file=sys.stderr) print( " Либо установите kind на хост: https://kind.sigs.k8s.io/docs/user/quick-start/#installation", file=sys.stderr, diff --git a/app/core/cluster_lifecycle.py b/app/core/cluster_lifecycle.py index 220eba2..23f31c3 100644 --- a/app/core/cluster_lifecycle.py +++ b/app/core/cluster_lifecycle.py @@ -303,6 +303,37 @@ def read_meta_json(cluster_name: str) -> dict[str, object] | None: return None +def _container_cli_bin() -> str: + """Имя CLI к сокету (docker / podman), как в kubeconfig_patch.""" + return (os.environ.get("CONTAINER_CLI") or "docker").strip() or "docker" + + +def container_engine_ping(*, timeout_sec: float = 12.0) -> tuple[bool, str, str]: + """ + Проверить доступ к движку контейнеров (``docker info`` / ``podman info``). + + Возвращает (успех, краткое сообщение или stderr, имя CLI). + """ + cli = _container_cli_bin() + if not shutil.which(cli): + return False, f"«{cli}» не найден в PATH", cli + try: + p = subprocess.run( + [cli, "info"], + capture_output=True, + text=True, + timeout=timeout_sec, + ) + except subprocess.TimeoutExpired: + logger.warning("%s info: таймаут %s с", cli, timeout_sec) + return False, f"таймаут {timeout_sec} с", cli + if p.returncode == 0: + return True, "OK", cli + err = (p.stderr or p.stdout or "").strip() or f"код {p.returncode}" + logger.info("%s info неуспешно: %s", cli, err[:200]) + return False, err[:800], cli + + def kubectl_nodes_wide(*, kubeconfig: str | Path) -> tuple[int, str]: """``kubectl get nodes -o wide``; возвращает (код, объединённый вывод).""" p = subprocess.run( @@ -325,6 +356,27 @@ def kubectl_nodes_wide(*, kubeconfig: str | Path) -> tuple[int, str]: return p.returncode, msg +def kubectl_pods_all_namespaces(*, kubeconfig: str | Path) -> tuple[int, str]: + """``kubectl get pods -A``; сводка подов по кластеру.""" + p = subprocess.run( + [ + "kubectl", + "--kubeconfig", + str(kubeconfig), + "get", + "pods", + "-A", + "--request-timeout=20s", + ], + capture_output=True, + text=True, + ) + out = (p.stdout or "").strip() + err = (p.stderr or "").strip() + msg = out if out else err + return p.returncode, msg + + def cluster_summary_for_api(name: str) -> dict[str, object]: """Сводка по кластеру для JSON API (без блокирующих долгих вызовов).""" meta = read_meta_json(name) or {} diff --git a/app/core/config.py b/app/core/config.py index 120f017..e3c04fd 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -1,4 +1,7 @@ -"""Настройки веб-приложения из переменных окружения (и опционально ``.env`` в рабочем каталоге). +"""Настройки веб-приложения из переменных окружения. + +Переменные задаются в ``docker-compose`` и/или в ``.env`` в корне репозитория +(Compose подставляет их в ``environment`` процесса — отдельный ``env_file`` в коде не требуется). Автор: Сергей Антропов Сайт: https://devops.org.ru @@ -6,34 +9,34 @@ from __future__ import annotations -from pathlib import Path - -from pydantic import Field +from pydantic import Field, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict -# Каталог пакета app/ — для поиска .env рядом с кодом (в образе: /opt/kind-k8s/app). -_APP_DIR = Path(__file__).resolve().parents[1] -_REPO_ROOT = _APP_DIR.parent +_DEFAULT_TITLE = "kind-k8s-develop" class Settings(BaseSettings): """Параметры HTTP-сервера и поведения UI.""" model_config = SettingsConfigDict( - env_file=( - str(_REPO_ROOT / ".env"), - str(_APP_DIR / ".env"), - ), - env_file_encoding="utf-8", extra="ignore", case_sensitive=False, + # Пустая строка из docker-compose (${VAR:-}) не должна затирать заголовок OpenAPI. + env_ignore_empty=True, ) kind_k8s_web_host: str = Field(default="0.0.0.0", validation_alias="KIND_K8S_WEB_HOST") kind_k8s_web_port: int = Field(default=6000, validation_alias="KIND_K8S_WEB_PORT") - # Заголовок в OpenAPI / HTML (без хардкода в шаблонах). - app_title: str = Field(default="kind-k8s-develop", validation_alias="KIND_K8S_APP_TITLE") + # Заголовок в OpenAPI / HTML; пустая строка из compose не должна ломать FastAPI. + app_title: str = Field(default=_DEFAULT_TITLE, validation_alias="KIND_K8S_APP_TITLE") + + @field_validator("app_title", mode="before") + @classmethod + def _non_empty_title(cls, v: object) -> object: + if v is None or (isinstance(v, str) and not v.strip()): + return _DEFAULT_TITLE + return v def get_settings() -> Settings: diff --git a/app/core/job_store.py b/app/core/job_store.py index a7d04c5..0df38dd 100644 --- a/app/core/job_store.py +++ b/app/core/job_store.py @@ -11,12 +11,15 @@ from __future__ import annotations import asyncio import logging import uuid -from dataclasses import dataclass, field +from dataclasses import dataclass from datetime import datetime, timezone from typing import Any, Literal logger = logging.getLogger("kind_k8s.job_store") +# Лимит записей в памяти (dev-инструмент; старые задания вытесняются) +_MAX_JOBS = 200 + JobStatus = Literal["queued", "running", "success", "failed"] @@ -53,6 +56,10 @@ class JobStore: ) async with self._lock: self._jobs[jid] = rec + while len(self._jobs) > _MAX_JOBS: + oldest_id = min(self._jobs, key=lambda k: self._jobs[k].created_at_utc) + del self._jobs[oldest_id] + logger.debug("Вытеснено старое задание из хранилища: %s", oldest_id) logger.info("Создано задание %s kind=%s cluster=%s", jid, kind, cluster_name) return rec @@ -84,6 +91,12 @@ class JobStore: """Снимок всех заданий (для отладки; без блокировки — eventual consistency).""" return list(self._jobs.values()) + def snapshot_recent_sorted(self, *, limit: int) -> list[JobRecord]: + """Задания от новых к старым, не более ``limit``.""" + items = self.snapshot_all() + items.sort(key=lambda r: r.created_at_utc, reverse=True) + return items[: max(1, limit)] + # Синглтон на процесс uvicorn job_store = JobStore() diff --git a/app/core/kind_guard.py b/app/core/kind_guard.py new file mode 100644 index 0000000..fc554d3 --- /dev/null +++ b/app/core/kind_guard.py @@ -0,0 +1,13 @@ +"""Глобальная блокировка для операций kind (последовательное создание/удаление). + +Параллельные ``kind create`` на одном Docker-движке часто нежелательны. + +Автор: Сергей Антропов +Сайт: https://devops.org.ru +""" + +from __future__ import annotations + +import asyncio + +kind_cluster_lock = asyncio.Lock() diff --git a/app/create_cluster.py b/app/create_cluster.py index 91714b0..f4088c2 100644 --- a/app/create_cluster.py +++ b/app/create_cluster.py @@ -7,7 +7,7 @@ Сайт: https://devops.org.ru Требования: kind, клиент контейнеров (``docker`` к сокету Docker/Podman) и kubectl в PATH. -Рекомендуется: ``make create`` из каталога kind-k8s-develop — всё внутри Docker, на хосте только Docker. +Рекомендуется: веб-интерфейс (``make docker up``) — всё внутри Docker, на хосте только Docker и make. Пакетный режим: ``--non-interactive --name X --kubernetes-version 1.29.4 [--workers N]``. """ @@ -21,7 +21,6 @@ import os import shutil import subprocess import sys -from pathlib import Path from core.cluster_lifecycle import ( CreateClusterResult, @@ -154,7 +153,7 @@ def _run_interactive() -> None: if not _which("kind"): print("Не найден бинарник kind.", file=sys.stderr) print(" Установка kind на хост: https://kind.sigs.k8s.io/docs/user/quick-start/#installation", file=sys.stderr) - print(" Через Docker: make -C kind-k8s-develop create (или make create из каталога репозитория).", file=sys.stderr) + print(" Через Docker: make -C kind-k8s-develop docker up и веб-интерфейс.", file=sys.stderr) sys.exit(127) cli = _container_cli_bin() if not _which(cli): @@ -173,7 +172,7 @@ def _run_interactive() -> None: print("Некорректное имя: только строчные буквы, цифры, дефис; не длиннее 63 символов.") continue if name in existing: - print(f"Кластер «{name}» уже существует в kind. Выберите другое имя или удалите его (make delete).") + print(f"Кластер «{name}» уже существует в kind. Удалите его в веб-интерфейсе или другое имя.") continue break diff --git a/app/delete_cluster.py b/app/delete_cluster.py index 581f1db..7d90eae 100644 --- a/app/delete_cluster.py +++ b/app/delete_cluster.py @@ -25,15 +25,6 @@ def _configure_logging() -> None: logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s") -def _run(cmd: list[str]) -> int: - p = subprocess.run(cmd, capture_output=True, text=True) - if p.stdout: - print(p.stdout, end="") - if p.stderr: - print(p.stderr, end="", file=sys.stderr) - return p.returncode - - def _list_kind_clusters() -> list[str]: p = subprocess.run(["kind", "get", "clusters"], capture_output=True, text=True) if p.returncode != 0: @@ -51,7 +42,7 @@ def _interactive() -> None: CLUSTERS_DIR = clusters_dir() if not shutil.which("kind"): print("Не найден kind.", file=sys.stderr) - print(" Через Docker: make -C kind-k8s-develop delete (или make delete из каталога репозитория).", file=sys.stderr) + print(" Через Docker: make -C kind-k8s-develop docker up и удаление в веб-интерфейсе.", file=sys.stderr) sys.exit(127) clusters = _list_kind_clusters() diff --git a/app/docs/api_routes.md b/app/docs/api_routes.md new file mode 100644 index 0000000..7a50693 --- /dev/null +++ b/app/docs/api_routes.md @@ -0,0 +1,290 @@ +# Описание REST API веб-интерфейса kind-k8s-develop + +**Базовый префикс:** `/api/v1` +**Автор:** Сергей Антропов — [devops.org.ru](https://devops.org.ru) + +Интерактивная документация OpenAPI: после запуска `make docker up` откройте [http://127.0.0.1:6000/docs](http://127.0.0.1:6000/docs). + +--- + +## GET /api/v1/health + +Проверка: `kind`/`kubectl` в PATH и ответ движка контейнеров (`docker info` / `podman info` по `CONTAINER_CLI`). +`status`: `ok` — всё готово к созданию кластеров; `degraded` — чего-то не хватает (см. поля ниже). + +**Пример ответа 200 (JSON):** + +```json +{ + "status": "ok", + "kind_in_path": true, + "kubectl_in_path": true, + "container_cli": "docker", + "container_engine_ok": true, + "container_engine_detail": null +} +``` + +**Если сокет Docker недоступен:** + +```json +{ + "status": "degraded", + "kind_in_path": true, + "kubectl_in_path": true, + "container_cli": "docker", + "container_engine_ok": false, + "container_engine_detail": "Cannot connect to the Docker daemon..." +} +``` + +--- + +## GET /api/v1/versions + +Список стабильных тегов `kindest/node` с Docker Hub (для выпадающего списка в UI). +При `KIND_K8S_SKIP_VERSION_LIST=1` список пустой. + +**Пример ответа 200:** + +```json +{ + "tags": ["v1.32.0", "v1.31.4"], + "skipped": false +} +``` + +**Пример при пропуске загрузки:** + +```json +{ + "tags": [], + "skipped": true, + "reason": "KIND_K8S_SKIP_VERSION_LIST" +} +``` + +--- + +## GET /api/v1/stats + +Сводная статистика для дашборда. + +**Пример ответа 200:** + +```json +{ + "kind_clusters_count": 2, + "local_cluster_dirs_count": 2, + "total_workers_from_meta": 4, + "jobs_total": 5, + "jobs_recent_failed": 1 +} +``` + +Поле `total_workers_from_meta` может быть `null`, если ни в одном `meta.json` нет `worker_nodes`. + +--- + +## GET /api/v1/clusters + +Список имён: объединение `kind get clusters` и подкаталогов `clusters/*`. + +**Пример ответа 200 (массив):** + +```json +[ + { + "name": "dev", + "registered_in_kind": true, + "has_local_kubeconfig": true, + "meta": { + "cluster_name": "dev", + "kubernetes_version_tag": "v1.29.4", + "node_image": "kindest/node:v1.29.4", + "worker_nodes": 2, + "kubeconfig_patched_for_host": true + } + } +] +``` + +--- + +## GET /api/v1/jobs + +Список последних фоновых заданий (создание кластера), от новых к старым. Данные только в памяти процесса. + +**Query:** `limit` (1–200, по умолчанию 30). + +**Пример ответа 200 (массив `JobView`):** + +```json +[ + { + "job_id": "abc123", + "kind": "create_cluster", + "status": "success", + "cluster_name": "dev", + "created_at_utc": "2026-04-04T12:00:00+00:00", + "message": "Кластер создан", + "result": { "cluster_name": "dev", "kubernetes_version_tag": "v1.29.4" } + } +] +``` + +--- + +## GET /api/v1/clusters/{name}/kubeconfig + +Скачать файл `kubeconfig` (ответ — тело файла, `Content-Disposition` с именем `kubeconfig-{name}.yaml`). + +**Ошибка 404:** файла нет в `clusters/{name}/`. + +--- + +## GET /api/v1/clusters/{name}/workloads + +`kubectl get nodes -o wide` и `kubectl get pods -A` по сохранённому kubeconfig. + +**Пример ответа 200:** + +```json +{ + "cluster_name": "dev", + "nodes_rc": 0, + "nodes_output": "NAME STATUS ROLES ...", + "pods_rc": 0, + "pods_output": "NAMESPACE NAME READY STATUS ...", + "error": null +} +``` + +Если kubeconfig нет: `"error": "Нет сохранённого kubeconfig..."`, остальные поля подов/узлов — `null`. + +--- + +## GET /api/v1/clusters/{name} + +Детали и попытка `kubectl get nodes -o wide` с **сохранённого** `clusters/{name}/kubeconfig` (если файл есть). + +**Пример ответа 200:** + +```json +{ + "name": "dev", + "registered_in_kind": true, + "has_local_kubeconfig": true, + "kubeconfig_path": "/work/clusters/dev/kubeconfig", + "meta": { "worker_nodes": 2 }, + "kubectl_get_nodes_rc": 0, + "kubectl_get_nodes": "NAME STATUS ROLES AGE VERSION INTERNAL-IP EXTERNAL-IP OS-IMAGE KERNEL-VERSION CONTAINER-RUNTIME\n..." +} +``` + +--- + +## POST /api/v1/clusters + +Создание кластера **в фоне** (ответ 202). + +**Тело запроса (JSON):** + +```json +{ + "name": "dev", + "kubernetes_version": "1.29.4", + "workers": 2 +} +``` + +**Пример ответа 202:** + +```json +{ + "job_id": "a1b2c3d4e5f6...", + "status": "queued", + "message": "Создание кластера выполняется в фоне; опросите GET /api/v1/jobs/{job_id}" +} +``` + +**Ошибка 409 (кластер уже есть в kind):** + +```json +{ + "detail": "Кластер с таким именем уже есть в kind" +} +``` + +--- + +## GET /api/v1/jobs/{job_id} + +Статус фонового задания создания. + +**В процессе (пример 200):** + +```json +{ + "job_id": "a1b2...", + "kind": "create_cluster", + "status": "running", + "cluster_name": "dev", + "created_at_utc": "2026-04-04T12:00:00+00:00", + "message": null, + "result": null +} +``` + +**Успех (пример 200):** + +```json +{ + "job_id": "a1b2...", + "kind": "create_cluster", + "status": "success", + "cluster_name": "dev", + "created_at_utc": "2026-04-04T12:00:00+00:00", + "message": "Кластер создан", + "result": { + "cluster_name": "dev", + "kubernetes_version_tag": "v1.29.4", + "node_image": "kindest/node:v1.29.4", + "workers": 2, + "kubeconfig_path": "/work/clusters/dev/kubeconfig", + "kubeconfig_patched_for_host": true, + "nodes_ready": true, + "nodes_ready_message": "..." + } +} +``` + +**Ошибка 404:** + +```json +{ + "detail": "Задание не найдено" +} +``` + +--- + +## DELETE /api/v1/clusters/{name} + +`kind delete cluster` и удаление каталога `clusters/{name}/`. + +**Пример ответа 200:** + +```json +{ + "name": "dev", + "kind_delete_ok": true, + "summary": "kind delete: OK; удалена папка /work/clusters/dev" +} +``` + +--- + +## GET / + +HTML-дашборд (не JSON): форма создания, таблица кластеров, ссылки на Swagger. diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..cac6e6d --- /dev/null +++ b/app/main.py @@ -0,0 +1,80 @@ +"""Веб-интерфейс и REST API для управления локальными кластерами kind (порт по умолчанию 6000). + +Запуск в контейнере: ``python3 -m uvicorn main:app --host 0.0.0.0 --port 6000`` из каталога ``/opt/kind-k8s/app`` +или через ``make docker up`` / ``make podman up``. + +Автор: Сергей Антропов +Сайт: https://devops.org.ru +""" + +from __future__ import annotations + +import logging +import os +from contextlib import asynccontextmanager +from pathlib import Path + +from fastapi import FastAPI, Request +from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates + +from api.v1.router import api_router +from core.config import get_settings + +_BASE = Path(__file__).resolve().parent +logger = logging.getLogger("kind_k8s.web") + + +def _configure_logging() -> None: + """Единая настройка логов для uvicorn и модулей kind-k8s.""" + if logging.root.handlers: + return + level = logging.DEBUG if os.environ.get("KIND_K8S_DEBUG", "").strip().lower() in ("1", "true", "yes", "да") else logging.INFO + logging.basicConfig(level=level, format="%(levelname)s %(name)s: %(message)s") + logger.info("Логирование инициализировано, уровень=%s", logging.getLevelName(level)) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Старт и остановка приложения.""" + _configure_logging() + settings = get_settings() + logger.info("Запуск FastAPI «%s»", settings.app_title) + yield + logger.info("Остановка FastAPI") + + +settings = get_settings() +app = FastAPI(title=settings.app_title, lifespan=lifespan) +app.include_router(api_router, prefix="/api/v1") + +_templates_dir = _BASE / "templates" +_static_dir = _BASE / "static" +if _static_dir.is_dir(): + app.mount("/static", StaticFiles(directory=str(_static_dir)), name="static") + +templates = Jinja2Templates(directory=str(_templates_dir)) + + +@app.get("/", response_class=HTMLResponse, summary="Веб-интерфейс") +async def dashboard(request: Request) -> HTMLResponse: + """Главная страница с формой создания и таблицей кластеров.""" + if not _templates_dir.is_dir(): + return HTMLResponse( + content="

Шаблоны не найдены. Ожидается каталог app/templates/

", + status_code=500, + ) + return templates.TemplateResponse( + "dashboard.html", + { + "request": request, + "app_title": settings.app_title, + }, + ) + + +@app.get("/ui", include_in_schema=False) +async def ui_redirect() -> RedirectResponse: + """Удобный алиас на корень UI.""" + return RedirectResponse(url="/", status_code=307) diff --git a/app/models/schemas.py b/app/models/schemas.py new file mode 100644 index 0000000..c17a9b5 --- /dev/null +++ b/app/models/schemas.py @@ -0,0 +1,73 @@ +"""Модели запросов/ответов REST API веб-интерфейса. + +Автор: Сергей Антропов +Сайт: https://devops.org.ru +""" + +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import BaseModel, Field + + +class ClusterCreateRequest(BaseModel): + """Тело POST /api/v1/clusters — создание кластера.""" + + name: str = Field(..., min_length=1, max_length=63, description="DNS-имя кластера (a-z0-9-)") + kubernetes_version: str = Field( + ..., + min_length=1, + description="Версия Kubernetes / тег kindest/node, например 1.29.4 или v1.29.4", + ) + workers: int = Field(2, ge=0, le=20, description="Число worker-нод (0–20)") + + +class ClusterCreateAccepted(BaseModel): + """Ответ 202 — задание поставлено в очередь.""" + + job_id: str + status: Literal["queued"] = "queued" + message: str = "Создание кластера выполняется в фоне; опросите GET /api/v1/jobs/{job_id}" + + +class JobView(BaseModel): + """Статус фонового задания.""" + + job_id: str + kind: str + status: Literal["queued", "running", "success", "failed"] + cluster_name: str | None + created_at_utc: str + message: str | None = None + result: dict[str, Any] | None = None + + +class ClusterSummary(BaseModel): + """Элемент списка кластеров.""" + + name: str + registered_in_kind: bool + has_local_kubeconfig: bool + meta: dict[str, Any] = Field(default_factory=dict) + + +class StatsResponse(BaseModel): + """Краткая статистика для дашборда.""" + + kind_clusters_count: int + local_cluster_dirs_count: int + total_workers_from_meta: int | None + jobs_total: int + jobs_recent_failed: int + + +class ClusterWorkloadsResponse(BaseModel): + """Вывод kubectl по кластеру (узлы и поды).""" + + cluster_name: str + nodes_rc: int | None = None + nodes_output: str | None = None + pods_rc: int | None = None + pods_output: str | None = None + error: str | None = None diff --git a/app/static/js/dashboard.js b/app/static/js/dashboard.js new file mode 100644 index 0000000..9b0c11e --- /dev/null +++ b/app/static/js/dashboard.js @@ -0,0 +1,484 @@ +/** + * Панель управления кластерами kind (REST /api/v1). + * + * Автор: Сергей Антропов + * Сайт: https://devops.org.ru + */ +(function () { + "use strict"; + + const body = document.body; + const API = (body.dataset.apiBase || "/api/v1").replace(/\/$/, ""); + + /** @type {ReturnType | null} */ + let autoTimer = null; + /** @type {ReturnType | null} */ + let pollTimer = null; + let createInProgress = false; + + function formatApiError(data, fallback) { + if (!data) return fallback; + if (typeof data.detail === "string") return data.detail; + if (Array.isArray(data.detail)) { + return data.detail + .map(function (x) { + return (x.msg || x) + (x.loc ? " (" + x.loc.join(".") + ")" : ""); + }) + .join("; "); + } + return fallback; + } + + /** + * @param {string} path + * @param {RequestInit} [opts] + */ + async function api(path, opts) { + const url = path.startsWith("http") ? path : API + path; + const r = await fetch(url, opts); + const text = await r.text(); + let data; + try { + data = text ? JSON.parse(text) : null; + } catch { + data = { raw: text }; + } + if (!r.ok) { + const msg = formatApiError(data, text || r.statusText); + const err = new Error(msg); + err.status = r.status; + throw err; + } + return data; + } + + function escapeHtml(s) { + const d = document.createElement("div"); + d.textContent = s; + return d.innerHTML; + } + + function showToast(message, isError) { + const el = document.getElementById("toast"); + if (!el) return; + el.textContent = message; + el.classList.remove("hidden", "toast-error", "toast-ok"); + el.classList.add(isError ? "toast-error" : "toast-ok"); + clearTimeout(el._hideT); + el._hideT = setTimeout(function () { + el.classList.add("hidden"); + }, 4500); + } + + function setBusy(section, busy) { + const el = document.querySelector("[data-busy='" + section + "']"); + if (!el) return; + if (busy) { + el.setAttribute("aria-busy", "true"); + } else { + el.removeAttribute("aria-busy"); + } + el.classList.toggle("is-loading", busy); + } + + async function loadHealth() { + const el = document.getElementById("status-banner"); + if (!el) return; + try { + const h = await api("/health"); + const ok = + h.status === "ok" && h.container_engine_ok && h.kind_in_path && h.kubectl_in_path; + el.className = "status-banner " + (ok ? "ok" : "degraded"); + let lines = "Среда: "; + lines += escapeHtml(String(h.container_cli || "?")); + lines += " → " + (h.container_engine_ok ? "API OK" : "API недоступен"); + lines += " · kind: " + (h.kind_in_path ? "да" : "нет"); + lines += " · kubectl: " + (h.kubectl_in_path ? "да" : "нет"); + if (!h.container_engine_ok && h.container_engine_detail) { + lines += + "
" + + escapeHtml(String(h.container_engine_detail).slice(0, 400)) + + ""; + } + el.innerHTML = lines; + } catch (e) { + el.className = "status-banner err"; + el.textContent = "Не удалось запросить health: " + e.message; + } + } + + async function loadStats() { + const dl = document.getElementById("stats-dl"); + const errEl = document.getElementById("stats-err"); + if (!dl) return; + errEl.classList.add("hidden"); + dl.innerHTML = ""; + try { + const s = await api("/stats"); + const rows = [ + ["Кластеров в kind", s.kind_clusters_count], + ["Локальных каталогов", s.local_cluster_dirs_count], + ["Сумма workers (meta)", s.total_workers_from_meta != null ? s.total_workers_from_meta : "—"], + ["Заданий в памяти", s.jobs_total], + ["Заданий с ошибкой", s.jobs_recent_failed], + ]; + rows.forEach(function (kv) { + const dt = document.createElement("dt"); + dt.textContent = kv[0]; + const dd = document.createElement("dd"); + dd.textContent = String(kv[1]); + dl.appendChild(dt); + dl.appendChild(dd); + }); + } catch (e) { + errEl.textContent = "Статистика: " + e.message; + errEl.classList.remove("hidden"); + } + } + + async function loadVersions() { + const sel = document.getElementById("version-select"); + const verInput = document.getElementById("kubernetes_version"); + if (!sel || !verInput) return; + sel.innerHTML = ""; + try { + const data = await api("/versions"); + sel.innerHTML = ""; + if (!data.tags || !data.tags.length) { + sel.innerHTML = ""; + return; + } + const opt0 = document.createElement("option"); + opt0.value = ""; + opt0.textContent = "— выберите тег —"; + sel.appendChild(opt0); + data.tags.slice(0, 100).forEach(function (t) { + const o = document.createElement("option"); + o.value = t; + o.textContent = t; + sel.appendChild(o); + }); + sel.onchange = function () { + if (sel.value) verInput.value = sel.value.replace(/^v/, ""); + }; + } catch { + sel.innerHTML = ""; + } + } + + function jobBadgeClass(status) { + if (status === "success") return "badge badge-ok"; + if (status === "failed") return "badge badge-err"; + if (status === "running") return "badge badge-run"; + return "badge"; + } + + async function loadClusters() { + const tbody = document.querySelector("#tbl-clusters tbody"); + const msg = document.getElementById("list-msg"); + if (!tbody) return; + setBusy("clusters", true); + tbody.innerHTML = ""; + if (msg) msg.textContent = ""; + try { + const rows = await api("/clusters"); + rows.forEach(function (c) { + const tr = document.createElement("tr"); + const ver = (c.meta && (c.meta.kubernetes_version_tag || c.meta.node_image)) || "—"; + const wn = c.meta && c.meta.worker_nodes != null ? c.meta.worker_nodes : "—"; + const nameEsc = escapeHtml(c.name); + const dlHref = API + "/clusters/" + encodeURIComponent(c.name) + "/kubeconfig"; + tr.innerHTML = + "" + + nameEsc + + "" + + "" + + (c.registered_in_kind ? "да" : "нет") + + "" + + "" + + (c.has_local_kubeconfig ? "да" : "нет") + + "" + + "" + + escapeHtml(String(ver)) + + "" + + "" + + escapeHtml(String(wn)) + + "" + + ""; + const td = tr.querySelector(".actions"); + const b1 = document.createElement("button"); + b1.type = "button"; + b1.className = "btn-small"; + b1.textContent = "Состояние"; + b1.addEventListener("click", function () { + openWorkloadsModal(c.name); + }); + td.appendChild(b1); + if (c.has_local_kubeconfig) { + const a = document.createElement("a"); + a.href = dlHref; + a.className = "btn-secondary btn-small"; + a.download = "kubeconfig-" + c.name + ".yaml"; + a.textContent = "kubeconfig"; + a.title = "Скачать kubeconfig"; + td.appendChild(a); + } + const b2 = document.createElement("button"); + b2.type = "button"; + b2.className = "btn-small btn-danger"; + b2.textContent = "Удалить"; + b2.addEventListener("click", function () { + deleteCluster(c.name); + }); + td.appendChild(b2); + tbody.appendChild(tr); + }); + if (!rows.length && msg) msg.textContent = "Кластеров пока нет."; + } catch (e) { + if (msg) msg.textContent = "Ошибка списка: " + e.message; + } finally { + setBusy("clusters", false); + } + } + + async function loadJobs() { + const tbody = document.querySelector("#tbl-jobs tbody"); + const msg = document.getElementById("jobs-msg"); + if (!tbody) return; + setBusy("jobs", true); + tbody.innerHTML = ""; + if (msg) msg.textContent = ""; + try { + const rows = await api("/jobs?limit=30"); + rows.forEach(function (j) { + const tr = document.createElement("tr"); + const st = escapeHtml(j.status || ""); + tr.innerHTML = + "" + + "" + + escapeHtml(j.cluster_name || "—") + + "" + + "" + + st + + "" + + "" + + escapeHtml((j.message || "").slice(0, 160)) + + ""; + tbody.appendChild(tr); + }); + if (!rows.length && msg) { + msg.textContent = "Заданий ещё не было (или контейнер перезапускали)."; + } + } catch (e) { + if (msg) msg.textContent = "Задания: " + e.message; + } finally { + setBusy("jobs", false); + } + } + + async function openWorkloadsModal(name) { + const overlay = document.getElementById("modal-overlay"); + const sub = document.getElementById("modal-sub"); + const nodes = document.getElementById("modal-nodes"); + const pods = document.getElementById("modal-pods"); + const spin = document.getElementById("modal-spinner"); + if (!overlay) return; + document.getElementById("modal-title").textContent = "Кластер «" + name + "»"; + sub.textContent = ""; + nodes.textContent = ""; + pods.textContent = ""; + if (spin) spin.classList.remove("hidden"); + overlay.classList.remove("hidden"); + document.body.classList.add("modal-open"); + try { + const w = await api("/clusters/" + encodeURIComponent(name) + "/workloads"); + if (w.error) { + sub.textContent = w.error; + return; + } + sub.textContent = "kubectl: узлы rc=" + w.nodes_rc + ", поды rc=" + w.pods_rc; + nodes.textContent = w.nodes_output || "(пусто)"; + pods.textContent = w.pods_output || "(пусто)"; + } catch (e) { + sub.textContent = "Ошибка: " + e.message; + } finally { + if (spin) spin.classList.add("hidden"); + } + } + + function closeModal() { + const overlay = document.getElementById("modal-overlay"); + if (overlay) overlay.classList.add("hidden"); + document.body.classList.remove("modal-open"); + } + + async function deleteCluster(name) { + if (!confirm("Удалить кластер «" + name + "» и папку clusters/" + name + "?")) return; + const msg = document.getElementById("list-msg"); + if (msg) msg.textContent = "Удаление…"; + try { + const res = await api("/clusters/" + encodeURIComponent(name), { method: "DELETE" }); + if (msg) msg.textContent = res.summary || "Готово."; + showToast("Кластер «" + name + "» удалён", false); + await loadClusters(); + await loadStats(); + await loadJobs(); + } catch (e) { + if (msg) msg.textContent = "Ошибка удаления: " + e.message; + showToast(e.message, true); + } + } + + function setCreateFormDisabled(disabled) { + const form = document.getElementById("form-create"); + if (!form) return; + const btn = form.querySelector('[type="submit"]'); + if (btn) { + btn.disabled = disabled; + btn.textContent = disabled ? "Создание…" : "Создать кластер"; + } + form.querySelectorAll("input, select").forEach(function (el) { + el.disabled = disabled; + }); + } + + function pollJob(jobId) { + const pre = document.getElementById("job-status"); + const details = document.getElementById("job-details"); + const msg = document.getElementById("create-msg"); + if (pre) pre.classList.add("hidden"); + if (details) { + details.classList.remove("hidden"); + details.open = true; + } + if (pollTimer) clearInterval(pollTimer); + createInProgress = true; + setCreateFormDisabled(true); + + const tick = async function () { + try { + const j = await api("/jobs/" + jobId); + const preEl = document.getElementById("job-json"); + if (preEl) preEl.textContent = JSON.stringify(j, null, 2); + if (j.status === "success" || j.status === "failed") { + clearInterval(pollTimer); + pollTimer = null; + createInProgress = false; + setCreateFormDisabled(false); + if (msg) { + msg.textContent = + j.status === "success" ? "Кластер создан." : "Ошибка: " + (j.message || ""); + } + if (j.status === "success") { + showToast("Кластер создан", false); + } else { + showToast(j.message || "Ошибка создания", true); + } + await loadClusters(); + await loadStats(); + await loadJobs(); + } + } catch (e) { + if (msg) msg.textContent = "Ошибка опроса задания: " + e.message; + } + }; + tick(); + pollTimer = setInterval(tick, 2000); + } + + function refreshAll() { + loadHealth(); + loadStats(); + loadClusters(); + loadJobs(); + } + + function init() { + const form = document.getElementById("form-create"); + if (form) { + form.addEventListener("submit", async function (ev) { + ev.preventDefault(); + if (createInProgress) return; + const msg = document.getElementById("create-msg"); + const details = document.getElementById("job-details"); + if (msg) msg.textContent = ""; + if (details) details.classList.add("hidden"); + const fd = new FormData(form); + const body = { + name: String(fd.get("name") || "").trim(), + kubernetes_version: String(fd.get("kubernetes_version") || "").trim(), + workers: parseInt(String(fd.get("workers") || "0"), 10), + }; + try { + const res = await api("/clusters", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (msg) msg.textContent = "Задание: " + res.job_id; + pollJob(res.job_id); + } catch (e) { + if (msg) msg.textContent = "Ошибка: " + e.message; + showToast(e.message, true); + } + }); + } + + const bStats = document.getElementById("btn-refresh-stats"); + if (bStats) bStats.addEventListener("click", function () { loadHealth(); loadStats(); }); + + const bList = document.getElementById("btn-refresh-list"); + if (bList) bList.addEventListener("click", loadClusters); + + const bJobs = document.getElementById("btn-refresh-jobs"); + if (bJobs) bJobs.addEventListener("click", loadJobs); + + const bAll = document.getElementById("btn-refresh-all"); + if (bAll) bAll.addEventListener("click", refreshAll); + + const mClose = document.getElementById("modal-close"); + if (mClose) mClose.addEventListener("click", closeModal); + + const overlay = document.getElementById("modal-overlay"); + if (overlay) { + overlay.addEventListener("click", function (ev) { + if (ev.target === overlay) closeModal(); + }); + } + + document.addEventListener("keydown", function (ev) { + if (ev.key === "Escape") closeModal(); + }); + + const auto = document.getElementById("auto-refresh"); + if (auto) { + auto.addEventListener("change", function (ev) { + if (autoTimer) { + clearInterval(autoTimer); + autoTimer = null; + } + if (ev.target.checked) { + autoTimer = setInterval(refreshAll, 15000); + } + }); + } + + loadHealth(); + loadStats(); + loadVersions(); + loadClusters(); + loadJobs(); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } +})(); diff --git a/app/static/style.css b/app/static/style.css new file mode 100644 index 0000000..d50160d --- /dev/null +++ b/app/static/style.css @@ -0,0 +1,504 @@ +/* Минимальные стили веб-интерфейса kind-k8s-develop. + Автор: Сергей Антропов — https://devops.org.ru */ + +:root { + color-scheme: light dark; + --bg: #0f1419; + --fg: #e7ecf1; + --muted: #8b98a5; + --card: #1a2332; + --accent: #3b82f6; + --border: #2f3d52; + font-family: system-ui, -apple-system, "Segoe UI", Roboto, Ubuntu, sans-serif; +} + +@media (prefers-color-scheme: light) { + :root { + --bg: #f4f6f9; + --fg: #111827; + --muted: #6b7280; + --card: #ffffff; + --border: #e5e7eb; + } +} + +body { + margin: 0; + padding: 0; + background: var(--bg); + color: var(--fg); + line-height: 1.45; +} + +body.modal-open { + overflow: hidden; +} + +/* Пропуск к основному содержимому (a11y) */ +.skip-link { + position: absolute; + left: -9999px; + top: 0.5rem; + z-index: 200; + padding: 0.5rem 1rem; + background: var(--accent); + color: #fff; + border-radius: 6px; + font-weight: 600; +} +.skip-link:focus { + left: 0.5rem; + outline: 2px solid var(--fg); + outline-offset: 2px; +} + +/* Верхняя навигация */ +.top-nav { + border-bottom: 1px solid var(--border); + background: var(--card); +} +.top-nav-inner { + max-width: 72rem; + margin: 0 auto; + padding: 0.65rem 1.25rem; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} +.nav-brand { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 0.5rem; +} +.nav-logo { + color: var(--accent); + font-size: 1.25rem; + line-height: 1; +} +.nav-title { + font-weight: 700; + font-size: 1.05rem; +} +.nav-tag { + font-size: 0.8rem; +} +.nav-links { + display: flex; + flex-wrap: wrap; + gap: 0.35rem 1rem; +} +.nav-link { + color: var(--accent); + text-decoration: none; + font-size: 0.9rem; +} +.nav-link:hover { + text-decoration: underline; +} + +.app-main { + max-width: 72rem; + margin: 0 auto; + padding: 1.25rem; + outline: none; +} + +.app-footer { + max-width: 72rem; + margin: 0 auto; + padding: 1rem 1.25rem 2rem; + border-top: 1px solid var(--border); + font-size: 0.85rem; +} + +.page-intro { + margin-bottom: 1rem; +} +.page-title { + margin: 0 0 0.35rem; + font-size: 1.35rem; +} +.page-lead { + margin: 0; + max-width: 42rem; +} + +.toolbar { + margin-bottom: 1rem; +} + +/* Тост уведомлений */ +.toast { + position: fixed; + top: 1rem; + right: 1rem; + z-index: 150; + max-width: min(22rem, calc(100vw - 2rem)); + padding: 0.65rem 1rem; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--card); + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.25); + font-size: 0.9rem; +} +.toast-ok { + border-color: #15803d; +} +.toast-error { + border-color: #b91c1c; +} + +/* Бейджи статусов */ +.badge { + display: inline-block; + padding: 0.15rem 0.45rem; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 600; + border: 1px solid var(--border); + background: rgba(0, 0, 0, 0.15); +} +.badge-ok { + border-color: #15803d; + color: #4ade80; +} +.badge-err { + border-color: #b91c1c; + color: #f87171; +} +.badge-run { + border-color: #b45309; + color: #fbbf24; +} + +.cluster-name { + font-weight: 600; +} + +/* Состояние загрузки таблиц */ +/* Пока идёт запрос к API, секция слегка приглушается */ +[data-busy].is-loading { + opacity: 0.55; + pointer-events: none; + transition: opacity 0.2s ease; +} + +.job-details { + margin-top: 0.75rem; + border: 1px solid var(--border); + border-radius: 8px; + padding: 0.5rem 0.75rem; + background: rgba(0, 0, 0, 0.12); +} +.job-details summary { + cursor: pointer; + font-size: 0.9rem; + font-weight: 600; +} +.job-json-pre { + max-height: 12rem; + margin-top: 0.5rem; +} + +/* Спиннер в модалке */ +.modal-head { + margin-bottom: 0.5rem; + align-items: flex-start; +} +.modal-title-text { + margin: 0; + font-size: 1.05rem; + flex: 1; + padding-right: 0.5rem; +} +.modal-sub { + margin: 0 0 0.75rem; +} +.modal-section-title { + margin: 0.5rem 0 0; + font-size: 0.85rem; +} +.modal-spinner { + display: flex; + align-items: center; + gap: 0.65rem; + padding: 1rem 0; +} +.spinner { + width: 1.5rem; + height: 1.5rem; + border: 3px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.7s linear infinite; +} +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +button:disabled, +input:disabled, +select:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +button:focus-visible, +a:focus-visible, +input:focus-visible, +select:focus-visible, +summary:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.header h1 { + margin: 0 0 0.25rem; + font-size: 1.5rem; +} + +.muted { + color: var(--muted); +} + +.grid { + display: grid; + gap: 1rem; + margin: 1.25rem 0; +} + +@media (min-width: 900px) { + .grid { + grid-template-columns: 1fr 1fr; + align-items: start; + } +} + +.card { + background: var(--card); + border: 1px solid var(--border); + border-radius: 10px; + padding: 1rem 1.1rem; +} + +.card h2 { + margin-top: 0; + font-size: 1.1rem; +} + +label { + display: block; + margin: 0.65rem 0 0.25rem; + font-size: 0.9rem; +} + +input, +select, +button { + font: inherit; +} + +input, +select { + width: 100%; + max-width: 28rem; + padding: 0.45rem 0.55rem; + border-radius: 6px; + border: 1px solid var(--border); + background: transparent; + color: inherit; +} + +button { + margin-top: 0.75rem; + padding: 0.45rem 0.85rem; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--accent); + color: #fff; + cursor: pointer; +} + +button:hover { + filter: brightness(1.08); +} + +.row { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + align-items: center; +} + +.row.spread { + justify-content: space-between; + align-items: center; +} + +.mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.85rem; + white-space: pre-wrap; + word-break: break-word; + background: rgba(0, 0, 0, 0.2); + padding: 0.6rem; + border-radius: 6px; + max-height: 14rem; + overflow: auto; +} + +.table-wrap { + overflow-x: auto; +} + +table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; +} + +th, +td { + border-bottom: 1px solid var(--border); + padding: 0.45rem 0.35rem; + text-align: left; + vertical-align: top; +} + +.msg { + margin-top: 0.5rem; +} + +.hidden { + display: none; +} + +.footer { + margin-top: 2rem; + font-size: 0.9rem; +} + +.footer a { + color: var(--accent); +} + +/* Плашка состояния среды (Docker/Podman, kind, kubectl) */ +.status-banner { + border-radius: 8px; + padding: 0.65rem 0.85rem; + margin-bottom: 1rem; + font-size: 0.9rem; + border: 1px solid var(--border); +} +.status-banner.ok { + border-color: #15803d; + background: rgba(34, 197, 94, 0.12); +} +.status-banner.degraded { + border-color: #b45309; + background: rgba(245, 158, 11, 0.12); +} +.status-banner.err { + border-color: #b91c1c; + background: rgba(239, 68, 68, 0.12); +} + +.stats-dl { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.25rem 1rem; + margin: 0; + font-size: 0.9rem; +} +.stats-dl dt { + margin: 0; + color: var(--muted); +} +.stats-dl dd { + margin: 0; +} + +button.btn-secondary, +a.btn-secondary { + background: transparent; + color: var(--accent); + border-color: var(--accent); + text-decoration: none; + display: inline-block; + margin-top: 0; + margin-right: 0.35rem; + padding: 0.35rem 0.65rem; + font-size: 0.85rem; +} + +button.btn-small { + margin-top: 0; + padding: 0.3rem 0.55rem; + font-size: 0.8rem; +} + +td.actions { + white-space: nowrap; +} +td.actions button { + margin-top: 0; + margin-right: 0.25rem; +} + +/* Модальное окно «Состояние кластера» */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.55); + display: flex; + align-items: flex-start; + justify-content: center; + padding: 2rem 1rem; + z-index: 100; + overflow-y: auto; +} +.modal-overlay.hidden { + display: none; +} +.modal-box { + background: var(--card); + border: 1px solid var(--border); + border-radius: 10px; + max-width: 52rem; + width: 100%; + padding: 1rem 1.15rem; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35); +} +.modal-box h3 { + margin: 0 0 0.75rem; + font-size: 1.05rem; +} +.modal-box pre { + max-height: 40vh; + margin: 0.5rem 0 1rem; +} +.modal-close { + flex-shrink: 0; + margin-top: 0; +} + +button.btn-danger, +a.btn-danger { + background: transparent; + color: #f87171; + border-color: #b91c1c; +} +@media (prefers-color-scheme: light) { + button.btn-danger, + a.btn-danger { + color: #b91c1c; + } +} +button.btn-danger:hover { + filter: brightness(1.12); +} diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..315cf12 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,49 @@ +{# Общий каркас страниц веб-интерфейса kind-k8s-develop. + Автор: Сергей Антропов — https://devops.org.ru #} + + + + + + {% block page_title %}{{ app_title }}{% endblock %} — kind + + {% block head_extra %}{% endblock %} + + + + + + + + +
+ {% block content %}{% endblock %} +
+ +
+ {% block footer %} + Данные: том clusters/ на хосте · app/docs/api_routes.md + {% endblock %} +
+ + {% block scripts %}{% endblock %} + + diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html new file mode 100644 index 0000000..d011af0 --- /dev/null +++ b/app/templates/dashboard.html @@ -0,0 +1,158 @@ +{# Главная панель: кластеры, задания, создание. + Расширяет base.html; логика в /static/js/dashboard.js. + Автор: Сергей Антропов — https://devops.org.ru #} +{% extends "base.html" %} + +{% block footer %} +Документация API: app/docs/api_routes.md · том данных clusters/ +{% endblock %} + +{% block content %} +
+

Панель управления

+

+ Создание и удаление кластеров kind, kubeconfig и просмотр узлов/подов через kubectl внутри контейнера. +

+
+ +
+ Проверка среды… +
+ +
+
+ + +
+
+ +
+
+

Статистика

+
+ +
+ +
+
+ +
+

Создать кластер

+
+ + + + +
+ +
+

+ Можно ввести версию вручную ниже. +

+ + + + + + + +
+

+ +
+
+ +
+
+

Кластеры

+ +
+
+ + + + + + + + + + + + +
ИмяkindkubeconfigВерсияWorkersДействия
+
+

+
+ +
+
+

Последние задания

+ +
+
+ + + + + + + + + + +
Время (UTC)КластерСтатусСообщение
+
+

+
+ + +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/clusters/.gitkeep b/clusters/.gitkeep index e69de29..da85a53 100644 --- a/clusters/.gitkeep +++ b/clusters/.gitkeep @@ -0,0 +1,2 @@ +# Каталог тома для kubeconfig и meta.json локальных кластеров kind. +# Содержимое подпапок не коммитится (см. .gitignore). diff --git a/docker-compose.yml b/docker-compose.yml index fde8492..d324874 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,36 +1,44 @@ -# Кластер kind на хосте через сокет Docker/Podman; скрипты внутри образа kind-k8s-tools. -# На хост монтируется только ./clusters → артефакты (kubeconfig, meta.json). -# -# Создание (интерактивно): make create +# Веб-UI kind: том ./clusters, сокет Docker/Podman. Каталог ./app монтируется в контейнер — +# правки Python/шаблонов/static без пересборки образа; uvicorn --reload (см. KIND_K8S_UVICORN_RELOAD). +# Запуск: make docker up # # Podman (пример rootless): # export CONTAINER_SOCKET="$XDG_RUNTIME_DIR/podman/podman.sock" -# podman compose run --rm -it kind-k8s-tools python3 /opt/kind-k8s/app/create_cluster.py +# make podman up # # Автор: Сергей Антропов — https://devops.org.ru services: - kind-k8s-tools: + kind-k8s-web: build: context: . dockerfile: Dockerfile args: KIND_VERSION: ${KIND_VERSION:-0.24.0} + KUBECTL_VERSION: ${KUBECTL_VERSION:-} image: kind-k8s-tools:local volumes: - ./clusters:/work/clusters - ${CONTAINER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock - working_dir: /work + # Локальная разработка: код с хоста сразу в контейнере (пересборка образа не нужна). + - ./app:/opt/kind-k8s/app + working_dir: /opt/kind-k8s/app + ports: + - "${KIND_K8S_WEB_PORT:-6000}:6000" environment: DOCKER_HOST: unix:///var/run/docker.sock KIND_K8S_IN_CONTAINER: "1" - # Из файла kind-k8s/.env (см. env.example): патч kubeconfig и CLI к сокету - KIND_K8S_PATCH_KUBECONFIG: ${KIND_K8S_PATCH_KUBECONFIG:-} + KIND_K8S_WORKDIR: /work + # По умолчанию включено: kubeconfig с хоста открывает API через проброшенный порт. + KIND_K8S_PATCH_KUBECONFIG: ${KIND_K8S_PATCH_KUBECONFIG:-1} CONTAINER_CLI: ${CONTAINER_CLI:-docker} - # Выбор версии kindest/node (create_cluster.py); см. env.example KIND_K8S_SKIP_VERSION_LIST: ${KIND_K8S_SKIP_VERSION_LIST:-} KIND_K8S_VERSION_LIST_DISPLAY: ${KIND_K8S_VERSION_LIST_DISPLAY:-} KIND_K8S_HUB_TAGS_MAX_PAGES: ${KIND_K8S_HUB_TAGS_MAX_PAGES:-} KIND_K8S_DEBUG: ${KIND_K8S_DEBUG:-} - stdin_open: true - tty: true + KIND_K8S_WAIT_NODES: ${KIND_K8S_WAIT_NODES:-} + KIND_K8S_WAIT_NODES_TIMEOUT_SEC: ${KIND_K8S_WAIT_NODES_TIMEOUT_SEC:-} + KIND_K8S_APP_TITLE: ${KIND_K8S_APP_TITLE:-} + # 1 — uvicorn --reload (изменения в ./app); 0 — один процесс без reload. + KIND_K8S_UVICORN_RELOAD: ${KIND_K8S_UVICORN_RELOAD:-1} + command: ["/opt/kind-k8s/run_uvicorn.sh"] diff --git a/docs/k8s_runbook.md b/docs/k8s_runbook.md deleted file mode 100644 index 635c95d..0000000 --- a/docs/k8s_runbook.md +++ /dev/null @@ -1,119 +0,0 @@ -# Runbook эксплуатации Kubernetes модуля - -Документ относится к приложению **AppsTemplate** (модуль Kubernetes в веб-платформе). Репозиторий **kind-k8s-develop** хранит этот runbook рядом с утилитами локального kind. - -Автор: Сергей Антропов -Сайт: https://devops.org.ru - -## 1. Кластер недоступен - -- Симптомы: `GET /api/v1/k8s/clusters/{id}/health` возвращает `unreachable`, preflight показывает ошибку connectivity. -- Проверки: - - Валиден ли kubeconfig кластера в записи `k8s_clusters`. - - Доступен ли API endpoint кластера из контейнера приложения. - - Не истекли ли сертификаты/токены в kubeconfig. -- Действия: - - Обновить kubeconfig кластера через `PATCH /api/v1/k8s/clusters/{id}`. - - Повторно запустить health-check с `force=1`. - - При необходимости временно отложить массовую выдачу доступов. - -### 1.1. Docker Desktop: kubeconfig с `127.0.0.1`, приложение в контейнере - -- **Симптом:** health-check и любые вызовы API кластера из приложения дают `unreachable`, хотя `kubectl` с хоста работает. -- **Причина:** в kubeconfig `server: https://127.0.0.1:…` означает «loopback того процесса, который подключается». Внутри контейнера `127.0.0.1` — это не хост с Docker Desktop. -- **Что сделать:** в записи кластера заменить адрес API на тот, который виден **из контейнера приложения**: - - **macOS / Windows, Docker Desktop:** обычно `https://host.docker.internal:ПОРТ` (тот же порт, что был у `127.0.0.1`, часто `6443`). - - **Linux:** при необходимости добавить в `docker-compose` для сервиса приложения `extra_hosts: ["host.docker.internal:host-gateway"]` и использовать `host.docker.internal`, либо указать IP шлюза к хосту / LAN-IP. -- **Имя вида `Something.local` (Bonjour / mDNS):** с Mac такое имя обычно резолвится на хосте, но **внутри контейнера приложения часто не резолвится** — health-check снова будет `unreachable` при том же kubeconfig. Для Docker Desktop надёжнее **`https://host.docker.internal:6443`** (или фиксированный LAN-IP Mac), а не `*.local`. -- **Только IPv6 у `host.docker.internal`:** если в контейнере `getent hosts host.docker.internal` показывает один адрес `fdc4:...` (IPv6), а API слушает IPv4, соединение может не установиться. В репозитории **AppsTemplate** для сервиса `app` в `docker-compose.yml` задано `extra_hosts: ["host.docker.internal:host-gateway"]` — после `docker compose up -d` перепроверьте `getent` (должен появиться маршрут через IPv4-шлюз хоста). Kubeconfig: `server: https://host.docker.internal:6443`. -- **TLS / hostname mismatch:** при `server: https://host.docker.internal:6443` сертификат API чаще всего выписан **не** на это имя → ошибка вида `CERTIFICATE_VERIFY_FAILED` / `Hostname mismatch`. **`GET /clusters/{id}/health`** сначала подключается с kubeconfig из БД; при типичной ошибке TLS выполняется **вторая попытка** с временной копией, где для cluster из `current-context` включён `insecure-skip-tls-verify` (в БД ничего не пишется). В ответе может быть `tls_insecure_fallback_used: true`. Для **apply RBAC / выдачи доступов** по-прежнему нужен рабочий TLS в сохранённом kubeconfig или явный `insecure-skip-tls-verify` в YAML. -- **Явный insecure в kubeconfig (рекомендуется для dev, если нужны не только health):** в записи **того же** `cluster` добавьте **`insecure-skip-tls-verify: true`**. Пример фрагмента: - ```yaml - clusters: - - name: docker-desktop - cluster: - server: https://host.docker.internal:6443 - insecure-skip-tls-verify: true - # certificate-authority-data: ... # при insecure можно убрать, чтобы не путаться - ``` - В **production** так не делают: там `server` и SAN в сертификате должны совпадать, проверка TLS включена. -- Обновление kubeconfig: UI **«Редактировать кластер»** (`/k8s/clusters/{id}/edit`) или `PATCH /api/v1/k8s/clusters/{id}`. - -## 2. Частично примененные манифесты RBAC - -- Симптомы: часть пользователей получила доступ, часть — ошибки в `POST /api/v1/k8s/access/bulk`. -- Проверки: - - Использовать preflight (`/access/preflight`, `/access/bulk/preflight`) перед повторной операцией. - - Проверить существующие активные записи в `k8s_user_configs`. - - Проверить аудит `k8s_access_audit`. -- Действия: - - Повторить операцию с `idempotency_key` только после устранения причины. - - Для конфликтных пользователей выполнить точечный revoke/restore или delete/create. - - Если обнаружены дубликаты, очистить лишние записи и заново выдать доступ. - -## 3. Восстановление после ошибок миграций - -- Симптомы: ошибки `UndefinedTableError` при старте приложения. -- Проверки: - - Убедиться, что применены миграции модуля из `app/db/migrations/modules/k8s/` (репозиторий AppsTemplate). - - Проверить наличие таблиц `k8s_*` в БД. -- Действия: - - Применить восстановительную миграцию `app/db/migrations/102_k8s_repair_missing_tables.sql`. - - Перезапустить приложение. - - Проверить работу API `GET /api/v1/k8s/health`. - -## 4. Ротация ключа шифрования kubeconfig - -- **Основной источник ключа:** настройка в БД `k8s.kubeconfig_encryption_key` (страница **Настройки → Модули → Kubernetes**). При непустом значении в БД оно имеет приоритет над переменной окружения. -- **Резерв:** `K8S_KUBECONFIG_ENCRYPTION_KEY` в `.env` — используется только если в БД ключ пустой (удобно для первого запуска и CI; см. `env.example` в AppsTemplate). -- Рекомендуемый порядок смены ключа: - 1. Перевести сервис в окно обслуживания. - 2. Считать и дешифровать существующие значения из `k8s_clusters.kubeconfig_encrypted` и `k8s_user_configs.kubeconfig_encrypted` **текущим** ключом (из БД или ENV — в том же порядке приоритета, что у приложения). - 3. Перешифровать новым ключом и сохранить обратно в таблицы. - 4. Сохранить новый секрет в настройках модуля (или обновить ENV, если используете только резерв). - 5. Перезапустить приложение при необходимости и проверить health-check и скачивание kubeconfig. -- Важно: не удалять старый ключ до завершения полной перешифровки. - -## 5. Мониторинг и безопасность фоновых задач - -- Используйте страницу `/k8s/jobs` и API `GET /api/v1/k8s/jobs` (пагинация `skip`/`limit`, фильтр `status`) и `GET /api/v1/k8s/jobs/{job_id}` для полного текста ошибки и результата. -- Детальные статусы: - - `GET /api/v1/k8s/access/jobs/{job_id}` - - `GET /api/v1/k8s/access/bulk/jobs/{job_id}` - - `GET /api/v1/k8s/kubeconfig/merge/jobs/{job_id}` -- Все job-status ответы проходят маскировку чувствительных полей (`kubeconfig`, `token`, `secret`, `password`, `private_key`, `certificate`). -- Для merge-задач поле `result.rendered` не возвращается в статусе и доступно только через download endpoint. -- Рекомендуется искать инциденты по `correlation_id` в логах приложения и в записи задачи в `k8s_jobs`. -- Утилита наблюдаемости: `GET /api/v1/k8s/observability/jobs/{job_id}` для задач с `job_type=k8s.observability`; UI — `/k8s/observability`. - -## 6. Утилита стека наблюдаемости (Metrics Server / Prometheus) - -- Настройки: `k8s.observability_metrics_server_manifest_url` (по умолчанию официальный `components.yaml` metrics-server), `k8s.observability_metrics_server_kubelet_insecure_tls`, `k8s.observability_prometheus_manifest_bundle_url` (опционально), таймауты и лимит кластеров за операцию. -- **Metrics Server:** при типичных kubeadm/kind кластерах без корректных kubelet-сертификатов включите `--kubelet-insecure-tls` (чекбокс в UI или настройка/тело запроса install). -- **Prometheus stack:** полный `kube-prometheus-stack` обычно ставят **Helm** вне приложения; через API имеет смысл подключать **сокращённый** multi-doc YAML, где все объекты имеют поддерживаемые `kind`. Иначе часть документов попадёт в `skipped` в ответе задачи. -- **Откат:** `POST .../uninstall/jobs` удаляет ресурсы в обратном порядке того же бандла; при ручных правках в кластере возможны остаточные объекты — добейте `kubectl delete` / повторным uninstall. - -### 6.1. Локальный кластер kind для разработки - -Этот репозиторий (**kind-k8s-develop**): образ **kind-k8s-tools**, **`make create`** (Docker и make на хосте) поднимает kind; артефакты — **`clusters/<имя>/kubeconfig`**. Импорт kubeconfig в **AppsTemplate** см. **§1.1** (адрес API должен быть достижим **из контейнера приложения**, не обязательно `127.0.0.1`). Краткая инструкция по командам — в **`README.md`** в корне этого репозитория. - -## 7. Срок действия доступа (`access_expires_at`) - -- В `POST /access`, `POST /access/bulk` и `PATCH /access/{id}` можно задать дату окончания; пустое значение в PATCH сбрасывает срок. -- Фоновый цикл использует `k8s.access_expiry_check_interval_hours`; отзыв выполняется так же, как ручной revoke (RBAC в кластере, уведомление владельцу; в аудите `reason: access_expired`). -- Уведомление при автоотзыве: в тексте указано «система (автоматически)». - -## 8. Отправка kubeconfig в Telegram - -- В настройках модуля задать `k8s.telegram_bot_token`. У владельца доступа в профиле — поле **`telegram_chat_id`** (числовой id; миграция `105_profile_field_telegram_chat_id.sql` добавляет описание в реестр полей). -- Ссылка `https://t.me/...` **не подставляет** chat_id: пользователь должен написать боту и узнать id (например через @userinfobot), затем сохранить `telegram_chat_id`. -- `POST /api/v1/k8s/access/{config_id}/send-telegram`: владелец или лид с `modules.k8s:update`. Длинный YAML режется по `k8s.telegram_kubeconfig_max_chars` — тогда в чат уходит короткое сообщение со ссылкой на `/k8s/my-configs`. - -## 9. Дрейф RBAC и сверка с кластером (reconcile) - -- **Симптом:** в UI на карточке кластера колонка «Сверка RBAC» показывает `drift_detected` или `sync_error`, либо администратор вручную удалил Role/Binding в кластере. -- **Проверки:** - - `POST /api/v1/k8s/access/{config_id}/reconcile-check` (или кнопка «Сверка» на вкладке «Доступы») — пересчитать статус по сохранённым `rbac_manifests`. - - Убедиться, что у записи доступа непустые `rbac_manifests` (иначе сначала `apply-manifests` или PATCH с пересборкой). -- **Действия при дрейфе:** `POST /api/v1/k8s/access/{config_id}/apply-manifests` — повторное применение; после успешного apply статус сверки обновляется вместе с записью. -- **Настройки:** `k8s.reconcile_check_timeout_seconds`, опционально `k8s.reconcile_background_interval_hours` (часы между фоновыми прогонами для активных доступов; `0` — только ручной вызов). diff --git a/env.example b/env.example deleted file mode 100644 index 4c03eba..0000000 --- a/env.example +++ /dev/null @@ -1,27 +0,0 @@ -# Пример переменных для каталога kind-k8s. -# Скопируйте в kind-k8s/.env и раскомментируйте нужные строки. -# Docker Compose читает .env при запуске из kind-k8s (make create, compose build и т.д.). - -# --- Сборка образа kind-k8s-tools (build-arg в docker-compose.yml) --- -# KIND_VERSION=0.24.0 - -# --- Сокет API контейнеров (volume в docker-compose.yml) --- -# Docker по умолчанию подставляет /var/run/docker.sock; Podman rootless — свой путь. -# CONTAINER_SOCKET=/var/run/docker.sock - -# --- Среда внутри контейнера (передаётся через docker-compose environment) --- -# Принудительно пропатчить server в kubeconfig на 127.0.0.1:<порт> (иначе — только при KIND_K8S_IN_CONTAINER) -# KIND_K8S_PATCH_KUBECONFIG=1 -# Команда для docker port / аналога (часто docker даже при Podman) -# CONTAINER_CLI=docker - -# Список версий kindest/node при make create (Docker Hub, стабильные теги >= 1.19) -# KIND_K8S_SKIP_VERSION_LIST=1 -# KIND_K8S_VERSION_LIST_DISPLAY=50 -# KIND_K8S_HUB_TAGS_MAX_PAGES=60 -# KIND_K8S_DEBUG=1 - -# --- Только Makefile (в .env compose не используется; задайте в оболочке или: make VAR=value) --- -# COMPOSE=docker compose -# COMPOSE=podman compose -# COMPOSE_BUILD_FLAGS=--platform linux/arm64 diff --git a/scripts/run_uvicorn.sh b/scripts/run_uvicorn.sh new file mode 100755 index 0000000..45378cf --- /dev/null +++ b/scripts/run_uvicorn.sh @@ -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" diff --git a/scripts/setup_env_interactive.py b/scripts/setup_env_interactive.py index 267483e..3336844 100644 --- a/scripts/setup_env_interactive.py +++ b/scripts/setup_env_interactive.py @@ -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__":