wordpress-charmers team mailing list archive
-
wordpress-charmers team
-
Mailing list archive
-
Message #00743
[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