6f3daa33ec
- Шапка: логотип Kubernetes, ссылка на главную, выпадающее меню API (Swagger/ReDoc/Health), переключатель светлой/тёмной темы (localStorage). - Светлая тема в синей гамме; выравнивание кнопки темы в ряду с пилюлями. - Дашборд: единая карточка ошибки health/stats, подсказка Docker/Podman, поле container_cli в GET /stats, total_workers_from_meta всегда число (0 без meta). - Правки кластеров, job_store, compose, документация и частичные шаблоны.
578 lines
24 KiB
Python
578 lines
24 KiB
Python
"""CRUD-операции над кластерами kind и сводная статистика.
|
|
|
|
Автор: Сергей Антропов
|
|
Сайт: https://devops.org.ru
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import os
|
|
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,
|
|
configured_container_cli,
|
|
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.container_resource_stats import (
|
|
aggregate_kind_cluster_resources,
|
|
collect_kind_clusters_resource_stats,
|
|
running_kind_clusters_mask,
|
|
)
|
|
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 (
|
|
AggregateResourcesSummary,
|
|
ClusterCreateAccepted,
|
|
ClusterCreateRequest,
|
|
ClusterSummary,
|
|
ClusterWorkloadsResponse,
|
|
JobView,
|
|
StatsResponse,
|
|
)
|
|
|
|
logger = logging.getLogger("kind_k8s.api.clusters")
|
|
|
|
router = APIRouter(tags=["clusters"])
|
|
|
|
|
|
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 not include_progress_log:
|
|
log_tail: list[str] = []
|
|
else:
|
|
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,
|
|
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
|
|
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)
|
|
except (TypeError, ValueError):
|
|
continue
|
|
|
|
jobs = job_store.snapshot_all()
|
|
failed = sum(1 for j in jobs if j.status == "failed")
|
|
|
|
res_blocks, res_err = collect_kind_clusters_resource_stats()
|
|
agg: AggregateResourcesSummary
|
|
if res_err:
|
|
agg = AggregateResourcesSummary()
|
|
else:
|
|
agg = aggregate_kind_cluster_resources(res_blocks)
|
|
|
|
return StatsResponse(
|
|
container_cli=configured_container_cli(),
|
|
kind_clusters_count=len(kind_names),
|
|
local_cluster_dirs_count=len(subdirs),
|
|
total_workers_from_meta=total_workers,
|
|
jobs_total=len(jobs),
|
|
jobs_recent_failed=failed,
|
|
cluster_resources=res_blocks,
|
|
cluster_resources_error=res_err,
|
|
aggregate_cluster_resources=agg,
|
|
)
|
|
|
|
|
|
@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)
|
|
# В списке не тащим 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="Список кластеров")
|
|
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)
|
|
running_map = running_kind_clusters_mask(all_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"]),
|
|
kind_nodes_running=bool(running_map.get(name, False)),
|
|
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)
|
|
|
|
|
|
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,
|
|
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="Остановить узлы кластера (фон, журнал в /jobs)",
|
|
responses={400: {"description": "Некорректное имя"}},
|
|
)
|
|
async def stop_cluster_nodes(name: str, background_tasks: BackgroundTasks) -> JSONResponse:
|
|
"""
|
|
Поставить остановку узлов в фон: тот же журнал в реальном времени, что и при создании.
|
|
|
|
Запись кластера в kind сохраняется; «Старт» снова поднимет узлы.
|
|
"""
|
|
if not validate_cluster_name(name):
|
|
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
|
|
|
|
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(
|
|
"/clusters/{name}/start",
|
|
summary="Запустить кластер (контейнеры или kind create по конфигу)",
|
|
responses={400: {"description": "Нет kind и нет kind-config.yaml"}},
|
|
)
|
|
async def start_cluster_nodes(
|
|
name: str,
|
|
background_tasks: BackgroundTasks,
|
|
) -> JSONResponse:
|
|
"""
|
|
Если кластер уже зарегистрирован — фоновый запуск сохранённых узлов (журнал в GET /jobs).
|
|
|
|
Иначе, при наличии сохранённого конфига в каталоге кластера — фоновый подъём как при создании.
|
|
"""
|
|
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:
|
|
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():
|
|
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",
|
|
"mode": "kind_config",
|
|
"message": "Подъём кластера по сохранённому конфигу; опросите 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]:
|
|
"""
|
|
Прервать активное задание: скачивание образа, создание кластера, запуск/остановка узлов.
|
|
|
|
Для длительных команд дочерний процесс завершается принудительно; между узлами останов
|
|
также учитывает флаг отмены.
|
|
"""
|
|
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": "Запрошено прерывание; текущая команда будет остановлена, задание перейдёт в отменено",
|
|
}
|
|
|
|
|
|
@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)
|