"""Синхронные операции с 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") def _rollback_after_cancel(*, cluster_name: str, out_dir: Path) -> None: """Удалить кластер kind и каталог данных после запроса отмены (best-effort).""" logger.info("Откат после отмены: kind delete «%s»", cluster_name) subprocess.run( ["kind", "delete", "cluster", "--name", cluster_name], capture_output=True, text=True, ) if out_dir.is_dir(): try: shutil.rmtree(out_dir) logger.info("Удалён каталог %s", out_dir) except OSError as e: logger.warning("Не удалось удалить %s: %s", out_dir, e) # Имя кластера: поддомен 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, job_id: str | None = None, ) -> CreateClusterResult: """ Создать кластер kind без диалогов. ``kubernetes_version_tag`` — тег kindest/node (например ``v1.29.4``), см. ``normalize_tag_v_prefix``. ``job_id`` — если задан, обновляется прогресс и проверяется отмена (см. ``job_store``). """ from core import job_store as _job_store def _progress(stage: str, pct: int) -> None: if job_id: _job_store.set_progress_sync(job_id, stage, pct) def _cancelled() -> bool: return bool(job_id and _job_store.is_cancelled_sync(job_id)) 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") _progress("Подготовка каталога и kind-config", 12) if _cancelled(): _rollback_after_cancel(cluster_name=name, out_dir=out_dir) raise KindClusterError("Создание отменено пользователем") logger.info("Создание кластера «%s», образ %s, workers=%s", name, node_image, workers) _progress("kind create cluster (скачивание образов и подъём нод — может занять несколько минут)", 28) _run_checked(["kind", "create", "cluster", "--name", name, "--config", str(cfg_path)]) if _cancelled(): _rollback_after_cancel(cluster_name=name, out_dir=out_dir) raise KindClusterError("Создание отменено пользователем") _progress("Сохранение kubeconfig", 58) kube = _run_capture_checked(["kind", "get", "kubeconfig", "--name", name]) kube_path.write_text(kube, encoding="utf-8") if _cancelled(): _rollback_after_cancel(cluster_name=name, out_dir=out_dir) raise KindClusterError("Создание отменено пользователем") patched = False if should_patch_after_create(): _progress("Патч kubeconfig для доступа к API с хоста", 72) patched = patch_kubeconfig_server_for_host(cluster_name=name, kube_path=kube_path) if _cancelled(): _rollback_after_cancel(cluster_name=name, out_dir=out_dir) raise KindClusterError("Создание отменено пользователем") nodes_ready: bool | None = None nodes_msg: str | None = None if _wait_nodes_enabled(): _progress("Ожидание готовности нод (kubectl wait …)", 82) 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") _progress("Финализация", 95) 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 _container_cli_bin() -> str: """Имя CLI к сокету (docker / podman), как в kubeconfig_patch.""" return (os.environ.get("CONTAINER_CLI") or "docker").strip() or "docker" def container_engine_ping(*, timeout_sec: float = 12.0) -> tuple[bool, str, str]: """ Проверить доступ к движку контейнеров (``docker info`` / ``podman info``). Возвращает (успех, краткое сообщение или stderr, имя CLI). """ cli = _container_cli_bin() if not shutil.which(cli): return False, f"«{cli}» не найден в PATH", cli try: p = subprocess.run( [cli, "info"], capture_output=True, text=True, timeout=timeout_sec, ) except subprocess.TimeoutExpired: logger.warning("%s info: таймаут %s с", cli, timeout_sec) return False, f"таймаут {timeout_sec} с", cli if p.returncode == 0: return True, "OK", cli err = (p.stderr or p.stdout or "").strip() or f"код {p.returncode}" logger.info("%s info неуспешно: %s", cli, err[:200]) return False, err[:800], cli 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 kubectl_pods_all_namespaces(*, kubeconfig: str | Path) -> tuple[int, str]: """``kubectl get pods -A``; сводка подов по кластеру.""" p = subprocess.run( [ "kubectl", "--kubeconfig", str(kubeconfig), "get", "pods", "-A", "--request-timeout=20s", ], 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