"""Редактирование ``kind-config.yaml`` и ``meta.json`` в каталоге кластера. Kind **не** меняет число нод и образ узлов у уже созданного кластера: сохранённые файлы используются при следующем ``kind create`` (например после ``kind delete`` и кнопки «Старт» или полного пересоздания). Автор: Сергей Антропов Сайт: https://devops.org.ru """ from __future__ import annotations import json import logging import re from typing import Any import yaml from core.cluster_lifecycle import build_kind_config_yaml, read_meta_json, validate_cluster_name from kindest_node_tags import normalize_tag_v_prefix from kind_k8s_paths import clusters_dir, data_root logger = logging.getLogger("kind_k8s.cluster_config_edit") # Пояснение для UI: до следующего «Старт» kind продолжает использовать старые узлы; после сохранения # конфига выставляется флаг — при «Старт» выполняется kind delete + create по новому kind-config.yaml. NOTE_REGISTERED_IN_KIND = ( "Конфиг сохранён. При следующем «Старт» кластер будет пересоздан в kind по обновлённому " "kind-config.yaml (запись в kind удаляется и создаётся заново; каталог clusters/<имя>/ сохраняется). " "Пока вы не нажали «Старт», работают прежние узлы." ) NOTE_NOT_IN_KIND = ( "Конфигурация сохранена. При «Старт» или создании кластера будет использован этот файл." ) def validate_kind_cluster_yaml_root(data: Any) -> str | None: """ Проверить распарсенный YAML корня конфига kind. Возвращает текст ошибки для пользователя или ``None``, если структура допустима. """ if not isinstance(data, dict): return "Корень YAML должен быть объектом (mapping)." if data.get("kind") != "Cluster": return "Ожидается поле kind: Cluster." api = str(data.get("apiVersion") or "") if "kind.x-k8s.io" not in api: return "Ожидается apiVersion с группой kind.x-k8s.io (например kind.x-k8s.io/v1alpha4)." nodes = data.get("nodes") if not isinstance(nodes, list) or len(nodes) < 1: return "Нужен непустой список nodes." has_cp = False for i, n in enumerate(nodes): if not isinstance(n, dict): return f"Элемент nodes[{i}] должен быть объектом." role = n.get("role") if role == "control-plane": has_cp = True elif role != "worker": return f"Узел nodes[{i}]: role должен быть control-plane или worker." if not has_cp: return "В nodes должна быть хотя бы одна нода с role: control-plane." return None def _count_workers(nodes: list[Any]) -> int: return sum(1 for n in nodes if isinstance(n, dict) and n.get("role") == "worker") def _first_image_from_nodes(nodes: list[Any]) -> str | None: for n in nodes: if isinstance(n, dict): img = n.get("image") if isinstance(img, str) and img.strip(): return img.strip() return None def _tag_from_node_image(image: str) -> str | None: m = re.search(r":([^:]+)$", image.strip()) return m.group(1) if m else None class ClusterConfigEditError(Exception): """Ошибка валидации или отсутствия каталога (сообщение для HTTP 400/404).""" def __init__(self, message: str, *, not_found: bool = False) -> None: super().__init__(message) self.not_found = not_found def apply_cluster_config_update( cluster_name: str, *, kubernetes_version: str | None, workers: int | None, kind_config_yaml: str | None, description: str | None, ) -> tuple[bool, str]: """ Обновить ``kind-config.yaml`` и/или поля ``meta.json``. Возвращает (обновлён ли YAML, краткое сообщение для ответа API). """ if not validate_cluster_name(cluster_name): raise ClusterConfigEditError("Некорректное имя кластера") out_dir = clusters_dir() / cluster_name if not out_dir.is_dir(): raise ClusterConfigEditError("Каталог кластера не найден", not_found=True) meta: dict[str, Any] = dict(read_meta_json(cluster_name) or {}) cfg_path = out_dir / "kind-config.yaml" root = data_root() updated_yaml = False yaml_raw = (kind_config_yaml or "").strip() if yaml_raw: try: parsed = yaml.safe_load(yaml_raw) except yaml.YAMLError as e: raise ClusterConfigEditError(f"Ошибка разбора YAML: {e}") from e err = validate_kind_cluster_yaml_root(parsed) if err: raise ClusterConfigEditError(err) if not yaml_raw.endswith("\n"): yaml_raw += "\n" cfg_path.write_text(yaml_raw, encoding="utf-8") updated_yaml = True nodes = parsed.get("nodes") or [] if isinstance(nodes, list): meta["worker_nodes"] = _count_workers(nodes) img = _first_image_from_nodes(nodes) if img: meta["node_image"] = img tag = _tag_from_node_image(img) if tag: meta["kubernetes_version_tag"] = tag logger.info("Обновлён kind-config.yaml (расширенный режим) для кластера «%s»", cluster_name) elif kubernetes_version is not None or workers is not None: ver_src = (kubernetes_version or "").strip() or str(meta.get("kubernetes_version_tag") or "v1.29.4") ver_tag = normalize_tag_v_prefix(ver_src) w_val = meta.get("worker_nodes") try: w_default = int(w_val) if w_val is not None else 0 except (TypeError, ValueError): w_default = 0 w = workers if workers is not None else w_default if w < 0 or w > 20: raise ClusterConfigEditError("Число worker-нод должно быть от 0 до 20.") node_image = f"kindest/node:{ver_tag}" text = build_kind_config_yaml(node_image=node_image, workers=w) cfg_path.write_text(text, encoding="utf-8") updated_yaml = True meta["kubernetes_version_tag"] = ver_tag meta["node_image"] = node_image meta["worker_nodes"] = w logger.info( "Пересобран kind-config.yaml для «%s»: workers=%s, образ %s", cluster_name, w, node_image, ) elif description is None: raise ClusterConfigEditError("Укажите версию/workers, полный YAML kind-config или описание.") # После смены kind-config.yaml при «Старт» (кластер в kind) — пересоздание, а не docker start. if updated_yaml: meta["apply_kind_config_on_next_start"] = True if description is not None: d = description.strip() if d: meta["description"] = d[:2000] else: meta.pop("description", None) meta["cluster_name"] = cluster_name if cfg_path.is_file(): try: meta["kind_config_path"] = str(cfg_path.relative_to(root)) except ValueError: meta["kind_config_path"] = str(cfg_path) meta_path = out_dir / "meta.json" meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8") logger.info("Обновлён meta.json для кластера «%s»", cluster_name) return updated_yaml, "Сохранено." def read_cluster_config_bundle(cluster_name: str) -> dict[str, Any]: """Собрать данные для GET /clusters/{name}/config (без флагов kind — их добавляет эндпоинт).""" if not validate_cluster_name(cluster_name): raise ClusterConfigEditError("Некорректное имя кластера") out_dir = clusters_dir() / cluster_name if not out_dir.is_dir(): raise ClusterConfigEditError("Каталог кластера не найден", not_found=True) cfg_path = out_dir / "kind-config.yaml" yaml_text: str | None = None if cfg_path.is_file(): yaml_text = cfg_path.read_text(encoding="utf-8") meta = read_meta_json(cluster_name) return { "cluster_name": cluster_name, "meta": meta if isinstance(meta, dict) else {}, "kind_config_yaml": yaml_text, "has_kind_config": cfg_path.is_file(), }