commit c5219ec4d65c6928a7cf3066627a046e71a5e8c9 Author: Sergey Antropoff Date: Sat Apr 4 05:15:54 2026 +0300 first commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8e2eca2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +__pycache__ +*.pyc +.git diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bb28386 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +# Сгенерированные конфиги и kubeconfig локальных кластеров kind +.env +clusters/*/ +!clusters/.gitkeep diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1ec8a99 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +# Образ kind-k8s-tools: kind, kubectl, docker CLI, Python-скрипты (без установки на хост). +# Данные кластеров монтируются в /work/clusters (см. docker-compose.yml). +# +# Автор: Сергей Антропов — https://devops.org.ru + +ARG KIND_VERSION=0.24.0 +# Платформа целевого образа (BuildKit подставляет amd64/arm64; иначе — uname внутри слоя) +ARG TARGETARCH + +FROM alpine:3.20 + +RUN apk add --no-cache python3 docker-cli curl bash ca-certificates \ + && ARCH="${TARGETARCH:-}" \ + && if [ -z "$ARCH" ]; then ARCH="$(uname -m)"; fi \ + && case "$ARCH" in \ + amd64|x86_64) KARCH=amd64 ;; \ + arm64|aarch64) KARCH=arm64 ;; \ + *) echo "Неподдерживаемая архитектура: $ARCH"; exit 1 ;; \ + 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) \ + && curl -sSLo /usr/local/bin/kubectl "https://dl.k8s.io/release/${KVER}/bin/linux/${KARCH}/kubectl" \ + && chmod +x /usr/local/bin/kubectl + +COPY app/ /opt/kind-k8s/app/ + +ENV KIND_K8S_WORKDIR=/work \ + PYTHONPATH=/opt/kind-k8s/app \ + KIND_K8S_IN_CONTAINER=1 \ + PATH=/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + +WORKDIR /work diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ae84310 --- /dev/null +++ b/Makefile @@ -0,0 +1,81 @@ +# Локальные кластеры Kubernetes через kind. +# Основной сценарий: только Docker (+ make) на хосте — скрипты и kind внутри образа kind-k8s-tools. +# +# Автор: Сергей Антропов — 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 + +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 +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}' + +setup: ## Интерактивно создать .env по env.example (scripts/setup_env_interactive.py) + @$(PYTHON) "$(SETUP_ENV_SCRIPT)" + +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; } + @echo "$(COMPOSE): OK" + +compose-build: clusters-dir ## Собрать образ kind-k8s-tools + 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 new file mode 100644 index 0000000..fd32708 --- /dev/null +++ b/README.md @@ -0,0 +1,125 @@ +# kind-k8s-develop — локальные кластеры Kubernetes (kind) + +Образ **kind-k8s-tools** и **Makefile**: поднять kind на машине с **Docker** (или Podman + compose), сохранить **kubeconfig** в `clusters/<имя>/` на хосте. Python-скрипты и бинарники **kind/kubectl** лежат **внутри образа** — на хосте достаточно **Docker**, **make** и при необходимости **kubectl** для проверки API. + +**Автор:** Сергей Антропов — [devops.org.ru](https://devops.org.ru) + +## Зачем это нужно + +- Быстро получить Kubernetes без облака (интеграционные тесты, проверка манифестов, обучение). +- Версия кластера и число worker-нод задаются **интерактивно** при создании. +- Артефакты на хосте: `clusters/<имя>/` в этом каталоге — удобно указать путь к `kubeconfig` в приложении или в `kubectl`. + +## Требования на хосте (основной сценарий) + +| Компонент | Назначение | +|-----------|------------| +| **Docker** + **Compose v2** | Сборка образа и запуск (`docker compose`) | +| **make** | Цели `create`, `delete`, `list`, … | +| **kubectl** (опционально) | Проверка кластера с хоста после создания | + +**На хост не ставятся:** Python, kind, curl для kind — всё уже в образе `kind-k8s-tools`. + +Смонтированы только **сокет** Docker/Podman и каталог **`./clusters`** → в контейнере `/work/clusters`. + +После `make create` kubeconfig **патчится** на `https://127.0.0.1:<порт>` (apiserver с хоста), см. `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 -C kind-k8s-develop create`. + +## Команды 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` (совместимость) | + +### Локальный запуск скриптов на хосте (без образа) + +Если **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` при запуске отсюда. + +| Переменная | Где используется | Назначение | +|------------|------------------|------------| +| **`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`. | + +## Podman (пример rootless) + +```bash +export CONTAINER_SOCKET="$XDG_RUNTIME_DIR/podman/podman.sock" +COMPOSE='podman compose' make create +``` + +## Файлы + +| Путь | Назначение | +|------|------------| +| `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 для выбора версии | + +При **`make create`** скрипт запрашивает версию Kubernetes: по умолчанию подгружается список тегов с Docker Hub (нужен интернет). Без сети или в air-gapped: **`KIND_K8S_SKIP_VERSION_LIST=1`** в **`.env`** — ввод версии только вручную. + +## Где лежат данные на хосте + +- `clusters/<имя>/kind-config.yaml` +- `clusters/<имя>/kubeconfig` +- `clusters/<имя>/meta.json` (в т.ч. `kubeconfig_patched_for_host`, `created_via_container`) + +Содержимое `clusters/*/` не коммитится (см. `.gitignore`), каталог `clusters/` держит `.gitkeep`. + +## Документация AppsTemplate (модуль Kubernetes) + +Файл **`docs/k8s_runbook.md`** в этом репозитории — runbook эксплуатации модуля Kubernetes **веб-приложения** (кластеры в БД, health, RBAC, observability и т.д.). Раздел **§6.1** связывает локальный kind из этого каталога с импортом kubeconfig в приложение. + +## Ограничения + +- Образ `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). diff --git a/__pycache__/kindest_node_tags.cpython-312.pyc b/__pycache__/kindest_node_tags.cpython-312.pyc new file mode 100644 index 0000000..19dfe2d Binary files /dev/null and b/__pycache__/kindest_node_tags.cpython-312.pyc differ diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..f7cb9ac --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,5 @@ +"""Скрипты и модули локального кластера kind (каталог репозитория kind-k8s). + +Автор: Сергей Антропов +Сайт: https://devops.org.ru +""" diff --git a/app/__pycache__/kindest_node_tags.cpython-312.pyc b/app/__pycache__/kindest_node_tags.cpython-312.pyc new file mode 100644 index 0000000..4788527 Binary files /dev/null and b/app/__pycache__/kindest_node_tags.cpython-312.pyc differ diff --git a/app/cluster_status.py b/app/cluster_status.py new file mode 100755 index 0000000..421ca94 --- /dev/null +++ b/app/cluster_status.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +"""Проверка статуса кластеров kind: регистрация, kubeconfig, узлы (kubectl). + +Запуск без аргументов — все кластеры из `kind get clusters`. +Один аргумент — только указанное имя. + +Автор: Сергей Антропов +Сайт: https://devops.org.ru + +Требования: kind и kubectl в PATH (в образе kind-k8s-tools уже есть). +""" + +from __future__ import annotations + +import argparse +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +from kind_k8s_paths import clusters_dir + + +def _kind_cluster_names() -> list[str]: + p = subprocess.run(["kind", "get", "clusters"], capture_output=True, text=True) + if p.returncode != 0: + return [] + lines = [x.strip() for x in (p.stdout or "").splitlines() if x.strip()] + return [x for x in lines if "no kind" not in x.lower()] + + +def _kubeconfig_for_cluster(name: str) -> tuple[str | None, str]: + """Временный файл kubeconfig или None; второе значение — пояснение.""" + p = subprocess.run( + ["kind", "get", "kubeconfig", "--name", name], + capture_output=True, + text=True, + ) + if p.returncode != 0: + err = (p.stderr or p.stdout or "").strip() + return None, err or "kind get kubeconfig не удался" + data = (p.stdout or "").strip() + if not data: + return None, "пустой kubeconfig" + with tempfile.NamedTemporaryFile( + mode="w", + suffix=".kubeconfig", + prefix=f"kind-{name}-", + delete=False, + encoding="utf-8", + ) as f: + f.write(data) + return f.name, "" + + +def _kubectl_nodes(kubeconfig: str) -> tuple[int, str]: + p = subprocess.run( + [ + "kubectl", + "--kubeconfig", + kubeconfig, + "get", + "nodes", + "-o", + "wide", + "--request-timeout=10s", + ], + 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 _local_meta(name: str) -> dict[str, str] | None: + meta = CLUSTERS_DIR / name / "meta.json" + if not meta.is_file(): + return None + try: + import json + + raw = json.loads(meta.read_text(encoding="utf-8")) + if isinstance(raw, dict): + return {str(k): str(v) for k, v in raw.items() if isinstance(k, str)} + except Exception: + pass + return None + + +def _print_cluster(name: str, *, kube_path_saved: Path | None) -> None: + print(f"── Кластер: {name} ──") + if kube_path_saved and kube_path_saved.is_file(): + print(f" Сохранённый kubeconfig: {kube_path_saved}") + meta = _local_meta(name) + if meta: + ver = meta.get("kubernetes_version_tag") or meta.get("node_image", "—") + wn = meta.get("worker_nodes", "—") + print(f" meta.json: версия={ver}, workers={wn}") + + # С хоста удобнее тот же файл, что после create (в т.ч. с патчем 127.0.0.1:порт). + 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).") + + tmp_kc: str | None = None + if not use_path: + tmp_kc, kerr = _kubeconfig_for_cluster(name) + if not tmp_kc: + print(f" Статус API: недоступен ({kerr})") + return + use_path = tmp_kc + + try: + rc, msg = _kubectl_nodes(use_path) + if rc == 0: + print(" Узлы (kubectl get nodes -o wide):") + for line in msg.splitlines(): + print(f" {line}") + else: + print(f" kubectl: код {rc}") + for line in msg.splitlines()[:20]: + print(f" {line}") + finally: + if tmp_kc: + Path(tmp_kc).unlink(missing_ok=True) + + +def main() -> None: + CLUSTERS_DIR = clusters_dir() + parser = argparse.ArgumentParser(description="Статус кластеров kind") + parser.add_argument( + "cluster", + nargs="?", + help="Имя кластера (если не указано — все из kind get clusters)", + ) + args = parser.parse_args() + + if not shutil.which("kind"): + print("Не найден kind.", file=sys.stderr) + print(" Обычно запускают: make -C kind-k8s-develop status (kind внутри образа).", file=sys.stderr) + print( + " Либо установите kind на хост: https://kind.sigs.k8s.io/docs/user/quick-start/#installation", + file=sys.stderr, + ) + sys.exit(127) + if not shutil.which("kubectl"): + print("Не найден kubectl.", file=sys.stderr) + sys.exit(127) + + names = _kind_cluster_names() + if args.cluster: + if args.cluster not in names: + print(f"Кластер «{args.cluster}» не найден в kind get clusters.", file=sys.stderr) + print("Известные:", ", ".join(names) if names else "(пусто)", file=sys.stderr) + sys.exit(1) + names = [args.cluster] + + if not names: + print("Нет кластеров kind (kind get clusters).") + return + + for name in names: + saved = CLUSTERS_DIR / name / "kubeconfig" + kube_saved = saved if saved.is_file() else None + _print_cluster(name, kube_path_saved=kube_saved) + print() + + +if __name__ == "__main__": + main() diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..e37f910 --- /dev/null +++ b/app/core/__init__.py @@ -0,0 +1,5 @@ +"""Вспомогательная логика для API и общих операций kind-k8s-develop. + +Автор: Сергей Антропов +Сайт: https://devops.org.ru +""" diff --git a/app/core/cluster_lifecycle.py b/app/core/cluster_lifecycle.py new file mode 100644 index 0000000..220eba2 --- /dev/null +++ b/app/core/cluster_lifecycle.py @@ -0,0 +1,340 @@ +"""Синхронные операции с kind: конфиг, создание, удаление, ожидание готовности нод. + +Используются интерактивными CLI-скриптами и веб-слоем (через asyncio.to_thread / executor). + +Автор: Сергей Антропов +Сайт: https://devops.org.ru +""" + +from __future__ import annotations + +import json +import logging +import os +import re +import shutil +import subprocess +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path + +from kind_k8s_paths import clusters_dir, data_root +from kindest_node_tags import normalize_tag_v_prefix +from kubeconfig_patch import patch_kubeconfig_server_for_host, should_patch_after_create + +logger = logging.getLogger("kind_k8s.cluster_lifecycle") + +# Имя кластера: поддомен DNS (RFC 1123) +_NAME_RE = re.compile(r"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$") + + +class KindClusterError(Exception): + """Ошибка операции kind (создание, удаление и т.д.).""" + + def __init__(self, message: str, *, exit_code: int = 1) -> None: + super().__init__(message) + self.exit_code = exit_code + + +def validate_cluster_name(name: str) -> bool: + """Проверить имя кластера (DNS-подмножество, длина ≤ 63).""" + if not name or len(name) > 63: + return False + return bool(_NAME_RE.match(name)) + + +def normalize_k8s_version(raw: str) -> str: + """Превратить ввод в тег образа kindest/node (например 1.29.4 → v1.29.4).""" + s = raw.strip() + if not s: + return "v1.29.4" + s = s.lower().removeprefix("v") + return f"v{s}" + + +def build_kind_config_yaml(*, node_image: str, workers: int) -> str: + """YAML для kind: один control-plane + workers.""" + lines = [ + "kind: Cluster", + "apiVersion: kind.x-k8s.io/v1alpha4", + "nodes:", + " - role: control-plane", + f" image: {node_image}", + ] + for _ in range(workers): + lines.append(" - role: worker") + lines.append(f" image: {node_image}") + return "\n".join(lines) + "\n" + + +def list_registered_kind_clusters() -> list[str]: + """Имена кластеров kind; при ошибке — пустой список.""" + p = subprocess.run(["kind", "get", "clusters"], capture_output=True, text=True) + if p.returncode != 0: + logger.info("kind get clusters завершился с кодом %s", p.returncode) + return [] + lines = [x.strip() for x in (p.stdout or "").splitlines() if x.strip()] + return [x for x in lines if "no kind" not in x.lower()] + + +def _in_container() -> bool: + return os.environ.get("KIND_K8S_IN_CONTAINER", "").strip() == "1" + + +def _run_checked(cmd: list[str], *, cwd: Path | None = None) -> None: + """Выполнить команду; при ошибке — KindClusterError с текстом stderr.""" + logger.info("Выполнение: %s", " ".join(cmd)) + p = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True) + if p.returncode != 0: + err = (p.stderr or p.stdout or "").strip() + raise KindClusterError(f"Команда завершилась с кодом {p.returncode}: {err}", exit_code=p.returncode) + + +def _run_capture_checked(cmd: list[str]) -> str: + p = subprocess.run(cmd, capture_output=True, text=True) + if p.returncode != 0: + err = (p.stderr or p.stdout or "").strip() + raise KindClusterError(err or "команда не удалась", exit_code=p.returncode) + return (p.stdout or "").strip() + + +def _wait_nodes_enabled() -> bool: + raw = (os.environ.get("KIND_K8S_WAIT_NODES") or "1").strip().lower() + return raw in ("1", "true", "yes", "да") + + +def _wait_nodes_timeout_sec() -> int: + raw = (os.environ.get("KIND_K8S_WAIT_NODES_TIMEOUT_SEC") or "300").strip() + try: + return max(30, min(int(raw), 3600)) + except ValueError: + return 300 + + +def wait_nodes_ready(*, kubeconfig_path: Path, timeout_sec: int | None = None) -> tuple[bool, str]: + """ + Дождаться condition=Ready для всех нод через kubectl wait. + + Возвращает (успех, сообщение для лога/UI). + """ + if timeout_sec is None: + timeout_sec = _wait_nodes_timeout_sec() + t = f"{timeout_sec}s" + cmd = [ + "kubectl", + "--kubeconfig", + str(kubeconfig_path), + "wait", + "--for=condition=Ready", + "nodes", + "--all", + f"--timeout={t}", + ] + logger.info("Ожидание готовности нод: timeout=%s", t) + p = subprocess.run(cmd, capture_output=True, text=True) + out = (p.stdout or "").strip() + err = (p.stderr or "").strip() + if p.returncode == 0: + return True, out or "ноды в состоянии Ready" + msg = err or out or f"код выхода {p.returncode}" + return False, msg + + +@dataclass(frozen=True) +class CreateClusterResult: + """Результат успешного создания кластера.""" + + cluster_name: str + ver_tag: str + node_image: str + workers: int + kubeconfig_path: Path + meta_path: Path + kubeconfig_patched_for_host: bool + nodes_ready: bool | None + nodes_ready_message: str | None + + +def create_cluster_non_interactive( + *, + name: str, + kubernetes_version_tag: str, + workers: int, +) -> CreateClusterResult: + """ + Создать кластер kind без диалогов. + + ``kubernetes_version_tag`` — тег kindest/node (например ``v1.29.4``), см. ``normalize_tag_v_prefix``. + """ + if not shutil.which("kind"): + raise KindClusterError("Не найден бинарник kind в PATH.", exit_code=127) + + if not validate_cluster_name(name): + raise KindClusterError("Некорректное имя кластера (a-z0-9-, не длиннее 63).") + + existing = list_registered_kind_clusters() + if name in existing: + raise KindClusterError(f"Кластер «{name}» уже существует в kind.") + + if workers < 0 or workers > 20: + raise KindClusterError("Количество worker-нод должно быть от 0 до 20.") + + ver_tag = normalize_tag_v_prefix(kubernetes_version_tag) + node_image = f"kindest/node:{ver_tag}" + + root = data_root() + cdir = clusters_dir() + out_dir = cdir / name + out_dir.mkdir(parents=True, exist_ok=True) + cfg_path = out_dir / "kind-config.yaml" + kube_path = out_dir / "kubeconfig" + meta_path = out_dir / "meta.json" + + yaml_text = build_kind_config_yaml(node_image=node_image, workers=workers) + cfg_path.write_text(yaml_text, encoding="utf-8") + + logger.info("Создание кластера «%s», образ %s, workers=%s", name, node_image, workers) + _run_checked(["kind", "create", "cluster", "--name", name, "--config", str(cfg_path)]) + + kube = _run_capture_checked(["kind", "get", "kubeconfig", "--name", name]) + kube_path.write_text(kube, encoding="utf-8") + + patched = False + if should_patch_after_create(): + patched = patch_kubeconfig_server_for_host(cluster_name=name, kube_path=kube_path) + + nodes_ready: bool | None = None + nodes_msg: str | None = None + if _wait_nodes_enabled(): + ok, msg = wait_nodes_ready(kubeconfig_path=kube_path) + nodes_ready = ok + nodes_msg = msg + if ok: + logger.info("Ноды готовы: %s", msg) + else: + logger.warning("Ожидание нод не завершилось успешно: %s", msg) + + meta = { + "cluster_name": name, + "kubernetes_version_tag": ver_tag, + "node_image": node_image, + "worker_nodes": workers, + "created_at_utc": datetime.now(timezone.utc).isoformat(), + "kind_config_path": str(cfg_path.relative_to(root)), + "kubeconfig_path": str(kube_path.relative_to(root)), + "kubeconfig_patched_for_host": patched, + "created_via_container": _in_container(), + "nodes_ready_after_create": nodes_ready, + "nodes_ready_message": nodes_msg, + } + meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8") + + return CreateClusterResult( + cluster_name=name, + ver_tag=ver_tag, + node_image=node_image, + workers=workers, + kubeconfig_path=kube_path, + meta_path=meta_path, + kubeconfig_patched_for_host=patched, + nodes_ready=nodes_ready, + nodes_ready_message=nodes_msg, + ) + + +def delete_kind_cluster_and_data(*, name: str, log_to_stdout: bool = False) -> tuple[bool, str]: + """ + ``kind delete cluster`` и удаление ``clusters/<имя>/``. + + Первый элемент — успешность ``kind delete``; второй — текстовое резюме всего шага. + + ``log_to_stdout=True`` — не перехватывать stdout/stderr kind (удобно в интерактивном CLI). + """ + if not shutil.which("kind"): + raise KindClusterError("Не найден kind в PATH.", exit_code=127) + + cdir = clusters_dir() + parts: list[str] = [] + kind_ok = True + + if log_to_stdout: + p = subprocess.run(["kind", "delete", "cluster", "--name", name]) + if p.returncode != 0: + parts.append(f"kind delete: код {p.returncode}") + logger.warning("kind delete cluster %s: код %s", name, p.returncode) + kind_ok = False + else: + parts.append("kind delete: OK") + else: + p = subprocess.run( + ["kind", "delete", "cluster", "--name", name], + capture_output=True, + text=True, + ) + if p.returncode != 0: + err = (p.stderr or p.stdout or "").strip() + parts.append(f"kind delete: ошибка ({err or p.returncode})") + logger.warning("kind delete cluster %s: %s", name, err) + kind_ok = False + else: + parts.append("kind delete: OK") + + d = cdir / name + if d.is_dir(): + shutil.rmtree(d) + parts.append(f"удалена папка {d}") + else: + parts.append("локальная папка отсутствовала") + + return kind_ok, "; ".join(parts) + + +def read_meta_json(cluster_name: str) -> dict[str, object] | None: + """Прочитать ``clusters/<имя>/meta.json`` если есть.""" + p = clusters_dir() / cluster_name / "meta.json" + if not p.is_file(): + return None + try: + raw = json.loads(p.read_text(encoding="utf-8")) + if isinstance(raw, dict): + return raw + except (OSError, json.JSONDecodeError) as e: + logger.debug("meta.json не прочитан: %s", e) + return None + + +def kubectl_nodes_wide(*, kubeconfig: str | Path) -> tuple[int, str]: + """``kubectl get nodes -o wide``; возвращает (код, объединённый вывод).""" + p = subprocess.run( + [ + "kubectl", + "--kubeconfig", + str(kubeconfig), + "get", + "nodes", + "-o", + "wide", + "--request-timeout=15s", + ], + 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 {} + saved_kc = clusters_dir() / name / "kubeconfig" + in_kind = name in list_registered_kind_clusters() + out: dict[str, object] = { + "name": name, + "registered_in_kind": in_kind, + "has_local_kubeconfig": saved_kc.is_file(), + "kubeconfig_path": str(saved_kc) if saved_kc.is_file() else None, + "meta": meta, + } + return out diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..120f017 --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,41 @@ +"""Настройки веб-приложения из переменных окружения (и опционально ``.env`` в рабочем каталоге). + +Автор: Сергей Антропов +Сайт: https://devops.org.ru +""" + +from __future__ import annotations + +from pathlib import Path + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + +# Каталог пакета app/ — для поиска .env рядом с кодом (в образе: /opt/kind-k8s/app). +_APP_DIR = Path(__file__).resolve().parents[1] +_REPO_ROOT = _APP_DIR.parent + + +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, + ) + + 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") + + +def get_settings() -> Settings: + """Экземпляр настроек (для импорта в main и роутерах).""" + return Settings() diff --git a/app/core/job_store.py b/app/core/job_store.py new file mode 100644 index 0000000..a7d04c5 --- /dev/null +++ b/app/core/job_store.py @@ -0,0 +1,89 @@ +"""Хранилище фоновых заданий (создание кластера) в памяти процесса. + +При перезапуске контейнера история заданий обнуляется — это ожидаемо для dev-среды. + +Автор: Сергей Антропов +Сайт: https://devops.org.ru +""" + +from __future__ import annotations + +import asyncio +import logging +import uuid +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any, Literal + +logger = logging.getLogger("kind_k8s.job_store") + +JobStatus = Literal["queued", "running", "success", "failed"] + + +@dataclass +class JobRecord: + """Описание одного задания.""" + + job_id: str + kind: str + status: JobStatus + cluster_name: str | None + created_at_utc: str + message: str | None = None + result: dict[str, Any] | None = None + + +class JobStore: + """Потокобезопасное (asyncio) хранилище заданий.""" + + def __init__(self) -> None: + self._jobs: dict[str, JobRecord] = {} + self._lock = asyncio.Lock() + + async def create_job(self, kind: str, *, cluster_name: str | None) -> JobRecord: + """Зарегистрировать задание в статусе ``queued``.""" + jid = uuid.uuid4().hex + now = datetime.now(timezone.utc).isoformat() + rec = JobRecord( + job_id=jid, + kind=kind, + status="queued", + cluster_name=cluster_name, + created_at_utc=now, + ) + async with self._lock: + self._jobs[jid] = rec + logger.info("Создано задание %s kind=%s cluster=%s", jid, kind, cluster_name) + return rec + + async def set_running(self, job_id: str) -> None: + async with self._lock: + if job_id in self._jobs: + self._jobs[job_id].status = "running" + self._jobs[job_id].message = None + + async def set_success(self, job_id: str, *, result: dict[str, Any] | None = None, message: str | None = None) -> None: + async with self._lock: + if job_id in self._jobs: + self._jobs[job_id].status = "success" + self._jobs[job_id].result = result + self._jobs[job_id].message = message + + async def set_failed(self, job_id: str, message: str) -> None: + async with self._lock: + if job_id in self._jobs: + self._jobs[job_id].status = "failed" + self._jobs[job_id].message = message + logger.warning("Задание %s завершилось ошибкой: %s", job_id, message) + + async def get(self, job_id: str) -> JobRecord | None: + async with self._lock: + return self._jobs.get(job_id) + + def snapshot_all(self) -> list[JobRecord]: + """Снимок всех заданий (для отладки; без блокировки — eventual consistency).""" + return list(self._jobs.values()) + + +# Синглтон на процесс uvicorn +job_store = JobStore() diff --git a/app/create_cluster.py b/app/create_cluster.py new file mode 100644 index 0000000..91714b0 --- /dev/null +++ b/app/create_cluster.py @@ -0,0 +1,267 @@ +#!/usr/bin/env python3 +"""Интерактивное или пакетное создание локального кластера Kubernetes через kind. + +Сохраняет kind-config.yaml, kubeconfig и meta.json в подпапку clusters/<имя>/. + +Автор: Сергей Антропов +Сайт: https://devops.org.ru + +Требования: kind, клиент контейнеров (``docker`` к сокету Docker/Podman) и kubectl в PATH. +Рекомендуется: ``make create`` из каталога kind-k8s-develop — всё внутри Docker, на хосте только Docker. + +Пакетный режим: ``--non-interactive --name X --kubernetes-version 1.29.4 [--workers N]``. +""" + +from __future__ import annotations + +import argparse +import json +import logging +import os +import shutil +import subprocess +import sys +from pathlib import Path + +from core.cluster_lifecycle import ( + CreateClusterResult, + KindClusterError, + create_cluster_non_interactive, + list_registered_kind_clusters, + normalize_k8s_version, + validate_cluster_name, +) +from kindest_node_tags import fetch_kindest_node_tags, normalize_tag_v_prefix +from kind_k8s_paths import clusters_dir + +# Имя кластера: поддомен DNS (RFC 1123) — дублируем только для CLI-подсказок; +# основная проверка в core.cluster_lifecycle. + + +def _which(cmd: str) -> str | None: + return shutil.which(cmd) + + +def _container_cli_bin() -> str: + return (os.environ.get("CONTAINER_CLI") or "docker").strip() or "docker" + + +def _in_container() -> bool: + return os.environ.get("KIND_K8S_IN_CONTAINER", "").strip() == "1" + + +def _ask(prompt: str, default: str | None = None) -> str: + if default is not None: + line = input(f"{prompt} [{default}]: ").strip() + return line if line else default + line = input(f"{prompt}: ").strip() + return line + + +def _ask_int(prompt: str, default: int, *, min_v: int, max_v: int) -> int: + while True: + raw = _ask(prompt, str(default)) + try: + n = int(raw, 10) + except ValueError: + print("Введите целое число.") + continue + if n < min_v or n > max_v: + print(f"Допустимый диапазон: {min_v}…{max_v}.") + continue + return n + + +def _configure_logging() -> None: + """Базовая настройка логов для вспомогательных модулей (kindest_node_tags и т.д.).""" + if logging.root.handlers: + return + level = logging.DEBUG if os.environ.get("KIND_K8S_DEBUG", "").strip() in ("1", "true", "yes") else logging.INFO + logging.basicConfig(level=level, format="%(levelname)s %(name)s: %(message)s") + + +def _display_limit_for_version_list() -> int: + raw = (os.environ.get("KIND_K8S_VERSION_LIST_DISPLAY") or "50").strip() + try: + return max(5, min(int(raw), 500)) + except ValueError: + return 50 + + +def _interactive_k8s_version_tag() -> str: + """ + Спросить версию Kubernetes: загрузить теги kindest/node с Docker Hub (1.19+), показать список, + выбор по номеру или ввод тега вручную. + """ + skip = os.environ.get("KIND_K8S_SKIP_VERSION_LIST", "").strip().lower() in ("1", "true", "yes", "да") + tags: list[str] = [] + + if not skip: + print( + "Загрузка списка стабильных тегов kindest/node с Docker Hub (Kubernetes 1.19+, нужна сеть)…", + flush=True, + ) + tags = fetch_kindest_node_tags() + if not tags: + print( + "Предупреждение: список тегов недоступен (сеть, лимит Docker Hub или пустой ответ). " + "Введите версию вручную.", + flush=True, + ) + else: + print("Загрузка списка пропущена (переменная KIND_K8S_SKIP_VERSION_LIST).", flush=True) + + if not tags: + raw = _ask("Версия Kubernetes / тег образа kindest/node (например 1.29.4)", "1.29.4") + return normalize_k8s_version(raw) + + display_n = _display_limit_for_version_list() + print(f"\nДоступные стабильные версии (всего {len(tags)}), от новых к старым:", flush=True) + for i, t in enumerate(tags[:display_n], start=1): + print(f" {i:3}) {t}", flush=True) + if len(tags) > display_n: + print( + f" … показаны первые {display_n} из {len(tags)}; можно ввести номер от 1 до {len(tags)} " + "или тег вручную (например 1.25.11).", + flush=True, + ) + + default_choice = "1" + while True: + raw = _ask(f"Номер строки (1–{len(tags)}) или версия вручную", default_choice) + choice = raw if raw else default_choice + if choice.isdigit(): + idx = int(choice, 10) + if 1 <= idx <= len(tags): + picked = tags[idx - 1] + print(f"Выбран образ kindest/node:{picked}", flush=True) + return picked + print(f"Введите число от 1 до {len(tags)} или тег версии (например 1.28.0).", flush=True) + continue + + ver = normalize_k8s_version(choice) + canon = normalize_tag_v_prefix(ver) + if canon not in tags: + print( + f"Примечание: «{ver}» нет среди загруженных тегов; при отсутствии образа в реестре kind сообщит об ошибке.", + flush=True, + ) + return ver + + +def _run_interactive() -> None: + print("=== Создание кластера kind ===\n") + 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) + sys.exit(127) + cli = _container_cli_bin() + if not _which(cli): + print(f"Не найден «{cli}» (CLI к API контейнеров).", file=sys.stderr) + sys.exit(127) + + existing = list_registered_kind_clusters() + + default_name = "dev" + if default_name in existing: + default_name = "dev2" + + while True: + name = _ask("Имя кластера (DNS-имя, a-z0-9-)", default_name) + if not validate_cluster_name(name): + print("Некорректное имя: только строчные буквы, цифры, дефис; не длиннее 63 символов.") + continue + if name in existing: + print(f"Кластер «{name}» уже существует в kind. Выберите другое имя или удалите его (make delete).") + continue + break + + ver_tag = _interactive_k8s_version_tag() + + workers = _ask_int( + "Количество worker-нод (0 = только control-plane, он же может принимать поды)", + 2, + min_v=0, + max_v=20, + ) + + try: + result = create_cluster_non_interactive(name=name, kubernetes_version_tag=ver_tag, workers=workers) + except KindClusterError as e: + print(str(e), file=sys.stderr) + raise SystemExit(getattr(e, "exit_code", 1)) from e + + print("\nГотово.") + print(f" kubeconfig (в среде запуска): {result.kubeconfig_path}") + if _in_container(): + print(f" Том на хосте: kind-k8s-develop/clusters/{result.cluster_name}/ (рядом с Makefile)") + print( + f' Проверка с хоста (из каталога репозитория): kubectl --kubeconfig="$(pwd)/clusters/{result.cluster_name}/kubeconfig" get nodes', + ) + else: + print(f" Проверка: KUBECONFIG={result.kubeconfig_path} kubectl get nodes") + print(f" Или: kubectl --kubeconfig={result.kubeconfig_path} get nodes") + if result.kubeconfig_patched_for_host: + print(" apiserver настроен на 127.0.0.1:<порт> для доступа с хоста.") + if result.nodes_ready is False and result.nodes_ready_message: + print(f" Предупреждение (ожидание нод): {result.nodes_ready_message}", file=sys.stderr) + + +def _parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser(description="Создание кластера kind (интерактивно или --non-interactive).") + p.add_argument( + "--non-interactive", + action="store_true", + help="Без диалогов; обязательны --name и --kubernetes-version", + ) + p.add_argument("--name", help="Имя кластера (DNS, a-z0-9-)") + p.add_argument( + "--kubernetes-version", + dest="kubernetes_version", + help="Версия / тег kindest/node, например 1.29.4 или v1.29.4", + ) + p.add_argument("--workers", type=int, default=2, help="Число worker-нод (0–20), по умолчанию 2") + return p.parse_args() + + +def main() -> None: + _configure_logging() + args = _parse_args() + + if args.non_interactive: + if not args.name or not args.kubernetes_version: + print("В режиме --non-interactive нужны --name и --kubernetes-version.", file=sys.stderr) + raise SystemExit(2) + try: + result = create_cluster_non_interactive( + name=args.name.strip(), + kubernetes_version_tag=args.kubernetes_version.strip(), + workers=args.workers, + ) + except KindClusterError as e: + print(str(e), file=sys.stderr) + raise SystemExit(getattr(e, "exit_code", 1)) from e + print(_json_result_summary(result)) + return + + _run_interactive() + + +def _json_result_summary(result: CreateClusterResult) -> str: + """JSON для stdout в пакетном режиме ``--non-interactive``.""" + payload = { + "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, + } + return json.dumps(payload, ensure_ascii=False, indent=2) + + +if __name__ == "__main__": + main() diff --git a/app/delete_cluster.py b/app/delete_cluster.py new file mode 100644 index 0000000..581f1db --- /dev/null +++ b/app/delete_cluster.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +"""Интерактивное или пакетное удаление кластера kind и локальной папки с конфигами. + +Автор: Сергей Антропов +Сайт: https://devops.org.ru + +Пакетный режим: ``--non-interactive --name ИМЯ [--yes]`` (``--yes`` пропускает подтверждение). +""" + +from __future__ import annotations + +import argparse +import logging +import shutil +import subprocess +import sys + +from core.cluster_lifecycle import KindClusterError, delete_kind_cluster_and_data +from kind_k8s_paths import clusters_dir + + +def _configure_logging() -> None: + if logging.root.handlers: + return + 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: + return [] + lines = [x.strip() for x in (p.stdout or "").splitlines() if x.strip()] + return [x for x in lines if x.lower() not in ("no kind clusters found.", "no kind clusters found")] + + +def _ask(prompt: str) -> str: + return input(f"{prompt}: ").strip() + + +def _interactive() -> None: + print("=== Удаление кластера kind ===\n") + 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) + sys.exit(127) + + clusters = _list_kind_clusters() + if not clusters: + print("Нет зарегистрированных кластеров kind.") + only_dirs = ( + sorted(p.name for p in CLUSTERS_DIR.iterdir() if p.is_dir() and not p.name.startswith(".")) + if CLUSTERS_DIR.is_dir() + else [] + ) + if only_dirs: + print("Есть локальные папки в clusters/: ", ", ".join(only_dirs)) + name = _ask("Удалить только данные в clusters/<имя> (без kind delete)? Введите имя или пусто для выхода") + if not name: + return + d = CLUSTERS_DIR / name + if d.is_dir(): + shutil.rmtree(d) + print(f"Удалена папка {d}") + else: + print("Папка не найдена.") + return + + print("Существующие кластеры kind:") + for i, c in enumerate(clusters, 1): + print(f" {i}) {c}") + raw = _ask("\nВведите имя кластера или номер из списка") + if not raw: + print("Отмена.") + return + if raw.isdigit(): + idx = int(raw, 10) + if idx < 1 or idx > len(clusters): + print("Неверный номер.") + sys.exit(1) + name = clusters[idx - 1] + else: + name = raw + if name not in clusters: + print(f"Кластер «{name}» не найден в kind. Отмена.") + sys.exit(1) + + confirm = _ask(f"Удалить кластер «{name}» и папку clusters/{name}? (yes/no)") + if confirm.lower() not in ("yes", "y", "да", "д"): + print("Отмена.") + return + + try: + kind_ok, summary = delete_kind_cluster_and_data(name=name, log_to_stdout=True) + except KindClusterError as e: + print(str(e), file=sys.stderr) + raise SystemExit(getattr(e, "exit_code", 1)) from e + + if not kind_ok: + print("kind delete завершился с ошибкой; локальная папка удалена при наличии.", file=sys.stderr) + print(summary) + print("Готово.") + + +def _parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser(description="Удаление кластера kind") + p.add_argument("--non-interactive", action="store_true", help="Без диалогов") + p.add_argument("--name", help="Имя кластера") + p.add_argument("--yes", "-y", action="store_true", help="Не спрашивать подтверждение (только с --non-interactive)") + return p.parse_args() + + +def main() -> None: + _configure_logging() + args = _parse_args() + + if args.non_interactive: + if not args.name: + print("Нужен --name в режиме --non-interactive.", file=sys.stderr) + raise SystemExit(2) + if not args.yes: + print("Добавьте --yes для подтверждения удаления в пакетном режиме.", file=sys.stderr) + raise SystemExit(2) + if not shutil.which("kind"): + print("Не найден kind.", file=sys.stderr) + sys.exit(127) + try: + kind_ok, summary = delete_kind_cluster_and_data(name=args.name.strip()) + except KindClusterError as e: + print(str(e), file=sys.stderr) + raise SystemExit(getattr(e, "exit_code", 1)) from e + print(summary) + raise SystemExit(0 if kind_ok else 1) + + _interactive() + + +if __name__ == "__main__": + main() diff --git a/app/kind_k8s_paths.py b/app/kind_k8s_paths.py new file mode 100644 index 0000000..b1b2f84 --- /dev/null +++ b/app/kind_k8s_paths.py @@ -0,0 +1,34 @@ +"""Корень данных kind-k8s: каталог ``clusters/`` и пути в meta.json. + +``KIND_K8S_WORKDIR`` — рабочий каталог, внутри него создаётся ``clusters/<имя>/``. +В Docker-образе задаётся ``/work``, куда монтируется том с хоста (только данные). + +Локальный запуск из ``kind-k8s/app/*.py``: переменную не задаём — корнем данных +считается **родитель** каталога ``app/`` (каталог ``kind-k8s/``, рядом с ``clusters/``). + +Автор: Сергей Антропов +Сайт: https://devops.org.ru +""" + +from __future__ import annotations + +import os +from pathlib import Path + +# Каталог пакета: kind-k8s/app/; в образе: /opt/kind-k8s/app +_LIB = Path(__file__).resolve().parent +# Корень репозитория kind-k8s (рядом с clusters/), если KIND_K8S_WORKDIR не задан +_REPO_KIND_K8S = _LIB.parent + + +def data_root() -> Path: + """Корень данных (родитель для ``clusters/``).""" + w = (os.environ.get("KIND_K8S_WORKDIR") or "").strip() + if w: + return Path(w).resolve() + return _REPO_KIND_K8S + + +def clusters_dir() -> Path: + """``/clusters``.""" + return data_root() / "clusters" diff --git a/app/kindest_node_tags.py b/app/kindest_node_tags.py new file mode 100644 index 0000000..8bc60c0 --- /dev/null +++ b/app/kindest_node_tags.py @@ -0,0 +1,146 @@ +"""Теги образа ``kindest/node`` с Docker Hub для интерактивного выбора версии Kubernetes. + +Фильтр: только стабильные семверы ``vX.Y.Z`` (без ``-rc`` и т.п.), версия **>= 1.19.0**. +Сортировка: от новых к старым. + +Автор: Сергей Антропов +Сайт: https://devops.org.ru +""" + +from __future__ import annotations + +import json +import logging +import os +import re +import urllib.error +import urllib.request +from typing import Final + +logger = logging.getLogger("kind_k8s.kindest_node_tags") + +# Минимальная версия Kubernetes (включительно) для списка выбора +MIN_K8S: Final[tuple[int, int, int]] = (1, 19, 0) + +# Первая страница API Docker Hub для репозитория kindest/node +_HUB_FIRST_PAGE: Final[str] = ( + "https://hub.docker.com/v2/repositories/kindest/node/tags?page_size=100" +) + +_USER_AGENT: Final[str] = "kind-k8s-tools/1.0 (+https://devops.org.ru)" + +# Семвер без префикса v, только три числа (стабильные релизы) +_TAG_RE = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)$") + + +def parse_semver_tag(name: str) -> tuple[int, int, int] | None: + """Разобрать тег вида ``v1.29.4`` / ``1.29.4`` в ``(major, minor, patch)`` или ``None``.""" + m = _TAG_RE.match(name.strip()) + if not m: + return None + return int(m.group(1)), int(m.group(2)), int(m.group(3)) + + +def normalize_tag_v_prefix(tag: str) -> str: + """Единый вид тега: ``v1.29.4``.""" + s = tag.strip().lower().removeprefix("v") + return f"v{s}" + + +def _version_ok(tup: tuple[int, int, int], minimum: tuple[int, int, int]) -> bool: + return tup >= minimum + + +def tags_from_hub_json_results( + results: list[dict], + *, + minimum: tuple[int, int, int] = MIN_K8S, +) -> list[tuple[tuple[int, int, int], str]]: + """Из элементов ``results`` API Hub извлечь подходящие ``(tuple, канонический_тег)``.""" + out: list[tuple[tuple[int, int, int], str]] = [] + for item in results: + name = (item.get("name") or "").strip() + if not name or name == "latest": + continue + tup = parse_semver_tag(name) + if tup is None: + continue + if not _version_ok(tup, minimum): + continue + out.append((tup, normalize_tag_v_prefix(name))) + return out + + +def merge_sort_unique_tags( + collected: list[tuple[tuple[int, int, int], str]], +) -> list[str]: + """Убрать дубликаты по ``(major, minor, patch)``, отсортировать от новых к старым.""" + by_key: dict[tuple[int, int, int], str] = {} + for tup, canon in collected: + by_key[tup] = canon + ordered = sorted(by_key.keys(), reverse=True) + return [by_key[k] for k in ordered] + + +def _default_max_hub_pages() -> int: + """Верхняя граница числа запросов к API Hub (защита от бесконечного цикла).""" + raw = (os.environ.get("KIND_K8S_HUB_TAGS_MAX_PAGES") or "").strip() + if raw.isdigit(): + return max(1, min(int(raw), 200)) + return 60 + + +def fetch_kindest_node_tags( + *, + minimum: tuple[int, int, int] = MIN_K8S, + max_pages: int | None = None, + timeout: float = 45.0, +) -> list[str]: + """ + Загрузить теги с Docker Hub (постранично), вернуть отсортированный список ``vX.Y.Z``. + + При ошибке сети или HTTP возвращает пустой список (в лог — предупреждение). + """ + pages_cap = max_pages if max_pages is not None else _default_max_hub_pages() + + collected: list[tuple[tuple[int, int, int], str]] = [] + url: str | None = _HUB_FIRST_PAGE + page_idx = 0 + + while url and page_idx < pages_cap: + page_idx += 1 + req = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT}) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + payload = json.load(resp) + except urllib.error.HTTPError as e: + logger.warning( + "Docker Hub HTTP %s при загрузке тегов kindest/node: %s", + e.code, + e.reason, + ) + break + except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as e: + logger.warning("Не удалось загрузить теги kindest/node: %s", e) + break + + batch = tags_from_hub_json_results(payload.get("results") or [], minimum=minimum) + collected.extend(batch) + url = payload.get("next") or None + logger.debug( + "kindest/node Docker Hub: страница %s/%s, подходящих тегов на странице %s", + page_idx, + pages_cap, + len(batch), + ) + + tags = merge_sort_unique_tags(collected) + logger.info( + "kindest/node: собрано %s стабильных тегов >= %s.%s.%s (страниц API: %s)", + len(tags), + minimum[0], + minimum[1], + minimum[2], + page_idx, + ) + return tags diff --git a/app/kubeconfig_patch.py b/app/kubeconfig_patch.py new file mode 100644 index 0000000..38fdc69 --- /dev/null +++ b/app/kubeconfig_patch.py @@ -0,0 +1,157 @@ +"""Правка kubeconfig kind для доступа к API с хоста (после create из контейнера). + +Kind внутри Docker видит другой адрес apiserver; на хосте нужен 127.0.0.1:<порт> +из проброса `docker port -control-plane 6443/tcp` (Podman — тот же клиент +`docker` к docker-совместимому сокету). + +Автор: Сергей Антропов +Сайт: https://devops.org.ru +""" + +from __future__ import annotations + +import logging +import os +import subprocess +from pathlib import Path + +logger = logging.getLogger("kind_k8s.kubeconfig_patch") + + +def _container_cli() -> str: + """CLI для `port` (обычно docker к сокету Docker или Podman).""" + return (os.environ.get("CONTAINER_CLI") or "docker").strip() or "docker" + + +def _control_plane_container_name(cluster_name: str) -> str: + return f"{cluster_name}-control-plane" + + +def _parse_docker_port_line(line: str) -> tuple[str, str] | None: + """Строка вида '0.0.0.0:32768' или '127.0.0.1:32768' -> (host, port).""" + line = line.strip() + if ":" not in line: + return None + # IPv6 [::]:port + if line.startswith("["): + rb = line.rfind("]") + if rb == -1: + return None + host = line[1:rb] + rest = line[rb + 1 :].lstrip(":") + if not rest.isdigit(): + return None + return host, rest + host, _, port = line.rpartition(":") + if not port.isdigit(): + return None + host = host.strip() + return host, port + + +def _host_bind_for_kubeconfig(host: str) -> str: + if host in ("0.0.0.0", "::", ""): + return "127.0.0.1" + if host == "[::]": + return "127.0.0.1" + return host + + +def get_apiserver_host_port(cluster_name: str) -> tuple[str, str] | None: + """Узнать (host, port) с хоста для доступа к apiserver.""" + cli = _container_cli() + ctr = _control_plane_container_name(cluster_name) + p = subprocess.run( + [cli, "port", ctr, "6443/tcp"], + capture_output=True, + text=True, + ) + if p.returncode != 0: + logger.info( + "Не удалось %s port %s 6443/tcp: %s", + cli, + ctr, + (p.stderr or p.stdout or "").strip(), + ) + return None + for raw in (p.stdout or "").splitlines(): + parsed = _parse_docker_port_line(raw) + if not parsed: + continue + h, port = parsed + return _host_bind_for_kubeconfig(h), port + return None + + +def _kubeconfig_cluster_name(kube_path: Path, logical_name: str) -> str: + """Имя блока cluster в kubeconfig (обычно kind-<имя>).""" + p = subprocess.run( + [ + "kubectl", + "--kubeconfig", + str(kube_path), + "config", + "view", + "-o", + 'jsonpath={range .clusters[*]}{.name}{"\n"}{end}', + ], + capture_output=True, + text=True, + ) + names = [x.strip() for x in (p.stdout or "").splitlines() if x.strip()] + want = f"kind-{logical_name}" + if want in names: + return want + for n in names: + if n.startswith("kind-"): + return n + return want + + +def patch_kubeconfig_server_for_host( + *, + cluster_name: str, + kube_path: Path, +) -> bool: + """ + Подставить в kubeconfig server=https://<хост>:<порт> для доступа с хоста. + + Порт берётся из ``docker port`` / аналога к сокету; для Podman — тот же клиент + при ``DOCKER_HOST`` на podman.sock. + """ + hp = get_apiserver_host_port(cluster_name) + if not hp: + print( + "Предупреждение: не удалось получить порт apiserver с хоста; " + "kubeconfig оставлен как выдал kind (с хоста может не открываться).", + ) + return False + host, port = hp + server = f"https://{host}:{port}" + cluster_id = _kubeconfig_cluster_name(kube_path, cluster_name) + p = subprocess.run( + [ + "kubectl", + "--kubeconfig", + str(kube_path), + "config", + "set-cluster", + cluster_id, + f"--server={server}", + ], + capture_output=True, + text=True, + ) + if p.returncode != 0: + err = (p.stderr or p.stdout or "").strip() + print(f"Предупреждение: kubectl config set-cluster не удался: {err}") + return False + print(f"Kubeconfig для хоста: apiserver → {server}") + return True + + +def should_patch_after_create() -> bool: + """Патчить после create, если задано явно или kind шёл из контейнера.""" + if os.environ.get("KIND_K8S_PATCH_KUBECONFIG", "").strip().lower() in ("1", "true", "yes", "да"): + return True + return os.environ.get("KIND_K8S_IN_CONTAINER", "").strip() == "1" diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..5b69195 --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,5 @@ +"""Pydantic-схемы для API. + +Автор: Сергей Антропов +Сайт: https://devops.org.ru +""" diff --git a/clusters/.gitkeep b/clusters/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..fde8492 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,36 @@ +# Кластер kind на хосте через сокет Docker/Podman; скрипты внутри образа kind-k8s-tools. +# На хост монтируется только ./clusters → артефакты (kubeconfig, meta.json). +# +# Создание (интерактивно): make create +# +# Podman (пример rootless): +# export CONTAINER_SOCKET="$XDG_RUNTIME_DIR/podman/podman.sock" +# podman compose run --rm -it kind-k8s-tools python3 /opt/kind-k8s/app/create_cluster.py +# +# Автор: Сергей Антропов — https://devops.org.ru + +services: + kind-k8s-tools: + build: + context: . + dockerfile: Dockerfile + args: + KIND_VERSION: ${KIND_VERSION:-0.24.0} + image: kind-k8s-tools:local + volumes: + - ./clusters:/work/clusters + - ${CONTAINER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock + working_dir: /work + environment: + DOCKER_HOST: unix:///var/run/docker.sock + KIND_K8S_IN_CONTAINER: "1" + # Из файла kind-k8s/.env (см. env.example): патч kubeconfig и CLI к сокету + KIND_K8S_PATCH_KUBECONFIG: ${KIND_K8S_PATCH_KUBECONFIG:-} + CONTAINER_CLI: ${CONTAINER_CLI:-docker} + # Выбор версии kindest/node (create_cluster.py); см. env.example + KIND_K8S_SKIP_VERSION_LIST: ${KIND_K8S_SKIP_VERSION_LIST:-} + KIND_K8S_VERSION_LIST_DISPLAY: ${KIND_K8S_VERSION_LIST_DISPLAY:-} + KIND_K8S_HUB_TAGS_MAX_PAGES: ${KIND_K8S_HUB_TAGS_MAX_PAGES:-} + KIND_K8S_DEBUG: ${KIND_K8S_DEBUG:-} + stdin_open: true + tty: true diff --git a/docs/k8s_runbook.md b/docs/k8s_runbook.md new file mode 100644 index 0000000..635c95d --- /dev/null +++ b/docs/k8s_runbook.md @@ -0,0 +1,119 @@ +# Runbook эксплуатации Kubernetes модуля + +Документ относится к приложению **AppsTemplate** (модуль Kubernetes в веб-платформе). Репозиторий **kind-k8s-develop** хранит этот runbook рядом с утилитами локального kind. + +Автор: Сергей Антропов +Сайт: https://devops.org.ru + +## 1. Кластер недоступен + +- Симптомы: `GET /api/v1/k8s/clusters/{id}/health` возвращает `unreachable`, preflight показывает ошибку connectivity. +- Проверки: + - Валиден ли kubeconfig кластера в записи `k8s_clusters`. + - Доступен ли API endpoint кластера из контейнера приложения. + - Не истекли ли сертификаты/токены в kubeconfig. +- Действия: + - Обновить kubeconfig кластера через `PATCH /api/v1/k8s/clusters/{id}`. + - Повторно запустить health-check с `force=1`. + - При необходимости временно отложить массовую выдачу доступов. + +### 1.1. Docker Desktop: kubeconfig с `127.0.0.1`, приложение в контейнере + +- **Симптом:** health-check и любые вызовы API кластера из приложения дают `unreachable`, хотя `kubectl` с хоста работает. +- **Причина:** в kubeconfig `server: https://127.0.0.1:…` означает «loopback того процесса, который подключается». Внутри контейнера `127.0.0.1` — это не хост с Docker Desktop. +- **Что сделать:** в записи кластера заменить адрес API на тот, который виден **из контейнера приложения**: + - **macOS / Windows, Docker Desktop:** обычно `https://host.docker.internal:ПОРТ` (тот же порт, что был у `127.0.0.1`, часто `6443`). + - **Linux:** при необходимости добавить в `docker-compose` для сервиса приложения `extra_hosts: ["host.docker.internal:host-gateway"]` и использовать `host.docker.internal`, либо указать IP шлюза к хосту / LAN-IP. +- **Имя вида `Something.local` (Bonjour / mDNS):** с Mac такое имя обычно резолвится на хосте, но **внутри контейнера приложения часто не резолвится** — health-check снова будет `unreachable` при том же kubeconfig. Для Docker Desktop надёжнее **`https://host.docker.internal:6443`** (или фиксированный LAN-IP Mac), а не `*.local`. +- **Только IPv6 у `host.docker.internal`:** если в контейнере `getent hosts host.docker.internal` показывает один адрес `fdc4:...` (IPv6), а API слушает IPv4, соединение может не установиться. В репозитории **AppsTemplate** для сервиса `app` в `docker-compose.yml` задано `extra_hosts: ["host.docker.internal:host-gateway"]` — после `docker compose up -d` перепроверьте `getent` (должен появиться маршрут через IPv4-шлюз хоста). Kubeconfig: `server: https://host.docker.internal:6443`. +- **TLS / hostname mismatch:** при `server: https://host.docker.internal:6443` сертификат API чаще всего выписан **не** на это имя → ошибка вида `CERTIFICATE_VERIFY_FAILED` / `Hostname mismatch`. **`GET /clusters/{id}/health`** сначала подключается с kubeconfig из БД; при типичной ошибке TLS выполняется **вторая попытка** с временной копией, где для cluster из `current-context` включён `insecure-skip-tls-verify` (в БД ничего не пишется). В ответе может быть `tls_insecure_fallback_used: true`. Для **apply RBAC / выдачи доступов** по-прежнему нужен рабочий TLS в сохранённом kubeconfig или явный `insecure-skip-tls-verify` в YAML. +- **Явный insecure в kubeconfig (рекомендуется для dev, если нужны не только health):** в записи **того же** `cluster` добавьте **`insecure-skip-tls-verify: true`**. Пример фрагмента: + ```yaml + clusters: + - name: docker-desktop + cluster: + server: https://host.docker.internal:6443 + insecure-skip-tls-verify: true + # certificate-authority-data: ... # при insecure можно убрать, чтобы не путаться + ``` + В **production** так не делают: там `server` и SAN в сертификате должны совпадать, проверка TLS включена. +- Обновление kubeconfig: UI **«Редактировать кластер»** (`/k8s/clusters/{id}/edit`) или `PATCH /api/v1/k8s/clusters/{id}`. + +## 2. Частично примененные манифесты RBAC + +- Симптомы: часть пользователей получила доступ, часть — ошибки в `POST /api/v1/k8s/access/bulk`. +- Проверки: + - Использовать preflight (`/access/preflight`, `/access/bulk/preflight`) перед повторной операцией. + - Проверить существующие активные записи в `k8s_user_configs`. + - Проверить аудит `k8s_access_audit`. +- Действия: + - Повторить операцию с `idempotency_key` только после устранения причины. + - Для конфликтных пользователей выполнить точечный revoke/restore или delete/create. + - Если обнаружены дубликаты, очистить лишние записи и заново выдать доступ. + +## 3. Восстановление после ошибок миграций + +- Симптомы: ошибки `UndefinedTableError` при старте приложения. +- Проверки: + - Убедиться, что применены миграции модуля из `app/db/migrations/modules/k8s/` (репозиторий AppsTemplate). + - Проверить наличие таблиц `k8s_*` в БД. +- Действия: + - Применить восстановительную миграцию `app/db/migrations/102_k8s_repair_missing_tables.sql`. + - Перезапустить приложение. + - Проверить работу API `GET /api/v1/k8s/health`. + +## 4. Ротация ключа шифрования kubeconfig + +- **Основной источник ключа:** настройка в БД `k8s.kubeconfig_encryption_key` (страница **Настройки → Модули → Kubernetes**). При непустом значении в БД оно имеет приоритет над переменной окружения. +- **Резерв:** `K8S_KUBECONFIG_ENCRYPTION_KEY` в `.env` — используется только если в БД ключ пустой (удобно для первого запуска и CI; см. `env.example` в AppsTemplate). +- Рекомендуемый порядок смены ключа: + 1. Перевести сервис в окно обслуживания. + 2. Считать и дешифровать существующие значения из `k8s_clusters.kubeconfig_encrypted` и `k8s_user_configs.kubeconfig_encrypted` **текущим** ключом (из БД или ENV — в том же порядке приоритета, что у приложения). + 3. Перешифровать новым ключом и сохранить обратно в таблицы. + 4. Сохранить новый секрет в настройках модуля (или обновить ENV, если используете только резерв). + 5. Перезапустить приложение при необходимости и проверить health-check и скачивание kubeconfig. +- Важно: не удалять старый ключ до завершения полной перешифровки. + +## 5. Мониторинг и безопасность фоновых задач + +- Используйте страницу `/k8s/jobs` и API `GET /api/v1/k8s/jobs` (пагинация `skip`/`limit`, фильтр `status`) и `GET /api/v1/k8s/jobs/{job_id}` для полного текста ошибки и результата. +- Детальные статусы: + - `GET /api/v1/k8s/access/jobs/{job_id}` + - `GET /api/v1/k8s/access/bulk/jobs/{job_id}` + - `GET /api/v1/k8s/kubeconfig/merge/jobs/{job_id}` +- Все job-status ответы проходят маскировку чувствительных полей (`kubeconfig`, `token`, `secret`, `password`, `private_key`, `certificate`). +- Для merge-задач поле `result.rendered` не возвращается в статусе и доступно только через download endpoint. +- Рекомендуется искать инциденты по `correlation_id` в логах приложения и в записи задачи в `k8s_jobs`. +- Утилита наблюдаемости: `GET /api/v1/k8s/observability/jobs/{job_id}` для задач с `job_type=k8s.observability`; UI — `/k8s/observability`. + +## 6. Утилита стека наблюдаемости (Metrics Server / Prometheus) + +- Настройки: `k8s.observability_metrics_server_manifest_url` (по умолчанию официальный `components.yaml` metrics-server), `k8s.observability_metrics_server_kubelet_insecure_tls`, `k8s.observability_prometheus_manifest_bundle_url` (опционально), таймауты и лимит кластеров за операцию. +- **Metrics Server:** при типичных kubeadm/kind кластерах без корректных kubelet-сертификатов включите `--kubelet-insecure-tls` (чекбокс в UI или настройка/тело запроса install). +- **Prometheus stack:** полный `kube-prometheus-stack` обычно ставят **Helm** вне приложения; через API имеет смысл подключать **сокращённый** multi-doc YAML, где все объекты имеют поддерживаемые `kind`. Иначе часть документов попадёт в `skipped` в ответе задачи. +- **Откат:** `POST .../uninstall/jobs` удаляет ресурсы в обратном порядке того же бандла; при ручных правках в кластере возможны остаточные объекты — добейте `kubectl delete` / повторным uninstall. + +### 6.1. Локальный кластер kind для разработки + +Этот репозиторий (**kind-k8s-develop**): образ **kind-k8s-tools**, **`make create`** (Docker и make на хосте) поднимает kind; артефакты — **`clusters/<имя>/kubeconfig`**. Импорт kubeconfig в **AppsTemplate** см. **§1.1** (адрес API должен быть достижим **из контейнера приложения**, не обязательно `127.0.0.1`). Краткая инструкция по командам — в **`README.md`** в корне этого репозитория. + +## 7. Срок действия доступа (`access_expires_at`) + +- В `POST /access`, `POST /access/bulk` и `PATCH /access/{id}` можно задать дату окончания; пустое значение в PATCH сбрасывает срок. +- Фоновый цикл использует `k8s.access_expiry_check_interval_hours`; отзыв выполняется так же, как ручной revoke (RBAC в кластере, уведомление владельцу; в аудите `reason: access_expired`). +- Уведомление при автоотзыве: в тексте указано «система (автоматически)». + +## 8. Отправка kubeconfig в Telegram + +- В настройках модуля задать `k8s.telegram_bot_token`. У владельца доступа в профиле — поле **`telegram_chat_id`** (числовой id; миграция `105_profile_field_telegram_chat_id.sql` добавляет описание в реестр полей). +- Ссылка `https://t.me/...` **не подставляет** chat_id: пользователь должен написать боту и узнать id (например через @userinfobot), затем сохранить `telegram_chat_id`. +- `POST /api/v1/k8s/access/{config_id}/send-telegram`: владелец или лид с `modules.k8s:update`. Длинный YAML режется по `k8s.telegram_kubeconfig_max_chars` — тогда в чат уходит короткое сообщение со ссылкой на `/k8s/my-configs`. + +## 9. Дрейф RBAC и сверка с кластером (reconcile) + +- **Симптом:** в UI на карточке кластера колонка «Сверка RBAC» показывает `drift_detected` или `sync_error`, либо администратор вручную удалил Role/Binding в кластере. +- **Проверки:** + - `POST /api/v1/k8s/access/{config_id}/reconcile-check` (или кнопка «Сверка» на вкладке «Доступы») — пересчитать статус по сохранённым `rbac_manifests`. + - Убедиться, что у записи доступа непустые `rbac_manifests` (иначе сначала `apply-manifests` или PATCH с пересборкой). +- **Действия при дрейфе:** `POST /api/v1/k8s/access/{config_id}/apply-manifests` — повторное применение; после успешного apply статус сверки обновляется вместе с записью. +- **Настройки:** `k8s.reconcile_check_timeout_seconds`, опционально `k8s.reconcile_background_interval_hours` (часы между фоновыми прогонами для активных доступов; `0` — только ручной вызов). diff --git a/env.example b/env.example new file mode 100644 index 0000000..4c03eba --- /dev/null +++ b/env.example @@ -0,0 +1,27 @@ +# Пример переменных для каталога kind-k8s. +# Скопируйте в kind-k8s/.env и раскомментируйте нужные строки. +# Docker Compose читает .env при запуске из kind-k8s (make create, compose build и т.д.). + +# --- Сборка образа kind-k8s-tools (build-arg в docker-compose.yml) --- +# KIND_VERSION=0.24.0 + +# --- Сокет API контейнеров (volume в docker-compose.yml) --- +# Docker по умолчанию подставляет /var/run/docker.sock; Podman rootless — свой путь. +# CONTAINER_SOCKET=/var/run/docker.sock + +# --- Среда внутри контейнера (передаётся через docker-compose environment) --- +# Принудительно пропатчить server в kubeconfig на 127.0.0.1:<порт> (иначе — только при KIND_K8S_IN_CONTAINER) +# KIND_K8S_PATCH_KUBECONFIG=1 +# Команда для docker port / аналога (часто docker даже при Podman) +# CONTAINER_CLI=docker + +# Список версий kindest/node при make create (Docker Hub, стабильные теги >= 1.19) +# KIND_K8S_SKIP_VERSION_LIST=1 +# KIND_K8S_VERSION_LIST_DISPLAY=50 +# KIND_K8S_HUB_TAGS_MAX_PAGES=60 +# KIND_K8S_DEBUG=1 + +# --- Только Makefile (в .env compose не используется; задайте в оболочке или: make VAR=value) --- +# COMPOSE=docker compose +# COMPOSE=podman compose +# COMPOSE_BUILD_FLAGS=--platform linux/arm64 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e8522c5 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +# Зависимости веб-интерфейса (FastAPI) в образе kind-k8s-tools. +# Установка: см. Dockerfile (pip install --break-system-packages). +# +# Автор: Сергей Антропов — https://devops.org.ru + +fastapi>=0.115.0 +uvicorn[standard]>=0.32.0 +jinja2>=3.1.4 +pydantic-settings>=2.6.0 diff --git a/scripts/setup_env_interactive.py b/scripts/setup_env_interactive.py new file mode 100644 index 0000000..267483e --- /dev/null +++ b/scripts/setup_env_interactive.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +"""Интерактивное создание ``.env`` по шаблону ``env.example`` в корне kind-k8s-develop. + +Запуск из корня репозитория: ``python3 scripts/setup_env_interactive.py`` +или ``make setup``. + +Опционально: ``--template`` / ``--output`` для других путей. + +Автор: Сергей Антропов +Сайт: https://devops.org.ru +""" + +from __future__ import annotations + +import argparse +import logging +import re +import secrets +from pathlib import Path + +logger = logging.getLogger("setup_env_interactive") + +# Корень репозитория kind-k8s-develop (родитель каталога scripts/) +REPO_ROOT = Path(__file__).resolve().parents[1] + +# Переменные, для которых предлагается сгенерировать секрет по вводу «g» +_SECRET_KEYS = frozenset( + { + "SECRET_KEY", + "CONNECTORS_ENCRYPTION_KEY", + "K8S_KUBECONFIG_ENCRYPTION_KEY", + "POSTGRES_PASSWORD", + "MINIO_SECRET_KEY", + "ROOT_PASSWORD", + }, +) + + +def _configure_logging() -> None: + logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") + + +def _is_comment_or_blank(line: str) -> bool: + s = line.strip() + return not s or s.startswith("#") + + +def _parse_assignment(line: str) -> tuple[str, str] | None: + """Строка ``KEY=значение`` без ведущего ``#``; иначе ``None``.""" + raw = line.rstrip("\n\r") + if raw.lstrip().startswith("#"): + return None + if "=" not in raw: + return None + key, _, value = raw.partition("=") + k = key.strip() + if not k or not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", k): + return None + return k, value + + +def _ask_line( + key: str, + default: str, + *, + collected: dict[str, str], +) -> str: + """Спросить одну переменную; ``g`` — сгенерировать секрет (для известных ключей).""" + extra = "" + if key in _SECRET_KEYS: + extra = " [g] — сгенерировать случайное значение" + + hint = default.replace("\n", " ")[:72] + if len(default) > 72: + hint += "…" + print(f"\n{key}") + print(f" По умолчанию из env.example: {hint!r}{extra}") + raw = input(" Значение (Enter — по умолчанию): ").strip() + + if not raw: + return default + + if raw.lower() == "g" and key in _SECRET_KEYS: + generated = secrets.token_urlsafe(32) + print(f" Сгенерировано ({len(generated)} символов).") + return generated + + return raw + + +def _default_database_url(collected: dict[str, str]) -> str | None: + u = collected.get("POSTGRES_USER") + p = collected.get("POSTGRES_PASSWORD") + d = collected.get("POSTGRES_DB") + if u is not None and p is not None and d is not None: + return f"postgresql://{u}:{p}@postgres:5432/{d}" + return None + + +def run( + *, + template: Path, + output: Path, + force: bool = False, +) -> int: + template = template.resolve() + output = output.resolve() + + if not template.is_file(): + logger.error("Не найден шаблон: %s", template) + return 1 + + if output.exists() and not force: + print(f"Файл уже существует: {output}") + ans = input("Перезаписать? [y/N]: ").strip().lower() + if ans not in ("y", "yes", "д", "да"): + print("Выход без изменений.") + return 0 + + lines_in = template.read_text(encoding="utf-8").splitlines(keepends=True) + collected: dict[str, str] = {} + out_chunks: list[str] = [] + + for line in lines_in: + if _is_comment_or_blank(line.rstrip("\n\r")): + out_chunks.append(line if line.endswith("\n") else line + "\n") + continue + + parsed = _parse_assignment(line) + if parsed is None: + out_chunks.append(line if line.endswith("\n") else line + "\n") + continue + + key, template_default = parsed + + default = template_default + if key == "DATABASE_URL": + built = _default_database_url(collected) + if built is not None: + default = built + print("\n--- DATABASE_URL: можно собрать из учётки PostgreSQL выше ---") + + value = _ask_line(key, default, collected=collected) + collected[key] = value + out_chunks.append(f"{key}={value}\n") + + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text("".join(out_chunks), encoding="utf-8") + print(f"\nГотово: записан файл {output}") + return 0 + + +def main() -> None: + _configure_logging() + parser = argparse.ArgumentParser(description="Интерактивное заполнение .env по env.example") + parser.add_argument( + "--template", + type=Path, + default=REPO_ROOT / "env.example", + help="Путь к шаблону (по умолчанию: env.example в корне репозитория)", + ) + parser.add_argument( + "--output", + type=Path, + default=REPO_ROOT / ".env", + help="Куда записать .env (по умолчанию: .env в корне репозитория)", + ) + parser.add_argument("-f", "--force", action="store_true", help="Не спрашивать подтверждение перезаписи") + args = parser.parse_args() + raise SystemExit(run(template=args.template, output=args.output, force=args.force)) + + +if __name__ == "__main__": + main()