From 5f7f6a6023c470337f0d879f55eb619fd63e2dbe Mon Sep 17 00:00:00 2001
From: Tim Bielawa <tbielawa@redhat.com>
Date: Wed, 5 Oct 2016 15:38:43 -0700
Subject: Support etcd certs now. Fix lint. Generate HTML report.

---
 library/openshift_cert_expiry.py                   | 210 ++++++++++++++-------
 .../openshift-cluster/check-cert-expiry.yaml       |   9 +-
 .../templates/cert-expiry-table.html.j2            | 110 +++++++++++
 3 files changed, 264 insertions(+), 65 deletions(-)
 create mode 100644 playbooks/common/openshift-cluster/templates/cert-expiry-table.html.j2

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>&nbsp;</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>
-- 
cgit v1.2.3