Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add create-admin action #13

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 22 additions & 20 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,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: [email protected]
build-base: [email protected]
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 @@ -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.
3 changes: 2 additions & 1 deletion src-docs/charm.py.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,15 @@ Maubot charm service.
**Global Variables**
---------------
- **MAUBOT_NAME**
- **MAUBOT_CONFIGURATION_PATH**


---

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

<a href="../src/charm.py#L39"><img align="right" style="float:right;" src="https://img.shields.io/badge/-source-cccccc?style=flat-square"></a>
<a href="../src/charm.py#L41"><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: 68 additions & 16 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 @@ -27,6 +28,7 @@
logger = logging.getLogger(__name__)

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


class MissingPostgreSQLRelationDataError(Exception):
Expand All @@ -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:
Expand All @@ -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:
amandahla marked this conversation as resolved.
Show resolved Hide resolved
"""Handle delete-profile action.

Args:
event: Action event.
"""
name = event.params["name"]
results = {"password": "", "error": ""}
if name == "root":
amandahla marked this conversation as resolved.
Show resolved Hide resolved
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."""
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 @@ -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
jdkandersson marked this conversation as resolved.
Show resolved Hide resolved
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
11 changes: 10 additions & 1 deletion tests/unit/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
34 changes: 34 additions & 0 deletions tests/unit/test_charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading