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