diff --git a/.gitignore b/.gitignore index 9ba664b..e533044 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ action/test .env .DS_Store -*.code-workspace \ No newline at end of file +*.code-workspace +__pycache__/ +*.pyc \ No newline at end of file diff --git a/README.md b/README.md index 1b2c608..f7ce9b6 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,14 @@ It starts by comparing filename of the github script (without the extension) aga After creating and updating scripts, if enabled, it can delete any leftover script that is not found in github, thus keeping Github as your one source. -## Future state -* handle extension attributes. +## Features +* ✅ **Script Management**: Create, update and delete scripts in Jamf from your GitHub repository +* ✅ **Extension Attribute Scripts**: Support for extension attribute scripts with dedicated folder separation +* ✅ **Branch Prefixing**: Optional branch name prefixing for test/staging workflows +* ✅ **Duplicate Detection**: Automatic detection and prevention of duplicate script names +* ✅ **Smart Sync**: Hash-based comparison to only update scripts that have actually changed + +## Future state * slack notifications * suggestions are welcome! @@ -34,9 +40,13 @@ After creating and updating scripts, if enabled, it can delete any leftover scri **optional** the directory where the scripts to upload will be, this could be a subdirectoy in your repository `path/to/scripts`. By default it will try to sync all .sh and .py files from the repo, so it's **greatly recommended to provide this input**, you can look for multiple subdirectories that share the same name, just provide a name like `**/scripts` +### `ea_script_dir` + +**optional** the directory where extension attribute scripts are located, this should be separate from your regular scripts directory. By default this is `false` (disabled). When enabled, these scripts will be created/updated as extension attributes in Jamf Pro instead of regular scripts. **Important**: Extension attribute scripts will be automatically excluded from regular script processing to prevent conflicts. + ### `script_extensions` -**optional** the extensions for the types of files we'll be searching for. By default it tries to look for `*.sh and *.py` files. To change the behavior, separate each extension with spaces and no periods. ie `sh py ps1` +**optional** the extensions for the types of files we'll be searching for. By default it tries to look for `*.sh and *.py` files. To change the behavior, separate each extension with spaces and no periods. ie `sh py ps1`. This setting applies to both regular scripts and extension attribute scripts. ### `delete` @@ -84,6 +94,38 @@ jobs: script_dir: 'scripts' ``` +## Example usage with extension attributes + +If you want to sync both regular scripts and extension attribute scripts, you can specify separate directories for each: + +```yaml +name: git2jamf +on: + push: + branches: + - main +jobs: + jamf_scripts: + runs-on: ubuntu-latest + name: git2jamf + steps: + - name: checkout + uses: actions/checkout@v3 + - name: git2jamf + uses: jgarcesres/git2jamf@main + with: + jamf_url: ${{ secrets.jamf_url }} + jamf_username: ${{ secrets.jamf_username }} + jamf_password: ${{ secrets.jamf_password }} + script_dir: 'scripts' + ea_script_dir: 'extension-attributes' +``` + +In this setup: +- Regular scripts go in the `scripts/` directory and become Jamf Pro scripts +- Extension attribute scripts go in the `extension-attributes/` directory and become Jamf Pro extension attributes +- The two directories are processed separately to prevent conflicts + ## Example usage with 2 instances you would probably have 2 sets of secrets, with url and credentials for each instance(or share the same user creds across both servers). You also will need 2 workflow files: one for pushes to the main branch and another that goes to test. @@ -183,4 +225,35 @@ jobs: script_dir: toplevelfolder/scripts ``` +## Troubleshooting + +### Extension Attribute API Issues + +If you encounter errors related to extension attributes such as "Extension attributes endpoint not found", this may indicate: + +1. **API Endpoint Compatibility**: The extension attribute API endpoints may vary between Jamf Pro versions. The current implementation uses `/uapi/v1/computer-extension-attributes` which should work with newer Jamf Pro versions. + +2. **Jamf Pro Version**: Extension attribute support in the Jamf Pro API was added in later versions. Ensure your Jamf Pro instance supports extension attributes in the modern API. + +3. **Permissions**: Verify that your API user has the necessary permissions to read, create, and update computer extension attributes. + +If you encounter API endpoint issues, please: +- Check your Jamf Pro version and API documentation +- Verify your user has extension attribute permissions +- Consider filing an issue with details about your Jamf Pro version and the specific error messages + +### Directory Structure + +Make sure your directory structure separates regular scripts from extension attribute scripts: + +``` +your-repo/ +├── scripts/ # Regular Jamf Pro scripts +│ ├── install_app.sh +│ └── configure_system.py +└── extension-attributes/ # Extension attribute scripts + ├── check_app_version.sh + └── get_system_info.py +``` + diff --git a/action.py b/action.py index 8e39d8e..5aad40b 100644 --- a/action.py +++ b/action.py @@ -151,45 +151,126 @@ def find_jamf_script(url, token, script_name, page = 0): raise Exception("failed to find the script, please investigate!") -#function to find a EA script using the filename as the script name +#function to get all extension attributes using the new API @logger.catch -def find_ea_script(ea_name): - ea_script = requests.get(url = f"{url}/JSSResource/computerextensionattributes/name/{ea_name}", auth=(username,password)) - if ea_script.status_code == requests.codes.ok: - return ea_script.json()['computer_extension_attribute'] - elif ea_script.status_code == requests.codes.not_found: - logger.warning(f"Found no script with name: {ea_name}") - return None +def get_all_jamf_extension_attributes(url, token, eas = [], page = 0): + header = {"Authorization": f"Bearer {token}"} + page_size=50 + params = {"page": page, "page-size": page_size, "sort": "name:asc"} + # Note: This endpoint may need verification against your Jamf Pro version + # Common possibilities: computer-extension-attributes, computerextensionattributes, extension-attributes + ea_list = requests.get(url=f"{url}/uapi/v1/computer-extension-attributes", headers=header, params=params) + if ea_list.status_code == requests.codes.ok: + ea_list = ea_list.json() + logger.info(f"we got {len(ea_list['results'])+page} of {ea_list['totalCount']} EA results") + page+=1 + if (page*page_size) < ea_list['totalCount']: + logger.info("seems there's more EAs to grab") + eas.extend(ea_list['results']) + return get_all_jamf_extension_attributes(url, token, eas, page) + else: + logger.info("reached the end of our EA search") + eas.extend(ea_list['results']) + logger.success(f"retrieved {len(eas)} total extension attributes") + return eas + elif ea_list.status_code == requests.codes.not_found: + logger.error("Extension attributes endpoint not found. This may indicate:") + logger.error("1. The API endpoint '/uapi/v1/computer-extension-attributes' doesn't exist in your Jamf Pro version") + logger.error("2. Your Jamf Pro version may not support extension attributes in the new API yet") + logger.error("3. The endpoint name might be different (try checking Jamf Pro API documentation)") + logger.error("Please verify the correct endpoint for your Jamf Pro version") + raise Exception("Extension attributes API endpoint not found") + else: + logger.error(f"status code: {ea_list.status_code}") + logger.error("error retrieving extension attribute list") + logger.error(f"endpoint used: {url}/uapi/v1/computer-extension-attributes") + logger.error(ea_list.text) + raise Exception("error retrieving extension attribute list") + + +#function to find a specific extension attribute by name using the new API +@logger.catch +def find_jamf_extension_attribute(url, token, ea_name, page = 0): + header = {"Authorization": f"Bearer {token}"} + page_size=50 + params = {"page": page, "page-size": page_size, "sort": "name:asc"} + ea_list = requests.get(url=f"{url}/uapi/v1/computer-extension-attributes", headers=header, params=params) + if ea_list.status_code == requests.codes.ok: + ea_list = ea_list.json() + logger.info(f"we have searched {len(ea_list['results'])+page} of {ea_list['totalCount']} EA results") + ea_search = jmespath.search(f"results[?name == '{ea_name}']", ea_list) + if len(ea_search) == 1: + logger.info('found the extension attribute, returning it') + return ea_search[0] + elif len(ea_search) == 0 and (page*page_size) < ea_list['totalCount']: + logger.info("couldn't find the EA in this page, seems there's more to look through") + return find_jamf_extension_attribute(url, token, ea_name, page+1) + else: + logger.info(f"did not find any extension attribute named {ea_name}") + return "n/a" + else: + logger.error(f"status code: {ea_list.status_code}") + logger.error("error retrieving extension attribute list") + logger.error(ea_list.text) + raise Exception("failed to find the extension attribute, please investigate!") + + +#function to create a new extension attribute using the new API +@logger.catch +def create_jamf_extension_attribute(url, token, payload): + header = {"Authorization": f"Bearer {token}"} + ea_request = requests.post(url=f"{url}/uapi/v1/computer-extension-attributes", headers=header, json=payload) + if ea_request.status_code == requests.codes.created: + logger.success("extension attribute created") + return True + elif ea_request.status_code == requests.codes.not_found: + logger.error("Extension attributes create endpoint not found") + logger.error("This may indicate the API endpoint doesn't exist in your Jamf Pro version") + logger.error(f"endpoint used: {url}/uapi/v1/computer-extension-attributes") + return False else: - logger.error("encountered an error retriving the extension attribute, stopping") - logger.error(ea_script.text) - raise Exception("encountered an error retriving the extension attribute, stopping") + logger.warning("failed to create the extension attribute") + logger.debug(f"status code for create: {ea_request.status_code}") + logger.debug(f"endpoint used: {url}/uapi/v1/computer-extension-attributes") + logger.warning("Response body:") + logger.warning(ea_request.text) + return False -#function to create EA script +#function to update an existing extension attribute using the new API @logger.catch -def create_ea_script(payload, id): - headers = {"Accept": "text/xml", "Content-Type": "text/xml"} - ea_script = requests.post(url = f"{url}/JSSResource/computerextensionattributes/id/{id}", json=payload, auth=(username,password)) - if ea_script.status_code == requests.codes.ok: - return "success" +def update_jamf_extension_attribute(url, token, payload): + header = {"Authorization": f"Bearer {token}"} + ea_request = requests.put(url=f"{url}/uapi/v1/computer-extension-attributes/{payload['id']}", headers=header, json=payload) + if ea_request.status_code in [requests.codes.accepted, requests.codes.ok]: + logger.success("extension attribute was updated successfully") + return True + elif ea_request.status_code == requests.codes.not_found: + logger.error(f"Extension attribute with id {payload['id']} not found for update") + logger.error(f"endpoint used: {url}/uapi/v1/computer-extension-attributes/{payload['id']}") + return False else: - logger.error("encountered an error creating the extension attribute, stopping") - logger.error(ea_script.text) - raise Exception("encountered an error creating the extension attribute, stopping") + logger.warning("failed to update the extension attribute") + logger.debug(f"status code for put: {ea_request.status_code}") + logger.debug(f"endpoint used: {url}/uapi/v1/computer-extension-attributes/{payload['id']}") + logger.warning("Response body:") + logger.warning(ea_request.text) + return False -#function to update existin EA script +#function to delete an extension attribute using the new API @logger.catch -def update_ea_script(payload, id): - headers = {"Accept": "text/xml", "Content-Type": "text/xml"} - ea_script = requests.put(url=f"{url}/JSSResource/computerextensionattributes/id/{id}", json=payload, auth=(username,password)) - if ea_script.status_code == requests.codes.ok: - return "success" +def delete_jamf_extension_attribute(url, token, id): + header = {"Authorization": f"Bearer {token}"} + ea_request = requests.delete(url=f"{url}/uapi/v1/computer-extension-attributes/{id}", headers=header) + if ea_request.status_code in [requests.codes.ok, requests.codes.accepted, requests.codes.no_content]: + logger.success("extension attribute was deleted successfully") + return True else: - logger.error("encountered an error creating the extension attribute, stopping") - logger.error(ea_script.text) - raise Exception("encountered an error creating the extension attribute, stopping") + logger.warning("failed to delete the extension attribute") + logger.debug(f"status code for delete: {ea_request.status_code}") + logger.warning(ea_request.text) + return False #function to compare sripts and see if they have changed. If they haven't, no need to update it @@ -209,11 +290,20 @@ def compare_scripts(new, old): #retrieves list of files given a folder path and the list of valid file extensions to look for @logger.catch -def find_local_scripts(script_dir, script_extensions): +def find_local_scripts(script_dir, script_extensions, exclude_dir=None): script_list = [] logger.info(f"searching for files ending in {script_extensions} in {script_dir}") for file_type in script_extensions: script_list.extend(glob.glob(f"{script_dir}/**/*.{file_type}", recursive = True)) + + # Filter out files from the exclude directory if specified + if exclude_dir and exclude_dir != 'false': + original_count = len(script_list) + script_list = [script for script in script_list if not script.startswith(exclude_dir)] + excluded_count = original_count - len(script_list) + if excluded_count > 0: + logger.info(f"excluded {excluded_count} files from EA script directory: {exclude_dir}") + logger.info("found these: ", script_dir) logger.info(script_list) return script_list @@ -226,14 +316,14 @@ def get_script_name(script_path): @logger.catch -def push_scripts(): +def push_scripts(exclude_ea_dir=None): #grab the token from jamf logger.info('grabing the token from jamf') token = get_jamf_token(url,auth_type, username, password) logger.info('checking the list of local scripts to upload or create') scripts = {} #this retrives the full path of the scripts we're trying to sync from github - scripts['github'] = find_local_scripts(script_dir, script_extensions) + scripts['github'] = find_local_scripts(script_dir, script_extensions, exclude_ea_dir) #I need to simplify this array down to the just the name of the script, stripping out the path. scripts['github_simple_name'] = [] for script in scripts['github']: @@ -304,8 +394,121 @@ def push_scripts(): logger.success("finished with the scripts") +@logger.catch def push_ea_scripts(): - return "" + if ea_script_dir == 'false': + logger.warning("EA script directory not set, skipping EA script processing") + return + + logger.info('starting EA script processing') + #grab the token from jamf + logger.info('grabbing the token from jamf for EA scripts') + token = get_jamf_token(url, auth_type, username, password) + logger.info('checking the list of local EA scripts to upload or create') + ea_scripts = {} + + #this retrieves the full path of the EA scripts we're trying to sync from github + ea_scripts['github'] = find_local_scripts(ea_script_dir, script_extensions) + + #I need to simplify this array down to just the name of the script, stripping out the path. + ea_scripts['github_simple_name'] = [] + for ea_script in ea_scripts['github']: + ea_scripts['github_simple_name'].append(get_script_name(ea_script).lower()) + + logger.info('double-checking for duplicate EA script names') + for count, ea_script in enumerate(ea_scripts['github_simple_name']): + if ea_scripts['github_simple_name'].count(ea_script) >= 2: + logger.error(f"the EA script name {ea_script} is duplicated {ea_scripts['github_simple_name'].count(ea_script)} times, please give it a unique name") + sys.exit(1) + + #continue if no dupes are found + logger.success("nice, no duplicate EA script names, we can continue") + logger.info('now checking jamf for its list of extension attributes') + ea_scripts['jamf'] = get_all_jamf_extension_attributes(url, token) + logger.info("setting all EA names to lower case to avoid false positives in our search.") + logger.info("worry not, this won't affect the actual naming :)") + + #save the EA names all in lower_case + for ea_script in ea_scripts['jamf']: + ea_script['lower_case_name'] = ea_script['name'].lower() + + #make a copy of the jamf EAs, we'll use this to determine which to delete later on + ea_scripts['to_delete'] = ea_scripts['jamf'] + + logger.info("processing each EA script now") + for count, ea_script in enumerate(ea_scripts['github']): + logger.info("----------------------") + logger.info(f"EA script {count+1} of {len(ea_scripts['github'])}") + logger.info(f"path of the EA script: {ea_script}") + ea_script_name = get_script_name(ea_script) + + if enable_prefix == "false": + #don't use the prefix + logger.info(f"EA script name is: {ea_script_name}") + else: + #use the branch name as prefix + prefix = branch.split('/')[-1] + ea_script_name = f"{prefix}_{ea_script_name}" + logger.info(f"the new EA script name: {ea_script_name}") + + #check to see if the EA script name exists in jamf + logger.info(f"now let's see if {ea_script_name} exists in jamf already") + ea_search = jmespath.search(f"[?lower_case_name == '{ea_script_name.lower()}']", ea_scripts['jamf']) + + if len(ea_search) == 0: + logger.info("it doesn't exist, lets create it") + #it doesn't exist, we can create it + with open(ea_script, 'r') as upload_ea_script: + ea_script_content = upload_ea_script.read() + payload = { + "name": ea_script_name, + "enabled": True, + "description": "Extension attribute script created via git2jamf", + "dataType": "String", + "inputType": { + "type": "Script", + "platform": "Mac", + "script": ea_script_content + }, + "inventoryDisplay": "General", + "reconDisplay": "Extension Attributes" + } + create_jamf_extension_attribute(url, token, payload) + + elif len(ea_search) == 1: + jamf_ea = ea_search.pop() + del jamf_ea['lower_case_name'] + ea_scripts['to_delete'].remove(jamf_ea) + logger.info("it does exist, lets compare them") + #it does exist, lets see if it has changed + with open(ea_script, 'r') as upload_ea_script: + ea_script_content = upload_ea_script.read() + # Check if the script content is different + current_script = "" + if 'inputType' in jamf_ea and 'script' in jamf_ea['inputType']: + current_script = jamf_ea['inputType']['script'] + + if not compare_scripts(ea_script_content, current_script): + logger.info("the local EA version is different than the one in jamf, updating jamf") + #the hash of the scripts is not the same, so we'll update it + jamf_ea['inputType']['script'] = ea_script_content + update_jamf_extension_attribute(url, token, jamf_ea) + else: + logger.info("we're skipping this EA script.") + + if delete == 'true': + logger.warning(f"we have {len(ea_scripts['to_delete'])} extension attributes left to delete") + for ea_script in ea_scripts['to_delete']: + # Only delete extension attributes that have scripts (not other types like text input, etc.) + if 'inputType' in ea_script and ea_script['inputType'].get('type', '').lower() == 'script': + logger.info(f"attempting to delete extension attribute {ea_script['name']} in jamf") + delete_jamf_extension_attribute(url, token, ea_script['id']) + else: + logger.info(f"skipping deletion of non-script extension attribute: {ea_script['name']}") + + logger.info("expiring the token so it can't be used further") + invalidate_jamf_token(url, token) + logger.success("finished with the EA scripts") #run this thing @@ -325,6 +528,9 @@ def push_ea_scripts(): workspace_dir = os.getenv('GITHUB_WORKSPACE') if script_dir != workspace_dir: script_dir = f"{workspace_dir}/{script_dir}" + # Process EA script directory path if it's set + if ea_script_dir != 'false' and ea_script_dir != workspace_dir: + ea_script_dir = f"{workspace_dir}/{ea_script_dir}" enable_prefix = os.getenv('INPUT_PREFIX') branch = os.getenv('GITHUB_REF') script_extensions = os.getenv('INPUT_SCRIPT_EXTENSIONS') @@ -333,6 +539,7 @@ def push_ea_scripts(): logger.info(f"url is: {url}") logger.info(f"workspace dir is: {workspace_dir}") logger.info(f"script_dir is: {script_dir}") + logger.info(f"ea_script_dir is: {ea_script_dir}") logger.info(f"branch is set to: {branch}") logger.info(f"script_deletion is: {delete}") logger.info(f"scripts_extensions are: {script_extensions}") @@ -341,7 +548,7 @@ def push_ea_scripts(): else: logger.warning(f"prefix enabled, using: {branch.split('/')[-1]}") #run the block to push the "normal" scripts to jamf - push_scripts() + push_scripts(ea_script_dir if ea_script_dir != 'false' else None) #check to see if we have an EA scripts to push over if ea_script_dir != 'false': logger.info("we have some EA scripts to process")