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

- Дашборд (Jinja2 + static), управление кластерами kind, задания и kubeconfig.
- API: health, stats, clusters CRUD, versions, jobs; документация app/docs/api_routes.md.
- Docker Compose: том app, uvicorn reload, KIND_K8S_PATCH_KUBECONFIG по умолчанию 1.
- setup_env_interactive.py: список переменных в скрипте, удалён env.example.
- Makefile: явный префикс docker/podman; прочие правки CLI и ядра кластеров.
This commit is contained in:
Sergey Antropoff
2026-04-04 05:39:53 +03:00
parent ae961ef5fe
commit e46a62cfdb
31 changed files with 2507 additions and 393 deletions
+1
View File
@@ -1,4 +1,5 @@
# Сгенерированные конфиги и kubeconfig локальных кластеров kind # Сгенерированные конфиги и kubeconfig локальных кластеров kind
.DS_Store
.env .env
clusters/*/ clusters/*/
!clusters/.gitkeep !clusters/.gitkeep
+14 -5
View File
@@ -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). # Данные кластеров монтируются в /work/clusters (см. docker-compose.yml).
# #
# Воспроизводимая версия kubectl: build-arg KUBECTL_VERSION=v1.32.0 (или пусто — stable.txt).
#
# Автор: Сергей Антропов — https://devops.org.ru # Автор: Сергей Антропов — https://devops.org.ru
FROM alpine:3.20
ARG KIND_VERSION=0.24.0 ARG KIND_VERSION=0.24.0
# Пусто — взять актуальный stable.txt; иначе явная версия, например v1.32.0
ARG KUBECTL_VERSION=
# Платформа целевого образа (BuildKit подставляет amd64/arm64; иначе — uname внутри слоя) # Платформа целевого образа (BuildKit подставляет amd64/arm64; иначе — uname внутри слоя)
ARG TARGETARCH 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:-}" \ && ARCH="${TARGETARCH:-}" \
&& if [ -z "$ARCH" ]; then ARCH="$(uname -m)"; fi \ && if [ -z "$ARCH" ]; then ARCH="$(uname -m)"; fi \
&& case "$ARCH" in \ && case "$ARCH" in \
@@ -19,11 +25,14 @@ RUN apk add --no-cache python3 docker-cli curl bash ca-certificates \
esac \ esac \
&& curl -sSLo /usr/local/bin/kind "https://kind.sigs.k8s.io/dl/v${KIND_VERSION}/kind-linux-${KARCH}" \ && curl -sSLo /usr/local/bin/kind "https://kind.sigs.k8s.io/dl/v${KIND_VERSION}/kind-linux-${KARCH}" \
&& chmod +x /usr/local/bin/kind \ && 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" \ && 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 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 \ ENV KIND_K8S_WORKDIR=/work \
PYTHONPATH=/opt/kind-k8s/app \ PYTHONPATH=/opt/kind-k8s/app \
+58 -64
View File
@@ -1,81 +1,75 @@
# Локальные кластеры Kubernetes через kind. # kind-k8s-develop — веб-интерфейс (FastAPI) для kind.
# Основной сценарий: только Docker (+ make) на хосте — скрипты и kind внутри образа kind-k8s-tools. # Создание кластеров — в браузере: 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://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 \ ifneq (,$(filter podman,$(MAKECMDGOALS)))
create-host delete-host status-host check-host kubeconfig create-compose delete-compose 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)))) KIND_K8S_DIR := $(abspath $(dir $(lastword $(MAKEFILE_LIST))))
SETUP_ENV_SCRIPT := $(KIND_K8S_DIR)/scripts/setup_env_interactive.py SETUP_ENV_SCRIPT := $(KIND_K8S_DIR)/scripts/setup_env_interactive.py
PYTHON ?= python3 PYTHON ?= python3
COMPOSE ?= docker compose # При «exec format error» у kind: make docker compose-build COMPOSE_BUILD_FLAGS=--platform linux/arm64
# При ошибке «exec format error» у kind в контейнере: make compose-build COMPOSE_BUILD_FLAGS=--platform linux/arm64
COMPOSE_BUILD_FLAGS ?= COMPOSE_BUILD_FLAGS ?=
K8S_APP := /opt/kind-k8s/app
help: ## Показать справку help: ## Справка по целям
@echo "Команды (из каталога kind-k8s-develop или: make -C kind-k8s-develop <цель>):" @echo "Веб-UI kind — только с выбором Docker или Podman в одной команде с целью:"
@echo " Основной путь — Docker: make create / delete / list / status (kind и Python не нужны на хосте)." @echo " make docker up или make podman up → http://127.0.0.1:\$${KIND_K8S_WEB_PORT:-6000}"
@grep -E '^[a-zA-Z0-9_-]+:.*?##' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?##"} {printf " \033[36m%-22s\033[0m %s\n", $$1, $$2}' @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)" @$(PYTHON) "$(SETUP_ENV_SCRIPT)"
clusters-dir: ## Создать каталог clusters/ для тома (если ещё нет) clusters-dir: ## Каталог clusters/ для тома (если ещё нет)
@mkdir -p "$(KIND_K8S_DIR)/clusters" @mkdir -p "$(KIND_K8S_DIR)/clusters"
check-docker: ## Проверить docker/podman в PATH и работу команды COMPOSE (по умолчанию docker compose) check-docker: _require_runtime ## (с docker/podman) Проверить CLI и compose
@command -v docker >/dev/null 2>&1 || command -v podman >/dev/null 2>&1 || { echo "Нужен docker или podman в PATH."; exit 1; } @case "$(COMPOSE)" in \
@$(COMPOSE) version >/dev/null 2>&1 || { echo "Нужна рабочая команда Compose: «$(COMPOSE)». Для Podman: COMPOSE='podman compose' make check-docker"; exit 1; } 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" @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) 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"
+90 -71
View File
@@ -1,125 +1,144 @@
# kind-k8s-develop — локальные кластеры Kubernetes (kind) # 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) **Автор:** Сергей Антропов — [devops.org.ru](https://devops.org.ru)
## Зачем это нужно ## Зачем это нужно
- Быстро получить Kubernetes без облака (интеграционные тесты, проверка манифестов, обучение). - Быстро получить Kubernetes без облака (интеграционные тесты, манифесты, обучение).
- Версия кластера и число worker-нод задаются **интерактивно** при создании. - Версия кластера и число worker-нод задаются **в веб-UI** (или при необходимости скриптами/API).
- Артефакты на хосте: `clusters/<имя>/` в этом каталоге — удобно указать путь к `kubeconfig` в приложении или в `kubectl`. - Количество кластеров **не ограничено** кодом (ограничения — ресурсы хоста и Docker).
- Артефакты на хосте: `clusters/<имя>/` — удобно указать путь к `kubeconfig` в приложении или в `kubectl`.
## Требования на хосте (основной сценарий) ## Требования на хосте
| Компонент | Назначение | | Компонент | Назначение |
|-----------|------------| |-----------|------------|
| **Docker** + **Compose v2** | Сборка образа и запуск (`docker compose`) | | **Docker** + **Compose v2** (или **Podman** + compose) | Сборка образа и запуск веб-сервиса |
| **make** | Цели `create`, `delete`, `list`, … | | **make** | `make docker up` / `make podman up` и вспомогательные цели |
| **kubectl** (опционально) | Проверка кластера с хоста после создания | | **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 ```bash
cd kind-k8s-develop cd kind-k8s-develop
make setup # опционально: интерактивно заполнить .env make setup # опционально: интерактивно .env (скрипт в scripts/; Enter — дефолты как в compose; нужен python3)
make check-docker # опционально: проверить docker compose make docker check-docker # или: make podman check-docker
make create # интерактивно: имя, версия образа нод, workers make docker up # или: make podman up
kubectl --kubeconfig="$(pwd)/clusters/<имя>/kubeconfig" get nodes # kubectl с хоста, если установлен # Браузер: http://127.0.0.1:6000 (порт: KIND_K8S_WEB_PORT в .env)
make delete
``` ```
Из родительского каталога: `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 ## Команды Makefile
| Цель | Описание | | Цель | Описание |
|------|----------| |------|----------|
| `make help` | Справка | | `make help` | Краткая справка |
| `make setup` | Интерактивно заполнить `.env` по `env.example` (`scripts/setup_env_interactive.py`, нужен `python3`) | | `make docker up` / `make podman up` | Поднять веб-UI (`kind-k8s-web`) |
| `make check-docker` | Проверить `docker` и `docker compose` | | `make docker down` / `make podman down` | Остановить compose в каталоге репозитория |
| `make compose-build` | Собрать образ `kind-k8s-tools` | | `make docker logs` / `make podman logs` | Логи `kind-k8s-web` |
| `make create` | Интерактивно создать кластер (**в контейнере**) | | `make docker compose-build` / `make podman compose-build` | Собрать образ `kind-k8s-tools:local` |
| `make delete` | Интерактивно удалить кластер и `clusters/<имя>/` | | `make docker check-docker` / `make podman check-docker` | Проверить выбранный CLI и `compose version` |
| `make list` | `kind get clusters` в контейнере | | `make setup` | Интерактивно заполнить `.env`: переменные и подсказки в `scripts/setup_env_interactive.py`, дефолты как в `docker-compose` |
| `make status` | Статус узлов (`kubectl` в контейнере) | | `make clusters-dir` | Создать каталог `clusters/` |
| `make status CLUSTER=имя` | Один кластер | | `make docker …` / `make podman …` | Префикс **обязателен** для целей `up`, `down`, `logs`, `compose-build`, `check-docker` |
| `make kubeconfig CLUSTER=имя` | Путь к `clusters/<имя>/kubeconfig` на хосте |
| `make create-compose` / `make delete-compose` | То же, что `create` / `delete` (совместимость) |
### Локальный запуск скриптов на хосте (без образа) Цели `up`, `down`, `logs`, `compose-build` и `check-docker` **без** `docker`/`podman` в той же команде завершатся с подсказкой.
Если **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` на хосте |
## Переменные окружения ## Переменные окружения
Шаблон: **`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`. | | **`KIND_VERSION`** | build-arg | Версия бинарника kind при сборке образа |
| **`CONTAINER_SOCKET`** | `docker-compose` (volume) | Сокет Docker/Podman на хосте (по умолчанию `/var/run/docker.sock`). | | **`KUBECTL_VERSION`** | build-arg | Версия kubectl в образе; в Dockerfile без build-arg — `stable.txt`; `make setup` предлагает закреплённый тег |
| **`KIND_K8S_PATCH_KUBECONFIG`** | контейнер (environment) | `1` / `true` — всегда патчить `server` в kubeconfig на `127.0.0.1:<порт>`. | | **`KIND_K8S_WEB_PORT`** | ports | Порт **на хосте** для веб-UI (в контейнере 6000) |
| **`CONTAINER_CLI`** | контейнер (environment) | CLI к API контейнеров для `docker port` (по умолчанию `docker`). | | **`KIND_K8S_UVICORN_RELOAD`** | контейнер | `1` (по умолчанию) — hot-reload при правках в `./app`; `0` — без reload |
| **`KIND_K8S_SKIP_VERSION_LIST`** | контейнер | `1` — не ходить в Docker Hub, версия только с клавиатуры. | | **`KIND_K8S_APP_TITLE`** | контейнер | Заголовок в OpenAPI и HTML |
| **`KIND_K8S_VERSION_LIST_DISPLAY`** | контейнер | Сколько строк списка версий показать (по умолчанию `50`, макс. `500`). | | **`KIND_K8S_WAIT_NODES`** | контейнер | `0` — не ждать Ready нод после create |
| **`KIND_K8S_HUB_TAGS_MAX_PAGES`** | контейнер | Лимит страниц API Hub при сборе тегов (по умолчанию `60`, макс. `200`). | | **`KIND_K8S_WAIT_NODES_TIMEOUT_SEC`** | контейнер | Таймаут `kubectl wait` (секунды) |
| **`KIND_K8S_DEBUG`** | контейнер | `1` — уровень логов DEBUG для модулей kind-k8s. | | **`CONTAINER_SOCKET`** | volume | Сокет Docker/Podman |
| **`COMPOSE`** | только **Makefile** | Команда Compose, по умолчанию `docker compose`; Podman: `COMPOSE='podman compose'`. | | **`KIND_K8S_PATCH_KUBECONFIG`** | контейнер | Патч `server` в kubeconfig на хост; по умолчанию **включено** (`1` в compose и в `make setup`) |
| **`COMPOSE_BUILD_FLAGS`** | только **Makefile** | Аргументы к `compose build`, например `--platform linux/arm64` при `exec format error`. | | **`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) ## Podman (пример rootless)
```bash ```bash
export CONTAINER_SOCKET="$XDG_RUNTIME_DIR/podman/podman.sock" 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` | | `Makefile` | Запуск веб-UI и вспомогательные цели |
| `docker-compose.yml` | Том `./clusters`, сокет Docker/Podman | | `scripts/setup_env_interactive.py` | Интерактивное создание `.env` (список переменных и дефолты внутри скрипта) |
| `scripts/setup_env_interactive.py` | Интерактивное заполнение `.env` (цель `make setup`) | | `scripts/run_uvicorn.sh` | Точка входа веб-сервиса: uvicorn с опциональным `--reload` |
| `app/` | Все Python-модули и скрипты (`PYTHONPATH=/opt/kind-k8s/app` в образе) | | `Dockerfile` | Образ: kind, kubectl, docker-cli, FastAPI |
| `app/kind_k8s_paths.py` | Корень данных: `KIND_K8S_WORKDIR` (в образе `/work`) или корень этого репозитория | | `requirements.txt` | pip-зависимости веб-интерфейса |
| `app/create_cluster.py` | Диалог создания кластера | | `docker-compose.yml` | Сервис `kind-k8s-web`, том `./clusters`, сокет |
| `app/delete_cluster.py` | Удаление | | `app/main.py` | FastAPI, дашборд |
| `app/cluster_status.py` | Узлы и meta | | `app/api/v1/` | REST API |
| `app/kubeconfig_patch.py` | Патч `server` в kubeconfig для доступа с хоста | | `app/core/` | Логика кластеров, задания, блокировки |
| `app/kindest_node_tags.py` | Теги `kindest/node` (1.19+) с Docker Hub для выбора версии | | `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/<имя>/kind-config.yaml`
- `clusters/<имя>/kubeconfig` - `clusters/<имя>/kubeconfig`
- `clusters/<имя>/meta.json` (в т.ч. `kubeconfig_patched_for_host`, `created_via_container`) - `clusters/<имя>/meta.json`
Содержимое `clusters/*/` не коммитится (см. `.gitignore`), каталог `clusters/` держит `.gitkeep`. Содержимое `clusters/*/` не коммитится; в репозитории есть `clusters/.gitkeep`.
## Документация AppsTemplate (модуль Kubernetes)
Файл **`docs/k8s_runbook.md`** в этом репозитории — runbook эксплуатации модуля Kubernetes **веб-приложения** (кластеры в БД, health, RBAC, observability и т.д.). Раздел **§6.1** связывает локальный kind из этого каталога с импортом kubeconfig в приложение.
## Ограничения ## Ограничения
- Образ `kindest/node:v…` должен быть в реестре; опечатка версии → ошибка pull/kind. - Образ `kindest/node:v…` должен быть доступен для pull.
- На **Windows** без WSL удобнее WSL2 + Docker Desktop; пути ориентированы на Unix. - На **Windows** без WSL удобнее WSL2 + Docker Desktop.
- Для проверки с хоста нужен отдельно установленный **kubectl** (образ ставит kubectl только **внутри** контейнера). - Для проверки с хоста нужен отдельный **kubectl** (в образе kubectl только внутри контейнера).
- Если при `make list` / `make create` в логе **`exec format error`** у `kind`, архитектура бинарника в образе не совпала с платформой контейнера. Пересоберите явно, например: `make compose-build COMPOSE_BUILD_FLAGS=--platform linux/arm64` или `linux/amd64` (как у вашего Docker). - При **`exec format error`** у kind пересоберите образ: `make docker compose-build COMPOSE_BUILD_FLAGS=--platform linux/arm64` (или `make podman …`, или `linux/amd64`).
+5
View File
@@ -0,0 +1,5 @@
"""HTTP-слой (FastAPI).
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
+5
View File
@@ -0,0 +1,5 @@
"""Версия API v1.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
+5
View File
@@ -0,0 +1,5 @@
"""Маршруты API v1.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
+280
View File
@@ -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,
)
+50
View File
@@ -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,
}
+36
View File
@@ -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}
+16
View File
@@ -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="")
+2 -2
View File
@@ -104,7 +104,7 @@ def _print_cluster(name: str, *, kube_path_saved: Path | None) -> None:
use_path: str | None = None use_path: str | None = None
if kube_path_saved and kube_path_saved.is_file(): if kube_path_saved and kube_path_saved.is_file():
use_path = str(kube_path_saved) use_path = str(kube_path_saved)
print(" Проверка API: kubectl с сохранённым kubeconfig (как на хосте после make create).") print(" Проверка API: kubectl с сохранённым kubeconfig (как на хосте после создания кластера).")
tmp_kc: str | None = None tmp_kc: str | None = None
if not use_path: if not use_path:
@@ -141,7 +141,7 @@ def main() -> None:
if not shutil.which("kind"): if not shutil.which("kind"):
print("Не найден kind.", file=sys.stderr) 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( print(
" Либо установите kind на хост: https://kind.sigs.k8s.io/docs/user/quick-start/#installation", " Либо установите kind на хост: https://kind.sigs.k8s.io/docs/user/quick-start/#installation",
file=sys.stderr, file=sys.stderr,
+52
View File
@@ -303,6 +303,37 @@ def read_meta_json(cluster_name: str) -> dict[str, object] | None:
return 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]: def kubectl_nodes_wide(*, kubeconfig: str | Path) -> tuple[int, str]:
"""``kubectl get nodes -o wide``; возвращает (код, объединённый вывод).""" """``kubectl get nodes -o wide``; возвращает (код, объединённый вывод)."""
p = subprocess.run( p = subprocess.run(
@@ -325,6 +356,27 @@ def kubectl_nodes_wide(*, kubeconfig: str | Path) -> tuple[int, str]:
return p.returncode, msg 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]: def cluster_summary_for_api(name: str) -> dict[str, object]:
"""Сводка по кластеру для JSON API (без блокирующих долгих вызовов).""" """Сводка по кластеру для JSON API (без блокирующих долгих вызовов)."""
meta = read_meta_json(name) or {} meta = read_meta_json(name) or {}
+17 -14
View File
@@ -1,4 +1,7 @@
"""Настройки веб-приложения из переменных окружения (и опционально ``.env`` в рабочем каталоге). """Настройки веб-приложения из переменных окружения.
Переменные задаются в ``docker-compose`` и/или в ``.env`` в корне репозитория
(Compose подставляет их в ``environment`` процесса — отдельный ``env_file`` в коде не требуется).
Автор: Сергей Антропов Автор: Сергей Антропов
Сайт: https://devops.org.ru Сайт: https://devops.org.ru
@@ -6,34 +9,34 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pydantic import Field, field_validator
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
# Каталог пакета app/ — для поиска .env рядом с кодом (в образе: /opt/kind-k8s/app). _DEFAULT_TITLE = "kind-k8s-develop"
_APP_DIR = Path(__file__).resolve().parents[1]
_REPO_ROOT = _APP_DIR.parent
class Settings(BaseSettings): class Settings(BaseSettings):
"""Параметры HTTP-сервера и поведения UI.""" """Параметры HTTP-сервера и поведения UI."""
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
env_file=(
str(_REPO_ROOT / ".env"),
str(_APP_DIR / ".env"),
),
env_file_encoding="utf-8",
extra="ignore", extra="ignore",
case_sensitive=False, 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_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") kind_k8s_web_port: int = Field(default=6000, validation_alias="KIND_K8S_WEB_PORT")
# Заголовок в OpenAPI / HTML (без хардкода в шаблонах). # Заголовок в OpenAPI / HTML; пустая строка из compose не должна ломать FastAPI.
app_title: str = Field(default="kind-k8s-develop", validation_alias="KIND_K8S_APP_TITLE") 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: def get_settings() -> Settings:
+14 -1
View File
@@ -11,12 +11,15 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import uuid import uuid
from dataclasses import dataclass, field from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Literal from typing import Any, Literal
logger = logging.getLogger("kind_k8s.job_store") logger = logging.getLogger("kind_k8s.job_store")
# Лимит записей в памяти (dev-инструмент; старые задания вытесняются)
_MAX_JOBS = 200
JobStatus = Literal["queued", "running", "success", "failed"] JobStatus = Literal["queued", "running", "success", "failed"]
@@ -53,6 +56,10 @@ class JobStore:
) )
async with self._lock: async with self._lock:
self._jobs[jid] = rec 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) logger.info("Создано задание %s kind=%s cluster=%s", jid, kind, cluster_name)
return rec return rec
@@ -84,6 +91,12 @@ class JobStore:
"""Снимок всех заданий (для отладки; без блокировки — eventual consistency).""" """Снимок всех заданий (для отладки; без блокировки — eventual consistency)."""
return list(self._jobs.values()) 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 # Синглтон на процесс uvicorn
job_store = JobStore() job_store = JobStore()
+13
View File
@@ -0,0 +1,13 @@
"""Глобальная блокировка для операций kind (последовательное создание/удаление).
Параллельные ``kind create`` на одном Docker-движке часто нежелательны.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
from __future__ import annotations
import asyncio
kind_cluster_lock = asyncio.Lock()
+3 -4
View File
@@ -7,7 +7,7 @@
Сайт: https://devops.org.ru Сайт: https://devops.org.ru
Требования: kind, клиент контейнеров (``docker`` к сокету Docker/Podman) и kubectl в PATH. Требования: 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]``. Пакетный режим: ``--non-interactive --name X --kubernetes-version 1.29.4 [--workers N]``.
""" """
@@ -21,7 +21,6 @@ import os
import shutil import shutil
import subprocess import subprocess
import sys import sys
from pathlib import Path
from core.cluster_lifecycle import ( from core.cluster_lifecycle import (
CreateClusterResult, CreateClusterResult,
@@ -154,7 +153,7 @@ def _run_interactive() -> None:
if not _which("kind"): if not _which("kind"):
print("Не найден бинарник kind.", file=sys.stderr) print("Не найден бинарник kind.", file=sys.stderr)
print(" Установка kind на хост: https://kind.sigs.k8s.io/docs/user/quick-start/#installation", 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) sys.exit(127)
cli = _container_cli_bin() cli = _container_cli_bin()
if not _which(cli): if not _which(cli):
@@ -173,7 +172,7 @@ def _run_interactive() -> None:
print("Некорректное имя: только строчные буквы, цифры, дефис; не длиннее 63 символов.") print("Некорректное имя: только строчные буквы, цифры, дефис; не длиннее 63 символов.")
continue continue
if name in existing: if name in existing:
print(f"Кластер «{name}» уже существует в kind. Выберите другое имя или удалите его (make delete).") print(f"Кластер «{name}» уже существует в kind. Удалите его в веб-интерфейсе или другое имя.")
continue continue
break break
+1 -10
View File
@@ -25,15 +25,6 @@ def _configure_logging() -> None:
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s") 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]: def _list_kind_clusters() -> list[str]:
p = subprocess.run(["kind", "get", "clusters"], capture_output=True, text=True) p = subprocess.run(["kind", "get", "clusters"], capture_output=True, text=True)
if p.returncode != 0: if p.returncode != 0:
@@ -51,7 +42,7 @@ def _interactive() -> None:
CLUSTERS_DIR = clusters_dir() CLUSTERS_DIR = clusters_dir()
if not shutil.which("kind"): if not shutil.which("kind"):
print("Не найден kind.", file=sys.stderr) 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) sys.exit(127)
clusters = _list_kind_clusters() clusters = _list_kind_clusters()
+290
View File
@@ -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.
+80
View File
@@ -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="<p>Шаблоны не найдены. Ожидается каталог app/templates/</p>",
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)
+73
View File
@@ -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-нод (020)")
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
+484
View File
@@ -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<typeof setInterval> | null} */
let autoTimer = null;
/** @type {ReturnType<typeof setInterval> | 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 = "<strong>Среда:</strong> ";
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 +=
"<br/><span class=\"muted\">" +
escapeHtml(String(h.container_engine_detail).slice(0, 400)) +
"</span>";
}
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 = "<option value=\"\">— загрузка —</option>";
try {
const data = await api("/versions");
sel.innerHTML = "";
if (!data.tags || !data.tags.length) {
sel.innerHTML = "<option value=\"\">(список пуст — введите версию вручную)</option>";
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 = "<option value=\"\">(ошибка загрузки тегов)</option>";
}
}
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 =
"<td><code class=\"cluster-name\">" +
nameEsc +
"</code></td>" +
"<td>" +
(c.registered_in_kind ? "<span class=\"badge badge-ok\">да</span>" : "<span class=\"badge\">нет</span>") +
"</td>" +
"<td>" +
(c.has_local_kubeconfig ? "<span class=\"badge badge-ok\">да</span>" : "<span class=\"badge\">нет</span>") +
"</td>" +
"<td>" +
escapeHtml(String(ver)) +
"</td>" +
"<td>" +
escapeHtml(String(wn)) +
"</td>" +
"<td class=\"actions\"></td>";
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 =
"<td><time datetime=\"" +
escapeHtml(j.created_at_utc || "") +
"\">" +
escapeHtml(j.created_at_utc || "") +
"</time></td>" +
"<td>" +
escapeHtml(j.cluster_name || "—") +
"</td>" +
"<td><span class=\"" +
jobBadgeClass(j.status) +
"\">" +
st +
"</span></td>" +
"<td class=\"jobs-msg-cell\">" +
escapeHtml((j.message || "").slice(0, 160)) +
"</td>";
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();
}
})();
+504
View File
@@ -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);
}
+49
View File
@@ -0,0 +1,49 @@
{# Общий каркас страниц веб-интерфейса kind-k8s-develop.
Автор: Сергей Антропов — https://devops.org.ru #}
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{% block page_title %}{{ app_title }}{% endblock %} — kind</title>
<link rel="stylesheet" href="/static/style.css" />
{% block head_extra %}{% endblock %}
</head>
<body
class="app-body"
data-api-base="/api/v1"
data-app-title="{{ app_title | e }}"
>
<a class="skip-link" href="#main-content">К основному содержимому</a>
<header class="top-nav" role="banner">
<div class="top-nav-inner">
<div class="nav-brand">
<span class="nav-logo" aria-hidden="true"></span>
<span class="nav-title">{{ app_title }}</span>
<span class="nav-tag muted">kind · локальные кластеры</span>
</div>
<nav class="nav-links" aria-label="Разделы">
<a href="/" class="nav-link">Панель</a>
<a href="/docs" class="nav-link" target="_blank" rel="noopener">Swagger</a>
<a href="/redoc" class="nav-link" target="_blank" rel="noopener">ReDoc</a>
<a href="/api/v1/health" class="nav-link" target="_blank" rel="noopener">Health</a>
</nav>
</div>
</header>
<div id="toast" class="toast hidden" role="status" aria-live="polite"></div>
<main id="main-content" class="app-main" tabindex="-1">
{% block content %}{% endblock %}
</main>
<footer class="footer muted app-footer">
{% block footer %}
<span>Данные: том <code>clusters/</code> на хосте · <code>app/docs/api_routes.md</code></span>
{% endblock %}
</footer>
{% block scripts %}{% endblock %}
</body>
</html>
+158
View File
@@ -0,0 +1,158 @@
{# Главная панель: кластеры, задания, создание.
Расширяет base.html; логика в /static/js/dashboard.js.
Автор: Сергей Антропов — https://devops.org.ru #}
{% extends "base.html" %}
{% block footer %}
<span>Документация API: <code>app/docs/api_routes.md</code> · том данных <code>clusters/</code></span>
{% endblock %}
{% block content %}
<div class="page-intro">
<h1 class="page-title">Панель управления</h1>
<p class="muted page-lead">
Создание и удаление кластеров kind, kubeconfig и просмотр узлов/подов через kubectl внутри контейнера.
</p>
</div>
<div id="status-banner" class="status-banner muted" role="status" aria-live="polite">
Проверка среды…
</div>
<div class="toolbar row spread">
<div class="row" style="margin-top:0;align-items:center;gap:0.75rem">
<button type="button" id="btn-refresh-all" class="btn-secondary">Обновить всё</button>
<label class="row" style="margin-top:0;align-items:center;gap:0.35rem">
<input type="checkbox" id="auto-refresh" />
авто каждые 15 с
</label>
</div>
</div>
<section class="grid">
<div class="card">
<h2>Статистика</h2>
<dl id="stats-dl" class="stats-dl" aria-label="Сводка по кластерам и заданиям"></dl>
<p id="stats-err" class="msg hidden" role="alert"></p>
<div class="row">
<button type="button" id="btn-refresh-stats">Обновить статистику</button>
</div>
</div>
<div class="card">
<h2>Создать кластер</h2>
<form id="form-create" novalidate>
<label for="fld-name">Имя (a-z0-9-)</label>
<input
id="fld-name"
name="name"
required
pattern="[a-z0-9]([-a-z0-9]*[a-z0-9])?"
maxlength="63"
placeholder="dev"
autocomplete="off"
/>
<label for="version-select">Тег образа kindest/node (подсказка)</label>
<div class="row">
<select id="version-select" name="kubernetes_version_preset" aria-describedby="ver-hint">
<option value="">— загрузить список —</option>
</select>
</div>
<p id="ver-hint" class="muted" style="margin:0.25rem 0 0;font-size:0.85rem">
Можно ввести версию вручную ниже.
</p>
<label for="kubernetes_version">Версия Kubernetes / тег node</label>
<input
name="kubernetes_version"
id="kubernetes_version"
required
placeholder="1.29.4 или v1.29.4"
autocomplete="off"
/>
<label for="fld-workers">Worker-ноды (020)</label>
<input id="fld-workers" name="workers" type="number" min="0" max="20" value="2" />
<button type="submit">Создать кластер</button>
</form>
<p id="create-msg" class="msg" aria-live="polite"></p>
<details id="job-details" class="job-details hidden">
<summary>Ход задания (JSON)</summary>
<pre id="job-json" class="mono job-json-pre" tabindex="0"></pre>
</details>
</div>
</section>
<section class="card clusters-card">
<div class="row spread">
<h2>Кластеры</h2>
<button type="button" class="btn-secondary" id="btn-refresh-list">Обновить список</button>
</div>
<div class="table-wrap" data-busy="clusters">
<table id="tbl-clusters" aria-label="Список кластеров">
<thead>
<tr>
<th>Имя</th>
<th>kind</th>
<th>kubeconfig</th>
<th>Версия</th>
<th>Workers</th>
<th>Действия</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<p id="list-msg" class="msg muted"></p>
</section>
<section class="card jobs-card">
<div class="row spread">
<h2>Последние задания</h2>
<button type="button" class="btn-secondary" id="btn-refresh-jobs">Обновить</button>
</div>
<div class="table-wrap" data-busy="jobs">
<table id="tbl-jobs" aria-label="Последние задания создания">
<thead>
<tr>
<th>Время (UTC)</th>
<th>Кластер</th>
<th>Статус</th>
<th>Сообщение</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<p id="jobs-msg" class="msg muted"></p>
</section>
<div
id="modal-overlay"
class="modal-overlay hidden"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
>
<div class="modal-box">
<div class="modal-head row spread">
<h3 id="modal-title" class="modal-title-text">Состояние кластера</h3>
<button type="button" class="modal-close btn-secondary" id="modal-close">Закрыть</button>
</div>
<div id="modal-spinner" class="modal-spinner hidden" aria-hidden="true">
<span class="spinner" aria-label="Загрузка"></span>
<span class="muted">Запрос kubectl…</span>
</div>
<p id="modal-sub" class="muted modal-sub"></p>
<h4 class="modal-section-title muted">Узлы</h4>
<pre id="modal-nodes" class="mono" role="region" aria-label="Вывод kubectl get nodes"></pre>
<h4 class="modal-section-title muted">Поды (все namespace)</h4>
<pre id="modal-pods" class="mono" role="region" aria-label="Вывод kubectl get pods"></pre>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="/static/js/dashboard.js" defer></script>
{% endblock %}
+2
View File
@@ -0,0 +1,2 @@
# Каталог тома для kubeconfig и meta.json локальных кластеров kind.
# Содержимое подпапок не коммитится (см. .gitignore).
+20 -12
View File
@@ -1,36 +1,44 @@
# Кластер kind на хосте через сокет Docker/Podman; скрипты внутри образа kind-k8s-tools. # Веб-UI kind: том ./clusters, сокет Docker/Podman. Каталог ./app монтируется в контейнер —
# На хост монтируется только ./clusters → артефакты (kubeconfig, meta.json). # правки Python/шаблонов/static без пересборки образа; uvicorn --reload (см. KIND_K8S_UVICORN_RELOAD).
# # Запуск: make docker up
# Создание (интерактивно): make create
# #
# Podman (пример rootless): # Podman (пример rootless):
# export CONTAINER_SOCKET="$XDG_RUNTIME_DIR/podman/podman.sock" # 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 # Автор: Сергей Антропов — https://devops.org.ru
services: services:
kind-k8s-tools: kind-k8s-web:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
args: args:
KIND_VERSION: ${KIND_VERSION:-0.24.0} KIND_VERSION: ${KIND_VERSION:-0.24.0}
KUBECTL_VERSION: ${KUBECTL_VERSION:-}
image: kind-k8s-tools:local image: kind-k8s-tools:local
volumes: volumes:
- ./clusters:/work/clusters - ./clusters:/work/clusters
- ${CONTAINER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock - ${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: environment:
DOCKER_HOST: unix:///var/run/docker.sock DOCKER_HOST: unix:///var/run/docker.sock
KIND_K8S_IN_CONTAINER: "1" KIND_K8S_IN_CONTAINER: "1"
# Из файла kind-k8s/.env (см. env.example): патч kubeconfig и CLI к сокету KIND_K8S_WORKDIR: /work
KIND_K8S_PATCH_KUBECONFIG: ${KIND_K8S_PATCH_KUBECONFIG:-} # По умолчанию включено: kubeconfig с хоста открывает API через проброшенный порт.
KIND_K8S_PATCH_KUBECONFIG: ${KIND_K8S_PATCH_KUBECONFIG:-1}
CONTAINER_CLI: ${CONTAINER_CLI:-docker} CONTAINER_CLI: ${CONTAINER_CLI:-docker}
# Выбор версии kindest/node (create_cluster.py); см. env.example
KIND_K8S_SKIP_VERSION_LIST: ${KIND_K8S_SKIP_VERSION_LIST:-} KIND_K8S_SKIP_VERSION_LIST: ${KIND_K8S_SKIP_VERSION_LIST:-}
KIND_K8S_VERSION_LIST_DISPLAY: ${KIND_K8S_VERSION_LIST_DISPLAY:-} KIND_K8S_VERSION_LIST_DISPLAY: ${KIND_K8S_VERSION_LIST_DISPLAY:-}
KIND_K8S_HUB_TAGS_MAX_PAGES: ${KIND_K8S_HUB_TAGS_MAX_PAGES:-} KIND_K8S_HUB_TAGS_MAX_PAGES: ${KIND_K8S_HUB_TAGS_MAX_PAGES:-}
KIND_K8S_DEBUG: ${KIND_K8S_DEBUG:-} KIND_K8S_DEBUG: ${KIND_K8S_DEBUG:-}
stdin_open: true KIND_K8S_WAIT_NODES: ${KIND_K8S_WAIT_NODES:-}
tty: true 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"]
-119
View File
@@ -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` — только ручной вызов).
-27
View File
@@ -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
+25
View File
@@ -0,0 +1,25 @@
#!/bin/sh
# Запуск uvicorn для kind-k8s-web.
# При KIND_K8S_UVICORN_RELOAD=1 (по умолчанию) включается --reload: правки в смонтированном ./app
# подхватываются без пересборки образа.
#
# Автор: Сергей Антропов
# Сайт: https://devops.org.ru
set -e
cd /opt/kind-k8s/app
REL="${KIND_K8S_UVICORN_RELOAD:-1}"
if [ "$REL" = "0" ] || [ "$REL" = "false" ] || [ "$REL" = "no" ]; then
exec python3 -m uvicorn main:app --host 0.0.0.0 --port 6000
fi
exec python3 -m uvicorn main:app \
--host 0.0.0.0 \
--port 6000 \
--reload \
--reload-dir /opt/kind-k8s/app \
--reload-include "*.py" \
--reload-include "*.html" \
--reload-include "*.css" \
--reload-include "*.js"
+160 -64
View File
@@ -1,10 +1,12 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Интерактивное создание ``.env`` по шаблону ``env.example`` в корне kind-k8s-develop. """Интерактивное создание ``.env`` в корне kind-k8s-develop.
Запуск из корня репозитория: ``python3 scripts/setup_env_interactive.py`` Список переменных и подсказок задаётся в этом файле (не используется env.example).
или ``make setup``. Дефолты совпадают с ``docker-compose.yml`` / приложением; Enter — записать предложенное значение.
Опционально: ``--template`` / ``--output`` для других путей. Запуск: ``python3 scripts/setup_env_interactive.py`` или ``make setup``.
Опции: ``--output`` / ``-f`` (перезапись без вопроса).
Автор: Сергей Антропов Автор: Сергей Антропов
Сайт: https://devops.org.ru Сайт: https://devops.org.ru
@@ -14,8 +16,8 @@ from __future__ import annotations
import argparse import argparse
import logging import logging
import re
import secrets import secrets
from dataclasses import dataclass
from pathlib import Path from pathlib import Path
logger = logging.getLogger("setup_env_interactive") logger = logging.getLogger("setup_env_interactive")
@@ -23,7 +25,29 @@ logger = logging.getLogger("setup_env_interactive")
# Корень репозитория kind-k8s-develop (родитель каталога scripts/) # Корень репозитория kind-k8s-develop (родитель каталога scripts/)
REPO_ROOT = Path(__file__).resolve().parents[1] 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_KEYS = frozenset(
{ {
"SECRET_KEY", "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: def _configure_logging() -> None:
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
def _is_comment_or_blank(line: str) -> bool: def _default_for_key(key: str) -> str:
s = line.strip() """Значение по умолчанию для ключа (только словарь скрипта)."""
return not s or s.startswith("#") return _SETUP_DEFAULTS.get(key, "")
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 _ask_line( def _ask_line(
key: str, key: str,
default: str, default: str,
*, *,
help_ru: str,
collected: dict[str, str], collected: dict[str, str],
) -> str: ) -> str | None:
"""Спросить одну переменную; ``g`` — сгенерировать секрет (для известных ключей).""" """
Спросить одну переменную.
Возвращает ``None``, если строку в ``.env`` не записывать (пропуск).
"""
extra = "" extra = ""
if key in _SECRET_KEYS: if key in _SECRET_KEYS:
extra = " [g] — сгенерировать случайное значение" extra = " [g] — сгенерировать случайное значение"
@@ -73,9 +175,17 @@ def _ask_line(
hint = default.replace("\n", " ")[:72] hint = default.replace("\n", " ")[:72]
if len(default) > 72: if len(default) > 72:
hint += "" hint += ""
if not default:
hint = "пустая строка"
print(f"\n{key}") print(f"\n{key}")
print(f" По умолчанию из env.example: {hint!r}{extra}") print(f" {help_ru}")
raw = input(" Значение (Enter — по умолчанию): ").strip() print(f" По умолчанию: {hint!r}{extra}")
print(" Enter — подставить это значение в .env; - или «пропустить» — не добавлять строку")
raw = input(" Значение: ").strip()
if raw in ("-", "пропустить", "skip"):
return None
if not raw: if not raw:
return default return default
@@ -89,6 +199,7 @@ def _ask_line(
def _default_database_url(collected: dict[str, str]) -> str | None: def _default_database_url(collected: dict[str, str]) -> str | None:
"""Сборка DATABASE_URL из POSTGRES_* (если такие ключи появятся в мастере)."""
u = collected.get("POSTGRES_USER") u = collected.get("POSTGRES_USER")
p = collected.get("POSTGRES_PASSWORD") p = collected.get("POSTGRES_PASSWORD")
d = collected.get("POSTGRES_DB") d = collected.get("POSTGRES_DB")
@@ -97,19 +208,9 @@ def _default_database_url(collected: dict[str, str]) -> str | None:
return None return None
def run( def run(*, output: Path, force: bool = False) -> int:
*,
template: Path,
output: Path,
force: bool = False,
) -> int:
template = template.resolve()
output = output.resolve() output = output.resolve()
if not template.is_file():
logger.error("Не найден шаблон: %s", template)
return 1
if output.exists() and not force: if output.exists() and not force:
print(f"Файл уже существует: {output}") print(f"Файл уже существует: {output}")
ans = input("Перезаписать? [y/N]: ").strip().lower() ans = input("Перезаписать? [y/N]: ").strip().lower()
@@ -117,32 +218,31 @@ def run(
print("Выход без изменений.") print("Выход без изменений.")
return 0 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] = {} collected: dict[str, str] = {}
out_chunks: list[str] = [] out_chunks: list[str] = [_ENV_FILE_HEADER]
for line in lines_in: for spec in _SETUP_PROMPTS:
if _is_comment_or_blank(line.rstrip("\n\r")): if spec.section_comment:
out_chunks.append(line if line.endswith("\n") else line + "\n") out_chunks.append(f"{spec.section_comment}\n")
continue
parsed = _parse_assignment(line) default = _default_for_key(spec.key)
if parsed is None: if spec.key == "DATABASE_URL":
out_chunks.append(line if line.endswith("\n") else line + "\n")
continue
key, template_default = parsed
default = template_default
if key == "DATABASE_URL":
built = _default_database_url(collected) built = _default_database_url(collected)
if built is not None: if built is not None:
default = built default = built
print("\n--- DATABASE_URL: можно собрать из учётки PostgreSQL выше ---") print("\n--- DATABASE_URL: собрано из POSTGRES_* ---")
value = _ask_line(key, default, collected=collected) value = _ask_line(spec.key, default, help_ru=spec.help_ru, collected=collected)
collected[key] = value if value is None:
out_chunks.append(f"{key}={value}\n") 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.parent.mkdir(parents=True, exist_ok=True)
output.write_text("".join(out_chunks), encoding="utf-8") output.write_text("".join(out_chunks), encoding="utf-8")
@@ -152,12 +252,8 @@ def run(
def main() -> None: def main() -> None:
_configure_logging() _configure_logging()
parser = argparse.ArgumentParser(description="Интерактивное заполнение .env по env.example") parser = argparse.ArgumentParser(
parser.add_argument( description="Интерактивное заполнение .env для kind-k8s-develop (переменные задаются в этом скрипте)",
"--template",
type=Path,
default=REPO_ROOT / "env.example",
help="Путь к шаблону (по умолчанию: env.example в корне репозитория)",
) )
parser.add_argument( parser.add_argument(
"--output", "--output",
@@ -167,7 +263,7 @@ def main() -> None:
) )
parser.add_argument("-f", "--force", action="store_true", help="Не спрашивать подтверждение перезаписи") parser.add_argument("-f", "--force", action="store_true", help="Не спрашивать подтверждение перезаписи")
args = parser.parse_args() 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__": if __name__ == "__main__":