<filename>src/charm.py
#!/usr/bin/env python3
# Copyright 2021 Ubuntu
# See LICENSE file for licensing details.
#
# Learn more at: https://juju.is/docs/sdk

"""Charm the service.

Refer to the following post for a quick-start guide that will help you
develop a new k8s charm using the Operator Framework:

    https://discourse.charmhub.io/t/4208
"""

import logging
from subprocess import check_call, CalledProcessError
import os
import secrets
import urllib.request
import shutil
import imghdr
import tempfile

from jinja2 import Environment, FileSystemLoader

from ops.charm import (
    CharmBase, ConfigChangedEvent, InstallEvent, RelationChangedEvent, RelationCreatedEvent,
    RelationDepartedEvent, RelationJoinedEvent, StartEvent,
)
from ops.framework import StoredState
from ops.main import main
from ops.model import ActiveStatus, BlockedStatus, MaintenanceStatus, WaitingStatus

logger = logging.getLogger(__name__)

# Templates go in the "src/templates".  Get them with
# `templates.get_template(filename)`.
templates = Environment(loader=FileSystemLoader("src/templates"))

# Where to find the mediawiki maintenance php scripts
MEDIAWIKI_MAINTENANCE_ROOT = "/usr/share/mediawiki/maintenance"

# Where to put the mediawiki config files
MEDIAWIKI_CONFIG_DIR = "/etc/mediawiki"

# Root of the mediawiki installation
MEDIAWIKI_ROOT_DIR = "/var/lib/mediawiki"

# Path of the config.php file containing the configuration generated from the
# charm config.
CONFIG_PHP_PATH = f"{MEDIAWIKI_CONFIG_DIR}/config.php"

# Path of the memcached.php file containing the configuration generated from a
# memcached relation.
MEMCACHED_PHP_PATH = f"{MEDIAWIKI_CONFIG_DIR}/memcached.php"

# Path of the db.php file containing configuration generated from a db relation
DB_PHP_PATH = f"{MEDIAWIKI_CONFIG_DIR}/db.php"

# Path of the main mediawiki configuration, which is generated by the
# install.php maintenance script once the database connection details are known.
# Include directives are added to also load the scripts above.
LOCALSETTINGS_PHP_PATH = f"{MEDIAWIKI_CONFIG_DIR}/LocalSettings.php"


