Панель: журнал заданий, pull --progress plain, PTY/EIO, старт/стоп в фоне, очистка jobs

- Фоновые stop/start с job_id и poll; отмена с kill; docker pull plain + снятие ANSI
- Лимиты журнала API/буфера; список jobs без progress_log; DELETE /jobs
- UI: опрос чаще, подсказка при пустом логе, кнопка очистки завершённых
This commit is contained in:
Sergey Antropoff
2026-04-04 07:04:46 +03:00
parent 6d4bc65c8a
commit 8bd44adbb0
10 changed files with 808 additions and 200 deletions
+139 -53
View File
@@ -8,6 +8,7 @@ from __future__ import annotations
import asyncio
import logging
import os
from typing import Any
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query
@@ -51,19 +52,31 @@ logger = logging.getLogger("kind_k8s.api.clusters")
router = APIRouter(tags=["clusters"])
def _record_to_job_view(rec: JobRecord) -> JobView:
def _api_job_log_limit_detail() -> int:
"""Сколько строк журнала отдавать в GET /jobs/{id} (полный хвост буфера задания)."""
raw = (os.environ.get("KIND_K8S_JOB_API_LOG_MAX_LINES") or "5000").strip()
try:
return max(200, min(int(raw), 20000))
except ValueError:
return 5000
def _record_to_job_view(rec: JobRecord, *, include_progress_log: bool = True) -> 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)
if not include_progress_log:
log_tail: list[str] = []
else:
log_tail = list(rec.log_lines or [])
max_log = 400
if len(log_tail) > max_log:
log_tail = log_tail[-max_log:]
if rec.status in ("queued", "running"):
log_tail = get_logs_snapshot_sync(rec.job_id)
else:
log_tail = list(rec.log_lines or [])
cap = _api_job_log_limit_detail()
if len(log_tail) > cap:
log_tail = log_tail[-cap:]
return JobView(
job_id=rec.job_id,
kind=rec.kind,
@@ -127,7 +140,18 @@ async def get_stats() -> StatsResponse:
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]
# В списке не тащим progress_log — экономим трафик; полный журнал только в GET /jobs/{id}.
return [_record_to_job_view(r, include_progress_log=False) for r in items]
@router.delete("/jobs", summary="Очистить завершённые задания из памяти")
async def delete_completed_jobs() -> dict[str, int]:
"""
Удаляет записи со статусом success, failed, cancelled. Задания в очереди и в работе не трогаются.
"""
removed = await job_store.purge_completed()
logger.info("Очистка заданий: удалено завершённых записей: %s", removed)
return {"removed": removed}
@router.get("/clusters", response_model=list[ClusterSummary], summary="Список кластеров")
@@ -230,7 +254,7 @@ async def _run_create_job(job_id: str, body: ClusterCreateRequest) -> None:
)
except KindClusterError as e:
msg = str(e)
if "отменено" in msg.lower():
if "отмен" in msg.lower():
await job_store.set_cancelled(job_id, msg)
else:
await job_store.set_failed(job_id, msg)
@@ -273,7 +297,7 @@ async def _run_start_cluster_job(job_id: str, name: str, kubernetes_version_tag:
)
except KindClusterError as e:
msg = str(e)
if "отменено" in msg.lower():
if "отмен" in msg.lower():
await job_store.set_cancelled(job_id, msg)
else:
await job_store.set_failed(job_id, msg)
@@ -300,6 +324,72 @@ async def _run_start_cluster_job(job_id: str, name: str, kubernetes_version_tag:
end_job_tracking(job_id)
async def _run_stop_cluster_job(job_id: str, name: str) -> None:
"""Фоновая остановка узлов с журналом в GET /jobs/{id}."""
n = name.strip()
try:
async with kind_cluster_lock:
await job_store.set_running(job_id, stage="Остановка узлов", percent=6)
try:
ok, summary = await asyncio.to_thread(stop_kind_cluster_containers, 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("stop_containers 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("stop_containers job %s: непредвиденная ошибка", job_id)
return
await job_store.set_success(
job_id,
result={"name": n, "containers_stopped_ok": ok},
message=summary,
)
logger.info("stop_containers job %s: ok=%s", job_id, ok)
finally:
end_job_tracking(job_id)
async def _run_start_containers_job(job_id: str, name: str) -> None:
"""Фоновый запуск уже существующих узлов (кластер зарегистрирован в kind)."""
n = name.strip()
try:
async with kind_cluster_lock:
await job_store.set_running(job_id, stage="Запуск узлов", percent=6)
try:
ok, summary = await asyncio.to_thread(start_kind_cluster_containers, 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("start_containers 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_containers job %s: непредвиденная ошибка", job_id)
return
if not ok:
await job_store.set_failed(job_id, summary)
return
await job_store.set_success(
job_id,
result={"name": n, "containers_started_ok": ok},
message=summary,
)
logger.info("start_containers job %s: ok=%s", job_id, ok)
finally:
end_job_tracking(job_id)
@router.post(
"/clusters",
response_model=ClusterCreateAccepted,
@@ -346,30 +436,31 @@ async def delete_cluster(name: str) -> dict[str, object]:
@router.post(
"/clusters/{name}/stop",
summary="Остановить узлы кластера (docker stop)",
summary="Остановить узлы кластера (фон, журнал в /jobs)",
responses={400: {"description": "Некорректное имя"}},
)
async def stop_cluster_nodes(name: str) -> dict[str, object]:
async def stop_cluster_nodes(name: str, background_tasks: BackgroundTasks) -> JSONResponse:
"""
Остановить контейнеры узлов kind; запись кластера в kind сохраняется.
Поставить остановку узлов в фон: тот же журнал в реальном времени, что и при создании.
После этого API «Старт» запустит те же контейнеры без ``kind create``.
Запись кластера в kind сохраняется; «Старт» снова поднимет узлы.
"""
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}
n = name.strip()
rec = await job_store.create_job("stop_containers", cluster_name=n)
background_tasks.add_task(_run_stop_cluster_job, rec.job_id, n)
logger.info("Остановка узлов %s в фоне, job_id=%s", n, rec.job_id)
return JSONResponse(
status_code=202,
content={
"job_id": rec.job_id,
"status": "queued",
"mode": "stop",
"message": "Остановка узлов; опросите GET /api/v1/jobs/{job_id}",
},
)
@router.post(
@@ -382,10 +473,9 @@ async def start_cluster_nodes(
background_tasks: BackgroundTasks,
) -> JSONResponse:
"""
Если кластер есть в ``kind get clusters`` — ``docker start`` всех узлов.
Если кластер уже зарегистрирован — фоновый запуск сохранённых узлов (журнал в GET /jobs).
Если в kind нет, но есть ``clusters/<имя>/kind-config.yaml`` — фоновое ``kind create``
(как при создании, с журналом в GET /jobs/{job_id}).
Иначе, при наличии сохранённого конфига в каталоге кластера — фоновый подъём как при создании.
"""
if not validate_cluster_name(name):
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
@@ -394,25 +484,20 @@ async def start_cluster_nodes(
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,
},
)
if in_kind:
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)
return JSONResponse(
status_code=202,
content={
"job_id": rec.job_id,
"status": "queued",
"mode": "containers",
"message": "Запуск узлов; опросите GET /api/v1/jobs/{job_id}",
},
)
cfg = clusters_dir() / n / "kind-config.yaml"
if not cfg.is_file():
@@ -437,22 +522,23 @@ async def start_cluster_nodes(
content={
"job_id": rec.job_id,
"status": "queued",
"message": "Подъём кластера по kind-config.yaml; опросите GET /api/v1/jobs/{job_id}",
"mode": "kind_config",
"message": "Подъём кластера по сохранённому конфигу; опросите GET /api/v1/jobs/{job_id}",
},
)
@router.post(
"/jobs/{job_id}/cancel",
summary="Запросить отмену создания кластера",
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:
@@ -465,7 +551,7 @@ async def cancel_create_job(job_id: str) -> dict[str, object]:
return {
"job_id": job_id,
"cancel_requested": True,
"message": "Отмена обрабатывается между этапами; во время kind create дождитесь окончания шага",
"message": "Запрошено прерывание; текущая команда будет остановлена, задание перейдёт в отменено",
}