Веб-интерфейс: страница /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

View File

@@ -22,7 +22,7 @@ from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from kind_k8s_paths import clusters_dir, data_root
from kind_k8s_paths import clusters_dir, container_cli_name, data_root
from kindest_node_tags import normalize_tag_v_prefix
from kubeconfig_patch import (
kubeconfig_host_file,
@@ -504,13 +504,15 @@ def create_cluster_non_interactive(
if not use_existing_config and (workers < 0 or workers > 20):
raise KindClusterError("Количество worker-нод должно быть от 0 до 20.")
ver_tag = normalize_tag_v_prefix(kubernetes_version_tag)
node_image = f"kindest/node:{ver_tag}"
# Каталог данных кластера до долгих шагов — чтобы при сбое журнал в journal/jobs_history.json
# и provision_log могли быть привязаны к существующему clusters/<имя>/.
root = data_root()
cdir = clusters_dir()
out_dir = cdir / name
out_dir.mkdir(parents=True, exist_ok=True)
ver_tag = normalize_tag_v_prefix(kubernetes_version_tag)
node_image = f"kindest/node:{ver_tag}"
cfg_path = out_dir / "kind-config.yaml"
kube_path = out_dir / "kubeconfig"
meta_path = out_dir / "meta.json"
@@ -912,14 +914,14 @@ def read_meta_json(cluster_name: str) -> dict[str, object] | None:
def _container_cli_bin() -> str:
"""Имя CLI к сокету (docker / podman), как в kubeconfig_patch."""
return (os.environ.get("CONTAINER_CLI") or "docker").strip() or "docker"
"""Имя CLI к сокету: см. :func:`kind_k8s_paths.container_cli_name`."""
return container_cli_name()
def configured_container_cli() -> str:
"""Активное имя CLI из ``CONTAINER_CLI`` (docker/podman).
"""Имя CLI, которое реально вызывает процесс (в контейнере веб-UI — всегда ``docker``).
Единый источник для проверки движка, сбора ``docker stats``/``podman stats`` и поля ``container_cli`` в GET /stats.
Единый источник для ``docker stats``/``podman stats``, ``kind``, health и поля ``container_cli`` в API.
"""
return _container_cli_bin()

View File

@@ -9,12 +9,12 @@
from __future__ import annotations
import logging
import os
import re
import shutil
import subprocess
from core.cluster_lifecycle import _sort_kind_node_containers, list_registered_kind_clusters
from kind_k8s_paths import container_cli_name
from models.schemas import AggregateResourcesSummary, KindClusterResources, KindNodeResourceStat
logger = logging.getLogger("kind_k8s.container_resource_stats")
@@ -24,7 +24,7 @@ _STAT_FORMAT = "{{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}\t{{.NetIO}}
def _container_cli_bin() -> str:
return (os.environ.get("CONTAINER_CLI") or "docker").strip() or "docker"
return container_cli_name()
def _list_running_container_names(cli: str) -> set[str]:

View File

@@ -37,6 +37,15 @@ HELM_REPOS: dict[str, str] = {
"kiali": "https://kiali.org/helm-charts",
}
# Идентификаторы аддонов, доступных для установки на странице «Аддоны» и через API ``/clusters/{name}/addons/…``.
# Должен совпадать с набором в ``HelmComposeValuesRequest.addon`` и кнопками в ``cluster_addons.html``.
HELM_INSTALLABLE_ADDON_IDS: tuple[str, ...] = (
"ingress-nginx",
"kube-prometheus-stack",
"metrics-server",
"istio-kiali",
)
# Ссылки для ``helm search repo <ref> --versions`` (ключи совпадают с полями API версий чартов).
HELM_ADDON_CHART_REFS: dict[str, str] = {
"ingress_nginx": "ingress-nginx/ingress-nginx",
@@ -59,6 +68,121 @@ class HelmAddonError(Exception):
self.exit_code = exit_code
def dump_helm_values_yaml(data: dict[str, Any]) -> str:
"""Сериализация mapping в YAML для UI и временных файлов Helm."""
return yaml.safe_dump(
data,
allow_unicode=True,
default_flow_style=False,
sort_keys=False,
)
def helm_show_values_dict(chart_ref: str, chart_version: str | None = None) -> dict[str, Any]:
"""
Словарь дефолтных values чарта: ``helm show values <ref> [--version …]``.
При пустом stdout или ошибке разбора возвращает ``{}`` (ошибка helm — :class:`HelmAddonError`).
"""
require_helm_binary()
cref = chart_ref.strip()
if not cref:
return {}
cmd = ["helm", "show", "values", cref]
ver = (chart_version or "").strip()
if ver:
cmd.extend(["--version", ver])
rc, out, err = _run_cmd(cmd, timeout=180)
if rc != 0:
raise HelmAddonError(
(err or out or f"helm show values {cref} код {rc}")[:4000],
exit_code=rc,
)
raw = (out or "").strip()
if not raw:
return {}
try:
data = yaml.safe_load(raw)
except yaml.YAMLError as e:
logger.warning("YAML из helm show values %s: %s", cref, e)
return {}
return data if isinstance(data, dict) else {}
def _ingress_kind_service_overlay() -> dict[str, Any]:
"""То же, что передаётся через ``--set`` при установке ingress для kind."""
return {
"controller": {
"service": {
"type": "NodePort",
"nodePorts": {"http": 30080},
}
}
}
def compose_ingress_effective_values(chart_version: str | None = None) -> dict[str, Any]:
"""Дефолты чарта ingress-nginx + сервис NodePort (как в install). Репозитории — см. :func:`helm_repo_ensure`."""
base = helm_show_values_dict("ingress-nginx/ingress-nginx", chart_version)
return deep_merge_values(base, _ingress_kind_service_overlay())
def compose_kube_prometheus_effective_values(
chart_version: str | None,
grafana_admin_user: str,
grafana_admin_password: str,
) -> dict[str, Any]:
"""Дефолты kube-prometheus-stack + учётные данные Grafana."""
base = helm_show_values_dict("prometheus-community/kube-prometheus-stack", chart_version)
overlay: dict[str, Any] = {
"grafana": {
"adminUser": grafana_admin_user.strip(),
"adminPassword": grafana_admin_password,
}
}
return deep_merge_values(base, overlay)
def compose_metrics_server_effective_values(chart_version: str | None = None) -> dict[str, Any]:
"""Дефолты metrics-server + args для kind (как в install)."""
base = helm_show_values_dict("metrics-server/metrics-server", chart_version)
overlay: dict[str, Any] = {
"args": [
"--kubelet-insecure-tls",
"--kubelet-preferred-address-types=InternalIP,Hostname,ExternalIP",
]
}
return deep_merge_values(base, overlay)
def compose_istio_mesh_preview_values(
istio_chart_version: str | None = None,
kiali_chart_version: str | None = None,
) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]:
"""
Три словаря values: ``kiali-server``, ``istio/base``, ``istio/istiod``
(чистые ``helm show values`` для редактирования в UI).
"""
kiali = helm_show_values_dict("kiali/kiali-server", kiali_chart_version)
ib = helm_show_values_dict("istio/base", istio_chart_version)
ird = helm_show_values_dict("istio/istiod", istio_chart_version)
return kiali, ib, ird
def deep_merge_values(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
"""
Рекурсивное объединение mapping: ключи из ``override`` перекрывают ``base``;
для вложенных dict сливаем дальше (как типичный merge для Helm values).
"""
out: dict[str, Any] = dict(base)
for k, v in override.items():
if k in out and isinstance(out[k], dict) and isinstance(v, dict):
out[k] = deep_merge_values(out[k], v)
else:
out[k] = v
return out
def _helm_timeout_sec() -> int:
raw = (os.environ.get("KIND_K8S_HELM_TIMEOUT_SEC") or "900").strip()
try:
@@ -283,6 +407,7 @@ def _helm_upgrade_install(
chart_version: str | None = None,
extra_args: list[str] | None = None,
values_file: Path | None = None,
extra_values_files: list[Path] | None = None,
) -> tuple[int, str, str]:
kcfg = str(_kubeconfig_runtime_path(cluster_name))
cmd = [
@@ -305,6 +430,8 @@ def _helm_upgrade_install(
cmd.extend(["--version", ver])
if values_file is not None:
cmd.extend(["-f", str(values_file)])
for p in extra_values_files or []:
cmd.extend(["-f", str(p)])
if extra_args:
cmd.extend(extra_args)
return _run_cmd(cmd, timeout=_helm_timeout_sec() + 60)
@@ -321,23 +448,41 @@ def _helm_uninstall(cluster_name: str, release: str, namespace: str) -> tuple[in
# --- Установка / удаление по типам ---
def install_ingress_nginx(cluster_name: str, chart_version: str | None) -> tuple[bool, str]:
"""ingress-nginx с NodePort для kind."""
def install_ingress_nginx(
cluster_name: str,
chart_version: str | None,
user_values: dict[str, Any] | None = None,
) -> tuple[bool, str]:
"""
ingress-nginx: полный YAML values из UI (``helm show values`` + NodePort) или переданный словарь;
после файла values остаются ``--set`` для NodePort (приоритет как у Helm).
"""
helm_repo_ensure()
eff = user_values if user_values is not None else compose_ingress_effective_values(chart_version)
extra: list[str] = [
"--set",
"controller.service.type=NodePort",
"--set",
"controller.service.nodePorts.http=30080",
]
rc, out, err = _helm_upgrade_install(
cluster_name,
"ingress-nginx",
"ingress-nginx/ingress-nginx",
"ingress-nginx",
chart_version=chart_version,
extra_args=extra,
)
user_tmp: Path | None = None
try:
fd, name = tempfile.mkstemp(suffix=".yaml", prefix="helm-ing-values-")
os.close(fd)
user_tmp = Path(name)
user_tmp.write_text(dump_helm_values_yaml(eff), encoding="utf-8")
rc, out, err = _helm_upgrade_install(
cluster_name,
"ingress-nginx",
"ingress-nginx/ingress-nginx",
"ingress-nginx",
chart_version=chart_version,
extra_args=extra,
extra_values_files=[user_tmp],
)
finally:
if user_tmp is not None:
user_tmp.unlink(missing_ok=True)
text = "\n".join(filter(None, [out, err])).strip()
if rc != 0:
raise HelmAddonError(text or f"helm exit {rc}", exit_code=rc)
@@ -358,18 +503,23 @@ def install_kube_prometheus_stack(
grafana_admin_user: str,
grafana_admin_password: str,
chart_version: str | None = None,
user_values: dict[str, Any] | None = None,
) -> tuple[bool, str]:
"""Полный values из UI или автосборка (``helm show values`` + Grafana из формы)."""
helm_repo_ensure()
values = {
"grafana": {
"adminUser": grafana_admin_user,
"adminPassword": grafana_admin_password,
}
}
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False, encoding="utf-8") as tmp:
yaml.safe_dump(values, tmp, allow_unicode=True)
tmp_path = Path(tmp.name)
if user_values is not None:
values = user_values
else:
values = compose_kube_prometheus_effective_values(
chart_version,
grafana_admin_user,
grafana_admin_password,
)
fd, tmp_name = tempfile.mkstemp(suffix=".yaml", prefix="helm-prom-stack-")
os.close(fd)
tmp_path = Path(tmp_name)
try:
tmp_path.write_text(dump_helm_values_yaml(values), encoding="utf-8")
rc, out, err = _helm_upgrade_install(
cluster_name,
"kube-prometheus-stack",
@@ -394,18 +544,22 @@ def uninstall_kube_prometheus_stack(cluster_name: str) -> tuple[bool, str]:
return True, text or "kube-prometheus-stack удалён (или отсутствовал)."
def install_metrics_server(cluster_name: str, chart_version: str | None = None) -> tuple[bool, str]:
def install_metrics_server(
cluster_name: str,
chart_version: str | None = None,
user_values: dict[str, Any] | None = None,
) -> tuple[bool, str]:
"""Полный values из UI или автосборка (чарт + args для kind)."""
helm_repo_ensure()
values = {
"args": [
"--kubelet-insecure-tls",
"--kubelet-preferred-address-types=InternalIP,Hostname,ExternalIP",
]
}
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False, encoding="utf-8") as tmp:
yaml.safe_dump(values, tmp, allow_unicode=True)
tmp_path = Path(tmp.name)
if user_values is not None:
values = user_values
else:
values = compose_metrics_server_effective_values(chart_version)
fd_m, tmp_ms = tempfile.mkstemp(suffix=".yaml", prefix="helm-metrics-")
os.close(fd_m)
tmp_path = Path(tmp_ms)
try:
tmp_path.write_text(dump_helm_values_yaml(values), encoding="utf-8")
rc, out, err = _helm_upgrade_install(
cluster_name,
"metrics-server",
@@ -437,8 +591,16 @@ def install_istio_and_kiali(
kiali_password: str,
istio_chart_version: str | None = None,
kiali_chart_version: str | None = None,
istio_base_values: dict[str, Any] | None = None,
istiod_values: dict[str, Any] | None = None,
kiali_user_values: dict[str, Any] | None = None,
) -> tuple[bool, str]:
"""Istio base + istiod + секрет входа Kiali + chart kiali-server (strategy=login)."""
"""
Istio base + istiod (опциональные values из UI) + секрет Kiali + kiali-server.
Для каждого чарта: если словарь ``None`` — без файла ``-f`` (дефолты чарта);
если непустой dict — полный values как в редакторе.
"""
helm_repo_ensure()
_ensure_namespace(cluster_name, "istio-system")
@@ -446,27 +608,53 @@ def install_istio_and_kiali(
istio_ver = (istio_chart_version or "").strip() or None
kiali_ver = (kiali_chart_version or "").strip() or None
rc, out, err = _helm_upgrade_install(
cluster_name,
"istio-base",
"istio/base",
"istio-system",
chart_version=istio_ver,
)
log_parts.append(out or err)
if rc != 0:
raise HelmAddonError((err or out or f"istio-base exit {rc}")[:8000], exit_code=rc)
base_tmp: Path | None = None
try:
base_file: Path | None = None
if istio_base_values:
fd, name = tempfile.mkstemp(suffix=".yaml", prefix="helm-istio-base-")
os.close(fd)
base_tmp = Path(name)
base_tmp.write_text(dump_helm_values_yaml(istio_base_values), encoding="utf-8")
base_file = base_tmp
rc, out, err = _helm_upgrade_install(
cluster_name,
"istio-base",
"istio/base",
"istio-system",
chart_version=istio_ver,
values_file=base_file,
)
log_parts.append(out or err)
if rc != 0:
raise HelmAddonError((err or out or f"istio-base exit {rc}")[:8000], exit_code=rc)
finally:
if base_tmp is not None:
base_tmp.unlink(missing_ok=True)
rc2, o2, e2 = _helm_upgrade_install(
cluster_name,
"istiod",
"istio/istiod",
"istio-system",
chart_version=istio_ver,
)
log_parts.append(o2 or e2)
if rc2 != 0:
raise HelmAddonError((e2 or o2 or f"istiod exit {rc2}")[:8000], exit_code=rc2)
istiod_tmp: Path | None = None
try:
istiod_file: Path | None = None
if istiod_values:
fd, name = tempfile.mkstemp(suffix=".yaml", prefix="helm-istiod-")
os.close(fd)
istiod_tmp = Path(name)
istiod_tmp.write_text(dump_helm_values_yaml(istiod_values), encoding="utf-8")
istiod_file = istiod_tmp
rc2, o2, e2 = _helm_upgrade_install(
cluster_name,
"istiod",
"istio/istiod",
"istio-system",
chart_version=istio_ver,
values_file=istiod_file,
)
log_parts.append(o2 or e2)
if rc2 != 0:
raise HelmAddonError((e2 or o2 or f"istiod exit {rc2}")[:8000], exit_code=rc2)
finally:
if istiod_tmp is not None:
istiod_tmp.unlink(missing_ok=True)
# Секрет для стратегии login (ключи username / passphrase — ожидание Kiali).
secret = {
@@ -484,14 +672,27 @@ def install_istio_and_kiali(
raise HelmAddonError(f"Secret kiali: {msgs}", exit_code=rcs)
log_parts.append(msgs)
rc3, o3, e3 = _helm_upgrade_install(
cluster_name,
"kiali-server",
"kiali/kiali-server",
"istio-system",
chart_version=kiali_ver,
extra_args=["--set", "auth.strategy=login"],
)
kiali_extra: list[Path] = []
kiali_tmp: Path | None = None
try:
if kiali_user_values:
fd, name = tempfile.mkstemp(suffix=".yaml", prefix="helm-kiali-user-")
os.close(fd)
kiali_tmp = Path(name)
kiali_tmp.write_text(dump_helm_values_yaml(kiali_user_values), encoding="utf-8")
kiali_extra.append(kiali_tmp)
rc3, o3, e3 = _helm_upgrade_install(
cluster_name,
"kiali-server",
"kiali/kiali-server",
"istio-system",
chart_version=kiali_ver,
extra_args=["--set", "auth.strategy=login"],
extra_values_files=kiali_extra or None,
)
finally:
if kiali_tmp is not None:
kiali_tmp.unlink(missing_ok=True)
log_parts.append(o3 or e3)
if rc3 != 0:
raise HelmAddonError((e3 or o3 or f"kiali-server exit {rc3}")[:8000], exit_code=rc3)

View File

@@ -82,9 +82,18 @@ def append_job_from_record_sync(
return
cdir = clusters_dir() / name
# Страница «Журнал» читает clusters/<имя>/journal/jobs_history.json — нужен каталог кластера.
# После отмены create_cluster каталог мог быть удалён (rmtree) — не создаём пустой каталог заново.
if not cdir.is_dir():
logger.debug("Журнал кластера: каталог %s отсутствует", cdir)
return
if (status or "").lower() == "cancelled":
logger.debug("Журнал кластера: каталог %s удалён при отмене — запись jobs_history пропускаем", cdir)
return
try:
cdir.mkdir(parents=True, exist_ok=True)
logger.info("Журнал: создан каталог кластера для записи jobs_history: %s", cdir)
except OSError as e:
logger.warning("Журнал кластера: не удалось создать %s: %s", cdir, e)
return
jdir = cdir / JOURNAL_SUBDIR
finished = datetime.now(timezone.utc).isoformat()
@@ -185,21 +194,49 @@ def _collect_all_journal_rows_sorted_sync() -> list[tuple[str, dict[str, Any]]]:
return flat
def collect_recent_journal_entries_page_sync(*, limit: int, offset: int) -> tuple[list[dict[str, Any]], int]:
def collect_recent_journal_entries_page_sync(
*,
limit: int,
offset: int,
cluster_name: str | None = None,
) -> tuple[list[dict[str, Any]], int]:
"""
Страница записей из всех ``clusters/*/journal/jobs_history.json``, новые первыми.
Страница записей из ``journal/jobs_history.json``, новые первыми.
В каждую запись добавляется поле ``source_cluster`` (имя каталога).
Возвращает ``(страница записей, общее число записей)``.
Если ``cluster_name`` задан — только этот кластер (файл на диске), иначе объединение по всем кластерам.
В каждую запись добавляется поле ``source_cluster``.
"""
lim = max(1, min(limit, 100))
off = max(0, offset)
if cluster_name and str(cluster_name).strip():
n = str(cluster_name).strip()
if not validate_cluster_name(n):
return [], 0
raw = read_cluster_journal_sync(n)
if not raw or not isinstance(raw.get("entries"), list):
return [], 0
entries = [e for e in raw["entries"] if isinstance(e, dict)]
def _entry_time_key(e: dict[str, Any]) -> str:
return str(e.get("finished_at_utc") or e.get("created_at_utc") or "")
entries.sort(key=_entry_time_key, reverse=True)
total = len(entries)
chunk = entries[off : off + lim]
out: list[dict[str, Any]] = []
for e in chunk:
row = dict(e)
row["source_cluster"] = n
out.append(row)
return out, total
flat = _collect_all_journal_rows_sorted_sync()
total = len(flat)
chunk = flat[off : off + lim]
out: list[dict[str, Any]] = []
out2: list[dict[str, Any]] = []
for cluster_dir_name, e in chunk:
row = dict(e)
row["source_cluster"] = cluster_dir_name
out.append(row)
return out, total
out2.append(row)
return out2, total

View File

@@ -1,5 +1,8 @@
"""Сохранение полного журнала развёртывания кластера в ``clusters/<имя>/provision_log.json``.
Журнал операций Helm-аддонов — ``helm_addon_log.json``: **история** операций (массив ``entries``),
старый формат «один объект в файле» при чтении мигрируется автоматически.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
@@ -8,10 +11,13 @@ from __future__ import annotations
import json
import logging
import os
import threading
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from core.cluster_lifecycle import validate_cluster_name
from kind_k8s_paths import clusters_dir
logger = logging.getLogger("kind_k8s.provision_log")
@@ -19,12 +25,42 @@ logger = logging.getLogger("kind_k8s.provision_log")
PROVISION_LOG_FILENAME = "provision_log.json"
PROVISION_LOG_VERSION = 1
HELM_ADDON_LOG_FILENAME = "helm_addon_log.json"
"""Имя файла истории Helm; корневой объект с полем ``entries`` (версия файла 2)."""
HELM_ADDON_LOG_VERSION = 1
"""Версия схемы одной записи внутри ``entries``."""
HELM_ADDON_LOG_FILE_VERSION = 2
"""Версия обёртки файла (список ``entries``)."""
_locks_guard = threading.Lock()
_helm_log_locks: dict[str, threading.Lock] = {}
def _helm_cluster_lock(cluster_name: str) -> threading.Lock:
with _locks_guard:
if cluster_name not in _helm_log_locks:
_helm_log_locks[cluster_name] = threading.Lock()
return _helm_log_locks[cluster_name]
def _max_helm_addon_entries() -> int:
raw = (os.environ.get("KIND_K8S_HELM_ADDON_LOG_MAX_ENTRIES") or "500").strip()
try:
return max(20, min(int(raw), 5000))
except ValueError:
return 500
def provision_log_file_path(cluster_name: str) -> Path:
"""Путь к JSON с журналом операции для кластера ``cluster_name``."""
return clusters_dir() / cluster_name.strip() / PROVISION_LOG_FILENAME
def helm_addon_log_file_path(cluster_name: str) -> Path:
"""Путь к JSON с историей журналов установки/удаления Helm-аддонов."""
return clusters_dir() / cluster_name.strip() / HELM_ADDON_LOG_FILENAME
def write_cluster_provision_log(
*,
cluster_name: str,
@@ -72,3 +108,156 @@ def write_cluster_provision_log(
except OSError:
pass
return None
def _helm_addon_rows_from_parsed_json(data: dict[str, Any], cname: str) -> list[dict[str, Any]]:
"""Разобрать содержимое ``helm_addon_log.json`` в плоский список строк для UI/API."""
raw_entries = data.get("entries")
if isinstance(raw_entries, list) and raw_entries:
out: list[dict[str, Any]] = []
for e in raw_entries:
if isinstance(e, dict):
row = dict(e)
row["source_cluster"] = cname
out.append(row)
return out
# Легаси: один объект операции в корне файла
if data.get("job_id") is not None or data.get("finished_at_utc") is not None:
row = dict(data)
row["source_cluster"] = cname
return [row]
return []
def write_cluster_helm_addon_log(
*,
cluster_name: str,
job_id: str,
job_kind: str,
status: str,
message: str | None,
lines: list[str],
result: dict[str, Any] | None,
) -> Path | None:
"""
Добавить запись в историю ``helm_addon_log.json`` (новые записи в начале списка).
Файл хранит ``{"file_version": 2, "entries": [ ... ]}``; лимит длины — ``KIND_K8S_HELM_ADDON_LOG_MAX_ENTRIES``.
"""
name = cluster_name.strip()
cdir = clusters_dir() / name
if not cdir.is_dir():
logger.debug("Каталог кластера отсутствует, helm_addon_log не пишем: %s", cdir)
return None
path = cdir / HELM_ADDON_LOG_FILENAME
new_entry: dict[str, Any] = {
"version": HELM_ADDON_LOG_VERSION,
"job_id": job_id,
"kind": job_kind,
"cluster_name": name,
"finished_at_utc": datetime.now(timezone.utc).isoformat(),
"status": status,
"message": message,
"lines": list(lines),
"result": result,
}
lock = _helm_cluster_lock(name)
with lock:
entries: list[dict[str, Any]] = []
if path.is_file():
try:
raw = json.loads(path.read_text(encoding="utf-8"))
if isinstance(raw, dict):
ex = raw.get("entries")
if isinstance(ex, list):
entries = [e for e in ex if isinstance(e, dict)]
elif raw.get("job_id") is not None or raw.get("finished_at_utc") is not None:
legacy = {k: v for k, v in raw.items() if k != "source_cluster"}
entries = [legacy]
except (OSError, json.JSONDecodeError) as e:
logger.warning("helm_addon_log %s не прочитан, создаём заново: %s", path, e)
entries = []
entries.insert(0, new_entry)
cap = _max_helm_addon_entries()
if len(entries) > cap:
entries = entries[:cap]
payload: dict[str, Any] = {
"file_version": HELM_ADDON_LOG_FILE_VERSION,
"entries": entries,
}
tmp = path.with_suffix(path.suffix + ".tmp")
try:
tmp.write_text(
json.dumps(payload, ensure_ascii=False, indent=2, default=str),
encoding="utf-8",
)
tmp.replace(path)
logger.info(
"Сохранён журнал Helm-аддонов: %s (запись %s, всего записей в файле: %s)",
path,
job_id,
len(entries),
)
return path
except OSError as e:
logger.warning("Не удалось записать %s: %s", path, e)
try:
tmp.unlink(missing_ok=True)
except OSError:
pass
return None
def collect_cluster_dir_logs_page_sync(
*,
filename: str,
limit: int,
offset: int,
) -> tuple[list[dict[str, Any]], int]:
"""
Собрать JSON с диска по кластерам.
- ``provision_log.json`` — по одной строке на кластер (как раньше).
- ``helm_addon_log.json`` — по одной строке на **каждую** запись в ``entries`` (или одна для легаси-файла).
Сортировка по ``finished_at_utc`` (новые первыми), затем пагинация.
"""
lim = max(1, min(limit, 100))
off = max(0, offset)
root = clusters_dir()
if not root.is_dir():
return [], 0
rows: list[dict[str, Any]] = []
for sub in sorted(root.iterdir()):
if not sub.is_dir():
continue
cname = sub.name
if not validate_cluster_name(cname):
continue
path = sub / filename
if not path.is_file():
continue
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
continue
if not isinstance(data, dict):
continue
if filename == HELM_ADDON_LOG_FILENAME:
rows.extend(_helm_addon_rows_from_parsed_json(data, cname))
else:
row = dict(data)
row["source_cluster"] = cname
rows.append(row)
def sort_key(r: dict[str, Any]) -> str:
return str(r.get("finished_at_utc") or "")
rows.sort(key=sort_key, reverse=True)
total = len(rows)
chunk = rows[off : off + lim]
return chunk, total