Use the Jamf API to Update a Smart Group with App Versions from AutoPkg

This post will outline a method to get app versions from AutoPkg and apply those version numbers to a single smart group in Jamf Pro.

I was inspired by the Jamf Tech Thoughts post, “Custom Self Service Patch Notifications” by ThomM. In it, Thom discusses using a custom alert that directs users to the “Notifications” section of Self Service where they can apply pending updates. Thom goes into some detail in that post about why this might be needed/preferred vs using the built-in notifications.

My only issue with implementing Thom’s workflow is that when using AutoPkg (and Jamf Upload), updating the smart group that identities out of date apps can become cumbersome rather quickly. So I created a workflow to automate updating that smart group.

To make this work there is bit of setup involved.

Assumptions:

This script assumes that you are using AutoPkg with Graham Pugh’s Jamf Upload processors. In our environment we have recipes configured to upload new packages to Jamf Pro, then add those new updates to patch management policies. This means that all of our recipes already include the PATCH_SOFTWARE_TITLE_ID key. If that is not true in your environment, you can add something like the following to each recipe override:

<key>PATCH_SOFTWARE_TITLE_ID</key>
<string>52</string>

It is assumed that you have a Jamf Pro API user configured for AutoPkg and Jamf Upload. The required permissions are listed in the github wiki here.

Prerequisites:

You will need to add Graham Pugh’s LastRecipeRunResult post processor to each recipe that you intend to use this method with. This processor creates a JSON file containing the results of the last run and requires no input.

I added this as the last processor in each recipe:

<dict>
<key>Processor</key>
<string>com.github.grahampugh.recipes.postprocessors/LastRecipeRunResult</string>
</dict>

After adding the processor, running the recipe again should generate the latest_version.json file in your AutoPkg cache.

The next prerequisite is to install jq so that we can easily parse JSON data. I used homebrew to accomplish this with: brew install jq

The Jamf Pro API user that is configured for AutoPkg will need two additional permissions:

  1. Read access to Jamf Pro Server Objects > External Patch Sources
  2. Read access to Jamf Pro Server Settings > Internal Patch Sources

Configuration

Configuring the script requires the following:

  • Enter the user account you are running AutoPkg from
  • Enter the path to your recipe list
  • Enter the ID and name of the group that you would like to update
  • Optionally enter a site name and id
  • Optionally enter any recipes from your recipe list that should be excluded

Once configured the script should be able to update a single smart group with many app versions so that you can identify all computers with one or more apps that are out of date.

Script Overview

The script performs the following actions:

  • Downloads all patch titles from all sources on your Jamf Pro server and saves them to a local file
  • Creates a local xml file to store smart group data
  • Reads each recipe from your recipe list in turn
  • Gets the app version from the latest_version.json created by the LastRecipeRunResult post processor
  • Gets the Patch Title ID from the recipe
  • Matches the Patch Title ID from the recipe to the Patch Title Name stored in the local file from step 1. (This is required because the patch title name in Jamf Pro can be edited and does not always match the actual name required for smart group criteria.)
  • Adds the name and version criteria to the smart group xml file
  • Uploads the smart group xml file to Jamf Pro

Usage

./autopkg-update-smart-group.sh [options]

You can pass the --dry-run option to have the script check for errors and create the xml file without uploading anything to Jamf Pro.

Implementation

We scoped an alert (using IBM Notifier) to this group that directs users to perform pending updates. Other options would be to use Jamf Helper or SwiftDialog for reminders.

The script is intended to be run from the same computer that AutoPkg is installed on. I have it running as part of our autopkg conductor run as the first action. This way, there is a 24 hour period between new apps being added to Self Service and users getting notified about them.

The Script:

