Skip to content

Commit

Permalink
feat: add create-admin action (#13)
Browse files Browse the repository at this point in the history
  • Loading branch information
amandahla authored Oct 9, 2024
1 parent e02115a commit f001b0c
Show file tree
Hide file tree
Showing 6 changed files with 180 additions and 35 deletions.
34 changes: 17 additions & 17 deletions charmcraft.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Copyright 2024 Canonical Ltd.
# See LICENSE file for licensing details.

type: charm
name: maubot
title: maubot
description: |
Expand All @@ -12,36 +13,27 @@ links:
source: https://github.com/canonical/maubot-operator
contact:
- https://launchpad.net/~canonical-is-devops

assumes:
- juju >= 3.4
base: [email protected]
build-base: [email protected]
config:
options:
public-url:
description: >-
Public base URL where the server is visible.
type: string
default: "https://maubot.local"

resources:
maubot-image:
type: oci-image
description: OCI image for maubot

containers:
maubot:
resource: maubot-image
mounts:
- storage: data
location: /data
storage:
data:
type: filesystem

type: charm
base: [email protected]
build-base: [email protected]
platforms:
amd64:

parts:
charm:
build-packages:
Expand All @@ -50,13 +42,21 @@ parts:
- libssl-dev
- pkg-config
- rustc

assumes:
- juju >= 3.4

platforms:
amd64:
requires:
postgresql:
interface: postgresql_client
limit: 1
ingress:
interface: ingress
storage:
data:
type: filesystem
actions:
create-admin:
description: Create administrator user to Maubot.
params:
name:
type: string
description: The name of the administrator user.
12 changes: 11 additions & 1 deletion src-docs/charm.py.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,26 @@ Maubot charm service.

**Global Variables**
---------------
- **MAUBOT_CONFIGURATION_PATH**
- **MAUBOT_NAME**
- **NGINX_NAME**


---

## <kbd>class</kbd> `EventFailError`
Exception raised when an event fails.





---

## <kbd>class</kbd> `MaubotCharm`
Maubot charm.

<a href="../src/charm.py#L40"><img align="right" style="float:right;" src="https://img.shields.io/badge/-source-cccccc?style=flat-square"></a>
<a href="../src/charm.py#L46"><img align="right" style="float:right;" src="https://img.shields.io/badge/-source-cccccc?style=flat-square"></a>

### <kbd>function</kbd> `__init__`

Expand Down
84 changes: 67 additions & 17 deletions src/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
"""Maubot charm service."""

import logging
import typing
import secrets
from typing import Any, Dict

import ops
import yaml
Expand All @@ -26,6 +27,7 @@

logger = logging.getLogger(__name__)

MAUBOT_CONFIGURATION_PATH = "/data/config.yaml"
MAUBOT_NAME = "maubot"
NGINX_NAME = "nginx"

Expand All @@ -34,61 +36,74 @@ class MissingPostgreSQLRelationDataError(Exception):
"""Custom exception to be raised in case of malformed/missing Postgresql relation data."""


class EventFailError(Exception):
"""Exception raised when an event fails."""


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()
self.container.push(MAUBOT_CONFIGURATION_PATH, yaml.safe_dump(config))
config["server"]["public_url"] = self.config.get("public-url")
container.push("/data/config.yaml", yaml.safe_dump(config))
self.container.push("/data/config.yaml", 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.restart(MAUBOT_NAME)
container.restart(NGINX_NAME)
self.container.add_layer(MAUBOT_NAME, self._pebble_layer, combine=True)
self.container.restart(MAUBOT_NAME)
self.container.restart(NGINX_NAME)
self.unit.status = ops.ActiveStatus()

def _on_maubot_pebble_ready(self, _: ops.PebbleReadyEvent) -> None:
Expand All @@ -107,6 +122,41 @@ def _on_ingress_revoked(self, _: IngressPerAppRevokedEvent) -> None:
"""Handle ingress revoked event."""
self._reconcile()

# Actions events handlers
def _on_create_admin_action(self, event: ops.ActionEvent) -> None:
"""Handle delete-profile action.
Args:
event: Action event.
Raises:
EventFailError: in case the event fails.
"""
try:
name = event.params["name"]
results = {"password": "", "error": ""}
if name == "root":
raise EventFailError("root is reserved, please choose a different name")
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()
):
raise EventFailError("maubot is not ready")
password = secrets.token_urlsafe(10)
config = self._get_configuration()
if name in config["admins"]:
raise EventFailError(f"{name} already exists")
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)
except EventFailError as e:
results["error"] = str(e)
event.set_results(results)
event.fail(str(e))

# Integrations events handlers
def _on_database_created(self, _: DatabaseCreatedEvent) -> None:
"""Handle database created event."""
Expand Down
49 changes: 49 additions & 0 deletions tests/integration/test_charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,55 @@ async def test_build_and_deploy(
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


@pytest.mark.abort_on_fail
async def test_public_url_config(
ops_test: OpsTest,
Expand Down
2 changes: 2 additions & 0 deletions tests/unit/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ def harness_fixture():
hostname: 0.0.0.0
port: 29316
public_url: https://example.com
admins:
root:
"""
(root / "data" / "config.yaml").write_text(yaml_content)
yield harness
Expand Down
34 changes: 34 additions & 0 deletions tests/unit/test_charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,40 @@ def test_database_created(harness):
)


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


def test_public_url_config_changed(harness):
"""
arrange: initialize harness and set postgresql integration.
Expand Down

0 comments on commit f001b0c

Please sign in to comment.