Веб-UI FastAPI, REST API v1, интерактивный setup без env.example

- Дашборд (Jinja2 + static), управление кластерами kind, задания и kubeconfig.
- API: health, stats, clusters CRUD, versions, jobs; документация app/docs/api_routes.md.
- Docker Compose: том app, uvicorn reload, KIND_K8S_PATCH_KUBECONFIG по умолчанию 1.
- setup_env_interactive.py: список переменных в скрипте, удалён env.example.
- Makefile: явный префикс docker/podman; прочие правки CLI и ядра кластеров.
This commit is contained in:
Sergey Antropoff
2026-04-04 05:39:53 +03:00
parent ae961ef5fe
commit e46a62cfdb
31 changed files with 2507 additions and 393 deletions

View File

@@ -303,6 +303,37 @@ def read_meta_json(cluster_name: str) -> dict[str, object] | None:
return None
def _container_cli_bin() -> str:
"""Имя CLI к сокету (docker / podman), как в kubeconfig_patch."""
return (os.environ.get("CONTAINER_CLI") or "docker").strip() or "docker"
def container_engine_ping(*, timeout_sec: float = 12.0) -> tuple[bool, str, str]:
"""
Проверить доступ к движку контейнеров (``docker info`` / ``podman info``).
Возвращает (успех, краткое сообщение или stderr, имя CLI).
"""
cli = _container_cli_bin()
if not shutil.which(cli):
return False, f"«{cli}» не найден в PATH", cli
try:
p = subprocess.run(
[cli, "info"],
capture_output=True,
text=True,
timeout=timeout_sec,
)
except subprocess.TimeoutExpired:
logger.warning("%s info: таймаут %s с", cli, timeout_sec)
return False, f"таймаут {timeout_sec} с", cli
if p.returncode == 0:
return True, "OK", cli
err = (p.stderr or p.stdout or "").strip() or f"код {p.returncode}"
logger.info("%s info неуспешно: %s", cli, err[:200])
return False, err[:800], cli
def kubectl_nodes_wide(*, kubeconfig: str | Path) -> tuple[int, str]:
"""``kubectl get nodes -o wide``; возвращает (код, объединённый вывод)."""
p = subprocess.run(
@@ -325,6 +356,27 @@ def kubectl_nodes_wide(*, kubeconfig: str | Path) -> tuple[int, str]:
return p.returncode, msg
def kubectl_pods_all_namespaces(*, kubeconfig: str | Path) -> tuple[int, str]:
"""``kubectl get pods -A``; сводка подов по кластеру."""
p = subprocess.run(
[
"kubectl",
"--kubeconfig",
str(kubeconfig),
"get",
"pods",
"-A",
"--request-timeout=20s",
],
capture_output=True,
text=True,
)
out = (p.stdout or "").strip()
err = (p.stderr or "").strip()
msg = out if out else err
return p.returncode, msg
def cluster_summary_for_api(name: str) -> dict[str, object]:
"""Сводка по кластеру для JSON API (без блокирующих долгих вызовов)."""
meta = read_meta_json(name) or {}

View File

@@ -1,4 +1,7 @@
"""Настройки веб-приложения из переменных окружения (и опционально ``.env`` в рабочем каталоге).
"""Настройки веб-приложения из переменных окружения.
Переменные задаются в ``docker-compose`` и/или в ``.env`` в корне репозитория
(Compose подставляет их в ``environment`` процесса — отдельный ``env_file`` в коде не требуется).
Автор: Сергей Антропов
Сайт: https://devops.org.ru
@@ -6,34 +9,34 @@
from __future__ import annotations
from pathlib import Path
from pydantic import Field
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
# Каталог пакета app/ — для поиска .env рядом с кодом (в образе: /opt/kind-k8s/app).
_APP_DIR = Path(__file__).resolve().parents[1]
_REPO_ROOT = _APP_DIR.parent
_DEFAULT_TITLE = "kind-k8s-develop"
class Settings(BaseSettings):
"""Параметры HTTP-сервера и поведения UI."""
model_config = SettingsConfigDict(
env_file=(
str(_REPO_ROOT / ".env"),
str(_APP_DIR / ".env"),
),
env_file_encoding="utf-8",
extra="ignore",
case_sensitive=False,
# Пустая строка из docker-compose (${VAR:-}) не должна затирать заголовок OpenAPI.
env_ignore_empty=True,
)
kind_k8s_web_host: str = Field(default="0.0.0.0", validation_alias="KIND_K8S_WEB_HOST")
kind_k8s_web_port: int = Field(default=6000, validation_alias="KIND_K8S_WEB_PORT")
# Заголовок в OpenAPI / HTML (без хардкода в шаблонах).
app_title: str = Field(default="kind-k8s-develop", validation_alias="KIND_K8S_APP_TITLE")
# Заголовок в OpenAPI / HTML; пустая строка из compose не должна ломать FastAPI.
app_title: str = Field(default=_DEFAULT_TITLE, validation_alias="KIND_K8S_APP_TITLE")
@field_validator("app_title", mode="before")
@classmethod
def _non_empty_title(cls, v: object) -> object:
if v is None or (isinstance(v, str) and not v.strip()):
return _DEFAULT_TITLE
return v
def get_settings() -> Settings:

View File

@@ -11,12 +11,15 @@ from __future__ import annotations
import asyncio
import logging
import uuid
from dataclasses import dataclass, field
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Literal
logger = logging.getLogger("kind_k8s.job_store")
# Лимит записей в памяти (dev-инструмент; старые задания вытесняются)
_MAX_JOBS = 200
JobStatus = Literal["queued", "running", "success", "failed"]
@@ -53,6 +56,10 @@ class JobStore:
)
async with self._lock:
self._jobs[jid] = rec
while len(self._jobs) > _MAX_JOBS:
oldest_id = min(self._jobs, key=lambda k: self._jobs[k].created_at_utc)
del self._jobs[oldest_id]
logger.debug("Вытеснено старое задание из хранилища: %s", oldest_id)
logger.info("Создано задание %s kind=%s cluster=%s", jid, kind, cluster_name)
return rec
@@ -84,6 +91,12 @@ class JobStore:
"""Снимок всех заданий (для отладки; без блокировки — eventual consistency)."""
return list(self._jobs.values())
def snapshot_recent_sorted(self, *, limit: int) -> list[JobRecord]:
"""Задания от новых к старым, не более ``limit``."""
items = self.snapshot_all()
items.sort(key=lambda r: r.created_at_utc, reverse=True)
return items[: max(1, limit)]
# Синглтон на процесс uvicorn
job_store = JobStore()

13
app/core/kind_guard.py Normal file
View File

@@ -0,0 +1,13 @@
"""Глобальная блокировка для операций kind (последовательное создание/удаление).
Параллельные ``kind create`` на одном Docker-движке часто нежелательны.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
from __future__ import annotations
import asyncio
kind_cluster_lock = asyncio.Lock()