first commit

This commit is contained in:
Sergey Antropoff
2026-04-04 05:15:54 +03:00
commit ae961ef5fe
25 changed files with 2022 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
"""Скрипты и модули локального кластера kind (каталог репозитория kind-k8s).
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
Binary file not shown.
+174
View File
@@ -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()
+5
View File
@@ -0,0 +1,5 @@
"""Вспомогательная логика для API и общих операций kind-k8s-develop.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
+340
View File
@@ -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
+41
View File
@@ -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()
+89
View File
@@ -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()
+267
View File
@@ -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()
+148
View File
@@ -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()
+34
View File
@@ -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"
+146
View File
@@ -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
+157
View File
@@ -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"
+5
View File
@@ -0,0 +1,5 @@
"""Pydantic-схемы для API.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""