class MediawikiCharm(CharmBase):
    """Charm the service."""

    _stored = StoredState()

    def __init__(self, *args):
        super().__init__(*args)
        self.framework.observe(self.on.install, self._on_install)
        self.framework.observe(self.on.start, self._on_start)
        self.framework.observe(self.on.config_changed, self._on_config_changed)

        self.framework.observe(self.on.db_relation_created, self._on_db_relation_created)
        self.framework.observe(self.on.db_relation_joined, self._on_db_relation_joined)
        self.framework.observe(self.on.db_relation_changed, self._on_db_relation_changed)
        self.framework.observe(self.on.db_relation_departed, self._on_db_relation_departed)

        # self.framework.observe(self.on.replicas_relation_joined,
        #                        self._on_replicas_relation_changed)
        self.framework.observe(self.on.replicas_relation_changed,
                               self._on_replicas_relation_changed)

        self.framework.observe(self.on.cache_relation_changed, self._on_cache_relation_changed)
        self.framework.observe(self.on.cache_relation_departed, self._on_cache_relation_departed)

        self.framework.observe(self.on.website_relation_joined, self._on_website_relation_joined)

    # Lifecycle hooks

    def _on_install(self, event: InstallEvent) -> None:
        self.unit.status = MaintenanceStatus("Installing mediawiki packages")
        try:
            install_mediawiki_packages()
            self.unit.status = WaitingStatus("Mediawiki packages installed")
        except CalledProcessError as e:
            logger.error("Package install failed with error: %s", e)
            self.unit.status = BlockedStatus("Failed to install packages")

    def _on_start(self, event: StartEvent) -> None:
        check_call(["open-port", "80"])
        self.unit.status = self._get_db_relation_status()

    def _on_config_changed(self, event: ConfigChangedEvent):
        self.unit.status = MaintenanceStatus("Updating Mediawiki configuration")
        try:
            configure_mediawiki(self.config)
            if self.unit.is_leader() and self.config["admins"]:
                setup_admins(self.config["admins"])
            reload_apache()
            self.unit.status = self._get_db_relation_status()
        except Exception as e:
            logger.error("Error configuring mediawiki: %s", e)
            self.unit.status = BlockedStatus("Failed to configure mediawiki")

    # Leader units do operations that affect the database, so only they react to
    # db_relation_changed events.  When they have created the database tables
    # successfully, they trigger an event on the peer relation "replicas" by
    # setting the "connected" key to True, thus indicating to the other units
    # that they can also install mediawiki.

    def _on_db_relation_created(self, event: RelationCreatedEvent) -> None:
        self.unit.status = WaitingStatus("Waiting to join db relation")

    def _on_db_relation_joined(self, event: RelationJoinedEvent) -> None:
        self.unit.status = WaitingStatus("Waiting for connection data from db relation")

    def _on_db_relation_changed(self, event: RelationChangedEvent) -> None:
        db = event.relation.data[event.unit]
        configure_db(db)
        if self.unit.is_leader():
            self._install_mediawiki(db)

    def _on_db_relation_departed(self, event: RelationDepartedEvent) -> None:
        self._set_db_connection_status(False)
        self.unit.status = BlockedStatus("Missing db relation")
        try:
            uninstall_mediawiki()
            reload_apache()
        except Exception as e:
            logger.error("Uninstalling failed with error %s", e)

    # Only non-leader units react to replicas_relation_changed.  It is a signal
    # from the leader unit that the mediawiki tables have been installed so they
    # can safely run the installation script without a risk of race.

    def _on_replicas_relation_changed(self, event: RelationChangedEvent) -> None:
        if self.unit.is_leader():
            return
        conf = event.relation.data[event.app]
        if conf.get("status") != "connected":
            # There should have been a db_relation_departed event that
            # triggered the uninstallation, so there is nothing to do in this
            # case.
            return
        db = self._get_db()
        if not db:
            logger.debug("No db connection data found even though the database is connected")
            return
        self._install_mediawiki(db)

    # All units configure themseleves to use memcached, making use off all
    # memcached units.

    def _on_cache_relation_changed(self, event: RelationChangedEvent) -> None:
        try:
            servers = []
            for unit in event.relation.units:
                unit_data = event.relation.data[unit]
                if "private-address" in unit_data and "port" in unit_data:
                    servers.append({
                        "address": unit_data["private-address"],
                        "port": unit_data["port"],
                    })
            configure_memcached(servers)
            reload_apache()
        except Exception as e:
            logger.error("Failed to configure memcached: %s", e)
            self.unit.status = BlockedStatus("Memcached configuration failed")

    def _on_cache_relation_departed(self, event: RelationDepartedEvent) -> None:
        try:
            configure_memcached(None)
            reload_apache()
        except Exception as e:
            logger.error("Unable to remove memcached configuration: %s", e)
            self.unit.status = BlockedStatus("Memcached removal failed")

    # The website relation implements providing the http interface, so it must
    # expose a port and a hostname to consumers of this interface.

    def _on_website_relation_joined(self, event: RelationJoinedEvent) -> None:
        unit_data = event.relation.data[self.unit]
        ingress_address = self.model.get_binding('website').network.ingress_address
        unit_data.update({
            "port": "80",
            "hostname": str(ingress_address),
        })

    # Methods that help event hooks

    def _get_db(self):
        '''
        Get db connection data from the relation if it exists
        '''
        db_rel = self.model.get_relation("db")
        if db_rel is None:
            return
        db_app_pfx = db_rel.app.name + "/"
        for u in db_rel.units:
            if u.name.startswith(db_app_pfx):
                db = db_rel.data[u]
                if db["slave"] == "False" and "database" in db:
                    return db

    def _get_db_relation_status(self):
        db_rel = self.model.get_relation("db")
        if db_rel is None:
            return BlockedStatus("Missing db relation")
        db = self._get_db()
        if db is None:
            return WaitingStatus("Waiting for connection data from db relation")
        if is_mediawiki_installed():
            return ActiveStatus()
        return WaitingStatus("Waiting to install Mediawiki")

    def _install_mediawiki(self, db):
        try:
            self.unit.status = MaintenanceStatus("Updating mediawiki db configuration")
            install_mediawiki(db)
            reload_apache()
            self._set_db_connection_status(True)
            self.unit.status = ActiveStatus()
        except Exception as e:
            logger.error("Mediawiki install failed with error: %s", e)
            self.unit.status = BlockedStatus("Failed to install mediawiki")

    def _uninstall_mediawiki(self):
        self._set_db_connection_status(False)
        self.unit.status = BlockedStatus("Missing db relation")
        try:
            uninstall_mediawiki()
        except Exception as e:
            logger.error("Uninstalling failed with error %s", e)

    def _set_db_connection_status(self, connected: bool) -> None:
        if not self.unit.is_leader():
            return
        app_data = self.model.get_relation("replicas").data[self.app]
        app_data.update({
            "status": "connected" if connected else "disconnected",
            "revision": str(int(app_data.get("revision", 0)) + 1),
        })

    def _get_db_connection_status(self) -> bool:
        app_data = self.model.get_relation("replicas").data[self.app]
        return app_data.get("status") == "connected"


