ADG Оставить заявку
Блог Автоматизация 6 мин чтения

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 в момент обновления дистрибутива значительно неприятнее, чем планово сейчас.

Контакт

Нужна такая же инженерная работа?

Опишите задачу и контекст. Ответим в течение рабочего дня, при необходимости подпишем NDA.