Веб-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:
@@ -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 {}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
13
app/core/kind_guard.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""Глобальная блокировка для операций kind (последовательное создание/удаление).
|
||||
|
||||
Параллельные ``kind create`` на одном Docker-движке часто нежелательны.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
kind_cluster_lock = asyncio.Lock()
|
||||
Reference in New Issue
Block a user