#
# Helper functions
#


def install_mediawiki_packages():
    '''
    Set up the necessary packages for mediawiki to run
    '''
    # Install mediawiki (which pulls php as a dependency) and imagemagick which
    # allows mediawiki to perform image manipulation.
    check_call(["apt-get", "install", "-y", "mediawiki", "imagemagick"])

    # Apache2 is configured by default to serve from /var/www/html.  We replace
    # the DocumentRoot directive in the apache default configuration to point at
    # the mediawiki root.
    check_call([
        "sed", "-i",
        f"s|DocumentRoot .*|DocumentRoot {MEDIAWIKI_ROOT_DIR}|",
        "/etc/apache2/sites-available/000-default.conf",
    ])


def are_mediawiki_packages_installed():
    try:
        check_call([
            "grep", "-q",
            f"DocumentRoot {MEDIAWIKI_ROOT_DIR}",
            "/etc/apache2/sites-available/000-default.conf",
        ])
        return True
    except CalledProcessError:
        return False


def install_mediawiki(db):
    '''
    Create the wiki database tables and the basic LocalSettings.php file
    '''
    # Call the mediawiki install script that creates the database tables if
    # necessary, creates an admin user and generates a LocalSettings.php file.
    # The fact that it does all these things in one go is a challenge!

    if is_mediawiki_installed():
        configure_db(db)
        return

    with tempfile.TemporaryDirectory() as temp_dir:
        check_call([
            "php", f"{MEDIAWIKI_MAINTENANCE_ROOT}/install.php",
            "--dbserver", db["private-address"],
            "--dbname", db["database"],
            "--dbuser", db["user"],
            "--dbpass", db["password"],
            "--confpath", temp_dir,
            "--installdbuser", db["user"],
            "--installdbpass", db["password"],
            "--pass", secrets.token_urlsafe(32),
            "--scriptpath", "",
            "Charmed Wiki",
            "generic_charm_admin"
        ])

        # Include the config php files in LocalSettings.  When configuration
        # changes, only that file needs to be regenerated.
        with open(f"{temp_dir}/LocalSettings.php", "a") as f:
            f.write("\n")
            f.write(f"include('{CONFIG_PHP_PATH}');\n")
            f.write(f"include('{MEMCACHED_PHP_PATH}');\n")
            f.write(f"include('{DB_PHP_PATH}');\n")

        # Make sure the config php files exists, as LocalSettings
        # will include them.
        touch_config(CONFIG_PHP_PATH)
        touch_config(MEMCACHED_PHP_PATH)
        touch_config(DB_PHP_PATH)

        # Finally swap in the configuration
        os.rename(f"{temp_dir}/LocalSettings.php", LOCALSETTINGS_PHP_PATH)


def configure_db(db):
    '''
    Regenerate the db.php file with database connection data
    '''
    template = templates.get_template("db.php")
    db_php = template.render(db=db)
    write_config(DB_PHP_PATH, db_php)


def is_mediawiki_installed():
    return os.path.exists(LOCALSETTINGS_PHP_PATH)


def uninstall_mediawiki():
    '''
    Remove the LocalSettings.php file,  returning mediawiki to its uninstalled
    state.
    '''
    for f in LOCALSETTINGS_PHP_PATH, CONFIG_PHP_PATH, MEMCACHED_PHP_PATH, DB_PHP_PATH:
        try:
            os.remove(f)
        except FileNotFoundError:
            pass


def configure_mediawiki(conf):
    '''
    Overwrite the config.php file containing the mediawiki config that comes
    from the charm config.
    '''
    template = templates.get_template("config.php")
    config_php = template.render(
        wiki_name=conf["name"],
        language_code=conf["language"],
        skin=conf["skin"],
        server_address=conf["server_address"],
        logo_path=fetch_logo(conf["logo"]),
        debug_file="" if not conf["debug"] else os.getcwd() + "/debug.log",
    )
    write_config(CONFIG_PHP_PATH, config_php)


