Files
KindClustersDashboard/app/api/v1/endpoints/clusters.py
T
Sergey Antropoff c1e867a01f Веб-UI: логи kind create, старт/стоп кластеров, документация README
- Потоковые логи в job_store и UI; kind create через Popen с построчным выводом
- POST /clusters/{name}/start|stop; create по сохранённому kind-config.yaml
- Страница /documentation: GET /api/v1/docs/readme, marked+DOMPurify из static/vendor
- Иконки действий, плавающие подсказки, модалка подтверждения вместо confirm
- Makefile: make docker|podman rebuild; compose: монтирование README.md
- Dockerfile: COPY README.md; readme_doc: несколько путей к README

Автор: Сергей Антропов — https://devops.org.ru
2026-04-04 06:21:00 +03:00

474 lines
19 KiB
Python

"""CRUD-операции над кластерами kind и сводная статистика.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
from __future__ import annotations
import asyncio
import logging
from typing import Any
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query
from fastapi.responses import FileResponse, JSONResponse
from core.cluster_lifecycle import (
KindClusterError,
cluster_summary_for_api,
create_cluster_non_interactive,
delete_kind_cluster_and_data,
kubectl_nodes_wide,
kubectl_pods_all_namespaces,
list_registered_kind_clusters,
read_meta_json,
start_kind_cluster_containers,
stop_kind_cluster_containers,
validate_cluster_name,
)
from core.job_store import (
JobRecord,
end_job_tracking,
get_logs_snapshot_sync,
get_progress_sync,
job_store,
request_cancel_sync,
)
from core.kind_guard import kind_cluster_lock
from kind_k8s_paths import clusters_dir
from models.schemas import (
ClusterCreateAccepted,
ClusterCreateRequest,
ClusterSummary,
ClusterWorkloadsResponse,
JobView,
StatsResponse,
)
logger = logging.getLogger("kind_k8s.api.clusters")
router = APIRouter(tags=["clusters"])
def _record_to_job_view(rec: JobRecord) -> JobView:
"""JobRecord → JobView с полями прогресса из потокобезопасного снимка."""
prog = get_progress_sync(rec.job_id)
stage, pct = (None, None)
if prog is not None:
stage, pct = prog[0], prog[1]
if rec.status in ("queued", "running"):
log_tail = get_logs_snapshot_sync(rec.job_id)
else:
log_tail = list(rec.log_lines or [])
max_log = 400
if len(log_tail) > max_log:
log_tail = log_tail[-max_log:]
return JobView(
job_id=rec.job_id,
kind=rec.kind,
status=rec.status,
cluster_name=rec.cluster_name,
created_at_utc=rec.created_at_utc,
message=rec.message,
result=rec.result,
progress_stage=stage,
progress_percent=pct,
progress_log=log_tail,
)
def _stats_sync() -> StatsResponse:
"""Собрать статистику (синхронно; вызывать из thread при необходимости)."""
kind_names = list_registered_kind_clusters()
cdir = clusters_dir()
subdirs: list[str] = []
if cdir.is_dir():
subdirs = sorted(p.name for p in cdir.iterdir() if p.is_dir() and not p.name.startswith("."))
total_workers = 0
counted = False
for name in subdirs:
meta = read_meta_json(name)
if not meta:
continue
w = meta.get("worker_nodes")
if w is None:
continue
try:
total_workers += int(w)
counted = True
except (TypeError, ValueError):
continue
jobs = job_store.snapshot_all()
failed = sum(1 for j in jobs if j.status == "failed")
return StatsResponse(
kind_clusters_count=len(kind_names),
local_cluster_dirs_count=len(subdirs),
total_workers_from_meta=total_workers if counted else None,
jobs_total=len(jobs),
jobs_recent_failed=failed,
)
@router.get("/stats", response_model=StatsResponse, summary="Статистика")
async def get_stats() -> StatsResponse:
"""Число кластеров kind, локальных каталогов, сумма workers из meta (если есть), счётчики заданий."""
return await asyncio.to_thread(_stats_sync)
@router.get("/jobs", response_model=list[JobView], summary="Список заданий")
async def list_jobs(limit: int = Query(30, ge=1, le=200, description="Сколько последних заданий")) -> list[JobView]:
"""История создания кластеров (в памяти процесса; после перезапуска контейнера пусто)."""
items = job_store.snapshot_recent_sorted(limit=limit)
return [_record_to_job_view(r) for r in items]
@router.get("/clusters", response_model=list[ClusterSummary], summary="Список кластеров")
async def list_clusters() -> list[ClusterSummary]:
"""Объединение: зарегистрированные в kind + каталоги в ``clusters/`` (без дубликатов в выдаче)."""
kind_names = set(list_registered_kind_clusters())
cdir = clusters_dir()
dir_names: set[str] = set()
if cdir.is_dir():
dir_names = {p.name for p in cdir.iterdir() if p.is_dir() and not p.name.startswith(".")}
all_names = sorted(kind_names | dir_names)
out: list[ClusterSummary] = []
for name in all_names:
summary = cluster_summary_for_api(name)
out.append(
ClusterSummary(
name=str(summary["name"]),
registered_in_kind=bool(summary["registered_in_kind"]),
has_local_kubeconfig=bool(summary["has_local_kubeconfig"]),
meta=dict(summary["meta"]) if isinstance(summary.get("meta"), dict) else {},
)
)
logger.debug("list_clusters: %s записей", len(out))
return out
@router.get(
"/clusters/{name}/kubeconfig",
summary="Скачать kubeconfig",
responses={404: {"description": "Файл не найден"}},
)
async def download_kubeconfig(name: str) -> FileResponse:
"""Файл ``clusters/<имя>/kubeconfig`` для использования с хоста (локальная dev-среда)."""
if not validate_cluster_name(name):
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
path = clusters_dir() / name / "kubeconfig"
if not path.is_file():
raise HTTPException(status_code=404, detail="kubeconfig не найден")
logger.info("Отдача kubeconfig для кластера %s", name)
return FileResponse(
path=path,
filename=f"kubeconfig-{name}.yaml",
media_type="application/x-yaml",
)
@router.get(
"/clusters/{name}/workloads",
response_model=ClusterWorkloadsResponse,
summary="Узлы и поды (kubectl)",
)
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"
if not kc.is_file():
return ClusterWorkloadsResponse(cluster_name=name, error="Нет сохранённого kubeconfig в clusters/<имя>/")
nodes_rc, nodes_out = await asyncio.to_thread(kubectl_nodes_wide, kubeconfig=kc)
pods_rc, pods_out = await asyncio.to_thread(kubectl_pods_all_namespaces, kubeconfig=kc)
return ClusterWorkloadsResponse(
cluster_name=name,
nodes_rc=nodes_rc,
nodes_output=nodes_out,
pods_rc=pods_rc,
pods_output=pods_out,
)
@router.get("/clusters/{name}", summary="Детали кластера")
async def get_cluster(name: str) -> dict[str, object]:
"""Сводка и попытка ``kubectl get nodes`` (предпочтительно сохранённый kubeconfig)."""
summary = cluster_summary_for_api(name)
saved = clusters_dir() / name / "kubeconfig"
kubectl_rc: int | None = None
kubectl_msg: str | None = None
if saved.is_file():
rc, msg = await asyncio.to_thread(kubectl_nodes_wide, kubeconfig=saved)
kubectl_rc = rc
kubectl_msg = msg
return {
**summary,
"kubectl_get_nodes_rc": kubectl_rc,
"kubectl_get_nodes": kubectl_msg,
}
async def _run_create_job(job_id: str, body: ClusterCreateRequest) -> None:
try:
async with kind_cluster_lock:
await job_store.set_running(job_id)
try:
result = await asyncio.to_thread(
create_cluster_non_interactive,
name=body.name.strip(),
kubernetes_version_tag=body.kubernetes_version.strip(),
workers=body.workers,
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("create job %s: %s", job_id, e)
return
except Exception as e:
await job_store.set_failed(job_id, f"{type(e).__name__}: {e}")
logger.exception("create job %s: непредвиденная ошибка", 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="Кластер создан")
logger.info("create job %s: успех, кластер %s", job_id, result.cluster_name)
finally:
end_job_tracking(job_id)
async def _run_start_cluster_job(job_id: str, name: str, kubernetes_version_tag: str, workers: int) -> None:
"""Фоновое создание кластера по уже сохранённому ``kind-config.yaml`` (без kind в списке)."""
try:
async with kind_cluster_lock:
await job_store.set_running(job_id)
try:
result = await asyncio.to_thread(
create_cluster_non_interactive,
name=name.strip(),
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("start_cluster job %s: %s", job_id, e)
return
except Exception as e:
await job_store.set_failed(job_id, f"{type(e).__name__}: {e}")
logger.exception("start_cluster job %s: непредвиденная ошибка", 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="Кластер поднят по сохранённому конфигу")
logger.info("start_cluster job %s: успех, кластер %s", job_id, result.cluster_name)
finally:
end_job_tracking(job_id)
@router.post(
"/clusters",
response_model=ClusterCreateAccepted,
status_code=202,
summary="Создать кластер (фон)",
)
async def post_create_cluster(
body: ClusterCreateRequest,
background_tasks: BackgroundTasks,
) -> ClusterCreateAccepted:
"""Поставить создание кластера в фон; идентификатор задания — в ответе."""
if not validate_cluster_name(body.name.strip()):
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
existing = await asyncio.to_thread(list_registered_kind_clusters)
if body.name.strip() in existing:
raise HTTPException(status_code=409, detail="Кластер с таким именем уже есть в kind")
rec = await job_store.create_job("create_cluster", cluster_name=body.name.strip())
background_tasks.add_task(_run_create_job, rec.job_id, body)
logger.info("Принят запрос на создание кластера %s, job_id=%s", body.name, rec.job_id)
return ClusterCreateAccepted(job_id=rec.job_id)
@router.delete("/clusters/{name}", summary="Удалить кластер")
async def delete_cluster(name: str) -> dict[str, object]:
"""``kind delete`` и удаление локальной папки ``clusters/<имя>/``."""
if not validate_cluster_name(name):
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
async with kind_cluster_lock:
def _do() -> tuple[bool, str]:
return delete_kind_cluster_and_data(name=name, log_to_stdout=False)
try:
kind_ok, summary = await asyncio.to_thread(_do)
except KindClusterError as e:
raise HTTPException(status_code=500, detail=str(e)) from e
logger.info("Удаление кластера %s: kind_ok=%s", name, kind_ok)
return {"name": name, "kind_delete_ok": kind_ok, "summary": summary}
@router.post(
"/clusters/{name}/stop",
summary="Остановить узлы кластера (docker stop)",
responses={400: {"description": "Некорректное имя"}},
)
async def stop_cluster_nodes(name: str) -> dict[str, object]:
"""
Остановить контейнеры узлов kind; запись кластера в kind сохраняется.
После этого API «Старт» запустит те же контейнеры без ``kind create``.
"""
if not validate_cluster_name(name):
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
async with kind_cluster_lock:
def _do() -> tuple[bool, str]:
return stop_kind_cluster_containers(name=name)
try:
ok, summary = await asyncio.to_thread(_do)
except KindClusterError as e:
raise HTTPException(status_code=500, detail=str(e)) from e
logger.info("Остановка узлов %s: ok=%s", name, ok)
return {"name": name, "containers_stopped_ok": ok, "summary": summary}
@router.post(
"/clusters/{name}/start",
summary="Запустить кластер (контейнеры или kind create по конфигу)",
responses={400: {"description": "Нет kind и нет kind-config.yaml"}},
)
async def start_cluster_nodes(
name: str,
background_tasks: BackgroundTasks,
) -> JSONResponse:
"""
Если кластер есть в ``kind get clusters`` — ``docker start`` всех узлов.
Если в kind нет, но есть ``clusters/<имя>/kind-config.yaml`` — фоновое ``kind create``
(как при создании, с журналом в GET /jobs/{job_id}).
"""
if not validate_cluster_name(name):
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
n = name.strip()
async with kind_cluster_lock:
in_kind = n in await asyncio.to_thread(list_registered_kind_clusters)
if in_kind:
def _start() -> tuple[bool, str]:
return start_kind_cluster_containers(name=n)
try:
ok, summary = await asyncio.to_thread(_start)
except KindClusterError as e:
raise HTTPException(status_code=500, detail=str(e)) from e
logger.info("Запуск контейнеров кластера %s: ok=%s", n, ok)
return JSONResponse(
status_code=200,
content={
"name": n,
"mode": "containers",
"containers_started_ok": ok,
"summary": summary,
},
)
cfg = clusters_dir() / n / "kind-config.yaml"
if not cfg.is_file():
raise HTTPException(
status_code=400,
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)
return JSONResponse(
status_code=202,
content={
"job_id": rec.job_id,
"status": "queued",
"message": "Подъём кластера по kind-config.yaml; опросите GET /api/v1/jobs/{job_id}",
},
)
@router.post(
"/jobs/{job_id}/cancel",
summary="Запросить отмену создания кластера",
responses={400: {"description": "Задание уже завершено"}, 404: {"description": "Нет задания"}},
)
async def cancel_create_job(job_id: str) -> dict[str, object]:
"""
Установить флаг отмены для задания ``create_cluster`` или ``start_cluster``.
Этап ``kind create cluster`` нельзя прервать до его завершения; после него отмена удалит
кластер и данные (если успели создать).
"""
rec = await job_store.get(job_id)
if not rec:
raise HTTPException(status_code=404, detail="Задание не найдено")
if rec.status not in ("queued", "running"):
raise HTTPException(status_code=400, detail="Задание уже завершено; отмена невозможна")
if not request_cancel_sync(job_id):
raise HTTPException(status_code=404, detail="Задание не найдено")
logger.info("Принят запрос отмены задания %s", job_id)
return {
"job_id": job_id,
"cancel_requested": True,
"message": "Отмена обрабатывается между этапами; во время kind create дождитесь окончания шага",
}
@router.get("/jobs/{job_id}", response_model=JobView, summary="Статус одного задания")
async def get_job(job_id: str) -> JobView:
"""Узнать состояние фонового создания кластера."""
rec = await job_store.get(job_id)
if not rec:
raise HTTPException(status_code=404, detail="Задание не найдено")
return _record_to_job_view(rec)