diff options
-rw-r--r-- | library/openshift_cert_expiry.py | 210 | ||||
-rw-r--r-- | playbooks/common/openshift-cluster/check-cert-expiry.yaml | 9 | ||||
-rw-r--r-- | playbooks/common/openshift-cluster/templates/cert-expiry-table.html.j2 | 110 |
3 files changed, 264 insertions, 65 deletions
diff --git a/library/openshift_cert_expiry.py b/library/openshift_cert_expiry.py index cd8662f67..4e66de755 100644 --- a/library/openshift_cert_expiry.py +++ b/library/openshift_cert_expiry.py @@ -1,5 +1,8 @@ #!/usr/bin/python # -*- coding: utf-8 -*- +# pylint: disable=line-too-long,invalid-name + +"""For details on this module see DOCUMENTATION (below)""" # etcd config file import ConfigParser @@ -66,18 +69,23 @@ EXAMPLES = ''' ''' -###################################################################### -# etcd does not begin their config file with an opening [section] as -# required by the Python ConfigParser module. We hack around it by -# slipping one in ourselves prior to parsing. +# We only need this for one thing, we don't care if it doesn't have +# that many public methods # -# Source: Alex Martelli - http://stackoverflow.com/a/2819788/6490583 +# pylint: disable=too-few-public-methods class FakeSecHead(object): + """etcd does not begin their config file with an opening [section] as +required by the Python ConfigParser module. We hack around it by +slipping one in ourselves prior to parsing. + +Source: Alex Martelli - http://stackoverflow.com/a/2819788/6490583 + """ def __init__(self, fp): self.fp = fp self.sechead = '[ETCD]\n' def readline(self): + """Make this look like a file-type object""" if self.sechead: try: return self.sechead @@ -86,14 +94,15 @@ class FakeSecHead(object): else: return self.fp.readline() + ###################################################################### def filter_paths(path_list): - # `path_list` - A list of file paths to check. Only files which - # exist will be returned - return filter( - lambda p: os.path.exists(os.path.realpath(p)), - 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))] + def load_and_handle_cert(cert_string, now, base64decode=False): """Load a certificate, split off the good parts, and return some @@ -131,6 +140,7 @@ A 3-tuple of the form: (certificate_common_name, certificate_expiry_date, certif return (cert_subject, cert_expiry_date, time_remaining) + 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'. @@ -163,7 +173,8 @@ Return: cert_list.append(cert_meta) return cert_list -def tabulate_summary(certificates, kubeconfigs): + +def tabulate_summary(certificates, kubeconfigs, etcd_certs): """Calculate the summary text for when the module finishes running. This includes counds of each classification and what have you. @@ -172,24 +183,25 @@ Params: - `certificates` (list of dicts) - Processed `expire_check_result` dicts with filled in `health` keys for system certificates. -- `kubeconfigs` (list of dicts) - Processed `expire_check_result` - dicts with filled in `health` keys for embedded kubeconfig - certificates. - +- `kubeconfigs` - as above for kubeconfigs +- `etcd_certs` - as above for etcd certs Return: -- `summary_results` (dict) - Counts of each cert/kubeconfig - classification and total items examined. + +- `summary_results` (dict) - Counts of each cert type classification + and total items examined. """ + items = certificates + kubeconfigs + etcd_certs + summary_results = { 'system_certificates': len(certificates), 'kubeconfig_certificates': len(kubeconfigs), - 'total': len(certificates + kubeconfigs), + 'etcd_certificates': len(etcd_certs), + 'total': len(items), 'ok': 0, 'warning': 0, 'expired': 0 } - items = certificates + kubeconfigs 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']) @@ -198,7 +210,15 @@ Return: ###################################################################### +# This is our module MAIN function after all, so there's bound to be a +# lot of code bundled up into one block +# +# pylint: disable=too-many-locals,too-many-locals,too-many-statements def main(): + """This module examines certificates (in various forms) which compose +an OpenShift Container Platform cluster + """ + module = AnsibleModule( argument_spec=dict( config_base=dict( @@ -223,7 +243,7 @@ def main(): os.path.join(openshift_base_config_path, "master/master-config.yaml") ) openshift_node_config_path = os.path.normpath( - os.path.join(openshift_base_config_path, "node/node-config.yaml") + os.path.join(openshift_base_config_path, "node/node-config.yaml") ) openshift_cert_check_paths = [ openshift_master_config_path, @@ -246,6 +266,14 @@ def main(): ), ] + # 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 @@ -262,15 +290,15 @@ def main(): check_results['meta']['warn_after_date'] = str(now + expire_window) check_results['meta']['show_all'] = str(module.params['show_all']) # All the analyzed certs accumulate here - certs = [] + ocp_certs = [] ###################################################################### # Sure, why not? Let's enable check mode. if module.check_mode: - check_results['certs'] = [] + check_results['ocp_certs'] = [] module.exit_json( check_results=check_results, - msg="Checked 0 certificates. Expired/Warning/OK: 0/0/0. Warning window: %s days" % module.params['warning_days'], + msg="Checked 0 total certificates. Expired/Warning/OK: 0/0/0. Warning window: %s days" % module.params['warning_days'], rc=0, changed=False ) @@ -307,7 +335,7 @@ def main(): 'health': None, } - classify_cert(expire_check_result, now, time_remaining, expire_window, certs) + classify_cert(expire_check_result, now, time_remaining, expire_window, ocp_certs) ###################################################################### # /Check for OpenShift Container Platform specific certs @@ -326,33 +354,36 @@ def main(): # this host is a node. with open(openshift_node_config_path, 'r') 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) + + # 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 open(node_kubeconfig, 'r') 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) = load_and_handle_cert(c, now, base64decode=True) - - expire_check_result = { - 'cert_cn': cert_subject, - 'path': fp.name, - 'expiry': cert_expiry_date, - 'days_remaining': time_remaining.days, - 'health': None, - } - classify_cert(expire_check_result, now, time_remaining, expire_window, kubeconfigs) - except Exception: + c = cfg['users'][0]['user']['client-certificate-data'] + (cert_subject, + cert_expiry_date, + time_remaining) = load_and_handle_cert(c, now, base64decode=True) + + expire_check_result = { + 'cert_cn': cert_subject, + 'path': fp.name, + 'expiry': cert_expiry_date, + 'days_remaining': time_remaining.days, + 'health': None, + } + + classify_cert(expire_check_result, now, time_remaining, expire_window, kubeconfigs) + except IOError: # This is not a node pass @@ -360,15 +391,60 @@ def main(): with open(kube, 'r') 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'] + + # 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) = load_and_handle_cert(c, now, base64decode=True) + + expire_check_result = { + 'cert_cn': cert_subject, + 'path': fp.name, + 'expiry': cert_expiry_date, + 'days_remaining': time_remaining.days, + 'health': None, + } + + classify_cert(expire_check_result, now, time_remaining, expire_window, kubeconfigs) + + ###################################################################### + # /Check service Kubeconfigs + ###################################################################### + + ###################################################################### + # Check etcd certs + ###################################################################### + # 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 open('/etc/etcd/etcd.conf', 'r') as fp: + etcd_config = ConfigParser.ConfigParser() + etcd_config.readfp(FakeSecHead(fp)) + + 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 open(etcd_cert, 'r') as fp: + c = fp.read() (cert_subject, cert_expiry_date, - time_remaining) = load_and_handle_cert(c, now, base64decode=True) + time_remaining) = load_and_handle_cert(c, now) expire_check_result = { 'cert_cn': cert_subject, @@ -378,15 +454,15 @@ def main(): 'health': None, } - classify_cert(expire_check_result, now, time_remaining, expire_window, kubeconfigs) - + classify_cert(expire_check_result, now, time_remaining, expire_window, etcd_certs) ###################################################################### - # /Check service Kubeconfigs + # /Check etcd certs ###################################################################### - res = tabulate_summary(certs, kubeconfigs) - msg = "Checked {count} certificates and kubeconfigs. Expired/Warning/OK: {exp}/{warn}/{ok}. Warning window: {window} days".format( + res = tabulate_summary(ocp_certs, kubeconfigs, etcd_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'], @@ -398,18 +474,22 @@ def main(): # warning certificates. If show_all is true then we will print all # the certificates examined. if not module.params['show_all']: - check_results['certs'] = filter(lambda ctr: ctr['health'] in ['expired', 'warning'], certs) - check_results['kubeconfigs'] = filter(lambda ctr: ctr['health'] in ['expired', 'warning'], kubeconfigs) + 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']] else: - check_results['certs'] = certs + check_results['ocp_certs'] = ocp_certs check_results['kubeconfigs'] = kubeconfigs + check_results['etcd'] = etcd_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. - check_results['certs'] = sorted(check_results['certs'], cmp=lambda x, y: cmp(x['days_remaining'], y['days_remaining'])) + check_results['ocp_certs'] = sorted(check_results['ocp_certs'], cmp=lambda x, y: cmp(x['days_remaining'], y['days_remaining'])) check_results['kubeconfigs'] = sorted(check_results['kubeconfigs'], cmp=lambda x, y: cmp(x['days_remaining'], y['days_remaining'])) + check_results['etcd'] = sorted(check_results['etcd'], cmp=lambda x, y: cmp(x['days_remaining'], y['days_remaining'])) + # 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 @@ -422,7 +502,9 @@ def main(): ) ###################################################################### -# import module snippets +# It's just the way we do things in Ansible. So disable this warning +# +# pylint: disable=wrong-import-position,import-error from ansible.module_utils.basic import AnsibleModule if __name__ == '__main__': main() diff --git a/playbooks/common/openshift-cluster/check-cert-expiry.yaml b/playbooks/common/openshift-cluster/check-cert-expiry.yaml index e160383af..b585fd849 100644 --- a/playbooks/common/openshift-cluster/check-cert-expiry.yaml +++ b/playbooks/common/openshift-cluster/check-cert-expiry.yaml @@ -34,4 +34,11 @@ - name: Check cert expirys on host openshift_cert_expiry: warning_days: 1500 - show_all: true + register: check_results + - name: Generate html + become: no + run_once: yes + template: + src: templates/cert-expiry-table.html.j2 + dest: /tmp/cert-table.html + delegate_to: localhost diff --git a/playbooks/common/openshift-cluster/templates/cert-expiry-table.html.j2 b/playbooks/common/openshift-cluster/templates/cert-expiry-table.html.j2 new file mode 100644 index 000000000..da7844c37 --- /dev/null +++ b/playbooks/common/openshift-cluster/templates/cert-expiry-table.html.j2 @@ -0,0 +1,110 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="UTF-8" /> + <title>OCP Certificate Expiry Report</title> + {# For fancy icons #} + <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" /> + <link href="https://fonts.googleapis.com/css?family=Source+Sans+Pro:300,400,700" rel="stylesheet" /> + <style type="text/css"> + body { + font-family: 'Source Sans Pro', sans-serif; + margin-left: 50px; + margin-right: 50px; + margin-bottom: 20px; + } + table { + border-collapse: collapse; + margin-bottom: 20px; + } + table, th, td { + border: 1px solid black; + } + th, td { + padding: 5px; + } + .cert-kind { + margin-top: 5px; + margin-bottom: 5px; + } + footer { + font-size: small; + text-align: center; + } + tr.odd { + background-color: #f2f2f2; + } + </style> + </head> + <body> + <center><h1>OCP Certificate Expiry Report</h1></center> + + <hr /> + + {# Each host has a header and table to itself #} + {% for host in play_hosts %} + <h1>{{ host }}</h1> + + <p> + {{ hostvars[host].check_results.msg }} + </p> + <ul> + <li><b>Expirations checked at:</b> {{ hostvars[host].check_results.check_results.meta.checked_at_time }}</li> + <li><b>Warn after date:</b> {{ hostvars[host].check_results.check_results.meta.warn_after_date }}</li> + </ul> + + <table border="1" width="100%"> + {# These are hard-coded right now, but should be grabbed dynamically from the registered results #} + {%- for kind in ['ocp_certs', 'etcd', 'kubeconfigs'] -%} + <tr> + <th colspan="6" style="text-align:center"><h2 class="cert-kind">{{ kind }}</h2></th> + </tr> + + <tr> + <th> </th> + <th>Certificate Common Name</th> + <th>Health</th> + <th>Days Remaining</th> + <th>Expiration Date</th> + <th>Path</th> + </tr> + + {# A row for each certificate examined #} + {%- for v in hostvars[host].check_results.check_results[kind] -%} + + {# Let's add some flair and show status visually with fancy icons #} + {% if v.health == 'ok' %} + {% set health_icon = 'glyphicon glyphicon-ok' %} + {% elif v.health == 'warning' %} + {% set health_icon = 'glyphicon glyphicon-alert' %} + {% else %} + {% set health_icon = 'glyphicon glyphicon-remove' %} + {% endif %} + + <tr class="{{ loop.cycle('odd', 'even') }}"> + <td style="text-align:center"><i class="{{ health_icon }}"></i></td> + <td>{{ v.cert_cn }}</td> + <td>{{ v.health }}</td> + <td>{{ v.days_remaining }}</td> + <td>{{ v.expiry }}</td> + <td>{{ v.path }}</td> + </tr> + {% endfor %} + {# end row generation per cert of this type #} + {% endfor %} + {# end generation for each kind of cert block #} + </table> + <hr /> + {% endfor %} + {# end section generation for each host #} + + <footer> + <p> + Expiration report generated by <a href="https://github.com/openshift/openshift-ansible" target="_blank">openshift-ansible</a> + </p> + <p> + Status icons from bootstrap/glyphicon + </p> + </footer> + </body> +</html> |