logo Записная книжка ежа

Ansible: передаем json в теле запроса, используя модуль uri

Предыстория вопроса

Задачка, которая заставила задаться этим вопросом выглядела совсем не сложной: есть ansible playbook-и, которые заказывают виртуальные машины в облаке, накатывают на них необходимое окружение, после чего довольные разработчики и тестировщики идут на получившиеся стенды и ломают их заниматься делом. Но чтобы они наверняка знали куда идти и какое окружение есть на каком стенде (а могут быть разные наборы) так повелось, что создавалась страничка в confluence (wiki-подобная штука от atalssian). Создавалась она вручную и, естественно, делать это никто особо не хотел и частенько забывал об этом.

Решение напрашивалось давно: идем в гугл, находим документацию по REST API у Confluence, лепим на коленке POST запрос к https://confluencehost/rest/api/content:

{
    "type":"page",
    "title":"Стенд крутейший: описание",
     "ancestors": [
        {
          "id": "123456789"
        }
      ],
    "space":{
        "key":"SPACE_NAME"
    },
    "body":{
        "storage":
        {
        "value":"<p>Тут крутое описание всего что надо</p>",
        "representation":"storage"
        }
    }
}

Отправляем запрос через любой REST-клиент (я использую DHC для Chrome), видим статус код 200, радуемся. Идем в confluence - видим, страничка создалась!

Осталась самая малость - запилить все это в ansible, подставляя переменные в правильный Jinja2 - шаблон.

Сделаем такой HTML-шаблон будущей странички:

<h2>Назначение стенда</h2>
<p>{{work_description}} </p>
<p>
	<strong>Ответственный за стенд</strong>: {{responsible_for_stand}}</p>
<h2>Описание серверов</h2>
...
и еще много букв

Подготовим структуру в ansible:

-  inventory
-- ...
-  roles
-- confluence
--- create_stand_description_page
---- tasks
----- main.yml
---- templates
----- stand_description.j2
-- ...
- playbook.yml

Добавим вызов нашей новой роли в playbook.yml:

---
...
- name: Create confluence page with stand description
  hosts: localhost
  gather_facts: no
  roles:
    - confluence/create_stand_description_page
...

И пойдем довольные читать документацию по модулю uri в ansible, который умеет делать HTTP-запросы.

Видим там такой пример:

- name: Create a JIRA issue
  uri:
    url: https://your.jira.example.com/rest/api/2/issue/
    method: POST
    user: your_username
    password: your_pass
    body: "{{ lookup('file','issue.json') }}"
    force_basic_auth: yes
    status_code: 201
    body_format: json

Кажется, то что надо!

Но стоп, у нас HTML-страничка, которую надо передать в JSON-файле. Для этого сначала ее бы надо привести в правильный вид, иначе JSON будет невалидным. Для этого напрашивается использовать какой-нибудь jinja-фильтр, немного гуглим и находим его: он называется неожиданно to_json. Получается, что фильтр надо применить к страничке, в которую на тот момент уже будут подставлены необходимые переменные (т.к. они тоже могут содержать спец. символы).

Немного подумав, добавим в templates роли еще один шаблон stand_request.j2:

{
    "type":"page",
    "title":"{{page_name}}",
     "ancestors": [
        {
          "id": "{{page_id}}"
        }
      ],
    "space":{
        "key":"{{space_name}}"
    },
    "body":{
        "storage":
        {
        "value":{{rendered_template|to_json}},
        "representation":"storage"
        }
    }
}

Собираем из всего этого конструкцию:

---
- set_fact:
    rendered_template: "{{ lookup('template', '../templates/stand_description.j2') }}"

- name: "Edit template"
  template:
    src: "../templates/stand_request.j2"
    dest: "{{playbook_dir}}/request.json"
    mode: 0777

- name: Create confluence page with stand description
  uri:
    url: https://confluencehost/rest/api/content
    method: POST
    user: "{{user}}"
    password: "{{password}}"
    body: "{{ lookup('file','{{playbook_dir}}/request.json') }}"
    force_basic_auth: yes
    status_code: 200
    body_format: json

Ну, можно выдохнуть, теперь все будет хорошо...

При выполнении получаем в файле request.json вполне себе валидный json:

{
    "type":"page",
    "title":"TEST",
    "ancestors":[
        {
            "id":"123456"
        }
    ],
    "space":{
        "key":"KEY"
    },
    "body":{
        "storage":{
            "value":"<h2>\u041D\u0430\u0437\u043D\u0430\u0447\u0435\u043D\u0438\u0435 \u0441\u0442\u0435\u043D\u0434\u0430<\/h2>",
            "representation":"storage"
        }
    }
}

Плейбук выполнился без ошибок и пора бы уже отчитаться о проделанной работе. Идем в confluence и неожиданно для себя видим:

Копируем запрос из сформированного файла request.json и выполняем точно такой же запрос, но через DHC и не перестаем удивляться, увидев, что все работает как положено:

Часы, потраченные на попытки найти решение в гуглах, стековерфлоу и так далее успеха не принесли, но зато удалось узнать для себя кое-что новое.

Ansible: cпособы передать JSON в теле запроса

Способов таких, как оказалось, есть несколько.

1. Можно описать YAML - конструкцию и в конце указать ansible-у, что ее надо обрабатывать как json.

- uri:
    url: some_url_here
    method: POST
    body:
      level1:
        level2string: level2value
      level1_other: 1
      level1_bool: false
      level1_array:
        - array_item1
        - array_item2
    status_code: 200
    body_format: json

На выходе конструкция даст JSON вида:

{
	"level1": {
		"level2": "level2value"
	},
	"level1_number": 1,
	"level1_bool": false,
	"level1_array": [
		"array_item1",
		"array_item2"
	]
}

Для отладки удобно использовать онлайн-конвертер YAML в JSON, например, тут.

Нужно понимать, что указав body_format: json, ansible сам добавит в заголовок запроса "Content-Type: application/json", если он не нужен - его надо переопределить, указав дополнительно HEADER_Content-Type: some_other_type.

2. Можно определить переменную и предоставить jinja возможность сделать из нее json.

vars:
  json_var:
    key: value
    ...
    body: ' {{json_var|to_json}}'

3. С использованием lookup-плагина, как мы и пытались сделать:

body: "{{ lookup('file','{{playbook_dir}}/request.json') }}"

4. Быстрый путь - использовать YAML folded style:

body: >
  {"items":{"key":"value"}}

5. Дешево и сердито - заэкранировать все что можно

body: "{\"items\":{\"key\":\"value\"}}

Возвращаясь к истории

А закончилось все в итоге хорошо - использовав первый вариант представления JSON из списка выше.

Playbook получился вот такого вида:

---
- set_fact:
    rendered_template: "{{ lookup('template', '../templates/stand_description.j2') }}"

- name: Create confluence page with stand description
  uri:
    url: https://confluence.billing.ru/rest/api/content
    method: POST
    user: "{{user}}"
    password: "{{password}}"
    body:
      type: page
      title: "{{stand_name}}"
      ancestors:
          - id: "{{parent_page_id}}"
      space:
        key: "{{space_key}}"
      body:
        storage:
          value: "{{rendered_template}}"
          representation: "storage"
    force_basic_auth: yes
    status_code: 200
    body_format: json

При таком варианте проблем с кодировками больше не возникало и странички успешно создавались, освобождая меня от лишней работы :)