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

