├── .circleci └── config.yml ├── .github └── workflows │ └── git4jamfpro.yml ├── .gitignore ├── README.md ├── bitbucket-pipelines.yml ├── extension_attributes └── Test Extension Attribute │ ├── record.xml │ └── script.sh ├── git4jamfpro.sh └── scripts ├── Sample Bash Script ├── record.xml └── script.sh ├── Sample Python Script ├── record.xml └── script.py └── Sample ZSH Script ├── record.xml └── script.zsh /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | jobs: 4 | push-changes-to-jamf-pro-1: 5 | docker: 6 | - image: cimg/base:stable 7 | steps: 8 | - checkout 9 | - run: 10 | name: "Install Requirements" 11 | command: sudo apt-get update && sudo apt-get install libxml2-utils xmlstarlet -y 12 | - run: 13 | name: "Update changes in Jamf Pro Server 1" 14 | command: ./git4jamfpro.sh --url "$JAMF_PRO_URL_1" --username "$API_USER" --password "$API_PASS_1" --push-changes-to-jamf-pro --backup-updated 15 | - store_artifacts: 16 | path: ./backups 17 | 18 | push-changes-to-jamf-pro-2: 19 | docker: 20 | - image: cimg/base:stable 21 | steps: 22 | - checkout 23 | - run: 24 | name: "Install Requirements" 25 | command: sudo apt-get update && sudo apt-get install libxml2-utils xmlstarlet -y 26 | - run: 27 | name: "Update changes in Jamf Pro Server 2" 28 | command: ./git4jamfpro.sh --url "$JAMF_PRO_URL_2" --username "$API_USER" --password "$API_PASS_2" --push-changes-to-jamf-pro --backup-updated 29 | - store_artifacts: 30 | path: ./backups 31 | 32 | workflows: 33 | git4jamfpro-workflow: 34 | jobs: 35 | - push-changes-to-jamf-pro-1 36 | - push-changes-to-jamf-pro-2 37 | -------------------------------------------------------------------------------- /.github/workflows/git4jamfpro.yml: -------------------------------------------------------------------------------- 1 | name: git4jamfpro Job 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | workflow_dispatch: 10 | 11 | jobs: 12 | push-changes-to-jamf-pro-1: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v3 17 | with: 18 | fetch-depth: 2 19 | 20 | - name: Install Requirements 21 | run: sudo apt-get update && sudo apt-get install libxml2-utils xmlstarlet -y 22 | 23 | - name: Push Changes to Jamf Pro Server 1 24 | run: ./git4jamfpro.sh --url ${{ vars.JAMF_PRO_URL_1 }} --username ${{ vars.API_USER }} --password ${{ secrets.API_PASS_1 }} --push-changes-to-jamf-pro --backup-updated 25 | 26 | - name: Archive Backups 27 | uses: actions/upload-artifact@v3 28 | with: 29 | name: jamf-pro-1-backups 30 | path: backups 31 | 32 | # Push changes to a second Jamf Pro server (optional) 33 | push-changes-to-jamf-pro-2: 34 | runs-on: ubuntu-latest 35 | 36 | steps: 37 | - uses: actions/checkout@v3 38 | with: 39 | fetch-depth: 2 40 | 41 | - name: Install Requirements 42 | run: sudo apt-get update && sudo apt-get install libxml2-utils xmlstarlet -y 43 | 44 | - name: Push Changes to Jamf Pro Server 2 45 | run: ./git4jamfpro.sh --url ${{ vars.JAMF_PRO_URL_2 }} --username ${{ vars.API_USER }} --password ${{ secrets.API_PASS_2 }} --push-changes-to-jamf-pro --backup-updated 46 | 47 | - name: Archive Backups 48 | uses: actions/upload-artifact@v3 49 | with: 50 | name: jamf-pro-2-backups 51 | path: backups 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.DS_Store 2 | /backups/* -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # git4jamfpro # 2 | 3 | A tool designed for CI/CD pipelines that uploads the most recently changed scripts and extension attributes in source control to a Jamf Pro server(s). 4 | 5 | This is a rewrite of the great (but very aged) [git2jss](https://github.com/badstreff/git2jss) python library written by [badstreff](https://github.com/badstreff). 6 | 7 | ### What are the benefits of git4jamfpro? ### 8 | 9 | * Designed and packaged to be be ran in a CI/CD pipeline. We even include sample pipeline config files for CircleCI, Bitbucket, and GitHub to get you up and running quickly. 10 | * No python dependency; git4jamfpro is written in bash. 11 | * Uses modern Bearer Token authentication with Jamf Pro. 12 | * Allows you to download all scripts and extension attributes (EAs) in parallel from a Jamf Pro server. 13 | * You can update (or create) scripts/EAs locally, commit the changes to your repository, and the changed scripts/EAs are pushed to Jamf Pro automatically by your pipeline. This ensures that script/EA changes are always tracked in source control. 14 | * When a script/EA is updated, a backup can be left as an artifact in your CI/CD pipeline. 15 | 16 | ### Setting up git4jamfpro ### 17 | 1. Fork your own copy of the repository. 18 | 2. Clone the repository locally: 19 | 20 | ``` 21 | git clone git@github.com:YOUR_ORGANIZATION/git4jamfpro.git (or equivalent) 22 | ``` 23 | 24 | 3. Traverse into the repository: 25 | 26 | ``` 27 | cd git4jamfpro 28 | ``` 29 | 30 | 4. Download your scripts/EAs: 31 | 32 | ``` 33 | ./git4jamfpro --url \ 34 | --username \ 35 | --password \ 36 | --download-scripts \ 37 | --download-eas 38 | ``` 39 | 40 | 5. Commit the repository populated with scripts/EAs to your source control: 41 | 42 | ``` 43 | git add . 44 | git commit -m "initial commit with scripts/EAs" 45 | ``` 46 | 47 | 6. Configure your pipeline (see the [Wiki](https://github.com/alectrona/git4jamfpro/wiki) for [CircleCI](https://github.com/alectrona/git4jamfpro/wiki/Deploy-in-CircleCI), [Bitbucket](https://github.com/alectrona/git4jamfpro/wiki/Deploy-in-Bitbucket), and [GitHub](https://github.com/alectrona/git4jamfpro/wiki/Deploy-in-GitHub) setup). 48 | 49 | 7. Now you can make changes to your scripts locally, push those changes to source control, and watch your pipeline automatically update Jamf Pro 🤯. 50 | 51 | ### Required Permissions 52 | 53 | The Jamf Pro user account used with git4jamfpro must have the below permissions. 54 | | Jamf Pro Server Objects | Create | Read | Update | Delete | 55 | | ---------- | ------ | ---- | ------ | ------ | 56 | | Computer Extension Attributes | ✓ | ✓ | ✓ | | 57 | | Scripts | ✓ | ✓ | ✓ | | 58 | -------------------------------------------------------------------------------- /bitbucket-pipelines.yml: -------------------------------------------------------------------------------- 1 | image: atlassian/default-image:3 2 | 3 | pipelines: 4 | default: 5 | - step: 6 | name: 'Update changes in Jamf Pro server 1' 7 | clone: 8 | depth: 2 9 | script: 10 | - apt-get update && apt-get install libxml2-utils xmlstarlet -y 11 | - ./git4jamfpro.sh --url "$JAMF_PRO_URL_1" --username "$API_USER" --password "$API_PASS_1" --push-changes-to-jamf-pro --backup-updated 12 | artifacts: 13 | - backups/** 14 | 15 | # Push changes to a second Jamf Pro server (optional) 16 | - step: 17 | name: 'Update changes in Jamf Pro server 2' 18 | clone: 19 | depth: 2 20 | script: 21 | - apt-get update && apt-get install libxml2-utils xmlstarlet -y 22 | - ./git4jamfpro.sh --url "$JAMF_PRO_URL_2" --username "$API_USER" --password "$API_PASS_2" --push-changes-to-jamf-pro --backup-updated 23 | artifacts: 24 | - backups/** -------------------------------------------------------------------------------- /extension_attributes/Test Extension Attribute/record.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Test Extension Attribute 4 | false 5 | Test Extension Attribute. 6 | String 7 | 8 | script 9 | Mac 10 | 11 | Extension Attributes 12 | 13 | -------------------------------------------------------------------------------- /extension_attributes/Test Extension Attribute/script.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "Hello world!" 4 | 5 | exit 0 -------------------------------------------------------------------------------- /git4jamfpro.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | numCores=$(getconf _NPROCESSORS_ONLN) 4 | maxParallelJobs="$(( numCores * 2 ))" 5 | unameType=$(uname -s) 6 | scriptSummariesFile="/tmp/script_summaries.xml" 7 | eaSummariesFile="/tmp/ea_summaries.xml" 8 | 9 | unset jamfProURL apiUser apiPass dryRun downloadScripts downloadEAs pushChangesToJamfPro apiToken 10 | 11 | # Clean up function that will run upon exiting 12 | function finish() { 13 | 14 | # Expire the Bearer Token 15 | [[ -n "$apiToken" ]] && curl -s -H "Authorization: Bearer $apiToken" "$jamfProURL/uapi/auth/invalidateToken" -X POST 16 | 17 | rm "$scriptSummariesFile" 2>/dev/null 18 | rm "$eaSummariesFile" 2>/dev/null 19 | } 20 | trap "finish" EXIT 21 | 22 | # Function to get a Jamf Pro API Bearer Token 23 | function get_jamf_pro_api_token() { 24 | local healthCheckHttpCode validityHttpCode 25 | 26 | # Make sure we can contact the Jamf Pro server 27 | healthCheckHttpCode=$(curl -s "$jamfProURL/healthCheck.html" -X GET -o /dev/null -w "%{http_code}") 28 | [[ "$healthCheckHttpCode" != "200" ]] && echo "Unable to contact the Jamf Pro server; exiting" && exit 1 29 | 30 | # Attempt to obtain the token 31 | apiToken=$(curl -s -u "$apiUser:$apiPass" "$jamfProURL/api/v1/auth/token" -X POST 2>/dev/null | jq -r '.token | select(.!=null)') 32 | [[ -z "$apiToken" ]] && echo "Unable to obtain a Jamf Pro API Bearer Token; exiting" && exit 2 33 | 34 | # Validate the token 35 | validityHttpCode=$(curl -s -H "Authorization: Bearer $apiToken" "${jamfProURL}/api/v1/auth" -X GET -o /dev/null -w "%{http_code}") 36 | parse_jamf_pro_api_http_codes "$validityHttpCode" || exit 3 37 | 38 | return 39 | } 40 | 41 | # Function to process changes scripts and potentially upload to Jamf Pro 42 | function process_changed_script() { 43 | local change="$1" 44 | local changedFile="./${change}" 45 | local record name script cleanRecord id xml httpCode 46 | 47 | # Bail if the file does not exist 48 | [[ ! -e "$changedFile" ]] && echo "File does not exist: $changedFile" 49 | 50 | # If the changed file is xml, then we need to locate the accompanying script 51 | if [[ "$change" == *.xml ]]; then 52 | 53 | record="$changedFile" 54 | 55 | if [[ "$unameType" == "Darwin" ]]; then 56 | script=$(find -E "$(dirname "$changedFile")" -regex '.*(.py|.swift|.pl|.rb|.applescript|.zsh|.sh)$' -maxdepth 1 -mindepth 1 | head -1) 57 | else 58 | script=$(find "$(dirname "$changedFile")" -regextype posix-extended -regex '.*(.py|.swift|.pl|.rb|.applescript|.zsh|.sh)$' -maxdepth 1 -mindepth 1 | head -1) 59 | fi 60 | fi 61 | 62 | # If the changed file is a script, we need to find the accompanying xml file 63 | if [[ "$change" =~ .*(.py|.swift|.pl|.rb|.applescript|.zsh|.sh)$ ]]; then 64 | script="$changedFile" 65 | record=$(find "$(dirname "$changedFile")" -name "*.xml" -maxdepth 1 -mindepth 1 | head -1) 66 | fi 67 | 68 | # Exit if there is no xml record file 69 | if [[ -z "$record" ]]; then 70 | echo "No record xml found for: $change" 71 | return 1 72 | fi 73 | 74 | # Exit if there is no script 75 | if [[ -z "$script" ]]; then 76 | echo "Script not found for script: $change" 77 | return 1 78 | fi 79 | 80 | # Make sure the record xml doesn't include things we don't want 81 | cleanRecord=$(cat "$record" | xmlstarlet ed --delete '/script/id' \ 82 | --delete '/script/script_contents' \ 83 | --delete '/script/script_contents_encoded' \ 84 | --delete '/script/filename') 85 | 86 | # Ensure we can get a name from the xml record 87 | name=$(echo "$cleanRecord" | xmlstarlet sel -T -t -m '/script' -v name) 88 | [[ -z "$name" ]] && echo "Could not determine name of script from the xml record, skipping." && return 1 89 | 90 | # Determine the id of a script that may exist in Jamf Pro with the same name 91 | id=$(get_script_summaries | xmlstarlet sel -T -t -m "//script[name=\"$name\"]" -v id 2>/dev/null) 92 | 93 | # Create xml containing both the original xml record and the script contents 94 | xml=$(echo "$cleanRecord" | xmlstarlet ed -s '/script' -t elem -n script_contents -v "$(cat "$script" | xmlstarlet esc)" | xmlstarlet fo -n -o) 95 | 96 | # Bail if the xml didn't get encoded properly 97 | if [[ -z "$xml" ]]; then 98 | echo "Failed to encode the script xml record and script contents properly." 99 | return 1 100 | fi 101 | 102 | # Update the script in Jamf Pro if it already exists 103 | # Otherwise, create a new script in Jamf Pro 104 | if [[ -n "$id" ]]; then 105 | 106 | # If configured to backup updated items, do that now 107 | [[ "$backupUpdated" == "true" ]] && download_script "$id" "$name" "./backups/scripts" 108 | 109 | # Handle dry run and return 110 | [[ "$dryRun" == "true" ]] && echo "Simulating updating script \"$name\"..." && sleep 1 && return 111 | 112 | echo "Updating script: $name..." 113 | httpCode=$(curl -s -H "Authorization: Bearer $apiToken" -H "Content-Type: application/xml" \ 114 | "$jamfProURL/JSSResource/scripts/id/$id" -d "$xml" -X PUT -o /dev/null -w "%{http_code}") 115 | parse_jamf_pro_api_http_codes "$httpCode" || return 1 116 | else 117 | [[ "$dryRun" == "true" ]] && echo "Simulating creating script \"$name\"..." && sleep 1 && return 118 | 119 | echo "Creating script: $name..." 120 | httpCode=$(curl -s -H "Authorization: Bearer $apiToken" -H "Content-Type: application/xml" \ 121 | "$jamfProURL/JSSResource/scripts/id/0" -d "$xml" -X POST -o /dev/null -w "%{http_code}") 122 | parse_jamf_pro_api_http_codes "$httpCode" || return 1 123 | fi 124 | 125 | return 126 | } 127 | 128 | # Function to process changed EAs and potentially upload to Jamf Pro 129 | function process_changed_ea() { 130 | local change="$1" 131 | local changedFile="./${change}" 132 | local record name script cleanRecord id xml httpCode 133 | 134 | # Bail if the file does not exist 135 | [[ ! -e "$changedFile" ]] && echo "File does not exist: $changedFile" 136 | 137 | # If the changed file is xml, then we need to locate the accompanying script 138 | if [[ "$change" == *.xml ]]; then 139 | 140 | record="$changedFile" 141 | 142 | if [[ "$unameType" == "Darwin" ]]; then 143 | script=$(find -E "$(dirname "$changedFile")" -regex '.*(.py|.swift|.pl|.rb|.applescript|.zsh|.sh)$' -maxdepth 1 -mindepth 1 | head -1) 144 | else 145 | script=$(find "$(dirname "$changedFile")" -regextype posix-extended -regex '.*(.py|.swift|.pl|.rb|.applescript|.zsh|.sh)$' -maxdepth 1 -mindepth 1 | head -1) 146 | fi 147 | fi 148 | 149 | # If the changed file is a script, we need to find the accompanying xml file 150 | if [[ "$change" =~ .*(.py|.swift|.pl|.rb|.applescript|.zsh|.sh)$ ]]; then 151 | script="$changedFile" 152 | record=$(find "$(dirname "$changedFile")" -name "*.xml" -maxdepth 1 -mindepth 1 | head -1) 153 | fi 154 | 155 | # Exit if there is no xml record file 156 | if [[ -z "$record" ]]; then 157 | echo "No record xml found for: $change" 158 | return 1 159 | fi 160 | 161 | # Make sure the record xml doesn't include things we don't want 162 | cleanRecord=$(cat "$record" | xmlstarlet ed --delete '/computer_extension_attribute/id' \ 163 | --delete '/computer_extension_attribute/input_type/script') 164 | 165 | # Ensure we can get a name of the EA from the xml record 166 | name=$(echo "$cleanRecord" | xmlstarlet sel -T -t -m '/computer_extension_attribute' -v name) 167 | [[ -z "$name" ]] && echo "Could not determine name of extension attribute from the xml record, skipping." && return 1 168 | 169 | # Create xml containing both the original xml record and the script contents (if exists) 170 | if [[ -n "$script" ]]; then 171 | xml=$(echo "$cleanRecord" | xmlstarlet ed -s '/computer_extension_attribute/input_type' -t elem -n script -v "$(cat "$script" | xmlstarlet esc)" | xmlstarlet fo -n -o) 172 | else 173 | xml=$(echo "$cleanRecord") 174 | fi 175 | 176 | # Bail if the xml didn't get encoded properly 177 | if [[ -z "$xml" ]]; then 178 | echo "Failed to encode the extension attribute xml record properly." 179 | return 1 180 | fi 181 | 182 | # Determine the id of an ea that may exist in Jamf Pro with the same name 183 | id=$(get_ea_summaries | xmlstarlet sel -T -t -m "//computer_extension_attribute[name=\"$name\"]" -v id 2>/dev/null) 184 | 185 | # Update the EA in Jamf Pro if it already exists 186 | # Otherwise, create a new EA in Jamf Pro 187 | if [[ -n "$id" ]]; then 188 | 189 | # If configured to backup updated items, do that now 190 | [[ "$backupUpdated" == "true" ]] && download_ea "$id" "$name" "./backups/extension_attributes" 191 | 192 | # Handle dry run and return 193 | [[ "$dryRun" == "true" ]] && echo "Simulating updating extension attribute \"$name\"..." && sleep 1 && return 194 | 195 | echo "Updating extension attribute: $name..." 196 | httpCode=$(curl -s -H "Authorization: Bearer $apiToken" -H "Content-Type: application/xml" \ 197 | "$jamfProURL/JSSResource/computerextensionattributes/id/$id" -d "$xml" -X PUT -o /dev/null -w "%{http_code}") 198 | parse_jamf_pro_api_http_codes "$httpCode" || return 1 199 | else 200 | [[ "$dryRun" == "true" ]] && echo "Simulating creating extension attribute \"$name\"..." && sleep 1 && return 201 | 202 | echo "Creating extension attribute: $name..." 203 | httpCode=$(curl -s -H "Authorization: Bearer $apiToken" -H "Content-Type: application/xml" \ 204 | "$jamfProURL/JSSResource/computerextensionattributes/id/0" -d "$xml" -X POST -o /dev/null -w "%{http_code}") 205 | parse_jamf_pro_api_http_codes "$httpCode" || return 1 206 | fi 207 | 208 | return 209 | } 210 | 211 | # Write the summaries (ID & Name) of each script locally for later parsing 212 | function get_script_summaries() { 213 | 214 | if [[ -e "$scriptSummariesFile" ]]; then 215 | cat "$scriptSummariesFile" 216 | else 217 | curl -s -H "Authorization: Bearer $apiToken" -H "accept: application/xml" \ 218 | "$jamfProURL/JSSResource/scripts" -X GET 2>/dev/null | xmlstarlet fo > "$scriptSummariesFile" 219 | cat "$scriptSummariesFile" 220 | fi 221 | } 222 | 223 | # Write the summaries (ID & Name) of each EA locally for later parsing 224 | function get_ea_summaries() { 225 | 226 | if [[ -e "$eaSummariesFile" ]]; then 227 | cat "$eaSummariesFile" 228 | else 229 | curl -s -H "Authorization: Bearer $apiToken" -H "accept: application/xml" \ 230 | "$jamfProURL/JSSResource/computerextensionattributes" -X GET 2>/dev/null | xmlstarlet fo > "$eaSummariesFile" 231 | cat "$eaSummariesFile" 232 | fi 233 | } 234 | 235 | # Function to parse Jamf Pro API http codes 236 | # https://developer.jamf.com/jamf-pro/docs/jamf-pro-api-overview#response-codes 237 | function parse_jamf_pro_api_http_codes() { 238 | local httpCode="$1" 239 | 240 | case "$httpCode" in 241 | 200) # Request successful. 242 | return 243 | ;; 244 | 201) # Request to create or update resource successful. 245 | return 246 | ;; 247 | 202) # The request was accepted for processing, but the processing has not completed. 248 | return 249 | ;; 250 | 204) # Request successful. Resource successfully deleted. 251 | return 252 | ;; 253 | # Anything past this point is an error and will return 1 254 | 400) 255 | echo "Bad request. Verify the syntax of the request, specifically the request body." 256 | ;; 257 | 401) 258 | echo "Authentication failed. Verify the credentials being used for the request." 259 | ;; 260 | 403) 261 | echo "Invalid permissions. Verify the account being used has the proper permissions for the resource you are trying to access." 262 | ;; 263 | 404) 264 | echo "Resource not found. Verify the URL path is correct." 265 | ;; 266 | 409) 267 | echo "The request could not be completed due to a conflict with the current state of the resource." 268 | ;; 269 | 412) 270 | echo "Precondition failed. See error description for additional details." 271 | ;; 272 | 414) 273 | echo "Request-URI too long." 274 | ;; 275 | 500) 276 | echo "Internal server error. Retry the request or contact support if the error persists." 277 | ;; 278 | 503) 279 | echo "Service unavailable." 280 | ;; 281 | *) 282 | echo "Unknown error occured ($httpCode)." 283 | ;; 284 | esac 285 | 286 | return 1 287 | } 288 | 289 | # Function to parse a script's shebang and determine the appropriate file extension 290 | function get_script_extension() { 291 | local shebang="$1" 292 | 293 | # Switch the shebang and determine the script extension 294 | # There are other possible script extensions but these are the most likely types 295 | # https://learn.jamf.com/bundle/jamf-pro-documentation-current/page/Scripts.html# 296 | case "$shebang" in 297 | *python*) 298 | echo "py" 299 | ;; 300 | *swift*) 301 | echo "swift" 302 | ;; 303 | *perl*) 304 | echo "pl" 305 | ;; 306 | *ruby*) 307 | echo "rb" 308 | ;; 309 | *osascript*) 310 | echo "applescript" 311 | ;; 312 | *zsh*) 313 | echo "zsh" 314 | ;; 315 | # Everything else falls into a sh script 316 | # Other types can easily be added above this point if necessary 317 | *) 318 | echo "sh" 319 | ;; 320 | esac 321 | 322 | return 323 | } 324 | 325 | # Function to download a script from Jamf Pro by ID 326 | function download_script() { 327 | local id="$1" 328 | local name="$2" 329 | local dlPath="$3" 330 | local script shebang extension 331 | 332 | # Pull the full script object from Jamf Pro 333 | script=$(curl --request GET -H "Authorization: Bearer $apiToken" \ 334 | "$jamfProURL/JSSResource/scripts/id/$id" -H "accept: application/xml" 2>/dev/null) 335 | 336 | [[ -z "$script" ]] && echo "Error getting script." && return 1 337 | 338 | # Get the shebang from the script 339 | shebang=$(echo "$script" | xmlstarlet sel -T -t -m '/script' -v script_contents | head -1) 340 | 341 | # Determine the file extension from the script's shebang 342 | extension=$(get_script_extension "$shebang") 343 | 344 | echo "Writing script \"$name\" to disk." 345 | 346 | # Make a directory for the script 347 | mkdir -p "${dlPath}/${name}" 348 | 349 | # Write the xml script object to disk 350 | echo "$script" | xmlstarlet ed --delete '/script/id' \ 351 | --delete '/script/script_contents' \ 352 | --delete '/script/script_contents_encoded' \ 353 | --delete '/script/filename' > "${dlPath}/${name}/record.xml" 354 | 355 | # Write the script file to disk 356 | echo "$script" | xmlstarlet sel -T -t -m '/script' -v script_contents | tr -d '\r' > "${dlPath}/${name}/script.${extension}" 357 | 358 | return 359 | } 360 | 361 | # Function to download an EA from Jamf Pro by ID 362 | function download_ea() { 363 | local id="$1" 364 | local name="$2" 365 | local dlPath="$3" 366 | local ea shebang extension 367 | 368 | # Pull the full EA object from Jamf Pro 369 | ea=$(curl --request GET -H "Authorization: Bearer $apiToken" \ 370 | "$jamfProURL/JSSResource/computerextensionattributes/id/$id" -H "accept: application/xml" 2>/dev/null) 371 | 372 | [[ -z "$ea" ]] && echo "Error getting extension attribute." && return 1 373 | 374 | # Get the shebang from the EAs script 375 | shebang=$(echo "$ea" | xmlstarlet sel -T -t -m '/computer_extension_attribute/input_type' -v script | head -1) 376 | 377 | # Determine the file extension by the script's shebang 378 | extension=$(get_script_extension "$shebang") 379 | 380 | echo "Writing extension attribute \"$name\" to disk." 381 | 382 | # Make a directory for the object 383 | mkdir -p "${dlPath}/${name}" 384 | 385 | # Write the xml script object to disk 386 | echo "$ea" | xmlstarlet ed --delete '/computer_extension_attribute/id' \ 387 | --delete '/computer_extension_attribute/input_type/script' > "${dlPath}/${name}/record.xml" 388 | 389 | # Write the script file to disk 390 | echo "$ea" | xmlstarlet sel -T -t -m '/computer_extension_attribute/input_type' -v script | tr -d '\r' > "${dlPath}/${name}/script.${extension}" 391 | 392 | return 393 | } 394 | 395 | # Begin main logic 396 | 397 | # Determine if we have jq installed, and exit if not 398 | if ! command -v jq > /dev/null ; then 399 | echo "Error: jq is not installed, can't continue." 400 | 401 | if [[ "$unameType" == "Darwin" ]]; then 402 | echo "Suggestion: Install jq with Homebrew: \"brew install jq\"" 403 | else 404 | echo "Suggestion: Install jq with your distro's package manager." 405 | fi 406 | 407 | exit 1 408 | fi 409 | 410 | # Determine if we have xmlstarlet installed, and exit if not 411 | if ! command -v xmlstarlet > /dev/null ; then 412 | echo "Error: xmlstarlet is not installed, can't continue." 413 | 414 | if [[ "$unameType" == "Darwin" ]]; then 415 | echo "Suggestion: Install xmlstarlet with Homebrew: \"brew install xmlstarlet\"" 416 | else 417 | echo "Suggestion: Install xmlstarlet with your distro's package manager." 418 | fi 419 | 420 | exit 1 421 | fi 422 | 423 | # Parse our command line arguments 424 | while test $# -gt 0 425 | do 426 | case "$1" in 427 | --url) 428 | shift 429 | jamfProURL="${1%/}" 430 | ;; 431 | --username) 432 | shift 433 | apiUser="$1" 434 | ;; 435 | --password) 436 | shift 437 | apiPass="$1" 438 | ;; 439 | --download-scripts) 440 | downloadScripts="true" 441 | ;; 442 | --download-eas) 443 | downloadEAs="true" 444 | ;; 445 | --push-changes-to-jamf-pro) 446 | pushChangesToJamfPro="true" 447 | ;; 448 | --backup-updated) 449 | backupUpdated="true" 450 | ;; 451 | --limit) 452 | shift 453 | maxParallelJobs="$1" 454 | ;; 455 | --dry-run) dryRun="true" 456 | ;; 457 | *) 458 | # Exit if we received an unknown option/flag/argument 459 | [[ "$1" == --* ]] && echo "Unknown option/flag: $1" && exit 4 460 | [[ "$1" != --* ]] && echo "Unknown argument: $1" && exit 4 461 | ;; 462 | esac 463 | shift 464 | done 465 | 466 | # Bail if our required cli options are missing 467 | [[ -z "$jamfProURL" ]] && echo "Error: Missing Jamf Pro URL (--url); exiting." && exit 1 468 | [[ -z "$apiUser" ]] && echo "Error: Missing API User (--username); exiting." && exit 2 469 | [[ -z "$apiPass" ]] && echo "Error: Missing API Password (--password); exiting." && exit 3 470 | 471 | # Get out Jamf Pro API Bearer Token 472 | get_jamf_pro_api_token 473 | 474 | # Push any scripts/EAs changed in the last `git commit` to Jamf Pro 475 | if [[ "$pushChangesToJamfPro" == "true" ]]; then 476 | 477 | # Make sure we are running from a git repository 478 | if ! git rev-parse --is-inside-work-tree &>/dev/null; then 479 | echo "Error: Not a git repository." 480 | echo "Hint: This is designed to upload changes to scripts/EAs that are changed between two git commits." 481 | exit 1 482 | fi 483 | 484 | echo "Determining changes between the last two git commits..." 485 | 486 | # Loop through each file changed between the last to commits 487 | while read -r change; do 488 | 489 | # Determine if the change was a script or EA and process accordingly 490 | if [[ "$change" == scripts/* ]]; then 491 | process_changed_script "$change" 492 | elif [[ "$change" == extension_attributes/* ]]; then 493 | process_changed_ea "$change" 494 | else 495 | echo "Ignoring non-tracked changed file: $change" 496 | continue 497 | fi 498 | 499 | # Coalesce multiple changes within the same directory so we don't process twice 500 | done < <(git diff --name-only HEAD HEAD~1 2>/dev/null | grep -E '^(scripts|extension_attributes).*' | rev | sort -u -t '/' -k2 | rev | sort) 501 | exit 0 502 | fi 503 | 504 | # Download scripts if configured to do so with two jobs per core (unless --limit is set) 505 | if [[ "$downloadScripts" == "true" ]]; then 506 | 507 | echo "Getting identifying info for all scripts in Jamf Pro..." 508 | 509 | # Loop through each script ID/Name from a summary obtained from Jamf Pro 510 | while read -r summary; do 511 | 512 | # Limit the parallel jobs to what we've set as the max 513 | until [[ "$(jobs -lr 2>&1 | wc -l)" -lt "$maxParallelJobs" ]]; do 514 | sleep 1 515 | done 516 | 517 | # Extract the id and name of each script 518 | id=$(echo "$summary" | xmlstarlet sel -T -t -m '/script' -v id) 519 | name=$(echo "$summary" | xmlstarlet sel -T -t -m '/script' -v name) 520 | 521 | # Download the script in a background thread 522 | download_script "$id" "$name" "./scripts" & 523 | done < <(get_script_summaries | xmllint --xpath '/scripts/script' --format -) 524 | wait 525 | fi 526 | 527 | # Download EAs if configured to do so with two jobs per core (unless --limit is set) 528 | if [[ "$downloadEAs" == "true" ]]; then 529 | 530 | echo "Getting identifying info for all computer extension attributes in Jamf Pro..." 531 | 532 | # Loop through each EA ID/Name from a summary obtained from Jamf Pro 533 | while read -r summary; do 534 | 535 | # Limit the parallel jobs to what we've set as the max 536 | until [[ "$(jobs -lr 2>&1 | wc -l)" -lt "$maxParallelJobs" ]]; do 537 | sleep 1 538 | done 539 | 540 | # Extract the id and name of each EA 541 | id=$(echo "$summary" | xmlstarlet sel -T -t -m '/computer_extension_attribute' -v id) 542 | name=$(echo "$summary" | xmlstarlet sel -T -t -m '/computer_extension_attribute' -v name) 543 | 544 | # Download the script in a background thread 545 | download_ea "$id" "$name" "./extension_attributes" & 546 | done < <(get_ea_summaries | xmllint --xpath '/computer_extension_attributes/computer_extension_attribute' --format -) 547 | wait 548 | fi 549 | 550 | exit 0 -------------------------------------------------------------------------------- /scripts/Sample Bash Script/record.xml: -------------------------------------------------------------------------------- 1 | 2 | 16 | -------------------------------------------------------------------------------- /scripts/Sample Bash Script/script.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "Hello World!" 4 | 5 | exit 0 -------------------------------------------------------------------------------- /scripts/Sample Python Script/record.xml: -------------------------------------------------------------------------------- 1 | 2 | 15 | -------------------------------------------------------------------------------- /scripts/Sample Python Script/script.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | print("Hello world!") 4 | 5 | exit() -------------------------------------------------------------------------------- /scripts/Sample ZSH Script/record.xml: -------------------------------------------------------------------------------- 1 | 2 | 15 | -------------------------------------------------------------------------------- /scripts/Sample ZSH Script/script.zsh: -------------------------------------------------------------------------------- 1 | #!/bin/zsh 2 | 3 | echo "Hello World!" 4 | 5 | exit 0 --------------------------------------------------------------------------------