Files
KindClustersDashboard/app/core/cluster_lifecycle.py
Sergey Antropoff c1e867a01f Веб-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
2026-04-04 06:21:00 +03:00

601 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Синхронные операции с kind: конфиг, создание, удаление, ожидание готовности нод.
Используются интерактивными CLI-скриптами и веб-слоем (через asyncio.to_thread / executor).
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
from __future__ import annotations
import json
import logging
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
from kind_k8s_paths import clusters_dir, data_root
from kindest_node_tags import normalize_tag_v_prefix
from kubeconfig_patch import patch_kubeconfig_server_for_host, should_patch_after_create
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])?$")
class KindClusterError(Exception):
"""Ошибка операции kind (создание, удаление и т.д.)."""
def __init__(self, message: str, *, exit_code: int = 1) -> None:
super().__init__(message)
self.exit_code = exit_code
def validate_cluster_name(name: str) -> bool:
"""Проверить имя кластера (DNS-подмножество, длина ≤ 63)."""
if not name or len(name) > 63:
return False
return bool(_NAME_RE.match(name))
def normalize_k8s_version(raw: str) -> str:
"""Превратить ввод в тег образа kindest/node (например 1.29.4 → v1.29.4)."""
s = raw.strip()
if not s:
return "v1.29.4"
s = s.lower().removeprefix("v")
return f"v{s}"
def build_kind_config_yaml(*, node_image: str, workers: int) -> str:
"""YAML для kind: один control-plane + workers."""
lines = [
"kind: Cluster",
"apiVersion: kind.x-k8s.io/v1alpha4",
"nodes:",
" - role: control-plane",
f" image: {node_image}",
]
for _ in range(workers):
lines.append(" - role: worker")
lines.append(f" image: {node_image}")
return "\n".join(lines) + "\n"
def list_registered_kind_clusters() -> list[str]:
"""Имена кластеров kind; при ошибке — пустой список."""
p = subprocess.run(["kind", "get", "clusters"], capture_output=True, text=True)
if p.returncode != 0:
logger.info("kind get clusters завершился с кодом %s", p.returncode)
return []
lines = [x.strip() for x in (p.stdout or "").splitlines() if x.strip()]
return [x for x in lines if "no kind" not in x.lower()]
def _in_container() -> bool:
return os.environ.get("KIND_K8S_IN_CONTAINER", "").strip() == "1"
def _run_checked(cmd: list[str], *, cwd: Path | None = None) -> None:
"""Выполнить команду; при ошибке — KindClusterError с текстом stderr."""
logger.info("Выполнение: %s", " ".join(cmd))
p = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)
if p.returncode != 0:
err = (p.stderr or p.stdout or "").strip()
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:
err = (p.stderr or p.stdout or "").strip()
raise KindClusterError(err or "команда не удалась", exit_code=p.returncode)
return (p.stdout or "").strip()
def _wait_nodes_enabled() -> bool:
raw = (os.environ.get("KIND_K8S_WAIT_NODES") or "1").strip().lower()
return raw in ("1", "true", "yes", "да")
def _wait_nodes_timeout_sec() -> int:
raw = (os.environ.get("KIND_K8S_WAIT_NODES_TIMEOUT_SEC") or "300").strip()
try:
return max(30, min(int(raw), 3600))
except ValueError:
return 300
def wait_nodes_ready(*, kubeconfig_path: Path, timeout_sec: int | None = None) -> tuple[bool, str]:
"""
Дождаться condition=Ready для всех нод через kubectl wait.
Возвращает (успех, сообщение для лога/UI).
"""
if timeout_sec is None:
timeout_sec = _wait_nodes_timeout_sec()
t = f"{timeout_sec}s"
cmd = [
"kubectl",
"--kubeconfig",
str(kubeconfig_path),
"wait",
"--for=condition=Ready",
"nodes",
"--all",
f"--timeout={t}",
]
logger.info("Ожидание готовности нод: timeout=%s", t)
p = subprocess.run(cmd, capture_output=True, text=True)
out = (p.stdout or "").strip()
err = (p.stderr or "").strip()
if p.returncode == 0:
return True, out or "ноды в состоянии Ready"
msg = err or out or f"код выхода {p.returncode}"
return False, msg
@dataclass(frozen=True)
class CreateClusterResult:
"""Результат успешного создания кластера."""
cluster_name: str
ver_tag: str
node_image: str
workers: int
kubeconfig_path: Path
meta_path: Path
kubeconfig_patched_for_host: bool
nodes_ready: bool | None
nodes_ready_message: str | None
def create_cluster_non_interactive(
*,
name: str,
kubernetes_version_tag: str,
workers: int,
job_id: str | None = None,
use_existing_config: bool = False,
) -> CreateClusterResult:
"""
Создать кластер kind без диалогов.
``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))
if not shutil.which("kind"):
raise KindClusterError("Не найден бинарник kind в PATH.", exit_code=127)
if not validate_cluster_name(name):
raise KindClusterError("Некорректное имя кластера (a-z0-9-, не длиннее 63).")
existing = list_registered_kind_clusters()
if name in existing:
raise KindClusterError(f"Кластер «{name}» уже существует в kind.")
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)
node_image = f"kindest/node:{ver_tag}"
root = data_root()
cdir = clusters_dir()
out_dir = cdir / name
out_dir.mkdir(parents=True, exist_ok=True)
cfg_path = out_dir / "kind-config.yaml"
kube_path = out_dir / "kubeconfig"
meta_path = out_dir / "meta.json"
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)
if _cancelled():
_rollback_after_cancel(cluster_name=name, out_dir=out_dir)
raise KindClusterError("Создание отменено пользователем")
logger.info(
"Создание кластера «%s», образ %s, workers=%s, existing_cfg=%s",
name,
node_image,
workers,
use_existing_config,
)
_progress("kind create cluster (скачивание образов и подъём нод — может занять несколько минут)", 28)
_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)
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
if ok:
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": 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)),
"kubeconfig_patched_for_host": patched,
"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")
_progress("Финализация", 95)
return CreateClusterResult(
cluster_name=name,
ver_tag=ver_tag,
node_image=node_image,
workers=worker_nodes_meta,
kubeconfig_path=kube_path,
meta_path=meta_path,
kubeconfig_patched_for_host=patched,
nodes_ready=nodes_ready,
nodes_ready_message=nodes_msg,
)
def delete_kind_cluster_and_data(*, name: str, log_to_stdout: bool = False) -> tuple[bool, str]:
"""
``kind delete cluster`` и удаление ``clusters/<имя>/``.
Первый элемент — успешность ``kind delete``; второй — текстовое резюме всего шага.
``log_to_stdout=True`` — не перехватывать stdout/stderr kind (удобно в интерактивном CLI).
"""
if not shutil.which("kind"):
raise KindClusterError("Не найден kind в PATH.", exit_code=127)
cdir = clusters_dir()
parts: list[str] = []
kind_ok = True
if log_to_stdout:
p = subprocess.run(["kind", "delete", "cluster", "--name", name])
if p.returncode != 0:
parts.append(f"kind delete: код {p.returncode}")
logger.warning("kind delete cluster %s: код %s", name, p.returncode)
kind_ok = False
else:
parts.append("kind delete: OK")
else:
p = subprocess.run(
["kind", "delete", "cluster", "--name", name],
capture_output=True,
text=True,
)
if p.returncode != 0:
err = (p.stderr or p.stdout or "").strip()
parts.append(f"kind delete: ошибка ({err or p.returncode})")
logger.warning("kind delete cluster %s: %s", name, err)
kind_ok = False
else:
parts.append("kind delete: OK")
d = cdir / name
if d.is_dir():
shutil.rmtree(d)
parts.append(f"удалена папка {d}")
else:
parts.append("локальная папка отсутствовала")
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"
if not p.is_file():
return None
try:
raw = json.loads(p.read_text(encoding="utf-8"))
if isinstance(raw, dict):
return raw
except (OSError, json.JSONDecodeError) as e:
logger.debug("meta.json не прочитан: %s", e)
return None
def _container_cli_bin() -> str:
"""Имя CLI к сокету (docker / podman), как в kubeconfig_patch."""
return (os.environ.get("CONTAINER_CLI") or "docker").strip() or "docker"
def container_engine_ping(*, timeout_sec: float = 12.0) -> tuple[bool, str, str]:
"""
Проверить доступ к движку контейнеров (``docker info`` / ``podman info``).
Возвращает (успех, краткое сообщение или stderr, имя CLI).
"""
cli = _container_cli_bin()
if not shutil.which(cli):
return False, f"«{cli}» не найден в PATH", cli
try:
p = subprocess.run(
[cli, "info"],
capture_output=True,
text=True,
timeout=timeout_sec,
)
except subprocess.TimeoutExpired:
logger.warning("%s info: таймаут %s с", cli, timeout_sec)
return False, f"таймаут {timeout_sec} с", cli
if p.returncode == 0:
return True, "OK", cli
err = (p.stderr or p.stdout or "").strip() or f"код {p.returncode}"
logger.info("%s info неуспешно: %s", cli, err[:200])
return False, err[:800], cli
def kubectl_nodes_wide(*, kubeconfig: str | Path) -> tuple[int, str]:
"""``kubectl get nodes -o wide``; возвращает (код, объединённый вывод)."""
p = subprocess.run(
[
"kubectl",
"--kubeconfig",
str(kubeconfig),
"get",
"nodes",
"-o",
"wide",
"--request-timeout=15s",
],
capture_output=True,
text=True,
)
out = (p.stdout or "").strip()
err = (p.stderr or "").strip()
msg = out if out else err
return p.returncode, msg
def kubectl_pods_all_namespaces(*, kubeconfig: str | Path) -> tuple[int, str]:
"""``kubectl get pods -A``; сводка подов по кластеру."""
p = subprocess.run(
[
"kubectl",
"--kubeconfig",
str(kubeconfig),
"get",
"pods",
"-A",
"--request-timeout=20s",
],
capture_output=True,
text=True,
)
out = (p.stdout or "").strip()
err = (p.stderr or "").strip()
msg = out if out else err
return p.returncode, msg
def cluster_summary_for_api(name: str) -> dict[str, object]:
"""Сводка по кластеру для JSON API (без блокирующих долгих вызовов)."""
meta = read_meta_json(name) or {}
saved_kc = clusters_dir() / name / "kubeconfig"
in_kind = name in list_registered_kind_clusters()
out: dict[str, object] = {
"name": name,
"registered_in_kind": in_kind,
"has_local_kubeconfig": saved_kc.is_file(),
"kubeconfig_path": str(saved_kc) if saved_kc.is_file() else None,
"meta": meta,
}
return out