← Back to team overview

wordpress-charmers team mailing list archive

[Merge] ~tcuthbert/charm-k8s-wordpress:sidecar into charm-k8s-wordpress:master

 

Thomas Cuthbert has proposed merging ~tcuthbert/charm-k8s-wordpress:sidecar into charm-k8s-wordpress:master.

Requested reviews:
  🤖 prod-jenkaas-is (prod-jenkaas-is): continuous-integration
  Wordpress Charmers (wordpress-charmers)
  Canonical IS Reviewers (canonical-is-reviewers)

For more details, see:
https://code.launchpad.net/~tcuthbert/charm-k8s-wordpress/+git/charm-k8s-wordpress-1/+merge/404135
-- 
Your team Wordpress Charmers is requested to review the proposed merge of ~tcuthbert/charm-k8s-wordpress:sidecar into charm-k8s-wordpress:master.
diff --git a/Dockerfile b/Dockerfile
index 8d92b92..c9d842d 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -80,6 +80,13 @@ RUN wget -O wordpress.tar.gz -t 3 -r "https://wordpress.org/wordpress-${VERSION}
     && rm -rf /var/www/html \
     && mv /usr/src/wordpress /var/www/html
 
+<<<<<<< Dockerfile
+=======
+RUN wget https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar \
+    && chmod +x wp-cli.phar \
+    && mv wp-cli.phar /usr/local/bin/wp
+
+>>>>>>> Dockerfile
 COPY ./image-builder/files/ /files/
 # wp-info.php contains template variables which our ENTRYPOINT script will populate
 RUN install -D /files/wp-info.php /var/www/html/wp-info.php
