wordpress-charmers team mailing list archive
-
wordpress-charmers team
-
Mailing list archive
-
Message #00697
[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:
Wordpress Charmers (wordpress-charmers)
For more details, see:
https://code.launchpad.net/~tcuthbert/charm-k8s-wordpress/+git/charm-k8s-wordpress-1/+merge/403361
--
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/README.md b/README.md
index d6e4fa4..fd3d807 100644
--- a/README.md
+++ b/README.md
@@ -19,11 +19,14 @@ 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 \
+ --config blog_hostname="myblog.example.com"
juju relate wordpress-k8s mariadb:mysql
+ juju relate wordpress-k8s ingress:website
-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 +34,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..2521e55 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."
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..738605f 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,19 +34,52 @@ 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
+ WordpressCharmEvents registers the custom WordpressFirstInstallEvent
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):
@@ -99,61 +87,312 @@ class WordpressCharm(CharmBase):
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_wordpress_pebble_ready)
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"]
+ 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_broken, self.on_ingress_relation_broken)
+ self.framework.observe(self.on.ingress_relation_changed, 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_setup_workload(self):
+ """Returns the initial WordPress pebble workload configuration."""
+ return {
+ "summary": "WordPress layer",
+ "description": "pebble config layer for WordPress",
+ "services": {
+ "wordpress-ready": {
+ "override": "replace",
+ "summary": "WordPress setup",
+ "command": "bash -c '/srv/wordpress-helpers/plugin_handler.py && stat /srv/wordpress-helpers/.ready && sleep infinity'",
+ "startup": "false",
+ "requires": [self._container_name],
+ "after": [self._container_name],
+ "environment": self._env_config,
+ },
+ self._container_name: {
+ "override": "replace",
+ "summary": "WordPress setup",
+ "command": "bash -c '/charm/bin/wordpressInit.sh >> /wordpressInit.log 2>&1'",
+ "startup": "false",
+ "requires": [],
+ "before": ["wordpress-ready"],
+ "environment": self._env_config,
+ },
+ },
+ }
+
+ @property
+ def wordpress_workload(self):
+ """Returns the WordPress pebble workload configuration."""
+ return {
+ "summary": "WordPress layer",
+ "description": "pebble config layer for WordPress",
+ "services": {
+ self._container_name: {
+ "override": "replace",
+ "summary": "WordPress production workload",
+ "command": "apache2ctl -D FOREGROUND",
+ "startup": "enabled",
+ "requires": [],
+ "before": [],
+ "environment": self._env_config,
+ },
+ "wordpress-ready": {
+ "override": "replace",
+ "summary": "Remove the WordPress initialiser",
+ "command": "/bin/true",
+ "startup": "false",
+ "requires": [],
+ "after": [],
+ "environment": {},
+ },
+ },
+ }
+
+ @property
+ def ingress_config(self):
+ ingress_config = {
+ "service-hostname": self.config["blog_hostname"],
+ "service-name": self._container_name,
+ "service-port": self.service_port,
+ "tls-secret-name": self.config["tls_secret_name"],
+ }
+ # TODO: Raise bug to handle additional hostnames for ingress rule.
+ return ingress_config
+
+ @property
+ def _db_config(self):
+ """Kubernetes Pod environment variables."""
+ 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,
+ }
+
+ @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.update(self._get_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_pebble_ready(self, event):
+ """Entry point into the WordPress Charm's lifecycle.
+
+ Each new workload must signal to the ingress controller
+ that it exists and should be updated with the additional
+ backend.
+ """
+ self.ingress.update_config(self.ingress_config)
+ self.on.config_changed.emit()
+
+ 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 the `TODO: name of the action`
+ 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 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 = True
+
+ 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: install ready state is {self.state.install_state}, first_time_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.wordpress.wordpress_configured(self.service_ip_address): return
+ 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):
+ container = self.unit.get_container(self._container_name)
+ logger.info("Adding WordPress setup layer to container...")
+ container.add_layer(self._container_name, self.wordpress_setup_workload, combine=True)
+ logger.info("Beginning WordPress setup process...")
+ pebble = container.pebble
+ wait_on = pebble.start_services(["wordpress-ready", self._container_name])
+ pebble.wait_change(wait_on)
+
+ self.state.installed_successfully = self.wordpress.first_install(self._get_initial_password())
+ if self.state.installed_successfully is False:
+ logger.error("Failed to setup WordPress with the HTTP installer...")
+
+ # TODO: We could defer the install and try again.
+ return
+
+ logger.info("Stopping WordPress setup layer...")
+ container = self.unit.get_container(self._container_name)
+ pebble = container.pebble
+ wait_on = pebble.stop_services([self._container_name, "wordpress-ready"])
+ pebble.wait_change(wait_on)
+
+ self.unit.status = MaintenanceStatus("WordPress Initialised")
+ logger.info("Replacing WordPress setup layer with production workload...")
+ container.add_layer(self._container_name, self.wordpress_workload, combine=True)
+
+ 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:
+ event.defer()
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")
+ service = container.get_service(self._container_name)
+ if service.is_running():
+ container.stop(self._container_name)
+
+ if not container.get_service(self._container_name).is_running():
+ logger.info("WordPress is not running, starting it now...")
+ container.autostart()
+
+ self.ingress.update_config(self.ingress_config)
+
+ self.unit.status = ActiveStatus()
+
+ def on_database_config_changed(self, event):
+ 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 +401,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 = False
+ self.on.config_changed.emit()
def on_db_relation_broken(self, event):
"""Handle the db-relation-broken hook.
@@ -176,176 +416,102 @@ 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.
+ """Handle the MySQL configuration 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.
+ 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.
"""
- 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()
+ self.leader_data["db_host"] = self.state.db_host = event.host
+ self.leader_data["db_name"] = self.state.db_name = event.database
+ self.leader_data["db_user"] = self.state.db_user = event.user
+ self.leader_data["db_password"] = self.state.db_password = event.password
+ self.state.has_db_relation = True
+ self.state.install_state.add("db")
+ self.on.config_changed.emit()
- def config_changed(self):
- """Handle configuration changes.
+ def on_ingress_relation_broken(self, event):
+ """Handle the ingress-relation-broken hook.
- Configuration changes are caused by both config-changed
- and the various relation hooks.
+ ingress service IP is used else where in the charm
+ to define current state. Ensure the state is wiped when a relation
+ is removed.
"""
- 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
-
- is_valid = self.is_valid_config()
- if not is_valid:
- return
+ self.state.has_ingress_relation = False
+ self.on.config_changed.emit()
+
+ def on_ingress_relation_created(self, event):
+ """Signal the configuration change to the ingress."""
+ self.ingress.update_config(self.ingress_config)
+ self.state.has_ingress_relation = True
+ self.on.config_changed.emit()
+
+ def on_ingress_relation_changed(self, event):
+ """Store the current ingress IP address on relation changed."""
+ self.ingress.update_config(self.ingress_config)
+ 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.
+
+ The charm has some requirements that do not exist in the current
+ Docker image, so push those files into the workload container.
+ """
+ container = self.unit.get_container(self._container_name)
+ setup_service = "wordpressInit"
+ src_path = f"src/{setup_service}.sh"
+ charm_bin = "/charm/bin"
+ dst_path = f"{charm_bin}/{setup_service}.sh"
- 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}}
- ]
- },
- }
- ],
- },
- }
- ]
- },
- }
+ if self.unit.is_leader() is True:
+ with open(src_path, "r", encoding="utf-8") as f:
+ container.push(dst_path, f, permissions=0o755)
+ 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'
+ with open("src/wp-info.php", "r", encoding="utf-8") as f:
+ container.push("/var/www/html/wp-info.php", f, permissions=0o755)
- 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 make_pod_spec(self):
+ def is_valid_config(self):
+ 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
+ if self.state.installed_successfully is False:
+ logger.info("WordPress has not been setup yet...")
+ is_valid = False
- def is_valid_config(self):
- is_valid = True
- config = self.model.config
+ if not self.unit.get_container(self._container_name).get_plan().services:
+ logger.info("No pebble plan seen 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 self.state.has_ingress_relation is False:
+ message = "Ingress relation missing."
+ logger.info(message)
+ self.model.unit.status = WaitingStatus(message)
+ is_valid = False
+
+ if self.state.has_db_relation is True:
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")
@@ -372,12 +538,6 @@ class WordpressCharm(CharmBase):
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 _get_wordpress_secrets(self):
"""Get secrets, creating them if they don't exist.
@@ -397,7 +557,7 @@ class WordpressCharm(CharmBase):
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
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..7b4d7d1
--- /dev/null
+++ b/src/wordpressInit.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+set -eux
+
+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
+
+
+# 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
+
+apache2ctl -D FOREGROUND -E /apache-error.log -e debug >> /apache-sout.log 2>&1
Follow ups
-
[Merge] ~tcuthbert/charm-k8s-wordpress:sidecar into charm-k8s-wordpress:master
From: Thomas Cuthbert, 2021-06-14
-
[Merge] ~tcuthbert/charm-k8s-wordpress:sidecar into charm-k8s-wordpress:master
From: Tom Haddon, 2021-05-31
-
Re: [Merge] ~tcuthbert/charm-k8s-wordpress:sidecar into charm-k8s-wordpress:master
From: 🤖 prod-jenkaas-is, 2021-05-30
-
Re: [Merge] ~tcuthbert/charm-k8s-wordpress:sidecar into charm-k8s-wordpress:master
From: 🤖 prod-jenkaas-is, 2021-05-30
-
Re: [Merge] ~tcuthbert/charm-k8s-wordpress:sidecar into charm-k8s-wordpress:master
From: 🤖 prod-jenkaas-is, 2021-05-28
-
Re: [Merge] ~tcuthbert/charm-k8s-wordpress:sidecar into charm-k8s-wordpress:master
From: 🤖 prod-jenkaas-is, 2021-05-28
-
Re: [Merge] ~tcuthbert/charm-k8s-wordpress:sidecar into charm-k8s-wordpress:master
From: Canonical IS Mergebot, 2021-05-27
-
Re: [Merge] ~tcuthbert/charm-k8s-wordpress:sidecar into charm-k8s-wordpress:master
From: 🤖 prod-jenkaas-is, 2021-05-27
-
Re: [Merge] ~tcuthbert/charm-k8s-wordpress:sidecar into charm-k8s-wordpress:master
From: Canonical IS Mergebot, 2021-05-27
-
[Merge] ~tcuthbert/charm-k8s-wordpress:sidecar into charm-k8s-wordpress:master
From: Thomas Cuthbert, 2021-05-27
-
Re: [Merge] ~tcuthbert/charm-k8s-wordpress:sidecar into charm-k8s-wordpress:master
From: 🤖 prod-jenkaas-is, 2021-05-27