diff --git a/app/api/v1/endpoints/clusters.py b/app/api/v1/endpoints/clusters.py index 890d5e2..c05f613 100644 --- a/app/api/v1/endpoints/clusters.py +++ b/app/api/v1/endpoints/clusters.py @@ -21,12 +21,20 @@ from fastapi import APIRouter, BackgroundTasks, HTTPException, Query from fastapi.responses import FileResponse, JSONResponse from starlette.background import BackgroundTask +from core.cluster_config_edit import ( + ClusterConfigEditError, + NOTE_NOT_IN_KIND, + NOTE_REGISTERED_IN_KIND, + apply_cluster_config_update, + read_cluster_config_bundle, +) from core.cluster_lifecycle import ( KindClusterError, cluster_summary_for_api, configured_container_cli, create_cluster_non_interactive, delete_kind_cluster_and_data, + delete_kind_cluster_keep_data, kubectl_delete_pod, kubectl_get_json, kubectl_get_all_namespaces_wide, @@ -60,6 +68,9 @@ from kind_k8s_paths import clusters_dir from kubeconfig_patch import kubeconfig_host_file, patch_kubeconfig_server_for_host from models.schemas import ( AggregateResourcesSummary, + ClusterConfigGetResponse, + ClusterConfigUpdateRequest, + ClusterConfigUpdateResponse, ClusterCreateAccepted, ClusterCreateRequest, ClusterOverviewResponse, @@ -74,6 +85,11 @@ from models.schemas import ( logger = logging.getLogger("kind_k8s.api.clusters") +# Сообщение UI: узлы kind остановлены — kubectl до API кластера не достучится (DNS control-plane и т.д.). +_K8S_CLUSTER_STOPPED_MSG = ( + "Кластер остановлен. Данные Kubernetes появятся после запуска узлов (кнопка «Старт» в панели)." +) + # Безопасные имена namespace/pod для kubectl (без shell-метасимволов). _K8S_NAME_SAFE = re.compile(r"^[a-zA-Z0-9][-a-zA-Z0-9.]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$") @@ -239,6 +255,100 @@ async def list_clusters() -> list[ClusterSummary]: return out +def _cluster_summary_model(name: str) -> ClusterSummary: + """Сводка кластера для ответов API (как в GET /clusters).""" + summary = cluster_summary_for_api(name) + running_map = running_kind_clusters_mask([name]) + return ClusterSummary( + name=str(summary["name"]), + registered_in_kind=bool(summary["registered_in_kind"]), + kind_nodes_running=bool(running_map.get(name, False)), + has_local_kubeconfig=bool(summary["has_local_kubeconfig"]), + has_provision_log=bool(summary.get("has_provision_log")), + meta=dict(summary["meta"]) if isinstance(summary.get("meta"), dict) else {}, + ) + + +@router.get( + "/clusters/{name}/config", + response_model=ClusterConfigGetResponse, + summary="Конфигурация кластера (meta + kind-config.yaml)", + responses={404: {"description": "Каталог кластера не найден"}}, +) +async def get_cluster_config(name: str) -> ClusterConfigGetResponse: + """ + Текущие ``meta.json`` и текст ``kind-config.yaml`` из ``clusters/<имя>/``. + Поле ``kind_note`` напоминает: у уже созданного в kind кластера смена YAML на диске не меняет узлы до пересоздания. + """ + n = name.strip() + if not validate_cluster_name(n): + raise HTTPException(status_code=400, detail="Некорректное имя кластера") + try: + bundle = await asyncio.to_thread(read_cluster_config_bundle, n) + except ClusterConfigEditError as e: + if e.not_found: + raise HTTPException(status_code=404, detail=str(e)) from e + raise HTTPException(status_code=400, detail=str(e)) from e + summary = await asyncio.to_thread(cluster_summary_for_api, n) + reg = bool(summary["registered_in_kind"]) + note = NOTE_REGISTERED_IN_KIND if reg else NOTE_NOT_IN_KIND + return ClusterConfigGetResponse( + cluster_name=str(bundle["cluster_name"]), + meta=dict(bundle["meta"]) if isinstance(bundle.get("meta"), dict) else {}, + kind_config_yaml=bundle.get("kind_config_yaml"), + has_kind_config=bool(bundle.get("has_kind_config")), + registered_in_kind=reg, + kind_note=note, + ) + + +@router.put( + "/clusters/{name}/config", + response_model=ClusterConfigUpdateResponse, + summary="Обновить конфигурацию кластера на диске", + responses={404: {"description": "Каталог кластера не найден"}}, +) +async def put_cluster_config(name: str, body: ClusterConfigUpdateRequest) -> ClusterConfigUpdateResponse: + """ + Сохраняет изменения в ``kind-config.yaml`` и/или ``meta.json``. + + Режимы: (1) непустой ``kind_config_yaml`` — полная замена файла с проверкой структуры kind Cluster; + (2) ``kubernetes_version`` и/или ``workers`` — пересборка простого конфига (как при создании); + (3) ``description`` — заметка в meta (пустая строка сбрасывает поле). + + Kind не применяет новый YAML к уже работающим нодам — см. ``kind_note`` в ответе. + """ + n = name.strip() + if not validate_cluster_name(n): + raise HTTPException(status_code=400, detail="Некорректное имя кластера") + try: + updated_yaml, msg = await asyncio.to_thread( + apply_cluster_config_update, + n, + kubernetes_version=body.kubernetes_version, + workers=body.workers, + kind_config_yaml=body.kind_config_yaml, + description=body.description, + ) + except ClusterConfigEditError as e: + if e.not_found: + raise HTTPException(status_code=404, detail=str(e)) from e + raise HTTPException(status_code=400, detail=str(e)) from e + summary_api = await asyncio.to_thread(cluster_summary_for_api, n) + reg = bool(summary_api["registered_in_kind"]) + note = NOTE_REGISTERED_IN_KIND if reg else NOTE_NOT_IN_KIND + summ = await asyncio.to_thread(_cluster_summary_model, n) + logger.info("Обновлена конфигурация кластера «%s», yaml=%s", n, updated_yaml) + return ClusterConfigUpdateResponse( + cluster_name=n, + updated_kind_config_yaml=updated_yaml, + message=msg, + registered_in_kind=reg, + kind_note=note, + summary=summ, + ) + + @router.get( "/clusters/{name}/kubeconfig", summary="Скачать kubeconfig", @@ -329,22 +439,28 @@ async def cluster_workloads(name: str) -> ClusterWorkloadsResponse: """``kubectl get nodes`` и ``kubectl get pods -A`` по сохранённому kubeconfig.""" if not validate_cluster_name(name): raise HTTPException(status_code=400, detail="Некорректное имя кластера") - kc = clusters_dir() / name / "kubeconfig" + n = name.strip() + kc = clusters_dir() / n / "kubeconfig" if not kc.is_file(): - return ClusterWorkloadsResponse(cluster_name=name, error="Нет сохранённого kubeconfig в clusters/<имя>/") + return ClusterWorkloadsResponse(cluster_name=n, error="Нет сохранённого kubeconfig в clusters/<имя>/") + + summary = cluster_summary_for_api(n) + run_mask = await asyncio.to_thread(running_kind_clusters_mask, [n]) + if bool(summary["registered_in_kind"]) and not bool(run_mask.get(n, False)): + return ClusterWorkloadsResponse(cluster_name=n, error=_K8S_CLUSTER_STOPPED_MSG) nodes_rc, nodes_out = await asyncio.to_thread( kubectl_nodes_wide, - cluster_name=name, + cluster_name=n, kubeconfig=kc, ) pods_rc, pods_out = await asyncio.to_thread( kubectl_pods_all_namespaces, - cluster_name=name, + cluster_name=n, kubeconfig=kc, ) return ClusterWorkloadsResponse( - cluster_name=name, + cluster_name=n, nodes_rc=nodes_rc, nodes_output=nodes_out, pods_rc=pods_rc, @@ -387,6 +503,8 @@ async def cluster_overview(name: str) -> ClusterOverviewResponse: k8s_cronjobs = K8sListJsonBlock(ok=False, items=[], message=None) k8s_pvcs = K8sListJsonBlock(ok=False, items=[], message=None) + reg_in_kind = bool(summary["registered_in_kind"]) + if not kc.is_file(): kube_err = "Нет сохранённого kubeconfig в clusters/<имя>/" _kube_missing = K8sListJsonBlock(ok=False, items=[], message=kube_err) @@ -394,6 +512,15 @@ async def cluster_overview(name: str) -> ClusterOverviewResponse: k8s_pods = k8s_deployments = k8s_statefulsets = k8s_daemonsets = _kube_missing k8s_services = k8s_ingresses = k8s_namespaces = k8s_replicasets = _kube_missing k8s_jobs = k8s_cronjobs = k8s_pvcs = _kube_missing + elif reg_in_kind and not kind_running: + # Не вызываем kubectl: контейнеры узлов остановлены — в логах был бы шум про dev-control-plane / DNS. + kube_err = _K8S_CLUSTER_STOPPED_MSG + _stopped = K8sListJsonBlock(ok=False, items=[], message=_K8S_CLUSTER_STOPPED_MSG) + k8s_nodes = _stopped + k8s_pods = k8s_deployments = k8s_statefulsets = k8s_daemonsets = _stopped + k8s_services = k8s_ingresses = k8s_namespaces = k8s_replicasets = _stopped + k8s_jobs = k8s_cronjobs = k8s_pvcs = _stopped + logger.debug("overview %s: kubectl пропущен — кластер в kind, узлы не запущены", n) else: ( n_res, @@ -443,7 +570,7 @@ async def cluster_overview(name: str) -> ClusterOverviewResponse: meta = summary.get("meta") return ClusterOverviewResponse( cluster_name=n, - registered_in_kind=bool(summary["registered_in_kind"]), + registered_in_kind=reg_in_kind, kind_nodes_running=kind_running, has_local_kubeconfig=bool(summary["has_local_kubeconfig"]), has_provision_log=bool(summary.get("has_provision_log")), @@ -653,6 +780,89 @@ async def _run_start_cluster_job(job_id: str, name: str, kubernetes_version_tag: end_job_tracking(job_id) +async def _run_reapply_kind_config_start_job( + job_id: str, name: str, kubernetes_version_tag: str, workers: int +) -> None: + """ + После правки kind-config.yaml: удалить кластер из kind без удаления каталога данных, + затем ``kind create`` по сохранённому файлу (новый ``meta.json`` без флага reapply). + """ + register_uncapped_job_log(job_id) + n = name.strip() + try: + async with kind_cluster_lock: + await job_store.set_running(job_id) + try: + await asyncio.to_thread(delete_kind_cluster_keep_data, name=n, job_id=job_id) + except KindClusterError as e: + msg = str(e) + if "отмен" in msg.lower(): + await job_store.set_cancelled(job_id, msg) + else: + await job_store.set_failed(job_id, msg) + logger.warning("reapply start job %s: delete_kind: %s", job_id, e) + return + except Exception as e: + await job_store.set_failed(job_id, f"{type(e).__name__}: {e}") + logger.exception("reapply start job %s: ошибка delete", job_id) + return + + try: + result = await asyncio.to_thread( + create_cluster_non_interactive, + name=n, + kubernetes_version_tag=kubernetes_version_tag.strip(), + workers=workers, + job_id=job_id, + use_existing_config=True, + ) + except KindClusterError as e: + msg = str(e) + if "отмен" in msg.lower(): + await job_store.set_cancelled(job_id, msg) + else: + await job_store.set_failed(job_id, msg) + logger.warning("reapply start job %s: create: %s", job_id, e) + return + except Exception as e: + await job_store.set_failed(job_id, f"{type(e).__name__}: {e}") + logger.exception("reapply start job %s: непредвиденная ошибка create", job_id) + return + + payload: dict[str, Any] = { + "cluster_name": result.cluster_name, + "kubernetes_version_tag": result.ver_tag, + "node_image": result.node_image, + "workers": result.workers, + "kubeconfig_path": str(result.kubeconfig_path), + "kubeconfig_patched_for_host": result.kubeconfig_patched_for_host, + "nodes_ready": result.nodes_ready, + "nodes_ready_message": result.nodes_ready_message, + } + await job_store.set_success( + job_id, + result=payload, + message="Кластер пересоздан в kind по обновлённому kind-config.yaml", + ) + logger.info("reapply start job %s: успех, кластер %s", job_id, result.cluster_name) + finally: + lines = take_uncapped_log_finalize(job_id) + rec = await job_store.get(job_id) + if rec is not None: + await asyncio.to_thread( + lambda: write_cluster_provision_log( + cluster_name=n, + job_id=job_id, + job_kind="start_cluster_reapply", + status=rec.status, + message=rec.message, + lines=lines, + result=rec.result, + ), + ) + end_job_tracking(job_id) + + async def _run_stop_cluster_job(job_id: str, name: str) -> None: """Фоновая остановка узлов с журналом в GET /jobs/{id}.""" n = name.strip() @@ -811,10 +1021,41 @@ async def start_cluster_nodes( n = name.strip() + meta = read_meta_json(n) or {} + ver_raw = str(meta.get("kubernetes_version_tag") or "v1.29.4").strip() or "v1.29.4" + w_raw = meta.get("worker_nodes") + try: + w = int(w_raw) if w_raw is not None else 0 + except (TypeError, ValueError): + w = 0 + async with kind_cluster_lock: in_kind = n in await asyncio.to_thread(list_registered_kind_clusters) if in_kind: + if bool(meta.get("apply_kind_config_on_next_start")): + cfg = clusters_dir() / n / "kind-config.yaml" + if not cfg.is_file(): + raise HTTPException( + status_code=400, + detail="Нет файла clusters/<имя>/kind-config.yaml — восстановите конфиг или снимите флаг пересоздания в meta.json.", + ) + rec = await job_store.create_job("start_cluster_reapply", cluster_name=n) + background_tasks.add_task(_run_reapply_kind_config_start_job, rec.job_id, n, ver_raw, w) + logger.info( + "Пересоздание кластера %s в kind по новому конфигу (фон), job_id=%s", + n, + rec.job_id, + ) + return JSONResponse( + status_code=202, + content={ + "job_id": rec.job_id, + "status": "queued", + "mode": "kind_config_reapply", + "message": "Удаление записи в kind и создание кластера по обновлённому kind-config.yaml; GET /api/v1/jobs/{job_id}", + }, + ) rec = await job_store.create_job("start_containers", cluster_name=n) background_tasks.add_task(_run_start_containers_job, rec.job_id, n) logger.info("Запуск контейнеров кластера %s в фоне, job_id=%s", n, rec.job_id) @@ -835,14 +1076,6 @@ async def start_cluster_nodes( detail="Кластер не в kind и нет файла clusters/<имя>/kind-config.yaml — создайте кластер или восстановите конфиг.", ) - meta = read_meta_json(n) or {} - ver_raw = str(meta.get("kubernetes_version_tag") or "v1.29.4").strip() or "v1.29.4" - w_raw = meta.get("worker_nodes") - try: - w = int(w_raw) if w_raw is not None else 0 - except (TypeError, ValueError): - w = 0 - rec = await job_store.create_job("start_cluster", cluster_name=n) background_tasks.add_task(_run_start_cluster_job, rec.job_id, n, ver_raw, w) logger.info("Фоновый старт кластера %s по конфигу, job_id=%s", n, rec.job_id) diff --git a/app/core/cluster_config_edit.py b/app/core/cluster_config_edit.py new file mode 100644 index 0000000..4765981 --- /dev/null +++ b/app/core/cluster_config_edit.py @@ -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(), + } diff --git a/app/core/cluster_lifecycle.py b/app/core/cluster_lifecycle.py index c335b89..e11005b 100644 --- a/app/core/cluster_lifecycle.py +++ b/app/core/cluster_lifecycle.py @@ -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``.""" diff --git a/app/docs/api_routes.md b/app/docs/api_routes.md index 5e4be17..b675211 100644 --- a/app/docs/api_routes.md +++ b/app/docs/api_routes.md @@ -20,12 +20,13 @@ | Маршрут | Описание | |---------|----------| | `GET /` | HTML-панель: единая карточка «панель + среда», статистика, **ссылка с имени кластера на** `GET /cluster/<имя>` (сводка ресурсов, kubectl, действия), **старт/стоп** кластера с тем же журналом (фоновые **POST …/start** и **…/stop**), модалка узлов/подов; шапка — пилюли, Swagger / ReDoc / Health в отдельных окнах. | -| `GET /cluster/{name}` | HTML **страница кластера**: донаты «Ресурсы узлов (сводка)», карточки узлов, **таблицы Kubernetes** из JSON (`kubectl get … -o json`), кнопка **Рестарт** у подов (**`POST …/pods/restart`**), те же кнопки действий, что в таблице на главной; данные — **`GET /api/v1/clusters/{name}/overview`** (автообновление с интервалом панели). | +| `GET /cluster/{name}` | HTML **страница кластера**: донаты «Ресурсы узлов (сводка)», карточки узлов, **таблицы Kubernetes** (данные API кластера в JSON), кнопка **Рестарт** у подов (**`POST …/pods/restart`**), те же кнопки действий, что в таблице на главной; данные — **`GET /api/v1/clusters/{name}/overview`** (автообновление с интервалом панели). | +| `GET /cluster/{name}/edit` | HTML **редактирование** сохранённого `kind-config.yaml` и полей `meta.json` (простой режим: тег/workers; расширенный: полный YAML kind Cluster). Сохранение — **`PUT /api/v1/clusters/{name}/config`**. | | `GET /documentation` | HTML-оболочка; **`documentation.js`**: без `path` — **`GET /api/v1/docs/readme`**, с `?path=app/docs/…` — **`GET /api/v1/docs/file`**; разбор Markdown из **`/static/js/vendor/`** (marked, DOMPurify). Каждая секция по **H2** — **одна карточка** (заголовок h2 и содержимое до следующего h2 вместе). Заголовок вкладки браузера: **«Документация — …»** + текст **первого H1** документа + имя приложения (`KIND_K8S_APP_TITLE` на `body`). В шапке на этой странице активна только **Документация**; **Панель** как обычная пилюля (на дашборде активна **Панель**). Путь к README: `KIND_K8S_README_PATH` или `README.md` рядом с `app/`; в образе — `/opt/kind-k8s/README.md`. | | `GET /ui` | Редирект **307** на `/` (удобный ярлык). | | `GET /static/…` | CSS (`style.css`), скрипты панели (`js/dashboard.js`) и документации (`js/documentation.js`); базовый URL API задаётся атрибутом `data-api-base` на `
` (по умолчанию `/api/v1`). | -Шаблоны: `app/templates/base.html` (шапка, навигация), `app/templates/dashboard.html` (контент панели), `app/templates/cluster_detail.html` (страница кластера), `app/templates/documentation.html` (README). +Шаблоны: `app/templates/base.html` (шапка, навигация), `app/templates/dashboard.html` (контент панели), `app/templates/cluster_detail.html` (страница кластера), `app/templates/cluster_edit.html` (редактирование конфигурации), `app/templates/documentation.html` (README). **kubectl на хосте не обязателен:** бинарник есть в образе; узлы и поды доступны через API и веб-UI. Внутри контейнера веб-приложения `kubectl` использует временный kubeconfig с `server` через **`host.docker.internal:<порт>`** (см. `kubeconfig_patch.py`, `extra_hosts` в compose). Скачивание для хоста — **`GET …/kubeconfig`** (файл **`kubeconfig.host`** при наличии). Для консоли: **`make docker kubectl CLUSTER=<имя>`** — **`/work/clusters/<имя>/kubeconfig`**; при сбое попробуйте kubectl **с хоста** с **`clusters/<имя>/kubeconfig.host`**. Перезапуск веб-сервиса: **`make docker restart`**. Подробности — **README.md**. @@ -42,11 +43,13 @@ | GET | `/api/v1/stats` | Сводка для дашборда | | GET | `/api/v1/clusters` | Список кластеров (поле `has_provision_log` — есть ли `clusters/<имя>/provision_log.json`) | | POST | `/api/v1/clusters` | Создание в фоне (**202** + `job_id`) | -| POST | `/api/v1/clusters/{name}/start` | Запуск в фоне (**202** + `job_id`, поле `mode`: `containers` или `kind_config`); журнал — `GET /jobs/{job_id}` | +| POST | `/api/v1/clusters/{name}/start` | Запуск в фоне (**202** + `job_id`, поле `mode`: `containers`, `kind_config` или `kind_config_reapply`); журнал — `GET /jobs/{job_id}` | | POST | `/api/v1/clusters/{name}/stop` | Остановка узлов в фоне (**202** + `job_id`, `mode`: `stop`); журнал — `GET /jobs/{job_id}` | | GET | `/api/v1/clusters/{name}` | Детали + `kubectl get nodes` при наличии kubeconfig | +| GET | `/api/v1/clusters/{name}/config` | Текущие `meta.json` и текст `kind-config.yaml`; подсказка `kind_note` про пересоздание | +| PUT | `/api/v1/clusters/{name}/config` | Сохранить YAML и/или meta (простой режим или полный YAML); ответ включает обновлённый `summary` | | GET | `/api/v1/clusters/{name}/kubeconfig` | Скачать файл kubeconfig | -| GET | `/api/v1/clusters/{name}/provision-log` | Полный журнал последнего **create_cluster** / **start_cluster** (JSON с диска) | +| GET | `/api/v1/clusters/{name}/provision-log` | Полный журнал последнего **create_cluster** / **start_cluster** / **start_cluster_reapply** (JSON с диска) | | GET | `/api/v1/clusters/{name}/workloads` | Узлы и поды (`kubectl`) | | GET | `/api/v1/clusters/{name}/overview` | Сводка для страницы UI: метрики узлов, агрегаты, блоки **`k8s_*`** (JSON из `kubectl get … -o json`) | | POST | `/api/v1/clusters/{name}/pods/restart` | Удалить под (`kubectl delete pod`) для мягкого рестарта | @@ -64,9 +67,9 @@ - Создание кластера: `POST /api/v1/clusters` → опрос `GET /api/v1/jobs/{job_id}` (как в веб-UI). - В ответе задания поля **`progress_stage`** (текст этапа) и **`progress_percent`** (0–100) обновляются во время создания. - В **GET /api/v1/jobs** (список) поле **`progress_log`** всегда **пустой массив** — меньше трафика; полный хвост — в **GET /api/v1/jobs/{job_id}** (лимит строк: `KIND_K8S_JOB_API_LOG_MAX_LINES`, по умолчанию **5000**). -- Буфер строк в памяти на задание: `KIND_K8S_JOB_LOG_MAX_LINES` (по умолчанию **2500**); при переполнении старые строки вытесняются. Для **create_cluster** и **start_cluster** полный журнал без обрезки дополнительно сохраняется в **`clusters/<имя>/provision_log.json`** (перезапись при каждом завершении такого задания); чтение — **GET /api/v1/clusters/{name}/provision-log**. +- Буфер строк в памяти на задание: `KIND_K8S_JOB_LOG_MAX_LINES` (по умолчанию **2500**); при переполнении старые строки вытесняются. Для **create_cluster**, **start_cluster** и **start_cluster_reapply** полный журнал без обрезки дополнительно сохраняется в **`clusters/<имя>/provision_log.json`** (перезапись при каждом завершении такого задания); чтение — **GET /api/v1/clusters/{name}/provision-log**. - Для **`docker pull`**: если в справке **`docker pull --help`** объявлен флаг **`--progress`**, при **`KIND_K8S_DOCKER_PULL_PLAIN=1`** вызывается **`--progress=plain`** без PTY; на старых CLI флаг не передаётся (нет строки «unknown flag» в журнале). Для **podman** и **kind** — псевдо-TTY по `KIND_K8S_STREAM_PTY`, из строк убираются ANSI-коды. -- Тип задания **`kind`**: `create_cluster`, `start_cluster` (подъём по сохранённому конфигу), `start_containers` (запуск уже созданных узлов), `stop_containers` (остановка узлов). +- Тип задания **`kind`**: `create_cluster`, `start_cluster` (подъём по сохранённому конфигу), `start_cluster_reapply` (после правки `kind-config.yaml`: `kind delete` без удаления каталога + `kind create`), `start_containers` (запуск уже созданных узлов), `stop_containers` (остановка узлов). - Статус **`cancelled`** — запрошена отмена (`POST .../cancel`); дочерний процесс текущей команды получает принудительное завершение. --- @@ -311,7 +314,7 @@ Accept: text/markdown ## POST /api/v1/jobs/{job_id}/cancel -Прервать фоновое задание (`create_cluster`, `start_cluster`, `start_containers`, `stop_containers`). Для длительной команды завершается связанный дочерний процесс; между шагами запуска/остановки отдельных узлов также проверяется флаг отмены. +Прервать фоновое задание (`create_cluster`, `start_cluster`, `start_cluster_reapply`, `start_containers`, `stop_containers`). Для длительной команды завершается связанный дочерний процесс; между шагами запуска/остановки отдельных узлов также проверяется флаг отмены. **Пример ответа 200:** @@ -328,6 +331,102 @@ Accept: text/markdown --- +## GET /api/v1/clusters/{name}/config + +Чтение **`clusters/<имя>/meta.json`** и текста **`kind-config.yaml`**. Поле **`kind_note`** напоминает: kind **не** меняет топологию и образ уже созданного кластера — новый YAML на диске применится при следующем **`kind create`** (после `kind delete` и «Старт» / пересоздания). + +**Ошибка 404:** каталога кластера нет. +**Ошибка 400:** некорректное имя. + +**Пример ответа 200 (JSON):** + +```json +{ + "cluster_name": "dev", + "meta": { + "cluster_name": "dev", + "kubernetes_version_tag": "v1.29.4", + "worker_nodes": 2, + "node_image": "kindest/node:v1.29.4", + "description": "Тестовый стенд" + }, + "kind_config_yaml": "kind: Cluster\napiVersion: kind.x-k8s.io/v1alpha4\nnodes:\n- role: control-plane\n image: kindest/node:v1.29.4\n- role: worker\n image: kindest/node:v1.29.4\n", + "has_kind_config": true, + "registered_in_kind": true, + "kind_note": "Файлы сохранены на диске. Кластер уже зарегистрирован в kind: действующие узлы и образ не меняются до удаления кластера из kind и повторного создания по обновлённому kind-config.yaml (или «Старт», если запись в kind удалена, а каталог данных остался)." +} +``` + +--- + +## PUT /api/v1/clusters/{name}/config + +Сохранение на диск. Нужно **хотя бы одно** поле тела: + +| Поле | Назначение | +|------|------------| +| `kubernetes_version` | Тег `kindest/node` для **пересборки** простого YAML (control-plane + N worker) | +| `workers` | Число worker-нод 0–20 (вместе с версией из запроса или из meta) | +| `kind_config_yaml` | **Полная замена** `kind-config.yaml` (проверка: `kind: Cluster`, `apiVersion` с `kind.x-k8s.io`, `nodes`, есть `control-plane`) | +| `description` | Строка в meta (пустая строка **сбрасывает** поле) | + +Если передан непустой **`kind_config_yaml`**, поля **`kubernetes_version`** и **`workers`** для генерации YAML **не используются** (можно не отправлять). + +**Пример тела (только описание):** + +```json +{ + "description": "Обновлённая заметка" +} +``` + +**Пример тела (простой режим):** + +```json +{ + "kubernetes_version": "1.30.0", + "workers": 3, + "description": "Три воркера" +} +``` + +**Пример тела (расширенный YAML):** + +```json +{ + "kind_config_yaml": "kind: Cluster\napiVersion: kind.x-k8s.io/v1alpha4\nnodes:\n- role: control-plane\n image: kindest/node:v1.29.4\n", + "description": "" +} +``` + +**Пример ответа 200 (JSON):** + +```json +{ + "cluster_name": "dev", + "updated_kind_config_yaml": true, + "message": "Сохранено.", + "registered_in_kind": true, + "kind_note": "Файлы сохранены на диске. Кластер уже зарегистрирован в kind: действующие узлы и образ не меняются до удаления кластера из kind и повторного создания по обновлённому kind-config.yaml (или «Старт», если запись в kind удалена, а каталог данных остался).", + "summary": { + "name": "dev", + "registered_in_kind": true, + "kind_nodes_running": true, + "has_local_kubeconfig": true, + "has_provision_log": true, + "meta": { + "kubernetes_version_tag": "v1.30.0", + "worker_nodes": 3 + } + } +} +``` + +**Ошибка 404:** каталога кластера нет. +**Ошибка 400:** некорректное имя, пустое тело, ошибка разбора или структуры YAML. + +--- + ## GET /api/v1/clusters/{name}/kubeconfig Скачать kubeconfig для **kubectl на машине пользователя** (ответ — тело файла, `Content-Disposition`: `kubeconfig-{name}.yaml`). @@ -342,7 +441,7 @@ Accept: text/markdown ## GET /api/v1/clusters/{name}/provision-log -Содержимое **`clusters/<имя>/provision_log.json`**: полный журнал строк последнего завершённого задания **create_cluster** или **start_cluster** (включая успех, ошибку и отмену), плюс метаданные (`job_id`, `kind`, `status`, `message`, `result`, `finished_at_utc`). +Содержимое **`clusters/<имя>/provision_log.json`**: полный журнал строк последнего завершённого задания **create_cluster**, **start_cluster** или **start_cluster_reapply** (включая успех, ошибку и отмену), плюс метаданные (`job_id`, `kind`, `status`, `message`, `result`, `finished_at_utc`). **Ошибка 404:** файла нет (кластер ещё не создавали/не стартовали с сохранением журнала, или каталог без записи). @@ -392,6 +491,8 @@ Accept: text/markdown Если kubeconfig нет: `error` — строка вида **`Нет сохранённого kubeconfig в clusters/<имя>/`**, поля вывода kubectl могут быть `null`. +Если кластер **есть в kind**, но **узлы остановлены** (`docker stop` / «Стоп» в UI): **`error`** — краткое сообщение без вызова kubectl (вместо сырого stderr про DNS `*-control-plane`). + **Ошибка 400:** некорректное имя кластера. --- @@ -401,6 +502,7 @@ Accept: text/markdown Единый ответ для **страницы кластера** (`GET /cluster/{name}`): флаги и `meta` как в списке кластеров, **метрики узлов** (docker/podman), **`aggregate_resources`** (как в `GET /stats` для донатов), данные Kubernetes в виде **`kubectl get … -o json`** (разбор `items` на фронтенде в таблицы). - При отсутствии kubeconfig: **`kubeconfig_error`** — строка-пояснение; блоки **`k8s_*`** недоступны (см. ниже). +- Если кластер **зарегистрирован в kind**, но **узлы не запущены** (`kind_nodes_running: false`): **`kubectl` не вызывается**; в **`kubeconfig_error`** и в **`message`** каждого блока **`k8s_*`** — одно и то же дружелюбное пояснение (без stderr про недоступный API). - Поля **`nodes_rc` / `nodes_output`**, **`pods_rc` / `pods_output`**, … — устаревший текстовый вывод; в текущей версии API могут быть **`null`** (UI использует только JSON-блоки). - Блоки **`k8s_nodes`**, **`k8s_namespaces`**, **`k8s_pods`**, **`k8s_deployments`**, **`k8s_statefulsets`**, **`k8s_daemonsets`**, **`k8s_replicasets`**, **`k8s_jobs`**, **`k8s_cronjobs`**, **`k8s_services`**, **`k8s_ingresses`** (ресурс **`ingresses.networking.k8s.io -A`**), **`k8s_pvcs`** — объекты вида **`K8sListJsonBlock`**; для ресурсов в namespace везде **`kubectl get … -A`** (все пространства имён). - **`ok`**: `true` при успешном `kubectl`; @@ -563,10 +665,13 @@ Accept: text/markdown ## POST /api/v1/clusters/{name}/start -Запуск кластера двумя сценариями (оба с **202** и `job_id`, журнал в `GET /jobs/{job_id}`): +Запуск кластера (все варианты — **202** и `job_id`, журнал в `GET /jobs/{job_id}`): -1. Кластер **зарегистрирован** в kind — задание **`start_containers`** (поочерёдный запуск узлов, `mode`: **`containers`**). -2. В kind кластера **нет**, но есть сохранённый **`clusters/<имя>/kind-config.yaml`** — задание **`start_cluster`** (`mode`: **`kind_config`**), логика как при создании (в т.ч. скачивание образа при необходимости). +1. Кластер **зарегистрирован** в kind и в **`meta.json`** **нет** флага **`apply_kind_config_on_next_start`** — задание **`start_containers`** (поочерёдный `docker start` узлов, `mode`: **`containers`**). +2. Кластер **зарегистрирован** в kind и после **PUT …/config** с изменением **`kind-config.yaml`** в **`meta.json`** выставлен **`apply_kind_config_on_next_start`: true** — задание **`start_cluster_reapply`** (`mode`: **`kind_config_reapply`**): `kind delete cluster` **без** удаления `clusters/<имя>/`, затем **`kind create`** по сохранённому YAML (как при создании; флаг в новом `meta.json` не сохраняется). +3. В kind кластера **нет**, но есть **`clusters/<имя>/kind-config.yaml`** — задание **`start_cluster`** (`mode`: **`kind_config`**). + +Остановка (**POST …/stop**) флаг пересоздания **не сбрасывает**; достаточно после правки конфига нажать **«Старт»** (после стопа или сразу — выполнится сценарий 2, если кластер всё ещё в kind). **Пример ответа 202 (запуск узлов):** @@ -590,7 +695,18 @@ Accept: text/markdown } ``` -**Ошибка 400:** некорректное имя или нет ни кластера в kind, ни `kind-config.yaml` в `clusters/<имя>/`. +**Пример ответа 202 (пересоздание после правки YAML):** + +```json +{ + "job_id": "c0ffee...", + "status": "queued", + "mode": "kind_config_reapply", + "message": "Удаление записи в kind и создание кластера по обновлённому kind-config.yaml; GET /api/v1/jobs/{job_id}" +} +``` + +**Ошибка 400:** некорректное имя; или нет ни кластера в kind, ни `kind-config.yaml`; или для **`kind_config_reapply`** отсутствует `kind-config.yaml`. --- diff --git a/app/main.py b/app/main.py index ce6fe17..2d5edea 100644 --- a/app/main.py +++ b/app/main.py @@ -98,6 +98,28 @@ async def cluster_detail_page(request: Request, cluster_name: str) -> HTMLRespon ) +@app.get("/cluster/{cluster_name}/edit", response_class=HTMLResponse, summary="Редактирование конфигурации кластера") +async def cluster_edit_page(request: Request, cluster_name: str) -> HTMLResponse: + """Форма: версия/workers, полный kind-config.yaml, описание в meta; сохранение через PUT /api/v1/clusters/{name}/config.""" + if not _templates_dir.is_dir(): + return HTMLResponse( + content="Шаблоны не найдены. Ожидается каталог app/templates/
", + status_code=500, + ) + n = cluster_name.strip() + if not validate_cluster_name(n): + raise HTTPException(status_code=404, detail="Некорректное имя кластера") + return templates.TemplateResponse( + request, + "cluster_edit.html", + { + "app_title": settings.app_title, + "nav_active": "panel", + "cluster_name": n, + }, + ) + + @app.get("/cluster-create", response_class=HTMLResponse, summary="Создание кластера и задания") async def cluster_create_page(request: Request) -> HTMLResponse: """Форма создания кластера, прогресс и список последних заданий.""" diff --git a/app/models/schemas.py b/app/models/schemas.py index cf45978..c0078af 100644 --- a/app/models/schemas.py +++ b/app/models/schemas.py @@ -8,7 +8,7 @@ from __future__ import annotations from typing import Any, Literal -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator class ClusterCreateRequest(BaseModel): @@ -143,12 +143,12 @@ class ClusterWorkloadsResponse(BaseModel): class K8sListJsonBlock(BaseModel): - """Фрагмент ответа ``kubectl get … -o json`` (items) для таблицы в UI.""" + """Фрагмент списка ресурсов Kubernetes (поле ``items`` из JSON List) для таблицы в UI.""" ok: bool = True - rc: int | None = Field(default=None, description="Код выхода kubectl") + rc: int | None = Field(default=None, description="Код возврата при обращении к API кластера") items: list[dict[str, Any]] = Field(default_factory=list) - message: str | None = Field(default=None, description="Ошибка kubectl или разбора JSON") + message: str | None = Field(default=None, description="Текст ошибки или разбора JSON") class PodRestartRequest(BaseModel): @@ -169,7 +169,7 @@ class PodRestartResponse(BaseModel): class ClusterOverviewResponse(BaseModel): - """Сводка для страницы кластера: мета, ресурсы узлов, kubectl (поды, workloads).""" + """Сводка для страницы кластера: мета, ресурсы узлов, таблицы ресурсов Kubernetes.""" cluster_name: str registered_in_kind: bool = False @@ -182,7 +182,7 @@ class ClusterOverviewResponse(BaseModel): default_factory=lambda: KindClusterResources(cluster_name="", nodes=[], note=None), ) aggregate_resources: AggregateResourcesSummary = Field(default_factory=AggregateResourcesSummary) - kubeconfig_error: str | None = Field(default=None, description="Нет kubeconfig или ошибка kubectl") + kubeconfig_error: str | None = Field(default=None, description="Нет kubeconfig или недоступен API кластера") nodes_rc: int | None = None nodes_output: str | None = None pods_rc: int | None = None @@ -197,8 +197,8 @@ class ClusterOverviewResponse(BaseModel): services_output: str | None = None ingresses_rc: int | None = None ingresses_output: str | None = None - k8s_nodes: K8sListJsonBlock = Field(default_factory=K8sListJsonBlock, description="kubectl get nodes -o json") - k8s_pods: K8sListJsonBlock = Field(default_factory=K8sListJsonBlock, description="kubectl get pods -A -o json") + k8s_nodes: K8sListJsonBlock = Field(default_factory=K8sListJsonBlock, description="Узлы кластера") + k8s_pods: K8sListJsonBlock = Field(default_factory=K8sListJsonBlock, description="Поды, все namespace") k8s_deployments: K8sListJsonBlock = Field(default_factory=K8sListJsonBlock) k8s_statefulsets: K8sListJsonBlock = Field(default_factory=K8sListJsonBlock) k8s_daemonsets: K8sListJsonBlock = Field(default_factory=K8sListJsonBlock) @@ -206,21 +206,80 @@ class ClusterOverviewResponse(BaseModel): k8s_ingresses: K8sListJsonBlock = Field(default_factory=K8sListJsonBlock) k8s_namespaces: K8sListJsonBlock = Field( default_factory=K8sListJsonBlock, - description="kubectl get namespaces -o json", + description="Namespaces", ) k8s_replicasets: K8sListJsonBlock = Field( default_factory=K8sListJsonBlock, - description="kubectl get replicasets -A -o json", + description="ReplicaSets, все namespace", ) k8s_jobs: K8sListJsonBlock = Field( default_factory=K8sListJsonBlock, - description="kubectl get jobs -A -o json", + description="Jobs, все namespace", ) k8s_cronjobs: K8sListJsonBlock = Field( default_factory=K8sListJsonBlock, - description="kubectl get cronjobs -A -o json", + description="CronJobs, все namespace", ) k8s_pvcs: K8sListJsonBlock = Field( default_factory=K8sListJsonBlock, - description="kubectl get pvc -A -o json", + description="PVC, все namespace", ) + + +class ClusterConfigGetResponse(BaseModel): + """Ответ GET /clusters/{name}/config — текущие meta и kind-config.yaml.""" + + cluster_name: str + meta: dict[str, Any] = Field(default_factory=dict) + kind_config_yaml: str | None = Field(default=None, description="Содержимое kind-config.yaml или null, если файла нет") + has_kind_config: bool = False + registered_in_kind: bool = Field( + default=False, + description="Есть ли кластер в списке kind (важно для подсказки про применение конфига)", + ) + kind_note: str = Field( + default="", + description="Пояснение: kind не меняет топологию уже созданного кластера без пересоздания", + ) + + +class ClusterConfigUpdateRequest(BaseModel): + """Тело PUT /clusters/{name}/config. + + Если передан непустой ``kind_config_yaml``, он перезаписывает файл целиком; поля ``kubernetes_version`` + и ``workers`` в этом запросе для генерации YAML не используются (можно не отправлять). + """ + + kubernetes_version: str | None = Field( + default=None, + description="Тег kindest/node для простого режима (пересборка YAML: control-plane + N worker)", + ) + workers: int | None = Field(default=None, ge=0, le=20) + kind_config_yaml: str | None = Field(default=None, description="Полный YAML манифеста kind Cluster") + description: str | None = Field( + default=None, + max_length=2000, + description="Текст в meta.json (пустая строка сбрасывает поле)", + ) + + @model_validator(mode="after") + def at_least_one_field(self) -> ClusterConfigUpdateRequest: + has_ver = self.kubernetes_version is not None + has_w = self.workers is not None + yaml_s = (self.kind_config_yaml or "").strip() + has_yaml = self.kind_config_yaml is not None and yaml_s != "" + has_desc = self.description is not None + if not (has_ver or has_w or has_yaml or has_desc): + raise ValueError("Укажите хотя бы одно поле для обновления.") + return self + + +class ClusterConfigUpdateResponse(BaseModel): + """Результат сохранения конфигурации кластера на диск.""" + + cluster_name: str + updated_kind_config_yaml: bool = Field(description="Был ли перезаписан kind-config.yaml") + message: str + registered_in_kind: bool + kind_note: str + summary: ClusterSummary = Field(description="Актуальная сводка для строки таблицы / панели") diff --git a/app/static/js/cluster_edit.js b/app/static/js/cluster_edit.js new file mode 100644 index 0000000..aa1a90b --- /dev/null +++ b/app/static/js/cluster_edit.js @@ -0,0 +1,811 @@ +/** + * Страница редактирования кластера: GET/PUT /api/v1/clusters/{name}/config. + * Расширенный режим — форма по документации kind (networking, nodes, extraPortMappings, kubeadmConfigPatches). + * + * Автор: Сергей Антропов + * Сайт: https://devops.org.ru + */ +(function () { + "use strict"; + + const body = document.body; + const API = (body.dataset.apiBase || "/api/v1").replace(/\/$/, ""); + const clusterName = (body.getAttribute("data-cluster-name") || "").trim(); + if (!clusterName) return; + + /** @type {string} */ + let initialTag = ""; + /** @type {number | null} */ + let initialWorkers = null; + /** @type {string} */ + let initialDescription = ""; + /** @type {string | null} */ + let loadedYamlSnapshot = null; + /** @type {string} */ + let initialAdvancedSnapshot = ""; + + /** + * @typedef {object} AdvancedFormState + * @property {string} apiVersion + * @property {string} image + * @property {number} controlPlanes + * @property {number} workers + * @property {string} ipFamily + * @property {string} apiServerAddress + * @property {string} apiServerPort + * @property {string} podSubnet + * @property {string} serviceSubnet + * @property {string} kubeProxyMode + * @property {boolean} disableDefaultCNI + * @property {boolean} portEnabled + * @property {string} hostPort + * @property {number} containerPort + * @property {string} portListenAddress + * @property {string} portProtocol + * @property {string} kubeadmPatch + */ + + function formatApiError(data, fallback) { + if (!data) return fallback; + if (typeof data.detail === "string") return data.detail; + if (Array.isArray(data.detail)) { + return data.detail + .map(function (x) { + return (x.msg || x) + (x.loc ? " (" + x.loc.join(".") + ")" : ""); + }) + .join("; "); + } + return fallback; + } + + /** + * @param {string} path + * @param {RequestInit} [opts] + */ + async function api(path, opts) { + const url = path.startsWith("http") ? path : API + path; + const r = await fetch(url, opts); + const text = await r.text(); + var data; + try { + data = text ? JSON.parse(text) : null; + } catch (e) { + data = { raw: text }; + } + if (!r.ok) { + const msg = formatApiError(data, text || r.statusText); + const err = new Error(msg); + err.status = r.status; + throw err; + } + return data; + } + + /** + * Собрать содержимое модалки: как на диске сохранилось и как это доходит до kind. + * @param {boolean} updatedYaml + * @param {boolean} registeredInKind + * @returns {DocumentFragment} + */ + function buildSaveInfoModalContent(updatedYaml, registeredInKind) { + const frag = document.createDocumentFragment(); + if (!updatedYaml) { + const p = document.createElement("p"); + p.textContent = + "Сохранены поля без изменения kind-config.yaml (например, только описание в meta.json). " + + "Топология кластера в kind и уже запущенные узлы от этого не меняются."; + frag.appendChild(p); + return frag; + } + const lead = document.createElement("p"); + lead.className = "cluster-edit-save-modal-lead"; + lead.textContent = "Файл kind-config.yaml и связанные поля meta.json записаны на диск."; + frag.appendChild(lead); + + if (registeredInKind) { + const pIntro = document.createElement("p"); + pIntro.textContent = "Кластер уже зарегистрирован в kind. Новый конфиг применяется так:"; + frag.appendChild(pIntro); + const ul = document.createElement("ul"); + const lines = [ + "До нажатия «Старт» продолжают работать текущие узлы (старая топология и образы).", + "При «Старт» на панели или на странице кластера выполняется пересоздание: удаление записи в kind без удаления каталога clusters/" + + clusterName + + "/, затем создание кластера по обновлённому YAML.", + "«Стоп» перед «Старт» не обязателен — можно нажать «Старт» сразу после сохранения.", + ]; + lines.forEach(function (line) { + const li = document.createElement("li"); + li.textContent = line; + ul.appendChild(li); + }); + frag.appendChild(ul); + const note = document.createElement("p"); + note.style.marginTop = "0.65rem"; + note.style.fontSize = "0.88rem"; + note.textContent = + "Состояние Kubernetes внутри кластера при пересоздании начинается заново; kubeconfig в каталоге кластера обновится после успешного «Старт»."; + frag.appendChild(note); + } else { + const p2 = document.createElement("p"); + p2.textContent = + "Кластер сейчас не в списке kind. При «Старт» он будет поднят по сохранённому kind-config.yaml (полный цикл создания, как при восстановлении из файла)."; + frag.appendChild(p2); + } + return frag; + } + + function openClusterEditSaveModal(updatedYaml, registeredInKind) { + const overlay = document.getElementById("cluster-edit-save-modal-overlay"); + const titleEl = document.getElementById("cluster-edit-save-modal-title"); + const bodyEl = document.getElementById("cluster-edit-save-modal-body"); + if (!overlay || !titleEl || !bodyEl) return; + + titleEl.textContent = updatedYaml ? "Конфигурация сохранена" : "Сохранено"; + bodyEl.replaceChildren(); + bodyEl.appendChild(buildSaveInfoModalContent(updatedYaml, registeredInKind)); + + overlay.classList.remove("hidden"); + overlay.setAttribute("aria-hidden", "false"); + document.body.classList.add("modal-open"); + + const okBtn = document.getElementById("cluster-edit-save-modal-ok"); + if (okBtn) okBtn.focus(); + } + + function closeClusterEditSaveModal() { + const overlay = document.getElementById("cluster-edit-save-modal-overlay"); + if (!overlay) return; + overlay.classList.add("hidden"); + overlay.setAttribute("aria-hidden", "true"); + document.body.classList.remove("modal-open"); + } + + /** Закрытие модалки после сохранения и переход на страницу кластера (только кнопка «Понятно»). */ + function onSaveInfoModalOk() { + closeClusterEditSaveModal(); + window.location.href = "/cluster/" + encodeURIComponent(clusterName); + } + + function wireSaveInfoModal() { + const overlay = document.getElementById("cluster-edit-save-modal-overlay"); + const ok = document.getElementById("cluster-edit-save-modal-ok"); + if (!overlay || !ok) return; + ok.addEventListener("click", onSaveInfoModalOk); + overlay.addEventListener("click", function (ev) { + if (ev.target === overlay) closeClusterEditSaveModal(); + }); + document.addEventListener("keydown", function (ev) { + if (ev.key === "Escape" && !overlay.classList.contains("hidden")) { + closeClusterEditSaveModal(); + } + }); + } + + function wireVersionSelect(selId, inputId) { + const sel = document.getElementById(selId); + const verInput = document.getElementById(inputId); + if (!sel || !verInput) return; + sel.onchange = function () { + if (!sel.value) return; + if (inputId === "fld-adv-image") { + verInput.value = + sel.value === "latest" ? "kindest/node:latest" : "kindest/node:" + sel.value.replace(/^v/, ""); + } else { + verInput.value = sel.value === "latest" ? "latest" : sel.value.replace(/^v/, ""); + } + }; + } + + async function loadVersions() { + const ids = [ + ["version-select-edit", "kubernetes_version_edit"], + ["version-select-adv", "fld-adv-image"], + ]; + ids.forEach(function (pair) { + const sel = document.getElementById(pair[0]); + if (sel) sel.innerHTML = ""; + }); + try { + const data = await api("/versions"); + ids.forEach(function (pair) { + const sel = document.getElementById(pair[0]); + if (!sel) return; + sel.innerHTML = ""; + if (!data.tags || !data.tags.length) { + sel.innerHTML = ""; + return; + } + const opt0 = document.createElement("option"); + opt0.value = ""; + opt0.textContent = "— выберите тег —"; + sel.appendChild(opt0); + data.tags.forEach(function (t) { + const o = document.createElement("option"); + o.value = t; + o.textContent = t === "latest" ? "latest (kindest/node)" : t; + sel.appendChild(o); + }); + }); + wireVersionSelect("version-select-edit", "kubernetes_version_edit"); + wireVersionSelect("version-select-adv", "fld-adv-image"); + } catch (e) { + ids.forEach(function (pair) { + const sel = document.getElementById(pair[0]); + if (sel) sel.innerHTML = ""; + }); + } + } + + /** + * Разбор секции networking: (документация kind — cluster-wide networking). + * @param {string} text + * @returns {Pick" +
nameEsc +
"
- Данные по всему кластеру: для ресурсов в namespace используется
- kubectl get … -A (все namespace); узлы и список namespace — без фильтра по ns.
- Ниже каждый тип ресурса — отдельная карточка с тем же запросом, что выполняет API.
+ Данные по всему кластеру: ресурсы в namespace — со всех пространств имён; узлы и список
+ namespace — без ограничения по отдельному ns.
kubectl get nodes -o json
kubectl get namespaces -o json
kubectl get pods -A -o json
kubectl get deployments -A -o json
kubectl get statefulsets -A -o json
kubectl get daemonsets -A -o json
kubectl get replicasets -A -o json
kubectl get jobs -A -o json
kubectl get cronjobs -A -o json
kubectl get services -A -o json
kubectl get ingresses.networking.k8s.io -A -o json
kubectl get pvc -A -o json
{{ cluster_name | e }}+ Загрузка подсказки… +
+
+ Изменения пишутся в clusters/{{ cluster_name | e }}/kind-config.yaml и meta.json.
+ После сохранения конфигурации YAML показывается окно с тем, как изменения попадут в kind; кратко: при «Старт» зарегистрированный кластер пересоздаётся по новому файлу (данные в clusters/{{ cluster_name | e }}/ сохраняются).
+
| Имя | -kind | -kubeconfig | -Версия | -Workers | -Состояние | +Имя | +kind | +kubeconfig | +Версия | +Workers | +Состояние | Действия |
|---|