├── INSTALL.md ├── LICENSE.md ├── README.md └── jss_resource_tools /INSTALL.md: -------------------------------------------------------------------------------- 1 | ## INSTALL 2 | This document outlines how to install JSS Resource Tools on your platform of choice. 3 | 4 | ### Requirements 5 | JSS Resource Tools is written in Python and has a few module dependencies. Specificially it requires: 6 | 7 | * Python 2.7 or greater 8 | * `click` Module 9 | * `requests` Module 10 | 11 | ### Easy Install with `pip` 12 | 13 | JSS Resource Tools is listed in [Python Package Index](https://pypi.python.org/pypi/jss-resource-tools/) and can easily be installed and managed by `pip`. 14 | 15 | #### macOS 16 | 17 | Due to some issues with the built in version of OpenSSL shipped with macOS, JSS Resource Tools won't work out of the box with the macOS python install. This is easily fixed with a proper Python install through Homebrew: 18 | 19 | 1. If you haven't already: install [Homebrew](https://brew.sh/). 20 | 2. Install python using Homebrew: `brew install python` 21 | 3. Install required modules using pip2: `pip2 install jss-resource-tools click requests` 22 | 4. You should now be able to run the tool by running `jss_resource_tools` 23 | 24 | #### Linux 25 | 26 | Installing on most modern Linux variants is pretty simple as most of the time, Python > 2.7 is already installed. If `pip` is not already installed, you many need to install it by running `apt-get install python-pip` or something simular depending on your distribution. Once Python and Pip are sorted, it should be as simple as running: 27 | 28 | `python -m pip install jss-resource-tools click requests` 29 | 30 | after which, eou should now be able to run the tool by running `jss_resource_tools` 31 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Beyond the Box 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # JSS Resource Tools 2 | 3 | JSS Resource Tools is a command line utility written in Python that utilises the Jamf Pro (previously Casper Suite) API in order to import, export and update JSS resources en-masse. Whilst its purposes are many, popular examples include: 4 | 5 | - Exporting mobile device or computer records to CSV (with custom fields, primary keys & extension attributes) 6 | - Mass import of users 7 | - Mass assignment of mobile device or computer records to users 8 | - Mass update of mobile device or computer custom fields & extension attributes 9 | - Automated synchronisation of JSS data with external asset management or similar systems 10 | 11 | JSS Resource Tools is developed and maintained by [Beyond the Box](https://www.beyondthebox.com.au). We first developed this script to assist with large scale 1:1 Mac & iOS rollouts into schools where traditional directory user assignment was not being utilised. Through many iterations over a few years, it has helped us build and manage successful Casper and Jamf Pro deployments for our customers. 12 | 13 | You can learn more about use cases and examples of JSS Resource Tools at [Beyond the Box Labs](https://beyondthebox.github.io). 14 | 15 | ## JSS Requirements 16 | 17 | JSS Resource Tools utilises the JSS API and therefore requires an account with API read and write access. It checks for successful authentication but does not currently do any specific authorisation checks, so assumes the account you give it has appropriate privileges. It has been tested and works with: 18 | 19 | - Casper Suite 9.8 and greater 20 | - Jamf Pro 10 21 | 22 | ## Installing JSS Resource Tools 23 | 24 | For requirements and install instructions, see [INSTALL](https://github.com/beyondthebox/JSS-Resource-Tools/blob/master/INSTALL.md). 25 | 26 | ## Exporting Resources 27 | 28 | Whilst basic export functionality is built into the JSS, there can be limited flexibility in terms of the fields you can request for export, particularly around keying to other resources. By using `--mode export`, the tool provides extremely flexible export functionality. In more advanced usage, this tool can export resource foreign keying (ie. exporting the mobile device ID/s for each user) or exporting fields that are not easily exportable using the JSS interface (i.e exporting the current version of GarageBand installed on each mobile device). 29 | 30 | When exporting a CSV of supported resources, the script will include a default set of fields. You can request export of nearly every other field by supplying 1 or more `--field` options to the script. The script uses the logic expressed in the Field Naming section below to match and export fields from a JSS resource. Common fields are also listed for your convenience in the Field Listing section at the bottom of this document. 31 | 32 | #### Performance 33 | A quick note on export performance: it's not going to set your world on fire. Due to API limitations and the tool's flexibility in allowing almost any custom field to be exported, each resource must be independently requested and processed as a separate and independent HTTP call. This will mean that larger resource sets take a while to export. 34 | 35 | ## Importing Resources 36 | 37 | By using `--mode import`, a CSV file can be passed for validation and processing. Properly formatted records will be sent to the JSS API for resource creation. 38 | 39 | ### Users 40 | 41 | This tool can easily and safely import user records from CSV, and can facilitate advanced user and group scoping in Jamf Pro where a tradtional directory connection (LDAP, Active Directory) does not exist. These user records can then be updated and keyed against mobile devices or computers, allowing you to assign hardware to users. 42 | 43 | ## Updating Resources 44 | 45 | One of the tool's best and most powerful features is its ability to update existing records in the JSS from a CSV input file. This mode (`--mode update`) can be used to make quick and consistent updates to resources; matching on resource ID or almost any other unique key available. 46 | 47 | Popular use cases include: 48 | 49 | - Assigning computers or mobile devices to users 50 | - Updating mobile device or computer names 51 | - Updating asset tags or purchasing information 52 | - Updating or assigning extension attributes against resources 53 | 54 | 55 | ### Field Naming 56 | 57 | Field naming is how you get data in and out of the JSS using the tool. Almost any field should be able be processed on a JSS resource using custom field naming in your CSV file. The script matches fields using XML XPath conventions, with some modified helpers for resource extension attributes. 58 | 59 | The JSS API returns resource records via XML, and stores fields within different children in a resource's tree structure. In order to simplify this concept for easy compatibility with CSV headers, JSS resource tools transparently maps fields using: 60 | 61 | - Field names in root element 62 | - Field via Xpath in child elements 63 | - Extension attributes helper 64 | 65 | #### Xpath (default) 66 | 67 | The tool uses Xpath to match to fields within a resource XML object. This allows for incredibly precise and advanced scoping of fields within a resource: 68 | 69 | To get the current IP address for a computer or mobile device: 70 | 71 | `general/ip_address` 72 | 73 | To get warranty expiry for a computer or mobile device: 74 | 75 | `purchasing/warranty_expires` 76 | 77 | To get the number of applications installed on a mobile device: 78 | 79 | `applications/size` 80 | 81 | To get the display name of the first configuration profile installed on a mobile device: 82 | 83 | `configuration_profiles/*[1]/display_name` 84 | 85 | To get the current version of Pages on a mobile device: 86 | 87 | `applications/application/[identifier="com.apple.Pages"]/application_version` 88 | 89 | #### Extension Attributes 90 | 91 | The tool fully supports export, import and updates of extension attributes on JSS resources. 92 | 93 | In order to make extention attributes easier to work with, you can format field names with a helper syntax. The syntax is as follows: 94 | 95 | `extension_attribute/{id}` 96 | 97 | For example, if you have a mobile device extention attribute called "Case Type" and with an ID of "2" that you wish to update, you would name your field: 98 | 99 | `extension_attribute/2` 100 | 101 | You can get a listing of valid extention attributes and their IDs for a particular resource by printing an Example Resource Record. See "Example Resource Record". 102 | 103 | 104 | ### Common Fields 105 | 106 | Following is a list of common fields or interesting examples that are available through field naming: 107 | 108 | #### Users 109 | 110 | | Fieldname | Description | 111 | |-----------|-------------| 112 | | `extension_attribute/1` | Value of the extention attribute with ID# 1 for the user. | 113 | | `links/mobile_devices/*[0]/id` | ID of the first mobile device assigned to the user. | 114 | | `links/computers/*[0]/id` | ID of the first mobile device assigned to the user. | 115 | 116 | #### Mobile Devices 117 | 118 | | Fieldname | Description | 119 | |-----------|-------------| 120 | | `general/name` | The mobile device name. | 121 | | `general/asset_tag` | The mobile device asset tag. | 122 | | `general/ip_address` | The last reported IP address for the device. | 123 | | `general/ip_address` | The last reported IP address for the device. | 124 | | `location/username` | The username assigned to the device. | 125 | | `purchasing/is_purchased` | Is device purchased? Takes TRUE or FALSE value. | 126 | | `purchasing/is_leased` | Is device leased? Takes TRUE or FALSE value. | 127 | | `purchasing/po_number` | Purchase Order Number | 128 | | `purchasing/vendor` | Vendor Name | 129 | | `purchasing/purchase_price` | Purchase Price | 130 | | `applications/size` | The number of applications installed on the device. | 131 | 132 | ## Command Examples 133 | 134 | Following is a list of command examples. For cleanliness, they omit the `--jss`, `--username` and `--password` options that you may generally provide in the command. In these examples, you will be prompted for them once running the command. 135 | 136 | `jss_resource_tools.py --mode example --type mobiledevices` 137 | 138 | Prints an example XML record of a mobile device from your JSS. 139 | 140 | `jss_resource_tools.py --mode import --type users UsersToImport.csv` 141 | 142 | Imports the users defined in the file "UsersToImport.csv" into the JSS. In this example, "UsersToImport.csv" would look like: 143 | 144 | 145 | 146 | `jss_resource_tools.py --mode export --type computers Export.csv` 147 | 148 | Exports the default fields for computer resource objects into the file "Export.csv". 149 | 150 | `jss_resource_tools.py --mode export --type mobiledevices --field general/ip_address --field location/username Export.csv` 151 | 152 | Exports the default fields, last known IP address and assigned user for mobile device resource objects into the file "Export.csv". 153 | 154 | `jss_resource_tools.py --mode export --type mobiledevices --field general/ip_address --field location/username Export.csv` 155 | Exports the default fields, last known IP address and assigned user for mobile device resource objects into the file "Export.csv". 156 | 157 | `jss_resource_tools.py --mode update --type computers MobileDevices.csv` 158 | Updates existing enrolled computers by keying on their resource "id", and updating other fields defined in the CSV file "MobileDevices.csv". In this example; in order to update the Vendor and PO Number for devices, "MobileDevices.csv" would look like: 159 | 160 | 161 | 162 | `jss_resource_tools.py --mode update --type mobiledevices --match serial_number MobileDevices.csv` 163 | 164 | Updates existing enrolled mobile devices by matching on their serial number, and updating other fields defined in the CSV file "MobileDevices.csv". In this example; in order to update device name and assign the device to a username, "MobileDevices.csv" would look like: 165 | 166 | 167 | 168 | ## Dry Run & Verbosity 169 | In order to better understand the tool's behavior, as well as protect your JSS data from unintentional edits, the tool supports both `--verbose` and `--dry` options. 170 | 171 | `--verbose` significantly increases the amount of output and logging that the tool produces, and will help of you are experiencing issues or errors. 172 | 173 | `--dry` toggles a "Dry Run" of the tool. This runs through standard processing of fields and elements but skips any actual writes or updates to your JSS. This is very useful in ensuring that your CSV formatting and field naming is correct before making production imports or updates to your JSS. 174 | 175 | ## Example Resource Record 176 | 177 | In order to more easily determine field names and structure for a resource for import or updating, you can use `--mode example` to fetch an example resource. This will simply pretty print the XML structure of a random resource returned by the API. See the "Field Naming" section above for details on how to use this information to update resource records. 178 | 179 | ## Danger Zone 180 | 181 | Can you break enrollments using this script? Absolutely. 182 | 183 | When updating mobile devices and computers using the script, changing the hardware UDID in the JSS can break enrollment. For this reason, the script will refuse to update any field name matching `"udid"`. If you really know what you are doing, you can override this safety feature by supplying the `--danger` flag. 184 | 185 | User beware. 186 | 187 | ## License & Support 188 | 189 | JSS Resource Tools is licensed under The MIT License, available here - [LICENSE.md](https://github.com/beyondthebox/JSS-Resource-Tools/blob/master/LICENSE.md). As per the license, this software is provided "as-is" and is not actively supported or warranted by Beyond the Box. 190 | 191 | ## About Beyond the Box 192 | 193 | Beyond the Box are a Jamf specialist and IT support provider based in Melbourne, Australia. We specialise in seamless deployments of Apple technology into schools and businesses large and small. To learn more, [visit us here](https://www.beyondthebox.com.au). 194 | -------------------------------------------------------------------------------- /jss_resource_tools: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | # 3 | # JSS Resource Tools (v1.15) 4 | # by Beyond the Box Labs (https://beyondthebox.github.io) 5 | # 6 | # Version History: 7 | # 1.15 (05/12/17) - Initial public release. 8 | # 9 | # -------------------- 10 | # 11 | # MIT License 12 | # 13 | # Copyright (c) 2017 Beyond the Box 14 | # 15 | # Permission is hereby granted, free of charge, to any person obtaining a copy 16 | # of this software and associated documentation files (the "Software"), to deal 17 | # in the Software without restriction, including without limitation the rights 18 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 19 | # copies of the Software, and to permit persons to whom the Software is 20 | # furnished to do so, subject to the following conditions: 21 | # 22 | # The above copyright notice and this permission notice shall be included in all 23 | # copies or substantial portions of the Software. 24 | # 25 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 26 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 27 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 28 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 29 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 30 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 31 | # SOFTWARE. 32 | 33 | import click 34 | import csv 35 | import requests 36 | import xml.etree.ElementTree as ET 37 | import xml.dom.minidom as minidom 38 | import os.path 39 | from random import randint 40 | 41 | version = 1.15 42 | 43 | class Settings(object): 44 | def __init__(self, base_url=None, username=None, password=None, verbose=None, danger=None, dry=None, match=None): 45 | self.base_url = base_url.strip("/") 46 | self.username = username 47 | self.password = password 48 | self.verbose = verbose 49 | self.danger = danger 50 | self.dry = dry 51 | self.match = match 52 | 53 | @click.command() 54 | @click.option('--jss', '-j', required=True, help='The base URL to your JSS server.', prompt="JSS Base URL") 55 | @click.option('--username', '-u', help='The password for your JSS account.', required=True, prompt="JSS Username" ) 56 | @click.option('--password', '-p', help='The password for your JSS account.', required=True, prompt="JSS Password", hide_input=True ) 57 | @click.option('--mode', '-m', type=click.Choice(['import', 'update', 'export', 'example']), required=True, prompt="Mode (choose from import, update, export or example)", help='Which mode to run in.') 58 | @click.option('--type', '-t', type=click.Choice(['mobiledevices', 'computers', 'users']), required=True, help='The type of JSS resource to process.', prompt="Resource Type (choose from mobiledevices, computers or users)") 59 | @click.option('--field', '-f', multiple=True, help='In export mode, specifies custom fields from the resource to include in the CSV.') 60 | @click.option('--match', '-m', 'match', default=False, help="In update mode, matches and keys resources to this field name.") 61 | @click.option('--dry', '-d', 'dry', is_flag=True, default=False, help="Performs a dry run and does not actually update or import data.") 62 | @click.option('--verbose', '-v', 'verbose', is_flag=True, default=False, help="Runs in verbose mode - logging more information.") 63 | @click.option('--danger', 'danger', is_flag=True, default=False, help="Bypasses safety checks - see README.") 64 | @click.argument('filepath', type=click.Path(), required=False) 65 | 66 | @click.pass_context 67 | def main(ctx, jss, username, password, mode, type, match, dry, verbose, field, danger, filepath): 68 | """JSS Resource Tools by Beyond the Box Labs (https://beyondthebox.github.io)\n 69 | \b 70 | A command line utility that utilises the Jamf Pro JSS API in order to import, export and update JSS resources en-masse.\n 71 | \b 72 | For detailed help, see the project README (https://github.com/beyondthebox/JSS-Resource-Tools/blob/master/README.md) 73 | """ 74 | global resource_ep 75 | global resource_el 76 | global resource_hr 77 | ctx.obj = Settings(jss, username, password, verbose, danger, dry, match) 78 | click.secho('JSS Resource Tools v{0} by Beyond the Box Labs'.format(version), fg='blue', bold=True) 79 | if dry: 80 | click.secho('Performing a dry run (--dry). No data will be updated or imported.', bold=True) 81 | if type == 'mobiledevices': 82 | resource_ep, resource_el, resource_hr = 'mobiledevices', 'mobile_device', 'mobile device' 83 | elif type == 'computers': 84 | resource_ep, resource_el, resource_hr = 'computers', 'computer', 'computer' 85 | elif type == 'users': 86 | resource_ep, resource_el, resource_hr = 'users', 'user', 'user' 87 | 88 | if auth_check(): 89 | if mode == 'example': 90 | click.secho('Will fetch and print an example record of a {0} from your JSS:'.format(resource_hr), fg='blue', bold=True) 91 | print_example_record() 92 | exit() 93 | elif mode == 'export': 94 | if filepath: 95 | file = open(filepath,"w") 96 | if file: 97 | click.secho('Will fetch and export {0}s from your JSS in CSV format:'.format(resource_hr), fg='blue', bold=True) 98 | export_to_csv(type, field, file) 99 | file.close() 100 | exit() 101 | else: 102 | echo_log('ERROR: Could not open file path "{0}" for writing.'.format(filepath), 4) 103 | exit(1) 104 | else: 105 | echo_log('ERROR: You must supply an export file path as [FILEPATH].', 4) 106 | exit(1) 107 | elif mode == 'import': 108 | if filepath: 109 | file = open(filepath,"r") 110 | if file: 111 | click.secho('Will import new {0}s into your JSS from CSV:'.format(resource_hr), fg='blue', bold=True) 112 | import_from_csv(file) 113 | if dry: 114 | click.secho('Finished a dry run of {0} imports. Processed {1} records.'.format(resource_hr, ctx.obj.reader.line_num-1), fg='blue', bold=True) 115 | else: 116 | click.secho('Finished processing {0} imports. Processed {1} records and imported {2} {3}(s).'.format(resource_hr, ctx.obj.reader.line_num-1, ctx.obj.records_mutated, resource_hr), fg='blue', bold=True) 117 | exit() 118 | else: 119 | echo_log('ERROR: Could not open file path "{0}" for reading.'.format(filepath), 4) 120 | exit(1) 121 | else: 122 | echo_log('ERROR: You must supply an import file path as [FILEPATH].', 4) 123 | exit(1) 124 | elif mode == 'update': 125 | if match and type == "users": 126 | echo_log('ERROR: --match is not currently supported for users. You must supply a user "id" field in your CSV.', 4) 127 | exit(1) 128 | if filepath: 129 | file = open(filepath,"r") 130 | if file: 131 | click.secho('Updating {0}s from CSV:'.format(resource_hr), fg='blue', bold=True) 132 | update_from_csv(file) 133 | if dry: 134 | click.secho('Finished a dry run of {0} updates. Processed {1} records and would have updated {2} field(s) across {3} {4}(s).'.format(resource_hr, ctx.obj.reader.line_num-1, ctx.obj.fields_mutated, ctx.obj.records_mutated, resource_hr), fg='blue', bold=True) 135 | else: 136 | click.secho('Finished processing {0} updates. Processed {1} records and updated {2} {3}(s).'.format(resource_hr, ctx.obj.reader.line_num, ctx.obj.records_mutated, resource_hr), fg='blue', bold=True) 137 | exit() 138 | else: 139 | echo_log('ERROR: Could not open file path "{0}" for reading.'.format(filepath), 4) 140 | exit(1) 141 | else: 142 | echo_log('ERROR: You must supply an import file path as [FILEPATH].', 4) 143 | exit(1) 144 | 145 | def print_example_record(): 146 | resources = get_all_jss_resources() 147 | if len(resources.findall("./"+resource_el)) > 0: 148 | # got at least one resource - fetch the first record 149 | resource = get_jss_resource(resources.find('./{0}/[{1}]/id'.format(resource_el, randint(0, len(resources.findall("./"+resource_el))-1))).text) 150 | click.echo(minidom.parseString(ET.tostring(resource)).toprettyxml()) 151 | else: 152 | echo_log('Your JSS does not have any {0}s defined.'.format(resource_hr), 3) 153 | 154 | @click.pass_obj 155 | def auth_check(ctx): 156 | echo_log('Checking authentication by requesting {0}/JSSResource/accounts/username/{1}.'.format(ctx.base_url, ctx.username), 1) 157 | account_response = requests.get('{0}/JSSResource/accounts/username/{1}'.format(ctx.base_url, ctx.username), auth=(ctx.username, ctx.password), verify=True) 158 | if account_response.status_code == 200: 159 | echo_log('Successfully authenticated to the JSS at {0}.'.format(ctx.base_url)) 160 | return True 161 | elif account_response.status_code == 401: 162 | echo_log('ERROR: Could not authenticate to the JSS at {0}. Your username and/or password were rejected by the server.'.format(ctx.base_url), 4) 163 | exit(1) 164 | else: 165 | echo_log('ERROR: Could not authenticate to the JSS at {0}. Encountered a {1} error when connecting. Ensure the URL is correct, and try again.'.format(ctx.base_url, account_response.status_code),4) 166 | exit(1) 167 | 168 | @click.pass_obj 169 | def echo_log(ctx, line, level=2): 170 | # level 1 is debug - will only show in verbose 171 | # level 2 is info 172 | # level 3 is warning 173 | # level 4 is error 174 | if level == 1 and ctx.verbose == True: 175 | click.echo(line) 176 | elif level == 2: 177 | click.echo(line) 178 | elif level == 3: 179 | click.secho(line, fg='yellow') 180 | elif level == 4: 181 | click.secho(line, fg='red', bold=True, err=True) 182 | 183 | @click.pass_obj 184 | def update_element(ctx, tree, field, value): 185 | # check we arent being asked to update a dangerous element 186 | if field.find("udid") != -1 and ctx.danger == False: 187 | echo_log('[{0}] WARNING: Refusing to update {1} UDID in field {2}, as it may break enrollment. See the --danger option for override if you know what you are doing.'.format(ctx.reader.line_num, resource_hr, field), 3) 188 | return '' 189 | # check for a formatted parent tag, then match elements in the tree with xpath 190 | field_matches = tree.findall(find_path_for_field(field)) 191 | if len(field_matches) == 1: 192 | # check to see that the element is not a list (ie. has subelements) 193 | if len(list(field_matches[0])) == 0: 194 | match_text = "" if field_matches[0].text == None else field_matches[0].text 195 | if match_text != value: 196 | echo_log('[{0}] "{1}" changed from "{2}" to "{3}"'.format(ctx.reader.line_num, field, str(match_text), value), 2) 197 | field_matches[0].text = value 198 | return True 199 | else: 200 | echo_log('[{0}] "{1}" is already "{2}".'.format(ctx.reader.line_num, field, value), 1) 201 | return False 202 | else: 203 | echo_log('WARNING: The element matching "'+field+'" for '+resource_element+' "'+each['match']+'" has subelements, and is not updateable directly. Will skip this field.', 3) 204 | return False 205 | elif len(field_matches) > 1: 206 | echo_log('WARNING: Found multiple elements ({0}) matching "{1}" for {2}. Will skip this field.'.format(len(field_matches), field, resource_hr), 3) 207 | return False 208 | else: 209 | echo_log('[{0}] WARNING: Could not find an element matching "{1}" for {2}. Will skip this field.'.format(ctx.reader.line_num, field, resource_hr, tree.find('./id')), 3) 210 | return False 211 | 212 | def find_path_for_field(field): 213 | # check for a formatted parent tag 214 | if len(field.split('/')) == 2: 215 | # parent element defined 216 | if field.split('/')[0] == 'extension_attribute': 217 | # field is an extension attribute. we deal with these a little differently 218 | return './extension_attributes/extension_attribute/[id="{0}"]/value'.format(field.split('/')[1]) 219 | if field.split('/')[0] == 'field': 220 | # field is a "field". we see these on peripherals and deal with them differently again - thanks jamf! 221 | return './general/fields/field/[name={0}]/value'.format(field.split('/')[1]) 222 | else: 223 | return './{0}/{1}'.format(field.split('/')[0], field.split('/')[1]) 224 | else: 225 | return './{0}'.format(field) 226 | 227 | @click.pass_obj 228 | def update_from_csv(ctx, file): 229 | required_key = ctx.match if ctx.match else "id" 230 | ctx.reader = csv.DictReader(file) 231 | ctx.records_mutated = 0 232 | ctx.fields_mutated = 0 233 | if ctx.reader.fieldnames: 234 | echo_log('Found fields in CSV: {0}'.format(ctx.reader.fieldnames), 1) 235 | if required_key in ctx.reader.fieldnames: 236 | echo_log('Using "{0}" as primary key.'.format(required_key), 1) 237 | for each in ctx.reader: 238 | matched_object = None 239 | record_updated = False 240 | if each[required_key] == "": 241 | echo_log('[{0}] WARNING: The match field ("{1}") in this row is empty. Will skip this row and will not update.'.format(ctx.reader.line_num, required_key), 3) 242 | continue 243 | if ctx.match: 244 | # ask API to match current row to a resource 245 | matches_response = requests.get('{0}/JSSResource/{1}/match/{2}'.format(ctx.base_url, resource_ep, each[required_key]), auth=(ctx.username, ctx.password), verify=True) 246 | if matches_response.status_code == 200: 247 | matches_tree = ET.ElementTree(ET.fromstring(matches_response.content)) 248 | matches = matches_tree.findall('./{0}'.format(resource_el)) 249 | if len(matches) == 1: 250 | # got a single match - load the whole resource object in xml 251 | match_response = requests.get('{0}/JSSResource/{1}/id/{2}'.format(ctx.base_url, resource_ep, matches_tree.find('./{0}/id'.format(resource_el)).text), auth=(ctx.username, ctx.password), verify=True) 252 | if match_response.status_code == 200: 253 | echo_log('[{0}] "{1}" matched to {2} ID #{3}'.format(ctx.reader.line_num, each[required_key], resource_hr, matches_tree.find('./{0}/id'.format(resource_el)).text), 1) 254 | matched_object = ET.fromstring(match_response.content) 255 | elif len(matches) > 1: 256 | echo_log('[{0}] WARNING: Matched more than one record ({1}) for "{2}". Will skip this row and will not update.'.format(ctx.reader.line_num, len(matches), each[required_key]), 3) 257 | continue 258 | else: 259 | echo_log('[{0}] WARNING: Could not match "{1}" to a {2}.'.format(ctx.reader.line_num, each[required_key], resource_hr), 3) 260 | else: # matches_response.status_code != 200 261 | echo_log('[{0}] ERROR: Could not load XML for "{1}". Encountered HTTP error {2}. Will skip this row and will not update.'.format(ctx.reader.line_num, each[required_key], matches_response.status_code), 3) 262 | else: 263 | resource_tree = get_jss_resource(each["id"]) 264 | if resource_tree is not None: 265 | echo_log('[{0}] Successfully keyed ID #{1} to {2}.'.format(ctx.reader.line_num, each['id'], resource_hr), 1) 266 | matched_object = resource_tree 267 | if matched_object is not None: 268 | record_updated = False 269 | for field in ctx.reader.fieldnames: 270 | if field != required_key and update_element(matched_object, field, each[field]): 271 | record_updated = True 272 | ctx.fields_mutated += 1 273 | if record_updated == True: 274 | ctx.records_mutated += 1 275 | if ctx.dry == False: 276 | resource_id = get_resource_id(matched_object) 277 | if resource_id: 278 | put_jss_resource(resource_id, ET.tostring(matched_object)) 279 | else: 280 | echo_log('[{0}] WARNING: Could not find resource id for {1}.'.format(ctx.reader.line_num, resource_hr), 3) 281 | 282 | 283 | else: 284 | echo_log('ERROR: Required key "{0}" could not be found in CSV.'.format(required_key), 4) 285 | exit(1) 286 | else: 287 | echo_log('ERROR: Could not load fieldnames from CSV file. Please check your update file.', 4) 288 | 289 | @click.pass_obj 290 | def import_from_csv(ctx, file): 291 | ctx.reader = csv.DictReader(file) 292 | ctx.records_mutated = 0 293 | if ctx.reader.fieldnames: 294 | echo_log('Importing {1}s with fields: {2}'.format(ctx.reader.line_num, resource_hr, ctx.reader.fieldnames), 2) 295 | for each in ctx.reader: 296 | resource_tag = ET.Element(resource_el) 297 | for field in ctx.reader.fieldnames: 298 | field_tag = ET.SubElement(resource_tag, field) 299 | field_tag.text = each[field] 300 | if ctx.dry == False: 301 | if post_jss_resource(ctx.reader.fieldnames[0], each[ctx.reader.fieldnames[0]], ET.tostring(resource_tag)): 302 | ctx.records_mutated += 1 303 | else: 304 | if ctx.verbose: 305 | echo_log('[{0}] Would have imported {1}: {2}'.format(ctx.reader.line_num, resource_hr, each), 1) 306 | else: 307 | echo_log('[{0}] Would have imported a {1} with {2} "{3}".'.format(ctx.reader.line_num, resource_hr, ctx.reader.fieldnames[0], each[ctx.reader.fieldnames[0]]), 2) 308 | 309 | else: 310 | echo_log('ERROR: Could not load fieldnames from CSV file. Please check your import file.', 4) 311 | 312 | 313 | def export_to_csv(type, fields, file): 314 | with file as csvfile: 315 | writer = csv.writer(csvfile, delimiter=',', quotechar='"', quoting=csv.QUOTE_MINIMAL) 316 | detailed_export_fields = [] 317 | if type == 'users': 318 | detailed_export_fields.extend([ 'full_name', 'email', 'phone_number', 'position' ] ) 319 | elif type == 'computers': 320 | detailed_export_fields.extend([ 'general/asset_tag', 'hardware/model', 'general/serial_number', 'general/mac_address', 'general/alt_mac_address', 'hardware/total_ram']) 321 | elif type == 'mobiledevices': 322 | detailed_export_fields.extend([ 'general/asset_tag', 'general/os_version', 'network/imei']) 323 | echo_log('Loaded built in export fields for {0}s: {1}.'.format(resource_hr, detailed_export_fields), 1) 324 | if (len(fields) > 0): 325 | # add custom fields from arguments 326 | detailed_export_fields.extend(fields) 327 | echo_log('Added custom export fields: {0}.'.format(fields), 1) 328 | all_resources = get_all_jss_resources() 329 | all_resources_list = all_resources.findall("./"+resource_el) 330 | if len(all_resources_list) > 0: 331 | # build a header 332 | header = [] 333 | for element in all_resources_list[0].iter(): 334 | header.append(element.tag) 335 | # FIX - this is where we would add our custom headers 336 | if len(detailed_export_fields) > 0: 337 | header.extend(detailed_export_fields) 338 | # pop the enclosing resource tag 339 | header.pop(0) 340 | writer.writerow(header) 341 | # build each row 342 | for simple_resource in all_resources_list: 343 | row = [] 344 | for element in simple_resource.iter(): 345 | row.append(element.text) 346 | # check to see if we have custom fields and need to load the actual resource 347 | if len(detailed_export_fields) > 0: 348 | # we have custom fields to map to the resource 349 | detailed_resource = get_jss_resource(simple_resource.find('./id').text) 350 | for detailed_export_field in detailed_export_fields: 351 | matched_element = detailed_resource.find(find_path_for_field(detailed_export_field)) 352 | if hasattr(matched_element, 'text'): 353 | row.append(matched_element.text) 354 | else: 355 | echo_log('WARNING: Could not find an element matching "{0}" for {1} ID#{2}.'.format(detailed_export_field, resource_hr, simple_resource.find('./id').text), 3) 356 | row.append('"Not Found"') 357 | # pop the enclosing resource tag 358 | row.pop(0) 359 | writer.writerow(row) 360 | echo_log('Wrote {0} with "{1}" "{2}" to CSV.'.format(resource_hr, header[0], row[0], resource_hr), 1) 361 | echo_log('Exported {0} {1}s.'.format(len(all_resources), resource_hr), 2) 362 | exit() 363 | else: 364 | echo_log('Your JSS does not have any {0}s defined.'.format(resource_hr), 3) 365 | 366 | def get_resource_id(resource): 367 | if resource.tag == "mobile_device": 368 | id_element = resource.find("general").find("id") 369 | elif resource.tag == "user": 370 | id_element = resource.find("id") 371 | if id_element is not None: 372 | return id_element.text 373 | 374 | @click.pass_obj 375 | def get_all_jss_resources(ctx): 376 | # echo_log('Fetching master list of {0}s from the JSS ({1}/JSSResource/{2}).'.format(resource_hr, ctx.base_url, resource_ep), 1) # debug line 377 | all_resources_response = requests.get('{0}/JSSResource/{1}'.format(ctx.base_url, resource_ep), auth=(ctx.username, ctx.password), verify=True) 378 | if all_resources_response.status_code == 200: 379 | all_resources_tree = ET.fromstring(all_resources_response.content) 380 | echo_log('Successfully fetched {0} {1}s.'.format(len(all_resources_tree), resource_hr), 1) 381 | return all_resources_tree 382 | else: 383 | echo_log('ERROR: could not fetch a list of {0}s. Encountered HTTP status {1}.'.format(resource_hr, all_resources_response.status_code), 4) 384 | return 385 | 386 | @click.pass_obj 387 | def get_jss_resource(ctx, resource_id): 388 | # echo_log('Will GET detailed {0} record from the JSS ({1}/JSSResource/{2}/id/{3}).'.format(resource_hr, ctx.base_url, resource_ep, resource_id), 1) # debug line 389 | resource_response = requests.get('{0}/JSSResource/{1}/id/{2}'.format(ctx.base_url, resource_ep, resource_id), auth=(ctx.username, ctx.password), verify=True) 390 | if resource_response.status_code == 200: 391 | resource_tree = ET.fromstring(resource_response.content) 392 | return resource_tree 393 | else: 394 | echo_log('[{0}] ERROR: could not fetch a {1} with ID # {2}. Encountered HTTP status {3}.'.format(current_line, resource_hr, resource_id, resource_response.status_code), 4) 395 | return 396 | 397 | @click.pass_obj 398 | def put_jss_resource(ctx, resource_id, xml_string): 399 | # echo_log('Will PUT updated {0} record to the JSS ({1}/JSSResource/{2}/id/{3}).'.format(resource_hr, ctx.base_url, resource_ep, resource_id), 1) # debug line 400 | put_resource_response = requests.put('{0}/JSSResource/{1}/id/{2}'.format(ctx.base_url, resource_ep, resource_id), auth=(ctx.username, ctx.password), data=xml_string, verify=True) 401 | if put_resource_response.status_code == 201: 402 | echo_log('[{0}] Successfully updated {1} ID #{2}.'.format(ctx.reader.line_num, resource_hr, resource_id), 2) 403 | return True 404 | else: 405 | echo_log('[{0}] ERROR: could not update a {1} with ID # {2}. Encountered HTTP status {3}.'.format(ctx.reader.line_num, resource_hr, resource_id, put_resource_response.status_code), 4) 406 | return False 407 | 408 | @click.pass_obj 409 | def post_jss_resource(ctx, resource_field_name, resource_field_value, xml_string): 410 | # echo_log('Will POST {0} record to the JSS ({1}/JSSResource/{2}/id/{3}).'.format(resource_hr, ctx.base_url, resource_ep, resource_id), 1) # debug line 411 | post_resource_response = requests.post('{0}/JSSResource/{1}'.format(ctx.base_url, resource_ep), auth=(ctx.username, ctx.password), data=xml_string, verify=True) 412 | if post_resource_response.status_code == 201: 413 | echo_log('[{0}] Successfully imported {1} with {2} "{3}".'.format(ctx.reader.line_num, resource_hr, resource_field_name, resource_field_value), 2) 414 | return True 415 | elif post_resource_response.status_code == 409: 416 | echo_log('[{0}] WARNING: Could not import a {1} with {2} "{3}". Error 409 indicates that this resource is a duplicate.'.format(ctx.reader.line_num, resource_hr, resource_field_name, resource_field_value), 3) 417 | return False 418 | else: 419 | echo_log('[{0}] ERROR: Could not import a {1} with {2} "{3}". Encountered HTTP status {4}.'.format(ctx.reader.line_num, resource_hr, resource_field_name, resource_field_value, post_resource_response.status_code), 4) 420 | return False 421 | 422 | if __name__ == '__main__': 423 | main() 424 | --------------------------------------------------------------------------------