def fetch_logo(logo_url) -> str:
    '''
    Fetch the wiki logo from the given URL if necessary, returning the file path
    where it has been stored
    '''

    if not logo_url:
        return

    url_logo_path = "/images/wiki_logo"
    fs_logo_path = f"{MEDIAWIKI_ROOT_DIR}{url_logo_path}"
    logo_src_path = f"{MEDIAWIKI_CONFIG_DIR}/logo_url"

    # Check for an already downloaded this image and return early if that's the
    # case
    try:
        with open(logo_src_path) as f:
            previous_url = f.read()
    except FileNotFoundError:
        previous_url = ""
    if logo_url == previous_url:
        return url_logo_path

    # Fetch the image and store it
    with urllib.request.urlopen(logo_url) as response:
        content = response.read()
        ext = imghdr.what(response, content)
    if ext is None:
        raise ValueError("logo is not an image")
    with open(fs_logo_path, "wb") as f:
        f.write(content)

    # Remember we've done that
    with open(logo_src_path, "w") as f:
        f.write(logo_url)

    shutil.chown(fs_logo_path, user="www-data", group="www-data")
    return url_logo_path


def setup_admins(admins: str):
    '''
    Make sure the given admin users are setup.

    TODO: remove other admins?
    '''
    for username, pwd in parse_admins(admins):
        create_or_update_admin(username, pwd)


def parse_admins(admins: str):
    '''
    Parse a string of the form "<name1>:<pwd1> <name2>:<pwd2> ..." into
    a list of pairs [["<name1>", "<pwd1>"], ["<name2>", "<pwd2>"], ...]

    Raise a ValueError if the string is not of the correct format.
    '''
    name_pwd_pairs = []
    for item in admins.split():
        if ":" not in item:
            raise ValueError("admin should be in format user:pass")
        name_pwd_pairs.append(item.split(":", 1))
    return name_pwd_pairs


def create_or_update_admin(username: str, pwd: str):
    '''
    Make sure the specified user exists, has the given password and is an
    "admin", i.e. belongs to the sysop and bureaucrat groups.
    '''
    check_call([
        "php", f"{MEDIAWIKI_MAINTENANCE_ROOT}/createAndPromote.php",
        "--conf", LOCALSETTINGS_PHP_PATH,
        "--force",
        "--sysop", "--bureaucrat",
        username, pwd,
    ])


def configure_memcached(servers):
    '''
    Update the memcached configuration with the given servers.  If servers is
    falsy, instead remove memcached configuration.
    '''
    if not servers:
        memcached_php = ""
    else:
        template = templates.get_template("memcached.php")
        memcached_php = template.render(servers=servers)
    write_config(MEMCACHED_PHP_PATH, memcached_php)


def reload_apache():
    check_call(["service", "apache2", "reload"])


def touch_config(path):
    '''
    Ensure a file exists (potentially creating an empty file) with suitable
    permissions for being read as config for mediawiki.
    '''
    with open(path, "a"):
        pass
    os.chmod(path, 0o644)


def write_config(path: str, content: str):
    '''
    Write a file to disk with suitable permissions for being read as config for
    mediawiki.
    '''
    with open(path, "w") as f:
        f.write(content)
    os.chmod(path, 0o644)


if __name__ == "__main__":
    main(MediawikiCharm)

<filename>src/charm.py
#!/usr/bin/env python3
# Copyright 2021 Ubuntu
# See LICENSE file for licensing details.
#
# Learn more at: https://juju.is/docs/sdk

"""Charm the service.

Refer to the following post for a quick-start guide that will help you
develop a new k8s charm using the Operator Framework:

    https://discourse.charmhub.io/t/4208
"""

import logging
from subprocess import check_call, CalledProcessError
import os
import secrets
import urllib.request
import shutil
import imghdr
import tempfile

from jinja2 import Environment, FileSystemLoader

from ops.charm import (
    CharmBase, ConfigChangedEvent, InstallEvent, RelationChangedEvent, RelationCreatedEvent,
    RelationDepartedEvent, RelationJoinedEvent, StartEvent,
)
from ops.framework import StoredState
from ops.main import main
from ops.model import ActiveStatus, BlockedStatus, MaintenanceStatus, WaitingStatus

logger = logging.getLogger(__name__)

# Templates go in the "src/templates".  Get them with
# `templates.get_template(filename)`.
templates = Environment(loader=FileSystemLoader("src/templates"))

# Where to find the mediawiki maintenance php scripts
MEDIAWIKI_MAINTENANCE_ROOT = "/usr/share/mediawiki/maintenance"

# Where to put the mediawiki config files
MEDIAWIKI_CONFIG_DIR = "/etc/mediawiki"

# Root of the mediawiki installation
MEDIAWIKI_ROOT_DIR = "/var/lib/mediawiki"

# Path of the config.php file containing the configuration generated from the
# charm config.
CONFIG_PHP_PATH = f"{MEDIAWIKI_CONFIG_DIR}/config.php"

