first commit

This commit is contained in:
Sergey Antropoff
2026-04-04 05:15:54 +03:00
commit ae961ef5fe
25 changed files with 2022 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
__pycache__
*.pyc
.git
+4
View File
@@ -0,0 +1,4 @@
# Сгенерированные конфиги и kubeconfig локальных кластеров kind
.env
clusters/*/
!clusters/.gitkeep
+33
View File
@@ -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
+81
View File
@@ -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"
+125
View File
@@ -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).
Binary file not shown.
+5
View File
@@ -0,0 +1,5 @@
"""Скрипты и модули локального кластера kind (каталог репозитория kind-k8s).
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
Binary file not shown.
+174
View File
@@ -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()
+5
View File
@@ -0,0 +1,5 @@
"""Вспомогательная логика для API и общих операций kind-k8s-develop.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
+340
View File
@@ -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
+41
View File
@@ -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()
+89
View File
@@ -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()
+267
View File
@@ -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()
+148
View File
@@ -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()
+34
View File
@@ -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:
"""``<data_root>/clusters``."""
return data_root() / "clusters"
+146
View File
@@ -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
+157
View File
@@ -0,0 +1,157 @@
"""Правка kubeconfig kind для доступа к API с хоста (после create из контейнера).
Kind внутри Docker видит другой адрес apiserver; на хосте нужен 127.0.0.1:<порт>
из проброса `docker port <cluster>-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"
+5
View File
@@ -0,0 +1,5 @@
"""Pydantic-схемы для API.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
View File
+36
View File
@@ -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
+119
View File
@@ -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` — только ручной вызов).
+27
View File
@@ -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
+9
View File
@@ -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
+174
View File
@@ -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()