Kiali anonymous, журнал Helm, kubeconfig для контейнеров, UI аддонов

- Kiali: убран login, anonymous по умолчанию; удалены поля логина/пароля из UI и API
- Журнал Helm: install/upgrade/delete, message и колонка в journal.js
- Аддоны: values свёрнуты при подгрузке для установленных
- GET …/kubeconfig/docker: host.docker.internal:порт + tls-server-name; кнопка в UI
- apply_apiserver_endpoint_to_kubeconfig_file; KIND_K8S_APISERVER_GATEWAY_HOST в compose/env.example
- README и api_routes.md обновлены
This commit is contained in:
Sergey Antropoff
2026-04-04 18:54:10 +03:00
parent 349b78d24d
commit 4b703801e1
13 changed files with 415 additions and 123 deletions
+12 -2
View File
@@ -26,7 +26,7 @@
- **Статистика**: число кластеров в kind, локальных каталогов, сумма workers из `meta.json`, счётчики фоновых заданий. - **Статистика**: число кластеров в kind, локальных каталогов, сумма workers из `meta.json`, счётчики фоновых заданий.
- **Создание кластера**: форма с подсказкой тегов `kindest/node` (`GET /api/v1/versions`), фоновое задание и опрос статуса (JSON в сворачиваемом блоке). - **Создание кластера**: форма с подсказкой тегов `kindest/node` (`GET /api/v1/versions`), фоновое задание и опрос статуса (JSON в сворачиваемом блоке).
- **Кластеры** (`/clusters`): сводка ресурсов узлов (донаты), таблица кластеров — **старт**/**стоп**, скачивание kubeconfig, модалки узлов/подов, ссылка на страницу кластера; с **панели** (`/`) — быстрый переход по ссылке в карточке «Создать кластер». - **Кластеры** (`/clusters`): сводка ресурсов узлов (донаты), таблица кластеров — **старт**/**стоп**, скачивание kubeconfig, модалки узлов/подов, ссылка на страницу кластера; с **панели** (`/`) — быстрый переход по ссылке в карточке «Создать кластер».
- **Аддоны** (`/cluster-addons`): выбор кластера и установка/удаление через **Helm** в контейнере — **ingress-nginx**, **kube-prometheus-stack** (логин/пароль Grafana), **metrics-server**, **Istio + Kiali** (логин/пароль Kiali); журнал операции на странице (прогресс + вывод как при создании кластера), история в **`clusters/<имя>/helm_addon_log.json`**. Нужна **пересборка образа** после обновления Dockerfile (бинарник `helm`). Таймаут операций: **`KIND_K8S_HELM_TIMEOUT_SEC`** (по умолчанию 900 с). - **Аддоны** (`/cluster-addons`): выбор кластера и установка/удаление через **Helm** в контейнере — **ingress-nginx**, **kube-prometheus-stack** (логин/пароль Grafana), **metrics-server**, **Istio + Kiali** (Kiali по умолчанию без формы входа, `auth` при необходимости в YAML values); журнал операции на странице (прогресс + вывод как при создании кластера), история в **`clusters/<имя>/helm_addon_log.json`**. Нужна **пересборка образа** после обновления Dockerfile (бинарник `helm`). Таймаут операций: **`KIND_K8S_HELM_TIMEOUT_SEC`** (по умолчанию 900 с).
- **Последние задания**: история в памяти процесса (до **200** записей; после перезапуска контейнера сбрасывается); кнопка **«Очистить завершённые»** вызывает **`DELETE /api/v1/jobs`** (из памяти удаляются только завершённые задания). - **Последние задания**: история в памяти процесса (до **200** записей; после перезапуска контейнера сбрасывается); кнопка **«Очистить завершённые»** вызывает **`DELETE /api/v1/jobs`** (из памяти удаляются только завершённые задания).
- **Автообновление** таблиц и плашки среды каждые ~3,5 с (fetch к API без перезагрузки страницы). - **Автообновление** таблиц и плашки среды каждые ~3,5 с (fetch к API без перезагрузки страницы).
- При активном задании — **прогресс-бар**, **журнал** (в т.ч. скачивание образа; для **docker** при поддержке CLI — **`pull --progress=plain`**, см. **`KIND_K8S_DOCKER_PULL_PLAIN`**), опрос статуса чаще, чем общие таблицы; кнопка **«Отменить»** — прерывание с завершением текущей дочерней команды. - При активном задании — **прогресс-бар**, **журнал** (в т.ч. скачивание образа; для **docker** при поддержке CLI — **`pull --progress=plain`**, см. **`KIND_K8S_DOCKER_PULL_PLAIN`**), опрос статуса чаще, чем общие таблицы; кнопка **«Отменить»** — прерывание с завершением текущей дочерней команды.
@@ -46,12 +46,22 @@
**На хост не нужны:** **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.host`** (или скачивание из веб-UI) для kubectl на хосте: **`https://<KIND_K8S_KUBECONFIG_CLIENT_HOST или localhost>:<порт>`** — см. `app/kubeconfig_patch.py`. Переменная позволяет задать IP/имя хоста, если `localhost` недоступен с вашей машины. Файл **`clusters/<имя>/kubeconfig.host`** (или скачивание из веб-UI — кнопка со стрелкой вниз) для kubectl на хосте: **`https://<KIND_K8S_KUBECONFIG_CLIENT_HOST или localhost>:<порт>`** — см. `app/kubeconfig_patch.py`. Переменная позволяет задать IP/имя хоста, если `localhost` недоступен с вашей машины. Рядом в UI — вторая кнопка (иконка «пакет»): **`GET …/kubeconfig/docker`** — kubeconfig для kubectl **из любого контейнера**: **`https://host.docker.internal:<порт>`** (порт с проброса 6443 control plane на хост) и **`tls-server-name: localhost`**, как у процесса веб-приложения. На **Linux** в вашем контейнере добавьте **`extra_hosts: ["host.docker.internal:host-gateway"]`** (в compose — как у сервиса `kind-k8s-web`). Если **`docker port`** недоступен при генерации файла, подставляется запасной **`https://<имя>-control-plane:6443`** (тогда нужна общая сеть с kind).
Смонтированы **сокет** 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`** без пересборки образа).
После создания кластера при **`KIND_K8S_PATCH_KUBECONFIG`** дополнительно пишется **`kubeconfig.host`**; скачивание через API каждый раз пересобирает файл с актуальным портом и хостом из **`KIND_K8S_KUBECONFIG_CLIENT_HOST`**. После создания кластера при **`KIND_K8S_PATCH_KUBECONFIG`** дополнительно пишется **`kubeconfig.host`**; скачивание через API каждый раз пересобирает файл с актуальным портом и хостом из **`KIND_K8S_KUBECONFIG_CLIENT_HOST`**.
### kubectl из другого контейнера (ошибка `localhost:<порт>` / `dev-control-plane: Name or service not known`)
Файл **`kubeconfig.host`** рассчитан на запуск **kubectl с хоста**. Внутри **другого** контейнера **`127.0.0.1` — не хост Docker**, поэтому **`https://localhost:<порт>`** из `kubeconfig.host` там не работает. Имя **`…-control-plane`** резолвится только в **той же сети**, что и узел kind — из «чужой» сети контейнера DNS его не видит.
**Вариант 1 (предпочтительно): скачать kubeconfig из UI** (кнопка с иконкой пакета) или **`GET /api/v1/clusters/{name}/kubeconfig/docker`**. В файле уже **`https://host.docker.internal:<порт>`** и **`tls-server-name: localhost`** (как у kubectl внутри `kind-k8s-web`): трафик идёт на **хост**, затем в проброшенный порт API kind — **без** общей сети с кластером. В **вашем** контейнере на **Linux** добавьте в compose/run: **`extra_hosts: ["host.docker.internal:host-gateway"]`** (как в `docker-compose.yml` у `kind-k8s-web`). Шлюз переопределяется **`KIND_K8S_APISERVER_GATEWAY_HOST`**; SNI — **`KIND_K8S_KUBECONFIG_TLS_SERVER_NAME`**.
**Вариант 2: общая сеть Docker с kind.** Подключите контейнер к сети kind и задайте **`server: https://<имя_кластера>-control-plane:6443`** (см. `docker network ls` / `docker inspect …-control-plane`).
Подробности — **`app/kubeconfig_patch.py`**.
## Быстрый старт ## Быстрый старт
```bash ```bash
+74 -14
View File
@@ -97,11 +97,63 @@ def _parse_optional_values_yaml(raw: str | None) -> dict[str, Any] | None:
return data return data
# Идентификатор аддона в API/UI → ключ :func:`helm_addon_snapshot` и суффикс ``job_kind`` (snake_case).
_HELM_ADDON_SNAPSHOT_KEY: dict[str, str] = {
"ingress-nginx": "ingress_nginx",
"kube-prometheus-stack": "kube_prometheus_stack",
"metrics-server": "metrics_server",
"istio-kiali": "istio_mesh_ready",
}
_HELM_ADDON_JOB_SLUG: dict[str, str] = {
"ingress-nginx": "ingress_nginx",
"kube-prometheus-stack": "kube_prometheus_stack",
"metrics-server": "metrics_server",
"istio-kiali": "istio_kiali",
}
def _helm_journal_message_ru(addon_id: str, action: str) -> str:
"""Краткая подпись операции для колонки «Сообщение» в журнале Helm и для UI."""
name = {
"ingress-nginx": "ingress-nginx",
"kube-prometheus-stack": "kube-prometheus-stack",
"metrics-server": "metrics-server",
"istio-kiali": "Istio + Kiali",
}.get(addon_id, addon_id)
verb = {
"install": "Установка",
"upgrade": "Обновление",
"delete": "Удаление",
}.get(action, action)
return f"{verb}: {name}"
async def _helm_install_action_and_job_kind(cluster_name: str, addon_id: str) -> tuple[str, str]:
"""
Перед ``helm upgrade --install``: если релиз уже есть — операция **upgrade**, иначе **install**.
Возвращает ``(action, job_kind)``, например ``("upgrade", "helm_addon_upgrade_ingress_nginx")``.
"""
snap_key = _HELM_ADDON_SNAPSHOT_KEY.get(addon_id)
slug = _HELM_ADDON_JOB_SLUG.get(addon_id, addon_id.replace("-", "_"))
if not snap_key:
return "install", f"helm_addon_install_{slug}"
try:
st = await asyncio.to_thread(helm_addon_snapshot, cluster_name)
installed = bool(st.get(snap_key))
except HelmAddonError:
installed = False
action = "upgrade" if installed else "install"
return action, f"helm_addon_{action}_{slug}"
async def _run_helm_with_journal( async def _run_helm_with_journal(
cluster_name: str, cluster_name: str,
job_kind: str, job_kind: str,
result_meta: dict[str, Any], result_meta: dict[str, Any],
sync_call: Callable[[], tuple[bool, str]], sync_call: Callable[[], tuple[bool, str]],
*,
journal_message: str | None = None,
) -> str: ) -> str:
""" """
Выполнить операцию под блокировкой kind, добавить запись в историю ``helm_addon_log.json``. Выполнить операцию под блокировкой kind, добавить запись в историю ``helm_addon_log.json``.
@@ -118,7 +170,7 @@ async def _run_helm_with_journal(
job_id=str(uuid.uuid4()), job_id=str(uuid.uuid4()),
job_kind=job_kind, job_kind=job_kind,
status="success", status="success",
message=None, message=journal_message,
lines=log.splitlines() if log else [], lines=log.splitlines() if log else [],
result=result_meta, result=result_meta,
) )
@@ -244,13 +296,15 @@ async def post_install_ingress_nginx(
if body is not None and body.chart_version and body.chart_version.strip(): if body is not None and body.chart_version and body.chart_version.strip():
ver = body.chart_version.strip() ver = body.chart_version.strip()
user_v = _parse_optional_values_yaml(body.values_yaml if body else None) user_v = _parse_optional_values_yaml(body.values_yaml if body else None)
meta = {"addon": "ingress-nginx", "action": "install", "chart_version": ver} action, job_kind = await _helm_install_action_and_job_kind(n, "ingress-nginx")
meta = {"addon": "ingress-nginx", "action": action, "chart_version": ver}
try: try:
log = await _run_helm_with_journal( log = await _run_helm_with_journal(
n, n,
"helm_addon_install_ingress_nginx", job_kind,
meta, meta,
lambda: install_ingress_nginx(n, ver, user_v), lambda: install_ingress_nginx(n, ver, user_v),
journal_message=_helm_journal_message_ru("ingress-nginx", action),
) )
except HelmAddonError as e: except HelmAddonError as e:
raise _map_helm_error(e) from e raise _map_helm_error(e) from e
@@ -274,6 +328,7 @@ async def delete_ingress_nginx(name: str) -> HelmAddonApplyResponse:
"helm_addon_delete_ingress_nginx", "helm_addon_delete_ingress_nginx",
meta, meta,
lambda: uninstall_ingress_nginx(n), lambda: uninstall_ingress_nginx(n),
journal_message=_helm_journal_message_ru("ingress-nginx", "delete"),
) )
except HelmAddonError as e: except HelmAddonError as e:
raise _map_helm_error(e) from e raise _map_helm_error(e) from e
@@ -297,16 +352,17 @@ async def post_install_kube_prometheus_stack(
prom_ver = body.chart_version.strip() prom_ver = body.chart_version.strip()
u = body.grafana_admin_user.strip() u = body.grafana_admin_user.strip()
user_v = _parse_optional_values_yaml(body.values_yaml) user_v = _parse_optional_values_yaml(body.values_yaml)
action, job_kind = await _helm_install_action_and_job_kind(n, "kube-prometheus-stack")
meta = { meta = {
"addon": "kube-prometheus-stack", "addon": "kube-prometheus-stack",
"action": "install", "action": action,
"chart_version": prom_ver, "chart_version": prom_ver,
"grafana_admin_user": u, "grafana_admin_user": u,
} }
try: try:
log = await _run_helm_with_journal( log = await _run_helm_with_journal(
n, n,
"helm_addon_install_kube_prometheus_stack", job_kind,
meta, meta,
lambda: install_kube_prometheus_stack( lambda: install_kube_prometheus_stack(
n, n,
@@ -315,6 +371,7 @@ async def post_install_kube_prometheus_stack(
chart_version=prom_ver, chart_version=prom_ver,
user_values=user_v, user_values=user_v,
), ),
journal_message=_helm_journal_message_ru("kube-prometheus-stack", action),
) )
except HelmAddonError as e: except HelmAddonError as e:
raise _map_helm_error(e) from e raise _map_helm_error(e) from e
@@ -338,6 +395,7 @@ async def delete_kube_prometheus_stack(name: str) -> HelmAddonApplyResponse:
"helm_addon_delete_kube_prometheus_stack", "helm_addon_delete_kube_prometheus_stack",
meta, meta,
lambda: uninstall_kube_prometheus_stack(n), lambda: uninstall_kube_prometheus_stack(n),
journal_message=_helm_journal_message_ru("kube-prometheus-stack", "delete"),
) )
except HelmAddonError as e: except HelmAddonError as e:
raise _map_helm_error(e) from e raise _map_helm_error(e) from e
@@ -360,13 +418,15 @@ async def post_install_metrics_server(
if body is not None and body.chart_version and body.chart_version.strip(): if body is not None and body.chart_version and body.chart_version.strip():
ms_ver = body.chart_version.strip() ms_ver = body.chart_version.strip()
user_v = _parse_optional_values_yaml(body.values_yaml if body else None) user_v = _parse_optional_values_yaml(body.values_yaml if body else None)
meta = {"addon": "metrics-server", "action": "install", "chart_version": ms_ver} action, job_kind = await _helm_install_action_and_job_kind(n, "metrics-server")
meta = {"addon": "metrics-server", "action": action, "chart_version": ms_ver}
try: try:
log = await _run_helm_with_journal( log = await _run_helm_with_journal(
n, n,
"helm_addon_install_metrics_server", job_kind,
meta, meta,
lambda: install_metrics_server(n, ms_ver, user_v), lambda: install_metrics_server(n, ms_ver, user_v),
journal_message=_helm_journal_message_ru("metrics-server", action),
) )
except HelmAddonError as e: except HelmAddonError as e:
raise _map_helm_error(e) from e raise _map_helm_error(e) from e
@@ -390,6 +450,7 @@ async def delete_metrics_server(name: str) -> HelmAddonApplyResponse:
"helm_addon_delete_metrics_server", "helm_addon_delete_metrics_server",
meta, meta,
lambda: uninstall_metrics_server(n), lambda: uninstall_metrics_server(n),
journal_message=_helm_journal_message_ru("metrics-server", "delete"),
) )
except HelmAddonError as e: except HelmAddonError as e:
raise _map_helm_error(e) from e raise _map_helm_error(e) from e
@@ -399,7 +460,7 @@ async def delete_metrics_server(name: str) -> HelmAddonApplyResponse:
@router.post( @router.post(
"/clusters/{name}/addons/istio-kiali", "/clusters/{name}/addons/istio-kiali",
response_model=HelmAddonApplyResponse, response_model=HelmAddonApplyResponse,
summary="Установить Istio (base+istiod) и Kiali с логином", summary="Установить Istio (base+istiod) и Kiali (по умолчанию auth anonymous)",
) )
async def post_install_istio_kiali( async def post_install_istio_kiali(
name: str, name: str,
@@ -414,32 +475,30 @@ async def post_install_istio_kiali(
kiali_ver: str | None = None kiali_ver: str | None = None
if body.kiali_chart_version and body.kiali_chart_version.strip(): if body.kiali_chart_version and body.kiali_chart_version.strip():
kiali_ver = body.kiali_chart_version.strip() kiali_ver = body.kiali_chart_version.strip()
ku = body.kiali_username.strip()
kiali_uv = _parse_optional_values_yaml(body.values_yaml) kiali_uv = _parse_optional_values_yaml(body.values_yaml)
istio_base_v = _parse_optional_values_yaml(body.istio_base_values_yaml) istio_base_v = _parse_optional_values_yaml(body.istio_base_values_yaml)
istiod_v = _parse_optional_values_yaml(body.istio_istiod_values_yaml) istiod_v = _parse_optional_values_yaml(body.istio_istiod_values_yaml)
action, job_kind = await _helm_install_action_and_job_kind(n, "istio-kiali")
meta = { meta = {
"addon": "istio-kiali", "addon": "istio-kiali",
"action": "install", "action": action,
"istio_chart_version": istio_ver, "istio_chart_version": istio_ver,
"kiali_chart_version": kiali_ver, "kiali_chart_version": kiali_ver,
"kiali_username": ku,
} }
try: try:
log = await _run_helm_with_journal( log = await _run_helm_with_journal(
n, n,
"helm_addon_install_istio_kiali", job_kind,
meta, meta,
lambda: install_istio_and_kiali( lambda: install_istio_and_kiali(
n, n,
kiali_username=ku,
kiali_password=body.kiali_password,
istio_chart_version=istio_ver, istio_chart_version=istio_ver,
kiali_chart_version=kiali_ver, kiali_chart_version=kiali_ver,
istio_base_values=istio_base_v, istio_base_values=istio_base_v,
istiod_values=istiod_v, istiod_values=istiod_v,
kiali_user_values=kiali_uv, kiali_user_values=kiali_uv,
), ),
journal_message=_helm_journal_message_ru("istio-kiali", action),
) )
except HelmAddonError as e: except HelmAddonError as e:
raise _map_helm_error(e) from e raise _map_helm_error(e) from e
@@ -463,6 +522,7 @@ async def delete_istio_kiali(name: str) -> HelmAddonApplyResponse:
"helm_addon_delete_istio_kiali", "helm_addon_delete_istio_kiali",
meta, meta,
lambda: uninstall_istio_and_kiali(n), lambda: uninstall_istio_and_kiali(n),
journal_message=_helm_journal_message_ru("istio-kiali", "delete"),
) )
except HelmAddonError as e: except HelmAddonError as e:
raise _map_helm_error(e) from e raise _map_helm_error(e) from e
+55 -1
View File
@@ -66,7 +66,11 @@ 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 kubeconfig_patch import (
kubeconfig_host_file,
patch_kubeconfig_server_for_docker_network,
patch_kubeconfig_server_for_host,
)
from models.schemas import ( from models.schemas import (
AggregateResourcesSummary, AggregateResourcesSummary,
ClusterConfigGetResponse, ClusterConfigGetResponse,
@@ -351,6 +355,56 @@ async def put_cluster_config(name: str, body: ClusterConfigUpdateRequest) -> Clu
) )
@router.get(
"/clusters/{name}/kubeconfig/docker",
summary="Скачать kubeconfig для kubectl из другого контейнера (host.docker.internal:порт + SNI)",
responses={404: {"description": "Файл не найден"}, 500: {"description": "Ошибка подготовки файла"}},
)
async def download_kubeconfig_docker_network(name: str) -> FileResponse:
"""
Kubeconfig как для **kubectl внутри веб-контейнера**: ``https://<шлюз>:<порт>`` (по умолчанию
**host.docker.internal** и порт из ``docker port … 6443``) плюс **tls-server-name** (обычно **localhost**).
Доступен из **любого** контейнера, где резолвится шлюз (на Linux: ``extra_hosts: host.docker.internal:host-gateway``).
Запасной вариант без порта на хосте — ``https://<имя>-control-plane:6443`` (нужна общая сеть с kind).
Копия ``clusters/<имя>/kubeconfig`` с подстановкой ``server`` / ``tls-server-name`` (см. ``kubeconfig_patch.py``).
"""
if not validate_cluster_name(name):
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
n = name.strip()
primary = clusters_dir() / n / "kubeconfig"
if not primary.is_file():
raise HTTPException(status_code=404, detail="kubeconfig не найден")
fd, tmp_name = tempfile.mkstemp(suffix=".yaml", prefix="kind-k8s-kube-docker-")
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 (docker network): %s", e)
raise HTTPException(status_code=500, detail="Не удалось подготовить kubeconfig") from e
patched = await asyncio.to_thread(
partial(patch_kubeconfig_server_for_docker_network, cluster_name=n, kube_path=tmp_path),
)
if not patched:
tmp_path.unlink(missing_ok=True)
raise HTTPException(
status_code=500,
detail="Не удалось подготовить kubeconfig для контейнера (kubectl config set-cluster / tls-server-name)",
)
logger.info("Отдача kubeconfig (docker network) для кластера %s", n)
return FileResponse(
path=str(tmp_path),
filename=f"kubeconfig-docker-{n}.yaml",
media_type="application/x-yaml",
background=BackgroundTask(_unlink_temp_kubeconfig, tmp_path),
)
@router.get( @router.get(
"/clusters/{name}/kubeconfig", "/clusters/{name}/kubeconfig",
summary="Скачать kubeconfig", summary="Скачать kubeconfig",
+23 -20
View File
@@ -754,11 +754,23 @@ def uninstall_metrics_server(cluster_name: str) -> tuple[bool, str]:
return True, text or "metrics-server удалён (или отсутствовал)." return True, text or "metrics-server удалён (или отсутствовал)."
def _kiali_user_values_define_auth_strategy(values: dict[str, Any] | None) -> bool:
"""
True, если в пользовательских values kiali-server уже задан ``auth.strategy``
(тогда Helm не подставляет ``anonymous`` поверх).
"""
if not values:
return False
auth = values.get("auth")
if not isinstance(auth, dict):
return False
strat = auth.get("strategy")
return isinstance(strat, str) and bool(strat.strip())
def install_istio_and_kiali( def install_istio_and_kiali(
cluster_name: str, cluster_name: str,
*, *,
kiali_username: str,
kiali_password: str,
istio_chart_version: str | None = None, istio_chart_version: str | None = None,
kiali_chart_version: str | None = None, kiali_chart_version: str | None = None,
istio_base_values: dict[str, Any] | None = None, istio_base_values: dict[str, Any] | None = None,
@@ -766,7 +778,11 @@ def install_istio_and_kiali(
kiali_user_values: dict[str, Any] | None = None, kiali_user_values: dict[str, Any] | None = None,
) -> tuple[bool, str]: ) -> tuple[bool, str]:
""" """
Istio base + istiod (опциональные values из UI) + секрет Kiali + kiali-server. Istio base + istiod (опциональные values из UI) + kiali-server.
Kiali: стратегия ``login`` в актуальных версиях не поддерживается upstream. По умолчанию
выставляется ``auth.strategy=anonymous`` (удобно для kind и port-forward); иначе
задайте ``auth`` в YAML values (``token``, ``openid`` и т.д., см. kiali.io).
Для каждого чарта: если словарь ``None`` — без файла ``-f`` (дефолты чарта); Для каждого чарта: если словарь ``None`` — без файла ``-f`` (дефолты чарта);
если непустой dict — полный values как в редакторе. если непустой dict — полный values как в редакторе.
@@ -826,22 +842,6 @@ def install_istio_and_kiali(
if istiod_tmp is not None: if istiod_tmp is not None:
istiod_tmp.unlink(missing_ok=True) istiod_tmp.unlink(missing_ok=True)
# Секрет для стратегии login (ключи username / passphrase — ожидание Kiali).
secret = {
"apiVersion": "v1",
"kind": "Secret",
"metadata": {"name": "kiali", "namespace": "istio-system"},
"type": "Opaque",
"stringData": {
"username": kiali_username,
"passphrase": kiali_password,
},
}
rcs, msgs = _kubectl_apply_manifest(cluster_name, secret)
if rcs != 0:
raise HelmAddonError(f"Secret kiali: {msgs}", exit_code=rcs)
log_parts.append(msgs)
kiali_extra: list[Path] = [] kiali_extra: list[Path] = []
kiali_tmp: Path | None = None kiali_tmp: Path | None = None
try: try:
@@ -851,13 +851,16 @@ def install_istio_and_kiali(
kiali_tmp = Path(name) kiali_tmp = Path(name)
kiali_tmp.write_text(dump_helm_values_yaml(kiali_user_values), encoding="utf-8") kiali_tmp.write_text(dump_helm_values_yaml(kiali_user_values), encoding="utf-8")
kiali_extra.append(kiali_tmp) kiali_extra.append(kiali_tmp)
kiali_helm_extra: list[str] = []
if not _kiali_user_values_define_auth_strategy(kiali_user_values):
kiali_helm_extra.extend(["--set", "auth.strategy=anonymous"])
rc3, o3, e3 = _helm_upgrade_install( rc3, o3, e3 = _helm_upgrade_install(
cluster_name, cluster_name,
"kiali-server", "kiali-server",
"kiali/kiali-server", "kiali/kiali-server",
"istio-system", "istio-system",
chart_version=kiali_ver, chart_version=kiali_ver,
extra_args=["--set", "auth.strategy=login"], extra_args=kiali_helm_extra or None,
extra_values_files=kiali_extra or None, extra_values_files=kiali_extra or None,
) )
finally: finally:
+19 -6
View File
@@ -52,6 +52,7 @@
| GET | `/api/v1/clusters/{name}/config` | Текущие `meta.json` и текст `kind-config.yaml`; подсказка `kind_note` про пересоздание | | GET | `/api/v1/clusters/{name}/config` | Текущие `meta.json` и текст `kind-config.yaml`; подсказка `kind_note` про пересоздание |
| PUT | `/api/v1/clusters/{name}/config` | Сохранить YAML и/или meta (простой режим или полный YAML); ответ включает обновлённый `summary` | | PUT | `/api/v1/clusters/{name}/config` | Сохранить YAML и/или meta (простой режим или полный YAML); ответ включает обновлённый `summary` |
| GET | `/api/v1/clusters/{name}/kubeconfig` | Скачать файл kubeconfig | | GET | `/api/v1/clusters/{name}/kubeconfig` | Скачать файл kubeconfig |
| GET | `/api/v1/clusters/{name}/kubeconfig/docker` | Скачать kubeconfig для kubectl из **любого** контейнера: `host.docker.internal:<порт>` + `tls-server-name` (запасной вариант — `*-control-plane:6443`) |
| GET | `/api/v1/clusters/{name}/provision-log` | Полный журнал последнего **create_cluster** / **start_cluster** / **start_cluster_reapply** (JSON с диска) | | GET | `/api/v1/clusters/{name}/provision-log` | Полный журнал последнего **create_cluster** / **start_cluster** / **start_cluster_reapply** (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`) | | GET | `/api/v1/clusters/{name}/overview` | Сводка для страницы UI: метрики узлов, агрегаты, блоки **`k8s_*`** (JSON из `kubectl get … -o json`) |
@@ -79,7 +80,7 @@
| DELETE | `/api/v1/jobs` | Удалить из памяти **завершённые** задания (`removed` — число записей) | | DELETE | `/api/v1/jobs` | Удалить из памяти **завершённые** задания (`removed` — число записей) |
| POST | `/api/v1/jobs/{job_id}/cancel` | Прервать задание: активная команда завершается принудительно (скачивание образа, создание кластера, старт/стоп узла) | | POST | `/api/v1/jobs/{job_id}/cancel` | Прервать задание: активная команда завершается принудительно (скачивание образа, создание кластера, старт/стоп узла) |
После каждого **POST/DELETE …/addons/…** в каталоге кластера в **`helm_addon_log.json`** **добавляется** новая запись в массив **`entries`** (история сохраняется, лимит **`KIND_K8S_HELM_ADDON_LOG_MAX_ENTRIES`**). Формат записи как у **`provision_log.json`**: `lines`, `kind`, `status`, `result`; пароли в файл не пишутся. После каждого **POST/DELETE …/addons/…** в каталоге кластера в **`helm_addon_log.json`** **добавляется** новая запись в массив **`entries`** (история сохраняется, лимит **`KIND_K8S_HELM_ADDON_LOG_MAX_ENTRIES`**). Формат записи как у **`provision_log.json`**: `lines`, `kind`, `status`, **`message`** (краткая подпись на русском при успехе), **`result`**; в **`result`** поля **`addon`** и **`action`**: **`install`** | **`upgrade`** (повторный POST при уже установленном релизе) | **`delete`**; пароли в файл не пишутся. Поле **`kind`**: `helm_addon_install_*`, `helm_addon_upgrade_*`, `helm_addon_delete_*` (суффикс — аддон в snake_case).
#### POST `/api/v1/helm/addons/compose-values-batch` #### POST `/api/v1/helm/addons/compose-values-batch`
@@ -105,7 +106,7 @@
- **ingress-nginx** — один файл **`-f`** с полным values; затем **`--set`** NodePort (сильнее файла). - **ingress-nginx** — один файл **`-f`** с полным values; затем **`--set`** NodePort (сильнее файла).
- **kube-prometheus-stack** — один файл: либо из **`values_yaml`**, либо автосборка из версии и полей Grafana. - **kube-prometheus-stack** — один файл: либо из **`values_yaml`**, либо автосборка из версии и полей Grafana.
- **metrics-server** — аналогично. - **metrics-server** — аналогично.
- **istio-kiali** — до трёх опциональных файлов: **istio/base**, **istio/istiod**, **kiali-server**; секрет логина Kiali и **`auth.strategy=login`** отдельно. - **istio-kiali** — до трёх опциональных файлов: **istio/base**, **istio/istiod**, **kiali-server**. Если в YAML kiali-server **нет** `auth.strategy`, сервер задаёт **`auth.strategy=anonymous`** (стратегия **login** в актуальных версиях Kiali не поддерживается). Логин/пароль Kiali в теле запроса **не** передаются.
**Пример POST compose-values (`kube-prometheus-stack`):** **Пример POST compose-values (`kube-prometheus-stack`):**
@@ -120,12 +121,10 @@
**Пример ответа 200 (фрагмент):** поле **`values_yaml`** — многострочная строка с полным YAML чарта. **Пример ответа 200 (фрагмент):** поле **`values_yaml`** — многострочная строка с полным YAML чарта.
**Пример POST установки istio-kiali с тремя YAML:** **Пример POST установки istio-kiali с тремя YAML** (Kiali с **token**, если задано в `values_yaml`; иначе будет **anonymous**):
```json ```json
{ {
"kiali_username": "kiali-admin",
"kiali_password": "MySecurePwd12",
"istio_chart_version": "1.22.0", "istio_chart_version": "1.22.0",
"kiali_chart_version": "1.89.0", "kiali_chart_version": "1.89.0",
"istio_base_values_yaml": "defaultRevision: default\n", "istio_base_values_yaml": "defaultRevision: default\n",
@@ -140,7 +139,7 @@
- В памяти держится не более **200** записей; при превышении старые задания вытесняются (`app/core/job_store.py`). - В памяти держится не более **200** записей; при превышении старые задания вытесняются (`app/core/job_store.py`).
- Снимок заданий сохраняется в JSON в каталоге **`clusters/`** (файл **`kind_k8s_jobs.json`** на томе с хоста) — после перезапуска контейнера список восстанавливается. Записи в статусе **queued**/**running** при старте помечаются как **failed** (процесс уже не выполняется). Путь переопределяется переменной **`KIND_K8S_JOBS_JSON`**. - Снимок заданий сохраняется в JSON в каталоге **`clusters/`** (файл **`kind_k8s_jobs.json`** на томе с хоста) — после перезапуска контейнера список восстанавливается. Записи в статусе **queued**/**running** при старте помечаются как **failed** (процесс уже не выполняется). Путь переопределяется переменной **`KIND_K8S_JOBS_JSON`**.
- При **завершении** задания (успех / ошибка / отмена), если указано **`cluster_name`** и существует каталог **`clusters/<имя>/`**, в **`clusters/<имя>/journal/jobs_history.json`** дозаписывается запись с хвостом лога и **`result`** (без секретов). Лимиты: **`KIND_K8S_CLUSTER_JOURNAL_MAX_ENTRIES`**, **`KIND_K8S_CLUSTER_JOURNAL_MAX_LOG_LINES`**. Чтение: **`GET /api/v1/journal/recent?cluster=…`**, **`GET /api/v1/clusters/{name}/journal`**, страница **`GET /journal`** (режим «По кластеру»). - При **завершении** задания (успех / ошибка / отмена), если указано **`cluster_name`** и существует каталог **`clusters/<имя>/`**, в **`clusters/<имя>/journal/jobs_history.json`** дозаписывается запись с хвостом лога и **`result`** (без секретов). Лимиты: **`KIND_K8S_CLUSTER_JOURNAL_MAX_ENTRIES`**, **`KIND_K8S_CLUSTER_JOURNAL_MAX_LOG_LINES`**. Чтение: **`GET /api/v1/journal/recent?cluster=…`**, **`GET /api/v1/clusters/{name}/journal`**, страница **`GET /journal`** (режим «По кластеру»).
- Операции **Helm-аддонов** (install/delete) **дописывают** запись в **`clusters/<имя>/helm_addon_log.json`** (массив **`entries`**, новые сверху), тот же набор полей, что у **`provision_log.json`**. Сводка по всем кластерам: **`GET /api/v1/journal/helm-addons`**. - Операции **Helm-аддонов** (первая установка, **обновление** через тот же POST при уже установленном релизе, удаление) **дописывают** запись в **`clusters/<имя>/helm_addon_log.json`** (массив **`entries`**, новые сверху). Сводка по всем кластерам: **`GET /api/v1/journal/helm-addons`**; на странице **`/journal`** режим **Helm-аддоны** показывает тип операции и аддон в человекочитаемом виде.
- Создание кластера: `POST /api/v1/clusters` → опрос `GET /api/v1/jobs/{job_id}` (как в веб-UI). - Создание кластера: `POST /api/v1/clusters` → опрос `GET /api/v1/jobs/{job_id}` (как в веб-UI).
- В ответе задания поля **`progress_stage`** (текст этапа) и **`progress_percent`** (0–100) обновляются во время создания. - В ответе задания поля **`progress_stage`** (текст этапа) и **`progress_percent`** (0–100) обновляются во время создания.
- В **GET /api/v1/jobs** (список) поле **`progress_log`** всегда **пустой массив** — меньше трафика; полный хвост — в **GET /api/v1/jobs/{job_id}** (лимит строк: `KIND_K8S_JOB_API_LOG_MAX_LINES`, по умолчанию **5000**). - В **GET /api/v1/jobs** (список) поле **`progress_log`** всегда **пустой массив** — меньше трафика; полный хвост — в **GET /api/v1/jobs/{job_id}** (лимит строк: `KIND_K8S_JOB_API_LOG_MAX_LINES`, по умолчанию **5000**).
@@ -623,6 +622,20 @@ Accept: text/markdown
--- ---
## GET /api/v1/clusters/{name}/kubeconfig/docker
Скачать kubeconfig для **kubectl из произвольного контейнера** (`Content-Disposition`: `kubeconfig-docker-{name}.yaml`).
Логика как у **kubectl внутри веб-контейнера** (`kubeconfig_patch.apply_apiserver_endpoint_to_kubeconfig_file`): при успешном **`docker port <имя>-control-plane 6443/tcp`** — **`server=https://<KIND_K8S_APISERVER_GATEWAY_HOST>:<порт>`** (по умолчанию **host.docker.internal**) и **`tls-server-name`** из **`KIND_K8S_KUBECONFIG_TLS_SERVER_NAME`** (по умолчанию **localhost**). Иначе запасной **`server=https://<имя>-control-plane:6443`** без SNI. На Linux у клиентского контейнера обычно нужен **`extra_hosts: host.docker.internal:host-gateway`**.
**Ошибка 404:** нет файла **`kubeconfig`** в `clusters/{name}/`.
**Ошибка 500:** не удалось выполнить `kubectl config set-cluster` / задать SNI.
**Ошибка 400:** некорректное имя кластера.
---
## GET /api/v1/clusters/{name}/provision-log ## GET /api/v1/clusters/{name}/provision-log
Содержимое **`clusters/<имя>/provision_log.json`**: полный журнал строк последнего завершённого задания **create_cluster**, **start_cluster** или **start_cluster_reapply** (включая успех, ошибку и отмену), плюс метаданные (`job_id`, `kind`, `status`, `message`, `result`, `finished_at_utc`). Содержимое **`clusters/<имя>/provision_log.json`**: полный журнал строк последнего завершённого задания **create_cluster**, **start_cluster** или **start_cluster_reapply** (включая успех, ошибку и отмену), плюс метаданные (`job_id`, `kind`, `status`, `message`, `result`, `finished_at_utc`).
+112 -43
View File
@@ -14,6 +14,10 @@ Kind внутри Docker видит другой адрес apiserver; для **
Запасной URL: ``https://<имя>-control-plane:6443`` (без доп. SNI). Запасной URL: ``https://<имя>-control-plane:6443`` (без доп. SNI).
- ``kubeconfig.host`` — копия с ``server=`` для kubectl на хосте (см. - ``kubeconfig.host`` — копия с ``server=`` для kubectl на хосте (см.
:func:`kubeconfig_download_client_hostname`). :func:`kubeconfig_download_client_hostname`).
- Скачивание через API **``GET …/kubeconfig/docker``** — тот же endpoint, что для kubectl в веб-контейнере:
``https://<KIND_K8S_APISERVER_GATEWAY_HOST>:<порт>`` (по умолчанию **host.docker.internal** и порт из ``docker port``)
плюс **tls-server-name** (см. :func:`patch_kubeconfig_server_for_docker_network`), чтобы **любой** контейнер с
``extra_hosts: host.docker.internal:host-gateway`` достучался до API через хост, без общей сети с kind.
Если патч отключён или не удалился, ``kubeconfig.host`` может отсутствовать — тогда Если патч отключён или не удалился, ``kubeconfig.host`` может отсутствовать — тогда
отдаётся основной ``kubeconfig``. отдаётся основной ``kubeconfig``.
@@ -230,6 +234,110 @@ def _web_container_apiserver_endpoint(cluster_name: str) -> tuple[str, str | Non
return apiserver_url_docker_bridge(cluster_name), None return apiserver_url_docker_bridge(cluster_name), None
def apply_apiserver_endpoint_to_kubeconfig_file(
*,
cluster_name: str,
kube_path: Path,
) -> tuple[bool, str, str | None, str]:
"""
Выставить в kubeconfig ``server`` и при необходимости ``tls-server-name`` по
:func:`_web_container_apiserver_endpoint` (шлюз хоста + порт или запасной ``*-control-plane:6443``).
Возвращает ``(успех, server, tls_server_name|None, cluster_id_в_kubeconfig)``.
"""
cluster_id = _kubeconfig_cluster_name(kube_path, cluster_name)
server, tls_server_name = _web_container_apiserver_endpoint(cluster_name)
p = subprocess.run(
[
"kubectl",
"--kubeconfig",
str(kube_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(
"apply apiserver kubeconfig: set-cluster не удался для %s: %s",
cluster_name,
err[:500],
)
return False, server, tls_server_name, cluster_id
if tls_server_name:
p_tls = subprocess.run(
[
"kubectl",
"--kubeconfig",
str(kube_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(
"apply apiserver kubeconfig: tls-server-name для %s: %s",
cluster_name,
err_tls[:400],
)
else:
subprocess.run(
[
"kubectl",
"--kubeconfig",
str(kube_path),
"config",
"unset",
f"clusters.{cluster_id}.tls-server-name",
],
capture_output=True,
text=True,
)
return True, server, tls_server_name, cluster_id
def patch_kubeconfig_server_for_docker_network(
*,
cluster_name: str,
kube_path: Path,
) -> bool:
"""
Kubeconfig для **kubectl из произвольного контейнера** (не обязательно в сети kind).
При успешном ``docker port … 6443/tcp``: ``server=https://<шлюз>:<порт>`` (по умолчанию
**host.docker.internal**, см. ``KIND_K8S_APISERVER_GATEWAY_HOST``) и **tls-server-name**
(по умолчанию **localhost**, ``KIND_K8S_KUBECONFIG_TLS_SERVER_NAME``) — как у
:func:`kubeconfig_path_for_container_kubectl`. Трафик идёт на хост и в проброшенный порт
control plane, DNS **имени узла kind не нужен**.
Если порт с хоста не получить — запасной ``https://<имя>-control-plane:6443`` без SNI
(нужна общая Docker-сеть с узлом kind).
"""
ok, server, tls_sni, cluster_id = apply_apiserver_endpoint_to_kubeconfig_file(
cluster_name=cluster_name,
kube_path=kube_path,
)
if ok:
logger.info(
"kubeconfig для стороннего контейнера: %s tls-server-name=%s (cluster=%s)",
server,
tls_sni or "",
cluster_id,
)
return ok
def apiserver_url_for_web_container_process(cluster_name: str) -> str: def apiserver_url_for_web_container_process(cluster_name: str) -> str:
"""Только URL apiserver для ``kubectl`` в контейнере веб-приложения (см. :func:`_web_container_apiserver_endpoint`).""" """Только URL apiserver для ``kubectl`` в контейнере веб-приложения (см. :func:`_web_container_apiserver_endpoint`)."""
url, _tls = _web_container_apiserver_endpoint(cluster_name) url, _tls = _web_container_apiserver_endpoint(cluster_name)
@@ -278,53 +386,14 @@ def kubeconfig_path_for_container_kubectl(*, cluster_name: str, kube_source_path
logger.warning("kubeconfig runtime: не удалось скопировать %s: %s", src, e) logger.warning("kubeconfig runtime: не удалось скопировать %s: %s", src, e)
return kube_source_path return kube_source_path
cluster_id = _kubeconfig_cluster_name(tmp_path, cluster_name) ok, server, tls_server_name, cluster_id = apply_apiserver_endpoint_to_kubeconfig_file(
server, tls_server_name = _web_container_apiserver_endpoint(cluster_name) cluster_name=cluster_name,
p = subprocess.run( kube_path=tmp_path,
[
"kubectl",
"--kubeconfig",
str(tmp_path),
"config",
"set-cluster",
cluster_id,
f"--server={server}",
],
capture_output=True,
text=True,
) )
if p.returncode != 0: if not ok:
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) tmp_path.unlink(missing_ok=True)
return kube_source_path 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) prev = _kubectl_runtime_cache.pop(key, None)
if prev is not None: if prev is not None:
try: try:
+3 -5
View File
@@ -419,10 +419,8 @@ class MetricsServerInstallRequest(BaseModel):
class IstioKialiInstallRequest(BaseModel): class IstioKialiInstallRequest(BaseModel):
"""Тело POST …/addons/istio-kiali — Istio + Kiali со входом по логину.""" """Тело POST …/addons/istio-kiali — Istio + Kiali; Kiali по умолчанию auth anonymous (без логина в UI/API)."""
kiali_username: str = Field(default="kiali-admin", min_length=1, max_length=256)
kiali_password: str = Field(..., min_length=8, max_length=256)
istio_chart_version: str | None = Field( istio_chart_version: str | None = Field(
default=None, default=None,
max_length=64, max_length=64,
@@ -438,8 +436,8 @@ class IstioKialiInstallRequest(BaseModel):
max_length=_VALUES_YAML_MAX, max_length=_VALUES_YAML_MAX,
description=( description=(
"Полный YAML values чарта **kiali-server** (``helm show values``). " "Полный YAML values чарта **kiali-server** (``helm show values``). "
"Пусто — без файла ``-f`` (дефолты чарта). Секрет логина/пароля из формы отдельно; " "Пусто — без файла ``-f`` (дефолты чарта). Если не задан ``auth.strategy`` в YAML, "
"``auth.strategy=login`` через ``--set``." "сервер выставляет ``anonymous`` (см. документацию Kiali; стратегия ``login`` удалена)."
), ),
) )
istio_base_values_yaml: str | None = Field( istio_base_values_yaml: str | None = Field(
+17 -15
View File
@@ -464,7 +464,13 @@
if (ta) ta.value = ""; if (ta) ta.value = "";
} }
function showAddonValuesPanel(addon) { /**
* Показать панель YAML values. Для установленного аддона при подгрузке values — блок <details> свёрнут.
* @param {string} addon
* @param {{ collapseDetails?: boolean } | undefined} opts
*/
function showAddonValuesPanel(addon, opts) {
opts = opts || {};
var pid = ADDON_VALUES_PANEL_ID[addon]; var pid = ADDON_VALUES_PANEL_ID[addon];
if (!pid) return; if (!pid) return;
var panel = document.getElementById(pid); var panel = document.getElementById(pid);
@@ -472,6 +478,10 @@
panel.classList.remove("hidden"); panel.classList.remove("hidden");
panel.removeAttribute("hidden"); panel.removeAttribute("hidden");
panel.setAttribute("aria-hidden", "false"); panel.setAttribute("aria-hidden", "false");
var det = panel.querySelector("details.cluster-addon-card__values-details");
if (det) {
det.open = opts.collapseDetails ? false : true;
}
} }
function resetAllAddonValuesPanels() { function resetAllAddonValuesPanels() {
@@ -532,7 +542,7 @@
var t = document.getElementById("addons-values-yaml-" + addon); var t = document.getElementById("addons-values-yaml-" + addon);
if (t) t.value = r.values_yaml || ""; if (t) t.value = r.values_yaml || "";
} }
showAddonValuesPanel(addon); showAddonValuesPanel(addon, { collapseDetails: addonEndpointInstalled(addon) });
} catch (e) { } catch (e) {
setBootstrapMsg("Не удалось загрузить values (helm show values): " + e.message); setBootstrapMsg("Не удалось загрузить values (helm show values): " + e.message);
} finally { } finally {
@@ -674,7 +684,7 @@
} else if (iv.ingress_nginx && iv.ingress_nginx.values_yaml) { } else if (iv.ingress_nginx && iv.ingress_nginx.values_yaml) {
var ti = document.getElementById("addons-values-yaml-ingress-nginx"); var ti = document.getElementById("addons-values-yaml-ingress-nginx");
if (ti) ti.value = iv.ingress_nginx.values_yaml; if (ti) ti.value = iv.ingress_nginx.values_yaml;
showAddonValuesPanel("ingress-nginx"); showAddonValuesPanel("ingress-nginx", { collapseDetails: true });
} }
if (!st.kube_prometheus_stack) { if (!st.kube_prometheus_stack) {
@@ -682,7 +692,7 @@
} else if (iv.kube_prometheus_stack && iv.kube_prometheus_stack.values_yaml) { } else if (iv.kube_prometheus_stack && iv.kube_prometheus_stack.values_yaml) {
var tp = document.getElementById("addons-values-yaml-kube-prometheus-stack"); var tp = document.getElementById("addons-values-yaml-kube-prometheus-stack");
if (tp) tp.value = iv.kube_prometheus_stack.values_yaml; if (tp) tp.value = iv.kube_prometheus_stack.values_yaml;
showAddonValuesPanel("kube-prometheus-stack"); showAddonValuesPanel("kube-prometheus-stack", { collapseDetails: true });
} }
if (!st.metrics_server) { if (!st.metrics_server) {
@@ -690,7 +700,7 @@
} else if (iv.metrics_server && iv.metrics_server.values_yaml) { } else if (iv.metrics_server && iv.metrics_server.values_yaml) {
var tm = document.getElementById("addons-values-yaml-metrics-server"); var tm = document.getElementById("addons-values-yaml-metrics-server");
if (tm) tm.value = iv.metrics_server.values_yaml; if (tm) tm.value = iv.metrics_server.values_yaml;
showAddonValuesPanel("metrics-server"); showAddonValuesPanel("metrics-server", { collapseDetails: true });
} }
if (!st.istio_mesh_ready) { if (!st.istio_mesh_ready) {
@@ -710,7 +720,7 @@
if (tiod) tiod.value = ik.istiod_values_yaml; if (tiod) tiod.value = ik.istiod_values_yaml;
} }
if (ik.kiali_values_yaml || ik.istio_base_values_yaml || ik.istiod_values_yaml) { if (ik.kiali_values_yaml || ik.istio_base_values_yaml || ik.istiod_values_yaml) {
showAddonValuesPanel("istio-kiali"); showAddonValuesPanel("istio-kiali", { collapseDetails: true });
} }
} }
} }
@@ -1002,17 +1012,9 @@
return; return;
} }
if (ep === "istio-kiali") { if (ep === "istio-kiali") {
const u = document.getElementById("addons-kiali-user");
const p = document.getElementById("addons-kiali-pass");
const isel = document.getElementById("addons-istio-version-select"); const isel = document.getElementById("addons-istio-version-select");
const ksel = document.getElementById("addons-kiali-version-select"); const ksel = document.getElementById("addons-kiali-version-select");
const user = u ? u.value.trim() : "kiali-admin"; const payload = {};
const pw = p ? p.value : "";
if (pw.length < 8) {
alert("Задайте пароль Kiali не короче 8 символов.");
return;
}
const payload = { kiali_username: user, kiali_password: pw };
if (isel && isel.value.trim()) payload.istio_chart_version = isel.value.trim(); if (isel && isel.value.trim()) payload.istio_chart_version = isel.value.trim();
if (ksel && ksel.value.trim()) payload.kiali_chart_version = ksel.value.trim(); if (ksel && ksel.value.trim()) payload.kiali_chart_version = ksel.value.trim();
if (!attachAddonYamlToPayload(ep, payload)) return; if (!attachAddonYamlToPayload(ep, payload)) return;
+33 -1
View File
@@ -250,6 +250,20 @@
document.body.removeChild(a); document.body.removeChild(a);
} }
/**
* Скачать kubeconfig для kubectl из другого контейнера: host.docker.internal:порт + tls-server-name (см. GET /kubeconfig/docker).
* @param {string} clusterName
*/
function downloadKubeconfigDocker(clusterName) {
const url = API + "/clusters/" + encodeURIComponent(clusterName) + "/kubeconfig/docker";
const a = document.createElement("a");
a.href = url;
a.download = "kubeconfig-docker-" + clusterName + ".yaml";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
/** SVG-иконки действий (stroke, currentColor). */ /** SVG-иконки действий (stroke, currentColor). */
var ICONS = { var ICONS = {
state: state:
@@ -260,6 +274,9 @@
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="5" y="5" width="14" height="14" rx="2"/></svg>', '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="5" y="5" width="14" height="14" rx="2"/></svg>',
download: download:
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>', '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>',
/** kubeconfig для контейнера: apiserver по имени control-plane в сети Docker */
kubeDocker:
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>',
trash: trash:
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>', '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>',
logFile: logFile:
@@ -1519,7 +1536,7 @@
iconActionButton( iconActionButton(
ICONS.download, ICONS.download,
c.has_local_kubeconfig c.has_local_kubeconfig
? "Скачать kubeconfig для kubectl на хосте" ? "Скачать kubeconfig для kubectl на хосте (localhost:порт)"
: "Kubeconfig ещё нет — появится после создания или подъёма кластера", : "Kubeconfig ещё нет — появится после создания или подъёма кластера",
"icon-btn--secondary", "icon-btn--secondary",
c.has_local_kubeconfig c.has_local_kubeconfig
@@ -1530,6 +1547,21 @@
!c.has_local_kubeconfig, !c.has_local_kubeconfig,
), ),
); );
host.appendChild(
iconActionButton(
ICONS.kubeDocker,
c.has_local_kubeconfig
? "Скачать kubeconfig для kubectl из другого контейнера: host.docker.internal:порт + tls-server-name (как в веб-UI; на Linux — extra_hosts host-gateway)"
: "Kubeconfig ещё нет — появится после создания или подъёма кластера",
"icon-btn--secondary",
c.has_local_kubeconfig
? function () {
downloadKubeconfigDocker(name);
}
: null,
!c.has_local_kubeconfig,
),
);
if (c.has_provision_log) { if (c.has_provision_log) {
host.appendChild( host.appendChild(
iconActionButton( iconActionButton(
+56 -1
View File
@@ -88,6 +88,61 @@
return "journal-status-pill"; return "journal-status-pill";
} }
/** Суффикс job_kind (после helm_addon_install_|upgrade_|delete_) → id аддона в API. */
function helmJobSlugToAddonId(slug) {
const m = {
ingress_nginx: "ingress-nginx",
kube_prometheus_stack: "kube-prometheus-stack",
metrics_server: "metrics-server",
istio_kiali: "istio-kiali",
};
return m[slug] || String(slug || "").replace(/_/g, "-") || "—";
}
function helmAddonTitle(id) {
const m = {
"ingress-nginx": "ingress-nginx",
"kube-prometheus-stack": "kube-prometheus-stack",
"metrics-server": "metrics-server",
"istio-kiali": "Istio + Kiali",
};
return m[id] || id || "—";
}
function helmOperationTitle(op) {
const o = (op || "").toLowerCase();
if (o === "install") return "Установка";
if (o === "upgrade") return "Обновление";
if (o === "delete") return "Удаление";
return op || "—";
}
/**
* Колонка «Вид операции» для режима Helm: установка / обновление / удаление и имя аддона.
* Старые записи без result.action разбираются по префиксу kind.
*/
function formatHelmJournalKind(e) {
const r = e.result;
if (r && typeof r === "object") {
const verb = helmOperationTitle(r.action);
const ad = r.addon != null ? helmAddonTitle(String(r.addon)) : "—";
if (r.action && r.addon != null && String(r.addon).length) {
return verb + " · " + ad;
}
}
const k = e.kind || "";
if (k.indexOf("helm_addon_delete_") === 0) {
return "Удаление · " + helmAddonTitle(helmJobSlugToAddonId(k.replace(/^helm_addon_delete_/, "")));
}
if (k.indexOf("helm_addon_upgrade_") === 0) {
return "Обновление · " + helmAddonTitle(helmJobSlugToAddonId(k.replace(/^helm_addon_upgrade_/, "")));
}
if (k.indexOf("helm_addon_install_") === 0) {
return "Установка · " + helmAddonTitle(helmJobSlugToAddonId(k.replace(/^helm_addon_install_/, "")));
}
return k || "—";
}
function visiblePageItems(current, total) { function visiblePageItems(current, total) {
const delta = 2; const delta = 2;
const range = []; const range = [];
@@ -200,7 +255,7 @@
const finished = formatUtc( const finished = formatUtc(
e.finished_at_utc || e.created_at_utc, e.finished_at_utc || e.created_at_utc,
); );
const kind = e.kind || ""; const kind = mode === "helm_addons" ? formatHelmJournalKind(e) : (e.kind || "");
const msg = (e.message || "").trim(); const msg = (e.message || "").trim();
let lines = []; let lines = [];
if (mode === "per_cluster") { if (mode === "per_cluster") {
+8 -15
View File
@@ -77,7 +77,7 @@
</div> </div>
</div> </div>
<div id="addons-values-panel-ingress-nginx" class="cluster-addon-card__values-panel hidden" hidden aria-hidden="true"> <div id="addons-values-panel-ingress-nginx" class="cluster-addon-card__values-panel hidden" hidden aria-hidden="true">
<details class="cluster-addon-card__values-details" open> <details class="cluster-addon-card__values-details">
<summary class="cluster-addon-card__values-summary">Values чарта (YAML)</summary> <summary class="cluster-addon-card__values-summary">Values чарта (YAML)</summary>
<p class="muted cluster-addon-card__values-hint"> <p class="muted cluster-addon-card__values-hint">
Содержимое из <code>helm show values</code> плюс сервис для kind. После <code>-f</code> Helm применяет <code>--set</code> для NodePort. Нажмите «Загрузить values» для выбранной версии. Содержимое из <code>helm show values</code> плюс сервис для kind. После <code>-f</code> Helm применяет <code>--set</code> для NodePort. Нажмите «Загрузить values» для выбранной версии.
@@ -126,7 +126,7 @@
</div> </div>
</div> </div>
<div id="addons-values-panel-kube-prometheus-stack" class="cluster-addon-card__values-panel hidden" hidden aria-hidden="true"> <div id="addons-values-panel-kube-prometheus-stack" class="cluster-addon-card__values-panel hidden" hidden aria-hidden="true">
<details class="cluster-addon-card__values-details" open> <details class="cluster-addon-card__values-details">
<summary class="cluster-addon-card__values-summary">Values чарта (YAML)</summary> <summary class="cluster-addon-card__values-summary">Values чарта (YAML)</summary>
<p class="muted cluster-addon-card__values-hint"> <p class="muted cluster-addon-card__values-hint">
Вывод <code>helm show values</code> с подстановкой логина и пароля Grafana из полей выше на момент нажатия «Загрузить values». Пустой textarea при установке — сервер соберёт values сам. Вывод <code>helm show values</code> с подстановкой логина и пароля Grafana из полей выше на момент нажатия «Загрузить values». Пустой textarea при установке — сервер соберёт values сам.
@@ -167,7 +167,7 @@
</div> </div>
</div> </div>
<div id="addons-values-panel-metrics-server" class="cluster-addon-card__values-panel hidden" hidden aria-hidden="true"> <div id="addons-values-panel-metrics-server" class="cluster-addon-card__values-panel hidden" hidden aria-hidden="true">
<details class="cluster-addon-card__values-details" open> <details class="cluster-addon-card__values-details">
<summary class="cluster-addon-card__values-summary">Values чарта (YAML)</summary> <summary class="cluster-addon-card__values-summary">Values чарта (YAML)</summary>
<p class="muted cluster-addon-card__values-hint"> <p class="muted cluster-addon-card__values-hint">
<code>helm show values</code> плюс аргументы для kind (<code>--kubelet-insecure-tls</code> и т.д.). Пустое поле при установке — автосборка на сервере. <code>helm show values</code> плюс аргументы для kind (<code>--kubelet-insecure-tls</code> и т.д.). Пустое поле при установке — автосборка на сервере.
@@ -194,18 +194,11 @@
<span class="cluster-addon-card__badge cluster-addon-card__badge--off" role="status">Не установлен</span> <span class="cluster-addon-card__badge cluster-addon-card__badge--off" role="status">Не установлен</span>
</header> </header>
<p class="muted cluster-addon-card__desc"> <p class="muted cluster-addon-card__desc">
Чарты <code>istio/base</code>, <code>istio/istiod</code> и <code>kiali-server</code> в <code>istio-system</code>. Вход в Kiali — стратегия Чарты <code>istio/base</code>, <code>istio/istiod</code> и <code>kiali-server</code> в <code>istio-system</code>. Kiali по умолчанию —
<strong>login</strong> (секрет с логином и паролем). <strong>anonymous</strong> (без экрана входа; уместно для kind и <code>kubectl port-forward</code>). Для
<strong>token</strong> / <strong>openid</strong> задайте <code>auth.strategy</code> в YAML values kiali-server.
</p> </p>
<div class="create-form-grid cluster-addon-istio-grid"> <div class="create-form-grid cluster-addon-istio-grid">
<div class="create-form-field">
<label for="addons-kiali-user">Имя пользователя Kiali</label>
<input id="addons-kiali-user" type="text" value="kiali-admin" maxlength="256" autocomplete="username" />
</div>
<div class="create-form-field">
<label for="addons-kiali-pass">Пароль Kiali</label>
<input id="addons-kiali-pass" type="password" minlength="8" maxlength="256" autocomplete="new-password" placeholder="не менее 8 символов" />
</div>
<div class="create-form-field"> <div class="create-form-field">
<label for="addons-istio-version-select">Версия чартов Istio (base + istiod)</label> <label for="addons-istio-version-select">Версия чартов Istio (base + istiod)</label>
<select id="addons-istio-version-select" class="cluster-addons-version-select"> <select id="addons-istio-version-select" class="cluster-addons-version-select">
@@ -226,10 +219,10 @@
</div> </div>
</div> </div>
<div id="addons-values-panel-istio-kiali" class="cluster-addon-card__values-panel hidden" hidden aria-hidden="true"> <div id="addons-values-panel-istio-kiali" class="cluster-addon-card__values-panel hidden" hidden aria-hidden="true">
<details class="cluster-addon-card__values-details" open> <details class="cluster-addon-card__values-details">
<summary class="cluster-addon-card__values-summary">Values чартов Istio и Kiali (YAML)</summary> <summary class="cluster-addon-card__values-summary">Values чартов Istio и Kiali (YAML)</summary>
<p class="muted cluster-addon-card__values-hint"> <p class="muted cluster-addon-card__values-hint">
Три блока из <code>helm show values</code> для выбранных версий Istio и Kiali. Логин и пароль Kiali задаются в секрете при установке; здесь — только шаблон values чартов. Три блока из <code>helm show values</code> для выбранных версий Istio и Kiali. Секрет логина больше не создаётся; при необходимости настройте <code>auth</code> в блоке kiali-server.
</p> </p>
<p class="cluster-addon-card__values-subhead">kiali-server</p> <p class="cluster-addon-card__values-subhead">kiali-server</p>
<label for="addons-values-yaml-istio-kiali" class="sr-only">YAML values kiali-server</label> <label for="addons-values-yaml-istio-kiali" class="sr-only">YAML values kiali-server</label>
+2
View File
@@ -55,6 +55,8 @@ services:
KIND_K8S_KUBECONFIG_CLIENT_HOST: ${KIND_K8S_KUBECONFIG_CLIENT_HOST:-localhost} KIND_K8S_KUBECONFIG_CLIENT_HOST: ${KIND_K8S_KUBECONFIG_CLIENT_HOST:-localhost}
# Для kubectl внутри контейнера при server=host.docker.internal: SAN kind — localhost (редко менять). # Для kubectl внутри контейнера при server=host.docker.internal: SAN kind — localhost (редко менять).
KIND_K8S_KUBECONFIG_TLS_SERVER_NAME: ${KIND_K8S_KUBECONFIG_TLS_SERVER_NAME:-localhost} KIND_K8S_KUBECONFIG_TLS_SERVER_NAME: ${KIND_K8S_KUBECONFIG_TLS_SERVER_NAME:-localhost}
# Хост в server= для GET …/kubeconfig/docker и для kubectl в контейнере (проброс порта на хост); пусто — host.docker.internal.
KIND_K8S_APISERVER_GATEWAY_HOST: ${KIND_K8S_APISERVER_GATEWAY_HOST:-}
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:-}
+1
View File
@@ -16,6 +16,7 @@ KIND_K8S_WEB_PORT
KIND_K8S_PATCH_KUBECONFIG KIND_K8S_PATCH_KUBECONFIG
KIND_K8S_KUBECONFIG_CLIENT_HOST KIND_K8S_KUBECONFIG_CLIENT_HOST
KIND_K8S_KUBECONFIG_TLS_SERVER_NAME KIND_K8S_KUBECONFIG_TLS_SERVER_NAME
KIND_K8S_APISERVER_GATEWAY_HOST
CONTAINER_CLI CONTAINER_CLI
KIND_K8S_SKIP_VERSION_LIST KIND_K8S_SKIP_VERSION_LIST
KIND_K8S_VERSION_LIST_DISPLAY KIND_K8S_VERSION_LIST_DISPLAY