Веб-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
This commit is contained in:
@@ -11,7 +11,7 @@ import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
|
||||
from core.cluster_lifecycle import (
|
||||
KindClusterError,
|
||||
@@ -22,9 +22,18 @@ from core.cluster_lifecycle import (
|
||||
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_progress_sync, job_store, request_cancel_sync
|
||||
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 (
|
||||
@@ -47,6 +56,13 @@ def _record_to_job_view(rec: JobRecord) -> JobView:
|
||||
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,
|
||||
@@ -57,6 +73,7 @@ def _record_to_job_view(rec: JobRecord) -> JobView:
|
||||
result=rec.result,
|
||||
progress_stage=stage,
|
||||
progress_percent=pct,
|
||||
progress_log=log_tail,
|
||||
)
|
||||
|
||||
|
||||
@@ -235,6 +252,49 @@ async def _run_create_job(job_id: str, body: ClusterCreateRequest) -> None:
|
||||
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,
|
||||
@@ -279,6 +339,104 @@ async def delete_cluster(name: str) -> dict[str, object]:
|
||||
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="Запросить отмену создания кластера",
|
||||
@@ -286,8 +444,10 @@ async def delete_cluster(name: str) -> dict[str, object]:
|
||||
)
|
||||
async def cancel_create_job(job_id: str) -> dict[str, object]:
|
||||
"""
|
||||
Установить флаг отмены. Этап ``kind create cluster`` нельзя прервать до его завершения;
|
||||
после него отмена удалит кластер и данные (если успели создать).
|
||||
Установить флаг отмены для задания ``create_cluster`` или ``start_cluster``.
|
||||
|
||||
Этап ``kind create cluster`` нельзя прервать до его завершения; после него отмена удалит
|
||||
кластер и данные (если успели создать).
|
||||
"""
|
||||
rec = await job_store.get(job_id)
|
||||
if not rec:
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Отдача сырого README.md для клиентского рендера Markdown (marked в static).
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import PlainTextResponse
|
||||
|
||||
from core.readme_doc import read_readme_text
|
||||
|
||||
logger = logging.getLogger("kind_k8s.api.docs_readme")
|
||||
|
||||
router = APIRouter(tags=["documentation"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/docs/readme",
|
||||
response_class=PlainTextResponse,
|
||||
summary="README.md как текст (Markdown)",
|
||||
responses={404: {"description": "Файл не найден"}},
|
||||
)
|
||||
async def get_readme_markdown() -> PlainTextResponse:
|
||||
"""
|
||||
Тело ответа — содержимое README в кодировке UTF-8.
|
||||
|
||||
Разбор Markdown выполняется в браузере скриптами из ``/static/js/vendor/`` (marked + DOMPurify).
|
||||
"""
|
||||
try:
|
||||
|
||||
def _read() -> str:
|
||||
return read_readme_text()
|
||||
|
||||
text = await asyncio.to_thread(_read)
|
||||
except FileNotFoundError:
|
||||
logger.info("GET /docs/readme: файл README не найден")
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=(
|
||||
"README.md не найден. Укажите KIND_K8S_README_PATH, смонтируйте в compose "
|
||||
"./README.md:/opt/kind-k8s/README.md:ro или пересоберите образ (COPY README.md)."
|
||||
),
|
||||
) from None
|
||||
logger.debug("GET /docs/readme: отдано %s символов", len(text))
|
||||
return PlainTextResponse(
|
||||
content=text,
|
||||
media_type="text/markdown; charset=utf-8",
|
||||
)
|
||||
Reference in New Issue
Block a user