first commit
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user