Панель: редактирование конфига кластера, 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:
Sergey Antropoff
2026-04-04 10:49:40 +03:00
parent af0d1705cc
commit 52538d9816
14 changed files with 2571 additions and 137 deletions
+247 -14
View File
@@ -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)