#!/bin/bash
#####################################################################################################
#
# SCRIPT: autopkg-update-smart-group.sh
# AUTHOR: Sam Mills (@mostlymac; github.com/sgmills)
# DATE: 08 December 2022
# REV: 1.0
#
#####################################################################################################
# USAGE
#
# ./autopkg-update-smart-group.sh
# Use option –dry-run to report errors and preview smart group before making changes in Jamf Pro
#
#####################################################################################################
# ASSUMPTIONS
#
# It is assumed that all recipes in your recipe list contain a Patch Software Title ID
#
# If this is not true, add the following key to each recipe override and supply the appropriate ID
# <key>PATCH_SOFTWARE_TITLE_ID</key>
# <string>52</string>
#
# The patch software title id can be found in the url for each patch title.
# In the following example, the ID is 52
# https://yourOrg.jamfcloud.com/patch.html?id=52&o=r
#
#—————————————————————————————————#
#
# It is assumed that you have added Graham Pugh's LastRecipeRunResult post processor to each recipe
# that you intend to use this method with. This processor creates a JSON file containing the results
# of the last autopkg run for each recipe and requires no input.
#
# See below for an example. This should be the last processor in each recipe:
# <dict>
# <key>Processor</key>
# <string>com.github.grahampugh.recipes.postprocessors/LastRecipeRunResult</string>
# </dict>
#
# For more information on the LastRecipeRunResult post processor, please see the link below:
# https://github.com/autopkg/grahampugh-recipes/blob/main/PostProcessors/LastRecipeRunResult.py
#
#—————————————————————————————————#
#
# It is assumed that you have jq installed to parse JSON. To install with homebrew: brew install jq
#
#—————————————————————————————————#
#
# It is assumed that your Jamf Pro API user has the following permissions, in additon to any already
# required for Jamf Upload
#
# Read access to Jamf Pro Server Objects > External Patch Sources
# Read access to Jamf Pro Server Settings > Internal Patch Sources
#
#####################################################################################################
# EDITABLE VARIABLES
#
# Adjust the following variables for your particular configuration.
#
# autopkgUser – This should be the user account you're running AutoPkg in
# autopkgUserHome – This should be the home folder location of the AutoPkg user account
# autoPkgCache – This should be the location of your AutoPkg cache directory
#
# Note: The home folder and AutoPkg cache locations are currently set to be automatically discovered
# using the autopkgUser variable.
autopkgUser="autopkg"
autopkgUserHome=$(/usr/bin/dscl . -read /Users/"$autopkgUser" NFSHomeDirectory | awk '{print $2}')
autoPkgCache="$autopkgUserHome/Library/AutoPkg/Cache"
# recipeList – This is the location of the plain text file being used to store
# your list of AutoPkg recipes. For more information about this list, please see
# the link below:
# https://github.com/autopkg/autopkg/wiki/Running-Multiple-Recipes
recipeList="$autopkgUserHome/Library/Application Support/AutoPkgr/recipe_list.txt"
# If you're using Jamf Upload, your Jamf Pro server and API user information should be
# populated automatically. If you're not using Jamf Upload, set this information accordingly.
jamfUser="$( /usr/bin/defaults read "$autopkgUserHome"/Library/Preferences/com.github.autopkg.plist API_USERNAME )"
jamfPass="$( /usr/bin/defaults read "$autopkgUserHome"/Library/Preferences/com.github.autopkg.plist API_PASSWORD )"
jamfURL="$( /usr/bin/defaults read "$autopkgUserHome"/Library/Preferences/com.github.autopkg.plist JSS_URL )"
# groupName – This should be the name of the Jamf Pro Smart Group that will be updated
# groupID – This should be the ID of the Jamf Pro Smart Group that will be updated
# Note: The group must already exisit in Jamf Pro. This script will not create one
groupName="Managed Apps Out-Of-Date"
groupID="123"
# jqLocation – This should be the location of the jq binary.
# Currently set to be automatically discovered
jqLocation="$(/usr/bin/which jq)"
#####################################################################################################
# OPTIONAL VARIABLES
# siteID – This should be the ID of the site for your smart group. Leave blank if no sites
# siteName – This should be the name of the site for your smart group. Leave blank if no sites
siteID=""
siteName=""
# If you would like to exclude any recipes from your recipe list, enter them here.
# Recipe names should be enclosed in quotes and separated by a space as shown below:
# excludedRecipes=("local.jamf.Chrome-patch" "local.jamf.Firefox-patch")
excludedRecipes=()
#####################################################################################################
# USE CAUTION EDITING BELOW THIS LINE
#####################################################################################################
# FUNCTIONS
# Function uses Basic Authentication to get a new bearer token for API authentication
GetJamfProAPIToken() {
api_token=$(/usr/bin/curl -X POST –silent -u "${jamfUser}:${jamfPass}" "${jamfURL}/api/v1/auth/token" | plutil -extract token raw –)
}
# Function to collect all internal and external patch sources and save the patch available titles.
# Saving this data to a file reduces API calls and speeds up operations.
# Takes one argument: internal or external
savePatchAvailableTitles () {
# Get the source ids
patchSourceIDs="$( /usr/bin/curl -s -H "authorization: Bearer ${api_token}" \
"Accept: text/xml" –request GET \
"$jamfURL"/JSSResource/patch"$1"sources | \
/usr/bin/xmllint –format – | \
/usr/bin/grep -e "<id>" | \
/usr/bin/awk -F "<id>|</id>" '{ print $2 }' )"
# For each patch source, append all patches to a file
for id in $patchSourceIDs; do
/usr/bin/curl -s -H "authorization: Bearer ${api_token}" \
"Accept: text/xml" –request GET \
"$jamfURL"/JSSResource/patchavailabletitles/sourceid/"$id" | \
/usr/bin/xmllint –format – >> "$patchAvailableTitles"
done
}
# Function to check if array contains a recipe
containsRecipe () {
local e
for e in "${@:2}"; do [[ "$e" == "$1" ]] && return 1; done
return 0
}
# Function to verify xml and PUT computer group to Jamf Pro
updateSmartGroup () {
echo ""
echo "Verifying XML data"
# If xml is valid, create a new comptuer group from file
if xmllint "$smartGroupData" 1> /dev/null; then
echo "XML is valid. Updating $groupName on Jamf Pro Server $jamfURL"
# Use Jamf Pro API to put data
/usr/bin/curl -s -H "authorization: Bearer ${api_token}" \
-H "Accept: application/xml" -H "Content-type: application/xml" –request PUT \
"${jamfURL}"/JSSResource/computergroups/id/"${groupID}" \
–upload-file "$smartGroupData"
else
echo "XML is invalid! Cannot upload to Jamf Pro. Exiting…"
exit 1
fi
}
# Invalidates the Jamf API token so it can no longer be used
InvalidateToken() {
/usr/bin/curl "${jamfURL}/api/v1/auth/invalidate-token" –silent –header "Authorization: Bearer ${api_token}" -X POST
api_token=""
}
#####################################################################################################
# PRELIMINARY CHECKS
# If the AutoPkg cache directory is missing, stop the script with an error.
if [[ ! -d "$autoPkgCache" ]]; then
echo "AutoPkg cache directory ($autoPkgCache) does not exist. Exiting…"
exit 1
fi
# If the AutoPkg recipe list is missing or unreadable, stop the script with an error.
if [[ ! -r "$recipeList" ]]; then
echo "Recipe list ($recipeList) is missing or unreadable. Exiting…"
exit 1
fi
# If Jamf Pro API user info is missing, stop the script with an error
if [[ -z $jamfUser ]] || [[ -z $jamfPass ]] || [[ -z $jamfURL ]]; then
echo "Jamf Pro API username, password, or URL is missing. Exiting…"
exit 1
fi
# If Jamf Pro smart group info is missing, stop the script with an error
if [[ -z $groupID ]] || [[ -z $groupName ]]; then
echo "Jamf Pro smart group name or id is missing. Exiting…"
exit 1
fi
# If jq is missing, stop the script with an error.
if [[ ! -x "$jqLocation" ]]; then
echo "jq is not installed. Exiting…"
exit 1
fi
#####################################################################################################
# GET A JAMF PRO API TOKEN
# Use function to get an API token
GetJamfProAPIToken
#####################################################################################################
# GET ALL AVAILABLE PATCH TITLES
# xml file for all available patch titles
patchAvailableTitles="/private/tmp/patchAvailableTitles.xml"
# Remove old patch title xml file if needed
rm "$patchAvailableTitles" 2> /dev/null
# Use fuction to collect patch avaialble titles
savePatchAvailableTitles "internal" 2> /dev/null
savePatchAvailableTitles "external" 2> /dev/null
# Check that there is now data in the file
if [[ ! -s "$patchAvailableTitles" ]]; then
echo "No available patch titles found on Jamf Pro server. Exiting…"
fi
#####################################################################################################
# CREATE THE XML FOR SMART GROUP
# xml file for uploading smart group to Jamf Pro
smartGroupData="/private/tmp/smartGroupData.xml"
# Remove old smart group xml file if needed
rm "$smartGroupData" 2> /dev/null
# Add opening tag and set the smart group name
echo "<computer_group>
<name>$groupName</name>
<is_smart>true</is_smart>" >> "$smartGroupData"
# Add site info to smart group xml
# If both site id and site name are supplied, write them
if [[ -n $siteID ]] && [[ -n $siteName ]]; then
echo " <site>
<id>$siteID</id>
<name>$siteName</name>
</site>
<criteria>" >> "$smartGroupData"
# If either site id or site name are missing report error
elif [[ -n $siteID ]] || [[ -n $siteName ]]; then
echo "Either siteID or siteName variable is missing."
exit 1
# If no site info is entered, use default values
else
echo " <site>
<id>-1</id>
<name>None</name>
</site>
<criteria>" >> "$smartGroupData"
fi
#####################################################################################################
# ADD PATCHES TO XML FOR SMART GROUP
# Set priority for smart group items to 0
priority=0
# For each recipe in the list, append to xml data
while IFS="" read -r recipe || [ -n "$recipe" ]; do
# Check if the recipe is excluded
containsRecipe "$recipe" "${excludedRecipes[@]}"
excludedRecpeResult="$?"
# If recipe is in exclusions skip it
if [[ "$excludedRecpeResult" = 1 ]]; then
echo "[ ] $recipe is excluded. Skipping…"
else
# Get the json file with the latest version in it
appLatestVersionJSON="$autoPkgCache/$recipe/latest_version.json"
# Use jq to parse json and extract version number
appVersion="$( "$jqLocation" -r '.version' "$appLatestVersionJSON" )"
# Get the patch title id from autopkg recipe
patchTitleID="$( /usr/local/bin/autopkg info "$recipe" | grep "PATCH_SOFTWARE_TITLE_ID" | awk -F "'" '{print $4}' )"
# Use patch title id to get the patch title name id (identifies actual name even if chagned in Jamf Pro UI)
patchTitleNameID="$( /usr/bin/curl -s -H "authorization: Bearer ${api_token}" \
"Accept: text/xml" –request GET \
"$jamfURL"/JSSResource/patchsoftwaretitles/id/"$patchTitleID" | \
/usr/bin/xmllint –format – | \
/usr/bin/grep -e "<name_id>" )"
# Use the patch title name id to get the jamf patch title name
patchTitleName="$( /usr/bin/grep -A4 "$patchTitleNameID" "$patchAvailableTitles" | \
/usr/bin/awk -F "<app_name>|</app_name>" '{ print $2 }' | xargs )"
# If if any required data is mising, skip recipe
if [[ -z $appLatestVersionJSON ]]; then
echo "[ ] No latest_version.json in $autoPkgCache/$recipe directory. Skipping $recipe"
elif [[ -z $appVersion ]] || [[ $appVersion == "null" ]]; then
echo "[ ] No version information found in latest_version.json for $recipe. Skipping…"
elif [[ -z $patchTitleID ]]; then
echo "[ ] Unable to determine Patch Title ID. Is it defined in $recipe? Skipping…"
elif [[ -z $patchTitleNameID ]]; then
echo "[ ] Unable to determine the Patch Title name_id for $recipe. Skipping…"
elif [[ -z $patchTitleName ]]; then
echo "[ ] Unable to match name_id: $patchTitleNameID to a Patch Title Name for $recipe. Skipping…"
# If all required data is present, add it to the smart group
else
echo " <criterion>
<name>Patch Reporting: $patchTitleName</name>
<priority>$priority</priority>
<and_or>or</and_or>
<search_type>less than</search_type>
<value>$appVersion</value>
<opening_paren>false</opening_paren>
<closing_paren>false</closing_paren>
</criterion>" >> $smartGroupData
echo "[+] Added $patchTitleName version $appVersion to $groupName XML file"
# Increment priority by 1
((priority=priority+1))
fi
fi
done < "$recipeList"
# Add closing tags to xml for smart group
echo " </criteria>
</computer_group>" >> "$smartGroupData"
#####################################################################################################
# GET INPUTS
# Allow for passing dry-run input
while test $# -gt 0; do
case "$1" in
–dry-run)
# Set the dry-run flag to true
dryRun=1
;;
esac
shift
done
#####################################################################################################
# UPDATE SMART GROUP
# Check for dry run and update smart group accordingly
if [[ $dryRun = 1 ]]; then
echo "Dry run complete. XML data is located at: $smartGroupData"
echo "Nothing uploaded. Nothing changed on Jamf Pro Server $jamfURL"
else
updateSmartGroup
fi
#####################################################################################################
# INVALIDATE TOKEN
# Use function to invalidate Jamf Pro API token
InvalidateToken

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s