Merge ~whereisrysmind/charm-kubernetes-service-checks:master into charm-kubernetes-service-checks:master

Proposed by Ryan Farrell
Status: Superseded
Proposed branch: ~whereisrysmind/charm-kubernetes-service-checks:master
Merge into: charm-kubernetes-service-checks:master
Diff against target: 1918 lines (+1743/-1)
28 files modified
.gitignore (+25/-0)
.gitmodules (+3/-1)
Makefile (+78/-0)
README.md (+96/-0)
config.yaml (+38/-0)
files/plugins/check_kubernetes_api.py (+118/-0)
hooks/install (+1/-0)
lib/.empty (+0/-0)
lib/charmhelpers (+1/-0)
lib/lib_kubernetes_service_checks.py (+159/-0)
lib/ops (+1/-0)
metadata.yaml (+26/-0)
mod/.empty (+0/-0)
mod/charm-helpers (+1/-0)
src/charm.py (+191/-0)
src/setuppath.py (+4/-0)
tests/functional/requirements.txt (+1/-0)
tests/functional/tests/bundles/bionic.yaml (+90/-0)
tests/functional/tests/bundles/focal.yaml (+90/-0)
tests/functional/tests/bundles/xenial.yaml (+90/-0)
tests/functional/tests/kubernetes_service_checks.py (+162/-0)
tests/functional/tests/tests.yaml (+33/-0)
tests/unit/requirements.txt (+5/-0)
tests/unit/setuppath.py (+6/-0)
tests/unit/test_charm.py (+243/-0)
tests/unit/test_lib_ksc.py (+164/-0)
tests/unit/test_plugins.py (+65/-0)
tox.ini (+52/-0)
Reviewer Review Type Date Requested Status
Giuseppe Petralia Pending
Joe Guo Pending
Adam Dyess Pending
Review via email: mp+387688@code.launchpad.net

This proposal supersedes a proposal from 2020-07-20.

This proposal has been superseded by a proposal from 2020-07-20.

Commit message

Initial charm for Kubernetes Service Checks

Currently 2 NRPE checks:
  - check_http for cert expirations
  - check_k8s_api_health for api responding

3 Relations required:
  - nrpe:nrpe-external-master
  - kubernetes-master:kube-api-endpoint
  - kubernetes-master:kube-control

Charm will block if the relations are not configured or if required data
is missing.

+ Unit tests (passing)
+ Functional tests (passing)
+ lint & black (passing)

Description of the change

Initial charm for Kubernetes Service Checks

To post a comment you must log in.
Revision history for this message
Giuseppe Petralia (peppepetra) wrote : Posted in a previous version of this proposal

Generally code looks good.

The only comment I have is on the self.state.configured. It is not changed to False when a config changed is happening or a relation has joined, changed or departed (it is set to false only on departed), while it should be to reflect that this state is controlling whether check_charm_status should reconfigure or not the unit.

Also the self.helper.configure() is called in check_charm_status without checking if this state is true or false, while seems that this should be called only if configured is false.

Same for self.helper.update_tls_certificates(). This is needed only on initial deploy (and configured is False at the beginning) or later if a config_changed has occurred as the certificate can be updated only on a config changed at this stage.

Maybe i am not reading this state correctly. In this case more information are needed.

review: Needs Information
5e57fec... by Ryan Farrell

Edited when ssl configuration occurs

Revision history for this message
Giuseppe Petralia (peppepetra) wrote : Posted in a previous version of this proposal

All tests passes. `make test` is blocked by charm proof that is testing things that are not valid for operator charm. I would suggest adding @-charm proof to ignore any error.

I am +1 to merge it.

Thanks.

review: Approve

Unmerged commits

5e57fec... by Ryan Farrell

Edited when ssl configuration occurs

003175a... by Ryan Farrell

Fixed charm configured flag handling

51c4121... by Ryan Farrell

Set required args in nrpe plugin

6f49451... by Ryan Farrell

Blackened code

05632f6... by Ryan Farrell

Replaced eval with json.loads

184e030... by Ryan Farrell

Added focal functional test bundle

296fbef... by Ryan Farrell

Some cleanup

- Merge some docstrings with neighboring comments
- Added Black section to Makefile
- Removed repeated set status calls in the charm

a17ae30... by Ryan Farrell

Fixed issues with SSL Host Key verification

- Added step to base64 decode of the SSL cert
- Changed plugin default to check SSL host key
- Updated unit tests
- Fixed fixed functional tests

8172b74... by Ryan Farrell

Lint Fixes

2e6fb0f... by Ryan Farrell

Added nagios-nrpe-server restart

