d55e69c756
- Отдельная section.card после сводки ресурсов узлов, выравнивание бейджа Helm с заголовком - Расширение эндпоинтов аддонов, схемы, helm_addons, cluster_addons.js/html - Обновление api_routes.md
470 lines
17 KiB
Python
470 lines
17 KiB
Python
"""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))
|