Веб-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:
@@ -1,4 +1,5 @@
|
|||||||
# Сгенерированные конфиги и kubeconfig локальных кластеров kind
|
# Сгенерированные конфиги и kubeconfig локальных кластеров kind
|
||||||
|
.DS_Store
|
||||||
.env
|
.env
|
||||||
clusters/*/
|
clusters/*/
|
||||||
!clusters/.gitkeep
|
!clusters/.gitkeep
|
||||||
|
|||||||
+14
-5
@@ -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 \
|
||||||
|
|||||||
@@ -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"
|
|
||||||
|
|||||||
@@ -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`).
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""HTTP-слой (FastAPI).
|
||||||
|
|
||||||
|
Автор: Сергей Антропов
|
||||||
|
Сайт: https://devops.org.ru
|
||||||
|
"""
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Версия API v1.
|
||||||
|
|
||||||
|
Автор: Сергей Антропов
|
||||||
|
Сайт: https://devops.org.ru
|
||||||
|
"""
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Маршруты API v1.
|
||||||
|
|
||||||
|
Автор: Сергей Антропов
|
||||||
|
Сайт: https://devops.org.ru
|
||||||
|
"""
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
@@ -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="")
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
"""Глобальная блокировка для операций kind (последовательное создание/удаление).
|
||||||
|
|
||||||
|
Параллельные ``kind create`` на одном Docker-движке часто нежелательны.
|
||||||
|
|
||||||
|
Автор: Сергей Антропов
|
||||||
|
Сайт: https://devops.org.ru
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
kind_cluster_lock = asyncio.Lock()
|
||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""Модели запросов/ответов REST API веб-интерфейса.
|
||||||
|
|
||||||
|
Автор: Сергей Антропов
|
||||||
|
Сайт: https://devops.org.ru
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class ClusterCreateRequest(BaseModel):
|
||||||
|
"""Тело POST /api/v1/clusters — создание кластера."""
|
||||||
|
|
||||||
|
name: str = Field(..., min_length=1, max_length=63, description="DNS-имя кластера (a-z0-9-)")
|
||||||
|
kubernetes_version: str = Field(
|
||||||
|
...,
|
||||||
|
min_length=1,
|
||||||
|
description="Версия Kubernetes / тег kindest/node, например 1.29.4 или v1.29.4",
|
||||||
|
)
|
||||||
|
workers: int = Field(2, ge=0, le=20, description="Число worker-нод (0–20)")
|
||||||
|
|
||||||
|
|
||||||
|
class ClusterCreateAccepted(BaseModel):
|
||||||
|
"""Ответ 202 — задание поставлено в очередь."""
|
||||||
|
|
||||||
|
job_id: str
|
||||||
|
status: Literal["queued"] = "queued"
|
||||||
|
message: str = "Создание кластера выполняется в фоне; опросите GET /api/v1/jobs/{job_id}"
|
||||||
|
|
||||||
|
|
||||||
|
class JobView(BaseModel):
|
||||||
|
"""Статус фонового задания."""
|
||||||
|
|
||||||
|
job_id: str
|
||||||
|
kind: str
|
||||||
|
status: Literal["queued", "running", "success", "failed"]
|
||||||
|
cluster_name: str | None
|
||||||
|
created_at_utc: str
|
||||||
|
message: str | None = None
|
||||||
|
result: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ClusterSummary(BaseModel):
|
||||||
|
"""Элемент списка кластеров."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
registered_in_kind: bool
|
||||||
|
has_local_kubeconfig: bool
|
||||||
|
meta: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class StatsResponse(BaseModel):
|
||||||
|
"""Краткая статистика для дашборда."""
|
||||||
|
|
||||||
|
kind_clusters_count: int
|
||||||
|
local_cluster_dirs_count: int
|
||||||
|
total_workers_from_meta: int | None
|
||||||
|
jobs_total: int
|
||||||
|
jobs_recent_failed: int
|
||||||
|
|
||||||
|
|
||||||
|
class ClusterWorkloadsResponse(BaseModel):
|
||||||
|
"""Вывод kubectl по кластеру (узлы и поды)."""
|
||||||
|
|
||||||
|
cluster_name: str
|
||||||
|
nodes_rc: int | None = None
|
||||||
|
nodes_output: str | None = None
|
||||||
|
pods_rc: int | None = None
|
||||||
|
pods_output: str | None = None
|
||||||
|
error: str | None = None
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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-ноды (0–20)</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 %}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# Каталог тома для kubeconfig и meta.json локальных кластеров kind.
|
||||||
|
# Содержимое подпапок не коммитится (см. .gitignore).
|
||||||
|
|||||||
+20
-12
@@ -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"]
|
||||||
|
|||||||
@@ -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
@@ -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
|
|
||||||
Executable
+25
@@ -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"
|
||||||
@@ -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__":
|
||||||
|
|||||||
Reference in New Issue
Block a user