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
+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