- Добавлен docs/custom-addons.md: структура addons/<name>, playbook, group_vars, playbooks/addons.yml, Makefile, Molecule, чеклист и см. также. - docs/addons.md, getting-started.md: отсылки на custom-addons.md. - README.md: строка в таблице документации. - docs/molecule-testing.md: уточнены molecule-prometheus/istio (тесты аддонов), разделы prometheus-stack/istio, ссылка на руководство в блоке про новые тесты. - docs/make-reference.md: примечание к make addon-<name>.
554 lines
26 KiB
Markdown
554 lines
26 KiB
Markdown
# Тестирование через 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
|
||
# ─── Роли (и legacy-алиасы) ────────────────────────────────────────────────
|
||
make molecule-k3s # роль k3s — 3 ноды (master + worker + Debian), ~8-12 мин
|
||
make molecule-prometheus # тест аддона prometheus-stack (molecule-addon prometheus-stack), ~2-3 мин
|
||
make molecule-istio # тест аддона istio (molecule-addon istio), ~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 # все аддоны последовательно, ~60 мин
|
||
make molecule-all # всё: роли + кластер + аддоны + HTML отчёт
|
||
|
||
# ─── Отчёт и линтинг ───────────────────────────────────────────────────────
|
||
make molecule-report # генерировать HTML из /tmp/molecule-junit/*.xml
|
||
make molecule-lint # yamllint + ansible-lint в контейнере
|
||
```
|
||
|
||
---
|
||
|
||
## Полная проверка проекта (рекомендуемый порядок)
|
||
|
||
Ниже практический порядок, который помогает поймать максимум ошибок в коде, особенно в `addons/`.
|
||
|
||
### 1) Базовая проверка плейбуков
|
||
|
||
```bash
|
||
make build
|
||
make lint
|
||
```
|
||
|
||
Что это даёт:
|
||
|
||
- Проверка, что runner-образ собирается на вашей архитектуре (amd64/arm64).
|
||
- `ansible-playbook --syntax-check` для `playbooks/site.yml`.
|
||
- Быстрый сигнал, что не сломан основной деплой кластера.
|
||
|
||
### 2) Проверка core-роли и cluster topology
|
||
|
||
```bash
|
||
make molecule-k3s
|
||
make molecule-cluster
|
||
```
|
||
|
||
Что это даёт:
|
||
|
||
- `molecule-k3s`: рендер/идемпотентность роли `k3s`.
|
||
- `molecule-cluster`: сценарий 3 master + 2 worker, включая kube-vip и verify-assertions.
|
||
|
||
### 3) Проверка аддонов
|
||
|
||
```bash
|
||
make molecule-addon-all
|
||
```
|
||
|
||
Важно:
|
||
|
||
- Команда останавливается на первом упавшем аддоне.
|
||
- После фикса обычно запускают сначала точечный тест:
|
||
- `make molecule-addon-<name>`
|
||
- Затем снова общий прогон:
|
||
- `make molecule-addon-all`
|
||
|
||
### 4) Финальный полный прогон
|
||
|
||
```bash
|
||
make molecule-all
|
||
```
|
||
|
||
Что включает:
|
||
|
||
- Роль `k3s` и аддонные сценарии `prometheus-stack`, `istio` (через `molecule-addon`, см. [Makefile](../Makefile) `molecule-prometheus` / `molecule-istio`)
|
||
- Cluster scenario
|
||
- Все addon-сценарии
|
||
- Генерацию HTML отчёта
|
||
|
||
---
|
||
|
||
## 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/kubernetes/k3s/server/manifests/kube-vip.yaml` |
|
||
| VIP `192.168.1.100` | kube-vip manifest | Правильный IP в аннотации |
|
||
|
||
|
||
### Аддон `prometheus-stack` (~2-3 мин)
|
||
|
||
Сценарий: `addons/prometheus-stack/role/molecule/`. Проверяет рендеринг Jinja2-шаблона Helm values без privileged режима.
|
||
|
||
|
||
| Проверка | Что именно |
|
||
| ----------------------------- | ------------------- |
|
||
| `grafana.adminUser` | Значение переменной |
|
||
| `grafana.persistence.enabled` | `true` |
|
||
| `prometheus.retention` | `7d` |
|
||
| `alertmanager.enabled` | `true` |
|
||
| `nodeExporter.enabled` | `true` |
|
||
|
||
|
||
### Аддон `istio` (~2-3 мин)
|
||
|
||
Сценарий: `addons/istio/role/molecule/`. Проверяет рендеринг шаблонов (istiod, Kiali, mesh policy и т.д.).
|
||
|
||
|
||
| Файл | Проверка |
|
||
| -------------------------- | ---------------------------------------------------------- |
|
||
| `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
|
||
```
|
||
|
||
---
|
||
|
||
## Частые причины падений addon Molecule
|
||
|
||
Практика по реальным падениям:
|
||
|
||
1. **Файл создаётся на тестовой ноде, а читается на localhost**
|
||
- Симптом: `open /tmp/<file>.yaml: no such file or directory` при `delegate_to: localhost`.
|
||
- Решение: рендерить/копировать файлы тоже на `localhost` (`delegate_to: localhost`, `run_once: true`), либо убрать `delegate_to`.
|
||
2. **Неверная проверка булевых значений в `verify.yml`**
|
||
- Симптом: assert вида `v.some_flag == true` падает, хотя в сообщении видно `True`.
|
||
- Причина: в части сценариев значение приходит строкой.
|
||
- Решение: сравнивать нормализованно:
|
||
- `(v.some_flag | string | lower) == 'true'`
|
||
3. **Multi-document YAML в шаблоне**
|
||
- Симптом: `from_yaml` падает на файлах с несколькими документами (`---`).
|
||
- Решение: использовать:
|
||
- `from_yaml_all | list`
|
||
- И проверять нужный документ по индексу/полям.
|
||
4. **Недостающие vars в `molecule/default/converge.yml`**
|
||
- Симптом: `AnsibleUndefinedVariable: '<var_name>' is undefined`.
|
||
- Решение: добавить переменные в `vars:` converge-сценария (даже если в роли есть defaults, тест может их не подхватить в нужном контексте).
|
||
5. **Старое имя шаблона в `src:`**
|
||
- Симптом: `Could not find or access .../templates/<name>.j2`.
|
||
- Решение: сверить актуальные имена файлов в `addons/<name>/role/templates/`.
|
||
|
||
---
|
||
|
||
## Написание новых тестов для аддонов
|
||
|
||
Полный сценарий **добавления нового аддона** (не только Molecule, но и `playbook.yml`, `addons.yml`, `Makefile`): [Свои аддоны для k3s-ansible](custom-addons.md).
|
||
|
||
Ниже — краткие шаблоны `molecule.yml` / `converge` / `verify`.
|
||
|
||
### 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` |
|
||
| `'molecule/default/molecule.yml' glob failed` при `molecule-cluster` | Нет default-сценария в корне `molecule/` (служебный shared-state warning) | Если тест завершился `✓ cluster topology: OK`, предупреждение можно игнорировать |
|
||
| `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-*` |
|
||
|
||
|