Когда вообще нужен свой модуль Ansible
Сначала честный фильтр. Свой модуль - это не первый инструмент, а последний. Сперва ищи готовое: коллекций в Ansible Galaxy тысячи, и почти всё, что нужно админу, уже покрыто (ansible.builtin, ansible.posix, community.general и сотни вендорских). Свой код имеет смысл писать, когда:
- нет подходящего готового модуля, а задача повторяется из плейбука в плейбук;
- ты обвязываешь внутренний API или самописную утилиту, про которую никто, кроме тебя, не знает;
- связка из command/shell с register и кучей when превратилась в нечитаемое месиво и не умеет в check_mode;
- нужна настоящая идемпотентность и понятный changed, а не угадывание по тексту вывода.

Анатомия: как написать модуль Ansible на Python
Любой ansible python module строится вокруг класса AnsibleModule из ansible.module_utils.basic. Он берёт на себя грязную работу: парсит и валидирует аргументы по схеме argument_spec, понимает check_mode, форматирует вывод в JSON. Тебе остаётся бизнес-логика.
Каркас всегда один и тот же. Объявляешь, какие параметры принимаешь (argument_spec), создаёшь AnsibleModule с supports_check_mode=True, читаешь module.params, что-то делаешь и завершаешься через exit_json (успех) или fail_json (ошибка). Никаких print и sys.exit - только эти два метода, иначе Ansible не поймёт результат.
Минимальный, но рабочий пример. Модуль управляет строкой "ключ=значение" в простом ini-подобном файле. Положи его в library/keyval.py рядом с плейбуком:
Код: Выделить всё
#!/usr/bin/python
# -*- coding: utf-8 -*-
from __future__ import annotations
DOCUMENTATION = r'''
---
module: keyval
short_description: Управляет строкой key=value в простом конфиге
description:
- Гарантирует наличие или отсутствие строки вида key=value в файле.
- Идемпотентен и поддерживает режим проверки (--check).
options:
path:
description: Путь к файлу конфигурации.
required: true
type: path
key:
description: Имя ключа.
required: true
type: str
value:
description: Значение ключа. Игнорируется при state=absent.
required: false
type: str
state:
description: Должна ли строка присутствовать.
default: present
choices: [present, absent]
type: str
author:
- Cyberlake Academy
'''
EXAMPLES = r'''
- name: Прописать порт в конфиг
keyval:
path: /etc/myapp/app.conf
key: port
value: "8080"
state: present
- name: Убрать устаревший ключ
keyval:
path: /etc/myapp/app.conf
key: legacy_mode
state: absent
'''
RETURN = r'''
changed:
description: Был ли изменён файл.
type: bool
returned: always
line:
description: Итоговая строка key=value (при state=present).
type: str
returned: when state is present
'''
from ansible.module_utils.basic import AnsibleModule
import os
def run_module():
argument_spec = dict(
path=dict(type='path', required=True),
key=dict(type='str', required=True),
value=dict(type='str', required=False),
state=dict(type='str', default='present',
choices=['present', 'absent']),
)
module = AnsibleModule(
argument_spec=argument_spec,
supports_check_mode=True,
required_if=[('state', 'present', ['value'])],
)
path = module.params['path']
key = module.params['key']
value = module.params['value']
state = module.params['state']
desired = "{0}={1}".format(key, value)
# читаем текущее состояние
lines = []
if os.path.exists(path):
with open(path, 'r', encoding='utf-8') as fh:
lines = fh.read().splitlines()
new_lines = [ln for ln in lines if not ln.startswith(key + '=')]
changed = False
result = {'changed': False}
if state == 'present':
new_lines.append(desired)
result['line'] = desired
changed = (new_lines != lines)
else: # absent
changed = (new_lines != lines)
result['changed'] = changed
# в check-режиме ничего не пишем, только сообщаем намерение
if module.check_mode or not changed:
module.exit_json(**result)
try:
with open(path, 'w', encoding='utf-8') as fh:
fh.write("\n".join(new_lines) + "\n")
except OSError as exc:
module.fail_json(msg="Не удалось записать {0}: {1}".format(path, exc),
**result)
module.exit_json(**result)
def main():
run_module()
if __name__ == '__main__':
main()
Где лежит модуль, тестирование и ansible-doc
Самый простой способ размещения - каталог library/ рядом с плейбуком. Ansible автоматически подхватывает оттуда модули:
Код: Выделить всё
project/
ansible.cfg
site.yml
library/
keyval.py
Прогнать модуль в обычном плейбуке:
Код: Выделить всё
- name: Проверяем свой модуль
hosts: web
become: true
tasks:
- name: Прописать порт
keyval:
path: /etc/myapp/app.conf
key: port
value: "8080"
register: out
- ansible.builtin.debug:
var: out.line
Отдельно про документацию. Те три блока DOCUMENTATION, EXAMPLES, RETURN в начале файла - не украшение. Их читает встроенный движок, и твой модуль сразу получает справку наравне со штатными:
Код: Выделить всё
ansible-doc -M ./library keyval
Пара слов про окружение по современному стандарту RHCE. На свежих вариантах экзамена (EX294v9 это RHEL 9, EX294v10 это RHEL 10) акцент сместился на ansible-navigator и execution environments - модули там запускаются внутри контейнерного образа. Это значит, что твой самописный модуль из library/ поедет в контейнер вместе с проектом, а его зависимости (например, нестандартные python-библиотеки) должны быть в самом образе EE, а не на хосте. На голом ansible-core (старый стиль, RHEL 8) всё проще: интерпретатор Python берётся прямо с управляемого узла.
Типичные грабли
- print вместо exit_json. Любой посторонний вывод в stdout ломает парсинг результата. Только exit_json/fail_json, и никаких отладочных print - для отладки есть -vvv и module.debug.
- Изменения в check_mode. Забыл проверку module.check_mode перед записью - и твой "безопасный" --check молча правит прод. Проверяй check_mode всегда, до любой записи.
- Вечный changed: true. Если модуль пишет файл при каждом запуске, не сравнивая с текущим состоянием, идемпотентности нет. Сначала вычисляй diff, потом решай, менять ли.
- Версии Python. ansible-core 2.14 и новее хочет Python 3.10+ на control node и Python 3.6+ на управляемых узлах. Код модуля исполняется на целевом хосте - не тащи туда f-string-фишки, которых нет в его Python, если узлы старые.
- Кривой YAML в DOCUMENTATION. Лишний таб или неэкранированный символ - и ansible-doc/sanity-тесты падают. Держи блоки в r'''...''' и проверяй ansible-doc сразу.
Повтори руками, без копипаста:
- Создай каталог проекта с ansible.cfg и подкаталогом library/, положи туда keyval.py из урока.
- Напиши плейбук, который через свой модуль добавляет два ключа в /tmp/app.conf, прогони его трижды: --check, обычный, ещё раз обычный. Убедись, что третий прогон даёт changed: false.
- Поменяй задачу на state=absent для одного ключа и проверь, что строка исчезла, а повторный запуск снова даёт changed: false.
- Сломай намеренно один из блоков (убери двоеточие в DOCUMENTATION) и посмотри, как ругнётся ansible-doc -M ./library keyval. Почини.
- Бонус: добавь параметр backup: bool с default=false и логику резервной копии файла перед записью.
- Чем модуль отличается от filter- и lookup-плагина, и в каком случае писать каждый из них?
- Что делает supports_check_mode=True и где в коде должна стоять проверка module.check_mode?
- Как обеспечить идемпотентность, чтобы повторный запуск давал changed: false?
- Зачем нужны блоки DOCUMENTATION/EXAMPLES/RETURN и какая команда показывает справку по своему модулю?
Свой модуль на Python - это AnsibleModule, честный argument_spec, идемпотентная логика, аккуратный check_mode и завершение через exit_json/fail_json. Положи его в library/ рядом с плейбуком, опиши тремя блоками для ansible-doc, прогони трижды и убедись в changed: false. На экзамене EX294 эта тема всплывает редко, но в программу гл.7 входит, и понимание устройства модуля делает тебя сильно увереннее в чтении чужого кода и отладке штатных модулей. Пиши свой код только когда готового реально нет - но когда нужно, теперь ты умеешь.