Панель: журнал заданий, 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:
@@ -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": "Запрошено прерывание; текущая команда будет остановлена, задание перейдёт в отменено",
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user