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:
Sergey Antropoff
2026-04-04 05:58:11 +03:00
parent 74538423d5
commit 02f4c655b9
17 changed files with 618 additions and 181 deletions
+51
View File
@@ -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,