18 Sep Publishing information to the Jamf Pro server using API calls and Python
Like a loyal droid, your Jamf Pro server wants to help! (Part two)
Jamf Software’s Jamf Pro server (JPS) provides an Application Programming Interface, or API, to interact with the JPS database. This allows an enterprise to customize specific areas of the JPS as needed. This post builds on the first article in the series: Fetching Data From the JSS using API calls & Python. We’ll discuss writing data to the JPS, creating XML data structures, and handling HTTP errors that may occur while communicating the server.
These articles are based on an internal inventory application that began as an experiment I described in a previous post: Using GIF images in Python GUI scripts. Since that post was written the small experiment turned into a cross-platform, automated, text- and GUI-based application that talks to our JPS, campus LDAP as well as multiple MySQL databases. The core activity of the application is maintaining the User and Location area of the Inventory pane for each computer in the JPS.
Lets talk about Representational state transfer or REST for a moment. The API in the JPS is a RESTful API and so clients use specially crafted HTTP URLs to communicate with the server. We use the following verbs to signal the server how the URL should be interpreted:
Verb | Definition |
---|---|
GET | Retrieve infomation |
PUT | Write information |
POST | Create new entry |
DELETE | Remove information |
We previously used the GET command, now we’ll be looking at PUT.
The API commands are typically provided as a hierarchy of information, from broad to granular. For example, you can request information regarding everything the JPS knows about a specific computer or only the contents of the hardware pane for the computer.
While working with the API the most important documentation resource available to you is already in your JPS. You access it through your browser at this address: https://your.jps.location:8443/api/
. This page shows you the types of interactions available, the proper format and data returned for each command.
Writing data to the Jamf Pro server
The API can speak JSON or XML, sometimes a specific call will only support one format, sometimes both. The API documentation will show which formats it supports. The JPS only accepts XML data uploads. The two formats are conceptually similar, and Python provides robust modules to deal with each. We covered working with JSON in Python in the previous article, so we’ll take a close look at XML.
Here is a link to the documentation for Python’s built in XML library.
XML
XML, or Extensible Markup Language, is the document markup language that is the basis of HTML. For our purposes, it consists of tags and attributes. Tags declare the type of data wrapped in an opening and closing block.
Roughly described, tags are general ideas or sections of an item and they can be identified by the following notation:
<tag> ... <\tag>
<computer> ... <location> ... </location> </computer>
Attributes can be thought of as the details of an item or its value. Examples of attributes are:
<id>17</id>
<position>Lady of Bear Island</position>
Here is a textual representation of an valid XML data structure we could send to the JPS:
<computer> <extension_attributes> <extension_attribute> <id>17</id> <name>Inventory purpose</name> <type>String</type> <value>Staff laptop</value> </extension_attribute> </extension_attributes> <general> <asset_tag>u00000000</asset_tag> </general> <location> <username>lynnamormont</username> <email_address>lynna.mormont@utah.edu</email_address> <real_name>Lynna Mormont</real_name> <phone>555-1212</phone> <room>6001 MLIB</room> <position>Lady of Bear Island</position> </location> </computer>
Here is a graphical representation of the XML document:
The tags and values we’ll be using are mapped to the panes and fields in the various JPS windows. In the example above we’re working on a specific computer, in the User and Location, General and Extension Attribute panes.
Here is the python representation of the above examples:
top = ET.Element('computer') ext_attrs = ET.SubElement(top, 'extension_attributes') ext_attr = ET.SubElement(ext_attrs, 'extension_attribute') id = ET.SubElement(ext_attr, 'id') id.text = '17' name = ET.SubElement(ext_attr, 'name') name.text = 'Inventory purpose' type = ET.SubElement(ext_attr, 'type') type.text = 'String' value = ET.SubElement(ext_attr, 'value') value.text = inventory_purpose general = ET.SubElement(top, 'general') asset_tag_xml = ET.SubElement(general, 'asset_tag') asset_tag_xml.text = asset_tag location = ET.SubElement(top, 'location') username_xml = ET.SubElement(location, 'username') username_xml.text = account_name email_xml = ET.SubElement(location, 'email_address') email_xml.text = email_address realname_xml = ET.SubElement(location, 'real_name') realname_xml.text = full_name phone_xml = ET.SubElement(location, 'phone') phone_xml.text = phone_number room_xml = ET.SubElement(location, 'room') room_xml.text = room_number position_xml = ET.SubElement(location, 'position') position_xml.text = position
Let’s examine this code a little closer.
top is the root of the document. The XML data structure will take care of the opening and closing tags, with the single variable assignment. Notice the SubElement declarations. We’re telling the new object that it belongs to a specific branch of the tree were building (see the graphical representation above). And then we’re setting the text value of the object to match our requirements.
ext_attrs is the variable for the Extension Attribute pane. ext_attr is single member of the pane. We then proceed to declare and assign value to the required parts of the EA. It’s id (17), name (Inventory Purpose), type (String) and value (the contents of the inventory_purpose variable).
general applies to the General pane. Here you can see we’re only interested in modifying the Asset Tag field, so we don’t need to reference the rest of the items in the pane.
Finally we address the User and Location pane. The API shortens this to location . We continue to declare tags and assign values to them.
PUT-ing to the Jamf Pro server
Now that we’ve created the XML object, we need to send it to the JPS. Here’s is the code we’ll use create the request, add the required features, submit it and display the reply from the server.
opener = urllib2.build_opener(urllib2.HTTPHandler) request = urllib2.Request(computer_url, data=ET.tostring(top)) base64string = base64.b64encode('%s:%s' % (jss_user, jss_pwrd)) request.add_header("Authorization", "Basic %s" % base64string) request.add_header('Content-Type', 'text/xml') request.get_method = lambda: 'PUT' response = opener.open(request) print "HTML PUT response code: %i" % response.code
Let’s take a closer look at this code.
opener is the structure that will be used to send the URL to the server.
request is the actual URL and we’ll continue to build up the object with additional information. computer_url contains the server address and the location within the API of the computer commands. data is the XML data structure we created.
base64string is the encoded form the the user and password we’ll be using the authenticate with.
The add_header methods are adding additional flags, Authorization and Content-Type .
get_method is telling the API how to interpret the URL. In this case, PUT tells the API we want to modify existing data. Similarly, we would use POST for new computer entries for example.
opener.open actually sends the URL to the API and stores its return communication in the response variable, which we then print.
Handling HTTP errors
Since we can never assume that some kind of error won’t occur, we have wrapped all of our communication with the JPS in a try except block. This type of control flow allows the script to continue executing instead of coming to a complete halt if some type of error occurs. It proceeds by interpreting the statements in the try portion until the execution completes or an error occurs. If an error occurs, the code will branch into the except block. Here’s the code describing our except block, we’ll examine it closely below:
except urllib2.HTTPError, error: contents = error.read() print contents print error.code if error.code == 400: print ("HTTP code %i: %s " % (error.code, "Request error.")) return elif error.code == 401: print ("HTTP code %i: %s " % (error.code, "Authorization error.")) return elif error.code == 403: print ("HTTP code %i: %s " % (error.code, "Permissions error.")) return elif error.code == 404: print ("HTTP code %i: %s " % (error.code, "Resource not found.")) return elif error.code == 409: error_message = re.findall("Error: (.*)<", contents) print("HTTP code %i: %s %s" % (error.code, "Resource conflict.", error_message[0])) return else: print ("HTTP code %i: %s " % (error.code, "Misc HTTP error.")) return except urllib2.URLError, error: print error.reason print ("Error contacting JSS.") return except: print ("Error submitting to JSS.") return
There are three sections to this except block: HTTP errors, URL errors, and any other type of error.
When an HTTP error occurs communicating the JPS, an urllib2.HTTPError will occur. This type of error could be caused by an incorrect password, incorrect access permissions or a conflict with the data you’re attempting to manipulate. The conflicts can occur when data meant to be unique has occurred in more than one computer. We address this event in https://apple.lib.utah.edu/using-the-jamf-pro-api-and-python-to-detect-duplicated-attributes/.
urllib2.URLError is likely caused by the inability to communicate the the server. A typo in the server address, perhaps.
The final bare except is a catchall for any other type of error not accounted for in the previous excepts.
A sample script
Here is a sample script that combines all the topics we’ve discussed. It will asked the user a series of questions and post the results to your JPS inside the record for the computer you run the code from. We’ve commented out the code relating to the extension attribute, since the values are unlikely to match those on your server.
#!/usr/bin/python from __future__ import print_function import subprocess import urllib2 import base64 import xml.etree.cElementTree as ET import re import platform import getpass def main(): # # detect current platform and discover UUID current_platform = platform.system() if current_platform == 'Darwin': local_uuid_raw = subprocess.check_output(["system_profiler", "SPHardwareDataType"]) local_uuid = re.findall('Hardware UUID: (.*)', local_uuid_raw)[0] elif current_platform == 'Windows': local_uuid_raw = subprocess.check_output("wmic CsProduct Get UUID") local_uuid_raw = local_uuid_raw.split("\r\r\n")[1] local_uuid = local_uuid_raw.split(" ")[0] else: quit() # acquire the account info for a user with API access privileges jss_user = raw_input("JSS username? ") jss_pwrd = getpass.getpass("JSS password? ") # the root API url to your Jamf pro server jss_base_url = 'https://your_jamfpro_server:8443/JSSResource' computer_url = jss_base_url + '/computers/udid/' + local_uuid # # data entry full_name = raw_input("Full name? ") account_name = raw_input("username? ") email_address = raw_input("email address? ") phone_number = raw_input("phone number? ") room_number = raw_input("room number? ") position = raw_input("position? ") asset_tag = raw_input("asset tag? ") # # uncomment when section below matches your server # inventory_purpose = raw_input("Purpose of machine? ") # # build XML object top = ET.Element('computer') general = ET.SubElement(top, 'general') asset_tag_xml = ET.SubElement(general, 'asset_tag') asset_tag_xml.text = asset_tag location = ET.SubElement(top, 'location') username_xml = ET.SubElement(location, 'username') username_xml.text = account_name email_xml = ET.SubElement(location, 'email_address') email_xml.text = email_address realname_xml = ET.SubElement(location, 'real_name') realname_xml.text = full_name phone_xml = ET.SubElement(location, 'phone') phone_xml.text = phone_number room_xml = ET.SubElement(location, 'room') room_xml.text = room_number position_xml = ET.SubElement(location, 'position') position_xml.text = position # # The values for id and name will need to be changed to match your values. # ext_attrs = ET.SubElement(top, 'extension_attributes') # # ext_attr = ET.SubElement(ext_attrs, 'extension_attribute') # id = ET.SubElement(ext_attr, 'id') # id.text = '17' # name = ET.SubElement(ext_attr, 'name') # name.text = 'Inventory purpose' # type = ET.SubElement(ext_attr, 'type') # type.text = 'String' # value = ET.SubElement(ext_attr, 'value') # value.text = inventory_purpose # # build pieces of URL for PUT-ing to JSS try: print("Submitting XML: %r" % ET.tostring(top)) # # urllib2 method opener = urllib2.build_opener(urllib2.HTTPHandler) request = urllib2.Request(computer_url, data=ET.tostring(top)) base64string = base64.b64encode('%s:%s' % (jss_user, jss_pwrd)) request.add_header("Authorization", "Basic %s" % base64string) request.add_header('Content-Type', 'text/xml') request.get_method = lambda: 'PUT' response = opener.open(request) print("HTML PUT response code: %i" % response.code) # # handle HTTP errors and report except urllib2.HTTPError, error: contents = error.read() print("HTTP error contents: %r" % contents) if error.code == 400: print("HTTP code %i: %s " % (error.code, "Request error.")) return elif error.code == 401: print("HTTP code %i: %s " % (error.code, "Authorization error.")) return elif error.code == 403: print("HTTP code %i: %s " % (error.code, "Permissions error.")) return elif error.code == 404: print("HTTP code %i: %s " % (error.code, "Resource not found.")) return elif error.code == 409: error_message = re.findall("Error: (.*)<", contents) print("HTTP code %i: %s %s" % (error.code, "Resource conflict.", error_message[0])) return else: print("HTTP code %i: %s " % (error.code, "Misc HTTP error.")) return except urllib2.URLError, error: print("URL error reason: %r" % error.reason) print("Error contacting JSS.") return except: print("Error submitting to JSS.") return if __name__ == '__main__': main()
Handling constants defined on your JPS
There are a three special cases of data customized by you on your JPS: Buildings, Departments and Sites. These categories are defined in Management Settings:Network Organization. If you attempt to submit data outside the scope of the specifics you provided, the JPS will return a 409: resource conflict error. Below is a function that requests a JSON object containing the members of the category you specify and builds a list the could then be presented in your user interface. If the category is empty, a list containing a single item, None , is returned.
def populate_menu(menu_choice): UsernameVar = "your_api_user" PasswordVar = "your_api_user_password" url = 'https://yourjss:8443/JSSResource/' + menu_choice request = urllib2.Request(url) request.add_header('Accept', 'application/json') request.add_header('Authorization', 'Basic ' + base64.b64encode(UsernameVar + ':' + PasswordVar)) response = urllib2.urlopen(request) response_json = json.loads(response.read()) menu_items = ['None'] for item in response_json[menu_choice]: menu_items.append(item.get('name')) return menu_items
Acknowledgements
Without the following resources, my project wouldn’t have progressed as quickly as I did.
http://macbrained.org/the-jss-rest-api-for-everyone/
https://unofficial-jss-api-docs.atlassian.net/wiki
No Comments