- Kiali: убран login, anonymous по умолчанию; удалены поля логина/пароля из UI и API - Журнал Helm: install/upgrade/delete, message и колонка в journal.js - Аддоны: values свёрнуты при подгрузке для установленных - GET …/kubeconfig/docker: host.docker.internal:порт + tls-server-name; кнопка в UI - apply_apiserver_endpoint_to_kubeconfig_file; KIND_K8S_APISERVER_GATEWAY_HOST в compose/env.example - README и api_routes.md обновлены
887 lines
33 KiB
Python
887 lines
33 KiB
Python
"""Установка и удаление типовых 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",
|
||
}
|
||
|
||
# Идентификаторы аддонов, доступных для установки на странице «Аддоны» и через API ``/clusters/{name}/addons/…``.
|
||
# Должен совпадать с набором в ``HelmComposeValuesRequest.addon`` и кнопками в ``cluster_addons.html``.
|
||
HELM_INSTALLABLE_ADDON_IDS: tuple[str, ...] = (
|
||
"ingress-nginx",
|
||
"kube-prometheus-stack",
|
||
"metrics-server",
|
||
"istio-kiali",
|
||
)
|
||
|
||
# Ссылки для ``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 dump_helm_values_yaml(data: dict[str, Any]) -> str:
|
||
"""Сериализация mapping в YAML для UI и временных файлов Helm."""
|
||
return yaml.safe_dump(
|
||
data,
|
||
allow_unicode=True,
|
||
default_flow_style=False,
|
||
sort_keys=False,
|
||
)
|
||
|
||
|
||
def helm_show_values_dict(chart_ref: str, chart_version: str | None = None) -> dict[str, Any]:
|
||
"""
|
||
Словарь дефолтных values чарта: ``helm show values <ref> [--version …]``.
|
||
|
||
При пустом stdout или ошибке разбора возвращает ``{}`` (ошибка helm — :class:`HelmAddonError`).
|
||
"""
|
||
require_helm_binary()
|
||
cref = chart_ref.strip()
|
||
if not cref:
|
||
return {}
|
||
cmd = ["helm", "show", "values", cref]
|
||
ver = (chart_version or "").strip()
|
||
if ver:
|
||
cmd.extend(["--version", ver])
|
||
rc, out, err = _run_cmd(cmd, timeout=180)
|
||
if rc != 0:
|
||
raise HelmAddonError(
|
||
(err or out or f"helm show values {cref} код {rc}")[:4000],
|
||
exit_code=rc,
|
||
)
|
||
raw = (out or "").strip()
|
||
if not raw:
|
||
return {}
|
||
try:
|
||
data = yaml.safe_load(raw)
|
||
except yaml.YAMLError as e:
|
||
logger.warning("YAML из helm show values %s: %s", cref, e)
|
||
return {}
|
||
return data if isinstance(data, dict) else {}
|
||
|
||
|
||
def _ingress_kind_service_overlay() -> dict[str, Any]:
|
||
"""То же, что передаётся через ``--set`` при установке ingress для kind."""
|
||
return {
|
||
"controller": {
|
||
"service": {
|
||
"type": "NodePort",
|
||
"nodePorts": {"http": 30080},
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
def compose_ingress_effective_values(chart_version: str | None = None) -> dict[str, Any]:
|
||
"""Дефолты чарта ingress-nginx + сервис NodePort (как в install). Репозитории — см. :func:`helm_repo_ensure`."""
|
||
base = helm_show_values_dict("ingress-nginx/ingress-nginx", chart_version)
|
||
return deep_merge_values(base, _ingress_kind_service_overlay())
|
||
|
||
|
||
def compose_kube_prometheus_effective_values(
|
||
chart_version: str | None,
|
||
grafana_admin_user: str,
|
||
grafana_admin_password: str,
|
||
) -> dict[str, Any]:
|
||
"""Дефолты kube-prometheus-stack + учётные данные Grafana."""
|
||
base = helm_show_values_dict("prometheus-community/kube-prometheus-stack", chart_version)
|
||
overlay: dict[str, Any] = {
|
||
"grafana": {
|
||
"adminUser": grafana_admin_user.strip(),
|
||
"adminPassword": grafana_admin_password,
|
||
}
|
||
}
|
||
return deep_merge_values(base, overlay)
|
||
|
||
|
||
def compose_metrics_server_effective_values(chart_version: str | None = None) -> dict[str, Any]:
|
||
"""Дефолты metrics-server + args для kind (как в install)."""
|
||
base = helm_show_values_dict("metrics-server/metrics-server", chart_version)
|
||
overlay: dict[str, Any] = {
|
||
"args": [
|
||
"--kubelet-insecure-tls",
|
||
"--kubelet-preferred-address-types=InternalIP,Hostname,ExternalIP",
|
||
]
|
||
}
|
||
return deep_merge_values(base, overlay)
|
||
|
||
|
||
def compose_istio_mesh_preview_values(
|
||
istio_chart_version: str | None = None,
|
||
kiali_chart_version: str | None = None,
|
||
) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]:
|
||
"""
|
||
Три словаря values: ``kiali-server``, ``istio/base``, ``istio/istiod``
|
||
(чистые ``helm show values`` для редактирования в UI).
|
||
"""
|
||
kiali = helm_show_values_dict("kiali/kiali-server", kiali_chart_version)
|
||
ib = helm_show_values_dict("istio/base", istio_chart_version)
|
||
ird = helm_show_values_dict("istio/istiod", istio_chart_version)
|
||
return kiali, ib, ird
|
||
|
||
|
||
def deep_merge_values(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
|
||
"""
|
||
Рекурсивное объединение mapping: ключи из ``override`` перекрывают ``base``;
|
||
для вложенных dict сливаем дальше (как типичный merge для Helm values).
|
||
"""
|
||
out: dict[str, Any] = dict(base)
|
||
for k, v in override.items():
|
||
if k in out and isinstance(out[k], dict) and isinstance(v, dict):
|
||
out[k] = deep_merge_values(out[k], v)
|
||
else:
|
||
out[k] = v
|
||
return out
|
||
|
||
|
||
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 helm_release_entry(
|
||
lst: list[dict[str, Any]],
|
||
release: str,
|
||
namespace: str,
|
||
) -> dict[str, Any] | None:
|
||
"""Одна запись ``helm list -o json`` по имени релиза и namespace."""
|
||
for x in lst:
|
||
if not isinstance(x, dict):
|
||
continue
|
||
if str(x.get("name", "")) == release and str(x.get("namespace", "")) == namespace:
|
||
return x
|
||
return None
|
||
|
||
|
||
def _chart_version_from_list_field(chart: str, release_name: str) -> str | None:
|
||
"""
|
||
Поле ``chart`` в выводе ``helm list`` имеет вид ``<имя_чарта>-<версия>`` (например ``ingress-nginx-4.11.2``).
|
||
Для релиза Istio base в списке часто ``base-1.22.3``.
|
||
"""
|
||
chart = (chart or "").strip()
|
||
if not chart:
|
||
return None
|
||
prefixes: dict[str, str] = {
|
||
"ingress-nginx": "ingress-nginx-",
|
||
"kube-prometheus-stack": "kube-prometheus-stack-",
|
||
"metrics-server": "metrics-server-",
|
||
"istio-base": "base-",
|
||
"istiod": "istiod-",
|
||
"kiali-server": "kiali-server-",
|
||
}
|
||
p = prefixes.get(release_name)
|
||
if p and chart.startswith(p):
|
||
v = chart[len(p) :].strip()
|
||
return v or None
|
||
return chart
|
||
|
||
|
||
def helm_get_values_all_yaml(cluster_name: str, release: str, namespace: str) -> str:
|
||
"""
|
||
Эффективные values релиза: ``helm get values --all -o yaml`` (для правки при upgrade).
|
||
"""
|
||
kcfg = str(_kubeconfig_runtime_path(cluster_name))
|
||
rc, out, err = _run_cmd(
|
||
[
|
||
"helm",
|
||
"get",
|
||
"values",
|
||
release,
|
||
"-n",
|
||
namespace,
|
||
"--all",
|
||
"-o",
|
||
"yaml",
|
||
"--kubeconfig",
|
||
kcfg,
|
||
],
|
||
timeout=120,
|
||
)
|
||
if rc != 0:
|
||
raise HelmAddonError(
|
||
(err or out or f"helm get values {release}/{namespace} код {rc}")[:8000],
|
||
exit_code=rc,
|
||
)
|
||
return (out or "").strip()
|
||
|
||
|
||
_VALUES_YAML_API_MAX = 131_072
|
||
|
||
|
||
def trim_values_yaml_for_api(raw: str) -> str:
|
||
"""Ограничение размера YAML в JSON-ответе API."""
|
||
if len(raw) <= _VALUES_YAML_API_MAX:
|
||
return raw
|
||
return raw[: _VALUES_YAML_API_MAX - 120] + "\n\n# … YAML обрезан (лимит ответа API).\n"
|
||
|
||
|
||
def helm_addon_snapshot(cluster_name: str) -> dict[str, Any]:
|
||
"""
|
||
Флаги установки, версии чартов из ``helm list`` и признак полного mesh Istio+Kiali.
|
||
"""
|
||
lst = helm_list_all(cluster_name)
|
||
|
||
def pair(rel: str, ns: str) -> tuple[bool, str | None]:
|
||
e = helm_release_entry(lst, rel, ns)
|
||
if not e:
|
||
return False, None
|
||
ch = str(e.get("chart") or "")
|
||
ver = _chart_version_from_list_field(ch, rel)
|
||
return True, ver
|
||
|
||
ing_on, ing_v = pair("ingress-nginx", "ingress-nginx")
|
||
prom_on, prom_v = pair("kube-prometheus-stack", "monitoring")
|
||
ms_on, ms_v = pair("metrics-server", "kube-system")
|
||
base_on, base_v = pair("istio-base", "istio-system")
|
||
istiod_on, istiod_v = pair("istiod", "istio-system")
|
||
kiali_on, kiali_v = pair("kiali-server", "istio-system")
|
||
mesh = bool(base_on and istiod_on and kiali_on)
|
||
return {
|
||
"ingress_nginx": ing_on,
|
||
"ingress_nginx_chart_version": ing_v if ing_on else None,
|
||
"kube_prometheus_stack": prom_on,
|
||
"kube_prometheus_stack_chart_version": prom_v if prom_on else None,
|
||
"metrics_server": ms_on,
|
||
"metrics_server_chart_version": ms_v if ms_on else None,
|
||
"istio_base": base_on,
|
||
"istiod": istiod_on,
|
||
"kiali_server": kiali_on,
|
||
"istio_base_chart_version": base_v if base_on else None,
|
||
"istiod_chart_version": istiod_v if istiod_on else None,
|
||
"kiali_server_chart_version": kiali_v if kiali_on else None,
|
||
"istio_mesh_ready": mesh,
|
||
}
|
||
|
||
|
||
def addon_status(cluster_name: str) -> dict[str, bool]:
|
||
"""Какие аддоны установлены (по имени релиза и namespace). Совместимость со старым кодом."""
|
||
s = helm_addon_snapshot(cluster_name)
|
||
return {
|
||
"ingress_nginx": bool(s["ingress_nginx"]),
|
||
"kube_prometheus_stack": bool(s["kube_prometheus_stack"]),
|
||
"metrics_server": bool(s["metrics_server"]),
|
||
"istio_base": bool(s["istio_base"]),
|
||
"istiod": bool(s["istiod"]),
|
||
"kiali": bool(s["kiali_server"]),
|
||
}
|
||
|
||
|
||
def collect_installed_addons_values(cluster_name: str) -> dict[str, Any]:
|
||
"""
|
||
Для установленных релизов — версия чарта (как в snapshot) и YAML ``helm get values --all``.
|
||
Ключи совпадают с логическими аддонами страницы UI.
|
||
"""
|
||
s = helm_addon_snapshot(cluster_name)
|
||
out: dict[str, Any] = {}
|
||
|
||
if s["ingress_nginx"]:
|
||
try:
|
||
y = trim_values_yaml_for_api(
|
||
helm_get_values_all_yaml(cluster_name, "ingress-nginx", "ingress-nginx"),
|
||
)
|
||
except HelmAddonError as e:
|
||
y = f"# Не удалось прочитать values: {e}\n"
|
||
out["ingress_nginx"] = {"chart_version": s["ingress_nginx_chart_version"], "values_yaml": y}
|
||
|
||
if s["kube_prometheus_stack"]:
|
||
try:
|
||
y = trim_values_yaml_for_api(
|
||
helm_get_values_all_yaml(cluster_name, "kube-prometheus-stack", "monitoring"),
|
||
)
|
||
except HelmAddonError as e:
|
||
y = f"# Не удалось прочитать values: {e}\n"
|
||
out["kube_prometheus_stack"] = {"chart_version": s["kube_prometheus_stack_chart_version"], "values_yaml": y}
|
||
|
||
if s["metrics_server"]:
|
||
try:
|
||
y = trim_values_yaml_for_api(
|
||
helm_get_values_all_yaml(cluster_name, "metrics-server", "kube-system"),
|
||
)
|
||
except HelmAddonError as e:
|
||
y = f"# Не удалось прочитать values: {e}\n"
|
||
out["metrics_server"] = {"chart_version": s["metrics_server_chart_version"], "values_yaml": y}
|
||
|
||
if s["istio_mesh_ready"]:
|
||
try:
|
||
kb = trim_values_yaml_for_api(
|
||
helm_get_values_all_yaml(cluster_name, "istio-base", "istio-system"),
|
||
)
|
||
ki = trim_values_yaml_for_api(helm_get_values_all_yaml(cluster_name, "istiod", "istio-system"))
|
||
kk = trim_values_yaml_for_api(
|
||
helm_get_values_all_yaml(cluster_name, "kiali-server", "istio-system"),
|
||
)
|
||
except HelmAddonError as e:
|
||
kb = ki = kk = f"# Не удалось прочитать values: {e}\n"
|
||
out["istio_kiali"] = {
|
||
"istio_chart_version": s["istiod_chart_version"] or s["istio_base_chart_version"],
|
||
"kiali_chart_version": s["kiali_server_chart_version"],
|
||
"istio_base_values_yaml": kb,
|
||
"istiod_values_yaml": ki,
|
||
"kiali_values_yaml": kk,
|
||
}
|
||
|
||
return out
|
||
|
||
|
||
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,
|
||
extra_values_files: list[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)])
|
||
for p in extra_values_files or []:
|
||
cmd.extend(["-f", str(p)])
|
||
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,
|
||
user_values: dict[str, Any] | None = None,
|
||
) -> tuple[bool, str]:
|
||
"""
|
||
ingress-nginx: полный YAML values из UI (``helm show values`` + NodePort) или переданный словарь;
|
||
после файла values остаются ``--set`` для NodePort (приоритет как у Helm).
|
||
"""
|
||
helm_repo_ensure()
|
||
eff = user_values if user_values is not None else compose_ingress_effective_values(chart_version)
|
||
extra: list[str] = [
|
||
"--set",
|
||
"controller.service.type=NodePort",
|
||
"--set",
|
||
"controller.service.nodePorts.http=30080",
|
||
]
|
||
user_tmp: Path | None = None
|
||
try:
|
||
fd, name = tempfile.mkstemp(suffix=".yaml", prefix="helm-ing-values-")
|
||
os.close(fd)
|
||
user_tmp = Path(name)
|
||
user_tmp.write_text(dump_helm_values_yaml(eff), encoding="utf-8")
|
||
rc, out, err = _helm_upgrade_install(
|
||
cluster_name,
|
||
"ingress-nginx",
|
||
"ingress-nginx/ingress-nginx",
|
||
"ingress-nginx",
|
||
chart_version=chart_version,
|
||
extra_args=extra,
|
||
extra_values_files=[user_tmp],
|
||
)
|
||
finally:
|
||
if user_tmp is not None:
|
||
user_tmp.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 "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,
|
||
user_values: dict[str, Any] | None = None,
|
||
) -> tuple[bool, str]:
|
||
"""Полный values из UI или автосборка (``helm show values`` + Grafana из формы)."""
|
||
helm_repo_ensure()
|
||
if user_values is not None:
|
||
values = user_values
|
||
else:
|
||
values = compose_kube_prometheus_effective_values(
|
||
chart_version,
|
||
grafana_admin_user,
|
||
grafana_admin_password,
|
||
)
|
||
fd, tmp_name = tempfile.mkstemp(suffix=".yaml", prefix="helm-prom-stack-")
|
||
os.close(fd)
|
||
tmp_path = Path(tmp_name)
|
||
try:
|
||
tmp_path.write_text(dump_helm_values_yaml(values), encoding="utf-8")
|
||
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,
|
||
user_values: dict[str, Any] | None = None,
|
||
) -> tuple[bool, str]:
|
||
"""Полный values из UI или автосборка (чарт + args для kind)."""
|
||
helm_repo_ensure()
|
||
if user_values is not None:
|
||
values = user_values
|
||
else:
|
||
values = compose_metrics_server_effective_values(chart_version)
|
||
fd_m, tmp_ms = tempfile.mkstemp(suffix=".yaml", prefix="helm-metrics-")
|
||
os.close(fd_m)
|
||
tmp_path = Path(tmp_ms)
|
||
try:
|
||
tmp_path.write_text(dump_helm_values_yaml(values), encoding="utf-8")
|
||
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 _kiali_user_values_define_auth_strategy(values: dict[str, Any] | None) -> bool:
|
||
"""
|
||
True, если в пользовательских values kiali-server уже задан ``auth.strategy``
|
||
(тогда Helm не подставляет ``anonymous`` поверх).
|
||
"""
|
||
if not values:
|
||
return False
|
||
auth = values.get("auth")
|
||
if not isinstance(auth, dict):
|
||
return False
|
||
strat = auth.get("strategy")
|
||
return isinstance(strat, str) and bool(strat.strip())
|
||
|
||
|
||
def install_istio_and_kiali(
|
||
cluster_name: str,
|
||
*,
|
||
istio_chart_version: str | None = None,
|
||
kiali_chart_version: str | None = None,
|
||
istio_base_values: dict[str, Any] | None = None,
|
||
istiod_values: dict[str, Any] | None = None,
|
||
kiali_user_values: dict[str, Any] | None = None,
|
||
) -> tuple[bool, str]:
|
||
"""
|
||
Istio base + istiod (опциональные values из UI) + kiali-server.
|
||
|
||
Kiali: стратегия ``login`` в актуальных версиях не поддерживается upstream. По умолчанию
|
||
выставляется ``auth.strategy=anonymous`` (удобно для kind и port-forward); иначе
|
||
задайте ``auth`` в YAML values (``token``, ``openid`` и т.д., см. kiali.io).
|
||
|
||
Для каждого чарта: если словарь ``None`` — без файла ``-f`` (дефолты чарта);
|
||
если непустой dict — полный values как в редакторе.
|
||
"""
|
||
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
|
||
|
||
base_tmp: Path | None = None
|
||
try:
|
||
base_file: Path | None = None
|
||
if istio_base_values:
|
||
fd, name = tempfile.mkstemp(suffix=".yaml", prefix="helm-istio-base-")
|
||
os.close(fd)
|
||
base_tmp = Path(name)
|
||
base_tmp.write_text(dump_helm_values_yaml(istio_base_values), encoding="utf-8")
|
||
base_file = base_tmp
|
||
rc, out, err = _helm_upgrade_install(
|
||
cluster_name,
|
||
"istio-base",
|
||
"istio/base",
|
||
"istio-system",
|
||
chart_version=istio_ver,
|
||
values_file=base_file,
|
||
)
|
||
log_parts.append(out or err)
|
||
if rc != 0:
|
||
raise HelmAddonError((err or out or f"istio-base exit {rc}")[:8000], exit_code=rc)
|
||
finally:
|
||
if base_tmp is not None:
|
||
base_tmp.unlink(missing_ok=True)
|
||
|
||
istiod_tmp: Path | None = None
|
||
try:
|
||
istiod_file: Path | None = None
|
||
if istiod_values:
|
||
fd, name = tempfile.mkstemp(suffix=".yaml", prefix="helm-istiod-")
|
||
os.close(fd)
|
||
istiod_tmp = Path(name)
|
||
istiod_tmp.write_text(dump_helm_values_yaml(istiod_values), encoding="utf-8")
|
||
istiod_file = istiod_tmp
|
||
rc2, o2, e2 = _helm_upgrade_install(
|
||
cluster_name,
|
||
"istiod",
|
||
"istio/istiod",
|
||
"istio-system",
|
||
chart_version=istio_ver,
|
||
values_file=istiod_file,
|
||
)
|
||
log_parts.append(o2 or e2)
|
||
if rc2 != 0:
|
||
raise HelmAddonError((e2 or o2 or f"istiod exit {rc2}")[:8000], exit_code=rc2)
|
||
finally:
|
||
if istiod_tmp is not None:
|
||
istiod_tmp.unlink(missing_ok=True)
|
||
|
||
kiali_extra: list[Path] = []
|
||
kiali_tmp: Path | None = None
|
||
try:
|
||
if kiali_user_values:
|
||
fd, name = tempfile.mkstemp(suffix=".yaml", prefix="helm-kiali-user-")
|
||
os.close(fd)
|
||
kiali_tmp = Path(name)
|
||
kiali_tmp.write_text(dump_helm_values_yaml(kiali_user_values), encoding="utf-8")
|
||
kiali_extra.append(kiali_tmp)
|
||
kiali_helm_extra: list[str] = []
|
||
if not _kiali_user_values_define_auth_strategy(kiali_user_values):
|
||
kiali_helm_extra.extend(["--set", "auth.strategy=anonymous"])
|
||
rc3, o3, e3 = _helm_upgrade_install(
|
||
cluster_name,
|
||
"kiali-server",
|
||
"kiali/kiali-server",
|
||
"istio-system",
|
||
chart_version=kiali_ver,
|
||
extra_args=kiali_helm_extra or None,
|
||
extra_values_files=kiali_extra or None,
|
||
)
|
||
finally:
|
||
if kiali_tmp is not None:
|
||
kiali_tmp.unlink(missing_ok=True)
|
||
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 удалены (или отсутствовали)."
|