Веб-интерфейс: страница /clusters, навигация и крошки для кластеров

- Выделена страница списка кластеров, панель упрощена; nav_active и крошки
  ведут в раздел Кластеры; theme.js синхронизирует активную пилюлю по URL.
- Доработки дашборда, аддонов, журнала, стилей и API-документации.
- Поддержка Podman: docker-compose.podman.yml, скрипты сокета; Makefile и env.
This commit is contained in:
Sergey Antropoff
2026-04-04 13:42:21 +03:00
parent 17f6233fd7
commit eb063aec20
38 changed files with 3990 additions and 734 deletions
+187 -46
View File
@@ -1,6 +1,8 @@
"""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
@@ -10,7 +12,11 @@ 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
@@ -28,6 +34,7 @@ from core.helm_addons import (
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 (
ClusterAddonsStatusResponse,
HelmAddonApplyResponse,
@@ -57,6 +64,79 @@ def _map_helm_error(e: HelmAddonError) -> HTTPException:
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,
@@ -102,11 +182,17 @@ async def post_install_ingress_nginx(
ver: str | None = None
if body is not None and body.chart_version and body.chart_version.strip():
ver = body.chart_version.strip()
async with kind_cluster_lock:
try:
_, log = await asyncio.to_thread(install_ingress_nginx, n, ver)
except HelmAddonError as e:
raise _map_helm_error(e) from e
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))
@@ -120,11 +206,16 @@ async def delete_ingress_nginx(name: str) -> HelmAddonApplyResponse:
n = name.strip()
if not validate_cluster_name(n):
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
async with kind_cluster_lock:
try:
_, log = await asyncio.to_thread(uninstall_ingress_nginx, n)
except HelmAddonError as e:
raise _map_helm_error(e) from e
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))
@@ -143,17 +234,29 @@ async def post_install_kube_prometheus_stack(
prom_ver: str | None = None
if body.chart_version and body.chart_version.strip():
prom_ver = body.chart_version.strip()
async with kind_cluster_lock:
try:
_, log = await asyncio.to_thread(
install_kube_prometheus_stack,
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=body.grafana_admin_user.strip(),
grafana_admin_user=u,
grafana_admin_password=body.grafana_admin_password,
chart_version=prom_ver,
)
except HelmAddonError as e:
raise _map_helm_error(e) from e
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))
@@ -167,11 +270,16 @@ async def delete_kube_prometheus_stack(name: str) -> HelmAddonApplyResponse:
n = name.strip()
if not validate_cluster_name(n):
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
async with kind_cluster_lock:
try:
_, log = await asyncio.to_thread(uninstall_kube_prometheus_stack, n)
except HelmAddonError as e:
raise _map_helm_error(e) from e
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))
@@ -190,11 +298,17 @@ async def post_install_metrics_server(
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()
async with kind_cluster_lock:
try:
_, log = await asyncio.to_thread(install_metrics_server, n, ms_ver)
except HelmAddonError as e:
raise _map_helm_error(e) from e
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))
@@ -208,11 +322,16 @@ async def delete_metrics_server(name: str) -> HelmAddonApplyResponse:
n = name.strip()
if not validate_cluster_name(n):
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
async with kind_cluster_lock:
try:
_, log = await asyncio.to_thread(uninstall_metrics_server, n)
except HelmAddonError as e:
raise _map_helm_error(e) from e
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))
@@ -234,18 +353,35 @@ async def post_install_istio_kiali(
kiali_ver: str | None = None
if body.kiali_chart_version and body.kiali_chart_version.strip():
kiali_ver = body.kiali_chart_version.strip()
async with kind_cluster_lock:
try:
_, log = await asyncio.to_thread(
install_istio_and_kiali,
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=body.kiali_username.strip(),
kiali_username=ku,
kiali_password=body.kiali_password,
istio_chart_version=istio_ver,
kiali_chart_version=kiali_ver,
)
except HelmAddonError as e:
raise _map_helm_error(e) from e
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))
@@ -259,9 +395,14 @@ async def delete_istio_kiali(name: str) -> HelmAddonApplyResponse:
n = name.strip()
if not validate_cluster_name(n):
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
async with kind_cluster_lock:
try:
_, log = await asyncio.to_thread(uninstall_istio_and_kiali, n)
except HelmAddonError as e:
raise _map_helm_error(e) from e
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))
+2
View File
@@ -52,6 +52,7 @@ from core.container_resource_stats import (
collect_single_cluster_resource_stats,
running_kind_clusters_mask,
)
from core.helm_addons import HELM_INSTALLABLE_ADDON_IDS
from core.job_store import (
JobRecord,
end_job_tracking,
@@ -198,6 +199,7 @@ def _stats_sync() -> StatsResponse:
total_workers_from_meta=total_workers,
jobs_total=len(jobs),
jobs_recent_failed=failed,
helm_addons_installable_count=len(HELM_INSTALLABLE_ADDON_IDS),
cluster_resources=res_blocks,
cluster_resources_error=res_err,
aggregate_cluster_resources=agg,
+116 -3
View File
@@ -1,4 +1,4 @@
"""Справочник версий Helm-чартов для страницы аддонов (``helm search repo … --versions``).
"""Справочник версий Helm-чартов и сборка YAML values для редактора на странице аддонов.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
@@ -11,14 +11,76 @@ import logging
from fastapi import APIRouter, HTTPException
from core.helm_addons import HelmAddonError, helm_addon_chart_versions_all, require_helm_binary
from models.schemas import HelmAddonChartVersionsResponse
from core.helm_addons import (
HelmAddonError,
compose_ingress_effective_values,
compose_istio_mesh_preview_values,
compose_kube_prometheus_effective_values,
compose_metrics_server_effective_values,
dump_helm_values_yaml,
helm_addon_chart_versions_all,
helm_repo_ensure,
require_helm_binary,
)
from models.schemas import (
HelmAddonChartVersionsResponse,
HelmAddonComposeBatchRequest,
HelmAddonComposeBatchResponse,
HelmAddonComposeValuesRequest,
HelmAddonComposeValuesResponse,
)
logger = logging.getLogger("kind_k8s.api.helm_charts")
router = APIRouter(tags=["helm-charts"])
def _compose_values_sync(req: HelmAddonComposeValuesRequest) -> HelmAddonComposeValuesResponse:
"""Синхронная сборка YAML (вызов из ``asyncio.to_thread``): ``helm show values`` + оверлеи."""
helm_repo_ensure()
addon = req.addon
if addon == "ingress-nginx":
data = compose_ingress_effective_values(req.chart_version)
return HelmAddonComposeValuesResponse(values_yaml=dump_helm_values_yaml(data))
if addon == "kube-prometheus-stack":
u = (req.grafana_admin_user or "admin").strip() or "admin"
pw = (req.grafana_admin_password or "").strip() or "ChangeMe12345"
data = compose_kube_prometheus_effective_values(req.chart_version, u, pw)
return HelmAddonComposeValuesResponse(values_yaml=dump_helm_values_yaml(data))
if addon == "metrics-server":
data = compose_metrics_server_effective_values(req.chart_version)
return HelmAddonComposeValuesResponse(values_yaml=dump_helm_values_yaml(data))
if addon == "istio-kiali":
k, ib, ir = compose_istio_mesh_preview_values(req.istio_chart_version, req.kiali_chart_version)
return HelmAddonComposeValuesResponse(
values_yaml=dump_helm_values_yaml(k),
istio_base_values_yaml=dump_helm_values_yaml(ib),
istio_istiod_values_yaml=dump_helm_values_yaml(ir),
)
raise RuntimeError(f"неизвестный addon: {addon}")
def _compose_batch_sync(body: HelmAddonComposeBatchRequest) -> HelmAddonComposeBatchResponse:
"""Один ``helm repo update`` и шесть выводов ``helm show values`` (с оверлеями где нужно)."""
helm_repo_ensure()
u = (body.grafana_admin_user or "admin").strip() or "admin"
pw = (body.grafana_admin_password or "").strip() or "ChangeMe12345"
ing = dump_helm_values_yaml(compose_ingress_effective_values(body.ingress_chart_version))
prom = dump_helm_values_yaml(
compose_kube_prometheus_effective_values(body.kube_prometheus_chart_version, u, pw),
)
ms = dump_helm_values_yaml(compose_metrics_server_effective_values(body.metrics_server_chart_version))
k, ib, ir = compose_istio_mesh_preview_values(body.istio_chart_version, body.kiali_chart_version)
return HelmAddonComposeBatchResponse(
ingress_nginx_values_yaml=ing,
kube_prometheus_values_yaml=prom,
metrics_server_values_yaml=ms,
kiali_values_yaml=dump_helm_values_yaml(k),
istio_base_values_yaml=dump_helm_values_yaml(ib),
istio_istiod_values_yaml=dump_helm_values_yaml(ir),
)
@router.get(
"/helm/chart-versions",
response_model=HelmAddonChartVersionsResponse,
@@ -42,3 +104,54 @@ async def get_helm_addon_chart_versions() -> HelmAddonChartVersionsResponse:
logger.exception("Ошибка получения версий чартов")
raise HTTPException(status_code=500, detail=str(e)) from e
return HelmAddonChartVersionsResponse(**data)
@router.post(
"/helm/addons/compose-values",
response_model=HelmAddonComposeValuesResponse,
summary="Собрать YAML values для редактора аддонов (helm show values + форма)",
)
async def post_helm_addon_compose_values(
body: HelmAddonComposeValuesRequest,
) -> HelmAddonComposeValuesResponse:
"""
Выполняет ``helm repo update`` (внутри compose-функций) и ``helm show values`` для выбранного аддона.
Для **kube-prometheus-stack** в YAML подставляются логин и пароль Grafana из тела запроса;
если пароль пуст, в preview используется временная строка ``ChangeMe12345`` (замените перед установкой).
Для **istio-kiali** возвращаются три блока YAML: **kiali-server**, **istio/base**, **istio/istiod**.
"""
try:
require_helm_binary()
except HelmAddonError as e:
raise HTTPException(status_code=503, detail=str(e)) from e
try:
return await asyncio.to_thread(_compose_values_sync, body)
except HelmAddonError as e:
logger.exception("compose-values: ошибка helm")
raise HTTPException(status_code=500, detail=str(e)) from e
@router.post(
"/helm/addons/compose-values-batch",
response_model=HelmAddonComposeBatchResponse,
summary="Собрать все YAML values для страницы аддонов одним запросом",
)
async def post_helm_addon_compose_values_batch(
body: HelmAddonComposeBatchRequest,
) -> HelmAddonComposeBatchResponse:
"""
Один проход **``helm repo update``** и подстановка версий из селектов / полей формы на странице аддонов.
Удобнее, чем четыре отдельных **compose-values**, когда UI обновляет все карточки сразу.
"""
try:
require_helm_binary()
except HelmAddonError as e:
raise HTTPException(status_code=503, detail=str(e)) from e
try:
return await asyncio.to_thread(_compose_batch_sync, body)
except HelmAddonError as e:
logger.exception("compose-values-batch: ошибка helm")
raise HTTPException(status_code=500, detail=str(e)) from e
+85 -4
View File
@@ -1,4 +1,4 @@
"""Чтение журнала завершённых заданий из ``clusters/<имя>/journal/jobs_history.json``.
"""Чтение журналов: задания (jobs_history), развёртывание (provision_log), Helm-аддоны (helm_addon_log).
Автор: Сергей Антропов
Сайт: https://devops.org.ru
@@ -13,7 +13,18 @@ from fastapi import APIRouter, HTTPException, Query
from core.cluster_lifecycle import validate_cluster_name
from core.job_journal import collect_recent_journal_entries_page_sync, read_cluster_journal_sync
from models.schemas import ClusterJournalResponse, JournalEntryModel, JournalRecentResponse
from core.provision_log import (
HELM_ADDON_LOG_FILENAME,
PROVISION_LOG_FILENAME,
collect_cluster_dir_logs_page_sync,
)
from models.schemas import (
ClusterDirLogEntryModel,
ClusterJournalResponse,
JournalEntryModel,
JournalPagedDirLogsResponse,
JournalRecentResponse,
)
logger = logging.getLogger("kind_k8s.api.journal")
@@ -26,20 +37,52 @@ def _journal_total_pages(total: int, limit: int) -> int:
return (total + limit - 1) // limit
def _build_paged_dir_response(
rows: list[dict],
*,
limit: int,
offset: int,
total: int,
) -> JournalPagedDirLogsResponse:
total_pages = _journal_total_pages(total, limit)
if total <= 0:
page = 1
else:
page = min((offset // limit) + 1, total_pages)
page = max(1, page)
entries = [ClusterDirLogEntryModel.model_validate(r) for r in rows]
return JournalPagedDirLogsResponse(
limit=limit,
offset=offset,
total=total,
page=page,
total_pages=total_pages,
entries=entries,
)
@router.get(
"/journal/recent",
response_model=JournalRecentResponse,
summary="Страница журнала по всем кластерам (пагинация)",
summary="Страница журнала заданий (jobs_history), опционально один кластер",
)
async def get_journal_recent(
limit: int = Query(default=30, ge=1, le=100, description="Записей на страницу"),
offset: int = Query(default=0, ge=0, description="Смещение (0 = первая страница)"),
cluster: str | None = Query(
default=None,
description="Имя кластера: только записи из clusters/<имя>/journal/jobs_history.json",
),
) -> JournalRecentResponse:
"""Сборка из ``clusters/*/journal/jobs_history.json``, новые первыми; ``total`` и ``total_pages`` для UI."""
"""Сборка из ``journal/jobs_history.json``; при ``cluster`` — только выбранный каталог."""
c = cluster.strip() if cluster else None
if c and not validate_cluster_name(c):
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
rows, total = await asyncio.to_thread(
collect_recent_journal_entries_page_sync,
limit=limit,
offset=offset,
cluster_name=c,
)
entries = [JournalEntryModel.model_validate(r) for r in rows]
total_pages = _journal_total_pages(total, limit)
@@ -58,6 +101,44 @@ async def get_journal_recent(
)
@router.get(
"/journal/provision",
response_model=JournalPagedDirLogsResponse,
summary="Журналы развёртывания по кластерам (provision_log.json)",
)
async def get_journal_provision_logs(
limit: int = Query(default=30, ge=1, le=100),
offset: int = Query(default=0, ge=0),
) -> JournalPagedDirLogsResponse:
"""По одному последнему ``provision_log.json`` на кластер, новые первыми."""
rows, total = await asyncio.to_thread(
collect_cluster_dir_logs_page_sync,
filename=PROVISION_LOG_FILENAME,
limit=limit,
offset=offset,
)
return _build_paged_dir_response(rows, limit=limit, offset=offset, total=total)
@router.get(
"/journal/helm-addons",
response_model=JournalPagedDirLogsResponse,
summary="Журналы установки Helm-аддонов (helm_addon_log.json)",
)
async def get_journal_helm_addon_logs(
limit: int = Query(default=30, ge=1, le=100),
offset: int = Query(default=0, ge=0),
) -> JournalPagedDirLogsResponse:
"""Все записи из ``helm_addon_log.json`` по кластерам (история операций), новые первыми."""
rows, total = await asyncio.to_thread(
collect_cluster_dir_logs_page_sync,
filename=HELM_ADDON_LOG_FILENAME,
limit=limit,
offset=offset,
)
return _build_paged_dir_response(rows, limit=limit, offset=offset, total=total)
@router.get(
"/clusters/{name}/journal",
response_model=ClusterJournalResponse,