# Тестирование через Molecule Molecule — стандартный инструмент для тестирования Ansible ролей и аддонов. Каждый сценарий запускается в Docker-контейнерах, проходит набор автоматических проверок и удаляется. Реальные серверы не нужны. ## Требования Только **Docker** — Molecule, Python и зависимости уже внутри образа `k3s-ansible`. ```bash make build # собрать образ (один раз) make molecule-k3s # запустить тест роли k3s ``` Как это работает: `make molecule-*` запускает контейнер `k3s-ansible` с примонтированным Docker socket. Внутри этого контейнера Molecule создаёт тестовые контейнеры — Docker-in-Docker без демона (только socket от хоста). --- ## Все доступные тесты ```bash # ─── Роли ────────────────────────────────────────────────────────────────── make molecule-k3s # роль k3s — 3 ноды (master + worker + Debian), ~8-12 мин make molecule-prometheus # роль prometheus-stack (Helm values шаблоны), ~2-3 мин make molecule-istio # роль istio + kiali (4 шаблона), ~2-3 мин # ─── Кластер (3 master + 2 worker, embedded etcd HA) ─────────────────────── make molecule-cluster # topology тест, ~15-20 мин # ─── Аддоны (Helm lint + template rendering) ─────────────────────────────── make molecule-addon-technitium-dns # ~2-3 мин make molecule-addon-authelia # ~2-3 мин make molecule-addon-ingress-proxypass # ~2 мин make molecule-addon-ingress-add-domains # ~2 мин make molecule-addon-yandex-dns-controller # ~2 мин # ─── Группы ──────────────────────────────────────────────────────────────── make molecule-addon-all # все аддоны последовательно, ~15 мин make molecule-all # всё: роли + аддоны + HTML отчёт, ~40 мин # ─── Отчёт и линтинг ─────────────────────────────────────────────────────── make molecule-report # генерировать HTML из /tmp/molecule-junit/*.xml make molecule-lint # yamllint + ansible-lint в контейнере ``` --- ## HTML отчёт После каждого запуска `make molecule-all` автоматически генерируется HTML отчёт. Для ручного запуска: ```bash make molecule-report ``` Отчёт по умолчанию создаётся в `/tmp/molecule-report.html`. Чтобы открыть: ```bash open /tmp/molecule-report.html ``` ### Как работает Ansible callback `junit` записывает результаты каждой задачи в XML файлы (`/tmp/molecule-junit/*.xml`). Скрипт `scripts/molecule-report.py` читает эти XML и генерирует один HTML с: - Общей статистикой: Total / Passed / Failed / Skipped - Таблицей по каждому сценарию (Expand / Collapse) - Подробностями ошибок — текст failure прямо в строке задачи - Авто-раскрытием упавших сценариев Для включения JUnit callback задай переменные окружения перед запуском: ```bash export JUNIT_OUTPUT_DIR=/tmp/molecule-junit export ANSIBLE_CALLBACKS_ENABLED=junit ``` Команды `molecule-addon` и `molecule-cluster` в `docker/entrypoint.sh` задают их автоматически. --- ## Жизненный цикл теста ``` dependency → зависимые роли lint → yamllint + ansible-lint syntax → ansible-playbook --syntax-check create → запустить Docker-контейнер(ы) prepare → подготовить контейнер converge → выполнить тестируемые задачи idempotency → повторный converge (проверка: changed=0) verify → assertions (проверить результаты) destroy → удалить контейнер(ы) ``` При ошибке на любой фазе тест падает, контейнеры удаляются автоматически. --- ## Описание тестов ### Роль `k3s` (3 контейнера, ~8-12 мин) | Контейнер | Образ | Соответствует | |---|---|---| | `master01` | `geerlingguy/docker-ubuntu2204-ansible` | Ubuntu 22.04 x86_64 | | `worker01` | `geerlingguy/docker-ubuntu2204-ansible` | Ubuntu 22.04 x86_64 | | `rpi01` | `geerlingguy/docker-debian12-ansible` | Debian 12 (Raspberry Pi OS) | Что проверяет `verify.yml`: | Проверка | Нода | Что именно | |---|---|---| | `/etc/kubernetes/k3s` существует | все | Директория создана | | `config.yaml` существует, права `0600` | все | Файл конфига | | `cluster-init: true` | master01 | Первый мастер инициализирует | | `server: https://...:6443` | worker01, rpi01 | Присоединяются к master01 | | `cluster-cidr: 10.42.0.0/16` | все | Правильная подсеть | | `disable: [traefik]` | все | Traefik выключен | | `net.ipv4.ip_forward = 1` | все | sysctl применён | ### Кластер (`molecule/cluster/`, ~15-20 мин) Тестирует **topology HA кластера**: 3 master (embedded etcd) + 2 worker. | Контейнер | Роль | Образ | |---|---|---| | `master01` | Primary master (cluster-init) | `geerlingguy/docker-ubuntu2204-ansible` (privileged) | | `master02` | HA master (join) | `geerlingguy/docker-ubuntu2204-ansible` (privileged) | | `master03` | HA master (join) | `geerlingguy/docker-ubuntu2204-ansible` (privileged) | | `worker01` | Worker | `geerlingguy/docker-ubuntu2204-ansible` | | `worker02` | Worker | `geerlingguy/docker-ubuntu2204-ansible` | Что проверяет `verify.yml`: | Проверка | Нода | Что именно | |---|---|---| | `cluster-init: true` | master01 | Только первый мастер инициализирует | | нет ключа `server:` | master01 | Первый мастер не join-ит | | `server: https://192.168.1.100:6443` | master02, master03 | Join через kube-vip VIP | | нет `cluster-init` | master02, master03 | Не инициализируют заново | | `server: https://192.168.1.100:6443` | worker01, worker02 | Подключаются через VIP | | kube-vip DaemonSet manifest | все master | Файл `/var/lib/rancher/k3s/server/manifests/kube-vip.yaml` | | VIP `192.168.1.100` | kube-vip manifest | Правильный IP в аннотации | ### Роль `prometheus-stack` (~2-3 мин) Тестирует рендеринг Jinja2-шаблона без privileged режима. | Проверка | Что именно | |---|---| | `grafana.adminUser` | Значение переменной | | `grafana.persistence.enabled` | `true` | | `prometheus.retention` | `7d` | | `alertmanager.enabled` | `true` | | `nodeExporter.enabled` | `true` | ### Роль `istio` (~2-3 мин) Тестирует рендеринг всех четырёх шаблонов роли. | Файл | Проверка | |---|---| | `istiod-values.yaml` | Ресурсы pilot, `meshConfig`, `enablePrometheusMerge: true` | | `kiali-values.yaml` | `auth.strategy: token`, Prometheus URL | | `peer-authentication.yaml` | `kind: PeerAuthentication`, `spec.mtls.mode: STRICT` | | `kiali-token-secret.yaml` | `type: kubernetes.io/service-account-token` | ### Аддон `technitium-dns` (~2-3 мин) Helm lint + `helm template` → assertions по Kubernetes манифестам. | Проверка | Что именно | |---|---| | `primary.ip == 192.168.1.53` | values.yaml.j2 рендер | | `secondary.enabled == true` | secondary DNS включён | | `dns.forwarders` содержит `1.1.1.1`, `8.8.8.8` | форвардеры | | `sync.schedule == '*/5 * * * *'` | расписание синхронизации | | `kind: Deployment` (primary + secondary) | оба Deployment рендерятся | | `kind: CronJob` | CronJob синхронизации зон | | `def main` в ConfigMap | sync.py вложен | | `kube-vip.io/loadbalancerIPs` | аннотация kube-vip | | `LoadBalancer` | тип Service | | `port: 53` | DNS порт | ### Аддон `authelia` (~2-3 мин) | Проверка | Что именно | |---|---| | `v.oidc.enabled == true` | OIDC включён | | Gitea/Grafana clients `enabled: true` | OIDC клиенты | | `protectedDomains` содержит sonarr, radarr | access control | | `oidcDomains` содержит gitea, grafana | bypass для OIDC | | `adminDomains` содержит argocd, vault | admin-only домены | | `kind: Deployment` | Authelia Deployment | | `configuration.yml` в Secret | конфиг смонтирован | | `identity_providers` в манифесте | OIDC секция рендерится | | `AUTHELIA_JWT_SECRET_FILE` | env var из Secret | | `authelia.authelia.svc.cluster.local` | URL forward-auth | ### Аддон `ingress-proxypass` (~2 мин) | Проверка | Что именно | |---|---| | `defaults.ingressClass == nginx` | значение по умолчанию | | 2 proxy записи | plex + router | | `plex.home.local` в Ingress | хост plex | | `router` auth `enabled: true` | basic auth включён | | `kind: Service`, `kind: Endpoints` | созданы для каждого proxy | | `kind: Secret` | htpasswd Secret для router | | `proxy-connect-timeout` аннотация | timeout аннотации | ### Аддон `ingress-add-domains` (~2 мин) | Проверка | Что именно | |---|---| | 2 entry записи | gitea + grafana | | `gitea.home.local` в Ingress | хост gitea | | `namespace: gitea` | Ingress в namespace сервиса | | `gitea-http` в backend | правильное имя Service | | `kind: Secret` | htpasswd Secret для grafana | | `auth-type` аннотация | basic auth аннотация | ### Аддон `yandex-dns-controller` (~2 мин) | Проверка | Что именно | |---|---| | `controller.schedule == '*/5 * * * *'` | расписание | | `secret.orgId`, `secret.token` | API credentials | | `zones.domains[0].name == home.local` | конфигурация зоны | | `kind: CronJob` | CronJob рендерится | | `def main` в ConfigMap | controller.py вложен | | `kind: Secret` | API credentials в Secret | | `kind: ServiceAccount`, `kind: Role` | RBAC | --- ## Запуск тестов пошагово ### Линтинг (~30 сек) ```bash make molecule-lint ``` Запускает `yamllint .` и `ansible-lint` на всём проекте. ### Тест одной роли ```bash make molecule-k3s ``` Ожидаемый вывод (успешный): ``` INFO Running default > create TASK [Create instance(s)] **** changed: [localhost] => (item=master01) ... INFO Running default > verify TASK [Assert cluster-init is set (только master01)] *** ok: [master01] => {"msg": "All assertions passed"} INFO Running default > destroy ✓ k3s role: OK ``` ### Тест кластера (3 master + 2 worker) ```bash make molecule-cluster ``` ### Тест аддона ```bash make molecule-addon-authelia ``` Аддон-тесты используют `delegate_to: localhost` для `helm lint` и `helm template` — команды выполняются прямо в runner-контейнере (где есть Helm), результат сохраняется в тестовый контейнер. ### Все тесты + HTML отчёт ```bash make molecule-all ``` Запускает все сценарии последовательно и генерирует `open /tmp/molecule-report.html`. --- ## Отладка упавших тестов ```bash # Войти в runner-контейнер с Docker socket: docker run --rm -it \ -v $(pwd):/ansible \ -v /var/run/docker.sock:/var/run/docker.sock \ --entrypoint bash \ k3s-ansible # Роль: cd /ansible/roles/k3s molecule converge molecule login molecule login --host master01 molecule verify molecule converge -- -vvv molecule destroy # Аддон: cd /ansible/addons/authelia/role molecule converge molecule login molecule verify # Кластер: cd /ansible molecule test -s cluster molecule converge -s cluster molecule verify -s cluster molecule destroy -s cluster ``` --- ## Написание новых тестов для аддонов ### molecule.yml (шаблон) ```yaml driver: name: docker platforms: - name: master01 image: geerlingguy/docker-ubuntu2204-ansible:latest pre_build_image: true groups: - k3s_master provisioner: name: ansible playbooks: converge: converge.yml verify: verify.yml config_options: defaults: interpreter_python: auto_silent verifier: name: ansible ``` ### converge.yml (шаблон для аддона с Helm chart) ```yaml - name: Converge — my-addon template tests hosts: all become: false gather_facts: false vars: my_addon_namespace: my-addon # ... все переменные аддона ... tasks: - name: Render Helm values ansible.builtin.template: src: "{{ playbook_dir }}/../../templates/values.yaml.j2" dest: /tmp/my-addon-values.yaml mode: "0644" - name: Run helm lint ansible.builtin.command: > helm lint /ansible/addons/my-addon/role/chart --values /tmp/my-addon-values.yaml --strict delegate_to: localhost changed_when: false - name: Run helm template ansible.builtin.command: > helm template my-addon /ansible/addons/my-addon/role/chart --values /tmp/my-addon-values.yaml --namespace my-addon delegate_to: localhost changed_when: false register: helm_template_result - name: Save manifests ansible.builtin.copy: content: "{{ helm_template_result.stdout }}" dest: /tmp/my-addon-manifests.yaml mode: "0644" ``` ### verify.yml (шаблон) ```yaml - name: Verify — my-addon templates hosts: all tasks: - name: Read values ansible.builtin.slurp: src: /tmp/my-addon-values.yaml register: values_raw - name: Parse values ansible.builtin.set_fact: v: "{{ values_raw.content | b64decode | from_yaml }}" - name: Assert key value ansible.builtin.assert: that: v.myKey == 'expected' fail_msg: "myKey неверный: {{ v.myKey }}" - name: Read manifests ansible.builtin.slurp: src: /tmp/my-addon-manifests.yaml register: manifests_raw - name: Assert Deployment rendered ansible.builtin.assert: that: "'kind: Deployment' in (manifests_raw.content | b64decode)" fail_msg: "Deployment не найден" ``` --- ## Типичные ошибки | Ошибка | Причина | Решение | |---|---|---| | `Unable to pull image` | Нет интернета | `docker pull geerlingguy/docker-ubuntu2204-ansible:latest` | | `FAILED: assert ... is defined` | Переменная не задана | Добавь в секцию `vars:` converge.yml | | `Idempotency: CHANGED` | Таск не идемпотентен | Добавь `changed_when: false` или исправь задачу | | `yamllint: wrong indentation` | Ошибка отступа | Исправь файл, `make molecule-lint` | | `ansible-lint: no-changed-when` | shell/command без changed_when | Добавь `changed_when: <условие>` | | `sysctl: Operation not permitted` | Нет privileged | Добавь `privileged: true` в molecule.yml | | `helm: command not found` | helm в тестовом контейнере | Используй `delegate_to: localhost` для helm команд | | JUnit XML не создаётся | Callback не активирован | Задай `ANSIBLE_CALLBACKS_ENABLED=junit` и `JUNIT_OUTPUT_DIR` | | HTML отчёт пустой | XML файлов нет | Убедись что тесты запускались через `make molecule-*` |