- Потоковые логи в 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
601 lines
22 KiB
Python
601 lines
22 KiB
Python
"""Синхронные операции с 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
|