Журнал: пагинация 30 записей на страницу, API offset/total_pages

- job_journal: collect_recent_journal_entries_page_sync(limit, offset)
- GET /api/v1/journal/recent: limit по умолчанию 30, offset, total, page, total_pages
- journal.html/js: навигация Первая/Назад/номера/Вперёд/Последняя, стили
- app/docs/api_routes.md: описание query и пример ответа
- Прочие изменения UI/API (аддоны, helm, job_journal в кластерах) в том же коммите
This commit is contained in:
Sergey Antropoff
2026-04-04 11:16:43 +03:00
parent 52538d9816
commit 17f6233fd7
22 changed files with 2811 additions and 4 deletions
+512
View File
@@ -0,0 +1,512 @@
"""Установка и удаление типовых Helm-чартов в выбранном кластере (kubeconfig с диска).
Используются бинарники ``helm`` и ``kubectl`` из PATH; ``KUBECONFIG`` — путь из
:func:`kubeconfig_patch.kubeconfig_path_for_container_kubectl` (как для kubectl в приложении).
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
from __future__ import annotations
import json
import logging
import os
import shutil
import subprocess
import tempfile
import threading
import time
from pathlib import Path
from typing import Any
import yaml
from core.cluster_lifecycle import validate_cluster_name
from kind_k8s_paths import clusters_dir
from kubeconfig_patch import kubeconfig_path_for_container_kubectl
logger = logging.getLogger("kind_k8s.helm_addons")
# Официальные репозитории чартов (имя → URL).
HELM_REPOS: dict[str, str] = {
"ingress-nginx": "https://kubernetes.github.io/ingress-nginx",
"prometheus-community": "https://prometheus-community.github.io/helm-charts",
"metrics-server": "https://kubernetes-sigs.github.io/metrics-server/",
"istio": "https://istio-release.storage.googleapis.com/charts",
"kiali": "https://kiali.org/helm-charts",
}
# Ссылки для ``helm search repo <ref> --versions`` (ключи совпадают с полями API версий чартов).
HELM_ADDON_CHART_REFS: dict[str, str] = {
"ingress_nginx": "ingress-nginx/ingress-nginx",
"kube_prometheus_stack": "prometheus-community/kube-prometheus-stack",
"metrics_server": "metrics-server/metrics-server",
# Для base и istiod обычно берут одну версию чарта Istio.
"istio": "istio/base",
"kiali_server": "kiali/kiali-server",
}
_versions_cache: dict[str, tuple[float, list[str]]] = {}
_versions_lock = threading.Lock()
class HelmAddonError(Exception):
"""Ошибка helm/kubectl (сообщение для API)."""
def __init__(self, message: str, *, exit_code: int = 1) -> None:
super().__init__(message)
self.exit_code = exit_code
def _helm_timeout_sec() -> int:
raw = (os.environ.get("KIND_K8S_HELM_TIMEOUT_SEC") or "900").strip()
try:
return max(60, min(int(raw), 7200))
except ValueError:
return 900
def _helm_versions_cache_sec() -> int:
"""TTL кэша списков версий (``helm search repo`` после ``repo update``)."""
raw = (os.environ.get("KIND_K8S_HELM_VERSIONS_CACHE_SEC") or "600").strip()
try:
return max(30, min(int(raw), 86400))
except ValueError:
return 600
def _helm_versions_max_list() -> int:
raw = (os.environ.get("KIND_K8S_HELM_VERSIONS_MAX") or "80").strip()
try:
return max(10, min(int(raw), 200))
except ValueError:
return 80
def search_repo_chart_versions(chart_ref: str) -> list[str]:
"""
Уникальные версии чарта (новые первыми), через ``helm search repo … --versions -o json``.
Перед вызовом рекомендуется :func:`helm_repo_ensure`; здесь кэш по ``chart_ref``.
"""
ref = chart_ref.strip()
if not ref:
return []
now = time.monotonic()
ttl = _helm_versions_cache_sec()
with _versions_lock:
hit = _versions_cache.get(ref)
if hit is not None:
ts, vers = hit
if now - ts < ttl:
logger.debug("Версии чарта %s из кэша (%s шт.)", ref, len(vers))
return list(vers)
require_helm_binary()
rc, out, err = _run_cmd(
["helm", "search", "repo", ref, "--versions", "-o", "json"],
timeout=180,
)
if rc != 0:
logger.warning("helm search repo %s: %s", ref, err or out or rc)
with _versions_lock:
_versions_cache[ref] = (now, [])
return []
versions: list[str] = []
seen: set[str] = set()
try:
rows = json.loads(out or "[]")
except json.JSONDecodeError:
rows = []
cap = _helm_versions_max_list()
if isinstance(rows, list):
for row in rows:
if not isinstance(row, dict):
continue
v = str(row.get("version", "")).strip()
if not v or v in seen:
continue
seen.add(v)
versions.append(v)
if len(versions) >= cap:
break
with _versions_lock:
_versions_cache[ref] = (now, versions)
logger.info("Получены версии для %s: %s записей", ref, len(versions))
return versions
def helm_addon_chart_versions_all() -> dict[str, list[str]]:
"""
Версии для всех аддонов UI (после обновления репозиториев — один раз).
Ключи: ``ingress_nginx``, ``kube_prometheus_stack``, ``metrics_server``, ``istio``, ``kiali_server``.
"""
helm_repo_ensure()
out: dict[str, list[str]] = {}
for key, cref in HELM_ADDON_CHART_REFS.items():
out[key] = search_repo_chart_versions(cref)
return out
def require_helm_binary() -> None:
if not shutil.which("helm"):
raise HelmAddonError("В образе не найден helm в PATH. Пересоберите образ с установленным Helm.", exit_code=127)
def require_kubectl_binary() -> None:
if not shutil.which("kubectl"):
raise HelmAddonError("Не найден kubectl в PATH.", exit_code=127)
def _kubeconfig_runtime_path(cluster_name: str) -> Path:
if not validate_cluster_name(cluster_name):
raise HelmAddonError("Некорректное имя кластера")
src = clusters_dir() / cluster_name / "kubeconfig"
if not src.is_file():
raise HelmAddonError(f"Нет kubeconfig: {src}")
return kubeconfig_path_for_container_kubectl(cluster_name=cluster_name, kube_source_path=src)
def _run_cmd(
cmd: list[str],
*,
timeout: int | None = None,
env: dict[str, str] | None = None,
) -> tuple[int, str, str]:
"""Выполнить команду; возвращает (код, stdout, stderr)."""
t = timeout if timeout is not None else _helm_timeout_sec()
logger.info("Выполнение: %s (timeout=%s)", " ".join(cmd[:12]) + ("" if len(cmd) > 12 else ""), t)
try:
p = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=t,
env={**os.environ, **(env or {})},
)
except subprocess.TimeoutExpired:
logger.warning("Таймаут команды: %s", cmd[0])
raise HelmAddonError(f"Превышено время ожидания ({t} с) для команды «{cmd[0]}»", exit_code=124) from None
out = (p.stdout or "").strip()
err = (p.stderr or "").strip()
return p.returncode, out, err
def helm_repo_ensure() -> tuple[int, str]:
"""Добавить недостающие репозитории и выполнить ``helm repo update``."""
require_helm_binary()
lines: list[str] = []
for name, url in HELM_REPOS.items():
rc, o, e = _run_cmd(["helm", "repo", "add", name, url], timeout=120)
if rc != 0 and "already exists" not in (e + o).lower():
raise HelmAddonError(f"helm repo add {name}: {e or o or rc}", exit_code=rc)
if o:
lines.append(o)
if e and "already exists" not in e.lower():
lines.append(e)
rc2, o2, e2 = _run_cmd(["helm", "repo", "update"], timeout=300)
if rc2 != 0:
raise HelmAddonError(f"helm repo update: {e2 or o2 or rc2}", exit_code=rc2)
lines.append(o2 or e2)
return 0, "\n".join(x for x in lines if x).strip()
def helm_list_all(cluster_name: str) -> list[dict[str, Any]]:
"""Список релизов ``helm list -A -o json``."""
kcfg = str(_kubeconfig_runtime_path(cluster_name))
rc, out, err = _run_cmd(
["helm", "list", "-A", "-o", "json", "--kubeconfig", kcfg],
timeout=120,
)
if rc != 0:
logger.warning("helm list: %s", err or out)
return []
if not out.strip():
return []
try:
data = json.loads(out)
return data if isinstance(data, list) else []
except json.JSONDecodeError:
return []
def addon_status(cluster_name: str) -> dict[str, bool]:
"""Какие аддоны установлены (по имени релиза и namespace)."""
lst = helm_list_all(cluster_name)
names_ns = {(str(x.get("name", "")), str(x.get("namespace", ""))) for x in lst if isinstance(x, dict)}
return {
"ingress_nginx": ("ingress-nginx", "ingress-nginx") in names_ns,
"kube_prometheus_stack": ("kube-prometheus-stack", "monitoring") in names_ns,
"metrics_server": ("metrics-server", "kube-system") in names_ns,
"istio_base": ("istio-base", "istio-system") in names_ns,
"istiod": ("istiod", "istio-system") in names_ns,
"kiali": ("kiali-server", "istio-system") in names_ns,
}
def _kubectl_apply_manifest(cluster_name: str, manifest: dict[str, Any]) -> tuple[int, str]:
require_kubectl_binary()
kcfg = str(_kubeconfig_runtime_path(cluster_name))
yml = yaml.safe_dump(manifest, allow_unicode=True, default_flow_style=False)
p = subprocess.run(
["kubectl", "apply", "-f", "-", "--kubeconfig", kcfg, "--request-timeout=120s"],
input=yml,
capture_output=True,
text=True,
timeout=180,
)
msg = ((p.stdout or "").strip() + "\n" + (p.stderr or "").strip()).strip()
return p.returncode, msg
def _ensure_namespace(cluster_name: str, namespace: str) -> None:
manifest = {
"apiVersion": "v1",
"kind": "Namespace",
"metadata": {"name": namespace},
}
rc, msg = _kubectl_apply_manifest(cluster_name, manifest)
if rc != 0:
raise HelmAddonError(f"Создание namespace {namespace}: {msg}", exit_code=rc)
def _helm_upgrade_install(
cluster_name: str,
release: str,
chart: str,
namespace: str,
*,
chart_version: str | None = None,
extra_args: list[str] | None = None,
values_file: Path | None = None,
) -> tuple[int, str, str]:
kcfg = str(_kubeconfig_runtime_path(cluster_name))
cmd = [
"helm",
"upgrade",
"--install",
release,
chart,
"-n",
namespace,
"--create-namespace",
"--kubeconfig",
kcfg,
"--wait",
"--timeout",
f"{_helm_timeout_sec()}s",
]
ver = (chart_version or "").strip()
if ver:
cmd.extend(["--version", ver])
if values_file is not None:
cmd.extend(["-f", str(values_file)])
if extra_args:
cmd.extend(extra_args)
return _run_cmd(cmd, timeout=_helm_timeout_sec() + 60)
def _helm_uninstall(cluster_name: str, release: str, namespace: str) -> tuple[int, str, str]:
kcfg = str(_kubeconfig_runtime_path(cluster_name))
return _run_cmd(
["helm", "uninstall", release, "-n", namespace, "--kubeconfig", kcfg, "--wait", "--timeout", "600s"],
timeout=660,
)
# --- Установка / удаление по типам ---
def install_ingress_nginx(cluster_name: str, chart_version: str | None) -> tuple[bool, str]:
"""ingress-nginx с NodePort для kind."""
helm_repo_ensure()
extra: list[str] = [
"--set",
"controller.service.type=NodePort",
"--set",
"controller.service.nodePorts.http=30080",
]
rc, out, err = _helm_upgrade_install(
cluster_name,
"ingress-nginx",
"ingress-nginx/ingress-nginx",
"ingress-nginx",
chart_version=chart_version,
extra_args=extra,
)
text = "\n".join(filter(None, [out, err])).strip()
if rc != 0:
raise HelmAddonError(text or f"helm exit {rc}", exit_code=rc)
return True, text or "ingress-nginx установлен."
def uninstall_ingress_nginx(cluster_name: str) -> tuple[bool, str]:
rc, out, err = _helm_uninstall(cluster_name, "ingress-nginx", "ingress-nginx")
text = "\n".join(filter(None, [out, err])).strip()
if rc != 0 and "not found" not in text.lower():
raise HelmAddonError(text or f"helm exit {rc}", exit_code=rc)
return True, text or "ingress-nginx удалён (или отсутствовал)."
def install_kube_prometheus_stack(
cluster_name: str,
*,
grafana_admin_user: str,
grafana_admin_password: str,
chart_version: str | None = None,
) -> tuple[bool, str]:
helm_repo_ensure()
values = {
"grafana": {
"adminUser": grafana_admin_user,
"adminPassword": grafana_admin_password,
}
}
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False, encoding="utf-8") as tmp:
yaml.safe_dump(values, tmp, allow_unicode=True)
tmp_path = Path(tmp.name)
try:
rc, out, err = _helm_upgrade_install(
cluster_name,
"kube-prometheus-stack",
"prometheus-community/kube-prometheus-stack",
"monitoring",
chart_version=chart_version,
values_file=tmp_path,
)
finally:
tmp_path.unlink(missing_ok=True)
text = "\n".join(filter(None, [out, err])).strip()
if rc != 0:
raise HelmAddonError(text or f"helm exit {rc}", exit_code=rc)
return True, text or "kube-prometheus-stack установлен."
def uninstall_kube_prometheus_stack(cluster_name: str) -> tuple[bool, str]:
rc, out, err = _helm_uninstall(cluster_name, "kube-prometheus-stack", "monitoring")
text = "\n".join(filter(None, [out, err])).strip()
if rc != 0 and "not found" not in text.lower():
raise HelmAddonError(text or f"helm exit {rc}", exit_code=rc)
return True, text or "kube-prometheus-stack удалён (или отсутствовал)."
def install_metrics_server(cluster_name: str, chart_version: str | None = None) -> tuple[bool, str]:
helm_repo_ensure()
values = {
"args": [
"--kubelet-insecure-tls",
"--kubelet-preferred-address-types=InternalIP,Hostname,ExternalIP",
]
}
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False, encoding="utf-8") as tmp:
yaml.safe_dump(values, tmp, allow_unicode=True)
tmp_path = Path(tmp.name)
try:
rc, out, err = _helm_upgrade_install(
cluster_name,
"metrics-server",
"metrics-server/metrics-server",
"kube-system",
chart_version=chart_version,
values_file=tmp_path,
)
finally:
tmp_path.unlink(missing_ok=True)
text = "\n".join(filter(None, [out, err])).strip()
if rc != 0:
raise HelmAddonError(text or f"helm exit {rc}", exit_code=rc)
return True, text or "metrics-server установлен."
def uninstall_metrics_server(cluster_name: str) -> tuple[bool, str]:
rc, out, err = _helm_uninstall(cluster_name, "metrics-server", "kube-system")
text = "\n".join(filter(None, [out, err])).strip()
if rc != 0 and "not found" not in text.lower():
raise HelmAddonError(text or f"helm exit {rc}", exit_code=rc)
return True, text or "metrics-server удалён (или отсутствовал)."
def install_istio_and_kiali(
cluster_name: str,
*,
kiali_username: str,
kiali_password: str,
istio_chart_version: str | None = None,
kiali_chart_version: str | None = None,
) -> tuple[bool, str]:
"""Istio base + istiod + секрет входа Kiali + chart kiali-server (strategy=login)."""
helm_repo_ensure()
_ensure_namespace(cluster_name, "istio-system")
log_parts: list[str] = []
istio_ver = (istio_chart_version or "").strip() or None
kiali_ver = (kiali_chart_version or "").strip() or None
rc, out, err = _helm_upgrade_install(
cluster_name,
"istio-base",
"istio/base",
"istio-system",
chart_version=istio_ver,
)
log_parts.append(out or err)
if rc != 0:
raise HelmAddonError((err or out or f"istio-base exit {rc}")[:8000], exit_code=rc)
rc2, o2, e2 = _helm_upgrade_install(
cluster_name,
"istiod",
"istio/istiod",
"istio-system",
chart_version=istio_ver,
)
log_parts.append(o2 or e2)
if rc2 != 0:
raise HelmAddonError((e2 or o2 or f"istiod exit {rc2}")[:8000], exit_code=rc2)
# Секрет для стратегии login (ключи username / passphrase — ожидание Kiali).
secret = {
"apiVersion": "v1",
"kind": "Secret",
"metadata": {"name": "kiali", "namespace": "istio-system"},
"type": "Opaque",
"stringData": {
"username": kiali_username,
"passphrase": kiali_password,
},
}
rcs, msgs = _kubectl_apply_manifest(cluster_name, secret)
if rcs != 0:
raise HelmAddonError(f"Secret kiali: {msgs}", exit_code=rcs)
log_parts.append(msgs)
rc3, o3, e3 = _helm_upgrade_install(
cluster_name,
"kiali-server",
"kiali/kiali-server",
"istio-system",
chart_version=kiali_ver,
extra_args=["--set", "auth.strategy=login"],
)
log_parts.append(o3 or e3)
if rc3 != 0:
raise HelmAddonError((e3 or o3 or f"kiali-server exit {rc3}")[:8000], exit_code=rc3)
return True, "\n\n".join(x for x in log_parts if x).strip() or "Istio и Kiali установлены."
def uninstall_istio_and_kiali(cluster_name: str) -> tuple[bool, str]:
"""Обратный порядок: Kiali → istiod → istio-base."""
parts: list[str] = []
for rel in ("kiali-server", "istiod", "istio-base"):
rc, out, err = _helm_uninstall(cluster_name, rel, "istio-system")
line = "\n".join(filter(None, [out, err])).strip()
if line:
parts.append(line)
if rc != 0 and "not found" not in line.lower() and "release: not found" not in line.lower():
raise HelmAddonError(line or f"helm uninstall {rel} код {rc}", exit_code=rc)
return True, "\n".join(parts).strip() or "Istio/Kiali удалены (или отсутствовали)."