# Path of the memcached.php file containing the configuration generated from a
# memcached relation.
MEMCACHED_PHP_PATH = f"{MEDIAWIKI_CONFIG_DIR}/memcached.php"

# Path of the db.php file containing configuration generated from a db relation
DB_PHP_PATH = f"{MEDIAWIKI_CONFIG_DIR}/db.php"

# Path of the main mediawiki configuration, which is generated by the
# install.php maintenance script once the database connection details are known.
# Include directives are added to also load the scripts above.
LOCALSETTINGS_PHP_PATH = f"{MEDIAWIKI_CONFIG_DIR}/LocalSettings.php"


class MediawikiCharm(CharmBase):
    """Charm the service."""

    _stored = StoredState()

    def __init__(self, *args):
        super().__init__(*args)
        self.framework.observe(self.on.install, self._on_install)
        self.framework.observe(self.on.start, self._on_start)
        self.framework.observe(self.on.config_changed, self._on_config_changed)

        self.framework.observe(self.on.db_relation_created, self._on_db_relation_created)
        self.framework.observe(self.on.db_relation_joined, self._on_db_relation_joined)
        self.framework.observe(self.on.db_relation_changed, self._on_db_relation_changed)
        self.framework.observe(self.on.db_relation_departed, self._on_db_relation_departed)

        # self.framework.observe(self.on.replicas_relation_joined,
        #                        self._on_replicas_relation_changed)
        self.framework.observe(self.on.replicas_relation_changed,
                               self._on_replicas_relation_changed)

        self.framework.observe(self.on.cache_relation_changed, self._on_cache_relation_changed)
        self.framework.observe(self.on.cache_relation_departed, self._on_cache_relation_departed)

        self.framework.observe(self.on.website_relation_joined, self._on_website_relation_joined)

    # Lifecycle hooks

    def _on_install(self, event: InstallEvent) -> None:
        self.unit.status = MaintenanceStatus("Installing mediawiki packages")
        try:
            install_mediawiki_packages()
            self.unit.status = WaitingStatus("Mediawiki packages installed")
        except CalledProcessError as e:
            logger.error("Package install failed with error: %s", e)
            self.unit.status = BlockedStatus("Failed to install packages")

    def _on_start(self, event: StartEvent) -> None:
        check_call(["open-port", "80"])
        self.unit.status = self._get_db_relation_status()

    def _on_config_changed(self, event: ConfigChangedEvent):
        self.unit.status = MaintenanceStatus("Updating Mediawiki configuration")
        try:
            configure_mediawiki(self.config)
            if self.unit.is_leader() and self.config["admins"]:
                setup_admins(self.config["admins"])
            reload_apache()
            self.unit.status = self._get_db_relation_status()
        except Exception as e:
            logger.error("Error configuring mediawiki: %s", e)
            self.unit.status = BlockedStatus("Failed to configure mediawiki")

    # Leader units do operations that affect the database, so only they react to
    # db_relation_changed events.  When they have created the database tables
    # successfully, they trigger an event on the peer relation "replicas" by
    # setting the "connected" key to True, thus indicating to the other units
    # that they can also install mediawiki.

    def _on_db_relation_created(self, event: RelationCreatedEvent) -> None:
        self.unit.status = WaitingStatus("Waiting to join db relation")

    def _on_db_relation_joined(self, event: RelationJoinedEvent) -> None:
        self.unit.status = WaitingStatus("Waiting for connection data from db relation")

    def _on_db_relation_changed(self, event: RelationChangedEvent) -> None:
        db = event.relation.data[event.unit]
        configure_db(db)
        if self.unit.is_leader():
            self._install_mediawiki(db)

    def _on_db_relation_departed(self, event: RelationDepartedEvent) -> None:
        self._set_db_connection_status(False)
        self.unit.status = BlockedStatus("Missing db relation")
        try:
            uninstall_mediawiki()
            reload_apache()
        except Exception as e:
            logger.error("Uninstalling failed with error %s", e)

    # Only non-leader units react to replicas_relation_changed.  It is a signal
    # from the leader unit that the mediawiki tables have been installed so they
    # can safely run the installation script without a risk of race.

    def _on_replicas_relation_changed(self, event: RelationChangedEvent) -> None:
        if self.unit.is_leader():
            return
        conf = event.relation.data[event.app]
        if conf.get("status") != "connected":
            # There should have been a db_relation_departed event that
            # triggered the uninstallation, so there is nothing to do in this
            # case.
            return
        db = self._get_db()
        if not db:
            logger.debug("No db connection data found even though the database is connected")
            return
        self._install_mediawiki(db)

    # All units configure themseleves to use memcached, making use off all
    # memcached units.

    def _on_cache_relation_changed(self, event: RelationChangedEvent) -> None:
        try:
            servers = []
            for unit in event.relation.units:
                unit_data = event.relation.data[unit]
                if "private-address" in unit_data and "port" in unit_data:
                    servers.append({
                        "address": unit_data["private-address"],
                        "port": unit_data["port"],
                    })
            configure_memcached(servers)
            reload_apache()
        except Exception as e:
            logger.error("Failed to configure memcached: %s", e)
            self.unit.status = BlockedStatus("Memcached configuration failed")

    def _on_cache_relation_departed(self, event: RelationDepartedEvent) -> None:
        try:
            configure_memcached(None)
            reload_apache()
        except Exception as e:
            logger.error("Unable to remove memcached configuration: %s", e)
            self.unit.status = BlockedStatus("Memcached removal failed")

    # The website relation implements providing the http interface, so it must
    # expose a port and a hostname to consumers of this interface.

    def _on_website_relation_joined(self, event: RelationJoinedEvent) -> None:
        unit_data = event.relation.data[self.unit]
        ingress_address = self.model.get_binding('website').network.ingress_address
        unit_data.update({
            "port": "80",
            "hostname": str(ingress_address),
        })

    # Methods that help event hooks

    def _get_db(self):
        '''
        Get db connection data from the relation if it exists
        '''
        db_rel = self.model.get_relation("db")
        if db_rel is None:
            return
        db_app_pfx = db_rel.app.name + "/"
        for u in db_rel.units:
            if u.name.startswith(db_app_pfx):
                db = db_rel.data[u]
                if db["slave"] == "False" and "database" in db:
                    return db

    def _get_db_relation_status(self):
        db_rel = self.model.get_relation("db")
        if db_rel is None:
            return BlockedStatus("Missing db relation")
        db = self._get_db()
        if db is None:
            return WaitingStatus("Waiting for connection data from db relation")
        if is_mediawiki_installed():
            return ActiveStatus()
        return WaitingStatus("Waiting to install Mediawiki")

    def _install_mediawiki(self, db):
        try:
            self.unit.status = MaintenanceStatus("Updating mediawiki db configuration")
            install_mediawiki(db)
            reload_apache()
            self._set_db_connection_status(True)
            self.unit.status = ActiveStatus()
        except Exception as e:
            logger.error("Mediawiki install failed with error: %s", e)
            self.unit.status = BlockedStatus("Failed to install mediawiki")

    def _uninstall_mediawiki(self):
        self._set_db_connection_status(False)
        self.unit.status = BlockedStatus("Missing db relation")
        try:
            uninstall_mediawiki()
        except Exception as e:
            logger.error("Uninstalling failed with error %s", e)

    def _set_db_connection_status(self, connected: bool) -> None:
        if not self.unit.is_leader():
            return
        app_data = self.model.get_relation("replicas").data[self.app]
        app_data.update({
            "status": "connected" if connected else "disconnected",
            "revision": str(int(app_data.get("revision", 0)) + 1),
        })

    def _get_db_connection_status(self) -> bool:
        app_data = self.model.get_relation("replicas").data[self.app]
        return app_data.get("status") == "connected"


