Using the Python Requests Module to Work with REST APIs

Blog Detail

In this post we’ll review one of the most widely used Python modules for interacting with web-based services such as REST APIs, the Python requests module. If you were ever wondering what magic is going on behind the scenes when running one of the thousands of Ansible networking modules, or many of the Python-based SDKs that are available from vendors, there’s a good chance the underlying operations are being performed by requests. The Python requests module is a utility that emulates the operations of a web browser using code. It enables programs to interact with a web-based service across the network, while abstracting and handling the lower-level details of opening up a TCP connection to the remote system. Like a web browser, the requests module allows you to programmatically:

  • Initiate HTTP requests such as GET, PUT, POST, PATCH, and DELETE
  • Set HTTP headers to be used in the outgoing request
  • Store and access the web server content in various forms (HTML, XML, JSON, etc.)
  • Store and access cookies
  • Utilize either HTTP or HTTPS

Retrieving Data

The most basic example of using requests is simply retrieving the contents of a web page using an HTTP GET:

import requests
response = requests.get('https://google.com')

The resulting response object will contain the actual HTML code that would be seen by a browser in the text object, which can be accessed by typing response.text.

<span role="button" tabindex="0" data-code=">>> response.text '<!doctype html><html itemscope="" itemtype="http://schema.org/WebPage" lang="en"><head><meta content="Search the world\'s information, including webpages, images, videos and more. Google has many special features to help you find exactly what you\'re looking for." name="description"><meta content="noodp" name="robots">
>>> response.text
'<!doctype html><html itemscope="" itemtype="http://schema.org/WebPage" lang="en"><head><meta content="Search the world\'s information, including webpages, images, videos and more. Google has many special features to help you find exactly what you\'re looking for." name="description"><meta content="noodp" name="robots"><meta content="text/html; charset=UTF-8" http-equiv="Content-Type">
-- output omitted for brevity --

There are a lot of great utilities for parsing HTML, but in most cases we will not be doing that when working with networking vendor APIs. In the majority of cases, the data will come back structured as XML or JSON.

response = requests.get('https://nautobot.demo.networktocode.com/api')

>>> response.content
b'{"circuits":"https://nautobot.demo.networktocode.com/api/circuits/","dcim":"https://nautobot.demo.networktocode.com/api/dcim/","extras":"https://nautobot.demo.networktocode.com/api/extras/","graphql":"https://nautobot.demo.networktocode.com/api/graphql/","ipam":"https://nautobot.demo.networktocode.com/api/ipam/","plugins":"https://nautobot.demo.networktocode.com/api/plugins/","status":"https://nautobot.demo.networktocode.com/api/status/","tenancy":"https://nautobot.demo.networktocode.com/api/tenancy/","users":"https://nautobot.demo.networktocode.com/api/users/","virtualization":"https://nautobot.demo.networktocode.com/api/virtualization/"}'

Notice that the above output is in bytes format. This is indicated by the lowercase “b” in front of the response text. We could convert this into a string using response.content.decode() and then use the Python json module to load it into a Python dictionary. However, because json is one of the most common data formats, the requests module has a convenience method that will automatically convert the response from bytes to a Python dictionary. Simply call response.json():

<span role="button" tabindex="0" data-code=">>> response.json() {'circuits': 'https://nautobot.demo.networktocode.com/api/circuits/', 'dcim': 'https://nautobot.demo.networktocode.com/api/dcim/', 'extras': 'https://nautobot.demo.networktocode.com/api/extras/', 'graphql': 'https://nautobot.demo.networktocode.com/api/graphql/', 'ipam': 'https://nautobot.demo.networktocode.com/api/ipam/', 'plugins': 'https://nautobot.demo.networktocode.com/api/plugins/', 'status': 'https://nautobot.demo.networktocode.com/api/status/', 'tenancy': 'https://nautobot.demo.networktocode.com/api/tenancy/', 'users': 'https://nautobot.demo.networktocode.com/api/users/', 'virtualization': 'https://nautobot.demo.networktocode.com/api/virtualization/'} >>> type(response.json())
>>> response.json()
{'circuits': 'https://nautobot.demo.networktocode.com/api/circuits/', 'dcim': 'https://nautobot.demo.networktocode.com/api/dcim/', 'extras': 'https://nautobot.demo.networktocode.com/api/extras/', 'graphql': 'https://nautobot.demo.networktocode.com/api/graphql/', 'ipam': 'https://nautobot.demo.networktocode.com/api/ipam/', 'plugins': 'https://nautobot.demo.networktocode.com/api/plugins/', 'status': 'https://nautobot.demo.networktocode.com/api/status/', 'tenancy': 'https://nautobot.demo.networktocode.com/api/tenancy/', 'users': 'https://nautobot.demo.networktocode.com/api/users/', 'virtualization': 'https://nautobot.demo.networktocode.com/api/virtualization/'}

>>> type(response.json())
<class 'dict'>

In some cases, we will have to specify the desired data format by setting the Accept header. For example:

headers = {'Accept': 'application/json'}
response = requests.get('https://nautobot.demo.networktocode.com/api', headers=headers)

In this example, we are informing the API that we would like the data to come back formatted as JSON. If the API provides the content as XML, we would specify the header as {'Accept': 'application/xml'}. The appropriate content type to request should be spelled out in the vendor API documentation. Many APIs use a default, so you may not need to specify the header. Nautobot happens to use a default of application/json, so it isn’t necessary to set the header. If you do not set the Accept header, you can find out the type of returned content by examining the Content-Type header in the response:

>>> response.headers['Content-Type']
'application/json'

Although we are using Nautobot for many of the examples of requests module usage, there is a very useful SDK called pynautobot that can handle a lot of the heavy lifting for you, so definitely check that out!

Authentication

Most APIs are protected by an authentication mechanism which can vary from product to product. The API documentation is your best resource in determining the method of authentication in use. We’ll review a few of the more common methods with examples below.

API Key

With API key authentication you typically must first access an administrative portal and generate an API key. Think of the API key the same way as you would your administrative userid/password. In some cases it will provide read/write administrative access to the entire system, so you want to protect it as such. This means don’t store it in the code or in a git repository where it can be seen in clear text. Commonly the API keys are stored as environment variables and imported at run time, or are imported from password vaults such as Hashicorp or Ansible vault. Once an API key is generated, it will need to be included in some way with all requests. Next we’ll describe a few common methods for including the API key in requests and provide example code.

Token in Authorization Header

One method that is used across a wide variety of APIs is to include the API key as a token in the Authorization header. A few examples of this are in the authentication methods for Nautobot and Cisco Webex. The two examples below are very similar, with the main difference being that Nautobot uses Token {token} in the Authorization header whereas Cisco Webex uses Bearer {token} in the Authorization header. Implementation of this is not standardized, so the API documentation should indicate what the format of the header should be.

Nautobot API

First, it is necessary to generate an API key from the Nautobot GUI. Sign into Nautobot and select your username in the upper right-hand corner, and then view your Profile. From the Profile view, select API Tokens and click the button to add a token. The token will then need to be specified in the Authorization header in all requests as shown below.

import requests
import os

# Get the API token from an environment variable
token = os.environ.get('NAUTOBOT_TOKEN')

# Add the Authorization header
headers = {'Authorization': f'Token {token}'}

# This is the base URL for all Nautobot API calls
base_url = 'https://nautobot.demo.networktocode.com/api'

# Get the list of devices from Nautobot using the requests module and passing in the authorization header defined above
response = requests.get('https://nautobot.demo.networktocode.com/api/dcim/devices/', headers=headers)

