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