├── LICENSE.txt ├── README.md ├── config.py ├── jsslib.py ├── main.py ├── manifests.py └── promoter.py /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Bryson Tyrrell 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 13 | all 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 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # promoter 2 | 3 | A Python module for migrating objects between two JSSs (*JAMF Software Server*) via the REST API. 4 | 5 | `promoter` was first shown during a remote presentation for [Macbrained.org](http://macbrained.org/recap-may-quantcast/) 6 | 7 | ## So, really important here... 8 | 9 | I am commiting the current **WORK-IN-PROGRESS** code for the promoter module. This is in no way ready to use as a part of any production workflow. To stress that again: 10 | 11 | **DO NOT USE THIS IN PRODUCTION!!!** 12 | 13 | In the module's current form it is capable of cleaning out a JSS via the REST API and then migrating objects over to replicate a source (as much as is possible via the REST API). There are a number of features that are not yet complete including: 14 | 15 | * Better error handling (JAMF Cloud has been throwing 504 GATEWAY_TIMEOUT errors at me for the sheer number of HTTP requests I could be making) 16 | * Actual encoding handling (right now in my example main.py script I have a hack to reload the Python environment's default encoding as UTF-8 - this really should be handled by the JSS class) 17 | * `dependencies` as a part of the object manifests (see below for how the manifests fit in) 18 | * `jsslib.py` does not cover every endpoint in the JSS REST API 19 | * `jsslib.py` needs to be version aware (API endpoints have made numerous changes in every update since 9.0 was released) 20 | * Fixes to some of the functions in `promoter.py` (insert_override_element() needs to be split up, additional functions to support single object promotion and the inclusion of `dependencies` in the manifests) 21 | * Allowing the user to pass a custom manifest object that will be used in place of the default included manifest (which should be treated more as a 'default' state and not modified) 22 | * Multi-threading API operations for speed 23 | * Lots of other things I'm not remmebering... 24 | 25 | As you can see from this short list there is a lot that is not done yet. So, again, please do not use this in any production workflow at this time. 26 | 27 | ## What does promoter do? 28 | 29 | There are a lot of solutions out there for migrating objects between two JSSs and are usually either restores of a MySQL database (if you have access) or using the API. I've seen a lot of great examples of both types. In my case, my JSS environment is entirely hosted (on JAMF Cloud) and any type of replication or copying of objects would have to be via the REST API as I don't have access to the database. 30 | 31 | `promoter` was written to allow me to replicate - as much as possible - my production JSS to another hosted JSS to perform testing or QA work. 32 | 33 | In addition to being able to replicate these objects through the API I also wanted to be able to selectively manipulate the data I was moving. There are elements in the data I would want to remove, others change and some I would want to insert. Having to manually modify items in the interface to complete my replicated enviroment, but point to test resources, was a cumbersome prospect. My goal was to effectively be able to push a button that would replicate my produciton JSS to any other JSS I pointed to and have it be ready to go using test accounts (like for LDAP) and distribution servers. 34 | 35 | ### Manifests 36 | 37 | Automating the manipulation of the API's XML data as it comes down is handled in promoter through the use of `manifests`. A manifest is a mapping of element paths for a JSS object and dictates the actions that are performed on those elements when promoter is running. 38 | 39 | For example, here are the `computers` and `ldap_servers` manifests: 40 | 41 | ``` 42 | manifests = { 43 | ... 44 | 'computers': { 45 | 'exclude': [ 46 | 'general/remote_management/management_password_md5', 47 | 'general/remote_management/management_password_sha256', 48 | 'general/distribution_point', 49 | 'general/sus', 50 | 'general/netboot_server', 51 | 'peripherals', 52 | 'groups_accounts/computer_group_memberships', 53 | 'configuration_profiles', 54 | 'purchasing/attachments' 55 | ], 56 | 'override': {}, 57 | 'inject': {'general/remote_management/management_password': 'jamfsoftware'}, 58 | 'collections': [ 59 | 'extension_attributes', 60 | 'configuration_profiles' 61 | ] 62 | }, 63 | ... 64 | 'ldap_servers': { 65 | 'exclude': [ 66 | 'account/password_md5', 67 | 'account/password_sha256' 68 | ], 69 | 'override': { 70 | 'connection/account/distinguished_username': 71 | 'CN=Captain Kirk,OU=Test Accounts,OU=Staff' 72 | }, 73 | 'inject': {'connection/account/password': 'GoClimbARock'}, 74 | 'collections': [] 75 | }, 76 | ... 77 | ``` 78 | 79 | A manifest is a Python dictionary that contains five keys: 80 | 81 | ##### dependencies 82 | 83 | **This is not yet written into promoter** 84 | 85 | The `dependencies` will be a list of element paths where promoter will look for linked JSS objects (like `categories`), see if they exist on the destination and promote those objects if not. This is a requirement for getting single object promotion working. 86 | 87 | ##### exclude 88 | 89 | These are elements (or as show above paths to elements) that should be removed from the XML as it is being processed by promoter. This is useful for preventing conflicts and/or removing choice data so that it does not appear in a non-production environment. 90 | 91 | ##### override 92 | 93 | These are pre-existing elements that have their values replaced with new values. In the `ldap_servers` manifest the existing value for the `distinguished_username` it being replaced with one for a test account. 94 | 95 | In this example the same LDAP server is being used but a different account for accessing it than production. 96 | 97 | ##### inject 98 | 99 | These are new elements to insert into the XML data. Again, for `ldap_servers` an element containing the password for the account that replaced the production username is being injected so the connection is live immediately after the data is POSTed. 100 | 101 | ##### collections 102 | 103 | These are groups within the XML that point to other JSS objects. promoter will remove the `id` tags for each item in the group so when the data is POSTed the JSS will handle linking to the correction object based on name matching. 104 | 105 | ### API account permissions 106 | 107 | While there is a `read_only=True` flag that can be set in `jsslib.py` it would be much safer to setup your API user in your source (usually production) JSS as an *Auditor* which gives full read access but no permissions for creating, updating or deleting. 108 | 109 | The target JSS account should be a full admin to avoid issues with creating new objects. 110 | 111 | At this time `promoter` does not check the permissions of the account used to authenticate, but that is something that is planned. 112 | 113 | ## Why post promoter now? 114 | 115 | Mostly to prove it isn't vaporware. The `main.py` file is an example script using the current version of the `promoter` module to clean out a target JSS and then begin replicating objects via the API. This example migrates all objects in a specific order to satify dependencies. 116 | 117 | The next big step for `promoter` is going to be building in the dependency pieces which will allow individual objects to be moved without having to manually satisfy the requirements of other objects they are linked to or strip out tons of additional data to make it work. 118 | 119 | If you're curious about what the project does, how it works in its current form and want to provide some feedback on the direction it's going please do so with a copy of this source. You can reach out to me here, on Slack or Twitter. 120 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | """Configuration settings for promoter""" 2 | config = { 3 | 'source_jss': { 4 | 'url': 'https://source.jss.com', 5 | 'username': '', 6 | 'password': '' 7 | }, 8 | 'target_jss': { 9 | 'url': 'https://target.jss.com', 10 | 'username': '', 11 | 'password': '' 12 | } 13 | } -------------------------------------------------------------------------------- /jsslib.py: -------------------------------------------------------------------------------- 1 | """A simple wrapper for the JSS REST API""" 2 | import logging 3 | import requests 4 | import xml.etree.ElementTree as etree 5 | 6 | __author__ = 'brysontyrrell' 7 | __version__ = '1.0' 8 | 9 | 10 | class JSS(object): 11 | """ 12 | An object for interacting with the JSS REST API 13 | 14 | set 'read_only' to True to only allow GET requests with the API 15 | calls to POST, PUT and DELETE will return a None value 16 | 17 | set 'return_json' to True to have GET requests return JSON instead of XML 18 | the JSS API can only accept XML for POST and PUT requests 19 | 20 | The HTTP method is inferred by the values passed to the resource 21 | 22 | GET: provide no value for 'id_name' or pass an integer (id) or string (name) 23 | POST: provide 'data' in string format or an ElementTree.Element object 24 | PUT: provide a value for 'id_name" and 'data' in string format or an ElementTree.Element object 25 | DELETE: provide a value for 'id_name' and pass 'delete=True' 26 | 27 | TODO: 28 | _update_only_object() 29 | Objects that only support GET, PUT requests 30 | /activationcode 31 | /byoprofiles 32 | /computercheckin 33 | /gsxconenction 34 | /smtpserver 35 | 36 | _invitation_object() 37 | Invitations support GET, POST, DELETE requests 38 | /computerinvitations 39 | /mobiledeviceinvitations 40 | 41 | _file_upload() 42 | Need to design use of this endpoint - add-on to existing methods that support fileupload? 43 | /fileupload 44 | 45 | _subset_object() 46 | Build subset support for objects that support it 47 | 48 | Add objects that are not implemented 49 | 50 | Add exceptions 51 | """ 52 | def __init__(self, url, username, password, read_only=False, return_json=False): 53 | """Initialize the JSS class""" 54 | self._session = requests.Session() 55 | self._session.auth = (username, password) 56 | self._url = '{}/JSSResource'.format(url) 57 | self._read_only = read_only 58 | self.version = self._get_version() 59 | self._content_header = {"Content-Type": "text/xml"} 60 | self._accept_header = {"Accept": "application/xml"} if not return_json else {"Accept": "application/json"} 61 | 62 | def _get_version(self): 63 | """Returns the version of the JSS (uses deprecated API)""" 64 | resp = self._session.get('{}/jssuser'.format(self._url)) 65 | resp.raise_for_status() 66 | return etree.fromstring(resp.text).findtext('version') 67 | 68 | @staticmethod 69 | def _is_int(value): 70 | """Tests a value to determine if it should be treated as an integer or string""" 71 | try: 72 | int(value) 73 | except ValueError: 74 | return False 75 | except TypeError: 76 | raise Exception 77 | else: 78 | return True 79 | 80 | @staticmethod 81 | def _return_list(xml, list_value): 82 | """Returns a list of ids for a collection""" 83 | id_list = [int(i.findtext('id')) for i in etree.fromstring(xml).findall(list_value)] 84 | id_list.sort() 85 | return id_list 86 | 87 | @staticmethod 88 | def _return_group_list_filtered(xml, list_value, group_filter): 89 | """Returns a list of ids for a group collection with an optional filter for only 'smart' and 'static'""" 90 | id_list = list() 91 | # It can be assumed that the only other possible value is 'static' - see _get() 92 | match = 'true' if group_filter == 'smart' else 'false' 93 | for i in etree.fromstring(xml).findall(list_value): 94 | if i.findtext('is_smart') == match: 95 | id_list.append(int(i.findtext('id'))) 96 | 97 | id_list.sort() 98 | return id_list 99 | 100 | @staticmethod 101 | def _element_check(data): 102 | """Checks if a value is an xml.etree.ElementTree.Element object and returns a string""" 103 | if isinstance(data, etree.Element): 104 | logging.debug("attempting to convert to xml string") 105 | return etree.tostring(data) 106 | else: 107 | return data 108 | 109 | def _append_id_name(self, url, value): 110 | """Appends '/id/value' or '/name/value' to a url""" 111 | return '{}/id/{}'.format(url, value) if self._is_int(value) else '{}/name/{}'.format(url, value) 112 | 113 | def _get(self, url, list_value=None, group_filter=None): 114 | """REST API GET request 115 | returns a list of ids (as integers) for a collection 116 | returns a string (xml text) for single objects""" 117 | logging.debug('GET: {}'.format(url)) 118 | resp = self._session.get(url, headers=self._accept_header) 119 | resp.raise_for_status() 120 | if list_value and group_filter is None: 121 | logging.debug("returning id list for collection") 122 | return self._return_list(resp.text, list_value) 123 | elif list_value and group_filter: 124 | logging.debug("returning filtered list of ids for group collection") 125 | return self._return_group_list_filtered(resp.text, list_value, group_filter) 126 | else: 127 | return resp.text 128 | 129 | def _post(self, url, xml): 130 | """REST API POST request 131 | returns the id of the created resource 132 | returns None if 'read_only' is True""" 133 | data = self._element_check(xml) 134 | logging.debug('POST: {}'.format(url)) 135 | logging.debug('DATA: {}'.format(data)) 136 | if self._read_only: 137 | logging.info("api read_only is enabled") 138 | return None 139 | 140 | url += '/id/0' 141 | resp = self._session.post(url, data, headers=self._content_header) 142 | resp.raise_for_status() 143 | return etree.fromstring(resp.text).findtext('id') 144 | 145 | def _put(self, url, xml): 146 | """REST API PUT request 147 | returns the id of the updated resource 148 | returns None if 'read_only' is True""" 149 | data = self._element_check(xml) 150 | logging.debug('PUT: {}'.format(url)) 151 | logging.debug('DATA: {}'.format(data)) 152 | if self._read_only: 153 | logging.info("api read_only is enabled") 154 | return None 155 | 156 | resp = self._session.put(url, data, headers=self._content_header) 157 | resp.raise_for_status() 158 | return etree.fromstring(resp.text).findtext('id') 159 | 160 | def _delete(self, url): 161 | """REST API DELETE request 162 | returns the id of the deleted resource 163 | returns None if 'read_only' is True""" 164 | logging.debug('DELETE: {}'.format(url)) 165 | if self._read_only: 166 | logging.info("api read_only is enabled") 167 | return None 168 | 169 | resp = self._session.delete(url) 170 | resp.raise_for_status() 171 | return etree.fromstring(resp.text).findtext('id') 172 | 173 | def _standard_object(self, **kwargs): 174 | """Method for interacting with most objects""" 175 | id_name = kwargs.pop('id_name') 176 | data = kwargs.pop('data') 177 | delete = kwargs.pop('delete') 178 | path = kwargs.pop('path') 179 | list_value = kwargs.pop('list_value') 180 | obj_url = '{}/{}'.format(self._url, path) 181 | if not (id_name or data or delete): 182 | return self._get(obj_url, list_value) 183 | elif data and not (id_name or delete): 184 | return self._post(obj_url, data) 185 | else: 186 | obj_url = self._append_id_name(obj_url, id_name) 187 | if id_name and not (data or delete): 188 | return self._get(obj_url) 189 | elif id_name and data and not delete: 190 | return self._put(obj_url, data) 191 | elif id_name and delete and not data: 192 | return self._delete(obj_url) 193 | else: 194 | raise Exception 195 | 196 | def _group_object(self, **kwargs): 197 | """Method for interacting with group objects""" 198 | id_name = kwargs.pop('id_name') 199 | data = kwargs.pop('data') 200 | delete = kwargs.pop('delete') 201 | path = kwargs.pop('path') 202 | list_value = kwargs.pop('list_value') 203 | obj_url = '{}/{}'.format(self._url, path) 204 | if not (id_name or data or delete): 205 | group_filter = kwargs.pop('group_filter') 206 | if group_filter not in ('smart', 'static', None): 207 | logging.debug("invalid filter: must be 'smart', 'static' or None") 208 | raise Exception 209 | 210 | return self._get(obj_url, list_value, group_filter) 211 | elif data and not (id_name or delete): 212 | return self._post(obj_url, data) 213 | else: 214 | obj_url = self._append_id_name(obj_url, id_name) 215 | if id_name and not (data or delete): 216 | return self._get(obj_url) 217 | elif id_name and data and not delete: 218 | return self._put(obj_url, data) 219 | elif id_name and delete and not data: 220 | return self._delete(obj_url) 221 | else: 222 | raise Exception 223 | 224 | def buildings(self, id_name=None, data=None, delete=False): 225 | """/JSSResource/buildings""" 226 | return self._standard_object(id_name=id_name, data=data, delete=delete, path='buildings', list_value='building') 227 | 228 | def categories(self, id_name=None, data=None, delete=False): 229 | """/JSSResource/categories""" 230 | return self._standard_object(id_name=id_name, data=data, delete=delete, path='categories', 231 | list_value='category') 232 | 233 | def computers(self, id_name=None, data=None, delete=False): 234 | """/JSSResource/computers""" 235 | return self._standard_object(id_name=id_name, data=data, delete=delete, path='computers', list_value='computer') 236 | 237 | def computer_extension_attributes(self, id_name=None, data=None, delete=False): 238 | """/JSSResource/computerextensionattributes""" 239 | return self._standard_object(id_name=id_name, data=data, delete=delete, path='computerextensionattributes', 240 | list_value='computer_extension_attribute') 241 | 242 | def computer_groups(self, id_name=None, data=None, delete=False, group_filter=None): 243 | """ 244 | /JSSResource/computergroups 245 | group_filter: 'smart', 'static' or None 246 | """ 247 | return self._group_object(id_name=id_name, data=data, delete=delete, path='computergroups', 248 | list_value='computer_group', group_filter=group_filter) 249 | 250 | def departments(self, id_name=None, data=None, delete=False): 251 | """/JSSResource/departments""" 252 | return self._standard_object(id_name=id_name, data=data, delete=delete, path='departments', 253 | list_value='department') 254 | 255 | def ebooks(self, id_name=None, data=None, delete=False): 256 | """/JSSResource/ebooks""" 257 | return self._standard_object(id_name=id_name, data=data, delete=delete, path='ebooks', list_value='ebook') 258 | 259 | def ibeacons(self, id_name=None, data=None, delete=False): 260 | """/JSSResource/ibeacons""" 261 | return self._standard_object(id_name=id_name, data=data, delete=delete, path='ibeacons', list_value='ibeacon') 262 | 263 | def ldap_servers(self, id_name=None, data=None, delete=False): 264 | """/JSSResource/ldapservers""" 265 | return self._standard_object(id_name=id_name, data=data, delete=delete, path='ldapservers', 266 | list_value='ldap_server') 267 | 268 | def mac_applications(self, id_name=None, data=None, delete=False): 269 | """/JSSResource/macapplications""" 270 | return self._standard_object(id_name=id_name, data=data, delete=delete, path='macapplications', 271 | list_value='mac_application') 272 | 273 | def mobile_device_applications(self, id_name=None, data=None, delete=False): 274 | """/JSSResource/mobiledeviceapplications""" 275 | return self._standard_object(id_name=id_name, data=data, delete=delete, path='mobiledeviceapplications', 276 | list_value='mobile_device_application') 277 | 278 | def mobile_device_configuration_profiles(self, id_name=None, data=None, delete=False): 279 | """/JSSResource/mobiledeviceconfigurationprofiles""" 280 | return self._standard_object(id_name=id_name, data=data, delete=delete, 281 | path='mobiledeviceconfigurationprofiles', list_value='configuration_profile') 282 | 283 | def mobile_device_extension_attributes(self, id_name=None, data=None, delete=False): 284 | """/JSSResource/mobiledeviceextensionattributes""" 285 | return self._standard_object(id_name=id_name, data=data, delete=delete, path='mobiledeviceextensionattributes', 286 | list_value='mobile_device_extension_attribute') 287 | 288 | def mobile_device_groups(self, id_name=None, data=None, delete=False, group_filter=None): 289 | """ 290 | /JSSResource/mobiledevicegroups 291 | group_filter: 'smart', 'static' or None 292 | """ 293 | return self._group_object(id_name=id_name, data=data, delete=delete, path='mobiledevicegroups', 294 | list_value='mobile_device_group', group_filter=group_filter) 295 | 296 | def mobile_devices(self, id_name=None, data=None, delete=False): 297 | """/JSSResource/mobiledevices""" 298 | return self._standard_object(id_name=id_name, data=data, delete=delete, path='mobiledevices', 299 | list_value='mobile_device') 300 | 301 | def network_segments(self, id_name=None, data=None, delete=False): 302 | """/JSSResource/networksegments""" 303 | return self._standard_object(id_name=id_name, data=data, delete=delete, path='networksegments', 304 | list_value='network_segment') 305 | 306 | def os_x_configuration_profiles(self, id_name=None, data=None, delete=False): 307 | """/JSSResource/osxconfigurationprofiles""" 308 | return self._standard_object(id_name=id_name, data=data, delete=delete, path='osxconfigurationprofiles', 309 | list_value='os_x_configuration_profile') 310 | 311 | def packages(self, id_name=None, data=None, delete=False): 312 | """/JSSResource/packages""" 313 | return self._standard_object(id_name=id_name, data=data, delete=delete, path='packages', list_value='package') 314 | 315 | def peripherals(self, id_name=None, data=None, delete=False): 316 | """/JSSResource/buildings""" 317 | return self._standard_object(id_name=id_name, data=data, delete=delete, path='peripherals', 318 | list_value='peripheral') 319 | 320 | def peripheral_types(self, id_name=None, data=None, delete=False): 321 | """/JSSResource/peripheraltypes""" 322 | return self._standard_object(id_name=id_name, data=data, delete=delete, path='peripheraltypes', 323 | list_value='peripheral_type') 324 | 325 | def policies(self, id_name=None, data=None, delete=False): 326 | """/JSSResource/policies""" 327 | return self._standard_object(id_name=id_name, data=data, delete=delete, path='policies', list_value='policy') 328 | 329 | def printers(self, id_name=None, data=None, delete=False): 330 | """/JSSResource/printers""" 331 | return self._standard_object(id_name=id_name, data=data, delete=delete, path='printers', list_value='printer') 332 | 333 | def scripts(self, id_name=None, data=None, delete=False): 334 | """/JSSResource/scripts""" 335 | return self._standard_object(id_name=id_name, data=data, delete=delete, path='scripts', list_value='script') 336 | 337 | def user_extension_attributes(self, id_name=None, data=None, delete=False): 338 | """/JSSResource/userextensionattributes""" 339 | return self._standard_object(id_name=id_name, data=data, delete=delete, path='userextensionattributes', 340 | list_value='user_extension_attribute') 341 | 342 | def user_groups(self, id_name=None, data=None, delete=False, group_filter=None): 343 | """ 344 | /JSSResource/usergroups 345 | group_filter: 'smart', 'static' or None 346 | """ 347 | return self._group_object(id_name=id_name, data=data, delete=delete, path='usergroups', 348 | list_value='user_group', group_filter=group_filter) 349 | 350 | def users(self, id_name=None, data=None, delete=False): 351 | """/JSSResource/users""" 352 | return self._standard_object(id_name=id_name, data=data, delete=delete, path='users', list_value='user') 353 | 354 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | from config import config 2 | import jsslib 3 | import logging 4 | import promoter 5 | import sys 6 | 7 | __author__ = 'brysontyrrell' 8 | 9 | if sys.getdefaultencoding() != 'utf-8': 10 | reload(sys) 11 | sys.setdefaultencoding('utf-8') 12 | 13 | logging.basicConfig(level=logging.DEBUG) 14 | 15 | 16 | def main(): 17 | # Configuration values are set in config.py 18 | src_cfg = config['source_jss'] 19 | logging.info("Source JSS: {}".format(src_cfg['url'])) 20 | trg_cfg = config['target_jss'] 21 | logging.info("Target JSS: {}".format(trg_cfg['url'])) 22 | 23 | source_jss = jsslib.JSS(src_cfg['url'], src_cfg['username'], src_cfg['password'], read_only=True) 24 | target_jss = jsslib.JSS(trg_cfg['url'], trg_cfg['username'], trg_cfg['password']) 25 | 26 | logging.info("prepping target jss") 27 | promoter.clean_jss(target_jss) 28 | promoter.promote_jss(source_jss, target_jss) 29 | 30 | if __name__ == '__main__': 31 | main() -------------------------------------------------------------------------------- /manifests.py: -------------------------------------------------------------------------------- 1 | """ 2 | Manifests are used by promoter to detail which sub-elements of an object's XML are to be included in a new XML 3 | document. 4 | 5 | Here is the default layout for a manifest: 6 | { 7 | objectName: { 8 | 'exclude': [ 9 | Element/Path1, 10 | Element/Path2 11 | ], 12 | 'override': { 13 | Element/Path1: NewValue, 14 | Element/Path2: NewValue 15 | }, 16 | 'inject': { 17 | NewElement/Path1: Value, 18 | NewElement/Path2: Value 19 | }, 20 | 'collections': [ 21 | Element/Path1, 22 | Element/Path2, 23 | ] 24 | } 25 | } 26 | 27 | Elements in the 'exclude' list will be omitted. 28 | Elements in the 'override' dictionary must not be in the 'exclude' list or they will be skipped. 29 | New elements can be injected into the output XML by passing the path and value in the 'inject' dictionary 30 | (e.g. can be used to pass a password with the XML when POSTing to a resource) 31 | Objects that contain collections referencing other objects can be listed in the 'collections' list 32 | These collections will have all 'id' elements removed 33 | 34 | If there is no manifest in this dictionary the object would be copies as-is. 35 | 36 | The three global_ variables are applied to all objects. 37 | For example: including 'id' and 'site' in the global_exclusions list will remove those elements from 38 | all ElementTree.Element objects that are processed 39 | """ 40 | __author__ = 'brysontyrrell' 41 | 42 | global_exclusions = [ 43 | 'general/id', 44 | 'general/site', 45 | 'general/category/id', 46 | 'id', 47 | 'site' 48 | ] 49 | 50 | global_overrides = { 51 | 'location/phone': '612-605-6625', 52 | 'location/building': 'Minneapolis', 53 | 'location/room': '301 4th Ave S' 54 | } 55 | 56 | global_injections = {} 57 | 58 | global_collections = {} 59 | 60 | manifests = { 61 | 'computer_groups': { 62 | 'exclude': ['computers'], 63 | 'override': {}, 64 | 'inject': {}, 65 | 'collections': [] 66 | }, 67 | 'computers': { 68 | 'exclude': [ 69 | 'general/remote_management/management_password_md5', 70 | 'general/remote_management/management_password_sha256', 71 | 'general/distribution_point', 72 | 'general/sus', 73 | 'general/netboot_server', 74 | 'peripherals', 75 | 'groups_accounts/computer_group_memberships', 76 | 'configuration_profiles', 77 | 'purchasing/attachments' 78 | ], 79 | 'override': {}, 80 | 'inject': {'general/remote_management/management_password': 'jamfsoftware'}, 81 | 'collections': [ 82 | 'extension_attributes', 83 | 'configuration_profiles' 84 | ] 85 | }, 86 | 'ebooks': { 87 | 'exclude': [ 88 | 'general/self_service_icon', 89 | 'self_service/self_service_icon', 90 | 'self_service/self_service_categories', 91 | 'self_service/self_service_icon', 92 | 'vpp_codes' 93 | ], 94 | 'override': {}, 95 | 'inject': {}, 96 | 'collections': [ 97 | 'scope/computers', 98 | 'scope/computer_groups', 99 | 'scope/buildings', 100 | 'scope/departments', 101 | 'scope/mobile_devices', 102 | 'scope/mobile_device_groups', 103 | 'scope/limitations/user_groups', 104 | 'scope/limitations/network_segments', 105 | 'scope/limitations/ibeacons', 106 | 'scope/exclusions/computers', 107 | 'scope/exclusions/computer_groups', 108 | 'scope/exclusions/buildings', 109 | 'scope/exclusions/departments', 110 | 'scope/exclusions/mobile_devices', 111 | 'scope/exclusions/mobile_device_groups', 112 | 'scope/exclusions/user_groups', 113 | 'scope/exclusions/network_segments', 114 | 'scope/exclusions/ibeacons', 115 | 'self_service/self_service_categories' 116 | ] 117 | }, 118 | 'ldap_servers': { 119 | 'exclude': [ 120 | 'account/password_md5', 121 | 'account/password_sha256' 122 | ], 123 | 'override': { 124 | 'connection/account/distinguished_username': 125 | 'CN=Captain Kirk,OU=Test Accounts,OU=Staff' 126 | }, 127 | 'inject': {'connection/account/password': 'GoClimbARock'}, 128 | 'collections': [] 129 | }, 130 | 'mac_applications': { 131 | 'exclude': [ 132 | 'self_service/self_service_categories', 133 | 'self_service/self_service_icon', 134 | 'vpp_codes' 135 | ], 136 | 'override': {}, 137 | 'inject': {}, 138 | 'collections': [ 139 | 'scope/computers', 140 | 'scope/computer_groups', 141 | 'scope/buildings', 142 | 'scope/departments', 143 | 'scope/limitations/user_groups', 144 | 'scope/limitations/network_segments', 145 | 'scope/limitations/ibeacons', 146 | 'scope/exclusions/computers', 147 | 'scope/exclusions/computer_groups', 148 | 'scope/exclusions/buildings', 149 | 'scope/exclusions/departments', 150 | 'scope/exclusions/user_groups', 151 | 'scope/exclusions/network_segments', 152 | 'scope/exclusions/ibeacons' 153 | ] 154 | }, 155 | 'mobile_device_applications': { 156 | 'exclude': [ 157 | 'general/ipa', 158 | 'general/icon', 159 | 'general/provisioning_profile', 160 | 'self_service/self_service_categories', 161 | 'self_service/self_service_icon' 162 | ], 163 | 'override': {}, 164 | 'inject': {}, 165 | 'collections': [ 166 | 'scope/mobile_devices', 167 | 'scope/mobile_device_groups', 168 | 'scope/buildings', 169 | 'scope/departments', 170 | 'scope/limitations/user_groups', 171 | 'scope/limitations/network_segments', 172 | 'scope/limitations/ibeacons', 173 | 'scope/exclusions/mobile_devices', 174 | 'scope/exclusions/mobile_device_groups', 175 | 'scope/exclusions/buildings', 176 | 'scope/exclusions/departments', 177 | 'scope/exclusions/user_groups', 178 | 'scope/exclusions/network_segments', 179 | 'scope/exclusions/ibeacons' 180 | ] 181 | }, 182 | 'mobile_device_configuration_profiles': { 183 | 'exclude': ['self_service/self_service_icon'], 184 | 'override': {}, 185 | 'inject': {}, 186 | 'collections': [ 187 | 'scope/mobile_devices', 188 | 'scope/mobile_device_groups', 189 | 'scope/buildings', 190 | 'scope/departments', 191 | 'scope/limitations/user_groups', 192 | 'scope/limitations/network_segments', 193 | 'scope/limitations/ibeacons', 194 | 'scope/exclusions/mobile_devices', 195 | 'scope/exclusions/mobile_device_groups', 196 | 'scope/exclusions/buildings', 197 | 'scope/exclusions/departments', 198 | 'scope/exclusions/user_groups', 199 | 'scope/exclusions/network_segments', 200 | 'scope/exclusions/ibeacons', 201 | 'self_service/self_service_categories' 202 | ] 203 | }, 204 | 'mobile_device_groups': { 205 | 'exclude': ['mobile_devices'], 206 | 'override': {}, 207 | 'inject': {}, 208 | 'collections': [] 209 | }, 210 | 'mobile_devices': { 211 | 'exclude': [ 212 | 'general/computer', 213 | 'general/phone_number' 214 | 'configuration_profiles', 215 | 'provisioning_profiles', 216 | 'mobile_device_groups', 217 | 'purchasing/attachments' 218 | ], 219 | 'override': {}, 220 | 'inject': {}, 221 | 'collections': ['extension_attributes'] 222 | }, 223 | 'network_segments': { 224 | 'exclude': [], 225 | 'override': {'distribution_server': 'jds.starfleet.corp'}, 226 | 'inject': {}, 227 | 'collections': [] 228 | }, 229 | 'os_x_configuration_profiles': { 230 | 'exclude': ['self_service/self_service_icon'], 231 | 'override': {}, 232 | 'inject': {}, 233 | 'collections': [ 234 | 'scope/computers', 235 | 'scope/computer_groups', 236 | 'scope/buildings', 237 | 'scope/departments', 238 | 'scope/limitations/user_groups', 239 | 'scope/limitations/network_segments', 240 | 'scope/limitations/ibeacons', 241 | 'scope/exclusions/computers', 242 | 'scope/exclusions/computer_groups', 243 | 'scope/exclusions/buildings', 244 | 'scope/exclusions/departments', 245 | 'scope/exclusions/user_groups', 246 | 'scope/exclusions/network_segments', 247 | 'scope/exclusions/ibeacons', 248 | 'self_service/self_service_categories' 249 | ] 250 | }, 251 | 'peripherals': { 252 | 'exclude': [ 253 | 'general/computer_id', 254 | 'location/phone', 255 | 'attachments' 256 | ], 257 | 'override': {}, 258 | 'inject': {}, 259 | 'collections': [] 260 | }, 261 | 'policies': { 262 | 'exclude': [ 263 | 'general/override_default_settings', 264 | 'self_service/self_service_icon', 265 | 'account_maintenance/open_firmware_efi_password', 266 | 'disk_encryption' 267 | ], 268 | 'override': {}, 269 | 'inject': {}, 270 | 'collections': [ 271 | 'scope/computers', 272 | 'scope/computer_groups', 273 | 'scope/buildings', 274 | 'scope/departments', 275 | 'scope/limitations/user_groups', 276 | 'scope/limitations/network_segments', 277 | 'scope/limitations/ibeacons', 278 | 'scope/exclusions/computers', 279 | 'scope/exclusions/computer_groups', 280 | 'scope/exclusions/buildings', 281 | 'scope/exclusions/departments', 282 | 'scope/exclusions/user_groups', 283 | 'scope/exclusions/network_segments', 284 | 'scope/exclusions/ibeacons', 285 | 'self_service/self_service_categories', 286 | 'package_configuration/packages' 287 | ] 288 | }, 289 | 'user_groups': { 290 | 'exclude': ['users'], 291 | 'override': {}, 292 | 'inject': {}, 293 | 'collections': [] 294 | }, 295 | 'users': { 296 | 'exclude': [ 297 | 'sites', 298 | 'links', 299 | 'ldap_server/id' 300 | ], 301 | 'override': {'phone_number': '612-605-6625'}, 302 | 'inject': {}, 303 | 'collections': ['extension_attributes'] 304 | } 305 | } -------------------------------------------------------------------------------- /promoter.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from manifests import manifests, global_exclusions, global_overrides, global_injections, global_collections 3 | from requests.exceptions import HTTPError 4 | import os 5 | import xml.etree.ElementTree as etree 6 | 7 | __author__ = 'brysontyrrell' 8 | 9 | 10 | def clean_jss(jss): 11 | """Iterates over all resources and deletes their objects through the API""" 12 | order_of_operations = [ 13 | # Objects that have scope 14 | 'ebooks', 15 | 'mac_applications', 16 | 'mobile_device_applications', 17 | 'mobile_device_configuration_profiles', 18 | 'network_segments', 19 | 'os_x_configuration_profiles', 20 | 'peripherals', 21 | 'policies', 22 | # Device and user records 23 | 'computers', 24 | 'mobile_devices', 25 | 'users', 26 | # Objects that point to other objects 27 | 'ldap_servers', 28 | 'packages', 29 | 'scripts', 30 | # Groups 31 | 'computer_groups', 32 | 'mobile_device_groups', 33 | 'user_groups', 34 | # Stand-alone objects 35 | 'buildings', 36 | 'categories', 37 | 'computer_extension_attributes', 38 | 'departments', 39 | 'ibeacons', 40 | 'mobile_device_extension_attributes', 41 | 'peripheral_types', 42 | 'printers', 43 | 'user_extension_attributes' 44 | ] 45 | for resource in order_of_operations: 46 | logging.info("removing all objects from /{}".format(resource)) 47 | for i in getattr(jss, resource)(): 48 | getattr(jss, resource)(i, delete=True) 49 | 50 | 51 | def remove_element(root, path): 52 | """Removes an element from an ElementTree.Element object""" 53 | parent, child = os.path.split(path) 54 | try: 55 | root.remove(root.find(child)) if not parent else root.find(parent).remove(root.find(path)) 56 | except (AttributeError, ValueError): 57 | logging.info("the element '{}' was not found".format(child)) 58 | else: 59 | logging.info("the element '{}' was removed".format(child)) 60 | 61 | 62 | def insert_override_element(root, path, value): 63 | """ 64 | Inserts an element into an ElementTree.Element object or changes the value of an existing element 65 | Child elements will be created for the passed path 66 | """ 67 | children = path.split('/') 68 | if root.find(children[0]) is None: 69 | logging.info("creating element '{}'".format(children[0])) 70 | etree.SubElement(root, children[0]) 71 | 72 | for p in range(1, len(children)): 73 | parent_path = '/'.join([children[x] for x in range(p)]) 74 | child_path = '/'.join([children[x] for x in range(p + 1)]) 75 | if root.find(child_path) is None: 76 | logging.info("creating element '{}'".format(children[p])) 77 | etree.SubElement(root.find(parent_path), children[p]) 78 | 79 | root.find(path).text = value 80 | 81 | 82 | def remove_element_from_collection(root, path, element='id'): 83 | """Takes an ElementTree.Element and searches a path for all instances of an element and removes them""" 84 | collection = root.find(path) 85 | if collection is not None: 86 | logging.info("removing '{}' elements from collection: {}".format(element, path)) 87 | for i in collection.getchildren(): 88 | try: 89 | i.remove(i.find(element)) 90 | except ValueError: 91 | logging.info("the element '{}' was not found".format(element)) 92 | 93 | else: 94 | logging.info("the collection '{}' was not found".format(path)) 95 | 96 | 97 | def process_xml(data, obj_type): 98 | """ 99 | Takes an XML string and returns an ElementTree.Element object that has had a manifest applied 100 | If no manifest exists for the object type it returned the ElementTree.Element object 101 | """ 102 | src_root = etree.fromstring(data) 103 | try: 104 | manifest = manifests[obj_type] 105 | except KeyError: 106 | logging.info("there is no manifest for the object: {}".format(obj_type)) 107 | manifest = None 108 | 109 | for element in global_exclusions: 110 | remove_element(src_root, element) 111 | 112 | for element, value in global_overrides.iteritems(): 113 | insert_override_element(src_root, element, value) 114 | 115 | for element, value in global_injections.iteritems(): 116 | insert_override_element(src_root, element, value) 117 | 118 | for element in global_collections: 119 | remove_element_from_collection(src_root, element) 120 | 121 | if manifest: 122 | for element in manifest['exclude']: 123 | remove_element(src_root, element) 124 | 125 | for element, value in manifest['override'].iteritems(): 126 | insert_override_element(src_root, element, value) 127 | 128 | for element, value in manifest['inject'].iteritems(): 129 | insert_override_element(src_root, element, value) 130 | 131 | for element in manifest['collections']: 132 | remove_element_from_collection(src_root, element) 133 | 134 | return src_root 135 | 136 | 137 | def promote_jss(src_jss, trg_jss): 138 | order_of_operations = [ 139 | # Stand-alone objects 140 | 'buildings', 141 | 'categories', 142 | 'computer_extension_attributes', 143 | 'departments', 144 | 'ibeacons', 145 | 'mobile_device_extension_attributes', 146 | 'peripheral_types', 147 | 'printers', 148 | 'user_extension_attributes', 149 | # Objects that point to other objects 150 | 'ldap_servers', 151 | 'packages', 152 | 'scripts', 153 | # Device and user records 154 | 'users', 155 | 'computers', 156 | 'mobile_devices', 157 | # Groups 158 | 'computer_groups', 159 | 'mobile_device_groups', 160 | 'user_groups', 161 | # Objects that have scope 162 | 'ebooks', 163 | 'mac_applications', 164 | 'mobile_device_applications', 165 | 'mobile_device_configuration_profiles', 166 | 'network_segments', 167 | 'os_x_configuration_profiles', 168 | 'peripherals', 169 | 'policies' 170 | ] 171 | for resource in order_of_operations: 172 | logging.info("promoting resource: {}".format(resource)) 173 | for i in getattr(src_jss, resource)(): 174 | xml = getattr(src_jss, resource)(i) 175 | new_object = process_xml(xml, resource) 176 | try: 177 | getattr(trg_jss, resource)(data=new_object) 178 | except HTTPError as e: 179 | if e.response.status_code == 409: 180 | logging.warning(e.message) 181 | logging.debug('response error message: {}'.format(e.response.text)) 182 | logging.warning("the object '{} {}' has not been promoted".format(resource, i)) 183 | --------------------------------------------------------------------------------