Веб-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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user