Веб-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"
+52 -1
View File
@@ -12,9 +12,11 @@ from __future__ import annotations
import asyncio
import logging
import os
import threading
import uuid
from dataclasses import dataclass
from collections import deque
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Literal
@@ -29,6 +31,46 @@ JobStatus = Literal["queued", "running", "success", "failed", "cancelled"]
_thread_lock = threading.Lock()
_cancel_events: dict[str, threading.Event] = {}
_progress: dict[str, tuple[str, int]] = {}
# Хвост логов для активных заданий (kind create и т.д.); после завершения копируется в JobRecord.log_lines
_job_log_deques: dict[str, deque[str]] = {}
def _max_job_log_lines() -> int:
raw = (os.environ.get("KIND_K8S_JOB_LOG_MAX_LINES") or "500").strip()
try:
return max(50, min(int(raw), 5000))
except ValueError:
return 500
def append_log_sync(job_id: str, line: str) -> None:
"""Добавить строку в журнал задания (вызывается из worker-thread во время долгих команд)."""
text = (line or "").rstrip()
if not text:
return
cap = _max_job_log_lines()
with _thread_lock:
if job_id not in _job_log_deques:
_job_log_deques[job_id] = deque(maxlen=cap)
_job_log_deques[job_id].append(text)
def get_logs_snapshot_sync(job_id: str) -> list[str]:
"""Снимок текущего журнала (для API во время running/queued)."""
with _thread_lock:
d = _job_log_deques.get(job_id)
return list(d) if d else []
def take_logs_finalize_sync(job_id: str) -> list[str]:
"""
Забрать журнал в список и удалить deque (после успеха/ошибки/отмены).
Вызывать перед или внутри обновления JobRecord.
"""
with _thread_lock:
d = _job_log_deques.pop(job_id, None)
return list(d) if d else []
def begin_job_tracking(job_id: str) -> None:
@@ -43,6 +85,7 @@ def end_job_tracking(job_id: str) -> None:
with _thread_lock:
_cancel_events.pop(job_id, None)
_progress.pop(job_id, None)
_job_log_deques.pop(job_id, None)
def set_progress_sync(job_id: str, stage: str, percent: int) -> None:
@@ -88,6 +131,8 @@ class JobRecord:
created_at_utc: str
message: str | None = None
result: dict[str, Any] | None = None
# Журнал после завершения (stdout/stderr kind create и этапы); пока задание активно — см. deque
log_lines: list[str] = field(default_factory=list)
class JobStore:
@@ -127,25 +172,31 @@ class JobStore:
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:
logs = take_logs_finalize_sync(job_id)
async with self._lock:
if job_id in self._jobs:
self._jobs[job_id].status = "success"
self._jobs[job_id].result = result
self._jobs[job_id].message = message
self._jobs[job_id].log_lines = logs
set_progress_sync(job_id, "Готово", 100)
async def set_failed(self, job_id: str, message: str) -> None:
logs = take_logs_finalize_sync(job_id)
async with self._lock:
if job_id in self._jobs:
self._jobs[job_id].status = "failed"
self._jobs[job_id].message = message
self._jobs[job_id].log_lines = logs
logger.warning("Задание %s завершилось ошибкой: %s", job_id, message)
async def set_cancelled(self, job_id: str, message: str = "Создание отменено пользователем") -> None:
logs = take_logs_finalize_sync(job_id)
async with self._lock:
if job_id in self._jobs:
self._jobs[job_id].status = "cancelled"
self._jobs[job_id].message = message
self._jobs[job_id].log_lines = logs
logger.info("Задание %s отменено: %s", job_id, message)
async def get(self, job_id: str) -> JobRecord | None:
+88
View File
@@ -0,0 +1,88 @@
"""Чтение README.md для API ``GET /api/v1/docs/readme`` и страницы «Документация».
Разметка Markdown преобразуется в браузере: ``/static/js/vendor/marked.min.js`` и
``purify.min.js`` (файлы входят в репозиторий, без CDN).
Путь к файлу: ``KIND_K8S_README_PATH`` или ``README.md`` в корне рядом с ``app/``;
в Docker-образе — ``/opt/kind-k8s/README.md``.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
from __future__ import annotations
import logging
import os
from pathlib import Path
logger = logging.getLogger("kind_k8s.readme_doc")
# app/core/readme_doc.py: parents[2] = корень репозитория (рядом с app/) или /opt/kind-k8s в образе
_LIB_FILE = Path(__file__).resolve()
def _candidates_without_env() -> list[Path]:
"""
Возможные пути к README без KIND_K8S_README_PATH.
Порядок: родитель каталога app/ (типично репозиторий), затем фиксированный путь образа.
В compose рекомендуется монтировать ./README.md → /opt/kind-k8s/README.md (см. docker-compose.yml).
"""
out: list[Path] = []
seen: set[Path] = set()
try:
repo_readme = (_LIB_FILE.parents[2] / "README.md").resolve()
if repo_readme not in seen:
seen.add(repo_readme)
out.append(repo_readme)
except (IndexError, OSError):
pass
fixed = Path("/opt/kind-k8s/README.md")
try:
fixed_r = fixed.resolve()
if fixed_r not in seen:
seen.add(fixed_r)
out.append(fixed_r)
except OSError:
out.append(fixed)
return out
def get_readme_path() -> Path | None:
"""Первый существующий путь к README или ``None``."""
raw = (os.environ.get("KIND_K8S_README_PATH") or "").strip()
if raw:
p = Path(raw).expanduser().resolve()
return p if p.is_file() else None
for p in _candidates_without_env():
if p.is_file():
return p
return None
def read_readme_text() -> str:
"""Прочитать README как UTF-8; ``FileNotFoundError`` если файла нет."""
raw = (os.environ.get("KIND_K8S_README_PATH") or "").strip()
if raw:
p = Path(raw).expanduser().resolve()
if not p.is_file():
logger.warning("KIND_K8S_README_PATH: файл не найден: %s", p)
raise FileNotFoundError(str(p))
text = p.read_text(encoding="utf-8")
logger.debug("README из KIND_K8S_README_PATH, %s символов", len(text))
return text
for p in _candidates_without_env():
if p.is_file():
text = p.read_text(encoding="utf-8")
logger.info("README прочитан: %s (%s символов)", p, len(text))
return text
logger.warning(
"README.md не найден. Проверены пути: %s. "
"В Docker Compose добавьте монтирование ./README.md:/opt/kind-k8s/README.md "
"или пересоберите образ (COPY README.md в Dockerfile).",
[str(x) for x in _candidates_without_env()],
)
raise FileNotFoundError("README.md")