Writing Custom Ansible Modules Integrating Redfish APIs

Writing Custom Ansible Modules Integrating Redfish APIs

1383 758 Will

Redfish is a growing industry standard for RESTful API-driven functionality for the management of commodity hardware. In a cloud arena that is increasingly becoming more multi-vendor and hyper-converged, the need to offer a platform-agnostic tooling set is becoming more vital. The Redfish API makes use of resources, expressed as an OData or JSON schema – the latter of which will be used in this guide. The three main categories of these objects include: systems (CPU memory), managers (management subsystem) and chassis (racks, blades).

Red Hat’s Ansible has surged in recent years as a market leader in configuration management and orchestration, significantly improving the scalability and reliability of data centre infrastructure. From the automation of simple, everyday system administration tasks to full-stack deployments, the ease in which these functionalities are executed thanks to Ansible’s use of YAML, spells a flat learning curve.

Another attraction of Ansible is the plethora of “pre-baked” modules available through its library, Ansible Core, which is continuously augmented and updated by the open source community. So too, due to its popularity, engineers and developers from notable vendors ensure that their contribution to the Ansible “Engine” are regular and reliable. However, it is that last point that results in a vendor-specific library of Ansible modules that only ends up catering to a portion of our multi-vendor environments. Thus, there is a growing need to support the common services and interfaces across our infrastructure fleet.

In this guide, we will go the steps to create a custom Ansible module comprising a Redfish API call to disable DHCP on the OOB Management Controller and designate a fixed IP Address in its place. 

Prerequisites
  • A Linux machine with Python 3 installed – I am using CentOS7. This will be used as your local Ansible server. Generally, we would utilise a server-host architecture whereby the Ansible playbooks are executed against a pool of designated hosts, but as we are interacting with the Out-Of-Band (OOB) controller of a bare-metal server, this architecture is not required.
  • A bare-metal server that supports Redfish functionality, i.e. an Alliance Partner of DMTF. Throughout this guide, we will make reference to the acronym, BMC (Baseboard Management Controller). BMC (accessible at Layer 3) provides the intelligence in the IPMI architecture, a standard interface protocol for the subsystem that facilitates management capabilities of the host’s CPU, firmware and OS. 
Installing Ansible

To begin using Ansible from a centrally controlled node, we need to install the Ansible engine on our Linux machine.

To get the latest version of Ansible for CentOS7, we need to install and enable the EPEL repository:

$ sudo yum install epel-release 

Once the latest version of EPEL has been installed, you can proceed to install Ansible with yum and accept the dependent packages.

$ sudo yum install ansible

We now have all the software we require installed and can begin to write Python code that will form the basis of our custom Ansible module.

Defining Ansible inventory

This is not so much a step as it is a comment around how we will handle Ansible’s concept of inventory in this guide. Typically, we would define the set of targets (hosts) that we intend to apply a configuration (play) against in Ansible’s Inventory. By default, this file can be found at /etc/ansible/hosts. In this file, we are able to compartmentalise our infrastructure into groups that allows users to decide what systems we are controlling and for what purpose. The below is an example of standalone host and a group of hosts:

mail.example.com

[webservers]
foo.example.com
bar.example.com

Ansible uses native OpenSSH for remote connections to the nominated hosts in a given playbook. Our case however is a little unique, in that because our core functionality comprises a Redfish API call, the URL or IP Address of the OOB Controller must be passed as an argument rather than referenced singularly as a host or within a group at the beginning of the playbook. This will make more sense as we go over Step 3 and observe how the API URL is captured as a variable in our Python code.

Instead we will set the hosts: direction to localhost which put simply, ensures any tasks are executed on the local machine.

Writing a Python script as our Ansible module

By default, Ansible will situate its configuration and inventory files under /etc/ansible. When scripting custom modules, I have found it best practice to segregate modules and playbooks (the YAML-syntaxed “instruction manuals” based on the modules) in this same directory. You may wish however, to create these under your home directory if multiple developers are working on the same node.

