#!/usr/bin/python # -*- coding: utf-8 -*- # pylint: disable=line-too-long,invalid-name """For details on this module see DOCUMENTATION (below)""" import base64 import datetime import io import os import subprocess import yaml # pylint import-error disabled because pylint cannot find the package # when installed in a virtualenv from ansible.module_utils.six.moves import configparser # pylint: disable=import-error from ansible.module_utils.basic import AnsibleModule try: # You can comment this import out and include a 'pass' in this # block if you're manually testing this module on a NON-ATOMIC # HOST (or any host that just doesn't have PyOpenSSL # available). That will force the `load_and_handle_cert` function # to use the Fake OpenSSL classes. import OpenSSL.crypto HAS_OPENSSL = True except ImportError: # Some platforms (such as RHEL Atomic) may not have the Python # OpenSSL library installed. In this case we will use a manual # work-around to parse each certificate. # # Check for 'OpenSSL.crypto' in `sys.modules` later. HAS_OPENSSL = False DOCUMENTATION = ''' --- module: openshift_cert_expiry short_description: Check OpenShift Container Platform (OCP) and Kube certificate expirations on a cluster description: - The M(openshift_cert_expiry) module has two basic functions: to flag certificates which will expire in a set window of time from now, and to notify you about certificates which have already expired. - When the module finishes, a summary of the examination is returned. Each certificate in the summary has a C(health) key with a value of one of the following: - C(ok) - not expired, and outside of the expiration C(warning_days) window. - C(warning) - not expired, but will expire between now and the C(warning_days) window. - C(expired) - an expired certificate. - Certificate flagging follow this logic: - If the expiration date is before now then the certificate is classified as C(expired). - The certificates time to live (expiration date - now) is calculated, if that time window is less than C(warning_days) the certificate is classified as C(warning). - All other conditions are classified as C(ok). - The following keys are ALSO present in the certificate summary: - C(cert_cn) - The common name of the certificate (additional CNs present in SAN extensions are omitted) - C(days_remaining) - The number of days until the certificate expires. - C(expiry) - The date the certificate expires on. - C(path) - The full path to the certificate on the examined host. version_added: "1.0" options: config_base: description: - Base path to OCP system settings. required: false default: /etc/origin warning_days: description: - Flag certificates which will expire in C(warning_days) days from now. required: false default: 30 show_all: description: - Enable this option to show analysis of ALL certificates examined by this module. - By default only certificates which have expired, or will expire within the C(warning_days) window will be reported. required: false default: false author: "Tim Bielawa (@tbielawa) " ''' EXAMPLES = ''' # Default invocation, only notify about expired certificates or certificates which will expire within 30 days from now - openshift_cert_expiry: # Expand the warning window to show certificates expiring within a year from now - openshift_cert_expiry: warning_days=365 # Show expired, soon to expire (now + 30 days), and all other certificates examined - openshift_cert_expiry: show_all=true ''' class FakeOpenSSLCertificate(object): """This provides a rough mock of what you get from `OpenSSL.crypto.load_certificate()`. This is a work-around for platforms missing the Python OpenSSL library. """ def __init__(self, cert_string): """`cert_string` is a certificate in the form you get from running a .crt through 'openssl x509 -in CERT.cert -text'""" self.cert_string = cert_string self.serial = None self.subject = None self.extensions = [] self.not_after = None self._parse_cert() def _parse_cert(self): """Manually parse the certificate line by line""" self.extensions = [] PARSING_ALT_NAMES = False PARSING_HEX_SERIAL = False for line in self.cert_string.split('\n'): l = line.strip() if PARSING_ALT_NAMES: # We're parsing a 'Subject Alternative Name' line self.extensions.append( FakeOpenSSLCertificateSANExtension(l)) PARSING_ALT_NAMES = False continue if PARSING_HEX_SERIAL: # Hex serials arrive colon-delimited serial_raw = l.replace(':', '') # Convert to decimal self.serial = int('0x' + serial_raw, base=16) PARSING_HEX_SERIAL = False continue # parse out the bits that we can if l.startswith('Serial Number:'): # Decimal format: # Serial Number: 11 (0xb) # => 11 # Hex Format (large serials): # Serial Number: # 0a:de:eb:24:04:75:ab:56:39:14:e9:5a:22:e2:85:bf # => 14449739080294792594019643629255165375 if l.endswith(':'): PARSING_HEX_SERIAL = True continue self.serial = int(l.split()[-2]) elif l.startswith('Not After :'): # Not After : Feb 7 18:19:35 2019 GMT # => strptime(str, '%b %d %H:%M:%S %Y %Z') # => strftime('%Y%m%d%H%M%SZ') # => 20190207181935Z not_after_raw = l.partition(' : ')[-1] # Last item: ('Not After', ' : ', 'Feb 7 18:19:35 2019 GMT') not_after_parsed = datetime.datetime.strptime(not_after_raw, '%b %d %H:%M:%S %Y %Z') self.not_after = not_after_parsed.strftime('%Y%m%d%H%M%SZ') elif l.startswith('X509v3 Subject Alternative Name:'): PARSING_ALT_NAMES = True continue elif l.startswith('Subject:'): # O = system:nodes, CN = system:node:m01.example.com self.subject = FakeOpenSSLCertificateSubjects(l.partition(': ')[-1]) def get_serial_number(self): """Return the serial number of the cert""" return self.serial def get_subject(self): """Subjects must implement get_components() and return dicts or tuples. An 'openssl x509 -in CERT.cert -text' with 'Subject': Subject: Subject: O=system:nodes, CN=system:node:m01.example.com might return: [('O=system', 'nodes'), ('CN=system', 'node:m01.example.com')] """ return self.subject def get_extension(self, i): """Extensions must implement get_short_name() and return the string 'subjectAltName'""" return self.extensions[i] def get_extension_count(self): """ get_extension_count """ return len(self.extensions) def get_notAfter(self): """Returns a date stamp as a string in the form '20180922170439Z'. strptime the result with format param: '%Y%m%d%H%M%SZ'.""" return self.not_after class FakeOpenSSLCertificateSANExtension(object): # pylint: disable=too-few-public-methods """Mocks what happens when `get_extension` is called on a certificate object""" def __init__(self, san_string): """With `san_string` as you get from: $ openssl x509 -in certificate.crt -text """ self.san_string = san_string self.short_name = 'subjectAltName' def get_short_name(self): """Return the 'type' of this extension. It's always the same though because we only care about subjectAltName's""" return self.short_name def __str__(self): """Return this extension and the value as a simple string""" return self.san_string # pylint: disable=too-few-public-methods class FakeOpenSSLCertificateSubjects(object): """Mocks what happens when `get_subject` is called on a certificate object""" def __init__(self, subject_string): """With `subject_string` as you get from: $ openssl x509 -in certificate.crt -text """ self.subjects = [] for s in subject_string.split(', '): name, _, value = s.partition(' = ') self.subjects.append((name, value)) def get_components(self): """Returns a list of tuples""" return self.subjects ###################################################################### def filter_paths(path_list): """`path_list` - A list of file paths to check. Only files which exist will be returned """ return [p for p in path_list if os.path.exists(os.path.realpath(p))] # pylint: disable=too-many-locals,too-many-branches # # TODO: Break this function down into smaller chunks def load_and_handle_cert(cert_string, now, base64decode=False, ans_module=None): """Load a certificate, split off the good parts, and return some useful data Params: - `cert_string` (string) - a certificate loaded into a string object - `now` (datetime) - a datetime object of the time to calculate the certificate 'time_remaining' against - `base64decode` (bool) - run base64.b64decode() on the input - `ans_module` (AnsibleModule) - The AnsibleModule object for this module (so we can raise errors) Returns: A tuple of the form: (cert_subject, cert_expiry_date, time_remaining, cert_serial_number) """ if base64decode: _cert_string = base64.b64decode(cert_string).decode('utf-8') else: _cert_string = cert_string # Disable this. We 'redefine' the type because we are working # around a missing library on the target host. # # pylint: disable=redefined-variable-type if HAS_OPENSSL: # No work-around required cert_loaded = OpenSSL.crypto.load_certificate( OpenSSL.crypto.FILETYPE_PEM, _cert_string) else: # Missing library, work-around required. Run the 'openssl' # command on it to decode it cmd = 'openssl x509 -text' try: openssl_proc = subprocess.Popen(cmd.split(), stdout=subprocess.PIPE, stdin=subprocess.PIPE) except OSError: ans_module.fail_json(msg="Error: The 'OpenSSL' python library and CLI command were not found on the target host. Unable to parse any certificates. This host will not be included in generated reports.") else: openssl_decoded = openssl_proc.communicate(_cert_string.encode('utf-8'))[0].decode('utf-8') cert_loaded = FakeOpenSSLCertificate(openssl_decoded) ###################################################################### # Read all possible names from the cert cert_subjects = [] for name, value in cert_loaded.get_subject().get_components(): if isinstance(name, bytes) or isinstance(value, bytes): name = name.decode('utf-8') value = value.decode('utf-8') cert_subjects.append('{}:{}'.format(name, value)) # To read SANs from a cert we must read the subjectAltName # extension from the X509 Object. What makes this more difficult # is that pyOpenSSL does not give extensions as an iterable san = None for i in range(cert_loaded.get_extension_count()): ext = cert_loaded.get_extension(i) if ext.get_short_name() == 'subjectAltName': san = ext if san is not None: # The X509Extension object for subjectAltName prints as a # string with the alt names separated by a comma and a # space. Split the string by ', ' and then add our new names # to the list of existing names cert_subjects.extend(str(san).split(', ')) cert_subject = ', '.join(cert_subjects) ###################################################################### # Grab the expiration date not_after = cert_loaded.get_notAfter() # example get_notAfter() => 20180922170439Z if isinstance(not_after, bytes): not_after = not_after.decode('utf-8') cert_expiry_date = datetime.datetime.strptime( not_after, '%Y%m%d%H%M%SZ') time_remaining = cert_expiry_date - now return (cert_subject, cert_expiry_date, time_remaining, cert_loaded.get_serial_number()) def classify_cert(cert_meta, now, time_remaining, expire_window, cert_list): """Given metadata about a certificate under examination, classify it into one of three categories, 'ok', 'warning', and 'expired'. Params: - `cert_meta` dict - A dict with certificate metadata. Required fields include: 'cert_cn', 'path', 'expiry', 'days_remaining', 'health'. - `now` (datetime) - a datetime object of the time to calculate the certificate 'time_remaining' against - `time_remaining` (datetime.timedelta) - a timedelta for how long until the cert expires - `expire_window` (datetime.timedelta) - a timedelta for how long the warning window is - `cert_list` list - A list to shove the classified cert into Return: - `cert_list` - The updated list of classified certificates """ expiry_str = str(cert_meta['expiry']) # Categorization if cert_meta['expiry'] < now: # This already expired, must NOTIFY cert_meta['health'] = 'expired' elif time_remaining < expire_window: # WARN about this upcoming expirations cert_meta['health'] = 'warning' else: # Not expired or about to expire cert_meta['health'] = 'ok' cert_meta['expiry'] = expiry_str cert_meta['serial_hex'] = hex(int(cert_meta['serial'])) cert_list.append(cert_meta) return cert_list def tabulate_summary(certificates, kubeconfigs, etcd_certs, router_certs, registry_certs): """Calculate the summary text for when the module finishes running. This includes counts of each classification and what have you. Params: - `certificates` (list of dicts) - Processed `expire_check_result` dicts with filled in `health` keys for system certificates. - `kubeconfigs` - as above for kubeconfigs - `etcd_certs` - as above for etcd certs Return: - `summary_results` (dict) - Counts of each cert type classification and total items examined. """ items = certificates + kubeconfigs + etcd_certs + router_certs + registry_certs summary_results = { 'system_certificates': len(certificates), 'kubeconfig_certificates': len(kubeconfigs), 'etcd_certificates': len(etcd_certs), 'router_certs': len(router_certs), 'registry_certs': len(registry_certs), 'total': len(items), 'ok': 0, 'warning': 0, 'expired': 0 } summary_results['expired'] = len([c for c in items if c['health'] == 'expired']) summary_results['warning'] = len([c for c in items if c['health'] == 'warning']) summary_results['ok'] = len([c for c in items if c['health'] == 'ok']) return summary_results ###################################################################### # This is our module MAIN function after all, so there's bound to be a # lot of code bundled up into one block # # Reason: These checks are disabled because the issue was introduced # during a period where the pylint checks weren't enabled for this file # Status: temporarily disabled pending future refactoring # pylint: disable=too-many-locals,too-many-statements,too-many-branches def main(): """This module examines certificates (in various forms) which compose an OpenShift Container Platform cluster """ module = AnsibleModule( argument_spec=dict( config_base=dict( required=False, default="/etc/origin", type='str'), warning_days=dict( required=False, default=30, type='int'), show_all=dict( required=False, default=False, type='bool') ), supports_check_mode=True, ) # Basic scaffolding for OpenShift specific certs openshift_base_config_path = os.path.realpath(module.params['config_base']) openshift_master_config_path = os.path.join(openshift_base_config_path, "master", "master-config.yaml") openshift_node_config_path = os.path.join(openshift_base_config_path, "node", "node-config.yaml") openshift_cert_check_paths = [ openshift_master_config_path, openshift_node_config_path, ] # Paths for Kubeconfigs. Additional kubeconfigs are conditionally # checked later in the code master_kube_configs = ['admin', 'openshift-master', 'openshift-node', 'openshift-router', 'openshift-registry'] kubeconfig_paths = [] for m_kube_config in master_kube_configs: kubeconfig_paths.append( os.path.join(openshift_base_config_path, "master", m_kube_config + ".kubeconfig") ) # Validate some paths we have the ability to do ahead of time openshift_cert_check_paths = filter_paths(openshift_cert_check_paths) kubeconfig_paths = filter_paths(kubeconfig_paths) # etcd, where do you hide your certs? Used when parsing etcd.conf etcd_cert_params = [ "ETCD_CA_FILE", "ETCD_CERT_FILE", "ETCD_PEER_CA_FILE", "ETCD_PEER_CERT_FILE", ] # Expiry checking stuff now = datetime.datetime.now() # todo, catch exception for invalid input and return a fail_json warning_days = int(module.params['warning_days']) expire_window = datetime.timedelta(days=warning_days) # Module stuff # # The results of our cert checking to return from the task call check_results = {} check_results['meta'] = {} check_results['meta']['warning_days'] = warning_days check_results['meta']['checked_at_time'] = str(now) check_results['meta']['warn_before_date'] = str(now + expire_window) check_results['meta']['show_all'] = str(module.params['show_all']) # All the analyzed certs accumulate here ocp_certs = [] ###################################################################### # Sure, why not? Let's enable check mode. if module.check_mode: check_results['ocp_certs'] = [] module.exit_json( check_results=check_results, msg="Checked 0 total certificates. Expired/Warning/OK: 0/0/0. Warning window: %s days" % module.params['warning_days'], rc=0, changed=False ) ###################################################################### # Check for OpenShift Container Platform specific certs ###################################################################### for os_cert in filter_paths(openshift_cert_check_paths): # Open up that config file and locate the cert and CA with io.open(os_cert, 'r', encoding='utf-8') as fp: cert_meta = {} cfg = yaml.load(fp) # cert files are specified in parsed `fp` as relative to the path # of the original config file. 'master-config.yaml' with certFile # = 'foo.crt' implies that 'foo.crt' is in the same # directory. certFile = '../foo.crt' is in the parent directory. cfg_path = os.path.dirname(fp.name) cert_meta['certFile'] = os.path.join(cfg_path, cfg['servingInfo']['certFile']) cert_meta['clientCA'] = os.path.join(cfg_path, cfg['servingInfo']['clientCA']) ###################################################################### # Load the certificate and the CA, parse their expiration dates into # datetime objects so we can manipulate them later for v in cert_meta.values(): with io.open(v, 'r', encoding='utf-8') as fp: cert = fp.read() (cert_subject, cert_expiry_date, time_remaining, cert_serial) = load_and_handle_cert(cert, now, ans_module=module) expire_check_result = { 'cert_cn': cert_subject, 'path': fp.name, 'expiry': cert_expiry_date, 'days_remaining': time_remaining.days, 'health': None, 'serial': cert_serial } classify_cert(expire_check_result, now, time_remaining, expire_window, ocp_certs) ###################################################################### # /Check for OpenShift Container Platform specific certs ###################################################################### ###################################################################### # Check service Kubeconfigs ###################################################################### kubeconfigs = [] # There may be additional kubeconfigs to check, but their naming # is less predictable than the ones we've already assembled. try: # Try to read the standard 'node-config.yaml' file to check if # this host is a node. with io.open(openshift_node_config_path, 'r', encoding='utf-8') as fp: cfg = yaml.load(fp) # OK, the config file exists, therefore this is a # node. Nodes have their own kubeconfig files to # communicate with the master API. Let's read the relative # path to that file from the node config. node_masterKubeConfig = cfg['masterKubeConfig'] # As before, the path to the 'masterKubeConfig' file is # relative to `fp` cfg_path = os.path.dirname(fp.name) node_kubeconfig = os.path.join(cfg_path, node_masterKubeConfig) with io.open(node_kubeconfig, 'r', encoding='utf8') as fp: # Read in the nodes kubeconfig file and grab the good stuff cfg = yaml.load(fp) c = cfg['users'][0]['user']['client-certificate-data'] (cert_subject, cert_expiry_date, time_remaining, cert_serial) = load_and_handle_cert(c, now, base64decode=True, ans_module=module) expire_check_result = { 'cert_cn': cert_subject, 'path': fp.name, 'expiry': cert_expiry_date, 'days_remaining': time_remaining.days, 'health': None, 'serial': cert_serial } classify_cert(expire_check_result, now, time_remaining, expire_window, kubeconfigs) except IOError: # This is not a node pass for kube in filter_paths(kubeconfig_paths): with io.open(kube, 'r', encoding='utf-8') as fp: # TODO: Maybe consider catching exceptions here? cfg = yaml.load(fp) # Per conversation, "the kubeconfigs you care about: # admin, router, registry should all be single # value". Following that advice we only grab the data for # the user at index 0 in the 'users' list. There should # not be more than one user. c = cfg['users'][0]['user']['client-certificate-data'] (cert_subject, cert_expiry_date, time_remaining, cert_serial) = load_and_handle_cert(c, now, base64decode=True, ans_module=module) expire_check_result = { 'cert_cn': cert_subject, 'path': fp.name, 'expiry': cert_expiry_date, 'days_remaining': time_remaining.days, 'health': None, 'serial': cert_serial } classify_cert(expire_check_result, now, time_remaining, expire_window, kubeconfigs) ###################################################################### # /Check service Kubeconfigs ###################################################################### ###################################################################### # Check etcd certs # # Two things to check: 'external' etcd, and embedded etcd. ###################################################################### # FIRST: The 'external' etcd # # Some values may be duplicated, make this a set for now so we # unique them all etcd_certs_to_check = set([]) etcd_certs = [] etcd_cert_params.append('dne') try: with io.open('/etc/etcd/etcd.conf', 'r', encoding='utf-8') as fp: # Add dummy header section. config = io.StringIO() config.write(u'[ETCD]\n') config.write(fp.read().replace('%', '%%')) config.seek(0, os.SEEK_SET) etcd_config = configparser.ConfigParser() etcd_config.readfp(config) for param in etcd_cert_params: try: etcd_certs_to_check.add(etcd_config.get('ETCD', param)) except configparser.NoOptionError: # That parameter does not exist, oh well... pass except IOError: # No etcd to see here, move along pass for etcd_cert in filter_paths(etcd_certs_to_check): with io.open(etcd_cert, 'r', encoding='utf-8') as fp: c = fp.read() (cert_subject, cert_expiry_date, time_remaining, cert_serial) = load_and_handle_cert(c, now, ans_module=module) expire_check_result = { 'cert_cn': cert_subject, 'path': fp.name, 'expiry': cert_expiry_date, 'days_remaining': time_remaining.days, 'health': None, 'serial': cert_serial } classify_cert(expire_check_result, now, time_remaining, expire_window, etcd_certs) ###################################################################### # Now the embedded etcd ###################################################################### try: with io.open('/etc/origin/master/master-config.yaml', 'r', encoding='utf-8') as fp: cfg = yaml.load(fp) except IOError: # Not present pass else: if cfg.get('etcdConfig', {}).get('servingInfo', {}).get('certFile', None) is not None: # This is embedded etcd_crt_name = cfg['etcdConfig']['servingInfo']['certFile'] else: # Not embedded etcd_crt_name = None if etcd_crt_name is not None: # etcd_crt_name is relative to the location of the # master-config.yaml file cfg_path = os.path.dirname(fp.name) etcd_cert = os.path.join(cfg_path, etcd_crt_name) with open(etcd_cert, 'r') as etcd_fp: (cert_subject, cert_expiry_date, time_remaining, cert_serial) = load_and_handle_cert(etcd_fp.read(), now, ans_module=module) expire_check_result = { 'cert_cn': cert_subject, 'path': etcd_fp.name, 'expiry': cert_expiry_date, 'days_remaining': time_remaining.days, 'health': None, 'serial': cert_serial } classify_cert(expire_check_result, now, time_remaining, expire_window, etcd_certs) ###################################################################### # /Check etcd certs ###################################################################### ###################################################################### # Check router/registry certs # # These are saved as secrets in etcd. That means that we can not # simply read a file to grab the data. Instead we're going to # subprocess out to the 'oc get' command. On non-masters this # command will fail, that is expected so we catch that exception. ###################################################################### router_certs = [] registry_certs = [] ###################################################################### # First the router certs try: router_secrets_raw = subprocess.Popen('oc get -n default secret router-certs -o yaml'.split(), stdout=subprocess.PIPE) router_ds = yaml.load(router_secrets_raw.communicate()[0]) router_c = router_ds['data']['tls.crt'] router_path = router_ds['metadata']['selfLink'] except TypeError: # YAML couldn't load the result, this is not a master pass except OSError: # The OC command doesn't exist here. Move along. pass else: (cert_subject, cert_expiry_date, time_remaining, cert_serial) = load_and_handle_cert(router_c, now, base64decode=True, ans_module=module) expire_check_result = { 'cert_cn': cert_subject, 'path': router_path, 'expiry': cert_expiry_date, 'days_remaining': time_remaining.days, 'health': None, 'serial': cert_serial } classify_cert(expire_check_result, now, time_remaining, expire_window, router_certs) ###################################################################### # Now for registry try: registry_secrets_raw = subprocess.Popen('oc get -n default secret registry-certificates -o yaml'.split(), stdout=subprocess.PIPE) registry_ds = yaml.load(registry_secrets_raw.communicate()[0]) registry_c = registry_ds['data']['registry.crt'] registry_path = registry_ds['metadata']['selfLink'] except TypeError: # YAML couldn't load the result, this is not a master pass except OSError: # The OC command doesn't exist here. Move along. pass else: (cert_subject, cert_expiry_date, time_remaining, cert_serial) = load_and_handle_cert(registry_c, now, base64decode=True, ans_module=module) expire_check_result = { 'cert_cn': cert_subject, 'path': registry_path, 'expiry': cert_expiry_date, 'days_remaining': time_remaining.days, 'health': None, 'serial': cert_serial } classify_cert(expire_check_result, now, time_remaining, expire_window, registry_certs) ###################################################################### # /Check router/registry certs ###################################################################### res = tabulate_summary(ocp_certs, kubeconfigs, etcd_certs, router_certs, registry_certs) msg = "Checked {count} total certificates. Expired/Warning/OK: {exp}/{warn}/{ok}. Warning window: {window} days".format( count=res['total'], exp=res['expired'], warn=res['warning'], ok=res['ok'], window=int(module.params['warning_days']), ) # By default we only return detailed information about expired or # warning certificates. If show_all is true then we will print all # the certificates examined. if not module.params['show_all']: check_results['ocp_certs'] = [crt for crt in ocp_certs if crt['health'] in ['expired', 'warning']] check_results['kubeconfigs'] = [crt for crt in kubeconfigs if crt['health'] in ['expired', 'warning']] check_results['etcd'] = [crt for crt in etcd_certs if crt['health'] in ['expired', 'warning']] check_results['registry'] = [crt for crt in registry_certs if crt['health'] in ['expired', 'warning']] check_results['router'] = [crt for crt in router_certs if crt['health'] in ['expired', 'warning']] else: check_results['ocp_certs'] = ocp_certs check_results['kubeconfigs'] = kubeconfigs check_results['etcd'] = etcd_certs check_results['registry'] = registry_certs check_results['router'] = router_certs # Sort the final results to report in order of ascending safety # time. That is to say, the certificates which will expire sooner # will be at the front of the list and certificates which will # expire later are at the end. Router and registry certs should be # limited to just 1 result, so don't bother sorting those. def cert_key(item): ''' return the days_remaining key ''' return item['days_remaining'] check_results['ocp_certs'] = sorted(check_results['ocp_certs'], key=cert_key) check_results['kubeconfigs'] = sorted(check_results['kubeconfigs'], key=cert_key) check_results['etcd'] = sorted(check_results['etcd'], key=cert_key) # This module will never change anything, but we might want to # change the return code parameter if there is some catastrophic # error we noticed earlier module.exit_json( check_results=check_results, summary=res, msg=msg, rc=0, changed=False ) if __name__ == '__main__': main()