>>> response.json()
{'count': 511, 'next': 'https://nautobot.demo.networktocode.com/api/dcim/devices/?limit=50&offset=50', 'previous': None, 'results': [{'id': 'fd94038c-f09f-4389-a51b-ffa03e798676', 'url': 'https://nautobot.demo.networktocode.com/api/dcim/devices/fd94038c-f09f-4389-a51b-ffa03e798676/', 'name': 'ams01-edge-01', 'device_type': {'id': '774f7008-3a75-46a2-bc75-542205574cee', 'url': 'https://nautobot.demo.networktocode.com/api/dcim/device-types/774f7008-3a75-46a2-bc75-542205574cee/', 'manufacturer': {'id': 'e83e2d58-73e2-468b-8a86-0530dbf3dff9', 'url': 'https://nautobot.demo.networktocode.com/api/dcim/manufacturers/e83e2d58-73e2-468b-8a86-0530dbf3dff9/', 'name': 'Arista', 'slug': 'arista', 'display': 'Arista'}, 'model': 'DCS-7280CR2-60', 'slug': 'dcs-7280cr2-60', 'display': 'Arista DCS-7280CR2-60'}, 'device_role': {'id': 'bea7cc02-e254-4b7d-b871-6438d1aacb76', 'url': 'https://nautobot.demo.networktocode.com/api/dcim/device-roles/bea7cc02-e254-4b7d-b871-6438d1aacb76/'
--- OUTPUT TRUNCATED FOR BREVITY ---

Cisco Webex API

When working with the Webex API, a bot must be created to get an API key. First create a bot in the dashboard https://developer.webex.com/docs/. Upon creating the bot you are provided a token which is good for 100 years. The token should then be included in the Authorization header in all requests as shown below.

import requests
import os

# Get the API token from an environment variable
token = os.environ.get('WEBEX_TOKEN')

# Add the Authorization header
headers = {'Authorization': f'Bearer {token}'}

# This is the base URL for all Webex API calls
base_url = 'https://webexapis.com'

# Get list of rooms
response = requests.get(f'{base_url}/v1/rooms', headers=headers)

>>> response.json()
{'items': [{'id': 'Y2lzY29zcGFyazovL3VzL1JPT00vNjZlNmZjYTAtMjIxZS0xMWVjLTg2Y2YtMzk0NmQ2YTMzOWVi', 'title': 'nautobot-chatops', 'type': 'group', 'isLocked': False, 'lastActivity': '2021-10-22T19:37:38.091Z', 'creatorId': 'Y2lzY29zcGFyazovL3VzL1BFT1BMRS9iYmRiZDljNC1hMTRkLTQwMTYtYjVjZi1jOGExNzY0MWI1YWQ', 'created': '2021-09-30T18:44:11.242Z', 'ownerId': 'Y2lzY29zcGFyazovL3VzL09SR0FOSVpBVElPTi8zZjE3OTcwNi1mMTFhLTRhYjctYmEzZS01N2E0YTk2YjA4OWY'}, {'id': 'Y2lzY29zcGFyazovL3VzL1JPT00vNzBjZTgwYTAtMjIxMi0xMWVjLWEwMDAtZjcyZTAyM2Q2MDIx', 'title': 'Webex space for Matt', 'type': 'group', 'isLocked': False, 'lastActivity': '2021-09-30T17:18:33.898Z', 'creatorId': 'Y2lzY29zcGFyazovL3VzL1BFT1BMRS9iYmRiZDljNC1hMTRkLTQwMTYtYjVjZi1jOGExNzY0MWI1YWQ', 'created': '2021-09-30T17:18:33.898Z', 'ownerId': 'Y2lzY29zcGFyazovL3VzL09SR0FOSVpBVElPTi8zZjE3OTcwNi1mMTFhLTRhYjctYmEzZS01N2E0YTk2YjA4OWY'}, {'id': 'Y2lzY29zcGFyazovL3VzL1JPT00vOWIwN2FmMjYtYmQ4Ny0zYmYwLWI2YzQtNTdlNmY1OGQwN2E2', 'title': 'Jason Belk', 'type': 'direct', 'isLocked': False, 'lastActivity': '2021-01-26T19:53:01.306Z', 'creatorId': 'Y2lzY29zcGFyazovL3VzL1BFT1BMRS9jNzg2YjVmOC1hZTdjLTQyMzItYjRiNS1jNzQxYTU3MjU4MzQ', 'created': '2020-12-10T17:53:01.202Z'}, {'id': 'Y2lzY29zcGFyazovL3VzL1JPT00vNTYwNzhhNTAtMTNjMi0xMWViLWJiNjctMTNiODIxYWUyMjE1', 'title': 'NTC NSO Projects', 'type': 'group', 'isLocked': False, 'lastActivity': '2021-05-28T17:46:16.727Z', 'creatorId': 'Y2lzY29zcGFyazovL3VzL1BFT1BMR
--- OUTPUT TRUNCATED FOR BREVITY ---

Custom Token Header

Some APIs require that the API key be provided in a custom header that is included with all requests. The key and the format to use for the value should be spelled out in the API documentation.

Cisco Meraki

Cisco Meraki requires that all requests have an X-Cisco-Meraki-API-Key header with the API key as the value. As with the Token in Authorization Header method discussed previously, you must first go to the API dashboard and generate an API key. This is done in the Meraki Dashboard under your profile settings. The key should then be specified in the X-Cisco-Meraki-API-Key for all requests.

import requests
import os

# Get the API key from an environment variable
api_key = os.environment.get('MERAKI_API_KEY')

# The base URI for all requests
base_uri = "https://api.meraki.com/api/v0"

# Set the custom header to include the API key
headers = {'X-Cisco-Meraki-API-Key': api_key}

# Get a list of organizations
response = requests.get(f'{base_uri}/organizations', headers=headers)

>>> response.json()
[{'id': '681155', 'name': 'DeLab', 'url': 'https://n392.meraki.com/o/49Gm_c/manage/organization/overview'}, {'id': '575334852396583536', 'name': 'TNF - The Network Factory', 'url': 'https://n22.meraki.com/o/K5Faybw/manage/organization/overview'}, {'id': '573083052582914605', 'name': 'Jacks_test_net', 'url': 'https://n18.meraki.com/o/22Uqhas/manage/organization/overview'}, {'id': '549236', 'name': 'DevNet Sandbox', 'url': 'https://n149.meraki.com/o/-t35Mb/manage/organization/overview'}, {'id': '575334852396583264', 'name': 'My organization', 'url': 'https://n22.meraki.com/o/
--- OUTPUT TRUNCATED FOR BREVITY ---

HTTP Basic Authentication w/ Token

Some APIs require that you first issue an HTTP POST to a login url using HTTP Basic Authentication. A token that must be used on subsequent requests is then issued in the response. This type of authentication does not require going to an administrative portal first to generate the token; the token is automatically generated upon successful login.

HTTP Basic Authentication/Token – Cisco DNA Center

The Cisco DNA Center login process requires that a request first be sent to a login URL with HTTP Basic Authentication, and upon successful authentication issues a token in the response. The token must then be sent in an X-Auth-Token header in subsequent requests.

import requests
from requests.auth import HTTPBasicAuth
import os

username = os.environ.get('DNA_USERNAME')
password = os.environ.get('DNA_PASSWORD')

hostname = 'sandboxdnac2.cisco.com'

# Create an HTTPBasicAuth object that will be passed to requests
auth = HTTPBasicAuth(username, password)

# Define the login URL to get the token
login_url = f"https://{hostname}/dna/system/api/v1/auth/token"

# Issue a login request
response = requests.post(login_url, auth=auth)

# Parse the token from the response if the response was OK 
if response.ok:
    token = response.json()['Token']
else:
    print(f'HTTP Error {response.status_code}:{response.reason} occurred')

# Define the X-Auth-Token header to be used in subsequent requests
headers = {'X-Auth-Token': token}

# Define the url for getting network health information from DNA Center
url = f"https://{hostname}/dna/intent/api/v1/network-health"

# Retrieve network health information from DNA Center
response = requests.get(url, headers=headers, auth=auth)

>>> response.json()
{'version': '1.0', 'response': [{'time': '2021-10-22T19:40:00.000+0000', 'healthScore': 100, 'totalCount': 14, 'goodCount': 14, 'unmonCount': 0, 'fairCount': 0, 'badCount': 0, 'entity': None, 'timeinMillis': 1634931600000}], 'measuredBy': 'global', 'latestMeasuredByEntity': None, 'latestHealthScore': 100, 'monitoredDevices': 14, 'monitoredHealthyDevices': 14, 'monitoredUnHealthyDevices': 0, 'unMonitoredDevices': 0, 'healthDistirubution': [{'category': 'Access', 'totalCount': 2, 'healthScore': 100, 'goodPercentage': 100, 'badPercentage': 0, 'fairPercentage': 0, 'unmonPercentage': 0, 'goodCount': 2, 'badCount': 0, 'fairCount': 0, 'unmonCount': 0}, {'category': 'Distribution', 'totalCount': 1, 'healthScore': 100, 'good
--- OUTPUT TRUNCATED FOR BREVITY ---

POST with JSON Payload

With this method of authentication, the user must first issue a POST to a login URL and include a JSON (most common), XML, or other type of payload that contains the user credentials. A token that must be used with subsequent API requests is then returned. In some cases the token is returned as a cookie in the response. When that is the case, a shortcut is to use a requests.session object. By using a session object, the token in the cookie can easily be reused on subsequent requests by sourcing the requests from the session object. This is the strategy used in the Cisco ACI example below.

POST with JSON Payload – Cisco ACI

Cisco ACI requires a JSON payload to be posted to the /aaaLogin URL endpoint with the username/password included. The response includes a cookie with key APIC-cookie and a token in the value that can be used on subsequent requests.

import requests
import os

username = os.environ.get('USERNAME')
password = os.environ.get('PASSWORD')
hostname = 'sandboxapicdc.cisco.com'

# Build the JSON payload with userid/password
payload = {"aaaUser": {"attributes": {"name": username, "pwd" : password }}}

# Create a Session object
session = requests.session()

# Specify the login URL
login_url = f'https://{hostname}/api/aaaLogin.json'

# Issue the login request. The cookie will be stored in session.cookies. 
response = session.post(login_url, json=payload, verify=False)

# Use the session object to get ACI tenants
if response.ok:
    response = session.get(f'https://{hostname}/api/node/class/fvTenant.json', verify=False)
else:
    print(f"HTTP Error {response.status_code}:{response.reason} occurred.")

>>> response.json()
{'totalCount': '4', 'imdata': [{'fvTenant': {'attributes': {'annotation': '', 'childAction': '', 'descr': '', 'dn': 'uni/tn-common', 'extMngdBy': '', 'lcOwn': 'local', 'modTs': '2021-10-08T15:31:47.480+00:00', 'monPolDn': 'uni/tn-common/monepg-default', 'name': 'common', 'nameAlias': '', 'ownerKey': '', 'ownerTag': '', 'status': '', 'uid': '0', 'userdom': 'all'}}}, {'fvTenant': {'attributes': {'annotation': '', 'childAction': '', 'descr': '', 'dn': 'uni/tn-infra', 'extMngdBy': '', 'lcOwn': 'local', 'modTs': '2021-10-08T15:31:55.077+00:00', 'monPolDn': 'uni/tn-common/monepg-default', 'name': 'infra', 'nameAlias': '', 'ownerKey': '', 'ownerTag': '', 'status': '', 'uid': '0', 'userdom': 'all'}}},
--- OUTPUT TRUNCATED FOR BREVITY ---

Certificate Checking

Note the verify=False in the above example. This can be used to turn off certificate checking when the device or API you are targeting is using a self-signed or invalid SSL certificate. This will cause a log message similar to the following to be generated:

InsecureRequestWarning: Unverified HTTPS request is being made to host ‘sandboxapicdc.cisco.com’. Adding certificate verification is strongly advised. See: https://urllib3.readthedocs.io/en/latest/advanced-usage.html#ssl-warnings`

The solution that should be used for a production deployment would be to install a valid SSL certificate, and don’t use verify=False. However, if you are dealing with lab devices that may never have a valid certificate then the message can be disabled using the following snippet:

import urllib3

urllib3.disable_warnings()

Handling Errors

It is helpful when working with requests to understand HTTP status codes and some of the common triggers for them when working with APIs. HTTP status codes indicate the success or failure of a request, and when errors occur, can give a hint toward what the problem might be. Here are some common HTTP status codes that you might see when working with APIs and potential causes:

200 OK: The request was successful

201 Created: Indicates a POST or PUT request was successful

204 Deleted: Indicates a successful DELETE request

400 Bad Request: Usually indicates there was a problem with the payload in the case of a POST, PUT, or PATCH request

401 Unauthorized: Invalid or missing credentials

403 Forbidden: An authenticated user does not have permission to the requested resource

404 Not Found: The URL was not recognized

429 Too Many Requests: The API may have rate limiting in effect. Check the API docs to see if there is a limit on number of requests per second or per minute.

500 Internal Server Error: The server encountered an error processing your request. Like a 400, this can also be caused by a bad payload on a POST, PUT or PATCH.

When the requests module receives the above status codes in the response, it returns a response object and populates the status_code and reason fields in the response object. If a connectivity error occurs, such as a hostname that is unreachable or unresolvable, requests will throw an exception. However, requests will not throw an exception by default for HTTP-based errors such as the 4XX and 5XX errors above. Instead it will return the failure status code and reason in the response. A common strategy in error handling is to use the raise_for_status() method of the response object to also throw an exception for HTTP-based errors as well. Then a Python try/except block can be used to catch any of the errors and provide a more human-friendly error message to the user, if desired.

Note that HTTP status codes in the 2XX range indicate success, and thus raise_for_status() will not raise an exception.

<span role="button" tabindex="0" data-code="# Example of error for which Requests would throw an exception # Define a purposely bad URL url = 'https://badhostname' # Implement a try/except block to handle the error try: response = requests.get(url, json=data) response.raise_for_status() except requests.exceptions.RequestException as e: print(f"Error while connecting to {url}: {e}") Error while connecting to https://badhostname: HTTPSConnectionPool(host='badhostname', port=443): Max retries exceeded with url: / (Caused by NewConnectionError('
# Example of error for which Requests would throw an exception

# Define a purposely bad URL
url = 'https://badhostname'

# Implement a try/except block to handle the error
try:
    response = requests.get(url, json=data)
    response.raise_for_status()
except requests.exceptions.RequestException as e:
    print(f"Error while connecting to {url}: {e}")

Error while connecting to https://badhostname: HTTPSConnectionPool(host='badhostname', port=443): Max retries exceeded with url: / (Caused by NewConnectionError('<urllib3.connection.HTTPSConnection object at 0x108890d60>: Failed to establish a new connection: [Errno 8] nodename nor servname provided, or not known'))

# Example of HTTP error, no exception thrown but we force one to be triggered with raise_for_status()

# Define a purposely bad URL
url = 'https://nautobot.demo.networktocode.com/api/dcim/regions/bogus'

# Get the API token from an environment variable.
token = os.environ.get('NAUTOBOT_TOKEN')

# Add the Authorization header
headers = {'Authorization': f'Token {token}'}

# Implement a try/except block to handle the error
try:
   response = requests.get(url, headers=headers)
   response.raise_for_status()
except requests.exceptions.RequestException as e:
   print(f"Error while connecting to {url}: {e}")

Error while connecting to https://nautobot.demo.networktocode.com/api/dcim/regions/bogus: 404 Client Error: Not Found for url: https://nautobot.demo.networktocode.com/api/dcim/regions/bogus/

CRUD (Create, Replace, Update, Delete) API Objects

So far we have mostly discussed retrieving data from an API using HTTP GET requests. When creating/updating objects, HTTP POST, PUT, and PATCH are used. A DELETE request would be used to remove objects from the API.

  • POST: Used when creating a new object
  • PATCH: Update an attribute of an object
  • PUT: Replaces an object with a new one
  • DELETE: Delete an object

It should be noted that some APIs support both PUT and PATCH, while some others may support only PUT or only PATCH. The Meraki API that we’ll be using for the following example supports only PUT requests to change objects.

POST

When using a POST request with an API, you typically must send a payload along with the request in the format required by the API (usually JSON, sometimes XML, very rarely something else). The format needed for the payload should be documented in the API specification. When using JSON format, you can specify the json argument when making the call to requests.post. For example, requests.post(url, headers=headers, json=payload). Other types of payloads such as XML would use the data argument. For example, requests.post(url, headers=headers, data=payload).

Create a Region in Nautobot

With Nautobot, we can determine the required payload by looking at the Swagger docs that are on the system itself at /api/docs/. Let’s take a look at the Swagger spec to create a Region in Nautobot.

Create a Region in Nautobot

The fields marked with a red * above indicate that they are required fields, the other fields are optional. If we click the Try it out button as shown above, it gives us an example payload.

Create a Region in Nautobot

Since the name and slug are the only required fields, we can form a payload from the example omitting the other fields if desired. The below code snippet shows how we can create the Region in Nautobot using requests.post.

<span role="button" tabindex="0" data-code="import requests import os # Get the API token from an environment variable. token = os.environ.get('NAUTOBOT_TOKEN') # Add the Authorization header headers = {'Authorization': f'Token {token}'} # This is the base URL for all Nautobot API calls base_url = 'https://nautobot.demo.networktocode.com/api' # Form the payload for the request, per the API specification payload = { "name": "Asia Pacific", "slug": "asia-pac", } # Create the region in Nautobot response = requests.post('https://nautobot.demo.networktocode.com/api/dcim/regions/', headers=headers, json=payload) >>> response
import requests
import os

# Get the API token from an environment variable.
token = os.environ.get('NAUTOBOT_TOKEN')

# Add the Authorization header
headers = {'Authorization': f'Token {token}'}

# This is the base URL for all Nautobot API calls
base_url = 'https://nautobot.demo.networktocode.com/api'

# Form the payload for the request, per the API specification
payload = {
    "name": "Asia Pacific",
    "slug": "asia-pac",
}

# Create the region in Nautobot
response = requests.post('https://nautobot.demo.networktocode.com/api/dcim/regions/', headers=headers, json=payload)

>>> response
<Response [201]>
>>> response.reason
'Created'

PATCH

PATCH request can be used to update an attribute of an object. For example, in this next snippet we will change the description of the Region we just created in the POST request. It was omitted in the previous POST request so it is currently a blank string. Although it is not called out in the Swagger API specification, the PATCH request for Nautobot requires the id field to be defined in the payload. The id can be looked up for our previously created Region by doing a requests.get on /regions?slug=asia-pac. The ?slug=asia-pac at the end of the URL is a query parameter that is used to filter the request for objects having a field matching a specific value. In this case, we filtered the objects for the one with the slug field set to asia-pac to grab the ID. In addition, the payload needs to be in the form of a list of dictionaries rather than a single dictionary as is shown in the Swagger example.

Update a Region Description in Nautobot

<span role="button" tabindex="0" data-code="import requests import os # Get the API token from an environment variable. token = os.environ.get('NAUTOBOT_TOKEN') # Add the Authorization header headers = {'Authorization': f'Token {token}'} # This is the base URL for all Nautobot API calls base_url = 'https://nautobot.demo.networktocode.com/api' # First we get the region_id from our previously created region response = requests.get('https://nautobot.demo.networktocode.com/api/dcim/regions/?slug=asia-pac', headers=headers) >>> response.json() {'count': 1, 'next': None, 'previous': None, 'results': [{'id': 'be2c22a2-56ce-4d84-8ac9-5a68c6a39d62', 'url': 'https://nautobot.demo.networktocode.com/api/dcim/regions/be2c22a2-56ce-4d84-8ac9-5a68c6a39d62/', 'name': 'Asia Pacific', 'slug': 'asia-pac', 'parent': None, 'description': 'Test region created from the API!', 'site_count': 0, '_depth': 0, 'custom_fields': {}, 'created': '2021-10-22', 'last_updated': '2021-10-22T21:20:07.628690', 'display': 'Asia Pacific'}]} # Parse the above response for the region identifier region_id = response.json()['results'][0]['id'] # Form the payload for the request, per the API specification (see preceding paragraph for some nuances!) payload = [{ "name": "Asia Pacific", "slug": "asia-pac", "description": "Test region created from the API!", "id": region_id }] # Update the region in Nautobot response = requests.patch('https://nautobot.demo.networktocode.com/api/dcim/regions/', headers=headers, json=payload) >>> response
import requests
import os

# Get the API token from an environment variable.
token = os.environ.get('NAUTOBOT_TOKEN')

# Add the Authorization header
headers = {'Authorization': f'Token {token}'}

# This is the base URL for all Nautobot API calls
base_url = 'https://nautobot.demo.networktocode.com/api'

# First we get the region_id from our previously created region
response = requests.get('https://nautobot.demo.networktocode.com/api/dcim/regions/?slug=asia-pac', headers=headers)

>>> response.json()
{'count': 1, 'next': None, 'previous': None, 'results': [{'id': 'be2c22a2-56ce-4d84-8ac9-5a68c6a39d62', 'url': 'https://nautobot.demo.networktocode.com/api/dcim/regions/be2c22a2-56ce-4d84-8ac9-5a68c6a39d62/', 'name': 'Asia Pacific', 'slug': 'asia-pac', 'parent': None, 'description': 'Test region created from the API!', 'site_count': 0, '_depth': 0, 'custom_fields': {}, 'created': '2021-10-22', 'last_updated': '2021-10-22T21:20:07.628690', 'display': 'Asia Pacific'}]}

# Parse the above response for the region identifier
region_id = response.json()['results'][0]['id']

# Form the payload for the request, per the API specification (see preceding paragraph for some nuances!)
payload = [{
    "name": "Asia Pacific",
    "slug": "asia-pac",
    "description": "Test region created from the API!",
    "id": region_id
}]

# Update the region in Nautobot
response = requests.patch('https://nautobot.demo.networktocode.com/api/dcim/regions/', headers=headers, json=payload)

>>> response
<Response [200]>

PUT

PUT request is typically used to replace an an entire object including all attributes of the object.

Replace a Region Object in Nautobot

Let’s say we want to replace the entire Region object that we created previously, giving it a completely new name, slug and description. For this we can use a PUT request, specifying the id of the previously created Region and providing new values for the name, slug, and description attributes.

<span role="button" tabindex="0" data-code="import requests import os # Get the API token from an environment variable token = os.environ.get('NAUTOBOT_TOKEN') # Add the Authorization header headers = {'Authorization': f'Token {token}'} # This is the base URL for all Nautobot API calls base_url = 'https://nautobot.demo.networktocode.com/api' # First we get the region_id from our previously created region response = requests.get('https://nautobot.demo.networktocode.com/api/dcim/regions/?slug=asia-pac', headers=headers) >>> response.json() {'count': 1, 'next': None, 'previous': None, 'results': [{'id': 'be2c22a2-56ce-4d84-8ac9-5a68c6a39d62', 'url': 'https://nautobot.demo.networktocode.com/api/dcim/regions/be2c22a2-56ce-4d84-8ac9-5a68c6a39d62/', 'name': 'Asia Pacific', 'slug': 'asia-pac', 'parent': None, 'description': 'Test region created from the API!', 'site_count': 0, '_depth': 0, 'custom_fields': {}, 'created': '2021-10-22', 'last_updated': '2021-10-22T21:20:07.628690', 'display': 'Asia Pacific'}]} # Parse the above response for the region identifier region_id = response.json()['results'][0]['id'] # Form the payload for the request, per the API specification (see preceding paragraph for some nuances!) payload = [{ "name": "Test Region", "slug": "test-region-1", "description": "Asia Pac region updated with a PUT request!", "id": region_id }] # Update the region in Nautobot response = requests.put('https://nautobot.demo.networktocode.com/api/dcim/regions/', headers=headers, json=payload) >>> response
import requests
import os

# Get the API token from an environment variable
token = os.environ.get('NAUTOBOT_TOKEN')

# Add the Authorization header
headers = {'Authorization': f'Token {token}'}

# This is the base URL for all Nautobot API calls
base_url = 'https://nautobot.demo.networktocode.com/api'

# First we get the region_id from our previously created region
response = requests.get('https://nautobot.demo.networktocode.com/api/dcim/regions/?slug=asia-pac', headers=headers)

>>> response.json()
{'count': 1, 'next': None, 'previous': None, 'results': [{'id': 'be2c22a2-56ce-4d84-8ac9-5a68c6a39d62', 'url': 'https://nautobot.demo.networktocode.com/api/dcim/regions/be2c22a2-56ce-4d84-8ac9-5a68c6a39d62/', 'name': 'Asia Pacific', 'slug': 'asia-pac', 'parent': None, 'description': 'Test region created from the API!', 'site_count': 0, '_depth': 0, 'custom_fields': {}, 'created': '2021-10-22', 'last_updated': '2021-10-22T21:20:07.628690', 'display': 'Asia Pacific'}]}

# Parse the above response for the region identifier
region_id = response.json()['results'][0]['id']

# Form the payload for the request, per the API specification (see preceding paragraph for some nuances!)
payload = [{
    "name": "Test Region",
    "slug": "test-region-1",
    "description": "Asia Pac region updated with a PUT request!",
    "id": region_id
}]

# Update the region in Nautobot
response = requests.put('https://nautobot.demo.networktocode.com/api/dcim/regions/', headers=headers, json=payload)

>>> response
<Response [200]>

# Search for the region using the new slug
response = requests.get('https://nautobot.demo.networktocode.com/api/dcim/regions/?slug=test-region-1', headers=headers)

# This returns the replaced object, while retaining the same identifier
>>> response.json()
{'count': 1, 'next': None, 'previous': None, 'results': [{'id': 'be2c22a2-56ce-4d84-8ac9-5a68c6a39d62', 'url': 'https://nautobot.demo.networktocode.com/api/dcim/regions/be2c22a2-56ce-4d84-8ac9-5a68c6a39d62/', 'name': 'Test Region', 'slug': 'test-region-1', 'parent': None, 'description': 'Asia Pac region updated with a PUT request!', 'site_count': 0, '_depth': 0, 'custom_fields': {}, 'created': '2021-10-22', 'last_updated': '2021-10-25T17:31:04.003235', 'display': 'Test Region'}]}

Enable an SSID in Meraki

Let’s look at another example of using a PUT to enable a wireless SSID in the Cisco Meraki dashboard. For this we will use a PUT request including the appropriate JSON payload to enable SSID 14.

import requests
import os

# Get the API key from an environment variable
api_key = os.environment.get('MERAKI_API_KEY')

# The base URI for all requests
base_uri = "https://api.meraki.com/api/v0"

# Set the custom header to include the API key
headers = {'X-Cisco-Meraki-API-Key': api_key}

net_id = 'DNENT2-mxxxxxdgmail.com' 
ssid_number = 14

url = f'{base_uri}/networks/{net_id}/ssids/{ssid_number}'

# Initiate the PUT request to enable an SSID. You must have a reservation in the Always-On DevNet sandbox to gain authorization for this. 
response = requests.put(url, headers=headers, json={"enabled": True})

DELETE

An object can be removed by making a DELETE request to the URI (Universal Resource Indicator) of an object. The URI is the portion of the URL that refers to the object, for example /dcim/regions/{id} in the case of the Nautobot Region.

Remove a Region from Nautobot

Let’s go ahead and remove the Region that we previously added. To do that, we’ll send a DELETE request to the URI of the region. The URI can be seen when doing a GET request in the url attribute of the Region object. We can also see in the API specification for DELETE that the call should be made to /regions/{id}

<span role="button" tabindex="0" data-code="# Search for the region using the slug response = requests.get('https://nautobot.demo.networktocode.com/api/dcim/regions/?slug=test-region-1', headers=headers) # Parse the URL from the GET request url = response.json()['results'][0]['url'] >>> url 'https://nautobot.demo.networktocode.com/api/dcim/regions/be2c22a2-56ce-4d84-8ac9-5a68c6a39d62/' # Delete the Region object response = requests.delete(url, headers=headers) # A status code of 204 indicates successful deletion >>> response
# Search for the region using the slug
response = requests.get('https://nautobot.demo.networktocode.com/api/dcim/regions/?slug=test-region-1', headers=headers)

# Parse the URL from the GET request
url = response.json()['results'][0]['url']

>>> url
'https://nautobot.demo.networktocode.com/api/dcim/regions/be2c22a2-56ce-4d84-8ac9-5a68c6a39d62/'

# Delete the Region object
response = requests.delete(url, headers=headers)

# A status code of 204 indicates successful deletion
>>> response
<Response [204]>

Rate Limiting

Some APIs implement a throttling mechanism to prevent the system from being overwhelmed with requests. This is usually implemented as a rate limit of X number of requests per minute. When the rate limit is hit, the API returns a status code 429: Too Many Requests. To work around this, your code must implement a backoff timer in order to avoid hitting the threshold. Here’s an example working around the Cisco DNA Center rate limit of 5 requests per minute:

<span role="button" tabindex="0" data-code="import requests from requests.auth import HTTPBasicAuth import time from pprint import pprint import os # Pull in credentials from environment variables username = os.environ.get('USERNAME') password = os.environ.get('PASSWORD') hostname = "sandboxdnac2.cisco.com" headers = {"Content-Type": "application/json"} # Use Basic Authentication auth = HTTPBasicAuth(username, password) # Request URL for the token login_url = f"https://{hostname}/dna/system/api/v1/auth/token" # Retrieve the token resp = requests.post(login_url, headers=headers, auth=auth) token = resp.json()['Token'] # Add the token to subsequent requests headers['X-Auth-Token'] = token url = f"https://{hostname}/dna/intent/api/v1/network-device" resp = requests.get(url, headers=headers, auth=auth) count = 0 # Loop over devices and get device by id # Each time we reach five requests, pause for 60 seconds to avoid the rate limit for i, device in enumerate(resp.json()['response']): count += 1 device_count = len(resp.json()['response']) print (f"REQUEST #{i+1}") url = f"https://{hostname}/dna/intent/api/v1/network-device/{device['id']}" response = requests.get(url, headers=headers, auth=auth) pprint(response.json(), indent=2) if count == 5 and (i+1)
import requests
from requests.auth import HTTPBasicAuth
import time
from pprint import pprint
import os

# Pull in credentials from environment variables  
username = os.environ.get('USERNAME')
password = os.environ.get('PASSWORD')
hostname = "sandboxdnac2.cisco.com"

headers = {"Content-Type": "application/json"}
# Use Basic Authentication
auth = HTTPBasicAuth(username, password)

# Request URL for the token
login_url = f"https://{hostname}/dna/system/api/v1/auth/token"

# Retrieve the token
resp = requests.post(login_url, headers=headers, auth=auth)
token = resp.json()['Token']

# Add the token to subsequent requests
headers['X-Auth-Token'] = token

url = f"https://{hostname}/dna/intent/api/v1/network-device"
resp = requests.get(url, headers=headers, auth=auth)

count = 0
# Loop over devices and get device by id
# Each time we reach five requests, pause for 60 seconds to avoid the rate limit
for i, device in enumerate(resp.json()['response']):
    count += 1
    device_count = len(resp.json()['response'])
    print (f"REQUEST #{i+1}")
    url = f"https://{hostname}/dna/intent/api/v1/network-device/{device['id']}"
    response = requests.get(url, headers=headers, auth=auth)
    pprint(response.json(), indent=2)
    if count == 5 and (i+1) < device_count:
      print("Sleeping for 60 seconds...")
      time.sleep(60)
      count = 0

Pagination

Some API calls may set a limit on the number of objects that are returned in a single call. In this case, the API should return paging details in the JSON body including the URL to request the next set of data as well as the previous set. If Previous is empty, we are on the first set of data. If Next is empty, we know we have reached the end of the dataset. Some API implementations follow RFC5988, which includes a Link header in the format:

Link: https://webexapis.com/v1/people?displayName=Harold&max=10&before&after=Y2lzY29zcGFyazovL3VzL1BFT1BMRS83MTZlOWQxYy1jYTQ0LTRmZWQtOGZjYS05ZGY0YjRmNDE3ZjU;

The above example is from the Webex API, which implements RFC5988. This is described in the API documentation here: https://developer.webex.com/docs/api/basics

Keep in mind that not all implementations use the RFC, however. The API documentation should explain how pagination is handled.

Handling Pagination in Nautobot

A good example of pagination can be seen when making a GET request to retrieve all Devices from Nautobot. Nautobot includes a countnext, and previous attribute in responses that are paginated. By default, the API will return a maximum of 50 records. The limit value as well as an offset value are indicated in the next value of the response. For example: 'next': 'https://nautobot.demo.networktocode.com/api/dcim/devices/?limit=50&offset=50'. In the URL, the limit indicates the max amount of records, and the offset indicates where the next batch of records begins. The previous attribute indicates the url for the previous set of records. If previous is None, it means we are on the first set of records. And if next is None, it means we are on the last set of records.

In the below snippet, we first retrieve the first set of 50 records and store them in a device_list variable. We then create a while loop that iterates until the next field in the response contains None. The returned results are added to the device_list at each iteration of the loop. At the end we can see that there are 511 devices, which is the same value as the count field in the response.

import requests
import os

# Get the API token from an environment variable
token = os.environ.get('NAUTOBOT_TOKEN')

# Add the Authorization header
headers = {'Authorization': f'Token {token}'}

# This is the base URL for all Nautobot API calls
base_url = 'https://nautobot.demo.networktocode.com/api'

# Create the initial request for the first batch of records
response = requests.get(f'{base_url}/dcim/devices', headers=headers)

# Store the initial device list
device_list = [device for device in response.json()['results']]

# Notice that we now have the first 50 devices
>>> len(device_list)
50

# But there are 511 total!
>>> response.json()['count']
511

# Loop until 'next' is None, adding the retrieved devices to device_list on each iteration
if response.json()['next']:
    while response.json()['next']:
        print(f"Retrieving {response.json()['next']}")
        response = requests.get(response.json()['next'], headers=headers)
        for device in response.json()['results']:
            device_list.append(device)

Retrieving https://nautobot.demo.networktocode.com/api/dcim/devices/?limit=50&offset=50
Retrieving https://nautobot.demo.networktocode.com/api/dcim/devices/?limit=50&offset=100
Retrieving https://nautobot.demo.networktocode.com/api/dcim/devices/?limit=50&offset=150
Retrieving https://nautobot.demo.networktocode.com/api/dcim/devices/?limit=50&offset=200
Retrieving https://nautobot.demo.networktocode.com/api/dcim/devices/?limit=50&offset=250
Retrieving https://nautobot.demo.networktocode.com/api/dcim/devices/?limit=50&offset=300
Retrieving https://nautobot.demo.networktocode.com/api/dcim/devices/?limit=50&offset=350
Retrieving https://nautobot.demo.networktocode.com/api/dcim/devices/?limit=50&offset=400
Retrieving https://nautobot.demo.networktocode.com/api/dcim/devices/?limit=50&offset=450
Retrieving https://nautobot.demo.networktocode.com/api/dcim/devices/?limit=50&offset=500

>>> len(device_list)
511

Handling Pagination in Cisco Webex

In the code below, first we get the Room IDs for the WebEx Teams rooms I am a member of. Then we retrieve the members from the DevNet Dev Support Questions room and create a continuous function that follows the Link URL and displays the content. The While loop is broken when the Link header is no longer present, returning None when we try to retrieve it with headers.get(‘Link’).

import requests
import re
import os

api_path = "https://webexapis.com/v1"

# You can retrieve your token here: https://developer.webex.com/docs/api/getting-started
token = os.environ.get('WEBEX_TOKEN')
headers = {"Authorization": f"Bearer {token}"}

# List the rooms, and collect the ID for the DevNet Support Questions room
get_rooms = requests.get(f"{api_path}/rooms", headers=headers)
for room in get_rooms.json()['items']:
    print(room['title'], room['id'])
    if room['title'] == "Sandbot-Support DevNet":
      room_id = room['id']

# This function will follow the Link URLs until there are no more, printing out
# the member display name and next URL at each iteration. Note that I have decreased the maximum number of records to 1 so as to force pagination. This should not be done in a real implementation. 

def get_members(room_id):
    params = {"roomId": room_id, "max": 1}
    # Make the initial request and print the member name
    response = requests.get(f"{api_path}/memberships", headers=headers, params=params)
    print(response.json()['items'][0]['personDisplayName'])
    # Loop until the Link header is empty or not present
    while response.headers.get('Link'):
        # Get the URL from the Link header
        next_url = response.links['next']['url']
        print(f"NEXT: {next_url}")
        # Request the next set of data
        response = requests.get(next_url, headers=headers)
        if response.headers.get('Link'):
            print(response.json()['items'][0]['personDisplayName'])
        else:
            print('No Link header, finished!')

# Execute the function using the Sandbox-Support DevNet RoomID
get_members(room_id)


Conclusion

I hope this has been a useful tutorial on using requests to work with vendor REST APIs! While no two API implementations are the same, many of the most common patterns for working with them are covered here. As always, hit us up in the comments or on our public Slack channel with any questions. Thanks for reading!

-Matt



ntc img
ntc img

Contact Us to Learn More

Share details about yourself & someone from our team will reach out to you ASAP!

Getting Started Using the Kubernetes Collection in Ansible Tower

Blog Detail

In this post I’ll cover setting up and working with the community.kubernetes collection in Ansible Tower. I’ll describe my experience with the initial installation of the collection on the Ansible Tower controller, discuss using a Custom Credential Type in Ansible Tower for authentication to the Kubernetes API, and cover retrieving and parsing the output from the Kubernetes cluster. Finally, I’ll provide a sample playbook to create a pod in Kubernetes and query the pod status using the collection.

While the main topic and examples are focused on using the community.kubernetes collection, much of the information here is applicable to other Ansible collections or modules as well. For example, getting Tower to recognize a collection, creating and using a Custom Credential Type, and parsing output using the json_query filter are very relevant when using any Ansible module or collection.

Overview

Ansible collections were introduced recently as a way to help scale Ansible development by allowing the maintainers of the modules to manage them outside of Ansible core. As part of this transition, the core Kubernetes modules have been migrated to the community.kubernetes collection. The modules have the same functionality and syntax as the previous core modules, but will be maintained in the collection going forward. With that being the case, it is recommended to begin utilizing the collection rather than the core modules. The goal of this post is to help the reader get started with using the collection, specifically on Ansible Tower or AWX.

Setup

First, it is necessary to install the openshift Python module which is used by the community.kubernetes modules on the Ansible Tower controller. By default Ansible Tower uses a virtual environment located at /var/lib/awx/venv/, so you must first activate that virtual environment and then use pip to install the module.

[centos@ip-10-125-1-252 ~]$ source /var/lib/awx/venv/ansible/bin/activate
(ansible) [centos@ip-10-125-1-252 ~]$ pip install openshift

The above requires elevated privileges. If you are not running as root but have sudo privileges, run the command by specifying the full path to the pip utility: sudo /var/lib/awx/venv/ansible/bin/pip install openshift

The next step is installing the collection on the Ansible Tower controller and ensuring Tower is able to find the modules in the collection. I have been able to verify two methods that work on a Tower controller running Ansible 2.9.7 and Tower 3.6.3:

Option 1: Downloading the Collection On-Demand

With this option, Ansible Tower will download the collection each time the playbook is run. Create a folder in the root of your project called collections and inside that folder include a requirements.yml file with contents such as below:

---
collections:
  - name: community.kubernetes
    version: 0.9.0

The directory structure of the project would thus be:

├── collections
    └── requirements.yml

This might not be desirable due to the delay incurred when downloading the collection, in which case you can opt for Option #2 below.

Caching of collections may remediate this in future versions of Tower/AWX. See issue #7643. Thanks to NTC vet Josh VeDeraa for the tip!

Option 2: Include a copy of the collection in your source control repository

In order to avoid having to pull the collection on each playbook run, the collection can be installed as a folder in your project. This means it will be checked into your source control repository and be tracked along with your playbooks and other files. To accomplish this, first we need to create an ansible.cfg file in the root of the repository and create the following lines:

[defaults]
collections_paths = ./

Then run the ansible-galaxy command to install a copy of the collection into an ansible_collections folder inside your project. The requirements file should have the same contents as shown above in Option #1.

ansible-galaxy collection install -r requirements.yml

This will create an ansible_collections folder at the root of your repo which contains the Kubernetes collection. The directory structure in the project will then be:

├── ansible.cfg
├── ansible_collections
│   └── community
│       └── kubernetes
└── requirements.yml

Commit the newly created ansible_collections folder into the repo, and make sure to re-sync the Project in Ansible Tower.

Avoiding the “unresolved module” problem on the Tower controller

When I originally began working on this project, I began by installing the collection locally on the Tower controller using Ansible Galaxy which would typically be the way to install collections. As it turned out, installing the module locally on the controller and then trying to get Ansible Tower to find the collections is difficult or may even be impossible, at least on our version of Tower (3.6.3). For example, you might be tempted to do this on the controller:

ansible-galaxy collection install community.kubernetes

This pulls down the collection to the Tower controller, however if you attempt to launch a template using one of the collection modules you will get an error such as:

ERROR! couldn't resolve module/action 'community.kubernetes.k8s_info'. This often indicates a misspelling, missing collection, or incorrect module path.

Even after locating where Galaxy installed the collections, /home/centos/.ansible/collections on our Tower system, and then putting this path under the collections_paths key in /etc/ansible/ansible.cfg, the modules still could not be located.

It seems like this should have worked, but at least on our version of Tower it did not. The options in the previous section were discovered only after many hours of trying to make this work, so save yourself some time!

Testing that Tower can find the module

Now that we have set things up to allow Tower to successfully find the collection, we can test it with a playbook that uses the collection. The below playbook uses the k8s_info module to gather information about the Kubernetes worker nodes.

---
- name: RETRIEVE WORKER NODE DETAILS FROM KUBERNETES NODE
  hosts: localhost
  gather_facts: no

  collections: 
    - community.kubernetes
  
  tasks:
  - name: GET WORKER NODE DETAILS
    community.kubernetes.k8s_info:
      kind: Node
      namespace: default 

The playbook called pb_test_setup.yml is checked into our GitHub repository, which is set up as a Project in Ansible Tower. It is then referenced in the below Template:

01-tower-template

If we run this playbook from the Tower controller, it will fail because we haven’t yet defined our credentials to access the control plane. A large amount of output will be displayed, but if you scroll up to the beginning of the output for the task GET WORKER NODE DETAILS, it should show the following output:

An exception occurred during task execution. To see the full traceback, use -vvv. The error was: kubernetes.config.config_exception.ConfigException: Invalid kube-config file. No configuration found.

This means that the module has been found, but we cannot connect to the Kubernetes control plane yet because we haven’t defined a kube-config file which provides the credentials to do so. In the next section I’ll show how to do that by defining a Custom Credential in Ansible Tower.

Handling Credentials

To access the Kubernetes control plane, the credentials are typically stored in a Kubeconfig file which is stored locally on the user workstation in the file ~/.kube/config by default. To securely store the credentials on the Ansible Tower controller, we can load the contents of the Kubeconfig file into a Credential in Ansible Tower. Tower does not currently have a native Kubernetes credential type, but it does provide the ability to create custom Credential Types. The steps below show how to create a Custom Credential Type in Ansible Tower to support storing of the Kubeconfig contents as a Credential. First a Kubernetes Credential Type is created, and then a Credential is created using the new credential type which stores the contents of the Kubeconfig file. We’ll then apply the Credential to the Job Template.

  1. Navigate to Administration > Credential Types and add a new credential type.
  2. Enter the following YAML in the Input Configuration:
---
fields:
  - id: kube_config
    type: string
    label: kubeconfig
    secret: true
    multiline: true
required:
  - kube_config

This defines a single input field in the credential that will be used to store the contents of the Kubeconfig file. Setting secret to true will cause the contents to be hidden in the Tower UI once saved. The label defines a caption for the field, and setting multiline to true causes a text entry field to be created which accepts multiple lines of text.

Enter the below YAML in the Injector Configuration:

---
env:
  K8S_AUTH_KUBECONFIG: "{{ tower.filename.kubeconfig }}"
file:
  template.kubeconfig: "{{ kube_config }}"

The Injector Configuration provides the ability to store the credential data input by the user on the Tower controller system in a way that allows it to be later referenced by an Ansible playbook, such as environment or extra variables. The above Injector Configuration will create a file called kubeconfig on the controller containing the content the user enters in the kube_config field of the Credential. The contents of the kubeconfig file created on the controller are then stored inside an environment variable on the Tower controller. The modules in the community.kubernetes collection by default look for the Kubeconfig in the environment variable K8S_AUTH_KUBECONFIG.

Injector Configuration is described here in the Ansible documentation

When complete the configuration should look similar to the image below:

02-custom-credential-type
  1. Navigate to Credentials and add a new Credential.
  2. In the Credential Type, select the Kubernetes custom Credential Type that was just created. In the Kubeconfig field, copy/paste the contents of your Kubeconfig file (usually ~/.kube/config). Your configuration should look similar to the following:
03-kubernetes-credential

Once you click Save, notice that the Kubeconfig contents are encrypted

  1. Apply the configuration to the Job Template. Navigate to Resources > Templates and under Credentials search for the Kubernetes credential we just defined. Under Credential Type select Kubernetes.
04-select-credential-type

When finished, your template should look similar to this:

05-template-with-credential

We are now ready to execute the playbook in Tower. If all is working correctly, we should have a successful job completion…

06-get-info-job-success

In the case of our setup we were using Amazon EKS for our Kubernetes cluster, so we also had to install the AWS CLI on the Tower controller, create an AWS credential in Tower containing the AWS API keys, and apply the AWS credential to the job template.

As you can see, the job was successful but it wasn’t very exciting because we aren’t yet displaying any of the information that we collected. In the next section, we’ll look at how to display and parse the output.

Displaying and Parsing Output

In order to display data, we first need to register a variable to store the output of the task and then create a second task using the debug module to display the variable. In the below playbook, I have added the register command to the initial task to store the output of the task in a variable called node_result, and added a debug task below it to display the registered variable.

  tasks:
  - name: GET WORKER NODE DETAILS
    community.kubernetes.k8s_info:
      kind: Node
      namespace: default
    register: node_result

  - name: DISPLAY OUTPUT
    debug:
      msg: "{{ node_result }}"

When the above playbook is run, the debug task will display a large amount of data about the Kubernetes worker node(s).


{
    "msg": {
        "changed": false,
        "resources": [
            {
                "metadata": {
                    "name": "ip-172-17-13-155.us-east-2.compute.internal",
                    "selfLink": "/api/v1/nodes/ip-172-17-31-155.us-east-2.compute.internal",
                    "uid": "27a57359-4019-458e-988f-9d4984e74662",
                    "resourceVersion": "275328",
                    "creationTimestamp": "2020-09-29T18:52:11Z",
                    "labels": {
                        "beta.kubernetes.io/arch": "amd64",
                        "beta.kubernetes.io/instance-type": "t3.medium",
                        "beta.kubernetes.io/os": "linux",
                        "eks.amazonaws.com/nodegroup": "eks-nodegroup",
                        "eks.amazonaws.com/nodegroup-image": "ami-0c619f57dc7e552a0",
                        "eks.amazonaws.com/sourceLaunchTemplateId": "lt-0ac1bb9bae7ecb7f6",
                        "eks.amazonaws.com/sourceLaunchTemplateVersion": "1",
                        "failure-domain.beta.kubernetes.io/region": "us-east-2",
                        "failure-domain.beta.kubernetes.io/zone": "us-east-2a",
                        "kubernetes.io/arch": "amd64",
                        "kubernetes.io/hostname": "ip-172-17-13-155.us-east-2.compute.internal",
                        "kubernetes.io/os": "linux",
                        "node.kubernetes.io/instance-type": "t3.medium",
                        "topology.kubernetes.io/region": "us-east-2",
                        "topology.kubernetes.io/zone": "us-east-2a"
                    },
                    "annotations": {
                        "node.alpha.kubernetes.io/ttl": "0",
                        "volumes.kubernetes.io/controller-managed-attach-detach": "true"
                    }
                },
                "spec": {
                    "providerID": "aws:///us-east-2a/i-0ba92f585eebfae76"
                },
                "status": {
                    "capacity": {
                        "attachable-volumes-aws-ebs": "25",
                        "cpu": "2",
                        "ephemeral-storage": "20959212Ki",
                        "hugepages-1Gi": "0",
                        "hugepages-2Mi": "0",
                        "memory": "3977908Ki",
                        "pods": "17"
                    },
                    "allocatable": {
                        "attachable-volumes-aws-ebs": "25",
                        "cpu": "1930m",
                        "ephemeral-storage": "18242267924",
                        "hugepages-1Gi": "0",
                        "hugepages-2Mi": "0",
                        "memory": "3422900Ki",
                        "pods": "17"
                    },
                    "conditions": [
                        {
                            "type": "MemoryPressure",
                            "status": "False",
                            "lastHeartbeatTime": "2020-09-30T20:24:06Z",
                            "lastTransitionTime": "2020-09-29T18:52:11Z",
                            "reason": "KubeletHasSufficientMemory",
                            "message": "kubelet has sufficient memory available"
                        },
                        {
                            "type": "DiskPressure",
                            "status": "False",
                            "lastHeartbeatTime": "2020-09-30T20:24:06Z",
                            "lastTransitionTime": "2020-09-29T18:52:11Z",
                            "reason": "KubeletHasNoDiskPressure",
                            "message": "kubelet has no disk pressure"
                        },
                        {
                            "type": "PIDPressure",
                            "status": "False",
                            "lastHeartbeatTime": "2020-09-30T20:24:06Z",
                            "lastTransitionTime": "2020-09-29T18:52:11Z",
                            "reason": "KubeletHasSufficientPID",
                            "message": "kubelet has sufficient PID available"
                        },
                        {
                            "type": "Ready",
                            "status": "True",
                            "lastHeartbeatTime": "2020-09-30T20:24:06Z",
                            "lastTransitionTime": "2020-09-29T18:52:31Z",
                            "reason": "KubeletReady",
                            "message": "kubelet is posting ready status"
                        }
                    ],
                    "addresses": [
                        {
                            "type": "InternalIP",
                            "address": "172.17.31.155"
                        },
                        {
                            "type": "ExternalIP",
                            "address": "13.14.131.143"
                        },
                        {
                            "type": "Hostname",
                            "address": "ip-172-17-31-155.us-east-2.compute.internal"
                        },
                        {
                            "type": "InternalDNS",
                            "address": "ip-172-17-31-155.us-east-2.compute.internal"
                        },
                        {
                            "type": "ExternalDNS",
                            "address": "ec2-13-14-131-143.us-east-2.compute.amazonaws.com"
                        }
                    ],
                    "daemonEndpoints": {
                        "kubeletEndpoint": {
                            "Port": 10250
                        }
                    },
                    "nodeInfo": {
                        "machineID": "ec2475ce297619b1bcfe0d56602b5284",
                        "systemUUID": "EC2475CE-2976-19B1-BCFE-0D56602B5284",
                        "bootID": "d591d21a-46de-4011-8941-12d72cb5950e",
                        "kernelVersion": "4.14.193-149.317.amzn2.x86_64",
                        "osImage": "Amazon Linux 2",
                        "containerRuntimeVersion": "docker://19.3.6",
                        "kubeletVersion": "v1.17.11-eks-cfdc40",
                        "kubeProxyVersion": "v1.17.11-eks-cfdc40",
                        "operatingSystem": "linux",
                        "architecture": "amd64"
                    },
                    "images": [
                        {
                            "names": [
                                "ceosimage/4.24.2.1f:latest"
                            ],
                            "sizeBytes": 1768555566
                        },
                        {
                            "names": [
                                "602401143452.dkr.ecr.us-east-2.amazonaws.com/amazon-k8s-cni@sha256:400ab98e321d88d57b9ffd15df51398e6c2c6c0167a25838c3e6d9637f6f5e0c",
                                "602401143452.dkr.ecr.us-east-2.amazonaws.com/amazon-k8s-cni:v1.6.3-eksbuild.1"
                            ],
                            "sizeBytes": 282945379
                        },
                        {
                            "names": [
                                "nfvpe/multus@sha256:9a43e0586a5e6cb33f09a79794d531ee2a6b97181cae12a82fcd2f2cd24ee65a",
                                "nfvpe/multus:stable"
                            ],
                            "sizeBytes": 277329369
                        },
                        {
                            "names": [
                                "602401143452.dkr.ecr.us-east-2.amazonaws.com/eks/kube-proxy@sha256:cbb2c85cbaa3d29d244eaec6ec5a8bbf765cc651590078ae30e9d210bac0c92a",
                                "602401143452.dkr.ecr.us-east-2.amazonaws.com/eks/kube-proxy:v1.17.9-eksbuild.1"
                            ],
                            "sizeBytes": 130676901
                        },
                        {
                            "names": [
                                "602401143452.dkr.ecr.us-east-2.amazonaws.com/eks/coredns@sha256:476c154960a843ac498376556fe5c42baad2f3ac690806b9989862064ab547c2",
                                "602401143452.dkr.ecr.us-east-2.amazonaws.com/eks/coredns:v1.6.6-eksbuild.1"
                            ],
                            "sizeBytes": 40859174
                        },
                        {
                            "names": [
                                "602401143452.dkr.ecr.us-east-2.amazonaws.com/eks/pause@sha256:1cb4ab85a3480446f9243178395e6bee7350f0d71296daeb6a9fdd221e23aea6",
                                "602401143452.dkr.ecr.us-east-2.amazonaws.com/eks/pause:3.1-eksbuild.1"
                            ],
                            "sizeBytes": 682696
                        }
                    ]
                },
                "apiVersion": "v1",
                "kind": "Node"
            }
        ],
        "ansible_facts": {
            "discovered_interpreter_python": "/usr/libexec/platform-python"
        },
        "failed": false
    },
    "_ansible_verbose_always": true,
    "_ansible_no_log": false,
    "changed": false
  }

What if we are only looking for a subset of that data? This is where a handy filter that is available in Ansible called json_query comes in. The json_query filter can be invoked by using the pipe | symbol followed by the json_query command and passing in a query as an argument. A query can be built to parse the JSON output and return specific data that we are looking for. Let’s say we wanted to return only the IP addressing assigned to the worker node. To do this, let’s add another set of tasks to the playbook:

  - name: SET VARIABLE STORING EKS WORKER NODE ADDRESS
    set_fact:
      eks_worker_address: "{{ node_result | json_query(query) }}"
    vars:
      query: "resources[].status.addresses"
  
  - name: DISPLAY EKS WORKER ADDRESS
    debug:
      msg: "{{ eks_worker_address }}"

Here we are using the set_fact module to set another variable called eks_worker_address that will contain our filtered results. The task variable query contains the query syntax that is passed to the json_query filter above. Breaking down the query, in the previous node_result variable output it can be seen that resources is a List containing many elements. The double brackets [] next to resources in the query instructs the filter to return all elements in the list. Next, under resources we have a number of dictionary keys, one of which is the status key. The .status after resources[] will filter the results to only content under the status key. Finally we have .addresses, which is a key that references another list containing all addresses on the worker node. When the task is run, the output should look similar to this:


"msg": [
    [
        {
            "type": "InternalIP",
            "address": "172.17.13.155"
        },
        {
            "type": "ExternalIP",
            "address": "3.14.130.143"
        },
        {
            "type": "Hostname",
            "address": "ip-172-17-13-155.us-east-2.compute.internal"
        },
        {
            "type": "InternalDNS",
            "address": "ip-172-17-13-155.us-east-2.compute.internal"
        },
        {
            "type": "ExternalDNS",
            "address": "ec2-3-14-130-143.us-east-2.compute.amazonaws.com"
        }
    ]
]

What if we wanted to filter this further and just return the External DNS name of our Kubernetes worker node? To accomplish this, we can filter on the type key in the list of addresses for the “ExternalDNS” value. To do this, the query in the task can be modified as shown below:

  - name: SET VARIABLE STORING EKS WORKER NODE ADDRESS
    set_fact:
      eks_worker_address: "{{ node_result | json_query(query) }}"
    vars:
      query: "resources[].status.addresses[?type=='ExternalDNS']"

Now the output is limited to just the ExternalDNS…


{
    "msg": [
        [
            {
                "type": "ExternalDNS",
                "address": "ec2-3-14-130-143.us-east-2.compute.amazonaws.com"
            }
        ]
    ],
    "_ansible_verbose_always": true,
    "_ansible_no_log": false,
    "changed": false
}

This only scratches the surface of what is possible when parsing with the json_query filter. Behind the scenes, Ansible is using a utility called JMESPATH. The query language in JMESPATH is the same syntax that is used for the query argument passed to the json_query filter. Have a look at the documentation to learn more.

Deploying Kubernetes Resources

In this final section, I’ll discuss using the community.kubernetesk8s module to initiate deployment of a Busybox pod in Kubernetes. Busybox is a lightweight utility container that is often used to validate and troubleshoot deployments. The k8s module can be used to deploy resources into Kubernetes by launching them from a Kubernetes definition file. For those familiar with using the kubectl command line utility, this is equivalent to running the command kubectl apply -f [deployment_file]. Given that we will be deploying a pod from a definition file, we must first create the definition file:

apiVersion: v1
kind: Pod
metadata:
  name: busybox
  labels:
    app: busybox
  namespace: default
spec:
  containers:
  - name: busybox
    image: busybox
    command: ['sleep','3600']
    imagePullPolicy: IfNotPresent
  restartPolicy: Always

The above yaml should be saved as busybox.yml. Next we create an Ansible playbook called pb_deploy_busybox.yml that will use the k8s module to apply the definition file.

---
- name: DEPLOY BUSYBOX
  hosts: localhost
  gather_facts: no
  
  tasks:

  - name: DEPLOY BUSYBOX TO KUBERNETES
    community.kubernetes.k8s:
        definition: "{{ lookup('template', 'busybox.yml') | from_yaml }}"
        state: present

Commit this to the the source control repository, and synchronize the project in Ansible Tower. The Job Template in Ansible Tower should look similar to this:

07-busybox-template

Here’s the output that will be shown when the template is launched in Ansible Tower:

PLAY [DEPLOY BUSYBOX] **********************************************************
09:44:35

TASK [DEPLOY BUSYBOX TO KUBERNETES] ********************************************
09:44:35

changed: [localhost]

PLAY RECAP *********************************************************************
09:44:37

localhost                  : ok=1    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

From the above output we can assume the pod was deployed successfully, and we could confirm from the command line using the kubectl get pod command. However, we can also use a similar output parsing strategy as was used in the previous section to gather information about running pods. Here we’ll use the k8s_info module to report on running Kubernetes pods, and filter the output using the json_query filter. Add another playbook called pb_display_pod_info.yml.

---
- name: RETRIEVE POD DETAILS FROM KUBERNETES
  hosts: localhost
  gather_facts: no
  
  collections: 
    - community.kubernetes

  tasks:
  - name: GET POD DETAILS
    community.kubernetes.k8s_info:
      kind: Pod
      namespace: default
    register: pod_result

  - name: DISPLAY OUTPUT
    debug:
      msg: "{{ pod_result }}"

This once again yields a lot of data, on which we can use json_query to filter the output to what we want to see. Let’s add a few more tasks to filter the output.

  - name: FILTER FOR POD NAME AND STATE
    set_fact:
      filtered_result: "{{ pod_result | json_query(query) }}"
    vars:
      query: "resources[].{name: status.containerStatuses[].name, status: status.containerStatuses[].state}"
 
  - name: DISPLAY FILTERED RESULTS
    debug:
      msg: "{{ filtered_result }}"

This time we are using a slightly different syntax for the query which creates a dictionary containing the values selected from the larger JSON output produced in the previous task, and that is stored in pod_result. Within the curly brackets {}, the name key is what we chose to hold the value retrieved by referencing status.containerStatuses[].name. Similarly, the status key holds the state of the container retrieved from status.containerStatuses[].state. The final output should be similar to below.


{
    "msg": [
        {
            "name": [
                "busybox"
            ],
            "status": [
                {
                    "running": {
                        "startedAt": "2020-10-01T13:44:38Z"
                    }
                }
            ]
        }
    ]


Conclusion

This method of filtering and storing data can be used not only for display purposes, but also to dynamically populate variables that can later be used in sub-sequent Ansible tasks if needed.

That does it for this post, I hope it provides some useful hints to get up and running quickly automating Kubernetes with Ansible Tower!

-Matt



ntc img
ntc img

Contact Us to Learn More

Share details about yourself & someone from our team will reach out to you ASAP!