#!/usr/bin/env python # pylint: disable=missing-docstring,invalid-name import warnings import os from os.path import dirname, join, realpath import sys import xml.etree.ElementTree as ET import getpass import argparse import logging import asyncio import async_timeout import aiohttp import uvloop logging.basicConfig( level=logging.DEBUG, format='%(levelname)7s: %(message)s', stream=sys.stderr, ) LOG = logging.getLogger('') # 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 = [] 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 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' + '\\' if not changed_scripts: contents = contents.rstrip('\\') + '\n' + "scripts=" + "None" 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): mypath = 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(mypath, '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(mypath, '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)) tasks.append(task) await asyncio.gather(*tasks) async def upload_extension_attribute(session, url, user, passwd, ext_attr, semaphore): mypath = dirname(realpath(__file__)) auth = aiohttp.BasicAuth(user, passwd) headers = {'Accept': 'application/xml', 'Content-Type': 'application/xml'} # 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('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) return # Need to skip if no script. with open( join(mypath, '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, auth=auth, headers=headers) as resp: template.find('input_type/script').text = data if args.verbose: print(ET.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, auth=auth, data=ET.tostring(template), headers=headers) else: post_url = url + '/JSSResource/computerextensionattributes/id/0' resp = await session.post( post_url, auth=auth, data=ET.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) mypath = dirname(realpath(__file__)) xml_file = [ f.name for f in os.scandir(join('extension_attributes', ext_attr)) if f.is_file() and f.name.split('.')[-1] in 'xml' ] try: with open( join(mypath, 'extension_attributes', ext_attr, xml_file[0]), 'r') as file: template = ET.fromstring(file.read()) except IndexError: with async_timeout.timeout(args.timeout): headers = { 'Accept': 'application/xml', 'Content-Type': 'application/xml' } async with session.get( url + '/JSSResource/computerextensionattributes/name/' + ext_attr, auth=auth, headers=headers) as resp: if resp.status == 200: async with session.get( url + '/JSSResource/computerextensionattributes/name/' + ext_attr, auth=auth, headers=headers) as response: template = ET.fromstring(await response.text()) else: template = ET.parse(join(mypath, 'templates/ea.xml')).getroot() # name is mandatory, so we use the foldername if nothing is set in # a template if args.verbose: print(ET.tostring(template)) if template.find('category') and template.find( 'category').text not in CATEGORIES: ET.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: ET.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): mypath = 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(mypath, '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(mypath, '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) async def upload_script(session, url, user, passwd, script, semaphore): mypath = dirname(realpath(__file__)) auth = aiohttp.BasicAuth(user, passwd) headers = {'Accept': 'application/xml', 'Content-Type': 'application/xml'} script_file = [ f.name for f in os.scandir(join('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(mypath, '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, auth=auth, 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, auth=auth, data=ET.tostring(template), headers=headers) else: post_url = url + '/JSSResource/scripts/id/0' resp = await session.post( post_url, auth=auth, data=ET.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) mypath = dirname(realpath(__file__)) xml_file = [ f.name for f in os.scandir(join('scripts', script)) if f.is_file() and f.name.split('.')[-1] in 'xml' ] try: with open(join(mypath, 'scripts', script, xml_file[0]), 'r') as file: template = ET.fromstring(file.read()) except IndexError: with async_timeout.timeout(args.timeout): headers = { 'Accept': 'application/xml', 'Content-Type': 'application/xml' } async with session.get( url + '/JSSResource/scripts/name/' + script, auth=auth, headers=headers) as resp: if resp.status == 200: async with session.get( url + '/JSSResource/scripts/name/' + script, auth=auth, headers=headers) as response: template = ET.fromstring(await response.text()) else: template = ET.parse(join( mypath, 'templates/script.xml')).getroot() # name is mandatory, so we use the filename if nothing is set in a template if args.verbose: print(ET.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: ET.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' } async with semaphore: with async_timeout.timeout(args.timeout): async with session.get( url + '/JSSResource/categories', auth=auth, headers=headers) as resp: if resp.status in (201, 200): return [ c.find('name').text for c in [ e for e in ET.fromstring(await resp.text()). findall('category') ] ] return [] async def main(): # pylint: disable=global-statement 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, args.url, args.username, args.password, semaphore) await upload_scripts(session, args.url, args.username, args.password, semaphore) await upload_extension_attributes(session, args.url, args.username, args.password, semaphore) if __name__ == '__main__': asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) parser = argparse.ArgumentParser(description='Sync repo with JamfPro') parser.add_argument('--url') parser.add_argument('--username') parser.add_argument('--password') 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() changed_ext_attrs = [] changed_scripts = [] check_for_changes() print('Changed Extension Attributes: ', changed_ext_attrs) print('Changed Scripts: ', changed_scripts) if args.jenkins: write_jenkins_file() # Ask for password if not supplied via command line args if not args.password: args.password = getpass.getpass() loop = asyncio.get_event_loop() if args.verbose: loop.set_debug(True) loop.slow_callback_duration = 0.001 warnings.simplefilter('always', ResourceWarning) loop.run_until_complete(main())