Firstly, create your Python file with an appropriate title. Ideally, you will want to construct your modules and playbooks with the same title for the sake of traceability and versioning. The module I am going to create will set DHCP to false for the BMC IP Address, so that I can access the OOB management via a static IP.

The following are a collection of micro-steps that will comprise an entire script that will be included at the bottom of this guide.

In our oob_dhcp_set_false.py, make sure to include a shebang line so as the script can be executed as a standalone executable:

#!/usr/bin/python

Ansible likes us to include strings for DOCUMENTATION and EXAMPLES at the top of our module. It is good practice to include a brief synopsis of what functionality your module is providing, including an expected standard structure and parameters of the Ansible playbook that will materialise from our Python script. We can either copy/paste this section from our YAML playbook file that we will create in the next step, or seeing as we are scripting our Python now, we can just create our playbook now essentially. 

"""
# Sample YAML file
#
- hosts: localhost
  gather_facts: no
  tasks:
  - name: Disable DHCP on Out-Of-Band Management Controller
    oob_dhcp_set_false:
      leased_bmc_ip: 127.0.0.1
      fixed_bmc_ip: 10.10.10.10
      fixed_bmc_netmask: 10.10.10.10/24
      bmc_username: username
      bmc_password: password
    register: result

  - debug: var=result
#
# Return Values
#
# failed: one of True or False
# changed: False
# msg: "HTTP Response {{ result }}. DHCP on iLO {{ leased_bmc_ip }} has successfully been disabled. A reset is now required to update the changes."
#
"""

AnsibleModule comes from from ansible.module_utils.basic import *. This must be imported with the *.

Import AnsibleModule along with the following Python modules:

from ansible.module_utils.basic import *
import requests
import json
import os
import urllib3

AnsibleModule provides lots of common code for handling returns, parses your arguments for you, and allows you to check inputs. All of which are important when our end-users are interacting with the front-end playbook element of our functionality.

After we have defined main() as the entrypoint into our module, we establish the accepted parameter type and mandatory/non-mandatory conditions:

module = AnsibleModule(
  argument_spec=dict(
    leased_bmc_ip=dict(type='str', required=True),
    fixed_bmc_ip=dict(type='str', required=True),
    fixed_bmc_netmask=dict(type='str', required=True),
    bmc_username=dict(type='str', required=True),
    bmc_password=dict(type='str', required=True),

  ),
  supports_check_mode=False,
)

leased_bmc_ip = module.params['leased_bmc_ip']
fixed_bmc_ip = module.params['fixed_bmc_ip']
fixed_bmc_netmask = module.params['fixed_bmc_netmask']
bmc_username = module.params['bmc_username']
bmc_password = module.params['bmc_password']

Now it’s time to embed the Redfish POST API into our script, whilst allowing for the relevant arguments to be parsed from the resulting playbook. First, to make things cleaner we will disable warnings so that they do not appear in the playbook’s output.

urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

Then we will define the three essential components of the API call namely the URL, headers and payload. Notice that leased_bmc_ip will be passed in as an argument here.

url = 'https://%s/redfish/v1/Managers/1/EthernetInterfaces/1/' % leased_bmc_ip
headers = {'content-type': 'application/json'}
payload = {
        "Oem": {
               "Hpe": {
                       "DHCPv4": {
                              "Enabled": True
                       }
               }
        }
  }

Employing try and except blocks will terminate the try block code execution in the instance of error. If an error is not returned, the status code as well as the BMC IP Address is returned successfully like this:

module.exit_json(changed=True, something_else=12345)

Otherwise, the error results in a transfer down to the except block, and the failures are handled just as simply: 

module.fail_json(msg="Something fatal happened")

The raising of exceptions in this example is handled very primitively, using RequestException which is a base class for HTTPError, ConnectionError, Timeout, URLRequired and others. 

