diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..410c612 --- /dev/null +++ b/config.yaml @@ -0,0 +1,11 @@ +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +options: + system-users: + type: secret + description: | + Configure the internal system user and it's password. The password will + be auto-generated if this option is not set. It is for internal use only + and SHOULD NOT be used by applications. This needs to be a Juju Secret URI pointing + to a secret that contains the following content: `root: `. \ No newline at end of file diff --git a/src/common/client.py b/src/common/client.py index 48ea3d6..ff49fb0 100644 --- a/src/common/client.py +++ b/src/common/client.py @@ -8,7 +8,8 @@ import logging import subprocess -from literals import SNAP_NAME +from common.exceptions import EtcdAuthNotEnabledError, EtcdUserManagementError +from literals import INTERNAL_USER, SNAP_NAME logger = logging.getLogger(__name__) @@ -18,9 +19,13 @@ class EtcdClient: def __init__( self, + username: str, + password: str, client_url: str, ): self.client_url = client_url + self.user = username + self.password = password def get_endpoint_status(self) -> dict: """Run the `endpoint status` command and return the result as dict.""" @@ -31,29 +36,85 @@ def get_endpoint_status(self) -> dict: endpoints=self.client_url, output_format="json", ): - endpoint_status = json.loads(result)[0] + try: + endpoint_status = json.loads(result)[0] + except json.JSONDecodeError: + pass return endpoint_status - def _run_etcdctl( + def add_user(self, username: str) -> None: + """Add a user to etcd.""" + if result := self._run_etcdctl( + command="user", + subcommand="add", + endpoints=self.client_url, + user=username, + # only admin user is added with password, all others require `CommonName` based auth + user_password=self.password if username == INTERNAL_USER else "", + ): + logger.debug(result) + else: + raise EtcdUserManagementError(f"Failed to add user {self.user}.") + + def update_password(self, username: str, new_password: str) -> None: + """Run the `user passwd` command in etcd.""" + if result := self._run_etcdctl( + command="user", + subcommand="passwd", + endpoints=self.client_url, + auth_username=self.user, + auth_password=self.password, + user=username, + use_input=new_password, + ): + logger.debug(f"{result} for user {username}.") + else: + raise EtcdUserManagementError(f"Failed to update user {username}.") + + def enable_auth(self) -> None: + """Enable authentication in etcd.""" + if result := self._run_etcdctl( + command="auth", + subcommand="enable", + endpoints=self.client_url, + ): + logger.debug(result) + else: + raise EtcdAuthNotEnabledError("Failed to enable authentication in etcd.") + + def _run_etcdctl( # noqa: C901 self, command: str, - subcommand: str | None, endpoints: str, - output_format: str | None = "simple", + subcommand: str | None = None, + # We need to be able to run `etcdctl` without user/pw + # otherwise it will error if auth is not yet enabled + # this is relevant for `user add` and `auth enable` commands + auth_username: str | None = None, + auth_password: str | None = None, + user: str | None = None, + user_password: str | None = None, + output_format: str = "simple", + use_input: str | None = None, ) -> str | None: """Execute `etcdctl` command via subprocess. - The list of arguments will be extended once authentication/encryption is implemented. This method aims to provide a very clear interface for executing `etcdctl` and minimize - the margin of error on cluster operations. + the margin of error on cluster operations. The following arguments can be passed to the + `etcdctl` command as parameters. Args: command: command to execute with etcdctl, e.g. `elect`, `member` or `endpoint` subcommand: subcommand to add to the previous command, e.g. `add` or `status` endpoints: str-formatted list of endpoints to run the command against + auth_username: username used for authentication + auth_password: password used for authentication + user: username to be added or updated in etcd + user_password: password to be set for the user that is added to etcd output_format: set the output format (fields, json, protobuf, simple, table) - ... + use_input: supply text input to be passed to the `etcdctl` command (e.g. for + non-interactive password change) Returns: The output of the subprocess-command as a string. In case of error, this will @@ -62,20 +123,40 @@ def _run_etcdctl( might differ. """ try: + args = [f"{SNAP_NAME}.etcdctl", command] + if subcommand: + args.append(subcommand) + if user: + args.append(user) + if user_password == "": + args.append("--no-password=True") + elif user_password: + args.append(f"--new-user-password={user_password}") + if endpoints: + args.append(f"--endpoints={endpoints}") + if auth_username: + args.append(f"--user={auth_username}") + if auth_password: + args.append(f"--password={auth_password}") + if output_format: + args.append(f"-w={output_format}") + if use_input: + args.append("--interactive=False") + result = subprocess.run( - args=[ - f"{SNAP_NAME}.etcdctl", - command, - subcommand, - f"--endpoints={endpoints}", - f"-w={output_format}", - ], + args=args, check=True, capture_output=True, text=True, + input=use_input if use_input else "", ).stdout.strip() except subprocess.CalledProcessError as e: - logger.warning(e) + logger.error( + f"etcdctl {command} command failed: returncode: {e.returncode}, error: {e.stderr}" + ) + return None + except subprocess.TimeoutExpired as e: + logger.error(f"Timed out running etcdctl: {e.stderr}") return None return result diff --git a/src/common/exceptions.py b/src/common/exceptions.py new file mode 100644 index 0000000..8fd165b --- /dev/null +++ b/src/common/exceptions.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Charm-specific exceptions.""" + + +class RaftLeaderNotFoundError(Exception): + """Custom Exception if there is no current Raft leader.""" + + +class EtcdUserManagementError(Exception): + """Custom Exception if user could not be added or updated in etcd cluster.""" + + +class EtcdAuthNotEnabledError(Exception): + """Custom Exception if authentication could not be enabled in the etcd cluster.""" diff --git a/src/common/secrets.py b/src/common/secrets.py new file mode 100644 index 0000000..2f1d74b --- /dev/null +++ b/src/common/secrets.py @@ -0,0 +1,22 @@ +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Utility functions related to secrets.""" + +import logging + +from ops.model import ModelError, SecretNotFoundError + +logger = logging.getLogger(__name__) + + +def get_secret_from_id(model, secret_id: str) -> dict[str, str] | None: + """Resolve the given id of a Juju secret and return the content as a dict.""" + try: + secret_content = model.get_secret(id=secret_id).get_content(refresh=True) + except SecretNotFoundError: + raise SecretNotFoundError(f"The secret '{secret_id}' does not exist.") + except ModelError: + raise + + return secret_content diff --git a/src/core/cluster.py b/src/core/cluster.py index 240ff4e..7b24b23 100644 --- a/src/core/cluster.py +++ b/src/core/cluster.py @@ -15,7 +15,7 @@ from ops import Object, Relation, Unit from core.models import EtcdCluster, EtcdServer -from literals import PEER_RELATION, SUBSTRATES +from literals import PEER_RELATION, SECRETS_APP, SUBSTRATES if TYPE_CHECKING: from charm import EtcdOperatorCharm @@ -29,7 +29,9 @@ class ClusterState(Object): def __init__(self, charm: "EtcdOperatorCharm", substrate: SUBSTRATES): super().__init__(parent=charm, key="charm_state") self.substrate: SUBSTRATES = substrate - self.peer_app_interface = DataPeerData(self.model, relation_name=PEER_RELATION) + self.peer_app_interface = DataPeerData( + self.model, relation_name=PEER_RELATION, additional_secret_fields=SECRETS_APP + ) self.peer_unit_interface = DataPeerUnitData(self.model, relation_name=PEER_RELATION) @property diff --git a/src/core/models.py b/src/core/models.py index 9cbe051..2c7ed91 100644 --- a/src/core/models.py +++ b/src/core/models.py @@ -5,12 +5,11 @@ """Collection of state objects for the Etcd relations, apps and units.""" import logging -from collections.abc import MutableMapping from charms.data_platform_libs.v0.data_interfaces import Data, DataPeerData, DataPeerUnitData from ops.model import Application, Relation, Unit -from literals import CLIENT_PORT, PEER_PORT, SUBSTRATES +from literals import CLIENT_PORT, INTERNAL_USER, PEER_PORT, SUBSTRATES logger = logging.getLogger(__name__) @@ -31,11 +30,6 @@ def __init__( self.substrate = substrate self.relation_data = self.data_interface.as_dict(self.relation.id) if self.relation else {} - @property - def data(self) -> MutableMapping: - """Data representing the state.""" - return self.relation_data - def update(self, items: dict[str, str]) -> None: """Write to relation data.""" if not self.relation: @@ -119,3 +113,16 @@ def __init__( def initial_cluster_state(self) -> str: """The initial cluster state ('new' or 'existing') of the etcd cluster.""" return self.relation_data.get("initial_cluster_state", "") + + @property + def internal_user_credentials(self) -> dict[str, str]: + """Retrieve the credentials for the internal admin user.""" + if password := self.relation_data.get(f"{INTERNAL_USER}-password"): + return {INTERNAL_USER: password} + + return {} + + @property + def auth_enabled(self) -> bool: + """Flag to check if authentication is already enabled in the Cluster.""" + return self.relation_data.get("authentication", "") == "enabled" diff --git a/src/core/workload.py b/src/core/workload.py index 8ae58c8..16c2a5f 100644 --- a/src/core/workload.py +++ b/src/core/workload.py @@ -4,6 +4,8 @@ """Base objects for workload operations across different substrates.""" +import secrets +import string from abc import ABC, abstractmethod @@ -24,3 +26,12 @@ def alive(self) -> bool: def write_file(self, content: str, file: str) -> None: """Write content to a file.""" pass + + @staticmethod + def generate_password() -> str: + """Create randomized string for use as app passwords. + + Returns: + String of 32 randomized letter+digit characters + """ + return "".join([secrets.choice(string.ascii_letters + string.digits) for _ in range(32)]) diff --git a/src/events/etcd.py b/src/events/etcd.py index 31bbd23..ed5fe5e 100644 --- a/src/events/etcd.py +++ b/src/events/etcd.py @@ -16,8 +16,15 @@ RelationDepartedEvent, RelationJoinedEvent, ) +from ops.model import ModelError, SecretNotFoundError -from literals import PEER_RELATION, Status +from common.exceptions import ( + EtcdAuthNotEnabledError, + EtcdUserManagementError, + RaftLeaderNotFoundError, +) +from common.secrets import get_secret_from_id +from literals import INTERNAL_USER, INTERNAL_USER_PASSWORD_CONFIG, PEER_RELATION, Status if TYPE_CHECKING: from charm import EtcdOperatorCharm @@ -51,6 +58,7 @@ def __init__(self, charm: "EtcdOperatorCharm"): ) self.framework.observe(self.charm.on.leader_elected, self._on_leader_elected) self.framework.observe(self.charm.on.update_status, self._on_update_status) + self.framework.observe(self.charm.on.secret_changed, self._on_secret_changed) def _on_install(self, event: ops.InstallEvent) -> None: """Handle install event.""" @@ -74,6 +82,15 @@ def _on_start(self, event: ops.StartEvent) -> None: self.charm.workload.start() + if self.charm.unit.is_leader() and not self.charm.state.cluster.auth_enabled: + try: + self.charm.cluster_manager.enable_authentication() + self.charm.state.cluster.update({"authentication": "enabled"}) + except (EtcdAuthNotEnabledError, EtcdUserManagementError) as e: + logger.error(e) + self.charm.set_status(Status.AUTHENTICATION_NOT_ENABLED) + return + if self.charm.workload.alive(): self.charm.set_status(Status.ACTIVE) else: @@ -81,7 +98,11 @@ def _on_start(self, event: ops.StartEvent) -> None: def _on_config_changed(self, event: ops.ConfigChangedEvent) -> None: """Handle config_changed event.""" - pass + if not self.charm.unit.is_leader(): + return + + if admin_secret_id := self.charm.config.get(INTERNAL_USER_PASSWORD_CONFIG): + self.update_admin_password(admin_secret_id) def _on_cluster_relation_created(self, event: RelationCreatedEvent) -> None: """Handle event received by a new unit when joining the cluster relation.""" @@ -102,8 +123,11 @@ def _on_cluster_relation_joined(self, event: RelationJoinedEvent) -> None: # Todo: remove this test at some point, this is just for showcasing that it works :) # We will need to perform any HA-related action against the raft leader # e.g. add members, trigger leader election, log compaction, etc. - if raft_leader := self.charm.cluster_manager.get_leader(): + try: + raft_leader = self.charm.cluster_manager.get_leader() logger.info(f"Raft leader: {raft_leader}") + except RaftLeaderNotFoundError as e: + logger.warning(e) def _on_leader_elected(self, event: LeaderElectedEvent) -> None: """Handle all events in the 'cluster' peer relation.""" @@ -111,7 +135,44 @@ def _on_leader_elected(self, event: LeaderElectedEvent) -> None: self.charm.set_status(Status.NO_PEER_RELATION) return + if self.charm.unit.is_leader() and not self.charm.state.cluster.internal_user_credentials: + self.charm.state.cluster.update( + {f"{INTERNAL_USER}-password": self.charm.workload.generate_password()} + ) + def _on_update_status(self, event: ops.UpdateStatusEvent) -> None: """Handle update_status event.""" if not self.charm.workload.alive(): self.charm.set_status(Status.SERVICE_NOT_RUNNING) + + def _on_secret_changed(self, event: ops.SecretChangedEvent) -> None: + """Handle the secret_changed event.""" + if not self.charm.unit.is_leader(): + return + + if admin_secret_id := self.charm.config.get(INTERNAL_USER_PASSWORD_CONFIG): + if admin_secret_id == event.secret.id: + self.update_admin_password(admin_secret_id) + + def update_admin_password(self, admin_secret_id: str) -> None: + """Compare current admin password and update in etcd if required.""" + try: + if new_password := get_secret_from_id(self.charm.model, admin_secret_id).get( + INTERNAL_USER + ): + # only update admin credentials if the password has changed + if new_password != self.charm.state.cluster.internal_user_credentials.get( + INTERNAL_USER + ): + logger.debug(f"{INTERNAL_USER_PASSWORD_CONFIG} have changed.") + try: + self.charm.cluster_manager.update_credentials( + username=INTERNAL_USER, password=new_password + ) + self.charm.state.cluster.update( + {f"{INTERNAL_USER}-password": new_password} + ) + except EtcdUserManagementError as e: + logger.error(e) + except (ModelError, SecretNotFoundError) as e: + logger.error(e) diff --git a/src/literals.py b/src/literals.py index 69cf8d3..270a843 100644 --- a/src/literals.py +++ b/src/literals.py @@ -21,6 +21,10 @@ CLIENT_PORT = 2379 PEER_PORT = 2380 +INTERNAL_USER = "root" +INTERNAL_USER_PASSWORD_CONFIG = "system-users" +SECRETS_APP = ["root-password"] + DebugLevel = Literal["DEBUG", "INFO", "WARNING", "ERROR"] SUBSTRATES = Literal["vm", "k8s"] SUBSTRATE = "vm" @@ -38,6 +42,9 @@ class Status(Enum): """Collection of possible statuses for the charm.""" ACTIVE = StatusLevel(ActiveStatus(), "DEBUG") + AUTHENTICATION_NOT_ENABLED = StatusLevel( + BlockedStatus("failed to enable authentication in etcd"), "ERROR" + ) SERVICE_NOT_INSTALLED = StatusLevel(BlockedStatus("unable to install etcd snap"), "ERROR") SERVICE_NOT_RUNNING = StatusLevel(BlockedStatus("etcd service not running"), "ERROR") NO_PEER_RELATION = StatusLevel(MaintenanceStatus("no peer relation available"), "DEBUG") diff --git a/src/managers/cluster.py b/src/managers/cluster.py index 01dee2f..324d91f 100644 --- a/src/managers/cluster.py +++ b/src/managers/cluster.py @@ -8,22 +8,24 @@ import socket from common.client import EtcdClient +from common.exceptions import ( + EtcdAuthNotEnabledError, + EtcdUserManagementError, + RaftLeaderNotFoundError, +) from core.cluster import ClusterState +from literals import INTERNAL_USER logger = logging.getLogger(__name__) -class RaftLeaderNotFoundError(Exception): - """Custom Exception if there is no current Raft leader.""" - - pass - - class ClusterManager: """Manage cluster members, quorum and authorization.""" def __init__(self, state: ClusterState): self.state = state + self.admin_user = INTERNAL_USER + self.admin_password = self.state.cluster.internal_user_credentials.get(INTERNAL_USER, "") self.cluster_endpoints = [server.client_url for server in self.state.servers] def get_host_mapping(self) -> dict[str, str]: @@ -42,7 +44,9 @@ def get_leader(self) -> str | None: # loop through list of hosts and compare their member id with the leader # if they match, return this host's endpoint for endpoint in self.cluster_endpoints: - client = EtcdClient(client_url=endpoint) + client = EtcdClient( + username=self.admin_user, password=self.admin_password, client_url=endpoint + ) try: endpoint_status = client.get_endpoint_status() member_id = endpoint_status["Status"]["header"]["member_id"] @@ -53,6 +57,31 @@ def get_leader(self) -> str | None: except KeyError: # for now, we don't raise an error if there is no leader # this may change when we have actual relevant tasks performed against the leader - logger.warning("No raft leader found in cluster.") + raise RaftLeaderNotFoundError("No raft leader found in cluster.") return None + + def enable_authentication(self) -> None: + """Enable the etcd admin user and authentication.""" + try: + client = EtcdClient( + username=self.admin_user, + password=self.admin_password, + client_url=self.state.unit_server.client_url, + ) + client.add_user(username=self.admin_user) + client.enable_auth() + except (EtcdAuthNotEnabledError, EtcdUserManagementError): + raise + + def update_credentials(self, username: str, password: str) -> None: + """Update a user's password.""" + try: + client = EtcdClient( + username=self.admin_user, + password=self.admin_password, + client_url=self.state.unit_server.client_url, + ) + client.update_password(username=username, new_password=password) + except EtcdUserManagementError: + raise diff --git a/tests/integration/helpers.py b/tests/integration/helpers.py index 80a5184..a62ee0f 100644 --- a/tests/integration/helpers.py +++ b/tests/integration/helpers.py @@ -6,6 +6,7 @@ import logging import subprocess from pathlib import Path +from typing import Dict import yaml from pytest_operator.plugin import OpsTest @@ -18,17 +19,41 @@ APP_NAME = METADATA["name"] -def put_key(model: str, unit: str, endpoints: str, key: str, value: str) -> str: +def put_key( + model: str, + unit: str, + endpoints: str, + key: str, + value: str, + user: str | None = None, + password: str | None = None, +) -> str: """Write data to etcd using `etcdctl` via `juju ssh`.""" etcd_command = f"{SNAP_NAME}.etcdctl put {key} {value} --endpoints={endpoints}" + if user: + etcd_command = f"{etcd_command} --user={user}" + if password: + etcd_command = f"{etcd_command} --password={password}" juju_command = f"juju ssh --model={model} {unit} {etcd_command}" return subprocess.getoutput(juju_command).split("\n")[0] -def get_key(model: str, unit: str, endpoints: str, key: str) -> str: +def get_key( + model: str, + unit: str, + endpoints: str, + key: str, + user: str | None = None, + password: str | None = None, +) -> str: """Read data from etcd using `etcdctl` via `juju ssh`.""" etcd_command = f"{SNAP_NAME}.etcdctl get {key} --endpoints={endpoints}" + if user: + etcd_command = f"{etcd_command} --user={user}" + if password: + etcd_command = f"{etcd_command} --password={password}" + juju_command = f"juju ssh --model={model} {unit} {etcd_command}" return subprocess.getoutput(juju_command).split("\n")[1] @@ -59,3 +84,19 @@ async def get_juju_leader_unit_name(ops_test: OpsTest, app_name: str = APP_NAME) for unit in ops_test.model.applications[app_name].units: if await unit.is_leader_from_status(): return unit.name + + +async def get_secret_by_label(ops_test: OpsTest, label: str) -> Dict[str, str]: + secrets_raw = await ops_test.juju("list-secrets") + secret_ids = [ + secret_line.split()[0] for secret_line in secrets_raw[1].split("\n")[1:] if secret_line + ] + + for secret_id in secret_ids: + secret_data_raw = await ops_test.juju( + "show-secret", "--format", "json", "--reveal", secret_id + ) + secret_data = json.loads(secret_data_raw[1]) + + if label == secret_data[secret_id].get("label"): + return secret_data[secret_id]["content"]["Data"] diff --git a/tests/integration/test_charm.py b/tests/integration/test_charm.py index 22da2ff..4f5db4c 100644 --- a/tests/integration/test_charm.py +++ b/tests/integration/test_charm.py @@ -7,12 +7,15 @@ import pytest from pytest_operator.plugin import OpsTest +from literals import INTERNAL_USER, INTERNAL_USER_PASSWORD_CONFIG, PEER_RELATION + from .helpers import ( APP_NAME, get_cluster_endpoints, get_cluster_members, get_juju_leader_unit_name, get_key, + get_secret_by_label, put_key, ) @@ -45,7 +48,87 @@ async def test_build_and_deploy(ops_test: OpsTest) -> None: assert len(cluster_members) == NUM_UNITS # make sure data can be written to the cluster + secret = await get_secret_by_label(ops_test, label=f"{PEER_RELATION}.{APP_NAME}.app") + password = secret.get(f"{INTERNAL_USER}-password") + + test_key = "test_key" + test_value = "42" + assert ( + put_key( + model, + leader_unit, + endpoints, + user=INTERNAL_USER, + password=password, + key=test_key, + value=test_value, + ) + == "OK" + ) + assert ( + get_key(model, leader_unit, endpoints, user=INTERNAL_USER, password=password, key=test_key) + == test_value + ) + + +@pytest.mark.runner(["self-hosted", "linux", "X64", "jammy", "large"]) +@pytest.mark.group(1) +@pytest.mark.abort_on_fail +async def test_authentication(ops_test: OpsTest) -> None: + """Assert authentication is enabled by default.""" + model = ops_test.model_full_name + endpoints = get_cluster_endpoints(ops_test, APP_NAME) + leader_unit = await get_juju_leader_unit_name(ops_test, APP_NAME) test_key = "test_key" test_value = "42" - assert put_key(model, leader_unit, endpoints, key=test_key, value=test_value) == "OK" - assert get_key(model, leader_unit, endpoints, key=test_key) == test_value + + # check that reading/writing data without credentials fails + assert get_key(model, leader_unit, endpoints, key=test_key) != test_value + assert put_key(model, leader_unit, endpoints, key=test_key, value=test_value) != "OK" + + +@pytest.mark.runner(["self-hosted", "linux", "X64", "jammy", "large"]) +@pytest.mark.group(1) +@pytest.mark.abort_on_fail +async def test_update_admin_password(ops_test: OpsTest) -> None: + """Assert the admin password is updated when adding a user secret to the config.""" + model = ops_test.model_full_name + endpoints = get_cluster_endpoints(ops_test, APP_NAME) + leader_unit = await get_juju_leader_unit_name(ops_test, APP_NAME) + test_key = "test_key" + test_value = "42" + + # create a user secret and grant it to the application + secret_name = "test_secret" + new_password = "some-password" + + secret_id = await ops_test.model.add_secret( + name=secret_name, data_args=[f"{INTERNAL_USER}={new_password}"] + ) + await ops_test.model.grant_secret(secret_name=secret_name, application=APP_NAME) + + # update the application config to include the secret + await ops_test.model.applications[APP_NAME].set_config( + {INTERNAL_USER_PASSWORD_CONFIG: secret_id} + ) + await ops_test.model.wait_for_idle(apps=[APP_NAME], status="active", timeout=1000) + + # perform read operation with the updated password + assert ( + get_key( + model, leader_unit, endpoints, user=INTERNAL_USER, password=new_password, key=test_key + ) + == test_value + ) + + # update the config again and remove the option `admin-password` + await ops_test.model.applications[APP_NAME].reset_config([INTERNAL_USER_PASSWORD_CONFIG]) + await ops_test.model.wait_for_idle(apps=[APP_NAME], status="active", timeout=1000) + + # make sure we can still read data with the previously set password + assert ( + get_key( + model, leader_unit, endpoints, user=INTERNAL_USER, password=new_password, key=test_key + ) + == test_value + ) diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index 81c1cdc..76be2d6 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -2,13 +2,19 @@ # Copyright 2024 Canonical Ltd. # See LICENSE file for licensing details. +from pathlib import Path +from subprocess import CalledProcessError, CompletedProcess from unittest.mock import patch import ops +import yaml from ops import testing from charm import EtcdOperatorCharm -from literals import CLIENT_PORT, PEER_RELATION +from literals import CLIENT_PORT, INTERNAL_USER, INTERNAL_USER_PASSWORD_CONFIG, PEER_RELATION + +METADATA = yaml.safe_load(Path("./metadata.yaml").read_text()) +APP_NAME = METADATA["name"] def test_install_failure_blocked_status(): @@ -20,6 +26,16 @@ def test_install_failure_blocked_status(): assert state_out.unit_status == ops.BlockedStatus("unable to install etcd snap") +def test_internal_user_creation(): + ctx = testing.Context(EtcdOperatorCharm) + relation = testing.PeerRelation(id=1, endpoint=PEER_RELATION) + + state_in = testing.State(relations={relation}, leader=True) + state_out = ctx.run(ctx.on.leader_elected(), state_in) + secret_out = state_out.get_secret(label=f"{PEER_RELATION}.{APP_NAME}.app") + assert secret_out.latest_content.get(f"{INTERNAL_USER}-password") + + def test_start(): ctx = testing.Context(EtcdOperatorCharm) state_in = testing.State() @@ -29,7 +45,6 @@ def test_start(): patch("workload.EtcdWorkload.alive", return_value=True), patch("workload.EtcdWorkload.write_file"), patch("workload.EtcdWorkload.start"), - patch("managers.cluster.ClusterManager.get_leader"), ): state_out = ctx.run(ctx.on.start(), state_in) assert state_out.unit_status == ops.MaintenanceStatus("no peer relation available") @@ -42,17 +57,39 @@ def test_start(): patch("workload.EtcdWorkload.alive", return_value=True), patch("workload.EtcdWorkload.write_file"), patch("workload.EtcdWorkload.start"), - patch("managers.cluster.ClusterManager.get_leader"), ): state_out = ctx.run(ctx.on.start(), state_in) assert state_out.unit_status == ops.ActiveStatus() + # if authentication cannot be enabled, the charm should be blocked + state_in = testing.State(relations={relation}, leader=True) + with ( + patch("workload.EtcdWorkload.write_file"), + patch("workload.EtcdWorkload.start"), + patch("subprocess.run", side_effect=CalledProcessError(returncode=1, cmd="test")), + ): + state_out = ctx.run(ctx.on.start(), state_in) + assert state_out.unit_status == ops.BlockedStatus( + "failed to enable authentication in etcd" + ) + + # if authentication was enabled, the charm should be active + with ( + patch("workload.EtcdWorkload.alive", return_value=True), + patch("workload.EtcdWorkload.write_file"), + patch("workload.EtcdWorkload.start"), + patch("subprocess.run", return_value=CompletedProcess(returncode=0, args=[], stdout="OK")), + ): + state_out = ctx.run(ctx.on.start(), state_in) + assert state_out.unit_status == ops.ActiveStatus() + assert state_out.get_relation(1).local_app_data.get("authentication") == "enabled" + # if the etcd daemon can't start, the charm should display blocked status with ( patch("workload.EtcdWorkload.alive", return_value=False), patch("workload.EtcdWorkload.write_file"), patch("workload.EtcdWorkload.start"), - patch("managers.cluster.ClusterManager.get_leader"), + patch("subprocess.run"), ): state_out = ctx.run(ctx.on.start(), state_in) assert state_out.unit_status == ops.BlockedStatus("etcd service not running") @@ -101,3 +138,24 @@ def test_get_leader(): with patch("managers.cluster.EtcdClient.get_endpoint_status", return_value=test_data): with ctx(ctx.on.relation_joined(relation=relation), state_in) as context: assert context.charm.cluster_manager.get_leader() == f"http://{test_ip}:{CLIENT_PORT}" + + +def test_config_changed(): + secret_key = "root" + secret_value = "123" + secret_content = {secret_key: secret_value} + secret = ops.testing.Secret(tracked_content=secret_content, remote_grants=APP_NAME) + relation = testing.PeerRelation(id=1, endpoint=PEER_RELATION) + + ctx = testing.Context(EtcdOperatorCharm) + state_in = testing.State( + secrets=[secret], + config={INTERNAL_USER_PASSWORD_CONFIG: secret.id}, + relations={relation}, + leader=True, + ) + + with patch("subprocess.run"): + state_out = ctx.run(ctx.on.config_changed(), state_in) + secret_out = state_out.get_secret(label=f"{PEER_RELATION}.{APP_NAME}.app") + assert secret_out.latest_content.get(f"{INTERNAL_USER}-password") == secret_value