Publishing information to the Jamf Pro server using API calls and Python

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.

 

Screen Shot 2016-07-21 at 11.17.04 AM

 

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:

xml graphical_colorThe 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


Screen Shot 2016-07-21 at 10.52.28 AM

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

Leave a Reply