← Back to team overview

wordpress-charmers team mailing list archive

[Merge] ~tcuthbert/charm-k8s-wordpress/+git/charm-k8s-wordpress:operator into charm-k8s-wordpress:master

 

Tom Haddon has proposed merging ~tcuthbert/charm-k8s-wordpress/+git/charm-k8s-wordpress:operator into charm-k8s-wordpress:master.

Commit message:
Convert to operator framework

Requested reviews:
  Wordpress Charmers (wordpress-charmers)

For more details, see:
https://code.launchpad.net/~tcuthbert/charm-k8s-wordpress/+git/charm-k8s-wordpress/+merge/381502

Convert to operator framework
-- 
Your team Wordpress Charmers is requested to review the proposed merge of ~tcuthbert/charm-k8s-wordpress/+git/charm-k8s-wordpress:operator into charm-k8s-wordpress:master.
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..172bf57
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+.tox
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..8c05fa9
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "mod/operator"]
+	path = mod/operator
+	url = https://github.com/canonical/operator
diff --git a/Makefile b/Makefile
index a46c2e2..cf18918 100644
--- a/Makefile
+++ b/Makefile
@@ -9,15 +9,8 @@ unittest:
 
 test: lint unittest
 
-build: lint
-	charm build
-
 clean:
 	@echo "Cleaning files"
-	@rm -rf ./.tox
-	@rm -rf ./.pytest_cache
-	@rm -rf ./tests/unit/__pycache__ ./reactive/__pycache__ ./lib/__pycache__
-	@rm -rf ./.coverage ./.unit-state.db
-
+	@git clean -fXd
 
