Ansible 5.0 и collections-only модель: аудируем legacy playbooks и пишем скрипт замены namespace
Red Hat анонсирует Ansible 5.0 с переходом на collections-only. Аудируем сотни задач со старыми именами модулей и пишем скрипт автоматической замены namespace.
Red Hat анонсирует Ansible 5.0 с переходом на collections-only модель - классические модули вынесены в community.general и другие коллекции
Red Hat анонсировал Ansible 5.0 с переходом на collections-only модель: классические модули, которые раньше шли в комплекте с ansible-core, теперь живут в отдельных коллекциях - прежде всего в community.general и community.network. Ansible-core при этом стал значительно компактнее и сфокусированным на базовых примитивах.
Звучит как внутренняя реорганизация пакета. На практике это означает что playbooks, написанные в стиле «просто copy:, просто yum:, просто uri:», продолжают работать - обратная совместимость через FQCN alias пока сохраняется. Но lint ругается, новые проекты стоит писать сразу с FQCN, а главное - legacy-репозитории теперь несут технический долг, который рано или поздно придётся отдавать. Мы решили отдать сейчас.
Что мы обнаружили при аудите
На сопровождении несколько клиентских репозиториев с playbooks разного возраста - от написанных три года назад до прошлогодних. Запустили ansible-lint с последней версией правил и получили несколько сотен предупреждений вида fqcn-builtins - задачи используют короткие имена модулей без namespace.
Картина по категориям оказалась примерно такой:
- Встроенные модули ansible.builtin -
copy,template,file,service,command,shell,stat,lineinfile,blockinfileи ещё десяток. Это самые частые. Переименовываются вansible.builtin.<module>. - Модули из community.general -
timezone,alternatives,ini_file,htpasswd,sysctlи другие системные утилиты. Переименовываются вcommunity.general.<module>. - Модули пакетных менеджеров -
yum,apt,pip,npm. Часть из нихansible.builtin, часть ушла вcommunity.general. - Сетевые модули - в нашем случае немного, но они переехали в
community.network.
Самое неприятное открытие: в нескольких ролях один и тот же модуль упоминается и с коротким именем, и через FQCN (кто-то начал переходить, не дошёл до конца). Такая мешанина хуже чем стабильный legacy - её сложнее читать.
Скрипт замены namespace
Делать это руками при нескольких сотнях вхождений - потеря времени и источник ошибок. Написали Python-скрипт, который проходит по YAML-файлам, находит имена модулей в задачах и заменяет их на FQCN согласно маппингу.
#!/usr/bin/env python3
"""
Заменяет короткие имена модулей Ansible на FQCN в yaml-файлах.
Запуск: python3 fqcn_migrate.py roles/ playbooks/
"""
import sys
import re
from pathlib import Path
# Маппинг: короткое имя -> FQCN
MODULE_MAP = {
# ansible.builtin
"copy": "ansible.builtin.copy",
"template": "ansible.builtin.template",
"file": "ansible.builtin.file",
"service": "ansible.builtin.service",
"command": "ansible.builtin.command",
"shell": "ansible.builtin.shell",
"stat": "ansible.builtin.stat",
"lineinfile": "ansible.builtin.lineinfile",
"blockinfile": "ansible.builtin.blockinfile",
"include_tasks":"ansible.builtin.include_tasks",
"import_tasks": "ansible.builtin.import_tasks",
"set_fact": "ansible.builtin.set_fact",
"debug": "ansible.builtin.debug",
"fail": "ansible.builtin.fail",
"assert": "ansible.builtin.assert",
"uri": "ansible.builtin.uri",
"get_url": "ansible.builtin.get_url",
"unarchive": "ansible.builtin.unarchive",
"user": "ansible.builtin.user",
"group": "ansible.builtin.group",
"cron": "ansible.builtin.cron",
"yum": "ansible.builtin.yum",
"apt": "ansible.builtin.apt",
"pip": "ansible.builtin.pip",
"package": "ansible.builtin.package",
# community.general
"timezone": "community.general.timezone",
"alternatives":"community.general.alternatives",
"ini_file": "community.general.ini_file",
"htpasswd": "community.general.htpasswd",
"sysctl": "ansible.posix.sysctl",
"npm": "community.general.npm",
"make": "community.general.make",
"ufw": "community.general.ufw",
}
# Ключевые слова задачи, после которых идёт имя модуля
TASK_KEYS = set(MODULE_MAP.keys())
def process_file(path: Path, dry_run: bool = False) -> int:
text = path.read_text(encoding="utf-8")
changes = 0
lines = text.splitlines(keepends=True)
new_lines = []
for line in lines:
# Ищем строку вида " module_name:" или " module_name: {...}"
# где module_name - короткое имя из маппинга
m = re.match(r'^(\s*)(' + '|'.join(re.escape(k) for k in TASK_KEYS) + r')(:\s*)', line)
if m:
indent, module, rest = m.group(1), m.group(2), m.group(3)
fqcn = MODULE_MAP[module]
new_line = f"{indent}{fqcn}{rest}{line[m.end():]}"
if new_line != line:
changes += 1
line = new_line
new_lines.append(line)
if changes > 0 and not dry_run:
path.write_text("".join(new_lines), encoding="utf-8")
return changes
def main():
args = sys.argv[1:]
dry_run = "--dry-run" in args
paths = [Path(a) for a in args if not a.startswith("--")]
yaml_files = []
for p in paths:
if p.is_dir():
yaml_files.extend(p.rglob("*.yml"))
yaml_files.extend(p.rglob("*.yaml"))
elif p.is_file():
yaml_files.append(p)
total = 0
for f in sorted(yaml_files):
n = process_file(f, dry_run=dry_run)
if n:
print(f"{'[dry]' if dry_run else '[fix]'} {f}: {n} замен")
total += n
print(f"\nИтого: {total} замен в {len(yaml_files)} файлах")
if __name__ == "__main__":
main()
Сначала гоняем с --dry-run, смотрим что собирается поменять, потом без флага. После - снова ansible-lint чтобы убедиться что ничего не пропущено.
Что скрипт не делает и где нужен глаз
Скрипт работает с текстом, а не парсит YAML-AST - это сознательное решение: не хотелось тянуть зависимость на ruamel.yaml ради одноразовой операции. Следствие: в нескольких случаях он мог не поймать модуль, если имя стоит не первым ключом в задаче или написано в нестандартном стиле.
Кроме того, маппинг требует проверки: часть модулей переехала не в community.general, а в более специализированные коллекции - ansible.posix, community.crypto, community.mysql. Для каждого проекта стоит проверить актуальный список через ansible-doc --list с нужными коллекциями.
ansible.posix.sysctl вместо community.general.sysctl. Это одна из частых путаниц: sysctl переехал именно в ansible.posix, а не в community.general. Если поставить неверный namespace - ansible упадёт с couldn't resolve module. Аналогично mount и authorized_key.
После прогона скрипта на всех репозиториях получилось несколько десятков мест, где автозамена не сработала или поставила неверный namespace - правили руками.
Состояние collections в requirements.yml
Переход на FQCN бессмысленен без явного указания коллекций в requirements.yml или collections/requirements.yml. Если коллекция не установлена - FQCN вызовет ошибку, а не волшебным образом найдёт модуль.
# collections/requirements.yml
collections:
- name: community.general
version: ">=3.0.0"
- name: ansible.posix
version: ">=1.3.0"
- name: community.crypto
version: ">=1.9.0"
В CI добавили шаг ansible-galaxy collection install -r collections/requirements.yml перед запуском lint и тестов. Локально - в onboarding-документацию.
Где сейчас
Основной массив замен сделан. Lint теперь не выдаёт fqcn-предупреждений на большинстве репозиториев. Оставшиеся - роли с нестандартным форматированием задач, туда скрипт не добрался, правим итерационно.
Переход занял больше времени чем казалось на старте - не из-за технической сложности, а из-за разнообразия состояний репозиториев: в одном requirements.yml вообще не было, в другом коллекции были перечислены но без версий, в третьем в ansible.cfg был прописан старый roles_path который перекрывал collections_paths. Каждый такой нюанс - отдельная итерация.
Ansible 5.0 ещё не вышел, но направление очевидно уже сейчас - и разбираться с этим на работающем prod-playbook в момент обновления дистрибутива значительно неприятнее, чем планово сейчас.