first commit
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user