diff --git a/.gitignore b/.gitignore index 2afc31a..99843d2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ -# Сгенерированные конфиги и kubeconfig локальных кластеров kind +# Сгенерированные конфиги и kubeconfig локальных кластеров kind. +# Содержимое clusters/<имя>/ в репозиторий не попадает (только clusters/.gitkeep). .DS_Store .env diff --git a/Makefile b/Makefile index af67a13..de0d712 100644 --- a/Makefile +++ b/Makefile @@ -1,10 +1,10 @@ # kind-k8s-develop — веб-интерфейс (FastAPI) для kind. -# Создание кластеров — в браузере: http://127.0.0.1:6000 (порт: KIND_K8S_WEB_PORT). +# Создание кластеров — в браузере: http://127.0.0.1:8080 (порт: KIND_K8S_WEB_PORT; 6000 на хосте — ERR_UNSAFE_PORT в Chrome). # # Все операции с Compose только с явным выбором среды: # make docker up | make docker down | make docker logs | … # make podman up | make podman down | … -# Без префикса docker/podman цели up/down/logs/compose-build/check-docker завершатся с подсказкой. +# Без префикса docker/podman цели up/down/logs/ps/compose-build/check-docker завершатся с подсказкой. # # Автор: Сергей Антропов — https://devops.org.ru @@ -14,7 +14,7 @@ else ifneq (,$(filter docker,$(MAKECMDGOALS))) COMPOSE := docker compose endif -.PHONY: help docker podman _require_runtime up down logs setup clusters-dir check-docker compose-build +.PHONY: help docker podman _require_runtime up down logs ps setup clusters-dir check-docker compose-build KIND_K8S_DIR := $(abspath $(dir $(lastword $(MAKEFILE_LIST)))) SETUP_ENV_SCRIPT := $(KIND_K8S_DIR)/scripts/setup_env_interactive.py @@ -24,9 +24,10 @@ COMPOSE_BUILD_FLAGS ?= help: ## Справка по целям @echo "Веб-UI kind — только с выбором Docker или Podman в одной команде с целью:" - @echo " make docker up или make podman up → http://127.0.0.1:\$${KIND_K8S_WEB_PORT:-6000}" + @echo " make docker up или make podman up → http://127.0.0.1:\$${KIND_K8S_WEB_PORT:-8080}" @echo " make docker down / make podman down" - @echo " make docker logs / make podman logs" + @echo " make docker logs / make podman logs (follow -f)" + @echo " make docker ps / make podman ps (статус сервисов)" @echo " make docker compose-build / make podman compose-build" @echo " make docker check-docker / make podman check-docker" @echo "Без установки Compose: make setup, make clusters-dir (python3 для setup)." @@ -38,12 +39,12 @@ docker: ## Маркер среды: задайте вторую цель (нап podman: ## Маркер среды: задайте вторую цель (например: make podman up) @: -# Общая проверка: цели up/down/logs/compose-build/check-docker вызывать только как make docker … / make podman … +# Общая проверка: цели up/down/logs/ps/compose-build/check-docker — только make docker … / make podman … _require_runtime: @if [ -z "$(COMPOSE)" ]; then \ echo >&2 "Укажите среду в той же команде, что и цель:"; \ echo >&2 " make docker up | make podman up"; \ - echo >&2 " make docker down | make docker logs | make docker compose-build | make docker check-docker"; \ + echo >&2 " make docker down | make docker logs | make docker ps | make docker compose-build | make docker check-docker"; \ echo >&2 " (или то же с префиксом podman)"; \ exit 1; \ fi @@ -54,9 +55,12 @@ up: _require_runtime clusters-dir compose-build ## (с docker/podman) Подня down: _require_runtime ## (с docker/podman) Остановить compose в этом каталоге cd "$(KIND_K8S_DIR)" && $(COMPOSE) down -logs: _require_runtime ## (с docker/podman) Логи kind-k8s-web +logs: _require_runtime ## (с docker/podman) Логи kind-k8s-web (follow -f) cd "$(KIND_K8S_DIR)" && $(COMPOSE) logs -f kind-k8s-web +ps: _require_runtime ## (с docker/podman) Статус контейнеров compose-проекта + cd "$(KIND_K8S_DIR)" && $(COMPOSE) ps + setup: ## Интерактивно создать .env (scripts/setup_env_interactive.py; нужен python3 на хосте) @$(PYTHON) "$(SETUP_ENV_SCRIPT)" diff --git a/README.md b/README.md index 3f0bfdc..004b793 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # kind-k8s-develop — локальные кластеры Kubernetes (kind) -Образ **kind-k8s-tools:local** и **Makefile** поднимают **веб-интерфейс** (FastAPI) на порту **6000** на хосте (или значении **`KIND_K8S_WEB_PORT`** в `.env`): через браузер создаёте и удаляете кластеры, смотрите статистику и вывод `kubectl`. **kubeconfig** сохраняется в `clusters/<имя>/`. На хосте достаточно **Docker** (или Podman) и **make**; **kind** и **kubectl** — внутри контейнера. +Образ **kind-k8s-tools:local** и **Makefile** поднимают **веб-интерфейс** (FastAPI) на порту **8080** на хосте по умолчанию (или **`KIND_K8S_WEB_PORT`** в `.env`; внутри контейнера приложение слушает **6000**). Порт **6000 на хосте** не используем по умолчанию: Chrome и другие браузеры на Chromium отдают **ERR_UNSAFE_PORT**. Через браузер создаёте и удаляете кластеры, смотрите статистику и вывод `kubectl`. **kubeconfig** сохраняется в `clusters/<имя>/`. На хосте достаточно **Docker** (или Podman) и **make**; **kind** и **kubectl** — внутри контейнера. **Автор:** Сергей Антропов — [devops.org.ru](https://devops.org.ru) @@ -10,7 +10,7 @@ |--------|----------| | **[app/docs/api_routes.md](app/docs/api_routes.md)** | Описание REST API `/api/v1/*` с примерами JSON (для фронтенда и интеграций) | | **[app/docs/README.md](app/docs/README.md)** | Указатель по каталогу `app/docs/` | -| **`/docs`** (Swagger) и **`/redoc`** | Интерактивная OpenAPI-документация на том же порту, что и UI | +| **`/docs`** (Swagger), **`/redoc`**, **`/api/v1/health`** | На панели открываются в **отдельном окне** браузера (`window.open`); прямой URL — тот же порт, что и UI (по умолчанию **8080**) | Шаблона **`env.example`** в репозитории нет: переменные для `.env` задаются интерактивно скриптом **`scripts/setup_env_interactive.py`** (`make setup`). @@ -23,14 +23,18 @@ ## Веб-интерфейс -- Плашка **состояния среды**: наличие `kind`/`kubectl`, доступность Docker/Podman API по сокету. +- Верхняя **единая карточка**: заголовок, краткое описание и строка **состояния среды** (`kind` / `kubectl` / Docker или Podman API). - **Статистика**: число кластеров в kind, локальных каталогов, сумма workers из `meta.json`, счётчики фоновых заданий. - **Создание кластера**: форма с подсказкой тегов `kindest/node` (`GET /api/v1/versions`), фоновое задание и опрос статуса (JSON в сворачиваемом блоке). - **Таблица кластеров**: признаки регистрации в kind и наличия kubeconfig, скачивание kubeconfig, просмотр узлов/подов в модальном окне, удаление. - **Последние задания** создания (данные в памяти процесса; после перезапуска контейнера история сбрасывается). -- Кнопки обновления, опциональное **автообновление каждые 15 с**, уведомления (toast) при успехе/ошибке. +- **Автообновление** таблиц и плашки среды каждые ~3,5 с (fetch к API без перезагрузки страницы). +- При создании кластера — **прогресс-бар** и текст текущего этапа; кнопка **«Отменить создание»** (между этапами; шаг `kind create` до конца не прерывается). +- Уведомления (toast) при успехе/ошибке; в подвале — копирайт и ссылка на **devops.org.ru**. -**Структура фронтенда:** `app/templates/base.html` (шапка, ссылки на Swagger/ReDoc/Health), `app/templates/dashboard.html`, стили `app/static/style.css`, логика `app/static/js/dashboard.js` (базовый префикс API из `data-api-base` на ``, по умолчанию `/api/v1`). +**Шапка:** навигация в виде **пилюль** (стили `.nav-pill`); пункты **Swagger**, **ReDoc** и **Health** открывают страницу в **отдельном именованном окне** (~1240×840), чтобы не уходить с панели (см. скрипт в `base.html`). + +**Структура фронтенда:** `app/templates/base.html` (шапка и меню), `app/templates/dashboard.html`, `app/static/style.css`, `app/static/js/dashboard.js` (префикс API: `data-api-base` на ``, по умолчанию `/api/v1`). ## Требования на хосте @@ -54,12 +58,12 @@ cd kind-k8s-develop make setup # опционально: интерактивно создать .env (Enter — дефолты из скрипта) make docker check-docker # или: make podman check-docker make docker up # или: make podman up -# Браузер: http://127.0.0.1:6000 (порт: KIND_K8S_WEB_PORT в .env) +# Браузер: http://127.0.0.1:8080 (порт: KIND_K8S_WEB_PORT в .env; не 6000 на хосте — Chrome ERR_UNSAFE_PORT) ``` Из родительского каталога: `make -C kind-k8s-develop docker up`. -**Логи и остановка:** `make docker logs` / `make podman logs`, `make docker down` / `make podman down`. +**Логи, статус и остановка:** `make docker logs` / `make podman logs` (follow), `make docker ps` / `make podman ps`, `make docker down` / `make podman down`. ### Разработка UI и API без пересборки образа @@ -92,14 +96,15 @@ docker compose run --rm --entrypoint python3 kind-k8s-web \ | `make help` | Краткая справка | | `make docker up` / `make podman up` | Поднять веб-UI (`kind-k8s-web`) | | `make docker down` / `make podman down` | Остановить compose в каталоге репозитория | -| `make docker logs` / `make podman logs` | Логи `kind-k8s-web` | +| `make docker logs` / `make podman logs` | Логи `kind-k8s-web` (stream, `-f`) | +| `make docker ps` / `make podman ps` | Статус контейнеров текущего compose-проекта | | `make docker compose-build` / `make podman compose-build` | Собрать образ `kind-k8s-tools:local` | | `make docker check-docker` / `make podman check-docker` | Проверить выбранный CLI и `compose version` | | `make setup` | Интерактивно создать `.env` (список переменных в `scripts/setup_env_interactive.py`) | | `make clusters-dir` | Создать каталог `clusters/` | -| `make docker …` / `make podman …` | Префикс **обязателен** для целей `up`, `down`, `logs`, `compose-build`, `check-docker` | +| `make docker …` / `make podman …` | Префикс **обязателен** для целей `up`, `down`, `logs`, `ps`, `compose-build`, `check-docker` | -Цели `up`, `down`, `logs`, `compose-build` и `check-docker` **без** `docker`/`podman` в той же команде завершатся с подсказкой. +Цели `up`, `down`, `logs`, `ps`, `compose-build` и `check-docker` **без** `docker`/`podman` в той же команде завершатся с подсказкой. ## Переменные окружения @@ -111,7 +116,7 @@ docker compose run --rm --entrypoint python3 kind-k8s-web \ |------------|------------------|------------| | **`KIND_VERSION`** | build-arg | Версия бинарника kind при сборке образа | | **`KUBECTL_VERSION`** | build-arg | Версия kubectl в образе; пусто в compose → в Dockerfile подставляется `stable.txt` при сборке; `make setup` предлагает закреплённый тег | -| **`KIND_K8S_WEB_PORT`** | ports | Порт **на хосте** для веб-UI (в контейнере 6000) | +| **`KIND_K8S_WEB_PORT`** | ports | Порт **на хосте** для веб-UI (по умолчанию **8080**; в контейнере публикация идёт на процесс на **6000**) | | **`KIND_K8S_WEB_HOST`** | локальный uvicorn / Settings | Хост привязки при запуске вне compose (в контейнере задаётся entrypoint) | | **`KIND_K8S_UVICORN_RELOAD`** | контейнер | `1` (по умолчанию) — hot-reload при правках в `./app`; `0` — без reload | | **`KIND_K8S_APP_TITLE`** | контейнер / Settings | Заголовок OpenAPI и HTML; пустое значение из compose не ломает приложение (`env_ignore_empty`, fallback) | diff --git a/app/api/v1/endpoints/clusters.py b/app/api/v1/endpoints/clusters.py index 2d988f6..51e9551 100644 --- a/app/api/v1/endpoints/clusters.py +++ b/app/api/v1/endpoints/clusters.py @@ -24,7 +24,7 @@ from core.cluster_lifecycle import ( read_meta_json, validate_cluster_name, ) -from core.job_store import job_store +from core.job_store import JobRecord, end_job_tracking, get_progress_sync, job_store, request_cancel_sync from core.kind_guard import kind_cluster_lock from kind_k8s_paths import clusters_dir from models.schemas import ( @@ -41,6 +41,25 @@ logger = logging.getLogger("kind_k8s.api.clusters") router = APIRouter(tags=["clusters"]) +def _record_to_job_view(rec: JobRecord) -> JobView: + """JobRecord → JobView с полями прогресса из потокобезопасного снимка.""" + prog = get_progress_sync(rec.job_id) + stage, pct = (None, None) + if prog is not None: + stage, pct = prog[0], prog[1] + return JobView( + job_id=rec.job_id, + kind=rec.kind, + status=rec.status, + cluster_name=rec.cluster_name, + created_at_utc=rec.created_at_utc, + message=rec.message, + result=rec.result, + progress_stage=stage, + progress_percent=pct, + ) + + def _stats_sync() -> StatsResponse: """Собрать статистику (синхронно; вызывать из thread при необходимости).""" kind_names = list_registered_kind_clusters() @@ -86,18 +105,7 @@ async def get_stats() -> StatsResponse: async def list_jobs(limit: int = Query(30, ge=1, le=200, description="Сколько последних заданий")) -> list[JobView]: """История создания кластеров (в памяти процесса; после перезапуска контейнера пусто).""" items = job_store.snapshot_recent_sorted(limit=limit) - return [ - JobView( - job_id=r.job_id, - kind=r.kind, - status=r.status, - cluster_name=r.cluster_name, - created_at_utc=r.created_at_utc, - message=r.message, - result=r.result, - ) - for r in items - ] + return [_record_to_job_view(r) for r in items] @router.get("/clusters", response_model=list[ClusterSummary], summary="Список кластеров") @@ -187,36 +195,44 @@ async def get_cluster(name: str) -> dict[str, object]: async def _run_create_job(job_id: str, body: ClusterCreateRequest) -> None: - async with kind_cluster_lock: - await job_store.set_running(job_id) - try: - result = await asyncio.to_thread( - create_cluster_non_interactive, - name=body.name.strip(), - kubernetes_version_tag=body.kubernetes_version.strip(), - workers=body.workers, - ) - except KindClusterError as e: - await job_store.set_failed(job_id, str(e)) - logger.warning("create job %s: %s", job_id, e) - return - except Exception as e: - await job_store.set_failed(job_id, f"{type(e).__name__}: {e}") - logger.exception("create job %s: непредвиденная ошибка", job_id) - return + try: + async with kind_cluster_lock: + await job_store.set_running(job_id) + try: + result = await asyncio.to_thread( + create_cluster_non_interactive, + name=body.name.strip(), + kubernetes_version_tag=body.kubernetes_version.strip(), + workers=body.workers, + job_id=job_id, + ) + except KindClusterError as e: + msg = str(e) + if "отменено" in msg.lower(): + await job_store.set_cancelled(job_id, msg) + else: + await job_store.set_failed(job_id, msg) + logger.warning("create job %s: %s", job_id, e) + return + except Exception as e: + await job_store.set_failed(job_id, f"{type(e).__name__}: {e}") + logger.exception("create job %s: непредвиденная ошибка", job_id) + return - payload: dict[str, Any] = { - "cluster_name": result.cluster_name, - "kubernetes_version_tag": result.ver_tag, - "node_image": result.node_image, - "workers": result.workers, - "kubeconfig_path": str(result.kubeconfig_path), - "kubeconfig_patched_for_host": result.kubeconfig_patched_for_host, - "nodes_ready": result.nodes_ready, - "nodes_ready_message": result.nodes_ready_message, - } - await job_store.set_success(job_id, result=payload, message="Кластер создан") - logger.info("create job %s: успех, кластер %s", job_id, result.cluster_name) + payload: dict[str, Any] = { + "cluster_name": result.cluster_name, + "kubernetes_version_tag": result.ver_tag, + "node_image": result.node_image, + "workers": result.workers, + "kubeconfig_path": str(result.kubeconfig_path), + "kubeconfig_patched_for_host": result.kubeconfig_patched_for_host, + "nodes_ready": result.nodes_ready, + "nodes_ready_message": result.nodes_ready_message, + } + await job_store.set_success(job_id, result=payload, message="Кластер создан") + logger.info("create job %s: успех, кластер %s", job_id, result.cluster_name) + finally: + end_job_tracking(job_id) @router.post( @@ -263,18 +279,35 @@ async def delete_cluster(name: str) -> dict[str, object]: return {"name": name, "kind_delete_ok": kind_ok, "summary": summary} +@router.post( + "/jobs/{job_id}/cancel", + summary="Запросить отмену создания кластера", + responses={400: {"description": "Задание уже завершено"}, 404: {"description": "Нет задания"}}, +) +async def cancel_create_job(job_id: str) -> dict[str, object]: + """ + Установить флаг отмены. Этап ``kind create cluster`` нельзя прервать до его завершения; + после него отмена удалит кластер и данные (если успели создать). + """ + rec = await job_store.get(job_id) + if not rec: + raise HTTPException(status_code=404, detail="Задание не найдено") + if rec.status not in ("queued", "running"): + raise HTTPException(status_code=400, detail="Задание уже завершено; отмена невозможна") + if not request_cancel_sync(job_id): + raise HTTPException(status_code=404, detail="Задание не найдено") + logger.info("Принят запрос отмены задания %s", job_id) + return { + "job_id": job_id, + "cancel_requested": True, + "message": "Отмена обрабатывается между этапами; во время kind create дождитесь окончания шага", + } + + @router.get("/jobs/{job_id}", response_model=JobView, summary="Статус одного задания") async def get_job(job_id: str) -> JobView: """Узнать состояние фонового создания кластера.""" rec = await job_store.get(job_id) if not rec: raise HTTPException(status_code=404, detail="Задание не найдено") - return JobView( - job_id=rec.job_id, - kind=rec.kind, - status=rec.status, - cluster_name=rec.cluster_name, - created_at_utc=rec.created_at_utc, - message=rec.message, - result=rec.result, - ) + return _record_to_job_view(rec) diff --git a/app/core/cluster_lifecycle.py b/app/core/cluster_lifecycle.py index 23f31c3..873cdb1 100644 --- a/app/core/cluster_lifecycle.py +++ b/app/core/cluster_lifecycle.py @@ -24,6 +24,22 @@ from kubeconfig_patch import patch_kubeconfig_server_for_host, should_patch_afte logger = logging.getLogger("kind_k8s.cluster_lifecycle") + +def _rollback_after_cancel(*, cluster_name: str, out_dir: Path) -> None: + """Удалить кластер kind и каталог данных после запроса отмены (best-effort).""" + logger.info("Откат после отмены: kind delete «%s»", cluster_name) + subprocess.run( + ["kind", "delete", "cluster", "--name", cluster_name], + capture_output=True, + text=True, + ) + if out_dir.is_dir(): + try: + shutil.rmtree(out_dir) + logger.info("Удалён каталог %s", out_dir) + except OSError as e: + logger.warning("Не удалось удалить %s: %s", out_dir, e) + # Имя кластера: поддомен DNS (RFC 1123) _NAME_RE = re.compile(r"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$") @@ -160,12 +176,24 @@ def create_cluster_non_interactive( name: str, kubernetes_version_tag: str, workers: int, + job_id: str | None = None, ) -> CreateClusterResult: """ Создать кластер kind без диалогов. ``kubernetes_version_tag`` — тег kindest/node (например ``v1.29.4``), см. ``normalize_tag_v_prefix``. + + ``job_id`` — если задан, обновляется прогресс и проверяется отмена (см. ``job_store``). """ + from core import job_store as _job_store + + def _progress(stage: str, pct: int) -> None: + if job_id: + _job_store.set_progress_sync(job_id, stage, pct) + + def _cancelled() -> bool: + return bool(job_id and _job_store.is_cancelled_sync(job_id)) + if not shutil.which("kind"): raise KindClusterError("Не найден бинарник kind в PATH.", exit_code=127) @@ -193,19 +221,40 @@ def create_cluster_non_interactive( yaml_text = build_kind_config_yaml(node_image=node_image, workers=workers) cfg_path.write_text(yaml_text, encoding="utf-8") + _progress("Подготовка каталога и kind-config", 12) + if _cancelled(): + _rollback_after_cancel(cluster_name=name, out_dir=out_dir) + raise KindClusterError("Создание отменено пользователем") + logger.info("Создание кластера «%s», образ %s, workers=%s", name, node_image, workers) + _progress("kind create cluster (скачивание образов и подъём нод — может занять несколько минут)", 28) _run_checked(["kind", "create", "cluster", "--name", name, "--config", str(cfg_path)]) + if _cancelled(): + _rollback_after_cancel(cluster_name=name, out_dir=out_dir) + raise KindClusterError("Создание отменено пользователем") + + _progress("Сохранение kubeconfig", 58) kube = _run_capture_checked(["kind", "get", "kubeconfig", "--name", name]) kube_path.write_text(kube, encoding="utf-8") + if _cancelled(): + _rollback_after_cancel(cluster_name=name, out_dir=out_dir) + raise KindClusterError("Создание отменено пользователем") + patched = False if should_patch_after_create(): + _progress("Патч kubeconfig для доступа к API с хоста", 72) patched = patch_kubeconfig_server_for_host(cluster_name=name, kube_path=kube_path) + if _cancelled(): + _rollback_after_cancel(cluster_name=name, out_dir=out_dir) + raise KindClusterError("Создание отменено пользователем") + nodes_ready: bool | None = None nodes_msg: str | None = None if _wait_nodes_enabled(): + _progress("Ожидание готовности нод (kubectl wait …)", 82) ok, msg = wait_nodes_ready(kubeconfig_path=kube_path) nodes_ready = ok nodes_msg = msg @@ -229,6 +278,8 @@ def create_cluster_non_interactive( } meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8") + _progress("Финализация", 95) + return CreateClusterResult( cluster_name=name, ver_tag=ver_tag, diff --git a/app/core/config.py b/app/core/config.py index e3c04fd..323c7bb 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -26,7 +26,8 @@ class Settings(BaseSettings): ) 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") + # Согласовано с дефолтом compose на хосте (8080); в контейнере процесс слушает 6000 через run_uvicorn.sh. + kind_k8s_web_port: int = Field(default=8080, validation_alias="KIND_K8S_WEB_PORT") # Заголовок в OpenAPI / HTML; пустая строка из compose не должна ломать FastAPI. app_title: str = Field(default=_DEFAULT_TITLE, validation_alias="KIND_K8S_APP_TITLE") diff --git a/app/core/job_store.py b/app/core/job_store.py index 0df38dd..d951107 100644 --- a/app/core/job_store.py +++ b/app/core/job_store.py @@ -2,6 +2,8 @@ При перезапуске контейнера история заданий обнуляется — это ожидаемо для dev-среды. +Потокобезопасные флаги отмены и прогресс (для worker-thread) — через ``threading.Lock``. + Автор: Сергей Антропов Сайт: https://devops.org.ru """ @@ -10,6 +12,7 @@ from __future__ import annotations import asyncio import logging +import threading import uuid from dataclasses import dataclass from datetime import datetime, timezone @@ -20,7 +23,58 @@ logger = logging.getLogger("kind_k8s.job_store") # Лимит записей в памяти (dev-инструмент; старые задания вытесняются) _MAX_JOBS = 200 -JobStatus = Literal["queued", "running", "success", "failed"] +JobStatus = Literal["queued", "running", "success", "failed", "cancelled"] + +# --- Синхронное сопровождение задания (worker-thread и HTTP отмена) --- +_thread_lock = threading.Lock() +_cancel_events: dict[str, threading.Event] = {} +_progress: dict[str, tuple[str, int]] = {} + + +def begin_job_tracking(job_id: str) -> None: + """Зарегистрировать отмену/прогресс для нового job_id (вызывать при создании задания).""" + with _thread_lock: + _cancel_events[job_id] = threading.Event() + _progress[job_id] = ("В очереди", 0) + + +def end_job_tracking(job_id: str) -> None: + """Очистить служебные структуры после завершения задания.""" + with _thread_lock: + _cancel_events.pop(job_id, None) + _progress.pop(job_id, None) + + +def set_progress_sync(job_id: str, stage: str, percent: int) -> None: + """Обновить текст этапа и процент (0–100) из worker-thread.""" + pct = max(0, min(100, int(percent))) + with _thread_lock: + if job_id in _progress: + _progress[job_id] = (stage, pct) + + +def get_progress_sync(job_id: str) -> tuple[str, int] | None: + """Снимок прогресса для ответа API.""" + with _thread_lock: + return _progress.get(job_id) + + +def request_cancel_sync(job_id: str) -> bool: + """Запросить отмену. Вернуть False, если job_id не отслеживается.""" + with _thread_lock: + ev = _cancel_events.get(job_id) + if ev is None: + return False + ev.set() + logger.info("Запрошена отмена задания %s", job_id) + return True + + +def is_cancelled_sync(job_id: str) -> bool: + """Проверка из worker-thread между этапами создания кластера.""" + with _thread_lock: + ev = _cancel_events.get(job_id) + return bool(ev and ev.is_set()) @dataclass @@ -58,8 +112,10 @@ class JobStore: self._jobs[jid] = rec while len(self._jobs) > _MAX_JOBS: oldest_id = min(self._jobs, key=lambda k: self._jobs[k].created_at_utc) + end_job_tracking(oldest_id) del self._jobs[oldest_id] logger.debug("Вытеснено старое задание из хранилища: %s", oldest_id) + begin_job_tracking(jid) logger.info("Создано задание %s kind=%s cluster=%s", jid, kind, cluster_name) return rec @@ -68,6 +124,7 @@ class JobStore: if job_id in self._jobs: self._jobs[job_id].status = "running" self._jobs[job_id].message = None + set_progress_sync(job_id, "Запуск создания кластера…", 5) async def set_success(self, job_id: str, *, result: dict[str, Any] | None = None, message: str | None = None) -> None: async with self._lock: @@ -75,6 +132,7 @@ class JobStore: self._jobs[job_id].status = "success" self._jobs[job_id].result = result self._jobs[job_id].message = message + set_progress_sync(job_id, "Готово", 100) async def set_failed(self, job_id: str, message: str) -> None: async with self._lock: @@ -83,6 +141,13 @@ class JobStore: self._jobs[job_id].message = message logger.warning("Задание %s завершилось ошибкой: %s", job_id, message) + async def set_cancelled(self, job_id: str, message: str = "Создание отменено пользователем") -> None: + async with self._lock: + if job_id in self._jobs: + self._jobs[job_id].status = "cancelled" + self._jobs[job_id].message = message + logger.info("Задание %s отменено: %s", job_id, message) + async def get(self, job_id: str) -> JobRecord | None: async with self._lock: return self._jobs.get(job_id) diff --git a/app/docs/README.md b/app/docs/README.md index e7f723e..9fbbf2b 100644 --- a/app/docs/README.md +++ b/app/docs/README.md @@ -6,6 +6,6 @@ |------|------------| | [api_routes.md](api_routes.md) | Полное описание REST API `/api/v1/*` с примерами JSON (ориентир для фронтенда и клиентов). | -После запуска сервиса доступны интерактивно: **Swagger** — `/docs`, **ReDoc** — `/redoc` (тот же порт, что и веб-UI). +После запуска: **Swagger** — `/docs`, **ReDoc** — `/redoc`, **Health** — `/api/v1/health` (тот же порт, что и UI). С дашборда эти ссылки открываются в **отдельном окне** браузера. **Автор:** Сергей Антропов — [devops.org.ru](https://devops.org.ru) diff --git a/app/docs/api_routes.md b/app/docs/api_routes.md index e012625..1288d6b 100644 --- a/app/docs/api_routes.md +++ b/app/docs/api_routes.md @@ -7,15 +7,18 @@ | Способ | URL / путь | |--------|------------| -| Swagger UI (OpenAPI) | `http://127.0.0.1:<порт>/docs` (порт по умолчанию **6000**, см. `KIND_K8S_WEB_PORT`) | +| Swagger UI (OpenAPI) | `http://127.0.0.1:<порт>/docs` (порт на хосте по умолчанию **8080**, см. `KIND_K8S_WEB_PORT`; 6000 на хосте блокируется Chrome) | | ReDoc | `http://127.0.0.1:<порт>/redoc` | +| Health (JSON) | `http://127.0.0.1:<порт>/api/v1/health` | | Этот файл | `app/docs/api_routes.md` в репозитории | +С **веб-панели** (`GET /`) пункты меню **Swagger**, **ReDoc** и **Health** вызывают `window.open` с именами окон `kind_swagger`, `kind_redoc`, `kind_health` (отдельное окно, повторный клик переиспользует то же окно). + ## Веб-интерфейс и статика (не JSON) | Маршрут | Описание | |---------|----------| -| `GET /` | HTML-панель: статус среды (kind, kubectl, Docker/Podman), статистика, форма создания кластера, таблицы кластеров и заданий, модальное окно «узлы / поды», ссылки на API. | +| `GET /` | HTML-панель: единая карточка «панель + среда», статистика, создание кластера (прогресс, отмена), таблицы (автообновление ~3,5 с), модалка узлов/подов; в шапке — меню-пилюли и отдельные окна для Swagger / ReDoc / Health. | | `GET /ui` | Редирект **307** на `/` (удобный ярлык). | | `GET /static/…` | CSS (`style.css`), скрипт панели (`js/dashboard.js`); базовый URL API задаётся атрибутом `data-api-base` на `` (по умолчанию `/api/v1`). | @@ -37,13 +40,16 @@ | GET | `/api/v1/clusters/{name}/workloads` | Узлы и поды (`kubectl`) | | DELETE | `/api/v1/clusters/{name}` | Удалить кластер и данные в `clusters/` | | GET | `/api/v1/jobs` | Последние задания создания | -| GET | `/api/v1/jobs/{job_id}` | Статус одного задания | +| GET | `/api/v1/jobs/{job_id}` | Статус одного задания (включая `progress_stage`, `progress_percent`) | +| POST | `/api/v1/jobs/{job_id}/cancel` | Запросить отмену создания (между этапами; `kind create` до конца не прерывается) | ### Фоновые задания (jobs) - Хранятся **только в памяти** процесса uvicorn; после перезапуска контейнера история обнуляется. - В памяти держится не более **200** записей; при превышении старые задания вытесняются (`app/core/job_store.py`). - Создание кластера: `POST /api/v1/clusters` → опрос `GET /api/v1/jobs/{job_id}` (как в веб-UI). +- В ответе задания поля **`progress_stage`** (текст этапа) и **`progress_percent`** (0–100) обновляются во время создания. +- Статус **`cancelled`** — пользователь запросил отмену (`POST .../cancel`); этап `kind create cluster` до завершения не прерывается. --- @@ -170,13 +176,34 @@ "cluster_name": "dev", "created_at_utc": "2026-04-04T12:00:00+00:00", "message": "Кластер создан", - "result": { "cluster_name": "dev", "kubernetes_version_tag": "v1.29.4" } + "result": { "cluster_name": "dev", "kubernetes_version_tag": "v1.29.4" }, + "progress_stage": null, + "progress_percent": null } ] ``` --- +## POST /api/v1/jobs/{job_id}/cancel + +Запрос отмены создания кластера. Пока задание в статусе `queued` или `running`, между этапами выполняется проверка флага; после уже запущенного `kind create cluster` нужно дождаться окончания этого шага. + +**Пример ответа 200:** + +```json +{ + "job_id": "a1b2…", + "cancel_requested": true, + "message": "Отмена обрабатывается между этапами; во время kind create дождитесь окончания шага" +} +``` + +**Ошибка 400:** задание уже завершено. +**Ошибка 404:** неизвестный `job_id`. + +--- + ## GET /api/v1/clusters/{name}/kubeconfig Скачать файл `kubeconfig` (ответ — тело файла, `Content-Disposition` с именем `kubeconfig-{name}.yaml`). diff --git a/app/main.py b/app/main.py index cac6e6d..8515f21 100644 --- a/app/main.py +++ b/app/main.py @@ -1,4 +1,6 @@ -"""Веб-интерфейс и REST API для управления локальными кластерами kind (порт по умолчанию 6000). +"""Веб-интерфейс и REST API для управления локальными кластерами kind. + +В контейнере uvicorn слушает порт 6000; на хост публикация по умолчанию 8080 (``KIND_K8S_WEB_PORT``), т.к. 6000 на хосте блокируется Chrome (ERR_UNSAFE_PORT). Запуск в контейнере: ``python3 -m uvicorn main:app --host 0.0.0.0 --port 6000`` из каталога ``/opt/kind-k8s/app`` или через ``make docker up`` / ``make podman up``. @@ -65,12 +67,11 @@ async def dashboard(request: Request) -> HTMLResponse: content="