-.PHONY: lint test unittest build clean
+.PHONY: lint test unittest clean
diff --git a/hooks/start b/hooks/start
new file mode 120000
index 0000000..25b1f68
--- /dev/null
+++ b/hooks/start
@@ -0,0 +1 @@
+../src/charm.py
\ No newline at end of file
diff --git a/layer.yaml b/layer.yaml
deleted file mode 100644
index d7700f7..0000000
--- a/layer.yaml
+++ /dev/null
@@ -1,4 +0,0 @@
-includes:
-  - 'layer:caas-base'
-  - 'layer:status'
-repo: git+ssh://git.launchpad.net/charm-k8s-wordpress
diff --git a/lib/ops b/lib/ops
new file mode 120000
index 0000000..d934193
--- /dev/null
+++ b/lib/ops
@@ -0,0 +1 @@
+../mod/operator/ops
\ No newline at end of file
diff --git a/mod/operator b/mod/operator
new file mode 160000
index 0000000..44dff93
--- /dev/null
+++ b/mod/operator
@@ -0,0 +1 @@
+Subproject commit 44dff930667aa8e9b179c11fa87ceb8c9b85ec5a
diff --git a/reactive/wordpress.py b/reactive/wordpress.py
deleted file mode 100644
index 9a1b013..0000000
--- a/reactive/wordpress.py
+++ /dev/null
@@ -1,292 +0,0 @@
-import io
-import os
-import re
-import requests
-from pprint import pprint
-from urllib.parse import urlparse, urlunparse
-from yaml import safe_load
-
-from charmhelpers.core import host, hookenv
-from charms import reactive
-from charms.layer import caas_base, status
-from charms.reactive import hook, when, when_not
-
-
-@hook("upgrade-charm")
-def upgrade_charm():
-    status.maintenance("Upgrading charm")
-    reactive.clear_flag("wordpress.configured")
-
-
-@when("config.changed")
-def reconfig():
-    status.maintenance("charm configuration changed")
-    reactive.clear_flag("wordpress.configured")
-
-    # Validate config
-    valid = True
-    config = hookenv.config()
-    # Ensure required strings
-    for k in ["image", "db_host", "db_name", "db_user", "db_password"]:
-        if config[k].strip() == "":
-            status.blocked("{!r} config is required".format(k))
-            valid = False
-
-    reactive.toggle_flag("wordpress.config.valid", valid)
-
-
-@when("wordpress.config.valid")
-@when_not("wordpress.configured")
-def deploy_container():
-    spec = make_pod_spec()
-    if spec is None:
-        return  # Status already set
-    if reactive.data_changed("wordpress.spec", spec):
-        status.maintenance("configuring container")
-        try:
-            caas_base.pod_spec_set(spec)
-        except Exception as e:
-            hookenv.log("pod_spec_set failed: {}".format(e), hookenv.DEBUG)
-            status.blocked("pod_spec_set failed! Check logs and k8s dashboard.")
-            return
-    else:
-        hookenv.log("No changes to pod spec")
-    if first_install():
-        reactive.set_flag("wordpress.configured")
-
-
-@when("wordpress.configured")
-def ready():
-    status.active("Ready")
-
-
-def sanitized_container_config():
-    """Container config without secrets"""
-    config = hookenv.config()
-    if config["container_config"].strip() == "":
-        container_config = {}
-    else:
-        container_config = safe_load(config["container_config"])
-        if not isinstance(container_config, dict):
-            status.blocked("container_config is not a YAML mapping")
-            return None
-    container_config["WORDPRESS_DB_HOST"] = config["db_host"]
-    container_config["WORDPRESS_DB_NAME"] = config["db_name"]
-    container_config["WORDPRESS_DB_USER"] = config["db_user"]
-    if config.get("wp_plugin_openid_team_map"):
-        container_config["WP_PLUGIN_OPENID_TEAM_MAP"] = config["wp_plugin_openid_team_map"]
-    return container_config
-
-
-def full_container_config():
-    """Container config with secrets"""
-    config = hookenv.config()
-    container_config = sanitized_container_config()
-    if container_config is None:
-        return None
-    if config["container_secrets"].strip() == "":
-        container_secrets = {}
-    else:
-        container_secrets = safe_load(config["container_secrets"])
-        if not isinstance(container_secrets, dict):
-            status.blocked("container_secrets is not a YAML mapping")
-            return None
-    container_config.update(container_secrets)
-    # Add secrets from charm config
-    container_config["WORDPRESS_DB_PASSWORD"] = config["db_password"]
-    if config.get("wp_plugin_akismet_key"):
-        container_config["WP_PLUGIN_AKISMET_KEY"] = config["wp_plugin_akismet_key"]
-    return container_config
-
-
-def make_pod_spec():
-    config = hookenv.config()
-    container_config = sanitized_container_config()
-    if container_config is None:
-        return  # Status already set
-
-    ports = [
-        {"name": name, "containerPort": int(port), "protocol": "TCP"}
-        for name, port in [addr.split(":", 1) for addr in config["ports"].split()]
-    ]
-
-    # PodSpec v1? https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.13/#podspec-v1-core
-    spec = {
-        "containers": [
-            {
-                "name": hookenv.charm_name(),
-                "imageDetails": {"imagePath": config["image"]},
-                "ports": ports,
-                "config": container_config,
-            }
-        ]
-    }
-    out = io.StringIO()
-    pprint(spec, out)
-    hookenv.log("Container environment config (sans secrets) <<EOM\n{}\nEOM".format(out.getvalue()))
-
-    # If we need credentials (secrets) for our image, add them to the spec after logging
-    if config.get("image_user") and config.get("image_pass"):
-        spec.get("containers")[0].get("imageDetails")["username"] = config["image_user"]
-        spec.get("containers")[0].get("imageDetails")["password"] = config["image_pass"]
-
-    config_with_secrets = full_container_config()
-    if config_with_secrets is None:
-        return None  # Status already set
-    container_config.update(config_with_secrets)
-
-    return spec
-
-
-def first_install():
-    """Perform initial configuration of wordpress if needed."""
-    config = hookenv.config()
-    if not is_pod_up("website"):
-        hookenv.log("Pod not yet ready - retrying")
-        return False
-    elif not is_vhost_ready():
-        hookenv.log("Wordpress vhost is not yet listening - retrying")
-        return False
-    elif wordpress_configured() or not config["initial_settings"]:
-        hookenv.log("No initial_setting provided or wordpress already configured. Skipping first install.")
-        return True
-    hookenv.log("Starting wordpress initial configuration")
-    payload = {
-        "admin_password": host.pwgen(24),
-        "blog_public": "checked",
-        "Submit": "submit",
-    }
-    payload.update(safe_load(config["initial_settings"]))
-    payload["admin_password2"] = payload["admin_password"]
-    if not payload["blog_public"]:
-        payload["blog_public"] = "unchecked"
-    required_config = set(("user_name", "admin_email"))
-    missing = required_config.difference(payload.keys())
-    if missing:
-        hookenv.log("Error: missing wordpress settings: {}".format(missing))
-        return False
-    call_wordpress("/wp-admin/install.php?step=2", redirects=True, payload=payload)
-    host.write_file(os.path.join("/root/", "initial.passwd"), payload["admin_password"], perms=0o400)
-    return True
-
-
-def call_wordpress(uri, redirects=True, payload={}, _depth=1):
-    max_depth = 10
-    if _depth > max_depth:
-        hookenv.log("Redirect loop detected in call_worpress()")
-        raise RuntimeError("Redirect loop detected in call_worpress()")
-    config = hookenv.config()
-    service_ip = get_service_ip("website")
-    if service_ip:
-        headers = {"Host": config["blog_hostname"]}
-        url = urlunparse(("http", service_ip, uri, "", "", ""))
-        if payload:
-            r = requests.post(url, allow_redirects=False, headers=headers, data=payload, timeout=30)
-        else:
-            r = requests.get(url, allow_redirects=False, headers=headers, timeout=30)
-        if redirects and r.is_redirect:
-            # Recurse, but strip the scheme and host first, we need to connect over HTTP by bare IP
-            o = urlparse(r.headers.get("Location"))
-            return call_wordpress(o.path, redirects=redirects, payload=payload, _depth=_depth + 1)
-        else:
-            return r
-    else:
-        hookenv.log("Error getting service IP")
-        return False
-
-
-def wordpress_configured():
-    """Check whether first install has been completed."""
-    # Check whether pod is deployed
-    if not is_pod_up("website"):
-        return False
-    # Check if we have WP code deployed at all
-    if not is_vhost_ready():
-        return False
-    # We have code on disk, check if configured
-    try:
-        r = call_wordpress("/", redirects=False)
-    except requests.exceptions.ConnectionError:
-        return False
-    if r.status_code == 302 and re.match("^.*/wp-admin/install.php", r.headers.get("location", "")):
-        return False
-    elif r.status_code == 302 and re.match("^.*/wp-admin/setup-config.php", r.headers.get("location", "")):
-        hookenv.log("MySQL database setup failed, we likely have no wp-config.php")
-        status.blocked("MySQL database setup failed, we likely have no wp-config.php")
-        return False
-    else:
-        return True
-
-
-def is_vhost_ready():
-    """Check whether wordpress is available using http."""
-    # Check if we have WP code deployed at all
-    try:
-        r = call_wordpress("/wp-login.php", redirects=False)
-    except requests.exceptions.ConnectionError:
-        hookenv.log("call_wordpress() returned requests.exceptions.ConnectionError")
-        return False
-    if r is None:
-        hookenv.log("call_wordpress() returned None")
-        return False
-    if hasattr(r, "status_code") and r.status_code in (403, 404):
-        hookenv.log("call_wordpress() returned status {}".format(r.status_code))
-        return False
-    else:
-        return True
-
-
-def get_service_ip(endpoint):
-    try:
-        info = hookenv.network_get(endpoint, hookenv.relation_id())
-        if "ingress-addresses" in info:
-            addr = info["ingress-addresses"][0]
-            if len(addr):
-                return addr
-        else:
-            hookenv.log("No ingress-addresses: {}".format(info))
-    except Exception as e:
-        hookenv.log("Caught exception checking for service IP: {}".format(e))
-
-    return None
-
-
-def is_pod_up(endpoint):
-    """Check to see if the pod of a relation is up.
-
-    application-vimdb: 19:29:10 INFO unit.vimdb/0.juju-log network info
-
-    In the example below:
-    - 10.1.1.105 is the address of the application pod.
-    - 10.152.183.199 is the service cluster ip
-
-    {
-        'bind-addresses': [{
-            'macaddress': '',
-            'interfacename': '',
-            'addresses': [{
-                'hostname': '',
-                'address': '10.1.1.105',
-                'cidr': ''
-            }]
-        }],
-        'egress-subnets': [
-            '10.152.183.199/32'
-        ],
-        'ingress-addresses': [
-            '10.152.183.199',
-            '10.1.1.105'
-        ]
-    }
-    """
-    try:
-        info = hookenv.network_get(endpoint, hookenv.relation_id())
-
-        # Check to see if the pod has been assigned its internal and external ips
-        for ingress in info["ingress-addresses"]:
-            if len(ingress) == 0:
-                return False
-    except Exception:
-        return False
-
-    return True
diff --git a/src/charm.py b/src/charm.py
new file mode 100755
index 0000000..967a0a9
--- /dev/null
+++ b/src/charm.py
@@ -0,0 +1,332 @@
+#!/usr/bin/env python3
+
+import io
+import re
+import secrets
+import subprocess
+import string
+import sys
+from urllib.parse import urlparse, urlunparse
+from yaml import safe_load
+
+sys.path.append("lib")
+
+from ops.charm import CharmBase, CharmEvents  # NoQA: E402
+from ops.framework import EventBase, EventSource, StoredState  # NoQA: E402
+from ops.main import main  # NoQA: E402
+from ops.model import ActiveStatus, BlockedStatus, MaintenanceStatus, WaitingStatus  # NoQA: E402
+
+import logging  # NoQA: E402
+
+logger = logging.getLogger()
+
+
+def password_generator():
+    alphabet = string.ascii_letters + string.digits
+    return ''.join(secrets.choice(alphabet) for i in range(8))
+
+class WordpressInitialiseEvent(EventBase):
+    pass
+
+
+class WordpressCharmEvents(CharmEvents):
+    wp_initialise = EventSource(WordpressInitialiseEvent)
+
+
+class WordpressK8sCharm(CharmBase):
+    state = StoredState()
+    on = WordpressCharmEvents()
+
+    def __init__(self, *args):
+        super().__init__(*args)
+        for event in (self.on.start,
+                      self.on.config_changed,
+                      self.on.wp_initialise,
+                      ):
+            self.framework.observe(event, self)
+
+        self.state.set_default(_init=True)
+        self.state.set_default(_started=False)
+        self.state.set_default(_valid=False)
+        self.state.set_default(_configured=False)
+
+    def on_start(self, event):
+        self.state._started = True
+
+    def on_config_changed(self, event):
+        if not self.state._valid:
+            config = self.model.config
+            want = ("image", "db_host", "db_name", "db_user", "db_password")
+            missing = [k for k in want if config[k].rstrip() == ""]
+            if missing:
+                message = " ".join(missing)
+                logger.info("Missing required config: {}".format(message))
+                self.model.unit.status = BlockedStatus("{} config is required".format(message))
+                event.defer()
+                return
+
+            self.state._valid = True
+
+        if not self.state._configured:
+            logger.info("Configuring pod")
+            return self.configure_pod()
+
+        if self.model.unit.is_leader() and self.state._init:
+            self.on.wp_initialise.emit()
+
+    def on_wp_initialise(self, event):
+        if not self.state._init:
+            return
+
+        ready = self.install_ready()
+        if not ready:
+            # Until k8s supports telling Juju our pod is available we need to defer initial
+            # site setup for a subsequent update-status or config-changed hook to complete.
+            self.model.unit.status = WaitingStatus("Waiting for pod to be ready")
+            event.defer()
+            return
+
+        installed = self.first_install()
+        if not installed:
+            event.defer()
+            return
+
+        logger.info("Wordpress installed and initialised")
+        self.state._init = False
+
+    def configure_pod(self):
+        # only the leader can set_spec()
+        if self.model.unit.is_leader():
+            spec = self.make_pod_spec()
+            self.model.unit.status = MaintenanceStatus("Configuring container")
+            self.model.pod.set_spec(spec)
+            self.state._configured = True
+
+    def make_pod_spec(self):
+        config = self.model.config
+        container_config = self.sanitized_container_config()
+        if container_config is None:
+            return  # Status already set
+
+        ports = [
+            {"name": name, "containerPort": int(port), "protocol": "TCP"}
+            for name, port in [addr.split(":", 1) for addr in config["ports"].split()]
+        ]
+
+        # PodSpec v1? https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.13/#podspec-v1-core
+        spec = {
+            "containers": [
+                {
+                    "name": self.app.name,
+                    "imageDetails": {"imagePath": config["image"]},
+                    "ports": ports,
+                    "config": container_config,
+                    "readinessProbe": {"exec": {"command": ["/bin/cat", "/srv/wordpress-helpers/.ready"]}},
+                }
+            ]
+        }
+
+        # If we need credentials (secrets) for our image, add them to the spec after logging
+        if config.get("image_user") and config.get("image_pass"):
+            spec.get("containers")[0].get("imageDetails")["username"] = config["image_user"]
+            spec.get("containers")[0].get("imageDetails")["password"] = config["image_pass"]
+
+        config_with_secrets = self.full_container_config()
+        if config_with_secrets is None:
+            return None  # Status already set
+        container_config.update(config_with_secrets)
+
+        return spec
+
+    def sanitized_container_config(self):
+        """Container config without secrets"""
+        config = self.model.config
+        if config["container_config"].strip() == "":
+            container_config = {}
+        else:
+            container_config = safe_load(config["container_config"])
+            if not isinstance(container_config, dict):
+                self.model.unit.status = BlockedStatus("container_config is not a YAML mapping")
+                return None
+        container_config["WORDPRESS_DB_HOST"] = config["db_host"]
+        container_config["WORDPRESS_DB_NAME"] = config["db_name"]
+        container_config["WORDPRESS_DB_USER"] = config["db_user"]
+        if config.get("wp_plugin_openid_team_map"):
+            container_config["WP_PLUGIN_OPENID_TEAM_MAP"] = config["wp_plugin_openid_team_map"]
+        return container_config
+
+    def full_container_config(self):
+        """Container config with secrets"""
+        config = self.model.config
+        container_config = self.sanitized_container_config()
+        if container_config is None:
+            return None
+        if config["container_secrets"].strip() == "":
+            container_secrets = {}
+        else:
+            container_secrets = safe_load(config["container_secrets"])
+            if not isinstance(container_secrets, dict):
+                self.model.unit.status = BlockedStatus("container_secrets is not a YAML mapping")
+                return None
+        container_config.update(container_secrets)
+        # Add secrets from charm config
+        container_config["WORDPRESS_DB_PASSWORD"] = config["db_password"]
+        if config.get("wp_plugin_akismet_key"):
+            container_config["WP_PLUGIN_AKISMET_KEY"] = config["wp_plugin_akismet_key"]
+        return container_config
+
+    def install_ready(self):
+        ready = True
+        config = self.model.config
+        if not self.is_pod_up("website"):
+            logger.info("Pod not yet ready - retrying")
+            ready = False
+
+        try:
+            if not self.is_vhost_ready():
+                ready = False
+        except Exception as e:
+            logger.info("Wordpress vhost is not yet listening - retrying: {}".format(e))
+            ready = False
+
+        if not config["initial_settings"]:
+            logger.info("No initial_setting provided or wordpress already configured. Skipping first install.")
+            logger.info("{} {}".format(self.state._configured, config["initial_settings"]))
+            ready = False
+
+        return ready
+
+    def first_install(self):
+        """Perform initial configuration of wordpress if needed."""
+        config = self.model.config
+        logger.info("Starting wordpress initial configuration")
+        admin_password = password_generator()
+        payload = {
+            "admin_password": admin_password,
+            "blog_public": "checked",
+            "Submit": "submit",
+        }
+        payload.update(safe_load(config["initial_settings"]))
+        payload["admin_password2"] = payload["admin_password"]
+
+        with open("/root/initial.passwd", "w") as f:
+            f.write(payload["admin_password"])
+
+        if not payload["blog_public"]:
+            payload["blog_public"] = "unchecked"
+        required_config = set(("user_name", "admin_email"))
+        missing = required_config.difference(payload.keys())
+        if missing:
+            logger.info("Error: missing wordpress settings: {}".format(missing))
+            return
+        try:
+            r = self.call_wordpress("/wp-admin/install.php?step=2", redirects=True, payload=payload)
+        except Exception as e:
+            logger.info("failed to call_wordpress: {}".format(e))
+            return
+
+        if not self.wordpress_configured():
+            self.model.unit.status = BlockedStatus("Failed to install wordpress")
+
+        self.model.unit.status = ActiveStatus()
+        return True
+
+    def call_wordpress(self, uri, redirects=True, payload={}, _depth=1):
+        try:
+            import requests
+        except ImportError:
+            subprocess.check_call(['apt-get', 'update'])
+            subprocess.check_call(['apt-get', '-y', 'install', 'python3-requests'])
+            import requests
+
+        max_depth = 10
+        if _depth > max_depth:
+            logger.info("Redirect loop detected in call_worpress()")
+            raise RuntimeError("Redirect loop detected in call_worpress()")
+        config = self.model.config
+        service_ip = self.get_service_ip("website")
+        if service_ip:
+            headers = {"Host": config["blog_hostname"]}
+            url = urlunparse(("http", service_ip, uri, "", "", ""))
+            if payload:
+                r = requests.post(url, allow_redirects=False, headers=headers, data=payload, timeout=30)
+            else:
+                r = requests.get(url, allow_redirects=False, headers=headers, timeout=30)
+            if redirects and r.is_redirect:
+                # Recurse, but strip the scheme and host first, we need to connect over HTTP by bare IP
+                o = urlparse(r.headers.get("Location"))
+                return self.call_wordpress(o.path, redirects=redirects, payload=payload, _depth=_depth + 1)
+            else:
+                return r
+        else:
+            logger.info("Error getting service IP")
+            return False
+
+    def wordpress_configured(self):
+        """Check whether first install has been completed."""
+        try:
+            import requests
+        except ImportError:
+            subprocess.check_call(['apt-get', 'update'])
+            subprocess.check_call(['apt-get', '-y', 'install', 'python3-requests'])
+            import requests
+
+        # Check whether pod is deployed
+        if not self.is_pod_up("website"):
+            return False
+        # Check if we have WP code deployed at all
+        if not self.is_vhost_ready():
+            return False
+        # We have code on disk, check if configured
+        try:
+            r = self.call_wordpress("/", redirects=False)
+        except requests.exceptions.ConnectionError:
+            return False
+
+        if r.status_code == 302 and re.match("^.*/wp-admin/install.php", r.headers.get("location", "")):
+            return False
+        elif r.status_code == 302 and re.match("^.*/wp-admin/setup-config.php", r.headers.get("location", "")):
+            logger.info("MySQL database setup failed, we likely have no wp-config.php")
+            self.model.unit.status = BlockedStatus("MySQL database setup failed, we likely have no wp-config.php")
+            return False
+        else:
+            return True
+
+    def is_vhost_ready(self):
+        """Check whether wordpress is available using http."""
+        try:
+            import requests
+        except ImportError:
+            subprocess.check_call(['apt-get', 'update'])
+            subprocess.check_call(['apt-get', '-y', 'install', 'python3-requests'])
+            import requests
+
+        rv = True
+        # Check if we have WP code deployed at all
+        try:
+            r = self.call_wordpress("/wp-login.php", redirects=False)
+            if r is None:
+                logger.error("call_wordpress() returned None")
+                rv = False
+            if hasattr(r, "status_code") and r.status_code in (403, 404):
+                logger.info("Wordpress returned an unexpected status {}".format(r.status_code))
+                rv = False
+        except requests.exceptions.ConnectionError:
+            logger.info("Apache vhost is not ready yet")
+            rv = False
+
+        return rv
+
+    def get_service_ip(self, endpoint):
+        try:
+            return str(self.model.get_binding(endpoint).network.ingress_addresses[0])
+        except Exception:
+            logger.info("We don't have any ingress addresses yet")
+
+    def is_pod_up(self, endpoint):
+        """Check to see if the pod of a relation is up"""
+        return self.get_service_ip(endpoint) or False
+
+
+if __name__ == "__main__":
+    main(WordpressK8sCharm)
diff --git a/tests/unit/test_wordpress.py b/tests/unit/test_wordpress.py
index 5ade069..e69de29 100644
--- a/tests/unit/test_wordpress.py
+++ b/tests/unit/test_wordpress.py
@@ -1,64 +0,0 @@
-import os
-import shutil
-import sys
-import tempfile
-import unittest
-from unittest import mock
-
-# We also need to mock up charms.layer so we can run unit tests without having
-# to build the charm and pull in layers such as layer-status.
-sys.modules['charms.layer'] = mock.MagicMock()
-
-from charms.layer import status  # NOQA: E402
-
-# Add path to where our reactive layer lives and import.
-sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.realpath(__file__)))))
-from reactive import wordpress  # NOQA: E402
-
-
-class TestCharm(unittest.TestCase):
-    def setUp(self):
-        self.maxDiff = None
-        self.tmpdir = tempfile.mkdtemp(prefix='charm-unittests-')
-        self.addCleanup(shutil.rmtree, self.tmpdir)
-
-        self.charm_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.realpath(__file__))))
-
-        patcher = mock.patch('charmhelpers.core.hookenv.log')
-        self.mock_log = patcher.start()
-        self.addCleanup(patcher.stop)
-        self.mock_log.return_value = ''
-
-        patcher = mock.patch('charmhelpers.core.hookenv.charm_dir')
-        self.mock_charm_dir = patcher.start()
-        self.addCleanup(patcher.stop)
-        self.mock_charm_dir.return_value = self.charm_dir
-
-        patcher = mock.patch('charmhelpers.core.hookenv.local_unit')
-        self.mock_local_unit = patcher.start()
-        self.addCleanup(patcher.stop)
-        self.mock_local_unit.return_value = 'mock-wordpress/0'
-
-        patcher = mock.patch('charmhelpers.core.hookenv.config')
-        self.mock_config = patcher.start()
-        self.addCleanup(patcher.stop)
-        self.mock_config.return_value = {'blog_hostname': 'myblog.example.com'}
-
-        patcher = mock.patch('charmhelpers.core.host.log')
-        self.mock_log = patcher.start()
-        self.addCleanup(patcher.stop)
-        self.mock_log.return_value = ''
-
-        status.active.reset_mock()
-        status.blocked.reset_mock()
-        status.maintenance.reset_mock()
-
-    @mock.patch('charms.reactive.clear_flag')
-    def test_hook_upgrade_charm_flags(self, clear_flag):
-        '''Test correct flags set via upgrade-charm hook'''
-        wordpress.upgrade_charm()
-        self.assertFalse(status.maintenance.assert_called())
-        want = [
-            mock.call('wordpress.configured'),
-        ]
-        self.assertFalse(clear_flag.assert_has_calls(want, any_order=True))
diff --git a/tox.ini b/tox.ini
index 7b45934..3f4c39f 100644
--- a/tox.ini
+++ b/tox.ini
@@ -10,7 +10,7 @@ setenv =
 
 [testenv:unit]
 commands =
-    pytest --ignore {toxinidir}/tests/functional \
+    pytest --ignore mod --ignore {toxinidir}/tests/functional \
       {posargs:-v  --cov=reactive --cov-report=term-missing --cov-branch}
 deps = -r{toxinidir}/tests/unit/requirements.txt
        -r{toxinidir}/requirements.txt
@@ -24,16 +24,16 @@ passenv =
   JUJU_REPOSITORY
   PATH
 commands =
-	pytest -v --ignore {toxinidir}/tests/unit {posargs}
+	pytest -v --ignore mod --ignore {toxinidir}/tests/unit {posargs}
 deps = -r{toxinidir}/tests/functional/requirements.txt
        -r{toxinidir}/requirements.txt
 
 [testenv:black]
-commands = black --skip-string-normalization --line-length=120 .
+commands = black --skip-string-normalization --line-length=120 src/ tests/
 deps = black
 
 [testenv:lint]
-commands = flake8
+commands = flake8 src/ tests/
 deps = flake8
 
 [flake8]
diff --git a/wheelhouse.txt b/wheelhouse.txt
deleted file mode 100644
index f229360..0000000
--- a/wheelhouse.txt
+++ /dev/null
@@ -1 +0,0 @@
-requests

Follow ups