Панель: редактирование конфига кластера, 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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user