Added logic to restart the nrpe service at times when the charm goes
from an unconfigured state to configured.
Cleaned up some code lint.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/.gitignore b/.gitignore
2new file mode 100644
3index 0000000..f04567f
4--- /dev/null
5+++ b/.gitignore
6@@ -0,0 +1,25 @@
7+# Byte-compiled / optimized / DLL files
8+__pycache__/
9+*.py[cod]
10+*$py.class
11+
12+# Log files
13+*.log
14+
15+.tox/
16+.coverage
17+
18+# vi
19+.*.swp
20+
21+# pycharm
22+.idea/
23+
24+# version data
25+repo-info
26+
27+# reports
28+report/*
29+
30+# coverage
31+htmlcov/*
32diff --git a/.gitmodules b/.gitmodules
33index 0b10c26..2970f9f 100644
34--- a/.gitmodules
35+++ b/.gitmodules
36@@ -1,4 +1,6 @@
37 [submodule "mod/operator"]
38 path = mod/operator
39 url = https://github.com/canonical/operator.git
40- branch = 0.7.0
41+[submodule "mod/charm-helpers"]
42+ path = mod/charm-helpers
43+ url = https://github.com/juju/charm-helpers.git
44diff --git a/Makefile b/Makefile
45new file mode 100644
46index 0000000..4dad760
47--- /dev/null
48+++ b/Makefile
49@@ -0,0 +1,78 @@
50+PYTHON := /usr/bin/python3
51+
52+ifndef CHARM_BUILD_DIR
53+ CHARM_BUILD_DIR=/tmp/charm-builds
54+endif
55+
56+PROJECTPATH=$(dir $(realpath $(MAKEFILE_LIST)))
57+METADATA_FILE="metadata.yaml"
58+CHARM_NAME=$(shell cat ${PROJECTPATH}/${METADATA_FILE} | grep -E '^name:' | awk '{print $$2}')
59+
60+help:
61+ @echo "This project supports the following targets"
62+ @echo ""
63+ @echo " make help - show this text"
64+ @echo " make clean - remove unneeded files"
65+ @echo " make submodules - make sure that the submodules are up-to-date"
66+ @echo " make build - build the charm"
67+ @echo " make release - run clean, submodules and build targets"
68+ @echo " make lint - run flake8 and black"
69+ @echo " make proof - run charm proof"
70+ @echo " make unittests - run the tests defined in the unittest subdirectory"
71+ @echo " make functional - run the tests defined in the functional subdirectory"
72+ @echo " make test - run lint, proof, unittests and functional targets"
73+ @echo ""
74+
75+clean:
76+ @echo "Cleaning files"
77+ @if [ -d .tox ] ; then rm -r .tox ; fi
78+ @if [ -d .pytest_cache ] ; then rm -r .pytest_cache ; fi
79+ @if [ -d .idea ] ; then rm -r .idea ; fi
80+ @if [ -d .coverage ] ; then rm -r .coverage ; fi
81+ @if [ -d report ] ; then rm -r report ; fi
82+ @find . -iname __pycache__ -exec rm -r {} +
83+ @find . -type f -name "*.py[cod]" -delete
84+ @find . -type f -name "*$py.class" -delete
85+ @find . -type f -name "*.log" -delete
86+ @find . -type f -name "*.swp" -delete
87+ @find . -type f -name ".unit-state.db" -delete
88+ @echo "Cleaning existing build"
89+ @rm -rf ${CHARM_BUILD_DIR}/${CHARM_NAME}
90+
91+submodules:
92+ @echo "Cloning submodules"
93+ @git submodule update --init --recursive
94+
95+build:
96+ @echo "Building charm to base directory ${CHARM_BUILD_DIR}/${CHARM_NAME}"
97+ @-git describe --tags > ./repo-info
98+ @mkdir -p ${CHARM_BUILD_DIR}/${CHARM_NAME}
99+ @cp -r ./* ${CHARM_BUILD_DIR}/${CHARM_NAME}
100+ @echo "Installing/updating env from requirements.txt"
101+ @mkdir -p ${CHARM_BUILD_DIR}/${CHARM_NAME}/env/
102+ @if [ -f requirements.txt ] ; then @pip3 install --target=${CHARM_BUILD_DIR}/${CHARM_NAME}/env -r requirements.txt --upgrade ; fi
103+
104+release: clean submodules build
105+ @echo "Charm is built at ${CHARM_BUILD_DIR}/${CHARM_NAME}"
106+
107+lint:
108+ @echo "Running lint checks"
109+ @tox -e lint
110+
111+proof:
112+ @echo "Running charm proof"
113+ @charm proof
114+
115+unittests:
116+ @echo "Running unit tests"
117+ @tox -e unit
118+
119+functional: build
120+ @echo "Executing functional tests in ${CHARM_BUILD_DIR}"
121+ @CHARM_BUILD_DIR=${CHARM_BUILD_DIR} tox -e func
122+
123+test: lint proof unittests functional
124+ @echo "Tests completed for charm ${CHARM_NAME}."
125+
126+# The targets below don't depend on a file
127+.PHONY: help submodules clean build release lint proof unittests functional test
128\ No newline at end of file
129diff --git a/README.md b/README.md
130new file mode 100644
131index 0000000..1a50bd9
132--- /dev/null
133+++ b/README.md
134@@ -0,0 +1,96 @@
135+# kubernetes-service-checks Charm
136+
137+Overview
138+--------
139+
140+This charm provides Kubernetes Service checks for Nagios
141+
142+Quickstart
143+----------
144+
145+ juju deploy cs:kubernetes-service-checks
146+ juju add-relation kubernetes-service-checks nrpe
147+ juju add-relation kubernetes-service-checks:kube-api-endpoint kubernetes-master
148+ juju add-relation kuberentes-service-checks:kube-control kuberentes-master
149+
150+
151+
152+
153+### Relations
154+
155+* **kubernetes-master:kube-api-endpoint** - Provides KSC with the kubernetes-api *hostname* and *port*
156+
157+* **kuberentes-master:kube-control** - Provides KSC with a kuberentes-api *client-token* for authentication
158+
159+* **nrpe:nrpe-external-master** - Required for nagios; provides additional plugins
160+
161+
162+**Note:** Future relations with kubernetes-master *may* be changed so that a
163+single relation can provide the K8S api hostname, port, client token and ssl ca
164+cert.
165+
166+### Config Options
167+
168+**trusted_ssl_ca** *(Optional)* Setting this option enables SSL host
169+certificate authentication in the api checks
170+
171+ juju config kubernetes-service-checks trusted_ssl_ca="${KUBERNETES_API_CA}"
172+
173+
174+Service Checks
175+--------------
176+The plugin *check_kubernetes_api.py* ships with this charm and contains an array of checks for the k8s api health.
177+
178+```
179+check_kubernetes_api.py --help
180+usage: check_kubernetes_api.py [-h] [-H HOST] [-P PORT] [-T CLIENT_TOKEN]
181+ [--check health] [-C SSL_CA_PATH]
182+
183+Check Kubernetes API status
184+
185+optional arguments:
186+ -h, --help show this help message and exit
187+ -H HOST, --host HOST Hostname or IP of the kube-api-server (default: None)
188+ -P PORT, --port PORT Port of the kube-api-server (default: 6443)
189+ -T CLIENT_TOKEN, --token CLIENT_TOKEN
190+ Client access token for authenticate with the
191+ Kubernetes API (default: None)
192+ --check health which check to run (default: health)
193+ -C SSL_CA_PATH, --trusted-ca-cert SSL_CA_PATH
194+ String containing path to the trusted CA certificate
195+ (default: None)
196+
197+```
198+
199+**health** - This polls the kubernetes-api */healthz* endpoint. Posting a GET to this URL endpoint is expected to
200+return 200 - 'ok' if the api is healthy, otherwise 500.
201+
202+
203+Other Checks
204+------------
205+
206+**Certificate Expiration:** The *check_http* plugin is shipped with nrpe, and contains a built in cert expiration check. The warning and crit
207+thesholds are configurable:
208+
209+ juju config kubernetes-service-checks tls_warn_days=90
210+ juju config kubernetes-service-checks tls_crit_days=30
211+
212+Testing
213+-------
214+
215+Juju should be installed and bootstrapped on the system to run functional tests.
216+
217+
218+```
219+ export MODEL_SETTINGS=<semicolon-separated list of "juju model-config" settings>
220+ make test
221+```
222+
223+NOTE: If you are behind a proxy, be sure to export a MODEL_SETTINGS variable as
224+described above. Note that you will need to use the juju-http-proxy, juju-https-proxy, juju-no-proxy
225+and similar settings.
226+
227+Contact
228+-------
229+ - Author: **Bootstack Charmers** *<bootstack-charmers@lists.canonical.com>*
230+ - Bug Tracker: [lp:charm-kubernetes-service-checks](https://launchpad.net/charm-kubernetes-service-checks)
231diff --git a/config.yaml b/config.yaml
232new file mode 100644
233index 0000000..8560b6e
234--- /dev/null
235+++ b/config.yaml
236@@ -0,0 +1,38 @@
237+options:
238+ channel:
239+ type: string
240+ default: 1.18/stable
241+ description: |
242+ Snap channel to install kubectl from
243+ nagios_context:
244+ default: "juju"
245+ type: string
246+ description: |
247+ Used by the nrpe subordinate charms.
248+ A string that will be prepended to instance name to set the host name
249+ in nagios. So for instance the hostname would be something like:
250+ juju-myservice-0
251+ If you're running multiple environments with the same services in them
252+ this allows you to differentiate between them.
253+ nagios_servicegroups:
254+ default: ""
255+ type: string
256+ description: |
257+ A comma-separated list of nagios servicegroups.
258+ If left empty, the nagios_context will be used as the servicegroup
259+ tls_warn_days:
260+ type: int
261+ default: 60
262+ description: |
263+ Number of days left for the TLS certificate to expire before Warning.
264+ tls_crit_days:
265+ type: int
266+ default: 30
267+ description: |
268+ Number of days left for the TLS certificate to expire before alerting Critical.
269+ # temporary config setting for trusted SSL CA (see LP1886982)
270+ trusted_ssl_ca:
271+ type: string
272+ default: ""
273+ description: |
274+ base64 encoded SSL ca cert to use for Kubernetes API client connections.
275\ No newline at end of file
276diff --git a/files/plugins/check_kubernetes_api.py b/files/plugins/check_kubernetes_api.py
277new file mode 100755
278index 0000000..ad91f26
279--- /dev/null
280+++ b/files/plugins/check_kubernetes_api.py
281@@ -0,0 +1,118 @@
282+#!/usr/bin/python3
283+"""NRPE Plugin for checking Kubernetes API."""
284+
285+import argparse
286+import sys
287+
288+import urllib3
289+
290+NAGIOS_STATUS_OK = 0
291+NAGIOS_STATUS_WARNING = 1
292+NAGIOS_STATUS_CRITICAL = 2
293+NAGIOS_STATUS_UNKNOWN = 3
294+
295+NAGIOS_STATUS = {
296+ NAGIOS_STATUS_OK: "OK",
297+ NAGIOS_STATUS_WARNING: "WARNING",
298+ NAGIOS_STATUS_CRITICAL: "CRITICAL",
299+ NAGIOS_STATUS_UNKNOWN: "UNKNOWN",
300+}
301+
302+
303+def nagios_exit(status, message):
304+ """Return the check status in Nagios preferred format.
305+
306+ :param status: Nagios Check status code (in [0, 1, 2, 3])
307+ :param message: Message describing the status
308+ :return: sys.exit("{status_string}: {message}")
309+ """
310+ assert status in NAGIOS_STATUS, "Invalid Nagios status code"
311+ # prefix status name to message
312+ output = "{}: {}".format(NAGIOS_STATUS[status], message)
313+ print(output) # nagios requires print to stdout, no stderr
314+ sys.exit(status)
315+
316+
317+def check_kubernetes_health(k8s_address, client_token, disable_ssl):
318+ """Call <kubernetes-api>/healthz endpoint and check return value is 'ok'.
319+
320+ :param k8s_address: Address to kube-api-server formatted 'https://<IP>:<PORT>'
321+ :param client_token: Token for authenticating with the kube-api
322+ :param disable_ssl: Disables SSL Host Key verification
323+ """
324+ url = k8s_address + "/healthz"
325+ if disable_ssl:
326+ # perform check without SSL verification
327+ http = urllib3.PoolManager(cert_reqs="CERT_NONE", assert_hostname=False)
328+ else:
329+ http = urllib3.PoolManager()
330+
331+ try:
332+ resp = http.request("GET", url, headers={"Authorization": "Bearer {}".format(client_token)})
333+ except urllib3.exceptions.MaxRetryError as e:
334+ return NAGIOS_STATUS_CRITICAL, e
335+
336+ if resp.status != 200:
337+ return NAGIOS_STATUS_CRITICAL, "Unexpected HTTP Response code ({})".format(resp.status)
338+ elif resp.data != b"ok":
339+ return NAGIOS_STATUS_WARNING, "Unexpected Kubernetes healthz status '{}'".format(resp.data)
340+ return NAGIOS_STATUS_OK, "Kubernetes health 'ok'"
341+
342+
343+if __name__ == "__main__":
344+ parser = argparse.ArgumentParser(
345+ description="Check Kubernetes API status", formatter_class=argparse.ArgumentDefaultsHelpFormatter,
346+ )
347+
348+ parser.add_argument("-H", "--host", dest="host", help="Hostname or IP of the kube-api-server", required=True)
349+
350+ parser.add_argument(
351+ "-P", "--port", dest="port", type=int, default=6443, help="Port of the kube-api-server", required=True
352+ )
353+
354+ parser.add_argument(
355+ "-T", "--token", dest="client_token", help="Client access token for authenticate with the Kubernetes API"
356+ )
357+
358+ check_choices = ["health"]
359+ parser.add_argument(
360+ "--check",
361+ dest="check",
362+ metavar="|".join(check_choices),
363+ type=str,
364+ choices=check_choices,
365+ default=check_choices[0],
366+ help="which check to run",
367+ )
368+
369+ parser.add_argument(
370+ "-d",
371+ "--disable-host-key-check",
372+ dest="disable_host_key_check",
373+ default=False,
374+ action="store_true",
375+ help="Disables Host SSL Key Authentication",
376+ )
377+ args = parser.parse_args()
378+
379+ checks = {
380+ "health": check_kubernetes_health,
381+ }
382+
383+ k8s_url = "https://{}:{}".format(args.host, args.port)
384+ nagios_exit(*checks[args.check](k8s_url, args.client_token, args.disable_host_key_check))
385+
386+"""
387+TODO: Future Checks
388+
389+GET /api/v1/componentstatuses HTTP/1.1
390+Authorization: Bearer $TOKEN
391+Accept: application/json
392+Connection: close
393+
394+GET /api/va/nodes HTTP/1.1
395+Authorization: Bearer $TOKEN
396+Accept: application/json
397+Connection: close
398+
399+"""
400diff --git a/hooks/install b/hooks/install
401new file mode 120000
402index 0000000..25b1f68
403--- /dev/null
404+++ b/hooks/install
405@@ -0,0 +1 @@
406+../src/charm.py
407\ No newline at end of file
408diff --git a/lib/.empty b/lib/.empty
409new file mode 100644
410index 0000000..e69de29
411--- /dev/null
412+++ b/lib/.empty
413diff --git a/lib/charmhelpers b/lib/charmhelpers
414new file mode 120000
415index 0000000..cedc09e
416--- /dev/null
417+++ b/lib/charmhelpers
418@@ -0,0 +1 @@
419+../mod/charm-helpers/charmhelpers
420\ No newline at end of file
421diff --git a/lib/lib_kubernetes_service_checks.py b/lib/lib_kubernetes_service_checks.py
422new file mode 100644
423index 0000000..14db4e6
424--- /dev/null
425+++ b/lib/lib_kubernetes_service_checks.py
426@@ -0,0 +1,159 @@
427+"""Kubernetes Service Checks Helper Library."""
428+import base64
429+import json
430+import logging
431+import os
432+import subprocess
433+
434+from charmhelpers.contrib.charmsupport.nrpe import NRPE
435+from charmhelpers.core import hookenv, host
436+from charmhelpers.fetch import snap
437+
438+CERT_FILE = "/usr/local/share/ca-certificates/kubernetes-service-checks.crt"
439+NAGIOS_PLUGINS_DIR = "/usr/local/lib/nagios/plugins/"
440+
441+
442+class KSCHelper:
443+ """Kubernetes Service Checks Helper Class."""
444+
445+ def __init__(self, config, state):
446+ """Initialize the Helper with the charm config and state."""
447+ self.config = config
448+ self.state = state
449+
450+ @property
451+ def kubernetes_api_address(self):
452+ """Get kubernetes api hostname."""
453+ return self.state.kube_api_endpoint.get("hostname", None)
454+
455+ @property
456+ def kubernetes_api_port(self):
457+ """Get kubernetes api port."""
458+ return self.state.kube_api_endpoint.get("port", None)
459+
460+ @property
461+ def kubernetes_client_token(self):
462+ """Get kubernetes client token."""
463+ try:
464+ data = json.loads(self.state.kube_control.get("creds", "{}"))
465+ except json.decoder.JSONDecodeError:
466+ data = {}
467+ for creds in data.values():
468+ token = creds.get("client_token", None)
469+ if token:
470+ return token
471+ return None
472+
473+ @property
474+ def use_tls_cert(self):
475+ """Check if SSL cert is provided for use."""
476+ return bool(self._ssl_certificate)
477+
478+ @property
479+ def _ssl_certificate(self):
480+ # TODO: Expand this later to take a cert from a relation or from the config.
481+ # cert from the relation is to be prioritized
482+ ssl_cert = self.config.get("trusted_ssl_ca", None)
483+ if ssl_cert:
484+ ssl_cert = ssl_cert.strip()
485+ return ssl_cert
486+
487+ @property
488+ def ssl_cert_path(self):
489+ """Get cert file path."""
490+ return CERT_FILE
491+
492+ @property
493+ def plugins_dir(self):
494+ """Get nagios plugins directory."""
495+ return NAGIOS_PLUGINS_DIR
496+
497+ def restart_nrpe_service(self):
498+ """Restart nagios-nrpe-server service."""
499+ host.service_restart("nagios-nrpe-server")
500+
501+ def update_tls_certificates(self):
502+ """Write the trusted ssl certificate to the CERT_FILE."""
503+ if self._ssl_certificate:
504+ cert_content = base64.b64decode(self._ssl_certificate).decode()
505+ try:
506+ logging.debug("Writing ssl ca cert to {}".format(self.ssl_cert_path))
507+ with open(self.ssl_cert_path, "w") as f:
508+ f.write(cert_content)
509+ subprocess.call(["/usr/sbin/update-ca-certificates"])
510+ return True
511+ except subprocess.CalledProcessError as e:
512+ logging.error(e)
513+ return False
514+ except PermissionError as e:
515+ logging.error(e)
516+ return False
517+ else:
518+ logging.error("Trusted SSL Certificate is not defined")
519+ return False
520+
521+ def configure(self):
522+ """Refresh configuration data."""
523+ self.update_plugins()
524+ self.render_checks()
525+
526+ def update_plugins(self):
527+ """Rsync plugins to the plugin directory."""
528+ charm_plugin_dir = os.path.join(hookenv.charm_dir(), "files", "plugins/")
529+ host.rsync(charm_plugin_dir, self.plugins_dir, options=["--executability"])
530+
531+ def render_checks(self):
532+ """Render nrpe checks."""
533+ nrpe = NRPE()
534+ if not os.path.exists(self.plugins_dir):
535+ os.makedirs(self.plugins_dir)
536+
537+ # register basic api health check
538+ check_k8s_plugin = os.path.join(self.plugins_dir, "check_kubernetes_api.py")
539+ for check in ["health"]:
540+ check_command = "{} -H {} -P {} -T {} --check {}".format(
541+ check_k8s_plugin,
542+ self.kubernetes_api_address,
543+ self.kubernetes_api_port,
544+ self.kubernetes_client_token,
545+ check,
546+ ).strip()
547+ if not self.use_tls_cert:
548+ check_command += " -d"
549+
550+ nrpe.add_check(
551+ shortname="k8s_api_{}".format(check),
552+ description="Check Kubernetes API ({})".format(check),
553+ check_cmd=check_command,
554+ )
555+
556+ # register k8s host certificate expiration check
557+ check_http_plugin = "/usr/lib/nagios/plugins/check_http"
558+ check_command = "{} -I {} -p {} -C {},{}".format(
559+ check_http_plugin,
560+ self.kubernetes_api_address,
561+ self.kubernetes_api_port,
562+ self.config.get("tls_warn_days"),
563+ self.config.get("tls_crit_days"),
564+ ).strip()
565+ nrpe.add_check(
566+ shortname="k8s_api_cert_expiration",
567+ description="Check Kubernetes API ({})".format(check),
568+ check_cmd=check_command,
569+ )
570+ nrpe.write()
571+
572+ def install_kubectl(self):
573+ """Attempt to install kubectl.
574+
575+ :returns: bool, indicating whether or not successful
576+ """
577+ # snap retry is excessive
578+ snap.SNAP_NO_LOCK_RETRY_DELAY = 0.5
579+ snap.SNAP_NO_LOCK_RETRY_COUNT = 3
580+ try:
581+ channel = self.config.get("channel")
582+ snap.snap_install("kubectl", "--classic", "--channel={}".format(channel))
583+ return True
584+ except snap.CouldNotAcquireLockException:
585+ return False
586diff --git a/lib/ops b/lib/ops
587new file mode 120000
588index 0000000..d934193
589--- /dev/null
590+++ b/lib/ops
591@@ -0,0 +1 @@
592+../mod/operator/ops
593\ No newline at end of file
594diff --git a/metadata.yaml b/metadata.yaml
595new file mode 100644
596index 0000000..09b26a5
597--- /dev/null
598+++ b/metadata.yaml
599@@ -0,0 +1,26 @@
600+name: kubernetes-service-checks
601+summary: Kubernetes Services NRPE Checks
602+maintainers:
603+ - Bootstack Charmers <bootstack-charmers@lists.canonical.com>
604+description: |
605+ This charm provides NRPE Checks verifying Kubernetes API accessibility
606+ and integrates with Nagios for timely alerting.
607+tags:
608+ - kubernetes
609+ - ops
610+ - monitoring
611+series:
612+ - focal
613+ - bionic
614+ - xenial
615+requires:
616+ kube-control:
617+ interface: kube-control
618+ kube-api-endpoint:
619+ interface: http
620+provides:
621+ nrpe-external-master:
622+ interface: nrpe-external-master
623+ scope: container
624+ optional: true
625+subordinate: false
626diff --git a/mod/.empty b/mod/.empty
627new file mode 100644
628index 0000000..e69de29
629--- /dev/null
630+++ b/mod/.empty
631diff --git a/mod/charm-helpers b/mod/charm-helpers
632new file mode 160000
633index 0000000..4b3602e
634--- /dev/null
635+++ b/mod/charm-helpers
636@@ -0,0 +1 @@
637+Subproject commit 4b3602e2bdf101bf58cc808264ec0c8092a67cd0
638diff --git a/src/charm.py b/src/charm.py
639new file mode 100755
640index 0000000..b06381d
641--- /dev/null
642+++ b/src/charm.py
643@@ -0,0 +1,191 @@
644+#! /usr/bin/env python3
645+# -*- coding: utf-8 -*-
646+# vim:fenc=utf-8
647+# Copyright © 2020 Bootstack Charmers bootstack-charmers@lists.canonical.com
648+
649+"""Operator Charm main library."""
650+# Load modules from lib directory
651+import logging
652+
653+import setuppath # noqa:F401
654+
655+from lib_kubernetes_service_checks import KSCHelper # noqa:I100
656+from ops.charm import CharmBase
657+from ops.framework import StoredState
658+from ops.main import main
659+from ops.model import ActiveStatus, BlockedStatus, MaintenanceStatus
660+
661+
662+class KubernetesServiceChecksCharm(CharmBase):
663+ """Class representing this Operator charm."""
664+
665+ state = StoredState()
666+
667+ def __init__(self, *args):
668+ """Initialize charm and configure states and events to observe."""
669+ super().__init__(*args)
670+ # -- standard hook observation
671+ self.framework.observe(self.on.install, self.on_install)
672+ self.framework.observe(self.on.start, self.on_start)
673+ self.framework.observe(self.on.config_changed, self.on_config_changed)
674+ self.framework.observe(
675+ self.on.kube_api_endpoint_relation_changed, self.on_kube_api_endpoint_relation_changed,
676+ )
677+ self.framework.observe(self.on.kube_api_endpoint_relation_departed, self.on_kube_api_endpoint_relation_departed)
678+ self.framework.observe(
679+ self.on.kube_control_relation_changed, self.on_kube_control_relation_changed,
680+ )
681+ self.framework.observe(self.on.kube_control_relation_departed, self.on_kube_control_relation_departed)
682+ self.framework.observe(
683+ self.on.nrpe_external_master_relation_joined, self.on_nrpe_external_master_relation_joined
684+ )
685+ self.framework.observe(
686+ self.on.nrpe_external_master_relation_departed, self.on_nrpe_external_master_relation_departed
687+ )
688+ # -- initialize states --
689+ self.state.set_default(
690+ installed=False,
691+ configured=False,
692+ started=False,
693+ kube_control={},
694+ kube_api_endpoint={},
695+ nrpe_configured=False,
696+ )
697+ self.helper = KSCHelper(self.model.config, self.state)
698+
699+ def on_install(self, event):
700+ """Handle install state."""
701+ self.unit.status = MaintenanceStatus("Install complete")
702+ logging.info("Install of software complete")
703+ self.state.installed = True
704+
705+ def on_upgrade_charm(self, event):
706+ """Handle upgrade and resource updates."""
707+ self.state.configured = False
708+ logging.info("Reinstalling for upgrade-charm hook")
709+ self.on_install(event)
710+ self.check_charm_status()
711+
712+ def check_charm_status(self):
713+ """
714+ Check that required data is available from relations.
715+
716+ - Check kube-api-endpoint relation and data available
717+ - Check kube-control relation and data available
718+ - Check nrpe-external-master is configured
719+ - Check any required config options
720+ - Finally, configure the charms checks and set flags
721+ """
722+ if not self.helper.kubernetes_api_address or not self.helper.kubernetes_api_port:
723+ logging.warning("kube-api-endpoint relation missing or misconfigured")
724+ self.unit.status = BlockedStatus("missing kube-api-endpoint relation")
725+ return
726+ if not self.helper.kubernetes_client_token:
727+ logging.warning("kube-control relation missing or misconfigured")
728+ self.unit.status = BlockedStatus("missing kube-control relation")
729+ return
730+ if not self.state.nrpe_configured:
731+ logging.warning("nrpe-external-master relation missing or misconfigured")
732+ self.unit.status = BlockedStatus("missing nrpe-external-master relation")
733+ return
734+
735+ # Check specific required config values
736+ # Set up TLS Certificate
737+ if self.helper.use_tls_cert:
738+ logging.info("Updating tls certificates")
739+ if self.helper.update_tls_certificates():
740+ logging.info("TLS Certificates updated successfully")
741+ else:
742+ logging.error("Failed to update TLS Certificates")
743+ self.unit.status = BlockedStatus("update-ca-certificates error. check logs")
744+ return
745+ else:
746+ logging.warning("No trusted_ssl_ca provided, SSL Host Authentication disabled")
747+
748+ # configure nrpe checks
749+ if not self.state.configured:
750+ logging.info("Configuring Kubernetes Service Checks")
751+ self.helper.configure()
752+
753+ logging.info("Reloading nagios-nrpe-server")
754+ self.helper.restart_nrpe_service()
755+ self.state.configured = True
756+ self.unit.status = ActiveStatus("Unit is ready")
757+
758+ def on_config_changed(self, event):
759+ """Handle config changed."""
760+ self.state.configured = False
761+ if not self.state.installed:
762+ logging.warning("Config changed called before install complete, deferring event: {}.".format(event.handle))
763+ self._defer_once(event)
764+ return
765+ self.check_charm_status()
766+
767+ def on_start(self, event):
768+ """Handle start state."""
769+ if not self.state.configured:
770+ logging.warning("Start called before configuration complete, deferring event: {}".format(event.handle))
771+ event.defer()
772+ return
773+ self.unit.status = ActiveStatus("Unit is ready")
774+ self.state.started = True
775+ logging.info("Started")
776+
777+ def _defer_once(self, event):
778+ """Defer the given event, but only once."""
779+ notice_count = 0
780+ handle = str(event.handle)
781+
782+ for event_path, _, _ in self.framework._storage.notices(None):
783+ if event_path.startswith(handle.split("[")[0]):
784+ notice_count += 1
785+ logging.debug("Found event: {} x {}".format(event_path, notice_count))
786+
787+ if notice_count > 1:
788+ logging.debug("Not deferring {} notice count of {}".format(handle, notice_count))
789+ else:
790+ logging.debug("Deferring {} notice count of {}".format(handle, notice_count))
791+ event.defer()
792+
793+ def on_kube_api_endpoint_relation_changed(self, event):
794+ """Handle kube_api_endpoint relation changed."""
795+ self.state.configured = False
796+ self.unit.status = MaintenanceStatus("Updating K8S Endpoint")
797+ self.state.kube_api_endpoint.update(event.relation.data.get(event.unit, {}))
798+ self.check_charm_status()
799+
800+ def on_kube_api_endpoint_relation_departed(self, event):
801+ """Handle kube-api-endpoint relation departed."""
802+ self.state.configured = False
803+ for k in self.state.kube_api_endpoint.keys():
804+ self.state.kube_api_endpoint[k] = ""
805+ self.check_charm_status()
806+
807+ def on_kube_control_relation_changed(self, event):
808+ """Handle kube-control relation changed."""
809+ self.state.configured = False
810+ self.unit.status = MaintenanceStatus("Updating K8S Credentials")
811+ self.state.kube_control.update(event.relation.data.get(event.unit, {}))
812+ self.check_charm_status()
813+
814+ def on_kube_control_relation_departed(self, event):
815+ """Handle kube-control relation departed."""
816+ self.state.configured = False
817+ for k in self.state.kube_control.keys():
818+ self.state.kube_control[k] = ""
819+ self.check_charm_status()
820+
821+ def on_nrpe_external_master_relation_joined(self, event):
822+ """Handle nrpe-external-master relation joined."""
823+ self.state.nrpe_configured = True
824+ self.check_charm_status()
825+
826+ def on_nrpe_external_master_relation_departed(self, event):
827+ """Handle nrpe-external-master relation departed."""
828+ self.state.configured = False
829+ self.state.nrpe_configured = False
830+ self.check_charm_status()
831+
832+
833+if __name__ == "__main__":
834+ main(KubernetesServiceChecksCharm)
835diff --git a/src/setuppath.py b/src/setuppath.py
836new file mode 100644
837index 0000000..768e049
838--- /dev/null
839+++ b/src/setuppath.py
840@@ -0,0 +1,4 @@
841+"""Include ./lib in the charm's PATH."""
842+import sys
843+
844+sys.path.append("lib")
845diff --git a/tests/functional/requirements.txt b/tests/functional/requirements.txt
846new file mode 100644
847index 0000000..b7c9112
848--- /dev/null
849+++ b/tests/functional/requirements.txt
850@@ -0,0 +1 @@
851+git+https://github.com/openstack-charmers/zaza.git#egg=zaza
852diff --git a/tests/functional/tests/bundles/bionic.yaml b/tests/functional/tests/bundles/bionic.yaml
853new file mode 100644
854index 0000000..c55ba41
855--- /dev/null
856+++ b/tests/functional/tests/bundles/bionic.yaml
857@@ -0,0 +1,90 @@
858+series: bionic
859+applications:
860+ kubernetes-service-checks:
861+ charm: ../../../../
862+ num_units: 1
863+ containerd:
864+ charm: cs:~containers/containerd
865+ options:
866+ gpu_driver: none
867+ resources: {}
868+ easyrsa:
869+ charm: cs:~containers/easyrsa
870+ num_units: 1
871+ resources:
872+ easyrsa: 5
873+ etcd:
874+ charm: cs:~containers/etcd
875+ num_units: 1
876+ options:
877+ channel: 3.3/stable
878+ resources:
879+ core: 0
880+ etcd: 3
881+ snapshot: 0
882+ flannel:
883+ charm: cs:~containers/flannel
884+ resources:
885+ flannel-amd64: 625
886+ flannel-arm64: 622
887+ flannel-s390x: 609
888+ kubernetes-master:
889+ charm: cs:~containers/kubernetes-master
890+ num_units: 1
891+ constraints: cores=4 mem=4G root-disk=16G
892+ options:
893+ channel: 1.18/stable
894+ resources:
895+ cdk-addons: 0
896+ core: 0
897+ kube-apiserver: 0
898+ kube-controller-manager: 0
899+ kube-proxy: 0
900+ kube-scheduler: 0
901+ kubectl: 0
902+ kubernetes-worker:
903+ charm: cs:~containers/kubernetes-worker
904+ expose: true
905+ num_units: 1
906+ constraints: cores=4 mem=4G root-disk=16G
907+ options:
908+ channel: 1.18/stable
909+ resources:
910+ cni-amd64: 645
911+ cni-arm64: 636
912+ cni-s390x: 648
913+ core: 0
914+ kube-proxy: 0
915+ kubectl: 0
916+ kubelet: 0
917+ nrpe:
918+ charm: cs:nrpe
919+relations:
920+ - - kubernetes-master:kube-api-endpoint
921+ - kubernetes-worker:kube-api-endpoint
922+ - - kubernetes-service-checks:nrpe-external-master
923+ - nrpe:nrpe-external-master
924+ - - kubernetes-master:kube-control
925+ - kubernetes-worker:kube-control
926+ - - kubernetes-master:certificates
927+ - easyrsa:client
928+ - - etcd:certificates
929+ - easyrsa:client
930+ - - kubernetes-master:etcd
931+ - etcd:db
932+ - - kubernetes-worker:certificates
933+ - easyrsa:client
934+ - - flannel:etcd
935+ - etcd:db
936+ - - flannel:cni
937+ - kubernetes-master:cni
938+ - - flannel:cni
939+ - kubernetes-worker:cni
940+ - - containerd:containerd
941+ - kubernetes-worker:container-runtime
942+ - - containerd:containerd
943+ - kubernetes-master:container-runtime
944+ - - kubernetes-service-checks:kube-control
945+ - kubernetes-master:kube-control
946+ - - kubernetes-service-checks:kube-api-endpoint
947+ - kubernetes-master:kube-api-endpoint
948\ No newline at end of file
949diff --git a/tests/functional/tests/bundles/focal.yaml b/tests/functional/tests/bundles/focal.yaml
950new file mode 100644
951index 0000000..31888a0
952--- /dev/null
953+++ b/tests/functional/tests/bundles/focal.yaml
954@@ -0,0 +1,90 @@
955+series: focal
956+applications:
957+ kubernetes-service-checks:
958+ charm: ../../../../
959+ num_units: 1
960+ containerd:
961+ charm: cs:~containers/containerd
962+ options:
963+ gpu_driver: none
964+ resources: {}
965+ easyrsa:
966+ charm: cs:~containers/easyrsa
967+ num_units: 1
968+ resources:
969+ easyrsa: 5
970+ etcd:
971+ charm: cs:~containers/etcd
972+ num_units: 1
973+ options:
974+ channel: 3.3/stable
975+ resources:
976+ core: 0
977+ etcd: 3
978+ snapshot: 0
979+ flannel:
980+ charm: cs:~containers/flannel
981+ resources:
982+ flannel-amd64: 625
983+ flannel-arm64: 622
984+ flannel-s390x: 609
985+ kubernetes-master:
986+ charm: cs:~containers/kubernetes-master
987+ num_units: 1
988+ constraints: cores=4 mem=4G root-disk=16G
989+ options:
990+ channel: 1.18/stable
991+ resources:
992+ cdk-addons: 0
993+ core: 0
994+ kube-apiserver: 0
995+ kube-controller-manager: 0
996+ kube-proxy: 0
997+ kube-scheduler: 0
998+ kubectl: 0
999+ kubernetes-worker:
1000+ charm: cs:~containers/kubernetes-worker
1001+ expose: true
1002+ num_units: 1
1003+ constraints: cores=4 mem=4G root-disk=16G
1004+ options:
1005+ channel: 1.18/stable
1006+ resources:
1007+ cni-amd64: 645
1008+ cni-arm64: 636
1009+ cni-s390x: 648
1010+ core: 0
1011+ kube-proxy: 0
1012+ kubectl: 0
1013+ kubelet: 0
1014+ nrpe:
1015+ charm: cs:nrpe
1016+relations:
1017+ - - kubernetes-master:kube-api-endpoint
1018+ - kubernetes-worker:kube-api-endpoint
1019+ - - kubernetes-service-checks:nrpe-external-master
1020+ - nrpe:nrpe-external-master
1021+ - - kubernetes-master:kube-control
1022+ - kubernetes-worker:kube-control
1023+ - - kubernetes-master:certificates
1024+ - easyrsa:client
1025+ - - etcd:certificates
1026+ - easyrsa:client
1027+ - - kubernetes-master:etcd
1028+ - etcd:db
1029+ - - kubernetes-worker:certificates
1030+ - easyrsa:client
1031+ - - flannel:etcd
1032+ - etcd:db
1033+ - - flannel:cni
1034+ - kubernetes-master:cni
1035+ - - flannel:cni
1036+ - kubernetes-worker:cni
1037+ - - containerd:containerd
1038+ - kubernetes-worker:container-runtime
1039+ - - containerd:containerd
1040+ - kubernetes-master:container-runtime
1041+ - - kubernetes-service-checks:kube-control
1042+ - kubernetes-master:kube-control
1043+ - - kubernetes-service-checks:kube-api-endpoint
1044+ - kubernetes-master:kube-api-endpoint
1045diff --git a/tests/functional/tests/bundles/xenial.yaml b/tests/functional/tests/bundles/xenial.yaml
1046new file mode 100644
1047index 0000000..962bdfd
1048--- /dev/null
1049+++ b/tests/functional/tests/bundles/xenial.yaml
1050@@ -0,0 +1,90 @@
1051+series: xenial
1052+applications:
1053+ kubernetes-service-checks:
1054+ charm: ../../../../
1055+ num_units: 1
1056+ containerd:
1057+ charm: cs:~containers/containerd
1058+ options:
1059+ gpu_driver: none
1060+ resources: {}
1061+ easyrsa:
1062+ charm: cs:~containers/easyrsa
1063+ num_units: 1
1064+ resources:
1065+ easyrsa: 5
1066+ etcd:
1067+ charm: cs:~containers/etcd
1068+ num_units: 1
1069+ options:
1070+ channel: 3.3/stable
1071+ resources:
1072+ core: 0
1073+ etcd: 3
1074+ snapshot: 0
1075+ flannel:
1076+ charm: cs:~containers/flannel
1077+ resources:
1078+ flannel-amd64: 625
1079+ flannel-arm64: 622
1080+ flannel-s390x: 609
1081+ kubernetes-master:
1082+ charm: cs:~containers/kubernetes-master
1083+ num_units: 1
1084+ constraints: cores=4 mem=4G root-disk=16G
1085+ options:
1086+ channel: 1.18/stable
1087+ resources:
1088+ cdk-addons: 0
1089+ core: 0
1090+ kube-apiserver: 0
1091+ kube-controller-manager: 0
1092+ kube-proxy: 0
1093+ kube-scheduler: 0
1094+ kubectl: 0
1095+ kubernetes-worker:
1096+ charm: cs:~containers/kubernetes-worker
1097+ expose: true
1098+ num_units: 1
1099+ constraints: cores=4 mem=4G root-disk=16G
1100+ options:
1101+ channel: 1.18/stable
1102+ resources:
1103+ cni-amd64: 645
1104+ cni-arm64: 636
1105+ cni-s390x: 648
1106+ core: 0
1107+ kube-proxy: 0
1108+ kubectl: 0
1109+ kubelet: 0
1110+ nrpe:
1111+ charm: cs:nrpe
1112+relations:
1113+ - - kubernetes-master:kube-api-endpoint
1114+ - kubernetes-worker:kube-api-endpoint
1115+ - - kubernetes-service-checks:nrpe-external-master
1116+ - nrpe:nrpe-external-master
1117+ - - kubernetes-master:kube-control
1118+ - kubernetes-worker:kube-control
1119+ - - kubernetes-master:certificates
1120+ - easyrsa:client
1121+ - - etcd:certificates
1122+ - easyrsa:client
1123+ - - kubernetes-master:etcd
1124+ - etcd:db
1125+ - - kubernetes-worker:certificates
1126+ - easyrsa:client
1127+ - - flannel:etcd
1128+ - etcd:db
1129+ - - flannel:cni
1130+ - kubernetes-master:cni
1131+ - - flannel:cni
1132+ - kubernetes-worker:cni
1133+ - - containerd:containerd
1134+ - kubernetes-worker:container-runtime
1135+ - - containerd:containerd
1136+ - kubernetes-master:container-runtime
1137+ - - kubernetes-service-checks:kube-control
1138+ - kubernetes-master:kube-control
1139+ - - kubernetes-service-checks:kube-api-endpoint
1140+ - kubernetes-master:kube-api-endpoint
1141diff --git a/tests/functional/tests/kubernetes_service_checks.py b/tests/functional/tests/kubernetes_service_checks.py
1142new file mode 100644
1143index 0000000..e1520f3
1144--- /dev/null
1145+++ b/tests/functional/tests/kubernetes_service_checks.py
1146@@ -0,0 +1,162 @@
1147+"""Charm Kubernetes Service Checks Functional Tests."""
1148+import concurrent.futures
1149+import logging
1150+import re
1151+import time
1152+import unittest
1153+
1154+from juju.errors import JujuAPIError
1155+import zaza.model
1156+
1157+
1158+class TestBase(unittest.TestCase):
1159+ """Base Class for charm functional tests."""
1160+
1161+ @classmethod
1162+ def setUpClass(cls):
1163+ """Run setup for tests."""
1164+ cls.model_name = zaza.model.get_juju_model()
1165+ cls.application_name = "kubernetes-service-checks"
1166+
1167+ def setUp(self):
1168+ """Set up functional tests & ensure all relations added."""
1169+ for local_relation_name, remote_relation_unit in [
1170+ ("kube-api-endpoint", "kubernetes-master"),
1171+ ("kube-control", "kubernetes-master"),
1172+ ("nrpe-external-master", "nrpe"),
1173+ ]:
1174+ logging.info("Adding relation {} with {}".format(local_relation_name, remote_relation_unit))
1175+ try:
1176+ zaza.model.add_relation(
1177+ self.application_name, local_relation_name, remote_relation_unit, self.model_name
1178+ )
1179+ except JujuAPIError as e:
1180+ p = r"^.*cannot\ add\ relation.*already\ exists"
1181+ if re.search(p, e.message):
1182+ pass
1183+ else:
1184+ raise (e)
1185+ zaza.model.block_until_wl_status_info_starts_with(self.application_name, status="Unit is ready", timeout=200)
1186+
1187+
1188+class TestChecks(TestBase):
1189+ """Tests for availability and usefulness of nagios checks."""
1190+
1191+ expected_checks = ["check_k8s_api_health.cfg", "check_k8s_api_cert_expiration.cfg"]
1192+ checks_dir = "/etc/nagios/nrpe.d/"
1193+ expected_plugins = ["check_kubernetes_api.py"]
1194+ plugins_dir = "/usr/local/lib/nagios/plugins/"
1195+
1196+ # TODO: Need testing around setting the trusted_ssl_ca cert
1197+ # - does it get written to /etc/ssl/certs/ca-certificates.crt?
1198+ # - does the k8s check plugin see it and use it for verification?
1199+
1200+ def test_check_plugins_exist(self):
1201+ """Verify that kubernetes service checks plugins are found."""
1202+ fail_messages = []
1203+ for plugin in self.expected_plugins:
1204+ pluginpath = self.plugins_dir + plugin
1205+ response = zaza.model.run_on_unit(
1206+ "kubernetes-service-checks/0", '[ -f "{}" ]'.format(pluginpath), model_name=self.model_name, timeout=30
1207+ )
1208+ if response["Code"] != "0":
1209+ fail_messages.append("Missing plugin: {}".format(pluginpath))
1210+ continue
1211+
1212+ # check executable
1213+ response = zaza.model.run_on_unit(
1214+ "kubernetes-service-checks/0", '[ -x "{}" ]'.format(pluginpath), model_name=self.model_name, timeout=30
1215+ )
1216+
1217+ if response["Code"] != "0":
1218+ fail_messages.append("Plugin not executable: {}".format(pluginpath))
1219+
1220+ if fail_messages:
1221+ self.fail("\n".join(fail_messages))
1222+
1223+ def test_checks_exist(self):
1224+ """Verify that kubernetes service checks nrpe checks exist."""
1225+ fail_messages = []
1226+ for check in self.expected_checks:
1227+ checkpath = self.checks_dir + check
1228+ response = zaza.model.run_on_unit(
1229+ "kubernetes-service-checks/0", '[ -f "{}" ]'.format(checkpath), model_name=self.model_name, timeout=30
1230+ )
1231+ if response["Code"] != "0":
1232+ fail_messages.append("Missing check: {}".format(checkpath))
1233+ if fail_messages:
1234+ self.fail("\n".join(fail_messages))
1235+
1236+
1237+class TestRelations(TestBase):
1238+ """Tests for charm behavior adding and removing relations."""
1239+
1240+ def _get_relation_id(self, remote_application, interface_name):
1241+ return zaza.model.get_relation_id(
1242+ self.application_name, remote_application, model_name=self.model_name, remote_interface_name=interface_name
1243+ )
1244+
1245+ def test_remove_kube_api_endpoint(self):
1246+ """Test removing kube-api-endpoint relation."""
1247+ rel_name = "kube-api-endpoint"
1248+ remote_app = "kubernetes-master"
1249+ logging.info("Removing kube-api-endpoint relation")
1250+
1251+ zaza.model.remove_relation(self.application_name, rel_name, remote_app, self.model_name)
1252+ try:
1253+ zaza.model.block_until_wl_status_info_starts_with(
1254+ self.application_name, status="missing kube-api-endpoint relation", timeout=180
1255+ )
1256+ except concurrent.futures._base.TimeoutError:
1257+ self.fail("Timed out waiting for Unit to become blocked")
1258+
1259+ logging.info("Waiting for relation {} to be destroyed".format(rel_name))
1260+ timeout = time.time() + 600
1261+ while self._get_relation_id(remote_app, rel_name) is not None:
1262+ time.sleep(5)
1263+ if time.time() > timeout:
1264+ self.fail("Timed out waiting for the relation {} to be destroyed".format(rel_name))
1265+
1266+ def test_remove_kube_control(self):
1267+ """Test removing kube-control relation."""
1268+ rel_name = "kube-control"
1269+ remote_app = "kubernetes-master"
1270+ logging.info("Removing kube-control relation")
1271+
1272+ zaza.model.remove_relation(self.application_name, rel_name, remote_app, self.model_name)
1273+
1274+ try:
1275+ zaza.model.block_until_wl_status_info_starts_with(
1276+ self.application_name, status="missing kube-control relation", timeout=180
1277+ )
1278+ except concurrent.futures._base.TimeoutError:
1279+ self.fail("Timed out waiting for Unit to become blocked")
1280+
1281+ logging.info("Waiting for relation {} to be destroyed".format(rel_name))
1282+ timeout = time.time() + 600
1283+ while self._get_relation_id(remote_app, rel_name) is not None:
1284+ time.sleep(5)
1285+ if time.time() > timeout:
1286+ self.fail("Timed out waiting for the relation {} to be destroyed".format(rel_name))
1287+
1288+ def test_remove_nrpe_external_master(self):
1289+ """Test removing nrpe-external-master relation."""
1290+ rel_name = "nrpe-external-master"
1291+ remote_app = "nrpe"
1292+ logging.info("Removing nrpe-external-master relation")
1293+
1294+ zaza.model.remove_relation(self.application_name, rel_name, remote_app, self.model_name)
1295+
1296+ try:
1297+ zaza.model.block_until_wl_status_info_starts_with(
1298+ self.application_name, status="missing nrpe-external-master relation", timeout=180
1299+ )
1300+ except concurrent.futures._base.TimeoutError:
1301+ self.fail("Timed out waiting for Unit to become blocked")
1302+
1303+ logging.info("Waiting for relation {} to be destroyed".format(rel_name))
1304+ timeout = time.time() + 600
1305+ while self._get_relation_id(remote_app, rel_name) is not None:
1306+ time.sleep(5)
1307+ if time.time() > timeout:
1308+ self.fail("Timed out waiting for the relation {} to be destroyed".format(rel_name))
1309diff --git a/tests/functional/tests/tests.yaml b/tests/functional/tests/tests.yaml
1310new file mode 100644
1311index 0000000..75a9ce8
1312--- /dev/null
1313+++ b/tests/functional/tests/tests.yaml
1314@@ -0,0 +1,33 @@
1315+tests:
1316+ - tests.kubernetes_service_checks.TestChecks
1317+ - tests.kubernetes_service_checks.TestRelations
1318+target_deploy_status:
1319+ kubernetes-service-checks:
1320+ workload-status: blocked
1321+ workload-status-message: "missing kube-api-endpoint relation"
1322+ kubernetes-master:
1323+ workload-status: active
1324+ workload-status-message: "Kubernetes master running."
1325+ kubernetes-worker:
1326+ workload-status: active
1327+ workload-status-message: "Kubernetes worker running."
1328+ flannel:
1329+ workload-status: active
1330+ workload-status-message: "Flannel subnet"
1331+ easyrsa:
1332+ workload-status: active
1333+ workload-status-message: "Certificate Authority connected."
1334+ containerd:
1335+ workload-status: active
1336+ workload-status-message: "Container runtime available"
1337+ etcd:
1338+ workload-status: active
1339+ workload-status-message: "Healthy with 1 known peer"
1340+ nrpe:
1341+ workload-status: active
1342+ workload-status-message: "ready"
1343+gate_bundles:
1344+ - xenial
1345+ - bionic
1346+smoke_bundles:
1347+ - focal
1348diff --git a/tests/unit/requirements.txt b/tests/unit/requirements.txt
1349new file mode 100644
1350index 0000000..b5f9fbd
1351--- /dev/null
1352+++ b/tests/unit/requirements.txt
1353@@ -0,0 +1,5 @@
1354+mock
1355+pyyaml
1356+coverage
1357+six
1358+urllib3
1359diff --git a/tests/unit/setuppath.py b/tests/unit/setuppath.py
1360new file mode 100644
1361index 0000000..c3404e5
1362--- /dev/null
1363+++ b/tests/unit/setuppath.py
1364@@ -0,0 +1,6 @@
1365+"""Include ./lib ./src and ./file/plugins in the tests' PATH."""
1366+import sys
1367+
1368+sys.path.append("lib")
1369+sys.path.append("src")
1370+sys.path.append("files/plugins")
1371diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py
1372new file mode 100644
1373index 0000000..24f83f5
1374--- /dev/null
1375+++ b/tests/unit/test_charm.py
1376@@ -0,0 +1,243 @@
1377+"""Charm unit tests."""
1378+import os
1379+from pathlib import Path
1380+import unittest
1381+
1382+
1383+import mock
1384+import yaml
1385+
1386+# include ./lib in the charm's PATH
1387+import setuppath # noqa:F401
1388+
1389+from charm import KubernetesServiceChecksCharm # noqa:I100
1390+import ops.main
1391+from ops.testing import Harness
1392+
1393+TEST_KUBE_CONTOL_RELATION_DATA = {
1394+ "creds": """{"system:node:juju-62684f-0":
1395+ {"client_token": "DECAFBADBEEF",
1396+ "kubelet_token": "ABCDEF012345",
1397+ "proxy_token": "BADC0FFEEDAD",
1398+ "scope": "kubernetes-worker/0"}
1399+ }""" # noqa:E127
1400+}
1401+TEST_KUBE_API_ENDPOINT_RELATION_DATA = {"hostname": "1.1.1.1", "port": "1111"}
1402+
1403+
1404+class TestKubernetesServiceChecksCharm(unittest.TestCase):
1405+ """Test Kubernetes Service Checks Charm Code."""
1406+
1407+ @classmethod
1408+ def setUpClass(cls):
1409+ """Prepare class fixture."""
1410+ # Stop unit test from calling fchown
1411+ fchown_patcher = mock.patch("os.fchown")
1412+ cls.mock_fchown = fchown_patcher.start()
1413+ chown_patcher = mock.patch("os.chown")
1414+ cls.mock_chown = chown_patcher.start()
1415+
1416+ # Stop charmhelpers host from logging via debug log
1417+ host_log_patcher = mock.patch("charmhelpers.core.host.log")
1418+ cls.mock_juju_log = host_log_patcher.start()
1419+
1420+ # Stop charmhelpers snap from logging via debug log
1421+ snap_log_patcher = mock.patch("charmhelpers.fetch.snap.log")
1422+ cls.mock_snap_log = snap_log_patcher.start()
1423+
1424+ charm_logger_patcher = mock.patch("charm.logging")
1425+ cls.mock_charm_log = charm_logger_patcher.start()
1426+
1427+ lib_logger_patcher = mock.patch("lib.lib_kubernetes_service_checks.logging")
1428+ cls.mock_lib_logger = lib_logger_patcher.start()
1429+
1430+ # Prevent charmhelpers from calling systemctl
1431+ host_service_patcher = mock.patch("charmhelpers.core.host.service_stop")
1432+ cls.mock_service_stop = host_service_patcher.start()
1433+ host_service_patcher = mock.patch("charmhelpers.core.host.service_start")
1434+ cls.mock_service_start = host_service_patcher.start()
1435+ host_service_patcher = mock.patch("charmhelpers.core.host.service_restart")
1436+ cls.mock_service_restart = host_service_patcher.start()
1437+
1438+ # Setup mock JUJU Environment variables
1439+ os.environ["JUJU_UNIT_NAME"] = "mock/0"
1440+ os.environ["JUJU_CHARM_DIR"] = "."
1441+
1442+ def setUp(self):
1443+ """Prepare tests."""
1444+ self.harness = Harness(KubernetesServiceChecksCharm)
1445+ # Mock config_get to return default config
1446+ with open(ops.main._get_charm_dir() / Path("config.yaml"), "r") as config_file:
1447+ config = yaml.safe_load(config_file)
1448+ charm_config = {}
1449+
1450+ for key, _ in config["options"].items():
1451+ charm_config[key] = config["options"][key]["default"]
1452+
1453+ self.harness._backend._config = charm_config
1454+
1455+ def test_harness(self):
1456+ """Verify harness."""
1457+ self.harness.begin()
1458+ self.assertFalse(self.harness.charm.state.installed)
1459+
1460+ @mock.patch("charmhelpers.fetch.snap.subprocess.check_call")
1461+ def test_install(self, mock_snap_subprocess):
1462+ """Test response to an install event."""
1463+ mock_snap_subprocess.return_value = 0
1464+ mock_snap_subprocess.side_effect = None
1465+
1466+ self.harness.begin()
1467+ self.harness.charm.on.install.emit()
1468+
1469+ self.assertEqual(self.harness.charm.unit.status.name, "maintenance")
1470+ self.assertEqual(self.harness.charm.unit.status.message, "Install complete")
1471+ self.assertTrue(self.harness.charm.state.installed)
1472+
1473+ def test_config_changed(self):
1474+ """Test response to config changed event."""
1475+ self.harness.set_leader(True)
1476+ self.harness.populate_oci_resources()
1477+ self.harness.begin()
1478+ self.harness.charm.check_charm_status = mock.MagicMock()
1479+ self.harness.charm.state.installed = True
1480+ self.harness.charm.on.config_changed.emit()
1481+ self.harness.charm.check_charm_status.assert_called_once()
1482+
1483+ def test_start_not_installed(self):
1484+ """Test response to start event without install state."""
1485+ self.harness.begin()
1486+ self.harness.charm.on.start.emit()
1487+ self.assertFalse(self.harness.charm.state.started)
1488+
1489+ def test_start_not_configured(self):
1490+ """Test response to start event without configured state."""
1491+ self.harness.begin()
1492+ self.harness.charm.state.installed = True
1493+ self.harness.charm.on.start.emit()
1494+ self.assertFalse(self.harness.charm.state.started)
1495+
1496+ def test_start(self):
1497+ """Test response to start event."""
1498+ self.harness.begin()
1499+ self.harness.charm.state.installed = True
1500+ self.harness.charm.state.configured = True
1501+ self.harness.charm.on.start.emit()
1502+ self.assertTrue(self.harness.charm.state.started)
1503+ self.assertEqual(self.harness.charm.unit.status.name, "active")
1504+
1505+ def test_on_kube_api_endpoint_relation_changed(self):
1506+ """Check kube-api-endpoint relation changed handling."""
1507+ relation_id = self.harness.add_relation("kube-api-endpoint", "kubernetes-master")
1508+ remote_unit = "kubernetes-master/0"
1509+ self.harness.begin()
1510+ self.harness.charm.check_charm_status = mock.MagicMock()
1511+ self.harness.add_relation_unit(relation_id, remote_unit)
1512+ self.harness.update_relation_data(relation_id, remote_unit, TEST_KUBE_API_ENDPOINT_RELATION_DATA)
1513+
1514+ self.harness.charm.check_charm_status.assert_called_once()
1515+ self.assertEqual(self.harness.charm.helper.kubernetes_api_address, "1.1.1.1")
1516+ self.assertEqual(self.harness.charm.helper.kubernetes_api_port, "1111")
1517+
1518+ def test_on_kube_control_relation_changed(self):
1519+ """Check kube-control relation changed handling."""
1520+ relation_id = self.harness.add_relation("kube-control", "kubernetes-master")
1521+ remote_unit = "kubernetes-master/0"
1522+ self.harness.begin()
1523+ self.harness.charm.check_charm_status = mock.MagicMock()
1524+ self.harness.add_relation_unit(relation_id, remote_unit)
1525+ self.harness.update_relation_data(relation_id, remote_unit, TEST_KUBE_CONTOL_RELATION_DATA)
1526+
1527+ self.harness.charm.check_charm_status.assert_called_once()
1528+ assert self.harness.charm.helper.kubernetes_client_token == "DECAFBADBEEF"
1529+
1530+ def test_nrpe_external_master_relation_joined(self):
1531+ """Check that nrpe.configure is True after nrpe relation joined."""
1532+ relation_id = self.harness.add_relation("nrpe-external-master", "nrpe")
1533+ remote_unit = "nrpe/0"
1534+ self.harness.begin()
1535+ self.assertFalse(self.harness.charm.state.nrpe_configured)
1536+ self.harness.charm.check_charm_status = mock.MagicMock()
1537+ self.harness.add_relation_unit(relation_id, remote_unit)
1538+
1539+ self.harness.charm.check_charm_status.assert_called_once()
1540+ self.assertTrue(self.harness.charm.state.nrpe_configured)
1541+
1542+ @mock.patch("ops.model.RelationData")
1543+ def test_nrpe_external_master_relation_departed(self, mock_relation_data):
1544+ """Check that nrpe.configure is False after nrpe relation departed."""
1545+ mock_relation_data.return_value.__getitem__.return_value = {}
1546+ self.harness.begin()
1547+ self.harness.charm.check_charm_status = mock.MagicMock()
1548+ self.emit("nrpe_external_master_relation_departed")
1549+ self.harness.charm.check_charm_status.assert_called_once()
1550+
1551+ self.assertFalse(self.harness.charm.state.nrpe_configured)
1552+
1553+ def test_check_charm_status_kube_api_endpoint_relation_missing(self):
1554+ """Check that the chatm blocks without kube-api-endpoint relation."""
1555+ self.harness.begin()
1556+ self.harness.charm.state.kube_control.update(TEST_KUBE_CONTOL_RELATION_DATA)
1557+ self.harness.charm.state.nrpe_configured = True
1558+ self.harness.charm.check_charm_status()
1559+
1560+ self.assertFalse(self.harness.charm.state.configured)
1561+ self.assertEqual(self.harness.charm.unit.status.name, "blocked")
1562+ self.assertEqual(self.harness.charm.unit.status.message, "missing kube-api-endpoint relation")
1563+
1564+ def test_check_charm_status_kube_control_relation_missing(self):
1565+ """Check that the charm blocks without kube-control relation."""
1566+ self.harness.begin()
1567+ self.harness.charm.state.kube_api_endpoint.update(TEST_KUBE_API_ENDPOINT_RELATION_DATA)
1568+ self.harness.charm.state.nrpe_configured = True
1569+ self.harness.charm.check_charm_status()
1570+
1571+ self.assertFalse(self.harness.charm.state.configured)
1572+ self.assertEqual(self.harness.charm.unit.status.name, "blocked")
1573+ self.assertEqual(self.harness.charm.unit.status.message, "missing kube-control relation")
1574+
1575+ def test_check_charm_status_nrpe_relation_missing(self):
1576+ """Check that the charm bloack without nrpe relation."""
1577+ self.harness.begin()
1578+ self.harness.charm.state.kube_control.update(TEST_KUBE_CONTOL_RELATION_DATA)
1579+ self.harness.charm.state.kube_api_endpoint.update(TEST_KUBE_API_ENDPOINT_RELATION_DATA)
1580+ self.harness.charm.check_charm_status()
1581+
1582+ self.assertFalse(self.harness.charm.state.configured)
1583+ self.assertEqual(self.harness.charm.unit.status.name, "blocked")
1584+ self.assertEqual(self.harness.charm.unit.status.message, "missing nrpe-external-master relation")
1585+
1586+ def test_check_charm_status_configured(self):
1587+ """Check the charm becomes configured."""
1588+ self.harness.begin()
1589+ self.harness.charm.helper.configure = mock.MagicMock()
1590+ self.harness.charm.state.kube_control.update(TEST_KUBE_CONTOL_RELATION_DATA)
1591+ self.harness.charm.state.kube_api_endpoint.update(TEST_KUBE_API_ENDPOINT_RELATION_DATA)
1592+ self.harness.charm.state.nrpe_configured = True
1593+ self.harness.charm.check_charm_status()
1594+
1595+ self.harness.charm.helper.configure.assert_called_once()
1596+ self.assertTrue(self.harness.charm.state.configured)
1597+
1598+ def emit(self, event):
1599+ """Emit the named hook on the charm."""
1600+ self.harness.charm.framework.reemit()
1601+
1602+ if "_relation_" in event:
1603+ relation_name = event.split("_relation")[0].replace("_", "-")
1604+ with mock.patch.dict(
1605+ "os.environ",
1606+ {
1607+ "JUJU_RELATION": relation_name,
1608+ "JUJU_RELATION_ID": "1",
1609+ "JUJU_REMOTE_APP": "mock",
1610+ "JUJU_REMOTE_UNIT": "mock/0",
1611+ },
1612+ ):
1613+ ops.main._emit_charm_event(self.harness.charm, event)
1614+ else:
1615+ ops.main._emit_charm_event(self.harness.charm, event)
1616+
1617+
1618+if __name__ == "__main__":
1619+ unittest.main()
1620diff --git a/tests/unit/test_lib_ksc.py b/tests/unit/test_lib_ksc.py
1621new file mode 100644
1622index 0000000..94f67bc
1623--- /dev/null
1624+++ b/tests/unit/test_lib_ksc.py
1625@@ -0,0 +1,164 @@
1626+"""Tests for Kubernetes Service Checks Helper."""
1627+import base64
1628+import os
1629+import subprocess
1630+from subprocess import CalledProcessError
1631+import tempfile
1632+import unittest
1633+
1634+from lib import lib_kubernetes_service_checks
1635+import mock
1636+import yaml
1637+
1638+
1639+TEST_CERTIFICATE = """-----BEGIN CERTIFICATE-----
1640+MIIDOzCCAiOgAwIBAgIJAPoOXrIwH+miMA0GCSqGSIb3DQEBCwUAMBgxFjAUBgNV
1641+BAMMDTEwLjEzMi4yNTEuNjAwHhcNMjAwNzE3MTMzMzI0WhcNMzAwNzE1MTMzMzI0
1642+WjAYMRYwFAYDVQQDDA0xMC4xMzIuMjUxLjYwMIIBIjANBgkqhkiG9w0BAQEFAAOC
1643+AQ8AMIIBCgKCAQEAqpYVlmT/eRBhCKHaqXjY6EAzvx5GZY0PhL/YGBl9uF8YQGEF
1644+F3k3Ec7pyJMIQblmWxdCPd1uNzHU8mwApiuPG9GtYOK+olqgslLsmOU9LTi6KJWX
1645+x956VxdefXDYvr0B6K/Hdgkb1x//XwvipSV1fZ1MCDIiP/hWKi4CmEq31sVpCBdp
1646+Uiz3qdCzsiGt0f4kbgIJSVtxhWlNJ5MaCOm7gXafkF8OIUTmWhmPp2gH7pfPzzl1
1647+glOX2Z41qwPuz7Jbcxx/z/yGjdPeJTQYoqJfpDpCrT2er5xyRf66HqKx9Ld/FiqM
1648+ZksRwmzF9WvqCBK8WoRmnvFxk1FZPGt6E5gotwIDAQABo4GHMIGEMB0GA1UdDgQW
1649+BBSUCCmRxb4tKD6w8jZ3hHs4ciFizDBIBgNVHSMEQTA/gBSUCCmRxb4tKD6w8jZ3
1650+hHs4ciFizKEcpBowGDEWMBQGA1UEAwwNMTAuMTMyLjI1MS42MIIJAPoOXrIwH+mi
1651+MAwGA1UdEwQFMAMBAf8wCwYDVR0PBAQDAgEGMA0GCSqGSIb3DQEBCwUAA4IBAQBv
1652+BYwILWI/4dGczqG0hcqt8tW04Oi+7y0HxzeI/oaUq/HKfvCz5a+WhpykMKRDJoaZ
1653+aejR2Oc7A0OUnenpvMeIiMcUIetM3Q1Gzx0aU+vqUNNaZlooSSbe3z1VK6bUsYDo
1654+qdKhs+mSyuEticK2SEWjT+ZWpV1rjSd5zRZ/UvC1ZhDNJGZotIIqryQWd3YfYl9l
1655+7JrdzUVCbxs4ywxNp9/I+MJEiBfMHQx8FWr1M2HvLDAm6NZLfM68y5FClzfGpopV
1656+0ARirz1AfbS6xUumyXHOH2qH527PUXFdfYGSn+juDG/dRTENYJ3OPAfWdj4ze1qQ
1657+n3ajLSYPvdyKaztdB1VL
1658+-----END CERTIFICATE-----"""
1659+
1660+
1661+class TestLibKSCHelper(unittest.TestCase):
1662+ """Unittest class for Kubernetes Service Checks Helper."""
1663+
1664+ @classmethod
1665+ def setUpClass(cls):
1666+ """Prepare Class Fixture."""
1667+ # Load default config
1668+ with open("./config.yaml") as default_config:
1669+ cls.config = yaml.safe_load(default_config)
1670+
1671+ # set defaults to the config object
1672+ for key in cls.config["options"]:
1673+ if "default" in cls.config["options"][key]:
1674+ cls.config[key] = cls.config["options"][key]["default"]
1675+
1676+ # Create test state object
1677+ class FakeStateObject(object):
1678+ kube_api_endpoint = {"hostname": "1.1.1.1", "port": "1111"}
1679+ kube_control = {"creds": """{"kube-client": {"client_token": "abcdef0123456789"}}"""}
1680+ installed = False
1681+ configured = False
1682+ started = False
1683+ nrpe_configured = False
1684+
1685+ cls.state = FakeStateObject()
1686+
1687+ # Stop unit test from calling fchown
1688+ fchown_patcher = mock.patch("os.fchown")
1689+ cls.mock_fchown = fchown_patcher.start()
1690+ chown_patcher = mock.patch("os.chown")
1691+ cls.mock_chown = chown_patcher.start()
1692+
1693+ # Stop charmhelpers host from logging via debug log
1694+ host_log_patcher = mock.patch("charmhelpers.core.host.log")
1695+ cls.mock_juju_log = host_log_patcher.start()
1696+
1697+ host_logger_patcher = mock.patch("lib.lib_kubernetes_service_checks.logging")
1698+ cls.mock_logger = host_logger_patcher.start()
1699+
1700+ # Stop charmhelpers snap from logging via debug log
1701+ snap_log_patcher = mock.patch("charmhelpers.fetch.snap.log")
1702+ cls.mock_snap_log = snap_log_patcher.start()
1703+
1704+ # Setup a tmpdir
1705+ cls.tmpdir = tempfile.TemporaryDirectory()
1706+ cls.cert_path = os.path.join(cls.tmpdir.name, "kubernetes-service-checks.crt")
1707+
1708+ lib_kubernetes_service_checks.CERT_FILE = cls.cert_path
1709+ lib_kubernetes_service_checks.NAGIOS_PLUGINS_DIR = cls.tmpdir.name
1710+
1711+ @classmethod
1712+ def tearDownClass(cls):
1713+ """Tear down class fixture."""
1714+ mock.patch.stopall()
1715+ cls.tmpdir.cleanup()
1716+
1717+ def setUp(self):
1718+ """Prepare test fixture."""
1719+ self.helper = lib_kubernetes_service_checks.KSCHelper(self.config, self.state)
1720+
1721+ def tearDown(self):
1722+ """Clean up test fixture."""
1723+ try:
1724+ os.remove(self.cert_path)
1725+ except FileNotFoundError:
1726+ pass
1727+
1728+ def test_kube_api_endpoint_properties(self):
1729+ """Test that hostname and port properties get passed through."""
1730+ # kube_api_endpoint (relation) -> hostname & port
1731+ self.assertEqual(self.helper.kubernetes_api_address, "1.1.1.1")
1732+ self.assertEqual(self.helper.kubernetes_api_port, "1111")
1733+
1734+ self.helper.state.kube_api_endpoint = {}
1735+ self.assertEqual(self.helper.kubernetes_api_address, None)
1736+ self.assertEqual(self.helper.kubernetes_api_port, None)
1737+
1738+ def test_kube_control_endpoint_properties(self):
1739+ """Test KSCHelper client_token gets passed though."""
1740+ # kube-control (relation) -> kube client token
1741+ self.assertEqual(self.helper.kubernetes_client_token, "abcdef0123456789")
1742+
1743+ self.helper.state.kube_control = {}
1744+ self.assertEqual(self.helper.kubernetes_client_token, None)
1745+
1746+ @mock.patch("lib.lib_kubernetes_service_checks.subprocess.call")
1747+ def test_update_tls_certificates(self, mock_subprocess):
1748+ """Test that SSL certificates get updated."""
1749+ # returns False when no available trusted_ssl_cert
1750+ self.assertFalse(self.helper.update_tls_certificates())
1751+
1752+ # returns True when subprocess successful
1753+ self.helper.config["trusted_ssl_ca"] = base64.b64encode(str.encode(TEST_CERTIFICATE))
1754+ self.assertTrue(self.helper.update_tls_certificates())
1755+ with open(self.cert_path, "r") as f:
1756+ self.assertEqual(f.read(), TEST_CERTIFICATE)
1757+ mock_subprocess.assert_called_once_with(["/usr/sbin/update-ca-certificates"])
1758+ mock_subprocess.reset_mock()
1759+
1760+ # returns false when subprocess hits an exception
1761+ mock_subprocess.side_effect = CalledProcessError("Command", "Mock Subprocess Call Error")
1762+ self.assertFalse(self.helper.update_tls_certificates())
1763+
1764+ def test_render_checks(self):
1765+ """Test that NPRE is called to add KSC checks."""
1766+ # TODO
1767+ pass
1768+
1769+ @mock.patch("charmhelpers.fetch.snap.subprocess.check_call")
1770+ def test_install_kubectl(self, mock_snap_subprocess):
1771+ """Test install kubectl snap helper function."""
1772+ self.assertTrue(self.helper.install_kubectl())
1773+ channel = self.config.get("channel")
1774+ mock_snap_subprocess.assert_called_with(
1775+ ["snap", "install", "--classic", "--channel={}".format(channel), "kubectl"], env=os.environ
1776+ )
1777+
1778+ @mock.patch("charmhelpers.fetch.snap.subprocess.check_call")
1779+ def test_install_snap_failure(self, mock_snap_subprocess):
1780+ """Test response to a failed install event."""
1781+ error = subprocess.CalledProcessError("cmd", "Install failed")
1782+ error.returncode = 1
1783+ mock_snap_subprocess.return_value = 1
1784+ mock_snap_subprocess.side_effect = error
1785+ self.assertFalse(self.helper.install_kubectl())
1786+
1787+
1788+if __name__ == "__main__":
1789+ unittest.main()
1790diff --git a/tests/unit/test_plugins.py b/tests/unit/test_plugins.py
1791new file mode 100644
1792index 0000000..42d1697
1793--- /dev/null
1794+++ b/tests/unit/test_plugins.py
1795@@ -0,0 +1,65 @@
1796+"""Unit tests for Kubernetes Service Checks NRPE Plugins."""
1797+import unittest
1798+
1799+import check_kubernetes_api
1800+import mock
1801+
1802+
1803+class TestKSCPlugins(unittest.TestCase):
1804+ """Test cases for Kubernetes Service Checks NRPE plugins."""
1805+
1806+ @mock.patch("check_kubernetes_api.sys.exit")
1807+ @mock.patch("check_kubernetes_api.print")
1808+ def test_nagios_exit(self, mock_print, mock_sys_exit):
1809+ """Test the nagios_exit function."""
1810+ msg = "Test message"
1811+ for code, status in check_kubernetes_api.NAGIOS_STATUS.items():
1812+ expected_output = "{}: {}".format(status, msg)
1813+ check_kubernetes_api.nagios_exit(code, msg)
1814+
1815+ mock_print.assert_called_with(expected_output)
1816+ mock_sys_exit.assert_called_with(code)
1817+
1818+ @mock.patch("check_kubernetes_api.urllib3.PoolManager")
1819+ def test_kubernetes_health_ssl(self, mock_http_pool_manager):
1820+ """Test the check k8s health function called with expected ssl params."""
1821+ host_address = "https://1.1.1.1:1111"
1822+ token = "0123456789abcdef"
1823+ disable_ssl = True
1824+
1825+ mock_http_pool_manager.return_value.status = 200
1826+ mock_http_pool_manager.return_value.data = b"ok"
1827+
1828+ check_kubernetes_api.check_kubernetes_health(host_address, token, disable_ssl)
1829+ mock_http_pool_manager.assert_called_with(cert_reqs="CERT_NONE", assert_hostname=False)
1830+
1831+ disable_ssl = False
1832+ check_kubernetes_api.check_kubernetes_health(host_address, token, disable_ssl)
1833+ mock_http_pool_manager.assert_called_with()
1834+
1835+ @mock.patch("check_kubernetes_api.urllib3.PoolManager")
1836+ def test_kubernetes_health_status(self, mock_http_pool_manager):
1837+ """Test kubernetes health function."""
1838+ host_address = "https://1.1.1.1:1111"
1839+ token = "0123456789abcdef"
1840+ ssl_ca = "test/cert/path"
1841+
1842+ mock_http_pool_manager.return_value.request.return_value.status = 200
1843+ mock_http_pool_manager.return_value.request.return_value.data = b"ok"
1844+
1845+ # verify status OK
1846+ status, _ = check_kubernetes_api.check_kubernetes_health(host_address, token, ssl_ca)
1847+ self.assertEqual(status, check_kubernetes_api.NAGIOS_STATUS_OK)
1848+ mock_http_pool_manager.return_value.request.assert_called_once_with(
1849+ "GET", "{}/healthz".format(host_address), headers={"Authorization": "Bearer {}".format(token)}
1850+ )
1851+
1852+ mock_http_pool_manager.return_value.request.return_value.status = 500
1853+ mock_http_pool_manager.return_value.request.return_value.data = b"ok"
1854+ status, _ = check_kubernetes_api.check_kubernetes_health(host_address, token, ssl_ca)
1855+ self.assertEqual(status, check_kubernetes_api.NAGIOS_STATUS_CRITICAL)
1856+
1857+ mock_http_pool_manager.return_value.request.return_value.status = 200
1858+ mock_http_pool_manager.return_value.request.return_value.data = b"not ok"
1859+ status, _ = check_kubernetes_api.check_kubernetes_health(host_address, token, ssl_ca)
1860+ self.assertEqual(status, check_kubernetes_api.NAGIOS_STATUS_WARNING)
1861diff --git a/tox.ini b/tox.ini
1862new file mode 100644
1863index 0000000..2f92371
1864--- /dev/null
1865+++ b/tox.ini
1866@@ -0,0 +1,52 @@
1867+[tox]
1868+skipsdist = True
1869+envlist = unit, func
1870+skip_missing_interpreters = True
1871+
1872+[testenv]
1873+basepython = python3
1874+setenv =
1875+ PYTHONPATH = {toxinidir}/lib/:{toxinidir}
1876+passenv =
1877+ HOME
1878+ MODEL_SETTINGS
1879+ CHARM_BUILD_DIR
1880+
1881+[testenv:unit]
1882+commands =
1883+ coverage run -m unittest discover -s {toxinidir}/tests/unit -v
1884+ coverage report \
1885+ --omit tests/*,mod/*,.tox/*
1886+ coverage html \
1887+ --omit tests/*,mod/*,.tox/*
1888+deps = -r{toxinidir}/tests/unit/requirements.txt
1889+
1890+[testenv:func]
1891+changedir = {toxinidir}/tests/functional
1892+commands = functest-run-suite {posargs}
1893+deps = -r{toxinidir}/tests/functional/requirements.txt
1894+
1895+[testenv:lint]
1896+commands =
1897+ flake8
1898+ black --check --line-length 120 --exclude /(\.eggs|\.git|\.tox|\.venv|build|dist|charmhelpers|mod)/ .
1899+deps =
1900+ black
1901+ flake8
1902+ flake8-docstrings
1903+ flake8-import-order
1904+ pep8-naming
1905+ flake8-colors
1906+
1907+[flake8]
1908+exclude =
1909+ .git,
1910+ __pycache__,
1911+ .tox,
1912+ mod,
1913+max-line-length = 120
1914+max-complexity = 10
1915+import-order-style = google
1916+
1917+[isort]
1918+force_to_top=setuppath

Subscribers

People subscribed via source and target branches

to all changes: