Files
KindClustersDashboard/app/api/v1/endpoints/cluster_addons.py
T
Sergey Antropoff d55e69c756 UI: карточка установленных аддонов на странице кластера; API аддонов и правки стилей
- Отдельная section.card после сводки ресурсов узлов, выравнивание бейджа Helm с заголовком
- Расширение эндпоинтов аддонов, схемы, helm_addons, cluster_addons.js/html
- Обновление api_routes.md
2026-04-04 14:13:01 +03:00

470 lines
17 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.
"""Helm-аддоны для выбранного кластера: ingress-nginx, kube-prometheus-stack, metrics-server, Istio+Kiali.
Операции синхронные (долгие таймауты — см. ``KIND_K8S_HELM_TIMEOUT_SEC``).
После каждой операции в ``clusters/<имя>/helm_addon_log.json`` **добавляется** новая запись в массив ``entries``
(история сохраняется, лимит ``KIND_K8S_HELM_ADDON_LOG_MAX_ENTRIES``).
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
from __future__ import annotations
import asyncio
import logging
import uuid
from collections.abc import Callable
from typing import Any
import yaml
from fastapi import APIRouter, Body, HTTPException
from core.cluster_lifecycle import validate_cluster_name
from core.helm_addons import (
HelmAddonError,
collect_installed_addons_values,
helm_addon_snapshot,
require_helm_binary,
install_ingress_nginx,
install_istio_and_kiali,
install_kube_prometheus_stack,
install_metrics_server,
uninstall_ingress_nginx,
uninstall_istio_and_kiali,
uninstall_kube_prometheus_stack,
uninstall_metrics_server,
)
from core.kind_guard import kind_cluster_lock
from core.provision_log import write_cluster_helm_addon_log
from models.schemas import (
ClusterAddonsInstalledValuesResponse,
ClusterAddonsStatusResponse,
HelmAddonApplyResponse,
InstalledAddonValuesBlob,
IstioKialiInstalledValuesBlob,
IngressNginxInstallRequest,
IstioKialiInstallRequest,
KubePrometheusStackInstallRequest,
MetricsServerInstallRequest,
)
logger = logging.getLogger("kind_k8s.api.cluster_addons")
router = APIRouter(tags=["cluster-addons"])
_LOG_MAX = 24000
def _trim_log(s: str) -> str:
if len(s) <= _LOG_MAX:
return s
return s[: _LOG_MAX - 20] + "\n… (обрезано)"
def _map_helm_error(e: HelmAddonError) -> HTTPException:
msg = str(e)
if e.exit_code == 127:
return HTTPException(status_code=503, detail=msg)
return HTTPException(status_code=500, detail=msg)
def _parse_optional_values_yaml(raw: str | None) -> dict[str, Any] | None:
"""
Разбор опционального пользовательского YAML для Helm values.
Пустая строка и ``null`` → ``None``. Корень документа должен быть mapping (объект YAML),
иначе 400. Используется :func:`yaml.safe_load`.
"""
if raw is None:
return None
s = raw.strip()
if not s:
return None
try:
data = yaml.safe_load(s)
except yaml.YAMLError as e:
raise HTTPException(
status_code=400,
detail=f"Некорректный YAML в values_yaml: {e}",
) from e
if data is None:
return None
if not isinstance(data, dict):
raise HTTPException(
status_code=400,
detail="values_yaml: в корне ожидается объект YAML (mapping), не список и не скаляр.",
)
return data
async def _run_helm_with_journal(
cluster_name: str,
job_kind: str,
result_meta: dict[str, Any],
sync_call: Callable[[], tuple[bool, str]],
) -> str:
"""
Выполнить операцию под блокировкой kind, добавить запись в историю ``helm_addon_log.json``.
При ошибке запись всё равно добавляется со статусом ``failed``.
"""
log = ""
try:
async with kind_cluster_lock:
_, log = await asyncio.to_thread(sync_call)
await asyncio.to_thread(
write_cluster_helm_addon_log,
cluster_name=cluster_name,
job_id=str(uuid.uuid4()),
job_kind=job_kind,
status="success",
message=None,
lines=log.splitlines() if log else [],
result=result_meta,
)
return log
except HelmAddonError as e:
msg = str(e)
lines = log.splitlines() if log else []
if msg and msg not in lines:
lines = [msg] + lines
await asyncio.to_thread(
write_cluster_helm_addon_log,
cluster_name=cluster_name,
job_id=str(uuid.uuid4()),
job_kind=job_kind,
status="failed",
message=msg,
lines=lines,
result=result_meta,
)
raise
@router.get(
"/clusters/{name}/addons/status",
response_model=ClusterAddonsStatusResponse,
summary="Статус Helm-аддонов в кластере",
)
async def get_cluster_addons_status(name: str) -> ClusterAddonsStatusResponse:
n = name.strip()
if not validate_cluster_name(n):
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
try:
require_helm_binary()
except HelmAddonError as e:
raise _map_helm_error(e) from e
try:
st = await asyncio.to_thread(helm_addon_snapshot, n)
except HelmAddonError as e:
raise _map_helm_error(e) from e
mesh = bool(st.get("istio_mesh_ready"))
return ClusterAddonsStatusResponse(
cluster_name=n,
ingress_nginx=bool(st.get("ingress_nginx")),
kube_prometheus_stack=bool(st.get("kube_prometheus_stack")),
metrics_server=bool(st.get("metrics_server")),
istio_base=bool(st.get("istio_base")),
istiod=bool(st.get("istiod")),
kiali_server=bool(st.get("kiali_server")),
istio_mesh_ready=mesh,
ingress_nginx_chart_version=st.get("ingress_nginx_chart_version"),
kube_prometheus_stack_chart_version=st.get("kube_prometheus_stack_chart_version"),
metrics_server_chart_version=st.get("metrics_server_chart_version"),
istiod_chart_version=st.get("istiod_chart_version"),
istio_base_chart_version=st.get("istio_base_chart_version"),
kiali_server_chart_version=st.get("kiali_server_chart_version"),
)
@router.get(
"/clusters/{name}/addons/installed-values",
response_model=ClusterAddonsInstalledValuesResponse,
summary="Values установленных аддонов из кластера (helm get values --all)",
)
async def get_cluster_addons_installed_values(name: str) -> ClusterAddonsInstalledValuesResponse:
"""
Для каждого установленного логического аддона возвращает версию чарта (как в ``helm list``)
и полный YAML эффективных values (``helm get values --all -o yaml``) для правки перед upgrade.
"""
n = name.strip()
if not validate_cluster_name(n):
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
try:
require_helm_binary()
except HelmAddonError as e:
raise _map_helm_error(e) from e
try:
raw = await asyncio.to_thread(collect_installed_addons_values, n)
except HelmAddonError as e:
raise _map_helm_error(e) from e
def blob(key: str) -> InstalledAddonValuesBlob | None:
b = raw.get(key)
if not isinstance(b, dict):
return None
return InstalledAddonValuesBlob(
chart_version=b.get("chart_version"),
values_yaml=str(b.get("values_yaml") or ""),
)
istio = raw.get("istio_kiali")
istio_model: IstioKialiInstalledValuesBlob | None = None
if isinstance(istio, dict):
istio_model = IstioKialiInstalledValuesBlob(
istio_chart_version=istio.get("istio_chart_version"),
kiali_chart_version=istio.get("kiali_chart_version"),
istio_base_values_yaml=str(istio.get("istio_base_values_yaml") or ""),
istiod_values_yaml=str(istio.get("istiod_values_yaml") or ""),
kiali_values_yaml=str(istio.get("kiali_values_yaml") or ""),
)
return ClusterAddonsInstalledValuesResponse(
cluster_name=n,
ingress_nginx=blob("ingress_nginx"),
kube_prometheus_stack=blob("kube_prometheus_stack"),
metrics_server=blob("metrics_server"),
istio_kiali=istio_model,
)
@router.post(
"/clusters/{name}/addons/ingress-nginx",
response_model=HelmAddonApplyResponse,
summary="Установить ingress-nginx (Helm)",
)
async def post_install_ingress_nginx(
name: str,
body: IngressNginxInstallRequest | None = Body(default=None),
) -> HelmAddonApplyResponse:
n = name.strip()
if not validate_cluster_name(n):
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
ver: str | None = None
if body is not None and body.chart_version and body.chart_version.strip():
ver = body.chart_version.strip()
user_v = _parse_optional_values_yaml(body.values_yaml if body else None)
meta = {"addon": "ingress-nginx", "action": "install", "chart_version": ver}
try:
log = await _run_helm_with_journal(
n,
"helm_addon_install_ingress_nginx",
meta,
lambda: install_ingress_nginx(n, ver, user_v),
)
except HelmAddonError as e:
raise _map_helm_error(e) from e
logger.info("Установлен ingress-nginx для кластера «%s»", n)
return HelmAddonApplyResponse(ok=True, message="ingress-nginx установлен.", log=_trim_log(log))
@router.delete(
"/clusters/{name}/addons/ingress-nginx",
response_model=HelmAddonApplyResponse,
summary="Удалить ingress-nginx",
)
async def delete_ingress_nginx(name: str) -> HelmAddonApplyResponse:
n = name.strip()
if not validate_cluster_name(n):
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
meta = {"addon": "ingress-nginx", "action": "delete"}
try:
log = await _run_helm_with_journal(
n,
"helm_addon_delete_ingress_nginx",
meta,
lambda: uninstall_ingress_nginx(n),
)
except HelmAddonError as e:
raise _map_helm_error(e) from e
return HelmAddonApplyResponse(ok=True, message="ingress-nginx удалён.", log=_trim_log(log))
@router.post(
"/clusters/{name}/addons/kube-prometheus-stack",
response_model=HelmAddonApplyResponse,
summary="Установить kube-prometheus-stack (Prometheus Operator + Grafana)",
)
async def post_install_kube_prometheus_stack(
name: str,
body: KubePrometheusStackInstallRequest,
) -> HelmAddonApplyResponse:
n = name.strip()
if not validate_cluster_name(n):
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
prom_ver: str | None = None
if body.chart_version and body.chart_version.strip():
prom_ver = body.chart_version.strip()
u = body.grafana_admin_user.strip()
user_v = _parse_optional_values_yaml(body.values_yaml)
meta = {
"addon": "kube-prometheus-stack",
"action": "install",
"chart_version": prom_ver,
"grafana_admin_user": u,
}
try:
log = await _run_helm_with_journal(
n,
"helm_addon_install_kube_prometheus_stack",
meta,
lambda: install_kube_prometheus_stack(
n,
grafana_admin_user=u,
grafana_admin_password=body.grafana_admin_password,
chart_version=prom_ver,
user_values=user_v,
),
)
except HelmAddonError as e:
raise _map_helm_error(e) from e
logger.info("Установлен kube-prometheus-stack для кластера «%s»", n)
return HelmAddonApplyResponse(ok=True, message="kube-prometheus-stack установлен.", log=_trim_log(log))
@router.delete(
"/clusters/{name}/addons/kube-prometheus-stack",
response_model=HelmAddonApplyResponse,
summary="Удалить kube-prometheus-stack",
)
async def delete_kube_prometheus_stack(name: str) -> HelmAddonApplyResponse:
n = name.strip()
if not validate_cluster_name(n):
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
meta = {"addon": "kube-prometheus-stack", "action": "delete"}
try:
log = await _run_helm_with_journal(
n,
"helm_addon_delete_kube_prometheus_stack",
meta,
lambda: uninstall_kube_prometheus_stack(n),
)
except HelmAddonError as e:
raise _map_helm_error(e) from e
return HelmAddonApplyResponse(ok=True, message="kube-prometheus-stack удалён.", log=_trim_log(log))
@router.post(
"/clusters/{name}/addons/metrics-server",
response_model=HelmAddonApplyResponse,
summary="Установить metrics-server",
)
async def post_install_metrics_server(
name: str,
body: MetricsServerInstallRequest | None = Body(default=None),
) -> HelmAddonApplyResponse:
n = name.strip()
if not validate_cluster_name(n):
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
ms_ver: str | None = None
if body is not None and body.chart_version and body.chart_version.strip():
ms_ver = body.chart_version.strip()
user_v = _parse_optional_values_yaml(body.values_yaml if body else None)
meta = {"addon": "metrics-server", "action": "install", "chart_version": ms_ver}
try:
log = await _run_helm_with_journal(
n,
"helm_addon_install_metrics_server",
meta,
lambda: install_metrics_server(n, ms_ver, user_v),
)
except HelmAddonError as e:
raise _map_helm_error(e) from e
logger.info("Установлен metrics-server для кластера «%s»", n)
return HelmAddonApplyResponse(ok=True, message="metrics-server установлен.", log=_trim_log(log))
@router.delete(
"/clusters/{name}/addons/metrics-server",
response_model=HelmAddonApplyResponse,
summary="Удалить metrics-server",
)
async def delete_metrics_server(name: str) -> HelmAddonApplyResponse:
n = name.strip()
if not validate_cluster_name(n):
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
meta = {"addon": "metrics-server", "action": "delete"}
try:
log = await _run_helm_with_journal(
n,
"helm_addon_delete_metrics_server",
meta,
lambda: uninstall_metrics_server(n),
)
except HelmAddonError as e:
raise _map_helm_error(e) from e
return HelmAddonApplyResponse(ok=True, message="metrics-server удалён.", log=_trim_log(log))
@router.post(
"/clusters/{name}/addons/istio-kiali",
response_model=HelmAddonApplyResponse,
summary="Установить Istio (base+istiod) и Kiali с логином",
)
async def post_install_istio_kiali(
name: str,
body: IstioKialiInstallRequest,
) -> HelmAddonApplyResponse:
n = name.strip()
if not validate_cluster_name(n):
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
istio_ver: str | None = None
if body.istio_chart_version and body.istio_chart_version.strip():
istio_ver = body.istio_chart_version.strip()
kiali_ver: str | None = None
if body.kiali_chart_version and body.kiali_chart_version.strip():
kiali_ver = body.kiali_chart_version.strip()
ku = body.kiali_username.strip()
kiali_uv = _parse_optional_values_yaml(body.values_yaml)
istio_base_v = _parse_optional_values_yaml(body.istio_base_values_yaml)
istiod_v = _parse_optional_values_yaml(body.istio_istiod_values_yaml)
meta = {
"addon": "istio-kiali",
"action": "install",
"istio_chart_version": istio_ver,
"kiali_chart_version": kiali_ver,
"kiali_username": ku,
}
try:
log = await _run_helm_with_journal(
n,
"helm_addon_install_istio_kiali",
meta,
lambda: install_istio_and_kiali(
n,
kiali_username=ku,
kiali_password=body.kiali_password,
istio_chart_version=istio_ver,
kiali_chart_version=kiali_ver,
istio_base_values=istio_base_v,
istiod_values=istiod_v,
kiali_user_values=kiali_uv,
),
)
except HelmAddonError as e:
raise _map_helm_error(e) from e
logger.info("Установлены Istio+Kiali для кластера «%s»", n)
return HelmAddonApplyResponse(ok=True, message="Istio и Kiali установлены.", log=_trim_log(log))
@router.delete(
"/clusters/{name}/addons/istio-kiali",
response_model=HelmAddonApplyResponse,
summary="Удалить Kiali, istiod и istio-base",
)
async def delete_istio_kiali(name: str) -> HelmAddonApplyResponse:
n = name.strip()
if not validate_cluster_name(n):
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
meta = {"addon": "istio-kiali", "action": "delete"}
try:
log = await _run_helm_with_journal(
n,
"helm_addon_delete_istio_kiali",
meta,
lambda: uninstall_istio_and_kiali(n),
)
except HelmAddonError as e:
raise _map_helm_error(e) from e
return HelmAddonApplyResponse(ok=True, message="Istio/Kiali удалены.", log=_trim_log(log))