"""Модели запросов/ответов REST API веб-интерфейса. Автор: Сергей Антропов Сайт: https://devops.org.ru """ from __future__ import annotations from typing import Any, Literal from pydantic import BaseModel, Field, model_validator class ClusterCreateRequest(BaseModel): """Тело POST /api/v1/clusters — создание кластера.""" name: str = Field(..., min_length=1, max_length=63, description="DNS-имя кластера (a-z0-9-)") kubernetes_version: str = Field( ..., min_length=1, description="Тег kindest/node: latest, 1.29.4, v1.29.4 и т.д.", ) workers: int = Field(2, ge=0, le=20, description="Число worker-нод (0–20)") class ClusterCreateAccepted(BaseModel): """Ответ 202 — задание поставлено в очередь.""" job_id: str status: Literal["queued"] = "queued" message: str = "Создание кластера выполняется в фоне; опросите GET /api/v1/jobs/{job_id}" class JobView(BaseModel): """Статус фонового задания.""" job_id: str kind: str status: Literal["queued", "running", "success", "failed", "cancelled"] cluster_name: str | None created_at_utc: str message: str | None = None result: dict[str, Any] | None = None progress_stage: str | None = Field(default=None, description="Текущий этап операции (пока задание активно)") progress_percent: int | None = Field(default=None, description="Прогресс 0–100 для индикатора в UI") progress_log: list[str] = Field( default_factory=list, description="Хвост журнала (скачивание образа, длительные команды, этапы); при опросе GET /jobs/{id}", ) class ClusterSummary(BaseModel): """Элемент списка кластеров.""" name: str registered_in_kind: bool kind_nodes_running: bool = Field( default=False, description="Запущены контейнеры узлов kind (имя-control-plane, имя-worker…; docker/podman ps)", ) has_local_kubeconfig: bool has_provision_log: bool = Field( default=False, description="Есть сохранённый полный журнал развёртывания (provision_log.json)", ) meta: dict[str, Any] = Field(default_factory=dict) class KindNodeResourceStat(BaseModel): """Одна нода kind (контейнер): снимок ``docker stats`` / ``podman stats``.""" container_name: str cpu_percent: str | None = Field(default=None, description="Доля CPU, напр. 1.23%") memory_usage: str | None = Field(default=None, description="Использование / лимит, напр. 120MiB / 7.7GiB") memory_percent: str | None = Field(default=None, description="Процент памяти от лимита контейнера") net_io: str | None = Field(default=None, description="Сетевой I/O (накопительно за жизнь контейнера)") block_io: str | None = Field(default=None, description="Блочный I/O") pids: int | None = Field(default=None, description="Число процессов в контейнере") class KindClusterResources(BaseModel): """Ресурсы узлов одного кластера kind (только запущенные контейнеры).""" cluster_name: str nodes: list[KindNodeResourceStat] = Field(default_factory=list) note: str | None = Field(default=None, description="Пояснение, если узлов нет или ошибка списка") class AggregateResourcesSummary(BaseModel): """Сводка по всем запущенным узлам kind для донат-диаграмм на главной.""" nodes_count: int = Field(0, description="Число учтённых контейнеров-нод") cpu_ring: float = Field(0, ge=0, le=100, description="Заполнение кольца «средний CPU», 0–100") cpu_label: str = Field("—", description="Подпись в центре доната") memory_percent_ring: float = Field(0, ge=0, le=100) memory_percent_label: str = Field("—") memory_used_ratio_ring: float = Field(0, ge=0, le=100, description="Доля занятой RAM от лимита по строке MemUsage") memory_used_ratio_label: str = Field("—") network_ring: float = Field(0, ge=0, le=100, description="Индикатор суммарного сетевого I/O (условная шкала)") network_label: str = Field("—") disk_ring: float = Field(0, ge=0, le=100, description="Индикатор суммарного блочного I/O") disk_label: str = Field("—") class StatsResponse(BaseModel): """Краткая статистика для дашборда.""" container_cli: str = Field( description="CLI движка контейнеров (docker или podman): узлы kind и метрики собираются через него", ) kind_clusters_count: int local_cluster_dirs_count: int total_workers_from_meta: int = Field( 0, ge=0, description="Сумма worker_nodes по meta.json каталогов; 0 если ни в одном meta нет поля или данных", ) jobs_total: int jobs_recent_failed: int helm_addons_installable_count: int = Field( 0, ge=0, description="Число типовых Helm-аддонов в каталоге UI (страница «Аддоны»), доступных для установки", ) cluster_resources: list[KindClusterResources] = Field( default_factory=list, description="CPU/RAM/I/O узлов kind по данным container CLI", ) cluster_resources_error: str | None = Field( default=None, description="Глобальная ошибка сбора метрик (например CLI не найден)", ) aggregate_cluster_resources: AggregateResourcesSummary = Field( default_factory=AggregateResourcesSummary, description="Агрегаты по узлам для донатов на главной странице", ) class ClusterWorkloadsResponse(BaseModel): """Вывод kubectl по кластеру (узлы и поды).""" cluster_name: str nodes_rc: int | None = None nodes_output: str | None = None pods_rc: int | None = None pods_output: str | None = None error: str | None = None class K8sListJsonBlock(BaseModel): """Фрагмент списка ресурсов Kubernetes (поле ``items`` из JSON List) для таблицы в UI.""" ok: bool = True rc: int | None = Field(default=None, description="Код возврата при обращении к API кластера") items: list[dict[str, Any]] = Field(default_factory=list) message: str | None = Field(default=None, description="Текст ошибки или разбора JSON") class PodRestartRequest(BaseModel): """Тело POST: перезапуск пода через ``kubectl delete pod`` (воссоздание контроллером).""" namespace: str = Field(..., min_length=1, max_length=253, description="Namespace пода") pod: str = Field(..., min_length=1, max_length=253, description="Имя пода (metadata.name)") class PodRestartResponse(BaseModel): """Результат запроса на удаление/рестарт пода.""" cluster_name: str namespace: str pod: str rc: int message: str class ClusterOverviewResponse(BaseModel): """Сводка для страницы кластера: мета, ресурсы узлов, таблицы ресурсов Kubernetes.""" cluster_name: str registered_in_kind: bool = False kind_nodes_running: bool = False has_local_kubeconfig: bool = False has_provision_log: bool = False meta: dict[str, Any] = Field(default_factory=dict) resources_error: str | None = Field(default=None, description="Ошибка сбора docker/podman stats") cluster_resources: KindClusterResources = Field( default_factory=lambda: KindClusterResources(cluster_name="", nodes=[], note=None), ) aggregate_resources: AggregateResourcesSummary = Field(default_factory=AggregateResourcesSummary) kubeconfig_error: str | None = Field(default=None, description="Нет kubeconfig или недоступен API кластера") nodes_rc: int | None = None nodes_output: str | None = None pods_rc: int | None = None pods_output: str | None = None deployments_rc: int | None = None deployments_output: str | None = None statefulsets_rc: int | None = None statefulsets_output: str | None = None daemonsets_rc: int | None = None daemonsets_output: str | None = None services_rc: int | None = None services_output: str | None = None ingresses_rc: int | None = None ingresses_output: str | None = None k8s_nodes: K8sListJsonBlock = Field(default_factory=K8sListJsonBlock, description="Узлы кластера") k8s_pods: K8sListJsonBlock = Field(default_factory=K8sListJsonBlock, description="Поды, все namespace") k8s_deployments: K8sListJsonBlock = Field(default_factory=K8sListJsonBlock) k8s_statefulsets: K8sListJsonBlock = Field(default_factory=K8sListJsonBlock) k8s_daemonsets: K8sListJsonBlock = Field(default_factory=K8sListJsonBlock) k8s_services: K8sListJsonBlock = Field(default_factory=K8sListJsonBlock) k8s_ingresses: K8sListJsonBlock = Field(default_factory=K8sListJsonBlock) k8s_namespaces: K8sListJsonBlock = Field( default_factory=K8sListJsonBlock, description="Namespaces", ) k8s_replicasets: K8sListJsonBlock = Field( default_factory=K8sListJsonBlock, description="ReplicaSets, все namespace", ) k8s_jobs: K8sListJsonBlock = Field( default_factory=K8sListJsonBlock, description="Jobs, все namespace", ) k8s_cronjobs: K8sListJsonBlock = Field( default_factory=K8sListJsonBlock, description="CronJobs, все namespace", ) k8s_pvcs: K8sListJsonBlock = Field( default_factory=K8sListJsonBlock, description="PVC, все namespace", ) class ClusterConfigGetResponse(BaseModel): """Ответ GET /clusters/{name}/config — текущие meta и kind-config.yaml.""" cluster_name: str meta: dict[str, Any] = Field(default_factory=dict) kind_config_yaml: str | None = Field(default=None, description="Содержимое kind-config.yaml или null, если файла нет") has_kind_config: bool = False registered_in_kind: bool = Field( default=False, description="Есть ли кластер в списке kind (важно для подсказки про применение конфига)", ) kind_note: str = Field( default="", description="Пояснение: kind не меняет топологию уже созданного кластера без пересоздания", ) class ClusterConfigUpdateRequest(BaseModel): """Тело PUT /clusters/{name}/config. Если передан непустой ``kind_config_yaml``, он перезаписывает файл целиком; поля ``kubernetes_version`` и ``workers`` в этом запросе для генерации YAML не используются (можно не отправлять). """ kubernetes_version: str | None = Field( default=None, description="Тег kindest/node для простого режима (пересборка YAML: control-plane + N worker)", ) workers: int | None = Field(default=None, ge=0, le=20) kind_config_yaml: str | None = Field(default=None, description="Полный YAML манифеста kind Cluster") description: str | None = Field( default=None, max_length=2000, description="Текст в meta.json (пустая строка сбрасывает поле)", ) @model_validator(mode="after") def at_least_one_field(self) -> ClusterConfigUpdateRequest: has_ver = self.kubernetes_version is not None has_w = self.workers is not None yaml_s = (self.kind_config_yaml or "").strip() has_yaml = self.kind_config_yaml is not None and yaml_s != "" has_desc = self.description is not None if not (has_ver or has_w or has_yaml or has_desc): raise ValueError("Укажите хотя бы одно поле для обновления.") return self class ClusterConfigUpdateResponse(BaseModel): """Результат сохранения конфигурации кластера на диск.""" cluster_name: str updated_kind_config_yaml: bool = Field(description="Был ли перезаписан kind-config.yaml") message: str registered_in_kind: bool kind_note: str summary: ClusterSummary = Field(description="Актуальная сводка для строки таблицы / панели") # --- Helm-аддоны (страница «Дополнения кластера») --- class ClusterAddonsStatusResponse(BaseModel): """Какие релизы Helm обнаружены для кластера (по helm list -A).""" cluster_name: str ingress_nginx: bool = Field(description="Релиз ingress-nginx в ns ingress-nginx") kube_prometheus_stack: bool = Field(description="Релиз kube-prometheus-stack в ns monitoring") metrics_server: bool = Field(description="Релиз metrics-server в ns kube-system") istio_base: bool istiod: bool kiali_server: bool istio_mesh_ready: bool = Field( description="Установлены istio-base, istiod и kiali-server в istio-system", ) ingress_nginx_chart_version: str | None = Field( default=None, description="Версия чарта из helm list (если релиз установлен)", ) kube_prometheus_stack_chart_version: str | None = Field(default=None) metrics_server_chart_version: str | None = Field(default=None) istiod_chart_version: str | None = Field( default=None, description="Версия чарта istiod в кластере (для селекта Istio на UI)", ) istio_base_chart_version: str | None = Field(default=None) kiali_server_chart_version: str | None = Field(default=None) class InstalledAddonValuesBlob(BaseModel): """Снимок values одного релиза (helm get values --all).""" chart_version: str | None = Field(default=None, description="Версия чарта из helm list") values_yaml: str = Field(default="", description="YAML эффективных values") class IstioKialiInstalledValuesBlob(BaseModel): """Три релиза mesh + версии для селектов UI.""" istio_chart_version: str | None = Field( default=None, description="Версия istiod (или base), для селекта «Версия чартов Istio»", ) kiali_chart_version: str | None = None istio_base_values_yaml: str = "" istiod_values_yaml: str = "" kiali_values_yaml: str = "" class ClusterAddonsInstalledValuesResponse(BaseModel): """Values из кластера только для установленных аддонов (для редактирования перед upgrade).""" cluster_name: str ingress_nginx: InstalledAddonValuesBlob | None = None kube_prometheus_stack: InstalledAddonValuesBlob | None = None metrics_server: InstalledAddonValuesBlob | None = None istio_kiali: IstioKialiInstalledValuesBlob | None = None class HelmAddonApplyResponse(BaseModel): """Результат синхронной установки/удаления чарта.""" ok: bool = True message: str log: str = Field(default="", description="Сводный вывод helm/kubectl") _VALUES_YAML_MAX = 131_072 class IngressNginxInstallRequest(BaseModel): """Тело POST …/addons/ingress-nginx — установка ingress-nginx.""" chart_version: str | None = Field( default=None, max_length=64, description="Версия чарта; пусто — последняя из репозитория", ) values_yaml: str | None = Field( default=None, max_length=_VALUES_YAML_MAX, description=( "Полный YAML values для чарта (корень — объект), как в редакторе на странице аддонов " "(``helm show values`` + сервис для kind). Пусто — собрать автоматически на сервере. " "После ``-f`` применяются ``--set`` NodePort (приоритет Helm)." ), ) class KubePrometheusStackInstallRequest(BaseModel): """Тело POST …/addons/kube-prometheus-stack.""" grafana_admin_user: str = Field(default="admin", min_length=1, max_length=256) grafana_admin_password: str = Field(..., min_length=8, max_length=256) chart_version: str | None = Field( default=None, max_length=64, description="Версия чарта kube-prometheus-stack; пусто — последняя", ) values_yaml: str | None = Field( default=None, max_length=_VALUES_YAML_MAX, description=( "Полный YAML values чарта (``helm show values`` + логин/пароль Grafana из формы). " "Пусто — собрать на сервере из версии чарта и полей формы." ), ) class MetricsServerInstallRequest(BaseModel): """Тело POST …/addons/metrics-server (все поля необязательны).""" chart_version: str | None = Field( default=None, max_length=64, description="Версия чарта metrics-server; пусто — последняя", ) values_yaml: str | None = Field( default=None, max_length=_VALUES_YAML_MAX, description=( "Полный YAML values чарта (``helm show values`` + args для kind). " "Пусто — собрать на сервере." ), ) class IstioKialiInstallRequest(BaseModel): """Тело POST …/addons/istio-kiali — Istio + Kiali со входом по логину.""" kiali_username: str = Field(default="kiali-admin", min_length=1, max_length=256) kiali_password: str = Field(..., min_length=8, max_length=256) istio_chart_version: str | None = Field( default=None, max_length=64, description="Версия чартов istio/base и istio/istiod; пусто — последняя", ) kiali_chart_version: str | None = Field( default=None, max_length=64, description="Версия чарта kiali/kiali-server; пусто — последняя", ) values_yaml: str | None = Field( default=None, max_length=_VALUES_YAML_MAX, description=( "Полный YAML values чарта **kiali-server** (``helm show values``). " "Пусто — без файла ``-f`` (дефолты чарта). Секрет логина/пароля из формы отдельно; " "``auth.strategy=login`` через ``--set``." ), ) istio_base_values_yaml: str | None = Field( default=None, max_length=_VALUES_YAML_MAX, description="Полный YAML values чарта **istio/base**; пусто — без ``-f``.", ) istio_istiod_values_yaml: str | None = Field( default=None, max_length=_VALUES_YAML_MAX, description="Полный YAML values чарта **istio/istiod**; пусто — без ``-f``.", ) class HelmAddonComposeValuesRequest(BaseModel): """Тело POST /helm/addons/compose-values — собрать YAML для редактора (helm show values + форма).""" addon: Literal["ingress-nginx", "kube-prometheus-stack", "metrics-server", "istio-kiali"] chart_version: str | None = Field( default=None, max_length=64, description="Версия чарта: ingress-nginx, kube-prometheus-stack или metrics-server", ) grafana_admin_user: str | None = Field(default=None, max_length=256) grafana_admin_password: str | None = Field( default=None, max_length=256, description="Для preview kube-prometheus-stack; если пусто — подставляется временный пароль в YAML", ) istio_chart_version: str | None = Field(default=None, max_length=64) kiali_chart_version: str | None = Field(default=None, max_length=64) class HelmAddonComposeValuesResponse(BaseModel): """Ответ compose-values: строки YAML для текстовых полей UI.""" values_yaml: str = Field(..., description="Основной чарт; для istio-kiali — kiali-server") istio_base_values_yaml: str | None = Field(default=None, description="Только istio-kiali: istio/base") istio_istiod_values_yaml: str | None = Field(default=None, description="Только istio-kiali: istio/istiod") class HelmAddonComposeBatchRequest(BaseModel): """Тело POST /helm/addons/compose-values-batch — одним запросом обновить все редакторы на странице аддонов.""" ingress_chart_version: str | None = Field(default=None, max_length=64) grafana_admin_user: str | None = Field(default=None, max_length=256) grafana_admin_password: str | None = Field(default=None, max_length=256) kube_prometheus_chart_version: str | None = Field(default=None, max_length=64) metrics_server_chart_version: str | None = Field(default=None, max_length=64) istio_chart_version: str | None = Field(default=None, max_length=64) kiali_chart_version: str | None = Field(default=None, max_length=64) class HelmAddonComposeBatchResponse(BaseModel): """Шесть строк YAML за один вызов (один ``helm repo update``).""" ingress_nginx_values_yaml: str kube_prometheus_values_yaml: str metrics_server_values_yaml: str kiali_values_yaml: str istio_base_values_yaml: str istio_istiod_values_yaml: str class HelmAddonChartVersionsResponse(BaseModel): """Список версий чартов для выпадающих списков на странице аддонов (после helm repo update).""" ingress_nginx: list[str] = Field(default_factory=list) kube_prometheus_stack: list[str] = Field(default_factory=list) metrics_server: list[str] = Field(default_factory=list) istio: list[str] = Field(default_factory=list, description="Версии istio/base (для base+istiod)") kiali_server: list[str] = Field(default_factory=list) class JournalEntryModel(BaseModel): """Одна запись из ``clusters/<имя>/journal/jobs_history.json``.""" model_config = {"extra": "allow"} version: int | None = Field(default=None, description="Версия схемы записи в файле") job_id: str = "" kind: str = "" cluster_name: str = "" status: str = "" message: str | None = None created_at_utc: str = "" finished_at_utc: str | None = None log_lines: list[str] = Field(default_factory=list) result: dict[str, Any] | None = None source_cluster: str | None = Field( default=None, description="Имя каталога кластера в ответе GET /journal/recent", ) class ClusterJournalResponse(BaseModel): """Содержимое журнала одного кластера.""" cluster_name: str file_version: int | None = None entries: list[JournalEntryModel] = Field(default_factory=list) class JournalRecentResponse(BaseModel): """Страница журнала заданий из ``journal/jobs_history.json`` (все кластеры или один).""" limit: int = Field(description="Размер страницы (записей за запрос)") offset: int = Field(description="Смещение от начала отсортированного списка (0 = первая страница)") total: int = Field(ge=0, description="Всего записей во всех журналах") page: int = Field(ge=1, description="Номер текущей страницы с 1") total_pages: int = Field(ge=0, description="Число страниц при данном limit; 0 если записей нет") entries: list[JournalEntryModel] = Field(default_factory=list) class ClusterDirLogEntryModel(BaseModel): """Запись из ``provision_log.json`` или ``helm_addon_log.json`` (формат как у развёртывания).""" model_config = {"extra": "allow"} version: int | None = None job_id: str = "" kind: str = "" cluster_name: str = "" finished_at_utc: str = "" status: str = "" message: str | None = None lines: list[str] = Field(default_factory=list) result: dict[str, Any] | None = None source_cluster: str | None = Field( default=None, description="Имя каталога кластера в агрегированных ответах /journal/provision и /journal/helm-addons", ) class JournalPagedDirLogsResponse(BaseModel): """Пагинация по одному последнему логу на кластер (развёртывание или Helm-аддоны).""" limit: int offset: int total: int page: int total_pages: int entries: list[ClusterDirLogEntryModel] = Field(default_factory=list)