"""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))