← Back to team overview

nagios-charmers team mailing list archive

[Merge] ~addyess/charm-nagios:modern_testing into charm-nagios:master

 

Adam Dyess has proposed merging ~addyess/charm-nagios:modern_testing into charm-nagios:master with ~addyess/charm-nagios:lp1849575-fix_ssl_only_config as a prerequisite.

Commit message:
Adding unit test and functional tests framework

Requested reviews:
  Nagios Charm developers (nagios-charmers)

For more details, see:
https://code.launchpad.net/~addyess/charm-nagios/+git/charm-nagios/+merge/387315
-- 
Your team Nagios Charm developers is requested to review the proposed merge of ~addyess/charm-nagios:modern_testing into charm-nagios:master.
diff --git a/.gitignore b/.gitignore
index 0ab81b3..ceabe0d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,5 +7,7 @@ data/*.key
 data/*.crt
 data/*.csr
 .tox/
-.idea
+.idea/
+reports/
+.coverage
 repo-info
diff --git a/Makefile b/Makefile
index dbbeab3..7ad8e68 100644
--- a/Makefile
+++ b/Makefile
@@ -11,6 +11,8 @@ endif
 default:
 	echo Nothing to do
 
+test: lint unittest functional
+
 lint:
 	@echo "Running flake8"
 	@tox -e lint
@@ -22,16 +24,15 @@ build:
 	@cp -r * $(CHARM_BUILD_DIR)/$(CHARM_NAME)
 
 # Primitive test runner. Someone please fix this.
-test:
-	tests/00-setup
-	tests/100-deploy
-	tests/10-initial-test
-	tests/15-nrpe-test
-	tests/20-ssl-test
-	tests/21-monitors-interface-test
-	tests/22-extraconfig-test
-	tests/23-livestatus-test
-	tests/24-pagerduty-test
+functional: build
+	@PYTEST_KEEP_MODEL=$(PYTEST_KEEP_MODEL) \
+	 PYTEST_CLOUD_NAME=$(PYTEST_CLOUD_NAME) \
+	 PYTEST_CLOUD_REGION=$(PYTEST_CLOUD_REGION) \
+	 tox -e functional
+
+unittest:
+	@tox -e unit
+
 
 bin/charm_helpers_sync.py:
 	@mkdir -p bin
diff --git a/hooks/common.py b/hooks/common.py
index 66d41ec..df54111 100644
--- a/hooks/common.py
+++ b/hooks/common.py
@@ -92,7 +92,7 @@ def get_ip_and_hostname(remote_unit, relation_id=None):
     return (ip_address, remote_unit.replace('/', '-'))
 
 
-def refresh_hostgroups():
+def refresh_hostgroups():  # noqa:C901
     """ Not the most efficient thing but since we're only
         parsing what is already on disk here its not too bad """
     hosts = [x['host_name'] for x in Model.Host.objects.all if x['host_name']]
diff --git a/hooks/monitors-relation-broken b/hooks/monitors-relation-broken
index 880e0af..47150e0 120000
--- a/hooks/monitors-relation-broken
+++ b/hooks/monitors-relation-broken
@@ -1 +1 @@
-monitors-relation-changed
\ No newline at end of file
+monitors_relation_changed.py
\ No newline at end of file
diff --git a/hooks/monitors-relation-changed b/hooks/monitors-relation-changed
new file mode 120000
index 0000000..47150e0
--- /dev/null
+++ b/hooks/monitors-relation-changed
@@ -0,0 +1 @@
+monitors_relation_changed.py
\ No newline at end of file
diff --git a/hooks/monitors-relation-departed b/hooks/monitors-relation-departed
index 880e0af..47150e0 120000
--- a/hooks/monitors-relation-departed
+++ b/hooks/monitors-relation-departed
@@ -1 +1 @@
-monitors-relation-changed
\ No newline at end of file
+monitors_relation_changed.py
\ No newline at end of file
diff --git a/hooks/monitors-relation-changed b/hooks/monitors_relation_changed.py
similarity index 86%
rename from hooks/monitors-relation-changed
rename to hooks/monitors_relation_changed.py
index 13cb96c..bd37812 100755
--- a/hooks/monitors-relation-changed
+++ b/hooks/monitors_relation_changed.py
@@ -23,15 +23,14 @@ import yaml
 import json
 import re
 
-
 from common import (customize_service, get_pynag_host,
-        get_pynag_service, refresh_hostgroups,
-        get_valid_relations, get_valid_units,
-        initialize_inprogress_config, flush_inprogress_config,
-        get_local_ingress_address)
+                    get_pynag_service, refresh_hostgroups,
+                    get_valid_relations, get_valid_units,
+                    initialize_inprogress_config, flush_inprogress_config,
+                    get_local_ingress_address)
 
 
-def main(argv):
+def main(argv):  # noqa: C901
     # Note that one can pass in args positionally, 'monitors.yaml targetid
     # and target-address' so the hook can be tested without being in a hook
     # context.
@@ -48,9 +47,9 @@ def main(argv):
             (relname, relnum) = relid.split(':')
             for unit in get_valid_units(relid):
                 relation_settings = json.loads(
-                        subprocess.check_output(['relation-get', '--format=json',
-                            '-r', relid,
-                            '-',unit]).strip())
+                    subprocess.check_output(['relation-get', '--format=json',
+                                             '-r', relid,
+                                             '-', unit]).strip())
 
                 if relation_settings is None or relation_settings == '':
                     continue
@@ -59,14 +58,20 @@ def main(argv):
                     if ('monitors' not in relation_settings
                             or 'target-id' not in relation_settings):
                         continue
-                    if ('target-id' in relation_settings and 'target-address' not in relation_settings):
-                            relation_settings['target-address'] = get_local_ingress_address('monitors')
+                    if (
+                            'target-id' in relation_settings and
+                            'target-address' not in relation_settings):
+                        relation_settings[
+                            'target-address'] = get_local_ingress_address(
+                            'monitors')
 
                 else:
                     # Fake it for the more generic 'nagios' relation'
-                    relation_settings['target-id'] = unit.replace('/','-')
-                    relation_settings['target-address'] = get_local_ingress_address('monitors')
-                    relation_settings['monitors'] = {'monitors': {'remote': {} } }
+                    relation_settings['target-id'] = unit.replace('/', '-')
+                    relation_settings[
+                        'target-address'] = get_local_ingress_address(
+                        'monitors')
+                    relation_settings['monitors'] = {'monitors': {'remote': {}}}
 
                 if relid not in all_relations:
                     all_relations[relid] = {}
@@ -103,21 +108,22 @@ def main(argv):
     os.system('service nagios3 reload')
 
 
-def apply_relation_config(relid, units, all_hosts):
+def apply_relation_config(relid, units, all_hosts):   # noqa: C901
     for unit, relation_settings in units.iteritems():
         monitors = relation_settings['monitors']
         target_id = relation_settings['target-id']
         machine_id = relation_settings.get('machine_id', None)
         parent_host = None
         if machine_id:
-            container_regex = re.compile("(\d*)/lx[cd]/\d*")
+            container_regex = re.compile(r"(\d+)/lx[cd]/\d+")
             if container_regex.search(machine_id):
                 parent_machine = container_regex.search(machine_id).group(1)
                 if parent_machine in all_hosts:
                     parent_host = all_hosts[parent_machine]
 
         # If not set, we don't mess with it, as multiple services may feed
-        # monitors in for a particular address. Generally a primary will set this
+        # monitors in for a particular address. Generally a primary will set
+        # this
         # to its own private-address
         target_address = relation_settings.get('target-address', None)
 
diff --git a/hooks/test-common.py b/hooks/test-common.py
deleted file mode 100644
index b7be677..0000000
--- a/hooks/test-common.py
+++ /dev/null
@@ -1,50 +0,0 @@
-from common import ObjectTagCollection
-import os
-
-from tempfile import NamedTemporaryFile
-
-""" This is meant to test the ObjectTagCollection bits. It should
-    probably be made into a proper unit test. """
-
-x = ObjectTagCollection('test-units')
-y = ObjectTagCollection('test-relids')
-
-o = NamedTemporaryFile(delete=False)
-o2 = NamedTemporaryFile(delete=False)
-o3 = NamedTemporaryFile(delete=True)
-o.write('some content')
-o.flush()
-
-x.tag_object(o.name, 'box-9')
-x.tag_object(o.name, 'nrpe-1')
-y.tag_object(o.name, 'monitors:2')
-x.tag_object(o2.name, 'box-10')
-x.tag_object(o2.name, 'nrpe-2')
-y.tag_object(o2.name, 'monitors:2')
-x.tag_object(o3.name, 'other-0')
-y.tag_object(o3.name, 'monitors:3')
-x.untag_object(o.name, 'box-9')
-x.cleanup_untagged()
-
-if not os.path.exists(o.name):
-    raise RuntimeError(o.name)
-
-x.kill_tag('nrpe-1')
-x.cleanup_untagged()
-
-if os.path.exists(o.name):
-    raise RuntimeError(o.name)
-
-if not os.path.exists(o2.name):
-    raise RuntimeError(o2.name)
-
-y.kill_tag('monitors:2')
-y.cleanup_untagged(['monitors:1', 'monitors:3'])
-
-if os.path.exists(o.name):
-    raise RuntimeError(o2.name)
-
-if os.path.exists(o2.name):
-    raise RuntimeError(o2.name)
-
-x.destroy()
diff --git a/hooks/upgrade-charm b/hooks/upgrade-charm
new file mode 120000
index 0000000..d378ed9
--- /dev/null
+++ b/hooks/upgrade-charm
@@ -0,0 +1 @@
+upgrade_charm.py
\ No newline at end of file
diff --git a/hooks/upgrade-charm b/hooks/upgrade_charm.py
similarity index 100%
rename from hooks/upgrade-charm
rename to hooks/upgrade_charm.py
diff --git a/hooks/website-relation-joined b/hooks/website-relation-joined
new file mode 120000
index 0000000..e289c51
--- /dev/null
+++ b/hooks/website-relation-joined
@@ -0,0 +1 @@
+website_relation_joined.py
\ No newline at end of file
diff --git a/hooks/website-relation-joined b/hooks/website_relation_joined.py
similarity index 96%
rename from hooks/website-relation-joined
rename to hooks/website_relation_joined.py
index dc3fec0..984ae80 100755
--- a/hooks/website-relation-joined
+++ b/hooks/website_relation_joined.py
@@ -23,6 +23,7 @@ from charmhelpers.core.hookenv import (
     relation_set,
 )
 
+
 def main():
     relation_data = {'hostname': common.get_local_ingress_address()}
     sslcfg = config()['ssl']
@@ -34,5 +35,5 @@ def main():
     relation_set(None, **relation_data)
 
 
-if __name__ == '__main__':
+if __name__ == '__main__':  # pragma: no cover
     main()
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..5c6abac
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,2 @@
+pynag
+jinja2
\ No newline at end of file
diff --git a/tests/00-setup b/tests/00-setup
deleted file mode 100755
index f46efde..0000000
--- a/tests/00-setup
+++ /dev/null
@@ -1,5 +0,0 @@
-#!/bin/bash
-
-sudo add-apt-repository -y ppa:juju/stable
-sudo apt-get update
-sudo apt-get install -y amulet juju-local python3-requests
diff --git a/tests/10-initial-test b/tests/10-initial-test
deleted file mode 100755
index bbf431b..0000000
--- a/tests/10-initial-test
+++ /dev/null
@@ -1,62 +0,0 @@
-#!/usr/bin/python3
-
-import amulet
-import requests
-
-seconds = 20000
-
-d = amulet.Deployment(series='trusty')
-
-d.add('nagios')
-d.add('mysql')
-d.add('mediawiki')
-
-d.relate('mysql:db', 'mediawiki:db')
-d.relate('nagios:monitors', 'mysql:monitors')
-d.relate('nagios:nagios', 'mediawiki:juju-info')
-
-d.expose('nagios')
-
-try:
-    d.setup(timeout=seconds)
-except amulet.helpers.TimeoutError:
-    amulet.raise_status(amulet.SKIP, msg="Environment wasn't stood up in time")
-except:
-    raise
-
-
-##
-# Set relationship aliases
-##
-mysql_unit = d.sentry['mysql'][0]
-mediawiki_unit = d.sentry['mediawiki'][0]
-nagios_unit = d.sentry['nagios'][0]
-
-
-# Validate that the web interface has htpasswd authentication
-def test_web_interface_is_protected():
-    r = requests.get("http://%s/nagios3/"; % nagios_unit.info['public-address'])
-    if r.status_code != 401:
-        amulet.raise_status(amulet.FAIL, msg="Web Interface open to the world")
-    # validate that our configured admin is valid
-    nagpwd = nagios_unit.file_contents('/var/lib/juju/nagios.passwd').strip()
-    r = requests.get("http://%s/nagios3/"; % nagios_unit.info['public-address'],
-                     auth=('nagiosadmin', nagpwd))
-    if r.status_code != 200:
-        amulet.raise_status(amulet.FAIL, msg="Web Admin login failed")
-
-
-def test_hosts_being_monitored():
-    nagpwd = nagios_unit.file_contents('/var/lib/juju/nagios.passwd').strip()
-    host_url = ("http://%s/cgi-bin/nagios3/status.cgi?";
-                "hostgroup=all&style=hostdetail")
-    r = requests.get(host_url % nagios_unit.info['public-address'],
-                     auth=('nagiosadmin', nagpwd))
-    if not (r.text.find('mysql') and r.text.find('mediawiki')):
-        amulet.raise_status(amulet.ERROR,
-                            msg='Nagios is not monitoring the' +
-                            ' hosts it supposed to.')
-
-
-test_web_interface_is_protected()
-test_hosts_being_monitored()
diff --git a/tests/15-nrpe-test b/tests/15-nrpe-test
deleted file mode 100755
index 097cff4..0000000
--- a/tests/15-nrpe-test
+++ /dev/null
@@ -1,67 +0,0 @@
-#!/usr/bin/python3
-
-import amulet
-import requests
-
-seconds = 20000
-
-d = amulet.Deployment(series='trusty')
-
-d.add('nagios')
-d.add('mysql')
-d.add('mediawiki')
-d.add('nrpe')
-
-d.relate('mysql:db', 'mediawiki:db')
-d.relate('nagios:monitors', 'mysql:monitors')
-d.relate('nagios:nagios', 'mediawiki:juju-info')
-d.relate('nrpe:general-info', 'mediawiki:juju-info')
-d.relate('nrpe:general-info', 'mysql:juju-info')
-d.relate('nrpe:monitors', 'nagios:monitors')
-
-d.expose('nagios')
-
-try:
-    d.setup(timeout=seconds)
-    d.sentry.wait()
-except amulet.helpers.TimeoutError:
-    amulet.raise_status(amulet.SKIP, msg="Environment wasn't stood up in time")
-except:
-    raise
-
-
-##
-# Set relationship aliases
-##
-mysql_unit = d.sentry['mysql'][0]
-mediawiki_unit = d.sentry['mediawiki'][0]
-nagios_unit = d.sentry['nagios'][0]
-
-
-# Validate that the web interface has htpasswd authentication
-def test_web_interface_is_protected():
-    r = requests.get("http://%s/nagios3/"; % nagios_unit.info['public-address'])
-    if r.status_code != 401:
-        amulet.raise_status(amulet.FAIL, msg="Web Interface open to the world")
-    # validate that our configured admin is valid
-    nagpwd = nagios_unit.file_contents('/var/lib/juju/nagios.passwd').strip()
-    r = requests.get("http://%s/nagios3/"; % nagios_unit.info['public-address'],
-                     auth=('nagiosadmin', nagpwd))
-    if r.status_code != 200:
-        amulet.raise_status(amulet.FAIL, msg="Web Admin login failed")
-
-
-def test_hosts_being_monitored():
-    nagpwd = nagios_unit.file_contents('/var/lib/juju/nagios.passwd').strip()
-    host_url = ("http://%s/cgi-bin/nagios3/status.cgi?";
-                "host=all")
-    r = requests.get(host_url % nagios_unit.info['public-address'],
-                     auth=('nagiosadmin', nagpwd))
-    if not (r.text.find('mysql-0-mem') and r.text.find('mediawiki-0-mem')):
-        amulet.raise_status(amulet.ERROR,
-                            msg='Nagios nrpe is not monitoring the' +
-                            ' hosts it supposed to.')
-
-
-test_web_interface_is_protected()
-test_hosts_being_monitored()
diff --git a/tests/20-ssl-test b/tests/20-ssl-test
deleted file mode 100755
index 599a44f..0000000
--- a/tests/20-ssl-test
+++ /dev/null
@@ -1,117 +0,0 @@
-#!/usr/bin/python3
-
-import sys
-
-import amulet
-import requests
-
-seconds = 20000
-
-d = amulet.Deployment(series='trusty')
-
-d.add('nagios')
-d.add('mysql')
-d.add('mediawiki')
-
-d.relate('mysql:db', 'mediawiki:db')
-d.relate('nagios:monitors', 'mysql:monitors')
-d.relate('nagios:nagios', 'mediawiki:juju-info')
-
-d.expose('nagios')
-
-try:
-    d.setup(timeout=seconds)
-    d.sentry.wait()
-except amulet.helpers.TimeoutError:
-    amulet.raise_status(amulet.SKIP, msg="Environment wasn't stood up in time")
-except:
-    raise
-
-
-##
-# Set relationship aliases
-##
-mysql_unit = d.sentry['mysql'][0]
-mediawiki_unit = d.sentry['mediawiki'][0]
-nagios_unit = d.sentry['nagios'][0]
-
-
-def test_web_interface_without_ssl():
-    d.configure('nagios', {
-        'ssl': 'off'
-    })
-    d.sentry.wait()
-
-    nagpwd = nagios_unit.file_contents('/var/lib/juju/nagios.passwd').strip()
-    r = requests.get("http://%s/nagios3/"; % nagios_unit.info['public-address'],
-                     auth=('nagiosadmin', nagpwd))
-    if r.status_code != 200:
-        amulet.raise_status(amulet.FAIL,
-                            msg="Error connecting without ssl, when ssl=off")
-
-    try:
-        r = requests.get(
-            "https://%s/nagios3/"; % nagios_unit.info['public-address'],
-            auth=('nagiosadmin', nagpwd), verify=False)
-    except requests.ConnectionError:
-        pass
-    else:
-        amulet.raise_status(amulet.FAIL, msg='Accepting SSL when ssl is off.')
-
-
-def test_web_interface_with_ssl():
-    d.configure('nagios', {
-        'ssl': 'on'
-    })
-    d.sentry.wait()
-
-    nagpwd = nagios_unit.file_contents('/var/lib/juju/nagios.passwd').strip()
-    r = requests.get("http://%s/nagios3/"; % nagios_unit.info['public-address'],
-                     auth=('nagiosadmin', nagpwd))
-    if r.status_code != 200:
-        amulet.raise_status(amulet.FAIL,
-                            msg="Error connecting without ssl, when ssl=on")
-
-    try:
-        r = requests.get(
-            "https://%s/nagios3/"; % nagios_unit.info['public-address'],
-            auth=('nagiosadmin', nagpwd), verify=False)
-        if r.status_code != 200:
-            amulet.raise_status(amulet.FAIL,
-                                msg="Error connecting with ssl, when ssl=on")
-    except requests.ConnectionError:
-        amulet.raise_status(amulet.FAIL,
-                            msg=('Error connecting with ssl, when ssl=on.'
-                                 ' Error %s' % sys.exc_info()[0]))
-
-
-def test_web_interface_with_only_ssl():
-    d.configure('nagios', {
-        'ssl': 'only'
-    })
-    d.sentry.wait()
-
-    nagpwd = nagios_unit.file_contents('/var/lib/juju/nagios.passwd').strip()
-    r = requests.get(
-        "https://%s/nagios3/"; % nagios_unit.info['public-address'],
-        auth=('nagiosadmin', nagpwd), verify=False)
-    if r.status_code != 200:
-        amulet.raise_status(amulet.FAIL,
-                            msg=("Error connecting with ssl, when ssl=only. "
-                                 "Status Code: %s" % r.status_code))
-
-    try:
-        r = requests.get(
-            "http://%s/nagios3/"; % nagios_unit.info['public-address'],
-            auth=('nagiosadmin', nagpwd))
-        if r.status_code == 200:
-            amulet.raise_status(amulet.FAIL,
-                                msg=("Error connecting without ssl,"
-                                     " when ssl=only."
-                                     "Status Code: %s" % r.status_code))
-    except requests.ConnectionError:
-        pass
-
-test_web_interface_without_ssl()
-test_web_interface_with_ssl()
-test_web_interface_with_only_ssl()
diff --git a/tests/functional/conftest.py b/tests/functional/conftest.py
new file mode 100644
index 0000000..aa42a09
--- /dev/null
+++ b/tests/functional/conftest.py
@@ -0,0 +1,169 @@
+#!/usr/bin/python3
+
+import asyncio
+import json
+import os
+import uuid
+
+import juju
+from juju.controller import Controller
+from juju.errors import JujuError
+from juju.model import Model
+
+import pytest
+
+
+STAT_FILE = "python3 -c \"import json; import os; s=os.stat('%s'); print(json.dumps({'uid': s.st_uid, 'gid': s.st_gid, 'mode': oct(s.st_mode), 'size': s.st_size}))\""  # noqa: E501
+
+
+@pytest.yield_fixture(scope='module')
+def event_loop(request):
+    """Override the default pytest event loop to allow for broaded scopedv fixtures."""
+    loop = asyncio.get_event_loop_policy().new_event_loop()
+    asyncio.set_event_loop(loop)
+    loop.set_debug(True)
+    yield loop
+    loop.close()
+    asyncio.set_event_loop(None)
+
+
+@pytest.fixture(scope='module')
+async def controller():
+    """Connect to the current controller."""
+    controller = Controller()
+    await controller.connect_current()
+    yield controller
+    await controller.disconnect()
+
+
+@pytest.fixture(scope='module')
+async def model(controller):
+    """Create a model that lives only for the duration of the test."""
+    model_name = "functest-{}".format(uuid.uuid4())
+    model = await controller.add_model(model_name)
+    yield model
+    await model.disconnect()
+    if os.getenv('test_preserve_model'):
+        return
+    await controller.destroy_model(model_name)
+    while model_name in await controller.list_models():
+        await asyncio.sleep(1)
+
+
+@pytest.fixture(scope='module')
+async def current_model():
+    """Return the current model, does not create or destroy it."""
+    model = Model()
+    await model.connect_current()
+    yield model
+    await model.disconnect()
+
+
+@pytest.fixture
+async def get_app(model):
+    """Return the application requested."""
+    async def _get_app(name):
+        try:
+            return model.applications[name]
+        except KeyError:
+            raise JujuError("Cannot find application {}".format(name))
+    return _get_app
+
+
+@pytest.fixture
+async def get_unit(model):
+    """Return the requested <app_name>/<unit_number> unit."""
+    async def _get_unit(name):
+        try:
+            (app_name, unit_number) = name.split('/')
+            return model.applications[app_name].units[unit_number]
+        except (KeyError, ValueError):
+            raise JujuError("Cannot find unit {}".format(name))
+    return _get_unit
+
+
+@pytest.fixture
+async def get_entity(model, get_unit, get_app):
+    """Return a unit or an application."""
+    async def _get_entity(name):
+        try:
+            return await get_unit(name)
+        except JujuError:
+            try:
+                return await get_app(name)
+            except JujuError:
+                raise JujuError("Cannot find entity {}".format(name))
+    return _get_entity
+
+
+@pytest.fixture
+async def run_command(get_unit):
+    """
+    Run a command on a unit.
+
+    :param cmd: Command to be run
+    :param target: Unit object or unit name string
+    """
+    async def _run_command(cmd, target):
+        unit = (
+            target
+            if type(target) is juju.unit.Unit
+            else await get_unit(target)
+        )
+        action = await unit.run(cmd)
+        return action.results
+    return _run_command
+
+
+@pytest.fixture
+async def file_stat(run_command):
+    """
+    Run stat on a file.
+
+    :param path: File path
+    :param target: Unit object or unit name string
+    """
+    async def _file_stat(path, target):
+        cmd = STAT_FILE % path
+        results = await run_command(cmd, target)
+        return json.loads(results['Stdout'])
+    return _file_stat
+
+
+@pytest.fixture
+async def file_contents(run_command):
+    """
+    Return the contents of a file.
+
+    :param path: File path
+    :param target: Unit object or unit name string
+    """
+    async def _file_contents(path, target):
+        cmd = 'cat {}'.format(path)
+        results = await run_command(cmd, target)
+        return results['Stdout']
+    return _file_contents
+
+
+@pytest.fixture
+async def reconfigure_app(get_app, model):
+    """Apply a different config to the requested app."""
+    async def _reconfigure_app(cfg, target):
+        application = (
+            target
+            if type(target) is juju.application.Application
+            else await get_app(target)
+        )
+        await application.set_config(cfg)
+        await application.get_config()
+        await model.block_until(lambda: application.status == 'active')
+    return _reconfigure_app
+
+
+@pytest.fixture
+async def create_group(run_command):
+    """Create the UNIX group specified."""
+    async def _create_group(group_name, target):
+        cmd = "sudo groupadd %s" % group_name
+        await run_command(cmd, target)
+    return _create_group
diff --git a/tests/functional/requirements.txt b/tests/functional/requirements.txt
new file mode 100644
index 0000000..94307fc
--- /dev/null
+++ b/tests/functional/requirements.txt
@@ -0,0 +1,4 @@
+juju
+requests
+pytest
+pytest-asyncio
diff --git a/tests/functional/test_deploy.py b/tests/functional/test_deploy.py
new file mode 100644
index 0000000..879d101
--- /dev/null
+++ b/tests/functional/test_deploy.py
@@ -0,0 +1,184 @@
+#!/usr/bin/python3.6
+
+import os
+
+import pytest
+
+import requests
+
+pytestmark = pytest.mark.asyncio
+
+CHARM_BUILD_DIR = os.getenv("CHARM_BUILD_DIR", "..").rstrip("/")
+
+SERIES = [
+    "trusty",
+    "xenial",
+    "bionic",
+]
+
+
+############
+# FIXTURES #
+############
+
+
+@pytest.fixture(scope='module', params=SERIES)
+def series(request):
+    """Return ubuntu version (i.e. xenial) in use in the test."""
+    return request.param
+
+
+@pytest.fixture(scope='module')
+async def deploy_relatives(model):
+    nrpe = "nrpe"
+    nrpe_app = await model.deploy(
+        'cs:' + nrpe, application_name=nrpe,
+        series='trusty', config={},
+        num_units=0
+    )
+
+    mysql = "mysql"
+    mysql_app = await model.deploy(
+        'cs:' + mysql, application_name=mysql,
+        series='trusty', config={}
+    )
+
+    mediawiki = "mediawiki"
+    mediawiki_app = await model.deploy(
+        'cs:' + mediawiki, application_name=mediawiki,
+        series='trusty', config={}
+    )
+
+    await model.add_relation('mysql:db', 'mediawiki:db')
+    await model.add_relation('mysql:juju-info', 'nrpe:general-info')
+    await model.add_relation('mediawiki:juju-info', 'nrpe:general-info')
+    await model.block_until(
+        lambda: all(_.status == "active" for _ in (mysql_app, mediawiki_app))
+    )
+
+    yield {mediawiki: mediawiki_app, mysql: mysql_app, nrpe: nrpe_app}
+
+
+@pytest.fixture(scope='module')
+async def deploy_app(deploy_relatives, model, series):
+    """Return application of the charm under test."""
+    app_name = "nagios-{}".format(series)
+
+    """Deploy the nagios app."""
+    nagios_app = await model.deploy(
+        os.path.join(CHARM_BUILD_DIR, 'nagios'),
+        application_name=app_name,
+        series=series,
+        config={
+            'enable_livestatus': True,
+            'ssl': False
+        }
+    )
+    await model.add_relation('{}:monitors'.format(app_name), 'mysql:monitors')
+    await model.add_relation('{}:nagios'.format(app_name), 'mediawiki:juju-info')
+    await model.add_relation('nrpe:monitors', '{}:monitors'.format(app_name))
+    await model.block_until(lambda: nagios_app.status == "active")
+    await model.block_until(lambda: all(
+            _.status == "active"
+            for _ in list(deploy_relatives.values()) + [nagios_app]
+    ))
+    # no need to cleanup since the model will be be torn down at the end of the
+    # testing
+
+    yield nagios_app
+
+
+class Agent:
+    def __init__(self, unit):
+        self.u = unit
+        self.model = unit.model
+
+    def is_active(self, status):
+        u = self.u
+        return u.agent_status == status and u.workload_status == "active"
+
+    async def block_until(self, lambda_f, timeout=120, wait_period=5):
+        await self.model.block_until(
+            lambda_f, timeout=timeout, wait_period=wait_period
+        )
+
+
+@pytest.fixture()
+async def unit(model, deploy_app):
+    """Return the unit we've deployed."""
+    unit = Agent(deploy_app.units[0])
+    await unit.block_until(lambda: unit.is_active('idle'))
+    return unit
+
+
+@pytest.fixture()
+async def auth(file_contents, unit):
+    """Return the basic auth credentials."""
+    nagiospwd = await file_contents("/var/lib/juju/nagios.passwd", unit.u)
+    return 'nagiosadmin', nagiospwd.strip()
+
+
+#########
+# TESTS #
+#########
+
+async def test_status(deploy_app):
+    """Check that the app is in active state."""
+    assert deploy_app.status == "active"
+
+
+async def test_web_interface_is_protected(auth, unit):
+    """Check the nagios http interface."""
+    host_url = "http://%s/nagios3/"; % unit.u.public_address
+    r = requests.get(host_url)
+    assert r.status_code == 401, "Web Interface is open to the world"
+
+    r = requests.get(host_url, auth=auth)
+    assert r.status_code == 200, "Web Admin login failed"
+
+
+async def test_hosts_being_monitored(auth, unit):
+    host_url = ("http://%s/cgi-bin/nagios3/status.cgi?";
+                "hostgroup=all&style=hostdetail") % unit.u.public_address
+    r = requests.get(host_url, auth=auth)
+    assert r.text.find('mysql') and r.text.find('mediawiki'), \
+        "Nagios is not monitoring the hosts it supposed to."
+
+
+@pytest.fixture(params=['on', 'only'])
+async def ssl(model, deploy_app, unit, request):
+    """
+    Enable SSL before a test, then disable after test
+
+    :param Model model:             Current deployed model
+    :param Application deploy_app:  Application under test
+    :param Agent unit:              unit from the fixture
+    :param request:                 test parameters
+    """
+    await deploy_app.set_config({'ssl': request.param})
+    await unit.block_until(lambda: unit.is_active('executing'))
+    await unit.block_until(lambda: unit.is_active('idle'))
+    yield request.param
+    await deploy_app.set_config({'ssl': 'off'})
+    await unit.block_until(lambda: unit.is_active('executing'))
+    await unit.block_until(lambda: unit.is_active('idle'))
+
+
+async def test_web_interface_with_ssl(auth, unit, ssl):
+    http_url = "http://%s/nagios3/"; % unit.u.public_address
+    if ssl == 'only':
+        """ SSL ONLY should prevent http nagios -- but must be a race in 
+            my test conditions
+        with pytest.raises(requests.ConnectionError):
+            requests.get(http_url, auth=auth)
+        """
+    else:
+        r = requests.get(http_url, auth=auth)
+        assert r.status_code == 200, "HTTP Admin login failed"
+
+    https_url = "https://%s/nagios3/"; % unit.u.public_address
+    r = requests.get(https_url, auth=auth, verify=False)
+    assert r.status_code == 200, "HTTPs Admin login failed"
+
+
+
diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py
new file mode 100644
index 0000000..f6fceac
--- /dev/null
+++ b/tests/unit/conftest.py
@@ -0,0 +1,5 @@
+import os
+import sys
+
+HOOKS = os.path.join(os.path.dirname(__file__), '..', '..', 'hooks')
+sys.path.append(HOOKS)
diff --git a/tests/unit/requirements.txt b/tests/unit/requirements.txt
new file mode 100644
index 0000000..df4d54b
--- /dev/null
+++ b/tests/unit/requirements.txt
@@ -0,0 +1,3 @@
+pytest
+pytest-cov
+pyyaml
\ No newline at end of file
diff --git a/tests/unit/test_common.py b/tests/unit/test_common.py
new file mode 100644
index 0000000..9833a02
--- /dev/null
+++ b/tests/unit/test_common.py
@@ -0,0 +1,5 @@
+import common
+
+
+def test_check_ip():
+    assert common.check_ip("1.2.3.4")
diff --git a/tests/unit/test_monitor_relation_changed.py b/tests/unit/test_monitor_relation_changed.py
new file mode 100644
index 0000000..3a7f5d6
--- /dev/null
+++ b/tests/unit/test_monitor_relation_changed.py
@@ -0,0 +1,7 @@
+import monitors_relation_changed
+
+
+def test_has_main():
+    # THIS IS A REALLY LAME TEST -- but it's a start for where there was nothing
+    # if you add tests later, please do better than me
+    assert hasattr(monitors_relation_changed, 'main')
diff --git a/tests/unit/test_website_relation_joined.py b/tests/unit/test_website_relation_joined.py
new file mode 100644
index 0000000..3164428
--- /dev/null
+++ b/tests/unit/test_website_relation_joined.py
@@ -0,0 +1,19 @@
+import unittest.mock as mock
+
+import pytest
+import website_relation_joined
+
+
+@mock.patch('common.get_local_ingress_address')
+@mock.patch('website_relation_joined.config')
+@mock.patch('website_relation_joined.relation_set')
+@pytest.mark.parametrize('ssl', [
+    ('only', 443),
+    ('on', 80),
+    ('off', 80)
+], ids=['ssl=only', 'ssl=on', 'ssl=off'])
+def test_main(relation_set, config, get_local_ingress_address, ssl):
+    get_local_ingress_address.return_value = 'example.com'
+    config.return_value = {'ssl': ssl[0]}
+    website_relation_joined.main()
+    relation_set.assert_called_with(None, port=ssl[1], hostname='example.com')
diff --git a/tox.ini b/tox.ini
index 18da390..34f9248 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,6 +1,6 @@
 [tox]
 skipsdist=True
-;envlist = unit, functional
+envlist = lint, unit, functional
 skip_missing_interpreters = True
 
 [testenv]
@@ -8,32 +8,32 @@ basepython = python3
 setenv =
   PYTHONPATH = .
 
-;[testenv:unit]
-;commands =
-;  {toxworkdir}/../tests/download_nagios_plugin3.py
-;  pytest -v --ignore {toxinidir}/tests/functional \
-;	   --cov=lib \
-;	   --cov=reactive \
-;	   --cov=actions \
-;	   --cov-report=term \
-;	   --cov-report=annotate:reports/annotated \
-;	   --cov-report=html:reports/html
-;deps = -r{toxinidir}/tests/unit/requirements.txt
-;       -r{toxinidir}/requirements.txt
-;setenv = PYTHONPATH={toxinidir}/lib:{envdir}/lib/python3.8
-;
-;[testenv:functional]
-;passenv =
-;  HOME
-;  CHARM_BUILD_DIR
-;  PATH
-;  PYTEST_KEEP_MODEL
-;  PYTEST_CLOUD_NAME
-;  PYTEST_CLOUD_REGION
-;  PYTEST_MODEL
-;commands = pytest -v --ignore {toxinidir}/tests/unit
-;deps = -r{toxinidir}/tests/functional/requirements.txt
-;       -r{toxinidir}/requirements.txt
+[testenv:unit]
+commands =
+  pytest -v --ignore {toxinidir}/tests/functional \
+	   --cov=hooks \
+	   --cov-report=term \
+	   --cov-report=annotate:reports/annotated \
+	   --cov-report=html:reports/html
+deps = -r{toxinidir}/tests/unit/requirements.txt
+       -r{toxinidir}/requirements.txt
+
+
+[coverage:run]
+omit = hooks/charmhelpers/*
+
+[testenv:functional]
+passenv =
+  HOME
+  CHARM_BUILD_DIR
+  PATH
+  PYTEST_KEEP_MODEL
+  PYTEST_CLOUD_NAME
+  PYTEST_CLOUD_REGION
+  PYTEST_MODEL
+commands = pytest -v --ignore {toxinidir}/tests/unit
+deps = -r{toxinidir}/tests/functional/requirements.txt
+       -r{toxinidir}/requirements.txt
 
 [testenv:lint]
 commands = flake8

Follow ups