"""Модели запросов/ответов 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 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="Актуальная сводка для строки таблицы / панели")