diff --git a/charmcraft.yaml b/charmcraft.yaml index df11fe8..ab1ef21 100644 --- a/charmcraft.yaml +++ b/charmcraft.yaml @@ -1,6 +1,7 @@ # Copyright 2024 Canonical Ltd. # See LICENSE file for licensing details. +type: charm name: maubot title: maubot description: | @@ -12,28 +13,17 @@ links: source: https://github.com/canonical/maubot-operator contact: - https://launchpad.net/~canonical-is-devops - -resources: - maubot-image: - type: oci-image - description: OCI image for maubot - +# build properties +assumes: + - juju >= 3.4 +base: ubuntu@24.04 +build-base: ubuntu@24.04 containers: maubot: resource: maubot-image mounts: - storage: data location: /data -storage: - data: - type: filesystem - -type: charm -base: ubuntu@24.04 -build-base: ubuntu@24.04 -platforms: - amd64: - parts: charm: build-packages: @@ -42,13 +32,25 @@ parts: - libssl-dev - pkg-config - rustc - -assumes: - - juju >= 3.4 - +platforms: + amd64: requires: postgresql: interface: postgresql_client limit: 1 ingress: interface: ingress +resources: + maubot-image: + type: oci-image + description: OCI image for Maubot. +storage: + data: + type: filesystem +actions: + create-admin: + description: Create administrator user to Maubot. + params: + name: + type: string + description: The name of the administrator user. diff --git a/src-docs/charm.py.md b/src-docs/charm.py.md index 61eb9ab..71694c9 100644 --- a/src-docs/charm.py.md +++ b/src-docs/charm.py.md @@ -8,6 +8,7 @@ Maubot charm service. **Global Variables** --------------- - **MAUBOT_NAME** +- **MAUBOT_CONFIGURATION_PATH** --- @@ -15,7 +16,7 @@ Maubot charm service. ## class `MaubotCharm` Maubot charm. - + ### function `__init__` diff --git a/src/charm.py b/src/charm.py index c808662..6127271 100755 --- a/src/charm.py +++ b/src/charm.py @@ -8,7 +8,8 @@ """Maubot charm service.""" import logging -import typing +import secrets +from typing import Any, Dict import ops import yaml @@ -27,6 +28,7 @@ logger = logging.getLogger(__name__) MAUBOT_NAME = "maubot" +MAUBOT_CONFIGURATION_PATH = "/data/config.yaml" class MissingPostgreSQLRelationDataError(Exception): @@ -36,56 +38,64 @@ class MissingPostgreSQLRelationDataError(Exception): class MaubotCharm(ops.CharmBase): """Maubot charm.""" - def __init__(self, *args: typing.Any): + def __init__(self, *args: Any): """Construct. Args: args: Arguments passed to the CharmBase parent constructor. """ super().__init__(*args) + self.container = self.unit.get_container(MAUBOT_NAME) self.ingress = IngressPerAppRequirer(self, port=8080) self.postgresql = DatabaseRequires( self, relation_name="postgresql", database_name=self.app.name ) self.framework.observe(self.on.maubot_pebble_ready, self._on_maubot_pebble_ready) self.framework.observe(self.on.config_changed, self._on_config_changed) + # Actions events handlers + self.framework.observe(self.on.create_admin_action, self._on_create_admin_action) # Integrations events handlers self.framework.observe(self.postgresql.on.database_created, self._on_database_created) self.framework.observe(self.postgresql.on.endpoints_changed, self._on_endpoints_changed) self.framework.observe(self.ingress.on.ready, self._on_ingress_ready) self.framework.observe(self.ingress.on.revoked, self._on_ingress_revoked) - def _configure_maubot(self, container: ops.Container) -> None: - """Configure maubot. + def _get_configuration(self) -> Dict[str, Any]: + """Get Maubot configuration content. - Args: - container: Container of the charm. + Returns: + Maubot configuration file as a dict. """ + config_content = str( + self.container.pull(MAUBOT_CONFIGURATION_PATH, encoding="utf-8").read() + ) + return yaml.safe_load(config_content) + + def _configure_maubot(self) -> None: + """Configure maubot.""" commands = [ - ["cp", "--update=none", "/example-config.yaml", "/data/config.yaml"], + ["cp", "--update=none", "/example-config.yaml", MAUBOT_CONFIGURATION_PATH], ["mkdir", "-p", "/data/plugins", "/data/trash", "/data/dbs"], ] for command in commands: - process = container.exec(command, combine_stderr=True) + process = self.container.exec(command, combine_stderr=True) process.wait() - config_content = str(container.pull("/data/config.yaml", encoding="utf-8").read()) - config = yaml.safe_load(config_content) + config = self._get_configuration() config["database"] = self._get_postgresql_credentials() - container.push("/data/config.yaml", yaml.safe_dump(config)) + self.container.push(MAUBOT_CONFIGURATION_PATH, yaml.safe_dump(config)) def _reconcile(self) -> None: """Reconcile workload configuration.""" self.unit.status = ops.MaintenanceStatus() - container = self.unit.get_container(MAUBOT_NAME) - if not container.can_connect(): + if not self.container.can_connect(): return try: - self._configure_maubot(container) + self._configure_maubot() except MissingPostgreSQLRelationDataError: self.unit.status = ops.BlockedStatus("postgresql integration is required") return - container.add_layer(MAUBOT_NAME, self._pebble_layer, combine=True) - container.replan() + self.container.add_layer(MAUBOT_NAME, self._pebble_layer, combine=True) + self.container.replan() self.unit.status = ops.ActiveStatus() def _on_maubot_pebble_ready(self, _: ops.PebbleReadyEvent) -> None: @@ -104,6 +114,48 @@ def _on_ingress_revoked(self, _: IngressPerAppRevokedEvent) -> None: """Handle ingress revoked event.""" self._reconcile() + # Actions events handlers + def _fail_event(self, event: ops.ActionEvent, results: Dict[str, str], message: str) -> None: + """Handle failure events. + + Args: + event: Action event. + results: Event results. + message: Error message. + """ + results["error"] = message + event.set_results(results) + event.fail(message) + + def _on_create_admin_action(self, event: ops.ActionEvent) -> None: + """Handle delete-profile action. + + Args: + event: Action event. + """ + name = event.params["name"] + results = {"password": "", "error": ""} + if name == "root": + self._fail_event(event, results, "root is reserved, please choose a different name") + return + if ( + not self.container.can_connect() + or MAUBOT_NAME not in self.container.get_plan().services + or not self.container.get_service(MAUBOT_NAME).is_running() + ): + self._fail_event(event, results, "maubot is not ready") + return + password = secrets.token_urlsafe(10) + config = self._get_configuration() + if name in config["admins"]: + self._fail_event(event, results, f"{name} already exists") + return + config["admins"][name] = password + self.container.push(MAUBOT_CONFIGURATION_PATH, yaml.safe_dump(config)) + self.container.restart(MAUBOT_NAME) + results["password"] = password + event.set_results(results) + # Integrations events handlers def _on_database_created(self, _: DatabaseCreatedEvent) -> None: """Handle database created event.""" diff --git a/tests/integration/test_charm.py b/tests/integration/test_charm.py index 00b2d1b..483059a 100644 --- a/tests/integration/test_charm.py +++ b/tests/integration/test_charm.py @@ -63,3 +63,52 @@ async def test_build_and_deploy( ) assert response.status_code == 200 assert "Maubot Manager" in response.text + + +async def test_create_admin_action_success(ops_test: OpsTest): + """ + arrange: Maubot charm integrated with PostgreSQL. + act: run the create-admin action. + assert: the action results contains a password. + """ + name = "test" + assert ops_test.model + unit = ops_test.model.applications["maubot"].units[0] + + action = await unit.run_action("create-admin", name=name) + await action.wait() + + assert "password" in action.results + password = action.results["password"] + response = requests.post( + "http://127.0.0.1/_matrix/maubot/v1/auth/login", + timeout=5, + headers={"Host": "maubot.local"}, + data=f'{{"username":"{name}","password":"{password}"}}', + ) + assert response.status_code == 200 + assert "token" in response.text + + +@pytest.mark.parametrize( + "name,expected_message", + [ + pytest.param("root", "root is reserved, please choose a different name", id="root"), + pytest.param("test", "test already exists", id="user_exists"), + ], +) +async def test_create_admin_action_failed(name: str, expected_message: str, ops_test: OpsTest): + """ + arrange: Maubot charm integrated with PostgreSQL. + act: run the create-admin action. + assert: the action results fails. + """ + assert ops_test.model + unit = ops_test.model.applications["maubot"].units[0] + + action = await unit.run_action("create-admin", name=name) + await action.wait() + + assert "error" in action.results + error = action.results["error"] + assert error == expected_message diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 00b2c83..f04d6f9 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -24,6 +24,15 @@ def harness_fixture(): ) root = harness.get_filesystem_root("maubot") (root / "data").mkdir() - (root / "data" / "config.yaml").write_text("database: sqlite:maubot.db") + yaml_content = """\ +database: sqlite:maubot.db +server: + hostname: 0.0.0.0 + port: 29316 + public_url: https://example.com +admins: + root: +""" + (root / "data" / "config.yaml").write_text(yaml_content) yield harness harness.cleanup() diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index edd468d..3b92356 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -98,3 +98,37 @@ def test_database_created(harness): harness.charm._get_postgresql_credentials() == "postgresql://someuser:somepasswd@dbhost:5432/maubot" ) + + +def test_create_admin_action_success(harness): + """ + arrange: initialize the testing harness and set up all required integration. + act: run create-admin charm action. + assert: ensure password is in the results. + """ + harness.set_leader() + harness.begin_with_initial_hooks() + set_postgresql_integration(harness) + + action = harness.run_action("create-admin", {"name": "test"}) + + assert "password" in action.results + assert "error" in action.results and not action.results["error"] + + +def test_create_admin_action_failed(harness): + """ + arrange: initialize the testing harness and set up all required integration. + act: run create-admin charm action with reserved name root. + assert: ensure action fails. + """ + harness.set_leader() + harness.begin_with_initial_hooks() + set_postgresql_integration(harness) + + try: + harness.run_action("create-admin", {"name": "root"}) + except ops.testing.ActionFailed as e: + message = "root is reserved, please choose a different name" + assert e.output.results["error"] == message + assert e.message == message