diff --git a/README.md b/README.md
index d6e4fa4..b2b4d3f 100644
--- a/README.md
+++ b/README.md
@@ -19,11 +19,13 @@ details on using Juju with MicroK8s for easy local testing [see here](https://ju
 To deploy the charm and relate it to the [MariaDB K8s charm](https://charmhub.io/mariadb) within a Juju
 Kubernetes model:
 
+    juju deploy nginx-ingress-integrator ingress
     juju deploy charmed-osm-mariadb-k8s mariadb
-    juju deploy wordpress-k8s
+    juju deploy wordpress-k8s --resource wordpress-image=wordpresscharmers/wordpress:bionic-5.7
     juju relate wordpress-k8s mariadb:mysql
+    juju relate wordpress-k8s ingress:ingress
 
-It will take about 5 to 10 minutes for Juju hooks to discover the site is live
+It will take about 2 to 5 minutes for Juju hooks to discover the site is live
 and perform the initial setup for you. Once the "Workload" status is "active",
 your WordPress site is configured.
 
@@ -31,7 +33,7 @@ To retrieve the auto-generated admin password, run the following:
 
     juju run-action --wait wordpress-k8s/0 get-initial-password
 
-You should now be able to browse to the IP address of the unit. Here's some
+You should now be able to browse to the site hostname. Here's some
 sample output from `juju status`:
 
     Unit                Workload     Agent  Address      Ports     Message
diff --git a/config.yaml b/config.yaml
index 55c3b0b..9568bb7 100644
--- a/config.yaml
+++ b/config.yaml
@@ -28,15 +28,15 @@ options:
   db_name:
     type: string
     description: "MySQL database name"
-    default: "wordpress"
+    default: ""
   db_user:
     type: string
     description: "MySQL database user"
-    default: "wordpress"
+    default: ""
   db_password:
     type: string
     description: "MySQL database user's password"
-    default: "wordpress"
+    default: ""
   additional_hostnames:
     type: string
     description: "Space separated list of aditional hostnames for the site."
@@ -78,8 +78,9 @@ options:
         admin_email: devnull@xxxxxxxxxxx
   blog_hostname:
     type: string
-    description: Blog hostname
-    default: "myblog.example.com"
+    description: >
+      The blog hostname. If left unset, defaults to wordpress-k8s.
+    default: ""
   wp_plugin_akismet_key:
     type: string
     description: Akismet key. If empty, akismet will not be automatically enabled
diff --git a/image-builder/files/wp-config.php b/image-builder/files/wp-config.php
index 696b14d..b3b88b3 100644
--- a/image-builder/files/wp-config.php
+++ b/image-builder/files/wp-config.php
@@ -55,5 +55,3 @@ define( 'WP_AUTO_UPDATE_CORE', false );
 $http_host = $_SERVER['HTTP_HOST'];
 define('WP_HOME',"https://$http_host";);
 define('WP_SITEURL',"https://$http_host";);
-
-remove_filter('template_redirect', 'redirect_canonical');
diff --git a/lib/charms/nginx_ingress_integrator/v0/ingress.py b/lib/charms/nginx_ingress_integrator/v0/ingress.py
new file mode 100644
index 0000000..688a77c
--- /dev/null
+++ b/lib/charms/nginx_ingress_integrator/v0/ingress.py
@@ -0,0 +1,198 @@
+"""Library for the ingress relation.
+
+This library contains the Requires and Provides classes for handling
+the ingress interface.
+
+Import `IngressRequires` in your charm, with two required options:
+    - "self" (the charm itself)
+    - config_dict
+
+`config_dict` accepts the following keys:
+    - service-hostname (required)
+    - service-name (required)
+    - service-port (required)
+    - limit-rps
+    - limit-whitelist
+    - max_body-size
+    - retry-errors
+    - service-namespace
+    - session-cookie-max-age
+    - tls-secret-name
+
+See [the config section](https://charmhub.io/nginx-ingress-integrator/configure) for descriptions
+of each, along with the required type.
+
+As an example, add the following to `src/charm.py`:
+```
+from charms.nginx_ingress_integrator.v0.ingress import IngressRequires
+
+# In your charm's `__init__` method.
+self.ingress = IngressRequires(self, {"service-hostname": self.config["external_hostname"],
+                                      "service-name": self.app.name,
+                                      "service-port": 80})
+
+# In your charm's `config-changed` handler.
+self.ingress.update_config({"service-hostname": self.config["external_hostname"]})
+```
+And then add the following to `metadata.yaml`:
+```
+requires:
+  ingress:
+    interface: ingress
+```
+"""
+
+import logging
+
+from ops.charm import CharmEvents
+from ops.framework import EventBase, EventSource, Object
+from ops.model import BlockedStatus
+
+# The unique Charmhub library identifier, never change it
+LIBID = "db0af4367506491c91663468fb5caa4c"
+
+# Increment this major API version when introducing breaking changes
+LIBAPI = 0
+
+# Increment this PATCH version before using `charmcraft publish-lib` or reset
+# to 0 if you are raising the major API version
+LIBPATCH = 5
+
+logger = logging.getLogger(__name__)
+
+REQUIRED_INGRESS_RELATION_FIELDS = {
+    "service-hostname",
+    "service-name",
+    "service-port",
+}
+
+OPTIONAL_INGRESS_RELATION_FIELDS = {
+    "limit-rps",
+    "limit-whitelist",
+    "max-body-size",
+    "retry-errors",
+    "service-namespace",
+    "session-cookie-max-age",
+    "tls-secret-name",
+}
+
+
+class IngressAvailableEvent(EventBase):
+    pass
+
+
+class IngressCharmEvents(CharmEvents):
+    """Custom charm events."""
+
+    ingress_available = EventSource(IngressAvailableEvent)
+
+
+class IngressRequires(Object):
+    """This class defines the functionality for the 'requires' side of the 'ingress' relation.
+
+    Hook events observed:
+        - relation-changed
+    """
+
+    def __init__(self, charm, config_dict):
+        super().__init__(charm, "ingress")
+
+        self.framework.observe(charm.on["ingress"].relation_changed, self._on_relation_changed)
+
+        self.config_dict = config_dict
+
+    def _config_dict_errors(self, update_only=False):
+        """Check our config dict for errors."""
+        blocked_message = "Error in ingress relation, check `juju debug-log`"
+        unknown = [
+            x
+            for x in self.config_dict
+            if x not in REQUIRED_INGRESS_RELATION_FIELDS | OPTIONAL_INGRESS_RELATION_FIELDS
+        ]
+        if unknown:
+            logger.error(
+                "Ingress relation error, unknown key(s) in config dictionary found: %s",
+                ", ".join(unknown),
+            )
+            self.model.unit.status = BlockedStatus(blocked_message)
+            return True
+        if not update_only:
+            missing = [x for x in REQUIRED_INGRESS_RELATION_FIELDS if x not in self.config_dict]
+            if missing:
+                logger.error(
+                    "Ingress relation error, missing required key(s) in config dictionary: %s",
+                    ", ".join(missing),
+                )
+                self.model.unit.status = BlockedStatus(blocked_message)
+                return True
+        return False
+
+    def _on_relation_changed(self, event):
+        """Handle the relation-changed event."""
+        # `self.unit` isn't available here, so use `self.model.unit`.
+        if self.model.unit.is_leader():
+            if self._config_dict_errors():
+                return
+            for key in self.config_dict:
+                event.relation.data[self.model.app][key] = str(self.config_dict[key])
+
+    def update_config(self, config_dict):
+        """Allow for updates to relation."""
+        if self.model.unit.is_leader():
+            self.config_dict = config_dict
+            if self._config_dict_errors(update_only=True):
+                return
+            relation = self.model.get_relation("ingress")
+            if relation:
+                for key in self.config_dict:
+                    relation.data[self.model.app][key] = str(self.config_dict[key])
+
+
+class IngressProvides(Object):
+    """This class defines the functionality for the 'provides' side of the 'ingress' relation.
+
+    Hook events observed:
+        - relation-changed
+    """
+
+    def __init__(self, charm):
+        super().__init__(charm, "ingress")
+        # Observe the relation-changed hook event and bind
+        # self.on_relation_changed() to handle the event.
+        self.framework.observe(charm.on["ingress"].relation_changed, self._on_relation_changed)
+        self.charm = charm
+
+    def _on_relation_changed(self, event):
+        """Handle a change to the ingress relation.
+
+        Confirm we have the fields we expect to receive."""
+        # `self.unit` isn't available here, so use `self.model.unit`.
+        if not self.model.unit.is_leader():
+            return
+
+        ingress_data = {
+            field: event.relation.data[event.app].get(field)
+            for field in REQUIRED_INGRESS_RELATION_FIELDS | OPTIONAL_INGRESS_RELATION_FIELDS
+        }
+
+        missing_fields = sorted(
+            [
+                field
+                for field in REQUIRED_INGRESS_RELATION_FIELDS
+                if ingress_data.get(field) is None
+            ]
+        )
+
+        if missing_fields:
+            logger.error(
+                "Missing required data fields for ingress relation: {}".format(
+                    ", ".join(missing_fields)
+                )
+            )
+            self.model.unit.status = BlockedStatus(
+                "Missing fields for ingress: {}".format(", ".join(missing_fields))
+            )
+
+        # Create an event that our charm can use to decide it's okay to
+        # configure the ingress.
+        self.charm.on.ingress_available.emit()
diff --git a/metadata.yaml b/metadata.yaml
index 4259f46..dec5c42 100644
--- a/metadata.yaml
+++ b/metadata.yaml
@@ -3,14 +3,21 @@ display-name: WordPress
 summary: "WordPress is open source software you can use to create a beautiful website, blog, or app."
 description: "WordPress is open source software you can use to create a beautiful website, blog, or app. https://wordpress.org/";
 docs: https://discourse.charmhub.io/t/wordpress-documentation-overview/4052
-min-juju-version: 2.8.0
 maintainers:
   - https://launchpad.net/~wordpress-charmers <wordpress-charmers@xxxxxxxxxxxxxxxxxxx>
 tags:
   - applications
   - blog
-series:
-  - kubernetes
+
+containers:
+  wordpress:
+    resource: wordpress-image
+
+resources:
+  wordpress-image:
+    type: oci-image
+    description: OCI image for wordpress
+
 provides:
   website:
     interface: http
@@ -18,3 +25,6 @@ requires:
   db:
     interface: mysql
     limit: 1
+  ingress:
+    interface: ingress
+    limit: 1
diff --git a/src/charm.py b/src/charm.py
index cba136a..0017686 100755
--- a/src/charm.py
+++ b/src/charm.py
@@ -1,75 +1,30 @@
 #!/usr/bin/env python3
-
-import io
 import logging
 import re
-from pprint import pprint
+import os
 from yaml import safe_load
 
 from ops.charm import CharmBase, CharmEvents
 from ops.framework import EventBase, EventSource, StoredState
 from ops.main import main
 from ops.model import ActiveStatus, BlockedStatus, MaintenanceStatus, WaitingStatus
-from leadership import LeadershipSettings
 
+from charms.nginx_ingress_integrator.v0.ingress import IngressRequires
+from leadership import LeadershipSettings
 from opslib.mysql import MySQLClient
+
 from wordpress import Wordpress, password_generator, WORDPRESS_SECRETS
 
 
 logger = logging.getLogger()
 
 
-def generate_pod_config(config, secured=True):
-    """Kubernetes pod config generator.
-
-    generate_pod_config generates Kubernetes deployment config.
-    If the secured keyword is set then it will return a sanitised copy
-    without exposing secrets.
-    """
-    pod_config = {}
-    if config["container_config"].strip():
-        pod_config = safe_load(config["container_config"])
-
-    pod_config["WORDPRESS_DB_HOST"] = config["db_host"]
-    pod_config["WORDPRESS_DB_NAME"] = config["db_name"]
-    pod_config["WORDPRESS_DB_USER"] = config["db_user"]
-    if not config["tls_secret_name"]:
-        pod_config["WORDPRESS_TLS_DISABLED"] = "true"
-    if config.get("wp_plugin_openid_team_map"):
-        pod_config["WP_PLUGIN_OPENID_TEAM_MAP"] = config["wp_plugin_openid_team_map"]
-
-    if secured:
-        return pod_config
-
-    # Add secrets from charm config.
-    pod_config["WORDPRESS_DB_PASSWORD"] = config["db_password"]
-    if config.get("wp_plugin_akismet_key"):
-        pod_config["WP_PLUGIN_AKISMET_KEY"] = config["wp_plugin_akismet_key"]
-    if config.get("wp_plugin_openstack-objectstorage_config"):
-        # Actual plugin name is 'openstack-objectstorage', but we're only
-        # implementing the 'swift' portion of it.
-        wp_plugin_swift_config = safe_load(config.get("wp_plugin_openstack-objectstorage_config"))
-        pod_config["SWIFT_AUTH_URL"] = wp_plugin_swift_config.get('auth-url')
-        pod_config["SWIFT_BUCKET"] = wp_plugin_swift_config.get('bucket')
-        pod_config["SWIFT_PASSWORD"] = wp_plugin_swift_config.get('password')
-        pod_config["SWIFT_PREFIX"] = wp_plugin_swift_config.get('prefix')
-        pod_config["SWIFT_REGION"] = wp_plugin_swift_config.get('region')
-        pod_config["SWIFT_TENANT"] = wp_plugin_swift_config.get('tenant')
-        pod_config["SWIFT_URL"] = wp_plugin_swift_config.get('url')
-        pod_config["SWIFT_USERNAME"] = wp_plugin_swift_config.get('username')
-        pod_config["SWIFT_COPY_TO_SWIFT"] = wp_plugin_swift_config.get('copy-to-swift')
-        pod_config["SWIFT_SERVE_FROM_SWIFT"] = wp_plugin_swift_config.get('serve-from-swift')
-        pod_config["SWIFT_REMOVE_LOCAL_FILE"] = wp_plugin_swift_config.get('remove-local-file')
-
-    return pod_config
-
-
 def juju_setting_to_list(config_string, split_char=" "):
     "Transforms Juju setting strings into a list, defaults to splitting on whitespace."
     return config_string.split(split_char)
 
 
-class WordpressInitialiseEvent(EventBase):
+class WordpressFirstInstallEvent(EventBase):
     """Custom event for signalling Wordpress initialisation.
 
     WordpressInitialiseEvent allows us to signal the handler for
@@ -79,81 +34,383 @@ class WordpressInitialiseEvent(EventBase):
     pass
 
 
+class WordpressStaticDatabaseChanged(EventBase):
+    """Custom event for static Database configuration changed.
+
+    WordpressStaticDatabaseChanged provides the same interface as the
+    db.on.database_changed event which enables the WordPressCharm's
+    on_database_changed handler to update state for both relation and static
+    database configuration events.
+    """
+
+    @property
+    def database(self):
+        return self.model.config["db_name"]
+
+    @property
+    def host(self):
+        return self.model.config["db_host"]
+
+    @property
+    def user(self):
+        return self.model.config["db_user"]
+
+    @property
+    def password(self):
+        return self.model.config["db_password"]
+
+    @property
+    def model(self):
+        return self.framework.model
+
+
 class WordpressCharmEvents(CharmEvents):
     """Register custom charm events.
 
-    WordpressCharmEvents registers the custom WordpressInitialiseEvent
-    event to the charm.
+    WordpressCharmEvents registers the custom WordpressFirstInstallEvent
+    and WordpressStaticDatabaseChanged event to the charm.
     """
 
-    wordpress_initialise = EventSource(WordpressInitialiseEvent)
+    wordpress_initial_setup = EventSource(WordpressFirstInstallEvent)
+    wordpress_static_database_changed = EventSource(WordpressStaticDatabaseChanged)
 
 
 class WordpressCharm(CharmBase):
+
+    _container_name = "wordpress"
+    _default_service_port = 80
+
     state = StoredState()
-    # Override the default list of event handlers with our WordpressCharmEvents subclass.
     on = WordpressCharmEvents()
 
-    def __init__(self, *args):
-        super().__init__(*args)
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
 
         self.leader_data = LeadershipSettings()
 
-        self.framework.observe(self.on.start, self.on_config_changed)
+        logger.debug("registering framework handlers...")
+
+        self.framework.observe(self.on.wordpress_pebble_ready, self.on_config_changed)
         self.framework.observe(self.on.config_changed, self.on_config_changed)
-        self.framework.observe(self.on.update_status, self.on_config_changed)
-        self.framework.observe(self.on.wordpress_initialise, self.on_wordpress_initialise)
+        self.framework.observe(self.on.leader_elected, self.on_leader_elected)
 
         # Actions.
         self.framework.observe(self.on.get_initial_password_action, self._on_get_initial_password_action)
 
-        self.db = MySQLClient(self, 'db')
+        self.db = MySQLClient(self, "db")
         self.framework.observe(self.on.db_relation_created, self.on_db_relation_created)
         self.framework.observe(self.on.db_relation_broken, self.on_db_relation_broken)
-        self.framework.observe(self.db.on.database_changed, self.on_database_changed)
+
+        # Handlers for if user supplies database connection details or a charm relation.
+        self.framework.observe(self.on.config_changed, self.on_database_config_changed)
+        for db_changed_handler in [self.db.on.database_changed, self.on.wordpress_static_database_changed]:
+            self.framework.observe(db_changed_handler, self.on_database_changed)
 
         c = self.model.config
         self.state.set_default(
-            initialised=False, valid=False, has_db_relation=False,
-            db_host=c["db_host"], db_name=c["db_name"], db_user=c["db_user"], db_password=c["db_password"]
+            blog_hostname=c["blog_hostname"] or self.app.name,
+            installed_successfully=False,
+            install_state=set(),
+            has_db_relation=False,
+            has_ingress_relation=False,
+            db_host=c["db_host"] or None,
+            db_name=c["db_name"] or None,
+            db_user=c["db_user"] or None,
+            db_password=c["db_password"] or None,
         )
+
         self.wordpress = Wordpress(c)
 
+        self.ingress = IngressRequires(self, self.ingress_config)
+
+        self.framework.observe(self.on.ingress_relation_changed, self.on_ingress_relation_changed)
+        self.framework.observe(self.on.ingress_relation_created, self.on_ingress_relation_changed)
+
+        # TODO: It would be nice if there was a way to unregister an observer at runtime.
+        # Once the site is installed there is no need for self.on_wordpress_uninitialised to continue to observe
+        # config-changed hooks.
+        if self.state.installed_successfully is False:
+            self.framework.observe(self.on.config_changed, self.on_wordpress_uninitialised)
+            self.framework.observe(self.on.wordpress_initial_setup, self.on_wordpress_initial_setup)
+        logger.debug("all observe hooks registered...")
+
+    @property
+    def container_name(self):
+        return self._container_name
+
+    @property
+    def service_ip_address(self):
+        return os.environ.get("WORDPRESS_SERVICE_SERVICE_HOST")
+
+    @property
+    def service_port(self):
+        return self._default_service_port
+
+    @property
+    def wordpress_workload(self):
+        """Returns the WordPress pebble workload configuration."""
+        return {
+            "summary": "WordPress layer",
+            "description": "pebble config layer for WordPress",
+            "services": {
+                "wordpress-plugins": {
+                    "override": "replace",
+                    "summary": "WordPress plugin updater",
+                    "command": (
+                        "bash -c '/srv/wordpress-helpers/plugin_handler.py && "
+                        "stat /srv/wordpress-helpers/.ready && "
+                        "sleep infinity'"
+                    ),
+                    "after": ["apache2"],
+                    "environment": self._env_config,
+                },
+                "wordpress-init": {
+                    "override": "replace",
+                    "summary": "WordPress initialiser",
+                    "command": (
+                        "bash -c '"
+                        "/charm/bin/wordpressInit.sh >> /wordpressInit.log 2>&1"
+                        "'"
+                    ),
+                    "environment": self._env_config,
+                },
+                "apache2": {
+                    "override": "replace",
+                    "summary": "Apache2 service",
+                    "command": (
+                        "bash -c '"
+                        "apache2ctl -D FOREGROUND -E /apache-error.log -e debug >>/apache-sout.log 2>&1"
+                        "'"
+                    ),
+                    "requires": ["wordpress-init"],
+                    "after": ["wordpress-init"],
+                    "environment": self._env_config,
+                },
+                self.container_name: {
+                    "override": "replace",
+                    "summary": "WordPress service",
+                    "command": "sleep infinity",
+                    "requires": ["apache2", "wordpress-plugins"],
+                    "environment": self._env_config,
+                },
+            },
+        }
+
+    @property
+    def ingress_config(self):
+        blog_hostname = self.state.blog_hostname
+        ingress_config = {
+            "service-hostname": blog_hostname,
+            "service-name": self.app.name,
+            "service-port": self.service_port,
+        }
+        tls_secret_name = self.model.config["tls_secret_name"]
+        if tls_secret_name:
+            ingress_config["tls-secret-name"] = tls_secret_name
+        return ingress_config
+
+    @property
+    def _db_config(self):
+        """Kubernetes Pod environment variables."""
+        # TODO: make this less fragile.
+        if self.unit.is_leader():
+            return {
+                "WORDPRESS_DB_HOST": self.state.db_host,
+                "WORDPRESS_DB_NAME": self.state.db_name,
+                "WORDPRESS_DB_USER": self.state.db_user,
+                "WORDPRESS_DB_PASSWORD": self.state.db_password,
+            }
+        else:
+            return {
+                "WORDPRESS_DB_HOST": self.leader_data["db_host"],
+                "WORDPRESS_DB_NAME": self.leader_data["db_name"],
+                "WORDPRESS_DB_USER": self.leader_data["db_user"],
+                "WORDPRESS_DB_PASSWORD": self.leader_data["db_password"],
+            }
+
+    @property
+    def _env_config(self):
+        """Kubernetes Pod environment variables."""
+        config = dict(self.model.config)
+        env_config = {}
+        if config["container_config"].strip():
+            env_config = safe_load(config["container_config"])
+
+        env_config["WORDPRESS_BLOG_HOSTNAME"] = self.state.blog_hostname
+        initial_settings = {}
+        if config["initial_settings"].strip():
+            initial_settings.update(safe_load(config["initial_settings"]))
+        # TODO: make these class default attributes
+        env_config["WORDPRESS_ADMIN_USER"] = initial_settings.get("user_name", "admin")
+        env_config["WORDPRESS_ADMIN_EMAIL"] = initial_settings.get("admin_email", "nobody@localhost")
+
+        env_config["WORDPRESS_INSTALLED"] = self.state.installed_successfully
+        env_config.update(self._wordpress_secrets)
+
+        if not config["tls_secret_name"]:
+            env_config["WORDPRESS_TLS_DISABLED"] = "true"
+        if config.get("wp_plugin_openid_team_map"):
+            env_config["WP_PLUGIN_OPENID_TEAM_MAP"] = config["wp_plugin_openid_team_map"]
+
+        # Add secrets from charm config.
+        if config.get("wp_plugin_akismet_key"):
+            env_config["WP_PLUGIN_AKISMET_KEY"] = config["wp_plugin_akismet_key"]
+        if config.get("wp_plugin_openstack-objectstorage_config"):
+            # Actual plugin name is 'openstack-objectstorage', but we're only
+            # implementing the 'swift' portion of it.
+            wp_plugin_swift_config = safe_load(config.get("wp_plugin_openstack-objectstorage_config"))
+            env_config["SWIFT_AUTH_URL"] = wp_plugin_swift_config.get("auth-url")
+            env_config["SWIFT_BUCKET"] = wp_plugin_swift_config.get("bucket")
+            env_config["SWIFT_PASSWORD"] = wp_plugin_swift_config.get("password")
+            env_config["SWIFT_PREFIX"] = wp_plugin_swift_config.get("prefix")
+            env_config["SWIFT_REGION"] = wp_plugin_swift_config.get("region")
+            env_config["SWIFT_TENANT"] = wp_plugin_swift_config.get("tenant")
+            env_config["SWIFT_URL"] = wp_plugin_swift_config.get("url")
+            env_config["SWIFT_USERNAME"] = wp_plugin_swift_config.get("username")
+            env_config["SWIFT_COPY_TO_SWIFT"] = wp_plugin_swift_config.get("copy-to-swift")
+            env_config["SWIFT_SERVE_FROM_SWIFT"] = wp_plugin_swift_config.get("serve-from-swift")
+            env_config["SWIFT_REMOVE_LOCAL_FILE"] = wp_plugin_swift_config.get("remove-local-file")
+
+        env_config.update(self._db_config)
+        return env_config
+
+    def on_wordpress_uninitialised(self, event):
+        """Setup the WordPress service with default values.
+
+        WordPress will expose the setup page to the user to manually
+        configure with their browser. This isn't ideal from a security
+        perspective so the charm will initialise the site for you and
+        expose the admin password via `get_initial_password_action`.
+
+        This method observes all changes to the system by registering
+        to the .on.config_changed event. This avoids current state split
+        brain issues because all changes to the system sink into
+        `on.config_changed`.
+
+        It defines the state of the install ready state as:
+          - We aren't leader, so check leader_data install state for the installed state answer.
+          - We aren't ready to setup WordPress yet (missing configuration data).
+          - We're ready to do the initial setup of WordPress (all dependent configuration data set).
+          - We're currently setting up WordPress, lock out any other events from attempting to install.
+          - WordPress is operating in a production capacity, no more work to do, no-op.
+        """
+
+        if self.unit.is_leader() is False:
+            # Poorly named, expect a separate flag for non leader units here.
+            self.state.installed_successfully = self.leader_data.setdefault("installed", False)
+
+        if self.state.installed_successfully is True:
+            logger.warning("already installed, nothing more to do...")
+            return
+
+        # By using sets we're able to follow a state relay pattern. Each event handler that is
+        # responsible for setting state adds their flag to the set. Once thet set is complete
+        # it will be observed here. During the install phase we use StoredState as a mutex lock
+        # to avoid race conditions with future events. By calling .emit() we flush the current
+        # state to persistent storage which ensures future events do not observe stale state.
+        first_time_ready = {"leader", "db", "ingress", "leader"}
+        install_running = {"attempted", "ingress", "db", "leader"}
+
+        logger.debug(
+            (
+                f"DEBUG: current install ready state is {self.state.install_state}, "
+                f"required install ready state is {first_time_ready}"
+            )
+        )
+
+        if self.state.install_state == install_running:
+            logger.info("Install phase currently running...")
+            BlockedStatus("WordPress installing...")
+
+        elif self.state.install_state == first_time_ready:
+            # TODO:
+            # Check if WordPress is already installed.
+            # Would be something like
+            #   if self.is_vhost_ready():[...]
+            WaitingStatus("WordPress not installed yet...")
+            self.state.attempted_install = True
+            self.state.install_state.add("attempted")
+            logger.info("Attempting WordPress install...")
+            self.on.wordpress_initial_setup.emit()
+
+    def on_wordpress_initial_setup(self, event):
+        logger.info("Beginning WordPress setup process...")
+        container = self.unit.get_container(self.container_name)
+        container.add_layer(self.container_name, self.wordpress_workload, combine=True)
+
+        # Temporary workaround until the init script is baked into the Dockerimage.
+        setup_service = "wordpressInit"
+        src_path = f"src/{setup_service}.sh"
+        charm_bin = "/charm/bin"
+        dst_path = f"{charm_bin}/{setup_service}.sh"
+        with open(src_path, "r", encoding="utf-8") as f:
+            container.push(dst_path, f, permissions=0o755)
+
+        admin_password = "/admin_password"
+        config = self._get_initial_password()
+        container.push(admin_password, config, permissions=0o400)
+
+        logger.info("Adding WordPress layer to container...")
+        self.ingress.update_config(self.ingress_config)
+        container = self.unit.get_container(self.container_name)
+        pebble = container.pebble
+        wait_on = pebble.start_services([self.container_name])
+        pebble.wait_change(wait_on)
+
+        logger.info("first time WordPress install was successful...")
+        container.remove_path(admin_password)
+        self.unit.status = MaintenanceStatus("WordPress Initialised")
+
+        wait_on = pebble.stop_services([s for s in self.wordpress_workload["services"]])
+        self.leader_data["installed"] = True
+        self.state.installed_successfully = True
+        self.on.config_changed.emit()
+
     def on_config_changed(self, event):
-        """Handle the config-changed hook."""
-        self.config_changed()
-
-    def on_wordpress_initialise(self, event):
-        wordpress_needs_configuring = False
-        pod_alive = self.model.unit.is_leader() and self.is_service_up()
-        if pod_alive:
-            wordpress_configured = self.wordpress.wordpress_configured(self.get_service_ip())
-            wordpress_needs_configuring = not self.state.initialised and not wordpress_configured
-        elif self.model.unit.is_leader():
-            msg = "Wordpress workload pod is not ready"
-            logger.info(msg)
-            self.model.unit.status = WaitingStatus(msg)
+        """Merge charm configuration transitions."""
+        logger.debug(f"Event {event} install ready state is {self.state.install_state}")
+
+        is_valid = self.is_valid_config()
+        if not is_valid:
             return
 
-        if wordpress_needs_configuring:
-            msg = "Wordpress needs configuration"
-            logger.info(msg)
-            self.model.unit.status = MaintenanceStatus(msg)
-            initial_password = self._get_initial_password()
-            installed = self.wordpress.first_install(self.get_service_ip(), initial_password)
-            if not installed:
-                msg = "Failed to configure wordpress"
-                logger.info(msg)
-                self.model.unit.status = BlockedStatus(msg)
-                return
-
-            self.state.initialised = True
-            logger.info("Wordpress configured and initialised")
-            self.model.unit.status = ActiveStatus()
+        container = self.unit.get_container(self.container_name)
+        services = container.get_plan().to_dict().get("services", {})
 
-        else:
-            logger.info("Wordpress workload pod is ready and configured")
-            self.model.unit.status = ActiveStatus()
+        if services != self.wordpress_workload["services"]:
+            logger.info("WordPress configuration transition detected...")
+            self.unit.status = MaintenanceStatus("Transitioning WordPress configuration")
+            container.add_layer(self.container_name, self.wordpress_workload, combine=True)
+
+            self.unit.status = MaintenanceStatus("Restarting WordPress")
+            running_services = [s for s in self.wordpress_workload["services"] if container.get_service(s).is_running()]
+            if running_services:
+                container.pebble.stop_services(running_services)
+
+            # Temporary workaround until the init script is baked into the Dockerimage.
+            setup_service = "wordpressInit"
+            src_path = f"src/{setup_service}.sh"
+            charm_bin = "/charm/bin"
+            dst_path = f"{charm_bin}/{setup_service}.sh"
+            with open(src_path, "r", encoding="utf-8") as f:
+                container.push(dst_path, f, permissions=0o755)
+
+            container.start(self.container_name)
+
+            self.unit.status = ActiveStatus("WordPress service is live!")
+            self.ingress.update_config(self.ingress_config)
+
+    def on_database_config_changed(self, event):
+        """Handle when the user supplies database details via charm config.
+        """
+        if self.state.has_db_relation is False:
+            db_config = {k: v or None for (k, v) in self.model.config.items() if k.startswith("db_")}
+            if any(db_config.values()) is True:  # User has supplied db config.
+                current_db_data = {self.state.db_host, self.state.db_name, self.state.db_user, self.state.db_password}
+                new_db_data = {db_config.values()}
+                db_differences = current_db_data.difference(new_db_data)
+                if db_differences:
+                    self.on.wordpress_static_database_changed.emit()
 
     def on_db_relation_created(self, event):
         """Handle the db-relation-created hook.
@@ -162,12 +419,13 @@ class WordpressCharm(CharmBase):
         credentials being specified in the charm configuration
         to being provided by the relation.
         """
-        self.state.has_db_relation = True
+
         self.state.db_host = None
         self.state.db_name = None
         self.state.db_user = None
         self.state.db_password = None
-        self.config_changed()
+        self.state.has_db_relation = True
+        self.on.config_changed.emit()
 
     def on_db_relation_broken(self, event):
         """Handle the db-relation-broken hook.
@@ -176,182 +434,101 @@ class WordpressCharm(CharmBase):
         credentials being provided by the relation to being
         specified in the charm configuration.
         """
+        self.state.db_host = None
+        self.state.db_name = None
+        self.state.db_user = None
+        self.state.db_password = None
         self.state.has_db_relation = False
-        self.config_changed()
+        self.on.config_changed.emit()
 
     def on_database_changed(self, event):
-        """Handle the MySQL endpoint database_changed event.
-
-        The MySQLClient (self.db) emits this event whenever the
-        database credentials have changed, which includes when
-        they disappear as part of relation tear down.
+        """Handle the MySQL configuration changed event.
+
+        The MySQLClient (self.db) and WordpressStaticDatabaseChanged
+        (self.on.wordpress_static_database_changed ) emits this event whenever
+        the database credentials have changed, this also includes when they
+        disappear as part of relation tear down. In addition to handling the
+        MySQLClient relation, this method handles the case where db
+        configuration is supplied by the user via model config. See
+        WordpressStaticDatabaseChanged for details.
         """
+        # TODO: we could potentially remove setting database config from state
+        # entirely and just rely on leader_data.
         self.state.db_host = event.host
         self.state.db_name = event.database
         self.state.db_user = event.user
         self.state.db_password = event.password
-        self.config_changed()
-
-    def config_changed(self):
-        """Handle configuration changes.
 
-        Configuration changes are caused by both config-changed
-        and the various relation hooks.
-        """
-        if not self.state.has_db_relation:
-            self.state.db_host = self.model.config["db_host"] or None
-            self.state.db_name = self.model.config["db_name"] or None
-            self.state.db_user = self.model.config["db_user"] or None
-            self.state.db_password = self.model.config["db_password"] or None
+        if self.unit.is_leader():
+            self.leader_data["db_host"] = event.host
+            self.leader_data["db_name"] = event.database
+            self.leader_data["db_user"] = event.user
+            self.leader_data["db_password"] = event.password
 
-        is_valid = self.is_valid_config()
-        if not is_valid:
-            return
+        self.state.has_db_relation = True
+        self.state.install_state.add("db")
+        self.on.config_changed.emit()
 
-        self.configure_pod()
-        if not self.state.initialised:
-            self.on.wordpress_initialise.emit()
-
-    def configure_pod(self):
-        # Only the leader can set_spec().
-        if self.model.unit.is_leader():
-            resources = self.make_pod_resources()
-            spec = self.make_pod_spec()
-            spec.update(resources)
-
-            msg = "Configuring pod"
-            logger.info(msg)
-            self.model.unit.status = MaintenanceStatus(msg)
-            self.model.pod.set_spec(spec)
-
-            if self.state.initialised:
-                msg = "Pod configured"
-                logger.info(msg)
-                self.model.unit.status = ActiveStatus(msg)
-            else:
-                msg = "Pod configured, but WordPress configuration pending"
-                logger.info(msg)
-                self.model.unit.status = MaintenanceStatus(msg)
-        else:
-            logger.info("Spec changes ignored by non-leader")
-
-    def make_pod_resources(self):
-        resources = {
-            "kubernetesResources": {
-                "ingressResources": [
-                    {
-                        "annotations": {
-                            "nginx.ingress.kubernetes.io/proxy-body-size": "10m",
-                            "nginx.ingress.kubernetes.io/proxy-send-timeout": "300s",
-                        },
-                        "name": self.app.name + "-ingress",
-                        "spec": {
-                            "rules": [
-                                {
-                                    "host": self.model.config["blog_hostname"],
-                                    "http": {
-                                        "paths": [
-                                            {"path": "/", "backend": {"serviceName": self.app.name, "servicePort": 80}}
-                                        ]
-                                    },
-                                }
-                            ],
-                        },
-                    }
-                ]
-            },
-        }
+    def on_ingress_relation_broken(self, event):
+        """Handle the ingress-relation-broken hook.
+        """
+        self.ingress.update_config({})
+        self.state.has_ingress_relation = False
+        self.state.install_state.discard("ingress")
+        self.on.config_changed.emit()
+
+    def on_ingress_relation_changed(self, event):
+        """Store the current ingress IP address on relation changed."""
+        self.state.has_ingress_relation = True
+        self.state.install_state.add("ingress")
+        self.on.config_changed.emit()
+
+    def on_leader_elected(self, event):
+        """Setup common workload state.
+
+        This includes:
+          - database config.
+          - wordpress secrets.
+        """
+        if self.unit.is_leader() is True:
+            if not all(self._wordpress_secrets.values()):
+                self._generate_wordpress_secrets()
+            self.state.install_state.add("leader")
 
-        if self.model.config["additional_hostnames"]:
-            additional_hostnames = juju_setting_to_list(self.model.config["additional_hostnames"])
-            rules = resources["kubernetesResources"]["ingressResources"][0]["spec"]["rules"]
-            for hostname in additional_hostnames:
-                rule = {
-                    "host": hostname,
-                    "http": {
-                        "paths": [
-                            {"path": "/", "backend": {"serviceName": self.app.name, "servicePort": 80}}
-                        ]
-                    },
-                }
-                rules.append(rule)
-
-        ingress = resources["kubernetesResources"]["ingressResources"][0]
-        if self.model.config["tls_secret_name"]:
-            ingress["spec"]["tls"] = [
-                {
-                    "hosts": [self.model.config["blog_hostname"]],
-                    "secretName": self.model.config["tls_secret_name"],
-                }
-            ]
         else:
-            ingress["annotations"]['nginx.ingress.kubernetes.io/ssl-redirect'] = 'false'
+            if not all(self._db_config.values()) or not all(self._wordpress_secrets.values()):
+                logger.info("Non leader has unexpected db_config or wp secrets...")
 
-        out = io.StringIO()
-        pprint(resources, out)
-        logger.info("This is the Kubernetes Pod resources <<EOM\n{}\nEOM".format(out.getvalue()))
+        self.on.config_changed.emit()
 
-        return resources
+    def is_valid_config(self):
+        """Validate that the current configuration is valid.
 
-    def make_pod_spec(self):
+        Before the workload can start we must ensure all prerequisite state
+        is present, the config_changed handler uses the return value here.
+        to guard the WordPress service from prematurely starting.
+        """
+        # TODO: This method is starting to look a bit wild and should definitely
+        # be refactored.
+        is_valid = True
         config = dict(self.model.config)
-        config["db_host"] = self.state.db_host
-        config["db_name"] = self.state.db_name
-        config["db_user"] = self.state.db_user
-        config["db_password"] = self.state.db_password
-
-        full_pod_config = generate_pod_config(config, secured=False)
-        full_pod_config.update(self._get_wordpress_secrets())
-        secure_pod_config = generate_pod_config(config, secured=True)
-
-        ports = [
-            {"name": name, "containerPort": int(port), "protocol": "TCP"}
-            for name, port in [addr.split(":", 1) for addr in config["ports"].split()]
-        ]
-
-        spec = {
-            "version": 2,
-            "containers": [
-                {
-                    "name": self.app.name,
-                    "imageDetails": {"imagePath": config["image"]},
-                    "ports": ports,
-                    "config": secure_pod_config,
-                    "kubernetes": {"readinessProbe": {"exec": {"command": ["/srv/wordpress-helpers/ready.sh"]}}},
-                }
-            ],
-        }
-
-        out = io.StringIO()
-        pprint(spec, out)
-        logger.info("This is the Kubernetes Pod spec config (sans secrets) <<EOM\n{}\nEOM".format(out.getvalue()))
-
-        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"]
-
-        secure_pod_config.update(full_pod_config)
 
-        return spec
-
-    def is_valid_config(self):
-        is_valid = True
-        config = self.model.config
+        if self.state.installed_successfully is False:
+            logger.info("WordPress has not been setup yet...")
+            is_valid = False
 
-        if not config["initial_settings"]:
+        if not config.get("initial_settings"):
             logger.info("No initial_setting provided. Skipping first install.")
             self.model.unit.status = BlockedStatus("Missing initial_settings")
             is_valid = False
 
         want = ["image"]
 
-        if self.state.has_db_relation:
-            if not (self.state.db_host and self.state.db_name and self.state.db_user and self.state.db_password):
-                logger.info("MySQL relation has not yet provided database credentials.")
-                self.model.unit.status = WaitingStatus("Waiting for MySQL relation to become available")
-                is_valid = False
-        else:
+        db_state = self._db_config.values()
+        if not all(db_state):
             want.extend(["db_host", "db_name", "db_user", "db_password"])
+            logger.info("MySQL relation has not yet provided database credentials.")
+            is_valid = False
 
         missing = [k for k in want if config[k].rstrip() == ""]
         if missing:
@@ -369,21 +546,14 @@ class WordpressCharm(CharmBase):
                 logger.info(message)
                 self.model.unit.status = BlockedStatus(message)
                 is_valid = False
-
         return is_valid
 
-    def get_service_ip(self):
-        try:
-            return str(self.model.get_binding("website").network.ingress_addresses[0])
-        except Exception:
-            logger.info("We don't have any ingress addresses yet")
+    def _generate_wordpress_secrets(self):
+        """Generate WordPress auth keys and salts.
 
-    def _get_wordpress_secrets(self):
-        """Get secrets, creating them if they don't exist.
-
-        These are part of the pod spec, and so this function can only be run
-        on the leader. We can therefore safely generate them if they don't
-        already exist."""
+        Secret data should be in sync for each container workload
+        so persist the state in leader_data.
+        """
         wp_secrets = {}
         for secret in WORDPRESS_SECRETS:
             # `self.leader_data` itself will never return a KeyError, but
@@ -395,13 +565,24 @@ class WordpressCharm(CharmBase):
             wp_secrets[secret] = self.leader_data[secret]
         return wp_secrets
 
+    @property
+    def _wordpress_secrets(self):
+        """WordPress auth keys and salts.
+        """
+        wp_secrets = {}
+        for secret in WORDPRESS_SECRETS:
+            wp_secrets[secret] = self.leader_data.get(secret)
+        return wp_secrets
+
     def is_service_up(self):
         """Check to see if the HTTP service is up"""
-        service_ip = self.get_service_ip()
+        service_ip = self.service_ip_address
         if service_ip:
             return self.wordpress.is_vhost_ready(service_ip)
         return False
 
+    # TODO: If a non leader unit invokes this method and the data
+    # doesn't exist, it will raise an exception. It needs to be refactored.
     def _get_initial_password(self):
         """Get the initial password.
 
diff --git a/src/wordpress.py b/src/wordpress.py
index 9ac5336..43270a1 100644
--- a/src/wordpress.py
+++ b/src/wordpress.py
@@ -2,6 +2,7 @@
 
 import logging
 import re
+import requests
 import secrets
 import string
 import subprocess
@@ -23,28 +24,17 @@ WORDPRESS_SECRETS = [
 ]
 
 
-def import_requests():
-    # Workaround until https://github.com/canonical/operator/issues/156 is fixed.
-    try:
-        import requests
-    except ImportError:
-        subprocess.check_call(['apt-get', 'update'])
-        subprocess.check_call(['apt-get', '-y', 'install', 'python3-requests'])
-        import requests
-
-    return requests
-
-
-def password_generator(length=24):
-    alphabet = string.ascii_letters + string.digits
-    return ''.join(secrets.choice(alphabet) for i in range(length))
+def password_generator(length=24, characters=None):
+    if characters is None:
+        characters = string.ascii_letters + string.digits
+    return ''.join(secrets.choice(characters) for i in range(length))
 
 
 class Wordpress:
     def __init__(self, model_config):
         self.model_config = model_config
 
-    def first_install(self, service_ip, admin_password):
+    def first_install(self, admin_password, service_ip="127.0.0.1"):
         """Perform initial configuration of wordpress if needed."""
         config = self.model_config
         logger.info("Starting wordpress initial configuration")
@@ -75,7 +65,6 @@ class Wordpress:
         return True
 
     def call_wordpress(self, service_ip, uri, redirects=True, payload={}, _depth=1):
-        requests = import_requests()
 
         max_depth = 10
         if _depth > max_depth:
@@ -97,8 +86,6 @@ class Wordpress:
 
     def wordpress_configured(self, service_ip):
         """Check whether first install has been completed."""
-        requests = import_requests()
-
         # We have code on disk, check if configured
         try:
             r = self.call_wordpress(service_ip, "/", redirects=False)
@@ -111,14 +98,13 @@ class Wordpress:
             logger.info("MySQL database setup failed, we likely have no wp-config.php")
             return False
         elif r.status_code in (500, 403, 404):
-            raise RuntimeError("unexpected status_code returned from Wordpress")
+            logger.info("Unexpected status_code returned from Wordpress")
+            return False
 
         return True
 
     def is_vhost_ready(self, service_ip):
         """Check whether wordpress is available using http."""
-        requests = import_requests()
-
         # Check if we have WP code deployed at all
         try:
             r = self.call_wordpress(service_ip, "/wp-login.php", redirects=False)
diff --git a/src/wordpressInit.sh b/src/wordpressInit.sh
new file mode 100755
index 0000000..e59438f
--- /dev/null
+++ b/src/wordpressInit.sh
@@ -0,0 +1,50 @@
+#!/usr/bin/env bash
+set -x
+
+if [[ -f "/var/www/html/wp-info.php.bk" ]]; then
+	mv -v /var/www/html/wp-info.php.bk /var/www/html/wp-info.php
+else
+	cp -v /var/www/html/wp-info.php{,.bk}
+fi
+
+sed -i -e "s/%%%WORDPRESS_DB_HOST%%%/$WORDPRESS_DB_HOST/" /var/www/html/wp-info.php
+sed -i -e "s/%%%WORDPRESS_DB_NAME%%%/$WORDPRESS_DB_NAME/" /var/www/html/wp-info.php
+sed -i -e "s/%%%WORDPRESS_DB_USER%%%/$WORDPRESS_DB_USER/" /var/www/html/wp-info.php
+sed -i -e "s/%%%WORDPRESS_DB_PASSWORD%%%/$WORDPRESS_DB_PASSWORD/" /var/www/html/wp-info.php
+
+for key in AUTH_KEY SECURE_AUTH_KEY LOGGED_IN_KEY NONCE_KEY AUTH_SALT SECURE_AUTH_SALT LOGGED_IN_SALT NONCE_SALT; do
+	sed -i -e "s/%%%${key}%%%/$(printenv ${key})/" /var/www/html/wp-info.php
+done
+
+# If we have passed in SWIFT_URL, then append swift proxy config.
+[ -z "${SWIFT_URL-}" ] || a2enconf docker-php-swift-proxy
+
+function wp_admin() {
+	/usr/local/bin/wp \
+		--path="/var/www/html" \
+		--allow-root "$@" 2> >(grep -v "PHP Notice")
+}
+
+if [ ${WORDPRESS_INSTALLED:false} ]; then
+	if ! wp_admin core is-installed; then
+		wp_admin core \
+			install \
+			--url="${WORDPRESS_BLOG_HOSTNAME}" \
+			--title="The ${WORDPRESS_BLOG_HOSTNAME} Blog" \
+			--admin_user="${WORDPRESS_ADMIN_USER}" \
+			--admin_password="$(</admin_password)" \
+			--admin_email="${WORDPRESS_ADMIN_EMAIL}"
+		wp_admin core is-installed
+	else
+		echo "WordPress already installed, updating admin password instead"
+		wp_admin \
+			user update "${WORDPRESS_ADMIN_USER}" --user_pass="$(</admin_password)"
+		exec sleep infinity
+	fi
+fi
+
+:> /admin_password && rm -v $_
+
+# Match against either php 7.2 (bionic) or 7.4 (focal).
+sed -i 's/max_execution_time = 30/max_execution_time = 300/' /etc/php/7.[24]/apache2/php.ini
+sed -i 's/upload_max_filesize = 2M/upload_max_filesize = 10M/' /etc/php/7.[24]/apache2/php.ini

Follow ups