Веб-UI кластера: страница деталей, kubectl по карточкам, мета 3 колонки

- Страница /cluster/<имя>: сводка, ресурсы узлов полосами, отдельные карточки на каждый kubectl get … -o json, рестарт пода.
- API: overview с блоками k8s_*, POST pods/restart, расширенный набор ресурсов (-A).
- Панель: спиннер загрузки, правки дашборда и стилей; документация api_routes, compose и прочие сопутствующие изменения.
This commit is contained in:
Sergey Antropoff
2026-04-04 09:13:08 +03:00
parent 4546f50aef
commit af0d1705cc
17 changed files with 2605 additions and 208 deletions
+3 -2
View File
@@ -86,7 +86,7 @@ check-docker: _require_runtime ## (с docker/podman) Проверить CLI и c
@$(COMPOSE) version >/dev/null 2>&1 || { echo >&2 "Команда «$(COMPOSE) version» недоступна."; exit 1; } @$(COMPOSE) version >/dev/null 2>&1 || { echo >&2 "Команда «$(COMPOSE) version» недоступна."; exit 1; }
@echo "$(COMPOSE): OK" @echo "$(COMPOSE): OK"
# kubectl и kind в образе; kubeconfig в томе /work/clusters/<CLUSTER>/kubeconfig — kubectl на хосте не нужен. # kubectl в образе; путь к kubeconfig — как у веб-UI (подстановка server через host.docker.internal, см. kubeconfig_patch.py).
kubectl: _require_runtime ## (с docker/podman) kubectl в контейнере: CLUSTER=имя [KUBECTL_ARGS="get pods -A"] kubectl: _require_runtime ## (с docker/podman) kubectl в контейнере: CLUSTER=имя [KUBECTL_ARGS="get pods -A"]
@if [ -z "$(CLUSTER)" ]; then \ @if [ -z "$(CLUSTER)" ]; then \
echo >&2 "Задайте CLUSTER=<имя_кластера> (каталог в ./clusters/)."; \ echo >&2 "Задайте CLUSTER=<имя_кластера> (каталог в ./clusters/)."; \
@@ -94,7 +94,8 @@ kubectl: _require_runtime ## (с docker/podman) kubectl в контейнере:
echo >&2 "Свои подкоманды: make docker kubectl CLUSTER=dev KUBECTL_ARGS=\"get pods -A\""; \ echo >&2 "Свои подкоманды: make docker kubectl CLUSTER=dev KUBECTL_ARGS=\"get pods -A\""; \
exit 1; \ exit 1; \
fi fi
cd "$(KIND_K8S_DIR)" && $(COMPOSE) exec kind-k8s-web kubectl --kubeconfig=/work/clusters/$(CLUSTER)/kubeconfig $(KUBECTL_ARGS) cd "$(KIND_K8S_DIR)" && KC=$$($(COMPOSE) exec -T kind-k8s-web python3 scripts/effective_kubeconfig_path.py $(CLUSTER) | tr -d '\r') && \
$(COMPOSE) exec kind-k8s-web kubectl --kubeconfig="$$KC" $(KUBECTL_ARGS)
build: _require_runtime clusters-dir ## (с docker/podman) Собрать образ kind-k8s-tools:local build: _require_runtime clusters-dir ## (с docker/podman) Собрать образ kind-k8s-tools:local
cd "$(KIND_K8S_DIR)" && $(COMPOSE) build $(COMPOSE_BUILD_FLAGS) cd "$(KIND_K8S_DIR)" && $(COMPOSE) build $(COMPOSE_BUILD_FLAGS)
+2 -2
View File
@@ -45,11 +45,11 @@
**На хост не нужны:** **kind**, **kubectl**, Python приложения — всё это в образе `kind-k8s-tools:local` и выполняется в контейнере **`kind-k8s-web`**. Проверка API и узлов: **веб-интерфейс** (кластер → узлы/поды) или **`make docker kubectl CLUSTER=<имя>`** / **`make podman kubectl …`** (см. ниже) — `kubectl` вызывается через **`docker compose exec`** / **`podman compose exec`** внутри уже запущенного сервиса. **На хост не нужны:** **kind**, **kubectl**, Python приложения — всё это в образе `kind-k8s-tools:local` и выполняется в контейнере **`kind-k8s-web`**. Проверка API и узлов: **веб-интерфейс** (кластер → узлы/поды) или **`make docker kubectl CLUSTER=<имя>`** / **`make podman kubectl …`** (см. ниже) — `kubectl` вызывается через **`docker compose exec`** / **`podman compose exec`** внутри уже запущенного сервиса.
Файл **`clusters/<имя>/kubeconfig`** на хосте можно использовать **опционально**, если у вас локально установлен kubectl (например IDE или отладка) — после патча apiserver обычно указывает на `127.0.0.1:<порт>`. Файл **`clusters/<имя>/kubeconfig.host`** (или скачивание из веб-UI) для kubectl на хосте: **`https://<KIND_K8S_KUBECONFIG_CLIENT_HOST или localhost>:<порт>`** — см. `app/kubeconfig_patch.py`. Переменная позволяет задать IP/имя хоста, если `localhost` недоступен с вашей машины.
Смонтированы **сокет** Docker/Podman и каталог **`./clusters`** → в контейнере **`/work/clusters`**. Каталог **`./app`** монтируется в **`/opt/kind-k8s/app`** для разработки без пересборки образа. Файл **`./README.md`** монтируется в **`/opt/kind-k8s/README.md`** (страница **«Документация»** и **`GET /api/v1/docs/readme`** без пересборки образа). Смонтированы **сокет** Docker/Podman и каталог **`./clusters`** → в контейнере **`/work/clusters`**. Каталог **`./app`** монтируется в **`/opt/kind-k8s/app`** для разработки без пересборки образа. Файл **`./README.md`** монтируется в **`/opt/kind-k8s/README.md`** (страница **«Документация»** и **`GET /api/v1/docs/readme`** без пересборки образа).
После создания кластера kubeconfig по умолчанию **патчится** на `https://127.0.0.1:<порт>` для доступа с хоста (`KIND_K8S_PATCH_KUBECONFIG`, см. `app/kubeconfig_patch.py`). После создания кластера при **`KIND_K8S_PATCH_KUBECONFIG`** дополнительно пишется **`kubeconfig.host`**; скачивание через API каждый раз пересобирает файл с актуальным портом и хостом из **`KIND_K8S_KUBECONFIG_CLIENT_HOST`**.
## Быстрый старт ## Быстрый старт
+267 -9
View File
@@ -10,10 +10,16 @@ import asyncio
import json import json
import logging import logging
import os import os
import re
import shutil
import tempfile
from functools import partial
from pathlib import Path
from typing import Any from typing import Any
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query from fastapi import APIRouter, BackgroundTasks, HTTPException, Query
from fastapi.responses import FileResponse, JSONResponse from fastapi.responses import FileResponse, JSONResponse
from starlette.background import BackgroundTask
from core.cluster_lifecycle import ( from core.cluster_lifecycle import (
KindClusterError, KindClusterError,
@@ -21,6 +27,9 @@ from core.cluster_lifecycle import (
configured_container_cli, configured_container_cli,
create_cluster_non_interactive, create_cluster_non_interactive,
delete_kind_cluster_and_data, delete_kind_cluster_and_data,
kubectl_delete_pod,
kubectl_get_json,
kubectl_get_all_namespaces_wide,
kubectl_nodes_wide, kubectl_nodes_wide,
kubectl_pods_all_namespaces, kubectl_pods_all_namespaces,
list_registered_kind_clusters, list_registered_kind_clusters,
@@ -32,6 +41,7 @@ from core.cluster_lifecycle import (
from core.container_resource_stats import ( from core.container_resource_stats import (
aggregate_kind_cluster_resources, aggregate_kind_cluster_resources,
collect_kind_clusters_resource_stats, collect_kind_clusters_resource_stats,
collect_single_cluster_resource_stats,
running_kind_clusters_mask, running_kind_clusters_mask,
) )
from core.job_store import ( from core.job_store import (
@@ -47,18 +57,51 @@ from core.job_store import (
from core.provision_log import provision_log_file_path, write_cluster_provision_log from core.provision_log import provision_log_file_path, write_cluster_provision_log
from core.kind_guard import kind_cluster_lock from core.kind_guard import kind_cluster_lock
from kind_k8s_paths import clusters_dir from kind_k8s_paths import clusters_dir
from kubeconfig_patch import kubeconfig_host_file, patch_kubeconfig_server_for_host
from models.schemas import ( from models.schemas import (
AggregateResourcesSummary, AggregateResourcesSummary,
ClusterCreateAccepted, ClusterCreateAccepted,
ClusterCreateRequest, ClusterCreateRequest,
ClusterOverviewResponse,
ClusterSummary, ClusterSummary,
ClusterWorkloadsResponse, ClusterWorkloadsResponse,
JobView, JobView,
K8sListJsonBlock,
PodRestartRequest,
PodRestartResponse,
StatsResponse, StatsResponse,
) )
logger = logging.getLogger("kind_k8s.api.clusters") logger = logging.getLogger("kind_k8s.api.clusters")
# Безопасные имена namespace/pod для kubectl (без shell-метасимволов).
_K8S_NAME_SAFE = re.compile(r"^[a-zA-Z0-9][-a-zA-Z0-9.]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$")
def _k8s_ns_pod_valid(s: str) -> bool:
if not s or len(s) > 253:
return False
return bool(_K8S_NAME_SAFE.match(s))
def _k8s_list_from_kubectl(
rc: int,
items: list[dict[str, Any]] | None,
err: str,
) -> K8sListJsonBlock:
"""Собрать блок JSON для UI из результата :func:`kubectl_get_json`."""
if items is not None and rc == 0:
return K8sListJsonBlock(ok=True, rc=0, items=items, message=None)
return K8sListJsonBlock(ok=False, rc=rc, items=[], message=err or "ошибка kubectl")
def _unlink_temp_kubeconfig(path: Path) -> None:
"""Удалить временный файл после отдачи ответа (скачивание kubeconfig)."""
try:
path.unlink(missing_ok=True)
except OSError as e:
logger.debug("Не удалось удалить временный kubeconfig %s: %s", path, e)
router = APIRouter(tags=["clusters"]) router = APIRouter(tags=["clusters"])
@@ -202,16 +245,56 @@ async def list_clusters() -> list[ClusterSummary]:
responses={404: {"description": "Файл не найден"}}, responses={404: {"description": "Файл не найден"}},
) )
async def download_kubeconfig(name: str) -> FileResponse: async def download_kubeconfig(name: str) -> FileResponse:
"""Файл ``clusters/<имя>/kubeconfig`` для использования с хоста (локальная dev-среда).""" """
Kubeconfig для **kubectl на машине пользователя**: копия ``clusters/<имя>/kubeconfig``
с ``server=https://<KIND_K8S_KUBECONFIG_CLIENT_HOST или localhost>:<порт>`` (порт из ``docker port``).
Патч выполняется при каждом скачивании, чтобы учитывать переменные окружения и актуальный порт.
Если патч невозможен — отдаётся сохранённый ``kubeconfig.host`` или сырой ``kubeconfig``.
"""
if not validate_cluster_name(name): if not validate_cluster_name(name):
raise HTTPException(status_code=400, detail="Некорректное имя кластера") raise HTTPException(status_code=400, detail="Некорректное имя кластера")
path = clusters_dir() / name / "kubeconfig" n = name.strip()
if not path.is_file(): base = clusters_dir() / n
primary = base / "kubeconfig"
if not primary.is_file():
raise HTTPException(status_code=404, detail="kubeconfig не найден") raise HTTPException(status_code=404, detail="kubeconfig не найден")
logger.info("Отдача kubeconfig для кластера %s", name)
fd, tmp_name = tempfile.mkstemp(suffix=".yaml", prefix="kind-k8s-kube-")
os.close(fd)
tmp_path = Path(tmp_name)
try:
shutil.copyfile(primary, tmp_path, follow_symlinks=True)
except OSError as e:
tmp_path.unlink(missing_ok=True)
logger.warning("Копирование kubeconfig для скачивания: %s", e)
raise HTTPException(status_code=500, detail="Не удалось подготовить kubeconfig") from e
patched = await asyncio.to_thread(
partial(patch_kubeconfig_server_for_host, cluster_name=n, kube_path=tmp_path),
)
if patched:
logger.info("Отдача kubeconfig для кластера %s (сгенерировано с актуальным server)", n)
return FileResponse( return FileResponse(
path=path, path=str(tmp_path),
filename=f"kubeconfig-{name}.yaml", filename=f"kubeconfig-{n}.yaml",
media_type="application/x-yaml",
background=BackgroundTask(_unlink_temp_kubeconfig, tmp_path),
)
tmp_path.unlink(missing_ok=True)
host_path = kubeconfig_host_file(base)
if host_path.is_file():
logger.info("Отдача kubeconfig для кластера %s (файл %s, патч при скачивании не удался)", n, host_path.name)
return FileResponse(
path=host_path,
filename=f"kubeconfig-{n}.yaml",
media_type="application/x-yaml",
)
logger.info("Отдача kubeconfig для кластера %s (сырой kubeconfig)", n)
return FileResponse(
path=primary,
filename=f"kubeconfig-{n}.yaml",
media_type="application/x-yaml", media_type="application/x-yaml",
) )
@@ -250,8 +333,16 @@ async def cluster_workloads(name: str) -> ClusterWorkloadsResponse:
if not kc.is_file(): if not kc.is_file():
return ClusterWorkloadsResponse(cluster_name=name, error="Нет сохранённого kubeconfig в clusters/<имя>/") return ClusterWorkloadsResponse(cluster_name=name, error="Нет сохранённого kubeconfig в clusters/<имя>/")
nodes_rc, nodes_out = await asyncio.to_thread(kubectl_nodes_wide, kubeconfig=kc) nodes_rc, nodes_out = await asyncio.to_thread(
pods_rc, pods_out = await asyncio.to_thread(kubectl_pods_all_namespaces, kubeconfig=kc) kubectl_nodes_wide,
cluster_name=name,
kubeconfig=kc,
)
pods_rc, pods_out = await asyncio.to_thread(
kubectl_pods_all_namespaces,
cluster_name=name,
kubeconfig=kc,
)
return ClusterWorkloadsResponse( return ClusterWorkloadsResponse(
cluster_name=name, cluster_name=name,
nodes_rc=nodes_rc, nodes_rc=nodes_rc,
@@ -261,6 +352,169 @@ async def cluster_workloads(name: str) -> ClusterWorkloadsResponse:
) )
@router.get(
"/clusters/{name}/overview",
response_model=ClusterOverviewResponse,
summary="Сводка кластера (страница UI)",
)
async def cluster_overview(name: str) -> ClusterOverviewResponse:
"""
Мета, метрики узлов (docker/podman), агрегаты как на главной, вывод kubectl по **всему кластеру**:
узлы; для namespace-ресурсов везде **-A** (все пространства имён); отдельно namespaces, RS, jobs и т.д.
"""
if not validate_cluster_name(name):
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
n = name.strip()
summary = cluster_summary_for_api(n)
run_mask = await asyncio.to_thread(running_kind_clusters_mask, [n])
kind_running = bool(run_mask.get(n, False))
block, res_err = await asyncio.to_thread(collect_single_cluster_resource_stats, n)
agg = aggregate_kind_cluster_resources([block])
kc = clusters_dir() / n / "kubeconfig"
kube_err: str | None = None
k8s_nodes = K8sListJsonBlock(ok=False, items=[], message=None)
k8s_pods = K8sListJsonBlock(ok=False, items=[], message=None)
k8s_deployments = K8sListJsonBlock(ok=False, items=[], message=None)
k8s_statefulsets = K8sListJsonBlock(ok=False, items=[], message=None)
k8s_daemonsets = K8sListJsonBlock(ok=False, items=[], message=None)
k8s_services = K8sListJsonBlock(ok=False, items=[], message=None)
k8s_ingresses = K8sListJsonBlock(ok=False, items=[], message=None)
k8s_namespaces = K8sListJsonBlock(ok=False, items=[], message=None)
k8s_replicasets = K8sListJsonBlock(ok=False, items=[], message=None)
k8s_jobs = K8sListJsonBlock(ok=False, items=[], message=None)
k8s_cronjobs = K8sListJsonBlock(ok=False, items=[], message=None)
k8s_pvcs = K8sListJsonBlock(ok=False, items=[], message=None)
if not kc.is_file():
kube_err = "Нет сохранённого kubeconfig в clusters/<имя>/"
_kube_missing = K8sListJsonBlock(ok=False, items=[], message=kube_err)
k8s_nodes = _kube_missing
k8s_pods = k8s_deployments = k8s_statefulsets = k8s_daemonsets = _kube_missing
k8s_services = k8s_ingresses = k8s_namespaces = k8s_replicasets = _kube_missing
k8s_jobs = k8s_cronjobs = k8s_pvcs = _kube_missing
else:
(
n_res,
p_res,
d_res,
st_res,
dm_res,
svc_res,
ing_res,
ns_res,
rs_res,
job_res,
cj_res,
pvc_res,
) = await asyncio.gather(
asyncio.to_thread(kubectl_get_json, cluster_name=n, kubeconfig=kc, resource_args=["nodes"]),
asyncio.to_thread(kubectl_get_json, cluster_name=n, kubeconfig=kc, resource_args=["pods", "-A"]),
asyncio.to_thread(kubectl_get_json, cluster_name=n, kubeconfig=kc, resource_args=["deployments", "-A"]),
asyncio.to_thread(kubectl_get_json, cluster_name=n, kubeconfig=kc, resource_args=["statefulsets", "-A"]),
asyncio.to_thread(kubectl_get_json, cluster_name=n, kubeconfig=kc, resource_args=["daemonsets", "-A"]),
asyncio.to_thread(kubectl_get_json, cluster_name=n, kubeconfig=kc, resource_args=["services", "-A"]),
asyncio.to_thread(
kubectl_get_json,
cluster_name=n,
kubeconfig=kc,
resource_args=["ingresses.networking.k8s.io", "-A"],
),
asyncio.to_thread(kubectl_get_json, cluster_name=n, kubeconfig=kc, resource_args=["namespaces"]),
asyncio.to_thread(kubectl_get_json, cluster_name=n, kubeconfig=kc, resource_args=["replicasets", "-A"]),
asyncio.to_thread(kubectl_get_json, cluster_name=n, kubeconfig=kc, resource_args=["jobs", "-A"]),
asyncio.to_thread(kubectl_get_json, cluster_name=n, kubeconfig=kc, resource_args=["cronjobs", "-A"]),
asyncio.to_thread(kubectl_get_json, cluster_name=n, kubeconfig=kc, resource_args=["pvc", "-A"]),
)
k8s_nodes = _k8s_list_from_kubectl(*n_res)
k8s_pods = _k8s_list_from_kubectl(*p_res)
k8s_deployments = _k8s_list_from_kubectl(*d_res)
k8s_statefulsets = _k8s_list_from_kubectl(*st_res)
k8s_daemonsets = _k8s_list_from_kubectl(*dm_res)
k8s_services = _k8s_list_from_kubectl(*svc_res)
k8s_ingresses = _k8s_list_from_kubectl(*ing_res)
k8s_namespaces = _k8s_list_from_kubectl(*ns_res)
k8s_replicasets = _k8s_list_from_kubectl(*rs_res)
k8s_jobs = _k8s_list_from_kubectl(*job_res)
k8s_cronjobs = _k8s_list_from_kubectl(*cj_res)
k8s_pvcs = _k8s_list_from_kubectl(*pvc_res)
meta = summary.get("meta")
return ClusterOverviewResponse(
cluster_name=n,
registered_in_kind=bool(summary["registered_in_kind"]),
kind_nodes_running=kind_running,
has_local_kubeconfig=bool(summary["has_local_kubeconfig"]),
has_provision_log=bool(summary.get("has_provision_log")),
meta=dict(meta) if isinstance(meta, dict) else {},
resources_error=res_err,
cluster_resources=block,
aggregate_resources=agg,
kubeconfig_error=kube_err,
nodes_rc=None,
nodes_output=None,
pods_rc=None,
pods_output=None,
deployments_rc=None,
deployments_output=None,
statefulsets_rc=None,
statefulsets_output=None,
daemonsets_rc=None,
daemonsets_output=None,
services_rc=None,
services_output=None,
ingresses_rc=None,
ingresses_output=None,
k8s_nodes=k8s_nodes,
k8s_pods=k8s_pods,
k8s_deployments=k8s_deployments,
k8s_statefulsets=k8s_statefulsets,
k8s_daemonsets=k8s_daemonsets,
k8s_services=k8s_services,
k8s_ingresses=k8s_ingresses,
k8s_namespaces=k8s_namespaces,
k8s_replicasets=k8s_replicasets,
k8s_jobs=k8s_jobs,
k8s_cronjobs=k8s_cronjobs,
k8s_pvcs=k8s_pvcs,
)
@router.post(
"/clusters/{name}/pods/restart",
response_model=PodRestartResponse,
summary="Рестарт пода (kubectl delete pod)",
)
async def restart_cluster_pod(name: str, body: PodRestartRequest) -> PodRestartResponse:
"""
Удаление пода: Deployment/RS/Job при необходимости создают новый Pod (типичный «рестарт»).
"""
if not validate_cluster_name(name):
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
n = name.strip()
ns = body.namespace.strip()
pod = body.pod.strip()
if not _k8s_ns_pod_valid(ns) or not _k8s_ns_pod_valid(pod):
raise HTTPException(status_code=400, detail="Некорректное имя namespace или пода")
kc = clusters_dir() / n / "kubeconfig"
if not kc.is_file():
raise HTTPException(status_code=404, detail="kubeconfig не найден")
rc, msg = await asyncio.to_thread(
kubectl_delete_pod,
cluster_name=n,
kubeconfig=kc,
namespace=ns,
pod_name=pod,
grace_period=30,
)
if rc != 0:
logger.warning("restart pod %s/%s в кластере %s: rc=%s %s", ns, pod, n, rc, msg[:500])
raise HTTPException(status_code=502, detail=msg[:4000] if msg else "kubectl delete pod завершился с ошибкой")
logger.info("Рестарт пода %s/%s в кластере %s", ns, pod, n)
return PodRestartResponse(cluster_name=n, namespace=ns, pod=pod, rc=rc, message=msg or "ok")
@router.get("/clusters/{name}", summary="Детали кластера") @router.get("/clusters/{name}", summary="Детали кластера")
async def get_cluster(name: str) -> dict[str, object]: async def get_cluster(name: str) -> dict[str, object]:
"""Сводка и попытка ``kubectl get nodes`` (предпочтительно сохранённый kubeconfig).""" """Сводка и попытка ``kubectl get nodes`` (предпочтительно сохранённый kubeconfig)."""
@@ -269,7 +523,11 @@ async def get_cluster(name: str) -> dict[str, object]:
kubectl_rc: int | None = None kubectl_rc: int | None = None
kubectl_msg: str | None = None kubectl_msg: str | None = None
if saved.is_file(): if saved.is_file():
rc, msg = await asyncio.to_thread(kubectl_nodes_wide, kubeconfig=saved) rc, msg = await asyncio.to_thread(
kubectl_nodes_wide,
cluster_name=name.strip(),
kubeconfig=saved,
)
kubectl_rc = rc kubectl_rc = rc
kubectl_msg = msg kubectl_msg = msg
return { return {
+159 -9
View File
@@ -17,13 +17,19 @@ import re
import shutil import shutil
import subprocess import subprocess
from collections.abc import Callable from collections.abc import Callable
from typing import Any
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from kind_k8s_paths import clusters_dir, data_root from kind_k8s_paths import clusters_dir, data_root
from kindest_node_tags import normalize_tag_v_prefix from kindest_node_tags import normalize_tag_v_prefix
from kubeconfig_patch import patch_kubeconfig_server_for_host, should_patch_after_create from kubeconfig_patch import (
kubeconfig_host_file,
kubeconfig_path_for_container_kubectl,
patch_kubeconfig_server_for_host,
should_patch_after_create,
)
logger = logging.getLogger("kind_k8s.cluster_lifecycle") logger = logging.getLogger("kind_k8s.cluster_lifecycle")
@@ -398,19 +404,31 @@ def _wait_nodes_timeout_sec() -> int:
return 300 return 300
def wait_nodes_ready(*, kubeconfig_path: Path, timeout_sec: int | None = None) -> tuple[bool, str]: def wait_nodes_ready(
*,
cluster_name: str,
kubeconfig_path: Path,
timeout_sec: int | None = None,
) -> tuple[bool, str]:
""" """
Дождаться condition=Ready для всех нод через kubectl wait. Дождаться condition=Ready для всех нод через kubectl wait.
В контейнере веб-приложения путь к kubeconfig проходит через
``kubeconfig_path_for_container_kubectl`` (шлюз хоста + проброшенный порт или имя узла kind).
Возвращает (успех, сообщение для лога/UI). Возвращает (успех, сообщение для лога/UI).
""" """
if timeout_sec is None: if timeout_sec is None:
timeout_sec = _wait_nodes_timeout_sec() timeout_sec = _wait_nodes_timeout_sec()
cfg = kubeconfig_path_for_container_kubectl(
cluster_name=cluster_name,
kube_source_path=kubeconfig_path,
)
t = f"{timeout_sec}s" t = f"{timeout_sec}s"
cmd = [ cmd = [
"kubectl", "kubectl",
"--kubeconfig", "--kubeconfig",
str(kubeconfig_path), str(cfg),
"wait", "wait",
"--for=condition=Ready", "--for=condition=Ready",
"nodes", "nodes",
@@ -561,10 +579,14 @@ def create_cluster_non_interactive(
_rollback_after_cancel(cluster_name=name, out_dir=out_dir) _rollback_after_cancel(cluster_name=name, out_dir=out_dir)
raise KindClusterError("Операция отменена") raise KindClusterError("Операция отменена")
host_kube_path = kubeconfig_host_file(kube_path.parent)
patched = False patched = False
if should_patch_after_create(): if should_patch_after_create():
_progress("Настройка доступа с вашего компьютера", 72) _progress("Настройка доступа с вашего компьютера", 72)
patched = patch_kubeconfig_server_for_host(cluster_name=name, kube_path=kube_path) shutil.copyfile(kube_path, host_kube_path, follow_symlinks=True)
patched = patch_kubeconfig_server_for_host(cluster_name=name, kube_path=host_kube_path)
if not patched:
host_kube_path.unlink(missing_ok=True)
if _cancelled(): if _cancelled():
_rollback_after_cancel(cluster_name=name, out_dir=out_dir) _rollback_after_cancel(cluster_name=name, out_dir=out_dir)
@@ -574,7 +596,7 @@ def create_cluster_non_interactive(
nodes_msg: str | None = None nodes_msg: str | None = None
if _wait_nodes_enabled(): if _wait_nodes_enabled():
_progress("Ожидание готовности узлов", 82) _progress("Ожидание готовности узлов", 82)
ok, msg = wait_nodes_ready(kubeconfig_path=kube_path) ok, msg = wait_nodes_ready(cluster_name=name, kubeconfig_path=kube_path)
nodes_ready = ok nodes_ready = ok
nodes_msg = msg nodes_msg = msg
if ok: if ok:
@@ -606,6 +628,8 @@ def create_cluster_non_interactive(
"nodes_ready_message": nodes_msg, "nodes_ready_message": nodes_msg,
"provisioned_from_existing_config": use_existing_config, "provisioned_from_existing_config": use_existing_config,
} }
if patched:
meta["kubeconfig_host_path"] = str(host_kube_path.relative_to(root))
meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8") meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
_progress("Завершение", 95) _progress("Завершение", 95)
@@ -872,13 +896,15 @@ def container_engine_ping(*, timeout_sec: float = 12.0) -> tuple[bool, str, str]
return False, err[:800], cli return False, err[:800], cli
def kubectl_nodes_wide(*, kubeconfig: str | Path) -> tuple[int, str]: def kubectl_nodes_wide(*, cluster_name: str, kubeconfig: str | Path) -> tuple[int, str]:
"""``kubectl get nodes -o wide``; возвращает (код, объединённый вывод).""" """``kubectl get nodes -o wide``; возвращает (код, объединённый вывод)."""
src = Path(kubeconfig)
cfg = kubeconfig_path_for_container_kubectl(cluster_name=cluster_name, kube_source_path=src)
p = subprocess.run( p = subprocess.run(
[ [
"kubectl", "kubectl",
"--kubeconfig", "--kubeconfig",
str(kubeconfig), str(cfg),
"get", "get",
"nodes", "nodes",
"-o", "-o",
@@ -894,13 +920,15 @@ def kubectl_nodes_wide(*, kubeconfig: str | Path) -> tuple[int, str]:
return p.returncode, msg return p.returncode, msg
def kubectl_pods_all_namespaces(*, kubeconfig: str | Path) -> tuple[int, str]: def kubectl_pods_all_namespaces(*, cluster_name: str, kubeconfig: str | Path) -> tuple[int, str]:
"""``kubectl get pods -A``; сводка подов по кластеру.""" """``kubectl get pods -A``; сводка подов по кластеру."""
src = Path(kubeconfig)
cfg = kubeconfig_path_for_container_kubectl(cluster_name=cluster_name, kube_source_path=src)
p = subprocess.run( p = subprocess.run(
[ [
"kubectl", "kubectl",
"--kubeconfig", "--kubeconfig",
str(kubeconfig), str(cfg),
"get", "get",
"pods", "pods",
"-A", "-A",
@@ -915,6 +943,128 @@ def kubectl_pods_all_namespaces(*, kubeconfig: str | Path) -> tuple[int, str]:
return p.returncode, msg return p.returncode, msg
def kubectl_get_all_namespaces_wide(
*,
cluster_name: str,
kubeconfig: str | Path,
resource: str,
timeout: str = "30s",
) -> tuple[int, str]:
"""
``kubectl get <resource> -A -o wide`` — Deployments, StatefulSets, DaemonSets, Service, Ingress и т.д.
``resource`` — аргумент kubectl (``deployments``, ``pods``, ``svc`` …).
"""
src = Path(kubeconfig)
cfg = kubeconfig_path_for_container_kubectl(cluster_name=cluster_name, kube_source_path=src)
p = subprocess.run(
[
"kubectl",
"--kubeconfig",
str(cfg),
"get",
resource,
"-A",
"-o",
"wide",
f"--request-timeout={timeout}",
],
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 _parse_kubectl_list_json(stdout: str) -> tuple[list[dict[str, Any]] | None, str]:
"""Разбор вывода ``kubectl get … -o json`` (Kind List)."""
if not (stdout or "").strip():
return [], ""
try:
data = json.loads(stdout)
except json.JSONDecodeError as e:
return None, f"JSON: {e}"
if not isinstance(data, dict):
return None, "корень ответа не объект"
raw_items = data.get("items")
if raw_items is None:
return [], ""
if not isinstance(raw_items, list):
return None, "поле items не список"
out: list[dict[str, Any]] = [x for x in raw_items if isinstance(x, dict)]
return out, ""
def kubectl_get_json(
*,
cluster_name: str,
kubeconfig: str | Path,
resource_args: list[str],
timeout: str = "30s",
) -> tuple[int, list[dict[str, Any]] | None, str]:
"""
``kubectl get <resource_args…> -o json``.
``resource_args`` — например ``["nodes"]`` или ``["pods", "-A"]``, ``["deployments", "-A"]``.
Возвращает (код, список items или None, сообщение об ошибке).
"""
src = Path(kubeconfig)
cfg = kubeconfig_path_for_container_kubectl(cluster_name=cluster_name, kube_source_path=src)
cmd = [
"kubectl",
"--kubeconfig",
str(cfg),
"get",
*resource_args,
"-o",
"json",
f"--request-timeout={timeout}",
]
p = subprocess.run(cmd, capture_output=True, text=True)
out = (p.stdout or "").strip()
err = (p.stderr or "").strip()
if p.returncode != 0:
return p.returncode, None, err or out or f"код {p.returncode}"
items, perr = _parse_kubectl_list_json(out)
if items is None:
return p.returncode, None, perr
return 0, items, ""
def kubectl_delete_pod(
*,
cluster_name: str,
kubeconfig: str | Path,
namespace: str,
pod_name: str,
grace_period: int = 30,
) -> tuple[int, str]:
"""
Удаление пода (контроллер при необходимости создаст новый) — «мягкий рестарт».
``grace_period`` — секунды до SIGKILL (0 — сразу; для системных подов осторожно).
"""
src = Path(kubeconfig)
cfg = kubeconfig_path_for_container_kubectl(cluster_name=cluster_name, kube_source_path=src)
cmd = [
"kubectl",
"--kubeconfig",
str(cfg),
"delete",
"pod",
pod_name,
"-n",
namespace,
f"--grace-period={grace_period}",
"--request-timeout=60s",
]
p = subprocess.run(cmd, capture_output=True, text=True)
msg = ((p.stdout or "").strip() or (p.stderr or "").strip() or f"код {p.returncode}")[:8000]
return p.returncode, msg
def cluster_summary_for_api(name: str) -> dict[str, object]: def cluster_summary_for_api(name: str) -> dict[str, object]:
"""Сводка по кластеру для JSON API (без блокирующих долгих вызовов).""" """Сводка по кластеру для JSON API (без блокирующих долгих вызовов)."""
from core.provision_log import provision_log_file_path from core.provision_log import provision_log_file_path
+41 -2
View File
@@ -221,8 +221,8 @@ def aggregate_kind_cluster_resources(blocks: list[KindClusterResources]) -> Aggr
cpu_l = f"ср. {av_cpu:.1f}%" if av_cpu is not None else "" cpu_l = f"ср. {av_cpu:.1f}%" if av_cpu is not None else ""
mem_l = f"ср. {av_mem_pct:.1f}%" if av_mem_pct is not None else "" mem_l = f"ср. {av_mem_pct:.1f}%" if av_mem_pct is not None else ""
ratio_l = f"ср. {av_mem_ratio:.1f}%" if av_mem_ratio is not None else "" ratio_l = f"ср. {av_mem_ratio:.1f}%" if av_mem_ratio is not None else ""
net_l = f"Σ {_fmt_bytes_ru(sum_net)}" if sum_net > 0 else "" net_l = f"всего {_fmt_bytes_ru(sum_net)}" if sum_net > 0 else ""
disk_l = f"Σ {_fmt_bytes_ru(sum_blk)}" if sum_blk > 0 else "" disk_l = f"всего {_fmt_bytes_ru(sum_blk)}" if sum_blk > 0 else ""
logger.debug( logger.debug(
"aggregate_kind_cluster_resources: узлов=%s cpu_ring=%.1f net_ring=%.1f", "aggregate_kind_cluster_resources: узлов=%s cpu_ring=%.1f net_ring=%.1f",
@@ -322,3 +322,42 @@ def collect_kind_clusters_resource_stats() -> tuple[list[KindClusterResources],
blocks.append(KindClusterResources(cluster_name=cluster_name, nodes=stats, note=None)) blocks.append(KindClusterResources(cluster_name=cluster_name, nodes=stats, note=None))
return blocks, None return blocks, None
def collect_single_cluster_resource_stats(cluster_name: str) -> tuple[KindClusterResources, str | None]:
"""
Метрики узлов **одного** кластера (без полного прохода по всем kind-кластерам).
Если кластера нет в ``kind get clusters`` — пустой список узлов и пояснение в ``note``.
"""
name = cluster_name.strip()
cli = _container_cli_bin()
if not shutil.which(cli):
return KindClusterResources(cluster_name=name, nodes=[], note=None), f"CLI «{cli}» не найден в PATH"
if name not in list_registered_kind_clusters():
return KindClusterResources(
cluster_name=name,
nodes=[],
note="Кластер не в списке kind — метрики контейнеров-узлов недоступны",
), None
all_lines, ps_err = _all_container_names_ps_a(cli)
if ps_err:
return KindClusterResources(cluster_name=name, nodes=[], note=None), ps_err
running = _list_running_container_names(cli)
prefix = f"{name}-"
node_names = _sort_kind_node_containers([n for n in all_lines if n.startswith(prefix)])
active = [n for n in node_names if n in running]
if not active:
return KindClusterResources(
cluster_name=name,
nodes=[],
note="узлы остановлены или контейнеры отсутствуют",
), None
stats = _stats_for_container_names(cli, active)
order = {n: i for i, n in enumerate(node_names)}
stats.sort(key=lambda s: order.get(s.container_name, 99))
return KindClusterResources(cluster_name=name, nodes=stats, note=None), None
+17 -2
View File
@@ -198,6 +198,16 @@ def _run_interactive() -> None:
print(f" kubeconfig (в среде запуска): {result.kubeconfig_path}") print(f" kubeconfig (в среде запуска): {result.kubeconfig_path}")
if _in_container(): if _in_container():
print(f" Том на хосте: clusters/{result.cluster_name}/ в корне репозитория (рядом с Makefile)") print(f" Том на хосте: clusters/{result.cluster_name}/ в корне репозитория (рядом с Makefile)")
if result.kubeconfig_patched_for_host:
print(
f" Kubectl **с хоста**: clusters/{result.cluster_name}/kubeconfig.host "
f"(или скачивание из веб-UI) — server=<KIND_K8S_KUBECONFIG_CLIENT_HOST или localhost>:<порт>.",
)
print(
" Файл kubeconfig (без .host) — как выдал kind; статистика в UI и kubectl внутри приложения "
"используют apiserver через шлюз хоста (host.docker.internal:<порт> из docker port; см. compose extra_hosts).",
)
else:
print( print(
f" Проверка в этом контейнере: kubectl --kubeconfig=/work/clusters/{result.cluster_name}/kubeconfig get nodes", f" Проверка в этом контейнере: kubectl --kubeconfig=/work/clusters/{result.cluster_name}/kubeconfig get nodes",
) )
@@ -205,9 +215,14 @@ def _run_interactive() -> None:
print(" Проверка без kubectl на хосте: веб-интерфейс (кластер → узлы/поды) или из корня репозитория:") print(" Проверка без kubectl на хосте: веб-интерфейс (кластер → узлы/поды) или из корня репозитория:")
print(f" make docker kubectl CLUSTER={result.cluster_name} # или: make podman kubectl …") print(f" make docker kubectl CLUSTER={result.cluster_name} # или: make podman kubectl …")
print(" (сервис kind-k8s-web должен быть запущен: make docker up).") print(" (сервис kind-k8s-web должен быть запущен: make docker up).")
print(f" Локально при установленном kubectl: kubectl --kubeconfig={result.kubeconfig_path} get nodes") kc_for_shell = (
result.kubeconfig_path.parent / "kubeconfig.host"
if result.kubeconfig_patched_for_host
else result.kubeconfig_path
)
print(f" Локально при установленном kubectl: kubectl --kubeconfig={kc_for_shell} get nodes")
if result.kubeconfig_patched_for_host: if result.kubeconfig_patched_for_host:
print(" apiserver настроен на 127.0.0.1:<порт> для доступа с хоста.") print(" (файл kubeconfig.host — apiserver: localhost или KIND_K8S_KUBECONFIG_CLIENT_HOST и порт Docker.)")
if result.nodes_ready is False and result.nodes_ready_message: if result.nodes_ready is False and result.nodes_ready_message:
print(f" Предупреждение (ожидание нод): {result.nodes_ready_message}", file=sys.stderr) print(f" Предупреждение (ожидание нод): {result.nodes_ready_message}", file=sys.stderr)
+121 -7
View File
@@ -19,14 +19,15 @@
| Маршрут | Описание | | Маршрут | Описание |
|---------|----------| |---------|----------|
| `GET /` | HTML-панель: единая карточка «панель + среда», статистика, создание кластера (прогресс, **журнал** в реальном времени, **«Отменить»** прерывает текущую команду), **старт/стоп** кластера с тем же журналом (фоновые **POST …/start** и **…/stop**), таблица **последних заданий** с кнопкой **«Очистить завершённые»** (**DELETE /api/v1/jobs**), модалка узлов/подов; шапка — пилюли, Swagger / ReDoc / Health в отдельных окнах. | | `GET /` | HTML-панель: единая карточка «панель + среда», статистика, **ссылка с имени кластера на** `GET /cluster/<имя>` (сводка ресурсов, kubectl, действия), **старт/стоп** кластера с тем же журналом (фоновые **POST …/start** и **…/stop**), модалка узлов/подов; шапка — пилюли, Swagger / ReDoc / Health в отдельных окнах. |
| `GET /cluster/{name}` | HTML **страница кластера**: донаты «Ресурсы узлов (сводка)», карточки узлов, **таблицы Kubernetes** из JSON (`kubectl get … -o json`), кнопка **Рестарт** у подов (**`POST …/pods/restart`**), те же кнопки действий, что в таблице на главной; данные — **`GET /api/v1/clusters/{name}/overview`** (автообновление с интервалом панели). |
| `GET /documentation` | HTML-оболочка; **`documentation.js`**: без `path`**`GET /api/v1/docs/readme`**, с `?path=app/docs/…`**`GET /api/v1/docs/file`**; разбор Markdown из **`/static/js/vendor/`** (marked, DOMPurify). Каждая секция по **H2****одна карточка** (заголовок h2 и содержимое до следующего h2 вместе). Заголовок вкладки браузера: **«Документация — …»** + текст **первого H1** документа + имя приложения (`KIND_K8S_APP_TITLE` на `body`). В шапке на этой странице активна только **Документация**; **Панель** как обычная пилюля (на дашборде активна **Панель**). Путь к README: `KIND_K8S_README_PATH` или `README.md` рядом с `app/`; в образе — `/opt/kind-k8s/README.md`. | | `GET /documentation` | HTML-оболочка; **`documentation.js`**: без `path`**`GET /api/v1/docs/readme`**, с `?path=app/docs/…`**`GET /api/v1/docs/file`**; разбор Markdown из **`/static/js/vendor/`** (marked, DOMPurify). Каждая секция по **H2****одна карточка** (заголовок h2 и содержимое до следующего h2 вместе). Заголовок вкладки браузера: **«Документация — …»** + текст **первого H1** документа + имя приложения (`KIND_K8S_APP_TITLE` на `body`). В шапке на этой странице активна только **Документация**; **Панель** как обычная пилюля (на дашборде активна **Панель**). Путь к README: `KIND_K8S_README_PATH` или `README.md` рядом с `app/`; в образе — `/opt/kind-k8s/README.md`. |
| `GET /ui` | Редирект **307** на `/` (удобный ярлык). | | `GET /ui` | Редирект **307** на `/` (удобный ярлык). |
| `GET /static/…` | CSS (`style.css`), скрипты панели (`js/dashboard.js`) и документации (`js/documentation.js`); базовый URL API задаётся атрибутом `data-api-base` на `<body>` (по умолчанию `/api/v1`). | | `GET /static/…` | CSS (`style.css`), скрипты панели (`js/dashboard.js`) и документации (`js/documentation.js`); базовый URL API задаётся атрибутом `data-api-base` на `<body>` (по умолчанию `/api/v1`). |
Шаблоны: `app/templates/base.html` (шапка, навигация), `app/templates/dashboard.html` (контент панели), `app/templates/documentation.html` (README). Шаблоны: `app/templates/base.html` (шапка, навигация), `app/templates/dashboard.html` (контент панели), `app/templates/cluster_detail.html` (страница кластера), `app/templates/documentation.html` (README).
**kubectl на хосте не обязателен:** бинарник есть в образе; узлы и поды доступны через API (**`GET /api/v1/clusters/{name}/workloads`**) и веб-UI. Для интерактивной консоли из корня репозитория при запущенном compose: **`make docker kubectl CLUSTER=<имя>`** (или **`make podman kubectl …`**), внутри контейнера kubeconfig — **`/work/clusters/<имя>/kubeconfig`**. Перезапуск только веб-сервиса без `down`/`up`: **`make docker restart`** / **`make podman restart`**. Подробности — **README.md**. **kubectl на хосте не обязателен:** бинарник есть в образе; узлы и поды доступны через API и веб-UI. Внутри контейнера веб-приложения `kubectl` использует временный kubeconfig с `server` через **`host.docker.internal:<порт>`** (см. `kubeconfig_patch.py`, `extra_hosts` в compose). Скачивание для хоста — **`GET …/kubeconfig`** (файл **`kubeconfig.host`** при наличии). Для консоли: **`make docker kubectl CLUSTER=<имя>`** **`/work/clusters/<имя>/kubeconfig`**; при сбое попробуйте kubectl **с хоста** с **`clusters/<имя>/kubeconfig.host`**. Перезапуск веб-сервиса: **`make docker restart`**. Подробности — **README.md**.
--- ---
@@ -47,6 +48,8 @@
| GET | `/api/v1/clusters/{name}/kubeconfig` | Скачать файл kubeconfig | | GET | `/api/v1/clusters/{name}/kubeconfig` | Скачать файл kubeconfig |
| GET | `/api/v1/clusters/{name}/provision-log` | Полный журнал последнего **create_cluster** / **start_cluster** (JSON с диска) | | GET | `/api/v1/clusters/{name}/provision-log` | Полный журнал последнего **create_cluster** / **start_cluster** (JSON с диска) |
| GET | `/api/v1/clusters/{name}/workloads` | Узлы и поды (`kubectl`) | | GET | `/api/v1/clusters/{name}/workloads` | Узлы и поды (`kubectl`) |
| GET | `/api/v1/clusters/{name}/overview` | Сводка для страницы UI: метрики узлов, агрегаты, блоки **`k8s_*`** (JSON из `kubectl get … -o json`) |
| POST | `/api/v1/clusters/{name}/pods/restart` | Удалить под (`kubectl delete pod`) для мягкого рестарта |
| DELETE | `/api/v1/clusters/{name}` | Удалить кластер и данные в `clusters/` | | DELETE | `/api/v1/clusters/{name}` | Удалить кластер и данные в `clusters/` |
| GET | `/api/v1/jobs` | Последние задания (`progress_log` в ответе пустой — полный журнал только в GET по `job_id`) | | GET | `/api/v1/jobs` | Последние задания (`progress_log` в ответе пустой — полный журнал только в GET по `job_id`) |
| GET | `/api/v1/jobs/{job_id}` | Статус задания + полный хвост `progress_log` (лимит см. `KIND_K8S_JOB_API_LOG_MAX_LINES`) | | GET | `/api/v1/jobs/{job_id}` | Статус задания + полный хвост `progress_log` (лимит см. `KIND_K8S_JOB_API_LOG_MAX_LINES`) |
@@ -218,9 +221,9 @@ Accept: text/markdown
"memory_used_ratio_ring": 5.8, "memory_used_ratio_ring": 5.8,
"memory_used_ratio_label": "ср. 5.8%", "memory_used_ratio_label": "ср. 5.8%",
"network_ring": 12.5, "network_ring": 12.5,
"network_label": "Σ 1.71 MiB", "network_label": "всего 1.71 MiB",
"disk_ring": 45.0, "disk_ring": 45.0,
"disk_label": "Σ 2.5 GiB" "disk_label": "всего 2.5 GiB"
} }
} }
``` ```
@@ -327,9 +330,11 @@ Accept: text/markdown
## GET /api/v1/clusters/{name}/kubeconfig ## GET /api/v1/clusters/{name}/kubeconfig
Скачать файл `kubeconfig` (ответ — тело файла, `Content-Disposition` с именем `kubeconfig-{name}.yaml`). Скачать kubeconfig для **kubectl на машине пользователя** (ответ — тело файла, `Content-Disposition`: `kubeconfig-{name}.yaml`).
**Ошибка 404:** файла нет в `clusters/{name}/`. При каждом запросе копируется **`clusters/{name}/kubeconfig`** и патчится `server=https://<KIND_K8S_KUBECONFIG_CLIENT_HOST или localhost>:<порт>` (порт из `docker port … 6443/tcp`). Если патч не удалился — отдаётся сохранённый **`kubeconfig.host`** или сырой **`kubeconfig`**.
**Ошибка 404:** нет ни `kubeconfig.host`, ни `kubeconfig` в `clusters/{name}/`.
**Ошибка 400:** некорректное имя кластера. **Ошибка 400:** некорректное имя кластера.
@@ -391,6 +396,115 @@ Accept: text/markdown
--- ---
## GET /api/v1/clusters/{name}/overview
Единый ответ для **страницы кластера** (`GET /cluster/{name}`): флаги и `meta` как в списке кластеров, **метрики узлов** (docker/podman), **`aggregate_resources`** (как в `GET /stats` для донатов), данные Kubernetes в виде **`kubectl get … -o json`** (разбор `items` на фронтенде в таблицы).
- При отсутствии kubeconfig: **`kubeconfig_error`** — строка-пояснение; блоки **`k8s_*`** недоступны (см. ниже).
- Поля **`nodes_rc` / `nodes_output`**, **`pods_rc` / `pods_output`**, … — устаревший текстовый вывод; в текущей версии API могут быть **`null`** (UI использует только JSON-блоки).
- Блоки **`k8s_nodes`**, **`k8s_namespaces`**, **`k8s_pods`**, **`k8s_deployments`**, **`k8s_statefulsets`**, **`k8s_daemonsets`**, **`k8s_replicasets`**, **`k8s_jobs`**, **`k8s_cronjobs`**, **`k8s_services`**, **`k8s_ingresses`** (ресурс **`ingresses.networking.k8s.io -A`**), **`k8s_pvcs`** — объекты вида **`K8sListJsonBlock`**; для ресурсов в namespace везде **`kubectl get … -A`** (все пространства имён).
- **`ok`**: `true` при успешном `kubectl`;
- **`rc`**: код возврата;
- **`items`**: массив объектов из `kubectl` (как в API Kubernetes);
- **`message`**: текст ошибки при `ok: false`.
- **`resources_error`** — ошибка сбора метрик контейнерного CLI (как `cluster_resources_error` в `/stats`).
- **`cluster_resources`** — один блок `KindClusterResources` (узлы с полями `cpu_percent`, `memory_usage`, …).
**Пример ответа 200 (фрагмент):**
```json
{
"cluster_name": "dev",
"registered_in_kind": true,
"kind_nodes_running": true,
"has_local_kubeconfig": true,
"has_provision_log": true,
"meta": { "worker_nodes": 2, "kubernetes_version_tag": "v1.29.4" },
"resources_error": null,
"cluster_resources": {
"cluster_name": "dev",
"nodes": [
{
"container_name": "dev-control-plane",
"cpu_percent": "2.10%",
"memory_usage": "800MiB / 7.7GiB",
"memory_percent": "10.14%",
"net_io": "1.2MB / 800kB",
"block_io": "0B / 0B",
"pids": 120
}
],
"note": null
},
"aggregate_resources": {
"nodes_count": 3,
"cpu_ring": 5.2,
"cpu_label": "5.2%",
"memory_percent_ring": 12.0,
"memory_percent_label": "12%",
"memory_used_ratio_ring": 45.0,
"memory_used_ratio_label": "2.1 / 4.6 GiB",
"network_ring": 8.0,
"network_label": "↓ 10 MB",
"disk_ring": 3.0,
"disk_label": "R/W 2 MB"
},
"kubeconfig_error": null,
"k8s_nodes": {
"ok": true,
"rc": 0,
"items": [{ "metadata": { "name": "dev-control-plane" }, "status": { "conditions": [] } }],
"message": null
},
"k8s_pods": { "ok": true, "rc": 0, "items": [], "message": null },
"k8s_deployments": { "ok": true, "rc": 0, "items": [], "message": null },
"k8s_statefulsets": { "ok": true, "rc": 0, "items": [], "message": null },
"k8s_daemonsets": { "ok": true, "rc": 0, "items": [], "message": null },
"k8s_services": { "ok": true, "rc": 0, "items": [], "message": null },
"k8s_ingresses": { "ok": true, "rc": 0, "items": [], "message": null },
"k8s_namespaces": { "ok": true, "rc": 0, "items": [], "message": null },
"k8s_replicasets": { "ok": true, "rc": 0, "items": [], "message": null },
"k8s_jobs": { "ok": true, "rc": 0, "items": [], "message": null },
"k8s_cronjobs": { "ok": true, "rc": 0, "items": [], "message": null },
"k8s_pvcs": { "ok": true, "rc": 0, "items": [], "message": null }
}
```
**Ошибка 400:** некорректное имя кластера.
---
## POST /api/v1/clusters/{name}/pods/restart
Мягкий **рестарт пода**: выполняется **`kubectl delete pod`** в указанном namespace (под пересоздаётся, если им управляет Deployment/ReplicaSet и т.д.).
**Тело запроса (JSON):**
```json
{
"namespace": "default",
"pod": "my-app-7d4f8b9-xk2cp"
}
```
**Пример ответа 200:**
```json
{
"ok": true,
"cluster_name": "dev",
"namespace": "default",
"pod": "my-app-7d4f8b9-xk2cp",
"message": null
}
```
**Ошибка 400:** неверное имя кластера, namespace или pod (валидация как в Kubernetes).
**Ошибка 502:** `kubectl delete` завершился с ненулевым кодом (текст stderr в теле ответа от сервера).
---
## GET /api/v1/clusters/{name} ## GET /api/v1/clusters/{name}
Детали и попытка `kubectl get nodes -o wide` с **сохранённого** `clusters/{name}/kubeconfig` (если файл есть). Детали и попытка `kubectl get nodes -o wide` с **сохранённого** `clusters/{name}/kubeconfig` (если файл есть).
+190 -6
View File
@@ -1,8 +1,22 @@
"""Правка kubeconfig kind для доступа к API с хоста (после create из контейнера). """Правка kubeconfig kind для доступа к API с хоста (после create из контейнера).
Kind внутри Docker видит другой адрес apiserver; на хосте нужен 127.0.0.1:<порт> Kind внутри Docker видит другой адрес apiserver; для **kubectl на машине пользователя**
из проброса `docker port <cluster>-control-plane 6443/tcp` (Podman тот же клиент в ``kubeconfig.host`` подставляется ``https://<клиентский_хост>:<порт>`` (порт из
`docker` к docker-совместимому сокету). ``docker port <cluster>-control-plane 6443/tcp``). Хост по умолчанию **localhost**
(часто надёжнее ``127.0.0.1`` с TLS); переопределение: ``KIND_K8S_KUBECONFIG_CLIENT_HOST``
(например IP хоста в LAN).
**Два файла в ``clusters/<имя>/``:**
- ``kubeconfig`` как выдал ``kind get kubeconfig``; для ``kubectl`` **в процессе веб-приложения**
:func:`kubeconfig_path_for_container_kubectl` задаёт ``server=https://host.docker.internal:<порт>``
и ``tls-server-name=localhost`` (иначе x509 не совпадает с SAN kind); см. ``KIND_K8S_KUBECONFIG_TLS_SERVER_NAME``.
Запасной URL: ``https://<имя>-control-plane:6443`` (без доп. SNI).
- ``kubeconfig.host`` копия с ``server=`` для kubectl на хосте (см.
:func:`kubeconfig_download_client_hostname`).
Если патч отключён или не удалился, ``kubeconfig.host`` может отсутствовать тогда
отдаётся основной ``kubeconfig``.
Автор: Сергей Антропов Автор: Сергей Антропов
Сайт: https://devops.org.ru Сайт: https://devops.org.ru
@@ -12,11 +26,17 @@ from __future__ import annotations
import logging import logging
import os import os
import shutil
import subprocess import subprocess
import tempfile
import threading
from pathlib import Path from pathlib import Path
logger = logging.getLogger("kind_k8s.kubeconfig_patch") logger = logging.getLogger("kind_k8s.kubeconfig_patch")
# Имя файла kubeconfig с server для доступа с хоста (не использовать для kubectl в контейнере веб-приложения).
KUBECONFIG_HOST_BASENAME = "kubeconfig.host"
def _container_cli() -> str: def _container_cli() -> str:
"""CLI для `port` (обычно docker к сокету Docker или Podman).""" """CLI для `port` (обычно docker к сокету Docker или Podman)."""
@@ -57,8 +77,19 @@ def _host_bind_for_kubeconfig(host: str) -> str:
return host return host
def kubeconfig_download_client_hostname() -> str:
"""
Имя хоста для поля ``server`` в kubeconfig, скачиваемом пользователем.
По умолчанию ``localhost`` (совпадает с SAN в сертификате kind чаще, чем проблемный 127.0.0.1).
Задаётся ``KIND_K8S_KUBECONFIG_CLIENT_HOST`` (например ``192.168.1.10`` для удалённого клиента).
"""
raw = (os.environ.get("KIND_K8S_KUBECONFIG_CLIENT_HOST") or "localhost").strip()
return raw or "localhost"
def get_apiserver_host_port(cluster_name: str) -> tuple[str, str] | None: def get_apiserver_host_port(cluster_name: str) -> tuple[str, str] | None:
"""Узнать (host, port) с хоста для доступа к apiserver.""" """Узнать (host, port) привязки проброса 6443/tcp (host из вывода docker port, порт — число)."""
cli = _container_cli() cli = _container_cli()
ctr = _control_plane_container_name(cluster_name) ctr = _control_plane_container_name(cluster_name)
p = subprocess.run( p = subprocess.run(
@@ -126,8 +157,9 @@ def patch_kubeconfig_server_for_host(
"kubeconfig оставлен как выдал kind (с хоста может не открываться).", "kubeconfig оставлен как выдал kind (с хоста может не открываться).",
) )
return False return False
host, port = hp _bind_host, port = hp
server = f"https://{host}:{port}" client_host = kubeconfig_download_client_hostname()
server = f"https://{client_host}:{port}"
cluster_id = _kubeconfig_cluster_name(kube_path, cluster_name) cluster_id = _kubeconfig_cluster_name(kube_path, cluster_name)
p = subprocess.run( p = subprocess.run(
[ [
@@ -155,3 +187,155 @@ def should_patch_after_create() -> bool:
if os.environ.get("KIND_K8S_PATCH_KUBECONFIG", "").strip().lower() in ("1", "true", "yes", "да"): if os.environ.get("KIND_K8S_PATCH_KUBECONFIG", "").strip().lower() in ("1", "true", "yes", "да"):
return True return True
return os.environ.get("KIND_K8S_IN_CONTAINER", "").strip() == "1" return os.environ.get("KIND_K8S_IN_CONTAINER", "").strip() == "1"
def in_kind_k8s_container() -> bool:
"""Процесс веб-приложения в Docker/Podman (``KIND_K8S_IN_CONTAINER=1``)."""
return os.environ.get("KIND_K8S_IN_CONTAINER", "").strip() == "1"
def kubeconfig_host_file(cluster_data_dir: Path) -> Path:
"""Путь к kubeconfig для скачивания на хост: ``clusters/<имя>/kubeconfig.host``."""
return cluster_data_dir / KUBECONFIG_HOST_BASENAME
def apiserver_url_docker_bridge(cluster_name: str) -> str:
"""
URL apiserver по имени узла kind в общей Docker-сети (если она есть).
Стандартное имя контрольной плоскости: ``<cluster>-control-plane``.
"""
return f"https://{_control_plane_container_name(cluster_name)}:6443"
def _web_container_apiserver_endpoint(cluster_name: str) -> tuple[str, str | None]:
"""
Пара ``(server URL, tls-server-name)`` для kubeconfig процесса в контейнере веб-UI.
Через шлюз (``host.docker.internal:<порт>``) в сертификате kind нет этого имени;
задаём ``tls-server-name`` (по умолчанию ``localhost``), которое есть в SAN.
Без проброса порта прямой ``https://<имя>-control-plane:6443``, ``tls-server-name`` не нужен.
"""
hp = get_apiserver_host_port(cluster_name)
if hp:
_bind_host, port = hp
gw = (os.environ.get("KIND_K8S_APISERVER_GATEWAY_HOST") or "host.docker.internal").strip()
if not gw:
gw = "host.docker.internal"
server = f"https://{gw}:{port}"
tls_name = (os.environ.get("KIND_K8S_KUBECONFIG_TLS_SERVER_NAME") or "localhost").strip() or "localhost"
return server, tls_name
return apiserver_url_docker_bridge(cluster_name), None
def apiserver_url_for_web_container_process(cluster_name: str) -> str:
"""Только URL apiserver для ``kubectl`` в контейнере веб-приложения (см. :func:`_web_container_apiserver_endpoint`)."""
url, _tls = _web_container_apiserver_endpoint(cluster_name)
return url
# Кэш временных kubeconfig для kubectl из контейнера: ключ → (mtime_ns исходника, путь).
# Версия в ключе — сброс кэша при смене логики (например tls-server-name).
_RUNTIME_KUBECONFIG_CACHE_VER = 2
_kubectl_runtime_cache: dict[str, tuple[int, Path]] = {}
_kubectl_runtime_lock = threading.Lock()
def kubeconfig_path_for_container_kubectl(*, cluster_name: str, kube_source_path: Path) -> Path:
"""
Путь к kubeconfig для вызова ``kubectl`` из процесса в контейнере.
Если ``KIND_K8S_IN_CONTAINER=1``, копия во временный файл: ``server`` через шлюз хоста
и при необходимости ``tls-server-name`` (SAN kind: ``localhost``, ).
Иначе возвращает ``kube_source_path`` без изменений.
Поддерживает старые установки с одним «хостовым» kubeconfig на диске.
"""
if not in_kind_k8s_container():
return kube_source_path
try:
src = kube_source_path.resolve()
mtime_ns = int(src.stat().st_mtime_ns)
except OSError as e:
logger.debug("kubeconfig runtime: нет исходника %s: %s", kube_source_path, e)
return kube_source_path
key = f"{cluster_name}\x00{src}\x00{_RUNTIME_KUBECONFIG_CACHE_VER}"
with _kubectl_runtime_lock:
hit = _kubectl_runtime_cache.get(key)
if hit is not None and hit[0] == mtime_ns:
return hit[1]
try:
fd, temp_name = tempfile.mkstemp(suffix=".kubeconfig", prefix="kind-k8s-kubectl-")
os.close(fd)
tmp_path = Path(temp_name)
shutil.copyfile(src, tmp_path, follow_symlinks=True)
except OSError as e:
logger.warning("kubeconfig runtime: не удалось скопировать %s: %s", src, e)
return kube_source_path
cluster_id = _kubeconfig_cluster_name(tmp_path, cluster_name)
server, tls_server_name = _web_container_apiserver_endpoint(cluster_name)
p = subprocess.run(
[
"kubectl",
"--kubeconfig",
str(tmp_path),
"config",
"set-cluster",
cluster_id,
f"--server={server}",
],
capture_output=True,
text=True,
)
if p.returncode != 0:
err = (p.stderr or p.stdout or "").strip()
logger.warning(
"kubeconfig runtime: kubectl set-cluster не удался для %s: %s",
cluster_name,
err[:500],
)
tmp_path.unlink(missing_ok=True)
return kube_source_path
if tls_server_name:
p_tls = subprocess.run(
[
"kubectl",
"--kubeconfig",
str(tmp_path),
"config",
"set-cluster",
cluster_id,
f"--tls-server-name={tls_server_name}",
],
capture_output=True,
text=True,
)
if p_tls.returncode != 0:
err_tls = (p_tls.stderr or p_tls.stdout or "").strip()
logger.warning(
"kubeconfig runtime: tls-server-name для %s не задан: %s",
cluster_name,
err_tls[:400],
)
prev = _kubectl_runtime_cache.pop(key, None)
if prev is not None:
try:
prev[1].unlink(missing_ok=True)
except OSError:
pass
_kubectl_runtime_cache[key] = (mtime_ns, tmp_path)
logger.debug(
"kubeconfig runtime: кластер «%s» server=%s tls-sni=%s (cluster=%s)",
cluster_name,
server,
tls_server_name or "",
cluster_id,
)
return tmp_path
+23
View File
@@ -22,6 +22,7 @@ from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from api.v1.router import api_router from api.v1.router import api_router
from core.cluster_lifecycle import validate_cluster_name
from core.config import get_settings from core.config import get_settings
_BASE = Path(__file__).resolve().parent _BASE = Path(__file__).resolve().parent
@@ -75,6 +76,28 @@ async def dashboard(request: Request) -> HTMLResponse:
) )
@app.get("/cluster/{cluster_name}", response_class=HTMLResponse, summary="Страница кластера")
async def cluster_detail_page(request: Request, cluster_name: str) -> HTMLResponse:
"""Ресурсы узлов, сводка, kubectl (поды, Deployments и др.), действия как в таблице на главной."""
if not _templates_dir.is_dir():
return HTMLResponse(
content="<p>Шаблоны не найдены. Ожидается каталог app/templates/</p>",
status_code=500,
)
n = cluster_name.strip()
if not validate_cluster_name(n):
raise HTTPException(status_code=404, detail="Некорректное имя кластера")
return templates.TemplateResponse(
request,
"cluster_detail.html",
{
"app_title": settings.app_title,
"nav_active": "panel",
"cluster_name": n,
},
)
@app.get("/cluster-create", response_class=HTMLResponse, summary="Создание кластера и задания") @app.get("/cluster-create", response_class=HTMLResponse, summary="Создание кластера и задания")
async def cluster_create_page(request: Request) -> HTMLResponse: async def cluster_create_page(request: Request) -> HTMLResponse:
"""Форма создания кластера, прогресс и список последних заданий.""" """Форма создания кластера, прогресс и список последних заданий."""
+84
View File
@@ -140,3 +140,87 @@ class ClusterWorkloadsResponse(BaseModel):
pods_rc: int | None = None pods_rc: int | None = None
pods_output: str | None = None pods_output: str | None = None
error: str | None = None error: str | None = None
class K8sListJsonBlock(BaseModel):
"""Фрагмент ответа ``kubectl get … -o json`` (items) для таблицы в UI."""
ok: bool = True
rc: int | None = Field(default=None, description="Код выхода kubectl")
items: list[dict[str, Any]] = Field(default_factory=list)
message: str | None = Field(default=None, description="Ошибка kubectl или разбора 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):
"""Сводка для страницы кластера: мета, ресурсы узлов, kubectl (поды, workloads)."""
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 или ошибка kubectl")
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="kubectl get nodes -o json")
k8s_pods: K8sListJsonBlock = Field(default_factory=K8sListJsonBlock, description="kubectl get pods -A -o json")
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="kubectl get namespaces -o json",
)
k8s_replicasets: K8sListJsonBlock = Field(
default_factory=K8sListJsonBlock,
description="kubectl get replicasets -A -o json",
)
k8s_jobs: K8sListJsonBlock = Field(
default_factory=K8sListJsonBlock,
description="kubectl get jobs -A -o json",
)
k8s_cronjobs: K8sListJsonBlock = Field(
default_factory=K8sListJsonBlock,
description="kubectl get cronjobs -A -o json",
)
k8s_pvcs: K8sListJsonBlock = Field(
default_factory=K8sListJsonBlock,
description="kubectl get pvc -A -o json",
)
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env python3
"""Печать пути kubeconfig с подстановкой apiserver для kubectl из контейнера веб-UI.
Совпадает с логикой ``kubeconfig_path_for_container_kubectl`` (шлюз хоста + порт).
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
from __future__ import annotations
import sys
from pathlib import Path
# Запуск из каталога app/ (рабочий каталог контейнера).
from kind_k8s_paths import clusters_dir
from kubeconfig_patch import kubeconfig_path_for_container_kubectl
def main() -> None:
if len(sys.argv) != 2:
print("Использование: effective_kubeconfig_path.py <имя_кластера>", file=sys.stderr)
raise SystemExit(2)
name = sys.argv[1].strip()
p = clusters_dir() / name / "kubeconfig"
if not p.is_file():
print(f"Нет файла {p}", file=sys.stderr)
raise SystemExit(1)
resolved = kubeconfig_path_for_container_kubectl(cluster_name=name, kube_source_path=p)
print(resolved, end="")
if __name__ == "__main__":
main()
+885 -134
View File
File diff suppressed because it is too large Load Diff
+580 -27
View File
@@ -619,12 +619,19 @@ a.btn-cta-large:hover {
} }
.donut-grid { .donut-grid {
display: grid; display: flex;
grid-template-columns: repeat(auto-fill, minmax(9.5rem, 1fr)); flex-direction: row;
gap: 1rem 1.25rem; flex-wrap: nowrap;
align-items: start; justify-content: space-evenly;
align-items: flex-start;
gap: 1rem 1.5rem;
overflow-x: auto;
padding-bottom: 0.35rem;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
} }
.donut-item { .donut-item {
flex: 0 0 auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@@ -671,34 +678,94 @@ a.btn-cta-large:hover {
max-width: 40rem; max-width: 40rem;
} }
/* Вторая строка таблицы: раскрытие ресурсов */
.cluster-resources-expand-row td {
border-top: none;
padding-top: 0;
}
.cluster-resources-expand-cell {
padding: 0 0.75rem 0.65rem;
background: rgba(0, 0, 0, 0.06);
}
html[data-theme="light"] .cluster-resources-expand-cell {
background: rgba(59, 130, 246, 0.06);
}
.cluster-nodes-resources {
margin: 0;
}
.cluster-nodes-resources summary {
cursor: pointer;
font-weight: 600;
font-size: 0.88rem;
padding: 0.35rem 0;
color: var(--accent);
}
.cluster-nodes-resources__empty { .cluster-nodes-resources__empty {
margin: 0.25rem 0 0; margin: 0.25rem 0 0;
font-size: 0.85rem; font-size: 0.85rem;
} }
.cluster-row-node-res {
/* Карточки узлов: полосы CPU, RAM %, память от лимита, сеть, диск + подсказка справа */
.cluster-nodes-resources__cards-row {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: stretch;
gap: 0.75rem;
margin-top: 0.35rem;
overflow-x: auto;
padding-bottom: 0.25rem;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
}
.cluster-node-res-card {
flex: 0 0 auto;
width: min(17.5rem, 88vw);
min-width: 14rem;
box-sizing: border-box;
padding: 0.55rem 0.65rem 0.5rem;
border-radius: 10px;
border: 1px solid var(--border);
background: rgba(0, 0, 0, 0.12);
border-left: 3px solid var(--accent);
}
html[data-theme="light"] .cluster-node-res-card {
background: rgba(59, 130, 246, 0.08);
}
.cluster-node-res-card__head {
margin: 0 0 0.45rem;
}
.cluster-node-res-card__head code {
font-size: 0.78rem;
word-break: break-all;
line-height: 1.35;
}
.cluster-node-res-card__bars {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.cluster-node-res-card__bar-row {
display: grid;
grid-template-columns: 3rem minmax(0, 1fr) minmax(2.8rem, 4.5rem);
align-items: center;
gap: 0.3rem 0.35rem;
}
.cluster-node-res-card__bar-hint {
font-size: 0.62rem;
line-height: 1.2;
text-align: right;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.cluster-node-res-card__bar-label {
font-size: 0.68rem;
font-weight: 650;
text-transform: uppercase;
letter-spacing: 0.02em;
color: var(--muted);
}
.cluster-node-res-card__bar-track {
height: 0.45rem;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
overflow: hidden;
}
html[data-theme="light"] .cluster-node-res-card__bar-track {
background: rgba(0, 0, 0, 0.06);
}
.cluster-node-res-card__bar-fill {
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, rgba(59, 130, 246, 0.55), var(--accent));
min-width: 2px;
transition: width 0.25s ease;
}
.cluster-node-res-card__foot {
margin-top: 0.45rem; margin-top: 0.45rem;
font-size: 0.72rem;
line-height: 1.4;
word-break: break-word;
} }
.cluster-create-hero { .cluster-create-hero {
@@ -961,6 +1028,12 @@ html[data-theme="light"] .badge {
border-color: #15803d; border-color: #15803d;
color: #4ade80; color: #4ade80;
} }
/* Светлая тема: насыщенный зелёный текст и фон — иначе «Запущен» сливается с белым карточным фоном */
html[data-theme="light"] .badge-ok {
border-color: #166534;
color: #14532d;
background: rgba(22, 101, 52, 0.12);
}
.badge-err { .badge-err {
border-color: #b91c1c; border-color: #b91c1c;
color: #f87171; color: #f87171;
@@ -1675,3 +1748,483 @@ html[data-theme="light"] .readme-section-card.markdown-body pre {
html[data-theme="light"] .readme-section-card.markdown-body th { html[data-theme="light"] .readme-section-card.markdown-body th {
background: rgba(59, 130, 246, 0.14); background: rgba(59, 130, 246, 0.14);
} }
/* Страница кластера: /cluster/<имя> — сводка, kubectl, действия */
.cluster-detail-breadcrumb {
margin: 0 0 1rem;
font-size: 0.9rem;
}
.cluster-detail-breadcrumb a {
color: var(--accent);
text-decoration: none;
}
.cluster-detail-breadcrumb a:hover {
text-decoration: underline;
}
/* Ссылка на страницу кластера в таблице: контраст в светлой и тёмной теме */
.cluster-name-link {
text-decoration: none;
font-weight: 600;
border-bottom: 1px solid transparent;
}
html[data-theme="light"] .cluster-name-link {
color: #0369a1;
}
html[data-theme="light"] .cluster-name-link:hover {
color: #0c4a6e;
border-bottom-color: #0369a1;
}
html[data-theme="light"] .cluster-name-link .cluster-name {
color: inherit;
}
html[data-theme="dark"] .cluster-name-link {
color: #7dd3fc;
}
html[data-theme="dark"] .cluster-name-link:hover {
color: #e0f2fe;
border-bottom-color: #38bdf8;
}
html[data-theme="dark"] .cluster-name-link .cluster-name {
color: inherit;
}
/* Страница кластера: отступы между карточками */
body.cluster-detail-page .app-main > .card {
margin-bottom: 1.35rem;
}
body.cluster-detail-page .app-main > .card:last-child {
margin-bottom: 0;
}
/* Шапка страницы кластера: слева заголовок и кнопки, справа мета столбиком */
.cluster-detail-hero-layout {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 1rem 1.75rem;
}
.cluster-detail-hero-left {
flex: 1 1 14rem;
min-width: 0;
}
.cluster-detail-hero-meta {
flex: 0 0 auto;
align-self: flex-start;
}
.cluster-detail-hero-card .page-title {
margin-bottom: 0.5rem;
}
.cluster-detail-meta {
min-width: 14rem;
max-width: 28rem;
}
/* Компактная карточка сводки (kind, kubeconfig, состояние, версия, workers) */
.cluster-detail-meta-card {
border-radius: 12px;
border: 1px solid var(--border);
background: linear-gradient(
155deg,
rgba(59, 130, 246, 0.09) 0%,
rgba(15, 23, 42, 0.35) 48%,
rgba(15, 23, 42, 0.2) 100%
);
padding: 0.65rem 0.75rem 0.7rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.12);
}
html[data-theme="light"] .cluster-detail-meta-card {
background: linear-gradient(
155deg,
rgba(59, 130, 246, 0.12) 0%,
rgba(255, 255, 255, 0.95) 55%,
rgba(241, 245, 249, 0.9) 100%
);
box-shadow: 0 4px 18px rgba(15, 23, 42, 0.08);
}
.cluster-detail-meta-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.5rem 0.55rem;
}
@media (max-width: 720px) {
.cluster-detail-meta-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 400px) {
.cluster-detail-meta-grid {
grid-template-columns: 1fr;
}
}
.cluster-meta-tile {
display: flex;
flex-direction: column;
gap: 0.2rem;
padding: 0.35rem 0.4rem;
border-radius: 8px;
background: rgba(0, 0, 0, 0.14);
border: 1px solid rgba(255, 255, 255, 0.06);
min-width: 0;
}
html[data-theme="light"] .cluster-meta-tile {
background: rgba(255, 255, 255, 0.65);
border-color: rgba(59, 130, 246, 0.15);
}
.cluster-meta-tile__label {
font-size: 0.65rem;
font-weight: 650;
text-transform: uppercase;
letter-spacing: 0.06em;
line-height: 1.2;
}
.cluster-meta-tile__value {
font-size: 0.88rem;
font-weight: 600;
line-height: 1.25;
min-height: 1.25rem;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.2rem;
}
.cluster-meta-tile--wide {
grid-column: 1 / -1;
}
.cluster-meta-badge {
font-size: 0.78rem !important;
padding: 0.12rem 0.45rem !important;
}
.cluster-meta-badge--muted {
background: transparent !important;
border: 1px dashed var(--border) !important;
color: var(--muted) !important;
font-weight: 500 !important;
}
.cluster-meta-version-tag {
font-size: 0.78rem;
font-weight: 600;
padding: 0.1rem 0.35rem;
border-radius: 6px;
background: rgba(0, 0, 0, 0.2);
border: 1px solid var(--border);
}
html[data-theme="light"] .cluster-meta-version-tag {
background: rgba(59, 130, 246, 0.08);
}
.cluster-meta-workers {
font-variant-numeric: tabular-nums;
font-size: 0.95rem;
}
/* Обёртка кнопок действий под заголовком кластера */
.cluster-detail-actions-card {
display: inline-flex;
align-items: center;
padding: 0.4rem 0.45rem;
margin-bottom: 0.35rem;
border-radius: 10px;
border: 1px solid var(--border);
background: rgba(0, 0, 0, 0.1);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
html[data-theme="light"] .cluster-detail-actions-card {
background: rgba(15, 23, 42, 0.03);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8);
}
.cluster-detail-actions-card .cluster-detail-toolbar {
margin: 0;
}
.cluster-k8s-scope-hint {
margin: 0 0 0.85rem;
font-size: 0.86rem;
line-height: 1.45;
max-width: 48rem;
}
.cluster-k8s-scope-hint code {
font-size: 0.8em;
}
.cluster-detail-meta__column {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.35rem 0;
text-align: right;
}
.cluster-detail-meta__line {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.35rem 0.65rem;
align-items: baseline;
font-size: 0.88rem;
width: 100%;
max-width: 22rem;
}
.cluster-detail-meta__label {
justify-self: end;
white-space: nowrap;
}
.cluster-detail-meta__value {
justify-self: start;
text-align: left;
min-width: 0;
}
.cluster-detail-hero-main {
flex: 1 1 12rem;
min-width: 0;
}
.cluster-detail-hero-actions {
flex: 0 0 auto;
align-self: flex-end;
}
.cluster-detail-hero-actions .actions-toolbar {
justify-content: flex-end;
flex-wrap: wrap;
gap: 0.35rem;
max-width: 22rem;
}
@media (max-width: 640px) {
.cluster-detail-hero-layout {
flex-direction: column;
align-items: stretch;
}
.cluster-detail-hero-meta {
align-self: stretch;
}
.cluster-detail-meta {
max-width: none;
}
.cluster-detail-meta__column {
align-items: flex-start;
text-align: left;
}
.cluster-detail-meta__line {
grid-template-columns: 6.5rem 1fr;
max-width: none;
}
.cluster-detail-meta__label {
justify-self: start;
}
.cluster-detail-meta__value {
justify-self: start;
}
.cluster-detail-hero-actions {
align-self: stretch;
}
.cluster-detail-hero-actions .actions-toolbar {
justify-content: flex-start;
max-width: none;
}
}
.cluster-detail-section-title {
font-size: 1.05rem;
margin: 0 0 0.65rem;
}
.cluster-detail-meta__row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1.25rem;
margin-top: 0.5rem;
}
.cluster-detail-meta__item {
font-size: 0.88rem;
}
.cluster-detail-toolbar {
margin: 0;
}
.cluster-kube-hint {
margin: 0;
padding: 0.5rem 0.65rem;
border-radius: 8px;
border: 1px solid var(--border);
background: rgba(234, 179, 8, 0.12);
}
/* Вводная карточка kubectl + отдельная карточка на каждый ресурс (как в шаблоне cluster_detail) */
.cluster-k8s-intro-card .cluster-k8s-scope-hint {
margin: 0 0 0.75rem;
}
.cluster-k8s-resource-card .cluster-detail-section-title {
margin: 0 0 0.2rem;
font-size: 1.02rem;
}
.cluster-k8s-cmd {
margin: 0 0 0.6rem;
font-size: 0.8rem;
line-height: 1.4;
}
.cluster-k8s-cmd code {
font-size: 0.78em;
word-break: break-all;
}
/* Сетка карточек узлов на странице кластера — на всю ширину секции */
.cluster-page-node-cards--wide.cluster-nodes-resources__cards-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
flex-wrap: wrap;
overflow-x: visible;
width: 100%;
}
.cluster-page-node-cards--wide .cluster-node-res-card {
width: auto;
min-width: 0;
flex: none;
}
/* Таблицы Kubernetes (kubectl -o json) */
.cluster-k8s-root {
min-height: 1.5rem;
}
.cluster-k8s-msg {
margin: 0;
font-size: 0.85rem;
line-height: 1.45;
}
.cluster-k8s-table-scroll {
width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
border-radius: 8px;
border: 1px solid var(--border);
background: rgba(0, 0, 0, 0.12);
}
html[data-theme="light"] .cluster-k8s-table-scroll {
background: rgba(15, 23, 42, 0.04);
}
.cluster-k8s-table {
width: 100%;
min-width: 36rem;
border-collapse: collapse;
font-size: 0.8rem;
}
.cluster-k8s-table th,
.cluster-k8s-table td {
padding: 0.4rem 0.55rem;
text-align: left;
border-bottom: 1px solid var(--border);
vertical-align: middle;
}
.cluster-k8s-table th {
font-weight: 650;
color: var(--muted);
white-space: nowrap;
background: rgba(0, 0, 0, 0.18);
}
html[data-theme="light"] .cluster-k8s-table th {
background: rgba(59, 130, 246, 0.1);
}
.cluster-k8s-table tbody tr:last-child td {
border-bottom: none;
}
.cluster-k8s-table td:last-child {
width: 1%;
white-space: nowrap;
}
.cluster-k8s-table .icon-tooltip-host {
display: inline-flex;
vertical-align: middle;
}
.btn-k8s-restart {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.25rem 0.5rem;
font-size: 0.78rem;
}
.btn-k8s-restart svg {
width: 1rem;
height: 1rem;
flex-shrink: 0;
}
/* Рестарт пода: только иконка; подсказка — #action-tooltip (.icon-tooltip-host) */
.btn-k8s-restart--solo {
width: 2rem;
height: 2rem;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 8px;
}
.btn-k8s-restart--solo svg {
width: 1.05rem;
height: 1.05rem;
}
.cluster-kube-pre {
max-height: 16rem;
overflow: auto;
margin: 0;
padding: 0.5rem 0.65rem;
font-size: 0.78rem;
line-height: 1.35;
border-radius: 8px;
border: 1px solid var(--border);
background: rgba(0, 0, 0, 0.2);
}
html[data-theme="light"] .cluster-kube-pre {
background: rgba(15, 23, 42, 0.04);
}
/* Полноэкранный спиннер первой загрузки главной панели */
body.dashboard-home-loading {
overflow: hidden;
}
/* Полноэкранный спиннер первой загрузки страницы кластера */
body.cluster-page-loading {
overflow: hidden;
}
.page-loading-overlay {
position: fixed;
inset: 0;
z-index: 9998;
display: flex;
align-items: center;
justify-content: center;
}
.page-loading-overlay.hidden {
display: none;
pointer-events: none;
}
.page-loading-backdrop {
position: absolute;
inset: 0;
background: rgba(15, 23, 42, 0.52);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
html[data-theme="light"] .page-loading-backdrop {
background: rgba(241, 245, 249, 0.72);
}
.page-loading-center {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
padding: 1.35rem 1.75rem;
border-radius: 12px;
border: 1px solid var(--border);
background: var(--card);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.25);
}
html[data-theme="light"] .page-loading-center {
box-shadow: 0 12px 40px rgba(15, 23, 42, 0.12);
}
.page-loading-spinner {
width: 2.5rem;
height: 2.5rem;
border-width: 4px;
}
.page-loading-label {
font-size: 0.92rem;
color: var(--muted);
text-align: center;
max-width: 18rem;
line-height: 1.4;
}
+164
View File
@@ -0,0 +1,164 @@
{# Страница одного кластера: сводка ресурсов, kubectl, действия.
Автор: Сергей Антропов — https://devops.org.ru #}
{% extends "base.html" %}
{% block page_title %}Кластер {{ cluster_name }}{% endblock %}
{% block body_extra_class %} cluster-detail-page cluster-page-loading{% endblock %}
{% block body_attrs %}data-dashboard-mode="cluster" data-cluster-name="{{ cluster_name | e }}"{% endblock %}
{% block footer %}
<div class="footer-inner">
<p class="footer-copyright">
© {{ app_title }} ·
<a href="https://devops.org.ru" target="_blank" rel="noopener">devops.org.ru</a>
</p>
</div>
{% endblock %}
{% block content %}
{# Полноэкранный спиннер до первой загрузки overview; скрывается в dashboard.js #}
<div
id="cluster-page-loading-overlay"
class="page-loading-overlay"
role="status"
aria-live="polite"
aria-busy="true"
aria-label="Загрузка данных кластера"
>
<div class="page-loading-backdrop" aria-hidden="true"></div>
<div class="page-loading-center">
<span class="spinner page-loading-spinner" aria-hidden="true"></span>
<span class="page-loading-label">Загрузка данных кластера…</span>
</div>
</div>
<p class="cluster-detail-breadcrumb muted" aria-label="Навигация">
<a href="/">Панель</a>
<span aria-hidden="true"> / </span>
<span>Кластер <code>{{ cluster_name | e }}</code></span>
</p>
<section class="card cluster-detail-hero-card" aria-labelledby="cluster-detail-title">
<div class="cluster-detail-hero-layout">
<div class="cluster-detail-hero-left">
<h1 class="page-title" id="cluster-detail-title">Кластер <code>{{ cluster_name | e }}</code></h1>
<div class="cluster-detail-actions-card" aria-label="Панель действий">
<div
id="cluster-detail-toolbar"
class="actions-toolbar cluster-detail-toolbar"
role="toolbar"
aria-label="Действия над кластером"
></div>
</div>
<p id="cluster-overview-msg" class="msg muted cluster-overview-msg" role="status" aria-live="polite"></p>
</div>
<aside class="cluster-detail-hero-meta" aria-label="Сводка кластера">
<div id="cluster-detail-meta" class="cluster-detail-meta" aria-live="polite"></div>
</aside>
</div>
</section>
<section id="cluster-aggregate-donuts-section" class="card aggregate-donuts-card" aria-labelledby="cluster-donuts-heading">
<h2 id="cluster-donuts-heading" class="donuts-card-title">Ресурсы узлов (сводка)</h2>
<div id="cluster-page-aggregate-donuts" class="aggregate-donuts" aria-live="polite"></div>
</section>
<section class="card cluster-detail-nodes-card" aria-labelledby="cluster-nodes-heading">
<h2 id="cluster-nodes-heading" class="cluster-detail-section-title">Ресурсы узлов</h2>
<div
id="cluster-page-node-cards"
class="cluster-nodes-resources__cards-row cluster-page-node-cards--wide"
aria-live="polite"
></div>
</section>
<section class="card cluster-k8s-intro-card" aria-labelledby="cluster-k8s-heading">
<h2 id="cluster-k8s-heading" class="cluster-detail-section-title">Kubernetes (kubectl)</h2>
<p class="muted cluster-k8s-scope-hint">
Данные по <strong>всему кластеру</strong>: для ресурсов в namespace используется
<code>kubectl get … -A</code> (все namespace); узлы и список namespace — без фильтра по ns.
Ниже каждый тип ресурса — отдельная карточка с тем же запросом, что выполняет API.
</p>
<p id="cluster-kube-global-hint" class="muted cluster-kube-hint hidden" role="status"></p>
</section>
<section class="card cluster-k8s-resource-card" aria-labelledby="k8s-nodes-title">
<h2 class="cluster-detail-section-title" id="k8s-nodes-title">Узлы</h2>
<p class="muted cluster-k8s-cmd"><code>kubectl get nodes -o json</code></p>
<div id="cluster-k8s-nodes-root" class="cluster-k8s-root" role="region" aria-label="Узлы кластера"></div>
</section>
<section class="card cluster-k8s-resource-card" aria-labelledby="k8s-ns-title">
<h2 class="cluster-detail-section-title" id="k8s-ns-title">Namespaces</h2>
<p class="muted cluster-k8s-cmd"><code>kubectl get namespaces -o json</code></p>
<div id="cluster-k8s-namespaces-root" class="cluster-k8s-root" role="region" aria-label="Namespaces"></div>
</section>
<section class="card cluster-k8s-resource-card" aria-labelledby="k8s-pods-title">
<h2 class="cluster-detail-section-title" id="k8s-pods-title">Поды</h2>
<p class="muted cluster-k8s-cmd"><code>kubectl get pods -A -o json</code></p>
<div id="cluster-k8s-pods-root" class="cluster-k8s-root" role="region" aria-label="Поды"></div>
</section>
<section class="card cluster-k8s-resource-card" aria-labelledby="k8s-deploy-title">
<h2 class="cluster-detail-section-title" id="k8s-deploy-title">Deployments</h2>
<p class="muted cluster-k8s-cmd"><code>kubectl get deployments -A -o json</code></p>
<div id="cluster-k8s-deployments-root" class="cluster-k8s-root" role="region"></div>
</section>
<section class="card cluster-k8s-resource-card" aria-labelledby="k8s-sts-title">
<h2 class="cluster-detail-section-title" id="k8s-sts-title">StatefulSets</h2>
<p class="muted cluster-k8s-cmd"><code>kubectl get statefulsets -A -o json</code></p>
<div id="cluster-k8s-statefulsets-root" class="cluster-k8s-root" role="region"></div>
</section>
<section class="card cluster-k8s-resource-card" aria-labelledby="k8s-ds-title">
<h2 class="cluster-detail-section-title" id="k8s-ds-title">DaemonSets</h2>
<p class="muted cluster-k8s-cmd"><code>kubectl get daemonsets -A -o json</code></p>
<div id="cluster-k8s-daemonsets-root" class="cluster-k8s-root" role="region"></div>
</section>
<section class="card cluster-k8s-resource-card" aria-labelledby="k8s-rs-title">
<h2 class="cluster-detail-section-title" id="k8s-rs-title">ReplicaSets</h2>
<p class="muted cluster-k8s-cmd"><code>kubectl get replicasets -A -o json</code></p>
<div id="cluster-k8s-replicasets-root" class="cluster-k8s-root" role="region"></div>
</section>
<section class="card cluster-k8s-resource-card" aria-labelledby="k8s-jobs-title">
<h2 class="cluster-detail-section-title" id="k8s-jobs-title">Jobs</h2>
<p class="muted cluster-k8s-cmd"><code>kubectl get jobs -A -o json</code></p>
<div id="cluster-k8s-jobs-root" class="cluster-k8s-root" role="region"></div>
</section>
<section class="card cluster-k8s-resource-card" aria-labelledby="k8s-cj-title">
<h2 class="cluster-detail-section-title" id="k8s-cj-title">CronJobs</h2>
<p class="muted cluster-k8s-cmd"><code>kubectl get cronjobs -A -o json</code></p>
<div id="cluster-k8s-cronjobs-root" class="cluster-k8s-root" role="region"></div>
</section>
<section class="card cluster-k8s-resource-card" aria-labelledby="k8s-svc-title">
<h2 class="cluster-detail-section-title" id="k8s-svc-title">Сервисы</h2>
<p class="muted cluster-k8s-cmd"><code>kubectl get services -A -o json</code></p>
<div id="cluster-k8s-services-root" class="cluster-k8s-root" role="region"></div>
</section>
<section class="card cluster-k8s-resource-card" aria-labelledby="k8s-ing-title">
<h2 class="cluster-detail-section-title" id="k8s-ing-title">Ingresses</h2>
<p class="muted cluster-k8s-cmd"><code>kubectl get ingresses.networking.k8s.io -A -o json</code></p>
<div id="cluster-k8s-ingresses-root" class="cluster-k8s-root" role="region"></div>
</section>
<section class="card cluster-k8s-resource-card" aria-labelledby="k8s-pvc-title">
<h2 class="cluster-detail-section-title" id="k8s-pvc-title">PVC</h2>
<p class="muted cluster-k8s-cmd"><code>kubectl get pvc -A -o json</code></p>
<div id="cluster-k8s-pvcs-root" class="cluster-k8s-root" role="region" aria-label="PersistentVolumeClaim"></div>
</section>
{% include "partials/dashboard_modals.html" %}
{% endblock %}
{% block scripts %}
<script src="/static/js/dashboard.js" defer></script>
{% endblock %}
+17
View File
@@ -2,6 +2,8 @@
Автор: Сергей Антропов — https://devops.org.ru #} Автор: Сергей Антропов — https://devops.org.ru #}
{% extends "base.html" %} {% extends "base.html" %}
{% block body_extra_class %} dashboard-home-loading{% endblock %}
{% block body_attrs %}data-dashboard-mode="home"{% endblock %} {% block body_attrs %}data-dashboard-mode="home"{% endblock %}
{% block footer %} {% block footer %}
@@ -14,6 +16,21 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div
id="home-page-loading-overlay"
class="page-loading-overlay"
role="status"
aria-live="polite"
aria-busy="true"
aria-label="Загрузка панели"
>
<div class="page-loading-backdrop" aria-hidden="true"></div>
<div class="page-loading-center">
<span class="spinner page-loading-spinner" aria-hidden="true"></span>
<span class="page-loading-label">Загрузка панели…</span>
</div>
</div>
<div class="top-dashboard-row"> <div class="top-dashboard-row">
<section class="card cta-hero-card" aria-labelledby="cta-hero-title"> <section class="card cta-hero-card" aria-labelledby="cta-hero-title">
<div class="cta-hero-inner"> <div class="cta-hero-inner">
+7 -1
View File
@@ -28,12 +28,18 @@ services:
# Хост по умолчанию 8080: порт 6000 в Chrome/Edge даёт ERR_UNSAFE_PORT; внутри контейнера слушает 6000. # Хост по умолчанию 8080: порт 6000 в Chrome/Edge даёт ERR_UNSAFE_PORT; внутри контейнера слушает 6000.
ports: ports:
- "${KIND_K8S_WEB_PORT:-8080}:6000" - "${KIND_K8S_WEB_PORT:-8080}:6000"
# Доступ к apiserver kind с проброшенного порта хоста (kubectl внутри контейнера веб-UI).
extra_hosts:
- "host.docker.internal:host-gateway"
environment: environment:
DOCKER_HOST: unix:///var/run/docker.sock DOCKER_HOST: unix:///var/run/docker.sock
KIND_K8S_IN_CONTAINER: "1" KIND_K8S_IN_CONTAINER: "1"
KIND_K8S_WORKDIR: /work KIND_K8S_WORKDIR: /work
# По умолчанию включено: kubeconfig с хоста открывает API через проброшенный порт. # kubeconfig.host на томе; скачивание в UI патчит копию с KIND_K8S_KUBECONFIG_CLIENT_HOST (по умолчанию localhost).
KIND_K8S_PATCH_KUBECONFIG: ${KIND_K8S_PATCH_KUBECONFIG:-1} KIND_K8S_PATCH_KUBECONFIG: ${KIND_K8S_PATCH_KUBECONFIG:-1}
KIND_K8S_KUBECONFIG_CLIENT_HOST: ${KIND_K8S_KUBECONFIG_CLIENT_HOST:-localhost}
# Для kubectl внутри контейнера при server=host.docker.internal: SAN kind — localhost (редко менять).
KIND_K8S_KUBECONFIG_TLS_SERVER_NAME: ${KIND_K8S_KUBECONFIG_TLS_SERVER_NAME:-localhost}
CONTAINER_CLI: ${CONTAINER_CLI:-docker} CONTAINER_CLI: ${CONTAINER_CLI:-docker}
KIND_K8S_SKIP_VERSION_LIST: ${KIND_K8S_SKIP_VERSION_LIST:-} KIND_K8S_SKIP_VERSION_LIST: ${KIND_K8S_SKIP_VERSION_LIST:-}
KIND_K8S_VERSION_LIST_DISPLAY: ${KIND_K8S_VERSION_LIST_DISPLAY:-} KIND_K8S_VERSION_LIST_DISPLAY: ${KIND_K8S_VERSION_LIST_DISPLAY:-}
+7 -3
View File
@@ -97,9 +97,13 @@ _SETUP_PROMPTS: tuple[_EnvPrompt, ...] = (
_EnvPrompt( _EnvPrompt(
"# --- docker-compose.yml: environment сервиса kind-k8s-web ---", "# --- docker-compose.yml: environment сервиса kind-k8s-web ---",
"KIND_K8S_PATCH_KUBECONFIG", "KIND_K8S_PATCH_KUBECONFIG",
"1/true/yes — после create всегда патчить server в kubeconfig на 127.0.0.1:<порт> для доступа с хоста; " "1/true/yes — после create писать clusters/<имя>/kubeconfig.host (server с KIND_K8S_KUBECONFIG_CLIENT_HOST, иначе localhost); "
"0 или пусто — не форсировать (в контейнере compose всё равно может сработать KIND_K8S_IN_CONTAINER). " "основной kubeconfig — как у kind. 0 или пусто — не форсировать. По умолчанию в скрипте: 1 (включено).",
"По умолчанию в скрипте: 1 (включено).", ),
_EnvPrompt(
None,
"KIND_K8S_KUBECONFIG_CLIENT_HOST",
"Имя хоста в server= для скачиваемого kubeconfig (kubectl на машине пользователя). Пусто — localhost (лучше 127.0.0.1 с TLS kind). Для доступа с другой машины — IP/имя хоста Docker.",
), ),
_EnvPrompt(None, "CONTAINER_CLI", "Имя CLI для вызовов к движку контейнеров: docker или podman."), _EnvPrompt(None, "CONTAINER_CLI", "Имя CLI для вызовов к движку контейнеров: docker или podman."),
_EnvPrompt( _EnvPrompt(