From 44e7fff54c5b3da63f8814256070cc34267ad166 Mon Sep 17 00:00:00 2001 From: Rusty Myers Date: Wed, 18 Oct 2023 14:24:02 -0400 Subject: [PATCH 01/44] Adding config file --- sync.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/sync.py b/sync.py index eea492c..0f84f6e 100755 --- a/sync.py +++ b/sync.py @@ -12,6 +12,7 @@ import async_timeout import aiohttp import uvloop +import configparser logging.basicConfig( level=logging.DEBUG, @@ -417,6 +418,37 @@ async def main(): if args.jenkins: write_jenkins_file() + # Get configs from files + CONFIG_FILE_LOCATIONS = ['jamfapi.cfg',os.path.expanduser('~/jamfapi.cfg')] + CONFIG_FILE = '' + # Parse Config File + CONFPARSER = configparser.ConfigParser() + for config_path in CONFIG_FILE_LOCATIONS: + if os.path.exists(config_path): + print("Found Config: {0}".format(config_path)) + CONFIG_FILE = config_path + + if CONFIG_FILE == "": + config_ = configparser.ConfigParser() + config_['jss'] = {} + config_['jss']['username'] = "username" + config_['jss']['password'] = "password" + config_['jss']['server'] = "server" + print(config_) + with open('jamfapi.cfg', 'w') as configfile: + config_.write(configfile) + print("Config File Created. Please edit jamfapi.cfg and run again.") + + print("No Config File found!") + exit(0) + else: + # Read local directory, user home, then /etc/ for besapi.conf + CONFPARSER.read(CONFIG_FILE) + # If file exists + # Get config + args.username = CONFPARSER.get('jss', 'username') + args.password = CONFPARSER.get('jss', 'password') + args.url = CONFPARSER.get('jss', 'server') # Ask for password if not supplied via command line args if not args.password: From 78915979c09ad37e7e7ba28074b26e9a19127fe6 Mon Sep 17 00:00:00 2001 From: Rusty Myers Date: Wed, 18 Oct 2023 14:34:37 -0400 Subject: [PATCH 02/44] Adding Requirements, configparser to download.py --- requirements.txt | 1 + tools/download.py | 34 +++++++++++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 1871bbf..c201a9a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,4 @@ cchardet aiodns uvloop requests +configparser \ No newline at end of file diff --git a/tools/download.py b/tools/download.py index 95b2aa6..c9961dd 100755 --- a/tools/download.py +++ b/tools/download.py @@ -6,6 +6,7 @@ import os import argparse import urllib3 +import configparser # Suppress the warning in dev urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) @@ -145,7 +146,38 @@ def download_scripts(mode, overwrite=None,): parser.add_argument('--overwrite', action='store_true') # Overwrites existing files parser.add_argument('--do_not_verify_ssl', action='store_false') # Skips SSL verification args = parser.parse_args() - + # Get configs from files + CONFIG_FILE_LOCATIONS = ['jamfapi.cfg',os.path.expanduser('~/jamfapi.cfg')] + CONFIG_FILE = '' + # Parse Config File + CONFPARSER = configparser.ConfigParser() + for config_path in CONFIG_FILE_LOCATIONS: + if os.path.exists(config_path): + print("Found Config: {0}".format(config_path)) + CONFIG_FILE = config_path + + if CONFIG_FILE == "": + config_ = configparser.ConfigParser() + config_['jss'] = {} + config_['jss']['username'] = "username" + config_['jss']['password'] = "password" + config_['jss']['server'] = "server" + print(config_) + with open('jamfapi.cfg', 'w') as configfile: + config_.write(configfile) + print("Config File Created. Please edit jamfapi.cfg and run again.") + + print("No Config File found!") + exit(0) + else: + # Read local directory, user home, then /etc/ for besapi.conf + CONFPARSER.read(CONFIG_FILE) + # If file exists + # Get config + args.username = CONFPARSER.get('jss', 'username') + args.password = CONFPARSER.get('jss', 'password') + args.url = CONFPARSER.get('jss', 'server') + # Ask for password if not supplied via command line args if args.password: password = args.password From af81b87d9339be12600dce7b55057ffadb1538b1 Mon Sep 17 00:00:00 2001 From: Rusty Myers Date: Wed, 18 Oct 2023 15:56:26 -0400 Subject: [PATCH 03/44] Adding token auth, configparser, and export_path --- tools/download.py | 71 +++++++++++++++++++++++++++++++++++------------ 1 file changed, 53 insertions(+), 18 deletions(-) diff --git a/tools/download.py b/tools/download.py index c9961dd..dc5a071 100755 --- a/tools/download.py +++ b/tools/download.py @@ -11,7 +11,24 @@ # Suppress the warning in dev urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) -mypath = os.path.dirname(os.path.realpath(__file__)) +# https://github.com/lazymutt/Jamf-Pro-API-Sampler/blob/5f8efa92911271248f527e70bd682db79bc600f2/jamf_duplicate_detection.py#L99 +def get_uapi_token(): + ''' + fetches api token + ''' + jamf_test_url = url + "/api/v1/auth/token" + response = requests.post(url=jamf_test_url, auth=(username, password)) + response_json = response.json() + return response_json['token'] + + +def invalidate_uapi_token(uapi_token): + ''' + invalidates api token + ''' + jamf_test_url = url + "/api/v1/auth/invalidate-token" + headers = {'Accept': '*/*', 'Authorization': 'Bearer ' + uapi_token} + _ = requests.post(url=jamf_test_url, headers=headers) def download_scripts(mode, overwrite=None,): """ Downloads Scripts to ./scripts and Extension Attributes to ./extension_attributes @@ -47,12 +64,13 @@ def download_scripts(mode, overwrite=None,): download_path = 'scripts' script_xml = 'script_contents' - + token = get_uapi_token() # Get all IDs of resource type - r = requests.get(args.url + '/JSSResource/%s' %resource, - auth = (args.username, password), - headers= {'Accept': 'application/xml','Content-Type': 'application/xml'}, - verify=args.do_not_verify_ssl) + r = requests.get(url + '/JSSResource/%s' %resource, + headers= {'Accept': 'application/xml', + 'Content-Type': 'application/xml', + 'Authorization': 'Bearer ' + token}, + verify=args.do_not_verify_ssl) # Basic error handling if r.status_code != 200: @@ -67,9 +85,11 @@ def download_scripts(mode, overwrite=None,): for resource_id in resource_ids: get_script = True - r = requests.get(args.url + '/JSSResource/%s/id/%s' % (resource,resource_id), - auth = (args.username, password), - headers= {'Accept': 'application/xml','Content-Type': 'application/xml'}, verify=args.do_not_verify_ssl) + r = requests.get(url + '/JSSResource/%s/id/%s' % (resource,resource_id), + headers= {'Accept': 'application/xml', + 'Content-Type': 'application/xml', + 'Authorization': 'Bearer ' + token}, + verify=args.do_not_verify_ssl) tree = ET.fromstring(r.content) if mode == 'ea': @@ -79,7 +99,7 @@ def download_scripts(mode, overwrite=None,): # continue # Determine resource path (folder name) - resource_path = os.path.join(mypath, '..', download_path ,tree.find('name').text) + resource_path = os.path.join(export_path, download_path ,tree.find('name').text) # Check to see if it exists if os.path.exists(resource_path): @@ -135,14 +155,14 @@ def download_scripts(mode, overwrite=None,): xmlstr = minidom.parseString(ET.tostring(tree, encoding='unicode', method='xml')).toprettyxml(indent=" ") with open(os.path.join(resource_path, '%s.xml' % mode), 'w') as f: f.write(xmlstr) - - + invalidate_uapi_token(token) if __name__ == '__main__': parser = argparse.ArgumentParser(description='Download Scripts from Jamf') parser.add_argument('--url') parser.add_argument('--username') parser.add_argument('--password') + parser.add_argument('--export_path') parser.add_argument('--overwrite', action='store_true') # Overwrites existing files parser.add_argument('--do_not_verify_ssl', action='store_false') # Skips SSL verification args = parser.parse_args() @@ -162,7 +182,8 @@ def download_scripts(mode, overwrite=None,): config_['jss']['username'] = "username" config_['jss']['password'] = "password" config_['jss']['server'] = "server" - print(config_) + config_['jss']['export_path'] = "export_path" + with open('jamfapi.cfg', 'w') as configfile: config_.write(configfile) print("Config File Created. Please edit jamfapi.cfg and run again.") @@ -174,16 +195,30 @@ def download_scripts(mode, overwrite=None,): CONFPARSER.read(CONFIG_FILE) # If file exists # Get config - args.username = CONFPARSER.get('jss', 'username') - args.password = CONFPARSER.get('jss', 'password') - args.url = CONFPARSER.get('jss', 'server') - + username = CONFPARSER.get('jss', 'username') + password = CONFPARSER.get('jss', 'password') + url = CONFPARSER.get('jss', 'server') + try: + export_path = CONFPARSER.get('jss', 'export_path') + except: + # Export to current directory by default + export_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), '..') + # Ask for password if not supplied via command line args if args.password: password = args.password - else: + elif password is None: password = getpass.getpass() + + if args.export_path: + export_path = args.export_path + + if args.url: + url = args.url + if args.username: + username = args.username + # Run script download for extension attributes download_scripts(overwrite=args.overwrite, mode='ea') # Run script download for scripts From 8029a4c07d7289e9958f8e2061c0876aa5a8d966 Mon Sep 17 00:00:00 2001 From: Rusty Myers Date: Wed, 18 Oct 2023 16:07:26 -0400 Subject: [PATCH 04/44] Adding token auth, configparser, and sync_path --- extension_attributes/Last User/ea.sh | 2 +- extension_attributes/Last User/ea.xml | 2 +- scripts/Install Software Updates/script.sh | 2 +- sync.py | 135 +++++++++++++-------- 4 files changed, 87 insertions(+), 54 deletions(-) diff --git a/extension_attributes/Last User/ea.sh b/extension_attributes/Last User/ea.sh index a9bd9df..059b4d3 100644 --- a/extension_attributes/Last User/ea.sh +++ b/extension_attributes/Last User/ea.sh @@ -4,4 +4,4 @@ lastUser=`defaults read /Library/Preferences/com.apple.loginwindow lastUserName` if [ $lastUser == "" ]; then echo "No logins" else - echo "$lastUser" + echo "$lastUser" \ No newline at end of file diff --git a/extension_attributes/Last User/ea.xml b/extension_attributes/Last User/ea.xml index 0a11e78..8195681 100644 --- a/extension_attributes/Last User/ea.xml +++ b/extension_attributes/Last User/ea.xml @@ -1,6 +1,7 @@ Last User + true This attribute displays the last user to log in. This attribute applies to both Mac and Windows. String @@ -9,5 +10,4 @@ diff --git a/tools/ci_tests/validate_files_and_folders.sh b/tools/ci_tests/validate_files_and_folders.sh old mode 100644 new mode 100755 index 7433be8..81d113c --- a/tools/ci_tests/validate_files_and_folders.sh +++ b/tools/ci_tests/validate_files_and_folders.sh @@ -5,20 +5,20 @@ #Load up some variables #Define scripts and templates folders -scripts=$(ls -p scripts | grep -v '/$' | sed -e 's/\..*$//') -scripts_templates=$(ls -p scripts/templates/| sed -e 's/\..*$//') +scripts=$(ls -1 scripts | grep -v "templates") +scripts_templates=$(ls -1 scripts/templates/) #Define EA and templates -extensionattributes=$(ls -p extension_attributes | grep -v '/$' | sed -e 's/\..*$//') -extensionattributes_templates=$(ls -p extension_attributes/templates/| sed -e 's/\..*$//') +extensionattributes=$(ls -1 extension_attributes | grep -v "templates") +extensionattributes_templates=$(ls -1 extension_attributes/templates/) #Validate both the Script and the Template for the Script exist. -echo "Making sure files exist in both places in scripts and scripts/Templates" +echo "Making sure files with same names exist in both places in scripts and scripts/Templates" scriptcompare=$(sdiff -bBWsw 75 <(echo "$scripts") <(echo "$scripts_templates" )) if [ "$scriptcompare" == "" ]; then - echo "Script and Script Template Exist All good in the hood!" + echo "Script and Script Template Exist in both folders!" else echo "Errors! occurred please correct the below" echo " Scripts | Templates" @@ -31,10 +31,10 @@ fi #Valate both the EA and the Template for the EA exist. -echo "Making sure files exist in both places extension_attributes and extension_attributes/Templates" +echo "Making sure files with same names exist in both places extension_attributes and extension_attributes/Templates" eacompare=$(sdiff -bBWsw 75 <(echo "$extensionattributes") <(echo "$extensionattributes_templates")) if [ "$eacompare" == "" ]; then - echo "EA and EA Template Exist All good in the hood!" + echo "EA and EA Template Exist in both folders!" else echo "Errors! occurred please correct the below" echo " Extension Attributes | Templates" diff --git a/tools/ci_tests/validatexml.sh b/tools/ci_tests/validatexml.sh old mode 100644 new mode 100755 index b20eaeb..c17f525 --- a/tools/ci_tests/validatexml.sh +++ b/tools/ci_tests/validatexml.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/bin/zsh ################################################################################### ## Validates XML for proper formatting ################################################################################### @@ -9,10 +9,10 @@ function scripts() { printf "\033[31m Working on Scripts\n" printf "\033[31m---------------------------------------------------------------------------------\n" printf "\033[0m" -scriptfolders=$(ls -ltr ./scripts | cut -c52- | awk 'NR>1') +scriptfolders=$(ls -1 ./scripts | awk 'NR>1') while read folder ; do echo "$folder" - xmllint --noout ./scripts/"$folder"/*.xml + xmllint --noout ./scripts/"$folder"/*.xml done <<< "$scriptfolders" } @@ -20,7 +20,7 @@ scriptfolders=$(ls -ltr ./scripts | cut -c52- | awk 'NR>1') function ea(){ - eafolders=$(ls -ltr ./extension_attributes | cut -c52- | awk 'NR>1') + eafolders=$(ls -1 ./extension_attributes | awk 'NR>1') printf "\033[31m---------------------------------------------------------------------------------\n" printf "\033[31m Working on Extension Attributes\n" @@ -30,7 +30,7 @@ function ea(){ echo "$folder" - xmllint --noout ./extension_attributes/"$folder"/*.xml + xmllint --noout ./extension_attributes/"$folder"/*.xml done <<< "$eafolders" } diff --git a/tools/ci_tests/verifyEA.py b/tools/ci_tests/verifyEA.py old mode 100644 new mode 100755 index 8e40a16..3995a19 --- a/tools/ci_tests/verifyEA.py +++ b/tools/ci_tests/verifyEA.py @@ -1,60 +1,125 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 import requests -from xml.etree import ElementTree as ET +from defusedxml import ElementTree as eTree import os import getpass import json +import configparser # Use this script to validate that EA values aren't changing as a result of syncing # Overwrite computers.json overwrite = False +smart_group = "1018" # Constants -url = 'https://your.jss.com' -username = getpass.getuser() -password = getpass.getpass() +# Get configs from files +CONFIG_FILE_LOCATIONS = ["jamfapi.cfg", os.path.expanduser("~/jamfapi.cfg")] +CONFIG_FILE = "" +# Parse Config File +CONFPARSER = configparser.ConfigParser() +for config_path in CONFIG_FILE_LOCATIONS: + if os.path.exists(config_path): + print("Found Config: {0}".format(config_path)) + CONFIG_FILE = config_path + +if CONFIG_FILE != "": + # Get config + CONFPARSER.read(CONFIG_FILE) + try: + username = CONFPARSER.get("jss", "username") + except configparser.NoOptionError: + print("Can't find username in configfile") + try: + password = CONFPARSER.get("jss", "password") + except configparser.NoOptionError: + print("Can't find password in configfile") + try: + url = CONFPARSER.get("jss", "server") + except configparser.NoOptionError: + print("Can't find url in configfile") + try: + smart_group = CONFPARSER.get("verifyEA", "smart_group") + except configparser.NoOptionError: + print("Can't find smart_group in configfile") + +else: + url = "https://your.jss.com" + username = getpass.getuser() + password = getpass.getpass() + smart_group = input("Enter smart_group") + + +def get_uapi_token(): + """ + fetches api token + """ + jamf_test_url = url + "/api/v1/auth/token" + response = requests.post(url=jamf_test_url, auth=(username, password), timeout=5) + response_json = response.json() + return response_json["token"] + + +def invalidate_uapi_token(uapi_token): + """ + invalidates api token + """ + jamf_test_url = url + "/api/v1/auth/invalidate-token" + headers = {"Accept": "*/*", "Authorization": "Bearer " + uapi_token} + _ = requests.post(url=jamf_test_url, headers=headers, timeout=5) def overwrite_file(): - print('Overwriting File: computers.json...') - with open('computers.json', 'w') as f: + print("Overwriting File: computers.json...") + with open("computers.json", "w") as f: f.write(json.dumps(computers)) + def read_file(): - print('Reading cached data from disk...') - with open('computers.json', 'r') as f: + print("Reading cached data from disk...") + with open("computers.json", "r") as f: computers_from_disk = json.load(f) return computers_from_disk -def build_computers_data_object(): - # Get IDs for computers - print('Communicating with the Jamf Pro Server...') + +def build_computers_data_object(token, group_id): + """Builds computer data into local file + params: token, group_id + returns: computers objects json + """ + print("Communicating with the Jamf Pro Server...") computers = {} - r = requests.get(url + '/JSSResource/computergroups/id/810', - auth = (username, password), - headers= {'Content-Type': 'application/xml'}) + r = requests.get( + url + "/JSSResource/computergroups/id/{0}".format(group_id), + headers={"Content-Type": "application/xml", "Authorization": "Bearer " + token}, + ) - tree = ET.fromstring(r.content) - resource_ids = [ e.text for e in tree.findall('computers/computer/id') ] + tree = eTree.fromstring(r.content) + resource_ids = [e.text for e in tree.findall("computers/computer/id")] # Download each resource and save to disk for resource_id in resource_ids: - # Get detailed information about the record - r = requests.get(url + '/JSSResource/computers/id/%s' % (resource_id), - auth = (username, password), - headers={'Content-Type': 'application/json'}) - - # Parse xml - tree = ET.fromstring(r.content) - ea_values = [ e.text for e in tree.findall('extension_attributes/extension_attribute/value') ] - ea_names = [ e.text for e in tree.findall('extension_attributes/extension_attribute/name') ] - - # Build the json for the comparison + r = requests.get( + url + "/JSSResource/computers/id/{0}".format(resource_id), + headers={"Content-Type": "application/json", "Authorization": "Bearer " + token}, + ) + + # Parse xml + tree = eTree.fromstring(r.content) + ea_values = [ + e.text + for e in tree.findall("extension_attributes/extension_attribute/value") + ] + ea_names = [ + e.text + for e in tree.findall("extension_attributes/extension_attribute/name") + ] + + # Build the json for the comparison computers[resource_id] = {} - for k,v in zip(ea_names,ea_values): + for k, v in zip(ea_names, ea_values): computers[resource_id][k] = v return computers @@ -67,25 +132,34 @@ def compare_computer(computer_id): print("Processing Computer ID: %s" % computer_id) for key in computers[computer_id].keys(): if computers[computer_id][key] != computers_from_disk[computer_id][key]: - print("Value Change Found\n\tEA Name:\t{}\n\tOriginal Value:\t{}\n\tNew Value:\t{}".format(key,computers[computer_id][key],computers_from_disk[computer_id][key])) + print( + "Value Change Found\n\tEA Name:\t{}\n\tOriginal Value:\t{}\n\tNew Value:\t{}".format( + key, + computers[computer_id][key], + computers_from_disk[computer_id][key], + ) + ) + # Is this the first time it was run? mypath = os.path.dirname(os.path.realpath(__file__)) -if os.path.exists(os.path.join(mypath,'computers.json')): +if os.path.exists(os.path.join(mypath, "computers.json")): computers_from_disk = read_file() else: - print('No cached data found, writing new data to computers.json') + print("No cached data found, writing new data to computers.json") overwrite = True -# Get computers information from JSS -computers = build_computers_data_object() +token = get_uapi_token() + +# Get computers information from JSS smart group +computers = build_computers_data_object(token, smart_group) # Overwrite local file? -if overwrite == True: +if overwrite == True: overwrite_file() - + print("Computer data staged for comparison with future runs.") else: # Compare each computer - print('Analyzing the results...') + print("Analyzing the results...") for computer_id in list(computers.keys()): compare_computer(computer_id) diff --git a/tools/download.py b/tools/download.py index 904c082..3d90c18 100755 --- a/tools/download.py +++ b/tools/download.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 import getpass import requests -from defusedxml import ElementTree as ET +from defusedxml import ElementTree as eTree from xml.dom import minidom import os import argparse @@ -79,7 +79,7 @@ def download_scripts( "Authorization": "Bearer " + token, }, verify=args.do_not_verify_ssl, - timeout=5 + timeout=5, ) # Basic error handling @@ -91,7 +91,7 @@ def download_scripts( % r.status_code ) exit(1) - tree = ET.fromstring(r.content) + tree = eTree.fromstring(r.content) resource_ids = [e.text for e in tree.findall(".//id")] # Download each resource and save to disk @@ -106,9 +106,9 @@ def download_scripts( "Authorization": "Bearer " + token, }, verify=args.do_not_verify_ssl, - timeout=5 + timeout=5, ) - tree = ET.fromstring(r.content) + tree = eTree.fromstring(r.content) if mode == "ea": if tree.find("input_type/type").text != "script": @@ -134,7 +134,7 @@ def download_scripts( # Create script string, and determine the file extension if get_script: - xmlstr = ET.tostring( + xmlstr = eTree.tostring( tree.find(script_xml), encoding="unicode", method="text" ).replace("\r", "") if xmlstr.startswith("#!/bin/sh"): @@ -172,7 +172,7 @@ def download_scripts( pass xmlstr = minidom.parseString( - ET.tostring(tree, encoding="unicode", method="xml") + eTree.tostring(tree, encoding="unicode", method="xml") ).toprettyxml(indent=" ") with open(os.path.join(resource_path, "%s.xml" % mode), "w") as f: f.write(xmlstr) @@ -204,26 +204,23 @@ def download_scripts( CONFIG_FILE = config_path if CONFIG_FILE != "": - try: - # Get config - CONFPARSER.read(CONFIG_FILE) - except: - print("Can't read config file") + # Get config + CONFPARSER.read(CONFIG_FILE) try: username = CONFPARSER.get("jss", "username") - except: + except configparser.NoOptionError: print("Can't find username in configfile") try: password = CONFPARSER.get("jss", "password") - except: + except configparser.NoOptionError: print("Can't find password in configfile") try: url = CONFPARSER.get("jss", "server") - except: + except configparser.NoOptionError: print("Can't find url in configfile") try: export_path = CONFPARSER.get("jss", "export_path") - except: + except configparser.NoOptionError: print("Can't find export_path in config") # Ask for password if not supplied via command line args From 2749dc548925c32f27d56332b99b20e623c8c89b Mon Sep 17 00:00:00 2001 From: Rusty Myers Date: Wed, 1 Nov 2023 12:07:56 -0400 Subject: [PATCH 26/44] Adding exception type to download, compressing configs for verify --- tools/ci_tests/verifyEA.py | 18 +++++++----------- tools/download.py | 2 +- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/tools/ci_tests/verifyEA.py b/tools/ci_tests/verifyEA.py index 3995a19..1dcd01c 100755 --- a/tools/ci_tests/verifyEA.py +++ b/tools/ci_tests/verifyEA.py @@ -29,21 +29,15 @@ CONFPARSER.read(CONFIG_FILE) try: username = CONFPARSER.get("jss", "username") - except configparser.NoOptionError: - print("Can't find username in configfile") - try: password = CONFPARSER.get("jss", "password") - except configparser.NoOptionError: - print("Can't find password in configfile") - try: url = CONFPARSER.get("jss", "server") - except configparser.NoOptionError: - print("Can't find url in configfile") - try: smart_group = CONFPARSER.get("verifyEA", "smart_group") except configparser.NoOptionError: - print("Can't find smart_group in configfile") - + print("Can't find configs in configfile") + pass + except configparser.NoSectionError: + print("Can't find sections in configfile") + pass else: url = "https://your.jss.com" username = getpass.getuser() @@ -93,6 +87,7 @@ def build_computers_data_object(token, group_id): r = requests.get( url + "/JSSResource/computergroups/id/{0}".format(group_id), headers={"Content-Type": "application/xml", "Authorization": "Bearer " + token}, + timeout=5 ) tree = eTree.fromstring(r.content) @@ -104,6 +99,7 @@ def build_computers_data_object(token, group_id): r = requests.get( url + "/JSSResource/computers/id/{0}".format(resource_id), headers={"Content-Type": "application/json", "Authorization": "Bearer " + token}, + timeout=5 ) # Parse xml diff --git a/tools/download.py b/tools/download.py index 3d90c18..02ef580 100755 --- a/tools/download.py +++ b/tools/download.py @@ -168,7 +168,7 @@ def download_scripts( tree.remove(tree.find("id")) tree.remove(tree.find("script_contents_encoded")) tree.remove(tree.find("filename")) - except: + except TypeError: pass xmlstr = minidom.parseString( From 1306f489842a8edff21ba66bbdd1e39ef81b08be Mon Sep 17 00:00:00 2001 From: Rusty Myers Date: Wed, 1 Nov 2023 13:17:42 -0400 Subject: [PATCH 27/44] Update verifyEA.py Fixing extra pass and if statement comparing to True. Adding file_path variable to use with functions --- tools/ci_tests/verifyEA.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tools/ci_tests/verifyEA.py b/tools/ci_tests/verifyEA.py index 1dcd01c..76980ca 100755 --- a/tools/ci_tests/verifyEA.py +++ b/tools/ci_tests/verifyEA.py @@ -34,7 +34,6 @@ smart_group = CONFPARSER.get("verifyEA", "smart_group") except configparser.NoOptionError: print("Can't find configs in configfile") - pass except configparser.NoSectionError: print("Can't find sections in configfile") pass @@ -64,15 +63,15 @@ def invalidate_uapi_token(uapi_token): _ = requests.post(url=jamf_test_url, headers=headers, timeout=5) -def overwrite_file(): +def overwrite_file(file_path): print("Overwriting File: computers.json...") - with open("computers.json", "w") as f: + with open(file_path, "w") as f: f.write(json.dumps(computers)) -def read_file(): +def read_file(file_path): print("Reading cached data from disk...") - with open("computers.json", "r") as f: + with open(file_path, "r") as f: computers_from_disk = json.load(f) return computers_from_disk @@ -139,8 +138,9 @@ def compare_computer(computer_id): # Is this the first time it was run? mypath = os.path.dirname(os.path.realpath(__file__)) -if os.path.exists(os.path.join(mypath, "computers.json")): - computers_from_disk = read_file() +myfile = os.path.join(mypath, "computers.json") +if os.path.exists(myfile): + computers_from_disk = read_file(myfile) else: print("No cached data found, writing new data to computers.json") overwrite = True @@ -151,8 +151,8 @@ def compare_computer(computer_id): computers = build_computers_data_object(token, smart_group) # Overwrite local file? -if overwrite == True: - overwrite_file() +if overwrite: + overwrite_file(myfile) print("Computer data staged for comparison with future runs.") else: # Compare each computer From 06d19b9f166adbd5b3467716c2eef1296b1d10e5 Mon Sep 17 00:00:00 2001 From: Rusty Myers Date: Wed, 1 Nov 2023 13:24:21 -0400 Subject: [PATCH 28/44] Update validate xml to work on folders except exclude. Removing pass from except --- tools/ci_tests/validatexml.sh | 4 ++-- tools/ci_tests/verifyEA.py | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/tools/ci_tests/validatexml.sh b/tools/ci_tests/validatexml.sh index c17f525..78f203b 100755 --- a/tools/ci_tests/validatexml.sh +++ b/tools/ci_tests/validatexml.sh @@ -9,7 +9,7 @@ function scripts() { printf "\033[31m Working on Scripts\n" printf "\033[31m---------------------------------------------------------------------------------\n" printf "\033[0m" -scriptfolders=$(ls -1 ./scripts | awk 'NR>1') +scriptfolders=$(ls -1 ./scripts | grep -v templates) while read folder ; do echo "$folder" xmllint --noout ./scripts/"$folder"/*.xml @@ -20,7 +20,7 @@ scriptfolders=$(ls -1 ./scripts | awk 'NR>1') function ea(){ - eafolders=$(ls -1 ./extension_attributes | awk 'NR>1') + eafolders=$(ls -1 ./extension_attributes | grep -v templates) printf "\033[31m---------------------------------------------------------------------------------\n" printf "\033[31m Working on Extension Attributes\n" diff --git a/tools/ci_tests/verifyEA.py b/tools/ci_tests/verifyEA.py index 76980ca..43825f9 100755 --- a/tools/ci_tests/verifyEA.py +++ b/tools/ci_tests/verifyEA.py @@ -36,7 +36,6 @@ print("Can't find configs in configfile") except configparser.NoSectionError: print("Can't find sections in configfile") - pass else: url = "https://your.jss.com" username = getpass.getuser() From baa6ea827aa84e8c3f3f6f744ec4d998b3f131a8 Mon Sep 17 00:00:00 2001 From: Rusty Myers Date: Fri, 14 Aug 2026 15:27:57 -0400 Subject: [PATCH 29/44] Fix config style --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 52a7023..ef5b3bc 100644 --- a/README.md +++ b/README.md @@ -33,9 +33,9 @@ A config file can be created in the project root or the users home folder. When A jamfapi.cfg file can provide the following variables: -- username -- password -- url + - username + - password + - url ### Prerequisites git2jss requires [Python 3.6](https://www.python.org/downloads/) and the python modules listed in `requirements.txt` From e7b96c0e0c1ef4fb1032a9cf6e53c281c02ce8b2 Mon Sep 17 00:00:00 2001 From: Rusty Myers Date: Fri, 14 Aug 2026 15:35:44 -0400 Subject: [PATCH 30/44] Update README.md --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ef5b3bc..f3fa861 100644 --- a/README.md +++ b/README.md @@ -33,9 +33,9 @@ A config file can be created in the project root or the users home folder. When A jamfapi.cfg file can provide the following variables: - - username - - password - - url +- username +- password +- url ### Prerequisites git2jss requires [Python 3.6](https://www.python.org/downloads/) and the python modules listed in `requirements.txt` From a0c71c976e3c9941d688a16f7121307bd661932d Mon Sep 17 00:00:00 2001 From: Rusty Myers Date: Sat, 15 Aug 2026 16:29:38 -0400 Subject: [PATCH 31/44] Update verifyEA.py text formatting --- tools/ci_tests/verifyEA.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tools/ci_tests/verifyEA.py b/tools/ci_tests/verifyEA.py index 43825f9..473f5a5 100755 --- a/tools/ci_tests/verifyEA.py +++ b/tools/ci_tests/verifyEA.py @@ -45,7 +45,7 @@ def get_uapi_token(): """ - fetches api token + Fetches api token. """ jamf_test_url = url + "/api/v1/auth/token" response = requests.post(url=jamf_test_url, auth=(username, password), timeout=5) @@ -55,7 +55,7 @@ def get_uapi_token(): def invalidate_uapi_token(uapi_token): """ - invalidates api token + Invalidates api token. """ jamf_test_url = url + "/api/v1/auth/invalidate-token" headers = {"Accept": "*/*", "Authorization": "Bearer " + uapi_token} @@ -76,7 +76,8 @@ def read_file(file_path): def build_computers_data_object(token, group_id): - """Builds computer data into local file + """ + Builds computer data into local file. params: token, group_id returns: computers objects json """ @@ -119,7 +120,8 @@ def build_computers_data_object(token, group_id): def compare_computer(computer_id): - """Compares a computer id record from live to cached copy on disk + """ + Compares a computer id record from live to cached copy on disk. params: computer_id returns: None """ From 8089943db8895ab8fb86420631266f903b9c2ec3 Mon Sep 17 00:00:00 2001 From: Rusty Myers Date: Sat, 15 Aug 2026 16:33:47 -0400 Subject: [PATCH 32/44] Additional formatting changes --- CODE_OF_CONDUCT.md | 20 ++++++++++---------- README.md | 32 ++++++++++++++++---------------- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index e45ba81..56e4c75 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -8,19 +8,19 @@ In the interest of fostering an open and welcoming environment, we as contributo Examples of behavior that contributes to creating a positive environment include: -* Using welcoming and inclusive language -* Being respectful of differing viewpoints and experiences -* Gracefully accepting constructive criticism -* Focusing on what is best for the community -* Showing empathy towards other community members +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members Examples of unacceptable behavior by participants include: -* The use of sexualized language or imagery and unwelcome sexual attention or advances -* Trolling, insulting/derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or electronic address, without explicit permission -* Other conduct which could reasonably be considered inappropriate in a professional setting +* The use of sexualized language or imagery and unwelcome sexual attention or advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a professional setting ## Our Responsibilities diff --git a/README.md b/README.md index f3fa861..f479f50 100644 --- a/README.md +++ b/README.md @@ -4,28 +4,28 @@ A fast asynchronous python library for syncing your scripts in git with your JSS easily. This allows admins to keep their script in a version control system for easy updating rather than googling and copy-pasting from resources that they find online. ## Getting Started -1. Fork the Project -2. Install [Python version 3.6](https://www.python.org/downloads/) or higher. (this is because of the async requirements) -3. Run `python3.6 -m pip install -r requirements.txt` to install required modules -4. Run `./tools/download.py --url https://your.jss.url:8443 --username api_user` to download all scripts and extension attributes to the repository -5. Run `./sync.py --url https://your.jss.url:8443 --username api_user` to sync all scripts back to your JSS +1. Fork the Project +2. Install [Python version 3.6](https://www.python.org/downloads/) or higher. (this is because of the async requirements) +3. Run `python3.6 -m pip install -r requirements.txt` to install required modules +4. Run `./tools/download.py --url https://your.jss.url:8443 --username api_user` to download all scripts and extension attributes to the repository +5. Run `./sync.py --url https://your.jss.url:8443 --username api_user` to sync all scripts back to your JSS Optional flags for `download.py`: -- `--password` for CI/CD (Will prompt for password if not set) -- `--do_not_verify_ssl` to skip ssl verification -- `--overwrite` to overwrite all scripts and extension attributes +- `--password` for CI/CD (Will prompt for password if not set) +- `--do_not_verify_ssl` to skip ssl verification +- `--overwrite` to overwrite all scripts and extension attributes Optional flags for `sync.py`: -- `--password` for CI/CD (Will prompt for password if not set) -- `--do_not_verify_ssl` to skip ssl verification -- `--overwrite` to overwrite all scripts and extension attributes -- `--limit` to limit max connections (default=25) -- `--timeout` to limit max connections (default=60) -- `--verbose` to add additional logging -- `--update_all` to upload all resources in `./extension_attributes` and `./scripts` -- `--jenkins` to write a Jenkins file:`jenkins.properties` with `$scripts` and `$eas` and compare `$GIT_PREVIOUS_COMMIT` with `$GIT_COMMIT` +- `--password` for CI/CD (Will prompt for password if not set) +- `--do_not_verify_ssl` to skip ssl verification +- `--overwrite` to overwrite all scripts and extension attributes +- `--limit` to limit max connections (default=25) +- `--timeout` to limit max connections (default=60) +- `--verbose` to add additional logging +- `--update_all` to upload all resources in `./extension_attributes` and `./scripts` +- `--jenkins` to write a Jenkins file:`jenkins.properties` with `$scripts` and `$eas` and compare `$GIT_PREVIOUS_COMMIT` with `$GIT_COMMIT` ### [ConfigParser](https://docs.python.org/3/library/configparser.html) (Optional): From 930a1474a61c9e6750af9527041e6c6d3f714f30 Mon Sep 17 00:00:00 2001 From: Rusty Myers Date: Sat, 29 Aug 2026 22:18:17 -0400 Subject: [PATCH 33/44] refactor main --- sync.py | 411 ++++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 312 insertions(+), 99 deletions(-) diff --git a/sync.py b/sync.py index 7005c8d..6bee184 100755 --- a/sync.py +++ b/sync.py @@ -32,25 +32,208 @@ CATEGORIES = [] -# https://github.com/lazymutt/Jamf-Pro-API-Sampler/blob/5f8efa92911271248f527e70bd682db79bc600f2/jamf_duplicate_detection.py#L99 -def get_uapi_token(): - """ - fetches api token - """ - jamf_test_url = url + "/api/v1/auth/token" - response = requests.post(url=jamf_test_url, auth=(username, password), timeout=5) +def get_uapi_token(jamf_url, username, password): + """Request a Jamf Pro bearer token.""" + token_url = f"{jamf_url}/api/v1/auth/token" + + response = requests.post( + url=token_url, + auth=(username, password), + timeout=10, + ) + response.raise_for_status() + response_json = response.json() return response_json["token"] -def invalidate_uapi_token(uapi_token): +def invalidate_uapi_token(jamf_url, uapi_token): + """Invalidate a Jamf Pro bearer token.""" + invalidate_url = f"{jamf_url}/api/v1/auth/invalidate-token" + headers = { + "Accept": "*/*", + "Authorization": f"Bearer {uapi_token}", + } + + response = requests.post( + url=invalidate_url, + headers=headers, + timeout=10, + ) + + if response.status_code not in (200, 204): + LOG.warning( + "Unable to invalidate Jamf token. HTTP status: %s", + response.status_code, + ) + +def parse_arguments(): + """Parse command-line arguments.""" + parser = argparse.ArgumentParser(description="Sync repository with Jamf Pro") + + parser.add_argument("--url") + parser.add_argument("--username") + parser.add_argument("--password") + parser.add_argument("--sync_path") + parser.add_argument("--limit", type=int, default=25) + parser.add_argument("--timeout", type=int, default=60) + parser.add_argument("--verbose", action="store_true") + parser.add_argument( + "--do_not_verify_ssl", + action="store_true", + help="Disable SSL certificate verification", + ) + parser.add_argument("--update_all", action="store_true") + parser.add_argument("--jenkins", action="store_true") + + return parser.parse_args() + + +def find_config_file(): + """Return the first available Jamf API configuration file.""" + config_locations = ( + "jamfapi.cfg", + os.path.expanduser("~/jamfapi.cfg"), + ) + + for config_path in config_locations: + if os.path.isfile(config_path): + LOG.info("Found configuration file: %s", config_path) + return config_path + + return None + + +def read_config_file(config_path): + """Read Jamf settings from a configuration file.""" + settings = { + "username": None, + "password": None, + "url": None, + "sync_path": None, + } + + if not config_path: + return settings + + config = configparser.ConfigParser() + config.read(config_path) + + if not config.has_section("jss"): + LOG.warning( + "Configuration file %s does not contain a [jss] section", + config_path, + ) + return settings + + settings["username"] = config.get("jss", "username", fallback=None) + settings["password"] = config.get("jss", "password", fallback=None) + settings["url"] = config.get("jss", "server", fallback=None) + settings["sync_path"] = config.get("jss", "sync_path", fallback=None) + + return settings + + +def first_value(*values): + """Return the first value that is not None or empty.""" + for value in values: + if value is not None and value != "": + return value + + return None + + +def resolve_settings(parsed_args): """ - invalidates api token + Resolve settings using this precedence: + + 1. Command-line arguments + 2. Environment variables + 3. jamfapi.cfg + 4. Built-in defaults """ - jamf_test_url = url + "/api/v1/auth/invalidate-token" - headers = {"Accept": "*/*", "Authorization": "Bearer " + uapi_token} - _ = requests.post(url=jamf_test_url, headers=headers, timeout=5) + config_path = find_config_file() + config = read_config_file(config_path) + + settings = { + "username": first_value( + parsed_args.username, + os.getenv("JAMF_API_USER"), + config["username"], + ), + "password": first_value( + parsed_args.password, + os.getenv("JAMF_API_PASS"), + config["password"], + ), + "url": first_value( + parsed_args.url, + os.getenv("MDM_URL"), + config["url"], + ), + "sync_path": first_value( + parsed_args.sync_path, + config["sync_path"], + dirname(realpath(__file__)), + ), + } + + if settings["url"]: + settings["url"] = settings["url"].rstrip("/") + if not settings["password"]: + settings["password"] = getpass.getpass( + f"Password for {settings['username'] or 'Jamf API user'}: " + ) + + validate_settings(settings) + return settings + + +def validate_settings(settings): + """Validate required settings and repository directories.""" + missing_settings = [ + setting_name + for setting_name in ("url", "username", "password") + if not settings.get(setting_name) + ] + + if missing_settings: + missing = ", ".join(missing_settings) + raise ValueError(f"Missing required Jamf settings: {missing}") + + sync_directory = settings["sync_path"] + + if not os.path.isdir(sync_directory): + raise ValueError( + f"Sync path does not exist or is not a directory: {sync_directory}" + ) + + required_directories = ( + "scripts", + "extension_attributes", + "templates", + ) + + missing_directories = [ + directory + for directory in required_directories + if not os.path.isdir(join(sync_directory, directory)) + ] + + if missing_directories: + missing = ", ".join(missing_directories) + raise ValueError( + f"Sync path is missing required directories: {missing}" + ) + + +def configure_debugging(parsed_args): + """Enable additional asyncio and resource debugging.""" + if not parsed_args.verbose: + return + + warnings.simplefilter("always", ResourceWarning) def check_for_changes(): """Looks for files that were changed between the current commit and @@ -415,105 +598,135 @@ async def get_existing_categories(session, url, user, passwd, semaphore): return [] -async def main(): - # pylint: disable=global-statement +async def async_main( + jamf_url, + username, + password, + bearer_token, + parsed_args, +): + """Run the Jamf synchronization tasks.""" global CATEGORIES - semaphore = asyncio.BoundedSemaphore(args.limit) - async with aiohttp.ClientSession() as session: - async with aiohttp.ClientSession( - connector=aiohttp.TCPConnector(ssl=args.do_not_verify_ssl) - ) as session: - CATEGORIES = await get_existing_categories( - session, url, username, password, semaphore - ) - await upload_scripts(session, url, username, password, semaphore) - await upload_extension_attributes( - session, url, username, password, semaphore - ) + semaphore = asyncio.BoundedSemaphore(parsed_args.limit) -if __name__ == "__main__": - asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) + headers = { + "Accept": "application/xml", + "Content-Type": "application/xml", + "Authorization": f"Bearer {bearer_token}", + } - # Export to current directory by default - sync_path = dirname(realpath(__file__)) + connector = aiohttp.TCPConnector( + ssl=False if parsed_args.do_not_verify_ssl else None + ) + + timeout = aiohttp.ClientTimeout(total=parsed_args.timeout) + + async with aiohttp.ClientSession( + connector=connector, + timeout=timeout, + headers=headers, + ) as session: + CATEGORIES = await get_existing_categories( + session, + jamf_url, + username, + password, + semaphore, + ) - parser = argparse.ArgumentParser(description="Sync repo with JamfPro") - parser.add_argument("--url") - parser.add_argument("--username") - parser.add_argument("--password") - parser.add_argument("--sync_path") - parser.add_argument("--limit", type=int, default=25) - parser.add_argument("--timeout", type=int, default=60) - parser.add_argument("--verbose", action="store_true") - parser.add_argument("--do_not_verify_ssl", action="store_false") - parser.add_argument("--update_all", action="store_true") - parser.add_argument("--jenkins", action="store_true") - args = parser.parse_args() + LOG.debug("Found %d Jamf categories", len(CATEGORIES)) + + await asyncio.gather( + upload_scripts( + session, + jamf_url, + username, + password, + semaphore, + ), + upload_extension_attributes( + session, + jamf_url, + username, + password, + semaphore, + ), + ) + +def run(): + """Initialize configuration and run the synchronization.""" + global args + global changed_ext_attrs + global changed_scripts + global sync_path + global username + global password + global url + global token + + args = parse_arguments() + configure_debugging(args) + + settings = resolve_settings(args) + + username = settings["username"] + password = settings["password"] + url = settings["url"] + sync_path = settings["sync_path"] changed_ext_attrs = [] changed_scripts = [] + check_for_changes() - print("Changed Extension Attributes: ", changed_ext_attrs) - print("Changed Scripts: ", changed_scripts) + + LOG.info( + "Changed Extension Attributes: %s", + changed_ext_attrs or "None", + ) + LOG.info( + "Changed Scripts: %s", + changed_scripts or "None", + ) if args.jenkins: write_jenkins_file() - # Set configs file locations - CONFIG_FILE_LOCATIONS = ["jamfapi.cfg", os.path.expanduser("~/jamfapi.cfg")] - CONFIG_FILE = "" - # Parse Config File - CONFPARSER = configparser.ConfigParser() - for config_path in CONFIG_FILE_LOCATIONS: - if os.path.exists(config_path): - print("Found Config: {0}".format(config_path)) - CONFIG_FILE = config_path - - if CONFIG_FILE != "": - # Get config - CONFPARSER.read(CONFIG_FILE) - try: - username = CONFPARSER.get("jss", "username") - except configparser.NoOptionError: - print("Can't find username in configfile") - try: - password = CONFPARSER.get("jss", "password") - except configparser.NoOptionError: - print("Can't find password in configfile") - try: - url = CONFPARSER.get("jss", "server") - except configparser.NoOptionError: - print("Can't find url in configfile") - try: - sync_path = CONFPARSER.get("jss", "sync_path") - except configparser.NoOptionError: - print("Can't find sync_path in config") - - # Ask for password if not supplied via command line args - if args.password: - password = args.password - elif password is None: - password = getpass.getpass() - - if args.sync_path: - sync_path = args.sync_path - - if args.url: - url = args.url - - if args.username: - username = args.username - - token = get_uapi_token() - - loop = asyncio.get_event_loop() - if args.verbose: - loop.set_debug(True) - loop.slow_callback_duration = 0.001 - warnings.simplefilter("always", ResourceWarning) + token = None - loop.run_until_complete(main()) + try: + token = get_uapi_token( + jamf_url=url, + username=username, + password=password, + ) - # Remove token - invalidate_uapi_token(token) + asyncio.run( + async_main( + jamf_url=url, + username=username, + password=password, + bearer_token=token, + parsed_args=args, + ), + debug=args.verbose, + ) + finally: + if token: + invalidate_uapi_token(url, token) + + +if __name__ == "__main__": + uvloop.install() + + try: + run() + except KeyboardInterrupt: + LOG.warning("Synchronization interrupted by user") + sys.exit(130) + except (ValueError, requests.RequestException) as error: + LOG.error("%s", error) + sys.exit(1) + except Exception: + LOG.exception("Unexpected synchronization failure") + sys.exit(1) \ No newline at end of file From 3a85cbc94139295291d87492c2c091ee09789618 Mon Sep 17 00:00:00 2001 From: Rusty Myers Date: Sun, 30 Aug 2026 13:29:58 -0400 Subject: [PATCH 34/44] Create JamfSync Class and pass variables --- sync.py | 1125 +++++++++++++++++++++++++------------------------------ 1 file changed, 518 insertions(+), 607 deletions(-) diff --git a/sync.py b/sync.py index 6bee184..297e3d7 100755 --- a/sync.py +++ b/sync.py @@ -1,76 +1,413 @@ #!/usr/bin/env python3 # pylint: disable=missing-docstring,invalid-name -import warnings -import os -from os.path import dirname, join, realpath -import sys -import getpass + +"""Synchronize Jamf Pro scripts and computer extension attributes from a repository.""" + +from __future__ import annotations + import argparse -import logging import asyncio -import async_timeout -import aiohttp -import uvloop import configparser +import getpass +import logging +import os +import subprocess +import sys +import warnings +from dataclasses import dataclass, field +from pathlib import Path +from typing import Sequence +from urllib.parse import quote + +import aiohttp import requests from defusedxml import ElementTree as eTree +try: + import uvloop +except ImportError: + uvloop = None + + logging.basicConfig( level=logging.DEBUG, format="%(levelname)7s: %(message)s", stream=sys.stderr, ) -LOG = logging.getLogger("") +LOG = logging.getLogger(__name__) -# The Jenkins file will contain a list of changes scripts and eas -# in $scripts and $eas. -# Use this variable to add a Slack emoji in front of each item if -# you use a post-build action for a Slack custom message SLACK_EMOJI = ":white_check_mark: " -SUPPORTED_SCRIPT_EXTENSIONS = ("sh", "py", "pl", "swift", "rb") -SUPPORTED_EA_EXTENSIONS = ("sh", "py", "pl", "swift", "rb") -CATEGORIES = [] +SUPPORTED_SCRIPT_EXTENSIONS = {"sh", "py", "pl", "swift", "rb"} +SUPPORTED_EA_EXTENSIONS = {"sh", "py", "pl", "swift", "rb"} +SUCCESS_STATUSES = {200, 201} -def get_uapi_token(jamf_url, username, password): - """Request a Jamf Pro bearer token.""" - token_url = f"{jamf_url}/api/v1/auth/token" +@dataclass(frozen=True) +class AppSettings: + """Resolved application settings.""" - response = requests.post( - url=token_url, - auth=(username, password), - timeout=10, - ) - response.raise_for_status() + url: str + username: str + password: str + sync_path: Path - response_json = response.json() - return response_json["token"] +@dataclass +class RuntimeContext: + """Mutable state shared by one synchronization run.""" -def invalidate_uapi_token(jamf_url, uapi_token): - """Invalidate a Jamf Pro bearer token.""" - invalidate_url = f"{jamf_url}/api/v1/auth/invalidate-token" - headers = { - "Accept": "*/*", - "Authorization": f"Bearer {uapi_token}", - } + args: argparse.Namespace + settings: AppSettings + token: str = "" + changed_scripts: list[str] = field(default_factory=list) + changed_ext_attrs: list[str] = field(default_factory=list) + categories: set[str] = field(default_factory=set) - response = requests.post( - url=invalidate_url, - headers=headers, - timeout=10, - ) + @property + def url(self) -> str: + return self.settings.url + + @property + def username(self) -> str: + return self.settings.username + + @property + def password(self) -> str: + return self.settings.password + + @property + def sync_path(self) -> Path: + return self.settings.sync_path + + +class JamfSync: + """Manage asynchronous Jamf Classic API synchronization operations.""" + + def __init__(self, context: RuntimeContext): + self.ctx = context + self.session: aiohttp.ClientSession | None = None + self.semaphore = asyncio.BoundedSemaphore(context.args.limit) + + @property + def headers(self) -> dict[str, str]: + return { + "Accept": "application/xml", + "Content-Type": "application/xml", + "Authorization": f"Bearer {self.ctx.token}", + } + + async def run(self) -> None: + """Create the HTTP session and run all synchronization operations.""" + connector = aiohttp.TCPConnector( + ssl=False if self.ctx.args.do_not_verify_ssl else None + ) + timeout = aiohttp.ClientTimeout(total=self.ctx.args.timeout) + + async with aiohttp.ClientSession( + connector=connector, + timeout=timeout, + headers=self.headers, + ) as session: + self.session = session + self.ctx.categories = await self.get_existing_categories() + LOG.debug("Found %d Jamf categories", len(self.ctx.categories)) + + script_results, ea_results = await asyncio.gather( + self.upload_scripts(), + self.upload_extension_attributes(), + ) + + failures = [ + *[name for name, status in script_results if status not in SUCCESS_STATUSES], + *[name for name, status in ea_results if status not in SUCCESS_STATUSES], + ] + if failures: + raise RuntimeError( + "One or more Jamf objects failed to upload: " + ", ".join(failures) + ) + + def require_session(self) -> aiohttp.ClientSession: + if self.session is None: + raise RuntimeError("JamfSync session has not been initialized") + return self.session + + async def request(self, method: str, endpoint: str, **kwargs) -> tuple[int, str]: + """Perform one concurrency-limited Jamf request and return status/body.""" + session = self.require_session() + url = f"{self.ctx.url}{endpoint}" + + async with self.semaphore: + async with session.request(method, url, **kwargs) as response: + body = await response.text() + if self.ctx.args.verbose: + LOG.debug("%s %s returned HTTP %s", method, endpoint, response.status) + return response.status, body + + async def get_existing_categories(self) -> set[str]: + status, body = await self.request("GET", "/JSSResource/categories") + if status not in SUCCESS_STATUSES: + LOG.warning("Unable to retrieve Jamf categories: HTTP %s", status) + return set() + + root = eTree.fromstring(body) + return { + category.text + for category in root.findall("category/name") + if category.text + } + + async def upload_scripts(self) -> list[tuple[str, int]]: + scripts = self._selected_directories( + root=self.ctx.sync_path / "scripts", + changed_names=self.ctx.changed_scripts, + object_label="scripts", + ) + + if not scripts: + LOG.info("No scripts selected for upload") + return [] + + results = await asyncio.gather( + *(self.upload_script(script_name) for script_name in scripts) + ) + return list(zip(scripts, results)) + + async def upload_script(self, script_name: str) -> int: + script_dir = self.ctx.sync_path / "scripts" / script_name + script_file = self._first_file_with_extensions( + script_dir, + SUPPORTED_SCRIPT_EXTENSIONS, + ) + + if script_file is None: + LOG.warning("No script file found in scripts/%s", script_name) + return 0 + + script_contents = script_file.read_text(encoding="utf-8") + template = await self.get_script_template(script_name) + name = self._ensure_name(template, script_name) + + contents_element = template.find("script_contents") + if contents_element is None: + contents_element = eTree.SubElement(template, "script_contents") + contents_element.text = script_contents + + status = await self._create_or_update( + resource="scripts", + name=name, + template=template, + ) + self._log_upload_result("script", name, status) + return status + + async def get_script_template(self, script_name: str): + object_dir = self.ctx.sync_path / "scripts" / script_name + template = await self._load_local_or_remote_template( + object_dir=object_dir, + fallback_path=self.ctx.sync_path / "templates" / "script.xml", + resource="scripts", + lookup_name=script_name, + ) + + self._normalize_category(template, add_none=False) + self._ensure_name(template, script_name) + self._log_xml(template) + return template + + async def upload_extension_attributes(self) -> list[tuple[str, int]]: + extension_attributes = self._selected_directories( + root=self.ctx.sync_path / "extension_attributes", + changed_names=self.ctx.changed_ext_attrs, + object_label="extension attributes", + ) + + if not extension_attributes: + LOG.info("No extension attributes selected for upload") + return [] + + results = await asyncio.gather( + *( + self.upload_extension_attribute(ext_attr) + for ext_attr in extension_attributes + ) + ) + return list(zip(extension_attributes, results)) + + async def upload_extension_attribute(self, ext_attr: str) -> int: + extension_attribute_dir = ( + self.ctx.sync_path / "extension_attributes" / ext_attr + ) + script_file = self._first_file_with_extensions( + extension_attribute_dir, + SUPPORTED_EA_EXTENSIONS, + ) + + if script_file is None: + LOG.warning( + "No script file found in extension_attributes/%s; " + "uploading the XML template without a script", + ext_attr, + ) + script_contents = None + else: + script_contents = script_file.read_text(encoding="utf-8") + + template = await self.get_ea_template(ext_attr) + name = self._ensure_name(template, ext_attr) + + if script_contents is not None: + script_element = template.find("input_type/script") + if script_element is None: + input_type = template.find("input_type") + if input_type is None: + input_type = eTree.SubElement(template, "input_type") + script_element = eTree.SubElement(input_type, "script") + script_element.text = script_contents + + status = await self._create_or_update( + resource="computerextensionattributes", + name=name, + template=template, + ) + self._log_upload_result("extension attribute", name, status) + return status + + async def get_ea_template(self, ext_attr: str): + object_dir = self.ctx.sync_path / "extension_attributes" / ext_attr + template = await self._load_local_or_remote_template( + object_dir=object_dir, + fallback_path=self.ctx.sync_path / "templates" / "ea.xml", + resource="computerextensionattributes", + lookup_name=ext_attr, + ) + + self._normalize_category(template, add_none=True) + self._ensure_name(template, ext_attr) + self._log_xml(template) + return template + + async def _load_local_or_remote_template( + self, + object_dir: Path, + fallback_path: Path, + resource: str, + lookup_name: str, + ): + xml_files = sorted( + path for path in object_dir.iterdir() if path.is_file() and path.suffix.lower() == ".xml" + ) + + if xml_files: + return eTree.parse(str(xml_files[0])).getroot() + + endpoint = f"/JSSResource/{resource}/name/{quote(lookup_name, safe='')}" + status, body = await self.request("GET", endpoint) + + if status == 200: + return eTree.fromstring(body) + + if not fallback_path.is_file(): + raise FileNotFoundError(f"Missing fallback template: {fallback_path}") + + return eTree.parse(str(fallback_path)).getroot() + + async def _create_or_update(self, resource: str, name: str, template) -> int: + encoded_name = quote(name, safe="") + lookup_endpoint = f"/JSSResource/{resource}/name/{encoded_name}" + lookup_status, _ = await self.request("GET", lookup_endpoint) + payload = eTree.tostring(template, encoding="utf-8") + + if lookup_status == 200: + status, body = await self.request("PUT", lookup_endpoint, data=payload) + elif lookup_status == 404: + create_endpoint = f"/JSSResource/{resource}/id/0" + status, body = await self.request("POST", create_endpoint, data=payload) + else: + LOG.error( + "Unable to determine whether %s '%s' exists: HTTP %s", + resource, + name, + lookup_status, + ) + return lookup_status + + if status not in SUCCESS_STATUSES and body: + LOG.error("Jamf response for %s '%s': %s", resource, name, body) + return status + + def _selected_directories( + self, + root: Path, + changed_names: Sequence[str], + object_label: str, + ) -> list[str]: + if self.ctx.args.update_all: + LOG.info("Copying all %s", object_label) + return sorted(path.name for path in root.iterdir() if path.is_dir()) + + if not changed_names: + return [] + + changed_set = set(changed_names) + return sorted( + path.name + for path in root.iterdir() + if path.is_dir() and path.name in changed_set + ) + + @staticmethod + def _first_file_with_extensions( + directory: Path, + extensions: set[str], + ) -> Path | None: + matches = sorted( + path + for path in directory.iterdir() + if path.is_file() and path.suffix.lower().lstrip(".") in extensions + ) + return matches[0] if matches else None + + @staticmethod + def _ensure_name(template, fallback_name: str) -> str: + name_element = template.find("name") + if name_element is None: + name_element = eTree.SubElement(template, "name") + if not name_element.text: + name_element.text = fallback_name + return name_element.text + + def _normalize_category(self, template, add_none: bool) -> None: + category = template.find("category") + if category is None or not category.text: + return + if category.text in self.ctx.categories: + return + + invalid_category = category.text + template.remove(category) + if add_none: + eTree.SubElement(template, "category").text = "None" - if response.status_code not in (200, 204): LOG.warning( - "Unable to invalidate Jamf token. HTTP status: %s", - response.status_code, + 'Category "%s" does not exist in Jamf; using %s', + invalid_category, + '"None"' if add_none else "no category", ) -def parse_arguments(): - """Parse command-line arguments.""" - parser = argparse.ArgumentParser(description="Sync repository with Jamf Pro") + def _log_xml(self, template) -> None: + if self.ctx.args.verbose: + LOG.debug("Template XML: %s", eTree.tostring(template, encoding="unicode")) + + @staticmethod + def _log_upload_result(object_type: str, name: str, status: int) -> None: + if status in SUCCESS_STATUSES: + LOG.info("Uploaded %s: %s", object_type, name) + else: + LOG.error("Error uploading %s '%s': HTTP %s", object_type, name, status) + +def parse_arguments() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Sync repository with Jamf Pro") parser.add_argument("--url") parser.add_argument("--username") parser.add_argument("--password") @@ -85,648 +422,222 @@ def parse_arguments(): ) parser.add_argument("--update_all", action="store_true") parser.add_argument("--jenkins", action="store_true") - return parser.parse_args() -def find_config_file(): - """Return the first available Jamf API configuration file.""" +def find_config_file() -> Path | None: config_locations = ( - "jamfapi.cfg", - os.path.expanduser("~/jamfapi.cfg"), + Path("jamfapi.cfg"), + Path.home() / "jamfapi.cfg", ) for config_path in config_locations: - if os.path.isfile(config_path): + if config_path.is_file(): LOG.info("Found configuration file: %s", config_path) return config_path - return None -def read_config_file(config_path): - """Read Jamf settings from a configuration file.""" - settings = { +def read_config_file(config_path: Path | None) -> dict[str, str | None]: + settings: dict[str, str | None] = { "username": None, "password": None, "url": None, "sync_path": None, } - - if not config_path: + if config_path is None: return settings config = configparser.ConfigParser() config.read(config_path) - if not config.has_section("jss"): - LOG.warning( - "Configuration file %s does not contain a [jss] section", - config_path, - ) + LOG.warning("Configuration file %s has no [jss] section", config_path) return settings settings["username"] = config.get("jss", "username", fallback=None) settings["password"] = config.get("jss", "password", fallback=None) settings["url"] = config.get("jss", "server", fallback=None) settings["sync_path"] = config.get("jss", "sync_path", fallback=None) - return settings def first_value(*values): - """Return the first value that is not None or empty.""" - for value in values: - if value is not None and value != "": - return value - - return None - + return next((value for value in values if value not in (None, "")), None) -def resolve_settings(parsed_args): - """ - Resolve settings using this precedence: - - 1. Command-line arguments - 2. Environment variables - 3. jamfapi.cfg - 4. Built-in defaults - """ - config_path = find_config_file() - config = read_config_file(config_path) - - settings = { - "username": first_value( - parsed_args.username, - os.getenv("JAMF_API_USER"), - config["username"], - ), - "password": first_value( - parsed_args.password, - os.getenv("JAMF_API_PASS"), - config["password"], - ), - "url": first_value( - parsed_args.url, - os.getenv("MDM_URL"), - config["url"], - ), - "sync_path": first_value( - parsed_args.sync_path, - config["sync_path"], - dirname(realpath(__file__)), - ), - } - if settings["url"]: - settings["url"] = settings["url"].rstrip("/") +def resolve_settings(args: argparse.Namespace) -> AppSettings: + config = read_config_file(find_config_file()) - if not settings["password"]: - settings["password"] = getpass.getpass( - f"Password for {settings['username'] or 'Jamf API user'}: " - ) + username = first_value(args.username, os.getenv("JAMF_API_USER"), config["username"]) + password = first_value(args.password, os.getenv("JAMF_API_PASS"), config["password"]) + url = first_value(args.url, os.getenv("MDM_URL"), config["url"]) + sync_path_value = first_value( + args.sync_path, + config["sync_path"], + str(Path(__file__).resolve().parent), + ) + if not username: + raise ValueError("Missing required Jamf setting: username") + if not url: + raise ValueError("Missing required Jamf setting: url") + if not password: + password = getpass.getpass(f"Password for {username}: ") + + settings = AppSettings( + url=str(url).rstrip("/"), + username=str(username), + password=str(password), + sync_path=Path(str(sync_path_value)).expanduser().resolve(), + ) validate_settings(settings) return settings -def validate_settings(settings): - """Validate required settings and repository directories.""" - missing_settings = [ - setting_name - for setting_name in ("url", "username", "password") - if not settings.get(setting_name) - ] - - if missing_settings: - missing = ", ".join(missing_settings) - raise ValueError(f"Missing required Jamf settings: {missing}") - - sync_directory = settings["sync_path"] - - if not os.path.isdir(sync_directory): - raise ValueError( - f"Sync path does not exist or is not a directory: {sync_directory}" - ) +def validate_settings(settings: AppSettings) -> None: + if not settings.sync_path.is_dir(): + raise ValueError(f"Sync path is not a directory: {settings.sync_path}") - required_directories = ( - "scripts", - "extension_attributes", - "templates", - ) - - missing_directories = [ - directory - for directory in required_directories - if not os.path.isdir(join(sync_directory, directory)) + required_directories = ("scripts", "extension_attributes", "templates") + missing = [ + name + for name in required_directories + if not (settings.sync_path / name).is_dir() ] - - if missing_directories: - missing = ", ".join(missing_directories) + if missing: raise ValueError( - f"Sync path is missing required directories: {missing}" + "Sync path is missing required directories: " + ", ".join(missing) ) -def configure_debugging(parsed_args): - """Enable additional asyncio and resource debugging.""" - if not parsed_args.verbose: - return - - warnings.simplefilter("always", ResourceWarning) - -def check_for_changes(): - """Looks for files that were changed between the current commit and - the last commit so we don't upload everything on every run - --jenkins will utilize $GIT_PREVIOUS_COMMIT and $GIT_COMMIT - environmental variables - --update_all can be invoked to upload all scripts and - extension attributes - """ - # This line will work with the environmental variables in Jenkins - if args.jenkins: - git_changes = ( - os.popen("git diff --name-only $GIT_PREVIOUS_COMMIT $GIT_COMMIT") - .read() - .split("\n") - ) - - # Compare the last two commits to determine the list of files that - # were changed - else: - git_commits = ( - os.popen('git log -2 --pretty=oneline --pretty=format:"%h"') - .read() - .split("\n") - ) - command = "git diff --name-only" + " " + git_commits[1] + " " + git_commits[0] - git_changes = os.popen(command).read().split("\n") - - for i in git_changes: - if "extension_attributes/" in i and i.split("/")[1] not in changed_ext_attrs: - changed_ext_attrs.append(i.split("/")[1]) - - for i in git_changes: - if "scripts/" in i and i.split("/")[1] not in changed_scripts: - changed_scripts.append(i.split("/")[1]) - +def get_uapi_token(settings: AppSettings) -> str: + response = requests.post( + f"{settings.url}/api/v1/auth/token", + auth=(settings.username, settings.password), + timeout=10, + ) + response.raise_for_status() + response_json = response.json() + token = response_json.get("token") + if not token: + raise ValueError("Jamf token response did not contain a token") + return token -def write_jenkins_file(): - """Write changed_ext_attrs and changed_scripts to jenkins file. - $eas will contains the changed extension attributes, - $scripts will contains the changed scripts - If there are no changes, the variable will be set to 'None' - """ - if not changed_ext_attrs: - contents = "eas=" + "None" - else: - contents = "eas=" + SLACK_EMOJI + changed_ext_attrs[0] + "\\n" + "\\" - for changed_ext_attr in changed_ext_attrs[1:]: - contents = contents + "\n" + SLACK_EMOJI + changed_ext_attr + "\\n" + "\\" +def invalidate_uapi_token(settings: AppSettings, token: str) -> None: + response = requests.post( + f"{settings.url}/api/v1/auth/invalidate-token", + headers={"Accept": "*/*", "Authorization": f"Bearer {token}"}, + timeout=10, + ) + if response.status_code not in (200, 204): + LOG.warning("Unable to invalidate Jamf token: HTTP %s", response.status_code) - if not changed_scripts: - contents = contents.rstrip("\\") + "\n" + "scripts=" + "None" +def git_changed_files(jenkins: bool) -> list[str]: + if jenkins: + previous_commit = os.getenv("GIT_PREVIOUS_COMMIT") + current_commit = os.getenv("GIT_COMMIT") + if not previous_commit or not current_commit: + raise ValueError( + "Jenkins mode requires GIT_PREVIOUS_COMMIT and GIT_COMMIT" + ) + command = ["git", "diff", "--name-only", previous_commit, current_commit] else: - contents = ( - contents.rstrip("\\") - + "\n" - + "scripts=" - + SLACK_EMOJI - + changed_scripts[0] - + "\\n" - + "\\" - ) - for changed_script in changed_scripts[1:]: - contents = contents + "\n" + SLACK_EMOJI + changed_script + "\\n" + "\\" - - with open("jenkins.properties", "w") as f: - f.write(contents) - - -async def upload_extension_attributes(session, url, user, passwd, semaphore): - # sync_path = dirname(realpath(__file__)) - if not changed_ext_attrs and not args.update_all: - print("No Changes in Extension Attributes") - return - ext_attrs = [ - f.name - for f in os.scandir(join(sync_path, "extension_attributes")) - if f.is_dir() and f.name in changed_ext_attrs - ] - if args.update_all: - print("Copying all extension attributes...") - ext_attrs = [ - f.name - for f in os.scandir(join(sync_path, "extension_attributes")) - if f.is_dir() - ] - tasks = [] - for ea in ext_attrs: - task = asyncio.ensure_future( - upload_extension_attribute(session, url, user, passwd, ea, semaphore) + result = subprocess.run( + ["git", "log", "-2", "--pretty=format:%H"], + check=True, + capture_output=True, + text=True, ) - tasks.append(task) - await asyncio.gather(*tasks) + commits = [line for line in result.stdout.splitlines() if line] + if len(commits) < 2: + LOG.warning("Fewer than two Git commits found; no changed files selected") + return [] + command = ["git", "diff", "--name-only", commits[1], commits[0]] + + result = subprocess.run( + command, + check=True, + capture_output=True, + text=True, + ) + return [line.strip() for line in result.stdout.splitlines() if line.strip()] -async def upload_extension_attribute(session, url, user, passwd, ext_attr, semaphore): - has_script = True +def check_for_changes(ctx: RuntimeContext) -> None: + for changed_path in git_changed_files(ctx.args.jenkins): + parts = Path(changed_path).parts + if len(parts) < 2: + continue - # sync_path = dirname(realpath(__file__)) - # auth = aiohttp.BasicAuth(user, passwd) - headers = { - "Accept": "application/xml", - "Content-Type": "application/xml", - "Authorization": "Bearer " + token, - } - # Get the script files within the folder, we'll only use - # script_file[0] in case there are multiple files - script_file = [ - f.name - for f in os.scandir(join(sync_path, "extension_attributes", ext_attr)) - if f.is_file() and f.name.split(".")[-1] in SUPPORTED_EA_EXTENSIONS - ] - if script_file == []: - print("Warning: No script file found in extension_attributes/%s" % ext_attr) - has_script = False - # return # Need to skip if no script. - if has_script: - with open( - join(sync_path, "extension_attributes", ext_attr, script_file[0]), "r" - ) as f: - data = f.read() - async with semaphore: - with async_timeout.timeout(args.timeout): - template = await get_ea_template(session, url, user, passwd, ext_attr) - async with session.get( - url - + "/JSSResource/computerextensionattributes/name/" - + template.find("name").text, - headers=headers, - ) as resp: - if has_script and data: - template.find("input_type/script").text = data - if args.verbose: - print(eTree.tostring(template)) - print("response status initial get: ", resp.status) - if resp.status == 200: - put_url = ( - url - + "/JSSResource/computerextensionattributes/name/" - + template.find("name").text - ) - resp = await session.put( - put_url, data=eTree.tostring(template), headers=headers - ) - else: - post_url = url + "/JSSResource/computerextensionattributes/id/0" - resp = await session.post( - post_url, data=eTree.tostring(template), headers=headers - ) - if args.verbose: - print("response status: ", resp.status) - print("EA: ", ext_attr) - print("EA Name: ", template.find("name").text) - if resp.status in (201, 200): - print("Uploaded Extension Attribute: %s" % template.find("name").text) - else: - print("Error uploading script: %s" % template.find("name").text) - print("Error: %s" % resp.status) - return resp.status - - -async def get_ea_template(session, url, user, passwd, ext_attr): - # auth = aiohttp.BasicAuth(user, passwd) - # sync_path = dirname(realpath(__file__)) - xml_file = [ - f.name - for f in os.scandir(join(sync_path, "extension_attributes", ext_attr)) - if f.is_file() and f.name.split(".")[-1] in "xml" - ] - try: - with open( - join(sync_path, "extension_attributes", ext_attr, xml_file[0]), "r" - ) as file: - template = eTree.parse(file.read()) - except IndexError: - with async_timeout.timeout(args.timeout): - headers = { - "Accept": "application/xml", - "Content-Type": "application/xml", - "Authorization": "Bearer " + token, - } - - async with session.get( - url + "/JSSResource/computerextensionattributes/name/" + ext_attr, - headers=headers, - ) as resp: - if resp.status == 200: - async with session.get( - url - + "/JSSResource/computerextensionattributes/name/" - + ext_attr, - headers=headers, - ) as response: - template = eTree.fromstring(await response.text()) - else: - template = eTree.parse( - join(sync_path, "templates/ea.xml") - ).getroot() - # name is mandatory, so we use the foldername if nothing is set in - # a template - if args.verbose: - print(eTree.tostring(template)) - if template.find("category") and template.find("category").text not in CATEGORIES: - eTree.SubElement(template, "category").text = "None" - if args.verbose: - c = template.find("category").text - print( - f"""WARNING: Unable to find category {c} in the JSS, - setting to None""" - ) - if template.find("name") is None: - eTree.SubElement(template, "name").text = ext_attr - elif not template.find("name").text or template.find("name").text is None: - template.find("name").text = ext_attr - return template - - -async def upload_scripts(session, url, user, passwd, semaphore): - # sync_path = dirname(realpath(__file__)) - - if not changed_scripts and not args.update_all: - print("No Changes in Scripts") - scripts = [ - f.name - for f in os.scandir(join(sync_path, "scripts")) - if f.is_dir() and f.name in changed_scripts - ] - if args.update_all: - print("Copying all scripts...") - scripts = [f.name for f in os.scandir(join(sync_path, "scripts")) if f.is_dir()] - - tasks = [] - for script in scripts: - task = asyncio.ensure_future( - upload_script(session, url, user, passwd, script, semaphore) - ) - tasks.append(task) - await asyncio.gather(*tasks) + if parts[0] == "extension_attributes": + if parts[1] not in ctx.changed_ext_attrs: + ctx.changed_ext_attrs.append(parts[1]) + elif parts[0] == "scripts": + if parts[1] not in ctx.changed_scripts: + ctx.changed_scripts.append(parts[1]) -async def upload_script(session, url, user, passwd, script, semaphore): - # sync_path = dirname(realpath(__file__)) - # auth = aiohttp.BasicAuth(user, passwd) - headers = { - "Accept": "application/xml", - "Content-Type": "application/xml", - "Authorization": "Bearer " + token, - } - script_file = [ - f.name - for f in os.scandir(join(sync_path, "scripts", script)) - if f.is_file() and f.name.split(".")[-1] in SUPPORTED_SCRIPT_EXTENSIONS - ] - if script_file == []: - print("Warning: No script file found in scripts/%s" % script) - return # Need to skip if no script. - with open(join(sync_path, "scripts", script, script_file[0]), "r") as f: - data = f.read() - async with semaphore: - with async_timeout.timeout(args.timeout): - template = await get_script_template(session, url, user, passwd, script) - async with session.get( - url + "/JSSResource/scripts/name/" + template.find("name").text, - headers=headers, - ) as resp: - template.find("script_contents").text = data - if resp.status == 200: - put_url = ( - url + "/JSSResource/scripts/name/" + template.find("name").text - ) - resp = await session.put( - put_url, data=eTree.tostring(template), headers=headers - ) - else: - post_url = url + "/JSSResource/scripts/id/0" - resp = await session.post( - post_url, data=eTree.tostring(template), headers=headers - ) - if resp.status in (201, 200): - print("Uploaded script: %s" % template.find("name").text) - else: - print("Error uploading script: %s" % template.find("name").text) - print("Error: %s" % resp.status) - return resp.status - - -async def get_script_template(session, url, user, passwd, script): - # auth = aiohttp.BasicAuth(user, passwd) - # sync_path = dirname(realpath(__file__)) - xml_file = [ - f.name - for f in os.scandir(join(sync_path, "scripts", script)) - if f.is_file() and f.name.split(".")[-1] in "xml" - ] - try: - with open(join(sync_path, "scripts", script, xml_file[0]), "r") as file: - template = eTree.fromstring(file.read()) - except IndexError: - with async_timeout.timeout(args.timeout): - headers = { - "Accept": "application/xml", - "Content-Type": "application/xml", - "Authorization": "Bearer " + token, - } - async with session.get( - url + "/JSSResource/scripts/name/" + script, headers=headers - ) as resp: - if resp.status == 200: - async with session.get( - url + "/JSSResource/scripts/name/" + script, headers=headers - ) as response: - template = eTree.fromstring(await response.text()) - else: - template = eTree.parse( - join(sync_path, "templates/script.xml") - ).getroot() - # name is mandatory, so we use the filename if nothing is set in a template - if args.verbose: - print(eTree.tostring(template)) - if ( - template.find("category") is not None - and template.find("category").text not in CATEGORIES - ): - c = template.find("category").text - template.remove(template.find("category")) - if args.verbose: - print( - f"""WARNING: Unable to find category "{c}" in the JSS, - setting to None""" - ) - if template.find("name") is None: - eTree.SubElement(template, "name").text = script - elif not template.find("name").text or template.find("name").text is None: - template.find("name").text = script - return template - - -async def get_existing_categories(session, url, user, passwd, semaphore): - # auth = aiohttp.BasicAuth(user, passwd) - headers = { - "Accept": "application/xml", - "Content-Type": "application/xml", - "Authorization": "Bearer " + token, - } - async with semaphore: - with async_timeout.timeout(args.timeout): - async with session.get( - url + "/JSSResource/categories", headers=headers - ) as resp: - if resp.status in (201, 200): - return [ - c.find("name").text - for c in [ - e - for e in eTree.fromstring(await resp.text()).findall( - "category" - ) - ] - ] - return [] - - -async def async_main( - jamf_url, - username, - password, - bearer_token, - parsed_args, -): - """Run the Jamf synchronization tasks.""" - global CATEGORIES - - semaphore = asyncio.BoundedSemaphore(parsed_args.limit) - - headers = { - "Accept": "application/xml", - "Content-Type": "application/xml", - "Authorization": f"Bearer {bearer_token}", - } +def format_jenkins_value(items: Sequence[str]) -> str: + if not items: + return "None" + return "\\n\\\n".join(f"{SLACK_EMOJI}{item}" for item in items) + "\\n" + - connector = aiohttp.TCPConnector( - ssl=False if parsed_args.do_not_verify_ssl else None +def write_jenkins_file(ctx: RuntimeContext) -> None: + contents = ( + f"eas={format_jenkins_value(ctx.changed_ext_attrs)}\n" + f"scripts={format_jenkins_value(ctx.changed_scripts)}" ) + Path("jenkins.properties").write_text(contents, encoding="utf-8") - timeout = aiohttp.ClientTimeout(total=parsed_args.timeout) - - async with aiohttp.ClientSession( - connector=connector, - timeout=timeout, - headers=headers, - ) as session: - CATEGORIES = await get_existing_categories( - session, - jamf_url, - username, - password, - semaphore, - ) - LOG.debug("Found %d Jamf categories", len(CATEGORIES)) - - await asyncio.gather( - upload_scripts( - session, - jamf_url, - username, - password, - semaphore, - ), - upload_extension_attributes( - session, - jamf_url, - username, - password, - semaphore, - ), - ) +def configure_runtime(args: argparse.Namespace) -> None: + if args.verbose: + warnings.simplefilter("always", ResourceWarning) -def run(): - """Initialize configuration and run the synchronization.""" - global args - global changed_ext_attrs - global changed_scripts - global sync_path - global username - global password - global url - global token +def run() -> None: args = parse_arguments() - configure_debugging(args) - + configure_runtime(args) settings = resolve_settings(args) + ctx = RuntimeContext(args=args, settings=settings) - username = settings["username"] - password = settings["password"] - url = settings["url"] - sync_path = settings["sync_path"] - - changed_ext_attrs = [] - changed_scripts = [] - - check_for_changes() - - LOG.info( - "Changed Extension Attributes: %s", - changed_ext_attrs or "None", - ) - LOG.info( - "Changed Scripts: %s", - changed_scripts or "None", - ) + check_for_changes(ctx) + LOG.info("Changed Extension Attributes: %s", ctx.changed_ext_attrs or "None") + LOG.info("Changed Scripts: %s", ctx.changed_scripts or "None") if args.jenkins: - write_jenkins_file() - - token = None + write_jenkins_file(ctx) try: - token = get_uapi_token( - jamf_url=url, - username=username, - password=password, - ) - - asyncio.run( - async_main( - jamf_url=url, - username=username, - password=password, - bearer_token=token, - parsed_args=args, - ), - debug=args.verbose, - ) + ctx.token = get_uapi_token(settings) + asyncio.run(JamfSync(ctx).run(), debug=args.verbose) finally: - if token: - invalidate_uapi_token(url, token) + if ctx.token: + invalidate_uapi_token(settings, ctx.token) if __name__ == "__main__": - uvloop.install() + if uvloop is not None: + uvloop.install() try: run() except KeyboardInterrupt: LOG.warning("Synchronization interrupted by user") sys.exit(130) - except (ValueError, requests.RequestException) as error: + except ( + ValueError, + FileNotFoundError, + requests.RequestException, + aiohttp.ClientError, + subprocess.CalledProcessError, + RuntimeError, + ) as error: LOG.error("%s", error) sys.exit(1) except Exception: LOG.exception("Unexpected synchronization failure") - sys.exit(1) \ No newline at end of file + sys.exit(1) From 55c4378ce3da83776e16c22c3f73467fc7d50c2e Mon Sep 17 00:00:00 2001 From: Rusty Myers Date: Thu, 3 Sep 2026 08:51:21 -0400 Subject: [PATCH 35/44] remove unused requirement, update docstrings --- requirements.txt | 1 - tools/ci_tests/verifyEA.py | 9 ++++++--- tools/download.py | 10 +++------- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/requirements.txt b/requirements.txt index 95ed3b5..d779d29 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,4 @@ aiohttp -cchardet aiodns uvloop requests diff --git a/tools/ci_tests/verifyEA.py b/tools/ci_tests/verifyEA.py index 473f5a5..13955c3 100755 --- a/tools/ci_tests/verifyEA.py +++ b/tools/ci_tests/verifyEA.py @@ -86,7 +86,7 @@ def build_computers_data_object(token, group_id): r = requests.get( url + "/JSSResource/computergroups/id/{0}".format(group_id), headers={"Content-Type": "application/xml", "Authorization": "Bearer " + token}, - timeout=5 + timeout=5, ) tree = eTree.fromstring(r.content) @@ -97,8 +97,11 @@ def build_computers_data_object(token, group_id): # Get detailed information about the record r = requests.get( url + "/JSSResource/computers/id/{0}".format(resource_id), - headers={"Content-Type": "application/json", "Authorization": "Bearer " + token}, - timeout=5 + headers={ + "Content-Type": "application/json", + "Authorization": "Bearer " + token, + }, + timeout=5, ) # Parse xml diff --git a/tools/download.py b/tools/download.py index 02ef580..24dfe4e 100755 --- a/tools/download.py +++ b/tools/download.py @@ -2,7 +2,7 @@ import getpass import requests from defusedxml import ElementTree as eTree -from xml.dom import minidom +from defusedxml import minidom import os import argparse import urllib3 @@ -14,9 +14,7 @@ # https://github.com/lazymutt/Jamf-Pro-API-Sampler/blob/5f8efa92911271248f527e70bd682db79bc600f2/jamf_duplicate_detection.py#L99 def get_uapi_token(): - """ - fetches api token - """ + """fetches api token""" jamf_test_url = url + "/api/v1/auth/token" response = requests.post(url=jamf_test_url, auth=(username, password), timeout=5) response_json = response.json() @@ -24,9 +22,7 @@ def get_uapi_token(): def invalidate_uapi_token(uapi_token): - """ - invalidates api token - """ + """invalidates api token""" jamf_test_url = url + "/api/v1/auth/invalidate-token" headers = {"Accept": "*/*", "Authorization": "Bearer " + uapi_token} _ = requests.post(url=jamf_test_url, headers=headers, timeout=5) From 903239974052bd7fca6a8a352ab6a0850f3a1511 Mon Sep 17 00:00:00 2001 From: Rusty Myers Date: Thu, 3 Sep 2026 09:01:04 -0400 Subject: [PATCH 36/44] download.py: add variable assignments before use --- tools/download.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tools/download.py b/tools/download.py index 24dfe4e..ced90d4 100755 --- a/tools/download.py +++ b/tools/download.py @@ -178,6 +178,9 @@ def download_scripts( if __name__ == "__main__": # Export to current directory by default export_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "..") + username = None + password = None + url = None parser = argparse.ArgumentParser(description="Download Scripts from Jamf") parser.add_argument("--url") From 8dc203aa24e3fe101a76f98cf869c4820d742cc0 Mon Sep 17 00:00:00 2001 From: Rusty Myers Date: Thu, 3 Sep 2026 09:04:23 -0400 Subject: [PATCH 37/44] Add string under heading --- CODE_OF_CONDUCT.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 56e4c75..5d1a560 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -1,5 +1,7 @@ # Contributor Covenant Code of Conduct +The following is the expectred code of conduct for contributing to this repo + ## Our Pledge In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. From 177a131b527f89535b59d4c6c142185b67c682d0 Mon Sep 17 00:00:00 2001 From: Rusty Myers Date: Thu, 3 Sep 2026 09:05:47 -0400 Subject: [PATCH 38/44] Update spacing in README --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index f479f50..27ccb5b 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,11 @@ # git2jss + [![Codacy Badge](https://app.codacy.com/project/badge/Grade/c49c0bd6a88d4f1e8c6808455171178e)](https://app.codacy.com/gh/rustymyers/git2jss/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade) A fast asynchronous python library for syncing your scripts in git with your JSS easily. This allows admins to keep their script in a version control system for easy updating rather than googling and copy-pasting from resources that they find online. ## Getting Started + 1. Fork the Project 2. Install [Python version 3.6](https://www.python.org/downloads/) or higher. (this is because of the async requirements) 3. Run `python3.6 -m pip install -r requirements.txt` to install required modules @@ -38,10 +40,13 @@ A config file can be created in the project root or the users home folder. When - url ### Prerequisites + git2jss requires [Python 3.6](https://www.python.org/downloads/) and the python modules listed in `requirements.txt` ## Deployment + The project can be ran ad-hoc with the example listed above, but ideally you setup webhooks and integrate into a CI/CD pipeline so each time a push is made to the repo your scripts are re-uploaded to the JSS. ## Contributing + PR's are always welcome! From 0ef8dcdcdd5219e723b78922fd989fb500e0244a Mon Sep 17 00:00:00 2001 From: Rusty Myers Date: Thu, 3 Sep 2026 09:07:35 -0400 Subject: [PATCH 39/44] Code of conduct test line length --- CODE_OF_CONDUCT.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 5d1a560..6aa7683 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -21,7 +21,8 @@ Examples of unacceptable behavior by participants include: * The use of sexualized language or imagery and unwelcome sexual attention or advances * Trolling, insulting/derogatory comments, and personal or political attacks * Public or private harassment -* Publishing others' private information, such as a physical or electronic address, without explicit permission +* Publishing others' private information, such as a physical or electronic address, without + explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Our Responsibilities From a68a473970a5f27598a744969be84a8e5ccfc461 Mon Sep 17 00:00:00 2001 From: Rusty Myers Date: Thu, 3 Sep 2026 09:08:51 -0400 Subject: [PATCH 40/44] Set empty password --- tools/download.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/download.py b/tools/download.py index ced90d4..78dd5bf 100755 --- a/tools/download.py +++ b/tools/download.py @@ -179,7 +179,7 @@ def download_scripts( # Export to current directory by default export_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "..") username = None - password = None + password = '' url = None parser = argparse.ArgumentParser(description="Download Scripts from Jamf") From 095b1b6dc86230e2bc75141d3c73687e2590c997 Mon Sep 17 00:00:00 2001 From: Rusty Myers Date: Thu, 3 Sep 2026 09:21:31 -0400 Subject: [PATCH 41/44] Remove password set --- tools/download.py | 235 ++++++++++++++++++++-------------------------- 1 file changed, 104 insertions(+), 131 deletions(-) diff --git a/tools/download.py b/tools/download.py index 78dd5bf..14817a0 100755 --- a/tools/download.py +++ b/tools/download.py @@ -28,47 +28,15 @@ def invalidate_uapi_token(uapi_token): _ = requests.post(url=jamf_test_url, headers=headers, timeout=5) -def download_scripts( - mode, - overwrite=None, -): - """Downloads Scripts to ./scripts and Extension Attributes to ./extension_attributes - - Folder Structure: - ./scripts/script_name/script.sh - ./scripts/script_name/script.xml - ./extension_attributes/ea_name/ea.sh - ./extension_attributes/ea_name/ea.xml - - Usage: - - Download all Extension Attributes from JSS: - download_scripts('ea','overwrite=False) - - Download all Extension Attributes from JSS: - download_scripts('script','overwrite=False) - - Params: - mode = 'script' or 'ea' - overwrite = True/False - Returns: None - """ - - # Set various values based on resource type - if mode == "ea": - resource = "computerextensionattributes" - download_path = "extension_attributes" - script_xml = "input_type/script" - - if mode == "script": - resource = "scripts" - download_path = "scripts" - script_xml = "script_contents" +RESOURCE_CONFIG = { + "ea": ("computerextensionattributes", "extension_attributes", "input_type/script"), + "script": ("scripts", "scripts", "script_contents"), +} - token = get_uapi_token() - # Get all IDs of resource type - r = requests.get( - url + "/JSSResource/%s" % resource, + +def request_xml(endpoint, token): + response = requests.get( + url + endpoint, headers={ "Accept": "application/xml", "Content-Type": "application/xml", @@ -77,101 +45,107 @@ def download_scripts( verify=args.do_not_verify_ssl, timeout=5, ) + response.raise_for_status() + return eTree.fromstring(response.content) + - # Basic error handling - if r.status_code != 200: +def get_resource_ids(resource, token): + try: + tree = request_xml("/JSSResource/%s" % resource, token) + except requests.HTTPError as error: print( - "Something went wrong with the request, check your password and privileges and try again. \n \ - It's also possible that the url is incorrect. \n \ - Here is the HTTP Status code: %s" - % r.status_code + "Something went wrong with the request, check your password and " + "privileges, URL, and HTTP status: %s" % error ) exit(1) - tree = eTree.fromstring(r.content) - resource_ids = [e.text for e in tree.findall(".//id")] - - # Download each resource and save to disk - for resource_id in resource_ids: - get_script = True - - r = requests.get( - url + "/JSSResource/%s/id/%s" % (resource, resource_id), - headers={ - "Accept": "application/xml", - "Content-Type": "application/xml", - "Authorization": "Bearer " + token, - }, - verify=args.do_not_verify_ssl, - timeout=5, + return [element.text for element in tree.findall(".//id")] + + +def script_extension(script, resource_name): + extensions = { + "#!/bin/sh": ".sh", + "#!/usr/bin/env sh": ".sh", + "#!/bin/bash": ".sh", + "#!/usr/bin/env bash": ".sh", + "#!/bin/zsh": ".sh", + "#!/usr/bin/python": ".py", + "#!/usr/bin/env python": ".py", + "#!/usr/bin/perl": ".pl", + "#!/usr/bin/ruby": ".rb", + } + for interpreter, extension in extensions.items(): + if script.startswith(interpreter): + return extension + print("No interpreter directive found for: ", resource_name) + return ".sh" + + +def prepare_resource(tree, mode, script_xml, resource_path): + script_node = tree.find(script_xml) + script = eTree.tostring(script_node, encoding="unicode", method="text").replace( + "\r", "" + ) + extension = script_extension(script, tree.find("name").text) + with open(os.path.join(resource_path, "%s%s" % (mode, extension)), "w") as handle: + handle.write(script) + + if script_node is not None: + script_node.clear() + for tag in ("id", "script_contents_encoded", "filename"): + node = tree.find(tag) + if node is not None: + tree.remove(node) + + +def save_resource(tree, mode, script_xml, resource_path, get_script): + if get_script: + prepare_resource(tree, mode, script_xml, resource_path) + xml = minidom.parseString( + eTree.tostring(tree, encoding="unicode", method="xml") + ).toprettyxml(indent=" ") + with open(os.path.join(resource_path, "%s.xml" % mode), "w") as handle: + handle.write(xml) + + +def download_resource(resource_id, mode, resource, download_path, script_xml, token, overwrite): + tree = request_xml("/JSSResource/%s/id/%s" % (resource, resource_id), token) + resource_name = tree.find("name").text + get_script = True + if mode == "ea" and tree.find("input_type/type").text != "script": + print("No script found in: %s" % resource_name) + get_script = False + + resource_path = os.path.join(export_path, download_path, resource_name) + if os.path.exists(resource_path): + print("Resource is already in the repo: ", resource_name) + if not overwrite: + print("\tSkipping: ", resource_name) + return + else: + os.makedirs(resource_path) + + print("Saving: ", resource_name) + save_resource(tree, mode, script_xml, resource_path, get_script) + + +def download_scripts(mode, overwrite=None): + """Download scripts or script-based extension attributes from Jamf Pro.""" + try: + resource, download_path, script_xml = RESOURCE_CONFIG[mode] + except KeyError: + raise ValueError("mode must be 'ea' or 'script'") from None + + token = get_uapi_token() + for resource_id in get_resource_ids(resource, token): + download_resource( + resource_id, + mode, + resource, + download_path, + script_xml, + token, + overwrite, ) - tree = eTree.fromstring(r.content) - - if mode == "ea": - if tree.find("input_type/type").text != "script": - print("No script found in: %s" % tree.find("name").text) - get_script = False - # continue - - # Determine resource path (folder name) - resource_path = os.path.join(export_path, download_path, tree.find("name").text) - - # Check to see if it exists - if os.path.exists(resource_path): - print("Resource is already in the repo: ", tree.find("name").text) - - if not overwrite: - print("\tSkipping: ", tree.find("name").text) - continue - - else: # Make the folder - os.makedirs(resource_path) - - print("Saving: ", tree.find("name").text) - - # Create script string, and determine the file extension - if get_script: - xmlstr = eTree.tostring( - tree.find(script_xml), encoding="unicode", method="text" - ).replace("\r", "") - if xmlstr.startswith("#!/bin/sh"): - ext = ".sh" - elif xmlstr.startswith("#!/usr/bin/env sh"): - ext = ".sh" - elif xmlstr.startswith("#!/bin/bash"): - ext = ".sh" - elif xmlstr.startswith("#!/usr/bin/env bash"): - ext = ".sh" - elif xmlstr.startswith("#!/bin/zsh"): - ext = ".sh" - elif xmlstr.startswith("#!/usr/bin/python"): - ext = ".py" - elif xmlstr.startswith("#!/usr/bin/env python"): - ext = ".py" - elif xmlstr.startswith("#!/usr/bin/perl"): - ext = ".pl" - elif xmlstr.startswith("#!/usr/bin/ruby"): - ext = ".rb" - else: - print("No interpreter directive found for: ", tree.find("name").text) - ext = ".sh" # Call it sh for now so the uploader detects it - - with open(os.path.join(resource_path, "%s%s" % (mode, ext)), "w") as f: - f.write(xmlstr) - - # Need to remove ID and script contents and write out xml - try: - tree.find(script_xml).clear() - tree.remove(tree.find("id")) - tree.remove(tree.find("script_contents_encoded")) - tree.remove(tree.find("filename")) - except TypeError: - pass - - xmlstr = minidom.parseString( - eTree.tostring(tree, encoding="unicode", method="xml") - ).toprettyxml(indent=" ") - with open(os.path.join(resource_path, "%s.xml" % mode), "w") as f: - f.write(xmlstr) invalidate_uapi_token(token) @@ -179,7 +153,6 @@ def download_scripts( # Export to current directory by default export_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "..") username = None - password = '' url = None parser = argparse.ArgumentParser(description="Download Scripts from Jamf") From e17f6d78a68ce9dbd94107487e4c1d1b156a1e05 Mon Sep 17 00:00:00 2001 From: Rusty Myers Date: Thu, 3 Sep 2026 09:22:41 -0400 Subject: [PATCH 42/44] Remove hard coded password --- sync.py | 1 - 1 file changed, 1 deletion(-) diff --git a/sync.py b/sync.py index 297e3d7..932d858 100755 --- a/sync.py +++ b/sync.py @@ -441,7 +441,6 @@ def find_config_file() -> Path | None: def read_config_file(config_path: Path | None) -> dict[str, str | None]: settings: dict[str, str | None] = { "username": None, - "password": None, "url": None, "sync_path": None, } From d7daedee202939806346f4f21caa0327a879b6b4 Mon Sep 17 00:00:00 2001 From: Rusty Myers Date: Thu, 3 Sep 2026 09:38:19 -0400 Subject: [PATCH 43/44] Test changes for codacy issues --- tools/download.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tools/download.py b/tools/download.py index 14817a0..125b470 100755 --- a/tools/download.py +++ b/tools/download.py @@ -153,6 +153,7 @@ def download_scripts(mode, overwrite=None): # Export to current directory by default export_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "..") username = None + password = "" url = None parser = argparse.ArgumentParser(description="Download Scripts from Jamf") @@ -198,7 +199,7 @@ def download_scripts(mode, overwrite=None): # Ask for password if not supplied via command line args if args.password: password = args.password - elif password is None: + elif not password: password = getpass.getpass() if args.export_path: From 3f6d03e21875cd8a724bbfc674b86bebaa1a4a3a Mon Sep 17 00:00:00 2001 From: Rusty Myers Date: Thu, 3 Sep 2026 09:40:22 -0400 Subject: [PATCH 44/44] refactor main --- tools/download.py | 107 ++++++++++++++++++++++++++-------------------- 1 file changed, 60 insertions(+), 47 deletions(-) diff --git a/tools/download.py b/tools/download.py index 125b470..ab03359 100755 --- a/tools/download.py +++ b/tools/download.py @@ -149,13 +149,7 @@ def download_scripts(mode, overwrite=None): invalidate_uapi_token(token) -if __name__ == "__main__": - # Export to current directory by default - export_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "..") - username = None - password = "" - url = None - +def parse_arguments(): parser = argparse.ArgumentParser(description="Download Scripts from Jamf") parser.add_argument("--url") parser.add_argument("--username") @@ -165,53 +159,72 @@ def download_scripts(mode, overwrite=None): parser.add_argument( "--do_not_verify_ssl", action="store_false" ) # Skips SSL verification - args = parser.parse_args() - # Get configs from files + return parser.parse_args() + + +def load_config(default_export_path): + config = { + "username": None, + "password": "", + "url": None, + "export_path": default_export_path, + } CONFIG_FILE_LOCATIONS = ["jamfapi.cfg", os.path.expanduser("~/jamfapi.cfg")] CONFIG_FILE = "" - # Parse Config File - CONFPARSER = configparser.ConfigParser() + config_parser = configparser.ConfigParser() for config_path in CONFIG_FILE_LOCATIONS: if os.path.exists(config_path): print("Found Config: {0}".format(config_path)) CONFIG_FILE = config_path if CONFIG_FILE != "": - # Get config - CONFPARSER.read(CONFIG_FILE) - try: - username = CONFPARSER.get("jss", "username") - except configparser.NoOptionError: - print("Can't find username in configfile") - try: - password = CONFPARSER.get("jss", "password") - except configparser.NoOptionError: - print("Can't find password in configfile") - try: - url = CONFPARSER.get("jss", "server") - except configparser.NoOptionError: - print("Can't find url in configfile") - try: - export_path = CONFPARSER.get("jss", "export_path") - except configparser.NoOptionError: - print("Can't find export_path in config") - - # Ask for password if not supplied via command line args - if args.password: - password = args.password - elif not password: - password = getpass.getpass() - - if args.export_path: - export_path = args.export_path - - if args.url: - url = args.url - - if args.username: - username = args.username - - # Run script download for extension attributes + config_parser.read(CONFIG_FILE) + config_options = { + "username": ("username", "Can't find username in configfile"), + "password": ("password", "Can't find password in configfile"), + "url": ("server", "Can't find url in configfile"), + "export_path": ("export_path", "Can't find export_path in config"), + } + for setting, (option, error_message) in config_options.items(): + try: + config[setting] = config_parser.get("jss", option) + except configparser.NoOptionError: + print(error_message) + return config + + +def apply_cli_settings(config, parsed_args): + if parsed_args.password: + config["password"] = parsed_args.password + elif not config["password"]: + config["password"] = getpass.getpass() + + if parsed_args.export_path: + config["export_path"] = parsed_args.export_path + if parsed_args.url: + config["url"] = parsed_args.url + if parsed_args.username: + config["username"] = parsed_args.username + return config + + +def main(): + global args, export_path, password, url, username + + default_export_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "..") + parsed_args = parse_arguments() + config = load_config(default_export_path) + config = apply_cli_settings(config, parsed_args) + + args = parsed_args + export_path = config["export_path"] + password = config["password"] + url = config["url"] + username = config["username"] + download_scripts(overwrite=args.overwrite, mode="ea") - # Run script download for scripts download_scripts(overwrite=args.overwrite, mode="script") + + +if __name__ == "__main__": + main()