52538d9816
- PUT/GET конфигурации кластера, страница редактирования и модалка после сохранения - После смены kind-config: флаг в meta и start_cluster_reapply (kind delete + create) - Старт/стоп: полноэкранный спиннер до завершения job; модалки и документация API - Таблица кластеров: колонка Имя 40% при table-layout fixed; чекбоксы без width 100% - Карточки ресурсов узлов на странице кластера: до 3 в ряд; прочие правки стилей и dashboard.js
1127 lines
49 KiB
Python
1127 lines
49 KiB
Python
"""CRUD-операции над кластерами kind и сводная статистика.
|
||
|
||
Автор: Сергей Антропов
|
||
Сайт: https://devops.org.ru
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import json
|
||
import logging
|
||
import os
|
||
import re
|
||
import shutil
|
||
import tempfile
|
||
from functools import partial
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query
|
||
from fastapi.responses import FileResponse, JSONResponse
|
||
from starlette.background import BackgroundTask
|
||
|
||
from core.cluster_config_edit import (
|
||
ClusterConfigEditError,
|
||
NOTE_NOT_IN_KIND,
|
||
NOTE_REGISTERED_IN_KIND,
|
||
apply_cluster_config_update,
|
||
read_cluster_config_bundle,
|
||
)
|
||
from core.cluster_lifecycle import (
|
||
KindClusterError,
|
||
cluster_summary_for_api,
|
||
configured_container_cli,
|
||
create_cluster_non_interactive,
|
||
delete_kind_cluster_and_data,
|
||
delete_kind_cluster_keep_data,
|
||
kubectl_delete_pod,
|
||
kubectl_get_json,
|
||
kubectl_get_all_namespaces_wide,
|
||
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,
|
||
collect_single_cluster_resource_stats,
|
||
running_kind_clusters_mask,
|
||
)
|
||
from core.job_store import (
|
||
JobRecord,
|
||
end_job_tracking,
|
||
get_logs_snapshot_sync,
|
||
get_progress_sync,
|
||
job_store,
|
||
register_uncapped_job_log,
|
||
request_cancel_sync,
|
||
take_uncapped_log_finalize,
|
||
)
|
||
from core.provision_log import provision_log_file_path, write_cluster_provision_log
|
||
from core.kind_guard import kind_cluster_lock
|
||
from kind_k8s_paths import clusters_dir
|
||
from kubeconfig_patch import kubeconfig_host_file, patch_kubeconfig_server_for_host
|
||
from models.schemas import (
|
||
AggregateResourcesSummary,
|
||
ClusterConfigGetResponse,
|
||
ClusterConfigUpdateRequest,
|
||
ClusterConfigUpdateResponse,
|
||
ClusterCreateAccepted,
|
||
ClusterCreateRequest,
|
||
ClusterOverviewResponse,
|
||
ClusterSummary,
|
||
ClusterWorkloadsResponse,
|
||
JobView,
|
||
K8sListJsonBlock,
|
||
PodRestartRequest,
|
||
PodRestartResponse,
|
||
StatsResponse,
|
||
)
|
||
|
||
logger = logging.getLogger("kind_k8s.api.clusters")
|
||
|
||
# Сообщение UI: узлы kind остановлены — kubectl до API кластера не достучится (DNS control-plane и т.д.).
|
||
_K8S_CLUSTER_STOPPED_MSG = (
|
||
"Кластер остановлен. Данные Kubernetes появятся после запуска узлов (кнопка «Старт» в панели)."
|
||
)
|
||
|
||
# Безопасные имена namespace/pod для kubectl (без shell-метасимволов).
|
||
_K8S_NAME_SAFE = re.compile(r"^[a-zA-Z0-9][-a-zA-Z0-9.]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$")
|
||
|
||
|
||
def _k8s_ns_pod_valid(s: str) -> bool:
|
||
if not s or len(s) > 253:
|
||
return False
|
||
return bool(_K8S_NAME_SAFE.match(s))
|
||
|
||
|
||
def _k8s_list_from_kubectl(
|
||
rc: int,
|
||
items: list[dict[str, Any]] | None,
|
||
err: str,
|
||
) -> K8sListJsonBlock:
|
||
"""Собрать блок JSON для UI из результата :func:`kubectl_get_json`."""
|
||
if items is not None and rc == 0:
|
||
return K8sListJsonBlock(ok=True, rc=0, items=items, message=None)
|
||
return K8sListJsonBlock(ok=False, rc=rc, items=[], message=err or "ошибка kubectl")
|
||
|
||
|
||
def _unlink_temp_kubeconfig(path: Path) -> None:
|
||
"""Удалить временный файл после отдачи ответа (скачивание kubeconfig)."""
|
||
try:
|
||
path.unlink(missing_ok=True)
|
||
except OSError as e:
|
||
logger.debug("Не удалось удалить временный kubeconfig %s: %s", path, e)
|
||
|
||
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"]),
|
||
has_provision_log=bool(summary.get("has_provision_log")),
|
||
meta=dict(summary["meta"]) if isinstance(summary.get("meta"), dict) else {},
|
||
)
|
||
)
|
||
logger.debug("list_clusters: %s записей", len(out))
|
||
return out
|
||
|
||
|
||
def _cluster_summary_model(name: str) -> ClusterSummary:
|
||
"""Сводка кластера для ответов API (как в GET /clusters)."""
|
||
summary = cluster_summary_for_api(name)
|
||
running_map = running_kind_clusters_mask([name])
|
||
return 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"]),
|
||
has_provision_log=bool(summary.get("has_provision_log")),
|
||
meta=dict(summary["meta"]) if isinstance(summary.get("meta"), dict) else {},
|
||
)
|
||
|
||
|
||
@router.get(
|
||
"/clusters/{name}/config",
|
||
response_model=ClusterConfigGetResponse,
|
||
summary="Конфигурация кластера (meta + kind-config.yaml)",
|
||
responses={404: {"description": "Каталог кластера не найден"}},
|
||
)
|
||
async def get_cluster_config(name: str) -> ClusterConfigGetResponse:
|
||
"""
|
||
Текущие ``meta.json`` и текст ``kind-config.yaml`` из ``clusters/<имя>/``.
|
||
Поле ``kind_note`` напоминает: у уже созданного в kind кластера смена YAML на диске не меняет узлы до пересоздания.
|
||
"""
|
||
n = name.strip()
|
||
if not validate_cluster_name(n):
|
||
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
|
||
try:
|
||
bundle = await asyncio.to_thread(read_cluster_config_bundle, n)
|
||
except ClusterConfigEditError as e:
|
||
if e.not_found:
|
||
raise HTTPException(status_code=404, detail=str(e)) from e
|
||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||
summary = await asyncio.to_thread(cluster_summary_for_api, n)
|
||
reg = bool(summary["registered_in_kind"])
|
||
note = NOTE_REGISTERED_IN_KIND if reg else NOTE_NOT_IN_KIND
|
||
return ClusterConfigGetResponse(
|
||
cluster_name=str(bundle["cluster_name"]),
|
||
meta=dict(bundle["meta"]) if isinstance(bundle.get("meta"), dict) else {},
|
||
kind_config_yaml=bundle.get("kind_config_yaml"),
|
||
has_kind_config=bool(bundle.get("has_kind_config")),
|
||
registered_in_kind=reg,
|
||
kind_note=note,
|
||
)
|
||
|
||
|
||
@router.put(
|
||
"/clusters/{name}/config",
|
||
response_model=ClusterConfigUpdateResponse,
|
||
summary="Обновить конфигурацию кластера на диске",
|
||
responses={404: {"description": "Каталог кластера не найден"}},
|
||
)
|
||
async def put_cluster_config(name: str, body: ClusterConfigUpdateRequest) -> ClusterConfigUpdateResponse:
|
||
"""
|
||
Сохраняет изменения в ``kind-config.yaml`` и/или ``meta.json``.
|
||
|
||
Режимы: (1) непустой ``kind_config_yaml`` — полная замена файла с проверкой структуры kind Cluster;
|
||
(2) ``kubernetes_version`` и/или ``workers`` — пересборка простого конфига (как при создании);
|
||
(3) ``description`` — заметка в meta (пустая строка сбрасывает поле).
|
||
|
||
Kind не применяет новый YAML к уже работающим нодам — см. ``kind_note`` в ответе.
|
||
"""
|
||
n = name.strip()
|
||
if not validate_cluster_name(n):
|
||
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
|
||
try:
|
||
updated_yaml, msg = await asyncio.to_thread(
|
||
apply_cluster_config_update,
|
||
n,
|
||
kubernetes_version=body.kubernetes_version,
|
||
workers=body.workers,
|
||
kind_config_yaml=body.kind_config_yaml,
|
||
description=body.description,
|
||
)
|
||
except ClusterConfigEditError as e:
|
||
if e.not_found:
|
||
raise HTTPException(status_code=404, detail=str(e)) from e
|
||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||
summary_api = await asyncio.to_thread(cluster_summary_for_api, n)
|
||
reg = bool(summary_api["registered_in_kind"])
|
||
note = NOTE_REGISTERED_IN_KIND if reg else NOTE_NOT_IN_KIND
|
||
summ = await asyncio.to_thread(_cluster_summary_model, n)
|
||
logger.info("Обновлена конфигурация кластера «%s», yaml=%s", n, updated_yaml)
|
||
return ClusterConfigUpdateResponse(
|
||
cluster_name=n,
|
||
updated_kind_config_yaml=updated_yaml,
|
||
message=msg,
|
||
registered_in_kind=reg,
|
||
kind_note=note,
|
||
summary=summ,
|
||
)
|
||
|
||
|
||
@router.get(
|
||
"/clusters/{name}/kubeconfig",
|
||
summary="Скачать kubeconfig",
|
||
responses={404: {"description": "Файл не найден"}},
|
||
)
|
||
async def download_kubeconfig(name: str) -> FileResponse:
|
||
"""
|
||
Kubeconfig для **kubectl на машине пользователя**: копия ``clusters/<имя>/kubeconfig``
|
||
с ``server=https://<KIND_K8S_KUBECONFIG_CLIENT_HOST или localhost>:<порт>`` (порт из ``docker port``).
|
||
|
||
Патч выполняется при каждом скачивании, чтобы учитывать переменные окружения и актуальный порт.
|
||
Если патч невозможен — отдаётся сохранённый ``kubeconfig.host`` или сырой ``kubeconfig``.
|
||
"""
|
||
if not validate_cluster_name(name):
|
||
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
|
||
n = name.strip()
|
||
base = clusters_dir() / n
|
||
primary = base / "kubeconfig"
|
||
if not primary.is_file():
|
||
raise HTTPException(status_code=404, detail="kubeconfig не найден")
|
||
|
||
fd, tmp_name = tempfile.mkstemp(suffix=".yaml", prefix="kind-k8s-kube-")
|
||
os.close(fd)
|
||
tmp_path = Path(tmp_name)
|
||
try:
|
||
shutil.copyfile(primary, tmp_path, follow_symlinks=True)
|
||
except OSError as e:
|
||
tmp_path.unlink(missing_ok=True)
|
||
logger.warning("Копирование kubeconfig для скачивания: %s", e)
|
||
raise HTTPException(status_code=500, detail="Не удалось подготовить kubeconfig") from e
|
||
|
||
patched = await asyncio.to_thread(
|
||
partial(patch_kubeconfig_server_for_host, cluster_name=n, kube_path=tmp_path),
|
||
)
|
||
if patched:
|
||
logger.info("Отдача kubeconfig для кластера %s (сгенерировано с актуальным server)", n)
|
||
return FileResponse(
|
||
path=str(tmp_path),
|
||
filename=f"kubeconfig-{n}.yaml",
|
||
media_type="application/x-yaml",
|
||
background=BackgroundTask(_unlink_temp_kubeconfig, tmp_path),
|
||
)
|
||
|
||
tmp_path.unlink(missing_ok=True)
|
||
host_path = kubeconfig_host_file(base)
|
||
if host_path.is_file():
|
||
logger.info("Отдача kubeconfig для кластера %s (файл %s, патч при скачивании не удался)", n, host_path.name)
|
||
return FileResponse(
|
||
path=host_path,
|
||
filename=f"kubeconfig-{n}.yaml",
|
||
media_type="application/x-yaml",
|
||
)
|
||
logger.info("Отдача kubeconfig для кластера %s (сырой kubeconfig)", n)
|
||
return FileResponse(
|
||
path=primary,
|
||
filename=f"kubeconfig-{n}.yaml",
|
||
media_type="application/x-yaml",
|
||
)
|
||
|
||
|
||
@router.get(
|
||
"/clusters/{name}/provision-log",
|
||
summary="Журнал развёртывания (JSON)",
|
||
responses={404: {"description": "Файл не найден"}},
|
||
)
|
||
async def get_cluster_provision_log(name: str) -> JSONResponse:
|
||
"""Полный журнал последнего создания/старта кластера (``provision_log.json`` в каталоге кластера)."""
|
||
if not validate_cluster_name(name):
|
||
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
|
||
path = provision_log_file_path(name)
|
||
if not path.is_file():
|
||
raise HTTPException(status_code=404, detail="Журнал развёртывания не найден")
|
||
try:
|
||
raw = await asyncio.to_thread(path.read_text, encoding="utf-8")
|
||
data = json.loads(raw)
|
||
except (OSError, json.JSONDecodeError) as e:
|
||
logger.warning("provision_log для %s не прочитан: %s", name, e)
|
||
raise HTTPException(status_code=500, detail="Не удалось прочитать журнал") from e
|
||
return JSONResponse(content=data)
|
||
|
||
|
||
@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="Некорректное имя кластера")
|
||
n = name.strip()
|
||
kc = clusters_dir() / n / "kubeconfig"
|
||
if not kc.is_file():
|
||
return ClusterWorkloadsResponse(cluster_name=n, error="Нет сохранённого kubeconfig в clusters/<имя>/")
|
||
|
||
summary = cluster_summary_for_api(n)
|
||
run_mask = await asyncio.to_thread(running_kind_clusters_mask, [n])
|
||
if bool(summary["registered_in_kind"]) and not bool(run_mask.get(n, False)):
|
||
return ClusterWorkloadsResponse(cluster_name=n, error=_K8S_CLUSTER_STOPPED_MSG)
|
||
|
||
nodes_rc, nodes_out = await asyncio.to_thread(
|
||
kubectl_nodes_wide,
|
||
cluster_name=n,
|
||
kubeconfig=kc,
|
||
)
|
||
pods_rc, pods_out = await asyncio.to_thread(
|
||
kubectl_pods_all_namespaces,
|
||
cluster_name=n,
|
||
kubeconfig=kc,
|
||
)
|
||
return ClusterWorkloadsResponse(
|
||
cluster_name=n,
|
||
nodes_rc=nodes_rc,
|
||
nodes_output=nodes_out,
|
||
pods_rc=pods_rc,
|
||
pods_output=pods_out,
|
||
)
|
||
|
||
|
||
@router.get(
|
||
"/clusters/{name}/overview",
|
||
response_model=ClusterOverviewResponse,
|
||
summary="Сводка кластера (страница UI)",
|
||
)
|
||
async def cluster_overview(name: str) -> ClusterOverviewResponse:
|
||
"""
|
||
Мета, метрики узлов (docker/podman), агрегаты как на главной, вывод kubectl по **всему кластеру**:
|
||
узлы; для namespace-ресурсов везде **-A** (все пространства имён); отдельно namespaces, RS, jobs и т.д.
|
||
"""
|
||
if not validate_cluster_name(name):
|
||
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
|
||
n = name.strip()
|
||
summary = cluster_summary_for_api(n)
|
||
run_mask = await asyncio.to_thread(running_kind_clusters_mask, [n])
|
||
kind_running = bool(run_mask.get(n, False))
|
||
|
||
block, res_err = await asyncio.to_thread(collect_single_cluster_resource_stats, n)
|
||
agg = aggregate_kind_cluster_resources([block])
|
||
|
||
kc = clusters_dir() / n / "kubeconfig"
|
||
kube_err: str | None = None
|
||
k8s_nodes = K8sListJsonBlock(ok=False, items=[], message=None)
|
||
k8s_pods = K8sListJsonBlock(ok=False, items=[], message=None)
|
||
k8s_deployments = K8sListJsonBlock(ok=False, items=[], message=None)
|
||
k8s_statefulsets = K8sListJsonBlock(ok=False, items=[], message=None)
|
||
k8s_daemonsets = K8sListJsonBlock(ok=False, items=[], message=None)
|
||
k8s_services = K8sListJsonBlock(ok=False, items=[], message=None)
|
||
k8s_ingresses = K8sListJsonBlock(ok=False, items=[], message=None)
|
||
k8s_namespaces = K8sListJsonBlock(ok=False, items=[], message=None)
|
||
k8s_replicasets = K8sListJsonBlock(ok=False, items=[], message=None)
|
||
k8s_jobs = K8sListJsonBlock(ok=False, items=[], message=None)
|
||
k8s_cronjobs = K8sListJsonBlock(ok=False, items=[], message=None)
|
||
k8s_pvcs = K8sListJsonBlock(ok=False, items=[], message=None)
|
||
|
||
reg_in_kind = bool(summary["registered_in_kind"])
|
||
|
||
if not kc.is_file():
|
||
kube_err = "Нет сохранённого kubeconfig в clusters/<имя>/"
|
||
_kube_missing = K8sListJsonBlock(ok=False, items=[], message=kube_err)
|
||
k8s_nodes = _kube_missing
|
||
k8s_pods = k8s_deployments = k8s_statefulsets = k8s_daemonsets = _kube_missing
|
||
k8s_services = k8s_ingresses = k8s_namespaces = k8s_replicasets = _kube_missing
|
||
k8s_jobs = k8s_cronjobs = k8s_pvcs = _kube_missing
|
||
elif reg_in_kind and not kind_running:
|
||
# Не вызываем kubectl: контейнеры узлов остановлены — в логах был бы шум про dev-control-plane / DNS.
|
||
kube_err = _K8S_CLUSTER_STOPPED_MSG
|
||
_stopped = K8sListJsonBlock(ok=False, items=[], message=_K8S_CLUSTER_STOPPED_MSG)
|
||
k8s_nodes = _stopped
|
||
k8s_pods = k8s_deployments = k8s_statefulsets = k8s_daemonsets = _stopped
|
||
k8s_services = k8s_ingresses = k8s_namespaces = k8s_replicasets = _stopped
|
||
k8s_jobs = k8s_cronjobs = k8s_pvcs = _stopped
|
||
logger.debug("overview %s: kubectl пропущен — кластер в kind, узлы не запущены", n)
|
||
else:
|
||
(
|
||
n_res,
|
||
p_res,
|
||
d_res,
|
||
st_res,
|
||
dm_res,
|
||
svc_res,
|
||
ing_res,
|
||
ns_res,
|
||
rs_res,
|
||
job_res,
|
||
cj_res,
|
||
pvc_res,
|
||
) = await asyncio.gather(
|
||
asyncio.to_thread(kubectl_get_json, cluster_name=n, kubeconfig=kc, resource_args=["nodes"]),
|
||
asyncio.to_thread(kubectl_get_json, cluster_name=n, kubeconfig=kc, resource_args=["pods", "-A"]),
|
||
asyncio.to_thread(kubectl_get_json, cluster_name=n, kubeconfig=kc, resource_args=["deployments", "-A"]),
|
||
asyncio.to_thread(kubectl_get_json, cluster_name=n, kubeconfig=kc, resource_args=["statefulsets", "-A"]),
|
||
asyncio.to_thread(kubectl_get_json, cluster_name=n, kubeconfig=kc, resource_args=["daemonsets", "-A"]),
|
||
asyncio.to_thread(kubectl_get_json, cluster_name=n, kubeconfig=kc, resource_args=["services", "-A"]),
|
||
asyncio.to_thread(
|
||
kubectl_get_json,
|
||
cluster_name=n,
|
||
kubeconfig=kc,
|
||
resource_args=["ingresses.networking.k8s.io", "-A"],
|
||
),
|
||
asyncio.to_thread(kubectl_get_json, cluster_name=n, kubeconfig=kc, resource_args=["namespaces"]),
|
||
asyncio.to_thread(kubectl_get_json, cluster_name=n, kubeconfig=kc, resource_args=["replicasets", "-A"]),
|
||
asyncio.to_thread(kubectl_get_json, cluster_name=n, kubeconfig=kc, resource_args=["jobs", "-A"]),
|
||
asyncio.to_thread(kubectl_get_json, cluster_name=n, kubeconfig=kc, resource_args=["cronjobs", "-A"]),
|
||
asyncio.to_thread(kubectl_get_json, cluster_name=n, kubeconfig=kc, resource_args=["pvc", "-A"]),
|
||
)
|
||
k8s_nodes = _k8s_list_from_kubectl(*n_res)
|
||
k8s_pods = _k8s_list_from_kubectl(*p_res)
|
||
k8s_deployments = _k8s_list_from_kubectl(*d_res)
|
||
k8s_statefulsets = _k8s_list_from_kubectl(*st_res)
|
||
k8s_daemonsets = _k8s_list_from_kubectl(*dm_res)
|
||
k8s_services = _k8s_list_from_kubectl(*svc_res)
|
||
k8s_ingresses = _k8s_list_from_kubectl(*ing_res)
|
||
k8s_namespaces = _k8s_list_from_kubectl(*ns_res)
|
||
k8s_replicasets = _k8s_list_from_kubectl(*rs_res)
|
||
k8s_jobs = _k8s_list_from_kubectl(*job_res)
|
||
k8s_cronjobs = _k8s_list_from_kubectl(*cj_res)
|
||
k8s_pvcs = _k8s_list_from_kubectl(*pvc_res)
|
||
|
||
meta = summary.get("meta")
|
||
return ClusterOverviewResponse(
|
||
cluster_name=n,
|
||
registered_in_kind=reg_in_kind,
|
||
kind_nodes_running=kind_running,
|
||
has_local_kubeconfig=bool(summary["has_local_kubeconfig"]),
|
||
has_provision_log=bool(summary.get("has_provision_log")),
|
||
meta=dict(meta) if isinstance(meta, dict) else {},
|
||
resources_error=res_err,
|
||
cluster_resources=block,
|
||
aggregate_resources=agg,
|
||
kubeconfig_error=kube_err,
|
||
nodes_rc=None,
|
||
nodes_output=None,
|
||
pods_rc=None,
|
||
pods_output=None,
|
||
deployments_rc=None,
|
||
deployments_output=None,
|
||
statefulsets_rc=None,
|
||
statefulsets_output=None,
|
||
daemonsets_rc=None,
|
||
daemonsets_output=None,
|
||
services_rc=None,
|
||
services_output=None,
|
||
ingresses_rc=None,
|
||
ingresses_output=None,
|
||
k8s_nodes=k8s_nodes,
|
||
k8s_pods=k8s_pods,
|
||
k8s_deployments=k8s_deployments,
|
||
k8s_statefulsets=k8s_statefulsets,
|
||
k8s_daemonsets=k8s_daemonsets,
|
||
k8s_services=k8s_services,
|
||
k8s_ingresses=k8s_ingresses,
|
||
k8s_namespaces=k8s_namespaces,
|
||
k8s_replicasets=k8s_replicasets,
|
||
k8s_jobs=k8s_jobs,
|
||
k8s_cronjobs=k8s_cronjobs,
|
||
k8s_pvcs=k8s_pvcs,
|
||
)
|
||
|
||
|
||
@router.post(
|
||
"/clusters/{name}/pods/restart",
|
||
response_model=PodRestartResponse,
|
||
summary="Рестарт пода (kubectl delete pod)",
|
||
)
|
||
async def restart_cluster_pod(name: str, body: PodRestartRequest) -> PodRestartResponse:
|
||
"""
|
||
Удаление пода: Deployment/RS/Job при необходимости создают новый Pod (типичный «рестарт»).
|
||
"""
|
||
if not validate_cluster_name(name):
|
||
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
|
||
n = name.strip()
|
||
ns = body.namespace.strip()
|
||
pod = body.pod.strip()
|
||
if not _k8s_ns_pod_valid(ns) or not _k8s_ns_pod_valid(pod):
|
||
raise HTTPException(status_code=400, detail="Некорректное имя namespace или пода")
|
||
kc = clusters_dir() / n / "kubeconfig"
|
||
if not kc.is_file():
|
||
raise HTTPException(status_code=404, detail="kubeconfig не найден")
|
||
rc, msg = await asyncio.to_thread(
|
||
kubectl_delete_pod,
|
||
cluster_name=n,
|
||
kubeconfig=kc,
|
||
namespace=ns,
|
||
pod_name=pod,
|
||
grace_period=30,
|
||
)
|
||
if rc != 0:
|
||
logger.warning("restart pod %s/%s в кластере %s: rc=%s %s", ns, pod, n, rc, msg[:500])
|
||
raise HTTPException(status_code=502, detail=msg[:4000] if msg else "kubectl delete pod завершился с ошибкой")
|
||
logger.info("Рестарт пода %s/%s в кластере %s", ns, pod, n)
|
||
return PodRestartResponse(cluster_name=n, namespace=ns, pod=pod, rc=rc, message=msg or "ok")
|
||
|
||
|
||
@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,
|
||
cluster_name=name.strip(),
|
||
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:
|
||
register_uncapped_job_log(job_id)
|
||
cname = body.name.strip()
|
||
try:
|
||
async with kind_cluster_lock:
|
||
await job_store.set_running(job_id)
|
||
try:
|
||
result = await asyncio.to_thread(
|
||
create_cluster_non_interactive,
|
||
name=cname,
|
||
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:
|
||
lines = take_uncapped_log_finalize(job_id)
|
||
rec = await job_store.get(job_id)
|
||
if rec is not None:
|
||
await asyncio.to_thread(
|
||
lambda: write_cluster_provision_log(
|
||
cluster_name=cname,
|
||
job_id=job_id,
|
||
job_kind="create_cluster",
|
||
status=rec.status,
|
||
message=rec.message,
|
||
lines=lines,
|
||
result=rec.result,
|
||
),
|
||
)
|
||
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 в списке)."""
|
||
register_uncapped_job_log(job_id)
|
||
n = name.strip()
|
||
try:
|
||
async with kind_cluster_lock:
|
||
await job_store.set_running(job_id)
|
||
try:
|
||
result = await asyncio.to_thread(
|
||
create_cluster_non_interactive,
|
||
name=n,
|
||
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:
|
||
lines = take_uncapped_log_finalize(job_id)
|
||
rec = await job_store.get(job_id)
|
||
if rec is not None:
|
||
await asyncio.to_thread(
|
||
lambda: write_cluster_provision_log(
|
||
cluster_name=n,
|
||
job_id=job_id,
|
||
job_kind="start_cluster",
|
||
status=rec.status,
|
||
message=rec.message,
|
||
lines=lines,
|
||
result=rec.result,
|
||
),
|
||
)
|
||
end_job_tracking(job_id)
|
||
|
||
|
||
async def _run_reapply_kind_config_start_job(
|
||
job_id: str, name: str, kubernetes_version_tag: str, workers: int
|
||
) -> None:
|
||
"""
|
||
После правки kind-config.yaml: удалить кластер из kind без удаления каталога данных,
|
||
затем ``kind create`` по сохранённому файлу (новый ``meta.json`` без флага reapply).
|
||
"""
|
||
register_uncapped_job_log(job_id)
|
||
n = name.strip()
|
||
try:
|
||
async with kind_cluster_lock:
|
||
await job_store.set_running(job_id)
|
||
try:
|
||
await asyncio.to_thread(delete_kind_cluster_keep_data, 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("reapply start job %s: delete_kind: %s", job_id, e)
|
||
return
|
||
except Exception as e:
|
||
await job_store.set_failed(job_id, f"{type(e).__name__}: {e}")
|
||
logger.exception("reapply start job %s: ошибка delete", job_id)
|
||
return
|
||
|
||
try:
|
||
result = await asyncio.to_thread(
|
||
create_cluster_non_interactive,
|
||
name=n,
|
||
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("reapply start job %s: create: %s", job_id, e)
|
||
return
|
||
except Exception as e:
|
||
await job_store.set_failed(job_id, f"{type(e).__name__}: {e}")
|
||
logger.exception("reapply start job %s: непредвиденная ошибка create", 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="Кластер пересоздан в kind по обновлённому kind-config.yaml",
|
||
)
|
||
logger.info("reapply start job %s: успех, кластер %s", job_id, result.cluster_name)
|
||
finally:
|
||
lines = take_uncapped_log_finalize(job_id)
|
||
rec = await job_store.get(job_id)
|
||
if rec is not None:
|
||
await asyncio.to_thread(
|
||
lambda: write_cluster_provision_log(
|
||
cluster_name=n,
|
||
job_id=job_id,
|
||
job_kind="start_cluster_reapply",
|
||
status=rec.status,
|
||
message=rec.message,
|
||
lines=lines,
|
||
result=rec.result,
|
||
),
|
||
)
|
||
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()
|
||
|
||
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
|
||
|
||
async with kind_cluster_lock:
|
||
in_kind = n in await asyncio.to_thread(list_registered_kind_clusters)
|
||
|
||
if in_kind:
|
||
if bool(meta.get("apply_kind_config_on_next_start")):
|
||
cfg = clusters_dir() / n / "kind-config.yaml"
|
||
if not cfg.is_file():
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="Нет файла clusters/<имя>/kind-config.yaml — восстановите конфиг или снимите флаг пересоздания в meta.json.",
|
||
)
|
||
rec = await job_store.create_job("start_cluster_reapply", cluster_name=n)
|
||
background_tasks.add_task(_run_reapply_kind_config_start_job, rec.job_id, n, ver_raw, w)
|
||
logger.info(
|
||
"Пересоздание кластера %s в kind по новому конфигу (фон), job_id=%s",
|
||
n,
|
||
rec.job_id,
|
||
)
|
||
return JSONResponse(
|
||
status_code=202,
|
||
content={
|
||
"job_id": rec.job_id,
|
||
"status": "queued",
|
||
"mode": "kind_config_reapply",
|
||
"message": "Удаление записи в kind и создание кластера по обновлённому kind-config.yaml; GET /api/v1/jobs/{job_id}",
|
||
},
|
||
)
|
||
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 — создайте кластер или восстановите конфиг.",
|
||
)
|
||
|
||
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)
|