Панель: редактирование конфига кластера, reapply при старте, UI и таблица
- PUT/GET конфигурации кластера, страница редактирования и модалка после сохранения - После смены kind-config: флаг в meta и start_cluster_reapply (kind delete + create) - Старт/стоп: полноэкранный спиннер до завершения job; модалки и документация API - Таблица кластеров: колонка Имя 40% при table-layout fixed; чекбоксы без width 100% - Карточки ресурсов узлов на странице кластера: до 3 в ряд; прочие правки стилей и dashboard.js
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
"""Редактирование ``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(),
|
||||
}
|
||||
@@ -630,6 +630,11 @@ def create_cluster_non_interactive(
|
||||
}
|
||||
if patched:
|
||||
meta["kubeconfig_host_path"] = str(host_kube_path.relative_to(root))
|
||||
# Сохраняем описание из прежнего meta.json при подъёме по существующему конфигу (в т.ч. reapply).
|
||||
if use_existing_config:
|
||||
desc = prev_meta_for_workers.get("description")
|
||||
if isinstance(desc, str) and desc.strip():
|
||||
meta["description"] = desc.strip()[:2000]
|
||||
meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
_progress("Завершение", 95)
|
||||
@@ -694,6 +699,55 @@ def delete_kind_cluster_and_data(*, name: str, log_to_stdout: bool = False) -> t
|
||||
return kind_ok, "; ".join(parts)
|
||||
|
||||
|
||||
def delete_kind_cluster_keep_data(*, name: str, job_id: str | None = None) -> tuple[bool, str]:
|
||||
"""
|
||||
Выполнить ``kind delete cluster`` без удаления каталога ``clusters/<имя>/``.
|
||||
|
||||
Используется после изменения ``kind-config.yaml``: затем ``kind create`` поднимает узлы
|
||||
по новому файлу. Если запись в kind уже отсутствует — считаем шаг успешным и идём к create.
|
||||
|
||||
При ``job_id`` вывод и отмена — как у других длительных операций (см. ``_run_checked_stream``).
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
from core import job_store as _js
|
||||
|
||||
def _log(line: str) -> None:
|
||||
if job_id:
|
||||
_js.append_log_sync(job_id, line)
|
||||
|
||||
if not shutil.which("kind"):
|
||||
raise KindClusterError("Не найден kind в PATH.", exit_code=127)
|
||||
|
||||
if job_id and _js.is_cancelled_sync(job_id):
|
||||
raise KindClusterError("Операция отменена", exit_code=130)
|
||||
|
||||
_log("Удаление записи кластера в kind (каталог clusters/<имя>/ на диске не удаляется).")
|
||||
logger.info("kind delete с сохранением каталога данных для «%s»", name)
|
||||
|
||||
try:
|
||||
_run_checked_stream(
|
||||
["kind", "delete", "cluster", "--name", name],
|
||||
on_line=_log if job_id else None,
|
||||
job_id=job_id,
|
||||
)
|
||||
return True, "kind delete: OK"
|
||||
except KindClusterError as e:
|
||||
if job_id and _js.is_cancelled_sync(job_id):
|
||||
raise KindClusterError("Операция отменена", exit_code=130) from e
|
||||
# kind иногда возвращает ошибку, если кластер уже снят с учёта — проверяем фактическое состояние
|
||||
remaining = list_registered_kind_clusters()
|
||||
if name not in remaining:
|
||||
_log("Запись кластера в kind отсутствует — далее создание по сохранённому kind-config.yaml.")
|
||||
logger.info(
|
||||
"После сбоя kind delete кластер «%s» не в списке зарегистрированных — продолжаем",
|
||||
name,
|
||||
)
|
||||
return True, "Запись в kind уже отсутствовала"
|
||||
raise
|
||||
|
||||
|
||||
def _sort_kind_node_containers(names: list[str]) -> list[str]:
|
||||
"""Сначала control-plane, затем остальные — удобнее для ``docker start``."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user