UI: автообновление, прогресс, отмена; порт 8080; меню-пилюли и отдельные окна
- Порт хоста по умолчанию 8080 (Chrome ERR_UNSAFE_PORT на 6000); compose, setup, config, README.
- Дашборд: одна hero-карточка, прогресс создания, POST /jobs/{id}/cancel, JobView progress_*.
- job_store: отмена и прогресс (thread-safe); cluster_lifecycle этапы и откат.
- Навигация: стили nav-pill; Swagger/ReDoc/Health через window.open.
- main.py: TemplateResponse(request, …) для Starlette.
- Документация: README, app/docs (api_routes, README); Makefile ps; .gitignore clusters.
This commit is contained in:
@@ -24,6 +24,22 @@ from kubeconfig_patch import patch_kubeconfig_server_for_host, should_patch_afte
|
||||
|
||||
logger = logging.getLogger("kind_k8s.cluster_lifecycle")
|
||||
|
||||
|
||||
def _rollback_after_cancel(*, cluster_name: str, out_dir: Path) -> None:
|
||||
"""Удалить кластер kind и каталог данных после запроса отмены (best-effort)."""
|
||||
logger.info("Откат после отмены: kind delete «%s»", cluster_name)
|
||||
subprocess.run(
|
||||
["kind", "delete", "cluster", "--name", cluster_name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if out_dir.is_dir():
|
||||
try:
|
||||
shutil.rmtree(out_dir)
|
||||
logger.info("Удалён каталог %s", out_dir)
|
||||
except OSError as e:
|
||||
logger.warning("Не удалось удалить %s: %s", out_dir, e)
|
||||
|
||||
# Имя кластера: поддомен DNS (RFC 1123)
|
||||
_NAME_RE = re.compile(r"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$")
|
||||
|
||||
@@ -160,12 +176,24 @@ def create_cluster_non_interactive(
|
||||
name: str,
|
||||
kubernetes_version_tag: str,
|
||||
workers: int,
|
||||
job_id: str | None = None,
|
||||
) -> CreateClusterResult:
|
||||
"""
|
||||
Создать кластер kind без диалогов.
|
||||
|
||||
``kubernetes_version_tag`` — тег kindest/node (например ``v1.29.4``), см. ``normalize_tag_v_prefix``.
|
||||
|
||||
``job_id`` — если задан, обновляется прогресс и проверяется отмена (см. ``job_store``).
|
||||
"""
|
||||
from core import job_store as _job_store
|
||||
|
||||
def _progress(stage: str, pct: int) -> None:
|
||||
if job_id:
|
||||
_job_store.set_progress_sync(job_id, stage, pct)
|
||||
|
||||
def _cancelled() -> bool:
|
||||
return bool(job_id and _job_store.is_cancelled_sync(job_id))
|
||||
|
||||
if not shutil.which("kind"):
|
||||
raise KindClusterError("Не найден бинарник kind в PATH.", exit_code=127)
|
||||
|
||||
@@ -193,19 +221,40 @@ def create_cluster_non_interactive(
|
||||
yaml_text = build_kind_config_yaml(node_image=node_image, workers=workers)
|
||||
cfg_path.write_text(yaml_text, encoding="utf-8")
|
||||
|
||||
_progress("Подготовка каталога и kind-config", 12)
|
||||
if _cancelled():
|
||||
_rollback_after_cancel(cluster_name=name, out_dir=out_dir)
|
||||
raise KindClusterError("Создание отменено пользователем")
|
||||
|
||||
logger.info("Создание кластера «%s», образ %s, workers=%s", name, node_image, workers)
|
||||
_progress("kind create cluster (скачивание образов и подъём нод — может занять несколько минут)", 28)
|
||||
_run_checked(["kind", "create", "cluster", "--name", name, "--config", str(cfg_path)])
|
||||
|
||||
if _cancelled():
|
||||
_rollback_after_cancel(cluster_name=name, out_dir=out_dir)
|
||||
raise KindClusterError("Создание отменено пользователем")
|
||||
|
||||
_progress("Сохранение kubeconfig", 58)
|
||||
kube = _run_capture_checked(["kind", "get", "kubeconfig", "--name", name])
|
||||
kube_path.write_text(kube, encoding="utf-8")
|
||||
|
||||
if _cancelled():
|
||||
_rollback_after_cancel(cluster_name=name, out_dir=out_dir)
|
||||
raise KindClusterError("Создание отменено пользователем")
|
||||
|
||||
patched = False
|
||||
if should_patch_after_create():
|
||||
_progress("Патч kubeconfig для доступа к API с хоста", 72)
|
||||
patched = patch_kubeconfig_server_for_host(cluster_name=name, kube_path=kube_path)
|
||||
|
||||
if _cancelled():
|
||||
_rollback_after_cancel(cluster_name=name, out_dir=out_dir)
|
||||
raise KindClusterError("Создание отменено пользователем")
|
||||
|
||||
nodes_ready: bool | None = None
|
||||
nodes_msg: str | None = None
|
||||
if _wait_nodes_enabled():
|
||||
_progress("Ожидание готовности нод (kubectl wait …)", 82)
|
||||
ok, msg = wait_nodes_ready(kubeconfig_path=kube_path)
|
||||
nodes_ready = ok
|
||||
nodes_msg = msg
|
||||
@@ -229,6 +278,8 @@ def create_cluster_non_interactive(
|
||||
}
|
||||
meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
_progress("Финализация", 95)
|
||||
|
||||
return CreateClusterResult(
|
||||
cluster_name=name,
|
||||
ver_tag=ver_tag,
|
||||
|
||||
+2
-1
@@ -26,7 +26,8 @@ class Settings(BaseSettings):
|
||||
)
|
||||
|
||||
kind_k8s_web_host: str = Field(default="0.0.0.0", validation_alias="KIND_K8S_WEB_HOST")
|
||||
kind_k8s_web_port: int = Field(default=6000, validation_alias="KIND_K8S_WEB_PORT")
|
||||
# Согласовано с дефолтом compose на хосте (8080); в контейнере процесс слушает 6000 через run_uvicorn.sh.
|
||||
kind_k8s_web_port: int = Field(default=8080, validation_alias="KIND_K8S_WEB_PORT")
|
||||
|
||||
# Заголовок в OpenAPI / HTML; пустая строка из compose не должна ломать FastAPI.
|
||||
app_title: str = Field(default=_DEFAULT_TITLE, validation_alias="KIND_K8S_APP_TITLE")
|
||||
|
||||
+66
-1
@@ -2,6 +2,8 @@
|
||||
|
||||
При перезапуске контейнера история заданий обнуляется — это ожидаемо для dev-среды.
|
||||
|
||||
Потокобезопасные флаги отмены и прогресс (для worker-thread) — через ``threading.Lock``.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
@@ -10,6 +12,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import threading
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
@@ -20,7 +23,58 @@ logger = logging.getLogger("kind_k8s.job_store")
|
||||
# Лимит записей в памяти (dev-инструмент; старые задания вытесняются)
|
||||
_MAX_JOBS = 200
|
||||
|
||||
JobStatus = Literal["queued", "running", "success", "failed"]
|
||||
JobStatus = Literal["queued", "running", "success", "failed", "cancelled"]
|
||||
|
||||
# --- Синхронное сопровождение задания (worker-thread и HTTP отмена) ---
|
||||
_thread_lock = threading.Lock()
|
||||
_cancel_events: dict[str, threading.Event] = {}
|
||||
_progress: dict[str, tuple[str, int]] = {}
|
||||
|
||||
|
||||
def begin_job_tracking(job_id: str) -> None:
|
||||
"""Зарегистрировать отмену/прогресс для нового job_id (вызывать при создании задания)."""
|
||||
with _thread_lock:
|
||||
_cancel_events[job_id] = threading.Event()
|
||||
_progress[job_id] = ("В очереди", 0)
|
||||
|
||||
|
||||
def end_job_tracking(job_id: str) -> None:
|
||||
"""Очистить служебные структуры после завершения задания."""
|
||||
with _thread_lock:
|
||||
_cancel_events.pop(job_id, None)
|
||||
_progress.pop(job_id, None)
|
||||
|
||||
|
||||
def set_progress_sync(job_id: str, stage: str, percent: int) -> None:
|
||||
"""Обновить текст этапа и процент (0–100) из worker-thread."""
|
||||
pct = max(0, min(100, int(percent)))
|
||||
with _thread_lock:
|
||||
if job_id in _progress:
|
||||
_progress[job_id] = (stage, pct)
|
||||
|
||||
|
||||
def get_progress_sync(job_id: str) -> tuple[str, int] | None:
|
||||
"""Снимок прогресса для ответа API."""
|
||||
with _thread_lock:
|
||||
return _progress.get(job_id)
|
||||
|
||||
|
||||
def request_cancel_sync(job_id: str) -> bool:
|
||||
"""Запросить отмену. Вернуть False, если job_id не отслеживается."""
|
||||
with _thread_lock:
|
||||
ev = _cancel_events.get(job_id)
|
||||
if ev is None:
|
||||
return False
|
||||
ev.set()
|
||||
logger.info("Запрошена отмена задания %s", job_id)
|
||||
return True
|
||||
|
||||
|
||||
def is_cancelled_sync(job_id: str) -> bool:
|
||||
"""Проверка из worker-thread между этапами создания кластера."""
|
||||
with _thread_lock:
|
||||
ev = _cancel_events.get(job_id)
|
||||
return bool(ev and ev.is_set())
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -58,8 +112,10 @@ class JobStore:
|
||||
self._jobs[jid] = rec
|
||||
while len(self._jobs) > _MAX_JOBS:
|
||||
oldest_id = min(self._jobs, key=lambda k: self._jobs[k].created_at_utc)
|
||||
end_job_tracking(oldest_id)
|
||||
del self._jobs[oldest_id]
|
||||
logger.debug("Вытеснено старое задание из хранилища: %s", oldest_id)
|
||||
begin_job_tracking(jid)
|
||||
logger.info("Создано задание %s kind=%s cluster=%s", jid, kind, cluster_name)
|
||||
return rec
|
||||
|
||||
@@ -68,6 +124,7 @@ class JobStore:
|
||||
if job_id in self._jobs:
|
||||
self._jobs[job_id].status = "running"
|
||||
self._jobs[job_id].message = None
|
||||
set_progress_sync(job_id, "Запуск создания кластера…", 5)
|
||||
|
||||
async def set_success(self, job_id: str, *, result: dict[str, Any] | None = None, message: str | None = None) -> None:
|
||||
async with self._lock:
|
||||
@@ -75,6 +132,7 @@ class JobStore:
|
||||
self._jobs[job_id].status = "success"
|
||||
self._jobs[job_id].result = result
|
||||
self._jobs[job_id].message = message
|
||||
set_progress_sync(job_id, "Готово", 100)
|
||||
|
||||
async def set_failed(self, job_id: str, message: str) -> None:
|
||||
async with self._lock:
|
||||
@@ -83,6 +141,13 @@ class JobStore:
|
||||
self._jobs[job_id].message = message
|
||||
logger.warning("Задание %s завершилось ошибкой: %s", job_id, message)
|
||||
|
||||
async def set_cancelled(self, job_id: str, message: str = "Создание отменено пользователем") -> None:
|
||||
async with self._lock:
|
||||
if job_id in self._jobs:
|
||||
self._jobs[job_id].status = "cancelled"
|
||||
self._jobs[job_id].message = message
|
||||
logger.info("Задание %s отменено: %s", job_id, message)
|
||||
|
||||
async def get(self, job_id: str) -> JobRecord | None:
|
||||
async with self._lock:
|
||||
return self._jobs.get(job_id)
|
||||
|
||||
Reference in New Issue
Block a user