first commit
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
"""Скрипты и модули локального кластера kind (каталог репозитория kind-k8s).
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
Binary file not shown.
Executable
+174
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Проверка статуса кластеров kind: регистрация, kubeconfig, узлы (kubectl).
|
||||
|
||||
Запуск без аргументов — все кластеры из `kind get clusters`.
|
||||
Один аргумент — только указанное имя.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
|
||||
Требования: kind и kubectl в PATH (в образе kind-k8s-tools уже есть).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from kind_k8s_paths import clusters_dir
|
||||
|
||||
|
||||
def _kind_cluster_names() -> list[str]:
|
||||
p = subprocess.run(["kind", "get", "clusters"], capture_output=True, text=True)
|
||||
if p.returncode != 0:
|
||||
return []
|
||||
lines = [x.strip() for x in (p.stdout or "").splitlines() if x.strip()]
|
||||
return [x for x in lines if "no kind" not in x.lower()]
|
||||
|
||||
|
||||
def _kubeconfig_for_cluster(name: str) -> tuple[str | None, str]:
|
||||
"""Временный файл kubeconfig или None; второе значение — пояснение."""
|
||||
p = subprocess.run(
|
||||
["kind", "get", "kubeconfig", "--name", name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if p.returncode != 0:
|
||||
err = (p.stderr or p.stdout or "").strip()
|
||||
return None, err or "kind get kubeconfig не удался"
|
||||
data = (p.stdout or "").strip()
|
||||
if not data:
|
||||
return None, "пустой kubeconfig"
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w",
|
||||
suffix=".kubeconfig",
|
||||
prefix=f"kind-{name}-",
|
||||
delete=False,
|
||||
encoding="utf-8",
|
||||
) as f:
|
||||
f.write(data)
|
||||
return f.name, ""
|
||||
|
||||
|
||||
def _kubectl_nodes(kubeconfig: str) -> tuple[int, str]:
|
||||
p = subprocess.run(
|
||||
[
|
||||
"kubectl",
|
||||
"--kubeconfig",
|
||||
kubeconfig,
|
||||
"get",
|
||||
"nodes",
|
||||
"-o",
|
||||
"wide",
|
||||
"--request-timeout=10s",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
out = (p.stdout or "").strip()
|
||||
err = (p.stderr or "").strip()
|
||||
msg = out if out else err
|
||||
return p.returncode, msg
|
||||
|
||||
|
||||
def _local_meta(name: str) -> dict[str, str] | None:
|
||||
meta = CLUSTERS_DIR / name / "meta.json"
|
||||
if not meta.is_file():
|
||||
return None
|
||||
try:
|
||||
import json
|
||||
|
||||
raw = json.loads(meta.read_text(encoding="utf-8"))
|
||||
if isinstance(raw, dict):
|
||||
return {str(k): str(v) for k, v in raw.items() if isinstance(k, str)}
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _print_cluster(name: str, *, kube_path_saved: Path | None) -> None:
|
||||
print(f"── Кластер: {name} ──")
|
||||
if kube_path_saved and kube_path_saved.is_file():
|
||||
print(f" Сохранённый kubeconfig: {kube_path_saved}")
|
||||
meta = _local_meta(name)
|
||||
if meta:
|
||||
ver = meta.get("kubernetes_version_tag") or meta.get("node_image", "—")
|
||||
wn = meta.get("worker_nodes", "—")
|
||||
print(f" meta.json: версия={ver}, workers={wn}")
|
||||
|
||||
# С хоста удобнее тот же файл, что после create (в т.ч. с патчем 127.0.0.1:порт).
|
||||
use_path: str | None = None
|
||||
if kube_path_saved and kube_path_saved.is_file():
|
||||
use_path = str(kube_path_saved)
|
||||
print(" Проверка API: kubectl с сохранённым kubeconfig (как на хосте после make create).")
|
||||
|
||||
tmp_kc: str | None = None
|
||||
if not use_path:
|
||||
tmp_kc, kerr = _kubeconfig_for_cluster(name)
|
||||
if not tmp_kc:
|
||||
print(f" Статус API: недоступен ({kerr})")
|
||||
return
|
||||
use_path = tmp_kc
|
||||
|
||||
try:
|
||||
rc, msg = _kubectl_nodes(use_path)
|
||||
if rc == 0:
|
||||
print(" Узлы (kubectl get nodes -o wide):")
|
||||
for line in msg.splitlines():
|
||||
print(f" {line}")
|
||||
else:
|
||||
print(f" kubectl: код {rc}")
|
||||
for line in msg.splitlines()[:20]:
|
||||
print(f" {line}")
|
||||
finally:
|
||||
if tmp_kc:
|
||||
Path(tmp_kc).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
CLUSTERS_DIR = clusters_dir()
|
||||
parser = argparse.ArgumentParser(description="Статус кластеров kind")
|
||||
parser.add_argument(
|
||||
"cluster",
|
||||
nargs="?",
|
||||
help="Имя кластера (если не указано — все из kind get clusters)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if not shutil.which("kind"):
|
||||
print("Не найден kind.", file=sys.stderr)
|
||||
print(" Обычно запускают: make -C kind-k8s-develop status (kind внутри образа).", file=sys.stderr)
|
||||
print(
|
||||
" Либо установите kind на хост: https://kind.sigs.k8s.io/docs/user/quick-start/#installation",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(127)
|
||||
if not shutil.which("kubectl"):
|
||||
print("Не найден kubectl.", file=sys.stderr)
|
||||
sys.exit(127)
|
||||
|
||||
names = _kind_cluster_names()
|
||||
if args.cluster:
|
||||
if args.cluster not in names:
|
||||
print(f"Кластер «{args.cluster}» не найден в kind get clusters.", file=sys.stderr)
|
||||
print("Известные:", ", ".join(names) if names else "(пусто)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
names = [args.cluster]
|
||||
|
||||
if not names:
|
||||
print("Нет кластеров kind (kind get clusters).")
|
||||
return
|
||||
|
||||
for name in names:
|
||||
saved = CLUSTERS_DIR / name / "kubeconfig"
|
||||
kube_saved = saved if saved.is_file() else None
|
||||
_print_cluster(name, kube_path_saved=kube_saved)
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Вспомогательная логика для API и общих операций kind-k8s-develop.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
@@ -0,0 +1,340 @@
|
||||
"""Синхронные операции с kind: конфиг, создание, удаление, ожидание готовности нод.
|
||||
|
||||
Используются интерактивными CLI-скриптами и веб-слоем (через asyncio.to_thread / executor).
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from kind_k8s_paths import clusters_dir, data_root
|
||||
from kindest_node_tags import normalize_tag_v_prefix
|
||||
from kubeconfig_patch import patch_kubeconfig_server_for_host, should_patch_after_create
|
||||
|
||||
logger = logging.getLogger("kind_k8s.cluster_lifecycle")
|
||||
|
||||
# Имя кластера: поддомен DNS (RFC 1123)
|
||||
_NAME_RE = re.compile(r"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$")
|
||||
|
||||
|
||||
class KindClusterError(Exception):
|
||||
"""Ошибка операции kind (создание, удаление и т.д.)."""
|
||||
|
||||
def __init__(self, message: str, *, exit_code: int = 1) -> None:
|
||||
super().__init__(message)
|
||||
self.exit_code = exit_code
|
||||
|
||||
|
||||
def validate_cluster_name(name: str) -> bool:
|
||||
"""Проверить имя кластера (DNS-подмножество, длина ≤ 63)."""
|
||||
if not name or len(name) > 63:
|
||||
return False
|
||||
return bool(_NAME_RE.match(name))
|
||||
|
||||
|
||||
def normalize_k8s_version(raw: str) -> str:
|
||||
"""Превратить ввод в тег образа kindest/node (например 1.29.4 → v1.29.4)."""
|
||||
s = raw.strip()
|
||||
if not s:
|
||||
return "v1.29.4"
|
||||
s = s.lower().removeprefix("v")
|
||||
return f"v{s}"
|
||||
|
||||
|
||||
def build_kind_config_yaml(*, node_image: str, workers: int) -> str:
|
||||
"""YAML для kind: один control-plane + workers."""
|
||||
lines = [
|
||||
"kind: Cluster",
|
||||
"apiVersion: kind.x-k8s.io/v1alpha4",
|
||||
"nodes:",
|
||||
" - role: control-plane",
|
||||
f" image: {node_image}",
|
||||
]
|
||||
for _ in range(workers):
|
||||
lines.append(" - role: worker")
|
||||
lines.append(f" image: {node_image}")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def list_registered_kind_clusters() -> list[str]:
|
||||
"""Имена кластеров kind; при ошибке — пустой список."""
|
||||
p = subprocess.run(["kind", "get", "clusters"], capture_output=True, text=True)
|
||||
if p.returncode != 0:
|
||||
logger.info("kind get clusters завершился с кодом %s", p.returncode)
|
||||
return []
|
||||
lines = [x.strip() for x in (p.stdout or "").splitlines() if x.strip()]
|
||||
return [x for x in lines if "no kind" not in x.lower()]
|
||||
|
||||
|
||||
def _in_container() -> bool:
|
||||
return os.environ.get("KIND_K8S_IN_CONTAINER", "").strip() == "1"
|
||||
|
||||
|
||||
def _run_checked(cmd: list[str], *, cwd: Path | None = None) -> None:
|
||||
"""Выполнить команду; при ошибке — KindClusterError с текстом stderr."""
|
||||
logger.info("Выполнение: %s", " ".join(cmd))
|
||||
p = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)
|
||||
if p.returncode != 0:
|
||||
err = (p.stderr or p.stdout or "").strip()
|
||||
raise KindClusterError(f"Команда завершилась с кодом {p.returncode}: {err}", exit_code=p.returncode)
|
||||
|
||||
|
||||
def _run_capture_checked(cmd: list[str]) -> str:
|
||||
p = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if p.returncode != 0:
|
||||
err = (p.stderr or p.stdout or "").strip()
|
||||
raise KindClusterError(err or "команда не удалась", exit_code=p.returncode)
|
||||
return (p.stdout or "").strip()
|
||||
|
||||
|
||||
def _wait_nodes_enabled() -> bool:
|
||||
raw = (os.environ.get("KIND_K8S_WAIT_NODES") or "1").strip().lower()
|
||||
return raw in ("1", "true", "yes", "да")
|
||||
|
||||
|
||||
def _wait_nodes_timeout_sec() -> int:
|
||||
raw = (os.environ.get("KIND_K8S_WAIT_NODES_TIMEOUT_SEC") or "300").strip()
|
||||
try:
|
||||
return max(30, min(int(raw), 3600))
|
||||
except ValueError:
|
||||
return 300
|
||||
|
||||
|
||||
def wait_nodes_ready(*, kubeconfig_path: Path, timeout_sec: int | None = None) -> tuple[bool, str]:
|
||||
"""
|
||||
Дождаться condition=Ready для всех нод через kubectl wait.
|
||||
|
||||
Возвращает (успех, сообщение для лога/UI).
|
||||
"""
|
||||
if timeout_sec is None:
|
||||
timeout_sec = _wait_nodes_timeout_sec()
|
||||
t = f"{timeout_sec}s"
|
||||
cmd = [
|
||||
"kubectl",
|
||||
"--kubeconfig",
|
||||
str(kubeconfig_path),
|
||||
"wait",
|
||||
"--for=condition=Ready",
|
||||
"nodes",
|
||||
"--all",
|
||||
f"--timeout={t}",
|
||||
]
|
||||
logger.info("Ожидание готовности нод: timeout=%s", t)
|
||||
p = subprocess.run(cmd, capture_output=True, text=True)
|
||||
out = (p.stdout or "").strip()
|
||||
err = (p.stderr or "").strip()
|
||||
if p.returncode == 0:
|
||||
return True, out or "ноды в состоянии Ready"
|
||||
msg = err or out or f"код выхода {p.returncode}"
|
||||
return False, msg
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CreateClusterResult:
|
||||
"""Результат успешного создания кластера."""
|
||||
|
||||
cluster_name: str
|
||||
ver_tag: str
|
||||
node_image: str
|
||||
workers: int
|
||||
kubeconfig_path: Path
|
||||
meta_path: Path
|
||||
kubeconfig_patched_for_host: bool
|
||||
nodes_ready: bool | None
|
||||
nodes_ready_message: str | None
|
||||
|
||||
|
||||
def create_cluster_non_interactive(
|
||||
*,
|
||||
name: str,
|
||||
kubernetes_version_tag: str,
|
||||
workers: int,
|
||||
) -> CreateClusterResult:
|
||||
"""
|
||||
Создать кластер kind без диалогов.
|
||||
|
||||
``kubernetes_version_tag`` — тег kindest/node (например ``v1.29.4``), см. ``normalize_tag_v_prefix``.
|
||||
"""
|
||||
if not shutil.which("kind"):
|
||||
raise KindClusterError("Не найден бинарник kind в PATH.", exit_code=127)
|
||||
|
||||
if not validate_cluster_name(name):
|
||||
raise KindClusterError("Некорректное имя кластера (a-z0-9-, не длиннее 63).")
|
||||
|
||||
existing = list_registered_kind_clusters()
|
||||
if name in existing:
|
||||
raise KindClusterError(f"Кластер «{name}» уже существует в kind.")
|
||||
|
||||
if workers < 0 or workers > 20:
|
||||
raise KindClusterError("Количество worker-нод должно быть от 0 до 20.")
|
||||
|
||||
ver_tag = normalize_tag_v_prefix(kubernetes_version_tag)
|
||||
node_image = f"kindest/node:{ver_tag}"
|
||||
|
||||
root = data_root()
|
||||
cdir = clusters_dir()
|
||||
out_dir = cdir / name
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
cfg_path = out_dir / "kind-config.yaml"
|
||||
kube_path = out_dir / "kubeconfig"
|
||||
meta_path = out_dir / "meta.json"
|
||||
|
||||
yaml_text = build_kind_config_yaml(node_image=node_image, workers=workers)
|
||||
cfg_path.write_text(yaml_text, encoding="utf-8")
|
||||
|
||||
logger.info("Создание кластера «%s», образ %s, workers=%s", name, node_image, workers)
|
||||
_run_checked(["kind", "create", "cluster", "--name", name, "--config", str(cfg_path)])
|
||||
|
||||
kube = _run_capture_checked(["kind", "get", "kubeconfig", "--name", name])
|
||||
kube_path.write_text(kube, encoding="utf-8")
|
||||
|
||||
patched = False
|
||||
if should_patch_after_create():
|
||||
patched = patch_kubeconfig_server_for_host(cluster_name=name, kube_path=kube_path)
|
||||
|
||||
nodes_ready: bool | None = None
|
||||
nodes_msg: str | None = None
|
||||
if _wait_nodes_enabled():
|
||||
ok, msg = wait_nodes_ready(kubeconfig_path=kube_path)
|
||||
nodes_ready = ok
|
||||
nodes_msg = msg
|
||||
if ok:
|
||||
logger.info("Ноды готовы: %s", msg)
|
||||
else:
|
||||
logger.warning("Ожидание нод не завершилось успешно: %s", msg)
|
||||
|
||||
meta = {
|
||||
"cluster_name": name,
|
||||
"kubernetes_version_tag": ver_tag,
|
||||
"node_image": node_image,
|
||||
"worker_nodes": workers,
|
||||
"created_at_utc": datetime.now(timezone.utc).isoformat(),
|
||||
"kind_config_path": str(cfg_path.relative_to(root)),
|
||||
"kubeconfig_path": str(kube_path.relative_to(root)),
|
||||
"kubeconfig_patched_for_host": patched,
|
||||
"created_via_container": _in_container(),
|
||||
"nodes_ready_after_create": nodes_ready,
|
||||
"nodes_ready_message": nodes_msg,
|
||||
}
|
||||
meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
return CreateClusterResult(
|
||||
cluster_name=name,
|
||||
ver_tag=ver_tag,
|
||||
node_image=node_image,
|
||||
workers=workers,
|
||||
kubeconfig_path=kube_path,
|
||||
meta_path=meta_path,
|
||||
kubeconfig_patched_for_host=patched,
|
||||
nodes_ready=nodes_ready,
|
||||
nodes_ready_message=nodes_msg,
|
||||
)
|
||||
|
||||
|
||||
def delete_kind_cluster_and_data(*, name: str, log_to_stdout: bool = False) -> tuple[bool, str]:
|
||||
"""
|
||||
``kind delete cluster`` и удаление ``clusters/<имя>/``.
|
||||
|
||||
Первый элемент — успешность ``kind delete``; второй — текстовое резюме всего шага.
|
||||
|
||||
``log_to_stdout=True`` — не перехватывать stdout/stderr kind (удобно в интерактивном CLI).
|
||||
"""
|
||||
if not shutil.which("kind"):
|
||||
raise KindClusterError("Не найден kind в PATH.", exit_code=127)
|
||||
|
||||
cdir = clusters_dir()
|
||||
parts: list[str] = []
|
||||
kind_ok = True
|
||||
|
||||
if log_to_stdout:
|
||||
p = subprocess.run(["kind", "delete", "cluster", "--name", name])
|
||||
if p.returncode != 0:
|
||||
parts.append(f"kind delete: код {p.returncode}")
|
||||
logger.warning("kind delete cluster %s: код %s", name, p.returncode)
|
||||
kind_ok = False
|
||||
else:
|
||||
parts.append("kind delete: OK")
|
||||
else:
|
||||
p = subprocess.run(
|
||||
["kind", "delete", "cluster", "--name", name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if p.returncode != 0:
|
||||
err = (p.stderr or p.stdout or "").strip()
|
||||
parts.append(f"kind delete: ошибка ({err or p.returncode})")
|
||||
logger.warning("kind delete cluster %s: %s", name, err)
|
||||
kind_ok = False
|
||||
else:
|
||||
parts.append("kind delete: OK")
|
||||
|
||||
d = cdir / name
|
||||
if d.is_dir():
|
||||
shutil.rmtree(d)
|
||||
parts.append(f"удалена папка {d}")
|
||||
else:
|
||||
parts.append("локальная папка отсутствовала")
|
||||
|
||||
return kind_ok, "; ".join(parts)
|
||||
|
||||
|
||||
def read_meta_json(cluster_name: str) -> dict[str, object] | None:
|
||||
"""Прочитать ``clusters/<имя>/meta.json`` если есть."""
|
||||
p = clusters_dir() / cluster_name / "meta.json"
|
||||
if not p.is_file():
|
||||
return None
|
||||
try:
|
||||
raw = json.loads(p.read_text(encoding="utf-8"))
|
||||
if isinstance(raw, dict):
|
||||
return raw
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
logger.debug("meta.json не прочитан: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def kubectl_nodes_wide(*, kubeconfig: str | Path) -> tuple[int, str]:
|
||||
"""``kubectl get nodes -o wide``; возвращает (код, объединённый вывод)."""
|
||||
p = subprocess.run(
|
||||
[
|
||||
"kubectl",
|
||||
"--kubeconfig",
|
||||
str(kubeconfig),
|
||||
"get",
|
||||
"nodes",
|
||||
"-o",
|
||||
"wide",
|
||||
"--request-timeout=15s",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
out = (p.stdout or "").strip()
|
||||
err = (p.stderr or "").strip()
|
||||
msg = out if out else err
|
||||
return p.returncode, msg
|
||||
|
||||
|
||||
def cluster_summary_for_api(name: str) -> dict[str, object]:
|
||||
"""Сводка по кластеру для JSON API (без блокирующих долгих вызовов)."""
|
||||
meta = read_meta_json(name) or {}
|
||||
saved_kc = clusters_dir() / name / "kubeconfig"
|
||||
in_kind = name in list_registered_kind_clusters()
|
||||
out: dict[str, object] = {
|
||||
"name": name,
|
||||
"registered_in_kind": in_kind,
|
||||
"has_local_kubeconfig": saved_kc.is_file(),
|
||||
"kubeconfig_path": str(saved_kc) if saved_kc.is_file() else None,
|
||||
"meta": meta,
|
||||
}
|
||||
return out
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Настройки веб-приложения из переменных окружения (и опционально ``.env`` в рабочем каталоге).
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
# Каталог пакета app/ — для поиска .env рядом с кодом (в образе: /opt/kind-k8s/app).
|
||||
_APP_DIR = Path(__file__).resolve().parents[1]
|
||||
_REPO_ROOT = _APP_DIR.parent
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Параметры HTTP-сервера и поведения UI."""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=(
|
||||
str(_REPO_ROOT / ".env"),
|
||||
str(_APP_DIR / ".env"),
|
||||
),
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
case_sensitive=False,
|
||||
)
|
||||
|
||||
kind_k8s_web_host: str = Field(default="0.0.0.0", validation_alias="KIND_K8S_WEB_HOST")
|
||||
kind_k8s_web_port: int = Field(default=6000, validation_alias="KIND_K8S_WEB_PORT")
|
||||
|
||||
# Заголовок в OpenAPI / HTML (без хардкода в шаблонах).
|
||||
app_title: str = Field(default="kind-k8s-develop", validation_alias="KIND_K8S_APP_TITLE")
|
||||
|
||||
|
||||
def get_settings() -> Settings:
|
||||
"""Экземпляр настроек (для импорта в main и роутерах)."""
|
||||
return Settings()
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Хранилище фоновых заданий (создание кластера) в памяти процесса.
|
||||
|
||||
При перезапуске контейнера история заданий обнуляется — это ожидаемо для dev-среды.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Literal
|
||||
|
||||
logger = logging.getLogger("kind_k8s.job_store")
|
||||
|
||||
JobStatus = Literal["queued", "running", "success", "failed"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class JobRecord:
|
||||
"""Описание одного задания."""
|
||||
|
||||
job_id: str
|
||||
kind: str
|
||||
status: JobStatus
|
||||
cluster_name: str | None
|
||||
created_at_utc: str
|
||||
message: str | None = None
|
||||
result: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class JobStore:
|
||||
"""Потокобезопасное (asyncio) хранилище заданий."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._jobs: dict[str, JobRecord] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def create_job(self, kind: str, *, cluster_name: str | None) -> JobRecord:
|
||||
"""Зарегистрировать задание в статусе ``queued``."""
|
||||
jid = uuid.uuid4().hex
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
rec = JobRecord(
|
||||
job_id=jid,
|
||||
kind=kind,
|
||||
status="queued",
|
||||
cluster_name=cluster_name,
|
||||
created_at_utc=now,
|
||||
)
|
||||
async with self._lock:
|
||||
self._jobs[jid] = rec
|
||||
logger.info("Создано задание %s kind=%s cluster=%s", jid, kind, cluster_name)
|
||||
return rec
|
||||
|
||||
async def set_running(self, job_id: str) -> None:
|
||||
async with self._lock:
|
||||
if job_id in self._jobs:
|
||||
self._jobs[job_id].status = "running"
|
||||
self._jobs[job_id].message = None
|
||||
|
||||
async def set_success(self, job_id: str, *, result: dict[str, Any] | None = None, message: str | None = None) -> None:
|
||||
async with self._lock:
|
||||
if job_id in self._jobs:
|
||||
self._jobs[job_id].status = "success"
|
||||
self._jobs[job_id].result = result
|
||||
self._jobs[job_id].message = message
|
||||
|
||||
async def set_failed(self, job_id: str, message: str) -> None:
|
||||
async with self._lock:
|
||||
if job_id in self._jobs:
|
||||
self._jobs[job_id].status = "failed"
|
||||
self._jobs[job_id].message = message
|
||||
logger.warning("Задание %s завершилось ошибкой: %s", job_id, message)
|
||||
|
||||
async def get(self, job_id: str) -> JobRecord | None:
|
||||
async with self._lock:
|
||||
return self._jobs.get(job_id)
|
||||
|
||||
def snapshot_all(self) -> list[JobRecord]:
|
||||
"""Снимок всех заданий (для отладки; без блокировки — eventual consistency)."""
|
||||
return list(self._jobs.values())
|
||||
|
||||
|
||||
# Синглтон на процесс uvicorn
|
||||
job_store = JobStore()
|
||||
@@ -0,0 +1,267 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Интерактивное или пакетное создание локального кластера Kubernetes через kind.
|
||||
|
||||
Сохраняет kind-config.yaml, kubeconfig и meta.json в подпапку clusters/<имя>/.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
|
||||
Требования: kind, клиент контейнеров (``docker`` к сокету Docker/Podman) и kubectl в PATH.
|
||||
Рекомендуется: ``make create`` из каталога kind-k8s-develop — всё внутри Docker, на хосте только Docker.
|
||||
|
||||
Пакетный режим: ``--non-interactive --name X --kubernetes-version 1.29.4 [--workers N]``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from core.cluster_lifecycle import (
|
||||
CreateClusterResult,
|
||||
KindClusterError,
|
||||
create_cluster_non_interactive,
|
||||
list_registered_kind_clusters,
|
||||
normalize_k8s_version,
|
||||
validate_cluster_name,
|
||||
)
|
||||
from kindest_node_tags import fetch_kindest_node_tags, normalize_tag_v_prefix
|
||||
from kind_k8s_paths import clusters_dir
|
||||
|
||||
# Имя кластера: поддомен DNS (RFC 1123) — дублируем только для CLI-подсказок;
|
||||
# основная проверка в core.cluster_lifecycle.
|
||||
|
||||
|
||||
def _which(cmd: str) -> str | None:
|
||||
return shutil.which(cmd)
|
||||
|
||||
|
||||
def _container_cli_bin() -> str:
|
||||
return (os.environ.get("CONTAINER_CLI") or "docker").strip() or "docker"
|
||||
|
||||
|
||||
def _in_container() -> bool:
|
||||
return os.environ.get("KIND_K8S_IN_CONTAINER", "").strip() == "1"
|
||||
|
||||
|
||||
def _ask(prompt: str, default: str | None = None) -> str:
|
||||
if default is not None:
|
||||
line = input(f"{prompt} [{default}]: ").strip()
|
||||
return line if line else default
|
||||
line = input(f"{prompt}: ").strip()
|
||||
return line
|
||||
|
||||
|
||||
def _ask_int(prompt: str, default: int, *, min_v: int, max_v: int) -> int:
|
||||
while True:
|
||||
raw = _ask(prompt, str(default))
|
||||
try:
|
||||
n = int(raw, 10)
|
||||
except ValueError:
|
||||
print("Введите целое число.")
|
||||
continue
|
||||
if n < min_v or n > max_v:
|
||||
print(f"Допустимый диапазон: {min_v}…{max_v}.")
|
||||
continue
|
||||
return n
|
||||
|
||||
|
||||
def _configure_logging() -> None:
|
||||
"""Базовая настройка логов для вспомогательных модулей (kindest_node_tags и т.д.)."""
|
||||
if logging.root.handlers:
|
||||
return
|
||||
level = logging.DEBUG if os.environ.get("KIND_K8S_DEBUG", "").strip() in ("1", "true", "yes") else logging.INFO
|
||||
logging.basicConfig(level=level, format="%(levelname)s %(name)s: %(message)s")
|
||||
|
||||
|
||||
def _display_limit_for_version_list() -> int:
|
||||
raw = (os.environ.get("KIND_K8S_VERSION_LIST_DISPLAY") or "50").strip()
|
||||
try:
|
||||
return max(5, min(int(raw), 500))
|
||||
except ValueError:
|
||||
return 50
|
||||
|
||||
|
||||
def _interactive_k8s_version_tag() -> str:
|
||||
"""
|
||||
Спросить версию Kubernetes: загрузить теги kindest/node с Docker Hub (1.19+), показать список,
|
||||
выбор по номеру или ввод тега вручную.
|
||||
"""
|
||||
skip = os.environ.get("KIND_K8S_SKIP_VERSION_LIST", "").strip().lower() in ("1", "true", "yes", "да")
|
||||
tags: list[str] = []
|
||||
|
||||
if not skip:
|
||||
print(
|
||||
"Загрузка списка стабильных тегов kindest/node с Docker Hub (Kubernetes 1.19+, нужна сеть)…",
|
||||
flush=True,
|
||||
)
|
||||
tags = fetch_kindest_node_tags()
|
||||
if not tags:
|
||||
print(
|
||||
"Предупреждение: список тегов недоступен (сеть, лимит Docker Hub или пустой ответ). "
|
||||
"Введите версию вручную.",
|
||||
flush=True,
|
||||
)
|
||||
else:
|
||||
print("Загрузка списка пропущена (переменная KIND_K8S_SKIP_VERSION_LIST).", flush=True)
|
||||
|
||||
if not tags:
|
||||
raw = _ask("Версия Kubernetes / тег образа kindest/node (например 1.29.4)", "1.29.4")
|
||||
return normalize_k8s_version(raw)
|
||||
|
||||
display_n = _display_limit_for_version_list()
|
||||
print(f"\nДоступные стабильные версии (всего {len(tags)}), от новых к старым:", flush=True)
|
||||
for i, t in enumerate(tags[:display_n], start=1):
|
||||
print(f" {i:3}) {t}", flush=True)
|
||||
if len(tags) > display_n:
|
||||
print(
|
||||
f" … показаны первые {display_n} из {len(tags)}; можно ввести номер от 1 до {len(tags)} "
|
||||
"или тег вручную (например 1.25.11).",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
default_choice = "1"
|
||||
while True:
|
||||
raw = _ask(f"Номер строки (1–{len(tags)}) или версия вручную", default_choice)
|
||||
choice = raw if raw else default_choice
|
||||
if choice.isdigit():
|
||||
idx = int(choice, 10)
|
||||
if 1 <= idx <= len(tags):
|
||||
picked = tags[idx - 1]
|
||||
print(f"Выбран образ kindest/node:{picked}", flush=True)
|
||||
return picked
|
||||
print(f"Введите число от 1 до {len(tags)} или тег версии (например 1.28.0).", flush=True)
|
||||
continue
|
||||
|
||||
ver = normalize_k8s_version(choice)
|
||||
canon = normalize_tag_v_prefix(ver)
|
||||
if canon not in tags:
|
||||
print(
|
||||
f"Примечание: «{ver}» нет среди загруженных тегов; при отсутствии образа в реестре kind сообщит об ошибке.",
|
||||
flush=True,
|
||||
)
|
||||
return ver
|
||||
|
||||
|
||||
def _run_interactive() -> None:
|
||||
print("=== Создание кластера kind ===\n")
|
||||
if not _which("kind"):
|
||||
print("Не найден бинарник kind.", file=sys.stderr)
|
||||
print(" Установка kind на хост: https://kind.sigs.k8s.io/docs/user/quick-start/#installation", file=sys.stderr)
|
||||
print(" Через Docker: make -C kind-k8s-develop create (или make create из каталога репозитория).", file=sys.stderr)
|
||||
sys.exit(127)
|
||||
cli = _container_cli_bin()
|
||||
if not _which(cli):
|
||||
print(f"Не найден «{cli}» (CLI к API контейнеров).", file=sys.stderr)
|
||||
sys.exit(127)
|
||||
|
||||
existing = list_registered_kind_clusters()
|
||||
|
||||
default_name = "dev"
|
||||
if default_name in existing:
|
||||
default_name = "dev2"
|
||||
|
||||
while True:
|
||||
name = _ask("Имя кластера (DNS-имя, a-z0-9-)", default_name)
|
||||
if not validate_cluster_name(name):
|
||||
print("Некорректное имя: только строчные буквы, цифры, дефис; не длиннее 63 символов.")
|
||||
continue
|
||||
if name in existing:
|
||||
print(f"Кластер «{name}» уже существует в kind. Выберите другое имя или удалите его (make delete).")
|
||||
continue
|
||||
break
|
||||
|
||||
ver_tag = _interactive_k8s_version_tag()
|
||||
|
||||
workers = _ask_int(
|
||||
"Количество worker-нод (0 = только control-plane, он же может принимать поды)",
|
||||
2,
|
||||
min_v=0,
|
||||
max_v=20,
|
||||
)
|
||||
|
||||
try:
|
||||
result = create_cluster_non_interactive(name=name, kubernetes_version_tag=ver_tag, workers=workers)
|
||||
except KindClusterError as e:
|
||||
print(str(e), file=sys.stderr)
|
||||
raise SystemExit(getattr(e, "exit_code", 1)) from e
|
||||
|
||||
print("\nГотово.")
|
||||
print(f" kubeconfig (в среде запуска): {result.kubeconfig_path}")
|
||||
if _in_container():
|
||||
print(f" Том на хосте: kind-k8s-develop/clusters/{result.cluster_name}/ (рядом с Makefile)")
|
||||
print(
|
||||
f' Проверка с хоста (из каталога репозитория): kubectl --kubeconfig="$(pwd)/clusters/{result.cluster_name}/kubeconfig" get nodes',
|
||||
)
|
||||
else:
|
||||
print(f" Проверка: KUBECONFIG={result.kubeconfig_path} kubectl get nodes")
|
||||
print(f" Или: kubectl --kubeconfig={result.kubeconfig_path} get nodes")
|
||||
if result.kubeconfig_patched_for_host:
|
||||
print(" apiserver настроен на 127.0.0.1:<порт> для доступа с хоста.")
|
||||
if result.nodes_ready is False and result.nodes_ready_message:
|
||||
print(f" Предупреждение (ожидание нод): {result.nodes_ready_message}", file=sys.stderr)
|
||||
|
||||
|
||||
def _parse_args() -> argparse.Namespace:
|
||||
p = argparse.ArgumentParser(description="Создание кластера kind (интерактивно или --non-interactive).")
|
||||
p.add_argument(
|
||||
"--non-interactive",
|
||||
action="store_true",
|
||||
help="Без диалогов; обязательны --name и --kubernetes-version",
|
||||
)
|
||||
p.add_argument("--name", help="Имя кластера (DNS, a-z0-9-)")
|
||||
p.add_argument(
|
||||
"--kubernetes-version",
|
||||
dest="kubernetes_version",
|
||||
help="Версия / тег kindest/node, например 1.29.4 или v1.29.4",
|
||||
)
|
||||
p.add_argument("--workers", type=int, default=2, help="Число worker-нод (0–20), по умолчанию 2")
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
_configure_logging()
|
||||
args = _parse_args()
|
||||
|
||||
if args.non_interactive:
|
||||
if not args.name or not args.kubernetes_version:
|
||||
print("В режиме --non-interactive нужны --name и --kubernetes-version.", file=sys.stderr)
|
||||
raise SystemExit(2)
|
||||
try:
|
||||
result = create_cluster_non_interactive(
|
||||
name=args.name.strip(),
|
||||
kubernetes_version_tag=args.kubernetes_version.strip(),
|
||||
workers=args.workers,
|
||||
)
|
||||
except KindClusterError as e:
|
||||
print(str(e), file=sys.stderr)
|
||||
raise SystemExit(getattr(e, "exit_code", 1)) from e
|
||||
print(_json_result_summary(result))
|
||||
return
|
||||
|
||||
_run_interactive()
|
||||
|
||||
|
||||
def _json_result_summary(result: CreateClusterResult) -> str:
|
||||
"""JSON для stdout в пакетном режиме ``--non-interactive``."""
|
||||
payload = {
|
||||
"cluster_name": result.cluster_name,
|
||||
"kubernetes_version_tag": result.ver_tag,
|
||||
"node_image": result.node_image,
|
||||
"workers": result.workers,
|
||||
"kubeconfig_path": str(result.kubeconfig_path),
|
||||
"kubeconfig_patched_for_host": result.kubeconfig_patched_for_host,
|
||||
"nodes_ready": result.nodes_ready,
|
||||
"nodes_ready_message": result.nodes_ready_message,
|
||||
}
|
||||
return json.dumps(payload, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Интерактивное или пакетное удаление кластера kind и локальной папки с конфигами.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
|
||||
Пакетный режим: ``--non-interactive --name ИМЯ [--yes]`` (``--yes`` пропускает подтверждение).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from core.cluster_lifecycle import KindClusterError, delete_kind_cluster_and_data
|
||||
from kind_k8s_paths import clusters_dir
|
||||
|
||||
|
||||
def _configure_logging() -> None:
|
||||
if logging.root.handlers:
|
||||
return
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
|
||||
|
||||
|
||||
def _run(cmd: list[str]) -> int:
|
||||
p = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if p.stdout:
|
||||
print(p.stdout, end="")
|
||||
if p.stderr:
|
||||
print(p.stderr, end="", file=sys.stderr)
|
||||
return p.returncode
|
||||
|
||||
|
||||
def _list_kind_clusters() -> list[str]:
|
||||
p = subprocess.run(["kind", "get", "clusters"], capture_output=True, text=True)
|
||||
if p.returncode != 0:
|
||||
return []
|
||||
lines = [x.strip() for x in (p.stdout or "").splitlines() if x.strip()]
|
||||
return [x for x in lines if x.lower() not in ("no kind clusters found.", "no kind clusters found")]
|
||||
|
||||
|
||||
def _ask(prompt: str) -> str:
|
||||
return input(f"{prompt}: ").strip()
|
||||
|
||||
|
||||
def _interactive() -> None:
|
||||
print("=== Удаление кластера kind ===\n")
|
||||
CLUSTERS_DIR = clusters_dir()
|
||||
if not shutil.which("kind"):
|
||||
print("Не найден kind.", file=sys.stderr)
|
||||
print(" Через Docker: make -C kind-k8s-develop delete (или make delete из каталога репозитория).", file=sys.stderr)
|
||||
sys.exit(127)
|
||||
|
||||
clusters = _list_kind_clusters()
|
||||
if not clusters:
|
||||
print("Нет зарегистрированных кластеров kind.")
|
||||
only_dirs = (
|
||||
sorted(p.name for p in CLUSTERS_DIR.iterdir() if p.is_dir() and not p.name.startswith("."))
|
||||
if CLUSTERS_DIR.is_dir()
|
||||
else []
|
||||
)
|
||||
if only_dirs:
|
||||
print("Есть локальные папки в clusters/: ", ", ".join(only_dirs))
|
||||
name = _ask("Удалить только данные в clusters/<имя> (без kind delete)? Введите имя или пусто для выхода")
|
||||
if not name:
|
||||
return
|
||||
d = CLUSTERS_DIR / name
|
||||
if d.is_dir():
|
||||
shutil.rmtree(d)
|
||||
print(f"Удалена папка {d}")
|
||||
else:
|
||||
print("Папка не найдена.")
|
||||
return
|
||||
|
||||
print("Существующие кластеры kind:")
|
||||
for i, c in enumerate(clusters, 1):
|
||||
print(f" {i}) {c}")
|
||||
raw = _ask("\nВведите имя кластера или номер из списка")
|
||||
if not raw:
|
||||
print("Отмена.")
|
||||
return
|
||||
if raw.isdigit():
|
||||
idx = int(raw, 10)
|
||||
if idx < 1 or idx > len(clusters):
|
||||
print("Неверный номер.")
|
||||
sys.exit(1)
|
||||
name = clusters[idx - 1]
|
||||
else:
|
||||
name = raw
|
||||
if name not in clusters:
|
||||
print(f"Кластер «{name}» не найден в kind. Отмена.")
|
||||
sys.exit(1)
|
||||
|
||||
confirm = _ask(f"Удалить кластер «{name}» и папку clusters/{name}? (yes/no)")
|
||||
if confirm.lower() not in ("yes", "y", "да", "д"):
|
||||
print("Отмена.")
|
||||
return
|
||||
|
||||
try:
|
||||
kind_ok, summary = delete_kind_cluster_and_data(name=name, log_to_stdout=True)
|
||||
except KindClusterError as e:
|
||||
print(str(e), file=sys.stderr)
|
||||
raise SystemExit(getattr(e, "exit_code", 1)) from e
|
||||
|
||||
if not kind_ok:
|
||||
print("kind delete завершился с ошибкой; локальная папка удалена при наличии.", file=sys.stderr)
|
||||
print(summary)
|
||||
print("Готово.")
|
||||
|
||||
|
||||
def _parse_args() -> argparse.Namespace:
|
||||
p = argparse.ArgumentParser(description="Удаление кластера kind")
|
||||
p.add_argument("--non-interactive", action="store_true", help="Без диалогов")
|
||||
p.add_argument("--name", help="Имя кластера")
|
||||
p.add_argument("--yes", "-y", action="store_true", help="Не спрашивать подтверждение (только с --non-interactive)")
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
_configure_logging()
|
||||
args = _parse_args()
|
||||
|
||||
if args.non_interactive:
|
||||
if not args.name:
|
||||
print("Нужен --name в режиме --non-interactive.", file=sys.stderr)
|
||||
raise SystemExit(2)
|
||||
if not args.yes:
|
||||
print("Добавьте --yes для подтверждения удаления в пакетном режиме.", file=sys.stderr)
|
||||
raise SystemExit(2)
|
||||
if not shutil.which("kind"):
|
||||
print("Не найден kind.", file=sys.stderr)
|
||||
sys.exit(127)
|
||||
try:
|
||||
kind_ok, summary = delete_kind_cluster_and_data(name=args.name.strip())
|
||||
except KindClusterError as e:
|
||||
print(str(e), file=sys.stderr)
|
||||
raise SystemExit(getattr(e, "exit_code", 1)) from e
|
||||
print(summary)
|
||||
raise SystemExit(0 if kind_ok else 1)
|
||||
|
||||
_interactive()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Корень данных kind-k8s: каталог ``clusters/`` и пути в meta.json.
|
||||
|
||||
``KIND_K8S_WORKDIR`` — рабочий каталог, внутри него создаётся ``clusters/<имя>/``.
|
||||
В Docker-образе задаётся ``/work``, куда монтируется том с хоста (только данные).
|
||||
|
||||
Локальный запуск из ``kind-k8s/app/*.py``: переменную не задаём — корнем данных
|
||||
считается **родитель** каталога ``app/`` (каталог ``kind-k8s/``, рядом с ``clusters/``).
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Каталог пакета: kind-k8s/app/; в образе: /opt/kind-k8s/app
|
||||
_LIB = Path(__file__).resolve().parent
|
||||
# Корень репозитория kind-k8s (рядом с clusters/), если KIND_K8S_WORKDIR не задан
|
||||
_REPO_KIND_K8S = _LIB.parent
|
||||
|
||||
|
||||
def data_root() -> Path:
|
||||
"""Корень данных (родитель для ``clusters/``)."""
|
||||
w = (os.environ.get("KIND_K8S_WORKDIR") or "").strip()
|
||||
if w:
|
||||
return Path(w).resolve()
|
||||
return _REPO_KIND_K8S
|
||||
|
||||
|
||||
def clusters_dir() -> Path:
|
||||
"""``<data_root>/clusters``."""
|
||||
return data_root() / "clusters"
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Теги образа ``kindest/node`` с Docker Hub для интерактивного выбора версии Kubernetes.
|
||||
|
||||
Фильтр: только стабильные семверы ``vX.Y.Z`` (без ``-rc`` и т.п.), версия **>= 1.19.0**.
|
||||
Сортировка: от новых к старым.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Final
|
||||
|
||||
logger = logging.getLogger("kind_k8s.kindest_node_tags")
|
||||
|
||||
# Минимальная версия Kubernetes (включительно) для списка выбора
|
||||
MIN_K8S: Final[tuple[int, int, int]] = (1, 19, 0)
|
||||
|
||||
# Первая страница API Docker Hub для репозитория kindest/node
|
||||
_HUB_FIRST_PAGE: Final[str] = (
|
||||
"https://hub.docker.com/v2/repositories/kindest/node/tags?page_size=100"
|
||||
)
|
||||
|
||||
_USER_AGENT: Final[str] = "kind-k8s-tools/1.0 (+https://devops.org.ru)"
|
||||
|
||||
# Семвер без префикса v, только три числа (стабильные релизы)
|
||||
_TAG_RE = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)$")
|
||||
|
||||
|
||||
def parse_semver_tag(name: str) -> tuple[int, int, int] | None:
|
||||
"""Разобрать тег вида ``v1.29.4`` / ``1.29.4`` в ``(major, minor, patch)`` или ``None``."""
|
||||
m = _TAG_RE.match(name.strip())
|
||||
if not m:
|
||||
return None
|
||||
return int(m.group(1)), int(m.group(2)), int(m.group(3))
|
||||
|
||||
|
||||
def normalize_tag_v_prefix(tag: str) -> str:
|
||||
"""Единый вид тега: ``v1.29.4``."""
|
||||
s = tag.strip().lower().removeprefix("v")
|
||||
return f"v{s}"
|
||||
|
||||
|
||||
def _version_ok(tup: tuple[int, int, int], minimum: tuple[int, int, int]) -> bool:
|
||||
return tup >= minimum
|
||||
|
||||
|
||||
def tags_from_hub_json_results(
|
||||
results: list[dict],
|
||||
*,
|
||||
minimum: tuple[int, int, int] = MIN_K8S,
|
||||
) -> list[tuple[tuple[int, int, int], str]]:
|
||||
"""Из элементов ``results`` API Hub извлечь подходящие ``(tuple, канонический_тег)``."""
|
||||
out: list[tuple[tuple[int, int, int], str]] = []
|
||||
for item in results:
|
||||
name = (item.get("name") or "").strip()
|
||||
if not name or name == "latest":
|
||||
continue
|
||||
tup = parse_semver_tag(name)
|
||||
if tup is None:
|
||||
continue
|
||||
if not _version_ok(tup, minimum):
|
||||
continue
|
||||
out.append((tup, normalize_tag_v_prefix(name)))
|
||||
return out
|
||||
|
||||
|
||||
def merge_sort_unique_tags(
|
||||
collected: list[tuple[tuple[int, int, int], str]],
|
||||
) -> list[str]:
|
||||
"""Убрать дубликаты по ``(major, minor, patch)``, отсортировать от новых к старым."""
|
||||
by_key: dict[tuple[int, int, int], str] = {}
|
||||
for tup, canon in collected:
|
||||
by_key[tup] = canon
|
||||
ordered = sorted(by_key.keys(), reverse=True)
|
||||
return [by_key[k] for k in ordered]
|
||||
|
||||
|
||||
def _default_max_hub_pages() -> int:
|
||||
"""Верхняя граница числа запросов к API Hub (защита от бесконечного цикла)."""
|
||||
raw = (os.environ.get("KIND_K8S_HUB_TAGS_MAX_PAGES") or "").strip()
|
||||
if raw.isdigit():
|
||||
return max(1, min(int(raw), 200))
|
||||
return 60
|
||||
|
||||
|
||||
def fetch_kindest_node_tags(
|
||||
*,
|
||||
minimum: tuple[int, int, int] = MIN_K8S,
|
||||
max_pages: int | None = None,
|
||||
timeout: float = 45.0,
|
||||
) -> list[str]:
|
||||
"""
|
||||
Загрузить теги с Docker Hub (постранично), вернуть отсортированный список ``vX.Y.Z``.
|
||||
|
||||
При ошибке сети или HTTP возвращает пустой список (в лог — предупреждение).
|
||||
"""
|
||||
pages_cap = max_pages if max_pages is not None else _default_max_hub_pages()
|
||||
|
||||
collected: list[tuple[tuple[int, int, int], str]] = []
|
||||
url: str | None = _HUB_FIRST_PAGE
|
||||
page_idx = 0
|
||||
|
||||
while url and page_idx < pages_cap:
|
||||
page_idx += 1
|
||||
req = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
payload = json.load(resp)
|
||||
except urllib.error.HTTPError as e:
|
||||
logger.warning(
|
||||
"Docker Hub HTTP %s при загрузке тегов kindest/node: %s",
|
||||
e.code,
|
||||
e.reason,
|
||||
)
|
||||
break
|
||||
except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as e:
|
||||
logger.warning("Не удалось загрузить теги kindest/node: %s", e)
|
||||
break
|
||||
|
||||
batch = tags_from_hub_json_results(payload.get("results") or [], minimum=minimum)
|
||||
collected.extend(batch)
|
||||
url = payload.get("next") or None
|
||||
logger.debug(
|
||||
"kindest/node Docker Hub: страница %s/%s, подходящих тегов на странице %s",
|
||||
page_idx,
|
||||
pages_cap,
|
||||
len(batch),
|
||||
)
|
||||
|
||||
tags = merge_sort_unique_tags(collected)
|
||||
logger.info(
|
||||
"kindest/node: собрано %s стабильных тегов >= %s.%s.%s (страниц API: %s)",
|
||||
len(tags),
|
||||
minimum[0],
|
||||
minimum[1],
|
||||
minimum[2],
|
||||
page_idx,
|
||||
)
|
||||
return tags
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Правка kubeconfig kind для доступа к API с хоста (после create из контейнера).
|
||||
|
||||
Kind внутри Docker видит другой адрес apiserver; на хосте нужен 127.0.0.1:<порт>
|
||||
из проброса `docker port <cluster>-control-plane 6443/tcp` (Podman — тот же клиент
|
||||
`docker` к docker-совместимому сокету).
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger("kind_k8s.kubeconfig_patch")
|
||||
|
||||
|
||||
def _container_cli() -> str:
|
||||
"""CLI для `port` (обычно docker к сокету Docker или Podman)."""
|
||||
return (os.environ.get("CONTAINER_CLI") or "docker").strip() or "docker"
|
||||
|
||||
|
||||
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 get_apiserver_host_port(cluster_name: str) -> tuple[str, str] | None:
|
||||
"""Узнать (host, port) с хоста для доступа к apiserver."""
|
||||
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
|
||||
host, port = hp
|
||||
server = f"https://{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"
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Pydantic-схемы для API.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
Reference in New Issue
Block a user