"""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 # Идентификатор аддона в API/UI → ключ :func:`helm_addon_snapshot` и суффикс ``job_kind`` (snake_case). _HELM_ADDON_SNAPSHOT_KEY: dict[str, str] = { "ingress-nginx": "ingress_nginx", "kube-prometheus-stack": "kube_prometheus_stack", "metrics-server": "metrics_server", "istio-kiali": "istio_mesh_ready", } _HELM_ADDON_JOB_SLUG: dict[str, str] = { "ingress-nginx": "ingress_nginx", "kube-prometheus-stack": "kube_prometheus_stack", "metrics-server": "metrics_server", "istio-kiali": "istio_kiali", } def _helm_journal_message_ru(addon_id: str, action: str) -> str: """Краткая подпись операции для колонки «Сообщение» в журнале Helm и для UI.""" name = { "ingress-nginx": "ingress-nginx", "kube-prometheus-stack": "kube-prometheus-stack", "metrics-server": "metrics-server", "istio-kiali": "Istio + Kiali", }.get(addon_id, addon_id) verb = { "install": "Установка", "upgrade": "Обновление", "delete": "Удаление", }.get(action, action) return f"{verb}: {name}" async def _helm_install_action_and_job_kind(cluster_name: str, addon_id: str) -> tuple[str, str]: """ Перед ``helm upgrade --install``: если релиз уже есть — операция **upgrade**, иначе **install**. Возвращает ``(action, job_kind)``, например ``("upgrade", "helm_addon_upgrade_ingress_nginx")``. """ snap_key = _HELM_ADDON_SNAPSHOT_KEY.get(addon_id) slug = _HELM_ADDON_JOB_SLUG.get(addon_id, addon_id.replace("-", "_")) if not snap_key: return "install", f"helm_addon_install_{slug}" try: st = await asyncio.to_thread(helm_addon_snapshot, cluster_name) installed = bool(st.get(snap_key)) except HelmAddonError: installed = False action = "upgrade" if installed else "install" return action, f"helm_addon_{action}_{slug}" async def _run_helm_with_journal( cluster_name: str, job_kind: str, result_meta: dict[str, Any], sync_call: Callable[[], tuple[bool, str]], *, journal_message: str | None = None, ) -> 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=journal_message, 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) action, job_kind = await _helm_install_action_and_job_kind(n, "ingress-nginx") meta = {"addon": "ingress-nginx", "action": action, "chart_version": ver} try: log = await _run_helm_with_journal( n, job_kind, meta, lambda: install_ingress_nginx(n, ver, user_v), journal_message=_helm_journal_message_ru("ingress-nginx", action), ) 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), journal_message=_helm_journal_message_ru("ingress-nginx", "delete"), ) 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) action, job_kind = await _helm_install_action_and_job_kind(n, "kube-prometheus-stack") meta = { "addon": "kube-prometheus-stack", "action": action, "chart_version": prom_ver, "grafana_admin_user": u, } try: log = await _run_helm_with_journal( n, job_kind, 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, ), journal_message=_helm_journal_message_ru("kube-prometheus-stack", action), ) 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), journal_message=_helm_journal_message_ru("kube-prometheus-stack", "delete"), ) 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) action, job_kind = await _helm_install_action_and_job_kind(n, "metrics-server") meta = {"addon": "metrics-server", "action": action, "chart_version": ms_ver} try: log = await _run_helm_with_journal( n, job_kind, meta, lambda: install_metrics_server(n, ms_ver, user_v), journal_message=_helm_journal_message_ru("metrics-server", action), ) 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), journal_message=_helm_journal_message_ru("metrics-server", "delete"), ) 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 (по умолчанию auth anonymous)", ) 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() 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) action, job_kind = await _helm_install_action_and_job_kind(n, "istio-kiali") meta = { "addon": "istio-kiali", "action": action, "istio_chart_version": istio_ver, "kiali_chart_version": kiali_ver, } try: log = await _run_helm_with_journal( n, job_kind, meta, lambda: install_istio_and_kiali( n, 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, ), journal_message=_helm_journal_message_ru("istio-kiali", action), ) 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), journal_message=_helm_journal_message_ru("istio-kiali", "delete"), ) except HelmAddonError as e: raise _map_helm_error(e) from e return HelmAddonApplyResponse(ok=True, message="Istio/Kiali удалены.", log=_trim_log(log))