#
# Helper functions
#


def install_mediawiki_packages():
    '''
    Set up the necessary packages for mediawiki to run
    '''
    # Install mediawiki (which pulls php as a dependency) and imagemagick which
    # allows mediawiki to perform image manipulation.
    check_call(["apt-get", "install", "-y", "mediawiki", "imagemagick"])

    # Apache2 is configured by default to serve from /var/www/html.  We replace
    # the DocumentRoot directive in the apache default configuration to point at
    # the mediawiki root.
    check_call([
        "sed", "-i",
        f"s|DocumentRoot .*|DocumentRoot {MEDIAWIKI_ROOT_DIR}|",
        "/etc/apache2/sites-available/000-default.conf",
    ])


def are_mediawiki_packages_installed():
    try:
        check_call([
            "grep", "-q",
            f"DocumentRoot {MEDIAWIKI_ROOT_DIR}",
            "/etc/apache2/sites-available/000-default.conf",
        ])
        return True
    except CalledProcessError:
        return False


def install_mediawiki(db):
    '''
    Create the wiki database tables and the basic LocalSettings.php file
    '''
    # Call the mediawiki install script that creates the database tables if
    # necessary, creates an admin user and generates a LocalSettings.php file.
    # The fact that it does all these things in one go is a challenge!

    if is_mediawiki_installed():
        configure_db(db)
        return

    with tempfile.TemporaryDirectory() as temp_dir:
        check_call([
            "php", f"{MEDIAWIKI_MAINTENANCE_ROOT}/install.php",
            "--dbserver", db["private-address"],
            "--dbname", db["database"],
            "--dbuser", db["user"],
            "--dbpass", db["password"],
            "--confpath", temp_dir,
            "--installdbuser", db["user"],
            "--installdbpass", db["password"],
            "--pass", secrets.token_urlsafe(32),
            "--scriptpath", "",
            "Charmed Wiki",
            "generic_charm_admin"
        ])

        # Include the config php files in LocalSettings.  When configuration
        # changes, only that file needs to be regenerated.
        with open(f"{temp_dir}/LocalSettings.php", "a") as f:
            f.write("\n")
            f.write(f"include('{CONFIG_PHP_PATH}');\n")
            f.write(f"include('{MEMCACHED_PHP_PATH}');\n")
            f.write(f"include('{DB_PHP_PATH}');\n")

        # Make sure the config php files exists, as LocalSettings
        # will include them.
        touch_config(CONFIG_PHP_PATH)
        touch_config(MEMCACHED_PHP_PATH)
        touch_config(DB_PHP_PATH)

        # Finally swap in the configuration
        os.rename(f"{temp_dir}/LocalSettings.php", LOCALSETTINGS_PHP_PATH)


