This article is about the Ansible URI module. In the last couple of months, I have spent a lot of time around automation of Elasticsearch and wanted to share some useful information about how to use Ansible to interact with REST API endpoints and making them idempotent.

We all know Ansible modules are ideompotent in nature which means performing an operation once is exactly the same as the result of performing it repeatedly without any intervening actions. However, sometimes making the tasks idempotent requires additional work, for instance, in URI modulde we have to decide when to use POST or PUT calls.

Difference between POST and PUT?

Both POST and PUT are used to send data to a server to create/update a resource. The difference between POST and PUT is that PUT requests are idempotent. That is, calling the same PUT request multiple times will always produce the same result. In contrast, calling a POST request repeatedly have side effects of creating the same resource multiple times or failing with an error message that the resource already exists.

Let’s start with the playbook:

---
- name: Kibana Fleet tasks
  hosts: localhost
  connection: local
  roles:
    - fleet

Directory Stucture of the fleet role:

fleet/
├── tasks       # Includes Ansible tasks using the URI module
├── policies    # Fleet policies
└── vars        # Variables

Variables main.yml in the vars directory:

---
kibana_url: https://<KIBANA_URL>:5601

fleet_username: <FLEET_USERNAME>
fleet_password: <FLEET_PASSWORD>

force_basic_auth: true
validate_certs: false

policies/elasticagent.json

{
    "name": "elasticagent",
    "description": "Elastic-Agent Endpoints",
    "namespace": "default",
    "monitoring_enabled": [
        "logs",
        "metrics"
    ]
}

Below are the List Policies related tasks under the tasks directory. We are making the GET call first to list the exisitng policies, so that later we can make a decisin based on the response of GET call, if we need to create a new policy or update an existing policy. For the new policies, we have created a new variable called policies_to_create based on the template files in the directory called policies.

Later, we ran a delta using the difference filter between the two two variables policies_to_create & policies_to_update. Result of this will the policies that we need to create.

More on difference filter: https://docs.ansible.com/ansible/devel/collections/ansible/builtin/difference_filter.html

---
- name: List Policies
  ansible.builtin.uri:
    url: "{{ kibana_url }}/api/fleet/agent_policies"
    method: GET
    body_format: json
    status_code: [200]
    user: "{{ fleet_username }}"
    password: "{{ fleet_password }}"
    force_basic_auth: "{{ force_basic_auth }}"
    validate_certs: "{{ validate_certs }}"
    headers:
      kbn-xsrf: "true"
  ignore_errors: true
  register: list_policies_response

- name: Extract all policies from the list_policies_response
  ansible.builtin.set_fact:
    policies: "{{ policies | default({}) | combine({item.name: item.id}) }}"
    policies_to_update: "{{ policies_to_update | default([]) + [item.name] }}"
  with_items: "{{ list_policies_response | json_query('json.items') }}"

- name: Extract all policies_to_create from the folder "policies/*.json"
  ansible.builtin.set_fact:
    policies_to_create: "{{ policies_to_create | default([]) + [item | basename | splitext | first] }}"
  with_fileglob: "policies/*.json"

- name: Gather all policies that need to be created
  ansible.builtin.set_fact:
    policies_to_create: "{{ policies_to_create | difference(policies_to_update) | list }}"
  when:
    - policies_to_update is defined
    - policies_to_update != []
  • Create or Update Policies

Based on the two variables policies_to_create & policies_to_update, we group our create and update tasks into two blocks.

---
- name: Print required variables
  ansible.builtin.debug:
    msg:
      - "policies_to_create -- {{ policies_to_create | default([]) }}"
      - "policies_to_update -- {{ policies_to_update | default([]) }}"

- name: A group of tasks to be executed when policies_to_create is defined
  when:
    - policies_to_create is defined
    - policies_to_create != []
  block:
    - name: Create Agent Policies
      ansible.builtin.uri:
        url: "{{ kibana_url }}/api/fleet/agent_policies"
        method: POST
        body_format: json
        body: "{{ lookup('file', 'policies/{{ item }}.json') }}"
        status_code: [200]
        user: "{{ fleet_username }}"
        password: "{{ fleet_password }}"
        force_basic_auth: "{{ force_basic_auth }}"
        validate_certs: "{{ validate_certs }}"
        headers:
          kbn-xsrf: "true"
      ignore_errors: true
      register: agent_policies_response
      with_items: "{{ policies_to_create }}"

    - name: Response
      ansible.builtin.debug:
        msg: "{{ agent_policies_response }}"

    - name: Extract the policy_ids from the agent_policies_response variable
      ansible.builtin.set_fact:
        policy_ids: "{{ policy_ids | default({}) | combine ({item.0 : item.1}) }}"
      with_together:
        - "{{ agent_policies_response | json_query('results[*].json.item.name') }}"
        - "{{ agent_policies_response | json_query('results[*].json.item.id') }}"

    - name: Debug
      ansible.builtin.debug:
        msg: "POLICY IDS - {{ policy_ids }}"

- name: A group of tasks to be executed when policies_to_update is defined
  when:
    - policies_to_update is defined
    - policies_to_update != []

  block:
    - name: Update Agent Policies
      ansible.builtin.uri:
        url: "{{ kibana_url }}/api/fleet/agent_policies/{{ policies | json_query(item) }}"
        method: PUT
        body_format: json
        body: "{{ lookup('file', 'policies/{{ item }}.json') }}"
        status_code: [200]
        user: "{{ fleet_username }}"
        password: "{{ fleet_password }}"
        force_basic_auth: "{{ force_basic_auth }}"
        validate_certs: "{{ validate_certs }}"
        headers:
          kbn-xsrf: "true"
      ignore_errors: true
      register: agent_policies_response
      with_items: "{{ policies_to_update }}"

    - name: Response
      ansible.builtin.debug:
        msg: "{{ agent_policies_response }}"

    - name: Extract the policy_ids from the agent_policies_response variable
      ansible.builtin.set_fact:
        policy_ids: "{{ policy_ids | default({}) | combine ({item.0 : item.1}) }}"
      with_together:
        - "{{ agent_policies_response | json_query('results[*].json.item.name') }}"
        - "{{ agent_policies_response | json_query('results[*].json.item.id') }}"

    - name: Debug
      ansible.builtin.debug:
        msg: "POLICY IDS - {{ policy_ids }}"

Note: As we can see there are some repeated code between two blocks for module set_fact, I tried to move this code outside of the block to keep the code clean, but the variable value is still being updated when the tasks are being skipped.

Ansible documentation on condtionals states “Ansible always registers something in a registered variable for every host, even on hosts where a task fails or Ansible skips a task because a condition is not met.”

Is this the best approach to make API calls idempotent, not sure. These are several things can be improved, but for now this is good enough for our use case. One of the short-coming is if a user creates a Fleet policy through Kibana console, and we run this role again, it will fail, as it will attempt to update the existing policy, but will not find the policy in the policies directory or in the code itself.

Hope you find this post helpful.


References