Files
K3S/Makefile
Sergey Antropoff 437d0cce34 refactor: перемещение плейбуков в playbooks/, ротация сертификатов, сохранение ключей локально
Организация плейбуков:
- все .yml плейбуки перенесены из корня в playbooks/
- Makefile и entrypoint.sh обновлены — все пути с playbooks/ префиксом
- k8s-user.yml переработан: include_tasks → include_role (корректная работа из подкаталога)

Сохранение ключей и kubeconfig локально:
- k8s-user.yml: новый play сохраняет k8s SSH ключи в ./keys/ на машине запуска
- переменная k8s_local_keys_dir: "./keys" (настраивается в group_vars)
- .gitignore: keys/k8s_id_rsa исключён (публичный ключ можно коммитить)
- kubeconfig уже сохранялся роль k3s (k3s_kubeconfig_local_path: "./kubeconfig")

Автоматическая ротация сертификатов K3S (роль k3s-certs):
- K3S выпускает сертификаты на 1 год (hardcoded), таймер обеспечивает их обновление
- скрипт k3s-cert-check.sh: проверяет срок через openssl, ротирует при k3s_cert_rotate_before_days
- systemd service + timer: запуск по расписанию k3s_cert_check_schedule (по умолчанию monthly)
- RandomizedDelaySec: снижает нагрузку при одновременном запуске на нескольких нодах
- переменные: k3s_cert_validity_years: 5, k3s_cert_rotate_before_days: 90
- добавлен в site.yml (тег certs) и отдельный плейбук playbooks/k3s-certs.yml
- make k3s-certs и команда k3s-certs в entrypoint.sh
2026-04-24 07:00:18 +03:00

