"""Синхронные операции с 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 collections.abc import Callable 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_checked_stream( cmd: list[str], *, cwd: Path | None = None, on_line: Callable[[str], None] | None = None, ) -> None: """ Выполнить команду с построчным выводом в колбэк (stdout+stderr объединены). Нужен для ``kind create cluster``: pull образов и подъём нод видны в UI по опросу job. """ logger.info("Выполнение (поток): %s", " ".join(cmd)) p = subprocess.Popen( cmd, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, ) if p.stdout is None: raise KindClusterError("Не удалось открыть stdout процесса", exit_code=1) try: for raw in p.stdout: line = raw.rstrip("\n\r") if on_line and line: on_line(line) if line: logger.debug("stream: %s", line[:800]) rc = p.wait() finally: p.stdout.close() if rc != 0: raise KindClusterError(f"Команда завершилась с кодом {rc} (см. журнал задания выше)", exit_code=rc) 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, use_existing_config: bool = False, ) -> CreateClusterResult: """ Создать кластер kind без диалогов. ``kubernetes_version_tag`` — тег kindest/node (например ``v1.29.4``), см. ``normalize_tag_v_prefix``. ``job_id`` — если задан, обновляется прогресс и проверяется отмена (см. ``job_store``). ``use_existing_config=True`` — не перезаписывать ``kind-config.yaml``, поднять кластер по уже сохранённому файлу (каталог ``clusters/<имя>/`` должен существовать). """ 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) _job_store.append_log_sync(job_id, f"[{pct}%] {stage}") def _log(line: str) -> None: if job_id: _job_store.append_log_sync(job_id, line) 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 not use_existing_config and (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" prev_meta_for_workers: dict[str, object] = {} if use_existing_config: if not cfg_path.is_file(): raise KindClusterError(f"Нет сохранённого kind-config.yaml: {cfg_path}") prev = read_meta_json(name) or {} prev_meta_for_workers = prev if prev.get("node_image"): node_image = str(prev["node_image"]) if prev.get("kubernetes_version_tag"): ver_tag = str(prev["kubernetes_version_tag"]) _progress("Используется существующий kind-config.yaml", 10) else: 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, existing_cfg=%s", name, node_image, workers, use_existing_config, ) _progress("kind create cluster (скачивание образов и подъём нод — может занять несколько минут)", 28) _log("--- kind create cluster ---") _run_checked_stream( ["kind", "create", "cluster", "--name", name, "--config", str(cfg_path)], on_line=_log, ) 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) _log(f"kubectl wait nodes: {msg}"[:4000]) worker_nodes_meta = workers if use_existing_config: prev_w = prev_meta_for_workers.get("worker_nodes") if prev_w is not None: try: worker_nodes_meta = int(prev_w) except (TypeError, ValueError): worker_nodes_meta = workers meta = { "cluster_name": name, "kubernetes_version_tag": ver_tag, "node_image": node_image, "worker_nodes": worker_nodes_meta, "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, "provisioned_from_existing_config": use_existing_config, } 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=worker_nodes_meta, 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 _sort_kind_node_containers(names: list[str]) -> list[str]: """Сначала control-plane, затем остальные — удобнее для ``docker start``.""" def sort_key(n: str) -> tuple[int, str]: if n.endswith("-control-plane"): return (0, n) return (1, n) return sorted(names, key=sort_key) def list_kind_cluster_container_names(*, cluster_name: str) -> list[str]: """Имена контейнеров узлов kind (все с префиксом ``<имя>-``).""" cli = _container_cli_bin() if not shutil.which(cli): raise KindClusterError(f"CLI контейнеров «{cli}» не найден в PATH.", exit_code=127) p = subprocess.run( [cli, "ps", "-a", "--format", "{{.Names}}"], capture_output=True, text=True, ) if p.returncode != 0: err = (p.stderr or p.stdout or "").strip() raise KindClusterError(f"{cli} ps: {err}", exit_code=p.returncode) prefix = f"{cluster_name}-" raw = [n.strip() for n in (p.stdout or "").splitlines() if n.strip()] matched = [n for n in raw if n.startswith(prefix)] return _sort_kind_node_containers(matched) def stop_kind_cluster_containers(*, name: str) -> tuple[bool, str]: """ Остановить контейнеры узлов (``docker stop`` / ``podman stop``). Запись kind о кластере сохраняется; позже можно вызвать ``start_kind_cluster_containers``. """ names = list_kind_cluster_container_names(cluster_name=name) if not names: return True, "Нет контейнеров с префиксом «%s-» (уже остановлены или удалены)" % name cli = _container_cli_bin() ok_all = True parts: list[str] = [] for ctr in names: p = subprocess.run([cli, "stop", ctr], capture_output=True, text=True) if p.returncode != 0: ok_all = False err = (p.stderr or p.stdout or "").strip() or str(p.returncode) parts.append(f"{ctr}: ошибка ({err})") logger.warning("%s stop %s: %s", cli, ctr, err) else: parts.append(f"{ctr}: OK") return ok_all, "; ".join(parts) def start_kind_cluster_containers(*, name: str) -> tuple[bool, str]: """Запустить контейнеры узлов kind (после ``stop`` или рестарта движка).""" names = list_kind_cluster_container_names(cluster_name=name) if not names: return False, ( "Не найдены контейнеры «%s-*». Если кластера нет в kind — используйте «Старт» " "из UI (создание по сохранённому kind-config.yaml) или создайте кластер заново." % name ) cli = _container_cli_bin() ok_all = True parts: list[str] = [] for ctr in names: p = subprocess.run([cli, "start", ctr], capture_output=True, text=True) if p.returncode != 0: ok_all = False err = (p.stderr or p.stdout or "").strip() or str(p.returncode) parts.append(f"{ctr}: ошибка ({err})") logger.warning("%s start %s: %s", cli, ctr, err) else: parts.append(f"{ctr}: OK") return ok_all, "; ".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