def configure_db(db):
    '''
    Regenerate the db.php file with database connection data
    '''
    template = templates.get_template("db.php")
    db_php = template.render(db=db)
    write_config(DB_PHP_PATH, db_php)


def is_mediawiki_installed():
    return os.path.exists(LOCALSETTINGS_PHP_PATH)


def uninstall_mediawiki():
    '''
    Remove the LocalSettings.php file,  returning mediawiki to its uninstalled
    state.
    '''
    for f in LOCALSETTINGS_PHP_PATH, CONFIG_PHP_PATH, MEMCACHED_PHP_PATH, DB_PHP_PATH:
        try:
            os.remove(f)
        except FileNotFoundError:
            pass


def configure_mediawiki(conf):
    '''
    Overwrite the config.php file containing the mediawiki config that comes
    from the charm config.
    '''
    template = templates.get_template("config.php")
    config_php = template.render(
        wiki_name=conf["name"],
        language_code=conf["language"],
        skin=conf["skin"],
        server_address=conf["server_address"],
        logo_path=fetch_logo(conf["logo"]),
        debug_file="" if not conf["debug"] else os.getcwd() + "/debug.log",
    )
    write_config(CONFIG_PHP_PATH, config_php)


def fetch_logo(logo_url) -> str:
    '''
    Fetch the wiki logo from the given URL if necessary, returning the file path
    where it has been stored
    '''

    if not logo_url:
        return

    url_logo_path = "/images/wiki_logo"
    fs_logo_path = f"{MEDIAWIKI_ROOT_DIR}{url_logo_path}"
    logo_src_path = f"{MEDIAWIKI_CONFIG_DIR}/logo_url"

    # Check for an already downloaded this image and return early if that's the
    # case
    try:
        with open(logo_src_path) as f:
            previous_url = f.read()
    except FileNotFoundError:
        previous_url = ""
    if logo_url == previous_url:
        return url_logo_path

    # Fetch the image and store it
    with urllib.request.urlopen(logo_url) as response:
        content = response.read()
        ext = imghdr.what(response, content)
    if ext is None:
        raise ValueError("logo is not an image")
    with open(fs_logo_path, "wb") as f:
        f.write(content)

    # Remember we've done that
    with open(logo_src_path, "w") as f:
        f.write(logo_url)

    shutil.chown(fs_logo_path, user="www-data", group="www-data")
    return url_logo_path


def setup_admins(admins: str):
    '''
    Make sure the given admin users are setup.

    TODO: remove other admins?
    '''
    for username, pwd in parse_admins(admins):
        create_or_update_admin(username, pwd)


def parse_admins(admins: str):
    '''
    Parse a string of the form "<name1>:<pwd1> <name2>:<pwd2> ..." into
    a list of pairs [["<name1>", "<pwd1>"], ["<name2>", "<pwd2>"], ...]

    Raise a ValueError if the string is not of the correct format.
    '''
    name_pwd_pairs = []
    for item in admins.split():
        if ":" not in item:
            raise ValueError("admin should be in format user:pass")
        name_pwd_pairs.append(item.split(":", 1))
    return name_pwd_pairs


