"""Правка kubeconfig kind для доступа к API с хоста (после create из контейнера). Kind внутри Docker видит другой адрес apiserver; для **kubectl на машине пользователя** в ``kubeconfig.host`` подставляется ``https://<клиентский_хост>:<порт>`` (порт из ``docker port -control-plane 6443/tcp``). Хост по умолчанию — **localhost** (часто надёжнее ``127.0.0.1`` с TLS); переопределение: ``KIND_K8S_KUBECONFIG_CLIENT_HOST`` (например IP хоста в LAN). **Два файла в ``clusters/<имя>/``:** - ``kubeconfig`` — как выдал ``kind get kubeconfig``; для ``kubectl`` **в процессе веб-приложения** :func:`kubeconfig_path_for_container_kubectl` задаёт ``server=https://host.docker.internal:<порт>`` и ``tls-server-name=localhost`` (иначе x509 не совпадает с SAN kind); см. ``KIND_K8S_KUBECONFIG_TLS_SERVER_NAME``. Запасной URL: ``https://<имя>-control-plane:6443`` (без доп. SNI). - ``kubeconfig.host`` — копия с ``server=`` для kubectl на хосте (см. :func:`kubeconfig_download_client_hostname`). - Скачивание через 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``. Автор: Сергей Антропов Сайт: https://devops.org.ru """ from __future__ import annotations import logging import os import shutil import subprocess import tempfile import threading from pathlib import Path from kind_k8s_paths import container_cli_name logger = logging.getLogger("kind_k8s.kubeconfig_patch") # Имя файла kubeconfig с server для доступа с хоста (не использовать для kubectl в контейнере веб-приложения). KUBECONFIG_HOST_BASENAME = "kubeconfig.host" def _container_cli() -> str: """CLI для ``port`` к смонтированному сокету (в контейнере веб-UI — ``docker``).""" return container_cli_name() def _control_plane_container_name(cluster_name: str) -> str: return f"{cluster_name}-control-plane" def _parse_docker_port_line(line: str) -> tuple[str, str] | None: """Строка вида '0.0.0.0:32768' или '127.0.0.1:32768' -> (host, port).""" line = line.strip() if ":" not in line: return None # IPv6 [::]:port if line.startswith("["): rb = line.rfind("]") if rb == -1: return None host = line[1:rb] rest = line[rb + 1 :].lstrip(":") if not rest.isdigit(): return None return host, rest host, _, port = line.rpartition(":") if not port.isdigit(): return None host = host.strip() return host, port def _host_bind_for_kubeconfig(host: str) -> str: if host in ("0.0.0.0", "::", ""): return "127.0.0.1" if host == "[::]": return "127.0.0.1" return host def kubeconfig_download_client_hostname() -> str: """ Имя хоста для поля ``server`` в kubeconfig, скачиваемом пользователем. По умолчанию ``localhost`` (совпадает с SAN в сертификате kind чаще, чем проблемный 127.0.0.1). Задаётся ``KIND_K8S_KUBECONFIG_CLIENT_HOST`` (например ``192.168.1.10`` для удалённого клиента). """ raw = (os.environ.get("KIND_K8S_KUBECONFIG_CLIENT_HOST") or "localhost").strip() return raw or "localhost" def get_apiserver_host_port(cluster_name: str) -> tuple[str, str] | None: """Узнать (host, port) привязки проброса 6443/tcp (host из вывода docker port, порт — число).""" cli = _container_cli() ctr = _control_plane_container_name(cluster_name) p = subprocess.run( [cli, "port", ctr, "6443/tcp"], capture_output=True, text=True, ) if p.returncode != 0: logger.info( "Не удалось %s port %s 6443/tcp: %s", cli, ctr, (p.stderr or p.stdout or "").strip(), ) return None for raw in (p.stdout or "").splitlines(): parsed = _parse_docker_port_line(raw) if not parsed: continue h, port = parsed return _host_bind_for_kubeconfig(h), port return None def _kubeconfig_cluster_name(kube_path: Path, logical_name: str) -> str: """Имя блока cluster в kubeconfig (обычно kind-<имя>).""" p = subprocess.run( [ "kubectl", "--kubeconfig", str(kube_path), "config", "view", "-o", 'jsonpath={range .clusters[*]}{.name}{"\n"}{end}', ], capture_output=True, text=True, ) names = [x.strip() for x in (p.stdout or "").splitlines() if x.strip()] want = f"kind-{logical_name}" if want in names: return want for n in names: if n.startswith("kind-"): return n return want def patch_kubeconfig_server_for_host( *, cluster_name: str, kube_path: Path, ) -> bool: """ Подставить в kubeconfig server=https://<хост>:<порт> для доступа с хоста. Порт берётся из ``docker port`` / аналога к сокету; для Podman — тот же клиент при ``DOCKER_HOST`` на podman.sock. """ hp = get_apiserver_host_port(cluster_name) if not hp: print( "Предупреждение: не удалось получить порт apiserver с хоста; " "kubeconfig оставлен как выдал kind (с хоста может не открываться).", ) return False _bind_host, port = hp client_host = kubeconfig_download_client_hostname() server = f"https://{client_host}:{port}" cluster_id = _kubeconfig_cluster_name(kube_path, 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() print(f"Предупреждение: kubectl config set-cluster не удался: {err}") return False print(f"Kubeconfig для хоста: apiserver → {server}") return True def should_patch_after_create() -> bool: """Патчить после create, если задано явно или kind шёл из контейнера.""" if os.environ.get("KIND_K8S_PATCH_KUBECONFIG", "").strip().lower() in ("1", "true", "yes", "да"): return True return os.environ.get("KIND_K8S_IN_CONTAINER", "").strip() == "1" def in_kind_k8s_container() -> bool: """Процесс веб-приложения в Docker/Podman (``KIND_K8S_IN_CONTAINER=1``).""" return os.environ.get("KIND_K8S_IN_CONTAINER", "").strip() == "1" def kubeconfig_host_file(cluster_data_dir: Path) -> Path: """Путь к kubeconfig для скачивания на хост: ``clusters/<имя>/kubeconfig.host``.""" return cluster_data_dir / KUBECONFIG_HOST_BASENAME def apiserver_url_docker_bridge(cluster_name: str) -> str: """ URL apiserver по имени узла kind в общей Docker-сети (если она есть). Стандартное имя контрольной плоскости: ``-control-plane``. """ return f"https://{_control_plane_container_name(cluster_name)}:6443" def _web_container_apiserver_endpoint(cluster_name: str) -> tuple[str, str | None]: """ Пара ``(server URL, tls-server-name)`` для kubeconfig процесса в контейнере веб-UI. Через шлюз (``host.docker.internal:<порт>``) в сертификате kind нет этого имени; задаём ``tls-server-name`` (по умолчанию ``localhost``), которое есть в SAN. Без проброса порта — прямой ``https://<имя>-control-plane:6443``, ``tls-server-name`` не нужен. """ hp = get_apiserver_host_port(cluster_name) if hp: _bind_host, port = hp gw = (os.environ.get("KIND_K8S_APISERVER_GATEWAY_HOST") or "host.docker.internal").strip() if not gw: gw = "host.docker.internal" server = f"https://{gw}:{port}" tls_name = (os.environ.get("KIND_K8S_KUBECONFIG_TLS_SERVER_NAME") or "localhost").strip() or "localhost" return server, tls_name return apiserver_url_docker_bridge(cluster_name), None def 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) return url # Кэш временных kubeconfig для kubectl из контейнера: ключ → (mtime_ns исходника, путь). # Версия в ключе — сброс кэша при смене логики (например tls-server-name). _RUNTIME_KUBECONFIG_CACHE_VER = 2 _kubectl_runtime_cache: dict[str, tuple[int, Path]] = {} _kubectl_runtime_lock = threading.Lock() def kubeconfig_path_for_container_kubectl(*, cluster_name: str, kube_source_path: Path) -> Path: """ Путь к kubeconfig для вызова ``kubectl`` из процесса в контейнере. Если ``KIND_K8S_IN_CONTAINER=1``, копия во временный файл: ``server`` через шлюз хоста и при необходимости ``tls-server-name`` (SAN kind: ``localhost``, …). Иначе возвращает ``kube_source_path`` без изменений. Поддерживает старые установки с одним «хостовым» kubeconfig на диске. """ if not in_kind_k8s_container(): return kube_source_path try: src = kube_source_path.resolve() mtime_ns = int(src.stat().st_mtime_ns) except OSError as e: logger.debug("kubeconfig runtime: нет исходника %s: %s", kube_source_path, e) return kube_source_path key = f"{cluster_name}\x00{src}\x00{_RUNTIME_KUBECONFIG_CACHE_VER}" with _kubectl_runtime_lock: hit = _kubectl_runtime_cache.get(key) if hit is not None and hit[0] == mtime_ns: return hit[1] try: fd, temp_name = tempfile.mkstemp(suffix=".kubeconfig", prefix="kind-k8s-kubectl-") os.close(fd) tmp_path = Path(temp_name) shutil.copyfile(src, tmp_path, follow_symlinks=True) except OSError as e: logger.warning("kubeconfig runtime: не удалось скопировать %s: %s", src, e) return kube_source_path ok, server, tls_server_name, cluster_id = apply_apiserver_endpoint_to_kubeconfig_file( cluster_name=cluster_name, kube_path=tmp_path, ) if not ok: tmp_path.unlink(missing_ok=True) return kube_source_path prev = _kubectl_runtime_cache.pop(key, None) if prev is not None: try: prev[1].unlink(missing_ok=True) except OSError: pass _kubectl_runtime_cache[key] = (mtime_ns, tmp_path) logger.debug( "kubeconfig runtime: кластер «%s» server=%s tls-sni=%s (cluster=%s)", cluster_name, server, tls_server_name or "—", cluster_id, ) return tmp_path