#!/usr/local/autopkg/python # -*- coding: utf-8 -*- # Copyright (C) 2015-2018 Shea G Craig # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. """Spruce is a tool to help you clean up your filthy JSS.""" import argparse from collections import Counter, namedtuple import datetime from distutils.version import StrictVersion from html.parser import HTMLParser import os import re import subprocess import sys import textwrap from xml.etree import ElementTree as ET # pylint: disable=import-error from Foundation import (NSData, NSPropertyListSerialization, NSPropertyListMutableContainersAndLeaves, NSPropertyListXMLFormat_v1_0) sys.path.insert(0, '/Library/AutoPkg/JSSImporter') import requests import jss # pylint: enable=import-error # Ensure that python-jss dependency is at minimum version try: from jss import __version__ as PYTHON_JSS_VERSION except ImportError: PYTHON_JSS_VERSION = "0.0.0" REQUIRED_PYTHON_JSS_VERSION = StrictVersion("2.1.0") # Globals # Edit these if you want to change their default values. AUTOPKG_PREFERENCES = "~/Library/Preferences/com.github.autopkg.plist" PYTHON_JSS_PREFERENCES = ( "~/Library/Preferences/com.github.sheagcraig.python-jss.plist") DESCRIPTION = ("Spruce is a tool to help you clean up your filthy JSS." "\n\nUsing the various reporting options, you can see " "unused packages, printers, scripts,\ncomputer groups, " "extension attributes, configuration profiles, mobile " "device groups, and mobile\ndevice configuration " "profiles.\n\nReports are by default output to stdout, " "and may optionally be output as\nXML for later use in " "automated removal.\n\n" "Spruce uses configured AutoPkg/JSSImporter settings " "first. If those are\nmissing, Spruce falls back to " "python-jss settings.\n\nThe recommended workflow is to " "begin by running the reports you find\ninteresting. " "After becoming familiar with the scale of unused " "things,\nreports can be output with the -o/--ofile " "option. This file can then be\nedited down to include " "only those things which you wish to remove.\nFinally, " "pass this filename as an option to the --remove " "argument to\nremove the specified objects.") __version__ = "2.0.1" class Error(Exception): """Module base exception.""" pass class PlistParseError(Error): """Error parsing a plist file.""" pass class PlistDataError(Error): """Data can not be serialized to plist.""" pass class PlistWriteError(Error): """Error writing a plist file.""" pass class Plist(dict): """Abbreviated plist representation (as a dict).""" def __init__(self, filename=None): """Init a Plist, optionally from parsing an existing file. Args: filename: String path to a plist file. """ if filename: dict.__init__(self, self.read_file(filename)) else: dict.__init__(self) self.new_plist() def read_file(self, path): """Replace internal XML dict with data from plist at path. Args: path: String path to a plist file. Raises: PlistParseError: Error in reading plist file. """ # pylint: disable=unused-variable info, pformat, error = ( NSPropertyListSerialization.propertyListWithData_options_format_error_( NSData.dataWithContentsOfFile_(os.path.expanduser(path)), NSPropertyListMutableContainersAndLeaves, None, None )) # pylint: enable=unused-variable if info is None: if error is None: error = "Invalid plist file." raise PlistParseError("Can't read %s: %s" % (path, error)) return info def write_plist(self, path): """Write plist to path. Args: path: String path to desired plist file. Raises: PlistDataError: There was an error in the data. PlistWriteError: Plist could not be written. """ plist_data, error = NSPropertyListSerialization.dataWithPropertyList_format_options_error_( self, NSPropertyListXMLFormat_v1_0, 0, None) if plist_data is None: if error is None: error = "Failed to serialize data to plist." raise PlistDataError(error) else: if not plist_data.writeToFile_atomically_( os.path.expanduser(path), True): raise PlistWriteError("Failed writing data to %s" % path) def new_plist(self): """Generate a barebones recipe plist.""" # Not implemented at this time. pass class JSSConnection(object): """Class for providing a single JSS connection.""" _jss_prefs = None _jss = None @classmethod def setup(cls, connection=None): """Set up the jss connection class variable. If no connection argument is provided, setup will use the standard JSSImporter preferences (com.github.autopkg). Args: connection: Dictionary with JSS connection info, keys: jss_prefs: String path to a preference file. url: Path with port to a JSS. user: API Username. password: API Password. repo_prefs: A list of dicts with repository names and passwords. See JSSPrefs. ssl_verify: Boolean indicating whether to verify SSL certificates. Defaults to True. verbose: Boolean indicating the level of logging. (Doesn't do much.) jss_migrated: Boolean indicating whether scripts have been migrated to the database. Used for determining copy_script type. suppress_warnings: Turns off the urllib3 warnings. Remember, these warnings are there for a reason! Use at your own risk. """ if not connection: connection = {"jss_prefs": jss.JSSPrefs()} cls._jss_prefs = connection # pylint: disable=not-a-mapping if isinstance(connection, jss.JSSPrefs): cls._jss = jss.JSS(jss_prefs=cls._jss_prefs) else: cls._jss = jss.JSS(**cls._jss_prefs) # pylint: enable=not-a-mapping @classmethod def get(cls): """Return the shared JSS object.""" if not cls._jss: cls.setup() return cls._jss class Result(object): """Encapsulates the metadata and results from a report.""" def __init__(self, results, verbose, heading, description=""): """Init our data structure. Args: results: A set of strings of some JSSObject names. include_in_non_verbose: Bool whether or not report will be included in non-verbose output. heading: String heading summarizing the results. description: Longer string describing the meaning of the results. """ self.results = results self.include_in_non_verbose = verbose self.heading = heading self.description = description def __len__(self): """Return the length of the results list.""" return len(self.results) class Report(object): """Represents a collection of Result objects.""" def __init__(self, obj_type, results, heading, metadata): """Init our data structure. Args: obj_type: String object type name (as returned by device_type) results: A list of Result objects to include in the report. heading: String heading describing the report. metadata: Dictionary of other data you want to output. key: Heading name. val Another dictionary, with: key: Subheading name. val: String of data to print. """ self.obj_type = obj_type self.results = results self.heading = heading self.metadata = metadata def get_result_by_name(self, name): """Return a result with argument name. Args: name: String name to find in results. Returns: A Result object or None. """ found = None for result in self.results: if result.heading == name: found = result break return found class AppStoreVersionParser(HTMLParser): """Subclasses HTMLParser to scrape current app version number.""" def __init__(self): HTMLParser.__init__(self) self.version = "Version Not Found" self.in_version_span = False def reset(self): """Manage data state to know when we are in the version span.""" HTMLParser.reset(self) self.in_version_span = False def handle_starttag(self, tag, attrs): """Override handling of tags to find version metadata.""" # <span itemprop="softwareVersion">3.0.3 attrs_dict = dict(attrs) if (tag == "span" and "itemprop" in attrs_dict and attrs_dict["itemprop"] == "softwareVersion"): self.in_version_span = True def handle_data(self, data): if self.in_version_span: self.version = data self.in_version_span = False def map_jssimporter_prefs(prefs): """Convert python-jss preferences to JSSImporter preferences.""" connection = {} connection["url"] = prefs["JSS_URL"] connection["user"] = prefs["API_USERNAME"] connection["password"] = prefs["API_PASSWORD"] connection["ssl_verify"] = prefs.get("JSS_VERIFY_SSL", True) connection["suppress_warnings"] = prefs.get("JSS_SUPPRESS_WARNINGS", True) connection["jss_migrated"] = prefs.get("JSS_MIGRATED", True) connection["repo_prefs"] = prefs.get("JSS_REPOS") print('JSS: {}'.format(connection["url"])) return connection def build_container_report(containers_with_search_paths, jss_objects): """Report on the usage of objects contained in container objects. Find the used and unused jss_objects across a list of containing JSSContainerObjects. For example, Computers can have packages or scripts scoped to policies or configurations. Args: containers_with_search_paths: A list of 2-tuples of: ([list of JSSContainerObjects], xpath to search for contained objects) jss_objects: A list of JSSObject names to search for in the containers_with_search_paths. Returns: A Report object with results and "cruftiness" metadata added, but no heading. """ used_object_sets = [] for containers, search in containers_with_search_paths: search = "container.%s" % search.replace('/',".") for container in containers: try: obj = eval(search) if obj is not None: used_object_sets.append( {(obj.id.text, obj.name.text)}) except AttributeError: pass used = set() if used_object_sets: used = used_object_sets.pop() for used_object_set in used_object_sets: used = used.union(used_object_set) unused = set(jss_objects).difference(used) # Use the xpath's second to last part to determine object type. obj_type = containers_with_search_paths[0][1].split( "/")[-1].replace("_", " ").title() all_result = Result(jss_objects, False, "All", "All %ss on the JSS." % obj_type) used_result = Result(used, False, "Used") unused_result = Result(unused, True, "Unused") report = Report(obj_type, [all_result, used_result, unused_result], "", {"Cruftiness": {}}) cruftiness = calculate_cruft(report.get_result_by_name("Unused").results, report.get_result_by_name("All").results) cruft_strings = get_cruft_strings(cruftiness) report.metadata["Cruftiness"] = {"Unscoped %s Cruftiness" % obj_type: cruft_strings} return report def build_device_report(check_in_period, devices): """Build a report of out-of-date or unresponsive devices. Finds the newest OS version and looks for devices which are out of date. (Builds a histogram of installed OS versions). Compiles a list of devices which have not checked in for 'check_in_period' days. Finally, does a report on the hardware models present. Args: check_in_period: Integer number of days since last check-in to include in report. Defaults to 30. devices: List of all Computer or MobileDevice objects from the JSS. These lists can be subsetted to include only the sections needed for this report. Returns: A Report object. """ check_in_period = validate_check_in_period(check_in_period) device_name = device_type(devices) report = Report(device_name, [], "%s Report" % device_name, {"Cruftiness": {}}) # Out of Date results. out_of_date_results = get_out_of_date_devices(check_in_period, devices) report.results.append(out_of_date_results[0]) report.metadata["Cruftiness"][ "%ss Not Checked In Cruftiness" % device_name] = out_of_date_results[1] # Orphaned device results. orphaned_device_results = get_orphaned_devices(devices) report.results.append(orphaned_device_results[0]) report.metadata["Cruftiness"]["%ss With no Group Membership Cruftiness" % device_name] = orphaned_device_results[1] # Version and model results. report.metadata["Version Spread"], report.metadata[ "Hardware Model Spread"] = get_version_and_model_spread(devices) # All Devices all_devices = [(device.id, device.name) for device in devices] report.results.append(Result(all_devices, False, "All %ss" % device_name)) return report def get_out_of_date_devices(check_in_period, devices): """Produce a report on devices not checked in since check_in_period. Args: check_in_period: Number of days to consider out of date. devices: List of all Computer or MobileDevice objects on the JSS. Returns: Tuple of (Result object, cruftiness) """ device_name = device_type(devices) strptime = datetime.datetime.strptime out_of_date = datetime.datetime.now() - datetime.timedelta(check_in_period) # Example computer contact time format: 2015-08-06 10:46:51 # Example mobile device time format:Friday, August 07 2015 at 3:51 PM if isinstance(devices[0], jss.Computer): fmt_string = "%Y-%m-%d %H:%M:%S" check_in = "general/last_contact_time" else: fmt_string = "%A, %B %d %Y at %H:%M %p" check_in = "general/last_inventory_update" out_of_date_devices = [] for device in devices: last_contact = device.findtext(check_in) # Fix incorrectly formatted Mobile Device times. if last_contact and isinstance(device, jss.MobileDevice): last_contact = hour_pad(last_contact) if not last_contact or (strptime(last_contact, fmt_string) < out_of_date): out_of_date_devices.append((device.id, device.name)) description = ("This report collects %ss which have not checked in for " "more than %i days (%s) based on their %s property." % ( device_name, check_in_period, out_of_date, check_in.split("/")[1])) out_of_date_report = Result( out_of_date_devices, True, "Out of Date %ss" % device_name, description) out_of_date_cruftiness = calculate_cruft( out_of_date_report.results, devices) cruftiness = get_cruft_strings(out_of_date_cruftiness) return (out_of_date_report, cruftiness) def get_orphaned_devices(devices): """Generate Result of devices with no group memberships. Also, include a cruftiness result. Args: devices: List of all Computer or MobileDevice objects on the JSS. Returns: Tuple of (Result object, cruftiness) """ device_name = device_type(devices) orphaned_devices = [(device.id, device.name) for device in devices if has_no_group_membership(device)] description = ("This report collects %ss which do not belong to any " "static or smart groups." % device_name) orphan_report = Result(orphaned_devices, True, "%ss With no Group Membership" % device_name, description) orphan_cruftiness = calculate_cruft(orphan_report.results, devices) cruftiness = get_cruft_strings(orphan_cruftiness) return (orphan_report, cruftiness) def device_type(devices): """Return a string type name for a list of devices.""" num_of_types = len({type(device) for device in devices}) if num_of_types == 1: # print("TEST: device: %s" % type(devices[0]).__name__) return type(devices[0]).__name__ elif num_of_types == 0: return None else: raise ValueError def get_version_and_model_spread(devices): """Generate version spread metadata for device reports. Args: devices: List of all Computer or MobileDevice objects on the JSS. Returns: Dictionary appropriate for use in Report.metadata. """ if isinstance(devices[0], jss.Computer): os_type_search = "hardware/os_name" os_type = "Mac OS X" os_version_search = "hardware/os_version" model_search = "hardware/model" model_identifier_search = "hardware/model_identifier" else: os_type_search = "general/os_type" os_type = "iOS" os_version_search = "general/os_version" model_search = "general/model" model_identifier_search = "general/model_identifier" versions, models = [], [] for device in devices: if device.findtext(os_type_search) == os_type: versions.append(device.findtext(os_version_search) or "No Version Inventoried") models.append("%s / %s" % ( device.findtext(model_search) or "No Model", device.findtext(model_identifier_search,) or "No Model Identifier")) version_counts = Counter(versions) # Standardize version number format. version_counts = fix_version_counts(version_counts) model_counts = Counter(models) total = len(devices) # Report on OS version spread strings = sorted(get_histogram_strings(version_counts, padding=8)) version_metadata = {"%s Version Histogram (%s)" % (os_type, total): strings} # Report on Model Spread # Compare on the model identifier since it is an easy numerical # sort. strings = sorted(get_histogram_strings(model_counts, padding=8), key=model_compare) model_metadata = {"Hardware Model Histogram (%s)" % total: strings} return (version_metadata, model_metadata) def model_compare(histogram_string): """Return Model Identifier for use as key in sorted() function. Args: histogram_string: Histogram string comprising of Mac model, count and emoji e.g. iMac Intel (21.5-inch, Late 2013) / iMac14,1 (2): 🍕🍕🍕🍕🍕🍕 Returns: Model Identifier e.g. iMac14,1 """ pattern = re.compile(r"(\D+\d+,\d+)") string_search = re.search(pattern, histogram_string) if string_search: return string_search.group(1) def build_computers_report(check_in_period, **kwargs): """Build a report of out-of-date or unresponsive computers. Finds the newest OS version and looks for computers which are out of date. (Builds a histogram of installed OS versions). Also, compiles a list of computers which have not checked in for 'check_in_period' days. Finally, does a report on the hardware models present. Args: check_in_period: Integer number of days since last check-in to include in report. Defaults to 30. Returns: A Report object. """ # All report functions support kwargs to support a unified interface, # even if they don't use them. _ = kwargs jss_connection = JSSConnection.get() all_computers = jss_connection.Computer(["general", "hardware", "groups_accounts"]).retrieve_all() if all_computers: report = build_device_report(check_in_period, all_computers) report.heading = "Computer Report" else: report = Report("Computer", [], "Computer Report", {}) return report def build_mobile_devices_report(check_in_period, **kwargs): """Build a report of out-of-date or unresponsive mobile devices. Finds the newest OS version and looks for devices which are out of date. (Builds a histogram of installed OS versions). Also, compiles a list of computers which have not checked in for 'check_in_period' days. Finally, does a report on the hardware models present. Args: check_in_period: Integer number of days since last check-in to include in report. Defaults to 30. Returns: A Report object. """ # All report functions support kwargs to support a unified interface, # even if they don't use them. _ = kwargs jss_connection = JSSConnection.get() mobile_devices = jss_connection.MobileDevice( ["general", "mobile_device_groups", "mobiledevicegroups"] ).retrieve_all() if mobile_devices: report = build_device_report(check_in_period, mobile_devices) report.heading = "Mobile Device Report" else: report = Report("MobileDevice", [], "Mobile Device Report", {}) return report def validate_check_in_period(check_in_period): """Ensure check_in_period argument is correct. Args: check_in_period: Number of days to consider out of date. Returns: A valid int check-in-period number of days. """ if not check_in_period: check_in_period = 30 if not isinstance(check_in_period, int): try: check_in_period = int(check_in_period) except ValueError: print("Incorrect check-in period given. Setting to 30.") check_in_period = 30 return check_in_period def hour_pad(datetime_string): """Fix time strings' zero padding. JAMF's dates don't always properly zero pad the hour. Do so. Args: datetime_string: A time string as referenced in MobileDevice's last_inventory_time field. Returns: The string plus any zero padding required. """ # Example mobile device time format: # Friday, August 07 2015 at 3:51 PM # Monday, February 10 2014 at 8:42 AM< components = datetime_string.split() if len(components[5]) == 1: components[5] = "0" + components[5] return " ".join(components) def build_packages_report(**kwargs): """Report on package usage. Looks for packages which are not installed by any policies or computer configurations. Returns: A Report object. """ # All report functions support kwargs to support a unified interface, # even if they don't use them. _ = kwargs jss_connection = JSSConnection.get() all_policies = jss_connection.Policy( ["general", "package_configuration", "packages"] ).retrieve_all() all_configs = jss_connection.ComputerConfiguration().retrieve_all() all_packages = [(pkg.id, pkg.name) for pkg in jss_connection.Package()] if not all_packages: report = Report("Package", [], "Package Usage Report", {}) else: policy_xpath = "package_configuration/packages/package" config_xpath = "packages/package" report = build_container_report( [(all_policies, policy_xpath), (all_configs, config_xpath)], all_packages) report.get_result_by_name("Used").description = ( "All packages which are installed by policies or imaging " "configurations.") report.get_result_by_name("Unused").description = ( "All packages which are not installed by any policies or imaging " "configurations.") report.heading = "Package Usage Report" return report def build_printers_report(**kwargs): """Report on printer usage. Looks for printers which are not installed by any policies or computer configurations. Returns: A Report object. """ # All report functions support kwargs to support a unified interface, # even if they don't use them. _ = kwargs jss_connection = JSSConnection.get() all_policies = jss_connection.Policy(["general", "printers"]).retrieve_all() all_configs = jss_connection.ComputerConfiguration().retrieve_all() all_printers = [(printer.id, printer.name) for printer in jss_connection.Printer()] if not all_printers: report = Report("Printer", [], "Printer Usage Report", {}) else: policy_xpath = "printers/printer" config_xpath = "printers/printer" report = build_container_report( [(all_policies, policy_xpath), (all_configs, config_xpath)], all_printers) report.get_result_by_name("Used").description = ( "All printers which are installed by policies or imaging " "configurations.") report.get_result_by_name("Unused").description = ( "All printers which are not installed by any policies or imaging " "configurations.") report.heading = "Printer Usage Report" return report def build_scripts_report(**kwargs): """Report on script usage. Looks for scripts which are not executed by any policies or computer configurations. Returns: A Report object. """ # All report functions support kwargs to support a unified interface, # even if they don't use them. _ = kwargs jss_connection = JSSConnection.get() all_policies = jss_connection.Policy(["general", "scripts"]).retrieve_all() all_configs = jss_connection.ComputerConfiguration().retrieve_all() all_scripts = [(script.id, script.name) for script in jss_connection.Script()] if not all_scripts: report = Report("Script", [], "Script Usage Report", {}) else: policy_xpath = "scripts/script" config_xpath = "scripts/script" report = build_container_report( [(all_policies, policy_xpath), (all_configs, config_xpath)], all_scripts) report.get_result_by_name("Used").description = ( "All scripts which are installed by policies or imaging " "configurations.") report.get_result_by_name("Unused").description = ( "All scripts which are not installed by any policies or imaging " "configurations.") report.heading = "Script Usage Report" return report def build_group_report(container_searches, groups_names, full_groups): """Report on group usage. Looks for computer or mobile device groups with no members. This does not mean they neccessarily are in-need-of-deletion. Args: container_searches: List of tuples to be passed to build_container_report. groups: List of (id, name) tuples for all groups on the JSS. full_groups: List of full JSSObject data for groups. Returns: A Report object. """ # Build results for groups which aren't scoped. report = build_container_report(container_searches, groups_names) # Here we do more work, since Smart Groups can nest other groups. # We want to remove any groups nested (at any level) within a group # that is used. # For convenience, pull out unused and used sets. unused_groups = report.get_result_by_name("Unused").results used_groups = report.get_result_by_name("Used").results used_full_group_objects = get_full_groups_from_names(used_groups, full_groups) full_used_nested_groups = get_nested_groups(used_full_group_objects, full_groups) used_nested_groups = get_names_from_full_objects(full_used_nested_groups) # Remove the nested groups from the unused list and add to the used. unused_groups.difference_update(used_nested_groups) # There's no harm in doing a union with the nested used groups vs. # adding _just_ the ones removed from unused_groups. used_groups.update(used_nested_groups) # Recalculate cruftiness unused_cruftiness = calculate_cruft(unused_groups, groups_names) obj_type = device_type(full_groups) report.metadata["Cruftiness"][ "Unscoped %s Cruftiness" % obj_type] = ( get_cruft_strings(unused_cruftiness)) # Build Empty Groups Report. empty_groups = get_empty_groups(full_groups) report.results.append(empty_groups) # Calculate empty cruftiness. empty_cruftiness = calculate_cruft(empty_groups, groups_names) report.metadata["Cruftiness"]["Empty Group Cruftiness"] = ( get_cruft_strings(empty_cruftiness)) # Build No Criteria Groups Report. no_criteria_groups = get_no_criteria_groups(full_groups) report.results.append(no_criteria_groups) # Calculate empty cruftiness. no_criteria_cruftiness = calculate_cruft(no_criteria_groups, groups_names) report.metadata["Cruftiness"]["No Criteria Group Cruftiness"] = ( get_cruft_strings(no_criteria_cruftiness)) return report def build_computer_groups_report(**kwargs): """Report on computer groups usage. Looks for computer groups with no members. This does not mean they neccessarily are in-need-of-deletion. Returns: A Report object. """ # All report functions support kwargs to support a unified interface, # even if they don't use them. _ = kwargs jss_connection = JSSConnection.get() group_list = jss_connection.ComputerGroup() if not group_list: return Report("ComputerGroup", [], "Computer Group Report", {}) all_computer_groups = [(group.id, group.name) for group in group_list] full_groups = group_list.retrieve_all() # all_policies = jss_connection.Policy().retrieve_all( # subset=[]) all_policies = jss_connection.Policy(["general", "scope"]).retrieve_all() # all_configs = jss_connection.OSXConfigurationProfile().retrieve_all( # subset=["general", "scope"]) all_configs = jss_connection.OSXConfigurationProfile( ["general", "scope"]).retrieve_all() # Account for fix in python-jss that isn't yet part of a release. if hasattr(jss_connection, 'RestrictedSoftware'): all_restricted_software = jss_connection.RestrictedSoftware().retrieve_all() else: all_restricted_software = jss_connection.RestrictedSfotware().retrieve_all() scope_xpath = "scope/computer_groups/computer_group" scope_exclusions_xpath = ( "scope/exclusions/computer_groups/computer_group") # Build results for groups which aren't scoped. report = build_group_report( [(all_policies, scope_xpath), (all_policies, scope_exclusions_xpath), (all_configs, scope_xpath), (all_configs, scope_exclusions_xpath), (all_restricted_software, scope_xpath), (all_restricted_software, scope_exclusions_xpath)], all_computer_groups, full_groups) report.heading = "Computer Group Usage Report" report.get_result_by_name("Used").description = ( "All groups which participate in scoping. Computer groups are " "considered to be in-use if they are designated in the scope or the " "exclusions of a policy or a configuration profile. This report " "includes all groups which are nested inside of smart groups using " "the 'member_of' criterion.") report.get_result_by_name("Unused").description = ( "All groups which do not participate in scoping. Computer groups are " "considered to be in-use if they are designated in the scope or the " "exclusions of a policy or a configuration profile. This report " "includes all groups which are nested inside of smart groups using " "the 'member_of' criterion.") return report def build_computer_ea_report(**kwargs): """Report on computer extension attributes usage. Looks for computer extension attributes not being used as criteria in any smart groups. This does not mean they neccessarily are in-need-of-deletion. Returns: A Report object. """ # All report functions support kwargs to support a unified interface, # even if they don't use them. _ = kwargs jss_connection = JSSConnection.get() all_eas = [(ea.id, ea.name) for ea in jss_connection.ComputerExtensionAttribute()] if not all_eas: return Report("Computer Extension Attribute", [], "Computer Extension Attribute Usage Report", {}) all_eas_result = Result(all_eas, False, "All Computer Extension Attributes") # Build results for extension attributes which aren't used in criteria. all_groups = jss_connection.ComputerGroup().retrieve_all() used_criteria = [] for group in all_groups: criteria_names = get_all_criteria_names(group) for criteria_name in criteria_names: if criteria_name not in used_criteria: used_criteria.append(criteria_name) unused_eas = [ea for ea in all_eas if ea[1] not in used_criteria] desc = ("All extension attributes which are not used in computer group criteria.") unused = Result(unused_eas, True, "Unused Computer Extension Attributes", desc) unused_cruftiness = calculate_cruft(unused_eas, all_eas) report = Report("Computer Extension Attribute", [unused, all_eas_result], "Computer Extension Attribute Report", {"Cruftiness": {}}) report.metadata["Cruftiness"]["Unused Computer Extension Attribute Cruftiness"] = ( get_cruft_strings(unused_cruftiness)) return report def get_all_criteria_names(group): """Get the names of any extension attribute criteria in a group, or an empty set. Args: group: A jss.ComputerGroup object to search for extension attributes. Returns: A tuple of the extension attribute criteria in the provided group. Returns an empty set if no extension attributes are present. """ return ( criterion.findtext("name") for criterion in group.findall("criteria/criterion") if criterion.findtext("search_type") != "member of") def build_device_groups_report(**kwargs): """Report on mobile device groups usage. Looks for mobile device groups with no members. This does not mean they neccessarily are in-need-of-deletion. Returns: A Report object. """ # All report functions support kwargs to support a unified interface, # even if they don't use them. _ = kwargs jss_connection = JSSConnection.get() group_list = jss_connection.MobileDeviceGroup() if not group_list: return Report("MobileDeviceGroup", [], "Mobile Device Group Report", {}) all_mobile_device_groups = [(group.id, group.name) for group in group_list] full_groups = group_list.retrieve_all() all_configs = ( jss_connection.MobileDeviceConfigurationProfile(["general", "scope"]).retrieve_all() ) all_provisioning_profiles = ( jss_connection.MobileDeviceProvisioningProfile(["general", "scope"]).retrieve_all() ) all_apps = ( jss_connection.MobileDeviceApplication(["general", "scope"]).retrieve_all() ) all_ebooks = ( jss_connection.EBook(["general", "scope"]).retrieve_all() ) xpath = "scope/mobile_device_groups/mobile_device_group" exclusion_xpath = ( "scope/exclusions/mobile_device_groups/mobile_device_group") # Build results for groups which aren't scoped. report = build_group_report( [(all_configs, xpath), (all_configs, exclusion_xpath), (all_provisioning_profiles, xpath), (all_provisioning_profiles, exclusion_xpath), (all_apps, xpath), (all_apps, exclusion_xpath), (all_ebooks, xpath), (all_ebooks, exclusion_xpath)], all_mobile_device_groups, full_groups) report.heading = "Mobile Device Group Usage Report" report.get_result_by_name("Used").description = ( "All groups which participate in scoping. Mobile device groups are " "considered to be in-use if they are designated in the scope or the " "exclusions of a configuration profile, provisioning profile, app, " "or ebook. This report includes all groups which are nested inside " "of smart groups using the 'member_of' criterion.") report.get_result_by_name("Unused").description = ( "All groups which do not participate in scoping. Mobile device groups " "are considered to be in-use if they are designated in the scope or " "the exclusions of a configuration profile, provisioning profile, " "app, or ebook. This report includes all groups which are nested " "inside of smart groups using the 'member_of' criterion.") return report def build_policies_report(**kwargs): """Report on policy usage. Looks for policies which are not scoped to anything or are disabled. Returns: A Report object. """ # All report functions support kwargs to support a unified interface, # even if they don't use them. _ = kwargs jss_connection = JSSConnection.get() all_policies = jss_connection.Policy(["general", "scope"]).retrieve_all() if not all_policies: return Report("Policy", [], "Policy Usage Report", {}) all_policies_result = Result([(policy.id, policy.name) for policy in all_policies], False, "All Policies") unscoped_policies = [(policy.id, policy.name) for policy in all_policies if policy.findtext("scope/all_computers") == "false" and not policy.findall("scope/computers/computer") and not policy.findall( "scope/computer_groups/computer_group") and not policy.findall("scope/buildings/building") and not policy.findall("scope/departments/department")] desc = ("Policies which are not scoped to any computers, computer groups, " "buildings, departments, or to the all_computers meta-scope.") unscoped = Result(unscoped_policies, True, "Policies not Scoped", desc) unscoped_cruftiness = calculate_cruft(unscoped_policies, all_policies) disabled_policies = [(policy.id, policy.name) for policy in all_policies if policy.findtext("general/enabled") == "false"] disabled = Result(disabled_policies, True, "Disabled Policies", "Policies which are currently disabled " "(Policy/General/Enabled toggle).") disabled_cruftiness = calculate_cruft(disabled_policies, all_policies) report = Report("Policy", [unscoped, disabled, all_policies_result], "Policy Report", {"Cruftiness": {}}) report.metadata["Cruftiness"]["Unscoped Policy Cruftiness"] = ( get_cruft_strings(unscoped_cruftiness)) report.metadata["Cruftiness"]["Disabled Policy Cruftiness"] = ( get_cruft_strings(disabled_cruftiness)) return report def build_config_profiles_report(**kwargs): """Report on computer configuration profile usage. Looks for profiles which are not scoped to anything. Returns: A Report object. """ # All report functions support kwargs to support a unified interface, # even if they don't use them. _ = kwargs jss_connection = JSSConnection.get() all_configs = jss_connection.OSXConfigurationProfile(["general", "scope"]).retrieve_all() if not all_configs: return Report("Computer Configuration Profile", [], "Computer Configuration Profile Report", {}) all_configs_result = Result([(config.id, config.name) for config in all_configs], False, "All OSX Configuration " "Profiles") unscoped_configs = [(config.id, config.name) for config in all_configs if config.findtext("scope/all_computers") == "false" and not config.findall("scope/computers/computer") and not config.findall("scope/computer_groups/" "computer_group") and not config.findall("scope/buildings/building") and not config.findall("scope/departments/department")] desc = ("Computer configuration profiles which are not scoped to any " "computers, computer groups, buildings, departments, or to the " "all_computers meta-scope.") unscoped = Result(unscoped_configs, True, "Computer Configuration Profiles not Scoped", desc) unscoped_cruftiness = calculate_cruft(unscoped_configs, all_configs) report = Report("Computer Configuration Profile", [unscoped, all_configs_result], "Computer Configuration Profile Report", {"Cruftiness": {}}) report.metadata["Cruftiness"]["Unscoped Profile Cruftiness"] = ( get_cruft_strings(unscoped_cruftiness)) return report def build_md_config_profiles_report(**kwargs): """Report on mobile device configuration profile usage. Looks for profiles which are not scoped to anything. Returns: A Report object. """ # All report functions support kwargs to support a unified interface, # even if they don't use them. _ = kwargs jss_connection = JSSConnection.get() all_configs = ( jss_connection.MobileDeviceConfigurationProfile(["general", "scope"]).retrieve_all() ) if not all_configs: return Report("Mobile Device Configuration Profile", [], "Mobile Device Configuration Profile Report", {}) all_configs_result = Result([(config.id, config.name) for config in all_configs], False, "All iOS Configuration " "Profiles") unscoped_configs = [(config.id, config.name) for config in all_configs if config.findtext("scope/all_mobile_devices") == "false" and not config.findall("scope/mobile_devices/mobile_device") and not config.findall( "scope/mobile_device_groups/mobile_device_group") and not config.findall("scope/jss_users/user") and not config.findall( "scope/jss_user_groups/user_group") and not config.findall("scope/buildings/building") and not config.findall("scope/departments/department")] desc = ("Mobile device configuration profiles which are not scoped to any " "devices, device groups, users, user groups, buildings, " "departments, or to the all_mobile_devices meta-scope.") unscoped = Result(unscoped_configs, True, "Mobile Device Configuration Profiles not Scoped", desc) unscoped_cruftiness = calculate_cruft(unscoped_configs, all_configs) report = Report("Mobile Device Configuration Profile", [unscoped, all_configs_result], "Mobile Device Configuration Profile Report", {"Cruftiness": {}}) report.metadata["Cruftiness"]["Unscoped Profile Cruftiness"] = ( get_cruft_strings(unscoped_cruftiness)) return report def build_apps_report(**kwargs): """Report on out of date and unscoped mobile apps. Returns: A Report object. """ # All report functions support kwargs to support a unified interface, # even if they don't use them. _ = kwargs jss_connection = JSSConnection.get() all_apps = ( jss_connection.MobileDeviceApplication(["general", "scope"]).retrieve_all() ) if not all_apps: return Report("Mobile Application", [], "Mobile Device Application Report", {}) all_apps_result = Result([(app.id, app.name) for app in all_apps], False, "All Mobile Device Applications") # Find apps not scoped anywhere. unscoped_apps = [(app.id, app.name) for app in all_apps if app.findtext("scope/all_mobile_devices") == "false" and app.findtext("scope/all_jss_users") == "false" and not app.findall("scope/mobile_devices/mobile_device") and not app.findall( "scope/mobile_device_groups/mobile_device_group") and app.findall("scope/jss_users/user") and not app.findall( "scope/jss_user_groups/user_group") and not app.findall("scope/buildings/building") and not app.findall("scope/departments/department")] desc = ("Mobile Applications which are not scoped to any " "devices, device groups, users, user groups, buildings, " "departments, or to the all_mobile_devices or all_jss_users " "meta-scopes.") unscoped = Result(unscoped_apps, True, "Mobile Device Applications not Scoped", desc) unscoped_cruftiness = calculate_cruft(unscoped_apps, all_apps) report = Report("Mobile Application", [unscoped, all_apps_result], "Mobile Device Application Report", {"Cruftiness": {}}) report.metadata["Cruftiness"]["Unscoped App Cruftiness"] = ( get_cruft_strings(unscoped_cruftiness)) # Find out-of-date and discontinued apps. out_of_date = {} discontinued = [] # Start a requests session. session = requests.session() for app in all_apps: external_url = app.findtext("general/external_url") if external_url: page = session.get(external_url).text version_parser = AppStoreVersionParser() version_parser.feed(page) current_version = version_parser.version if app.findtext("general/version") != current_version: out_of_date[app.name] = (app.findtext("general/version"), current_version) if current_version == "Version Not Found": discontinued.append((app.id, app.name)) report.metadata["Out-of-Date Apps"] = {} report.metadata["Out-of-Date Apps"]["Out-of-Date Apps"] = ( get_out_of_date_strings(out_of_date)) desc = ("Mobile applications which are no longer available from the Apple " " App Store.") discontinued_result = Result(discontinued, True, "Apps No Longer Available", desc) report.results.append(discontinued_result) out_of_date_cruftiness = calculate_cruft(out_of_date, all_apps) report.metadata["Cruftiness"]["Out-of-Date App Cruftiness"] = ( get_cruft_strings(out_of_date_cruftiness)) discontinued_cruftiness = calculate_cruft(discontinued, all_apps) report.metadata["Cruftiness"]["Discontinued App Cruftiness"] = ( get_cruft_strings(discontinued_cruftiness)) return report def get_nested_groups(groups, full_groups): """Get all of the groups 'nested' in an iterable of groups. A smart group may include other groups with a Computer Group criterion. This function will find all of the groups nested within a provided iterable of jss.ComputerGroup objects (including nested groups that _also_ nest groups). Args: groups: An iterable of jss.ComputerGroup objects to search for nested groups within. full_groups: A list of all computer groups. This will hopefully be deprecated once a connection caching procedure is devised. Returns: A set of groups nested within the original groups argument. """ results = set() for group in groups: # Get the names of any nested groups. nested_groups_names = get_nested_groups_names(group) if nested_groups_names: # Function needs full objects, and criteria only specify # the name, so we need to "convert" names to full objects. nested_groups = get_full_groups_from_names(nested_groups_names, full_groups) # Add the nested groups to the results. results.update(nested_groups) # Recursively look for any groups nested in the nested # groups. results.update(get_nested_groups(nested_groups, full_groups)) # If no groups are nested, we are done. return results def get_nested_groups_names(group): """Get the names of any nested groups in a group, or an empty set. Args: group: A jss.ComputerGroup object to search for nested groups. Returns: A tuple of the group names nested in the provided group. Returns an empty set if no nested groups are present. """ # print(group.criteria.criterion) return_groups = [] for criterion in group.findall("criteria/criterion"): try: criterion.name.text if (criterion.name.text in ("Computer Group", "Mobile Device Group") and criterion.search_type.text == "member of"): return_groups.append(criterion.value.text) except AttributeError: # print(criterion) pass return return_groups # return ( # criterion.value.text # for criterion in group.findall("criteria/criterion") if # criterion.name.text in ("Computer Group", "Mobile Device Group") # and criterion.search_type.text == "member of") # return ( # criterion.value # for criterion in group.findall("criteria/criterion") if # criterion.name in ("Computer Group", "Mobile Device Group") # and criterion.search_type == "member of") def get_full_groups_from_names(groups, full_groups): """Given a list a of group names, get the full objects. Args: groups: A list of names. full_groups: A list of all jss.ComputerGroup or jss.MobileDeviceGroup objects. Returns: A list of jss.ComputerGroup or jss.MobileDeviceGroup objects corresponding to the names given by the groups argument. """ return_full_groups = [] for full_group in full_groups: if full_group.is_smart is True: for group in groups: group_name = group[1] if full_group.name == group_name: return_full_groups.append(full_group) continue return return_full_groups # return [full_group for group in groups for full_group in # full_groups if full_group.name == group[1]] def get_names_from_full_objects(objects): """Return a list of object names provided list of full objects.""" return [obj.name for obj in objects] def get_empty_groups(full_groups): """Return all groups with no members as a Result. Args: full_groups: list of all groups from jss; i.e. jss_connection.ComputerGroup().retrieve_all() Returns: Result object. """ if isinstance(full_groups[0], jss.ComputerGroup): obj_type = ("computers", "Computer") elif isinstance(full_groups[0], jss.MobileDeviceGroup): obj_type = ("mobile_devices", "Mobile Device") else: raise TypeError("Incorrect group type.") groups_with_no_members = {(group.id, group.name) for group in full_groups if group.findtext("%s/size" % obj_type[0]) == "0"} return Result(groups_with_no_members, True, "Empty %s Groups" % obj_type[1], "%s groups which have no members." % obj_type[1]) def get_no_criteria_groups(full_groups): """Return a Result with all smart groups that have no criteria. Args: full_groups: list of all groups from jss; i.e. jss_connection.ComputerGroup().retrieve_all() Returns: Result object. """ if isinstance(full_groups[0], jss.ComputerGroup): obj_type = ("computers", "Computer") elif isinstance(full_groups[0], jss.MobileDeviceGroup): obj_type = ("mobile_devices", "Mobile Device") else: raise TypeError("Incorrect group type.") groups_with_no_criteria = {(group.id, group.name) for group in full_groups if group.findtext("is_smart") == "true" and int(group.findtext('criteria/size')) == 0} return Result(groups_with_no_criteria, True, "No Criteria %s Groups" % obj_type[1], "%s groups which have no criteria." % obj_type[1]) def has_no_group_membership(device): """Test whether a computer or mobile device belongs to any groups. This test does not count membership in the default smart groups: "All Managed Clients", "All Managed Servers", "All Managed iPads", "All Managed iPhones", "All Managed iPod touches" Args: device: A jss.Computer or jss.MobileDevice object. Returns: Bool. """ excluded_groups = ("All Managed Clients", "All Managed Servers", "All Managed iPads", "All Managed iPhones", "All Managed iPod touches") if isinstance(device, jss.Computer): xpath = "groups_accounts/computer_group_memberships/group" group_membership = [group.text for group in device.findall(xpath) if not group.text in excluded_groups] elif isinstance(device, jss.MobileDevice): xpath = "mobile_device_groups/mobile_device_group" group_membership = [group.findtext("name") for group in device.findall(xpath) if not group.findtext("name") in excluded_groups] else: raise TypeError if group_membership: result = False else: result = True return result def calculate_cruft(dividend, divisor): """Zero-safe find percentage of a subgroup within a larger group.""" if divisor: result = float(len(dividend)) / len(divisor) else: result = 0.0 return result def print_output(report, verbose=False): """Print report data. Args: reports: Report object. verbose: Bool, whether to print all results or just unused results. """ # Handle command line arguments. parser = build_argparser() args = parser.parse_args() # set emoji if not args.kawaii: SPRUCE = "*" elif sys.version_info[0] < 3: SPRUCE = "\xF0\x9F\x8C\xB2" else: SPRUCE = "\N{evergreen tree}" # Indent is a space and a spruce emoji wide (so 3). indent_size = 3 * " " forest_length = (64 - len(report.heading)) / 2 print("%s %s %s " % (SPRUCE, report.heading, SPRUCE * int(forest_length))) if not report.results: print("%s No Results %s" % (SPRUCE, SPRUCE)) else: for result in report.results: if not result.include_in_non_verbose and not verbose: continue else: print("\n%s %s (%i)" % ( SPRUCE, result.heading, len(result.results))) if result.description: print(textwrap.fill(result.description, initial_indent=indent_size, subsequent_indent=indent_size)) print("") for line in sorted(result.results, key=lambda s: s[1].upper().strip()): if line[1].strip() == "": text = "(***NO NAME: ID is %s***)" % line[0] else: text = line[1] print("\t%s" % text) for heading, subsection in report.metadata.items(): print("\n%s %s %s" % (SPRUCE, heading, SPRUCE)) for subheading, strings in subsection.items(): print("%s %s" % (SPRUCE, subheading)) for line in strings: print("\t%s" % line) def get_cruftmoji(percentage): """Return one of 11 possible emojis depending on how crufty. Args: percentage: A float between 0 and 1. Returns: An emoji string. """ # emoji are not handled the same in python2 and 3 so we need a different kind # of encoding for each. if sys.version_info[0] < 3: PIZZA = "\xf0\x9f\x8d\x95" ALIEN = "\xf0\x9f\x91\xbe" BEER = "\xf0\x9f\x8d\xbb" CROSSED_ARMS = "\xf0\x9f\x99\x8f" SNAKE = "\xf0\x9f\x90\x8d" PLANE = "\xe2\x9c\x88\xef\xb8\x8f" GUARD = "\xf0\x9f\x92\x82" GHOST = "\xf0\x9f\x91\xbb" BOMB = "\xf0\x9f\x92\xa3" POODLE = "\xf0\x9f\x90\xa9" WIND = "\xf0\x9f\x92\xa8" SKULL = "\xf0\x9f\x92\x80" VHS = "\xf0\x9f\x93\xbc" CACTUS = "\xf0\x9f\x8c\xb5" POO = "\xf0\x9f\x92\xa9" else: PIZZA = "\N{slice of pizza}" ALIEN = "\N{alien monster}" BEER = "\N{clinking beer mugs}" CROSSED_ARMS = "\N{person with folded hands}" SNAKE = "\N{snake}" PLANE = "\N{airplane}" GUARD = "\N{guardsman}" GHOST = "\N{ghost}" BOMB = "\N{bomb}" POODLE = "\N{poodle}" WIND = "\N{dash symbol}" SKULL = "\N{skull}" VHS = "\N{videocassette}" CACTUS = "\N{cactus}" POO = "\N{pile of poo}" # Handle command line arguments. parser = build_argparser() args = parser.parse_args() if not args.kawaii: level = [ "Master", "Snakes on a Plane", "Furry Hat Pizza Party", "Ghost", "The Bomb", "Farting Poodle", "Skull", "Video Cassette", "Cactus", "Smiling Poo", "Three steaming piles of poo"] return str(level[int(percentage * 10)]) else: level = [ # Master "%s %s %s %s %s %s %s" % (CROSSED_ARMS, BEER, PIZZA, ALIEN, PIZZA, BEER, CROSSED_ARMS), # Snakes on a Plane "%s %s %s" % (SNAKE, SNAKE, PLANE), # Furry Hat Pizza Party "%s %s %s" % (PIZZA, GUARD, PIZZA), GHOST, # Ghost BOMB, # The Bomb "%s %s" % (POODLE, WIND), # Poodle Fart SKULL, # Skull VHS, # VHS Cassette CACTUS, # Cactus POO, # Smiling Poo "%s %s %s" % (POO, POO, POO)] # Smiling Poo (For 100%) if sys.version_info[0] < 3: return level[int(percentage * 10)].decode("utf-8") else: return str(level[int(percentage * 10)]) def get_cruft_strings(cruft): """Generate a list of strings for cruft reports.""" # Handle command line arguments. parser = build_argparser() args = parser.parse_args() if args.kawaii: return ["{:.2%}".format(cruft), "Rank: %s" % get_cruftmoji(cruft)] else: return ["{:.2%}".format(cruft)] def get_terminal_size(): """Get the size of the terminal window.""" rows, columns = subprocess.check_output(["stty", "size"]).split() return (int(rows), int(columns)) def fix_version_counts(version_counts): """Fix too short version names by appending a '.0'. Args: version_counts: Dict of key: version name val: Count of clients. Returns: The updated version_counts dict. """ result = {} ignored = ("", "No Version Inventoried") for version in version_counts: if version.count(".") < 2 and version not in ignored: updated_version = "%s.0" % version else: updated_version = version result[updated_version] = version_counts[version] return result def get_histogram_strings(data, padding=0): """Generate a horizontal text histogram. Given a dictionary of items, generate a list of column aligned, padded strings. Args: data: Dict with key: string heading/name val: Float between 0 and 1 for histogram value. padding: int number of characters to subtract from max bar size. Defaults to zero. (If you intend on indenting, the indent level should be specified to make sure large bars don't overflow the length of the terminal. hist_char: Single character string to use as bar fill. Defaults to '#'. Returns: List of strings ready to print. """ parser = build_argparser() args = parser.parse_args() if not args.kawaii: hist_char = "||" elif sys.version_info[0] < 3: hist_char = "\xf0\x9f\x8d\x95" else: hist_char = "\N{slice of pizza}" max_key_width = max([len(key) for key in data]) max_val_width = max([len(str(val)) for val in list(data.values())]) max_value = max(data.values()) _, width = get_terminal_size() # Find the length we have left for the histogram bars. # Magic number 6 is the _():_ parts of the string, and the # guaranteed value of one that gets added. # all divided by 3 to take account of the extra width of a pizza slice histogram_width = (width - padding - max_key_width - max_val_width - 6) / 3 result = [] for key, val in data.items(): preamble = "{:>{max_key}} ({:>{max_val}}): ".format( key, val, max_key=max_key_width, max_val=max_val_width) #percentage = float(val) / osx_clients percentage = float(val) / max_value histogram_bar = int(percentage * histogram_width + 1) * hist_char try: result.append((preamble + histogram_bar).decode("utf-8")) except AttributeError: result.append(preamble + histogram_bar) return result def get_out_of_date_strings(data): """Build a list of strings for data with three items. Given a dictionary of items, generate a list of column aligned, padded strings. Args: data: Dict with key: string heading/name val: 2-Tuple of data to fill in string. Returns: List of strings ready to print. """ result = [] if data: max_key_width = max([len(key) for key in data]) max_val1_width = max([len(str(val[0])) for val in list(data.values())]) max_val2_width = max([len(str(val[1])) for val in list(data.values())]) for key, val in data.items(): output_string = ("{:>{max_key}} JSS Version:{:>{max_val1}} App " "Store Version: {:>{max_val2}}".format( key, val[0], val[1], max_key=max_key_width, max_val1=max_val1_width, max_val2=max_val2_width)) result.append(output_string) return result def add_output_metadata(root): """Build the main metadata and tags for an XML report. Args: root: Element to be used as the root for the report. """ jss_connection = JSSConnection.get() report_date = ET.SubElement(root, "ReportDate") report_date.text = datetime.datetime.strftime(datetime.datetime.now(), "%Y%m%d-%H%M%S") report_server = ET.SubElement(root, "Server") report_server.text = jss_connection.base_url api_user = ET.SubElement(root, "APIUser") api_user.text = jss_connection.user report_user = ET.SubElement(root, "LocalUser") report_user.text = os.getenv("USER") spruce_version = ET.SubElement(root, "SpruceVersion") spruce_version.text = __version__ python_jss_version = ET.SubElement(root, "python-jssVersion") python_jss_version.text = jss.__version__ removals = ET.SubElement(root, "Removals") removals.insert(0, ET.Comment("Move items to be removed here")) def add_report_output(root, report): """Write the results to an xml file. Args: results: A Result object. ofile: String path to desired output filename. """ report_element = ET.SubElement(root, tagify(report.heading)) # Results for result in report.results: if not result.include_in_non_verbose: continue subreport_element = ET.SubElement(report_element, tagify(result.heading)) subreport_element.attrib["length"] = str(len(result)) desc = ET.SubElement(subreport_element, "Description") desc.text = result.description for id_, name in sorted(result.results, key=lambda x: x[1]): item = ET.SubElement(subreport_element, tagify(report.obj_type)) item.text = name item.attrib["id"] = str(id_) # Metadata for metadata, val in report.metadata.items(): metadata_element = ET.SubElement(report_element, tagify(metadata)) #subreport_element.attrib["length"] = str(len(result)) for submeta, submeta_val in val.items(): item = ET.SubElement(metadata_element, tagify(submeta)) for line in submeta_val: value = ET.SubElement(item, "Value") #value.text = line.encode("ascii", errors="replace").strip() value.text = line.strip() def tagify(text): """Make a string appropriate for XML tag names.""" if "(" in text: text = text.split("(")[0] return text.title().replace(" ", "") def indent(elem, level=0, more_sibs=False): """Indent an xml element object to prepare for pretty printing. Method is internal to discourage indenting the self._root Element, thus potentially corrupting it. """ i = "\n" pad = ' ' if level: i += (level - 1) * pad num_kids = len(elem) if num_kids: if not elem.text or not elem.text.strip(): elem.text = i + pad if level: elem.text += pad count = 0 for kid in elem: if kid.tag == "data": kid.text = "*DATA*" indent(kid, level+1, count < num_kids - 1) count += 1 if not elem.tail or not elem.tail.strip(): elem.tail = i if more_sibs: elem.tail += pad else: if level and (not elem.tail or not elem.tail.strip()): elem.tail = i if more_sibs: elem.tail += pad def build_argparser(): """Create our argument parser.""" parser = argparse.ArgumentParser( description=DESCRIPTION, formatter_class=argparse.RawDescriptionHelpFormatter) # Global Args phelp = ("Include a list of all objects and used objects in addition to " "unused objects in reports.") parser.add_argument("-v", "--verbose", help=phelp, action="store_true") phelp = ("Show cute emoji in output and reports.") parser.add_argument("--kawaii", help=phelp, action="store_true") phelp = ("For computer and mobile device reports, the number of " "days since the last check-in to consider device " "out-of-date.") parser.add_argument("--check_in_period", help=phelp) phelp = ("Path to preference file.") parser.add_argument("--prefs", help=phelp) # General Reporting Args general_group = parser.add_argument_group("General Reporting Arguments") phelp = ("Output results to OFILE, in plist format (also usable as " "input to the --remove option).") general_group.add_argument("-o", "--ofile", help=phelp) phelp = ("Generate all reports. With no other arguments, this is " "the default.") general_group.add_argument("-a", "--all", help=phelp, action="store_true") # Computers group = parser.add_argument_group("Computer Reporting Arguments") phelp = "Generate computer report." group.add_argument("-c", "--computers", help=phelp, action="store_true") phelp = "Generate unused computer-groups report (Static and Smart)." group.add_argument("-g", "--computer_groups", help=phelp, action="store_true") phelp = "Generate unused computer extension attribute report." group.add_argument("-e", "--computer_extension_attributes", help=phelp, action="store_true") phelp = "Generate unused package report." group.add_argument("-p", "--packages", help=phelp, action="store_true") phelp = "Generate unused printer report." group.add_argument("--printers", help=phelp, action="store_true") phelp = "Generate unused script report." group.add_argument("-s", "--scripts", help=phelp, action="store_true") phelp = "Generate unused policy report." group.add_argument("-t", "--policies", help=phelp, action="store_true") phelp = "Generate unused computer configuration profile report." group.add_argument("-u", "--computer_configuration_profiles", help=phelp, action="store_true") # Mobile Devices md_group = parser.add_argument_group("Mobile Device Reporting Arguments") phelp = "Generate mobile device report." md_group.add_argument("-d", "--mobile_devices", help=phelp, action="store_true") phelp = "Generate unused mobile-device-groups report (Static and Smart)." md_group.add_argument("-r", "--mobile_device_groups", help=phelp, action="store_true") phelp = "Generate unused mobile-device-profiles report." md_group.add_argument("-m", "--mobile_device_configuration_profiles", help=phelp, action="store_true") phelp = "Generate out-of-date and unused mobile apps report." md_group.add_argument("-b", "--apps", help=phelp, action="store_true") # Removal Args removal_group = parser.add_argument_group("Removal Arguments") phelp = ("Remove all objects specified in supplied XML file REMOVE from " "the subelement 'Removals'. If this option is used, all " "reporting is skipped. The input file is most easily created by " "editing the results of a report done with the -o/--ofile " "option.") removal_group.add_argument("--remove", help=phelp) return parser def run_reports(args): """Runs reports specified as commandline args to spruce. Runs each report specified as a commandline arguement, and outputs by default to stdout, or to a plist file specified with -o/--ofile. Shows report construction progress, and prints report after all data is crunched. Args: args: parsed argparser namespace object for spruce. """ # Define the types of reports we can accept. # TODO: Roll this data structure into the Reports class. reports = {} reports["computers"] = {"heading": "Computer Report", "func": build_computers_report, "report": None} reports["mobile_devices"] = {"heading": "Mobile Device Report", "func": build_mobile_devices_report, "report": None} reports["computer_groups"] = {"heading": "Computer Groups Report", "func": build_computer_groups_report, "report": None} reports["computer_extension_attributes"] = { "heading": "Computer Extension Attributes Report", "func": build_computer_ea_report, "report": None} reports["packages"] = {"heading": "Package Report", "func": build_packages_report, "report": None} reports["printers"] = {"heading": "Printers Report", "func": build_printers_report, "report": None} reports["scripts"] = {"heading": "Scripts Report", "func": build_scripts_report, "report": None} reports["policies"] = {"heading": "Policy Report", "func": build_policies_report, "report": None} reports["computer_configuration_profiles"] = { "heading": "Computer Configuration Profile Report", "func": build_config_profiles_report, "report": None} reports["mobile_device_configuration_profiles"] = { "heading": "Mobile Device Configuration Profile Report", "func": build_md_config_profiles_report, "report": None} reports["mobile_device_groups"] = { "heading": "Mobile Device Group Report", "func": build_device_groups_report, "report": None} reports["apps"] = { "heading": "Mobile Apps", "func": build_apps_report, "report": None} args_dict = vars(args) # Build a list of report key names, requested by user, which are # tightly coupled, despite the smell, to arg names. requested_reports = [report for report in reports if args_dict[report]] # If either the --all option has been provided, OR none of the # other reports options have been specified, assume user wants all # reports (filtering out --remove is handled elsewhere). if args.all or not requested_reports: # Replace report list with all known report names. # TODO: THis is dumb... Just puts the name in so I can later # pull it again with dict. requested_reports = [report for report in reports] # Build the reports if not args.kawaii: SPRUCE = "*" elif sys.version_info[0] < 3: SPRUCE = "\xF0\x9F\x8C\xB2" else: SPRUCE = "\N{evergreen tree}" results = [] for report_name in requested_reports: report_dict = reports[report_name] print("%s Building: %s... %s" % (SPRUCE, report_dict["heading"], SPRUCE)) func = reports[report_name]["func"] results.append(func(**args_dict)) # Output the reports output_xml = ET.Element("SpruceReport") add_output_metadata(output_xml) for report in results: # Print output to stdout. if not args.ofile: print("") print_output(report, args.verbose) else: add_report_output(output_xml, report) if args.ofile: indent(output_xml) tree = ET.ElementTree(output_xml) #print(ET.tostring(output_xml, encoding="UTF-8")) try: tree.write(os.path.expanduser(args.ofile), encoding="UTF-8", xml_declaration=True) print("%s Wrote output to %s" % (SPRUCE, args.ofile)) except IOError: print("Error writing output to %s" % args.ofile) sys.exit(1) def remove(removal_tree): """Remove desired objects from the JSS and distribution points. Given an XML file with subelement "Removals", remove each child object from the JSS. The child Element must have a tag name corresponding to the JSSObject class to delete (e.g. "Computer", or "Policy"), with an attribute of "id" containing the object's ID. The name is not used, as this is not always a guarantor of identity. Packages and Scripts (when applicable) will be removed from all distribution points and servers that support delete methods. Args: ElementTree instance with Element "Removals", as detailed above. """ if not check_with_user(): sys.exit(0) jss_connection = JSSConnection.get() # Tag map is a dictionary mapping our Element tags to JSS factory # methods. tag_map = {"Computer": jss_connection.Computer, "ComputerGroup": jss_connection.ComputerGroup, "Package": jss_connection.Package, "Printer": jss_connection.Printer, "Script": jss_connection.Script, "Policy": jss_connection.Policy, "ComputerConfigurationProfile": jss_connection.OSXConfigurationProfile, "MobileDevice": jss_connection.MobileDevice, "MobileDeviceGroup": jss_connection.MobileDeviceGroup, "MobileDeviceConfigurationProfile": jss_connection.MobileDeviceConfigurationProfile, "MobileApplication": jss_connection.MobileDeviceApplication} root = removal_tree.getroot() removals = root.find("Removals") # JDS and CDP distribution points do not require files to be deleted # in addition to the objects being deleted (i.e. they handle it). # AFP/SMB DP's on the other hand do, so first test to see if any # File Share Distribution Points exist. if (hasattr(jss_connection.distribution_points, "dp_info") and jss_connection.distribution_points.dp_info): # See if we are trying to delete any packages or scripts. # JSS's which have been migrated store their scripts in the # database, and thus do not need to have them deleted. needs_file_removal = ["Package"] # Assume that JSS has been migrated by now # if not jss_connection.jss_migrated: # needs_file_removal.append("Script") file_type_removals = any([removal.tag for removal in removals if removal.tag in needs_file_removal]) if file_type_removals: # Mount the shares now in preparation. jss_connection.distribution_points.mount() else: file_type_removals = False # Remove duplicate items. removals_set = ET.Element("Removals") for item in removals: if not item.attrib["id"] in [obj.get("id") for obj in removals_set.findall(item.tag)]: removals_set.append(item) for item in removals_set: # Only try to delete members of the tag_map types. search_func = tag_map.get(item.tag) if not search_func: continue else: try: # Get the item from the JSS. obj = search_func(item.attrib["id"]) except jss.GetError as error: # Object probably no longer exists. if hasattr(error, "status_code"): print ("%s object %s with ID %s is not available or does " "not exist.\nStatus Code: %s\nError: %s" % ( item.tag, item.text, item.attrib["id"], error.status_code, error.message)) else: print ("%s object %s with ID %s is not available or does " "not exist.\nError: %s" % ( item.tag, item.text, item.attrib["id"], error.message)) continue # Try to delete the item. try: obj.delete() print("%s object %s: %s deleted." % (item.tag, obj.id, obj.name)) except jss.DeleteError as error: print("%s object %s with ID %s failed to delete.\n" "Status Code:%s Error: %s" % ( item.tag, item.text, item.attrib["id"], error.status_code, error.message)) continue # If the item is a Package, or a Script on a non-migrated # JSS, delete the file from the distribution points. if file_type_removals and item.tag in needs_file_removal: # The name property of a script or package is called # "Display Name" in the gui, and it can differ from the # actual filename, so get the filename rather than use name. # However, if there is a DistributionServer type repo # configured, it tries to delete the db object, which needs # "name". Since this has already been done, it's going to # throw a GetError regardless. In the event that a user has # a Display Name that matches another package's filename, bad # things could happen! # Get filename, but fall back to name. filename = obj.findtext("filename", item.text) try: jss_connection.distribution_points.delete(filename) print("%s file %s deleted." % (item.tag, obj.name)) except OSError as error: print("Unable to delete %s: %s with error: %s" % (item.tag, filename, error)) except jss.GetError: # User has a DistributionServer of some kind and # A.) The db object has already been deleted above # and possibly also B.) The "Display Name" and # "Filename" do not match, and the GET is failing due # to no db objects named "Filename" existing. pass def check_with_user(): jss_connection = JSSConnection.get() response = input("Are you sure you want to continue deleting objects " "from %s? (Y or N): " % jss_connection.base_url) if response.strip().upper() in ["Y", "YES"]: result = True else: result = False return result def connect(args): """make the connection to the JSS""" # Allow override to prefs file if args.prefs: if os.path.exists(os.path.expanduser(args.prefs)): user_supplied_prefs = Plist(args.prefs) connection = map_jssimporter_prefs(user_supplied_prefs) print("Preferences used: %s" % args.prefs) # Otherwise, get AutoPkg configuration settings for JSSImporter, # and barring that, get python-jss settings. elif os.path.exists(os.path.expanduser(AUTOPKG_PREFERENCES)): autopkg_env = Plist(AUTOPKG_PREFERENCES) connection = map_jssimporter_prefs(autopkg_env) print("Preferences used: %s" % AUTOPKG_PREFERENCES) else: try: connection = jss.JSSPrefs() print("Preferences used: %s" % PYTHON_JSS_PREFERENCES) except jss.exceptions.JSSPrefsMissingFileError: sys.exit("No python-jss or AutoPKG/JSSImporter configuration " "file!") JSSConnection.setup(connection) def main(): """Commandline processing.""" # Ensure we have the right version of python-jss. python_jss_version = StrictVersion(PYTHON_JSS_VERSION) if python_jss_version < REQUIRED_PYTHON_JSS_VERSION: sys.exit("Requires python-jss version: %s. Installed: %s\n" "Please update" % (REQUIRED_PYTHON_JSS_VERSION, python_jss_version)) # Handle command line arguments. parser = build_argparser() args = parser.parse_args() # make the connection to the JSS connect(args) # Determine actions based on supplied arguments. # The remove argument is mutually exclusive with the others. if args.remove: removal_tree = ET.parse(os.path.expanduser(args.remove)) remove(removal_tree) else: run_reports(args) if __name__ == "__main__": main()