def create_or_update_admin(username: str, pwd: str):
    '''
    Make sure the specified user exists, has the given password and is an
    "admin", i.e. belongs to the sysop and bureaucrat groups.
    '''
    check_call([
        "php", f"{MEDIAWIKI_MAINTENANCE_ROOT}/createAndPromote.php",
        "--conf", LOCALSETTINGS_PHP_PATH,
        "--force",
        "--sysop", "--bureaucrat",
        username, pwd,
    ])


def configure_memcached(servers):
    '''
    Update the memcached configuration with the given servers.  If servers is
    falsy, instead remove memcached configuration.
    '''
    if not servers:
        memcached_php = ""
    else:
        template = templates.get_template("memcached.php")
        memcached_php = template.render(servers=servers)
    write_config(MEMCACHED_PHP_PATH, memcached_php)


def reload_apache():
    check_call(["service", "apache2", "reload"])


def touch_config(path):
    '''
    Ensure a file exists (potentially creating an empty file) with suitable
    permissions for being read as config for mediawiki.
    '''
    with open(path, "a"):
        pass
    os.chmod(path, 0o644)


def write_config(path: str, content: str):
    '''
    Write a file to disk with suitable permissions for being read as config for
    mediawiki.
    '''
    with open(path, "w") as f:
        f.write(content)
    os.chmod(path, 0o644)


if __name__ == "__main__":
    main(MediawikiCharm)

#!/usr/bin/env python3 # Copyright 2021 Ubuntu # See LICENSE file for licensing details. # # Learn more at: https://juju.is/docs/sdk Charm the service.  Refer to the following post for a quick-start guide that will help you develop a new k8s charm using the Operator Framework:      https://discourse.charmhub.io/t/4208 # Templates go in the "src/templates".  Get them with # `templates.get_template(filename)`. # Where to find the mediawiki maintenance php scripts # Where to put the mediawiki config files # Root of the mediawiki installation # Path of the config.php file containing the configuration generated from the # charm config. # Path of the memcached.php file containing the configuration generated from a # memcached relation. # Path of the db.php file containing configuration generated from a db relation # Path of the main mediawiki configuration, which is generated by the # install.php maintenance script once the database connection details are known. # Include directives are added to also load the scripts above. Charm the service. # self.framework.observe(self.on.replicas_relation_joined, #                        self._on_replicas_relation_changed) # Lifecycle hooks # Leader units do operations that affect the database, so only they react to # db_relation_changed events.  When they have created the database tables # successfully, they trigger an event on the peer relation "replicas" by # setting the "connected" key to True, thus indicating to the other units # that they can also install mediawiki. # Only non-leader units react to replicas_relation_changed.  It is a signal # from the leader unit that the mediawiki tables have been installed so they # can safely run the installation script without a risk of race. # There should have been a db_relation_departed event that # triggered the uninstallation, so there is nothing to do in this # case. # All units configure themseleves to use memcached, making use off all # memcached units. # The website relation implements providing the http interface, so it must # expose a port and a hostname to consumers of this interface. # Methods that help event hooks Get db connection data from the relation if it exists # # Helper functions # Set up the necessary packages for mediawiki to run # Install mediawiki (which pulls php as a dependency) and imagemagick which # allows mediawiki to perform image manipulation. # Apache2 is configured by default to serve from /var/www/html.  We replace # the DocumentRoot directive in the apache default configuration to point at # the mediawiki root. Create the wiki database tables and the basic LocalSettings.php file # Call the mediawiki install script that creates the database tables if # necessary, creates an admin user and generates a LocalSettings.php file. # The fact that it does all these things in one go is a challenge! # Include the config php files in LocalSettings.  When configuration # changes, only that file needs to be regenerated. # Make sure the config php files exists, as LocalSettings # will include them. # Finally swap in the configuration Regenerate the db.php file with database connection data Remove the LocalSettings.php file,  returning mediawiki to its uninstalled     state. Overwrite the config.php file containing the mediawiki config that comes     from the charm config. Fetch the wiki logo from the given URL if necessary, returning the file path     where it has been stored # Check for an already downloaded this image and return early if that's the # case # Fetch the image and store it # Remember we've done that Make sure the given admin users are setup.      TODO: remove other admins? Parse a string of the form "<name1>:<pwd1> <name2>:<pwd2> ..." into     a list of pairs [["<name1>", "<pwd1>"], ["<name2>", "<pwd2>"], ...]      Raise a ValueError if the string is not of the correct format. Make sure the specified user exists, has the given password and is an     "admin", i.e. belongs to the sysop and bureaucrat groups. Update the memcached configuration with the given servers.  If servers is     falsy, instead remove memcached configuration. Ensure a file exists (potentially creating an empty file) with suitable     permissions for being read as config for mediawiki. Write a file to disk with suitable permissions for being read as config for     mediawiki.