first commit
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
__pycache__
|
||||
*.pyc
|
||||
.git
|
||||
@@ -0,0 +1,4 @@
|
||||
# Сгенерированные конфиги и kubeconfig локальных кластеров kind
|
||||
.env
|
||||
clusters/*/
|
||||
!clusters/.gitkeep
|
||||
+33
@@ -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
|
||||
@@ -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"
|
||||
@@ -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.
@@ -0,0 +1,5 @@
|
||||
"""Скрипты и модули локального кластера kind (каталог репозитория kind-k8s).
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
Binary file not shown.
Executable
+174
@@ -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()
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Вспомогательная логика для API и общих операций kind-k8s-develop.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Pydantic-схемы для API.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user