Шаблоны не найдены. Ожидается каталог app/templates/

", status_code=500, ) + # Starlette ≥0.37: первым аргументом обязателен Request (иначе dict уйдёт в get_template → unhashable type). return templates.TemplateResponse( + request, "dashboard.html", - { - "request": request, - "app_title": settings.app_title, - }, + {"app_title": settings.app_title}, ) diff --git a/app/models/schemas.py b/app/models/schemas.py index c17a9b5..df3e06f 100644 --- a/app/models/schemas.py +++ b/app/models/schemas.py @@ -36,11 +36,13 @@ class JobView(BaseModel): job_id: str kind: str - status: Literal["queued", "running", "success", "failed"] + 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") class ClusterSummary(BaseModel): diff --git a/app/static/js/dashboard.js b/app/static/js/dashboard.js index 9b0c11e..9dac2db 100644 --- a/app/static/js/dashboard.js +++ b/app/static/js/dashboard.js @@ -1,5 +1,6 @@ /** * Панель управления кластерами kind (REST /api/v1). + * Автообновление списков и health; прогресс и отмена создания кластера. * * Автор: Сергей Антропов * Сайт: https://devops.org.ru @@ -10,11 +11,18 @@ const body = document.body; const API = (body.dataset.apiBase || "/api/v1").replace(/\/$/, ""); + /** Интервал опроса списков и среды (мс) */ + var AUTO_REFRESH_MS = 3500; + /** Интервал опроса задания создания (мс) */ + var JOB_POLL_MS = 1500; + /** @type {ReturnType | null} */ - let autoTimer = null; + var autoTimer = null; /** @type {ReturnType | null} */ - let pollTimer = null; - let createInProgress = false; + var pollTimer = null; + var createInProgress = false; + /** @type {string | null} */ + var currentPollJobId = null; function formatApiError(data, fallback) { if (!data) return fallback; @@ -37,10 +45,10 @@ const url = path.startsWith("http") ? path : API + path; const r = await fetch(url, opts); const text = await r.text(); - let data; + var data; try { data = text ? JSON.parse(text) : null; - } catch { + } catch (e) { data = { raw: text }; } if (!r.ok) { @@ -81,6 +89,16 @@ el.classList.toggle("is-loading", busy); } + function setStatusBannerClass(ok, degraded) { + const el = document.getElementById("status-banner"); + if (!el) return; + var cls = "hero-panel-status muted "; + if (ok) cls += "ok"; + else if (degraded) cls += "degraded"; + else cls += "err"; + el.className = cls; + } + async function loadHealth() { const el = document.getElementById("status-banner"); if (!el) return; @@ -88,8 +106,8 @@ const h = await api("/health"); const ok = h.status === "ok" && h.container_engine_ok && h.kind_in_path && h.kubectl_in_path; - el.className = "status-banner " + (ok ? "ok" : "degraded"); - let lines = "Среда: "; + setStatusBannerClass(ok, !ok); + var lines = "Среда: "; lines += escapeHtml(String(h.container_cli || "?")); lines += " → " + (h.container_engine_ok ? "API OK" : "API недоступен"); lines += " · kind: " + (h.kind_in_path ? "да" : "нет"); @@ -102,7 +120,7 @@ } el.innerHTML = lines; } catch (e) { - el.className = "status-banner err"; + setStatusBannerClass(false, false); el.textContent = "Не удалось запросить health: " + e.message; } } @@ -161,7 +179,7 @@ sel.onchange = function () { if (sel.value) verInput.value = sel.value.replace(/^v/, ""); }; - } catch { + } catch (e) { sel.innerHTML = ""; } } @@ -170,6 +188,7 @@ if (status === "success") return "badge badge-ok"; if (status === "failed") return "badge badge-err"; if (status === "running") return "badge badge-run"; + if (status === "cancelled") return "badge badge-cancelled"; return "badge"; } @@ -253,6 +272,10 @@ rows.forEach(function (j) { const tr = document.createElement("tr"); const st = escapeHtml(j.status || ""); + var cellMsg = (j.message || "").slice(0, 160); + if ((j.status === "running" || j.status === "queued") && j.progress_stage) { + cellMsg = j.progress_stage + (j.progress_percent != null ? " (" + j.progress_percent + "%)" : ""); + } tr.innerHTML = "