Files
KindClustersDashboard/app/core/cluster_config_edit.py
Sergey Antropoff 52538d9816 Панель: редактирование конфига кластера, reapply при старте, UI и таблица
- PUT/GET конфигурации кластера, страница редактирования и модалка после сохранения
- После смены kind-config: флаг в meta и start_cluster_reapply (kind delete + create)
- Старт/стоп: полноэкранный спиннер до завершения job; модалки и документация API
- Таблица кластеров: колонка Имя 40% при table-layout fixed; чекбоксы без width 100%
- Карточки ресурсов узлов на странице кластера: до 3 в ряд; прочие правки стилей и dashboard.js
2026-04-04 10:49:40 +03:00

216 lines
9.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Редактирование ``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(),
}