Веб-UI: логи kind create, старт/стоп кластеров, документация README

- Потоковые логи в job_store и UI; kind create через Popen с построчным выводом
- POST /clusters/{name}/start|stop; create по сохранённому kind-config.yaml
- Страница /documentation: GET /api/v1/docs/readme, marked+DOMPurify из static/vendor
- Иконки действий, плавающие подсказки, модалка подтверждения вместо confirm
- Makefile: make docker|podman rebuild; compose: монтирование README.md
- Dockerfile: COPY README.md; readme_doc: несколько путей к README

Автор: Сергей Антропов — https://devops.org.ru
This commit is contained in:
Sergey Antropoff
2026-04-04 06:21:00 +03:00
parent 02f4c655b9
commit c1e867a01f
23 changed files with 1689 additions and 180 deletions
+165 -8
View File
@@ -14,6 +14,7 @@ import os
import re
import shutil
import subprocess
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
@@ -106,6 +107,42 @@ def _run_checked(cmd: list[str], *, cwd: Path | None = None) -> None:
raise KindClusterError(f"Команда завершилась с кодом {p.returncode}: {err}", exit_code=p.returncode)
def _run_checked_stream(
cmd: list[str],
*,
cwd: Path | None = None,
on_line: Callable[[str], None] | None = None,
) -> None:
"""
Выполнить команду с построчным выводом в колбэк (stdout+stderr объединены).
Нужен для ``kind create cluster``: pull образов и подъём нод видны в UI по опросу job.
"""
logger.info("Выполнение (поток): %s", " ".join(cmd))
p = subprocess.Popen(
cmd,
cwd=cwd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
)
if p.stdout is None:
raise KindClusterError("Не удалось открыть stdout процесса", exit_code=1)
try:
for raw in p.stdout:
line = raw.rstrip("\n\r")
if on_line and line:
on_line(line)
if line:
logger.debug("stream: %s", line[:800])
rc = p.wait()
finally:
p.stdout.close()
if rc != 0:
raise KindClusterError(f"Команда завершилась с кодом {rc} (см. журнал задания выше)", exit_code=rc)
def _run_capture_checked(cmd: list[str]) -> str:
p = subprocess.run(cmd, capture_output=True, text=True)
if p.returncode != 0:
@@ -177,6 +214,7 @@ def create_cluster_non_interactive(
kubernetes_version_tag: str,
workers: int,
job_id: str | None = None,
use_existing_config: bool = False,
) -> CreateClusterResult:
"""
Создать кластер kind без диалогов.
@@ -184,12 +222,20 @@ def create_cluster_non_interactive(
``kubernetes_version_tag`` — тег kindest/node (например ``v1.29.4``), см. ``normalize_tag_v_prefix``.
``job_id`` — если задан, обновляется прогресс и проверяется отмена (см. ``job_store``).
``use_existing_config=True`` — не перезаписывать ``kind-config.yaml``, поднять кластер по уже
сохранённому файлу (каталог ``clusters/<имя>/`` должен существовать).
"""
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)
_job_store.append_log_sync(job_id, f"[{pct}%] {stage}")
def _log(line: str) -> None:
if job_id:
_job_store.append_log_sync(job_id, line)
def _cancelled() -> bool:
return bool(job_id and _job_store.is_cancelled_sync(job_id))
@@ -204,7 +250,7 @@ def create_cluster_non_interactive(
if name in existing:
raise KindClusterError(f"Кластер «{name}» уже существует в kind.")
if workers < 0 or workers > 20:
if not use_existing_config and (workers < 0 or workers > 20):
raise KindClusterError("Количество worker-нод должно быть от 0 до 20.")
ver_tag = normalize_tag_v_prefix(kubernetes_version_tag)
@@ -218,17 +264,39 @@ def create_cluster_non_interactive(
kube_path = out_dir / "kubeconfig"
meta_path = out_dir / "meta.json"
yaml_text = build_kind_config_yaml(node_image=node_image, workers=workers)
cfg_path.write_text(yaml_text, encoding="utf-8")
prev_meta_for_workers: dict[str, object] = {}
if use_existing_config:
if not cfg_path.is_file():
raise KindClusterError(f"Нет сохранённого kind-config.yaml: {cfg_path}")
prev = read_meta_json(name) or {}
prev_meta_for_workers = prev
if prev.get("node_image"):
node_image = str(prev["node_image"])
if prev.get("kubernetes_version_tag"):
ver_tag = str(prev["kubernetes_version_tag"])
_progress("Используется существующий kind-config.yaml", 10)
else:
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)
_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)
logger.info(
"Создание кластера «%s», образ %s, workers=%s, existing_cfg=%s",
name,
node_image,
workers,
use_existing_config,
)
_progress("kind create cluster (скачивание образов и подъём нод — может занять несколько минут)", 28)
_run_checked(["kind", "create", "cluster", "--name", name, "--config", str(cfg_path)])
_log("--- kind create cluster ---")
_run_checked_stream(
["kind", "create", "cluster", "--name", name, "--config", str(cfg_path)],
on_line=_log,
)
if _cancelled():
_rollback_after_cancel(cluster_name=name, out_dir=out_dir)
@@ -262,12 +330,22 @@ def create_cluster_non_interactive(
logger.info("Ноды готовы: %s", msg)
else:
logger.warning("Ожидание нод не завершилось успешно: %s", msg)
_log(f"kubectl wait nodes: {msg}"[:4000])
worker_nodes_meta = workers
if use_existing_config:
prev_w = prev_meta_for_workers.get("worker_nodes")
if prev_w is not None:
try:
worker_nodes_meta = int(prev_w)
except (TypeError, ValueError):
worker_nodes_meta = workers
meta = {
"cluster_name": name,
"kubernetes_version_tag": ver_tag,
"node_image": node_image,
"worker_nodes": workers,
"worker_nodes": worker_nodes_meta,
"created_at_utc": datetime.now(timezone.utc).isoformat(),
"kind_config_path": str(cfg_path.relative_to(root)),
"kubeconfig_path": str(kube_path.relative_to(root)),
@@ -275,6 +353,7 @@ def create_cluster_non_interactive(
"created_via_container": _in_container(),
"nodes_ready_after_create": nodes_ready,
"nodes_ready_message": nodes_msg,
"provisioned_from_existing_config": use_existing_config,
}
meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
@@ -284,7 +363,7 @@ def create_cluster_non_interactive(
cluster_name=name,
ver_tag=ver_tag,
node_image=node_image,
workers=workers,
workers=worker_nodes_meta,
kubeconfig_path=kube_path,
meta_path=meta_path,
kubeconfig_patched_for_host=patched,
@@ -340,6 +419,84 @@ def delete_kind_cluster_and_data(*, name: str, log_to_stdout: bool = False) -> t
return kind_ok, "; ".join(parts)
def _sort_kind_node_containers(names: list[str]) -> list[str]:
"""Сначала control-plane, затем остальные — удобнее для ``docker start``."""
def sort_key(n: str) -> tuple[int, str]:
if n.endswith("-control-plane"):
return (0, n)
return (1, n)
return sorted(names, key=sort_key)
def list_kind_cluster_container_names(*, cluster_name: str) -> list[str]:
"""Имена контейнеров узлов kind (все с префиксом ``<имя>-``)."""
cli = _container_cli_bin()
if not shutil.which(cli):
raise KindClusterError(f"CLI контейнеров «{cli}» не найден в PATH.", exit_code=127)
p = subprocess.run(
[cli, "ps", "-a", "--format", "{{.Names}}"],
capture_output=True,
text=True,
)
if p.returncode != 0:
err = (p.stderr or p.stdout or "").strip()
raise KindClusterError(f"{cli} ps: {err}", exit_code=p.returncode)
prefix = f"{cluster_name}-"
raw = [n.strip() for n in (p.stdout or "").splitlines() if n.strip()]
matched = [n for n in raw if n.startswith(prefix)]
return _sort_kind_node_containers(matched)
def stop_kind_cluster_containers(*, name: str) -> tuple[bool, str]:
"""
Остановить контейнеры узлов (``docker stop`` / ``podman stop``).
Запись kind о кластере сохраняется; позже можно вызвать ``start_kind_cluster_containers``.
"""
names = list_kind_cluster_container_names(cluster_name=name)
if not names:
return True, "Нет контейнеров с префиксом «%s-» (уже остановлены или удалены)" % name
cli = _container_cli_bin()
ok_all = True
parts: list[str] = []
for ctr in names:
p = subprocess.run([cli, "stop", ctr], capture_output=True, text=True)
if p.returncode != 0:
ok_all = False
err = (p.stderr or p.stdout or "").strip() or str(p.returncode)
parts.append(f"{ctr}: ошибка ({err})")
logger.warning("%s stop %s: %s", cli, ctr, err)
else:
parts.append(f"{ctr}: OK")
return ok_all, "; ".join(parts)
def start_kind_cluster_containers(*, name: str) -> tuple[bool, str]:
"""Запустить контейнеры узлов kind (после ``stop`` или рестарта движка)."""
names = list_kind_cluster_container_names(cluster_name=name)
if not names:
return False, (
"Не найдены контейнеры «%s-*». Если кластера нет в kind — используйте «Старт» "
"из UI (создание по сохранённому kind-config.yaml) или создайте кластер заново."
% name
)
cli = _container_cli_bin()
ok_all = True
parts: list[str] = []
for ctr in names:
p = subprocess.run([cli, "start", ctr], capture_output=True, text=True)
if p.returncode != 0:
ok_all = False
err = (p.stderr or p.stdout or "").strip() or str(p.returncode)
parts.append(f"{ctr}: ошибка ({err})")
logger.warning("%s start %s: %s", cli, ctr, err)
else:
parts.append(f"{ctr}: OK")
return ok_all, "; ".join(parts)
def read_meta_json(cluster_name: str) -> dict[str, object] | None:
"""Прочитать ``clusters/<имя>/meta.json`` если есть."""
p = clusters_dir() / cluster_name / "meta.json"