try:
        response=requests.patch(url, data=json.dumps(payload), headers=headers, verify=False, auth=(bmc_username,bmc_password), timeout=30)
        status=response.status_code
        module.exit_json(changed=False, status=status, fixed_bmc_ip=fixed_bmc_ip)
except requests.exceptions.RequestException as e:
        module.fail_json(changed=False, msg="%s" % (e))

We now have a working script that will form the basis of our Ansible playbook!

Writing the Ansible playbook

As has been mentioned several times but perhaps not explained entirely, Playbooks is the shorthand of the modules, designed to be human-readable via its YAML syntax. That’s essentially the beauty of Ansible, a set of instructions (plays) run against a group of targets (hosts) that can be picked up by virtually anyone with ease thus promoting reusuability. 

Most certainly, we bore the brunt of the work when creating our Python script. All we have to do now is produce a simple YAML file reflective of the module we’ve created and that’s all there is to it!

Playbooks always begin with three YAML dashes (-  –  -).

Afterwards, defining a name for the playbook is always good practice and keeps the playbooks reusable. Then we will define a host or set of hosts (referenced as a group in our inventory file, explained in Step 2). However, in our case we will running the playbook against a single ‘host’ (an Out-Of-Band Management controller). On the same indent level as the previous two lines, will go the tasks: statement which is where the plays (modules) designated are executed. As per YAML nesting, these plays are listed another indent deeper (I usually opt for a two-spaced indentation). 

register is used to capture the output of a task to a variable which in our case will be result. We then go onto to make use of the inbuilt Ansible module debug to simply print the output; as the name suggests most useful for debugging variables or expressions – particularly when you are running multiple plays in one playbook and do not want to abort the entire playbook. The last line, when, provides a conditional statement to say only print the debug message in the instance of a successful output from the Redfish API call.

---
# My Ansible playbook
- name: Disable DHCP on Out-Of-Band Management Controller
  hosts: localhost
  gather_facts: false
  tasks:
  - oob_set_dhcp_false:
      leased_bmc_ip: 10.10.10.10
      fixed_bmc_ip: 10.10.10.20
      fixed_bmc_netmask: 255.255.255.0
      bmc_username: username
      bmc_password: password
    register: result

  - debug: msg="HTTP Response {{ result.status }}. DHCP on new OOB IP Address {{ result.fixed_bmc_ip }} has successfully been disabled. A reset is now required to update the changes."
    when: result is succeeded

We can now run our playbook:

$ ansible-playbook oob_set_dhcp_false.yml
Output:
[root@~ playbooks]# ansible-playbook oob_set_dhcp_false.yml

PLAY [Disable DHCP on Out-Of-Band Management Controller] ***************************************************

TASK [oob_set_dhcp_false] **********************************************************************************
 [WARNING]: Module did not set no_log for bmc_password

ok: [localhost]

TASK [debug] ***********************************************************************************************
ok: [localhost] => {
    "msg": "HTTP Response 200. DHCP on new OOB IP Address 10.10.10.20 has successfully been disabled. A reset is now required to update the changes."
}

PLAY RECAP *************************************************************************************************
localhost                  : ok=2    changed=0    unreachable=0    failed=0
Conclusion

And that’s it. Congrats! You’ve successfully created an Ansible module that can disable DHCP and assign a fixed IP Address to a Out-Of-Band Management Controller on a Redfish-supported server.

There’s certainly a lot of room for improvement with our module; including:
  • We could triage the varying error codes from the Redfish API. As is, we are handling any error as generic, but an improvement would be to cater for the likes of 400 BAD REQUEST and 404 NOT FOUND
  • Naturally we would not want to store usernames and passwords as plaintext in our playbook, instead we would use Ansible Vault, a feature allowing encryption of passwords and keys. We will explore Ansible Vault in future posts. 

Here is the final code for our module: