From 46a744954348b11f201572d82ea9747b52768f79 Mon Sep 17 00:00:00 2001 From: Sergey Antropoff Date: Sat, 4 Apr 2026 18:54:10 +0300 Subject: [PATCH] =?UTF-8?q?Kiali=20anonymous,=20=D0=B6=D1=83=D1=80=D0=BD?= =?UTF-8?q?=D0=B0=D0=BB=20Helm,=20kubeconfig=20=D0=B4=D0=BB=D1=8F=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BD=D1=82=D0=B5=D0=B9=D0=BD=D0=B5=D1=80=D0=BE=D0=B2,?= =?UTF-8?q?=20UI=20=D0=B0=D0=B4=D0=B4=D0=BE=D0=BD=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 обновлены --- README.md | 14 ++- app/api/v1/endpoints/cluster_addons.py | 88 +++++++++++--- app/api/v1/endpoints/clusters.py | 56 ++++++++- app/core/helm_addons.py | 43 +++---- app/docs/api_routes.md | 25 +++- app/kubeconfig_patch.py | 155 ++++++++++++++++++------- app/models/schemas.py | 8 +- app/static/js/cluster_addons.js | 32 ++--- app/static/js/dashboard.js | 34 +++++- app/static/js/journal.js | 57 ++++++++- app/templates/cluster_addons.html | 23 ++-- docker-compose.yml | 2 + env.example | 1 + 13 files changed, 415 insertions(+), 123 deletions(-) diff --git a/README.md b/README.md index de12b12..9181f62 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ - **Статистика**: число кластеров в kind, локальных каталогов, сумма workers из `meta.json`, счётчики фоновых заданий. - **Создание кластера**: форма с подсказкой тегов `kindest/node` (`GET /api/v1/versions`), фоновое задание и опрос статуса (JSON в сворачиваемом блоке). - **Кластеры** (`/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`** (из памяти удаляются только завершённые задания). - **Автообновление** таблиц и плашки среды каждые ~3,5 с (fetch к API без перезагрузки страницы). - При активном задании — **прогресс-бар**, **журнал** (в т.ч. скачивание образа; для **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`** внутри уже запущенного сервиса. -Файл **`clusters/<имя>/kubeconfig.host`** (или скачивание из веб-UI) для kubectl на хосте: **`https://:<порт>`** — см. `app/kubeconfig_patch.py`. Переменная позволяет задать IP/имя хоста, если `localhost` недоступен с вашей машины. +Файл **`clusters/<имя>/kubeconfig.host`** (или скачивание из веб-UI — кнопка со стрелкой вниз) для kubectl на хосте: **`https://:<порт>`** — см. `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`** без пересборки образа). После создания кластера при **`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 diff --git a/app/api/v1/endpoints/cluster_addons.py b/app/api/v1/endpoints/cluster_addons.py index ef15eac..b9ea44b 100644 --- a/app/api/v1/endpoints/cluster_addons.py +++ b/app/api/v1/endpoints/cluster_addons.py @@ -97,11 +97,63 @@ def _parse_optional_values_yaml(raw: str | None) -> dict[str, Any] | None: 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( cluster_name: str, job_kind: str, result_meta: dict[str, Any], sync_call: Callable[[], tuple[bool, str]], + *, + journal_message: str | None = None, ) -> str: """ Выполнить операцию под блокировкой kind, добавить запись в историю ``helm_addon_log.json``. @@ -118,7 +170,7 @@ async def _run_helm_with_journal( job_id=str(uuid.uuid4()), job_kind=job_kind, status="success", - message=None, + message=journal_message, lines=log.splitlines() if log else [], 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(): ver = body.chart_version.strip() 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: log = await _run_helm_with_journal( n, - "helm_addon_install_ingress_nginx", + job_kind, meta, lambda: install_ingress_nginx(n, ver, user_v), + journal_message=_helm_journal_message_ru("ingress-nginx", action), ) except HelmAddonError as 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", meta, lambda: uninstall_ingress_nginx(n), + journal_message=_helm_journal_message_ru("ingress-nginx", "delete"), ) except HelmAddonError as 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() u = body.grafana_admin_user.strip() user_v = _parse_optional_values_yaml(body.values_yaml) + action, job_kind = await _helm_install_action_and_job_kind(n, "kube-prometheus-stack") meta = { "addon": "kube-prometheus-stack", - "action": "install", + "action": action, "chart_version": prom_ver, "grafana_admin_user": u, } try: log = await _run_helm_with_journal( n, - "helm_addon_install_kube_prometheus_stack", + job_kind, meta, lambda: install_kube_prometheus_stack( n, @@ -315,6 +371,7 @@ async def post_install_kube_prometheus_stack( chart_version=prom_ver, user_values=user_v, ), + journal_message=_helm_journal_message_ru("kube-prometheus-stack", action), ) except HelmAddonError as 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", meta, lambda: uninstall_kube_prometheus_stack(n), + journal_message=_helm_journal_message_ru("kube-prometheus-stack", "delete"), ) except HelmAddonError as 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(): ms_ver = body.chart_version.strip() 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: log = await _run_helm_with_journal( n, - "helm_addon_install_metrics_server", + job_kind, meta, lambda: install_metrics_server(n, ms_ver, user_v), + journal_message=_helm_journal_message_ru("metrics-server", action), ) except HelmAddonError as 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", meta, lambda: uninstall_metrics_server(n), + journal_message=_helm_journal_message_ru("metrics-server", "delete"), ) except HelmAddonError as e: raise _map_helm_error(e) from e @@ -399,7 +460,7 @@ async def delete_metrics_server(name: str) -> HelmAddonApplyResponse: @router.post( "/clusters/{name}/addons/istio-kiali", response_model=HelmAddonApplyResponse, - summary="Установить Istio (base+istiod) и Kiali с логином", + summary="Установить Istio (base+istiod) и Kiali (по умолчанию auth anonymous)", ) async def post_install_istio_kiali( name: str, @@ -414,32 +475,30 @@ async def post_install_istio_kiali( kiali_ver: str | None = None if body.kiali_chart_version and 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) istio_base_v = _parse_optional_values_yaml(body.istio_base_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 = { "addon": "istio-kiali", - "action": "install", + "action": action, "istio_chart_version": istio_ver, "kiali_chart_version": kiali_ver, - "kiali_username": ku, } try: log = await _run_helm_with_journal( n, - "helm_addon_install_istio_kiali", + job_kind, meta, lambda: install_istio_and_kiali( n, - kiali_username=ku, - kiali_password=body.kiali_password, istio_chart_version=istio_ver, kiali_chart_version=kiali_ver, istio_base_values=istio_base_v, istiod_values=istiod_v, kiali_user_values=kiali_uv, ), + journal_message=_helm_journal_message_ru("istio-kiali", action), ) except HelmAddonError as 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", meta, lambda: uninstall_istio_and_kiali(n), + journal_message=_helm_journal_message_ru("istio-kiali", "delete"), ) except HelmAddonError as e: raise _map_helm_error(e) from e diff --git a/app/api/v1/endpoints/clusters.py b/app/api/v1/endpoints/clusters.py index 582a07d..deab0ea 100644 --- a/app/api/v1/endpoints/clusters.py +++ b/app/api/v1/endpoints/clusters.py @@ -66,7 +66,11 @@ from core.job_store import ( from core.provision_log import provision_log_file_path, write_cluster_provision_log from core.kind_guard import kind_cluster_lock 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 ( AggregateResourcesSummary, 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( "/clusters/{name}/kubeconfig", summary="Скачать kubeconfig", diff --git a/app/core/helm_addons.py b/app/core/helm_addons.py index 2e38b11..fa93ac8 100644 --- a/app/core/helm_addons.py +++ b/app/core/helm_addons.py @@ -754,11 +754,23 @@ def uninstall_metrics_server(cluster_name: str) -> tuple[bool, str]: 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( cluster_name: str, *, - kiali_username: str, - kiali_password: str, istio_chart_version: str | None = None, kiali_chart_version: str | 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, ) -> 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`` (дефолты чарта); если непустой dict — полный values как в редакторе. @@ -826,22 +842,6 @@ def install_istio_and_kiali( if istiod_tmp is not None: 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_tmp: Path | None = None try: @@ -851,13 +851,16 @@ def install_istio_and_kiali( kiali_tmp = Path(name) kiali_tmp.write_text(dump_helm_values_yaml(kiali_user_values), encoding="utf-8") 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( cluster_name, "kiali-server", "kiali/kiali-server", "istio-system", chart_version=kiali_ver, - extra_args=["--set", "auth.strategy=login"], + extra_args=kiali_helm_extra or None, extra_values_files=kiali_extra or None, ) finally: diff --git a/app/docs/api_routes.md b/app/docs/api_routes.md index 6956695..241a48f 100644 --- a/app/docs/api_routes.md +++ b/app/docs/api_routes.md @@ -52,6 +52,7 @@ | GET | `/api/v1/clusters/{name}/config` | Текущие `meta.json` и текст `kind-config.yaml`; подсказка `kind_note` про пересоздание | | PUT | `/api/v1/clusters/{name}/config` | Сохранить YAML и/или meta (простой режим или полный YAML); ответ включает обновлённый `summary` | | 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}/workloads` | Узлы и поды (`kubectl`) | | GET | `/api/v1/clusters/{name}/overview` | Сводка для страницы UI: метрики узлов, агрегаты, блоки **`k8s_*`** (JSON из `kubectl get … -o json`) | @@ -79,7 +80,7 @@ | DELETE | `/api/v1/jobs` | Удалить из памяти **завершённые** задания (`removed` — число записей) | | 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` @@ -105,7 +106,7 @@ - **ingress-nginx** — один файл **`-f`** с полным values; затем **`--set`** NodePort (сильнее файла). - **kube-prometheus-stack** — один файл: либо из **`values_yaml`**, либо автосборка из версии и полей Grafana. - **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`):** @@ -120,12 +121,10 @@ **Пример ответа 200 (фрагмент):** поле **`values_yaml`** — многострочная строка с полным YAML чарта. -**Пример POST установки istio-kiali с тремя YAML:** +**Пример POST установки istio-kiali с тремя YAML** (Kiali с **token**, если задано в `values_yaml`; иначе будет **anonymous**): ```json { - "kiali_username": "kiali-admin", - "kiali_password": "MySecurePwd12", "istio_chart_version": "1.22.0", "kiali_chart_version": "1.89.0", "istio_base_values_yaml": "defaultRevision: default\n", @@ -140,7 +139,7 @@ - В памяти держится не более **200** записей; при превышении старые задания вытесняются (`app/core/job_store.py`). - Снимок заданий сохраняется в 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`** (режим «По кластеру»). -- Операции **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). - В ответе задания поля **`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**). @@ -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://:<порт>`** (по умолчанию **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 Содержимое **`clusters/<имя>/provision_log.json`**: полный журнал строк последнего завершённого задания **create_cluster**, **start_cluster** или **start_cluster_reapply** (включая успех, ошибку и отмену), плюс метаданные (`job_id`, `kind`, `status`, `message`, `result`, `finished_at_utc`). diff --git a/app/kubeconfig_patch.py b/app/kubeconfig_patch.py index 1d1f6b9..f7faa88 100644 --- a/app/kubeconfig_patch.py +++ b/app/kubeconfig_patch.py @@ -14,6 +14,10 @@ Kind внутри Docker видит другой адрес apiserver; для ** Запасной URL: ``https://<имя>-control-plane:6443`` (без доп. SNI). - ``kubeconfig.host`` — копия с ``server=`` для kubectl на хосте (см. :func:`kubeconfig_download_client_hostname`). +- Скачивание через API **``GET …/kubeconfig/docker``** — тот же endpoint, что для kubectl в веб-контейнере: + ``https://:<порт>`` (по умолчанию **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``. @@ -230,6 +234,110 @@ def _web_container_apiserver_endpoint(cluster_name: str) -> tuple[str, str | Non 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: """Только URL apiserver для ``kubectl`` в контейнере веб-приложения (см. :func:`_web_container_apiserver_endpoint`).""" 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) 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, + ok, server, tls_server_name, cluster_id = apply_apiserver_endpoint_to_kubeconfig_file( + cluster_name=cluster_name, + kube_path=tmp_path, ) - 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], - ) + if not ok: 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: diff --git a/app/models/schemas.py b/app/models/schemas.py index cd76e84..7441f42 100644 --- a/app/models/schemas.py +++ b/app/models/schemas.py @@ -419,10 +419,8 @@ class MetricsServerInstallRequest(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( default=None, max_length=64, @@ -438,8 +436,8 @@ class IstioKialiInstallRequest(BaseModel): max_length=_VALUES_YAML_MAX, description=( "Полный YAML values чарта **kiali-server** (``helm show values``). " - "Пусто — без файла ``-f`` (дефолты чарта). Секрет логина/пароля из формы отдельно; " - "``auth.strategy=login`` через ``--set``." + "Пусто — без файла ``-f`` (дефолты чарта). Если не задан ``auth.strategy`` в YAML, " + "сервер выставляет ``anonymous`` (см. документацию Kiali; стратегия ``login`` удалена)." ), ) istio_base_values_yaml: str | None = Field( diff --git a/app/static/js/cluster_addons.js b/app/static/js/cluster_addons.js index 772dd28..08db3fd 100644 --- a/app/static/js/cluster_addons.js +++ b/app/static/js/cluster_addons.js @@ -464,7 +464,13 @@ if (ta) ta.value = ""; } - function showAddonValuesPanel(addon) { + /** + * Показать панель YAML values. Для установленного аддона при подгрузке values — блок
свёрнут. + * @param {string} addon + * @param {{ collapseDetails?: boolean } | undefined} opts + */ + function showAddonValuesPanel(addon, opts) { + opts = opts || {}; var pid = ADDON_VALUES_PANEL_ID[addon]; if (!pid) return; var panel = document.getElementById(pid); @@ -472,6 +478,10 @@ panel.classList.remove("hidden"); panel.removeAttribute("hidden"); 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() { @@ -532,7 +542,7 @@ var t = document.getElementById("addons-values-yaml-" + addon); if (t) t.value = r.values_yaml || ""; } - showAddonValuesPanel(addon); + showAddonValuesPanel(addon, { collapseDetails: addonEndpointInstalled(addon) }); } catch (e) { setBootstrapMsg("Не удалось загрузить values (helm show values): " + e.message); } finally { @@ -674,7 +684,7 @@ } else if (iv.ingress_nginx && iv.ingress_nginx.values_yaml) { var ti = document.getElementById("addons-values-yaml-ingress-nginx"); if (ti) ti.value = iv.ingress_nginx.values_yaml; - showAddonValuesPanel("ingress-nginx"); + showAddonValuesPanel("ingress-nginx", { collapseDetails: true }); } if (!st.kube_prometheus_stack) { @@ -682,7 +692,7 @@ } else if (iv.kube_prometheus_stack && iv.kube_prometheus_stack.values_yaml) { var tp = document.getElementById("addons-values-yaml-kube-prometheus-stack"); if (tp) tp.value = iv.kube_prometheus_stack.values_yaml; - showAddonValuesPanel("kube-prometheus-stack"); + showAddonValuesPanel("kube-prometheus-stack", { collapseDetails: true }); } if (!st.metrics_server) { @@ -690,7 +700,7 @@ } else if (iv.metrics_server && iv.metrics_server.values_yaml) { var tm = document.getElementById("addons-values-yaml-metrics-server"); if (tm) tm.value = iv.metrics_server.values_yaml; - showAddonValuesPanel("metrics-server"); + showAddonValuesPanel("metrics-server", { collapseDetails: true }); } if (!st.istio_mesh_ready) { @@ -710,7 +720,7 @@ if (tiod) tiod.value = 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; } 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 ksel = document.getElementById("addons-kiali-version-select"); - const user = u ? u.value.trim() : "kiali-admin"; - const pw = p ? p.value : ""; - if (pw.length < 8) { - alert("Задайте пароль Kiali не короче 8 символов."); - return; - } - const payload = { kiali_username: user, kiali_password: pw }; + const payload = {}; 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 (!attachAddonYamlToPayload(ep, payload)) return; diff --git a/app/static/js/dashboard.js b/app/static/js/dashboard.js index afbc201..49b2bdf 100644 --- a/app/static/js/dashboard.js +++ b/app/static/js/dashboard.js @@ -250,6 +250,20 @@ 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). */ var ICONS = { state: @@ -260,6 +274,9 @@ '', download: '', + /** kubeconfig для контейнера: apiserver по имени control-plane в сети Docker */ + kubeDocker: + '', trash: '', logFile: @@ -1519,7 +1536,7 @@ iconActionButton( ICONS.download, c.has_local_kubeconfig - ? "Скачать kubeconfig для kubectl на хосте" + ? "Скачать kubeconfig для kubectl на хосте (localhost:порт)" : "Kubeconfig ещё нет — появится после создания или подъёма кластера", "icon-btn--secondary", c.has_local_kubeconfig @@ -1530,6 +1547,21 @@ !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) { host.appendChild( iconActionButton( diff --git a/app/static/js/journal.js b/app/static/js/journal.js index 781a056..e9b8038 100644 --- a/app/static/js/journal.js +++ b/app/static/js/journal.js @@ -88,6 +88,61 @@ 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) { const delta = 2; const range = []; @@ -200,7 +255,7 @@ const finished = formatUtc( 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(); let lines = []; if (mode === "per_cluster") { diff --git a/app/templates/cluster_addons.html b/app/templates/cluster_addons.html index ff34303..26c9e5a 100644 --- a/app/templates/cluster_addons.html +++ b/app/templates/cluster_addons.html @@ -77,7 +77,7 @@