442 lines
26 KiB
Makefile
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ═══════════════════════════════════════════════════════════════════════════════
# K3S Ansible Stack — Makefile
# Всё запускается через Docker — Ansible устанавливать не нужно!
#
# Требования: docker, make
# Начало работы: make setup → отредактируй .env и inventory → make install
# ═══════════════════════════════════════════════════════════════════════════════
# ── Конфигурация ──────────────────────────────────────────────────────────────
IMAGE_NAME := k3s-ansible
CONTAINER_NAME := k3s-ansible-runner
# Загружаем .env если существует
-include .env
export
# Цвета терминала
CYAN := \033[0;36m
GREEN := \033[0;32m
YELLOW := \033[1;33m
RED := \033[0;31m
BOLD := \033[1m
NC := \033[0m
# ── Базовая команда запуска контейнера ────────────────────────────────────────
# Molecule запускается тоже из контейнера — монтируем Docker socket для DinD
DOCKER_RUN_MOLECULE := docker run --rm -it \
--name $(CONTAINER_NAME)-molecule \
-v $(PWD):/ansible \
-v /var/run/docker.sock:/var/run/docker.sock \
-e ANSIBLE_FORCE_COLOR=1 \
-e MOLECULE_NO_LOG=0 \
$(IMAGE_NAME)
DOCKER_RUN := docker run --rm -it \
--name $(CONTAINER_NAME) \
--network host \
-v $(PWD):/ansible \
-v $(or $(SSH_KEY_PATH),$(HOME)/.ssh):/root/.ssh:ro \
-e VAULT_PASSWORD="$(VAULT_PASSWORD)" \
-e ANSIBLE_VERBOSITY="$(ANSIBLE_VERBOSITY)" \
-e ANSIBLE_TAGS="$(ANSIBLE_TAGS)" \
-e ANSIBLE_SKIP_TAGS="$(ANSIBLE_SKIP_TAGS)" \
-e EXTRA_VARS="$(EXTRA_VARS)" \
-e VERSION="$(VERSION)" \
-e ANSIBLE_FORCE_COLOR=1 \
$(IMAGE_NAME)
.PHONY: help setup build rebuild \
bootstrap k8s-user mdadm k3s-certs \
install install-k3s install-cni install-kubevip install-nfs install-ingress \
install-cert-manager install-istio install-monitoring \
add-node remove-node \
etcd-backup etcd-restore etcd-list-snapshots \
upgrade uninstall health verify ping \
shell lint check \
molecule-k3s molecule-prometheus molecule-istio molecule-all molecule-lint \
vault-create vault-edit vault-view vault-encrypt-string \
vault-bootstrap-create vault-bootstrap-edit \
clean clean-all \
_check_env _check_image
# ── DEFAULT ───────────────────────────────────────────────────────────────────
.DEFAULT_GOAL := help
help: ## Показать эту справку
@echo ""
@printf "$(CYAN)$(BOLD)K3S Ansible Stack — управление через Docker$(NC)\n"
@printf "$(CYAN)━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━$(NC)\n"
@echo ""
@printf "$(BOLD)Первый запуск:$(NC)\n"
@echo " 1. make setup — создать .env из шаблона"
@echo " 2. Отредактируй .env и inventory/hosts.ini"
@echo " 3. make build — собрать Docker образ"
@echo " 4. make vault-create — создать vault с K3S токеном"
@echo " 5. make vault-bootstrap-create NODE=... — создать vault с паролями нод"
@echo " 6. make bootstrap — создать пользователя + задеплоить SSH ключ"
@echo " 7. make ping — проверить SSH"
@echo " 8. make install — развернуть полный стек"
@echo ""
@printf "$(BOLD)Все команды:$(NC)\n"
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| grep -v '^_' \
| awk 'BEGIN {FS = ":.*?## "}; {printf " $(CYAN)%-26s$(NC) %s\n", $$1, $$2}'
@echo ""
@printf "$(BOLD)Переменные (передаются в командной строке):$(NC)\n"
@printf " $(CYAN)VERSION$(NC)=v1.30.0+k3s1 версия K3S для upgrade\n"
@printf " $(CYAN)NODE$(NC)=master04 нода для add-node/remove-node\n"
@printf " $(CYAN)SNAPSHOT$(NC)=k3s-etcd-...db снимок для etcd-restore\n"
@printf " $(CYAN)ETCD_COPY$(NC)=true скопировать backup локально\n"
@printf " $(CYAN)ANSIBLE_VERBOSITY$(NC)=2 уровень debug вывода (0-4)\n"
@printf " $(CYAN)ANSIBLE_TAGS$(NC)=k3s,kube_vip запустить только теги\n"
@echo ""
# ═══════════════════════════════════════════════════════════════════════════════
# НАСТРОЙКА
# ═══════════════════════════════════════════════════════════════════════════════
setup: ## Первоначальная настройка: создать .env из шаблона
@if [ -f .env ]; then \
printf "$(YELLOW)⚠ .env уже существует, пропускаю$(NC)\n"; \
else \
cp .env.example .env; \
printf "$(GREEN)✓ Создан .env$(NC)\n"; \
fi
@echo ""
@printf "$(BOLD)Следующие шаги:$(NC)\n"
@echo " 1. nano .env — задай VAULT_PASSWORD"
@echo " 2. nano inventory/hosts.ini — укажи IP серверов"
@echo " 3. nano group_vars/all/main.yml — задай kube_vip_address и интерфейс"
@echo " 4. make build — собери Docker образ"
@echo " 5. make vault-create — создай vault"
@echo " 6. make ping — проверь SSH"
@echo " 7. make install — разверни стек"
# ═══════════════════════════════════════════════════════════════════════════════
# DOCKER ОБРАЗ
# ═══════════════════════════════════════════════════════════════════════════════
build: ## Собрать Docker образ ansible-runner
@printf "$(CYAN)Сборка образа $(IMAGE_NAME)...$(NC)\n"
docker build \
--build-arg HELM_VERSION=3.14.4 \
--build-arg KUBECTL_VERSION=v1.29.3 \
-t $(IMAGE_NAME):latest \
-f Dockerfile .
@printf "$(GREEN)✓ Образ $(IMAGE_NAME):latest собран$(NC)\n"
rebuild: ## Пересобрать образ без кэша
@printf "$(CYAN)Пересборка без кэша...$(NC)\n"
docker build --no-cache \
--build-arg HELM_VERSION=3.14.4 \
--build-arg KUBECTL_VERSION=v1.29.3 \
-t $(IMAGE_NAME):latest \
-f Dockerfile .
@printf "$(GREEN)✓ Готово$(NC)\n"
# ═══════════════════════════════════════════════════════════════════════════════
# ПРОВЕРКИ
# ═══════════════════════════════════════════════════════════════════════════════
ping: _check_env _check_image ## Проверить SSH доступность всех нод
@printf "$(CYAN)Проверка SSH...$(NC)\n"
$(DOCKER_RUN) ping
check: _check_env _check_image ## Dry-run: проверить плейбук без изменений
@printf "$(CYAN)Dry-run...$(NC)\n"
$(DOCKER_RUN) ansible-playbook playbooks/site.yml --check --diff
lint: _check_image ## Проверить синтаксис всех плейбуков
@printf "$(CYAN)Проверка синтаксиса...$(NC)\n"
$(DOCKER_RUN) ansible-playbook playbooks/site.yml --syntax-check
@printf "$(GREEN)✓ Синтаксис корректен$(NC)\n"
# ═══════════════════════════════════════════════════════════════════════════════
# УСТАНОВКА
# ═══════════════════════════════════════════════════════════════════════════════
# ═══════════════════════════════════════════════════════════════════════════════
# BOOTSTRAP — первичная настройка нод (создание пользователя + SSH ключ)
# ═══════════════════════════════════════════════════════════════════════════════
bootstrap: _check_env _check_image ## Первый запуск: создать пользователя и задеплоить SSH ключ на все ноды
@printf "$(CYAN)$(BOLD)Bootstrap нод (пользователь + SSH ключ)...$(NC)\n"
@printf "$(YELLOW)Нужны: host_vars/<node>/vault.yml с bootstrap_user и bootstrap_password$(NC)\n"
$(DOCKER_RUN) ansible-playbook playbooks/bootstrap.yml \
$(if $(NODE),-e "node_to_bootstrap=$(NODE)",)
vault-bootstrap-create: _check_image ## Создать vault с bootstrap credentials для ноды (NODE=master01)
@if [ -z "$(NODE)" ]; then \
printf "$(RED)✗ Укажи ноду: make vault-bootstrap-create NODE=master01$(NC)\n"; \
exit 1; \
fi
@if [ ! -f "host_vars/$(NODE)/vault.yml.example" ]; then \
printf "$(RED)Не найден host_vars/$(NODE)/vault.yml.example$(NC)\n"; \
exit 1; \
fi
@cp host_vars/$(NODE)/vault.yml.example host_vars/$(NODE)/vault.yml
docker run --rm -it \
-v $(PWD):/ansible \
-e VAULT_PASSWORD="$(VAULT_PASSWORD)" \
$(IMAGE_NAME) \
ansible vault encrypt host_vars/$(NODE)/vault.yml
@printf "$(GREEN)✓ Vault host_vars/$(NODE)/vault.yml создан$(NC)\n"
vault-bootstrap-edit: _check_image ## Редактировать bootstrap vault ноды (NODE=master01)
@if [ -z "$(NODE)" ]; then \
printf "$(RED)✗ Укажи ноду: make vault-bootstrap-edit NODE=master01$(NC)\n"; \
exit 1; \
fi
docker run --rm -it \
-v $(PWD):/ansible \
-e VAULT_PASSWORD="$(VAULT_PASSWORD)" \
$(IMAGE_NAME) \
ansible vault edit host_vars/$(NODE)/vault.yml
k8s-user: _check_env _check_image ## Создать k8s пользователя + разложить SSH ключи на все ноды (cluster + lab_hosts)
@printf "$(CYAN)$(BOLD)Настройка k8s пользователя и SSH ключей...$(NC)\n"
@printf "$(YELLOW)Нужны: host_vars/<node>/vault.yml с bootstrap_user и bootstrap_password для lab_hosts$(NC)\n"
$(DOCKER_RUN) ansible-playbook playbooks/k8s-user.yml \
$(if $(NODE),-e "node_to_limit=$(NODE)" --limit $(NODE),)
mdadm: _check_env _check_image ## Найти RAID массив и смонтировать в /storage (mdadm_enabled: true)
@printf "$(CYAN)Настройка mdadm RAID...$(NC)\n"
$(DOCKER_RUN) ansible-playbook playbooks/mdadm.yml \
$(if $(NODE),--limit $(NODE),)
k3s-certs: _check_env _check_image ## Установить systemd таймер автоматической ротации сертификатов K3S
@printf "$(CYAN)Настройка автоматической ротации сертификатов K3S...$(NC)\n"
$(DOCKER_RUN) ansible-playbook playbooks/k3s-certs.yml \
$(if $(NODE),--limit $(NODE),)
install: _check_env _check_image ## Развернуть полный стек (K3S + kube-vip + NFS + ingress)
@printf "$(CYAN)$(BOLD)Разворачиваю полный K3S стек...$(NC)\n"
$(DOCKER_RUN) install
install-k3s: _check_env _check_image ## Установить только K3S кластер
@printf "$(CYAN)Устанавливаю K3S...$(NC)\n"
$(DOCKER_RUN) install-k3s
install-cni: _check_env _check_image ## Установить CNI плагин (задай K3S_CNI=calico|cilium)
@printf "$(CYAN)Устанавливаю CNI ($(or $(K3S_CNI),flannel))...$(NC)\n"
$(DOCKER_RUN) ansible-playbook playbooks/site.yml --tags cni $(if $(K3S_CNI),-e k3s_cni=$(K3S_CNI),)
install-kubevip: _check_env _check_image ## Установить только kube-vip
@printf "$(CYAN)Устанавливаю kube-vip...$(NC)\n"
$(DOCKER_RUN) install-kubevip
install-nfs: _check_env _check_image ## Установить NFS сервер + CSI Driver
@printf "$(CYAN)Устанавливаю NFS + CSI...$(NC)\n"
$(DOCKER_RUN) install-nfs
install-ingress: _check_env _check_image ## Установить ingress-nginx
@printf "$(CYAN)Устанавливаю ingress-nginx...$(NC)\n"
$(DOCKER_RUN) install-ingress
install-cert-manager: _check_env _check_image ## Установить cert-manager + ClusterIssuer
@printf "$(CYAN)Устанавливаю cert-manager...$(NC)\n"
$(DOCKER_RUN) ansible-playbook playbooks/site.yml --tags cert_manager -e cert_manager_enabled=true
install-istio: _check_env _check_image ## Установить Istio + Kiali (нужен istio_enabled: true в vars)
@printf "$(CYAN)Устанавливаю Istio + Kiali...$(NC)\n"
$(DOCKER_RUN) ansible-playbook playbooks/site.yml --tags istio -e istio_enabled=true
install-monitoring: _check_env _check_image ## Установить Prometheus + Grafana (нужен prometheus_stack_enabled: true)
@printf "$(CYAN)Устанавливаю kube-prometheus-stack...$(NC)\n"
$(DOCKER_RUN) ansible-playbook playbooks/site.yml --tags monitoring -e prometheus_stack_enabled=true
# ═══════════════════════════════════════════════════════════════════════════════
# МАСШТАБИРОВАНИЕ КЛАСТЕРА
# ═══════════════════════════════════════════════════════════════════════════════
add-node: _check_env _check_image ## Добавить ноду: make add-node NODE=master04
@if [ -z "$(NODE)" ]; then \
printf "$(RED)✗ Укажи ноду: make add-node NODE=<nodename>$(NC)\n"; \
exit 1; \
fi
@printf "$(CYAN)Добавляю ноду $(NODE) в кластер...$(NC)\n"
$(DOCKER_RUN) ansible-playbook playbooks/add-node.yml -e "node_to_add=$(NODE)"
remove-node: _check_env _check_image ## Удалить ноду: make remove-node NODE=worker04
@if [ -z "$(NODE)" ]; then \
printf "$(RED)✗ Укажи ноду: make remove-node NODE=<nodename>$(NC)\n"; \
exit 1; \
fi
@printf "$(RED)$(BOLD)Удаление ноды $(NODE) из кластера...$(NC)\n"
$(DOCKER_RUN) ansible-playbook playbooks/remove-node.yml -e "node_to_remove=$(NODE)"
# ═══════════════════════════════════════════════════════════════════════════════
# РЕЗЕРВНОЕ КОПИРОВАНИЕ ETCD
# ═══════════════════════════════════════════════════════════════════════════════
etcd-backup: _check_env _check_image ## Создать снимок etcd (make etcd-backup [SNAPSHOT=name] [ETCD_COPY=true])
@printf "$(CYAN)Создаю снимок etcd...$(NC)\n"
$(DOCKER_RUN) ansible-playbook playbooks/etcd-backup.yml \
$(if $(SNAPSHOT),-e etcd_backup_name=$(SNAPSHOT),) \
$(if $(ETCD_COPY),-e etcd_backup_copy_to_local=$(ETCD_COPY),)
etcd-restore: _check_env _check_image ## Восстановить etcd: make etcd-restore SNAPSHOT=k3s-etcd-20250101.db
@if [ -z "$(SNAPSHOT)" ]; then \
printf "$(RED)✗ Укажи снимок: make etcd-restore SNAPSHOT=<filename>$(NC)\n"; \
printf "$(YELLOW) Список снимков: make etcd-list-snapshots$(NC)\n"; \
exit 1; \
fi
@printf "$(RED)$(BOLD)ВНИМАНИЕ: восстановление etcd перезапишет данные кластера!$(NC)\n"
$(DOCKER_RUN) ansible-playbook playbooks/etcd-restore.yml \
-e "etcd_restore_snapshot=$(SNAPSHOT)" \
$(if $(FORCE),-e etcd_restore_force=true,)
etcd-list-snapshots: _check_env _check_image ## Показать доступные снимки etcd
@printf "$(CYAN)Список снимков etcd...$(NC)\n"
$(DOCKER_RUN) ansible-playbook playbooks/etcd-restore.yml --tags list
# ═══════════════════════════════════════════════════════════════════════════════
# ОБНОВЛЕНИЕ
# ═══════════════════════════════════════════════════════════════════════════════
upgrade: _check_env _check_image ## Обновить K3S (make upgrade VERSION=v1.30.0+k3s1)
@if [ -z "$(VERSION)" ]; then \
printf "$(RED)✗ Укажи версию: make upgrade VERSION=v1.30.0+k3s1$(NC)\n"; \
exit 1; \
fi
@printf "$(CYAN)Обновляю K3S до $(VERSION)...$(NC)\n"
$(DOCKER_RUN) upgrade
# ═══════════════════════════════════════════════════════════════════════════════
# ДИАГНОСТИКА
# ═══════════════════════════════════════════════════════════════════════════════
health: _check_env _check_image ## Диагностика кластера (сервисы, поды, диск, память)
@printf "$(CYAN)Диагностика...$(NC)\n"
$(DOCKER_RUN) health
verify: _check_env _check_image ## Проверить весь стек (nodes/pods/svc/sc/ingress)
@printf "$(CYAN)Проверка стека...$(NC)\n"
$(DOCKER_RUN) verify
# ═══════════════════════════════════════════════════════════════════════════════
# УДАЛЕНИЕ
# ═══════════════════════════════════════════════════════════════════════════════
uninstall: _check_env _check_image ## ВНИМАНИЕ: удалить весь стек и данные
@printf "$(RED)$(BOLD)ВНИМАНИЕ: Удаление всего стека — данные будут потеряны!$(NC)\n"
@printf "$(YELLOW)Введи 'yes' для подтверждения: $(NC)"; \
read CONFIRM && [ "$$CONFIRM" = "yes" ] || (printf "Отменено.\n" && exit 1)
$(DOCKER_RUN) uninstall
# ═══════════════════════════════════════════════════════════════════════════════
# ANSIBLE VAULT
# ═══════════════════════════════════════════════════════════════════════════════
vault-create: _check_image ## Создать зашифрованный vault (group_vars/all/vault.yml)
@printf "$(CYAN)Создание vault...$(NC)\n"
docker run --rm -it \
-v $(PWD):/ansible \
$(IMAGE_NAME) \
ansible vault create group_vars/all/vault.yml
@printf "$(GREEN)✓ Vault создан$(NC)\n"
vault-edit: _check_image ## Редактировать vault
docker run --rm -it \
-v $(PWD):/ansible \
-e VAULT_PASSWORD="$(VAULT_PASSWORD)" \
$(IMAGE_NAME) \
ansible vault edit group_vars/all/vault.yml
vault-view: _check_image ## Просмотреть vault (расшифрованный вывод)
docker run --rm -it \
-v $(PWD):/ansible \
-e VAULT_PASSWORD="$(VAULT_PASSWORD)" \
$(IMAGE_NAME) \
ansible vault view group_vars/all/vault.yml
vault-encrypt-string: _check_image ## Зашифровать строку: make vault-encrypt-string STR=... NAME=...
@if [ -z "$(STR)" ]; then \
printf "$(RED)✗ Нужна строка: make vault-encrypt-string STR=мой-токен NAME=vault_k3s_token$(NC)\n"; \
exit 1; \
fi
docker run --rm -it \
-v $(PWD):/ansible \
-e VAULT_PASSWORD="$(VAULT_PASSWORD)" \
$(IMAGE_NAME) \
ansible vault encrypt_string '$(STR)' --name '$(or $(NAME),encrypted_var)'
# ═══════════════════════════════════════════════════════════════════════════════
# MOLECULE — ТЕСТИРОВАНИЕ РОЛЕЙ
# Требования: pip install -r requirements-python.txt (molecule, molecule-plugins[docker])
# ═══════════════════════════════════════════════════════════════════════════════
molecule-k3s: _check_image ## Тест роли k3s — 3 контейнера (Ubuntu+Debian), ~8-12 мин
@printf "$(CYAN)Тестирую роль k3s (3 ноды: master01, worker01, rpi01)...$(NC)\n"
$(DOCKER_RUN_MOLECULE) molecule k3s
@printf "$(GREEN)✓ k3s role: OK$(NC)\n"
molecule-prometheus: _check_image ## Тест роли prometheus-stack (шаблоны + PVC), ~2-3 мин
@printf "$(CYAN)Тестирую роль prometheus-stack...$(NC)\n"
$(DOCKER_RUN_MOLECULE) molecule prometheus-stack
@printf "$(GREEN)✓ prometheus-stack role: OK$(NC)\n"
molecule-istio: _check_image ## Тест роли istio + kiali (шаблоны), ~2-3 мин
@printf "$(CYAN)Тестирую роль istio...$(NC)\n"
$(DOCKER_RUN_MOLECULE) molecule istio
@printf "$(GREEN)✓ istio role: OK$(NC)\n"
molecule-all: _check_image ## Запустить все Molecule тесты последовательно (~15-20 мин)
@printf "$(CYAN)$(BOLD)Запуск всех Molecule тестов...$(NC)\n"
$(MAKE) molecule-k3s
$(MAKE) molecule-prometheus
$(MAKE) molecule-istio
@printf "$(GREEN)$(BOLD)Все тесты прошли успешно$(NC)\n"
molecule-lint: _check_image ## Линтинг (yamllint + ansible-lint) в контейнере, ~30 сек
@printf "$(CYAN)Запуск линтинга...$(NC)\n"
$(DOCKER_RUN_MOLECULE) molecule-lint
@printf "$(GREEN)✓ Линтинг прошёл$(NC)\n"
# ═══════════════════════════════════════════════════════════════════════════════
# ИНТЕРАКТИВНЫЙ SHELL
# ═══════════════════════════════════════════════════════════════════════════════
shell: _check_image ## Запустить интерактивный bash внутри контейнера
@printf "$(CYAN)Запуск shell (exit для выхода)...$(NC)\n"
docker run --rm -it \
--name $(CONTAINER_NAME)-shell \
--network host \
-v $(PWD):/ansible \
-v $(or $(SSH_KEY_PATH),$(HOME)/.ssh):/root/.ssh:ro \
-e VAULT_PASSWORD="$(VAULT_PASSWORD)" \
-e ANSIBLE_FORCE_COLOR=1 \
$(IMAGE_NAME) shell
# ═══════════════════════════════════════════════════════════════════════════════
# ОЧИСТКА
# ═══════════════════════════════════════════════════════════════════════════════
clean: ## Удалить Docker образ
@printf "$(YELLOW)Удаляю образ $(IMAGE_NAME)...$(NC)\n"
docker rmi $(IMAGE_NAME):latest 2>/dev/null || printf "$(YELLOW)Образ не найден$(NC)\n"
clean-all: clean ## Удалить образ + kubeconfig + кэш Docker
rm -f kubeconfig
docker system prune -f
@printf "$(GREEN)✓ Очистка завершена$(NC)\n"
# ═══════════════════════════════════════════════════════════════════════════════
# ВНУТРЕННИЕ
# ═══════════════════════════════════════════════════════════════════════════════
_check_env:
@if [ ! -f .env ]; then \
printf "$(RED)✗ Файл .env не найден! Запусти: make setup$(NC)\n"; \
exit 1; \
fi
_check_image:
@if ! docker image inspect $(IMAGE_NAME):latest > /dev/null 2>&1; then \
printf "$(YELLOW)⚠ Образ не найден, собираю...$(NC)\n"; \
$(MAKE) build; \
fi