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: [email protected] 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 <[email protected]> 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
-- Mailing list: https://launchpad.net/~wordpress-charmers Post to : [email protected] Unsubscribe : https://launchpad.net/~wordpress-charmers More help : https://help.launchpad.net/ListHelp

