From 1158564fac87553e2da45622bde793d8adf14aa5 Mon Sep 17 00:00:00 2001 From: Roman Nyschuk Date: Tue, 4 Jul 2023 22:16:34 +0300 Subject: [PATCH 1/4] D42-29347 Bring up to date with the changes the app has --- d42_sd_sync.py | 620 +++++++++++++++--- freshservice.py | 195 ++++-- mapping.xml.sample | 1504 ++++++++++++++++++++++++++++++++++++++------ requirements.txt | 2 + 4 files changed, 1995 insertions(+), 326 deletions(-) diff --git a/d42_sd_sync.py b/d42_sd_sync.py index d58e9d2..ff0f855 100755 --- a/d42_sd_sync.py +++ b/d42_sd_sync.py @@ -7,7 +7,7 @@ import argparse import datetime from device42 import Device42 -from freshservice import FreshService, FreshServiceDuplicateSerialError +from freshservice import FreshService, FreshServiceDuplicateValueError import xml.etree.ElementTree as eTree from xmljson import badgerfish as bf import time @@ -28,6 +28,7 @@ # The number of seconds to wait before we check the status of create relationships jobs. RELATIONSHIPS_JOB_WAIT_SECONDS = int(math.ceil(RELATIONSHIP_BATCH_SIZE / float(RELATIONSHIPS_CREATED_PER_SECOND))) ASSET_TYPE_BUSINESS_SERVICE = "Business Service" +ASSET_TYPE_SERVER = "Server" parser = argparse.ArgumentParser(description="freshservice") @@ -36,7 +37,9 @@ parser.add_argument('-c', '--config', help='Config file', default='mapping.xml') parser.add_argument('-l', '--logfolder', help='log folder path', default='.') -freshservice = None +freshservice = FreshService() +default_approver = None +fs_cache = dict() class JSONEncoder(json.JSONEncoder): @@ -46,14 +49,39 @@ def default(self, o): return json.JSONEncoder.default(self, o) +def escape_value(name): + if name: + name = name.replace('<', '[') + name = name.replace('>', ']') + # Replace Unicode no-break spaces with normal spaces. + name = name.replace(u'\xa0', ' ') + name = name.strip() + return name + + def find_object_by_name(assets, name): for asset in assets: - if asset["name"].lower() == name.lower(): + if asset["name"].lower() == escape_value(name).lower(): return asset return None +def find_object_in_map(objects_map, name): + if name: + return objects_map.get(escape_value(name).lower()) + + return None + + +def find_object_id_in_map(objects_map, name): + obj = find_object_in_map(objects_map, name) + if obj: + return obj["id"] + + return None + + def get_asset_type_field(asset_type_fields, map_info): for section in asset_type_fields: if section["field_header"] == map_info["@target-header"]: @@ -82,14 +110,15 @@ def get_map_value_from_device42(source, map_info, b_add=False, asset_type_id=Non break else: if "value-mapping" in map_info: - d42_val = None - if isinstance(map_info["value-mapping"]["item"], list): - items = map_info["value-mapping"]["item"] - else: - items = [map_info["value-mapping"]["item"]] - for item in items: - if item["@key"] == d42_value: - d42_val = item["@value"] + cache_key = "%s-%s" % (map_info["@resource"], map_info["@target"]) + if cache_key not in fs_cache: + if isinstance(map_info["value-mapping"]["item"], list): + items = map_info["value-mapping"]["item"] + else: + items = [map_info["value-mapping"]["item"]] + + fs_cache[cache_key] = {item["@key"]: item["@value"] for item in items} + d42_val = fs_cache[cache_key].get(d42_value) if d42_val is None and "@default" in map_info["value-mapping"]: default_value = map_info["value-mapping"]["@default"] @@ -110,76 +139,134 @@ def get_map_value_from_device42(source, map_info, b_add=False, asset_type_id=Non pass if "@target-foregin-key" in map_info: - value = freshservice.get_id_by_name(map_info["@target-foregin"], d42_value) - if b_add and value is None and "@not-null" in map_info and map_info[ - "@not-null"]: # and "@required" in map_info and map_info["@required"] + target_foregin = map_info["@target-foregin"] + if target_foregin not in fs_cache: + fs_cache[target_foregin] = freshservice.get_objects_map("api/v2/%s" % target_foregin, target_foregin, map_info["@target-foregin-key"]) + + value = find_object_id_in_map(fs_cache[target_foregin], d42_value) + if b_add and value is None and "@not-null" in map_info and map_info["@not-null"]: # and "@required" in map_info and map_info["@required"] if d42_value is not None: if "@max-length" in map_info and len(d42_value) > map_info["@max-length"]: name = d42_value[0:map_info["@max-length"] - 3] + "..." else: name = d42_value - if map_info["@target-foregin"] == "vendors": - new_id = freshservice.insert_and_get_id_by_name(map_info["@target-foregin"], name, None) + if target_foregin in ["vendors", "groups", "agents"]: + new_item = freshservice.insert_and_get_by_name(target_foregin, name, None, map_info["@target-foregin-key"]) else: - new_id = freshservice.insert_and_get_id_by_name(map_info["@target-foregin"], name, asset_type_id) - d42_value = new_id + new_item = freshservice.insert_and_get_by_name(target_foregin, name, asset_type_id, map_info["@target-foregin-key"]) + fs_cache[target_foregin][new_item[map_info["@target-foregin-key"]].lower()] = new_item + d42_value = new_item["id"] else: d42_value = None else: # If value is None, that means we could not find a match for the D42 value in Freshservice. # We will return the same D42 value since for product we will call this function again with # the required asset_type_id which is needed to create the value in Freshservice. - if value is not None: + if value is not None or "@not-null" not in map_info: d42_value = value + if "@escape" in map_info and map_info["@escape"]: + d42_value = escape_value(d42_value) + return d42_value +def get_asset_type_field_from_map(asset_type_fields_map, asset_type_id, asset_type_fields, map_info): + if asset_type_id not in asset_type_fields_map: + asset_type_fields_map[asset_type_id] = dict() + + target_header = map_info["@target-header"] if "@target-header" in map_info else "" + key = map_info["@resource"] + "-" + target_header + if key in asset_type_fields_map[asset_type_id]: + asset_type_field = asset_type_fields_map[asset_type_id][key] + else: + asset_type_field = get_asset_type_field(asset_type_fields, map_info) + asset_type_fields_map[asset_type_id][key] = asset_type_field + + return asset_type_field + + +def submit_relationship_create_job(relationships_to_create): + logger.info("adding relationship create job") + # Creating relationships using the v2 API is now an asynchronous operation and is + # performed using background jobs. We will get back the job ID which can then be + # used to query the status of the job. + job_id = freshservice.insert_relationships({"relationships": relationships_to_create}) + logger.info("added new relationship create job %s" % job_id) + + return { + "job_id": job_id, + "relationships_to_create_count": len(relationships_to_create) + } + + def update_objects_from_server(sources, _target, mapping): global freshservice - logger.info("Getting all existing devices in FS.") - existing_objects = freshservice.request(_target["@path"] + "?include=type_fields", "GET", _target["@model"]) - logger.info("Finished getting all existing devices in FS.") + # This method gets called for both devices and business apps. Since it gets called first for devices, + # that is when the assets from Freshservice will get added to the cache. When this method gets called + # for business apps, we can get the objects out of the cache. + if "assets" in fs_cache: + logger.info("Getting all existing assets in FS from cache.") + existing_objects_map = fs_cache["assets"] + logger.info("finished getting all existing assets in FS from cache.") + else: + logger.info("Getting all existing assets in FS.") + existing_objects_map = freshservice.get_objects_map(_target["@path"], _target["@model"]) + logger.info("finished getting all existing assets in FS.") + fs_cache["assets"] = existing_objects_map + + if "asset_types" not in fs_cache: + asset_types_map = freshservice.get_objects_map("api/v2/asset_types", "asset_types") + fs_cache["asset_types"] = asset_types_map + else: + asset_types_map = fs_cache["asset_types"] - asset_type = freshservice.get_ci_type_by_name(_target["@asset-type"]) + if "asset_type_fields" not in fs_cache: + fs_cache["asset_type_fields"] = {} - asset_type_fields = freshservice.get_asset_type_fields(asset_type["id"]) + asset_type_fields_map = dict() + server_asset_type_id = find_object_id_in_map(asset_types_map, ASSET_TYPE_SERVER) for source in sources: error_skip = False while True: try: - existing_object = find_object_by_name(existing_objects, source["name"]) + existing_object = find_object_in_map(existing_objects_map, source["name"]) + if existing_object is None or existing_object["asset_type_id"] == server_asset_type_id: + asset_type_id = find_object_id_in_map(asset_types_map, source["asset_type"]) + else: + asset_type_id = existing_object["asset_type_id"] + + if asset_type_id in fs_cache["asset_type_fields"]: + asset_type_fields = fs_cache["asset_type_fields"][asset_type_id] + else: + asset_type_fields = freshservice.get_asset_type_fields(asset_type_id) + fs_cache["asset_type_fields"][asset_type_id] = asset_type_fields + data = dict() + data['asset_type_id'] = asset_type_id data["type_fields"] = dict() - for map_info in mapping["field"]: - if error_skip and "@error-skip" in map_info and map_info["@error-skip"]: - continue - asset_type_field = get_asset_type_field(asset_type_fields, map_info) - if asset_type_field is None: - continue - value = get_map_value_from_device42(source, map_info) - - if asset_type_field["asset_type_id"] is not None: - data["type_fields"][asset_type_field["name"]] = value - else: - data[map_info["@target"]] = value + # if there is only one field in the mapping, it will be dict. + if isinstance(mapping["field"], dict): + mapping["field"] = [mapping["field"]] # validation for map_info in mapping["field"]: if error_skip and "@error-skip" in map_info and map_info["@error-skip"]: continue - asset_type_field = get_asset_type_field(asset_type_fields, map_info) + asset_type_field = get_asset_type_field_from_map(asset_type_fields_map, asset_type_id, asset_type_fields, map_info) if asset_type_field is None: continue + value = get_map_value_from_device42(source, map_info) + if asset_type_field["asset_type_id"] is not None: - value = data["type_fields"][asset_type_field["name"]] + data["type_fields"][asset_type_field["name"]] = value else: - value = data[map_info["@target"]] + data[map_info["@target"]] = value is_valid = True if value is not None and "@min-length" in map_info and len(value) < map_info["@min-length"]: @@ -190,7 +277,7 @@ def update_objects_from_server(sources, _target, mapping): # value might have been translated to an associated ID in Freshservice by get_map_value_from_device42 # which is why we need to check that value is a string using isinstance. if value is not None and "@max-length" in map_info and isinstance(value, str) and len(value) > map_info["@max-length"]: - value = value[0:map_info["@max-length"]-3] + "..." + value = value[0:map_info["@max-length"] - 3] + "..." if value is None and "@not-null" in map_info and map_info["@not-null"]: if map_info["@target"] == "asset_tag": is_valid = False @@ -206,6 +293,9 @@ def update_objects_from_server(sources, _target, mapping): target_type = map_info["@target-type"] if target_type == "integer" or target_type == "float": value = 0 + elif target_type == "date": + value = None + is_valid = False else: value = " " else: @@ -219,7 +309,25 @@ def update_objects_from_server(sources, _target, mapping): if target_type == "integer": try: value = int(value) - except: + except Exception as e: + logger.error(str(e)) + is_valid = False + + elif target_type == "dropdown": + try: + option = None + for choice in asset_type_field["choices"]: + d42_value = value.lower() + choice_value = choice[0].lower() + if d42_value in choice_value or choice_value in d42_value: + option = choice[0] + break + if option is None: + is_valid = False + else: + value = option + except Exception as e: + logger.error(str(e)) is_valid = False if not is_valid: @@ -236,8 +344,11 @@ def update_objects_from_server(sources, _target, mapping): if existing_object is None: logger.info("adding asset %s" % source["name"]) - new_asset_id = freshservice.insert_asset(data) - logger.info("added new asset %d" % new_asset_id) + new_asset = freshservice.insert_asset(data) + logger.info("added new asset %d" % new_asset["id"]) + # We added a new object to Freshservice. Add it to the map of objects that we know exist + # in Freshservice. + existing_objects_map[new_asset["name"].lower()] = new_asset else: logger.info("updating asset %s" % source["name"]) # This is a workaround for an issue with the Freshservice API where if a business service @@ -248,20 +359,20 @@ def update_objects_from_server(sources, _target, mapping): # Assigned agent isn't a member of the group. # So, if the business service asset has an agent_id already populated, we will send that # same value over and that will avoid this error. - if _target["@asset-type"] == ASSET_TYPE_BUSINESS_SERVICE and "agent_id" in existing_object and existing_object["agent_id"]: + if source["asset_type"] == ASSET_TYPE_BUSINESS_SERVICE and "agent_id" in existing_object and existing_object["agent_id"]: data["agent_id"] = existing_object["agent_id"] updated_asset_id = freshservice.update_asset(data, existing_object["display_id"]) - logger.info("updated new asset %d" % updated_asset_id) + logger.info("updated existing asset %d" % updated_asset_id) break - except FreshServiceDuplicateSerialError: + except FreshServiceDuplicateValueError: if not error_skip: error_skip = True continue break except Exception as e: log = "Error (%s) updating device %s" % (str(e), source["name"]) - logger.exception(log) + logger.error(log) break @@ -286,19 +397,20 @@ def delete_objects_from_server(sources, _target, mapping): logger.info("deleted asset %s" % existing_object["name"]) except Exception as e: log = "Error (%s) deleting device %s" % (str(e), existing_object["name"]) - logger.exception(log) + logger.error(log) def update_softwares_from_server(sources, _target, mapping): global freshservice logger.info("Getting all existing softwares in FS.") - existing_objects = freshservice.request(_target["@path"], "GET", _target["@model"]) + existing_objects_map = freshservice.get_objects_map(_target["@path"], _target["@model"]) logger.info("finished getting all existing softwares in FS.") + fs_cache["softwares"] = existing_objects_map for source in sources: try: - existing_object = find_object_by_name(existing_objects, source["name"]) + existing_object = find_object_in_map(existing_objects_map, source["name"]) data = dict() for map_info in mapping["field"]: value = get_map_value_from_device42(source, map_info) @@ -312,15 +424,18 @@ def update_softwares_from_server(sources, _target, mapping): if existing_object is None: logger.info("adding software %s" % source["name"]) - new_software_id = freshservice.insert_software(data) - logger.info("added new software %d" % new_software_id) + new_software = freshservice.insert_software(data) + logger.info("added new software %d" % new_software["id"]) + # We added a new object to Freshservice. Add it to the map of objects that we know exist + # in Freshservice. + existing_objects_map[new_software["name"].lower()] = new_software else: logger.info("updating software %s" % source["name"]) updated_software_id = freshservice.update_software(data, existing_object["id"]) - logger.info("updated new software %d" % updated_software_id) + logger.info("updated existing software %d" % updated_software_id) except Exception as e: log = "Error (%s) updating software %s" % (str(e), source["name"]) - logger.exception(log) + logger.error(log) def delete_softwares_from_server(sources, _target, mapping): @@ -344,42 +459,97 @@ def delete_softwares_from_server(sources, _target, mapping): logger.info("deleted software %s" % existing_object["name"]) except Exception as e: log = "Error (%s) deleting software %s" % (str(e), existing_object["name"]) - logger.exception(log) + logger.error(log) + + +def update_products_from_server(sources, _target, mapping): + global freshservice + + logger.info("Getting all existing products in FS.") + existing_objects_map = freshservice.get_objects_map(_target["@path"], _target["@model"]) + logger.info("finished getting all existing products in FS.") + + asset_types_map = freshservice.get_objects_map("api/v2/asset_types", "asset_types") + fs_cache["asset_types"] = asset_types_map + asset_type_id = find_object_id_in_map(asset_types_map, _target["@asset-type"]) + + for source in sources: + try: + existing_object = find_object_in_map(existing_objects_map, source["name"]) + data = dict() + for map_info in mapping["field"]: + value = get_map_value_from_device42(source, map_info) + + # value might have been translated to an associated ID in Freshservice by get_map_value_from_device42 + # which is why we need to check that value is a string using isinstance. + if value is not None and "@max-length" in map_info and isinstance(value, str) and len(value) > map_info["@max-length"]: + value = value[0:map_info["@max-length"] - 3] + "..." + + data[map_info["@target"]] = value + + if existing_object is None: + logger.info("adding product %s" % source["name"]) + data['asset_type_id'] = asset_type_id + new_product = freshservice.insert_product(data) + logger.info("added new product %d" % new_product["id"]) + # We added a new object to Freshservice. Add it to the map of objects that we know exist + # in Freshservice. + existing_objects_map[new_product["name"].lower()] = new_product + else: + logger.info("updating product %s" % source["name"]) + updated_product_id = freshservice.update_product(data, existing_object["id"]) + logger.info("updated existing product %d" % updated_product_id) + except Exception as e: + log = "Error (%s) updating product %s" % (str(e), source["name"]) + logger.error(log) def create_installation_from_software_in_use(sources, _target, mapping): global freshservice - logger.info("Getting all existing devices in FS.") - existing_objects = freshservice.request("api/v2/assets", "GET", "assets") - logger.info("finished getting all existing devices in FS.") + if "assets" in fs_cache: + logger.info("Getting all existing assets in FS from cache.") + existing_objects_map = fs_cache["assets"] + logger.info("finished getting all existing assets in FS from cache.") + else: + logger.info("Getting all existing assets in FS.") + existing_objects_map = freshservice.get_objects_map(_target["@path"], _target["@model"]) + logger.info("finished getting all existing assets in FS.") + fs_cache["assets"] = existing_objects_map + + if "softwares" in fs_cache: + logger.info("Getting all existing softwares in FS from cache.") + existing_softwares_map = fs_cache["softwares"] + logger.info("finished getting all existing softwares in FS from cache.") + else: + logger.info("Getting all existing softwares in FS.") + existing_softwares_map = freshservice.get_objects_map(_target["@path"], _target["@model"]) + logger.info("finished getting all existing softwares in FS.") + fs_cache["softwares"] = existing_softwares_map - logger.info("Getting all existing softwares in FS.") - existing_softwares = freshservice.request("api/v2/applications", "GET", "applications") - logger.info("finished getting all existing softwares in FS.") + software_to_assets_map = dict() for source in sources: try: logger.info("Processing %s - %s." % (source[mapping["@device-name"]], source[mapping["@software-name"]])) - asset = find_object_by_name(existing_objects, source[mapping["@device-name"]]) - software = find_object_by_name(existing_softwares, source[mapping["@software-name"]]) + asset = find_object_in_map(existing_objects_map, source[mapping["@device-name"]]) + software = find_object_in_map(existing_softwares_map, source[mapping["@software-name"]]) if asset is None: log = "There is no asset(%s) in FS." % source[mapping["@device-name"]] - logger.exception(log) + logger.error(log) continue if software is None: log = "There is no software(%s) in FS." % source[mapping["@software-name"]] - logger.exception(log) + logger.error(log) continue - installations = freshservice.get_installations_by_id(software["id"]) - exist = False - for installation in installations: - if installation["installation_machine_id"] == asset["display_id"]: - exist = True - break + if software["id"] not in software_to_assets_map: + installations = freshservice.get_installations_by_id(software["id"]) + software_to_assets_map[software["id"]] = {i["installation_machine_id"] for i in installations} + + exist = asset["display_id"] in software_to_assets_map[software["id"]] if exist: logger.info("There is already installation in FS.") continue @@ -390,18 +560,27 @@ def create_installation_from_software_in_use(sources, _target, mapping): data["installation_date"] = source[mapping["@install-date"]] logger.info("adding installation %s-%s" % (source[mapping["@device-name"]], source[mapping["@software-name"]])) freshservice.insert_installation(software["id"], data) + # We added a new installation to Freshservice. Add it to the map of installations that we know exist + # in Freshservice. + software_to_assets_map[software["id"]].add(asset["display_id"]) logger.info("added installation %s-%s" % (source[mapping["@device-name"]], source[mapping["@software-name"]])) except Exception as e: log = "Error (%s) creating installation %s" % (str(e), source[mapping["@device-name"]]) - logger.exception(log) + logger.error(log) def create_relationships_from_affinity_group(sources, _target, mapping): global freshservice - logger.info("Getting all existing devices in FS.") - existing_objects = freshservice.request("api/v2/assets" + "?include=type_fields", "GET", _target["@model"]) - logger.info("finished getting all existing devices in FS.") + if "assets" in fs_cache: + logger.info("Getting all existing assets in FS from cache.") + existing_objects_map = fs_cache["assets"] + logger.info("finished getting all existing assets in FS from cache.") + else: + logger.info("Getting all existing assets in FS.") + existing_objects_map = freshservice.get_objects_map(_target["@path"], _target["@model"]) + logger.info("finished getting all existing assets in FS.") + fs_cache["assets"] = existing_objects_map logger.info("Getting relationship type in FS.") relationship_type = freshservice.get_relationship_type_by_content(mapping["@downstream-relationship"], @@ -413,6 +592,9 @@ def create_relationships_from_affinity_group(sources, _target, mapping): logger.info(log) return + # The key will be the display_id of the primary asset and the value will be a set of + # the secondary asset display_id's that the primary asset is related to. + relationships_map = dict() relationships_to_create = list() source_count = len(sources) submitted_jobs = list() @@ -420,48 +602,56 @@ def create_relationships_from_affinity_group(sources, _target, mapping): for idx, source in enumerate(sources): try: logger.info("Processing %s - %s." % (source[mapping["@key"]], source[mapping["@target-key"]])) - primary_asset = find_object_by_name(existing_objects, source[mapping["@key"]]) - secondary_asset = find_object_by_name(existing_objects, source[mapping["@target-key"]]) + primary_asset = find_object_in_map(existing_objects_map, source[mapping["@key"]]) if primary_asset is None: log = "There is no dependent asset(%s) in FS." % source[mapping["@key"]] - logger.exception(log) + logger.error(log) continue + secondary_asset = find_object_in_map(existing_objects_map, source[mapping["@target-key"]]) + if secondary_asset is None: log = "There is no dependency asset(%s) in FS." % source[mapping["@target-key"]] - logger.exception(log) + logger.error(log) continue - relationships = freshservice.get_relationships_by_id(primary_asset["display_id"]) - exist = False - for relationship in relationships: - if relationship["relationship_type_id"] == relationship_type["id"]: - if relationship["secondary_id"] == secondary_asset["display_id"]: - exist = True - break + primary_asset_display_id = primary_asset["display_id"] + + if primary_asset_display_id not in relationships_map: + relationships_map[primary_asset_display_id] = set() + relationships = freshservice.get_relationships_by_id(primary_asset_display_id) + + for relationship in relationships: + if relationship["relationship_type_id"] == relationship_type["id"]: + relationships_map[primary_asset_display_id].add(relationship["secondary_id"]) + + exist = secondary_asset["display_id"] in relationships_map[primary_asset_display_id] if exist: logger.info("There is already relationship in FS.") continue relationships_to_create.append({ "relationship_type_id": relationship_type["id"], - "primary_id": primary_asset["display_id"], + "primary_id": primary_asset_display_id, "primary_type": "asset", "secondary_id": secondary_asset["display_id"], "secondary_type": "asset" }) + relationships_to_create_count = len(relationships_to_create) + relationships_map[primary_asset_display_id].add(secondary_asset["display_id"]) + # Create a new job if we reached our batch size or we are on the last item (which # means this is the last batch we will be submitting). - if len(relationships_to_create) >= RELATIONSHIP_BATCH_SIZE or idx == source_count - 1: + if relationships_to_create_count >= RELATIONSHIP_BATCH_SIZE or idx == source_count - 1: submitted_jobs.append(submit_relationship_create_job(relationships_to_create)) # Clear the list for the next batch of relationships we are going to send. del relationships_to_create[:] except Exception as e: log = "Error (%s) creating relationship %s" % (str(e), source[mapping["@key"]]) - logger.exception(log) + logger.error(log) # We may not have submitted the last batch of relationships to create if the last item in # sources did not result in a relationship needing to be created (e.g. one of the assets @@ -541,19 +731,6 @@ def create_relationships_from_affinity_group(sources, _target, mapping): logger.info("%d of %d relationship create jobs did not complete." % (jobs_not_completed_count, submitted_jobs_count)) -def submit_relationship_create_job(relationships_to_create): - logger.info("adding relationship create job") - # Creating relationships using the v2 API is now an asynchronous operation and is - # performed using background jobs. We will get back the job ID which can then be - # used to query the status of the job. - job_id = freshservice.insert_relationships({"relationships": relationships_to_create}) - logger.info("added new relationship create job %s" % job_id) - - return { - "job_id": job_id, - "relationships_to_create_count": len(relationships_to_create) - } - def delete_relationships_from_affinity_group(sources, _target, mapping): global freshservice @@ -650,6 +827,217 @@ def delete_relationships_from_business_app(sources, _target, mapping): logger.exception(log) +def update_contracts_from_server(sources, _target, mapping): + global freshservice + + logger.info("Getting all existing contracts in FS.") + existing_objects_map = freshservice.get_objects_map(_target["@path"], _target["@model"]) + logger.info("finished getting all existing contracts in FS.") + fs_cache["contracts"] = existing_objects_map + + if "softwares" in fs_cache: + logger.info("Getting all existing softwares in FS from cache.") + existing_softwares_map = fs_cache["softwares"] + logger.info("finished getting all existing softwares in FS from cache.") + else: + logger.info("Getting all existing softwares in FS.") + existing_softwares_map = freshservice.get_objects_map(_target["@path"], _target["@model"]) + logger.info("finished getting all existing softwares in FS.") + fs_cache["softwares"] = existing_softwares_map + + for source in sources: + error_skip = False + while True: + try: + existing_object = find_object_in_map(existing_objects_map, source["name"]) + data = dict() + if not default_approver: + raise Exception("The approver is required.") + data['approver_id'] = int(default_approver) + + # validation + for map_info in mapping["field"]: + if error_skip and "@error-skip" in map_info and map_info["@error-skip"]: + continue + + if "@target-foregin-key" in map_info and map_info["@target-foregin"] == "applications": + existing_software = find_object_in_map(existing_softwares_map, source[map_info["@resource"]]) + value = existing_software["id"] + else: + value = get_map_value_from_device42(source, map_info) + + if "@target-sub-key" not in map_info: + data[map_info["@target"]] = value + else: # Item cost detail attributes + if map_info["@target"] not in data: + data[map_info["@target"]] = [{ + map_info["@target-sub-key"]: value + }] + else: + data[map_info["@target"]][0][map_info["@target-sub-key"]] = value + + is_valid = True + if value is not None and "@min-length" in map_info and len(value) < map_info["@min-length"]: + is_valid = False + if value == "" and "@set-space" in map_info and map_info["@set-space"]: + is_valid = True + value = " " * map_info["@min-length"] + # value might have been translated to an associated ID in Freshservice by get_map_value_from_device42 + # which is why we need to check that value is a string using isinstance. + if value is not None and "@max-length" in map_info and isinstance(value, str) and len(value) > map_info["@max-length"]: + value = value[0:map_info["@max-length"] - 3] + "..." + if value is None and "@not-null" in map_info and map_info["@not-null"]: + # There is an issue with the Freshservice API where sending a null value for + # a field will result in the API returning an error like "Has 0 characters, + # it should have minimum of 1 characters and can have maximum of 255 characters". + # This prevents us from being able to clear these field values in Freshservice (even though + # the Freshservice UI allows you to clear these fields). To get around this, we will send + # a single space for string values and a 0 for integer and float values when the value + # coming from D42 is null. + if "@target-type" in map_info: + target_type = map_info["@target-type"] + if target_type == "integer" or target_type == "float": + value = 0 + elif target_type == "date": + value = None + is_valid = False + else: + value = " " + else: + value = " " + + if value == 0 and "@not-zero" in map_info and map_info["@not-zero"]: + # Some fields in Freshservice do not allow a 0 value. + # D42 does allow a 0 for the value being synced over, + # so when we try to sync that data to Freshservice, + # the API returns an error like "It should be a Positive Number + # less than or equal to 99999999.99" when we send a 0 value to a field + # that does not accept 0. + # To get around this, we will send 1 for integer values and a 0.01 + # for float values when the value coming from D42 is 0. + if "@target-type" in map_info: + target_type = map_info["@target-type"] + if target_type == "integer": + value = 1 + elif target_type == "float": + value = 0.01 + else: + value = None + is_valid = False + else: + value = 0.01 + + if "@target-foregin-key" in map_info and value is not None and isinstance(value, str): + value = get_map_value_from_device42(source, map_info, True) + is_valid = value is not None + if "@target-type" in map_info and value is not None: + target_type = map_info["@target-type"] + if target_type == "integer": + try: + value = int(value) + except Exception as e: + logger.error(str(e)) + is_valid = False + + if not is_valid: + logger.debug("argument '%s' is invalid." % map_info["@target"]) + if "@target-sub-key" not in map_info: + data.pop(map_info["@target"], None) + else: + data[[map_info["@target"]]][0].pop(map_info["@target-sub-key"]) + if is_valid: + if "@target-sub-key" not in map_info: + data[map_info["@target"]] = value + else: + data[map_info["@target"]][0][map_info["@target-sub-key"]] = value + + if existing_object is None: + logger.info("adding contract %s" % source["name"]) + new_contract = freshservice.insert_contract(data) + logger.info("added new contract %d" % new_contract["id"]) + # We added a new object to Freshservice. Add it to the map of objects that we know exist + # in Freshservice. + existing_objects_map[new_contract["name"].lower()] = new_contract + else: + logger.info("updating contract %s" % source["name"]) + updated_id = freshservice.update_contract(data, existing_object["id"]) + logger.info("updated contract %d" % updated_id) + + break + except FreshServiceDuplicateValueError: + if not error_skip: + error_skip = True + continue + break + except Exception as e: + log = "Error (%s) updating contract %s" % (str(e), source["name"]) + logger.error(log) + break + + +def create_association_between_asset_and_contract(sources, _target, mapping): + global freshservice + + if "assets" in fs_cache: + logger.info("Getting all existing assets in FS from cache.") + existing_assets_map = fs_cache["assets"] + logger.info("finished getting all existing assets in FS from cache.") + else: + logger.info("Getting all existing assets in FS.") + existing_assets_map = freshservice.get_objects_map(_target["@path"], _target["@model"]) + logger.info("finished getting all existing assets in FS.") + fs_cache["assets"] = existing_assets_map + + if "contracts" in fs_cache: + logger.info("Getting all existing contracts in FS from cache.") + existing_contracts_map = fs_cache["contracts"] + logger.info("finished getting all existing contracts in FS from cache.") + else: + logger.info("Getting all existing contracts in FS.") + existing_contracts_map = freshservice.get_objects_map(_target["@path"], _target["@model"]) + logger.info("finished getting all existing contracts in FS.") + fs_cache["contracts"] = existing_contracts_map + + # Key will be the contract ID and the value will be a set of asset IDs that the contract is + # associated with. + contract_to_assets_map = dict() + + for source in sources: + try: + logger.info("Processing %s - %s." % (source[mapping["@device-name"]], source[mapping["@contract-name"]])) + asset = find_object_in_map(existing_assets_map, source[mapping["@device-name"]]) + contract = find_object_in_map(existing_contracts_map, source[mapping["@contract-name"]]) + + if asset is None: + log = "There is no asset(%s) in FS." % source[mapping["@device-name"]] + logger.error(log) + continue + + if contract is None: + log = "There is no contract(%s) in FS." % source[mapping["@contract-name"]] + logger.error(log) + continue + + if contract["id"] not in contract_to_assets_map: + associated_assets = freshservice.get_associated_assets_by_contract(contract["id"]) + contract_to_assets_map[contract["id"]] = {a["display_id"] for a in associated_assets} + + if asset['display_id'] in contract_to_assets_map[contract["id"]]: + logger.info("There is already associated asset in FS.") + continue + + contract_to_assets_map[contract["id"]].add(asset['display_id']) + data = dict() + data['associated_asset_ids'] = list(contract_to_assets_map[contract["id"]]) + + logger.info("adding associated asset %s-%s" % (source[mapping["@device-name"]], source[mapping["@contract-name"]])) + freshservice.update_contract(data, contract["id"]) + logger.info("added associated asset %s-%s" % (source[mapping["@device-name"]], source[mapping["@contract-name"]])) + except Exception as e: + log = "Error (%s) creating associated assets %s-%s" % (str(e), source[mapping["@device-name"]], source[mapping["@contract-name"]]) + logger.error(log) + + def parse_config(url): config = eTree.parse(url) meta = config.getroot() @@ -706,6 +1094,12 @@ def task_execute(task, device42): delete_softwares_from_server(sources, _target, mapping) else: create_installation_from_software_in_use(sources, _target, mapping) + elif _type == "contracts": + update_contracts_from_server(sources, _target, mapping) + elif _type == "contract_in_asset": + create_association_between_asset_and_contract(sources, _target, mapping) + elif _type == "product": + update_products_from_server(sources, _target, mapping) else: if "@delete" in _target and _target["@delete"]: delete_objects_from_server(sources, _target, mapping) @@ -714,8 +1108,24 @@ def task_execute(task, device42): update_objects_from_server(sources, _target, mapping) +def get_agent_from_freshservice(email): + global freshservice + + default_approver = None + try: + agents = freshservice.get_all_agents() + for agent in agents: + if agent['email'] == email: + return agent['id'] + except Exception as e: + logger.error(str(e)) + + return default_approver + + def main(): global freshservice + global default_approver args = parser.parse_args() if args.debug: @@ -736,8 +1146,10 @@ def main(): settings = config["meta"]["settings"] device42 = Device42(settings['device42']['@url'], settings['device42']['@user'], settings['device42']['@pass']) freshservice = FreshService(settings['freshservice']['@url'], settings['freshservice']['@api_key'], logger) + if '@default_approver_email' in settings['freshservice']: + default_approver = get_agent_from_freshservice(settings['freshservice']['@default_approver_email']) - if not "task" in config["meta"]["tasks"]: + if "task" not in config["meta"]["tasks"]: logger.debug("No task") return 0 diff --git a/freshservice.py b/freshservice.py index e6e3ec3..074113d 100755 --- a/freshservice.py +++ b/freshservice.py @@ -5,9 +5,15 @@ from datetime import datetime import time import logging +import jwt +import pytz requests.packages.urllib3.disable_warnings() +# in seconds +DEFAULT_RETRY_AFTER = 10 +RETRY_AFTER_HEADER = 'Retry-After' + class FreshServiceBaseException(Exception): pass @@ -17,12 +23,16 @@ class FreshServiceHTTPError(FreshServiceBaseException): pass -class FreshServiceDuplicateSerialError(FreshServiceHTTPError): +class FreshServiceDuplicateValueError(FreshServiceHTTPError): pass class FreshService(object): CITypeServerName = "Server" + PAGE_SIZE = 100 + FS_INTEGRATION_NAME_HEADER = 'FS-INTEGRATION-NAME' + JWT_ALGORITHM = 'HS256' + JWT_RECREATE_TIME = 15 def __init__(self, endpoint, api_key, logger, **kwargs): self.base = endpoint @@ -36,8 +46,26 @@ def __init__(self, endpoint, api_key, logger, **kwargs): self.period_call_api = 1 self.api_call_count = 0 self.asset_types = None - - def _send(self, method, path, data=None): + self.created_by_jwt = None + self.expired_time_jwt = None + + def _get_created_by_jwt(self): + if self.created_by_jwt is not None: + if self.expired_time_jwt - int(time.time()) > self.JWT_RECREATE_TIME: + return self.created_by_jwt + + timestamp = int(round(time.time() + 120)) + encoded = jwt.encode( + {'iss': 'device_42', 'exp': timestamp}, + self.api_key, + self.JWT_ALGORITHM + ) + + self.created_by_jwt = encoded + self.expired_time_jwt = timestamp + return encoded + + def _send(self, method, path, data=None, headers=None): """ General method to send requests """ now = datetime.now() self.api_call_count += 1 @@ -56,15 +84,19 @@ def _send(self, method, path, data=None): params = data data = None + all_headers = self.headers + if headers: + all_headers.update(headers) + while True: if method == 'GET': resp = requests.request(method, url, data=data, params=params, auth=(self.api_key, "X"), - verify=self.verify_cert, headers=self.headers) + verify=self.verify_cert, headers=all_headers) else: resp = requests.request(method, url, json=data, params=params, auth=(self.api_key, "X"), - verify=self.verify_cert, headers=self.headers) + verify=self.verify_cert, headers=all_headers) self.last_time_call_api = datetime.now() @@ -72,8 +104,18 @@ def _send(self, method, path, data=None): if resp.status_code == 429: self._log("HTTP %s (%s) Error %s: %s\n request was %s" % (method, path, resp.status_code, resp.text, data)) - self._log("Throttling 1 min...") - time.sleep(60) + + retry_after = DEFAULT_RETRY_AFTER + header_value = resp.headers.get(RETRY_AFTER_HEADER) + if header_value: + try: + retry_after = int(header_value) + except ValueError as e: + client.captureException(extra={'header_value': header_value}) + self._log('Failed to convert Retry-After value of "%s" to int: %s' % (header_value, str(e))) + + self._log("Throttling %d second(s)..." % retry_after) + time.sleep(retry_after) continue if resp.status_code == 400: @@ -82,8 +124,9 @@ def _send(self, method, path, data=None): error_resp = resp.json() if error_resp["description"] == "Validation failed": for error in error_resp["errors"]: - if error["field"] == "serial_number" and error["message"] == " must be unique": - exception = FreshServiceDuplicateSerialError("HTTP %s (%s) Error %s: %s\n request was %s" % + if (error["field"] == "serial_number" or error["field"] == "item_id") and \ + (error["message"] == " must be unique" or error["message"] == " is not unique"): + exception = FreshServiceDuplicateValueError("HTTP %s (%s) Error %s: %s\n request was %s" % (method, path, resp.status_code, resp.text, data)) break except Exception: @@ -107,15 +150,15 @@ def _send(self, method, path, data=None): def _get(self, path, data=None): return self._send("GET", path, data=data) - def _post(self, path, data): + def _post(self, path, data, headers=None): if not path.endswith('/'): path += '/' - return self._send("POST", path, data=data) + return self._send("POST", path, data=data, headers=headers) - def _put(self, path, data): + def _put(self, path, data, headers=None): if not path.endswith('/'): path += '/' - return self._send("PUT", path, data=data) + return self._send("PUT", path, data=data, headers=headers) def _delete(self, path, data=None): return self._send("DELETE", path, data) @@ -124,14 +167,17 @@ def _log(self, message, level=logging.DEBUG): if self.logger: self.logger.log(level, message) + def _get_asset_headers(self): + return {self.FS_INTEGRATION_NAME_HEADER: self._get_created_by_jwt()} + def insert_asset(self, data): path = "api/v2/assets" - result = self._post(path, data) - return result["asset"]["id"] + result = self._post(path, data, self._get_asset_headers()) + return self.create_basic_object(result["asset"]) def update_asset(self, data, display_id): path = "api/v2/assets/%d" % display_id - result = self._put(path, data) + result = self._put(path, data, self._get_asset_headers()) return result["asset"]["id"] def delete_asset(self, display_id): @@ -147,7 +193,7 @@ def get_assets_by_asset_type(self, asset_type_id): def insert_software(self, data): path = "api/v2/applications" result = self._post(path, data) - return result["application"]["id"] + return self.create_basic_object(result["application"]) def update_software(self, data, id): path = "api/v2/applications/%d" % id @@ -159,6 +205,30 @@ def delete_software(self, id): result = self._delete(path) return result + def insert_product(self, data): + path = "api/v2/products" + result = self._post(path, data) + return self.create_basic_object(result["product"]) + + def update_product(self, data, id): + path = "api/v2/products/%d" % id + result = self._put(path, data) + return result["product"]["id"] + + def insert_contract(self, data): + path = "api/v2/contracts" + result = self._post(path, data) + return self.create_basic_object(result["contract"]) + + def update_contract(self, data, id): + path = "api/v2/contracts/%d" % id + result = self._put(path, data) + return result["contract"]["id"] + + def get_associated_assets_by_contract(self, contract_id): + path = "/api/v2/contracts/%d/associated-assets" % contract_id + return self.request(path, "GET", "associated_assets") + def get_all_ci_types(self): if self.asset_types is not None: return self.asset_types @@ -194,12 +264,6 @@ def get_all_server_ci_types(self): def get_server_ci_type(self): return self.get_ci_type_by_name(self.CITypeServerName) - def get_windows_server_ci_type(self): - return self.get_ci_type_by_name(self.CITypeWindowsServerName) - - def get_unix_server_ci_type(self): - return self.get_ci_type_by_name(self.CITypeUnixServerName) - def get_asset_type_fields(self, asset_type_id): path = "api/v2/asset_types/%d/fields" % asset_type_id return self._get(path)["asset_type_fields"] @@ -223,25 +287,37 @@ def get_vendors(self): vendors = self._get(path) return vendors["vendors"] - def get_id_by_name(self, model, name): + def get_all_agents(self): + path = "/api/v2/agents" + return self.request(path, "GET", "agents") + + def get_agents(self, search, page, per_page): + path = "/api/v2/agents" + data = {'page': page, 'per_page': per_page} + if search and len(search) >= 2: + data['query'] = '"~[name|first_name|last_name|email]:\'' + search + '\'"' + vendors = self._get(path, data) + return vendors["agents"] + + def get_id_by_name(self, model, name, foregin_key="name"): path = "/api/v2/%s" % model models = self.request(path, "GET", model) for model in models: - if "name" in model and model["name"] is not None and name is not None and \ - model["name"].lower() == name.lower(): + if foregin_key in model and model[foregin_key] is not None and name is not None and \ + model[foregin_key].lower() == name.lower(): return model["id"] return None - def insert_and_get_id_by_name(self, model, name, asset_type_id): + def insert_and_get_by_name(self, model, name, asset_type_id, foregin_key="name"): path = "/api/v2/%s" % model if asset_type_id is not None: - data = {"name": name, "asset_type_id": asset_type_id} + data = {foregin_key: name, "asset_type_id": asset_type_id} else: - data = {"name": name} + data = {foregin_key: name} models = self._post(path, data) for key in models: - return models[key]["id"] + return self.create_basic_object(models[key]) return None @@ -250,7 +326,7 @@ def request(self, source_url, method, model): models = [] page = 1 while True: - result = self._get(source_url, data={"page": page}) + result = self._get(source_url, data={"page": page, "per_page": self.PAGE_SIZE}) if model in result: models += result[model] if len(result[model]) == 0: @@ -263,6 +339,46 @@ def request(self, source_url, method, model): return models return [] + def normalize_value(self, val): + if val: + # Replace Unicode no-break spaces with normal spaces. + # We are doing the same with the data we get from D42. + # This will allow us to find objects in Freshservice regardless if the name + # is using Unicode no-break spaces or normal ASCII spaces since the Unicode + # no-break spaces will always be converted to normal ASCII spaces. + return val.replace(u'\xa0', ' ') + + return val + + def create_basic_object(self, m): + # Create an object using only the properties that we will need. This object will + # be stored in the cache, so we want to try to minimize the memory footprint of it. + obj = {"id": m["id"]} + + if "name" in m: + obj["name"] = self.normalize_value(m["name"]) + + if "email" in m: + obj["email"] = m["email"] + + # Not all models have a display_id (e.g. assets do, but asset types do not). + if "display_id" in m: + obj["display_id"] = m["display_id"] + + if "agent_id" in m: + obj["agent_id"] = m["agent_id"] + + if "asset_type_id" in m: + obj["asset_type_id"] = m["asset_type_id"] + + return obj + + def get_objects_map(self, source_url, model, foregin_key="name"): + objects = self.request(source_url, "GET", model) + # Return a dictionary where the key is the lowercase name of the object and the value is + # the basic object (e.g. id, name, etc.). + return {self.normalize_value(obj[foregin_key]).lower(): self.create_basic_object(obj) for obj in objects} + def get_relationship_type_by_content(self, downstream, upstream): path = "/api/v2/relationship_types" relationship_types = self.request(path, "GET", "relationship_types") @@ -288,21 +404,7 @@ def detach_relationship(self, relationship_id): def get_installations_by_id(self, display_id): path = "/api/v2/applications/%d/installations" % display_id - - models = [] - page = 1 - while True: - result = self._get(path, data={"page": page}) - if "installations" in result: - models += result["installations"] - if len(result["installations"]) == 0: - break - else: - break - - page += 1 - - return models + return self.request(path, "GET", "installations") def insert_installation(self, display_id, data): path = "/api/v2/applications/%d/installations" % display_id @@ -315,4 +417,3 @@ def insert_installation(self, display_id, data): def get_job(self, job_id): path = "/api/v2/jobs/%s" % job_id return self._get(path) - diff --git a/mapping.xml.sample b/mapping.xml.sample index edee6b5..4dfbb93 100644 --- a/mapping.xml.sample +++ b/mapping.xml.sample @@ -2,7 +2,8 @@ + api_key="{api_key}" + default_approver_email=""/> - + - - + + + + + + + + + + + + + + + + + + + + + + + - + + + + + + + + + - - - - - - - - - - - + + + + + + + + + + + + + + + - - + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - + - + + + + + + + + + - - - - - - - - - - - - - + + + + + + + + + + + + + + - - + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -251,7 +1100,7 @@ - + - - + + - + @@ -276,23 +1125,93 @@ - + + doql=" + WITH device as + ( + SELECT + case + WHEN lower(view_device_v2.type) = 'physical' AND lower(view_device_v2.physicalsubtype) = 'laptop' THEN 'Computer' + WHEN lower(view_device_v2.type) = 'physical' AND lower(view_device_v2.physicalsubtype) = 'workstation' THEN 'Computer' + WHEN lower(view_device_v2.type) = 'physical' AND lower(view_device_v2.physicalsubtype) = 'network printer' THEN 'Printer' + WHEN lower(view_device_v2.type) = 'physical' AND lower(view_device_v2.physicalsubtype) = 'router' THEN 'Router' + WHEN lower(view_device_v2.type) = 'physical' AND lower(view_device_v2.physicalsubtype) = 'firewall' THEN 'Firewall' + WHEN lower(view_device_v2.os_name) similar to '%(f5|netscaler)%' THEN 'Load Balancer' + WHEN view_device_v2.network_device THEN 'Switch' + WHEN lower(view_device_v2.type) = 'physical' AND lower(view_device_v2.os_name) similar to '%(windows|microsoft)%' AND lower(view_device_v2.os_name) like '%server%' THEN 'Computer' + WHEN lower(view_device_v2.type) = 'physical' AND lower(view_device_v2.os_name) similar to '%(unix|z/os|z os|zos|hp-ux|os400|os/400|os 400|linux|amazon|ubuntu|centos|redhat|debian|sles|suse|gentoo|oracle|freebsd|rhel|red hat|fedora|alma|rocky|arch)%' THEN 'Computer' + WHEN lower(view_device_v2.type) = 'physical' AND lower(view_device_v2.os_name) similar to '%(aix)%' THEN 'Computer' + WHEN lower(view_device_v2.type) = 'physical' AND lower(view_device_v2.os_name) similar to '%(solaris|sunos|sun os)%' THEN 'Computer' + WHEN view_device_v2.virtual_host AND lower(view_device_v2.os_name) like '%esxi%' THEN 'VMware VCenter Host' + WHEN view_device_v2.virtual_host THEN 'Host' + WHEN lower(view_device_v2.type) = 'virtual' AND lower(view_device_v2.virtualsubtype) = 'vmware' THEN 'Virtual Machine' + WHEN lower(view_device_v2.type) = 'virtual' AND lower(view_device_v2.virtualsubtype) = 'amazon ec2 instance' THEN 'Virtual Machine' + WHEN lower(view_device_v2.type) = 'virtual' AND lower(view_device_v2.virtualsubtype) = 'azure virtual machine' THEN 'Virtual Machine' + WHEN lower(view_device_v2.type) = 'physical' THEN 'Computer' + WHEN lower(view_device_v2.type) = 'virtual' THEN 'Virtual Machine' + ELSE 'Computer' + END as asset_type, + view_device_v2.device_pk, + view_device_v2.name, + view_device_v2.last_edited + FROM view_device_v2 + ) + select device.name as device_name, trim(view_software_v1.name) as software_name, view_softwareinuse_v1.install_date, view_softwareinuse_v1.version from view_softwareinuse_v1 + left join view_software_v1 on software_fk=software_pk inner join device on device_fk=device_pk + where (device.asset_type = 'Computer' or device.asset_type = 'Virtual Machine') + "/> - + - + - + + + + + + + + + + - @@ -319,11 +1237,10 @@ - + - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 6085abd..02dc600 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,5 @@ pyparsing==2.1.10 pyzmq==16.0.2 requests==2.13.0 xmljson==0.2.0 +atlassian-jwt==1.9.0 +pytz==2017.3 From 6eca078864cd2318f3a7adf1ae78b4220e57947b Mon Sep 17 00:00:00 2001 From: Roman Nyschuk Date: Tue, 4 Jul 2023 22:51:09 +0300 Subject: [PATCH 2/4] D42-29347 Bring up to date with the changes the app has --- d42_sd_sync.py | 8 ++++++-- mapping.xml.sample | 26 +++++++++++++------------- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/d42_sd_sync.py b/d42_sd_sync.py index ff0f855..30a7722 100755 --- a/d42_sd_sync.py +++ b/d42_sd_sync.py @@ -37,7 +37,7 @@ parser.add_argument('-c', '--config', help='Config file', default='mapping.xml') parser.add_argument('-l', '--logfolder', help='log folder path', default='.') -freshservice = FreshService() +freshservice = None default_approver = None fs_cache = dict() @@ -1059,7 +1059,11 @@ def task_execute(task, device42): else: doql = None - source_url = _resource['@path'] + if '@path' in _resource: + source_url = _resource['@path'] + else: + source_url = "services/data/v1.0/query/" + if "@extra-filter" in _resource: source_url += _resource["@extra-filter"] + "&" diff --git a/mapping.xml.sample b/mapping.xml.sample index 4dfbb93..cbc1b77 100644 --- a/mapping.xml.sample +++ b/mapping.xml.sample @@ -25,7 +25,7 @@ " /> - + @@ -46,7 +46,7 @@ " /> - + @@ -314,7 +314,7 @@ /> - + @@ -575,7 +575,7 @@ target-header="Switch" not-null="true" max-length="255"/> - + @@ -826,7 +826,7 @@ /> - + @@ -1083,7 +1083,7 @@ target-header="Switch" not-null="true" max-length="255"/> - + @@ -1108,7 +1108,7 @@ doql="select * from (select trim(name) as name, min(view_software_v1.software_type) as software_type, min(view_software_v1.notes) as notes, 'desktop' as application_type, min(view_software_v1.software_type) as category, min(last_changed) as last_changed from view_software_v1 group by trim(name)) a" /> - + - + @@ -1204,7 +1204,7 @@ where (device.asset_type = 'Computer' or device.asset_type = 'Virtual Machine') "/> - + @@ -1294,7 +1294,7 @@ /> - + - + @@ -1333,11 +1333,11 @@ /> - + - + From df6d9bed899f93be7deee2cab0ec16a7230fa8b4 Mon Sep 17 00:00:00 2001 From: Roman Nyschuk Date: Mon, 10 Jul 2023 20:37:49 +0300 Subject: [PATCH 3/4] Update several comments --- README.md | 22 +++++++++++----------- d42_sd_sync.py | 42 +++++++++++++++++++++--------------------- 2 files changed, 32 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 9b2441f..487b3aa 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ [Device42](http://www.device42.com/) is a Continuous Discovery software for your IT Infrastructure. It helps you automatically maintain an up-to-date inventory of your physical, virtual, and cloud servers and containers, network components, software/services/applications, and their inter-relationships and inter-dependencies. -This repository contains script that helps you sync data from Device42 to FreshService. +This repository contains script that helps you sync data from Device42 to Freshservice. ### Download and Installation ----------------------------- @@ -35,35 +35,35 @@ These can all be installed by running pip install -r requirements.txt. In order to run the legacy migration, you will also need to modify the mapping.xml file so that the legacy mapping options are used modify the following line so that `enable` is set to false for the v2_views -```enable="false" description="Copy Servers from Device42 to FreshService using DOQL v2_views"``` +```enable="false" description="Copy Servers from Device42 to Freshservice using DOQL v2_views"``` modify the following line so that `enable` is set to true for the v1_views -```enable="true" description="Copy Servers from Device42 to FreshService using DOQL v1_views"``` +```enable="true" description="Copy Servers from Device42 to Freshservice using DOQL v1_views"``` Once the packages are installed and the script is configured, the script can be run by this command: python d42_sd_sync.py. ### Configuration ----------------------------- -Prior to using the script, it must be configured to connect to your Device42 instance and your FreshService instance. +Prior to using the script, it must be configured to connect to your Device42 instance and your Freshservice instance. * Save a copy of mapping.xml.sample as mapping.xml. -* Enter your URL, User, Password, API Key in the FreshService and Device42 sections (lines 2-10). -API Key can be obtained from FreshService profile page +* Enter your URL, User, Password, API Key, and default approver email address in the Freshservice and Device42 sections (lines 2-11). +API Key can be obtained from Freshservice profile page Below the credential settings, you’ll see a Tasks section. -Multiple Tasks can be setup to synchronize various CIs from Device42 to FreshService. +Multiple Tasks can be setup to synchronize various CIs from Device42 to Freshservice. In the section of each task, there will be a section that queries Device42 to obtain the desired CIs. Full documentation of the Device42 API and endpoints is available at https://api.device42.com. Individual tasks within a mapping.xml file can be enabled or disabled at will by changing the `enable="true"` to `enable="false"` in the section. -Once the Device42 API resource and FreshService Target are entered, the section is where fields from Device42 (the `resource` value) can be mapped to fields in FreshService (the `target` value). +Once the Device42 API resource and Freshservice Target are entered, the section is where fields from Device42 (the `resource` value) can be mapped to fields in Freshservice (the `target` value). It is very important to adjust the list of default values in accordance between freshservice and device 42 (for example, service_level). After configuring the fields to map as needed, the script should be ready to run. ### Gotchas ----------------------------- -* FreshService API Limit is 1000 calls per hour (https://api.freshservice.com/#ratelimit) -* Due to the nature of FreshService rate limits, large inventories may take extended periods of time to migrate +* Freshservice API Limit is 1000 calls per hour (https://api.freshservice.com/#ratelimit) +* Due to the nature of Freshservice rate limits, large inventories may take extended periods of time to migrate Please use the following table as a reference only, actual times may vary due to request limit cooldowns and other internal API calls @@ -82,7 +82,7 @@ Please use the following table as a reference only, actual times may vary due to ### Info ----------------------------- -* mapping.xml - file from where we get fields relations between D42 and FreshService +* mapping.xml - file from where we get fields relations between D42 and Freshservice * devicd42.py - file with integration device42 instance * freshservice.py - file with integration freshservice instance * d42_sd_sync.py - initialization and processing file, where we prepare API calls diff --git a/d42_sd_sync.py b/d42_sd_sync.py index 30a7722..7002c08 100755 --- a/d42_sd_sync.py +++ b/d42_sd_sync.py @@ -310,7 +310,7 @@ def update_objects_from_server(sources, _target, mapping): try: value = int(value) except Exception as e: - logger.error(str(e)) + logger.exception(str(e)) is_valid = False elif target_type == "dropdown": @@ -327,7 +327,7 @@ def update_objects_from_server(sources, _target, mapping): else: value = option except Exception as e: - logger.error(str(e)) + logger.exception(str(e)) is_valid = False if not is_valid: @@ -372,7 +372,7 @@ def update_objects_from_server(sources, _target, mapping): break except Exception as e: log = "Error (%s) updating device %s" % (str(e), source["name"]) - logger.error(log) + logger.exception(log) break @@ -397,7 +397,7 @@ def delete_objects_from_server(sources, _target, mapping): logger.info("deleted asset %s" % existing_object["name"]) except Exception as e: log = "Error (%s) deleting device %s" % (str(e), existing_object["name"]) - logger.error(log) + logger.exception(log) def update_softwares_from_server(sources, _target, mapping): @@ -435,7 +435,7 @@ def update_softwares_from_server(sources, _target, mapping): logger.info("updated existing software %d" % updated_software_id) except Exception as e: log = "Error (%s) updating software %s" % (str(e), source["name"]) - logger.error(log) + logger.exception(log) def delete_softwares_from_server(sources, _target, mapping): @@ -459,7 +459,7 @@ def delete_softwares_from_server(sources, _target, mapping): logger.info("deleted software %s" % existing_object["name"]) except Exception as e: log = "Error (%s) deleting software %s" % (str(e), existing_object["name"]) - logger.error(log) + logger.exception(log) def update_products_from_server(sources, _target, mapping): @@ -501,7 +501,7 @@ def update_products_from_server(sources, _target, mapping): logger.info("updated existing product %d" % updated_product_id) except Exception as e: log = "Error (%s) updating product %s" % (str(e), source["name"]) - logger.error(log) + logger.exception(log) def create_installation_from_software_in_use(sources, _target, mapping): @@ -537,12 +537,12 @@ def create_installation_from_software_in_use(sources, _target, mapping): if asset is None: log = "There is no asset(%s) in FS." % source[mapping["@device-name"]] - logger.error(log) + logger.exception(log) continue if software is None: log = "There is no software(%s) in FS." % source[mapping["@software-name"]] - logger.error(log) + logger.exception(log) continue if software["id"] not in software_to_assets_map: @@ -566,7 +566,7 @@ def create_installation_from_software_in_use(sources, _target, mapping): logger.info("added installation %s-%s" % (source[mapping["@device-name"]], source[mapping["@software-name"]])) except Exception as e: log = "Error (%s) creating installation %s" % (str(e), source[mapping["@device-name"]]) - logger.error(log) + logger.exception(log) def create_relationships_from_affinity_group(sources, _target, mapping): @@ -606,14 +606,14 @@ def create_relationships_from_affinity_group(sources, _target, mapping): if primary_asset is None: log = "There is no dependent asset(%s) in FS." % source[mapping["@key"]] - logger.error(log) + logger.exception(log) continue secondary_asset = find_object_in_map(existing_objects_map, source[mapping["@target-key"]]) if secondary_asset is None: log = "There is no dependency asset(%s) in FS." % source[mapping["@target-key"]] - logger.error(log) + logger.exception(log) continue primary_asset_display_id = primary_asset["display_id"] @@ -651,7 +651,7 @@ def create_relationships_from_affinity_group(sources, _target, mapping): del relationships_to_create[:] except Exception as e: log = "Error (%s) creating relationship %s" % (str(e), source[mapping["@key"]]) - logger.error(log) + logger.exception(log) # We may not have submitted the last batch of relationships to create if the last item in # sources did not result in a relationship needing to be created (e.g. one of the assets @@ -697,7 +697,7 @@ def create_relationships_from_affinity_group(sources, _target, mapping): for relationship in job["relationships"]: if not relationship["success"]: log = "Job %s failed to create relationship: %s" % (job_to_check["job_id"], relationship) - logger.error(log) + logger.exception(log) elif status in ["queued", "in progress"]: # The job has not completed yet. next_jobs_to_check.append(job_to_check) @@ -936,7 +936,7 @@ def update_contracts_from_server(sources, _target, mapping): try: value = int(value) except Exception as e: - logger.error(str(e)) + logger.exception(str(e)) is_valid = False if not is_valid: @@ -971,7 +971,7 @@ def update_contracts_from_server(sources, _target, mapping): break except Exception as e: log = "Error (%s) updating contract %s" % (str(e), source["name"]) - logger.error(log) + logger.exception(log) break @@ -1010,12 +1010,12 @@ def create_association_between_asset_and_contract(sources, _target, mapping): if asset is None: log = "There is no asset(%s) in FS." % source[mapping["@device-name"]] - logger.error(log) + logger.exception(log) continue if contract is None: log = "There is no contract(%s) in FS." % source[mapping["@contract-name"]] - logger.error(log) + logger.exception(log) continue if contract["id"] not in contract_to_assets_map: @@ -1035,7 +1035,7 @@ def create_association_between_asset_and_contract(sources, _target, mapping): logger.info("added associated asset %s-%s" % (source[mapping["@device-name"]], source[mapping["@contract-name"]])) except Exception as e: log = "Error (%s) creating associated assets %s-%s" % (str(e), source[mapping["@device-name"]], source[mapping["@contract-name"]]) - logger.error(log) + logger.exception(log) def parse_config(url): @@ -1119,10 +1119,10 @@ def get_agent_from_freshservice(email): try: agents = freshservice.get_all_agents() for agent in agents: - if agent['email'] == email: + if agent['email'].lower() == email.lower(): return agent['id'] except Exception as e: - logger.error(str(e)) + logger.exception(str(e)) return default_approver From 047b4b8a92ac06d5b29ff5e3292c1c561f495ecd Mon Sep 17 00:00:00 2001 From: Roman Nyschuk Date: Mon, 10 Jul 2023 20:55:24 +0300 Subject: [PATCH 4/4] Update several comments --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 487b3aa..42a486d 100644 --- a/README.md +++ b/README.md @@ -35,10 +35,10 @@ These can all be installed by running pip install -r requirements.txt. In order to run the legacy migration, you will also need to modify the mapping.xml file so that the legacy mapping options are used modify the following line so that `enable` is set to false for the v2_views -```enable="false" description="Copy Servers from Device42 to Freshservice using DOQL v2_views"``` +```enable="false" description="Copy Servers from Device42 to FreshService using DOQL v2_views"``` modify the following line so that `enable` is set to true for the v1_views -```enable="true" description="Copy Servers from Device42 to Freshservice using DOQL v1_views"``` +```enable="true" description="Copy Servers from Device42 to FreshService using DOQL v1_views"``` Once the packages are installed and the script is configured, the script can be run by this command: python d42_sd_sync.py.